Search code examples
gitrebase

Git: Delete one of two parent commits


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

Solution

  • TL;DR

    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.

    Long

    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:

    1. 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.

    2. Set up a temporary branch pointing to B (from your argument B).

    3. For all commits listed in step 1, copy those commits, building up this new temporary branch.

    4. 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.