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@xxxxxxxxxxxxxx> --- .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