Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand All @@ -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
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <TAB>` 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`):
Expand Down
120 changes: 120 additions & 0 deletions completions/_git-trees
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
#compdef git-trees
# zsh completion for git-trees.
#
# The filename is not arbitrary: zsh's `_git` dispatches `git <cmd>` by calling
# a function named `_git-<cmd>`, 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 "$@"
183 changes: 183 additions & 0 deletions completions/git-trees.bash
Original file line number Diff line number Diff line change
@@ -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 <cmd>`
# to `_git_<cmd>` 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 <subcommand>
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
Loading
Loading