Git: track your work
Version control from your first commit to release automation: the three-area model, inspecting/undoing/recovering history, scripting Git in Python for changelogs + semver, and enforcing conventions.
This chapter climbs from your first commit to reading Git from Python (release automation, repo analytics) and the conventions a tech lead sets. Shell runs in your terminal; the Python blocks build a throwaway repo so they run offline.
The words you'll hear (in plain terms):
| Term | What it actually means |
|---|---|
| repository | a project folder Git tracks. |
| commit | a saved snapshot with a message. |
| stage | choosing what goes in the next commit (git add). |
| HEAD | pointer to your current commit. |
| remote | a hosted copy (GitHub). |
What you need before starting:
- The command line (DF1).
git --versionto check it's installed.- A GitHub account for the remote parts.
New to the topic? Read this box, then take the chapters in order — each section is tagged essential → expert so you always know the depth you're at.
Learning objectives
- Use the working-dir → staging → repo model fluently.
- Inspect history and undo mistakes at every stage.
- Read Git from Python to automate changelogs/versioning.
- Set and enforce the team's commit conventions.
code/df2-git-basics/. Python runs offline; configs are ready to use.1 · The three areas essential
Everyone has a folder like this somewhere. report_final.py, report_final_v2.py, report_FINAL_actually.py, report_use_this_one.py. It's how you "keep versions" before you know Git — and it's a nightmare: you can't tell what changed between them, you can't collaborate without emailing zips, and the moment you delete the wrong one, that work is gone forever. Git exists to end that folder. It gives you an unlimited, labelled undo history for your whole project — and the very first thing to understand is where a change lives on its way into that history.
Here's the mental model that makes every other command in this lesson obvious. Think of Git like a photographer assembling a group photo. Your working directory is the room full of people milling about — that's your project folder, where you actually edit files. Before you take the shot, you call certain people onto the riser: that's staging, deciding exactly who's in this particular photo. Then you press the shutter — that's the commit, a permanent snapshot saved forever with a caption. The people not on the riser simply aren't in this photo; they're still in the room, ready for the next one.
The part beginners find surprising — and the reason the staging area earns its own box — is that middle step. Most tools save everything that changed, all at once. Git deliberately makes you choose what goes into each snapshot. That feels like extra work until the day you've fixed a bug and half-written a feature in the same file, and you want to commit only the bug fix cleanly. Staging is what lets each commit be one coherent, reviewable idea instead of a jumbled "misc changes" dump. The common mistake is treating git add as a meaningless ritual you type before every commit — staging blindly with git add . every time — and then wondering why your history is a mess of unrelated changes lumped together. Staging is a decision, not a formality.
A change lives in one of three places, and Git commands move it between them: edit → git add (stage) → git commit (save to history).
This is the mental model everything else in the lesson builds on. Git tracks your work in three separate places, and each Git command just moves a change from one box to the next. Read the boxes left to right — that's the order a change travels in.
- Working dir (leftmost box) is your project folder as it is right now — the files you edit in your editor. Git sees changes here but is not saving them yet.
- The first arrow, labelled
git add, means "pick which changes to include next". Runninggit addcopies a change from the working dir into the middle box. - Staging (middle box) is a waiting room: the exact set of changes that will go into your next snapshot. Nothing is permanent yet — you can still add or remove things.
- The second arrow, labelled
git commit, saves everything in staging as one permanent snapshot in the rightmost box, with a message describing it.
In short: edit a file → git add it (working dir → staging) → git commit it (staging → history). Every Git workflow in this lesson is some version of that left-to-right trip.
init.shgit init
git config user.name "You"; git config user.email "you@example.com"
echo "# Project" > README.md
git status # README.md is untracked
git add README.md # stage it
git commit -m "Add README"
git log --oneline
This is the complete birth of a repository: turn a plain folder into one Git watches, identify yourself, create a file, and make your first commit (your first saved snapshot). Run these lines top to bottom in a fresh folder.
git initcreates a hidden.gitfolder — that's what turns an ordinary directory into a Git repository. You only do this once per project.- The two
git configcommands set the name and email Git stamps onto every commit, so history records who made each change. echo "# Project" > README.mdcreates a file (this is shell, not Git). Thengit statusshows it as untracked — Git can see the file but isn't following it yet.git add README.mdstages it (working dir → staging), andgit commit -m "Add README"saves that snapshot with a message.git log --onelinethen lists your commits, one per line.
What the output means: git log --oneline prints a single line: a short commit ID (a sha) followed by your message Add README — proof the snapshot is saved in history.
Try this: Run git status again right after the commit. It now says "nothing to commit, working tree clean" — meaning all three areas match and there's nothing waiting to be saved.
2 · The everyday loop & good messages essential
Setting up a repo is a one-time event; this loop is the rest of your career. The git init ceremony you just did happens once per project. What you'll actually do thousands of times — every real day of coding — is the tiny rhythm below: change something, stage it, snapshot it. If §1 was learning where the three rooms are, this is learning to walk between them without thinking, the way you stopped consciously spelling words once you could read.
The analogy that sticks: a commit is a save point in a video game. You don't save after every footstep — you save when you've reached somewhere you'd be annoyed to have to redo. A commit is the same instinct: make one small, complete unit of progress ("the login form validates now"), then snapshot it. Do that and your history becomes a series of safe places you can always jump back to. Skip it — code for three hours and commit once at the end — and you've thrown away the whole point, because you can only ever roll back to that one distant save point, not to "just before I broke the parser."
Which brings us to the half of this that people undervalue for years: the commit message. A message isn't paperwork for Git — it's a letter to a stranger, and that stranger is you, six months from now, staring at a line of code with no idea why it's there. The code already tells the reader what it does; the message is your one chance to record why you did it. The near-universal beginner mistake is writing messages that describe the mechanics you can already see — update file, changes, fix, or the infamous stuff — instead of the intent. Compare "fixed it" against "Fix off-by-one so the last page of results is no longer dropped": only one of those saves the next person (you) an hour of archaeology. Write messages in the imperative mood ("Add", "Fix", "Remove") as if completing the sentence "This commit will…", and you'll thank yourself every time you run git log or git blame.
Read the three boxes left to right — that's one lap of the everyday loop — then follow the curved arrow underneath back to the start, because you repeat the whole thing constantly.
- The blue "edit" box is you changing a file in your working directory — one small, complete unit of progress ("the login form validates now").
- The middle "git add" box stages that change, and the green "git commit" box snapshots it — a permanent save point in your history.
- The curved dashed arrow looping back means the job is never one trip: you return to editing and take the lap again, so your history becomes a chain of save points rather than one distant one.
In short: edit → git add → git commit, then repeat — commit at each spot you'd hate to redo, not once at the end of the day.
loop.shecho "print('hi')" > app.py
git add app.py # or `git add -p` to stage hunks interactively
git commit -m "Add entrypoint"
git log --oneline --stat # what changed in each commit
This is the loop you'll repeat hundreds of times a day: edit → stage → commit. Once a repo exists (from the previous lab), real work is just running these three steps over and over as you make progress.
echo "print('hi')" > app.pycreates/edits a file — this is the edit step, happening in your working directory.git add app.pystages that change. The comment mentionsgit add -p: an interactive mode that lets you stage parts of a file (called "hunks") when one file has several unrelated edits.git commit -m "Add entrypoint"saves the staged change as a snapshot with a clear message.git log --oneline --statshows history plus, for each commit, which files changed and by how many lines — a quick way to see the size of each change.
Try this: Edit app.py again but don't stage it, then run git status. You'll see it listed under "Changes not staged for commit" — the working dir and staging no longer match until you git add again.
Fix off-by-one in pagination. Bad: stuff. Six months later, debugging with git blame, the message is the only context you'll have.3 · Inspecting history intermediate
A bug appeared this week that definitely wasn't there last month. Nobody remembers touching that code. On a project without version control this is where you start guessing and re-reading everything. With Git, it's a five-minute investigation: the history knows exactly which commits changed those lines, who wrote them, when, and — if the messages were good — why. Learning to read that history is what turns Git from "a thing that saves my files" into a genuine debugging superpower.
Think of your commit history as a security-camera archive for your codebase. Every commit is timestamped footage of exactly what the project looked like at that moment, and the commands in this section are the different ways you review the tapes. git log is scrubbing through the timeline of events; git show is playing one clip in full; git diff is a split-screen comparing two moments side by side; and git blame is the frame-by-frame that tells you which recording last touched a specific spot. Crucially, every command here is read-only — like reviewing footage, you can't damage anything by looking, so you should reach for these constantly and without fear whenever you're confused.
The one distinction worth burning in before you start, because it's the source of endless confusion, is the difference between git diff and git diff --staged. Remember the three areas from §1: plain git diff compares your working directory against staging ("what have I changed but not yet added?"), while git diff --staged compares staging against the last commit ("what will actually go into my next snapshot?"). The classic mistake is running git diff, seeing nothing, and concluding you have no changes — when in fact you already staged them, so they've moved out of git diff's view and into git diff --staged's. The two diffs answer two different questions about two different gaps; knowing which is which is what lets you review a commit precisely before you make it.
git diff compares the working dir against staging; git diff --staged compares staging against the last commit. Seeing "nothing" from one just means the change moved to the other's gap.
The three boxes are the same three areas from §1, laid left to right in the order a change travels. Each labelled line underneath (or above) spans the pair of areas that one command compares.
- The blue
git diffline sits between Working dir and Staging: it answers "what have I changed but not yetadded?" - The amber
git diff --stagedline sits between Staging and Last commit: it answers "what will actually go into my next snapshot?" - The payoff: when you
git adda change it jumps from the first gap to the second — so plaingit diffgoing blank doesn't mean "no changes", it means they moved into--staged's view.
In short: git diff = working-vs-staged; git diff --staged = staged-vs-last-commit. Different questions about different gaps.
inspect.shgit log --oneline --graph --all # visual history
git show HEAD # the last commit's full diff
git diff # unstaged changes
git diff --staged # staged changes
git blame app.py # who last touched each line (and when/why)
These are your read-only commands for understanding a repo's past — none of them change anything, so they're safe to run any time you're confused about what happened or who changed what.
git log --oneline --graph --alldraws history as a compact text diagram, including branches — a quick visual overview of how commits connect.git show HEADdisplays the most recent commit in full, including its exact diff.HEADis Git's word for "the commit you're currently on".git diffshows edits you've made but not yet staged;git diff --stagedshows what's staged and ready to commit. Comparing the two tells you exactly which changes are in which area.git blame app.pyannotates every line of a file with the commit, author, and date that last changed it — invaluable when you need to know why a line exists.
Try this: Run git diff, then git add the file, then git diff again. The second time it shows nothing — because the change moved to staging, and plain git diff only reports the working-dir-vs-staging gap.
4 · Undoing mistakes safely intermediate
The reason people are scared of Git is that they think they can break it. They've heard the horror stories — someone ran a command, lost a day's work, and swore off touching the terminal. So they cling to a safe subset of three commands and never experiment. That fear is almost entirely misplaced, and getting rid of it is the single biggest confidence upgrade in this whole chapter, because Git is built to make mistakes recoverable. Once you trust that, you stop tiptoeing and start using it properly.
Here's the reassuring truth: a commit, once made, is almost impossible to truly destroy. Even when you "delete" one with a reset, Git doesn't shred it — it just stops pointing at it, like a library book pulled from the catalogue but still sitting on the shelf. git reflog is the shelf: a private log of every position your HEAD has ever been at, so you can walk back to a "lost" commit and check it out again. The mistakes that are unrecoverable are the ones involving work you never committed — discarding un-staged edits with git restore, for instance, genuinely throws them away, because Git never had a snapshot to fall back to. The lesson that follows: commit early and often, and almost nothing can hurt you.
The table below is your recovery menu, and the mental model for reading it is to match the fix to which of the three areas the mistake is in — the same map from §1. Staged something by accident? git restore --staged pulls it back out of staging without touching your edit. Want to abandon changes in your working directory? git restore the file. Wrote a bad commit message? git commit --amend. Committed too early? git reset --soft HEAD~1 undoes the commit but keeps the work staged, as if you never pressed the shutter. The one genuine trap — and the reason this section says "safely" — is that these commands rewrite local history, which is fine until a commit has been pushed and pulled by teammates; rewriting shared history breaks their clones. The rule of thumb: reset and amend freely on commits that are still only yours, but undo a shared commit with git revert (which adds a new, inverse commit rather than erasing the old one). We'll return to that distinction in DF3.
reset is recoverable via git reflog — only uncommitted work is truly at risk.
The three boxes are the same working-dir → staging → history areas from §1. To pick the right undo, first ask which box the mistake is sitting in, then read the command under that box.
- The arrows point right-to-left — the opposite of doing work — because undoing always pulls a change back toward where it came from: out of staging, or off the last commit.
- Under each box is its fix:
git restore filefor working-dir edits,git restore --stagedto un-stage,--amend/reset --softfor a just-made commit. - The red band is the safety net:
git reflogremembers every positionHEADhas been at, so a "lost" commit after a reset can be checked out again — the only truly unrecoverable case is work you never committed.
In short: find the area, apply its undo; commit early so reflog can always rescue you.
| Situation | Command |
|---|---|
| Discard un-staged edits | git restore file |
| Un-stage (keep edit) | git restore --staged file |
| Fix last commit message | git commit --amend |
| Undo last commit, keep work | git reset --soft HEAD~1 |
| Recover a 'lost' commit | git reflog then git checkout <sha> |
reset, git reflog shows every state HEAD was at, so you can recover. Git's safety net is why you can experiment fearlessly.5 · .gitignore — never commit junk or secrets advanced
There is a genre of news story that ruins careers: "developer accidentally commits AWS keys to public GitHub, wakes up to a $50,000 bill." It is astonishingly common, and it almost always happens the same way — someone runs git add ., sweeping every file in the folder into a commit without looking, and a .env file full of secrets rides along. Bots scan public repos for exactly this within minutes of a push. The humble .gitignore file is the guardrail that stops it, and this is the section where "commit everything" habits get corrected before they cost you.
A .gitignore is a plain text list of patterns for files Git should pretend don't exist — it never stages them, never nags you about them, never lets a careless git add . sweep them in. Think of it as the bouncer's "do not admit" list at the door of your repository. Two broad categories belong on that list. First, junk that's generated, not authored: virtual environments (.venv/), compiled caches (__pycache__/, *.pyc), build output (dist/, build/), and OS clutter (.DS_Store). These are big, machine-specific, and regenerated on demand — committing them just bloats history and creates pointless merge conflicts. Second, and far more important, secrets: API keys, passwords, tokens, anything in a .env file. Those must never enter history at all.
And that word — history — is the trap that makes this section "advanced" rather than trivial. Here is the mistake that catches even experienced developers: they assume that adding a secret to .gitignore after it's already been committed will hide it. It will not. .gitignore only prevents files from being tracked in the future; a file Git is already tracking keeps getting committed regardless. Worse, Git history is permanent and additive — deleting the secret in a later commit doesn't remove it, it's still sitting in the earlier snapshot for anyone who checks out that commit. So the discipline is: write your .gitignore before your first commit, so the secret never gets in. If one does slip through, the only correct response is to assume it's compromised, rotate the credential immediately (generate a new key, revoke the old one), and only then worry about scrubbing history. Ignoring is prevention, not a cure — as the danger note below hammers home.
Follow it left to right: every file in your folder passes through the .gitignore filter, which sorts them into two piles.
- The left blue box is everything sitting in your project folder — source you wrote plus junk and secrets.
- The middle box is
.gitignore, the bouncer's "do not admit" list; the arrows fan out to the two outcomes. - The green box is what Git tracks (your real source); the red box is what it pretends doesn't exist — caches, virtualenvs, and secrets like
.env. - The amber warning is the trap that makes this "advanced": the filter only applies to files not already tracked. Add a secret to
.gitignoreafter committing it and it stays in history — you must rotate the credential.
In short: write .gitignore before the first commit so junk and secrets never get in; ignoring afterward is too late.
.gitignore.venv/
__pycache__/
*.pyc
.env # secrets/API keys — NEVER commit
.DS_Store
dist/
build/
*.log
A .gitignore file is a plain list of things Git should pretend it can't see. Without it, you'd accidentally commit generated files, caches, and — most dangerously — secrets. Each line is one pattern to ignore.
.venv/and__pycache__/(the trailing/means "a folder") skip your virtual environment and Python's compiled cache — big, machine-specific, and pointless to share.*.pycuses*as a wildcard: ignore every file ending in.pyc. Same idea for*.log..envis the important one — it holds secrets/API keys. The comment shouts NEVER commit it, because a committed secret is exposed forever (see the warning below the code)..DS_Store,dist/, andbuild/are OS clutter and build output — regenerated automatically, so they don't belong in history.
Try this: Create a file called secret.env, add the line .env to .gitignore, then run git status. If the pattern matches, the file simply won't appear in the list of things to commit — Git is ignoring it.
.gitignore your .env BEFORE the first commit; if a secret ever lands, rotate it immediately (and consider history rewriting).6 · Advanced — read Git from Python advanced
Somebody on your team is writing the release notes by hand, and it shows. Every release, they scroll back through the commits, try to remember what shipped, and paste a half-remembered summary into an email. It's tedious, it's error-prone, and it's completely unnecessary — because the information is already there, structured, in the commit history. This is the moment Git stops being something you type at and becomes something you program against: the same history you've been browsing by eye, a script can read and turn into changelogs, version bumps, dashboards, and repo analytics.
The key realization is that Git is, at heart, a database of your project's history — and like any database, you can query it from code instead of clicking through it. You don't need a special library to start; the plainest, most durable approach is to shell out: have your Python run the exact same git commands you'd type by hand and capture their text output, using the subprocess module you met in DF1. Think of it as your program picking up the same tools you use manually — it's just automation of the commands you already know. The trick that makes it clean is --pretty=format: (and its cousin --format=), which tells git log to print exactly the fields you want in a shape that's trivial to parse, rather than the human-friendly default.
Reading Git this way opens a door, but it comes with one habit worth forming early. The common mistake is parsing raw, unstructured commit messages and hoping they're consistent — they never are, because humans wrote them freehand ("fixed the thing", "WIP", "asdf"). The lab below sidesteps this by using conventional commits — messages with a machine-readable type: description shape (feat: login, fix: null check) — which is precisely why the parsing is a one-liner. That's the whole thesis of this chapter's back half: if you impose a small convention on how commits are written (§9 enforces it), you get automation like this and semantic versioning (§7) almost for free. One more practical note about the code: it builds a throwaway repo in a temp folder and commits into it, so the example runs anywhere with zero setup and can't touch your real work — a pattern worth stealing whenever you want to demo Git behaviour safely.
Release tooling and dashboards read Git programmatically. Shell out with subprocess (DF1) and parse. This builds a throwaway repo so it runs anywhere, error-free.
git log for just the subject lines, split each on its type: prefix, group by type — and out falls a changelog, no hand-editing.
Read it left to right as a pipeline: raw history in on the left, a finished changelog out on the right, one transform per box.
- The left box shells out to
git log --pretty=format:%s, which prints just the subject line of every commit instead of the human-friendly default. - The middle two boxes split each line on its
:to separate thetypefrom the description, then file the descriptions under their type in a dictionary. - The green box is the payoff: printing each type as a section header with its messages as bullets — a grouped changelog assembled entirely from commit text.
- The caption's point — conventional messages make each step a one-liner — is why the structure (
feat: …,fix: …) matters: freehand messages wouldn't parse.
In short: query subject lines → split on : → group by type → print. Convention in, automation out.
changelog.pyimport subprocess, tempfile
from pathlib import Path
repo = Path(tempfile.mkdtemp())
def git(*a): return subprocess.run(["git", *a], cwd=repo, capture_output=True, text=True).stdout.strip()
git("init", "-q"); git("config","user.email","a@b.c"); git("config","user.name","D")
for msg in ["feat: login", "fix: null check", "feat: logout", "chore: bump deps"]:
(repo/"f.txt").write_text(msg); git("add","."); git("commit","-q","-m",msg)
log = git("log", "--pretty=format:%s").splitlines()
groups = {}
for line in log:
kind = line.split(":")[0]
groups.setdefault(kind, []).append(line.split(": ",1)[1])
for kind in ("feat", "fix", "chore"):
if groups.get(kind):
print(f"## {kind}"); [print(" -", x) for x in groups[kind]]
## feat
- login
- logout
## fix
- null check
## chore
- bump deps
This Python program answers a real release question — "what changed since last time?" — by reading Git from code instead of by hand. It builds a tiny throwaway repo, adds a few commits, then groups their messages into a changelog. It runs anywhere because it creates its own repo in a temp folder.
repo = Path(tempfile.mkdtemp())makes a brand-new empty folder, and thegit(*a)helper runs any Git command inside that folder (cwd=repo) and hands back its text output. This is how Python "talks to" Git — by running the same commands you'd type.- The
for msg in [...]loop creates four commits, each with a conventional message likefeat: loginorfix: null check— a type, a colon, then a description. git("log", "--pretty=format:%s")asks Git for just the subject line of each commit..split(":")[0]pulls the type (feat/fix/chore) andgroups.setdefault(kind, []).append(...)files each message under its type.- The final loop prints a section header (
## feat) and a bullet per message — a finished, human-readable changelog.
What the output means: A grouped changelog: a ## feat section listing login and logout, a ## fix section with null check, and a ## chore section with bump deps — assembled entirely from the commit messages.
Try this: Add "docs: readme" to the list of messages. It won't appear in the output yet — because the last loop only prints feat, fix, and chore. Add "docs" to that tuple to include it.
7 · Professional — semantic versioning from commits professional
Ever updated a library from 3.2.1 to 3.2.2 without a second thought, but hesitated before jumping to 4.0.0? That instinct is semantic versioning working exactly as designed. Those three numbers aren't arbitrary — they're a promise the author is making to you about how risky the upgrade is. A change in the last number means "safe, just bug fixes"; the middle means "new stuff, but nothing you relied on broke"; the first means "watch out, I changed something you'll have to adapt to." Learning to read and set those numbers correctly is a mark of professional maturity, and this section shows how to compute them automatically from your commits.
The scheme is MAJOR.MINOR.PATCH, and the cleanest way to remember it is as a set of promises in decreasing order of importance. PATCH (the rightmost) is "I fixed a bug; your code should still work exactly as before." MINOR (the middle) is "I added a feature; your existing code still works, but there's new capability if you want it." MAJOR (the leftmost) is the serious one: "I broke backward compatibility; upgrading may require you to change your code." A useful analogy is editions of a textbook: fixing typos is a patch reprint, adding a new chapter is a minor edition, but renumbering all the chapters so every reference in your notes is now wrong — that's a new major edition. The rule that ties it together: a bigger bump always wins over a smaller one in the same release, and bumping a higher number resets the lower ones to zero (1.4.2 with a new feature becomes 1.5.0, not 1.5.2).
Now the payoff, and the reason this sits right after the changelog section: if your commits already declare their type via conventional commits, a program can decide the version bump for you with no human judgement at all. A fix: commit means patch, a feat: means minor, and a commit flagged BREAKING means major — so the highest-severity change since the last release dictates the next number. The mistake to avoid — and it trips people up because these numbers look like decimals — is thinking 1.9.0 is "almost 2.0" or that 2.9 rolls over to 3.0. They aren't decimals; each field is an independent counter, so the minor after 2.9.0 with a new feature is 2.10.0, not 3.0.0. The version only crosses to a new major when something actually breaks, never by "running out of" minor numbers. The lab below is a compact version of the exact engine inside real release tools like semantic-release.
Conventional commits let you compute the next version automatically: a feat bumps minor, a fix bumps patch, a breaking change bumps major. This is how release automation works.
fix touches PATCH, a feat touches MINOR (patch→0), a breaking change touches MAJOR (minor and patch→0). They are counters, not decimals.
Start at the left with today's version 1.4.2 split into its three independent counters, then follow each arrow to see what a given kind of commit turns it into.
- The three coloured cells are MAJOR . MINOR . PATCH — red, amber, blue from left to right, matching the arrows.
- Each arrow is one commit type: a
fix→1.4.3(patch +1), afeat→1.5.0(minor +1, patch reset), aBREAKINGchange →2.0.0(major +1, both lower reset). - The note "highest-severity change wins" is the rule for a release with many commits: one
BREAKINGamong fifty fixes still makes it a major bump. - The bottom line is the counter-not-decimal point: bumping a part resets everything to its right to 0, which is why
2.9.9+ a feat is2.10.0, never3.0.
In short: fix→patch, feat→minor, breaking→major; the biggest bump present wins and zeroes the fields below it.
semver.pydef next_version(current, commits):
major, minor, patch = map(int, current.split("."))
bump = "patch"
for c in commits:
if "BREAKING" in c: bump = "major"; break
if c.startswith("feat") and bump != "major": bump = "minor"
elif c.startswith("fix") and bump == "patch": bump = "patch"
if bump == "major": return f"{major+1}.0.0"
if bump == "minor": return f"{major}.{minor+1}.0"
return f"{major}.{minor}.{patch+1}"
print(next_version("1.4.2", ["fix: typo"])) # 1.4.3
print(next_version("1.4.2", ["feat: new endpoint", "fix: x"])) # 1.5.0
print(next_version("1.4.2", ["feat: x", "BREAKING: drop v1"])) # 2.0.0
1.4.3
1.5.0
2.0.0
This computes the next version number automatically from your commits. Versions look like MAJOR.MINOR.PATCH (e.g. 1.4.2): a bug-fix bumps the last number, a new feature bumps the middle, and a breaking change bumps the first and resets the rest. This is exactly how professional release tools decide the version.
major, minor, patch = map(int, current.split("."))splits"1.4.2"into the three numbers1,4,2so we can do math on them.bumpstarts at"patch"(the smallest change) and only ever gets bigger as the loop scans commits: afeatraises it to"minor", and the wordBREAKINGraises it all the way to"major"and stops early withbreak.- The three
if bump == ...lines at the end build the new version string:majorresets minor and patch to0(2.0.0);minorresets only patch (1.5.0); otherwise just add one to patch (1.4.3). - The three
print(...)calls test it on sample commit lists, with the expected answer in the comment beside each.
What the output means: Three version numbers, one per test: 1.4.3 (only a fix), 1.5.0 (a feat present), and 2.0.0 (a BREAKING change wins over everything).
Try this: Call next_version("2.9.9", ["feat: x"]) and predict the answer before running: a feat bumps minor and resets patch, so you should get 2.10.0 (note 9 becomes 10, not carrying over — versions aren't decimals).
8 · Professional — connect to GitHub professional
Everything you've built so far lives in exactly one place: the hard drive in front of you. Spill coffee on the laptop, and the entire project — every commit, every carefully written message — is gone. And nobody else can see it, review it, or contribute. A repository that only exists on your machine is a diary; connecting it to a remote turns it into a shared, backed-up, collaborative project. This is the step where your local Git becomes a team sport, and where "GitHub" (the website most people conflate with Git itself) finally enters the picture.
The crucial thing to understand is that a remote is just another full copy of your repository that happens to live on a server. It's not special or authoritative by nature — GitHub holds the same commits your laptop does, and your laptop is every bit as complete a copy. This is what "distributed" means in "distributed version control": there's no single master machine everyone depends on, just clones that sync with each other. By convention the primary remote is nicknamed origin (that's just a friendly label for a long URL), and the primary branch is called main. The mental model to hold: think of the remote as a shared cloud drive for your history, and push/pull as the sync buttons in both directions.
Which is exactly where the beginner mistake hides. push and pull are not automatic — Git never talks to the remote behind your back. Nothing you commit locally appears on GitHub until you explicitly push, and no teammate's work reaches you until you pull. New developers routinely commit all day, assume it's "saved to GitHub," and are baffled when a colleague can't see any of it — because it never left the laptop. The -u flag in git push -u origin main is a small kindness: it links your local branch to its remote counterpart once, so afterwards a bare git push and git pull know where to go without you respelling the destination every time. And git clone is simply this whole relationship established in reverse — it downloads a complete existing repository (all its history included) onto your machine, which is how you start working on any project that already lives on GitHub. Collaboration proper — branches, pull requests, resolving conflicts — is DF3; this section is just getting your history off the island.
origin hold the same history; nothing syncs until you explicitly push (send up) or pull (bring down).
Two boxes, two directions: your machine on the left, the GitHub copy (origin) on the right, and an arrow for each way work flows between them.
- Both boxes say "full copy of history" on purpose — that is what "distributed" means: neither is more authoritative, they're just clones that sync.
- The blue top arrow,
git push, sends your local commits up toorigin; the green bottom arrow,git pull, brings teammates' commits down to you. - The line underneath is the beginner trap: neither arrow fires on its own. Commit all day and nothing reaches GitHub until you
push— which is why a colleague can't see work you never pushed.
In short: push = send up, pull = bring down, both manual; clone is the first pull that creates the local copy.
remote.shgit remote add origin git@github.com:you/proj.git
git branch -M main
git push -u origin main # push + set upstream
git push # thereafter
git pull # get others' commits
git clone git@github.com:you/other.git
So far everything lived only on your computer. These commands connect your local repo to a remote — a hosted copy on GitHub — so you can back it up and share it. A remote is just another copy of the same repo that lives on a server.
git remote add origin git@github.com:you/proj.gittells your repo where the GitHub copy lives and nicknames itorigin(the conventional name for "the main remote").git branch -M mainrenames your current branch tomain— the standard name for the primary line of work.git push -u origin mainuploads your commits and, thanks to-u, remembers the link so that afterwards a baregit push(upload) andgit pull(download others' commits) just work with no extra arguments.git clone git@github.com:you/other.gitis the reverse: it downloads a whole existing repo from GitHub onto your machine — how you start working on someone else's project.
Try this: Think of push and pull as sync: push sends your local commits up to GitHub, pull brings teammates' commits down. Nothing on the remote changes until you push.
9 · Tech-lead — enforce conventions with a hook tech-lead
Here's a pattern every tech lead learns the hard way. You introduce conventional commits to the team, everyone nods, and for two weeks the history is beautiful. Then a new engineer joins, doesn't know the rule, and writes update. A senior in a hurry writes fix. Someone commits asdf at 2am. Within a month your changelog automation is producing garbage and your version bumps are wrong — because a convention that depends on everyone remembering is a convention that's already failing. The lead's job in this final section is to stop relying on discipline and start relying on enforcement.
The tool for this is a Git hook: a script Git runs automatically at a specific moment in its workflow, without anyone asking. The one we want is commit-msg, which fires the instant you finish writing a commit message and gets handed the message to inspect — it can approve the commit or reject it outright. The mental model is a spell-checker that refuses to let you send the email: instead of catching the bad message later in code review (by which point it's already in someone's history), the hook catches it at the source, at commit time, and simply won't let a non-conforming message through. How does a hook say "no"? Through the oldest convention in Unix: its exit code. Exit 0 means "all good, proceed"; any non-zero exit (typically 1) means "stop," and Git aborts the commit. That single number is the entire mechanism.
Two things about hooks routinely surprise people, and both matter if you're the one setting this up. First, enforce at the right layer: a local hook is instant feedback for the developer, but the well-known mistake is trusting it alone — hooks live in each person's local repo, so someone can bypass one with --no-verify or simply not have it installed. The professional setup pairs the local hook (fast feedback) with the same check running in CI (the real gate nobody can skip). Second, and this catches nearly everyone: the .git/hooks directory is not part of your repository — it doesn't get committed, so cloning the repo doesn't give teammates your hook. To share a hook you commit the script into a tracked folder (say hooks/) and point Git at it with git config core.hooksPath hooks, or adopt a framework like pre-commit that manages this for you. Get this right and the whole back half of the chapter clicks into place: enforced conventions (§9) make the changelog (§6) and semantic versioning (§7) automatic and trustworthy — which is exactly the leverage that separates a tech lead from an individual contributor.
A lead makes history useful and automatable by mandating a commit convention and enforcing it with a hook (and in CI). Then changelogs (§6) and versioning (§7) come for free.
Follow the commit left to right: you write a message, the hook inspects it, and the path forks on the result.
- The left box is you finishing a commit message; Git hands that text to the
commit-msghook automatically — you don't call it. - The middle box is the hook checking the message against the approved pattern (a type like
feat/fix, then:and a description). - The fork is decided by the hook's exit code: the green
exit 0path saves the commit; the redexit 1path aborts it. That single number is the entire mechanism — like a spell-checker refusing to send the email.
In short: the hook runs at commit time and returns 0 to allow or non-zero to block — pair it with the same check in CI, since local hooks can be skipped.
commit_msg_hook.pyimport re
PATTERN = re.compile(r"^(feat|fix|docs|refactor|test|chore)(\(.+\))?: .{1,72}$")
def valid(message: str) -> bool:
first = message.strip().splitlines()[0] if message.strip() else ""
return bool(PATTERN.match(first))
for msg in ["feat: add login", "fixed stuff", "fix(auth): handle null token", "wip"]:
print(f"{'OK ' if valid(msg) else 'REJECT'} {msg}")
# real hook (.git/hooks/commit-msg): sys.exit(0 if valid(open(sys.argv[1]).read()) else 1)
OK feat: add login
REJECT fixed stuff
OK fix(auth): handle null token
REJECT wip
A hook is a script Git runs automatically at a certain moment — here, right after you write a commit message. This one checks the message follows the team's convention and rejects the commit if it doesn't, so bad messages never reach history in the first place.
PATTERN = re.compile(r"^(feat|fix|docs|refactor|test|chore)(\(.+\))?: .{1,72}$")is a regular expression — a pattern for text. In plain English it demands: one of those approved types, an optional(scope), then:and a description of 1–72 characters.valid(message)takes the first line of the message (.splitlines()[0]) and returnsTrueonly if it matches the pattern —bool(PATTERN.match(first)).- The
forloop feeds four sample messages throughvalidand printsOKorREJECTfor each, so you can see the rule in action. - The final comment shows the real hook: in an actual
.git/hooks/commit-msgfile it wouldsys.exit(0)to allow the commit orsys.exit(1)to block it — a non-zero exit is how a hook says "no".
What the output means: Four lines: feat: add login and fix(auth): handle null token pass (OK), while fixed stuff and wip fail (REJECT) because they don't start with an approved type and colon.
Try this: Add "docs: update readme" to the list — it passes. Then try "feat add login" (no colon) — it's rejected, because the pattern requires the : separator.
Exercise DF2.1 — Version + automate a repo
Context: Put the whole chapter together on one repo: conventions in, automation out, plus the recovery move that lets you experiment without fear. This is the daily rhythm of a well-run project.
Your task: Init a repo with a proper .gitignore, make conventional commits, then use the changelog and semver logic to generate release notes and the next version — add the commit-msg hook, and deliberately reset then recover a commit.
Requirements:
- Start from a
.gitignorethat keeps env/caches/secrets out - Make several conventional commits (
feat:,fix:,chore:…) - Generate grouped release notes and compute the next version from those commits
- Install the commit-msg hook and confirm it rejects a non-conforming message
- Drop a commit with
git reset, then recover it viagit reflog+ checkout
💡 Hint: The reflog records every state HEAD was ever at, so a "lost" commit after a bad reset is almost always still recoverable — that safety net is the point of the exercise.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Every project's history starts the same way: turn a plain folder into a repo and make a first snapshot. This is the working-tree → staging → history trip that every later Git workflow is built on.
Your task: Create a new repo in an empty folder, add a file hello.txt, and make your first commit with a clear message — showing the change travel through all three areas.
Requirements:
- Initialize the repo with
git init - Create the file, then confirm it shows as untracked with
git status - Stage it with
git add hello.txt(working tree → staging) - Commit with
git commit -mand a message that says what and why - Verify the snapshot landed with
git log --oneline
💡 Hint: Write the message in the imperative mood describing the change — not "stuff" or "wip" — because that line is your only context six months later.
Show solution
The everyday loop is status → add → commit:
git init
echo "hello" > hello.txt
git status # hello.txt is untracked (working tree)
git add hello.txt # now staged
git commit -m "Add hello.txt with a greeting"
git log --oneline # one commit in history
The message says what and why in the imperative mood, not "stuff" or "wip".
Context: In real work you often touch several files but want each commit to be one focused change. Reading the diffs and staging selectively is how you keep history reviewable.
Your task: You edited two files but only want to commit one. Show the diff of unstaged changes, stage a single file, confirm exactly what is staged, then commit only that file.
Requirements:
- View unstaged edits with
git diff(working tree vs staging) - Stage just the intended file with
git add <file> - Confirm what will be committed with
git diff --staged - Use
git statusto see one file staged and the other still modified - Commit only the staged file with a focused message
💡 Hint: Remember the two diffs answer different questions: plain git diff is working-vs-staged, git diff --staged is staged-vs-last-commit.
Show solution
git diff # unstaged changes across all files
git add report.py # stage just this one
git diff --staged # exactly what will go into the commit
git status # report.py staged, notes.txt still modified
git commit -m "Fix off-by-one in report totals"
git diff = working vs staged; git diff --staged = staged vs last commit. Committing a focused change keeps history reviewable.
Context: Git's safety net is why you can experiment fearlessly: almost every mistake before you push is reversible. Knowing the three everyday undos means you never fear breaking your local repo.
Your task: Practise three reversible undos — unstage a file added by accident, discard uncommitted edits to a file, and reword the commit you just made — and explain why none of them rewrite shared history.
Requirements:
- Un-stage but keep the edit with
git restore --staged <file> - Discard local edits to a tracked file with
git restore <file> - Reword the most recent unpushed commit with
git commit --amend - Explain that all three touch only local state
- Note that a shared commit should be undone with
git revert <sha>, not reset
💡 Hint: The dividing line is whether a commit has been pushed: rewriting local history is safe, rewriting history teammates already pulled is not.
Show solution
# 1. unstage but keep the edit in the working tree
git restore --staged secrets.env
# 2. throw away local edits to a tracked file (irreversible for that edit)
git restore config.py
# 3. reword the most recent (unpushed) commit
git commit --amend -m "Add rate limiter to the API client"
All three touch only local state. Prefer git revert <sha> (a new inverse commit) over reset once a commit is shared, because rewriting pushed history breaks teammates' clones.
Context: A committed secret is compromised forever — history is permanent, so deleting a key in a later commit doesn't erase it. A correct .gitignore is the first line of defence, and handling an already-tracked secret is the harder real case.
Your task: Write a .gitignore for a Python project that excludes the virtualenv, byte-code, and a .env secrets file — then handle a secret that was already committed.
Requirements:
- Ignore the environment and caches (
.venv/,__pycache__/,*.pyc) - Ignore the secrets file
.envand any*.keyfiles - Use trailing
/for folders and*as a wildcard - Stop tracking an already-committed secret with
git rm --cached .env(file stays on disk) - State that a secret already in history must be rotated and scrubbed (filter-repo / BFG)
💡 Hint: Ignoring only prevents future tracking — if the key is already in a past commit, assume it is compromised and rotate it regardless.
Show solution
# .gitignore
__pycache__/
*.pyc
.venv/
.env
*.key
.DS_Store
Ignoring only prevents future tracking. If .env is already tracked, stop tracking it (file stays on disk):
git rm --cached .env
git commit -m "Stop tracking .env; add to .gitignore"
If the secret is in history, ignoring is not enough — rotate the credential and scrub history with git filter-repo (or BFG). Assume any pushed secret is compromised.
Context: Conventional commit messages are machine-readable, so the next version number can be computed instead of guessed. This is the engine inside tools like semantic-release.
Your task: Read the repo's tags and commit subjects from Python and compute the next semantic version: a feat: since the last tag bumps minor, fix:/other bumps patch, and a BREAKING note bumps major.
Requirements:
- Shell out to git (via
subprocess) to read the last tag and commits since it - Parse
MAJOR.MINOR.PATCHout of the tag, defaulting to 0.0.0 when none exists - Scan commit subjects:
BREAKING→ major,feat→ minor, else patch - A bigger bump wins and a major reset sends minor and patch to 0
- Print the current tag and the computed next version
💡 Hint: Let the bump level only ever escalate as you scan — start at patch, raise to minor on a feat, and jump straight to major (breaking out) the moment you see BREAKING.
Show solution
Runnable — shells out to git, works in any repo with at least one commit:
import subprocess, re
def sh(*args):
return subprocess.run(["git", *args], capture_output=True,
text=True).stdout.strip()
def last_tag():
t = sh("describe", "--tags", "--abbrev=0")
return t or "v0.0.0"
def commits_since(tag):
rng = f"{tag}..HEAD" if tag != "v0.0.0" else "HEAD"
log = sh("log", rng, "--format=%s%n%b")
return log.splitlines()
def next_version():
tag = last_tag()
major, minor, patch = map(int, re.findall(r"\d+", tag)[:3] or (0, 0, 0))
lines = commits_since(tag)
bump = "patch"
for ln in lines:
if "BREAKING" in ln:
bump = "major"; break
if ln.startswith("feat") and bump != "major":
bump = "minor"
if bump == "major": major, minor, patch = major + 1, 0, 0
elif bump == "minor": minor, patch = minor + 1, 0
else: patch += 1
return f"v{major}.{minor}.{patch}"
print("current:", last_tag(), "-> next:", next_version())
This is the core of tools like semantic-release: the commit convention becomes machine-readable release automation.
Context: New engineers keep writing messages like "fix" or "update", which quietly breaks every downstream automation. Enforcing the convention at commit time — not in review — is what actually keeps history clean.
Your task: Add a commit-msg hook that rejects any message not matching Conventional Commits, so the rule is enforced automatically before a bad message reaches history.
Requirements:
- Match the first line against a regex of approved types (
feat|fix|docs|refactor|test|chore…) with optional(scope)and a:description - Read the message from the file path Git passes as
sys.argv[1] - Print a helpful example and
sys.exit(1)to abort when it doesn't match - Install it at
.git/hooks/commit-msgand make it executable (chmod +x) - Note that
.git/hooksisn't versioned, so share it via a trackedhooks/dir +core.hooksPath(or pre-commit)
💡 Hint: A non-zero exit is how a hook says "no" — return 0 to allow the commit and 1 to block it, and keep the pattern anchored to the first line only.
Show solution
Save as .git/hooks/commit-msg and chmod +x it. The hook gets the message file path as $1:
#!/usr/bin/env python3
import sys, re
PATTERN = re.compile(
r"^(feat|fix|docs|refactor|test|chore|perf)(\(.+\))?: .{1,}")
msg = open(sys.argv[1]).readline().rstrip("\n")
if not PATTERN.match(msg):
print("commit rejected: message must be Conventional Commits, e.g.")
print(" feat(auth): add token refresh")
print(f"got: {msg!r}")
sys.exit(1)
A non-zero exit aborts the commit. To share it with the team, commit the script under a tracked hooks/ dir and wire it with git config core.hooksPath hooks (or use pre-commit), since .git/hooks itself is not versioned.
✓ Checkpoint — you can move on when you can…
- Run the repo→stage→commit loop with good messages.
- Inspect history and undo/recover at every stage.
- Read Git from Python for changelogs + version bumps.
- Enforce team commit conventions with a hook.
Knowledge check check yourself
What are Git's three areas, and which command moves a change between each pair?
Show answer
git add → Staging (changes chosen for the next snapshot) → git commit → the repository/history (a permanent snapshot).Under conventional commits, how do a fix, a feat, and a breaking change each affect the next semantic version?
Show answer
fix bumps the patch number, a feat bumps the minor (resetting patch to 0), and a breaking change bumps the major (resetting minor and patch to 0) — e.g. 1.4.2 → 1.4.3, 1.5.0, or 2.0.0 respectively.