Core EngineeringJuly 16, 2026

Building A Git-Aware “Hot Reload” Plugin For Local System Integration Tests

M

Written by

Maximus Arc

The problem I kept running into

I was building a small system made of multiple services (one API, one worker, one “data fixer” job). The goal was simple: run end-to-end integration tests locally, with minimal friction.

The frustrating part was state drift. Every time I changed code, tests either:

  1. reused stale artifacts (old build outputs, old container images, old configs), or
  2. needed a manual “clean rebuild” step that killed my feedback loop.

So I set out to design a very specific solution: a Git-aware hot reload orchestrator for local integration tests that automatically rebuilds only when the relevant parts changed—and does so by inspecting the current Git diff.

The design: a tiny orchestrator with three contracts

I ended up with a pipeline split into three clear steps:

  1. Detect what changed using git diff --name-only.
  2. Map file changes to build/test targets using a small rules table.
  3. Run tasks in the right order and write “done markers” so we don’t rebuild unnecessarily.

Key design choices

  • No magic: change detection is explicit and based on file paths.
  • Deterministic: task selection is purely a function of the diff.
  • Fast feedback: we avoid running everything on every change.
  • Local-only: this is meant for developer machines (so we don’t need heavy distributed coordination).

What counts as “relevant change”?

To keep this niche and practical, I hard-coded rules that reflect the structure of a typical repo:

  • api/** → rebuild API and rerun API-related tests
  • worker/** → rebuild worker and rerun worker-related tests
  • shared/** → rebuild both + rerun all integration tests
  • migrations/** → run migrations before tests
  • docker/** or Dockerfile* → rebuild everything because runtime environment changes

This mapping is the heart of the system design. It’s “system architecture” in miniature: a policy layer on top of execution.

Step-by-step implementation (working code)

Below is a working Python script that:

  • figures out the files changed since a base commit,
  • determines which tasks to run,
  • runs them,
  • and caches outcomes using a marker file in .cache/.

1) git_diff_hot_reload.py

#!/usr/bin/env python3 import hashlib import json import os import subprocess import sys from dataclasses import dataclass from pathlib import Path from typing import Dict, List, Set REPO_ROOT = Path(__file__).resolve().parent CACHE_DIR = REPO_ROOT / ".cache" CACHE_DIR.mkdir(exist_ok=True) MARKER_DIR = CACHE_DIR / "markers" MARKER_DIR.mkdir(exist_ok=True) @dataclass(frozen=True) class Rule: prefixes: List[str] targets: List[str] # symbolic task names RULES: List[Rule] = [ Rule(prefixes=["api/"], targets=["build_api", "test_integration_api"]), Rule(prefixes=["worker/"], targets=["build_worker", "test_integration_worker"]), Rule(prefixes=["shared/"], targets=["build_api", "build_worker", "test_integration_all"]), Rule(prefixes=["migrations/"], targets=["run_migrations", "test_integration_all"]), Rule(prefixes=["docker/", "Dockerfile", "Dockerfile.", ".github/workflows/"], targets=["build_all", "test_integration_all"]), # fallback safety: if anything unknown changes, run everything Rule(prefixes=["."], targets=["build_all", "test_integration_all"]), ] TASKS: Dict[str, List[str]] = { # In a real project these would be actual commands; for a working demo # they are shell-friendly placeholders. "build_api": ["bash", "-lc", "echo '[build_api] compiling api...' && sleep 0.2"], "build_worker": ["bash", "-lc", "echo '[build_worker] compiling worker...' && sleep 0.2"], "build_all": ["bash", "-lc", "echo '[build_all] building full system...' && sleep 0.3"], "run_migrations": ["bash", "-lc", "echo '[run_migrations] applying migrations...' && sleep 0.2"], "test_integration_api": ["bash", "-lc", "echo '[tests] running API integration tests...' && sleep 0.2"], "test_integration_worker": ["bash", "-lc", "echo '[tests] running worker integration tests...' && sleep 0.2"], "test_integration_all": ["bash", "-lc", "echo '[tests] running full integration suite...' && sleep 0.25"], } def run(cmd: List[str], cwd: Path = REPO_ROOT) -> str: proc = subprocess.run(cmd, cwd=str(cwd), text=True, capture_output=True) if proc.returncode != 0: raise RuntimeError(f"Command failed: {cmd}\nSTDOUT:\n{proc.stdout}\nSTDERR:\n{proc.stderr}") return proc.stdout.strip() def get_changed_files(base_ref: str, head_ref: str) -> List[str]: # name-only lists paths changed between the two refs out = run(["git", "diff", "--name-only", f"{base_ref}..{head_ref}"]) files = [line.strip() for line in out.splitlines() if line.strip()] return files def decide_targets(changed_files: List[str]) -> Set[str]: selected: Set[str] = set() # Track whether we matched any specific prefix. matched_specific = False for f in changed_files: # Special: ignore deleted/renamed path ambiguity for simplicity in this local tool. for rule in RULES: # If a rule prefix is ".", it acts as a fallback. if rule.prefixes == ["."]: continue if any(f.startswith(pfx) for pfx in rule.prefixes): matched_specific = True selected.update(rule.targets) # Break after first match to keep behavior predictable. break if not matched_specific: # If we changed files but they didn't match known prefixes, be safe. selected.update(["build_all", "test_integration_all"]) # If there's a fallback rule, keep it as the safety net. if not selected: selected.update(["build_all", "test_integration_all"]) return selected def cache_key_for(changed_files: List[str], targets: Set[str]) -> str: payload = { "changed_files": sorted(changed_files), "targets": sorted(targets), } raw = json.dumps(payload, sort_keys=True).encode("utf-8") return hashlib.sha256(raw).hexdigest() def marker_path(task: str, key: str) -> Path: safe_task = task.replace("/", "_") return MARKER_DIR / f"{safe_task}.{key}.done" def run_tasks_if_needed(changed_files: List[str], targets: Set[str], key: str) -> None: for task in sorted(targets): marker = marker_path(task, key) if marker.exists(): print(f"[skip] {task} (cached)") continue cmd = TASKS.get(task) if not cmd: raise KeyError(f"No command configured for task '{task}'") print(f"[run] {task}") run(cmd) marker.write_text("done\n", encoding="utf-8") def main(argv: List[str]) -> int: base_ref = "origin/main" head_ref = "HEAD" # Small CLI: allow overriding base ref for local workflows if len(argv) >= 2: base_ref = argv[1] if len(argv) >= 3: head_ref = argv[2] changed_files = get_changed_files(base_ref, head_ref) print(f"[info] changed files between {base_ref}..{head_ref}:") for f in changed_files: print(f" - {f}") targets = decide_targets(changed_files) print(f"[info] selected targets: {sorted(targets)}") key = cache_key_for(changed_files, targets) run_tasks_if_needed(changed_files, targets, key) print("[done] hot reload orchestration finished successfully.") return 0 if __name__ == "__main__": raise SystemExit(main(sys.argv))

2) How it works (and what happens on a run)

When you run:

python3 git_diff_hot_reload.py

the script:

  1. Calls Git to list changed files:
    • git diff --name-only origin/main..HEAD
  2. Assigns tasks using path prefixes.
  3. Computes a cache key from:
    • the file list
    • the selected task set
  4. For each selected task:
    • checks for a marker file like:
      • .cache/markers/build_api.<hash>.done
    • if it exists, it skips the task
    • otherwise it runs the configured shell command and writes the marker

That means: re-running the script without new relevant changes becomes instant.

Making it feel like “hot reload” (tight feedback loop)

To use this as a workflow, I wired it into a pre-test step.

A common pattern is:

  • rebuild/test only what changed,
  • then run a stable test runner.

Here’s an example “integration test runner” that uses the orchestrator output implicitly (via markers) and then starts tests that rely on the rebuilt services.

#!/usr/bin/env python3 import subprocess import sys def main() -> int: # First run the orchestrator to ensure the right components are up-to-date. subprocess.check_call(["python3", "git_diff_hot_reload.py", "origin/main", "HEAD"]) # Then run a single stable command. subprocess.check_call(["bash", "-lc", "echo '[tests] running docker-compose integration checks...' && sleep 0.2"]) return 0 if __name__ == "__main__": raise SystemExit(main())

In my local setup, the “rebuild” commands were hooked into whatever tooling I used (Make, docker build, mvn/gradle, etc.). The important part wasn’t the build tool—it was the diff-driven selection.

System design notes I wish I had known earlier

1) Path-based rules are an architecture, not a hack

The rules table is where the system’s “domain knowledge” lives. It’s a policy layer:

  • detector (diff)
  • classifier (paths → targets)
  • executor (targets → commands)

I treated that like a first-class design artifact, and everything got easier to reason about.

2) Cache markers should be keyed to intent, not time

A common mistake is caching “last build” by timestamp. My version keys to the changed inputs. So if two unrelated diffs happen to arrive close together, the cache won’t lie.

3) Safety beats cleverness when rules don’t match

If a change doesn’t match known prefixes, I default to:

  • build_all
  • test_integration_all

That’s less efficient, but it prevents a subtle failure mode I hit once: tests passing against an environment that was missing a required migration or rebuild.

Conclusion

I built a Git-aware hot reload orchestrator for local system integration tests by designing a small three-part pipeline: detect changed files with git diff, map those paths to explicit build/test targets via rules, and execute tasks with deterministic caching markers. The result was a much tighter feedback loop and a system that avoids stale artifacts without the “always rebuild everything” tax.