日々、開発で誰もが当たり前のように使っている git。
「変更履歴(差分)を順番に保存し続けている」と安直に考えてしまいがちですが、実は Gitはそのような単純な差分管理を行っていません。 ここが、SVNなどの従来のバージョン管理システムと決定的に異なる点です。
本記事では、Gitの内部構造を整理し、普段のコマンド(add や commit、マージ時のコンフリクト判定など)の裏側で何が起きているのかを解説します。
Gitの中身を構成する「3つのオブジェクト」
Gitは、内部的に「3つのオブジェクト」の構造を巧みに組み合わせて、コミット、ブランチ、マージなどを管理しています。
1. Blob(ブロブ):ファイルの実態
ファイルの中身そのものを保存するオブジェクトです。例えば train.py のソースコードの中身がここに入ります。
重要なポイントは、Blobには「ファイル名」や「ディレクトリ構造」などのメタデータが一切保存されないということです。ただ単に「ファイルの内容」だけが記録されます。
そして、その内容はSHA-1という計算方式によって 40文字のハッシュ値 に変換され、このハッシュ値をIDとして一意に保存されます。ファイルの中身が1文字でも変われば、全く異なるハッシュ値を持つ新しいBlobが生成されます。
2. Tree(ツリー):ディレクトリ構造
ディレクトリ構造を管理するオブジェクトです。「どのファイル名が、どのBlob(ハッシュ値)に紐づいているか」を記録します。また、それがファイルなのかディレクトリなのかという情報も持ちます。
Tree自体も、作成された瞬間に内容からハッシュ値が計算され、保存されます。
3. Commit(コミット):スナップショットの頂点
我々が普段行っているコミットの記録です。「どのTree(ディレクトリ構造のトップ)を指しているか」というハッシュ値に加えて、以下の情報が保存されています。
- 誰がコミットしたか(Author/Committer)
- 親コミットのハッシュ値(どのコミットから派生したか)
- コミットメッセージ
ファイル名を変えただけなら中身(Blob)は増えない
Gitでは、ファイルが1行でも変更されると、変更部分だけの「差分」ではなく、ファイル全体を全く新しいBlob(固有のハッシュ値)として丸ごと保存します。
逆に、中身を変えずに「ファイル名」だけを変更した場合、ファイルの内容自体は変わっていないため、既存のBlobのハッシュ値はそのまま再利用されます。変わるのは、ファイル名とハッシュ値の対応関係を記録している「Tree」側だけです。
疑問:毎回事丸ごと保存して、ファイルサイズは爆発しないのか?
「ちょっとの変更でも新しいBlobを作って丸ごと保存する(=スナップショット方式)」と聞くと、プロジェクトのファイルサイズが天文学的に増えてしまうのではないかと疑問に思うかもしれません。
結論から言うと、問題ありません。Gitの肝は、その背後にある優れた圧縮技術にあります。
-
Zlib圧縮
GitはBlobなどのオブジェクトを保存する際、そのままではなく「Zlib」という標準的な技術で圧縮して保存しています。テキストファイルは非常に圧縮率が高いため、これだけでも容量はかなり抑えられます。 -
Packfile(パックファイル)による差分圧縮
ある程度オブジェクトが溜まったり、ネットワーク経由でgit push/pullしたりするタイミング(またはgit gcコマンド実行時)に、Gitは内部でファイルの整理を行います。この時、「似たようなファイル(過去のBlobと新しいBlob)」を見つけ出し、最新のファイルをそのまま保持し、古いファイルを「最新からの差分(デルタ)」として再圧縮して1つの「Packfile」にまとめます。
つまり、「論理的にはすべて丸ごとスナップショットとして扱う(管理がシンプルになる)」一方で、「物理的な保存時は賢く差分圧縮を行う」というハイブリッドな仕組みによって、このバージョン管理が実現できているのです。
コンフリクトはどうやって判定しているのか?
Gitのすごいところは、複数人の変更を統合(マージ)する際の「コンフリクト(衝突)」の検知能力にあります。これはどうやって実現しているのでしょうか。
単純に「ブランチA」と「ブランチB」の最新ファイルを上から行ごとに比較しようとすると、どちらかのブランチで新しい行が追加されていた場合、そこから下はずっと行ズレを起こしてしまい、正確な比較ができません。
Gitはこれを、**「3-Way Merge(3方向マージ)」**という計算手法と、Treeの履歴をたどることで解決しています。
- ブランチAとブランチBの履歴を相互にたどり、**「共通の祖先(Merge Base)」**となるコミットを見つけ出します。
- 「共通の祖先」と「ブランチA」を比較し、Aで「どの部分がどう変わったか」を計算します。
- 同様に、「共通の祖先」と「ブランチB」を比較し、Bでの変更点を計算します。
- この2つの変更内容(差分)を突き合わせます。もし、同じファイルの同じ行付近に対して、AとBで別々の変更が加えられていれば、「競合している(コンフリクト)」と判定するわけです。
補足:なぜGitは「差分の記録」ではなく「スナップショットの計算」を選んだのか?
Gitの生みの親であるLinus Torvalds(Linuxカーネルの開発者)は、何万人もの開発者が入り乱れる巨大プロジェクトにおいて、「ブランチの作成・切り替え・マージ」を爆速で行うことを最優先事項としました。
「変更差分(パッチ)」を順番に保存していくシステムの場合、ある時点のファイルの状態を復元するには、最初の状態から順に差分を足し引きしていく計算が必要になり、履歴が長くなるほど動作が遅くなります。
しかし、Gitのように**「各コミットが完全なスナップショットを持っている」設計であれば、そのコミット(Tree)を読み込むだけで一瞬で状態を復元できます。** 差分は「保存しておくもの」ではなく、必要になった時に「2つのスナップショットを比較して計算で動的に求めるもの」としたのです。これがGitの圧倒的なスピードと柔軟性の理由です。
なぜ git add してから git commit するのか?
ここまでの内部構造を理解すると、なぜ add と commit という2段階の操作が必要なのかが明確になります。(git commit -am で一気にやることもできますが、内部処理は分かれています)。
内部的に起きていることは以下の通りです。
-
git addの役割
ファイルを読み込み、その内容からハッシュ値を計算して Blobオブジェクトとして保存(登録) します。つまり、この段階でファイルの実態の保存が完了し、コミットの準備(インデックスへの登録)が行われます。 -
git commitの役割
addによって準備されたファイル群(ハッシュ値のリスト)を参照し、ディレクトリ構造を表す Treeオブジェクトを作成 します。そして、そのTreeを指し示す Commitオブジェクトを作成 し、メッセージや親コミットの情報を付与します。
つまり、Tree(ディレクトリ構造)を作ってコミットとして記録するためには、事前にファイルがBlob(ハッシュ値)に変換されている必要があります。
「Blobを作る(add)」→「それらをまとめたTreeとCommitを作る(commit)」という依存関係があるため、内部処理的には明確に別の操作となり、コマンドも分離されているのです。
まとめ
Gitは単なる「差分の履歴帳」ではなく、**「ファイル内容そのものをハッシュ化して管理するデータベース」**です。
- Blob / Tree / Commit の3つのオブジェクトでスナップショットを管理している。
- 変更のたびに全体を保存しているが、強力な圧縮技術により容量問題はクリアしている。
- コンフリクトは、共通の祖先から動的に差分を計算する 3-Way Merge で正確に判定している。
-
addでファイル実態(Blob)を保存し、commitで構造(Tree)を保存するため、コマンドが分かれている。
普段何気なく打っているコマンドの裏側で、このような美しいデータ構造と計算が行われていることを知ると、Gitの動きがより論理的に理解できるようになり、トラブル時の解決力もグッと上がるはずです。