The smallest safe change is not the smallest diff.
It is the smallest set of edits whose failure modes you can already name. In messy repos, half of every refactor goes red on its own. That redness is coupling, not breakage.
So measure coupling before you commit anything. Apply each candidate edit alone. Record which tests fail. Group edits that share a signature.
Why half a refactor is worse than none
Characterization tests tell you current behavior is pinned. They do not tell you which edits share state.
A rename in the producer and the consumer is one intent. Split it across two commits and the first commit is broken. Reviewers then argue about a red intermediate state that was never meant to be green.
Finding this out after the push is expensive. Finding it out in a throwaway worktree costs one test run per patch.
Step 1 — Re-pin behavior on a clean baseline
Start from a green suite. Record the exact commit and the exact command.
mkdir -p .matrix
git rev-parse HEAD > .matrix/baseline.sha
python -m pytest -q --tb=no 2>&1 | tail -n 3
Write the command into a file. Every later run must use the identical invocation.
If you have no characterization tests yet, stop here and write them first. Ten golden-file tests over the module's public entry points beat a large speculative suite.
Step 2 — Cut the refactor into single-intent patches
One intent per patch file. Extract a helper. Rename a field. Move a validation call.
Do not group by file. Group by the sentence you would write in the commit message.
mkdir -p patches
# patches/01-extract-normalize.patch
# patches/02-rename-customer-id.patch
# patches/03-move-validation.patch
git status --short # keep the working tree clean
Each patch must apply to the baseline alone. A patch that needs another patch to apply is already coupled. Note it and move on.
Keep every patch small enough that a reviewer reads it in under a minute. Small patches are the input format this method assumes.
Step 3 — Run every patch alone in a throwaway worktree
Never test patches in your working tree. Use git worktree so each run is isolated and disposable.
The script below is illustrative. It depends only on git, python, and pytest. Adjust the paths, then run it against a scratch clone.
# couple.py
import subprocess, sys, pathlib, xml.etree.ElementTree as ET
REPO = pathlib.Path(".").resolve()
PATCHES = sorted((REPO / "patches").glob("*.patch"))
SCRATCH = REPO / ".matrix"
SCRATCH.mkdir(exist_ok=True)
def sh(args, cwd):
return subprocess.run(args, cwd=cwd, capture_output=True, text=True)
def failing(report):
out = set()
for case in ET.parse(report).iter("testcase"):
bad = case.find("failure") is not None or case.find("error") is not None
if bad:
out.add(f"{case.get('classname')}::{case.get('name')}")
return frozenset(out)
def signature(patch):
wt = SCRATCH / f"wt-{patch.stem}"
sh(["git", "worktree", "add", "--detach", str(wt), "HEAD"], REPO)
try:
applied = sh(["git", "apply", str(patch)], wt)
if applied.returncode != 0:
return "DOES_NOT_APPLY"
report = wt / "report.xml"
sh([sys.executable, "-m", "pytest", "-q", "--tb=no",
f"--junitxml={report}"], wt)
return failing(report) if report.exists() else "NO_REPORT"
finally:
sh(["git", "worktree", "remove", "--force", str(wt)], REPO)
sigs = {p.name: signature(p) for p in PATCHES}
for name, sig in sigs.items():
size = len(sig) if isinstance(sig, frozenset) else sig
print(f"{name:45} {size}")
Then cluster. Compute Jaccard similarity over failure sets. Merge anything at or above 0.5.
def jaccard(a, b):
if not isinstance(a, frozenset) or not isinstance(b, frozenset):
return 0.0
if not a and not b:
return 1.0
return len(a & b) / len(a | b)
names = list(sigs)
parent = {n: n for n in names}
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
for i, a in enumerate(names):
for b in names[i + 1:]:
if jaccard(sigs[a], sigs[b]) >= 0.5:
parent[find(a)] = find(b)
groups = {}
for n in names:
groups.setdefault(find(n), []).append(n)
for members in groups.values():
print("GROUP:", ", ".join(members))
A worked shape, with placeholder counts only:
| patch | failing tests alone | reading |
|---|---|---|
| 01-extract-normalize | 0 | no visible coupling, land first |
| 02-rename-customer-id | 7 | coupled to 03 |
| 03-move-validation | 7, same ids | coupled to 02 |
| 04-drop-dead-branch | 2 | unknown coupling, investigate |
Those numbers are placeholders. Substitute your own run before you act on them.
Step 4 — Read the matrix, not the diff
The signature is a claim about shared state. Trust overlap over intuition.
| signature shape | meaning | action |
|---|---|---|
| empty | no behavioral coupling visible | one commit, land early |
| identical non-empty | same shared state | one commit, both patches |
| partial overlap | direction matters | smaller overlap first |
| disjoint non-empty | two separate breakages | split, investigate each |
| DOES_NOT_APPLY | textual dependency | stack the patches |
An empty signature is not proof of safety. It means your suite cannot see the coupling. Treat it as a lower bound, never a guarantee.
Step 5 — Land one group, then re-pin
Apply a single group. Run the full suite. Commit only when it is green.
Then move the baseline and rerun the matrix. Coupling changes as code moves.
git apply patches/01-extract-normalize.patch
python -m pytest -q --tb=no
git commit -am "refactor: extract normalize"
git rev-parse HEAD > .matrix/baseline.sha
python couple.py
Repeat until the patch directory is empty. Each commit is then a known-safe unit, not a guess.
Where this breaks
The loop costs one suite run per patch. At two minutes per run and thirty patches, that is an hour of waiting. Bound it with -k selection or a per-package target.
False-passing tests produce empty signatures everywhere. A suite that asserts nothing about the moved code will call every patch independent. The matrix inherits every weakness of the pins.
Over-pinned tests produce huge signatures. Golden files that embed timestamps or dict ordering fail on nearly any patch. Fix the pins before reading the matrix.
The method says nothing about behavior you never pinned. It cannot protect an untested branch, and it will not catch semantic drift your tests accept.
Who should skip this
Skip it when a full suite run takes longer than your patience budget. Skip it when no characterization tests exist and none can be written this week. Skip it for a one-line fix where the diff is already obvious.
Use it when the module is large, the tests are slow but real, and reviewers keep asking why the intermediate commit is red.
Tooling note
I drafted candidate single-intent patches with a model, then discarded most of them. Drafting is cheap. Reading the signature matrix is the actual work.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. Per the operator, MonkeyCode offers free model access and a free server option. The free models are useful for generating candidate patches; the free server option can host the repeated worktree runs without occupying your laptop. Neither shortcut replaces the baseline pin. If the free tier fits your loop, start there; if it does not, run couple.py locally and keep the rest of the workflow unchanged.
The gate you want is not a smaller diff. It is a known signature per commit.
Top comments (0)