バージョン管理システム Git:Merkle DAG としての履歴と共同開発フロー
0. この記事の要点
Section titled “0. この記事の要点”- Git は「差分の列」ではなく、内容のハッシュをアドレスとする不変オブジェクトの集まりです。コミットは、そのときのファイル木全体へのポインタと、親コミットへのポインタを持ちます。
- 全オブジェクトが子のハッシュを含む形で直列化されるため、オブジェクトグラフは Merkle DAG になります。その帰結として、コミットハッシュ 40 文字を照合すれば、そこから到達できる履歴とファイル内容のすべてが確定します(定理 3.3)。
- ブランチは「コミットを指す名前つきポインタ」にすぎません。
git branchが安価なのはこのためです。 git mergeは 2 つのコミットのマージベース(共通祖先の極大元)を求め、ベース・自分・相手の 3 つの版から 3-way マージを行います。マージベースが HEAD 自身になることと、HEAD が相手の祖先であることは同値で、これが fast-forward の正体です(命題 5.3)。- マージベースは一意とは限りません。criss-cross と呼ばれる履歴では 2 つ以上になり、どれを選ぶかでマージ結果が変わります(命題 5.5)。Git の既定戦略はこの問題を「ベース同士をマージして仮想ベースを作る」ことで処理します。
- rebase は親を付け替えるので、必ず別のハッシュを持つ別のコミットを作ります(命題 5.7)。共有ブランチで rebase してはいけない理由がここにあります。
1. 動機:バージョン管理は何を解く問題か
Section titled “1. 動機:バージョン管理は何を解く問題か”report.docx、report_v2.docx、report_v2_修正.docx、report_最終.docx、report_最終_本当に最終.docx。誰もが一度は作ったことのあるファイル名の列です。これは素朴なバージョン管理であり、実際にある程度は機能します。しかし次の 3 つの問いに答えられません。
report_v2.docxとreport_v2_修正.docxの違いは何か。ファイル名は違いを記録しません。report_最終.docxはreport_v2.docxから作られたのか、report_v2_修正.docxから作られたのか。つまりどれが誰の子孫なのか。- 2 人が同時に
report_v2.docxを編集して別々に保存したとき、両方の変更を残すにはどうするか。
1 番目は「差分」、2 番目は「履歴の構造」、3 番目は「並行編集の統合」の問題です。バージョン管理システム(VCS)とは、この 3 つを同時に扱う道具のことです。
歴史的には、この 3 つは順に解かれてきました。1970 年代前半に Bell 研究所の Marc Rochkind が作った SCCS が、ファイルの改訂履歴を 1 つの保管ファイルにまとめる方式を確立します。1980 年代前半に Purdue 大学の Walter Tichy が作った RCS はこれを改良し、最新版を丸ごと持ち、過去へ遡る差分(逆差分)を蓄える方式を採りました。どちらも 3 番目の問題は「ロック」で回避します。編集する人がファイルを排他ロックし、他の人は待つ、という方式です。
ロック方式は、開発者が数人ならうまくいきます。しかし数十人になると待ち行列が破綻します。そこで CVS(1980 年代後半)以降は方針が反転し、誰でも自由に編集してよい、衝突したら後で統合するという楽観的な方式が主流になりました。データベースの同時実行制御でいう楽観的並行制御と同じ発想です(データベース設計の基礎 も参照してください)。統合の道具が、この記事の後半で扱う 3-way マージです。
CVS と、その後継である Subversion(2000 年〜)には、なお中央集権という制約が残っていました。履歴はサーバーにしかなく、コミットするにはネットワークが要ります。ブランチを切るのはサーバー上のディレクトリコピーで、重い操作でした。
2005 年 4 月、Linux カーネル開発チームが使っていた商用の分散型 VCS である BitKeeper の無償利用が打ち切られます。Linus Torvalds は代替を数週間で書き上げました。これが Git です。設計上の要求は明確でした。数万ファイル・数十万コミットの規模で高速に動くこと、ネットワークなしで全操作ができること、そして履歴の改竄を検出できることです。3 番目の要求が、Git を単なるファイル履歴管理ではなく、暗号学的ハッシュに基づくデータ構造にしました。
2. 準備:ハッシュ関数と内容アドレス方式
Section titled “2. 準備:ハッシュ関数と内容アドレス方式”Git を理解する鍵は、ファイルに名前で触らず、内容のハッシュ値で触るという発想です。まずその道具立てを定義します。
定義 2.1(内容アドレス格納庫)
を有限バイト列全体、 を正整数とする。写像 が暗号学的ハッシュ関数であるとは、次の性質が(計算量的な意味で)成り立つことをいう。
- (衝突困難性) かつ となる を現実的な計算資源で求められない。
- (原像困難性) が与えられたとき、 となる を現実的な計算資源で求められない。
いま有限集合 を格納するとき、 を鍵 のもとに保存し、取り出しも で行う方式を内容アドレス格納庫(content-addressable store)という。この方式では、鍵は内容から決まり、内容が変われば必ず鍵も変わる。
Git はこの として長らく SHA-1()を使ってきました。ハッシュ値は 16 進 40 文字で表示されます。「衝突困難」が実際にどれくらいの安全余裕を意味するのか、数で確認しておきます。
命題 2.2(誕生日境界による衝突確率の上界)
の出力が 上の一様乱数として振る舞うと仮定する(ランダムオラクル模型)。相異なる 個の入力 に対し、そのうちのどこかで衝突が起きる確率 は
を満たす。
証明(命題 2.2)
に対し、事象 を「」とします。 であり、仮定より と は 上の独立な一様分布に従うので、
です。求める事象は ですから、和事象の確率が各確率の和以下であること(ブールの不等式)より
を得ます。最後の不等号は によります。
数を入れてみます。Linux カーネルのリポジトリはオブジェクト数がおよそ 個です。安全側に見積もって 、 とすると
となります。偶然の衝突は起こらないと考えてよい水準です。ただしこれは が乱数のように振る舞うという仮定のもとの話で、攻撃者が意図的に衝突を作れるかどうかは別問題です。この点は 注意 2.4 で述べます。
例 2.3(Git のオブジェクト名を手で計算する)
Git はファイル内容 をそのままハッシュするのではなく、種別と長さを前置したバイト列
をハッシュします( は連結、 は のバイト数の 10 進表記、NUL は 1 バイトの 0x00)。内容が test content と改行 1 文字、すなわち 13 バイトの場合を計算します。
import hashlib
content = b"test content\n" # 13 バイトstore = b"blob " + str(len(content)).encode() + b"\x00" + contentprint(store) # b'blob 13\x00test content\n'print(hashlib.sha1(store).hexdigest())# => d670460b4b4aece5915caf5c68d12f560a9fe3e4同じ値が Git 本体からも得られます。
$ echo 'test content' | git hash-object --stdind670460b4b4aece5915caf5c68d12f560a9fe3e4-w を付けると実際に保存され、.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4 というパスにファイルができます。先頭 2 文字をディレクトリ名にするのは、1 ディレクトリ内のエントリ数を抑えるためです。保存時に内容は zlib で圧縮されますが、ハッシュを取るのは圧縮前のバイト列です。だから圧縮方式が変わってもオブジェクト名は変わりません。
同じ規則で、空のディレクトリを表す木オブジェクトの名前も計算できます。中身が 0 バイトなので であり、hashlib.sha1(b"tree 0\x00").hexdigest() は 4b825dc642cb6eb9a060e54bf8d69288fbee4904 です。この値はどのリポジトリでも同じで、「空の木」を指す定数としてスクリプトでよく使われます。
注意 2.4(SHA-1 の衝突と Git の対応)
2017 年、Stevens らが SHA-1 の完全な衝突(同じ SHA-1 値を持つ相異なる 2 つの PDF)を実際に構成しました。これは 命題 2.2 の仮定が破れた例です。Git はこれに対して二段構えで対応しています。第一に、2017 年以降の Git は衝突検出機能つきの SHA-1 実装(sha1collisiondetection)を既定で使い、既知の攻撃に特徴的な計算パターンを検出したらエラーで停止します。第二に、SHA-256 を使うリポジトリ形式が用意されており、git init --object-format=sha256 で作成できます(記事執筆時点で実験的な位置づけです)。 なら での上界は になります。
3. Git のデータモデル:4 種のオブジェクトと Merkle DAG
Section titled “3. Git のデータモデル:4 種のオブジェクトと Merkle DAG”定義 3.1(Git オブジェクト)
Git の格納庫に入るオブジェクトは次の 4 種類である。いずれも <種別> <バイト数> に NUL を続けたヘッダと本体を連結したバイト列 を持ち、その名前(オブジェクト ID)は で定まる。オブジェクトは一度作られたら変更されない。
| 種別 | 本体の内容 | 何を表すか |
|---|---|---|
| blob | ファイルの中身そのもの(ファイル名は持たない) | ファイル 1 個分の内容 |
| tree | <モード> <名前> に NUL と 20 バイトのオブジェクト ID を続けた項目の列(名前順) | ディレクトリ 1 個分の構造 |
| commit | 木の ID を 1 個、親コミットの ID を 0 個以上、作者・コミッター・日時、空行、コミットメッセージ | ある時点のプロジェクト全体の状態 |
| tag | 対象オブジェクトの ID、種別、タグ名、タグ付け者、メッセージ | 注釈つきタグ(署名を付けられる) |
tree の項目のモードは、通常ファイルが 100644、実行可能ファイルが 100755、サブディレクトリが 40000、シンボリックリンクが 120000 である。
重要なのは、tree が blob の ID を含み、commit が tree と親 commit の ID を含むという入れ子構造です。子の名前が親の本体に埋め込まれているので、子が 1 バイトでも変われば親の名前も変わります。この構造を Merkle DAG と呼びます。
flowchart LR H["HEAD"] --> R["refs/heads/main"] R --> C2["commit C2"] C2 -->|parent| C1["commit C1"] C2 -->|tree| T2["tree T2"] C1 -->|tree| T1["tree T1"] T2 --> B1["blob B1 : README.md"] T2 --> B2["blob B2 : main.py(更新後)"] T1 --> B1 T1 --> B3["blob B3 : main.py(更新前)"]
定義 3.2(参照・ブランチ・HEAD)
参照(ref)とは、コミットの ID を値として持つ名前である。.git/refs/heads/<名前> に置かれる参照をブランチ、.git/refs/tags/<名前> に置かれる参照をタグという。.git/HEAD は特別な参照で、通常はブランチ名への間接参照(ref: refs/heads/main という 1 行)を保持し、これを現在のブランチという。HEAD が直接コミット ID を保持している状態を detached HEAD という。
コミット を作る操作は、 をオブジェクト格納庫に書き込み、現在のブランチの参照の値を の ID に書き換えることからなる。オブジェクトは不変だが、参照は可変である。
Git で可変なものは参照とインデックス(後述)だけで、あとはすべて不変オブジェクトです。この分離が Git の性質のほとんどを説明します。ブランチの作成が瞬時に終わるのは、41 バイトのファイルを 1 つ書くだけだからです。
定理 3.3(ハッシュによる履歴全体の同定)
オブジェクトの有限集合 と を考える(例えば手元のクローンと相手のリポジトリ)。各オブジェクト はその直列化 と同一視され、 は が参照する子オブジェクトの ID をすべて含むとする。 とおき、 は 上で単射である(この集合の中に衝突が存在しない)と仮定する。 から到達可能なオブジェクト全体を と書く。
このとき、、 に対して
が成り立つ。すなわち、オブジェクト ID が一致すれば、そこから辿れるオブジェクトの集合とその中身は完全に一致する。
証明(定理 3.3)
まず、参照関係が有向非巡回であることを確かめます。オブジェクト を作るには を確定させる必要があり、そのためには が参照する子の ID がすでに決まっていなければなりません。したがって、オブジェクトの生成時刻の順序に沿って親は必ず子より後に作られ、参照の有向路を辿ると生成時刻が真に減少します。もし閉路があれば時刻が真に減少して元に戻ることになり矛盾です。よってグラフは有限 DAG であり、各頂点 から出る有向路の長さの最大値 (有限)が定義できます。
に関する数学的帰納法で示します。
の場合。 は子を持たないので、 と の単射性から 、すなわちバイト列として です。よって となります。
で、 が 未満のすべての場合に主張が成り立つとする。 と の単射性より です。 はヘッダに種別とバイト数を含むので、 と は同じ種別・同じバイト列であり、特に本体に現れる子オブジェクトの ID の並びが一致します。その並びを とし、 の子を 、 の子を とすると、 です。子は の後続なので であり、帰納法の仮定が使えて を得ます。したがって
となります( は から従います)。以上で帰納法が完成します。
この定理が実務で意味することは大きいので、言い換えておきます。コミット ID を 1 つ、信頼できる経路(署名つきタグ、口頭、別のチャネル)で受け取れば、そのコミットに至る全履歴・全ファイルが 1 バイトも改竄されていないことを検証できます。攻撃者が過去のコミットのファイルを 1 文字書き換えれば、その blob の ID が変わり、tree の ID が変わり、そのコミットの ID が変わり、以後のすべての子孫コミットの ID が変わるからです。git fsck はこの検証を全オブジェクトについて実行するコマンドです。
例 3.4(スナップショットなのに容量が爆発しない理由)
「コミットはプロジェクト全体のスナップショット」と聞くと、1000 ファイルのプロジェクトで 100 回コミットしたら 10 万個の blob ができるように思えます。実際にはそうなりません。定義 2.1 の内容アドレス方式では、内容が同じなら ID が同じだからです。1 ファイルだけ変えてコミットすると、新しくできるのは変更されたファイルの blob 1 個、そのファイルを含むディレクトリの木、その先祖ディレクトリの木、そして commit 1 個だけです。深さ の位置にある 1 ファイルを変更したときに増えるオブジェクトは 個で、ファイル総数には依存しません。定義 3.1 の後に示したオブジェクトグラフの図でいえば、README.md の blob B1 が 2 つの木から共有されている状況です。
さらに Git は、緩いオブジェクトがたまると git gc でそれらをパックファイルに詰め直し、内容が似たオブジェクト同士を差分(デルタ)圧縮します。ここで重要なのは、この差分はあくまで格納形式の最適化であって、履歴の意味を担っていないことです。デルタの相手はコミットの親とは限らず、単に内容が似ているものが選ばれます。RCS や Subversion では差分が履歴そのものでしたが、Git では履歴は commit の親ポインタが担い、差分は圧縮の都合にすぎません。この分離のおかげで、git log の表示や git diff の計算は、格納形式と独立に定義できます。なお、「内容が同じものは 1 つだけ持ち、複数の版から共有する」という発想は Git 固有のものではなく、コンテナイメージのレイヤ(定義 4.1[Docker と Kubernetes])も同じ仕組みで容量と転送量を抑えています。
注意 3.5(インデックス(ステージ領域))
.git/index は、次のコミットで作られる木の下書きを保持するバイナリファイルです。作業ツリーのファイルパスと、対応する blob の ID、モード、更新時刻などが並んでいます。git add は作業ツリーのファイルを読んで blob を書き込み、インデックスの該当行を更新します。git commit はインデックスから木オブジェクトを構築し、それを指すコミットを作ります。「作業ツリー・インデックス・HEAD」という 3 つの状態があることが Git の学習を難しくしていますが、逆にいえば、コミットする内容を作業ツリーとは独立に組み立てられるということです。git add -p で 1 つのファイルの一部の変更だけをステージできるのはこの構造のおかげです。
4. コミットグラフ:祖先関係とマージベース
Section titled “4. コミットグラフ:祖先関係とマージベース”以降、オブジェクトのうち commit だけを取り出したグラフを考えます。
定義 4.1(コミットグラフと祖先関係)
リポジトリのコミット全体を頂点集合 、各コミットからその親コミットへの辺の全体を とする有向グラフ をコミットグラフという。 は有限 DAG である(定理 3.3 の証明冒頭と同じ議論による)。
に対し、 から への有向路(長さ を含む)が存在するとき と書き、 は の祖先であるという。 かつ のときは と書く。
かつ を満たす を の共通祖先といい、その全体を と書く。 の に関する極大元を のマージベースという。
証明(補題 4.2)
反射律。 任意の について、 から への長さ の路が存在するので です。
推移律。 かつ とします。定義より から への有向路 と、 から への有向路 があります。 の終点と の始点はともに なので連結でき、 から への有向路が得られます。よって です。
反対称律。 かつ で と仮定します。 から への路と から への路を連結すると、 から への路が得られます。 より少なくとも一方の路の長さは 以上なので、この路は長さ 以上の閉路です。これは が DAG であること(定義 4.1)に矛盾します。よって です。
命題 4.3(マージベースの存在)
有限コミットグラフ において、 が共通祖先を少なくとも 1 つ持つ、すなわち と仮定する。このとき は に関する極大元を少なくとも 1 つ持つ。特に、リポジトリのすべてのコミットが唯一の根コミット (親を持たないコミット)の子孫であるならば、任意の についてマージベースが存在する。
証明(命題 4.3)
は有限集合で、仮定より空でありません。補題 4.2 より は 上でも半順序です。
極大元を構成します。 を任意に取ります。 が極大でなければ、 を満たす が存在します。同様に が極大でなければ なる が取れ、これを繰り返して列 を作ります。推移律よりすべての で であり、反対称律より です(もし なら となり、 の反対称律に反します)。したがってこの列の項はすべて相異なり、 が有限であることから列は有限回で止まります。止まった項が極大元です。
後半について。 がすべてのコミットの祖先であれば なので、 が保証され、前半が適用できます。
git merge-base --all A B を実行すると、Git が計算したマージベースをすべて表示できます。通常のブランチ運用では 1 つしか出ませんが、後で見るようにそうならない履歴も作れます。
5. マージ:3-way マージ、fast-forward、criss-cross
Section titled “5. マージ:3-way マージ、fast-forward、criss-cross”マージベースが求まると、Git は「ベース」「自分の版」「相手の版」という 3 つの状態を比較します。これを 3-way マージといいます。まず抽象化して定義します。
定義 5.1(3-way マージ)
有限集合 (行やファイルパスなどの「位置」の集合)と集合 (その位置に入りうる値の集合)を固定し、写像 を版と呼ぶ。版 (ベース)と版 に対し、各 で
とおく。すべての で が成り立つとき、 と は を基準として衝突しないといい、マージ結果 を
で定める。ある で となるとき、位置 で衝突が起きたという。
この定義は「両者の版のうち、ベースから変わっているものを採用する。変わっているものが 2 通りあれば人間に判断を委ねる」という規則をそのまま書いたものです。 を集合として定義したので、場合分けの重複を気にせずに済み、可換性なども見やすくなります。
命題 5.2(3-way マージの基本性質)
定義 5.1 の記法のもとで、任意の版 について次が成り立つ。
- (衝突の特徴づけ)位置 で衝突が起きるための必要十分条件は、 かつ かつ が成り立つことである。
- (可換性) が を基準として衝突しないならば も衝突せず、 である。
- (ベースは単位元) と は を基準として決して衝突せず、 である。
- (冪等性) と は を基準として決して衝突せず、 である。
証明(命題 5.2)
1. とは、集合 から を除いた結果が 2 元集合になることです。まず 自体が 2 元集合でなければならないので 、次に除去で要素が減っていないので かつ です。逆にこの 3 条件が成り立てば は 2 元集合です。
2. の定義に現れる集合 は と の入れ替えで不変です。したがって も、 も、 の値も不変です。
3. とすると です。 なら となり、定義より です。 なら となり、 です。いずれの場合も なので衝突は起きず、値は に一致します。すべての でこれが成り立つので です。
4. なので であり、衝突は起きません。 なら で値は 、 なら で値は です。よって です。
性質 3 は見た目より重要です。「一方がベースから何も変えていなければ、マージは他方をそのまま採用し、絶対に衝突しない」ということを言っています。これが fast-forward の理論的な中身です。
命題 5.3(fast-forward の特徴づけ)
コミット (現在の HEAD が指すコミット)と (取り込む相手のコミット)について、 とする。このとき次の 2 つは同値である。
- 、すなわち は の祖先である。
- の極大元がちょうど ただ 1 つである(マージベースが 自身)。
さらにこのとき、ベースを の木、2 つの版を の木と の木として 3-way マージを行うと衝突は起きず、結果は の木に一致する。したがって Git は新しいコミットを作らず、ブランチの参照の値を から に書き換えるだけでよい。この操作を fast-forward という。
証明(命題 5.3)
1 から 2。 補題 4.2 の反射律より であり、仮定より なので です。次に任意の を取ると、共通祖先の定義から です。つまり は の最大元です。最大元は唯一の極大元になります。実際、 を極大元とすると ですが、 なら となって の極大性に反するので です。また 自身は、 なる に対し でもあるので反対称律より となり、極大です。
2 から 1。 極大元は の元なので、 です。共通祖先の定義から が従います。
後半。 木をファイルパスから内容への写像とみなし、 をパスの集合、 を blob の ID(存在しないパスには特別な値を割り当てる)とします。ベースは の木そのもので、2 つの版は の木と の木です。命題 5.2 の性質 3 を、ベースの版を の木、一方の版をそれと同じ の木、他方の版を の木として適用すると、衝突は起きず、結果は の木に一致します。結果の木が の木と同じで、かつ より は の子孫ですから、 と の両方を親に持つ新しいコミットを作る意味がありません。参照を に進めれば十分です。
例 5.4(fast-forward するときとしないとき)
main から feature を切り、feature だけで 2 回コミットした状況を考えます。main が指すコミットを 、feature が指すコミットを とすると です。
$ git switch main$ git merge featureUpdating a1b2c3d..d0e1f2aFast-forward src/auth.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+)Fast-forward と表示され、マージコミットは作られていません。命題 5.3 の状況です。履歴は 1 本の直線になり、feature というブランチが存在したという情報は残りません。
一方、この間に他の人が main に 1 コミット積んでいれば、main の指す は の祖先ではありません。 で極大元は ですから、命題 5.3 の条件 2 が破れています。この場合は 3-way マージが実行され、親を 2 つ持つマージコミットが作られます。
fast-forward できる場合でもマージコミットを作りたいときは git merge --no-ff feature とします。こうするとブランチの範囲が履歴に残り、git log --first-parent で「main に何が取り込まれたか」を機能単位で追えるようになります。逆に、直線的な履歴を保ちたい方針なら git config --global pull.ff only として fast-forward できないときは失敗させる、という運用もあります。どちらを選ぶかはチームの方針の問題です。
ここまでは、マージベースが 1 つに定まることを暗に前提にしていました。しかしこれは一般には成り立ちません。
flowchart RL C["C(A と B のマージ)"] --> A["A"] C --> B["B"] D["D(A と B のマージ)"] --> A D --> B A --> R["R(根コミット)"] B --> R
命題 5.5(マージ結果はマージベースの選び方に依存する)
マージベースが 2 つ以上存在するコミットグラフと、各コミットに割り当てられたファイル内容であって、次を満たすものが存在する。片方のマージベースを基準にすると 3-way マージは衝突せず、もう片方のマージベースを基準にすると衝突する。
証明(命題 5.5)
図の criss-cross グラフを用います。まず を計算します。 の祖先は 、 の祖先は なので です。 かつ なので は極大ではありません。 の祖先は で を含まず、 の祖先は で を含まないので、 と は に関して比較不能であり、どちらも極大です。よってマージベースは と の 2 つです。
次に内容を割り当てます。位置の集合を (1 行だけのファイル)、値の集合を とし、各コミットの版を次のように定めます。
| コミット | 版の値 | 説明 |
|---|---|---|
| 出発点 | ||
| この行を から に変更した | ||
| この行は触らず、別のファイルだけ変更した | ||
| と のマージ結果をそのまま採用した | ||
| と をマージした後、この行を に変更した |
と が整合的であることを確認します。 と をベース でマージすると、定義 5.1 より なので衝突せず、結果は です。 はこれをそのまま採用したので値 、 はその後この行を編集したので値 で、どちらも実際に作れるコミットです。
いま と をマージします。
ベースに (値 )を選んだ場合。 で、 ですから衝突せず、結果は です。
ベースに (値 )を選んだ場合。 で ですから、命題 5.2 の性質 1 の条件(、、)がすべて成り立ち、衝突します。
以上により、同じ 2 つのコミットのマージが、ベースの選び方によって「衝突なしで結果 」にも「衝突」にもなります。
注意 5.6(Git は複数のマージベースをどう扱うか)
命題 5.5 が示すように、マージベースが複数あるときに「どちらか一方を選ぶ」のは恣意的です。Git の既定のマージ戦略は、代わりにマージベース同士を再帰的にマージして 1 つの仮想ベースを作り、それを基準に 3-way マージを行います。上の例なら、(値 )と (値 )をベース (値 )でマージして仮想ベース(値 )を作り、それを使って と をマージするので、衝突せずに が得られます。
この戦略は長く recursive と呼ばれていましたが、Git 2.34(2021 年)以降は書き直された実装 ort(Ostensibly Recursive’s Twin)が既定になっています。挙動の考え方は同じで、性能とリネーム検出が改善されています。git merge -s resolve を指定すると、複数のマージベースから 1 つを選ぶだけの古い戦略に切り替わり、上の例のような差が観察できます。
命題 5.7(rebase は必ず別のコミットを作る)
コミットオブジェクトの直列化 は、木の ID、親の ID の並び、作者情報、コミッター情報、メッセージから定まるとし、 は考えている範囲の直列化の集合上で単射であるとする(定理 3.3 と同じ仮定)。コミット の親の並びを から に取り替え、他の情報はすべて同じにして作ったコミットを とする。もし であるか、またはある で であるならば、 である。
証明(命題 5.7)
仮定より、 と は親の ID を記述する部分で異なります。親の ID はコミットオブジェクトの本体に parent <ID> の行として、順序を保って現れるので、 なら行数が違い、 なら 番目の行の内容が違います。いずれにせよバイト列として です。
ここで対偶を考えます。もし 、すなわち ならば、 の単射性より となり、いま示したことに矛盾します。よって です。
git rebase は、あるコミット列の各コミットについて、変更内容(親との差分)を新しい土台の上に適用し直して新しいコミットを作る操作です。命題 5.7 より、rebase 後のコミットは元のコミットとは別の ID を持つ別のオブジェクトです。元のコミットは消えるのではなく、どの参照からも到達できなくなるだけです(例 6.1 の後の Appendix を参照してください)。
6. 実践:コマンドと共同開発フロー
Section titled “6. 実践:コマンドと共同開発フロー”ここまでのモデルに、日常のコマンドを対応させます。
6.1. .git の中身
Section titled “6.1. .git の中身”.git/├── HEAD 現在のブランチへの間接参照(例: ref: refs/heads/main)├── config このリポジトリの設定(リモート URL など)├── index ステージ領域。次のコミットで作る木の下書き├── objects/ オブジェクト格納庫│ ├── d6/70460b... 緩いオブジェクト(zlib 圧縮された 1 個)│ └── pack/ パックファイル(多数のオブジェクトを差分圧縮してまとめたもの)├── refs/│ ├── heads/main ブランチ。中身はコミット ID 1 行│ ├── tags/v1.0 タグ│ └── remotes/origin/main リモート追跡ブランチ└── logs/ 参照が動いた履歴(reflog)中身は git cat-file で覗けます。
$ git cat-file -t HEAD # 種別を見るcommit$ git cat-file -p HEAD # 中身を整形して見るtree 9f8e7d6c5b4a39281706f5e4d3c2b1a098765432parent a1b2c3d4e5f60718293a4b5c6d7e8f9012345678author Hanako Yamada <hanako@example.com> 1755907200 +0900committer Hanako Yamada <hanako@example.com> 1755907200 +0900
ログイン処理にレート制限を追加定義 3.1 の表そのままの構造が見えます。tree の行を辿れば木が、parent の行を辿れば履歴が展開できます。
6.2. 主要コマンドとオブジェクト・参照への作用
Section titled “6.2. 主要コマンドとオブジェクト・参照への作用”| コマンド | 利用者から見た意味 | オブジェクトと参照への作用 |
|---|---|---|
git add <path> | 変更をステージする | 内容の blob を書き、index の該当項目を更新する |
git commit -m "..." | ステージした内容を記録する | index から tree を構築し、HEAD を親とする commit を書き、現在のブランチ参照を進める |
git switch -c <name> | ブランチを作って移動する | refs/heads/<name> を現在のコミット ID で作り、HEAD をそこへ向ける |
git merge <branch> | 相手のブランチを取り込む | マージベースを求め、fast-forward か 3-way マージを行う |
git rebase <base> | 自分のコミットを土台に載せ替える | 新しい親を持つ commit を作り直し、ブランチ参照をその先頭に向ける |
git fetch <remote> | リモートの履歴を取得する | 不足オブジェクトを取り込み、refs/remotes/... を更新する。作業ツリーと自分のブランチは変えない |
git pull | 取得して取り込む | git fetch の後に git merge(設定によっては git rebase)を実行する |
git push <remote> <branch> | 自分の履歴を送る | オブジェクトを送り、リモートの参照を進める。fast-forward でない更新は既定で拒否される |
git fetch が作業ツリーを一切変えないことは覚えておく価値があります。「まず git fetch して git log --oneline HEAD..origin/main で相手の変更を確認し、それから取り込む」という手順は、常に安全です。
6.3. プルリクエストに基づく共同開発
Section titled “6.3. プルリクエストに基づく共同開発”GitHub や GitLab で標準になっている流れを、Git の操作として書き下します。ここでは、書き込み権限を持つメンバーが同じリポジトリにブランチを作る方式(外部の貢献者の場合は最初にフォークを作る点だけが異なります)を示します。
- 最新化する。
git switch mainのあとgit pull。mainを最新のリモートに合わせます。 - 作業ブランチを作る。
git switch -c feature/rate-limit。ブランチ名は「何をするか」がわかる語にします。 - 小さくコミットする。
git add -pで意味のある単位に分け、git commitします。1 コミットは「後で単独で取り消せる単位」が目安です。 - 公開する。
git push -u origin feature/rate-limit。-uは以後git pushだけで済むよう上流を設定するオプションです。 - プルリクエスト(PR)を開く。 何を・なぜ変えたかを本文に書きます。差分を読むだけではわからないのは常に「なぜ」です。
- 自動検査を通す。 CI がテスト・静的解析・ビルドを実行します。CI の実行環境をコンテナ(定義 3.1[Docker と Kubernetes])で固定する方法は 仮想化技術(Docker・Kubernetes) で扱います。
- レビューを受けて修正する。 追加コミットを積んで
git pushします。PR は自動で更新されます。 mainの変更を取り込む。 レビュー中にmainが進んだら、git fetchのあとgit merge origin/main(または方針によりgit rebase origin/main)を作業ブランチ上で実行します。衝突はここで解消しておきます。- 統合する。 PR をマージします。GitHub には 3 つの方式があります。マージコミットを作る方式(履歴の形をそのまま残す)、squash して 1 コミットにする方式(
mainを読みやすく保つ)、rebase して並べる方式(直線的な履歴になる)です。 - 後片付け。 作業ブランチを削除し、手元で
git switch mainとgit pullを実行します。
例 6.1(衝突を解消する)
手順 8 で衝突した場合の実際の操作を追います。
$ git fetch origin$ git merge origin/mainAuto-merging src/auth.pyCONFLICT (content): Merge conflict in src/auth.pyAutomatic merge failed; fix conflicts and then commit the result.src/auth.py を開くと、Git が印を書き込んでいます。
<<<<<<< HEADMAX_ATTEMPTS = 5=======MAX_ATTEMPTS = 3>>>>>>> origin/main<<<<<<< と ======= の間が自分の版、======= と >>>>>>> の間が相手の版です。命題 5.2 の性質 1 の状況、つまりベースの値(例えば MAX_ATTEMPTS = 10)から双方が別々に変更した箇所です。3 つの版をすべて見たいときは、
$ git checkout --conflict=diff3 src/auth.pyとすると、ベースの内容も ||||||| で区切って表示されます。どちらが正しいかは、両方の変更の意図を知らなければ決められません。だから Git は自動判断せず、人間に渡すのです。
解消したら、印を消してから次のようにします。
$ git add src/auth.py # 「この内容で解決した」と Git に伝える$ git merge --continue # マージコミットを作る(git commit でも同じ)途中でやめたいときは git merge --abort でマージ前の状態に完全に戻せます。オブジェクトが不変であること(定義 3.2)から、参照を戻すだけで元の状態が復元できるためです。
echo 'hello world' | git hash-object --stdin が出力する値を、例 2.3 の規則から求めてください。echo は末尾に改行を 1 文字付けることに注意してください。計算は Python の hashlib を使ってかまいません。また、なぜこの値が「どのリポジトリでも、いつ実行しても同じ」になるのかを説明してください。
解答
hello world は 11 バイト、改行が 1 バイトなので内容は 12 バイトです。したがってハッシュを取る対象は b"blob 12\x00hello world\n" です。
import hashlibcontent = b"hello world\n"store = b"blob " + str(len(content)).encode() + b"\x00" + contentprint(hashlib.sha1(store).hexdigest())# => 3b18e512dba79e4c8300dd08aeb37f8e728b8dad値が普遍的である理由は、定義 2.1 の内容アドレス方式では鍵が内容だけから決まるからです。blob オブジェクトはファイル名・タイムスタンプ・作者・リポジトリの識別子を一切含みません(定義 3.1 の表で、ファイル名を持つのは tree であって blob ではないことを確認してください)。同じ内容のファイルは、世界中のどのリポジトリでも同じ ID を持ちます。これが git fetch で「相手が持っていないオブジェクトだけ」を効率よく判定できる理由でもあります。
演習 7.2標準
次のコミットグラフを考えます。辺は子から親への向きです。
- の親は
- の親は
- の親は
- の親は と (マージコミット)
- の親は と (マージコミット)
を求め、そのうち極大なもの(マージベース)をすべて挙げてください。また、git switch D の状態で git merge E を実行したとき fast-forward になるかどうかを、命題 5.3 を使って判定してください。
解答
まず祖先集合を求めます。 から親を辿ると と なので、 の祖先は です。 から辿ると と なので、 の祖先は です。共通部分を取って
を得ます。
極大性を調べます。 かつ なので は極大ではありません。 の祖先は で を含まないので は成り立たず、 の祖先は で を含まないので も成り立ちません。つまり と は比較不能で、どちらも極大です。よってマージベースは と の 2 つで、この履歴は 命題 5.5 の criss-cross と同じ形をしています。
fast-forward の判定。命題 5.3 によれば、fast-forward になるのは の極大元が ただ 1 つのときです。いま極大元は と であり、 ではないうえに 2 つあります。よって fast-forward にはならず、3-way マージが実行されて と を親に持つマージコミットが作られます(定義 5.1 の意味で衝突しなければ)。
なお、 が の祖先でも子孫でもないことは直接にも確認できます。 の祖先集合に は入っておらず、 の祖先集合に は入っていないからです。
共通のベース と 3 つの版 について、 を作ってから を計算する場合と、 を作ってから を計算する場合を比べます。次を示してください。
各位置 について とおくとき、どちらの順序でも、途中と最後を通じて衝突が起きないための必要十分条件は「すべての で 」であり、衝突しない場合の最終結果も両者で一致する。
つまり、ベースを固定する限り 3-way マージは結合的です。それにもかかわらず実際の Git でマージの順序が結果を変えうるのはなぜか、命題 5.5 を踏まえて説明してください。
解答
位置 を固定し、記号を簡単にするため , , , と書きます。定義 5.1 より、 は が空なら 、1 元集合ならその元、2 元集合なら衝突です。これは次のように言い換えられます。
補題。 のとき、 は「 の中で と異なる唯一の値、そのような値がなければ 」であり、いずれにせよ が成り立つ。
実際、 なら で 、 なら で です。
十分性と結果の一致。 すべての で と仮定します。 なので であり、 は衝突せずに定義されます。補題より ですから、
となります。仮定より なので 2 段目も衝突せず、結果は「 の唯一の元、または なら 」です。この表式は について対称ですから、もう一方の順序でも同じ値になります。
必要性。 ある で と仮定し、この順序のどこかで衝突が起きることを示します。 なので、 と異なる相異なる 2 つの値が に現れます。
- が 2 元集合なら、1 段目の が位置 で衝突します。
- そうでなければ なので は定義され、上の計算より です。 かつ なので となり、2 段目の が位置 で衝突します。
いずれの場合も衝突が起きます。同じ議論が の入れ替えでそのまま通るので、もう一方の順序でも衝突します。以上で必要十分性が示せました。
Git で順序が効く理由。 いま示したのは「ベース を固定すれば」という条件付きの結合性です。実際の Git はベースを固定しません。マージのたびに、そのときの 2 つのコミットからマージベースを計算し直します。マージを 1 回行うと新しいマージコミットができ、コミットグラフの形が変わるので、次のマージのマージベースは前回と違うコミットになりえます。命題 5.5 は、ベースが変われば衝突の有無すら変わることを具体的に示しています。したがって「取り込む順番を変えたら衝突した」という現象は、3-way マージの規則そのものの非結合性ではなく、ベース選択が履歴の形に依存することから来ています。
演習 7.4標準
あなたと同僚が同じブランチ feature/x で作業しています。あなたが git rebase main を実行して git push --force しました。同僚の手元では次に git pull したときに何が起きますか。命題 5.7 を使って説明し、--force-with-lease がどのような事故を防ぐのかを述べてください。
解答
rebase は各コミットの親を付け替えます。命題 5.7 より、親の並びが変わったコミットは必ず別の ID を持ちます。したがって rebase 後のリモート feature/x は、同僚が持っているコミットとは別のコミットの列を指しています。
同僚が既定設定で git pull すると、git fetch に続いて git merge origin/feature/x が走ります。同僚のローカル feature/x が指す旧コミット と、リモートの新コミット について、 は成り立ちません( の祖先には が現れないからです)。よって 命題 5.3 より fast-forward にはならず、3-way マージが実行されます。その結果、同じ変更を表す 2 系統のコミットが両方とも履歴に残ります。同じ行への同じ変更が 2 度現れるため、内容によっては大量の衝突が出るか、衝突せずに変更が二重適用されて壊れます。ここで同僚がそのまま push すると、あなたが消したはずの旧コミットがリモートに戻ってきます。
正しい対処は、同僚が git fetch のあと自分の未 push のコミットだけを新しい土台に載せ直すこと、例えば git rebase --onto origin/feature/x <旧の分岐点> feature/x を実行することです。ただし、そもそもこの手間を発生させないために、共有ブランチでは rebase しないのが原則です。
--force-with-lease が防ぐのは別の事故です。素の --force は、リモートの現在の値を一切確認せずに参照を上書きします。あなたが git fetch してから push するまでの間に同僚が新しいコミットを push していた場合、そのコミットはどの参照からも到達できなくなり、実質的に失われます。--force-with-lease は「リモートの現在の値が、自分が最後に取得したときの値と一致していること」を条件に付けた更新です。条件が破れていれば push が失敗するので、知らないうちに他人の作業を消す事故を防げます。これは楽観的並行制御における条件付き更新(compare-and-swap)と同じ仕組みです。
- Scott Chacon, Ben Straub, Pro Git, 2nd ed., Apress, 2014 — 第 3 章「Git Branching」と第 10 章「Git Internals」。全文が https://git-scm.com/book/ja/v2 で公開されており、日本語訳もあります。この記事の §3 の内容は第 10 章に対応します。
- Git 公式リファレンス https://git-scm.com/docs — 特に
git-merge-base、gitrevisions、githooks、およびhash-function-transitionの設計文書。 - Marc J. Rochkind, “The Source Code Control System”, IEEE Transactions on Software Engineering SE-1, no. 4 (1975), 364–370. DOI: 10.1109/TSE.1975.6312866 — SCCS の原論文。バージョン管理という問題設定そのものを定式化しています。
- Walter F. Tichy, “RCS — A System for Version Control”, Software: Practice and Experience 15, no. 7 (1985), 637–654. DOI: 10.1002/spe.4380150703 — 逆差分による履歴保持とロック方式。
- Sanjeev Khanna, Keshav Kunal, Benjamin C. Pierce, “A Formal Investigation of Diff3”, in FSTTCS 2007, Lecture Notes in Computer Science 4855, Springer, 2007, 485–496. DOI: 10.1007/978-3-540-77050-3_40 — 3-way マージ(diff3)の代数的性質を扱った論文。§5 の形式化はこの系統の議論を簡略化したものです。
- Marc Stevens, Elie Bursztein, Pierre Karpman, Ange Albertini, Yarik Markov, “The First Collision for Full SHA-1”, in CRYPTO 2017, Lecture Notes in Computer Science 10401, Springer, 2017, 570–596. DOI: 10.1007/978-3-319-63688-7_19 — 注意 2.4 で触れた衝突の構成。
- Eugene W. Myers, “An O(ND) Difference Algorithm and Its Variations”, Algorithmica 1 (1986), 251–266. DOI: 10.1007/BF01840446 —
git diffが既定で使う差分アルゴリズム。3-way マージの前段にあたる「位置の対応付け」を与えます。
Appendix: 失った作業を取り戻す
Section titled “Appendix: 失った作業を取り戻す”参照は動くが、オブジェクトは消えない。 git reset --hard でコミットを捨てたつもりでも、git rebase で古いコミット列を置き換えたつもりでも、定義 3.2 のとおり動いたのは参照だけで、オブジェクトは格納庫に残っています。残っているものを見つけられれば復旧できます。
まず reflog を見る。 Git は .git/logs/ に、各参照が「いつ・どの値から・どの値へ」動いたかを記録しています。これを reflog といいます。
$ git reflogd0e1f2a HEAD@{0}: reset: moving to HEAD~39c8b7a6 HEAD@{1}: commit: 認証エラーのログを追加5f4e3d2 HEAD@{2}: commit: レート制限を実装HEAD@{1} のような表記で過去の位置を参照できます。捨てたコミットに戻るには git reset --hard HEAD@{1}、そこから枝を生やすには git switch -c rescue HEAD@{1} とします。reflog はローカルの記録なので、git clone した相手には引き継がれません。
reflog にもなければ fsck を使う。 どの参照からも reflog からも辿れないオブジェクトは、git fsck --lost-found で列挙できます。出力の dangling commit <ID> が孤立したコミットです。git show <ID> で内容を確認し、必要なら git switch -c rescue <ID> で救出します。
ただし無期限ではない。 git gc は、到達不能なオブジェクトを一定期間後に本当に削除します。既定では、到達可能な参照の reflog は約 90 日、到達不能なものの reflog は約 30 日で期限切れになります(gc.reflogExpire と gc.reflogExpireUnreachable で設定できます)。事故に気づいたら早めに探してください。そして、重要な作業は早めに push してください。分散型 VCS における最良のバックアップは、別のクローンが存在することです。リモートの可用性そのものについては クラウドコンピューティング(AWS, GCP)(可用性の定義は 定義 6.1[クラウドコンピューティング]、複製を増やすと可用性が上がることの計算は 命題 6.2[クラウドコンピューティング])、git push が実際に何を喋っているかについては ネットワーク(TCP/IP)(定義 2.1[ネットワーク(TCP/IP)])を参照してください。
この記事の誤りを報告する ・運営: 夢現技研合同会社 ・料金プラン ・利用条件 ・特定商取引法に基づく表記
© 2026 夢現技研合同会社 ・本文の LLM への入力は自由です。コード例は MIT ライセンスです。