Git Internals: How Version Control Really Works
Follow a file from your working directory into Git's object database. Discover how commits, branches, merges, rebases, and remote synchronization actually work—with interactive diagrams and a Git repository simulator.
1. Why Git exists
Version control is a system that records changes to files over time so that earlier versions can be recalled later, and it works for nearly any kind of file, not only source code . With one you can roll a single file or the whole project back to an earlier state, compare changes over time and find out who changed something and when .
The problems it solves
Anyone who has worked on a project without version control knows the folder list: project-final, project-final-v2, project-final-fixed. Copying files into (perhaps time-stamped) directories is the common do-it-yourself method, and Pro Git calls it simple but very error prone; a plain copy records no history, no author and no reason for the change . Four questions keep coming back:
- Which copy is current? Folder names drift, and nothing ties one copy to the next.
- How do two people change the same project in parallel and then combine their work?
- How do I get last Tuesday's version back without overwriting today's?
- Why did this line change, and who changed it?
From local to distributed
Early local systems such as RCS stored patch sets, the differences between files, and rebuilt any version by adding the patches up . Centralized systems such as CVS, Subversion and Perforce keep every versioned file on one server that clients check files out from, which makes that server a single point of failure . In a distributed system such as Git, each client mirrors the whole repository, history included .
Git itself came out of the Linux kernel project. The kernel used the proprietary distributed system BitKeeper from 2002; when that relationship broke down in 2005, the community, led by Linus Torvalds, built its own tool . Its design goals were speed, a simple design, strong support for non-linear development with thousands of parallel branches, being fully distributed, and handling projects the size of the kernel efficiently .
Try it
The lab below puts the two approaches side by side: a folder of hand-made copies on one side, a repository on the other. Make three changes to hello-git and watch each one become a commit that references a complete snapshot, then inspect an old version and recover it. The lab is a simplified simulation, but the object IDs it shows are computed the way Git computes them.
A realistic scenario
Ada Lovelace changes the greeting in src/utils.js, commits "Punctuate the greeting", then adds run instructions to README.md and commits "Document how to run". A week later a user reports that the greeting looks wrong. Instead of hunting through folders, Ada asks Git for the history of src/utils.js, sees which commit changed it, who wrote it and why, and compares it with the version before. Every one of those questions is answered from her own disk, because nearly every Git operation reads only the local database .
Snapshots, not differences
Most other version control systems store a base version plus a list of per-file changes. Git instead treats its data as a series of snapshots of a miniature filesystem . Snapshots are cheap because a file that has not changed is not stored again: the new snapshot just links to the identical copy already stored . Git does not store a diff for a commit at all; when you show a commit, Git calculates the diff from its parent on the fly . Underneath, Pro Git describes Git as a content-addressable filesystem with a version-control user interface on top , which is what the object chapters of this study take apart.
Packfiles do contain deltas, but only as storage compression, and Git usually keeps the newest version whole with older ones stored as deltas . That is not the same as a commit storing a diff, and chapter 25 covers it.
Local, checksummed, append-mostly
Three properties follow from the design:
Property — What it means; Nearly every operation is local — Browsing history, diffing against old versions and committing read and write the local database, so you can commit offline and upload later; Integrity — Everything is checksummed before it is stored and is then referred to by that checksum, so changed or corrupted content cannot go unnoticed . In a default repository the checksum is a SHA-1 hash written as 40 hexadecimal characters; Git generally only adds data — Nearly all actions only add data to the database, so committed work is hard to lose
Version control is not a backup
The last row has a limit. Uncommitted changes in the working tree can still be lost, as in any version control system . Several other things a backup would keep are not part of the shared history either:
- Untracked files are not in any snapshot until you stage them , and ignored files stay out by design .
- Reflogs, the local record of where branches pointed, are never pushed or fetched .
- A clone carries all versioned data but not server-side hooks and similar settings .
- Integrity checks detect corruption but cannot repair it: git fsck reports a hash mismatch, and good copies must come from backups or other clones .
A distributed history does make each full clone a copy that could restore a lost server , but that is a side effect of the design, not a backup policy.
Investigate it
``bash # Who changed src/utils.js, when, and why git log --oneline -- src/utils.js git log -p -1 -- src/utils.js # the diff is computed now, from two snapshots # See an old version without changing anything git show HEAD~1:src/utils.js # Bring one file back from an older commit into the working tree git restore --source=HEAD~1 src/utils.js # Check the object database for corruption git fsck `
git restore --source` updates only files; it does not move your branch . Output depends on your own history, so treat any sample you see elsewhere as illustrative.
2. Git's distributed architecture
In a centralized system, your computer holds a checkout of the latest files and the history lives on the server. In Git, a clone mirrors the whole repository including its full history . The command is called clone, not checkout, because by default every version of every file is pulled down .
Every clone is a repository
Once you have cloned, nothing distinguishes your copy from the one you cloned from except configuration. Clone creates a remote-tracking branch for each branch in the source and checks out one local branch forked from the source's currently active branch . The link back to the source is just configuration and refs: the remote's branch heads are recorded under refs/remotes/origin, and remote.origin.url and remote.origin.fetch are set .
That has practical consequences:
- Commits are local. Committing, browsing history and diffing use only the local database, so you can work offline and share later .
- "Committed" is not "shared". A committed change is safely stored in your local database; that says nothing about whether it has been pushed anywhere .
- "origin" is a name, not a server role. It is only the default name for the repository you cloned from, and a remote-tracking branch such as origin/main is your local record of the last-known state of a branch over there .
Who is the source of truth?
Technically, nobody. In Git every developer is potentially both a node and a hub, and an "official" repository is a convention of the team's workflow, not a property of the software . Two common arrangements:
Workflow — How it works; Centralized — One shared repository that everyone pushes to. A push that is not a fast-forward is rejected until the pusher fetches and merges; Integration manager (and dictator and lieutenants) — Several public repositories; contributors publish to their own, and a maintainer pulls from them. The Linux kernel is the famous example of the lieutenants model
Try it
The lab below shows two developers' repositories, Ada's and Grace Hopper's, and a shared remote. Commit while "offline", fetch, push, then let both of you commit on main to create divergence and compare each local branch with its remote-tracking ref. It is a simplified simulation: transfers happen instantly and there is no authentication.
A realistic scenario
Ada is on a train with no connection. She commits "Document how to run" to hello-git; her main moves, origin/main does not, because nothing has talked to the remote. Meanwhile Grace pushes her own commits to main on the shared repository. When Ada reconnects, her push is rejected as a non-fast-forward until she fetches and integrates Grace's commits . Nothing was lost on either side; the two repositories simply knew different things until they exchanged data.
Remotes are just other repositories
A remote is a name plus a URL and a fetch mapping in your config . Fetching downloads objects and updates remote-tracking refs, not your own branches , so origin/main only changes when you fetch or push. Ahead/behind counts are measured against those refs and can be out of date without a fresh fetch . Some history is never exchanged at all: reflogs record changes in the local repository only .
Bare repositories
A shared repository on a server usually has no working tree. A bare repository has no checked-out files; the administrative files that would be in .git live directly in a directory conventionally named with a .git suffix . Bare repositories are typically used for exchanging history, and a minimal Git server is SSH access plus a bare repository . git clone --bare implies no checkout and copies the remote's branch heads directly to local branches, without the refs/remotes/origin/ mapping .
Pushing into a non-bare repository's checked-out branch is refused by default, because it would leave HEAD out of step with that repository's index and working tree . That is one practical reason shared repositories are bare.
Exceptions to "full history"
"Every clone has everything" is the default, not a law :
Option — What you get; --depth <n> — A shallow clone with history truncated to n commits; implies --single-branch unless --no-single-branch is given; --single-branch — Only the history leading to one branch tip; --filter=<spec> — A partial clone: the server sends a subset of reachable objects ; skipped objects are fetched on demand later
A partial clone must be online when it reaches a missing object, because the promisor remote has to be reachable . A clone from a local path skips the network transport and hardlinks object files when possible . Even a full clone is not a perfect copy of a server, since server-side hooks are not transferred .
Misconceptions
- "The server has the real repository." By convention, perhaps; technically your clone is equally complete .
- "Fetch updates my branch." It updates remote-tracking refs; integrating them is a separate step .
Investigate it
``bash # Where does origin point, and how is it mapped? git remote -v git config --get-regexp '^remote\.origin\.' # Your branches and your record of the remote's branches git branch -vv git for-each-ref refs/heads refs/remotes # Refresh your record of the remote, then compare git fetch origin git log --oneline main..origin/main # commits on origin/main you do not have git log --oneline origin/main..main # commits you have not pushed # Make a bare copy suitable for sharing git clone --bare hello-git hello-git.git ``
Hosting services add their own access rules, protected branches and web interfaces on top of this model; those vary by service and are not part of Git.
3. Working directory, index and repository
Pro Git describes three states a file can be in: modified (changed but not committed), staged (marked to go into the next commit in its current version) and committed (stored in the local database) . They correspond to three sections of a Git project: the working tree, the staging area and the Git directory .
Area — What it is; Working tree — The actual checked-out files: normally the HEAD commit's tree plus any local changes not yet committed; Index (staging area) — A file, usually inside the Git directory, describing what will go into the next commit; "index" is its technical name; Repository (Git directory) — The metadata and object database; this is what is copied when you clone
Why have an index at all?
The index lets you build the next commit deliberately instead of committing whatever happens to be on disk. Pro Git suggests reading git add as "add precisely this content to the next commit", not "add this file to the project" . Plain git commit records only what is in the index .
The index is not a folder of files. It is a flat list of entries; at commit time Git converts that list into a directory tree . When you stage a file, Git hashes it, stores it as a blob and records the blob's ID in the index entry .
Staged and modified at the same time
git add stages a file exactly as it is at that moment. Edit it again afterwards and it is listed as both staged and unstaged; you must git add again to stage the newer version . The index holds the version from your last git add, and the working tree has the newer edits .
Try it
The lab below shows the working tree, the index and the repository as three panels. Edit src/app.js, stage it, edit it again, run git status and notice that the same path appears under both headings; then commit and see which version went in. The simulator stages whole files (there is no git add -p).
A realistic scenario
Ada fixes a typo in src/app.js and runs git add src/app.js. Before committing she adds a debug console.log to the same file. git status now lists src/app.js under "Changes to be committed" and under "Changes not staged for commit" . If she runs git commit, the commit gets the typo fix only, because that is what the index holds ; the debug line stays in her working tree, uncommitted.
What an index entry contains
Each index entry has a file type (regular, executable, symlink or gitlink; never a directory), a blob ID, a stage number and a path . The stage is 0 normally; during a merge conflict one path can have up to three entries, stage 1 for the common ancestor, 2 for ours and 3 for theirs . The index also caches stat data such as modification times, which lets git status notice changed files without re-reading every one; this data never goes into tree objects . git ls-files --stage prints mode, object name, stage and path for each entry .
Reading git status
git status compares three pairs: the index against HEAD, the working tree against the index, and the working tree against what is tracked at all . The first group is what git commit would record now; the second and third are what you could commit after git add .
Long-format heading — Comparison; Changes to be committed — index vs HEAD; Changes not staged for commit — working tree vs index, tracked files; Untracked files — present on disk, not tracked, not ignored
In the short format (-s) the left column is the index and the right column the working tree, so MM means changes both staged and unstaged . ?? marks untracked paths, and ignored files appear as !! only with --ignored .
Tracked, untracked, ignored
Tracked files are those in the last snapshot plus any newly staged files; everything else in the working directory is untracked . A .gitignore file lists intentionally untracked files to ignore and has no effect on files already tracked; git rm --cached stops tracking one . Patterns come from several places with a fixed precedence, and within one level the last matching pattern wins . A ! pattern cannot re-include a file whose parent directory is excluded .
Misconceptions and edge cases
- "git commit -a commits everything." It stages changes to already-tracked files only; new untracked files are left out .
- "Unstaging deletes my change." git restore --staged <path> resets the index entry from HEAD and leaves the working tree alone .
- "Staging is just bookkeeping." Staging writes a blob immediately; if you stage, edit and stage again, the first blob is left unreferenced .
- Status hint text varies by Git version and repository state; older Git suggested git reset HEAD <file> to unstage .
Investigate it
``bash git status # long format with headings git status -s # two-column short format git status -s --ignored # include ignored files as !! git ls-files --stage # mode, blob ID, stage, path for every index entry git diff # working tree vs index (unstaged) git diff --cached # index vs HEAD (staged) git restore --staged src/app.js # unstage, keep the edit `
git diff and git diff --cached are the two comparisons behind the status headings . Output like MM src/app.js from git status -s` is illustrative of the staged-and-modified case.
4. What happens during git init
git init creates an empty repository: a .git directory with subdirectories for objects, refs/heads, refs/tags and template files, plus an initial branch that has no commits yet . Nothing is read from or sent to any server, and your existing files are not touched or tracked.
What appears
Entry — Purpose; HEAD — A symbolic ref to the current branch; every valid repository must have one; config — Repository-specific configuration; objects/ — The object database; fresh, it holds only the empty info/ and pack/ subdirectories; refs/heads/, refs/tags/ — Branch tips and tags, empty at first; hooks/ — Sample hooks, all disabled; info/exclude — Per-repository ignore patterns that are not committed; description — Used only by GitWeb
HEAD names a branch that does not exist yet
Right after git init, HEAD names a branch, but that branch has no commit, so no file exists for it under refs/heads/. HEAD may legitimately name such an unborn branch . With the default ref storage, .git/HEAD is a small text file of the form ref: refs/heads/master . The branch only comes into existence when the first commit is written.
Which name: master or main?
Without -b/--initial-branch, Git falls back to its built-in default, which is currently master and will become main when Git 3.0 is released . The init.defaultBranch setting, available since Git 2.28, overrides it . Hosting services are a separate matter: GitHub switched its default to main in mid-2020 and others followed, while Git itself kept master . There is nothing special about either name; it is an ordinary branch that git init happens to create .
Try it
The lab starts with an empty folder. Run git init and watch .git appear with HEAD, config, objects/, refs/ and hooks/; then add and commit and watch objects/xx/…, the index and refs/heads/main appear. The simulated user has set init.defaultBranch=main, so the lab's first branch is main. It shows a typical layout; real directories vary with version, templates and options.
A realistic scenario
Ada starts hello-git with git init. Her four files are untracked: none of them is in a snapshot or staged yet . Listing .git shows no index file: in a fresh non-bare repository it does not exist until something is staged . After git add . the index appears; after her "Initial commit", refs/heads/main appears too.
Re-running init is safe
Running git init in an existing repository overwrites nothing; its main use is to pick up newly added templates . If GIT_DIR is set, it replaces ./.git as the repository location .
Choices made at init time
Option — Effect; --object-format=<sha1 or sha256> — Picks the hash algorithm; sha1 is the default and sha256 is available if built in . SHA-1 and SHA-256 repositories cannot currently interoperate; --ref-format=<files or reftable> — Picks ref storage: "files" (loose files plus packed-refs) is the default, "reftable" the alternative; --bare — Creates a bare repository, in the current directory if GIT_DIR is not set; --separate-git-dir — Leaves a text "gitfile" in place of .git that points to the real repository
init.defaultObjectFormat and init.defaultRefFormat set defaults for new repositories, but command-line options and the GIT_DEFAULT_HASH / GIT_DEFAULT_REF_FORMAT environment variables take precedence . The hash algorithm is recorded in extensions.objectFormat, and changing it afterwards breaks the repository .
Protecting old Git from new repositories
Every repository records core.repositoryformatversion in its config, and Git must refuse to operate on a version it does not understand . Version 1 repositories also declare extensions.* keys, and Git must stop if it does not implement one; that is how SHA-256 and reftable repositories stop older Git from misreading them .
Templates and hooks
Template files come from a default directory such as /usr/share/git-core/templates, but the path is installation-dependent; the sample hooks it provides stay disabled until their .sample suffix is removed . So the exact contents of a new .git vary between installations.
Layouts vary
- .git can be a directory or a plain gitfile pointing elsewhere, as used by submodules and worktrees .
- packed-refs holds the same information as loose ref files in a single file .
- The legacy branches/ and remotes/ directories are deprecated, and Git will stop reading them in Git 3.0 .
The layout reference does not document the reftable/ directory that a --ref-format=reftable repository uses . Recent Git prints a hint about the initial branch name when init.defaultBranch is unset; the manual pages checked do not document that hint .
Investigate it
``bash mkdir hello-git && cd hello-git git init -b main # or set: git config --global init.defaultBranch main find .git -type f | sort # what init actually created on this installation cat .git/HEAD # ref: refs/heads/main git symbolic-ref HEAD # the branch HEAD names, even though it is unborn git config --get core.repositoryformatversion ls .git/index # absent until something is staged git add . && git commit -m "Initial commit" git show-ref # refs/heads/main now exists `
git symbolic-ref HEAD reads the symbolic ref , and git show-ref is the documented way to list refs instead of reading files under .git` directly .
5. Git's object database
At its core Git is a key-value store: you insert content and get back a key you can use to retrieve it later . Because the key is computed from the content, Pro Git calls this a content-addressable filesystem . The store is the object database, usually kept in $GIT_DIR/objects/ .
Four kinds of object
Type — Holds; blob — File contents only; no name; tree — A directory: names, modes and the IDs of blobs and subtrees; commit — The top-level tree, parent commits, author, committer and message; tag — An annotated tag: target object, its type, tag name, tagger and message
Those are the only four object types . All of them are stored the same way; only the type word in the header differs .
How a key is made
The key is not a hash of the content alone. Git prefixes a header: the object type, a space, the content size in bytes and a NUL byte, then hashes header plus content . In a default repository the hash is SHA-1, written as 40 hex characters .
Where it is stored
A new object is compressed with zlib and written as a loose object at .git/objects/ plus the first two hex characters as a directory and the remaining 38 as the file name . That fan-out spreads objects over 256 subdirectories to keep each directory manageable . Many objects can instead be stored together in packs under objects/pack .
Try it
The lab below lists every object in hello-git by type: blobs, trees, commits and an annotated tag. Click one to read its raw content, then follow the IDs it contains to the objects it points at. The object IDs are real SHA-1 IDs; commit IDs depend on author, time and message, so they will not match your own repositories.
A realistic scenario
Ada wants to know what "Document how to run" really recorded. git cat-file -t on the commit ID says commit and git cat-file -p shows its text : a tree line, a parent line, author and committer lines, a blank line and the message . She runs cat-file -p on the tree ID and sees README.md, package.json and a src entry pointing to another tree; one more step reaches the blob for src/utils.js. The whole project at that moment is reachable from that one commit ID.
Objects are immutable
An object is identified by the hash of its contents, so it cannot be changed . The data-model page says it directly: objects never change after creation, and "amending" a commit makes a new commit . What looks like editing history in Git is always the creation of new objects plus a change to which object a name points at.
Objects versus refs
Refs are not objects. A ref is a name that points to an object ID, or to another ref in the case of a symbolic ref, and it is stored outside the object database . Branches are expected to move with each commit and tags usually stay put, but both are refs; immutability belongs to objects only . A repository, in the glossary's words, is a collection of refs plus an object database holding every object reachable from them .
Reachability
Reachability follows tags to their targets, commits to their parents and trees, and trees to their subtrees and blobs . Git may delete objects that no reference or reflog can reach, but reachable objects are never deleted . For garbage collection, the index, remote-tracking branches, reflogs and anything under refs/ all count as roots , and unreachable loose objects are pruned only after a grace period, two weeks by default .
Sharing and reuse
Unchanged files reuse existing blobs: a commit that changes 2 of 1000 files creates just 2 new blobs . Because trees, commits and tags store the IDs of the objects they point to, changing one blob changes the tree that lists it and the commit that points to that tree . A repository can also borrow objects from another object database through the alternates mechanism, so an object store is not always self-contained .
Porcelain on top of plumbing
git add plus git commit is equivalent to plumbing steps: store blobs, update the index, write trees, then write a commit pointing at the top tree and its parents . Plumbing commands are meant for scripts and are kept more stable than porcelain .
Misconceptions
- "A commit stores the files that changed." It points to one tree, a snapshot of the whole project .
- "Objects live forever." Only reachable ones are guaranteed to .
- "Every object is a file under objects/xx/." Only loose objects are; packed objects live in packfiles .
Investigate it
``bash git cat-file -t HEAD # commit git cat-file -p HEAD # tree, parent, author, committer, message git cat-file -p 'HEAD^{tree}' # the root tree's entries git ls-tree -r HEAD # every blob in the snapshot, recursively git cat-file --batch-all-objects --batch-check # every object: ID, type, size find .git/objects -type f | head # loose objects and pack files on disk git count-objects -v # loose vs packed counts git fsck # validity and connectivity `
cat-file --batch-check prints a header of name, type and size per object , and git fsck` checks that every object is valid and that the objects it references exist . Exact output depends on your repository.
6. Blob objects and content addressing
A blob is the simplest Git object: the contents of a file, and nothing else. The file name is not stored in the blob; trees hold names . A blob is just bytes, points to no other object and carries no name, mode or timestamps .
Content in, key out
git hash-object computes the object ID for some content. It writes the object to the database only when you also pass -w; without it, it just prints the key . Its default type is blob .
Every object is stored as a prefix <type> <size>\0 followed by the data, where the size is the data length in bytes written in ASCII decimal . The object ID is the hash of that prefix plus the data . Two worked examples are documented:
Content — Hashed bytes — SHA-1 object ID; what is up, doc? (16 bytes, no newline) — blob 16\0what is up, doc? — bd9dbf5aae1a3862dd1526723246b20206e5fc37; Hello world plus a newline (12 bytes) — blob 12\0Hello world and the newline — 802992c4220de19a90767f3000a79a31b98d0df7
Because the header is part of what is hashed, running a plain sha1sum on a file does not give its Git object name .
Same content, same ID, stored once
Object names are computed the same way everywhere, so the same content gets the same ID in every repository . Identical content is stored once: two files with the same contents share one blob, wherever they are and in whichever version, and renaming a file keeps the same blob .
Try it
In the lab below, type into the text box and watch the blob ID update as you type. The panel shows the exact bytes being hashed, blob, a space, the size, the NUL byte and your content, one byte at a time. Type Hello world followed by Enter to reproduce the 802992c… test vector, then type the same text in a second box and see the identical ID. The lab hashes exactly what you type, with no line-ending conversion.
A realistic scenario
Ada copies src/utils.js to src/greet.js for a refactor and stages both. git ls-files --stage shows two index entries with different paths and the same blob ID, because the contents are identical . Only one blob exists in the object database. When she later edits src/greet.js, a new blob is written for the new content, and the original blob is untouched .
From bytes to a file on disk
The hash is taken over the uncompressed prefix plus data; the same bytes are then zlib-compressed and written to the object file . A loose object lives at .git/objects/<first 2 hex characters>/<remaining 38> for SHA-1 . New objects start as one loose file each and are packed into packfiles later by maintenance . Inside a pack the <type> <size>\0 prefix is not stored, because the pack entry header already carries type and size, but the object ID is still hashed over the rebuilt prefix .
For a feel of the format, a blob containing abc is stored, before compression, as blob 3\0abc .
Exact bytes
When hash-object reads with --stdin, it hashes the content exactly as given, with no attribute filters or line-ending conversion unless --path is given . When Git reads a file from your working tree, configured filters can change the bytes first , so, depending on configuration, the same visible text can yield a different blob on systems configured differently. That is why the lab works in exact-bytes mode.
Reading blobs back
git cat-file -p pretty-prints an object according to its type, -t prints the type, -s prints the size in bytes, and git cat-file <type> <object> prints the raw decompressed contents . For a blob, -p simply prints the file's contents.
Integrity
Since an object's name is the hash of its bytes, Git can detect corruption by re-hashing an object when it reads it . If the content no longer matches its name, git fsck reports a hash mismatch; it cannot repair the object .
SHA-1 and SHA-256
SHA-1 is the default object format; SHA-256 is chosen with git init --object-format=sha256, and the two kinds of repository currently cannot work together . A SHA-1 name is 40 hex characters (20 bytes) and a SHA-256 name is 64 hex characters (32 bytes) . A blob points to no other object, so its stored content is the same under either hash; only its name differs . Chapter 26 covers the transition, collision hardening and signing.
Misconceptions
- "Git stores files by name." It stores content by hash; names live in trees .
- "Renaming a file creates a new copy." The blob is reused .
- "Unchanged files are stored again in every commit." A commit reuses the existing blob IDs for every file it does not change .
Investigate it
``bash # Compute IDs without writing anything printf 'Hello world\n' | git hash-object --stdin # 802992c4220de19a90767f3000a79a31b98d0df7 printf '%s' 'what is up, doc?' | git hash-object --stdin # bd9dbf5aae1a3862dd1526723246b20206e5fc37 # Reproduce the ID by hand: header + content, then SHA-1 printf 'blob 12\0Hello world\n' | sha1sum # Write a blob and inspect it printf 'Hello world\n' | git hash-object -w --stdin git cat-file -t 802992c4220de19a90767f3000a79a31b98d0df7 # blob git cat-file -s 802992c4220de19a90767f3000a79a31b98d0df7 # 12 git cat-file -p 802992c4220de19a90767f3000a79a31b98d0df7 # Hello world ls .git/objects/80/ # the loose object file # The blob behind a tracked file git rev-parse HEAD:src/utils.js ``
The two IDs in the comments are the documented test vectors ; in a SHA-256 repository the same commands print 64-character names .
7. Tree objects and directory snapshots
A blob holds a file's bytes and nothing else: no name, no mode, no timestamp . Something has to say that those bytes are called src/utils.js. That job belongs to the tree object, Git's version of a directory: it stores filenames and groups files together, while blobs hold only contents .
What a tree contains
A tree is a list of entries. Each entry has a mode, an object type, an object name (hash) and a filename, and the entries are kept sorted by name . The type follows from the mode: files and symbolic links point to blobs, directories point to other trees, and gitlinks point to commits .
Mode — Meaning; 100644 — Regular file; 100755 — Executable file; 120000 — Symbolic link; 040000 — Directory (a nested tree); 160000 — Gitlink, used for a submodule
Those five are the only modes that exist . They look like UNIX permissions but are far less flexible: Git tracks only the executable bit, so a file is either 644 or 755 .
Directories are trees inside trees
A subdirectory is a nested tree: the directory entry points to another tree object, so every directory in the snapshot has a tree of its own . In hello-git the root tree lists README.md, package.json and a src entry of mode 040000; that entry names a second tree, which lists app.js and utils.js.
Because a tree stores the IDs of the objects it lists inside its own content, changing one blob changes the tree that lists it, and that change ripples up to the root . The reverse also holds: two trees have the same ID if and only if their whole contents, subdirectories included, are identical . Unchanged files keep their existing blobs , and unchanged directories keep their existing trees.
Try it
In the lab below, edit src/utils.js (change the greet function) and commit. Watch which objects are new: only the changed blob, the src tree and the root tree. README.md, package.json, src/app.js and their IDs are shared with the previous snapshot. The object IDs shown are real SHA-1 names computed from Git's format; the lab is otherwise a simplified simulation.
A realistic scenario
Ada Lovelace changes one line of src/utils.js in a project of a thousand files. Her commit does not store a thousand files again. It adds one blob, the trees on the path from that file to the root, and a commit . Every other entry still points at objects that already existed. That is why a full snapshot per commit is affordable.
The binary entry format
No manual page describes how a tree entry is laid out in bytes; the format comes from Git's source. Each entry is the mode in octal with no leading zero, a space, the name, a NUL byte, then the raw binary hash (20 bytes for SHA-1). The tree body is all entries joined with no separator . Raw hashes are 20 bytes for SHA-1 and 32 bytes for SHA-256 . Like every object, the stored form is prefixed with "tree", a space, the size and a NUL byte before hashing ; the empty tree is stored as tree 0\0 .
A raw tree stores a directory mode as 40000. The 040000 you see in git ls-tree output is display padding, and git fsck warns about modes stored with a leading zero .
Sort order
Entries are compared as raw bytes, but a directory's name is compared as if it ended with "/", so a file a.c sorts before a directory a . This is not cosmetic: git fsck reports an error for a tree whose entries are out of order or duplicated . The index uses a different rule, plain byte order of full paths with no special handling for "/" , which is one reason trees cannot simply be copied out of the index.
Path names are stored as plain bytes. By convention they are UTF-8 in normalization form C, but core Git does not convert encodings, except on Mac and Windows .
Where trees come from
Git normally builds trees from the index, writing one tree object per directory . The plumbing equivalent is git write-tree, which turns the current index into trees and prints the root tree's name; it requires a fully merged index . git read-tree goes the other way, loading a tree into the index without touching working files .
Empty directories
Git does not track empty directories: a directory exists in a snapshot only because files inside it are tracked, and the usual workaround is to put a file such as an empty .gitignore in it . This follows from the format, since there is no entry type for "directory with nothing in it" other than a tree, and trees are built from file paths in the index .
The only source found that states this directly is the archived Git SCM Wiki FAQ, which is marked as obsolete content. The current gitfaq does not cover it .
Investigate it
git ls-tree prints one line per entry as mode, type, object name, a TAB and the path; -r recurses into subtrees and -l adds blob sizes .
``bash git ls-tree HEAD # root tree of the current commit git ls-tree HEAD src # the entry for the src directory git ls-tree -r -l HEAD # every file, with sizes git rev-parse HEAD^{tree} # the root tree's object name git cat-file -p HEAD^{tree} # pretty-printed tree git write-tree # tree for what is staged now `
For hello-git, git ls-tree HEAD gives output like this (illustrative, IDs shortened):
`text 100644 blob 3b18e51... README.md 100644 blob a1f2c09... package.json 040000 tree 9d7e4a2... src ``
8. Commit objects and parent relationships
A tree is a snapshot of a directory, but on its own it says nothing about who made it, when, or why. A commit object adds that. Every commit points to exactly one tree, the snapshot of the whole project at that moment . A commit stores that snapshot, not a diff: changes are worked out by comparing its tree with its parents' trees, and renames are not recorded .
What is inside
A commit is a short text object. In order it holds the top-level tree, the parent commits (if any), the author and committer lines with name, email and timestamp, a blank line, and the message . The stored header lines look like this :
``text tree <hex> parent <hex> author Ada Lovelace <[email protected]> <unix-seconds> <+hhmm> committer Ada Lovelace <[email protected]> <unix-seconds> <+hhmm>
Punctuate the greeting `
Author and committer
The author is the person who wrote the change; the committer is the person who last applied it, for example when applying someone else's patch, and each has its own timestamp . If Grace Hopper applies a patch that Ada wrote, Ada stays the author and Grace becomes the committer. Dates are stored as seconds since the epoch followed by a signed time-zone offset such as +0100 .
Parents
The parent lines are what turn separate snapshots into a history. A root (initial) commit has no parents, an ordinary commit has one, and a merge commit has two or more . A repository can have more than one root commit, though that is unusual .
Try it
The commit inspector below shows each commit's tree, parent or parents, author and committer with their time zones, and message. Open "Initial commit" to see a root commit with no parent line, then a merge commit with two. Compare two commits to see that each points to a complete tree. The IDs are real SHA-1 names, but they depend on the simulated author, time and message, so they will not match commits in your own repositories.
A realistic scenario
Ada commits "Punctuate the greeting" on her laptop in London, then the same change is committed again on another machine with the same tree and message a minute later. The two commits have different IDs. The hash covers the author, committer and timestamps, so the same tree and message give a different commit ID at a different time or with a different identity . That is also why commits can never be edited: git commit --amend` makes a new commit with the same parent instead of changing the old one .
Optional headers
Two more headers can appear. encoding records i18n.commitEncoding; when it is absent the message is taken to be UTF-8 . A signed commit (git commit -S) carries its signature in a gpgsig header after committer and before the blank line . A header that spans several lines continues on lines that each start with one space, even if the original line was empty .
Where identity and time come from
Author and committer details come from GIT_AUTHOR_NAME, GIT_AUTHOR_EMAIL, GIT_AUTHOR_DATE and the matching GIT_COMMITTER_ variables when they are set; the characters "<", ">" and newline are removed . Without them Git uses user.name and user.email from config, then $EMAIL, then the system user name and host name .
GIT__DATE accepts RFC 2822 and ISO 8601 dates, and ignores fractions of a second . A raw timestamp should be written with a leading "@" such as @0 +0000; this is required for values below 100,000,000, which could otherwise be read as YYYYMMDD . Fixing both dates and both identities makes a commit ID reproducible .
Why the ID depends on everything
A commit's ID is a hash of its full text, which includes the tree ID and every parent ID . So a commit's name stands for its whole snapshot and its whole ancestry: if any earlier commit were different, its ID would differ, the parent line here would differ, and so would this ID. With a secure hash, trusting one commit name lets you trust every object it references .
Misconceptions
- "A commit is a diff." It is a pointer to a full tree plus metadata .
- "Amend edits the last commit." It creates a new commit; the old one is unchanged .
- "The author pushed it." Author and committer say nothing about pushing; they record who wrote and who applied the change .
Failure behaviour
git fsck checks commit headers: a commit with no author, or a bad time zone in an author or committer line, is reported as an error .
Investigate it
git commit-tree is the plumbing behind git commit: it writes a commit for a given tree and prints its ID, each -p adds a parent, and it does not move any branch .
``bash git cat-file -p HEAD # raw commit headers and message git cat-file -p HEAD^ # its parent git log --format='%H %P %an | %cn' -5 # IDs, parents, author and committer git rev-list --max-parents=0 HEAD # root commit(s) git rev-list --min-parents=2 HEAD # merge commits # a reproducible commit from the current tree GIT_AUTHOR_DATE='@1700000000 +0100' GIT_COMMITTER_DATE='@1700000000 +0100' \ git commit-tree HEAD^{tree} -p HEAD -m 'Test' ``
Running the last command again without fixed dates gives a different ID each time, because the timestamps are part of the hash .
10. How git add and git commit work
Most people learn git add and git commit as rituals. Underneath, they are two separate steps that write different things. git add is best thought of as "add precisely this content to the next commit", not "add this file to the project" .
What git add does
When you stage a file, Git hashes its current contents, stores them as a blob object straight away, and records the blob's ID in the index against the file's path. Trees and the commit are not written yet . The index is a binary file, normally .git/index, holding a sorted list of path names, each with its permissions and a blob hash . It is a flat list of full paths, not a folder of files .
So staging is a snapshot of content at that moment. If you edit the file again afterwards, the commit would get the older staged version unless you run git add again . Until then the file shows as both staged and modified: the index has the version from your last add, the working tree has newer edits . The first blob is left unreferenced, a "dangling blob" . git add -p lets you pick individual hunks, so you can stage only part of a file .
What git commit does
git commit creates a new commit from the current index and the message. The new commit is a direct child of HEAD, and the current branch is moved to point at it . It only records what is in the index, so modified files must be staged first . In plumbing terms the pair is: store blobs, update the index, write trees, and write a commit that points at the top-level tree and the parent .
Step — Working tree — Index — Objects — Refs; Edit src/app.js — Changed — Unchanged — Unchanged — Unchanged; git add src/app.js — Unchanged — Entry now names the new blob — New blob — Unchanged; git commit — Unchanged — Unchanged — New trees and a commit — Current branch moves
Try it
The lab runs the whole flow with an editor, the index, the object store, the commit graph and a terminal. Edit src/app.js, run git add, then git commit, and step through the playback: you will see the blob written at add time, the index entry change, then trees and a commit written and refs/heads/main move. Try editing again after adding to see a file that is staged and unstaged at once, and unstage with git restore --staged. The lab stages whole files; it does not simulate git add -p.
A realistic scenario
Ada fixes the greeting in src/utils.js and, while there, adds a debug line to src/app.js. She runs git add src/utils.js only and commits. The commit contains her fix, and the debug line stays as an uncommitted change in the working tree, because the commit was built from the index, not from the folder.
The index in more detail
Each index entry has a file type (regular, executable, symlink or gitlink, never a directory), a blob ID, a stage number from 0 to 3 and a path; the stage is normally 0 and higher only during merge conflicts . The file starts with the signature "DIRC" and a version number (2, 3 or 4) . It also caches stat data such as modification times, which lets git status spot changed files without re-reading every file; that data never goes into trees . Because entries are sorted by full path in plain byte order , committing has to regroup them into one tree per directory .
Losing the index loses no committed data: it can be rebuilt from HEAD .
Variations of add
Command — Effect; git add -u — Updates only already-tracked paths; adds no new files; git add -A — Adds, modifies and removes entries to match the working tree; git add -N — Records only that a path will be added later (an entry with no content); git add --force — Also adds ignored files, which are otherwise skipped
Variations of commit
git commit -a stages changes to all tracked files, modified and deleted, before committing; new untracked files are not included . git commit <paths> ignores what is staged and commits the current contents of the listed tracked files . On a detached HEAD, git commit still works, but no branch moves .
Unstaging
git restore --staged <path> resets the index entry from HEAD and leaves the working tree alone . git reset <pathspec> does the same and is described as the opposite of git add . Older git status output suggested git reset HEAD <file>; newer versions suggest git restore --staged <file> .
Misconceptions
- "git add moves the file somewhere." The file stays where it is. Git writes a blob of its current bytes and points an index entry at it .
- "git commit takes what is on disk." It takes what is in the index , unless you use -a or name paths .
- "Committed means backed up." It means stored in your local database, not pushed anywhere .
Investigate it
``bash echo "// note" >> src/app.js git hash-object src/app.js # the ID the blob will have git add src/app.js git ls-files --stage src/app.js # mode, blob ID, stage number, path git cat-file -t :src/app.js # blob: written already, before any commit git status -s # M in the left column: staged echo "// more" >> src/app.js git status -s # MM: staged and unstaged git diff --cached # index vs HEAD git restore --staged src/app.js # unstage, keep the edits git add src/app.js && git commit -m "Add a note" git cat-file -p HEAD # new tree, parent = previous HEAD git fsck # may report a dangling blob from the first add ``
In short status the left column is the index and the right the working tree, so "MM" means staged and unstaged changes .
11. The Git commit DAG
Each commit records its parents. Follow those links from every branch tip and you get a graph: Git's history is a directed acyclic graph (DAG). It is directed because each commit points to its parents, and acyclic because no chain of commits leads back to where it started . Git calls it the commit graph; branch tips reference it, and it is the definitive history .
Arrows point backward
A commit's parents are its logical predecessors. The list can be empty (a root commit), have one entry, or have several (a merge) . A child knows its parents, but a parent does not know its children, so the arrows always point back in time.
The graph is acyclic by construction. A commit's ID is a hash over its content, including its parent IDs , so a parent has to exist before any child can name it. A commit cannot name a descendant without already knowing that descendant's ID, which depends on its own.
Reachability
Every ancestor of a commit is reachable from it. More generally, reachability follows tags to their targets, commits to their parents and trees, and trees to their subtrees and blobs . An object no branch, tag or other reference can reach is unreachable , and Git may eventually delete it, whereas reachable objects are never deleted .
git log is a set operation over this graph: it lists the commits reachable from the commits you give it, minus those reachable from commits prefixed with ^ . With no range it starts from HEAD and shows the whole history leading to the current commit, newest first by default .
Try it
The lab shows hello-git with a merge. Select a commit to highlight its parents, and run git rev-parse HEAD^1, HEAD^2 and HEAD~2 to see which commits they name. Arrows point from child to parent, and columns are generations, not time.
A realistic scenario
feature/login has been merged into main. Grace wants to know what the merge brought in. HEAD^1 is main before the merge, HEAD^2 is the tip of feature/login , and git log HEAD^1..HEAD^2 lists "Add login" and "Require a password": the commits reachable from the second parent but not from the first .
Ordering
The default git log order is reverse chronological , but timestamps alone do not respect the graph. Two options do:
Option — Rule; --date-order — Never shows a parent before all its children; otherwise orders by commit timestamp; --topo-order — Never shows a parent before its children, and keeps each line of history together, so older commits can appear before newer ones
The manual's worked example, a two-track history, prints 8 7 6 5 4 3 2 1 under --date-order and 8 6 5 3 7 4 2 1 (or 8 7 4 2 6 5 3 1) under --topo-order . --graph draws the history as ASCII art and implies --topo-order unless --date-order is given . --first-parent follows only the first parent of each merge .
Revision syntax
Syntax — Means; rev^, rev^n — First parent, nth parent; rev^0 is the commit itself; rev~n — n generations back along first parents; HEAD~3 = HEAD^^^; A..B — ^A B: reachable from B, not from A; A...B — Reachable from either but not both; rev^@, rev^! — All parents of rev; rev alone with its parents excluded; @ — HEAD
Either end of .. or ... may be omitted and defaults to HEAD, so origin.. means "what did I do since I forked?" .
HEAD~2 and HEAD^2 are different commits. The first is the grandparent along first parents; the second is the second parent of a merge .
Misconceptions
- "History is a line." Merges give a commit several parents, so history is a graph .
- "Commits are listed in the order they happened." Default order uses commit timestamps , which come from the committing machine's clock and can be set by environment variables .
- "The commit-graph file is the history." It is only another representation of the DAG .
Investigate it
``bash git log --oneline --graph --all git log --oneline --date-order git log --oneline --first-parent main git rev-parse HEAD^1 HEAD^2 HEAD~2 git log --oneline main..feature/login # on feature/login, not on main git log --oneline main...feature/login # on one side only git merge-base main feature/login git rev-list --count HEAD # commits reachable from HEAD git branch --merged main # tips reachable from main `
git branch --merged` is itself a reachability query on the DAG .
12. Branches and references
In many older systems a branch is a copy of the project. In Git it is not a copy of files at all: a branch is a lightweight, movable pointer to a commit . Every commit already holds a full snapshot through its tree, so a branch only needs to say which commit is the tip of a line of work .
What a branch is on disk
Branch heads live under $GIT_DIR/refs/heads/, unless they have been packed into packed-refs . Pro Git puts a loose branch ref at 41 bytes: 40 hex characters plus a newline. That figure assumes SHA-1 and a loose ref . Writing a commit hash into .git/refs/heads/<name>, or more safely running git update-ref, is all it takes to make a branch, and git branch does essentially that .
That is why branches are cheap: creating one writes a tiny ref, whatever the size of the project . "main" (or "master") is not special either; it is an ordinary branch that git init happens to create . Without -b, Git's built-in default name for it is still master, and it will become main when Git 3.0 is released ; init.defaultBranch overrides it .
How a branch moves
On every commit, the checked-out branch pointer moves forward to the new commit . HEAD normally names that branch, so the new commit's parent is whatever HEAD resolves to, and HEAD follows the branch indirectly .
Creating, deleting and renaming
- git branch <name> [<start-point>] creates a branch at HEAD, or at the start point . It does not switch to it; use git switch for that .
- git branch -d is the safe delete: it refuses unless the branch is fully merged into its upstream, or into HEAD if none is set .
- git branch -D deletes whatever the merge status, and deletes the branch's reflog too .
- git branch -m renames a branch and its reflog; -M is needed if the new name exists .
Try it
In the lab, run git branch feature/login and watch the refs panel: one new ref appears, pointing at the same commit as main, and no objects are written . Then git switch feature/login and commit. Only refs/heads/feature/login moves; main stays where it was.
A realistic scenario
Ada starts the login work with git switch -c feature/login, commits "Add login" and "Require a password", and switches back to main to fix a typo. Her main commit and her login commits share every unchanged blob and tree; the two branches are just two pointers into the same graph. When the work is merged, git branch -d feature/login succeeds because the branch is fully merged .
The glossary view
The glossary defines a branch as a line of development. Its newest commit is the tip, referenced by a branch head that moves forward as work is added . A branch is a ref, a small pointer to the commit at the tip , and git branch <name> basically runs update-ref to point a new ref at the commit HEAD resolves to .
Safety rules
- git branch will not move an existing branch to a new start point unless you pass -f .
- -D deletes even a branch that no longer points to a valid commit, and its reflog goes with it .
- -d checks reachability, not names. On Git 2.52 it reports "not fully merged" and suggests -D .
Deleting an unmerged branch removes the only name for its commits. Once nothing refers to them, garbage collection will eventually delete them . Because -D also deletes the branch's own reflog , the HEAD reflog is the usual place to find the tip again .
Listing and inspecting
With no arguments, or with --list, git branch lists branches and marks the current one with an asterisk; filtering by pattern needs --list, otherwise the pattern is taken as a new branch name . -v adds each branch's hash, subject and ahead/behind relation to its upstream, and -vv adds the upstream name and any linked worktree path . --show-current prints the current branch and prints nothing on a detached HEAD . --merged lists branches whose tips are reachable from a commit, HEAD by default .
A branch started from a remote-tracking branch gets branch.<name>.remote and branch.<name>.merge config, so git pull knows what to merge . The 2.56 manual also adds git branch --delete-merged <pattern>, which deletes local branches whose tips are already reachable from their configured upstream; Git 2.52 does not have it .
Misconceptions
- "Switching branches copies files." A branch is a pointer ; git switch rewrites the working tree and index to match the target commit .
- "main is the real branch." It is an ordinary branch with a conventional name .
Investigate it
``bash git branch feature/login # create, stay where you are git rev-parse main feature/login # same commit git show-ref --heads # refs, read through Git git switch feature/login git commit --allow-empty -m "Try it" git rev-parse main feature/login # only feature/login moved git symbolic-ref HEAD # refs/heads/feature/login git branch -vv git branch --show-current git switch main git branch -m feature/login feature/auth git branch -d feature/auth # refuses if not fully merged git reflog # find a tip after a mistaken -D `
With the default files backend a loose ref can be read directly, but packed refs and reftable repositories do not keep one file per branch , so git show-ref and git rev-parse` are the reliable way to look .
13. HEAD and detached HEAD
Every Git command that cares about "where you are" asks HEAD. Normally HEAD names one of your branch heads; in detached HEAD state it points directly at a commit instead . The files you see in the working tree are normally derived from the tree of the commit HEAD refers to . If you come from Subversion or CVS, note the difference: there HEAD means the latest revision, while in Git it is a pointer to the local branch you are on .
A pointer to a pointer
HEAD is a symbolic ref (symref): instead of a hash it holds text like ref: refs/heads/main, and Git follows it, recursively, to find a commit . With the default files ref backend, .git/HEAD is a small text file containing exactly that line .
That indirection is what makes branches advance. When you commit, the new commit's parent is whatever HEAD resolves to; Git then moves the branch HEAD names to the new commit, and HEAD follows indirectly because it still names the branch .
Detaching
Checking out a tag, or a commit such as main^^, detaches HEAD so that it refers to the commit rather than to a branch . You can still commit there; each commit moves HEAD forward but moves no branch . The catch comes when you leave. Once you switch away, nothing refers to those commits any more, and garbage collection will eventually delete them unless you create a ref first .
In the lab below, run git checkout HEAD~1 and watch the HEAD marker leave main and point at a commit. Make a commit while detached, then switch back to main: the new commit drops out of the branch graph and is left reachable only through the reflog. Try git symbolic-ref HEAD in both states to see the difference. As with every lab here, it is a simplified simulation of the repository.
A realistic scenario
Ada Lovelace wants to check how hello-git behaved before "Document how to run", so she checks out the "Punctuate the greeting" commit. While there, she spots a bug in greet in src/utils.js, fixes it and commits. Her commit exists, but no branch knows about it. If she now runs git switch main, the fix is out of sight.
The safe move is to name the work before leaving: git switch -c fix/greeting creates a branch at the detached commit and reattaches HEAD to it . If she has already switched away, the commit's ID is still in the reflog (git reflog -2 HEAD or git log -g -2 HEAD), and she can create a branch at it .
The labs use main because the simulated user set init.defaultBranch . Without that setting or -b, Git's initial branch name is still master; it is due to become main with Git 3.0 .
What the HEAD file holds
Every valid repository must have a HEAD file, and HEAD may name a branch that does not exist yet. That is the "unborn branch" right after git init, before the first commit . When HEAD stores a commit hash directly instead of a symref, the repository is in detached HEAD state .
Historically .git/HEAD was a filesystem symlink to refs/heads/master. Symlinks were not portable, so the text symref replaced them; this describes earlier versions of Git .
Reading and setting HEAD
git symbolic-ref HEAD prints the branch HEAD points to (--short drops the refs/heads/ prefix), and git symbolic-ref HEAD refs/heads/x repoints it . The documented exit codes are 0 when a name is printed, 1 when the name is not a symbolic ref and 128 on other errors. In a test with Git 2.52, a detached HEAD gave 128 with a fatal message and gave 1 only with -q, so scripts should not rely on the code alone . For a script that only needs the branch name, git branch --show-current prints it, and prints nothing on a detached HEAD .
git switch only accepts branches, so detaching with it needs --detach; git checkout <commit> detaches implicitly .
Keeping detached work
Command — Creates — HEAD afterwards; git switch -c foo or git checkout -b foo — a branch — attached to foo; git branch foo — a branch — still detached; git tag foo — a tag — still detached
All three are documented ways to keep commits made on a detached HEAD . If you leave without one, the reflog still remembers. Reflog entries that are no longer reachable from the current tip expire after 30 days by default , and unreachable objects are pruned only after a grace period, 2 weeks by default . Both are configurable, so treat the window as a safety net rather than a plan.
The other *_HEAD refs
Git writes several related refs while it works:
- ORIG_HEAD: written by git am, git merge, git rebase and git reset, which move HEAD drastically; it records the old position so the move can be undone .
- FETCH_HEAD: what the last git fetch fetched .
- MERGE_HEAD: the commit or commits being merged in; REBASE_HEAD, CHERRY_PICK_HEAD and REVERT_HEAD play the same role for those commands .
- AUTO_MERGE: the tree the ort strategy wrote to the working tree when a merge hit conflicts .
Misconceptions
Detached HEAD is not an error. It is a normal state for inspecting old commits and is what you get when you check out a tag . The risk is only to commits you make there and then abandon .
Investigate it
``bash cat .git/HEAD # files backend: "ref: refs/heads/main" git symbolic-ref --short HEAD # main git branch --show-current # main git switch --detach HEAD~1 # detach on purpose git rev-parse HEAD # the commit HEAD now holds directly git branch --show-current # prints nothing while detached git switch -c keep-this # name the spot and reattach git reflog -2 HEAD # recent HEAD moves `
cat .git/HEAD relies on the files ref backend, which is the default ; on a repository using another backend, use git symbolic-ref and git rev-parse` instead of reading files.
14. Branch creation and switching
A branch is a pointer, so creating one is cheap: git branch feature/login makes a new pointer at the commit you are on and does not switch to it . Switching is the operation that touches your files. git switch moves you to a branch and updates both the index and the working tree to match it; new commits then go on top of that branch .
switch, checkout and restore
git switch arrived in Git 2.23 as a branch-only alternative to git checkout, with forms such as git switch <b>, git switch -c <new> and git switch - . git checkout still works, but it has two jobs: switching branches and restoring file versions from the index or a commit. When an argument is ambiguous it is treated as a branch or commit unless -- comes before it . The file-restoring half now has its own command, git restore .
Task — Command; Create a branch and switch to it — git switch -c <new> , or the older git checkout -b <new>; Go back to the previous branch — git switch -, the same as @{-1}; Rename a branch (and its reflog) — git branch -m <old> <new>; Delete a branch that is fully merged — git branch -d <name>; Delete a branch whatever its merge status — git branch -D <name>
Switching with uncommitted work
A switch does not need a clean tree. It aborts only if it would lose local changes, unless you pass --discard-changes or --merge . git checkout <branch> behaves the same way: it fails if an uncommitted file differs between the two commits, and otherwise carries your uncommitted changes across . Pro Git puts it from the other side: switching rewrites the working directory to the target branch's last commit, and if Git cannot do that cleanly it refuses .
The usual way out is git stash, which saves local changes and resets the working directory to HEAD . Afterwards, git stash pop applies the saved changes and removes the entry, but keeps it if applying conflicts .
In the lab below, create, rename and delete branches, then edit src/app.js on main and try to switch to feature/login. The simulator refuses because the switch would overwrite your edit. Stash, switch, come back and pop to see the change return.
A realistic scenario
Ada is halfway through an edit to src/app.js on main when Grace Hopper asks her to look at feature/login, which changes the same file. git switch feature/login stops and names the file. Ada runs git stash, switches, reviews Grace's work, runs git switch - to return to main and git stash pop to get her edit back . Had the two branches had the same src/app.js, the switch would simply have carried her edit across .
What a switch changes
A switch moves HEAD to the new branch and rewrites the index and working tree to match that branch's commit . Files that are the same in both commits are left alone, which is why local edits to them survive the trip . git switch -c creates the branch only if the switch succeeds, so a refused switch leaves no half-made branch behind; -C resets an existing branch to the start point .
Two conveniences are worth knowing. git switch refuses a bare commit unless you add --detach (Git 2.52 says "fatal: a branch is expected, got commit"), whereas git checkout <commit> detaches silently . And if no local branch has the name but exactly one remote has a matching tracking branch, git switch <name> creates a local tracking branch; this --guess behaviour is on by default .
When a switch is refused
Choice — What happens to your edit; Commit it first — Kept as a commit on the current branch; git stash, switch, git stash pop — Saved as a stash entry and reapplied later; git switch --merge <b> — Conflicting local changes are stashed automatically and reapplied after the switch; if that conflicts, the entry stays in the stash list; git switch --discard-changes <b> (-f) — Thrown away: index and working tree are reset to the target
--discard-changes destroys uncommitted work. As with git reset --hard, overwritten uncommitted changes cannot be recovered ; reflogs record only updates to refs .
Current docs and older docs
From 2.23 through at least 2.50, the git-switch page warned that the command was experimental and that its behaviour might change ; git-restore carried the same warning . Both describe earlier documentation.
The current git-switch and git-restore pages (2.56), and the 2.51.0 page, have no experimental warning. The manual gives no reason or release note for removing it .
The meaning of --merge also changed. Older docs described it as a three-way merge of the current branch, your working tree and the new branch, leaving conflicting paths unmerged in the index . The current docs describe the stash-and-reapply behaviour in the table above .
How a stash is built
A stash is made of ordinary commits. The stash commit W has the working directory as its tree; its first parent is HEAD and its second parent, I, records the index, with I itself a child of HEAD . The newest stash is refs/stash, and older ones live in that ref's reflog as stash@{1}, stash@{2} and so on . Untracked files are stashed only with -u/--include-untracked .
Investigate it
``bash git branch feature/login # pointer only; you stay put git switch feature/login git switch - # back to the previous branch git stash # save and clean local edits git stash list # stash@{0}: ... git cat-file -p stash@{0} # tree plus two parent lines (W) git rev-parse stash@{0}^2 # the index commit (I) git stash pop git branch -m feature/login feature/auth git branch -d feature/auth # refuses if not fully merged ``
In real Git the refusal is decided per path: a switch fails only for an uncommitted file that differs between the two commits . Check your own repository's behaviour rather than relying on the simulator for edge cases.
15. Git diff and snapshot comparison
Git records history as a series of snapshots of the whole project, not as per-file changes the way CVS or Subversion do . An unchanged file is not stored again; the new snapshot links to the identical blob already stored . So when you ask "what changed?", Git answers by comparing two snapshots. A diff always compares two endpoints .
Which two endpoints?
The commands differ only in which two states they compare:
Command — Compares — Shows; git diff — working tree vs index — changes you have not staged yet; git diff --cached (or --staged) — index vs HEAD (or a commit you name) — what the next commit would record; git diff <commit> — working tree vs that commit — all local changes, staged or not; git diff A B — commit A vs commit B — the difference between two snapshots
A common surprise follows from the first row. After git add, plain git diff goes quiet, because the working tree now matches the index; the change has not vanished, it has moved to the --cached comparison. If you edit the file again, it is both staged and unstaged at once .
In the lab below, edit src/utils.js, then compare git diff, git diff --cached and git diff main feature/login. Each hunk is shown with its @@ header so you can match line numbers to the file.
Reading a patch
Each file starts with a diff --git a/<file> b/<file> line , followed by extended headers such as index <hash>..<hash> <mode>, which name the blobs before and after . Each hunk starts with @@ -start,count +start,count @@ . Inside it, - marks a line removed, + a line added and a leading space an unchanged context line . By default there are 3 lines of context around each change .
Ada stages a change to greet and runs git diff --cached. Output like this (illustrative):
``text diff --git a/src/utils.js b/src/utils.js index 3b18e51..a9c2f04 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,3 +1,3 @@ function greet(name) { - return 'Hello, ' + name; + return 'Hello, ' + name + '!'; } `
The two hashes on the index line are the blob IDs of the old and new content , the same blob IDs you can inspect with git cat-file`. Nothing in the repository stores this patch; it is produced for display from the two blobs.
Two dots and three dots mean something else here
In git log, A..B and A...B are ranges: A..B is the commits reachable from B but not from A , and A...B is the symmetric difference . git diff borrows the spellings but not the meanings. git diff A..B is the same as git diff A B , and git diff A...B diffs B against the merge base of A and B, which shows what B's side changed since the two forked . A merge base is a best common ancestor, and a pair of commits can have more than one .
For hello-git, git diff main...feature/login shows only "Add login" and "Require a password", while git diff main feature/login also shows "Document how to run" reversed, because main has it and feature/login does not.
More about hunks
- A hunk covering one line shows only the start number; Git 2.52 prints @@ -1 +1 @@ in that case .
- Git adds the enclosing function name after the second @@, and gitattributes can customise how it is found .
- Rename and copy detection reports a similarity index: the percentage of unchanged lines, rounded down .
- Combined diffs of merges use one @ per parent plus one, so @@@ for a two-parent merge. They are for review, not for applying with patch .
Diff algorithms
The default algorithm is myers, a basic greedy algorithm; the others are minimal, patience and histogram, selected with --diff-algorithm or diff.algorithm . Minimal spends extra time to find the smallest possible diff, and histogram extends patience to support low-occurrence common elements . The indent heuristic, which shifts hunk boundaries to make patches easier to read, is on by default . Merges are a separate case: ort's internal diffs use histogram by default, whatever diff.algorithm says .
Computed, not stored
A commit stores a tree, not a patch . Packfiles do contain deltas, but only as storage compression; Git usually keeps the newest version whole and the older one as a delta, which is the opposite of a per-commit diff log .
No manual page we read says outright that git diff computes its output on demand and stores nothing per commit. That conclusion comes from how Git stores snapshots and from the git-diff synopsis, which compares trees, the index and the working tree .
Investigate it
``bash git diff # working tree vs index git diff --cached # index vs HEAD git diff HEAD # working tree vs HEAD git diff main feature/login # snapshot vs snapshot git diff main...feature/login # feature/login since the merge base git diff -U1 --diff-algorithm=histogram main feature/login git ls-files --stage src/utils.js # the blob ID in the index git rev-parse HEAD:src/utils.js # the blob ID in HEAD git cat-file -p HEAD:src/utils.js # its content `
The lab computes its diffs in the simulator rather than with Git's own code, so on unusual edits its hunks may be placed differently from your git diff`.
16. Fast-forward and three-way merges
git merge brings the changes from the named commits into the current branch, counting from the point where the histories diverged; git pull uses it too . That point is the merge base: the best common ancestor of the two commits . What happens next depends on whether the histories have actually diverged.
Fast-forward
If the commit being merged can be reached by following the current commit's history, there is no divergent work to combine, so Git just moves the branch pointer forward . No merge commit is needed; HEAD and the index move to the named commit . Git does this whenever it can, because --ff is the default .
Three-way merge
When the histories have diverged, Git does a three-way merge using the two branch-tip snapshots and their common ancestor . The result is recorded as a new commit that names both parent commits . That merge commit is special only because it has more than one parent . HEAD, the index and the working tree are all updated to the merged result .
Before any of this starts, Git saves the current branch tip in ORIG_HEAD so the merge can be undone . And if every named commit is already an ancestor of HEAD, there is nothing to do; Git prints "Already up to date." .
In the lab below, merge feature/login into main. The simulator highlights the merge base, shows the three snapshots it compares and draws the new commit with two parent arrows. Then try the fast-forward case, where the branch label simply slides forward and no new commit appears.
A realistic scenario
In hello-git, feature/login started at "Punctuate the greeting" and gained "Add login" and "Require a password". Meanwhile Ada committed "Document how to run" on main. The two tips have diverged, and their merge base is "Punctuate the greeting". Ada runs:
``bash git switch main git merge feature/login `
Git combines what each side changed since the merge base and records a merge commit whose first parent is the old main tip and whose second is the tip of feature/login, so HEAD^1 and HEAD^2 name them . If Ada had not committed "Document how to run", main would have been an ancestor of feature/login and the same command would have fast-forwarded main` without creating a commit .
Controlling fast-forwards
Option — Behaviour; --ff (default) — Fast-forward when possible, otherwise create a merge commit; --no-ff — Always create a merge commit; --ff-only — Fast-forward or refuse with a non-zero exit status; --squash — Build the merged index and working tree but do not commit, move HEAD or write MERGE_HEAD; the next commit has a single parent
The --ff default has one exception: merging an annotated tag that is not stored in refs/tags/ assumes --no-ff . Because a fast-forward makes no commit, --no-commit cannot stop one; combine --no-ff with --no-commit if you need the branch not to move . To test in a script whether a fast-forward is possible, git merge-base --is-ancestor A B exits 0 when A is an ancestor of B and 1 when it is not .
Pre-merge safety checks
Merge stops without doing anything if uncommitted changes overlap files it may need to update . It also aborts if the index has any changes relative to HEAD, so unrelated staged work cannot slip into the merge commit; some strategies have narrow exceptions . The docs discourage merging with non-trivial uncommitted changes at all, because git merge --abort may not be able to rebuild them after a conflict .
Strategies
When no -s is given, merge uses ort for a single head and octopus for more than one . Ort resolves two heads with a three-way merge; when there are several common ancestors it first merges them into one tree and uses that as the base . It handles renames but not copies, and its name stands for "Ostensibly Recursive's Twin" . Naming several commits creates an octopus merge with more than two parents ; the octopus strategy refuses any complex merge that would need manual resolution .
The recursive strategy was the default for two heads from v0.99.9k until v2.33.0, so ort has been the default since v2.34; since v2.50.0 "recursive" is just another name for ort . Older tutorials that mention recursive describe earlier versions.
Two similar names do very different things. -s ours keeps the current branch's tree exactly and ignores every change from the other side; -Xours is an option to ort that only favours our side in conflicting hunks and still keeps the other side's non-conflicting changes .
Subtleties
- With criss-cross merges, two commits can have more than one best common ancestor, and without --all the docs do not specify which one git merge-base prints .
- Merge refuses to join histories with no common ancestor unless you pass --allow-unrelated-histories .
- A three-way merge looks only at the two heads and the base, not the commits between. A change made on both branches and later reverted on one still appears in the result .
Investigate it
``bash git merge-base main feature/login # the base commit git merge-base --is-ancestor main feature/login && echo "fast-forward possible" git switch main git merge --no-ff feature/login git cat-file -p HEAD # two "parent" lines git rev-parse HEAD^1 HEAD^2 # first and second parent git log --graph --oneline -5 git reset --hard ORIG_HEAD # undo the merge `
git reset --hard ORIG_HEAD undoes a merge, even a fast-forward, but discards local changes; git reset --merge ORIG_HEAD` keeps them .
17. Merge conflicts and resolution
A three-way merge copies non-overlapping changes, where only one side touched an area, into the result verbatim. When both sides changed the same area, Git cannot know which version you want, so it leaves both for you to resolve . That is a merge conflict. It is not damage; it is Git declining to guess.
What Git leaves behind
When a merge cannot be resolved cleanly, HEAD does not move and MERGE_HEAD is set to the other branch head. Paths that merged cleanly are already updated in both the index and the working tree . Git does not create the merge commit; it pauses, and git status lists the conflicted files as unmerged ("both modified") .
In each conflicted file, the disputed hunk is wrapped in markers: <<<<<<<, ======= and >>>>>>>. The part before ======= is usually your side and the part after it usually theirs . In Pro Git's terms, the top half is the HEAD version, the branch you had checked out, and the bottom half is the branch being merged in .
The hello-git conflict
src/theme.js started as 'light' at the merge base. On main it became 'system'; on dark-mode it became 'dark'. Merging dark-mode into main leaves something like this (illustrative):
``text <<<<<<< HEAD export const theme = 'system'; ======= export const theme = 'dark'; >>>>>> dark-mode `
Note what is missing: the default style does not show what the base said , so nothing here tells you both sides started from 'light'.
In the lab below, merge dark-mode into main. The simulator shows the base, ours and theirs versions side by side as index stages 1, 2 and 3, and the working file with markers. Edit the result, then git add and git merge --continue, or start again with git merge --abort.
Resolving
- Edit each conflicted file into the version you want and delete the markers.
- git add the file to record the resolution in the index.
- Run git merge --continue or git commit . --continue first checks that a merge is actually in progress, then calls git commit .
If you would rather not merge after all, git merge --abort` tries to rebuild the pre-merge state. It may fail to rebuild uncommitted work-tree changes, which is why the docs recommend committing or stashing before merging .
Ada decides the product should follow the system setting but keep a dark option, so her resolution is neither side verbatim. That is common: the right answer is often a combination, not a choice.
Three versions in the index
The index is normally a flat list with one entry per path at stage 0, but during a conflict it can hold stages 1 to 3 for a path . Stage 1 is the common ancestor, stage 2 is HEAD (ours) and stage 3 is MERGE_HEAD (theirs); git ls-files -u lists them , and git show :1:file, :2:file and :3:file print each version . Running git add on the edited file records your resolution in the index .
The ort strategy also writes an AUTO_MERGE ref, pointing to a tree of the working-tree content it produced, conflict markers included; only ort writes it . While you resolve, plain git diff shows a three-way diff against both HEAD and MERGE_HEAD, and git diff AUTO_MERGE shows what you have changed so far .
Marker styles
merge.conflictStyle chooses the style; the default is merge . Setting it to diff3 or zdiff3 adds a seven-pipe marker followed by the base text, and seeing the original can help you find a better resolution . With diff3, the theme conflict would show 'light' between ||||||| and =======, which makes it obvious that both sides changed the same line away from the same value.
Taking one side
git checkout --ours / --theirs checks out stage 2 or stage 3 for unmerged paths ; git restore --ours / --theirs does the same for working-tree files and cannot be combined with --source . At merge time, -Xours resolves conflicting hunks in our favour but still keeps the other side's non-conflicting changes , whereas -s ours ignores the other side entirely .
Picking a side is not always right. The clean parts of the merge are already applied , so other files from dark-mode may assume 'dark' exists. Build and test the merged result before committing it.
During a rebase, ours and theirs are swapped: --ours is the branch being rebased onto and --theirs is your own work being replayed . The rebase chapter explains why.
Bailing out
With MERGE_HEAD present, git merge --abort is the same as git reset --merge, unless an autostash exists . git merge --quit forgets the merge in progress and leaves the index and working tree as they are .
Tools
git mergetool runs a merge utility on each conflicted file; without --tool it uses merge.tool, and if that is unset it picks a suitable default . git rerere records conflicted results and your resolutions and reapplies them when the same conflict appears again, which helps with long-lived topic branches . It is off unless rerere.enabled is set or an rr-cache directory already exists .
Investigate it
``bash git merge dark-mode # conflict in src/theme.js git status # both modified: src/theme.js git ls-files -u # stages 1, 2 and 3 git show :1:src/theme.js # base git show :2:src/theme.js # ours (HEAD) git show :3:src/theme.js # theirs (MERGE_HEAD) git diff AUTO_MERGE # your edits so far git add src/theme.js git merge --continue # or: git merge --abort git config merge.conflictStyle zdiff3 ``
18. Rebase internals
git rebase reapplies commits on top of another base tip . Where a merge joins two lines of history with a merge commit, a rebase makes your branch look as if it had been started from a newer point. It does this by creating new commits, not by relocating the old ones.
What happens, step by step
For git rebase <upstream>, Git:
- Lists the commits on the current branch since it branched off upstream that have no equivalent commit in upstream .
- Checks out upstream as a detached HEAD .
- Replays the listed commits one by one, in order, much like cherry-pick .
- Points the branch at the last new commit .
Each replayed commit has a new parent, and a commit's content includes its parent's ID , so it is a new commit with a new ID; the docs draw them as A', B', C' . The originals are abandoned, not edited. When the rebase starts, ORIG_HEAD points to the old branch tip .
In the lab below, rebase feature/login onto main. Watch each commit replayed with a new ID while the originals stay in the store, drawn hollow because no branch reaches them. Then inject a conflict and try --continue, --skip and --abort; the reflog panel records every step.
A realistic scenario
feature/login forked from "Punctuate the greeting"; main has since gained "Document how to run". Ada wants a straight line before opening a review:
``bash git switch feature/login git rebase main `
Afterwards, "Add login" and "Require a password" sit on top of "Document how to run" as two new commits with new IDs, and feature/login` points at the second one. The history is linear, but the final snapshot is the same one a merge would have produced .
The rule that matters
Do not rebase commits that exist outside your repository and that others may have based work on . Grace Hopper may have built on the old "Require a password"; after Ada's rebase, Grace's work hangs off commits that are no longer on the branch, and everyone downstream has to fix their history by hand . Rebasing your own unpublished work is safe; rewriting shared history is what causes trouble.
What gets replayed
Rebase skips commits whose change is already in upstream (clean cherry-picks), unless --reapply-cherry-picks is given or --keep-base is in use . Commits that become empty after rebasing are dropped by default (--empty=drop); with -i, --empty=stop is implied .
There are two backends: merge, the default, and apply, which --apply selects and which calls git am internally . They differ in detail: apply drops intentionally empty commits and does no directory rename detection, while merge keeps intentionally empty commits by default . rebase.backend can change the default . The apply backend used to be called "am" and the merge backend "interactive"; those names describe earlier versions . Pro Git's description (go to the common ancestor, save each commit's diff to temporary files, reset, apply each in turn) is the older, apply-style view .
--onto <newbase> sets where the new commits start; without it the start is <upstream>. The manual's example, git rebase --onto master next topic, rebuilds the commits of topic that are not in next on top of master . With no upstream argument, rebase uses the branch's configured upstream and assumes --fork-point; with none configured it aborts .
When a step conflicts
Rebase stops at the first problem commit and leaves conflict markers. Resolve, git add, then git rebase --continue; or use --skip or --abort . --skip drops the current patch and carries on . --abort resets HEAD to the original branch, while --quit stops without resetting HEAD, the index or the working tree . REBASE_HEAD records the commit being applied .
Ours and theirs are swapped. In a rebase, "ours" is the rebased series so far, starting with upstream, and "theirs" is your working branch . So git checkout --theirs gives you your own version .
Getting the old branch back
ORIG_HEAD can be overwritten by commands such as reset during the rebase; the old tip is always available in the branch reflog as @{1} . Abandoned commits stay reachable from the reflog until their entry expires, 30 days by default for unreachable entries, and can then be pruned after the gc grace period .
Interactive rebase
git rebase -i opens a todo list of pick <hash> <subject> lines; Git reads only the commit names, and you can reorder or delete lines . edit stops to let you amend, reword changes only the message, drop removes a commit and break pauses . squash and fixup fold a commit into the previous one ; exec runs a shell command and stops if it fails . --autosquash moves "fixup! " and "squash! " commits next to their targets . The lab does not simulate -i.
--update-refs also force-updates other branches pointing at rebased commits .
The reference docs do not state a default for rebase.updateRefs; they only say true enables --update-refs by default .
Recovering from an upstream rebase
If someone rebased the branch you built on and the patches are unchanged, a plain git rebase subsystem works, because rebase skips changes already upstream . Otherwise name the old upstream tip, for example from the reflog, and run git rebase --onto subsystem subsystem@{1} . git merge-base --fork-point uses the reflog in the same way .
Investigate it
``bash git log --oneline main..feature/login # what will be replayed git switch feature/login git rebase main git rev-parse ORIG_HEAD feature/login@{1} # the old tip, twice git reflog -6 # each replay step git rebase --continue # after git add, on conflict git reset --hard feature/login@{1} # undo the whole rebase ``
19. Cherry-pick and revert
Sometimes you need one change rather than a whole branch. A fix written on feature/login is needed on main today, or a commit already on main turns out to be wrong and has to be undone without disturbing the work built on top of it. Git has a command for each job, and both work the same way underneath: they add new commits instead of editing old ones.
Cherry-pick: apply a change again somewhere else
git cherry-pick takes one or more existing commits, applies the change each one introduces, and records a new commit for each . A commit does not store a diff; when you look at one, Git computes the difference against its parent on the fly . Cherry-pick works out that difference and applies it to your current branch.
The result is a different commit from the original. A commit's content names its tree and its parents , and an object's ID is a hash of its content , so a commit with a different parent gets a different ID. The original stays exactly where it was.
Cherry-pick needs a clean working tree, with no changes from the HEAD commit, unless you use -n . With -x, Git appends a "(cherry picked from commit …)" line to the message, but only for picks that applied without conflicts; the manual suggests it for picking between public branches, such as backporting a fix .
Revert: undo a change with a new commit
git revert reverses the changes that existing commits introduced and records new commits for the reversal; existing history is not rewritten . By default the message body starts "This reverts <full-object-name>." , and from a terminal Git opens your editor so you can explain why .
Because nothing is removed, revert is safe on a branch other people have fetched. git reset, by contrast, moves the branch tip and so changes commit history , and the reset documentation warns against throwing away commits you have already shared . The Git FAQ recommends revert for undoing a buggy change that has already reached the main branch .
Try it
In the lab below, cherry-pick "Require a password" from feature/login onto main and compare the two commits: the change is the same, the ID is not. Then revert a commit on main and watch a new commit appear on top while the original stays in the graph. The simulator is a simplified model that works on whole files.
A realistic scenario
Ada has finished "Require a password" on feature/login, but the login branch is not ready to merge. Grace needs the password check on main for a release. Ada runs git switch main and git cherry-pick -x <id>, and main gains one new commit carrying the same change, with a note pointing back to the original. A day later the check turns out to break the release build; since main is shared, Ada runs git revert on her picked commit rather than resetting, and everyone's next fetch brings in the undo as an ordinary commit.
When a pick or revert conflicts
When a pick cannot be applied cleanly, the branch stays at the last successful commit, CHERRY_PICK_HEAD points at the commit that failed to apply, and the index records up to three stages for each conflicted path, just as in a merge . Stage 1 is the common ancestor, stage 2 ours and stage 3 theirs . Cherry-pick and revert share a sequencer: --continue resumes using the state in .git/sequencer, --skip skips the current commit, --quit forgets the operation and --abort returns to where you were before the sequence started .
Options worth knowing
Option — Effect; -n / --no-commit — Applies the change to the working tree and index without committing, and does not record CHERRY_PICK_HEAD, so a later git commit records you as the author; --ff — Fast-forwards instead of creating a commit when HEAD is already the parent of the picked commit; --empty=stop (default) — Stops when a pick becomes redundant; a commit that was empty to begin with fails unless --allow-empty or --empty=keep is given; -m <parent-number> — Required to pick a merge commit: names the mainline parent, counting from 1, and replays the change relative to it
A range such as git cherry-pick ..main picks every commit that is an ancestor of main but not of HEAD, creating a new commit for each . Older versions added the -x note by default and used -r to turn it off; today -x is off by default and -r does nothing .
Reverting a merge
A merge has more than one parent, so reverting one needs -m to say which parent is the mainline . The consequence is easy to miss: reverting a merge declares that you never want its tree changes, so a later merge of the same branch brings in only commits that are not ancestors of the reverted merge . If the branch is fixed and merged again, the earlier work does not come back on its own.
Revert is for undoing committed changes with new commits. To throw away uncommitted work the manual points to git reset --hard, and to get files from another commit, git restore --source .
Misconceptions
- "Revert deletes the commit." It adds a commit; the original stays in history .
- "Cherry-pick moves a commit." It creates a new one for each pick ; the original branch is untouched.
- "Revert needs a clean tree only for picks." Revert has the same clean-tree requirement unless -n is used .
Investigate it
``bash git switch main git cherry-pick -x <commit> # new commit, note in the message git log -1 --format='%H %an%n%B' # compare with the original commit git cat-file -p HEAD # tree, parent, author, committer git revert HEAD # new commit that undoes HEAD git revert -m 1 <merge-commit> # revert a merge relative to parent 1 git cherry-pick --abort # give up a conflicted sequence ``
20. Reset, restore and reflog
"Undo" in Git means several different things. You might want to unstage a file, discard edits in your working tree, or take the last commit off a branch. Git gives these jobs to different commands, and knowing which part of the repository each one touches is the difference between a safe undo and lost work.
The three trees
Pro Git describes Git as managing three "trees": HEAD, the snapshot of the last commit and the parent of the next one; the index, the proposed snapshot for the next commit; and the working directory, your sandbox . Each undo command changes some of these and leaves the rest alone.
git reset moves the branch
git reset <commit> points the current branch at <commit> and, depending on the mode, also updates the index and the working tree; the old tip is saved in ORIG_HEAD . It moves the branch that HEAD points to, not HEAD itself .
Mode — Branch (via HEAD) — Index — Working tree; --soft — Moved — Unchanged — Unchanged; --mixed (default) — Moved — Reset to match — Unchanged; --hard — Moved — Reset to match — Overwritten to match
--hard is the only form of reset that destroys data: uncommitted work it overwrites cannot be recovered .
git restore changes files, not branches
git restore restores working-tree paths, or the index with --staged. Without --source it restores from the index, or from HEAD when --staged is given . Unlike reset, it never updates your branch . git restore --staged <path> unstages a path: it copies the HEAD version back into the index and leaves the working-tree file alone .
The reflog remembers where refs were
Reflogs record when the tips of branches and other references were updated, in the local repository only . HEAD@{2} means where HEAD was two moves ago . Git records HEAD each time it changes, which makes git reflog the quickest way to find a "lost" commit and put a branch back on it .
Try it
In the lab, run git reset --soft HEAD~1, then --mixed, then --hard, and compare what happens to the branch ref, the index and the working tree in each case. Then recover the commit with git reset --hard HEAD@{1}. The simulator is simplified and works on whole files.
A realistic scenario
Ada commits "Document how to run" and immediately notices a typo in the message and a missing file. git reset --soft HEAD~1 takes the commit off main but keeps everything staged; she adds the file and commits again. Later she runs git reset --hard HEAD~1 by mistake. Her committed work is not gone: git reflog shows the previous position, and git reset --hard HEAD@{1} puts main back.
The steps reset takes
Pro Git explains reset as up to three steps. First it moves the branch HEAD points to; --soft stops there . Second, --mixed updates the index with the snapshot HEAD now points to; third, --hard makes the working directory match the index . That is why git reset --soft HEAD~5 followed by git commit squashes the last five commits into one . Tracked files that are not in the target commit are removed by --hard, and it may overwrite untracked files .
Two less common modes protect local work. --merge resets the index and updates files that differ between <commit> and HEAD but keeps unstaged changes, aborting if a file it must change has unstaged edits; its main use is clearing unmerged entries . --keep also updates files that differ between the two commits, but aborts if any of them has local changes .
Reset with a path
git reset <pathspec> updates only the index, not HEAD or the working tree. It is the opposite of git add and the same as git restore --staged . If a tracked path is missing from the restore source, restore removes it to match .
How the reflog is stored and expires
Each reflog lives in $GIT_DIR/logs/<ref>, one line per update with old and new IDs, date and reason; core.logAllRefUpdates is on by default in non-bare repositories and off in bare ones . git reflog is an alias for git log -g --abbrev-commit --pretty=oneline . Reflogs are not pushed or fetched .
Setting — Default — Applies to; gc.reflogExpire — 90 days — Entries reachable from the current tip; gc.reflogExpireUnreachable — 30 days — Entries not reachable from the tip, such as pre-amend or pre-rebase commits; gc.pruneExpire — 2 weeks — Loose unreachable objects, pruned by git gc
So a commit dropped by reset or rebase stays reachable from the reflog until its entry expires, and only after that can its objects be pruned once past the gc grace period . --prune=now skips the grace period but risks corruption if another process is writing .
Recovery recipes
- Undo a merge or pull: merge and pull always leave the old tip in ORIG_HEAD, so git reset --hard ORIG_HEAD undoes it, even a fast-forward; use --merge to keep local changes .
- Undo a rebase: ORIG_HEAD may have been overwritten, so use the branch reflog, @{1} .
- Lost commit: find it with git reflog or git log -g and create a branch at it .
- No reflog: git fsck --full lists unreferenced objects such as "dangling commit <id>"; point a branch at one to recover it .
The reflog only saves commits. Changes that were never committed and were overwritten by reset --hard cannot be recovered this way .
Investigate it
``bash git reset --soft HEAD~1 # branch moves; index and files stay git status # changes now show as staged git reset HEAD~1 # --mixed: index reset too git restore --staged src/app.js git reflog # where HEAD has been git reflog show main # where main has been git reset --hard HEAD@{1} # go back one reflog step git fsck --full --no-reflogs # commits only a reflog still holds ``
21. Clone and repository initialization
git init starts a repository from nothing. git clone starts one from someone else's. Most developers meet Git through clone, and what it creates explains a lot of later behaviour: why you can browse history offline, why origin/main exists next to main, and why a later git fetch knows what to update.
What clone creates
git clone makes a new directory and copies the repository into it. It creates a remote-tracking branch for every branch in the source, then creates and checks out one local branch, forked from the source's currently active branch . That initial branch starts wherever the source's HEAD points .
Unlike a centralised checkout, a clone mirrors the whole repository including its history, so every clone is a full backup . It is not a perfect copy of the server, though: server-side hooks and similar things are not transferred, while all versioned data is .
The link back to the source
The connection to where you cloned from is just configuration and refs. Clone writes remote-tracking refs under refs/remotes/origin and sets remote.origin.url and remote.origin.fetch, so a plain git fetch later updates every remote-tracking branch . "origin" is only the default name, which -o/--origin or clone.defaultRemoteName can change . A remote-tracking branch is Git's local record of the last-known state of a branch in another repository .
Try it
The lab clones an origin that has two branches, main and feature/login, plus a tag. Run git clone and watch every object arrive, then look at the refs: origin/main and origin/feature/login exist, but only one local branch, main, is checked out. The simulator shows objects arriving one by one; over the smart protocol, real Git builds a packfile for the client .
Where you clone from
Git supports the ssh, git, http and https transports; ftp and ftps work for fetching but are deprecated, and the native git:// transport does no authentication . URLs can be written as ssh://[user@]host[:port]/path, git://host[:port]/path or https://host[:port]/path, or in the scp-like form [user@]host:path, which is recognised only when no slash comes before the first colon . Cloning from a local path skips the network transport: Git copies HEAD, objects and refs, and hardlinks object files when it can .
A realistic scenario
Grace joins the hello-git project. She runs git clone [email protected]:team/hello-git.git and gets a hello-git directory with main checked out. git branch lists only main, which puzzles her until git branch -r shows origin/feature/login as well. Running git switch feature/login then creates a local branch from that remote-tracking branch, because by default git switch creates a tracking branch when exactly one remote has a matching branch .
Cloning less than everything
Full history is the default, but several options cut it down. They limit different things.
Option — What it limits; --depth <n> — A shallow clone whose history is cut to n commits; implies --single-branch unless --no-single-branch is given; --single-branch — Only the history leading to one branch tip; later fetches update only that one remote-tracking branch; --filter=<spec> — Partial clone: the server sends only a subset of reachable objects . blob:none leaves out all file contents until needed; blob:limit=<size> leaves out large blobs
Partial clone filters which blobs and trees are sent, while shallow clone, single-branch and fetch refspecs limit which commits are sent . Skipped objects can be "demand fetched" later from a promisor remote , which means a partial clone must be online when it hits a missing object .
Bare and mirror clones
--bare makes the target directory itself the $GIT_DIR, with no working tree, and so no checkout . In a bare clone the remote's branches are copied straight to local branches and no remote-tracking branches are created . --mirror implies --bare, maps all refs including remote-tracking branches and notes, and sets a refspec so git remote update overwrites all of them . Bare repositories are typically what people push to and fetch from, and a minimal Git server is just SSH access plus a bare repository ; by convention their directory names end in .git .
Other options
- -b <name> checks out a named branch instead of the remote's HEAD; given a tag, it leaves HEAD detached at that commit .
- -n/--no-checkout skips the checkout .
- The hash function is taken from the remote: GIT_DEFAULT_HASH is ignored when cloning .
How clone compares with init
git init creates an empty repository: a .git directory with objects, refs/heads, refs/tags and template files, plus an initial branch with no commits yet . Without -b, that branch falls back to the default, currently master, which will become main in Git 3.0 ; init.defaultBranch overrides it . Clone does not use that default for the checked-out branch: it follows the source's HEAD .
How the data travels, and how much of it, depends on the transport, the server and options such as --filter. The transfer protocols chapter covers the conversation itself.
Investigate it
``bash git clone https://example.com/team/hello-git.git cd hello-git git branch -a # local and remote-tracking branches git config --get remote.origin.url git config --get-all remote.origin.fetch git show-ref # every local ref with its ID git clone --filter=blob:none https://example.com/team/hello-git.git hg-partial git clone --bare https://example.com/team/hello-git.git hello-git.git ``
22. Fetch, pull and push
Your repository and the shared one are separate databases. Nothing moves between them until you run a command, and three commands do almost all of that work. fetch brings commits in, pull brings them in and combines them with your branch, and push sends your commits out and asks the other side to move its refs.
Fetch: download and record
git fetch downloads refs from another repository along with the objects needed to complete their history, then updates remote-tracking branches . It writes to refs/remotes/... through the configured refspec, not to your local branches, so your own branches stay where they are . It also records what it fetched in .git/FETCH_HEAD . With no remote named, it uses origin unless the current branch has an upstream configured .
Pull: fetch, then integrate
git pull runs git fetch and then integrates the fetched branch, by default the current branch's upstream, into the current branch . In current Git the default is fast-forward only: if your branch has diverged from the remote branch, pull fails rather than merging . You get a merge or a rebase only by asking for one, with --no-rebase, --rebase, or the pull.rebase/pull.ff config .
Push: send objects, request a ref update
git push updates refs in a remote repository and sends any objects the remote does not have yet . With no refspec it follows push.default, which defaults to simple: push the current branch to the branch of the same name . The safety rule is that a branch update must be a fast-forward, meaning the remote ref is an ancestor of what you push; anything else is rejected .
Try it
In the lab, Grace pushes a commit while Ada commits locally. Run git fetch and watch only origin/main move; then try git push and see it rejected as non-fast-forward. git pull refuses too, until you configure pull.rebase; then merge or rebase, push again, and finally try a force push to see what it overwrites. The simulator talks to one remote and models a single repository per person.
A realistic scenario
Ada commits "Document how to run" on main, but Grace pushed a commit an hour ago. Ada's git push is rejected. The fix is to fetch, combine both histories with git pull (merge) or git pull --rebase, and push again; the update is now a fast-forward . Ada sets git config pull.rebase true so future pulls rebase her local commits on top of Grace's.
Fetch details
By default fetch also gets tags that point into the history it fetches . Remote-tracking refs for branches deleted on the remote are kept until you prune them ; --prune deletes them , and fetch.prune or remote.<name>.prune make pruning automatic . FETCH_HEAD is overwritten on each fetch unless --append is given .
Pull's defaults have changed
--ff-only is the default only when no way of reconciling divergent histories is configured . pull.ff=false always creates a merge commit, and pull.ff=only allows only fast-forwards . Earlier, Git 2.27 added a warning when pull.rebase was unset, and such a pull still made a merge .
The manual does not say in which version pull started refusing divergent branches by default. The 2.33.1 release notes describe fixes to pull corner cases but do not state the change outright .
Force and its safer forms
Option — What it checks; --force — Turns off the ancestor check for every ref pushed, so the remote can lose commits; + on one refspec forces only that ref; --force-with-lease — Overwrites only if the remote ref still has the expected value, by default your remote-tracking branch's value; --force-if-includes — With --force-with-lease, forces only if the remote-tracking tip has been integrated locally, checked through the branch's reflog; alone it does nothing
Even forced, a branch never accepts a non-commit object, and a remote configured to deny the push still refuses it ; receive.denyNonFastForwards is one such setting . The FAQ reserves --force for when you really mean to discard remote history .
Other push rules
- Updating an existing tag is always rejected unless forced; creating and deleting refs is allowed unless config or hooks forbid it .
- Push does not send tags by default; push one by name, or use --tags or --follow-tags .
- -u records the upstream for each branch pushed, so a plain git pull knows where to go .
- simple has been the push.default since Git 2.0; before that it was matching .
- receive.denyCurrentBranch defaults to refuse: a push to the checked-out branch of a non-bare repository is denied because it would leave HEAD out of step with the index and working tree; updateInstead updates the working tree instead .
Misconceptions
- "Fetch updates my branch." It updates remote-tracking refs only .
- "origin/main is the remote's current state." It is only as fresh as your last fetch or push .
What happens on the server side after a push, such as hooks or branch protection, depends on the hosting service and its configuration.
Investigate it
``bash git fetch origin git log --oneline main..origin/main # commits you have not integrated cat .git/FETCH_HEAD git status -sb # ahead/behind vs the upstream git config pull.rebase true git pull git push --force-with-lease origin main ``
23. Remote-tracking branches and refspecs
After a clone you have main and also origin/main. They look alike, but they are different refs, kept in different places, and updated by different commands. A short string called a refspec decides how branches in another repository map onto refs in yours.
Where refs live
Refs are named by path. refs/heads/<name> holds branch tips, refs/tags/<name> can hold any object name, and refs/remotes/<remote>/<name> holds remote-tracking branch tips . origin/main, remotes/origin/main and refs/remotes/origin/main all name the same ref, because Git expands each of them to the full name .
Your branches move when you commit . Remote-tracking refs move when you fetch or push, so ahead/behind counts against them can be out of date until you fetch again .
What a refspec says
A refspec is an optional +, then <src>:<dst>: src is the pattern for refs on the remote side and dst is where they are tracked locally . The + lets the ref update even when the update is not a fast-forward . A clone stores the default fetch refspec in remote.origin.fetch:
``text +refs/heads/:refs/remotes/origin/ `
It maps every remote branch to refs/remotes/origin/<name> . A pattern refspec must contain exactly one on each side, and the in dst is replaced by whatever matched in src .
Upstream
A branch can have an upstream: main@{u} (or @{upstream}) names the remote-tracking branch it builds on, set through branch.<name>.remote and branch.<name>.merge . A new branch created from a remote-tracking branch gets this tracking automatically, because branch.autoSetupMerge defaults to true .
Try it
The lab shows your refs beside origin's refs. Run git fetch and follow each arrow from refs/heads/ on the remote to refs/remotes/origin/ in your repository. Then create feature/login, push it with git push -u origin feature/login, and see the new remote branch, the new remote-tracking ref and the upstream setting appear together.
A realistic scenario
Grace deletes feature/login on the server after merging it. Ada's git branch -r still lists origin/feature/login, because Git keeps stale remote-tracking refs until they are pruned . git fetch --prune removes it . Ada also wants to see what the server has without downloading anything, so she runs git ls-remote origin`, which lists the remote's refs and their IDs without fetching objects .
Push refspecs
Push refspecs have the form [+]<src>[:<dst>], where src can be any revision expression but dst must be a real ref name . Leaving out :<dst> updates the ref of the same name, so main expands to main:refs/heads/main .
Command — Effect; git push origin main — Updates refs/heads/main on the remote; git push origin :feature/login — Empty src: deletes the remote branch, like git push --delete; git push origin : — Pushes "matching" branches, those that exist by the same name on both sides; git push origin +main — Forces this one ref
remote.<name>.push can rewrite where branches go, for example pushing local master to qa/master . @{push} names the remote-tracking branch for where git push would send a branch .
Fetch refspec rules
- Explicit refspecs on the command line, such as git fetch origin main, fetch only those refs; remote.<name>.fetch is then used only to decide which remote-tracking branch to update .
- A refspec starting with ^ is negative: a ref matches only if it matches a positive refspec and no negative one .
- Partial globs such as qa have worked in fetch patterns since Git 2.6.0 .
- Updates outside refs/{tags,heads}/ are accepted without +, and fetch has no pre-fetch hook or config that changes these rules .
Tags: a historical change
Before Git 2.20, fetch accepted any update to refs/tags/* without +. Since 2.20, tag updates on fetch are rejected unless forced, as with push . This matches the general rule that tags are not supposed to move once others have fetched them .
Misconceptions
- "origin/main is on the server." It is a ref in your repository, Git's record of the last-known state of the remote branch .
- "Refs are always loose files." They can be packed into packed-refs , and the reftable backend is an alternative to the default files backend . Use commands rather than reading .git directly; the docs recommend git show-ref over reading files .
Hosting services often add their own refs, such as review or pull request refs, and may restrict which refs you can push. What you see in git ls-remote therefore varies by service and configuration.
Investigate it
``bash git config --get-all remote.origin.fetch git for-each-ref refs/remotes # remote-tracking refs git rev-parse --symbolic-full-name main@{u} git ls-remote origin # the remote's refs, no download git fetch --prune origin git push -u origin feature/login git push origin --delete feature/login ``
24. Git transfer protocols and object negotiation
Fetch, pull, clone and push all need two Git repositories to agree on what to send. The rules for that conversation are Git's transfer protocols. Most people never see them, but they explain why a fetch of a large repository can be quick, why some URLs need a password and others do not, and why a push is rejected when someone else pushed first.
Dumb and smart
Git moves data in two ways, the "dumb" protocol and the "smart" protocol . The dumb protocol needs no Git code on the server: fetching is a series of plain HTTP GETs that assume the repository's file layout . It is rarely used now, is hard to secure, and cannot write data from client to server .
The smart protocol runs a Git-aware process on the server that works out what the client has and needs, and builds a custom packfile for it . Fetching pairs fetch-pack on the client with upload-pack on the server; pushing pairs send-pack with receive-pack . These are plumbing: users rarely run upload-pack directly .
Two steps: advertise, then negotiate
The pack protocol works in two steps: the server says what it has, then both sides negotiate the smallest amount of data needed . In a fetch the client sends "want" lines for objects it needs and "have" lines for objects it already has, and the server acknowledges the ones they share . Then the server sends a packfile . Objects both sides already have are never sent.
In a push, the client sends one line per ref: the old ID, the new ID and the ref name, followed by a packfile . The server checks that each ref still has the old ID before updating it , which is why a push based on stale information fails.
Try it
The lab steps through a smart-protocol fetch: the protocol v2 ls-refs advertisement, want/have negotiation, the packfile and the ref updates. Then it runs a push through receive-pack and shows the old/new/ref command lines. Each message is shown with its pkt-line length prefix. The lab shows one simplified exchange; real sequences differ by version, transport and capabilities.
A realistic scenario
Ada clones hello-git over SSH. Her client runs git-upload-pack on the server , learns the refs, wants the tips of main and feature/login, and receives one pack. A week later, git fetch sends "have" lines for the commits she already has, so the server sends only Grace's new commits. When Ada pushes, her client sends a single command line for refs/heads/main and a pack of her new objects.
pkt-line framing
Messages are pkt-lines: each starts with four hex digits giving the length, counting those four bytes, so 0006a\n carries a\n . A pkt-line carries at most 65516 bytes of data, and 0000 is the flush-pkt, which is different from an empty 0004 line . Protocol v2 adds 0001, the delim-pkt that separates sections, and 0002, the response-end-pkt for stateless connections .
``text 0006a\n 4-digit length (6) + "a\n" 0000 flush-pkt 0001 delim-pkt (v2) 0002 response-end-pkt (v2) `
v0/v1 and v2
In protocol v0/v1 the server begins with a ref advertisement listing every branch and tag with its ID, HEAD first if valid ; capabilities follow a NUL byte on the first line, and an annotated tag's peeled value comes right after it . Protocol v2 is command-oriented: the server advertises capabilities and the client asks for commands such as ls-refs or fetch, several per connection . It sends no ref advertisement unless asked, and ls-refs can be limited by prefix . In v2 fetch, wants are not limited to advertised objects .
The client requests v2 with version=2: an extra parameter on git://, GIT_PROTOCOL over SSH or file, or the Git-Protocol header over HTTP . protocol.version is unset by default, which means 2, and if the server does not support the requested version they fall back to 0 . The default has a history: v2 first became the default in Git 2.26 , was dropped again in 2.27 , and has been the default since 2.29 .
Negotiation details
In v0/v1, wants must name advertised objects; the client may add shallow lines and a deepen depth, then a flush-pkt . In multi_ack mode it sends haves 32 at a time . It sends "done" when it has enough ACKs, or gives up after 256 haves with no ACK; the server then sends a final ACK or NAK and the packfile . A partial clone filter travels as a "filter" line . Pushes are thin by default, leaving out objects the receiver should already have .
In a push command line, an all-zero old ID means create and an all-zero new ID means delete . receive-pack unpacks the pack, checks each ref still equals its old ID, runs the update hooks and only then updates refs .
Transports and authentication
Transport — How it works; SSH — The client runs git-upload-pack or git-receive-pack on the remote host; Smart HTTP — GET $GIT_URL/info/refs?service=..., then POST to the service URL ; smart clients upgrade dumb URLs automatically; git:// — git daemon on TCP port 9418, by default only upload-pack , unauthenticated
The pack protocol has no authentication of its own; SSH or HTTP provides it, and receive-pack over git:// would let anyone who can reach port 9418 write . ssh, git and file carry the same pack protocol, while HTTP is specified separately .
Exact message sequences depend on the protocol version, transport and negotiated capabilities. Treat traces as illustrative of one setup, not as a fixed script.
Investigate it
`bash GIT_TRACE_PACKET=1 git ls-remote origin # shows pkt-lines, version 2 git -c protocol.version=0 ls-remote origin GIT_TRACE_PACKET=1 git fetch origin git ls-remote --heads origin ``
25. Packfiles, compression and garbage collection
Every object Git creates starts life as a loose object: one file per object, compressed with zlib . It lives under .git/objects/, in a subdirectory named after the first two hex characters of its ID, with the remaining characters as the file name . Loose objects are stored whole. A one-line change to a 22 KB file creates a second, complete 22 KB blob; nothing is stored as a difference at this stage .
That sounds wasteful, but two separate mechanisms keep a repository small.
Identical content is stored once
The first is content addressing. A snapshot does not store an unchanged file again: the new tree just names the blob that is already there . A commit that changes 2 of 1,000 files creates just 2 new blobs , and every other entry reuses an existing blob ID . This is why "every commit is a full snapshot" does not mean "every commit copies the project".
Similar content is delta-compressed in packs
The second mechanism handles content that is similar but not identical, such as two versions of the same file. Git packs objects into a packfile when there are too many loose objects, when you run git gc, or when you push to a remote . While packing, it looks for files with similar names and sizes and stores only the deltas between versions . In Pro Git's example the newest version is stored whole and the older one as a delta, because the newest version is the one most likely to be needed quickly . That is the opposite of a log of per-commit diffs: deltas exist only as storage compression inside packfiles , and a commit still describes a snapshot, not a change .
Each pack is a pair of files: the .pack holds the objects and the .idx holds offsets so any object can be found quickly; current Git may add a .rev reverse index as well .
In the lab below, commit a few versions of src/utils.js and then run git gc. You will see the loose objects disappear into one pack, the versions of the file drawn as one whole base plus deltas, and an abandoned commit that stays outside the pack until its grace period ends. The delta encoding is simplified and labelled as such.
Housekeeping happens on its own
You rarely need to run git gc yourself. Everyday porcelain commands that create objects check whether the repository has grown a lot and, if so, run git gc --auto . gc compresses file revisions, removes unreachable objects, packs refs, and prunes reflogs, rerere metadata and stale worktrees .
A realistic case: Ada Lovelace has been committing small changes to hello-git for months. One day a git commit takes a little longer than usual. Nothing has gone wrong: the loose object count passed the gc.auto threshold, which defaults to 6700 , and Git repacked. Her history is unchanged; only its storage format moved from many small files to a few packs.
Garbage collection only removes objects that nothing reaches, and only after a grace period: 2 weeks by default . Reachable objects are never deleted .
Inside a .pack file
A pack starts with a 12-byte header: the signature PACK, a version (Git writes only 2 and accepts 2 or 3) and an object count, all in network byte order . Each entry begins with a variable-length header holding 3 bits of type and then size bits, and a trailer at the end holds a checksum of the whole pack .
Code — Type; 1 — commit; 2 — tree; 3 — blob; 4 — tag; 6 — OFS_DELTA; 7 — REF_DELTA
Type 5 is reserved and 0 is invalid . Packed objects drop the loose <type> <size>\0 prefix, because the entry header already carries type and size, but the object ID is still hashed over the rebuilt prefix . So packing never changes an object's name.
A REF_DELTA names its base by object ID; an OFS_DELTA points to a base in the same pack by a negative offset. A thin pack, used on the wire, may refer to bases outside the pack . Delta data starts with the base size and the result size, followed by instructions that either copy a byte range from the base or insert new literal bytes . Checksums and object IDs use SHA-1 or SHA-256, matching the repository .
The .idx starts with a 256-entry fan-out table: entry N counts the objects whose first byte is at most N, which makes lookup by ID fast . Version 2 indexes support packs larger than 4 GiB . Lookup slows as packs multiply; a multi-pack-index is one index across many packs , and the optional commit-graph file speeds up history walks .
What gc keeps and what it drops
- More than about gc.auto (6700) loose objects triggers packing; 0 turns off the auto heuristics .
- More than gc.autoPackLimit (50) packs not marked .keep are merged into one .
- Unreachable loose objects older than gc.pruneExpire (2 weeks) are pruned; "now" removes the grace period and "never" turns pruning off .
- By default (gc.cruftPacks = true), unreachable objects go into a separate cruft pack rather than staying loose .
Reachability counts more than branches and tags: the index, remote-tracking branches, reflogs and anything under refs/ keep objects alive . Reflog entries expire after 90 days, or 30 days if unreachable from the current tip . The grace period exists to protect a concurrent writer: any object newer than the prune date is kept, with everything reachable from it . --prune=now raises the risk of corruption if another process is writing .
git repack rewrites packs ; -a packs everything referenced and -d deletes what became redundant . Delta search compares each object with others in a sliding window (default 10), and chains are capped at a depth (default 50, maximum 4095); deeper chains make reading slower . gc --aggressive uses a window of 250, costs much more time, and the docs say most users should not use it .
git prune removes only loose unreachable objects; packed ones stay until a repack . Disk usage may not drop the moment you delete a branch.
Investigate it
``bash git count-objects -v # loose count and size, in-pack count, packs git gc # pack, prune past the grace period ls .git/objects/pack/ # .pack, .idx (and possibly .rev) git verify-pack -v .git/objects/pack/pack-*.idx | head `
count-objects -v helps you decide when to repack . verify-pack -v` lists name, type, size, size-in-pack and offset, plus depth and base for deltified objects . Which objects become deltas, and against which base, depends on Git's heuristics and settings, so results vary between repositories.
26. Git integrity, hashes and object IDs
Git is a content-addressable filesystem: files, directories and revisions are named by a hash of their content, not by sequential numbers . An object's name is the hash of a header plus the content: the type, the length, a NUL byte, then the content bytes . The file Hello world followed by a newline is a 12-byte blob; hashing blob 12\0 plus those bytes gives 802992c4220de19a90767f3000a79a31b98d0df7, which is exactly what git hash-object prints . Because the header is part of the hashed data, plain sha1sum on the file gives a different value .
Names are 40 hex digits in a SHA-1 repository and 64 in a SHA-256 repository .
Why this gives integrity
Since everything is stored under its checksum, Git notices if any file or directory content is changed or corrupted . A flipped bit changes the hash, so the content no longer matches its name; the same property makes lookup fast .
The cascade
Trees, commits and tags store the names of the objects they point to inside their own content. Changing a blob therefore changes the tree that lists it, which changes the commit that points to that tree . A commit also stores its parents' IDs , so every descendant commit changes too. This is why rewriting an old commit gives every later commit a new ID, and why rebased commits always get new IDs . It also works the other way: two trees have the same ID only if their entire contents are identical, so comparisons can skip any subtree whose ID did not change .
In the lab below, change one character of src/utils.js in hello-git. Watch the blob ID change, then the src tree, the root tree, the commit, and every commit after it. The lab also shows the same object named in 40-hex SHA-1 and 64-hex SHA-256 form, and the difference between a content hash and a signature.
Integrity is not authenticity
A hash proves the content has not changed. It does not prove who wrote it: anyone can create a commit with any author name. To check origin, Git supports signing commits and tags . With a secure hash, one object name stands for the whole graph below it, so signing a commit or tag's name lets others trust every object it references .
A realistic scenario: Grace Hopper reviews a release of hello-git. The tag v1.0 names a commit, the commit names a tree, the tree names every blob. If Ada signed the tag, Grace can verify one signature and know that every file in that snapshot is byte-for-byte what Ada tagged. Without the signature, the IDs still prove that nothing changed since Grace fetched them, but not that Ada was the author.
SHA-1 is still the default object format in Git 2.56 . SHA-256 repositories exist, but cannot exchange data with SHA-1 repositories .
SHA-1, hardened
After the SHAttered collision (23 February 2017), Git v2.13.0 and later use a hardened SHA-1 that resists that attack by default; the project still treats SHA-1 as weak and picked SHA-256 as its successor in 2018 .
The documentation pages checked do not name the hardened SHA-1 implementation or explain how it detects collision attempts .
SHA-256 status
Question — Answer in current Git; How is it chosen? — git init --object-format=sha256; sha1 is the default; Where is it recorded? — extensions.objectFormat; unset means sha1, and changing it later breaks the repository; Do old Git versions misread it? — No: it uses repository format version 1 with extensions, so older Git refuses to open it; Can it talk to SHA-1 repositories? — No ; extensions.compatObjectFormat is incomplete and only for development and testing; Other ways to set the default — GIT_DEFAULT_HASH (ignored when cloning) ; init.defaultObjectFormat, overridden by both; Future default — Git 3.0 plans to switch new repositories to SHA-256, waiting for the ecosystem ; no release date is planned , and SHA-1 is not planned to be deprecated
Earlier documentation warned that SHA-256 repositories might need backward-incompatible changes; that warning describes earlier versions, and current docs expect only compatible changes . A blob refers to no other object, so its content is the same under both hashes and only its name differs; trees, commits and tags differ in bytes because they embed other names .
Checking the database
git fsck checks that every object is valid and that the objects it references are present . "hash mismatch" means real corruption, and fsck cannot repair it: good copies must come from backups or other clones . --connectivity-only is faster because it skips reading blobs, so it does not detect blob corruption .
By default, fetch and receive do not fsck incoming objects: transfer.fsckObjects defaults to false. When it is on, a malformed object or a link to a missing object aborts the transfer .
Signatures
git commit -S signs a commit, and commit.gpgSign makes that the default . The signature is stored inside the commit object as a gpgsig header; a tag's signature is appended after its message . gpg.format selects the backend: openpgp by default, or x509 or ssh . git verify-commit validates commit signatures , and git merge --verify-signatures aborts if the tip being merged is not signed with a valid key . Which keys count as trusted depends on your signing backend's configuration.
Investigate it
``bash printf 'Hello world\n' | git hash-object --stdin # 802992c4... git cat-file -p HEAD # tree and parent IDs inside the commit git init --object-format=sha256 /tmp/sha256-demo # a SHA-256 repository git -C /tmp/sha256-demo rev-parse --show-object-format git fsck git verify-commit HEAD # fails if HEAD is unsigned `
--stdin` hashes the bytes exactly as given, with no end-of-line conversion .
27. Advanced Git internals and plumbing commands
Git started as a toolkit. Its low-level plumbing commands are meant to be chained together or called from scripts, and the everyday porcelain commands such as checkout, branch and commit are built on top of them . Plumbing interfaces (input, output, options, semantics) are kept much more stable than porcelain, so scripts should use plumbing .
The manual groups plumbing into three kinds: commands that manipulate objects, commands that interrogate and compare them, and commands that sync objects and refs between repositories . Interrogation commands such as cat-file, ls-tree, ls-files, rev-parse, show-ref and for-each-ref generally leave the working tree alone , which makes them safe to experiment with.
What git add and git commit really do
git add plus git commit is the same as a short list of plumbing steps: store blobs, update the index, write trees, and write a commit that points at the top-level tree and the parent . Then the branch moves. Each step has its own command:
Step — Plumbing — What it does; Store a blob — git hash-object -w — Computes the ID; writes the object only with -w; Record it in the index — git update-index --add — Writes file contents into the index; new files need --add; Build trees — git write-tree — Turns the index into tree objects and prints the root tree; Make the commit — git commit-tree — Creates a commit from a tree and parents; moves no branch; Move the branch — git update-ref — Sets a ref safely, optionally compare-and-swap
Porcelain like git branch is basically update-ref underneath: it writes the current commit's ID into a new ref .
In the lab below, run the plumbing commands against the simulated hello-git repository: hash src/utils.js, read objects with cat-file, list a tree with ls-tree, inspect the index with ls-files --stage, write a tree, resolve names with rev-parse and list refs with show-ref. Each command shows which objects or refs it read or wrote. The simulator supports a subset of each command's options.
A realistic use
Ada wants a script that refuses to deploy hello-git unless main exists and is where she expects. Parsing git branch output would be fragile, because porcelain output may change . Instead the script uses git show-ref --exists refs/heads/main, which exits 0 if the ref exists, 2 if it is missing and 1 on another error , and git rev-parse --verify main, which turns exactly one revision expression into a full object name or fails . Both are plumbing, with output and exit codes meant for machines.
The docs recommend git show-ref over reading files under .git directly . Refs may be packed into one file or kept in a different ref backend, so the files you expect may not be there .
Output formats worth knowing
- cat-file: -t prints the type, -s the size in bytes, -p pretty-prints by type, -e only sets the exit status . --batch reads names from stdin and prints a header line (name, type, size by default) followed by the raw content ; a name that does not resolve prints <object> missing instead of failing the whole run .
- ls-tree: one line per entry: mode, type, object name, a TAB, then the path; the same format can be fed to update-index --index-info . -l adds blob sizes, right-justified to at least 7 characters, with "-" for other entries .
- ls-files: file names only by default; --stage prints mode, object name, stage number and path . During a conflict a path can have stages 1, 2 and 3; a resolved path is back at stage 0 .
- for-each-ref: default line is object name, type, TAB, refname, sorted by refname .
- rev-parse --short: a unique prefix of at least 4 characters, with the default length from core.abbrev .
Details that bite
hash-object defaults to type blob and, with --stdin, hashes the exact bytes given . update-index --cacheinfo <mode>,<object>,<path> adds an entry for an object already in the database without needing the file on disk . write-tree refuses if the index has unresolved conflicts , and normally checks that every referenced object exists .
commit-tree takes one -p per parent: none makes a root commit, one an ordinary commit, more than one a merge . Commit IDs include author and committer timestamps, so running the same command later gives a different hash .
update-ref with an old value is a compare-and-swap; 40 zeros (or an empty string) as the old value means "must not exist yet" . --stdin applies a batch all-or-nothing, though a concurrent reader may see only some updates . Each update appends a reflog line with old value, new value, committer ident and optional message . git symbolic-ref reads or sets symbolic refs such as HEAD, which store ref: refs/... instead of an object ID .
rev-parse --verify documentation still says "20-byte SHA-1"; in a SHA-256 repository the name is 32 bytes .
Investigate it: rebuild a commit by hand
Run this in a scratch clone of a repository, never in one you care about. It recreates what git add src/utils.js && git commit would do, step by step.
``bash blob=$(git hash-object -w src/utils.js) # store the blob git cat-file -t "$blob" # blob git update-index --add src/utils.js # record it in the index git ls-files --stage src/utils.js # mode, ID, stage 0, path tree=$(git write-tree) # index -> trees git ls-tree "$tree" old=$(git rev-parse --verify HEAD) new=$(echo "Punctuate the greeting" | git commit-tree "$tree" -p "$old") git cat-file -p "$new" # tree, parent, author, committer git update-ref refs/heads/main "$new" "$old" # only if main is still $old git show-ref --exists refs/heads/main; echo $? git for-each-ref refs/heads `
After the last update-ref, git log shows the new commit exactly as if porcelain had made it. If main is not your current branch, or HEAD is detached, adjust the ref name; update-ref will refuse the swap if main no longer holds $old` .
28. Git troubleshooting and recovery
Most Git emergencies are less serious than they look, because nearly all Git actions only add data to the database . Committed work is hard to lose; uncommitted changes in the working tree can still be lost, as in any version control system . Recovery usually comes down to three questions:
- Where is the work? In the working tree, the index, or a commit?
- What still references it? A branch, a tag, HEAD, ORIG_HEAD, a reflog entry, or nothing?
- Has it been shared? Rewriting commits others have fetched creates new problems .
Quick reference
Situation — First move; Commits on a detached HEAD — git switch -c <name> before leaving; Accidental reset --hard — git reset --hard ORIG_HEAD or a reflog entry; Merge conflict — resolve and git add, or git merge --abort; Rebase conflict — resolve, --continue, --skip or --abort; Push rejected — fetch, integrate, push again; Lost commit — git reflog, then git fsck; Staged the wrong file — git restore --staged <path>; Switch refused — git stash, or commit, or switch --merge; Pull refuses divergent branches — choose merge or rebase; origin/main looks stale — git fetch --prune
The lab below turns each of these into a drill. Pick an incident, read the state of the repository panels, and type the commands that fix it in the terminal. The lab checks the resulting refs, index and working tree, and you can reset and try a different fix. It runs on the simplified simulator, so some options shown here are not available there.
A realistic incident
Ada meant to discard an edit to src/app.js and ran git reset --hard HEAD~1. The edit is gone, and so, apparently, is "Document how to run". The commit is not gone: reset saved the old tip in ORIG_HEAD , and the branch reflog records it too . git reset --hard ORIG_HEAD puts main back. The uncommitted edit is a different matter: --hard is the only form of reset that destroys data, and uncommitted work it overwrites cannot be recovered from the reflog .
Reflogs record changes in the local repository only; they are not pushed or fetched . A fresh clone has none of your history of mistakes, and a teammate's reflog cannot rescue your work.
1. Detached HEAD with commits
Why: HEAD points straight at a commit, for example after checking out a tag or a commit ID . Commits made there are referenced only by HEAD; after switching away nothing references them, and gc will eventually delete them . Fix: git switch -c rescue before leaving ; afterwards, find the ID with git reflog -2 HEAD and create a ref to it .
2. Accidental reset --hard
Why: reset moved the branch and overwrote the index and working tree . Fix: git reset --hard ORIG_HEAD , or main@{1} from the branch reflog . Content that was staged had already been written as blobs , so git fsck --lost-found may still find it as dangling blobs, written out as their contents .
3. Merge conflict
Why: both sides changed the same area . Fix: edit, git add, then git merge --continue , or git merge --abort, which may fail to rebuild uncommitted changes, so commit or stash first .
4. Rebase conflict
Why: a replayed commit does not apply cleanly . During a rebase, "ours" and "theirs" are swapped . Fix: resolve, git add, git rebase --continue; or --skip; or --abort, which resets HEAD to the original branch .
5. Rejected push
Why: the update is not a fast-forward, and push refuses those by default because they lose history . Fix: fetch, integrate with git pull or git pull --rebase, push again . Do not force blindly: --force is for really discarding remote history ; --force-with-lease overwrites only if the remote ref still matches your remote-tracking branch . On a shared branch, prefer git revert to undo a change .
6. Lost commit
Fix: git reflog or git log -g, then create a branch at the commit . If the reflog is gone, git fsck lists dangling commits ; --no-reflogs treats reflog-only commits as unreachable, which surfaces work that used to be on a branch .
7. Staged the wrong file
Fix: git restore --staged <path> copies the HEAD version back into the index and leaves the file alone .
8. Switch blocked
Why: switching would lose local changes . Fix: git stash, switch, git stash pop ; commit first; or git switch --merge .
9. Divergent branches on pull
Why: pull's default is fast-forward only, so it fails on divergence . Fix: git pull --rebase or --no-rebase, or set pull.rebase .
The manual gives no version for when the fast-forward-only default arrived .
10. Stale origin/main
Why: remote-tracking branches only change on fetch or push , and refs for deleted remote branches stay until pruned . Fix: git fetch --prune; fetch.prune makes it automatic . Fetch updates remote-tracking refs, not your local branches .
What the reflog cannot save
Reflog entries expire after 90 days, or 30 days if unreachable from the current tip , after which objects can be pruned once past the 2-week grace period . Reflogs are local , and nothing records uncommitted work .
Investigate it
``bash git status git reflog -10 git log -g -3 main git fsck --no-reflogs git branch rescue <commit-id> git fetch --prune && git status -sb ``
29. Interactive Git repository playground
The playground is the full repository simulator behind every lab in this deep dive, with nothing hidden. It shows the working tree, the index, the object database, refs, the commit graph and a terminal side by side. Every command you type updates all of them together, and you can step back through what it did: which object was written, which index entry changed, which ref moved.
How to use it
Pick a scenario, type commands in the terminal (help lists what is supported), and use play, pause and step to replay each command's internal steps. You can edit files in the editor panel or with echo "text" > path. Reset returns the scenario to its starting point.
Scenario — Starting state; Fresh clone of hello-git — One commit on main, in step with origin; Diverged branches — main and feature/login have both moved since they split; Merge conflict ahead — Merging dark-mode into main will conflict in src/theme.js; Teammate pushed — origin has Grace Hopper's commit you have not fetched; you have one it has not seen; Empty folder — No repository yet: start with git init
In the playground below, try the experiments suggested underneath, or your own. The author is Ada Lovelace, and the clock advances by a fixed step per command, so commit IDs are reproducible inside the simulator but differ from the ones your own Git would produce.
What is real and what is simulated
The object IDs are real. Blobs, trees, commits and annotated tags are built in Git's exact object format, <type> <size>\0 followed by the content , and hashed with SHA-1, so a blob with the same bytes has the same ID as in your own repository . Commit IDs depend on author, committer, timestamps and message , which is why commits do not match yours. The repository, the remote and the network are simulated in your browser; nothing touches a real Git installation.
Suggested experiments
- Stage src/app.js, edit it again and run git status: one file, staged and unstaged at once (three areas).
- In "Diverged branches", run git merge feature/login, reset, then run git switch feature/login and git rebase main. Compare the graphs: the merge adds one commit with two parents, the rebase creates new commits with new IDs (merging, rebase).
- In "Merge conflict ahead", merge and read the three index stages with git ls-files --stage (conflicts).
- In "Teammate pushed", commit, push, read the rejection, then fetch and watch only origin/main move (fetch, pull and push).
- Run git reset --hard HEAD~1, then recover with the reflog (reset and the reflog).
- In "Empty folder", rebuild a commit with hash-object -w, write-tree and update-ref (plumbing).
How the simulator works
The repository is a plain data structure, and every command is a pure, deterministic function from one repository state to the next. Each function returns the new state, the terminal output and a list of frames, one per internal step, each with a snapshot. That is what makes step playback possible: the panels simply render the snapshot of the selected frame.
It implements the documented behaviours this deep dive cites:
- Objects. Object names are hashes of header plus content , checked against the Git command line (2.52 at the time of writing) in the site's tests. Objects are immutable and stored once .
- The index as its own snapshot. add writes the blob immediately and records its ID under the path ; commit builds trees from the index, not the working tree . A file can therefore be staged and modified at the same time .
- Refs and HEAD. Branches and tags are refs ; HEAD is a symbolic ref or a detached commit ID ; ORIG_HEAD is written by merge, rebase and reset ; HEAD and each branch have a reflog .
- Merges. Fast-forward when possible , otherwise a three-way merge from the merge base that produces a two-parent commit . Line regions changed on only one side are taken from that side, overlapping changes become conflicts with stages 1, 2 and 3 and marker lines , in the style of a classic diff3 merge.
- Rebase. A replay that writes new commits with new IDs while the originals stay in the object store .
- Remotes. origin is a separate repository with its own object store. Fetch copies missing objects and moves remote-tracking refs only ; push sends objects and is refused when not a fast-forward unless forced ; pull is fetch plus merge or rebase , and refuses divergent branches until a pull mode is configured .
Supported commands
init, status, add (paths or -A), restore (and --staged), commit -m / -am, log, branch (create, -d, -D, -m), switch (-c, --detach), checkout, tag (lightweight or -a/-m), merge (--no-ff, --ff-only, --continue, --abort), rebase (--continue, --skip, --abort), cherry-pick, revert, reset (--soft, --mixed, --hard), reflog, diff (--cached), show, stash and stash pop, clone, fetch, pull, push (-u, --force), config pull.rebase / pull.ff only, and the plumbing commands cat-file, ls-tree, ls-files, hash-object, rev-parse, show-ref, write-tree, update-ref, symbolic-ref and fsck.
What it leaves out
- Packfiles. Objects are kept as a simple map; packing is modelled separately in the packfiles lab.
- Hooks, submodules, and file modes other than 100644 (real Git also has executable files, symlinks, directories and gitlinks ).
- Interactive tools: rebase -i and add -p .
- Rename detection, which real merges perform .
- Multiple merge bases. Real histories can have more than one best common ancestor , and ort merges them first ; the simulator uses the first one it finds.
- Configuration beyond the user identity and the pull mode.
The simulator implements --force-with-lease as the manual describes: it forces only if origin's branch still matches your remote-tracking branch . Line diffs are computed with a longest-common-subsequence algorithm, which usually matches Git's default Myers output on small files but may choose different hunks.
Investigate it
Repeat any experiment in a real repository and compare:
``bash git init hello-git && cd hello-git printf 'Hello world\n' > README.md git add README.md git ls-files --stage # same blob ID as the playground for the same bytes git commit -m "Initial commit" git cat-file -p HEAD # different commit ID: different author and time ``
30. Putting it all together
Across thirty chapters, one idea keeps returning: Git is a content-addressable store of immutable objects, with a version-control interface built on top . Every everyday command either writes new objects, moves a ref, or both. Here is the whole journey of one change to hello-git.
- Edit. Ada changes src/utils.js. Only the working tree changes; Git has recorded nothing yet .
- Add. git add src/utils.js hashes the file, stores it as a blob and records that blob's ID in the index . The index now describes the next commit; the working tree and the last commit are unchanged .
- Commit. git commit turns the flat index into tree objects, writes a commit pointing at the root tree and the parent, and moves the current branch to the new commit . Unchanged files reuse their existing blobs . The commit stores a snapshot, not a diff .
- Branch. git branch feature/login creates a new ref at the current commit; no files are copied .
- Merge or rebase. A merge records a new commit with two parents , or just moves the ref for a fast-forward . A rebase creates new commits with new IDs and points the branch at the last one . Either way, existing objects are left as they were .
- Push and fetch. Push sends objects the remote lacks and asks it to update refs . Fetch downloads objects and updates remote-tracking refs, not your branches .
What Git does not do
- It does not store diffs per commit; diffs are computed when you ask . Deltas inside packfiles are storage compression only .
- A branch does not copy files .
- git add does not move a file anywhere; it records content for the next commit .
- Rebase does not move commits; it writes new ones .
- Revert does not delete history; it adds a commit that undoes another .
- Fetch does not change your local branches .
- A content hash does not prove who wrote a commit .
- The reflog is not shared , and committing does not mean pushing .
A practical checklist
- Commit small and often. Committed work is hard to lose; uncommitted work is not .
- Never rewrite shared history. Do not rebase commits that exist outside your repository , and do not reset away commits you have shared .
- On shared branches, prefer revert to undo a change .
- If you must force-push, use --force-with-lease .
- *Fetch before you judge origin/. Remote-tracking refs change only on fetch or push .
- Remember the reflog is local and expires .
- Keep backups through remotes.** Every clone carries the full history by default , but corruption cannot be repaired from the damaged copy itself; good objects come from other clones or backups .
This chapter has no lab. To try the whole journey yourself, open the playground and start from the "Empty folder" scenario.
The same model, one level down
Porcelain — Objects written — Refs moved; git add — blobs — none (the index changes); git commit — trees and a commit — current branch; git branch — none — a new ref; git tag -a — a tag object — refs/tags/<name>; git merge (three-way) — a merge commit — current branch; git rebase — new commits — the rebased branch; git fetch — objects from the remote — remote-tracking refs; git push — objects on the remote — refs on the remote; git gc — packs — none; it may prune unreachable objects
When something surprises you, ask the same two questions: which objects exist? and which refs point where? git cat-file -p, git show-ref and git reflog answer them .
What varies
Defaults covered in this deep dive describe Git 2.56. Several are expected to change or already depend on configuration: the initial branch name falls back to master until Git 3.0 unless init.defaultBranch is set ; the default hash is planned to move to SHA-256 in Git 3.0, with no release date ; pull behaviour depends on pull.rebase and pull.ff . Hosting services add their own rules on top, such as branch names chosen at repository creation , and a server can be configured to deny non-fast-forward pushes even when forced .
Related explainers on Bit Culled
- Kubernetes architecture: follows a deployment from a single API request through scheduling, container creation, networking, scaling and self-healing, with a cluster simulation.
- PostgreSQL internals: a tour of PostgreSQL 18's processes, query path, planner, pages and buffers, write-ahead log, MVCC, isolation and VACUUM.
- NATS: how the NATS messaging system connects applications, routes messages, distributes work and persists events.
Investigate it
``bash git log --oneline --graph --all # the DAG and every ref git cat-file -p HEAD # one commit: tree, parents, people, message git ls-tree -r HEAD # the snapshot it names git for-each-ref # every ref and what it points to git reflog -5 # where HEAD has been, locally git count-objects -v # how it is stored ``
Technologies
- Git: Distributed version control: a content-addressable object store with a version-control interface on top. This study describes the 2.56 manual.
Sources
- Git - git-init Documentation — Git documentation, undated (current docs, Git 2.56)
- Git - git-tag Documentation — Git documentation, undated (current docs, Git 2.56)
- Git - git-verify-commit Documentation — Git documentation, undated (current docs, Git 2.56)
- Git - gitformat-signature Documentation — Git documentation, undated (current docs, Git 2.56)
- 7.4 Git Tools - Signing Your Work — Pro Git (2nd edition), undated (Pro Git 2nd edition)
- Git - git-merge Documentation — Git documentation, undated (current docs, Git 2.56)
- 10.1 Git Internals - Plumbing and Porcelain — Pro Git (2nd edition), undated (Pro Git 2nd edition)
- Git - git-hash-object Documentation — Git documentation, undated (current docs, Git 2.56)
- Git - git-cat-file Documentation — Git documentation, undated (current docs, Git 2.56)
- Git - git-ls-tree Documentation — Git documentation, undated (current docs, Git 2.56)
- Git - git-update-index Documentation — Git documentation, undated (current docs, Git 2.56)
- Git - hash-function-transition Documentation — Git documentation, undated (current docs, Git 2.56)
- Git - git-write-tree Documentation — Git documentation, undated (current docs, Git 2.56)
- Git - git-commit-tree Documentation — Git documentation, undated (current docs, Git 2.56)
- Git - git-rev-parse Documentation — Git documentation, undated (current docs, Git 2.56)
- Git - git-update-ref Documentation — Git documentation, undated (current docs, Git 2.56)
- Git - git-show-ref Documentation — Git documentation, undated (current docs, Git 2.56)
- Git - git-symbolic-ref Documentation — Git documentation, undated (current docs, Git 2.56)
- Git - git-for-each-ref Documentation — Git documentation, undated (current docs, Git 2.56)
- Git - git-ls-files Documentation — Git documentation, undated (current docs, Git 2.56)
- 10.3 Git Internals - Git References — Pro Git (2nd edition), undated (Pro Git 2nd edition)
- Git - git-checkout Documentation — Git documentation, undated (current docs, Git 2.56)
- Git - BreakingChanges Documentation — Git documentation, undated (current docs, Git 2.56)
- Git - git-switch Documentation — Git documentation, undated (current docs, Git 2.56)
- Git - git-reset Documentation — Git documentation, undated (current docs, Git 2.56)
- Git - git-rebase Documentation — Git documentation, undated (current docs, Git 2.56)
- Git - git-push Documentation — Git documentation, undated (current docs, Git 2.56)
- Git - git-stash Documentation — Git documentation, undated (current docs, Git 2.56)
- Git - git-restore Documentation — Git documentation, undated (current docs, Git 2.56)
- Git - gitfaq Documentation — Git documentation, undated (current docs, Git 2.56)
- 10.7 Git Internals - Maintenance and Data Recovery — Pro Git (2nd edition), undated (Pro Git 2nd edition)
- Git - git-reflog Documentation — Git documentation, undated (current docs, Git 2.56)
- Git - git-remote Documentation — Git documentation, undated (current docs, Git 2.56)
- Git - git-config Documentation — Git documentation, undated (current docs, Git 2.56)
- Git - git-fetch Documentation — Git documentation, undated (current docs, Git 2.56)
- 3.5 Git Branching - Remote Branches — Pro Git (2nd edition), undated (Pro Git 2nd edition)
- Git - git Documentation — Git documentation, undated (current docs, Git 2.56)
- Git - git-fsck Documentation — Git documentation, undated (current docs, Git 2.56)
- 1.3 Getting Started - What is Git? — Pro Git (2nd edition), undated (Pro Git 2nd edition)
- 10.2 Git Internals - Git Objects — Pro Git (2nd edition), undated (Pro Git 2nd edition)
- Git - git-commit Documentation — Git documentation, undated (current docs, Git 2.56)
- git-clone - Clone a repository into a new directory — Git documentation, undated (current docs, Git 2.56)
- 10.6 Git Internals - Transfer Protocols — Pro Git (2nd edition), undated (Pro Git 2nd edition)
- gitprotocol-pack - How packs are transferred over-the-wire — Git documentation, undated (current docs, Git 2.56)
- gitprotocol-common - Things common to various protocols — Git documentation, undated (current docs, Git 2.56)
- gitprotocol-v2 - Git Wire Protocol, Version 2 — Git documentation, undated (current docs, Git 2.56)
- gitprotocol-http - Git HTTP-based protocols — Git documentation, undated (current docs, Git 2.56)
- git-daemon - A really simple server for Git repositories — Git documentation, undated (current docs, Git 2.56)
- git-upload-pack - Send objects packed back to git-fetch-pack — Git documentation, undated (current docs, Git 2.56)
- Git v2.26 Release Notes — Git source, 2020-03 (Git 2.26.0)
- Git v2.27 Release Notes — Git source, 2020-06 (Git 2.27.0)
- Partial Clone — Git documentation, undated (current docs, Git 2.56)
- Git v2.29 Release Notes — Git source, 2020-10 (Git 2.29.0)
- 10.4 Git Internals - Packfiles — Pro Git (2nd edition), undated (Pro Git 2nd edition)
- gitformat-pack - Git pack format — Git documentation, undated (current docs, Git 2.56)
- Documentation/gitformat-pack.adoc — Git source, undated (master)
- git-gc - Cleanup unnecessary files and optimize the local repository — Git documentation, undated (current docs, Git 2.56)
- git-prune - Prune all unreachable objects from the object database — Git documentation, undated (current docs, Git 2.56)
- git-repack - Pack unpacked objects in a repository — Git documentation, undated (current docs, Git 2.56)
- git-count-objects - Count unpacked number of objects and their disk consumption — Git documentation, undated (current docs, Git 2.56)
- git-verify-pack - Validate packed Git archive files — Git documentation, undated (current docs, Git 2.56)
- Multi-Pack-Index (MIDX) Design Notes — Git documentation, undated (current docs, Git 2.56)
- Git Commit-Graph Design Notes — Git documentation, undated (current docs, Git 2.56)
- Git v2.33.1 Release Notes — Git source, 2021-10 (Git 2.33.1)
- git-pull - Fetch from and integrate with another repository or a local branch — Git documentation, undated (current docs, Git 2.56)
- 10.5 Git Internals - The Refspec — Pro Git (2nd edition), undated (Pro Git 2nd edition)
- gitrevisions - Specifying revisions and ranges for Git — Git documentation, undated (current docs, Git 2.56)
- git-ls-remote - List references in a remote repository — Git documentation, undated (current docs, Git 2.56)
- 1.1 Getting Started - About Version Control — Pro Git (2nd edition), undated (Pro Git 2nd edition)
- 1.6 Getting Started - First-Time Git Setup — Pro Git (2nd edition), undated (Pro Git 2nd edition)
- gitglossary - A Git Glossary — Git documentation, undated (current docs, Git 2.56; page last updated in 2.56.0)
- gitdatamodel - Git's core data model — Git documentation, undated (current docs, Git 2.56; page last updated in 2.56.0)
- git-status - Show the working tree status — Git documentation, undated (current docs, Git 2.56; page last updated in 2.53.0)
- gitrepository-layout - Git Repository Layout — Git documentation, undated (current docs, Git 2.56; page last updated in 2.49.0)
- gitignore - Specifies intentionally untracked files to ignore — Git documentation, undated (current docs, Git 2.56; page last updated in 2.55.0)
- 1.2 Getting Started - A Short History of Git — Pro Git (2nd edition), undated (Pro Git 2nd edition)
- 2.1 Git Basics - Getting a Git Repository — Pro Git (2nd edition), undated (Pro Git 2nd edition)
- 2.2 Git Basics - Recording Changes to the Repository — Pro Git (2nd edition), undated (Pro Git 2nd edition)
- 5.1 Distributed Git - Distributed Workflows — Pro Git (2nd edition), undated (Pro Git 2nd edition)
- 4.2 Git on the Server - Getting Git on a Server — Pro Git (2nd edition), undated (Pro Git 2nd edition)
- git-switch - Switch branches (2.50.0) — Git documentation, 2025-06-16 (Git 2.50.0)
- git-restore - Restore working tree files (2.50.0) — Git documentation, 2025-06-16 (Git 2.50.0)
- git-diff - Show changes between commits, commit and working tree, etc — Git documentation, undated (current docs, Git 2.56)
- git-merge-base - Find as good common ancestors as possible for a merge — Git documentation, undated (current docs, Git 2.56)
- 2.2.2.2 Detailed Description of Unified Format — GNU diffutils manual, undated (current GNU diffutils manual)
- git-log - Show commit logs — Git documentation, undated (current docs, Git 2.56)
- 3.1 Git Branching - Branches in a Nutshell — Pro Git (2nd edition), undated (Pro Git 2nd edition)
- git-branch - List, create, or delete branches — Git documentation, undated (current docs, Git 2.56)
- Git Branching - Rebasing — Pro Git (2nd edition), undated (Pro Git 2nd edition)
- git-cherry-pick Documentation — Git documentation, undated (current docs, Git 2.56)
- git-revert Documentation — Git documentation, undated (current docs, Git 2.56)
- Git Tools - Reset Demystified — Pro Git (2nd edition), undated (Pro Git 2nd edition)
- merge-strategies Documentation — Git documentation, undated (current docs, Git 2.56)
- Git Branching - Basic Branching and Merging — Pro Git (2nd edition), undated (Pro Git 2nd edition)
- git-mergetool Documentation — Git documentation, undated (current docs, Git 2.56)
- git-rerere Documentation — Git documentation, undated (current docs, Git 2.56)
- git-read-tree — Git documentation, undated (current docs, Git 2.56)
- fsck.c — Git source, undated (tag v2.56.0)
- cache-tree.c — Git source, undated (tag v2.56.0)
- Git FAQ - Git SCM Wiki (archived, obsolete content) — Git SCM Wiki (archived), undated (archived wiki)
- gitsubmodules — Git documentation, undated (current docs, Git 2.56)
- 2.3 Git Basics - Viewing the Commit History — Pro Git (2nd edition), undated (Pro Git 2nd edition)
- 2.6 Git Basics - Tagging — Pro Git (2nd edition), undated (Pro Git 2nd edition)
- git-pack-refs — Git documentation, undated (current docs, Git 2.56)
- git-add — Git documentation, undated (current docs, Git 2.56)
- gitformat-index - Git index format — Git documentation, undated (current docs, Git 2.56)
- gitformat-loose - Git loose object format — Git documentation, undated (current docs, Git 2.56)
- Git User Manual — Git documentation, undated (current docs, Git 2.56)