3

I incorrectly branched in the past and one commit was left in the start of another branch:

* 03431cb (HEAD -> bar) a2
| * d332e4d (foo) b2
| * 9b29ae3 b1
| * 4656a98 a1
|/  
* 6ebca20 (master) root

How can I move a1 out of foo into bar, so that bar's history is root -> a1 -> a2 and a1 is not in foo? Is it possible to do it with one single git commit?
This is not pushed so no need to worry about breaking others' local repos.

I first thought about doing a cherry pick of a1 and then correcting the order between a2 and a1. The issue with this is that those two commits conflicts in my real case scenario and I would have to correct the conflict both when doing the cherry pick and when switching their order.


mwe in bash:

#!/bin/bash
set -e

rm -rf .git

git init -b master
echo content > my-file
git add my-file
git commit -m root

git checkout -B foo
echo asd >> my-file
git add my-file
git commit -m a1
echo qwe >> my-file
git add my-file
git commit -m b1
echo zxc >> my-file
git add my-file
git commit -m b2

git checkout master
git checkout -B bar
echo jkl >> my-file
git add my-file
git commit -m a2
ffigari
  • 431
  • 6
  • 18

2 Answers2

7

I'm a big fan of the syntax git rebase --onto x y z, which means:

Starting at z, look back thru the parent chain until you are about to come to y and stop. Now rebase those commits, ie everything after y up to and including z, onto x.

In other words, using this syntax, you get to say clearly where to snip the chain. Plus you don't have to switch branches before rebasing. The syntax takes some getting used to, but once you get fluent at it, you'll find yourself using it all the time.

So:

  1. Create a temporary branch at a1 just to give it a name: git branch temp 4656a98
  2. Now rebase b1 and b2 onto root: git rebase --onto master temp foo
  3. Finally rebase a2 onto a1: git rebase --onto temp master bar
  4. Now you can delete temp if you like: git branch -D temp

Sure, we could save two steps and just do 2 and 3 using SHA number 4656a98 instead of the name temp, but names are nicer.


Proof.

Starting position:

* 9a97622 (HEAD -> bar) a2
| * 83638ec (foo) b2
| * 7e7cbd0 b1
| * 931632a a1
|/  
* 6976e30 (master) root

Now:

% git branch temp 931632a
% git rebase --onto master temp foo
% git rebase --onto temp master bar
% git branch -D temp

Result:

* 3a87b61 (HEAD -> bar) a2
* 931632a a1
| * bbb83d0 (foo) b2
| * 5fa70af b1
|/  
* 6976e30 (master) root

I believe that's what you said you wanted.

matt
  • 515,959
  • 87
  • 875
  • 1,141
2

Is it possible to do [what I want] with one single git commit?

I assume here you mean one Git command and not one Git commit. The answer is: no, it's not possible to do that.

How can I move a1 out of foo into bar, so that bar's history is root -> a1 -> a2 and a1 is not in foo?

See matt's answer for a way to do this with git rebase --onto.

It may, however, help to draw this the way Git sees it. It's not root -> a1 -> a2. It's root <-a1 <-a2. The name foo itself can be moved, but the three existing commits here are carved in stone: no parts of them can be changed. That's OK since, as you said, none of those commits have been sent anywhere else.

No matter what you do, you must copy some commit(s) to new-and-improved commits, with different hash IDs. You can leave the existing carved-in-stone commits alone provided you're changing nothing about them, including the backwards-pointing arrows that are part of the commits.

The root commit (really: 6ebca20) has no backwards-pointing arrow. That's what makes it a root commit. As long as you don't dislike anything about this commit, you can leave it alone—which is good since root commits are a pain to copy. Both git cherry-pick and git rebase can do that, but it's all a little weird and special-case-y, since the cherry-pick or rebase operation works by comparing a commit's snapshot to its parent's snapshot. The fact that there is no parent of a root commit is what makes it a little weird.1

Commit a1 (really: 4656a98) points backwards to the root commit. That too seems OK.

Commit b1, however—which is really 9b29ae3—points backwards to commit 4656a98. That's set in stone. It cannot be changed. You can make a new and different commit, that you also call b1, that instead points backwards to the root commit, and that's what you want to do.

Having done this with b1, you now need to copy commit b2 to a new-and-improved commit, because the existing b2 (d332e4d) points back to 9b29ae3. You need a copy that makes the same changes—something cherry-pick will do for you—but that has the new b1, whatever its hash ID becomes, as its parent.

Once you've copied b1 and b2, you can point the name foo at the copy of b2. Branch names, in Git, simply point to some actual, existing commit. You can change which commit they point to, at any time; whatever commit they point to is the last commit in the branch. Earlier commits are also in the branch by virtue of working backwards through the parent linkage in the last commit, then working backwards another step to the parent's parent (or parents' parents or whatever).

Since you must copy b1 to a new-and-improved version, this forces you to copy b2 as well. The git rebase command runs git cherry-pick repeatedly to achieve said copying, then—once all the copying is done—moves the branch name to point to the last copied commit. So one git rebase, with the right options (--onto included), will do the trick for the foo branch.

Separately, you must copy the existing a2 to a new-and-improved version. Having made the copy, you must then move the name bar to point to the copied a2. One git rebase command suffices to do all of these operations as well.

Because git rebase can run git checkout for you, the minimum number of Git commands needed is, at this point, two. That leads to the set of commands that matt used: the two extra ones were for convenience.


1The cherry-pick code itself handles this by using a faked-up "has no files" parent, temporarily, so that as far as the cherry-pick operation is concerned, the "change" made by the commit is add all the files. The rebase code requires the --root option, at least in older versions of Git; I'm not sure what has happened here since the sequencer has now learned to do the interactive stuff that used to be in shell scripts.

torek
  • 448,244
  • 59
  • 642
  • 775
  • Great insight. In particular "The name foo itself can be moved, but the three existing commits here are carved in stone: no parts of them can be changed" was not clear to me yet – ffigari Jul 26 '21 at 15:00