All of lore.kernel.org
 help / color / mirror / Atom feed
From: Igor Djordjevic <igor.d.djordjevic@gmail.com>
To: git@vger.kernel.org
Cc: Johannes Sixt <j6t@kdbg.org>,
	Nikolay Shustov <nikolay.shustov@gmail.com>,
	Johannes Schneider <mailings@cedarsoft.com>,
	Patrik Gornicz <patrik-git@mail.pgornicz.com>,
	Martin Waitz <tali@admingilde.org>,
	Shawn Pearce <spearce@spearce.org>, Sam Vilain <sam@vilain.net>,
	Jakub Narebski <jnareb@gmail.com>
Subject: [SCRIPT/RFC 3/3] git-commit--onto-parent.sh
Date: Sun, 26 Nov 2017 23:45:10 +0100	[thread overview]
Message-ID: <7ea28777-1e68-09e3-5f39-4ca0291e3f36@gmail.com> (raw)
In-Reply-To: <8998e832-f49f-4de4-eb8d-a7934fba97b5@gmail.com>

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 "$@"

  parent reply	other threads:[~2017-11-26 23:01 UTC|newest]

Thread overview: 26+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2017-11-26 22:35 [SCRIPT/RFC 0/3] git-commit --onto-parent (three-way merge, no working tree file changes) Igor Djordjevic
2017-11-26 22:36 ` [SCRIPT/RFC 1/3] setup.sh Igor Djordjevic
2017-11-26 22:36 ` [SCRIPT/RFC 2/3] git-merge-one-file--cached Igor Djordjevic
2017-11-26 22:45 ` Igor Djordjevic [this message]
2017-11-27 21:54 ` [SCRIPT/RFC 0/3] git-commit --onto-parent (three-way merge, no working tree file changes) Johannes Sixt
2017-11-28  1:15   ` Igor Djordjevic
2017-11-29 19:11     ` Johannes Sixt
2017-11-29 23:10       ` Igor Djordjevic
2017-12-01 17:23         ` Johannes Sixt
2017-12-04  2:33           ` Igor Djordjevic
2017-12-06 18:34             ` Johannes Sixt
2017-12-06 18:40               ` Junio C Hamano
2017-12-08  0:15                 ` Igor Djordjevic
2017-12-08 16:24                   ` Junio C Hamano
2017-12-08 23:54                     ` Igor Djordjevic
2017-12-09  2:18                       ` Alexei Lozovsky
2017-12-09  3:03                         ` Igor Djordjevic
2017-12-09 19:00                           ` [SCRIPT/RFC 0/3] git-commit --onto-parent (three-way merge,noworking " Phillip Wood
2017-12-09 19:01                           ` [SCRIPT/RFC 0/3] git-commit --onto-parent (three-way merge, noworking " Phillip Wood
2017-12-10  1:20                             ` Igor Djordjevic
2017-12-10 12:22                               ` [SCRIPT/RFC 0/3] git-commit --onto-parent (three-way merge,noworking " Phillip Wood
2017-12-10 23:17                                 ` Igor Djordjevic
2017-12-11  1:13                                   ` Alexei Lozovsky
2017-12-11  1:00                                 ` Alexei Lozovsky
2017-11-30 22:40 ` [SCRIPT/RFC 0/3] git-commit --onto-parent (three-way merge, no working " Chris Nerwert
2017-12-03 23:01   ` Igor Djordjevic

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=7ea28777-1e68-09e3-5f39-4ca0291e3f36@gmail.com \
    --to=igor.d.djordjevic@gmail.com \
    --cc=git@vger.kernel.org \
    --cc=j6t@kdbg.org \
    --cc=jnareb@gmail.com \
    --cc=mailings@cedarsoft.com \
    --cc=nikolay.shustov@gmail.com \
    --cc=patrik-git@mail.pgornicz.com \
    --cc=sam@vilain.net \
    --cc=spearce@spearce.org \
    --cc=tali@admingilde.org \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.