修复git blame,以便在文件已被复制后仍显示原始文件
从前有一个名为 old.txt 的文件。这个文件多年来有着许多提交。很久以前,复制了 old.txt,并被称作 new.txt。自那以后,这两个文件就再也没有被修改过。
(以下内容为简化描述;实际上不止一个文件、不止一个副本被创建过,“自那时起两份文件都未被修改”之类的说法远非事实……等。这些对核心问题大体无关紧要,我想。)
在查看 new.txt 的历史时,这确实很恼人:
- git log不能可靠地追踪到
old.txt;更糟糕的是 - git blame不能可靠地在
old.txt中找到匹配的行。
在你开始给出我可以传入git log和 git blame的标志之前:
- 我对我的IDE使用的标志控制得并不多,这是我的主要使用场景
- 我对GitHub使用的标志控制得更少,这是我的次要场景。
相反,我希望git blame能够“直接生效”。
研究/想法
还有一个题为“我想复制一个文件并保留行历史”的问题,而我的问题是“别人已经复制但没有保留行历史,现在我在事后尝试修复它”。我不确定这是否是同一情形。有人在评论中建议“直接删除那个‘坏的’副本,这样就把问题降到已解决的程度了”。可惜,这种做法在明显的方式上并不可行。
问题在于,“不合适”的副本已经成为历史图的一部分,所以增加一个“正确”的副本并不会让坏的副本消失。因此,我们需要小心地构建提交图,使得 git blame 能找到我们的好副本,即使坏的那个先到达那里。关键在于确保当git blame遇到一个合并提交时,它选择“正确”的路径。
怎么做呢?Git blame有一个简单的规则:有疑问时,跟随第一父节点。所以,我们需要精心构建我们的合并提交,使第一父能够引导到正确的副本,同时确保我们不会在主干分支上实际引入任何代码变更。这就是下面六步计划中我试图实现的目标,未必一定可行。
Git在跨副本跟踪文件方面并不擅长,但在跨重命名时却做得挺好。所以,我们只需要让副本看起来像一次重命名!大致步骤如下:
- 从分支
trunk开始,情形如我上文所述。 - 在主干上创建一个新的分支
twig。 - 在twig分支执行
git rm new.txt并提交。 - 然后执行
git mv old.txt new.txt并提交。 - 再对
commit-tree做些处理,创建一个合并提交M,使其树看起来像trunk。父提交是twig和 trunk,重要的是,twig是第一父。 - 将trunk快进到M。(我认为用PR将 M合并到trunk不会起作用,对吧?因为那样trunk就成了第一父,在并列的情况下blame会倾向于跟随第一父,所以这必须是一次快进而非真正的合并。)
上述想法可行吗?还有别的办法吗?即使另一种方法在此情形下并不更好,我也想听听,因为我需要把这推广到 new.txt 自副本以来其实已经改变了相当多的情况,或许替代方法更具通用性。
我真的需要快进吗?用GitHub的说法,这意味着需要为PR启用相关选项,否则我就得直接推送到master,对吧?这看起来挺麻烦的,我很想要下面中的一种或两种:
- 不需要这样做的替代方案
- 确认我的想法是否确实需要它(或不需要)
解决方案
简而言之:
git diffs是会被计算的。每个提交都是一个完整的、独立的快照,任何形式的文件历史(变更、重命名、删除)都是通过将每个提交与父提交进行比较并寻找差异来生成的。
你可以利用这一点在不改变现有提交的情况下修改一个文件的历史。你只需要合并一个包含该文件替代历史的分支。git diff算法会发现那段替代历史并顺势采用它。
关于客户端的说明:
我必须强调,文件历史是由git客户端计算的,无论该客户端是git CLI、VS Code还是其他工具。提交是通过引用相关联,但它们本身不包含历史。你可以用多种算法来计算文件历史。最基本的做法只是跟踪同名文件之间的变动。例如,当使用git CLI查看一个文件历史($ git log)时,你必须使用 --follow 选项 来追踪文件重命名,否则算法会在文件被重命名的提交处停止:$ git log --follow -- new.txt
大多数可视化工具会预设跟随文件重命名(这是相当常见的操作),但如果你看不到重命名后的文件历史,可能需要检查工具的配置设置。例如,GitLens有一个 gitlens.advanced.fileHistoryFollowsRenames 设置,“在重命名时跟随文件历史,影响合并提交的显示”。
用于跟踪历史的算法可以变得相当复杂,在某些边缘情形下会得到令人惊讶的结果。此处有更完整的对这些怪异之处的描述:
详细说明:
理解git diff的工作方式很重要,原因有以下几点:
- 文件重命名不会直接存储在提交内。它们是像其他信息一样通过计算得出的。Git将一个提交与其父提交进行比较,发现一个新文件的内容与一个被删除的文件的内容相匹配(父中存在而子中不存在的文件),并在算法上判断这是一次“重命名”。如果你在同一个提交内对一个文件重命名并改变其内容(超过某些容忍度),那么Git将不再把这视作重命名…… 它会把一个文件视为删除,另一个文件视为新增。
- 提交可以有多个父提交。一个文件的历史可以跨越两条或更多的提交链。
- 你可以通过合并一个分支并引入一个包含该文件独立历史的新提交链,为该文件创建一条“人工”历史。
于是,事情是这样的:
COMMIT_1 old.txt
|
COMMIT_2 old.txt
|
COMMIT_3 old.txt +new.txt
|
COMMIT_4 old.txt new.txt
|
...
|
COMMIT_100 old.txt new.txt
new.txt 作为一个全新的文件出现了。git diff不会显示重命名,因此git无法把 new.txt 与 old.txt 联系起来。
解决办法是创建一个替代时间线,让 old.txt 被重命名为 new.txt:
COMMIT_1 old.txt
|
COMMIT_2 old.txt
|
COMMIT_3_alt x ---> new.txt
重要的是要回到 new.txt 创建之前,这样git diff就不会搞乱。
你可以随后把你的“重命名”提交合并到你的主分支。这会导致冲突。接受来自 “HEAD” 的所有改动(你想保留 new.txt 的最新版本),然后恢复 old.txt(你不想删除这个文件)。
这看起来会让你得到一个空提交,因为你既没有删除 old.txt 也没有改变 new.txt。但关键是……该提交有两个父提交,COMMIT_100 和 COMMIT_3_alt,从而为 new.txt 与 old.txt 之间提供了缺失的历史连接。
提交你的改动(你很可能需要使用 --allow-empty 标志,或你客户端使用的等效设置),然后检查 new.txt 的历史。它现在应该包含 old.txt 的完整历史,直到两份文件分叉的点。
结果大致会是这样的:
COMMIT_1 old.txt
| |
COMMIT_2 old.txt
| |
/ \ / \
| COMMIT_3_alt | x--------> new.txt
| | | |
COMMIT_3 | old.txt +new.txt |
| | | | |
COMMIT_4 | old.txt new.txt |
| | | | |
... | ... ... |
| | | | |
COMMIT_100 | old.txt new.txt |
\ | | \ |
COMMIT_101 old.txt new.txt
演示
这是一个示例仓库,演示这一技术:
https://github.com/jdatskuid/retroactive_copy_with_history/commits/main/
完成后的图如下所示:
* cbf0be1 (HEAD -> main, origin/main) commit_6
|\
| * 4f43a3b (commit_3_alt) commit_3_alt
* | 4638ab1 commit_5
* | 1f72f7e commit_4
* | e1a82e4 commit_3
|/
* b44878b commit_2
* c549bd0 commit_1
以下是 new.txt 的文件历史:
$ git log --oneline --follow new.txt
4f43a3b (commit_3_alt) commit_3_alt
4638ab1 commit_5
1f72f7e commit_4
b44878b commit_2
c549bd0 commit_1