git 何时会在合并期间丢失更改?

When does git lose changes during a merge?

假设:

  1. 我们有一个主分支,同事不小心添加了一系列本应属于新功能的提交(我们称它们为 A B C)。
  2. 我发现了这一点,我告诉他将这些提交移动到一个新分支,但保留其他不相关的提交,这些提交后来在 master 中完成。我向他发送了我问过的这个问题,并告诉他按照回复进行操作:
  3. 几天后,当新功能分支准备就绪时,我将其合并到 master 中。
  4. 解决合并中的所有冲突后,我提交更改...
  5. ...我发现那些第一个提交(A B C 个)已经消失了。
  6. 我问我的同事,他说 "he thinks" 他使用 link 中提到的方法移动了这些更改(基本上:检查最后一个公共提交,然后使用 git cherry-pick 只选择我们稍后想要的提交),但他记不清了。
  7. 我检查了 repo 的历史,A B C 在功能分支中,在开头。它们看起来像是从 master 成功迁移过来的。

鉴于以上情况,谁能解释为什么 git 丢失了这些更改? (我个人的理论是 git 不知何故 "remembered" 我们撤消了提交 A B C,所以当它们来自新功能分支时,git 决定不合并它们。编辑: 抱歉,如果这个解释听起来太像 "magical thinking",但我不知所措。如果正确的话,我欢迎任何用更专业的术语来解释这个解释的尝试。

很抱歉无法提供更多详细信息,但我没有亲自在回购协议中进行这些更改,因此无法提供所做的具体细节。

编辑:好的,正如此处所建议的,我让我的同事在他的机器上执行 git reflog,所以我将结果粘贴在这里。回到我之前的(linked)问题,我们有一棵这样的树:

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

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

那么,他发给我的git reflog就在这里了。提交 5acb457 将对应于上图中的 "commit 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

任何人都可以理解这 4 个连续的 cherry-pick 吗?我怀疑他并没有真正做 git cherry-pick master~3 的事情,特别是 ~3 的部分(当我第一次看到它时,我承认这让我失望了)。

之所以提交 A、B 和 C 丢失,是因为这是您与同事分享的 link 所做的。让我们用下图来说明:

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。它将直接在提交 X 上 rebase 提交 DE,因此您无法在 master 分支上找到 ABC .

更新:

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

检出
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 分支上 cherry-pick 提交,详情如下:

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

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

To get back to my previous (linked) question, we had a tree like this:

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

And we wanted to move B and C to the new feature branch.

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

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

(请注意,唯一不是 copied-and-switched-to 的提交是 A!)。

当你现在运行:

git checkout master
git merge feature

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

  1. 获取当前提交的哈希 ID (git rev-parse HEAD)。
  2. 获取feature(git rev-parse feature)提示的hash ID。
  3. 找到这两个提交的(单个,在本例中)合并基础。 merge base 的技术定义是 DAG 中的 Lowest Common Ancestor,但笼统地说,它就在两个分支分叉之前,简单来说就是 "commit D'".
  4. 运行 相当于 git diff D' F':将合并基础与 master 的尖端区分开来。这是 "what we changed on master since the merge base":一个大文件列表(及其哈希 ID 版本),以及任何计算的重命名信息等。
  5. 运行 相当于 git diff D' H':将合并基础与 feature 的尖端区分开来。这是"what they changed on feature",和第4步一样。我在第4步用"we",第5步这里用"they",因为我们可以用git checkout --oursgit checkout --theirs 在合并冲突期间提取特定文件:--ours 指的是提交 F' 中的文件,即 "we" 更改的内容,--theirs 指的是文件在提交 H'.
  6. 尝试合并差异以获得单个变更集。

    如果 Git 能够自行完成所有这些组合,它就宣布胜利,将这个单一变更集应用到基础提交 D',并进行新的提交——让我们称之为 M 用于合并——以通常的方式(这样 master 移动到指向 M),除了 M 有两个 parent:

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

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

三个输入,一个输出

请注意,此 three-way 合并有 三个输入

  • 合并基础的树
  • 当前 (--ours, HEAD) 提示提交的树
  • 其他 (--theirs) 提示提交的树

合并基础在这里起作用,因为它是——事实上,最好的——两个提交已经分道扬镳的共同起点。 Git 能够直接获取两个分支提示,因为每个提交都是一个完整的快照:1 它永远不必查看所有中间提交,除了图形以便找到合并基础。

我们还故意掩盖了一些微妙的技术问题,例如 pair-breaking 和 rename-finding(见脚注 1),以及合并策略(-s ours 意味着我们甚至 他们的)和策略选项(-X ours-X theirs)。但只要您只是 运行宁 git merge feature 并且很少或根本不需要担心重命名,那不是问题。

但是——这是关键项目之一——为了弄清楚Git要做什么,你必须画出图表,或以其他方式识别合并基础。 一旦你有了合并基础提交的哈希 ID,你可以(如果你愿意)git diff 合并基础针对两个提示提交并查看什么Git 就行了。但是 如果合并基础不是您期望的提交,合并将不会执行您期望的操作。


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

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

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

在某些情况下,Git 无法通过这种方式找到重命名。还有额外的控制旋钮。如果文件已更改 "too much",则有一个会破坏配对,即让 Git 说仅仅因为 dir/file.txt 在底部和顶部,它实际上可能不是 相同 文件。出于 rename-detection 的目的,还有另一个设置 Git 声明要匹配的文件的阈值。最后,有最大配对队列大小,可配置为 diff.renameLimitmerge.renameLimit。默认合并配对队列大小大于默认差异配对队列大小(当前为 400 对 1000,从 Git 版本 1.7.5 开始)。


如果有冲突,你会变得一团糟

当 Git 声明一个 "merge conflict" 时,它在步骤 6 的中间停止。它 不会 进行新的合并提交 M。相反,它会让你一团糟,存储在两个地方:

  • work-tree 最好猜测它可以作为自动合并做什么,加上所有用冲突标记写出的冲突合并.如果 file.txt 有冲突——Git 无法将 "what we did" 与 "what they did" 合并的地方——它可能有几行如下所示:

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

    如果您将 merge.conflictStyle 设置为 diff3(我推荐此设置;另请参阅 Should diff3 be default conflictstyle on git?),以上内容将被修改为包含合并基础中的内容(提交 D' 在我们的例子中),即在 "we" 和 "they" 改变它之前有什么文本:

    <<<<<<< 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
    
  • 同时,index——你构建 next 提交的地方——最多有三个每个冲突文件的条目数 "slot"。在这种情况下,对于file.txt,有三个版本的file.txt,编号为:

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

现在,仅仅因为 file.txt 中存在冲突并不意味着 other 中没有 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 会将它们组合成一个差异块,因为它们之间只有一个上下文行,但 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

同样,我们进行了两项更改,但只有一项冲突。

合并文件的 work-tree 版本将采用每个 冲突的更改,以便文件将完整阅读:

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

但是其中任何一个都只是将 "ours" 或 "theirs" index 版本(从插槽 2 或 3)复制到 work-tree。无论您选择哪一个,您都将丢失 other 分支的更改。

您可以 hand-edit 文件,删除冲突标记并保留或修改部分或全部剩余行以解决冲突。

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

不过,在所有情况下,work-tree 中的任何内容都将是您的最终产品。然后你应该 运行:

git add file.txt

清除阶段 1、2 和 3 的条目并将文件的 work-tree 版本复制到正常的 stage-zero file.txt。这告诉 Git file.txt.

的合并现在已解决

您必须对所有剩余的未合并文件重复此操作。在某些情况下(rename/rename 冲突,rename/delete,delete/modify,等等)还有更多的工作要做,但一切都沸腾了直到确保索引只有您想要的最后 stage-zero 个条目,而没有 higher-stage 个条目。 (您可以使用 git ls-files --stage 查看 all 中的条目 all 它们的阶段,尽管 git status 做得很好总结有趣的。特别是,所有具有 stage-zero 条目与 HEAD 提交完全匹配的文件都非常无聊,并且 git status 直接跳过它们。如果有数百或数千这些文件非常有用。)

一旦您解析了索引中的所有文件,您就可以 运行 git commit。这使得合并提交 M中是什么提交是索引中的任何内容,即,无论您git add-ed删除更高阶段的索引条目并插入stage-zero条目。

使用git checkout签出同时解析

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

不过,如果这就是您想要的,short-cut。您可以:

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,以及它们的 short-cut 版本使用 HEADMERGE_HEAD,将对方的修改放到一个文件中。您将得到的唯一指示是合并结果缺少一些更改。就 Git 而言,这是 正确的结果: 想要 那些更改被删除;这就是为什么在进行合并提交之前设置 stage-zero 索引条目的原因。

也很容易得到一个令人惊讶的合并基础,特别是如果您尝试做很多 git rebasegit cherry-pick 工作来复制提交并移动分支名称以指向新副本.仔细研究提交 DAG 总是值得的。从 "A DOG" 获得帮助:git log --all --decorate --oneline --graphall decorate o内线 graph;或使用 gitk 或其他一些图形查看器来可视化提交图。 (除了 --all,您还可以考虑使用有问题的两个分支名称,即 DOG 而不是任何旧的 A DOG:git log --decorate --oneline --graph master feature。生成的图表可能更简单、更易于阅读。然而,如果你做了很多变基和 cherry-picking,--all 可能会揭示更多。你甚至可以将它与特定的 reflog 名称结合起来,例如 feature@5,尽管这有点 long-winded 并生成非常混乱的图表。)

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

My personal theory is that git somehow "remembered" that we had undone commits A B C, so when they came from the new feature branch, git decided not to merge them.

Git 从不 "somehow" "remembers" 任何关于您存储库内容的事情。它也不会根据您之前所做的决定决定做或不做任何事情。在这方面非常干净。它的所有命令只是用于处理其提交的有向无环图(以及在较低级别上,它存储的所有其他 objects)正在构建的工具。为了使它更容易,它只添加东西,从不更改或删除任何东西。

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

的提交

肯定没有神奇的、未记录的东西在发生。存储库非常开放,您可以使用 git 命令从字面上查看所有内容,并且所有内容都已完整记录(google "git data structures" 或 "git internals" 如果您有兴趣)。如果您愿意,甚至修改内部 objects 也很容易。

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

EDIT: sorry if this explanation sounds too much like "magical thinking", but I'm at a loss. I welcome any attempt to put this explanation in more technical terms, if it's right

相信消息来源,卢克。很高兴您正在努力让自己的头脑围绕 git,并且坚信一切都很简单并且 non-magical 应该有所帮助,希望如此。