mirror of
https://github.com/wfxr/forgit.git
synced 2026-09-10 07:16:23 -04:00
Keep `forgit add` on the porcelain `git status --porcelain -zs` path so filenames containing backslashes continue to work, while restoring the behaviors that regressed when we moved away from Git's cwd-relative output. This change makes the status picker emit a display label and a hidden absolute-path payload separately. That lets the UI keep showing intuitive cwd-relative paths, while preview, edit, and add actions operate on the real path instead of reparsing the rendered status line. As a result, untracked files, subdirectory workflows, and special filenames now share one consistent path flow. It also restores the old-Git fallback for plain `?? path` output before status filtering, raises the required fzf version for `--accept-nth`, and adds regression coverage for backslashes, spaces, tabs, subdirectory entries, sibling directories, and logical symlink paths. We explored a few alternatives before landing here. Keeping a single human-readable line and reparsing it downstream remained too fragile for quoted paths and backslashes. Shell-only display-path rewriting worked for some cases but stayed brittle across logical vs physical paths and still failed in macOS CI. A per-path `realpath` approach would have been easier to read, but GNU-style relative-path support is not portable across the platforms we test and would add one external process per file in a hot path. The final tradeoff keeps the pipeline batch-oriented and portable by doing the path normalization once in a single helper step, even though that is less lightweight than the earlier shell-only versions. That complexity is justified here because it fixes the old-Git untracked regression, preserves correct preview/add behavior from subdirectories, and avoids reintroducing long-standing filename parsing bugs.
1726 lines
58 KiB
Bash
Executable file
1726 lines
58 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# MIT (c) Wenxuan Zhang
|
|
|
|
# This file is meant to be executed directly. If it's available on the PATH,
|
|
# it can also be used as a subcommand of git, which then forwards all arguments
|
|
# on to forgit. So, all of these commands will work as expected:
|
|
#
|
|
# `git forgit log`
|
|
# `git forgit checkout_file`
|
|
# `git forgit checkout_file README.md`
|
|
#
|
|
# This gives users the choice to set aliases inside of their git config instead
|
|
# of their shell config if they prefer.
|
|
|
|
REQUIRED_FZF_VERSION="0.60.0"
|
|
|
|
# forgit-fzf separator used between the visible label and hidden payload.
|
|
_ffsep=$'\x1f\x1e'
|
|
|
|
FORGIT_FZF_DEFAULT_OPTS="
|
|
$FZF_DEFAULT_OPTS
|
|
--ansi
|
|
--height='80%'
|
|
--bind='alt-k:preview-up,alt-p:preview-up'
|
|
--bind='alt-j:preview-down,alt-n:preview-down'
|
|
--bind='ctrl-r:toggle-all'
|
|
--bind='ctrl-s:toggle-sort'
|
|
--bind='?:toggle-preview'
|
|
--bind='alt-w:toggle-preview-wrap'
|
|
--preview-window='right:60%'
|
|
+1
|
|
$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_print_dim() { printf "\e[90m%s\e[0m\n" "$*"; }
|
|
_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
|
|
if [[ "$(git rev-parse "$1")" == "$(git rev-list --max-parents=0 HEAD)" ]]; then
|
|
echo "--root"
|
|
else
|
|
echo "$1~"
|
|
fi
|
|
}
|
|
|
|
_forgit_contains_non_flags() {
|
|
while (("$#")); do
|
|
case "$1" in
|
|
-*) shift ;;
|
|
*)
|
|
return 0
|
|
;;
|
|
esac
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# optional render emoji characters (https://github.com/wfxr/emoji-cli)
|
|
_forgit_emojify() {
|
|
if hash emojify &>/dev/null; then
|
|
emojify
|
|
else
|
|
cat
|
|
fi
|
|
}
|
|
|
|
# extract the first git sha occurring in the input and strip trailing newline
|
|
_forgit_extract_sha() {
|
|
grep -Eo '[a-f0-9]+' | head -1 | tr -d '[:space:]'
|
|
}
|
|
|
|
# extract the first git sha and copy it to the clipboard
|
|
_forgit_yank_sha() {
|
|
echo "$1" | _forgit_extract_sha | ${FORGIT_COPY_CMD:-pbcopy}
|
|
}
|
|
|
|
# extract the first stash name in the input
|
|
_forgit_extract_stash_name() {
|
|
cut -d: -f1 | tr -d '[:space:]'
|
|
}
|
|
|
|
# extract the first stash name and copy it to the clipboard
|
|
_forgit_yank_stash_name() {
|
|
echo "$1" | _forgit_extract_stash_name | ${FORGIT_COPY_CMD:-pbcopy}
|
|
}
|
|
|
|
# parse a space separated string into an array
|
|
# arrays parsed with this function are global
|
|
_forgit_parse_array() {
|
|
${IFS+"false"} && unset old_IFS || old_IFS="$IFS"
|
|
# read the value of the second argument
|
|
# into an array that has the name of the first argument
|
|
IFS=" " read -r -a "$1" <<< "$2"
|
|
${old_IFS+"false"} && unset IFS || IFS="$old_IFS"
|
|
}
|
|
|
|
# parse the input arguments and print only those after the "--"
|
|
# separator as a single line of quoted arguments to stdout
|
|
_forgit_quote_files() {
|
|
local files add
|
|
files=()
|
|
add=false
|
|
while (( "$#" )); do
|
|
case "$1" in
|
|
--)
|
|
add=true
|
|
shift
|
|
;;
|
|
*)
|
|
if [ $add == true ]; then
|
|
files+=("'$1'")
|
|
fi
|
|
shift
|
|
;;
|
|
esac
|
|
done
|
|
echo "${files[*]}"
|
|
}
|
|
|
|
_forgit_log_graph_enable=${FORGIT_LOG_GRAPH_ENABLE:-"true"}
|
|
_forgit_log_format=${FORGIT_LOG_FORMAT:-%C(auto)%h%d %s %C(black)%C(bold)%cr%Creset}
|
|
_forgit_log_preview_options=("--graph" "--pretty=format:$_forgit_log_format" "--color=always" "--abbrev-commit" "--date=relative")
|
|
_forgit_fullscreen_context=${FORGIT_FULLSCREEN_CONTEXT:-10}
|
|
_forgit_preview_context=${FORGIT_PREVIEW_CONTEXT:-3}
|
|
_forgit_dir_view=${FORGIT_DIR_VIEW:-$(hash tree &> /dev/null && echo 'tree' || echo 'find')}
|
|
|
|
_forgit_pager() {
|
|
local pager
|
|
# Preview mode must be marked explicitly by forgit. Inferred signals such
|
|
# as FZF_PREVIEW_COLUMNS or a non-TTY stdout also show up in execute/fullscreen
|
|
# paths, which would incorrectly route Enter actions to FORGIT_PREVIEW_PAGER.
|
|
if [[ -n "$FORGIT_IN_PREVIEW" ]] && [[ -n "$FORGIT_PREVIEW_PAGER" ]]; then
|
|
pager="$FORGIT_PREVIEW_PAGER"
|
|
else
|
|
pager=$(_forgit_get_pager "$1")
|
|
fi
|
|
[[ -z "${pager}" ]] && exit 1
|
|
eval "${pager} ${*:2}"
|
|
}
|
|
|
|
_forgit_preview() {
|
|
local cmd=$1
|
|
shift
|
|
# Funnel all fzf --preview commands through a single wrapper so preview-only
|
|
# pager behavior is controlled by an explicit marker instead of ambient env.
|
|
export FORGIT_IN_PREVIEW=1
|
|
_forgit_"${cmd}" "$@"
|
|
}
|
|
|
|
_forgit_get_pager() {
|
|
local pager
|
|
pager=${1:-core}
|
|
case "$pager" in
|
|
core) echo -n "${FORGIT_PAGER:-$(git config core.pager || echo 'cat')}" ;;
|
|
show) echo -n "${FORGIT_SHOW_PAGER:-$(git config pager.show || _forgit_get_pager)}" ;;
|
|
diff) echo -n "${FORGIT_DIFF_PAGER:-$(git config pager.diff || _forgit_get_pager)}" ;;
|
|
ignore) echo -n "${FORGIT_IGNORE_PAGER:-$(hash bat &>/dev/null && echo 'bat -l gitignore --color=always' || echo 'cat')}" ;;
|
|
attributes) echo -n "${FORGIT_ATTRIBUTES_PAGER:-$(hash bat &>/dev/null && echo 'bat -l gitattributes --color=always' || echo 'cat')}" ;;
|
|
blame) echo -n "${FORGIT_BLAME_PAGER:-$(git config pager.blame || _forgit_get_pager)}" ;;
|
|
enter) echo -n "${FORGIT_ENTER_PAGER:-"LESS='-r' less"}" ;;
|
|
*) echo "pager not found: $1" >&2 ;;
|
|
esac
|
|
}
|
|
|
|
_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
|
|
_forgit_print_dim "* $current"
|
|
else
|
|
_forgit_print_dim "* (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)
|
|
# git escapes special characters in it's output when core.quotePath is
|
|
# true or unset. Git always expects unquoted file paths as input. This
|
|
# leads to issues when we consume output from git and use it to build
|
|
# input for other git commands. Use the -z flag to ensure file paths are
|
|
# unquoted.
|
|
# uniq is necessary because unmerged files are printed once for each
|
|
# merge conflict.
|
|
# With the -z flag, git also uses \0 line termination, so we
|
|
# have to replace the terminators.
|
|
git ls-files -z "$@" "$rootdir" | tr '\0' '\n' | uniq
|
|
}
|
|
|
|
# Print changed files in the worktree
|
|
#
|
|
# Always includes modified and unmerged files. Includes untracked files when
|
|
# status.showUntrackedFiles is true or unset. Never includes staged files.
|
|
#
|
|
# The output is formatted for `forgit add` and contains two fields separated by
|
|
# `$_ffsep`:
|
|
# 1. the human-readable status line shown in fzf
|
|
# 2. the absolute path payload used by preview/edit/add actions
|
|
# fzf only shows field 1 and uses field 2 for actions.
|
|
#
|
|
# Keeping the display text separate from the payload avoids reparsing quoted
|
|
# `git status` output, which breaks for filenames containing backslashes.
|
|
_forgit_worktree_changes() {
|
|
local changed reset rootdir show_untracked unmerged untracked
|
|
changed=$(git config --get-color color.status.changed red)
|
|
unmerged=$(git config --get-color color.status.unmerged red)
|
|
untracked=$(git config --get-color color.status.untracked red)
|
|
reset=$(git config --get-color '' reset)
|
|
show_untracked=$(git config status.showUntrackedFiles)
|
|
rootdir=$(git rev-parse --show-toplevel)
|
|
|
|
git -c color.status=always status --porcelain -zs --untracked="${show_untracked:-all}" |
|
|
tr '\0' '\n' |
|
|
_forgit_restore_untracked_color "$untracked" "$reset" |
|
|
grep -F -e "$changed" -e "$unmerged" -e "$untracked" |
|
|
_forgit_build_status_entries "$rootdir"
|
|
}
|
|
|
|
# Normalize plain `?? path` rows from older Git versions before the add-list
|
|
# color filter runs.
|
|
#
|
|
# Input:
|
|
# stdin - one status line per entry
|
|
# $1 untracked - color sequence to apply to the `??` marker
|
|
# $2 reset - reset sequence appended after the colored marker
|
|
#
|
|
# Output:
|
|
# Writes the input lines back to stdout, but rewrites plain `?? path` rows as
|
|
# colored `??` rows so untracked entries remain visible after filtering.
|
|
_forgit_restore_untracked_color() {
|
|
local reset untracked
|
|
untracked=$1
|
|
reset=$2
|
|
|
|
awk -v untracked="$untracked" -v reset="$reset" '
|
|
/^\?\? / { sub(/^\?\? /, untracked "??" reset " ") }
|
|
{ print }
|
|
'
|
|
}
|
|
|
|
# Build fzf-friendly entries from colored porcelain status lines.
|
|
#
|
|
# Input:
|
|
# stdin - colored porcelain `git status --porcelain -z` rows, converted
|
|
# to one line per entry
|
|
# $1 rootdir - absolute repo root used to build the hidden payload
|
|
#
|
|
# Output:
|
|
# Writes one line per entry with two `$_ffsep`-separated fields:
|
|
# 1. the human-readable status line shown in fzf
|
|
# 2. the absolute-path payload used by preview/edit/add actions
|
|
_forgit_build_status_entries() {
|
|
local cwd rootdir
|
|
rootdir=$1
|
|
cwd=$(pwd -P)
|
|
|
|
# Use a single Perl process so path normalization stays portable while the
|
|
# full add-list transformation still runs as one batch pipeline stage.
|
|
perl -MCwd=realpath -MFile::Spec -e '
|
|
use strict;
|
|
use warnings;
|
|
|
|
my ($rootdir, $cwd, $separator) = @ARGV;
|
|
my $normalized_rootdir = realpath($rootdir);
|
|
my $normalized_cwd = realpath($cwd);
|
|
|
|
while (my $line = <STDIN>) {
|
|
chomp $line;
|
|
next unless $line =~ /^(..[^[:space:]]*)( )(.*)$/;
|
|
|
|
my ($status, $repo_path) = ($1, $3);
|
|
my $absolute_path = "$normalized_rootdir/$repo_path";
|
|
my $display_path = File::Spec->abs2rel(realpath($absolute_path) // $absolute_path, $normalized_cwd);
|
|
$display_path = "." if $display_path eq q{};
|
|
|
|
print "[$status] ${display_path}${separator}${normalized_rootdir}/${repo_path}\n";
|
|
}
|
|
' "$rootdir" "$cwd" "$_ffsep"
|
|
}
|
|
|
|
_forgit_is_submodule() {
|
|
git submodule --quiet status "$1"
|
|
}
|
|
|
|
_forgit_log_preview() {
|
|
local sha
|
|
sha=$(echo "$1" | _forgit_extract_sha)
|
|
shift
|
|
git show --color=always -U"$_forgit_preview_context" "${sha}" -- "$@" | _forgit_pager show
|
|
}
|
|
|
|
_forgit_log_enter() {
|
|
local sha
|
|
sha=$(echo "$1" | _forgit_extract_sha)
|
|
shift
|
|
"${FORGIT}" show "${sha}" "$@"
|
|
}
|
|
|
|
_forgit_git_log() {
|
|
local graph log_format
|
|
log_format=$1
|
|
shift
|
|
graph=()
|
|
[[ $_forgit_log_graph_enable == true ]] && graph=(--graph)
|
|
_forgit_log_git_opts=()
|
|
_forgit_parse_array _forgit_log_git_opts "$FORGIT_LOG_GIT_OPTS"
|
|
git log "${graph[@]}" --color=always --format="$log_format" "${_forgit_log_git_opts[@]}" "$@" |
|
|
_forgit_emojify
|
|
}
|
|
|
|
# git commit viewer
|
|
_forgit_log() {
|
|
_forgit_inside_work_tree || return 1
|
|
local opts quoted_files log_format
|
|
quoted_files=$(_forgit_quote_files "$@")
|
|
opts="
|
|
$FORGIT_FZF_DEFAULT_OPTS
|
|
+s +m --tiebreak=index
|
|
--bind=\"enter:execute($FORGIT log_enter {} $quoted_files)\"
|
|
--bind=\"ctrl-y:execute-silent($FORGIT yank_sha {})\"
|
|
--preview=\"$FORGIT preview log_preview {} $quoted_files\"
|
|
$FORGIT_LOG_FZF_OPTS
|
|
"
|
|
log_format=${FORGIT_GLO_FORMAT:-$_forgit_log_format}
|
|
_forgit_git_log "$log_format" "$@" | FZF_DEFAULT_OPTS="$opts" fzf
|
|
fzf_exit_code=$?
|
|
# exit successfully on 130 (ctrl-c/esc)
|
|
[[ $fzf_exit_code == 130 ]] && return 0
|
|
return $fzf_exit_code
|
|
}
|
|
|
|
# git reflog viewer
|
|
_forgit_reflog() {
|
|
_forgit_inside_work_tree || return 1
|
|
_forgit_contains_non_flags "$@" && { git reflog "$@"; return $?; }
|
|
local opts reflog_format
|
|
opts="
|
|
$FORGIT_FZF_DEFAULT_OPTS
|
|
+s +m --tiebreak=index
|
|
--bind=\"enter:execute($FORGIT log_enter {})\"
|
|
--bind=\"ctrl-y:execute-silent($FORGIT yank_sha {})\"
|
|
--preview=\"$FORGIT preview log_preview {}\"
|
|
$FORGIT_REFLOG_FZF_OPTS
|
|
"
|
|
reflog_format=${FORGIT_GRL_FORMAT:-$_forgit_log_format}
|
|
_forgit_reflog_git_opts=()
|
|
_forgit_parse_array _forgit_reflog_git_opts "$FORGIT_REFLOG_GIT_OPTS"
|
|
git reflog show --color=always --format="$reflog_format" "${_forgit_reflog_git_opts[@]}" "$@" |
|
|
_forgit_emojify |
|
|
FZF_DEFAULT_OPTS="$opts" fzf
|
|
fzf_exit_code=$?
|
|
# exit successfully on 130 (ctrl-c/esc)
|
|
[[ $fzf_exit_code == 130 ]] && return 0
|
|
return $fzf_exit_code
|
|
}
|
|
|
|
_forgit_remove_status_from_diff_line() {
|
|
# Remove the status prefix from a diff line, e.g.
|
|
# [M] somefile
|
|
# becomes
|
|
# somefile
|
|
sed 's/^[[:space:]]*\[[A-Z0-9]*\][[:space:]]*//'
|
|
}
|
|
|
|
_forgit_get_files_from_diff_line() {
|
|
# Construct a null-terminated list of the filenames
|
|
# The input looks like one of these lines:
|
|
# [R100] file -> another file
|
|
# [A] file with spaces
|
|
# [D] oldfile
|
|
# And we transform it to this representation for further usage with "xargs -0":
|
|
# file\0another file\0
|
|
# file with spaces\0
|
|
# oldfile\0
|
|
# We have to do a two-step sed -> tr pipe because OSX's sed implementation does
|
|
# not support the null-character directly.
|
|
_forgit_remove_status_from_diff_line | sed 's/ -> /\n/' | tr '\n' '\0'
|
|
}
|
|
|
|
_forgit_get_single_file_from_diff_line() {
|
|
# Similar to the function above, but only gets a single file from a single line
|
|
# Gets the new name of renamed files
|
|
_forgit_remove_status_from_diff_line | sed 's/.*-> //'
|
|
}
|
|
|
|
_forgit_exec_diff() {
|
|
_forgit_diff_git_opts=()
|
|
_forgit_parse_array _forgit_diff_git_opts "$FORGIT_DIFF_GIT_OPTS"
|
|
git diff --color=always "${_forgit_diff_git_opts[@]}" "$@"
|
|
}
|
|
|
|
_forgit_diff_view() {
|
|
local input_line=$1
|
|
local diff_context=$2
|
|
local repo
|
|
local commits=()
|
|
repo=$(git rev-parse --show-toplevel)
|
|
cd "$repo" || return 1
|
|
if [ $# -gt 2 ]; then
|
|
IFS=" " read -r -a commits <<< "${*:3}"
|
|
fi
|
|
echo "$input_line" | _forgit_get_files_from_diff_line | xargs -0 \
|
|
"$FORGIT" exec_diff "${commits[@]}" -U"$diff_context" -- | _forgit_pager diff
|
|
}
|
|
|
|
_forgit_edit_diffed_file() {
|
|
local input_line rootdir
|
|
input_line=$1
|
|
rootdir=$(git rev-parse --show-toplevel)
|
|
filename=$(echo "$input_line" | _forgit_get_single_file_from_diff_line)
|
|
$EDITOR "$rootdir/$filename" >/dev/tty </dev/tty
|
|
}
|
|
|
|
_forgit_diff_enter() {
|
|
file=$1
|
|
commits=("${@:2}")
|
|
_forgit_diff_view "$file" "$_forgit_fullscreen_context" "${commits[@]}"
|
|
}
|
|
|
|
# git diff viewer
|
|
_forgit_diff() {
|
|
_forgit_inside_work_tree || return 1
|
|
local files opts commits escaped_commits
|
|
commits=()
|
|
files=()
|
|
[[ $# -ne 0 ]] && {
|
|
if git rev-parse "$1" -- &>/dev/null ; then
|
|
if [[ $# -gt 1 ]] && git rev-parse "$2" -- &>/dev/null; then
|
|
commits=("$1" "$2") && files=("${@:3}")
|
|
else
|
|
commits=("$1") && files=("${@:2}")
|
|
fi
|
|
else
|
|
files=("$@")
|
|
fi
|
|
}
|
|
# Git stashes are named "stash@{x}", which contains the fzf placeholder "{x}".
|
|
# In order to support passing stashes as arguments to _forgit_diff, we have to
|
|
# prevent fzf from interpreting this substring by escaping the opening bracket.
|
|
# The string is evaluated a few subsequent times, so we need multiple escapes.
|
|
for commit in "${commits[@]}"; do
|
|
escaped_commits+="'${commit//\{/\\\\\{}' "
|
|
done
|
|
opts="
|
|
$FORGIT_FZF_DEFAULT_OPTS
|
|
+m -0 --bind=\"enter:execute($FORGIT diff_enter {} $escaped_commits | $FORGIT pager enter)\"
|
|
--preview=\"$FORGIT preview diff_view {} '$_forgit_preview_context' $escaped_commits\"
|
|
--bind=\"alt-e:execute($FORGIT edit_diffed_file {})+refresh-preview\"
|
|
$FORGIT_DIFF_FZF_OPTS
|
|
--prompt=\"${commits[*]} > \"
|
|
"
|
|
_forgit_diff_git_opts=()
|
|
_forgit_parse_array _forgit_diff_git_opts "$FORGIT_DIFF_GIT_OPTS"
|
|
git diff --name-status "${_forgit_diff_git_opts[@]}" "${commits[@]}" -- "${files[@]}" |
|
|
sed -E 's/^([[:alnum:]]+)[[:space:]]+(.*)$/[\1] \2/' |
|
|
sed 's/ / -> /2' | expand -t 8 |
|
|
FZF_DEFAULT_OPTS="$opts" fzf
|
|
fzf_exit_code=$?
|
|
# exit successfully on 130 (ctrl-c/esc)
|
|
[[ $fzf_exit_code == 130 ]] && return 0
|
|
return $fzf_exit_code
|
|
}
|
|
|
|
_forgit_exec_show() {
|
|
_forgit_show_git_opts=()
|
|
_forgit_parse_array _forgit_show_git_opts "$FORGIT_SHOW_GIT_OPTS"
|
|
git show --pretty="" --diff-merges=first-parent --color=always "${_forgit_show_git_opts[@]}" "$@"
|
|
}
|
|
|
|
_forgit_show_view() {
|
|
local input_line=$1
|
|
local diff_context=$2
|
|
local commit=$3
|
|
local repo
|
|
repo=$(git rev-parse --show-toplevel)
|
|
cd "$repo" || return 1
|
|
echo "$input_line" | _forgit_get_files_from_diff_line | xargs -0 \
|
|
"$FORGIT" exec_show "${commit}^{commit}" -U"$diff_context" -- | _forgit_pager diff
|
|
}
|
|
|
|
_forgit_show_preview() {
|
|
local input_line=$1
|
|
local diff_context=$2
|
|
local commit=$3
|
|
if [[ "$FZF_PREVIEW_LABEL" =~ "Diff" ]]; then
|
|
_forgit_show_view "${input_line}" "${diff_context}" "${commit}"
|
|
else
|
|
git show --quiet --color=always "${FZF_PROMPT%% *}"
|
|
fi
|
|
}
|
|
|
|
_forgit_show_enter() {
|
|
file=$1
|
|
commit=$2
|
|
_forgit_show_view "$file" "$_forgit_fullscreen_context" "${commit}"
|
|
}
|
|
|
|
_forgit_git_show() {
|
|
local commit=$1
|
|
shift
|
|
|
|
_forgit_show_git_opts=()
|
|
_forgit_parse_array _forgit_show_git_opts "$FORGIT_SHOW_GIT_OPTS"
|
|
# Add "^{commit}" suffix after the actual commit. This suppresses the tag information in case it is a tag.
|
|
# See: https://git-scm.com/docs/git-show#Documentation/git-show.txt-codegitshow-s--formatsv100commitcode
|
|
git show --pretty="" --name-status --diff-merges=first-parent "${_forgit_show_git_opts[@]}" "${commit}^{commit}" -- "$@" |
|
|
sed -E 's/^([[:alnum:]]+)[[:space:]]+(.*)$/[\1] \2/' |
|
|
sed 's/ / -> /2' | expand -t 8
|
|
}
|
|
|
|
# git show viewer
|
|
_forgit_show() {
|
|
_forgit_inside_work_tree || return 1
|
|
local files opts commit escaped_commit
|
|
files=()
|
|
if [[ $# -ne 0 ]]; then
|
|
if git rev-parse "$1" -- &>/dev/null ; then
|
|
commit="$1" && files=("${@:2}")
|
|
else
|
|
commit="HEAD" && files=("$@")
|
|
fi
|
|
else
|
|
commit="HEAD"
|
|
fi
|
|
# Escape opening brackets to support stashes (see comment in _forgit_diff)
|
|
escaped_commit=${commit//\{/\\\\\{}
|
|
opts="
|
|
$FORGIT_FZF_DEFAULT_OPTS
|
|
+m -0 --bind=\"enter:execute($FORGIT show_enter {} $escaped_commit | $FORGIT pager enter)\"
|
|
--preview=\"$FORGIT preview show_preview {} '$_forgit_preview_context' $escaped_commit\"
|
|
--preview-label=\" Diff \"
|
|
--bind=\"alt-e:execute($FORGIT edit_diffed_file {})+refresh-preview\"
|
|
--bind=\"alt-t:transform:[[ ! \\\"\$FZF_PREVIEW_LABEL\\\" =~ 'Diff' ]] &&
|
|
echo 'change-preview-label( Diff )+refresh-preview' ||
|
|
echo 'change-preview-label( Commit Message )+refresh-preview'\"
|
|
$FORGIT_SHOW_FZF_OPTS
|
|
--prompt=\"${commit} > \"
|
|
"
|
|
_forgit_git_show "$commit" "${files[@]}" | FZF_DEFAULT_OPTS="$opts" fzf
|
|
fzf_exit_code=$?
|
|
# exit successfully on 130 (ctrl-c/esc)
|
|
[[ $fzf_exit_code == 130 ]] && return 0
|
|
return $fzf_exit_code
|
|
}
|
|
|
|
_forgit_add_preview() {
|
|
local file
|
|
file=$1
|
|
# $file can be a directory when status.showUntrackedFiles is set to 'normal'
|
|
# When this is the case and the directory is not a submodule show the content of the directory and return
|
|
if [[ -d "$file" ]] && ! _forgit_is_submodule "$file"; then
|
|
eval "$_forgit_dir_view \"$file\""
|
|
return 0
|
|
fi
|
|
if (git status -s -- "$file" | grep '^??') &>/dev/null; then # diff with /dev/null for untracked files
|
|
git diff --color=always --no-index -- /dev/null "$file" | _forgit_pager diff | sed '2 s/added:/untracked:/'
|
|
else
|
|
git diff --color=always -- "$file" | _forgit_pager diff
|
|
fi
|
|
}
|
|
|
|
_forgit_git_add() {
|
|
_forgit_add_git_opts=()
|
|
_forgit_parse_array _forgit_add_git_opts "$FORGIT_ADD_GIT_OPTS"
|
|
git add "${_forgit_add_git_opts[@]}" "$@"
|
|
}
|
|
|
|
_forgit_edit_add_file() {
|
|
local filename
|
|
filename=$1
|
|
$EDITOR "$filename" >/dev/tty </dev/tty
|
|
}
|
|
|
|
# git add selector
|
|
_forgit_add() {
|
|
_forgit_inside_work_tree || return 1
|
|
local files opts
|
|
# Add files if passed as arguments
|
|
_forgit_contains_non_flags "$@" && { _forgit_git_add "$@" && git status -s; return $?; }
|
|
|
|
opts="
|
|
$FORGIT_FZF_DEFAULT_OPTS
|
|
--delimiter=$_ffsep
|
|
# Show only the formatted label in fzf, but return the hidden absolute
|
|
# path payload so downstream actions never need to parse status lines.
|
|
-0 -m --with-nth=1 --accept-nth=2
|
|
--preview=\"$FORGIT preview add_preview {2}\"
|
|
--bind=\"alt-e:execute($FORGIT edit_add_file {2})+refresh-preview\"
|
|
$FORGIT_ADD_FZF_OPTS
|
|
"
|
|
files=()
|
|
while IFS='' read -r file; do
|
|
files+=("$file")
|
|
done < <(_forgit_worktree_changes | FZF_DEFAULT_OPTS="$opts" fzf)
|
|
[[ "${#files[@]}" -gt 0 ]] && _forgit_git_add "$@" "${files[@]}" && git status -s && return
|
|
echo 'Nothing to add.'
|
|
}
|
|
|
|
_forgit_reset_head_preview() {
|
|
file=$1
|
|
git diff --staged --color=always -- "$file" | _forgit_pager diff
|
|
}
|
|
|
|
_forgit_git_reset_head() {
|
|
_forgit_reset_head_git_opts=()
|
|
_forgit_parse_array _forgit_reset_head_git_opts "$FORGIT_RESET_HEAD_GIT_OPTS"
|
|
git reset -q "${_forgit_reset_head_git_opts[@]}" HEAD "$@"
|
|
}
|
|
|
|
# git reset HEAD (unstage) selector
|
|
_forgit_reset_head() {
|
|
_forgit_inside_work_tree || return 1
|
|
local files opts rootdir
|
|
[[ $# -ne 0 ]] && { _forgit_git_reset_head "$@" && git status --short; return $?; }
|
|
rootdir=$(git rev-parse --show-toplevel)
|
|
opts="
|
|
$FORGIT_FZF_DEFAULT_OPTS
|
|
-m -0
|
|
--preview=\"$FORGIT preview reset_head_preview '$rootdir'/{}\"
|
|
$FORGIT_RESET_HEAD_FZF_OPTS
|
|
"
|
|
files=()
|
|
while IFS='' read -r file; do
|
|
files+=("$file")
|
|
done < <(git diff -z --staged --name-only | tr '\0' '\n' | FZF_DEFAULT_OPTS="$opts" fzf)
|
|
if [[ ${#files[@]} -eq 0 ]]; then
|
|
echo 'Nothing to unstage.'
|
|
return 1
|
|
fi
|
|
for file in "${files[@]}"; do
|
|
_forgit_git_reset_head "$rootdir/$file"
|
|
done
|
|
git status --short
|
|
}
|
|
|
|
_forgit_stash_show_preview() {
|
|
local stash
|
|
stash=$(echo "$1" | _forgit_extract_stash_name)
|
|
git show --color=always -U"$_forgit_preview_context" "${stash}" | _forgit_pager show
|
|
}
|
|
|
|
_forgit_stash_show_enter() {
|
|
local stash
|
|
stash=$(echo "$1" | _forgit_extract_stash_name)
|
|
"${FORGIT}" show "${stash}"
|
|
}
|
|
|
|
# git stash viewer
|
|
_forgit_stash_show() {
|
|
_forgit_inside_work_tree || return 1
|
|
local opts
|
|
[[ $# -ne 0 ]] && { "${FORGIT}" show "$@"; return $?; }
|
|
_forgit_stash_show_git_opts=()
|
|
_forgit_parse_array _forgit_stash_show_git_opts "$FORGIT_STASH_SHOW_GIT_OPTS"
|
|
opts="
|
|
$FORGIT_FZF_DEFAULT_OPTS
|
|
+s +m -0 --tiebreak=index --bind=\"enter:execute($FORGIT stash_show_enter {})\"
|
|
--bind=\"ctrl-y:execute-silent($FORGIT yank_stash_name {})\"
|
|
--preview=\"$FORGIT preview stash_show_preview {}\"
|
|
$FORGIT_STASH_FZF_OPTS
|
|
"
|
|
git stash list "${_forgit_stash_show_git_opts[@]}" | FZF_DEFAULT_OPTS="$opts" fzf
|
|
fzf_exit_code=$?
|
|
# exit successfully on 130 (ctrl-c/esc)
|
|
[[ $fzf_exit_code == 130 ]] && return 0
|
|
return $fzf_exit_code
|
|
}
|
|
|
|
_forgit_stash_push_preview() {
|
|
if _forgit_is_file_tracked "$1"; then
|
|
git diff --color=always -- "$1" | _forgit_pager diff
|
|
else
|
|
git diff --color=always /dev/null "$1" | _forgit_pager diff
|
|
fi
|
|
}
|
|
|
|
_forgit_git_stash_push() {
|
|
_forgit_stash_push_git_opts=()
|
|
_forgit_parse_array _forgit_stash_push_git_opts "$FORGIT_STASH_PUSH_GIT_OPTS"
|
|
git stash push "${_forgit_stash_push_git_opts[@]}" "$@"
|
|
}
|
|
|
|
# git stash push selector
|
|
_forgit_stash_push() {
|
|
_forgit_inside_work_tree || return 1
|
|
local msg args
|
|
args=( "$@" )
|
|
while (( "$#" )); do
|
|
case "$1" in
|
|
# allow message as argument
|
|
-m|--message)
|
|
msg="$2"
|
|
shift 2
|
|
;;
|
|
# ignore -u as it's used implicitly
|
|
-u|--include-untracked) shift ;;
|
|
# pass to git directly when encountering anything else
|
|
*) _forgit_git_stash_push "${args[@]}"; return $?
|
|
esac
|
|
done
|
|
local opts files
|
|
opts="
|
|
$FORGIT_FZF_DEFAULT_OPTS
|
|
-m
|
|
--preview=\"$FORGIT preview stash_push_preview {}\"
|
|
$FORGIT_STASH_PUSH_FZF_OPTS
|
|
"
|
|
# Show both modified and untracked files
|
|
files=()
|
|
while IFS='' read -r file; do
|
|
files+=("$file")
|
|
done < <(_forgit_list_files --exclude-standard --modified --others |
|
|
FZF_DEFAULT_OPTS="$opts" fzf --exit-0)
|
|
[[ "${#files[@]}" -eq 0 ]] && echo "Nothing to stash" && return 1
|
|
_forgit_git_stash_push ${msg:+-m "$msg"} -u "${files[@]}"
|
|
}
|
|
|
|
_forgit_clean_preview() {
|
|
local path
|
|
path=$1
|
|
if [[ -d "$path" ]]; then
|
|
eval "$_forgit_dir_view \"$path\""
|
|
else
|
|
git diff --color=always /dev/null "$path" | _forgit_pager diff
|
|
fi
|
|
}
|
|
|
|
_forgit_clean_select_files() {
|
|
local opts
|
|
opts="
|
|
$FORGIT_FZF_DEFAULT_OPTS
|
|
--preview=\"$FORGIT preview clean_preview {}\"
|
|
-m -0
|
|
$FORGIT_CLEAN_FZF_OPTS
|
|
"
|
|
_forgit_clean_list_files_opts=()
|
|
_forgit_parse_array _forgit_clean_list_files_opts "$FORGIT_CLEAN_LIST_FILES_OPTS"
|
|
# Note: Postfix '/' in directory path should be removed. Otherwise the directory itself will not be removed.
|
|
_forgit_list_files --others "${_forgit_clean_list_files_opts[@]}" "$@" | FZF_DEFAULT_OPTS="$opts" fzf | sed 's#/$##'
|
|
}
|
|
|
|
# git clean selector
|
|
_forgit_clean() {
|
|
_forgit_inside_work_tree || return 1
|
|
_forgit_contains_non_flags "$@" && { git clean -q "$@"; return $?; }
|
|
local files
|
|
_forgit_clean_git_opts=()
|
|
_forgit_parse_array _forgit_clean_git_opts "$FORGIT_CLEAN_GIT_OPTS"
|
|
files=$(_forgit_clean_select_files "$@")
|
|
[[ -n "$files" ]] && echo "$files" | tr '\n' '\0' | xargs -0 -I% git clean "${_forgit_clean_git_opts[@]}" -xdff '%' && git status --short && return
|
|
echo 'Nothing to clean.'
|
|
}
|
|
|
|
_forgit_cherry_pick_preview() {
|
|
local sha
|
|
sha=$(echo "$1" | cut -f2- | _forgit_extract_sha)
|
|
git show --color=always "${sha}" | _forgit_pager show
|
|
}
|
|
|
|
_forgit_cherry_pick() {
|
|
local base target opts fzf_selection fzf_exitval
|
|
|
|
base=$(git branch --show-current)
|
|
[[ -z "$base" ]] && echo "Current commit is not on a branch." && return 1
|
|
|
|
[[ -z $1 ]] && echo "Please specify target branch" && return 1
|
|
target="$1"
|
|
|
|
# in this function, we do something interesting to maintain proper ordering as it's assumed
|
|
# you generally want to cherry pick oldest->newest when you multiselect
|
|
# The instances of "cut", "nl" and "sort" all serve this purpose
|
|
# Please see https://github.com/wfxr/forgit/issues/253 for more details
|
|
|
|
opts="
|
|
$FORGIT_FZF_DEFAULT_OPTS
|
|
--preview=\"$FORGIT preview cherry_pick_preview {}\"
|
|
--multi --ansi --with-nth 2.. -0 --tiebreak=index
|
|
$FORGIT_CHERRY_PICK_FZF_OPTS
|
|
"
|
|
# Note: do not add any pipe after the fzf call here, otherwise the fzf_exitval is not propagated properly.
|
|
# Any eventual post processing can be done afterwards when the "commits" variable is assigned below.
|
|
fzf_selection=$(git log --right-only --color=always --cherry-pick --oneline "$base"..."$target" | nl |
|
|
FZF_DEFAULT_OPTS="$opts" fzf)
|
|
fzf_exitval=$?
|
|
[[ $fzf_exitval != 0 ]] && return $fzf_exitval
|
|
[[ -z "$fzf_selection" ]] && return $fzf_exitval
|
|
|
|
commits=()
|
|
while IFS='' read -r commit; do
|
|
commits+=("$commit")
|
|
done < <(echo "$fzf_selection" | sort -n -k 1 | cut -f2 | cut -d' ' -f1 | _forgit_reverse_lines)
|
|
[ ${#commits[@]} -eq 0 ] && return 1
|
|
|
|
_forgit_cherry_pick_git_opts=()
|
|
_forgit_parse_array _forgit_cherry_pick_git_opts "$FORGIT_CHERRY_PICK_GIT_OPTS"
|
|
git cherry-pick "${_forgit_cherry_pick_git_opts[@]}" "${commits[@]}"
|
|
}
|
|
|
|
_forgit_cherry_pick_from_branch_preview() {
|
|
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() {
|
|
_forgit_inside_work_tree || return 1
|
|
local opts branch exitval input_branch args base
|
|
|
|
base=$(git branch --show-current)
|
|
[[ -z "$base" ]] && echo "Current commit is not on a branch." && return 1
|
|
|
|
args=("$@")
|
|
if [[ $# -ne 0 ]]; then
|
|
input_branch=${args[0]}
|
|
fi
|
|
opts="
|
|
$FORGIT_FZF_DEFAULT_OPTS
|
|
+s +m --tiebreak=index --header-lines=1
|
|
--preview=\"$FORGIT preview 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="$(_forgit_branch_list --all | FZF_DEFAULT_OPTS="$opts" fzf | _forgit_extract_branch_name)"
|
|
else
|
|
branch=$input_branch
|
|
fi
|
|
|
|
unset input_branch
|
|
[[ -z "$branch" ]] && return 1
|
|
|
|
_forgit_cherry_pick "$branch"
|
|
|
|
exitval=$?
|
|
[[ $exitval != 130 ]] || [[ $# -ne 0 ]] && return $exitval
|
|
done
|
|
}
|
|
|
|
_forgit_rebase() {
|
|
_forgit_inside_work_tree || return 1
|
|
_forgit_contains_non_flags "$@" && { git rebase "$@"; return $?; }
|
|
local opts graph target_commit prev_commit
|
|
graph=()
|
|
[[ $_forgit_log_graph_enable == true ]] && graph=(--graph)
|
|
_forgit_rebase_git_opts=()
|
|
_forgit_parse_array _forgit_rebase_git_opts "$FORGIT_REBASE_GIT_OPTS"
|
|
opts="
|
|
$FORGIT_FZF_DEFAULT_OPTS
|
|
+s +m --tiebreak=index
|
|
--bind=\"ctrl-y:execute-silent($FORGIT yank_sha {})\"
|
|
--preview=\"$FORGIT preview file_preview {}\"
|
|
$FORGIT_REBASE_FZF_OPTS
|
|
"
|
|
target_commit=$(
|
|
git log "${graph[@]}" --color=always --format="$_forgit_log_format" |
|
|
_forgit_emojify |
|
|
FZF_DEFAULT_OPTS="$opts" fzf |
|
|
_forgit_extract_sha)
|
|
if [[ -n "$target_commit" ]]; then
|
|
prev_commit=$(_forgit_previous_commit "$target_commit")
|
|
git rebase -i "${_forgit_rebase_git_opts[@]}" "$@" "$prev_commit"
|
|
fi
|
|
}
|
|
|
|
_forgit_file_preview() {
|
|
local sha
|
|
sha=$(echo "$1" | _forgit_extract_sha)
|
|
shift
|
|
git show --color=always "${sha}" -- "$@" | _forgit_pager show
|
|
}
|
|
|
|
_forgit_fixup() {
|
|
_forgit_inside_work_tree || return 1
|
|
git diff --cached --quiet && echo 'Nothing to fixup: there are no staged changes.' && return 1
|
|
_forgit_edit_commit --fixup "$FORGIT_FIXUP_FZF_OPTS" "$FORGIT_FIXUP_GIT_OPTS" "$@"
|
|
}
|
|
|
|
_forgit_squash() {
|
|
_forgit_inside_work_tree || return 1
|
|
git diff --cached --quiet && echo 'Nothing to squash: there are no staged changes.' && return 1
|
|
_forgit_edit_commit --squash "$FORGIT_SQUASH_FZF_OPTS" "$FORGIT_SQUASH_GIT_OPTS" "$@"
|
|
}
|
|
|
|
_forgit_edit_commit() {
|
|
local action fzf_opts opts graph quoted_files target_commit prev_commit
|
|
action=$1
|
|
fzf_opts=$2
|
|
graph=()
|
|
[[ $_forgit_log_graph_enable == true ]] && graph=(--graph)
|
|
git_opts=()
|
|
_forgit_parse_array git_opts "$3"
|
|
shift 3
|
|
quoted_files=$(_forgit_quote_files "$@")
|
|
opts="
|
|
$FORGIT_FZF_DEFAULT_OPTS
|
|
+s +m --tiebreak=index
|
|
--bind=\"ctrl-y:execute-silent($FORGIT yank_sha {})\"
|
|
--preview=\"$FORGIT preview file_preview {} $quoted_files\"
|
|
$fzf_opts
|
|
"
|
|
target_commit=$(
|
|
git log "${graph[@]}" --color=always --format="$_forgit_log_format" "$@" |
|
|
_forgit_emojify |
|
|
FZF_DEFAULT_OPTS="$opts" fzf |
|
|
_forgit_extract_sha)
|
|
# GIT_EDITOR=: is needed to skip the editor
|
|
if [[ -n "$target_commit" ]] && GIT_EDITOR=: git commit "${git_opts[@]}" "$action" "$target_commit"; then
|
|
prev_commit=$(_forgit_previous_commit "$target_commit")
|
|
# rebase will fail if there are unstaged changes so --autostash is needed to temporarily stash them
|
|
# GIT_SEQUENCE_EDITOR=: is needed to skip the editor
|
|
GIT_SEQUENCE_EDITOR=: git rebase --autostash -i --autosquash "$prev_commit"
|
|
fi
|
|
}
|
|
|
|
_forgit_reword() {
|
|
_forgit_inside_work_tree || return 1
|
|
local opts graph quoted_files target_commit prev_commit
|
|
graph=()
|
|
[[ $_forgit_log_graph_enable == true ]] && graph=(--graph)
|
|
git_opts=()
|
|
_forgit_parse_array _forgit_reword_git_opts "$FORGIT_REWORD_GIT_OPTS"
|
|
quoted_files=$(_forgit_quote_files "$@")
|
|
opts="
|
|
$FORGIT_FZF_DEFAULT_OPTS
|
|
+s +m --tiebreak=index
|
|
--bind=\"ctrl-y:execute-silent($FORGIT yank_sha {})\"
|
|
--preview=\"$FORGIT preview file_preview {} $quoted_files\"
|
|
$FORGIT_REWORD_FZF_OPTS
|
|
"
|
|
target_commit=$(
|
|
git log "${graph[@]}" --color=always --format="$_forgit_log_format" "$@" |
|
|
_forgit_emojify |
|
|
FZF_DEFAULT_OPTS="$opts" fzf |
|
|
_forgit_extract_sha)
|
|
if [[ -n "$target_commit" ]] && git commit "${git_opts[@]}" --fixup=reword:"$target_commit"; then
|
|
prev_commit=$(_forgit_previous_commit "$target_commit")
|
|
# rebase will fail if there are unstaged changes so --autostash is needed to temporarily stash them
|
|
# GIT_SEQUENCE_EDITOR=: is needed to skip the editor
|
|
GIT_SEQUENCE_EDITOR=: git rebase --autostash -i --autosquash "$prev_commit"
|
|
fi
|
|
}
|
|
|
|
_forgit_checkout_file_preview() {
|
|
git diff --color=always -- "$1" | _forgit_pager diff
|
|
}
|
|
|
|
_forgit_git_checkout_file() {
|
|
_forgit_checkout_file_git_opts=()
|
|
_forgit_parse_array _forgit_checkout_file_git_opts "$FORGIT_CHECKOUT_FILE_GIT_OPTS"
|
|
git checkout "${_forgit_checkout_file_git_opts[@]}" "$@"
|
|
}
|
|
|
|
# git checkout-file selector
|
|
_forgit_checkout_file() {
|
|
_forgit_inside_work_tree || return 1
|
|
local files opts
|
|
_forgit_contains_non_flags "$@" && { _forgit_git_checkout_file "$@"; return $?; }
|
|
[[ $(_forgit_list_files --modified | wc -l) -eq 0 ]] && echo 'Nothing to checkout.' && return 1
|
|
opts="
|
|
$FORGIT_FZF_DEFAULT_OPTS
|
|
-m -0
|
|
--preview=\"$FORGIT preview checkout_file_preview {}\"
|
|
$FORGIT_CHECKOUT_FILE_FZF_OPTS
|
|
"
|
|
files=()
|
|
while IFS='' read -r file; do
|
|
files+=("$file")
|
|
done < <(_forgit_list_files --modified |
|
|
FZF_DEFAULT_OPTS="$opts" fzf)
|
|
[[ "${#files[@]}" -gt 0 ]] && _forgit_git_checkout_file "$@" "${files[@]}"
|
|
}
|
|
|
|
# git checkout-file from commit selector
|
|
_forgit_checkout_file_from_commit() {
|
|
_forgit_inside_work_tree || return 1
|
|
local opts commit file branch
|
|
|
|
if [[ $# -gt 0 ]]; then
|
|
branch=$1
|
|
shift
|
|
else
|
|
# default to the current branch if none was passed
|
|
branch=$(git rev-parse --abbrev-ref HEAD)
|
|
fi
|
|
|
|
# select the commit interactively
|
|
opts="
|
|
$FORGIT_FZF_DEFAULT_OPTS
|
|
+s +m --tiebreak=index
|
|
--preview=\"$FORGIT log_preview {}\"
|
|
$FORGIT_CHECKOUT_FILE_FROM_COMMIT_LOG_FZF_OPTS
|
|
"
|
|
commit=$(_forgit_git_log "$_forgit_log_format" "$branch" "$@" |
|
|
FZF_DEFAULT_OPTS="$opts" fzf |
|
|
_forgit_extract_sha)
|
|
[[ -n "$commit" ]] || return 0
|
|
|
|
# select the file interactively
|
|
opts="
|
|
$FORGIT_FZF_DEFAULT_OPTS
|
|
+m -0
|
|
--preview=\"$FORGIT preview show_preview {} '$_forgit_preview_context' $commit\"
|
|
--preview-label=\" Diff \"
|
|
--bind=\"alt-t:transform:[[ ! \\\"\$FZF_PREVIEW_LABEL\\\" =~ 'Diff' ]] &&
|
|
echo 'change-preview-label( Diff )+refresh-preview' ||
|
|
echo 'change-preview-label( Commit Message )+refresh-preview'\"
|
|
$FORGIT_CHECKOUT_FILE_FROM_COMMIT_SHOW_FZF_OPTS
|
|
--prompt=\"${commit} > \"
|
|
"
|
|
file=$(_forgit_git_show "$commit" | FZF_DEFAULT_OPTS="$opts" fzf)
|
|
[[ -n "$file" ]] || return 0
|
|
|
|
# special case: when the file was deleted in the commit
|
|
# check out the file from the previous commit.
|
|
[[ "$file" =~ ^\[D\] ]] && commit="$commit~"
|
|
file=$(echo "$file" | _forgit_get_single_file_from_diff_line)
|
|
|
|
_forgit_git_checkout_file "$commit" -- "$file"
|
|
}
|
|
|
|
_forgit_git_checkout_branch() {
|
|
_forgit_checkout_branch_git_opts=()
|
|
_forgit_parse_array _forgit_checkout_branch_git_opts "$FORGIT_CHECKOUT_BRANCH_GIT_OPTS"
|
|
git checkout "${_forgit_checkout_branch_git_opts[@]}" "$@"
|
|
}
|
|
|
|
# git checkout-branch selector
|
|
_forgit_checkout_branch() {
|
|
_forgit_inside_work_tree || return 1
|
|
# if called with arguments, check if branch exists, else create a new one
|
|
if [[ $# -ne 0 ]]; then
|
|
if [[ "$*" == "-" ]] || git show-branch "$@" &>/dev/null; then
|
|
git switch "$@"
|
|
else
|
|
git switch -c "$@"
|
|
fi
|
|
checkout_status=$?
|
|
git status --short
|
|
return $checkout_status
|
|
fi
|
|
|
|
local opts branch
|
|
opts="
|
|
$FORGIT_FZF_DEFAULT_OPTS
|
|
+s +m --tiebreak=index --header-lines=1
|
|
--preview=\"$FORGIT preview 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="$(_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
|
|
if [[ "$branch" == "remotes/"* ]]; then
|
|
if git branch | grep -qw "${branch#remotes/*/}"; then
|
|
# hack to force creating a new branch which tracks the remote if a local branch already exists
|
|
_forgit_git_checkout_branch -b "track/${branch#remotes/*/}" --track "$branch"
|
|
elif ! _forgit_git_checkout_branch --track "$branch" 2>/dev/null; then
|
|
_forgit_git_checkout_branch "$branch"
|
|
fi
|
|
else
|
|
_forgit_git_checkout_branch "$branch"
|
|
fi
|
|
}
|
|
|
|
_forgit_git_switch_branch() {
|
|
_forgit_switch_branch_git_opts=()
|
|
_forgit_parse_array _forgit_switch_branch_git_opts "$FORGIT_SWITCH_BRANCH_GIT_OPTS"
|
|
git switch "${_forgit_switch_branch_git_opts[@]}" "$@"
|
|
}
|
|
|
|
_forgit_switch_branch() {
|
|
_forgit_inside_work_tree || return 1
|
|
# if called with arguments, check if branch exists, else create a new one
|
|
if [[ $# -ne 0 ]]; then
|
|
if [[ "$*" == "-" ]] || git show-branch "$@" &>/dev/null; then
|
|
git switch "$@"
|
|
else
|
|
git switch -c "$@"
|
|
fi
|
|
checkout_status=$?
|
|
git status --short
|
|
return $checkout_status
|
|
fi
|
|
|
|
local opts branch
|
|
opts="
|
|
$FORGIT_FZF_DEFAULT_OPTS
|
|
+s +m --tiebreak=index --header-lines=1
|
|
--preview=\"$FORGIT preview 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="$(_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
|
|
if [[ "$branch" == "remotes/"* ]]; then
|
|
if git branch | grep -qw "${branch#remotes/*/}"; then
|
|
# hack to force creating a new branch which tracks the remote if a local branch already exists
|
|
_forgit_git_switch_branch --create "track/${branch#remotes/*/}" --track "$branch"
|
|
elif ! _forgit_git_switch_branch --track "$branch" 2>/dev/null; then
|
|
_forgit_git_switch_branch "$branch"
|
|
fi
|
|
else
|
|
_forgit_git_switch_branch "$branch"
|
|
fi
|
|
}
|
|
|
|
_forgit_git_checkout_tag() {
|
|
_forgit_checkout_tag_git_opts=()
|
|
_forgit_parse_array _forgit_checkout_tag_git_opts "$FORGIT_CHECKOUT_TAG_GIT_OPTS"
|
|
git checkout "${_forgit_checkout_tag_git_opts[@]}" "$@"
|
|
}
|
|
|
|
# git checkout-tag selector
|
|
_forgit_checkout_tag() {
|
|
_forgit_inside_work_tree || return 1
|
|
local opts tag
|
|
[[ $# -ne 0 ]] && { _forgit_git_checkout_tag "$@"; return $?; }
|
|
[[ $(git tag -l | wc -l) -eq 0 ]] && echo 'Nothing to checkout: there are no tags.' && return 1
|
|
opts="
|
|
$FORGIT_FZF_DEFAULT_OPTS
|
|
+s +m --tiebreak=index
|
|
--preview=\"$FORGIT preview branch_preview {}\"
|
|
$FORGIT_CHECKOUT_TAG_FZF_OPTS
|
|
"
|
|
tag="$(git tag -l --sort=-v:refname | FZF_DEFAULT_OPTS="$opts" fzf)"
|
|
[[ -z "$tag" ]] && return 1
|
|
_forgit_git_checkout_tag "$tag"
|
|
}
|
|
|
|
_forgit_checkout_commit_preview() {
|
|
local sha
|
|
sha=$(echo "$1" | _forgit_extract_sha)
|
|
git show --color=always "${sha}" | _forgit_pager show
|
|
}
|
|
|
|
_forgit_git_checkout_commit() {
|
|
_forgit_checkout_commit_git_opts=()
|
|
_forgit_parse_array _forgit_checkout_commit_git_opts "$FORGIT_CHECKOUT_COMMIT_GIT_OPTS"
|
|
git checkout "${_forgit_checkout_commit_git_opts[@]}" "$@"
|
|
}
|
|
|
|
# git checkout-commit selector
|
|
_forgit_checkout_commit() {
|
|
_forgit_inside_work_tree || return 1
|
|
local opts graph commit
|
|
[[ $# -ne 0 ]] && { _forgit_git_checkout_commit "$@"; return $?; }
|
|
opts="
|
|
$FORGIT_FZF_DEFAULT_OPTS
|
|
+s +m --tiebreak=index
|
|
--bind=\"ctrl-y:execute-silent($FORGIT yank_sha {})\"
|
|
--preview=\"$FORGIT preview checkout_commit_preview {}\"
|
|
$FORGIT_CHECKOUT_COMMIT_FZF_OPTS
|
|
"
|
|
graph=()
|
|
[[ $_forgit_log_graph_enable == true ]] && graph=(--graph)
|
|
commit="$(git log "${graph[@]}" --color=always --format="$_forgit_log_format" |
|
|
_forgit_emojify |
|
|
FZF_DEFAULT_OPTS="$opts" fzf | _forgit_extract_sha)"
|
|
_forgit_git_checkout_commit "$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 "$branch" "${_forgit_log_preview_options[@]}" --
|
|
}
|
|
|
|
_forgit_git_branch_delete() {
|
|
_forgit_branch_delete_git_opts=()
|
|
_forgit_parse_array _forgit_branch_delete_git_opts "$FORGIT_BRANCH_DELETE_GIT_OPTS"
|
|
git branch "${_forgit_branch_delete_git_opts[@]}" -D "$@"
|
|
}
|
|
|
|
_forgit_branch_delete() {
|
|
_forgit_inside_work_tree || return 1
|
|
local opts
|
|
[[ $# -ne 0 ]] && { _forgit_git_branch_delete "$@"; return $?; }
|
|
|
|
opts="
|
|
$FORGIT_FZF_DEFAULT_OPTS
|
|
+s --multi --tiebreak=index --header-lines=1
|
|
--preview=\"$FORGIT preview branch_preview {}\"
|
|
$FORGIT_BRANCH_DELETE_FZF_OPTS
|
|
"
|
|
for branch in $(_forgit_branch_list | FZF_DEFAULT_OPTS="$opts" fzf | _forgit_extract_branch_name)
|
|
do
|
|
_forgit_git_branch_delete "$branch"
|
|
done
|
|
}
|
|
|
|
_forgit_revert_preview() {
|
|
local sha
|
|
sha=$(echo "$1" | cut -f2- | _forgit_extract_sha)
|
|
git show --color=always "${sha}" | _forgit_pager show
|
|
}
|
|
|
|
_forgit_git_revert() {
|
|
_forgit_revert_commit_git_opts=()
|
|
_forgit_parse_array _forgit_revert_commit_git_opts "$FORGIT_REVERT_COMMIT_GIT_OPTS"
|
|
git revert "${_forgit_revert_commit_git_opts[@]}" "$@"
|
|
}
|
|
|
|
# git revert-commit selector
|
|
_forgit_revert_commit() {
|
|
_forgit_inside_work_tree || return 1
|
|
local opts commits IFS
|
|
[[ $# -ne 0 ]] && { _forgit_git_revert "$@"; return $?; }
|
|
|
|
opts="
|
|
$FORGIT_FZF_DEFAULT_OPTS
|
|
-m +s --tiebreak=index
|
|
--ansi --with-nth 2..
|
|
--preview=\"$FORGIT preview revert_preview {}\"
|
|
$FORGIT_REVERT_COMMIT_FZF_OPTS
|
|
"
|
|
graph=()
|
|
[[ $_forgit_log_graph_enable == true ]] && graph=(--graph)
|
|
|
|
# in this function, we do something interesting to maintain proper ordering as it's assumed
|
|
# you generally want to revert newest->oldest when you multiselect
|
|
# The instances of "cut", "nl" and "sort" all serve this purpose
|
|
# Please see https://github.com/wfxr/forgit/issues/253 for more details
|
|
|
|
commits=()
|
|
while IFS='' read -r commit; do
|
|
commits+=("$commit")
|
|
done < <(
|
|
git log "${graph[@]}" --color=always --format="$_forgit_log_format" |
|
|
_forgit_emojify |
|
|
nl |
|
|
FZF_DEFAULT_OPTS="$opts" fzf |
|
|
sort -n -k 1 |
|
|
cut -f2- |
|
|
sed 's/^[^a-f^0-9]*\([a-f0-9]*\).*/\1/')
|
|
|
|
[ ${#commits[@]} -eq 0 ] && return 1
|
|
|
|
_forgit_git_revert "${commits[@]}"
|
|
}
|
|
|
|
_forgit_blame_preview() {
|
|
if _forgit_is_file_tracked "$1"; then
|
|
_forgit_blame_git_opts=()
|
|
_forgit_parse_array _forgit_blame_git_opts "$FORGIT_BLAME_GIT_OPTS"
|
|
git blame --date=short "${_forgit_blame_git_opts[@]}" "$@" | _forgit_pager blame
|
|
else
|
|
echo "File not tracked"
|
|
fi
|
|
}
|
|
|
|
_forgit_git_blame() {
|
|
_forgit_blame_git_opts=()
|
|
_forgit_parse_array _forgit_blame_git_opts "$FORGIT_BLAME_GIT_OPTS"
|
|
git blame "${_forgit_blame_git_opts[@]}" "$@"
|
|
}
|
|
|
|
# git blame viewer
|
|
_forgit_blame() {
|
|
_forgit_inside_work_tree || return 1
|
|
local opts flags file
|
|
_forgit_contains_non_flags "$@" && { _forgit_git_blame "$@"; return $?; }
|
|
flags=()
|
|
while IFS='' read -r flag; do
|
|
flags+=("$flag")
|
|
done < <(git rev-parse --flags "$@")
|
|
opts="
|
|
$FORGIT_FZF_DEFAULT_OPTS
|
|
--preview=\"$FORGIT preview blame_preview {} ${flags[*]}\"
|
|
$FORGIT_BLAME_FZF_OPTS
|
|
"
|
|
# flags is not quoted here, which is fine given that they are retrieved
|
|
# with git rev-parse and can only contain flags
|
|
file=$(FZF_DEFAULT_OPTS="$opts" fzf)
|
|
[[ -z "$file" ]] && return 1
|
|
_forgit_git_blame "$file" "${flags[@]}"
|
|
}
|
|
|
|
# git ignore generator
|
|
export FORGIT_GI_REPO_REMOTE=${FORGIT_GI_REPO_REMOTE:-https://github.com/github/gitignore}
|
|
export FORGIT_GI_REPO_LOCAL="${FORGIT_GI_REPO_LOCAL:-${XDG_CACHE_HOME:-$HOME/.cache}/forgit/gi/repos/github/gitignore}"
|
|
export FORGIT_GI_TEMPLATES=${FORGIT_GI_TEMPLATES:-$FORGIT_GI_REPO_LOCAL}
|
|
|
|
_forgit_path_preview() {
|
|
local path name ext pager
|
|
path=$1
|
|
name=$2
|
|
ext=$3
|
|
pager=$4
|
|
quoted_files=()
|
|
while IFS='' read -r file; do
|
|
quoted_files+=("'$file'")
|
|
done < <(find -L "$path" -type f -name "$name" -o -name "$name$ext")
|
|
_forgit_pager "$pager" "${quoted_files[@]}" 2>/dev/null
|
|
}
|
|
|
|
_forgit_ignore() {
|
|
[ -d "$FORGIT_GI_REPO_LOCAL" ] \
|
|
|| _forgit_repo_update "$FORGIT_GI_REPO_REMOTE" "$FORGIT_GI_REPO_LOCAL"
|
|
local IFS args opts
|
|
opts="
|
|
$FORGIT_FZF_DEFAULT_OPTS
|
|
-m --preview-window='right:70%'
|
|
--preview=\"$FORGIT preview path_preview $FORGIT_GI_TEMPLATES {2} .gitignore ignore\"
|
|
$FORGIT_IGNORE_FZF_OPTS
|
|
"
|
|
args=("$@")
|
|
if [[ $# -eq 0 ]]; then
|
|
args=()
|
|
while IFS='' read -r arg; do
|
|
args+=("$arg")
|
|
done < <(_forgit_paths_list "$FORGIT_GI_TEMPLATES" .gitignore |
|
|
nl -w4 -s' ' |
|
|
FZF_DEFAULT_OPTS="$opts" fzf | awk '{print $2}')
|
|
fi
|
|
[ ${#args[@]} -eq 0 ] && return 1
|
|
_forgit_path_get "$FORGIT_GI_TEMPLATES" .gitignore "${args[@]}"
|
|
}
|
|
|
|
# git attributes generator
|
|
export FORGIT_ATTR_REPO_REMOTE=${FORGIT_ATTR_REPO_REMOTE:-https://github.com/gitattributes/gitattributes}
|
|
export FORGIT_ATTR_REPO_LOCAL=${FORGIT_ATTR_REPO_LOCAL:-${XDG_CACHE_HOME:-$HOME/.cache}/forgit/gat/repos/gitattributes/gitattributes}
|
|
export FORGIT_ATTR_TEMPLATES=${FORGIT_ATTR_TEMPLATES:-$FORGIT_ATTR_REPO_LOCAL}
|
|
|
|
_forgit_attributes() {
|
|
[ -d "$FORGIT_ATTR_REPO_LOCAL" ] \
|
|
|| _forgit_repo_update "$FORGIT_ATTR_REPO_REMOTE" "$FORGIT_ATTR_REPO_LOCAL"
|
|
local IFS args opts
|
|
opts="
|
|
$FORGIT_FZF_DEFAULT_OPTS
|
|
-m --preview-window='right:70%'
|
|
--preview=\"$FORGIT preview path_preview $FORGIT_ATTR_TEMPLATES {2} .gitattributes attributes\"
|
|
$FORGIT_ATTRIBUTES_FZF_OPTS
|
|
"
|
|
args=("$@")
|
|
if [[ $# -eq 0 ]]; then
|
|
args=()
|
|
while IFS='' read -r arg; do
|
|
args+=("$arg")
|
|
done < <(_forgit_paths_list "$FORGIT_ATTR_TEMPLATES" .gitattributes |
|
|
nl -w4 -s' ' |
|
|
FZF_DEFAULT_OPTS="$opts" fzf | awk '{print $2}')
|
|
fi
|
|
[ ${#args[@]} -eq 0 ] && return 1
|
|
_forgit_path_get "$FORGIT_ATTR_TEMPLATES" .gitattributes "${args[@]}"
|
|
}
|
|
|
|
_forgit_repo_update() {
|
|
local remote path
|
|
remote=$1
|
|
path=$2
|
|
if [[ -d "$path" ]]; then
|
|
_forgit_info 'Updating repo...'
|
|
(cd "$path" && git pull --no-rebase --ff) || return 1
|
|
else
|
|
_forgit_info 'Initializing repo...'
|
|
git clone --depth=1 "$remote" "$path"
|
|
fi
|
|
}
|
|
|
|
_forgit_path_get() {
|
|
local path ext item filename header
|
|
path=$1
|
|
ext=$2
|
|
shift 2
|
|
for item in "$@"; do
|
|
if filename=$(find -L "$path" -type f \( -iname "${item}$ext" -o -iname "${item}" \) -print -quit); then
|
|
[[ -z "$filename" ]] && _forgit_warn "No template found for '$item'." && continue
|
|
header="${filename##*/}" && header="${header%"$ext"}"
|
|
echo "### $header" && cat "$filename" && echo
|
|
fi
|
|
done
|
|
}
|
|
|
|
_forgit_paths_list() {
|
|
local path ext
|
|
path=$1
|
|
ext=$2
|
|
find "$path" -name "*$ext" -print |sed -e "s#$ext\$##" -e 's#.*/##' -e '/^$/d' | sort -fu
|
|
}
|
|
|
|
# Get the root path of the main worktree (not the current worktree)
|
|
_forgit_main_worktree_root() {
|
|
git worktree list --porcelain | sed -n 's/^worktree //p;q'
|
|
}
|
|
|
|
# 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 preview 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 $?; }
|
|
|
|
local candidates
|
|
candidates=$(_forgit_worktree_list_deletable)
|
|
if [[ -z "$candidates" ]]; then
|
|
echo "Nothing to delete."
|
|
return 1
|
|
fi
|
|
|
|
opts="
|
|
$FORGIT_FZF_DEFAULT_OPTS
|
|
+s --multi --tiebreak=index
|
|
--preview=\"$FORGIT preview worktree_preview {}\"
|
|
--bind=\"ctrl-y:execute-silent($FORGIT worktree_yank_path {})\"
|
|
--bind=\"alt-l:execute-silent($FORGIT worktree_toggle_lock {})+reload($FORGIT worktree_list_deletable)\"
|
|
$FORGIT_WORKTREE_DELETE_FZF_OPTS
|
|
"
|
|
|
|
worktrees=()
|
|
while IFS='' read -r line; do
|
|
[[ -n "$line" ]] && worktrees+=("$(echo "$line" | _forgit_extract_worktree_path)")
|
|
done < <(echo "$candidates" | FZF_DEFAULT_OPTS="$opts" fzf)
|
|
|
|
[[ ${#worktrees[@]} -eq 0 ]] && return 1
|
|
|
|
for worktree in "${worktrees[@]}"; do
|
|
_forgit_git_worktree_delete "$worktree"
|
|
done
|
|
}
|
|
|
|
_forgit_worktree_add() {
|
|
_forgit_inside_git_repo || return 1
|
|
|
|
# Default to main worktree root, not current worktree's root
|
|
local wt_dir="${FORGIT_WORKTREE_ADD_DIR:-$(_forgit_main_worktree_root)/.wt}"
|
|
|
|
# Two or more arguments: pass through to git directly
|
|
if [[ $# -ge 2 ]]; then
|
|
git worktree add "$@"
|
|
return $?
|
|
fi
|
|
|
|
local new_branch="${1:-}"
|
|
local opts branch
|
|
|
|
_forgit_worktree_add_branch_git_opts=()
|
|
_forgit_parse_array _forgit_worktree_add_branch_git_opts "$FORGIT_WORKTREE_ADD_BRANCH_GIT_OPTS"
|
|
|
|
local header_opt=""
|
|
[[ -z "$new_branch" ]] && header_opt="--header-lines=1"
|
|
|
|
opts="
|
|
$FORGIT_FZF_DEFAULT_OPTS
|
|
+s +m --tiebreak=index $header_opt
|
|
--preview=\"$FORGIT branch_preview {}\"
|
|
$FORGIT_WORKTREE_ADD_FZF_OPTS
|
|
"
|
|
branch=$(_forgit_branch_list "${_forgit_worktree_add_branch_git_opts[@]:---all}" |
|
|
FZF_DEFAULT_OPTS="$opts" fzf | _forgit_extract_branch_name)
|
|
[[ -z "$branch" ]] && return 1
|
|
|
|
# Strip remotes/<remote>/ prefix for remote branches
|
|
local local_branch="${branch#remotes/*/}"
|
|
|
|
if [[ -n "$new_branch" ]]; then
|
|
# gwa <new-branch>: create new branch from selected base
|
|
local target="$wt_dir/$new_branch"
|
|
git worktree add -b "$new_branch" "$target" "$branch" >&2 && echo "$target"
|
|
else
|
|
# gwa: create worktree for selected branch
|
|
local target="$wt_dir/$local_branch"
|
|
git worktree add "$target" "$branch" >&2 && echo "$target"
|
|
fi
|
|
}
|
|
|
|
check_prequisites() {
|
|
local installed_fzf_version
|
|
local higher_fzf_version
|
|
|
|
# Check if fzf is installed
|
|
installed_fzf_version=$(fzf --version 2>/dev/null | awk '{print $1}')
|
|
if [[ -z "$installed_fzf_version" ]]; then
|
|
echo "fzf is not installed. Please install fzf first."
|
|
exit 1
|
|
fi
|
|
|
|
# Check fzf version
|
|
higher_fzf_version=$(printf '%s\n' "$REQUIRED_FZF_VERSION" "$installed_fzf_version" | sort -V | tail -n1)
|
|
if [[ "$higher_fzf_version" != "$installed_fzf_version" ]]; then
|
|
echo "fzf version $REQUIRED_FZF_VERSION or higher is required. You have $installed_fzf_version."
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
main() {
|
|
local cmd="$1"
|
|
shift
|
|
|
|
check_prequisites
|
|
|
|
# Set shell for fzf preview commands
|
|
SHELL="$(which bash)"
|
|
export SHELL
|
|
|
|
# Get absolute forgit path
|
|
FORGIT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)/$(basename -- "${BASH_SOURCE[0]}")
|
|
|
|
# shellcheck disable=SC2076
|
|
if [[ ! " ${PUBLIC_COMMANDS[*]} " =~ " ${cmd} " ]] && [[ ! " ${PRIVATE_COMMANDS[*]} " =~ " ${cmd} " ]]; then
|
|
if [[ -z "$cmd" ]]; then
|
|
printf "forgit: missing command\n\n"
|
|
else
|
|
printf "forgit: '%s' is not a valid forgit command.\n\n" "$cmd"
|
|
fi
|
|
printf "The following commands are supported:\n"
|
|
printf "\t%s\n" "${PUBLIC_COMMANDS[@]}"
|
|
exit 1
|
|
fi
|
|
|
|
_forgit_"${cmd}" "$@"
|
|
}
|
|
|
|
PUBLIC_COMMANDS=(
|
|
"add"
|
|
"attributes"
|
|
"blame"
|
|
"branch_delete"
|
|
"checkout_branch"
|
|
"switch_branch"
|
|
"checkout_commit"
|
|
"checkout_file"
|
|
"checkout_file_from_commit"
|
|
"checkout_tag"
|
|
"cherry_pick"
|
|
"cherry_pick_from_branch"
|
|
"clean"
|
|
"diff"
|
|
"fixup"
|
|
"squash"
|
|
"reword"
|
|
"ignore"
|
|
"log"
|
|
"reflog"
|
|
"rebase"
|
|
"reset_head"
|
|
"revert_commit"
|
|
"show"
|
|
"stash_show"
|
|
"stash_push"
|
|
"worktree"
|
|
"worktree_add"
|
|
"worktree_delete"
|
|
)
|
|
|
|
PRIVATE_COMMANDS=(
|
|
"diff_enter"
|
|
"exec_show"
|
|
"preview"
|
|
"show_enter"
|
|
"stash_show_enter"
|
|
"yank_sha"
|
|
"yank_stash_name"
|
|
"log_enter"
|
|
"exec_diff"
|
|
"diff_view"
|
|
"edit_diffed_file"
|
|
"edit_add_file"
|
|
"pager"
|
|
"worktree_yank_path"
|
|
"worktree_toggle_lock"
|
|
"worktree_list"
|
|
"worktree_list_deletable"
|
|
)
|
|
|
|
# Check if the script is being sourced. This is necessary for unit tests where
|
|
# we do not want to execute the main function.
|
|
if [[ "${BASH_SOURCE[0]}" != "$0" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
main "${@}"
|