From fc081846c006a26336b935799ba74d6c6355e969 Mon Sep 17 00:00:00 2001 From: Wenxuan Zhang Date: Fri, 27 Feb 2026 20:31:32 +0800 Subject: [PATCH] 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 --- README.md | 14 +++- bin/git-forgit | 148 +++++++++++++++++++++++++++++++++++ completions/_git-forgit | 9 +++ completions/git-forgit.bash | 10 +++ completions/git-forgit.fish | 9 ++- conf.d/forgit.plugin.fish | 11 +++ forgit.plugin.zsh | 15 ++++ tests/branch-helpers.test.sh | 16 ++++ 8 files changed, 229 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d64f6d1..495aed9 100644 --- a/README.md +++ b/README.md @@ -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=' | Alt - W | Toggle preview wrap | | Ctrl - S | Toggle sort | | Ctrl - R | Toggle selection | -| Ctrl - Y | Copy commit hash/stash ID* | +| Ctrl - Y | Copy commit hash/stash ID/worktree path1 | | Ctrl - K / P | Selection move up | | Ctrl - J / N | Selection move down | | Alt - K / P | Preview move up | | Alt - J / N | Preview move down | | Alt - E | Open file in default editor (when possible) | | Alt - T | Show commit message (when viewing a commit) | +| Alt - L | Toggle worktree lock/unlock2 | + +1 Available when the selection contains a commit hash, stash ID, or worktree path. + +2 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 diff --git a/bin/git-forgit b/bin/git-forgit index e5c713d..1b252c4 100755 --- a/bin/git-forgit +++ b/bin/git-forgit @@ -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 diff --git a/completions/_git-forgit b/completions/_git-forgit index ee80c88..9a1d9a7 100644 --- a/completions/_git-forgit +++ b/completions/_git-forgit @@ -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 diff --git a/completions/git-forgit.bash b/completions/git-forgit.bash index 7a508ce..050783a 100755 --- a/completions/git-forgit.bash +++ b/completions/git-forgit.bash @@ -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 diff --git a/completions/git-forgit.fish b/completions/git-forgit.fish index 48f995c..7827f24 100644 --- a/completions/git-forgit.fish +++ b/completions/git-forgit.fish @@ -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)" diff --git a/conf.d/forgit.plugin.fish b/conf.d/forgit.plugin.fish index 2ffd429..a0205e2 100644 --- a/conf.d/forgit.plugin.fish +++ b/conf.d/forgit.plugin.fish @@ -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 diff --git a/forgit.plugin.zsh b/forgit.plugin.zsh index 012c354..a928fa1 100755 --- a/forgit.plugin.zsh +++ b/forgit.plugin.zsh @@ -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 diff --git a/tests/branch-helpers.test.sh b/tests/branch-helpers.test.sh index 222709c..8752128 100644 --- a/tests/branch-helpers.test.sh +++ b/tests/branch-helpers.test.sh @@ -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" +}