这里有多个问题,其答案相互交织。让我们从重命名和复制检测开始,然后转到分支。
重命名检测
但是,由于 diff 将文件路径作为文件名的一部分考虑在内,因此两个不同目录中的同名文件会导致 diff 输出带有重命名标志而不是更改。
这不太对。 (下面的文字旨在解决您的第 1 项和第 2 项。)
虽然您使用的是--no-index(大概是为了让Git 在存储库之外的目录上工作),但Git 的差异代码在所有情况下的行为方式都是相同的。为了区分(比较)两棵树中的两个文件,Git 必须首先确定文件身份。也就是说,有两组文件:“左侧”或源树(第一个目录名称)中的文件,以及“右侧”或目标中的文件> 树(第二个目录名)。左边的一些文件与右边的一些文件同一个文件。左侧的一些文件是不同的文件,没有对应的右侧文件,即它们已被删除。最后,右侧的一些文件是新的,即它们已创建。
“相同文件”的文件不需要具有相同的路径名。在这种情况下,这些文件已被重命名。
这是它的详细工作原理。请注意,使用git diff --no-index dir1 dir2 时“完整路径名”会有所修改:“完整路径名”是去掉dir1 和dir2 前缀后剩下的内容。
在比较左侧树和右侧树时,具有相同完整路径名的文件通常自动被视为“同一文件”。我们将所有这些文件放入“要区分的文件”队列中,没有一个文件会显示为被重命名。请注意这里的“正常”一词——我们稍后再讨论。
这给我们留下了两个剩余的文件列表:
- 存在于左侧但不存在于右侧的路径:没有目的地的源
- 存在于右侧但不存在于左侧的路径:没有源的目的地
天真地说,我们可以简单地声明所有这些源端文件都已删除,并且所有这些目标文件都已创建。您可以指示git diff 这样做:设置--no-renames 标志以禁用重命名检测。
或者,Git 可以继续使用更智能的算法:设置 --find-renames 和/或 -M <threshold> 标志来执行此操作。在 Git 2.9 及更高版本中,重命名检测默认开启。
现在,Git 应该如何确定源文件与目标文件具有相同的身份?他们有不同的路径;左侧的a/b/c.txt 对应哪个右侧文件?可能是d/e/f.bin,或d/e/f.txt,或a/b/renamed.txt,等等。实际算法比较简单,过去并没有使final name组件生效(我不确定现在是否生效,Git在不断发展):
如果存在内容完全匹配的源文件和目标文件,请将它们配对。因为 Git 对内容进行哈希处理,所以这种比较非常快。我们可以通过查看左侧的 a/b/c.txt 的哈希 ID 与右侧的 每个 文件进行比较,只需查看所有 它们的 哈希 ID。因此,我们首先遍历所有个源文件,找到匹配的目标文件,将新的对放入差异队列并将它们从两个列表中拉出。
对于所有剩余的源文件和目标文件,运行一个高效但不适合git diff 输出的算法来计算“文件相似度”。至少<threshold> 与某个目标文件相似的源文件会导致配对,并且该文件对被删除。默认阈值为 50%:如果您在未选择特定阈值的情况下启用了重命名检测,则此时仍在列表中且相似度为 50% 的两个文件配对。
所有剩余的文件要么被删除要么被创建。
现在我们已经找到了所有配对,git diff 继续比较配对的相同身份的文件,并告诉我们删除的文件被删除,新创建的文件被创建。如果相同身份文件的两个路径名不同,git diff 表示文件已重命名。
任意文件配对代码很昂贵(即使同名配对代码非常便宜),因此 Git 对这些名称中的多少名有限制配对源列表和目标列表。该限制是通过git config diff.renameLimit 配置的。多年来,默认值一直在攀升,现在有数千个文件。您可以将其设置为 0(零)以使 Git 始终使用自己的内部最大值。
断对
上面我说过通常,同名文件会自动配对。这通常是正确的做法,因此它是 Git 的默认设置。然而,在某些情况下,名为a/b/c.txt 的左侧文件实际上与名为a/b/c.txt 的右侧文件不相关,它实际上与右侧的a/doc/c.txt 相关。例如。我们可以告诉 Git打破“太不同”的文件对。
我们看到了上面用于形成文件配对的“相似性指数”。相同的相似性索引可用于拆分文件:例如-B20%/60%。这两个数字加起来不必等于 100%,您实际上可以省略其中一个,或两者都省略:如果您设置 -B 模式,每个数字都有一个默认值。
第一个数字是可以将默认已配对文件放入重命名检测列表的点。对于-B20%,如果文件有 20% 不相似(即只有 80% 相似),则文件将进入“重命名源”列表。如果它从未被视为重命名,它可以与它的自动目的地重新配对——但此时,第二个数字,即斜线后面的那个,将生效。
第二个数字设置了配对肯定中断的点。例如,对于-B/70%,如果文件有 70% 不相似(即只有 30% 相似),则配对会中断。 (当然,如果文件作为重命名源被拿走,那么配对就已经失效了。)
拷贝检测
除了通常的配对和重命名检测之外,您还可以让 Git 查找源文件的副本。在运行所有常见的配对代码后,包括查找重命名和断开配对,如果您指定了-C,Git 将查找实际从现有源复制的“新”(即未配对)目标文件。这有两种模式,取决于您是指定两次-C 还是添加--find-copies-harder:一种只考虑经过修改 的源文件(这是单个-C 的情况),另一种是考虑 每个 源文件(即 -C 或 --find-copies-harder 两种情况)。请注意,在这种情况下,“源文件被修改”意味着源文件已经在配对队列中——如果不是,则根据定义它没有被“修改”——它对应的目标文件具有不同的哈希 ID(同样,这是一个成本非常低的测试,有助于保持单个 -C 选项的成本较低)。
分支无关紧要
使用分支代替目录是否可以轻松解决此问题?如果可以,上面列出的命令的分支版本是什么?
分支在这里没有区别。
在 Git 中,术语 branch 是模棱两可的。请参阅What exactly do we mean by "branch"? 对于git diff,不过,分支 name 只是解析为单个提交,即该分支的 tip 提交。
我喜欢这样画 Git 的分支:
...--o--o--o <-- branch1
\
o--o--o <-- branch2
小圆 os 每个代表一个提交。这两个分支名称在 Git 中只是 指针:它们指向 一个特定的提交。名称branch1 指向顶行最右侧的提交,名称branch2 指向底行最右侧的提交。
Git 中的每个提交都指向其父级或父级(大多数提交只有一个父级,而合并提交只是具有两个或多个父级的提交)。这就是形成提交的链,我们也称之为“分支”。分支name 直接指向链的tip。1
当你运行时:
$ git diff branch1 branch2
Git 所做的只是将每个名称解析为其对应的提交。例如,如果 branch1 名称提交 1234567... 和 branch2 名称提交 89abcde...,这与以下内容相同:
$ git diff 1234567 89abcde
Git 的 diff 需要两棵树
Git 甚至不在乎这些是提交,真的。 Git 只需要一个左侧或源树,以及一个右侧或目标树。这两个树可以来自一个提交,因为一个提交命名一棵树:任何提交的 tree 都是您进行该提交时拍摄的源快照。它们可以来自一个分支,因为一个分支名称命名一个提交,它命名一棵树。其中一棵树可以来自 Git 的“索引”(又名“暂存区”又名“缓存”),因为索引基本上是一棵扁平树。2其中一棵树可以是您的工作树.一棵或两棵树甚至可能不在 Git 的控制范围内(因此有 --no-index 标志)。
当然,Git 只能区分两个文件
如果您运行git diff --no-index /path/to/file1 /path/to/file2,Git 将简单地区分这两个文件,即将它们视为一对。这完全绕过了所有配对和重命名检测代码。如果没有对--no-renames、--find-renames、--rename-threshold 等的摆弄,选项就可以解决问题,您可以显式区分文件路径,而不是目录(树)路径。对于大量文件,这当然会很痛苦。
1在那个点之后可以有更多的提交,但它仍然是其链的末端。此外,多个名称可以指向一个提交。我把这种情况画成:
...--o--o <-- tip1
\
o--o <-- tip2, tip3
请注意,“在”多个分支名称“后面”的提交实际上是在这些分支的所有上。所以底行提交都在tip2 和tip3 分支上,而顶行提交都在所有三个 分支上。尽管如此,每个分支 name 都解析为一个,并且只有一个,提交。
2事实上,为了进行 new 提交,Git 只需使用 git write-tree 将索引转换为树,就像现在一样,然后进行命名该树的提交(并将当前提交用作其父级,并具有作者和提交者以及提交消息)。 Git 使用现有索引的事实是为什么您必须在提交之前将更新的工作树文件git add 放入索引中。
有一些便捷的快捷方式可以让您告诉git commit 将文件添加到索引中,例如git commit -a 或git commit <path>。这些可能有点棘手,因为它们并不总是产生您可能期望的索引。例如,参见git commit <path> 的--include 与--only 选项。它们还通过将主索引复制到新的临时索引来工作;这可能会产生令人惊讶的结果,因为如果提交成功,临时索引会被复制回常规索引。