.dotfiles/.config/fish/conf.d/bin/git-forgit
2024-10-22 23:45:53 +03:00

1070 lines
35 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.
# Set shell for fzf preview commands
# Disable shellcheck for "which", because it suggests "command -v xxx" instead,
# which is not a working replacement.
# See https://github.com/koalaman/shellcheck/issues/1162
# shellcheck disable=2230
SHELL="$(which bash)"
export SHELL
# Get absolute forgit path
FORGIT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)/$(basename -- "${BASH_SOURCE[0]}")
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_inside_work_tree() { git rev-parse --is-inside-work-tree >/dev/null; }
# 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_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
pager=$(_forgit_get_pager "$1")
[[ -z "${pager}" ]] && exit 1
eval "${pager} ${*:2}"
}
_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')}" ;;
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
}
_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
}
_forgit_log_preview() {
local sha
sha=$(echo "$1" | _forgit_extract_sha)
shift
echo "$sha" | xargs -I% git show --color=always -U"$_forgit_preview_context" % -- "$@" | _forgit_pager show
}
_forgit_log_enter() {
local sha
sha=$(echo "$1" | _forgit_extract_sha)
shift
echo "$sha" | xargs -I% "${FORGIT}" diff %^! "$@"
}
# git commit viewer
_forgit_log() {
_forgit_inside_work_tree || return 1
local opts graph 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 log_preview {} $quoted_files\"
$FORGIT_LOG_FZF_OPTS
"
graph=()
[[ $_forgit_log_graph_enable == true ]] && graph=(--graph)
log_format=${FORGIT_GLO_FORMAT:-$_forgit_log_format}
_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 |
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 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_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.
sed 's/^[[:space:]]*\[[A-Z0-9]*\][[:space:]]*//' | 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
sed 's/^[[:space:]]*\[[A-Z0-9]*\][[:space:]]*//' | 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 diff_view {} '$_forgit_preview_context' $escaped_commits\"
--bind=\"alt-e:execute-silent($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_add_preview() {
file=$(echo "$1" | _forgit_get_single_file_from_add_line)
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_get_single_file_from_add_line() {
# NOTE: paths listed by 'git status -su' mixed with quoted and unquoted style
# remove indicators | remove original path for rename case | remove surrounding quotes
sed 's/^.*] //' |
sed 's/.* -> //' |
sed -e 's/^\"//' -e 's/\"$//'
}
_forgit_edit_add_file() {
local input_line=$1
filename=$(echo "$input_line" | _forgit_get_single_file_from_add_line)
$EDITOR "$filename" >/dev/tty </dev/tty
}
# git add selector
_forgit_add() {
_forgit_inside_work_tree || return 1
local changed unmerged untracked files opts
# Add files if passed as arguments
[[ $# -ne 0 ]] && { _forgit_git_add "$@" && git status -s; return $?; }
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)
opts="
$FORGIT_FZF_DEFAULT_OPTS
-0 -m --nth 2..,..
--preview=\"$FORGIT add_preview {}\"
--bind=\"alt-e:execute-silent($FORGIT edit_add_file {})+refresh-preview\"
$FORGIT_ADD_FZF_OPTS
"
files=()
while IFS='' read -r file; do
files+=("$file")
done < <(git -c color.status=always -c status.relativePaths=true -c core.quotePath=false status -su |
grep -F -e "$changed" -e "$unmerged" -e "$untracked" |
sed -E 's/^(..[^[:space:]]*)[[:space:]]+(.*)$/[\1] \2/' |
FZF_DEFAULT_OPTS="$opts" fzf |
_forgit_get_single_file_from_add_line)
[[ "${#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 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" | cut -d: -f1)
_forgit_git_stash_show "$stash" | _forgit_pager diff
}
_forgit_git_stash_show() {
git stash show --color=always --ext-diff "$@"
}
# git stash viewer
_forgit_stash_show() {
_forgit_inside_work_tree || return 1
local opts
[[ $# -ne 0 ]] && { _forgit_git_stash_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_preview {} | $FORGIT pager enter)\"
--bind=\"ctrl-y:execute-silent($FORGIT yank_stash_name {})\"
--preview=\"$FORGIT 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 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
}
# git clean selector
_forgit_clean() {
_forgit_inside_work_tree || return 1
_forgit_contains_non_flags "$@" && { git clean -q "$@"; return $?; }
local files opts
_forgit_clean_git_opts=()
_forgit_parse_array _forgit_clean_git_opts "$FORGIT_CLEAN_GIT_OPTS"
opts="
$FORGIT_FZF_DEFAULT_OPTS
--preview=\"$FORGIT clean_preview {}\"
-m -0
$FORGIT_CLEAN_FZF_OPTS
"
# Note: Postfix '/' in directory path should be removed. Otherwise the directory itself will not be removed.
files=$(git -c core.quotePath=false clean -xdffn "$@"| sed 's/^Would remove //' | FZF_DEFAULT_OPTS="$opts" fzf |sed 's#/$##')
[[ -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() {
echo "$1" | cut -f2- | _forgit_extract_sha | xargs -I% git show --color=always % | _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 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() {
git log --right-only --color=always --cherry-pick --oneline "$1"..."$2"
}
_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 cherry_pick_from_branch_preview '$base' {1}\"
$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}')"
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
local opts graph quoted_files 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"
quoted_files=$(_forgit_quote_files "$@")
opts="
$FORGIT_FZF_DEFAULT_OPTS
+s +m --tiebreak=index
--bind=\"ctrl-y:execute-silent($FORGIT yank_sha {})\"
--preview=\"$FORGIT file_preview {} $quoted_files\"
$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
echo "$sha" | xargs -I% git show --color=always % -- "$@" | _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
local opts graph quoted_files target_commit prev_commit
graph=()
[[ $_forgit_log_graph_enable == true ]] && graph=(--graph)
_forgit_fixup_git_opts=()
_forgit_parse_array _forgit_fixup_git_opts "$FORGIT_FIXUP_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 file_preview {} $quoted_files\"
$FORGIT_FIXUP_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 "${_forgit_fixup_git_opts[@]}" --fixup "$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
[[ $# -ne 0 ]] && { _forgit_git_checkout_file -- "$@"; return $?; }
opts="
$FORGIT_FZF_DEFAULT_OPTS
-m -0
--preview=\"$FORGIT 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[@]}"
}
_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 branch_preview {1}\"
$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}')"
[[ -z "$branch" ]] && return 1
# track the remote branch if possible
if [[ "$branch" == "remotes/origin/"* ]]; then
if git branch | grep -qw "${branch#remotes/origin/}"; 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/origin/}" --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_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
[[ $# -ne 0 ]] && { _forgit_git_checkout_tag "$@"; return $?; }
opts="
$FORGIT_FZF_DEFAULT_OPTS
+s +m --tiebreak=index
--preview=\"$FORGIT 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() {
echo "$1" | _forgit_extract_sha | xargs -I% git show --color=always % | _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 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() {
# 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[@]}" --
}
_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 branch_preview {1}\"
$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}')
do
_forgit_git_branch_delete "$branch"
done
}
_forgit_revert_preview() {
echo "$1" |
cut -f2- |
_forgit_extract_sha |
xargs -I% git show --color=always % |
_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 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 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/dvcs/gitignore}
export FORGIT_GI_REPO_LOCAL="${FORGIT_GI_REPO_LOCAL:-${XDG_CACHE_HOME:-$HOME/.cache}/forgit/gi/repos/dvcs/gitignore}"
export FORGIT_GI_TEMPLATES=${FORGIT_GI_TEMPLATES:-$FORGIT_GI_REPO_LOCAL/templates}
_forgit_ignore_preview() {
quoted_files=()
for file in "$FORGIT_GI_TEMPLATES/$1"{,.gitignore}; do
quoted_files+=("'$file'")
done
_forgit_pager ignore "${quoted_files[@]}" 2>/dev/null
}
_forgit_ignore() {
[ -d "$FORGIT_GI_REPO_LOCAL" ] || _forgit_ignore_update
local IFS args opts
opts="
$FORGIT_FZF_DEFAULT_OPTS
-m --preview-window='right:70%'
--preview=\"$FORGIT ignore_preview {2}\"
$FORGIT_IGNORE_FZF_OPTS
"
args=("$@")
if [[ $# -eq 0 ]]; then
args=()
while IFS='' read -r arg; do
args+=("$arg")
done < <(_forgit_ignore_list | nl -w4 -s' ' |
FZF_DEFAULT_OPTS="$opts" fzf | awk '{print $2}')
fi
[ ${#args[@]} -eq 0 ] && return 1
_forgit_ignore_get "${args[@]}"
}
_forgit_ignore_update() {
if [[ -d "$FORGIT_GI_REPO_LOCAL" ]]; then
_forgit_info 'Updating gitignore repo...'
(cd "$FORGIT_GI_REPO_LOCAL" && git pull --no-rebase --ff) || return 1
else
_forgit_info 'Initializing gitignore repo...'
git clone --depth=1 "$FORGIT_GI_REPO_REMOTE" "$FORGIT_GI_REPO_LOCAL"
fi
}
_forgit_ignore_get() {
local item filename header
for item in "$@"; do
if filename=$(find -L "$FORGIT_GI_TEMPLATES" -type f \( -iname "${item}.gitignore" -o -iname "${item}" \) -print -quit); then
[[ -z "$filename" ]] && _forgit_warn "No gitignore template found for '$item'." && continue
header="${filename##*/}" && header="${header%.gitignore}"
echo "### $header" && cat "$filename" && echo
fi
done
}
_forgit_ignore_list() {
find "$FORGIT_GI_TEMPLATES" -print |sed -e 's#.gitignore$##' -e 's#.*/##' | sort -fu
}
_forgit_ignore_clean() {
setopt localoptions rmstarsilent
[[ -d "$FORGIT_GI_REPO_LOCAL" ]] && rm -rf "$FORGIT_GI_REPO_LOCAL"
}
public_commands=(
"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"
"stash_show"
"stash_push"
)
private_commands=(
"add_preview"
"blame_preview"
"branch_preview"
"checkout_commit_preview"
"checkout_file_preview"
"cherry_pick_from_branch_preview"
"cherry_pick_preview"
"clean_preview"
"diff_enter"
"file_preview"
"ignore_preview"
"revert_preview"
"reset_head_preview"
"stash_push_preview"
"stash_show_preview"
"yank_sha"
"yank_stash_name"
"log_preview"
"log_enter"
"exec_diff"
"diff_view"
"edit_diffed_file"
"edit_add_file"
"pager"
)
cmd="$1"
shift
# 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}" "$@"