Git & GitHub Module 10 – Git internals
Git objects: blobs, trees, commits and tags
Compute a Git object ID by hand, walk from a commit to its trees and blobs with git cat-file, see the rules trees follow, and what SHA-256 changes.
What you will learn
- Inspect objects with git cat-file and create them with git hash-object
- Compute a blob's object ID by hand and match Git's result
- Explain how trees and commits link into a content-addressed graph
- Describe SHA-1 hardening, the SHA-256 object format and Git 3.0's planned default
Before you start
What you’ll learn · Before you start
What you will learn
- Inspect objects with git cat-file and create them with git hash-object
- Compute a blob's object ID by hand and match Git's result
- Explain how trees and commits link into a content-addressed graph
- Describe SHA-1 hardening, the SHA-256 object format and Git 3.0's planned default
Before you start
On this page
Git keeps everything it records as objects in .git/objects, and there are only four kinds. A blob holds the
bytes of one file. A tree lists one folder: a mode, a name and an object ID for each entry. A commit names a
root tree, its parent commits, an author and a committer with their times, and a message. A tag object names
another object, usually a commit, with a tag name, a tagger and a message. Every object’s ID is a hash of its type,
its size and its content, so equal content always gets an equal ID and the smallest change gets a new one. This
lesson takes the four apart in real sessions: it computes an ID without Git, opens the file Git writes, reads a tree
byte by byte, follows a one-byte edit up to the commit, damages an object on purpose to see what notices, and makes a
repository that uses SHA-256.
Four kinds of object, one store
The objects form a graph that only points backwards in time. A commit points to the tree of its snapshot and to the
commits it was made on top of. A tree points to the blobs of its files and to the trees of its subfolders. A tag
object points to whatever it tags. Branch and tag names such as main or v1 are not objects at all: they are
references, small records that hold an object’s ID, and the next lesson of this module shows where Git keeps
them.
The four kinds of object behind one tagged commit
Text description of the diagram
The diagram shows the repository of the tags session, from the names at the top to the file content at the bottom.
- refs/tags/v1 is a name, not an object. It holds the ID of the tag object 6adbce0.
- The tag object 6adbce0 records the object it tags, that object's type, the tag's name, the tagger with a time, and the message "Menu for term 1". It names commit 873972d.
- refs/tags/term-1 is a lightweight tag: a name that holds the ID of commit 873972d directly, with no tag object between them.
- Commit 873972d records its root tree, the author and the committer with their times, and the message "First menu". It has no parent because it is the first commit.
- Tree 6aef014 has one entry: the mode 100644, the name menu.txt and the ID of blob 6b25698.
- Blob 6b25698 holds the eight bytes "idli 30" and a newline, and nothing else: no name, no mode, no time.
Because an ID is computed from content, Git calls its store content-addressed: you look an object up by what it
is, not by where it was put. That has a consequence you will meet in every section of this lesson. An object cannot
be edited in place, because different bytes would need a different name, so objects are written once and never
changed. “Changing” a commit, as git commit --amend does, always means writing a new one.
- Blob: the bytes of one file and nothing else; it points to nothing.
- Tree: one entry per name, each with a mode and an ID; it points to blobs, to trees and, for submodules, to commits.
- Commit: a tree, its parents, an author, a committer and a message; it points to one tree and to any number of parent commits.
- Tag object: the tagged object and its type, a tag name, a tagger and a message; it points to any object, usually a commit.
git add and git hash-object -w write blobs. git commit writes the trees and the commit, which git write-tree,
git mktree and git commit-tree can also do one step at a time, and git tag -a writes a tag object.
An object ID is a hash of a header and the content
The canteen’s menu starts as one line, idli 30. This session asks Git for its ID, stores it, and then looks at what
Git actually wrote:
from gitsession import session
session(r"""
git config set --global user.name "Ravi Kumar"
git config set --global user.email "ravi@example.com"
git init -q -b main canteen
cd canteen
# Work out an ID without storing anything
printf 'idli 30\n' | git hash-object --stdin
# -w stores the object too, in a file named after its ID
printf 'idli 30\n' > menu.txt
git hash-object -w menu.txt
find .git/objects -type f
# Ask Git what the object is, how big it is, and what it holds
git cat-file -t 6b25698
git cat-file -s 6b25698
git cat-file -p 6b25698
# On disk: the header, a NUL byte and the content, compressed with zlib
python3 ../../objtools.py inflate .git/objects/6b/256986d67fde7e02accec2d83df50e4f137850
# A second file with the same bytes is the same object, so nothing new is stored
cp menu.txt today.txt
git add menu.txt today.txt
find .git/objects -type f
git ls-files --stage
# Git counts bytes: the rupee sign takes 3 of them
printf 'chai ₹10\n' | git hash-object --stdin
""") Output
$ git config set --global user.name "Ravi Kumar" $ git config set --global user.email "ravi@example.com" $ git init -q -b main canteen $ cd canteen # Work out an ID without storing anything $ printf 'idli 30\n' | git hash-object --stdin 6b256986d67fde7e02accec2d83df50e4f137850 # -w stores the object too, in a file named after its ID $ printf 'idli 30\n' > menu.txt $ git hash-object -w menu.txt 6b256986d67fde7e02accec2d83df50e4f137850 $ find .git/objects -type f .git/objects/6b/256986d67fde7e02accec2d83df50e4f137850 # Ask Git what the object is, how big it is, and what it holds $ git cat-file -t 6b25698 blob $ git cat-file -s 6b25698 8 $ git cat-file -p 6b25698 idli 30 # On disk: the header, a NUL byte and the content, compressed with zlib $ python3 ../../objtools.py inflate .git/objects/6b/256986d67fde7e02accec2d83df50e4f137850 b'blob 8\x00idli 30\n' # A second file with the same bytes is the same object, so nothing new is stored $ cp menu.txt today.txt $ git add menu.txt today.txt $ find .git/objects -type f .git/objects/6b/256986d67fde7e02accec2d83df50e4f137850 $ git ls-files --stage 100644 6b256986d67fde7e02accec2d83df50e4f137850 0 menu.txt 100644 6b256986d67fde7e02accec2d83df50e4f137850 0 today.txt # Git counts bytes: the rupee sign takes 3 of them $ printf 'chai ₹10\n' | git hash-object --stdin d8208b0376c1698fde9f367f0b4d728ca6b56a86
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 store.py
git hash-object --stdin only computes: it printed 6b25698… and stored nothing. With -w the same bytes became a
file, .git/objects/6b/256986…: the first two hexadecimal characters of the ID name a folder, and the other 38 name
the file, which keeps any one folder from holding millions of entries. git cat-file answers three questions about
any object by its ID, or by an unambiguous prefix such as 6b25698: -t gives the type, -s the size in bytes and
-p the content in a readable form.
The inflate step decompressed the file and printed it as Python bytes: blob 8, a NUL byte, then the eight bytes of
idli 30 and its newline. That is the whole loose-object format. The header is the type, a space and the size as
decimal digits; the ID is the SHA-1 of the header and the content together, before compression; and the file on disk
is that same byte string compressed with zlib.
Two details in the rest of the session matter later. today.txt, a copy of the menu, added nothing to the store:
git ls-files --stage shows two names with one ID, because a blob has no name and no mode, only bytes. Names and
modes live in trees. And the header counts bytes, not characters: chai ₹10 is nine characters, but the rupee sign
takes three bytes in UTF-8, so its blob is eleven bytes long.
Nothing in that recipe needs Git. This program builds the same header and hashes it with Python’s hashlib; it gets
the same IDs, and it runs in your browser as well:
"""Git object IDs computed from bytes alone, with no Git at all (so this also runs in the browser)."""
import hashlib
def object_id(kind, content, algorithm="sha1"):
"""The ID Git gives an object: a hash of "<kind> <size in bytes>", one NUL byte, then the content."""
header = f"{kind} {len(content)}\0".encode()
return hashlib.new(algorithm, header + content).hexdigest()
menu = b"idli 30\n"
print("what is hashed:", b"blob %d\0" % len(menu) + menu)
print("SHA-1 ID: ", object_id("blob", menu))
print("SHA-256 ID: ", object_id("blob", menu, "sha256"))
print("empty file: ", object_id("blob", b""))
price = "chai ₹10\n"
data = price.encode("utf-8")
print(f"{price!r} is {len(price)} characters but {len(data)} bytes; Git counts bytes:")
print("SHA-1 ID: ", object_id("blob", data)) Output
what is hashed: b'blob 8\x00idli 30\n' SHA-1 ID: 6b256986d67fde7e02accec2d83df50e4f137850 SHA-256 ID: 997c288fc20e4b2806d6d65680da135dfa4633ddfe1fde17694a7ef7e167860c empty file: e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 'chai ₹10\n' is 9 characters but 11 bytes; Git counts bytes: SHA-1 ID: d8208b0376c1698fde9f367f0b4d728ca6b56a86
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 object_ids.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The empty-file ID, e69de29…, is worth recognising: every SHA-1 repository that tracks an empty file holds that same
blob. The SHA-256 line is the ID the same blob gets in a SHA-256 repository, which the last session of this lesson
creates. The same recipe in Node.js shows the classic bug of building the header from a string’s length:
// The same IDs from Node.js. The header holds the size in bytes, which is not always the string's length.
import { createHash } from 'node:crypto';
const objectId = (kind, bytes) =>
createHash('sha1').update(Buffer.concat([Buffer.from(`${kind} ${bytes.length}\0`), bytes])).digest('hex');
console.log('idli 30:', objectId('blob', Buffer.from('idli 30\n')));
const price = 'chai ₹10\n';
console.log('bytes: ', objectId('blob', Buffer.from(price)));
// A common bug: price.length counts UTF-16 code units (9), not bytes (11), so the header says the wrong size.
const wrong = createHash('sha1').update(`blob ${price.length}\0${price}`).digest('hex');
console.log('length: ', wrong, '(wrong: the header says 9)'); Output
idli 30: 6b256986d67fde7e02accec2d83df50e4f137850 bytes: d8208b0376c1698fde9f367f0b4d728ca6b56a86 length: 25efef759e5199357c732572c4124fa2c7073d71 (wrong: the header says 9)
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node object_ids.mjs
price.length is 9, because JavaScript counts UTF-16 code units, while the content is 11 bytes. The header then
claims the wrong size, and the program prints an ID that Git never produces for that file. Hash bytes, and take the
size from the bytes.
Note
git hash-object <file> treats a file the way git add would, so line-ending rules from .gitattributes can change
the bytes it hashes. Content given with --stdin is hashed exactly as it arrives, unless you also pass --path.
Add --no-filters to hash a file’s raw bytes.
Trees: names, modes and IDs
A tree turns blobs into a folder. This session commits a file, an executable script, a symbolic link, a subfolder and a file whose name starts like the subfolder’s, then reads the trees back:
from gitsession import session
session(r"""
git config set --global user.name "Ravi Kumar"
git config set --global user.email "ravi@example.com"
git init -q -b main canteen
cd canteen
printf 'idli 30\n' > menu.txt
printf '#!/bin/sh\necho "The canteen is open"\n' > open.sh
chmod +x open.sh
ln -s menu.txt today.txt
mkdir snacks
printf 'samosa 15\n' > snacks/fried.txt
printf 'chai 10\n' > snacks.txt
git add .
git commit -q -m "First menu"
# The root tree: one line per entry, with its mode, type, ID and name
git cat-file -p 'HEAD^{tree}'
# A folder is a tree of its own
git cat-file -p HEAD:snacks
# A symbolic link is a blob that holds the path it points to
git cat-file blob HEAD:today.txt | python3 ../../objtools.py bytes
# The tree object itself is binary
git cat-file tree 'HEAD^{tree}' | python3 ../../objtools.py show-tree
""") Output
$ git config set --global user.name "Ravi Kumar"
$ git config set --global user.email "ravi@example.com"
$ git init -q -b main canteen
$ cd canteen
$ printf 'idli 30\n' > menu.txt
$ printf '#!/bin/sh\necho "The canteen is open"\n' > open.sh
$ chmod +x open.sh
$ ln -s menu.txt today.txt
$ mkdir snacks
$ printf 'samosa 15\n' > snacks/fried.txt
$ printf 'chai 10\n' > snacks.txt
$ git add .
$ git commit -q -m "First menu"
# The root tree: one line per entry, with its mode, type, ID and name
$ git cat-file -p 'HEAD^{tree}'
100644 blob 6b256986d67fde7e02accec2d83df50e4f137850 menu.txt
100755 blob 5b0c1d152b0cc504560f18ed3a203a6ee850c8b8 open.sh
100644 blob b9f8826a6646d5320cff6699abba8d6756de901f snacks.txt
040000 tree f7e0565d4fc07e413ebda309a380c57047f7cad7 snacks
120000 blob bfaaad8be5cbb22838cc83fc02d461b0c4992730 today.txt
# A folder is a tree of its own
$ git cat-file -p HEAD:snacks
100644 blob d1c2122f53808c83016527d5afaafbd862d1c5a8 fried.txt
# A symbolic link is a blob that holds the path it points to
$ git cat-file blob HEAD:today.txt | python3 ../../objtools.py bytes
b'menu.txt'
# The tree object itself is binary
$ git cat-file tree 'HEAD^{tree}' | python3 ../../objtools.py show-tree
179 bytes; each entry is <mode> <name>, a NUL byte, then the ID as 20 raw bytes
100644 menu.txt 6b256986d67fde7e02accec2d83df50e4f137850
100755 open.sh 5b0c1d152b0cc504560f18ed3a203a6ee850c8b8
100644 snacks.txt b9f8826a6646d5320cff6699abba8d6756de901f
40000 snacks f7e0565d4fc07e413ebda309a380c57047f7cad7
120000 today.txt bfaaad8be5cbb22838cc83fc02d461b0c4992730
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 trees.py
Each line of git cat-file -p on a tree is one entry: a mode, the type of the object it names, the object’s ID and
the name. The subfolder snacks is a tree of its own, f7e0565…, with one entry. The symbolic link today.txt is a
blob, and its content is the path it points to, menu.txt, without a newline. Git records only these modes:
100644: a regular file, whose object is a blob;100755: an executable file, also a blob;120000: a symbolic link, a blob that holds the target path;040000: a folder, whose object is a tree;160000: a gitlink, the commit a submodule is at, which lives in another repository.
The last step piped the raw tree through show-tree. The object is 179 bytes of binary: each entry is the mode, a
space, the name, a NUL byte and the ID as 20 raw bytes, with no separator between entries and no type field, since
the mode already says what the entry is. The folder’s mode is stored as 40000, without the leading zero that
git cat-file -p adds when it prints. Git keeps no other permissions: a file is executable or it is not.
The rules a tree must follow
Look at the order of the entries again: snacks.txt comes before the folder snacks, although a plain sort by name
would put the shorter name first. Git compares names byte by byte, but it compares a folder’s name as if it ended in
/. The . after snacks is byte 0x2E and / is 0x2F, so the file wins. This session shows that the order is
a rule, not a habit:
from gitsession import session
session(r"""
git config set --global user.name "Ravi Kumar"
git config set --global user.email "ravi@example.com"
git init -q -b main canteen
cd canteen
printf 'samosa 15\n' | git hash-object -w --stdin
printf 'chai 10\n' | git hash-object -w --stdin
# git mktree reads entries in ls-tree format and writes the tree object
printf '100644 blob d1c2122f53808c83016527d5afaafbd862d1c5a8\tfried.txt\n' | git mktree
# It sorts the entries itself: the folder "snacks" goes after the file "snacks.txt"
printf '040000 tree f7e0565d4fc07e413ebda309a380c57047f7cad7\tsnacks\n100644 blob b9f8826a6646d5320cff6699abba8d6756de901f\tsnacks.txt\n' | git mktree
git ls-tree 95dd714
# The same two entries written by hand in plain name order are refused
python3 ../../objtools.py raw-tree 40000 snacks f7e0565d4fc07e413ebda309a380c57047f7cad7 100644 snacks.txt b9f8826a6646d5320cff6699abba8d6756de901f > unsorted.bin
! git hash-object -t tree -w unsorted.bin
# So is a folder written with the leading zero that ls-tree shows
python3 ../../objtools.py raw-tree 040000 snacks f7e0565d4fc07e413ebda309a380c57047f7cad7 > padded.bin
! git hash-object -t tree -w padded.bin
""") Output
$ git config set --global user.name "Ravi Kumar" $ git config set --global user.email "ravi@example.com" $ git init -q -b main canteen $ cd canteen $ printf 'samosa 15\n' | git hash-object -w --stdin d1c2122f53808c83016527d5afaafbd862d1c5a8 $ printf 'chai 10\n' | git hash-object -w --stdin b9f8826a6646d5320cff6699abba8d6756de901f # git mktree reads entries in ls-tree format and writes the tree object $ printf '100644 blob d1c2122f53808c83016527d5afaafbd862d1c5a8\tfried.txt\n' | git mktree f7e0565d4fc07e413ebda309a380c57047f7cad7 # It sorts the entries itself: the folder "snacks" goes after the file "snacks.txt" $ printf '040000 tree f7e0565d4fc07e413ebda309a380c57047f7cad7\tsnacks\n100644 blob b9f8826a6646d5320cff6699abba8d6756de901f\tsnacks.txt\n' | git mktree 95dd7143dff3ebc17ebb031a41e3a5020672aa80 $ git ls-tree 95dd714 100644 blob b9f8826a6646d5320cff6699abba8d6756de901f snacks.txt 040000 tree f7e0565d4fc07e413ebda309a380c57047f7cad7 snacks # The same two entries written by hand in plain name order are refused $ python3 ../../objtools.py raw-tree 40000 snacks f7e0565d4fc07e413ebda309a380c57047f7cad7 100644 snacks.txt b9f8826a6646d5320cff6699abba8d6756de901f > unsorted.bin $ git hash-object -t tree -w unsorted.bin error: object fails fsck: treeNotSorted: not properly sorted fatal: refusing to create malformed object [exit status 128] # So is a folder written with the leading zero that ls-tree shows $ python3 ../../objtools.py raw-tree 040000 snacks f7e0565d4fc07e413ebda309a380c57047f7cad7 > padded.bin $ git hash-object -t tree -w padded.bin error: object fails fsck: zeroPaddedFilemode: contains zero-padded file modes fatal: refusing to create malformed object [exit status 128]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 tree_rules.py
git mktree reads entries in the format git ls-tree prints, in any order, and writes the tree in Git’s order:
git ls-tree 95dd714 lists snacks.txt first. Writing the same two entries by hand in plain name order and asking
git hash-object -t tree to store them fails with treeNotSorted, and a folder written as 040000 fails with
zeroPaddedFilemode. Both names come from git fsck’s list of checks. The strictness protects the ID itself: if a
folder could be encoded in two orders, it would have two IDs, and Git could no longer tell that two snapshots hold
the same folder by comparing 40 characters.
Commits: a snapshot, its parents and two times
A commit is a short text object. This session makes two commits, the second changing one byte of one file, and compares the IDs before and after:
from gitsession import session
session(r"""
git config set --global user.name "Ravi Kumar"
git config set --global user.email "ravi@example.com"
git init -q -b main canteen
cd canteen
printf 'idli 30\n' > menu.txt
mkdir snacks
printf 'samosa 15\n' > snacks/fried.txt
git add .
git commit -q -m "First menu"
git cat-file -p HEAD
# Change one byte of one file and commit again
printf 'samosa 16\n' > snacks/fried.txt
git commit -q -am "Samosa costs 16"
git cat-file -p HEAD
# Before and after: the file, its folder, the root tree and the commit all have new IDs
git rev-parse HEAD~1:snacks/fried.txt HEAD:snacks/fried.txt
git rev-parse HEAD~1:snacks HEAD:snacks
git rev-parse 'HEAD~1^{tree}' 'HEAD^{tree}'
# menu.txt did not change, so both commits share its blob
git rev-parse HEAD~1:menu.txt HEAD:menu.txt
# The commit's ID is the hash of exactly the text above, after a "commit <size>" header
git cat-file commit HEAD | python3 ../../objtools.py rehash commit
git rev-parse HEAD
# The same snapshot, committed an hour later, is another commit with the same tree
GIT_COMMITTER_DATE="@1767243600 +0530" git commit -q --amend --no-edit
git rev-parse HEAD 'HEAD^{tree}'
""") Output
$ git config set --global user.name "Ravi Kumar"
$ git config set --global user.email "ravi@example.com"
$ git init -q -b main canteen
$ cd canteen
$ printf 'idli 30\n' > menu.txt
$ mkdir snacks
$ printf 'samosa 15\n' > snacks/fried.txt
$ git add .
$ git commit -q -m "First menu"
$ git cat-file -p HEAD
tree f330a106d5ef1227cf591fcc68c2c6c997e539f5
author Ravi Kumar <ravi@example.com> 1767240000 +0530
committer Ravi Kumar <ravi@example.com> 1767240000 +0530
First menu
# Change one byte of one file and commit again
$ printf 'samosa 16\n' > snacks/fried.txt
$ git commit -q -am "Samosa costs 16"
$ git cat-file -p HEAD
tree 66f0aaaf39fc7eba47fe28f7024b89d1c8b24a10
parent f170bbdad9be9b3b2f096031a1f154b6f5d57217
author Ravi Kumar <ravi@example.com> 1767240000 +0530
committer Ravi Kumar <ravi@example.com> 1767240000 +0530
Samosa costs 16
# Before and after: the file, its folder, the root tree and the commit all have new IDs
$ git rev-parse HEAD~1:snacks/fried.txt HEAD:snacks/fried.txt
d1c2122f53808c83016527d5afaafbd862d1c5a8
23101a7841105201eead6a2d039a49c6836a198a
$ git rev-parse HEAD~1:snacks HEAD:snacks
f7e0565d4fc07e413ebda309a380c57047f7cad7
c5a584651172c531bb50ce81a2554bafe618d342
$ git rev-parse 'HEAD~1^{tree}' 'HEAD^{tree}'
f330a106d5ef1227cf591fcc68c2c6c997e539f5
66f0aaaf39fc7eba47fe28f7024b89d1c8b24a10
# menu.txt did not change, so both commits share its blob
$ git rev-parse HEAD~1:menu.txt HEAD:menu.txt
6b256986d67fde7e02accec2d83df50e4f137850
6b256986d67fde7e02accec2d83df50e4f137850
# The commit's ID is the hash of exactly the text above, after a "commit <size>" header
$ git cat-file commit HEAD | python3 ../../objtools.py rehash commit
e98d742a9055a415188b209dfc4585a764fd4970
$ git rev-parse HEAD
e98d742a9055a415188b209dfc4585a764fd4970
# The same snapshot, committed an hour later, is another commit with the same tree
$ GIT_COMMITTER_DATE="@1767243600 +0530" git commit -q --amend --no-edit
$ git rev-parse HEAD 'HEAD^{tree}'
a3da3728938d081bb347a7db11696dc9d6743c61
66f0aaaf39fc7eba47fe28f7024b89d1c8b24a10
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 commits.py
The first commit has no parent line, because nothing came before it; the second names f170bba…. A merge commit
has two or more parent lines, one for each commit it joins. The author and committer lines carry a name, an
email address, the time as seconds since 1970 and a time zone, here India’s +0530. The two can differ: when a
commit is rebased, cherry-picked or applied from a patch, the author line keeps who wrote the change and when, and
the committer line records who made the new commit and when.
The rev-parse pairs show what one byte did. samosa 15 became samosa 16, so fried.txt got a new blob. The
snacks tree lists that blob’s ID, so its bytes changed and it got a new ID too; the root tree lists the snacks
tree’s ID, so it changed; and the commit names the root tree, so the commit’s ID changed. menu.txt did not change,
and both root trees name the same blob, 6b25698…, which Git stored once.
What changed when one byte of one file changed
Text description of the diagram
The diagram shows the two commits of the commits session side by side. Objects drawn with a double border are new in the second commit; the object drawn with a dashed border is shared by both.
- The second commit, e98d742 ("Samosa costs 16"), points to the first commit, f170bba ("First menu"), as its parent.
- Each commit names its own root tree: f330a10 for the first commit and 66f0aaa for the second.
- Each root tree names its own tree for the snacks folder: f7e0565 in the first commit and c5a5846 in the second.
- Each snacks tree names its own blob for fried.txt: d1c2122, which holds "samosa 15", and 23101a7, which holds "samosa 16".
- Both root trees name the same blob for menu.txt, 6b25698, which holds "idli 30", because its bytes did not change.
One changed byte gave the file a new blob ID. The folder that lists it then held a different ID, so its tree got a new ID too, and so did the root tree and the commit that names it. Nothing that did not change was stored again.
Two more lines close the loop. Hashing exactly the text that git cat-file commit HEAD printed, after a
commit <size> header, gives e98d742…, the commit’s own ID: there is no hidden field. And committing the same
snapshot an hour later with --amend made a3da372…, a different commit with the same tree, 66f0aaa…, because
the committer’s time is part of the hashed text. That is why every session in this track fixes the author and
committer dates (GIT_AUTHOR_DATE and GIT_COMMITTER_DATE) as well as the names: with the same bytes, your run
prints the same IDs as this page.
The links make a commit’s ID a fingerprint of everything it can reach. Change one byte of any file in any earlier snapshot and every ID from that blob up to the newest commit would change. Git’s documentation on the hash transition relies on exactly this when it explains why a signed commit or tag can vouch for every object it refers to.
Annotated and lightweight tags
Tags come in two kinds, and only one of them is an object:
from gitsession import session
session(r"""
git config set --global user.name "Ravi Kumar"
git config set --global user.email "ravi@example.com"
git init -q -b main canteen
cd canteen
printf 'idli 30\n' > menu.txt
git add menu.txt
git commit -q -m "First menu"
# An annotated tag makes a tag object; a lightweight tag is only a name
git tag -a v1 -m "Menu for term 1"
git tag term-1
git cat-file -t v1
git cat-file -p v1
git cat-file -t term-1
# Both names lead to the same commit; v1 gets there through its tag object
git rev-parse v1 term-1 'v1^{commit}'
# git describe counts from the newest annotated tag
printf 'idli 30\nvada 20\n' > menu.txt
git commit -q -am "Add vada"
git describe
""") Output
$ git config set --global user.name "Ravi Kumar"
$ git config set --global user.email "ravi@example.com"
$ git init -q -b main canteen
$ cd canteen
$ printf 'idli 30\n' > menu.txt
$ git add menu.txt
$ git commit -q -m "First menu"
# An annotated tag makes a tag object; a lightweight tag is only a name
$ git tag -a v1 -m "Menu for term 1"
$ git tag term-1
$ git cat-file -t v1
tag
$ git cat-file -p v1
object 873972d08bd11c2ed48f4fd65599453e7ac6a0ab
type commit
tag v1
tagger Ravi Kumar <ravi@example.com> 1767240000 +0530
Menu for term 1
$ git cat-file -t term-1
commit
# Both names lead to the same commit; v1 gets there through its tag object
$ git rev-parse v1 term-1 'v1^{commit}'
6adbce0c1ba412ae7efbac609eeb286fdb8351ac
873972d08bd11c2ed48f4fd65599453e7ac6a0ab
873972d08bd11c2ed48f4fd65599453e7ac6a0ab
# git describe counts from the newest annotated tag
$ printf 'idli 30\nvada 20\n' > menu.txt
$ git commit -q -am "Add vada"
$ git describe
v1-1-gbb975f3
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 tags.py
git tag -a wrote a tag object, 6adbce0…, with an object line, the type of that object, the tag’s name, a
tagger line with a time, and the message. The name refs/tags/v1 holds the tag object’s ID, so git rev-parse v1
prints 6adbce0…, and v1^{commit} follows the tag to the commit, 873972d…. git tag term-1 wrote no object:
term-1 is a name that holds the commit’s ID directly, which is why git cat-file -t term-1 says commit.
Annotated tags carry who made the tag, when and why, and a signed tag adds a signature to that object; use them for
releases. git describe uses only annotated tags unless you pass --tags: after one more commit it printed
v1-1-gbb975f3, which means one commit after v1, at the commit whose ID starts with bb975f3.
What notices a damaged object
Content addressing makes damage detectable, but only when something checks. This session overwrites the stored menu blob with other bytes under the same file name, the way a failing disk or a careless copy might, and asks around:
from gitsession import session
session(r"""
git config set --global user.name "Ravi Kumar"
git config set --global user.email "ravi@example.com"
git init -q -b main canteen
cd canteen
printf 'idli 30\n' > menu.txt
git add menu.txt
git commit -q -m "First menu"
git cat-file -p HEAD:menu.txt
# Damage the stored blob: other bytes under the same file name, as a failing disk or a bad copy might leave it
python3 ../../objtools.py forge .git/objects/6b/256986d67fde7e02accec2d83df50e4f137850 'idli 99\n'
# Reading by ID trusts the file name
git cat-file -p HEAD:menu.txt
git status --short
# fsck hashes every object again and compares
! git fsck --full
# The working tree still holds the right bytes, and the right bytes always get the same name
rm -f .git/objects/6b/256986d67fde7e02accec2d83df50e4f137850
git hash-object -w menu.txt
git fsck --full
git cat-file -p HEAD:menu.txt
""") Output
$ git config set --global user.name "Ravi Kumar" $ git config set --global user.email "ravi@example.com" $ git init -q -b main canteen $ cd canteen $ printf 'idli 30\n' > menu.txt $ git add menu.txt $ git commit -q -m "First menu" $ git cat-file -p HEAD:menu.txt idli 30 # Damage the stored blob: other bytes under the same file name, as a failing disk or a bad copy might leave it $ python3 ../../objtools.py forge .git/objects/6b/256986d67fde7e02accec2d83df50e4f137850 'idli 99\n' # Reading by ID trusts the file name $ git cat-file -p HEAD:menu.txt idli 99 $ git status --short # fsck hashes every object again and compares $ git fsck --full error: bb19224a5300c3ee3929812c7ee30614b38f9e2b: hash-path mismatch, found at: .git/objects/6b/256986d67fde7e02accec2d83df50e4f137850 missing blob 6b256986d67fde7e02accec2d83df50e4f137850 [exit status 3] # The working tree still holds the right bytes, and the right bytes always get the same name $ rm -f .git/objects/6b/256986d67fde7e02accec2d83df50e4f137850 $ git hash-object -w menu.txt 6b256986d67fde7e02accec2d83df50e4f137850 $ git fsck --full $ git cat-file -p HEAD:menu.txt idli 30
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 corrupt.py
git cat-file -p printed idli 99 without a complaint, and git status reported nothing. Reading an object by its
ID does not hash it again, so the damage went unnoticed, and git status compares your files with the index, not
with the object store. git fsck --full is the command that hashes every object again. It reported a hash-path
mismatch, naming the real hash of the bytes it found, bb19224…, and the blob the tree needs, 6b25698…, as
missing, and it exited with a non-zero status.
The repair works because names follow content. The working tree still held the right eight bytes, so after the bad
file was deleted, git hash-object -w menu.txt wrote them again under exactly the old name, and fsck was clean. A
clone, a backup or a teammate’s repository would have served as well. When repositories exchange objects,
transfer.fsckObjects makes git fetch check the objects it receives, and a repository that receives a push check
those too; it is off by default.
SHA-1 today, SHA-256 next
In 2017 the SHAttered attack produced two different files with the same SHA-1 hash. Git 2.13 then made a hardened SHA-1 its default, one that detects attempts to build such collisions, and the Git project says it has protections in place against the known attacks. It also expects more attacks to come, so it has a format that uses SHA-256 instead, and a SHA-256 repository works today:
from gitsession import session
session(r"""
git config set --global user.name "Ravi Kumar"
git config set --global user.email "ravi@example.com"
git init -q -b main --object-format=sha256 canteen
cd canteen
git rev-parse --show-object-format
printf 'idli 30\n' | git hash-object --stdin
printf 'idli 30\n' > menu.txt
git add menu.txt
git commit -q -m "First menu"
git cat-file -p HEAD
git log --oneline
# The repository says which hash it uses, and old versions of Git refuse to read it
git config get core.repositoryformatversion
git config get extensions.objectformat
# A SHA-1 repository cannot receive SHA-256 objects
cd ..
git init -q --bare -b main shared-sha1.git
cd canteen
! git push ../shared-sha1.git main
""") Output
$ git config set --global user.name "Ravi Kumar" $ git config set --global user.email "ravi@example.com" $ git init -q -b main --object-format=sha256 canteen $ cd canteen $ git rev-parse --show-object-format sha256 $ printf 'idli 30\n' | git hash-object --stdin 997c288fc20e4b2806d6d65680da135dfa4633ddfe1fde17694a7ef7e167860c $ printf 'idli 30\n' > menu.txt $ git add menu.txt $ git commit -q -m "First menu" $ git cat-file -p HEAD tree 6ffb5cf2fdcf66821f4afbf25d206180a70a42805108c0cd24649b0d84d3c5c0 author Ravi Kumar <ravi@example.com> 1767240000 +0530 committer Ravi Kumar <ravi@example.com> 1767240000 +0530 First menu $ git log --oneline 9e59368 First menu # The repository says which hash it uses, and old versions of Git refuse to read it $ git config get core.repositoryformatversion 1 $ git config get extensions.objectformat sha256 # A SHA-1 repository cannot receive SHA-256 objects $ cd .. $ git init -q --bare -b main shared-sha1.git $ cd canteen $ git push ../shared-sha1.git main fatal: the receiving end does not support this repository's hash algorithm [exit status 128]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 sha256.py
Every ID is now 64 hexadecimal characters, and the blob ID is the SHA-256 that the Python program computed from the
same header and content. git log --oneline still shows seven characters, because that is only an abbreviation. The
repository records its choice in its configuration, extensions.objectformat = sha256 with format version 1, and
versions of Git too old to know the extension refuse to open it rather than misread it.
The last command is the catch. The bare repository was created with SHA-1, and the push stopped with “the receiving
end does not support this repository’s hash algorithm”: the two formats cannot exchange objects yet, as the
git init documentation warns. A repository’s object format is chosen when it is created, and everyone who shares it
needs tools and a hosting service that support that format, so read your host’s documentation before you create a
shared SHA-256 repository.
Version note
Git 3.0 plans to make SHA-256 the default for new repositories once Git libraries, applications and hosting services
support it. The Git project has set no release date for Git 3.0, and it has no plan to deprecate SHA-1
repositories. Git 2.53 added a manual page on the data model, gitdatamodel, which covers objects, references, the
index and reflogs. The sessions on this page run Git 2.54.
Practise: compute blob and tree IDs
Write the two functions that this lesson has been doing by hand. Every expected ID in the tests was printed by Git, and the trees include the cases that trip people up: a folder’s mode and a folder whose name starts like a file’s.
Exercise · Medium · Python
Compute blob and tree IDs without Git
Git names every object by hashing it, so you can work out an ID with nothing but hashlib. Write two functions in Python:
blob_id(content)takes the bytes of a file and returns the SHA-1 ID thatgit hash-objectprints for it, as 40 hexadecimal characters.tree_id(entries)takes a list of tree entries and returns the ID of the tree object that lists them. Each entry is a tuple(mode, name, object_id)written the waygit ls-treeprints it:"100644"for a file,"100755"for an executable,"120000"for a symbolic link and"040000"for a folder, a name without slashes, and the entry's ID in hexadecimal. The entries may arrive in any order.
Every expected ID in the tests was printed by Git itself. For example, blob_id(b"idli 30\n") returns "6b256986d67fde7e02accec2d83df50e4f137850", and a tree with that one blob named menu.txt has the ID "6aef0141b507d707f772f7aa8b30bd51ecca37b4".
Starter code · object_ids.py
def blob_id(content):
"""Return the SHA-1 ID, in hexadecimal, that Git gives a file holding these bytes."""
# Replace this line with your code.
return ""
def tree_id(entries):
"""Return the SHA-1 ID of the tree that lists these entries.
Each entry is a tuple (mode, name, object ID) as git ls-tree prints it, such as
("100644", "menu.txt", "6b256986d67fde7e02accec2d83df50e4f137850"), and the entries come in any order.
"""
# Replace this line with your code.
return "" The sample tests · test_object_ids.py
from object_ids import blob_id, tree_id
# Every expected ID below was printed by Git 2.54 (git hash-object and git mktree).
MENU = "6b256986d67fde7e02accec2d83df50e4f137850"
EMPTY = "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"
CHAI = "b9f8826a6646d5320cff6699abba8d6756de901f"
SNACKS = "f7e0565d4fc07e413ebda309a380c57047f7cad7"
def test_blob():
"""hashes a small file"""
assert blob_id(b"idli 30\n") == MENU
def test_empty_blob():
"""hashes an empty file"""
assert blob_id(b"") == EMPTY
def test_blob_counts_bytes():
"""counts bytes, not characters"""
assert blob_id("chai ₹10\n".encode("utf-8")) == "d8208b0376c1698fde9f367f0b4d728ca6b56a86"
def test_tree_one_file():
"""builds a tree with one file"""
assert tree_id([("100644", "menu.txt", MENU)]) == "6aef0141b507d707f772f7aa8b30bd51ecca37b4"
def test_tree_sorts_entries():
"""sorts the entries by name"""
assert tree_id([("100644", "notes.txt", EMPTY), ("100644", "menu.txt", MENU)]) == "1098dc47f1d00e21a8d823d8ed29ef62fdbf46d5"
def test_tree_with_folder():
"""writes a folder's mode and place the way Git does"""
entries = [("040000", "snacks", SNACKS), ("100644", "snacks.txt", CHAI)]
assert tree_id(entries) == "95dd7143dff3ebc17ebb031a41e3a5020672aa80"
def test_tree_from_the_lesson():
"""rebuilds the root tree of the trees session"""
entries = [
("120000", "today.txt", "bfaaad8be5cbb22838cc83fc02d461b0c4992730"),
("040000", "snacks", SNACKS),
("100755", "open.sh", "5b0c1d152b0cc504560f18ed3a203a6ee850c8b8"),
("100644", "snacks.txt", CHAI),
("100644", "menu.txt", MENU),
]
assert tree_id(entries) == "7d021b318dd93d1169b3fbf81561d09a2f003d19" A hint
Both IDs are the SHA-1 of a header, "<type> <size in bytes>", a NUL byte and the content. For a tree, the content is every entry written as the mode, a space, the name, a NUL byte and the 20 raw bytes of the ID (bytes.fromhex(...)), one after another with nothing between them. Then look closely at the mode of a folder and at the order in which git ls-tree printed snacks.txt and snacks in the lesson.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Run the sessions yourself
The sessions are Python programs that drive the real git command. gitsession.py reads a session’s script line by
line, echoes each command behind a $ and prints whatever Git answered. Before the first line it makes a scratch
folder named learner and treats it as the session’s home, so git config --global writes there and your own
configuration stays untouched; it also pins the date of every commit, which is what makes your IDs equal to the ones
on this page. objtools.py does the few steps Git has no command for, such as inflating a loose object or writing a
raw tree. Copy both next to a session file, for instance trees.py, and start it with python3 trees.py on macOS or
Linux; you need Git and Python 3.
"""Plays a terminal session with real Git: each command is printed after "$ ", then everything it printed.
The session runs in a new, empty folder called learner/, which is also its home folder, so your own Git settings
change nothing, and GIT_CONFIG_NOSYSTEM=1 makes Git skip the computer's system-wide settings file too. Git never
looks for a repository above that folder, so a session cannot touch a repository you run it from. In the output,
"~" stands for that folder. Commit times are fixed, because a commit's ID covers the time it was made: with the
same files, message, name and time, every run makes the same commit IDs.
Each line of a script is one /bin/sh command (macOS and Linux), except "cd", which the session does itself. A line
that starts with "# " is printed as a comment. A command written as "! command" is meant to fail: its exit status
is printed and the session goes on. Any other command that fails stops the session with exit status 1.
"""
import os
import shutil
import subprocess
import sys
from pathlib import Path
HOME = Path("learner").resolve()
# One fixed moment in Git's own date format: seconds since 1970, then the time zone (India, UTC+05:30).
FIXED_TIME = "@1767240000 +0530"
def _environment():
# Git's own folder comes first, so every command runs that Git directly (on macOS, /usr/bin/git is a small
# launcher that looks Git up again on every call).
git_folder = subprocess.run(["git", "--exec-path"], capture_output=True, text=True, check=True).stdout.strip()
env = {
"PATH": os.pathsep.join([git_folder, os.environ.get("PATH", "/usr/bin:/bin")]),
"HOME": str(HOME),
"XDG_CONFIG_HOME": str(HOME / ".config"),
"GIT_CONFIG_NOSYSTEM": "1",
"GIT_CEILING_DIRECTORIES": str(HOME.parent),
"GIT_AUTHOR_DATE": FIXED_TIME,
"GIT_COMMITTER_DATE": FIXED_TIME,
"LANG": "C",
"LC_ALL": "C",
"TERM": "dumb",
}
if "TMPDIR" in os.environ:
env["TMPDIR"] = os.environ["TMPDIR"]
return env
def _with_tilde(text):
"""Writes the home folder as "~", however the operating system spells its path."""
for spelling in sorted({str(HOME), os.path.realpath(HOME)}, key=len, reverse=True):
text = text.replace(spelling, "~")
return text
def session(script):
"""Runs the script's commands one by one, starting in an empty home folder."""
shutil.rmtree(HOME, ignore_errors=True)
HOME.mkdir()
env = _environment()
folder = HOME
for line in script.strip().splitlines():
command = line.strip()
if not command:
continue
if command.startswith("# "):
print(command)
continue
meant_to_fail = command.startswith("! ")
if meant_to_fail:
command = command[2:]
print("$", command)
if command.startswith("cd "):
folder = (folder / command[3:].strip()).resolve()
continue
done = subprocess.run(command, shell=True, cwd=folder, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
print(_with_tilde(done.stdout), end="")
if done.returncode != 0:
print(f"[exit status {done.returncode}]")
if not meant_to_fail:
sys.exit(1)
elif meant_to_fail:
sys.exit(f"{command} was meant to fail, but it worked") """Small helpers that the sessions of this lesson run with python3, for the steps Git has no command for.
python3 objtools.py inflate <file> print a loose object file, decompressed: header, NUL byte, content
python3 objtools.py bytes print standard input as Python bytes, so that every byte shows
python3 objtools.py show-tree read a raw tree object on standard input and list its entries
python3 objtools.py rehash <kind> read an object's content on standard input and print its SHA-1 ID
python3 objtools.py raw-tree <mode> <name> <id> [...]
write a raw tree object, entries in the order given, to standard output
python3 objtools.py forge <file> <text> overwrite a loose object file with other content (to damage it on purpose)
"""
import hashlib
import os
import sys
import zlib
def inflate(path):
with open(path, "rb") as f:
print(zlib.decompress(f.read()))
def show_bytes():
print(sys.stdin.buffer.read())
def show_tree():
data = sys.stdin.buffer.read()
print(f"{len(data)} bytes; each entry is <mode> <name>, a NUL byte, then the ID as 20 raw bytes")
at = 0
while at < len(data):
space = data.index(b" ", at)
nul = data.index(b"\0", space)
mode, name = data[at:space].decode(), data[space + 1:nul].decode()
raw = data[nul + 1:nul + 21]
print(f" {mode:<7}{name:<11}{raw.hex()}")
at = nul + 21
def rehash(kind):
content = sys.stdin.buffer.read()
print(hashlib.sha1(f"{kind} {len(content)}\0".encode() + content).hexdigest())
def raw_tree(args):
out = b""
for mode, name, hex_id in zip(args[0::3], args[1::3], args[2::3]):
out += f"{mode} {name}\0".encode() + bytes.fromhex(hex_id)
sys.stdout.buffer.write(out)
def forge(path, text):
content = text.encode().decode("unicode_escape").encode()
os.chmod(path, 0o644)
with open(path, "wb") as f:
f.write(zlib.compress(f"blob {len(content)}\0".encode() + content))
command, args = sys.argv[1], sys.argv[2:]
if command == "inflate":
inflate(args[0])
elif command == "bytes":
show_bytes()
elif command == "show-tree":
show_tree()
elif command == "rehash":
rehash(args[0])
elif command == "raw-tree":
raw_tree(args)
elif command == "forge":
forge(args[0], args[1])
else:
sys.exit(f"unknown command {command}") Interview questions
Fresher to mid-level · What are Git’s four object types, and how are they connected?
A blob holds the bytes of one file, with no name or mode. A tree lists one folder: for each entry a mode, a name and the ID of a blob, of another tree, or, for a submodule, of a commit. A commit names one root tree, zero or more parent commits, an author and a committer with their times, and a message. A tag object names another object, usually a commit, with a tag name, a tagger and a message. Every ID is the hash of the object’s type, size and content, so the objects form a graph that points from newer to older: tags to commits, commits to trees and parents, trees to blobs and subtrees. Branches and lightweight tags are references, not objects.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
A teammate says renaming a 2 GB video in the repository will add another 2 GB to every clone, and that a one-line edit to a 50,000-line generated file stores the whole file again. Which claim is right, and what does Git actually store in each case?
-
Mid to staff
After reading about SHA-1 collisions, your security team asks whether Git commit IDs can still be trusted to identify the code that was reviewed, and whether every repository should move to SHA-256 now. What do you tell them?
Key takeaways
- Git stores four kinds of object, blobs, trees, commits and tag objects, each named by the hash of a
"<type> <size>"header, a NUL byte and the content. - A loose object is that byte string compressed with zlib, stored as
.git/objects/<2 hex>/<38 hex>. - Blobs have no names; trees supply names and modes, write a folder’s mode as
40000, and sort a folder as if its name ended in/. - A commit’s ID covers its tree, its parents, both times and the message, so one changed byte gives new IDs all the way up, while unchanged files keep their blobs.
- Reading an object trusts its name;
git fsck --fullhashes everything again, and any copy of the right bytes restores a damaged object. - SHA-256 repositories work today but cannot exchange objects with SHA-1 ones; Git 3.0 plans SHA-256 as the default for new repositories, with no date yet.
References
- gitdatamodel, Git's core data model (The Git project)
- git-cat-file documentation (The Git project)
- git-hash-object documentation (The Git project)
- gitformat-loose, the loose object format (The Git project)
- git-mktree documentation (The Git project)
- git-fsck documentation (message IDs such as treeNotSorted) (The Git project)
- git-describe documentation (The Git project)
- git-config documentation (transfer.fsckObjects) (The Git project)
- git-init documentation (--object-format) (The Git project)
- Hash function transition (SHA-1 to SHA-256) (The Git project)
- Upcoming breaking changes (Git 3.0) (The Git project)
- Git 2.13 release notes (SHA-1 collision detection by default) (The Git project)
- Git 2.53 release notes (the data model manual) (The Git project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress