How Git actually works

Four tiers, twenty-nine sections. Every concept gets a plain-English explanation, the exact commands, a step-through diagram where the state change matters, and a “gotchas” box for the traps.

Current stable Gitv2.55.0

Tier 1Beginner

The mental model, and the five commands you'll use every day.

What version control is (and why Git)

A version control system (VCS) records snapshots of your project over time so you can see what changed, when, and by whom — and go back to any earlier state. Without one, "the current version" is whatever is on someone's laptop, and collaboration means emailing zip files.

Git is a distributed VCS: every clone is a full copy of the project and its entire history, not a thin checkout from a central server. You can commit, branch, view history, and diff with no network at all. There is no privileged "master copy" in the protocol — repositories like the one on GitHub are a shared convention, not a technical requirement.

Git was built in 2005 by Linus Torvalds to host Linux kernel development after its previous tool became unavailable. Its design goals — speed, a simple internal model, and first-class support for thousands of parallel branches — are why it now runs almost everything.

NOTE

Git tracks content, not files. If you rename a file and change one line, Git figures out the rename by comparing snapshots — it never stores "rename" as an operation. This is why git mv is just a convenience for mv + git add.

The rest of this guide builds from that snapshot model up. For the full origin story, see the History page.

Installing Git & first-time setup

Install Git from git-scm.com/downloads, or your platform's package manager (brew install git, apt install git, winget install Git.Git). Check what you have:

git --version

Git reads configuration from three files, most-specific wins: the repo's .git/config, your user file (~/.gitconfig, written with --global), and a system file (--system). Set your identity once, globally — it's stamped onto every commit you make:

git config --global user.name "Ada Lovelace"
git config --global user.email "ada@example.com"

# Quality-of-life defaults
git config --global init.defaultBranch main   # name the first branch "main"
git config --global pull.rebase false          # be explicit about pull's behaviour
git config --global core.editor "code --wait"  # editor for commit messages

# See every setting and the file it came from
git config --list --show-origin

WARNING

The email you set here is public in every commit you push. Use an address you're comfortable exposing — GitHub can give you a noreply address for this exact reason. Changing it later does not rewrite commits you already made.

The mental model: working tree, staging area, repository

Git has three places a file's contents can live, and almost every everyday command just moves contents between them. Step through the diagram below.

  • Working tree — the actual files on disk that you edit. Git compares them against the last commit to tell you what's "modified".
  • Staging area (also called the index) — a draft of your next commit. git add copies the current contents of a file into it. You can stage some changes and leave others for a later commit.
  • Repository — the .git directory, where committed snapshots live permanently as objects.
git add file.txt      # working tree  → staging area
git commit            # staging area  → repository
git restore file.txt  # discard working-tree changes (copy back from staging)
git restore --staged file.txt   # unstage, keeping the working-tree changes

WARNING

git add stages a snapshot of the file as it was when you ran it — not a live link. If you edit the file again after git add, that new edit is not staged; git status will list the file as both "staged" and "modified". Run git add again to catch up.

Working tree → staging area → repositoryclick through the lifecycle
Working treeStaging areaRepositoryfile.txt

$ vim file.txt

1/4 You edit file.txt. Git sees it as modified in the working tree — nothing is staged yet.

init, status, add, commit

Four commands get you from an empty folder to a project with history.

git init                     # create .git/ — this folder is now a repository
git status                   # always safe; shows tracked / staged / untracked

git add README.md            # stage one file
git add .                    # stage everything under the current directory
git add -p                   # stage selected hunks, interactively

git commit -m "Initial commit"
git commit                   # opens your editor for a longer message

A good commit message has a short summary line (≤ ~50 chars, imperative mood — "Add login form", not "Added" or "Adds"), a blank line, then a body explaining why if it isn't obvious.

Made a typo in the last message, or forgot a file?

git add forgotten-file.js
git commit --amend           # replace the previous commit with a new one

WARNING

git commit --amend does not edit the old commit — it creates a new commit with a new hash and moves the branch to it. Only amend commits you haven't pushed. If you've already shared it, amending forces everyone else to reconcile a rewritten history.

NOTE

git add . also stages new files you might not want tracked — build output, .env, node_modules. That's what .gitignore is for (next section). Check git status before every commit.

Ignoring files with .gitignore

A .gitignore file lists patterns for paths Git should not track. It lives in the repo (usually at the root) and is committed like any other file, so the whole team shares it.

# a comment
node_modules/        # a directory anywhere in the tree
*.log                # any file ending in .log
/dist                # dist, but only at the repo root
.env                 # secrets
!.env.example        # …but DO track this one (negation)
build/*.tmp          # tmp files inside build/

Patterns follow shell globbing. A leading / anchors to the .gitignore's location; a trailing / matches directories only; ! re-includes something an earlier rule excluded.

WARNING

.gitignore only affects untracked files. If a file is already tracked, adding it to .gitignore does nothing — Git keeps versioning it. Stop tracking it (while keeping it on disk) with:

git rm --cached secrets.env
git commit -m "Stop tracking secrets.env"

And if a secret was ever committed, it's in the history forever until you rewrite it — rotate the credential.

Start from a template for your stack at github.com/github/gitignore.

Viewing history: git log

git log walks backwards from your current commit through its parents.

git log                       # full messages, newest first
git log --oneline             # one line per commit: short hash + summary
git log --oneline --graph --all --decorate
                              # ASCII graph of every branch and where refs point
git log --stat                # + files changed and line counts
git log -p                    # + the full diff of every commit
git log -5                    # just the last five
git log --since="2 weeks ago" --author="Ada"
git log main..feature         # commits on feature that aren't on main
git log -- path/to/file       # history touching one path

git show <commit> inspects a single commit; git blame <file> shows which commit last changed each line.

NOTE

git log --oneline --graph --all is the single most useful way to see what Git is doing — branches diverging, merges, where HEAD is. Alias it: git config --global alias.lg "log --oneline --graph --all --decorate", then just git lg.

Every commit is identified by a 40-character SHA-1 hash (or 64-char SHA-256 in newer repositories). You can abbreviate it to the first 7–8 characters anywhere Git expects a commit, as long as it's unambiguous.

Tier 2Intermediate

Branches as pointers, merging, and working with other repositories.

What a branch really is

This is the single most important idea in Git, and the one most tutorials get subtly wrong.

A branch is not a copy of your files. A branch is a movable pointer to one commit — physically, a file under .git/refs/heads/ containing a 40-character hash and a newline. That's it. Creating a branch writes ~41 bytes and touches nothing else:

git branch feature        # create "feature" pointing at the current commit
cat .git/refs/heads/feature   # → a single commit hash

Because a commit records its parent, and a branch just names a commit, "the commits on a branch" means "this commit and everything you reach by following parent links." Two branches can point at the same commit — they only diverge once you commit on one of them.

HEAD is another pointer: it names the branch you currently have checked out (.git/HEAD usually contains ref: refs/heads/main). When you git commit, Git creates the commit, then moves the branch HEAD points to forward — not HEAD directly.

Step through the diagram below to watch git branch, git switch, and git commit move these pointers.

WARNING

Because branches are so cheap, make one for every piece of work. Deleting a branch (git branch -d) just removes the pointer — the commits live on until garbage collection, and only if nothing else can reach them.

A branch is a pointerstep through branch + switch + commit
C1C2C3mainHEAD

$ git log --oneline

1/5 One branch. `main` is just a pointer to commit C3, and HEAD points to `main`.

Creating & switching branches

Since Git 2.23 there are two focused commands, switch and restore, that split up the old overloaded git checkout.

git branch                     # list local branches (* marks current)
git branch -a                  # include remote-tracking branches

git switch main                # check out an existing branch
git switch -c feature          # create "feature" and check it out
git switch -c fix origin/main  # branch from a specific starting point
git switch -                   # switch to the previous branch

git branch -d feature          # delete a branch that's been merged
git branch -D feature          # force-delete (unmerged — you lose the pointer)
git branch -m old new          # rename

git checkout still works and you'll see it everywhere: git checkout feature (= switch), git checkout -- file.txt (= restore file.txt), git checkout abc123 (detached HEAD — see HEAD & refs).

NOTE

git switch refuses to change branches if it would silently overwrite uncommitted changes. Either commit them, git stash them, or — if the changes make sense on the other branch — Git will carry them across cleanly when there's no conflict.

WARNING

git checkout -- file.txt and git restore file.txt permanently discard your uncommitted changes to that file. There is no reflog for the working tree. Double-check the path.

Merging: fast-forward vs. three-way

git merge feature brings the commits from feature into your current branch. There are two outcomes — toggle between them in the diagram below.

Fast-forward. If your current branch has no commits that feature doesn't, there's nothing to combine. Git just slides your branch pointer forward to feature's commit. History stays perfectly linear; no merge commit is created.

Three-way merge. If both branches have moved on since they diverged, Git finds their common ancestor, combines the three snapshots (ancestor + both tips), and records the result as a merge commit — a commit with two parents. Your branch's history now shows the fork and the join.

git switch main
git merge feature                 # fast-forward if possible, else a merge commit
git merge --no-ff feature         # always create a merge commit (keeps the branch visible)
git merge --ff-only feature       # only if it's a fast-forward, else abort

WARNING

A merge is not "combining files line by line." It's a new snapshot with two parent pointers. Git computes that snapshot by three-way-comparing; where the two sides changed the same lines differently, it can't decide — that's a conflict (next section).

NOTE

Teams often prefer --no-ff for feature branches so the history records that a set of commits belonged together, and --ff-only for git pull so pulling never quietly creates a merge commit.

Fast-forward vs. three-way mergepick a scenario, then merge
C1C2C3F1mainfeature

Both branches moved on from C2, so Git finds the common ancestor (C2), builds a new snapshot from all three, and records it as merge commit M — a commit with two parents.

Merge conflicts

A conflict happens when both branches changed the same region of the same file in different ways, and Git can't pick a winner. It's not an error — it's Git handing you the decision.

Git marks the spot in the file and pauses the merge:

<<<<<<< HEAD
const timeout = 30_000;
=======
const timeout = 60_000;
>>>>>>> feature

Between <<<<<<< and ======= is your current branch's version; between ======= and >>>>>>> is the incoming version. Resolve it by editing the file so it's correct — keep one side, the other, or write something new — and delete all three marker lines. Then:

git status                 # lists "Unmerged paths"
git add resolved-file.js   # mark this file as resolved
git merge --continue       # finish once everything is resolved
git merge --abort          # or bail out entirely, back to pre-merge state

NOTE

git config --global merge.conflictstyle zdiff3 adds a third section showing the common ancestor, which makes it far easier to see what each side actually changed.

WARNING

Conflicts are resolved per-file with git add. It's easy to fix the obvious conflict markers, git add, and miss a second conflict lower in the same file. Search the whole file for <<<<<<< before staging, and build/test before running git merge --continue.

What a remote actually is

A remote is just another Git repository that yours knows how to reach — usually on a server, identified by a URL and a short name (origin by convention). Nothing about a remote is special; it's a repo like yours, and "GitHub" is a host that stores one for you plus a web UI.

git remote -v                          # list remotes and their URLs
git remote add origin git@github.com:me/project.git
git remote add upstream https://github.com/original/project.git
git remote rename origin gh
git remote remove upstream

Your repo keeps remote-tracking branches — read-only local pointers named origin/main, upstream/dev, etc. — that record where each branch was on the remote the last time you communicated with it. They only move when you fetch, pull, or push. git branch -r lists them.

NOTE

origin/main and main are different pointers. main is your local branch you commit to; origin/main is your cached snapshot of the server's main. When they've diverged, git status says things like "your branch is ahead of 'origin/main' by 2 commits."

A common setup: origin is your fork (you push here), upstream is the canonical repo (you fetch here to stay current).

clone, fetch vs. pull

git clone git@github.com:me/project.git       # full copy: all history + working tree
git clone --depth 1 <url>                      # shallow: just the latest commit

clone sets up origin, creates remote-tracking branches, and checks out the default branch with an upstream already configured.

git fetch downloads new commits and objects from the remote and moves your remote-tracking branches (origin/*). It does not touch your local branches or working tree. It's always safe — nothing you're working on changes.

git pull is git fetch followed immediately by an integration of the upstream branch into your current one — a merge by default, or a rebase with --rebase or pull.rebase=true.

git fetch origin
git log HEAD..origin/main --oneline   # review what you fetched before integrating
git merge origin/main                 # …then integrate deliberately

git pull                              # fetch + merge in one step
git pull --rebase                     # fetch + replay your commits on top

Step through the diagram below to see exactly which pointer moves at each stage.

WARNING

git pull on a branch with local commits and new upstream commits creates a merge commit (often a messy "Merge branch 'main' of …"). Many people set git config --global pull.rebase true for a linear history — but only rebase commits you haven't pushed.

fetch vs. pullstep through the sync
REMOTE — originC1C2C3C4LOCAL cloneC1C2C3mainorigin/main

$ git log origin/main..main

1/3 The remote `origin` has a new commit C4. Locally, both `main` and the remote-tracking ref `origin/main` still point at C3 — Git hasn't talked to the server yet.

push, upstream tracking, rejections

git push uploads commits your current branch has that its upstream branch on the remote doesn't, then moves the remote's branch pointer.

git push                              # push current branch to its upstream
git push -u origin feature            # first push: also set the upstream link
git push origin feature               # push without setting upstream
git push origin --delete old-branch   # delete a branch on the remote
git push --tags                       # tags are not pushed by default

An upstream (tracking) branch is the remote branch your local branch is paired with. -u (--set-upstream) records it, so afterwards bare git push / git pull / git status know what to compare against.

Push rejections. If the remote branch has commits you don't have locally, Git rejects the push as non-fast-forward — accepting it would make those commits unreachable. The fix is to integrate first:

git pull --rebase     # replay your commits on top of the remote's
git push              # now it's a fast-forward

WARNING

git push --force overwrites the remote branch and can destroy commits other people pushed in between. Use git push --force-with-lease instead: it refuses unless the remote is exactly where you last saw it. Never force-push a shared branch like main.

Setting work aside with git stash

git stash shelves your uncommitted changes (working tree + staged) and reverts your working tree to a clean HEAD, so you can switch context without committing half-done work.

git stash                       # or: git stash push -m "wip: refactor auth"
git stash list                  # stash@{0}, stash@{1}, …
git stash show -p stash@{0}     # what's in a stash
git stash pop                   # re-apply the newest stash AND drop it
git stash apply stash@{1}       # re-apply a specific stash, keep it in the list
git stash drop stash@{0}        # delete one
git stash -u                    # also stash untracked files
git stash branch fix-x          # create a branch from a stash (if it won't apply cleanly)

Under the hood a stash is a couple of real commits parked on the refs/stash ref — not a patch file.

WARNING

The stash is a stack, and pop from a dirty working tree can itself conflict. It's easy to lose track of which stash is which — always use -m to name them, and prefer a throwaway branch or a WIP commit for anything you'll leave stashed for more than a few minutes.

NOTE

git stash is not backed up anywhere and isn't included in git push. A stash you forget about can be lost in a later git stash clear.

Tier 3Advanced

Rewriting history deliberately — and the safety nets that let you.

Rebasing vs. merging

Both merge and rebase integrate one branch's work into another. They differ in what the history looks like afterwards — compare them side by side in the diagram below.

Merge preserves exactly what happened: your commits stay as they are, and a merge commit records the join. History is truthful but can get tangled with many branches.

Rebase rewrites your branch's commits so they appear to have been made on top of the target branch. Git takes each of your commits, and re-creates it with a new parent — and therefore a new hash. The old commits become unreachable. Result: a clean, linear history with no merge commits.

git switch feature
git rebase main            # replay feature's commits onto the tip of main
git rebase --continue      # after resolving a conflict
git rebase --abort         # back to before you started
git rebase --onto main old-base feature   # surgical: move just part of a branch

CAUTION

Never rebase commits that others have based work on (anything you've pushed to a shared branch). Rebasing rewrites hashes; everyone else still has the originals, and their next pull creates duplicate commits and confusion. Rule of thumb: rebase local, unpushed work freely; merge everything else.

NOTE

A common workflow: rebase your feature branch onto main while you work (keeps it current and clean), then merge --no-ff it into main when it's done (records the feature as a unit).

Merge vs. rebasesame history, two strategies

git merge feature (on main)

C1C2C3F1F2mainfeature

git rebase main (on feature)

C1C2C3F1F2mainfeature

$ git switch feature

1/2 Starting point: `feature` (F1, F2) branched off C2, while `main` moved on to C3.

Interactive rebase

git rebase -i <base> opens an editor listing every commit from <base> to HEAD, oldest at the top, each with an action you can change:

pick   a1b2c3d  Add user model
squash 4d5e6f7  fix typo
reword 8a9b0c1  Add login endpoint
drop   2c3d4e5  debugging noise
Action Effect
pick keep the commit as-is
reword keep the changes, edit the message
edit pause here so you can amend the snapshot
squash merge into the previous commit, combine messages
fixup like squash, but discard this commit's message
drop delete the commit entirely

Reorder commits by reordering the lines. Save and close; Git replays from the top, stopping wherever it needs you (edit, or a conflict). git rebase --continue after each.

git rebase -i HEAD~4          # tidy the last 4 commits
git rebase -i main            # tidy everything since you branched
git commit --fixup=<hash>     # queue a fixup; then: git rebase -i --autosquash main

CAUTION

Interactive rebase rewrites every commit from the edit point onward — new hashes. Only do this on commits you haven't pushed to a shared branch. If you must update a shared branch, coordinate and use git push --force-with-lease.

Cherry-picking

git cherry-pick <commit> takes the change introduced by one commit and applies it as a new commit on your current branch. Use it to pull one hotfix out of a branch without merging the whole thing, or to move a commit you made on the wrong branch.

git switch main
git cherry-pick a1b2c3d              # apply one commit here
git cherry-pick a1b2c3d..f6e5d4c     # a range (exclusive of the first)
git cherry-pick -x a1b2c3d           # note the original hash in the message
git cherry-pick --continue           # after resolving a conflict
git cherry-pick --abort

The new commit has the same diff and message but a different hash and parent — it's a copy, not a move.

WARNING

Cherry-picking a commit that later gets merged in the normal way means the same change arrives twice. Git usually notices identical patches during a merge, but not always — and never during a rebase. Overusing cherry-pick is a sign the branches should have been organised differently.

NOTE

Committed on the wrong branch? git switch right-branch, git cherry-pick <hash>, then go back and git reset --hard HEAD~1 on the wrong branch (or git revert if it was pushed).

reset: --soft, --mixed, --hard

git reset <target> moves the current branch pointer to <target>. The flag controls how far the change propagates into the staging area and working tree. Try each mode in the diagram below.

Mode HEAD / branch Staging area Working tree Use it to…
--soft → target unchanged unchanged undo commits but keep everything staged (e.g. to re-commit as one)
--mixed (default) → target → target unchanged unstage things / undo commits, keep the edits to re-stage
--hard → target → target → target throw the changes away completely
git reset --soft HEAD~1     # undo last commit, keep changes staged
git reset HEAD~1            # undo last commit, keep changes unstaged (--mixed)
git reset --hard HEAD~1     # undo last commit AND discard its changes
git reset --hard origin/main   # make local branch exactly match the remote
git reset path/to/file     # unstage one file (no commit move)

CAUTION

--hard is the only Git command in everyday use that can destroy uncommitted work with no undo — the working tree has no reflog. The commits it "removes" are safe in git reflog for ~90 days; the uncommitted edits are simply gone.

NOTE

reset moves a branch pointer — it does not delete commits. C2 and C3 in the diagram still exist; they're just no longer reachable from main.

reset: --soft / --mixed / --hardpick a mode
C1C2C3HEADindexworking tree

$ git reset --<mode> C1

HEAD, the index and the working tree all currently sit at C3.

revert vs. reset

Both undo a change; they differ in whether they rewrite history.

git reset moves the branch pointer backwards. The unwanted commits vanish from the branch. Great for local work, unsafe on shared branches — everyone else still has those commits, and your next push is non-fast-forward.

git revert <commit> creates a new commit that applies the inverse of the target commit. History only grows; nothing is rewritten. This is the safe way to undo something that's already been pushed.

git revert a1b2c3d              # new commit undoing a1b2c3d
git revert HEAD                 # undo the last commit, safely
git revert -m 1 <merge-commit>  # revert a merge (pick which parent to keep)
git revert --no-commit A B C    # stage several reverts, commit once

NOTE

Decision rule: has the commit been pushed to a branch other people use? Yes → revert. No → reset is fine and keeps history tidy.

WARNING

Reverting a merge commit is a known footgun: it undoes the code, but the branch stays "merged," so re-merging later won't re-introduce the changes. If you revert a merge, you typically have to revert the revert before merging again.

The reflog: your safety net

Every time HEAD moves — commit, checkout, reset, rebase, merge, pull — Git appends a line to the reflog, a local journal of where HEAD (and each branch) has been.

git reflog                       # HEAD's recent positions
git reflog show main             # just the "main" branch's history of positions
a1b2c3d HEAD@{0}: reset: moving to HEAD~2
9f8e7d6 HEAD@{1}: commit: Add caching layer
4d5e6f7 HEAD@{2}: checkout: moving from main to feature

This is your undo button for almost any "oh no" moment. Botched a rebase, hard-reset too far, deleted a branch? The commits are still there — find them in the reflog and point a branch back at them:

git reset --hard HEAD@{1}          # go back to where you were one action ago
git switch -c recovered a1b2c3d    # rescue a specific commit into a new branch
git branch recovered HEAD@{5}

NOTE

The reflog is per-repository and local — it isn't cloned, fetched, or pushed. If you delete the repo, the reflog goes with it. Entries expire after 90 days (30 for unreachable commits) and then become eligible for garbage collection.

WARNING

Because it's local-only, the reflog can't help a teammate recover your lost commit. And git reflog expire --expire=now --all followed by git gc --prune=now really does delete unreachable history — don't run that when you're trying to recover something.

Tags: lightweight vs. annotated

A tag is a fixed name for a specific commit — typically a release. Unlike a branch, a tag never moves.

Lightweight tag — just a pointer (a file in refs/tags/), like a branch that won't advance:

git tag v1.4.0

Annotated tag — a full object in the database with its own author, date, message, and (optionally) a GPG signature. This is what you want for releases:

git tag -a v1.4.0 -m "Release 1.4.0"
git tag -s v1.4.0 -m "Release 1.4.0"    # signed
git tag                       # list
git tag -l "v1.4.*"           # filter
git show v1.4.0               # tag message + the commit it points to
git tag -a v1.3.9 9fceb02     # tag an old commit
git push origin v1.4.0        # tags are NOT pushed by git push
git push origin --tags        # push all of them
git tag -d v1.4.0             # delete locally
git push origin --delete v1.4.0

WARNING

git checkout v1.4.0 lands you in detached HEAD — you're on a commit, not a branch. Fine for looking around; commit from there and you need git switch -c to keep the work, or it's lost.

NOTE

Don't move a published tag. If a release is broken, cut a new version. Tools and people assume v1.4.0 means the same commit forever.

Submodules (and when to avoid them)

A submodule embeds one Git repository inside another. The parent repo doesn't store the submodule's files — it stores a .gitmodules file (URL + path) and, in its tree, a single commit hash pinning the submodule to an exact revision.

git submodule add https://github.com/org/lib vendor/lib
git clone --recurse-submodules <url>       # clone parent + all submodules
git submodule update --init --recursive    # if you forgot --recurse-submodules

# bump the submodule to a newer commit
cd vendor/lib && git switch main && git pull && cd -
git add vendor/lib && git commit -m "Bump vendor/lib"

CAUTION

Submodules are a frequent source of confusion: cloning without --recurse-submodules gives empty directories; the submodule is checked out in detached HEAD by default, so commits made inside it are easy to lose; and every collaborator has to run submodule update after each pull that changed the pin. Reach for a package manager, a monorepo, or Git subtree first, and use submodules only when you genuinely need a nested, independently-versioned repo.

Git hooks

Hooks are executable scripts Git runs at specific points in its workflow. They live in .git/hooks/ (see the .sample files Git ships) and are named for the event they fire on.

Client-side, common ones:

  • pre-commit — runs before the commit is created; exit non-zero to abort. Used for linting, formatting, running fast tests, blocking secrets.
  • commit-msg — receives the message file path; validate or rewrite it (e.g. enforce Conventional Commits).
  • pre-push — last gate before commits leave your machine.

Server-side (on the remote): pre-receive, update, post-receive — enforce policy, trigger deploys or CI.

ls .git/hooks/                 # *.sample templates
chmod +x .git/hooks/pre-commit # a hook must be executable to run
git commit --no-verify         # bypass pre-commit and commit-msg hooks

NOTE

.git/hooks/ is not part of the repository — it isn't cloned or pushed, so hooks don't distribute themselves. Teams use a tool like Husky, pre-commit, or Lefthook that keeps hook definitions in the repo and installs them for everyone. This site's template does exactly that.

WARNING

Hooks run arbitrary code on your machine. Be wary of any setup step that installs hooks from an untrusted source, and remember --no-verify exists for when a hook is broken and blocking you.

Tier 4Internals

What Git actually stores on disk, and why branching is nearly free.

A tour of the .git directory

Everything Git knows about your project is in one directory: .git/. Delete it and you have a plain folder; keep it and you have the entire history. A quick tour:

.git/
├── HEAD              # ref: refs/heads/main  — which branch is checked out
├── config            # this repo's configuration
├── description       # only used by GitWeb
├── index             # the staging area (a binary file)
├── objects/          # every blob, tree, commit and tag, content-addressed
│   ├── 3a/           #   loose objects: dir = first 2 hex chars of the hash
│   └── pack/         #   packfiles: many objects, compressed together
├── refs/
│   ├── heads/        # local branches — each file holds one commit hash
│   ├── tags/
│   └── remotes/
│       └── origin/   # remote-tracking branches
├── logs/             # the reflog
└── hooks/            # hook scripts (not versioned)
git rev-parse --git-dir        # where is .git for this working tree?
cat .git/HEAD
cat .git/refs/heads/main       # → a commit hash, plus a newline

NOTE

This is why Git operations are fast and work offline: reading history is walking a local graph of files, not querying a server. It's also why .git can grow — every version of every file is in objects/ until garbage collection packs and prunes it.

The object model: blobs, trees, commits

Git's database has just four object types, and each is stored under the SHA-1 (or SHA-256) hash of its own contents — it's content-addressed. Step through the diagram below.

  • blob — the raw bytes of one file. No name, no permissions, just content.
  • tree — a directory listing: a set of entries, each (mode, name, hash) pointing at a blob or another tree.
  • commit — a pointer to one root tree (the snapshot), plus zero or more parent commits, plus author, committer, and message.
  • tag — an annotated tag: a pointer to an object plus metadata and an optional signature.
git cat-file -p HEAD           # show the commit: tree, parent, author, message
git cat-file -p HEAD^{tree}    # show the root tree
git cat-file -p <blob-hash>    # show a file's contents
git cat-file -t <hash>         # what type is this object?
git hash-object file.txt       # compute the hash Git would give this content

NOTE

Content-addressing gives Git automatic deduplication and integrity checking for free. Identical content anywhere — same file in two branches, unchanged file across 1,000 commits — is stored once. And if a byte in objects/ ever corrupts, the hash no longer matches and Git tells you.

WARNING

A commit hash is derived from its tree, its parents, and its author/date/message. That's why commit --amend, rebase, and cherry-pick all produce new hashes — any change to any input changes the identity of the commit.

Git's object modelcommit → tree → blob
COMMIT · a1b2c3dtree 9f8e7d6parent …author …

$ git cat-file -p HEAD

1/3 The commit object stores metadata (author, message, parent) and one pointer: the root tree for this snapshot.

Snapshots, not diffs

Many version control systems store a file as an original plus a chain of diffs. Git stores snapshots. Every commit points to a tree that names the complete state of every file in the project at that moment.

That sounds wasteful until you remember content-addressing: a file that didn't change between commits is the same blob, so the new tree just points at the blob that already exists. A commit that touches one file in a 10,000-file repo adds one new blob, a handful of new trees along that file's path, and one ~150-byte commit object. Watch this in the diagram below.

git cat-file -p HEAD^{tree}      # the whole snapshot — every top-level entry
git cat-file -p HEAD~5^{tree}    # five commits ago, still a complete listing

Why branching is cheap. A branch is a 41-byte pointer to a commit. The commit already shares every unchanged blob and tree with its neighbours. Creating a branch and switching to it copies nothing — it just changes which commit HEAD resolves to and updates the working tree to match.

NOTE

Git does compute diffs — for git diff, git log -p, git blame, and to compress storage in packfiles. But those are calculated on demand from the snapshots; they are not how history is stored.

Snapshots, not diffsunchanged files reuse blobs
C1a.txtb0b.txtb1c.txtb2

$ git cat-file -p C1^{tree}

1/3 C1 snapshots three files, storing a blob for each.

HEAD, refs, and the ref namespace

A ref is a human-readable name for a commit hash — a small file under .git/refs/. Branches and tags are just refs in different namespaces:

Namespace Contains Example
refs/heads/ local branches refs/heads/main
refs/remotes/ remote-tracking branches refs/remotes/origin/main
refs/tags/ tags refs/tags/v1.4.0

HEAD is a special ref that answers "where am I?". Normally it's symbolic — it contains ref: refs/heads/main, meaning "I'm on main, and committing moves main." Check out a commit directly and HEAD becomes detached: it holds a raw hash, and commits you make aren't on any branch.

Refs can be combined with suffixes to navigate the graph:

HEAD~1      # first parent of HEAD  (also HEAD^)
HEAD~3      # three first-parents back
HEAD^2      # the SECOND parent (only meaningful for merge commits)
main@{1}    # where main pointed one reflog entry ago
main@{yesterday}
git rev-parse HEAD      # resolve any of the above to a full 40-char hash
git symbolic-ref HEAD   # what HEAD currently points to

NOTE

git switch -c rescue <hash> turns a detached HEAD into a real branch, so commits made while detached aren't lost to garbage collection.

Packfiles & garbage collection

New objects are written loose — one zlib-compressed file each under .git/objects/xx/. That's fast to write but inefficient at scale, so Git periodically runs housekeeping.

git gc (garbage collection):

  1. Packs loose objects into a single packfile (.git/objects/pack/), storing similar objects as deltas against each other and compressing the whole thing. A repo can shrink dramatically.
  2. Updates the reflog, expiring old entries.
  3. Prunes objects that are both unreachable from any ref and older than the grace period (default 2 weeks) — this is when "lost" commits are finally deleted.
git gc                     # usually runs automatically after enough activity
git gc --aggressive        # slower, tighter packing — rarely needed
git count-objects -vH      # loose vs packed object counts and sizes
git prune --dry-run        # what would be pruned right now
git fsck --lost-found      # find dangling (unreachable) objects

NOTE

Git triggers gc --auto on its own after operations that create many loose objects (commit, merge, receive-pack). You rarely run it by hand.

WARNING

After a bad rebase or reset, your rescue window is "until gc prunes it" — reachable via git reflog for ~90 days, or git fsck until the ~2-week unreachable grace period passes. Recover first, tidy later. Don't run git gc --prune=now while trying to get something back.

Cheat sheet

Every command on this page, grouped by task. Click a command to jump back to the section that explains it.

Setup

git config --global user.name "…"Set the name on your commits
git config --global user.email "…"Set the email on your commits
git config --global init.defaultBranch mainName new repos' first branch `main`
git config --list --show-originShow every setting and which file it came from

Everyday

git initTurn the current directory into a repository
git statusWhat's modified, staged, and untracked
git add <path>Stage a file's current contents
git add -pStage selected hunks interactively
git commit -m "…"Record the staged snapshot
git commit --amendReplace the last commit (rewrites it)
git diffWorking tree vs. staged
git diff --stagedStaged vs. last commit
git restore <path>Discard working-tree changes to a file
git restore --staged <path>Unstage a file, keep the changes

History

git log --oneline --graph --allCompact, visual history of every branch
git log -p <path>Every change to one file, with diffs
git show <commit>Inspect one commit's message and diff
git blame <path>Which commit last touched each line
git reflogEvery position HEAD has held — your undo log

Branching

git branchList local branches
git switch -c <name>Create a branch and check it out
git switch <name>Check out an existing branch
git branch -d <name>Delete a merged branch (pointer only)
git merge <name>Merge a branch into the current one
git rebase <base>Replay current branch's commits onto `base`
git rebase -i <base>Squash / reword / reorder / drop commits
git cherry-pick <commit>Copy one commit onto the current branch

Remotes

git clone <url>Copy a remote repo and its history
git remote -vList configured remotes
git fetch <remote>Download new commits; move remote-tracking refs only
git pullfetch + merge (or --rebase) the upstream branch
git pushUpload commits to the upstream branch
git push -u <remote> <branch>Push and set the upstream tracking branch
git push --force-with-leaseOverwrite remote history, but only if nobody else pushed

Undo & rescue

git reset --soft <commit>Move HEAD; keep index and working tree
git reset --mixed <commit>Move HEAD and index; keep working tree (default)
git reset --hard <commit>Move all three — discards uncommitted work
git revert <commit>New commit that undoes another (safe on shared branches)
git stashShelve working-tree changes
git stash popRe-apply the most recent stash
git reflogFind a 'lost' commit and reset back to it

Tags & internals

git tag <name>Lightweight tag — a bare pointer
git tag -a <name> -m "…"Annotated tag — a real object with metadata
git cat-file -p <hash>Print any object: commit, tree, or blob
git rev-parse HEADResolve a ref to its full SHA
git gcCompress loose objects into a packfile