This page is the Git basics case-book . The parent note taught the machinery — repository, commit, branch, staging, remote. Here we do the opposite: we list every kind of situation Git can put you in, then work one example per situation so that when the real thing happens on your keyboard, you have already seen it.
Before we start, one honesty note. Git is not geometry — there are no angles or quadrants. But it does have "case classes" that behave exactly like signs and edge cases in maths: the merge that fast-forwards vs the merge that must build a new commit , the clean pull vs the conflicting pull , the empty repo vs the detached HEAD . Those are our quadrants. We enumerate them all.
Definition Three things this page leans on — defined before we use them
HEAD — a pointer that answers "where am I right now? ". Normally HEAD points at a branch (like main), and that branch points at your latest commit. So HEAD = your current position in the history graph. When a command says HEAD~1, that means "one commit before where I am."
origin/main — your local memory of the remote's main . origin is the nickname for the remote server (usually GitHub); origin/main is a read-only pointer showing where main sat on the server the last time you talked to it. It can be ahead of you (teammate pushed) or behind you (you committed locally).
the figure labels s01, s02, ... — every diagram on this page is a numbered step figure . "s01" simply means "step figure 01 " — the first picture on the page, embedded right under the scenario matrix. When the text says "the s01 figure", look for that same numbered image above. s02 is the second, and so on, in reading order.
==double equals== markup
Throughout this vault, wrapping a word in ==...== marks it as a key term to memorise (in Obsidian it renders as a coloured highlight). When you see it, that word is worth burning into memory.
Common mistake Two setup assumptions to make explicit
Default branch name. This page writes main everywhere. But the branch that git init creates is not guaranteed to be called main — older Git versions default to master, and the name is set by the config key init.defaultBranch (or the git init --initial-branch=main flag). If your first branch is master, mentally swap the word main for master in every example, or run once: git config --global init.defaultBranch main.
Math rendering. A few "Verify" blocks use math written between dollar signs, e.g. $P$ or $$2 + 1 = 3$$. In Obsidian (which renders LaTeX via KaTeX) these appear as proper equations. In a plain-text viewer they show as literal text like 2 + 1 = 3 — still readable, just not typeset. Every such formula is also stated in plain words next to it, so you never depend on the renderer.
Common mistake How to use this page:
reset between scenarios
Every example below is independent , not cumulative — each assumes a fresh little playground repo. If you follow along at the keyboard, before starting a new Cell either work in a new empty folder (mkdir demoX && cd demoX && git init) or wipe the old one (cd .. && rm -rf demo && ...). The commit counts in each "Verify" are counted within that example's own repo only , so leftover branches or commits from a previous scenario would confuse the numbers. When an example says "count is now 2 (after Ex-1 base)", that means inside Example 2's own sequence , not carried over from Example 1's folder.
Read the table as: "which shape can the repository be in right now, and what does the command do in that shape?" Every one of these nine cells is worked in full below on this page — the rightmost column tells you which example (all on this page) covers it.
Cell
Situation (the "sign" of the repo)
The degenerate / edge twist
Example (this page)
A
Brand-new folder, no history at all
Nothing staged refusal + --allow-empty exception
Ex 1
B
Linear history, one branch moves forward
Amending the last commit
Ex 2
C
Two branches, only one moved
Fast-forward merge (no new commit) + --no-ff twist
Ex 3
D
Two branches, both moved
Three-way merge (new merge commit)
Ex 4
E
Two branches edited the same line
Merge conflict — Git refuses, you decide
Ex 5
F
Remote ahead of you and remote behind you
rejected push vs clean push (both quadrants)
Ex 6
G
You checked out an old commit directly
Detached HEAD — commits with no branch
Ex 7
H
Real-world word problem
Committed a secret / big file by mistake
Ex 8
I
Exam-style twist
"Undo" family: reset vs revert vs checkout
Ex 9
Intuition Why these are the only cases
A commit history is a graph of arrows : each commit points at its parent(s). At any moment, two branches can only relate in three ways — one is an ancestor of the other (fast-forward, cell C), they share an ancestor but both moved (three-way, cell D), or they moved and touched the same lines (conflict, cell E). Cells A/B/G are about a single pointer; cells F/H/I are those same three shapes seen across a network or run backwards in time. There is no fourth shape.
What the figure above shows (this is figure s01, the first step figure on the page): three mini-graphs left to right. On the left , two blue circles C1 -> C2 joined by one arrow — a plain linear chain (cells A, B), just one pointer walking forward. In the middle , a blue ancestor P with a single green child F1 — only one branch moved, so a merge here is a fast-forward . On the right , ancestor P splits into a red M1 (main moved) and a yellow F1 (feature moved), and both feed a white MG node — the diverged shape that forces a three-way merge. Trace the arrows with your finger: every real repo situation is one of these three pictures.
Worked example Example 1 — first commit, the "nothing staged" trap, and the
--allow-empty exception
Scenario: brand-new folder, zero history. You run git init, then immediately try git commit.
Forecast: Guess before reading. Will git commit -m "start" succeed on a fresh repo where you have not run git add? And is there any way to make an empty commit on purpose?
Steps:
mkdir demo && cd demo
git init
Why this step? init creates the hidden .git folder and a branch pointer (named main or master depending on your config — see the setup note above) that points at nothing yet — there are zero commits. (This is the leftmost picture in figure s01: a pointer with no commit under it.)
git commit -m "start"
# nothing to commit (create/copy files and use "git add")
Why this step? The staging area is empty, so there is no snapshot to freeze. Git refuses — this is the zero-input degenerate case , exactly like dividing by an empty set.
git commit --allow-empty -m "empty on purpose"
Why this step? (the exception) --allow-empty overrides the refusal and records a commit with no file changes . It sounds pointless, but it is real: teams use it to trigger CI pipelines or to mark a milestone. So the rule "nothing staged ⇒ refuse" has exactly one escape hatch, and this is it. (For the clean count below, assume we did not run this line — it is shown only to prove the exception exists.)
echo "# Demo" > README.md
git add README.md
git commit -m "Initial commit"
Why this step? Now one file sits in the staging area, so the snapshot is non-empty and the commit is born.
Verify: count the commits (skipping the optional step 3).
git rev-list --count HEAD # -> 1
HEAD (remember: "where am I") now points at the branch, which points at that one commit. The refused empty-commit attempt added 0 ; the real one added 1 . Total 0 + 1 = 1. ✓
git init does not create a commit
A common wrong mental model: "init gives me commit zero." No — after init the count is 0 . You are one add + commit away from your first snapshot (or one --allow-empty away from a deliberately empty one).
Worked example Example 2 — fixing the last commit without adding a new one
Scenario: (fresh repo — see the reset note at the top.) Starting from one existing commit, you commit "Add modle" (typo) and want to fix the message and include a file you forgot.
Forecast: does git commit --amend create a new commit on top, or replace the last one? (Watch the count.)
Steps:
git commit -m "Add modle" # this repo's chain: Initial -> Add modle (count = 2)
Why? Sets up a linear chain of two commits inside this example's repo.
echo "print('hi')" > model.py
git add model.py
git commit --amend -m "Add model"
Why this step? --amend takes the current staged changes plus the previous commit's changes and rebuilds one commit in place, giving it a new hash and the corrected message. The old commit is discarded, not stacked. Note this rewrites history, so only do it before pushing.
Verify:
git rev-list --count HEAD # still 2, NOT 3
git log -1 --pretty=%s # -> "Add model"
The count stays 2 because amend replaces . If it had added , we'd see 3. ✓
What the figure above shows (figure s02): two scenes split by a dashed line. Left (fast-forward): blue P -> A -> B in a straight line, with a green arrow underneath sweeping the main label all the way to B — nothing new is built, the pointer simply slides . Right (three-way): blue ancestor P branches into red M and yellow F, and a white MG node draws its two arrows back to both — that white node is the newly minted merge commit with two parents . The contrast to burn in: left creates zero commits, right creates one.
Worked example Example 3 — Cell C: fast-forward merge, and forcing a merge commit with
--no-ff
Scenario: (fresh repo.) main untouched, feature has 1 new commit.
Forecast: how many commits does main have after merging — and is a merge commit created? Then: what changes if you add --no-ff?
Steps:
git checkout -b feature # branch off, both point at same commit
echo "a" > a.txt ; git add . ; git commit -m "add a"
Why? feature now leads main by exactly one commit; main did not move. (Middle picture of figure s01: only one branch moved.)
git checkout main
git merge feature # "Fast-forward"
Why this step? Because main is an ancestor of feature, Git needs no third commit — it just relabels main to feature's tip. Zero merge commits.
The --no-ff twist. Suppose instead you had run:
git merge --no-ff feature -m "merge feature"
Why this step? --no-ff forbids the pointer-slide and forces a real merge commit even though a fast-forward was possible . Teams use this so the history graph visibly records "a feature branch was merged here" instead of a flat line. So the same Cell-C shape can be resolved two ways: default (0 merge commits) or --no-ff (1 merge commit).
Verify: suppose main had 2 commits before branching and feature added 1.
plain FF: 2 + 1 = 3 commits, 0 merge commits
with --no-ff : 2 + 1 + 1 = 4 commits, 1 merge commit
In plain words: plain fast-forward = 3 commits and 0 merge commits; with --no-ff = 4 commits and 1 merge commit.
git rev-list --count HEAD # -> 3 (plain FF) | -> 4 (if --no-ff was used)
Zero merge commits is the signature of a plain fast-forward; the extra +1 is the signature of --no-ff. ✓
Worked example Example 4 — Cell D: three-way merge
Scenario: (fresh repo.) both main and feature gained commits after splitting. They edit different files.
Forecast: will there be a new merge commit? How many parents does it have?
Steps:
Split at ancestor P (say main had 3 commits).
git checkout -b feature
echo "f" > feat.txt ; git add . ; git commit -m "feat" # feature = 4
git checkout main
echo "m" > main.txt ; git add . ; git commit -m "main" # main = 4, diverged
Why? Now neither tip is an ancestor of the other — this is the diverged shape (right picture of both figures s01 and s02).
git merge feature -m "merge feature"
Why this step? Git cannot slide a pointer, so it computes the ancestor P , applies both diffs (they touch different files → no conflict), and records a merge commit with two parents .
Verify: commits on main afterward = (its 4) + (feature's 1 unique) + (1 merge) = 4 + 1 + 1 = 6 ; parents of the tip = 2.
git rev-list --count HEAD # -> 6
git cat-file -p HEAD | grep -c '^parent' # -> 2
Two parents confirm the three-way merge. ✓
What the figure above shows (figure s03): a blue base (lr=.01) node splits into a red main (lr=.001) node above and a yellow feat (lr=.1) node below — both edited the same line . On the right sits a dark box outlined in red titled "CONFLICT in config.txt" containing the literal marker block Git writes into your file: <<<<<<< HEAD (your main side, red), a ======= divider, then the feature side (yellow), closed by >>>>>>> feature. The green caption underneath — "you delete markers, add, commit" — is the human step. The picture's message: Git does not guess between 0.001 and 0.1; it hands you the file to decide.
Worked example Example 5 — same line, both sides: Git hands you the wheel
Scenario: (fresh repo.) main and feature both change line 1 of the same file . This is where a merge cannot be automatic.
Forecast: does git merge finish on its own, or stop and ask you? And when it stops, what does the file look like?
Steps:
echo "lr = 0.01" > config.txt
git add . ; git commit -m "base config"
Why this step? Create the split point — one file with lr = 0.01. Both branches will diverge from here.
git checkout -b feature
echo "lr = 0.1" > config.txt
git commit -am "feature lr"
Why this step? On feature we rewrite line 1 to lr = 0.1. (-am stages tracked changes and commits in one shot.)
git checkout main
echo "lr = 0.001" > config.txt
git commit -am "main lr"
Why this step? Back on main we rewrite the same line 1 to lr = 0.001. Now both branches changed the identical line to different values — Git has no rule to pick 0.1 vs 0.001.
git merge feature
# Auto-merging config.txt
# CONFLICT (content): Merge conflict in config.txt
# Automatic merge failed; fix conflicts and then commit the result.
Why this step? This is the moment the conflict is triggered . Git detects overlapping edits and stops — it does not create a merge commit yet. Instead it writes both versions into config.txt with marker lines so you choose. Open the file and you literally see:
<<<<<<< HEAD
lr = 0.001
=======
lr = 0.1
>>>>>>> feature
Here HEAD = your current side (main, so lr = 0.001); the part after ======= is the incoming feature side (lr = 0.1). This is exactly the marker block drawn in figure s03.
echo "lr = 0.1" > config.txt # you decide: keep the feature value
Why this step? You resolve the disagreement by editing the file so it contains exactly one final version and no marker lines (<<<<<<<, =======, >>>>>>> all gone). Here we chose feature's lr = 0.1.
git add config.txt
Why this step? Staging the file is how you tell Git "this conflict is resolved." Until you add it, Git still considers the merge unfinished.
git commit -m "merge: keep feature lr"
Why this step? Now — and only now — Git creates the merge commit (with two parents, main and feature). The merge you started in step 4 finally completes.
Verify: the commit chain is base(1) + feature(1) + main(1) + merge(1) = 4, and the resolved file holds the chosen value with no markers left.
git rev-list --count HEAD # -> 4
cat config.txt # -> lr = 0.1
grep -c -E '^(<<<<<<<|=======|>>>>>>>)' config.txt # -> 0 (no leftover markers)
Four commits (a conflict still ends in a merge commit — you just built it by hand), the file equals lr = 0.1, and zero conflict markers remain. ✓
Common mistake A conflict is not an error you broke
Git stopping is correct behaviour — it is protecting you from silently discarding someone's change. The word "CONFLICT" means "two humans disagreed, a human must decide." Leaving a marker line (<<<<<<< etc.) in the file and committing it is the classic beginner bug — always grep for markers before committing a resolved merge.
Worked example Example 6 — the rejected push (remote ahead) vs the clean push (remote behind)
Scenario: two complementary quadrants of the same picture. Recall origin/main = your local memory of where main sits on the server.
Forecast: in which of the two cases does git push succeed immediately, and in which is it rejected?
Part 1 — remote is AHEAD (you diverged): push rejected.
# teammate already pushed 1 commit; you made 1 local commit
git push origin main
# ! [rejected] (fetch first)
Why this step? Push is fast-forward-only by default. Your local main is not a descendant of origin/main — you diverged (the cell-D shape, across the network). Git refuses to overwrite the teammate's commit.
git pull origin main # = fetch + three-way merge
Why? pull downloads their commit and merges it with yours, producing a merge commit locally — turning the diverged shape back into a single linear tip.
git push origin main # now fast-forwards, succeeds
Why? After the pull, your local tip contains origin/main as an ancestor, so the push is a clean fast-forward.
Part 2 — remote is BEHIND (only you moved): push succeeds instantly.
4. ```bash
git push origin main # succeeds, fast-forward on the server
**Why this step?** Here `origin/main` is an **ancestor** of your local `main` — the *fast-forward quadrant* across the network. The server can simply slide its `main` pointer up to your tip. No merge, no rejection. This is the everyday happy path.
**Verify:**
- Part 1: nodes you contribute over the common base = your 1 + their 1 + 1 merge = $1 + 1 + 1 = 3$.
$$\text{Part 1 new nodes} = 1 + 1 + 1 = 3$$
- Part 2: commits the server gains = your 2, with 0 merge commits.
$$\text{Part 2 server gains} = 2, \quad \text{merges} = 0$$
In plain words: Part 1 adds 3 nodes over the shared base; Part 2 adds 2 commits and 0 merges. Rejection happens **iff** you diverged; a plain push works **iff** the remote is your ancestor. ✓
Worked example Example 7 — commits on no branch
Scenario: (fresh repo.) you checkout a commit hash (not a branch) to inspect an old version, then commit.
Forecast: where does the new commit live — on main, or nowhere?
Steps:
git checkout < old-has h >
# You are in 'detached HEAD' state
Why this step? HEAD (your "where am I" pointer) now points directly at a commit instead of at a branch pointer. There is no branch to advance.
echo "x" > x.txt ; git add . ; git commit -m "orphan"
Why? The commit is created — but no branch name moves to it. If you checkout main now, this commit becomes unreachable (eligible for garbage collection).
git branch rescue # NOW a name points at the orphan
Why? Giving it a branch name anchors it so it is never lost.
Verify:
git branch --contains HEAD # lists 'rescue' -> commit is saved
Before step 3 the commit belonged to 0 branches; after, it belongs to 1 . ✓
Mnemonic Detached HEAD = a balloon with no string
The commit floats. Tie a branch string (git branch name) before you walk away, or the balloon drifts off and Git eventually pops it.
Worked example Example 8 — you committed a secret API key (and a 2 GB dataset)
Scenario: you ran git add . and accidentally committed secrets.env and data.csv (huge). You have not pushed yet.
Forecast: is deleting the file in a new commit enough to keep the secret out of history?
Steps:
rm secrets.env
git add secrets.env
git commit -m "remove secret"
Why this "obvious" step is WRONG: the secret still lives in the previous commit's snapshot. Anyone who checks out that commit sees it. Deletion going forward ≠ deletion from history.
git reset --soft HEAD~1 # undo last commit, keep changes staged
git rm --cached secrets.env data.csv
echo "secrets.env" >> .gitignore
echo "*.csv" >> .gitignore
git commit -m "add code, ignore secret and data"
Why this step? HEAD~1 means "one commit before now". Because we never pushed, we can rewrite the still-local tip: uncommit, untrack the two files, ignore them, and re-commit without them ever entering the new snapshot.
Verify: the tracked-file count should exclude both.
git ls-files | grep -c -E 'secrets.env|data.csv' # -> 0
Zero — the secret and the big file are not in any tracked snapshot on this fresh tip. ✓ (If you had already pushed , you'd need history-rewriting tools like git filter-repo and to rotate the key — assume it leaked.)
What the figure above shows (figure s04): the same blue chain C1 -> C2 -> C3 drawn once, with three different undo actions branching off it. revert (green) adds a new node C4 undo to the right — history grows forward. reset --hard C2 (red arrow) points backward from C3 to C2 — the tip pointer moves back and C3 is dropped. checkout -- foo.py (yellow box) sits off to the side labelled "history UNTOUCHED, discards file edits only" — it never touches the graph at all. One picture, three utterly different "undos": forward, backward, and sideways.
Worked example Example 9 — three ways to "undo", three different meanings
Scenario: an exam asks: "Commit C3 is broken and already pushed. Undo it safely. Then, separately, discard uncommitted edits in foo.py."
Forecast: which of reset, revert, checkout is safe on pushed history?
Steps:
Undo the pushed commit → revert (safe).
git revert C3
Why this step? revert creates a new commit that applies the inverse diff of C3. History only grows forward (the green C4 in figure s04), so collaborators are never surprised by rewritten hashes. This is the only safe undo for shared history.
Why NOT reset here?
git reset --hard C2 # DANGEROUS on pushed commits
Why avoid? reset moves the branch pointer backward (the red arrow in figure s04) and rewrites the tip — fine locally, but on pushed history everyone else still has C3, causing rejected pushes and confusion.
Discard uncommitted edits → checkout / restore.
git checkout -- foo.py # or: git restore foo.py
Why? This throws away working-directory changes only (the yellow box in figure s04), replacing foo.py with the last committed version. It touches no history at all.
Verify: count history nodes after each, assuming main had 3 commits (C1,C2,C3):
revert → 3 + 1 = 4 commits (adds an undo commit).
reset --hard C2 → 3 − 1 = 2 commits (removes the tip).
checkout -- foo.py → still 3 commits (history untouched).
4 = 2 = 3
In plain words: revert = 4 commits, reset = 2 commits, checkout = 3 commits — three different counts confirm three different operations. ✓
Recall After
git init on an empty folder, how many commits exist — and how can you make one with no file changes?
Zero — init creates the .git folder and a branch pointer (main or master) aimed at nothing. ::: You need one git add + git commit for your first snapshot, or git commit --allow-empty to record a deliberately empty commit.
Recall Fast-forward vs three-way merge — the deciding question, and how do you force a merge commit anyway?
"Did both branches gain commits since they split?" ::: If only one did → fast-forward (no merge commit); pass --no-ff to force a merge commit anyway. If both did → three-way merge (a merge commit with two parents).
Recall When
git merge hits a conflict, does it create the merge commit automatically?
No — it stops , writes conflict markers into the file, and waits. ::: You edit the file to one final version, remove all markers, git add it, then git commit — only then is the merge commit born.
Recall When does
git push get rejected, and when does it succeed instantly?
Rejected when you diverged (remote is ahead) — you must pull first. ::: Succeeds instantly when the remote (origin/main) is your ancestor (remote behind) — the server just fast-forwards.
Recall Which undo is safe on already-pushed commits?
git revert — it adds a new inverse commit instead of rewriting history. ::: git reset --hard rewrites the tip and breaks collaborators.
Recall What is
HEAD?
A pointer that answers "where am I in the history right now?" — normally it points at a branch, which points at your latest commit. ::: In detached-HEAD state it points straight at a commit with no branch attached.