Git Worktreeを使い始めたんだけど、ghqとの相性が悪くて困った。
ghqユーザーでworktree使ってる人って、みんなどうやってるんだろう?
調べた限りだと既存ツールもあるにはあるんだけど、too muchだったり、将来性が不安だったので、結局150行くらいのBashスクリプトを書いた。
Git Worktreeの一般的な使い方とghq問題
Git Worktreeは、一つのリポジトリに対して複数のブランチを同時にチェックアウトできる機能。
ファイル(ディレクトリ)として存在するので、git switchする手間が省ける。
PRレビュー中に緊急のhotfixが入ったときや複数ブランチで同時作業するのに便利な機能で、2017年ごろから使えるようになった。
公式ドキュメントには、
$ git worktree add ../hotfix master
という使い方の例がある。
これはリポジトリの一つ上のディレクトリに hotfix というworktreeを作って、こういう構造になる。
parent/
├── repo/ # メインのリポジトリ
└── hotfix/ # worktree
これが問題なのは、ghqは設定されたGHQ_ROOT(デフォルトは ~/ghq)以下のディレクトリが~/ghq/github.com/owner/repoの構造にそってリポジトリがある前提なので、
上位にworktreeを作ると、あたかもowner/worktreeというリポジトリがあるかのように扱われてしまう。
また、これだと全く別のリポジトリからうっかり同名のWorktreeを作った時に衝突する恐れもある。
一覧性も悪い。(一応、git worktree listすればどこから伸びてきたWorktreeは表示されるけど)
自分が欲しかったのは、
~/ghq/github.com/owner/repo/
├── .git/
├── worktrees/
│ ├── feature-auth/
│ └── hotfix/
└── README.md
つまり、リポジトリ内に worktrees/ ディレクトリを作って、リポジトリの中で閉じたかった。
これなら、GHQ_ROOTに迷惑をかけない。
ただ、どうやらこの考え方はかなり少数派なようで、ざっと調べても同じように悩んでる人すら見当たらない。
公式ドキュメントで例示されているように、Git Worktreeとしてはリポジトリ外に分散配置する想定なんだろう。
既存ツールを試す
「git worktree tool」辺りで検索すると、いくつかツールが見つかった。
実際に全部試したんだけど、どれも継続利用には至らなかった。
まず、npm installやgo installするのが面倒。
厳密には、npm installやgo installするまでのNode環境やGo環境を構築するのが面倒。
PC買ってきて、すぐ使える仕組みが良い。
次に、将来のメンテナンス継続性への不安。
どのツールも新しすぎて、今は活発なんだけど、3年後も使えるかって言われると確証がない感じ。
(まあ俺自身が何年も同じワークフローを維持するか、という話もある)
各ツールの思想が自分の求めてるものと微妙にズレてて、やっぱり「リポジトリ内にworktreeを置く」ことを想定してなかったり、機能が多すぎて学習コストが高かったり。
作ったもの
冷静に考えるとやりたいことはそんなに複雑ではないのでシェルスクリプトで解決することにした。
ghqの cd $(ghq root)/$(ghq list | fzf) みたいな使い方から影響を受けて、「パスを出力する」ことに特化した。
これで、cd $(wt)でもcode $(wt)でも、UNIX的な思想で汎化できる。
サブコマンドは最小限:
wt(デフォルト): fzfで選択してパス出力wt add <branch> [-b]: 新規worktree作成wt list: 一覧表示wt remove: 削除(fzfで選択)
fzfのpreview機能を使って、選択中のworktreeのgit logとstatusを表示するようにした。
selected=$(echo "$worktrees" | fzf \
--prompt="Select worktree > " \
--preview='
path=$(echo {} | awk "{print \$1}");
echo "=== Git Log ===";
git -C "$path" log --oneline --graph --color=always -20;
echo "";
echo "=== Git Status ===";
git -C "$path" status --short;
' \
--preview-window=right:50% \
--height=100%)
うっかりfeature/auth みたいなブランチ名を指定すると、ディレクトリ構造と衝突するから、feature-auth に正規化する仕組みも加えた。
normalize_branch_name() {
echo "$1" | sed 's|/|-|g'
}
細かいところでは、worktrees/ディレクトリがなければ自動作成して、.gitignoreに追加する処理も入れた。
手動でやると忘れそうだったので。
一方で、wtpなどが持ってる「Worktree作成時に指定したファイルを自動コピー」機能は実装を見送った。
これも手動でやると絶対忘れるんだけど、Worktreeの使い方がまだ自分の中で定まっていないので、もう少し慣れたら加えていこうと思う。
aliasと組み合わせた使い方
.zshrcに、ghqの設定と合わせてこんな感じで書いてる。
## ghq
#
if which ghq >/dev/null; then
alias repos="ghq list | fzf --preview 'bat --color=always --style=header,grid --line-range :80 $(ghq root)/{}/README.*'"
alias repo='cd $(ghq root)/$(repos)'
fi
## wt: Git worktree manager
#
if which wt >/dev/null; then
alias wtcd='cd $(wt)'
alias wtcode='code $(wt)'
fi
repoでリポジトリを移動して、wtcdでWorktreeを移動する流れ。
実装で悩んだポイント
Bashを選んだ理由は、依存の最小化とシェル環境との親和性。
Goで書いたほうがクロスプラットフォーム対応などは楽かと思うけど、自分しか使わないツールだし、Bashで十分だった。
エラーハンドリングをどこまでやるかも悩んだ。
set -euo pipefailで基本的なエラーは拾うようにしたけど、全パターンを網羅するのは諦めた。
実用上問題ないレベルで妥協してる。
wt removeでメインリポジトリを削除できないようにする処理も入れた。
git worktree list の出力をパースして、リポジトリルートと一致するパスを除外してる。
worktrees=$(git worktree list --porcelain | awk -v root="$repo_root" '
/^worktree/ { path = $2 }
/^branch/ { branch = $2; gsub("^refs/heads/", "", branch) }
/^$/ {
if (path != "" && path != root) {
# ... 出力処理
}
# ...
}
')
とにかく「何をやらないか」を決めるのが重要だった。
たとえばworktreeの自動削除とか、ブランチの自動マージとか、そういう高度な機能は全部スコープ外にした。
シンプルに「選んで移動する」「作る」「消す」だけに絞った。
所感
しばらくこれで使ってるけど、今のところ満足してる。
既存ツールを試す過程で、Git Worktreeの設計思想とか、ghqとの相性問題とか、いろいろ理解が深まったのも良かった。
車輪の再発明ではあるものの、「自分のために作る」ことの価値を再確認した感じ。
それにしても、ghqユーザーでworktree使ってる人って、実際どれくらいいるんだろう?
同時に複数案件を抱えるのが常態化しているとghqなしの環境にはもう戻れない。
もし同じような環境の人がいたら、どうやって運用してるか教えてほしい。
もっといい方法があるような気がしてならない。
/bin/wt 全文
#!/usr/bin/env bash # # wt - Git worktree manager for ghq-managed repositories # # USAGE: # wt Select worktree with fzf and output path (for command composition) # wt add[-b] Add new worktree (use -b to create new branch) # wt list List all worktrees # wt remove Remove worktree interactively # wt claude Select worktree and launch Claude Code # # EXAMPLES: # cd $(wt) # Navigate to selected worktree # code $(wt) # Open selected worktree in VS Code # ls $(wt) # List files in selected worktree # # RECOMMENDED ALIASES (add to ~/.zshrc or ~/.zprezto/runcoms/zshrc): # alias wtcd='cd $(wt)' # alias wtclaude='cd $(wt) && claude' # alias wtcode='code $(wt)' # # INSTALLATION: # chmod +x ~/.zprezto/bin/wt # Add to ~/.zprezto/runcoms/zshrc: # export PATH="$HOME/.zprezto/bin:$PATH" # # REQUIREMENTS: # - git # - fzf set -euo pipefail #------------------------------------------------------------------------------ # Helper Functions #------------------------------------------------------------------------------ # Check if we're in a git repository check_git_repo() { if ! git rev-parse --git-dir &>/dev/null; then echo "Error: Not a git repository" >&2 return 1 fi } # Check if fzf is installed check_fzf() { if ! command -v fzf &>/dev/null; then echo "Error: fzf is not installed. Please install fzf first." >&2 return 1 fi } # Get repository root directory get_repo_root() { git rev-parse --show-toplevel } # Ensure worktrees directory exists and is in .gitignore ensure_worktrees_dir() { local repo_root repo_root="$(get_repo_root)" local worktrees_dir="$repo_root/worktrees" local gitignore="$repo_root/.gitignore" # Create worktrees directory if it doesn't exist if [[ ! -d "$worktrees_dir" ]]; then mkdir -p "$worktrees_dir" echo "Created worktrees directory: $worktrees_dir" >&2 fi # Add worktrees/ to .gitignore if not already present if [[ ! -f "$gitignore" ]]; then echo "worktrees/" >"$gitignore" echo "Added worktrees/ to .gitignore" >&2 elif ! grep -q '^/\?worktrees/$' "$gitignore"; then echo "worktrees/" >>"$gitignore" echo "Added worktrees/ to .gitignore" >&2 fi } # Normalize branch name (replace / with -) normalize_branch_name() { echo "$1" | sed 's|/|-|g' } #------------------------------------------------------------------------------ # Command Implementations #------------------------------------------------------------------------------ # Default command: Select worktree with fzf and output path cmd_default() { check_git_repo check_fzf local worktrees worktrees=$(git worktree list --porcelain | awk ' /^worktree/ { path = $2 } /^branch/ { branch = $2; gsub("^refs/heads/", "", branch) } /^$/ { if (path != "") { if (branch != "") { print path " (" branch ")" } else { print path " (detached)" } } path = "" branch = "" } ') if [[ -z "$worktrees" ]]; then echo "Error: No worktrees found" >&2 return 1 fi local selected selected=$(echo "$worktrees" | fzf \ --prompt="Select worktree > " \ --preview=' path=$(echo {} | awk "{print \$1}"); echo "=== Git Log ==="; git -C "$path" log --oneline --graph --color=always -20 2>/dev/null || echo "No commits yet"; echo ""; echo "=== Git Status ==="; git -C "$path" status --short 2>/dev/null || echo "No changes"; ' \ --preview-window=right:50% \ --height=100%) if [[ -z "$selected" ]]; then return 1 fi # Extract path from selection echo "$selected" | awk '{print $1}' } # Add new worktree cmd_add() { check_git_repo local create_branch=false local branch="" # Parse arguments while [[ $# -gt 0 ]]; do case "$1" in -b) create_branch=true shift ;; *) if [[ -z "$branch" ]]; then branch="$1" else echo "Error: Unexpected argument: $1" >&2 return 1 fi shift ;; esac done if [[ -z "$branch" ]]; then echo "Error: Branch name required" >&2 echo "Usage: wt add [-b]" >&2 return 1 fi ensure_worktrees_dir local repo_root repo_root="$(get_repo_root)" local normalized_branch normalized_branch="$(normalize_branch_name "$branch")" local worktree_path="$repo_root/worktrees/$normalized_branch" if [[ -d "$worktree_path" ]]; then echo "Error: Worktree already exists at $worktree_path" >&2 return 1 fi # Create worktree if $create_branch; then git worktree add -b "$branch" "$worktree_path" else git worktree add "$worktree_path" "$branch" fi echo "Worktree created at: $worktree_path" } # List all worktrees cmd_list() { check_git_repo git worktree list } # Remove worktree interactively cmd_remove() { check_git_repo check_fzf local repo_root repo_root="$(get_repo_root)" # Get worktrees excluding the main worktree local worktrees worktrees=$(git worktree list --porcelain | awk -v root="$repo_root" ' /^worktree/ { path = $2 } /^branch/ { branch = $2; gsub("^refs/heads/", "", branch) } /^$/ { if (path != "" && path != root) { if (branch != "") { print path " (" branch ")" } else { print path " (detached)" } } path = "" branch = "" } ') if [[ -z "$worktrees" ]]; then echo "No worktrees to remove (main worktree cannot be removed)" >&2 return 1 fi local selected selected=$(echo "$worktrees" | fzf \ --prompt="Select worktree to remove > " \ --preview=' path=$(echo {} | awk "{print \$1}"); echo "=== Git Status ==="; git -C "$path" status --short 2>/dev/null || echo "No changes"; echo ""; echo "=== Git Log ==="; git -C "$path" log --oneline --graph --color=always -10 2>/dev/null || echo "No commits yet"; ' \ --preview-window=right:50% \ --height=100%) if [[ -z "$selected" ]]; then return 1 fi # Extract path from selection local worktree_path worktree_path=$(echo "$selected" | awk '{print $1}') # Confirmation prompt echo -n "Remove worktree at $worktree_path? [y/N] " >&2 read -r response if [[ "$response" =~ ^[Yy]$ ]]; then git worktree remove "$worktree_path" echo "Removed worktree: $worktree_path" else echo "Cancelled" return 1 fi } # Select worktree and launch Claude Code cmd_claude() { check_git_repo check_fzf local selected_path if ! selected_path=$(cmd_default); then return 1 fi cd "$selected_path" && claude } # Show usage cmd_help() { sed -n '2,/^$/p' "$0" | sed 's/^# \?//' } #------------------------------------------------------------------------------ # Main #------------------------------------------------------------------------------ main() { case "${1:-}" in "") cmd_default ;; add) shift cmd_add "$@" ;; list) cmd_list ;; remove) cmd_remove ;; claude) cmd_claude ;; help | --help | -h) cmd_help ;; *) echo "Error: Unknown command: $1" >&2 echo "Run 'wt help' for usage information" >&2 return 1 ;; esac } main "$@"
