Integrating history: merge, rebase, cherry-pick
Mid / SeniorFast-forward and three-way merges, how conflicts arise and get resolved, rebase and interactive rebase, cherry-picking commits between branches, and recovering from mistakes with the reflog and bisect.
Created 15 October 2026
8 concepts · detail hidden
01Glossary — terms used in this page
#c-glossary-terms-used-in-this-page- Merge base — the best common ancestor of two commits; the reference point for a three-way merge.
- Three-way merge — combining two versions of content by comparing each against their common ancestor.
- Fast-forward — integrating by moving a branch pointer forward, without a new commit.
- Merge commit — a commit with two (or more) parents.
- Patch / diff — the set of line changes a commit introduces relative to its parent.
- Patch-id — a hash of a commit’s diff (ignoring line numbers and whitespace); Git uses it to recognise “the same change” under different commit hashes.
- Squash — combining several commits into one.
- ours / theirs — the two sides of a conflict; their meaning flips between merge and rebase (see below).
- ORT — “Ostensibly Recursive’s Twin”, Git’s default merge strategy since 2.34 (replaced
recursive).
02Fast-forward vs three-way merge
#c-fast-forward-vs-three-way-mergegit merge feature(while onmain) has two possible outcomes.Fast-forward
If
mainis an ancestor offeature, there is nothing to combine — Git just moves themainpointer:before: A───B ← main after: A───B───C───D ← main, feature └───C───D ← featureNo new commit, linear history.
git merge --ff-onlymerges only if this is possible.Three-way merge
If both branches have new commits, Git:
- Finds the merge base (
git merge-base main feature→B). - Computes
diff(B → main tip)anddiff(B → feature tip). - Applies both sets of changes to
B’s snapshot. A hunk changed on only one side is taken from that side; a hunk changed differently on both sides is a conflict. - Creates a merge commit with two parents:
parent1 = main(where you were),parent2 = feature.
before: A───B───E───F ← main after: A───B───E───F───M ← main └───C───D ← feature └───C───D───┘ ← featureWhy three-way: with only the two tips, Git could not tell whether a line was added on one side or deleted on the other. The base disambiguates.
--no-ffand squash mergesgit merge --no-ff feature— always create a merge commit, even if a fast-forward was possible. Preserves “this was a feature branch” in the graph.git merge --squash feature— apply the combined diff of the branch to the index without recording a merge; you thengit commitone regular commit. The branch’s commits are not ancestors ofmainafterwards, so Git does not consider the branch merged (git branch -dcomplains; use-D).
- Finds the merge base (
03Merge conflicts — what Git writes and how to resolve them
#c-merge-conflicts-what-git-writes-and-how-to-resolve-themWhen the same region changed on both sides, Git stops mid-merge and:
- writes conflict markers into the working file,
- stores three versions in the index for that path: stage 1 = base, stage 2 = ours, stage 3 = theirs (
git ls-files -ushows them), - records
MERGE_HEAD(the commit being merged in).
<<<<<<< HEAD timeout = 30 ||||||| merged common ancestor timeout = 10 ======= timeout = 15 >>>>>>> feature(The
|||||||base section appears only withgit config --global merge.conflictStyle zdiff3— strongly recommended; seeing the base shows what each side intended: one side raised 10→30, the other 10→15.)Resolving
git status # "both modified: config.py" # edit the file, remove markers, keep the correct result git add config.py # marks resolved (collapses stages 1-3 to stage 0) git merge --continue # or: git commit git merge --abort # give up, back to the pre-merge state git checkout --ours config.py # take our whole version of a file git checkout --theirs config.py # take theirs git diff # during a conflict: combined diff of unresolved hunksgit rerereWith
git config --global rerere.enabled true(“reuse recorded resolution”), Git remembers how you resolved a given conflict and reapplies it automatically when the same conflict reappears — very useful when repeatedly rebasing a long-lived branch.04Rebase — replaying commits onto a new base
#c-rebase-replaying-commits-onto-a-new-basegit rebase main(while onfeature) rewrites the branch so it starts from the current tip ofmain:before: A───B───E───F ← main after: A───B───E───F ← main └───C───D ← feature └───C'───D' ← featureWhat actually happens
- Find the merge base of
featureandmain(B). - List the commits in
main..feature(C,D), skipping any whose patch-id already exists upstream (e.g. it was cherry-picked). - Detach HEAD at
F. - For each commit, apply its diff (internally a cherry-pick) and create a new commit — same message and author, new parent, new committer date, therefore new hash (
C',D'). - Move
featureto the last new commit and reattach HEAD. The oldC,Dare still in the reflog (ORIG_HEADpoints to oldD).
Conflicts can occur at each step:
# fix files git add <files> git rebase --continue git rebase --skip # drop this commit git rebase --abort # return to the original stateours / theirs flip during rebase. Because rebase is replaying your commits onto
main, “ours” (HEAD) is the upstream being built on, and “theirs” is your commit being applied.git checkout --theirs fduring a rebase keeps your version.--onto— transplanting a branchA───B───C ← main └───D───E ← featureA └───F───G ← featureB (built on featureA, but should go to main alone)git rebase --onto main featureA featureB # take commits in featureA..featureB (F, G) and replay them onto mainMerge or rebase?
Merge Rebase History True topology, merge commits Linear, as if work was sequential Rewrites commits No Yes (new hashes) Safe on shared branches Yes No — only on commits nobody else built on Conflict resolution Once, in the merge commit Possibly once per replayed commit bisect/logreadabilityNoisier Cleaner The golden rule: don’t rebase commits that others have already based work on. Rebase your own local/feature branches to keep them current and clean; merge (typically via PR) into shared branches.
- Find the merge base of
05Interactive rebase — editing history before sharing it
#c-interactive-rebase-editing-history-before-sharing-itgit rebase -i <base>opens a to-do list of the commits after<base>; you edit the list and Git executes it top to bottom:pick 1a2b3c4 Add login form pick 5d6e7f8 Fix typo in login form pick 9a8b7c6 WIP debug prints pick 3c4d5e6 Add login API call # Commands: p pick, r reword, e edit, s squash, f fixup, d drop, x exec, b break ...Changed to:
pick 1a2b3c4 Add login form fixup 5d6e7f8 Fix typo in login form # melt into previous, discard its message drop 9a8b7c6 WIP debug prints # remove entirely reword 3c4d5e6 Add login API call # stop to edit the messageResult: two clean commits instead of four.
Command Effect pickkeep as is rewordkeep, edit message editstop after applying, so you can amend or split it ( git reset HEAD~then several commits, then--continue)squashcombine with previous, merge messages fixupcombine with previous, keep previous message dropremove reorder lines reorder commits exec <cmd>run a shell command (e.g. tests) — stop if it fails Autosquash workflow
git commit --fixup=1a2b3c4 # creates "fixup! Add login form" git rebase -i --autosquash main # fixup commit is automatically moved and marked git rebase -i --exec "pytest -q" main # verify every commit still passes the testsgit commit --amendis the one-commit special case: replace the tip commit with a new one (new hash) that includes the current index and/or a new message.06Cherry-pick — copying a commit to another branch
#c-cherry-pick-copying-a-commit-to-another-branchgit cherry-pick <hash>takes the change a commit introduced (its diff against its parent) and applies it on top of the current HEAD as a new commit.before: A───B───C───D ← main after: A───B───C───D ← main └───E───F ← release/1.4 ← HEAD └───E───F───C' ← release/1.4C'has the same diff, message, and author asC, but a different parent and committer date → a different hash.Typical use: backporting a fix
git switch release/1.4 git cherry-pick -x 7f3e2a1 # [release/1.4 b4c5d6e] Fix null check in payment parser # (cherry picked from commit 7f3e2a1...) <- added by -x git push origin release/1.4-xappends the origin hash to the message — essential for traceability when backporting.Variants
git cherry-pick A B C # several commits, applied in this order git cherry-pick A..C # range: commits after A up to C (A excluded!) git cherry-pick A^..C # range including A git cherry-pick -n A B # --no-commit: apply to index/working tree only, combine yourself git cherry-pick -m 1 <merge-hash> # pick a merge commit: diff against parent 1 (the mainline) git cherry-pick -e <hash> # edit the message git cherry-pick origin/feature~2 # anything that resolves to a commit worksConflicts
Same flow as a rebase step (rebase is a sequence of cherry-picks):
git status # resolve, then git add <files> git cherry-pick --continue git cherry-pick --skip | git cherry-pick --abortPitfalls
- Duplicate commits. After cherry-picking
Cintorelease, mergingreleaseback intomainbringsC'alongsideC. Usually Git merges them cleanly (identical change), but if the code changed meanwhile you get confusing conflicts.git log --cherry-pick --oneline main...releasehides patch-equivalent commits;git cherry -v main releaselists which commits are already upstream. - Hidden dependencies. The picked commit may depend on earlier commits not present on the target branch — it applies with conflicts or, worse, compiles but behaves differently. Run the tests.
- Prefer “fix on oldest, merge forward” where possible: fix in
release/1.4, then mergerelease/1.4intomain, so the fix exists once in history. Cherry-pick is the right tool when you cannot merge (main has moved on with unreleasable work).
- Duplicate commits. After cherry-picking
07Recovering lost work — reflog, ORIG_HEAD, fsck
#c-recovering-lost-work-reflog-orig_head-fsckCommits are almost never truly lost; they become unreachable and are pruned only after the reflog entry expires and
gcruns.Situation Recovery Bad reset --hard, merge, or rebasegit reset --hard ORIG_HEAD(if it was the last such operation)Anything that moved HEAD git reflog→ find the hash →git reset --hard HEAD@{n}orgit switch -c rescue <hash>Deleted a branch git reflog(HEAD’s log has commits you were on) →git branch name <hash>Commits made on a detached HEAD git reflog→git switch -c name <hash>Dropped stash / never-referenced commit git fsck --lost-foundorgit fsck --unreachable | grep commitStaged-but-never-committed file content git fsck --lost-foundfinds dangling blobsUncommitted, never staged edits gone — Git never saw them (try your editor’s local history) $ git reflog show feature/login d4e5f6a feature/login@{0}: rebase (finish): refs/heads/feature/login onto 9a8b7c6 1a2b3c4 feature/login@{1}: commit: Add login API call ... $ git branch feature/login-before-rebase feature/login@{1}08`git bisect` — binary-searching history for a regression
#c-git-bisect-binary-searching-history-for-a-regressionWhen something worked at
v1.3.0and is broken now,bisectfinds the first bad commit in O(log n) steps:git bisect start git bisect bad # current HEAD is broken git bisect good v1.3.0 # this was fine # Git checks out a commit halfway (detached HEAD). Test it, then: git bisect good # or: git bisect bad # ... repeat ~log2(N) times ... # => 3c4d5e6 is the first bad commit git bisect reset # return to where you startedWith 1,000 commits between good and bad, that is about 10 tests. Fully automated with a script that exits 0 for good, 1–127 for bad, and 125 for “can’t test, skip”:
git bisect start HEAD v1.3.0 git bisect run pytest -q tests/test_payments.pyThis is a strong argument for small commits that each build and pass tests — bisect’s answer is only as precise as your commits are focused.