TL;DR 的 TL;DR
Git 不会使用你的工作树文件,除非你(或其他东西)运行git add。请注意git mergetool 仅在meld 使用的一个 文件上运行git add。因此,您可以编写任意数量的额外文件。吉特不在乎。当meld 完成时,它只关心一个特定的文件。
TL;DR
大概您正在通过git mergetool 运行此合并工具meld。 git mergetool 的工作方式非常简单,一旦您了解了合并本身的工作原理,这就是您可以修改所有这些文件的原因:因为它们都只是文件。
要使这一切变得有意义,您需要了解git merge 的工作原理。这使我们能够区分:
- 提交,这是 Git 实际存储内容的方式;
- Git的index,有三个名字;它参与提交,并在合并期间发挥更大的作用;和
- 你的工作树或工作树(两个名字指的是同一个东西),它保存着你的文件,以及像
meld或vim这样的程序或其他任何东西,可以实际查看和编辑。
第三个——你的工作树——是唯一存放你可以看到的文件的地方。但是——这非常重要——你的工作树根本不在 Git 中。它只是 Git 将文件粘贴到其中的地方,以便您可以查看它们并使用它们进行处理。稍后,git add 会将这些文件中的 一个 复制回 Git 的索引中。如果您使用git mergetool 运行合并工具,git mergetool 代码将为您运行git add。
mergetool 脚本在 merged 文件(按名称)上运行 git add,因此无论 in 文件是什么,都会得到 git added。就 Git 而言,任何剩余的文件都只是垃圾:它们只是未跟踪的文件。我相信 mergetool 应该清理垃圾文件(但 should 并不意味着 总是会 并且意见也可能在 should 部分有所不同;有一个此处“保留备份”选项,我从未使用过)。
长
根据您对 Git 的熟悉程度,您可以跳过以下部分。我会尽量让它们保持简短(通过省略很多),但它们仍然会很长。
关于提交的更多背景
每个 Git 提交都有一个唯一的编号。这些数字不是简单的计数数字——我们没有提交 #1,然后是 #2,然后是 #3,依此类推。取而代之的是,这些数字是由加密散列函数计算出来的随机、大、丑的散列 ID。这些编号在各地的所有 Git 存储库中是唯一的(这是 Git 管理提交的分布式性质的方式),但我们在这里需要知道的是提交是有编号的。
每个提交包含两件事。提交的所有部分都是只读的,因此这些内容是不可更改的,并且永远有效——或者至少只要提交本身继续存在:
-
每个提交都有每个文件的完整快照,以只有 Git 可以读取的特殊存档格式存储。 (这种格式是经过压缩的,通常是高度压缩的,并且对文件内容进行重复数据删除。它可以存储您的操作系统可能无法有效使用的文件,甚至在某些情况下检出;在这些情况下,合并将很困难或不可能。 ) 提交中的文件由 Git 索引中的内容确定,如下一节所述,在有人运行 git commit 时。
-
每个提交也有一些元数据,或者关于提交本身的信息。这包括作者的姓名和电子邮件地址,以及提交者的姓名和电子邮件地址。每一个都有一个日期和时间戳。有空间放置日志消息,由做出提交的人编写,以描述他们做出此提交的原因。而且,为了让 Git 可以将提交向后串在一起,每个提交都会记录其父提交的哈希 ID。
合并提交只是一个包含至少两个父哈希 ID 的提交。 git merge 命令通常在最后进行这样的提交:第一个父级与任何普通的非合并提交具有相同的父级,第二个父级是您刚刚合并的提交的哈希 ID(例如,提示您按分支名称合并的分支的提交)。合并的快照部分与任何提交相同:它只是合并完成时记录在 Git 索引中的每个文件的完整副本。
Git 的索引,以及它在合并过程中如何扩展
Git 的 index 有三个名称:Git 将其称为索引(就像我在这里所做的那样)、暂存区(至少对于正常提交而言),并且——很少这些天来,主要是在像--cached这样的标志中——缓存。对于正常的非合并提交,我喜欢将索引描述为保存您的提议的下一次提交。
索引中的内容通常是一个元组列表:名称、模式和哈希 ID:
-
名称是一个文件名,带有正斜杠,如top/sub/file.ext。在这个级别,Git 不会“考虑”保存文件的目录:它只是包含带有斜杠的长名称的文件。即使在 Windows 上,这些斜线也会继续前进,即使 Git 必须将这样的文件放入名为 file.ext 的文件中,该文件位于名为 top 的文件夹中,其中包含子文件夹 sub,Windows 更愿意将其表示为 top\sub\file.ext。该指数在内部坚持使用正斜杠。 (这通常不会向用户显示,这只是理解 Git 阻止它存储空文件夹的问题的一种方式。这样的东西根本不存在于 Git 的索引中:索引只包含 文件。)
-
模式,对于一个普通文件,真的只记住是+x还是-x:一个可执行文件,还是一个不可执行文件。对于hysterical reasons,这分别存储为100755 或100644。
-
哈希 ID 与 Git 如何在内部存储文件内容有关,作为 blob 对象。这些东西是压缩的和只读的,如果对象被存储为 packed 对象,它可能会使用delta encoding 进行更多压缩。
同样,这是在正常的非合并情况下。这些条目有一个始终为零的阶段编号(因为索引是“暂存区”)。这就是它们正常的原因。
当git merge 启动时,它扩展索引。它用 stage 2 条目替换了所有阶段零条目,这些条目代表 当前提交——索引需要在合并操作开始时与当前提交匹配。这也为 stage 1 和 stage 3 条目打开了空间。我们将在下面回到这一点。
你的工作树
提交文件(通过 blob 哈希 ID 存储)和索引(字面上存储这些相同类型的 blob 哈希 ID)都存储 Git 文件的 内部格式 版本,其中内容是压缩和去重,甚至可能是增量编码。这种格式适用于存档(因为它是压缩和去重复的),但不适用于完成任何实际工作。所以 Git 必须从提交或 Git 的索引中提取这样的文件,展开任何压缩。
提取归档的 blob 对象的结果进入一个普通文件。这些文件需要存在于某个地方,而那个地方就是你的工作树。所以git checkout 或git switch 的工作原理是将文件从提交复制到 Git 的索引中——这部分既快速又便宜,因为索引以与提交相同的格式保存文件——然后复制到你的工作树。
复制到工作树的速度很慢,但 Git 会作弊。因为索引跟踪你的工作树中的内容,Git 通常可以非常快速地判断工作树文件是否在最后检出时未被触及。它还可以通过检查哈希 ID 来判断您现在签出的新提交中的文件是否与您之前签出的旧提交中的文件相同。如果一切顺利——通常情况下确实如此——Git 可以不理会文件,它确实如此。
原则上,不同提交的git checkout 必须删除每个旧文件(从 Git 的索引和您的工作树中),然后从新提交中填写每个新文件。 Git 只是跳过了很多这样的工作,这意味着一个多兆字节或千兆字节的结帐可能需要很少的时间(有时只需几毫秒,但这很大程度上取决于操作系统、缓存和其他细节,以及从提交 X 的切换提交 Y 不需要更改很多工作树文件)。
不过,除此之外,您的工作树只是一组常规的旧文件和目录/文件夹(无论您喜欢哪个术语)。在您的计算机上运行的一切都在这里运行。除了在你告诉它时将其写入到——例如,使用git checkout——Git 只是让你随心所欲地玩它。然后你可以运行git status,它只查看它,或者git add,它从中复制到Git的索引中。但是,在您执行其中任何一项之前,Git 都是完全不干涉的。
简而言之,您的工作树是您的,您可以随意使用。你可以在这里创建 Git 永远不需要知道的文件。只要(a)你不git add他们和(b)他们永远不会从一些现有的提交中出来,他们永远不会进入 Git的索引,而且Git永远不会知道他们。 git status 命令会抱怨它们,您需要在.gitignore 中列出这些文件以使 Git 关闭哔哔声,但除此之外,它们'完全无关紧要。
三路合并的内部
当我们运行git merge 时,我们通常会进行三路合并,这可能会产生冲突。要了解发生了什么,让我们看一个示例提交图,即在某个 Git 存储库中找到的一组提交。由于实际提交的哈希 ID 难以理解,我们将使用单个大写字母来代替它们,如下所示:
I--J <-- branch1 (HEAD)
/
...--G--H
\
K--L <-- branch2
我添加了两个分支名称,branch1——我们目前已经签出,即,我们使用提交 J 来填充 Git 的索引和我们的工作树——以及 branch2,它选择提交 @ 987654367@。 (HEAD) 表示法表明我们已签出branch1。列出的所有六个提交都是普通的单亲提交,因此从提交J(即,如果我们现在运行它,则为git log)来看,我们看到,作为历史,首先提交J,然后提交@ 987654373@,然后提交H,然后提交G,以此类推。从提交L 来看——如果我们运行git log branch2——我们首先看到提交L,然后是K,然后是H,然后是G,以此类推。
这两个提交历史相遇,当我们像这样倒退时,在提交H。所以commit H是这个三路合并中的合并基础。
合并的目标是合并工作。我们希望 Git 自己弄清楚自提交 H 以来我们所做的更改。这些是“我们的改变”。我们想让 Git 弄清楚自提交 H 以来它们发生了什么变化。这些是“他们的变化”。 Git 实际上可以做到这一点,使用 git diff:
git diff --find-renames <hash-of-H> <hash-of-J>
这将生成我们更改的每个文件的列表,以及需要删除哪些行并将其添加到每个文件中,以将提交 H 中存在的那些文件的副本转换为那些相同文件的副本存在于J。
同样:
git diff --find-renames <hash-of-H> <hash-of-L>
将生成一个他们更改的文件列表,以及这些文件中需要修改的行。
如果 Git 简单地(简单地?)组合这两个列表并将 两个 组更改应用于从提交 H 获取的文件,Git 将到达一组保存我们更改的文件( H-to-J) 并添加他们的更改 (H-to-L)。在许多情况下,我们更改的某些文件将有 no 更改,反之亦然。这些对 Git 来说很容易。在某些情况下,某些文件会在两边发生变化。如果这些更改涉及不同行,Git 可能能够自行组合这些更改。
无论如何,这些都是 Git 使用的规则。它只是:
- 提取(到 Git 的索引)
H 中的每个文件:这些文件进入 slot-1 条目。
- 提取(到 Git 的索引)
J 中的每个文件:这些进入 slot-2 条目。当然它们已经在 slot 0 中了,所以不需要提取; Git 可以将 slot-0 条目移动到 slot-2。 (当使用git cherry-pick -n 或类似名称时,Git 确实只需要移动槽条目,因为这些情况不需要索引匹配任何内容。但这是git merge 通常不允许的特殊情况。)
- 提取(到 Git 的索引)
L 中的每个文件:这些进入 slot-3 条目。
索引现在具有每个文件的 三个 副本,来自合并基本提交 (BASE)、--ours 提交 (LOCAL) 和他们的 (REMOTE)。其中每一个实际上只是一个哈希 ID,用于内部 Git blob 对象(好吧,加上名称和模式,以及代表插槽的暂存编号)。1
由于重复数据删除技巧,如果没有人对文件进行任何更改,所有三个暂存槽将保存相同的哈希 ID(和模式),Git 可以折叠所有三个索引条目回到一个单一的零槽条目。如果我们更改了文件,但他们没有,则基础和它们的插槽将具有相同的哈希 ID(和模式),而我们的将不同,Git 将只占用我们的版本的文件,将插槽 2 移动到插槽 0 并擦除插槽 1 和 3。如果 他们 更改了文件而我们没有更改,则基础和我们的插槽将具有相同的哈希 ID,但它们的 ID 会有所不同,Git 只会采用 他们的 版本的文件,将插槽 3 移动到插槽 0,等等。
这意味着我们只需要努力处理双方都进行了更改的文件(嗯,或用于高级/树冲突,我将跳过这边)。在这种情况下,Git 目前的各种合并策略通过以下方式起作用:
- 调用合并驱动程序,如果有的话:这个程序必须完成这项工作;或
- 调用内置的低级合并驱动程序,否则。
内置的低级合并驱动程序逐行工作,在单个文件上使用git diff。2对于您在@中看到的每个差异块987654406@ 输出,它会查看对方是否触及了相同的行,或者“触及”另一个变化的行(例如,如果“我们的”差异在末尾添加了一行,并且“他们的" diff 还在末尾添加了一行,Git 不知道在添加两组行时使用哪个 order。3 它写道,我们的工作有问题的文件的树副本,Git 对正确合并的最佳猜测。如果这一切顺利——如果 Git 能够无冲突地组合这两组更改——Git 然后在文件上执行一个内部git add。如果不是,Git 将冲突留在文件的工作树副本中,并带有冲突标记,并且不对文件执行内部git add。
当低级驱动程序遇到被认为是冲突的事情时,如果有一个扩展参数-X ours 或-X theirs 有效,它将只接受我们的更改(从 1-vs-2)或它们的更改(1-vs-3) 根据-X 值,并且不放入任何冲突标记。所以低级冲突可以在软件中使用这些标志自动解决。但请注意,Git 在这里没有做任何事情智能。它只是根据逐行差异块选择 1-vs-2 文件差异或 1-vs-3 文件差异。但这确实让 Git 自己运行一个内部 git add。
当 Git确实 运行内部 git add 时,这只会获取文件的工作树副本并将其复制到插槽 0,从而擦除该文件的插槽 1 到 3。这将文件标记为已解决。对于那一组文件条目,索引收缩回正常。处理完所有文件后,Git 的索引中仍然显示一些冲突(因为某些文件没有预先折叠并且没有得到git add-ed),或者没有(所有文件都得到了一个简单的索引崩溃,或者在低级驱动程序完成后得到git add-ed)。
1此处的设计本应在进行递归合并时允许多个 slot-1 条目,但这从未发生过。目前尚不清楚它是否可以去任何地方,因为有一些非常棘手的极端情况,其中文件在三个提交中的一个或两个中不存在,如果你允许这种事情,它们会变得更棘手.
2在现有的合并递归算法中,无论是高层代码还是底层代码,都有大量的冗余工作。正在进行的改进合并的工作正在消除很多这种情况,并将加速许多更困难的合并。这不会改变合并代码的目标,也不会改变我在这里给出的高级描述,但会改变完成某些工作并保存或不保存结果的点,以便它们可以完成一次而不是重复。
3一个低级的联合合并,Git 不直接支持,但你可以通过git merge-file 获得,用作低级合并驱动程序你写的——假设行顺序是无关紧要的,并且可以在不称其为冲突的情况下处理它。
这一切的结果
关于合并对 Git 索引的作用的描述很长,但如果你一直遵循逻辑,你会看到:
-
不能有冲突的任何文件现在都处于零阶段。
-
可能有冲突的任何文件,但驱动程序(来自
.gitattributes)或默认的内置低级文件合并能够自行解决——也许使用@987654418 @ 或 -X theirs——也处于零阶段。
- 因此,只有存在无法解决的低级冲突或存在一些高级/树级冲突(出于篇幅原因我在此省略)的文件具有非零索引阶段条目。
所以当且仅当 Git 的索引中有任何非零阶段编号时,合并冲突仍然存在。在这种情况下,git merge 停止,留下一堆内部文件——例如.git/MERGE_HEAD 和.git/MERGE_MSG——来记录正在进行的合并。同时索引本身有一些非零的槽号,记录了有冲突。
如果冲突是低级冲突,并且我们在某个文件上使用了 Git 内置的低级合并驱动程序,该文件的工作树副本具有其中的冲突标记。这些标记来自通过git merge-file 可用的相同代码运行三个原始输入文件(因此您可以通过这种方式重建合并冲突,但此时使用git checkout -m 或git restore -m 有更简单的方法)。不管文件的工作树副本中有什么,三个输入文件都存在于索引中。
如果我们现在运行git mergetool,此代码会翻阅索引(使用git ls-files --stage 或等效项)以查找冲突文件。然后它使用git checkout-index 提取作为低级合并驱动程序输入的三个文件。这些得到时髦的.gittemporary 样式名称,git mergetool 分别重命名为<em>file</em>_BASE、<em>file</em>_LOCAL 和<em>file</em>_REMOTE(嗯,确切的命名模式很棘手,这只是一个近似值)。出于内部目的,它将 file 复制到 <em>file</em>_BACKUP。然后它会在这些文件(不包括备份文件)上运行您选择的合并工具。
您的合并工具现在可以处理 工作树 文件。这些文件都不在 Git 中。您可以使用合并工具对他们做任何您喜欢的事情。无论 file 中的内容是什么,git mergetool 都假定这是您通过使用合并工具产生的结果。
这里还有一个特别的技巧:
-
一些合并工具具有“受信任”的退出代码,而有些则没有。
-
如果您的合并工具被标记为“受信任”并退出并显示合并完成,请使用结果,Git 将git add 表示。这会擦除三个插槽并标记文件已解析。
-
如果您的合并操作不受信任,Git 会将_BACKUP 文件与工具的输出比较。如果文件未更改,git mergetool 会询问您是否认为合并有效。只有当你说是时它才会git add 结果。
当git merge 停在中间时,你的工作是清理混乱,通过将 正确的合并结果写入 Git 的索引中的零槽。你可以用任何你喜欢的方式来做这件事。我的首选方法通常是在 Git 将 merge.conflictStyle 设置为 diff3 写入之后,在 vim 中打开 file。我发现大多数冲突都可以通过这种方式轻松解决。在某些情况下,我真的很想获得三个版本,对于这些情况,git mergetool 是一种方法——但玩过git mergetool,我还没有找到这是一种特别好的方法。不过,这是用户偏好的交易之一。
无论如何,一旦你解决了所有的冲突,并运行git add 来更新 Git 的索引,你应该运行:
git merge --continue
告诉 Git 完成合并。 Git 不在乎如何您解决冲突。 Git 只关心您在暂存槽零处将正确的文件放入索引,清除其他三个暂存槽。
在过去糟糕的日子里,你不得不跑步:
git commit
完成合并,如果你感到困惑(例如,被打断了,将cd'ed 到其他存储库,然后开会或其他什么,现在不是你当时想的那样你跑了git commit) 你可以做一个普通的提交而不是完成你的合并。 --continue 检查是否确实有要完成的合并,然后运行 git commit 来完成它。