Skip to content
Open
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
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`):
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 "$@"
114 changes: 114 additions & 0 deletions completions/git-trees.bash
Original file line number Diff line number Diff line change
@@ -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 <cmd>` to `_git_<cmd>` 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 <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 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
22 changes: 22 additions & 0 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 ;;
Expand All @@ -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 <repo>'"
Expand Down
Loading
Loading