Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Advanced
  • 35 minutes
  • Examples run with git 2.54.0, Python 3.14.8, Pyodide 314.0.7 and Node.js 24.21.0
  • By MySmartCoPilot
Free lesson, Pro answers: 35 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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.

Two tag names lead to one commit, one of them through a tag object; the commit names a tree, and the tree names a blob.refs/tags/v1(a name)refs/tags/term-1(a name)tag object6adbce0commit873972dtree6aef014blob 6b25698idli 30objecttreemenu.txt

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.

  1. refs/tags/v1 is a name, not an object. It holds the ID of the tag object 6adbce0.
  2. 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.
  3. refs/tags/term-1 is a lightweight tag: a name that holds the ID of commit 873972d directly, with no tag object between them.
  4. 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.
  5. Tree 6aef014 has one entry: the mode 100644, the name menu.txt and the ID of blob 6b25698.
  6. 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:

Hash, store and read back one blob Python · store.py
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:

Object IDs from bytes alone Python · by-hand/object_ids.py
"""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

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 in Node.js, and the length bug JavaScript · object_ids.mjs
// 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.

Hash Generator Hash a file that holds the header and the content, such as the one printf 'blob 8\0idli 30\n' > obj writes, and compare its SHA-1 with Git's ID.

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:

One commit's trees, readable and raw Python · trees.py
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:

git mktree sorts; hand-made trees that break the rules are refused Python · tree_rules.py
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:

One changed byte, traced up to the commit Python · commits.py
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.

After one byte of snacks/fried.txt changed, its blob, its folder, the root tree and the commit are new; menu.txt keeps its blob.commit e98d742commit f170bbaroot tree66f0aaaroot treef330a10snacksc5a5846snacksf7e0565blob23101a7blobd1c2122blob6b25698

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.

  1. The second commit, e98d742 ("Samosa costs 16"), points to the first commit, f170bba ("First menu"), as its parent.
  2. Each commit names its own root tree: f330a10 for the first commit and 66f0aaa for the second.
  3. Each root tree names its own tree for the snacks folder: f7e0565 in the first commit and c5a5846 in the second.
  4. Each snacks tree names its own blob for fried.txt: d1c2122, which holds "samosa 15", and 23101a7, which holds "samosa 16".
  5. 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:

A tag object and a tag that is only a name Python · tags.py
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:

A damaged blob, who notices it, and the repair Python · corrupt.py
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:

A SHA-256 repository, and the push it refuses Python · sha256.py
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 that git hash-object prints 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 way git ls-tree prints 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.

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.

Hex Viewer & File Inspector Open a raw tree object saved with git cat-file tree 'HEAD^{tree}' > tree.bin and find the NUL byte and the 20 ID bytes after each name.

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.

  1. Question 1 of 6 Which bytes does Git hash to get the ID of a blob?

    Choose one answer.

    Show the answer to question 1

    Answer: The word blob, a space, the size in bytes, a NUL byte, then the file's content

    The ID covers a short header and the content: "blob 8", a NUL byte and "idli 30" with its newline, in the store session. The name is not part of it, which is why two files with the same bytes share one blob. Compression happens only when the object is written to disk, after the ID is known.

  2. Question 2 of 6 How many bytes does Git write as the size in the header of a blob that holds "chai ₹10" and a newline, saved as UTF-8?

    Type a number.

    Show the answer to question 2

    Answer: 11 bytes

    The text has 9 characters, but the rupee sign takes 3 bytes in UTF-8, so the content is 11 bytes and the header is "blob 11". Counting characters instead, as JavaScript's string length does, gives a header that says 9 and an ID Git never prints.

  3. Question 3 of 6 A tree lists a file named snacks.txt and a folder named snacks. In which order does Git write them?

    Choose one answer.

    Show the answer to question 3

    Answer: snacks.txt first, because Git compares a folder's name as if it ended in a slash

    Git sorts a folder as "snacks/". The dot in snacks.txt is byte 0x2E and the slash is 0x2F, so the file comes first. A tree written in plain name order is refused with treeNotSorted, as the tree rules session showed.

  4. Question 4 of 6 You change one byte of snacks/fried.txt and commit. Which IDs differ from the previous commit's?

    Choose every answer that is right.

    Show the answer to question 4

    Answer:

    • The root tree
    • The tree of the snacks folder
    • The commit
    • The blob of snacks/fried.txt

    The new bytes get a new blob ID. The snacks tree lists that ID, so its own bytes and ID change, and the same happens to the root tree and the commit above it. menu.txt did not change, so both root trees name the same blob, 6b25698.

  5. Question 5 of 6 A loose object file was overwritten with other bytes but kept its name. Which command reports it?

    Choose one answer.

    Show the answer to question 5

    Answer: git fsck --full, which hashes every object again

    Reading an object by its ID trusts the file name, so git cat-file -p printed "idli 99" without a word, and git status saw nothing wrong. git fsck --full hashed the content again and reported a hash-path mismatch and a missing blob.

  6. Question 6 of 6 Which command creates a new object in the repository?

    Choose one answer.

    Show the answer to question 6

    Answer: git tag -a v1 -m "Menu for term 1"

    An annotated tag writes a tag object with the tagger, a time and a message, and the name refs/tags/v1 holds that object's ID. A lightweight tag and a branch are only names that hold a commit's ID, and switching moves HEAD.

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.

gitsession.py: runs a session with real Git Python · gitsession.py
"""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")
objtools.py: the steps Git has no command for Python · objtools.py
"""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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

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 --full hashes 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.