Easiest way to bring back a previous commit point

2020-02-15 20:57发布

问题:

Ok, here is what I want, very like Going back to certain previous commit and not modifying git history:

Suppose my git log is like this:

detour C
detour B
detour A
Last good point

I want to revert to "Last good point", while still keeping the detours in the history, but unlike Going back to certain previous commit and not modifying git history, I want to make it top again. So afterward my git log would like:

Revert to last good point
detour C
detour B
detour A
Last good point

I know the official way is

git revert HEAD~3

However, I got

error: could not revert f755e55... Last good point
hint: after resolving the conflicts, mark the corrected paths
hint: with 'git add <paths>' or 'git rm <paths>'

I.e., I need to resolve those very messy conflicts, which is what I want to avoid as much as possible. I know

git checkout HEAD~3

will bring me there right away, but I read then the git will be in a detached stage or something, and I don't know how to duplicate this stage back to the top again. Please help. Thx.

回答1:

TL;DR

There are three fairly direct ways to achieve what you want:

  1. git revert --no-commit HEAD~3..HEAD && git commit
  2. git read-tree --reset -u HEAD~3 && git commit
  3. git rm -rf -- . && git checkout HEAD~3 -- . && git commit

All three require that you enter the new commit message; you might add -C HEAD~3 --edit to the git commit command so that you can edit starting from the message that's in HEAD~3. The last of these three require that you be in (cd-ed to) the top level of the repository. If you are not already there, you should first use:

cd $(git rev-parse --show-toplevel)

or:

git rev-parse --show-toplevel

and then cut and paste the output into a cd command to get to the top level.

Longer: why the above are correct, starting with #3

The key phrase is this:

I want to revert to "Last good point"

(emphasis mine: revert to, not just revert, which is a Git command that does something a bit different).

You should also be wary of the word stage, too, which has a technical defined meaning in Git (referring to copying into the staging area, which is another phrase for the thing that Git calls, variously, the index, the cache, and of course the staging area). [edit: removed since the title is adjusted now]

The low level command that does this is git read-tree, as in PetSerAl's answer. I would recommend git read-tree --reset -u, since -m means to perform a merge and you want an index reset. But there is a way to do this that, while slightly clumsier, may make more sense to humans, using git checkout. That's command-set 3, which we will look at first.

As you note, git checkout HEAD~3 will make the desired commit be the current commit—but it does so by "detaching HEAD", which is a scary phrase that just means that you are no longer on a named branch. (You "re-attach" your HEAD by running git checkout branchname, which sets things up again so that you're on that branch, by checking out that branch's tip commit, which of course means you are no longer using the desired commit.) This happens because all commits are more or less permanent,1 and entirely read-only: you cannot change the past, you can only re-visit it.

The git checkout command, though, can do more than re-visit the past (by checking out a specific past commit) or switch to some other branch (by checking out any named branch). Probably, many or most of these operations should have a different front-end command, because lumping them all under git checkout just makes Git more confusing; but that's what we have: git checkout commit-specifier -- paths tells git checkout to extract the given paths (file or directory names), into the index and then on into the work-tree, overwriting whatever is currently in the index and work-tree, without changing commits.

Hence:

git checkout HEAD~3 -- .

tells Git to extract, from commit HEAD~3 (the one three steps back from where you are now), the directory .. If you are in the top level of your Git repository, . names every file in the repository.

More precisely, . names every file in that particular commit of the repository. This is why you should first run:

git rm -rf -- .

That tells Git to remove every file (that Git knows about, i.e., that's in the index right now) from both the index and work-tree. The point of this is ... well, suppose that during the three detour commits, you added a new file newfile.ext. That new file is in commit detour C, at least, if not in all three of those. But it's not in HEAD~3, which names commit 22769c2, the last good one that you want to restore. So when you tell git git checkout 22769c2 -- . or equivalent, Git looks into 22769c2, finds all the files that commit has—which doesn't include newfile.txt—and replaces the current files with the ones from the good commit, but leaves newfile.ext in the index and work-tree.

By first removing everything that Git knows about in the detour C commit, you give the git checkout ... -- . command a clean slate into which to extract everything.

Hence, command set 3 means:

  • Remove everything that Git knows about, to produce a clean-slate index and work-tree. (Files that Git doesn't know about, such as .o files built by a compiler, or .pyc byte-code files from Python, or whatever, that are ignored via a .gitignore, do not get removed.)

  • Extract everything that was in the good commit, into the index and work-tree: fill the clean slate with the good stuff.

  • Commit: make a new commit, not 22769c2 but some other hash ID, whose parent is the detour C commit but whose contents are whatever is in the index right now, which is the stuff we just extracted from 22769c2.


1The "more or less" part is because you can abandon commits, by changing your various names so that no name locates those commits any more. Having no names that find them, the commits become lost and abandoned. Once they have been abandoned sufficiently long—generally at least 30 days as there are hidden reflog entry names that still find the commits, but those reflog entries eventually expire, typically in 30 days for such commits—Git's Grim Reaper Collector, also known as the garbage collector or git gc, will actually remove them.


The git read-tree method

What git read-tree --reset does is, to put it as simply as possible, combine the git rm -r --cached . step with most of the git checkout HEAD~3 -- . step. Of course those aren't quite what are in #3 at all: this form, with --cached, removes only index entries. Moreover, the git checkout step populates the work-tree. That's what the -u addition to the command does: it updates the work-tree to match the changes made to the index. Removing some entries, if any do wind up removed, causes the corresponding work-tree file to be removed; updating the rest of the entries, including adding new entries from the commit being read, causes the corresponding work-tree file to be updated or created. So git read-tree --reset -u HEAD~3 is the same as our remove-and-check-out sequence, except that it's more efficient.

(You might not remember it though: git read-tree is not a command one uses often. Also, using -m tells Git to merge the target tree into the current index, which isn't quite what you want either, although it's almost certainly going to do the right thing here.)

Or you can use git revert -n

The first command above uses git revert --no-commit. This is the long way to spell -n, which means do each revert without committing the result. Normally, what git revert does is to turn each commit-to-be-reverted into a change-set, then "reverse apply" the changes. Given a range of commits like HEAD~3..HEAD, Git first collects a list of all the hash IDs involved—in this case they are:

7a6c2cc detour C
dc99368 detour B
1cf4eb4 detour A

Git then runs through them in backwards order, child-most to parent-most, i.e., first looking at detour C, then at detour B, then at detour A.

Each of these commits is a snapshot in itself, but each has a parent that is also a snapshot. Subtracting what's in the detour B snapshot from what's in detour C tells Git, in effect, what changed in order to go from B to C. Git can then "un-change" exactly those changes: if going from B to C added a line to README.md, remove that line from README.md. If it removed a line from a.txt, add that line back to a.txt. If it removed an entire file, put that file back; if it added a new file, remove it.

Once all of the changes have been backed-out (with the result matching what's in the detour B snapshot), git revert—which obviously should be called git backout—would normally make a new commit from the result; but with -n, it doesn't. Instead, it leaves the result in the index and work-tree, ready to commit. Then it moves on to the next commit in the list, which is that for detour B. Git compares this to its parent to see what changed, and undoes those changes. The result is, in this case, the same snapshot that's in detour A.

Had we started from something other than the detour C snapshot, though, backing out the detour C changes would have not matched detour B, and then backing out the detour B changes would not match what's in detour A. But we did start from what's in the detour C snapshot. So now Git backs out whatever changed in detour A, leaving—that's right!—whatever is in the last good commit.

This state is now in the index and work-tree, ready to commit. So now we simply commit it as a new commit. And that's command-sequence 1: revert (back out) the three bad ideas, in reverse order, which is guaranteed to work since we're starting with the snapshot in the last of them. Don't commit any of the intermediate results. Then, once the index and work-tree match the last good commit, make a new commit.



回答2:

You can simply read tree from Last good point commit, and then commit it:

git read-tree -m -u HEAD~3
git commit

-m -u options of read-tree will update working directory from given tree.



回答3:

Here is an alternative to git revert which would leave you with the same information, yet would avoid messy merge conflicts. Just checkout a new branch from your current point and then hard reset that branch to the earlier point:

git checkout your_branch
git checkout -b new_branch
git reset --hard HEAD~3

Now new_branch is functionally what you want, plus you still have those three commits sitting in the bona fide your_branch branch.



回答4:

You can revert your commits one by one in the reverse order to avoid fixing merge conflicts and finally reach the required commit.

Suppose you have the following history initially.

7a6c2cc detour C
dc99368 detour B
1cf4eb4 detour A
22769c2 Last good point

You can run the following sequence of git revert commands.

git revert 7a6c2cc
git revert dc99368
git revert 1cf4eb4

This will leave you with a commit history like below.

3b67aaf Revert "detour A"
9028879 Revert "detour B"
6d9bcce Revert "detour C"
7a6c2cc detour C
dc99368 detour B
1cf4eb4 detour A
22769c2 Last good point

At the end, your code will the same as it was at the Last good point commit.

You can also use the --no-commit flag with git revert to avoid individual commits for each revert.

git revert --no-commit 7a6c2cc
git revert --no-commit dc99368
git revert --no-commit 1cf4eb4
git commit -m "Revert detour C, B and A"