From a1d0c5a09c426dbfc0a1fd27ed7561a1bc153bfb Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Fri, 21 Aug 2026 12:05:07 -0400 Subject: [PATCH 1/2] Add bash and zsh shell completions (#51) Adds completions/git-trees.bash and completions/_git-trees covering every subcommand and its own flags, with branch and worktree names derived from git for `rm`. The filenames and function names are load-bearing. bash-completion's git driver dispatches `git ` to `_git_` with dashes turned into underscores, so `git trees` requires a function named `_git_trees`. zsh's `_git` dispatches via `_call_function ret _git-$words[1]`, so it requires a file named `_git-trees` on fpath. Both files also wire up the standalone `git-trees` binary. install.sh copies both files to ~/.config/git-trees/completions/ using the same no-overwrite guard already used for AGENTS.md.template, and prints the activation line for each shell. CI gains completions/git-trees.bash in both the `bash -n` and `shellcheck` steps. completions/_git-trees is deliberately excluded from both: `#compdef` and `_arguments` are zsh syntax and are not valid bash. Closes #51 Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 4 +- README.md | 43 +++++++++++++ completions/_git-trees | 120 +++++++++++++++++++++++++++++++++++++ completions/git-trees.bash | 114 +++++++++++++++++++++++++++++++++++ install.sh | 22 +++++++ tests/smoke.sh | 56 +++++++++++++++++ 6 files changed, 357 insertions(+), 2 deletions(-) create mode 100644 completions/_git-trees create mode 100644 completions/git-trees.bash diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c9e390d..392424c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: - uses: actions/checkout@v4 - name: Syntax check - run: bash -n git-trees && bash -n install.sh && bash -n tests/smoke.sh + run: bash -n git-trees && bash -n install.sh && bash -n tests/smoke.sh && bash -n completions/git-trees.bash - name: Install ShellCheck run: | @@ -27,7 +27,7 @@ jobs: fi - name: ShellCheck - run: shellcheck -s bash git-trees install.sh tests/smoke.sh + run: shellcheck -s bash git-trees install.sh tests/smoke.sh completions/git-trees.bash - name: Smoke tests run: tests/smoke.sh ./git-trees diff --git a/README.md b/README.md index e5588cf..a2bc894 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,49 @@ case ":$PATH:" in *":$HOME/.local/bin:"*) ;; *) `install.sh` warns if it isn't; the curl path cannot. Anything on `PATH` named `git-trees` becomes `git trees`. +### Shell completions + +`install.sh` copies both completion files to `~/.config/git-trees/completions/` +and prints the activation line for each. It never overwrites a copy you have +edited, so a reinstall keeps your changes. + +**bash** — source the file from `~/.bashrc`, after bash-completion itself: + +```bash +source ~/.config/git-trees/completions/git-trees.bash +``` + +**zsh** — put the directory on `fpath` *before* `compinit` runs in `~/.zshrc`: + +```zsh +fpath=(~/.config/git-trees/completions $fpath) +autoload -U compinit && compinit +``` + +If you already ran `compinit`, start a new shell (or `rm -f ~/.zcompdump` +first) so the new file is picked up. + +Completion covers every subcommand and its own flags, and completes branch and +worktree names for `rm` from git itself. Outside a repository it stays silent +rather than erroring. + +**If you installed via the curl path**, `install.sh` never ran, so fetch the +files yourself first: + +```bash +mkdir -p ~/.config/git-trees/completions +for f in git-trees.bash _git-trees; do + curl -fsSL -o ~/.config/git-trees/completions/"$f" \ + https://raw.githubusercontent.com/brightdigit/git-trees/main/completions/"$f" +done +``` + +Then add the `source` line (bash) or the `fpath` line (zsh) above. + +The filenames are load-bearing. bash-completion dispatches `git trees` to a +function named `_git_trees`, and zsh's `_git` dispatches it to a file named +`_git-trees` on `fpath` — renaming either one silently disables completion. + ## Configuration All three variables are optional. Add to `~/.zshrc` (or `~/.bashrc`): diff --git a/completions/_git-trees b/completions/_git-trees new file mode 100644 index 0000000..2f0a521 --- /dev/null +++ b/completions/_git-trees @@ -0,0 +1,120 @@ +#compdef git-trees +# zsh completion for git-trees. +# +# The filename is not arbitrary: zsh's `_git` dispatches `git ` by calling +# a function named `_git-`, so `git trees` requires this file to be named +# `_git-trees` and to sit on `fpath` ahead of `compinit`. The `#compdef +# git-trees` tag additionally wires up the standalone `git-trees` binary. + +# Worktree directory names, which are branch names slugged with `/`->`-` and so +# routinely coincide with branch names — hence the `(u)` dedupe below. The bare +# container root is listed as a worktree by git but is not a removable target. +# Errors are swallowed so completing outside a repository is silent, not noisy. +__git_trees_worktree_names() { + git worktree list --porcelain 2>/dev/null | + awk '/^worktree /{ sub(/^worktree /, ""); n = split($0, p, "/"); if (p[n] !~ /\.git$/) print p[n] }' +} + +__git_trees_targets() { + local -a targets + targets=( + ${(f)"$(git for-each-ref --format='%(refname:short)' refs/heads 2>/dev/null)"} + ${(f)"$(__git_trees_worktree_names)"} + ) + _describe -t targets 'branch or worktree' "${(@u)targets}" +} + +__git_trees_worktrees() { + local -a wts + wts=( ${(f)"$(__git_trees_worktree_names)"} ) + _describe -t worktrees 'worktree' wts +} + +_git-trees() { + local curcontext="$curcontext" state line ret=1 + typeset -A opt_args + + local -a commands + commands=( + 'init:create bare repo + worktree layout' + 'root:print project root; link .git if missing' + 'add:create a worktree (sets upstream)' + 'track:ensure branch has an upstream' + 'list:worktrees + branches without one' + 'ls:alias for list' + 'rm:remove worktree and delete branch' + 'clean:report/remove merged or gone branches' + 'sync:update a worktree from its upstream' + 'prune:remove stale worktree administrative files' + 'help:show usage' + ) + + _arguments -C \ + '1: :->command' \ + '*:: :->args' && ret=0 + + case $state in + command) + _describe -t commands 'git trees command' commands && ret=0 + ;; + args) + case $words[1] in + init) + _arguments \ + '--host[host for the clone URL]:host:_hosts' \ + '--dir[directory to create]:directory:_files -/' \ + '1:repository:' && ret=0 + ;; + root) + _arguments \ + '--agents[seed AGENTS.md at the container root]' \ + '1:directory:_files -/' && ret=0 + ;; + add) + _arguments \ + '--print-path[print the worktree path on stdout]' \ + '--no-push[do not create the branch on origin]' \ + '1:branch:__git_trees_targets' \ + '2:base:__git_trees_targets' && ret=0 + ;; + track) + _arguments \ + '--no-push[do not create the branch on origin]' \ + '1:worktree path:_files -/' && ret=0 + ;; + list|ls) + _arguments '--json[emit JSON]' && ret=0 + ;; + rm) + _arguments \ + '--apply[actually remove; without it, report only]' \ + '1:branch or path:__git_trees_targets' && ret=0 + ;; + clean) + # --merged and --gone are mutually exclusive selectors. + _arguments \ + '(--gone)--merged[select branches merged into the default branch]' \ + '(--merged)--gone[select branches whose upstream is gone]' \ + '--apply[actually remove; without it, report only]' && ret=0 + ;; + sync) + # --ff-only and --rebase pick competing merge strategies. + _arguments \ + '--pull[fetch and integrate from the upstream]' \ + '(--rebase)--ff-only[refuse anything but a fast-forward]' \ + '(--ff-only)--rebase[rebase onto the upstream]' \ + '1:worktree:__git_trees_worktrees' && ret=0 + ;; + prune) + _arguments '--dry-run[report without removing]' && ret=0 + ;; + esac + ;; + esac + + return ret +} + +# When zsh's `_git` sources this file it only wants the function defined; when +# compinit autoloads it for the standalone binary the function must also run. +_git-trees "$@" diff --git a/completions/git-trees.bash b/completions/git-trees.bash new file mode 100644 index 0000000..189eeef --- /dev/null +++ b/completions/git-trees.bash @@ -0,0 +1,114 @@ +# git-trees bash completion +# +# Source from ~/.bashrc (after bash-completion), or drop into a +# bash-completion completions directory as `git-trees`. +# +# The function name is not arbitrary: bash-completion's git driver dispatches +# `git ` to `_git_` with dashes turned into underscores, so +# `git trees` lands on `_git_trees`. The standalone `git-trees` binary is wired +# up separately at the bottom of this file. + +# SC2207 disabled file-wide: its suggested fix is `mapfile`, which is bash 4+, +# and this repo targets bash 3.2 (macOS system bash). The `COMPREPLY=( $(...) )` +# word-splitting idiom is the portable form and is what bash-completion itself +# uses. +# shellcheck disable=SC2207 + +# Commands and per-subcommand flags live in one place so the two entry points +# (`git trees` and `git-trees`) cannot drift apart. +__git_trees_commands='init root add track list ls rm clean sync prune help' + +__git_trees_flags() { # __git_trees_flags + case "$1" in + init) echo '--host --dir' ;; + root) echo '--agents' ;; + add) echo '--print-path --no-push' ;; + track) echo '--no-push' ;; + list|ls) echo '--json' ;; + rm) echo '--apply' ;; + clean) echo '--merged --gone --apply' ;; + sync) echo '--pull --ff-only --rebase' ;; + prune) echo '--dry-run' ;; + *) echo '' ;; + esac +} + +# Worktree directory names, which are branch names slugged with `/`->`-`, so +# they routinely coincide with branch names — hence the awk dedupe. The bare +# container root is listed as a worktree by git but is not a removable target. +# Every git call is silenced and short-circuited so completing outside a +# repository (or in a broken one) yields an empty list rather than an error in +# the prompt. +__git_trees_worktrees() { + git worktree list --porcelain 2>/dev/null | + awk '/^worktree /{ + sub(/^worktree /, "") + n = split($0, p, "/") + if (p[n] ~ /\.git$/) next + if (!seen[p[n]]++) print p[n] + }' +} + +# Branch names plus worktree directory names — `rm` accepts either. +__git_trees_targets() { + { + git for-each-ref --format='%(refname:short)' refs/heads 2>/dev/null + __git_trees_worktrees + } | awk '!seen[$0]++' +} + +# The shared body. $1 is the index of the `git-trees` word itself in COMP_WORDS, +# which differs between `git trees …` (1) and `git-trees …` (0). +__git_trees_complete() { + local base="$1" cur prev sub i + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + + # First non-flag word after the command name is the subcommand. + sub='' + i=$((base + 1)) + while [ "$i" -lt "$COMP_CWORD" ]; do + case "${COMP_WORDS[i]}" in + -*) ;; + *) sub="${COMP_WORDS[i]}"; break ;; + esac + i=$((i + 1)) + done + + if [ -z "$sub" ]; then + COMPREPLY=( $(compgen -W "$__git_trees_commands" -- "$cur") ) + return 0 + fi + + # --host and --dir take a value; offering flags there would be wrong. + case "$prev" in + --host) COMPREPLY=(); return 0 ;; + --dir) COMPREPLY=( $(compgen -d -- "$cur") ); return 0 ;; + esac + + local flags + flags=$(__git_trees_flags "$sub") + + if [ "${cur:0:1}" = "-" ]; then + COMPREPLY=( $(compgen -W "$flags" -- "$cur") ) + return 0 + fi + + # Positional argument. `init` takes an org/repo or URL we cannot enumerate. + case "$sub" in + rm) COMPREPLY=( $(compgen -W "$(__git_trees_targets)" -- "$cur") ) ;; + add) COMPREPLY=( $(compgen -W "$(__git_trees_targets)" -- "$cur") ) ;; + sync) COMPREPLY=( $(compgen -W "$(__git_trees_worktrees)" -- "$cur") ) ;; + root|track) COMPREPLY=( $(compgen -d -- "$cur") ) ;; + *) COMPREPLY=( $(compgen -W "$flags" -- "$cur") ) ;; + esac + return 0 +} + +# bash-completion's git driver calls this with COMP_WORDS[0]="git", +# COMP_WORDS[1]="trees". +_git_trees() { __git_trees_complete 1; } + +# Direct invocation as `git-trees`. +_git_trees_standalone() { __git_trees_complete 0; } +complete -F _git_trees_standalone git-trees diff --git a/install.sh b/install.sh index 98363a4..aa96a5f 100755 --- a/install.sh +++ b/install.sh @@ -24,6 +24,21 @@ if [ -f "$SRC/AGENTS.md.template" ] && [ ! -f "$CFG/AGENTS.md" ]; then echo "installed $CFG/AGENTS.md (template; used by init or root --agents to seed the container root)" fi +# Shell completions — same no-overwrite shape as the template above, so a user +# who edited an installed copy keeps it across reinstalls. +BASHCOMP="$CFG/completions/git-trees.bash" +ZSHCOMP="$CFG/completions/_git-trees" +if [ -f "$SRC/completions/git-trees.bash" ] && [ ! -f "$BASHCOMP" ]; then + mkdir -p "$CFG/completions" + cp "$SRC/completions/git-trees.bash" "$BASHCOMP" + echo "installed $BASHCOMP (bash completion; source it from ~/.bashrc)" +fi +if [ -f "$SRC/completions/_git-trees" ] && [ ! -f "$ZSHCOMP" ]; then + mkdir -p "$CFG/completions" + cp "$SRC/completions/_git-trees" "$ZSHCOMP" + echo "installed $ZSHCOMP (zsh completion; put its directory on fpath before compinit)" +fi + case ":$PATH:" in *":$DEST:"*) ;; *) echo "warning: $DEST is not on PATH — add it to use \`git trees\`" >&2 ;; @@ -32,6 +47,13 @@ esac echo echo "try: git trees help" +if [ -f "$BASHCOMP" ] || [ -f "$ZSHCOMP" ]; then + echo + echo "to activate completions, add one of these to your shell rc:" + [ -f "$BASHCOMP" ] && echo " bash: source $BASHCOMP" + [ -f "$ZSHCOMP" ] && echo " zsh: fpath=($CFG/completions \$fpath) # before compinit" +fi + if [ -z "${TREES_ORG:-}" ]; then echo echo "optional: set a default org so you can write 'git trees init '" diff --git a/tests/smoke.sh b/tests/smoke.sh index 63d4f59..b99e5ad 100755 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -480,12 +480,68 @@ assert_ok "install.sh seeded the agents template" \ assert_eq "install.sh template matches AGENTS.md.template" \ "$(cat "$IHOME/.config/git-trees/AGENTS.md")" \ "$(cat "$REPO/AGENTS.md.template")" +assert_ok "install.sh installed the bash completion" \ + test -f "$IHOME/.config/git-trees/completions/git-trees.bash" +assert_ok "install.sh installed the zsh completion" \ + test -f "$IHOME/.config/git-trees/completions/_git-trees" +assert_eq "installed bash completion matches the source" \ + "$(cat "$IHOME/.config/git-trees/completions/git-trees.bash")" \ + "$(cat "$REPO/completions/git-trees.bash")" +assert_eq "installed zsh completion matches the source" \ + "$(cat "$IHOME/.config/git-trees/completions/_git-trees")" \ + "$(cat "$REPO/completions/_git-trees")" +assert_contains "install.sh reports where the bash completion landed" \ + "$out" ".config/git-trees/completions/git-trees.bash" +assert_contains "install.sh reports where the zsh completion landed" \ + "$out" ".config/git-trees/completions/_git-trees" +assert_contains "install.sh explains how to activate completions" \ + "$out" "to activate completions" + +# The bash completion must define the function bash-completion's git driver +# dispatches to: `git trees` -> `_git_trees` (dashes become underscores). +out=$(bash -c ' + source "$1" || exit 1 + declare -f _git_trees >/dev/null || exit 1 + COMP_WORDS=(git trees ""); COMP_CWORD=2; COMPREPLY=() + _git_trees + echo "${COMPREPLY[*]}" +' _ "$REPO/completions/git-trees.bash" 2>&1) +assert_contains "bash completion defines _git_trees and offers subcommands" "$out" "clean" +assert_contains "bash completion offers the list alias" "$out" "ls" + +out=$(bash -c ' + source "$1" || exit 1 + COMP_WORDS=(git trees clean "--"); COMP_CWORD=3; COMPREPLY=() + _git_trees + echo "${COMPREPLY[*]}" +' _ "$REPO/completions/git-trees.bash" 2>&1) +assert_contains "bash completion offers clean flags" "$out" "--merged" +assert_contains "bash completion offers --apply" "$out" "--apply" + +# Completing outside a repository must be silent and empty, never an error. +# The single quotes are deliberate: these expansions belong to the inner bash. +# shellcheck disable=SC2016 +COMP_PROBE_OUTSIDE=' + source "$1" || exit 1 + COMP_WORDS=(git trees rm ""); COMP_CWORD=3; COMPREPLY=() + _git_trees + echo "rc=$? n=${#COMPREPLY[@]}" +' +out=$(in_dir "$TMP" bash -c "$COMP_PROBE_OUTSIDE" _ "$REPO/completions/git-trees.bash" 2>&1) +assert_eq "bash completion is empty and quiet outside a repo" "$out" "rc=0 n=0" + echo CUSTOM > "$IHOME/.config/git-trees/AGENTS.md" +echo CUSTOMBASH > "$IHOME/.config/git-trees/completions/git-trees.bash" +echo CUSTOMZSH > "$IHOME/.config/git-trees/completions/_git-trees" HOME="$IHOME" bash "$REPO/install.sh" "$IDEST" >/dev/null 2>&1 rc=$? assert_eq "install.sh rerun exits 0" "$rc" "0" assert_eq "install.sh does not overwrite an existing template" \ "$(cat "$IHOME/.config/git-trees/AGENTS.md")" "CUSTOM" +assert_eq "install.sh does not overwrite an existing bash completion" \ + "$(cat "$IHOME/.config/git-trees/completions/git-trees.bash")" "CUSTOMBASH" +assert_eq "install.sh does not overwrite an existing zsh completion" \ + "$(cat "$IHOME/.config/git-trees/completions/_git-trees")" "CUSTOMZSH" # --- rm ---------------------------------------------------------------------- From cd61d9667860e56e652a5dd6c9808587459744b7 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Tue, 25 Aug 2026 13:26:23 -0400 Subject: [PATCH 2/2] Fix bash completion under Homebrew's zsh git wrapper. Use the git-completion __gitcomp API so `git trees ` offers subcommands instead of falling through to path completion. Co-authored-by: Cursor --- README.md | 21 +++-- completions/git-trees.bash | 163 ++++++++++++++++++++++++++----------- install.sh | 11 +-- tests/smoke.sh | 11 +++ 4 files changed, 145 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index a2bc894..9532a38 100644 --- a/README.md +++ b/README.md @@ -160,15 +160,17 @@ edited, so a reinstall keeps your changes. source ~/.config/git-trees/completions/git-trees.bash ``` -**zsh** — put the directory on `fpath` *before* `compinit` runs in `~/.zshrc`: +**zsh** — source the same bash file from `~/.zshrc` (after oh-my-zsh / +`bashcompinit` if you use them): ```zsh -fpath=(~/.config/git-trees/completions $fpath) -autoload -U compinit && compinit +source ~/.config/git-trees/completions/git-trees.bash ``` -If you already ran `compinit`, start a new shell (or `rm -f ~/.zcompdump` -first) so the new file is picked up. +Homebrew's `git` completion is a bash wrapper: it dispatches `git trees` to a +function named `_git_trees`, so the bash file is what `git trees ` needs. +Putting only `completions/` on `fpath` wires up the standalone `git-trees` +binary under stock zsh `_git`, but is not enough for Homebrew. Completion covers every subcommand and its own flags, and completes branch and worktree names for `rm` from git itself. Outside a repository it stays silent @@ -185,11 +187,12 @@ for f in git-trees.bash _git-trees; do done ``` -Then add the `source` line (bash) or the `fpath` line (zsh) above. +Then add the `source` line above. -The filenames are load-bearing. bash-completion dispatches `git trees` to a -function named `_git_trees`, and zsh's `_git` dispatches it to a file named -`_git-trees` on `fpath` — renaming either one silently disables completion. +The filenames are load-bearing. Git's completion dispatches `git trees` to a +function named `_git_trees`, and stock zsh's `_git` also looks for a file named +`_git-trees` on `fpath` for the standalone binary — renaming either one +silently disables completion. ## Configuration diff --git a/completions/git-trees.bash b/completions/git-trees.bash index 189eeef..0969b54 100644 --- a/completions/git-trees.bash +++ b/completions/git-trees.bash @@ -1,18 +1,18 @@ # git-trees bash completion # -# Source from ~/.bashrc (after bash-completion), or drop into a -# bash-completion completions directory as `git-trees`. +# Source from ~/.bashrc (after bash-completion), or from ~/.zshrc when using +# Homebrew's git completion (a bash wrapper). Drop into a bash-completion +# completions directory as `git-trees`. # -# The function name is not arbitrary: bash-completion's git driver dispatches -# `git ` to `_git_` with dashes turned into underscores, so -# `git trees` lands on `_git_trees`. The standalone `git-trees` binary is wired -# up separately at the bottom of this file. - -# SC2207 disabled file-wide: its suggested fix is `mapfile`, which is bash 4+, -# and this repo targets bash 3.2 (macOS system bash). The `COMPREPLY=( $(...) )` -# word-splitting idiom is the portable form and is what bash-completion itself -# uses. -# shellcheck disable=SC2207 +# The function name is not arbitrary: git's completion dispatches `git ` +# to `_git_` with dashes turned into underscores, so `git trees` lands on +# `_git_trees`. That path is shared by bash-completion and by Homebrew's zsh +# `_git` wrapper — both expect this function to speak the git-completion API +# (`$cur` / `$words` / `__gitcomp`), not raw `compgen`/`COMPREPLY`. Using +# `compgen` under the zsh wrapper leaves `_ret=1` and falls through to path +# completion. +# +# The standalone `git-trees` binary is wired up separately at the bottom. # Commands and per-subcommand flags live in one place so the two entry points # (`git trees` and `git-trees`) cannot drift apart. @@ -36,9 +36,8 @@ __git_trees_flags() { # __git_trees_flags # Worktree directory names, which are branch names slugged with `/`->`-`, so # they routinely coincide with branch names — hence the awk dedupe. The bare # container root is listed as a worktree by git but is not a removable target. -# Every git call is silenced and short-circuited so completing outside a -# repository (or in a broken one) yields an empty list rather than an error in -# the prompt. +# Every git call is silenced so completing outside a repository is empty, not +# noisy. __git_trees_worktrees() { git worktree list --porcelain 2>/dev/null | awk '/^worktree /{ @@ -57,58 +56,128 @@ __git_trees_targets() { } | awk '!seen[$0]++' } -# The shared body. $1 is the index of the `git-trees` word itself in COMP_WORDS, -# which differs between `git trees …` (1) and `git-trees …` (0). +# Prefer git-completion's __gitcomp when present (bash, and Homebrew's zsh +# wrapper which redefines it to compadd). Fall back to a COMPREPLY filler so +# tests and a bare `source` without git-completion still work. +__git_trees_comp() { + if declare -F __gitcomp >/dev/null 2>&1; then + __gitcomp "$@" + return + fi + local list="$1" prefix="${2-}" cur_="${3-$cur}" suffix="${4- }" + local c i=0 + local IFS=$' \t\n' + COMPREPLY=() + for c in $list; do + if [ "$c" = "--" ]; then + continue + fi + case "$c" in + "$cur_"*) + case "$c" in + *=|*.) COMPREPLY[i++]="${prefix}$c" ;; + *) COMPREPLY[i++]="${prefix}$c${suffix}" ;; + esac + ;; + esac + done +} + +__git_trees_comp_nl() { + if declare -F __gitcomp_nl >/dev/null 2>&1; then + __gitcomp_nl "$@" + return + fi + local list="$1" prefix="${2-}" cur_="${3-$cur}" suffix="${4- }" + local c i=0 + local IFS=$'\n' + COMPREPLY=() + for c in $list; do + case "$c" in + "$cur_"*) COMPREPLY[i++]="${prefix}$c${suffix}" ;; + esac + done +} + +# Uses git-completion locals: cur, words, cword, prev, __git_cmd_idx. +# __git_cmd_idx is the index of `trees` (or `git-trees` for the standalone). __git_trees_complete() { - local base="$1" cur prev sub i - cur="${COMP_WORDS[COMP_CWORD]}" - prev="${COMP_WORDS[COMP_CWORD-1]}" + local sub i flags - # First non-flag word after the command name is the subcommand. - sub='' - i=$((base + 1)) - while [ "$i" -lt "$COMP_CWORD" ]; do - case "${COMP_WORDS[i]}" in + sub= + i=$((__git_cmd_idx + 1)) + while [ "$i" -lt "$cword" ]; do + case "${words[i]}" in -*) ;; - *) sub="${COMP_WORDS[i]}"; break ;; + *) sub="${words[i]}"; break ;; esac i=$((i + 1)) done if [ -z "$sub" ]; then - COMPREPLY=( $(compgen -W "$__git_trees_commands" -- "$cur") ) - return 0 + __git_trees_comp "$__git_trees_commands" + return fi # --host and --dir take a value; offering flags there would be wrong. + # Returning with no completer lets the shell fall back to default/path + # completion for --dir (and for root/track positionals below). case "$prev" in - --host) COMPREPLY=(); return 0 ;; - --dir) COMPREPLY=( $(compgen -d -- "$cur") ); return 0 ;; + --host) return ;; + --dir) return ;; esac - local flags flags=$(__git_trees_flags "$sub") - if [ "${cur:0:1}" = "-" ]; then - COMPREPLY=( $(compgen -W "$flags" -- "$cur") ) - return 0 - fi + case "$cur" in + -*) + __git_trees_comp "$flags" + return + ;; + esac # Positional argument. `init` takes an org/repo or URL we cannot enumerate. case "$sub" in - rm) COMPREPLY=( $(compgen -W "$(__git_trees_targets)" -- "$cur") ) ;; - add) COMPREPLY=( $(compgen -W "$(__git_trees_targets)" -- "$cur") ) ;; - sync) COMPREPLY=( $(compgen -W "$(__git_trees_worktrees)" -- "$cur") ) ;; - root|track) COMPREPLY=( $(compgen -d -- "$cur") ) ;; - *) COMPREPLY=( $(compgen -W "$flags" -- "$cur") ) ;; + rm|add) __git_trees_comp_nl "$(__git_trees_targets)" ;; + sync) __git_trees_comp_nl "$(__git_trees_worktrees)" ;; + root|track) return ;; + *) __git_trees_comp "$flags" ;; esac - return 0 } -# bash-completion's git driver calls this with COMP_WORDS[0]="git", -# COMP_WORDS[1]="trees". -_git_trees() { __git_trees_complete 1; } +# git's completion driver (bash, and Homebrew's zsh wrapper) calls this with +# cur/words/cword/prev/__git_cmd_idx already set. When invoked from tests via +# COMP_WORDS only, bootstrap those locals so the shared body can run. +_git_trees() { + if [ -z "${words+set}" ] && [ -n "${COMP_WORDS+set}" ]; then + words=("${COMP_WORDS[@]}") + cword=$COMP_CWORD + cur="${COMP_WORDS[COMP_CWORD]}" + if [ "$COMP_CWORD" -gt 0 ]; then + prev="${COMP_WORDS[COMP_CWORD-1]}" + else + prev= + fi + __git_cmd_idx=1 + fi + __git_trees_complete +} + +# Direct invocation as `git-trees` (COMP_WORDS[0]=git-trees). +_git_trees_standalone() { + words=("${COMP_WORDS[@]}") + cword=$COMP_CWORD + cur="${COMP_WORDS[COMP_CWORD]}" + if [ "$COMP_CWORD" -gt 0 ]; then + prev="${COMP_WORDS[COMP_CWORD-1]}" + else + prev= + fi + __git_cmd_idx=0 + __git_trees_complete +} -# Direct invocation as `git-trees`. -_git_trees_standalone() { __git_trees_complete 0; } -complete -F _git_trees_standalone git-trees +# `complete` is a bash builtin; under zsh it exists only after bashcompinit. +if [ -n "${BASH_VERSION-}" ] || declare -F complete >/dev/null 2>&1; then + complete -F _git_trees_standalone git-trees +fi diff --git a/install.sh b/install.sh index aa96a5f..0649a39 100755 --- a/install.sh +++ b/install.sh @@ -31,12 +31,12 @@ ZSHCOMP="$CFG/completions/_git-trees" if [ -f "$SRC/completions/git-trees.bash" ] && [ ! -f "$BASHCOMP" ]; then mkdir -p "$CFG/completions" cp "$SRC/completions/git-trees.bash" "$BASHCOMP" - echo "installed $BASHCOMP (bash completion; source it from ~/.bashrc)" + echo "installed $BASHCOMP (completion for bash and for zsh with Homebrew git; source it from your shell rc)" fi if [ -f "$SRC/completions/_git-trees" ] && [ ! -f "$ZSHCOMP" ]; then mkdir -p "$CFG/completions" cp "$SRC/completions/_git-trees" "$ZSHCOMP" - echo "installed $ZSHCOMP (zsh completion; put its directory on fpath before compinit)" + echo "installed $ZSHCOMP (zsh completion for the standalone git-trees binary under stock zsh _git)" fi case ":$PATH:" in @@ -49,9 +49,10 @@ echo "try: git trees help" if [ -f "$BASHCOMP" ] || [ -f "$ZSHCOMP" ]; then echo - echo "to activate completions, add one of these to your shell rc:" - [ -f "$BASHCOMP" ] && echo " bash: source $BASHCOMP" - [ -f "$ZSHCOMP" ] && echo " zsh: fpath=($CFG/completions \$fpath) # before compinit" + echo "to activate completions, add this to your shell rc:" + # Homebrew's zsh git completion is a bash wrapper and needs the bash file; + # the zsh `_git-trees` on fpath only covers the standalone binary under stock zsh. + [ -f "$BASHCOMP" ] && echo " source $BASHCOMP" fi if [ -z "${TREES_ORG:-}" ]; then diff --git a/tests/smoke.sh b/tests/smoke.sh index b99e5ad..5914cd0 100755 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -518,6 +518,17 @@ out=$(bash -c ' assert_contains "bash completion offers clean flags" "$out" "--merged" assert_contains "bash completion offers --apply" "$out" "--apply" +# Under Homebrew's zsh git wrapper, _git_trees must use __gitcomp (not +# compgen/COMPREPLY). Stub the git-completion API and ensure we call it. +out=$(bash -c ' + source "$1" || exit 1 + __gitcomp() { printf "GITCOMP:%s\n" "$1"; } + words=(git trees ""); cword=2; cur=""; prev=trees; __git_cmd_idx=1 + _git_trees +' _ "$REPO/completions/git-trees.bash" 2>&1) +assert_contains "bash completion uses __gitcomp when available" "$out" "GITCOMP:" +assert_contains "bash completion __gitcomp receives subcommands" "$out" "clean" + # Completing outside a repository must be silent and empty, never an error. # The single quotes are deliberate: these expansions belong to the inner bash. # shellcheck disable=SC2016