Team training · Block 6 of 6

Investigation and conflicts

Finding what broke, juggling half-done work, and merging properly

6.1

Reading history

Who last touched this line — blame

$ git blame invoice.py
6afb878 (Sam    2026-01-09  1) def total(items):
6afb878 (Sam    2026-01-09  2)     return sum(items)
c584509 (Priya  2026-03-14  3) TAX_RATE = 0.20
9f2ab41 (Priya  2026-04-02  4)     return round(total * TAX_RATE, 2)

Every line tagged with the commit that last changed it. The hash is the payload — feed it to git show and you get the diff, the author, and the message that says why.

blame shows only the last touch. To see what a line was before that, blame the parent: git blame c584509~1 -- invoice.py. Or git log -L :total:invoice.py to watch one function evolve.

When did this text arrive or vanish? — the pickaxe

$ git log -S "TAX_RATE" --oneline    # commits that add or remove the string
c584509 Add tax calculation

$ git log -p -- invoice.py           # every change to one file, diffs inline
$ git show c584509                   # one commit in full: diff, author, message

-S finds where a string was introduced or deleted — "who added this call", "where did this constant go". -G takes a regex instead of a literal.

Blame answers "what's on this line now". The pickaxe answers "which commit changed this text, ever" — even if later commits moved or reindented it.

Investigate your own moves — the reflog

$ git reflog
9d4e1a7 HEAD@{0}: commit: Cap discount at subtotal
2b8c6f0 HEAD@{1}: reset: moving to HEAD~1
7a1f9c3 HEAD@{2}: commit (amend): Tidy discount helper
1f9a7d2 HEAD@{3}: rebase (finish): returning to refs/heads/feature/discount
a30f5c8 HEAD@{4}: pull --rebase: Fast-forward
8e3b4a6 HEAD@{5}: checkout: moving from main to feature/discount
6d0c2f1 HEAD@{6}: merge feature/tax: Merge made by the 'ort' strategy
f6c6987 HEAD@{7}: checkout: moving from feature/tax to main
c584509 HEAD@{8}: commit: Add tax calculation
b71d3e8 HEAD@{9}: clone: from github.com:acme/pricing.git

Every HEAD movement is journaled — not just commit and reset, but checkout, pull/clone, merge/rebase too. Newest on top; HEAD@{n} counts movements, not commits.

Two things that scale with a real repo: each branch keeps its own reflog (git reflog show feature/discount, or feature/discount@{1}), and you can index by time — HEAD@{yesterday}, HEAD@{2.hours.ago}.

6.2

bisect

It worked in March. It's broken now.

Four hundred commits in between. Which one did it?

Reading four hundred diffs: hours.
Binary search: nine checks.

Marking good and bad

$ git bisect start
$ git bisect bad                 # current commit is broken
$ git bisect good v2.1           # this tag was fine

Bisecting: 187 revisions left to test (roughly 8 steps)

Git checks out a commit halfway between. You test it, and tell git what you found.

$ git bisect good      # or: git bisect bad
kata · bisect

Each answer throws away half the commits

v2.1 HEAD bad · HEAD good · v2.1 d5f1a83 f6c6987 1a2b3c4 c584509 first bad
$ git bisect start
$ git bisect bad              # HEAD 8b3f1a9 is broken
$ git bisect good v2.1         # 91ac5d3 was fine
Bisecting: 5 left (~3 steps) — now testing d5f1a83git bisect good
Bisecting: 2 left (~2 steps) — now testing f6c6987git bisect bad
Bisecting: 1 left (~1 step)  — now testing 1a2b3c4git bisect good
Bisecting: 0 left (~1 step)  — now testing c584509git bisect bad
c584509 is the first bad commit

Mark the ends — bad HEAD, good v2.1 — then each round is two moves: git checks out a commit and asks, you test it and answer. Four rounds pin the culprit; nine would pin one in four hundred.

The culprit, and getting out

c584509 is the first bad commit
    Add tax calculation

$ git bisect reset          # back to where you started

If you can script the test, git will do the whole thing unattended:

$ git bisect run pytest tests/test_pricing.py
kata · bisect
6.3

stash

Half-finished work, urgent interruption

$ git stash
Saved working directory and index state WIP on main

$ git switch hotfix          # deal with the fire

$ git switch main
$ git stash pop              # pick up where you left off

Stash is a stack. git stash list shows them; pop takes the most recent.

Plain git stash only tucks away tracked changes. A brand-new file you haven't added is untracked — it stays in your working tree and follows you to the other branch.

$ git stash -u     # --include-untracked: sweep up new files too
$ git stash -a     # --all: untracked and ignored (rarely what you want)
6.4

cherry-pick

Copying one commit somewhere else

main feature c584509 same change, new hash 7d3f9a1
$ git switch main
$ git cherry-pick c584509

A copy, not a move. The original stays where it was.

6.5

Conflicts, properly

A conflict is not a failure

Git merges automatically when the two sides changed different regions relative to the common ancestor.

A conflict is git saying: both of you changed the same region, and I will not guess which one you meant.

It's a request for a decision, not an error.

Which side is ours?

ours = the base. theirs = the commits stacked on top of it.

merge — run from main feature = theirs main = ours · you're here merge commit rebase — run from feature main = ours (the base) your commits, replayed = theirs git rebuilds feature on top of main → your work arrives as "theirs"

Same shape both times: main is the base (ours), feature sits on top (theirs). Only your standing point changes — and a rebase runs from feature, yet feature is still theirs. Unsure? Skip the flags and resolve in the editor.

The same two sides, in the markers

<<<<<<< HEAD          ← ours · the base (main)
TAX_RATE = 0.20
=======
TAX_RATE = 0.175
>>>>>>> feature       ← theirs · stacked on top

The top block, up to =======, is ours (HEAD). The bottom block is theirs. Exactly the two sides from the graph — teal base, rose on top — now in the file.

HEAD is main in both a merge and a rebase. So in a rebase the top block is main's line and >>>>>>> names your own commit — "HEAD" is not "the branch you started on". Read the position, not the label: top is the base, bottom is what's arriving.

The default markers hide the useful part

TAX_RATE = ? common ancestor dropped by the default markers main · ours = 0.20 feature · theirs = 0.175 proposed merge = ?

All you're shown are the two tips — main's 0.20 and feature's 0.175. The ancestor they both diverged from is dropped, so you can't tell which side actually changed the line.

Did someone raise it from 0.175, or lower it from 0.20? The answer changes the decision entirely.

Turn on zdiff3. Once. Forever.

TAX_RATE = 0.175 common ancestor changed → 0.20 untouched (= ancestor) main · ours = 0.20 feature · theirs = 0.175 proposed merge = 0.20 (ours)
$ git config --global merge.conflictStyle zdiff3
<<<<<<< HEAD
TAX_RATE = 0.20
||||||| common ancestor
TAX_RATE = 0.175
=======
TAX_RATE = 0.175
>>>>>>> feature

The ancestor block is the amber box, now in the file. Main raised it; feature never touched it. Keep ours.

Reduce conflicts — trailing commas

Two branches touch one list: A appends an item, B renames the last one.

✗ no trailing comma

scopes = [
    "read",
<<<<<<< append
    "write",
    "delete"
=======
    "admin"
>>>>>>> rename
]

To append, A had to add a comma to the "write" line — the exact line B renamed. Same line, two edits → conflict.

✓ trailing comma

scopes = [
    "read",
    "admin",     # B: renamed
    "delete",    # A: new line
]

The comma was already on "write", so A's append is a brand-new line and B's rename touches a different line. Git merges both — no conflict.

Reduce conflicts — sort your lists

Now both branches do the same thing: each adds a dependency.

✗ appended at the end

deps = [
    "flask",
    "requests",
<<<<<<< add aiohttp
    "aiohttp",
=======
    "urllib3",
>>>>>>> add urllib3
]

Both new lines land in the same spot — the end — so git can't tell them apart. Conflict.

✓ kept alphabetical

deps = [
    "aiohttp",    # A: sorts to top
    "flask",
    "requests",
    "urllib3",    # B: sorts to bottom
]

Alphabetical order sends each addition to a different line. Git drops both in and merges without a word.

Reduce conflicts — more habits

Same principle as the commas and the sorting: keep every change small, local, and canonical.

HabitWhy it cuts conflicts
One item per lineEach change is its own line, so two edits can't overlap
A shared auto-formatter on save (prettier · black · gofmt)Everyone's output is byte-identical — no whitespace or reflow conflicts, and minimal diffs
Never hand-edit generated files or lockfilesRegenerate them — the tool owns the format, not you
Union-merge append-only files (CHANGELOG.md merge=union in .gitattributes)Git keeps both sides automatically instead of conflicting

Block 5's rule, one more time: the cheapest conflict is the one you never create. Short branches + code that merges cleanly removes most of them before they start.

Stop resolving the same conflict twice

$ git config --global rerere.enabled true

Reuse recorded resolution. Git remembers how you resolved a conflict and reapplies it automatically next time it sees the same one.

Useful during a long rebase where the same conflict recurs commit after commit. Set it and forget it.

Escape hatches

$ git merge --abort        # put everything back
$ git rebase --abort       # same, mid-rebase
$ git checkout --ours file    # take our whole version
$ git checkout --theirs file  # take theirs

During a rebase, "ours" and "theirs" are reversed from what you'd expect — you're replaying your commits onto their base, so "ours" is the branch you're rebasing onto.

That's the course

BlockThe one-line version
1 — Local modelThree trees, snapshots, pointers
2 — RecoveryAlmost nothing is ever really lost
3 — RewritingNew commits, not edited ones
4 — Remotesorigin/main is your note about the server
5 — Team workflowShort branches, small PRs, authors own them
6 — InvestigationTools for when it goes sideways

Handout: the 13 commandments. Slides and katas are in the repo — the speaker notes have every command.

The 13 commandments

  1. 01Commit early, commit often.
  2. 02One logical change per commit. If the message needs an "and", it's two.
  3. 03Read your own diff before you commit.
  4. 04Never rewrite history someone else has pulled.
  5. 05Force push only your own branch — with --force-with-lease.
  6. 06Revert in public, reset in private.
  7. 07Keep your branch current with main.
  8. 08Never commit secrets, build output, or dependencies.
  9. 09When it looks broken, stop. reflog, then ask.
  10. 10The PR description is the permanent record.
  11. 11Keep PRs small enough to review in one sitting.
  12. 12You opened it, you own it.
  13. 13After a squash merge, delete the branch and re-branch from main.

Nine follow from how git works. Four are ours to change. The printable handout is in the repo.