I merged and then committed only some of the files. How do I re-merge the others?

726 views Asked by At

I'm working in a feature branch. After running git merge from the main branch, I had two conflicts.

I manually resolved them, and then committed only those two files, and ran git reset --hard to remove the other changes.

The result - git thinks the other files were merged, while in reality, they're 100% from my feature branch.

How do I fixed this and "re-merge" with the main branch?

1

There are 1 answers

2
torek On BEST ANSWER

The problem statement itself is just a little off, because git commit writes all the files, not just some of them. I'll tweak it a bit first, but feel free to skip forward to the next section. :-)

More specifically—this should help you think about Git in the future—Git writes whatever is in the index (which must be fully merged). The git merge command builds, in the index, the result of merging two (or more but let's stick with two) commits. If there are conflicts, Git stores the conflicted files in both the work-tree and in the index.

You can then edit the files in the work-tree, which you did. You can use git add to copy the work-tree files into the index, replacing the conflicted versions with the single resolved version.1 You did that too. You can use git reset, in its git reset paths form, to copy specific paths from the HEAD commit into the index, replacing whatever is currently in the index.

It's this last step that created the problem: Git had merged those other files, successfully (at least in Git's tiny little mind :-) ). Then you told Git that it should copy the HEAD (current commit, pre-merge) versions of those files into the index, undoing the effect of the merge on those files.


1The gritty detail here is that when there is a conflict, the index winds up holding not one but three versions of some file. For instance, suppose you have run git merge other, and conflict.txt has conflicting changes from base commit to HEAD vs base commit to other. These conflicts show up in the work-tree copy, but at the same time, Git actually puts all three versions—base, HEAD, and other—into the index, under the name conflict.txt. You can see them with git show :1:conflict.txt, git show :2:conflict.txt, and git show :3:conflict.txt, for instance.

Running git add tells Git to toss out the three extra index versions and store just one :0:conflict.txt file as the correctly-merged index version, ready for the next git commit command. Git will allow another commit any time the index has only zero-numbered versions. Whatever is in the index at that time, becomes the contents of the new commit. (If these contents exactly match the current or HEAD commit, Git requires that you add the --allow-empty flag. This makes it seem like the index is empty, but it's not: it's the diff that's empty.)


Diagramming the problem

There are several ways to go about fixing this. To find the best and simplest, we really need to draw some diagrams.

Specifically, before you did the merge you want to re-do, you had some series of commits:

...--o--B--o--L   <-- mainbranch (HEAD)
         \
          o--o--R   <-- feature

You were on mainbranch (hence it was HEAD). This means that HEAD represented commit L (for Local or Left-side). You ran git merge feature, which identified commit R (for Remote or Right-side or otheR or featuRe; anyway, I generally call it R). Git then computed the merge base commit B, which is the first place the histories of the two selected left and right commits join up.

Git then ran, in effect:

git diff --find-renames B L
git diff --find-renames B R

and combined the two diffs. There were some conflicts, which you resolved, and some non-conflicts, which you git reset away to the L versions. Due to the conflicts, Git made you run git commit yourself, and when you did, you got this:

...--o--B--o--L---M   <-- mainbranch (HEAD)
         \       /
          o--o--R   <-- feature

The new merge commit M has, as its contents, whatever was in the index when you ran git commit.

It's my guess that you did not discover the issue until later. You probably did another git checkout feature and wrote more commits, giving:

...--o--B--o--L---M   <-- mainbranch
         \       /
          o--o--R--o--S   <-- feature

Then you went back to mainbranch and ran git merge feature again. I can't call the new tips L and R any more, so I will stick with M and S. Let's find the merge base of M and S, by following all the connections backwards (leftwards, including left-and-down), from both M and S simultaneously:

  • It's not M or S themselves, we'd have to go back and then forward from M.
  • It's not the o before S either.
  • But R looks pretty good: we can go back one step from M to R by going down too; and we can go back two steps from S to get to R.

So the merge base is R, and Git runs git diff on R M and on R S. Git then sees that R-to-M takes feature away from the feature code, while R-to-S adds or modifies the features. Git tries to combine these changes, and the result is a big mess. If the merge itself works, we get this:

...--o--B--o--L---M-----M2   <-- mainbranch (HEAD)
         \       /     /
          o--o--R--o--S   <-- feature

though it might not work well due to conflicts in the "undoing" going from R to M, vs the "doing" going from R to S.

But we might even have this, depending on what happened on the main branch:

...--o--B--o--L---M--N--M2   <-- mainbranch
         \       /     /
          o--o--R--o--S   <-- feature

(in which case the merge base was still R, but the first diff was to compare R to N).

Option 1: remove M entirely

If we don't have commit N at all, and if it's safe to "remove" commit M entirely, we can just do that, using git reset. We can git checkout mainbranch (to attach HEAD to it), git reset --hard to commit L, and have this:

...--o--B--o--L   <-- mainbranch (HEAD)
         \
          o--o--R--o--S   <-- feature

Then git merge feature will run diff on B L and a second diff on B S, and try to combine the diffs. You'll have mostly the same merge conflicts as B L vs B R, plus perhaps some more.

You can resolve them the same way (repeating earlier work). Or you can save the identity of commit M and extract the resolutions from M. Although we have erased M from the drawing, it's actually still in the repository. It's just hidden; it will really go away later, if you don't do anything else, in about a month. Since M is still in there, we can extract files from it:

git show <hash-id>:<path>

We can even attach a name to M before we do the git reset. For instance, using a tag name:

git tag temp-save-merge <hash-id>

we can then:

git show temp-save-merge:<path>

to get the saved files. Once we're done, we can just git tag -d temp-save-merge to drop the special name for commit M (which will, as before, let Git remove it automatically some time after 30 days, now that there are no names by which we can see/reach it).

Option 2: keep M

If commit M has been pushed elsewhere, though, and/or if commit N exists and/or has been pushed, it may not be such a great idea to attempt to remove M from existence. We may succeed in removing it from our repository, but it might come back from another repository later. We'd have to force-push to make it go away from any central repository, and convince everyone else who uses that repository to discard their copy of M. If we need N we'd have to bring it back as well.

Instead, we'd probably like to reconstruct the files we wish we had not git reset, and bring those changes in atop N. I'll leave commit N in the drawings from here on; if N doesn't actually exist, we're just building atop M, and the result is all the same anyway.

First, let's get rid of M2 (which is unsuccessful at best and presumably has not been pushed anywhere) by running git merge --abort if we haven't actually made M2 yet, or by git reset --hard if we have, so that we have a clean index and work-tree and have this graph:

...--o--B--o--L---M--N    <-- mainbranch
         \       /
          o--o--R--o--S   <-- feature

Next, let's make a new branch pointing to commit L:

git checkout -b temp-merge <hash-of-L>

which gives us this diagram:

                ----M--N   <-- mainbranch
               /   /
...--o--B--o--L  <-- temp-merge (HEAD)
         \       /
          o--o--R--o--S   <-- feature

We'll stop drawing mainbranch for a bit since it's getting all messy. We can now run:

git merge feature

to repeat the same merge we did incorrectly earlier. This will get us a "new and improved" merge, although it will stop with the same conflicts as before. We'll get the correctly-merged files out of M, add them, and commit:

git show <hash-of-M>:<path1> > <path1>
git show <hash-of-M>:<path2> > <path2>
git add <path1> <path2>
git diff --cached   # inspect carefully
git commit

Now we have this:

...--o--B--o--L---M2  <-- temp-merge (HEAD)
         \       /
          o--o--R--o--S   <-- feature

Now let's add a copy of N, using git cherry-pick mainbranch, and attempt to draw it all:

                ----M--N   <-- mainbranch
               /   /
...--o--B--o--L---/-M2--N2   <-- temp-merge (HEAD)
         \       //
          o--o---R--o--S   <-- feature

This final commit N2 is what we would like to have in branch mainbranch. The reason we are doing all this hairy stuff, though, is to preserve the existing hash IDs M and N, so what we want now is something Git doesn't quite offer: a "theirs strategy" merge. (See Is there a "theirs" version of "git merge -s ours"?)

But we can get what we need anyway, using one of the methods in that other answer. Here's the most obvious one, which assumes you are sitting at the top level of your work-tree:

git checkout mainbranch
git merge -s ours --no-commit temp-merge
git rm -rf .
git checkout temp-merge -- .
git commit

The git merge step starts the merge, but with --no-commit, never finishes it. We use --ours just to make it go fast: this means "keep the contents of N", which is quite wrong. Then we remove everything! The index is now truly empty.

Now we use the real trick: we re-fill the index from the N2 commit. The git checkout temp-merge -- . command replaces everything we have with the contents of the commit to which the name temp-merge points, i.e., N2. This happens in both index and work-tree, so now we are all set to commit the final merge result. When we do we get this graph:

                ----M--N---M3   <-- mainbranch (HEAD)
               /   /      /
...--o--B--o--L---/-M2--N2   <-- temp-merge
         \       //
          o--o---R--o--S   <-- feature

where the contents—the tree—for commit M3 is exactly the same as that for N2, which is the corrected tree.

We can now delete the branch name temp-merge. The history is full of our double merge, so the graph is always messy to draw. That's because we chose Option 2, in which we don't rewrite history to erase our earlier mistake. The mistake is preserved for all time, but this also means that we're only adding new commits (M3 and its side chain that used to be called temp-merge), so other people using clones of this repository can work in their normal fashion.

Summary

If you can use Option 1 (rewrite history, pretend the bad merge never happened), you probably should.

You can even use a sort of hybrid: start with the temporary merge branch method, make commit M2, and cherry-pick N to N2 if N exists. But then, instead of merging this with mainbranch, rename the old mainbranch and rename the temporary merge to mainbranch, giving:

                ----M--N   <-- mistake
               /   /
...--o--B--o--L---/-M2--N2   <-- mainbranch (HEAD)
         \       //
          o--o---R--o--S   <-- feature

You may have to force-push the renamed temporary branch, and this makes headaches for other users if there are any, but if we then delete the mistake branch later and draw our graph, we get:

...--o--B--o--L--M2--N2   <-- mainbranch (HEAD)
         \      /
          o--o-R--o--S   <-- feature

which is quite normal looking. (And, note that we can now merge S atop N2 in the usual way.) It does mean we have rewritten history, but we did it in a sort of easy-ish way: we re-did the merge of L and R, grabbing the correctly-merged files out of commit M, then we tossed commit M and N aside in favor of M2 and N2. This is effectively the same as Option 1, except that we keep the mistake branch around until we're done with it.