#!/bin/bash

# git-debpush -- create & push a git tag with metadata for an ftp-master upload
#
# Copyright (C) 2019, 2024-2025 Sean Whitton
# Copyright (C) 2025            Ian Jackson
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.

set -e$DGIT_TEST_DEBPUSH_DEBUG
set -o pipefail
shopt -s inherit_errexit # #514862, wtf

# DESIGN PRINCIPLES
#
# - do not invoke dgit, do anything involving any tarballs, no network
#   access except `git push` right at the end
#
# - do not look at the working tree, like `git push` `git tag`
#
# - we are always in split brain mode, because that fits this workflow,
#   and avoids pushes failing just because dgit in the intermediary
#   service wants to append commits
#
# - if there is no previous tag created by this script, require a quilt
#   mode; if there is a previous tag, and no quilt mode provided, assume
#   same quilt mode as in previous tag created by this script

#**** Helper functions and variables ****

us="$(basename "$0")"
git_playtree_setup=git-playtree-setup ###substituted###
git_playtree_setup=${DEBPUSH_GIT_PLAYTREE_SETUP-$git_playtree_setup}

cleanup() {
    if [ -d "$temp" ]; then
        rm -rf -- "$temp"
    fi
}

fail () {
    echo >&2 "$us: $*";
    exit 127;
}

fail_failed_check () {
    # We don't mention the --force=check options here as those are
    # mainly for use by scripts, or when you already know what check
    # is going to fail before you invoke git-debpush.  Keep the
    # script's terminal output as simple as possible.  No "see the
    # manpage"!
    fail "some non-overridden check(s) failed; you can pass -f/--force to ignore them"
}

badusage () {
    fail "bad usage: $*";
}

get_file_from_ref () {
    local path=$1

    # redirect to /dev/null instead of using `grep -Eq` to avoid grep
    # SIGPIPEing git-ls-tree
    if git ls-tree --name-only -r "$branch" \
            | grep -E "^$path$" >/dev/null; then
        git cat-file blob "$branch:$path"
    fi
}

convert_version_to_dep14_tag () {
    # distro must be set
    local version="$1"
    local git_version
    git_version=$(
	echo $version | tr ':~' '%_' | sed 's/\.(?=\.|$|lock$)/.#/g'
    )
    echo "$distro/$git_version"
}

failed_check=false
fail_check () {
    local check=$1; shift
    local check_is_forced=false

    case ",$force," in
        *",$check,"*) check_is_forced=true ;;
    esac
    if $force_all || $check_is_forced; then
        echo >&2 "$us: warning: $* ('$check' check)"
    else
        echo >&2 "$us: check failed: $* ('$check' check)"
	# Require STDERR to be a terminal too so that we know the name
	# of the check that failed is visible.
	if $prompting && [ -t 0 ] && [ -t 1 ] && [ -t 2 ]; then
	    read -p "$us: proceed anyway? (y/N) " resp
	    [ "X${resp,,}" = Xy ] || [ "X${resp,,}" = Xyes ] \
		|| fail_failed_check
	else
            failed_check=true
	fi
    fi
}

fail_check_upstream_nonidentical () {
    fail_check upstream-nonidentical \
 "the upstream source in tag $upstream_tag is not identical to the upstream source in $branch"
}

check_treesame () {
    local first=$1
    local second=$2
    shift 2

    set +e
    git diff --quiet --exit-code "$first".."$second" -- . "$@"
    git_diff_rc=$?
    set -e

    # show the user what the difference was
    if [ $git_diff_rc = 1 ]; then
        git diff --compact-summary "$first".."$second" -- . "$@"
    fi

    if [ $git_diff_rc -le 1 ]; then
        return $git_diff_rc
    else
        fail "'git diff' exited with unexpected code $git_diff_rc"
    fi
}

check_patches_apply () {
    local should_match_branch="$1"

    local git_apply_rc=0

    cd "$playtree"

    # checking out the upstream source and then d/patches on top
    # ensures this check will work for a variety of quilt modes
    git checkout -q -b upstream "$upstream_committish"
    git checkout "$branch_commit" -- debian

    if [ -s "debian/patches/series" ]; then
        while read patch; do
            shopt -s extglob; patch="${patch%%?( )#*}"; shopt -u extglob
            if [ -z "$patch" ]; then continue; fi
            set +e
            git apply --index --whitespace=nowarn \
		"debian/patches/$patch"
            git_apply_rc=$?
            set -e
            if ! [ $git_apply_rc = 0 ]; then
                fail_check patches-nonapplicable \
                           "'git apply' failed to apply patch $patch"
                break
            fi
        done <debian/patches/series

        if $should_match_branch && [ $git_apply_rc = 0 ]; then
            git commit -q -a -m"commit result of applying all patches"
            check_treesame HEAD "$branch_commit" ':!debian' \
                || fail_check patches-nonapplicable \
                              "applying all patches does not yield $branch"
        fi
    fi

    cd "$pwd"
}

untracked_in () {
    local dir=$1
    git ls-files -o --exclude-standard -- :"$dir"
}

#**** Parse command line ****

getopt=$(getopt -s bash -o 'ntfu:' \
              -l 'dry-run,tag-only,print-tag-text,batch,force::,\
branch:,remote:,distro:,upstream:,quilt:,gbp,dpm,\
baredebian,baredebian+git,baredebian+tarball' \
              -n "$us" -- "$@")
eval "set - $getopt"
set -e$DGIT_TEST_DEBPUSH_DEBUG

git_tag_opts=()
tagging=true
pushing=true
printing=false
prompting=true
force_all=false
force=
distro=debian
quilt_mode=
branch="HEAD"

while true; do
    case "$1" in
        '-n'|'--dry-run')   tagging=false; pushing=false; shift;   continue ;;
        '-t'|'--tag-only')  pushing=false;                shift;   continue ;;
        '--print-tag-text') printing=true;                shift;   continue ;;
        '--batch')          prompting=false;              shift;   continue ;;
        '-u')               git_tag_opts+=(-u "$2");      shift 2; continue ;;
        '-f')               force_all=true;               shift;   continue ;;
        '--gbp')            quilt_mode='gbp';             shift;   continue ;;
        '--dpm')            quilt_mode='dpm';             shift;   continue ;;
        '--branch')         branch=$2;                    shift 2; continue ;;
        '--remote')         remote=$2;                    shift 2; continue ;;
        '--distro')         distro=$2;                    shift 2; continue ;;
        '--quilt')          quilt_mode=$2;                shift 2; continue ;;
        '--upstream')       upstream_tag=$2;              shift 2; continue ;;

        '--baredebian'|'--baredebian+git')
            quilt_mode=baredebian;                        shift;   continue ;;
        '--baredebian+tarball')
            fail "--baredebian+tarball quilt mode not supported"
            ;;

        # we require the long form of the option to skip individual
        # checks, not permitting `-f check`, to avoid problems if we
        # later want to introduce positional args
        '--force')
            case "$2" in
                '')
                    force_all=true                         ;;
                *)
                    force="$force,$2"                      ;;
            esac
            shift 2; continue ;;

        '--') shift; break ;;
	*) badusage "unknown option $1" ;;
    esac
done

if [ $# != 0 ]; then
    badusage 'no positional arguments allowed'
fi

case "$quilt_mode" in
    linear|try-linear|smash|nofix|gbp|dpm|unapplied|baredebian|'') ;;
    baredebian+git) quilt_mode="baredebian" ;;
    baredebian+tarball) fail "--baredebian+tarball quilt mode not supported" ;;
    *) badusage "invalid quilt mode: $quilt_mode" ;;
esac

#**** Early sanity check ****

set +e
headref="$(git symbolic-ref --quiet HEAD)"
git_sym_ref_rc=$?
set -e
if [ $git_sym_ref_rc = 0 ]; then
    detached=false
elif [ $git_sym_ref_rc = 1 ]; then
    detached=true
else
    fail "git-symbolic-ref unexpectedly exited with exit code >1"
fi

if [ "x$branch" = xHEAD ] && $detached; then
    fail_check detached \
 "HEAD is detached; you probably don't want to debpush it"
fi

#**** Gather git information ****

remoteconfigs=()
to_push=()

# Maybe $branch is a symbolic ref.  If so, resolve it
branchref="$(git symbolic-ref -q "$branch" || test $? = 1)"
if [ "x$branchref" != x ]; then
   branch="$branchref"
fi
# If $branch is the name of a branch but it does not start with
# 'refs/heads/', prepend 'refs/heads/', so that we can know later
# whether we are tagging a branch or some other kind of committish
case "$branch" in
    refs/heads/*) ;;
    *)
        branchref="$(git for-each-ref --format='%(objectname)' \
                         "[r]efs/heads/$branch")"
	# TODO test this code path
        if [ "x$branchref" != x ]; then
            branch="refs/heads/$branch"
        fi
        ;;
esac

# If our tag will point at a branch, push that branch, and add its
# pushRemote and remote to the things we'll check if the user didn't
# supply a remote
case "$branch" in
    refs/heads/*)
        b=${branch#refs/heads/}
        to_push+=("$b")
        remoteconfigs+=( "branch.$b.pushRemote" "branch.$b.remote" )
        ;;
esac

# resolve $branch to a commit
branch_commit="$(git rev-parse --verify "${branch}^{commit}")"

# also check, if the branch does not have its own pushRemote or
# remote, whether there's a default push remote configured
remoteconfigs+=(remote.pushDefault)

if $pushing && [ "x$remote" = x ]; then
    for c in "${remoteconfigs[@]}"; do
	remote=$(git config "$c" || test $? = 1)
	if [ "x$remote" != x ]; then break; fi
    done
    if [ "x$remote" = x ]; then
	fail "pushing, but could not determine remote, so need --remote="
    fi
fi

#---- set up a playtree

pwd="$(pwd)"

playground="$(git rev-parse --git-dir)/git-debpush"
playtree="$playground/check"

rm -rf "$playground"
mkdir -p "$playtree"

cd "$playtree"
"$git_playtree_setup" .
cd "$pwd"

#**** Second early sanity check ****

# || and && have equal precedence in shell.
if [ "x$branch" = xHEAD ] \
       || { ! $detached && [ "X$branch" = "X$headref" ]; }; then
    git_status_uno="$(git status -uno --porcelain)"
    [ -n "$git_status_uno" ] && fail_check uncommitted \
 "there are uncommitted changes, which won't be uploaded"

    if [ "X$quilt_mode" = Xbaredebian ]; then
	untracked="$(untracked_in /debian)"
	[ -n "$untracked" ] && fail_check untracked \
 "there are untracked files in debian/, which won't be uploaded"
    else
	untracked="$(untracked_in /)"
	[ -n "$untracked" ] && fail_check untracked \
 "there are untracked files, which won't be uploaded"
    fi
fi

#**** Gather source package information ****

temp=$(mktemp -d)
trap cleanup EXIT
mkdir "$temp/debian"
git cat-file blob "$branch":debian/changelog >"$temp/debian/changelog"
version=$(cd "$temp"; dpkg-parsechangelog -SVersion)
source=$(cd "$temp"; dpkg-parsechangelog -SSource)
target=$(cd "$temp"; dpkg-parsechangelog -SDistribution)

# Find the version of the most recent maintainer upload (ie disregarding
# NMUs), by looking at the changelog.  We use this to get the right quilt
# mode in the face of concurrent pushes from co-maintainers.
previous_maintainer_version=$(
    cd $temp

    # We would have preferred not to hard code this knowledge of Debian
    # Policy, because that's distro-specific.  However, we only use this for
    # quilt mode detection (see above).  Doing it this way seems better than
    # inventing a distro policy config mechanism.

    case "$distro" in
	# Debian Policy 5.6.12.2 "Special version conventions"
	# NMUs are either revision with a dot,
	# or native version containing `nmu`.
	debian) version_is_nmu_pcre='-.*\.|^[^-]*nmu^[^-]*$' ;;
	*) version_is_nmu_pcre='^$' ;;
    esac

    set +o pipefail # Work around #423399
    dpkg-parsechangelog --all --format=rfc822 -SVersion	\
        | tail -n +2					\
	| grep -P -v -e "$version_is_nmu_pcre"		\
	| head -n1
    ps="${PIPESTATUS[*]/141/0}"
    case "$ps" in
	'0 0 1 0' | '0 0 0 0') ;; # ok if grep findsno matches
	*) fail 'changelog parsing failed'
    esac
    set -o pipefail
)

rm -rf -- "$temp"
trap - EXIT

format="$(get_file_from_ref debian/source/format)"
case "$format" in
    '3.0 (quilt)')  upstream=true ;;
    '3.0 (native)') upstream=false ;;
    '1.0'|'')
	if get_file_from_ref debian/source/options | grep -q '^-sn *$'; then
	    upstream=false
        elif get_file_from_ref debian/source/options | grep -q '^-sk *$'; then
	    upstream=true
	else
	    fail 'please see "SOURCE FORMAT 1.0" in git-debpush(1)'
	fi
	;;
    *)
	fail "unsupported debian/source/format $format"
	;;
esac

#---- information in git form, but derived from the source tree

debian_tag=$(convert_version_to_dep14_tag "$version")

#**** Determine upstream git info ****

upstream_info=""
if $upstream; then
    if [ "x$upstream_tag" = x ]; then
	upstream_tag=$(
	    set +e
	    git deborig --just-print --version="$version" \
			   | head -n1
	    ps="${PIPESTATUS[*]}"
	    set -e
	    case "$ps" in
		"0 0"|"141 0") ;; # ok or SIGPIPE
		*" 0")
		    echo >&2 \
 "$us: git-deborig failed; maybe try $us --upstream=TAG"
		    exit 0
		    ;;
		*) exit 127; # presumably head will have complained
	    esac
	)
	if [ "x$upstream_tag" = x ]; then exit 127; fi
    fi
    upstream_committish=$(git rev-parse "refs/tags/${upstream_tag}"^{})
    upstream_info=" upstream-tag=$upstream_tag upstream=$upstream_committish"
    to_push+=("$upstream_tag")
fi

#**** Useful sanity checks ****

#---- UNRELEASED suite

if [ "$target" = "UNRELEASED" ]; then
    fail_check unreleased "UNRELEASED changelog"
fi

#---- Pushing dgit view to maintainer view

if ! [ "x$last_debian_tag" = x ] && ! [ "x$last_archive_tag" = x ]; then
    last_debian_tag_c=$(git rev-parse "$last_debian_tag"^{})
    last_archive_tag_c=$(git rev-parse "$last_archive_tag"^{})
    if ! [ "$last_debian_tag_c" = "$last_archive_tag_c" ] \
            && git merge-base --is-ancestor \
                   "$last_debian_tag" "$last_archive_tag"; then
        fail_check dgit-view \
"looks like you might be trying to push the dgit view to the maintainer branch?"
    fi
fi

#---- Upstream tag is not ancestor of $branch

if ! [ "x$upstream_tag" = x ] \
        && ! git merge-base --is-ancestor "$upstream_tag" "$branch" \
        && ! [ "$quilt_mode" = "baredebian" ]; then
    fail_check upstream-nonancestor \
 "upstream tag $upstream_tag is not an ancestor of $branch; probably a mistake"
fi

#---- Quilt mode-specific checks

case "$quilt_mode" in
    gbp)
        check_treesame "$upstream_tag" "$branch" ':!debian' ':!**.gitignore' \
            || fail_check_upstream_nonidentical
        check_patches_apply false
        ;;
    unapplied)
        check_treesame "$upstream_tag" "$branch" ':!debian' \
            || fail_check_upstream_nonidentical
        check_patches_apply false
        ;;
    baredebian)
        check_patches_apply false
        ;;
    dpm|nofix)
        check_patches_apply true
        ;;
esac

#---- git-debrebase branch format checks

# only check branches, since you can't run `git debrebase conclude` on
# non-branches
case "$branch" in
    refs/heads/*)
        # see "STITCHING, PSEUDO-MERGES, FFQ RECORD" in git-debrebase(5)
        ffq_prev_ref="refs/ffq-prev/${branch#refs/}"
        if git show-ref --quiet --verify "$ffq_prev_ref"; then
            fail_check unstitched \
 "this looks like an unstitched git-debrebase branch, which should not be pushed"
        fi
esac

#---- Intent to use pristine-tar for this upload

case "$version" in
    *"-1"|*"-0.1")
	uversion="${version%-*}"
	if $upstream && type pristine-tar >/dev/null 2>/dev/null \
		&& pristine-tar list \
		    | grep -q "^${source}_${uversion}"'\.orig\.tar\.'
	then
	    fail_check pristine-tar \
 "pristine-tar data present for $uversion, but this will be ignored (#1106071)"
	fi
esac

#---- Submodules

# Per gitmodules(7) "FORMS", .gitmodules is always present at the
# top-level of the tree if there are submodules.
[ -n "$(git ls-tree --full-tree "$branch" -- .gitmodules)" ] \
    && fail_check submodule \
 "git submodule(s) detected; these are not supported"

#**** fetch from the remote ****

# This is fiddly.  We want to check for the existence of various
# remote refs and/or fetch from them:
#
# Specifically, the following refs on the remote:
#
#   * refs/tags/debian/VVVV
#
#     This probably doesn't exist but if it does exist we should fail.
#     We don't actually need to see the git objects it consists of
#     for our own purposes.  We *could* take the trouble to fetch it
#     to the user's git namespace, but right now we don't do that.
#
#   * refs/tags/debian/PPPP where PPPP is the previous maintainer
#     upload version.
#
#     We want to properly fetch this into our playtree's ref namespace.
#     We want it because if the user's branch has been converted to a
#     different branch format by their co-maintainer, we'll need it
#     to tell us the quilt mode.  We want it *not* to be in the user's
#     refs/tags/ because the global tag namespace means fetching tags
#     willy-nilly can be annoying.
#
#   * refs/heads/BRANCH.
#
#     We want this for ff checks.
#
#     Ideally we want to fetch this into the user's remote but that
#     would involve running git fetch outside the playtree as well
#     as in it.
#
# There are some awkwardnesses:
#
#  a. If a ref we ask for doesn't exist on the remote, git-fetch crashes.
#  b. git-fetch supports glob patterns but only * at the end, so
#     we can't use it to fetch only things we want.
#  c. Remote tracking branches are allowed to be affected by ref mapping
#     driven by the config.
#  d. tags have an annoying global namespace and we want to be careful
#     not to accidentally pollute it
#
# Our strategy is:
#
#  0. Mirror the user's tag namespace in the playtree's refs/tags/.
#
#  1. Run git ls-remote and interpret its output to tell us what the
#     situation is there.
#
#  2. For the objects we actually want (see above), check whether
#     we have them already (with git cat-file).  If we don't seem to,
#     run git fetch.

# TODO this code is not yet complete!

if $pushing; then
    fetch_url="$(git remote get-url "$remote")"
fi

cd "$playtree"

# copy all tags from the main tree into the playtree
git fetch -q "$pwd" 'refs/tags/*:refs/tags/*'

get_remote_objid () {
    # must be called in playtree
    # expects playground/remote-listing from git-ls-remote (see below)
    local remote_ref_want=$1 remote_ref_got
    exec 5<../remote-listing
    while IFS=$'\t' read <&5 remote_objid remote_ref_got; do
	if [ "x$remote_ref_got" = "x$remote_ref_want" ]; then return; fi
    done
    remote_objid=''
}

if $pushing; then

    ls_remote_patterns=("refs/tags/$debian_tag")

    git ls-remote "$fetch_url" "${ls_remote_patterns[@]}" >../remote-listing

    get_remote_objid "refs/tags/$debian_tag"
    if [ "x$remote_objid" != x ]; then
	fail "Tag $debian_tag already exists at $fetch_url; can't push it again.
This version seems already to have been released."
    fi

fi

cd "$pwd"

#**** Gather git history information ****

cd "$playtree"

find_last_tag () {
    local prefix=$1

    set +o pipefail             # perl will SIGPIPE git-log(1) here
    git log --pretty=format:'%D' --decorate=full "$branch_commit" \
        | perl -MDpkg::Version -F", " -we'
            @debian_tag_vs =
                  sort { version_compare($b, $a) } grep defined,
                  map m|tag: refs/tags/'"$prefix"'(.+)|, @F
              or next;
            print "'"$prefix"'$debian_tag_vs[0]\n"; exit'
    set -o pipefail
}

last_debian_tag=$(find_last_tag "$distro/")
last_archive_tag=$(find_last_tag "archive/$distro/")

cd "$pwd"

#**** Create the git tag text ****

to_push+=("$debian_tag")

#---- calculate the default quilt mode

# If the user didn't supply a quilt mode, look for it in a previous
# tag made by this script
if [ "x$quilt_mode" = x ] && [ "$format" = "3.0 (quilt)" ]; then
    set +o pipefail             # perl will SIGPIPE git-cat-file(1) here
    if [ "x$last_debian_tag" != x ]; then
        quilt_mode=$(git cat-file -p $(git rev-parse "$last_debian_tag") \
                         | perl -wne'/^\[dgit.*--quilt=([a-z+]+).*\]$/
                                       or next;
                                     print "$1\n"; exit')
    fi
    set -o pipefail
fi

quilt_mode_text=
if [ "$format" = "3.0 (quilt)" ]; then
    if [ "x$quilt_mode" = x ]; then
        echo >&2 "$us: could not determine the git branch layout"
        echo >&2 "$us: please supply a --quilt= argument"
        exit 1
    else
        quilt_mode_text=" --quilt=$quilt_mode"
    fi
fi

tagmessage="$source release $version for $target

[dgit distro=$distro split$quilt_mode_text]
[dgit please-upload source=$source version=$version$upstream_info]
"

#**** Actually produce and deliver the output ****

$failed_check && fail_failed_check
fail_check () { fail "internal error - fail_check called too late $*"; }

if $printing; then
    printf "%s" "$tagmessage"
fi

if $tagging; then
    git tag "${git_tag_opts[@]}" -s -m "$tagmessage" \
	"$debian_tag" "$branch"
fi

if $pushing; then
    git push "$remote" "${to_push[@]}"
fi

rm -rf "$playground"
