替换与嫁接
git replace 是在对 git 说:你本来要读对象 A 的地方,改读 B。什么都没被重写。没有任何 sha 变化。每个提交都还老老实实待在原地——只是 git 路过的时候朝别处看了一眼。
这听起来像个奇技淫巧,直到你想要一份更小的克隆。那时它就是历史重写的诚实替代品:把一个提交嫁接到「没有父提交」上,它之前的一切就从日志里、从提交图里、从由此制作的每一份克隆里消失了——而与此同时它们仍然被存着,仍然可以被获取,而且只差删掉一个引用就能回来。
⌘K → 替换与嫁接。

嫁接
| 给它 | 你就得到 |
|---|---|
| 一个提交,没有父提交 | 那个提交成为历史的起点 |
| 一个提交,一个或多个父提交 | 它挂到那里,而不是它真正所在的位置 |
第二种形式才是有意思的那个。把完整历史留在一个归档仓库里,把干活的那个截断掉,再用一个指向归档仓库顶端的嫁接把两者重新接上——GitHub 用来提供「仍然可以加深的浅克隆」的,就是同一个把戏。
嫁接到「没有父提交」会先问你,因为「历史没了」和「历史被藏起来了」从日志上看一模一样,而这完全是两回事。那些对象会一直活到某次 gc 把它们修剪掉为止;参见仓库维护。
与它共处
替换本身就是引用,位于 refs/replace/ 之下。这带来三个值得知道的后果:
- 在推送之前它们是本地的:
git push origin "refs/replace/*"可以把它们分享出去,而任何不带着它们克隆的人看到的都是未经改动的历史。 - 撤销是有效的——删掉那个引用,真实的祖先关系立刻恢复,而且 Gitcito 会像对待其他任何操作一样,把这次嫁接记为一个可撤销的动作。
core.useReplaceRefs=false会让 git 一次性无视掉所有替换。这里的开关写的正是这个设置,而且关掉的时候对话框会明说,因为一个悄悄无视自己替换规则的仓库,是个让人摸不着头脑的地方。
在命令行上,git --no-replace-objects log 可以在不改动任何设置的情况下显示真实的历史。
什么时候该用这个,而不是重写
| 目标 | 工具 |
|---|---|
| 克隆太大了,历史本身没问题 | 嫁接——什么都没重写,可逆 |
| 某个机密或某个巨大的 blob 必须消失 | 把一个文件从历史中移除——一次真正的重写 |
| 只是想一次性少下载一点 | git clone --depth——浅克隆,没有引用要管理 |
嫁接不移除任何东西。如果你想让那些旧提交消失的原因是它们含有一些本就不该被提交的东西,那你翻错页了:那些对象还在,仍然可以按 sha 获取,而且仍然存在于每一份现有的克隆里。
值得知道的限制
- 你看到的东西和实际存储的东西不再一致。 这既是它的功能,也是它的隐患。任何在一份带替换的克隆上排查问题的人,都得知道这些替换的存在。
- 替换默认不会随着推送和克隆传播,所以同事的
git log和你的可以理直气壮地各说各话。 - 替换能对工具隐藏一个提交,却瞒不过 git。
git cat-file和对象浏览器照样能按 sha 打开原始对象。 - Gitcito 不提供
git replace --edit(手工改写一个对象的内容)。那是文本编辑器对着原始对象干的活,给它套个界面等于给自己脚上装了把枪。
另请参阅:对象浏览器 · 把一个文件从历史中移除 · 仓库维护