Lesson 5: Version Control
Git is usually taught as a way to save and share work. This lesson treats it as an investigative instrument, which is where most of its value sits.
Atomic commits and branches
Git's more powerful features, including bisect, revert, cherry-pick, and meaningful blame, all depend on one habit: commits that do one thing.
An atomic commit contains a single logical change and leaves the codebase in a working state. Not one file, and not one line, but one idea.
Compare this:
a3f8c21 fix stuffagainst this:
c9d1e07 Fix servings parse failure on missing field
b2a7f14 Add regression test for recipes without servings
8e4d0a9 Extract parse_servings into its own functionThe first is unrevertable, unreviewable, and tells you nothing six months later. The second reads as a narrative, and any one of the three can be reverted independently of the others.
Why it matters concretely:
- Revert. You can undo the bug fix without losing the refactor.
- Bisect. It only works if each commit builds and runs, covered later in this lesson.
- Review. Reviewers can hold one idea in their head at a time.
- Blame.
git blameshows why a line exists, but only if the commit was focused.
Staging selectively. Work rarely arrives pre-sorted, so split it at commit time.
git add -p # step through each change and choose what to stage
git status # see what is staged and what is not
git diff --staged # review exactly what you are about to commitgit add -p presents each hunk, meaning a contiguous block of changed lines, and asks whether to stage it. Press y to stage, n to skip, and s to split a hunk into smaller pieces. This is how you commit the bug fix now and the unrelated typo separately.
Make git diff --staged a habit before every commit. It catches debug prints, commented-out code, and stray API keys before they enter history.
Commit messages.
Fix servings parse failure on missing field
Recipes scraped from the mobile site omit the servings field
entirely rather than sending null. parse_servings now returns
None for a missing key, and the caller decides the default.
Fixes #214A short imperative subject line, a blank line, then a body explaining why. The diff already shows what changed. It cannot show what you were thinking. Six months later, the body is the only part that still has value.
Branches. A branch is a movable pointer to a commit, cheap to create and cheap to delete.
git switch -c fix/missing-servings # create and switch
git switch main # switch back
git branch -d fix/missing-servings # delete once mergedgit switch is the modern command for changing branches. You will see git checkout in older material. It still works, but it does several unrelated jobs, which is why it was split into switch and restore.
Use one branch per logical unit of work. Short-lived branches produce small, reviewable pull requests and few conflicts.
Rebase versus merge
Both integrate one branch's work into another. They produce different histories, and the choice has real consequences.
Merge.
git switch main
git merge feature/recipe-scalingMerge creates a new commit with two parents, joining the branches. History records exactly what happened, namely that work happened in parallel and was combined at this point.
main: A---B---C-------M
\ /
feature: D---EIt preserves true history and never rewrites existing commits, at the cost of merge commits that can clutter a busy log.
Rebase.
git switch feature/recipe-scaling
git rebase mainRebase replays your commits on top of the target branch, as though you had started from there.
main: A---B---C
\
feature: D'---E'Note D' and E'. These are new commits with different identifiers. The originals are discarded.
It produces linear, readable history, at the cost of rewriting commits, which requires care.
The rule that keeps you safe: never rebase commits that others may have pulled.
Rebasing rewrites history. If a colleague holds the old commits and you replace them, their repository and yours disagree about what happened, and resolving that is unpleasant and error prone.
The practical version:
- Rebasing your own unpushed local branch is safe and genuinely useful for tidying up before review.
- Rebasing a shared branch is not.
- Merging into main is always safe.
A common team workflow combines both. Rebase your feature branch onto main before opening a pull request so that review sees a clean linear series, then merge the pull request.
Interactive rebase.
git rebase -i HEAD~3This opens an editor listing your last three commits, where you can reorder, reword, squash, or drop them. It is how you turn six messy work-in-progress commits into three clean atomic ones before anyone else sees them. The same rule applies: only on commits you have not pushed.
[IMAGE PROMPT M1-6
Purpose: Show the different commit graphs produced by merging and rebasing, and make clear that rebase creates new commit objects.
Visual type: Side-by-side commit-graph comparison.
Prompt: A clean educational diagram comparing two git histories side by side, each drawn as a node-and-edge commit graph with circular commit nodes connected by lines. The left panel is headed "Merge" and shows a horizontal main line with nodes A, B, C, then a node M. A branch line diverges after B with nodes D and E and rejoins into M. Node M is drawn slightly larger and labelled "merge commit, two parents". The right panel is headed "Rebase" and shows a single straight horizontal line with nodes A, B, C, then D-prime and E-prime. Below the D-prime and E-prime nodes, a faded pair of nodes labelled D and E is shown crossed out with the caption "originals replaced, new commit IDs". Each panel has a one-line caption beneath it: the left reads "true history preserved" and the right reads "linear history, commits rewritten".
Required elements: Labelled commit nodes, branch divergence and rejoin on the left, a single line on the right, faded crossed-out original commits on the right, prime notation, captions under each panel.
Style: Clean educational illustration, professional, uncluttered, high contrast, flat vector, consistent circular nodes and clear connecting lines.
Layout: Two equal panels side by side separated by a thin vertical divider, each graph reading left to right chronologically.
Text labels: "Merge", "Rebase", "A", "B", "C", "D", "E", "M", "D'", "E'", "merge commit, two parents", "originals replaced, new commit IDs", "true history preserved", "linear history, commits rewritten".
Aspect ratio: 16:9
Accessibility: Distinguish replaced commits using prime notation, crossing out, and text captions rather than colour alone.
Avoid: Terminal screenshots, GUI chrome, decorative elements, tiny text, logos, watermarks.
Alt text: Side-by-side git commit graphs comparing merge, which joins two branches with a merge commit and preserves the original history, against rebase, which replays commits onto the target branch as new commits with new identifiers and discards the originals.
END IMAGE PROMPT]
Concept check. You have three local commits on a feature branch. A colleague has pushed changes to main. You want your work on top of theirs with clean history, and nobody else has pulled your branch. Merge or rebase?
Answer
Rebase. Run git switch your-branch then git rebase main. Your commits are local and unpulled, so rewriting them is safe, and the result is a linear history that reviews cleanly.
If your branch had been pushed and a colleague had pulled it, merge main into your branch instead. A slightly messier graph is a far smaller cost than forcing a teammate to untangle rewritten history.
Reading a diff and resolving a conflict
Reading a diff.
--- a/src/recipe_extractor/parser.py
+++ b/src/recipe_extractor/parser.py
@@ -52,7 +52,10 @@ def parse(data: dict) -> Recipe:
title = data["title"]
ingredients = data["ingredients"]
- servings = int(data["servings"])
+ raw_servings = data.get("servings")
+ servings = int(raw_servings) if raw_servings is not None else None
+
return Recipe(title=title, ingredients=ingredients, servings=servings)--- marks the old version and +++ the new. The @@ -52,7 +52,10 @@ header means this hunk starts at line 52 and covered 7 lines before the change and 10 lines after it. Lines prefixed - were removed, lines prefixed + were added, and unprefixed lines are unchanged context. The context lines exist so you can read the change in place rather than guessing at its surroundings.
Resolving a conflict. A conflict occurs when two branches change the same lines and git cannot decide between them. Git marks the file:
<<<<<<< HEAD
servings = int(data["servings"])
=======
servings = data.get("servings", 1)
>>>>>>> feature/recipe-scalingBetween <<<<<<< and ======= is the version on your current branch. Between ======= and >>>>>>> is the version coming in. After >>>>>>> is the name of the branch it came from.
To resolve, edit the file into the correct final state and delete all three markers. Correct may mean one side, the other, or a combination that neither branch had:
raw_servings = data.get("servings")
servings = int(raw_servings) if raw_servings is not None else 1Then:
git add src/recipe_extractor/parser.py
git status # confirm nothing else is still conflicted
git commit # or: git rebase --continueTwo mistakes to avoid. Leaving a marker behind, so always search for <<<<<<< across the repository before committing. A marker in a Python file is a syntax error, and in a config file it may fail silently. And blindly taking one side, since accepting yours discards their work and accepting theirs discards yours. Read both, understand the intent behind each, and write the correct result.
If a conflict becomes confusing, git merge --abort or git rebase --abort returns you to the state before you started. Nothing is lost.
git bisect for finding a regression
A feature worked in last month's release. It is broken now. Four hundred commits sit between the two points. Which one caused it?
Bisect finds it by binary search. With 400 commits, that is roughly nine steps.
git bisect start
git bisect bad # the current commit is broken
git bisect good v0.3.0 # this tag was fineGit checks out a commit halfway between the two. You test it and report the result:
git bisect good # the bug is not present here
# or
git bisect bad # the bug is present hereEach answer halves the remaining range. After nine or so rounds:
b2a7f14e is the first bad commit
Author: ...
Date: ...
Switch servings parsing to use the new schemaFinish up and return to where you started:
git bisect resetAutomating it. If you can write a command that exits 0 for good and non-zero for bad, git runs the whole search unattended.
git bisect start HEAD v0.3.0
git bisect run uv run pytest tests/test_parser.py::test_missing_servingsWalk away, come back to the answer.
What bisect requires. This is where atomic commits pay off. Bisect only works if each commit builds and runs. A commit in a broken intermediate state cannot be judged good or bad, so you must mark it with git bisect skip, and enough skips make the search useless.
That is the real, practical argument for atomic commits. Not tidiness, but the ability to find a regression in ten minutes rather than a day.
[IMAGE PROMPT M1-7
Purpose: Show how bisect narrows a long commit range by halving it, and why it needs far fewer steps than checking every commit.
Visual type: Multi-row binary search illustration.
Prompt: A clean educational diagram with four stacked horizontal rows, each showing the same sequence of small circular commit nodes in a line. Row 1 is labelled "Step 0" and shows a row of about sixteen nodes, with the leftmost marked "known good" and the rightmost marked "known bad", and the whole span bracketed and labelled "range: 16 commits". Row 2 is labelled "Step 1" and shows a marker at the midpoint labelled "test here", with the left half greyed out and a bracket over the right half labelled "range: 8". Row 3 is labelled "Step 2" and repeats with the range halved again, labelled "range: 4". Row 4 is labelled "Step 3" and shows the range down to two nodes labelled "range: 2", with a caption to the right of the whole figure reading "16 commits, 4 tests".
Required elements: Four rows of commit nodes, good and bad endpoints labelled, a midpoint test marker on each row, eliminated regions greyed with bracketed remaining ranges labelled by count, a summary caption.
Style: Clean educational illustration, professional, uncluttered, high contrast, flat vector, consistent node sizing.
Layout: Four rows stacked vertically and aligned left, each reading left to right, with the summary caption at the right edge.
Text labels: "Step 0", "Step 1", "Step 2", "Step 3", "known good", "known bad", "test here", "range: 16 commits", "range: 8", "range: 4", "range: 2", "16 commits, 4 tests".
Aspect ratio: 4:3
Accessibility: Indicate eliminated regions with greying plus explicit brackets and numeric range labels rather than colour alone.
Avoid: Terminal screenshots, decorative art, tiny labels, logos, watermarks, clutter.
Alt text: Four-row diagram showing git bisect halving a range of sixteen commits at each step, reaching the offending commit in four tests instead of sixteen.
END IMAGE PROMPT]