为什么 "git stash push" 导致 "Unmerged paths: ... both modified: ..."?
Why does "git stash push" cause "Unmerged paths: ... both modified: ..."?
Q1:如何重现这个场景? (我尝试重现失败)
Q2:这个Unmerged paths:... both modified:...
状态是什么意思?
我执行了 git stash
(推送)并进入了 Unmerged paths: ... both modified: ...
状态,但我不知道为什么/这意味着什么。
我的步数:
- 修改了单个文件
- (我可能做了一个
git add -p
,然后可能修改了工作树副本(我不记得了))
- $
git stash -m 'my message'
- $
git status
Unmerged paths:
(use "git restore --staged <file>..." to unstage)
(use "git add <file>..." to mark resolution)
both modified: questions/templates/flashcard.html
no changes added to commit (use "git add" and/or "git commit -a")
您的出发点是错误的:git stash push
本身 不会 导致问题。一旦问题存在,它只是遭受问题。
(I unsuccessfully tried to reproduce it)
这并不奇怪。
What does this Unmerged paths
... mean?
这意味着使用Git的合并引擎的某些操作开始了合并,但由于合并冲突而无法完成合并。如果您正在寻找调用 Git 的合并引擎的列表,它相当长:
git merge
当然可以;
git cherry-pick
和git revert
——用一段普通的代码实现——可以做到;
git rebase
可以调用 git cherry-pick
因此可以做到;
git stash apply
直接调用 git merge-recursive
——git merge
的内部,因此可以这样做,而 git stash pop
运行s git stash apply
;
和许多其他 Git 操作可能调用其中之一,例如 git pull
运行s git fetch
,后跟 git merge
或 git rebase
。考虑到您对 git stash
很纠结,我猜是之前的 git stash apply
,可能是通过 git stash pop
调用的,这让您陷入了问题状态。
quick-ish 深入“未合并的路径”
理解合并冲突和未合并路径状态的最佳方法是从 git merge
本身开始。想象一下 the standard two different users, Alice and Bob,从一些共同的起点开始,即在 Git 存储库中的一些提交都已克隆:
...--G--H <-- main (HEAD)
这里的大写字母代表单个提交; commit H
只是主分支上 last 提交的哈希 ID。
Alice 在她的存储库副本中创建了一个新的分支名称 alice
并开始工作。她进行了两次新提交,每一次都获得了一个新的、唯一的哈希 ID。在 Alice 的存储库中,这些看起来像这样:
I--J <-- alice (HEAD)
/
...--G--H <-- main
同时 Bob 做了同样的事情,但当然得到了不同的(still-unique,但不同于 Alice 的)哈希 ID,所以这些变成:
...--G--H <-- main
\
K--L <-- bob (HEAD)
你,或 Alice 或 Bob——无论如何——现在将所有这些提交放入一个存储库,使用相同的名称(alice
和 bob
)来标识相同的提交哈希 ID(J
和 L
分别)得到:
I--J <-- alice
/
...--G--H <-- main (HEAD)
\
K--L <-- bob
或者,也许您使用的名称略有不同,例如 alice/alice
或 bob/bob
或 origin/alice
或其他名称。 names 无关紧要:重要的是拥有并能够找到具有唯一哈希 ID 的提交。
在上图中,我们目前位于分支 main
,这不是我们想要的位置,所以让我们 运行 git checkout alice
或 git checkout bob
根据需要,使 当前提交 成为提交 J
或 L
。让我们通过名称 alice
使用提交 J
。 (如果 Alice 是执行合并的人,她可能有类似 bob/bob
作为 Bob 提交的名称,并且已经/仍在提交 J
,所以如果 她是 做合并,她甚至不需要 运行 git checkout
这里。但是 我们正在 做,所以我们使用 git checkout alice
.)
I--J <-- alice (HEAD)
/
...--G--H
\
K--L <-- bob
名字main
还在,我现在不画了,要碍事了。我们现在 运行:
git merge bob
Git 将:
- 找到我们当前的提交(很简单,它是
J
);
- 使用名称
bob
定位另一个提交 (L
);和
- 使用两个 提交向后工作以找出Alice 和Bob 从 开始的共同共享提交。这是 merge base 提交,它对合并过程至关重要 - Git 的合并引擎执行的 merge-as-a-verb 操作。
在这种情况下,合并基础是提交 H
。因此 Git 找到 H
并提供这三个提交:H
(合并基础)、J
(当前或 --ours
提交)和 L
(other or --theirs
commit)到合适的 Git merge strategy.1 merge strategy负责执行合并:做 merge-as-a-verb 部分。
如果一切顺利,Git 自行完成合并并进行新提交:
I--J
/ \
...--G--H M <-- alice (HEAD)
\ /
K--L <-- bob
新提交 M
是一个 合并提交 因为它有两个父项,J
和 L
,而不是通常的(如果这不是合并提交,那将是 J
)。但这只有在 Git 能够自行完成合并时才会发生。
1策略,默认-s recursive
,是合并引擎的实现。策略不止一种,所以合并引擎其实不止一种。各种不同的策略中的每一个都至少做了一些不同的事情——毕竟有两个相同的策略是没有意义的——但我们将忽略除了 recursive
的简单情况之外的所有策略。只要只有一个合并基础,递归和解析策略就做同样的事情。如果有多个合并基础,它们就会变得不同,但这种情况很少见。
Git的索引
此时,我们需要一个侧边栏来讨论Git的index。 Git 中的索引是一个非常核心的东西。例如,这对于进行任何新的提交都是至关重要的。这个重要的实体有一个非常通用、毫无意义的名字,“索引”。这可能是一个错误,并且有一个更新的 better-for-many-purposes 名称,它是 staging area.2 这是指如何索引最常使用:保存提议的下一次提交。
当您使用 git checkout
提取一些提交时,Git 会填充它自己的索引(暂存区)和您的 工作树 或 work-tree 来自该提交中存储的文件。每个提交都包含每个文件的完整快照,压缩格式为 read-only、Git-only、de-duplicated。您实际上无法处理或使用这些文件,因此 Git 必须提取它们。将它们放入您的 work-tree 就足够了,这也是其他 non-Git 版本控制系统所做的;但那些其他 VCS 不是 Git。 Git 将它们放入您的 work-tree 和 自己的索引中。
索引副本是 pre-compressed 和前 de-duplicated(并且是 de-duplicated,实际上只是对现有副本的引用)。他们准备好进行新的提交。当您对文件的 work-tree 副本大惊小怪时,索引副本 没有任何反应。这就是为什么您必须一直 运行 git add
的原因。 git add
步骤读取 work-tree 副本,压缩并 de-duplicates 它,并将结果填充到 Git 的索引中。现在可以提交更新的文件了。
因此,在任何时候,Git 的索引都会保存您的提议的下一次提交。您最初使用 git checkout
进行设置。然后你改变了你的 work-tree 文件,它本身并没有做任何有用的事情。但是 然后 你 git add
,它 替换你告诉它更新的文件的索引副本 。 Git 的索引仍然保存着你提议的下一次提交,只是现在,提议的下一次提交 与你之前签出的不匹配。
这就是 Git 索引的正常日常工作:它包含您提议的下一次提交。您可以更新其中的文件,如果愿意,可以添加新文件,如果愿意,可以删除现有文件;这会更新您提议的下一次提交。您 获取 文件以添加到其中或从您的 work-tree 更新其中您拥有可以使用普通程序(包括您的编辑器)的普通文件。 Git 为您填写您的 work-tree,并为自己填写其索引,然后您以这种迂回方式更新 Git 的索引。 git add -p
之类的命令一次只更新一个文件的索引副本,而不是将整个 work-tree 文件作为替换。3
2甚至还有第三个名字,cache,但这个名字不再经常使用了。您通常会在标志中看到它:git rm --cached
、git diff --cached
等。其中一些允许您使用 --staged
但每个 Git 命令在这里都是不同的。这不是很一致。这是 Git 的弱点之一:不一致且经常令人困惑的命令行。
3git add -p
的实际实现是:
- 提取索引副本到一个临时文件;
- 比较临时文件和 work-tree 文件以获得一系列差异块;
- 按照指示将差异块应用到临时文件;
git add
整个,now-modified临时文件放入原始文件名下的索引;和
- 删除临时文件。
至少,现有的 Perl-based git add -p
是这样。正在努力用 C 重写 git add -p
,这可能会在内存中做更多这样的事情 and/or 尝试提高效率——Perl 实现在应用每个 hunk 后更新索引副本,当它可能是最好等待——但相同的原则将适用:您只是获取索引副本,对其进行更改,然后将更新后的文件塞回索引,压缩和 de-duplicating 内容以将其放入索引。
Git 合并期间的索引
为了完成上述所有工作,Git 的索引需要为每个 work-tree 文件保存一个条目。更准确地说,它为每个 跟踪文件 保存一个条目,正是该条目 在 Git 的索引中的存在使得提交“跟踪文件”。新提交使用 运行 git commit
时 Git 索引中的任何内容,因此新提交恰好包含 Git 索引中的那些文件。
然而,要进行合并,我们需要查看 三个 提交,而不仅仅是一个。合并的三个输入是:
- 合并基础提交;
- 当前(
--ours
)提交;
- 其他 (
--theirs
) 提交。
为了处理这个问题,合并过程扩展索引。索引中的所有文件现在都被认为位于“零槽”中,每个条目都有四个编号的槽。槽零是“全部完成”槽。插槽 1 用于合并基础,插槽 2 用于 --ours
,插槽 3 用于 --theirs
。4 当前提交位于合并,已经在 Git 的索引中;它只是在错误的插槽中——插槽 0,而不是插槽 2。因此进程的开始将这些移动到插槽 2,或者将当前提交读取到插槽 2,但是你想看它。5 同时,它将合并基础提交读入插槽 1,将 --theirs
提交读入插槽 3。
这意味着索引现在持有 每个文件的三个副本。6 合并过程继续决定要做什么每个文件:
如果一个文件的所有三个副本都相同,则没有人更改该文件。使用文件的任何副本作为合并结果。
如果两个副本匹配——基础和我们的,或者基础和他们的——另一个人更改了文件。使用更改后的文件作为合并结果。
如果所有三个副本都不同,则此文件需要实际合并。
如果一个文件不需要任何实际的合并,Git可以只使用正确的索引槽并将其重新编号为“槽零”,擦除另一个插槽。该文件现已合并。如果work-tree文件需要替换,Git可以同时替换work-tree文件。
如果文件 确实 需要实际合并,Git 将合并基础版本(在插槽 1 中)与其他两个文件(在插槽 2 和3), line-by-line.这些比较会产生一组变化。 Git 尝试 合并 两组更改。如果 Alice 更改了第 3 行,而 Bob 没有,Git 可以接受 Alice 的更改。如果 Bob 更改了第 42 行,而 Alice 没有,Git 可以接受 Bob 的更改。所有 non-conflicting 的变化,涉及合并基础文件中 不同行 ,可以堆在一起。
如果 Bob 和 Alice 触及 相同的 行,7,则:
- 要么他们做了相同的改变,所以Git只能复制一份;或
- 他们做了不同的改变。 Git不知道该用哪个!
对于 最后一个 案例,Git 声明了一个合并冲突。 Git 将文件的所有三个副本留在索引中的非零暂存槽中。 Git 尽最大努力写入 work-tree 文件合并文件——使用 Git 能够组合它们的组合更改——以及冲突标记和 Git 无法组合更改的冲突部分。
作为在 运行 合并过程中监督 Git 的人,你的工作现在是 解决冲突 通过生产—— any 意味着你喜欢 - 正确的合并结果 并将其填充到 Git 的索引中。一种方法是编辑普通文件,其中包含冲突标记。解决这里的冲突,然后运行git加上<em>path</em>
。这告诉 Git 擦除所有三个高编号插槽,压缩和 de-duplicating 文件的 work-tree 副本,并像往常一样将其填充到索引插槽 #0 中。这个特定的合并冲突现已解决。
另一种方法是 运行 git mergetool
,它使用 lower-level Git 命令从索引中提取所有三个文件,并且 运行 一些 third-party 程序(你的编辑器,一些合并工具,等等)。这个 third-party 程序必须解决冲突 — 可能与您的输入有关 — 并将结果写入您的 work-tree,其中 git mergetool
将使用 git add
读取它。正如您所看到的,git mergetool
非常类似于手动解析它——它只是为您提供了一种简单的方法来对所有三个文件进行 运行 处理,而不是 Git 的处理best-effort-with-conflict-markers复制。
4实际实现使用单独的条目,每个条目中都有一个槽号,而不是每个文件一个条目有四个槽号。但是,on-disk 索引格式将来可能会更改,并且过去已更改。您真正承诺的是 git ls-files --stage
和 git update-index
.
文档中描述的内容
5这掩盖了索引的内容可能与当前提交的内容不同的事实。 git merge
命令和许多其他命令,默认情况下首先检查以确保 不是 情况,如果失败(停止执行并显示错误消息)索引与当前提交不匹配。他们还检查索引和 work-tree 是否匹配。如果三者都匹配,事情就安全多了,因为如果出现任何问题,您关心的所有文件都安全地存储在当前提交中。
git stash apply
代码 不是 这些 safety-checking 命令之一。出错的应用程序会导致很难恢复的混乱。我建议尽可能避免这种情况,这样你甚至不需要怀疑它是“读取提交到槽 2”还是“将 2 写入现有索引条目的槽号”。
6这掩盖了某些提交中可能不存在某些文件的事实。例如,如果 Alice 创建了一个 all-new 文件,则该文件不存在于插槽 1 和 3 中,仅存在于插槽 2 中。如果 Bob 删除了一个现有文件,则该文件仅存在于插槽 1 和 2 中,而不存在于插槽 1 和 3 中。在 3.
我们也完全忽略了这里可能发生的复杂冲突,例如,Alice 修改了一个文件,Bob 删除了同一个文件,或者 Alice 和 Bob 都创建了一个同名但不同的新文件内容。 Git 检测到一些(虽然不是全部)文件重命名案例,这些案例也会产生冲突。重命名检测和其中一些冲突是特殊情况,而其余情况可以直接通过索引槽条目查看。
7出于安全目的和处理文件末尾的排序问题,Git 认为“接触”的两个更改存在冲突。例如,如果 Alice 更改了第 14 行而不是第 15 行,并且 Bob 在第 14 行之后和第 15 行之前添加了一行,那么这两个更改相邻并且 Git 在这里声明合并冲突。
Cherry-pick、还原和其他操作使用合并引擎
虽然上面是 git merge
本身——我们有一个合并基础提交 H
和两个 branch-tip 提交 J
和 L
——很多其他 Git 操作将使用合并引擎。为了使这项工作有效,他们只需 分配 一些承诺作为合并基础。他们选择当前提交作为当前提交(总是),并将其他一些提交分配为 --theirs
提交。
对于cherry-pick,合并基础提交是您告诉Git到cherry-pick的提交的父提交,而--theirs
提交是您告诉的提交Git 到 cherry-pick。使用提交的父级作为合并基础会产生预期的效果:我们采用 diff-ing 父级和提交所定义的更改,并将其添加到我们当前的提交中。
对于 git stash apply
,合并基础提交是正在应用的存储中 work-tree 提交的父提交。 git stash push
或 git stash save
命令创建了两个或三个提交,其中一个或两个或全部三个可以稍后在 apply
步骤中使用。大多数存储进行两次提交,而标准 git stash apply
只使用这两个中的一个(加上它的父级)。有关详细信息,请参阅 How to recover from "git stash save --all"?
结论
现在我们知道了:
both modified: questions/templates/flashcard.html
表示:目前,在Git的索引中,存在名为questions/templates/flashcard.html
的文件的三个副本。插槽#1 中的副本来自合并基地; slot #2 中的副本来自当前的提交,或者陷入了上面脚注 5 中提到的复杂情况;插槽 #3 中的副本来自用作另一个提交的提交。比较这些副本,三个都不一样。
您可以通过以下方式查看实际文件内容:
git show :1:questions/templates/flashcard.html
显示合并基础副本,
git show :2:questions/templates/flashcard.html
显示 --ours
插槽 2 副本,并且:
git show :3:questions/templates/flashcard.html
显示了 --theirs
插槽 3 副本。 Git 将尽最大努力将这些合并到您的 work-tree questions/templates/flashcard.html
文件中。
当你处于这种状态时——索引包含任何 nonzero-numbered 条目——none 写入新提交的操作可以运行,因为 Git 只能写出当所有条目都在暂存槽 #0 中时的索引。要解决此问题,您必须使用 git add
或 git rm
来更新 Git 的索引。8
8你也可以使用git update-index
,但这需要深入了解索引条目的存储方式。
Q1:如何重现这个场景? (我尝试重现失败)
Q2:这个Unmerged paths:... both modified:...
状态是什么意思?
我执行了 git stash
(推送)并进入了 Unmerged paths: ... both modified: ...
状态,但我不知道为什么/这意味着什么。
我的步数:
- 修改了单个文件
- (我可能做了一个
git add -p
,然后可能修改了工作树副本(我不记得了)) - $
git stash -m 'my message'
- $
git status
Unmerged paths:
(use "git restore --staged <file>..." to unstage)
(use "git add <file>..." to mark resolution)
both modified: questions/templates/flashcard.html
no changes added to commit (use "git add" and/or "git commit -a")
您的出发点是错误的:git stash push
本身 不会 导致问题。一旦问题存在,它只是遭受问题。
(I unsuccessfully tried to reproduce it)
这并不奇怪。
What does this
Unmerged paths
... mean?
这意味着使用Git的合并引擎的某些操作开始了合并,但由于合并冲突而无法完成合并。如果您正在寻找调用 Git 的合并引擎的列表,它相当长:
git merge
当然可以;git cherry-pick
和git revert
——用一段普通的代码实现——可以做到;git rebase
可以调用git cherry-pick
因此可以做到;git stash apply
直接调用git merge-recursive
——git merge
的内部,因此可以这样做,而git stash pop
运行sgit stash apply
;
和许多其他 Git 操作可能调用其中之一,例如 git pull
运行s git fetch
,后跟 git merge
或 git rebase
。考虑到您对 git stash
很纠结,我猜是之前的 git stash apply
,可能是通过 git stash pop
调用的,这让您陷入了问题状态。
quick-ish 深入“未合并的路径”
理解合并冲突和未合并路径状态的最佳方法是从 git merge
本身开始。想象一下 the standard two different users, Alice and Bob,从一些共同的起点开始,即在 Git 存储库中的一些提交都已克隆:
...--G--H <-- main (HEAD)
这里的大写字母代表单个提交; commit H
只是主分支上 last 提交的哈希 ID。
Alice 在她的存储库副本中创建了一个新的分支名称 alice
并开始工作。她进行了两次新提交,每一次都获得了一个新的、唯一的哈希 ID。在 Alice 的存储库中,这些看起来像这样:
I--J <-- alice (HEAD)
/
...--G--H <-- main
同时 Bob 做了同样的事情,但当然得到了不同的(still-unique,但不同于 Alice 的)哈希 ID,所以这些变成:
...--G--H <-- main
\
K--L <-- bob (HEAD)
你,或 Alice 或 Bob——无论如何——现在将所有这些提交放入一个存储库,使用相同的名称(alice
和 bob
)来标识相同的提交哈希 ID(J
和 L
分别)得到:
I--J <-- alice
/
...--G--H <-- main (HEAD)
\
K--L <-- bob
或者,也许您使用的名称略有不同,例如 alice/alice
或 bob/bob
或 origin/alice
或其他名称。 names 无关紧要:重要的是拥有并能够找到具有唯一哈希 ID 的提交。
在上图中,我们目前位于分支 main
,这不是我们想要的位置,所以让我们 运行 git checkout alice
或 git checkout bob
根据需要,使 当前提交 成为提交 J
或 L
。让我们通过名称 alice
使用提交 J
。 (如果 Alice 是执行合并的人,她可能有类似 bob/bob
作为 Bob 提交的名称,并且已经/仍在提交 J
,所以如果 她是 做合并,她甚至不需要 运行 git checkout
这里。但是 我们正在 做,所以我们使用 git checkout alice
.)
I--J <-- alice (HEAD)
/
...--G--H
\
K--L <-- bob
名字main
还在,我现在不画了,要碍事了。我们现在 运行:
git merge bob
Git 将:
- 找到我们当前的提交(很简单,它是
J
); - 使用名称
bob
定位另一个提交 (L
);和 - 使用两个 提交向后工作以找出Alice 和Bob 从 开始的共同共享提交。这是 merge base 提交,它对合并过程至关重要 - Git 的合并引擎执行的 merge-as-a-verb 操作。
在这种情况下,合并基础是提交 H
。因此 Git 找到 H
并提供这三个提交:H
(合并基础)、J
(当前或 --ours
提交)和 L
(other or --theirs
commit)到合适的 Git merge strategy.1 merge strategy负责执行合并:做 merge-as-a-verb 部分。
如果一切顺利,Git 自行完成合并并进行新提交:
I--J
/ \
...--G--H M <-- alice (HEAD)
\ /
K--L <-- bob
新提交 M
是一个 合并提交 因为它有两个父项,J
和 L
,而不是通常的(如果这不是合并提交,那将是 J
)。但这只有在 Git 能够自行完成合并时才会发生。
1策略,默认-s recursive
,是合并引擎的实现。策略不止一种,所以合并引擎其实不止一种。各种不同的策略中的每一个都至少做了一些不同的事情——毕竟有两个相同的策略是没有意义的——但我们将忽略除了 recursive
的简单情况之外的所有策略。只要只有一个合并基础,递归和解析策略就做同样的事情。如果有多个合并基础,它们就会变得不同,但这种情况很少见。
Git的索引
此时,我们需要一个侧边栏来讨论Git的index。 Git 中的索引是一个非常核心的东西。例如,这对于进行任何新的提交都是至关重要的。这个重要的实体有一个非常通用、毫无意义的名字,“索引”。这可能是一个错误,并且有一个更新的 better-for-many-purposes 名称,它是 staging area.2 这是指如何索引最常使用:保存提议的下一次提交。
当您使用 git checkout
提取一些提交时,Git 会填充它自己的索引(暂存区)和您的 工作树 或 work-tree 来自该提交中存储的文件。每个提交都包含每个文件的完整快照,压缩格式为 read-only、Git-only、de-duplicated。您实际上无法处理或使用这些文件,因此 Git 必须提取它们。将它们放入您的 work-tree 就足够了,这也是其他 non-Git 版本控制系统所做的;但那些其他 VCS 不是 Git。 Git 将它们放入您的 work-tree 和 自己的索引中。
索引副本是 pre-compressed 和前 de-duplicated(并且是 de-duplicated,实际上只是对现有副本的引用)。他们准备好进行新的提交。当您对文件的 work-tree 副本大惊小怪时,索引副本 没有任何反应。这就是为什么您必须一直 运行 git add
的原因。 git add
步骤读取 work-tree 副本,压缩并 de-duplicates 它,并将结果填充到 Git 的索引中。现在可以提交更新的文件了。
因此,在任何时候,Git 的索引都会保存您的提议的下一次提交。您最初使用 git checkout
进行设置。然后你改变了你的 work-tree 文件,它本身并没有做任何有用的事情。但是 然后 你 git add
,它 替换你告诉它更新的文件的索引副本 。 Git 的索引仍然保存着你提议的下一次提交,只是现在,提议的下一次提交 与你之前签出的不匹配。
这就是 Git 索引的正常日常工作:它包含您提议的下一次提交。您可以更新其中的文件,如果愿意,可以添加新文件,如果愿意,可以删除现有文件;这会更新您提议的下一次提交。您 获取 文件以添加到其中或从您的 work-tree 更新其中您拥有可以使用普通程序(包括您的编辑器)的普通文件。 Git 为您填写您的 work-tree,并为自己填写其索引,然后您以这种迂回方式更新 Git 的索引。 git add -p
之类的命令一次只更新一个文件的索引副本,而不是将整个 work-tree 文件作为替换。3
2甚至还有第三个名字,cache,但这个名字不再经常使用了。您通常会在标志中看到它:git rm --cached
、git diff --cached
等。其中一些允许您使用 --staged
但每个 Git 命令在这里都是不同的。这不是很一致。这是 Git 的弱点之一:不一致且经常令人困惑的命令行。
3git add -p
的实际实现是:
- 提取索引副本到一个临时文件;
- 比较临时文件和 work-tree 文件以获得一系列差异块;
- 按照指示将差异块应用到临时文件;
git add
整个,now-modified临时文件放入原始文件名下的索引;和- 删除临时文件。
至少,现有的 Perl-based git add -p
是这样。正在努力用 C 重写 git add -p
,这可能会在内存中做更多这样的事情 and/or 尝试提高效率——Perl 实现在应用每个 hunk 后更新索引副本,当它可能是最好等待——但相同的原则将适用:您只是获取索引副本,对其进行更改,然后将更新后的文件塞回索引,压缩和 de-duplicating 内容以将其放入索引。
Git 合并期间的索引
为了完成上述所有工作,Git 的索引需要为每个 work-tree 文件保存一个条目。更准确地说,它为每个 跟踪文件 保存一个条目,正是该条目 在 Git 的索引中的存在使得提交“跟踪文件”。新提交使用 运行 git commit
时 Git 索引中的任何内容,因此新提交恰好包含 Git 索引中的那些文件。
然而,要进行合并,我们需要查看 三个 提交,而不仅仅是一个。合并的三个输入是:
- 合并基础提交;
- 当前(
--ours
)提交; - 其他 (
--theirs
) 提交。
为了处理这个问题,合并过程扩展索引。索引中的所有文件现在都被认为位于“零槽”中,每个条目都有四个编号的槽。槽零是“全部完成”槽。插槽 1 用于合并基础,插槽 2 用于 --ours
,插槽 3 用于 --theirs
。4 当前提交位于合并,已经在 Git 的索引中;它只是在错误的插槽中——插槽 0,而不是插槽 2。因此进程的开始将这些移动到插槽 2,或者将当前提交读取到插槽 2,但是你想看它。5 同时,它将合并基础提交读入插槽 1,将 --theirs
提交读入插槽 3。
这意味着索引现在持有 每个文件的三个副本。6 合并过程继续决定要做什么每个文件:
如果一个文件的所有三个副本都相同,则没有人更改该文件。使用文件的任何副本作为合并结果。
如果两个副本匹配——基础和我们的,或者基础和他们的——另一个人更改了文件。使用更改后的文件作为合并结果。
如果所有三个副本都不同,则此文件需要实际合并。
如果一个文件不需要任何实际的合并,Git可以只使用正确的索引槽并将其重新编号为“槽零”,擦除另一个插槽。该文件现已合并。如果work-tree文件需要替换,Git可以同时替换work-tree文件。
如果文件 确实 需要实际合并,Git 将合并基础版本(在插槽 1 中)与其他两个文件(在插槽 2 和3), line-by-line.这些比较会产生一组变化。 Git 尝试 合并 两组更改。如果 Alice 更改了第 3 行,而 Bob 没有,Git 可以接受 Alice 的更改。如果 Bob 更改了第 42 行,而 Alice 没有,Git 可以接受 Bob 的更改。所有 non-conflicting 的变化,涉及合并基础文件中 不同行 ,可以堆在一起。
如果 Bob 和 Alice 触及 相同的 行,7,则:
- 要么他们做了相同的改变,所以Git只能复制一份;或
- 他们做了不同的改变。 Git不知道该用哪个!
对于 最后一个 案例,Git 声明了一个合并冲突。 Git 将文件的所有三个副本留在索引中的非零暂存槽中。 Git 尽最大努力写入 work-tree 文件合并文件——使用 Git 能够组合它们的组合更改——以及冲突标记和 Git 无法组合更改的冲突部分。
作为在 运行 合并过程中监督 Git 的人,你的工作现在是 解决冲突 通过生产—— any 意味着你喜欢 - 正确的合并结果 并将其填充到 Git 的索引中。一种方法是编辑普通文件,其中包含冲突标记。解决这里的冲突,然后运行git加上<em>path</em>
。这告诉 Git 擦除所有三个高编号插槽,压缩和 de-duplicating 文件的 work-tree 副本,并像往常一样将其填充到索引插槽 #0 中。这个特定的合并冲突现已解决。
另一种方法是 运行 git mergetool
,它使用 lower-level Git 命令从索引中提取所有三个文件,并且 运行 一些 third-party 程序(你的编辑器,一些合并工具,等等)。这个 third-party 程序必须解决冲突 — 可能与您的输入有关 — 并将结果写入您的 work-tree,其中 git mergetool
将使用 git add
读取它。正如您所看到的,git mergetool
非常类似于手动解析它——它只是为您提供了一种简单的方法来对所有三个文件进行 运行 处理,而不是 Git 的处理best-effort-with-conflict-markers复制。
4实际实现使用单独的条目,每个条目中都有一个槽号,而不是每个文件一个条目有四个槽号。但是,on-disk 索引格式将来可能会更改,并且过去已更改。您真正承诺的是 git ls-files --stage
和 git update-index
.
5这掩盖了索引的内容可能与当前提交的内容不同的事实。 git merge
命令和许多其他命令,默认情况下首先检查以确保 不是 情况,如果失败(停止执行并显示错误消息)索引与当前提交不匹配。他们还检查索引和 work-tree 是否匹配。如果三者都匹配,事情就安全多了,因为如果出现任何问题,您关心的所有文件都安全地存储在当前提交中。
git stash apply
代码 不是 这些 safety-checking 命令之一。出错的应用程序会导致很难恢复的混乱。我建议尽可能避免这种情况,这样你甚至不需要怀疑它是“读取提交到槽 2”还是“将 2 写入现有索引条目的槽号”。
6这掩盖了某些提交中可能不存在某些文件的事实。例如,如果 Alice 创建了一个 all-new 文件,则该文件不存在于插槽 1 和 3 中,仅存在于插槽 2 中。如果 Bob 删除了一个现有文件,则该文件仅存在于插槽 1 和 2 中,而不存在于插槽 1 和 3 中。在 3.
我们也完全忽略了这里可能发生的复杂冲突,例如,Alice 修改了一个文件,Bob 删除了同一个文件,或者 Alice 和 Bob 都创建了一个同名但不同的新文件内容。 Git 检测到一些(虽然不是全部)文件重命名案例,这些案例也会产生冲突。重命名检测和其中一些冲突是特殊情况,而其余情况可以直接通过索引槽条目查看。
7出于安全目的和处理文件末尾的排序问题,Git 认为“接触”的两个更改存在冲突。例如,如果 Alice 更改了第 14 行而不是第 15 行,并且 Bob 在第 14 行之后和第 15 行之前添加了一行,那么这两个更改相邻并且 Git 在这里声明合并冲突。
Cherry-pick、还原和其他操作使用合并引擎
虽然上面是 git merge
本身——我们有一个合并基础提交 H
和两个 branch-tip 提交 J
和 L
——很多其他 Git 操作将使用合并引擎。为了使这项工作有效,他们只需 分配 一些承诺作为合并基础。他们选择当前提交作为当前提交(总是),并将其他一些提交分配为 --theirs
提交。
对于cherry-pick,合并基础提交是您告诉Git到cherry-pick的提交的父提交,而--theirs
提交是您告诉的提交Git 到 cherry-pick。使用提交的父级作为合并基础会产生预期的效果:我们采用 diff-ing 父级和提交所定义的更改,并将其添加到我们当前的提交中。
对于 git stash apply
,合并基础提交是正在应用的存储中 work-tree 提交的父提交。 git stash push
或 git stash save
命令创建了两个或三个提交,其中一个或两个或全部三个可以稍后在 apply
步骤中使用。大多数存储进行两次提交,而标准 git stash apply
只使用这两个中的一个(加上它的父级)。有关详细信息,请参阅 How to recover from "git stash save --all"?
结论
现在我们知道了:
both modified: questions/templates/flashcard.html
表示:目前,在Git的索引中,存在名为questions/templates/flashcard.html
的文件的三个副本。插槽#1 中的副本来自合并基地; slot #2 中的副本来自当前的提交,或者陷入了上面脚注 5 中提到的复杂情况;插槽 #3 中的副本来自用作另一个提交的提交。比较这些副本,三个都不一样。
您可以通过以下方式查看实际文件内容:
git show :1:questions/templates/flashcard.html
显示合并基础副本,
git show :2:questions/templates/flashcard.html
显示 --ours
插槽 2 副本,并且:
git show :3:questions/templates/flashcard.html
显示了 --theirs
插槽 3 副本。 Git 将尽最大努力将这些合并到您的 work-tree questions/templates/flashcard.html
文件中。
当你处于这种状态时——索引包含任何 nonzero-numbered 条目——none 写入新提交的操作可以运行,因为 Git 只能写出当所有条目都在暂存槽 #0 中时的索引。要解决此问题,您必须使用 git add
或 git rm
来更新 Git 的索引。8
8你也可以使用git update-index
,但这需要深入了解索引条目的存储方式。