When a test starts failing on the production floor, the first question is always: "What changed?" If your test scripts aren't version controlled, you can't answer that. Git gives you a complete history of every change to every test. Recording the code version on every run lets you link a result back to the exact script that produced it.
This guide covers Git workflow for test projects, recording commit hashes on your runs, and a practical .gitignore for hardware test repos.
Why Version Control Matters for Hardware Tests
Hardware test scripts aren't throwaway code. They're part of your quality system. Regulators, auditors, and your future self need to know:
- Which version of the test script produced a given result
- Who changed a measurement limit, and when
- Whether the test that ran in DVT is the same one running in PVT
Without Git, you get folders named test_v2_final_FINAL_john.py. With Git, you get a clean audit trail.
Git Workflow for Test Scripts
A simple branching strategy works well for test projects. You don't need GitFlow. You need something your team will actually use.
main ──●──●──●──●──●──●── (production-ready tests) \ /feature/add- ──●──●──●──● (new test or change)thermal-testmain is always deployable to production stations. Every commit on main has been reviewed and tested.
Feature branches are where you develop new tests or modify existing ones. Name them descriptively: feature/add-thermal-cycling, fix/dmm-timeout-handling, update/voltage-limits-rev-c.
Basic workflow:
# Start a new test changegit checkout -b feature/add-current-measurement# Make your changes, test locallygit add tests/power_rail_fct.pygit commit -m "Add idle current measurement to power rail FCT"# Push and create a pull request for reviewgit push -u origin feature/add-current-measurement# After review, merge to maingit checkout maingit pullgit merge feature/add-current-measurementgit pushTag releases when you deploy to production stations:
# Tag a release before deploying to productiongit tag -a v1.2.0 -m "Add thermal cycling test, update voltage limits for Rev C"git push origin v1.2.0Read the Git Commit Hash at Runtime
Link every test run to the exact code that produced it. This is the key traceability link between your test results and your test code.
git_version.py39 lines
import subprocessdef get_git_info() -> dict: """Get current Git commit hash and tag.""" info = {} try: info["commit"] = subprocess.check_output( ["git", "rev-parse", "HEAD"], stderr=subprocess.DEVNULL, ).decode().strip() info["commit_short"] = subprocess.check_output( ["git", "rev-parse", "--short", "HEAD"], stderr=subprocess.DEVNULL, ).decode().strip() # Check for uncommitted changes status = subprocess.check_output( ["git", "status", "--porcelain"], stderr=subprocess.DEVNULL, ).decode().strip() info["dirty"] = len(status) > 0 # Get latest tag if available try: info["tag"] = subprocess.check_output( ["git", "describe", "--tags", "--abbrev=0"], stderr=subprocess.DEVNULL, ).decode().strip() except subprocess.CalledProcessError: info["tag"] = None except subprocess.CalledProcessError: info["commit"] = "unknown" info["commit_short"] = "unknown" info["dirty"] = True info["tag"] = None return infoThis relies on the station having Git installed and the script running inside the working tree. If you deploy a packaged executable instead, capture the version at build time and read it from a file, because git rev-parse will fail on the station.
Record the Code Version on Every Run
Record the version as a measurement. Measurements are stored per run and are filterable, so you can compare passing and failing runs by code version.
test_with_version.py41 lines
import openhtf as htffrom openhtf.util import unitsfrom tofupilot.openhtf import uploadfrom git_version import get_git_infogit_info = get_git_info()# Warn if running with uncommitted changes in productionif git_info["dirty"]: print("WARNING: Running tests with uncommitted changes")@htf.measures( htf.Measurement("script_version").doc("Git tag or short commit of the test script"), htf.Measurement("script_dirty").doc("True if the working tree had uncommitted changes"),)def record_script_version(test): test.measurements.script_version = git_info.get("tag") or git_info["commit_short"] test.measurements.script_dirty = git_info["dirty"]@htf.measures( htf.Measurement("voltage_3v3").in_range(3.2, 3.4).with_units(units.VOLT),)def test_power_rail(test): test.measurements.voltage_3v3 = 3.29def main(): test = htf.Test( record_script_version, test_power_rail, procedure_id="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", # procedure UUID from the dashboard part_number="PCBA-100", ) test.add_output_callbacks(upload()) test.execute(lambda: "DUT-001")if __name__ == "__main__": main()Now every run carries the code version that produced it. When a test starts failing after a deployment, filter by script_version and compare the passing runs against the failing ones to isolate the change.
Recording script_dirty alongside it is worth the extra line. A run produced from an uncommitted working tree cannot be reproduced from any commit, so you want that flagged in the data rather than only printed to a console nobody reads.
A note on where this belongs: the fields promoted onto the run itself are a fixed set (procedure_id, part_number, revision, batch_number, sub_units, operated_by). Anything else you put on test.metadata stays on the Python object and is not persisted, which is why the version goes in as a measurement rather than as run metadata.
If you deploy procedures from a Git push, this is already handled for you: each deployment is an immutable artifact tied to a commit, so the run records which deployment produced it without any code on your side.
.gitignore Template for Test Projects
Hardware test repos have specific files you don't want to track. Here's a practical starting point.
.gitignore42 lines
# Python__pycache__/*.py[cod]*.so*.egg-info/dist/build/venv/.venv/# Environment and secrets.env.env.*!.env.example# IDE.vscode/.idea/*.swp*.swo# Test artifacts (logs, reports, captures)logs/test_output/*.log*.csvcaptures/screenshots/# Instrument calibration data (station-specific)cal_data/fixture_cal/# OS files.DS_StoreThumbs.db# PyInstaller (if you package tests)*.spec# Local config overridesconfig/local.yamlA few notes on what's excluded:
.envfiles contain API keys. Never commit these. Include a.env.examplewith placeholder values so new team members know what to configure.logs/andtest_output/are generated per run. They belong with your test results, not in Git.cal_data/is station-specific calibration data. It doesn't belong in the test script repo.config/local.yamlis for per-developer overrides. The shared configs (config/dev.yaml,config/production.yaml) stay tracked.
One caveat on the *.csv line: it is there to catch generated test output, but it will also silently ignore a CSV you intended to track, such as a limits table or a calibration reference. If you keep data files in the repo, narrow that pattern to the directories that actually hold output.
