#!/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 -+F -+E"}" ;;
        *) 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_rewrite_repo_paths_for_cwd() {
    local rootdir mode separator cwd
    rootdir=$1
    mode=${2:-paths}
    separator=${3:-}
    cwd=$(pwd -P)

    perl -MCwd=realpath -MFile::Spec -e '
        use strict;
        use warnings;

        my ($rootdir, $cwd, $mode, $separator) = @ARGV;
        my $normalized_rootdir = realpath($rootdir);
        my $normalized_cwd = realpath($cwd);

        while (my $line = <STDIN>) {
            chomp $line;

            my ($status, $repo_path);
            if ($mode eq q{status_entries}) {
                next unless $line =~ /^(..[^[:space:]]*)( )(.*)$/;
                ($status, $repo_path) = ($1, $3);
            } else {
                next if $line eq q{};
                $repo_path = $line;
            }

            my $absolute_path = "$normalized_rootdir/$repo_path";
            my $display_path = File::Spec->abs2rel(realpath($absolute_path) // $absolute_path, $normalized_cwd);
            $display_path = q{.} if $display_path eq q{};

            if ($mode eq q{status_entries}) {
                print "[$status]  ${display_path}${separator}${normalized_rootdir}/${repo_path}\n";
            } else {
                print "$display_path\n";
            }
        }
    ' "$rootdir" "$cwd" "$mode" "$separator"
}

_forgit_list_modified_files() {
    # on large repos with many untracked files, git ls-files
    # is substantially slower than git diff
    # Wherever possible (when we don't care about untracked files)
    # use this command over _forgit_list_files

    # See note on _forgit_list_files() about -z and other options
    local rootdir
    rootdir=$(git rev-parse --show-toplevel)
    git -C "$rootdir" diff --name-only -z |
        tr '\0' '\n' |
        uniq |
        _forgit_rewrite_repo_paths_for_cwd "$rootdir"
}

_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 rootdir
    rootdir=$1

    # Use a single Perl process so path normalization stays portable while the
    # full add-list transformation still runs as one batch pipeline stage.
    _forgit_rewrite_repo_paths_for_cwd "$rootdir" status_entries "$_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_restore_preview() {
    git diff --color=always "$@" | _forgit_pager diff
}

_forgit_git_restore() {
    _forgit_restore_git_opts=()
    _forgit_parse_array _forgit_restore_git_opts "$FORGIT_RESTORE_GIT_OPTS"
    git restore "${_forgit_restore_git_opts[@]}" "$@"
}

_forgit_parse_restore_flags() {
    local staged worktree preview_arg
    staged=false
    worktree=false

    for arg in "$@"; do
        case "$arg" in
            -S | --staged) staged=true ;;
            -W | --worktree) worktree=true ;;
        esac
    done

    if [[ $staged == true && $worktree != true ]]; then
        preview_arg=--staged
    elif [[ $staged == true && $worktree == true ]]; then
        preview_arg=HEAD
    fi

    printf '%s\t%s\t%s' "$staged" "$worktree" "$preview_arg"
}

# git restore selector
_forgit_restore() {
    _forgit_inside_work_tree || return 1
    _forgit_contains_non_flags "$@" && {
        _forgit_git_restore "$@"
        return $?
    }
    local files opts staged worktree candidates preview_arg
    IFS=$'\t' read -r staged worktree preview_arg < <(_forgit_parse_restore_flags "$@")
    opts="
        $FORGIT_FZF_DEFAULT_OPTS
        -m -0
        --preview=\"$FORGIT preview restore_preview $preview_arg -- {}\"
        $FORGIT_RESTORE_FZF_OPTS
    "

    candidates=()
    if [[ $staged == true ]]; then
        while IFS='' read -r file; do
            candidates+=("$file")
        done < <(_forgit_list_staged_files)
    fi
    if [[ $worktree == true || $staged != true ]]; then
        while IFS='' read -r file; do
            candidates+=("$file")
        done < <(_forgit_list_modified_files)
    fi

    [[ ${#candidates[@]} -eq 0 ]] && echo "Nothing to restore." && return 1

    files=()
    while IFS='' read -r file; do
        files+=("$file")
    done < <(printf '%s\n' "${candidates[@]}" | sort -u | FZF_DEFAULT_OPTS="$opts" fzf)
    [[ ${#files[@]} -gt 0 ]] && _forgit_git_restore "$@" -- "${files[@]}"
}

_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 modified_files
    _forgit_contains_non_flags "$@" && {
        _forgit_git_checkout_file "$@"
        return $?
    }
    modified_files="$(_forgit_list_modified_files)"
    [[ -z $modified_files ]] && 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 < <(printf '%s\n' "$modified_files" |
        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/<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_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 "${@}"
