How .git/index really works
"Staging area" is just a nickname. Behind it sits a real binary file with a documented format, its own checksum, and a stage number per entry. This article goes straight into the internals: the file format, what git add actually writes, how git status and git diff use the index as their pivot, how to inspect and manipulate it directly with git ls-files and git update-index, and why git add -p only works because the index is decoupled from the working tree.
Table of Contents
- 1. What the index really is: a binary file with its own format
- 2. The index as a preview of the next commit
- 3. git add in detail: how content actually enters the index
- 4. git status and git diff: the index as the pivot
- 5. Inspecting the index directly with git ls-files
- 6. Low-level access with git update-index
- 7. Partial staging with git add -p
- 8. The index during merge conflicts: stage 1, 2, and 3
- 9. Index operations compared side by side
- 10. Summary
- 11. FAQ
1. What the index really is: a binary file with its own format
Anyone who thinks of the Git index as just a metaphor for a "staging area" has grasped only half the truth. It is in fact a real binary file at .git/index with a documented, versioned format. The file header starts with the signature DIRC (dircache), followed by a version number, usually 2, 3, or 4, and the entry count. Each following entry stores path, file mode, blob hash, stage number, and stat metadata such as ctime, mtime, device, and inode number. The file ends with a SHA-1 or SHA-256 checksum over the entire content, which Git uses to reliably detect corruption.
Starting with version 3, the format supports additional extensions, such as the cache tree, which caches already-computed tree hashes for unchanged directories and significantly speeds up git commit, and the resolve-undo extension, which keeps recently resolved merge conflicts around for a possible follow-up git checkout -m. Important for daily use: the index is always sorted by pathname, which enables binary search and explains why commands like git ls-files always return output in alphabetical order, regardless of the order in which files were staged.
2. The index as a preview of the next commit
Every entry in the index is literally what git commit turns into a tree object. There is no translation step in between: path, mode, and blob hash from the index are copied directly into the corresponding tree entries, split hierarchically by directory. Running git commit therefore does not produce a diff against the last state, it produces a complete, addressable snapshot of exactly the current index content.
The key efficiency gain: for files that have not changed since the last commit, Git creates no new blob. The index still carries the already-known hash for those paths, and that exact hash gets reused when building the new tree object. Only paths with genuinely changed content get a fresh blob. That makes the index not just a preview of the next commit, but the actual blueprint that git write-tree works through when committing, completely independent of how many files exist in the repository overall.
3. git add in detail: how content actually enters the index
git add technically does two independent things. First: it reads the current content of the file from the working directory, compresses it, and writes it as a new blob object into .git/objects, addressed by its SHA hash. This happens regardless of whether a commit ever follows. Second: it overwrites the matching index entry with that new hash plus current stat data such as file size, mtime, and inode number.
That stat data is not incidental, it is a deliberate optimization: a later git status does not need to rehash the file content if size and timestamp on disk match exactly what is stored in the index. Only on a mismatch does Git fall back to actual content hashing. On large Magento repositories with thousands of files, this stat cache is the reason git status answers in a fraction of a second instead of re-reading every file.
# git add: new blob object plus updated index entry
$ echo "<?php echo 'v2';" > app/code/Mironsoft/SeoSuite/Helper.php
$ git add app/code/Mironsoft/SeoSuite/Helper.php
$ git status --short
A app/code/Mironsoft/SeoSuite/Helper.php
# git ls-files -s: mode, blob hash, stage, path, straight from the index
$ git ls-files -s app/code/Mironsoft/SeoSuite/Helper.php
100644 8f94139338f9404f26296befa88755fc2598c289 0 app/code/Mironsoft/SeoSuite/Helper.php
# The blob already exists in the object store, even without a commit
$ git cat-file -p 8f94139338f9404f26296befa88755fc2598c289
<?php echo 'v2';
4. git status and git diff: the index as the pivot
git status is at its core a double comparison that uses the index as the pivot both times: once working directory against index, once index against HEAD. "Changes to be committed" lists the second comparison, "Changes not staged for commit" lists the first. For the first comparison, the stat cache from the previous section usually suffices; for the second, the index tree is actually resolved against the tree stored in the last commit.
The same two-way logic applies to git diff, except the command compares a different pair depending on the flag. git diff with no parameters compares working directory against index, exactly the changes a subsequent git add would capture. git diff --staged (alias --cached) instead compares index against HEAD, exactly what the next commit would actually record. Mixing the two up makes it look like there are no changes, even though the index has long since diverged from the last commit.
# git diff: working directory vs index
$ echo "<?php echo 'v3';" > app/code/Mironsoft/SeoSuite/Helper.php
$ git diff -- app/code/Mironsoft/SeoSuite/Helper.php
diff --git a/app/code/Mironsoft/SeoSuite/Helper.php b/app/code/Mironsoft/SeoSuite/Helper.php
index 8f94139..a1c2e3f 100644
--- a/app/code/Mironsoft/SeoSuite/Helper.php
+++ b/app/code/Mironsoft/SeoSuite/Helper.php
-<?php echo 'v2';
+<?php echo 'v3';
# git diff --staged: index vs HEAD (only what was already git add'ed)
$ git diff --staged -- app/code/Mironsoft/SeoSuite/Helper.php
diff --git a/app/code/Mironsoft/SeoSuite/Helper.php b/app/code/Mironsoft/SeoSuite/Helper.php
index e69de29..8f94139 100644
--- a/app/code/Mironsoft/SeoSuite/Helper.php
+++ b/app/code/Mironsoft/SeoSuite/Helper.php
-
+<?php echo 'v2';
5. Inspecting the index directly with git ls-files
git ls-files exposes the index without detours. The -s flag (or --stage) lists every entry exactly as it sits in the index: mode, blob hash, stage number, and path, separated by tabs. -u shows only unresolved merge conflicts with their multiple stage entries, -m lists paths whose working directory content, according to the stat comparison, differs from the index, and -o combined with --exclude-standard shows untracked files that appear neither in the index nor in .gitignore.
How the index technically interprets such comparisons additionally depends on core settings in the configuration. core.filemode determines whether pure permission changes (chmod +x) count as a modification at all, important on filesystems without real Unix permission bits, such as certain network mounts. core.ignorecase controls whether path comparisons ignore case, relevant for developers switching a checkout between macOS and Linux. sparseCheckoutCone finally influences which paths in the index are even expected to have a working-tree entry.
# .git/config: [core] settings that change how the index behaves
[core]
# If false, chmod +x on files is never recorded as a modification
# (important on filesystems without real Unix permission bits)
filemode = true
# If true, "APP.PHP" and "app.php" are treated as the same index path
# (relevant when switching a checkout between macOS and Linux)
ignorecase = false
# Enables sparse-checkout in cone mode: paths outside the cone stay
# in the index but are marked skip-worktree, no file on disk expected
sparseCheckoutCone = true
[extensions]
# Newer index format extension that speeds up status on large repos
worktreeConfig = true
6. Low-level access with git update-index
git update-index is the low-level interface scripts use to manipulate the index directly, without going through the working directory. With --add --cacheinfo <mode> <hash> <path>, an already existing blob object can be registered in the index, even when no matching file exists in the working tree at all. Merge drivers or build pipelines use this to insert generated artifacts into a commit deliberately, without ever writing them to disk temporarily.
For performance on large repositories, update-index offers two more switches: --assume-unchanged marks a path so that Git never even checks its stat data during a status check, practical for locally overridden configuration files that should never change in the repository itself. --skip-worktree goes a step further and is also used by sparse-checkout mechanisms to keep paths in the index without expecting them in the working directory at all. Both flags are purely local settings and are never committed.
# git update-index: insert a blob into the index without a worktree file
$ git hash-object -w --stdin <<< "return ['generated' => true];"
c7a1b2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9
$ git update-index --add --cacheinfo 100644 \
c7a1b2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9 \
generated/config-cache.php
$ git ls-files -s generated/config-cache.php
100644 c7a1b2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9 0 generated/config-cache.php
# git update-index: skip stat checks for a locally overridden file
$ git update-index --assume-unchanged src/app/etc/env.php
$ git status --short
# env.php no longer shows up as modified, even after local edits
# Undo it again when the file needs tracking normally
$ git update-index --no-assume-unchanged src/app/etc/env.php
7. Partial staging with git add -p
Partial staging with git add -p is the practical consequence of a single fact: the index is fully decoupled from the working directory and operates not on file granularity, but on blob granularity. The command splits every changed file into hunks, individual contiguous change blocks, and asks about each hunk separately: y stages the hunk, n skips it, s tries to split an oversized hunk into smaller pieces, and q aborts the entire session.
Technically, add -p builds an internal patch containing only the selected hunks, applies it via git apply --cached exclusively to the index, and leaves the working directory completely untouched. The result is a new blob that matches neither the old committed state nor the current file content on disk, but exactly the manually selected intermediate version. That's precisely what makes focused, topically clean commits possible out of a single, messy work session.
$ git add -p app/code/Mironsoft/SeoSuite/Model/MetaGenerator.php
diff --git a/.../MetaGenerator.php b/.../MetaGenerator.php
@@ -12,6 +12,9 @@ class MetaGenerator
public function generate(string $sku): string
{
+ // Bug fix: trim whitespace before length check
+ $sku = trim($sku);
+
Stage this hunk [y,n,q,a,d,s,e,?]? s
Split into 2 hunks.
@@ -12,3 +12,4 @@ class MetaGenerator
public function generate(string $sku): string
{
+ $sku = trim($sku);
Stage this hunk [y,n,q,a,d,j,J,g,/,e,?]? y
@@ -20,6 +21,9 @@ class MetaGenerator
return $meta;
}
+
+ // New feature: separate log entry, unrelated to the bug fix above
+ private function logGeneration(string $sku): void { /* ... */ }
Stage this hunk [y,n,q,a,d,j,J,g,/,e,?]? n
$ git diff --staged --stat
MetaGenerator.php | 1 +
1 file changed, 1 insertion(+)
8. The index during merge conflicts: stage 1, 2, and 3
During a merge conflict, the index briefly leaves its normal schema. Instead of a single entry per path at stage 0, it holds up to three entries simultaneously for each conflicted path: stage 1 for the common ancestor version (merge base), stage 2 for "ours", the current branch's version, and stage 3 for "theirs", the version from the branch being merged in. Only once a conflict is resolved and the file is captured again with git add do the stage 1 through 3 entries disappear in favor of a single stage 0 entry.
git ls-files -u makes exactly these three versions visible, each with its own blob hash. git checkout --ours <file> and git checkout --theirs <file> build on that: they copy the blob from stage 2 or stage 3, respectively, back into the working directory, without resolving the conflict in the index itself. Anyone who instead merges the content by hand still has to call git add at the end, because only that reduces the three competing stage entries back to the regular stage 0 state.
9. Index operations compared side by side
The table below lines up the index operations covered in this article: what each command reads, what it actually changes, and what it's typically used for in daily work. It makes visible that most confusion around the index ultimately comes down to a question of read versus write direction.
| Command | What it reads | What it writes | Typical use |
|---|---|---|---|
| git add <path> | Working directory | New blob + index entry (stage 0) | Stage changes for the next commit |
| git status | Working directory, index, HEAD | Nothing | Overview of all three trees at once |
| git diff | Working directory vs. index | Nothing | Preview what an add would capture |
| git diff --staged | Index vs. HEAD | Nothing | Preview what a commit would record |
| git ls-files -s | Index only | Nothing | Inspect raw index entries |
| git update-index --cacheinfo | Existing blob hash | Index entry directly | Script-driven index manipulation |
| git add -p | Working directory (per hunk) | New partial blob + index entry | Build focused, topically clean commits |
| git checkout --ours <path> | Stage 2 entry in the index | Working directory | Resolve a merge conflict in favor of your own version |
Internalizing this table reveals a recurring pattern: reading commands like status, diff, and ls-files never change the index itself, they only surface its current state. Writing commands like add, add -p, and update-index are the only ways the content of .git/index actually changes, regardless of whether the change comes through the working directory, a patch, or a directly supplied hash.
Mironsoft
Git workflows, code reviews, and CI/CD pipelines for PHP and Magento teams
Want cleaner commits through real index understanding?
We help development teams build focused commit histories, clean merge conflict resolution, and index-based pre-commit hooks, instead of using Git commands by feel.
Git internals training
Index, objects, and trees explained hands-on for development teams
Workflow audit
Analyze and optimize your existing staging and commit practices
CI/CD integration
Set up index-based pre-commit hooks and linting pipelines
10. Summary
The Git index is not a metaphor, it is a real binary file at .git/index with a documented format, a checksum, and sorted entries made of path, mode, blob hash, and stage number. git add actually writes a new blob into .git/objects and updates the matching index entry including its stat cache, long before any commit exists. git commit turns this exact index state directly into a tree object, with no intermediate step and without creating new blobs for unchanged files.
git status and git diff are ultimately just comparison operations between working directory, index, and HEAD, while git ls-files and git update-index allow direct read and write access to the index itself, independent of the working directory. That exact decoupling is what makes git add -p possible, and it explains why the index can briefly hold up to three versions of the same path at once during a merge conflict, addressed via stage numbers 1, 2, and 3.
The Git Index at a Glance
Index format
.git/index is a binary, versioned file with a DIRC signature, a checksum, and sorted entries of path, mode, hash, and stage.
git add
Writes a new blob into .git/objects and updates the matching index entry including its stat cache.
Inspection
git ls-files -s/-u/-m/-o and git update-index make the index directly visible and editable.
Merge conflicts
Stage 1 through 3 store base, ours, and theirs at the same time, until git add collapses them back to stage 0.