Once upon a time, I dreamt of an interactive rebase that would not
flatten branch structure, but instead recreate the commit topology
faithfully.
My original attempt was --preserve-merges, but that design was so
limited that I did not even enable it in interactive mode.
Subsequently, it *was* enabled in interactive mode, with the predictable
consequences: as the --preserve-merges design does not allow for
specifying the parents of merge commits explicitly, all the new commits'
parents are defined *implicitly* by the previous commit history, and
hence it is *not possible to even reorder commits*.
This design flaw cannot be fixed. Not without a complete re-design, at
least. This patch series offers such a re-design.
Think of --rebase-merges as "--preserve-merges done right". It
introduces new verbs for the todo list, `label`, `reset` and `merge`.
For a commit topology like this:
A - B - C
\ /
D
the generated todo list would look like this:
# branch D
pick 0123 A
label branch-point
pick 1234 D
label D
reset branch-point
pick 2345 B
merge -C 3456 D # C
There are more patches in the pipeline, based on this patch series, but
left for later in the interest of reviewable patch series: one mini
series to use the sequencer even for `git rebase -i --root`, and another
one to add support for octopus merges to --rebase-merges. And then one
to allow for rebasing merge commits in a smarter way (this one will need
a bit more work, though, as it can result in very complicated, nested
merge conflicts *very* easily).
Changes since v6:
- Reworded the REBASING MERGES section of the man page a bit (thanks, Martin &
Phillip!).
- The `reset` todo command now refuses to overwrite untracked files (thanks
Phillip!).
- The do_merge() function was prevented from leaking memory left and right.
- Added a nice advice for the case when todo commands were rescheduled.
- Refactored the way we get to the original line of any given todo command in
the todo list, simplifying even existing code to make it a lot more readable.
- Failed `label` and `reset` commands, as well as `merge` that failed before
even attempting to merge, are now rescheduled automatically (thanks
Phillip and Philip!).
- The do_merge() function no longer tries to commit when there are merge
conflicts (thanks Phillip!).
- When do_merge() failed to run the recursive merge, it no longer claims that
there were conflicts (thanks Phillip!).
- When the merge failed, we now write out the index before giving `rerere` a
chance (d'oh!).
Johannes Schindelin (15):
sequencer: avoid using errno clobbered by rollback_lock_file()
sequencer: make rearrange_squash() a bit more obvious
sequencer: refactor how original todo list lines are accessed
sequencer: offer helpful advice when a command was rescheduled
sequencer: introduce new commands to reset the revision
# This is a combination of 2 commits. # This is the 1st commit
message:
sequencer: fast-forward `merge` commands, if possible
rebase-helper --make-script: introduce a flag to rebase merges
rebase: introduce the --rebase-merges option
sequencer: make refs generated by the `label` command worktree-local
sequencer: handle post-rewrite for merge commands
rebase --rebase-merges: avoid "empty merges"
pull: accept --rebase=merges to recreate the branch topology
rebase -i: introduce --rebase-merges=[no-]rebase-cousins
rebase -i --rebase-merges: add a section to the man page
Phillip Wood (1):
rebase --rebase-merges: add test for --keep-empty
Stefan Beller (1):
git-rebase--interactive: clarify arguments
Documentation/config.txt | 8 +
Documentation/git-pull.txt | 5 +-
Documentation/git-rebase.txt | 147 ++++-
builtin/pull.c | 14 +-
builtin/rebase--helper.c | 13 +-
builtin/remote.c | 18 +-
contrib/completion/git-completion.bash | 4 +-
git-rebase--interactive.sh | 22 +-
git-rebase.sh | 16 +
refs.c | 3 +-
sequencer.c | 869 +++++++++++++++++++++++--
sequencer.h | 7 +
t/t3421-rebase-topology-linear.sh | 1 +
t/t3430-rebase-merges.sh | 221 +++++++
14 files changed, 1288 insertions(+), 60 deletions(-)
create mode 100755 t/t3430-rebase-merges.sh
base-commit: fe0a9eaf31dd0c349ae4308498c33a5c3794b293
Published-As: https://github.com/dscho/git/releases/tag/recreate-merges-v7
Fetch-It-Via: git fetch https://github.com/dscho/git recreate-merges-v7
Interdiff vs v6:
diff --git a/Documentation/git-rebase.txt b/Documentation/git-rebase.txt
index be946de2efb..0ff83b62821 100644
--- a/Documentation/git-rebase.txt
+++ b/Documentation/git-rebase.txt
@@ -849,14 +849,18 @@ merge -C 6f5e4d report-a-bug # Merge 'report-a-bug'
In contrast to a regular interactive rebase, there are `label`, `reset` and
`merge` commands in addition to `pick` ones.
-The `label` command puts a label to whatever will be the current
-revision when that command is executed. Internally, these labels are
-worktree-local refs that will be deleted when the rebase finishes or
-when it is aborted. That way, rebase operations in multiple worktrees
-linked to the same repository do not interfere with one another.
-
-The `reset` command is essentially a `git reset --hard` to the specified
-revision (typically a previously-labeled one).
+The `label` command associates a label with the current HEAD when that
+command is executed. These labels are created as worktree-local refs
+(`refs/rewritten/<label>`) that will be deleted when the rebase
+finishes. That way, rebase operations in multiple worktrees linked to
+the same repository do not interfere with one another. If the `label` command
+fails, it is rescheduled immediately, with a helpful message how to proceed.
+
+The `reset` command is essentially a `git read-tree -m -u` (think: `git
+reset --hard`, but refusing to overwrite untracked files) to the
+specified revision (typically a previously-labeled one). If the `reset`
+command fails, it is rescheduled immediately, with a helpful message how to
+proceed.
The `merge` command will merge the specified revision into whatever is
HEAD at that time. With `-C <original-commit>`, the commit message of
@@ -864,13 +868,16 @@ the specified merge commit will be used. When the `-C`
is changed to
a lower-case `-c`, the message will be opened in an editor after a
successful merge so that the user can edit the message.
+If a `merge` command fails for any reason other than merge conflicts (i.e.
+when the merge operation did not even start), it is rescheduled immediately.
+
At this time, the `merge` command will *always* use the `recursive`
merge strategy, with no way to choose a different one. To work around
this, an `exec` command can be used to call `git merge` explicitly,
using the fact that the labels are worktree-local refs (the ref
`refs/rewritten/onto` would correspond to the label `onto`).
-Note: the first command (`reset onto`) labels the revision onto which
+Note: the first command (`label onto`) labels the revision onto which
the commits are rebased; The name `onto` is just a convention, as a nod
to the `--onto` option.
diff --git a/sequencer.c b/sequencer.c
index 809df1ce484..3c7bb5d3fd8 100644
--- a/sequencer.c
+++ b/sequencer.c
@@ -1925,6 +1925,23 @@ static int count_commands(struct todo_list *todo_list)
return count;
}
+static int get_item_line_offset(struct todo_list *todo_list, int index)
+{
+ return index < todo_list->nr ?
+ todo_list->items[index].offset_in_buf : todo_list->buf.len;
+}
+
+static const char *get_item_line(struct todo_list *todo_list, int index)
+{
+ return todo_list->buf.buf + get_item_line_offset(todo_list, index);
+}
+
+static int get_item_line_length(struct todo_list *todo_list, int index)
+{
+ return get_item_line_offset(todo_list, index + 1)
+ - get_item_line_offset(todo_list, index);
+}
+
static ssize_t strbuf_read_file_or_whine(struct strbuf *sb, const char *path)
{
int fd;
@@ -2299,29 +2316,27 @@ static int save_todo(struct todo_list *todo_list,
struct replay_opts *opts)
fd = hold_lock_file_for_update(&todo_lock, todo_path, 0);
if (fd < 0)
return error_errno(_("could not lock '%s'"), todo_path);
- offset = next < todo_list->nr ?
- todo_list->items[next].offset_in_buf : todo_list->buf.len;
+ offset = get_item_line_offset(todo_list, next);
if (write_in_full(fd, todo_list->buf.buf + offset,
todo_list->buf.len - offset) < 0)
return error_errno(_("could not write to '%s'"), todo_path);
if (commit_lock_file(&todo_lock) < 0)
return error(_("failed to finalize '%s'"), todo_path);
- if (is_rebase_i(opts)) {
- const char *done_path = rebase_path_done();
- int fd = open(done_path, O_CREAT | O_WRONLY | O_APPEND, 0666);
- int prev_offset = !next ? 0 :
- todo_list->items[next - 1].offset_in_buf;
+ if (is_rebase_i(opts) && next > 0) {
+ const char *done = rebase_path_done();
+ int fd = open(done, O_CREAT | O_WRONLY | O_APPEND, 0666);
+ int ret = 0;
- if (fd >= 0 && offset > prev_offset &&
- write_in_full(fd, todo_list->buf.buf + prev_offset,
- offset - prev_offset) < 0) {
- close(fd);
- return error_errno(_("could not write to '%s'"),
- done_path);
- }
- if (fd >= 0)
- close(fd);
+ if (fd < 0)
+ return 0;
+ if (write_in_full(fd, get_item_line(todo_list, next - 1),
+ get_item_line_length(todo_list, next - 1))
+ < 0)
+ ret = error_errno(_("could not write to '%s'"), done);
+ if (close(fd) < 0)
+ ret = error_errno(_("failed to finalize '%s'"), done);
+ return ret;
}
return 0;
}
@@ -2619,7 +2634,6 @@ static int do_reset(const char *name, int len, struct
replay_opts *opts)
unpack_tree_opts.fn = oneway_merge;
unpack_tree_opts.merge = 1;
unpack_tree_opts.update = 1;
- unpack_tree_opts.reset = 1;
if (read_cache_unmerged()) {
rollback_lock_file(&lock);
@@ -2671,6 +2685,17 @@ static int do_merge(struct commit *commit, const char
*arg, int arg_len,
static struct lock_file lock;
const char *p;
+ if (hold_locked_index(&lock, LOCK_REPORT_ON_ERROR) < 0) {
+ ret = -1;
+ goto leave_merge;
+ }
+
+ head_commit = lookup_commit_reference_by_name("HEAD");
+ if (!head_commit) {
+ ret = error(_("cannot merge without a current revision"));
+ goto leave_merge;
+ }
+
oneline_offset = arg_len;
merge_arg_len = strcspn(arg, " \t\n");
p = arg + merge_arg_len;
@@ -2688,19 +2713,10 @@ static int do_merge(struct commit *commit, const char
*arg, int arg_len,
strbuf_splice(&ref_name, 0, strlen("refs/rewritten/"), "", 0);
merge_commit = lookup_commit_reference_by_name(ref_name.buf);
}
- if (!merge_commit) {
- error(_("could not resolve '%s'"), ref_name.buf);
- strbuf_release(&ref_name);
- return -1;
- }
- if (hold_locked_index(&lock, LOCK_REPORT_ON_ERROR) < 0)
- return -1;
-
- head_commit = lookup_commit_reference_by_name("HEAD");
- if (!head_commit) {
- rollback_lock_file(&lock);
- return error(_("cannot merge without a current revision"));
+ if (!merge_commit) {
+ ret = error(_("could not resolve '%s'"), ref_name.buf);
+ goto leave_merge;
}
if (commit) {
@@ -2709,21 +2725,20 @@ static int do_merge(struct commit *commit, const char
*arg, int arg_len,
int len;
if (!message) {
- rollback_lock_file(&lock);
- return error(_("could not get commit message of '%s'"),
- oid_to_hex(&commit->object.oid));
+ ret = error(_("could not get commit message of '%s'"),
+ oid_to_hex(&commit->object.oid));
+ goto leave_merge;
}
write_author_script(message);
find_commit_subject(message, &body);
len = strlen(body);
- if (write_message(body, len, git_path_merge_msg(), 0) < 0) {
+ ret = write_message(body, len, git_path_merge_msg(), 0);
+ unuse_commit_buffer(commit, message);
+ if (ret) {
error_errno(_("could not write '%s'"),
git_path_merge_msg());
- unuse_commit_buffer(commit, message);
- rollback_lock_file(&lock);
- return -1;
+ goto leave_merge;
}
- unuse_commit_buffer(commit, message);
} else {
struct strbuf buf = STRBUF_INIT;
int len;
@@ -2742,14 +2757,13 @@ static int do_merge(struct commit *commit, const char
*arg, int arg_len,
len = buf.len;
}
- if (write_message(p, len, git_path_merge_msg(), 0) < 0) {
+ ret = write_message(p, len, git_path_merge_msg(), 0);
+ strbuf_release(&buf);
+ if (ret) {
error_errno(_("could not write '%s'"),
git_path_merge_msg());
- strbuf_release(&buf);
- rollback_lock_file(&lock);
- return -1;
+ goto leave_merge;
}
- strbuf_release(&buf);
}
/*
@@ -2777,10 +2791,10 @@ static int do_merge(struct commit *commit, const char
*arg, int arg_len,
!commit->parents->next->next &&
!oidcmp(&commit->parents->next->item->object.oid,
&merge_commit->object.oid)) {
- strbuf_release(&ref_name);
rollback_lock_file(&lock);
- return fast_forward_to(&commit->object.oid,
- &head_commit->object.oid, 0, opts);
+ ret = fast_forward_to(&commit->object.oid,
+ &head_commit->object.oid, 0, opts);
+ goto leave_merge;
}
write_message(oid_to_hex(&merge_commit->object.oid), GIT_SHA1_HEXSZ,
@@ -2790,10 +2804,9 @@ static int do_merge(struct commit *commit, const char
*arg, int arg_len,
bases = get_merge_bases(head_commit, merge_commit);
if (bases && !oidcmp(&merge_commit->object.oid,
&bases->item->object.oid)) {
- strbuf_release(&ref_name);
- rollback_lock_file(&lock);
+ ret = 0;
/* skip merging an ancestor of HEAD */
- return 0;
+ goto leave_merge;
}
for (j = bases; j; j = j->next)
@@ -2807,28 +2820,40 @@ static int do_merge(struct commit *commit, const char
*arg, int arg_len,
o.buffer_output = 2;
ret = merge_recursive(&o, head_commit, merge_commit, reversed, &i);
- if (!ret)
- rerere(opts->allow_rerere_auto);
if (ret <= 0)
fputs(o.obuf.buf, stdout);
strbuf_release(&o.obuf);
if (ret < 0) {
- strbuf_release(&ref_name);
- rollback_lock_file(&lock);
- return error(_("conflicts while merging '%.*s'"),
- merge_arg_len, arg);
+ error(_("could not even attempt to merge '%.*s'"),
+ merge_arg_len, arg);
+ goto leave_merge;
}
+ /*
+ * The return value of merge_recursive() is 1 on clean, and 0 on
+ * unclean merge.
+ *
+ * Let's reverse that, so that do_merge() returns 0 upon success and
+ * 1 upon failed merge (keeping the return value -1 for the cases where
+ * we will want to reschedule the `merge` command).
+ */
+ ret = !ret;
if (active_cache_changed &&
write_locked_index(&the_index, &lock, COMMIT_LOCK)) {
- strbuf_release(&ref_name);
- return error(_("merge: Unable to write new index file"));
+ ret = error(_("merge: Unable to write new index file"));
+ goto leave_merge;
}
+
rollback_lock_file(&lock);
+ if (ret)
+ rerere(opts->allow_rerere_auto);
+ else
+ ret = run_git_commit(git_path_merge_msg(), opts,
+ run_commit_flags);
- ret = run_git_commit(git_path_merge_msg(), opts, run_commit_flags);
+leave_merge:
strbuf_release(&ref_name);
-
+ rollback_lock_file(&lock);
return ret;
}
@@ -2922,6 +2947,17 @@ static const char *reflog_message(struct replay_opts
*opts,
return buf.buf;
}
+static const char rescheduled_advice[] =
+N_("Could not execute the todo command\n"
+"\n"
+" %.*s"
+"\n"
+"It has been rescheduled; To edit the command before continuing, please\n"
+"edit the todo list first:\n"
+"\n"
+" git rebase --edit-todo\n"
+" git rebase --continue\n");
+
static int pick_commits(struct todo_list *todo_list, struct replay_opts *opts)
{
int res = 0;
@@ -2966,7 +3002,12 @@ static int pick_commits(struct todo_list *todo_list,
struct replay_opts *opts)
res = do_pick_commit(item->command, item->commit,
opts, is_final_fixup(todo_list));
if (is_rebase_i(opts) && res < 0) {
- /* Reschedule */
+reschedule:
+ advise(_(rescheduled_advice),
+ get_item_line_length(todo_list,
+ todo_list->current),
+ get_item_line(todo_list,
+ todo_list->current));
todo_list->current--;
if (save_todo(todo_list, opts))
return -1;
@@ -2990,7 +3031,7 @@ static int pick_commits(struct todo_list *todo_list,
struct replay_opts *opts)
intend_to_amend();
return error_failed_squash(item->commit, opts,
item->arg_len, item->arg);
- } else if (res && is_rebase_i(opts))
+ } else if (res && is_rebase_i(opts) && item->commit)
return res | error_with_patch(item->commit,
item->arg, item->arg_len, opts, res,
item->command == TODO_REWORD);
@@ -3016,13 +3057,17 @@ static int pick_commits(struct todo_list *todo_list,
struct replay_opts *opts)
/* `current` will be incremented below */
todo_list->current = -1;
}
- } else if (item->command == TODO_LABEL)
- res = do_label(item->arg, item->arg_len);
- else if (item->command == TODO_RESET)
- res = do_reset(item->arg, item->arg_len, opts);
- else if (item->command == TODO_MERGE) {
+ } else if (item->command == TODO_LABEL) {
+ if ((res = do_label(item->arg, item->arg_len)))
+ goto reschedule;
+ } else if (item->command == TODO_RESET) {
+ if ((res = do_reset(item->arg, item->arg_len, opts)))
+ goto reschedule;
+ } else if (item->command == TODO_MERGE) {
res = do_merge(item->commit, item->arg, item->arg_len,
item->flags, opts);
+ if (res < 0)
+ goto reschedule;
if (item->commit)
record_in_rewritten(&item->commit->object.oid,
peek_command(todo_list, 1));
@@ -4046,8 +4091,7 @@ int skip_unnecessary_picks(void)
oid = &item->commit->object.oid;
}
if (i > 0) {
- int offset = i < todo_list.nr ?
- todo_list.items[i].offset_in_buf : todo_list.buf.len;
+ int offset = get_item_line_offset(&todo_list, i);
const char *done_path = rebase_path_done();
fd = open(done_path, O_CREAT | O_WRONLY | O_APPEND, 0666);
@@ -4227,12 +4271,10 @@ int rearrange_squash(void)
continue;
while (cur >= 0) {
- int offset = todo_list.items[cur].offset_in_buf;
- int end_offset = cur + 1 < todo_list.nr ?
- todo_list.items[cur + 1].offset_in_buf :
- todo_list.buf.len;
- char *bol = todo_list.buf.buf + offset;
- char *eol = todo_list.buf.buf + end_offset;
+ const char *bol =
+ get_item_line(&todo_list, cur);
+ const char *eol =
+ get_item_line(&todo_list, cur + 1);
/* replace 'pick', by 'fixup' or 'squash' */
command = todo_list.items[cur].command;
diff --git a/t/t3430-rebase-merges.sh b/t/t3430-rebase-merges.sh
index ee006810573..f2de7059830 100755
--- a/t/t3430-rebase-merges.sh
+++ b/t/t3430-rebase-merges.sh
@@ -52,25 +52,24 @@ test_expect_success 'setup' '
git tag -m H H
'
-cat >script-from-scratch <<\EOF
-label onto
-
-# onebranch
-pick G
-pick D
-label onebranch
+test_expect_success 'create completely different structure' '
+ cat >script-from-scratch <<-\EOF &&
+ label onto
-# second
-reset onto
-pick B
-label second
+ # onebranch
+ pick G
+ pick D
+ label onebranch
-reset onto
-merge -C H second
-merge onebranch # Merge the topic branch 'onebranch'
-EOF
+ # second
+ reset onto
+ pick B
+ label second
-test_expect_success 'create completely different structure' '
+ reset onto
+ merge -C H second
+ merge onebranch # Merge the topic branch '\''onebranch'\''
+ EOF
test_config sequence.editor \""$PWD"/replace-editor.sh\" &&
test_tick &&
git rebase -i -r A &&
@@ -115,6 +114,17 @@ test_expect_success 'generate correct todo list' '
test_cmp expect output
'
+test_expect_success '`reset` refuses to overwrite untracked files' '
+ git checkout -b refuse-to-reset &&
+ test_commit dont-overwrite-untracked &&
+ git checkout @{-1} &&
+ : >dont-overwrite-untracked.t &&
+ echo "reset refs/tags/dont-overwrite-untracked" >script-from-scratch &&
+ test_config sequence.editor \""$PWD"/replace-editor.sh\" &&
+ test_must_fail git rebase -r HEAD &&
+ git rebase --abort
+'
+
test_expect_success 'with a branch tip that was cherry-picked already' '
git checkout -b already-upstream master &&
base="$(git rev-parse --verify HEAD)" &&
--
2.17.0.windows.1.4.g7e4058d72e3