Git stores snapshots, not changes. Almost every command that confuses people makes sense once that lands: a commit is a complete picture of your files, a branch is a sticky note pointing at one, and history is those pictures linked by parentage. The commands are a thin layer over that model, which is why memorizing commands produces someone who is stuck the moment a situation differs from the recipe. Read this for the model. Once you can say where a change lives — working tree, index, or a commit — the right command is usually obvious.
Each chapter opens with the short version. Tap one to read the detail.
What a repository actually stores
~2 min
Git is a content-addressed store. A file's contents become a blob named by the hash of those contents, a tree records a directory, and a commit points at one tree plus its parents. Branches and tags are just names pointing into that graph.
Save a file and Git hashes its contents and stores them under that hash. That is a blob. A tree lists filenames and the blobs or trees they point at — a directory. A commit points at exactly one tree, which is the entire project at that instant, plus a pointer to its parent commit or commits.
Two consequences follow, and they explain most of Git's behavior. Identical content is stored once no matter how many files or branches contain it, because the same bytes produce the same hash. And a commit's identity depends on everything it points at, including its parent — which is why you cannot change history without changing every hash downstream of the change.
Refs are the human layer on top. A branch is a file containing a commit hash; HEAD is a pointer to whichever branch you are on. Nothing else distinguishes a branch from a tag from a stash entry — they are all names for commits.
A commit reachable from no name still exists on disk, which is why "lost" work is usually recoverable.
Rule: think of history as a graph of snapshots and names pointing into it, not as a list of diffs.
The three trees, and why add catches people out
~2 min
A change lives in one of three places: the working tree, the index (what your next commit will contain), or a commit. git add copies the file as it is at that moment — edit it again and you must add it again.
The working tree is the files on disk. The index — also called the staging area — is a full snapshot of what the next commit will contain. The repository holds committed snapshots. Nearly every everyday command moves content between these three.
The classic surprise is that staging captures a moment, not a file. Stage a file, edit it again, and commit: the commit contains the version you staged, not the one on disk.
git add report.md # stages the current contents
vim report.md # keep editing
git commit -m "..." # commits the STAGED version, not what you just typed
git status distinguishes the two states plainly — "changes to be committed" versus "changes not staged for commit" — which is why reading it carefully solves most confusion before it starts.
This is a feature rather than a wart: the index lets you commit a coherent subset of your work while leaving the rest in progress. git add -p leans into it, staging individual hunks so an unrelated fix does not ride along in a commit.
Rule: before running any command, ask which of the three trees you want to change; git status will tell you where things currently are.
Making commits
~2 min
A commit records a snapshot plus who made it and when, taken from your configured identity. --amend does not edit a commit — it replaces it with a new one, which matters the moment the original has been pushed.
Committing writes whatever the index holds, together with an author name, email, and timestamp drawn from your configuration. Setting user.name and user.email is the first thing to do in a new environment, because Git will otherwise guess from the system and stamp every commit with something you do not want.
-a is a convenience that stages tracked files that were modified or deleted before committing. It skips files Git has never seen, so a brand-new file is silently left out of a commit -a.
--amend looks like editing the previous commit and is not. It builds a replacement with a new hash, keeping the original message and parents as a starting point. Locally that is exactly what you want for a typo in a message or a forgotten file. Once the original has been pushed, the replacement is a different commit and anyone who pulled the old one now has a diverged history.
Good commits are scoped so their message can be one honest sentence; if the message needs "and", it is probably two commits.
Rule: amend freely before you push, and treat pushing as the moment a commit becomes permanent.
Naming commits, and reading history
~2 min
Almost every command takes a revision, and the two operators people mix up are ~ and ^. Ranges matter just as much: A..B asks what is in B and not A, which is the question you usually mean.
You can name a commit by hash, by branch, by tag, or relative to another. HEAD~3 walks back three generations following first parents. HEAD^2 means the second parent — which only exists on a merge commit. In a linear history ~ and ^ do the same thing, and that coincidence is exactly why the distinction bites later.
Ranges are the other half. A..B means commits reachable from B but not from A: what does this branch have that main does not. A...B is the symmetric difference — what each has that the other lacks — which is what you want when comparing two divergent branches.
git log --oneline origin/main..HEAD # my unpushed work
git log --oneline main...feature # where the two diverged
git log reads the graph; git diff reads content. The confusion worth naming is that bare git diff shows working tree against the index — not against the last commit. Staged changes vanish from its output, which reads as "my changes disappeared". git diff --staged shows the other half.
Rule: say which two points you are comparing, then pick the operator — most "wrong output" here is a comparison of the wrong pair.
Branches are pointers, which is why they are free
~2 min
A branch is a file containing one commit hash. Creating and deleting them costs nothing, so branch often. -d refuses to delete unmerged work; -D does it anyway.
There is no copy of the code behind a branch. It is a name holding a commit hash, and switching branches means updating HEAD and adjusting the working tree to match that commit's tree. That is why branching is instant regardless of repository size, and why "should I make a branch for this?" is almost always yes.
Committing moves the current branch's pointer forward. Nothing else about the repository changes.
The safety distinction worth knowing cold is the delete flags. -d refuses when the branch holds commits not merged anywhere else, which is Git protecting work you would otherwise have to hunt for in the reflog. -D forces it.
git branch -d feature # refuses if unmerged — listen to it
git branch -D feature # deletes regardless
If -d refuses and you are certain, the honest move is to check what is unmerged first rather than escalating to -D reflexively. A refusal is information.
Branch names have no inherent meaning to Git — main is a convention, not a keyword. Any structure your team relies on lives in habits and tooling, not in the tool.
Rule: branch freely, and treat a -d refusal as a question to answer rather than an obstacle to force past.
Merging, and what a conflict actually is
~2 min
If your branch has not diverged, a merge is just a pointer move — a fast-forward, with no merge commit. If both sides changed, Git makes a commit with two parents. A conflict means Git will not guess, not that something broke.
When the branch you are merging is directly ahead of yours, there is nothing to reconcile: Git slides your branch pointer forward. That is a fast-forward, and it leaves no record that a branch existed.
When both sides have commits the other lacks, Git builds a merge commit with two parents, combining the changes. Where the two sides touched different things it merges silently; where they touched the same region it stops and writes both versions into the file between markers, leaving the index holding all three versions — original, yours, theirs.
A conflict is not an error state. It is Git declining to invent an answer.
Two options change the shape of history rather than the content. --no-ff forces a merge commit even when a fast-forward was possible, keeping visible evidence that a branch existed. --squash does the opposite: it stages the combined result with no merge relationship recorded, so the branch's history disappears into one commit.
Rule: resolve conflicts by deciding what the file should say, not by picking a side mechanically — and remove every marker before staging.
Rewriting history, and the one rule about it
~2 min
Rebase replays your commits onto a new base, producing new commits with new hashes. That is why the rule is absolute: do not rebase anything others have already based work on.
Rebasing takes your commits and replays them one at a time on top of a different starting point. Because a commit's hash depends on its parent, every replayed commit is a new commit — the originals still exist but nothing points at them.
Locally this is excellent. Rebasing a feature branch onto the latest main gives a linear history that reads as though you started today, and interactive rebase lets you reorder, reword, squash, or drop commits before anyone sees them.
The rule follows directly from new hashes. If a branch has been pushed and someone else has built on it, rewriting it means their commits now descend from commits that no longer exist, and they have to repair that by hand. Rebase your own unpublished work; merge everything else.
Cherry-pick is the same mechanism aimed at one commit — copy this change onto that branch — and it produces a new hash for the same reason. Revert is the alternative that does not rewrite anything: it makes a new commit undoing an old one, which is the right tool once history is shared.
Rule: rebase before you push, revert after.
Undoing, and why almost nothing is really lost
~2 min
reset moves your branch pointer; how much it drags along depends on the flag. Only --hard touches your files, and it is the one that can lose work. The reflog remembers where your branch used to point, which makes most mistakes recoverable.
Reset moves the current branch to a commit you name. The flag decides what else follows.
•
--soft moves the branch only; your index and files are untouched, so the changes appear staged.
•
--mixed, the default, also resets the index; your edits survive as unstaged changes.
•
--hard also overwrites the working tree, discarding uncommitted changes to tracked files.
--hard is the one to slow down on. Committed work it removes is recoverable; uncommitted work it discards is not.
For everyday "undo this file" there is a better tool. git restore <file> discards working-tree changes and git restore --staged <file> unstages, neither of which moves your branch. Reaching for reset when you meant restore is how people move history by accident.
The safety net is the reflog: Git records every position HEAD and your branches have held, so a branch deleted or reset away can be recovered by finding its old hash.
git reflog # every position HEAD has held
git reset --hard <old-hash> # put the branch back
Rule: commit before anything risky — committed work is recoverable, uncommitted work is not.
Remotes: fetching, pulling and pushing
~2 min
fetch downloads and updates origin/main without touching your branch or your files. pull is fetch plus a merge or rebase. Understanding that split explains most confusion about being behind or diverged.
A remote is a named address. Fetching from it downloads new commits and updates your remote-tracking branches — origin/main is your local record of where main was on the server at last contact. Fetch never changes your branch or working tree, which makes it completely safe to run at any time.
Pull is fetch followed by integration. That second step is where surprises live: depending on configuration it merges or rebases, and an unexpected merge commit or an unexpected rewrite usually traces back to that setting.
git fetch # safe: only updates origin/*
git log --oneline HEAD..origin/main # what arrived that I don't have
"Your branch is behind" means your branch's commit is an ancestor of the remote-tracking branch. "Diverged" means both have commits the other lacks — the point at which you must choose merge or rebase rather than being given one.
Tags travel differently from branches: they are not pushed by default, so a release tag exists only locally until pushed explicitly. A tag is meant to be permanent, so moving a published one is worse than rewriting a branch — consumers who already have it will not see the change.
Rule: fetch early and read HEAD..origin/main before deciding how to integrate.
Ignoring files, and stashing work in progress
~2 min
.gitignore only prevents untracked files from being added — it has no effect on a file Git is already tracking, which is the single most common surprise. Stash parks uncommitted work when you need a clean tree.
Ignore patterns are matched against untracked files. Once a file is tracked, adding it to .gitignore changes nothing at all: it stays in the index and its edits keep showing up. This is why committing a secret or a build artifact and then "fixing it" by ignoring it does not work.
The fix is to untrack it first, then ignore it.
git rm --cached .env # stop tracking, keep the local file
echo ".env" >> .gitignore
Note that this removes the file from future commits but not from history — a committed secret must be treated as compromised regardless.
Patterns are ordinary globs with a few extras: a leading slash anchors to the directory, a trailing slash matches directories only, and a leading ! re-includes something an earlier pattern excluded. Files are matched by the nearest .gitignore first, so a rule deeper in the tree overrides one at the root.
Stash is the neighboring tool: it takes uncommitted changes off your working tree and stores them so you can switch context, then git stash pop puts them back. It is a stack, and things left on it are easy to forget — git stash list is worth checking occasionally.
Rule: ignoring never untracks; use rm --cached first, and treat anything already committed as public.
Collaborating without stepping on each other
~2 min
Nearly every workable team convention is the same shape: short-lived topic branches off a shared integration branch, merged back when done. The failure modes are long-lived branches and rewriting anything already shared.
The durable pattern is a branch per piece of work, cut from the integration branch it will return to, and merged back once it is finished. Everything else — naming, review, who merges — is team preference layered on top.
Two failure modes recur regardless of convention. A branch that lives for weeks accumulates conflicts against a moving target, and the cost of integrating grows faster than the work itself; branching often and merging early is not fussiness but conflict avoidance. And rewriting a shared branch forces everyone downstream to repair history by hand, which is the same rule the rebase chapter arrives at from the other direction.
Merge direction matters too: fixes belong on the oldest branch that needs them and travel forward, because merging a less stable branch into a more stable one drags along everything else it contains.
Hooks are scripts Git runs at defined points — before a commit, after a merge — and they are local by default, so a hook on your machine is not a guarantee about anyone else's. Enforcement that must hold for the team belongs in continuous integration, not in a hook.
Rule: keep branches short-lived, merge upward not downward, and never rewrite what you have shared.
Finding the commit that broke it
~2 min
Bisect binary-searches history for the commit that introduced a problem. Mark one bad commit and one good one, and it halves the range each step — a few hundred commits becomes a handful of checks.
When something worked and now does not, and the cause is not obvious, bisect is faster than reading diffs. You tell Git a commit where the problem exists and one where it does not; it checks out the midpoint and asks. Each answer halves the remaining range, so several hundred commits resolve in about eight or nine checks.
The step people skip is deciding the test first. "Bad" has to mean something you can evaluate identically at every commit — a failing test, a specific error, a measurable threshold. A vague sense that things feel wrong produces a confident answer pointing at the wrong commit.
Once the check is a command, hand it over entirely:
git bisect start HEAD v1.4 # bad, then good
git bisect run npm test -- foo # exit 0 = good, non-zero = bad
Git then walks the range unattended and names the first bad commit.
Two things to know: mark a commit that cannot be evaluated as skip rather than guessing, and remember git bisect reset when you are done, since you are otherwise left on a detached checkout partway through history.
Rule: define the pass/fail check before starting, then automate it — bisect is only as trustworthy as that test.
Written by Keentune. We are not affiliated with or endorsed by the organizations whose documentation informs this guide, and any linked sources belong to their respective owners.
All exam, test, and product names and trademarks are the property of their respective owners and are used here for identification and reference only. Keentune is independent study practice — not affiliated with, authorized, or endorsed by any of these organizations.