メインコンテンツへスキップ
Git の仕組み・リポジトリ運用
Git
バージョン管理
アーキテクチャ設計
信頼性
セキュリティ

Gitの仕組みを内部構造から理解する:オブジェクト・参照・インデックス・パックファイルの実践ガイド

Gitの内部構造を公式ドキュメントに忠実に解説。コンテンツアドレス指定のオブジェクトDB、41バイトの参照とreftable、DIRC形式のインデックスとコンフリクトのstage 1/2/3、ort戦略の3-wayマージ、パックファイルのデルタ圧縮、SHA-256とGit 3.0の破壊的変更まで、手元で再現できる配管コマンドの実測ログ付きで解説します。

公開日
読了時間
32分
著者
友田 陽大
シェア
目次

git reset --hard で数時間分の作業が消えた。git push --force で同僚のコミットを吹き飛ばした。マージの結果が明らかにおかしいのに、どこで壊れたか説明できない。

この3つは別々の事故に見えて、原因は同じです。Gitが管理している3つのデータ構造 —— オブジェクト・参照・インデックス —— のどれを操作しているかを知らないまま、コマンドを暗記して使っている

Gitのコマンドは覚える対象としては最悪です。checkout は「ブランチを切り替える」と「ファイルを戻す」という無関係な2つの仕事をしていました(だからv2.23で switchrestore に分割されました)。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)
160000gitlink(サブモジュールのコミット参照)

gitformat-index(5) は、インデックスのモードフィールドについて「通常ファイルで有効なのは 07550644 のみ」と明記しています。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つあります。

  1. git commit --amend は既存コミットを編集しない。 メッセージが変われば別のバイト列=別のIDになる。元のコミットはそのまま残り、参照が新しい方を向くだけ。
  2. rebaseは履歴の「移動」ではなく「作り直し」。 親が変われば子のIDも変わり、その子も…と連鎖する。だから共有ブランチのrebaseは他人の履歴と両立しない。
  3. 署名はコミットの一部。 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点です。

  1. 大文字小文字を区別しないファイルシステム(Windows/macOS)で、refs/heads/Foorefs/heads/foo を同時に持てない。 reftableは参照名をファイルパスで表現しないのでこの問題が消える。
  2. macOSのUnicode正規化により、異なる符号化の同名参照を保持できない。 同様に解消。
  3. files バックエンドでの参照削除は packed-refs 全体の書き直しを要求する。 巨大リポジトリでは数十MB〜GB規模になる。reftableは墓標(tombstone)で消すので全書き直しが要らない。
  4. 複数参照の同時更新が files ではアトミックでない。 トランザクションのコミット途中の状態が他プロセスから見えうる。
  5. 多数の参照を一度に書くのが遅い。 参照ごとにファイルを作るため。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・ネットワークFSgit config core.fsmonitor trueOSのファイル変更通知を使い、走査対象を変更分だけに絞る(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つの木で resetrestore を導出する

ここまでで登場した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つは予約)です。

1OBJ_COMMIT
2OBJ_TREE
3OBJ_BLOB
4OBJ_TAG
6OBJ_OFS_DELTA(同一パック内の相対オフセットでベースを指す)
7OBJ_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...

右端の数字がデルタの深さ、その後ろがベースオブジェクトです。766195b719d02e からの深さ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-graphcommit-graphの増分更新と検証毎時
prefetch全リモートの最新オブジェクトを refs/prefetch/ に先読み毎時
loose-objectsloose objectをバッチでパックへ毎日
incremental-repackmulti-pack-indexを使った再パック毎日
pack-refsloose refを1ファイルへ集約未スケジュール(明示指定が必要)
reflog-expire期限切れreflogエントリの削除未スケジュール
rerere-gcrerereキャッシュの掃除未スケジュール
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-objectsincremental-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点です(リリース日は未定と明記)。

変更内容実務への影響
既定ハッシュsha1sha256新規リポジトリのみ。sha1 の廃止予定は現時点でないと明記
既定の参照ストレージfilesreftable直接 .git/refs/* を読むスクリプト・フックが壊れる
既定ブランチ名mastermainCI設定・ドキュメントの前提
ビルド要件Rustが必須(2.55で両ビルドシステムが既定で有効化、3.0で必須化)自前ビルド・組み込み配布に影響
safe.bareRepositoryallexplicitセキュリティ強化。暗黙の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-dirGIT_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-depthfilter の指定全履歴cloneはジョブ数×リポジトリサイズの転送費用
フックが信頼できるかcore.hooksPathsafe.bareRepositoryフックは任意コード実行

12. まとめ:暗記から導出へ

Gitの内部は、突き詰めると次の3行です。

  1. オブジェクトDBは、内容のハッシュをキーにした追記専用のキーバリューストア。 一度書いたものは(GCされるまで)消えない。だから復旧できる。
  2. 参照は、そのストアを指す41バイトのポインタ。 ブランチ操作が軽いのも、rebaseが「作り直し」なのも、force pushが危険なのも、すべてここから出る。
  3. インデックスは、次のコミットの下書き。 stat キャッシュを兼ねるから status が速く、コンフリクト時は3世代(stage 1/2/3)を同時に保持する。

この3つが分かっていれば、「いま自分はどの木を壊そうとしているのか」がコマンドを打つ前に分かります。Gitの事故は、ほぼ例外なく作業ツリーだけが失われる事故です(オブジェクトは残っているので)。そして作業ツリーを壊すコマンドは限られています。

次に読むべきは、実際に失った状態からの復旧手順です。reflogの寿命、到達不能オブジェクトの猶予期間(既定2週間)、cruft pack、git fsck --lost-found —— 何がいつまで残るのかを知っておけば、事故のときに焦らずに済みます。

よくある質問

Gitは差分を保存しているのですか、スナップショットを保存しているのですか?
保存モデルはスナップショットです。コミットは毎回ツリー全体を指し、ファイルの内容はblobオブジェクトとして丸ごと保存されます。ただし同じ内容には同じオブジェクト名が付くため、変更のないファイルは新しい実体を作らず既存のblobを共有します。さらにパックファイル化の段階でデルタ圧縮(OBJ_OFS_DELTA / OBJ_REF_DELTA)が掛かるため、ディスク上は差分に近い形で保持されます。『論理モデルはスナップショット、物理表現はデルタ』と理解するのが正確です。
git reset --soft / --mixed / --hard の違いを一言で説明すると?
動かす『木』の数が違います。--soft はHEADだけを動かし、インデックスも作業ツリーも触りません(コミットのやり直し向け)。--mixed(既定)はHEADとインデックスを動かし、作業ツリーは残します(ステージを解除したいとき)。--hard はHEADとインデックスと作業ツリーの3つすべてを動かすため、コミットしていない変更は失われます。失いたくない変更があるときは --hard の前に git stash を挟んでください。
git push --force-with-lease があれば --force より安全ですか?
条件付きで安全です。--force-with-lease はリモートの現在値が『自分のリモート追跡ブランチの値』と一致するときだけ更新を通します。ただし公式は、エディタやcronがバックグラウンドで git fetch を実行していると『この保護はあっさり無効化される(trivially defeated)』と明記しています。CIやスクリプトからは期待値を明示する --force-with-lease=<refname>:<expect> 形式を使うか、--force-if-includes を併用してください。
SHA-1が破られたという話がありますが、Gitは危険なのですか?
現在のGitはv2.13.0以降、SHAtteredのような既知の衝突攻撃を検知する強化版SHA-1(sha1dc)を既定で使っています。そのうえで公式のBreakingChangesは『SHA-1は依然として弱い』としてGit 3.0で新規リポジトリの既定ハッシュをSHA-256へ変更すると宣言しています。ただし現時点でSHA-256リポジトリはSHA-1リポジトリとの相互運用ができず、公式もプロトコル対応が済むまで公開サーバでの利用を強く非推奨としているため、いま本番を移行する段階ではありません。
巨大なリポジトリでcloneやstatusが遅いとき、まず何をすべきですか?
計測してから、効く順に3つです。(1)履歴のオブジェクト量が問題なら partial clone(--filter=blob:none)で必要になったときだけblobを取り寄せる。(2)作業ツリーのファイル数が問題なら cone modeのsparse-checkoutとsparse indexでインデックス自体を縮める。(3)走査コストが問題なら git maintenance start でcommit-graph・prefetch・loose-objects・incremental-repackを定期実行させる。CIでは fetch-depth と filter の組み合わせが最も費用対効果が高く、ジョブごとの転送量を桁で落とせます。

参考文献

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事の実装を、案件として承ります

リポジトリ運用とCIパイプラインの設計・高速化を承ります

巨大化したリポジトリの clone・status・CI が遅い、モノレポの分割方針が決まらない、履歴の書き換え事故が繰り返される——原因はほぼ Git の内部構造(オブジェクト量・参照数・インデックス)にあります。partial clone / sparse-checkout / commit-graph / git maintenance の適用可否を計測から判断し、ブランチ戦略と保護ルールまで含めて設計・実装します。

プロジェクト単位(請負)・技術顧問のどちらにも対応可能です。まずは30分の無料技術相談から。

最短ルート:カレンダーから直接予約

相談内容が固まっている方は、フォーム送信よりその場で日程を確定する方がスムーズです。下記から空き時間をお選びください。

  • 30分のオンライン無料相談
  • Google Meet / Zoom / Microsoft Teams
  • NDA 商談前締結可・無理な営業はいたしません
無料相談の空き枠を予約する

あわせて読みたい