Web lists-archives.com

[SCRIPT/RFC 3/3] git-commit--onto-parent.sh




Finally, "git-commit--onto-parent.sh"[*1*] shows an initial script 
version for you to examine, test out and hopefully comment on :)

Especially interesting part might be index-only three-way file merge, 
through usage of "git-merge-one-file--cached" script. Of course, this 
still only works for some trivial resolutions, where in case of more 
complex ones, involving unresolved conflicts, we back-out and fail. 
Still, it should be more powerful than `git-apply`.

Consider this proof of concept and work in progress, an idea where 
I`d like feedback on everything you come up with or find interesting, 
even parameter name possibly used instead of "--onto-parent" (and its 
short version), or approach in general.

For example, it might make sense to separate commit creation (on 
current HEAD`s parent) and its actual re-merging into integration 
test branch, where "--remerge" (or something) parameter would be used 
on top of "--onto-parent" to trigger both, if/when desired.

Another direction to think in might be introducing more general 
"--onto" parameter, too (or instead), without "parent" restriction, 
allowing to record a commit on top of any arbitrary commit (other 
than HEAD). This could even be defaulted to "git commit <commit-ish>" 
(no option needed), where current "git commit" behaviour would then 
just be a special case of omitted <commit-ish> defaulting to HEAD, 
aligning well with other Git commands sharing the same behaviour.

Alas, rewind to present...

Please do note that I`m still relatively new to Git, and pretty new 
to both Linux and scripting in general (on Windows as well), and the 
whole concept of open-source software contributing, even, so please 
bare with me (or at least don`t get upset too much, lol), and do feel 
free to share your thoughts and remarks, even the trivial or harsh 
ones -- I`m grateful to learn and expand my knowledge, hopefully 
producing something useful in return :) Heck, might be I`m totally 
off-track here as well.

p.s. For some context - nowadays I mostly work in Delphi, and 
occasionally in C#, though through last 20 years I`ve been involved 
with C, Pascal, Basic, but also PHP, JavaScript, and whatnot - even 
good old assembly from time to time, when needed :)

Regards, Buga

[*1*] "git-commit--onto-parent.sh", probably too heavily commented in 
 the first place, but as I`m new to everything here I kind of feel the 
 plain words might unfortunately describe my intention a bit better 
 than my code, for now at least.
--- 8< ---
#!/bin/sh
#
# Copyright (c) 2017 Igor Djordjevic

i=$#
while test $i != 0
do
	#
	# Parameter parsing might be uninteresting here, as the whole
	# script is currently just a wrapper around `git commit`, for a
	# functionality that conceptually belongs there directly.
	#
	case "$1" in
	--onto-parent=*)
		onto_parent="${1#*=}"

		shift && i=$(expr $i - 1)
		;;
	--onto-parent)
		shift && i=$(expr $i - 1)
		onto_parent="$1"

		shift && i=$(expr $i - 1)
		;;
	-a|--a|--al|--all)
		all=t

		#
		# For now, `git commit` "--all" option is special-cased in
		# terms of being stripped out of the original command line
		# (to be passed to `git commit`) and processed manually, as
		# once commit is to be made, due to states of index and
		# working tree, "--all" is most probably NOT what the user
		# wants nor expects ;)
		#
		shift && i=$(expr $i - 1)
		;;
	*)
		# parameters to pass down to `git commit`
		set -- "$@" "$1"
		shift && i=$(expr $i - 1)
		;;
	esac
done

main () {
	#
	# Store current HEAD (ref or commit) and verify that
	# --onto-parent is valid and amongst its parents.
	#
	head="$(git symbolic-ref --short --quiet HEAD)" ||
	head="$(git rev-parse HEAD^0)" &&
	verify_onto_parent "$head" "$onto_parent" || exit 1

	#
	# As both HEAD and "--onto-parent" could be refs, where underlying
	# commits could change, store original commits for later parents
	# processing, getting updated parent list for new/updated
	# merge commit.
	#
	head_commit="$(git rev-parse "$head"^0)" &&
	onto_parent_commit="$(git rev-parse "$onto_parent"^0)" || exit 1

	#
	# Custom processing of stripped "--all" parameter - if we were to
	# just pass it to `git commit`, "--all" would most probably yield
	# an unexpected result in the eyes of the user, as it would include
	# _all changes from all the other merge commit parents as well_,
	# not just the changes we may actually wanted to "push down"
	# (commit) onto specified parent (what would `git diff` show),
	# due to state of index and working tree at the time of commit.
	#
	if test -n "$all"
	then
		git add --update
	fi

	#
	# Abort if no cached changes, nothing to be committed.
	#
	git diff --cached --quiet
	if test $? -eq 0
	then
		printf >&2 '%s\n' "error: no changes added to commit"
		exit 1
	fi

	#
	# Backup current index to be restored (and committed) in the end.
	#
	merge_index="$(git write-tree)" || exit 1

	#
	# Reset index to destination parent (without touching working
	# tree), and try applying cached changes.
	#
	# In case changes do not apply cleanly onto desired parent, abort.
	#
	apply_changes "$head_commit" "$onto_parent_commit" "$merge_index" "$onto_parent" || exit 1

	#
	# Move HEAD to specified parent (without touching working tree)
	# to prepare to record a commit there, and make the commit,
	# passing parameters through to `git commit`.
	#
	# In case of error, or when `git commit` didn`t actually make
	# the commit, like when --dry-run parameter is provided (for
	# example), abort, as there is nothing to do - no new commit, no
	# need to produce the "updated" merge commit, either.
	#
	# Note that we don`t abort right away, as restoring original
	# index and HEAD position is needed all the same, so we
	# potentially abort only once that is done, a bit further below.
	#
	# [ This is something that could be thought of a bit more,
	# might be forbidding passing through of some `git commit`
	# parameters in the first place, like --dry-run...? ]
	#
	move_head "$onto_parent" &&
	git commit "$@"
	if test $? -ne 0 || {
		new_parent_commit="$(git rev-parse HEAD^0)"
		test "$new_parent_commit" = "$onto_parent_commit"
	}
	then
		no_commit=t
	fi

	#
	# Remove entry from HEAD reflog, not to pollute it with
	# uninteresting in-between steps we take, leaking implementation
	# details to end user.
	#
	# We do left it inside corresponding branch reflog where commit
	# is made (if $onto_parent was a branch), though, as that`s where
	# it still matters.
	#
	git reflog delete HEAD@{0}

	#
	# Restore original index state and move HEAD to original position,
	# (still not touching working tree), aborting if previously
	# signalled.
	#
	git read-tree --reset "$merge_index" &&
	move_head "$head" &&
	test -z "$no_commit" || exit 1

	#
	# Drop original HEAD merge commit to have it replaced by
	# upcoming "updated" merge commit.
	#
	# This step is needed for eventually getting an expected merge
	# message out of "git fmt-merge-msg", as it seems HEAD dependent
	# as well, beside being input format picky already...?
	#
	#git update-ref --create-reflog -m "reset: moving to HEAD^" HEAD HEAD^ || exit 1
	reflog_ref="$(git symbolic-ref --short --quiet HEAD)"
	git update-ref HEAD HEAD^ || exit 1

	#
	# Remove both HEAD and underlying reference reflog entries this
	# time, as here we really want to mask previous step completely,
	# being taken just to satisfy "git fmt-merge-msg" expectations.
	#
	if test -n "$reflog_ref"
	then
		git reflog delete "$reflog_ref"@{0}
	fi
	git reflog delete HEAD@{0}

	#
	# Prepare "updated" merge commit message and parent list.
	#
	if test -n "$(git rev-parse --verify --quiet $head_commit^2^{commit})"
	then
		merge_parents="$(get_merge_parents "$head_commit" "$onto_parent" "$onto_parent_commit" "$new_parent_commit")" &&
		merge_message="$(get_merge_message "$merge_parents")" || exit 1
	else
		#
		# As we`re actually selling the option as "--onto-parent" and
		# not "--onto-MERGE-parent", we might as well properly support
		# a special case where HEAD commit is not a merge.
		#
		# Existing HEAD commit will come after commit to be made,
		# basically being kind of rebased onto new commit (but still
		# not touching working tree), where we can then also reuse
		# original HEAD commit authorship, and message, too (instead
		# of building a merge one).
		#
		merge_parents="$new_parent_commit" &&
		merge_message="$(git show -s --format=%B "$head_commit")" &&
		reuse_authorship $head_commit || exit 1
	fi
	merge_parent_commits="$(get_merge_parent_commits "$merge_parents")" &&

	#
	# Do the actual commit, updating HEAD accordingly.
	#
	merge_commit="$(printf '%s\n' "$merge_message" |
	git commit-tree "$merge_index" $merge_parent_commits)" &&
	git update-ref --create-reflog -m "$merge_message" HEAD "$merge_commit" || exit 1
}

verify_onto_parent () {
	#
	# $1 starting point head (ref or commit)
	# $2 parent of $1 to commit onto (ref or commit)
	#
	local head="$1"
	local onto_parent="$2"

	if test -z "$onto_parent"
	then
		printf >&2 '%s\n' "error: no parent provided"
		printf >&2 '%s\n' "(use \"--onto-parent <commit-ish>\""
		return 1;
	fi

	if test -z "$(git rev-parse --verify --quiet "$onto_parent"^{commit})"
	then
		printf >&2 '%s\n' "error: '$onto_parent' not valid commit object"
		return 1
	fi

	local onto_parent_commit="$(git rev-parse "$onto_parent"^0)"
	for parent_commit in $(git rev-parse $head^@)
	do
		if test "$parent_commit" = "$onto_parent_commit"
		then
			return 0
		fi
	done

	printf >&2 '%s\n' "error: '$onto_parent' not parent of '$head'"
	return 1
}

apply_changes () {
	#
	# $1 original/starting point HEAD commit
	# $2 parent commit of $1 to apply changes to
	# $3 index with changes on top of $1 to apply/merge onto $2
	# $4 original parameter value of $2 (ref or commit), used for
	#    prettier message only
	#
	local head_commit="$1"
	local onto_parent_commit="$2"
	local merge_index="$3"
	local onto_parent="$4"

	git read-tree --reset $onto_parent_commit &&

	#
	# Attempt simple patching first - take differences between
	# $head_commit and $merge_index and try applying to current index
	# (previously reset to $onto_parent_commit).
	#
	git diff-tree --binary --patch --find-renames --find-copies $head_commit $merge_index |
	git apply --cached 2>/dev/null &&
	return 0

	printf '%s\n' "Unable to apply cleanly onto '$onto_parent', trying simple merge"

	#
	# A bit more aggressive approach - try merging with resolving
	# trivial conflicts on tree level only (involving file as a whole,
	# no conflicts inside file itself).
	#
	# Note that we take $head_commit as merge-base, producing such
	# three-way merge result that basically all changes between
	# $onto_parent_commit and $head_commit are reversed, as they`re
	# also included inside $merge_index, where only differences
	# between $head_commit and $merge_index are applied (in a
	# three-way merge manner) to $onto_parent_commit, being exactly
	# what we want here.
	#
	git read-tree -i -m --aggressive $head_commit $onto_parent_commit $merge_index || exit 1
	git write-tree >/dev/null 2>&1 &&
	return 0

	printf '%s\n' "Simple merge did not work, trying automatic merge"

	#
	# Final attempt - try merging with resolving trivial conflicts on
	# file level, too (conflicts inside file itself).
	#
	# Notice usage of "git-merge-one-file--cached" script here, being
	# a slightly tweaked version of original "git-merge-one-file",
	# not touching working tree but stuffing trivial three-way
	# file merge resolution back into index directly.
	#
	# If still left with conflicts that need to be resolved manually,
	# abort... and go home, you`re drunk.
	#
	if ! git merge-index -o git-merge-one-file--cached -a
	then
		# abort, cleanup
		git read-tree --reset $merge_index
		exit 1
	fi
}

move_head () {
	#
	# $1 destination ref or commit
	#
	# Move HEAD to $1 without touching the working tree.
	#
	# Kind of "soft checkout", where original "git checkout" touches
	# the working tree, and "git reset --soft" does not move HEAD,
	# both undesired here.
	#
	local destination="$1"

	local destination_commit="$(git rev-parse --verify --quiet $destination^0)" ||
	{
		printf >&2 '%s\n' "fatal: invalid reference: $destination"
		return 1
	}
	#local reflog_message="$(get_checkout_reflog_message $destination)"
	local destination_ref="$(git rev-parse --symbolic-full-name $destination)"

	case "$destination_ref" in
	refs/heads/*)
		# can`t use "update-ref --no-deref" as it writes commit only,
		# instead of ref, essentially detaching HEAD to that commit
		#git symbolic-ref -m "$reflog_message" HEAD "$destination_ref"
		git symbolic-ref HEAD "$destination_ref"
		;;
	refs/tags/*|\
	refs/remotes/*|\
	"")
		# can`t use "symbolic-ref" as it refuses to write commit only,
		# expecting a valid ref instead (value inside "refs/")
		#git update-ref --create-reflog -m "$reflog_message" --no-deref HEAD "$destination_commit"
		git update-ref --no-deref HEAD "$destination_commit"

		# mask this step as end-user uninteresting implementation detail
		git reflog delete HEAD@{0}
		;;
	*)
		printf >&2 '%s\n' "fatal: invalid reference: $destination_ref"
		return 1
		;;
	esac

	return 0
}

get_merge_parents () {
	#
	# $1 original/starting point HEAD commit
	# $2 parent of $1 to commit onto (ref or commit)
	# $3 original commit of $2 (if $2 is ref, otherwise equals $2)
	# $4 new commit (onto $2, to be new merge parent)
	#
	# Walk original merge commit parents to find the one we`re posting
	# onto (or amending, even), and update/replace it accordingly with
	# new commit, becoming a new parent of upcoming new/updated merge
	# commit.
	#
	# Where possible, prefer taking ref over commit, making for a
	# prettier merge commit message.
	#
	local head_commit="$1"
	local onto_parent="$2"
	local onto_parent_old_commit="$3"
	local new_parent_commit="$4"
	local merge_parents=

	local onto_parent_new_commit="$(git rev-parse $onto_parent^0)"

	for parent_commit in $(git rev-parse $head_commit^@)
	do
		local merge_parent=

		if test "$parent_commit" = "$onto_parent_old_commit"
		then
			if test "$onto_parent_new_commit" = "$new_parent_commit"
			then
				# $onto_parent is a branch (updateable ref)
				merge_parent="$onto_parent"
			else
				merge_parent="$new_parent_commit"
			fi
		else
			parent_ref="$(git for-each-ref --points-at $parent_commit --count=1 --format="%(refname)")"
			if test -n "$parent_ref"
			then
				merge_parent="$parent_ref"
			else
				merge_parent="$parent_commit"
			fi
		fi

		# echo to flatten whitespace
		merge_parents="$(echo $merge_parents $merge_parent)"
	done

	if test -n "$merge_parents"
	then
		printf '%s\n' "$merge_parents"
		return 0
	else
		return 1
	fi
}

get_merge_message () {
	#
	# $@ merge_parents
	#
	# Provide to-be merge commit message using
	# existing `git fmt-merge-msg` machinery.
	#
	local merge_heads="$(get_merge_heads $@)" &&
	local merge_message="$(printf "$merge_heads" | git fmt-merge-msg)"

	if test -n "$merge_message"
	then
		printf '%s\n' "$merge_message"
		return 0
	else
		return 1
	fi
}

get_merge_heads () {
	#
	# $@ merge_parents
	#
	# Provide input for `git fmt-merge-msg` to get
	# nicely formatted merge commit message.
	#
	# Final result loosely mimics FETCH_HEAD file layout.
	#
	local merge_heads=
	local merge_head=

	#
	# Skip the first parent, as that is the original merge
	# destination, where we`re only interested in parents to be
	# merged into it.
	#
	shift

	for merge_parent in $@
	do
		local merge_parent_ref="$(git rev-parse --symbolic-full-name $merge_parent)"
		local merge_parent_commit="$(git rev-parse $merge_parent)"

		case "$merge_parent_ref" in
		refs/heads/*)
			merge_head="$merge_parent_commit\t\tbranch '${merge_parent_ref#refs/heads/}' of ."
			;;
		refs/tags/*)
			merge_head="$merge_parent_commit\t\ttag '${merge_parent_ref#refs/tags/}' of ."
			;;
		refs/remotes/*)
			merge_head="$merge_parent_commit\t\tremote-tracking branch '${merge_parent_ref#refs/remotes/}' of ."
			;;
		*)
			merge_head="$merge_parent_commit\t\t'$(git rev-parse --short $merge_parent_commit)' of ."
			;;
		esac

		merge_heads="$merge_heads$merge_head\n"
	done

	if test -n "$merge_heads"
	then
		# '\n' already appended
		printf '%s' "$merge_heads"
		return 0
	else
		return 1
	fi
}

reuse_authorship () {
	#
	# $1 commit to reuse authorship from
	#
	local commit="$1"

	GIT_AUTHOR_NAME="$(git show -s --format=%an $commit)"
	GIT_AUTHOR_EMAIL="$(git show -s --format=%ae $commit)"
	GIT_AUTHOR_DATE="$(git show -s --format=%at $commit)"

	export GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL GIT_AUTHOR_DATE
}

get_merge_parent_commits () {
	#
	# $@ merge_parents (might contain ref)
	#
	# Provide to-be merge commit parent parameters
	# in format suitable for `git commit-tree`.
	#
	local merge_parent_commits=

	for merge_parent in $@
	do
		merge_parent_commits="$(printf '%s\n' "$merge_parent_commits -p $(git rev-parse $merge_parent^0)")"
	done

	if test -n "$merge_parent_commits"
	then
		printf '%s\n' "$merge_parent_commits"
		return 0
	else
		return 1
	fi
}

get_checkout_reflog_message () {
	#
	# $1 destination ref or commit
	#
	local destination="$1"
	local source=

	source="$(git symbolic-ref --short --quiet HEAD)" ||
	source="$(git rev-parse HEAD^0)"

	printf '%s' "checkout: moving from $source to $destination"
}

main "$@"