DEV Community

yureki_lab
yureki_lab

Posted on

How I Let Claude Code Resolve 312 Merge Conflicts in One Rebase: 5 Lessons

TL;DR

I had a feature branch that drifted five months behind main, and rebasing it produced 312 merge conflicts across 137 files. Instead of grinding through them by hand, I built a small triage harness that classified each conflict, handed the hard ones to Claude Code with the right context, and gated every batch behind a compile-and-test check. It took 11 hours instead of the two weeks I'd budgeted — and the failures taught me more than the successes.

The Problem

Every team has one of these branches. Ours was a storage-layer rewrite: started in February, kept alive by "we'll land it next sprint," and by July it was 5 months and 1,900 commits behind main.

The numbers when I finally ran the rebase:

$ git rebase main
...
CONFLICT (content): Merge conflict in src/storage/adapter.ts
CONFLICT (content): Merge conflict in src/storage/index.ts
CONFLICT (add/add): Merge conflict in src/storage/cache/lru.ts
... 309 more

$ git diff --name-only --diff-filter=U | wc -l
     137
Enter fullscreen mode Exit fullscreen mode

312 conflict hunks. 137 files. I estimated three days of solid work at best, two weeks realistically, and a meaningful chance of silently breaking something on the way through — which is the actual danger. A merge conflict resolved plausibly but wrongly doesn't blow up in your face. It compiles, it passes the tests that happen to exist, and it ships a bug that looks like it was always there.

My first instinct was the obvious one: open the repo in Claude Code and say "resolve the merge conflicts." I tried it. It was a disaster, and the way it failed is the whole reason this post exists.

Why the naive approach fails

The agent resolved about 40 hunks before things went sideways. Reading back through what it produced, three distinct failure modes:

  1. It picked a side to make the markers go away. When a hunk was genuinely ambiguous, it took HEAD (or theirs) and moved on. Syntactically clean. Semantically a silent revert of somebody's bug fix.
  2. It lost track of decisions across files. Conflict #12 in adapter.ts established that we keep the new retry signature. Conflict #88 in a caller reintroduced the old one. Nothing in the session tied those together.
  3. Context ran out and quality degraded. By hunk 40 the useful details from hunk 5 were long gone, and the resolutions got noticeably more "generic."

None of these are the model being dumb. They're all the same problem wearing different hats: a merge conflict is not a text-merging problem, it's a question about intent, and the intent lives in history the agent was never shown.

So I stopped asking for resolutions and started building the thing that supplies the intent.

How I Solved It

The shape I landed on:

flowchart TD
    A[git rebase main<br/>312 conflicts] --> B[Triage script:<br/>classify every hunk]
    B --> C{Class?}
    C -->|Mechanical<br/>117 hunks| D[Auto-resolve<br/>deterministic rules]
    C -->|Semantic<br/>171 hunks| E[Build context bundle<br/>per conflict]
    C -->|Human-only<br/>24 hunks| F[Queue for me]
    E --> G[Claude Code<br/>batch of 10]
    D --> H[Gate: build + test]
    G --> H
    F --> H
    H -->|pass| I[Commit batch<br/>+ record in resolutions.md]
    H -->|fail| J[Revert batch,<br/>re-run with failure output]
    J --> G

Four pieces. Let me go through the two that actually mattered.

1. Triage before you touch anything

Not all 312 conflicts deserved a model. A large chunk were noise: import block collisions, lockfile churn, formatter disagreements, two branches appending to the same list. Those have deterministic answers, and spending agent tokens (and my review attention) on them is waste.

I wrote a small classifier that walks every conflicted hunk and buckets it:

# triage.py — classify each conflict hunk before deciding who resolves it
import re, subprocess

MECHANICAL = [
    (re.compile(r"^\s*(import|from)\s"), "imports"),
    (re.compile(r"^\s*[\"']?[\w@/.-]+[\"']?:\s*[\"'][\d.^~]+[\"']"), "dep-version"),
    (re.compile(r"^\s*[-*]\s"), "list-append"),
]

def hunks(path):
    """Yield (ours, theirs) line lists for each conflict block in a file."""
    ours, theirs, side = [], [], None
    for line in open(path, encoding="utf-8"):
        if line.startswith("<<<<<<<"):
            ours, theirs, side = [], [], "ours"
        elif line.startswith("======="):
            side = "theirs"
        elif line.startswith(">>>>>>>"):
            yield ours, theirs
            side = None
        elif side == "ours":
            ours.append(line)
        elif side == "theirs":
            theirs.append(line)

def classify(ours, theirs):
    body = ours + theirs
    if all(not ln.strip() or any(p.match(ln) for p, _ in MECHANICAL) for ln in body):
        return "mechanical"
    # A hunk touching control flow or signatures is where silent bugs hide.
    if any(re.search(r"\b(if|for|while|return|throw|await|def |function |class )\b", ln)
           for ln in body):
        return "semantic"
    return "review"

files = subprocess.run(
    ["git", "diff", "--name-only", "--diff-filter=U"],
    capture_output=True, text=True, check=True,
).stdout.split()

for f in files:
    for i, (ours, theirs) in enumerate(hunks(f)):
        print(f"{f}\t{i}\t{classify(ours, theirs)}")
Enter fullscreen mode Exit fullscreen mode

The split on my 312:

Class Count Who resolves it
Mechanical 117 Deterministic rules (union merge, take-newer-version)
Semantic 171 Claude Code, with context
Review 24 Me, by hand

Triage is the highest-leverage step and it's the one everybody skips. Cutting 117 trivial hunks out of the agent's workload didn't just save tokens — it removed 117 chances for the agent to get bored and start pattern-matching.

The 24 in the "review" bucket were the ones where both sides had changed the same business rule. I never let the agent near those. More on that in the lessons.

2. The context bundle is the whole trick

For each semantic conflict, I stopped sending the conflict. I started sending the story of the conflict.

#!/usr/bin/env bash
# bundle.sh <file> <start-line> <end-line> — everything the agent needs to decide
set -euo pipefail
file="$1"; start="$2"; end="$3"

echo "### File: $file"
echo
echo "### Our side — commits that touched these lines on this branch"
git log --format='%h %s' -L "${start},${end}:${file}" HEAD --no-patch | head -20
echo
echo "### Their side — commits that touched these lines on main"
git log --format='%h %s' -L "${start},${end}:${file}" main --no-patch | head -20
echo
echo "### Common ancestor version"
git show "$(git merge-base HEAD main):${file}" | sed -n "${start},${end}p"
echo
echo "### Conflict, with 40 lines of surrounding context"
sed -n "$((start > 40 ? start - 40 : 1)),$((end + 40))p" "$file"
Enter fullscreen mode Exit fullscreen mode

That git log -L is the load-bearing part. It answers the only question that matters — why does each side look like this? — and it's exactly what a human does instinctively and an agent can't do unless you hand it over.

Real example. Both sides changed a timeout:

### Our side
a3f9c21 fix: bump storage timeout to 30s for large blob writes

### Their side
7d1e884 perf: drop default timeout to 5s after connection pooling landed
Enter fullscreen mode Exit fullscreen mode

Without the log, that hunk is 30 vs 5 and any resolution is a coin flip. With it, the answer is obvious and it isn't either side: pooling made the default fast, but large blob writes still need headroom, so it's a per-call override. The agent got that right on the first try, and cited both commits in its explanation. That's not the model being clever — that's the model finally having the information.

The prompt per batch was deliberately narrow:

Resolve these 10 conflict hunks. For each one:
- State which side you kept and WHY, citing the commit subjects above.
- If the correct answer is neither side, write the combined version.
- If you cannot determine intent from the provided history, output
  UNRESOLVED and move on. Do not guess.
- Prior decisions in this rebase are in resolutions.md — stay consistent.
Enter fullscreen mode Exit fullscreen mode

Two details there earned their keep. UNRESOLVED gave the agent a legitimate exit that wasn't "pick a side" — it used it 19 times, and 14 of those were genuinely ambiguous. And resolutions.md — a running append-only log of every resolution with its rationale, fed back in with each batch — is what stopped the cross-file contradictions from failure mode #2.

3. The gate

Every batch of 10 hunks had to pass before the next one started:

git add -A && npm run build && npm test -- --changed || {
  git checkout -- .              # batch is dead, nothing partial survives
  echo "BATCH FAILED — feeding output back"
}
Enter fullscreen mode Exit fullscreen mode

Batches were small on purpose. When a batch failed, I fed the compiler and test output straight back to the agent for that batch only. 8 batches failed on the first pass; 6 of them self-corrected with the error output attached. The other 2 went into my by-hand queue.

I also turned on git rerere at the start, which meant that when I inevitably aborted and restarted the rebase, git replayed everything already settled:

git config rerere.enabled true
Enter fullscreen mode Exit fullscreen mode

That one line saved me from redoing roughly 90 conflicts on the second attempt.

The result

Estimate Actual
Wall-clock 3–14 days 11 hours
Hunks I touched by hand 312 43 (24 triaged + 19 UNRESOLVED)
Bugs found in review afterward 3
Bugs that reached staging 1

Three bugs in code review, one that got to staging. Let me be honest about that one, because it's the most useful thing here: it was a hunk the agent resolved correctly in isolation while an adjacent non-conflicted line depended on the old behavior. Git never flagged it as a conflict, so it was never in the bundle, so nobody — human or agent — was looking at it. Same bug I would have shipped by hand.

Lessons Learned

1. Merge conflicts are a context problem, not a merge-algorithm problem. Every failure in my first naive attempt came from the agent not knowing why each side looked the way it did. git log -L on the conflicted range moved my first-pass correctness from roughly 60% to something like 90%. If you take one thing from this post, take that flag.

2. Triage first. Do not send a mechanical conflict to a model. 117 of 312 hunks had deterministic answers. Deterministic answers deserve deterministic code — it's faster, it's free, and it's right, whereas an agent resolving import-block collisions is 117 opportunities for a novel mistake.

3. Never let the agent resolve a conflict you can't test. My gate was build + affected tests after every batch of 10. The single bug that escaped was in a code path with no test coverage — which tells you the gate worked exactly as designed and my coverage didn't. If a conflicted file has no tests, that hunk belongs in the human bucket, full stop.

4. Give the agent a way to say "I don't know," and it will use it. 19 UNRESOLVED markers. Without that escape hatch, those 19 would have been confident wrong guesses that looked identical to the correct ones. An agent forced to always produce an answer will always produce an answer.

5. Small batches with a shared decision log beat one long session. Batch size 10, with resolutions.md re-fed each time. The log is what keeps resolution #88 consistent with resolution #12 — long-context alone doesn't do it, because "remembering" and "treating as binding" aren't the same thing. Small batches also mean a bad batch reverts cheaply instead of poisoning everything downstream.

And the meta-lesson, which is really an argument I'd make to your tech lead: none of this beats not having a 5-month-old branch. The best version of this story is the one where you rebase weekly and the conflicts never accumulate. I built a nice harness for a problem I should not have had.

What's Next

Two directions I'm working on:

  • Running triage continuously instead of at rebase time. A nightly job that dry-run-merges every long-lived branch against main and reports conflict count and class. Watching that number climb from 4 to 40 is a much better signal than discovering 312 in July.
  • Feeding the escaped-bug pattern back in. The staging bug came from an unconflicted line that depended on conflicted behavior. I'm experimenting with expanding each bundle to include references to the symbols in the hunk — so the agent sees the callers, not just the conflict.

If you try this, start with git config rerere.enabled true and the git log -L bundle. Those two are 80% of the value for about 20 minutes of setup.

Wrap-up

Long-lived branches are a process failure, but when you're already in one, the difference between a two-week slog and a one-day job is entirely about how much context you can put in front of whoever — or whatever — is resolving each hunk.

Follow me here on Dev.to if you want more war stories like this one, and if you've got a rebase horror story of your own, drop it in the comments — especially if you found a conflict class my triage buckets would have gotten wrong. I'm still tuning the classifier and the interesting failures always come from someone else's repo.

Stack for this write-up: Claude Code (Aug 2026), Node.js 22.x, Python 3.13, git 2.46.

Top comments (0)