---
name: merge-conflicts
description: >-
  How to bring a branch up to date and resolve git merge conflicts the way this
  repo expects — merge (never rebase or force-push), understand each side's
  intent before choosing, ask when a resolution isn't clear-cut, keep the merge
  commit to just the merge, and audit what merged *cleanly* before committing.
  Trigger whenever you're about to run
  `git merge`/`git pull`, sync a branch with `main`, "update from main", or
  resolve conflict markers left by a merge, cherry-pick, or interrupted pull —
  including bare phrasings like "fix the conflicts", "merge main in", or "this
  branch is behind". Not for merging data/files (PDFs, CSVs) or algorithms
  (merge sort) — this is strictly about git branch integration.
allowed-tools: Bash, Read, Edit, Write, Glob, Grep
---

# Resolving merge conflicts

## Determine the base branch first — don't assume `main`

**Re-read `git branch --show-current` rather than trusting the branch from earlier in the session.** Conductor runs sessions concurrently, so another one can check this worktree out onto a different branch and commit to it while your conversation is open — and the base you resolved for the branch you *were* on is then the wrong base, merged into work you haven't read.

"Update from base", "sync with main", "this branch is behind", "merge the base in" all need a base branch to merge *from*. **Don't default to `main`.** A branch is often stacked on another feature branch, and merging `main` instead silently pulls the wrong history — the diff looks "already up to date" against `main` while the real base has commits you're missing. Resolve the base in this order:

1. **A branch the user names** — "update from X" / "base is X" wins outright.
2. **An open PR's base** — `gh pr view <branch> --json baseRefName`. Also check the base of any feature branch you recently merged into this one (`gh pr view <that-branch> --json baseRefName`); a stack's branches usually share the same base.
3. **The upstream tracking ref**, if it names a branch other than this one's own remote mirror (`git rev-parse --abbrev-ref @{u}`).
4. **The Conductor workspace target branch** (from the workspace/system instructions) — a default hint, *not* the last word.

**Re-resolve the base every time — it moves.** When a base branch's PR merges, GitHub retargets the child PR (usually to `main`), so what you merged from last time may no longer be the base. Check `gh pr view <branch> --json baseRefName`, and `gh pr list` for whether the old base still has an open PR. A base whose PR has merged often keeps accumulating commits behind no PR at all; those aren't yours to integrate.

If 1–3 turn up nothing and the branch clearly builds on another feature branch — it was created by merging one in, was cut from `main` but layers work that lives on an unmerged branch, or the user talks about it as part of a stack — **ask which branch to update from (offer the likely candidate) rather than merging `main`.** Confirm before running the merge; a wrong base is expensive to unwind.

## Bring a branch up to date

- `git fetch origin`, then `git merge --no-edit origin/<base>` (the base resolved above, not reflexively `main`).
- **Read what the merge brought in from the merge itself, not from an earlier ahead/behind count** — `git log --oneline <pre-merge-HEAD>..<merge-commit>^2`. Conductor worktrees share one `.git`, so `refs/remotes/origin/*` is shared too: another session's `git fetch` advances your base mid-conversation, and a count taken before the merge under-reports it. Telling the user "1 commit behind" and then merging 2 is how that surfaces.
- **Merge, never rebase.** Republishing a rebased branch needs a force-push, and we never force-push.
- If uncommitted work blocks the merge, commit that work first (it belongs to the branch anyway), then merge.
- Already up to date → nothing to do.

### Base already merged? Merge its final commit *before* `main`

This repo squash-merges, so a merged base lands on `main` as one commit sharing no ancestry with the base's real commits. Merge `main` straight into the stacked branch and every file the base *added* comes back as an **add/add** conflict — git has no common ancestor to three-way merge against, so it hands you files you never touched.

Merge the base's tip first, then `main`:

```bash
gh pr view <base-pr> --json headRefOid --jq .headRefOid  # tip survives branch deletion
git fetch origin <sha>                                   # or refs/pull/<base-pr>/head
git merge --no-edit <sha>
git merge --no-edit origin/main
```

That makes your side byte-identical to what the squash put on `main`, so the add/add conflicts collapse and you're left only with files this branch actually changed.

The branch is usually deleted locally *and* on the remote by then, so don't look for `origin/<base>`; `headRefOid` and `refs/pull/<n>/head` are how you reach the commit.

### Part of this branch already shipped as its own PR

Same cause the other way round: work split off this branch into its own PR lands on `main` as one squash commit, so every file both touched conflicts. `main`'s side is the reviewed version of your own change — take it, then check that `git diff origin/main` lists only the work that PR left out.

## Keep the merge commit to *just* the merge

A merge commit should contain **only** the reconciliation of the two histories — nothing else. Don't fold in lint fixes, refactors, renames, or "while I'm here" cleanups; buried inside a merge they're invisible in most diff views. Land them as separate commits *after* the merge.

## Resolving conflicts

When git leaves `<<<<<<<` / `=======` / `>>>>>>>` markers:

- **Understand each side before choosing.** The version on `main` and the version on the branch each exist for a reason. Read enough of both to know what each is trying to do — don't mechanically keep "ours" or "theirs." The correct resolution is often a combination, not one side wholesale.
- **Consider the branch's purpose.** What is this branch trying to accomplish? A conflict resolution that quietly drops the branch's intent (or reverts something `main` deliberately changed) is a bug, even if it compiles.
- **Ask when it isn't clear-cut.** If you can't confidently tell which side should win, or the two changes are semantically entangled, stop and ask the user rather than guessing. A wrong silent resolution is worse than a question.
- **Both sides added at the same spot? Order matters.** Keeping both isn't enough when either block has side effects. If the incoming block ends by reloading the page, anything of yours that depends on unsaved state has to come *after* it — concatenated the other way it still passes while testing nothing.
- **Taking one side resolves the marked blocks, not the file.** `git checkout --ours`/`--theirs` replaces the whole file, discarding the other side's hunks that merged cleanly around the conflict. Delete the unwanted half of each `<<<<<<<` block instead; `git checkout -m -- <file>` restores the markers if you already reached for it.
- **Don't blanket-replace a renamed string.** Two call sites that shared a string can have legitimately diverged; `sed`-ing the whole file changes the one that shouldn't move.
- **A conflicted `schema_migrations` list takes both versions.** Each side appended its own migration, so keep both lines in descending order — in `db/structure.sql` and `db/primary_replica_structure.sql` alike — then `bin/rails db:migrate` to re-dump. Never hand-edit the structure files.
- After resolving, verify the result actually makes sense — the merged code should reflect both intents, not just parse. Run the relevant tests if the conflict touched logic.

## The dangerous part is what merged *cleanly*

Conflict markers are the easy case — git is asking for help. The silent breakages come from hunks it merged without asking, because a three-way merge keeps *your* side of any line it can't attribute to a common ancestor. A base that arrived as a **squash-merge** has history unrelated to yours, so git will cheerfully resurrect code that base deliberately deleted, in files it reports as auto-merged.

After every merge, before committing:

```bash
git diff origin/<base> -- app/ lib/ config/   # then account for every file listed
```

Every differing file must be explainable as *this branch's work* (or a sibling branch you're intentionally stacked on). Anything else is a resurrection or a stray. For a file that's mostly wrong, don't hand-patch hunks — `git checkout origin/<base> -- <file>` and re-apply your change on top.

**Reverting a file to the base's version *before* you've merged that base takes whatever the base has gained since** — commits your branch doesn't have, landing in your diff as your own work, and breaking against the code around them. `git checkout $(git merge-base origin/main HEAD) -- <file>` is the version your branch actually forked from.

**Resolving two files to opposite sides breaks the interface between them**, and neither looks wrong on its own. Taking the base's version of a component while the helper that calls it auto-merges keeping your argument is an unknown-keyword error on every render, past an audit that reports both files as expected. Whenever you reset a file that has callers, grep the arguments you dropped: `git grep -n '<kwarg>' -- app` should come back empty, or only where the base still accepts it.

What it catches:

- **Deleted code coming back.** A constant, predicate, or callback the base removed reappears, along with the call sites that reference it — reintroducing behavior the base decided against.
- **Another branch's change riding along.** A retention window, a flag, a tweak that came in when you merged a sibling branch and the base never took. Not yours to carry; reset it.
- **Committed churn in generated files.** `git checkout --` reverts to HEAD, not to the base — so once churn is committed it survives every later revert. Check VCR cassettes and lockfiles specifically; a diff that's only timestamps/nonces should be reset to the base.
- **Your side calling an API the base deleted.** Nothing conflicts: your file is untouched by the merge and the base's deletion lands cleanly, so the break is a `NoMethodError` at load. Fix it in the commit after the merge, never in it.

## Extracting a slice of another branch: `git checkout <branch> -- <file>` overwrites, it doesn't merge

Pulling part of a feature branch into a fresh one takes the file *whole*, so for anything `main` has moved since that branch last merged, you silently revert the newer work — no conflict, no warning. List the overlap first, and hand-apply those hunks:

```bash
MB=$(git merge-base origin/main origin/<branch>)
comm -12 <(git diff --name-only $MB origin/main | sort) <(git diff --name-only $MB origin/<branch> | sort)
```

## A merged `Gemfile.lock` needs `bundle install` before anything else runs

A dependency bump arriving in the merge leaves the lockfile ahead of what's installed, and every `bin/` script and spec then dies at boot with `Could not find <gem> in locally installed gems (Bundler::GemNotFound)`. That reads like a broken script rather than a missing gem — `bundle install` is the whole fix, and it should leave the lockfile untouched (if it rewrites it, the merge resolved it wrong). A `bin/dev` already running keeps its old gems until it restarts — `bin/rails restart` is enough, and leaves its watchers up.

## Run the linter, not just the specs

`bin/lint` after every merge. A bad auto-merge that duplicates a method or strands a constant parses fine and passes its specs — `Lint/DuplicateMethods` is what catches it.

Then run specs for the merged area, **including the browser ones**. The base renaming or moving something your branch calls produces no conflict marker at all: a method that moved to a service, a route reshaped into a query param, copy your specs assert on. Those only surface at runtime.

`bin/rails db:migrate` too, when the merge brought migrations — the test database is maintained from the schema, so the specs stay green while every page in the browser is an `ActiveRecord::PendingMigrationError`.

**`bin/rails tailwindcss:build` when the merge brought `app/assets/tailwind/**`**, before the browser specs — they read `app/assets/builds/tailwind.css`, so a rule the base added is missing until it's rebuilt and the failure names the assertion (a border width, a radius) rather than the build.

## Never force-push

No exceptions, even on a personal branch. If history has already diverged from the remote and you're tempted to force-push, stop and merge `origin/<branch>` back in — then add follow-up work as new commits and push normally.
