Git keeps every state of your project as a named snapshot, arranges the snapshots in a graph, and moves each change through four places: working directory, staging area, local repository, remote. Six commands cover this entire course. A 7-billion-parameter fp16 checkpoint — 14 GB — is not one of them.
Git stores the entire tracked state at each commit, then computes differences later, on demand. Every snapshot is named by a Secure Hash Algorithm 1 (SHA-1) hash of 160 bits — 40 hex characters — and records its parent, which is what makes history a graph instead of a list. The seven-character form in `git log --oneline` is only a display shortcut.
40 hex chars = 160 bits · parent pointers make the graph02 / FOUR PLACES, SIX COMMANDS
A change is not saved until it travels.
`git add` stages it, `git commit` records the snapshot in your local repository, `git push` publishes it to the remote. Commits work fully offline — push is the only step that needs the network. `git status` reports where everything is, and `git checkout -b` starts an experiment without moving main.
WORKING → STAGING → LOCAL → REMOTE03 / WHAT NEVER GETS COMMITTED
Checkpoints are regenerable gigabytes.
A 7-billion-parameter fp16 checkpoint is 7 × 10⁹ × 2 bytes = 14 GB — about 134× GitHub's 100 MiB hard block. History is forever: deleting the file in a later commit does not remove the blob from the database, and every clone still pays for it. `.gitignore` keeps the weights, run outputs, datasets and `.env` out; the code that produced them stays in.
7B × 2 B = 14 GB ≈ 134× the 100 MiB block
MENTAL MODEL IN ONE SENTENCE
Git is a graph of named snapshots you can move between: a branch is one movable pointer, a commit hash is the experiment’s identity, and anything you can regenerate — weights, datasets, secrets — never belongs in history.
By the end you will be able to fork and clone the course repo, run the daily add → commit → push loop, branch for an experiment and merge it back, resolve a conflict by hand, write a .gitignore that keeps data/train.csv while excluding 14 GB of checkpoints, and read git log --oneline as the project’s story.
01
VERSION CONTROL IS NOT OPTIONAL
There is no undo for a week of work.
You are about to write hundreds of files across 20 phases — the course alone is 523 lessons. Without version control you will lose work, break things you cannot undo, and have no way to collaborate. Git is not bureaucracy on top of the work; it is what makes the work survive.
The source opens with the arithmetic of this course: hundreds of code files across 20 phases. Every one of them is a state you may need again — the working training script from Tuesday, the config that produced the good loss curve, the notes you deleted by accident. A single folder of files gives you exactly one state: now. Version control gives you all of them.
First, a distinction that causes half of all beginner confusion: Git is the tool, GitHub is a place to put the result. Git runs on your laptop, works offline, and keeps the entire history inside a hidden .git directory next to your files. GitHub is a hosting service — the remote copy you push to so other people (and your future self on another machine) can get it. You can use git without GitHub. You cannot use GitHub without git.
The unit git cares about is the repository: the project folder plus that .git database holding every commit, branch and version of every tracked file. From the outside it is still just a folder. From the inside it is a time machine whose handle is a commit hash.
Three habits follow directly from the source, and they are all you need to keep for this course:
Save often. A commit is cheap and local; commit each logical change with a message that says what and why.
Push to remote. Commits on one laptop are one spilled cup of coffee from gone. Pushing is the backup.
Branch for experiments. Try the risky idea on a branch; main stays a known-good version you can always return to.
The same week’s work, two ways. Folders of numbered copies encode history in filenames — a format with no parents, no messages and no way to ask what changed. Git stores one chain of snapshots and rebuilds any comparison you want, when you ask for it.
For AI work specifically, the feedback loop makes this sharper: a training run costs minutes to hours, so you cannot afford to be unsure which code produced which curve. The discipline is one sentence long — commit before a long run, and never change the code while it is running. The run’s record is the commit hash; the weights it produced are not the repository’s job (Chapter 06).
Quick check
Your laptop dies tonight. Which of your work actually survives?
02
FOUR PLACES, SIX COMMANDS
A change is not saved until it travels.
Git gives a change four places to live: your working directory, the staging area, your local repository, and a remote. Six commands move it between them — and every one of them is just a pointer move you can watch.
The source’s sequence diagram is the whole lesson in five arrows. Working directory: the files you actually edit, where nothing is recorded yet. Staging area (also called the index): the draft of your next commit — the files you just decided to include. Local repository: the .git database on your machine, where commits become permanent, named snapshots. Remote: another copy of the repository on a server, conventionally called origin, where other people can see your work.
The staging area is the part that surprises beginners, and it is worth the surprise: git separates what changed on disk from what goes into the next commit. That is what makes a clean history possible. You edit five files, notice that two of them belong to one logical change, stage those two with git add file1 file2, and commit them together — the other three stay in the working directory for the next, separate commit. Without that middle place, every commit would be “everything I have right now”.
Command
Moves a change…
When you run it
git status
nowhere — it reports
The first command of every session: what changed, what is staged, what is untracked
git add <files>
working → staging
Choose the pieces of the next commit
git commit -m “…”
staging → local repo
Record the snapshot; works offline
git push
local repo → remote
Back up and publish: `git push origin main`
git fetch / git pull
remote → local repo (→ working dir)
See what others pushed; pull also merges it in
git checkout -b <name>
switches where you commit
Start an experiment without touching main (Chapter 04)
The four places a change travels through
Edit a file, add it, commit it, push it — then let a teammate push and bring their work back with fetch and pull. Every button prints what git would print, and the boxes show where the change actually lives at each step.
$ git status -sb
## main...origin/main
Two commits on both sides — everything is in sync.
places
working dir clean
staging empty
local main 2 commits
remote main 2 commits
origin/main fetched through 2
$ git status -sb
## main...origin/main
A commit is local; only push publishes it. fetch downloads the remote’s new commits and moves origin/main without touching your files; pull is fetch plus a merge into your current branch. If a teammate pushed while you have unpushed commits, the push is rejected and pull creates a merge commit — that is the same divergence the conflict lab resolves by hand.
Before any of that, git needs to know who is writing the commits. Two global settings; you do this once per machine:
configure git once, then the daily loopbash
git config --global user.name "Your Name"
git config --global user.email "you@example.com"# the source's daily workflow, start to finish
git status # what changed?
git add train.py # stage the changes
git commit -m "Add perceptron implementation"
git push origin main # publish
The name and email are written into every commit's author field — that is why the lesson configures them first. `git commit` refuses to run until they are set.
one full loop, with the output you should expectbash
$ git status -sb
## main...origin/main [ahead 1]
M train.py
?? notes.md
$ git add train.py notes.md
$ git commit -m "Log validation loss every 10 steps"
[main 3f9a1c2] Log validation loss every 10 steps
2 files changed, 14 insertions(+), 2 deletions(-)
$ git push
Enumerating objects: 7, done.
To https://github.com/YOUR-USERNAME/ai-engineering-from-scratch.git
8b2d4e1..3f9a1c2 main -> main
Illustrative output from a practice repository; hashes are abbreviated to 7 of their 40 characters. `[ahead 1]` means one local commit is not on origin/main yet — the push clears it. The ` M` and `??` markers mean modified and untracked.
Quick check
You commit twice on your laptop, then close the lid. Which command makes those two snapshots visible to a teammate?
03
COMMITS ARE SNAPSHOTS
Every commit is a full picture with a 40-character name.
A commit is not a change list. It is a complete snapshot of the tracked project at one moment, labelled with a hash, an author, a message and a parent. The diffs you see everywhere are computed later, by comparing two snapshots.
Open any commit and git shows you its identity card: a hash (the commit’s name), a tree (the entire project contents at that moment), one or more parents (which snapshot came before), an author and timestamp, and the message a human wrote. That is the object. A diff is not stored anywhere — when you run git show or git diff, git pulls two snapshots out of the database and computes the difference for you.
This is the concept most likely to blur, because every tutorial, code-review tool and GitHub page presents commits as diffs. The view is a diff; the storage is a snapshot. The practical consequences are real: checking out an old commit restores the whole project state instantly (no replaying of patches), and content git has seen before is stored once — a file that did not change between two commits is the same object in both trees, identified by its own content hash.
The name is a SHA-1 hash — “Secure Hash Algorithm 1” — computed from the commit’s content. That name is what makes cloning, syncing and merging possible: two repositories can compare notes by hash instead of trusting filenames or timestamps. git log --oneline, the source’s window into history, prints the shortest unambiguous prefix of that hash (7 characters by default) plus the message, newest first.
Worked numbers — how much name does a commit need?
A hash is written in hexadecimal, where each character carries 4 bits:
1 hex character = 4 bits
40 hex characters = 160 bits = 20 bytes — the SHA-1 identity
16^40 = 2^160 ≈ 1.46 × 10^48 possible hashes
git log --oneline shortens it to 7 characters:
7 hex characters = 28 bits
16^7 = 2^28 = 268,435,456 possible short hashes
One hundred and sixty bits is why two commits never collide by accident. Two hundred and sixty-eight million short hashes is why seven characters is plenty for a small repository — and why git lengthens the abbreviation on its own (the core.abbrev=auto default) the moment two objects in your repository share the same prefix. The short form is a convenience; the 40-character value is the identity.
One honest footnote: SHA-1’s collision resistance is broken — the 2017 SHAttered attack produced two different PDFs with the same SHA-1. Git hardened against it (it now refuses to store objects whose collision it can detect) and supports SHA-256 repositories. For identifying your own commits on the course repo, none of that changes the workflow in this lesson.
What changed? Walk the history
A small practice project, six commits. Click any line of git log --oneline to open that snapshot — full hash, parent, files and the stat line git show would print.
$ GIT LOG --ONELINE --DECORATE · NEWEST FIRST · REACHABLE FROM HEAD
$ git show fed9092 --statcommit
commit fed90924e4825c6424a25de0849e968fdcf7cd1d
Author: You <you@example.com>
Date: Sun Sep 14 23:58:07 2026 +0200
Try lr 3e-4 and merge
README.md
.gitignore
requirements.txt
~ perceptron.py
~ train.py
~ config.yaml
+ eval.py
4 files changed, 38 insertions(+), 2 deletions(-)
commit fed9092 · Try lr 3e-4 and merge
full hash fed90924e4825c6424a25de0849e968fdcf7cd1d (example hash)
author You <you@example.com>
parent 130f10c · Ignore model checkpoints and run outputs
snapshot 7 files · 204 lines total
vs parent 4 changed · +38 −2
The snapshot is what git stores. The + / ~ marks are computed later,
by comparing this snapshot with its parent.
Every commit here is reachable from main, so the log shows all six, newest first. Commit a branch and leave it unmerged and its commits would not appear — the log answers “where did I come from?”, not “what exists?”. git log --all is how you see the other branches.
history as a story, one line per snapshotbash
$ git log --oneline
9c41f0a (HEAD -> main) Try lr 3e-4and merge
b27d3e8 Ignore model checkpoints and run outputs
4a1c9d2 Add training loop with loss logging
7f3b8a1 Add perceptron script
2d9e4c7 Set up Python 3.11 environment
1a8b5f3 Initial commit
$ git log --format='%H %s' | head -1
9c41f0a8d2e64b17c3f5a90e7b21c4d6f8a3e5b7 Try lr 3e-4and merge
Illustrative output from the practice repository in the lab; the full hash shown is a 40-character example, not a real SHA-1. `git log` walks backwards from the commit HEAD points at and stops at the root — commits on unmerged branches are not shown.
Quick check
You commit a 40 MB dataset, then delete it and commit again. What does the repository contain?
04
BRANCH TO EXPERIMENT
A branch is not a copy. It is a bookmark that moves.
Branching is how you try the risky idea — a new optimizer, a different learning rate, a rewritten data loader — without touching the version that works. The whole feature is a 41-byte file and a pointer.
When people hear “branch” they imagine copying the project into a parallel folder. Git does the opposite: a branch is a movable pointer to one commit. A loose branch reference is a text file of about 41 bytes — the 40-hex-character hash of that commit plus a newline — under .git/refs/heads/. Creating a branch writes a new small file; switching branches rewrites one file and updates your working directory. Nothing is duplicated, and it takes no measurable time no matter how large the project is.
The source’s recipe is three commands. First you make the branch and switch onto it in one step; then you commit as usual; then you return to main and merge the experiment in:
the source's branching recipebash
git checkout -b experiment/new-optimizer
# ... make changes, commit ...
git checkout main
git merge experiment/new-optimizer
`-b` means 'create and switch'. Modern git also offers `git switch -c experiment/new-optimizer`, which does exactly the same thing with a more honest name — `checkout` historically did several unrelated jobs, which is why the switch/restore pair was added in 2019.
Build a commit graph
Four buttons, four real commands. Commit to move the branch pointer, branch to fork an experiment, checkout to move HEAD, merge to bring the experiment back. Watch the three merge outcomes — fast-forward, merge commit, and “already up to date”.
$ git clone https://github.com/YOUR-USERNAME/ai-engineering-from-scratch.git
Cloning into 'ai-engineering-from-scratch'... done.
Two snapshots arrived, and main points at the newest one.
branches
* main -> 2dfca45
$ git log --oneline --decorate (reachable from main, newest first)
2dfca45 (HEAD -> main) Add training loop
4232267 Initial commit
A merge commit is the only node with two parents. If HEAD already contains the other branch, git says Already up to date and adds nothing; if the other branch is simply ahead, git fast-forwards — it moves the pointer and creates no commit at all.
A commit on the branch moves that branch’s pointer forward and leaves main exactly where it was. Merging is the step that brings the work back, and it has four outcomes, three of which the lab reproduces exactly:
already up to date HEAD already contains the other branch
→ git says "Already up to date." and does nothing
fast-forward main has not moved since the branch was created
→ git moves main's pointer to the branch tip
→ no merge commit is created
merge commit both sides have new commits
→ git creates a commit with TWO parents
→ this is the only new node in the graph
conflict both sides changed the same lines
→ git stops and asks you (Chapter 05)
The default fast-forward is worth noticing because tutorials often show a merge commit for every merge: if main has not moved, git takes the cheap path and simply slides the bookmark forward. Both are correct merges; only the second records “a merge happened here” as a node.
One piece of honest scope: on a solo course repository, working directly on main is completely fine — you are the only one who can break it. Branches earn their keep when an experiment might be thrown away, or when someone else depends on main staying good. The source keeps it simple: branch for experiments.
a branch experiment, with outputbash
$ git checkout -b experiment/new-optimizer
Switched to a new branch 'experiment/new-optimizer'
$ git commit -am "Try lr 3e-4"
[experiment/new-optimizer 5d17c93] Try lr 3e-41 file changed, 2 insertions(+), 2 deletions(-)
$ git checkout main
Switched to branch 'main'
$ git merge experiment/new-optimizer
Updating 8b2d4e1..5d17c93
Fast-forward
config.yaml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
Illustrative output; hashes are 7-character abbreviations of 40-character SHA-1 values. Because main had not moved since the branch was created, this merge fast-forwards — main's bookmark simply slides to the new commit.
Quick check
You create a branch, make two commits on it, then run `git checkout main`. Where are the two commits?
05
WHEN MERGES COLLIDE
Two edits, one line. Git stops and asks.
A conflict is not an error and nothing is lost: it is git refusing to guess which of two competing edits you meant. You make the decision in a text file, mark it resolved, and finish the merge.
Every branch starts from some commit, and git keeps track of that merge base — the last snapshot the two branches share. When you merge, git compares both sides to the base and combines the differences automatically. It can do that when the changes touch different regions: main changed learning_rate, the experiment changed batch_size, so both edits apply cleanly. A conflict happens when both sides changed the same lines since the base — two values for one variable, two versions of one function. Git has no way to know which one you meant, so it writes both into the file between markers and pauses the merge.
The markers are three lines of ordinary text, and they are not valid code:
<<<<<<< HEAD ← your side starts (the branch you are on)
learning_rate = 0.0005
======= ← divider: their side starts
learning_rate = 0.01
>>>>>>> experiment/new-optimizer ← their side ends (the branch merging in)
The resolution is a decision, not a repair job. Edit the file to the version you want — take one side, combine the changes, or rewrite the region entirely — then delete all three marker lines and tell git the file is settled with git add. The merge commit that follows has two parents, exactly like the lab’s merge node.
Resolve a merge conflict
The same file changed on both branches. Pick the scenario, then pick the resolution — the file content, the status output and the exact commands update underneath.
merge base — the common ancestortrain.py at the fork point
learning_rate = 0.001
batch_size = 32
HEAD → mainyour side
learning_rate = 0.0005
batch_size = 32
experiment/new-optimizertheir side
learning_rate = 0.01
batch_size = 32
git merge experiment/new-optimizer — conflict, file left with markerstrain.py
$ git status
On branch main
You have unmerged paths.
(fix conflicts and run "git commit")
(use "git merge --abort" to abort the merge)
Unmerged paths:
both modified: train.py
# edit train.py, delete the three marker lines, then:
$ git add train.py
$ git commit -m "Merge branch 'experiment/new-optimizer'"
# changed your mind?
$ git merge --abort ← back to before the merge; both branches keep their commits
why it happened: both branches changed the same line since the merge base — 0.0005 vs 0.01 — and git will not guess which value you meant.
A conflict is not an error: it is git refusing to guess. Nothing is lost — both versions are in the file until you choose. And the outside-first rule: keep the markers, decide, delete the three marker lines (<<<<<<<, =======, >>>>>>>), then git add — a file still containing markers is not resolved in a real merge.
the full conflict loop, including the escape hatchbash
$ git merge experiment/new-optimizer
Auto-merging train.py
CONFLICT (content): Merge conflict in train.py
Automatic merge failed; fix conflicts and then commit the result.
$ git status
On branch main
You have unmerged paths.
(fix conflicts and run "git commit")
(use "git merge --abort" to abort the merge)
Unmerged paths:
both modified: train.py
# ... edit train.py, delete the <<<<<<<, =======, >>>>>>> lines ...
$ git add train.py
$ git commit -m "Merge branch 'experiment/new-optimizer'"# or, if you would rather not resolve it now:
$ git merge --abort
Illustrative output. `git status` names the conflicted files as 'both modified'; `git merge --abort` returns the repository to exactly the state before the merge, with both branches untouched.
06
WHAT NEVER GETS COMMITTED
A 14 GB checkpoint never belongs in history.
Git is for the code that produces results, not the results. Model weights, run outputs, datasets and API keys all have somewhere better to live — and once any of them is in history, deleting the file does not remove the bytes.
The rule of thumb is one sentence: commit what you cannot regenerate; ignore what is large or secret. The training script, the config, the notes and the tests cannot be re-derived from anything — they go in git. A checkpoint is a deterministic-ish byproduct of code + data + seed, and it is gigabytes; a dataset is re-downloadable; an API key (Application Programming Interface key) is a credential that must never leave your machine. In AI repositories the usual ignore list is short and stable:
*.pt *.pth *.safetensors model weights (PyTorch, Hugging Face)
runs/ *.log *.tfevents training output, logs, TensorBoard events
data/ datasets — keep small samples with !negation
.env secrets: API keys, tokens, passwords
__pycache__/ .venv/ *.pyc caches and environments, rebuildable
The .gitignore file is a plain list of patterns in the repository root (or any subdirectory). Patterns are evaluated per-path, last match wins, and the syntax follows glob conventions with two twists worth memorizing: * matches anything except a slash, and a pattern containing a slash is anchored to the repository root while a bare name like runs matches at any depth. The docs put the negations’ one hard limit plainly: it is not possible to re-include a file if a parent directory of that file is excluded — which is why the canonical way to keep one file out of an ignored folder is data/* followed by !data/train.csv, and not data/ followed by the negation.
Worked numbers — why 14 GB, and why deleting it does not help
Size a checkpoint as parameters × bytes per parameter. In fp16 (16-bit floating point, “half precision”) each parameter takes 2 bytes; fp32 takes 4:
Now compare with what GitHub will accept. It warns above 50 MiB and blocks any file above 100 MiB (104,857,600 bytes), and it recommends repositories stay under 1 GB:
14 × 10^9 bytes ÷ 104,857,600 bytes ≈ 133.5
→ a 7B fp16 checkpoint is about 134× over the hard file limit
five people clone once: 5 × 14 GB = 70 GB of downloads
for a file that can be regenerated from code that fits in megabytes
And the delete-it-later plan does not work: a commit references the blob, so the blob stays in the object database. Removing it requires rewriting history (git filter-repo), which changes every affected commit hash and forces every clone to re-download. The discipline is cheaper than the cleanup by orders of magnitude — which is exactly why the source makes .gitignore a first-class lesson topic and Exercise 2.
The .gitignore pattern tester
Edit the patterns on the right; every file below re-evaluates live with git’s rules — last match wins, and a slash anchors a pattern to the repository root.
WHAT `GIT STATUS` WOULD LIST7 ignored · 5 tracked
model.pt7B params · fp16 · 14.0 GB · 13.0 GiBlast match — line 1: “*.pt”IGNORED
checkpoint_epoch9.pth1.5B params · fp32 · 6.00 GB · 5.6 GiBlast match — line 2: “*.pth”IGNORED
weights.safetensors3B params · fp16 · 6.00 GB · 5.6 GiBlast match — line 3: “*.safetensors”IGNORED
runs/2026-09-15/train.logtraining loghidden by parent directory “runs” — patterns on files inside an excluded directory never runIGNORED
runs/2026-09-15/events.out.tfeventsTensorBoard eventshidden by parent directory “runs” — patterns on files inside an excluded directory never runIGNORED
data/train.csvthe one data file you want in git · 40 MBlast match — line 7: “!data/train.csv”TRACKS
data/val.csva second split you do not · 1.20 GB · 1.1 GiBlast match — line 6: “data/*”IGNORED
.envAPI keys — never commitlast match — line 5: “.env”IGNORED
keep.mea last-match-wins corner caselast match — line 9: “!keep.me”TRACKS
notebooks/explore.ipynbexploration notebookno pattern matches — git would track this fileTRACKS
app.pysource codeno pattern matches — git would track this fileTRACKS
README.mddocumentationno pattern matches — git would track this fileTRACKS
files 12
ignored 7
tracked 5
bytes kept out of git (avoided in every clone)
27.2 GB · 25.3 GiB
bytes that would still enter history (lower bound)
40 MB
rule syntax
* anything except / ** crosses directories
? one character ! re-includes a path
name/ directories only /x anchored to the root
last matching pattern wins
In the default set, data/* ignores the data folder’s contents and !data/train.csv brings one file back. Try the “broken negation” preset: data/ hides the directory itself, and then no pattern on a file inside can rescue it — the gitignore documentation says so in as many words.
the .gitignore the course expects, and the rescue for a file already trackedbash
# .gitignore — patterns for files git should never track
*.pt
*.pth
*.safetensors
runs/
.env
data/*
!data/train.csv
# if a checkpoint was already committed, untrack it but keep it on disk
git rm --cached model.pt
git commit -m "Untrack model checkpoints"
Patterns only affect untracked files: a checkpoint committed before the pattern existed stays tracked until `git rm --cached`. That command removes the file from the next commit's snapshot and leaves the 14 GB file on your disk — which is what you want while the run continues.
Quick check
You want `data/train.csv` in the repository while everything else under data/ stays out. Which pattern pair does that?
07
THE COURSE FORK WORKFLOW
You cannot push upstream. So you fork first.
Only maintainers have write access to the course repository. Your work goes to your own copy on GitHub, and this chapter is the exact sequence — fork, clone, branch, commit, push — plus what to commit while you follow the lessons.
The source says it plainly: you can’t push to the course repo itself. A fork is your own server-side copy of it, created with the Fork button at the top right of the repository page. Once it exists, the workflow is ordinary git: clone the fork (not the upstream), work on a branch, and push.
The fork triangle: GitHub holds the read-only upstream and your writable fork; your laptop holds a clone of the fork. Pushes travel to origin — your fork — and the course repo never changes.
# on GitHub: press Fork, so the repo becomes YOUR-USERNAME/ai-engineering-from-scratch
git clone https://github.com/YOUR-USERNAME/ai-engineering-from-scratch.git
cd ai-engineering-from-scratch
git checkout -b my-progress
# work through lessons, commit your code
git push -u origin my-progress
# later pushes are just:
git push
`-u` on the first push links your local my-progress to origin/my-progress, which is why later pushes need no arguments. `origin` is the conventional name git gives the remote you cloned from — here, your fork.
While working through the phases, commit the things that are yours: exercise solutions, notes, small scripts, configs, a README for your progress branch. Keep the ignore discipline from Chapter 06 — model checkpoints, run directories, datasets and .env stay out. That single habit is what makes the repository clone-able by future you on a different machine in a few seconds instead of a few hours.
Optional, and outside the source’s core workflow: if you later want course updates in your fork, add the upstream repository as a second remote and pull from it — git remote add upstream https://github.com/rohitg00/ai-engineering-from-scratch.git — then git pull upstream main brings new lessons into your copy. You do not need this on day one; knowing that remotes are named, not magic, is enough.
08
CHECK YOURSELF
Five questions. Then the terms worth keeping.
Answer before you look. The order-of-commands question and the checkpoint question are the two that separate “I have seen git” from “I can run a session without losing anything”.
0 / 5 answered · 0 correct
01What does 'version control' primarily help you do?
02What is a 'repository' in the context of software development?
03What is the correct sequence for saving and backing up your work in git?
04What does 'git checkout -b experiment/new-optimizer' do?
05Why should you add '.pt', '.pth', and '.safetensors' to your .gitignore?
Key terms, demystified
Click a card to swap the lazy description for what it actually means.
Exercises from the lesson
Four problems with exact commands and numbers — fork and push, write the .gitignore with the negation trap, read a real history, and resolve a two-branch conflict. Try first; a worked answer is one click away.
Fork the course repo, clone your fork, create a branch called 'my-progress', add a file, commit it and push it. Write the exact sequence and the state of the four places (working directory, staging, local repo, remote) after each command.Show one worked answer
The fork happens on GitHub (the Fork button) and gives you `github.com/YOUR-USERNAME/ai-engineering-from-scratch`. Then, from a terminal: `git clone https://github.com/YOUR-USERNAME/ai-engineering-from-scratch.git` copies the full history to your laptop and names that remote `origin`; `cd ai-engineering-from-scratch` enters it. `git checkout -b my-progress` creates a branch at the current commit and switches to it — main does not move. Create `notes.md`, then `git status` shows it under 'Untracked files' (working directory only). `git add notes.md` stages it (working directory → staging area). `git commit -m "Start my progress notes"` prints `[my-progress 4e2d9a7] Start my progress notes` / `1 file changed, 1 insertion(+)` and moves the branch pointer (staging → local repo); the commit now exists on your laptop only, and `git log --oneline` shows it at the top with a 7-character hash. `git push -u origin my-progress` uploads the commit and sets the upstream, so later `git push` alone works (local repo → remote). Verify with `git status -sb`: `## my-progress...origin/my-progress` with no 'ahead' count means everything is saved and published. The order matters — pushing before committing would upload nothing, because `git push` sends commits, never working-directory files.
Write the .gitignore for an AI training repository that must exclude model checkpoints (.pt, .pth, .safetensors), the runs/ output tree, a 4.2 GB dataset at data/, and .env — while keeping data/train.csv tracked. Then explain why a `data/` pattern alone cannot be fixed with `!data/train.csv`.Show one worked answer
```
# model weights — regenerable, gigabytes each
*.pt
*.pth
*.safetensors
# training output: logs, images, tensorboard event files
runs/
# datasets: keep the small sample, ignore the rest
data/*
!data/train.csv
# secrets — API keys, tokens
.env
```
The classic mistake is `data/` followed by `!data/train.csv`. The gitignore documentation is explicit: 'It is not possible to re-include a file if a parent directory of that file is excluded.' Once `data/` excludes the directory, git never descends into it, so patterns on files inside have no effect. The working recipe ignores the *contents* with `data/*` (which matches `data/val.csv`, `data/images/` and so on) and then re-includes the one file you want; because the directory itself is never excluded, the negation can take effect. What the patterns are worth, in bytes: leaving them out would commit a 7B fp16 checkpoint (7 × 10⁹ × 2 = 14 GB), a 4.2 GB dataset and a 6 GB second checkpoint — 24.2 GB, when GitHub warns above 50 MiB, blocks any file above 100 MiB, and recommends repositories stay under 1 GB. And if a checkpoint was already committed, add the pattern and run `git rm --cached model.pt` — the pattern only affects untracked files.
Here is a `git log --oneline` from a practice repo. Read it: which commit is newest, which added .gitignore, how do you see the full 40-character hash of the checkpoint commit, and what does the marker in parentheses mean?
```
9c41f0a (HEAD -> main) Try lr 3e-4 and merge
b27d3e8 Ignore model checkpoints and run outputs
4a1c9d2 Add training loop with loss logging
7f3b8a1 Add perceptron script
2d9e4c7 Set up Python 3.11 environment
1a8b5f3 Initial commit
```Show one worked answer
`git log` walks backwards from the commit HEAD points at, so the newest commit is first: `9c41f0a Try lr 3e-4 and merge`. The `.gitignore` arrived with the second commit, `b27d3e8 Ignore model checkpoints and run outputs` — its message says so, and you can confirm with `git show b27d3e8`. The characters before each message are abbreviated hashes: 7 of the 40 hex characters that make up the SHA-1 identifier. To see the full value use `git log --format='%H %s'`, `git show b27d3e8 --format='%H'`, or `git rev-parse b27d3e8` — git accepts the short form as long as it is unambiguous. The parentheses are the decoration: `HEAD -> main` marks the branch you are on and the commit it currently points to; merge commits can carry several names, e.g. `(HEAD -> main, experiment/new-optimizer)`, which means both pointers currently sit on the same commit. The 7-character display is a convenience; the 40-character hash is the identity, because two different commits must never share a name.
main and experiment/new-optimizer both changed the learning-rate line in train.py. Write the conflict markers git leaves in the file, then list the exact commands that finish the merge — and the command that backs out of it instead.Show one worked answer
Git writes both sides into the file, separated by markers that name the source of each version. With `HEAD` on main:
```
<<<<<<< HEAD
learning_rate = 0.0005
=======
learning_rate = 0.01
>>>>>>> experiment/new-optimizer
```
`<<<<<<< HEAD` starts your side (the branch you ran `git merge` on), `=======` is the divider, `>>>>>>> experiment/new-optimizer` ends the side being merged in, and the two values are the competing edits since the merge base. Nothing is lost and nothing is broken — git simply refuses to guess. Edit the file to the version you want: keep one value, or combine both if the change is not really the same line (here, keeping both lines would define `learning_rate` twice, so one must win — this is the case that separates a real conflict from an auto-merged one). Delete all three marker lines, save, then `git add train.py` tells git the file is resolved, `git status` shows 'All conflicts fixed but you are still merging', and `git commit` (or `git merge --continue`) records the merge commit with two parents. If you would rather not deal with it now, `git merge --abort` restores the state from before the merge — your work on both branches stays intact. One honest note: conflicts are not bugs. They happen exactly when two branches edited the same lines since their common ancestor, which is also how genuinely different intentions show up.
Terms this lesson borrows from later lessons (or outside)
You do not need to master these here. Each one gets a proper treatment in its own lesson; the one-line meaning is enough to keep reading. Orange dotted underlines in the prose point back to this list.
model checkpoint — The saved weights of a trained model — the file that must never enter git. Phase 0, Lesson 09 (Data Management) covers where checkpoints go (artifact stores, the Hugging Face Hub, DVC) and why regenerable gigabytes stay out of history.
Git LFS — Large File Storage: git keeps a small pointer in the repository and the actual bytes on a separate server. GitHub's included storage and bandwidth for large files change over time — the free plan currently lists 10 GiB of each, and older write-ups quote ~1 GiB — so check GitHub's LFS billing page before relying on a number. Phase 0, Lesson 09 compares `.gitignore`, Git LFS and DVC for model and dataset files.
DVC — Data Version Control: your dataset lives in cloud storage while small `.dvc` pointer files live in git, so experiments can be reproduced exactly. Phase 0, Lesson 09 builds the full comparison.
experiment tracking — Recording what code, data and seed produced each run — git tracks the code half of that. Phase 2, Lesson 13 (ML Pipelines) and Capstone 52 (Experiment Runner) build the rest.
pull request — GitHub's review workflow for proposing changes from a fork back to the original repository. Capstone 16 (GitHub Issue-to-PR Agent) drives it end to end; this lesson only needs fork → branch → push.
KEEP GOING
A picture is a start. Practice is the rest.
This lesson is a port of an open course. Everything here traces back to it — and the next step is running the code yourself.
Lesson text, quiz and the daily workflow are adapted from AI Engineering from Scratch (Phase 00, Lesson 02) and the Math Foundations Notebook reference build. The five labs (the commit-DAG builder, the four-areas pipeline, the .gitignore pattern tester, the merge-conflict resolver and the history reader) are original to this page, as is the hash arithmetic (SHA-1 is 160 bits = 40 hex characters; the 7-character short form), the commit object's fields, the branch-as-pointer detail (a loose ref is about 41 bytes), the checkpoint arithmetic (7B × 2 bytes = 14 GB ≈ 13.0 GiB, about 134× GitHub's 100 MiB block), the two worked .gitignore recipes including the parent-directory negation trap, the merge-outcome taxonomy (already up to date / fast-forward / merge commit / conflict), the annotated daily-flow output, and the fork workflow's push target. Every command output shown is illustrative and marked as such.