Undo a merge that has been pushed

dmeu picture dmeu · Apr 16, 2012 · Viewed 8.7k times · Source

OK, I have made a bit of a mess. Apparently, on my machine at home, the develop branch was not updated. I made a commit and pushed. The result was that the actual origin/develop branch has been merged into my local develop branch - which for some reason were considered as different branches!

For one thing, I really don't understand how this happened and second of all, can I undo this?

To illustrate it, the network looks something like this now:

 local-develop ---------------------------------- C*--- M -
origin/develop --- C --- C --- C --- C --- C --------- / 

What I really wanted was that the C* would be committed to the origin/develop instead of merging the branches.

As I said, this has been pushed already. Is there a way to delete the changes and commit it the way I wanted?

For example I do:

git reset --hard HEAD~1

I am not sure if this undoes the merge and I have two different develops, and then the merge deleted etc...?

Answer

torek picture torek · Apr 16, 2012

Merges don't happen on push, they happen on git merge (well, and pull but that's really just fetch + merge, hence, merge happening on merge).

It seems more likely that you did something like this:

<get copy from origin/develop>
<wait around for remote's origin/develop to acquire new commits>
git checkout develop      # get onto (local) develop branch
<edit>
git commit -m message     # create some new commit(s)
git push origin develop   # attempt to push -- but this fails!
git pull

It's that last step that creates the merge commit (M above), because pull means fetch (get all those new commits that are now in origin/develop), then merge (take your local develop and merge your commit with those new ones just fetched).

If you haven't git pushed this new result, then the remote repo doesn't have either of your local commits, the ones you've labeled C* and M. In this case, you're in good shape! (You can check by running git fetch again to make sure your local repo's origin/develop matches the one in the remote, then doing git log origin/develop to see what's in it.)

It may help to remember that there are two completely separate git repos here: yours, with your stuff; and the one you're calling origin here. (Let's call that machine X.) If you were to log on to X, it has its own separate commit history and branch names and so on. Over there, you would change directory to the repo, run git log -1 develop, and see what's at the tip of that branch.

Now if you log off of X and are back on your own machine, you can run git log -1 origin/develop. If that's the same as what you saw on X, then get fetch has nothing to update, because what git fetch does is, in effect (but more efficiently), log on to X and look at what's in develop there. Anything that X has that you don't have in origin/develop, fetch brings over and adds to origin/develop. Now you're in sync with X. X doesn't have your stuff, but you have theirs.

If you then take the extra step of doing a merge (including the one implied by pull), git will, if it has to, make a merge commit ... but all this is in your repo, at the tip of the branch (still develop in this case). Unless and until you push this merge commit to X (or someone on X pulls your commits from you but let's ignore that for now :-) ), X won't have it.

Anyway, as long as the remote (X here) does not have your merge commit, you're golden. Since they don't have it, nobody else does either. You can do a rebase of your develop branch to put your commit (C*) on top of origin/develop. That will get rid of the merge commit (M), and then you can push a simple fast-forward to origin/develop.

If X does have your merge commit—i.e., if you pushed after you pulled and got that merge—then you're stuck (to some extent) because presumably other people have access to X and are now using your merge commit. It's possible to roll back the repo on X, similar to the way you can do this in your own repo with git reset and git rebase and so on, but it's generally a bad idea.


Now, suppose you have in fact pushed to the other repo (on machine X), but you're absolutely sure nobody else has seen your changes yet, and you're definitely going to reset them, rather than reverting them (reverting amounts to "fessing up" and leaving the screwup-record behind, which lets everyone else recover from it easily, but also lets them see your error :-) ).1

Here's the trick: you first need to get machine X's repo to say that "tip of branch devel is commit C7", where C7 is in your very own diagram from earlier, just re-numbered so that I can name each commit differently:

--------------------------- C*--- M
--- C4 --- C5 --- C6 --- C7 ---- /

So, how can you do that? Well, one way is to log in on X,2 cd into the repo (even if it's --bare), and use git update-ref there. Let's say the SHA1 for C7 is actually 50db850 (as shown by "git log"). Then you could do this:

localhost$ ssh X
X$ cd /path/to/repo.git
X$ git update-ref refs/heads/develop 50db850

But if you can't log in to X, or even just don't want to, you can do the same with git push -f.3 (This has other advantages: in particular, your git repo will know that origin/develop has been rewound, once the push -f completes successfully.) Just make a local branch-tip pointing to the right commit:4

localhost$ git branch resetter 50db850
localhost$ git log resetter         # make sure it looks right
...
localhost$ git push -f origin resetter:develop
Total 0 (delta 0), reused 0 (delta 0)
To ssh://[redacted]
 + 21011c9...50db850 resetter -> develop (forced update)
localhost$ git branch -d resetter   # we don't need it anymore

Once you have done this, machine X is back in the state you wanted it, and you can proceed as if you never pushed the merge you didn't like.

Note that when you do the push -f, if anyone else has made new commits on top of M, those will also become invisible (technically they're still in there, along with your merge commit, but they are "lost" in the lost+found sense of git fsck --lost-found, and after a few months they'll really go away forever).5

Again and very important: this kind of "rollback of shared repo" is a big pain for other users of that shared repo, so be really sure it's OK before you do it.


1This sort of trivial merge doesn't even need reverting. There's nothing fundamentally wrong with leaving the merge in there. If you're dealing with a more serious mistaken merge, though, there's one other disadvantage to reverting a merge, besides the record of your "oops": it makes "redoing" the changes later a bit harder, in that a later "merge on purpose" will see the earlier merge and think: OK, I don't need to re-merge those changes. You then have to "revert the revert" instead.

I think the right take-away lesson here is: look (git log) before you push to make sure that what you are about to push is what you intend to push.

2Or, easier and simpler: git ls-remote. This uses the fetch protocol code to see what the remote has. But it hides the theme I'm going for here, which is: the remote is a repo just like yours!

3The upcoming git version 2 releases have new "safety features" that will be useable with git push -f. But they're not out yet, so that doesn't help you. I'll re-edit this later when they are, to note them. Until then, be really careful here: you're racing against anyone else trying to push new stuff to the shared repository, at this point.

4You can even do this by raw SHA-1 ID. The branch name is easier to type correctly several times in a row though.

5The retaining and expiration is via git's "reflogs". The shared server may not be logging all ref updates; if not, only private repos that already had the new commits will retain them. The shortest default expiration is 30 days, i.e., about one month, for commits no longer reachable from the branch tip. But note, it's a no-fun, crazy scramble to make everyone search through their repositories for "lost" commits after force-pushes "lose work" off a shared repository.