仅还原推送提交的单个文件

Revert only a single file of a pushed commit

下面是推送的提交历史。

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)

我想还原 文件 b 提交 3 发生的更改。 但我希望在提交 4 和 5 中对该文件进行更改。

假设您想要的提交的哈希是 c77s87:

git checkout c77s87-- file1/to/restore file2/to/restore

git结帐主页提供更多信息。

在Git中,每次提交都会保存一个快照——也就是说,每个文件的状态——而不是一个一组更改。

然而,每个提交——好吧,几乎每个提交——也有一个parent(前一个)提交。如果你问 Git,例如,提交 a123456 时发生了什么?,Git 所做的就是找到 parenta123456,提取 那个 快照,然后提取 a123456 本身,然后比较两者。 a123456有什么不同,Git就会告诉你。

由于每次提交都是一个完整的快照,因此很容易将恢复为特定提交中特定文件的特定版本。你只要告诉 Git: Get me file b.ext from commit a123456,例如,现在你有文件 [=262= 的版本]b.ext 来自提交 a123456。这就是您似乎要问的问题,因此链接的问题和当前答案(截至我输入此内容时)提供的内容。不过,您编辑了您的问题,要求的内容完全不同。

更多背景知识

我现在必须猜测您的五次提交中每一次的实际哈希 ID。 (每个提交都有一个唯一的哈希 ID。哈希 ID——由字母和数字组成的又大又丑的字符串——是提交的 "true name"。这个哈希 ID 永远不会改变;使用它总是会让你 提交,只要该提交存在即可。)但它们是由字母和数字组成的又大又丑的字符串,所以不用猜测,比如 8858448bb49332d353febc078ce4a3abcc962efe,我会直接调用你的 "commit 1" D,你的"commit 2" E,等等。

由于大多数提交都有一个 parent,它允许 Git 从较新的提交向后跳到较旧的提交,让我们用这些向后箭头将它们排列成一行:

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

一个 分支名称 就像 master 实际上只是保存了该分支上 最新 提交的哈希 ID。我们说名称 指向 提交,因为它具有让 Git 检索提交的哈希 ID。所以 master 指向 H。但是H的哈希ID是G,所以H指向它的parentGG 的哈希 ID 为 F;等等。这就是 Git 首先设法向您展示提交 H 的方式:您问 Git 哪个提交是 master? 并且它说 H。你让 Git 给你看 H 和 Git 提取 GH 并比较它们,然后告诉你在 H.

发生了什么变化

您要求的内容

I want to revert the changes happened to File b of Commit 3 [F]. But I want the changes [that] happened to this file in commit 4 and 5 [G and H].

请注意,此版本的文件可能不会出现在任何提交中。如果我们采用提交 E(您的提交 2)中出现的文件,我们会得到一个没有来自 F 的更改的文件,但它没有来自 G 和 [= 的更改29=] 添加到它。如果我们继续 doG 的更改添加到它,那可能与 in G;如果我们在那之后将 H 的更改添加到它,那可能与 in H.

的内容不同

显然,这会有点困难。

Git 提供了 git revert 来做这个,但是做的太多了

git revert 命令旨在执行此类操作,但它是在 commit-wide 基础上执行的git revert 所做的实际上是弄清楚某些提交中发生了什么变化,然后(尝试)撤消 只是 那些变化.

这里有一个很好的思考 git revert 的方法:它将提交——快照——变成一组更改,就像所有其他查看提交的 Git 命令一样,通过将提交与其 parent 进行比较。因此,对于提交 F,它会将其与提交 E 进行比较,找到对文件 abc 的更改。然后——这是第一个棘手的部分——它 reverse-applies 那些更改 到你的 current 提交。也就是说,由于您实际上是在提交 Hgit revert 可以获取所有三个文件中的任何内容——abc——并且(尝试)撤消 确切地 他们提交E.

(实际上有点复杂,因为这个"undo the changes"想法还考虑了自提交F以来所做的其他更改,使用Git的three-way合并机械。)

拥有来自某个特定提交的所有更改 reverse-applied,git revert 现在进行 new 提交。所以如果你做了 git revert <hash of F> 你会得到一个 new 提交,我们可以称之为 I:

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

其中 F 对所有三个文件的更改都被撤消,生成的三个版本可能不在早期提交的 any 中。但这太多了:您只想要 Fb backed-out.

的更改

所以解决的办法就是少做一点,或者做多了,然后再去解决

我们我已经将 git revert 操作描述为: 找到更改,然后 reverse-apply 相同的更改 。我们可以使用一些 Git 命令自己手动完成此操作。让我们从 git diff 或 short-hand 版本 git show 开始:这两个版本都将 快照 转换为更改。

  • 使用git diff,我们将Git指向parentE和childF并询问Git: 这两个有什么区别? Git 提取文件,比较它们,并向我们展示发生了什么变化。

  • 使用git show,我们指向Git提交F; Git 自己找到 parent E,然后提取文件并比较它们并向我们展示发生了什么变化(以日志消息为前缀)。也就是说,git show <em>commit</em> 相当于 git log(仅针对那一次提交),然后是 git diff(从该提交的 parent,到该提交)。

Git 将显示的更改本质上是 说明: 它们告诉我们,如果我们从 E 中的文件开始,删除一些行,并插入一些其他行,我们将得到 F 中的文件。所以我们只需要反转差异,这很容易。事实上,我们有两种方法可以做到这一点:我们可以用 git diff 交换哈希 ID,或者我们可以使用 -R 标志来 git diffgit show。然后我们会得到说明,本质上说:如果您从 F 中的文件开始,并应用这些说明,您将获得 E 中的文件。

当然,这些说明会告诉我们更改所有三个 文件,abc。但是现在我们可以剥离三个文件中两个文件的指令,只留下我们想要.

的指令

同样,有多种方法可以做到这一点。显而易见的一种是将所有指令保存在一个文件中,然后编辑该文件:

git 显示-R <em>hash-of-F</em> > /tmp/instructions

(然后编辑 /tmp/instructions)。不过,还有一种更简单的方法,那就是告诉 Git: 只需为特定文件显示说明即可 。我们关心的文件是b,所以:

git show -R <em>hash-of-F</em> -- b > /tmp/instructions

如果您查看说明文件,它现在应该描述如何使用 F 和 un-change b 中的内容,使其看起来像 E 中的内容.

现在我们只需要 Git 应用这些指令,除了我们将使用来自 current[= 的文件而不是来自提交 F 的文件338=] 提交 H,它已经在我们的 work-tree 中准备好进行修补。应用补丁的 Git 命令——一组关于如何更改某些文件集的指令——是 git apply,因此:

git apply < /tmp/instructions

应该可以解决问题。 (不过请注意,如果指令说要更改 b 中随后被提交 GH 更改的行,这将 失败 。这就是 git revert 更聪明的地方,因为它可以完成整个 "merge" 的事情。)

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

(旁注:您可以使用以下方法一次完成所有操作:

git show -R <em>hash</em> -- b | git申请

而且,git apply 也有自己的 -R--reverse 标志,因此您可以这样拼写:

git 显示 <em>hash</em> -- b | git 应用-R

做同样的事情。还有额外的 git apply 标志,包括 -3 / --3way 可以让它做更奇特的事情,很像 git revert 做的。)

"do too much, then back some of it out" 方法

另一种相对简单的处理方法是让 git revert 完成所有工作。当然,这将撤销对 other 文件的更改,您不希望 backed-out。但是我们在顶部展示了从 any 提交中获取 any 文件是多么容易得离谱。那么,假设我们让 Git 撤消 all F:

中的更改

git 还原 <em>hash-of-F</em>

这使得新提交 I 退出 一切 in F:

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

现在 git checkout 这两个文件 ac from commit H:

git结帐<em>hash-of-H</em> -- a c

然后进行新的提交 J:

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

IJ中的文件b是我们想要的方式,[=127中的文件ac =] 是我们想要的方式——它们匹配 H 中的文件 ac——所以我们现在差不多完成了,除了烦人的额外提交 I.

我们可以去掉I 有几种方式:

  • 在制作 J 时使用 git commit --amend:这会将 I 推开,使提交 J 使用提交 H 作为 J 的 parent。 Commit I 仍然存在,只是被废弃了。最终(大约一个月后)它会过期并真正消失。

    提交图,如果我们这样做,看起来像这样:

                       I   [abandoned]
                      /
    ...--D--E--F--G--H--J   <-- master
    
  • 或者,git revert 有一个 -n 标志告诉 Git: 执行还原,但不要提交结果。 (这也允许使用脏索引 and/or work-tree 进行还原,但如果您确保从提交 H 的干净检出开始,则不需要担心这意味着什么。)这里我们将从 H 开始,还原 F,然后告诉 Git 获取文件 ac 从提交返回 H:

    git revert -n <em>hash-of-F</em>
    git checkout HEAD -- a c
    git commit

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

(Git 是 Git,还有六种其他方法可以做到这一点;我只是使用我们在这里一般说明的方法。)