What a Git Hash Is Actually a Hash Of
Hashing a file's bytes gives the wrong answer. A type word, a byte count and a zero byte go in first, and three rules about repositories fall straight out of that.
The repository behind every example
Every name quoted below was read out of a real repository, and the twenty two
lines that built it are printed here so that the same names can be read out of
yours. Three files, four commits on the main line, and a side line of three
commits branching off the second. One file, the settings file, is edited on
both sides, which is what makes it useful later.
git init -q -b main plotter && cd plotter
export GIT_AUTHOR_NAME='A Writer' GIT_AUTHOR_EMAIL='[email protected]'
export GIT_COMMITTER_NAME='A Writer' GIT_COMMITTER_EMAIL='[email protected]'
export GIT_AUTHOR_DATE='2026-01-05T09:00:00 +0000'
export GIT_COMMITTER_DATE='2026-01-05T09:00:00 +0000'
printf '# Plotter\n\nA small renderer.\n' > readme.md
printf '[page]\nwidth = 640\nheight = 480\n' > settings.ini
printf 'def render(page):\n return page\n' > render.py
git add -A && git commit -q -m 'Start the project'
printf 'def render(page):\n frame(page)\n return page\n' > render.py
git add -A && git commit -q -m 'Draw the frame'
printf '[page]\nwidth = 800\nheight = 480\n' > settings.ini
git add -A && git commit -q -m 'Raise the default width'
printf '# Plotter\n\nA small renderer.\n\nSettings live in settings.ini.\n' > readme.md
git add -A && git commit -q -m 'Document the settings'
git checkout -q -b tint main~2
printf '[page]\nwidth = 640\nheight = 480\ntint = warm\n' > settings.ini
git add -A && git commit -q -m 'Add a tint setting'
printf 'def render(page):\n frame(page)\n tint(page)\n return page\n' > render.py
git add -A && git commit -q -m 'Apply the tint'
printf '# Plotter\n\nA small renderer.\n\nThe tint setting warms the page.\n' > readme.md
git add -A && git commit -q -m 'Mention the tint'Names are quoted to their first seven characters throughout, which is what the
tool itself prints. The full name is forty hexadecimal digits standing for
twenty bytes.
Seven characters is not a convention somebody liked the look of, and the
arithmetic behind it is worth a moment because it explains a behaviour that
surprises people later. Each hexadecimal digit carries four bits, so seven of
them carry twenty eight bits and name two to the power of twenty eight
possibilities, which is 268,435,456. Two objects in a store of share a
seven character prefix with even odds once reaches about $1.1774
\sqrt{268435456}$, and the square root of 268,435,456 is 16,384, so that is
about 19,300 objects. The repository above holds twenty three, which is why
seven characters is safe here and why a repository with a few hundred thousand
objects in it starts printing eight, nine or ten instead. The abbreviation
lengthens with the store. The name underneath it never changes.
What the name is a hash of
The obvious guess is that the name of a stored file is the SHA-1 of the file.
It is worth being precise about how wrong this is, because almost every
explanation of content addressing states the guess and moves on.
The first file above holds twenty nine bytes. Hashing exactly those twenty nine
bytes gives d40615f. The name the repository files it under is 88282c8. Those
are not the same number and neither is a rounding of the other.
What is hashed is a short header followed by the contents. The header is the
object's type as a word, then a single space, then the length of the contents
in bytes written in ordinary decimal digits, then a single zero byte.
- the name the object is stored under, twenty bytes printed as forty hexadecimal digits
- the type as a word, one of blob for a file, tree for a directory, commit or tag
- one space, the byte with value thirty two
- the length of the contents in bytes, written out in decimal digits as text
- one zero byte, which is what marks the end of the header
- the contents, byte for byte, with nothing added and nothing stripped
So for the first file the hash is taken over the eight bytes of the header,
which are the four letters of the word for a file type, a space, the digits two
and nine, and a zero byte, followed by the twenty nine bytes of the file. Eight
plus twenty nine is thirty seven bytes hashed in total.
import hashlib
contents = open('readme.md', 'rb').read()
header = b'blob %d\0' % len(contents)
print(len(contents), len(header) + len(contents)) # 29 37
print(hashlib.sha1(header + contents).hexdigest()) # 88282c83..., the real name
print(hashlib.sha1(contents).hexdigest()) # d40615fa..., the usual guessNow the question nobody asks. Why is the header there at all, given that the
contents already determine themselves?
Two reasons, and the first is the one that matters. The store holds four kinds
of thing, not one, and a name has to mean a file with these bytes rather than
merely these bytes. Hash the twenty nine bytes of that file as a file and the
answer is 88282c8. Hash the identical twenty nine bytes as a directory and the
answer is 8a0b28b. As a commit, 200bf53. Three unrelated names for one run of
bytes, because the type word went through the hash with them. Without that, a
directory listing whose serialised bytes happened to coincide with somebody's
file would be one object wearing two meanings, and every reader of the store
would have to be told out of band which meaning to use.
The second reason is that the length is hashed too, which makes the object self
delimiting and makes truncation detectable. An object read back out of a stream
announces how long it is, and a copy that lost its tail cannot keep its name,
because the recomputation would be over fewer bytes and a shorter count.
Why nothing is stored twice
Seven commits, three files in each, so twenty one file versions appear across
the history. Count the distinct names and there are nine.
| step | commit | readme.md | render.py | settings.ini | distinct names so far | what happened |
|---|---|---|---|---|---|---|
| 1 | Start the project | 88282c8 | e00f051 | 4a5dd80 | 3 | Three files, three new names, three objects written. |
| 2 | Draw the frame | 88282c8 | 5b6cf77 | 4a5dd80 | 4 | One file changed, so one name changed. The other two names are the names from the commit before, unchanged and therefore not written again. |
| 3 | Raise the default width | 88282c8 | 5b6cf77 | 04f61eb | 5 | The settings file goes from a width of 640 to 800. Three bytes differ and the whole name differs, which is what a hash does. |
| 4 | Document the settings | 33769db | 5b6cf77 | 04f61eb | 6 | Four commits in and six objects hold all twelve file versions so far. |
| 5 | Add a tint setting | 88282c8 | 5b6cf77 | 6737997 | 7 | The side line starts from the second commit, so it inherits that commit's readme and renderer by name. Nothing was copied to make the branch. |
| 6 | Apply the tint | 88282c8 | d130181 | 6737997 | 8 | The renderer gains a line. The settings file is carried over by the same name it had one commit ago. |
| 7 | Mention the tint | c12ee2e | d130181 | 6737997 | 9 | Nine names for twenty one versions. Twelve of the twenty one writes found their own name already present and did nothing. |
Put bytes on it. The nine distinct objects hold 29, 61, 63, 34, 50, 65, 32, 32
and 44 bytes of file content, which is 410 bytes. Adding up the three files in
each of the seven commits instead gives 95, 111, 111, 143, 123, 138 and 172
bytes, which is 893. So 893 bytes of file content across seven snapshots sits
in 410 bytes of stored objects, and the 483 byte difference was never paid.
Notice what is absent from that account. No comparison was made between
versions. No index of already known files was consulted. Nothing scanned the
store looking for duplicates afterwards. The saving happens because the
destination is computed from the contents before the write, so a second write
of identical contents has nowhere new to put them.
A name that proves the contents
Turn the naming rule around. If the name is a function of the contents, then
anybody holding a name can check any claimed contents against it, by running
the same function and comparing.
That is a genuinely unusual property for an identifier. A row number proves
nothing about the row. A file path proves nothing about the file. A content
address proves its contents, with no signature, no certificate, no trusted
server and no conversation with whoever sent the bytes. Fetching a repository
over a channel controlled by an attacker is still safe in this one respect,
because every object that arrives is verified against the name it arrived under
and a substituted object fails that check.
The property this leans on is worth naming precisely, because the loose version
of it is what causes the panic in the last section. It is not that nobody can
find two contents with the same name. It is that nobody handed a name can find
contents to fit it. Finding a pair that agree is a collision; being handed one
of them and having to produce the other is a second preimage, and the second is
a far harder problem. With twenty byte names there are two to the power of one
hundred and sixty possible names, about one and a half times ten to the
forty eighth, and that is the search a second preimage attack faces.
Nothing is ever edited
Here is the consequence that shapes every operation to come. You cannot modify
an object. Not because something forbids it, but because the request does not
parse: changing the contents under a name would change the name, so what you
have described is the writing of a second object and the first one is still
there.
So an append only store falls out of an arithmetic rule rather than out of a
policy somebody has to enforce. There is no code path that overwrites an
object, because no caller can ask for one.
The one way a name could lie
All of this rests on two different contents never sharing a name, and SHA-1 no
longer guarantees that. In 2017 two different files with the same SHA-1 were
published, and the work was measured at about two to the power of sixty three
point one hash computations, which is nine point nine times ten to the
eighteenth. Compare that with the birthday bound for a one hundred and sixty
bit hash, two to the power of eighty, or one point two one times ten to the
twenty fourth. Dividing one by the other gives two to the power of sixteen
point nine, about a hundred and twenty two thousand. So collisions are a
hundred and twenty two thousand times cheaper than they should be, and that is
a real break rather than an academic one.
It is also the wrong attack for this purpose, which is why the response was
proportionate rather than panicked. A collision gives an attacker two files
they chose themselves. To poison a repository they would need the other thing:
your file, your name, and a replacement of their choosing fitting the name you
already hold. That is a second preimage, and the published work does not
provide one or come close to providing one.
Three things were done anyway. The hashing path now runs a collision detector,
which recognises the distinctive structure the known attack leaves behind and
refuses such an object rather than storing it. A whole second object format
using SHA-256 exists and a repository can be created with it by naming the
object format when it is initialised. And the length is in the header, which
removes the simplest length extension games from the board.
The arithmetic underneath never changed, and neither did anything that follows
from it. Compute a name from the contents and you get deduplication for free, a
proof of contents for free, and a store that cannot be edited. What is left is
to build a directory and then a commit out of these named pieces, and to find
out why keeping a whole snapshot per commit turns out to cost almost nothing.
Recap
- A Git object's name is the SHA-1 of a short header, which is the object's type word, a space, its length in bytes and a zero byte, followed by the contents, so hashing the file's bytes on their own gives a different forty character answer.
- Because the name is computed from the contents, storing the same contents twice stores the same name twice, which is why a file left alone across a hundred commits is held exactly once rather than a hundred times.
- A content address is also a proof, since recomputing the hash of whatever arrived and comparing it with the name that was asked for detects corruption or substitution without any signature or any trusted channel.
This is the reading half
Starting the course gives you your own copy of it. Every idea on every page has problems standing under it, marked with a reason rather than a tick, and any sentence you do not believe can be opened and argued with. None of that can happen on a page nobody owns.
The contents