m3tech.blog
git worktree大増殖で困っていた
Gitのworktreeサブコマンドは、AIエージェント開発が普及してから急によく知られるようになった感があります。実際にこれが登場したのはもう10年前、2016年のことだそうですね。私自身は業務の性質上、複数の依頼案件を並列で進めることが多くてAIエージェントが出てくる前からworktreeみたいなことをしていました。同じリポジトリを何個もローカルに別々にcloneしてあっちへ行ったりこっちへ来たりと作業していたんです。それで昨今の流行でworktreeサブコマンドの存在を知ったときには悔しいのなんの。
とはいえ、worktreeを使って(AIエージェントも使って)超多並列で開発業務をやっているとやはり問題になってくるのがworktreeディレクトリが溢れかえってしまう問題です。ディスク残量を圧迫される、とまでなる方は少ないと思いますが、脳のコンテキストが圧迫されます。どのディレクトリがなんだっけ? 我々の脳のコンテキストサイズはそう大きくはありません。こんにちは、エムスリーエンジニアリンググループUnit9(エビデンス創出プロダクトチーム)の三浦[記事一覧 ]です。データ基盤チーム & Unit9ブログリレー5日目の記事になります。前回は木田さんのKestraとAmazon EKSのキャパシティプランニング - JVMに思いを馳せながらでした。
## 終わったworktreeを「整理」するツール
整理整頓という言葉の整理と整頓は別の意味だそうですね。きれいに並べ直すのが整頓、いらないものを分別して捨てるのが整理なのだとか*1。多すぎるworktreeにも同じことがいえます。どのworktreeがなんだか分かりやすくするアプローチと、いらなくなったworktreeを削除していくアプローチとがあるってことです。
複数のAIエージェントセッションをわかり易く表示してくれるブラウザなど今いろいろ出ていますので「整頓」はそちらに任せて、いらなくなったworktreeの削除の方をやっていきましょう。やるべきことは「いらない」の条件を決めて、その通り機械的に削除してくれるツールを作ることです。それでは「いらない」の条件。自分が作業未完了のリポジトリがどうなっているかを思い起こしてみて⋯
* すでにmain(master)ブランチにマージ済みである(squash merge派の皆様すみません)
* stage済み・未コミット変更がない
* そのディレクトリ上で動いているClaude Codeセッションがない
* mergeやrebaseの作業中ってのもダメですよね
こんな感じの条件でいきましょう。ちなみに、rebase作業中でないというのは、簡単にはdetached head状態でない(ブランチをチェックアウトした状態である)ことという条件でだいたいカバーでき、merge作業中でないことは未コミット変更がないことでカバーできます。さらに言うと、ディレクトリが既に削除済みのゾンビworktree(prunableと言います)というのもあり得て、このゾンビを片付けるには`git worktree remove`ではなく`git worktree prune`という別のコマンドを使います。ディレクトリが消えてしまっていて作業が残っていない以上、このゾンビも片付け対象でしょう。
(こちらのフローチャートはデータ基盤チームの石塚さん作のツールで作成してもらいました。このブログリレーで石塚さんも投稿しているのでお楽しみください。AIの分析が返す"それっぽい数字"をどう防ぐか——dbt × Lightdash × MCPでガードレールを作る)
次に、存在するワークツリーの列挙し方です。マシン内の全ディレクトリを操作していては大変なので、まずあなたがリポジトリをクローンしてくる親ディレクトリというのを決め打ちしてその直下で探すことにしましょう。私はいつも `~/projects` がその親ディレクトリです。そしてそこにあるリポジトリそれぞれで `git worktree list` して出てくるディレクトリも対象に含めることにします。AIエージェントは `.claude/` の下とかにworktreeディレクトリを作ってきますからね。
親ディレクトリ(私のマシンでいう `~/projects` のことですね)直下を走査するとき、通常のリポジトリとworktreeディレクトリを簡単に見分ける方法ってご存知です? 実は本当に簡単で、通常リポジトリなら `.git` がディレクトリ、worktreeなら `.git` がファイルだというところで見分けられます。リポジトリを見て、そのメインディレクトリを一発で確実に知る方法はわかります? `.git`がファイルの場合にそれを読めばわかるというのもありますが、`git worktree list`したときにメインディレクトリは必ず先頭に出てくるという性質も利用できます。
## ツールを作ってみましょう
さあここまでやることが固まったら、今だったらこの要望をAIエージェントに伝えるだけで一瞬でツールを作ってくれますね。とはいえ読者の誰もが使いたいときにAIエージェントを使える環境とも限りませんので、私がすでに常用しているスクリプトを共有いたします。
git-worktree-gc コマンド (Click to open)
#!/bin/bash
set -euo pipefail
PROJECTS_DIR="${HOME}/projects"
usage() {
cat <<EOF
Usage: $(basename "$0") [-n|--dry-run]
git worktree のガベージコレクション。
~/projects 直下のリポジトリに紐づく全worktree(本体配下に作られたものを含む)について:
- ディレクトリが消失済みなら git worktree prune する
- 以下の条件をすべて満たすなら削除する
- ステージ済みまたは未コミットの変更がない(未追跡ファイルは無視)
- cwd がそのworktree配下にある claude セッションが起動していない
- detached HEAD ではない
- カレントブランチが origin/main または origin/master にマージ済み
Options:
-n, --dry-run 削除せず対象を表示するだけ
-h, --help このヘルプを表示
EOF
exit 1
}
main() {
DRY_RUN=false
parse_args "$@"
if [[ "$DRY_RUN" == true ]]; then echo "[DRY RUN]"; fi
local parent worktree
# システムに存在するすべてのワークツリーを取得し
list_worktrees | \
while IFS= read -r -d '' parent && IFS= read -r -d '' worktree; do # 各ワークツリーにつき
if is_missing_directory "$worktree"; then # ディレクトリが不存在なら
prune_worktree "$parent" "$worktree" # prune
elif should_remove "$worktree"; then # 削除妥当なら
remove_worktree "$parent" "$worktree" # 削除
fi
done
}
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
-n|--dry-run) DRY_RUN=true; shift ;;
-h|--help) usage ;;
*) echo "Unknown option: $1"; usage ;;
esac
done
}
display_name() {
echo "${1/#$HOME/~}"
}
# projects/ 直下のリポジトリ(本体・リンク済みworktree問わず)から本体のパスを重複なしで列挙する
list_main_repos() {
local dir common_dir
for dir in "${PROJECTS_DIR}"/*/; do
dir="${dir%/}"
[[ -e "$dir/.git" ]] || continue
common_dir=$(git -C "$dir" rev-parse --path-format=absolute --git-common-dir 2>/dev/null) || continue
dirname "$common_dir"
done | sort -u
}
# Claude が本体配下(例: .claude/worktrees/)に作る worktree を拾うため、
# ディレクトリ走査ではなく git の登録情報(git worktree list)を使う
# 出力は「親リポジトリ\0worktree\0」の繰り返し
list_worktrees() {
list_main_repos | while IFS= read -r repo; do
# 先頭エントリは本体worktreeなので除外する
git -C "$repo" worktree list --porcelain 2>/dev/null | sed -n 's/^worktree //p' | tail -n +2 |
while IFS= read -r wt; do
printf '%s\0%s\0' "$repo" "$wt"
done
done
}
is_missing_directory() {
[[ ! -d "$1" ]]
}
# 以下の has_* / is_* は「削除を見送る理由」の判定。該当すれば理由ラベルを出力して 0 を返す
# ステージ済み・未コミット変更があるか(?? = 未追跡は除外)
has_uncommitted_changes() {
local uncommitted
uncommitted=$(git -C "$1" status --porcelain 2>/dev/null | grep -v '^??' || true)
[[ -n "$uncommitted" ]] && echo "uncommitted"
}
# cwd がこのworktree配下にある claude プロセスがあるか
has_running_claude_session() {
lsof -a -d cwd -c claude -Fn 2>/dev/null | sed -n 's/^n//p' |
awk -v d="$1" 'index($0, d) == 1 && (length($0) == length(d) || substr($0, length(d) + 1, 1) == "/") { found = 1 } END { exit !found }' &&
echo "claude running"
}
is_detached_head() {
! git -C "$1" symbolic-ref -q HEAD &>/dev/null && echo "detached HEAD"
}
# fetch を含むため、安価な判定を先に済ませてから最後に呼ぶ
is_unmerged() {
git -C "$1" fetch origin --quiet
! find_merged_upstream "$1" >/dev/null && echo "not merged"
}
# origin/main または origin/master にマージ済みならその ref を出力する
find_merged_upstream() {
local dir="$1" upstream
for upstream in origin/main origin/master; do
if git -C "$dir" rev-parse --verify "$upstream" &>/dev/null &&
git -C "$dir" merge-base --is-ancestor HEAD "$upstream" 2>/dev/null; then
echo "$upstream"
return 0
fi
done
return 1
}
# 削除してよいか判定する。削除しない場合は理由を表示
# 削除してよい場合は、remove_worktree のログ用に BRANCH と MERGED_INTO を埋める
should_remove() {
local dir="$1" name check reason
name=$(display_name "$dir")
for check in has_uncommitted_changes has_running_claude_session is_detached_head is_unmerged; do
if reason=$("$check" "$dir"); then
echo "SKIP [$reason] $name"
return 1
fi
done
BRANCH=$(git -C "$dir" branch --show-current)
MERGED_INTO=$(find_merged_upstream "$dir")
}
prune_worktree() {
local parent="$1" worktree="$2" name
name=$(display_name "$worktree")
if [[ "$DRY_RUN" == true ]]; then
echo "PRUNE [directory missing] $name"
else
echo "Pruning $name (directory missing)"
git -C "$parent" worktree prune
echo " -> done"
fi
}
remove_worktree() {
local parent="$1" worktree="$2" name
name=$(display_name "$worktree")
if [[ "$DRY_RUN" == true ]]; then
echo "REMOVE[merged into $MERGED_INTO] $name (branch: $BRANCH)"
else
echo "Removing $name (branch: $BRANCH, merged into $MERGED_INTO)"
# --force: untracked files があっても削除する(変更ファイルは should_remove で確認済み)
git -C "$parent" worktree remove --force "$worktree"
echo " -> done"
fi
}
main "$@"
このコマンドの注意点としては、未追跡ファイルの存在は気にせず削除します(worktree remove --force)ので、未追跡ファイルが重要になる開発をしている場合、たとえば`.env`ファイルに秘密情報を書いて置いているなどですと消されてしまうというのがあります。
## worktree運用を楽にするコマンド、ほかにもあります
もうひとつ、worktree運用の面倒事をなくしてくれるコマンドをプレゼント。面倒事というのは、worktreeを立ち上げるときです。
* 親ディレクトリがすでに作業中でリポジトリが汚れた状態だと面倒(origin/mainからブランチを起こせ、ってコマンドを使えばいいだけなんですけどね)
* そのorigin/mainを最新に追従させるのを忘れると後で面倒
* ブランチ名とディレクトリ名を決めるのが面倒なのと、打ち込むと長いコマンドになるのと、その後新しいディレクトリに移動するために同じディレクトリ名を打ち込むのがしんどい
こんな感じ。それを、 `kickoff some-feature` のワンコマンドで全部やってくれてディレクトリ移動まで済んでたら楽じゃないですかってことです!
(ブランチ名は feature/some-feature、ディレクトリ名は親ディレクトリ名のうしろに -some-feature を付けた名前に。ブランチ名かぶりがあれば連番サフィックスで回避)
kickoff コマンド (Click to open)
#!/bin/bash
set -euo pipefail
TOPIC="${1:?トピック名が未指定です}"
MAIN_DIR="$(git worktree list --porcelain | sed -n 's/^worktree //p' | head -1)"
MAIN="$(git branch | sed 's/^. //' | grep -E '^(master|main)$' | head -1)"
is_taken() {
local branch="$1" dir="$2"
git show-ref --verify --quiet "refs/heads/$branch" ||
git show-ref --verify --quiet "refs/remotes/origin/$branch" ||
[[ -e "$dir" ]]
}
suffix=""
i=0
while is_taken "feature/$TOPIC$suffix" "$MAIN_DIR-$TOPIC$suffix"; do
i=$(( i + 1 ))
suffix="_$i"
done
git fetch origin "$MAIN" >&2
git worktree add -b "feature/$TOPIC$suffix" "$MAIN_DIR-$TOPIC$suffix" "origin/$MAIN" >&2
echo "$MAIN_DIR-$TOPIC$suffix"
実行完了時にディレクトリ移動まで済んでいるという体験を実現するには、シェルスクリプトだけではどうしても無理ですよね。なので、関数定義を.bashrcまたは.zshrcにこう書いておきます(上記kickoffコマンドは~/binに置いたものとして)。スクリプトの最後でディレクトリパス名をタイプアウトしているのはこうできるようにという布石というわけ。
function kickoff () { DIR="$(~/bin/kickoff "$1")" && cd "$DIR"; }
## まとめ
AIエージェント開発が増えると、待ち時間というのが増えます。その待ち時間の間に時間を無駄にしないようにと他のことにも着手しがちなので、あっという間にしかかりの並行作業を大量に抱えてしまいがちになっています。しかかり作業の量を増えすぎないようコントロールするのは読者の皆さまへの課題とさせていただきつつ、しかかり作業の数以上にはworktreeがあふれないようにするコマンドを作り方含めてご紹介いたしました。快適な開発を!
## We are hiring
エムスリーではコード管理基盤をGitLabからGitHubに絶賛移行中です。GitHub Actionsならバリバリだぜって方もこの機会にご検討を。カジュアル面談のご応募、お待ちしております!
### エンジニア採用ページはこちら
jobs.m3.com
### カジュアル面談もお気軽にどうぞ
jobs.m3.com
### インターンも常時募集しています
open.talentio.com
*1:すると「交通整理」というのは本来の意味からするととても恐ろしい光景ということになりますね。私は自然言語のこういうところが大好き。