#!/usr/bin/env bash
# MIT (c) Wenxuan Zhang

# This file is meant to be executed directly. If it's available on the PATH,
# it can also be used as a subcommand of git, which then forwards all arguments
# on to forgit. So, all of these commands will work as expected:
#
# `git forgit log`
# `git forgit checkout_file`
# `git forgit checkout_file README.md`
#
# This gives users the choice to set aliases inside of their git config instead
# of their shell config if they prefer.

# Set shell for fzf preview commands
# Disable shellcheck for "which", because it suggests "command -v xxx" instead,
# which is not a working replacement.
# See https://github.com/koalaman/shellcheck/issues/1162
# shellcheck disable=2230
SHELL="$(which bash)"
export SHELL

# Get absolute forgit path
FORGIT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)/$(basename -- "${BASH_SOURCE[0]}")

FORGIT_FZF_DEFAULT_OPTS="
$FZF_DEFAULT_OPTS
--ansi
--height='80%'
--bind='alt-k:preview-up,alt-p:preview-up'
--bind='alt-j:preview-down,alt-n:preview-down'
--bind='ctrl-r:toggle-all'
--bind='ctrl-s:toggle-sort'
--bind='?:toggle-preview'
--bind='alt-w:toggle-preview-wrap'
--preview-window='right:60%'
+1
$FORGIT_FZF_DEFAULT_OPTS
"

_forgit_warn() { printf "%b[Warn]%b %s\n" '\e[0;33m' '\e[0m' "$@" >&2; }
_forgit_info() { printf "%b[Info]%b %s\n" '\e[0;32m' '\e[0m' "$@" >&2; }
_forgit_inside_work_tree() { git rev-parse --is-inside-work-tree >/dev/null; }
# tac is not available on OSX, tail -r is not available on Linux, so we use either of them
_forgit_reverse_lines() { tac 2> /dev/null || tail -r; }

_forgit_previous_commit() {
    # "SHA~" is invalid when the commit is the first commit, but we can use "--root" instead
    if [[ "$(git rev-parse "$1")" == "$(git rev-list --max-parents=0 HEAD)" ]]; then
        echo "--root"
    else
        echo "$1~"
    fi
}

_forgit_contains_non_flags() {
    while (("$#")); do
        case "$1" in
        -*) shift ;;
        *)
            return 0
            ;;
        esac
    done
    return 1
}

# optional render emoji characters (https://github.com/wfxr/emoji-cli)
_forgit_emojify() {
    if hash emojify &>/dev/null; then
        emojify
    else
        cat
    fi
}

# extract the first git sha occurring in the input and strip trailing newline
_forgit_extract_sha() {
    grep -Eo '[a-f0-9]+' | head -1 | tr -d '[:space:]'
}

# extract the first git sha and copy it to the clipboard
_forgit_yank_sha() {
    echo "$1" | _forgit_extract_sha | ${FORGIT_COPY_CMD:-pbcopy}
}

# extract the first stash name in the input
_forgit_extract_stash_name() {
    cut -d: -f1 | tr -d '[:space:]'
}

# extract the first stash name and copy it to the clipboard
_forgit_yank_stash_name() {
    echo "$1" | _forgit_extract_stash_name | ${FORGIT_COPY_CMD:-pbcopy}
}

# parse a space separated string into an array
# arrays parsed with this function are global
_forgit_parse_array() {
    ${IFS+"false"} && unset old_IFS || old_IFS="$IFS"
    # read the value of the second argument
    # into an array that has the name of the first argument
    IFS=" " read -r -a "$1" <<< "$2"
    ${old_IFS+"false"} && unset IFS || IFS="$old_IFS"
}

# parse the input arguments and print only those after the "--"
# separator as a single line of quoted arguments to stdout
_forgit_quote_files() {
    local files add
    files=()
    add=false
    while (( "$#" )); do
        case "$1" in
            --)
            add=true
            shift
            ;;
            *)
            if [ $add == true ]; then
                files+=("'$1'")
            fi
            shift
            ;;
        esac
    done
    echo "${files[*]}"
}

_forgit_log_graph_enable=${FORGIT_LOG_GRAPH_ENABLE:-"true"}
_forgit_log_format=${FORGIT_LOG_FORMAT:-%C(auto)%h%d %s %C(black)%C(bold)%cr%Creset}
_forgit_log_preview_options=("--graph" "--pretty=format:$_forgit_log_format" "--color=always" "--abbrev-commit" "--date=relative")
_forgit_fullscreen_context=${FORGIT_FULLSCREEN_CONTEXT:-10}
_forgit_preview_context=${FORGIT_PREVIEW_CONTEXT:-3}
_forgit_dir_view=${FORGIT_DIR_VIEW:-$(hash tree &> /dev/null && echo 'tree' || echo 'find')}

_forgit_pager() {
    local pager
    pager=$(_forgit_get_pager "$1")
    [[ -z "${pager}" ]] && exit 1
    eval "${pager} ${*:2}"
}

_forgit_get_pager() {
    local pager
    pager=${1:-core}
    case "$pager" in
        core) echo -n "${FORGIT_PAGER:-$(git config core.pager || echo 'cat')}" ;;
        show) echo -n "${FORGIT_SHOW_PAGER:-$(git config pager.show || _forgit_get_pager)}" ;;
        diff) echo -n "${FORGIT_DIFF_PAGER:-$(git config pager.diff || _forgit_get_pager)}" ;;
        ignore) echo -n "${FORGIT_IGNORE_PAGER:-$(hash bat &>/dev/null && echo 'bat -l gitignore --color=always' || echo 'cat')}" ;;
        blame) echo -n "${FORGIT_BLAME_PAGER:-$(git config pager.blame || _forgit_get_pager)}" ;;
        enter) echo -n "${FORGIT_ENTER_PAGER:-"LESS='-r' less"}" ;;
        *) echo "pager not found: $1" >&2 ;;
    esac
}

_forgit_is_file_tracked() {
    git ls-files "$1" --error-unmatch &> /dev/null
}

_forgit_list_files() {
    local rootdir
    rootdir=$(git rev-parse --show-toplevel)
    # git escapes special characters in it's output when core.quotePath is
    # true or unset. Git always expects unquoted file paths as input. This
    # leads to issues when we consume output from git and use it to build
    # input for other git commands. Use the -z flag to ensure file paths are
    # unquoted.
    # uniq is necessary because unmerged files are printed once for each
    # merge conflict.
    # With the -z flag, git also uses \0 line termination, so we 
    # have to replace the terminators.
    git ls-files -z "$@" "$rootdir" | tr '\0' '\n' | uniq
}

_forgit_log_preview() {
    local sha
    sha=$(echo "$1" | _forgit_extract_sha)
    shift
    echo "$sha" | xargs -I% git show --color=always -U"$_forgit_preview_context" % -- "$@" | _forgit_pager show
}

_forgit_log_enter() {
    local sha
    sha=$(echo "$1" | _forgit_extract_sha)
    shift
    echo "$sha" | xargs -I% "${FORGIT}" diff %^! "$@"
}

# git commit viewer
_forgit_log() {
    _forgit_inside_work_tree || return 1
    local opts graph quoted_files log_format
    quoted_files=$(_forgit_quote_files "$@")
    opts="
        $FORGIT_FZF_DEFAULT_OPTS
        +s +m --tiebreak=index
        --bind=\"enter:execute($FORGIT log_enter {} $quoted_files)\"
        --bind=\"ctrl-y:execute-silent($FORGIT yank_sha {})\"
        --preview=\"$FORGIT log_preview {} $quoted_files\"
        $FORGIT_LOG_FZF_OPTS
    "
    graph=()
    [[ $_forgit_log_graph_enable == true ]] && graph=(--graph)
    log_format=${FORGIT_GLO_FORMAT:-$_forgit_log_format}
    _forgit_log_git_opts=()
    _forgit_parse_array _forgit_log_git_opts "$FORGIT_LOG_GIT_OPTS"
    git log "${graph[@]}" --color=always --format="$log_format" "${_forgit_log_git_opts[@]}" "$@" |
        _forgit_emojify |
        FZF_DEFAULT_OPTS="$opts" fzf
    fzf_exit_code=$?
    # exit successfully on 130 (ctrl-c/esc)
    [[ $fzf_exit_code == 130 ]] && return 0
    return $fzf_exit_code
}

# git reflog viewer
_forgit_reflog() {
    _forgit_inside_work_tree || return 1
    _forgit_contains_non_flags "$@" && { git reflog "$@"; return $?; }
    local opts reflog_format
    opts="
        $FORGIT_FZF_DEFAULT_OPTS
        +s +m --tiebreak=index
        --bind=\"enter:execute($FORGIT log_enter {})\"
        --bind=\"ctrl-y:execute-silent($FORGIT yank_sha {})\"
        --preview=\"$FORGIT log_preview {}\"
        $FORGIT_REFLOG_FZF_OPTS
    "
    reflog_format=${FORGIT_GRL_FORMAT:-$_forgit_log_format}
    _forgit_reflog_git_opts=()
    _forgit_parse_array _forgit_reflog_git_opts "$FORGIT_REFLOG_GIT_OPTS"
    git reflog show --color=always --format="$reflog_format" "${_forgit_reflog_git_opts[@]}" "$@" |
        _forgit_emojify |
        FZF_DEFAULT_OPTS="$opts" fzf
    fzf_exit_code=$?
    # exit successfully on 130 (ctrl-c/esc)
    [[ $fzf_exit_code == 130 ]] && return 0
    return $fzf_exit_code
}

_forgit_get_files_from_diff_line() {
    # Construct a null-terminated list of the filenames
    # The input looks like one of these lines:
    #   [R100]  file  ->  another file
    #   [A]     file with spaces
    #   [D]     oldfile
    # And we transform it to this representation for further usage with "xargs -0":
    #   file\0another file\0
    #   file with spaces\0
    #   oldfile\0
    # We have to do a two-step sed -> tr pipe because OSX's sed implementation does
    # not support the null-character directly.
    sed 's/^[[:space:]]*\[[A-Z0-9]*\][[:space:]]*//' | sed 's/  ->  /\n/' | tr '\n' '\0'
}

_forgit_get_single_file_from_diff_line() {
    # Similar to the function above, but only gets a single file from a single line
    # Gets the new name of renamed files
    sed 's/^[[:space:]]*\[[A-Z0-9]*\][[:space:]]*//' | sed 's/.*->  //'
}

_forgit_exec_diff() {
    _forgit_diff_git_opts=()
    _forgit_parse_array _forgit_diff_git_opts "$FORGIT_DIFF_GIT_OPTS"
    git diff --color=always "${_forgit_diff_git_opts[@]}" "$@"
}

_forgit_diff_view() {
    local input_line=$1
    local diff_context=$2
    local repo
    local commits=()
    repo=$(git rev-parse --show-toplevel)
    cd "$repo" || return 1
    if [ $# -gt 2 ]; then
        IFS=" " read -r -a commits <<< "${*:3}"
    fi
    echo "$input_line" | _forgit_get_files_from_diff_line | xargs -0 \
        "$FORGIT" exec_diff "${commits[@]}" -U"$diff_context" -- | _forgit_pager diff
}

_forgit_edit_diffed_file() {
    local input_line rootdir
    input_line=$1
    rootdir=$(git rev-parse --show-toplevel)
    filename=$(echo "$input_line" | _forgit_get_single_file_from_diff_line)
    $EDITOR "$rootdir/$filename" >/dev/tty </dev/tty
}

_forgit_diff_enter() {
    file=$1
    commits=("${@:2}")
    _forgit_diff_view "$file" "$_forgit_fullscreen_context" "${commits[@]}"
}

# git diff viewer
_forgit_diff() {
    _forgit_inside_work_tree || return 1
    local files opts commits escaped_commits
    commits=()
    files=()
    [[ $# -ne 0 ]] && {
        if git rev-parse "$1" -- &>/dev/null ; then
            if [[ $# -gt 1 ]] && git rev-parse "$2" -- &>/dev/null; then
                commits=("$1" "$2") && files=("${@:3}")
            else
                commits=("$1") && files=("${@:2}")
            fi
        else
            files=("$@")
        fi
    }
    # Git stashes are named "stash@{x}", which contains the fzf placeholder "{x}".
    # In order to support passing stashes as arguments to _forgit_diff, we have to
    # prevent fzf from interpreting this substring by escaping the opening bracket.
    # The string is evaluated a few subsequent times, so we need multiple escapes.
    for commit in "${commits[@]}"; do
        escaped_commits+="'${commit//\{/\\\\\{}' "
    done
    opts="
        $FORGIT_FZF_DEFAULT_OPTS
        +m -0 --bind=\"enter:execute($FORGIT diff_enter {} $escaped_commits | $FORGIT pager enter)\"
        --preview=\"$FORGIT diff_view {} '$_forgit_preview_context' $escaped_commits\"
        --bind=\"alt-e:execute-silent($FORGIT edit_diffed_file {})+refresh-preview\"
        $FORGIT_DIFF_FZF_OPTS
        --prompt=\"${commits[*]} > \"
    "
    _forgit_diff_git_opts=()
    _forgit_parse_array _forgit_diff_git_opts "$FORGIT_DIFF_GIT_OPTS"
    git diff --name-status "${_forgit_diff_git_opts[@]}" "${commits[@]}" -- "${files[@]}" |
        sed -E 's/^([[:alnum:]]+)[[:space:]]+(.*)$/[\1]	\2/' |
        sed 's/	/  ->  /2' | expand -t 8 |
        FZF_DEFAULT_OPTS="$opts" fzf
    fzf_exit_code=$?
    # exit successfully on 130 (ctrl-c/esc)
    [[ $fzf_exit_code == 130 ]] && return 0
    return $fzf_exit_code
}

_forgit_add_preview() {
    file=$(echo "$1" | _forgit_get_single_file_from_add_line)
    if (git status -s -- "$file" | grep '^??') &>/dev/null; then  # diff with /dev/null for untracked files
        git diff --color=always --no-index -- /dev/null "$file" | _forgit_pager diff | sed '2 s/added:/untracked:/'
    else
        git diff --color=always -- "$file" | _forgit_pager diff
    fi
}

_forgit_git_add() {
    _forgit_add_git_opts=()
    _forgit_parse_array _forgit_add_git_opts "$FORGIT_ADD_GIT_OPTS"
    git add "${_forgit_add_git_opts[@]}" "$@"
}

_forgit_get_single_file_from_add_line() {
    # NOTE: paths listed by 'git status -su' mixed with quoted and unquoted style
    # remove indicators | remove original path for rename case | remove surrounding quotes
    sed 's/^.*]  //' |
    sed 's/.* -> //' |
    sed -e 's/^\"//' -e 's/\"$//'
}

_forgit_edit_add_file() {
    local input_line=$1
    filename=$(echo "$input_line" | _forgit_get_single_file_from_add_line)
    $EDITOR "$filename" >/dev/tty </dev/tty
}

# git add selector
_forgit_add() {
    _forgit_inside_work_tree || return 1
    local changed unmerged untracked files opts
    # Add files if passed as arguments
    [[ $# -ne 0 ]] && { _forgit_git_add "$@" && git status -s; return $?; }

    changed=$(git config --get-color color.status.changed red)
    unmerged=$(git config --get-color color.status.unmerged red)
    untracked=$(git config --get-color color.status.untracked red)
    opts="
        $FORGIT_FZF_DEFAULT_OPTS
        -0 -m --nth 2..,..
        --preview=\"$FORGIT add_preview {}\"
        --bind=\"alt-e:execute-silent($FORGIT edit_add_file {})+refresh-preview\"
        $FORGIT_ADD_FZF_OPTS
    "
    files=()
    while IFS='' read -r file; do
        files+=("$file")
    done < <(git -c color.status=always -c status.relativePaths=true -c core.quotePath=false status -su |
        grep -F -e "$changed" -e "$unmerged" -e "$untracked" |
        sed -E 's/^(..[^[:space:]]*)[[:space:]]+(.*)$/[\1]  \2/' |
        FZF_DEFAULT_OPTS="$opts" fzf |
        _forgit_get_single_file_from_add_line)
    [[ "${#files[@]}" -gt 0 ]] && _forgit_git_add "${files[@]}" && git status -s && return
    echo 'Nothing to add.'
}

_forgit_reset_head_preview() {
    file=$1
    git diff --staged --color=always -- "$file" | _forgit_pager diff
}

_forgit_git_reset_head() {
    _forgit_reset_head_git_opts=()
    _forgit_parse_array _forgit_reset_head_git_opts "$FORGIT_RESET_HEAD_GIT_OPTS"
    git reset -q "${_forgit_reset_head_git_opts[@]}" HEAD "$@"
}

# git reset HEAD (unstage) selector
_forgit_reset_head() {
    _forgit_inside_work_tree || return 1
    local files opts rootdir
    [[ $# -ne 0 ]] && { _forgit_git_reset_head "$@" && git status --short; return $?; }
    rootdir=$(git rev-parse --show-toplevel)
    opts="
        $FORGIT_FZF_DEFAULT_OPTS
        -m -0
        --preview=\"$FORGIT reset_head_preview '$rootdir'/{}\"
        $FORGIT_RESET_HEAD_FZF_OPTS
    "
    files=()
    while IFS='' read -r file; do
        files+=("$file")
    done < <(git diff -z --staged --name-only | tr '\0' '\n' | FZF_DEFAULT_OPTS="$opts" fzf)
    if [[ ${#files} -eq 0 ]]; then
        echo 'Nothing to unstage.'
        return 1
    fi
    for file in "${files[@]}"; do
        _forgit_git_reset_head "$rootdir/$file"
    done
    git status --short
}

_forgit_stash_show_preview() {
    local stash
    stash=$(echo "$1" | cut -d: -f1)
    _forgit_git_stash_show "$stash" | _forgit_pager diff
}

_forgit_git_stash_show() {
    git stash show --color=always --ext-diff "$@"
}

# git stash viewer
_forgit_stash_show() {
    _forgit_inside_work_tree || return 1
    local opts
    [[ $# -ne 0 ]] && { _forgit_git_stash_show "$@"; return $?; }
    _forgit_stash_show_git_opts=()
    _forgit_parse_array _forgit_stash_show_git_opts "$FORGIT_STASH_SHOW_GIT_OPTS"
    opts="
        $FORGIT_FZF_DEFAULT_OPTS
        +s +m -0 --tiebreak=index --bind=\"enter:execute($FORGIT stash_show_preview {} | $FORGIT pager enter)\"
        --bind=\"ctrl-y:execute-silent($FORGIT yank_stash_name {})\"
        --preview=\"$FORGIT stash_show_preview {}\"
        $FORGIT_STASH_FZF_OPTS
    "
    git stash list "${_forgit_stash_show_git_opts[@]}" | FZF_DEFAULT_OPTS="$opts" fzf
    fzf_exit_code=$?
    # exit successfully on 130 (ctrl-c/esc)
    [[ $fzf_exit_code == 130 ]] && return 0
    return $fzf_exit_code
}

_forgit_stash_push_preview() {
    if _forgit_is_file_tracked "$1"; then
        git diff --color=always -- "$1" | _forgit_pager diff
    else
        git diff --color=always /dev/null "$1" | _forgit_pager diff
    fi
}

_forgit_git_stash_push() {
    _forgit_stash_push_git_opts=()
    _forgit_parse_array _forgit_stash_push_git_opts "$FORGIT_STASH_PUSH_GIT_OPTS"
    git stash push "${_forgit_stash_push_git_opts[@]}" "$@"
}

# git stash push selector
_forgit_stash_push() {
    _forgit_inside_work_tree || return 1
    local msg args
    args=( "$@" )
    while (( "$#" )); do
        case "$1" in
            # allow message as argument
            -m|--message)
            msg="$2"
            shift 2
            ;;
            # ignore -u as it's used implicitly
            -u|--include-untracked) shift ;;
            # pass to git directly when encountering anything else
            *) _forgit_git_stash_push "${args[@]}"; return $?
        esac
    done
    local opts files
    opts="
        $FORGIT_FZF_DEFAULT_OPTS
        -m
        --preview=\"$FORGIT stash_push_preview {}\"
        $FORGIT_STASH_PUSH_FZF_OPTS
    "
    # Show both modified and untracked files
    files=()
    while IFS='' read -r file; do
        files+=("$file")
    done < <(_forgit_list_files --exclude-standard --modified --others |
        FZF_DEFAULT_OPTS="$opts" fzf --exit-0)
    [[ "${#files[@]}" -eq 0 ]] && echo "Nothing to stash" && return 1
    _forgit_git_stash_push ${msg:+-m "$msg"} -u "${files[@]}"
}

_forgit_clean_preview() {
    local path
    path=$1
    if [[ -d "$path" ]]; then
        eval "$_forgit_dir_view \"$path\""
    else
        git diff --color=always /dev/null "$path" | _forgit_pager diff
    fi
}

# git clean selector
_forgit_clean() {
    _forgit_inside_work_tree || return 1
    _forgit_contains_non_flags "$@" && { git clean -q "$@"; return $?; }
    local files opts
    _forgit_clean_git_opts=()
    _forgit_parse_array _forgit_clean_git_opts "$FORGIT_CLEAN_GIT_OPTS"
    opts="
        $FORGIT_FZF_DEFAULT_OPTS
        --preview=\"$FORGIT clean_preview {}\"
        -m -0
        $FORGIT_CLEAN_FZF_OPTS
    "
    # Note: Postfix '/' in directory path should be removed. Otherwise the directory itself will not be removed.
    files=$(git -c core.quotePath=false clean -xdffn "$@"| sed 's/^Would remove //' | FZF_DEFAULT_OPTS="$opts" fzf |sed 's#/$##')
    [[ -n "$files" ]] && echo "$files" | tr '\n' '\0' | xargs -0 -I% git clean "${_forgit_clean_git_opts[@]}" -xdff '%' && git status --short && return
    echo 'Nothing to clean.'
}

_forgit_cherry_pick_preview() {
    echo "$1" | cut -f2- | _forgit_extract_sha | xargs -I% git show --color=always % | _forgit_pager show
}

_forgit_cherry_pick() {
    local base target opts fzf_selection fzf_exitval

    base=$(git branch --show-current)
    [[ -z "$base" ]] && echo "Current commit is not on a branch." && return 1

    [[ -z $1 ]] && echo "Please specify target branch" && return 1
    target="$1"

    # in this function, we do something interesting to maintain proper ordering as it's assumed 
    # you generally want to cherry pick oldest->newest when you multiselect
    # The instances of "cut", "nl" and "sort" all serve this purpose
    # Please see https://github.com/wfxr/forgit/issues/253 for more details

    opts="
        $FORGIT_FZF_DEFAULT_OPTS
        --preview=\"$FORGIT cherry_pick_preview {}\"
        --multi --ansi --with-nth 2.. -0 --tiebreak=index
        $FORGIT_CHERRY_PICK_FZF_OPTS
    "
    # Note: do not add any pipe after the fzf call here, otherwise the fzf_exitval is not propagated properly.
    # Any eventual post processing can be done afterwards when the "commits" variable is assigned below.
    fzf_selection=$(git log --right-only --color=always --cherry-pick --oneline "$base"..."$target" | nl |
        FZF_DEFAULT_OPTS="$opts" fzf)
    fzf_exitval=$?
    [[ $fzf_exitval != 0 ]] && return $fzf_exitval
    [[ -z "$fzf_selection" ]] && return $fzf_exitval

    commits=()
    while IFS='' read -r commit; do
        commits+=("$commit")
    done < <(echo "$fzf_selection" | sort -n -k 1 | cut -f2 | cut -d' ' -f1 | _forgit_reverse_lines)
    [ ${#commits[@]} -eq 0 ] && return 1
    
    _forgit_cherry_pick_git_opts=()
    _forgit_parse_array _forgit_cherry_pick_git_opts "$FORGIT_CHERRY_PICK_GIT_OPTS"
    git cherry-pick "${_forgit_cherry_pick_git_opts[@]}" "${commits[@]}"
}

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

_forgit_cherry_pick_from_branch() {
    _forgit_inside_work_tree || return 1
    local opts branch exitval input_branch args base

    base=$(git branch --show-current)
    [[ -z "$base" ]] && echo "Current commit is not on a branch." && return 1

    args=("$@")
    if [[ $# -ne 0 ]]; then
        input_branch=${args[0]}
    fi
    opts="
        $FORGIT_FZF_DEFAULT_OPTS
        +s +m --tiebreak=index --header-lines=1
        --preview=\"$FORGIT cherry_pick_from_branch_preview '$base' {1}\"
        $FORGIT_CHERRY_PICK_FROM_BRANCH_FZF_OPTS
        "
    # loop until either the branch selector is closed or a commit to be cherry
    # picked has been selected from within a branch
    while true
    do
        if [[ -z $input_branch ]]; then
            branch="$(git branch --color=always --all |
            LC_ALL=C sort -k1.1,1.1 -rs |
            FZF_DEFAULT_OPTS="$opts" fzf |
            awk '{print $1}')"
        else
            branch=$input_branch
        fi

        unset input_branch
        [[ -z "$branch" ]] && return 1

        _forgit_cherry_pick "$branch"

        exitval=$?
        [[ $exitval != 130 ]] || [[ $# -ne 0 ]] && return $exitval
    done
}

_forgit_rebase() {
    _forgit_inside_work_tree || return 1
    local opts graph quoted_files target_commit prev_commit
    graph=()
    [[ $_forgit_log_graph_enable == true ]] && graph=(--graph)
    _forgit_rebase_git_opts=()
    _forgit_parse_array _forgit_rebase_git_opts "$FORGIT_REBASE_GIT_OPTS"
    quoted_files=$(_forgit_quote_files "$@")
    opts="
        $FORGIT_FZF_DEFAULT_OPTS
        +s +m --tiebreak=index
        --bind=\"ctrl-y:execute-silent($FORGIT yank_sha {})\"
        --preview=\"$FORGIT file_preview {} $quoted_files\"
        $FORGIT_REBASE_FZF_OPTS
    "
    target_commit=$(
        git log "${graph[@]}" --color=always --format="$_forgit_log_format" "$@" |
        _forgit_emojify |
        FZF_DEFAULT_OPTS="$opts" fzf |
        _forgit_extract_sha)
    if [[ -n "$target_commit" ]]; then
        prev_commit=$(_forgit_previous_commit "$target_commit")
        git rebase -i "${_forgit_rebase_git_opts[@]}" "$prev_commit"
    fi
}

_forgit_file_preview() {
    local sha
    sha=$(echo "$1" | _forgit_extract_sha)
    shift
    echo "$sha" | xargs -I% git show --color=always % -- "$@" | _forgit_pager show
}

_forgit_fixup() {
    _forgit_inside_work_tree || return 1
    git diff --cached --quiet && echo 'Nothing to fixup: there are no staged changes.' && return 1
    local opts graph quoted_files target_commit prev_commit
    graph=()
    [[ $_forgit_log_graph_enable == true ]] && graph=(--graph)
    _forgit_fixup_git_opts=()
    _forgit_parse_array _forgit_fixup_git_opts "$FORGIT_FIXUP_GIT_OPTS"
    quoted_files=$(_forgit_quote_files "$@")
    opts="
        $FORGIT_FZF_DEFAULT_OPTS
        +s +m --tiebreak=index
        --bind=\"ctrl-y:execute-silent($FORGIT yank_sha {})\"
        --preview=\"$FORGIT file_preview {} $quoted_files\"
        $FORGIT_FIXUP_FZF_OPTS
    "
    target_commit=$(
        git log "${graph[@]}" --color=always --format="$_forgit_log_format" "$@" |
        _forgit_emojify |
        FZF_DEFAULT_OPTS="$opts" fzf |
        _forgit_extract_sha)
    if [[ -n "$target_commit" ]] && git commit "${_forgit_fixup_git_opts[@]}" --fixup "$target_commit"; then
        prev_commit=$(_forgit_previous_commit "$target_commit")
        # rebase will fail if there are unstaged changes so --autostash is needed to temporarily stash them
        # GIT_SEQUENCE_EDITOR=: is needed to skip the editor
        GIT_SEQUENCE_EDITOR=: git rebase --autostash -i --autosquash "$prev_commit"
    fi
}

_forgit_checkout_file_preview() {
    git diff --color=always -- "$1" | _forgit_pager diff
}

_forgit_git_checkout_file() {
    _forgit_checkout_file_git_opts=()
    _forgit_parse_array _forgit_checkout_file_git_opts "$FORGIT_CHECKOUT_FILE_GIT_OPTS"
    git checkout "${_forgit_checkout_file_git_opts[@]}" "$@"
}

# git checkout-file selector
_forgit_checkout_file() {
    _forgit_inside_work_tree || return 1
    local files opts
    [[ $# -ne 0 ]] && { _forgit_git_checkout_file -- "$@"; return $?; }
    opts="
        $FORGIT_FZF_DEFAULT_OPTS
        -m -0
        --preview=\"$FORGIT checkout_file_preview {}\"
        $FORGIT_CHECKOUT_FILE_FZF_OPTS
    "
    files=()
    while IFS='' read -r file; do
        files+=("$file")
    done < <(_forgit_list_files --modified |
        FZF_DEFAULT_OPTS="$opts" fzf)
    [[ "${#files[@]}" -gt 0 ]] && _forgit_git_checkout_file "${files[@]}"
}

_forgit_git_checkout_branch() {
    _forgit_checkout_branch_git_opts=()
    _forgit_parse_array _forgit_checkout_branch_git_opts "$FORGIT_CHECKOUT_BRANCH_GIT_OPTS"
    git checkout "${_forgit_checkout_branch_git_opts[@]}" "$@"
}

# git checkout-branch selector
_forgit_checkout_branch() {
    _forgit_inside_work_tree || return 1
    # if called with arguments, check if branch exists, else create a new one
    if [[ $# -ne 0 ]]; then
        if [[ "$*" == "-" ]] || git show-branch "$@" &>/dev/null; then
            git switch "$@"
        else
            git switch -c "$@"
        fi
        checkout_status=$?
        git status --short
        return $checkout_status
    fi

    local opts branch
    opts="
        $FORGIT_FZF_DEFAULT_OPTS
        +s +m --tiebreak=index --header-lines=1
        --preview=\"$FORGIT branch_preview {1}\"
        $FORGIT_CHECKOUT_BRANCH_FZF_OPTS
        "
    _forgit_checkout_branch_branch_git_opts=()
    _forgit_parse_array _forgit_checkout_branch_branch_git_opts "$FORGIT_CHECKOUT_BRANCH_BRANCH_GIT_OPTS"
    branch="$(git branch --color=always "${_forgit_checkout_branch_branch_git_opts[@]:---all}" | LC_ALL=C sort -k1.1,1.1 -rs |
              FZF_DEFAULT_OPTS="$opts" fzf | awk '{print $1}')"
    [[ -z "$branch" ]] && return 1

    # track the remote branch if possible
    if [[ "$branch" == "remotes/origin/"* ]]; then
        if git branch | grep -qw "${branch#remotes/origin/}"; then
            # hack to force creating a new branch which tracks the remote if a local branch already exists
            _forgit_git_checkout_branch -b "track/${branch#remotes/origin/}" --track "$branch"
        elif ! _forgit_git_checkout_branch --track "$branch" 2>/dev/null; then
            _forgit_git_checkout_branch "$branch"
        fi
    else
        _forgit_git_checkout_branch "$branch"
    fi
}

_forgit_git_checkout_tag() {
    _forgit_checkout_tag_git_opts=()
    _forgit_parse_array _forgit_checkout_tag_git_opts "$FORGIT_CHECKOUT_TAG_GIT_OPTS"
    git checkout "${_forgit_checkout_tag_git_opts[@]}" "$@"
}

# git checkout-tag selector
_forgit_checkout_tag() {
    _forgit_inside_work_tree || return 1
    local opts
    [[ $# -ne 0 ]] && { _forgit_git_checkout_tag "$@"; return $?; }
    opts="
        $FORGIT_FZF_DEFAULT_OPTS
        +s +m --tiebreak=index
        --preview=\"$FORGIT branch_preview {}\"
        $FORGIT_CHECKOUT_TAG_FZF_OPTS
    "
    tag="$(git tag -l --sort=-v:refname | FZF_DEFAULT_OPTS="$opts" fzf)"
    [[ -z "$tag" ]] && return 1
    _forgit_git_checkout_tag "$tag"
}

_forgit_checkout_commit_preview() {
    echo "$1" | _forgit_extract_sha | xargs -I% git show --color=always % | _forgit_pager show
}

_forgit_git_checkout_commit() {
    _forgit_checkout_commit_git_opts=()
    _forgit_parse_array _forgit_checkout_commit_git_opts "$FORGIT_CHECKOUT_COMMIT_GIT_OPTS"
    git checkout "${_forgit_checkout_commit_git_opts[@]}" "$@"
}

# git checkout-commit selector
_forgit_checkout_commit() {
    _forgit_inside_work_tree || return 1
    local opts graph commit
    [[ $# -ne 0 ]] && { _forgit_git_checkout_commit "$@"; return $?; }
    opts="
        $FORGIT_FZF_DEFAULT_OPTS
        +s +m --tiebreak=index
        --bind=\"ctrl-y:execute-silent($FORGIT yank_sha {})\"
        --preview=\"$FORGIT checkout_commit_preview {}\"
        $FORGIT_CHECKOUT_COMMIT_FZF_OPTS
    "
    graph=()
    [[ $_forgit_log_graph_enable == true ]] && graph=(--graph)
    commit="$(git log "${graph[@]}" --color=always --format="$_forgit_log_format" |
        _forgit_emojify |
        FZF_DEFAULT_OPTS="$opts" fzf | _forgit_extract_sha)"
    _forgit_git_checkout_commit "$commit"
}

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

_forgit_git_branch_delete() {
    _forgit_branch_delete_git_opts=()
    _forgit_parse_array _forgit_branch_delete_git_opts "$FORGIT_BRANCH_DELETE_GIT_OPTS"
    git branch "${_forgit_branch_delete_git_opts[@]}" -D "$@"
}

_forgit_branch_delete() {
    _forgit_inside_work_tree || return 1
    local opts
    [[ $# -ne 0 ]] && { _forgit_git_branch_delete "$@"; return $?; }

    opts="
        $FORGIT_FZF_DEFAULT_OPTS
        +s --multi --tiebreak=index --header-lines=1
        --preview=\"$FORGIT branch_preview {1}\"
        $FORGIT_BRANCH_DELETE_FZF_OPTS
    "

    for branch in $(git branch --color=always |
        LC_ALL=C sort -k1.1,1.1 -rs |
        FZF_DEFAULT_OPTS="$opts" fzf |
        awk '{print $1}')
    do
        _forgit_git_branch_delete "$branch"
    done
}

_forgit_revert_preview() {
    echo "$1" |
    cut -f2- |
    _forgit_extract_sha |
    xargs -I% git show --color=always % |
    _forgit_pager show
}

_forgit_git_revert() {
    _forgit_revert_commit_git_opts=()
    _forgit_parse_array _forgit_revert_commit_git_opts "$FORGIT_REVERT_COMMIT_GIT_OPTS"
    git revert "${_forgit_revert_commit_git_opts[@]}" "$@"
}

# git revert-commit selector
_forgit_revert_commit() {
    _forgit_inside_work_tree || return 1
    local opts commits IFS
    [[ $# -ne 0 ]] && { _forgit_git_revert "$@"; return $?; }

    opts="
        $FORGIT_FZF_DEFAULT_OPTS
        -m +s --tiebreak=index
        --ansi --with-nth 2..
        --preview=\"$FORGIT revert_preview {}\"
        $FORGIT_REVERT_COMMIT_FZF_OPTS
    "
    graph=()
    [[ $_forgit_log_graph_enable == true ]] && graph=(--graph)

    # in this function, we do something interesting to maintain proper ordering as it's assumed 
    # you generally want to revert newest->oldest when you multiselect
    # The instances of "cut", "nl" and "sort" all serve this purpose
    # Please see https://github.com/wfxr/forgit/issues/253 for more details

    commits=()
    while IFS='' read -r commit; do
        commits+=("$commit")
    done < <(
        git log "${graph[@]}" --color=always --format="$_forgit_log_format" |
        _forgit_emojify |
        nl |
        FZF_DEFAULT_OPTS="$opts" fzf |
        sort -n -k 1 | 
        cut -f2- |
        sed 's/^[^a-f^0-9]*\([a-f0-9]*\).*/\1/')

    [ ${#commits[@]} -eq 0 ] && return 1

    _forgit_git_revert "${commits[@]}"
}

_forgit_blame_preview() {
    if _forgit_is_file_tracked "$1"; then
        _forgit_blame_git_opts=()
        _forgit_parse_array _forgit_blame_git_opts "$FORGIT_BLAME_GIT_OPTS"
        git blame --date=short "${_forgit_blame_git_opts[@]}" "$@" | _forgit_pager blame
    else
        echo "File not tracked"
    fi
}

_forgit_git_blame() {
    _forgit_blame_git_opts=()
    _forgit_parse_array _forgit_blame_git_opts "$FORGIT_BLAME_GIT_OPTS"
    git blame "${_forgit_blame_git_opts[@]}" "$@"
}

# git blame viewer
_forgit_blame() {
    _forgit_inside_work_tree || return 1
    local opts flags file
    _forgit_contains_non_flags "$@" && { _forgit_git_blame "$@"; return $?; }
    flags=()
    while IFS='' read -r flag; do
        flags+=("$flag")
    done < <(git rev-parse --flags "$@")
    opts="
        $FORGIT_FZF_DEFAULT_OPTS
        --preview=\"$FORGIT blame_preview {} ${flags[*]}\"
        $FORGIT_BLAME_FZF_OPTS
    "
    # flags is not quoted here, which is fine given that they are retrieved
    # with git rev-parse and can only contain flags
    file=$(FZF_DEFAULT_OPTS="$opts" fzf)
    [[ -z "$file" ]] && return 1
    _forgit_git_blame "$file" "${flags[@]}"
}

# git ignore generator
export FORGIT_GI_REPO_REMOTE=${FORGIT_GI_REPO_REMOTE:-https://github.com/dvcs/gitignore}
export FORGIT_GI_REPO_LOCAL="${FORGIT_GI_REPO_LOCAL:-${XDG_CACHE_HOME:-$HOME/.cache}/forgit/gi/repos/dvcs/gitignore}"
export FORGIT_GI_TEMPLATES=${FORGIT_GI_TEMPLATES:-$FORGIT_GI_REPO_LOCAL/templates}

_forgit_ignore_preview() {
    quoted_files=()
    for file in "$FORGIT_GI_TEMPLATES/$1"{,.gitignore}; do
        quoted_files+=("'$file'")
    done
    _forgit_pager ignore "${quoted_files[@]}" 2>/dev/null
}

_forgit_ignore() {
    [ -d "$FORGIT_GI_REPO_LOCAL" ] || _forgit_ignore_update
    local IFS args opts
    opts="
        $FORGIT_FZF_DEFAULT_OPTS
        -m --preview-window='right:70%'
        --preview=\"$FORGIT ignore_preview {2}\"
        $FORGIT_IGNORE_FZF_OPTS
    "
    args=("$@")
    if [[ $# -eq 0 ]]; then
        args=()
        while IFS='' read -r arg; do
            args+=("$arg")
        done < <(_forgit_ignore_list | nl -w4 -s'  ' |
            FZF_DEFAULT_OPTS="$opts" fzf | awk '{print $2}')
    fi
    [ ${#args[@]} -eq 0 ] && return 1
    _forgit_ignore_get "${args[@]}"
}
_forgit_ignore_update() {
    if [[ -d "$FORGIT_GI_REPO_LOCAL" ]]; then
        _forgit_info 'Updating gitignore repo...'
        (cd "$FORGIT_GI_REPO_LOCAL" && git pull --no-rebase --ff) || return 1
    else
        _forgit_info 'Initializing gitignore repo...'
        git clone --depth=1 "$FORGIT_GI_REPO_REMOTE" "$FORGIT_GI_REPO_LOCAL"
    fi
}
_forgit_ignore_get() {
    local item filename header
    for item in "$@"; do
        if filename=$(find -L "$FORGIT_GI_TEMPLATES" -type f \( -iname "${item}.gitignore" -o -iname "${item}" \) -print -quit); then
            [[ -z "$filename" ]] && _forgit_warn "No gitignore template found for '$item'." && continue
            header="${filename##*/}" && header="${header%.gitignore}"
            echo "### $header" && cat "$filename" && echo
        fi
    done
}
_forgit_ignore_list() {
    find "$FORGIT_GI_TEMPLATES" -print |sed -e 's#.gitignore$##' -e 's#.*/##' | sort -fu
}
_forgit_ignore_clean() {
    setopt localoptions rmstarsilent
    [[ -d "$FORGIT_GI_REPO_LOCAL" ]] && rm -rf "$FORGIT_GI_REPO_LOCAL"
}

public_commands=(
    "add"
    "blame"
    "branch_delete"
    "checkout_branch"
    "checkout_commit"
    "checkout_file"
    "checkout_tag"
    "cherry_pick"
    "cherry_pick_from_branch"
    "clean"
    "diff"
    "fixup"
    "ignore"
    "log"
    "reflog"
    "rebase"
    "reset_head"
    "revert_commit"
    "stash_show"
    "stash_push"
)

private_commands=(
    "add_preview"
    "blame_preview"
    "branch_preview"
    "checkout_commit_preview"
    "checkout_file_preview"
    "cherry_pick_from_branch_preview"
    "cherry_pick_preview"
    "clean_preview"
    "diff_enter"
    "file_preview"
    "ignore_preview"
    "revert_preview"
    "reset_head_preview"
    "stash_push_preview"
    "stash_show_preview"
    "yank_sha"
    "yank_stash_name"
    "log_preview"
    "log_enter"
    "exec_diff"
    "diff_view"
    "edit_diffed_file"
    "edit_add_file"
    "pager"
)

cmd="$1"
shift

# shellcheck disable=SC2076
if [[ ! " ${public_commands[*]} " =~ " ${cmd} " ]] && [[ ! " ${private_commands[*]} " =~ " ${cmd} " ]]; then
    if [[ -z "$cmd" ]]; then
        printf "forgit: missing command\n\n"
    else
        printf "forgit: '%s' is not a valid forgit command.\n\n" "$cmd"
    fi
    printf "The following commands are supported:\n"
    printf "\t%s\n" "${public_commands[@]}"
    exit 1
fi

_forgit_"${cmd}" "$@"
