- Published on
Gitの内部構造で理解する命令 — オブジェクト、リファレンス、インデックスで読み直すresetとrebase
- Authors

- Name
- Youngju Kim
- @fjvbn20031
はじめに — 命令を覚える人とモデルを知る人
Gitの使い方を検索すると、たいてい命令の一覧が出てきます。こういう状況にはこの命令、ああいう状況にはあの命令。問題は、一覧にない状況が必ず来ることです。そのとき命令を覚えた人はまた検索し、データモデルを知る人は推論します。
幸い覚えるべきモデルはごく小さいものです。オブジェクト4種類、リファレンス、インデックス。この3つがすべてです。この記事はその3つを直接開いてみて、そのあとresetとrebaseとcherry-pickと分離されたHEADがなぜそう動くのかを1つずつたどり直します。データがどう保存されるのかそのものが気になるならGitはデータをどう保存しているかの回のほうが詳しいです。この記事はそのモデルで命令を読み直すほうに集中します。
4種類のオブジェクト — そして同じ内容は同じハッシュ
Gitが保存するオブジェクトは4種類だけです。
blobはファイルの内容だけを収めます。ファイル名も、権限も、位置も入っていません。treeはディレクトリ1つを表現し、モードとタイプとハッシュと名前の一覧を収めます。commitはtree1つと親コミットたち、作成者とコミッター、メッセージを収めます。tagオブジェクトは注釈付きタグを作るときに生まれ、対象オブジェクトとタグ名と署名を収めます。
そしてこれらすべてのオブジェクトの名前は、内容をハッシュした値です。ですから内容が同じなら名前も同じです。確認は簡単です。
$ printf 'hello\n' > hello.txt
$ cp hello.txt copy.txt
$ git add hello.txt copy.txt
$ git ls-files --stage
100644 ce013625030ba8dba906f756967f9e9ca394464a 0 copy.txt
100644 ce013625030ba8dba906f756967f9e9ca394464a 0 hello.txt
ファイル名が違うのにハッシュが同じです。保存されたオブジェクトも1つだけです。内容が同じなら、リポジトリのどこで何度コミットし直してもオブジェクトは増えません。
ここから派生する事実が1つあり、実務でしばしば人を混乱させます。blobが名前を知らないため、Gitはファイル名の変更を記録しません。名前はtreeが持っており、名前だけが変わった変更はtreeだけが違う新しいコミットにすぎません。ログやdiffに見える名前変更の表示は保存された事実ではなく、Gitがその都度内容の類似度で推定した結果です。名前の変更を越えて履歴を追うオプションがときどきおかしな動きをする理由はここにあります。
直接開いてみる — hash-objectとcat-file
ハッシュがどう計算されるのかは命令2つで確認できます。
$ printf 'hello\n' | git hash-object --stdin
ce013625030ba8dba906f756967f9e9ca394464a
$ printf 'blob 6\0hello\n' | shasum
ce013625030ba8dba906f756967f9e9ca394464a -
2行目がGitの実際にしていることです。タイプ名とバイト数とヌルバイトからなるヘッダを内容の前に付け、その全体をハッシュします。ヘッダがあるので、同じバイト列でもblobとtreeは違うハッシュを持ちます。
コミットも特別なところのないテキストです。
$ git commit -q -m "첫 커밋"
$ git cat-file -p HEAD
tree aaa96ced2d9a1c8e72c56b253a0e2fe78393feb7
author Demo <demo@example.com> 1785081963 +0900
committer Demo <demo@example.com> 1785081963 +0900
첫 커밋
親の行がありません。最初のコミットだからです。ここからtreeをたどってもう1段入ります。
$ git cat-file -t aaa96ce
tree
$ git cat-file -p aaa96ce
100644 blob ce013625030ba8dba906f756967f9e9ca394464a hello.txt
コミットがtreeを指し、treeが名前とblobを組にして持っています。この構造がリポジトリ全体に再帰的に繰り返されるだけです。コミットオブジェクトのなかにファイルの内容は1バイトもなく、ファイル名すらありません。
オブジェクトがいくつあるのか、どんなタイプなのかをまとめて見たいなら一括照会があります。
$ git cat-file --batch-check --batch-all-objects | head -4
ad50a5790e96b8f448d121aa76cee2cc3f05afce commit 178
aaa96ced2d9a1c8e72c56b253a0e2fe78393feb7 tree 37
ce013625030ba8dba906f756967f9e9ca394464a blob 6
リファレンス — ブランチが41バイトのファイルである理由
ブランチはデータ構造ではありません。コミットハッシュを1つ収めたテキストファイルです。
$ cat .git/HEAD
ref: refs/heads/main
$ cat .git/refs/heads/main
951f4a64d6435393fdedb7eb6aadd53939826b4c
$ wc -c .git/refs/heads/main
41 .git/refs/heads/main
40文字の16進数と改行1つ。ですからブランチの作成はファイルを1つ書く作業であり、リポジトリがどれだけ大きくても費用は同じです。ほかのバージョン管理システムでブランチが重い作業だった時代の感覚をそのままGitに持ち込むとブランチを惜しむようになりますが、惜しむ理由はまったくありません。
HEADはもう1枚あります。コミットを直接指す代わりにブランチ名を指すシンボリックリファレンスです。ですからコミットを作るとHEADが指すブランチのファイルが更新されます。
$ git symbolic-ref HEAD
refs/heads/main
$ git update-ref refs/heads/experiment 951f4a6
$ git rev-parse experiment
951f4a64d6435393fdedb7eb6aadd53939826b4c
1つ罠があります。リファレンスが常にファイルとして存在するわけではありません。整理作業が走ったあとはリファレンスが1つのファイルに束ねられ、個別のファイルは消えます。
$ git pack-refs --all
$ ls .git/refs/heads/
$ cat .git/packed-refs
# pack-refs with: peeled fully-peeled sorted
951f4a64d6435393fdedb7eb6aadd53939826b4c refs/heads/main
ディレクトリが空だからといってブランチが消えたわけではありません。ファイルを直接読む代わりに、いつでもGitに尋ねるべきです。
$ git rev-parse main
951f4a64d6435393fdedb7eb6aadd53939826b4c
$ git show-ref --heads
リファレンスが数万個に増える大規模リポジトリのために新しい保存形式も用意されています。まだ実験段階ですが、リファレンスの参照自体がボトルネックになる環境では検討する価値があります。
$ git init --ref-format=reftable myrepo
インデックス — ステージングエリアの実体
ステージングエリアは概念ではなくファイルです。リポジトリのなかのバイナリファイル1つであり、パスとblobのハッシュとファイルの状態情報の一覧を収めています。
$ git ls-files --stage
100644 ce013625030ba8dba906f756967f9e9ca394464a 0 hello.txt
100644 3b18e512dba79e4c8300dd08aeb37f8e728b8dad 0 note.txt
ここから2つを読み取る必要があります。
第一に、ファイルをステージングした瞬間、その内容はすでにオブジェクトとして保存されます。ステージングは予約ではなく記録です。だから下の結果になります。
$ echo v1 > note.txt
$ git add note.txt
$ echo v2 > note.txt
$ git commit -q -m "메모 추가"
$ git show HEAD:note.txt
v1
$ cat note.txt
v2
ステージングしたあとにファイルをさらに直したなら、コミットされるのはステージングした時点の内容です。これはバグではなく、インデックスとは何かという定義そのままの動作です。同じ理由で、ステージングしただけでコミットしていない内容も事故のあとに復旧できます。オブジェクトはすでに保存されているからです。
第二に、一覧の数字0はステージ番号です。平常時は0の1つだけですが、コンフリクト中は同じパスが3行に増えます。
$ git ls-files --unmerged
100644 a1b2c3d4e5f60718293a4b5c6d7e8f9012345678 1 src/checkout/retry.ts
100644 b2c3d4e5f60718293a4b5c6d7e8f90123456789a 2 src/checkout/retry.ts
100644 c3d4e5f60718293a4b5c6d7e8f90123456789ab1 3 src/checkout/retry.ts
1番が共通祖先、2番が現在のブランチ、3番が入ってくる側です。コンフリクト状態とは、インデックスに1つのパスに対する候補が3つ入っている状態のことです。コンフリクトを解決してステージングすると3行が0番の1行に統合され、そのとき初めてコミットが可能になります。解決の途中で各バージョンを直接取り出して見ることもできます。
$ git show :1:src/checkout/retry.ts > base.ts
$ git show :2:src/checkout/retry.ts > ours.ts
$ git show :3:src/checkout/retry.ts > theirs.ts
インデックスにはファイルの大きさや更新時刻といった状態情報も一緒に入っています。状態確認がファイルを開かずに変更の有無を判断できる理由であり、ファイルが数十万個になるとその確認自体がボトルネックになる理由でもあります。その地点の処方はリポジトリが遅くなったときの回にまとめました。
モデルが説明してくれる命令たち
ここで命令を読み直します。判断の基準は3つだけです。新しいオブジェクトを作るのか、リファレンスを動かすのか、ワーキングツリーを上書きするのか。
| 命令 | データモデルでしていること | 新しいオブジェクト | ワーキングツリーを上書きするか |
|---|---|---|---|
git branch | コミットハッシュ1行を収めたリファレンスを書く | なし | いいえ |
git switch | HEADが指す対象を変え、インデックスとワーキングツリーを合わせる | なし | はい |
git commit | treeとcommitオブジェクトを作り、現在のブランチのリファレンスを動かす | 作る | いいえ |
git reset --soft | ブランチのリファレンスだけを動かす | なし | いいえ |
git reset --hard | リファレンスを動かし、インデックスとワーキングツリーを対象コミットに合わせる | なし | はい |
git rebase | コミットごとに新しいコミットオブジェクトを作り、リファレンスを動かす | 作る | はい |
git cherry-pick | 対象コミットの親を基準に三方向マージしてから新しいコミットを作る | 作る | はい |
git tag -a | tagオブジェクトを作り、リファレンスを書く | 作る | いいえ |
表を読み終えると、よく出てくる質問がおのずと解けます。
resetが危険な命令と呼ばれる理由は、リファレンスを動かすからではありません。リファレンスの移動は41バイトのファイルを書き直す作業であり、以前の値はreflogに残ります。危険なのはワーキングツリーを上書きするオプションだけです。表の最後の列がそのまま危険度です。
rebaseがコミットを移動せず新しく作る理由も明確です。コミットオブジェクトのなかに親のハッシュが入っており、コミットの名前はその内容全体のハッシュなので、親が変われば名前も変わらざるをえません。書き直しという言葉は比喩ではなく文字どおりの説明です。この事実が作るチームの規則はmergeとrebaseの回で扱いました。
cherry-pickがコンフリクトする理由もここから出てきます。cherry-pickは保存されたdiffを切り貼りするものではありません。Gitはそもそもdiffを保存せずスナップショットだけを保存します。ですからcherry-pickは対象コミットの親treeを共通祖先として三方向マージを実行します。取り込もうとするコミットの親の状態と今のブランチの状態が遠いほどコンフリクトが増えます。同じ変更を複数のブランチへ繰り返し移しているなら、コンフリクト解決の再利用を有効にしておくほうがましです。
分離されたHEADも特別な状態ではありません。HEADファイルにブランチ名の代わりにコミットハッシュが直接入っているだけです。
$ git switch --detach 951f4a6
HEAD is now at 951f4a6 메모 추가
$ cat .git/HEAD
951f4a64d6435393fdedb7eb6aadd53939826b4c
この状態でコミットを作るとHEAD自体が新しいコミットを指すよう更新されますが、更新するブランチのリファレンスがありません。ですから別の場所へ移動した瞬間、そのコミットを指すものが何も残りません。Gitが親切に警告してくれる理由です。
$ git switch -
Warning: you are leaving 1 commit behind, not connected to
any of your branches:
d66381f 임시 실험
If you want to keep it by creating a new branch, this may be a good time
to do so with:
git branch <new-branch-name> d66381f
Switched to branch 'main'
分離されたHEADは故障ではなく、名札がない状態です。コミットオブジェクトは無事に保存されており、名前を付けるだけでそのまま生き返ります。
SHA-1からSHA-256へ — 衝突攻撃とGitの対応
オブジェクトの名前がハッシュだという設計は優雅ですが、ハッシュ関数の寿命に従属します。Gitが2005年に選んだのはSHA-1であり、その後も攻撃は着実に進歩しました。
2017年、CWIアムステルダムとGoogleの研究陣が同じSHA-1ハッシュを持つ異なる2つのPDFを公開しました。2020年にはルランとペイランが選択接頭辞衝突を実現し、費用を数万ドル規模まで下げました。選択接頭辞衝突は攻撃者が前の部分を指定できるという意味なので、実際の文書偽造にはるかに近づきます。
Gitのハッシュは一次的には整合性検査と識別のためのものであり、署名ではありません。しかし署名されたコミットとタグは結局このハッシュに署名するので、衝突が可能なら署名の意味が揺らぎます。
対応は2つの筋で進みました。1つ目はすぐに適用できる防御です。Git 2.13から衝突検知機能の付いたSHA-1実装が既定で入りました。既知の攻撃手法の痕跡があるデータをハッシュしようとすると、計算を終える代わりに拒否します。
$ git hash-object --stdin < shattered-1.pdf
fatal: SHA-1 collision detected on 38762cf7f55934b34d179ae6a4c80cadccbb7f0a
2つ目は根本的な解決です。Git 2.29からSHA-256オブジェクトフォーマットを実験的に使えます。
$ git init --object-format=sha256 sha256demo
$ cd sha256demo
$ git rev-parse --show-object-format
sha256
$ printf 'hello\n' | git hash-object --stdin
2cf8d83d9ee29543b34a87727421fdecb7e3f3a183d337639025de576db9ebb4
同じ内容なのに名前がまったく違い、長さが64文字です。リポジトリのなかではすべての命令がいつもどおり動きます。ただし今の実務で移行する対象ではありません。SHA-1リポジトリとSHA-256リポジトリのあいだの相互運用がまだ完成しておらず、主要なホスティングサービスが受け入れていないからです。
それでも今しておくことはあります。ハッシュの長さを40と仮定したスクリプトと正規表現は、いつかすべて壊れます。ツールを作るなら、長さをハードコードする代わりにリポジトリに尋ねるほうが安全です。
$ git rev-parse --show-object-format
sha1
おわりに — 命令表の代わりに3つの質問
Gitの命令を全部覚える必要はありません。見慣れない命令に出会ったら3つだけ問えば済みます。この命令は新しいオブジェクトを作るのか、リファレンスを動かすのか、ワーキングツリーを上書きするのか。
新しいオブジェクトを作るならハッシュが変わるので、共有された履歴では気をつける必要があります。リファレンスだけを動かすならreflogが戻してくれます。ワーキングツリーを上書きするならコミットされていない変更が消え、それだけはどんな道具も生き返らせてくれません。Gitで本当に危険な命令は履歴を変える命令ではなく、まだオブジェクトになっていないものを消す命令です。
同じモデルで巻き戻しの命令を整理した記事は状況別の巻き戻しの回にあります。