合并同一分支上的两个GIT提交记录

3

我已经重现了我遇到过几次的一个问题。这是基本情况:

创建一个名为file1.txt的文件,并填充以下内容:

Hello,
欢迎来到我的文件。
再见。

$ git add file1.txt
$ git commit -m “Initial commit”

在file1.txt中添加第二个正文内容行。**注意:在添加此行时“意外地”删除“欢迎来到我的文件”。

你好,
这是第二行。
再见。

$ git add file1.txt
$ git commit -m “Added second line”


$ git log
commit ccd8.. (HEAD -> master)
Author: ____
Date:   Tue Jan 28 11:50:11 2020 -0800

Added second line

commit 6d83..
Author: ___
Date:   Tue Jan 28 11:49:36 2020 -0800

Initial commit

什么是合并这两个提交的最佳方法?目标是将文件file1.txt合并成以下内容:

Hello,
Welcome to my file.
This is the second line.
Goodbye.

我尝试的方法是:
$ git checkout 6d83..
$ git branch tmp
$ git checkout master
$ git merge tmp

但我收到了“已经是最新的”消息。在这里,git rebase是最好的选择吗?为什么创建一个临时分支然后合并不起作用?


虽然不完全符合您的情况,但这篇文章可能会有所帮助。它涉及到相同的问题,但是该问题分散在两个文件中。 - Ashhar Hasan
此外,您收到的最新消息是因为Git跟踪提交而不是实际内容。因此,由于主分支和临时分支包含完全相同的提交,它可以确定实际上没有发生任何新变化。 - Ashhar Hasan
你无法按照自己的意愿使用Git的原因是,Git提交被设计为不可变的。也就是说,如果提交的内容发生更改,则提交ID必须更改。由于这个规则,你没有办法在创建新的提交之外更改现有数据。实现你想做的最简单方法是进行rebase操作。 - Ashhar Hasan
4个回答

5
问题在于,对于Git来说,删除你删除的行是正确答案。
请记住,Git的基本存储单位是提交。每个提交都包括:
- 一些数据:所有文件的快照;和 - 一些元数据:关于提交的信息。这包括谁制作了它、何时(日期和时间戳)以及为什么(您或提交者的日志消息)。然而,对于Git来说,最后且最重要的元数据是父提交哈希值。
每个提交都有一个唯一的哈希ID。该哈希ID在您进行提交时分配给提交。从那时起,该哈希ID保留给该提交。只有该提交可以具有该ID。同时,正如我们刚才提到的,每个提交都能够在其元数据中存储一个哈希ID。从技术上讲,每个提交可以存储Git想要的任意多个哈希ID,但它们必须是已经存在的提交的哈希ID。大多数提交仅存储另一个提交哈希ID:父提交(单数)。(合并提交存储两个哈希ID,这就是使它们成为合并提交的原因,而在新的完全空的存储库中第一个提交没有父提交——没有早期提交可供参考——因此它根本不存在。)
在您的情况下,您可能有一些早期提交,也可能没有。我们将只绘制一个假设您有早期提交的图形:
... <-F <-G <-H

提交的哈希值为HH代表真实的哈希值,看起来是随机的)记得其父提交G的哈希值,而G又记得其父提交F的哈希值,依此类推。这些向后指向的箭头被嵌入到每个提交的元数据中,这就是Git如何“查找”提交的方式。但对于提交H本身,它是最后一个提交。
Git查找任何分支的最后一个提交的方法是:该分支的名称,例如master,保存了提交的哈希值。所以为了使绘画更加完整,让我们在图中添加这一点。由于提交后不能更改任何内容,我们可以懒惰一些,停止将那些箭头作为箭头绘制,只要我们记住它们指向后面即可。
...--F--G--H   <-- master

现在,让我们创建一个新的提交以添加这个新文件file1.txt。提交H中根本没有file1.txt - 它有一些其他文件,但不是file1.txt。我们运行git add file1.txt并运行git commit,然后提供日志消息。Git创建一个新的提交,它具有新的唯一的大而丑的哈希ID,但我们将其称为I。Git将父项设置为H,以便I指向H
...--F--G--H   <-- master
            \
             I

接着,在git commit的最后一步,Git将的实际哈希ID写入名称为master的位置:

...--F--G--H
            \
             I   <-- master

没必要把 I 单独放在一行,所以我们不再这么做。

现在你编辑文件,并按照通常的流程创建了新提交 J。提交 J 的父提交是 I,Git 把 J 的哈希 ID 写入名称为 master 的分支:

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

这里没有可合并的内容,也就是说,您无法使用 git merge 进行所需操作。您有一条线性的提交链,以 J 结尾。从 JI,再到 H,依此类推。


1 从某种意义上讲,在创建提交之前哈希 ID 已经为该提交保留了——不过哈希 ID 本身取决于您 创建提交的确切时间,精确到秒。因此,如果您早了一秒或晚了一秒进行提交,它将具有不同的哈希 ID。无论如何,哈希 ID 是唯一的:只有该提交才能具有该哈希 ID。

如果 Git 找不到唯一的哈希 ID,则不会允许您进行提交!虽然这是一种理论可能性,但实际上从未发生过。另请参见How does the newly found SHA-1 collision affect Git?

2 我们即将创建的新提交的哈希 ID 取决于其父提交的哈希 ID。因此,即使我们确定正在创建的新提交将具有的哈希 ID(如果其父提交是现有提交 X),对于任何 X,如果我们在创建提交之前将哈希 ID 插入到提交的元数据中,则最终会得到一个不同的哈希 ID。因此,提交不能引用自身,也不允许随意添加任何内容。因此,每个提交都始终引用某个较早的提交。

简而言之,给定一个提交,您可以向后移动到其父提交……但您只能向移动,不能向移动到其未来的子提交。

由于这个原因,您无法更改任何提交,并删除任何较早的提交也会同时删除所有较新的提交。(Git使删除提交变得特别困难。与 Mercurial 相比,您需要运行hg strip -r <rev>,它将删除该提交其所有子提交。您仍然不能选择子提交,但很容易删除一个提交。)


合并

通常情况下,在Git中进行合并是指我们有多个分支名称。让我们回到这样一种情况,即当我们在master上仅有提交H作为最后一次提交时。(我们可以使用git reset --hard HEAD~2来实现这一点 - 这使得master直接指向H,并且还设置了工作区 - Git的索引和我们可以查看文件的工作树 - 以再次反映提交HIJ将继续存在,并且默认情况下,它们可以在至少30天内被检索到。但是我们假装从未创建IJ)因此我们有以下内容:
...--G--H   <-- master

现在我们将创建一个或两个新分支。 在这样做时,我们需要向我们的图纸中添加一件事情。 如果只有一个分支名称 master,那可能是我们使用的分支。 但是如果我们添加了 dev 作为第二个名称呢?我们使用哪个 名称
Git 的答案是使用特殊名称 HEAD。 这个特殊的名称通常被 附加 到你的一个分支名称上。(它只能附加到一个或零个:永远不会超过一个。) 我们将添加第二个分支名称 dev,但是保留 HEAD 附加到 master 上:
...--G--H   <-- master (HEAD), dev

现在我们将像往常一样创建新的提交IJ。让我们把它们画出来:

          I--J   <-- master (HEAD)
         /
...--G--H   <-- dev

请注意,dev 没有移动:它仍然指向现有提交 H。名称 master 现在指向新的提交 J
现在,让我们在 dev 上创建两个提交。我们首先执行 git checkout dev。这将把我们的 HEAD 附加到 dev,并提取提交 H 的内容以进行操作/使用。
          I--J   <-- master
         /
...--G--H   <-- dev (HEAD)

代码库中的 提交 没有改变!但我们看到和使用的文件已经改变,当前分支dev当前提交H.3 现在我们做两个新的提交。可以是任何数量,但是两个更容易说明问题:

          I--J   <-- master
         /
...--G--H
         \
          K--L   <-- dev (HEAD)

现在我们可以运行git merge。 我们选择一个分支来使用-git checkout mastergit checkout dev - 然后运行git merge并给它另一个分支的名称。4 让我们git checkout mastergit merge dev,这样HEAD和当前提交将标识为J而不是L:5

          I--J   <-- master (HEAD)
         /
...--G--H
         \
          K--L   <-- dev

Git现在需要找到两个分支上最好的提交。在这种情况下,很明显:它是提交H。我们从J回退两步到达那里, 从L回退两步也可以到达那里。如果底部的链更长,我们就需要回退3或4或其他数量的步骤,但只要我们能够到达提交H,提交H就是最佳的共享提交。
Git将此共享的最佳提交称为“合并基础”。它是我们和他们都开始的关键提交。您(或Git)通过查看显示提交连接方式的图形来查找它。
Git现在将运行两个git diff操作:
1. `git diff --find-renames hash-of-H hash-of-J`,以查找我们自己在主分支上对共享提交H之后做出的更改; 2. `git diff --find-renames hash-of-H hash-of-L`,以查找开发分支上对共享提交H之后他们所做的更改。
git merge所做的就是将这些变化组合起来,然后将组合后的变化应用到提交H的快照中。这样,我们就可以保留我们的更改,并添加他们的更改。
这也是为什么合并大多数情况下都是对称的原因。如果我们已经检出了dev或提交L,并运行了git merge master,Git仍将找到公共提交H作为合并基础。它将运行相同的两个git diff命令(顺序不同,但谁在乎呢?),然后将这些差异组合成一个大的组合集,并将其应用于提交H中的快照。结果将是一样的。
如果我们的更改和他们的更改在某种方式上重叠,Git将宣布一个合并冲突。在这种情况下,Git无法自动完成合并,因此会留下一个您必须手动清理的混乱状态。这没问题:只需清理它,git add,然后提交(或运行git merge --continue)以完成工作。
为了完成该任务,Git会创建一个新的提交记录——我们称之为M,因为我们已经聪明地标记了之前的每个提交记录为HL——并像往常一样更新当前分支名称,以便我们现在检出的任何分支都以新的合并提交记录M结束。为了将其标记为合并提交记录,Git将其两个父提交设置为JL,按顺序排列,因为我们在开始时处于J上。所以我们可以画出结果:
          I--J
         /    \
...--G--H      M   <-- master (HEAD)
         \    /
          K--L   <-- dev

我们已经完成了合并。与合并相关的“快照”是将从 H-vs-J 和 H-vs-L 中结合的更改应用于 H 的结果。合并的“父提交”通常是上一个提交,以及我们运行 git merge dev 时挑选出的另一个提交。
现在,尝试将 L 或甚至 K 合并到 master 中都无法完成。原因是 LM 之间最好的共享提交是提交 L……它已经是 M 历史记录的一部分。如果我们沿着底部行从 M 向后退,我们会到达 L。Git 中的历史记录包括提交及其连接,这意味着 L 已经在此处合并。
当您询问 Git:“HEAD 中有什么内容?”时,您有两种方式提问。您可以问 Git:“HEAD 中有哪个分支名称?”或者您可以问:“HEAD 选择哪个提交?”这两个不同的问题得到了两个不同的答案。在“分离 HEAD”模式下,即 HEAD 没有连接到任何分支名称时,第一个问题会导致错误而不是答案。第二个问题几乎总是有效的。
Git 还有一个“未出生的分支”概念,当您开始使用没有任何提交的全新空存储库时,它需要这个分支。在这种情况下,HEAD 存在并持有一个分支名称,但分支名称本身不存在且无效。因此,在这种特殊情况下,您可以问关于 HEAD 的“名称”问题,但不能问“ID”问题:与分离的 HEAD 设置相反。
实际上,git merge 通过提交哈希 ID 运作,因此我们可以给它想要的任何提交的哈希 ID。但通常我们——人类——按名称工作。
合并结果通常是相同的,除了列出的第一个父提交不同。如果我们使用特定的标志参数来运行 git merge,则合并结果可能会有所不同。

挑选

不过,我们确实可以做某些事情。无论是否存在分叉,都可以给定任何一系列提交。
          o--P--C--o--o   <-- branch1
         /
...--o--o
         \
          o--o--H   <-- branch2 (HEAD)

或者只是一个线性链,如下所示:
...--o--o--P--C--o--o--H   <-- branch (HEAD)

我们可以选择某个提交记录C,它的父提交为P,并且在其上运行git cherry-pick命令。(通常会使用C的哈希值)。这样做会强制Git执行以下操作:
  • 查找提交P,即C的父提交:这很容易,因为C中包含了P的哈希值;
  • P视为一个“合并基础”,将C视为“他们”的提交,将当前提交H(由HEAD选择)视为“我们”的提交,并像往常一样进行完整的三向合并。
因此,Git现在会比较PC之间的差异,查看“他们”所做的更改,比较PH之间的差异,查看我们所做的更改,并将这两组更改结合在一起。然后,Git会将这些合并后的更改应用于P中的快照。如果一切顺利,Git将把生成的文件作为一个新的快照C'——C的副本——提交,使用C的原始提交消息等信息。这不会创建一个合并提交,而只是普通的提交。
          o--P--C--o--o   <-- branch1
         /
...--o--o
         \
          o--o--H--C'  <-- branch2 (HEAD)

或者:

...--o--o--P--C--o--o--H--C'  <-- branch (HEAD)

更合理的做法是从另一个分支挑选某个提交记录,如上图。但您也可以从自己的历史记录中挑选某个提交,以重新应用相同的更改。如果在C和C'之间有一些提交撤消了C中发生的任何更改,则特别有用。6


6Git有一个命令git revert,用于创建这样的提交记录。你指定它的目标为某个子节点,Git执行与挑选某个提交相同的三路合并,但这次的“合并基础”是C,而“theirs”提交是P。(ours/HEAD提交像往常一样是当前的HEAD提交)。练习:尝试获取C与P的差异,并按此顺序进行。如果将此组更改与C与HEAD的更改组合,按照此顺序会发生什么?


请注意,所有这些操作都是针对整个提交记录的

您最初想要处理一个文件。但是Git所做的一切或我们所展示的Git所做的一切都是基于整个提交记录的。这是因为提交记录实际上是Git中的基本单元。提交记录存储文件是正确的,但是Git不是真正关于文件的。Git关注的是提交记录。文件只是使提交记录有用的东西。

您可以从单个提交记录中提取单个文件,并对其进行操作:例如,git diff可以仅针对两个文件的名称进行差异比较。但这种使用Git的方式是不典型的。Git针对以提交记录为单位的操作。


1

你不能像上面尝试的那样使用合并自动完成此操作。但是,假设你已正确配置了喜欢的差异编辑器,则可以在提交之前手动访问先前的内容来修复文件。在你的主分支上:

git difftool ccd8:file1.txt file1.txt

一旦正确修复并保存,在退出编辑器后。
git add file1.txt

如果您还没有推送,您可以修改之前的提交。
git commit --amend

或者用恢复的行创建一个全新的。
git commit -m "Recovered line"

0

一个简单的方法是使用git checkout -p @^ file.txt,它会找到你的工作树版本和祖父版本之间的每个差异并提供应用它们的选项,你可以编辑这些选项。

Cherrypick通常只是diff | apply -3的简写,如果你想要获取所有@^的更改,你也可以尝试git diff @^!|git apply -3,这可能会留下一些冲突需要解决,但不要害怕这些,它们很少但很正常。练习使用好的合并/差异工具。我喜欢vimdiff,解决微不足道的冲突非常快速。像争夺新标志位之类的东西通常只需要几秒钟就能解决。


0

在 Git 中没有办法做你试图做的事情。这会导致合并冲突。


如果您知道的话,我真的很想了解其中的原因。是由于Git跟踪内容的方式导致技术上不可能吗?还是只是因为没有现有的工具支持它? - Ashhar Hasan
我希望出现合并冲突,这样我就可以接受两行代码。但是目前,它甚至不允许合并。 - meinna
合并冲突有什么问题吗?它们只是需要人类判断才能正确应用的更改。 - jthill
在这种情况下,整个合并都是一场冲突。没有任何意义。 - JoelFan

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