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
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
The MIT License (MIT)

Copyright © 2017-2021 Wenxuan Zhang <wenxuangm@gmail.com>
Copyright © 2017-2026 Wenxuan Zhang <wenxuangm@gmail.com>

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the “Software”), to deal
Expand Down
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,12 @@ If you're having issues after updating, and commands such as `forgit::add` or al

- **Interactive `git commit --fixup=reword && git rebase -i --autosquash` selector** (`grw`)

- **Interactive `git worktree` selector** (`gwt`)

Select a worktree and `cd` into it. This command can only be used via the alias as it needs to change the current shell's working directory.

- **Interactive `git worktree remove` selector** (`gwd`)

# ⌨ Keybindings

| Key | Action |
Expand All @@ -189,14 +195,18 @@ If you're having issues after updating, and commands such as `forgit::add` or al
| <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>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> Only available in the worktree browser (`gwt`).

\* 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'`.

# ⚙ Options
Expand Down Expand Up @@ -240,6 +250,8 @@ forgit_blame=gbl
forgit_fixup=gfu
forgit_squash=gsq
forgit_reword=grw
forgit_worktree=gwt
forgit_worktree_delete=gwd
```

## git integration
Expand Down Expand Up @@ -302,6 +314,7 @@ These are passed to the according `git` calls.
| `gsq` | `FORGIT_SQUASH_GIT_OPTS` |
| `grw` | `FORGIT_REWORD_GIT_OPTS` |
| `gcp` | `FORGIT_CHERRY_PICK_GIT_OPTS` |
| `gwd` | `FORGIT_WORKTREE_DELETE_GIT_OPTS` |

## pagers

Expand Down Expand Up @@ -362,6 +375,8 @@ Customizing fzf options for each command individually is also supported:
| `gsq` | `FORGIT_SQUASH_FZF_OPTS` |
| `grw` | `FORGIT_REWORD_FZF_OPTS` |
| `gcp` | `FORGIT_CHERRY_PICK_FZF_OPTS` |
| `gwt` | `FORGIT_WORKTREE_FZF_OPTS` |
| `gwd` | `FORGIT_WORKTREE_DELETE_FZF_OPTS` |

Complete loading order of fzf options is:

Expand Down
213 changes: 193 additions & 20 deletions bin/git-forgit
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,12 @@ $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; }
_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"; }

_forgit_previous_commit() {
# "SHA~" is invalid when the commit is the first commit, but we can use "--root" instead
Expand Down Expand Up @@ -151,6 +154,33 @@ _forgit_is_file_tracked() {
git ls-files "$1" --error-unmatch &> /dev/null
}

# List branches with current branch first (for use as fzf header)
# Usage: _forgit_branch_list [git-branch-options...]
#
# Note: We explicitly print the current branch first rather than using
# `LC_ALL=C sort -k1.1,1.1 -rs` because git branch output has three possible
# prefixes: '*' (current), '+' (checked out in a worktree), and ' ' (other).
# Sorting by the first character doesn't reliably place '*' first when '+'
# is present, since their ASCII order (* < +) conflicts with the desired order.
_forgit_branch_list() {
local current
current=$(git branch --show-current)
if [[ -n "$current" ]]; then
printf '\e[90m%s\e[0m\n' "* $current"
else
printf '\e[90m%s\e[0m\n' "* (HEAD detached at $(git rev-parse --short HEAD))"
fi
git branch --color=always "$@" | grep -v '^\*' | grep -v ' -> '
}

# Extract branch name from git branch output
# Handles ANSI escape codes, prefix characters (* + ' '), and symbolic refs (->)
_forgit_extract_branch_name() {
_forgit_strip_ansi |
sed -E 's/^[*+ ] //; s/ -> .*//' |
awk '{print $1}'
}

_forgit_list_files() {
local rootdir
rootdir=$(git rev-parse --show-toplevel)
Expand Down Expand Up @@ -686,7 +716,9 @@ _forgit_cherry_pick() {
}

_forgit_cherry_pick_from_branch_preview() {
git log --right-only --color=always --cherry-pick --oneline "$1"..."$2"
local branch
branch=$(echo "$2" | _forgit_extract_branch_name)
git log --right-only --color=always --cherry-pick --oneline "$1"..."$branch"
}

_forgit_cherry_pick_from_branch() {
Expand All @@ -703,18 +735,15 @@ _forgit_cherry_pick_from_branch() {
opts="
$FORGIT_FZF_DEFAULT_OPTS
+s +m --tiebreak=index --header-lines=1
--preview=\"$FORGIT cherry_pick_from_branch_preview '$base' {1}\"
--preview=\"$FORGIT cherry_pick_from_branch_preview '$base' {}\"
$FORGIT_CHERRY_PICK_FROM_BRANCH_FZF_OPTS
"
# loop until either the branch selector is closed or a commit to be cherry
# picked has been selected from within a branch
while true
do
if [[ -z $input_branch ]]; then
branch="$(git branch --color=always --all |
LC_ALL=C sort -k1.1,1.1 -rs |
FZF_DEFAULT_OPTS="$opts" fzf |
awk '{print $1}')"
branch="$(_forgit_branch_list --all | FZF_DEFAULT_OPTS="$opts" fzf | _forgit_extract_branch_name)"
else
branch=$input_branch
fi
Expand Down Expand Up @@ -888,13 +917,13 @@ _forgit_checkout_branch() {
opts="
$FORGIT_FZF_DEFAULT_OPTS
+s +m --tiebreak=index --header-lines=1
--preview=\"$FORGIT branch_preview {1}\"
--preview=\"$FORGIT branch_preview {}\"
$FORGIT_CHECKOUT_BRANCH_FZF_OPTS
"
_forgit_checkout_branch_branch_git_opts=()
_forgit_parse_array _forgit_checkout_branch_branch_git_opts "$FORGIT_CHECKOUT_BRANCH_BRANCH_GIT_OPTS"
branch="$(git branch --color=always "${_forgit_checkout_branch_branch_git_opts[@]:---all}" | LC_ALL=C sort -k1.1,1.1 -rs |
FZF_DEFAULT_OPTS="$opts" fzf | awk '{print $1}')"
branch="$(_forgit_branch_list "${_forgit_checkout_branch_branch_git_opts[@]:---all}" |
FZF_DEFAULT_OPTS="$opts" fzf | _forgit_extract_branch_name)"
[[ -z "$branch" ]] && return 1

# track the remote branch if possible
Expand Down Expand Up @@ -934,13 +963,13 @@ _forgit_switch_branch() {
opts="
$FORGIT_FZF_DEFAULT_OPTS
+s +m --tiebreak=index --header-lines=1
--preview=\"$FORGIT branch_preview {1}\"
--preview=\"$FORGIT branch_preview {}\"
$FORGIT_SWITCH_BRANCH_FZF_OPTS
"
_forgit_switch_branch_branch_git_opts=()
_forgit_parse_array _forgit_switch_branch_branch_git_opts "$FORGIT_SWITCH_BRANCH_BRANCH_GIT_OPTS"
branch="$(git branch --color=always "${_forgit_switch_branch_branch_git_opts[@]:---all}" | LC_ALL=C sort -k1.1,1.1 -rs |
FZF_DEFAULT_OPTS="$opts" fzf | awk '{print $1}')"
branch="$(_forgit_branch_list "${_forgit_switch_branch_branch_git_opts[@]:---all}" |
FZF_DEFAULT_OPTS="$opts" fzf | _forgit_extract_branch_name)"
[[ -z "$branch" ]] && return 1

# track the remote branch if possible
Expand Down Expand Up @@ -1012,9 +1041,11 @@ _forgit_checkout_commit() {
}

_forgit_branch_preview() {
local branch
branch=$(echo "$1" | _forgit_extract_branch_name)
# the trailing '--' ensures that this works for branches that have a name
# that is identical to a file
git log "$1" "${_forgit_log_preview_options[@]}" --
git log "$branch" "${_forgit_log_preview_options[@]}" --
}

_forgit_git_branch_delete() {
Expand All @@ -1031,14 +1062,10 @@ _forgit_branch_delete() {
opts="
$FORGIT_FZF_DEFAULT_OPTS
+s --multi --tiebreak=index --header-lines=1
--preview=\"$FORGIT branch_preview {1}\"
--preview=\"$FORGIT branch_preview {}\"
$FORGIT_BRANCH_DELETE_FZF_OPTS
"

for branch in $(git branch --color=always |
LC_ALL=C sort -k1.1,1.1 -rs |
FZF_DEFAULT_OPTS="$opts" fzf |
awk '{print $1}')
for branch in $(_forgit_branch_list | FZF_DEFAULT_OPTS="$opts" fzf | _forgit_extract_branch_name)
do
_forgit_git_branch_delete "$branch"
done
Expand Down Expand Up @@ -1234,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
Expand Down Expand Up @@ -1307,6 +1474,8 @@ PUBLIC_COMMANDS=(
"show"
"stash_show"
"stash_push"
"worktree"
"worktree_delete"
)

PRIVATE_COMMANDS=(
Expand Down Expand Up @@ -1338,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
Expand Down
Loading