From 558d4c49d6454c14772202f64d9d63c5e2a82ce5 Mon Sep 17 00:00:00 2001 From: Kentaro Ohkouchi Date: Thu, 16 Jul 2026 10:23:51 +0900 Subject: [PATCH 1/2] feat(completion): add bash/zsh tab completion for subcommands and task names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `worktree completion ` で補完スクリプトを出力する。`shell-init` と 同じ eval 方式で、`~/.zshrc` / `~/.bashrc` に 1 行足すだけで有効になる。 補完対象: - サブコマンド (エイリアス ls/clean/rm/co/sw を含む) とサブコマンドごとのオプション - タスク名 (switch / cleanup) - ローカルブランチ名 (checkout。マルチリポは全サブリポから収集) 設計: - 補完スクリプトはチェックイン済みファイルではなく lib/cmd_completion.sh から 生成する。コマンド定義と補完定義が同じバイナリに同梱され乖離しない。 - タスク名候補は `list --names-only` (= list_all_task_names) 由来。list/switch と 候補集合が常に一致し、gh もリポジトリごとの git 呼び出しも走らないため対話補完に 耐える速度。プロジェクト外では静かに空を返す。 - bash 側は bash 3.2 互換 (連想配列・_init_completion 不使用)。補完スクリプトを 読むのは macOS の /bin/bash かもしれず、bash-completion 未導入環境もあるため。 - zsh 側は #compdef タグと funcstack[1] 分岐で eval / fpath autoload の両対応。 - マッチングは prefix ベースで、switch の substring 解決より狭い (シェル標準の 挙動に合わせた意図的な差)。 テスト: 補完関数を COMP_WORDS で直接駆動する bats を 11 件追加 (single_repo 9 + multi_repo 2)。zsh は zsh -n と compdef 登録を検証 (補完駆動は pty が必要なため 範囲外)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 14 ++ README.md | 38 ++++++ lib/cmd_completion.sh | 303 ++++++++++++++++++++++++++++++++++++++++++ test/multi_repo.bats | 32 +++++ test/single_repo.bats | 155 +++++++++++++++++++++ worktree | 5 + 6 files changed, 547 insertions(+) create mode 100644 lib/cmd_completion.sh diff --git a/CLAUDE.md b/CLAUDE.md index cd71619..4bdff4b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,6 +36,7 @@ worktree switch [name|branch|URL|-] worktree pull worktree install --skills [--global] worktree shell-init +worktree completion ``` `worktree list --merged --names-only` の出力は `worktree cleanup` の stdin にそのままパイプ可能 (1 行 1 task name)。マージ済み worktree をまとめて削除する用途で使う。`list --merged` は any-merged (1 つでもマージ済み sub-repo を含む) 判定で、`cleanup --merged` の all-merged 判定とは仕様が異なるため、パイプ削除時は未マージ sub-repo まで巻き込んで消える点に注意。 @@ -77,6 +78,18 @@ worktree shell-init - `checkout ` (URL でないブランチ切替) は新ディレクトリを作らないので cd しない。issue URL は `cmd_create` 経由、PR URL は `cmd_checkout_pr` 末尾で `write_cd_target` を呼ぶ。 - 自動 cd 後は `_WORKTREE_PREV` に元のディレクトリが入るため、`worktree switch -` で作成前の場所へ戻れる。 +## シェル補完 (worktree completion) + +`worktree completion ` は補完スクリプトを stdout に出力する。`shell-init` と同じ「eval して使う」方式で、`~/.zshrc` / `~/.bashrc` に `eval "$(worktree completion zsh)"` を追加する (zsh は **compinit の後**)。zsh は `fpath` に `_worktree` として置く運用も可能。 + +- **補完スクリプトはチェックイン済みファイルではなく `lib/cmd_completion.sh` から生成する**。コマンド定義と補完定義が同じバイナリに同梱され、乖離しない。**コマンド・オプションを追加したらここも更新する** (「コマンド追加・修正時のチェックリスト」参照)。 +- タスク名の候補は `worktree list --names-only` (= `list_all_task_names`) を呼んで得る。`list` / `switch` と候補集合が常に一致し、`gh` もリポジトリごとの git 呼び出しも走らないので対話補完に耐える速度。プロジェクト外では空を返すだけ (エラーにしない)。 +- `checkout` のブランチ補完はプロジェクトルート自身 (シングルリポ) と直下のサブリポ (マルチリポ) の `refs/heads` を集める。ツール側の検出ロジックを呼ばず補完スクリプト内で完結させ、CLI に内部用サブコマンドを増やさない方針。 +- **マッチングは prefix ベース** (`compgen` / `_describe`) で、`switch` の substring 解決より狭い。`switch login` は解決できるが `switch login` は補完されない — シェル標準の挙動に合わせる意図的な差。 +- bash 側は **bash 3.2 互換**を保つ (連想配列・`_init_completion` 等の bash-completion ヘルパーを使わない)。バイナリ本体と違い、補完スクリプトを読むのは macOS の `/bin/bash` (3.2) かもしれず、bash-completion 未導入環境もあるため。 +- zsh 側は eval と fpath autoload の**両対応**: 先頭に `#compdef worktree` タグを置き、末尾で `funcstack[1]` を見て「autoload なら `_worktree` を実行 / eval なら `compdef` で登録」を分岐する。 +- `switch` と同様、これは人間がシェルで使う機能なので Claude Code 用 Skill は提供しない。 + ## base branch の決定ロジック `worktree create` および `cleanup --merged` のマージ判定は、**`upstream` remote が存在する場合は `upstream/HEAD` を優先**し、なければ `origin/HEAD` を使う。fork ワークフロー (例: `origin` = `nanasess/ec-cube2`、`upstream` = `EC-CUBE/ec-cube2`) で origin が upstream より遅れている場合でも、worktree は upstream の最新を base にできる。`upstream` がある場合は `git fetch upstream` も自動で実行する。 @@ -149,6 +162,7 @@ git submodule update --init --recursive 2. **README.md 更新** — Usage セクションにコマンドの説明を追加・修正 3. **CLAUDE.md 更新** — コマンド一覧を更新 4. **Skills 更新** — `skills/` 配下の SKILL.md を追加・修正。`lib/cmd_install.sh` の Available skills 表示も更新 +5. **補完更新** — `lib/cmd_completion.sh` の bash / zsh 両スクリプトにサブコマンド・オプションを追加・修正 (`worktree` 本体の `usage()` とサブコマンド dispatch も忘れずに) ## 言語ルール diff --git a/README.md b/README.md index 98d9c3e..6c56d64 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,10 @@ ln -sf ~/git-repos/git-worktree-manager/worktree ~/.local/bin/worktree # Enable `worktree switch` (cd into worktrees) — add to ~/.zshrc or ~/.bashrc eval "$(worktree shell-init)" +# Enable tab completion (subcommands, options, task names) — same rc file +eval "$(worktree completion zsh)" # zsh: must come after compinit +eval "$(worktree completion bash)" # bash + # Create worktrees for a task cd ~/git-repos/my-project worktree create feature-login @@ -229,6 +233,40 @@ path (and a hint) instead of changing directory. an [`fzf`](https://github.com/junegunn/fzf) picker when one is available and a terminal is attached; otherwise it prints the list of worktree names. +### `worktree completion ` + +```bash +# zsh — add to ~/.zshrc, after compinit +eval "$(worktree completion zsh)" + +# zsh — or install into fpath instead, so startup pays nothing +worktree completion zsh > "${fpath[1]}/_worktree" + +# bash — add to ~/.bashrc +eval "$(worktree completion bash)" +``` + +Prints a tab-completion script for the given shell. It completes subcommands +(including aliases like `sw` / `co`), each subcommand's options, and — the point +of it — **worktree task names**: + +| Context | Completes | +|---|---| +| `worktree ` | subcommands | +| `worktree switch `, `worktree cleanup ` | task names of the current project | +| `worktree checkout ` | local branch names (all sub-repos in a multi-repo project) | +| `worktree create --`, `worktree list --`, … | that subcommand's options | + +Task names come from `worktree list --names-only`, so completion always agrees +with what `switch` and `cleanup` accept. Outside a project (no +`.worktrees/`) it simply completes nothing. Matching is prefix-based, +which is narrower than `switch`'s substring resolution: `worktree switch fea` +completes `feature/login`, while `worktree switch login` still resolves it. + +The zsh script works both ways — `eval`'d as above, or dropped into an `fpath` +directory as `_worktree` for lazy autoloading. It requires `compinit` to have run +first; put the `eval` after it in your `~/.zshrc`. + ### `worktree pull` ```bash diff --git a/lib/cmd_completion.sh b/lib/cmd_completion.sh new file mode 100644 index 0000000..6419f63 --- /dev/null +++ b/lib/cmd_completion.sh @@ -0,0 +1,303 @@ +#!/bin/bash +# +# cmd_completion.sh - completion subcommand (bash / zsh completion scripts) +# +# Like `shell-init`, this prints a script for the user's shell to evaluate; +# stdout carries the script and nothing else, so every diagnostic goes to +# stderr. The bash and zsh scripts are kept here (rather than as checked-in +# files) so the completion always ships with the binary that defines the +# commands it completes. +# +# Task names come from `worktree list --names-only`, which is the same +# discovery path `list` and `switch` use (list_all_task_names) — no gh, no +# per-repo git calls, so it stays fast enough for interactive completion. +# + +cmd_completion_usage() { + { + echo -e "${BOLD}worktree completion${NC} - Print a shell completion script" + echo "" + echo -e "${BOLD}USAGE:${NC}" + echo " worktree completion bash Print the bash completion script" + echo " worktree completion zsh Print the zsh completion script" + echo "" + echo -e "${BOLD}SETUP:${NC}" + echo " bash — add to ~/.bashrc:" + echo " eval \"\$(worktree completion bash)\"" + echo "" + echo " zsh — add to ~/.zshrc, after compinit:" + echo " eval \"\$(worktree completion zsh)\"" + echo "" + echo " zsh (fpath install, no startup cost):" + echo " worktree completion zsh > \"\${fpath[1]}/_worktree\"" + echo "" + echo -e "${BOLD}NOTES:${NC}" + echo " Completes subcommands, options, task names (cleanup/switch)" + echo " and local branches (checkout)." + } >&2 +} + +# bash completion script. +# +# Deliberately free of bash 4+ syntax and of any bash-completion helper +# (_init_completion et al): an interactive macOS /bin/bash 3.2 without +# bash-completion installed must still be able to source this. +completion_bash() { + cat <<'BASH_COMPLETION' +# git-worktree-manager bash completion +# Installed via: eval "$(worktree completion bash)" + +# Worktree task names for the current project (empty outside a project). +_worktree_task_names() { + command worktree list --names-only 2>/dev/null +} + +# Local branches: the project root itself when it is a git repo (single repo), +# plus every immediate sub-repo (multi repo). Matches what `checkout ` +# actually operates on. +_worktree_branches() { + local dir + git for-each-ref --format='%(refname:short)' refs/heads 2>/dev/null + for dir in */; do + [ -e "${dir}.git" ] || continue + git -C "$dir" for-each-ref --format='%(refname:short)' refs/heads 2>/dev/null + done +} + +_worktree() { + local cur prev cmd i + COMPREPLY=() + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + + # --branch-prefix takes a free-form value; there is nothing to suggest. + if [ "$prev" = "--branch-prefix" ]; then + return 0 + fi + + # The subcommand is the first non-option word after the binary name. + cmd="" + for ((i = 1; i < COMP_CWORD; i++)); do + case "${COMP_WORDS[i]}" in + -*) ;; + *) cmd="${COMP_WORDS[i]}"; break ;; + esac + done + + if [ -z "$cmd" ]; then + COMPREPLY=($(compgen -W "create list ls cleanup clean rm checkout co switch sw pull install shell-init completion help version -h --help -v --version" -- "$cur")) + return 0 + fi + + case "$cmd" in + create) + # The task name is new by definition — only options are completable. + case "$cur" in + -*) COMPREPLY=($(compgen -W "--branch-prefix --no-install --no-cd -h --help" -- "$cur")) ;; + esac + ;; + list|ls) + COMPREPLY=($(compgen -W "--merged --names-only -h --help" -- "$cur")) + ;; + cleanup|clean|rm) + case "$cur" in + -*) COMPREPLY=($(compgen -W "--merged --delete-branches --dry-run --force -h --help" -- "$cur")) ;; + *) COMPREPLY=($(compgen -W "$(_worktree_task_names)" -- "$cur")) ;; + esac + ;; + checkout|co) + case "$cur" in + -*) COMPREPLY=($(compgen -W "--no-install --no-cd --branch-prefix -h --help" -- "$cur")) ;; + *) COMPREPLY=($(compgen -W "$(_worktree_branches)" -- "$cur")) ;; + esac + ;; + switch|sw) + case "$cur" in + # `-` (previous worktree) is offered alongside the help flags. + -*) COMPREPLY=($(compgen -W "- -h --help" -- "$cur")) ;; + *) COMPREPLY=($(compgen -W "$(_worktree_task_names)" -- "$cur")) ;; + esac + ;; + pull) + COMPREPLY=($(compgen -W "-h --help" -- "$cur")) + ;; + install) + COMPREPLY=($(compgen -W "--skills --global -h --help" -- "$cur")) + ;; + completion) + COMPREPLY=($(compgen -W "bash zsh -h --help" -- "$cur")) + ;; + esac + return 0 +} + +complete -F _worktree worktree +BASH_COMPLETION +} + +# zsh completion script. +# +# Written to work both ways: `eval "$(worktree completion zsh)"` (the trailing +# guard runs compdef) and dropped into an fpath directory as `_worktree` (the +# `#compdef` tag applies and the guard invokes the function instead). +completion_zsh() { + cat <<'ZSH_COMPLETION' +#compdef worktree +# git-worktree-manager zsh completion +# Installed via: eval "$(worktree completion zsh)" # after compinit +# or: worktree completion zsh > "${fpath[1]}/_worktree" + +# Worktree task names for the current project (empty outside a project). +_worktree_task_names() { + local -a names + names=(${(f)"$(command worktree list --names-only 2>/dev/null)"}) + names=(${names:#}) + (( ${#names} )) || return 1 + _describe -t worktrees 'worktree' names +} + +# Local branches: the project root itself when it is a git repo (single repo), +# plus every immediate sub-repo (multi repo). +_worktree_branches() { + local -a branches + local dir + branches=(${(f)"$(git for-each-ref --format='%(refname:short)' refs/heads 2>/dev/null)"}) + for dir in */; do + [[ -e ${dir}.git ]] || continue + branches+=(${(f)"$(git -C $dir for-each-ref --format='%(refname:short)' refs/heads 2>/dev/null)"}) + done + branches=(${(u)branches:#}) + (( ${#branches} )) || return 1 + _describe -t branches 'branch' branches +} + +_worktree() { + local curcontext="$curcontext" state line ret=1 + typeset -A opt_args + + local -a commands aliases + commands=( + 'create:Create worktrees for a task' + 'list:List current worktrees' + 'cleanup:Remove worktrees' + 'checkout:Checkout a branch, or create a worktree from an issue/PR URL' + 'switch:Change directory to a worktree' + 'pull:Pull all repositories' + 'install:Install Claude Code skills' + 'shell-init:Print the shell integration for switch/create/checkout' + 'completion:Print a shell completion script' + ) + # Offered as their own group so `worktree sw` completes without + # burying the canonical names in the menu. + aliases=( + 'ls:Alias of list' + 'clean:Alias of cleanup' + 'rm:Alias of cleanup' + 'co:Alias of checkout' + 'sw:Alias of switch' + ) + + _arguments -C \ + '(- 1 *)'{-h,--help}'[Show help]' \ + '(- 1 *)'{-v,--version}'[Show version]' \ + '1: :->command' \ + '*:: :->args' && ret=0 + + case $state in + command) + _describe -t commands 'worktree command' commands && ret=0 + _describe -t aliases 'worktree alias' aliases && ret=0 + ;; + args) + case $words[1] in + create) + _arguments \ + '--branch-prefix[Prefix for the created branch]:prefix:' \ + '--no-install[Skip dependency installation]' \ + '--no-cd[Do not cd into the new worktree]' \ + '(- *)'{-h,--help}'[Show help]' \ + '1:task name:' && ret=0 + ;; + list|ls) + _arguments \ + '--merged[Show only tasks with at least one merged sub-repo]' \ + '--names-only[Print task names only, one per line]' \ + '(- *)'{-h,--help}'[Show help]' && ret=0 + ;; + cleanup|clean|rm) + _arguments \ + '--merged[Auto-detect and remove merged tasks]' \ + '--delete-branches[Delete branches along with worktrees]' \ + '--dry-run[Show targets without actually deleting]' \ + '--force[Skip confirmation prompts]' \ + '(- *)'{-h,--help}'[Show help]' \ + '1:task name:_worktree_task_names' && ret=0 + ;; + checkout|co) + _arguments \ + '--no-install[Skip dependency installation]' \ + '--no-cd[Do not cd into the new worktree]' \ + '--branch-prefix[Prefix for the created branch]:prefix:' \ + '(- *)'{-h,--help}'[Show help]' \ + '1:branch or issue/PR URL:_worktree_branches' && ret=0 + ;; + switch|sw) + _arguments \ + '(- *)'{-h,--help}'[Show help]' \ + '1:worktree:_worktree_task_names' && ret=0 + ;; + pull) + _arguments '(- *)'{-h,--help}'[Show help]' && ret=0 + ;; + install) + _arguments \ + '--skills[Install Claude Code skills to the project]' \ + '--global[Install to ~/.claude/skills (all projects)]' \ + '(- *)'{-h,--help}'[Show help]' && ret=0 + ;; + completion) + _arguments '1:shell:(bash zsh)' && ret=0 + ;; + esac + ;; + esac + + return ret +} + +# Autoloaded from fpath: funcstack[1] is _worktree, so run the completer. +# Sourced via eval: register it with compdef instead. +if [ "${funcstack[1]}" = "_worktree" ]; then + _worktree "$@" +else + compdef _worktree worktree +fi +ZSH_COMPLETION +} + +cmd_completion() { + local shell="${1:-}" + + case "$shell" in + -h|--help) + cmd_completion_usage + return 0 + ;; + bash) + completion_bash + ;; + zsh) + completion_zsh + ;; + "") + log_error "Shell name required: worktree completion " >&2 + cmd_completion_usage + return 1 + ;; + *) + log_error "Unsupported shell: ${shell} (supported: bash, zsh)" >&2 + cmd_completion_usage + return 1 + ;; + esac +} diff --git a/test/multi_repo.bats b/test/multi_repo.bats index 75a15c0..b345001 100644 --- a/test/multi_repo.bats +++ b/test/multi_repo.bats @@ -147,6 +147,38 @@ teardown_file() { assert_output --partial "Invalid GitHub URL" } +# completion in a multi-repo project: task names come from `list --names-only`, +# so they are the task directories, matching what `switch` accepts. +@test "completion bash: switch completes multi-repo task names" { + cd "$MULTI_REPO_DIR" + run env PATH="$(dirname "$WORKTREE_CMD"):$PATH" bash -c ' + eval "$(worktree completion bash)" + COMP_WORDS=(worktree switch "test") + COMP_CWORD=2 + _worktree + printf "%s\n" "${COMPREPLY[@]}" + ' + assert_success + assert_line "test-task" + assert_line "test-task2" +} + +# The project root of a multi-repo project is not itself a git repo, so branch +# completion has to descend into the sub-repos. test-task2 was created with +# --branch-prefix ci/, giving a branch name no task name would produce. +@test "completion bash: checkout collects branches from sub-repos" { + cd "$MULTI_REPO_DIR" + run env PATH="$(dirname "$WORKTREE_CMD"):$PATH" bash -c ' + eval "$(worktree completion bash)" + COMP_WORDS=(worktree checkout "ci/") + COMP_CWORD=2 + _worktree + printf "%s\n" "${COMPREPLY[@]}" + ' + assert_success + assert_output --partial "ci/test-task2" +} + @test "pull: exits 0" { cd "$MULTI_REPO_DIR" run "$WORKTREE_CMD" pull diff --git a/test/single_repo.bats b/test/single_repo.bats index 6aef4b2..91955ff 100644 --- a/test/single_repo.bats +++ b/test/single_repo.bats @@ -149,6 +149,161 @@ teardown_file() { assert_output --partial "create|checkout|co" } +# completion: the printed scripts are what the user evals, so their shape is +# part of the contract (bash registers via `complete`, zsh via `#compdef` + +# `compdef`). The functional tests below drive the emitted function directly. +@test "completion bash: prints a script that registers the completer" { + run "$WORKTREE_CMD" completion bash + assert_success + assert_output --partial "_worktree()" + assert_output --partial "complete -F _worktree worktree" +} + +@test "completion zsh: prints a script usable via compdef and fpath" { + run "$WORKTREE_CMD" completion zsh + assert_success + # The tag makes the script valid as an fpath `_worktree` file; the compdef + # branch is what makes the same output work when eval'd. + assert_output --partial "#compdef worktree" + assert_output --partial "compdef _worktree worktree" +} + +@test "completion: missing shell name fails with usage" { + run "$WORKTREE_CMD" completion + assert_failure + assert_output --partial "Shell name required" +} + +@test "completion: unsupported shell fails" { + run "$WORKTREE_CMD" completion fish + assert_failure + assert_output --partial "Unsupported shell: fish" +} + +# Functional: drive _worktree the way bash would. The completer shells out to +# `worktree list --names-only`, so the binary must be reachable on PATH. +@test "completion bash: switch completes worktree task names" { + cd "$SINGLE_REPO_DIR" + run env PATH="$(dirname "$WORKTREE_CMD"):$PATH" bash -c ' + eval "$(worktree completion bash)" + COMP_WORDS=(worktree switch "") + COMP_CWORD=2 + _worktree + printf "%s\n" "${COMPREPLY[@]}" + ' + assert_success + assert_output --partial "feature/slash-test" +} + +@test "completion bash: cleanup completes worktree task names" { + cd "$SINGLE_REPO_DIR" + run env PATH="$(dirname "$WORKTREE_CMD"):$PATH" bash -c ' + eval "$(worktree completion bash)" + COMP_WORDS=(worktree cleanup "feature/") + COMP_CWORD=2 + _worktree + printf "%s\n" "${COMPREPLY[@]}" + ' + assert_success + # A slash-bearing task name completes as one word: '/' is not in + # COMP_WORDBREAKS, so the candidate is the full task name. + assert_output "feature/slash-test" +} + +# Completion is prefix-based (compgen), which is narrower than `switch`'s +# substring resolution: 'slash' resolves as a switch argument but is not a +# completion candidate for 'feature/slash-test'. +@test "completion bash: task names complete on prefix, not substring" { + cd "$SINGLE_REPO_DIR" + run env PATH="$(dirname "$WORKTREE_CMD"):$PATH" bash -c ' + eval "$(worktree completion bash)" + COMP_WORDS=(worktree switch "slash") + COMP_CWORD=2 + _worktree + printf "%s\n" "${COMPREPLY[@]}" + ' + assert_success + assert_output "" +} + +@test "completion bash: first word completes subcommands" { + cd "$SINGLE_REPO_DIR" + run env PATH="$(dirname "$WORKTREE_CMD"):$PATH" bash -c ' + eval "$(worktree completion bash)" + COMP_WORDS=(worktree "c") + COMP_CWORD=1 + _worktree + printf "%s\n" "${COMPREPLY[@]}" + ' + assert_success + assert_line "create" + assert_line "cleanup" + assert_line "checkout" + assert_line "clean" + assert_line "co" + assert_line "completion" +} + +@test "completion bash: options are completed per subcommand" { + cd "$SINGLE_REPO_DIR" + run env PATH="$(dirname "$WORKTREE_CMD"):$PATH" bash -c ' + eval "$(worktree completion bash)" + COMP_WORDS=(worktree cleanup "--") + COMP_CWORD=2 + _worktree + printf "%s\n" "${COMPREPLY[@]}" + ' + assert_success + assert_line "--merged" + assert_line "--delete-branches" + assert_line "--dry-run" + assert_line "--force" + # `list`-only options must not leak into `cleanup`. + refute_line "--names-only" +} + +@test "completion bash: checkout completes local branch names" { + cd "$SINGLE_REPO_DIR" + run env PATH="$(dirname "$WORKTREE_CMD"):$PATH" bash -c ' + eval "$(worktree completion bash)" + COMP_WORDS=(worktree checkout "mas") + COMP_CWORD=2 + _worktree + printf "%s\n" "${COMPREPLY[@]}" + ' + assert_success + assert_output --partial "master" +} + +@test "completion bash: --branch-prefix value is not completed" { + cd "$SINGLE_REPO_DIR" + run env PATH="$(dirname "$WORKTREE_CMD"):$PATH" bash -c ' + eval "$(worktree completion bash)" + COMP_WORDS=(worktree create --branch-prefix "") + COMP_CWORD=3 + _worktree + printf "%s\n" "${COMPREPLY[@]}" + ' + assert_success + assert_output "" +} + +# The zsh script must parse and register under a real zsh + compinit. Driving +# the completion itself needs a pty, which is out of scope here. +@test "completion zsh: script loads and registers with compdef" { + if ! command -v zsh >/dev/null 2>&1; then + skip "zsh not available" + fi + run env PATH="$(dirname "$WORKTREE_CMD"):$PATH" zsh -f -c ' + autoload -Uz compinit; compinit -u -d "${TMPDIR:-/tmp}/wt-zcompdump-$$" + eval "$(worktree completion zsh)" + print -r -- "comps=${_comps[worktree]:-NONE}" + rm -f "${TMPDIR:-/tmp}/wt-zcompdump-$$" + ' + assert_success + assert_output --partial "comps=_worktree" +} + # Auto-cd: create records the destination worktree in _WORKTREE_CD_FILE, which # the shell function reads to perform the cd. The binary itself never cd's. @test "create: records cd target when _WORKTREE_CD_FILE is set" { diff --git a/worktree b/worktree index 2a3cd7d..0644243 100755 --- a/worktree +++ b/worktree @@ -46,6 +46,7 @@ source "$SCRIPT_DIR/lib/cmd_install.sh" source "$SCRIPT_DIR/lib/cmd_pull.sh" source "$SCRIPT_DIR/lib/cmd_checkout.sh" source "$SCRIPT_DIR/lib/cmd_switch.sh" +source "$SCRIPT_DIR/lib/cmd_completion.sh" VERSION="0.1.0" @@ -64,6 +65,7 @@ usage() { echo " pull Pull all repositories" echo " install --skills Install Claude Code skills" echo " shell-init Print shell integration for 'switch'" + echo " completion Print completion script (bash|zsh)" echo "" echo -e "${BOLD}OPTIONS:${NC}" echo " -h, --help Show help" @@ -101,6 +103,9 @@ main() { shell-init) cmd_shell_init "$@" ;; + completion) + cmd_completion "$@" + ;; pull) cmd_pull "$@" ;; From 0aa7eb6c2f5e76016afe167cd1b2312ef5469944 Mon Sep 17 00:00:00 2001 From: Kentaro Ohkouchi Date: Thu, 16 Jul 2026 10:54:52 +0900 Subject: [PATCH 2/2] fix(completion): harden glob and array access in completion scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit レビュー指摘のうち検証で妥当と確認できた 3 点を修正。 - zsh: `for dir in */` を `*/(N)` にし、`"$dir"` / `"${dir}.git"` を quote。 補完システムは NULL_GLOB を設定するため補完中は問題ないが (実測: 補完関数内で nullglob=on)、補完コンテキスト外から呼ぶと `no matches found` で関数が中断する。 明示的な (N) で呼び出し元に依存しないようにする。 - bash: COMP_CWORD=0 のとき COMP_WORDS[-1] を評価しないようガードを追加。負の 添字は bash 4.2 以降の機能で、本スクリプトが対象とする 3.2 ではエラーになる。 `complete -F` は第 1 語では発火しないため到達不能だが、3.2 互換の前提を明示的に 守る。 - zsh: `completion` サブコマンドで -h/--help を補完 (bash 側と同じ。cmd_completion は実際に -h を実装している)。 shell-init への -h/--help 補完提案は不採用: cmd_shell_init は引数を一切解釈せず、 `worktree shell-init -h` はシェル関数本体を出力する。存在しないオプションを補完 候補に出すことになるため。 テスト: COMP_CWORD=0 のガードを固定する bats を 1 件追加 (計 102 件 green)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/cmd_completion.sh | 24 +++++++++++++++++++----- test/single_repo.bats | 17 +++++++++++++++++ 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/lib/cmd_completion.sh b/lib/cmd_completion.sh index 6419f63..7788c53 100644 --- a/lib/cmd_completion.sh +++ b/lib/cmd_completion.sh @@ -68,7 +68,13 @@ _worktree() { local cur prev cmd i COMPREPLY=() cur="${COMP_WORDS[COMP_CWORD]}" - prev="${COMP_WORDS[COMP_CWORD-1]}" + # Guard COMP_CWORD=0 instead of letting COMP_WORDS[-1] be evaluated: + # bash only grew negative array subscripts in 4.2, so on 3.2 that would + # be a "bad array subscript" error rather than the last word. + prev="" + if [ "$COMP_CWORD" -gt 0 ]; then + prev="${COMP_WORDS[COMP_CWORD-1]}" + fi # --branch-prefix takes a free-form value; there is nothing to suggest. if [ "$prev" = "--branch-prefix" ]; then @@ -162,9 +168,13 @@ _worktree_branches() { local -a branches local dir branches=(${(f)"$(git for-each-ref --format='%(refname:short)' refs/heads 2>/dev/null)"}) - for dir in */; do - [[ -e ${dir}.git ]] || continue - branches+=(${(f)"$(git -C $dir for-each-ref --format='%(refname:short)' refs/heads 2>/dev/null)"}) + # (N) applies NULL_GLOB to this expansion only. The completion system + # already sets NULL_GLOB, so this is belt-and-braces — but it keeps the + # function from aborting on `no matches found` if it is ever called + # outside a completion context (e.g. sourced by hand while debugging). + for dir in */(N); do + [[ -e "${dir}.git" ]] || continue + branches+=(${(f)"$(git -C "$dir" for-each-ref --format='%(refname:short)' refs/heads 2>/dev/null)"}) done branches=(${(u)branches:#}) (( ${#branches} )) || return 1 @@ -256,7 +266,11 @@ _worktree() { '(- *)'{-h,--help}'[Show help]' && ret=0 ;; completion) - _arguments '1:shell:(bash zsh)' && ret=0 + # -h/--help is offered here (unlike shell-init, which + # parses no arguments) because cmd_completion implements it. + _arguments \ + '(- *)'{-h,--help}'[Show help]' \ + '1:shell:(bash zsh)' && ret=0 ;; esac ;; diff --git a/test/single_repo.bats b/test/single_repo.bats index 91955ff..18ffd2e 100644 --- a/test/single_repo.bats +++ b/test/single_repo.bats @@ -275,6 +275,23 @@ teardown_file() { assert_output --partial "master" } +# bash grew negative array subscripts only in 4.2, so COMP_WORDS[COMP_CWORD-1] +# with COMP_CWORD=0 would error on the 3.2 this script still targets. `complete +# -F` never invokes us for word 0, but the guard must survive direct calls. +@test "completion bash: COMP_CWORD=0 does not index COMP_WORDS negatively" { + cd "$SINGLE_REPO_DIR" + run env PATH="$(dirname "$WORKTREE_CMD"):$PATH" bash -c ' + eval "$(worktree completion bash)" + COMP_WORDS=(worktree) + COMP_CWORD=0 + _worktree + echo "rc=$?" + ' + assert_success + assert_output --partial "rc=0" + refute_output --partial "bad array subscript" +} + @test "completion bash: --branch-prefix value is not completed" { cd "$SINGLE_REPO_DIR" run env PATH="$(dirname "$WORKTREE_CMD"):$PATH" bash -c '