Gitの仕組みと内部データ構造
Gitの説明は「blob、tree、commit がある」で終わりがちだ。しかしそこで止まると、なぜその3つなのか、なぜ他の設計ではなかったのか、そして git rebase が具体的にどのオブジェクトをどう作り替えているのかまでは見えてこない。
この記事では、内容でアドレスするという1つの選択から、Gitのデータ構造がほぼ必然的に導かれることを出発点にする。そのうえで、その選択が引き受けた代償と、rebase が内部で何をしているかを、実際のバイト列まで降りて追う。
記事中のハッシュ値・バイト数・圧縮率は、すべて手元のGit 2.43で実際に再現して確認した値だ。図の中の値もブラウザ上で計算しており、git hash-object の出力と一致する。
結論
- Gitの設計を貫いているのは「調整なしに一意な名前を付ける」という分散システム由来の要請だ。集中型VCSの連番リビジョンは採番する中央サーバーがあって初めて成立する。内容のハッシュをIDにすれば、誰とも相談せずに名前を決められ、しかも同じ内容には必然的に同じ名前が付く
- この「同じ内容=同じID」という性質が、重複排除・整合性検証・同期時の差分計算という3つを同時に成立させる。3つが別々の機能ではなく1つの性質の帰結である点が、この設計の効率の源になっている
- オブジェクトは不変で、参照(ブランチ・HEAD)だけが可変という2層構造になっている。日常のコマンドのほとんどは、実は下の層を一切触らず上の層のポインタを書き換えているだけだ
- コミットはスナップショットだが、変更されていない部分は同じオブジェクトが共有される。これは関数型言語の永続データ構造とまったく同じ仕組みで、コミットのコストはリポジトリ全体ではなく変更されたパスの深さに比例する
- 代償もある。Gitはリネームを記録しないし、空ディレクトリを表現できないし、ファイル単位の履歴を持たない。これらは実装漏れではなく、スナップショット+内容アドレスというモデルから直接導かれる帰結だ
rebaseは「パッチを当て直す」処理ではない。コミット1個につき3方向マージを1回走らせる処理で、その入力は base=元の親、ours=積み直し先、theirs=積み直すコミットになる。衝突時に自分の変更がtheirs側に出る理由もここにある- ハッシュが変わるのは、
tree・parent・committerの3行が変わるからだ。実測では、元のコミットと作り直したコミットはどちらも192バイトだった。author行とメッセージは完全に一致している - 物理層のデルタ圧縮は履歴とは無関係に選ばれる。同一コミット内の2ファイル間でデルタが張られることを実際に確認できた(97KBのファイルが26バイトのデルタになった)。データモデルと保存形式が分離しているからこそ可能な最適化だ
前提
- 対象読者: 日常的にGitを使っていて、コマンドの裏で
.gitの中身がどう変化しているかを構造から理解したい人 - 検証環境: Git 2.43.0(Linux)。オブジェクト形式は長年安定しているが、既定値(
gcの閾値、rebaseのバックエンドなど)はバージョンに依存する - この記事は「なぜそうなっているか」に重心を置く。コマンドの網羅的な使い方は扱わない
全体像
細部へ降りる前に、地図を先に置いておく。Gitの内部は、2つの層と4種類のオブジェクトしかない。
この図は JavaScript で描画する。JavaScript を有効にすると、図を操作しながら確認できる。
上下の帯が、この記事を通して繰り返し出てくる区別だ。
**上の層(可変)**は、ブランチとHEADとタグの参照。実体はどれも「40文字のハッシュを1行書いたファイル」で、自由に書き換わる。
**下の層(不変)**が、blob・tree・commit・tag の4種類のオブジェクト。一度作られたら二度と変わらない。役割は明快に分かれている。
- blob はファイルの中身だけを持ち、名前を持たない
- tree がその blob に名前を与え、ディレクトリ構造を作る
- commit が tree に文脈(親・作者・メッセージ)を与える
- tag は特定のオブジェクトに注釈と署名を付ける
線のラベルは、その参照を作っているフィールド名そのものだ。commit の tree 行がスナップショットを指し、parent 行が履歴を繋ぐ。
ここで押さえておきたい点が2つある。1つは、参照が常にハッシュ経由であること。これが全体をMerkle DAGにしており、後半で扱う整合性や署名の話がここから出てくる。
もう1つは、git branch や git switch のようなコマンドが、下の層を一切触らず上のポインタを動かしているだけだという点だ。図で「可変な層だけ」を選ぶと、日常操作の大半が実はこの3個の小さなファイルの書き換えにすぎないことが見える。
以降はこの地図を、上から順ではなく「なぜこの形になったか」という順序でたどっていく。
なぜ内容でアドレスするのか
集中型VCSは「採番する人」がいて成立していた
SVNのリビジョン番号 r1, r2, r3 … は、単調増加する整数だ。これは分かりやすいが、サーバーが1台しかないから成立している。番号を割り当てる主体が1つだけなので、衝突しない。
分散型にした瞬間、この前提が崩れる。2人が飛行機の中でそれぞれコミットしたら、どちらも次の番号を主張することになる。後から統合するときに番号を振り直せば、既にその番号を参照していた記録がすべて壊れる。
回避策としてUUIDを振ることは考えられる。衝突はしないが、今度は別の問題が出る。まったく同じ内容に別のIDが付いてしまうため、2つのリポジトリを突き合わせたときに「この2つは同じものか」を判定できない。同期のたびに中身を全部比較する羽目になる。
内容をそのまま名前にする
Gitの答えは、名前を決めるのをやめて内容から計算することだった。オブジェクトIDは、内容のSHA-1そのものだ。
この1つの決定から、必要だった性質が同時に手に入る。
| 欲しかった性質 | 内容アドレスがどう満たすか |
|---|---|
| 調整なしに名前を付けたい | ハッシュ計算はローカルで完結する。誰とも通信しない |
| 同じ内容を同じものと認識したい | 同じ内容なら必然的に同じID。判定が文字列比較で済む |
| 壊れていないことを確認したい | IDを再計算して一致すれば無傷。別途チェックサムを持つ必要がない |
| 同期の差分を知りたい | 「持っているIDの集合」の差集合を取るだけ。中身を比較しない |
最後の行が地味に効いている。git fetch の交渉が「そちらにあってこちらにないオブジェクトは何か」という集合演算に還元できるのは、IDが内容から決まるからだ。IDが恣意的な採番だったら、この単純化は成り立たない。
実際に計算してみる
具体的な手順は単純だ。"<型> <バイト長>\0<内容>" というバイト列を組み立て、その全体のSHA-1を取る。
この図は JavaScript で描画する。JavaScript を有効にすると、図を操作しながら確認できる。
1文字変えるとIDが無関係な値に飛ぶ性質(雪崩効果)は偶然の副産物ではなく、上の表の3行目と4行目を成り立たせる土台になっている。
なお、ヘッダに型と長さを含めるのは意味がある。長さを入れておくと、内容の途中で切れたデータを「短いオブジェクト」として誤って受け入れることがない。型を入れておくと、たまたま同じバイト列になったblobとtreeが同一視されることもない。
「差分」ではなく「状態」を記録する
もう1つの分岐点が、何を記録単位にするかだった。CVSやSVNの多くは、ファイルごとに前版からの差分を積み上げる。1ファイル単位では効率的に見えるが、内容アドレスとは相性が悪い。
差分ベースでは、あるバージョンの内容は「初版+差分の列」としてしか存在しない。すると「この状態」を一意に指す名前を付けにくく、プロジェクト全体の状態を1つのIDで表すのが難しくなる。
Gitはこれとは逆に、コミットのたびに全体のスナップショットを記録する。一見ディスクを浪費しそうだが、後述する構造共有によって実際の増加分は変更部分だけに収まる。「状態を記録する」というモデルと「効率的に保存する」という要求は、レイヤーを分けることで両立させている。
歴史的な経緯
2005年、LinuxカーネルはそれまでBitKeeperという商用の分散型VCSを無償で使っていたが、ライセンス方針の変更で利用できなくなった。Linus Torvaldsは集中型に戻るのではなく、数週間で自作する道を選んでいる。当時、既存の分散型VCSとしてMonotoneなども検討されたが、カーネル規模のリポジトリを扱うには性能が足りないと判断された経緯が知られている。
「数週間で作った」ものが20年使われている理由は、機能を盛り込んだからではなく、逆に土台のデータモデルを極限まで単純に保ったからだと言える。オブジェクトは4種類しかなく、その上の層はすべてポインタ操作にすぎない。この記事で見ていく高度な機能は、ほぼすべてこの単純な土台の上の応用として説明できる。
オブジェクトの実バイト表現
オブジェクトは4種類しかない。
| 種類 | 中身 | 持たないもの |
|---|---|---|
| blob | ファイルの内容そのもの | ファイル名、パス、権限、更新日時 |
| tree | 1ディレクトリ分の (mode, 名前, 参照先ID) の並び | 中身の実体(blobやtreeを参照するだけ) |
| commit | ルートtree、親コミット0個以上、author、committer、メッセージ | 差分、変更ファイル一覧、リネーム情報 |
| tag(注釈付き) | 対象オブジェクト、型、タグ名、tagger、メッセージ | — |
右の列が重要だ。持っていないものが、Gitの挙動の多くを説明する。
blob は名前を持たない
blobはファイルの中身だけで、ファイル名を含まない。だから同じ内容のファイルは、リポジトリ内のどこにあっても自動的に1つのオブジェクトに集約される。名前を与えるのは、次のtreeの役目だ。
この分離は後で効いてくる。ファイルを移動しただけならblobは再利用され、treeのエントリ名だけが変わる。逆に言えば、Gitは移動を「移動」として記録していない。
tree はバイナリで、並び順に規則がある
git cat-file -p が整形して見せるので誤解しやすいが、treeの実体はテキストではない。1エントリは次の並びになっている。
| |
エントリ間に区切りバイトはない。20バイト読み終えた次のバイトが、そのまま次のエントリの先頭になる。
この図は JavaScript で描画する。JavaScript を有効にすると、図を操作しながら確認できる。
図で sub-x を追加すると、それがディレクトリ sub より前に並ぶ。Gitはtreeのエントリを名前順にソートするが、そのときディレクトリだけは「名前 + /」として比較するためだ。sub-x と sub/ を4文字目で比べると -(0x2d) < /(0x2f) なので、この順になる。
細かい規則に見えるが、これはハッシュに直接影響する。並び順が1つ違えばバイト列が変わり、treeのIDが変わり、それを参照するcommitのIDも変わる。Gitを自前で実装するとき、まず引っかかるのがこの規則だ。
もう1つ、表示と実体の差がある。cat-file -p はモードを 040000 と6桁で表示するが、保存されているバイト列は 40000 の5文字だ。先頭のゼロは整形時に足されている。
mode という小さな正規化
modeとして使える値は、実質次の5つに限られている。
| mode | 意味 |
|---|---|
100644 | 通常ファイル |
100755 | 実行可能ファイル |
120000 | シンボリックリンク(リンク先のパス文字列がblobの中身になる) |
40000 | サブディレクトリ(別のtreeを参照) |
160000 | サブモジュール(別リポジトリのコミットIDを指す。gitlinkと呼ばれる) |
UNIXのパーミッションは本来もっと多様だが、Gitは実行ビットの有無しか記録しない。所有者、グループ、読み書き権限の細部は捨てられている。
これは手抜きではなく、移植性のための正規化だ。パーミッションをそのまま記録すると、環境が違うだけでtreeのハッシュが変わってしまい、「同じ内容なら同じID」という前提が崩れる。記録する情報を意図的に減らすことで、モデルの一貫性を守っている。
commit はテキストで、ヘッダの順序が固定されている
commitの中身は素直なテキストだ。ヘッダ行が並び、空行を挟んで、メッセージが続く。
parent は0個(最初のコミット)にも、2個以上(マージ)にもなる。ここが可変長であるために、履歴はDAG(有向非巡回グラフ)になる。
ヘッダの順序は固定だ。順序が揺れると同じ内容から違うハッシュが出てしまい、再現性が失われる。署名する場合は gpgsig ヘッダが加わるが、これも位置が決まっている。
author と committer が別々にあるのは、後の rebase の議論で効いてくる。前者は「変更を書いた人と日時」、後者は「このコミットオブジェクトを作った人と日時」で、通常は一致するが、履歴を作り替えると乖離する。
空 tree という定数
エントリが0個のtreeは、必ず 4b825dc642cb6eb9a060e54bf8d69288fbee4904 になる。内容が空なのだから当然だが、これはどのリポジトリでも同じ値だ。「何もない状態」との差分を取りたいときに使える定数として、実務でもたまに登場する。
Merkle DAG として何が保証されるか
blobはtreeに参照され、treeはcommitに参照され、commitは親commitを参照する。参照が常にハッシュ経由である点によって、この構造はMerkle DAGになる。
部分の完全性が、全体の完全性になる
treeの中身には子のハッシュが含まれる。だからtreeのIDは、その配下すべての内容に依存する。同じようにcommitのIDは、ルートtree(=スナップショット全体)と親コミットのIDに依存する。
再帰的にたどると、1つのコミットIDが、そこから到達できる履歴全体を覆っていることになる。ファイルを1バイト書き換えれば、そのblob、それを含むtree、上位のtree、コミット、そしてその子孫のコミットまで、IDが芋づる式に変わる。
この性質から、実用的な結論が2つ出る。
1つは検証だ。コミットIDが正しいと分かっていれば、そこから辿れる全データが無傷だと確認できる。git fsck がやっているのは、格納されているオブジェクトを読み直してIDを再計算し、一致を確かめる作業にすぎない。
もう1つは署名だ。1つのコミットに署名すれば、それが指す履歴全体を保証したことになる。過去のコミット1つ1つに署名する必要はない。GitHubなどでタグに署名するだけでリリース内容を保証できるのは、この性質による。
構造共有 — スナップショットなのに増えない
スナップショットを毎回記録しても容量が増えない理由は、この図で見える。
この図は JavaScript で描画する。JavaScript を有効にすると、図を操作しながら確認できる。
2回目のコミットで a.txt だけ変えた場合、変わっていない README.md のblobは新しいtreeからもそのまま参照される。増えるのは、新しいblob・新しいtree・新しいcommitの3つだけだ。
つまりコミットのコストは、リポジトリの総量ではなく、変更されたパスの深さに比例する。深さ d のファイルを1つ変えれば、新しく作られるtreeは d 個程度で済む。1万ファイルのリポジトリでも、1ファイルの変更なら数個のオブジェクトしか増えない。
永続データ構造としての Git
この仕組みは、関数型言語の永続データ構造(persistent data structure)とまったく同じ考え方だ。ImmutableなMapを更新すると、変更されたパス上のノードだけが新しく作られ、残りの部分木は共有される。
Gitのオブジェクトグラフは、ファイルシステムのスナップショットに対してこれを適用したものと見なせる。「古い版が壊れない」「履歴を安全に共有できる」といった性質は、不変性と構造共有から自動的についてくる。過去のコミットが改変されないのは、Gitがそれを禁止しているからではなく、内容が変われば別のIDになるので、そもそも「同じものを書き換える」という操作が存在しないからだ。
このモデルが引き受けた代償
美点だけでは片手落ちなので、捨てたものも見ておく。以下はいずれも実装上の不足ではなく、モデルからの帰結だ。
リネームを記録しない
コミットオブジェクトに「リネーム」という項目はない。ファイルを移動しても、記録されるのは前後のtreeだけだ。実際に確認すると、blobのIDは同一のまま、treeのエントリ名だけが変わっている。
では git log --follow や git diff -M が出すリネーム表示は何かというと、保存された事実ではなく、表示時に計算された推測だ。前後のtreeを比べ、消えたパスと現れたパスの内容の類似度を計算して、閾値を超えたものをリネームと判定している。
だから挙動に幅が出る。移動と同時に中身を大きく書き換えると、類似度が閾値に届かず「削除+新規追加」と判定されることがある。リネーム追跡が期待通りに効かない場合、まず疑うべきはこの検出の仕組みだ。
空ディレクトリを表現できない
treeのエントリはblobかtreeを指す。ファイルを1つも含まないディレクトリには、指すべきものがない。結果として、空ディレクトリはインデックスとtreeのどちらにも載らない。
.gitkeep のような空ファイルを置く慣習があるのは、これを回避するためだ。ファイルシステムには存在できるがGitのモデルには存在できない、という境界のずれがここに出ている。
ファイル単位の履歴は一級市民ではない
「このファイルの変更履歴」は、どこにも保存されていない。git log -- path を実行すると、Gitはコミットを新しい方から順にたどり、各コミットで前後のtreeを比較して、そのパスに変化があったかを毎回判定している。
ファイルごとの履歴リストを持つ設計と比べると、この操作は本質的に重い。長い履歴で git log -- path が遅く感じるのはこのためだ。commit-graphやBloomフィルタといった後付けの高速化機構が用意されているのも、この構造的なコストを埋めるためのものだ。
巨大バイナリで前提が崩れる
構造共有が効くのは「変わらないファイルは同じblobになる」からで、デルタ圧縮が効くのは「似た内容には短い差分がある」からだ。画像や動画、学習済みモデルのようなバイナリは、少し編集しただけで全体が別物になり、どちらの前提も満たさない。
しかも一度コミットしたオブジェクトは履歴に残り続けるので、git clone のたびに全バージョンが転送される。これこそGit LFSが解こうとしている問題で、記事の後半で扱う。
可変な層 — refs・HEAD・reflog
ここまでのオブジェクトはすべて不変だった。では「いま自分がどこにいるか」はどう表現するのか。ここで初めて書き換え可能な層が登場する。
この図は JavaScript で描画する。JavaScript を有効にすると、図を操作しながら確認できる。
ブランチの実体は .git/refs/heads/<名前> というファイルで、中身は40文字のハッシュ1行だけだ。HEADは通常 ref: refs/heads/main のように「どのブランチにいるか」を指す。
図で確認できるとおり、git branch はファイルを1つ増やすだけで、オブジェクトを1つも作らない。git switch は .git/HEAD の1行を書き換えるだけだ。日常的なコマンドの大半は、不変の層に触れずポインタだけを動かしている。SVNのブランチ作成に相当のコストがかかるのに対し、Gitのそれが一瞬で終わる理由がここにある。
packed-refs
ブランチやタグが増えると小さなファイルが大量にできるので、Gitはこれらを .git/packed-refs という1ファイルにまとめる。まとめた後は .git/refs/ 以下のファイルが消えるが、参照解決の結果は変わらない。
ここでも、論理的な意味(refはハッシュを指す)と物理的な保存形式(個別ファイルか、まとめた1ファイルか)が分離されている。同じ分離が、後述するpackfileにも現れる。
reflog はローカルのジャーナル
.git/logs/ 以下には、refが動くたびに1行ずつ追記される。形式は素直で、遷移前後のハッシュ・実行者・日時・操作内容が並ぶ。
| |
重要なのは、これがローカル限定でリモートには送られないことだ。reflogは「自分のリポジトリでrefがどう動いたか」の記録であり、履歴そのものではない。別のマシンやクローンし直したリポジトリには、この記録は存在しない。
reset --hard のような操作の前後では ORIG_HEAD にも直前の位置が保存される。git reset --hard ORIG_HEAD で戻せるのはこのためだ。
GC は到達可能性のトレース
不変オブジェクトが増え続けると、いずれ不要なものが溜まる。Gitの gc は、refをルートとしたmark-and-sweepのトレーシングGCとして動く。プログラミング言語のGCと同じ構造だ。
- ルート: すべてのref(ブランチ、タグ、リモート追跡ブランチ)とHEAD、そしてreflogに記録されたハッシュ
- マーク: そこから参照をたどって到達できるオブジェクトすべて
- スイープ: 到達できず、かつ猶予期間を過ぎたものを削除
参照カウント方式ではなく到達可能性方式である点が効いている。参照カウントだと循環参照が回収できないが、そもそもDAGなので循環は起きない。それでも到達可能性を選んでいるのは、refの付け替えで大量のオブジェクトが一度に孤立するというGit特有の使い方に、こちらのほうが素直に対応できるからだ。
猶予期間の既定値は、確認した範囲では次のとおりだった。
| 設定 | 既定 | 意味 |
|---|---|---|
gc.reflogExpire | 90日 | 到達可能なコミットのreflogエントリを保持する期間 |
gc.reflogExpireUnreachable | 30日 | 到達不能になったコミットのreflogエントリを保持する期間 |
gc.pruneExpire | 2週間 | 到達不能オブジェクトを削除するまでの猶予 |
rebase の直後に「しまった」と思っても復旧できるのは、この猶予期間のおかげだ。逆に言えば、永久に残る保証はない。
インデックス — なぜ3本目の木が必要なのか
作業ディレクトリとHEADがあれば足りそうに見えて、Gitにはもう1つ .git/index という木がある。これが3つの役割を兼ねている。
この図は JavaScript で描画する。JavaScript を有効にすると、図を操作しながら確認できる。
役割1: 次のコミットの下書き
git add は作業ディレクトリの内容からblobを作って書き込み、そのIDをindexに記録する。この時点でblobオブジェクトはもう存在している点は見落とされがちだ。コミットしていない add 済みの内容も、実はオブジェクトデータベースの中にある。
git commit はindexの内容をtreeに固め、それを指すcommitを作る。コミットされるのは作業ディレクトリではなくindexだ。この区別が、部分ステージ(git add -p)を可能にしている。
役割2: stat キャッシュ
indexの各エントリは、blobのIDだけでなくファイルシステムのメタデータも持っている。実際に覗くと次の情報が入っていた。
git status が数万ファイルのリポジトリでも一瞬で終わるのは、これがあるからだ。まず stat の結果とindexの記録を比べ、mtime とサイズが変わっていなければ、ファイルを読まずに「変更なし」と判定する。中身を読んでハッシュを計算するのは、メタデータに差があったものだけだ。
つまりindexは、ステージング機能であると同時に性能上のキャッシュでもある。この二重の役割が、「なぜステージングエリアなんてものがあるのか」という疑問への実務的な答えになっている。
役割3: マージ中の作業領域
通常、indexの各パスは「ステージ0」という1つの状態しか持たない。ところが衝突が起きると、同じパスが3つのステージを同時に持つ。実際に確認したものが次だ。
作業ディレクトリのファイルには <<<<<<< のような衝突マーカーが書かれるが、Gitが本当に持っている情報はこちらの3つのblobだ。マーカーはこの3つから生成された表示にすぎない。git checkout --ours や --theirs が動くのは、元の3つが失われずindexに残っているからだ。
この3ステージの構造は、次に見るリベースの理解に直結する。
到達可能性 — 祖先・fast-forward・マージベース
コミットグラフに対する問い合わせの多くは、実は同じ1つの問題に還元できる。あるコミットから親をたどって到達できるかという到達可能性の問題だ。
この図は JavaScript で描画する。JavaScript を有効にすると、図を操作しながら確認できる。
fast-forward の正体
「XはYの祖先か」が判定できると、fast-forwardが定義できる。マージしようとしている相手が現在地の子孫なら、統合すべき差分は存在しない。refを進めるだけでよく、コミットオブジェクトは1つも作られない。
図で「一直線」を選ぶと、この状態が確認できる。--no-ff を付けたときだけマージコミットが作られるのは、この判定を意図的に無視しているからだ。
マージベースは1つとは限らない
分岐したブランチを統合するには、共通の祖先が要る。これがマージベースで、DAG上の最近共通祖先(LCA)にあたる。
木構造と違ってDAGでは、これが複数になりうる。図の「たすき掛け」がその例で、実際に確認すると2つ返ってきた。
なぜ問題かというと、どちらか一方だけを基準に選ぶと、もう片方の経路で既に解決済みの変更を「未解決」と誤認しかねないからだ。同じ衝突を何度も解かされる状況が生まれる。
既定のマージ戦略(現在は ort、以前は recursive)はこれを次のように処理する。複数のマージベース同士を再帰的にマージして、仮想的な単一のベースを作り、それを基準に本来のマージを行う。recursive という名前の「再帰」は、この処理を指していた。
普段意識することはないが、長期間並行して開発しているブランチ同士を何度も相互マージしていると、この状況は実際に発生する。
commit-graph
到達可能性の判定は、素朴にやると大量のコミットをたどる羽目になる。そこでGitは .git/objects/info/commit-graph という補助ファイルを作れる。コミットの親・tree・日時に加えて世代番号(generation number)を持つ索引だ。
世代番号は「そのコミットまでの最長経路長」のような単調な値で、「世代番号が大きいコミットは、小さいコミットの祖先ではありえない」という枝刈りに使える。これでたどる範囲を大幅に減らせる。
ここでも構図は同じだ。データモデルは変えず、問い合わせを速くする索引を横に足している。commit-graphを消しても履歴は1ビットも壊れない。
マージ
準備が整ったので、マージを整理する。やっているのは3方向マージで、入力は3つある。
- base: マージベースのtree(共通の祖先の状態)
- ours: 現在のブランチのtree
- theirs: 取り込む側のtree
各パスについて、baseからoursへの変化とbaseからtheirsへの変化を見る。片方だけが変わっていればその結果を採用し、両方が同じように変わっていれば当然一致し、両方が違う形で変わっていたときだけ衝突になる。treeは再帰的に処理され、部分木が両側で変わっていなければ丸ごと再利用される。
結果としてできるのは、統合後のtreeを指し、parentを2つ持つコミット1個だけだ。既存のオブジェクトには一切触れない。
リベースの正体
ここからが本題だ。まず全体像を見る。
この図は JavaScript で描画する。JavaScript を有効にすると、図を操作しながら確認できる。
同じ内容を取り込むのに、作られるオブジェクトはまったく違う。マージはコミットを1つ足すだけで、C と D は1バイトも変わらない。リベースは C と D を作り直すので、元の2つはグラフから浮いてしまう。
以下、リベースの中で何が起きているかを順に見る。
何を積み直すのか — 集合演算
git rebase main を feature 上で実行すると、まず対象が決まる。それは main..feature、つまり feature から到達できて main から到達できないコミットの集合だ。ここでも到達可能性の話が出てくる。
一般形は git rebase --onto <新しい土台> <upstream> <branch> で、積み直す集合が <upstream>..<branch>、積む先が <新しい土台> になる。--onto を省略すると土台は <upstream> と同じになる。3つの引数が「何を」と「どこへ」を独立に指定していると理解すると、--onto の使い方は素直に導ける。
既に取り込まれた変更は自動的に落ちる
この集合がそのまま使われるわけではない。変更内容が既に upstream に入っているコミットは除外される。
判定に使われるのが patch-id で、これは差分を正規化してハッシュを取ったものだ。実際に、同じ変更を別コミットとして両側に入れた状態を作って確認すると、両者の patch-id は一致していた。
この状態でリベースすると、対象2件のうち1件が黙って落ち、Rebasing (1/1) となった。同じ変更が二重に適用されるのを防ぐ仕組みで、パッチをメールでやり取りする開発フローでは特に効いてくる。
コミットIDが違っても内容が同じなら同一視される、という点がポイントだ。IDは内容から決まるが、「変更」の同一性はIDでは測れないので、別の指標が要る。
積み直しは「マージの繰り返し」
ここが最も誤解されている部分だ。リベースは「パッチを取り出して当て直す」処理ではない。現在の既定バックエンドでは、コミット1個につき3方向マージを1回走らせている。
この図は JavaScript で描画する。JavaScript を有効にすると、図を操作しながら確認できる。
コミット C を積み直すときの3つの入力は次のようになる。
| 入力 | 実体 | 意味 |
|---|---|---|
| base | C の元の親 | 変更前の状態 |
| ours | 積み直し先(初回は E、以降は直前に作ったコミット) | 積む先の現状 |
| theirs | C 自身 | 変更後の状態 |
base と theirs の差が「Cが加えた変更」にあたり、それを ours に統合する。つまり**「パッチを当てる」という表現は、3方向マージという実装の近似的な言い換え**だった。
これが実装だと分かると、いくつかの挙動が一度に説明できる。
衝突したときのindexを見ると、通常のマージとまったく同じ3ステージが立っていた。
リベースの衝突がマージの衝突と同じ形をしているのは、同じ処理だからだ。git checkout --ours などのツールもそのまま使える。
ours と theirs が反転して見える理由
リベース中の衝突で多くの人を混乱させるのが、この点だ。実際に確認すると、衝突マーカーはこうなっていた。
HEAD(= ours)は main 側であり、自分が書いたコミットのほうが theirs 側に出ている。直感とは逆に見える。
上の表と突き合わせれば理由は明白だ。リベースは「main の上に、自分の変更を1つずつマージしていく」処理なので、マージの主体は常に積み直し先になる。自分のコミットは「取り込まれる側」であり、theirs に置かれるのが構造的に正しい。
この理解があると、git rebase 中の --ours / --theirs の意味を毎回迷わずに済む。リベース中の --ours は「main側を採用」、--theirs は「自分の変更を採用」だ。
なぜハッシュが変わるのか
積み直したコミットが別物になる理由を、バイト単位で確認する。元の C と作り直した C' を並べると、こうなっていた。
| |
どちらも192バイトで、author 行とメッセージは完全に同一だった。違うのは3行だけだ。
tree: 積み直し先の内容と統合したので、スナップショットが変わったparent: 親がBからEに変わったcommitter: このオブジェクトを作った時刻が更新された
SHA-1は入力全体から計算されるので、3行違えば当然まったく別のIDになる。つまり rebase は既存のコミットを書き換えているのではなく、内容の近い別のコミットを新規に作っている。この記事の冒頭で「同じものを書き換えるという操作は存在しない」と書いたことが、ここで具体化する。
author と committer の非対称
上の比較で author だけが保存されていた点は、意図された設計だ。「変更を書いた人と日時」は積み直しても変わらないが、「このオブジェクトを作った日時」は変わる。両者を分けて持っているからこそ、履歴を作り替えても元の作業日時を失わずに済む。
git log で著者日時とコミット日時がずれているコミットを見かけたら、リベースかチェリーピックを経ている可能性が高い。
進行中の状態はファイルに置かれている
リベースは複数コミットにまたがる複数ステップの処理なので、途中で止まっても再開できる必要がある。その状態は .git/rebase-merge/ 以下に素直なファイルとして置かれていた。
| ファイル | 中身 |
|---|---|
onto | 積み先のコミットID |
orig-head | 開始時のブランチ先端(--abort の戻り先) |
head-name | 対象ブランチ名(refs/heads/feature) |
git-rebase-todo | これから処理する残りの手順 |
done | 処理済みの手順 |
git rebase --abort が確実に元に戻せるのは、orig-head に開始地点が保存されているからだ。インタラクティブリベースで編集するあの pick の羅列も、git-rebase-todo というただのテキストファイルでしかない。エディタが閉じられた後、Gitはこのファイルを上から1行ずつ実行しているだけだ。
なお、ディレクトリ名が rebase-merge である点自体、現在の既定がマージを使うバックエンドだと示している。古い実装は format-patch と am を使い、状態を .git/rebase-apply/ に置いていた。
同じ衝突を繰り返さないために
対象が n 個なら、マージが n 回走る。ということは、同じ箇所を触っているコミットが続いていると、毎回同じ衝突を解かされる。
これを軽減するのが rerere(reuse recorded resolution)だ。有効にすると、一度解決した衝突の「解き方」を記録し、同じ衝突が再び現れたときに自動で適用する。
| |
長期のブランチをリベースし直す機会が多いなら、有効にしておく価値がある。
マージコミットは平坦化される
素の git rebase は、対象範囲にマージコミットがあっても、それを落として一直線に並べ直す。マージコミットは「2つの履歴が合流した」という情報そのものなので、直線化するとその情報は失われる。
トポロジーを保ったまま積み直したい場合は --rebase-merges を使う。この場合 git-rebase-todo には label や merge といった命令が並び、分岐と合流の構造を再構築する手順が記述される。
cherry-pick と amend も同じ操作
ここまで理解すると、履歴を操作する他のコマンドもすべて同じ枠組みに収まる。
| コマンド | やっていること |
|---|---|
git cherry-pick | 1コミットを別の土台の上で3方向マージして、新しいコミットを作る |
git commit --amend | 現在のコミットの親をそのまま親にして、内容だけ差し替えた新しいコミットを作り、refを付け替える |
git rebase -i の squash | 複数コミットの結果を1つのコミットとして書き込む |
git filter-repo | 全コミットを走査して作り直す |
いずれも「新しいコミットオブジェクトを作り、refを付け替える」以上のことはしていない。既存のオブジェクトを書き換えるコマンドは、Gitに1つも存在しない。
物理層 — loose object と packfile
論理的なオブジェクトの話は以上で、ここからは実際のディスク上の話になる。
loose object
コミット直後、新しいオブジェクトはzlib圧縮された1オブジェクト1ファイルとして書かれる。パスはIDの先頭2文字をディレクトリ名、残り38文字をファイル名にして決まる。
先頭2文字で分けているのは、ファイルシステムへの配慮だ。1つのディレクトリに数十万のエントリを平坦に置くと、多くのファイルシステムで探索が重くなる。16進2文字なので最大256分割され、1ディレクトリあたりの数が現実的な範囲に収まる。
packfile とデルタ圧縮
ファイル数が増えると効率が悪いので、git gc は大量のloose objectを1つのpackfileにまとめる。この中で、似たオブジェクト同士がデルタとして圧縮される。
実際に、約100KBのファイルを12回書き換えたリポジトリで測ってみた。
| 状態 | .git/objects のサイズ |
|---|---|
| gc 前(loose object 36個) | 340K |
| gc 後(packfile 1個) | 40K |
git verify-pack -v で中を見ると、12個のblobのうち1個だけが完全な形(102682バイトの内容が5445バイトに圧縮)で保存され、残り11個は前のものに対する200〜260バイト程度のデルタとして連鎖していた。
デルタは履歴とは無関係に選ばれる
ここが面白いところで、デルタの基準は親子関係とは何の関係もない。これを確かめるため、似た内容の2ファイルを1つのコミットで同時に作ってみた。両者の間に履歴上の関係は一切ない。
97KBのファイルが、履歴的に無関係な別ファイルに対する26バイトのデルタとして格納された。デルタの基準は、型・パス・サイズ順に並べたうえでスライディングウィンドウ(既定 pack.window は10)を動かす発見的手法によって選ばれる。連鎖の深さには上限(既定 pack.depth は50)がある。
これが可能なのは、論理的なオブジェクトIDと物理的な保存方法が完全に分離しているからだ。あるIDのオブジェクトが、loose objectなのか、packfile内の完全な形なのか、深さ40のデルタ連鎖の先なのかは、利用側から見えない。だからGitは、データモデルを一切変えずに保存形式だけを改良し続けられた。この分離は記事を通して何度も出てきたが、最も効果を上げているのがここだ。
索引とビットマップ
packfileには対になる .idx があり、「このIDはpack内のどのオフセットか」を高速に引ける。さらに、クローンやフェッチの際に「このコミットから到達できるオブジェクト集合」を毎回計算し直すのは重いので、その結果をビットマップとして保存する仕組みもある。
いずれも本体のデータではなく索引だ。消しても再生成できる。
ファイルシステムとの境界
treeのmodeがOSごとの差を吸収する層になっていることは既に見た。同じ性質の調整が他にもある。
- 実行ビット: Windowsのファイルシステムには実行ビットがないため、
core.fileModeで変化を無視できる - 大文字小文字: macOSやWindowsの既定のファイルシステムは大文字小文字を区別しないことが多い。
README.mdとreadme.mdのように大文字小文字しか違わない2ファイルは、Linux上のtreeには両方載るが、これらの環境ではチェックアウトで衝突する。core.ignorecaseで緩和する - 改行コード:
core.autocrlfや.gitattributesの指定は、blobの中身を変えるのではなく、作業ディレクトリとblobの間の変換層として働く。blobには常に正規化された1つの内容だけが入る - シンボリックリンク: mode
120000で表現され、リンク先のパス文字列がそのままblobの中身になる。シンボリックリンクを扱えない環境では、代わりにパスを書いた通常ファイルとして展開される場合がある
共通しているのは、Gitのモデル側を環境に合わせて曲げず、境界に変換層を置くという方針だ。モデルを曲げると「同じ内容なら同じID」という前提が崩れ、リポジトリの同一性が環境依存になってしまう。
Git LFS
大きなバイナリでモデルの前提が崩れる話に戻る。Git LFSの解決策は、この記事の視点から見ると非常に素直だ。
オブジェクトモデルには一切手を入れず、blobの中身だけを差し替える。実際のファイルの代わりに、次のような小さなテキストをblobとしてコミットする。
実体は .gitattributes の指定に応じて、コミット時(cleanフィルタ)にこのポインタへ置き換えられ、チェックアウト時(smudgeフィルタ)に外部ストレージから取得されて書き戻される。
注目すべきは、LFSのストア側もまた内容アドレス方式だという点だ。SHA-256のハッシュで、先頭数文字ずつディレクトリを分けて格納する。Gitのloose objectとまったく同じ発想で、同じ理由(重複排除と整合性検証)から選ばれている。
commit・tree・DAG・refといった仕組みには何も変更を加えないので、LFSを知らないクライアントから見ても壊れては見えない。ポインタファイルの中身がそのまま見えるだけだ。
実務上の注意点をいくつか挙げる。
- LFS未導入の環境でクローンすると、実体ではなくポインタの中身が見える
- 保存容量と転送量のコストはホスティング側にかかる。無料枠を超えると課金対象になる場合があるので、利用中のサービスの条件を確認しておく
.gitattributesの指定より前にコミットしたファイルは自動では移行されない。後から移すにはgit lfs migrateなど履歴の書き換えを伴う作業が必要になる- 一度コミットした巨大ファイルを履歴から完全に消したい場合、
git rmだけでは足りない。過去のコミットが依然としてそのblobを参照しているからだ
最後の点は、ここまで読んだ内容から導ける。コミットは不変で、過去のtreeがそのblobを指し続けている以上、到達可能なままだ。消すには履歴そのものを作り直すしかない。
実践
手元で確認する最短のコマンド列を挙げておく。
| |
git cat-file -p <ID> でオブジェクトを1つずつ覗いていくと、この記事の図が指している構造が実際のリポジトリでそのまま確認できる。特に git cat-file tree <ID> | od -c は、treeがバイナリであることを一度見ておく価値がある。
注意点
- 既定のSHA-1には2017年に発表された衝突攻撃(SHAttered)がある。Gitは衝突検知付きの実装で緩和しているが、長期的にはSHA-256をオブジェクト形式として使う移行パスが用意されている。ただし現時点では実験的な位置づけで、既存リポジトリとの相互運用に制約がある
rebaseが安全なのは、まだ自分しか触っていないブランチに対して使う場合だ。共有済みのブランチを作り替えると、他の人の手元の履歴と食い違う。作り替えた後に push する場合は、影響範囲をチームで合意してから--force-with-leaseのような安全策を検討する- reflogはローカル限定なので、別のマシンやクローンし直したリポジトリでは、消えたコミットを復元できない
gcがいつ走るか、到達不能オブジェクトをいつ削除するかは設定とバージョンに依存する。「リベース直後なら必ず戻せる」と過信せず、大きな作業の前にブランチを分けておくほうが確実だ- この記事のバイト数や圧縮率は特定の条件での実測値であり、内容やバージョンによって変わる。傾向を掴む材料として読んでほしい
まとめ
- Gitの設計は「分散環境で調整なしに一意な名前を付ける」という要請から出発している。内容のハッシュをIDにするという1つの決定が、重複排除・整合性検証・同期の差分計算をまとめて解決した
- オブジェクトは4種類しかなく、blobは名前を持たず、treeが名前と構造を与え、commitがスナップショットに文脈を与える。参照が常にハッシュ経由なので、全体がMerkle DAGになり、1つのIDが到達範囲すべての完全性を保証する
- 不変なオブジェクトの層と、可変なrefの層は明確に分かれている。日常のコマンドの多くは下の層に触れずポインタを動かすだけで、だからブランチ操作が速い
- 構造共有によって、スナップショット方式でありながらコミットのコストは変更されたパスの深さに比例する。これは永続データ構造そのものの性質だ
- 代償として、リネームは記録されず(表示時に推測される)、空ディレクトリは表現できず、ファイル単位の履歴は毎回計算される。いずれもモデルからの必然的な帰結だ
- インデックスは「次のコミットの下書き」「statキャッシュ」「マージ中の3ステージ領域」という3つの役割を兼ねている
rebaseはコミットごとに3方向マージを走らせる処理で、ours が積み直し先・theirs が自分のコミットになる。ハッシュが変わるのはtree・parent・committerの3行が変わるからで、既存オブジェクトの書き換えは行われていない- 物理層のデルタ圧縮は履歴と無関係に内容の類似性だけで選ばれる。論理的なIDと物理的な保存形式が分離しているからこそ、モデルを変えずに保存効率を改良し続けられた
参考
- Scott Chacon, Ben Straub, “Pro Git”, 2nd edition(https://git-scm.com/book)— 10章 “Git Internals” が本記事の範囲に対応する
- Git公式ドキュメント “Git Internals - Plumbing and Porcelain”(https://git-scm.com/book/en/v2/Git-Internals-Plumbing-and-Porcelain)
- Git man pages:
gitrepository-layout(5)、gitformat-pack(5)、gitformat-index(5)、gitformat-commit-graph(5)(https://git-scm.com/docs) - Git公式ドキュメント
git-rebase(1)の “MERGE STRATEGIES” および “BEHAVIORAL DIFFERENCES” 節 - Linus Torvalds, “Tech Talk: Linus Torvalds on git”, Google Tech Talk, 2007
- Marc Stevens et al., “The first collision for full SHA-1”, CRYPTO 2017(https://shattered.io/)
- Git LFS 公式ドキュメント(https://git-lfs.com/)
- Chris Okasaki, “Purely Functional Data Structures”, Cambridge University Press, 1998 — 構造共有と永続データ構造の一般論