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:
- A branch the user names — "update from X" / "base is X" wins outright.
- 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.
- The upstream tracking ref, if it names a branch other than this one's own remote mirror (
git rev-parse --abbrev-ref @{u}).
- 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:
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:
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:
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.