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

以上!実用性はまずないと思うが、面白くはあるかもしれない!

Fix typo Subscribe All posts%20Get%20in%20touch matklad

出典: matklad← ホームへ戻る