Most failed refactors break review, not behavior. The smallest safe change is usually two commits, not one. Commit one adds a seam and moves no code. Commit two moves the logic behind that seam.
Golden-master tests tell you what a function returns. They do not tell you who calls it, or with which arguments. A single extract commit skips that second question. Reviewers then approve a diff they cannot fully verify.
Why One Extract Commit Fails
Three reasons, all visible at review time:
- The diff mixes moving, renaming, and signature changes in one hunk.
- Call sites change in the same commit as the body you claim is unchanged.
- When the suite fails, you cannot tell which half caused it.
Splitting the commit isolates the question. If commit two fails, the seam is innocent. If commit one fails, you never had a seam.
Step 1: Count Fan-In Before You Touch Anything
Run a grep, not a memory. Pick the function you want to extract and count real callers.
git grep -n 'parse_report(' -- '*.py' \ | grep -v '^tests/' \ | grep -v 'def parse_report' \ | tee /tmp/fan_in.txt \ | wc -l Fan-in of one is a different problem than fan-in of nine. With fan-in one, extract inside the caller. With fan-in above one, you need a seam before any move. Zero hits is not proof of zero callers. Dynamic dispatch and string imports hide call sites. Add a log line first and ship it for a day.
Step 2: Record the Call Boundary, Not Just the Output
Output pinning and boundary pinning answer different questions. Output pinning asks "same bytes?". Boundary pinning asks "same arguments, same order, same count?".
Save this as pinseam.py. It is a standalone harness you can adapt to your module.
'''pinseam.py - record and diff call boundaries around one function.''' import json from contextlib import contextmanager def _norm(value, depth=0): if depth > 3: return '<deep>' if value is None or isinstance(value, (str, int, float, bool)): return value if isinstance(value, dict): items = sorted(value.items(), key=lambda kv: str(kv[0])) return {str(k): _norm(v, depth + 1) for k, v in items} if isinstance(value, (list, tuple)): return [_norm(v, depth + 1) for v in value] return f'<{type(value).__name__}>' @contextmanager def seam(module, name, log): original = getattr(module, name) def wrapper(*args, **kwargs): log.append({ 'fn': name, 'args': _norm(args), 'kwargs': _norm(kwargs), }) return original(*args, **kwargs) setattr(module, name, wrapper) try: yield log finally: setattr(module, name, original) def capture(module, name, call): log = [] with seam(module, name, log): call() return log def diff(before, after): problems = [] if len(before) != len(after): problems.append(f'call count {len(before)} -> {len(after)}') for i, (b, a) in enumerate(zip(before, after)): if b != a: problems.append(f'call {i}: {b} -> {a}') return problems Usage is two lines of setup and one assertion.
from pinseam import capture, diff import src.report.parse as target_mod from src.report.job import run_job before = capture(target_mod, 'parse_report', lambda: run_job('/tmp/fixture.csv')) # ... apply the refactor ... after = capture(target_mod, 'parse_report', lambda: run_job('/tmp/fixture.csv')) assert diff(before, after) == [], diff(before, after) One hard requirement: call sites must look up the attribute at call time. Direct from x import parse_report at module top defeats the patch. In that case, patch the calling module instead, or patch both. Write that down before you start, not after the first false pass.
Step 3: Enforce the Split With a Script
The protocol has three commits: base, seam, move. The script below proves the invariants without trusting anyone's memory.
#!/usr/bin/env bash set -euo pipefail BASE=$1; SEAM=$2; MOVE=$3 PINNED=tests/pinned TARGET=src/report/parse.py run_suite() { git checkout -q "$1"; python -m pytest -q "$PINNED"; } run_suite "$BASE"; run_suite "$SEAM"; run_suite "$MOVE" # 1. Pinned tests must not change during the refactor. git diff --quiet "$BASE" "$MOVE" -- "$PINNED" \ || { echo 'pinned tests changed mid-refactor'; exit 1; } # 2. The seam commit must be small. git diff --shortstat "$BASE" "$SEAM" # 3. The seam commit must not delete lines from the target module. if git diff "$BASE" "$SEAM" -- "$TARGET" | grep -q '^-[^-]'; then echo 'seam commit removed lines: that is a move, not a seam'; exit 1 fi echo 'two-commit protocol OK' Rule three is the useful one. A seam adds indirection. A move deletes bodies. If your seam commit deletes lines, you merged the two commits again.
Step 4: Pick the Smallest Action From the Signals
Not every extract is the right smallest change. Use the signal, not the preference.
| Signal from step 1 | Smallest safe action | Why |
|---|---|---|
| Fan-in of 1, duplicated block | Extract a private helper in the same module | No public surface changes |
| Fan-in above 1, no behavior change needed | Add a keyword parameter with the old default | Call sites stay untouched |
| Behavior differs at one call site only | Wrap an adapter at that site | One file changes, not the callee |
| Body mixes I/O and pure calculation | Split pure core out, leave I/O in place | Testable core, no transport change |
| Grep finds 0 callers but the code is dynamic | Ship logging first, extract later | You cannot pin what you cannot see |
| Suite is flaky or slow | Do nothing yet | Refactor on a stable baseline only |
The last row is the honest one. A flaky suite turns a two-commit protocol into noise.
Step 5: Draft the Mechanical Parts With Free Tooling
Two parts of this workflow are mechanical. Drafting the recorder, and proving the pinned suite passes somewhere other than your laptop.
I used MonkeyCode's free model access to draft the first version of pinseam.py and to generate three candidate seam patches. I ran the pinned suite on its free server option as a second environment. A pass tied to one local Python build is weak evidence.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Treat any free tier as best effort. Do not assume a fixed quota, fixed hardware, or permanence, because none is claimed here. Keep the suite runnable locally and offline. Strip fixtures of secrets before you send them anywhere.
Limitations and Who Should Skip This
- The recorder pins shape, not identity. Custom objects appear as
<Report>. - It pins argument order and count, not concurrency or timing.
- High-traffic functions produce huge logs. Sample or filter by argument type.
- Late binding is required. Modules that import names directly need per-module patching.
- Teams with a flaky suite should fix flakiness first. The script will not save you.
- Repos that forbid code leaving the network should skip the remote run entirely.
- This is a refactor protocol. If behavior must change, you are doing a feature, not a refactor.
What to Check Before You Call It Done
Run seam.sh on the smallest module you own. Confirm four things: three green suite runs, an unchanged pinned directory, a one-line shortstat on the seam commit, and zero deleted lines in that commit. If any check fails, reset and split again.
If you try this on a real repo, start with fan-in of one. Send me the check that failed first; that is usually where the interesting work is.


