3

I have a file that simply looks like this:

line 1
line 2
line 3
line 4

I have commits that have done 3 things:

  1. add a 5th line that says line 5
  2. capitalize the L in line 2
  3. make the number in line 2 22

I've done this in 3 separate commits, so I have the following graph:

*   F - add line 5
*   E - Merge branch 'branch' into 'master'
|\
| * D - change 2 to 22
* | C - capitalize line 2
|/
*   B - adding line numbers
*   A - Initial commit

My file now looks like this:

line 1
Line 22
line 3
line 4
line 5

Obviously, the merge at E contained a conflict that I resolved manually. I want to move F so that it falls after B (eventually squashing them, but that's a separate problem). So I did a git rebase -i --preserve-merges A, and the editor opened up, I did this:

pick B adding line numbers
pick F add line 5
pick C capitalize line 2
pick D change 2 to 22
pick E Merge branch 'branch'

The problem is after the rebase, I've lost commit F entirely. My graph now looks like this:

*   E - Merge branch 'branch' into 'master'
|\
| * D - change 2 to 22
* | C - capitalize line 2
|/
*   B - adding line numbers
*   A - Initial commit

F is just gone. It didn't get applied by the rebase. What went wrong, and how can I resolve it?

ewok
  • 20,148
  • 51
  • 149
  • 254
  • i guess it have something to do with preserve merges. Maybe the merge commit have to combine D and C. And not a branch where F allready has been applied. But it's only a guess. I'm pretty sure it would work if you don't want to keep the merge commit. – snap Feb 05 '18 at 16:54
  • I tried, and it works fine if you don't use `--preserve-merges`. It's not an answer, but [here](https://stackoverflow.com/a/15915431/147356) is a pretty in-depth overview of how rebase works in the context of `--preserve-merges`. – larsks Feb 05 '18 at 16:57
  • @larsks if I don't use `--preserve-merges`, I would have to manually do the merge again, right? not a huge problem here, but a big problem in a repo with a larger, more complex history. – ewok Feb 05 '18 at 16:58
  • I guess it depends what you're trying to accomplish. You would not lose the changes created by the merge commit. You would simply lose the metadata that "there was a merge here". – larsks Feb 05 '18 at 17:02
  • @larsks so in the past, when I've not used `--preserve-merges`, the merge commit gets lost and I have to manually redo the merge. in the context of this history, if I do `git rebase -i A` and don't use `--preserve-merges`, even if I make no changes at all to the history, the rebase will fail with a conflict – ewok Feb 05 '18 at 17:03
  • The rebase doesn't "fail". There *is* a conflict, and it needs to be resolved, but that's normal. – larsks Feb 05 '18 at 17:05
  • 1
    @ewok - The preserve merges option does *not* mean that the conflict resolution work is preserved; it only means that rebase tries to preserve the commit topology. Also, it is documented that the "preserve merges" option doesn't combine well with the "interactive" option for reordering commits. – Mark Adelsberger Feb 05 '18 at 17:06

2 Answers2

2

The prior comments and answers (including my comment) are factually correct as far as why what you're doing may not work, but probably aren't great as far as being useful answers.

First thing is, while the answer "it's a documented bug" may not be very satisfying, I would point out that the re-ordered TODO list in your example doesn't actually tell git what topology you're trying to reproduce. And that's the first clue that what you want to do won't be so easy, because how would you tell git what topology you want? How would you explain to git that you mean for C and D to both change parent from B to F, in a way that couldn't also be read as "put F before C, so that F' and D' each have B as parent"; or "put F before D, so that F' and C' each have B as parent"? Of course it turns out git did none of those things, which I suppose is the buggy bit. (That said, I'll bet it did rewrite F, but then made the parent of F', C', and D' all be B, which makes F' ultimately unreachable.)

Anyway, I think you mean to ask just: Can you get from

A -- B - C - E -- F <--(master)
      \     /
       - D -

to

A -- B -- F' - C' - E' <--(master)
            \      /
             - D' - 

without manually re-resolving the merge conflicts at E.

And the answer is, you can; but it won't be as simple as finding the right rebase options and issuing a single command.

The truth is rebase is designed to produce linear histories; its proponents tend to insist that a linear history is "easier to read" or "cleaner", and that this is more important than being "accurate" or "made up of commits that have been tested". Those statements are not objectively universal, but that is what the tool of rebase is designed to do. The preserve-merges option tries to let you have your cake and eat it to, but it has a lot of problems (not just the bug when using it with -i). It might happen to work for some simple cases, but in general trying to rebase through a merge you want to keep is likely to be a headache at best.

So what to do instead? Here's one way... Note that when referring to a commit I've given an expression that resolves to the correct commit in this example, rather than just using the placeholder letter.

git checkout master
git branch temp
git rebase -i master~4
// in the editor, move F after B and remove D

At this point you should have

       F' -- C' <--(master)
      /
A -- B - C - E -- F <--(temp)
      \     /
       - D -

Then you need to rebase D. (In this case, since it's a single commit, you could possibly do a cherry-pick instead.)

git checkout temp^^2
git checkout -b branch
git rebase --onto master^ master branch

At this point you have

         D' <--(branch)
        /
       F' -- C' <--(master)
      /
A -- B - C - E -- F <--(temp)
      \     /
       - D -

and you need to reproduce the merge. There are again several ways to look at this problem, but the simplifying observation is that your end content should match what you had at F in the original history. So a valid solution is

git checkout master
git reset --hard $(git commit-tree -p master -p branch -m "Merging branch into master" temp^{tree})

(For the -m you could give whatever commit message was originally at E.) And then you can delete the temp branch and, if you're done with it, the branch branch.

In more complex scenarios, this little cheat of grabbing the merge result TREE from an existing commit may not work, because maybe no existing commit matches the intended state. In that case I think your best bet would be to try git rerere (documented at https://git-scm.com/docs/git-rerere). This command's purpose is to record the resolution to a merge conflict so it can be reapplied if the same conflict is seen in a different merge, and I would think it could be applied here. (That said, I never use it since I disagree with the premises that lead to a workflow that routinely requires it, so I can't speak to exactly how well it will server here.)

I should add that if the merge were non-conflicting (and non-"evil"; i.e. the default merge strategy and options produce the desired result), and remain non-conflicting under the desired re-ordering, then the following procedure would work in place of the above:

    // first rewrite F
git checkout master
git checkout -b temp
git rebase --onto master~3 master^
    // now move master to exclude the original F
git checkout master
git reset --hard HEAD^
    // now move the rest of the history
git rebase --preserve-merges temp

So it may be that, with rerere in place, this (conceptually simpler) approach might work even in a conflicting case.

Mark Adelsberger
  • 42,148
  • 4
  • 35
  • 52
1

There is a warning inside the git documentation for rebase that the preserve-merges option should not be used together with the interactive mode. There is also a bug mentioned in the doc for this combination:

BUGS The todo list presented by --preserve-merges --interactive does not represent the topology of the revision graph. Editing commits and rewording their commit messages should work fine, but attempts to reorder commits tend to produce counterintuitive results.

For example, an attempt to rearrange...

See https://git-scm.com/docs/git-rebase

Community
  • 1
  • 1
snap
  • 1,598
  • 1
  • 14
  • 21
  • 1
    so is it possible to have a merge commit like this and move commit `F` to before the merge, without losing the fact that there was a merge? For instance, if I remove `--preserve-merges`, then `F` shows up, but I lose the merge commit, and instead I just get a single history. – ewok Feb 05 '18 at 17:16
  • 1
    @ewok yes this should work IMO. but maybe you get a conflict while rebasing. if this happen you can resolve it without getting a merge commit or something similar in the history. – snap Feb 05 '18 at 17:26
  • but I WANT the merge commit in my history. – ewok Feb 05 '18 at 17:31
  • @ewok why? isn't the new history after the rebase easier to understand? – snap Feb 05 '18 at 17:56
  • I think this is a stylistic preference, but I prefer wide graphs with lots of branches. When I merge a branch, I want to know that I merged a branch, so that I can easily look at the changes for that branch as opposed to the changes to another one. Keep in mind my goal here isn't to rebase `master` on top of `branch`, it's simply to move a single commit in `master` from after the merge to before it. – ewok Feb 05 '18 at 17:59
  • 1
    @ewok maybe you should reset your master before the merge. rebase both branches (ok in your case only one branch) and then make again a new merge to the master. it'a more manual work but it should result in a history which fits your requirement. – snap Feb 05 '18 at 18:09
  • How do I do that without losing commit `F`? Since `F` is after the merge, if I reset master to before the merge, I'd lose`F`, right? – ewok Feb 05 '18 at 18:11
  • 1
    right, but you still can cherry-pick the changes from F because the old commits and structure still exists. or maybe you can first rebase the branches and the reset and merge the master to avoid the need for cherry-picking. – snap Feb 05 '18 at 18:18
  • In particular, if the merge requires either conflict resolution or manual special-casing (the latter being called an *evil merge*), you must start the merge but not commit it—this happens automatically on conflicts, or you can use `--no-commit` as an argument—then replace the index contents with the desired merge result. The final merge, made by `git commit` or `git merge --continue`, will use whatever is in the index, so whatever you put in the index becomes the merge commit's tree. – torek Feb 05 '18 at 19:26