撤销已推送提交中的单个文件

24

以下是推送的提交历史。

Commit      Changed Files
Commit 1|  (File a, File b, File c)
Commit 2|  (File a, File b, File c)
Commit 3|  (File a, File b, File c)
Commit 4|  (File a, File b, File c)
Commit 5|  (File a, File b, File c)

我想撤销对提交3中的文件b所做的更改。

但是...我想保留提交4和5中对该文件所做的更改。


2
可能是使用Git重置或还原特定文件到特定版本?的重复问题。 - Andreas
1
@Andreas,所述的即是回滚至早期版本。 - Aki T
@AkiT,由Andreas提出的重复问题的最佳答案中提到了您想要的内容——您可以将文件检出到特定历史提交版本而不是当前文件。 - icc97
1
@Andreas,最后一句话使得这个问题不是重复的。这只是从一堆提交中还原一个提交。你链接的问题会还原所有回到指定提交的提交。 - icc97
1
这似乎是一个反向的 cherry-pick - icc97
显示剩余3条评论
1个回答

25
在Git中,每个提交都保存一个快照,即每个文件的状态,而不是一组更改。但是,每个提交(几乎每个提交)也有一个父提交(上一个提交)。如果您问Git:“在提交a123456中发生了什么?”,Git会找到a123456的父提交,提取该快照,然后提取a123456本身,然后进行比较。a123456中不同的内容就是Git将告诉您的内容。由于每个提交都是完整的快照,因此很容易还原到特定提交中特定文件的特定版本。例如,您只需告诉Git:“从提交a123456获取文件b.ext”,现在您就有了来自提交a123456的文件b.ext的版本。这可能是您要求的内容,并且是链接问题和当前答案(从我打字时的情况)提供的内容。不过,您编辑了您的问题,要求不同的东西。
稍微多一点背景信息 现在我必须猜测您的五个提交的实际哈希ID。 (每个提交都有唯一的哈希ID。哈希ID - 大而丑陋的字母和数字字符串 - 是提交的“真名”。这个哈希ID永远不会改变;只要该提交存在,使用它将始终得到该提交。)但它们是大而丑陋的字母和数字字符串,因此,不是猜测例如8858448bb49332d353febc078ce4a3abcc962efe,我将只称您的“提交1”为D,您的“提交2”为E等。由于大多数提交只有一个父提交,这使Git能够从新的提交向旧的提交向后跳转,因此让我们用这些向后箭头将它们排成一行。
... <-D <-E <-F <-G <-H   <--master
分支名称像“master”一样实际上只是保存了该分支上的最新提交的哈希ID。我们说该名称“指向”该提交,因为它具有使Git检索提交的哈希ID。因此,“master”指向“H”。但是“H”具有“G”的哈希ID,因此“H”指向其父项“G”;“G”具有“F”的哈希ID;依此类推。这就是Git如何能够在第一时间向您显示提交“H”的方式:您询问Git“master是哪个提交?”,它会说“H”。您请求Git显示H,Git提取“GH”并比较它们,告诉您H中发生了什么变化。
我想撤消文件b在提交3 [F] 中发生的更改,但我希望保留提交4和5 [GH] 对该文件所做的更改。请注意,此文件版本可能不会出现在任何提交中。如果我们以提交E(您的提交2)中的形式获取该文件,则得到的文件没有来自F的更改,但未添加GH 的更改。显然,这将要麻烦一些。
Git提供了git revert来执行此操作,但它会做得过多。git revert命令旨在处理这种情况,但它是基于提交级别的。实际上,git revert将快照变成一组更改,就像每个查看提交的Git命令一样,通过将提交与其父提交进行比较。因此,对于提交F,它将其与提交E进行比较,找到文件和的更改。然后,它将这些更改反向应用于当前提交。与提交F所做的完全相反(尝试)撤消它们。
(这实际上更加复杂,因为这个“撤消更改”的想法还考虑了自提交F以来所做的其他更改,使用Git的三次合并机制。)
撤消某个特定提交中的所有更改后,git revert现在会创建一个新提交。因此,如果您执行git revert <哈希值F>,则会得到一个新提交,我们可以称之为I
...--D--E--F--G--H--I   <-- master

撤销F对三个文件所做的更改,产生三个版本,这些版本可能都不是之前提交中的版本。但这太过了:你只想要撤销Fb所做的更改。

因此解决方案是少做一点,或者做得太多然后再修复它。我们可以手动使用几个Git命令来找到差异并反向应用相同的更改,就像我们已经描述的git revert行为一样。让我们从git diff或缩写版的git show开始:这两个命令都将快照转换为更改。

使用git diff,我们指向父级E和子级F,并询问Git:“这两者之间有什么区别?”。Git提取文件,比较它们,并显示更改内容。使用git show,我们将Git指向提交F;Git会自动找到父级E,提取文件并比较它们,然后展示更改内容(前缀为日志消息)。也就是说,git showcommit等同于git log(只针对一个提交)后跟git diff(从该提交的父级到该提交)。Git将显示的更改本质上是指令:它们告诉我们,如果我们从E中开始,移除一些行并插入一些其他行,我们将得到F中的文件。所以我们只需要反转差异即可,这很容易做到。事实上,我们有两种方法可以做到这一点:git diff中交换哈希ID,或使用-R标志到git diffgit show。然后,我们会收到这样的指令:如果您从F中开始,并应用这些指令,则将获得E的文件。当然,这些指令将告诉我们对于全部三个文件abc进行更改。但现在我们可以剥离两个文件的指令,只保留我们想要的那个。

再次说明,有多种方法可以做到这一点。其中一个显而易见的方法是将所有指令保存在文件中,然后编辑文件:git show -R F的哈希值> /tmp/instructions(然后编辑/tmp/instructions)。不过,还有一种更简单的方法,即告诉Git:只显示特定文件的指令。我们关心的文件是b,因此:git show -RF的哈希值-- b> /tmp/instructions。如果您检查指令文件,现在应该描述如何对b进行反更改,以使其看起来像E中的内容。现在我们只需要让Git应用这些指令,但不是从提交F的文件中,而是使用当前提交H中已经准备好打补丁的工作目录中的文件。应用补丁(一组关于如何更改某些文件的指令)的Git命令是git apply

git apply < /tmp/instructions

使用这个命令应该就行了。请注意,如果指令要求更改之后由提交 G 或 H 更改的 b 中的行,则此方法将失败。这就是 git revert 更为智能的地方,因为它可以完成整个“合并”过程。

一旦指令成功应用,我们可以检查文件,确保它看起来正确,并像往常一样使用 git addgit commit

(附注:您可以使用以下命令一次性完成所有操作:

git show -R hash -- b | git apply

而且,git apply 还有其自己的 -R--reverse 标志,因此您可以拼写如下命令:

git show hash -- b | git apply -R

它的作用相同。还有其他 git apply 标志,包括 -3 / --3way,让它做更高级的事情,就像 git revert 一样。)

“做太多,然后撤销一些”的方法

另一种相对容易处理这种情况的方法是让 git revert 执行全部撤销操作。当然,这将撤消您不想撤消的其他文件的更改。但是我们在前面展示了如何轻松从任何提交中获取任何文件。因此,假设我们让 Git 撤消 F 中的所有更改:

git revert hash-of-F

这样会创建新的提交 I,以撤消 F 中的所有更改。

...--D--E--F--G--H--I   <-- master

现在可以轻松地使用 git checkout 命令从提交记录 H 中检出两个文件 ac:

git checkout H的哈希值 -- a c

然后创建一个新的提交记录 J:

...--D--E--F--G--H--I--J   <-- master

I is no longer needed and can be abandoned by using git commit --amend when creating J, which sets H as the parent of J and effectively pushes I out of the way. However, I still exists and will expire after a month.

The original files a, b, and c, in both I and J, are as intended. The files a and c in J also match those in H.

                   I   [abandoned]
                  /
...--D--E--F--G--H--J   <-- master
或者,git revert有一个-n标识,告诉Git:撤销更改,但不要提交结果。(这也允许使用脏索引和/或工作树进行撤销,尽管如果确保从提交H开始清洁检出,则不必担心这意味着什么。)这里我们将从H开始,撤销F,然后告诉Git:从提交H获取文件ac

git revert -n F的哈希值
git checkout HEAD -- a c
git commit

由于在执行此操作时我们在提交H上,因此可以使用名称HEAD来引用提交H中的ac的副本。


谢谢,我遇到了完全相同的问题,你的答案正是我需要的。不过有一个问题:为什么要去掉I?如果J是HEAD,那不是意味着实际内容就是我们想要的吗?去掉I是绝对必要的吗? - Nefrasky

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接