From: Adam Spiers <git@adamspiers.org>
To: git list <git@vger.kernel.org>
Subject: [PATCH 1/1] add git-splice command for non-interactive branch splicing
Date: Mon, 31 Jul 2017 22:18:49 +0100 [thread overview]
Message-ID: <c3213758552a02e233d9c173f0c52d05d2460a0f.1501535033.git-series.git@adamspiers.org> (raw)
In-Reply-To: <cover.55495badd28b73b39c60ca4107b50aae7ee95028.1501535033.git-series.git@adamspiers.org>
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
next prev parent reply other threads:[~2017-07-31 21:26 UTC|newest]
Thread overview: 4+ messages / expand[flat|nested] mbox.gz Atom feed top
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 [this message]
2017-07-31 22:18 ` Junio C Hamano
2017-08-01 1:14 ` Adam Spiers
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
List information: http://vger.kernel.org/majordomo-info.html
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=c3213758552a02e233d9c173f0c52d05d2460a0f.1501535033.git-series.git@adamspiers.org \
--to=git@adamspiers.org \
--cc=git@vger.kernel.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.
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).