Coordinating a large refactor without conflict storms Jump to heading

A rename applied across four hundred files is trivial to produce and expensive to land. Every open branch that touches any of those files now conflicts on every touched line, and the conflicts are resolved by people who did not make the change, under time pressure, against work they are trying to finish. The refactor itself is usually fine; the coordination is what goes wrong. This recipe makes the sweep survivable, within conflict prevention by design.

When to use this approach Jump to heading

  • A change will touch more files than anyone can review line by line.
  • The change is mechanical: a rename, a signature change, an import rewrite.
  • Several branches are open against the affected paths.
  • A previous sweep produced a bad week and you would rather not repeat it.
  • If the change touches five files, just make it; this procedure costs more than it saves at that scale.

Step 1 β€” Find out what is actually in flight Jump to heading

Landing a sweep blind is the root cause of most of the pain.

git fetch --prune origin

# Which open branches touch the paths the sweep will rewrite?
git for-each-ref --format='%(refname:short)' refs/remotes/origin | while read -r b; do
  case "$b" in origin/main|origin/HEAD) continue ;; esac
  n=$(git diff --name-only "origin/main...$b" -- src/payments/ 2>/dev/null | wc -l)
  [ "$n" -gt 0 ] && printf '%3s file(s)  %-45s %s\n' "$n" "$b" \
    "$(git log -1 --format='%an, %ar' "$b")"
done | sort -rn
# Verification: the list is the set of people to talk to before landing
git for-each-ref --format='%(refname:short)' refs/remotes/origin | wc -l
The cost of landing a sweep without checkingEach open branch touching the affected paths must resolve conflicts on every line the sweep rewrote. With eight such branches, a change that took an afternoon to produce consumes several days across the team, none of it spent on the work people were doing.hours spent per branch resolving the sweepbranch touching 40 files3.5 hbranch touching 12 files1.5 hbranch touching 3 files0.4 hbranch touching none0 hmultiply the top rows by the number of branches nobody checked for

Step 2 β€” Split the mechanical half from the semantic half Jump to heading

A sweep that also changes behaviour is unreviewable and unrebaseable. Separate them.

# 1. The semantic change, small and reviewable, landed first
git switch -c refactor/introduce-adapter origin/main
# ... add the new interface, keep the old one working ...
gh pr create --title 'refactor: introduce the payment adapter interface'
# 2. The mechanical sweep, after the first has merged
git switch -c refactor/adopt-adapter origin/main
comby 'oldCall(:[args])' 'adapter.call(:[args])' -in-place -matcher .ts src/
# Verification: the sweep commit changes no behaviour
git diff --stat origin/main
npm test 2>&1 | tail -3

Landing the interface first means the sweep is purely a replacement, which makes it reviewable by re-running the tool and comparing β€” and makes a rebase across it mechanical rather than a judgement call.

Step 3 β€” Make the sweep reproducible Jump to heading

A reviewer who can regenerate the diff does not have to read it, and anyone rebasing can re-run the tool instead of resolving conflicts.

# Commit the command that produced the change, in the message
git commit -am 'refactor: adopt the payment adapter across src/

Produced mechanically with:

    comby "oldCall(:[args])" "adapter.call(:[args])" -in-place -matcher .ts src/

No behavioural change. If you are rebasing across this commit, drop your
conflicting hunks and re-run the command above instead of resolving by hand.'
# Verification: re-running the tool on the merged result changes nothing
comby 'oldCall(:[args])' 'adapter.call(:[args])' -in-place -matcher .ts src/
git diff --exit-code && echo "reproducible"
Landing a sweep in three commits instead of oneThe interface lands first and is small enough to review normally. The mechanical sweep follows, reproducible from a recorded command. The removal of the old path lands last, once every open branch has rebased.Interfacesmall, reviewableold path still worksMechanical sweeprecorded commandno behaviour changeBranches rebasere-run the toolRemove old pathonce nothing uses itthe third box is only possible because the second is reproducible

Step 4 β€” Land it in a quiet window, having told people Jump to heading

# Announce with specifics: what, when, which paths, what to do
cat <<'MSG'
Sweep landing Thursday 09:00 UTC: adopting the payment adapter across src/payments.

Affected branches (please merge or park before Thursday):
  feat/PAY-812-refund-window   (Ada, 40 files)
  fix/PAY-901-timeout          (Grace, 12 files)

After it lands, rebase and re-run:
  comby 'oldCall(:[args])' 'adapter.call(:[args])' -in-place -matcher .ts src/
MSG
# Verification: on the day, confirm the list has shrunk
git for-each-ref --format='%(refname:short)' refs/remotes/origin | while read -r b; do
  git diff --name-only "origin/main...$b" -- src/payments/ 2>/dev/null | grep -q . && echo "$b"
done

SAFETY WARNING β€” do not land a sweep and a release in the same window. If the sweep introduces a subtle problem, bisecting through it is painful precisely because every commit in the range touches everything, and separating β€œthe release broke it” from β€œthe sweep broke it” becomes guesswork. Leave a clear gap, and make sure the sweep commit is identifiable by its message.

Step 5 β€” Give everyone a scripted rebase Jump to heading

The people rebasing did not make the change, so hand them the procedure rather than the conflicts.

#!/usr/bin/env sh
# scripts/rebase-across-sweep β€” drop conflicting hunks, re-run the tool.
set -eu
sweep=$(git log --format='%H' --grep='adopt the payment adapter' -1 origin/main)

git fetch origin
git rebase origin/main || true
while [ -d .git/rebase-merge ] || [ -d .git/rebase-apply ]; do
  git checkout --theirs -- src/payments/ 2>/dev/null || true
  comby 'oldCall(:[args])' 'adapter.call(:[args])' -in-place -matcher .ts src/
  git add -A && git rebase --continue || break
done
npm test
# Verification: the rebased branch passes its own tests and contains the sweep
git merge-base --is-ancestor "$sweep" HEAD && echo "sweep included"
npm test 2>&1 | tail -3
Resolving by hand against re-running the toolHand-resolving a mechanical sweep means making the same substitution the tool already made, hundreds of times, with the chance of getting one wrong. Taking the incoming side and re-running the tool produces the correct result every time and takes seconds.Resolve by handRe-run the tooltime per branchhourssecondschance of an errorrealnoneneeds the sweep's contextyesnorequires the tool installednoyesthe only cost of the right-hand column is that everyone needs the tool

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

What if a branch cannot be merged or parked before the sweep? Jump to heading

Then rebase it for its author, or let them go first. A branch that is nearly finished should land before the sweep; one that is weeks from done should be rebased by whoever is landing the sweep, since they understand the change and the author does not yet.

Should the sweep be a squash or several commits? Jump to heading

One commit for the mechanical part, because it is reviewed by reproduction rather than by reading, and a single identifiable commit is what makes the rebase script and the blame-ignore entry possible. Splitting it into fifty commits multiplies the rebase work for everyone.

Does rerere help here? Jump to heading

For a person rebasing several branches across the same sweep, yes β€” the resolutions repeat, and rerere replays them. It does not help the first branch, and it does not substitute for re-running the tool, which is both faster and more reliable. The setup is in automating repeated conflict resolution with rerere.