Test Station Setup

Version Control Hardware Tests with Git

Learn how to version control hardware test scripts with Git and link test results to code versions in TofuPilot.

JJulien Buteau
beginner7 min readMarch 14, 2026

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.

git_workflow.txt
main          ──●──●──●──●──●──●──  (production-ready tests)                    \        /feature/add-  ──●──●──●──●          (new test or change)thermal-test

main 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:

workflow_commands.sh
# 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 push

Tag releases when you deploy to production stations:

tagging.sh
# 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.0

Read 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.py
39 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 info

This 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.py
41 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.

.gitignore
42 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.yaml

A few notes on what's excluded:

  • .env files contain API keys. Never commit these. Include a .env.example with placeholder values so new team members know what to configure.
  • logs/ and test_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.yaml is 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.

More Guides

Put this guide into practice