git checkout --ours无法从未合并文件列表中删除文件

42

你好,我需要像这样合并两个分支。

这只是一个示例,实际上我需要解决数百个文件的合并问题。

git merge branch1
...conflicts...
git status
....
# Unmerged paths:
#   (use "git add/rm <file>..." as appropriate to mark resolution)
#
#   both added:   file1
#   both added:   file2
#   both added:   file3
#   both added:   file4
git checkout --ours file1
git chechout --theirs file2
git checkout --ours file3
git chechout --theirs file4
git commit -a -m "this should work"
U   file1
fatal: 'commit' is not possible because you have unmerged files.
Please, fix them up in the work tree, and then use 'git add/rm <file>' as
appropriate to mark resolution and make a commit, or use 'git commit -a'.
当我执行git merge tool时,会出现来自"ours"分支的正确内容,当我保存后,该文件就会从未合并列表中消失。但是由于我有数百个这样的文件,这不是一个选项。 我以为这种方法会让我达到想要的目的 - 轻松选择我想要保留哪个分支中的哪个文件。 但我想我误解了合并后git checkout --ours/theirs命令的概念。 请提供一些信息告诉我如何处理这种情况好吗?我使用git 1.7.1
1个回答

124
主要是由于 git checkout 内部工作方式的怪癖。Git 的开发人员有让实现决定接口的倾向性。
这样,如果你想解决冲突,经过使用 --ours 或 --theirs 参数的 git checkout 命令之后,你必须同时使用 git add 命令添加相同的路径。
git checkout --ours -- path/to/file
git add path/to/file

但这并不适用于其他形式的 git checkout 命令:

git checkout HEAD -- path/to/file

或:

git checkout MERGE_HEAD -- path/to/file

(这些在多个方面都有微妙的不同)。 在某些情况下,这意味着最快的方法是使用中间命令。(顺便说一句,在这里 -- 是为了确保Git能够区分路径名和选项或分支名。例如,如果您有一个名为 - -theirs 的文件,它看起来像一个选项,但是 -- 会告诉Git,不,它实际上是一个路径名。)

要查看这一切内部如何工作,以及为什么需要单独的 git add 除非您不需要,请继续阅读。:-) 首先,让我们快速回顾一下合并过程。

合并,第1部分:合并开始的方式

当您运行:

$ git merge commit-or-branch

Git 做的第一件事就是找到指定提交与当前( HEAD )提交之间的合并基础。 (请注意,如果您在此处提供分支名称,例如 git merge otherbranch ,Git将其转换为提交ID,即该分支的末尾。它将分支名称参数保存到最终的合并日志消息中,但需要提交ID来找到合并基础。)

一旦找到合适的合并基础1,Git则生成两个 git diff 列表:一个从合并基础到 HEAD ,另一个从合并基础到您确定的提交。 这将得到“您更改了什么”和“他们更改了什么”,Git现在必须将它们组合起来。

对于您做出更改而他们没有做出更改的文件,Git可以使用您的版本。

对于他们做出更改而您没有做出更改的文件,Git可以使用他们的版本。

对于您和他们都做出更改的文件,Git必须进行一些真正的合并工作。 它按行比较更改,以查看是否可以将它们合并。 如果它可以将它们合并,则会这样做。 如果合并(再次基于纯粹的逐行比较)似乎冲突,则Git会为该文件声明“合并冲突”(并继续尝试合并,但是留下冲突标记)。

一旦Git合并了所有内容,它要么完成合并——因为没有冲突——要么停止并出现合并冲突。


1如果您绘制提交图,则合并基础很明显。 如果不绘制图形,则有点神秘。 这就是为什么我总是告诉人们绘制图形,或者至少绘制足够使其有意义的部分。

技术上的定义是合并基础是提交图中的“最近共同祖先”(LCA)节点。换句话说,它是当前分支与您要合并的分支连接的最新提交。也就是说,通过记录每个合并的父提交ID,Git能够找到两个分支上次在一起的时间,并因此确定您做了什么以及他们做了什么。但是,为了使这一切正常工作,Git必须记录每个合并。具体而言,它必须将新合并提交的两个(或全部,如所谓的“章鱼”合并)父ID都写入其中。 在某些情况下,可能有多个合适的合并基础。然后过程取决于您的合并策略。默认的递归策略将合并多个合并基础以生成“虚拟合并基础”。这种情况很少,您现在可以忽略它。
当Git以这种方式停止时,它需要给您解决冲突的机会。但是这也意味着它需要记录冲突,这就是Git的“索引”(也称为“暂存区”,有时称为“缓存区”)真正存在的地方。
对于工作树中已暂存的每个文件,索引最多有四个条目,而不是仅有一个条目。最多只有三个条目实际上在使用,但有四个插槽,编号为0到3。
插槽零用于已解决的文件。当您使用Git并且不进行合并时,仅使用插槽零。当您编辑工作树中的文件时,它具有“未暂存的更改”,然后您将文件添加到Git并将更改写入存储库,更新插槽零;您的更改现在已被“暂存”。
插槽1-3用于未解决的文件。当git merge必须因合并冲突而停止时,它将留下空的插槽0,并将所有内容写入插槽1、2和3。文件的合并基础版本记录在插槽1中,--ours版本记录在插槽2中,--theirs版本记录在插槽3中。这些非零插槽条目是Git知道文件未解决的方式。

在解决文件冲突时,您需要使用git add命令将其添加到Git的暂存区中。这会抹掉所有1-3号条目并写入一个零号条目,代表该文件已解决且准备好进行新的提交。(或者在某些情况下,您可能需要使用git rm命令删除文件,这样Git会向零号条目写入一个特殊的“删除”值,并再次擦除1-3号条目。)


2有一些情况下,这三个条目中的一个也可能为空。例如,假设文件new在合并基础中不存在,并在我们和对方两边都被添加了,则:1:new为空,而:2:new:3:new则记录了添加/添加冲突。又例如,假设文件f在基础版本中存在,在我们的HEAD分支中被修改,并在对方的分支中被删除,则:1:f记录了基础文件,:2:f记录了我们的文件版本,而:3:f为空,记录了修改/删除冲突。

对于修改/修改的冲突,三个条目都被占用;只有当一个文件不存在时,其中一个条目才会为空。逻辑上不可能有两个空条目:没有删除/删除冲突或nocreate/添加冲突。但是在重命名冲突方面有一些奇怪之处,在此不再详述。无论如何,正是1、2或3号条目中存在某些值标记了该文件未解决。


合并,第3部分:完成合并

一旦所有文件都已解决,即所有条目都只位于零号条目中,您就可以使用git commit命令提交合并结果。如果git merge能够在不需要帮助的情况下完成合并,它通常会为您运行git commit,但实际提交操作仍然需要通过运行git commit来完成。

commit命令的工作方式与往常一样:将索引内容转换为tree对象,并写入新的提交记录。合并提交唯一的特殊之处在于它有多个父提交ID。3额外的父提交ID来自Git留下的一个文件。默认合并消息也来自一个文件(实际上是两个单独的文件,虽然原则上它们可以组合在一起)。

请注意,在所有情况下,新提交的内容都由索引的内容确定。此外,一旦完成新提交,索引仍然是完整的:它仍然包含相同的内容。默认情况下,git commit不会在此时进行另一个新提交,因为它看到索引与HEAD提交匹配。它称之为“空”,需要--allow-empty来进行额外的提交,但是该索引根本不是空的。它仍然很满——它只是充满了与HEAD提交相同的东西。

这假设您正在进行真正的合并,而不是压缩合并。在进行压缩合并时,git merge故意不将额外的父ID写入额外的文件中,以便新合并提交只有一个父项。 (由于某种原因,git merge --squash还抑制了自动提交,就好像它也包括--no-commit标志一样。不清楚为什么,因为如果您希望抑制自动提交,则可以只运行git merge --squash --no-commit。)

压缩合并不记录其其他父项。这意味着,如果我们稍后再次执行合并,Git将不知道从何处开始进行差异比较。这意味着,通常只应在计划放弃其他分支时进行压缩合并。(有一些棘手的方法可以结合压缩合并和真正的合并,但它们远远超出了本答案的范围。)

有了这些内容之后,我们必须再看一下git checkout如何使用Git的索引。请记住,在正常使用中,仅占用槽零,并且索引为每个已暂存的文件具有一个条目。此外,该条目与当前(HEAD)提交相匹配,除非您修改了文件并git add了结果。它还与工作树中的文件相匹配,除非您修改了文件。4

如果您处于某个分支,并且您git checkout另一个分支,Git会尝试切换到另一个分支。为了成功,Git必须将每个文件的索引条目替换为与其他分支相对应的条目。

假设你当前在 master 分支,并执行了 git checkout branch 命令。Git 会将每个当前索引条目与 branch 分支的最新提交所需的索引条目进行比较,即对于文件 README.txtmaster 分支的内容与 branch 分支的内容是否相同?

如果内容相同,Git 可以轻松地继续下一个文件。如果内容不同,则 Git 必须对索引条目进行处理。(在这个点上,Git 还会检查工作树文件是否与索引条目不同。)

具体来说,在 branch 文件与 master 文件不同时,git checkout 必须使用来自 branch 的版本替换索引条目,或者如果 README.txtbranch 最新提交中不存在,则 Git 必须删除索引条目。此外,如果 git checkout 将修改或删除索引条目,它也需要修改或删除工作树文件。Git 确保这是一个安全的操作,即在允许您切换分支之前,工作树文件与 master 提交的文件匹配。

换句话说,这就是 Git 发现是否可以切换分支(即您是否有在切换到 branch 时会被覆盖的修改)并因此执行所需操作的方式和原因。如果您的工作树中有修改,但是这些修改在两个分支中是相同的,Git 可以将修改留在索引和工作树中,并通知您这些修改的文件已经“继承”到了新分支中。

一旦所有测试都通过并且 Git 确定从 master 切换到 branch 是安全的(或者您指定了 --force),git checkout 实际上会更新所有已更改(或删除)的文件的索引,并更新工作树以匹配。

请注意,所有操作都使用了槽口零。不存在槽口1-3条目,因此 git checkout 不必删除任何此类条目。您不处于冲突的合并状态,并且您运行的是 git checkout branch 命令而不仅仅是检出一个文件,而是一整套文件并切换分支。

需要注意的是,您可以选择检出特定的提交,而不是检出分支。例如,以下是您查看先前提交的方法:

$ git log
... peruse log output ...
$ git checkout f17c393 # let's see what's in this commit

与检出分支类似,此处操作的作用是检出一个任意提交点,而不是使用分支的tip提交点。你现在不再“在”新分支上了,而是在没有分支上:5Git 给你一个“游离的 HEAD”。为重新连接 HEAD,您必须 git checkout mastergit checkout branch 来回到所在的分支。


4如果 Git 进行特殊的 CR-LF 结尾修改或应用模糊过滤器,索引条目可能与工作树版本不匹配。这有点高级,现在最好的方法是忽略这种情况。:-)

5更准确地说,这将使您处于一个匿名(未命名)分支上,该分支将从当前提交点开始增长。如果您进行新的提交,则会保持脱离 HEAD 模式,一旦您git checkout 到其他提交或分支,您将切换到那里,并且 Git 将 “放弃” 您所做的提交。这个脱离 HEAD 模式的重点是让您四处浏览和让您进行新的提交,如果您不采取特殊措施保存它们,它们就会消失。对于相对较新的 Git 的任何人来说,提交“消失”不是很好,因此请确保知道您何时处于此“脱离 HEAD”模式。

git status 命令将告诉您是否处于脱离 HEAD 模式。经常使用它。6如果您的 Git 版本较旧(OP 的版本为 1.7.1,现在非常旧),git status 不如在现代 Git 版本中那么有用,但仍比没有要好。

6一些程序员喜欢将关键的 git status 信息编码到每个命令提示符中。我个人不走这一步,但这可能是一个好主意。


检出特定文件,以及为什么有时会解决合并冲突问题

git checkout 命令还有其他操作模式。特别地,您可以运行 git checkout [flags etc] -- path [path ...] 来检出特定文件。这就是事情变得奇怪的地方。请注意,当您使用形式的命令时,Git不会检查以确保您没有覆盖文件。7

现在,您告诉 Git 从某个地方获取某些特定的文件,并将它们放入工作树中,如果有任何文件,则覆盖它们。复杂的问题是:Git 从哪里获取这些文件?

一般来说,Git 保留文件的位置有三个:

  • 在提交中;8
  • 在索引中;
  • 以及在工作树中。

checkout 命令可以从前两个位置读取,并始终将结果写入工作树。

git checkout 从提交中获取文件时,它首先将其复制到索引中。每次这样做时,它都会将文件写入槽零。如果占用了槽1-3,则写入槽零会将它们清除。当 git checkout 从索引中获取文件时,它不必将其复制到索引中(当然不用:它已经存在了!)。这就是 git checkout 在您未处于合并过程中时如何运作的方式:您可以使用 git checkout -- path/to/file 将其还原为索引版本。9

但是,假设您正在进行冲突合并过程中,并且要通过某个路径(可能是使用 --ours)进行 git checkout。 (如果您没有处于合并过程中,则槽位1-3中没有任何内容,而 --ours 没有意义)。因此,您可以运行 git checkout --ours -- path/to/file

这个 git checkout 从索引中获取文件——在这种情况下,从索引槽位2中获取。由于它已经在索引中了,Git 不会写入索引中,只会将其写入工作树。因此,该文件没有解决!

git checkout --theirs 同样如此:它从索引中获取文件(槽3),并且不解决任何内容。

但是: 如果您使用 git checkout HEAD -- path/to/file,则表示要从HEAD提交中提取。由于这是一个提交,Git 首先将文件内容写入索引。这会写入槽0并擦除1-3。现在文件是已经解决的了!

由于在冲突合并期间,Git 在 MERGE_HEAD中记录被合并的提交 ID,因此您还可以使用 git checkout MERGE_HEAD -- path/to/file 来从另一个提交中获取文件。这也是从提交中提取,因此它会写入索引,解决该文件。


7 我常常希望 Git 使用不同的前端命令来完成这项任务,因为我们可以毫不含糊地说,git checkout 是安全的,并且不会不经 --force 覆盖文件。但是这种类型的 git checkout 函数是特意覆盖文件的!

8这有点虚假,或者至少是有些牵强:提交并不直接包含文件。相反,提交包含一个(单一的)指向“树”对象的指针。该树对象包含其他树对象和blob对象的ID。blob对象包含实际的文件内容。

实际上,索引也是如此。每个索引槽包含的不是实际的文件内容,而是存储在仓库中blob对象的哈希ID。

然而,对于我们的目的来说,这并不重要:我们只需要请求Git检索commit:path,它会为我们找到树和blob ID。或者,我们请求Git检索:n:path,它会在n号索引槽中的path条目中查找blob ID。然后它会将文件的内容返回给我们,一切就绪了。

这种冒号和数字符号在Git中随处可用,而--ours--theirs标志仅在git checkout中适用。这个有趣的冒号语法在gitrevisions中有描述。

9使用git checkout -- path的用例是这样的:假设你不管是否正在进行合并,都对文件进行了一些更改、测试、发现这些更改起作用了,然后在文件上运行了git add。然后你决定再做更多的更改,但没有再次运行git add。你测试第二组更改,发现它们是错误的。如果只能将工作树版本的文件设置回刚才git add的版本....啊哈,你可以:你可以 git checkout -- path,Git会将索引版本从槽0复制回到工作树中。


细微的行为警告

请注意,使用--ours--theirs除了“从索引中提取并因此不能解决”行为之外,还有另一个轻微而微妙的差异。假设,在我们的冲突合并中,Git检测到某个文件被重命名了。也就是说,在合并基础中,我们有一个文件doc.txt,但现在在HEAD中,我们有Documentation/doc.txt。我们需要git checkout --ours的路径是Documentation/doc.txt。这也是HEAD提交中的路径,因此可以使用git checkout HEAD -- Documentation/doc.txt

但如果我们合并的提交中,doc.txt 没有被重命名怎么办呢?这种情况下,我们应该能够使用 git checkout --theirs -- Documentation/doc.txt 从索引中获取他们的 doc.txt。但是如果我们尝试使用 git checkout MERGE_HEAD -- Documentation/doc.txt,Git 就无法找到文件:它不在 Documentation 中,在 MERGE_HEAD 提交中也没有。我们必须使用 git checkout MERGE_HEAD -- doc.txt 来获取他们的文件...而这样做不会解决 Documentation/doc.txt。实际上,它只会创建一个新的 ./doc.txt(如果它被重命名了,几乎肯定没有 ./doc.txt,因此“创建”比“覆盖”更好的猜测)。
由于合并使用 HEAD 的名称,通常安全的方法是使用 git checkout HEAD -- path 一步提取和解决。如果您正在解决文件并一直运行 git status,则应该知道它们是否有重命名的文件,因此是否可以放心地使用 git checkout MERGE_HEAD -- path 一步提取和解决,并且舍弃自己的更改。但是,您仍然应该意识到这一点,并知道如果有重命名需要关注该怎么做。
我在这里说“应该”而不是“可以”,因为 Git 当前会有一些问题会比较容易忘记重命名。因此,如果使用 --theirs 来获取您在 HEAD 中重命名的文件,则此处也必须使用旧名称,然后在工作树中重命名该文件。

20
这可能是我见过的最被低估的帖子之一。这应该被列入维基! - Nicolas D
3
我一周前发现了这篇文章,已经返回了三次。低估这个词对这个答案来说太过温和! - lucidbrot
我不喜欢必须要知道这个,但至少这个答案很好地解释了它。更好的做法是添加注释(更多注释!)关于新的 git switchgit restore 命令。它们减轻了 git checkout 的负担。 - Andrew Keeton
1
@AndrewKeeton:我实际上还没有尝试过新的git restore(我的主要机器的Git版本此时已经落后于一个或多个版本),但根据文档,现在可以分别读取索引和/或工作树,因此可能会得到任一行为。但通常文档对细节不够严谨,所以我想先测试一下。 :-) - torek

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