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"
+}