#!/usr/bin/env bash
# git-fixup (https://github.com/keis/git-fixup)
# We cannot set -u, because included git libraries don't support it.
set -e

# shellcheck disable=SC2034
OPTIONS_SPEC="\
git fixup [options] [<ref>]
--
h,help        Show this help text
s,squash      Create a squash! commit
f,fixup       Create a fixup! commit
a,amend       Create an amend! commit
c,commit      Show a menu from which to pick a commit
no-commit     Don't show a menu to pick a commit
rebase        Do a rebase right after commit
no-rebase     Don't do a rebase after commit
n,no-verify   Bypass the pre-commit and commit-msg hooks
b,base=rev    Use <rev> as base of the revision range for the search
A,all         Show all candidates
r,reverse     Reverse the sort
"
# shellcheck disable=SC2034
SUBDIRECTORY_OK=yes
# shellcheck disable=SC1091
. "$(git --exec-path)/git-sh-setup"

# Define a sed program that turns `git diff` output into a stream of filenames
# and sections within those files.
grok_diff='/^--- .*/p ;
           s/^@@ -\([0-9]*\),\([0-9]*\).*/\1 \2/p'

# Produce suggestion of commits by finding the sections of files with changes
# staged (U1 to diff is used to give some context for when adding items to
# lists etc) and looking up the previous commits touching those sections.
fixup_candidates_lines () {
    git diff --cached -U1 --no-prefix | sed -n "$grok_diff" | (
        file=''
        while read -r offs len ; do
            if test "$offs" = '---'; then
                file="$len"
            else
                if test "$len" != '0'; then
                    if test "$file" != '/dev/null' ; then
                        git blame -sl -L "$offs,+$len" "$rev_range" -- "$file"
                    fi
                fi
            fi
        done
    ) | grep -v "^^" | cut -d' ' -f 1 | sed 's/^/L /g'
}

# Produce suggestion of commits by taking the latest commit to each file with
# staged changes
fixup_candidates_files () {
    git diff --cached --name-only | (
        while read -r file; do
            git rev-list -n 1 -E --invert-grep --grep='^(fixup|squash)' "$rev_range" -- "$file"
        done
    ) | sed 's/^/F /g'
}

# Produce suggestion of all commits in $rev_range
fixup_candidates_all_commits () {
	git rev-list "$rev_range" | sed 's/^/F /g'
}

# Pretty print details of a commit
print_sha () {
    local sha=$1
    local type=$2

    git --no-pager log --format="%h %ai [$type] %s <%ae>" -n 1 "$sha"
}

# Call git commit
call_commit () {
    local flag=$op
    local target=$1

    if test "$op" = "amend"; then
        flag=fixup
        target="amend:$target"
    fi

    # shellcheck disable=SC2086
    git commit "${git_commit_args[@]}" "--$flag=$target" || die
}

# Call git rebase
call_rebase () {
    local target=$1

    # If our target-commit has a parent, we call a rebase with that
    # shellcheck disable=SC1083
    if git rev-parse --quiet --verify "$target"~1^{commit}; then
        git rebase --interactive --autosquash "$target~1"
    # If our target-commit exists but has no parents, it must be the very first commit
    # the repo. We simply call a rebase with --root
    elif git rev-parse --quiet --verify "$target"^{commit}; then
        git rebase --interactive --autosquash --root
    fi
}

# Print list of fixup/squash candidates
print_candidates () {
    (
        if [ "$show_all" = "false" ]; then
            fixup_candidates_lines
            fixup_candidates_files
        else
            fixup_candidates_all_commits
        fi
    ) | sort -uk2 | while read -r type sha; do
        if test -n "$sha"; then
            print_sha "$sha" "$type"
        fi
    done
}

fallback_menu () {
    (
        IFS=$'\n'
        read -d '' -ra options
        PS3="Which commit should I $op? "
        select line in "${options[@]}"; do
            if test -z "$line"; then
                declare -a args=("$REPLY")
                case ${args[0]} in
                    quit|q)
                        echo "Alright, no action taken." >&2
                        break
                        ;;
                    show|s)
                        idx=$((args[1] - 1))
                        if test "$idx" -ge 0; then
                            git show "${options[$idx]%% *}" >&2
                        fi
                        ;;
                    help|h)
                        local fmt="%s\n    %s\n"
                        # shellcheck disable=SC2059
                        printf "$fmt" "<n>" "$op the <n>-th commit from the list" >&2
                        # shellcheck disable=SC2059
                        printf "$fmt" "s[how] <n>" "show the <n>-th commit from the list" >&2
                        # shellcheck disable=SC2059
                        printf "$fmt" "q[uit]" "abort operation" >&2
                        # shellcheck disable=SC2059
                        printf "$fmt" "h[elp]" "show this help message" >&2
                        ;;
                esac
            else
                echo "$line"
                break
            fi
        done < /dev/tty
    )
}

show_menu () {
    if test -n "$fixup_menu"; then
        eval command "$fixup_menu"
    else
        fallback_menu
    fi
}

sort_commits () {
    # shellcheck disable=SC2086
    sort $sort_flags $additional_sort_flags
}

git_commit_args=()
target=
op=${GITFIXUPACTION:-$(git config --default=fixup fixup.action)}
rebase=${GITFIXUPREBASE:-$(git config --default=false fixup.rebase)}
fixup_menu=${GITFIXUPMENU:-$(git config --default="" fixup.menu)}
create_commit=${GITFIXUPCOMMIT:-$(git config --default=false --type bool fixup.commit)}
base=${GITFIXUPBASE:-$(git config --default="" fixup.base)}
show_all=false
sort_flags="-k2 -k3 -k4" # default flags to sort by time (eg 2025-01-03 10:04:43 +0100, hence 3 fields)
additional_sort_flags=${GITFIXUPADDITIONALSORTFLAGS:-$(git config --default="" fixup.additionalSortFlags)}
while test $# -gt 0; do
    case "$1" in
        -s|--squash)
            op="squash"
            ;;
        -f|--fixup)
            op="fixup"
            ;;
        -a|--amend)
            op="amend"
            ;;
        -c|--commit)
            create_commit=true
            ;;
        --no-commit)
            create_commit=false
            ;;
        --rebase)
            rebase=true
            ;;
        --no-rebase)
            rebase=false
            ;;
        -n|--no-verify)
            git_commit_args+=("$1")
            ;;
        -b|--base)
            shift
            if [ $# -eq 0 ]; then
                die "--base requires an argument"
            fi
            base="$1"
            ;;
        -A|--all)
            show_all=true
            ;;
        -r|--reverse)
            additional_sort_flags="-r"
            ;;
        --)
            shift
            break
            ;;
    esac
    shift
done

target="$1"
if test $# -gt 1; then
    die "Pass only one ref, please"
fi

if test -n "$target"; then
    call_commit "$target"
    if test "$rebase" = "true"; then
        call_rebase "$target"
    fi
    exit
fi

if git diff --cached --quiet; then
    die 'No staged changes. Use git add -p to add them.'
fi

cd_to_toplevel

if test "$base" = "closest"; then
    base=$(git for-each-ref \
        --merged HEAD~1 \
        --sort=-committerdate \
        --count 1 \
        --format='%(objectname)' \
        refs/heads/ \
    )
    if test -z "$base"; then
        die "Could not find the ancestor branch"
    fi
fi

if test -z "$base"; then
    upstream=$(git rev-parse "@{upstream}" 2>/dev/null || true)
    head=$(git rev-parse HEAD 2>/dev/null)
    if test -n "$upstream" && test "$upstream" != "$head"; then
        base="$upstream"
    fi
fi

if test -n "$base"; then
    rev_range="$base..HEAD"
else
    rev_range="HEAD"
fi

if test "$create_commit" = "true"; then
    target=$(print_candidates | sort_commits | show_menu)
    if test -z "$target"; then
        exit
    fi
    call_commit "${target%% *}"
    if test "$rebase" = "true"; then
        call_rebase "${target%% *}"
    fi
else
    print_candidates | sort_commits
fi
