主要是由于 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.txt
,master
分支的内容与 branch
分支的内容是否相同?
如果内容相同,Git 可以轻松地继续下一个文件。如果内容不同,则 Git 必须对索引条目进行处理。(在这个点上,Git 还会检查工作树文件是否与索引条目不同。)
具体来说,在 branch
文件与 master
文件不同时,git checkout
必须使用来自 branch
的版本替换索引条目,或者如果 README.txt
在 branch
最新提交中不存在,则 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
与检出分支类似,此处操作的作用是检出一个任意提交点,而不是使用分支的tip提交点。你现在不再“在”新分支上了,而是在没有分支上:5Git 给你一个“游离的 HEAD”。为重新连接 HEAD,您必须 git checkout master
或 git 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 保留文件的位置有三个:
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
中重命名的文件,则此处也必须使用旧名称,然后在工作树中重命名该文件。
git switch
和git restore
命令。它们减轻了git checkout
的负担。 - Andrew Keetongit restore
(我的主要机器的Git版本此时已经落后于一个或多个版本),但根据文档,现在可以分别读取索引和/或工作树,因此可能会得到任一行为。但通常文档对细节不够严谨,所以我想先测试一下。 :-) - torek