Git Worktreeを楽にする自作スクリプトを書いた

Git Worktreeを使い始めたんだけど、ghqとの相性が悪くて困った。

ghqユーザーでworktree使ってる人って、みんなどうやってるんだろう?
調べた限りだと既存ツールもあるにはあるんだけど、too muchだったり、将来性が不安だったので、結局150行くらいのBashスクリプトを書いた。

SPONSORED BY

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 installgo installするのが面倒。
厳密には、npm installgo 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 logstatusを表示するようにした。


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 "$@"
SPONSORED BY