#!/usr/bin/env bash
#
# Commit into a GIT repository.
# Copyright (c) Petr Baudis, 2005
#
# Commits your changes to the GIT repository. Accepts the commit message
# from `stdin`. If the commit message is not modified the commit will be
# aborted.
#
# Note that you can undo a commit by the `cg-admin-uncommit` command,
# but that is possible only under special circumstances. See the CAVEATS
# section of its documentation.
#
# Commit author
# ~~~~~~~~~~~~~
# Each commit has two user identification fields - commit author and committer.
# By default, it is recorded that you authored the commit, but it is considered
# a good practice to change this to the actual author of the change if you are
# merely applying someone else's patch. It is always recorded that you were the
# patch committer.
#
# The commit author is determined by examining various sources in this order:
#
# * '--author' (see OPTIONS)
#
# * 'GIT_AUTHOR_*' (see ENVIRONMENT)
#
# * '.git/author' (see FILES)
#
# * System information: The author name defaults to the GECOS field of your
#   '/etc/passwd' entry, which is taken almost verbatim. The author email
#   defaults to your 'username@hostname.domainname' (but you should change this
#   to the real email address you use if it is any different).
#
# OPTIONS
# -------
# --author AUTHOR_STRING:: Set the author information according to the argument
#	Set the commit author information according to the argument instead
#	of your environment, .git/author, or user information.
#
#	The 'AUTHOR_STRING' format is `Author Name <author@email> Date`. The
#	author name and date is optional, only the email is required to be
#	always present (e.g. '--author "<pasky@ucw.cz>"' will use the current
#	date and the real name set for your system account (usually in
#	the gecos field), but a different email address).
#
# -c COMMIT_ID:: Copy author info and commit message from COMMIT_ID
#	Copy the commit from a given commit ID (that is the author information
#	and the commit message - NOT committer information). This option
#	is typically used when replaying commits from one lineage or
#	repository to another - see also `cg-patch -C`.
#
# -C:: Ignore cache
#	Make `cg-commit` ignore the cache and just commit the thing as-is.
#	Note, this is used internally by 'Cogito' when merging, and it is
#	also useful when you are performing the initial commit manually. This
#	option does not make sense when files are given on the command line.
#
# -m MESSAGE:: Specify commit message
#	Specify the commit message, which is used instead of starting
#	up an editor (if the input is not `stdin`, the input is appended
#	after all the '-m' messages). Multiple '-m' parameters are appended
#	to a single commit message, each as separate paragraph.
#
# -M FILE:: Read commit message from a file
#	Include commit message from a file (this has the same effect as if
#	you would cat it to stdin).
#
# -e:: Force message editing of messages given with -m
#	Force the editor to be brought up even when '-m' parameters were
#	passed to `cg-commit`.
#
# -E:: Force message editing and commit the result
#	Force the editor to be brought up and do the commit even if
#	the default commit message is not changed.
#
# -f:: Force commit when no changes has been made
#	Force the commit even when there's "nothing to commit", that is
#	the tree is the same as the last time you committed, no changes
#	happened. This also forces the commit even if committing is blocked
#	for some reason.
#
# -N:: Only update the cache
#	Don't add the files to the object database, just update the caches
#	and the commit information. This is for special purposes when you
#	might not actually _have_ any object database. This option is
#	normally not interesting.
#
# -p, --review:: Show and enable editing of changes being committed
#	Show changes being commited as a patch appended to the commit message
#	buffer. Changes made to the patch will be reapplied before completing
#	the commit. This only makes sense if you are going to edit the commit
#	message interactively.
#
# -q:: Be very very quiet
#	Be quiet in case there's "nothing to commit", and silently exit
#	returning success. In a sense, this is the opposite to '-f'.
#
# -s, --signoff[=STRING]:: Automatically append a sign off line
#	Add Signed-off-by line at the end of the commit message.
#	Optionally, specify the exact name and email to sign off with by
#	passing: `--signoff="Author Name <user@example.com>"`.
#
# FILES
# -----
# $GIT_DIR/author::
#	If exists, it should be in the format
#		Person Name <email@addy>
#	(both parts are optional) and the GIT_AUTHOR_* environment variables
#	will be set accordingly - if they are not present in the environment
#	yet!
#
# $GIT_DIR/commit-template::
#	If the file exists it will be used as a template when creating
#	the commit message. The template file makes it possible to
#	automatically add `Signed-off-by` line to the log message.
#
# $GIT_DIR/hooks/commit-post::
#	If the file exists and is executable it will be executed upon
#	completion of the commit. The script is passed two arguments.
#	The first argument is the commit ID and the second is the
#	branchname. A sample `commit-post` script might look like:
#
#	#!/bin/sh
#	id=$1
#	branch=$2
#	echo "Committed $id in $branch" | mail user@host
#
# ENVIRONMENT VARIABLES
# ---------------------
# See the 'Commit author' section above for details about the name/email/date
# environment variables meaning and default values.
#
# GIT_AUTHOR_NAME::
#	Author's name.
#
# GIT_AUTHOR_EMAIL::
#	Author's e-mail address.
#
# GIT_AUTHOR_DATE::
#	Date, useful when applying patches submitted over e-mail.
#
# GIT_COMMITTER_NAME::
#	Committer's name. It defaults to the same as GIT_AUTHOR_NAME.
#
# GIT_COMMITTER_EMAIL::
#	Committer's e-mail address. It defaults to the same as
#	GIT_AUTHOR_EMAIL. The recommended policy is not to change this,
#	though - it may not be necessarily a valid e-mail address, but
#	its purpose is more to identify the actual user and machine
#	where the commit was done. However, it is obviously ultimately
#	a policy decision of a particular project to determine whether
#	this should be a real e-mail or not.
#
# EDITOR::
#	The editor used for entering revision log information.
#
# CONFIGURATION VARIABLES
# -----------------------
# The following GIT configuration file variables are recognized:
#
# cogito.hooks.commit.post.allmerged::
#	If set to "true" and you are committing a merge, the post-hook will
#	be called for all the merged commits in sequence (the earliest first).
#	Otherwise, the hook will be called only for the merge commit.

USAGE="cg-commit [-m MESSAGE]... [-e] [-c COMMIT_ID] [OTHER_OPTIONS] [FILE]... [< MESSAGE]"

. "${COGITO_LIB}"cg-Xlib || exit 1


### XXX: The spaghetti code below got rather messy and convoluted over
### the time. Someone should clean it up. :/ --pasky


load_author()
{
	local astr="$1" force="$2"
	if [ "$force" -o -z "$GIT_AUTHOR_NAME" ] && echo "$astr" | grep -q '^[^< ]'; then
		export GIT_AUTHOR_NAME="$(echo "$astr" | sed 's/ *<.*//')"
	fi
	if [ "$force" -o -z "$GIT_AUTHOR_EMAIL" ] && echo "$astr" | grep -q '<.*>'; then
		export GIT_AUTHOR_EMAIL="$(echo "$astr" | sed 's/.*<\(.*\)>.*/\1/')"
	fi
	if [ "$force" -o -z "$GIT_AUTHOR_DATE" ] && echo "$astr" | grep -q '[^> ]$'; then
		export GIT_AUTHOR_DATE="$(echo "$astr" | sed 's/.*> *//')"
	fi
}

if [ -s "$_git/author" ]; then
	load_author "$(cat "$_git/author")"
fi
if [ -z "$GIT_AUTHOR_NAME" -o -z "$GIT_AUTHOR_EMAIL" ]; then
	# Always pre-fill those so that the user can modify them in the
	# commit template.
	idline="$(git-var GIT_AUTHOR_IDENT)"
	[ -z "$GIT_AUTHOR_NAME" ] && export GIT_AUTHOR_NAME="$(echo "$idline" | sed 's/ *<.*//')"
	[ -z "$GIT_AUTHOR_EMAIL" ] && export GIT_AUTHOR_EMAIL="$(echo "$idline" | sed 's/.*<\(.*\)>.*/\1/')"
fi

force=
forceeditor=
ignorecache=
infoonly=
commitalways=
missingok=
review=
signoff=
copy_commit=
msgs=()
msgfile=
quiet=
while optparse; do
	if optparse --author=; then
		load_author "$OPTARG" force
	elif optparse -C; then
		ignorecache=1
	elif optparse -N; then
		missingok=--missing-ok
		infoonly=--info-only
	elif optparse -e; then
		forceeditor=1
	elif optparse -E; then
		forceeditor=1
		commitalways=1
	elif optparse -f; then
		force=1
	elif optparse -q; then
		quiet=1
	elif optparse -p || optparse --review; then
		review=1
	elif optparse -s || optparse --signoff; then
		[ "$signoff" ] || signoff="$(git-var GIT_AUTHOR_IDENT | sed 's/> .*/>/')"
	elif optparse --signoff=; then
		signoff="$OPTARG"
	elif optparse -m=; then
		msgs[${#msgs[@]}]="$OPTARG"
	elif optparse -M=; then
		msgfile="$OPTARG"
	elif optparse -c=; then
		copy_commit="$(cg-object-id -c "$OPTARG")" || exit 1
	else
		optfail
	fi
done

if [ -s "$_git/blocked" ]; then
	if [ "$force" ]; then
		warn "committing to a blocked repository. Assuming you know what are you doing."
	else
		die "committing blocked: $(cat "$_git/blocked")"
	fi
fi

[ "$ignorecache" ] || cg-object-id HEAD >/dev/null 2>&1 || die "no previous commit; use -C for the initial commit"

editor=
[ "$forceeditor" ] && editor=1
[ ! "$msgs" ] && [ ! "$msgfile" ] && [ ! "$copy_commit" ] && editor=1

if [ "$review" ]; then
	PATCH="$(mktemp -t gitci.XXXXXX)"
	PATCH2="$(mktemp -t gitci.XXXXXX)"
fi

if [ "$ARGS" -o "$_git_relpath" ]; then
	[ "$ignorecache" ] && die "-C and listing files to commit does not make sense"
	[ -s "$_git/merging" ] && die "cannot commit individual files when merging"

	filter="$(mktemp -t gitci.XXXXXX)"
	[ "$_git_relpath" -a ! "$ARGS" ] && echo "$_git_relpath" >>"$filter"
	for file in "${ARGS[@]}"; do
		echo "${_git_relpath}$file" >>"$filter"
	done

	eval "commitfiles=($(cat "$filter" | path_xargs git-diff-index -r -m HEAD -- | \
		sed -e 's/"\|\\/\\&/g' -e 's/^\([^	]*\)\(.\)	\(.*\)\(	.*\)*$/"\2 \3"/'))"
	customfiles=1

	[ "$review" ] && cat "$filter" | path_xargs git-diff-index -r -m -p HEAD -- > "$PATCH"
	rm "$filter"

else
	# We bother with added/removed files here instead of updating
	# the cache at the time of cg-(add|rm), since we want to
	# have the cache in a consistent state representing the tree
	# as it was the last time we committed. Otherwise, e.g. partial
	# conflicts would be a PITA since added/removed files would
	# be committed along automagically as well.

	if [ ! "$ignorecache" ]; then
		# \t instead of the tab character itself works only with new
		# sed versions.
		eval "commitfiles=($(git-diff-index -r -m HEAD | \
			sed -e 's/"\|\\/\\&/g' -e 's/^\([^	]*\)\(.\)	\(.*\)\(	.*\)*$/"\2 \3"/'))"

		if [ -s "$_git/commit-ignore" ]; then
			newcommitfiles=()
			for file in "${commitfiles[@]}"; do
				fgrep -qx "${file:2}" "$_git/commit-ignore" && continue
				newcommitfiles[${#newcommitfiles[@]}]="$file"
			done
			commitfiles=("${newcommitfiles[@]}")
		fi
	fi

	[ "$review" ] && git-diff-index -r -m -p HEAD > "$PATCH"

	merging=
	[ -s "$_git/merging" ] && merging="$(cat "$_git/merging" | sed 's/^/-p /')"
fi


LOGMSG="$(mktemp -t gitci.XXXXXX)"
LOGMSG2="$(mktemp -t gitci.XXXXXX)"

written=
if [ "$merging" ] && [ ! "$editor" ]; then
	warn "suppressing default merge log messages in favour of the custom -m passed to me."
elif [ "$merging" ]; then
	echo -n 'Merge with ' >>"$LOGMSG"
	[ -s "$_git/merging-sym" ] || cp "$_git/merging" "$_git/merging-sym"
	for sym in $(cat "$_git/merging-sym"); do
		uri="$(cat "$_git/branches/$sym" 2>/dev/null)"
		[ "$uri" ] || uri="$sym"
		echo "$uri" >>"$LOGMSG"
	done
	echo >>"$LOGMSG"
	if [ -s "$_git/squashing" ]; then
		# We are squashing all the merged commits to a single one.
		# Therefore, helpfully pre-fill the commit message with
		# the messages of all the merged commits.
		git-rev-list --pretty "$(cat "$_git/merging")" ^HEAD >>"$LOGMSG"
	fi
	written=1
fi
for msg in "${msgs[@]}"; do
	[ "$written" ] && echo >>"$LOGMSG"
	echo "$msg" | fmt -s >>"$LOGMSG"
	written=1
done

if [ "$copy_commit" ]; then
	[ "$written" ] && echo >>"$LOGMSG"
	eval "$(git-cat-file commit "$copy_commit" | pick_author)"
	git-cat-file commit "$copy_commit" | sed -e '1,/^$/d' >>"$LOGMSG"
        written=1
fi

if [ "$msgfile" ]; then
	[ "$written" ] && echo >>"$LOGMSG"
	cat "$msgfile" >>"$LOGMSG" || exit 1
	written=1
fi

# Always have at least one blank line, to ease the editing for
# the poor people whose text editor has no 'O' command.
[ "$written" ] || { tty -s && echo >>"$LOGMSG"; }

if [ "$signoff" ] && ! grep -q -i "signed-off-by: $signoff" $LOGMSG; then
	grep -q -i sign-off-by $LOGMSG || echo
	echo "Signed-off-by: $signoff"
fi >> $LOGMSG

if [ -e "$_git/commit-template" ]; then
	cat "$_git/commit-template" >>"$LOGMSG"
else
	cat >>"$LOGMSG" <<EOT
CG: -----------------------------------------------------------------------
CG: Lines beginning with the CG: prefix are removed automatically.
EOT
fi
if [ "$GIT_AUTHOR_NAME" -o "$GIT_AUTHOR_EMAIL" -o "$GIT_AUTHOR_DATE" ]; then
	echo "CG:" >>"$LOGMSG"
	[ "$GIT_AUTHOR_NAME" ] && echo "CG: Author: $GIT_AUTHOR_NAME" >>"$LOGMSG"
	[ "$GIT_AUTHOR_EMAIL" ] && echo "CG: Email: $GIT_AUTHOR_EMAIL" >>"$LOGMSG"
	[ "$GIT_AUTHOR_DATE" ] && echo "CG: Date: $GIT_AUTHOR_DATE" >>"$LOGMSG"
	echo "CG:" >>"$LOGMSG"
fi

if [ ! "$ignorecache" ] && [ ! "$review" ]; then
	if [ ! "$merging" ]; then
		if [ ! "$force" ] && [ ! "${commitfiles[*]}" ]; then
			rm "$LOGMSG" "$LOGMSG2"
			[ "$quiet" ] && exit 0 || die 'Nothing to commit'
		fi
		echo "CG: By deleting lines beginning with CG:F, the associated file" >>"$LOGMSG"
		echo "CG: will be removed from the commit list." >>"$LOGMSG"
	fi	
	echo "CG:" >>"$LOGMSG"
	echo "CG: Modified files:" >>"$LOGMSG"
	for file in "${commitfiles[@]}"; do
		# TODO: Prepend a letter describing whether it's addition,
		# removal or update. Or call git status on those files.
		echo "CG:F   $file" >>"$LOGMSG"
		[ ! "$editor" ] && echo "$file"
	done
	if [ -s "$_git/commit-ignore" ]; then
		echo "CG:" >>"$LOGMSG"
		echo "CG: I have kept back the $(wc -l "$_git/commit-ignore" | cut -d ' ' -f 1) file(s) containing your local changes." >>"$LOGMSG"
		echo "CG: You need not worry, the local changes will not interfere with the merge." >>"$LOGMSG"
	fi
fi
if [ "$review" ]; then
	echo "CG: Changes summary:"
	echo "CG:"
	git-apply --stat --summary < "$PATCH" | sed 's/^/CG: /'
	echo "CG:"
fi >>"$LOGMSG"
[ "$commitalways" ] || echo "CG: Do not save this file and just quit if you want to abort the commit." >>"$LOGMSG"
echo "CG: -----------------------------------------------------------------------" >>"$LOGMSG"

if [ "$review" ]; then
	{
		echo "CG:"
		echo "CG: The patch being committed:"
		echo "CG: (You can edit it; your tree will be modified accordingly and"
		echo "CG: the modified patch will be committed.)"
		echo "CG:"
		cat "$PATCH"
	} >>"$LOGMSG"

	ftdiff="filetype=diff"
fi

echo "CG: vim: textwidth=75 $ftdiff" >>"$LOGMSG"

cp "$LOGMSG" "$LOGMSG2"
if tty -s; then
	if [ "$editor" ]; then
		${EDITOR:-vi} "$LOGMSG2"
		if ! [ "$commitalways" ] && ! [ "$LOGMSG2" -nt "$LOGMSG" ]; then
			echo "Log message unchanged or not specified" >&2
			while true; do
				read -p 'Abort or commit? [ac] ' choice
				if [ "$choice" = "a" ] || [ "$choice" = "q" ]; then
					rm "$LOGMSG" "$LOGMSG2"
					[ "$review" ] && rm "$PATCH" "$PATCH2"
					echo "Commit message not modified, commit aborted" >&2
					if [ "$merging" ]; then
						cat >&2 <<__END__
Note that the merge is NOT aborted - you can cg-commit again, cg-reset will abort it.
__END__
						[ -s "$_git/commit-ignore" ] && cat >&2 <<__END__
(But note that cg-reset will remove your pending local changes as well!)
__END__
					fi
					exit 1
				elif [ "$choice" = "c" ]; then
					break
				fi
			done
		fi
	fi
	if [ ! "$ignorecache" ] && [ ! "$merging" ] && [ ! "$review" ]; then
		eval "newcommitfiles=($(grep ^CG:F "$LOGMSG2" | sed 's/^CG:F *\(.*\)$/"\1"/'))"
		if [ ! "$force" ] && [ ! "${newcommitfiles[*]}" ]; then
			rm "$LOGMSG" "$LOGMSG2"
			[ "$quiet" ] && exit 0 || die 'Nothing to commit'
		fi
		if [ "${commitfiles[*]}" != "${newcommitfiles[*]}" ]; then
			commitfiles=("${newcommitfiles[@]}")
			customfiles=1
		fi
	fi
	setif () {
		if ! grep -q "^CG: $2:" "$LOGMSG2"; then
			unset $1
		else
			export $1="$(grep "^CG: $2:" "$LOGMSG2" | cut -d ' ' -f 3-)"
		fi
	}
	setif GIT_AUTHOR_NAME Author
	setif GIT_AUTHOR_EMAIL Email
	setif GIT_AUTHOR_DATE Date
else
	cat >>"$LOGMSG2"
fi

# Remove heading and trailing blank lines.
if [ ! "$review" ]; then
	grep -v ^CG: "$LOGMSG2" | git-stripspace >"$LOGMSG"
else
	sed '/^CG: Changes summary:/,$d' < "$LOGMSG2" | grep -v ^CG: | git-stripspace >"$LOGMSG"
	sed -n '/^CG: Changes summary:/,$p' < "$LOGMSG2" | grep -v ^CG: > "$PATCH2"
fi
rm "$LOGMSG2"

if [ "$review" ]; then
	if ! cmp -s "$PATCH" "$PATCH2"; then
		echo "Reverting the original patch..."
		if ! cg-patch -R < "$PATCH"; then
			rm "$PATCH" "$LOGMSG"
			die "unable to revert the original patch; your edited patch is available in $PATCH2"
		fi
		echo "Applying the edited patch..."
		if ! cg-patch < "$PATCH2"; then
			rm "$PATCH" "$PATCH2" "$LOGMSG"
			die "unable to apply the edited patch"
		fi
	fi
fi


precommit_update()
{
	queueN=(); queueD=(); queueM=();
	for file in "$@"; do
		op="${file%% *}"
		fname="${file#* }"
		[ "$op" = "N" ] && op=A # N is to be renamed to A
		[ "$op" = "A" ] || [ "$op" = "D" ] || [ "$op" = "M" ] || op=M
		eval "queue$op[\${#queue$op[@]}]=\"\$fname\""
	done
	oldIFS="$IFS"
	IFS=$'\n'
	# XXX: Do we even need to do the --add and --remove update-caches?
	[ "$queueA" ] && { ( echo "${queueA[*]}" | path_xargs git-update-index --add ${infoonly} -- ) || return 1; }
	[ "$queueD" ] && { ( echo "${queueD[*]}" | path_xargs git-update-index --force-remove -- ) || return 1;  }
	[ "$queueM" ] && { ( echo "${queueM[*]}" | path_xargs git-update-index ${infoonly} -- ) || return 1; }
	IFS="$oldIFS"
	return 0
}

if [ ! "$ignorecache" ]; then
	if [ "$customfiles" ]; then
		precommit_update "${commitfiles[@]}" || die "update-cache failed"
		export GIT_INDEX_FILE="$(mktemp -t gitci.XXXXXX)"
		git-read-tree HEAD
	fi
	precommit_update "${commitfiles[@]}" || die "update-cache failed"
fi


oldhead=
oldheadname="$(git-symbolic-ref HEAD)"
if [ -s "$_git/$oldheadname" ]; then
	oldhead="$(cat "$_git/$oldheadname")"
	oldheadstr="-p $oldhead"
fi

treeid="$(git-write-tree ${missingok})"
[ "$treeid" ] || die "git-write-tree failed"
if [ ! "$force" ] && [ ! "$merging" ] && [ "$oldhead" ] &&
   [ "$treeid" = "$(cg-object-id -t)" ]; then
	echo "Refusing to make an empty commit - the tree was not modified" >&2
	echo "since the previous commit. If you really want to make the" >&2
	echo "commit, pass cg-commit the -f argument." >&2
	exit 2;
fi

[ -s "$_git/squashing" ] && merging=" " # viciously prevent recording a proper merge
newhead=$(git-commit-tree $treeid $oldheadstr $merging <"$LOGMSG")
rm "$LOGMSG"

if [ "$customfiles" ]; then
	rm "$GIT_INDEX_FILE"
	export GIT_INDEX_FILE=
fi

if [ "$newhead" ]; then
	git-update-ref HEAD $newhead $oldhead || die "unable to move to the new commit $newhead"
	echo "Committed as $newhead"
	[ "$merging" ] && rm -f "$_git/merging" "$_git/merging-sym" "$_git/merge-base" "$_git/squashing"
	rm -f "$_git/commit-ignore"

	# Trigger the postcommit hook
	branchname=
	if [ -s "$_git/branch-name" ]; then
		warn ".git/branch-name is deprecated and support for it will be removed soon."
		warn "So please stop relying on it, or complain at pasky@suse.cz. Thanks."
		branchname="$(cat "$_git/branch-name")"
	fi
	[ -z "$branchname" ] && [ "$_git_head" != "master" ] && branchname="$_git_head"
	if [ -x "$_git/hooks/commit-post" ]; then
		if [ "$(git-repo-config cogito.hooks.commit.post.allmerged)" = "true" ]; then
			# We just hope that for the initial commit, the user didn't
			# manage to install the hook yet.
			for merged in $(git-rev-list $newhead ^$oldhead | tac); do
				"$_git/hooks/commit-post" "$merged" "$branchname"
			done
		else
			"$_git/hooks/commit-post" "$newhead" "$branchname"
		fi
	fi

	exit 0
else
	die "error during commit (oldhead $oldhead, treeid $treeid)"
fi
