Team training · Block 1 of 6

The local model

How git stores your work, before anyone else is involved

What we'll cover

Six ideas, in dependency order

#IdeaYou'll be able to
1.1The three treesSay where any file currently sits
1.2init, add, commitDescribe the minimum sequence
1.3Staging as compositionExplain why staging exists
1.4Commit as snapshotSay what a commit actually stores
1.5Branch as pointerExplain why branching is cheap
1.6–1.7MergingPredict the graph shape
1.1

The three trees

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.

A file is always in one of four states

StateMeaningGets committed?
untrackedGit has never seen itNo
modifiedChanged since last commitNo
stagedMarked for the next commitYes
committedSafely in historyAlready is

The same file can be staged and modified at once — staged version A, then kept editing to version B.

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.

Why we start here

Uncommitted work is the only work git cannot get back for you.

Commandment 01

Commit early, commit often.

1.2

init, add, commit

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.

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.

1.3

Staging as composition

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.

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.

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

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.

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.

1.4

Commit as snapshot

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.

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 → treeblob. The blob is your file's real content; the snapshot is the whole chain.

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.

Parents make a graph

6afb878 91ac5d3 e91e334 c584509 f6c6987 HEAD

Each commit points backwards to its parent. Git walks this chain to build history.

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"

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.

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.

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.

1.5

Branch as pointer

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.

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.

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.

1.6 — 1.7

Merging

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.

kata · ff-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.

kata · 3-way-merge

Where we got to

IdeaThe one-line version
Three treesWorking dir → staging → repository
StagingChoose which changes, not just which files
CommitImmutable snapshot + parent pointer, named by its contents
BranchA file containing one hash
HEADA pointer to a branch
MergeSlide the pointer, or build a two-parent commit

Next: everything we just built is recoverable. Block 2 destroys it and gets it back.