恢复错误合并后恢复丢失的更改

Recovering lost changes after reverting bad merge

我对 Git 比较陌生,在误解帮助文章后犯了一个(愚蠢的)错误,我不确定如何使用 Git 完全解决问题而不是手动重新引入更改为目标 b运行ch.

S---sc1---sc2---sc3-----sc4----M4---R1---M5---sc5
 \                         \  /         /
  T1-------------------M2---M3--------R2
   \               \  /
    F---fc1---fc2---M1

一些注意事项:S是这个场景中的主b运行ch,T1是从S拉出来的b运行ch, F 是我从 T1.

中提取的特征 b运行ch

我设置了自动合并,所以当提交到 T1 b运行ch 时,它们是 运行 通过持续集成然后自动合并到ST1 b运行ch 中有一个文件与另一团队成员提交的 S 存在合并冲突,所以我决定在完成 F.

我将T1合并为FM1),然后将F合并为T1M2)。考虑到我过去遇到的合并冲突解决方案不符合我预期的问题,我想我会尝试一些新的东西:将冲突文件从 S 合并到 T1,解决合并问题在那里发生冲突,从合并中删除所有其他文件,然后允许持续集成合并所有内容直到 S

我开始了从 ST1 (M3) 的合并,但没有提交,解决了冲突,从合并中删除了其他 (~200) 个文件,然后提交。这会自动合并到 S (M4).

我立即注意到,排除那些约 200 个文件似乎完全消除了更改,这相当于 2 个团队大约一个月的工作量。我(错误地)决定最好的行动方案是迅速采取行动并在我的错误进入其他任何人的本地回购之前恢复合并提交 M4M3。我首先恢复了 M4 (R1),一旦提交,我恢复了 M3 (R2)。我认为这是正确的顺序,因为我不确定当自动合并启动时,反过来是否会引入问题。最终 R2 被提交并自动合并到 S (M5).

这解决了其他人的更改被清除的问题,但我在 F 中的所有更改加上最初有合并冲突的文件都从 S 中消失了。我能够将单个文件的更改直接提交到 S (sc5),但是 F 中的更改要复杂得多。他们仍然生活在 T1,但由于他们作为 R1 的一部分从 S 恢复,我不能只提交他们回来。

我花了一天的大部分时间试图弄清楚如何最好地让这些变化达到 S,但是 git rebasegit cherry-pick 看起来不像他们会做我需要的,尽管我很清楚我在这方面可能是错的。如果有人比我更擅长 Git 可以建议至少一个起点,那将是惊人的。谢谢!

编辑: 从图中删除了 unhelpful/confusing 个点。 M2 没有自动合并到 S,因为我试图用 M3 解决合并冲突。

编辑 2: 在阅读了 torek 的精彩解释后,我开始尝试重新设置基准。我忘记了我在 F 的历史中多次将 T1 b运行ch 合并到 F b运行ch 中,因为有多少时间此功能 b运行ch 跨越。这意味着有很多很多合并冲突需要解决。

在 torek 对此的回应中,我尝试了合并壁球。我最初的想法是我需要将合并压缩中的新 b运行ch 合并到 T1 b运行ch,然后合并 T1 b运行ch 到 S,但我 运行 遇到了同样的问题,但它没有看到更改。我认为这是因为更改已经存在于 T1 中,所以它基本上只是将相同的、先前还原的更改反馈回 S,而 S 不需要它们。

编辑 3: 多亏了 torek 的解释非常详细的答案(非常感谢!),我正在完成合并南瓜,然后合并解决冲突后 S b运行ch 的结果。

这很长,所以请随意跳过您已经知道的部分(或一直滚动到最后)。每个部分都有设置信息来解释发生了什么,或者我们在做什么,在后面的部分。

Introduction-y位

让我从 re-drawing 这个图表开始(我认为它是一个部分图表,但它包含我们需要的关键提交)我喜欢的方式:

S0--sc1---sc2---sc3-----sc4----M4---R1---M5---sc5   <-- branch-S
  \                        \  /         /
   T0-------------o----M2---M3--------R2   <---- branch-T1
    \              \  /
    F0--fc1---fc2---M1   <------------------- branch-F

此处,分支名称branch-Sbranch-T1branch-F,这些名称当前标识哈希ID为一些人类无法发音和不可能记住的东西,但我们分别调用 sc5R2M1。任何 o 节点都是没有特别区分的提交,实际上可能代表任意数量的提交。命名的 fc<number>s 是功能分支上的一些提交集,M<number> 提交被合并。我重命名了第一个提交 S0T0F0 只是为了将它们与分支名称区分开来。

有些合并是手动进行的:

$ git checkout <branch-name>
$ git merge [options] <other-branch>
... fix up conflicts if necessary, and git commit (or git merge --continue)

其他合并是由软件进行的,只有在没有冲突的情况下才会发生。 R 提交来自 运行ning:

git checkout <branch>
git revert -m 1 <hash ID of some M commit>

其中 <branch> 要么是 T1 要么是 S,而 -m 1 是因为你总是必须告诉 git revert 哪个 parent在还原合并时使用,它几乎总是 parent #1.

进行提交会移动一个分支名称

最简单的Git提交图是一条直线,有一个分支名称,通常是master:

A--B--C   <-- master (HEAD)

这里需要提一下Git的index。最好将索引描述为 Git 构建 next 提交的地方。它最初包含保存在当前提交中的每个文件(此处 C):您检查此提交,使用来自提交 C 的文件填充索引和 work-tree。名字master指向这个提交,名字HEAD附加到名字master.

然后修改work-tree中的文件,使用git add复制回索引,使用git add复制new如果需要,将文件放入索引,然后 运行 git commit。通过将这些索引副本冻结到快照中来进行新的提交。 Git 然后添加快照元数据(您的姓名和电子邮件、您的日志消息等)以及当前提交的哈希 ID,以便新提交指向现有提交。结果是:

A--B--C   <-- master (HEAD)
       \
        D

有了新的提交,有了新的唯一哈希 ID,只是悬在半空中,没有什么值得记住的。因此,进行新提交的最后步骤是将新提交的哈希ID写入分支名称:

A--B--C--D   <-- master (HEAD)

现在当前提交是 D,索引和当前提交匹配。如果您 git add 编辑了 work-tree 中的所有文件,那也与当前提交和索引匹配。如果没有,您可以 git add 更多文件并再次提交,使名称 master 指向新提交 E,依此类推。在任何情况下,新提交的(单个)parent 是当前提交 .

关于合并

让我概述一下 git merge 的实际工作原理。它在某些情况下和某些方面非常简单,让我们从最简单的 true-merge 案例开始。考虑一个看起来像这样的图表:

          o--...--L   <-- mainline (HEAD)
         /
...--o--*
         \
          o--...--R   <-- feature

我们有 运行 git checkout mainline; git merge feature,所以我们告诉 Git 合并分支 feature / 提交 R 到分支 mainline /提交 L。为此,Git 必须首先找到 merge base 提交。粗略地说,合并基础是 "nearest" 共同提交——即, 可从 访问——两个分支。在这个简单的例子中,我们从 L 开始向后走到更早的提交,然后从 R 开始向后走,我们遇到的第一个地方是提交 *,所以这就是合并基地.

(有关可达性的更多信息,请参阅 Think Like (a) Git。)

找到合并基础后,Git 需要同时打开 L (left-side / local / --ours) 和 R (right-side / remote / --theirs) 快照到 change-sets。这些 change-sets 告诉 Git 我们在 mainline 自合并基础 * 之后做了什么,以及他们在 feature 自合并基础以来做了什么。这三个commit都有hash ID,是三个commit的真实名称,所以Git可以在内部运行等价于:

git diff --find-renames <hash-of-*> <hash-of-L>   # what we changed
git diff --find-renames <hash-of-*> <hash-of-R>   # what they changed

合并简单地合并两组更改,并将合并的集合应用到*中快照中的文件。

当一切顺利时,Git 以通常的方式进行新提交,除了新提交有 两个 parents。这使得当前分支指向新的合并提交:

          o--...--L
         /         \
...--o--*           M   <-- mainline (HEAD)
         \         /
          o--...--R   <-- feature

M的第一个parent是L,第二个是R。这就是为什么 reverts 几乎总是使用 parent #1,以及为什么 git log --first-parent 只 "sees" 主线分支,从ML 而完全忽略 R 分支。 (注意这里的branch这个词指的是图的结构,而不是像feature那样的branch names:此时,我们可以完全删除 name feature。另见 What exactly do we mean by "branch"?)

当事情出错时

如果两个 change-sets 在 "bad way" 中重叠,合并将停止,并出现 合并冲突 。特别是,假设 base-vs-L 表示要更改文件 F 的第 75 行,而 base-vs-R 表示要更改文件 F。如果 change-sets 都说要进行 相同的 更改,那么 Git 就可以了:两个更改的组合就是进行一次更改。但是,如果他们说要进行 不同的 更改,则 Git 会声明合并冲突。在这种情况下,Git会自行停止,让你收拾残局。

由于有三个输入,Git 此时将在索引中保留文件 F 所有三个 版本。通常索引有每个要提交的文件的副本,但在这个冲突解决阶段,它最多有三个副本。 ("up to" 部分是因为您可能会有其他类型的冲突,由于 space 原因我不会在这里讨论。)同时,在 work-tree 文件 F 的副本,Git 将其近似值保留为合并,work-tree 文件中的两组或全部三组行与 <<<<<<< / >>>>>>> 他们周围的标记。 (要获得所有三个,请将 merge.conflictStyle 设置为 diff3。我更喜欢这种解决冲突的模式。)

如您所见,您可以按照自己喜欢的方式解决这些冲突。 Git 假定无论您做什么都是 正确的 解决问题的方法:这会产生 exactly-correct 最终合并文件,或者在某些情况下缺少文件。

不管你做什么,最终的合并——假设你没有中止它,并且没有使用合并的 non-merge-y 变体之一——仍然会在图中产生相同的结果,无论你放入索引,通过解决冲突,就是合并的结果。那是合并提交中的新快照。

More-complex合并基地

当图表像上面那样非常简单时,合并基很容易看出来。但是图表不会保持简单,而您的图表并非如此。包含一些合并的图形的合并基础比较棘手。例如,考虑以下片段:

 ...--sc4----M4---R1
         \  /
...--M2---M3--------R2

如果 R1R2 是两个 tip 提交,它们的合并基础是什么?答案是M3,而不是sc4。原因是虽然 M3sc4 都是可以通过从 R1R2 开始并向后工作来到达的提交,但 M3 是 "closer" 到 R2 (退一步)。从 R1M3sc4 的距离是两跳——转到 M4,然后再返回一步——但是从 R2M3 是一跳,从 R2sc4 的距离是两跳。所以 M3 是 "lower"(以图表形式),因此赢得了比赛。

(幸运的是,您的图表没有平局的情况。如果 平局,Git 的默认方法是合并所有平局一次提交两个,以产生一个 "virtual merge base",这实际上是一个实际的,尽管是临时的提交。然后它使用通过合并合并基础所做的临时提交。这是 递归 策略,它的名字来源于 Git 递归合并合并基以获得合并基。你可以选择 resolve 策略,它简单地选择 seemingly-random 中的一个碱基——无论哪个碱基出现在算法的前面。这几乎没有任何优势:递归方法通常要么做同样的事情,要么是对随机选择获胜者的改进.)

这里的关键要点是进行合并提交更改,提交未来合并将选择作为它们的合并基础。即使在进行简单的合并时,这也很重要,这就是为什么我将其用粗体显示的原因。这就是为什么我们进行合并提交,而不是不合并的 squash-"merge" 操作。 (但是 squash 合并仍然有用,我们稍后会看到。)

问题介绍:哪里出了问题(方便以后避免)

上面的内容都解决了,现在我们可以看看真正的问题了。让我们从这个开始(稍微编辑以使用更新的提交和分支名称):

I merged branch-T1 into branch-F (M1), then branch-F into branch-T1 (M2).

我在这里假设合并 fc2(作为 branch-F 的 then-tip)和 o(作为 branch-T1 的 then-tip ) 进展顺利,Git 能够自己制作 M1。正如我们之前看到的,合并实际上不是基于 branches,而是基于 commits。它是创建一个调整分支名称的新提交。所以这就创建了M1,使得branch-F指向了M1M1 本身指向 branch-T1 的现有提示——我现在标记为 o 的提交——作为它的第二个 parent,fc2 作为它的第一个parent。 Git 通过 git diff-ing 合并基础 T0 的内容针对 o 计算出此提交的正确 内容 反对 fc2:

T0-------------o   <-- branch-T1
 \
 F0--fc1---fc2   <--- branch-F (HEAD)

一切顺利,Git 现在可以自己制作 M1

T0-------------o   <-- branch-T1
 \              \
 F0--fc1---fc2---M1   <--- branch-F (HEAD)

现在你 git checkout branch-T1git merge --no-ff branch-F(没有 --no-ff Git 只会做一个 fast-forward,这不是图片中的),所以 Git 找到 oM1 的合并基础,即 o 本身。这个合并很简单:从 oo 的差异是什么,加上从 oM1 的差异等于 M1 的内容。所以M2作为快照,和M1完全一样,Git轻松创建:

T0-------------o----M2   <-- branch-T1 (HEAD)
 \              \  /
 F0--fc1---fc2---M1   <--- branch-F

到目前为止一切顺利,但现在事情开始变得非常糟糕:

There was one file in the T1 branch that was having merge conflicts with S ... Given the issues I've had in the past with merge conflict resolutions not behaving how I expect, I thought I'd try something new: merging just the conflicting file from S into T1, solving the merge conflict there, removing all of the other files from the merge, and then allowing continuous integration to merge everything up to S.

所以,此时您所做的是:

git checkout branch-T1
git merge branch-S

因合并冲突而停止。此时的图表与上面的图表相同,但有更多上下文:

S0--sc1---sc2---sc3-----sc4   <-- branch-S
  \
   T0-------------o----M2   <-- branch-T1 (HEAD)
    \              \  /
    F0--fc1---fc2---M1   <-- branch-F

合并操作找到合并基础(S0),将其与两个提示提交(M2sc4)进行比较,合并结果更改,并将它们应用于S0 的内容。一个冲突文件现在在索引中作为三个输入副本,在 work-tree 中作为 Git 的合并努力,但带有冲突标记。同时所有无冲突文件都在索引中,准备冻结。

唉,您现在在冲突合并期间删除了一些文件 (git rm)。这将从索引和 work-tree 中删除文件。生成的提交 M3 将说明根据 merge-base S0 合并提交 M2sc4 的正确方法是删除这些文件。 (这当然是错误的。)

This auto-merged to S (M4).

在这里,我假设这意味着系统使用它拥有的任何 pre-programmed 规则,做了相当于:

git checkout branch-S
git merge --no-ff branch-T1

找到了提交 sc4branch-S 的提示)和 M3 的合并基础,即 M3,与 M3 的合并基础相同=50=] 和 M1 之前是 M1。因此,新提交 M4 在内容方面与 M3 匹配,此时我们有:

S0--sc1---sc2---sc3-----sc4----M4   <-- branch-S
  \                        \  /
   T0-------------o----M2---M3   <-- branch-T1
    \              \  /
    F0--fc1---fc2---M1   <-- branch-F

I noticed immediately that excluding those ~200 files looked to have wiped the changes out entirely, which equated to about a month's worth of work across 2 teams. I (incorrectly) decided the best course of action was to act swiftly and revert the merge commits M4 and M3 before my mistake got into anyone else's local repos. I first reverted M4 (R1) and once that was committed I reverted M3 (R2).

实际上,这是一件好事!它获得正确的 content,这在您立即执行时非常有用。使用 git checkout branch-s && git revert -m 1 branch-S(或 git revert -m 1 <hash-of-M4>)从 M4 创建 R1 基本上撤消了内容方面的合并,因此:

git diff <hash-of-sc4> <hash-of-R1>

应该什么都不产生。同样,使用 git checkout branch-T1 && git revert -m 1 branch-T1(或与散列相同)从 M3 创建 R2 会在内容方面撤消合并:比较 M2R2,你应该看到相同的内容。

撤销合并会撤销内容,但不会撤销历史

现在的问题是 Git 认为您的功能分支中的所有更改都已正确合并。任何 git checkout branch-T1git checkout branch-S 后跟 git merge <any commit within branch-F> 将查看图表,遵循从提交到提交的 backwards-pointing 链接,并看到 branch-F 中的此提交 -例如 fc2M1 已经合并 ​​

让他们加入的技巧是进行 new 提交,它与 F0 到 [=49= 的 commit-sequence 执行相同的操作] 确实如此,那是 not 已经合并了。最简单但最丑陋的方法是使用 git merge --squash。更难但也许更好的方法是使用 git rebase --force-rebase 创建一个 new 功能分支。 (注意:这个选项有三种拼法,最容易打的是-f,但是Linus Torvalds' description中的是--no-ff。我觉得最难忘的是--force-rebase版本,但实际上我自己会使用 -f。)

让我们快速浏览一下两者,然后考虑使用哪个以及为什么。无论哪种情况,完成后,这次您都必须正确合并新的提交,而不删除文件;但既然你知道 git merge 真正在做什么,那么做起来应该容易得多。

我们首先创建一个新的分支名称。我们可以re-usebranch-F,但是我觉得不这样比较清楚。如果我们想使用 git merge --squash,我们创建这个新的分支名称指向提交 T0(忽略 T0 之后有提交的事实——记住,任何分支名称都可以指向 任何提交):

T0   <-- revised-F (HEAD)
 \
  F0--fc1--fc2--M1   <-- branch-F

如果我们想使用 git rebase -f,我们创建这个指向提交的新名称 fc2:

T0-----....
 \
  F0--fc1--fc2--M1   <-- branch-F, revised-F (HEAD)

我们这样做:

git checkout -b revised-F <hash of T0>   # for merge --squash method

或:

git checkout -b revised-f branch-F^1      # for rebase -f method

取决于我们要使用哪种方法。 (^1~1 后缀——你可以使用任何一个——排除了 M1 本身,后退一步 first-parent 到 fc2。这里的想法是排除提交 o 和任何其他可从 o 访问的提交。不需要其他合并到 branch-F 中底行提交,在这里。)

现在,如果我们想使用 "squash merge"(它使用 Git 的合并机制而不进行合并 commit),我们 运行:

git merge --squash branch-F

这里使用我们当前的commit,加上branch-F的提示(commit M1),作为合并的左右两边,找到它们共同的commit作为合并基础。 common commit当然只是F0,所以合并结果就是M1中的快照。但是,新的提交只有一个 parent:它根本不是合并提交,它看起来像这样:

   fc1--fc2--M1   <-- branch-F
  /
F0-------------F3   <-- revised-F (HEAD)

F3 中的 快照 M1 中的相匹配,但提交本身是全新的。当 Git 将 F3 视为提交时,它会收到一条新的提交消息(您可以对其进行编辑),其效果是进行从 F0M1.

如果我们选择变基方法,我们现在运行:

git rebase -f <hash-of-T0>

(您可以使用 o 的哈希值,即 branch-F^2,即 M1 的第二个 parent。在这种情况下,您可以从revised-F 指向 M1 本身。这可能是我会做的,以避免不得不剪切和粘贴大量可能有拼写错误的哈希 ID,但除非你做了一个,否则它是如何工作的并不明显很多图形操作练习。)

也就是说,我们要将 F0fc2 的提交复制到 new 提交,并使用新的哈希 ID。这就是这个 git rebase 将要做的事情(参见其他 Whosebug 答案 and/or Linus 上面的描述):我们得到:

  F0'-fc1'-fc2'   <-- revised-F (HEAD)
 /
T0-----....
 \
  F0--fc1--fc2--M1   <-- branch-F

现在我们 revised-F 指向单个提交 (F3) 或提交链(链结束于 fc2',[=135= 的副本]), 我们可以 git checkout 一些其他分支和 git merge revised-F.

根据评论,这里有两个执行 re-merge

的路径

我假设此时您有一个 squash-merge 结果(一个 single-parent 提交,它不是合并,但确实包含所需的快照,我称之为 F3这里)。我们还需要稍微修改一下 re-drawn 图,根据表明有更多合并到 branch-F:

的评论
S0--sc1---sc2---sc3-----sc4----M4---R1---M5---sc5   <-- branch-S
  \                        \  /         /
   T0-----o-------o----M2---M3--------R2   <---- branch-T1
    \      \       \  /
    F0--fc1-o-fc2---M1   <--------------- branch-F

现在我们将添加 revised-F 分支,它应该有一个提交是 F0T0 的后代。哪个并不重要。由于我之前使用了F0,所以我们在这里使用它:

S0--sc1---sc2---sc3-----sc4----M4---R1---M5---sc5   <-- branch-S
  \                        \  /         /
   T0-----o-------o----M2---M3--------R2   <---- branch-T1
    \      \       \  /
    F0--fc1-o-fc2---M1   <--------------- branch-F
      \
       ---------------------------------F3   <-- revised-F

提交F3的内容与M1的内容匹配(所以git diff branch-F revised-F什么也没说),但是F3的parent这里是F0。 (注意:有使用git commit-tree创建F3的快捷方式,但只要它已经存在并且匹配M1 content-wise,我们就可以使用它。)

如果我们现在这样做:

git checkout branch-T1
git merge revised-F

Git 将找到提交 R2(分支 T1 的提示)和 F3revised-F 的提示)之间的合并基础。如果我们沿着 R2 的所有向后(向左)链接,我们可以通过 M3 然后 M2 然后一些 o 到达 T0 ,最后T0,或者我们可以通过 M3 然后 M2 然后 M1 然后 fc2 回到 F0 到达 F0。同时我们可以从 F3 直接到达 F0,只需要一跳,所以合并基础可能是 F0.

(要确认这一点,请使用 git merge-base:

git merge-base --all branch-T1 revised-F

这将打印一个或多个哈希 ID,每个合并基一个。理想情况下只有一个合并基础,即提交 F0。)

Git 现在将 运行 两个 git diff,比较 F0F3 的内容——即我们为完成所做的一切特征——并将 F0 的内容与 R2 的内容进行比较,位于 branch-T1 的顶端。当两个差异更改相同文件的相同行时,我们会遇到冲突。在其他地方,Git 将采用 F0 的内容,应用合并的更改,并将结果准备好提交(在索引中)。

解决这些冲突并提交将为您提供一个新的提交结果:

S0--sc1---sc2---sc3-----sc4----M4---R1---M5---sc5   <-- branch-S
  \                        \  /         /
   T0-----o-------o----M2---M3--------R2-----M6   <---- branch-T1
    \      \       \  /                     /
    F0--fc1-o-fc2---M1   <-- branch-F      /
      \                                   /
       ---------------------------------F3   <-- revised-F

现在 M6 也许是 merge-able 到 branch-S


或者,我们可以直接合并到 branch-S。哪个提交是合并基础不太明显,但它可能又是 F0。这是同一张图:

S0--sc1---sc2---sc3-----sc4----M4---R1---M5---sc5   <-- branch-S
  \                        \  /         /
   T0-----o-------o----M2---M3--------R2   <---- branch-T1
    \      \       \  /
    F0--fc1-o-fc2---M1   <--------------- branch-F
      \
       ---------------------------------F3   <-- revised-F

从提交 sc5 开始,我们向后工作到 M5R2,我们现在处于与之前相同的情况。所以我们可以 git checkout branch-S 做同样的合并,解决类似的冲突——这次我们比较 F0sc5 而不是 R2,所以冲突可能会稍微小一些不同——并最终提交:

S0--sc1---sc2---sc3-----sc4----M4---R1---M5---sc5----M6   <-- branch-S
  \                        \  /         /           /
   T0-----o-------o----M2---M3--------R2   <------ / -- branch-T1
    \      \       \  /                           /
    F0--fc1-o-fc2---M1   <-- branch-F            /
      \                                         /
       ---------------------------------------F3   <-- revised-F

要验证F0是合并基础,像以前一样使用git merge-base

git merge-base --all branch-S revised-F

并查看您必须合并的内容,运行 从合并基础到两个提示的两个 git diff

(选择哪种合并由您决定。)