3

Git rebase doesn’t seem to be working the way I’d expect, based on my understanding of rebasing, and based on how I’ve seen rebasing work in Mercurial. I’ve generated an example to illustrate the strange behavior, and am hoping someone can explain why Git is behaving the way it is. Consider this state of the DAG:

Starting State

In this scenario, I’ve made commits f7 and f8 on master, but I want to move these new commits onto the feature branch instead. i.e. I did the commits on the wrong branch, and want to correct the mistake. I can perform the rebase from SourceTree:

Choose Rebase

SourceTree confirms my intention:

Rebase Confirm

But the result is not at all what I’d expect:

Rebase Result

Although the nodes are in the correct position in the DAG, the branch heads are incorrect! By rebasing f7 and f8 onto f6, I expect to see master reset to its position on origin, and expect feature/AAA-1 to advance to f8. Like this:

Rebase Corrected

This is the behavior I would expect, based on rebasing in Mercurial, and just generally based on what rebasing is doing. Why did Git do this, and how do I make it behave correctly?

Zombo
  • 1
  • 62
  • 391
  • 407
Ken Mason
  • 712
  • 2
  • 7
  • 16
  • 1
    "how do I make it behave correctly?" It is usually a good idea to start by assuming that _you_ are wrong, not the tool. You simply have a different mental model of what rebase does (which may very well be correct in the Mercurial world) compared to the (equally valid) idea of rebase in the Git world. – ChrisGPT was on strike Oct 14 '15 at 01:16
  • Thanks, Mr. Internet Troll for adding an unproductive comment to this question :) – Ken Mason Oct 14 '15 at 17:15
  • On the contrary, I believe my comment _is_ productive. If you fight against your tool, insisting that it is doing things wrong, you will continue to have problems. Branches are fundamentally different things in Git and Mercurial. Git isn't _wrong_; it's simply _different_. Mercurial works differently from Subversion, which works differently from Perforce… Are any of them more objectively "right" than the others? This change in mindset is important. – ChrisGPT was on strike Oct 14 '15 at 17:30
  • On the contrary, I've worked with CVS, SVN, SourceSafe, TFS, Perforce, Mercurial, Git, and Bazaar. I'm well-versed in these both conventional and DVCS concepts, and have contributed source code to more than one of them. I've encountered bugs, defects, and design flaws in almost all of them (no VCS is perfect after all), and on several occasions have contributed patches to resolve these issues. It is naiive for you to insist that the present behavior of Git is the absolutely correct behavior, or to think that Git is bug-free, and without room for improvement. This is a flaw in Git rebasing. – Ken Mason Oct 14 '15 at 17:49
  • "It is naiive for you to insist that the present behavior of Git is the absolutely correct behavior, or to think that Git is bug-free, and without room for improvement." I agree that this would be naïve, which is why I haven't said anything close to that. My point is that the Git model and the Mercurial model are different, and to ask how you can "make [Git] behave correctly", meaning "the way Mercurial does", is a poor choice of words. Nothing makes the Mercurial model objectively "correct". Unfortunately, this comment thread is getting a bit silly, so I'll stop here. – ChrisGPT was on strike Oct 14 '15 at 17:59
  • Why do you think git should assume that the commits relate to feature/AAA-1? That may be another feature based on it, for example. – max630 Oct 18 '15 at 06:08
  • Let's take more realistic example: at f6 there are "feature/AAA", "feature/BBB", "feature/CCC". Do you think it should advance *all* of them? – max630 Oct 18 '15 at 06:14

4 Answers4

5

Coming from Mercurial, you're expecting that a commit is permanently attached to a specific branch. That is, if you manage to extract some commit in isolation, you have something that says:

I am a commit on branch foo.
I change file bar.

Git does not work this way: a commit is independent of any branch (name), and in fact, branch names—labels, if you will—can be peeled away and stuck somewhere else willy-nilly. They have no use1 except to humans trying to interpret the mess.

In Mercurial, when you "rebase" some changeset(s), you (in effect at least) dump them out as diffs against their bases, then you change over to the other branch you want them on and make new commits on that other branch. Mercurial used to (maybe still does) call this first step, "grafting". These new commits are now permanently attached to (and only to) this other, different branch:

master:    f1 - f2 - f3 - f4 - f7 - f8
                             \
feature/AAA-1:                 f5 - f6

becomes:

master:    f1 - f2 - f3 - f4 - f7 - f8
                             \
feature/AAA-1:                 f5 - f6 - 9 - 10

At this point you can "safely undo" f7 and f8, taking them off the master line, and your rebase is finished with the copies only on the other branch.

Note that I draw the branch labels on the left here. This is safe because all commits are permanently stuck to their branches, so once a changeset is on the line of its branch, it's always on the line of its branch. The only time there's a violation of the "changeset goes on the (single) line of its branch" rule is for a merge, when a changeset attaches to (exactly) two branches: it sits on its main branch, but draws in a connection to the other branch.

In git, on the other hand, a commit can be considered to be "on" zero or more branches (there's no "exactly 1 or 2" constraint), and the set of branches a commit is "on" is dynamic as branch names can be added or removed at any time. (Note also that the word "branch" has at least two meanings in git.)

Git's rebase works very similarly to Mercurial's: it actually copies the commits. But there's one important difference to start: the copies aren't specifically "on" any branch (and in fact the rebase process operates on no branch, using what git calls a "detached HEAD"). Then, there's an even more important difference at the end.

As before we can start with a graph drawing, but this time I'll draw it a bit differently:

                     f7 <- f8   <-- master
                    /
f1 <- f2 <- f3 <- f4
                    \
                     f5 <- f6   <-- feature/AAA-1

This time, the labels are on the right, with arrows. The name master actually points directly to commit f8, and it's f8 that points back to f7 and f7 points back to f4 and so on.

What this means is that, right now, commits f1 through f4 are "on" both branches. With git, it's better to say that these commits are "contained in" (the history of) both branches. There's nothing in any of those commits to say which branch they were originally "made on": they carry their parent pointers, source tree IDs, and author and committer names (and timestamps etc), but no "source branch name". (Newcomers to git from hg often find this quite frustrating, I think.)

If you now ask git to rebase f7 and f8 onto feature/AAA-1, git will make copies of the two commits:

                     f7 <- f8
                    /
f1 <- f2 <- f3 <- f4
                    \
                     f5 <- f6 <- f7' <- f8'

(the ' marks, or f7prime and f8prime, mean these are copies of the originals—git cherry-picks, analogous to hg's grafts). But now we get to the key difference, the one that's tripping you up: git now "peels off" the original master label and makes it point to the tip-most new commit instead. This means the final graph looks like this:

                     f7 <- f8   [abandoned -- was master]
                    /
f1 <- f2 <- f3 <- f4
                    \
                     f5 <- f6   <-- feature/AAA-1
                             \
                              f7' <- f8'   <-- master

Mercurial can't do this: branch labels cannot be peeled off and shuffled around and re-pasted elsewhere. So it doesn't, and that's why its rebase works differently.

In git, what you want to do here is simply cherry-pick the two commits into the feature/AAA-1 branch, then remove them off the master branch:

$ git checkout feature/AAA-1
$ git cherry-pick master~2..master   # copy the commits
$ git checkout master
$ git reset --hard master~2          # back up over the originals

The idea here is that you're not rebasing master at all, and you're not even really rebasing your feature branch either: instead, you're just copying two commits into your feature branch, then removing them from master.


1This is a bit of an overstatement, since transfers between repositories—git fetch and git push—use branch and tag labels as well. Also, you need some references to commits to keep them alive, otherwise git's garbage collector will eventually reap them as "unreachable".

Community
  • 1
  • 1
torek
  • 448,244
  • 59
  • 642
  • 775
  • Thanks, you've well-described what Git is doing, and why. I think Git is doing it wrong, personally... When I tell Git to rebase f7 / f8, I would expect Git to rewind master to point to its previous position, then change f7 to point to f6 for its parent, then move feature/AAA-1 forward, since that branch has received new commits. This is not how Git is behaving, and this seems inconsistent with the concept of a rebase, regardless of the current behavior. – Ken Mason Oct 14 '15 at 00:38
  • Because git's branches are ephemeral, it *must* make some sort of rather arbitrary definition for "rebase". Git's definition is "find some part of the commit DAG, replay those commits onto some target commit, then move the current branch label." – torek Oct 14 '15 at 00:44
  • Yep, that seems accurate :) I think it's just a naiive implementation of a rebase. It's missing that "rewind the source" and "fast-forward the destination" steps, which is really what makes a rebase a rebase, imho. In any event, thanks for the answer. – Ken Mason Oct 14 '15 at 00:48
  • Thinking about this more overnight, and how you've explained Git's current behavior, I've realized that you wouldn't see this issue when you're rebasing your tracking branch onto your origin branch, because the resultant branch heads would be correct in that scenario. In every other scenario, however, the branch heads resulting from a rebase would be incorrect. It seems like the issue could be described better as "Rebasing doesn't work correctly in Git when rebasing across branches". – Ken Mason Oct 14 '15 at 17:19
0

It looks like you're not really interested in rebasing. You just want to move the branches to point to new heads. That's easy enough, in this case. I don't use SourceTree, but it's pretty simple to do in the CLI:

git checkout feature/AAA-1
git reset --hard master
git checkout master
git reset --hard origin/master
mipadi
  • 398,885
  • 90
  • 523
  • 479
0

Your expectation is correct, but I think that the confusion is just in terminology. From git-scm:

In this example, you’d run the following:

$ git checkout experiment
$ git rebase master
First, rewinding head to replay your work on top of it...
Applying: added staged command

It works by going to the common ancestor of the two branches (the one you’re on and the one you’re rebasing onto)...

Note the usage of "on" and "onto" in this case. In git, the branch you're rebasing onto is the branch that will be a subtree after the rebase. I suspect your usage of SourceTree is just backwards for git.

Community
  • 1
  • 1
Jeremy Fortune
  • 2,459
  • 1
  • 18
  • 21
0

The purpose why git rebase exists is: You committed something to feature/AAA, meanwhile somebody else pushed changes to origin/feature/AAA. Now feature/AAA (being current) and origin/feature/AAA are diverged. You could merge them, but project rules state that history should be linear. So instead you run git rebase, and your current branch feature/AAA becomes based on latest origin/feature/AAA.

Your case is different. Probably, there could be command to move commits from one branch to another, explicitly naming both, but it is not a rebase.

max630
  • 8,762
  • 3
  • 30
  • 55