Git の内部構造:オブジェクト・packfile・gc・bisect を実測
blob のハッシュを手計算し、cat-file でツリーとコミットを読み、plumbing でコミットを作成。gc と巨大ファイルを実測し、bisect で 300 中 8 回で原因を特定。
シリーズ:Git 講座 6/6(上級)。 毎日 Git を使っていて(第1〜5回)、.git の中に何があるのか、なぜリポジトリが重く巨大になるのか、何かを壊したコミットをどう見つけるのかを知りたい人向けです。オブジェクトのハッシュを手で計算し、git commit を使わずにコミットを作り、gc・大容量ファイル・bisect を実測します。
「動画ファイルはコミットするな」と言われたけれど数字で納得したい人、rebase でハッシュが変わる理由が腑に落ちない人、300 個のコミットのどれが計算を壊したのか探さなければならない人のための記事です。オブジェクトモデルを知ると、Git の不思議な挙動(rebase 後に ID が変わる、「消した」コミットが戻ってくる、クローンが巨大になる)が予測できる結果に変わります。ここに載せたのは公式ドキュメントの言い換えではなく、すべて実際に実行して測った結果です。
環境:Git 2.54.0(Apple Git-157)、macOS の shasum、テストデータの生成に Python 3.9.6(システム標準)、Apple Silicon Mac(M1 Pro)。作業用リポジトリでデモ用の名前と固定日時を使っているので、以下のハッシュは再現できます。
blob は「blob <サイズ>\0」+中身
$ printf 'hello\n' > hello.txt
$ git hash-object hello.txt
ce013625030ba8dba906f756967f9e9ca394464a
$ printf 'blob 6\0hello\n' | shasum -a 1
ce013625030ba8dba906f756967f9e9ca394464a -
秘密はこれだけです。オブジェクト ID は、短いヘッダー(blob、空白、バイト数、NUL バイト)に中身を続けたものの SHA-1 です。ファイル名は含まれません。git add すると、ハッシュを名前にしたファイルがディスクに現れます(先頭2桁がディレクトリ名)。
$ find .git/objects -type f
.git/objects/ce/013625030ba8dba906f756967f9e9ca394464a
$ git cat-file -t ce01362 -> blob
$ git cat-file -s ce01362 -> 6
$ git cat-file -p ce01362 -> hello
ディスク上のファイルは 21 バイトで、zlib で圧縮されています。Python で展開すると、ハッシュされたバイト列そのものが見えます。
$ python3 -c "import zlib;print(zlib.decompress(open('.git/objects/ce/013625030ba8dba906f756967f9e9ca394464a','rb').read()))"
b'blob 6\x00hello\n'
1文字変えれば、まったく別のオブジェクトになります(hello → v2 で 8c1384d…)。Git は「変更点」を保存しません。版ごとに丸ごと保存し、後で packfile が圧縮します。
ツリーとコミット:スナップショットの保存方法
src/copy.txt(中身は同じ hello)と src/main.py を追加してコミットし、オブジェクトを数えました。
.git/objects/3d/f2b26e… tree (src/)
.git/objects/68/b30a75… tree (ルート)
.git/objects/87/2bfebe… commit
.git/objects/b9/17a726… blob (main.py)
.git/objects/ce/013625… blob (hello.txt と src/copy.txt の両方)
3ファイルなのにオブジェクトは5つ。hello を含む2ファイルが blob を1つ共有しているからです。中身を読みます。
$ git cat-file -p HEAD
tree 68b30a75a76fffa7bf25023339501ec8cd7104d5
author demo <demo@example.com> 1794445200 +0000
committer demo <demo@example.com> 1794445200 +0000
First commit
$ git cat-file -p HEAD^{tree}
100644 blob ce013625030ba8dba906f756967f9e9ca394464a hello.txt
040000 tree 3df2b26ecb8bcdda43db72b3f32af69ffa7736b3 src
$ git cat-file -p HEAD:src
100644 blob ce013625030ba8dba906f756967f9e9ca394464a copy.txt
100644 blob b917a726c93f902e43291d9009d6488385133b67 main.py
ツリーはディレクトリの一覧(モード、種類、ハッシュ、名前)、コミットは小さなテキスト(ルートツリー、親コミット、作者、コミッター、メッセージ)です。そしてコミットも blob と同じ方法でハッシュされます。
$ git cat-file commit HEAD > c.raw
$ printf 'commit %s\0' $(wc -c < c.raw | tr -d ' ') | cat - c.raw | shasum -a 1
872bfebe6661489bdb33dce1ebf41088eb94f21b -
$ git rev-parse HEAD
872bfebe6661489bdb33dce1ebf41088eb94f21b
親のハッシュがコミットの本文に入っているので、どこか1つのコミットを変えると、それ以降のすべてのコミットのハッシュが変わります。rebase が「履歴を書き換える」理由(第4回)も、コミット ID がその裏の履歴全体を保証できる理由も、ここにあります。
plumbing コマンドだけでコミットを作る
git commit は3つの「plumbing(配管)」コマンドを包んだ便利コマンドです。
$ echo "v2" > hello.txt
$ git add hello.txt
$ git write-tree
9bc1402c06ef1cef8f6d0e539a5333f3797b22de
$ echo "Commit made by hand" | git commit-tree 9bc1402 -p HEAD
6b582d907ea233b6037ec127b2e4405c052d999b
$ git update-ref refs/heads/main 6b582d9
$ git log --oneline
6b582d9 Commit made by hand
872bfeb First commit
$ git status --short
<- 何も出ない:インデックスは新しいツリーと一致している
write-tree はインデックスからツリーオブジェクトを作り、commit-tree はツリーをコミットで包み、update-ref はブランチ名を動かします。ブランチの正体は、ハッシュが1行書かれたファイルにすぎません。オブジェクトをいじり始めると出会うエラー:
$ git cat-file -p 0000000
fatal: Not a valid object name 0000000
SHA-256 のリポジトリもあります(git init --object-format=sha256)。そこでは git hash-object が 2cf8d83d…ebb4 を出力し、printf 'blob 6\0hello\n' | shasum -a 256 と一致しました。仕組みは同じで、ハッシュが長いだけです。
loose オブジェクトと packfile:gc を実測
git add や git commit のたびに loose オブジェクト(1オブジェクト1ファイルの zlib)が書かれます。git gc はそれを packfile にまとめ、似たオブジェクトは互いの差分(delta)として保存します。効果を見るため、Python の random.seed(7) で「1,000 行の CSV(30,691 バイト)の1行だけを変えるコミット」を 1,000 個作りました。在庫と価格の一覧を毎日少しずつ更新するイメージです。git fast-import で取り込んだ後 git unpack-objects で loose に展開し、毎回同じ 3,000 個の loose オブジェクトから始めています。
## gc 前
count: 3000
size: 19.53 MiB
## gc 後
count: 0
in-pack: 3000
packs: 1
size-pack: 484.38 KiB
| 回(Apple Silicon Mac、M1 Pro) | gc 前の .git/objects |
gc 後 | git gc の時間 |
|---|---|---|---|
| 1 | 20,000 KB | 564 KB | 0.80 秒 |
| 2 | 20,000 KB | 576 KB | 0.92 秒 |
| 3 | 20,000 KB | 568 KB | 0.92 秒 |
pack の中身を種類別に(git cat-file --batch-check='%(objectsize) %(objectsize:disk)'):
| 種類 | 個数 | 元のサイズ | pack 内のサイズ |
|---|---|---|---|
| blob | 1,000 | 30,661,605 B | 214,805 B |
| tree | 1,000 | 39,000 B | 49,846 B |
| commit | 1,000 | 209,837 B | 146,248 B |
git verify-pack -v によると 990 個が delta として保存され、チェーンの深さは最大 49 でした。30 MB の CSV の履歴が 210 KB の blob になっています。ツリーに注目してください。小さなオブジェクトは zlib の固定オーバーヘッドのせいで、圧縮すると逆に大きくなります。
普段は自分で gc を実行する必要はありません。loose オブジェクトが一定数を超えると、Git が多くのコマンドの後に gc --auto を走らせます。第4回でベンチマークを壊したのも、このバックグラウンドの自動実行でした。
大容量バイナリ:痛みを実測する
次はよく警告されるケースです。20 MiB のランダムなバイト列(版 v ごとに Python の random.seed(v) を使うので再現可能)を 10 版コミットしました。動画やゲームのアセットを書き出し直すたびにコミットする状況です。
## 10 コミット後の loose
count: 40
size: 201.51 MiB
## git gc(1回):24.49 秒
size-pack: 200.07 MiB
gc で減ったのは 0.7% で、時間はテキスト履歴の 0.8〜0.9 秒に対して 24.49 秒。20,971,520 バイトの各版は pack の中で 20,977,930 バイトを占めます。ランダムな(またはすでに圧縮された)データは縮まず、有効な差分もないので、Git はすべての版を丸ごと永遠に保存し、クローンするたびに全部をダウンロードします。3通りの方法でクローンしました(ローカルの file:// 転送なのでネットワーク時間は含まない)。各3回:
| クローン | 1回目 | 2回目 | 3回目 | .git のサイズ |
|---|---|---|---|---|
完全(--no-local) |
1.46 秒 | 1.66 秒 | 2.99 秒 | 207〜213 MB |
--depth 1 |
0.23 秒 | 0.35 秒 | 0.28 秒 | 20 MB |
--filter=blob:none(blobless) |
0.27 秒 | 0.55 秒 | 0.39 秒 | 20 MB |
実際のネットワークでは差がもっと広がります。完全クローンは浅いクローンの約 10 倍のバイト数を転送するからです。部分クローンにも代償があります。浅いクローンでは git switch --detach HEAD~5 が fatal: invalid reference: HEAD~5 で失敗し(その履歴がない)、blobless クローンでは同じコマンドが成功する代わりに足りない版をその場で取得しました(ここでは 0.43 秒、.git は 20 MB から 40 MB に増加)。どうしてもバージョン管理が必要な大きなファイルには、Git にはポインタだけを置く Git LFS がありますが、このマシンには入っていないので試していません。
| 状況 | 選択 |
|---|---|
生成物(ビルド結果、.apk、.ipa、書き出しファイル) |
コミットしない。.gitignore に入れ、リリース成果物として別に保管 |
| コードと一緒に版管理が必要な大きなアセット | Git LFS(ここでは未実行) |
| 最新のコードだけ必要(CI、ちょっと見るだけ) | git clone --depth 1 |
| 履歴は必要だが古いファイルの中身は不要 | git clone --filter=blob:none |
| 大きなファイルをうっかりコミットしてしまった | 履歴に残り続ける。消すには履歴の書き換え(git filter-repo、ここでは未実行)と全員の再クローンが必要 |
git bisect:300 コミットから 8 回で犯人を見つける
税込み計算が最初は正しく、今は間違っています。その間に「Refactor step N」という名前のコミットが 300 個。バグ(10% が 1% になった)はコミット 211 にありますが、知らないふりをします。bisect は履歴を二分探索し、git bisect run を使えば各コミットの判定をスクリプトに任せられます。スクリプトは正常なら 0、異常なら 1 で終了する必要があります。
#!/bin/sh
# test_tax.sh:0 = 正常、1 = 異常
out=$(python3 -c 'from tax import total; print(total(1000))')
[ "$out" = "1100" ]
$ git bisect start HEAD HEAD~299
Bisecting: 149 revisions left to test after this (roughly 7 steps)
[95892e7b…] Refactor step 150
$ git bisect run ~/demo/test_tax.sh
running '~/demo/test_tax.sh'
Bisecting: 74 revisions left to test after this (roughly 6 steps)
[d0546bdf…] Refactor step 225
running '~/demo/test_tax.sh'
Bisecting: 37 revisions left to test after this (roughly 5 steps)
[e787361d…] Refactor step 187
...
[72f52537…] Refactor step 211
running '~/demo/test_tax.sh'
72f52537d6519b73cf52dd4aa0392555090184c1 is the first bad commit
commit 72f52537d6519b73cf52dd4aa0392555090184c1
Refactor step 211
tax.py | 4 ++--
bisect found first bad commit
$ git bisect reset
古い順に全コミットをチェックアウトして最初に失敗するまでテストする素朴な方法と比べました。各3回(Apple Silicon Mac、M1 Pro)。
| 履歴 | bisect のテスト回数 | bisect の時間 | 線形のテスト回数 | 線形の時間 |
|---|---|---|---|---|
| 300 コミット、バグは 211 番目 | 8 | 0.44 / 0.37 / 0.38 秒 | 211 | 11.69 / 13.73 / 12.89 秒 |
| 3,000 コミット、バグは 2,111 番目 | 11 | 0.52 / 0.50 / 0.49 秒 | 2,111 | 122.29 / 135.56 / 133.10 秒 |
履歴が 10 倍になっても、bisect のテストは 3 回増えただけです。テストに1分かかる(ビルドとテストスイート)なら、11 分と1日半の差になります。
よくある失敗
壊れた bisect スクリプトが別のコミットを犯人にする。 テストスクリプトにタイプミス(pyhton3)を入れてみました。毎回 pyhton3: command not found が表示され、比較が失敗し、どのコミットでもスクリプトは 1(「異常」)で終了。bisect は自信満々にこう報告しました。
2d4b99385a37b3ebbb3cd09658e6d8af549f2ddd is the first bad commit
Refactor step 2
bisect found first bad commit
対策:bisect run の前に、正常だとわかっているコミットでスクリプトを手動実行し、echo $? が 0 になることを確かめる。終了コード 125 は「テストできないのでスキップ」、127 を超えるコードは bisect を中断します。
start の前に git bisect good。 Git は You need to start by "git bisect start" と答えます。最後に git bisect reset も忘れずに。忘れると履歴の途中のコミットに detached のまま残ります。
.git/objects を ls -l してリポジトリの大きさを判断する。 見えるのは loose オブジェクトだけで、gc の後はほぼ空です。loose と pack を分けて表示する git count-objects -vH を使いましょう。
正直な限界・バージョンメモ
- 確認したのは Git 2.54.0(Apple Git-157)だけです。SHA-256 形式は Git 2.29 からあり、長く実験的扱いでした。SHA-1 と SHA-256 のリポジトリ間の相互運用は試していません。
- 部分クローン(
--filter)はサーバー側の対応が必要です。今回のローカル「サーバー」はuploadpack.allowFilter=trueにしています。ホスティングサービスによって異なります。 - gc の時間は CPU コア数(delta 探索はマルチスレッド)とディスクで変わり、実行ごとのばらつきは ±15% でした。大容量ファイルの gc は1回だけの計測です。
duとcount-objectsの値は少しずれます(ファイルシステムのブロックとファイルのバイト数の違い)。表にはどちらを使ったか書いています。- Git LFS と
git filter-repoはこの環境に入っておらず、説明のみです。
練習問題
- Git を使わずに、
hiと改行だけ(3バイト)のファイルのオブジェクト ID を表示するコマンドラインを書いてください。その後git hash-objectで確かめましょう。 - bisect のテストにはビルドが必要ですが、古いコミットでは依存関係が足りずに関係ない理由で失敗することがあります。その場合スクリプトは何で終了すべきで、なぜ 1 ではいけないのでしょう?
解答
printf 'blob 3\0hi\n' | shasum -a 1。printf 'hi\n' | git hash-object --stdinと一致するはずです(45b983be…)。- 125 で終了します。bisect に「このコミットはテストできないのでスキップ」と伝えるコードです。1 で終了するとそのコミットが異常と判定され、上の壊れたスクリプトと同じように探索が間違った場所へ進みます。
前回: Git 講座 5:reset・revert・reflog で元に戻す · シリーズの最初: Git 講座 1:init・add・commit · 関連:rebase と cherry-pick · 新人向け学習ロードマップに戻る
よくある質問
Git はファイルやコミットのハッシュをどう計算している?
ヘッダーと中身をまとめてハッシュします。ファイルなら 'blob <サイズ>' と NUL バイトの後に内容が続くバイト列です。printf 'blob 6\0hello\n' | shasum の結果は ce013625… で、git hash-object の出力と一致しました。コミットも本文(tree、parent、author、committer、メッセージ)を同じ方法でハッシュするので、親が変わると以降のハッシュがすべて変わります。
git gc でリポジトリは小さくなる?
テキストの履歴なら劇的に小さくなります。30 KB の CSV の 1,000 版は loose オブジェクトで 19.53 MiB、gc 後は 484 KiB でした。1,000 版のうち 990 が差分(delta)として保存されたためです。ランダムなバイナリにはまったく効かず、20 MiB のファイル 10 版は loose で 201.51 MiB、pack 後 200.07 MiB、gc に 24.5 秒かかりました。
git bisect は何回のテストで終わる?
おおよそコミット数の log2 回です。git bisect run とテストスクリプトで、300 コミットは 8 回(0.4 秒)、3,000 コミットは 11 回(0.5 秒)でした。古い順に全部テストすると 211 回と 2,111 回(約 12 秒と約 2 分)。スクリプトは正常で 0、異常で 1〜127(125 を除く)を返す必要があり、そうでないと bisect は違うコミットを犯人にします。