Fix git-rebase -i to allow squashing of fast-forwardable commits
[gitweb.git] / git-rebase--interactive.sh
index ab3657250e9ffc6f090e9de989d89df7be22fa4a..f3950767ea6256cd9c2dceabbe7c91fa62e3f3aa 100755 (executable)
@@ -10,7 +10,8 @@
 # The original idea comes from Eric W. Biederman, in
 # http://article.gmane.org/gmane.comp.version-control.git/22407
 
-USAGE='(--continue | --abort | --skip | [--onto <branch>] <upstream> [<branch>])'
+USAGE='(--continue | --abort | --skip | [--preserve-merges] [--verbose]
+       [--onto <branch>] <upstream> [<branch>])'
 
 . git-sh-setup
 require_work_tree
@@ -18,8 +19,13 @@ require_work_tree
 DOTEST="$GIT_DIR/.dotest-merge"
 TODO="$DOTEST"/todo
 DONE="$DOTEST"/done
+REWRITTEN="$DOTEST"/rewritten
+PRESERVE_MERGES=
 STRATEGY=
 VERBOSE=
+test -d "$REWRITTEN" && PRESERVE_MERGES=t
+test -f "$DOTEST"/strategy && STRATEGY="$(cat "$DOTEST"/strategy)"
+test -f "$DOTEST"/verbose && VERBOSE=t
 
 warn () {
        echo "$*" >&2
@@ -56,17 +62,29 @@ make_patch () {
 }
 
 die_with_patch () {
+       test -f "$DOTEST"/message ||
+               git cat-file commit $sha1 | sed "1,/^$/d" > "$DOTEST"/message
+       test -f "$DOTEST"/author-script ||
+               get_author_ident_from_commit $sha1 > "$DOTEST"/author-script
        make_patch "$1"
        die "$2"
 }
 
+die_abort () {
+       rm -rf "$DOTEST"
+       die "$1"
+}
+
 pick_one () {
        case "$1" in -n) sha1=$2 ;; *) sha1=$1 ;; esac
        git rev-parse --verify $sha1 || die "Invalid commit name: $sha1"
+       test -d "$REWRITTEN" &&
+               pick_one_preserving_merges "$@" && return
        parent_sha1=$(git rev-parse --verify $sha1^ 2>/dev/null)
        current_sha1=$(git rev-parse --verify HEAD)
        if [ $current_sha1 = $parent_sha1 ]; then
                git reset --hard $sha1
+               test "a$1" = a-n && git reset --soft $current_sha1
                sha1=$(git rev-parse --short $sha1)
                warn Fast forward to $sha1
        else
@@ -74,12 +92,79 @@ pick_one () {
        fi
 }
 
+pick_one_preserving_merges () {
+       case "$1" in -n) sha1=$2 ;; *) sha1=$1 ;; esac
+       sha1=$(git rev-parse $sha1)
+
+       if [ -f "$DOTEST"/current-commit ]
+       then
+               current_commit=$(cat "$DOTEST"/current-commit) &&
+               git rev-parse HEAD > "$REWRITTEN"/$current_commit &&
+               rm "$DOTEST"/current-commit ||
+               die "Cannot write current commit's replacement sha1"
+       fi
+
+       # rewrite parents; if none were rewritten, we can fast-forward.
+       fast_forward=t
+       preserve=t
+       new_parents=
+       for p in $(git rev-list --parents -1 $sha1 | cut -d\  -f2-)
+       do
+               if [ -f "$REWRITTEN"/$p ]
+               then
+                       preserve=f
+                       new_p=$(cat "$REWRITTEN"/$p)
+                       test $p != $new_p && fast_forward=f
+                       case "$new_parents" in
+                       *$new_p*)
+                               ;; # do nothing; that parent is already there
+                       *)
+                               new_parents="$new_parents $new_p"
+                       esac
+               fi
+       done
+       case $fast_forward in
+       t)
+               echo "Fast forward to $sha1"
+               test $preserve=f && echo $sha1 > "$REWRITTEN"/$sha1
+               ;;
+       f)
+               test "a$1" = a-n && die "Refusing to squash a merge: $sha1"
+
+               first_parent=$(expr "$new_parents" : " \([^ ]*\)")
+               # detach HEAD to current parent
+               git checkout $first_parent 2> /dev/null ||
+                       die "Cannot move HEAD to $first_parent"
+
+               echo $sha1 > "$DOTEST"/current-commit
+               case "$new_parents" in
+               \ *\ *)
+                       # redo merge
+                       author_script=$(get_author_ident_from_commit $sha1)
+                       eval "$author_script"
+                       msg="$(git cat-file commit $sha1 | \
+                               sed -e '1,/^$/d' -e "s/[\"\\]/\\\\&/g")"
+                       # NEEDSWORK: give rerere a chance
+                       if ! git merge $STRATEGY -m "$msg" $new_parents
+                       then
+                               echo "$msg" > "$GIT_DIR"/MERGE_MSG
+                               die Error redoing merge $sha1
+                       fi
+                       ;;
+               *)
+                       git cherry-pick $STRATEGY "$@" ||
+                               die_with_patch $sha1 "Could not pick $sha1"
+               esac
+       esac
+}
+
 do_next () {
+       test -f "$DOTEST"/message && rm "$DOTEST"/message
+       test -f "$DOTEST"/author-script && rm "$DOTEST"/author-script
        read command sha1 rest < "$TODO"
        case "$command" in
        \#|'')
                mark_action_done
-               continue
                ;;
        pick)
                comment_for_reflog pick
@@ -109,19 +194,20 @@ do_next () {
                        die "Cannot 'squash' without a previous commit"
 
                mark_action_done
-               failed=f
-               pick_one -n $sha1 || failed=t
                MSG="$DOTEST"/message
                echo "# This is a combination of two commits." > "$MSG"
                echo "# The first commit's message is:" >> "$MSG"
                echo >> "$MSG"
                git cat-file commit HEAD | sed -e '1,/^$/d' >> "$MSG"
                echo >> "$MSG"
+               failed=f
+               pick_one -n $sha1 || failed=t
                echo "# And this is the 2nd commit message:" >> "$MSG"
                echo >> "$MSG"
                git cat-file commit $sha1 | sed -e '1,/^$/d' >> "$MSG"
                git reset --soft HEAD^
                author_script=$(get_author_ident_from_commit $sha1)
+               echo "$author_script" > "$DOTEST"/author-script
                case $failed in
                f)
                        # This is like --amend, but with a different message
@@ -133,10 +219,6 @@ do_next () {
                        cp "$MSG" "$GIT_DIR"/MERGE_MSG
                        warn
                        warn "Could not apply $sha1... $rest"
-                       warn "After you fixed that, commit the result with"
-                       warn
-                       warn "  $(echo $author_script | tr '\012' ' ') \\"
-                       warn "    git commit -F \"$GIT_DIR\"/MERGE_MSG -e"
                        die_with_patch $sha1 ""
                esac
                ;;
@@ -146,8 +228,25 @@ do_next () {
        esac
        test -s "$TODO" && return
 
-       HEAD=$(git rev-parse HEAD)
-       HEADNAME=$(cat "$DOTEST"/head-name)
+       comment_for_reflog finish &&
+       HEADNAME=$(cat "$DOTEST"/head-name) &&
+       OLDHEAD=$(cat "$DOTEST"/head) &&
+       SHORTONTO=$(git rev-parse --short $(cat "$DOTEST"/onto)) &&
+       if [ -d "$REWRITTEN" ]
+       then
+               test -f "$DOTEST"/current-commit &&
+                       current_commit=$(cat "$DOTEST"/current-commit) &&
+                       git rev-parse HEAD > "$REWRITTEN"/$current_commit
+               NEWHEAD=$(cat "$REWRITTEN"/$OLDHEAD)
+       else
+               NEWHEAD=$(git rev-parse HEAD)
+       fi &&
+       message="$GIT_REFLOG_ACTION: $HEADNAME onto $SHORTONTO)" &&
+       git update-ref -m "$message" $HEADNAME $NEWHEAD $OLDHEAD &&
+       git symbolic-ref HEAD $HEADNAME && {
+               test ! -f "$DOTEST"/verbose ||
+                       git diff --stat $(cat "$DOTEST"/head)..HEAD
+       } &&
        rm -rf "$DOTEST" &&
        warn "Successfully rebased and updated $HEADNAME."
 
@@ -159,9 +258,6 @@ do_rest () {
        do
                do_next
        done
-       test -f "$DOTEST"/verbose &&
-               git diff --stat $(cat "$DOTEST"/head)..HEAD
-       exit
 }
 
 while case $# in 0) break ;; esac
@@ -172,6 +268,15 @@ do
 
                test -d "$DOTEST" || die "No interactive rebase running"
 
+               # commit if necessary
+               git rev-parse --verify HEAD > /dev/null &&
+               git update-index --refresh &&
+               git diff-files --quiet &&
+               ! git diff-index --cached --quiet HEAD &&
+               . "$DOTEST"/author-script &&
+               export GIT_AUTHOR_NAME GIT_AUTHOR_NAME GIT_AUTHOR_DATE &&
+               git commit -F "$DOTEST"/message -e
+
                require_clean_work_tree
                do_rest
                ;;
@@ -212,9 +317,12 @@ do
        -C*)
                die "Interactive rebase uses merge, so $1 does not make sense"
                ;;
-       -v)
+       -v|--verbose)
                VERBOSE=t
                ;;
+       -p|--preserve-merges)
+               PRESERVE_MERGES=t
+               ;;
        -i|--interactive)
                # yeah, we know
                ;;
@@ -262,28 +370,57 @@ do
                echo $HEAD > "$DOTEST"/head
                echo $UPSTREAM > "$DOTEST"/upstream
                echo $ONTO > "$DOTEST"/onto
+               test -z "$STRATEGY" || echo "$STRATEGY" > "$DOTEST"/strategy
                test t = "$VERBOSE" && : > "$DOTEST"/verbose
+               if [ t = "$PRESERVE_MERGES" ]
+               then
+                       # $REWRITTEN contains files for each commit that is
+                       # reachable by at least one merge base of $HEAD and
+                       # $UPSTREAM. They are not necessarily rewritten, but
+                       # their children might be.
+                       # This ensures that commits on merged, but otherwise
+                       # unrelated side branches are left alone. (Think "X"
+                       # in the man page's example.)
+                       mkdir "$REWRITTEN" &&
+                       for c in $(git merge-base --all $HEAD $UPSTREAM)
+                       do
+                               echo $ONTO > "$REWRITTEN"/$c ||
+                                       die "Could not init rewritten commits"
+                       done
+                       MERGES_OPTION=
+               else
+                       MERGES_OPTION=--no-merges
+               fi
 
+               SHORTUPSTREAM=$(git rev-parse --short $UPSTREAM)
+               SHORTHEAD=$(git rev-parse --short $HEAD)
+               SHORTONTO=$(git rev-parse --short $ONTO)
                cat > "$TODO" << EOF
-# Rebasing $UPSTREAM..$HEAD onto $ONTO
+# Rebasing $SHORTUPSTREAM..$SHORTHEAD onto $SHORTONTO
 #
 # Commands:
 #  pick = use commit
 #  edit = use commit, but stop for amending
 #  squash = use commit, but meld into previous commit
+#
+# If you remove a line here THAT COMMIT WILL BE LOST.
+#
 EOF
-               git rev-list --no-merges --pretty=oneline --abbrev-commit \
+               git rev-list $MERGES_OPTION --pretty=oneline --abbrev-commit \
                        --abbrev=7 --reverse $UPSTREAM..$HEAD | \
                        sed "s/^/pick /" >> "$TODO"
 
                test -z "$(grep -ve '^$' -e '^#' < $TODO)" &&
-                       die "Nothing to do"
+                       die_abort "Nothing to do"
 
                cp "$TODO" "$TODO".backup
                ${VISUAL:-${EDITOR:-vi}} "$TODO" ||
                        die "Could not execute editor"
 
-               git reset --hard $ONTO && do_rest
+               test -z "$(grep -ve '^$' -e '^#' < $TODO)" &&
+                       die_abort "Nothing to do"
+
+               git checkout $ONTO && do_rest
        esac
        shift
 done