#!/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.
#
# By default, the commit is recorded as made by you, but you can change
# that - useful if you are e.g. applying a patch submitted by someone
# else. See the ENVIRONMENT section below.
#
# 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.
#
# OPTIONS
# -------
# -c 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.
#
# -C::
#	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 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.
#
# -e::
#	Force the editor to be brought up even when '-m' parameters were
#	passed to `cg-commit`.
#
# -E::
#	Force the editor to be brought up and do the commit even if
#	the default commit message is not changed.
#
# -f::
#	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.
#
# -N::
#	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.
#
# -q::
#	Be quiet in case there's "nothing to commit", and silently exit
#	returning success. In a sense, this is the opposite to '-f'.
#
# 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
# ---------------------
# GIT_AUTHOR_* general notes::
#	Those variables describe who is the author of the change being
#	committed. It defaults to you, but it is considered good practice
#	to change this to the actual author of the change if the patch
#	was submitted by someone else etc.
#
# GIT_AUTHOR_NAME::
#	Author's name. This defaults to the GECOS field of your /etc/passwd
#	entry, which is taken almost verbatim. (Note that this is carried
#	over by GIT, not Cogito, and may change in the future GIT versions.)
#
# GIT_AUTHOR_EMAIL::
#	Author's e-mail address. This defaults to your
#	username@hostname.domainname, but you should change it to your
#	real email you use if it is different.
#
# 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.

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

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


[ -s $_git/blocked ] && die "committing blocked: $(cat $_git/blocked)"

if [ -s $_git/author ]; then
	if [ -z "$GIT_AUTHOR_NAME" ] && grep -q '^[^<]' $_git/author; then
		export GIT_AUTHOR_NAME="$(sed 's/ *<.*//' $_git/author)"
	fi
	if [ -z "$GIT_AUTHOR_EMAIL" ] && grep -q '<.*>' $_git/author; then
		export GIT_AUTHOR_EMAIL="$(sed 's/.*<\(.*\)>.*/\1/' $_git/author)"
	fi
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=
copy_commit=
msgs=()
quiet=
while optparse; do
	if 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 -m=; then
		msgs[${#msgs[@]}]="$OPTARG"
	elif optparse -c=; then
		copy_commit="$(cg-object-id -c "$OPTARG")"
	else
		optfail
	fi
done

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

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 's/^\([^	]*\)\(.	.*\)\(	.*\)*$/"\2"/'))"
	customfiles=1

	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 's/^\([^	]*\)\(.	.*\)\(	.*\)*$/"\2"/'))"

		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

	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" ] && [ "$msgs" ] && [ ! "$forceeditor" ]; then
	echo "Warning: Suppressing default merge log messages in favour of the custom -m passed to me." >&2
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
		read
	fi
	written=1
fi
first=1
for msg in "${msgs[@]}"; do
	if [ "$first" ]; then
		first=
	else
		echo >>$LOGMSG
	fi
	echo "$msg" | fmt -s >>$LOGMSG
	written=1
done

if [ "$copy_commit" ]; then
	pick_author_script='
		/^author /{
			h
			s/^author \([^<]*\) <[^>]*> .*$/\1/
			s/'\''/'\''\'\'\''/g
			s/.*/GIT_AUTHOR_NAME='\''&'\''/p

			g
			s/^author [^<]* <\([^>]*\)> .*$/\1/
			s/'\''/'\''\'\'\''/g
			s/.*/GIT_AUTHOR_EMAIL='\''&'\''/p

			g
			s/^author [^<]* <[^>]*> \(.*\)$/\1/
			s/'\''/'\''\'\'\''/g
			s/.*/GIT_AUTHOR_DATE='\''&'\''/p

			q
		}
	'
	set_author_env="$(git-cat-file commit "$copy_commit" |
	                  sed -ne "$pick_author_script")"
	eval "$set_author_env"
	export GIT_AUTHOR_NAME
	export GIT_AUTHOR_EMAIL
	export GIT_AUTHOR_DATE
	git-cat-file commit "$copy_commit" | sed -e '1,/^$/d'
        written=1
fi >> $LOGMSG

# Always have at least one blank line, to ease the editing for
# the poor people whose text editor has no 'O' command.
[ "$written" ] || echo >>$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" ]; 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
		[ "$msgs" ] && ! [ "$forceeditor" ] && 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
[ "$commitalways" ] || echo "CG: Do not save this file and just quit if you want to abort the commit." >>$LOGMSG
echo "CG: -----------------------------------------------------------------------" >>$LOGMSG
echo "CG: vim: textwidth=75" >>$LOGMSG

cp $LOGMSG $LOGMSG2
if tty -s; then
	if ! ([ "$copy_commit" ] || [ "$msgs" ]) || [ "$forceeditor" ]; 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
					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" ]; 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.
grep -v ^CG: $LOGMSG2 | git-stripspace >$LOGMSG
rm $LOGMSG2


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
		echo "Warning: .git/branch-name is deprecated and support for it will be removed soon." >&2
		echo "Warning: So please stop relying on it, or complain at pasky@suse.cz. Thanks." >&2
		branchname=$(cat $_git/branch-name)
	fi
	[ -z "$branchname" ] && [ "$_git_head" != "master" ] && branchname="$_git_head"
	if [ -x $_git/hooks/commit-post ]; 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
	fi

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