2

I understand that this question has been asked multiple times, but either the scenario was a little different or I couldn't really understand it.

Basically, my git history looks something like this:

Merge A
Commit B
Merge C
Commit D
Commit E
Merge F
Commit G
Commit H <-- What I want

Merge A is currently the latest and I want to revert the 3 merge and all commits until "H".

How exactly do I sequentially revert the merge requests and commits so that my code will now be at state "Commit H"? After which I can then push these reverts to origin.

3 Answers 3

3

Here's the standard way to do this.

You can start with:

# make sure you state is clean
git reset --hard H

If you hate the history that came after H, you could just stop here, and after a force push the commits that came after H will be lost as if they never happened. This is typically not ideal though for shared branches, so you may wish to continue with:

git reset --soft A
git commit -m "Revert commits G through A, changing state back to H"

What we just did here was add a new commit on top of A that contains the same state as H, as requested.

Side Note: this is pretty much the same idea as the first option in knittl's answer, but this answer uses a more standard approach. The end result should be the same, but knittl's answer may be slightly more efficient because with that one you don't have to temporarily perform a hard reset back to H, and also works with a dirty tree for other files (outside of commits H-A) that were modified and uncommitted yet.

Sign up to request clarification or add additional context in comments.

2 Comments

Good answer, it didn't even occur to me to use a "forward" soft reset!
@knittl Thx, but I still might like your answer better, at least from a coolness factor. ;)
2

You can avoid multiple reverts and you don't have to use reset. Using the plumbing command commit-tree, you can create a new commit with arbitrary parents and any known tree:

git checkout branch-at-A
hash="$(git commit-tree H^{tree} -p A -m 'Restoring project at commit H')"
git merge --ff-only "$hash"

After running the above commands, you have one new commit with exactly the same files as the original H, but the parent will be commit A instead. Your history will look like this:

Commit 0 <- branch-at-A, same tree (i.e. project content) as H
Merge A
Commit B
Merge C
Commit D
Commit E
Merge F
Commit G
Commit H

Alternatively, create a new merge commit with parents A and H using the ours merge strategy to take only the content of H.

git checkout H
git merge -s ours A
git checkout -B branch-at-A

Find an in-depth explanation in my answer on the question How do I 'overwrite', rather than 'merge', a branch on another branch in Git?. Since branches are only names for commits, the same principles apply.

4 Comments

Terrifyingly cool.
Very cool. Does the first option work on a dirty tree as well? (Haven't had time to test this yet.)
@TTT it should, given that the fast-forward doesn't have to update modified files. If the only dirty files are files that weren't modified between H and A, then this approach should work without any problems (because the tree is taken from commit H, not created from the index)
That makes sense. That right there might make your answer the best. My answer won't work with a dirty tree due to the hard reset.
1

TL;DR

git reset --hard <H_SHA1>

# or alternatively

git reset --hard my_branch^~^~2^~

Why reset instead of revert

When undoing a set of commits which happen to be also merge commits, the best way to go is to reset your history rather than reverting. If you revert your commits, the changes brought by other branches will still be marked by Git as merged in your current branch, preventing you from including them in the future. If you need to void a set of commits coming from other branches, the best approach is to reset your branch to a previous commit (in your case H). This will bring back your branch to that previous point, removing all subsequent changes, and allowing you to merge those other branches once again.

Meaning of ^ and ~

The caret sign (^) allows you to reference a parent commit from a merge commit.

^ to identify which parent you want; for example, d921970^2 means “the second parent of d921970.” This syntax is useful only for merge commits, which have more than one parent — the first parent of a merge commit is from the branch you were on when you merged (frequently master), while the second parent of a merge commit is from the branch that was merged (say, topic).

The tilde sign (~) allows to reference a parent commit in a linear history.

refers to the first parent, so HEAD~ and HEAD^ are equivalent. The difference becomes apparent when you specify a number. HEAD~2 means “the first parent of the first parent,” or “the grandparent” — it traverses the first parents the number of times you specify

See the section Ancestry References of the official documentation for more details.

Breaking down indirect referencing

  • my_branch^ references the first parent (B) of the HEAD commit of your branch (merge commit A).
  • my_branch^~ references the previous commit (C) of the parent commit (B) that concurred to the merge.
  • my_branch^~^ references the first parent commit (D) of your second to last merge.
  • my_branch^~^~2 references the "grandparent" commit (F) of commit D.
  • my_branch^~^~2^ references commit G.
  • my_branch^~^~2^~ references commit H.

Expanding from the comments: Revert a Merge in Details

To answer your first question: yes, you could reset your branch by supplying a commit ID instead of using an intricate string of caret and tilde signs. That was the just an another indirect way of achieving your desired output. So, git reset --hard <H_SHA1> does also work of course.

Regarding git revert, the reason why I've pushed for a reset approach instead of a revert solution is because git revert introduces the opposite changes of the given commits. This means that if a commit adds a single line of code, the revert of that commit removes that very line (introduces the opposite changes). So, for each supplied commit, there will be a new commit with the opposite changes. Revert is suitable, if you're fine with having a commit history the first introduces some changes, and then removes those same changes. Also, keep in mind that a reverted history can look confusing for someone who reads the project history for the very first time.

As I've specified in the section Why reset instead of revert, reverting a merge commit is rarely a good idea. When performing a merge between two branches, Git first finds the merge-base between the head commits of the two branches, then detects the changes between the merge-base and the head of the other branch, and finally applies the detected changes on top of the target branch. If you revert a merge commit, you're not canceling the changes introduced by the merge, you're instead introducing even more changes on top of the merge commit, that are the opposite of what has been brought in by the merge. This means that, when you'll try to merge the two branches again after the revert, you won't be able to bring those changes back into your branch, because those modifications already present in the history of the current branch. You don't see them, because they've been canceled by the subsequent revert commits.

Revert is a viable approach for linear histories. Of course, you can use it on merge commits as well, as long as you're fine with not being able to merge the reverted changes into the current branch ever again.

In your case, if you're absolutely sure to proceed with a revert, you could write:

# revert each commit between H (excluded) up to the head (included)
git revert <H_SHA1>..HEAD

# alternatively, you could refer to H indirectly
git revert HEAD^~^~2^~..HEAD

6 Comments

"If you revert your commits, the changes brought by other branches will still be marked by Git as merged in your current branch, preventing you from including them in the future." Perfectly true, but the OP specifies revert explicitly multiple times.
@matt I don't know if he's considering git revert instead of git reset because they might think that the end result is the same without knowing the perils of reverting merge commits; or if git revert is something that they must use. If the latter is the case, I will update my answer. For now, this could be an alternative solution to bringing their situation back to commit H. Also, thanks for your comment. It was a legit point.
Hi dani, thank you for your solution! But just wondering, if going by this reset route, is it possible to just reset to a specific commit hash instead of going through these complicated chain of ~ and ^? Also, i've used git but rarely having to revert, so i'm just trying to understand and learn exactly how does reverts work when it includes merges and commits.
Hi @Cherple I figured it could be your case, so I added the section Why reset instead of revert because of that. I've expanded my answer hoping it will help you more to understand the point I was trying to make.
To reset to a specific commit, simply git reset "$commit_hash" is enough, in your case git reset H. There's no need to walk the history with a complicated refspec
|

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.