Git内部でのファイルの扱われ方を調べる

Git の内部構造探索の旅に出ている。
やはり Git を使うからには Git の内部構造を避けて通ることは出来ないし、 Git を使うからには Git クローンの実装をしてみるのも面白そうと思っているのが理由。

今回は Git 内部でのファイルの扱い方を特に見ていこうと思う。

.git/objects の中身

git initすると.gitディレクトリができるのはGitを使っていたら誰でも気づいていると思う。
この中にはいくつかファイル・ディレクトリが存在するけど、コミットした各ファイルやディレクトリの木構造、コミットの実体が置かれるのが .git/objects になる。

.
├── 61
│   └── 67b342ca4d0c30b7400ebcd7021e1325a7975b
├── ab
│   └── 63bca019c8f1c618c27851e3ffd3e9537d9d81
└── d6
    └── 42a9f2b5a52125640c2c7299ddd80facc2796d

ディレクトリは各オブジェクトのSHA-1ハッシュの先頭2文字で、ファイルは zlib フォーマットで圧縮されている。
注意すべきはオブジェクトファイルのハッシュであるということで、コンテンツのハッシュではないということ。
なので、 sha1sum /path/to/file でハッシュを算出しても、該当するオブジェクトを見つけることは出来ない。

ファイルを解凍する最も簡便な方法は pigz を使うことだった。
Arch なら普通に yaourt -S pigz で入る。
ファイルに .zz 拡張子をつけてから、 pigz -d .git/objects/xx/yyyy.zz で解凍が行われる。
戻すときは、 pigz -z .git/objects/xx/yyyy で zlib フォーマットに圧縮が行われるので、拡張子を削除すると良い。

解凍したファイルは、こんな感じになる。

blob 13<NULL>Hello, World.
* <NULL> は NULL 文字を指す。

構造としてはこんな感じだ。

[オブジェクトタイプ] [コンテンツサイズ]<NULL>[コンテンツ]

オブジェクトタイプ

Git のオブジェクトは 3 種類存在する。
git cat-file だと -t オプションでオブジェクトタイプを確認することができる。

  • blob
    • コンテンツそのものが格納されている
  • tree
    • ディレクトリの構造を表している
    • ディレクトリ直下のファイルを表すために blob オブジェクトのハッシュを参照として持っている
    • サブディレクトリは別の tree オブジェクトのハッシュを参照として持つと形で表現される
  • commit
    • コミットの実体
    • 持っている情報は以下の通り
      • コミット時点でのレポジトリルートディレクトリの tree へのリンク
      • 親コミットのハッシュ
      • コミットの作者
      • コミッタ
      • コミットメッセージ

関係性を図解するとこんな感じになる。 Git Objects

コンテンツ

blob

blob オブジェクトのコンテンツにはファイルの内容そのものが格納されている。
Git オブジェクトの中では最も単純な構造になっている。

興味深いのはファイルの内容だけが格納されていることだ。
このため、ファイル移動やリネームだけなら blob オブジェクトは生成されないで済む。

tree

tree ディレクトリはディレクトリの構造を表しているので、コンテンツも通常のディレクトリと同じように直下に存在するファイルやディレクトリへの参照になっている。
参照として他のと同じく SHA-1 ハッシュが利用されている。

コンテンツ部分は下記のような構造となっている。

[モード] [blob または tree の名前]<NULL>[ハッシュ]

[モード] にはファイルのパーミッションや種類が記録されている (e.g. 100644 ならファイルで UNIX パーミッションが 644)。

commit

Git のコミットの実体。 コンテンツ部分の構造は下記のような感じとなっている。

tree [ルートディレクトリのハッシュ]
parent [親コミットのハッシュ]
author [作者名][作者email]
comittor [コミッタ名][コミッタemail]

[コミットメッセージ]

parent はイニシャルコミットにはつかない。

Conclusion

Git の内部構造を見てみると、 Git が大容量ファイルの扱いが苦手と言われる理由がよくわかる。
コミット時点のファイルの複製をそのまま保持しているのだから、些細な変更でもレポジトリの容量は倍に膨れ上がってしまう。
一方でこの挙動はカーネル開発が要求する高速なマージ処理を上手く処理するためには必須であるのだとも推察されるけど良くは判っていない。
あの開発頻度だとパッチ投稿時点の HEAD とマージ時点の HEAD が違うからマージのためにまず diff を取らないといけない、とかだろうか。あとで am を調べないといけないようだ。
Git-Flow や GitHub-Flow をであれば、ブランチのマージがストレスなく行える利点があると思えば良いと思う。
(ただし、 SSD が NVMe によって高速化した現代において2コミット間の diff を積み重ねていく Mercurial のような実装が、2コミット間のスナップショットの diff を取る Git にどこまでハンディキャップがあるのかは不明だけど)