From a1d0c5a09c426dbfc0a1fd27ed7561a1bc153bfb Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Fri, 21 Aug 2026 12:05:07 -0400 Subject: [PATCH] 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 ----------------------------------------------------------------------