Keentune

Git curriculum

24 chapters
·
213 concepts
·
free
Everything the adaptive question bank can teach and test in Git, from foundations through advanced practice. Work through it in order, or start practising and let the questions find your level.
New here? Read the Git guide
A free 15-minute primer — the mental model, the mistakes beginners make, and what to practise first.
A. The object model
an object's name is the hash of its type, length and content, so the name is also the integrity check
blob, tree, commit and tag are the only things a repository stores
a blob has no filename, no path and no timestamp; the name lives in the tree that points at it
a tree maps each entry name to a mode plus the id of a blob or another tree
a commit names one root tree, so it snapshots the whole project rather than a change
zero parents is a root commit, one is ordinary, two or more is a merge
diffs are computed on demand; deltas exist only inside packfiles, as storage
change one byte and the id changes, so history is append-only and "editing" always means replacing
100644, 100755, 120000 for a symlink and 160000 for a submodule; of file permissions git records only the executable bit
B. Refs, HEAD and reachability
a branch is a file holding one commit id, so creating a branch copies nothing
HEAD normally contains ref: refs/heads/<name>, which is what makes a new commit advance that branch
with HEAD holding a raw id, commits succeed but no branch follows them
refs/heads, refs/tags, refs/remotes and refs/notes, and why the full path disambiguates a name
refs/remotes/origin/main is a local cache of what origin had at the last fetch, not a live view
branch.<name>.remote plus branch.<name>.merge is what makes a bare pull and @{u} work
refs may live individually as files or together in packed-refs, and both are read
an object survives because some ref reaches it, not because it was once committed
--orphan starts a branch with no parent, so it shares no history with anything
C. The index and the three trees
HEAD, the index and the working tree are three separate states, and each command moves a specific subset
editing a file after git add leaves the older content staged for the commit
git add -p stages individual hunks, so one file can be half-committed
the left column is index-vs-HEAD, the right is worktree-vs-index
an untracked file is invisible to commit, to diff and to a plain stash
git rm --cached removes a path from the index while leaving the file on disk
both hide local edits from status; only skip-worktree is intended to survive other commands
during a conflict the index holds stage 1 base, stage 2 ours and stage 3 theirs for the same path
git tracks files, so a directory containing nothing cannot be committed
D. Configuration
system, then global, then local, then worktree, then -c on the command line
user.name and user.email are copied into each commit, so changing them never fixes old ones
alias.* expands to a git subcommand, and a leading ! runs an arbitrary shell command instead
includeIf gitdir: swaps identity or settings per directory tree, e.g. work versus personal
core.autocrlf and core.eol rewrite line endings on checkout and check-in, which changes content
on a case-insensitive filesystem, renaming only the case of a filename needs mv -f or two commits
pull.rebase and pull.ff decide whether pull merges, rebases, or refuses on divergence
where a token is cached, and that the stored URL decides which entry a request matches
E. Making commits
a short subject, a blank line, then the body; every tool treats that first line as the summary
rebase, amend and cherry-pick preserve the author but reset the committer to you, now
-a stages modifications and deletions of tracked files, and never adds a new file
amending writes a new commit with a new id; the previous one survives only in the reflog
--fixup=<sha> writes a message that rebase --autosquash knows how to fold in
Signed-off-by and Co-authored-by are parsed key/value trailers, not decorative last lines
-S attaches a GPG or SSH signature, and --show-signature or %G? reports whether it verifies
one logical change per commit is precisely what makes revert, bisect and cherry-pick usable
F. Ignoring files, and attributes
an ignore rule never un-tracks a file that is already in the index
the last matching pattern decides, and a .gitignore deeper in the tree overrides a shallower one
! cannot re-include a file when a parent directory is itself excluded
a leading slash anchors the pattern to that directory; a trailing slash matches directories only
** crosses path separators, while a single * stops at one
git check-ignore -v names the file and line number of the rule that excluded a path
.git/info/exclude is per-clone and never committed, unlike .gitignore
.gitattributes pins per-path eol handling and marks a path binary, overriding each user's config
merge=union or a custom driver changes how conflicts in one path are resolved
content filters transform a file on check-in and checkout, which is the mechanism behind LFS
G. Naming revisions
HEAD~2 walks two first-parents back, while HEAD^2 selects the second parent of one merge
A..B is the commits reachable from B but not A, which is reachability rather than a diff
A...B is the symmetric difference: unique to either side, excluding shared ancestry
git diff A...B compares B against the merge base, so the three dots mean something else here
main@{2.days.ago} reads that branch's reflog, so it is local and may simply not exist
HEAD@{3} is a position in the reflog, not an ancestor in history
@{u} resolves to the configured upstream and @{push} to the push destination
HEAD:src/a.js names a blob inside a commit, and :0:src/a.js names the index copy
a prefix works while it is unambiguous, and the length needed grows as the repository grows
-- tells git that the following words are paths, not revisions, when a name could be both
H. Reading history: log and blame
--graph draws the DAG, and --topo-order keeps clock skew from interleaving unrelated branches
git log -- <path> prunes history and can hide merges that did touch the path
--follow continues past a rename, and works for a single path only
-S finds commits that change how often a string occurs; -G matches the text of the diff itself
--grep and --author search commit metadata, never file content, and --all-match combines them
--first-parent follows the mainline, collapsing each merged topic branch to a single entry
a merge shows no patch by default; -m, -c and --cc each present it differently
blame reports the last commit that touched each line, which is not who wrote it
-M and -C follow lines moved within a file or copied from another
--ignore-rev and blame.ignoreRevsFile skip a bulk reformat so real authorship shows through
git log -L :func:file shows every change to one function instead of the whole file
I. Diffs and patches
plain git diff compares worktree to index, so it shows nothing once everything is staged
--staged (--cached) compares the index to HEAD, which is exactly what the next commit will contain
the @@ -a,b +c,d @@ header gives the start line and line count on each side
git stores no rename; it is inferred at display time from content similarity
-w hides reformatting noise, and --check flags whitespace errors you are about to commit
--diff-filter=ACDMR restricts output to added, copied, deleted, modified or renamed paths
git apply fails when context has drifted, and --3way falls back to a real merge using the blob ids
format-patch writes mail-formatted commits and am replays them, preserving author and message
J. Branching
a new branch writes one small ref file, so branching never copies the working tree
switch changes which branch you are on, restore changes file contents; checkout did both
switching carries dirty files along unless the switch would overwrite them
a branch can start at any tag, id or remote-tracking ref, not only at the current HEAD
-d refuses to delete a branch that is not merged, and -D deletes the ref anyway
--merged lists branches already contained in the current one, which is how you prune safely
establishing or repairing the tracking link after the branch already exists
the counts compare against the remote-tracking ref, so they are stale until you fetch
K. Merging
when the target already contains your commits git just moves the pointer, creating no merge commit
--no-ff forces a merge commit for the record, --ff-only refuses anything that is not a fast-forward
the best common ancestor is the third input that both sides are compared against
changes touching different regions are taken from both sides automatically; overlaps become conflicts
<<<<<<< ours, =======, >>>>>>> theirs, plus the ||||||| base section when diff3 style is on
during a merge "ours" is the branch you are on, and during a rebase that meaning inverts
running git add on the conflicted path is what declares the conflict settled
--abort restores the pre-merge state, while --quit leaves the half-merged index and worktree in place
--squash produces one ordinary commit with no second parent, so git no longer records that the branch merged
merging two independent roots is refused without --allow-unrelated-histories
L. Merge strategies and rerere
ort replaced recursive as the default merge strategy in Git 2.34
when several candidate merge bases exist, the strategy merges those bases into a virtual one
-s ours records a merge commit while keeping your tree byte for byte
-X ours only auto-resolves conflicting hunks, so their non-conflicting changes still land
-X theirs resolves conflicts toward the incoming side, again only where there is a conflict
how a merge survives a whitespace-only reformat on one side
merging a project so that its root aligns with a subdirectory of yours
rerere caches how you resolved a conflict and replays it when the same conflict recurs
M. Rebasing and rewriting history
every rebased commit gets a new id even when its diff is identical
do not rebase commits that others have already based work on
--onto moves one specific range to a new base, which is how you drop an obsolete intermediate branch
pick, reword, edit, squash, fixup, drop, exec and break, applied top to bottom
squash opens both messages for editing, fixup silently discards the second
--autosquash reorders fixup! and squash! commits under their targets before the todo list opens
resolve, git add, then --continue; --skip drops that commit and --abort unwinds the whole rebase
mid-rebase "ours" is the branch you are rebasing onto, which reads backwards to most people
--autostash sets dirty work aside before the rebase and restores it afterwards
-x "npm test" runs a command after each replayed commit, finding exactly which one broke
--force-with-lease refuses if the remote moved since your last fetch, which plain --force would overwrite
filter-branch is deprecated and slow; git filter-repo is the recommended history rewriter
editing one old commit changes the id of every commit after it
N. Cherry-pick and revert
cherry-pick applies one commit's diff as a new commit with a new id
git cherry-pick A..B starts at A's child, so A itself is not picked
picking a merge commit requires -m 1 or -m 2 to say which parent the diff is taken against
-x appends a "cherry picked from" line so the duplicate stays traceable
a picked commit can conflict with itself when its source branch is merged later
revert records a new commit that undoes a change, and removes nothing from history
reverting a merge needs -m, and afterwards re-merging that branch brings back nothing
reverting the revert is the documented way to re-land a previously reverted merge
O. Undoing: reset, restore and clean
moves the branch pointer and leaves both the index and the working tree untouched
the default: moves the branch and rewrites the index, keeping your file contents on disk
moves the branch and overwrites index and worktree, discarding uncommitted work permanently
git reset <path> never moves the branch; it copies that path from a commit into the index
--worktree, --staged, or both, decide which of the three trees git restore rewrites
--source=<rev> pulls one file's content out of any commit without moving the branch
git clean requires -f, takes -d for directories and -x for ignored files, and -n previews
reset rewrites the branch while revert appends a commit, so only one is safe on a shared branch
merge, rebase and reset record the previous position in ORIG_HEAD, giving a one-step undo
P. Stashing and multiple working trees
a stash entry is stored as real commits under refs/stash, not as a patch file
a plain stash leaves untracked files behind; -u includes them and -a adds ignored ones too
pop deletes the entry only if it applied cleanly, while apply always keeps it
stash@{0} is the newest entry, so every index shifts when you stash again
a pop that conflicts leaves the entry on the stack, so nothing is lost while you resolve
the commit is merely dangling, so fsck --unreachable plus a new ref brings it back
git worktree gives several working directories over one object store, with no second clone
the same branch cannot be checked out in two worktrees at once
a worktree shares refs, config and stashes with the main repo; a clone shares nothing
Q. Remotes, transports and clones
origin is only a default name, and a repository may have any number of remotes
ssh, https, the git daemon and plain local paths differ in authentication and in whether they allow writes
fetch updates remote-tracking refs and the object store, and never merges into your work
the second half is a merge or a rebase, depending on configuration
+<src>:<dst> maps a source ref onto a destination, with the leading + permitting a non-fast-forward update
a push is refused when the remote has commits you lack, which is what "fetch first" means
a compare-and-swap against the remote value you last saw, unlike a blind --force
fetch --prune deletes tracking refs whose branch is gone from the remote
a push sends no tags by default; --tags sends all of them and --follow-tags sends reachable annotated ones
a repository with no working tree, which is what a server hosts and what you can safely push into
--depth truncates history, which speeds up CI but limits merge bases, blame and later rebases
--filter=blob:none defers file contents until a command actually needs them
R. Tags, describe and notes
a lightweight tag is just a ref, while an annotated tag is an object with tagger, date and message
a tag stays on its commit, so new commits on the branch never advance it
an ordinary push sends no tags, which is how a release tag goes missing on the server
moving a published tag does not update existing clones, so publish a new version instead
git describe names a commit as <tag>-<commits-since>-g<sha>, which is where build version strings come from
-s signs a tag and -v verifies it, the usual release-provenance mechanism
git notes attaches mutable metadata to a commit without changing the commit's id
S. Hooks
.git/hooks is not part of the repository, so cloning installs nobody's hooks
pointing core.hooksPath at a tracked directory is how a team actually shares hooks
runs before the message is composed, and any non-zero exit aborts the commit
receives the path to the message file and may validate it or rewrite it in place
seeds the message before the editor opens, for example with a ticket id
the last client-side gate, given the refs and ids being pushed on stdin
pre-receive, update and post-receive, and that only the first two can reject the push
client hooks are advisory, so any real policy must also be enforced server-side
T. Bisecting a regression
roughly log2(n) tests isolate the first bad commit among n candidates
the good commit must be an ancestor of the bad one, or the search is undefined
git bisect run <cmd> reads the exit code: 0 is good, 1-124 is bad, 125 means untestable
marks a commit that cannot be built or tested so the search routes around it
bisect leaves a detached HEAD until git bisect reset returns you to your branch
--term-old and --term-new when you are searching for when something started working
a history full of broken intermediate commits makes bisect useless
U. Submodules and vendoring
the superproject stores one commit id with mode 160000, never the submodule's files
.gitmodules is the committed mapping, while .git/config holds this clone's actual URL
a fresh clone leaves submodule directories empty until init and update, or --recurse-submodules
submodule update checks out the recorded id, so commits made there sit on no branch
updating a dependency means committing the new pointer in the parent repository
submodule.<name>.branch with update --remote follows a branch instead of the pinned id
deinit, then git rm, and the .git/modules directory still lingers
git subtree vendors the code into the tree itself, so clones need no extra command
V. Maintenance, packing and large repositories
gc packs loose objects, expires reflog entries and prunes unreachable objects, and it also runs automatically
packs store objects delta-compressed against similar objects, which is storage and not the object model
unreachable objects survive a default two-week window, which is what makes most recovery possible
cached generation numbers make reachability queries and topo-order walks fast on a large history
verifies object integrity and lists dangling and unreachable objects
cone mode materializes only part of the tree while the repository itself stays complete
a large file stays in every clone's history until history is rewritten; LFS avoids adding it in the first place
git maintenance start runs upkeep in the background instead of ad-hoc gc
W. Reflog and recovery
every update to HEAD and to each branch is logged locally, with the command that caused it
reflogs are never cloned, fetched or pushed, so a fresh clone has none of your history of moves
find the old tip in the reflog and re-create the ref at that id
HEAD@{1} is where you stood before the last move, so reset --hard HEAD@{1} reverses it
the overwritten commits are still in your local object store until they are pruned
reachable entries expire after 90 days and unreachable ones after 30, after which gc may collect them
when no reflog entry survives, fsck --lost-found can still surface the commit
every recovery trick works only until the objects are actually pruned
X. Collaboration workflows
direct push access versus a fork plus a pull request, and what each implies for permissions
the branch is the unit of review, of merge and of revert
a stable trunk with a staging branch in front of it, and the rule for graduating work
a faithful history versus a readable linear one, and what each costs when debugging
the common compromise: linear content, plus one merge commit marking the topic
force-push bans and required checks are enforced by the hosting forge, not by git itself
add an upstream remote, fetch it, and rebase your topic onto its default branch
cherry-picking a fix onto a maintenance line, and forward-porting it to the trunk
Keentune is not affiliated with or endorsed by the organizations whose documentation informs these maps.
All about Git practice
Also on your phone
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.
© 2026 SportaApp LLC