fzf + git worktree で快適なブランチ並行開発環境を実装
概要
ghq でリポジトリを管理している環境に、git worktree を組み合わせてブランチごとにディレクトリを分離する仕組みを作りました。fzf を使ったキーバインド(Alt+B)で、ブランチの選択・worktree の作成・削除をインタラクティブに操作できます。
この記事では、ghq と worktree を共存させるディレクトリ設計、fzf を活用したキーバインドの実装、そして実装中に遭遇したいくつかの落とし穴について書きます。
背景
前回の記事で触れた開発サーバーでは、ghq でリポジトリを一元管理しています。最近、Claude Code を使った開発で、あるブランチの実装を Claude Code に任せつつ、自分は別のブランチでレビューや別作業をする場面が増えてきました。しかし作業ディレクトリが 1 つだと、ブランチを切り替えるたびに git stash → checkout → 作業 → stash → checkout... を繰り返す必要があり、Claude Code の作業中に割り込む形にもなって非効率でした。
git worktree(1 つのリポジトリから複数の作業ディレクトリを作る機能)を使えばブランチごとにディレクトリを分離できます。しかし素のままでは、パスを毎回指定する必要がある、どのブランチに worktree があるか把握しづらい、ghq のディレクトリ構造との整合性をどうするか、といった課題がありました。
アプローチ: ghq と共存する 2 層ディレクトリ設計
ghq 管理ディレクトリの中に worktree を置く案(例: ~/ghq/.../repo/.worktrees/feature-x/)も検討しましたが、以下の理由で別ルートに分離する設計にしました。
-
ghq listの結果に worktree が混在してノイズになる -
ghq root以下はリポジトリ単位の管理が前提であり、ブランチ単位のディレクトリを入れると意味が変わる - worktree は一時的に作って消すものなので、ghq の永続的な管理とはライフサイクルが異なる
具体的には、ghq 管理のリポジトリは main ブランチ固定で使い、worktree は ~/worktrees/ に配置します。
~/ghq/github.com/user/repo/ ← main ブランチ固定(ghq 管理)
~/worktrees/github.com/user/repo/
├── feature-auth/ ← worktree
├── bugfix-login/ ← worktree
└── optimize-perf/ ← worktree
ポイントは、~/worktrees/ 以下に ghq と同じ階層構造(github.com/user/repo/)を持たせている点です。これにより、worktree のパスからどのリポジトリのものかが一目でわかります。ブランチ名に含まれるスラッシュはハイフンに変換しています(feature/auth → feature-auth)。なお、feature/auth と feature-auth という 2 つのブランチが存在した場合は同じパスになるため、ブランチの命名規則で衝突を避ける前提の設計です。
ghq 側を main 固定にしているのは、ghq list や fzf 連携でリポジトリにアクセスする際の起点を常に保持しておくためです。main で直接作業したい場合は ghq ディレクトリでそのまま作業できます。ただし git worktree の制約として、同じブランチを複数箇所で同時にチェックアウトすることはできないため、main の作業場所は ghq ディレクトリに限定されます。
実装: Alt+B キーバインドの設計と fzf の活用
この仕組みの核は、fzf を使ったインタラクティブなブランチ操作です。Alt+B を押すと fzf が起動し、1 つのキーバインドから 4 つの操作に分岐します。
| キー | 動作 |
|---|---|
| Enter | worktree へ移動(なければ自動作成) |
| Ctrl+o | 従来の git checkout |
| Ctrl+n | 新規ブランチ + worktree 作成 |
| Ctrl+d | worktree 削除(確認付き) |
ブランチ一覧への注釈表示
fzf に表示するブランチ一覧には、各ブランチの worktree / ghq の状態を注釈として付与しています。どのブランチがどこのディレクトリで作業中かが一目でわかります。
main [ghq: ~/ghq/github.com/user/repo]
feature-x [worktree: ~/worktrees/.../feature-x]
bugfix-y
この注釈の生成部分です。
while IFS= read -r branch; do
[[ -z "$branch" ]] && continue
wt_path=$(__wt_get_worktree_path "$repo_path" "$branch")
if __wt_exists "$wt_path"; then
branch_list+="${branch}"$'\t'"[worktree: ${wt_path}]"$'\n'
elif [[ -n "$ghq_dir" && "$branch" == "$ghq_branch" ]]; then
branch_list+="${branch}"$'\t'"[ghq: ${ghq_dir}]"$'\n'
else
branch_list+="${branch}"$'\n'
fi
done <<< "$branches"
fzf による操作の分岐
fzf の --expect と --print-query を組み合わせて、押されたキーに応じて処理を分岐させています。
result=$(echo "$branch_list" | \
fzf --ansi \
--header "Enter: switch | Ctrl-o: checkout | Ctrl-n: new branch | Ctrl-d: delete" \
--expect=ctrl-o,ctrl-n,ctrl-d \
--print-query \
--preview "git log --oneline --graph -20 {1} 2>/dev/null || echo 'New branch: {q}'" \
--preview-window=right:50%)
# fzf の出力は3行: クエリ、押されたキー、選択された項目
query=$(echo "$result" | sed -n '1p')
key=$(echo "$result" | sed -n '2p')
selected=$(echo "$result" | sed -n '3p' | awk '{print $1}')
--expect に指定したキーが押されると、fzf は出力の 2 行目にそのキー名を返します。--print-query を併用することで、1 行目に入力中のクエリ文字列も取得でき、Ctrl+n での新規ブランチ名として利用しています。preview にはブランチのコミット履歴を表示しているため、切り替え前にブランチの状態を確認できます。
wt コマンド群
Alt+B のインタラクティブ操作とは別に、スクリプトから直接呼べる wt コマンドも用意しています。
| コマンド | 動作 |
|---|---|
wt new <branch> |
指定ブランチの worktree を作成 |
wt rm |
fzf で選択した worktree を削除 |
wt clean |
マージ済みブランチの worktree を一括削除 |
wt(引数なし) |
既存 worktree を fzf で一覧・移動 |
日常の操作は Alt+B で完結していますが、wt clean はマージ後の掃除に便利で定期的に使っています。
実装で遭遇した落とし穴
実装過程でいくつかハマったポイントがありました。
1. worktree 内での .git の挙動
通常のリポジトリでは .git はディレクトリですが、worktree では .git はファイルであり、中身はメインリポジトリの .git への参照パスです。この違いを意識しないと、「今いるディレクトリがメインリポジトリなのか worktree なのか」の判定を誤ります。
git rev-parse --git-common-dir を使うことで、worktree からメインリポジトリのパスを正しく取得できます。
__wt_get_main_git_dir() {
local git_common_dir
git_common_dir=$(git rev-parse --git-common-dir 2>/dev/null)
if [[ "$git_common_dir" == ".git" ]]; then
# 通常リポジトリ
git rev-parse --show-toplevel 2>/dev/null
else
# worktree内: git_common_dir はメインリポジトリの .git への絶対パス
echo "${git_common_dir%/.git}"
fi
}
2. find コマンドでの .git 検出
worktree の .git はディレクトリではなくファイルです。-type d で検索すると worktree を見落とします。
# 修正前(worktree の .git ファイルを見落とす)
find "$WORKTREE_ROOT" -mindepth 3 -maxdepth 4 -type d -name ".git"
# 修正後(.git ファイルを正しく検出)
find "$WORKTREE_ROOT" -mindepth 3 -maxdepth 5 -name ".git" -not -type d
この問題は wt clean(不要な worktree を一括削除する機能)で発覚しました。
3. パイプとサブシェルの変数スコープ
bash ではパイプの右辺がサブシェルで実行されるため、ループ内で変数に値を追加しても、パイプの外では反映されません。
# パイプでサブシェルが生まれ、変数が外に見えない
git branch --all | while read -r branch; do
branch_list+="..." # この変数はパイプの外で空のまま
done
# ヒアストリングでサブシェルを回避
while IFS= read -r branch; do
branch_list+="..." # メインシェルの変数に正しく追加
done <<< "$branches"
これは bash の基本的な仕様ですが、パイプで繋いで書く癖がついていると見落としがちです。
4. worktree ディレクトリから ghq パスを逆算
worktree 内で作業中に、元の ghq ディレクトリを特定する必要がある場面がありました。メインリポジトリが ghq 管理下にあればパスからすぐわかりますが、worktree のパスは ~/worktrees/ 以下にあるため、git remote URL から ghq パスを復元する処理が必要でした。
__wt_get_ghq_dir() {
local main_repo
main_repo=$(__wt_get_main_git_dir)
if [[ "$main_repo" == *"/ghq/"* ]]; then
echo "$main_repo"
else
local remote_url ghq_root repo_path
remote_url=$(git -C "$main_repo" remote get-url origin)
ghq_root=$(ghq root)
repo_path=$(echo "$remote_url" | \
sed -E 's#(git@|https://)##' | sed 's#:#/#' | sed 's#\.git$##')
echo "${ghq_root}/${repo_path}"
fi
}
なお、この URL 変換は GitHub など一般的な Git ホスティングの SSH・HTTPS URL を想定しています。GitLab のサブグループ(git@gitlab.com:group/subgroup/repo.git)のような形式では : → / の変換だけでは不十分なケースがあり、対応していません。
結果と振り返り
導入前後の変化は明確です。
-
Before:
git stash→checkout→ 作業 →stash→checkout... を繰り返す - After: Alt+B → ブランチ選択 → Enter で別ディレクトリに即移動
複数ブランチをそれぞれ別のディレクトリで開いたまま作業できるため、コンテキストスイッチのコストが大幅に減りました。特に Claude Code との並行作業で効果を実感しています。たとえば tmux で 3 ペインを開き、ペイン A では feature-auth の worktree で Claude Code に実装を進めさせ、ペイン B では bugfix-login のコードレビュー、ペイン C では ghq の main ブランチでドキュメント作業、といった使い方ができます。各ペインが独立したディレクトリなので、互いの作業が干渉しません。
落とし穴のセクションで書いた通り、worktree の .git がファイルである点はシェルスクリプトの各所に影響し、最も修正に時間がかかりました。git の内部構造を正しく理解してから書くべきだったと反省しています。
まとめ
git worktree と fzf を組み合わせて、Alt+B 一発でブランチごとの作業ディレクトリを管理できる環境を構築しました。ghq との共存は 2 層ディレクトリ設計で解決し、fzf の --expect / --print-query で 1 つのキーバインドに複数操作をまとめています。
実装の過程では、worktree の .git がファイルであることや、bash のパイプとサブシェルの変数スコープなど、基本的だが見落としやすいポイントにいくつかハマりました。これらの知見も含めて記録として残しておきます。
Discussion