git@vger.kernel.org mailing list mirror (one of many)
 help / color / mirror / code / Atom feed
* [PATCH 0/1] add git-splice subcommand for non-interactive branch splicing
@ 2017-07-31 21:18 Adam Spiers
  2017-07-31 21:18 ` [PATCH 1/1] add git-splice command " Adam Spiers
  2017-07-31 22:18 ` [PATCH 0/1] add git-splice subcommand " Junio C Hamano
  0 siblings, 2 replies; 4+ messages in thread
From: Adam Spiers @ 2017-07-31 21:18 UTC (permalink / raw)
  To: git list

This patch adds a new subcommand called git-splice, which facilitates
higher-level workflow operations in the area of branch management, for
example moving commits from one branch into another, or decomposing
large branches into smaller, independent branches, or vice-versa.

Motivation
----------

By now git is very mature, and excels at low-level plumbing and
porcelain operations.  However, developer workflows have become
increasingly sophisticated, partially as a result of CI / review
systems supporting development and testing of large numbers of
concurrent patches to the same repository.  These systems can track
dependencies / conflicts between those patches (gerrit) or group
series of related commmits into a single topic (GitLab merge requests;
GitHub pull requests).

This trend towards higher-level workflows has also driven innovations
in branch management tools (e.g. topgit, gitflow, gitwork,
git-series).  I expect this trend to continue, and git UIs to evolve
which make re-organising commits between branches almost as easy as
moving files around a filesystem with a file manager tool.

Of course, any git UI which aids the user in manipulating branches can
already automate the tasks using existing porcelain and plumbing.  But
this will typically require mid-level operations, such as:

  1) removing commits from a branch
  2) porting (copying) commits from one branch into another
  3) moving commits from one branch into another
  4) predicting when any of the above will cause conflicts
  5) decomposing large branches into smaller, independent branches
     in order to reduce conflicts

Performing these operations using existing porcelain is cumbersome,
generally involving manual (i.e. interactive) use of git rebase -i
etc.  However, automated higher-level workflows will need these
operations to be as *non*-interactive as possible.

Therefore there is a risk that each new UI for higher-level workflows
will end up re-implementing these mid-level operations.  This
undesirable situation could be avoided if git itself provided those
mid-level operations.

This is where git-splice comes in.  It handles operations 1) and 2),
and lays the foundation for git-transplant, another git subcommand I
have written which implements 3).  (See below for more info on this,
and on other tools related to 4) and 5)).

Description
-----------

git-splice(1) non-interactively splices the current branch by removing
a range of commits from within it and/or cherry-picking a range of
commits into it.

It's essentially a convenience wrapper around cherry-pick and
interactive rebase, but the workflow state is persisted to disk, and
thereby supports standard --abort and --continue semantics just like
git's other extended workflow commands.  It also handles more complex
cases, as briefly demonstrated by the examples below.

(See git-splice.txt in the patch for full detail.)

Example usage
-------------

    # Remove commits A..B (i.e. excluding A) from the current branch.
    git splice A..B

    # Remove commit A from the current branch.
    git splice A^!

    # Remove commits A..B from the current branch, and cherry-pick
    # commits C..D at the same point.
    git splice A..B C..D

    # Cherry-pick commits C..D, splicing them in just after commit A.
    git splice A C..D

    # Remove all commits since 11am this morning mentioning "foo".
    git splice --since=11am --grep="foo" --

    # Remove commit A and all its ancestors (including the root commit)
    # from the current branch.
    git splice --root A

    # Abort a splice which failed during cherry-pick or rebase.
    git splice --abort

    # Resume a splice after manually fixing conflicts caused by
    # cherry-pick or rebase.
    git splice --continue

Remaining work
--------------

The code does not yet conform 100% to Documentation/CodingGuidelines.
The only known areas of non-conformance are:

    1. It relies on bash arrays, which is a non-POSIX feature.

    2. It does not support i18n.

I would be more than happy to fix these if there is a chance of
git-splice being accepted for inclusion within the git distribution.

I appreciate that adding a new subcommand to git automatically brings
concerns about the increase in maintenance burden.  ICBW but I would
expect git-splice to add significantly less burden than the last
subcommand I wrote (check-ignore), because it only relies on very
mainstream porcelain commands and one plumbing command (update-ref).
OTOH, the test suite makes very heavy use of git's test framework, so
separating it out into a separate tree would presumably be
non-trivial.

Previous and recent work
------------------------

I first announced git-splice just over a year ago:

    https://www.spinics.net/lists/git/msg277346.html
    https://public-inbox.org/git/20160527140811.GB11256@pacific.linksys.moosehall/

I have just fixed all known remaining issues and further beefed up the
test suite, so I think it's now ready for serious consideration.
(Previously I merely provided a URL to a branch, but did not actually
submit a patch.)

Related tools
-------------

I have also implemented git-transplant as a further patch on top of
this one:

    https://github.com/aspiers/git/compare/splice...aspiers:transplant

and other tools which provide additional help with moving/copying
commits from one branch to another, and with predicting and avoiding
any conflicts which could arise.  They are out of scope for this post,
so I will just give links for anyone who is interested:

    https://blog.adamspiers.org/2015/01/19/git-deps/
    https://blog.adamspiers.org/2013/09/19/easier-upstreaming-with-git/

All feedback is of course very welcome!

Thanks,
Adam

Adam Spiers (1):
  add git-splice command for non-interactive branch splicing

 .gitignore                   |   1 +-
 Documentation/git-splice.txt | 125 ++++++-
 Makefile                     |   1 +-
 git-splice.sh                | 737 ++++++++++++++++++++++++++++++++++++-
 t/t7900-splice.sh            | 630 +++++++++++++++++++++++++++++++-
 5 files changed, 1494 insertions(+)
 create mode 100644 Documentation/git-splice.txt
 create mode 100755 git-splice.sh
 create mode 100755 t/t7900-splice.sh

base-commit: 5800c63717ae35286a1441f14ffff753e01f7e2b
-- 
git-series 0.9.1

^ permalink raw reply	[flat|nested] 4+ messages in thread

* [PATCH 1/1] add git-splice command for non-interactive branch splicing
  2017-07-31 21:18 [PATCH 0/1] add git-splice subcommand for non-interactive branch splicing Adam Spiers
@ 2017-07-31 21:18 ` Adam Spiers
  2017-07-31 22:18 ` [PATCH 0/1] add git-splice subcommand " Junio C Hamano
  1 sibling, 0 replies; 4+ messages in thread
From: Adam Spiers @ 2017-07-31 21:18 UTC (permalink / raw)
  To: git list

Add a new subcommand git-splice(1) which non-interactively splices the
current branch by removing a range of commits from within it and/or
cherry-picking a range of commits into it.

It's essentially a convenience wrapper around cherry-pick and
interactive rebase, but the workflow state is persisted to disk, and
thereby supports standard --abort and --continue semantics just like
git's other extended workflow commands.  It also handles more complex
cases, as described in the manual page.

Signed-off-by: Adam Spiers <git@adamspiers.org>
---
 .gitignore                   |   1 +-
 Documentation/git-splice.txt | 125 ++++++-
 Makefile                     |   1 +-
 git-splice.sh                | 737 ++++++++++++++++++++++++++++++++++++-
 t/t7900-splice.sh            | 630 +++++++++++++++++++++++++++++++-
 5 files changed, 1494 insertions(+)
 create mode 100644 Documentation/git-splice.txt
 create mode 100755 git-splice.sh
 create mode 100755 t/t7900-splice.sh

diff --git a/.gitignore b/.gitignore
index 833ef3b..4062009 100644
--- a/.gitignore
+++ b/.gitignore
@@ -150,6 +150,7 @@
 /git-show-branch
 /git-show-index
 /git-show-ref
+/git-splice
 /git-stage
 /git-stash
 /git-status
diff --git a/Documentation/git-splice.txt b/Documentation/git-splice.txt
new file mode 100644
index 0000000..29f3ac8
--- /dev/null
+++ b/Documentation/git-splice.txt
@@ -0,0 +1,125 @@
+git-splice(1)
+=============
+
+NAME
+----
+git-splice - Splice commits into/out of current branch
+
+SYNOPSIS
+--------
+[verse]
+'git splice' <insertion point> <cherry pick range>
+'git splice' <insertion point> \-- <cherry pick range args ...>
+'git splice' [-r|--root] <remove range> [<cherry pick range>]
+'git splice' [-r|--root] <remove range args ...> \-- [<cherry pick range args ...>]
+'git splice' (--abort | --continue | --in-progress)
+
+DESCRIPTION
+-----------
+Non-interactively splice branch by removing a range of commits from
+within the current branch, and/or cherry-picking a range of commits
+into the current branch.
+
+<remove range> specifies the range of commits to remove from the
+current branch, and <cherry-pick-range> specifies the range to insert
+at the point where <remove-range> previously existed, or just after
+<insertion-point>.
+
+<insertion point> is a commit-ish in the standard format accepted
+by linkgit:git-rev-parse[1].
+
+<remove range> and <cherry pick range> are single shell words
+specifying commit ranges in the standard format accepted by
+linkgit:git-rev-list[1], e.g.
+
+    A..B
+    A...B
+    A^!   (just commit A)
+
+It is possible to pass multi-word specifications for both the removal
+and insertion ranges, in which case they are passed to
+linkgit:git-rev-list[1] to calculate the commits to remove or
+cherry-pick.  For this you need to terminate <remove range args> with
+`--` to indicate that the multi-word form of parameters is being used.
+
+When the `--root` option is present, a removal range can be specified
+as a commit-ish in the standard format accepted by
+linkgit:git-rev-parse[1], in which case the commit-ish is treated as a
+range.  This makes it possible to remove or replace root
+(i.e. parentless) commits.
+
+Currently git-splice assumes that all commits being operated on have a
+single parent; removal and insertion of merge commits is not supported.
+
+N.B. Obviously this command rewrites history!  As with
+linkgit:git-rebase[1], you should be aware of all the implications of
+history rewriting before using it.  (And actually this command is just
+a glorified wrapper around linkgit:git-cherry-pick[1] and
+linkgit:git-rebase[1] in interactive mode.)
+
+OPTIONS
+-------
+
+-r::
+--root::
+	Treat (each) removal range argument as a commit-ish, and
+	remove all its ancestors.
+
+--abort::
+	Abort an in-progress splice.
+
+--continue::
+	Resume an in-progress splice.
+
+--in-progress::
+	Exit 0 if and only if a splice is in progress.
+
+EXAMPLES
+--------
+
+`git splice A..B`::
+
+	Remove commits A..B (i.e. excluding A) from the current branch.
+
+`git splice A^!`::
+
+	Remove commit A from the current branch.
+
+`git splice --root A`::
+
+	Remove commit A and all its ancestors (including the root commit)
+	from the current branch.
+
+`git splice A..B C..D`::
+
+	Remove commits A..B from the current branch, and cherry-pick
+	commits C..D at the same point.
+
+`git splice A C..D`::
+
+	Cherry-pick commits C..D, splicing them in just after commit A.
+
+`git splice --since=11am --grep="foo" --`::
+
+	Remove all commits since 11am this morning mentioning "foo".
+
+`git splice --abort`::
+
+	Abort a splice which failed during cherry-pick or rebase.
+
+`git splice --continue`::
+
+	Resume a splice after manually fixing conflicts caused by
+	cherry-pick or rebase.
+
+`git splice --in-progress && git splice --abort`::
+
+	Abort if there is a splice in progress.
+
+SEE ALSO
+--------
+linkgit:git-rebase[1], linkgit:git-cherry-pick[1]
+
+GIT
+---
+Part of the linkgit:git[1] suite
diff --git a/Makefile b/Makefile
index 461c845..eeaabc2 100644
--- a/Makefile
+++ b/Makefile
@@ -547,6 +547,7 @@ SCRIPT_SH += git-quiltimport.sh
 SCRIPT_SH += git-rebase.sh
 SCRIPT_SH += git-remote-testgit.sh
 SCRIPT_SH += git-request-pull.sh
+SCRIPT_SH += git-splice.sh
 SCRIPT_SH += git-stash.sh
 SCRIPT_SH += git-submodule.sh
 SCRIPT_SH += git-web--browse.sh
diff --git a/git-splice.sh b/git-splice.sh
new file mode 100755
index 0000000..e4f3e53
--- /dev/null
+++ b/git-splice.sh
@@ -0,0 +1,737 @@
+#!/bin/bash
+#
+# git-splice - splice commits into/out of current branch
+# Copyright (c) 2016 Adam Spiers
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+#
+# ---------------------------------------------------------------------
+#
+
+dashless=$(basename "$0" | sed -e 's/-/ /')
+USAGE="<insertion point> <cherry pick range>
+   or: $dashless <insertion point> -- <cherry pick range args ...>
+   or: $dashless [-r|--root] <remove range> [<cherry pick range>]
+   or: $dashless [-r|--root] <remove range args> ... -- <cherry pick range args ...>
+   or: $dashless (--abort | --continue | --in-progress)"
+LONG_USAGE=\
+'    -h, --help     Show this help and exit
+     -r, root      Treat (each) removal range argument as a commit-ish, and
+                   remove all its ancestors.
+    --abort        Abort an in-progress splice
+    --continue     Continue an in-progress splice
+    --in-progress  Exit 0 if and only if a splice is in progress'
+
+OPTIONS_SPEC=
+. git-sh-setup
+
+export PS4="+\${BASH_SOURCE/\$HOME/\~}@\${LINENO}(\${FUNCNAME[0]}): "
+
+me=$(basename $0)
+git_dir=$(git rev-parse --git-dir) || exit 1
+splice_dir="$git_dir/splice"
+base_file="$splice_dir/base"
+branch_file="$splice_dir/branch"
+insert_todo="$splice_dir/insert-todo"
+remove_todo="$splice_dir/remove-todo"
+rebase_exit="$splice_dir/rebase-exit"
+rebase_cancelled="$splice_dir/rebase-cancelled"
+TMP_BRANCH="tmp/splice"
+
+main () {
+	parse_opts "$@"
+
+	if test -n "$in_progress"
+	then
+		if in_progress
+		then
+			echo "Splice in progress: $reason"
+			exit 0
+		else
+			echo "Splice not in progress"
+			exit 1
+		fi
+	fi
+
+	if test -n "$abort" || test -n "$continue" || test -n "$rebase_edit"
+	then
+		ensure_splice_in_progress
+	else
+		# Needs to happen before parse_args(), otherwise the in-flight
+		# files will already exist.
+		ensure_splice_not_in_progress
+	fi
+
+	parse_args "${ARGV[@]}"
+
+	if test -n "$rebase_edit"
+	then
+		# We're being invoked by git rebase as the rebase todo list editor,
+		# rather than by the user.  This mode is for internal use only.
+		rebase_edit
+		return
+	fi
+
+	if test -n "$abort"
+	then
+		splice_abort
+		return
+	fi
+
+	# Handle both normal execution and --continue
+	splice
+}
+
+prepare_tmp_branch () {
+	if valid_ref "$TMP_BRANCH"
+	then
+		if test -z "$continue"
+		then
+			die "BUG: $TMP_BRANCH exists but no --continue"
+		fi
+
+		if ! on_tmp_branch
+		then
+			: "Presumably on a detached head in the middle of a rebase"
+		fi
+	else
+		if removing_root
+		then
+			echo git checkout -q --orphan "$TMP_BRANCH"
+			git checkout -q --orphan "$TMP_BRANCH"
+			git reset --hard
+		else
+			echo git checkout -q -B "$TMP_BRANCH" "$base"
+			git checkout -q -B "$TMP_BRANCH" "$base"
+		fi
+	fi
+}
+
+do_cherry_picks () {
+	if cherry_pick_active
+	then
+		if ! git cherry-pick --continue
+		then
+			error_and_pause "git cherry-pick --continue failed!"
+		fi
+	else
+		reason="cat $insert_todo | xargs git cherry-pick"
+		if ! cat $insert_todo | xargs -t git cherry-pick
+		then
+			error_and_pause "git cherry-pick failed!"
+		fi
+		rm "$insert_todo"
+	fi
+}
+
+do_rebase () {
+	if rebase_active
+	then
+		args=( --continue )
+	elif removing_root
+	then
+		args=( -i --root "$branch" )
+	else
+		args=( -i --onto "$TMP_BRANCH" "$base" "$branch" )
+	fi
+
+	# We make git rebase -i use a special internal-only invocation of
+	# git-splice which non-interactively edits the temporary
+	# $rebase_todo file.
+	export GIT_SEQUENCE_EDITOR="$0 $debug --rebase-edit"
+
+	echo git rebase "${args[@]}"
+	# git rebase can output messages on STDOUT or STDERR depending
+	# on whether verbose is enabled.  Either way we want to catch
+	# references to "git rebase --continue" / "git rebase --abort"
+	# and tweak them to refer to git splice instead.
+	#
+	# To achieve that, we filter both STDOUT and STDERR through pipes,
+	# using a clever technique explained here:
+	# http://wiki.bash-hackers.org/howto/redirection_tutorial
+	rm -f "$rebase_exit"
+	{
+		{
+			{
+				git rebase "${args[@]}" 3>&-;
+				echo $? >"$rebase_exit"
+			} |
+				tweak_rebase_error 2>&3 3>&-
+		} 2>&1 >&4 4>&- |
+			tweak_rebase_error 3>&- 4>&-
+	} 3>&2 4>&1
+	rebase_exitcode="$(cat $rebase_exit)"
+	rm -f "$rebase_exit"
+	if test "$rebase_exitcode" != 0
+	then
+		if test -e "$rebase_cancelled"
+		then
+			: "happens if there were no commits (left) to rebase"
+			git reset --hard "$TMP_BRANCH"
+			rm "$rebase_cancelled"
+		else
+			error_and_pause "git rebase ${args[*]} failed!"
+		fi
+	fi
+}
+
+splice () {
+	base="$(cat $base_file)"
+	branch="$(cat $branch_file)"
+
+	validate_base
+
+	if removing_root
+	then
+		if test -s "$insert_todo"
+		then
+			# If we're creating a new root commit, it will either come
+			# by cherry-picking onto a new orphaned $TMP_BRANCH, if we
+			# have any cherry-picking to do:
+			prepare_tmp_branch
+		else
+			# or it will come via rebase --root, in which case we don't
+			# need a temporary branch.
+			no_tmp_branch=y
+		fi
+	else
+		prepare_tmp_branch
+	fi
+
+	if test -s "$insert_todo"
+	then
+		do_cherry_picks
+	fi
+
+	if ! removing_root && test "$base" = "$branch"
+	then
+		echo git checkout -B "$branch" "$TMP_BRANCH"
+		git checkout -B "$branch" "$TMP_BRANCH"
+	else
+		do_rebase
+	fi
+
+	if test -z "$no_tmp_branch"
+	then
+		git branch -d "$TMP_BRANCH"
+	fi
+	rm -rf "$splice_dir"
+}
+
+tweak_rebase_error () {
+	grep -v 'When you have resolved this problem, run "git rebase --continue"\.' |
+		sed -e 's/git rebase \(--continue\|--abort\)/git splice \1/g'
+}
+
+valid_ref () {
+	git rev-parse --quiet --verify "$@" >/dev/null
+}
+
+# Returns true (0) iff the arguments passed explicitly describe a
+# range of commits (e.g. A..B).  Note that this deliberately returns
+# false when fed a single commit-ish A, even though a commit-ish
+# technically describes a range covering A and all its ancestors.
+# This is used to infer whether the user intended this commit to be
+# interpreted as an insertion point or a removal range, when it is not
+# made clear by the use of --root or a particular combination of
+# arguments on ARGV.
+valid_commit_range () {
+	if ! parsed=( $(git rev-parse "$@" 2>/dev/null) )
+	then
+		cleanup
+		fatal "Failed to parse commit range $1"
+	fi
+	test "${#parsed[@]}" -gt 1
+}
+
+cherry_pick_active () {
+	# Ideally git rebase would have some plumbing for this, so
+	# we wouldn't have to assume knowledge of internals.
+	valid_ref CHERRY_PICK_HEAD
+}
+
+rebase_active () {
+	# Ideally git rebase would have some plumbing for this, so
+	# we wouldn't have to assume knowledge of internals.  See:
+	# http://stackoverflow.com/questions/3921409/how-to-know-if-there-is-a-git-rebase-in-progress
+	test -e "$git_dir/rebase-merge" ||
+		test -e "$git_dir/rebase-apply"
+}
+
+removing_root () {
+	test "$base" = 'ROOT'
+}
+
+validate_base () {
+	if test -z "$base"
+	then
+		die "BUG: base should not be empty"
+	fi
+
+	if removing_root
+	then
+		: "We're removing the root commit"
+		return
+	fi
+
+	if ! valid_ref "$base"
+	then
+		cleanup
+		die "BUG: base commit $base was not valid"
+	fi
+}
+
+error_and_pause () {
+	warn "$*"
+	warn "When you have resolved this problem, run \"git splice --continue\","
+	warn "or run \"git splice --abort\" to abandon the splice."
+	exit 1
+}
+
+in_progress () {
+	if test -e "$insert_todo"
+	then
+		reason="$insert_todo exists"
+		return 0
+	fi
+
+	if test -e "$remove_todo"
+	then
+		reason="$remove_todo exists"
+		return 0
+	fi
+
+	if test -d "$splice_dir"
+	then
+		reason="$splice_dir exists"
+		return 0
+	fi
+
+	if on_tmp_branch
+	then
+		reason="on $TMP_BRANCH branch"
+		return 0
+	fi
+
+	reason=
+	return 1
+}
+
+cleanup () {
+	aborted=
+
+	if test -e "$insert_todo"
+	then
+		# Can we be sure that the in-flight cherry-pick was started by
+		# git splice?  Probably, because otherwise
+		# ensure_cherry_pick_not_in_progress should have prevented us
+		# from reaching this point in the code.
+		if cherry_pick_active
+		then
+			git cherry-pick --abort
+		fi
+
+		rm "$insert_todo"
+		aborted=y
+	fi
+
+	if test -e "$remove_todo"
+	then
+		if rebase_active
+		then
+			git rebase --abort
+		fi
+
+		rm "$remove_todo"
+		aborted=y
+	fi
+
+	if valid_ref "$TMP_BRANCH"
+	then
+		if on_tmp_branch
+		then
+			git checkout "$(cat $branch_file)"
+		fi
+
+		git branch -d "$TMP_BRANCH"
+		aborted=y
+	fi
+
+	if test -d "$splice_dir"
+	then
+		rm -rf "$splice_dir"
+		aborted=y
+	fi
+}
+
+splice_abort () {
+	cleanup
+
+	if test -z "$aborted"
+	then
+		fatal "No splice in progress"
+	fi
+}
+
+head_ref () {
+	git symbolic-ref --short -q HEAD
+}
+
+on_branch () {
+	[ "$(head_ref)" = "$1" ]
+}
+
+on_tmp_branch () {
+	on_branch "$TMP_BRANCH"
+}
+
+ensure_splice_in_progress () {
+	if ! in_progress
+	then
+		fatal "Splice not in progress"
+	fi
+}
+
+ensure_splice_not_in_progress () {
+	for file in "$insert_todo" "$remove_todo"
+	do
+		if test -e "$file"
+		then
+			in_progress_error "$file already exists."
+		fi
+	done
+
+	ensure_cherry_pick_not_in_progress
+	ensure_rebase_not_in_progress
+
+	if on_tmp_branch
+	then
+		fatal "On $TMP_BRANCH branch, but no splice in progress."\
+		    "Try switching to another branch first."
+	fi
+
+	if valid_ref "$TMP_BRANCH"
+	then
+		fatal "$TMP_BRANCH branch exists, but no splice in"\
+		    "progress. Try deleting $TMP_BRANCH first."
+	fi
+}
+
+in_progress_error () {
+	cat <<EOF >&2
+$*
+
+git splice already in progress; please complete it, or run
+
+  git splice --abort
+EOF
+	exit 1
+}
+
+ensure_cherry_pick_not_in_progress () {
+	if cherry_pick_active
+	then
+		fatal "Can't start git splice when there is a"\
+		      "cherry-pick in progress"
+	fi
+}
+
+ensure_rebase_not_in_progress () {
+	if rebase_active
+	then
+		warn "Can't start git splice when there is a rebase in progress."
+
+		# We know this will fail; we run it because we want to output
+		# the same error message which git-rebase uses to tell the user
+		# to finish or abort their in-flight rebase.
+		git rebase
+		exit 1
+	fi
+}
+
+rebase_edit () {
+	if ! test -e "$rebase_todo"
+	then
+		die "BUG: $me invoked in rebase edit mode,"\
+		    "but $rebase_todo was missing"
+	fi
+
+	if test -e "$remove_todo"
+	then
+		sed -i 's/^\([0-9a-f]\+\)$/^pick \1/' "$remove_todo"
+		grep -v -f "$remove_todo" "$rebase_todo" >"$rebase_todo".new
+		if test -n "$debug"
+		then
+			set +x
+			echo -e "-----------------------------------"
+			echo "$rebase_todo"
+			cat "$rebase_todo"
+			echo -e "-----------------------------------"
+			echo "$remove_todo"
+			cat "$remove_todo"
+			echo -e "-----------------------------------"
+			echo "$rebase_todo.new"
+			cat "$rebase_todo.new"
+			set -x
+		fi
+		mv "$rebase_todo".new "$rebase_todo"
+	fi
+
+	if ! grep '^ *[a-z]' "$rebase_todo"
+	then
+		echo "Nothing left to rebase; cancelling."
+		>"$rebase_todo"
+		touch "$rebase_cancelled"
+	fi
+}
+
+warn () {
+	echo >&2 "$*"
+}
+
+fatal () {
+	die "fatal: $*"
+}
+
+parse_opts () {
+	ORIG_ARGV=( "$@" )
+	while test $# != 0
+	do
+		case "$1" in
+		-h|--help)
+			usage
+			;;
+		-v|--version)
+			echo "$me $VERSION"
+			;;
+		-d|--debug)
+			debug=--debug
+			echo >&2 "#-------------------------------------------------"
+			echo >&2 "# Invocation: $0 ${ORIG_ARGV[@]}"
+			set -x
+
+			shift
+			;;
+		--continue)
+			continue=yes
+			shift
+			;;
+		--abort)
+			abort=yes
+			shift
+			;;
+		--in-progress)
+			in_progress=yes
+			shift
+			;;
+		-r|--root)
+			root=yes
+			shift
+			;;
+		# for internal use only
+		--rebase-edit)
+			rebase_edit=yes
+			rebase_todo="$2"
+			shift 2
+			;;
+		*)
+			break
+			;;
+		esac
+	done
+
+	if echo "$continue$abort$in_progress" | grep -q yesyes
+	then
+		fatal "You must only select one of --abort, --continue,"\
+		    "and --in-progress."
+	fi
+
+	ARGV=( "$@" )
+}
+
+detect_remove_range_or_insertion_point () {
+	# Figure out whether the first parameter is a remove range
+	# or insertion point.
+	if test -z "$root"
+	then
+		if valid_commit_range "$@"
+		then
+			: "$1 must be a removal range"
+			remove_range=( "$@" )
+		else
+			: "$* must be an insertion point"
+			insertion_point="$@"
+		fi
+	else
+		# The user has explicitly requested a removal of the
+		# commit-ish and all its ancestors.
+		remove_range=( "$@" )
+	fi
+}
+
+parse_args () {
+	if test -n "$abort" || test -n "$continue" ||
+			test -n "$in_progress" || test -n "$rebase_edit"
+	then
+		return
+	fi
+
+	count=$#
+	for word in "$@"
+	do
+		if test "$word" = '--'
+		then
+			multi_word=yes
+			count=$(( $count - 1 ))
+			break
+		fi
+	done
+
+	if test $count -eq 0
+	then
+		fatal "You must specify at least one range to splice."
+	fi
+
+	if test -z "$multi_word"
+	then
+		# No "--" argument present, so the number of arguments is significant.
+		if test $# -eq 1
+		then
+			if test -z "$root"
+			then
+				# In this invocation form, $1 must be a removal range,
+				# because nothing has been given to cherry-pick.
+				if ! valid_commit_range "$1"
+				then
+					fatal "$1 is not a valid removal range"
+				fi
+			else
+				# The user has explicitly requested a removal of the
+				# commit-ish and all its ancestors.
+				if ! valid_ref "$1"
+				then
+					fatal "$1 is not a valid removal commit-ish"
+				fi
+			fi
+			remove_range=( "$1" )
+		elif test $# -eq 2
+		then
+			insert_range=( "$2" )
+			detect_remove_range_or_insertion_point "$1"
+		elif test $# -ge 2
+		then
+			fatal "Use of multiple words in the removal or insertion"\
+			    "ranges requires the -- separator"
+		fi
+	else
+		# "--" argument is present, so split
+		remove_range_or_insertion_base=()
+		for word in "$@"
+		do
+			if test "$word" = '--'
+			then
+				shift
+				insert_range=( "$@" )
+				break
+			fi
+			remove_range_or_insertion_base+=( "$word" )
+			shift
+		done
+
+		detect_remove_range_or_insertion_point \
+			"${remove_range_or_insertion_base[@]}"
+	fi
+
+	mkdir -p "$splice_dir"
+
+	if ! head_ref >"$branch_file"
+	then
+		rm "$branch_file"
+		fatal "Cannot run $me on detached head"
+	fi
+
+	if [ "${#remove_range[@]}" -gt 0 ]
+	then
+		# In this case we already know it's a range
+		: "removing range ${remove_range[@]}"
+		check_no_merge_commits "Removing" "${remove_range[@]}"
+		populate_remove_todo "${remove_range[@]}"
+		populate_base_file "${remove_range[@]}"
+	elif test -n "$insertion_point"
+	then
+		echo "$insertion_point" >"$base_file"
+	else
+		die "BUG: didn't get removal range or insertion point"
+	fi
+
+	if [ "${#insert_range[@]}" -gt 0 ]
+	then
+		if ! valid_commit_range "${insert_range[@]}"
+		then
+			cleanup
+			fatal "Failed to parse ${insert_range[*]} as insertion range"
+		fi
+
+		check_no_merge_commits "Inserting" "${insert_range[@]}"
+
+		if [ "${#insert_range[@]}" -eq 1 ]
+		then
+			echo "${insert_range[@]}" >"$insert_todo"
+		else
+			git rev-list --reverse "${insert_range[@]}" >"$insert_todo"
+		fi
+	fi
+}
+
+check_no_merge_commits () {
+	action="$1"
+	shift
+	if git rev-list --min-parents=2 -n1 "$@" | grep -q .
+	then
+		cleanup
+		fatal "$action merge commits is not supported"
+	fi
+}
+
+populate_remove_todo () {
+	git rev-list --abbrev-commit "$@" >"$remove_todo"
+	if ! test -s "$remove_todo"
+	then
+		cleanup
+		fatal "No commits found in removal range $*"
+	fi
+	newest=$(head -n1 "$remove_todo")
+	newest=$(git rev-parse "$newest") # unabbreviate for comparison below
+	head=$(head_ref)
+	mb=$(git merge-base "$newest" "$head")
+	if test "$mb" != "$newest"
+	then
+		cleanup
+		fatal "$newest is in removal range but not in $head branch"
+	fi
+}
+
+populate_base_file () {
+	earliest=$(tail -n1 "$remove_todo")
+	echo "Earliest commit in $@ is $earliest"
+	if git rev-list --min-parents=1 -n1 "${earliest}" | grep -q .
+	then
+		# Earliest in removal range has a parent
+		echo "${earliest}^" >"$base_file"
+	else
+		echo "ROOT" >"$base_file"
+	fi
+}
+
+main "$@"
diff --git a/t/t7900-splice.sh b/t/t7900-splice.sh
new file mode 100755
index 0000000..5654309
--- /dev/null
+++ b/t/t7900-splice.sh
@@ -0,0 +1,630 @@
+#!/bin/sh
+#
+# Copyright (c) 2016 Adam Spiers
+#
+
+test_description='git splice
+
+This tests all features of git-splice.
+'
+
+. ./test-lib.sh
+
+TMP_BRANCH=tmp/splice
+
+#############################################################################
+# Setup
+
+for i in one two three
+do
+	for j in a b
+	do
+		tag=$i-$j
+		test_expect_success "setup $i" "
+			echo $i $j >> $i &&
+			git add $i &&
+			git commit -m \"$i $j\" &&
+			git tag $tag"
+	done
+done
+git_dir=`git rev-parse --git-dir`
+latest_tag=$tag
+
+setup_other_branch () {
+	branch="$1" base="$2"
+	shift 2
+	git checkout -b $branch $base &&
+	for i in "$@"
+	do
+		echo $branch $i >> $branch &&
+		git add $branch &&
+		git commit -m "$branch $i" &&
+		git tag "$branch-$i"
+	done
+}
+
+test_expect_success "setup four branch" '
+	setup_other_branch four one-b a b c &&
+	git checkout master
+'
+
+test_debug 'git show-ref'
+
+del_tmp_branch () {
+	git update-ref -d refs/heads/$TMP_BRANCH
+}
+
+reset () {
+	# First check that tests don't leave a splice in progress,
+	# as they should always do --abort or --continue if necessary
+	test_splice_not_in_progress &&
+	on_branch master &&
+	git reset --hard $latest_tag &&
+	del_tmp_branch &&
+	rm -f stdout stderr
+}
+
+git_splice () {
+	git splice ${debug:+-d} "$@" >stdout 2>stderr
+	ret=$?
+	set +x
+	if [ -s stdout ]
+	then
+		echo "====== STDOUT from git splice $* ======"
+	fi
+	cat stdout
+	if [ -s stderr ]
+	then
+		echo "------ STDERR from git splice $* ------"
+		cat stderr
+	fi
+	echo "====== exit $ret from git splice $* ======"
+	if test -n "$trace"
+	then
+		set -x
+	fi
+	return $ret
+}
+
+test_splice_in_progress () {
+	git splice --in-progress
+}
+
+head_ref () {
+	git symbolic-ref --short -q HEAD
+}
+
+on_branch () {
+	if test "`head_ref`" = "$1"
+	then
+		return 0
+	else
+		echo "not on $1 branch" >&2
+		return 1
+	fi
+}
+
+test_splice_not_in_progress () {
+	test_must_fail test_splice_in_progress &&
+	test_must_fail git_splice --continue &&
+		grep -q "Splice not in progress" stderr &&
+		test_debug 'echo "--continue failed as expected - good"' &&
+	test_must_fail git_splice --abort    &&
+		grep -q "Splice not in progress" stderr &&
+		test_debug 'echo "--abort failed as expected - good"'
+}
+
+#############################################################################
+# Invalid arguments
+
+test_expect_success 'empty command line' '
+	test_must_fail git_splice &&
+	grep "You must specify at least one range to splice" stderr
+'
+
+test_expect_success 'too many arguments' '
+	test_must_fail git_splice a b c &&
+	grep "Use of multiple words in the removal or insertion ranges requires the -- separator" stderr
+'
+
+test_only_one_option () {
+	test_splice_not_in_progress &&
+	test_must_fail git_splice "$@" &&
+	grep "You must only select one of --abort, --continue, and --in-progress" stderr &&
+	test_splice_not_in_progress
+}
+
+for combo in \
+	'--abort --continue' \
+	'--continue --abort' \
+	'--abort --in-progress' \
+	'--in-progress --abort' \
+	'--continue --in-progress' \
+	'--in-progress --continue'
+do
+	test_expect_success "$combo" "
+		test_only_one_option $combo
+	"
+done
+
+test_expect_success 'insertion point without insertion range' '
+	test_must_fail git_splice one &&
+	grep "fatal: one is not a valid removal range" stderr &&
+	test_splice_not_in_progress
+'
+
+test_failed_to_parse_removal_spec () {
+	test_must_fail git_splice "$@" &&
+	grep "fatal: Failed to parse commit range $*" stderr &&
+	test_splice_not_in_progress
+}
+
+test_expect_success 'remove invalid single commit' '
+	test_failed_to_parse_removal_spec five
+'
+
+test_expect_success 'remove range with invalid start' '
+	test_failed_to_parse_removal_spec five..two-b
+'
+
+test_expect_success 'remove range with invalid end' '
+	test_failed_to_parse_removal_spec two-b..five
+'
+
+test_expect_success 'empty removal range' '
+	test_must_fail git_splice two-a..two-a &&
+	grep "^fatal: No commits found in removal range two-a..two-a" stderr &&
+	test_splice_not_in_progress
+'
+
+#############################################################################
+# Invalid initial state
+
+test_expect_success "checkout $TMP_BRANCH; ensure splice won't start" "
+	test_when_finished 'git checkout master; del_tmp_branch' &&
+	reset &&
+	git checkout -b $TMP_BRANCH &&
+	test_must_fail git_splice two-b^! &&
+	grep 'fatal: On $TMP_BRANCH branch, but no splice in progress' stderr &&
+	git checkout master &&
+	del_tmp_branch &&
+	test_splice_not_in_progress
+"
+
+test_expect_success "create $TMP_BRANCH; ensure splice won't start" "
+	test_when_finished 'del_tmp_branch' &&
+	reset &&
+	git branch $TMP_BRANCH master &&
+	test_must_fail git_splice two-b^! &&
+	grep '$TMP_BRANCH branch exists, but no splice in progress' stderr &&
+	del_tmp_branch &&
+	test_splice_not_in_progress
+"
+
+test_expect_success "start cherry-pick with conflicts; ensure splice won't start" '
+	test_when_finished "git cherry-pick --abort" &&
+	reset &&
+	test_must_fail git cherry-pick four-b >stdout 2>stderr &&
+	grep "error: could not apply .* four b" stderr &&
+	test_must_fail git_splice two-b^! &&
+	grep "Can'\''t start git splice when there is a cherry-pick in progress" stderr &&
+	test_splice_not_in_progress
+'
+
+test_expect_success "start rebase with conflicts; ensure splice won't start" '
+	test_when_finished "git rebase --abort" &&
+	reset &&
+	test_must_fail git rebase --onto one-b two-a >stdout 2>stderr &&
+	grep "CONFLICT" stdout &&
+	grep "Failed to merge in the changes" stderr &&
+	test_must_fail git_splice two-b^! &&
+	grep "Can'\''t start git splice when there is a rebase in progress" stderr &&
+	test_splice_not_in_progress
+'
+
+test_expect_success 'cause conflict; ensure not re-entrant' '
+	test_when_finished "
+		git_splice --abort &&
+		test_splice_not_in_progress
+	" &&
+	reset &&
+	test_must_fail git_splice two-a^! &&
+	test_splice_in_progress &&
+	test_must_fail git_splice two-a^! &&
+	grep "git splice already in progress; please complete it, or run" stderr &&
+	grep "git splice --abort" stderr &&
+	test_splice_in_progress
+'
+
+#############################################################################
+# Removing a single commit
+
+test_remove_two_b () {
+	reset &&
+	git_splice two-b^! "$@" &&
+	grep "one b"   one   &&
+	grep "three b" three &&
+	grep "two a"   two   &&
+	! grep "two b" two   &&
+	test_splice_not_in_progress
+}
+
+test_expect_success 'remove single commit' '
+	test_remove_two_b
+'
+
+test_expect_success 'remove single commit with --' '
+	test_remove_two_b --
+'
+
+test_expect_success 'remove single commit causing conflict; abort' '
+	reset &&
+	test_must_fail git_splice two-a^! &&
+	grep "Could not apply .* two b" stdout stderr &&
+	grep "When you have resolved this problem, run \"git splice --continue\"" stdout stderr &&
+	grep "or run \"git splice --abort\"" stdout stderr &&
+	test_splice_in_progress &&
+	git_splice --abort &&
+	test_splice_not_in_progress
+'
+
+test_expect_success 'remove single commit causing conflict; fix; continue' '
+	reset &&
+	test_must_fail git_splice two-a^! &&
+	grep "Could not apply .* two b" stdout stderr &&
+	grep "When you have resolved this problem, run \"git splice --continue\"" stdout stderr &&
+	grep "or run \"git splice --abort\"" stdout stderr &&
+	test_splice_in_progress &&
+	echo two merged >two &&
+	git add two &&
+	git_splice --continue &&
+	grep "two merged" two &&
+	grep "three b" three &&
+	test_splice_not_in_progress
+'
+
+test_expect_success 'remove root commit' '
+	# We have to remove one-b first, in order to avoid conflicts when
+	# we remove one-a.
+	reset &&
+	git_splice one-b^! &&
+	! grep "one b" one &&
+	git_splice --root one-a &&
+	! test -e one &&
+	grep "three b" three &&
+	test_splice_not_in_progress
+'
+
+test_expect_success 'remove root commit causing conflict; abort' '
+	reset &&
+	test_must_fail git_splice --root one-a &&
+	grep "Could not apply .* one b" stdout stderr &&
+	grep "When you have resolved this problem, run \"git splice --continue\"" stdout stderr &&
+	grep "or run \"git splice --abort\"" stdout stderr &&
+	test_splice_in_progress &&
+	git_splice --abort &&
+	test_splice_not_in_progress
+'
+
+test_expect_success 'remove root commit causing conflict; fix; continue' '
+	reset &&
+	test_must_fail git_splice --root one-a &&
+	grep "Could not apply .* one b" stdout stderr &&
+	grep "When you have resolved this problem, run \"git splice --continue\"" stdout stderr &&
+	grep "or run \"git splice --abort\"" stdout stderr &&
+	test_splice_in_progress &&
+	echo one merged >one &&
+	git add one &&
+	git_splice --continue &&
+	grep "one merged" one &&
+	grep "three b" three &&
+	test_splice_not_in_progress
+'
+
+#############################################################################
+# Removing a range of commits
+
+test_remove_range_of_commits () {
+	reset &&
+	git_splice one-b..two-b "$@" &&
+	grep "one b"   one   &&
+	grep "three b" three &&
+	! test -e two        &&
+	test_splice_not_in_progress
+}
+
+test_expect_success 'remove range of commits' '
+	test_remove_range_of_commits
+'
+
+test_expect_success 'remove range of commits with --' '
+	test_remove_range_of_commits --
+'
+
+test_expect_success 'remove commit from branch tip' '
+	reset &&
+	git_splice HEAD^! &&
+	test `git rev-parse HEAD` = `git rev-parse three-a` &&
+	test_splice_not_in_progress
+'
+
+test_expect_success 'remove commits from branch tip' '
+	reset &&
+	git_splice HEAD~3..HEAD &&
+	test `git rev-parse HEAD` = `git rev-parse two-a` &&
+	test_splice_not_in_progress
+'
+
+test_expect_success 'remove range of commits starting at root' '
+	reset &&
+	git_splice --root one-b &&
+	! test -e one &&
+	grep "three b" three &&
+	test_splice_not_in_progress
+'
+
+test_expect_success 'remove range of commits starting at root' '
+	reset &&
+	git_splice --root one-b -- &&
+	! test -e one &&
+	test_splice_not_in_progress
+'
+
+test_expect_success 'remove range of commits outside branch' '
+	reset &&
+	test_must_fail git_splice four-a..four-c &&
+	grep "^fatal: .* is in removal range but not in master" stderr &&
+	! test -e four &&
+	grep "three b" three &&
+	test_splice_not_in_progress
+'
+
+test_expect_success 'dirty working tree prevents removing commit on same file' '
+	reset &&
+	echo dirty >>two &&
+	test_when_finished "
+		git_splice --abort &&
+		test_splice_not_in_progress
+	" &&
+	test_must_fail git_splice two-b^! &&
+	grep "^error: Your local changes to the following files would be overwritten by checkout:" stderr &&
+	grep "^[[:space:]]*two" stderr &&
+	grep "^Please commit your changes or stash them before you switch branches" stderr &&
+	grep dirty two &&
+	test_splice_in_progress
+'
+
+test_expect_success 'dirty working tree prevents removing commit on other file' '
+	reset &&
+	echo dirty >>three &&
+	test_when_finished "
+		git_splice --abort &&
+		test_splice_not_in_progress
+	" &&
+	test_must_fail git_splice two-b^! &&
+	grep "^error: Your local changes to the following files would be overwritten by checkout:" stderr &&
+	grep "^[[:space:]]*three" stderr &&
+	grep "^Please commit your changes or stash them before you switch branches" stderr &&
+	test_splice_in_progress
+'
+
+create_merge_commit () {
+	test_when_finished "git tag -d four-merge" &&
+	reset &&
+	git merge four &&
+	git tag four-merge &&
+	echo "four d" >>four &&
+	git commit -am"four d"
+}
+
+test_expect_success 'abort when trying to remove a merge commit' '
+	create_merge_commit &&
+	test_must_fail git_splice four-merge^! &&
+	grep "^fatal: Removing merge commits is not supported" stderr &&
+	test_splice_not_in_progress
+'
+
+test_expect_success 'abort when removal range contains merge commits' '
+	create_merge_commit &&
+	test_must_fail git_splice four-merge^^..HEAD &&
+	grep "^fatal: Removing merge commits is not supported" stderr &&
+	test_splice_not_in_progress
+'
+
+# The foo.. notation doesn't naturally play nice with our implementation,
+# since HEAD gets moved around during the splice.
+test_expect_success 'abort when removal range contains merge commits (2)' '
+	create_merge_commit &&
+	test_must_fail git_splice four-merge^^.. &&
+	grep "^fatal: Removing merge commits is not supported" stderr &&
+	test_splice_not_in_progress
+'
+
+#############################################################################
+# Inserting a single commit
+
+test_expect_success 'insert single commit at HEAD' '
+	reset &&
+	git_splice HEAD four-a^! &&
+	grep "two b" two &&
+	grep "three a" three &&
+	grep "four a" four &&
+	! grep "four b" four &&
+	git log --format=format:%s, | xargs |
+		grep "four a, three b, three a, two b," &&
+	test_splice_not_in_progress
+'
+
+test_expect_success 'insert single commit within branch' '
+	reset &&
+	git_splice two-b four-a^! &&
+	grep "two b" two &&
+	grep "three a" three &&
+	grep "four a" four &&
+	! grep "four b" four &&
+	git log --format=format:%s, | xargs |
+		grep "three b, three a, four a, two b," &&
+	test_splice_not_in_progress
+'
+
+create_five_branch () {
+	test_when_finished "
+		git branch -D five &&
+		git tag -d five-{a,b,c,merge}
+	" &&
+	setup_other_branch five one-b a b &&
+	git checkout five &&
+	git merge four-a &&
+	git tag five-merge &&
+	echo "five c" >>five &&
+	git commit -am"five c" &&
+	git tag five-c &&
+	git checkout master
+}
+
+test_expect_success 'abort when appending a single merge commit on HEAD' '
+	reset &&
+	create_five_branch &&
+	test_must_fail git_splice HEAD five-merge^! &&
+	grep "^fatal: Inserting merge commits is not supported" stderr &&
+	test_splice_not_in_progress
+'
+
+test_expect_success 'abort when inserting a single merge commit within branch' '
+	reset &&
+	create_five_branch &&
+	test_must_fail git_splice HEAD~2 five-merge^! &&
+	grep "^fatal: Inserting merge commits is not supported" stderr &&
+	test_splice_not_in_progress
+'
+
+#############################################################################
+# Inserting a range of commits
+
+test_expect_success 'insert commit range' '
+	reset &&
+	git_splice two-b one-b..four-b &&
+	grep "two b" two &&
+	grep "three a" three &&
+	grep "four b" four &&
+	git log --format=format:%s, | xargs |
+		grep "three b, three a, four b, four a, two b," &&
+	test_splice_not_in_progress
+'
+
+test_expect_success 'insert commit causing conflict; abort' '
+	reset &&
+	test_must_fail git_splice two-b four-b^! &&
+	grep "could not apply .* four b" stderr &&
+	grep "git cherry-pick failed" stderr &&
+	grep "When you have resolved this problem, run \"git splice --continue\"" stdout stderr &&
+	grep "or run \"git splice --abort\"" stdout stderr &&
+	test_splice_in_progress &&
+	git_splice --abort &&
+	test_splice_not_in_progress
+'
+
+test_expect_success 'insert commit causing conflict; fix; continue' '
+	reset &&
+	test_must_fail git_splice two-b four-b^! &&
+	grep "could not apply .* four b" stderr &&
+	grep "git cherry-pick failed" stderr &&
+	grep "When you have resolved this problem, run \"git splice --continue\"" stdout stderr &&
+	grep "or run \"git splice --abort\"" stdout stderr &&
+	test_splice_in_progress &&
+	echo four merged >four &&
+	git add four &&
+	git_splice --continue &&
+	grep "four merged" four &&
+	grep "three b" three &&
+	test_splice_not_in_progress
+'
+
+test_expect_success 'abort when appending range includes a merge commit' '
+	reset &&
+	create_five_branch &&
+	test_must_fail git_splice HEAD five-a^..five &&
+	grep "^fatal: Inserting merge commits is not supported" stderr &&
+	test_splice_not_in_progress
+'
+
+test_expect_success 'abort when inserting range includes a merge commit' '
+	reset &&
+	create_five_branch &&
+	test_must_fail git_splice HEAD~2 five-a^..five &&
+	grep "^fatal: Inserting merge commits is not supported" stderr &&
+	test_splice_not_in_progress
+'
+
+#############################################################################
+# Removing a range and inserting one or more commits
+
+test_expect_success 'remove range; insert commit' '
+	reset &&
+	git_splice two-a^..two-b four-a^! &&
+	grep "four a" four &&
+	! grep "four b" four &&
+	grep "three b" three &&
+	! test -e two &&
+	test_splice_not_in_progress
+'
+
+test_expect_success 'remove range; insert commit range' '
+	reset &&
+	git_splice two-a^..two-b four-a^..four-b &&
+	grep "four b" four &&
+	! grep "four c" four &&
+	grep "three b" three &&
+	! test -e two &&
+	test_splice_not_in_progress
+'
+
+test_expect_success 'remove range; insert commit causing conflict; abort' '
+	reset &&
+	test_must_fail git_splice two-a^..two-b four-b^! &&
+	grep "could not apply .* four b" stderr &&
+	grep "git cherry-pick failed" stderr &&
+	grep "When you have resolved this problem, run \"git splice --continue\"" stderr &&
+	grep "or run \"git splice --abort\" to abandon the splice" stderr &&
+	test_splice_in_progress &&
+	git_splice --abort &&
+	test_splice_not_in_progress
+'
+
+test_remove_range_insert_commit_fix_conflict_continue () {
+	reset &&
+	test_must_fail git_splice two-a^..two-b "$@" four-b^! &&
+	grep "could not apply .* four b" stderr &&
+	grep "git cherry-pick failed" stderr &&
+	grep "When you have resolved this problem, run \"git splice --continue\"" stdout stderr &&
+	grep "or run \"git splice --abort\"" stdout stderr &&
+	test_splice_in_progress &&
+	echo four merged >four &&
+	git add four &&
+	git_splice --continue &&
+	grep "four merged" four &&
+	grep "three b" three &&
+	! test -e two &&
+	test_splice_not_in_progress
+}
+
+test_expect_success 'remove range; insert commit causing conflict; fix; continue' '
+	test_remove_range_insert_commit_fix_conflict_continue
+'
+
+test_expect_success 'remove range -- insert commit causing conflict; fix; continue' '
+	test_remove_range_insert_commit_fix_conflict_continue --
+'
+
+test_expect_success 'remove grepped commits; insert grepped commits' '
+	reset &&
+	git_splice --grep=two -n1 three-b -- --grep=four --skip=1 four &&
+	grep "two a" two &&
+	! grep "two b" two &&
+	grep "four b" four &&
+	! grep "four c" four &&
+	grep "three b" three &&
+	test_splice_not_in_progress
+'
+
+test_done
-- 
git-series 0.9.1

^ permalink raw reply related	[flat|nested] 4+ messages in thread

* Re: [PATCH 0/1] add git-splice subcommand for non-interactive branch splicing
  2017-07-31 21:18 [PATCH 0/1] add git-splice subcommand for non-interactive branch splicing Adam Spiers
  2017-07-31 21:18 ` [PATCH 1/1] add git-splice command " Adam Spiers
@ 2017-07-31 22:18 ` Junio C Hamano
  2017-08-01  1:14   ` Adam Spiers
  1 sibling, 1 reply; 4+ messages in thread
From: Junio C Hamano @ 2017-07-31 22:18 UTC (permalink / raw)
  To: Adam Spiers; +Cc: git list

Adam Spiers <git@adamspiers.org> writes:

> Therefore there is a risk that each new UI for higher-level workflows
> will end up re-implementing these mid-level operations.  This
> undesirable situation could be avoided if git itself provided those
> mid-level operations.

Let me make sure if I get your general idea right, first.

Is your aim is to give a single unified mid-layer that these other
tools can build on instead of rolling their own "cherry-pick these
ranges, then squash that in, and then merge the other one in, ..."
sequencing machinery?

If so, I think that is a very good goal.

>     # Remove commits A..B (i.e. excluding A) from the current branch.
>     git splice A..B
>     # Remove commit A from the current branch.
>     git splice A^!
>     # Remove commits A..B from the current branch, and cherry-pick
>     # commits C..D at the same point.
>     git splice A..B C..D

We need to make sure that the mid-layer tool offers a good set of
primitive operations that serve all of these other tools' needs.  I
do not know offhand if what you implemented that are illustrated by
these examples is or isn't that "good set".

Assuming that there is such a "good set of primitives" surfaced at
the UI level so that these other tools can express what they want to
perform with, I'd personally prefer to see a solution that extends
and uses the common "sequencer" machinery we have been using to
drive cherry-picks, reverts and interactive rebases that work on
multiple commits.  IOW, it would be nice to see that the only thing
"git splice A..B" does is to prepare a series of instructions in a
file, e.g. .git/sequencer/todo, just like "git cherry-pick A..B"
would, and let the sequencer machinery to handle the sequencing.

E.g. In a history like

    ---o---A---o---B---X---Y---Z   HEAD

"git splice A..B" command would write something like this:

    reset to A
    pick X
    pick Y
    pick Z

to the todo file and drive the sequencer.  As you notice, you would
need to extend the vocabulary of the sequencer a bit to allow
various things that the current users of the sequencer machinery do
not need, like resetting the HEAD to a specific commit, merging a
side branch, remembering the result of an operation, and referring
to such a commit in later operation.  For example, if you tell "git
splice" to expunge A from this sample history (I am not sure how you
express that operation in your UI):

         B---C---D
        /         \
    ---o---A---E---F---G   HEAD

it might create a "todo" list like this to rebuild the history:

    reset to A^
    pick B
    pick C
    pick D
    mark :1
    reset to A^
    pick E
    merge :1 using F's log message and conflict resolution as reference
    pick G

to result in:

         B---C---D
        /         \
    ---o-------E---F---G   HEAD

Do not pay too much attention to how the hypothetical "extended todo
instruction set" is spelled in the above illustration (e.g. I am not
advocating for multi-word command like "reset to"); these are only
to illustrate what kind of features would be needed for the job.  In
the final shape of the system, "merge" in the illustration above may
be a more succinct "merge F :1", for example (i.e. the first
parameter would name an existing merge to use as reference, the
remainder is a list of commits to be merged to the current HEAD),
just like "pick X" is a succinct way to say "cherry-pick the change
introduced by existing commit X to HEAD, reusing X's log message
and author information".

Something like that may have a place in the git-core, I would think. 

I am not sure if a bash script that calls rebase/cherry-pick/commit
manually can serve as a good "universal mid-layer" or just adding
another random command to the set of existing third-party commands
for "higher-level workflows".

^ permalink raw reply	[flat|nested] 4+ messages in thread

* Re: [PATCH 0/1] add git-splice subcommand for non-interactive branch splicing
  2017-07-31 22:18 ` [PATCH 0/1] add git-splice subcommand " Junio C Hamano
@ 2017-08-01  1:14   ` Adam Spiers
  0 siblings, 0 replies; 4+ messages in thread
From: Adam Spiers @ 2017-08-01  1:14 UTC (permalink / raw)
  To: Junio C Hamano; +Cc: git list

On 31 July 2017 at 23:18, Junio C Hamano <gitster@pobox.com> wrote:
> Adam Spiers <git@adamspiers.org> writes:
>
> > Therefore there is a risk that each new UI for higher-level workflows
> > will end up re-implementing these mid-level operations.  This
> > undesirable situation could be avoided if git itself provided those
> > mid-level operations.
>
> Let me make sure if I get your general idea right, first.
>
> Is your aim is to give a single unified mid-layer that these other
> tools can build on instead of rolling their own "cherry-pick these
> ranges, then squash that in, and then merge the other one in, ..."
> sequencing machinery?

Pretty much, yes.  The original itch I wanted to scratch was
implementing git-explode, which aims to automatically explode a large
topic branch into a set of smaller, independent topic branches, by
harnessing my git-deps for automatically detecting inter-dependencies
between commits in the large source branch and using that dependency
tree to construct the smaller topic branches.  (Before anyone protests
at this point, yes, I am fully aware that it is not possible to
automate 100% accurate detection of these dependencies, and no, that
does not completely invalidate the approach.[0])

My initial thought was that in order to be able to automatically
decompose a branch into smaller branches, I would need a mid-layer
operation "git-transplant" somewhat analogous to mv(1), which would
let me easily move commits out of the source branch into a new target
branch.  And then I realised that, in the same way that
(simplistically speaking) mv(1) could be reimplemented as cp(1)
followed by rm(1), implementing "git-transplant" in turn would require
more primitive operations for copying commits between branches, and
removing commits from branches.  At this point I saw value in
generalising those operations; hence the idea for git-splice was born.

Consequently I implemented prototypes for splice and transplant, which
didn't take too long.  (The real work was writing comprehensive test
suites and polishing the tools until they were reliable enough to pass
100%.)

Ironically, soon after I started to implement git-explode, I realised
that the order in which I needed to walk the dependency tree
discovered by git-deps actually meant that I couldn't use
git-transplant for this particular use case, so in the end I
implemented it with pygit2.  (I still need to polish it up a bit more
before releasing.)

However, even though splice and transplant are not useful for this
particular use case, I still believe that they (or similar tools) have
the potential to serve as a useful foundation for other workflows.

> If so, I think that is a very good goal.

Glad to hear it :-)

> >     # Remove commits A..B (i.e. excluding A) from the current branch.
> >     git splice A..B
> >     # Remove commit A from the current branch.
> >     git splice A^!
> >     # Remove commits A..B from the current branch, and cherry-pick
> >     # commits C..D at the same point.
> >     git splice A..B C..D
>
> We need to make sure that the mid-layer tool offers a good set of
> primitive operations that serve all of these other tools' needs.  I
> do not know offhand if what you implemented that are illustrated by
> these examples is or isn't that "good set".

Agreed.  That's why I sent the RFC to this list last year: in the hope
that these details could be hashed out and guide my development in the
right direction.  Unfortunately I didn't get much response at the
time, which was probably my fault for not explaining my "mission
goals" clearly enough.  Although in fairness to myself, I think I
needed a year anyway to let the ideas in my head mature to the point
where I understood them well enough myself to communicate them clearly
to others :-)

> Assuming that there is such a "good set of primitives" surfaced at
> the UI level so that these other tools can express what they want to
> perform with, I'd personally prefer to see a solution that extends
> and uses the common "sequencer" machinery we have been using to
> drive cherry-picks, reverts and interactive rebases that work on
> multiple commits.  IOW, it would be nice to see that the only thing
> "git splice A..B" does is to prepare a series of instructions in a
> file, e.g. .git/sequencer/todo, just like "git cherry-pick A..B"
> would, and let the sequencer machinery to handle the sequencing.
>
> E.g. In a history like
>
>     ---o---A---o---B---X---Y---Z   HEAD
>
> "git splice A..B" command would write something like this:
>
>     reset to A
>     pick X
>     pick Y
>     pick Z
>
> to the todo file and drive the sequencer.

That sounds great to me!  At this point sadly I'm currently a bit
ignorant of the intricacies of the sequencer, otherwise I might have
adopted this approach from day 0.  But I'm pleased to be able to say
that under the hood, the way I implemented splice and transplant isn't
too dissimilar to this: they both write "todo" files, under
.git/splice and .git/transplant respectively, and then execute the
instructions in those files.  So hopefully it wouldn't be much work to
bring them closer to the kind of format you describe above, and then
feed that to the sequencer instead of have them process the tasks
themselves.

> As you notice, you would
> need to extend the vocabulary of the sequencer a bit to allow
> various things that the current users of the sequencer machinery do
> not need, like resetting the HEAD to a specific commit, merging a
> side branch, remembering the result of an operation, and referring
> to such a commit in later operation.  For example, if you tell "git
> splice" to expunge A from this sample history (I am not sure how you
> express that operation in your UI):
>
>          B---C---D
>         /         \
>     ---o---A---E---F---G   HEAD

Currently splice explicitly avoids editing history with merge commits,
although this example has made me realise that there's a bug with the
way it currently does that: it only checks that the removal and
insertion ranges are all non-merge commits before starting execution,
whereas it actually needs to check all the descendant commits too.
Fortunately that's easy to fix :-)

> it might create a "todo" list like this to rebuild the history:
>
>     reset to A^
>     pick B
>     pick C
>     pick D
>     mark :1
>     reset to A^
>     pick E
>     merge :1 using F's log message and conflict resolution as reference
>     pick G
>
> to result in:
>
>          B---C---D
>         /         \
>     ---o-------E---F---G   HEAD
>
> Do not pay too much attention to how the hypothetical "extended todo
> instruction set" is spelled in the above illustration (e.g. I am not
> advocating for multi-word command like "reset to"); these are only
> to illustrate what kind of features would be needed for the job.  In
> the final shape of the system, "merge" in the illustration above may
> be a more succinct "merge F :1", for example (i.e. the first
> parameter would name an existing merge to use as reference, the
> remainder is a list of commits to be merged to the current HEAD),
> just like "pick X" is a succinct way to say "cherry-pick the change
> introduced by existing commit X to HEAD, reusing X's log message
> and author information".

Yep, that all makes perfect sense.  It seems to me that there would be
three main strands of work required here:

     (0) gather use cases for automated higher-level workflows
         from users, so we're clear what kinds of problems are
         most worth solving

     (1) automate generation of instruction sequences which
         reflect those workflows (or parts thereof)

     (2) extend the sequencer as/when required by (1)

> Something like that may have a place in the git-core, I would think.

OK, good to know.

> I am not sure if a bash script that calls rebase/cherry-pick/commit
> manually can serve as a good "universal mid-layer" or just adding
> another random command to the set of existing third-party commands
> for "higher-level workflows".

I'm not sure either.  It might or might not be, but I think a debate
on that topic would be worthwhile and something in which I'd be very
interested in taking part.

My first hunch is that if we were to attempt to design this
"mid-layer" of operations, it would make sense to start with the more
primitive operations in that layer, and then build the more
sophisticated ones later - on top of the primitives, if that made
sense.

For example first we could focus on sequences which achieve simple
things like removing a range of commits from a branch where the
descendants of that range are all non-merge commits, and inserting a
range of commits into a branch which satisfies the same "no merge
commits" constraint.  This would achieve parity with git-splice.

Next we could add support for the same operations with the "no merge
commits" constraint dropped, so that your example scenario above could
be handled correctly.

Then we could add support for more complicated operations such as
transplants, and removing / transplanting a whole range of commits
which can form an arbitrarily complex commit graph.  This last one
sounds pretty hairy, which reinforces the value of starting simple.

Also, implementing the more primitive operations first would allow us
to extend the sequencer's capabilities in a more incremental and
risk-averse manner.

Thanks a lot for the reply!  What would you recommend as the next
steps?


[0] This has been discussed before, e.g.
     https://public-inbox.org/git/20160528112417.GD11256@pacific.linksys.moosehall/

^ permalink raw reply	[flat|nested] 4+ messages in thread

end of thread, other threads:[~2017-08-01  1:14 UTC | newest]

Thread overview: 4+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2017-07-31 21:18 [PATCH 0/1] add git-splice subcommand for non-interactive branch splicing Adam Spiers
2017-07-31 21:18 ` [PATCH 1/1] add git-splice command " Adam Spiers
2017-07-31 22:18 ` [PATCH 0/1] add git-splice subcommand " Junio C Hamano
2017-08-01  1:14   ` Adam Spiers

Code repositories for project(s) associated with this public inbox

	https://80x24.org/mirrors/git.git

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox;
as well as URLs for read-only IMAP folder(s) and NNTP newsgroup(s).