DEV Community

Dakota Huang
Dakota Huang

Posted on

Two Commits per Refactor: A Diff Gate That Keeps Pins Away From the Move

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"
  ]
}
Enter fullscreen mode Exit fullscreen mode

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())
Enter fullscreen mode Exit fullscreen mode

--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
Enter fullscreen mode Exit fullscreen mode

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 }}"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)