Git 两个目录中相同文件的差异总是导致 "renamed"

Git Diff of same files in two directories always result in "renamed"

git diff --no-index --no-prefix --summary -U4000 directory1 directory2

这按预期工作,因为它 returns 两个目录之间所有文件的差异。添加的文件按预期输出,删除的文件也会产生预期的差异输出。

但是,由于 diff 将文件路径作为文件名的一部分考虑在内,因此在两个不同的目录中具有相同名称的文件会导致 diff 输出带有重命名标志而不是更改。

  1. 有没有办法告诉 git 不考虑 diff 中的完整文件路径而只查看文件名,就好像文件来自同一个文件一样目录?

  2. 有没有办法让 git 真正知道不同目录中的同一文件的副本是否真的被重命名了?我不知道怎么做,除非它有办法以某种方式比较文件 md5s(可能是一个错误的猜测哈哈)。

  3. 使用分支而不是目录是否可以轻松解决此问题?如果可以,上面列出的命令的分支版本是什么?

这里有多个问题,答案相互交织。让我们从重命名和复制检测开始,然后转到分支。

重命名检测

However because the diff takes into account the file path as part of the file name, files with the same name, in the two different directories, result in a diff output with the renamed flag instead of changed.

这不太对。 (下面的文字旨在解决您的第 1 项和第 2 项。)

虽然您正在使用 --no-index(大概是为了让 Git 在存储库外部的目录上工作),但 Git 的 diff 代码在所有情况下都以相同的方式运行。为了区分(比较)两棵树中的两个文件,Git 必须首先确定 文件标识 。即有两组文件:"left side"或source树(第一个目录名)中的文件,以及"right side"或destination 树(第二个目录名)。左边的一些文件与右边的一些文件同一个文件。左边的一些文件是不同的个文件,没有相应的右边文件,即它们已被删除。最后,右边的一些文件是新的,即它们已经创建

"the same file"的文件不必具有相同的路径名。在这种情况下,这些文件已 重命名

这里详细介绍了它的工作原理。请注意,"full path name" 在使用 git diff --no-index dir1 dir2 时会有所修改:"full path name" 是去掉 dir1dir2 前缀后剩下的部分。

比较左右侧树时,具有相同完整路径名的文件通常自动被认为"the same file"。我们将所有这些文件放入 "files to be diffed" 的队列中,none 将显示为已重命名。请注意这里的 "normally" 这个词——我们稍后会回来讨论这个。

剩下两个文件列表:

  • 左侧存在但右侧不存在的路径:没有目的地的源
  • 右边不存在的路径:没有源头的目的地

天真地,我们可以简单地声明所有这些源端文件都已删除,并且所有这些目标文件都已创建。您可以指示 git diff 以这种方式运行:设置 --no-renames 标志以禁用重命名检测。

或者,Git 可以继续使用更智能的算法:设置 --find-renames and/or -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 对内容进行散列,所以这种比较非常快。我们可以通过哈希 ID 将左侧 a/b/c.txt 与右侧的 每个 文件进行比较,只需查看所有 他们的 哈希身份证。因此,我们运行首先通过所有个源文件,找到匹配的目标文件,将新对放入差异队列并将它们从两个列表中拉出。

  • 对于所有剩余的源文件和目标文件,运行一种高效但不适合git diff输出的算法,用于计算"file similarity"。与某个目标文件至少 <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 到 打破 文件对 "too different".

我们看到上面使用的 "similarity index" 来形成文件对。这个相同的相似性索引可用于 拆分 文件:例如 -B20%/60%。这两个数字不需要加起来等于 100%,实际上您可以省略其中一个,或两个都省略:如果您设置 -B 模式,每个数字都有一个默认值。

第一个数字是可以将默认配对文件放入重命名检测列表的点。使用 -B20%,如果文件有 20% 不相似(即只有 80% 相似),则文件进入 "source for renames" 列表。如果它从未被视为重命名,它可以与其自动目的地重新配对——但此时,第二个数字,即斜线后的那个,生效。

第二个数字设置配对肯定中断的点。例如,对于 -B/70%,如果文件有 70% 不相似(即只有 30% 相似),配对就会中断。 (当然,如果文件被拿走作为重命名源,配对就已经坏了。)

复制检测

除了通常的配对和重命名检测外,您还可以要求Git找到个源文件。在 运行 完成所有常用配对代码后,包括查找重命名和断开配对,如果您指定了 -C,Git 将查找 "new"(即未配对的)目标文件实际上是从现有来源复制的。这有两种模式,具体取决于您是指定 -C 两次还是添加 --find-copies-harder:一种只考虑 修改的 的源文件(即单个 -C 案例),以及一个考虑 每个 源文件的案例(即两个 -C--find-copies-harder 案例)。请注意,这个 "was a source file modified" 意味着,在这种情况下,源文件已经在配对队列中——如果不在,则根据定义它不是 "modified"—— 它的相应的目标文件具有不同的哈希 ID(同样,这是一个非常低成本的测试,有助于保持单个 -C 选项便宜)。

分支无关紧要

Would using branches instead of directories resolve this issue easily and if so what is the branch version of the command listed above?

分支在这里没有区别。

在Git中,术语分支是有歧义的。请参阅 What exactly do we mean by "branch"? 对于 git diff,但是,分支 name 简单地解析为单个提交,即 tip 提交那个分支。

我喜欢这样画Git的树枝:

...--o--o--o   <-- branch1
         \
          o--o--o   <-- branch2

小轮o每轮代表一次提交。这两个分支名称只是指针,在Git中:它们指向一个特定的提交。名称 branch1 指向顶行最右边的提交,名称 branch2 指向底行最右边的提交。

Git 中的每个提交都指向其父项或多个父项(大多数提交只有一个父项,而合并提交只是具有两个或多个父项的提交)。这就是形成我们也称为 "a branch" 的提交 的原因。分支 name 直接指向链的 tip1

当你运行:

$ git diff branch1 branch2

Git 所做的只是将每个名称解析为其相应的提交。例如,如果 branch1 名称提交 1234567...branch2 名称提交 89abcde...,这与以下内容相同:

$ git diff 1234567 89abcde

Git的差异需要两个

Git 甚至不关心这些是提交,真的。 Git 只需要一个左侧或源树,以及一个右侧或目标树。这两棵树可以来自一个提交,因为一个提交命名了一棵树:任何提交的 tree 是您进行该提交时拍摄的源快照。它们可以来自一个分支,因为一个分支名称命名一个提交,它命名一个树。其中一棵树可以来自 Git 的 "index"(又名 "staging area" 又名 "cache"),因为索引基本上是一个扁平的树。2 其中一棵树可以成为您的工作树。一棵或两棵树甚至可以在 Git 的控制之外(因此 --no-index 标志)。

当然,Git可以只diff两个文件

如果您 运行 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

请注意,"behind" 个以上分支名称的提交实际上在 所有 个分支上。因此,两个底行提交都在 tip2tip3 分支上,而两个顶行提交都在 所有三个 分支上。尽管如此,每个分支 name 都会解析为一个,并且只有一个提交。

2事实上,要进行 new 提交,Git 只是简单地转换索引,就如它所代表的那样现在,使用 git write-tree 进入一棵树,然后进行命名该树的提交(并且使用当前提交作为其父项,并且具有作者和提交者以及提交消息)。 Git 使用现有索引的事实是您必须 git add 在提交之前将更新的工作树文件放入索引的原因。

有一些方便的快捷方式可以让您告诉 git commit 将文件添加到索引,例如 git commit -agit commit <path>。这些可能有点棘手,因为它们并不总是产生您可能期望的索引。例如,参见 --include--only 选项 git commit <path>。它们还通过将主索引复制到一个新的临时索引来工作;这可能会产生令人惊讶的结果,因为如果提交成功,临时索引将被复制回常规索引。