Basis Lab

How a Repository Remembers

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.

FIG 1The repository every name on this page came from
bash
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'
The four export lines are not decoration and they are not about tidiness. File names and directory names come out the same for everybody, because they are computed from contents alone, but a commit's name also covers who wrote it and when, so without those four lines your commit names would differ from the ones quoted here while every file name still matched. Running this twice in two folders gives two byte-identical object stores.

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 nn share a

seven character prefix with even odds once nn 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.

FIG 2The name of an object
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
Everything to the left of the contents is twelve bytes or so of header, and it is inside the hash rather than beside it. That is the detail that makes the arithmetic reproduce, and it is the detail every short explanation leaves out.

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.

FIG 3The name, computed without the tool
python
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 guess
Thirty seven bytes go into the hash to name twenty nine bytes of file. The third line is the one worth keeping: a plain SHA-1 of a file will never agree with the name the repository holds it under, and an hour has been lost by more than one person checking the arithmetic for a mistake that was in the header all along.

Now 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.

FIG 4Twenty one file versions, nine distinct names
stepcommitreadme.mdrender.pysettings.inidistinct names so farwhat happened
1Start the project88282c8e00f0514a5dd803Three files, three new names, three objects written.
2Draw the frame88282c85b6cf774a5dd804One file changed, so one name changed. The other two names are the names from the commit before, unchanged and therefore not written again.
3Raise the default width88282c85b6cf7704f61eb5The settings file goes from a width of 640 to 800. Three bytes differ and the whole name differs, which is what a hash does.
4Document the settings33769db5b6cf7704f61eb6Four commits in and six objects hold all twelve file versions so far.
5Add a tint setting88282c85b6cf7767379977The 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.
6Apply the tint88282c8d13018167379978The renderer gains a line. The settings file is carried over by the same name it had one commit ago.
7Mention the tintc12ee2ed13018167379979Nine names for twenty one versions. Twelve of the twenty one writes found their own name already present and did nothing.
7 steps
Read down the first file's column. The same seven characters appear in five of the seven commits, and that is not a shorthand for five copies: it is one object, named five times. The readme is unchanged in those five commits, so its contents are unchanged, so its name is unchanged, so there is nothing to write.

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.

FIG 5Storing one file, start to finish
Both branches out of the decision hand back the same name, and the caller cannot tell which one it took. That is why the store needs no concept of updating, and it is also why the operations that appear to rewrite history later on are appends with the old objects left in place, which is the only reason anything can be recovered after a mistake.

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

NextA Snapshot, Not a Change →

The rest of this course

  1. 01What a Git Hash Is Actually a Hash Ofyou are here
  2. 02Why a Commit Is a Whole Snapshot and Still Costs Almost Nothingopening only
  3. 03Every Commit Names Its Parent, and a Branch Is One Line in a Fileopening only
  4. 04Why the Same File Is Reported as Both Staged and Not Stagedopening only
  5. 05Why a Merge Only Stops to Ask About Some of Your Changesopening only
  6. 06Why Your Commits Get New Names When You Rebaseopening only
  7. 07A Hard Reset Deletes Nothing, and You Have About Thirty Daysopening only
  8. 08Why Your Push Was Rejected, and the One Push That Destroys Workopening only

Read alongside