Most messy-repo refactors fail at review, not at runtime. The riskiest commit is the one that edits tests and production code together. Once that happens, nobody can tell a broken pin from a broken move.
Characterization tests answer one question: does behavior match today? That answer is only trustworthy if the pins stand alone. Mixed diffs destroy that independence. Enforce the split with a script, not with discipline.
Step 1: Declare which paths are pins and which are moves
You need a machine-readable map of your repo. Ambiguity is where the gate dies. Write a small config at the repo root.
{
"test_globs": ["tests/**", "**/test_*.py", "**/*_test.py"],
"code_globs": ["src/**", "lib/**", "app/**"],
"neutral_globs": [
"docs/**",
"*.md",
"requirements*.txt",
"pyproject.toml",
"split-gate.json"
]
}
Three buckets, no fourth. Anything unmatched is an error, not a silent pass. That asymmetry is the whole point.
One detail: Python's fnmatch lets * cross directory separators. So tests/** behaves like "everything under tests". Convenient here, but it differs from gitignore semantics. Do not assume your teammates read the globs the same way you do.
Step 2: Classify the diff per commit
The gate below is a proposed pattern, not a packaged tool. It is short enough to audit line by line.
#!/usr/bin/env python3
"""split_gate.py: reject commits that mix test pins with production moves.
Usage: python split_gate.py [REV_RANGE] (default: HEAD~1..HEAD)
Exit: 0 ok, 1 mixed commit, 2 unclassified path, 3 git error
"""
import fnmatch
import json
import subprocess
import sys
def load_config(path="split-gate.json"):
with open(path, encoding="utf-8") as fh:
cfg = json.load(fh)
for key in ("test_globs", "code_globs", "neutral_globs"):
if key not in cfg:
raise SystemExit(f"config error: missing {key}")
return cfg
def changed_files(rev_range):
proc = subprocess.run(
["git", "diff", "--name-only", "--no-renames", rev_range],
capture_output=True, text=True,
)
if proc.returncode != 0:
raise SystemExit(f"git error: {proc.stderr.strip()}")
return [line.strip() for line in proc.stdout.splitlines() if line.strip()]
def classify(paths, cfg):
buckets = {"test": [], "code": [], "neutral": [], "unknown": []}
for path in paths:
if any(fnmatch.fnmatch(path, g) for g in cfg["test_globs"]):
buckets["test"].append(path)
elif any(fnmatch.fnmatch(path, g) for g in cfg["code_globs"]):
buckets["code"].append(path)
elif any(fnmatch.fnmatch(path, g) for g in cfg["neutral_globs"]):
buckets["neutral"].append(path)
else:
buckets["unknown"].append(path)
return buckets
def main():
rev_range = sys.argv[1] if len(sys.argv) > 1 else "HEAD~1..HEAD"
buckets = classify(changed_files(rev_range), load_config())
if buckets["unknown"]:
print("unclassified paths; extend split-gate.json:")
for path in buckets["unknown"]:
print(f" {path}")
return 2
if buckets["test"] and buckets["code"]:
print("mixed commit: tests and production code changed together")
print(" tests:", *buckets["test"])
print(" code: ", *buckets["code"])
return 1
if not buckets["test"] and not buckets["code"]:
print("neutral-only commit; nothing to gate")
return 0
kind = "pins" if buckets["test"] else "move"
total = len(buckets["test"]) + len(buckets["code"])
print(f"ok: {kind} commit touching {total} file(s)")
return 0
if __name__ == "__main__":
sys.exit(main())
--no-renames matters. Without it, a rename can surface as a delete plus an add across two buckets. You want the pre-refactor path visible in the move commit, not hidden.
Step 3: Run the gate for every commit in a branch
A single HEAD~1..HEAD check only inspects the last commit. Loop over the branch instead.
#!/usr/bin/env bash
set -euo pipefail
base="${1:-origin/main}"
git rev-list --reverse "$base..HEAD" | while read -r sha; do
echo "== gating $sha"
python split_gate.py "$sha^..$sha"
done
Wire that into CI on pull requests. Use fetch-depth: 0 so the base ref is present.
name: split-gate
on: [pull_request]
jobs:
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- run: ./scripts/split-gate.sh "origin/${{ github.base_ref }}"
A local pre-commit hook is optional. It is also bypassable with --no-verify. Treat CI as the authority.
Step 4: Write the pins commit alone, then prove it
Commit A contains only tests. No seams, no fixes, no drive-by cleanups. Then run the revert drill.
git switch -c refactor/invoice-pins
git add tests/
git commit -m "pins: characterize Invoice.total rounding"
# commit B, later: the move
git commit -am "move: extract rounding helper into money.py"
# drill 1: pins fail against a deliberate break
python -m pytest -q # after hand-editing the rounding line
git checkout -- src/
# drill 2: revert the move only; pins must still pass
git revert --no-edit HEAD
python -m pytest -q
git revert --no-edit HEAD
Drill 2 is the one people skip. If your pins still pass with the refactor reverted, they describe behavior, not implementation. If they collapse, you pinned the wrong thing.
Step 5: Cap the move commit
Use git diff --stat "$sha^..$sha" and compare against a number you declare in the commit message. One function, or one module, not both. If the diff exceeds the cap, split again.
A cap is not bureaucracy. It is the only thing that keeps a "smallest safe change" actually small when the file is 900 lines long.
Where a free-model workflow fits
Drafting pins is the slow part. A free model can propose candidate characterization tests from existing call sites and docstrings, and that draft is a starting point, never an answer. Each proposed assertion still needs a human check against real behavior.
A free server option is useful for the boring half: running the gate loop and the full pin suite against a scratch clone, repeatedly, without burning your laptop. Both free model access and a free server option are operator-supplied availability claims. Check the current terms on the product's own site before you plan around them; nothing here describes quotas, model names, or hardware.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Decision table for awkward diffs
| Situation | Action |
|---|---|
Pin needs a seam (e.g. main() is not importable) |
Put the minimal seam in commit A with the pins; describe it in the message |
| Required behavior change appears mid-refactor | Stop. That is a feature series, not a refactor |
| Generated file changes | Add it to neutral_globs, once, with a comment |
| Rename plus edit in one commit | Allowed; --no-renames keeps the old path visible |
| Config file changed alongside tests | Neutral bucket, so the gate stays quiet |
| Team squash-merges everything | The split is erased at merge; use merge commits or --no-ff
|
Limitations and who should skip this
This gate checks commit shape, not test quality. A commit full of vacuous assertions passes cleanly. It also adds friction to genuine interface changes, where test and code must move together. For those, expect to argue with the gate and document the exception.
Skip it if your repo squashes on merge, has no runnable test suite, or if you are doing a one-off three-line fix. The overhead is real, and it only pays back on multi-commit work in code nobody fully understands.
Start with the config file. That is where the argument actually happens.
Top comments (0)