Inside a Git Commit: Snapshots, Objects and Hashes
We open commit, tree and blob objects with git cat-file to see that a commit is a snapshot, not a diff, compute a hash by hand with shasum and find out why an unchanged file is not stored twice. We cover the rules of a good commit message and why git commit --amend creates a new commit.
Version note: The commands in this post were tested with Git 2.54.
Guess First
In the previous post we made two commits in the bookstore repo. The second commit did not touch src/books.js at all. Do you think Git stores that file a second time for the second commit? And one more: if you fix a single letter in the last commit's message, does the commit's hash stay the same?
Both answers depend on what is inside a commit. In this post we lift the lid on the .git/objects folder and take a commit apart. Then we look at how to write a good commit message and how to fix the last commit with --amend.
A Snapshot, Not a Diff
Many version control tools think of history as a list of changes: the first version, then these lines added, then those lines removed. Git thinks differently. Every commit is a full picture of the project at that moment, a snapshot. A commit does not say "a line was added to the README"; it says "this is what the README contains right now".
That does not mean every file is copied in every commit. For a file that has not changed, Git does not create a new copy; it points to the copy from the previous commit. Think of a class photo album: every page shows the whole class, but for the faces that stayed the same, no new print was made, the same photo was glued in again. The changes you see with git log -p or git diff are not stored anywhere; Git computes them on the spot by comparing two snapshots.
Opening a Commit Object
Everything Git stores is an object, and every object has a hash. git cat-file is a low-level command for reading these objects: -t shows the type, -p shows the content.
$ git log --oneline
fae4454 (HEAD -> main) Add shopping cart
29f27c8 Add README and book list
$ git cat-file -t HEAD
commit
$ git cat-file -p HEAD
tree c93a32e8f9f704b6eda3f36f76f4e561e3371ff9
parent 29f27c8b6b3bb1a9abea102641c7bfb23f90d575
author Ada Lovelace <ada@example.com> 1794034860 +0300
committer Ada Lovelace <ada@example.com> 1794034860 +0300
Add shopping cart
What we call a commit is this small piece of text. The tree line points to the snapshot of the project's root folder. parent is the full hash of the previous commit; this is the line that chains history together. author is the person who wrote the change, committer the person who created the commit. Most of the time they are the same; they differ when you apply someone else's patch or rewrite a commit. The number after the name is the date: seconds since January 1, 1970 (Unix time) plus the time zone. At the bottom, after an empty line, comes the commit message.
Notice that the commit contains no file content and no diff. A commit only ties together one tree, a parent, and the who, when and why.
Trees and Blobs
Let's open the tree the commit points to. A short hash is enough; Git accepts the first few characters as long as they match a single object in the repo:
$ git cat-file -t c93a32e
tree
$ git cat-file -p c93a32e
100644 blob 62ada8e0411d44b007ad18b3bb6ce007b86766ab README.md
040000 tree c0ab695ca8c78ad42a7555e7b0b13426040d070b src
$ git cat-file -p c0ab695
100644 blob 4426e0f1682977f267f15efbfa866d3ce583ad6f books.js
100644 blob 4dd8f15e0a1d5ecb79a472f73d915bbbfd78d84f cart.js
A tree is the snapshot of a folder. Each line is an entry: mode, type, hash and name. 100644 means a regular file, 040000 a subfolder; an executable script would show 100755. The root tree points to a blob for README.md and to another tree for src. The src tree in turn points to the blobs of two files. The folder structure is built from trees nested inside each other.
$ git cat-file -t 4426e0f
blob
$ git cat-file -p 4426e0f
export const books = [
{ id: 1, title: "Dune", price: 12.5 },
{ id: 2, title: "Neuromancer", price: 9.99 },
];
$ git hash-object src/books.js
4426e0f1682977f267f15efbfa866d3ce583ad6f
A blob is a file's content, and only its content. The file name is not in the blob; the tree above it holds that. git hash-object computes the hash a file would get in Git. The hash for src/books.js on disk is exactly the same as the blob's hash in the tree.
Where the Hash Comes From
A hash is not a random ID; it is a fingerprint of the content. Git puts a small header with the object's type and size in front of the content and takes the SHA-1 digest of the result:
# The size of the content in bytes
$ git cat-file -s 4426e0f
115
# Compute the same hash by hand, without Git
$ (printf 'blob 115\0'; cat src/books.js) | shasum
4426e0f1682977f267f15efbfa866d3ce583ad6f -
Without asking Git at all, using only shasum, we arrived at the same 4426e0f... value. That has two important consequences. First, the same content gets the same hash anywhere in the world; that is why Git is called "content-addressable". If you know the content, you know its address. Second, if a single byte changes, the hash changes completely. When a file changes, its blob changes; when the blob changes, the content of the tree pointing to it changes; when the tree changes, the content of the commit changes. A commit's hash is a fingerprint of the entire history up to that commit. Nobody can change history without it being noticed.
In everyday work you rarely need the full hash:
$ git rev-parse HEAD
fae445405fbc78d83b054ec05f64c00d259a3ee0
$ git rev-parse --short HEAD
fae4454
git rev-parse turns a name like HEAD into a full hash, and --short gives the shortest form that is still unique in the repo. Git uses SHA-1 by default today. You can also create a repo with SHA-256, and it is planned to become the default for new repos in Git 3.0; we will talk about that in the version posts at the end of the series.
The Parent Chain and the Opening Question
HEAD~1 means the commit before HEAD. Let's look at the first commit:
$ git cat-file -p HEAD~1
tree b269fa4063053158c295b245671c0c8b9785ddb9
author Ada Lovelace <ada@example.com> 1794034800 +0300
committer Ada Lovelace <ada@example.com> 1794034800 +0300
Add README and book list
$ git cat-file -p b269fa4
100644 blob 0c0f2aa13b8a7f7fdc76bdcdaa5ced0014200f01 README.md
040000 tree 8610f9abc29a16233d17d31ba7aa4646ec6be446 src
$ git cat-file -p 8610f9a
100644 blob 4426e0f1682977f267f15efbfa866d3ce583ad6f books.js
The first commit has no parent line; that is what the root-commit from the previous post means. Now look at the books.js line: 4426e0f.... It is the same hash as in the second commit's src tree. That answers the first opening question: the unchanged file was not stored a second time; both commits' trees point to the same blob. README.md changed, so its blob differs between the two commits.
Let's count the objects in the repo:
$ git count-objects
11 objects, 44 kilobytes
$ git fsck --dangling
dangling blob 1db6c00f7df85da2ae0c0b97dc7e98c7bdab3e81
$ git cat-file -p 1db6c00
# Bookstore
A tiny bookstore app for the Git & GitHub series.
Books live in src/books.js.
Each of the two commits has five objects: the commit, the root tree, the src tree and two blobs. But the count is 11, not 10. The extra one is left over from the MM moment in the previous post: the in-between version of the README with only the first added line. When git add puts a file into the staging area, it writes the content into objects as a blob right away. When we added the file again, no tree pointed to that blob anymore; git fsck calls it "dangling", meaning orphaned. Git's housekeeping removes objects like this over time.
The answer to the second question is clear now too. The commit message lives inside the commit object. Change a single letter and the content changes; change the content and the hash changes. So you cannot "edit" a commit; you can only put a new commit in its place. In a moment we will see that this is exactly what --amend does.
A Good Commit Message
Hashes are for machines, messages are for people. The person reading git log six months from now is usually you. First, a few bad and good examples:
# Bad: says nothing about what changed or why
fix
update files
WIP
Added some stuff to cart and also fixed README typo
# Good: short, imperative, about one thing
Fix rounding in cartTotal
Add The Hobbit to the book list
Remove unused leftPad dependency
These are the established rules. The first line is a summary line and is kept short; about 50 characters is a good target. git log --oneline, GitHub's commit list and many tools show only this line. The summary line is written in the imperative mood: not "Added" or "Adds", but "Add". Think of it as finishing the sentence "if applied, this commit will ...": "if applied, this commit will Fix rounding in cartTotal". The messages Git generates itself follow this form too: Merge branch ..., Revert ....
If more detail is needed, leave an empty line after the summary and write a body. The body explains the "why" more than the "what"; the diff already shows what changed. Wrapping body lines at around 72 characters makes them easier to read in a terminal. We write messages in English: teams, open source projects and tools expect it, and the code is in English anyway. Each commit should be about one thing; if your message contains "and also", it is probably two commits.
Let's add a function that computes the cart's total price:
export function cartTotal(cart) {
return cart.reduce((sum, book) => sum + book.price, 0);
}
You can write a short body by passing -m twice; each -m becomes a separate paragraph. For longer messages it is more comfortable to run git commit without -m and write in the editor we set up in the previous post.
# After adding the cartTotal function to src/cart.js
$ git add src/cart.js
$ git commit -m "Add cart total" -m "Sum the book prices in the cart. Rounding is left to the UI for now."
[main dd0de64] Add cart total
1 file changed, 4 insertions(+)
$ git log -1
commit dd0de649e7e2f53b206fcae4cfdfda7528d0a3e9 (HEAD -> main)
Author: Ada Lovelace <ada@example.com>
Date: Sat Nov 7 10:02:00 2026 +0300
Add cart total
Sum the book prices in the cart. Rounding is left to the UI for now.
Fixing the Last Commit: --amend
We made the commit and then realized we forgot to mention cartTotal() in the README. Instead of a separate "Update README" commit, we can fix the last commit:
$ git log --oneline
dd0de64 (HEAD -> main) Add cart total
fae4454 Add shopping cart
29f27c8 Add README and book list
# We forgot to update the README; add the line and fold it into the same commit
$ git status -s
M README.md
$ git add README.md
$ git commit --amend --no-edit
[main e3ef418] Add cart total
Date: Sat Nov 7 10:02:00 2026 +0300
2 files changed, 5 insertions(+)
$ git log --oneline
e3ef418 (HEAD -> main) Add cart total
fae4454 Add shopping cart
29f27c8 Add README and book list
--amend takes what is in the staging area and puts a new commit in place of the last one. --no-edit keeps the message as it is; if you want to change the message too, run git commit --amend -m "...", and with an empty staging area only the message changes. Look at the hash: dd0de64 is gone and e3ef418 took its place. This is not an "edit", it is a new commit:
$ git log -1 --format=fuller
commit e3ef4186cc77d8cba596c0d4a7a5b7b0fc430768 (HEAD -> main)
Author: Ada Lovelace <ada@example.com>
AuthorDate: Sat Nov 7 10:02:00 2026 +0300
Commit: Ada Lovelace <ada@example.com>
CommitDate: Sat Nov 7 10:03:00 2026 +0300
Add cart total
Sum the book prices in the cart. Rounding is left to the UI for now.
# The old commit was not deleted; no branch points to it anymore
$ git cat-file -t dd0de64
commit
--format=fuller shows the two dates separately. AuthorDate was kept as the moment the change was first written; CommitDate is the moment of the amend. The old dd0de64 is still in the repo, but main no longer points to it. We will see how to find it again after a bad amend in the reflog post.
The rule is simple: use --amend only on commits you have not shared yet, that is, commits you have not pushed. If you amend a commit you already pushed, your history and your teammates' history diverge, because the commit they have no longer exists in yours.
Common Pitfalls
The first pitfall is believing Git stores diffs. That wrong model makes it harder later to understand why rebase or cherry-pick produce "new" commits. Keep this in mind: a commit is a snapshot, and every commit whose parent changes gets a new hash.
The second is amending a commit that was already pushed. Everything looks fine locally, but your next push is rejected and your history forks away from your teammate's. The safe way to fix a shared mistake is to make a new commit.
The third is thinking --amend only changes the message. Whatever is waiting in the staging area goes into the commit. If you don't look at git status before amending, you can accidentally bury a change that belonged in a separate commit.
The fourth is getting by with one-word messages like "fix", "update" or "WIP". They save time in the moment, but they make git log useless when you are hunting for the commit that introduced a bug.
A Small Experiment
★☆☆ In your own repo, start with git cat-file -p HEAD and follow the tree, the subtree and the blobs until you reach the content of src/cart.js. How many steps did it take?
★★☆ Find the size of the README.md blob with git cat-file -s and compute its hash by hand with the printf 'blob SIZE\0' trick (put the number you found in place of SIZE). Compare it with git hash-object README.md. Then add a single space to the README and compute the hash again.
★★★ Change the last commit's message with git commit --amend -m "...". Write down the hashes before and after, and compare the two commit objects with git cat-file -p. Which lines stayed the same and which changed? Can you explain why the tree line stayed the same?
In the next post we deal with files that should not get into the repo: .gitignore patterns, exceptions with !, global excludes, git check-ignore -v, untracking an already committed file with git rm --cached, git mv, and line endings with .gitattributes.