煙と消えるその前に

一服してるうちに忘れる、自分のための備忘録。とかとか

.git/objectsについて少し調べてみた

事の発端はささいな出来事。仕事でgitを使っていて、開発中のソースからビルドしたバイナリファイルもリポジトリに突っ込んで管理してる。最近やけにgit cloneした時に時間がかかるなーと思って見たら、リポジトリサイズが200Mb超えてる?!よくよく見たら.git/objects以下が肥大化してた。なんだこれ、と思って調べてみた。

こちらが大変参考になりました:

ざっくりまとめ

  • gitではcommit、tree、blob、3種類のオブジェクトでリポジトリを表現してる
    • blobがファイルそのもの。ただしファイル名などのメタデータは含まない
    • treeがディレクトリ。blobや配下treeのハッシュ値を持ち、あるコミット時点のディレクトリ構造を表現する。blobのメタデータはtreeが持ってる
    • commitがある時点のリポジトリを表すコミットそのもの。commit -> tree -> (blob|tree)*の関係になると理解した
  • gitオブジェクトはadd、commitされたタイミングで新しいものが生まれる
    • ファイルが修正されると、そのblobオブジェクトと、blobが入ってるtreeオブジェクトが新しくなる
    • 以前のgitオブジェクトはそのまま残る
  • blobはただのファイルコピーではなく、zlibで圧縮された状態になる
    • 元のファイルサイズに依存するから、大きなファイルをぽんぽん変更すると・・・
  • gitオブジェクトは何かのタイミングでpackfileにまとめられる
    • ディスクスペースの有効活用とかなんとか。どのタイミングでpackされるかはわからなかった
    • bareではunpackした状態で管理されてる。cloneするタイミングでpackして送ってくるみたい

長々調べたけど、更新頻度が高く、かつサイズが大きいファイルをgitで管理するのは賢くない。という結論に至った。今回の場合だと、ソースをビルドしたバイナリだから、ソースだけバージョン管理されてればいいわけだ・・・。

実際に調べてみた記録

そもそもgit commitした時に何が起きるのか

commitした時.git/objectsにオブジェクトが増えるらしい。実際にgit commitしたら何が起きるのか、ローカルにgitリポジトリ作って試してみる

#ローカルで作った空のリポジトリをclone
$ git clone localhost:/var/git/my_repo.git

#.git/* 以外は何もなし
$ ls -a
.  ..  .git

#.git/objectsの中身はディレクトリだけ
$ find .git/objects
.git/objects
.git/objects/info
.git/objects/pack

#ファイル作ってみる (この時点では.git/objectsには変化なし)
echo "first commit" > first.txt

#addしたら.git/objectsにファイルが増えた
$ git add first.txt
$ find .git/objects
.git/objects
.git/objects/info
.git/objects/pack
.git/objects/5e
.git/objects/5e/c586d228b5ff1e8c845c4ed8c2d01f3a159b24

addした時点で何やら.git/objectsにファイルが増えてる。
おそらく、今addしたファイルのハッシュ値なのかなーと思って見たらビンゴ。ハッシュ値の上位2桁がディレクトリ名になるようだ

$ git hash-object first.txt
5ec586d228b5ff1e8c845c4ed8c2d01f3a159b24

cat-file -p [sha-1]でオブジェクトの中身が見れるらしい。
試してみると、今作ったファイルだった。

$ git cat-file -p 5ec586d228b5ff1e8c845c4ed8c2d01f3a159b24
first commit

なるほど、gitってこうやってオブジェクトを管理してるのね。
そういやまたcommitしてなかった、と思いcommitしてみるとさらに何か増えた?!

$ git commit -m "my first commit"
$ find .git/objects -type f
.git/objects/47/17b4c5681f5c31e733b36ad571b8c90b62a1e7
.git/objects/59/4f237fd0220226fb625e781e633ff22e104e5a
.git/objects/5e/c586d228b5ff1e8c845c4ed8c2d01f3a159b24

git cat-file -tでオブジェクトのタイプがわかる。

#コミットしたファイルだとblob
$ git cat-file -t 5ec586d228b5ff1e8c845c4ed8c2d01f3a159b24
blob

#増えた残りの2つはtreeとcommit
$ git cat-file -t 4717b4c5681f5c31e733b36ad571b8c90b62a1e7
commit
$ git cat-file -t 594f237fd0220226fb625e781e633ff22e104e5a
tree

へーcommitそのものがオブジェクトになるのか。確認したら確かにハッシュ値が一致する

$ git log -1
commit 4717b4c5681f5c31e733b36ad571b8c90b62a1e7
...

で、treeってなんだ?と思ったところで、参考サイトを見て理解。
あるcommit時点でのファイルツリーを表現するためのものらしい。git ls-treeで中身を見てみると、あーなるほどね。ディレクトリを作ったらそいつがtreeになるわけだ。

$ git ls-tree 594f237fd0220226fb625e781e633ff22e104e5a
100644 blob 5ec586d228b5ff1e8c845c4ed8c2d01f3a159b24    first.txt

つまり、commit -> tree -> (blob|tree)* な関係で1つのcommitが管理されるのかー。
そしてファイル名とかのblobのメタ情報はtreeが持つと。

変更が入ると新しいオブジェクトが作られる

directory -> file1,file2 な関係の時ってcommitごとにオブジェクト作られるのかな?

#dir1を作って、その下に2つテキストを追加する
$ mkdir dir1
$ echo "second commit" > dir1/second.txt
$ echo "third commit" > dir1/third.txt
$ git add --all
$ git commit -m "added dir1/second.txt dir1/third.txt"

$ find .git/objects -type f
.git/objects/b5/abbe4d64d728c9b9468b515b0df03cebcdad06
.git/objects/47/17b4c5681f5c31e733b36ad571b8c90b62a1e7
.git/objects/2f/f0cc013f80a72af170879f7c91252348570649
.git/objects/59/4f237fd0220226fb625e781e633ff22e104e5a
.git/objects/61/82892b5d9799fea00e13db79bcb46beff10204
.git/objects/c3/e1215a4e3ed4a6f732b7768c4b73a307de5f65
.git/objects/29/fa8de42d514b1d99739e51105d214df6f7ada6
.git/objects/5e/c586d228b5ff1e8c845c4ed8c2d01f3a159b24

おー、増えた増えた。1つがcommit、1つがルートに相当するtree、残りがdir1のtreeと2ファイルのblobか。parentとして、前のcommitのハッシュ値も記録されてる。なるほど、そりゃそうか。

#今回のcommitオブジェクト
$ git cat-file -t 6182892b5d9799fea00e13db79bcb46beff10204
commit

#commitの中身
$ git cat-file commit 6182892b5d9799fea00e13db79bcb46beff10204
tree b5abbe4d64d728c9b9468b515b0df03cebcdad06
parent 4717b4c5681f5c31e733b36ad571b8c90b62a1

#ルート相当のtreeの中身
$ git ls-tree b5abbe4d64d728c9b9468b515b0df03cebcdad06
040000 tree 29fa8de42d514b1d99739e51105d214df6f7ada6    dir1
100644 blob 5ec586d228b5ff1e8c845c4ed8c2d01f3a159b24    first.txt

#dir1のオブジェクトの中身
$ git ls-tree 29fa8de42d514b1d99739e51105d214df6f7ada6
100644 blob 2ff0cc013f80a72af170879f7c91252348570649    second.txt
100644 blob c3e1215a4e3ed4a6f732b7768c4b73a307de5f65    third.txt

ここで、first.txtのハッシュ値が変わってないことに気づく。変更が入らないオブジェクトは更新されないのかな?

#dir1/second.txtを更新してみる
$ echo "fourth commit" >> dir1/second.txt
$ git commit -am "fourth commit to dir1/second.txt"

#commitのハッシュ値を調べて
$ git log -1
commit c0abb1b3f9d4d8e4414085f8d69ea9948009900e

#commitの中身を調べて
$ git cat-file commit c0abb1b3f9d4d8e4414085f8d69ea9948009900e
tree 59fd1590accc6e58d3a6695f820ddeedb6d2b276

#treeの中からdir1のオブジェクトを辿り
$ git ls-tree 59fd1590accc6e58d3a6695f820ddeedb6d2b276
040000 tree e53622b524682d1b33bda38a64581248dc9aaf46    dir1
100644 blob 5ec586d228b5ff1e8c845c4ed8c2d01f3a159b24    first.txt

#dir1オブジェクトの中身を見てみる
$ git ls-tree e53622b524682d1b33bda38a64581248dc9aaf46
100644 blob 2135c44150c46ddffabe9451f2dc7e3404012b19    second.txt
100644 blob c3e1215a4e3ed4a6f732b7768c4b73a307de5f65    third.txt

あー、なるほどね。変更されたものだけオブジェクトが増えると。dir1に入ってるファイルが更新されたから、dir1のハッシュ値も新しくなってる。毎回丸ごとオブジェクト作り直しは非効率だもんなー。

blobってただのファイルコピー?

それはそうと、blobオブジェクトってつまり、ほとんどファイルコピーみたいな物ってことなのか?!と思って試してみた。

#1Mbのtmpfileを作ってみて
$ dd if=/dev/zero of=tempfile bs=1M count=1
$ ls -lh tempfile | awk '{print $5}'
1.0M

#addしてハッシュ値調べて
$ git add tempfile
$ git hash-object tempfile
9e0f96a2a253b173cb45b41868209a5d043e1437

#サイズを見てみると
$ ls -lh .git/objects/9e/0f96a2a253b173cb45b41868209a5d043e1437 | awk '{print $5}'
4.6K

さすがにそんなことはないか。圧縮はされてる模様。
参考サイトによれば、

Gitは zlib を用いて新しいコンテンツを圧縮します。

http://git-scm.com/book/ja/Gitの内側-Gitオブジェクト

とのこと。ただまあ、もとファイルの大きさに比例したオブジェクトが変更のたびに作られていくわけだ!

あれ、てことはリポジトリのサイズはひたすら膨らむ一方なわけ・・・?

Git がディスク上にオブジェクトを格納する初期のフォーマットは、緩いオブジェクトフォーマット(loose object format)と呼ばれます。しかし Git はこれらのオブジェクトの中の幾つかをひとつのバイナリファイルに詰め込む(pack up)ことがあります。そのバイナリファイルは、空きスペースを保存してより効率的にするための、パックファイル(packfile)と呼ばれます。あまりにたくさんの緩いオブジェクトがそこら中にあるときや、git gc コマンドを手動で実行したとき、または、リモートサーバにプッシュしたときに、Git はこれを実行します。

http://git-scm.com/book/ja/Git%E3%81%AE%E5%86%85%E5%81%B4-%E3%83%91%E3%83%83%E3%82%AF%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB

なるほど、さすがに対策はされてるわけだ。とはいえ、どのタイミングでpackfileが作られるのかはよくわからず。
せっかくなのでgit gc試してみますか。

#tempfileに1行加えてcommit
$ echo "fifth commit" >> tempfile
$ git commit -am "fifth commit"

#作られたオブジェクトとサイズ確認
$ git hash-object tempfile
cf4391a3214fce294af57c452668895f993e1f66
$ ls -lh .git/objects/cf/4391a3214fce294af57c452668895f993e1f66 | awk '{print $5}'
4.6K

#git gcしてみる
$ git gc
Counting objects: 18, done.
Compressing objects: 100% (13/13), done.
Writing objects: 100% (18/18), done.
Total 18 (delta 6), reused 0 (delta 0)

#.git/objectsの下を見ると
$ find .git/objects
.git/objects
.git/objects/info
.git/objects/info/packs
.git/objects/pack
.git/objects/pack/pack-bd58b8db3630142ad24e2c79868b3721741cc942.idx
.git/objects/pack/pack-bd58b8db3630142ad24e2c79868b3721741cc942.pack

おー、objectsがごっそり消えて、packファイルなるものが出来てる。サイズ見てみると

$ ls -lh .git/objects/pack/pack-bd58b8db3630142ad24e2c79868b3721741cc942.pack | awk '{print $5}'
2.4K

なーるほど。ここまでcommitしてきたファイルがまさにpackされたわけだ。
こうやってディスクスペースの無駄遣いを減らしてるのね。
ちなみに、packした状態でpushしたらどうなるんだろう?

$ git push origin master

#bareブランチの方を見に行ってみる
$ find /var/git/my_repo.git/objects -type f
/var/git/my_repo.git/objects/b5/abbe4d64d728c9b9468b515b0df03cebcdad06
/var/git/my_repo.git/objects/47/17b4c5681f5c31e733b36ad571b8c90b62a1e7
(以下省略)

あっれー、packファイルがない。bareでは全部展開した状態になるのか。
ちなみに、もう一度cloneしなおすと、packされた状態になってた。
わざわざpackして送ってるってことか・・・。つまり、大きなオブジェクトが大量にあるとpackに時間がかかってしまうってことなのかな。
無意味にバイナリを突っ込むのは得策じゃないなぁ。