Team training · Block 1 of 6
The local model
How git stores your work, before anyone else is involved
Opening frame. Say: this block has no remotes, no team, no rewriting. Everything here happens on your own machine and nothing you do can affect anyone else. That's deliberate — we build the model first, then add risk.
Expect ~88 minutes. All commands are demoed; nobody needs a laptop open. Every slide with a kata footer links to an exercise you can run yourself afterwards.
What we'll cover
Six ideas, in dependency order
# Idea You'll be able to
1.1 The three trees Say where any file currently sits
1.2 init, add, commit Describe the minimum sequence
1.3 Staging as composition Explain why staging exists
1.4 Commit as snapshot Say what a commit actually stores
1.5 Branch as pointer Explain why branching is cheap
1.6–1.7 Merging Predict the graph shape
Don't dwell. The point of showing this is that the order is not arbitrary — each row depends on the one above it. Mention that if anything in an earlier row is shaky, later rows will feel like magic incantations rather than mechanisms.
1.1
The three trees
~12 minutes, concept only. No commands run in anger yet.
Git has three places your work can be
Working directory
The files you edit
Ordinary files on disk
Staging area
What you've chosen
to include next
Repository
Committed history
Permanent, in .git
add
commit
Two commands, two moves. Everything else in git is a variation on this.
The single most useful diagram in the whole course. Come back to it whenever someone looks lost.
Emphasise: the middle box is the one that has no equivalent in Dropbox, Google Drive, or any "save file" mental model. It's the reason git feels different, and it's the thing most people never form a clear picture of.
Say out loud: "add" does not mean "add a new file". It means "add this version of this file to the set I'm about to commit." That misreading causes a lot of confusion later.
A file is always in one of four states
State Meaning Gets committed?
untrackedGit has never seen it No
modifiedChanged since last commit No
stagedMarked for the next commit Yes
committedSafely in history Already is
The same file can be staged and modified at once — staged version A, then kept editing to version B.
The last line is the one worth pausing on. It surprises people, and it's the clearest proof that staging is a real separate place rather than a flag on a file.
Concrete framing: you stage a file, then keep typing. The staged copy is frozen at the moment you ran add. If you commit now, you commit version A — your newer edits stay behind in the working directory. This trips people up constantly and explains a lot of "why didn't my change go in?" moments.
Reading git status
$ git status
On branch main
Changes to be committed:
modified: invoice.py <- staged
Changes not staged for commit:
modified: README.md <- modified, not staged
Untracked files:
notes.txt <- git has never seen this
Three headings, three of the four states. Status is the three trees, printed.
Run this live against the demo repo rather than reading the slide. Make three different kinds of change first so all three sections appear.
Point at each heading and map it back to the diagram from two slides ago — left box, middle box, and "not in any box yet". People who learn to read status this way stop guessing.
Worth mentioning: git status is safe to run constantly. It changes nothing. Encourage it as a reflex.
Why we start here
Uncommitted work is the only work git cannot get back for you.
Commandment 01
Commit early, commit often.
Land this properly — it's the first commandment and it justifies most of Block 2.
The claim is literal, not motivational. In Block 2 we will destroy commits four different ways and get every one of them back. The single thing we will not be able to recover is a working-directory change that was never committed. Committing is what moves work into the recoverable zone.
Reframe for the sceptics in the room: a commit is not a publication. Nobody sees it. Commit messages on a branch get squashed away when the PR merges. So there is no cost to committing often and no audience to impress.
1.2
init, add, commit
~5 minutes, demo only. Deliberately brisk — this is the part most people already know.
Starting from nothing
$ git init
Initialized empty Git repository in /work/pricing/.git/
$ git status
On branch main
No commits yet
Untracked files:
invoice.py
.git is the repository. Delete that folder and you delete the history — the files stay.
Run it. Then run ls -a to show the .git directory actually appearing — people rarely see this.
Optionally ls .git to show refs/, objects/, HEAD. Don't explain them yet; just establish that the repository is a directory of ordinary files, not a service. This pays off in Block 4 when we say a clone is a complete repository.
The first commit
$ git add invoice.py
$ git commit -m "Add invoice total calculation"
[main (root-commit) 6afb878] Add invoice total calculation
1 file changed, 2 insertions(+)
$ git log --oneline
6afb878 Add invoice total calculation
That short hex string is the commit's identity. We'll come back to where it comes from.
Flag but do not explain the hash — 1.4 covers it. Just plant that it's an identity, not a sequence number. Someone usually asks "why isn't it commit #1" and you can defer that to 1.4.
Mention -m exists to avoid opening an editor, and that without it git opens whatever core.editor is set to. Everyone should have set that in pre-flight; if anyone hasn't, this is where they'd meet vim.
1.3
Staging as composition
~13 minutes. The densest section in Block 1 and the one worth slowing down for. If people leave with only one concept from today, this should be it.
The staging area is a drafting table
You've fixed a bug and renamed a variable and added a debug print.
Three changes, one working directory.
Without staging, your only option is to commit all of it together.
Staging lets you choose which changes go into the next commit — not just which files.
This is the "why", and it's the part that's usually skipped. Most tutorials present add as a chore you do before commit. It isn't — it's the mechanism that makes commandment 2 achievable.
Concrete story worth telling: you're deep in a bug fix and you notice a typo in a comment three files away. You fix it. Now those two unrelated things are tangled in your working directory. Staging is how you untangle them into two commits without reverting anything.
Two diffs, two questions
$ git diff
What have I changed that is NOT staged?
(working directory vs staging area)
$ git diff --cached
What have I staged, ready to commit?
(staging area vs last commit)
If both are empty, everything is committed. If git diff is empty but --cached isn't, you're ready to commit.
Demo both against a repo with one staged and one unstaged change, so they visibly differ. This is the moment the staging area stops being abstract.
The naming is genuinely bad and worth acknowledging: --cached, --staged, and (in newer git) plain git diff --staged all mean the same thing. Say so rather than letting people think they've missed a distinction.
Staging part of a file
$ git add -p invoice.py
@@ -1,3 +1,6 @@
def total(items):
return sum(items)
+
+def apply_discount(amount, pct):
+ return amount * (1 - pct / 100)
Stage this hunk [y,n,q,a,d,s,e,?]?
y stage it · n skip it · s split it smaller · q stop
The highest-leverage command in this block. Demo it on a file with two genuinely unrelated changes and stage only one.
Then run git diff and git diff --cached to show the split worked — one change in each. That's the payoff and it should feel slightly magical.
Only four of the letters matter in practice: y, n, s, q. Say that explicitly so the prompt stops looking intimidating.
Changed your mind
$ git restore --staged invoice.py # unstage, keep the edit
$ git restore invoice.py # discard the edit entirely
The second one is destructive. The edit was never committed, so git cannot get it back.
Call out the asymmetry deliberately. One is a bookkeeping change, the other destroys work permanently. They differ by one word.
This is the first appearance of the only truly unrecoverable operation in git, and it reinforces commandment 1 from ten minutes ago. Say the connection out loud.
If anyone asks about git reset HEAD file — that's the older spelling of the first one. restore is the modern, clearer form and is what we'll use.
What this buys you
Commandment 02
One logical change per commit. If the message needs an "and", it's two commits.
Commandment 03
Read your own diff before you commit.
Both are now achievable rather than aspirational, because they just saw add -p.
Important caveat given our squash-merge setup: atomic commits do NOT keep trunk tidy — the squash already does that. They exist so a reviewer can walk your branch commit by commit. Say this explicitly, because "for clean history" is demonstrably false on our setup and someone will notice.
On commandment 3: git diff --cached costs ten seconds and catches the debug print. Frame it as the cheapest code review available.
1.4
Commit as snapshot
~14 minutes. Where experienced attendees most often learn something — the diff misconception is widespread.
A commit is not a diff
What people assume
A commit stores the changes since last time. History is a stack of patches, replayed to rebuild a version.
What actually happens
A commit stores a complete snapshot of every tracked file, plus a pointer to its parent.
Git shows you diffs because they're useful to read. It doesn't store them.
Ask the room which one they believed. Usually a real split, including among senior people. It's a good moment because it's a genuine correction rather than a beginner topic.
Pre-empt the storage objection, someone always raises it: yes, identical files are stored once and shared between commits, and git compresses aggressively into packfiles. But that's an optimisation underneath. The model you reason with is snapshots.
Why it matters: nearly everything in Block 2 and 3 makes sense only under the snapshot model. Checking out an old commit is cheap because it's just laying down a stored tree, not replaying a hundred patches.
What's inside a commit
$ git cat-file -p HEAD
tree 772d013dfa36ba8fb50288a206dc8660f0e5e168
parent c584509a53315022e69818a160d73401bd600954
author Demo User <demo@example.com> 1785127120 +0000
committer Demo User <demo@example.com> 1785127120 +0000
Add tax calculation
A snapshot (tree), a link backwards (parent), who and when, and a message.
But the tree is itself just a hash. Follow it down and you reach the files — and their actual bytes.
$ git cat-file -p 772d013 # what the tree holds
100644 blob 9a1f0c2 README.md
100644 blob 3b8e5d7 invoice.py
$ git cat-file -p 3b8e5d7 # what that blob holds
def total(items):
return sum(items)
commit → tree → blob. The blob is your file's real content; the snapshot is the whole chain.
Run it live. It demystifies the whole thing — people are often surprised how little is in the commit object itself.
Point at parent specifically. That single line is what turns a pile of snapshots into a history, and it's what we'll be moving around for the rest of the course.
Then reveal the drill-down: the commit doesn't contain the files, it points at a tree. The tree lists the tracked files and points each at a blob, and the blob is the file's actual bytes. Do this live — cat-file the commit, copy the tree hash, cat-file that, copy a blob hash, cat-file that, and out comes the source. Seeing the source code fall out the bottom of a chain of hashes is the moment "snapshot" stops being a metaphor.
This is why identical files across commits cost nothing: same content, same blob hash, stored once. Worth a sentence, no more.
Where the hash comes from
The hash is computed from the commit's entire content — snapshot, parent, author, timestamp, message.
Change any one of them and you get a different hash. Which means a different commit.
This is why you cannot edit a commit. You can only make a new one that resembles it.
This is the load-bearing slide for Block 3. Amend, rebase, and interactive rebase all "rewrite history" — and this explains why the word rewrite is misleading. Nothing is edited. New commits are created and the branch pointer is moved to them.
It also sets up the shared-history rule in Block 4: if your rewrite produces new hashes, and a colleague has the old ones, your histories have genuinely diverged.
Tie back to 1.1 if it helps: the commit is immutable because its name is derived from its contents.
Parents make a graph
6afb878
91ac5d3
e91e334
c584509
f6c6987
HEAD
Each commit points backwards to its parent. Git walks this chain to build history.
Stress the direction. Arrows point backwards, from child to parent. A commit knows its parent; it has no idea what came after it.
That asymmetry explains a lot later: it's why a commit can become unreferenced and invisible (nothing points to it) while still existing on disk — which is exactly what Block 2 exploits to recover lost work.
Establish the visual language now: amber circle means "where we are / what just changed". This convention runs through every graph in the course.
Reading history
$ git log --oneline --graph --decorate
* f6c6987 (HEAD -> main) Add end-to-end invoice_total
* c584509 Add tax calculation
* e91e334 Add README
* 91ac5d3 Add discount helper
* 6afb878 Add invoice total calculation
Worth an alias — you'll type it constantly:git config --global alias.lol "log --oneline --graph --decorate --all"
Set the alias live on the demo machine and use git lol for the rest of the course. People adopt what they see repeatedly.
Explain --decorate: it prints the pointers (HEAD, branch names, tags). Without it you see commits but not where anything is pointing, which hides the most important information on screen.
--all matters more once branches exist in 1.5. Mention it now, demonstrate it then.
Tags — a permanent name for a commit
$ git tag v2.1 # lightweight: a name pinned to a commit
$ git tag -a v2.1 -m "Release 2.1" # annotated: its own object, with a message
$ git log --oneline
f6c6987 (HEAD -> main, tag: v2.1) Add end-to-end invoice_total
A branch label moves forward every time you commit on it. A tag doesn't — it stays pinned to the one commit. That's why releases get tagged: a fixed name you can always come back to.
Tags don't travel on a normal push — git push origin v2.1, or git push --tags. We'll name a commit by its tag when we go bug-hunting in Block 6.
Kept short — tags only need to be on the radar. The one idea that matters: a tag is a fixed name, a branch is a moving one. Both are just pointers, exactly like everything else in this block.
Lightweight vs annotated: lightweight is a name and nothing more; annotated is a real object carrying a tagger, date and message, and is what you want for an actual release. For our purposes either is fine.
Callback to the previous slide: --decorate is what printed tag: v2.1 next to the commit. Tags show up in the same decoration as HEAD and branch names.
Forward reference: the bisect demo in Block 6 marks the last good release as git bisect good v2.1 — that v2.1 is one of these. Plant it now so it isn't a mystery there.
Warn that tags don't move on a plain push; people tag a release and then can't find it on the server.
Naming commits without the hash — counting back
HEAD
HEAD~1 · HEAD^
HEAD~2
HEAD^2
main
feature
merged in
HEAD^ and HEAD~1 both mean the first parent — one step back along main. HEAD~2 is two steps, HEAD~3 three: tilde walks the first-parent line. HEAD^2 is a different axis — the merge's second parent, i.e. the tip of the feature branch that was merged into HEAD.
reset --hard HEAD~2, git show HEAD^2 — position names work anywhere a command wants a commit, no hash required.
A reference slide, not a demo. HEAD~2 lands in Block 2's reset; HEAD^2 comes back the moment we start resolving merges.
The one that trips people: HEAD^ and HEAD~1 are the same thing (one step back), but HEAD^2 is not "two steps back" — it's the second parent, which only exists on a merge commit. Tilde walks the first-parent line; caret picks which parent.
Make the picture concrete: HEAD here is a merge commit. Its first parent is the main commit it advanced from (HEAD^ / HEAD~1); its second parent (HEAD^2) is the tip of the feature branch that got merged in. Point at the drop down to the lower line as you say "second parent".
The node colours tie each name to the commit it points at; we'll keep using that trick whenever a command refers to a specific commit.
Naming commits without the hash — ranges
common ancestor
main
feature
main..feature — feature only
main...feature — either side, not the ancestor
main..feature (two dots) — commits reachable from feature but not main : your branch's own work. main...feature (three dots) — commits on either side but not both: the full divergence. The common ancestor is in neither.
git log main..feature is the everyday "what's on my branch that isn't on main yet?" — named by range, never by hash.
Two-dot vs three-dot is the whole slide. Two-dot is asymmetric — "what's on the right that isn't on the left" — and is what you want almost always: git log main..feature is "my unmerged commits". Three-dot is the symmetric difference, mostly seen in git diff.
Use the fragments as a predict-then-reveal: with the branches diverged on screen, ask which commits main..feature selects before advancing. Advance 1 boxes the feature-only pair. Advance 2 swaps to the wider box — both sides, ancestor excluded.
The ranges come back in Block 6's log and investigation; this is the reference they'll lean on.
1.5
Branch as pointer
~14 minutes. Second-most-important concept in the block after staging.
A branch is a label on a commit
Not a copy. Not a folder. Not a directory of files.
A branch is a file in .git containing one hash — 41 bytes.
$ cat .git/refs/heads/main
f6c69875a5c1e28e3a34e50f7e1a2b9c4d8e6f01
That's the whole thing. Creating a branch writes one more of these.
Run the cat live. It's the single most demystifying command in the course — people arrive imagining branches as heavyweight objects and leave knowing they're a text file with a hash in it.
This is why branching is instant and why nobody should hesitate to create one. Contrast briefly with older centralised tools where branching meant server-side copying and was something you asked permission for.
HEAD is where you are
main
feature ← HEAD
HEAD is a pointer to a branch. The branch is a pointer to a commit. Two levels of indirection — that's the whole model.
Two levels is the bit people miss. HEAD usually points at a branch name, not directly at a commit. When it points straight at a commit instead, that's detached HEAD — flagged here, covered properly in Block 2.
Show cat .git/HEAD live: it prints "ref: refs/heads/main". Seeing the indirection written down makes it concrete.
Committing moves the pointer
4a91c02
main
← HEAD
feature
← HEAD
feature
← HEAD
$ git switch -c feature # step 1: HEAD hops to a new label
$ git commit -m "Add tax constant" # step 2: new commit, feature + HEAD advance
4a91c02 (HEAD -> feature) Add tax constant
f6c6987 (main) Add end-to-end invoice_total
feature moved forward. main stayed exactly where it was.
Walk the graph one fragment at a time and narrate each beat.
Start (no fragments): HEAD is on main, both sitting on the tip commit. This is the everyday state.
Advance 1 — git switch -c feature: nothing about the commits changes. A new feature label is written pointing at the same commit, and HEAD hops off main onto feature. Two labels, one commit. Point out no files moved and no commit was made.
Advance 2 — git commit: now a commit is created, and the branch HEAD points at (feature) advances to it, dragging HEAD along. main is untouched — still on the old tip. That asymmetry is the whole lesson.
The core mechanic to say out loud: commit advances the branch that HEAD currently points at, and leaves every other branch exactly where it was.
Note switch over checkout. Checkout does too many unrelated things and is a common source of accidents; switch and restore split its jobs apart. Mention checkout still works and they'll see it in older docs and Stack Overflow answers.
Run git lol after to show both branches at once — first real payoff for the --all flag.
1.6 — 1.7
Merging
~20 minutes for both. Concept-heavy; the graph shapes matter more than the commands.
Fast-forward
main
feature
the label just slides forward
main, feature
main has not moved since you branched. What should merging do?
No merge commit. There was nothing to reconcile — main contributed no commits of its own, so git just moves the label to catch up.
STOP on the first state. Ask the room what git should do before advancing. Give it a few seconds of silence — someone usually reasons it out, and the concept sticks far better when they've predicted it.
Advance 1: the arrow. Advance 2: the label lands. Advance 3: the conclusion.
"Fast-forward" is a genuinely good name for once — main catches up to where feature already is, like scrubbing a video forward.
Note our squash-merge PR setup means this rarely appears in the shared workflow, but it shows up constantly in local work and it's the necessary setup for three-way merge.
Both sides moved
main
feature
common ancestor
what changed?
two parents
main moved too. How can git possibly know what to keep?
By comparing each side against the ancestor. Two files that differ tell git nothing — but knowing what each side changed lets it combine them.
Again, stop on the first state and ask. This one is harder and the room may not get it — that's fine, the struggle is what makes the ancestor feel necessary rather than arbitrary.
Advance 1: the ancestor lights up. Advance 2: the comparison. Advance 3: the merge commit with two parents. Advance 4: the conclusion.
"Three-way" = three inputs: your side, their side, and the ancestor. Not three branches. The name confuses people every time — say it explicitly.
Plant the flag for Block 6: a conflict is precisely the case where both sides changed the same region relative to the ancestor, so git refuses to guess. The JetBrains three-pane merge view is literally this diagram — ours, theirs, and base.
Where we got to
Idea The one-line version
Three trees Working dir → staging → repository
Staging Choose which changes , not just which files
Commit Immutable snapshot + parent pointer, named by its contents
Branch A file containing one hash
HEAD A pointer to a branch
Merge Slide the pointer, or build a two-parent commit
Next: everything we just built is recoverable. Block 2 destroys it and gets it back.
Recap deliberately — this is the shared vocabulary the rest of the course assumes.
Point people at the kata footers: every demo today has an exercise behind it, and the fastest way to make this stick is to run them.
Tease Block 2 honestly: we will destroy work four different ways and recover from all but one of them. The one we can't recover is the one commandment 1 exists to prevent.