文件历史记录和重命名检测
您永远不需要担心 Git 中的“保存历史”。 Git 根本没有 file 历史记录,它只有 commit 历史记录。也就是说,每个提交都“指向”(包含哈希 ID)其父级(或者,对于合并,both 它的父级),而这 是 历史记录: E 前面是 commit D,而 commit D 前面是 commit C,以此类推。只要你有提交,你就有历史。
也就是说,Git 可以尝试使用git log --follow 合成一个特定文件的历史记录。您指定起始提交和路径名,Git 会逐个提交检查文件是否在将当前提交的父级与当前提交进行比较时重命名。这使用 Git 的 重命名检测 来识别提交 L 中的文件 a/b.txt(左)与提交 Rc/d.txt 是“相同的文件” /em>(右)。
重命名检测有很多繁琐的旋钮,但在基础级别,基本上是这样的:
- Git 查看提交 L 中的所有文件名。
- Git 查看提交 R 中的所有文件名。
- 如果有一个文件名从 L 中消失并出现在 R 中,例如
a/b.txt 消失了而 c/d.txt 是全新的,为什么,那就是检测到的重命名的候选。
- 现在有候选文件(未配对的 L 文件和未配对的 R 文件),Git 比较这些未配对文件的 内容。李>
未配对的文件进入一个配对队列(一个用于L,一个用于R),Git 对所有文件的内容进行哈希处理.它已经有内部 Git 哈希,所以它首先直接比较所有这些。如果一个文件完全不变,它在L和R中具有相同的Git hash ID(但名称不同),并且可以立即配对-up 并从配对队列中移除。
现在完全匹配已被删除,Git 尝试了漫长而缓慢的 slog。它需要一个未配对的 L 文件,并为每个 R 文件计算一个“相似度指数”。如果某个 R 文件足够相似(或多个相似),它将采用“最相似”的 R 文件并将其与 L 文件配对。如果没有文件足够相似,则 L 文件保持未配对状态(从队列中取出)并被视为“从 L 中删除”。最终,未配对的 L 队列中没有文件,并且无论未配对的 R 队列中剩余什么文件,这些文件都会被“添加”(R 中的新文件) em>)。同时,所有配对的文件都已重命名。
这意味着:在比较 (git diff) 提交 L 和 R 时,如果两个文件足够相似,它们将配对为重命名。默认相似度索引是 50%,因此文件需要 50% 匹配(无论如何,相似度索引计算有些不透明),但是精确 匹配对于 Git 来说更容易、更快捷。
请注意,git log --follow 启用重命名检测(仅在一个目标 R 文件上,因为我们正在通过日志向后工作,将父提交与仅一个我们在孩子中知道其名称的文件)。从 Git 2.9 版开始,git diff 和 git log -p 现在都自动启用了重命名检测。在旧版本中,您必须使用-M 选项设置相似度阈值,或者将diff.renames 配置为true,以获取git diff 和git log -p 进行重命名检测。
配对队列也有一个最大长度。这已经翻了两次,一次在 Git 1.5.6 中,一次在 Git 1.7.5 中。您可以自己控制它:可配置为diff.renameLimit 和merge.renameLimit。当前的限制是 400 和 1000。(如果将这些设置为零,Git 会使用它自己的内部最大值,这会消耗大量的 CPU 时间——这就是为什么这两个限制首先存在的原因。如果你设置 diff.renameLimit但不是merge.renameLimit,git merge 使用您的差异设置。)
这导致适用于git log --follow 的经验法则:如果可能,当您打算重命名某个文件或一组文件时,请自行提交重命名步骤,而不更改任何文件内容。 如果可能,请尽量减少重命名文件的数量:例如,不超过 400。您可以在多个步骤中提交更多重命名,一次 400 个。但请记住,您正在权衡git log --follow 的能力和速度,而不是用无意义的提交弄乱您的历史记录:如果您需要重命名 50000 个文件,也许您应该这样做。
但这对合并有何影响?好吧,git merge 和git log --follow 一样,总是打开重命名检测。但是哪个提交是 L 哪些提交是 R?
合并重命名检测
无论何时运行:
git merge <commit-specifier>
Git 必须在您当前 (HEAD) 提交和指定的其他提交之间找到 合并基础。 (通常这只是git merge <branchname>。通过将分支名称解析为它指向的提交来选择另一个分支的 tip 提交。根据 Git 中“分支名称”的定义,那就是该分支的提示提交,以便“正常工作”。但是您可以通过哈希 ID 指定 any 提交,例如。)让我们称之为合并基础提交 B (用于基地)。我们已经知道我们自己的提交是HEAD,尽管有些东西称之为“本地”。让我们将另一个提交称为 O(对于其他),尽管有些东西称之为“远程”(这很愚蠢:Git 中没有什么是远程的!)。
Git 然后实际上是两个 git diffs。比较 B 与 HEAD,因此对于这个特定的差异,L 是 B 而 R 是 HEAD。 Git 将根据我们上面看到的规则检测或检测不到重命名。然后 Git 执行另一个 git diff,它将 B 与 O 进行比较。 Git 将再次根据相同的规则检测或检测重命名失败。
如果某些文件在 B-vs-HEAD 中被重命名,Git 会像往常一样区分它的 contents。如果某些文件在 B-vs-O 中重命名,Git 会像往常一样区分其内容。如果在 HEAD 和 O 中将单个 B 文件 F 重命名为 两个不同的名称,Git 会声明重命名/该文件的重命名冲突,并在工作树中留下 both 名称供您清理。如果它在 only one diff 中重命名——它仍然在 HEAD 或 O 中称为 F——然后 Git 使用任何一方的新名称重命名它。在任何情况下,Git 都会像往常一样尝试组合两组更改(来自 B-vs-HEAD 和 B-vs-O) .1
当然,为了让 Git 检测重命名,文件的内容必须与往常一样足够相似。这对于 Java 文件(有时也包括 Python)尤其成问题,其中 文件名 嵌入在 import 语句中。如果一个模块主要由 import 语句组成,只有几行代码,那么重命名引起的更改将压倒剩余的文件内容,文件甚至不会匹配 50%。
有一个解决方案,虽然它有点难看。与git log --follow 的经验法则一样,我们可以先提交just 重命名,然后将更改内容的“修复所有导入”作为单独的提交提交。然后,当我们去合并的时候,我们可以做两个甚至三个合并:
git checkout ... # whatever branch we plan to merge into
git merge <hash> # merge with everything just before the Great Renaming
由于没有文件被重命名,因此此合并将像往常一样进行,或者说很差。这是结果,以图表的形式。请注意,我们提供给git merge 命令的哈希是提交A 的哈希,就在执行所有重命名的R 之前:
...--*--o--...--o--M <-- mainline
\ /
o--o--...-A--R--...--o <-- develop, with renames at R
然后:
git merge <hash of R>
由于每个文件的 内容 在另一个 R 提交中是完全相同的,在名称上是完全相同的——合并基础是提交 A——这里的效果仅仅是拾取所有重命名.我们保留来自 HEAD 提交 M 的文件内容,但保留来自 R 的名称。此合并应自动成功:
...--*--o--...--o--M--N <-- mainline
\ / /
o--o--...-A--R--...--o <-- develop, with renames at R
现在我们可以git merge develop 继续合并开发分支。
在许多情况下,我们不需要合并 M,但无论如何这样做可能不是一个坏主意如果我们需要合并 N 只是为了重命名。原因是提交 R 不起作用: 它的导入名称错误。在二等分期间必须跳过提交R。这意味着合并N 同样不起作用,必须在二分时跳过。有M 在场可能会很好,因为M 实际上可以工作。
请注意,如果您这样做,您就是在扭曲/扭曲您的源代码,只是为了取悦您的版本控制系统。这不是一个好情况。它可能不如您的其他选择那么糟糕,但不要告诉自己它是好。
1我仍然需要看看当发生重命名/重命名冲突时文件的两个副本会发生什么。由于 Git 将两个 names 都留在了工作树中,这两个名称是否包含相同的合并内容,以及任何需要的冲突标记?也就是说,如果文件被命名为base.txt,现在被命名为head.txt 和other.txt,那么head.txt 和other.txt 的工作树版本是否总是匹配?