Most developers use maybe eight Git commands and treat the rest as a source of anxiety. That is not a knowledge gap so much as a teaching failure: Git is almost always taught as a list of commands, when the thing that makes it click is the data model underneath. Learn what Git actually stores, and the commands stop being spells you memorised and start being obvious.
This is a long, practical guide built around that idea. The object model first, then the everyday workflow, then the parts people avoid - rebasing, undoing things, finding the commit that broke production - with the commands you would actually type.
What Git actually stores
Git is a content-addressable filesystem with a version control interface bolted on top. That sounds like a joke about its usability, but it is literally the architecture, and it is why everything works the way it does.
There are exactly four object types. Every one is stored under the hash of its own contents, which is what makes history tamper-evident and deduplication automatic.
| Object | Contains | Analogy |
|---|---|---|
| Blob | File contents. No name, no path, no permissions | The bytes of a file |
| Tree | A list of names pointing at blobs and other trees | A directory listing |
| Commit | One tree, zero or more parents, author, committer, message | A snapshot plus its provenance |
| Tag | A pointer to an object, with a message and optional signature | An annotated bookmark |
Commits are snapshots, not diffs
This is the single most important correction to make. Git does not store changes. Every commit points at a complete tree representing the whole project at that moment. The diffs you see are computed on demand by comparing two trees.
It sounds wasteful and is not. Unchanged files point at the same blob, so a commit touching one file in a 50,000-file repository adds one blob, a handful of trees and one commit object. Everything else is reused by hash.
# Look at the machinery directly
git cat-file -t HEAD # commit
git cat-file -p HEAD # the raw commit object
# tree 9f2b...
# parent 4a1c...
# author Naiem <naiem@example.com> 1755100000 +0600
# committer Naiem <naiem@example.com> 1755100000 +0600
#
# Add order validation
git cat-file -p HEAD^{tree} # the directory listing it points at
git cat-file -p HEAD:src/app.ts # the blob contents of one filecat-file will teach you more about Git than any tutorial. Every object is inspectable and there is nothing hidden.Refs are just files
cat .git/HEAD # ref: refs/heads/main
cat .git/refs/heads/main # 4a1c9f2b3d...
git rev-parse HEAD # resolve any ref to a hash
git rev-parse --abbrev-ref HEAD # the current branch nameSo the whole model is: commits form a directed acyclic graph through their parent pointers, branches are movable labels on commits, and HEAD is a pointer to the branch you are currently on. Nearly every Git command is moving one of those pointers or building new objects.
The three areas
Working tree Index (staging area) HEAD / repository
───────────── ──────────────────── ─────────────────
your actual files → what the next commit → committed history
will contain
git add ─────────────►
git commit ─────────────►
◄───────────── git restore
◄──────────────────────────────── git restore --source=HEADSet-up worth doing once
git config --global user.name "Naiem Inahid"
git config --global user.email "naiem@example.com"
# Default branch for new repositories
git config --global init.defaultBranch main
# Only fast-forward or explicit merges on pull - never a surprise merge commit
git config --global pull.ff only
# Push the current branch to its same-named upstream
git config --global push.default simple
git config --global push.autoSetupRemote true
# Remember how you resolved a conflict, and reapply it automatically next time
git config --global rerere.enabled true
# Better diffs
git config --global diff.algorithm histogram
git config --global diff.colorMoved zebra
# Sort branch listings by most recent activity
git config --global branch.sort -committerdate
# Keep the repository fast in the background
git maintenance startrerere - reuse recorded resolution - is the most underused setting here. On a long-lived branch you rebase repeatedly, it means resolving each conflict once instead of every time.Sign your commits
# SSH signing - simpler than GPG and uses a key you already have
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
git config --global commit.gpgsign true
git config --global tag.gpgsign true
git log --show-signature -1Aliases that earn their keep
[alias]
s = status -sb
lg = log --graph --oneline --decorate --all
last = log -1 HEAD --stat
unstage = restore --staged
amend = commit --amend --no-edit
# branches by recency, with their upstream and last commit
br = for-each-ref --sort=-committerdate refs/heads/ \
--format='%(color:yellow)%(refname:short)%(color:reset) %(committerdate:relative) %(contents:subject)'
# what have I not pushed?
unpushed = log --oneline @{u}..HEADThe everyday loop
git status -sb # short, with branch and tracking info
git diff # working tree vs index - what is NOT staged
git diff --staged # index vs HEAD - what WILL be committed
git add src/orders.ts # stage a file
git add -p # stage selected hunks, interactively
git add -u # stage modifications and deletions, not new files
git commit -m "Reject orders with a zero total"
git commit --amend --no-edit # fold staged changes into the last commit
git log --oneline -10
git log --graph --oneline --decorate --allgit add -p is the habit worth building
Interactive staging lets you split what you did into what you meant to do. You fixed a bug and also renamed a variable and also removed a stale comment - git add -p turns that into three reviewable commits instead of one unreviewable one.
git add -p
# y - stage this hunk n - skip it
# s - split into smaller hunks
# e - edit the hunk by hand
# q - quit ? - helps to split and e to edit are the two that make it genuinely powerful. You can stage half a line if you need to.Commit messages that survive contact with the future
Add rate limiting to the login endpoint
Brute-force attempts against /auth/login were not throttled, so an
attacker could try passwords at the speed of the network.
Limits to five attempts per minute per IP, returning 429 with a
Retry-After header. The limit is deliberately per-IP rather than
per-account so that an attacker cannot lock out a real user.
Refs: SEC-214Branching, merging and rebasing
git switch -c feature/order-limits # create and switch
git switch main # switch
git switch - # back to the previous branch
git branch -d feature/order-limits # delete if merged
git branch -D feature/order-limits # delete regardless
git branch --merged main # what is safe to deletegit switch and git restore split the jobs that git checkout used to do. checkout still works; the newer commands are less ambiguous and harder to misuse.Merge or rebase
Both integrate one branch into another. They differ in what history you end up with, and the argument about which is correct has consumed more engineering hours than it deserves.
| Merge | Rebase | |
|---|---|---|
| What it does | Creates a commit with two parents | Replays your commits onto a new base |
| History shape | Preserves the actual branching | Linear, as if you had worked in sequence |
| Commit hashes | Unchanged | Rewritten - every commit is a new object |
| Conflicts | Resolved once | Potentially once per replayed commit |
| Safe on shared branches | Yes | No |
| Best for | Integrating a finished branch | Tidying your own work before review |
# Rebase your feature branch onto the latest main before opening a PR
git switch feature/order-limits
git fetch origin
git rebase origin/main
# If a conflict stops you
# fix the files, then:
git add <resolved-files>
git rebase --continue
# or, to give up entirely and return to where you started:
git rebase --abort
# Merge a finished branch into main with an explicit merge commit
git switch main
git merge --no-ff feature/order-limitsInteractive rebase
git rebase -i HEAD~5
# pick 4a1c9f2 Add order validation
# reword 8b3d1e4 fix typo ← change the message
# squash 2c9f8a1 more validation ← fold into the previous commit
# fixup 7e1b4c3 oops ← fold in, discard the message
# drop 9d2a6f8 debug logging ← delete the commit entirely
# edit 1f8c3b2 Refactor pricing ← stop here so you can amend it# The classic pattern for fixing an earlier commit
git add -p # stage just the fix
git commit --fixup=4a1c9f2
git rebase -i --autosquash 4a1c9f2~1 # positions it automatically
# Git 2.55 collapses that into one command (still experimental)
git add -p
git history fixup 4a1c9f2git history fixup applies the staged change to the named commit and replays everything after it, keeping the original message. It aborts cleanly on conflict rather than stranding you mid-rebase.Undoing things
This is the section people actually need and the one where the command names are least helpful. The question to ask first is always: which of the three areas am I trying to change?
| Situation | Command |
|---|---|
| Unstage a file, keep the edits | git restore --staged file.ts |
| Discard uncommitted edits to a file | git restore file.ts |
| Discard everything uncommitted | git restore . then git clean -fd |
| Fix the last commit's message | git commit --amend |
| Add a forgotten file to the last commit | git add file.ts && git commit --amend --no-edit |
| Undo the last commit, keep changes staged | git reset --soft HEAD~1 |
| Undo the last commit, keep changes unstaged | git reset HEAD~1 |
| Undo the last commit and destroy the changes | git reset --hard HEAD~1 |
| Undo a commit that is already pushed | git revert <sha> |
| Recover something you thought you destroyed | git reflog |
reset versus revert
reset moves the branch pointer backwards, so the commits vanish from that branch's history. revert creates a new commit that undoes an old one, leaving history intact. On anything you have pushed, revert is the correct tool - reset plus a force push rewrites history other people already have.
git revert 4a1c9f2 # undo one commit
git revert 4a1c9f2..8b3d1e4 # undo a range
git revert -m 1 <merge-sha> # undo a merge, keeping the first parentThe reflog, and why you have not lost your work
Git records every movement of HEAD in a local log. Bad rebase, hard reset, deleted branch - as long as it was committed at some point in the last ninety days, it is still there and reachable.
git reflog
# 8b3d1e4 HEAD@{0}: reset: moving to HEAD~3
# 2c9f8a1 HEAD@{1}: commit: Add order limits
# 7e1b4c3 HEAD@{2}: rebase (finish): returning to refs/heads/feature
# Go back to where you were
git reset --hard HEAD@{1}
# Or recover the state onto a fresh branch, which is safer
git switch -c rescue 2c9f8a1
# Deleted a branch by mistake?
git reflog | grep "checkout: moving from feature/order-limits"Finding things in history
Git's investigative commands are the least-known and most valuable part of the tool. This is what you reach for when something broke and nobody knows when.
Searching
# Commits whose message matches
git log --grep="rate limit" --oneline
# Commits that ADDED or REMOVED a string - the pickaxe
git log -S "calculateVat" --oneline
# Commits whose diff matches a regex
git log -G "fetch\(.*retry" --oneline
# History of one file, following it through renames
git log --follow -p src/pricing.ts
# What changed on specific lines of a file
git log -L 40,80:src/pricing.ts
# Everything one author did last month
git log --author="Naiem" --since="1 month ago" --stat-S is the one to remember. "When did this function disappear?" is a question it answers in seconds and grep cannot answer at all.Blame, used properly
git blame src/pricing.ts
# Ignore pure formatting commits so blame shows real authorship
git blame --ignore-revs-file .git-blame-ignore-revs src/pricing.ts
# Detect code moved from another file, not just edited in place
git blame -C -C src/pricing.ts
# Blame a specific line range
git blame -L 40,60 src/pricing.ts# The commit that reformatted the entire codebase with Prettier
a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0
# The commit that converted tabs to spaces
b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1git config blame.ignoreRevsFile .git-blame-ignore-revs and commit the file. GitHub honours it too, so blame stops pointing at whoever ran the formatter.Bisect: finding the commit that broke it
You know it worked in the release two weeks ago and it is broken now, and there are four hundred commits between. Bisect finds the culprit in about nine steps by binary search.
git bisect start
git bisect bad # current commit is broken
git bisect good v1.4.0 # this tag was fine
# Git checks out a commit halfway between. Test it, then:
git bisect good # or: git bisect bad
# …repeat until Git names the first bad commit
git bisect reset # return to where you started# Fully automatic: give it a script that exits 0 for good, non-zero for bad
git bisect start HEAD v1.4.0
git bisect run npm test -- --testPathPattern=checkout
# Or a custom script
git bisect run ./scripts/reproduce-bug.shgit bisect run is one of the highest-leverage commands in the tool. Write a script that reproduces the bug, go and get a coffee, come back to the exact commit.Working with other people
git remote -v
git remote add upstream https://github.com/org/repo.git
git fetch origin # download, change nothing locally
git fetch --prune # also delete refs for branches gone from the remote
git pull # fetch + merge (or rebase, depending on config)
git pull --rebase # fetch + rebase your local commits on top
git push -u origin feature/x # push and set upstream tracking
git push --force-with-lease # safe force: refuses if the remote movedPrefer fetch then look, over pull. pull is two operations pretending to be one, and the second one - merge or rebase - is the one that can surprise you. Fetch, inspect with git log HEAD..origin/main, then integrate deliberately.
Resolving conflicts
git status # lists the conflicted files
# In the file:
# <<<<<<< HEAD
# your version
# =======
# their version
# >>>>>>> feature/order-limits
# Better: show the common ancestor too, so you can see what each side changed
git config --global merge.conflictStyle zdiff3
# Take one side wholesale
git checkout --ours config/prod.json
git checkout --theirs src/pricing.ts
# Or open a three-way merge tool
git mergetool
git add <resolved>
git commit # or: git rebase --continuezdiff3 is a meaningful upgrade over the default conflict style. Seeing the original alongside both changes usually makes the correct resolution obvious.A branch workflow that works for most teams
mainis always deployable and protected. Nobody pushes to it directly.- Short-lived branches off
main, one per unit of work, named for the work rather than the person. - Rebase onto
mainbefore opening the pull request so the review is against current code. - Squash or merge on the forge, matching whichever history shape your team agreed on. Pick one and stop discussing it.
- Delete the branch after merging.
git fetch --prunecleans up your local copies. - Tag releases with annotated, signed tags:
git tag -s v1.4.0 -m "Release 1.4.0".
The tools people do not know about
Stash
git stash push -m "half-done pricing refactor"
git stash push -p # stash selected hunks only
git stash push -u # include untracked files
git stash list
git stash show -p stash@{0} # see what is in it
git stash apply stash@{0} # apply, keep it in the list
git stash pop # apply and remove
git stash drop stash@{0}Worktrees
A worktree gives you a second working directory for the same repository, on a different branch, without cloning again. When someone needs an urgent review while you are mid-refactor, this beats stashing.
git worktree add ../repo-review origin/feature/their-branch
cd ../repo-review # a full checkout, same .git objects
git worktree list
git worktree remove ../repo-reviewHooks
#!/bin/sh
# .git/hooks/pre-commit - must be executable
if git diff --cached --name-only | grep -qE '\.(ts|tsx)$'; then
npx tsc --noEmit || {
echo "Type check failed. Commit aborted."
exit 1
}
fi.git/hooks are local and not committed. For shared hooks, point core.hooksPath at a tracked directory, or use a manager like Husky or Lefthook.Since Git 2.54 hooks can also be defined in configuration rather than as files, and 2.55 added the ability to run config-based hooks in parallel. Keep hooks fast - a pre-commit hook that takes twenty seconds is a hook people will start bypassing with --no-verify.
Submodules, and the honest advice
git submodule add https://github.com/org/lib.git vendor/lib
git clone --recurse-submodules <url>
git submodule update --init --recursive
git submodule update --remote # move to the tracked branch's latestBefore adopting submodules, check whether your package manager can express the dependency instead. A private npm, PyPI or Cargo package is almost always the less painful answer. Submodules earn their place when you genuinely need the source, not the artifact - vendored forks, shared configuration, coupled repositories you build together.
Large repositories
# Shallow clone - history truncated, much faster
git clone --depth 1 <url>
git fetch --unshallow # get the rest later if you need it
# Partial clone - full history, blobs fetched on demand
git clone --filter=blob:none <url>
# Sparse checkout - only the directories you work in
git sparse-checkout init --cone
git sparse-checkout set apps/web packages/ui
# Speed up status on a huge working tree
git config core.fsmonitor true
git config core.untrackedCache true
# Background maintenance: prefetch, repack, commit-graph
git maintenance start
# What is taking up all the space?
git count-objects -vHGit in 2026, and the road to 3.0
Git 2.55 shipped on 29 June 2026. Most of it is invisible - faster packing, less memory in the diff engine - but a few things are worth knowing about.
git history fixupfolds staged changes into an earlier commit and replays the rest, replacing the--fixupplus autosquash dance. Still marked experimental.- fsmonitor on Linux, so
git statusin a large working tree can rely on observed filesystem events rather than a full scan. - Config-based hooks can run in parallel, building on the hook configuration added in 2.54.
- Push remote groups - define a named group in config and push to several remotes at once. Note there is no
--atomicacross a group. - Rust is now enabled by default at build time, still optional via
NO_RUST. It is expected to become mandatory at or after 3.0.
What Git 3.0 changes
| Change | What it means |
|---|---|
| SHA-256 by default | Replaces SHA-1, which has had a demonstrated practical collision. The blocker is forge support - GitHub does not yet host SHA-256 repositories |
main as the default branch | 2.52 added hints preparing users for the switch, including how to keep master if you prefer |
| Reftable storage | A new reference backend replacing loose and packed refs, much faster in repositories with very many branches and tags |
| Rust in the build | Currently optional and on by default; expected to become required |
None of this changes the commands you type. SHA-256 will mean longer hashes and a migration period where SHA-1 and SHA-256 repositories need to interoperate - work that has been landing incrementally since 2.45.
Mistakes that keep recurring
| Mistake | Consequence and fix |
|---|---|
| Committing secrets | The key is in history forever. Rotate it immediately - removing the commit does not un-leak it |
git push --force on a shared branch | Erases other people's commits. Use --force-with-lease |
| Huge commits touching thirty files | Unreviewable, and impossible to revert cleanly. Use git add -p |
| Commit messages like "fix" or "wip" | Useless in six months, and useless in git log --grep |
Merging main into a feature branch repeatedly | A tangled graph. Rebase instead, or merge once at the end |
Committing generated files or node_modules | Bloated clones and constant conflicts. That is what .gitignore is for |
| Long-lived branches | Merge pain grows superlinearly with branch age. Merge weekly at worst |
Using git pull without knowing your config | You get a merge or a rebase depending on settings you have never read |
| Fear of the reflog | Work discarded that was fully recoverable |
| Committing large binaries | Permanent repository bloat. Use LFS, or do not commit them |
If you committed a secret
# 1. Rotate the credential. Right now. Before anything else.
# Assume it is compromised - it was pushed, mirrored, and possibly indexed.
# 2. Then remove it from history
uvx git-filter-repo --path config/secrets.json --invert-paths
# 3. Force push, and tell everyone to reclone
git push --force --all
# Prevent it happening again
uvx detect-secrets scan --baseline .secrets.baselinegit-filter-repo replaced the old filter-branch, which was slow and full of sharp edges. Step one is the one that matters - rewriting history does not recall a leaked key.A learning path
- Week one.
init,add,commit,status,log,diff. Do it in a throwaway repository and inspect.git/as you go. Look atHEADandrefs/heads/as plain files. - Week two. Branching, merging, remotes. Deliberately create a conflict and resolve it. Turn on
merge.conflictStyle = zdiff3and see the difference. - Week three. The undo commands, and the reflog. Break things on purpose in a scratch repository and recover them. This is the week Git stops being scary.
- Week four.
git add -puntil it is automatic, then interactive rebase to clean up a branch before review. - Month two. Archaeology:
log -S,blame -C, and a realgit bisect run. Then worktrees and hooks. - Ongoing. Read Pro Git - it is free, official, and chapter 10 on internals is the one that makes everything else make sense. Play Learn Git Branching for the graph intuition.
Verdict
Git's interface is genuinely inconsistent - the same word means different things in different commands, and checkout did four unrelated jobs for a decade. That is a real criticism and it is why so many people learn a fixed set of commands and stop.
Underneath it is a small, elegant model that has not needed to change in twenty years. Four object types, content-addressed, with branches as movable labels. Once that model is in your head, the commands become predictable, the error messages become readable, and the recovery paths become obvious - because almost nothing in Git is actually destructive.
Spend an afternoon with
git cat-fileandgit reflog. One teaches you what Git stores, the other teaches you that you cannot easily lose anything. Between them they remove most of the fear.
If you take three things from this: use git add -p so your commits say one thing each, use --force-with-lease instead of --force, and remember that the reflog has your back. The rest is detail you can look up - and now you know what to look up.
Sources
- Pro Git - the official book, free, and the internals chapter is the best explanation of the object model anywhere
- Git reference documentation - every command, every flag
- Git 2.55 release notes -
git history fixup, fsmonitor on Linux, parallel hooks - Git 2.55 coverage - LWN's detailed writeup of the release
- git-filter-repo - the supported way to rewrite history
- Learn Git Branching - interactive, and the fastest way to build graph intuition



