はじめに
git add、git commit、git merge は日常的に使うコマンドですが、その裏でGitが何をしているかを正確に説明できる人は意外と少ないのではないでしょうか。
本記事では、Gitのオブジェクトモデル(blob/tree/commit)の基本構造から、ブランチの正体、マージやcherry-pickの内部動作までを、一連の流れとして整理します。
Gitのオブジェクトモデル
Gitはすべてのデータを .git/objects 配下に、内容のSHA-1(またはSHA-256)ハッシュをキーとして保存します。
.git/objects/ab/cdef1234...
ハッシュの先頭2文字がディレクトリ名、残りがファイル名になります。
オブジェクトには4種類あります。
| オブジェクト | 役割 |
|---|---|
| blob | ファイルの中身そのもの |
| tree | ディレクトリ構造(ファイル名とハッシュの対応表) |
| commit | スナップショットへのポインタ+メタデータ |
| tag | 注釈付きタグ(commitを指す) |
以降、それぞれの詳細を見ていきます。
blob:ファイルの中身
blobはファイルの中身のバイトデータだけを保持するオブジェクトです。重要なのは、blobはファイル名という概念を一切持たないという点です。
同じ内容のファイルが複数の場所に別名で存在していても、内容のハッシュが一致すれば同じblobを指すため、実データは重複して保存されません。ファイル名という「置き場所の情報」は、次に説明するtreeが担当します。
tree:ディレクトリ構造
treeはディレクトリ1つに対応するオブジェクトで、その階層にあるファイル・サブディレクトリの一覧を保持します。
$ git cat-file -p <treeハッシュ>
100644 blob a1b2c3d4... README.md
100644 blob e5f6a7b8... build.gradle.kts
040000 tree 7e8f9a0b... src
040000 tree 1c2d3e4f... sql
各エントリは「モード・オブジェクト種別・ハッシュ・名前」の4点セットです。
| モード | 意味 |
|---|---|
| 100644 | 通常ファイル |
| 100755 | 実行可能ファイル |
| 040000 | ディレクトリ(=別のtreeを指す) |
| 120000 | シンボリックリンク |
| 160000 | サブモジュール |
ディレクトリが深くなるほど、treeが別のtreeを再帰的に参照する構造になります。フルパス(src/main/java/...)はどこか1箇所にまとめて記録されているわけではなく、treeを辿った経路そのものから合成される情報です。
重要な性質:treeは常に全ファイルの参照を持つ
どのcommitも、そのcommit時点の全ファイル・全ディレクトリへの参照を漏れなく持っています。1行だけの変更であっても、変更していないファイルのエントリは省略されず、そのまま(同じハッシュを指す形で)treeに含まれます。
C5のroot tree:
100644 blob aaa... README.md ← 今回変更した(新しいblob)
100644 blob bbb... build.gradle.kts ← 変更してないが参照はある(既存blobを再利用)
040000 tree ddd... src ← 同上
そのため、あるcommitのtreeを1回読むだけで、そのcommit時点の全ファイルが復元できます。親commitを遡って足りないファイルを集める、という動作は発生しません。
commit:スナップショットへのポインタ
commitオブジェクトの生データは以下のような形式です。
$ git cat-file -p <commitハッシュ>
tree 789abcdef...
parent 456def0123...
author Taro Yamada <taro@example.com> 1735689600 +0900
committer Taro Yamada <taro@example.com> 1735689600 +0900
SecurityConfigにCSRF対応を追加
| フィールド | 内容 |
|---|---|
| tree | このcommit時点の全ファイルスナップショットを指すtreeのハッシュ(必須、1つ) |
| parent | 親commitのハッシュ。0個(root commit)/1個(通常)/2個以上(マージ) |
| author | 変更を書いた人+タイムスタンプ |
| committer | 履歴に記録した人+タイムスタンプ |
| メッセージ | 自由記述のコミットメッセージ |
commitが持つデータは実質これだけです。ファイルの中身そのものはtree/blobに委譲しており、commit自体は「このtree、この親、誰がいつ、なぜ」という最小限のメタデータのみを持ちます。
commitのハッシュは、この生データ全体をハッシュ化した値です。そのため、メッセージを1文字変えても、親commitのハッシュが変わっても、commit自身のハッシュは変化します。rebaseで後続のcommitハッシュがすべて変わるのはこのためです。
authorとcommitterが分かれる理由
通常のcommitでは両者は同じですが、後述するcherry-pickやrebase、commit --amend の際には分離します。authorは「変更を最初に書いた人」、committerは「今この歴史に組み込んだ人」を表すため、移植や書き換えの操作でauthor情報は保持しつつcommitterだけが更新される、という挙動になります。
branchとHEADの正体
refsは単なるポインタファイル
ブランチの実体は、refs/heads/<ブランチ名> という1行だけのテキストファイルです。
$ cat .git/refs/heads/main
a1b2c3d4e5f6...
中身はそのブランチが指す先頭commitのハッシュのみです。commitオブジェクト自体は、自分がどのブランチに属するかという情報を一切持ちません。
HEADは間接参照
$ cat .git/HEAD
ref: refs/heads/main
HEAD → refs/heads/main → commitハッシュ という2段階の間接参照になっています。
objectsはブランチ横断でごちゃまぜ
.git/objects はブランチという概念を持たず、全ブランチのオブジェクトが同じ場所に、内容ハッシュだけを頼りに混在して保存されます。
「あるcommitがどのブランチに属するか」は、commit自身に書かれているわけではなく、refsから親を辿って到達可能かどうかで事後的に判定されます。
refs/heads/main → C5 → C4 → C3 → C2 → C1
refs/heads/feature → C6 → C3 → C2 → C1
この例ではC3以前が両ブランチから到達可能な共通commitとなり、実体としても同一オブジェクトが1つ存在するだけです。この設計により、ブランチ作成は新しいオブジェクトを一切生成せず、refsファイル1つを書くだけの軽量な操作になります。
マージの内部動作
マージでは、変更が重なるかどうかによって挙動が変わります。
ケース1:別ファイルをそれぞれ変更
コンフリクトは発生せず、双方の変更を組み合わせた新しいtreeとマージcommitが1つ作られます。既存のblobがそのまま再利用されるため、blobは増えません。
ケース2:同じファイルの別行を変更(自動マージ)
同じファイルでも、変更箇所(行)が重ならなければ自動マージされます。ただしこの場合、両方の変更を合成した中身は元のどちらのblobとも一致しないため、新しいblobが1つ生成されます。
ケース3:同じ箇所を変更(コンフリクト)
共通祖先から見て、両ブランチが同じ行を別々に変更した場合にコンフリクトが発生します。
<<<<<<< HEAD
# EC API (Production Ready)
=======
# EC API - Learning Project
>>>>>>> feature
この間、indexは通常の1エントリではなく、以下の3ステージを一時的に持ちます。
| stage | 意味 |
|---|---|
| stage 1 | 共通祖先(base) |
| stage 2 | ours(現在のブランチ) |
| stage 3 | theirs(取り込む側) |
手動解決後にcommitすると、親を2つ持つマージcommitが生成されます。tree・commitの仕組み自体は通常のcommitと変わらず、parentの数だけが特殊です。
cherry-pickの仕組み
cherry-pickは特定のcommitを別ブランチに「移植」する操作ですが、実態はcommitの移動ではなく、diffの再計算による新規commitの生成です。
- 対象commitとその親の差分(diff)を計算する
- 現在のHEADにそのdiffを適用する
- 適用結果から新しいtreeを構築する
- 新しいcommitを作成する(authorは元のまま保持、committerは新規に設定)
main: C1 - C2 - C3 - C5'
\
feature: C4 - C5 - C6
元の C5 はfeatureブランチにそのまま残り、mainには中身は同じでも別ハッシュの C5' が追加されます。treeの親構造が異なるため、tree・commitともに新しいハッシュになります。
なお git rebase は、内部的には対象commit群に対してこのcherry-pickを連続実行しているのとほぼ同じ処理です。
まとめ
- Gitのオブジェクトモデルはblob(中身)・tree(構造)・commit(スナップショットへのポインタ)の3層で構成される
- commitのtreeは常に全ファイルへの参照を持ち、差分ではなく完全なスナップショットを表す
- ブランチの実体は1行のポインタファイルに過ぎず、「どのブランチに属するか」はcommitグラフの到達可能性で決まる
- マージやcherry-pickは、変更内容に応じてblob・tree・commitのいずれが新規生成されるかが変わる
Gitの操作コマンドを暗記するのではなく、この内部構造を理解しておくことで、コンフリクトやrebase時の挙動、履歴調査の際に迷いにくくなります。