在 Git 中,“保留历史记录”不是必要的。Git 没有文件历史,只有提交历史。也就是说,每个提交“指向”(包含哈希 ID)它的父提交,或者对于一个合并提交,指向它的两个父提交,这就是历史记录:提交 E
之前是提交 D
,提交 D
之前是提交 C
,以此类推。只要你有提交,就有了历史记录。
尽管如此,Git 可以尝试合成特定文件的历史记录,使用 git log --follow
命令。你需要指定一个起始提交和文件路径,Git 逐个提交进行比较,以确定该文件是否已被重命名。这使用了 Git 的重命名检测功能,来标识提交 L 中的文件 a/b.txt
与提交 R 中的文件 c/d.txt
是“同一文件”。
重命名检测有很多微妙的选项,但根本上它是这样工作的:
a/b.txt
消失了而 c/d.txt
是全新的,那么这是一个检测到的重命名候选者。未成对的文件进入一个配对队列(一个用于 L,一个用于 R),Git 对所有文件的内容进行哈希处理。它已经具有内部 Git 哈希值,因此首先直接比较它们。如果一个文件完全没有更改,则它在 L 和 R 中具有相同的 Git 哈希 ID(但名称不同),可以立即配对并从配对队列中删除。
现在,在不精确匹配之后,Git会进行长时间的耗时分析。它会对每个R文件计算一个“相似度指数”,如果某个R文件足够相似,或者有几个文件足够相似,Git就会选择最相似的R文件并将其与L文件配对。如果没有文件足够相似,L文件将保持未配对状态(从队列中移除)并被视为“从L中删除”。最终,未配对的L队列中没有文件,而未配对的R队列中剩余的文件则被视为“添加”(在R中新增)。与此同时,所有已配对的文件都已被重命名。
这意味着:当比较(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)提交和指定的其他提交之间的合并基础(merge base)。 (通常只是git merge <branchname>
。通过将分支名称解析为指向的提交来选择该其他分支的tip提交。根据Git中“分支名称”的定义,这是该分支的尖端提交,因此这“只是起作用”。“ 但是,您可以通过哈希ID指定任何提交(例如)。让我们称此合并基础提交为B(基础)。虽然有些东西称之为“本地”,但我们已经知道自己提交是HEAD
。让我们将另一个提交称为O(其他),尽管有些东西称之为“远程”(这很傻:Git中没有远程!)。
然后,Git实际上执行了两个git diff
。其中一个比较B vs HEAD,因此对于这个特定的diff,L是B,R是HEAD。 Git将根据我们上面看到的规则检测或无法检测重命名。然后Git进行另一个git diff
,它将B与O进行比较。 Git将再次根据相同的规则检测或无法检测重命名。
如果在B-vs-HEAD中重命名了某个文件,则Git通常会像往常一样比较其内容。如果在B-vs-O中重命名某个文件,则Git会像往常一样比较其内容。如果单个的B文件F在HEAD和O中被重命名为两个不同的名称,则Git会在该文件上声明一个重命名/重命名冲突,并留下两个 名称在工作树中供您清理。如果在仅一个diff中重命名它,即在HEAD或O中仍称为F ,那么Git将使用重命名它的任何一侧的新名称将文件存储在工作树中。无论如何,Git都会像往常一样尝试合并两组更改(从B-vs-HEAD和B-vs-O )。1
当然,对于Git来检测重命名,文件的内容必须足够相似,就像以往一样。这对于Java文件(有时也适用于Python)特别有问题,其中文件名嵌入了导入语句中。如果一个模块主要由导入语句组成,只有很少的自己的代码行,则重命名引起的更改将压倒其余文件内容,并且文件甚至不会匹配50%。
有一个解决方案,虽然有点丑陋。与git log --follow
的经验法则一样,我们可以先只提交重命名,然后将“修复所有导入”的内容更改作为单独的提交进行提交。然后,当我们进行合并时,我们可以执行两个或甚至三个合并:
git checkout ... # whatever branch we plan to merge into
git merge <hash> # merge with everything just before the Great Renaming
由于没有重命名文件,这次合并将像往常一样进行得好或不好。以下是以图形方式呈现的结果。请注意,我们提供给git merge
命令的哈希值是 A
提交的哈希值,在执行所有重命名操作之前就已经提交了:
...--*--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将两个名称留在工作树中,两个名称是否都包含相同的合并内容(如果需要,还包括任何冲突标记)?也就是说,如果文件命名为base.txt
,现在命名为head.txt
和other.txt
,那么head.txt
和other.txt
的工作树版本是否总是匹配的?