#!/bin/sh
# repo-commit -- a Gentoo repository commit helper
# (c) 2011-2012 Michał Górny & Nathan Phillip Brink
# Released under the terms of the 2-clause BSD license.

# -- output helpers --

# Output the message to STDERR.
say() {
	echo "${@}" >&2
}

# Output the error message and abort the script with non-zero status.
die() {
	say "${RED}${@}${RESET}"
	exit 1
}

# Output the debug message if --verbose was used.
sayv() {
	[ -n "${SC_VERBOSE}" ] && say "${GREEN}${@}${RESET}"
}

# Execute the command and die with simple error message if it fails.
req() {
	"${@}" || die "'${@}' failed."
}

# Append the arguments to an IFS-separated list variable whose name was
# passed as the first arg.
array_append() {
	local varname
	varname=${1}
	shift

	eval "set -- \${${varname}} \"\${@}\"; ${varname}=\${*}"
}

# -- POSIX compat --

# Check whether 'local' is supported.
local_supported() {
	PATH= local test 2>/dev/null
}

# If it is not, declare dummy local() function unsetting the variables.
( local_supported ) || eval 'local() {
	unset "${@}"
}'

# -- 'look around' functions --

# See if we're in a repo, and what VCS are we using.
find_repo() {
	if svn info >/dev/null 2>&1; then
		SC_VCS=svn
		: ${SC_WANT_CHANGELOG=1}
	elif cvs status -l >/dev/null 2>&1; then
		SC_VCS=cvs
		: ${SC_WANT_CHANGELOG=1}
	elif hg tip >/dev/null 2>&1; then
		SC_VCS=hg
	else
		local remotes ret
		remotes=$(git branch -r 2>/dev/null)
		ret=${?}

		if [ ${ret} -ne 127 ] && [ ${ret} -ne 128 ]; then
			if echo "${remotes}" | grep git-svn >/dev/null 2>&1; then
				: ${SC_WANT_CHANGELOG=1}
			fi
			SC_VCS=git
		else
			die 'Unable to find any familiar repository type (are you inside the repo?).'
		fi
	fi

	sayv "Ok, we're in the ${SC_VCS} working tree. Let's see what I can do around here..."
}

# Check whether a particular directory has been completely removed
# from the repo.
is_whole_dir_removed() {
	if [ ${SC_VCS} = svn ]; then
		[ "$(svn status --depth=empty -- "${1}" | wc -l)" = 1 ]
	elif [ ${SC_VCS} = git ]; then
		[ -z "$(git ls-files -c -- "${1}")" ]
	elif [ ${SC_VCS} = hg ]; then
		[ -z "$(hg status -madc "${1}")" ]
	elif [ ${SC_VCS} = cvs ]; then
		[ -z "$(cvs -Q status -R "${1}" 2>/dev/null | grep '^File:' | grep -v 'Status: Locally Removed$')" ]
	fi
}

# Check whether we're having a clean package removal.
is_package_removal() {
	local fields list
	[ -d profiles ] && fields=1-2 || fields=1

	if [ ${SC_VCS} = git ]; then
		list=$(git diff-index --relative --name-only --diff-filter=D HEAD)
	elif [ ${SC_VCS} = hg ]; then
		list=$(hg status -nr .)
	elif [ ${SC_VCS} = cvs ]; then
		list=$(cvs -n -q up 2>/dev/null | sed -n -e 's/^R//p')
	elif [ ${SC_VCS} = svn ]; then
		list=$(svn status -q | sed -n -e 's/^D       //p')
	fi
	list=$(echo "${list}" | cut -d / -f ${fields} | sort | uniq)

	# 1) We have to have any removes.
	[ -z "${list}" ] && return 1

	# Few more checks.
	local dir olist
	for dir in ${list}; do
		# 2) These removes have to remove whole directories.
		is_whole_dir_removed ${dir} && array_append olist "${dir}"
	done

	[ -z "${olist}" ] && return 1

	SC_CHANGE_LIST=${olist}
	return 0
}

# Look around for ebuilds; determine the scenario we're working on.
find_ebuilds() {
	# POSIX is fun -- look for ebuilds in the current directory.
	if [ -n "$(find \( -name '*.ebuild' -print -o ! -name '.' -prune \))" ]; then
		local stripped

		# Get CATEGORY and PN.
		stripped=${PWD%/*}
		stripped=${stripped%/*}
		SC_CP=${PWD#${stripped}/}

		SC_SCENARIO=ebuild-commit
		sayv "We found ebuilds for ${SC_CP} here."
	elif is_package_removal; then
		local cplist category pkg rootprefix
		# We can either have the category on the list or in PWD.
		if [ -d profiles ]; then
			category=
		else
			local stripped
			stripped=${PWD%/*}
			category=${PWD#${stripped}/}/
		fi

		SC_CP=
		SC_REMOVED_PACKAGE_LIST=
		# Now we can have multiple packages around.
		for pkg in ${SC_CHANGE_LIST}; do
			if [ -z "${category}" ]; then
				case ${pkg} in
					eclass/*|licenses/*|local/*|profiles/*|scripts/*)
						continue
						;;
				esac
			fi
			SC_CP=${SC_CP:+${SC_CP}, }${category}${pkg}
			array_append SC_REMOVED_PACKAGE_LIST "${category}${pkg}"
		done

		SC_ROOT=${category:+../}

		# Replace with the filtered version, placing all atoms
		# relative to SC_ROOT.
		SC_CHANGE_LIST=
		for pkg in ${SC_REMOVED_PACKAGE_LIST}; do
			array_append SC_CHANGE_LIST "${SC_ROOT}${pkg}"
		done

		local sdir
		for sdir in eclass licenses profiles; do
			check_for_changes ${SC_ROOT}${sdir} >/dev/null && SC_CHANGE_LIST="${SC_CHANGE_LIST} ${SC_ROOT}${sdir}"
		done
		SC_SCENARIO=package-removal
		sayv "We're removing ${SC_CP}."
	else
		die 'No familiar scenario found.'
	fi
}

# -- VCS helpers --

# Check whether a particular locations have changed, ignoring ChangeLog
# changes.
check_for_changes() {
	local output

	if [ ${SC_VCS} = git ]; then
		output=$(git diff-index --name-only --relative HEAD -- "${@}")
	elif [ ${SC_VCS} = hg ]; then
		output=$(hg status -- ${1-.} "${@}")
	elif [ ${SC_VCS} = svn ]; then
		output=$(svn status -- "${@}")
	elif [ ${SC_VCS} = cvs ]; then
		# `U' indicates a remote, incoming update.
		output=$(cvs -n -q update -R -- "${@}" 2>/dev/null | grep -v '^U')
	fi

	[ -z "${output}" ] && return 1
	# We do not care about user mangling ChangeLog, we will reset it anyway.
	echo "${output}" | grep -v ChangeLog >/dev/null
}

# Discard any changes to a particular set of files.
vcs_reset() {
	if [ ${SC_VCS} = git ]; then
		git checkout HEAD -- "${@}" 2>/dev/null || req rm -f -- "${@}"
	elif [ ${SC_VCS} = hg ]; then
		[ -n "$(hg status -au "${@}")" ] && req rm -f -- "${@}"
		hg revert --no-backup -- "${@}" 2>/dev/null
	elif [ ${SC_VCS} = svn ]; then
		req rm -f -- "${@}"
		svn revert -- "${@}" >/dev/null
	elif [ ${SC_VCS} = cvs ]; then
		# cvs update -C does exist, but it sometimes doesn't
		# work.
		req rm -f -- "${@}"
		cvs update -- "${@}" >/dev/null 2>&1
	fi
}

# Request VCS to provide a verbose status report.
vcs_status() {
	if [ ${SC_VCS} = git ]; then
		git status -s -- ${1-.} "${@}"
	elif [ ${SC_VCS} = hg ]; then
		hg status -- ${1-.}  "${@}"
	elif [ ${SC_VCS} = svn ]; then
		svn status -- "${@}"
	elif [ ${SC_VCS} = cvs ]; then
		cvs -n -q up -- "${@}" 2>/dev/null | grep -v '^U'
	fi
}

# Request VCS to provide a verbose diff.
vcs_diff() {
	if [ ${SC_VCS} = git ]; then
		git --no-pager diff HEAD -- ${1-.}
	elif [ ${SC_VCS} = hg ]; then
		hg diff -- ${1-.}
	elif [ ${SC_VCS} = svn ]; then
		svn diff -- "${@}"
	elif [ ${SC_VCS} = cvs ]; then
		cvs -n -q diff -u -p -- "${@}"
	fi
}

# Add particular files to the repository.
vcs_add() {
	${SC_VCS} add -- "${@}"
}

# Commit the specified objects using the commit message provided
# as the first argument. Does not return.
vcs_commit() {
	local msg
	msg=${1}
	shift

	if [ ${SC_VCS} = git ]; then
		exec git commit -m "${msg}" ${1+-o} -- "${@}"
	elif [ ${SC_VCS} = hg ]; then
		exec hg commit -m "${msg}" -- ${1-.} "${@}"
	elif [ ${SC_VCS} = svn ]; then
		exec svn commit -m "${msg}" -- "${@}"
	elif [ ${SC_VCS} = cvs ]; then
		exec cvs commit -m "${msg}" -- "${@}"
	fi
}

# Call VCS to update the working copy to HEAD revision.
vcs_update() {
	# Unlike svn, DVCSes don't push the changes to their origins immediately.
	# That's why we don't force update to it right here.
	if [ ${SC_VCS} = svn ]; then
		sayv "Updating the repository..."
		svn up -- "${@}" || say "Warning: ${SC_VCS} up failed, trying to proceed anyway."
	elif [ ${SC_VCS} = cvs ]; then
		sayv "Updating the repository..."
		cvs up -d -P -- "${@}" || say "Warning: ${SC_VCS} up failed, trying to proceed anyway."
	fi
}

# Check the spelling of the commit message if enabled
check_spelling() {
	if [ -n "${no_check_spelling}" ]; then
		echo "${@}"
		return
	fi

	local speller misspelled_words
	for speller in "enchant -l -d en | cat" \
		"aspell -l en list | cat" \
		"hunspell -l -d en_US | hunspell -l -d en_GB" \
		"ispell -l -denglish | ispell -l -dbritish"; do
		if echo | ${speller%|*} 2>/dev/null \
				&& echo | ${speller%|*} | ${speller#*|} 2>/dev/null; then
			misspelled_words=$(echo "${@}" | ${speller%|*} | ${speller#*|})
			break
		fi
	done

	local word expressions
	for word in ${misspelled_words}; do
		case ${word} in
			[Ee]build|[Gg]entoo|[Gg]entoo-x86|${SC_CP#*/}*|${SC_CP%/*})
				continue
				;;
			[Rr]epoman|[Mm]etadata|[Xx][Mm][Ll])
				continue
				;;
		esac
		expressions="${expressions} -e s/\\(^\\|[^a-zA-Z]\\)\\(${word}\\)\\([^a-zA-Z]\\|\$\\)/\\1${RED}\\2${RESET}\\3/g"
	done

	# sed can't handle zero expressions.
	if [ -z "${expressions}" ]; then
		echo "${@}"
		return
	fi

	echo "${@}" | sed ${expressions}
}

# Print the help message.
print_help() {
	cat <<_EOH_
Synopsis:
	repo-commit [options] [--] <commit message>

Options:
	-?, -h, --help		print this message,
	-V, --version		print version string,

	-c, --changelog		force creating a ChangeLog entry,
	-C, --nocolor		disable colorful output,
	-d, --noupdate		disable updating the repository,
	--diff			display diff of changes before committing,
	-f, --force		force repoman to proceed with the commit,
	-H, --nochangelog	do not append to ChangeLog nor revert it,
	-m, --noformat		do not prepend the commit message with package names,
	-S, --nospelling	do not try to check commit message's spelling,
	-q, --quiet		backwards compat (ignored),
	-t, --trivial		trivial changes (do not add a ChangeLog entry),
	-v, --verbose		enable verbose output,
	-y, --noask		do not ask before committing (avoid interactivity).
_EOH_
}

# Request confirmation before committing. Abort if it is not granted.
confirm() {
	${SC_NOASK+return}
	while true; do
		local answ
		printf '\n%s' "${WHITE}Commit changes?${RESET} [${BGREEN}Yes${RESET}/${RED}No${RESET}] ${GREEN}" >&2
		read answ
		printf '%s' "${RESET}"

		case "${answ}" in
			[yY]|[yY][eE]|[yY][eE][sS])
				break
				;;
			[nN]|[nN][oO])
				die 'Aborting.'
				;;
			*)
				say "Your response '${answ}' not understood, try again."
		esac
	done
}

# Guess what!
main() {
	local no_check_spelling commitmsg force monochrome noprepend noupdate \
		trivial print_diff repoman_changelog
	unset SC_NOASK SC_VERBOSE SC_WANT_CHANGELOG

	# Command-line parsing.
	while [ ${#} -gt 0 ]; do
		case "${1}" in
			--help|-\?|-h)
				print_help
				exit 0
				;;
			--version|-V)
				echo 'repo-commit 0.4.1'
				exit 0
				;;

			-c|--changelog)
				SC_WANT_CHANGELOG=force
				;;
			-C|--nocolor)
				monochrome=1
				;;
			-d|--noupdate)
				noupdate=1
				;;
			--diff)
				print_diff=1
				;;
			-f|--force)
				force=1
				;;
			-H|--nochangelog)
				SC_WANT_CHANGELOG=
				;;
			-m|--noformat)
				noprepend=
				;;
			-q|--quiet)
				;;
			-S|--nospelling|--no-spelling)
				no_check_spelling=1
				;;
			-t|--trivial)
				trivial=1
				;;
			-v|--verbose)
				SC_VERBOSE=1
				;;
			-y|--noask)
				SC_NOASK=
				;;

			--)
				shift
				array_append commitmsg "${@}"
				break
				;;
			-*)
				die "Unknown option: ${1}; see --help." >&2
				;;
			*)
				array_append commitmsg "${1}"
				;;
		esac
		shift
	done

	# Initialize colors.
	if [ -n "${monochrome}" ]; then
		RESET=
		RED=
		GREEN=
		BGREEN=
		YELLOW=
		WHITE=
	else
		local esc
		esc=$(printf '\033[')

		RESET=${esc}0m
		RED="${esc}1;31m"
		GREEN=${esc}32m
		BGREEN="${esc}1;32m"
		YELLOW="${esc}1;33m"
		WHITE="${esc}1;37m"
	fi

	[ -n "${commitmsg}" ] || die 'No commit message provided.'

	# Look around.
	find_repo
	find_ebuilds

	case ${SC_SCENARIO} in
		# Committing changes within the ebuild directory.
		# This includes committing new ebuilds.
		ebuild-commit)
			check_for_changes || die 'No changes found to commit.'

			if [ -z "${noupdate}" ]; then
				vcs_update
			fi

			local bns bn word bug_next
			for word in ${commitmsg}; do
				case ${word} in
					[Bb][Uu][Gg])
						bug_next=1
						;;
					\#*)
						bn="${word#\#}"
						;;
					*)
						[ -z "${bug_next}" ] && continue
						bn="${word}"
						bug_next=
						;;
				esac
				if [ -n "${bn}" ]; then
					bns="${bns:+${bns} }$(expr "${bn}" : '[^[:digit:]]*\([[:digit:]]\{1,\}\)')"
					bn=
				fi
			done

			# With DVCS repos, we do not do ChangeLogs by default...
			# ...at least unless they're already there.
			[ -f ChangeLog ] && : ${SC_WANT_CHANGELOG=1}
			if [ -n "${SC_WANT_CHANGELOG}" ]; then
				sayv 'Cleaning up the ChangeLog...'
				vcs_reset ChangeLog

				# Creating a new ChangeLog? Let's take a look at the commit message.
				if [ ! -f ChangeLog ]; then
					[ -n "${trivial}" ] && die "Trivial change for an initial commit? You're joking, right?"

					# Sunrise-specific checks.
					if [ "$(cat ../../profiles/repo_name 2>/dev/null)" = "sunrise" ]; then
						[ -z "${bns}" ] && die 'Please supply the bug number in the initial commit message!'
						if [ ! -f metadata.xml ]; then
							req cp ../../skel.metadata.xml metadata.xml
							# Output similar to echangelog.
							[ -n "${print_diff}" ] || diff -dup /dev/null metadata.xml
							req vcs_add metadata.xml
						fi
					fi
				fi

				# create ChangeLog entries using repoman if possible
				repoman --version --echangelog=y >/dev/null 2>&1
				if [ ${?} -ne 2 ]; then
					if [ -z "${trivial}" ]; then
						repoman_changelog='--echangelog=y'
					else
						repoman_changelog='--echangelog=n'
					fi
				else
					if [ -z "${trivial}" ]; then
						local ecopts
						[ ${SC_WANT_CHANGELOG} = force ] && ecopts=--no-strict
						sayv '...and appending to it.'
						echangelog --vcs ${SC_VCS} ${ecopts} -- "${commitmsg}" \
							|| die 'Please correct the problems shown by echangelog.'
						echo
					fi
				fi
			fi

			if [ -n "${bns}" ]; then
				local bn cbn
				for bn in ${bns}; do
					cbn=#${WHITE}${bn}${NORMAL}
					wget -q --no-check-certificate \
						"https://bugs.gentoo.org/show_bug.cgi?id=${bn}" -O - \
					| sed -n \
						-e "s, *<title>\(Gentoo \)\?Bug \([0-9]*\) \(-\|&ndash;\) \(.*\)</title>,Bug ${cbn}: ${BGREEN}\4${RESET},p" \
						-e "s, *<title>\(Gentoo \)\?\(Invalid Bug ID\)</title>,Bug ${cbn}: ${YELLOW}!! \2${RESET},p"
				done
				echo
			fi

			if [ -n "${print_diff}" ]; then
				vcs_diff
			elif [ ${SC_VCS} != cvs ] || [ -n "${noupdate}" ]; then
				vcs_status
			fi
			echo

			# Since commit 32264c3, repoman supports '--ask' option,
			# which requests user confirmation before the commit.
			# We like that, because it does it in the right place.
			#
			# If user is using earlier repoman version, we need to
			# request that confirmation ourselves. As we would like
			# the user to see 'repoman full' results first, we need
			# to call it ourselves. Moreover, it requires Manifest to be
			# up-to-date, so we need to call 'repoman manifest' too.
			#
			# That's pretty sad, because it means we're wasting time
			# calling the same repoman functions twice (once manually,
			# then within 'repoman commit'). That's why we would be
			# happy if user updated his/her Portage, and we'd like to
			# encourage him/her to do so -- but we'll have to delay that
			# until a new Portage version is released.

			local old_repoman
			repoman --version -a >/dev/null 2>&1
			if [ ${?} -eq 2 ]; then
				old_repoman=

				#say "${GREEN}Please consider updating portage to newer version.${RESET}"
				#say
				req repoman manifest
				if ! repoman full; then
					[ -n "${force}" ] || die 'Please correct the problems shown by repoman.'
				fi
			fi

			# In CVS, we don't prepend the package name to the commit message.
			[ ${SC_VCS} = cvs ] && noprepend=

			say "${BGREEN}Ready to commit using the following commit message:${RESET}"
			say "${noprepend-${SC_CP}: }$(check_spelling "${commitmsg}")"
			${old_repoman+confirm}

			sayv "Now, let's let repoman do its job..."
			exec repoman commit ${old_repoman-${SC_NOASK--a}} ${force+-f} ${repoman_changelog} -m "${noprepend-${SC_CP}: }${commitmsg}"
			;;

		# Clean removal of a package set.
		package-removal)
			vcs_status ${SC_CHANGE_LIST}
			echo

			local pkg regex
			regex=
			for pkg in ${SC_REMOVED_PACKAGE_LIST}; do
				regex=${regex:+${regex}\|}${pkg}
			done

			say "${GREEN}Grepping for package references...${RESET}"
			# -n is for line numbers, -C would be non-POSIX
			if grep -n "${regex}" ${SC_ROOT}*/*/*.ebuild ${SC_ROOT}*/*/metadata.xml ${SC_ROOT}profiles/package.mask 2>/dev/null; then
				echo
				[ -n "${force}" ] || die 'Please remove the removed package references or use --force.'
			else
				echo
			fi

			say "${BGREEN}Ready to commit ${WHITE}$(echo ${SC_REMOVED_PACKAGE_LIST} | wc -w)${BGREEN} package removal(s), with commit message:${RESET}"
			say "${SC_CP}: $(check_spelling "${commitmsg}")"
			confirm

			if [ -z "${noupdate}" ]; then
				vcs_update ${SC_CHANGE_LIST}
			fi

			vcs_commit "${noprepend-${SC_CP}: }${commitmsg}" ${SC_CHANGE_LIST}
			;;
	esac
}

main "${@}"
