#!/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. # Split on any whitespace (spaces, tabs, newlines) and use an empty # delimiter so the whole value is consumed instead of stopping at the # first line; leading/trailing whitespace is ignored. IFS=$' \t\n' read -r -d '' -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 } _forgit_list_staged_files() { local up up="$(git rev-parse --show-cdup)" git diff --name-only --cached -z | tr '\0' '\n' | awk -v up="$up" '{ print up $0 }' } # 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 = ) { 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/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 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 preview 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 preview 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// prefix for remote branches local local_branch="${branch#remotes/*/}" if [[ -n $new_branch ]]; then # gwa : 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_prerequisites() { 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_prerequisites # 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" "restore" "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 "${@}"