Skip to content

Integrating history: merge, rebase, cherry-pick

Mid / Senior

Fast-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

  1. 01Glossary — 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).
    #c-glossary-terms-used-in-this-page
  2. 02Fast-forward vs three-way merge

    git merge feature (while on main) has two possible outcomes.

    Fast-forward

    If main is an ancestor of feature, there is nothing to combine — Git just moves the main pointer:

    before:  A───B  ← main                after:   A───B───C───D  ← main, feature
                  └───C───D  ← feature
    

    No new commit, linear history. git merge --ff-only merges only if this is possible.

    Three-way merge

    If both branches have new commits, Git:

    1. Finds the merge base (git merge-base main feature → B).
    2. Computes diff(B → main tip) and diff(B → feature tip).
    3. 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.
    4. 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───┘   ← feature
    

    Why 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-ff and squash merges

    • git 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 then git commit one regular commit. The branch’s commits are not ancestors of main afterwards, so Git does not consider the branch merged (git branch -d complains; use -D).
    #c-fast-forward-vs-three-way-merge
  3. 03Merge conflicts — what Git writes and how to resolve them

    When 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 -u shows 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 with git 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 hunks
    

    git rerere

    With 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.

    #c-merge-conflicts-what-git-writes-and-how-to-resolve-them
  4. 04Rebase — replaying commits onto a new base

    git rebase main (while on feature) rewrites the branch so it starts from the current tip of main:

    before:  A───B───E───F  ← main                after:  A───B───E───F  ← main
                  └───C───D  ← feature                                 └───C'───D'  ← feature
    

    What actually happens

    1. Find the merge base of feature and main (B).
    2. List the commits in main..feature (C, D), skipping any whose patch-id already exists upstream (e.g. it was cherry-picked).
    3. Detach HEAD at F.
    4. 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').
    5. Move feature to the last new commit and reattach HEAD. The old C, D are still in the reflog (ORIG_HEAD points to old D).

    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 state
    

    ours / 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 f during a rebase keeps your version.

    --onto — transplanting a branch

    A───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 main
    

    Merge 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 / log readability Noisier 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.

    #c-rebase-replaying-commits-onto-a-new-base
  5. 05Interactive rebase — editing history before sharing it

    git 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 message
    

    Result: two clean commits instead of four.

    Command Effect
    pick keep as is
    reword keep, edit message
    edit stop after applying, so you can amend or split it (git reset HEAD~ then several commits, then --continue)
    squash combine with previous, merge messages
    fixup combine with previous, keep previous message
    drop remove
    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 tests
    

    git commit --amend is 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.

    #c-interactive-rebase-editing-history-before-sharing-it
  6. 06Cherry-pick — copying a commit to another branch

    git 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.4
    

    C' has the same diff, message, and author as C, 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
    

    -x appends 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 works
    

    Conflicts

    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 --abort
    

    Pitfalls

    • Duplicate commits. After cherry-picking C into release, merging release back into main brings C' alongside C. Usually Git merges them cleanly (identical change), but if the code changed meanwhile you get confusing conflicts. git log --cherry-pick --oneline main...release hides patch-equivalent commits; git cherry -v main release lists 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 merge release/1.4 into main, so the fix exists once in history. Cherry-pick is the right tool when you cannot merge (main has moved on with unreleasable work).
    #c-cherry-pick-copying-a-commit-to-another-branch
  7. 07Recovering lost work — reflog, ORIG_HEAD, fsck

    Commits are almost never truly lost; they become unreachable and are pruned only after the reflog entry expires and gc runs.

    Situation Recovery
    Bad reset --hard, merge, or rebase git 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} or git 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-found or git fsck --unreachable | grep commit
    Staged-but-never-committed file content git fsck --lost-found finds dangling blobs
    Uncommitted, 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}
    
    #c-recovering-lost-work-reflog-orig_head-fsck
  8. 08`git bisect` — binary-searching history for a regression

    When something worked at v1.3.0 and is broken now, bisect finds 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 started
    

    With 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.py
    

    This 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.

    #c-git-bisect-binary-searching-history-for-a-regression