I have the following commit history:
A--B--C branch1
/ /
I D
\
\
J branch2
and I want to simply delete commit D, which isn't connected elsewhere.
I tried git rebase --onto B C
, but for some reason this deletes C as well and produces
A--B branch1
/
I
\
\
J branch2
Save the hash ID of C somewhere. The simplest method, other than using raw cut-and-pasted hash IDs, is to make a temporary branch or tag name, e.g.:
git branch save branch1
Then force the name branch1
to point to commit B
. Assuming you are still on branch1
—be sure you have nothing to commit too; use git status
to check both of these:
git reset --hard <hash-of-B>
or similar. If branch1
still points to commit C
you can use HEAD~1
or HEAD^1
to identify commit B, for instance.
Last, use git cherry-pick
to make a copy of commit C
, but in which this copy has only one parent. To do so you must tell which of the two parents git cherry-pick
should think of as C
's (single) parent for the purpose of this copying. The parent to choose will be #1; this is almost always true anyway; and it's definitely true in this case, where C
was made by merging D
:
git cherry-pick -m 1 save
You can then delete the name save
at any point (it was just around to remember the hash ID for commit C
).
Note that you could cherry-pick D
directly. However, using git cherry-pick -m 1 <thing-to-locate-commit-C>
means that if you had to resolve any conflicts when you did the merge, you don't have to re-resolve them now.
Based on comments, the actual graph structure is this:
A--B--C <-- branch1 (HEAD)
/ /
...--I D
\ /
\ /
J <-- branch2
but this has no effect on the answer.
I tried
git rebase --onto B C
, but for some reason this deletes C as well ...
It doesn't technically delete commit C
at all; it just arranges for the name branch1
to point to commit B
(only). That is, we can redraw the graph like this:
A---B <-- branch1 [after rebase]
/ \
...--I C <-- branch1 [before rebase]
\ /
\ D
\ /
J <-- branch2
The reason for this is that:
git rebase --onto B C
tells Git:
List all commits reachable from HEAD
(using the name HEAD
is in-built, part of git rebase
itself) that are not reachable from C
(from your argument C
), minus some commits we can describe later. List them in "reverse topological" order, i.e., older required commits before newer ones.
In this case, the list is empty: there are no commits starting from HEAD
—attached to branch1
with branch1
pointing to commit C
—that are not reachable from commit C
as well.
Set up a temporary branch pointing to B
(from your argument B
).
For all commits listed in step 1, copy those commits, building up this new temporary branch.
When done, change the name branch1
to point to the final commit copied.
Since no commits are copied, the temporary branch still points to B
, so the name branch1
is moved to point to B
.
You could try to fix this by naming something other than C
as "what not to copy". The problem is that you only get one commit ID for "what not to copy". You could specify B
, but Git will then try to copy D
and J
(in the other order: D
first, then J
). You could specify D
, but then Git will try to copy B
, A
, and I
!
The other remaining issue is that we noted above that in step 1, Git omits certain commits. Specifically, it omits all merge commits—it won't try to copy C
at all—and it also omits commits that are already in the upstream (through a mechanism I won't try to describe here; it doesn't apply in your case, so it's not important). What this means is that even if you could somehow say "copy C
but don't copy B
or D
", Git would toss C
out of the list and still copy nothing at all.
This is why we need an alternative mechanism: a "by hand" rebase, using git cherry-pick
. We can cherry-pick D
itself. This means re-doing any conflict resolution. Or, we can use git cherry-pick -m 1 <specifier-for-C>
to cherry-pick the effect of merging D
and doing any conflict resolution, and that's the method in the TL;DR section above.