git 在合并过程中何时丢失更改



假设:

我们
  1. 有一个主分支,一位同事不小心添加了一系列应该属于新功能的提交(我们称之为A B C)。
  2. 发现了这一点,我告诉他将这些提交移动到一个新的分支,但保留后来在 master 中完成的其他不相关的提交。我把我问的这个问题发给他,并告诉他按照回答: git:如何将分支的根移回两次提交
  3. 几天后,当新功能分支准备就绪时,我将其合并到 master 中。
  4. 解决合并中的所有冲突后,我提交更改...
  5. 。我发现那些第一次提交(A B C提交)已经消失了。
  6. 我问我的同事,他说"他认为"他使用链接中提到的方法移动了这些更改(基本上:检查出最后一个常见提交,然后使用git cherry-pick只选择我们稍后想要的提交),但他记不清了。
  7. 我检查了存储库的历史记录,A B C在开始时位于功能分支中。它们看起来像是从主服务器成功迁移的。

鉴于上述情况,谁能解释为什么 git 丢失了这些更改?(我个人的理论是,git 以某种方式"记住"我们已经撤消了A B C提交,所以当它们来自新功能分支时,git 决定不合并它们。编辑:对不起,如果这个解释听起来太像"神奇的思维",但我不知所措。我欢迎任何尝试用更技术性的术语来解释,如果它是正确的)。

很抱歉无法提供更多详细信息,但我没有亲自在存储库中进行这些更改,因此无法提供所做工作的确切细节。

编辑:好的,正如这里建议的那样,我让我的同事在他的机器中执行git reflog,所以我在这里粘贴结果。回到我之前的(链接)问题,我们有这样的树:

A - B - C - D - E - F  master
 
- G - H  new feature branch

我们希望将 B 和 C 移动到新的功能分支。

所以,他寄给我的git reflog就在这里。提交5acb457对应于上图中的"提交 A":

4629c88 HEAD@{59}: commit: blah
f93f3d3 HEAD@{60}: commit: blah
57b0ea7 HEAD@{61}: checkout: moving from master to feature_branch
4b39fbf HEAD@{62}: commit: Added bugfix F again
4fa21f2 HEAD@{63}: commit: undid checkouts that were in the wrong branch
1c8b2f9 HEAD@{64}: reset: moving to origin/master
5acb457 HEAD@{65}: checkout: moving from 5acb4576eca4b44e0a7574eea19cca067c039dc5 to master
5acb457 HEAD@{66}: checkout: moving from master to 5acb4576eca4b44e0a7574eea19cca067c039dc5
1c8b2f9 HEAD@{67}: checkout: moving from 1c8b2f9bf54ca1d80472c08f3ce7d9028a757985 to master
1c8b2f9 HEAD@{68}: rebase: checkout master
5acb457 HEAD@{69}: checkout: moving from master to 5acb4576eca4b44e0a7574eea19cca067c039dc5
1c8b2f9 HEAD@{70}: checkout: moving from 5acb4576eca4b44e0a7574eea19cca067c039dc5 to master
5acb457 HEAD@{71}: checkout: moving from master to 5acb4576eca4b44e0a7574eea19cca067c039dc5
1c8b2f9 HEAD@{72}: merge origin/master: Fast-forward
5acb457 HEAD@{73}: checkout: moving from master to master
5acb457 HEAD@{74}: checkout: moving from 5acb4576eca4b44e0a7574eea19cca067c039dc5 to master
5acb457 HEAD@{75}: checkout: moving from undo_branch to 5acb4576eca4b44e0a7574eea19cca067c039dc5
5acb457 HEAD@{76}: checkout: moving from master to undo_branch
1c8b2f9 HEAD@{77}: checkout: moving from undo_branch to master
525dbce HEAD@{78}: cherry-pick: Bugfix F
a1a5028 HEAD@{79}: cherry-pick: Bugfix E
32f8968 HEAD@{80}: cherry-pick: Feature C
8b003cb HEAD@{81}: cherry-pick: Feature B
5acb457 HEAD@{82}: checkout: moving from 5acb4576eca4b44e0a7574eea19cca067c039dc5 to undo_branch
5acb457 HEAD@{83}: checkout: moving from master to 5acb4576eca4b44e0a7574eea19cca067c039dc5
1c8b2f9 HEAD@{84}: checkout: moving from 1c8b2f9bf54ca1d80472c08f3ce7d9028a757985 to master
1c8b2f9 HEAD@{85}: pull origin HEAD:master: Fast-forward
5acb457 HEAD@{86}: checkout: moving from master to 5acb4576eca4b44e0a7574eea19cca067c039dc5
5acb457 HEAD@{87}: reset: moving to 5acb4576eca4b44e0a7574eea19cca067c039dc5
1c8b2f9 HEAD@{88}: merge origin/master: Fast-forward
5acb457 HEAD@{89}: reset: moving to 5acb4576eca4b44e0a7574eea19cca067c039dc5
1c8b2f9 HEAD@{90}: checkout: moving from 5acb4576eca4b44e0a7574eea19cca067c039dc5 to master
5acb457 HEAD@{91}: checkout: moving from master to 5acb4576eca4b44e0a7574eea19cca067c039dc5
1c8b2f9 HEAD@{92}: merge origin/master: Merge made by the 'recursive' strategy.
7b912cd HEAD@{93}: checkout: moving from 7b912cdf33843d28dd4a7b28b37b5edbe11cf3b9 to master
7b912cd HEAD@{94}: cherry-pick: Bugfix F
df7a9cd HEAD@{95}: cherry-pick: Bugfix E
d4d0e41 HEAD@{96}: cherry-pick: Feature C
701c8cc HEAD@{97}: cherry-pick: Feature B
5acb457 HEAD@{98}: checkout: moving from master to 5acb4576eca4b44e0a7574eea19cca067c039dc5
22ecc3a HEAD@{99}: checkout: moving from 5acb4576eca4b44e0a7574eea19cca067c039dc5 to master
5acb457 HEAD@{100}: checkout: moving from master to 5acb4576eca4b44e0a7574eea19cca067c039dc5
22ecc3a HEAD@{101}: commit: bugfix E
3b568bc HEAD@{102}: checkout: moving from feature_branch to master
57b0ea7 HEAD@{103}: commit: blah
152c5b9 HEAD@{104}: checkout: moving from master to feature_branch
3b568bc HEAD@{105}: commit: bugfix D
fe3bbce HEAD@{106}: checkout: moving from feature_branch to master
152c5b9 HEAD@{107}: commit: blah
2318ebc HEAD@{108}: commit: blah
cc5ea32 HEAD@{109}: commit: blah
a5c2303 HEAD@{110}: commit: blah
544a99a HEAD@{111}: commit: blah
299f86a HEAD@{112}: commit: Feature G
fe3bbce HEAD@{113}: checkout: moving from master to feature_branch
fe3bbce HEAD@{114}: commit: Feature C
3852e71 HEAD@{115}: commit: Feature B
5acb457 HEAD@{116}: merge origin/master: Fast-forward

谁能理解连续这 4cherry-pick?我怀疑他并没有真正做git cherry-pick master~3的事情,特别是不是~3部分(诚然,当我第一次看到它时,它也让我失望了)。

提交 A、B 和 C 丢失的原因,那是因为这是您共享给同事的链接所做的。让我们用下面的图表来说明:

1. 假设您的同事所做的原始提交历史记录为

...X---A---B---C---D---E  master

2.将ABC移至feature分支。因此,您的同事从 master 创建了一个新的feature分支(提交E)或任何提交。并通过以下步骤变基:

git checkout -b feature
git cherry-pick master~5 master~2
...X---A---B---C---D---E  master

A'---B'---C' feature 

3. 修改master分支

git checkout X
git cherry-pick master~2..master
git branch -f master
git checkout master

提交结构如下所示:

...X---D---E  master

A'---B'---C' feature 

所以直接原因是命令git cherry-pick master~2..master.它将变基提交D并直接在提交XE,因此您无法在主分支上找到ABC

更新:

根据git flog,这些HEAD信息似乎不足以显示您的同事所做的事情。feature分支似乎从提交C签出,而不是D

3b568bc HEAD@{105}: commit: bugfix D
fe3bbce HEAD@{106}: checkout: moving from feature_branch to master
152c5b9 HEAD@{107}: commit: blah
2318ebc HEAD@{108}: commit: blah
cc5ea32 HEAD@{109}: commit: blah
a5c2303 HEAD@{110}: commit: blah
544a99a HEAD@{111}: commit: blah
299f86a HEAD@{112}: commit: Feature G
fe3bbce HEAD@{113}: checkout: moving from master to feature_branch
fe3bbce HEAD@{114}: commit: Feature C

所以结构应该是:

A---B---C---D---E  master

G---H feature

如果您只想更改提交结构,例如:

A ---D---E  master

B---C---G---H feature

您可以将master分支和feature分支重置为原始分支,然后在master分支上挑选提交,详细信息如下:

git checkout master
git reset --hard <original commit id for E>
git checkout feature 
git reset --hard  <original commit id for H>
git checkout master
git checkout <commit id for A>
git cherry-pick master~4..master~2 #To make the commits as A---D---E (drop B and C)
git branch -f master
git checkout master

让我们专注于合并结果,但首先快速浏览这部分(我已经重新绘制了图表):

回到我之前的(链接)问题,我们有这样的树:

A--B--C--D--E--F   <-- master
 
G--H   <-- feature

我们希望将 B 和 C 移动到新的功能分支。

结果应该是这样的(刻度线表示您现在拥有的提交是副本,而不是原始副本,因此它们的哈希 ID 已更改,因此每个获得原始副本的人都必须争先恐后地确保他们也使用新副本)。 但我只是假设它实际上看起来像这样:

A--D'-E'-F'   <-- master

B'-C'-G'-H'   <-- feature

(请注意,唯一未复制和切换到的提交是A

现在运行时:

git checkout master
git merge feature

Git 将按以下顺序执行这些操作:

  1. 获取当前提交的哈希 ID (git rev-parse HEAD)。
  2. 获取feature提示的哈希 ID (git rev-parse feature)。
  3. 找到这两个提交的合并基(在本例中为单个)合并基。 合并基础的技术定义是 DAG 中的最低共同祖先,但松散地说,它就在两个分支分道扬镳之前,这只是"提交 D'"。
  4. 运行相当于git diff D' F':用master的尖端比较合并基础。 这是"自合并库以来我们在master上更改的内容":文件(及其哈希ID版本)的大列表,以及任何计算的重命名信息等。
  5. 运行相当于git diff D' H':用feature的尖端比较合并基础。 这是"他们在feature上更改的内容",与步骤 4 中的方式相同。 我在步骤 4 中使用了"我们"一词,在步骤 5 中使用了"他们"一词,因为我们可以使用git checkout --oursgit checkout --theirs在合并冲突期间提取特定文件:--ours是指提交F'中的文件,即"我们"更改了什么,--theirs指的是提交H'中的文件。
  6. 尝试合并差异以获得单个变更集。

    如果 Git 能够自己完成所有这些组合,它就会宣布胜利,将此单个变更集应用于基本提交D',并以通常Mmaster的方式进行新的提交(让我们称之为合并M),除了M有两个父项:

    A--D'-E'-F'-----M   <-- master
              /
    B'-C'-G'-H'   <-- feature
    

    但是,如果自动合并失败,Git 会举起它的隐喻之手,给你留下一个烂摊子,你必须自己清理。 我们稍后会讨论这个问题。

三个输入,一个输出

请注意,此三向合并有三个输入

  • 合并基础的树
  • 当前(--oursHEAD)提示提交的树
  • 另一个(--theirs)提示提交的树

合并基在这里有效,因为它是两个提交偏离的最佳公共起点。Git 能够直接使用两个分支提示,因为每个提交都是一个完整的快照:1它永远不必查看所有中间提交,除了在图形方面以找到合并基础。

我们还故意掩盖了一堆微妙的技术问题,例如成对中断和重命名查找(见脚注 1),以及合并策略(-s ours意味着我们甚至不他们的策略)和策略选项(-X ours-X theirs)。 但只要您只是运行git merge feature并且很少或没有重命名需要担心,那就不是问题。

但是,这是关键项目之一,为了弄清楚 Git 将要做什么,您必须绘制图形,或以其他方式标识合并基础。获得合并基提交的哈希 ID 后,您可以(如果需要)针对两个提示提交git diff合并库,看看 Git 会做什么。 但是,如果合并基础不是您期望的提交,则合并将不会执行您期望它执行的操作。


1与 Mercurial 相比,在 Mercurial 中,每个提交或多或少都存储为其父提交的增量或变更集。 然后,您可能会认为 Mercurial 必须从合并基础开始,并沿着每个分支链进行每次提交。 但这里有两件事需要注意:首先,Mercurial 很可能必须在合并基础之前开始,因为这也可能是早期提交的变更集。 其次,假设沿着链条到任一小费,进行一些更改,然后退出。 当 Mercurial 合并最终变更集以实现与 Git 相同的合并时,提交及其回退对最终结果没有影响。 所以从这个意义上说,中间提交毕竟都无关紧要! 我们只需要它们来重建要组合的两个最终变更集。

但事实上,Mercurial 并没有这样做,因为 Mercurial 中的每个文件偶尔都会重新存储,完全完整,因此 Mercurial 不必遵循极长的变更集链来重建文件。因此,Mercurial 所做的实际上与 Git 所做的相同:它只是提取基本提交,然后提取两个提示提交,并执行两个差异。

这里有一个很大的技术差异,那就是 Mercurial 不必猜测重命名:中间提交,它必须遍历才能找到合并库,每个记录与其父提交相关的任何重命名,因此 Mercurial 可以确定每个文件的原始名称是什么, 以及它在任一提示中的新名称可能是什么。 Git 不记录重命名:它只是猜测如果路径dir/file.txt出现在合并库中,但不出现在一个或两个提示提交中,则可能是在一个或两个提示提交中重命名了dir/file.txt。 如果提示提交 #1 具有不在合并基库中的other/new.txt,则这是重命名的候选文件。

在某些情况下,Git 无法以这种方式找到重命名。 还有额外的控制旋钮。 如果文件更改"太多",则有一个可以中断配对,即让 Git 说仅仅因为dir/file.txt同时在基本和提示中,它实际上可能不是同一个文件。 还有另一个方法可以设置 Git 声明要匹配的文件的阈值,以便进行重命名检测。 最后,有一个最大配对队列大小,可配置为diff.renameLimitmerge.renameLimit. 默认合并配对队列大小大于默认差异配对队列大小(自 Git 版本 1.7.5 以来,目前为 400 vs 1000)。


如果有冲突,你会得到的混乱

当 Git 声明"合并冲突"时,它会在步骤 6 的中间停止。 它不会使新的合并提交M。 相反,它会给你留下一团糟,存储在两个地方:

  • 工作树可以最好地猜测它可以作为自动合并做什么,以及用冲突标记写出的所有冲突合并。 如果file.txt有冲突——Git 无法将"我们做了什么"与"他们做了什么"合并——它可能有几行看起来像这样:

    <<<<<<< HEAD
    stuff from the HEAD commit
    =======
    stuff from the other commit (H' in our case)
    >>>>>>> feature
    

    如果你将merge.conflictStyle设置为diff3(我推荐这个设置;另请参阅 diff3 应该是 git 上的默认冲突风格吗?),上面的内容被修改为包括合并库中的内容(在我们的例子中是提交D'),即在"我们"和"他们"更改它之前有什么文本:

    <<<<<<< HEAD
    stuff from the HEAD commit
    ||||||| merged common ancestors
    this is what was there before the two
    changes in our HEAD commit and our other commit
    =======
    stuff from the other commit (H' in our case)
    >>>>>>> feature
    
  • 同时,索引(您构建下一次提交的地方)对于每个冲突文件的每个"槽"最多有三个条目。 在这种情况下,对于file.txt,有三个版本的file.txt,它们被编号:

    • :1:file.txt:这是合并库中显示的file.txt的副本。
    • :2:file.txt:这是出现在我们(HEAD)提交中的file.txt的副本。
    • :3:file.txt:这是file.txt的副本,因为它出现在他们的(feature提示)提交中。

现在,仅仅因为file.txt存在冲突并不意味着 Git 无法自行解决其他一些更改。 例如,假设合并基版本为:

this is file.txt.
it has a bunch of lines.
we plan to change some of them on one side of the merge.
we plan to change other lines on the other side.
here is something to change without conflict:
la la la, banana fana fo fana
here is something else
to change with conflict:
this is what was there before the two
changes in our HEAD commit and our other commit
and finally,
here is something to change without conflict:
one potato two potato

HEAD中,让我们以这种方式读取文件,使用我们喜欢的提交次数来达到这一点:

this is file.txt.
it has a bunch of lines.
we plan to change some of them on one side of the merge.
we plan to change other lines on the other side.
here is something to change without conflict:
a bit from the Name Game
here is something else
to change with conflict:
stuff from our HEAD commit
and finally,
here is something to change without conflict:
one potato two potato

(请注意,我们进行了两个不同的更改区域。 默认情况下,git diff将它们合并为一个 diff 大块,因为它们之间只有一个上下文行,但git merge会将它们视为单独的更改。

在另一个(feature)分支中,让我们进行一组不同的更改,以便file.txt为:

this is file.txt.
it has a bunch of lines.
we plan to change some of them on one side of the merge.
we plan to change other lines on the other side.
here is something to change without conflict:
la la la, banana fana fo fana
here is something else
to change with conflict:
stuff from the other commit (H' in our case)
and finally,
here is something to change without conflict:
cut potato and deep fry to make delicious chips

同样,我们做了两个更改,但只有一个冲突。

合并文件的工作版本将采用冲突的每个更改,以便文件完整读取:

this is file.txt.
it has a bunch of lines.
we plan to change some of them on one side of the merge.
we plan to change other lines on the other side.
here is something to change without conflict:
a bit from the Name Game
here is something else
to change with conflict:
<<<<<<< HEAD
stuff from the HEAD commit
=======
stuff from the other commit (H' in our case)
>>>>>>> feature
and finally,
here is something to change without conflict:
cut potato and deep fry to make delicious chips

作为执行合并的人,您的工作是解决冲突。

您可以选择这样做:

git checkout --ours file.txt

或:

git checkout --theirs file.txt

但其中任何一个只是将"我们的"或"他们的">索引版本(从插槽 2 或 3)复制到工作树中。 无论您选择哪一个分支,您都将丢失来自另一个分支的更改。

您可以手动编辑文件,删除冲突标记并保留或修改部分或全部剩余行以解决冲突。

或者,当然,您可以使用任何您喜欢的合并工具来处理冲突。

但是,在所有情况下,您的工作树中的任何内容都将是您的最终产品。 然后,您应该运行:

git add file.txt

清除阶段 1、2 和 3 条目,并将文件的工作树版本复制到正常的阶段零file.txt。 这告诉 Git 合并现已解析为file.txt.

您必须对所有剩余的未合并文件重复此操作。 在某些情况下(重命名/重命名冲突、重命名/删除、删除/修改等),还有更多的工作要做,但这一切都归结为确保索引只有您想要的最终阶段零条目,而没有更高阶段的条目。 (您可以使用git ls-files --stage查看所有阶段的所有条目,尽管git status很好地总结了有趣的条目。 特别是,所有具有与提交HEAD完全匹配的阶段零条目的文件都非常无聊,git status会跳过它们。 如果有成百上千个这样的文件,那将非常有帮助。

解析索引中的所有文件后,运行git commit。 这使得合并提交M。 提交的内容是索引中的任何内容,即,您git add-ed 删除更高阶段索引条目并插入阶段零条目的任何内容。

使用git checkout同时签出解决

如上所述,git checkout --oursgit checkout --theirs只是从索引槽 2 或 3 获取副本并将其写入工作树。 这不会解析索引条目:所有插槽 1、2 和 3 未合并的条目仍然存在。 您必须git add回工作树文件以将其标记为已解决。 正如我们也指出的,这会丢失来自其他提示提交的任何更改。

但是,如果这是您想要的,那么有一个捷径。 您可以:

git checkout HEAD file.txt

或:

git checkout MERGE_HEAD file.txt

这会从 HEAD (F') 或 MERGE_HEAD (H') 提交中提取file.txt的版本。 这样做,它将内容写入file.txt阶段零,从而清除阶段 1、2 和 3。 实际上,它立即获得--ours--theirs版本git add结果。

同样,这会丢失提示提交的任何更改。

很容易弄错

这些解决步骤很容易出错。特别是,git checkout --oursgit checkout --theirs,以及使用HEADMERGE_HEAD的快捷方式版本,将对方的更改放到文件中。 您唯一会得到的迹象是合并结果缺少一些更改。 就 Git 而言,这是正确的结果:您希望删除这些更改;这就是在进行合并提交之前以这种方式设置阶段零索引条目的原因。

获得一个意外的合并基础也很容易,特别是如果你尝试做很多git rebasegit cherry-pick工作来复制提交并移动分支名称以指向新副本。 仔细研究提交 DAG 总是值得的。 从"一只狗"那里获得帮助:git log --all --decorate --oneline --graph一个lldecorateonelinegraph;或使用gitk或其他图形查看器来可视化提交图。 (除了--all,您还可以考虑使用有问题的两个分支名称,即 DOG,而不仅仅是任何旧的 A DOG:git log --decorate --oneline --graph master feature。 生成的图形可能更简单、更易于阅读。 但是,如果您进行了很多变基和挑选,--all可能会揭示更多。 您甚至可以将其与特定的 reflog 名称(例如feature@5)结合使用,尽管这有点冗长并且会产生相当混乱的图表。

你已经得到了很长的答案。让我补充一下:

我个人的理论是,git 以某种方式"记住"我们已经撤消了 A B C,所以当它们来自新功能分支时,git 决定不合并它们。

Git 从不"以某种方式"记住"有关存储库内容的任何内容。它也不会根据你以前做的事情来决定做或不做任何事情。在这方面非常干净。它的所有命令都只是处理其提交(以及在较低级别上存储的所有其他对象)正在构建的有向无环图的工具。为了使它更容易,它只添加内容,从不更改或删除任何内容。

除了提交(即作者、时间戳、父提交等)、树(即目录)、blob(即二进制数据)和一些不太重要的东西之外,仓库中实际上没有关于文件等的数据结构或进一步的管理信息。合并提交不会留下任何特定于"合并"的信息;它只是与多个父母的承诺。

当然没有魔法,没有记录的东西发生。存储库非常开放,您可以使用 git 命令从字面上查看所有内容,并且所有内容都完整记录(如果您有兴趣,可以搜索"git 数据结构"或"git internals")。如果您愿意,甚至修改内部对象也很容易。

有一点比特保留历史信息,这就是所谓的"rerere缓存",它存储以前的冲突解决方案,因此确实可以改变未来合并的行为。确实非常方便,但默认情况下未启用,当然与手头的主题无关。

编辑:对不起,如果这个解释听起来太像"神奇的思维",但我不知所措。我欢迎任何尝试用更技术性的术语来解释,如果它是正确的的话。

相信消息来源,卢克。很高兴你试图了解 git,并且坚信一切都是简单和非魔法的,希望会有所帮助。

最新更新