Git’s behind-the-scenes mechanics: index, stash, refs, and merge diffs

Digging into Git’s internals often surfaces details that are obvious in hindsight but rarely considered. A few of these stand out when you start explaining how the tool really works—particularly around the index, the stash, references, and merge commits.

The index, staging area, and --cached are one and the same

Running git add file.txt followed by git status shows the familiar output:

$ git add content/post/2023-10-20-some-miscellaneous-git-facts.markdown
$ git status
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
	new file:   content/post/2023-10-20-some-miscellaneous-git-facts.markdown

That state is commonly called the “staging area,” but Git itself uses three interchangeable names for it: git diff --cached, git diff --staged, and the file .git/index. When you stage a file, Git writes it into the object database under .git/objects and updates .git/index to point to that new object. All three terms refer to that same underlying index file.

The stash is just a set of commits

It’s easy to wonder where changes go when you run git stash. The answer: Git creates commits for your changes and labels them with a reference named stash, stored at .git/refs/stash. Viewing the log for that reference shows what happened:

$ git log stash --oneline
6cb983fe (refs/stash) WIP on main: c6ee55ed wip
2ff2c273 index on main: c6ee55ed wip
... some more stuff

Inspecting the commit it points to confirms the stash contains your work-in-progress:

$ git show 2ff2c273  --stat
commit 2ff2c273357c94a0087104f776a8dd28ee467769
Author: Julia Evans <[email protected]>
Date:   Fri Oct 20 14:49:20 2023 -0400

    index on main: c6ee55ed wip

 content/post/2023-10-20-some-miscellaneous-git-facts.markdown | 40 ++++++++++++++++++++++++++++++++++++++++

Notably, git stash doesn’t create a single commit—it creates two: one for the index and one for unstaged changes. Older stash entries are kept in the reflog.

References extend beyond branches and tags

Most of the time, a Git “reference” means a branch, HEAD, or a tag. But the stash is a concrete example of a reference that is none of those. In a typical repository, the full list of refs (excluding HEAD) can look like this:

$ find .git/refs -type f
.git/refs/heads/main
.git/refs/remotes/origin/HEAD
.git/refs/remotes/origin/main
.git/refs/stash

Other useful references that aren’t branches or tags include:

  • refs/notes/*, used by git notes
  • refs/pull/123/merge, for GitHub pull requests (fetchable with git fetch origin refs/pull/123/merge)
  • refs/bisect/*, used by git bisect

Merge commits aren’t really empty

In a toy repository with two branches, each containing a single file (x.txt and y.txt), merging them produces a commit that looks like this:

$ git log --oneline
96a8afb (HEAD -> y) Merge branch 'x' into y
0931e45 y
1d8bd2d (x) x

Running git show on that merge commit shows no diff:

git show 96a8afb
commit 96a8afbf776c2cebccf8ec0dba7c6c765ea5d987 (HEAD -> y)
Merge: 0931e45 1d8bd2d
Author: Julia Evans <[email protected]>
Date:   Fri Oct 20 14:07:00 2023 -0400

    Merge branch 'x' into y

But diffing the merge commit against each parent individually reveals the expected changes:

$ git diff 0931e45 96a8afb   --stat
 x.txt | 1 +
 1 file changed, 1 insertion(+)
$ git diff 1d8bd2d 96a8afb   --stat
 y.txt | 1 +
 1 file changed, 1 insertion(+)

The reason merge commits appear empty is that Git’s default merge diff only shows conflicts. In a repo where two branches add different content to the same file and the merge is resolved manually, the merge commit diff shows exactly that resolution:

$ git show HEAD
commit 3bfe8311afa4da867426c0bf6343420217486594
Merge: 782b3d5 ac7046d
Author: Julia Evans <[email protected]>
Date:   Fri Oct 20 15:29:06 2023 -0400

    Merge branch 'x' into y

diff --cc file.txt
index 975fbec,587be6b..b680253
--- a/file.txt
+++ b/file.txt
@@@ -1,1 -1,1 +1,1 @@@
- y
 -x
++z

If there is no conflict, Git simply shows nothing. The merge commit is still a full snapshot of the repository—it just doesn’t display a diff unless there was something to resolve.