I'm pretty sure this has already been asked and answered, but I can't find a good answer quickly, so I'll just write one. Note that this is different from Move the most recent commit(s) to a new branch with Git. You just need git rebase --onto
. There is, however, a lot to know about this.
Long
It helps, I think, to draw a picture—even if it's a simplified one—of the "before" and "after" results. Here is what you have now, drawn the way I prefer:
...--E--F <-- main
\
G--H <-- branch-A
\
I--J <-- branch-B (HEAD)
That is, we've represented each commit (Git is all about commits, which are identified by big ugly hash IDs) by a single uppercase letter. (Git uses big long hexadecimal numbers—those hash IDs—because every commit in the universe must get its own unique number. Using uppercase letters, as I do, we'd run out after 26 or so commits.) The attached HEAD
, in parentheses, just tells us which branch we have checked out (via git checkout
or git switch
).
The newer commits appear towards the right. Each commit connects (backwards, though I've drawn the arrows without the proper arrowheads) to a previous commit. A branch name like main
or branch-B
simply contains the actual hash ID of the last commit that is to be considered part of that branch.
Every earlier commit, obtained by starting at the last one and working backwards, is "on" that branch, including commits that are on other branches too. So here, every commit is on branch-B
, but only the commits up through H
are on branch-A
and only those up through F
are on main
. Commit E
is on all three branches; commit G
is on two; and commit I
is only on branch-B
.
You have discovered that there is a problem in some of the commits that are on both branch-A
and branch-B
. You assert (or assume, or have checked) that there is no problem in your commits that are only on branch-B
. So now you wish to somehow change the picture above to look more like this:
I--J <-- branch-B (HEAD)
/
...--E--F <-- main
\
G--H <-- branch-A
You can't actually get this but you can get something Almost As Good Or Maybe Even Better, which looks instead like this:
I'-J' <-- branch-B (HEAD)
/
...--E--F <-- main
\
G--H <-- branch-A
\
I--J [abandoned]
Commits I'
and J'
are copies of original commits I
and J
, with a number of differences:
- Commit
I'
connects, backwards, to commit F
, not to commit H
.
- Commit
I'
has, as its snapshot, the files from commit F
plus the changes you made when you went from commit H
to your commit I
. That is, you make the same changes, but to a different base.
- Likewise, commit
J'
connects backwards to I'
...
- ... and has the same changes as there are from
I
to J
, but to a different base.
Since Git commits that are "abandoned" like this—that have no branch name by which to find them—become mostly invisible, a casual inspection of the revamped repository shows the same number of commits that seem to do the same things but now your commits, on your branch, don't contain the mistakes made in the commits on branch-A
. A closer, more careful look reveals that these commits that are solely on branch-B
have different hash ID numbers than the originals, showing that they are copies.
There are two ways to achieve the result. One is slow and requires a lot of work on your part, and one makes Git do all of that work by itself, so that it's quick and easy. It's a good idea to understand the slow method first though, because sometimes, the copying step for any given commit has a glitch.
Method 1: git cherry-pick
Let's draw the starting setup again:
...--E--F <-- main
\
G--H <-- branch-A
\
I--J <-- branch-B (HEAD)
We need a new branch, new-and-improved-B
perhaps, that points to commit F
. So we make that:
git checkout -b new-and-improved-B main # or git switch -c etc
which gives us this:
...--E--F <-- main, new-and-improved-B (HEAD)
\
G--H <-- branch-A
\
I--J <-- branch-B
We also use git log
to find the hash IDs of commits I
and J
. If there are more commits to be copied, we find all the hash IDs. We save them somewhere—maybe in a file, or jot them down on scratch paper, or whatever. We make sure this list is in the right order: commit I
first, then commit J
.
Now we're ready to start copying. We run:
git cherry-pick <hash-of-I>
This tells Git to go look at commit I
, find its parent commit—H
—and compare the contents of H
and I
. It should then apply whatever changes are required to get from H
to I
, adding them to whatever changes are required to back down from H
to F
(i.e., take out the bad stuff from branch-A
).
You can think of this as adding the H-vs-I changes to F, but that only works when there are no conflicts between what you added, and what we need to take away. A cherry-pick is, internally, actually a merge operation. Quite often, there aren't any merge conflicts, and this "can think of" part works fine. But if you get a merge conflict from this operation, remember that Git is combining the addition of your changes—to get from H
to I
—with "undo their changes" to get from H
back to F
.
Moreover, because HEAD
, via new-and-improved-B
, selects commit F
, Git thinks of the H
-to-F
changes as "our" changes, and the H
-to-I
changes as "their" changes. So when you go to resolve any conflict, remember that what Git calls "theirs" is really your work, at this point.
If all goes well, Git will make a new commit on its own, which we will call I'
to indicate that it is a copy of I
. The commit message for new commit I'
, Git just copies from I
. (You can choose to edit it, or not, when you run the git cherry-pick
command, by adding --edit
.)
I' <-- new-and-improved-B (HEAD)
/
...--E--F <-- main
\
G--H <-- branch-A
\
I--J <-- branch-B
If things don't go well—if you have a merge conflict—Git will stop and make you clean up the mess. For details on how to do that, see any instructions about cleaning up a merge conflict with git merge
: the method is the same. Just remember that --theirs
means your commit I
, and --ours
means ... well, whoever's commit F
is: Git is trying to add "undo the branch-A stuff" to "do the commit-I stuff". Eventually, once you have fixed up the mess, you will run git cherry-pick --continue
and Git will go ahead and make commit I'
, from your fixed-up resolution.
Now that we have commit I'
, we repeat this for all the remaining commits. If there's just one commit remaining—J
—we have just one more cherry-pick to run; if there are many, we run however many there are. As with I
, this can have a merge conflict: perhaps the I
-to-J
changes don't mix well with the "back out all the branch-A stuff from I
" changes.
If you do have a conflict, remember that --theirs
, at this point, means commit J
(the one you are copying) and --ours
means commit I'
. I'
is the one you just made with the previous cherry-pick. It may happen that you have to resolve the same conflict again. It's not all that common, but this is one of the painful aspects of repeated cherry-picking.
Once you're all done, you will have copies of each of your commits:
I'-J' <-- new-and-improved-B (HEAD)
/
...--E--F <-- main
\
G--H <-- branch-A
\
I--J <-- branch-B
You now have to do a few last things. In particular, you need to convince Git to take the name branch-B
away from commit J
and make it point to commit J'
instead, and then you probably want to check out branch-B
to attach HEAD
there and get rid of the temporary branch.
You can do the first two parts of this with one command:
git checkout -B branch-B # or git switch -C branch-B
or you can do it with git branch -f branch-B
to move branch-B
, then git checkout
or git switch
to switch to the moved branch. Then you'll just need to delete the temporary branch, with git branch -d new-and-improved-B
.
That's a fair amount of work, of course. It would be nice if Git would do it for us ... and in fact, Git will do it for us.
Using git rebase --onto
The git rebase
command is designed to do exactly this kind of repeated cherry-picking for us. It:
- lists out commits to copy;
- uses Git's detached HEAD mode, rather than a temporary branch, to do the copying, one cherry-pick at a time, just like we showed above; and
- afterward, moves the branch name.
As with the cherry-pick method, each cherry-pick can result in a merge conflict. If so, Git will stop in the middle and make us clean up the mess. Then we run git rebase --continue
to get Git to go back to the remaining cherry-picks and final rebase move-the-branch step.
The wrinkle here is that rebase
is designed in particular for a slightly different scenario. If we had this:
...--o--o--o <-- main
\
A--B--C--D <-- feature (HEAD)
and we wanted this:
A'-B'-C'-D' <-- feature (HEAD)
/
...--o--o--o <-- main
\
A--B--C--D [abandoned]
we'd just do git checkout feature
(if needed—the drawing shows that it's not) and then git rebase main
. The rebase command figures out which commits to copy by figuring out which commits are on our branch that aren't on the target branch main
.
But we don't want that. We have:
...--E--F <-- main
\
G--H <-- branch-A
\
I--J <-- branch-B (HEAD)
and rebase would therefore assume that we want to copy commits G-H-I-J
, because commits G
and H
are on our branch. They're just also on another branch. So we have to tell rebase: and hey, don't copy G-H
.
What we must do, then, is:
git checkout branch-B
git rebase --onto main branch-A
The --onto
tells Git: here's where the copies go. That frees up the last argument to be the name branch-A
. The name branch-A
selects commit H
. So now we're telling Git: Copy all the commits that are on branch-B, but are not on branch-A. Put these copies after the commit selected by the name main
.
The rebase operation will therefore list out the commit hash IDs of commits I
and J
here, as the ones to copy. Then it will use the detached-HEAD trick to make an unnamed branch that ends at the commit selected by the name main
(use whatever name is the right one for your situation here). Then, it will do the one-by-one copying of each commit it listed out earlier, and last, it will yank the branch name we were on—branch-B
—over to point to the last copied commit.
Miscellaneous but important extra details
The idea of copying commits, one at a time, as if by using git cherry-pick
is really the heart of git rebase
. We just have to have Git list out the right commits, do the detached-HEAD trick, do the copying, and update the branch name at the end—and that's what it does. But there is a long history behind it, and a bunch of special cases to know about:
Older version of Git will, by default, do the copying using git format-patch
and git am
instead of using git cherry-pick
. Mostly this works the same, but there are minor differences:
- it's a lot faster in some cases; and
- it does not deal well with file renames (which is what makes it faster).
You can force these older Gits to use cherry-pick by adding various options to the rebase, or by upgrading your Git version.
By default, rebase drops merge commits entirely. There are many reasons for this that we won't go into here. As long as there are no merges in your own branch, this does not affect you.
By default, since Git version 2.0, rebase uses what Git calls the fork-point code to figure out other commits to drop. Again, we won't go into details here: they should not affect your case.
Git will also omit from the copying any commits that it thinks are already in the target. This should not affect your case either (but it's useful to know).
Last, rebase has an "interactive" mode in which you gain a lot of power. You don't need it for this case, but you could, if you wanted, use it instead of the --onto
method. The --onto
trick is easier for your case, but just remember that interactive rebase exists.
If your Git is sufficiently modern, it also has a way to redo merge commits, if you want to "copy" them, using this interactive rebase feature. If you had a complex set of branches with internal merges to rebase, you would need this.