#!/usr/bin/env bash
#
# Apply a patch from a file, standard input, or a commit
# Copyright (c) Petr Baudis, 2005
#
# Apply a patch in a manner similar to the 'patch' tool, but while also
# handling the Git extensions to the diff format: file mode changes, file
# renames, distinguishing removal of files and empty files, etc. Newly
# created files are automatically `cg-add`ed and removed files are
# `cg-rm`oved.
#
# `cg-patch` can also automatically commit the applied patches and extract
# patches from existing commits, therefore effectively enabling you to
# 'cherrypick' certain changes from a different branch.
#
# In comparison with the 'git-apply' tool, `cg-patch` will also apply
# fuzzy patches.
#
# OPTIONS
# -------
# -c:: Automatically commit the patch
#	Automatically extract the commit message and authorship information
#	(if provided) from the patch and commit it after applying it
#	successfully.
#
# -C COMMIT:: Cherry-pick the given commit
#	Instead of applying a patch from stdin, apply and commit the patch
#	introduced by the given commit. This is basically an extension of
#	`cg-commit -c`, it also applies the commit diff.
#
#	In combination with '-R', this does the opposite - it will revert
#	the given commit and then try to commit a revert commit - it will
#	prefill the headline and open the commit editor for you to write
#	further details.
#
#	Note that even though this is functionally equivalent to the
#	cherry-picking concept present in other version control systems,
#	this does not play very well together with regular merges and if
#	you both cherry-pick and merge between two branches, the picking
#	may increase the number of conflicts you will get when merging.
#
# -d DIRNAME:: Apply all patches in directory
#	Instead of applying a patch from stdin, apply and separately commit
#	all patches in the specified directory. This can be used to import
#	a range of patches made by `cg-mkpatch -d`. Implies '-c'.
#
# -e:: Edit commit message before committing
#	Edit the commit message before performing a commit. Makes sense
#	only with '-c' or other options implying '-c' (e.g. '-m').
#
# -m:: Apply patches in a mailbox
#	Applies series of patches in a mailbox fed to the command's
#	standard input. Implies '-c'.
#
# -pN:: Strip path to the level N
#	Strip path of filenames in the diff to the level N. This works
#	exactly the same as in the `patch` tool except that the default
#	strip level is not infinite but 1 (or more if you are in a
#	subdirectory; in short, `cg-diff | cg-patch -R` and such always
#	works).
#
# -R:: Apply in reverse
#	Apply the patch in reverse (therefore effectively unapply it).
#	Implies '-e' except when the input is not a tty.
#
# --resolved:: Resume -d or -m after conflicts were resolved
#	In case the patch series application failed in the middle and
#	you resolved the situation, running cg-patch with with the '-d' or '-m'
#	argument as well as '--resolved' will cause it to pick up where it
#	dropped off and go on applying. (This includes committing the failed
#	patch; do not commit it on your own!) (For '-m', you don't need to
#	feed the mailbox on stdin anymore.)
#
# -s, --signoff[=STRING]:: Automatically append a sign off line
#	Add Signed-off-by line at the end of the commit message when
#	autocommitting (-c, -C, -d or -m). Optionally, specify the exact name
#	and email to sign off with by passing:
#	`--signoff="Author Name <user@example.com>"`.
#
# Takes the diff on stdin (unless specified otherwise).

# Testsuite: TODO

USAGE="cg-patch [-c] [-C COMMIT] [-pN] [-R] [-m | -d DIR] [OTHER_OPTIONS] < PATCH"

. "${COGITO_LIB:-/usr/lib/cogito/}"cg-Xlib || exit 1


lookover_patch()
{
	local file="$1" where="$2"
	local author="$(sed -n '/^\(---\|-- \)$/,$p' < "$file" | sed -n '/^author /p')"
	[ "$author" ] || warn "no author info found$where, assuming your authorship"
	eval "$(echo "$author" | pick_author)"
}

commit_patch()
{
	local file="$1"
	local -a ciargs=()
	[ -z "$signoff" ] || ciargs[${#ciargs[@]}]="$signoff"
	[ -z "$edit" ] || ciargs[${#ciargs[@]}]="$edit"
	# FIXME: -e is broken, it won't pre-fill the message
	sed '/^\(---\|-- \|diff --git .*\)$/,$d' < "$file" | cg-commit "${ciargs[@]}"
}

resume_filter()
{
	sed "0,/\/$(echo "$lastpatch" | sed 's#/#\\/#g')$/d"
}


parse_mail_info()
{
	local patch="$1"
	while read line; do
		case $line in
		Author:*)
			export GIT_AUTHOR_NAME="${line#* }";;
		Email:*)
			export GIT_AUTHOR_EMAIL="${line#* }";;
		Date:*)
			export GIT_AUTHOR_DATE="${line#* }";;
		Subject:*)
			mi_subj="${line#* }";;
		esac
	done <"$resume/i/$patch"
}

# Assuming that parse_mail_info() has been already ran on the patch.
commit_mail_patch()
{
	local patch="$1"
	local -a ciargs=()
	[ -z "$signoff" ] || ciargs[${#ciargs[@]}]="$signoff"
	[ -z "$edit" ] || ciargs[${#ciargs[@]}]="$edit"
	cg-commit -m"$mi_subj" -M"$resume/m/$patch" "${ciargs[@]}"
}


applyargs=()
commitid=
commit=
mbox=
commitdir=
strip=$((1+$(echo "$_git_relpath" | tr -cd / | wc -c)))
reverse=
resolved=
signoff=
edit=
while optparse; do
	if optparse -C=; then
		commitid="$(cg-object-id -c "$OPTARG")" || exit 1
		commitparent="$(cg-object-id -p "$commitid")" || exit 1
		[ -z "$commitparent" ] && die "cannot pick initial commit"
		[ "$(echo "$commitparent" | wc -l)" -gt 1 ] &&
			die "refusing to pick merge commits"

	elif optparse -c; then
		commit=1

	elif optparse -d=; then
		commitdir="$(echo "$OPTARG" | sed 's,/*$,,')"
		[ -d "$commitdir" ] || die "$commitdir: not a directory"

	elif optparse -m; then
		mbox=1

	elif optparse -p=; then
		strip="$OPTARG"
		[ -n "$(echo "$strip" | tr -d 0-9)" ] &&
			die "the -p argument must be numeric"
		applyargs[${#applyargs[@]}]="-p$strip"

	elif optparse --resolved; then
		resolved=1

	elif optparse -R; then
		reverse=1
		applyargs[${#applyargs[@]}]="-R"

	elif optparse -s || optparse --signoff; then
		[ "$signoff" ] || signoff="--signoff=$(git-var GIT_AUTHOR_IDENT | sed 's/> .*/>/')"

	elif optparse --signoff=; then
		signoff="--signoff=$OPTARG"

	elif optparse -e; then
		edit="-e"

	else
		optfail
	fi
done


[ "$resolved" ] && [ -z "$commitdir" ] && [ -z "$mbox" ] &&
	die "--resolved can be passed only with -d"


if [ "$commitid" ] || [ "$commit" ] || [ "$commitdir" ] || [ "$mbox" ]; then
	[ "$_git_relpath" ] && die "must be ran from project root"

	if [ "$commitid" ]; then
		[ "$unidiff" ] && die "-u does not make sense here"
		[ $strip -ne 1 ] && die "-p does not make sense here"

		files="$(mktemp -t gitpatch.XXXXXX)"
		git-diff-tree -m -r "$commitparent" "$commitid" | cut -f 2- >"$files"
		if local_changes_in "$files"; then
			rm "$files"
			die "cherry-pick blocked by local changes"
		fi
		eval "afiles=($(cat "$files" | sed -e 's/"\|\\/\\&/g' -e 's/^.*$/"&"/'))"
		rm "$files"

		ciargs=()
		if ! [ "$reverse" ]; then
			ciargs=(-c "$commitid")
		else
			ciargs=(-m "Revert ${commitid:0:12}")
			if tty -s; then
				ciargs[${#ciargs[@]}]="-e"
			fi
			reverse=-R
		fi
		[ -z "$signoff" ] || ciargs[${#ciargs[@]}]="$signoff"
		[ -z "$edit" ] || ciargs[${#ciargs[@]}]="$edit"

		if ! cg-diff -p -r "$commitid" | cg-patch "${applyargs[@]}"; then
			echo "Cherry-picking $commitid failed, please fix up the rejects." >&2
			echo "You can use cg-commit -c $commitid to commit afterwards (that will" >&2
			echo "reuse the commit message and authorship); throw in -e to add own comments." >&2
			exit 1
		fi
		cg-commit "${ciargs[@]}" "${afiles[@]}"
	fi

	if [ "$commit" ]; then
		[ "$reverse" ] && die "cannot do -R here"

		local_changes &&
			die "cannot auto-commit patches when the tree has local changes"
		file="$(mktemp -t gitpatch.XXXXXX)"
		cat >"$file"
		lookover_patch "$file"
		cg-patch "${applyargs[@]}" <"$file" || exit 1
		commit_patch "$file"
		rm "$file"
	fi

	if [ "$commitdir" ]; then
		[ "$reverse" ] && die "cannot do -R here"

		resume="$commitdir/.cg-patch-resume"
		if [ -s "$resume" ]; then
			if [ ! "$resolved" ]; then
				echo "cg-patch: previous import in progress" >&2
				echo "Use --resolved to resume after conflicts." >&2
				echo "Cancel the resume by deleting $resume" >&2
				exit 1
			fi

			echo "Resuming import of $commitdir:"
			filter=resume_filter

			lastpatch="$(cat "$resume")"
			echo "* $lastpatch"
			commit_patch "$commitdir/$lastpatch"
			rm -f "$resume"

		elif local_changes; then
			die "cannot auto-commit patches when the tree has local changes"

		else
			echo "Importing $commitdir:"
			filter=cat
		fi

		find "$commitdir" -name '[0-9]*-*' | sort | "$filter" | \
		while read -r file; do
			echo "* ${file#$commitdir/}"
			lookover_patch "$file" " in $file"
			if ! cg-patch "${applyargs[@]}" < "$file"; then
				echo "${file#$commitdir/}" > "$resume"
				echo "cg-patch: conflicts during import" >&2
				echo "Rerun cg-patch with the -d... --resolved arguments to resume after resolving them." >&2
				echo "Cancel the resume by deleting $resume" >&2
				exit 1
			fi
			commit_patch "$file"
		done
	fi

	if [ "$mbox" ]; then
		[ "$reverse" ] && die "cannot do -R here"

		resume="$_git/info/cg-patch-mresume"
		if [ -d "$resume" ]; then
			if [ ! "$resolved" ]; then
				echo "cg-patch: previous import in progress" >&2
				echo "Use --resolved to resume after conflicts." >&2
				echo "Cancel the resume by deleting $resume" >&2
				exit 1
			fi

			echo "Resuming import of mailbox:"

			lastpatch="$(cat "$resume/last")"
			parse_mail_info "$lastpatch"
			echo
			echo "* $lastpatch $mi_subj"
			commit_mail_patch "$lastpatch"
			rm "$resume/a/$lastpatch"

		elif local_changes; then
			die "cannot auto-commit patches when the tree has local changes"

		else
			echo "Importing mailbox:"
			mkdir -p "$resume" || exit 1
			mkdir -p "$resume/a"
			mkdir -p "$resume/i"
			mkdir -p "$resume/m"
			mkdir -p "$resume/p"
			git-mailsplit -o"$resume/a" >/dev/null
		fi

		for patch in "$resume"/a/*; do
			patch="${patch#$resume/a/}"
			[ "$patch" = "*" ] && break # Out of patches
			echo "$patch" >"$resume/last"
			git-mailinfo "$resume/m/$patch" "$resume/p/$patch" \
				<"$resume/a/$patch" >"$resume/i/$patch"
			parse_mail_info "$patch"
			echo
			echo "* $patch $mi_subj"
			if ! cg-patch "${applyargs[@]}" < "$resume/p/$patch"; then
				echo "cg-patch: conflicts during import" >&2
				echo "Rerun cg-patch with the -m --resolved arguments to resume after resolving them." >&2
				echo "Cancel the resume by deleting $resume" >&2
				exit 1
			fi
			commit_mail_patch "$patch"
			rm "$resume/a/$patch"
		done

		rm -r "$resume"
	fi

	exit
fi


# We want to run patch in the subdirectory and at any rate protect other
# parts of the tree from inadverent pollution.
[ -n "$_git_relpath" ] && cd "$_git_relpath"


exec git-apply --index --reject "${applyargs[@]}"
