Skip to main content

Git โ€” The Complete Guide

A single-read, end-to-end reference for Git: enough to reason about what's actually happening under the hood, work a feature branch confidently, or walk into an SDE interview. Organized as a lookup you can also read top-to-bottom.

๐Ÿ“‹ Quick reference: Git โ†’
How to use this page

This guide explains why Git behaves as it does โ€” the model under the commands, and the trade-offs between them. New to Git? Learn the commands step by step with the Git cheat sheet and its practice repo first, then practise with the Git learning path. Come back here when you want to understand what a command really does.

Beginner
Learn this in 10 minutes10 min
  1. Learn the three trees: working directory, staging area, repository
  2. Branch, merge, and see how rebase differs
  3. Practise Undoing Things Safely: restore, reset, revert, reflog
  4. Skim Common Gotchas before your next merge conflict
After this guide you can
  • Explain Git's object model and the three trees
  • Branch, merge, and rebase with confidence
  • Resolve conflicts and undo mistakes safely
  • Use tags, .gitignore, and hooks

๐Ÿงญ Mental modelThe git snapshot pipelineEdits in the working directory are staged with git add, committed into the local repo as a DAG of snapshots, and pushed to a remote. Git fetch or pull brings remote history back, and git checkout or restore brings a snapshot back into the working directory.Working Diryour edited filesgit addStaging Areagit add snapshotgit commitLocal RepoDAG of commits(snapshots, not diffs)git pushRemote Repoorigin (GitHub)git fetch / pullgit checkout / restore

Changes flow one way through git add, commit, and push โ€” from working directory to staging to the local snapshot history to a remote โ€” and flow back the other way through fetch/pull and checkout/restore, which together are most of what git commands do.

1. What Git Actually Isโ€‹

In short: Git keeps a full history of snapshots on every copy of a project, linked together like a family tree โ€” understanding that picture explains every command.

Git is a distributed, content-addressable version control system (VCS): a tool that records the history of a project's files (version control), where every copy is complete (distributed), and where everything stored is named by a fingerprint of its contents (content-addressable). Every clone is a full copy of the repository's history โ€” there is no single "central" copy required for the system to function (GitHub/GitLab are just a convention, not a technical requirement).

The mental model that unlocks everything else: Git is a directed acyclic graph (DAG) of snapshots, not a stack of diffs. A DAG is a set of points joined by one-way arrows that never loop back โ€” here, each commit points back to its parent commit(s). Each commit points to a full snapshot of the project (via a tree), not a delta against the previous commit โ€” deltas are only a storage optimization Git applies internally (packfiles), invisible at the model level. Understanding this DAG is what separates "I memorized some commands" from "I understand what rebase, reset, and merge are actually rewriting."


2. The Object Modelโ€‹

In short: everything Git stores is one of four kinds of object, each named by a fingerprint (hash) of its contents; branches and tags are just names pointing at commits.

Git's entire history is four object types, all stored under .git/objects, each addressed by the SHA-1 (or SHA-256 on newer repos) hash of its content โ€” identical content always produces the identical hash, which is what "content-addressable" means.

ObjectWhat it storesAnalogy
BlobRaw file contents only โ€” no filename, no pathA file's bytes
TreeA directory listing: names + modes + pointers to blobs/other treesA directory
CommitPointer to one tree (the snapshot), pointer(s) to parent commit(s), author, committer, timestamp, messageA labeled snapshot + metadata
RefA human-readable pointer to a commit hash (branch, tag, HEAD)A named bookmark
git cat-file -p HEAD              # show the commit object: tree, parent, message
git cat-file -p 'HEAD^{tree}' # show the tree object: entries and their blob hashes
git cat-file -p HEAD:README.md # show a blob's raw content (a file as stored in a commit)
git rev-parse HEAD # the commit's SHA

A commit is a snapshot, not a diff โ€” every commit's tree is a complete picture of the project at that point. Two files with identical content anywhere in history (even in unrelated commits) share the same blob object โ€” this is why Git is efficient about renames/duplicated content despite storing "full snapshots."

Refs and branchesโ€‹

A branch is nothing but a movable pointer to a commit โ€” a plain text file under .git/refs/heads/<name> containing a 40-character SHA. Creating a branch is O(1): write one small file. This is why Git branches are cheap enough to create per-feature, per-experiment, per-bugfix โ€” unlike centralized VCS branches, which historically meant copying the whole tree.

  • HEAD โ€” a ref pointing to the ref you currently have checked out (usually HEAD โ†’ refs/heads/main โ†’ <commit sha>) โ€” a pointer to a pointer.
  • Tags โ€” like branches but don't move as you commit; git tag v1.0 marks a specific commit permanently (annotated tags also store a message and signer, useful for releases).
cat .git/refs/heads/main            # literally just a commit SHA
git symbolic-ref HEAD # refs/heads/main

Why "DAG" and not "a stack of commits"โ€‹

Every commit points to its parent(s) โ€” a merge commit has two parents. Follow those parent pointers from any commit and you get the full ancestry graph: a directed acyclic graph, not a line. This is exactly why operations like merge-base (find the common ancestor of two branches) and rebase (replay commits onto a different point in the graph) are well-defined graph operations, not string/diff hacks.


3. The Three Trees: Working Directory, Staging Area, Repositoryโ€‹

In short: your files, the staging area, and the history are three separate places, and most commands just copy content from one to another.

Git tracks state across three areas, and almost every command is really about moving content between them:

AreaWhat it isCommand that updates it
Working directoryThe actual files on disk you editYour editor / git checkout, git restore
Staging area (index)A snapshot-in-progress of what the next commit will containgit add
Repository (HEAD / history)The committed, immutable historygit commit
working directory  --git add-->  staging area (index)  --git commit-->  repository (HEAD)
^ |
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ git checkout / restore โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

The staging area is Git's most distinctive feature versus most other VCS: it lets you build a commit incrementally โ€” stage only part of your changes (git add -p) to make small, logically coherent commits even when your working directory has multiple unrelated edits mixed together.

git status                    # see what's in working dir vs staged vs committed
git add file.py # stage a whole file
git add -p # interactively stage hunks โ€” the power move for clean commits
git diff # working dir vs staging area (unstaged changes)
git diff --staged # staging area vs last commit (what commit would contain)

Try it: in the practice repo, change two lines in app.py, then use git add -p to stage only one of them. Compare git diff and git diff --staged.


4. Branching, Merging, and Rebasingโ€‹

In short: merging joins two lines of history with a new commit; rebasing copies your commits onto a new starting point โ€” clean, but it changes their ids.

Creating and switching branchesโ€‹

git branch feature/checkout        # create, don't switch
git switch feature/checkout # switch (modern, Git 2.23+)
git switch -c feature/checkout # create + switch in one step
git checkout -b feature/checkout # older equivalent, still everywhere in scripts

Mergingโ€‹

git merge combines two branch histories by creating a new merge commit with two parents, preserving the exact history of both branches as it happened.

git switch main
git merge feature/checkout # creates a merge commit if main has diverged
  • Fast-forward merge: if main hasn't moved since the branch diverged, Git just slides the main pointer forward โ€” no merge commit, linear history. Force one explicitly to guarantee this or fail: git merge --ff-only. Force a merge commit even when a fast-forward is possible with git merge --no-ff (common in team workflows to keep feature boundaries visible in history).

Rebasingโ€‹

git rebase replays your branch's commits one by one onto a new base commit, producing brand-new commits (new SHAs, same content) โ€” it rewrites history rather than merging it.

git switch feature/checkout
git rebase main # replay feature commits on top of latest main
git rebase -i HEAD~4 # interactive rebase โ€” squash/reorder/reword last 4 commits

Interactive rebase, in practiceโ€‹

git rebase -i HEAD~5 opens your editor with the last 5 commits, oldest first, each on a pick line:

pick a1b2c3d Add parser skeleton
pick e4f5a6b Fix typo
pick 7c8d9e0 Add tests
pick 1a2b3c4 fixup: address review comment
pick 5d6e7f8 Add docs

# Commands:
# p, pick = keep commit as-is
# r, reword = keep contents, edit commit message
# e, edit = pause here to amend the commit
# s, squash = combine into previous commit, merge messages
# f, fixup = combine into previous commit, discard this message
# d, drop = remove commit entirely
# (reorder by moving lines)

A common real edit โ€” collapse the "fix typo" and "fixup" noise commits into their logical parent, and reword the first:

r a1b2c3d Add parser skeleton
f e4f5a6b Fix typo
pick 7c8d9e0 Add tests
f 1a2b3c4 fixup: address review comment
pick 5d6e7f8 Add docs

Save and Git replays commits in order, pausing at reword/edit steps, and opens a combined-message editor for squash (skipped automatically for fixup). If a conflict occurs mid-rebase, Git pauses; resolve it (see Section 7), git add the files, then git rebase --continue (--skip to drop the commit entirely, --abort to bail out and restore the pre-rebase state).

Tip: git commit --fixup=<sha> creates a properly-tagged fixup commit for an existing commit, and git rebase -i --autosquash <base> automatically reorders and marks it for you โ€” the standard "address review feedback without polluting history" pattern, so you never have to hand-edit the todo list to move a fixup next to its target.

Merge vs rebase โ€” when to use eachโ€‹

MergeRebase
History shapePreserves true history, including divergence (non-linear)Produces a clean, linear history
Commit SHAsUnchangedRewritten โ€” every replayed commit gets a new SHA
Safe on shared/pushed branches?Yes, alwaysNo โ€” rewriting history others have pulled causes divergence
Best forIntegrating a finished feature into main; preserving an accurate recordCleaning up your own local/private branch before opening a PR
Conflict resolutionOnce, at the merge pointPotentially once per replayed commit

Rule of thumb: rebase your own local, not-yet-shared work to keep history clean; merge (never rebase) once a branch is shared/pushed and others may have built on it. "Never rewrite history you've already shared" is the single most important Git safety rule.

Golden rule violation example (what goes wrong)โ€‹

# Alice pushes feature-x, Bob pulls and adds a commit on top
# Alice then rebases feature-x locally and force-pushes
git push --force origin feature-x
# Bob's local feature-x now has commits with different SHAs than origin's โ€”
# Bob's next `git pull` produces a confusing, duplicated-looking history
# or outright conflicts, because Alice's rebase orphaned the commits Bob built on.

Try it: make two commits on a branch and one on main. Merge the branch in one clone and rebase it in another, then compare the two git log --oneline --graph outputs.


5. Common Branching Workflowsโ€‹

In short: most teams use short-lived feature branches merged into main through pull requests; how long a branch lives matters more than which model you pick.

WorkflowShapeWhen it fits
Feature branch workflowOne short-lived branch per feature/fix, merged via PR into mainDefault for most teams โ€” small teams to large, works with CI/CD
Trunk-based developmentEveryone commits directly to main (or very short-lived branches, hours not days), behind feature flagsHigh-deploy-frequency teams, strong CI, feature-flag infrastructure
Git-flowLong-lived develop + main, plus feature/, release/, hotfix/ branch types with strict merge rulesScheduled/versioned releases (e.g. desktop software, mobile apps with app-store review cycles) โ€” heavier process, less common for continuously-deployed web services now

Most modern web teams use feature branch + trunk-based hybrid: short feature branches merged frequently into main via PR, with feature flags gating anything not ready for users โ€” git-flow's release branches are largely legacy for anything deploying more than a few times a month.

The single biggest predictor of merge pain isn't which model a team picks โ€” it's branch lifetime. Conflict risk grows nonlinearly the longer a branch lives without integrating trunk, because both sides keep drifting further from their common ancestor. A trunk-based team gets low conflict risk largely as a side effect of forcing short branch lifetimes; git-flow's long-lived develop/release branches earn their keep only when the process genuinely requires maintaining multiple versions in parallel (e.g. shipping v1.x patches while developing v2.0) โ€” not as a default choice.


6. Essential Commands Beyond add/commit/pushโ€‹

In short: fetch before you merge, search history with log, shelve work with stash, copy single commits with cherry-pick, find bugs with bisect, and rescue anything with reflog.

git fetch vs git pull โ€” the distinction that trips people upโ€‹

  • git fetch downloads new commits/refs from the remote into your remote-tracking branches (origin/main) and touches nothing else โ€” your local main and working directory are untouched. Always safe to run.
  • git pull = git fetch + git merge origin/<branch> (or git rebase with --rebase/pull.rebase=true). It does touch your current branch and can create a merge commit or a conflict right then.
git fetch origin                     # safe, inspect first
git log HEAD..origin/main --oneline # what did I miss?
git diff HEAD origin/main # what exactly changed?
git merge origin/main # now integrate, on your terms

Prefer fetch + inspect + merge/rebase explicitly over a blind pull on anything you care about โ€” it separates "did anything change" from "integrate it now," which is the source of most avoidable pull-time surprises. git pull --rebase is the right default for a branch that's only a commit or two behind and not yet pushed elsewhere.

git log โ€” reading historyโ€‹

git log --oneline --graph --all      # compact visual DAG across all branches
git log -p -- path/to/file.py # full diffs touching this file
git log --author="Alice" # filter by author
git log -S"functionName" # find commits that added/removed this exact string ("pickaxe")
git log main..feature/checkout # commits on feature/checkout not yet on main

git diff โ€” comparing statesโ€‹

git diff HEAD~3 HEAD                  # changes over the last 3 commits
git diff main feature/checkout # changes between two branches
git diff --stat # just file names + line counts, no content

git stash โ€” shelve work in progressโ€‹

git stash                              # shelve tracked changes, restore clean working dir
git stash push -m "wip: refactor auth" # stash with a message
git stash list # see all stashes
git stash pop # reapply the most recent stash and drop it
git stash apply stash@{2} # reapply a specific stash, keep it in the list
git stash drop stash@{2} # discard a stash without applying it
git stash push -u -m "wip" # include untracked files (-u); --all also includes ignored files

Use when you need to switch branches urgently (hotfix interrupt) without committing half-finished work. Stashes are themselves commits (on a hidden ref, refs/stash) with their own reflog โ€” git stash list is really git reflog show refs/stash โ€” so they survive branch switches but, like the main reflog, are still local-only and not pushed or cloned.

git cherry-pick โ€” apply a specific commit elsewhereโ€‹

git cherry-pick abc1234                 # replay one commit's changes onto current branch
git cherry-pick abc1234^..def5678 # a range of commits
git cherry-pick -x abc1234 # append "(cherry picked from commit ...)" to the message
git cherry-pick --continue # after resolving a conflict, same mechanics as rebase
git cherry-pick --abort # bail out and restore the pre-pick state

Common real use: a hotfix landed on main and needs to also apply to a release/2.4 branch without merging all of main's other changes. Use -x for hotfix backports specifically โ€” it stamps the original commit SHA into the message, keeping provenance traceable when someone later asks "where did this come from." Like rebase, cherry-pick produces a new commit with a new SHA; the original is untouched. Cherry-picking a commit onto a branch that later merges the source branch can surface as an "already applied"/no-op โ€” that's Git's common-ancestor detection working correctly, not a bug.

git bisect โ€” binary-search for the commit that broke somethingโ€‹

git bisect start
git bisect bad # current commit is broken
git bisect good v1.2.0 # this old tag was known-good
# Git checks out the midpoint commit โ€” you test it, then:
git bisect good # or: git bisect bad
# repeat until Git identifies the exact first-bad commit
git bisect reset # return to where you started

Can be automated: git bisect run ./test-script.sh โ€” Git drives the entire search itself using the script's exit code (any command that exits 0 for good and nonzero for bad works). Reserve exit code 125 for "skip this commit" (git bisect skip) when a commit is genuinely untestable, e.g. it doesn't build โ€” Git routes around it instead of treating it as a false good/bad answer. This turns a manual O(log n) investigation into a single unattended command and is one of the highest-leverage debugging tools in Git โ€” far faster than guessing based on commit messages or diffing by eye.

git reflog โ€” your safety netโ€‹

git reflog                              # every place HEAD has pointed, including "lost" commits
git reset --hard HEAD@{2} # recover from a bad reset/rebase by rewinding to a prior HEAD position

The reflog is local-only (not pushed, not shared) and tracks every HEAD movement โ€” commit, checkout, rebase, reset, merge, even bisect โ€” for ~90 days by default for reachable entries (30 days for unreachable ones, via gc.reflogExpire/gc.reflogExpireUnreachable). Almost anything that looks like "I lost my commits" is recoverable via reflog as long as it's not been garbage-collected โ€” this is why reset --hard and even a botched rebase are less catastrophic than they feel in the moment.

If you don't want to reset your current branch, point a new branch at the recovered commit instead โ€” safer when you're not sure yet which state you want:

git reflog                                   # find the SHA from before the disaster
git branch recovery-branch 9a8b7c6 # point a new branch at it, inspect at leisure

Note the reflog only tracks commits โ€” it can't recover changes that were never committed. For that, fall back to git fsck --unreachable (finds dangling objects still in the object store) or git fsck --lost-found (writes truly orphaned blobs/commits into .git/lost-found/ for inspection) โ€” a last resort compared to the reflog, but occasionally the only way back for uncommitted or unstashed work.

A few more tools worth knowingโ€‹

  • git worktree โ€” check out multiple branches into separate directories from one clone, sharing the same object store. Useful for building a hotfix while a feature branch stays checked out elsewhere, without stashing or juggling clones:
    git worktree add ../hotfix-2.3 release/2.3     # separate working dir, same repo
    git worktree list # see all active worktrees
    git worktree remove ../hotfix-2.3 # clean up when done
  • git log -G"<regex>" โ€” the pickaxe's sibling: -S finds commits that changed how many times a string occurs (added or removed one instance), while -G finds commits whose diff matches a regex anywhere in the added or removed lines. -G is the better tool when you're hunting for a pattern (e.g. a changed function signature) rather than an exact string count.
  • git range-diff A..B C..D โ€” compares two versions of a rebased branch commit-by-commit (e.g. before/after an interactive rebase, or your branch before/after --force-with-lease-ing over review feedback). Shows exactly what the rebase changed per-commit, instead of just the final aggregate diff โ€” much faster than eyeballing two git log -p outputs side by side.

Try it: use git log -S"print" --oneline in the practice repo to find the commit that first added a print.


7. Resolving Merge Conflictsโ€‹

In short: a conflict is Git asking you to decide between two edits to the same lines โ€” combine the intent of both sides, not just one side's text.

A conflict happens when Git can't automatically reconcile the same lines changed differently on both sides being merged/rebased.

<<<<<<< HEAD
const timeout = 3000;
=======
const timeout = 5000;
>>>>>>> feature/checkout
git status                     # lists conflicted files
# edit the file: remove markers, keep the correct/combined content
git add resolved_file.js # mark as resolved
git commit # (merge) โ€” or `git rebase --continue` (rebase)
  • git merge --abort / git rebase --abort โ€” bail out entirely and return to the pre-operation state if the conflict resolution goes sideways.
  • git checkout --ours <file> / --theirs <file> โ€” take one side's version wholesale for a file, when you know one side is simply correct.
  • git mergetool โ€” launch a configured visual diff/merge tool (vimdiff, Beyond Compare, VS Code) instead of resolving markers by hand.
  • During a rebase, "ours"/"theirs" is flipped from intuition โ€” because each commit is being replayed onto the target, "theirs" is the commit being replayed (your branch's commit), "ours" is the base you're rebasing onto. This trips people up constantly โ€” verify with git diff before trusting --ours/--theirs during a rebase.
  • For a large or unfamiliar conflicting file, don't just stare at the markers โ€” git log --merge -p <file> shows both sides' commits that touched the conflicting lines, which is usually faster than reasoning about intent from the diff alone. Reach for --ours/--theirs only when one side really is simply correct wholesale (a generated lockfile, for example) โ€” most real conflicts need you to merge intent, not pick a side, and blindly taking one side silently discards someone's fix.
  • git config rerere.enabled true makes Git remember how you resolved a conflict and auto-reapply the same resolution next time the identical conflict recurs ("reuse recorded resolution") โ€” valuable on a long-lived branch that repeatedly rebases against a fast-moving trunk and keeps hitting the same conflict.

Try it: create a conflict during a rebase and check which side --ours gives you. Is it the one you expected?


8. Undoing Things Safelyโ€‹

In short: choose the undo by how far the change has travelled โ€” restore for your files, reset for local commits, revert for anything already pushed.

This is the single highest-stakes topic in day-to-day Git โ€” knowing the blast radius of each undo command prevents real data loss.

CommandWhat it movesRewrites history?Safe on pushed/shared commits?
git restore <file>Working dir โ† staging/HEADNoAlways safe
git restore --staged <file>Unstages a file (staging โ† HEAD)NoAlways safe
git reset --soft <commit>Moves branch pointer; keeps changes stagedYes (local ref only)Unsafe if already pushed
git reset --mixed <commit> (default)Moves branch pointer; unstages changes, keeps them in working dirYesUnsafe if already pushed
git reset --hard <commit>Moves branch pointer; discards working dir + staged changesYesDangerous โ€” unsafe if already pushed, and destroys uncommitted work
git revert <commit>Creates a new commit that undoes a prior commit's changesNo โ€” adds history, doesn't remove itAlways safe, even on shared branches
git checkout <commit> -- <file>Restores one file from any commit into working dir/stagingNoSafe

reset vs revert โ€” the one interview question that separates juniors from seniorsโ€‹

  • reset rewrites the branch's history โ€” it moves where the branch pointer is. Fine for local, not-yet-pushed commits. On a shared branch, it creates divergence: anyone who already pulled the "reset-away" commits now has commits that no longer exist on the remote.
  • revert adds a new commit that inverses a previous one โ€” history stays intact and forward-only. This is the only safe way to undo a change that's already been pushed/shared/deployed, because it doesn't require anyone else to change what they have.
git revert abc1234                # undo one specific commit, safely, on any branch
git revert HEAD~3..HEAD # revert a range (creates one revert commit per original, by default)

Force-push, done responsiblyโ€‹

Sometimes rewriting a pushed but not-yet-merged feature branch (e.g. after an interactive rebase to clean up commits before review) is legitimate.

git push --force-with-lease origin feature/checkout

--force-with-lease refuses to push if the remote branch has commits you haven't seen yet (i.e., someone else pushed since your last fetch) โ€” it prevents silently clobbering a teammate's work, unlike plain --force, which overwrites unconditionally. Treat plain --force as effectively banned on any branch other than your own untouched-by-others feature branch.

The "Oh No" Recovery Playbookโ€‹

A quick-lookup table for the moment something has gone wrong and you need the fix, not the theory:

SituationFix
Undo the last commit, keep the changesgit reset --soft HEAD~1 (changes stay staged) or git reset HEAD~1 (changes stay unstaged)
Undo the last commit, discard the changes entirelygit reset --hard HEAD~1
Already pushed โ€” need to undo without rewriting shared historygit revert HEAD (creates a new commit undoing the last one; safe to push normally)
Already pushed โ€” must actually remove it and you're certain no one else pulled itgit reset --hard HEAD~1 && git push --force-with-lease
Recover a deleted branchgit reflog โ†’ find the last SHA the branch pointed to โ†’ git branch <name> <sha>
Recover commits after a bad rebasegit reflog โ†’ find the entry just before rebase (start) โ†’ git reset --hard HEAD@{N} (or branch off it if you want to keep the post-rebase work too)
Unstage a file (keep the edits)git restore --staged <file> (or the older git reset HEAD <file>)
Discard local (uncommitted) changes to a filegit restore <file> (or the older git checkout -- <file>) โ€” irreversible, no reflog for this
Discard ALL local uncommitted changesgit restore . for tracked files; add git clean -fd to also remove untracked files/dirs (-n first to dry-run โ€” this is also irreversible)
Amend the last commit (message or forgot a file)git add <forgotten-file>; git commit --amend โ€” rewrites the last commit's SHA, so treat it like a rebase (don't do it on shared commits)
Find which commit deleted a line/filegit log --all --oneline -- <path> to find when it disappeared, then git log -S"<string>" (pickaxe search) to find the commit that added/removed specific text anywhere in history
Restore a file from an old commit without touching anything elsegit restore --source=<sha> -- <file>
Committed a secret and already pushed itDeleting the file in a new commit does not remove it from history โ€” the blob is still fully retrievable via git log -p or git show <old-sha>:<path>. Rewrite history with git filter-repo (or BFG Repo-Cleaner) to strip it from every commit, force-push to all remotes, have every collaborator re-clone, and rotate the credential โ€” anyone with an existing clone or prior fetch still has the old blob until they re-clone.

Try it: for each row of the "Oh No" table you haven't used before, cause the situation in the practice repo and then fix it.


9. Tags โ€” Lightweight vs Annotatedโ€‹

In short: a tag permanently names one commit; use annotated tags (with author, date, and message) for releases.

Tags mark a specific commit permanently โ€” unlike branches, they don't move as you commit.

git tag v1.2.0-rc1                          # lightweight: just a named pointer to a commit
git tag -a v1.2.0 -m "Release 1.2.0" # annotated: a full object (tagger, date, message, optional signature)
git tag -s v1.2.0 -m "Release 1.2.0" # signed annotated tag (GPG)
git push origin v1.2.0 # tags don't push automatically โ€” explicit
git push origin --tags # push all local tags
git tag -d v1.2.0-rc1 # delete locally
git push origin :refs/tags/v1.2.0-rc1 # delete on remote

Use annotated tags for anything that's a real release. They're actual objects with author/date/message (and can be GPG-signed for provenance), and git describe uses them to compute "commits since last tag." Lightweight tags are fine for personal bookmarks (before-refactor, known-good) but shouldn't be used for release versions since they carry no metadata and no signature.


10. .gitignore and Hooksโ€‹

In short: .gitignore keeps files out of Git; hooks run your checks automatically โ€” useful for fast feedback, but CI is the real gate.

# .gitignore
node_modules/
*.pyc
__pycache__/
.env
dist/
.DS_Store

Patterns are matched relative to the .gitignore file's location; .gitignore only affects untracked files โ€” a file already tracked before being added to .gitignore keeps being tracked (git rm --cached <file> to stop tracking it without deleting it from disk).

Hooks are scripts Git runs automatically at specific points, stored in .git/hooks/ (not version-controlled by default โ€” teams use tools like husky or pre-commit to share hook config via the repo itself).

HookFiresCommon use
pre-commitBefore a commit is createdLint, format, run fast unit tests
commit-msgAfter message is written, before commit finalizesEnforce commit message conventions (e.g. Conventional Commits)
pre-pushBefore git push sends dataRun the full test suite, block push on failure
post-mergeAfter a merge completesReinstall dependencies if lockfile changed

A real pre-commit hook โ€” block obvious secrets and debug breakpointsโ€‹

.git/hooks/pre-commit:

#!/usr/bin/env bash
set -euo pipefail

staged=$(git diff --cached --name-only --diff-filter=ACM)

if git diff --cached | grep -qE 'AKIA[0-9A-Z]{16}'; then
echo "ERROR: possible AWS access key in staged changes. Aborting commit." >&2
exit 1
fi

for f in $staged; do
case "$f" in
*.py)
if grep -qE '^\s*(import pdb|pdb\.set_trace\(\))' "$f"; then
echo "ERROR: debugger breakpoint left in $f" >&2
exit 1
fi
;;
esac
done
exit 0

A real pre-push hook โ€” run the fast suite before anything leaves the machineโ€‹

.git/hooks/pre-push:

#!/usr/bin/env bash
set -euo pipefail
echo "Running fast test suite before push..."
pytest -q tests/unit || {
echo "ERROR: unit tests failed. Push aborted." >&2
exit 1
}
chmod +x .git/hooks/pre-commit .git/hooks/pre-push   # hooks must be executable
git commit --no-verify -m "..." # explicit bypass, use sparingly

Because hooks live outside the repo's tracked files by default, they can't be relied on as a security boundary for a team โ€” anyone can skip or delete their local copy. Treat local hooks as a fast feedback loop for the author, and enforce the same checks in CI as the actual gate.

Try it: install the pre-commit hook above, then try to commit a Python file containing import pdb.


11. Common Gotchasโ€‹

In short: the mistakes that catch nearly everyone once, and the habit that prevents each.

  • Detached HEAD state โ€” checking out a commit SHA or tag directly (not a branch) puts HEAD on the commit itself instead of a branch ref. Any new commits made here aren't attached to any branch โ€” switch away without creating a branch first and they become unreachable (recoverable briefly via reflog, then garbage-collected). Fix: git switch -c new-branch-name immediately if you want to keep work done in this state.

    git checkout abc1234        # detached HEAD โ€” "You are in 'detached HEAD' state"
    git switch -c hotfix-attempt # rescue: attach current position to a real branch
  • Force-push danger โ€” plain git push --force on a shared branch can silently discard a teammate's commits from the remote with no warning. Default to --force-with-lease, and never force-push main/master (most platforms let you branch-protect against this โ€” turn it on).

  • Rewriting shared history โ€” rebasing, commit --amend, or reset --hard followed by a force-push on any branch others have already pulled from creates divergent histories that are painful to reconcile. The rule: once pushed and others may have it, treat history as append-only (use revert, new commits) โ€” only rewrite history that's still purely local.

  • git add . sweeping up unwanted files โ€” stages everything in the current directory, including files you forgot were untracked (secrets, build artifacts not yet gitignored). Prefer explicit paths or git add -p in shared/sensitive repos, and check git status before committing.

  • Merge commit noise from pulling without a strategy โ€” git pull defaults to fetch + merge, which creates a merge commit any time your local branch has diverged from upstream, even for a one-line change. git pull --rebase (or git config pull.rebase true globally) replays your local commits on top instead โ€” cleaner history for the common "I was a commit behind" case, but only appropriate for not-yet-pushed local work.

  • Committing large binaries directly โ€” Git's model works best for text; large binaries bloat .git permanently (history can't easily "forget" large old blobs without a history rewrite via git filter-repo or BFG Repo-Cleaner). Use Git LFS for assets like large images/videos/models.

  • Committing a secret, then thinking git rm fixes it โ€” deleting a file in a new commit only removes it going forward; the secret is still fully retrievable from every prior commit's tree/blob (git log -p, git show <old-sha>:<path>). Real remediation is two steps, both required: rewrite history to strip the blob (git filter-repo or BFG, see the "oh no" table in Section 8) and rotate the credential โ€” anyone who already cloned or fetched keeps the old blob until they re-clone, so the leaked value must be treated as compromised regardless of the history rewrite.


12. Interview-Ready Q&Aโ€‹

In short: the questions interviewers ask most, with answers that show you understand the model, not just the commands. Try answering each aloud before reading the answer.

Q: What is a Git commit, really โ€” a diff or a snapshot? A: A snapshot. Every commit points to a tree object representing the complete state of the project at that point, plus a pointer to its parent commit(s). Git computes diffs on the fly for display (git diff, git log -p) and compresses storage internally via packfiles/deltas, but the logical model is a full snapshot per commit, not a stored diff.

Q: What's the actual difference between git merge and git rebase? A: Merge creates a new commit with two parents that combines both histories, preserving exactly what happened including the divergence โ€” existing commit SHAs are untouched. Rebase replays your branch's commits one at a time onto a new base, producing entirely new commits (different SHAs, same content) and a linear history. Merge is always safe on shared branches; rebase rewrites history and should only be used on local/not-yet-shared commits.

Q: git reset --hard vs git revert โ€” when is each appropriate? A: reset --hard moves the branch pointer and discards changes, rewriting history โ€” safe only for local commits nobody else has. revert creates a new commit that undoes a previous one without touching history at all, so it's safe on any branch, including ones already pushed, merged, or deployed. If the commit in question is already shared, revert is essentially always the right tool.

Q: What is a detached HEAD state and how do you get out of it safely? A: It happens when you check out a specific commit or tag rather than a branch โ€” HEAD points directly at a commit instead of at a branch ref. Any new commits made there aren't reachable from any branch; switching away without saving them risks losing them once the reflog expires. To keep work done in that state, run git switch -c <new-branch-name> before moving away to attach those commits to a real branch.

Q: Why is --force-with-lease preferred over --force? A: --force-with-lease checks that the remote branch hasn't changed since your last fetch before overwriting it โ€” if a teammate pushed in the meantime, the push is rejected instead of silently discarding their commits. Plain --force overwrites unconditionally with no such check, making it a common source of lost work on shared branches.

Q: How would you find which commit introduced a regression, across hundreds of commits, without reading each one? A: git bisect โ€” mark a known-good and known-bad commit, and Git binary searches the range, checking out the midpoint each time for you to test. With a scripted reproduction (git bisect run ./test.sh), it fully automates the search and reports the exact first-bad commit in O(log n) steps instead of a linear scan.

Q: Why does Git use a staging area instead of just committing the working directory directly? A: The staging area lets you construct a commit's contents independently of what's currently on disk โ€” you can stage only some files, or even only some hunks within a file (git add -p), to build small, logically coherent commits even when your working directory has several unrelated changes mixed together. Without it, every commit would have to be "everything that's currently different," which encourages large, unreviewable commits.

Q: A teammate says they "lost" three commits after a bad rebase. What do you check first? A: git reflog โ€” it records every position HEAD has pointed to locally, including commits no branch currently references, for roughly 90 days by default before garbage collection. In the vast majority of "lost work" cases the commits still exist as unreachable objects; git reset --hard HEAD@{n} (or git cherry-pick from the reflog SHA) recovers them.

Q: When would you actually reach for git-flow instead of trunk-based development? A: When the team genuinely ships and supports multiple discrete, versioned releases in parallel โ€” e.g. patching v1.x while developing v2.0 โ€” where long-lived release/develop branches earn their keep. For a continuously-deployed service with strong CI, trunk-based (short-lived branches, feature flags) wins because merge-conflict risk grows nonlinearly with branch lifetime, and git-flow's extra long-lived branches work against that. The model matters less than keeping branches short.

Q: A secret got committed and pushed to a shared repo. Is deleting it in a new commit sufficient? A: No โ€” it removes the file going forward but the secret is still fully present in the old commit's blob, retrievable via git log -p or git show <old-sha>:<path> by anyone with a clone. Fixing it requires rewriting history to strip the blob from every commit (git filter-repo or BFG), force-pushing, having every collaborator re-clone, and rotating the credential โ€” the rewrite alone doesn't help against clones/fetches that already happened before it.

Q: What does git worktree give you that git stash doesn't? A: git stash shelves uncommitted changes on your single working directory so you can switch branches โ€” you're still one checkout at a time. git worktree add ../hotfix release/2.3 checks out a second branch into a completely separate directory backed by the same repository and object store, so you can have two branches checked out and buildable simultaneously (e.g. patching a hotfix while a long-running feature branch stays untouched elsewhere) without stashing or cloning twice.


Practical exercises
  1. In a scratch directory, create three commits, throw the last one away, and get it back:

    git init -b main rescue && cd rescue
    echo one > notes.txt && git add . && git commit -m "first"
    echo two >> notes.txt && git commit -am "second"
    echo three >> notes.txt && git commit -am "third"

    git reset --hard HEAD~1
    git log --oneline
    git reflog

    Find the third commit's hash in the reflog, then move back to it with git reset --hard HASH.

    Done when: after the reset, git log --oneline shows only two commits and notes.txt has two lines, and after the recovery all three commits are back and notes.txt has three lines.

    Stretch: Recover it a second time by creating a branch at the commit instead of moving your current branch.

  2. Create a conflict between two branches, then rebase and inspect both sides (this needs Git 2.23 or newer for git switch):

    git init -b main conflict && cd conflict
    printf 'greeting: hello\n' > app.txt && git add . && git commit -m "base"
    git switch -c feature
    printf 'greeting: hello from feature\n' > app.txt && git commit -am "feature edit"
    git switch main
    printf 'greeting: hello from main\n' > app.txt && git commit -am "main edit"
    git switch feature

    git rebase main
    git checkout --ours app.txt && cat app.txt
    git checkout --theirs app.txt && cat app.txt
    git rebase --abort

    Done when: the rebase stops with a conflict in app.txt, --ours gives greeting: hello from main, --theirs gives greeting: hello from feature, and after the abort you are back on feature. You can explain why this is the opposite of what a merge would suggest.

The bad commit that was reset instead of reverted
Context

Illustrative scenario. A broken commit reaches the shared main branch. To get rid of it quickly, an engineer resets their local branch back one commit and force-pushes.

What happened

Several teammates had already pulled the bad commit. After the force-push, the commit no longer existed on the remote, so their histories diverged from it and their next pulls produced confusing conflicts. The engineer had also overwritten a commit someone else had pushed in the meantime.

Lesson

On any shared or pushed branch, undo with git revert, which adds a new commit and rewrites nothing. Keep reset for local, unpushed work, and use --force-with-lease, never plain --force, and only on a feature branch that is yours.

Use this with AI
  • Ask an assistant to explain a sequence of reflog entries and propose the recovery command, then rehearse it in a scratch clone and check with git log and git diff before touching your real repository.
  • Have it draft a plan for splitting a large change into commits with git add -p, and review by hand that each commit builds and passes tests on its own.
  • Ask it to resolve a merge conflict, then read both sides with git log --merge -p to confirm it merged intent instead of silently choosing one side.

13. One-Line Summaryโ€‹

In short: the whole guide in one sentence.

Git is a content-addressable DAG of snapshots, not a stack of diffs โ€” the staging area lets you shape commits deliberately, rebase only what's still local, merge or revert (never reset or force-push) what's already shared, and the reflog is your safety net for almost everything else.