コミットはスナップショット
Git のコミットは不変オブジェクトで、保存されたパッチではない。インデックスから作った完全なツリーを参照し、親、作者、コミッター、メッセージを記録して、直列化した内容をハッシュする。ファイルが同一でも親だけ変わればコミット ID が変わる。
ツリーを写真、コミットを裏に付けた記録カードと考える。同じ写真でも「前のカード」が違えば別の記録になる。Git は写真だけでなく、カード全体をハッシュする。
Git のコミットスナップショットの仕組み
スナップショットの元はインデックス。各ステージ済みパスは blob を指し、その ID はファイルの正確なバイト列から決まる。ツリーは全パスとオブジェクトの対応を記録し、コミットはツリー ID、親情報、作者とコミッターの行、メッセージを記録する。
- blob はファイルのバイト列を持つ。名前は識別に含まれず、同じ内容の二ファイルで共有できる。
- tree は名前、モード、オブジェクト ID を持つ。一ファイルが変わっても、新ツリーは未変更ファイルの旧 blob を参照できる。
- commit はツリー ID と履歴メタデータを持つ。親フィールドが、同じスナップショットを特定の履歴上の新しい点にする。
ハッシュ形式は厳密だ。本文の前にオブジェクト型、空白、バイト長、ヌルバイトを付け、ヘッダーと本文をハッシュする。本レッスンの SHA-1 形式では、空ツリーは常に 4b825dc642cb6eb9a060e54bf8d69288fbee4904。図用の仮名ではなく実際の Git ID だ。
ID は内容の識別子で、上書きできる保存枠ではない。同じバイト列は同じ ID になり、既存オブジェクトを再利用できる。バイトが変われば別オブジェクトを書き、旧オブジェクトは変えない。git diff は二スナップショットの差を計算し、コミット内のパッチを読むわけではない。rebase、cherry-pick、git commit --amend は、チェックアウトしたファイルが同じでも祖先やメタデータが変わって新 ID を作りうる。
コミットスナップショットのステップ
既定は app.py、README.md、線形の main を使う。識別情報と時刻を固定し、最後の ID 変化を親フィールドに帰属できるようにする。
- app.py を
print("one")に編集する。作業バイトだけが変わり、blob はまだない。 - add が 12 バイトを blob
51ee69d...にし、保存後にインデックスへapp.py -> 51ee69d...を書く。 - README を
helloにして add すると blobb6fc4c6...ができ、インデックスは二パスになる。 - メッセージ
baseでコミットする。二項目からツリーd5ba943...を作る。最初のコミットは親を持たず、ツリー、固定の識別行、メッセージを含めて45ecc20...になる。オブジェクトができてから main が動く。 - app.py を
print("two")にして add し、新 blob を作る。README は未変更なのでb6fc4c6...を参照し続ける。 updateでコミットする。新ツリーff8ad65...は新 app.py blob と旧 README blob を参照する。親45ecc20...を持つコミット ID は309aeee...。--allow-emptyと同じupdateでもう一度コミットする。インデックスが同じなのでff8ad65...を再利用する。メッセージ、識別情報、時刻も同じだが、親だけが309aeee...に変わる。新 ID はccf8be2...となり、main が再び進む。
六番目は構造共有を示す。README のバイトを新容器へコピーせず、新ツリーが同じ blob を指す。七番目ではプロジェクトのスナップショットが変わらなくても、履歴が変わる。
同じスナップショットで ID が違う理由
既定の C2 と C3 は共に ff8ad65... を指すが、C3 の親は C2、C2 の親は C1。コミット ID は祖先を含む内容を識別するので、異なる履歴は異なる ID を持つ。
親行を消すと、二リポジトリが別の前コミットを経ていても、ツリー、固定の識別情報、時刻、メッセージが同じなら同じ内容と ID になり、どの履歴に続くかをオブジェクトから示せない。ツリーは何を保存するか、親はどの履歴の後かを答え、ハッシュは両方を識別に含める。
--allow-empty は重複ツリーを作らない。通常のツリー等値チェックを越え、新コミットが既存ツリーを参照することを許す。新しい親だけで内容を変えられる。印付け、自動化の起動、ファイル変更のない出来事の記録などに使える。
境界ケース
- ステージ変更なし:通常のコミットはインデックスのツリーを HEAD と比べ、同じなら内容の組み立て前に止まる。未ステージの作業編集は関係しない。
- allow-empty:同じツリーを認めても現在の HEAD が新しい親になるので、固定メタデータやメッセージが同じでも新コミットになる。
- 作業ファイルなし:このパス指定 add は blob やインデックス項目を作らない。実際の Git の削除ステージは二ファイルモデルの対象外。
- 同じファイルのバイト列:再 add でも同じ blob ID になり、二枚目のオブジェクトは不要。
- 短い ID の衝突:少なくとも七文字の最短一意接頭辞を表示し、必要なら延ばす。識別子は完全なハッシュだ。
- ハッシュ形式:本レッスンは SHA-1 を使うが、Git は SHA-256 リポジトリも扱える。ID を常に 40 桁の十六進数と仮定しない。
よくある間違い
- 「コミットは変更行を保存する。」 差分はスナップショットの比較で作る。コミットは完全なツリーを指し、未変更パスも残る。
- 「一ファイルを変えると全体をコピーする。」 新ツリーはパスの対応を更新し、未変更項目の ID を再利用する。
- 「コミット ID はファイルだけのハッシュ。」 オブジェクトヘッダー付きの直列化コミットをハッシュし、本文にはツリー、親、識別行、メッセージが含まれる。ファイルはツリーを介して影響する。
- 「親は移動用のヒントだけ。」 親もハッシュ対象なので、変えればチェックアウトしたファイルが同じでも別コミットになる。
- 「allow-empty は空ツリーを作る。」 通常は現在のステージ済みツリーを使う。「空」は親に対してファイルツリー変更がない意味で、ファイルがない意味ではない。
- 「Git は現在のディスク内容から ID を再計算する。」 作業ツリーはオブジェクト庫の外にある。ID は保存したバイト列から決まり、編集しても変わらない。
モデルの範囲
二つの平坦なファイル、モード 100644、線形履歴、固定の作者とコミッター、再現可能な SHA-1 直列化を使う。ディレクトリ、実行可能・シンボリックリンクのモード、削除、マージの親、署名、フック、参照保存、インデックスのメタデータ、SHA-256 の設定、pack、delta、reflog、拡張は省く。それらが加わっても、コミットがツリー、祖先、メタデータを含む原則は変わらない。
形式と動作は Git オブジェクトの内部構造、git commit-tree、ハッシュ関数移行計画 を参照できる。
続けて学ぶ
同じ手法につながるレッスンや、次の概念への入口です。
鍵マークのあるレッスンの操作には有料アクセスが必要です。解説はすべて無料で読めます。
誤りや気になる点を見つけましたか? フィードバックを送る。