feat: add interactive git worktree operations

Add worktree browser (gwt) and worktree delete selector (gwd) with:
- Interactive worktree list with lock status, branch info, and age
- Preview showing working tree status and recent commits
- Ctrl-Y to copy worktree path, Alt-L to toggle lock/unlock
- Shell integration for zsh (with cd) and fish (with cd)
- Tab completions for zsh, bash, and fish
- Documentation in README with keybindings and options

Co-Authored-By: Sufien Tout <sufientout@gmail.com>
This commit is contained in:
Wenxuan Zhang 2026-02-27 20:31:32 +08:00 committed by Wenxuan
parent ea58599e66
commit fc081846c0
8 changed files with 229 additions and 3 deletions

View file

@ -69,6 +69,8 @@ It's **lightweight** and **easy to use**.
| `gsq` | Interactive `git commit --squash && git rebase -i --autosquash` selector |
| `grw` | Interactive `git commit --fixup=reword && git rebase -i --autosquash` selector |
| `gclean` | Interactive `git clean` selector |
| `gwt` | Interactive `git worktree` selector |
| `gwd` | Interactive `git worktree remove` selector |
# 📥 Installation
@ -197,6 +199,8 @@ forgit_blame=gbl
forgit_fixup=gfu
forgit_squash=gsq
forgit_reword=grw
forgit_worktree=gwt
forgit_worktree_delete=gwd
```
## Git Integration
@ -271,6 +275,8 @@ Each forgit command can be customized with dedicated environment variables for g
| `gfu` | `FORGIT_FIXUP_GIT_OPTS` | `FORGIT_FIXUP_FZF_OPTS` |
| `gsq` | `FORGIT_SQUASH_GIT_OPTS` | `FORGIT_SQUASH_FZF_OPTS` |
| `grw` | `FORGIT_REWORD_GIT_OPTS` | `FORGIT_REWORD_FZF_OPTS` |
| `gwt` | | `FORGIT_WORKTREE_FZF_OPTS` |
| `gwd` | `FORGIT_WORKTREE_DELETE_GIT_OPTS` | `FORGIT_WORKTREE_DELETE_FZF_OPTS` |
## Pagers
@ -352,15 +358,19 @@ export FORGIT_LOG_FZF_OPTS='
| <kbd>Alt</kbd> - <kbd>W</kbd> | Toggle preview wrap |
| <kbd>Ctrl</kbd> - <kbd>S</kbd> | Toggle sort |
| <kbd>Ctrl</kbd> - <kbd>R</kbd> | Toggle selection |
| <kbd>Ctrl</kbd> - <kbd>Y</kbd> | Copy commit hash/stash ID* |
| <kbd>Ctrl</kbd> - <kbd>Y</kbd> | Copy commit hash/stash ID/worktree path<sup>1</sup> |
| <kbd>Ctrl</kbd> - <kbd>K</kbd> / <kbd>P</kbd> | Selection move up |
| <kbd>Ctrl</kbd> - <kbd>J</kbd> / <kbd>N</kbd> | Selection move down |
| <kbd>Alt</kbd> - <kbd>K</kbd> / <kbd>P</kbd> | Preview move up |
| <kbd>Alt</kbd> - <kbd>J</kbd> / <kbd>N</kbd> | Preview move down |
| <kbd>Alt</kbd> - <kbd>E</kbd> | Open file in default editor (when possible) |
| <kbd>Alt</kbd> - <kbd>T</kbd> | Show commit message (when viewing a commit) |
| <kbd>Alt</kbd> - <kbd>L</kbd> | Toggle worktree lock/unlock<sup>2</sup> |
<sup>1</sup> Available when the selection contains a commit hash, stash ID, or worktree path.
<sup>2</sup> Available in the worktree browser (`gwt`) and worktree delete selector (`gwd`).
\* Available when the selection contains a commit hash or a stash ID.
For Linux users `FORGIT_COPY_CMD` should be set to make copy work. Example: `FORGIT_COPY_CMD='xclip -selection clipboard'`.
# 📦 Optional dependencies

View file

@ -32,6 +32,8 @@ $FORGIT_FZF_DEFAULT_OPTS
_forgit_warn() { printf "%b[Warn]%b %s\n" '\e[0;33m' '\e[0m' "$@" >&2; }
_forgit_info() { printf "%b[Info]%b %s\n" '\e[0;32m' '\e[0m' "$@" >&2; }
_forgit_inside_work_tree() { git rev-parse --is-inside-work-tree >/dev/null 2>&1; }
_forgit_inside_git_dir() { git rev-parse --is-inside-git-dir >/dev/null 2>&1; }
_forgit_inside_git_repo() { _forgit_inside_work_tree || _forgit_inside_git_dir; }
# tac is not available on OSX, tail -r is not available on Linux, so we use either of them
_forgit_reverse_lines() { tac 2> /dev/null || tail -r; }
_forgit_strip_ansi() { local ESC=$'\033'; sed "s/${ESC}\[[0-9;]*m//g"; }
@ -1259,6 +1261,146 @@ _forgit_paths_list() {
find "$path" -name "*$ext" -print |sed -e "s#$ext\$##" -e 's#.*/##' -e '/^$/d' | sort -fu
}
# Parse git worktree list --porcelain output and format it for display
# Output format: [XY] /path/to/worktree (branch) 3 hours ago
# X: '*' = current worktree, ' ' = other
# Y: 'L' (yellow) = locked, 'P' (yellow) = prunable, ' ' = normal
# When both locked and prunable, 'L' takes precedence
_forgit_worktree_list() {
local worktree head branch locked prunable line relative_date
local _cyan=$'\033[36m' _gray=$'\033[90m' _yellow=$'\033[33m' _reset=$'\033[0m'
local current_worktree
current_worktree=$(git rev-parse --show-toplevel 2>/dev/null)
git worktree list --porcelain | while IFS= read -r line; do
case "$line" in
"worktree "*)
worktree="${line#worktree }"
head="" branch="" locked="" prunable=""
;;
"HEAD "*)
head="${line#HEAD }"
;;
"branch "*)
branch="${line#branch refs/heads/}"
;;
"detached")
branch="detached"
;;
"locked"*)
locked="${_yellow}L${_reset}"
;;
"prunable"*)
prunable="${_yellow}P${_reset}"
;;
"")
relative_date=$(git log -1 --format='%cr' "$head" 2>/dev/null)
local current_marker=" " lock_marker=" "
[[ "$worktree" == "$current_worktree" ]] && current_marker="*"
[[ -n "$prunable" ]] && lock_marker="$prunable"
[[ -n "$locked" ]] && lock_marker="$locked"
printf "[%s%s] %s ${_cyan}(%s)${_reset} ${_gray}%s${_reset}\n" \
"$current_marker" "$lock_marker" "$worktree" "${branch:-HEAD}" "$relative_date"
;;
esac
done
}
# Return deletable worktrees (exclude main worktree which is the first one)
_forgit_worktree_list_deletable() {
_forgit_worktree_list | tail -n +2
}
# Extract worktree path from formatted line (strip ANSI codes, skip 5-char prefix '[XY] ')
# TODO: awk '{print $1}' breaks on paths containing spaces or parentheses.
# Consider switching _forgit_worktree_list to a tab-delimited format so we can
# use 'cut -f1' (or awk -F'\t') for reliable path extraction.
_forgit_extract_worktree_path() {
_forgit_strip_ansi | cut -c6- | awk '{print $1}'
}
# Copy worktree path to clipboard
_forgit_worktree_yank_path() {
echo "$1" | _forgit_extract_worktree_path | ${FORGIT_COPY_CMD:-pbcopy}
}
# Toggle worktree lock status (check 3rd char 'L' in prefix '[XY]')
_forgit_worktree_toggle_lock() {
local line="$1" worktree stripped
worktree=$(echo "$line" | _forgit_extract_worktree_path)
stripped=$(echo "$line" | _forgit_strip_ansi)
if [[ "${stripped:2:1}" == "L" ]]; then
git worktree unlock "$worktree"
else
git worktree lock "$worktree"
fi
}
# Preview function for worktree
_forgit_worktree_preview() {
local worktree
worktree=$(echo "$1" | _forgit_extract_worktree_path)
[[ ! -d "$worktree" ]] && echo "Worktree directory not found: $worktree" && return 1
local status_output
status_output=$(git -c color.status=always -C "$worktree" status -s 2>/dev/null)
[[ -n "$status_output" ]] && echo "$status_output" && echo ""
git -C "$worktree" log --oneline -n 200 --color=always 2>/dev/null
}
# Git worktree delete wrapper
_forgit_git_worktree_delete() {
_forgit_worktree_delete_git_opts=()
_forgit_parse_array _forgit_worktree_delete_git_opts "$FORGIT_WORKTREE_DELETE_GIT_OPTS"
git worktree remove "${_forgit_worktree_delete_git_opts[@]}" "$@"
}
# git worktree browser
# Note: we intentionally do NOT use --header-lines=1 here, because the current
# worktree (listed first) should remain selectable for operations like lock/unlock.
_forgit_worktree() {
_forgit_inside_git_repo || return 1
local opts worktree
[[ $# -ne 0 ]] && { git worktree "$@"; return $?; }
opts="
$FORGIT_FZF_DEFAULT_OPTS
+s +m --tiebreak=index
--preview=\"$FORGIT worktree_preview {}\"
--bind=\"ctrl-y:execute-silent($FORGIT worktree_yank_path {})\"
--bind=\"alt-l:execute-silent($FORGIT worktree_toggle_lock {})+reload($FORGIT worktree_list)\"
$FORGIT_WORKTREE_FZF_OPTS
"
worktree=$(_forgit_worktree_list | FZF_DEFAULT_OPTS="$opts" fzf)
[[ -z "$worktree" ]] && return 1
echo "$worktree" | _forgit_extract_worktree_path
}
# git worktree delete selector
_forgit_worktree_delete() {
_forgit_inside_git_repo || return 1
local opts worktrees
[[ $# -ne 0 ]] && { _forgit_git_worktree_delete "$@"; return $?; }
opts="
$FORGIT_FZF_DEFAULT_OPTS
+s --multi --tiebreak=index
--preview=\"$FORGIT worktree_preview {}\"
--bind=\"ctrl-y:execute-silent($FORGIT worktree_yank_path {})\"
$FORGIT_WORKTREE_DELETE_FZF_OPTS
"
worktrees=()
while IFS='' read -r line; do
[[ -n "$line" ]] && worktrees+=("$(echo "$line" | _forgit_extract_worktree_path)")
done < <(_forgit_worktree_list_deletable | FZF_DEFAULT_OPTS="$opts" fzf)
[[ ${#worktrees[@]} -eq 0 ]] && return 1
for worktree in "${worktrees[@]}"; do
_forgit_git_worktree_delete "$worktree"
done
}
check_prequisites() {
local installed_fzf_version
local higher_fzf_version
@ -1332,6 +1474,8 @@ PUBLIC_COMMANDS=(
"show"
"stash_show"
"stash_push"
"worktree"
"worktree_delete"
)
PRIVATE_COMMANDS=(
@ -1363,6 +1507,10 @@ PRIVATE_COMMANDS=(
"edit_diffed_file"
"edit_add_file"
"pager"
"worktree_preview"
"worktree_yank_path"
"worktree_toggle_lock"
"worktree_list"
)
# Check if the script is being sourced. This is necessary for unit tests where

View file

@ -18,6 +18,10 @@ _git-stash-show() {
_alternative "files:filename:($(git stash list | sed -n -e 's/:.*//p'))"
}
_git-worktrees() {
_alternative "worktrees:worktree:($(git worktree list --porcelain 2>/dev/null | grep '^worktree ' | cut -d' ' -f2-))"
}
# The completions for git already define a _git-diff completion function, but
# it provides the wrong results when called from _git-forgit because it heavily
# depends on the context it's been called from (usage of $curcontext and
@ -79,6 +83,8 @@ _git-forgit() {
'squash:git squash'
'stash_show:git stash viewer'
'stash_push:git stash push selector'
'worktree:git worktree browser'
'worktree_delete:git worktree remove selector'
)
_describe -t commands 'git forgit' subcommands
;;
@ -102,6 +108,8 @@ _git-forgit() {
squash) _git-log ;;
stash_show) _git-stash-show ;;
show) _git-show ;;
worktree) ;;
worktree_delete) _git-worktrees ;;
esac
}
@ -131,6 +139,7 @@ compdef _git-log forgit::reword
compdef _git-log forgit::squash
compdef _git-stash-show forgit::stash::show
compdef _git-show forgit::show
compdef _git-worktrees forgit::worktree::delete
# this is the case of calling the command and pressing tab
# the very first time of a shell session, we have to manually

View file

@ -35,6 +35,11 @@ _git_stash_show()
__gitcomp_nl "$(__git stash list | sed -n -e 's/:.*//p')"
}
_git_worktrees()
{
__gitcomp_nl "$(__git worktree list --porcelain 2>/dev/null | grep '^worktree ' | cut -d' ' -f2-)"
}
# Completion for git-forgit
# This includes git aliases, e.g. "alias.cb=forgit checkout_branch" will
# correctly complete available branches on "git cb".
@ -80,6 +85,8 @@ _git_forgit()
squash
stash_show
stash_push
worktree
worktree_delete
"
case ${cword} in
@ -108,6 +115,7 @@ _git_forgit()
show) _git_show ;;
squash) _git_log ;;
stash_show) _git_stash_show ;;
worktree_delete) _git_worktrees ;;
esac
;;
*)
@ -146,6 +154,7 @@ then
__git_complete forgit::show _git_show
__git_complete forgit::squash _git_log
__git_complete forgit::stash::show _git_stash_show
__git_complete forgit::worktree::delete _git_worktrees
# Completion for forgit plugin shell aliases
if [[ -z "$FORGIT_NO_ALIASES" ]]; then
@ -169,5 +178,6 @@ then
__git_complete "${forgit_show}" _git_show
__git_complete "${forgit_squash}" _git_log
__git_complete "${forgit_stash_show}" _git_stash_show
__git_complete "${forgit_worktree_delete}" _git_worktrees
fi
fi

View file

@ -8,7 +8,7 @@
function __fish_forgit_needs_subcommand
for subcmd in add blame branch_delete checkout_branch checkout_commit checkout_file checkout_tag \
cherry_pick cherry_pick_from_branch clean diff fixup ignore log reflog rebase reset_head \
revert_commit reword squash stash_show stash_push switch_branch
revert_commit reword squash stash_show stash_push switch_branch worktree worktree_delete
if contains -- $subcmd (commandline -opc)
return 1
end
@ -16,6 +16,10 @@ function __fish_forgit_needs_subcommand
return 0
end
function __fish_forgit_worktrees
git worktree list --porcelain 2>/dev/null | string match -r '^worktree .+' | string replace 'worktree ' ''
end
# Load helper functions in git completion file
not functions -q __fish_git && source $__fish_data_dir/completions/git.fish
@ -46,6 +50,8 @@ complete -c git-forgit -n __fish_forgit_needs_subcommand -a squash -d 'git squas
complete -c git-forgit -n __fish_forgit_needs_subcommand -a stash_show -d 'git stash viewer'
complete -c git-forgit -n __fish_forgit_needs_subcommand -a stash_push -d 'git stash push selector'
complete -c git-forgit -n __fish_forgit_needs_subcommand -a switch_branch -d 'git switch branch selector'
complete -c git-forgit -n __fish_forgit_needs_subcommand -a worktree -d 'git worktree browser'
complete -c git-forgit -n __fish_forgit_needs_subcommand -a worktree_delete -d 'git worktree remove selector'
complete -c git-forgit -n '__fish_seen_subcommand_from add' -a "(complete -C 'git add ')"
complete -c git-forgit -n '__fish_seen_subcommand_from branch_delete' -a "(__fish_git_local_branches)"
@ -68,3 +74,4 @@ complete -c git-forgit -n '__fish_seen_subcommand_from squash' -a "(complete -C
complete -c git-forgit -n '__fish_seen_subcommand_from stash_show' -a "(__fish_git_complete_stashes)"
complete -c git-forgit -n '__fish_seen_subcommand_from stash_push' -a "(__fish_git_files modified deleted modified-staged-deleted)"
complete -c git-forgit -n '__fish_seen_subcommand_from switch_branch' -a "(complete -C 'git switch ')"
complete -c git-forgit -n '__fish_seen_subcommand_from worktree_delete' -a "(__fish_forgit_worktrees)"

View file

@ -30,6 +30,15 @@ end
# alias `git-forgit` to the full-path of the command
alias git-forgit "$FORGIT"
function forgit::worktree
if test (count $argv) -ne 0
git-forgit worktree $argv
return $status
end
set -l tree (git-forgit worktree)
test -n "$tree"; and cd "$tree"
end
# register abbreviations
if test -z "$FORGIT_NO_ALIASES"
abbr -a -- (string collect $forgit_add; or string collect "ga") git-forgit add
@ -56,4 +65,6 @@ if test -z "$FORGIT_NO_ALIASES"
abbr -a -- (string collect $forgit_revert_commit; or string collect "grc") git-forgit revert_commit
abbr -a -- (string collect $forgit_blame; or string collect "gbl") git-forgit blame
abbr -a -- (string collect $forgit_checkout_tag; or string collect "gct") git-forgit checkout_tag
abbr -a -- (string collect $forgit_worktree; or string collect "gwt") forgit::worktree
abbr -a -- (string collect $forgit_worktree_delete; or string collect "gwd") git-forgit worktree_delete
end

View file

@ -160,6 +160,17 @@ forgit::attributes() {
"$FORGIT" attributes "$@"
}
forgit::worktree() {
if [[ $# -ne 0 ]]; then "$FORGIT" worktree "$@"; return $?; fi
local tree
tree=$("$FORGIT" worktree) || return $?
[[ -n "$tree" ]] && builtin cd "$tree" || return 1
}
forgit::worktree::delete() {
"$FORGIT" worktree_delete "$@"
}
# register aliases
# shellcheck disable=SC2139
if [[ -z "$FORGIT_NO_ALIASES" ]]; then
@ -188,6 +199,8 @@ if [[ -z "$FORGIT_NO_ALIASES" ]]; then
builtin export forgit_squash="${forgit_squash:-gsq}"
builtin export forgit_reword="${forgit_reword:-grw}"
builtin export forgit_blame="${forgit_blame:-gbl}"
builtin export forgit_worktree="${forgit_worktree:-gwt}"
builtin export forgit_worktree_delete="${forgit_worktree_delete:-gwd}"
builtin alias "${forgit_add}"='forgit::add'
builtin alias "${forgit_reset_head}"='forgit::reset::head'
@ -213,5 +226,7 @@ if [[ -z "$FORGIT_NO_ALIASES" ]]; then
builtin alias "${forgit_squash}"='forgit::squash'
builtin alias "${forgit_reword}"='forgit::reword'
builtin alias "${forgit_blame}"='forgit::blame'
builtin alias "${forgit_worktree}"='forgit::worktree'
builtin alias "${forgit_worktree_delete}"='forgit::worktree::delete'
fi

View file

@ -39,3 +39,19 @@ function provider_strip_ansi() {
bashunit::data_set '\e[1;31mbar\e[0m' "bar"
bashunit::data_set 'no-ansi' "no-ansi"
}
# --- _forgit_extract_worktree_path ---
# @data_provider provider_extract_worktree_path
function test_forgit_extract_worktree_path() {
local -r input="$1"
local -r expected="$2"
local actual
actual=$(echo "$input" | _forgit_extract_worktree_path)
assert_same "$expected" "$actual"
}
function provider_extract_worktree_path() {
bashunit::data_set '[* ] /tmp/foo (main) 3 hours ago' "/tmp/foo"
bashunit::data_set '[ ] /tmp/bar (feature) 1 day ago' "/tmp/bar"
}