One Revertible Slice at a Time: A Diff Budget for Messy Repos
A refactor fails when one patch does five things. You cannot revert it cleanly. You cannot bisect it. You cannot review it.
So the rule here is blunt. Every commit in the refactor must stand alone, with the suite green before and after. This post gives you the budget, the gate script, and the history check that enforces it.
Step 1: Freeze the baseline before you edit anything
A budget is meaningless without a fixed starting point. Record the commit and the exact command that defines "green".
mkdir -p .refactor git rev-parse HEAD > .refactor/baseline.sha printf '%s\n' "python -m pytest -q tests/characterization" > .refactor/suite.cmd git add .refactor && git commit -m "chore: freeze refactor baseline" Two small files, one commit. Nothing in src/ moves yet.
Then run the suite three times on that baseline. All three runs must agree. If they disagree, you have a flake problem, not a refactor problem. Fix the flake first.
Step 2: Put a number on "small change"
Reviewers cannot argue with a number. Here is the budget I use for messy repos:
| Metric | Budget | Reason |
|---|---|---|
| Files touched | ≤ 3 | Review fits in one sitting |
| Changed lines | ≤ 120 | Fits one honest revert |
| Public symbols changed | ≤ 1 | Keeps the API surface still |
| New dependencies | 0 | No install drift mid-refactor |
| Deleted or skipped tests | 0 | Protects the safety net |
Pick numbers you can defend for your codebase. Then enforce them mechanically, not by willpower.
Step 3: Gate each commit with a script
Subjective judgements drift over a long refactor. A script does not. Save this as tools/refactor_gate.py; it uses only the standard library.
#!/usr/bin/env python3 """Fail a commit whose diff exceeds the refactor budget.""" import re import subprocess import sys BUDGET = {"files": 3, "lines": 120, "public_symbols": 1} SYMBOL = re.compile(r"^[+-]\s*(?:async\s+)?(?:def|class)\s+([A-Za-z]\w*)") def git(*args: str) -> str: return subprocess.check_output(["git", *args], text=True) def main(base: str = "HEAD~1", head: str = "HEAD") -> int: rows = [r for r in git("diff", "--numstat", base, head).splitlines() if r.strip()] files = len(rows) lines = 0 for row in rows: added, deleted, _path = row.split("\t") if added.isdigit() and deleted.isdigit(): lines += int(added) + int(deleted) symbols = set() for row in git("diff", "-U0", base, head).splitlines(): match = SYMBOL.match(row) if match and not match.group(1).startswith("_"): symbols.add(match.group(1)) report = {"files": files, "lines": lines, "public_symbols": len(symbols)} print(report, sorted(symbols)) over = [key for key in BUDGET if report[key] > BUDGET[key]] if over: print("FAIL:", ", ".join(over)) return 1 print("PASS: slice within budget") return 0 if __name__ == "__main__": sys.exit(main(*sys.argv[1:])) Run it on the commit you just made, not on the working tree. The working tree lies; history does not.
python tools/refactor_gate.py HEAD~1 HEAD A FAIL here is information, not a scolding. It usually means two intentions landed in one commit.
Step 4: Walk the history green, commit by commit
Budget compliance says nothing about build state. You also need every intermediate commit to pass alone. This is what makes git revert and git bisect actually usable later.
#!/usr/bin/env bash # tools/green_walk.sh <base-sha> [branch] set -uo pipefail BASE=${1:?usage: green_walk.sh <base-sha> [branch]} BRANCH=${2:-HEAD} SUITE=$(cat .refactor/suite.cmd) git rev-list --reverse "$BASE..$BRANCH" | while read -r sha; do git switch --detach "$sha" --quiet || exit 2 if eval "$SUITE" > /tmp/green_walk.log 2>&1; then printf 'ok %s\n' "${sha:0:8}" else printf 'FAIL %s\n' "${sha:0:8}" tail -n 20 /tmp/green_walk.log exit 1 fi done git switch --quiet "$BRANCH" Commit or stash before running it. The detached checkouts need a clean tree.
On a five-commit branch this takes a few minutes. On a forty-commit branch it does not. Sample instead: check the first commit, the last commit, and every fifth one.
Step 5: Split the slice when it blows the budget
The gate says "over budget". The next question is how to cut. Use the shape of the failure.
| Gate result | Likely cause | Split along |
|---|---|---|
| 4+ files | One mechanical rename spread wide | Rename first, behavior second |
| Lines over, 1 file | Function does several jobs | Extract one pure function |
| 2+ public symbols | API drift sneaking in | Keep one, add a private helper |
| Files and symbols over | Move plus rewrite together | Move verbatim, rewrite after |
| Green only on your machine | Hidden environment state | Remove the dependency first |
The ordering matters. A verbatim move is trivially reviewable. Rename-then-behavior keeps two diffs independent. Behavior-then-move produces a diff nobody can read.
Step 6: Run one slice on a machine that never saw your edits
Many "clean" refactors break on a box without your shell history. Your local pass can be a lie caused by stale caches, env files, or a globally installed package.
So verify one representative slice in a fresh environment. This is where MonkeyCode's free server option is genuinely useful to me: a starting point that has no relation to my laptop state. If the frozen suite passes there, the slice is probably portable.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
The free models there also help on one specific task. When you inherit a module you do not understand, ask a model for candidate characterization tests, then review each one by hand before pinning it. A model can guess the intended behavior; it cannot decide what the code currently does. Keep only the assertions you can trace to real inputs and outputs.
Failure modes worth naming
- Green locally, red on the clean box. Something outside git matters. Find it before slicing further.
- Budget exceeded, suite green. The tests are too coarse. The change is doing two jobs.
- Suite needs three runs to agree. You have a flake. Stop slicing; stabilize first.
- Nobody can name the revert. The commit message is vague, or the slice is too wide.
- Gate passes on every commit, but the branch is unmergeable. The slices are individually safe and collectively wrong. Re-read the whole diff once at the end.
Who should not use this
Skip this workflow if you have no runnable test command. A budget without a green check is theater.
Skip it too when a single file is thousands of lines and every edit touches all of it. You need a file-level extraction first, and that extraction is the refactor.
And skip the green walk if your CI takes longer than the review itself. Sample the history instead; perfect verification is not free.
The point is not ceremony. The point is that a bad slice costs minutes to revert. A bad refactor costs a weekend.
If you want to try step 6 without provisioning anything, MonkeyCode's free server option is a low-effort way to get that second, untainted box.