My working git was 6 commands and one rule. The commands were add, commit, pull, push, checkout and log. The rule was that when the repository got into a state I did not recognize, I deleted the directory and cloned it again. The rule works. It also means I never knew what I was deleting.
So instead of learning a seventh command I built a commit by hand, out of plumbing only. Everything below is git 1.7.8, built from source, at the default compression level. Author and committer dates are pinned to one second so that every hash here reproduces.
The names come first, in a repository with nothing in it:
$ printf 'hello world\n' > a.txt
$ git hash-object a.txt
3b18e512dba79e4c8300dd08aeb37f8e728b8dad
$ printf 'blob 12\0hello world\n' | sha1sum
3b18e512dba79e4c8300dd08aeb37f8e728b8dad -
The name of an object is the sha1 of a header plus the content. The header is the type, a space, the length in bytes and a zero byte. Hashing the 12 bytes on their own gives 22596363b3de40b06f981fb85d82312e8c0ed511, which this repository never refers to.
Then I copied a.txt to b.txt and wrote both:
$ git hash-object -w a.txt
3b18e512dba79e4c8300dd08aeb37f8e728b8dad
$ git hash-object -w b.txt
3b18e512dba79e4c8300dd08aeb37f8e728b8dad
$ find .git/objects -type f
.git/objects/3b/18e512dba79e4c8300dd08aeb37f8e728b8dad
2 files, one name, one object. The name is a function of those bytes, header included, so the same content under 2 paths cannot land in 2 objects. On disk the object is 28 bytes. 12 bytes of content and 8 bytes of header go into deflate and 28 come out. Compression added 8 bytes here.
A commit with no porcelain in it
A fresh repository, then 2 blobs, a tree, a commit and a ref. 5 calls:
$ git hash-object -w greeting.txt
3b18e512dba79e4c8300dd08aeb37f8e728b8dad
$ git hash-object -w note.txt
1c59427adc4b205a270d8f810310394962e79a8b
$ printf '100644 blob 3b18e512dba79e4c8300dd08aeb37f8e728b8dad\tgreeting.txt
100644 blob 1c59427adc4b205a270d8f810310394962e79a8b\tnote.txt
' | git mktree
b88f66c9fea440e76158a5d4dec51ecaf7e3f53d
$ echo handmade | git commit-tree b88f66c9fea440e76158a5d4dec51ecaf7e3f53d
b0eabc99e5bbf3ffdc8127c50e8ea05c0de3dfba
$ git update-ref refs/heads/master b0eabc99e5bbf3ffdc8127c50e8ea05c0de3dfba
The commit is 187 bytes of content and this is all of it:
$ git cat-file -p b0eabc99e5bbf3ffdc8127c50e8ea05c0de3dfba
tree b88f66c9fea440e76158a5d4dec51ecaf7e3f53d
author Dmitriy Semenkevich <dimhold@gmail.com> 1323253380 +0100
committer Dmitriy Semenkevich <dimhold@gmail.com> 1323253380 +0100
handmade
The tree is 76 bytes. The branch is a file with 40 hex digits and a newline in it:
$ wc -c .git/refs/heads/master
41 .git/refs/heads/master
$ cat .git/HEAD
ref: refs/heads/master
git log --stat reads that and prints my 2 files as added, which is what I came to see. A reader like log needs the objects and one ref. I had written both of those by hand.
Then this:
$ git status --short
D greeting.txt
D note.txt
?? greeting.txt
?? note.txt
Both files deleted and untracked at once. There was no .git/index on disk at all, because I built the tree with mktree and never went through the index. Git compares the index against HEAD, finds 2 files in HEAD and nothing in the index, then reports 2 deletions. The untracked list comes from walking the directory and keeping whatever the index does not mention, which is the same 2 files again. git reset with no arguments writes a 184 byte index from HEAD and the status goes quiet, with the ref left where it was. So the index is a third thing on disk, next to the objects and the refs. It holds the list of files git will put in the next commit. After git reset with no arguments that list is a copy of HEAD’s tree, which is why the status goes quiet.
What goes into the hash
commit-tree on the same tree with the same date and message gives b0eabc99 again. One second later gives 47e87c9eaa1871559a5ee5c44ca3170cfc73f0a2. The tree hashes to b88f66c9 both times.
The tree is a function of the content. The commit carries the time and the author too. I did not run a rebase here, but this is where the new commit ids after a rebase come from, because the committer line gets written again for every commit that moves.
What I thought a commit stored
I assumed a commit held a diff, because a diff is what git show prints. So I took read-cache.c out of the git source, 46456 bytes over 1703 lines. Then 11 commits: the file as it came, then 10 more, each rewriting a single line in the middle of it:
$ git diff --stat HEAD~1 HEAD
read-cache.c | 2 +-
1 files changed, 1 insertions(+), 1 deletions(-)
count-objects -v says count: 33. 33 objects for 11 commits, so 11 blobs and 11 trees under them. Every blob is a whole copy of the file, 16828 to 16833 bytes on disk depending on the revision. The loose objects add up to 187576 bytes. One rewritten comment line costs a full new blob.
Then git gc:
$ git count-objects -v
count: 0
size: 0
in-pack: 33
packs: 1
size-pack: 18
prune-packable: 0
garbage: 0
$ wc -c .git/objects/pack/*.pack
16490 .git/objects/pack/pack-d208f07049583dc92008db070bb8c6daa90890fa.pack
$ git verify-pack -v .git/objects/pack/pack-*.idx | grep ' blob '
1530d3b547cf0a52ec2f6f4ed20053f5229581ea blob 46468 13889 1783
4723c6ab5406c51c12595742b8770ebd36062d5b blob 16 29 15722 1 1530d3b547cf0a52ec2f6f4ed20053f5229581ea
7765d864e63e91df92c92c73404d6d857be734d3 blob 16 29 15802 1 1530d3b547cf0a52ec2f6f4ed20053f5229581ea
5790a91044e4fdf5b2eec515051a66c110e0daa4 blob 18 31 16439 1 1530d3b547cf0a52ec2f6f4ed20053f5229581ea
187576 bytes became 16490, a factor of 11.4. 4 of the 11 blob lines are above. Of the 7 I left out, 6 are 16 byte deltas and one is 14.
The base 1530d3b5 is the newest version of the file. The 10 older revisions are deltas against it at depth 1, between 14 and 18 bytes each. The revision I committed first is stored as an 18 byte delta from the last one. I read that size column twice before I believed it, because for a deltified object it holds the size of the delta:
$ git cat-file -s 4723c6ab5406c51c12595742b8770ebd36062d5b
46467
So diffs are real, but they sit a layer below the commit. The repack that gc runs computes them, backwards from the newest version here. A commit names a tree, that tree names the blob and the blob on disk is still a whole copy of read-cache.c.
41 bytes
The rule about deleting the directory is the part I wanted to fix. This runs on a copy of the repository taken before the gc:
$ git rev-parse HEAD
c4608054b7ae3474916ed487338c7d3a5c72ec20
$ git reset --hard HEAD~3
HEAD is now at 972ff27 revision 7
$ sed -n '853p' read-cache.c
* Trivial optimization, revision 7: if we find an entry that
$ git cat-file -t c4608054b7ae3474916ed487338c7d3a5c72ec20
commit
$ git reflog
972ff27 HEAD@{0}: reset: moving to HEAD~3
c460805 HEAD@{1}: commit: revision 10
Those are the first 2 of 12 reflog entries. The working tree went back 3 revisions. The commit I left is still an object and the reflog still names it. Getting it back took one update-ref. The reset --hard after that rewrote the index and 46 KB of working file out of objects that had not gone anywhere. count-objects reports 33 before and after all of it.
git branch invoice-fix writes a 41 byte ref and adds no object at all. It also writes a 167 byte reflog for the new branch, so a branch cost 208 bytes here. Cheap branching is a phrase I had repeated without a number behind it.
What I did not check
How the repack picks the delta base and whether depth stays at 1 on a real history. The defaults here are a window of 10 and depth up to 50. 11 revisions of one text file are a friendly input for a window of 10.
A binary file, where the delta search has nothing to reuse. Text only here.
When an unreachable commit actually goes away. I measured that it survives the reset and stopped at prune.