About

worktree はシンボリックリンクでもクローンでもない

ファイルはコピー、リポジトリは一つ

worktree を使い始めると一度は引っかかる。これはシンボリックリンクなのか、それとも clone のように丸ごと別物なのか。

どちらでもない。作業ファイルは物理的に別にあり、リポジトリは一つを共有する。

.git がディレクトリではない

追加した worktree の .git を開くと一行のテキストだ。シンボリックリンクでもない。

$ file ../wt/.git
../wt/.git: ASCII text

$ cat ../wt/.git
gitdir: /Users/me/repo/.git/worktrees/wt

git はこの一行を読んでメインリポジトリへ辿る。つなぎはこれだけだ。

何が共有され、何が別なのか

推測する必要はない。worktree の中で git rev-parse --git-path を叩けば git が答える。

$ git rev-parse --git-path HEAD
/Users/me/repo/.git/worktrees/wt/HEAD

$ git rev-parse --git-path index
/Users/me/repo/.git/worktrees/wt/index

$ git rev-parse --git-path refs/heads
/Users/me/repo/.git/refs/heads

$ git rev-parse --git-path objects
/Users/me/repo/.git/objects

HEAD と index は worktree 専用ディレクトリを指し、refs と objects はメインの .git を指す。公式ドキュメントの用語では前者が $GIT_DIR、後者が $GIT_COMMON_DIR だ。例外は refs/bisect と refs/worktree の二つだけで、これは worktree ごとに分かれる。

HEAD と index が別だから、異なるブランチを同時にチェックアウトできる。理由はそれだけだ。

コミットは fetch なしで渡る

refs と objects が一箇所なら当然の結果だ。

# worktree 側
$ git commit -m "sentinel"
[wt/demo c48eba9] sentinel

# メイン側 — fetch していない
$ git log wt/demo --oneline -1
c48eba9 sentinel

clone 二つなら push と fetch を挟む必要がある。日々の作業で効いてくる差はほぼここだ。

ディスクが節約できるという説明は半分だけ正しい

「clone より容量が節約できる」とよく言われるが、相手がローカル clone なら話が変わる。

同じファイルシステム上では git clone は pack ファイルをハードリンクする。コピーではない。

$ stat -f '%l links  %N' repo/.git/objects/pack/*.pack
2 links  .../pack-1eb982d2.pack
2 links  .../clone/.git/.../pack-1eb982d2.pack   # 同じ inode

--no-hardlinks を付けるとリンク数が 1 に落ちる。つまり既定がハードリンクだ。

このリポジトリ(.git が 439MB)でメインと clone の .git を一回の du 呼び出しで測ると、clone 側が余分に使ったのは 1.2MB だった。別々に測ると 365MB に見えるが、それは同じ inode を二度数えている。

作業ファイルは worktree でも clone でもコピーされる。このリポジトリで 431MB。

ローカル clone に対する worktree の利点は容量ではない。refs と objects が一箇所であることだ。

フォルダだけ消すと git が掴んだままになる

一番よく踏む罠だ。rm -rf でディレクトリだけ消すと登録情報が残る。

$ rm -rf ../wt
$ git worktree list
/private/tmp/wt  c48eba9 [wt/demo] prunable

$ git worktree add ../wt2 wt/demo
fatal: 'wt/demo' is already checked out at '/private/tmp/wt'

$ git branch -d wt/demo
error: Cannot delete branch 'wt/demo' checked out at '/private/tmp/wt'

ディレクトリは無いのにブランチを再チェックアウトすることも削除することもできない。git worktree prune 一回で解ける。最初から git worktree remove を使えば起きない。

gitignore 対象は付いてこない

新しい worktree には git が追跡するファイルだけがチェックアウトされる。Rails プロジェクトなら .env、config/master.key、node_modules/、storage/ の SQLite ファイルが丸ごと抜ける。新しい worktree でサーバーが起動しないときは大抵これだ。

計測環境は git 2.39.5 (Apple Git-154)、macOS。

動作フロー

1

`git worktree add ../wt -b feature` — 追跡中のファイルがチェックアウトされ、そこに `.git` テキストファイルが作られる

2

`.git` の中の `gitdir:` 一行がメインリポジトリの `.git/worktrees/<名前>/` を指す

3

HEAD と index はそのディレクトリから(`$GIT_DIR`)、refs と objects はメインの `.git` から読む(`$GIT_COMMON_DIR`)

4

コミットするとオブジェクトと ref がメインリポジトリに直接積まれ、反対側から fetch なしで見える

5

`git worktree remove ../wt` — ファイルと登録情報を一緒に消す。フォルダだけ消すと `prune` が別途必要になる

メリット

  • ✓ refs と objects が一箇所なのでコミットが fetch なしで渡る。fetch も一回で全部に反映される
  • ✓ 同じブランチを二箇所でチェックアウトしようとすると git が止める。うっかり分岐しない

デメリット

  • ✗ gitignore 対象は付いてこない。`.env`、master.key、node_modules を毎回別に用意する必要がある
  • ✗ フォルダだけ消すと登録情報が残り、ブランチの削除も再利用もできなくなる
  • ✗ 作業ファイルはコピーなのでディスクはその分増える。ローカル clone に対する容量の利点はない

ユースケース

長いビルドやテストが走っている間に別のブランチを触りたいとき AI エージェントをセッションごとに隔離して並列で走らせるとき レビュー用のチェックアウトを自分の作業と分けたいとき
← Portlessとは?localhostのポート番号をmyapp.localhostの名前に置き換える仕組み Postgresのボリュームパスが3年間間違っていたのに動いていた理由 — VOLUME宣言がこっそり付けた名前のないボリューム →