Besides matt's answer, it's worth considering what git rebase
is about in the first place. Why do we ever use git rebase
? There is only one real1 answer:
- We have some existing commit(s).
- We don't like something about these existing commits.
- So, we'd like to make new-and-improved replacement commits, that fix whatever it is we don't like about the existing commits.
Because git rebase
is essentially git cherry-pick
on steroids, and using git cherry-pick
allows us to alter commits during the copying process, we gain two abilities when we rebase:
First, the new commits often exist at a different "place" in history (in the Git commit graph). We may actually have to work harder to make them appear at the "same place": the default rebase action is to place the new, copied commits after the commit we select as the upstream
or --onto
argument, depending on how we run git rebase
. That's very often the latest commit on the target branch, which is very often not where things were when we started.
Second, using git rebase -i
and its edit
mode, or when Git rebase pauses with a merge conflict, we can or even must change the effect of the in-progress copy we had going at the time the rebase action paused. We make this change and resume the rebase, and the "copied" commit no longer does exactly the same thing as the original commit, and/or has a different commit message. (It's possible that the commit message alone is the one thing we want to change, in which case we can use the reword
action of git rebase -i
.)
In the end, though, the result of a rebase is a set of new-and-improved replacement commits, that fix something we didn't like about the original commits. (If the result of the rebase doesn't fix anything—or makes things worse—then we should undo the rebase, which is easy right after rebase finishes.2)
Once we're done with the rebase, it may be the case that a true merge is no longer necessary, even though such a merge was necessary before we started. This happens when one of the changes we make is to put the commits into a new "place in history". The git merge
command you run, at the end, may do what Git calls a fast-forward merge instead of a true merge. A fast-forward merge is not a merge at all (and I wish Git did not call it that).
Some people like this fast-forward effect. Some people don't; if you're in the "don't like it" group, you can use git merge --no-ff
to force Git to make a true merge, even if a fast-forward non-merge is possible. That's all a matter of personal and/or group preferences. If you or your group likes the fast-forward effect, though, the rebase itself may be required in order to enable the fast-forward effect. This may be why you are doing rebases.
1The unreal answers are: "because my boss told me to", "because that's what we've always done", and other kinds of cargo-cult programming. Those are answers, they're just not what I would consider good ones.
2Right after the rebase has finished, we can run git reset --hard ORIG_HEAD
and all is as if we never started the rebase in the first place. Or, if the rebase is going very badly in the middle, we can use git rebase --abort
, which has the same effect: it is as if we never started a rebase at all. There is a lot going on behind the scenes here, and files that aren't in Git may not be restored by this process, of course.
Update per question update
Let me draw, as text, the same pictures you drew. Well, almost the same.
We start with this:
...--G--H <-- somebranch (HEAD), origin/somebranch
in your own repository. You now add two new commits—we'll call these K
and L
, skipping over I
and J
to reserve them:
...--G--H <-- origin/somebranch
\
K--L <-- somebranch (HEAD)
At this point, you're ready to combine your work with any work someone else may have done, so you run git fetch origin
. This brings into your repository two new commits that someone else wrote. Let's draw them in now:
I--J <-- origin/somebranch
/
...--G--H
\
K--L <-- somebranch (HEAD)
This is analogous to your "before rebasing" picture, except that I'm using the same name on both sides (somebranch
), rather than the name main
. Your Git renames their branch names to make your remote-tracking names; that's why you have an origin/somebranch
at all, and that's where we picked up the two new commits.
In order to combine our work (K-L
) with their work (I-J
), we must make a choice: rebase, merge, or some combination of both. (Not many people do the last one as it is a fair bit of extra work, and it's usually important to get a barely-adequately-working answer as fast as possible, rather than a beautifully-crafted-internally answer next week. Nobody looks at the innards of software, the buyers just pay for getting it done yesterday!)
A regular merge is the simplest answer. Git will:
- compare the snapshot in commit
H
to that in commit J
to see what they changed;
- compare the snapshot in commit
H
to that in commit L
to see what we changed;
- do its best to combine these two sets of changes and apply the combined result to the snapshot in
H
.
This keeps our changes and adds theirs, or—equivalently—keeps their changes and adds ours. The resulting merge commit M
looks like this:
I--J <-- origin/somebranch
/ \
...--G--H M <-- somebranch (HEAD)
\ /
K--L
and since commit M
comes after commit J
, without losing any of their work in other words, as well as coming after commit L
, keeping our work too, we can now git push
commit M
to some shared repository over on origin
. (Commit M
will bring commits K-L
along with it, automatically.)
For those who dislike merges, though, we can instead, copy our commit K
to a new-and-improved commit K'
. We start by making a temporary branch name:
I--J <-- temp (HEAD), origin/somebranch
/
...--G--H
\
K--L <-- somebranch
We then have our Git copy the effect of commit K
—by comparing H
-vs-K
to see what we changed—and apply that to commit J
, where we are now. If all goes well, Git will take the merged results—this is a merge, just like git merge
—and make a new non-merge, ordinary commit, whose effect on J
is like K
's effect on H
. We'll call this new commit K'
, and re-use K
's commit message:
K' <-- temp (HEAD)
/
I--J <-- origin/somebranch
/
...--G--H
\
K--L <-- somebranch
We now need Git to do the same thing with commit L
. This time, the internal merge that git cherry-pick
uses will compare commit K
vs L
to figure out what we changed, and K
-vs-K'
to figure out what "they" changed. (That's really what they-and-we changed: K'
is I+J+K
, after all, with respect to where we all started at H
. But Git still refers to this as --theirs
.)
The result of this cherry-pick merge is, if all goes well, a new commit L'
:
K'-L' <-- temp (HEAD)
/
I--J <-- origin/somebranch
/
...--G--H
\
K--L <-- somebranch
The last part of a git rebase
consists of "peeling the branch name off" the old commit chain, K-L
in this case, and pasting it on the end of the new chain:
K'-L' <-- somebranch (HEAD)
/
I--J <-- origin/somebranch
/
...--G--H
\
K--L [abandoned]
If we strip out the [abandoned]
section, this is more or less the same as what you drew as your "after" picture.
This means you have the right picture.
Now, let's suppose that the goal of all of this work was to be able to make the name main
move forward. The name main
currently points to commit J
. That is, instead of origin/somebranch
in our picture, we should have this:
K'-L' <-- somebranch (HEAD)
/
I--J <-- main
/
...--G--H
The rebase has done its job: it copied K-L
, which came off H
, to new and improved commits that now come after J
. But the name main
has not moved.
Doing a:
git checkout main
git merge somebranch
tells Git to figure out whether a true merge is required, or not. A true merge is required when there's an actual set of branching commits, as there were with our "regular merge" example when we made commit M
. It's optional in this other case, where we could just "slide the name main
forward" (and up because of the kink in the drawing, which is only there because this is limited ASCII art).
The default action for a merge command where a fast-forward operation is possible is to do the fast-forward instead of the merge. The result is:
K'-L' <-- main (HEAD), somebranch
/
I--J
/
...--G--H
Note that HEAD
is attached to main
now, because we ran git checkout main
before we ran git merge somebranch
.
We don't have to do this in any sort of absolute sense, because Git does not care about branch names. We could just start using the name somebranch
as the main branch now, and even just delete the name main
entirely:
K'-L' <-- somebranch (HEAD)
/
I--J
/
...--G--H
But if we, as mere humans, can't hack that—and we probably can't—we should git checkout main
and have Git slide the name forward, and then maybe delete the name somebranch
instead:
K'-L' <-- main (HEAD)
/
I--J
/
...--G--H
and stop drawing the kinks too, and drop the prime marks from the copied K-and-L commits:
...--G--H--I--J--K--L <-- main (HEAD)
It now looks like we made commits K
and L
after, and while seeing, commits I-J
. In fact, we made the originals—not called K
and L
, and abandoned some time ago—before we had access to I-J
. But we're so sure that we don't need those originals any more that we're willing to make it impossible, or at least painful, to find them ever again.