4.5.8 · D3Software Engineering

Worked examples — Git operations — branch, merge, rebase, cherry-pick, stash, bisect

2,309 words10 min readBack to topic

The scenario matrix

Every worked example below is tagged with the cell it covers. Think of each row as a "quadrant" of Git behaviour — just like signs of pick a quadrant for an angle.

# Case class The deciding question Degenerate / limiting edge
A Fast-forward merge Is the target a direct ancestor? (no divergence) zero commits between = "already up to date"
B Three-way merge, clean Diverged, but different files/lines touched both sides identical edit → auto-resolved
C Three-way merge, CONFLICT Diverged AND same region changed differently one side deletes, other edits
D Rebase (linearise) Replay my commits on a new base rebasing shared commits (forbidden)
E Cherry-pick Copy one commit's diff elsewhere context differs → conflict
F Stash Shelve dirty tree to switch context nothing to stash (clean tree)
G Bisect Binary search for first bad commit , and the count
H Word problem + exam twist Combine several cells under time pressure duplicate-change trap (pick then merge)

Let me build the vocabulary once, then never assume it again.


Cell A — Fast-forward merge (no divergence)

Figure — Git operations — branch, merge, rebase, cherry-pick, stash, bisect
  1. Locate the merge base . Since feature grew directly out of and main never moved, = the current tip of main. Why this step? Merge always starts by finding the last shared commit — that decides FF vs three-way.
  2. Check ancestry. Is main's tip an ancestor of feature's tip? Yes — look at the figure: is one straight chain. Why this step? If the target is a direct ancestor there is nothing to reconcile — no divergence means no new information to combine.
  3. Slide the pointer. Git just moves main from forward to . No new commit is made. Why this step? Creating a merge commit would add a redundant node with only one meaningful parent — pointless.

Verify: commits on main after = commits before () plus = 5 commits, 0 merge commits. Number of new commits created by the merge = . ✓ (checked in VERIFY)


Cell B — Three-way merge, clean (diverged, no overlap)

Figure — Git operations — branch, merge, rebase, cherry-pick, stash, bisect
  1. Find . The last commit both share (the fork point in the figure). Why this step? The Three-way merge algorithm compares and ; we need first.
  2. Apply the per-line rule for style.css. Here (we changed it) but (they did not). Why this step? If only one side moved away from the base, that side's version is the unique answer.
  3. Apply the rule for app.js. Now , → result . Why this step? Symmetric to step 2 — the other side is the only one that changed.
  4. Write the merge commit with two parents and . Why this step? Divergence really happened, so history records the join (unlike cell A).

Verify: each file falls in a single-changer branch of the piecewise rule, so CONFLICT is never reached. Files auto-merged = 2, conflicts = 0, merge commit parents = 2. ✓


Cell C — Three-way merge, CONFLICT (same region, both differ)

  1. Evaluate the rule. = 1, = 2, = 3. Test the CONFLICT clause: Why this step? A conflict is not random — it is exactly the cell where both sides moved the same region to different values. Git refuses to guess.
  2. Read the markers. Git writes both versions between <<<<<<</=======/>>>>>>>. Why this step? It hands you the raw data (Conflict resolution) so a human decides intent.
  3. Resolve, then git add app.js && git commit. Why this step? Staging tells Git "this region is settled"; the commit becomes the two-parent merge.

Verify: plug into the piecewise function → CONFLICT branch is the only true one. ✓


Cell D — Rebase to linearise (and the forbidden edge)

Figure — Git operations — branch, merge, rebase, cherry-pick, stash, bisect
  1. Find merge base of feature and main (the fork commit). Why this step? Rebase only replays commits after the base — everything before is already shared.
  2. Save the diffs of (patches relative to their parents). Why this step? Rebase re-applies changes, not whole snapshots — same idea as diffs.
  3. Move feature to main's tip , replay: , on top of . Why this step? This produces a linear history with new SHAs (new parents = new content-address, see Immutability and content-addressable storage).

Verify: commit count on feature stays (replayed 1-for-1); SHAs change because parent changed. New tip's ancestor count = base + main gain + feature commits = . If had 3 commits behind it (incl. itself), total = . ✓


Cell E — Cherry-pick one commit (and its context edge)

  1. Compute the patch . Why this step? Cherry-pick copies the diff of a single commit, nothing before it.
  2. Apply the patch on main's tip, creating a new commit (new SHA). Why this step? Same change, different history location → content-address differs.
  3. If surrounding code differs → conflict, resolve exactly like cell C. Why this step? The patch's context lines may not match main; then a human must place it.

Verify: commits copied = exactly ; main's commit count rises by . ✓


Cell F — Stash, including the empty (degenerate) case

  1. git stash saves dirty tree + index as a hidden commit on a stack, reverts to clean HEAD. Why this step? Git blocks branch switches that would overwrite changes; stash = safe shelf.
  2. Switch, fix, switch back, then git stash pop = apply + drop. Why this step? pop reapplies your work and removes the entry — one clean round-trip.
  3. Degenerate case: if the tree was clean, git stash reports "No local changes to save" and creates 0 stash entries. Why this step? There is literally nothing to shelve — the stack is untouched.

Verify: clean pop → stack goes entries. Empty-tree stash → stack stays at . ✓


Cell G — Bisect: binary search and the count

Figure — Git operations — branch, merge, rebase, cherry-pick, stash, bisect
  1. Mark endpoints: git bisect bad (now), git bisect good v1.0 (known good). Why this step? Binary Search needs a range with a known-good low end and known-bad high end.
  2. Git checks out the midpoint; you test and answer good/bad. Each answer discards half the range. Why this step? Halving is why the cost is logarithmic, not linear.
  3. Count the tests. Each test does . Halvings to reach 1: Why this step? , so 10 halvings cover 1000 commits.
  4. Degenerate : extra tests — the single suspect is the answer.

Verify: ; linear search would need up to . And . ✓


Cell H — Word problem + exam twist (combines cells)

  1. Cherry-pick on main (cell E). Ships only the fix, not the 40 commits. Why this step? You need the patch live without releasing unfinished work.
  2. Later, main and dev have diverged ( on main; + 40 on dev) → three-way merge (cell B/C). Why this step? Both branches moved past their base → divergence → merge commit with 2 parents.
  3. Twist resolved: the change from now exists as both and . Modern Git often patch-IDs them equal and skips a redraw, but if surrounding lines shifted you get a cell-C conflict on that region. Why this step? This is the duplicate-change trap — the reason cherry-pick + later merge is delicate.
Recall Which cell was each command?

cherry-pick ::: Cell E (copy one commit) merge dev into main ::: Cell B or C (three-way, divergence) the "appears twice" risk ::: the duplicate-change trap of Cell E

Verify: merge in step 2 has parents = ; patches carrying the same change = ( and ). ✓