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..9532a38 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,52 @@ 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** — source the same bash file from `~/.zshrc` (after oh-my-zsh / +`bashcompinit` if you use them): + +```zsh +source ~/.config/git-trees/completions/git-trees.bash +``` + +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 +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 above. + +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 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..0969b54 --- /dev/null +++ b/completions/git-trees.bash @@ -0,0 +1,183 @@ +# git-trees bash completion +# +# 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: 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. +__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 so completing outside a repository is empty, not +# noisy. +__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]++' +} + +# 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 sub i flags + + sub= + i=$((__git_cmd_idx + 1)) + while [ "$i" -lt "$cword" ]; do + case "${words[i]}" in + -*) ;; + *) sub="${words[i]}"; break ;; + esac + i=$((i + 1)) + done + + if [ -z "$sub" ]; then + __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) return ;; + --dir) return ;; + esac + + flags=$(__git_trees_flags "$sub") + + 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|add) __git_trees_comp_nl "$(__git_trees_targets)" ;; + sync) __git_trees_comp_nl "$(__git_trees_worktrees)" ;; + root|track) return ;; + *) __git_trees_comp "$flags" ;; + esac +} + +# 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 +} + +# `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 98363a4..0649a39 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 (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 for the standalone git-trees binary under stock zsh _git)" +fi + case ":$PATH:" in *":$DEST:"*) ;; *) echo "warning: $DEST is not on PATH — add it to use \`git trees\`" >&2 ;; @@ -32,6 +47,14 @@ esac echo echo "try: git trees help" +if [ -f "$BASHCOMP" ] || [ -f "$ZSHCOMP" ]; then + echo + 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 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..5914cd0 100755 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -480,12 +480,79 @@ 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" + +# 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 +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 ----------------------------------------------------------------------