How to rebase branch against master after parent is squashed and committed?
Asked Answered
T

1

16

I use an optimistic work-flow in Gitlab, which assumes the majority of my merge requests will be accepted without change. The flow looks like this:

  1. Submit a merge request for branch cool-feature-A
  2. Create a new branch, based on cool-feature-A, called cool-feature-B. Begin developing on this branch.
  3. A colleague approves my merge request for cool-feature-A.
  4. I rebase cool-feature-B against master (which is painless) and continue development.

The problem occurs if a colleague does a squash merge at step 3. This rewrites large chunks of history which are present in cool-feature-B. When I get to step 4, there is a world of merge pain ahead of me.

How can I avoid this happening?

Traumatize answered 28/6, 2019 at 9:48 Comment(4)
To my knowledge, true merge and squash merge don't make any difference to the code. The 2 methods are expected to result in the same code, with the same criteria to resolve conflicts if any.Genteelism
@Genteelism I've added some more detail to the question. There is definitely a difference during a rebase if squashing has occurred.Traumatize
This sounds like a people problem, not a technical problem. But have you tried git checkout cool-feature-B && git rebase --fork-point master? I've had very good results with --fork-point when rebasing several dependent branches (e.g. cool-feature-A, cool-feature-B, cool-feature-C) though admittedly I haven't tried it with a squashed merge.Cowart
@Chris: --fork-point uses the branch's upstream's reflog, so for this to work you'd have have had commits B and C on the upstream (and have seen them go into your own repository), none of which is going to hold in the general case here.Perplexity
P
34

Essentially, you have to tell Git: I want to rebase cool-feature-B against master, but I want to copy a different set of commits than the ones you'd normally compute here. The easiest way to do this is going to be to use --onto. That is, normally you run, as you said:

git checkout cool-feature-B
git rebase master

but you'll need to do:

git rebase --onto master cool-feature-A

before you delete your own branch-name / label cool-feature-A.

You can always do this, even if they use a normal merge. It won't hurt to do it, except in that you have to type a lot more and remember (however you like) that you'll want this --onto, which needs the right name or hash ID, later.

(If you can get the hash ID of the commit to which cool-feature-A points at the moment into the reflog for your upstream of cool-feature-B, you can use the --fork-point feature to make Git compute this for you automatically later, provided the reflog entry in question has not expired. But that's probably harder, in general, than just doing this manually. Plus it has that whole "provided" part.)

Why this is the case

Let's start, as usual, with the graph drawings. Initially, you have this setup in your own repository:

...--A   <-- master, origin/master
      \
       B--C   <-- cool-feature-A
           \
            D   <-- cool-feature-B

Once you have run git push origin cool-feature-A cool-feature-B, you have:

...--A   <-- master, origin/master
      \
       B--C   <-- cool-feature-A, origin/cool-feature-A
           \
            D   <-- cool-feature-B, origin/cool-feature-B

Note that all we did here was add two origin/ names (two remote-tracking names): in their repository, over at origin, they acquired commits B, C, and D and they set their cool-feature-A and cool-feature-B names to remember commits C and D respectively, so your own Git added your origin/ variants of these two names.

If they (whoever "they" are—the people who control the repository over on origin) do a fast-forward merge, they'll slide their master up to point to commit C. (Note that the GitHub web interface has no button to make a fast-forward merge. I have not used GitLab's web interface; it may be different in various ways.) If they force a real merge—which is what the GitHub web page "merge this now" clicky button does by default; again, I don't know what GitLab does here—they'll make a new merge commit E:

...--A------E
      \    /
       B--C
           \
            D

(here I've deliberately stripped off all the names as theirs don't quite match yours). They'll presumably delete (or maybe even never actually created) their cool-feature-A name. Either way, you can have your own Git fast-forward your master name, while updating your origin/* names:

...--A------E   <-- master, origin/master
      \    /
       B--C   <-- cool-feature-A [, origin/cool-feature-A if it exists]
           \
            D   <-- cool-feature-B, origin/cool-feature-B

or:

...--A
      \
       B--C   <-- cool-feature-A, master, origin/master [, origin/cool-feature-A]
           \
            D   <-- cool-feature-B, origin/cool-feature-B

Whether or not you delete your name cool-feature-A now—for convenience in later drawings, let's say you do—if you run:

git checkout cool-feature-B
git rebase master

Git will now enumerate the list of commits reachable from DD, then C, then B, and so on—and subtract away the list of commits reachable from master: E (if it exists), then A (if E exists) and C (whether or not E exists), then B, and so on. The result of the subtraction is just commit D.

Your Git now copies the commits in this list so that the new copies come after the tip of master: i.e., after E if they made a real merge, or after C if they did a fast-forward merge. So Git either copies D to a new commit D' that comes after E:

              D'   <-- cool-feature-B
             /
...--A------E   <-- master, origin/master
      \    /
       B--C
           \
            D   <-- origin/cool-feature-B

or it leaves D alone because it already comes after C (so there's nothing new to draw).

The tricky parts occur when they use whatever GitLab's equivalent is of GitHub's "squash and merge" or "rebase and merge" clicky buttons. I'll skip the "rebase and merge" case (which usually causes no problems because Git's rebase checks patch-IDs too) and go straight for the hard case, the "squash and merge". As you correctly noted, this makes a new and different, single, commit. When you bring that new commit into your own repository—e.g., after git fetch—you have:

...--A--X   <-- master, origin/master
      \
       B--C   <-- cool-feature-A [, origin/cool-feature-A]
           \
            D   <-- cool-feature-B, origin/cool-feature-B

where X is the result of making a new commit whose snapshot would match merge E (if they were to make merge E), but whose (single) parent is existing commit A. So the history—the list of commits enumerated by working backwards—from X is just X, then A, then whatever commits come before A.

If you run a regular git rebase master while on cool-feature-B, Git:

  1. enumerates the commits reachable from D: D, C, B, A, ...;
  2. enumerates the commits reachable from X: X, A, ...;
  3. subtracts the set in step 2 from the set in step 1, leaving D, C, B;
  4. copies those commits (in un-backwards-ized order) so that they come after X.

Note that steps 2 and 3 both use the word master to find the commits: the commits to copy, for step 2, are those that aren't reachable from master. The place to put the copies, for step 3, is after the tip of master.

But if you run:

git rebase --onto master cool-feature-A

you have Git use different items in steps 2 and 3:

  • The list of commits to copy, from step 2, comes from cool-feature-A..cool-feature-B: subtract C-B-A-... from D-C-B-A-.... That leaves just commit D.
  • The place to put the copies, in step 3, comes from --onto master: put them after X.

So now Git only copies D to D', after which git rebase yanks the name cool-feature-B over to point to D':

          D'  <-- cool-feature-B
         /
...--A--X   <-- master, origin/master
      \
       B--C   <-- cool-feature-A [, origin/cool-feature-A]
           \
            D   <-- origin/cool-feature-B

which is what you wanted.

Had they—the people in control of the GitLab repo—used a true merge or a fast-forward not-really-a-merge-at-all, this would all still work: you would have your Git enumerate D-on-backwards but remove C-on-backwards from the list, leaving just D to copy; and then Git would copy D so that it comes after either E (the true merge case) or C (the fast-forward case). The fast-forward case, "copy D so that it comes where it already is", would cleverly not bother to copy at all and just leave everything in place.

Perplexity answered 28/6, 2019 at 16:36 Comment(3)
What's your next step to update origin/cool-feature-B to point to the new local branch? Is git push -f safe to do here?Acknowledgment
@zeroliu: remember that origin/cool-feature-B is your Git's memory of some other Git's cool-feature-B name. So you must convince that other Git to change its name—more precisely, change the hash ID stored in its branch name—and to achieve that you will in general need some sort of forced push. The --force-with-lease option is a good way to do that in the case where you're concerned that someone else might also be trying to update the other Git's branch names; if you're sure that no one else is working on it now, git push -f is simpler.Perplexity
Would this still work if there were changes in cool-feature-A that weren't in master that cool-feature-B made changes on top of?Careworn

© 2022 - 2024 — McMap. All rights reserved.