# Pre-merge contamination sweep

Version: 1.0
Updated: 2026-10-02
Method: https://suhailmohebi.com/notes/the-branch-that-predates-the-decision/

Purpose: stop a branch cut before a decision from quietly restoring the facts the decision retired.

Output: a sweep verdict for every branch older than the decision, and a check that fails such merges on its own.

## Use when

A decision retired names, routes, identifiers or claims while branches were open, especially agent branches that will merge without conflicts.

## Quick start

1. Record the decision with its literal search list (section 1).
2. List the branches that predate it (section 2) and sweep each one (sections 3–4).
3. Classify every hit and fix forward (sections 5–6), then make CI enforce the list (section 7).

A conflict is a question; restored stale content is an answer nobody asked for. Sweep the clean, old branch hardest.

## 1. Give the decision a search list

Record the literal retired text, not the reasoning, as the decision is made.

| ID | Decided (UTC) | Decision | Retired strings | Replacement |
|---|---|---|---|---|
| D-07 | 2026-09-15T00:00:00Z | Project renamed; old export API retired | `Atlas`, `/v1/legacy-export` | `Compass`, `/v2/exports` |

Include names and vocabulary, identifiers (endpoints, handles, contact routes), claims and surfaces (routes, files). Keep them in the repository as `retired-strings.txt`, one `ID|string` per line:

```text
D-07|Atlas
D-07|/v1/legacy-export
```

## 2. Sort branches by age against the decision

```bash
# Read-only. Run from the repository root.
MAIN=main
DECISION=2026-09-15T00:00:00Z            # UTC, in exactly this format
export TZ=UTC LC_ALL=C; fmt=format-local:%Y-%m-%dT%H:%M:%SZ
git for-each-ref --format='%(refname:short)' refs/heads refs/remotes | while read -r b; do
  case "$b" in "$MAIN"|origin/"$MAIN"|origin/HEAD|origin) continue ;; esac
  mb=$(git merge-base "$MAIN" "$b") || { echo "SKIP no merge-base: $b"; continue; }
  base=$(git log -1 --date="$fmt" --format=%cd "$mb")
  oldest=$(git log --date="$fmt" --format=%ad "$MAIN..$b" | sort | head -n 1)
  if [[ "$base" < "$DECISION" || "${oldest:-$DECISION}" < "$DECISION" ]]; then v=SWEEP; else v=normal; fi
  printf '%s\tbase %s\toldest own commit %s\t%s\n' "$v" "$base" "${oldest:--}" "$b"
done
```

SWEEP: the merge-base, or any of the branch’s own commits by author date, predates the decision. A rebase moves the base, not the content.

| Branch age | Conflicts | Action |
|---|---|---|
| After the decision | any | normal review |
| Before the decision | yes | sweep, then resolve: conflicts are only the visible part |
| Before the decision | none | sweep hardest: it merges silently |

## 3. Sweep what the branch adds

`git diff main...feature/x` (three dots) compares the branch with its merge-base: only what the branch wrote. Two dots (`main..feature/x`, same as `main feature/x`) compares the tips, so strings `main` removed after the cut look re-added.

```bash
# Read-only: added lines containing a retired string.
git diff main...feature/x | grep '^+' | grep -v '^+++ ' |
  grep -i -F -f <(awk -F'|' '{ sub(/\r$/, "") } NF == 2 && $2 != "" { print $2 }' retired-strings.txt)
```

## 4. Search the whole branch, not only the diff

Section 3 sees only lines the branch wrote. Search the branch tip as a whole:

```bash
# Read-only: retired strings anywhere in the branch tip, then the files it touched.
git grep -n -i -F -f <(awk -F'|' '{ sub(/\r$/, "") } NF == 2 && $2 != "" { print $2 }' retired-strings.txt) \
  feature/x -- ':!retired-strings.txt'
git diff --name-only main...feature/x
```

A touched-file hit returns if a conflict is resolved in the branch’s favour; an untouched-file hit survives a true merge only where `main` still has it, but ships if the branch tree is deployed or copied.

## 5. Classify every hit

| Class | Looks like | Action |
|---|---|---|
| Reintroduced retired fact | the old name, route or claim stated as current, or built from old sources | fix on the branch; rebuild artefacts |
| Legitimate historical mention | a dated changelog entry, migration note or redirect rule | keep it; cite the decision ID on the line |
| False positive | the string with another meaning, such as a sprite sheet called `ui-atlas.png` | cite the ID on the line or narrow the string; never delete it |

## 6. Fix forward

- [ ] Fix hits with new commits on the branch; never rewrite `main`’s history.
- [ ] If a retired fact already reached `main`, remove it in a new commit that cites the decision ID.
- [ ] Add new variants (spacing, old URL forms) to `retired-strings.txt`.
- [ ] Re-run sections 3 and 4 until only lines citing the ID remain, then merge.
- [ ] If the fact reached a public page or feed, remove it there and ask for a recrawl.

## 7. Make the merge fail on its own

```bash
#!/usr/bin/env bash
# retired-sweep.sh RANGE   Read-only. Exit 1 if the diff adds a line containing a retired string.
# RANGE: main...feature/x (before merging), 'HEAD^1..HEAD' (CI on a merge commit), - (diff on stdin)
set -u -o pipefail
[ $# -eq 1 ] || { echo "usage: $0 RANGE" >&2; exit 2; }
terms="${TERMS:-retired-strings.txt}"
{ if [ "$1" = - ]; then cat; else git diff --no-color -U0 "$1"; fi; } | awk -v terms="$terms" '
BEGIN { while ((getline t < terms) > 0) { sub(/\r$/, "", t)
          if (split(t, f, "|") == 2 && f[2] != "") { id[++n] = f[1]; s[n] = tolower(f[2]) } }
        if (!n) { print "STOP: no ID|string lines in " terms; bad = 1; exit } }
/^\+\+\+ / { file = substr($0, 7); sub(/\t$/, "", file); next }
/^\+/ { l = substr($0, 2); for (i = 1; i <= n; i++) if (index(tolower(l), s[i]) && !index(l, id[i])) {
          print "RETIRED " id[i] "  " file ": " l; hits++; break } }
END { if (bad) exit 2
      if (hits) print hits " line(s) add retired strings: use the current terms or cite the decision ID."
      exit (hits > 0) }'
```

- CI on the merge commit is the gate: `TERMS=<(git show HEAD^1:retired-strings.txt) retired-sweep.sh 'HEAD^1..HEAD'` also catches old lines restored while resolving conflicts, and a branch cannot weaken the base’s list.
- A `pre-merge-commit` hook (`git diff --cached HEAD | retired-sweep.sh -`) is only an early warning: it never runs on a merge that stopped for conflicts, fast-forward and squash merges skip it, as does `--no-verify`, and `git commit` can still complete a blocked merge.
- The check matches strings, not meaning. A pass is not approval; a reviewer still reads for the same claim in new words.

## Worked example

D-07 (2026-09-15) renamed the project from “Atlas” to “Compass” and retired `/v1/legacy-export`. Section 2 marks `feature/faq` (base 2026-09-01) and `feature/rebased` (authored 2026-09-12, rebased 2026-09-21) SWEEP, and `feature/migration-guide` (cut 2026-09-20) normal.

`retired-sweep.sh main...feature/faq` (no conflicts) prints:

```text
RETIRED D-07  faq.md: A: Open Atlas settings and choose Export.
RETIRED D-07  faq.md: A: Yes, call GET /v1/legacy-export.
RETIRED D-07  tests/sprites.yml: sheets: [ui-atlas.png, icons.png]
3 line(s) add retired strings: use the current terms or cite the decision ID.
```

The FAQ lines are reintroduced facts: rewrite them with “Compass” and `/v2/exports`. The sprite sheet is a false positive: append `# texture atlas, not the project (D-07)`. The migration guide passes: its one mention cites D-07. The rebased branch adds pre-decision notes saying “Atlas”.

**Verdict:** `feature/faq` and `feature/rebased` are blocked until fixed; after its fix commit `feature/faq` exits 0. `feature/migration-guide` takes normal review.

## Stop if

- No literal search list exists yet: write it first.
- A hit could be historical or current: ask the decision’s owner.
- The only fix would rewrite shared history or force-push: fix forward instead.
- The decision concerns intent or tone that no string captures: route it to a human reviewer.
- Two decisions disagree about the same string: settle them first.

## Sources

- Git: git-diff documentation — https://git-scm.com/docs/git-diff (checked 2026-10-02)
- Git: githooks documentation — https://git-scm.com/docs/githooks (checked 2026-10-02)
