Git 参照について
Git refとは何か、.git/refs配下のファイルとpacked-refsファイルがどう関係するのか、そしてなぜ「refとはブランチである」というメンタルモデルがエッジケースで混乱を招き続けるのか。
日本語
コピー
最近、Git に対する自分のメンタルモデルを更新した。次の2つの git コマンドを見てほしい:
$ git fetch origin master
$ git switch -c my-feature origin/master
なぜ一方には origin master があり、もう一方には origin/master があるのか分かるだろうか?数週間前の自分には分からなかった。
以前の理解はこうだった:git は内容アドレス指定型のデータベースである。Git は commit を保存し、commit はその内容のハッシュで識別される。commit の内容は主に次のものだ:
- ある時点でのコードベースの状態を記憶しないスナップショット
- 親 commit の(ハッシュの)リスト
自分にとって、この程度の git の知識があれば git log の出力を読むには十分だったし、rebase をどんなに壊してもリポジトリを clone し直さずに済ませられた(キャリアの半分くらいは、実際に clone し直していた。恥ずかしいことではない!git を学ぶのは役に立つが、駆け出しの頃に最優先で学ぶべきことではない)。
今なら分かる。git は追記専用の(「不変」な)内容アドレス指定型データベースであるだけでなく、ごく普通の可変なキーバリューストアでもある。
Git には可変のマッピングテーブルがあり、キーは文字列、値は内容アドレス指定型のオブジェクトだ。キーは慣例的にファイルシステムのパスとしてフォーマットされ、通常は .git/refs ディレクトリを一覧すればこのマッピングテーブルの状態を確認できる:
$ eza -T .git/refs
.git/refs
├── heads
│ ├── make
│ ├── master
│ ├── my-feature
│ └── pbd-adt
├── origin
├── remotes
│ └── origin
│ ├── context-switches
│ ├── gh-pages
│ ├── HEAD
│ ├── make
│ └── master
└── tags
$ cat .git/refs/heads/master
b59148228e52f7c615ead7fdd4e91001994ad50f
$ git show-ref refs/heads/master
b59148228e52f7c615ead7fdd4e91001994ad50f refs/heads/master
この refs キーバリュー基盤が紛らわしいのは:
- 互いに異なる多くのユーザー可視の git 機能を支えているが、ref 自体は実装の詳細にすぎない。
- 抽象化がやや漏れており、日常的な使用では ref はほとんど見えない。
- Git CLI は ref に省略記法を使い、さらに大量のデフォルト引数があるため、ある CLI 引数が実は ref であることに気づきにくい。
- そして git はいつも通り、同じものにいくつもの名前を付け、同じ名前を別のものに使うのが好きだ。
ブランチ、タグ、git notes、すべて ref だ!
CLI の各種省略記法を展開すると、構造がずっと明確になる。元のコマンド
$ git fetch origin master
はこうなる
$ git fetch \
https://github.com/matklad/matklad.github.io \
refs/heads/master:refs/remotes/origin/master
fetch の最初の引数(https://...)はリモートリポジトリのアドレスだ。Git はこのアドレスに「電話をかけ」、ネットワーク越しにそのマシンからローカルへデータを転送する。
2番目の引数は文字列キー(ref)からなる source:target のペアだ。source はリモートリポジトリ上のキー、target はローカルキーの名前であり、fetch 全体としては git にリモートリポジトリから値を読ませ、別の名前でローカルに保存させるものだ。
毎回リポジトリの URL を入力しなくて済むよう、git はそれらにシンボリック名を割り当てる。origin は主要なリモートリポジトリの慣用的な名前だ:
$ git fetch origin \
refs/heads/master:refs/remotes/origin/master
refs/heads/master はリモートリポジトリ上のとあるブランチの完全な展開名だ。つまり、ブランチ my-feature は単なる refs/heads/my-feature ref にすぎない。refs/branch/my-feature でもよかったのだが、そうはなっていない :)
正確な省略規則は分からないが、一般に git は ref の接尾辞だけを書くことを許す:
$ git fetch origin \
master:refs/remotes/origin/master
refs/remotes/origin/master は結果を保存するために使うローカルの ref 名だ。ローカルでリモートと同じ名前をそのまま使うのが自然に見えるが、それはリモートリポジトリが1つしかない場合にしか成り立たない。上流リポジトリが2つあると(例えば自分の fork と、fork 元の元リポジトリ)、ref 名が衝突する。だから foo という名前のリモートリポジトリの ref を refs/remotes/foo という名前空間の下にまとめるのだ。そして origin は、単純な設定におけるそのリモートリポジトリの慣用的な名前でしかない。
同様に、リモート ref の構造をそのままローカルにミラーする方が自然だろう:
refs/ heads/my-branch -> refs/ remotes/origin/ heads/my-branch
しかし git は余分な heads 部分を取り除く。そしてこの -heads、+remotes/$remote のマッピングは組み込みなので、コマンドはこう圧縮される
$ git fetch origin master
それがなぜこうなっているかは考える価値がある。Git のモデルはオフラインファーストだ。そして同期点は明示的であると仮定する。ネットワークがあればバックグラウンドでリモートリポジトリと同期するのではなく、ネットワーク上でバイトを転送するために fetch と push の操作を明示的に実行することを要求する。このパラダイムでは、相手が最後に通信した時点の状態をモデル化しておくと役に立つ。心の理論だ!
これで git におけるローカルブランチとリモートブランチの概念の混乱がおそらく解けるはずだ。main ブランチを例に取る。それは origin という名前のリモートリポジトリ上に、refs/heads/main という名前で存在する。ローカルリポジトリを origin と同期すると refs/remotes/origin/main が得られる——これが origin 上の main の状態について、現時点で君が持つ最良の把握だ。
次にローカルの refs/heads/main。これは普通、最初は refs/remotes/origin/main と同じ commit を指す。しかし一度 commit すると、refs/heads/main は進み、refs/remotes/origin/main はその場に留まる。
ローカルの commit を origin に push したいとき、その時点で origin 上の main ブランチが先に進んでいると、衝突する。この場合 git は refs/remotes/origin/main を自動で更新する(もともとリモート状態のローカルミラーにすぎない)が、次は自分で refs/heads/main を更新し、もう一度 push する必要がある。
もう一度完全な例を見てみよう:
$ git fetch origin master
$ git switch -c my-feature origin/master
最初のコマンドは .git/config で origin remote に対応する URL を調べ、そのマシンへネットワークリクエストを送る。結果としてローカルの refs/remotes/origin/master がリモートの refs/heads/master と同じ commit に更新される(その commit とその祖先がまとめてローカルに転送される)。
2番目のコマンドは refs/heads/my-feature ref(つまりブランチ)を refs/remotes/origin/master を起点に作成する。これは抽象化の漏れの一例だ。
2 番目の引数は ref の省略形なので、こう書ける
$ git switch -c my-feature \
refs/remotes/origin/master
しかし 1 番目の引数は ref を作成するものの、それ自体は ref ではない。つまり、こちらも展開しようとすると
$ git switch -c refs/heads/my-feature \
refs/remotes/origin/master
こうなる
refs/heads/refs/heads/my-feature
以上!実用性はまずないと思うが、面白くはあるかもしれない!