Git as a graph

Commits, trees, blobs, refs, the index, and why understanding the graph makes every command obvious.

7 min read🐙 Git, Maven and Gradle

Most people learn Git as a list of commands to type when they want something to happen: add, commit, push, and a memorised incantation for when it goes wrong. That works until the day it does not, and then there is nothing to reason from.

There is a smaller thing to learn instead. Git stores snapshots in a graph, and every command you will ever run either adds a node to that graph or moves a pointer around in it. Learn the graph and the commands stop needing to be memorised, because you can work out what each one must do.

A repository is a graph, not a folder of versions

The mental model most beginners arrive with is a stack of saved versions, like a document's revision history. It is the wrong shape, and it makes branch and merge look like magic.

What Git actually keeps is a set of objects, each named by the hash of its own contents, plus a handful of tiny files that point into that set. Nothing is ever edited. New objects are added, and pointers move.

That single design decision explains almost everything else: why a commit hash changes when you amend it, why a branch is instant to create, why rebasing produces different commits, and why almost nothing you do is actually destructive.

Three kinds of object, and one of them is your file

Start with a file. Git turns its contents into an object called a blob:

plaintext
$ echo "hello" > greeting.txt
$ git hash-object greeting.txt
ce013625030ba8dba906f756967f9e9ca394464a

Now the part worth pausing on. That hash is not a property of your repository, or your machine, or the file's name. It is a property of the bytes:

plaintext
$ echo "hello" | git hash-object --stdin
ce013625030ba8dba906f756967f9e9ca394464a
 
$ echo "hello" > copy.txt
$ git hash-object copy.txt
ce013625030ba8dba906f756967f9e9ca394464a

Same content, different name, same object. Every Git repository on earth holding a file whose content is hello and a newline is storing it under that exact hash. This is why copying a file costs Git nothing, and why a rename is not stored as a rename — Git works it out afterwards by noticing the same blob under a new name.

A blob has no name and no path. So a second object type holds those: a tree is a directory listing that maps names to blobs and to other trees.

plaintext
$ git cat-file -p HEAD^{tree}
100644 blob ce013625030ba8dba906f756967f9e9ca394464a	greeting.txt

And a commit points at one tree, plus the commit or commits that came before it:

plaintext
$ git cat-file -p HEAD
tree 57e9529754dc514a3ec10db2ff882018fbe1fcbf
author Demo <a@b.c> 1767241800 +0530
committer Demo <a@b.c> 1767241800 +0530
 
Add a greeting

That is a whole commit. A pointer to a complete snapshot of the project, a parent, two names with timestamps, and a message. The commit above has no parent line, which is exactly what makes it a root commit — and why there is nothing before it to move back to.

Notice also that author and committer are separate fields. They are usually the same person, and they stop being the same the moment somebody rebases or cherry-picks your work: the author stays you, the committer becomes them.

A branch is a file with forty characters in it

Here is where the mystery usually is, and here is the whole answer:

plaintext
$ cat .git/refs/heads/main
0ca842bc5ee2e0977c1f3e8305290a629e143abd

That is the branch. Not a copy of the code, not a directory, not a container that commits live inside. A file holding one hash. Creating a branch writes forty-one bytes, which is why it is instant — and why "branch less, branches are expensive" is advice imported from a different version-control system.

HEAD is one more pointer, and it usually points at a branch rather than at a commit:

plaintext
$ cat .git/HEAD
ref: refs/heads/main

Read those two files together and you can state what a commit does in one sentence: write the objects, then rewrite the file that HEAD names. Everything in Git that looks like it is moving your code is moving a pointer.

The index is the third state, and it is why status has two columns

Between the files you are editing and the last commit there is a third place, and most confusion about git status is really confusion about this. The index — also called the staging area — holds the version your next commit will contain.

So a file has three versions at once, and git status reports two comparisons in two columns:

plaintext
$ echo "hello there" > greeting.txt
$ git status --short
 M greeting.txt
 
$ git add greeting.txt
$ git status --short
M  greeting.txt

The letter did not change. The column did. The right column is working tree versus index; the left is index versus last commit. git add did not save anything permanently; it moved the change one column to the left.

The two diff commands split along the same line, and this is the answer to "why does git diff show nothing when I clearly changed something":

plaintext
$ git diff --stat
                       ← nothing: the working tree now matches the index
 
$ git diff --cached --stat
 greeting.txt | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
edit the filegit addgit commitgit branch fixgit switch fix
new objectsnoneHEAD points atmain → commit Agit statusM in the right column

edit the file. You change the bytes on disk. Git has done nothing at all — it does not watch your editor, and it has not stored this version anywhere. If the disk died now, the change is gone.

1 / 5

Detached HEAD is not an error

Check out a commit instead of a branch and Git prints a wall of text that reads like a warning. It is not one. Look at what actually changed:

plaintext
$ git checkout 0ca842b
$ cat .git/HEAD
0ca842bc5ee2e0977c1f3e8305290a629e143abd

HEAD now holds a hash directly instead of ref: refs/heads/main. That is the entire meaning of "detached": HEAD is not pointing at a branch, so there is no branch file to rewrite when you commit.

Which is the real risk, and it is worth stating precisely: commits you make here are perfectly valid objects, but nothing points at them. Switch away and they become unreachable — not deleted, but with no name to find them by. If you made something you want, give it a name before you leave:

bash
git switch -c experiment    # names the commit you are sitting on

The reflog is the undo you did not know you had

Because Git only ever adds objects and moves pointers, "losing work" almost always means a pointer moved and you do not know where it was before. Git records every move of HEAD:

plaintext
$ git reset --hard HEAD~1
$ git log --oneline
0ca842b Add a greeting
 
$ git reflog
0ca842b HEAD@{0}: reset: moving to HEAD~1
22e4f03 HEAD@{1}: commit: Say more
0ca842b HEAD@{2}: commit (initial): Add a greeting

git log says the commit is gone. It is not gone — the branch merely stopped pointing at it. 22e4f03 is right there, and git reset --hard 22e4f03 puts it back.

This is the most valuable paragraph in this lesson for the day something goes wrong. A hard reset, a bad rebase, a branch deleted too early, an amend that swallowed a commit — in every one of them the objects still exist and the reflog holds the hash. Entries do expire (ninety days for reachable commits, thirty for unreachable ones, by default), so the window is long but not infinite.

Progress is saved on this device and to your account when signed in.