Rebase Without Regret: The Answer Key Pattern for Massive Branch Divergence
The 3 AM Rebase That Broke the Release
It was two days before a release deadline. The feature branch had been in flight for three months — long enough for the main branch to accumulate over 1,500 commits from five other teams. The lead engineer started a rebase on Thursday afternoon, expecting a few hours of conflict resolution.
By Friday morning, they were on commit 340 of the rebase, had resolved the same rename conflict eleven times, and the codebase no longer compiled. Worse, they couldn't tell whether a test failure was from a bad merge resolution or a legitimate bug in the feature work. The git reflog was a graveyard of abandoned attempts.
The team panicked. They abandoned the rebase, ran a git merge into main — a single merge commit, not a squashed PR — and shipped. Six weeks later, a git bisect to track down a regression was useless — the merge commit was a 4,000-line diff with no meaningful intermediate states. The bug took three days to find manually. The post-mortem had one action item: "figure out a better way to handle long-lived branches."
This article is about that better way. It's a pattern I call the Answer Key Rebase — a two-pass approach that lets you resolve every conflict exactly once, preserve your commit metadata and linear series, and verify correctness with a single command. It's not clever for the sake of cleverness. It's clever because it turns a high-risk, multi-hour ordeal into a mechanical, verifiable process.
TL;DR
- A naive rebase across hundreds of commits forces you to resolve the same conflicts repeatedly — once per commit that touches the same files.
- The Answer Key Rebase splits the work into two passes: first solve all conflicts in a single squash-merge, then replay history using that solved state as a reference.
- Pass 1 (the "answer key") is a throwaway squash-merge commit where you resolve conflicts once, verify correctness, and tag it.
- Pass 2 is the real rebase, but every conflict is resolved mechanically by checking out the known-good file from your answer key tag.
- A final
git diffagainst the answer key proves the rebase is correct — zero ambiguity. Agit range-diffprovides a secondary structural check. - This preserves commit metadata and a linear series — useful for
git blame, review context, audit trails, and improved bisectability compared to a mega-merge. But it does not guarantee every intermediate commit will build unless the branch already maintained that property. - The verification step is non-negotiable. Without it, you're trusting that hundreds of mechanical resolutions were all correct.
- The pattern works best when your branch has diverged significantly (200+ commits) and a merge commit is unacceptable.
- It costs one extra pass and a temporary tag. That's it.
- If you don't need linear history, a regular merge is simpler and perfectly fine. Don't use this pattern out of vanity.
Definitions
Rebase: The process of replaying a series of commits on top of a new base commit. Produces linear history but requires conflict resolution at each commit that diverges from the new base.
Squash-merge (git merge --squash): Combines all changes from a source branch into a single staged changeset on the target, without creating a merge commit. You resolve conflicts once for the aggregate diff, not per-commit.
Detached HEAD: A Git state where HEAD points directly to a commit rather than a branch ref. Changes made here don't affect any branch until you explicitly create or reset one. Think of it as a safe scratch pad — you can always get back to any branch via its name, and back to the detached commit via its tag or SHA. We use it in Pass 1 specifically so the squash-merge can't accidentally rewrite any branch.
Answer key: In this pattern, a tagged commit that represents the correct merged tree — the result of combining your feature work with a specific point on the target branch. It's your source of truth during the real rebase. The "correct" state is relative to the base you chose, which is why both passes must target the same base SHA (even if origin/main has moved since).
git rerere (Reuse Recorded Resolution): A Git feature that records how you resolved a conflict and automatically applies the same resolution if it encounters the same conflict again. Helpful as a convenience, but it won't eliminate conflicts when surrounding context shifts between commits in a large rebase — treat it as an accelerator, not a guarantee.
git bisect: A binary search tool that walks through commit history to find the commit that introduced a bug. Requires each commit in the range to be independently buildable — which is why squash-merges and giant merge commits make it far less useful (bisect can still land on them, but the result isn't informative). You can use git bisect skip on non-building commits, but each skip reduces precision by widening the candidate range.
Force push with lease (git push --force-with-lease): A safer alternative to git push --force that refuses to overwrite the remote branch if someone else has pushed commits you haven't seen. Always use this after a rebase.
git range-diff: A tool that compares two versions of the same commit series (before and after rebase). While git diff compares final trees, range-diff compares how individual commits changed during the rebase — useful for catching accidentally dropped, reordered, or semantically altered commits.
Reality Wins: Why Naive Approaches Fail
1. The Repeated Conflict Trap
A standard rebase replays each commit sequentially. If commit 12 and commit 847 both touch the same file that was also modified on main, you resolve the same structural conflict twice — possibly with slightly different surrounding context each time. Across 1,500 commits, you might resolve the same logical conflict dozens of times.
2. Conflict Fatigue and Silent Errors
By the 50th conflict, your brain is fried. You start accepting "theirs" or "ours" reflexively. One wrong resolution in a deeply nested utility file doesn't show up until weeks later in production. There's no safety net.
3. The Non-Compiling Intermediate State
During a long rebase, individual commits may not compile after resolution. This is expected — the commit was written against a different base. But if you're interrupted or need to verify progress, you can't run tests. You're flying blind.
4. git rerere Helps but Won't Save You
git rerere is a useful accelerator for small rebases, but it matches conflicts by surrounding context. In a large rebase, the same logical conflict may present with different context lines at different commits, so rerere doesn't recognize it as the same conflict. It reduces toil; it doesn't eliminate it.
5. The "Just Merge" Copout
A merge commit solves the conflict-repetition problem but creates a single massive diff node in history. git bisect can't step through it. Code review is meaningless on a 4,000-line merge diff. And if the merge introduced a subtle error, you have no commit-level granularity to isolate it.
6. The Squash-and-Pray
Squashing the feature branch into one commit solves the rebase pain but destroys all commit history. Three months of carefully structured, reviewable commits become one opaque blob. If your team values commit hygiene, git blame context, or audit trails, this is unacceptable.
7. The Rebase Abort Loop
Engineer starts rebase. Hits a wall at commit 200. Aborts. Tries again with a different strategy. Aborts again. Each attempt takes an hour. By the end of the day, they've made zero progress and the branch is untouched. The Answer Key breaks this cycle because Pass 1 always produces a usable result, even if you abandon Pass 2.
8. The Unverifiable Outcome
After a long rebase, how do you know the result is correct? You can run tests, but tests don't catch every resolution error. Without a reference state to diff against, you're relying on test coverage — which, in most codebases, is nowhere near 100%.
9. Force Push Accidents
After a rebase, you must force push. If you use --force instead of --force-with-lease, you risk overwriting a colleague's work. If you push to the wrong branch, you rewrite shared history. The pressure of a long rebase makes these mistakes more likely.
10. The Timezone Handoff Problem
In distributed teams, a rebase started by one engineer may need to be continued by another in a different timezone. A naive rebase in progress can't be easily handed off — the state is local and context-dependent. The Answer Key pattern produces a portable reference artifact (a tag) that any team member can use.
11. Rename Detection is a Heuristic, Not a Contract
Git detects file renames using similarity heuristics. During a large rebase, the same rename can present inconsistently across commits — sometimes Git sees it as a rename, sometimes as a delete-plus-add. This leads to conflicts that look different each time they appear, even though they're logically the same operation. Use git diff --name-status and git status to confirm what Git thinks happened before resolving. Consider enabling diff3 conflict style (git config merge.conflictStyle diff3) and histogram diff algorithm (git config diff.algorithm histogram) for more reliable context during resolution.
12. Your CI Model Might Not Expect This
Some CI pipelines build every commit in a pushed range, not just the branch tip. If your pipeline does this, the Answer Key Rebase will produce red builds on intermediate commits — because Pass 2 resolves conflicts using the final file state, not the correct intermediate state. Check your CI model before you start. If it's tip-only (most are), you're fine. If it's per-commit, you'll need to either adjust your pipeline or accept the intermediate failures.
The Correct Design: Two-Pass Answer Key Rebase
Architecture
The pattern has three distinct phases with clear responsibilities:
┌─────────────────────────────────────────────────────────┐
│ PASS 1: SOLVE │
│ │
│ target (main) ──── detached HEAD │
│ │ │
│ squash-merge ← feature-branch │
│ │ │
│ resolve conflicts (ONCE) │
│ │ │
│ commit + tag "answer-key" │
│ │
│ Output: A single commit representing the correct │
│ merged tree. This is your source of truth. │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ PASS 2: REPLAY │
│ │
│ feature-branch ──── git rebase $BASE │
│ │ │
│ on each conflict: │
│ checkout file from answer-key tag │
│ git add + git rebase --continue │
│ │ │
│ Output: Full commit history, linearly rebased, │
│ with correct resolutions at every step. │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ PASS 3: VERIFY │
│ │
│ git diff answer-key │
│ │ │
│ empty diff ──► PASS: trees are identical │
│ non-empty ──► FAIL: investigate differences │
│ │
│ Output: Binary proof of correctness. │
└─────────────────────────────────────────────────────────┘
Responsibility Boundaries
- Pass 1 owns all intellectual work — understanding conflicts, choosing resolutions, verifying compilation and tests.
- Pass 2 owns only mechanical execution — applying known-good file states during replay. Zero decision-making.
- Pass 3 owns verification — a single diff that proves the outcome matches the intent.
This separation is what makes the pattern safe. The hard thinking happens once in a calm, low-pressure environment (Pass 1). The tedious replay happens without cognitive load (Pass 2). And the verification is deterministic (Pass 3).
Flagship Example: Step-by-Step Walkthrough
Scenario
You're on feature/payments-v2. It branched from main twelve weeks ago. main has 1,500 new commits. Your feature branch has 87 commits. A merge commit is unacceptable because the team requires linear history for release tagging, git blameclarity, and improved bisectability (noting that bisect will still need a build-tolerant test script if intermediate commits don't compile — more on that below).
Pass 1: Build the Answer Key
# Ensure feature branch is clean
git checkout feature/payments-v2
git status # must be clean — stash or commit any WIP
# Detach at the tip of main
# CRITICAL: Record this exact SHA. Pass 2's rebase MUST target the same commit,
# even if origin/main moves between passes. You can fetch — just rebase onto this SHA.
git fetch origin
BASE=$(git rev-parse origin/main)
echo "Rebase base: $BASE" # save this
git checkout --detach $BASE
# Squash-merge all feature work into this detached state
git merge --squash feature/payments-v2
Git will report conflicts. Resolve them — all of them. This is the one time you do real conflict resolution.
# After resolving every conflict:
git add .
# Verify the code compiles and critical tests pass
# (use your project's build/test commands)
xcodebuild -scheme PaymentsV2 -sdk iphonesimulator build
swift test # or your equivalent
# Commit the resolved state
git commit -m "ANSWER KEY: payments-v2 squashed onto main — all conflicts resolved"
# Tag it for easy reference — use a namespace to avoid collisions
# Annotated tag captures who/when/why for audit trails
git tag -a answer-key/payments-v2/2026-02-16 \
-m "Answer key for payments-v2 rebased onto $BASE"
At this point, answer-key/payments-v2/2026-02-16 is your source of truth.
Pass 2: Rebase with the Answer Key
# Return to the feature branch
git checkout feature/payments-v2
# Save the old tip for range-diff verification later
OLD=$(git rev-parse HEAD)
# Start the rebase — use the SAME base SHA from Pass 1, not origin/main
git rebase $BASE
Explicit form (optional): If you want zero ambiguity about the replay range — for example, if the branch topology is unusual or you've cherry-picked across branches — use the fully explicit version:
The--fork-pointvariant handles prior rebases and cherry-picks more intelligently; the fallback covers cases where fork-point data isn't available. The simplergit rebase $BASEworks in the common case where your feature branch diverged cleanly from an ancestor of$BASE. The explicit form guarantees correct behavior regardless of topology.
When a conflict appears:
# See which files are conflicted
git status
# For each conflicted file, pull the resolved version from the answer key
git checkout answer-key/payments-v2/2026-02-16 -- Sources/Payments/PaymentProcessor.swift
git checkout answer-key/payments-v2/2026-02-16 -- Sources/Networking/APIClient.swift
# Stage everything
git add .
# Continue
git rebase --continue
Repeat until the rebase completes. This is mechanical — no thinking required.
Handling edge cases during Pass 2:
# If a file was DELETED in the answer key but exists in the conflict:
git rm Sources/Legacy/OldPaymentFlow.swift
git add .
git rebase --continue
# If a file was RENAMED in the answer key:
git rm Sources/Old/Name.swift
git checkout answer-key/payments-v2/2026-02-16 -- Sources/New/Name.swift
git add .
git rebase --continue
# If the rebase stops on an empty commit (all changes already applied):
git rebase --skip
Pass 3: Verify
# The moment of truth — compare your rebased branch to the answer key
git diff answer-key/payments-v2/2026-02-16
If the output is empty, you're done. An empty diff proves your rebased branch tip matches the answer key tree for this base — it confirms final-state correctness, not that every intermediate commit is independently correct.
As a secondary check, use git range-diff to verify individual commits weren't accidentally dropped, reordered, or semantically altered during the rebase:
# Compare the old commit series against the rebased series
# (requires saving the old branch tip BEFORE starting Pass 2)
# OLD=$(git rev-parse HEAD) ← run this before Pass 2
# After rebase completes:
NEW=$(git rev-parse HEAD)
git range-diff $BASE..$OLD $BASE..$NEW
git diff proves the final trees match — that's the primary goal. git range-diff is a safety check that you didn't accidentally warp the series: dropped commits, reordered patches, or semantically altered diffs that happen to produce the same tree. Together, they cover both "is the result correct?" and "did anything weird happen along the way?"
If there are differences in the tree diff, investigate:
# See which files differ
git diff --stat answer-key/payments-v2/2026-02-16
# Inspect a specific file
git diff answer-key/payments-v2/2026-02-16 -- Sources/Payments/PaymentProcessor.swift
Fix any discrepancies, amend the last commit, and re-verify.
Cleanup and Push
# Push the rebased branch first (safe force push)
git push --force-with-lease origin feature/payments-v2
# Wait for CI to go green and another engineer to verify the diff
# ONLY THEN remove the temporary tag
git tag -d answer-key/payments-v2/2026-02-16
Tag lifecycle: The answer key tag is your insurance policy. Don't delete it until the branch is pushed, CI is green, and someone else has confirmed the verification. If something goes wrong after the push, the tag lets you recover immediately.
Quality-of-life trick:git worktree
If you want to keep the answer key visible as actual files on disk while you rebase in another directory, usegit worktree. Create a worktree checked out at your answer key tag and keep it open in a second editor window. During Pass 2, you can visually compare files or copy content without switching branches. This eliminates context-switching mistakes and is especially helpful when renames or deletions are involved.
Team Mode: Making the Answer Key Shareable
Everything above assumes a single engineer operating locally. If the rebase involves handoffs, peer verification, or post-mortem reproducibility, three additions make the pattern team-safe:
- Push the tag. A local tag only exists on your machine. Push it so others can reference the exact merged tree:
git push origin answer-key/payments-v2/2026-02-16. If you pushed the tag, delete it remotely too when you're done:git push origin :refs/tags/answer-key/payments-v2/2026-02-16. - Second-person verification. Before deleting the tag, have another engineer check out the rebased branch and run
git diff answer-key/payments-v2/2026-02-16. Empty diff from two machines = the rebase is correct.
Use an annotated tag. Lightweight tags are just pointers. Annotated tags capture who, when, and why — which matters when someone finds the tag six months later during a post-mortem:
git tag -a answer-key/payments-v2/2026-02-16 \
-m "Answer key for payments-v2 rebased onto $BASE ($(git rev-parse --short $BASE))"
Tradeoffs: What This Costs and When to Skip It
What it costs
- Time: Pass 1 adds one full conflict-resolution cycle. For a branch with few actual conflicts, this overhead isn't worth it. The break-even point is roughly 200+ commits of divergence or 10+ conflicting files.
- Disk/ref clutter: You create a temporary tag. This is trivial but worth noting for teams with strict ref hygiene.
- Intermediate commit compilability: Pass 2 resolves conflicts mechanically by pulling the final file state. This means individual intermediate commits may not compile on their own. If your CI pipeline builds every commit in a push (not just the tip), you'll get failures. Most teams only build the branch tip — verify your setup.
- Learning curve: The pattern is counterintuitive at first. Engineers used to linear workflows need a walkthrough the first time. Budget 30 minutes to explain it.
- Not parallelizable: Pass 2 must happen sequentially. You can't split the rebase across engineers.
When NOT to use this
- Small divergence (under 100 commits, fewer than 5 conflicting files): A normal rebase is fine. Don't over-engineer it.
- History doesn't matter: If you're going to squash on merge to main anyway, just do
git merge --squashand skip Pass 2 entirely. - Merge commits are acceptable: If your team and tooling are fine with merge commits,
git mergesolves the problem in one step with one conflict resolution pass. - Single-file conflicts: If the divergence is large but conflicts are isolated to one or two files, a standard rebase with
git rerereenabled is sufficient. - The branch is yours alone: If nobody else has based work on your feature branch, you have maximum flexibility. Consider whether squash-merge onto main is simpler for your workflow.
A Note on Bisectability
This pattern preserves commit history and avoids mega-merge diffs, which improves bisectability compared to a plain git merge. But because Pass 2 resolves conflicts using the final file state, intermediate commits may not compile independently. This matters if your team relies on git bisect with a build-dependent test script.
Three options depending on how much you need bisect:
- Option A — Accept and skip: Configure your bisect test script to classify "doesn't build" as
git bisect skip. This is imperfect but works in most cases because bisect can navigate around non-compiling commits. It's the lowest-effort option. - Option B — Selective answer-key resolution: During Pass 2, use the answer key only for files that actually conflict. Leave non-conflicting files untouched so they reflect the state the original commit intended. More work, more intermediate correctness, more buildability.
- Option C — Fixup checkpoints: After the rebase, insert periodic fixup commits every N commits to restore buildability. Run a quick build check, amend with fixes, then interactive-rebase to fold them in. Still imperfect but narrows the skip range for bisect.
For most teams, Option A is sufficient. If your project has strict per-commit-buildability requirements, evaluate Options B and C based on how much time you're willing to invest.
Observability and Rollout
This isn't a feature you ship to users — it's an engineering process. But it still benefits from structured adoption.
Metrics to Track
- Time to complete rebase: Before vs. after adopting the pattern. Track per-engineer and per-branch.
- Rebase abort rate: How often engineers abandon a rebase attempt. This should drop to near zero.
- Post-rebase defect rate: Bugs attributed to bad conflict resolution. Track via post-mortems.
- Verification diff failures: How often Pass 3 catches a discrepancy. If this is frequent, your Pass 2 process needs refinement.
- CI failures on rebased branches: Build failures on the branch tip after a rebase.
Staged Rollout
| Stage | Scope | Duration | Kill Criteria |
|---|---|---|---|
| Internal pilot | 1–2 senior engineers on one long-lived branch | 1 sprint | Pass 3 verification fails consistently; time savings not measurable |
| Team adoption | All engineers on branches with 200+ commit divergence | 2 sprints | Increased defect rate post-rebase; engineer frustration or confusion despite documentation |
| Standard practice | Default recommendation for large divergence rebases in engineering handbook | Ongoing | Discovery of edge cases where pattern produces incorrect results |
| Tooling | Optional: wrapper script that automates Pass 2 mechanical resolution | After 2+ months of manual adoption | Script introduces bugs not caught by Pass 3 verification |
Suggested Automation (Optional)
Once the team is comfortable with the manual process, a shell script can automate the mechanical parts of Pass 2:
#!/bin/bash
set -euo pipefail
# rebase-with-answer-key.sh
# Usage: ./rebase-with-answer-key.sh <answer-key-tag> <base-sha>
ANSWER_KEY=$1
BASE=$2
OLD=$(git rev-parse HEAD)
# Start the rebase — handle the three possible outcomes:
# 1. Exit 0: rebase completed with no conflicts
# 2. Exit non-zero + rebase state exists: conflicts to resolve (enter loop)
# 3. Exit non-zero + no rebase state: unexpected failure (abort)
if git rebase "$BASE"; then
echo "Rebase completed with no conflicts."
else
if ! [ -d ".git/rebase-merge" ] && ! [ -d ".git/rebase-apply" ]; then
echo "ERROR: Rebase failed unexpectedly before entering conflict state."
exit 1
fi
fi
while [ -d ".git/rebase-merge" ] || [ -d ".git/rebase-apply" ]; do
# Get list of conflicted files
CONFLICTED=$(git diff --name-only --diff-filter=U)
if [ -z "$CONFLICTED" ]; then
git rebase --skip
continue
fi
for file in $CONFLICTED; do
if git show "$ANSWER_KEY":"$file" > /dev/null 2>&1; then
git checkout "$ANSWER_KEY" -- "$file"
else
git rm "$file"
fi
done
git add .
GIT_EDITOR=true git rebase --continue || true
done
echo "Rebase complete. Verifying..."
NEW=$(git rev-parse HEAD)
DIFF=$(git diff "$ANSWER_KEY")
if [ -z "$DIFF" ]; then
echo "PASS: Rebased branch matches answer key."
echo "Running range-diff for structural verification..."
git range-diff "$BASE".."$OLD" "$BASE".."$NEW"
else
echo "FAIL: Differences detected. Run 'git diff $ANSWER_KEY' to inspect."
exit 1
fi
This script is a sketch — treat it as a starting point, not production tooling. The || true on rebase --continue can mask non-conflict failures. In particular, if Git encounters a real error (corrupt object, disk full, permission denied), the loop may spin. Review and adapt it to your environment before relying on it. Do the process manually at least twice before automating — understanding the mechanics is the safety net, not the script. Even if the script prints PASS, run git diff <answer-key-tag> yourself once.
Checklist: If You Ship This, Make Sure You Have…
- ☐ Confirmed that linear history is actually required for your branch (if not, just merge)
- ☐ Fetched the latest target branch (
git fetch origin) before starting - ☐ Verified your feature branch is clean — no uncommitted changes, no stashed WIP
- ☐ Created the detached HEAD from the correct target (
origin/main, not localmain) - ☐ Recorded the exact base commit SHA (
BASE=$(git rev-parse origin/main)) — both passes must target this SHA - ☐ Pass 2 rebases onto
$BASE, notorigin/main(the remote may have moved) - ☐ Resolved all conflicts in Pass 1 — not just "accepted theirs" to get past them
- ☐ Built the project successfully on the answer key commit
- ☐ Run your critical test suite against the answer key commit
- ☐ Tagged the answer key with an annotated, namespaced tag (
git tag -a answer-key/<branch>/<date> -m "...") - ☐ Noted the answer key tag's SHA for recovery if the tag is accidentally deleted
- ☐ Saved the old feature branch tip before starting Pass 2:
OLD=$(git rev-parse HEAD) - ☐ Started Pass 2 from the feature branch, not from the detached HEAD
- ☐ Used
git checkout <answer-key-tag> -- <file>for every conflict, not manual edits - ☐ Used
git diff --name-statusto confirm renames vs delete/add before resolving - ☐ Handled file deletions and renames explicitly during Pass 2
- ☐ Used
git rebase --skipfor commits that become empty after resolution - ☐ Run
git diff <answer-key-tag>after rebase — verified the diff is empty - ☐ Run
NEW=$(git rev-parse HEAD) && git range-diff $BASE..$OLD $BASE..$NEWto verify no commits were dropped or reordered - ☐ If diff was non-empty, investigated and fixed discrepancies before proceeding
- ☐ Used
git push --force-with-lease, never--force - ☐ Notified teammates before force-pushing (Slack message, PR comment, etc.)
- ☐ Verified CI passes on the rebased branch tip
- ☐ Checked your CI model: if it builds per-commit (not tip-only), expect intermediate failures
- ☐ Did NOT delete the answer key tag until the rebased branch was pushed and CI was green
- ☐ Confirmed someone else can reproduce the verification before deleting the tag
- ☐ Deleted the temporary answer key tag only after all of the above
- ☐ Documented the rebase in the PR description for reviewers' context
- ☐ If using the automation script: run
git diffverification manually anyway the first three times - ☐ Confirmed no one else had based branches off your pre-rebase feature branch (if they did, coordinate)
Closing
The Answer Key Rebase works because it respects a simple truth: conflict resolution is intellectually expensive, but history replay is mechanically cheap. By separating the two, you turn an unbounded, error-prone marathon into two bounded, verifiable steps.
The best engineering patterns aren't the ones that impress people in code review. They're the ones that let you ship on Thursday and sleep on Thursday night. When your branch has drifted 1,500 commits from main and the release is next week, the Answer Key is how you get there — with your history intact, your correctness provable, and your sanity preserved.