diff --git a/worktree/_worktree b/worktree/_worktree index 5887ce4..7093943 100644 --- a/worktree/_worktree +++ b/worktree/_worktree @@ -36,6 +36,7 @@ _worktree() { local commands; commands=( 'add:Create a worktree for a branch' 'cd:Change directory to a worktree by branch' + 'find:Find a path in every worktree' 'list:List managed worktrees' 'ls:List managed worktrees' 'remove:Remove a worktree by branch' @@ -53,6 +54,11 @@ _worktree() { (cd) _arguments '1:: :__worktree_all_branches' ;; + (find) + _arguments \ + '(-i --ignore-case)'{-i,--ignore-case}'[Match the glob case insensitively]' \ + '1:: :_files' + ;; (rm|remove) _arguments \ '(-f --force)'{-f,--force}'[Force removal even with modified or untracked files]' \ diff --git a/worktree/worktree.plugin.zsh b/worktree/worktree.plugin.zsh index a45d8f1..38ca41d 100644 --- a/worktree/worktree.plugin.zsh +++ b/worktree/worktree.plugin.zsh @@ -1,9 +1,92 @@ +# Emit one "pathbranchhead" row per worktree, main clone first, +# branch empty when the worktree is detached. +__worktree_list() { + git worktree list --porcelain | awk ' + /^worktree /{ p = substr($0, 10); b = ""; h = "" } + /^HEAD /{ h = substr($0, 6) } + /^branch refs\/heads\//{ b = $0; sub(/^branch refs\/heads\//, "", b) } + /^$/{ if (p != "") { print p "\t" b "\t" h; p = "" } } + END{ if (p != "") print p "\t" b "\t" h } + ' +} + +# Fill the caller's paths, names, dirs and mismatched arrays with an entry per +# worktree. names is the checked out branch, or its short hash when detached. +# dirs is @ for a worktree directory of the form
@, +# and the directory name otherwise. mismatched is 1 when that suffix and the +# branch differ. With -m, only worktrees with such a suffix are returned. +__worktree_labels() { + local managed_only=0 + [[ "$1" == "-m" ]] && managed_only=1 + local -a rows + rows=("${(@f)$(__worktree_list)}") + local main=${rows[1]%%$'\t'*} + local prefix="${main:h}/${main:t}@" + local row p b h name suffix dir mm + for row in "${rows[@]}"; do + [[ -z "$row" ]] && continue + p=${row%%$'\t'*} + b=${${row#*$'\t'}%%$'\t'*} + h=${row##*$'\t'} + if [[ -n "$b" ]]; then + name=$b + else + name="(detached ${h[1,7]})" + fi + if [[ "${p[1,${#prefix}]}" == "$prefix" ]]; then + suffix=${p[${#prefix}+1,-1]} + dir="@$suffix" + if [[ "$b" == "$suffix" ]]; then + mm=0 + else + mm=1 + fi + elif (( managed_only )); then + continue + else + dir=${p:t} + mm=0 + fi + paths+=("$p") + names+=("$name") + dirs+=("$dir") + mismatched+=($mm) + done +} + +__worktree_find_in() { + local dir=$1 pat=$2 icase=$3 + if (( $+commands[fd] )); then + local -a fd_args=( + --base-directory "$dir" --hidden --no-ignore --exclude .git --glob + ) + if (( icase )); then + fd_args+=(--ignore-case) + else + fd_args+=(--case-sensitive) + fi + if [[ "$pat" == */* ]]; then + fd "${fd_args[@]}" --full-path "**/$pat" + else + fd "${fd_args[@]}" "$pat" + fi + else + local ic= + (( icase )) && ic=i + local -a expr=(-${ic}name "$pat") + [[ "$pat" == */* ]] && expr=(-${ic}path "*/$pat") + (cd "$dir" && find . -name .git -prune -o "${expr[@]}" -print) + fi +} + worktree() { if [[ "$1" == "" ]]; then - echo "usage: worktree {add,cd,list,remove,sync} [...]" + echo "usage: worktree {add,cd,list,remove,sync} [...] + worktree find [-i] " return 1 elif [[ "$1" == "-h" ]] || [[ "$1" == "--help" ]]; then echo "usage: worktree {add,cd,list,remove,sync} [...] + worktree find [-i] Manage git worktrees by branch name." return 0 @@ -17,7 +100,7 @@ Manage git worktrees by branch name." return 1 fi - if [[ "$cmd" != "cd" ]] && \ + if [[ "$cmd" != "cd" ]] && [[ "$cmd" != "find" ]] && \ [[ "$(git rev-parse --is-inside-work-tree 2>/dev/null)" == "true" ]] && \ [[ "$(git rev-parse --absolute-git-dir)" != "$(git rev-parse --show-toplevel)/.git" ]] then @@ -64,42 +147,102 @@ Manage git worktrees by branch name." cd "$wt_path" ;; - list|ls) - local root=$(git rev-parse --show-toplevel) - local prefix="${root:h}/${root:t}@" - local -a names dirs mismatched - local maxw=0 - local row p b h name suffix - for row in "${(@f)$(git worktree list --porcelain | awk ' - /^worktree /{ p = substr($0, 10); b = ""; h = "" } - /^HEAD /{ h = substr($0, 6) } - /^branch refs\/heads\//{ b = $0; sub(/^branch refs\/heads\//, "", b) } - /^$/{ if (p != "") { print p "\t" b "\t" h; p = "" } } - END{ if (p != "") print p "\t" b "\t" h } - ')}"; do - [[ -z "$row" ]] && continue - p=${row%%$'\t'*} - b=${${row#*$'\t'}%%$'\t'*} - h=${row##*$'\t'} - [[ "${p[1,${#prefix}]}" == "$prefix" ]] || continue - suffix=${p[${#prefix}+1,-1]} - if [[ -n "$b" ]]; then - name=$b + find) + local icase=0 + local -a globs + local arg + for arg in "${@:2}"; do + case "$arg" in + -i|--ignore-case) icase=1 ;; + *) globs+=("$arg") ;; + esac + done + if (( ${#globs} == 0 )); then + print -P "%F{red}error:%f missing argument" + return 1 + fi + if (( ${#globs} > 1 )); then + print -P "%F{red}error:%f unexpected argument: ${globs[2]}" + return 1 + fi + local pattern=${globs[1]} + # Matching is on the tail of a path, so leading and trailing slashes + # carry no meaning. + local pat=$pattern + while [[ "$pat" == /* ]]; do pat=${pat#/}; done + while [[ "$pat" == */ ]]; do pat=${pat%/}; done + if [[ -z "$pat" ]]; then + print -P "%F{red}error:%f invalid argument: $pattern" + return 1 + fi + + local -a paths names dirs mismatched + __worktree_labels + + # Search every worktree at once, each into its own file so that the + # results don't interleave. + local REPORTTIME=-1 + local tmp + tmp=$(mktemp -d "${TMPDIR:-/tmp}/worktree-find.XXXXXX") || return 1 + local -a results + local i + { + ( + for (( i = 1; i <= ${#paths}; i++ )); do + __worktree_find_in "${paths[i]}" "$pat" "$icase" | sort >"$tmp/$i" & + done + wait + ) + for (( i = 1; i <= ${#paths}; i++ )); do + results+=("$(<$tmp/$i)") + done + } always { + rm -rf "$tmp" + } + + # Report every worktree with matches under the same heading list prints, + # followed by its matches as absolute paths. + local c_dir c_warn c_off + if [[ -t 1 ]]; then + c_dir=$(print -Pn '%F{8}') + c_warn=$(print -Pn '%F{yellow}') + c_off=$(print -Pn '%f') + fi + local found=0 line root c + for (( i = 1; i <= ${#paths}; i++ )); do + [[ -n "${results[i]}" ]] || continue + (( found )) && print + found=1 + root=${paths[i]} + [[ "$root" == "$HOME" || "$root" == "$HOME"/* ]] && root="~${root#$HOME}" + if (( mismatched[i] )); then + c=$c_warn else - name="(detached ${h[1,7]})" + c=$c_dir fi - names+=("$name") - dirs+=("@$suffix") - if [[ "$b" == "$suffix" ]]; then - mismatched+=(0) - else - mismatched+=(1) - fi - (( ${#name} > maxw )) && maxw=${#name} + print -r -- "${names[i]} ${c}${dirs[i]}${c_off}" + for line in "${(@f)${results[i]}}"; do + print -r -- " $root/${line#./}" + done done + if (( ! found )); then + print -u2 -- "no matches: $pattern" + return 1 + fi + ;; + + list|ls) + local -a paths names dirs mismatched + __worktree_labels -m + (( ${#names} )) || return 0 + local maxw=0 name + for name in "${names[@]}"; do + (( ${#name} > maxw )) && maxw=${#name} + done + local c_dir c_warn c_off if [[ -t 1 ]]; then c_dir=$(print -Pn '%F{8}') @@ -236,3 +379,6 @@ Manage git worktrees by branch name." ;; esac } + +# A is matched against every worktree, never the current directory. +alias worktree='noglob worktree'