为什么 "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: ... 状态,但我不知道为什么/这意味着什么。


我的步数:

  1. 修改了单个文件
  2. (我可能做了一个git add -p,然后可能修改了工作树副本(我不记得了))
  3. $git stash -m 'my message'
  4. $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-pickgit 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 mergegit 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——无论如何——现在将所有这些提交放入一个存储库,使用相同的名称(alicebob)来标识相同的提交哈希 ID(JL 分别)得到:

          I--J   <-- alice
         /
...--G--H   <-- main (HEAD)
         \
          K--L   <-- bob

或者,也许您使用的名称略有不同,例如 alice/alicebob/boborigin/alice 或其他名称。 names 无关紧要:重要的是拥有并能够找到具有唯一哈希 ID 的提交。

在上图中,我们目前位于分支 main,这不是我们想要的位置,所以让我们 运行 git checkout alicegit checkout bob根据需要,使 当前提交 成为提交 JL。让我们通过名称 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 是一个 合并提交 因为它有两个父项,JL,而不是通常的(如果这不是合并提交,那将是 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 --cachedgit 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 索引中的那些文件。

然而,要进行合并,我们需要查看 三个 提交,而不仅仅是一个。合并的三个输入是:

  1. 合并基础提交;
  2. 当前(--ours)提交;
  3. 其他 (--theirs) 提交。

为了处理这个问题,合并过程扩展索引。索引中的所有文件现在都被认为位于“零槽”中,每个条目都有四个编号的槽。槽零是“全部完成”槽。插槽 1 用于合并基础,插槽 2 用于 --ours,插槽 3 用于 --theirs4 当前提交位于合并,已经在 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 --stagegit 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 提交 JL——很多其他 Git 操作将使用合并引擎。为了使这项工作有效,他们只需 分配 一些承诺作为合并基础。他们选择当前提交作为当前提交(总是),并将其他一些提交分配为 --theirs 提交。

对于cherry-pick,合并基础提交是您告诉Git到cherry-pick的提交的父提交,而--theirs提交是您告诉的提交Git 到 cherry-pick。使用提交的父级作为合并基础会产生预期的效果:我们采用 diff-ing 父级和提交所定义的更改,并将其添加到我们当前的提交中。

对于 git stash apply,合并基础提交是正在应用的存储中 work-tree 提交的父提交。 git stash pushgit 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 addgit rm 来更新 Git 的索引。8


8你也可以使用git update-index,但这需要深入了解索引条目的存储方式。