sequencer (rebase -i): add support for the 'fixup' and 'squash' commands
authorJohannes Schindelin <johannes.schindelin@gmx.de>
Mon, 2 Jan 2017 15:27:07 +0000 (16:27 +0100)
committerJunio C Hamano <gitster@pobox.com>
Mon, 9 Jan 2017 22:57:29 +0000 (14:57 -0800)
This is a huge patch, and at the same time a huge step forward to
execute the performance-critical parts of the interactive rebase in a
builtin command.

Since 'fixup' and 'squash' are not only similar, but also need to know
about each other (we want to reduce a series of fixups/squashes into a
single, final commit message edit, from the user's point of view), we
really have to implement them both at the same time.

Most of the actual work is done by the existing code path that already
handles the "pick" and the "edit" commands; We added support for other
features (e.g. to amend the commit message) in the patches leading up to
this one, yet there are still quite a few bits in this patch that simply
would not make sense as individual patches (such as: determining whether
there was anything to "fix up" in the "todo" script, etc).

In theory, it would be possible to reuse the fast-forward code path also
for the fixup and the squash code paths, but in practice this would make
the code less readable. The end result cannot be fast-forwarded anyway,
therefore let's just extend the cherry-picking code path for now.

Since the sequencer parses the entire `git-rebase-todo` script in one go,
fixup or squash commands without a preceding pick can be reported early
(in git-rebase--interactive, we could only report such errors just before
executing the fixup/squash).

Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>
Signed-off-by: Junio C Hamano <gitster@pobox.com>
sequencer.c
index 8ea3d6aa9492875cdf01332440f5d1ac07a563a3..6a939a10bdf6617bcedcf7bc695a78dab36b6480 100644 (file)
@@ -46,6 +46,35 @@ static GIT_PATH_FUNC(rebase_path_todo, "rebase-merge/git-rebase-todo")
  * actions.
  */
 static GIT_PATH_FUNC(rebase_path_done, "rebase-merge/done")
+/*
+ * The commit message that is planned to be used for any changes that
+ * need to be committed following a user interaction.
+ */
+static GIT_PATH_FUNC(rebase_path_message, "rebase-merge/message")
+/*
+ * The file into which is accumulated the suggested commit message for
+ * squash/fixup commands. When the first of a series of squash/fixups
+ * is seen, the file is created and the commit message from the
+ * previous commit and from the first squash/fixup commit are written
+ * to it. The commit message for each subsequent squash/fixup commit
+ * is appended to the file as it is processed.
+ *
+ * The first line of the file is of the form
+ *     # This is a combination of $count commits.
+ * where $count is the number of commits whose messages have been
+ * written to the file so far (including the initial "pick" commit).
+ * Each time that a commit message is processed, this line is read and
+ * updated. It is deleted just before the combined commit is made.
+ */
+static GIT_PATH_FUNC(rebase_path_squash_msg, "rebase-merge/message-squash")
+/*
+ * If the current series of squash/fixups has not yet included a squash
+ * command, then this file exists and holds the commit message of the
+ * original "pick" commit.  (If the series ends without a "squash"
+ * command, then this can be used as the commit message of the combined
+ * commit without opening the editor.)
+ */
+static GIT_PATH_FUNC(rebase_path_fixup_msg, "rebase-merge/message-fixup")
 /*
  * A script to set the GIT_AUTHOR_NAME, GIT_AUTHOR_EMAIL, and
  * GIT_AUTHOR_DATE that will be used for the commit that is currently
@@ -641,6 +670,8 @@ enum todo_command {
        TODO_PICK = 0,
        TODO_REVERT,
        TODO_EDIT,
+       TODO_FIXUP,
+       TODO_SQUASH,
        /* commands that do something else than handling a single commit */
        TODO_EXEC,
        /* commands that do nothing but are counted for reporting progress */
@@ -651,6 +682,8 @@ static const char *todo_command_strings[] = {
        "pick",
        "revert",
        "edit",
+       "fixup",
+       "squash",
        "exec",
        "noop"
 };
@@ -667,15 +700,114 @@ static int is_noop(const enum todo_command command)
        return TODO_NOOP <= (size_t)command;
 }
 
+static int is_fixup(enum todo_command command)
+{
+       return command == TODO_FIXUP || command == TODO_SQUASH;
+}
+
+static int update_squash_messages(enum todo_command command,
+               struct commit *commit, struct replay_opts *opts)
+{
+       struct strbuf buf = STRBUF_INIT;
+       int count, res;
+       const char *message, *body;
+
+       if (file_exists(rebase_path_squash_msg())) {
+               struct strbuf header = STRBUF_INIT;
+               char *eol, *p;
+
+               if (strbuf_read_file(&buf, rebase_path_squash_msg(), 2048) <= 0)
+                       return error(_("could not read '%s'"),
+                               rebase_path_squash_msg());
+
+               p = buf.buf + 1;
+               eol = strchrnul(buf.buf, '\n');
+               if (buf.buf[0] != comment_line_char ||
+                   (p += strcspn(p, "0123456789\n")) == eol)
+                       return error(_("unexpected 1st line of squash message:"
+                                      "\n\n\t%.*s"),
+                                    (int)(eol - buf.buf), buf.buf);
+               count = strtol(p, NULL, 10);
+
+               if (count < 1)
+                       return error(_("invalid 1st line of squash message:\n"
+                                      "\n\t%.*s"),
+                                    (int)(eol - buf.buf), buf.buf);
+
+               strbuf_addf(&header, "%c ", comment_line_char);
+               strbuf_addf(&header,
+                           _("This is a combination of %d commits."), ++count);
+               strbuf_splice(&buf, 0, eol - buf.buf, header.buf, header.len);
+               strbuf_release(&header);
+       } else {
+               unsigned char head[20];
+               struct commit *head_commit;
+               const char *head_message, *body;
+
+               if (get_sha1("HEAD", head))
+                       return error(_("need a HEAD to fixup"));
+               if (!(head_commit = lookup_commit_reference(head)))
+                       return error(_("could not read HEAD"));
+               if (!(head_message = get_commit_buffer(head_commit, NULL)))
+                       return error(_("could not read HEAD's commit message"));
+
+               find_commit_subject(head_message, &body);
+               if (write_message(body, strlen(body),
+                                 rebase_path_fixup_msg(), 0)) {
+                       unuse_commit_buffer(head_commit, head_message);
+                       return error(_("cannot write '%s'"),
+                                    rebase_path_fixup_msg());
+               }
+
+               count = 2;
+               strbuf_addf(&buf, "%c ", comment_line_char);
+               strbuf_addf(&buf, _("This is a combination of %d commits."),
+                           count);
+               strbuf_addf(&buf, "\n%c ", comment_line_char);
+               strbuf_addstr(&buf, _("This is the 1st commit message:"));
+               strbuf_addstr(&buf, "\n\n");
+               strbuf_addstr(&buf, body);
+
+               unuse_commit_buffer(head_commit, head_message);
+       }
+
+       if (!(message = get_commit_buffer(commit, NULL)))
+               return error(_("could not read commit message of %s"),
+                            oid_to_hex(&commit->object.oid));
+       find_commit_subject(message, &body);
+
+       if (command == TODO_SQUASH) {
+               unlink(rebase_path_fixup_msg());
+               strbuf_addf(&buf, "\n%c ", comment_line_char);
+               strbuf_addf(&buf, _("This is the commit message #%d:"), count);
+               strbuf_addstr(&buf, "\n\n");
+               strbuf_addstr(&buf, body);
+       } else if (command == TODO_FIXUP) {
+               strbuf_addf(&buf, "\n%c ", comment_line_char);
+               strbuf_addf(&buf, _("The commit message #%d will be skipped:"),
+                           count);
+               strbuf_addstr(&buf, "\n\n");
+               strbuf_add_commented_lines(&buf, body, strlen(body));
+       } else
+               return error(_("unknown command: %d"), command);
+       unuse_commit_buffer(commit, message);
+
+       res = write_message(buf.buf, buf.len, rebase_path_squash_msg(), 0);
+       strbuf_release(&buf);
+       return res;
+}
+
 static int do_pick_commit(enum todo_command command, struct commit *commit,
-               struct replay_opts *opts)
+               struct replay_opts *opts, int final_fixup)
 {
+       int edit = opts->edit, cleanup_commit_message = 0;
+       const char *msg_file = edit ? NULL : git_path_merge_msg();
        unsigned char head[20];
        struct commit *base, *next, *parent;
        const char *base_label, *next_label;
        struct commit_message msg = { NULL, NULL, NULL, NULL };
        struct strbuf msgbuf = STRBUF_INIT;
-       int res, unborn = 0, allow;
+       int res, unborn = 0, amend = 0, allow;
 
        if (opts->no_commit) {
                /*
@@ -720,7 +852,7 @@ static int do_pick_commit(enum todo_command command, struct commit *commit,
        else
                parent = commit->parents->item;
 
-       if (opts->allow_ff &&
+       if (opts->allow_ff && !is_fixup(command) &&
            ((parent && !hashcmp(parent->object.oid.hash, head)) ||
             (!parent && unborn)))
                return fast_forward_to(commit->object.oid.hash, head, unborn, opts);
@@ -779,6 +911,27 @@ static int do_pick_commit(enum todo_command command, struct commit *commit,
                }
        }
 
+       if (is_fixup(command)) {
+               if (update_squash_messages(command, commit, opts))
+                       return -1;
+               amend = 1;
+               if (!final_fixup)
+                       msg_file = rebase_path_squash_msg();
+               else if (file_exists(rebase_path_fixup_msg())) {
+                       cleanup_commit_message = 1;
+                       msg_file = rebase_path_fixup_msg();
+               } else {
+                       const char *dest = git_path("SQUASH_MSG");
+                       unlink(dest);
+                       if (copy_file(dest, rebase_path_squash_msg(), 0666))
+                               return error(_("could not rename '%s' to '%s'"),
+                                            rebase_path_squash_msg(), dest);
+                       unlink(git_path("MERGE_MSG"));
+                       msg_file = dest;
+                       edit = 1;
+               }
+       }
+
        if (!opts->strategy || !strcmp(opts->strategy, "recursive") || command == TODO_REVERT) {
                res = do_recursive_merge(base, next, base_label, next_label,
                                         head, &msgbuf, opts);
@@ -834,8 +987,13 @@ static int do_pick_commit(enum todo_command command, struct commit *commit,
                goto leave;
        }
        if (!opts->no_commit)
-               res = run_git_commit(opts->edit ? NULL : git_path_merge_msg(),
-                                    opts, allow, opts->edit, 0, 0);
+               res = run_git_commit(msg_file, opts, allow, edit, amend,
+                                    cleanup_commit_message);
+
+       if (!res && final_fixup) {
+               unlink(rebase_path_fixup_msg());
+               unlink(rebase_path_squash_msg());
+       }
 
 leave:
        free_message(commit, &msg);
@@ -976,7 +1134,7 @@ static int parse_insn_buffer(char *buf, struct todo_list *todo_list)
 {
        struct todo_item *item;
        char *p = buf, *next_p;
-       int i, res = 0;
+       int i, res = 0, fixup_okay = file_exists(rebase_path_done());
 
        for (i = 1; *p; i++, p = next_p) {
                char *eol = strchrnul(p, '\n');
@@ -991,8 +1149,16 @@ static int parse_insn_buffer(char *buf, struct todo_list *todo_list)
                if (parse_insn_line(item, p, eol)) {
                        res = error(_("invalid line %d: %.*s"),
                                i, (int)(eol - p), p);
-                       item->command = -1;
+                       item->command = TODO_NOOP;
                }
+
+               if (fixup_okay)
+                       ; /* do nothing */
+               else if (is_fixup(item->command))
+                       return error(_("cannot '%s' without a previous commit"),
+                               command_to_string(item->command));
+               else if (!is_noop(item->command))
+                       fixup_okay = 1;
        }
        if (!todo_list->nr)
                return error(_("no commits parsed."));
@@ -1435,6 +1601,20 @@ static int error_with_patch(struct commit *commit,
        return exit_code;
 }
 
+static int error_failed_squash(struct commit *commit,
+       struct replay_opts *opts, int subject_len, const char *subject)
+{
+       if (rename(rebase_path_squash_msg(), rebase_path_message()))
+               return error(_("could not rename '%s' to '%s'"),
+                       rebase_path_squash_msg(), rebase_path_message());
+       unlink(rebase_path_fixup_msg());
+       unlink(git_path("MERGE_MSG"));
+       if (copy_file(git_path("MERGE_MSG"), rebase_path_message(), 0666))
+               return error(_("could not copy '%s' to '%s'"),
+                            rebase_path_message(), git_path("MERGE_MSG"));
+       return error_with_patch(commit, subject, subject_len, opts, 1, 0);
+}
+
 static int do_exec(const char *command_line)
 {
        const char *child_argv[] = { NULL, NULL };
@@ -1475,6 +1655,21 @@ static int do_exec(const char *command_line)
        return status;
 }
 
+static int is_final_fixup(struct todo_list *todo_list)
+{
+       int i = todo_list->current;
+
+       if (!is_fixup(todo_list->items[i].command))
+               return 0;
+
+       while (++i < todo_list->nr)
+               if (is_fixup(todo_list->items[i].command))
+                       return 0;
+               else if (!is_noop(todo_list->items[i].command))
+                       break;
+       return 1;
+}
+
 static int pick_commits(struct todo_list *todo_list, struct replay_opts *opts)
 {
        int res = 0;
@@ -1490,9 +1685,15 @@ static int pick_commits(struct todo_list *todo_list, struct replay_opts *opts)
                struct todo_item *item = todo_list->items + todo_list->current;
                if (save_todo(todo_list, opts))
                        return -1;
-               if (item->command <= TODO_EDIT) {
+               if (is_rebase_i(opts)) {
+                       unlink(rebase_path_message());
+                       unlink(rebase_path_author_script());
+                       unlink(rebase_path_stopped_sha());
+                       unlink(rebase_path_amend());
+               }
+               if (item->command <= TODO_SQUASH) {
                        res = do_pick_commit(item->command, item->commit,
-                                       opts);
+                                       opts, is_final_fixup(todo_list));
                        if (item->command == TODO_EDIT) {
                                struct commit *commit = item->commit;
                                if (!res)
@@ -1503,6 +1704,12 @@ static int pick_commits(struct todo_list *todo_list, struct replay_opts *opts)
                                        item->arg, item->arg_len, opts, res,
                                        !res);
                        }
+                       if (res && is_fixup(item->command)) {
+                               if (res == 1)
+                                       intend_to_amend();
+                               return error_failed_squash(item->commit, opts,
+                                       item->arg_len, item->arg);
+                       }
                } else if (item->command == TODO_EXEC) {
                        char *end_of_arg = (char *)(item->arg + item->arg_len);
                        int saved = *end_of_arg;
@@ -1601,7 +1808,7 @@ static int single_pick(struct commit *cmit, struct replay_opts *opts)
 {
        setenv(GIT_REFLOG_ACTION, action_name(opts), 0);
        return do_pick_commit(opts->action == REPLAY_PICK ?
-               TODO_PICK : TODO_REVERT, cmit, opts);
+               TODO_PICK : TODO_REVERT, cmit, opts, 0);
 }
 
 int sequencer_pick_revisions(struct replay_opts *opts)