git reset --hard で数時間分の作業が消えた。git push --force で同僚のコミットを吹き飛ばした。マージの結果が明らかにおかしいのに、どこで壊れたか説明できない。
この3つは別々の事故に見えて、原因は同じです。Gitが管理している3つのデータ構造 —— オブジェクト・参照・インデックス —— のどれを操作しているかを知らないまま、コマンドを暗記して使っている。
Gitのコマンドは覚える対象としては最悪です。checkout は「ブランチを切り替える」と「ファイルを戻す」という無関係な2つの仕事をしていました(だからv2.23で switch と restore に分割されました)。reset は3つの木のうち何個を動かすかがオプションで変わります。表面のコマンド体系は歴史的経緯の塊で、暗記に向いていません。
一方で中身は驚くほど単純です。Gitの本体は「内容のハッシュをキーにしたキーバリューストア」と「そこを指す41バイトのポインタ」と「次のコミットの下書き」の3つしかありません。これを理解すると、コマンドは暗記するものではなく導出できるものになります。
この記事では、Gitの公式ドキュメント(Git 2.55時点)と git/git リポジトリの Documentation/ を一次情報として、内部構造を分解します。すべての実行ログは手元の git version 2.50.1 で実際に取得したものです。読み終えたときには、事故が起きたときに「どの木が動いたのか」を自分で説明できる状態になります。
1. Gitはコンテンツアドレス指定のキーバリューストアである
実験:ハッシュは自分で計算できる
Gitのオブジェクト名(いわゆるコミットID・SHA)は、乱数でもシーケンスでもありません。内容から決定論的に計算されます。
$ echo -n "what is up, doc?" | git hash-object --stdin
bd9dbf5aae1a3862dd1526723246b20206e5fc37
この値は、Gitを使わなくても再現できます。Gitは本文をそのままハッシュするのではなく、<型> <バイト長>\0 というヘッダを前置してからハッシュします。
$ printf 'blob 16\0what is up, doc?' | shasum
bd9dbf5aae1a3862dd1526723246b20206e5fc37 -
完全に一致します。hash-object の実体は「ヘッダを付けてSHA-1を取る」だけです。
なぜヘッダを付けるのか。型を名前空間に含めるためです。同じバイト列でも blob として保存したものと tree として保存したものは別のオブジェクト名になります。型の取り違えが起きない設計になっています。
実験:ディスク上の形
-w を付けると、オブジェクトDBに書き込まれます。
$ printf 'hello\n' > a.txt
$ git hash-object -w a.txt
ce013625030ba8dba906f756967f9e9ca394464a
$ find .git/objects -type f
.git/objects/ce/013625030ba8dba906f756967f9e9ca394464a
パスは先頭2文字がディレクトリ名、残り38文字がファイル名です。1つのディレクトリにファイルが数十万個並ぶのを避けるための分割で、これがなければ多くのファイルシステムで走査が急激に劣化します。
中身はzlibで圧縮されています。展開すればヘッダごと見えます。
import zlib
data = open('.git/objects/ce/013625030ba8dba906f756967f9e9ca394464a', 'rb').read()
print(repr(zlib.decompress(data)))
# => b'blob 6\x00hello\n'
blob 6\0hello\n —— まさにハッシュを取った対象そのものが、そのまま保存されています。Gitのオブジェクトストアは、この意味で極めて素直です。
これが効いてくる場所
「Gitは差分ではなくスナップショットを保存する」という説明はここから来ます。コミットは毎回ツリー全体を指しますが、内容が同じファイルは同じオブジェクト名になるので、変更されていないファイルは新しい実体を作りません。1万ファイルのうち1つを変えたコミットは、blobを1つ増やし、変更されたディレクトリ分のtreeを作り直すだけです。
そして「同じ内容は必ず同じ名前」という性質は、そのまま整合性検証になります。ダウンロードしたオブジェクトを展開してハッシュを取り直せば、改ざんも転送エラーも検出できる。Gitの fsck はこれをやっています。
2. 4つのオブジェクト型
Gitのオブジェクトは4種類しかありません。
| 型 | 役割 | 中身 |
|---|---|---|
blob | ファイルの内容 | バイト列そのもの(ファイル名もパーミッションも持たない) |
tree | ディレクトリ | 「モード・型・オブジェクト名・名前」の並び |
commit | スナップショット+メタデータ | ルートtree、親コミット、author/committer、メッセージ |
tag | 注釈付きタグ | 対象オブジェクト、タガー、メッセージ、任意で署名 |
tree:ファイル名はここにある
blobは名前を持ちません。名前とパーミッションを持つのは tree の側です。
$ git cat-file -p 2e81171448eb9f2ee3821e3d447aa6b2fe3ddba1
100644 blob ce013625030ba8dba906f756967f9e9ca394464a a.txt
モードはUnixのパーミッションそのものではなく、Gitが認める限られた語彙です。
| モード | 意味 |
|---|---|
100644 | 通常ファイル |
100755 | 実行可能ファイル |
120000 | シンボリックリンク |
040000 | サブディレクトリ(tree) |
160000 | gitlink(サブモジュールのコミット参照) |
gitformat-index(5) は、インデックスのモードフィールドについて「通常ファイルで有効なのは 0755 と 0644 のみ」と明記しています。Gitは実行ビット以外のパーミッションを記録しません。chmod 600 がバージョン管理されないのはこのためで、秘密ファイルの権限をGitに任せてはいけない理由でもあります。
commit:IDが変わる条件
$ git cat-file -p 8994a20d11cbdb935ae7a857349e19a815e2e61e
tree 2e81171448eb9f2ee3821e3d447aa6b2fe3ddba1
author Demo <a@example.com> 1786806000 +0900
committer Demo <a@example.com> 1786806000 +0900
first
コミットオブジェクトの中身はこれだけです。マージコミットなら parent 行が複数並び、署名付きなら gpgsig 行が入ります。
重要なのは、このテキスト全体のハッシュがコミットIDであるという事実です。ここから直接導かれる帰結が3つあります。
git commit --amendは既存コミットを編集しない。 メッセージが変われば別のバイト列=別のIDになる。元のコミットはそのまま残り、参照が新しい方を向くだけ。- rebaseは履歴の「移動」ではなく「作り直し」。 親が変われば子のIDも変わり、その子も…と連鎖する。だから共有ブランチのrebaseは他人の履歴と両立しない。
- 署名はコミットの一部。
gpgsigはコミットオブジェクトに含まれるため、コミットIDが署名を含んだ内容にコミットしている。後から署名だけ差し替えることはできない。
実演:配管コマンドだけでコミットを作る
Gitの「磁器(porcelain)」コマンドが内部で何をしているかは、配管(plumbing)だけで再現すると一目で分かります。
$ git init -q -b main demo && cd demo
$ git config user.email a@example.com && git config user.name Demo
# 1. 内容をオブジェクトDBへ(blobができる)
$ printf 'hello\n' > a.txt
$ git hash-object -w a.txt
ce013625030ba8dba906f756967f9e9ca394464a
# 2. インデックスに登録(= git add 相当)
$ git update-index --add --cacheinfo 100644,ce013625030ba8dba906f756967f9e9ca394464a,a.txt
$ git ls-files --stage
100644 ce013625030ba8dba906f756967f9e9ca394464a 0 a.txt
# 3. インデックスからtreeオブジェクトを作る
$ git write-tree
2e81171448eb9f2ee3821e3d447aa6b2fe3ddba1
# 4. treeからcommitオブジェクトを作る(親がないので初回コミット)
$ git commit-tree 2e81171448eb9f2ee3821e3d447aa6b2fe3ddba1 -m "first"
8994a20d11cbdb935ae7a857349e19a815e2e61e
# 5. ブランチをそのコミットに向ける(= ここで初めて「コミットされた」)
$ git update-ref refs/heads/main 8994a20d11cbdb935ae7a857349e19a815e2e61e
git commit の正体は、この2〜5を1コマンドにまとめたものです。逆に言えば、git commit を実行しても最後の update-ref が失敗すればオブジェクトだけが残ります。「コミットしたはずなのにログに出ない」ときに、まずオブジェクトを疑うのではなく参照を疑うべき理由がここにあります。
3. 参照(refs):ブランチの正体は41バイト
ブランチはファイルである
$ cat .git/refs/heads/main
8994a20d11cbdb935ae7a857349e19a815e2e61e
$ wc -c .git/refs/heads/main
41 .git/refs/heads/main
40文字の16進表現+改行で41バイト。これがブランチの実体です。
だから、
- ブランチ作成が一瞬で終わる。 41バイトのファイルを1つ書くだけ。
- ブランチをいくつ作ってもリポジトリは重くならない。
- 「ブランチを消した」は履歴の削除ではない。 ポインタを消しただけで、オブジェクトはまだそこにある(→ 復旧できる)。
HEAD は少し違って、シンボリック参照です。
$ cat .git/HEAD
ref: refs/heads/main
HEAD が別の参照を指しているのが通常の状態、HEAD が直接オブジェクト名を持っているのが detached HEAD です。「detached HEADでコミットしても消える」と言われるのは、HEAD を進めても対応するブランチファイルが存在しないため、別のブランチに移動した瞬間にそのコミットを指すものが無くなるからです(reflogには残ります)。
packed-refs と reftable
参照が数万個になると、41バイトのファイルが数万個という構造は破綻します。従来の対策が .git/packed-refs(全参照を1つのテキストファイルにまとめる)で、Gitが持つ新しい答えが reftable です。
git-init(1) の記載は明確です。
--ref-format=<format>の有効な値はfiles(loose filesとpacked-refs。これが既定)とreftable(reftable形式)である。
そして公式の BreakingChanges.adoc は、Git 3.0でreftableを新規リポジトリの既定にすると宣言し、その理由を列挙しています。要約すると次の5点です。
- 大文字小文字を区別しないファイルシステム(Windows/macOS)で、
refs/heads/Fooとrefs/heads/fooを同時に持てない。 reftableは参照名をファイルパスで表現しないのでこの問題が消える。 - macOSのUnicode正規化により、異なる符号化の同名参照を保持できない。 同様に解消。
filesバックエンドでの参照削除はpacked-refs全体の書き直しを要求する。 巨大リポジトリでは数十MB〜GB規模になる。reftableは墓標(tombstone)で消すので全書き直しが要らない。- 複数参照の同時更新が
filesではアトミックでない。 トランザクションのコミット途中の状態が他プロセスから見えうる。 - 多数の参照を一度に書くのが遅い。 参照ごとにファイルを作るため。reftableは「桁違いに(by multiple orders of magnitude)」速いと明記されている。
reftableはブロック単位のバイナリ形式で、参照名にプレフィックス圧縮を掛け、.git/reftable/tables.list に列挙されたテーブルの不変スタックとして更新されます。新しい更新は新しいテーブルとして追記され、tables.list の差し替えがアトミックなコミットになります。読み手はスタックを新しい順に検索します。
いま試すなら1コマンドです。
$ git init --ref-format=reftable myrepo
$ ls myrepo/.git/reftable/
0x000000000001-0x000000000001-0d8df5e0.ref
tables.list
$ cat myrepo/.git/reftable/tables.list
0x000000000001-0x000000000001-0d8df5e0.ref
ファイル名は ${min_update_index}-${max_update_index}-${random}.ref。更新のたびに新しいテーブルが増え、tables.list に追記されていきます。
既存リポジトリの移行は git refs migrate --ref-format=reftable で行えます(ミラーやフックがパスを直接読んでいないかを先に確認してください)。
リモート追跡ブランチは「前回見た相手の値」
refs/remotes/origin/main は、リモートの現在値ではありません。fetch した時点でのスナップショットです。この理解が、次節の --force-with-lease の安全性を正しく評価する鍵になります。
応用:CIでのアトミックな参照更新
参照の更新をトランザクションにまとめたいときは git update-ref --stdin を使います。すべて成功するか、1つも適用されないかのどちらかになります。
# リリースタグと release ブランチを不可分に更新する
$ git update-ref --stdin <<'EOF'
start
update refs/heads/release ceb1a2f9c8ec5f4e2a1d0b3c6e7f8a9b0c1d2e3f
create refs/tags/v1.4.0 ceb1a2f9c8ec5f4e2a1d0b3c6e7f8a9b0c1d2e3f
prepare
commit
EOF
update <ref> <newvalue> [<oldvalue>] の第3引数に期待する現在値を書けます。これはリモート側の楽観ロックそのもので、並行するCIジョブ同士の踏み合いを検出できます。ブランチ保護ルールでは表現できない「複数参照を同時に動かす」要件は、この方法でしか安全に満たせません。
4. インデックス:3番目の木
.git/index の中身
「ステージング領域」と呼ばれるものの実体は .git/index という単一のバイナリファイルです。gitformat-index(5) によれば、
- 12バイトのヘッダ:シグネチャ
{'D','I','R','C'}("dircache"の略)、バージョン番号(現在の対応は2・3・4)、エントリ数 - ソート済みのエントリ列
- 拡張(extension)領域
- 全体のハッシュチェックサム
エントリ1件が持つのは、オブジェクト名とパスだけではありません。stat(2) の結果がまるごと入っています —— ctime(秒+ナノ秒)、mtime(秒+ナノ秒)、dev、ino、mode、uid、gid、ファイルサイズ。
これが git status の速度の正体です。Gitは作業ツリーの全ファイルを読み直しているのではなく、lstat() の結果とインデックスの記録を比較して、一致すれば中身を読まない。差分がありそうなものだけ実際に読みます。
「git status が遅い」ときの原因はほぼこの層にあります。対策は順に、
| 症状 | 効く設定 | 何をしているか |
|---|---|---|
| ファイル数が多い | git config core.untrackedCache true | 未追跡ファイル探索のディレクトリ単位キャッシュ(UNTR 拡張) |
| 仮想FS・ネットワークFS | git config core.fsmonitor true | OSのファイル変更通知を使い、走査対象を変更分だけに絞る(FSMN 拡張) |
| モノレポで作業範囲が狭い | cone modeのsparse-checkout | 作業対象外をディレクトリ単位のエントリに畳む(sparse index) |
sparse indexは特に効きます。gitformat-index(5) はこう説明しています。
sparse-checkoutがcone modeで有効(
core.sparseCheckoutCone)でextensions.sparseIndexが有効な場合、インデックスにはsparse-checkout定義の外側のディレクトリのエントリが含まれることがある。これらのエントリはモード040000を持ち、SKIP_WORKTREEビットを含み、パスはディレクトリ区切りで終わる。
つまり 10万ファイルのサブツリーが、インデックス上の1エントリに畳まれる。エントリ数がそのまま git status のコストなので、これは線形の削減ではなく桁の削減です。
cache tree 拡張:write-tree が速い理由
インデックスの TREE 拡張(cache tree)は、ディレクトリごとに計算済みのtreeオブジェクト名をキャッシュしています。変更のないディレクトリはtreeを作り直す必要がないと即座に判断できるため、git write-tree(=git commit の内部処理)が大規模リポジトリでも一定時間で終わります。
コンフリクトの正体:stage 1 / 2 / 3
普段インデックスのエントリは stage 0 です。マージが衝突すると、同じパスに3世代が同居します。
$ git merge feature
Auto-merging f.txt
CONFLICT (content): Merge conflict in f.txt
Automatic merge failed; fix conflicts and then commit the result.
$ git ls-files -u
100644 83db48f84ec878fbfb30b46d16630e944e34f205 1 f.txt
100644 d791e9b8158e2be3a792fe1881d57828989a3449 2 f.txt
100644 00dbdcfd2b3a2c6a3a0facd9c753578741dc921e 3 f.txt
| stage | 意味 | 取り出し方 |
|---|---|---|
| 1 | 共通祖先(base) | git show :1:f.txt |
| 2 | 自分の側(ours / HEAD) | git show :2:f.txt |
| 3 | 相手の側(theirs / MERGE_HEAD) | git show :3:f.txt |
これを知っていると、コンフリクト解決が推測ではなく比較になります。
# 「相手は何を変えたのか」を祖先との差分で見る
$ git diff $(git merge-base HEAD MERGE_HEAD) MERGE_HEAD -- f.txt
# 3世代を並べて見る(--conflict=diff3 は祖先も出す)
$ git checkout --conflict=diff3 -- f.txt
そして git add の意味も変わります。git add は「stage 1/2/3 を捨てて stage 0 を1つ作る」=「解決したと宣言する」操作です。中身を確認せずに git add . を打つのがなぜ危険か、これで説明できます。
5. 3つの木で reset と restore を導出する
ここまでで登場した3つの木を並べます。
| 木 | 実体 | 役割 |
|---|---|---|
| HEAD | .git/HEAD → ブランチ → コミット | 「最後にコミットした状態」 |
| インデックス | .git/index | 「次にコミットする予定の状態」 |
| 作業ツリー | ファイルシステム | 「いま編集している状態」 |
git reset は「どこまでの木を巻き戻すか」を指定するコマンドです。
| コマンド | HEAD | インデックス | 作業ツリー | 典型的な用途 |
|---|---|---|---|---|
git reset --soft <c> | ✅ 動く | ❌ | ❌ | 直近のコミットをやり直す(変更はステージ済みのまま) |
git reset --mixed <c>(既定) | ✅ 動く | ✅ 動く | ❌ | ステージを解除する |
git reset --hard <c> | ✅ 動く | ✅ 動く | ✅ 動く | 完全に巻き戻す(未コミットの変更は失われる) |
git restore --staged <path> | ❌ | ✅ 動く | ❌ | 特定ファイルのステージだけ解除 |
git restore <path> | ❌ | ❌ | ✅ 動く | 特定ファイルの編集を破棄 |
git switch <branch> | ✅ 動く | ✅ 動く | ✅ 動く | ブランチ切り替え(衝突する変更があれば中断) |
Git v2.23で checkout から switch / restore が分離されたのは、まさにこの表の行が checkout 1つに混在していたからです。新しく書くドキュメントやスクリプトでは switch / restore を使ってください。
「--hard だけが作業ツリーを壊す」——この一行が、事故の大半を防ぎます。逆に言えば --soft と --mixed は何も失いません。オブジェクトは残り、reflogも残ります。
6. マージの内部:merge-base と ort 戦略
3-wayマージは「祖先との比較」
2つのブランチを比べるだけでは、ある行が「追加されたのか削除されなかったのか」を区別できません。だからGitは**共通祖先(merge base)**を第3の基準点として使います。
$ git merge-base main feature
c3dcd69263c50d4a3fa0fac8428f07fdf1dd27ba
各行について、
- 祖先と
oursが同じ、theirsが違う →theirsを採用 - 祖先と
theirsが同じ、oursが違う →oursを採用 - 両方が違う → コンフリクト(stage 1/2/3 に3世代が入る)
既定戦略 ort
merge-strategies(7) の記述をそのまま引きます。
ort—— これは1つのブランチをpull/mergeするときの既定のマージ戦略である。この戦略は3-wayマージアルゴリズムで2つのheadのみを解決できる。3-wayマージに使える共通祖先が複数ある場合、それらの共通祖先をマージしたツリーを作り、それを3-wayマージの基準ツリーとして使う。 […] この名前は頭字語("Ostensibly Recursive's Twin")であり、以前の既定アルゴリズムrecursiveの置き換えとして書かれたことに由来する。
「共通祖先が複数ある場合」というのは、いわゆるcriss-crossマージです。ブランチが相互にマージし合うと共通祖先が一意に決まらなくなり、そのときortは祖先同士を再帰的にマージした仮想の基準ツリーを作ります。「なぜかコンフリクトが出る/出ない」の直感が外れるケースの多くはここです。
recursive については同ドキュメントが明記しています。
これは現在
ortの同義語である。v2.49.0までは別実装だったが、v2.50.0でortを意味するようリダイレクトされた。
古い記事の「-s recursive を指定する」という助言は、いま何の効果もありません。
知っておくべき挙動と落とし穴
公式ドキュメントが自ら警告している挙動があります。
3-wayマージを使う戦略(既定の
ortを含む)では、両方のブランチで変更が行われ、後で片方のブランチで元に戻された(revertされた)場合、その変更はマージ結果に現れる。これを混乱すると感じる人もいる。これはマージの実行時にheadとマージベースのみが考慮され、個々のコミットは考慮されないために起こる。
「revertしたはずの変更がマージで復活する」はバグではなく仕様です。共有ブランチでrevertを使ったら、そのブランチをマージする側でもrevertが必要になる場合があります。
覚えておくと効く戦略オプション:
| オプション | 使いどころ |
|---|---|
-X ours / -X theirs | 衝突ハンクだけを片側優先で自動解決(戦略 -s ours とは別物。-s ours は相手の中身を一切見ない) |
-X renormalize | 改行コード正規化やcleanフィルタの設定が両ブランチで違うときの大量コンフリクトを潰す |
-X diff-algorithm=patience | 閉じ括弧などの無意味な一致による誤マージを避ける(ortの既定は histogram) |
-X find-renames=<n> | リネーム検出の閾値調整(既定で有効) |
そして同じコンフリクトを何度も解決しているなら rerere を有効にしてください。解決結果を記録し、同じ衝突が再発したときに自動適用します。長寿命ブランチのrebaseを繰り返す運用では効果が大きい機能です。
$ git config --global rerere.enabled true
7. rebase・force pushの正体と、安全な --force-with-lease
rebaseは「新しいコミットの生成」
セクション2で見たとおり、コミットIDは親を含めた内容のハッシュです。したがって rebaseは既存コミットを動かすのではなく、同じ変更内容を持つ別のコミットを新規に作ります。元のコミットは孤児(unreachable)になってreflogに残ります。
これが「共有ブランチをrebaseするな」の理由です。他人はあなたが捨てた古いIDを持っており、あなたのpushはそれと両立しません。
--force-with-lease の仕組みと、公式が認める限界
git-push(1) の記述は正確に読む価値があります。
このオプションは、リモート参照の現在値が期待した値である場合に、この制限(fast-forwardでなければならない)を上書きする。そうでなければ
git pushは失敗する。 […] 参照に明示的なロックをかけずに「リース(lease)」を取るようなもので、リースが有効な間だけリモート参照が更新される。
期待値をどこから取るかがポイントです。
--force-with-leaseを単独で(詳細を指定せずに)使うと、更新されるすべてのリモート参照について、その現在値が我々が持つリモート追跡ブランチと同じであることを要求して保護する。
つまり期待値は refs/remotes/origin/* です。そしてドキュメントは安全性に関する一般的注意として次を明記しています。
期待値を指定しない形式、すなわち
--force-with-leaseや--force-with-lease=<refname>は、pushしようとしているリモートに対して暗黙にgit fetchを実行するもの(例:cronでgit fetch originを回している)と非常に相性が悪い。 […]--forceに対して提供される保護は、あなたの作業が基づいていない後続の変更を潰さないことを保証するものだが、バックグラウンドプロセスが参照を更新していればこれは容易に無効化される(trivially defeated)。
エディタの拡張機能やIDEが定期fetchをしている環境は珍しくありません。したがって自動化(CI・スクリプト)では期待値を明示する形式を使うべきです。
# 良い: 期待するリモートの現在値をこちらが宣言する
$ EXPECTED=$(git rev-parse refs/remotes/origin/main)
$ git push --force-with-lease="main:${EXPECTED}" origin main
# さらに: リモート先端が自分のreflogに取り込まれていることも要求する
$ git push --force-with-lease --force-if-includes origin main
--force-if-includes は補完的なチェックです。ドキュメントいわく、
リモート追跡参照の先端が、書き換えの基となったローカルブランチの「reflog」エントリのいずれかから到達可能かを検証するチェックを有効にする。このチェックは、リモートからの更新がローカルに取り込まれていることを保証し、そうでない場合は強制更新を拒否する。
ただし注意点も明記されています。--force-with-lease なしで渡した場合、あるいは --force-with-lease=<refname>:<expect> と併用した場合は no-op です。前者はチェックの土台がなく、後者は期待値が明示されているので追加の推測が不要、という理屈です。
実務での置き所
| 場面 | 推奨 |
|---|---|
| 個人のトピックブランチ | git push --force-with-lease |
| CI/自動化からの強制更新 | --force-with-lease=<ref>:<expect> で期待値明示 |
| 共有・保護ブランチ | 強制更新をサーバ側で禁止(ブランチ保護ルール) |
| どうしても履歴を直したい共有ブランチ | 新ブランチを作ってPRにする(force pushしない) |
--force を全面禁止にするより、サーバ側でブロックしてクライアント側は --force-with-lease を既定にするほうが現実的です。人間の規律ではなく仕組みで縛るべき部分です。
8. パックファイル:転送とストレージの仕組み
loose object から pack へ
ここまで見てきた「1オブジェクト=1ファイル」は loose object 形式です。増えると効率が悪いので、Gitは定期的にパックファイルへまとめます。
# 13個のloose object
$ git count-objects -vH
count: 13
size: 52.00 KiB
in-pack: 0
packs: 0
size-pack: 0 bytes
$ git gc -q
# 1つのパックにまとまり、サイズも大きく縮む
$ git count-objects -vH
count: 0
size: 0 bytes
in-pack: 13
packs: 1
size-pack: 2.16 KiB
52.00 KiB → 2.16 KiB。これは3コミットだけの極小デモなのでこの比率をそのまま一般化はできませんが、loose objectはファイルシステムのブロック単位で切り上げられるぶん実サイズより遥かに大きく場所を取る、という性質はどのリポジトリでも同じです。
形式(gitformat-pack(5))
パックファイル .pack の構造は次のとおりです。
- 4バイトのシグネチャ
{'P','A','C','K'} - 4バイトのバージョン番号(Gitはバージョン2または3を受け付けるが、生成するのは2のみ)
- 4バイトのオブジェクト数
- オブジェクトエントリの並び
- 末尾にパック全体のチェックサム
エントリの型は7種類(うち1つは予約)です。
| 値 | 型 |
|---|---|
| 1 | OBJ_COMMIT |
| 2 | OBJ_TREE |
| 3 | OBJ_BLOB |
| 4 | OBJ_TAG |
| 6 | OBJ_OFS_DELTA(同一パック内の相対オフセットでベースを指す) |
| 7 | OBJ_REF_DELTA(オブジェクト名でベースを指す) |
型6・7がデルタ圧縮です。「スナップショットなのに肥大しない」の物理的な答えがここにあります。似たオブジェクトはベース+差分命令として格納され、さらにzlibが掛かります。
実際のデルタ鎖は verify-pack で見えます。
$ git verify-pack -v .git/objects/pack/pack-*.idx
719d02e... commit 265 184 12
766195b... commit 67 78 196 1 719d02e...
faf9142... commit 19 30 274 2 766195b...
右端の数字がデルタの深さ、その後ろがベースオブジェクトです。766195b は 719d02e からの深さ1のデルタ、faf9142 はさらにその上の深さ2。67バイトのコミットが30バイトで格納されています。
インデックス .idx と多パックインデックス
パックの中を線形探索したら意味がないので、.idx が付属します。v2の構造は、
- マジックナンバー
\377tOcとバージョン2 - 256エントリのfan-outテーブル(オブジェクト名の先頭バイト値ごとの累積個数)
- ソート済みオブジェクト名テーブル
- オブジェクトごとのCRC32(v2で追加=転送・保存時の破損検出)
- 4バイトオフセットテーブル(2GiB超は8バイトのlarge offsetテーブルへ退避)
- パックのチェックサムとインデックス自身のチェックサム
fan-outテーブルは、先頭1バイトで探索範囲を1/256に絞り込むための仕掛けです。その後は二分探索なので、数百万オブジェクトでも数十回の比較で見つかります。
パックが複数になると .idx を跨いだ探索が必要になるため、multi-pack-index(MIDX) が用意されています。シグネチャは {'M','I','D','X'} で、PNAM(パック名)OIDF(fan-out)OIDL(名前テーブル)OOFF(オフセット)などのチャンク構造を持ちます。
commit-graph:履歴走査の索引
コミットの親子関係を毎回オブジェクトから読むと、git log --graph や到達可能性判定が遅くなります。commit-graph はこれを別ファイルに索引化したものです。
さらに --changed-paths を付けると、変更パスのBloomフィルタが書かれます。
--changed-pathsオプションを付けると、コミットとその第1親の間で変更されたパスに関する情報を計算して書き込む。この処理は大きなリポジトリでは時間がかかることがある。git log -- <path>でディレクトリやファイルの履歴を取得する際に、大きな性能向上をもたらす。
git log -- path/to/file が遅いモノレポでは、これが最も効く1手であることが多いです。
9. 巨大リポジトリの現実解
partial clone:オブジェクトを遅延取得する
公式の設計ノートは動機をこう説明しています。
cloneとfetchの操作で、Gitはリポジトリの完全な内容と履歴をダウンロードする。 […] 極めて大きなリポジトリでは、cloneに数時間(あるいは数日)かかり、100GiB以上のディスクを消費することがある。
partial cloneは、必要になるまでオブジェクトを取りに行かない仕組みです。後から供給できるリモートを promisor remote と呼びます。
# 履歴のblobを持たずにclone(ファイルを実際に開くときだけ取得)
$ git clone --filter=blob:none https://github.com/org/repo.git
# treeも持たない(CIのビルドなど、履歴走査が不要な用途向け)
$ git clone --filter=tree:0 --depth=1 https://github.com/org/repo.git
重要な前提が公式に明記されています。
partial cloneの利用は、ユーザがオンラインであり、origin(または他のpromisor)リモートが不足オブジェクトのオンデマンド取得のために利用可能であることを要求する。
オフライン作業やネットワークが不安定な環境では、git log -p のような何気ない操作が大量の追加フェッチを発生させ、かえって遅くなります。全員に強制するものではなく、用途で選ぶものです。
sparse-checkout(cone mode)
作業ツリー側を絞るのが sparse-checkout です。cone modeはディレクトリ単位に制限する代わりに高速で、前述の sparse index と組み合わせられます。
$ git sparse-checkout set --cone apps/web packages/ui
$ git config core.sparseCheckoutCone true
git maintenance:定期メンテを仕組みにする
git gc を手で叩く運用は続きません。git maintenance はタスク単位のスケジューラです。
$ git maintenance start # OSのスケジューラに登録して定期実行
git-maintenance(1) に記載された incremental 戦略の既定スケジュールは次のとおりです。
| タスク | 内容 | 既定スケジュール |
|---|---|---|
gc | 全オブジェクトを1つのパックに再構成 | 無効(incremental戦略では) |
commit-graph | commit-graphの増分更新と検証 | 毎時 |
prefetch | 全リモートの最新オブジェクトを refs/prefetch/ に先読み | 毎時 |
loose-objects | loose objectをバッチでパックへ | 毎日 |
incremental-repack | multi-pack-indexを使った再パック | 毎日 |
pack-refs | loose refを1ファイルへ集約 | 未スケジュール(明示指定が必要) |
reflog-expire | 期限切れreflogエントリの削除 | 未スケジュール |
rerere-gc | rerereキャッシュの掃除 | 未スケジュール |
worktree-prune | 壊れた/古いworktreeの削除 | 未スケジュール |
incremental 戦略が実際にスケジュールするのは上の5つだけです(gc は明示的に「disabled」と記載)。pack-refs 以下は task としては存在しますが、既定のスケジュールには含まれません。必要なら明示的に有効化します。
# 参照が多いリポジトリで pack-refs を週次に載せる
$ git config maintenance.pack-refs.enabled true
$ git config maintenance.pack-refs.schedule weekly
incremental 戦略で gc が無効なのは意図的です。全体repackは重く、しかも到達不能オブジェクトの削除を伴うため、日常運用では loose-objects と incremental-repack の増分処理に置き換えるほうが安定します。
CIでの実践パターン
CIの git は、開発者のそれとは要件が違います。履歴は要らない、作業ツリーは要ることが多い。
# GitHub Actions: 全履歴が不要なジョブ(ビルド・テスト)
- uses: actions/checkout@v5
with:
fetch-depth: 1
# 変更ファイルだけを見たいジョブ(lint差分・影響範囲判定)
# → 履歴のメタデータは要るが、過去のファイル内容は要らない
- uses: actions/checkout@v5
with:
fetch-depth: 0
filter: blob:none
fetch-depth: 0(全履歴)はそのままだと最も高価な設定です。filter: blob:none を添えるだけで、コミット・treeは全部持ちつつ過去のファイル実体を取らずに済み、大規模リポジトリでは転送量が桁で落ちます。
10. 完全性と将来:SHA-1、SHA-256、そしてGit 3.0
いまのSHA-1は「素のSHA-1」ではない
hash-function-transition はこう記しています。
Git v2.13.0以降は既定で強化されたSHA-1実装に移行しており、これはSHAttered攻撃に対して脆弱ではない。
これが sha1dc(SHA-1 collision detection)です。衝突攻撃に特有のビットパターンを検出して拒否します。したがって「SHAtteredのPDFがGitに入れられる」という話は、現在のGitには当てはまりません。
ただし同ドキュメントは「それでもSHA-1は弱い」と続けます。
SHA-256は使えるが、まだ本番の選択肢ではない
$ git init --object-format=sha256 myrepo
git-init(1) の記載は率直です。
有効な値は
sha1と(有効化されていれば)sha256である。sha1が既定。現時点では、SHA-256リポジトリとSHA-1リポジトリの間に相互運用性はない。
さらに hash-function-transition は運用上の警告を出しています。
GitプロトコルがSHA-256サポートを得るまで、公開向けGitサーバでSHA-256ベースのストレージを使うことは強く非推奨である。
結論:いま新規プロジェクトをSHA-256で始めるのは時期尚早です。GitHub等のホスティングとツールチェーン(JGit・libgit2・gitoxide)が揃うのを待つのが正しい判断です。
Git 3.0で何が変わるか
公式の BreakingChanges.adoc が挙げる主要な変更は5点です(リリース日は未定と明記)。
| 変更 | 内容 | 実務への影響 |
|---|---|---|
| 既定ハッシュ | sha1 → sha256 | 新規リポジトリのみ。sha1 の廃止予定は現時点でないと明記 |
| 既定の参照ストレージ | files → reftable | 直接 .git/refs/* を読むスクリプト・フックが壊れる |
| 既定ブランチ名 | master → main | CI設定・ドキュメントの前提 |
| ビルド要件 | Rustが必須(2.55で両ビルドシステムが既定で有効化、3.0で必須化) | 自前ビルド・組み込み配布に影響 |
safe.bareRepository | all → explicit | セキュリティ強化。暗黙のbareリポジトリ発見を拒否 |
最後の項目は特に重要なので、公式の説明を引きます。
悪意あるフックが設定された埋め込みbareリポジトリを含むリポジトリをcloneさせるよう、攻撃者がユーザを騙すのはあまりに簡単である。ユーザがそのサブディレクトリに入って何かGitコマンドを実行すれば、Gitはbareリポジトリを発見し、フックが発火する。ユーザが明示的にGitコマンドを実行する必要すらない——多くのシェルプロンプトはブランチや変更状態を表示するためにバックグラウンドで
git statusを実行し、そのgit statusが設定次第でfsmonitorフックを起動しうるため、ディレクトリにcdした瞬間に脆弱になる。
cd しただけで任意コード実行というのは、Gitを日常的に使う開発者が今すぐ知っておくべきリスクです。Git 3.0を待たずに、いま設定できます。
$ git config --global safe.bareRepository explicit
--git-dir や GIT_DIR で明示指定したbareリポジトリは引き続き動くので、実運用への副作用はほぼありません。untrustedなリポジトリをcloneする機会がある人(≒ほぼ全員)は、今日入れてよい設定です。
11. 本番運用チェックリスト
内部構造の理解を、そのまま設定と運用に落とします。
全員に入れてよい設定
# 悪意あるbareリポジトリによる暗黙のフック実行を防ぐ(Git 3.0の既定を先取り)
git config --global safe.bareRepository explicit
# 同じコンフリクトの解決を記録・再利用する
git config --global rerere.enabled true
# force pushを事故らせない既定(ただし §7 の限界を理解したうえで)
git config --global push.default simple
# fsckを有効にして、壊れたオブジェクトの受信を検出する
git config --global transfer.fsckObjects true
git config --global fetch.fsckObjects true
git config --global receive.fsckObjects true
大規模リポジトリで効く設定
# 未追跡ファイル探索のキャッシュ(UNTR拡張)
git config core.untrackedCache true
# ファイル変更監視でstat走査を削る(FSMN拡張)
git config core.fsmonitor true
# 定期メンテナンスをOSスケジューラに登録
git maintenance start
レビューで見るべき観点
| 観点 | 確認方法 | なぜ |
|---|---|---|
| 大きなバイナリが履歴に入っていないか | git count-objects -vH / git verify-pack -v でサイズ上位を確認 | 一度入ると全cloneに永久に付いてくる(デルタが効かない) |
| 秘密情報がコミットされていないか | 履歴全体を対象にした走査 | オブジェクトは残るので、後のコミットで消しても無意味 |
| 共有ブランチにforce pushできるか | サーバ側のブランチ保護 | クライアント設定は規律に依存する |
| CIのcheckoutが過剰でないか | fetch-depth と filter の指定 | 全履歴cloneはジョブ数×リポジトリサイズの転送費用 |
| フックが信頼できるか | core.hooksPath と safe.bareRepository | フックは任意コード実行 |
12. まとめ:暗記から導出へ
Gitの内部は、突き詰めると次の3行です。
- オブジェクトDBは、内容のハッシュをキーにした追記専用のキーバリューストア。 一度書いたものは(GCされるまで)消えない。だから復旧できる。
- 参照は、そのストアを指す41バイトのポインタ。 ブランチ操作が軽いのも、rebaseが「作り直し」なのも、force pushが危険なのも、すべてここから出る。
- インデックスは、次のコミットの下書き。
statキャッシュを兼ねるからstatusが速く、コンフリクト時は3世代(stage 1/2/3)を同時に保持する。
この3つが分かっていれば、「いま自分はどの木を壊そうとしているのか」がコマンドを打つ前に分かります。Gitの事故は、ほぼ例外なく作業ツリーだけが失われる事故です(オブジェクトは残っているので)。そして作業ツリーを壊すコマンドは限られています。
次に読むべきは、実際に失った状態からの復旧手順です。reflogの寿命、到達不能オブジェクトの猶予期間(既定2週間)、cruft pack、git fsck --lost-found —— 何がいつまで残るのかを知っておけば、事故のときに焦らずに済みます。