章节 ▾ 第二版

7.13 Git 工具 - 替换

替换

正如我们之前所强调的,Git 对象数据库中的对象是不可更改的,但 Git 提供了一种有趣的方式,可以让你在数据库中“假装”用其他对象替换掉现有的对象。

replace 命令允许你指定一个 Git 对象,并声明:“每当你引用这个对象时,请假装它是另一个对象”。这最常见的用途是在不重写整个历史(例如使用 git filter-branch)的情况下,将历史中的某个提交替换为另一个提交。

例如,假设你有一个庞大的代码历史,并且想将仓库拆分为两个:一个供新开发者使用的精简历史仓库,另一个供数据挖掘需求者使用的更长、更完整的历史仓库。你可以通过“替换”新历史中的最早提交,将其连接到旧历史的最新提交上,从而实现历史的拼接。这样做的好处是,你不需要像合并历史时通常需要做的那样去重写新历史中的每一个提交(因为父节点的变化会改变 SHA-1 值)。

让我们来试一下。我们先拿一个现有的仓库,把它拆分成两个仓库(一个近期版本,一个历史版本),然后看看如何通过 replace 在不修改近期仓库 SHA-1 值的情况下将它们重新结合起来。

我们将使用一个包含五个简单提交的简单仓库

$ git log --oneline
ef989d8 Fifth commit
c6e1e95 Fourth commit
9c68fdc Third commit
945704c Second commit
c1822cf First commit

我们想把这些历史拆分成两条线。一条是从提交 1 到提交 4 —— 这将作为历史版本;另一条只包含提交 4 和 5 —— 这将作为近期历史。

Example Git history
图 163. Git 历史示例

创建历史版本很容易,我们只需在历史中创建一个分支,然后将该分支推送到新远程仓库的 master 分支即可。

$ git branch history c6e1e95
$ git log --oneline --decorate
ef989d8 (HEAD, master) Fifth commit
c6e1e95 (history) Fourth commit
9c68fdc Third commit
945704c Second commit
c1822cf First commit
Creating a new `history` branch
图 164. 创建一个 history 分支

现在我们可以将新的 history 分支推送到新仓库的 master 分支

$ git remote add project-history https://github.com/schacon/project-history
$ git push project-history history:master
Counting objects: 12, done.
Delta compression using up to 2 threads.
Compressing objects: 100% (4/4), done.
Writing objects: 100% (12/12), 907 bytes, done.
Total 12 (delta 0), reused 0 (delta 0)
Unpacking objects: 100% (12/12), done.
To git@github.com:schacon/project-history.git
 * [new branch]      history -> master

好的,历史记录已经发布了。现在比较困难的部分是缩减我们的近期历史记录,使其变小。我们需要一个重叠部分,以便可以用一个提交来替换另一个等效的提交,所以我们将它截断为仅保留提交 4 和 5(这样提交 4 就会重叠)。

$ git log --oneline --decorate
ef989d8 (HEAD, master) Fifth commit
c6e1e95 (history) Fourth commit
9c68fdc Third commit
945704c Second commit
c1822cf First commit

在这种情况下,创建一个包含如何扩展历史说明的基础提交是很有用的,这样其他开发者如果遇到截断历史中的第一个提交并需要更多信息时,就知道该怎么做。因此,我们要做的就是创建一个包含说明的初始提交对象作为基础点,然后将剩余的提交(4 和 5)变基(rebase)到它上面。

为此,我们需要选择一个分割点,对于我们来说是第三个提交,即 SHA-1 为 9c68fdc 的提交。所以,我们的基础提交将基于该树(tree)构建。我们可以使用 commit-tree 命令创建基础提交,它只需要一个树对象,就会返回一个全新的、无父节点的提交对象 SHA-1。

$ echo 'Get history from blah blah blah' | git commit-tree 9c68fdc^{tree}
622e88e9cbfbacfb75b5279245b9fb38dfea10cf
注意

commit-tree 命令属于通常所说的“底层(plumbing)”命令集。这些命令通常不打算直接使用,而是由其他 Git 命令在后台调用以完成较小的任务。当我们需要进行这类特殊操作时,它们允许我们执行非常底层的操作,但不适合日常使用。你可以在 底层命令与高层命令 中阅读更多关于底层命令的内容。

Creating a base commit using `commit-tree`
图 165. 使用 commit-tree 创建基础提交

好了,既然我们有了基础提交,现在可以用 git rebase --onto 将剩下的历史记录变基到它上面。--onto 参数将是我们刚才从 commit-tree 得到的 SHA-1,而变基的起点将是第三个提交(我们想保留的第一个提交 9c68fdc 的父节点)。

$ git rebase --onto 622e88 9c68fdc
First, rewinding head to replay your work on top of it...
Applying: fourth commit
Applying: fifth commit
Rebasing the history on top of the base commit
图 166. 将历史变基到基础提交之上

好的,现在我们将近期历史重写到了一个临时的基础提交之上,这个提交里包含了如何恢复完整历史的说明。我们可以将这个新历史推送到一个新项目,现在当人们克隆该仓库时,他们只会看到最近的两个提交以及一个包含说明的基础提交。

现在让我们切换角色,假设有人第一次克隆该项目并且想要完整的历史记录。在克隆了这个截断的仓库后,要获取历史数据,需要添加第二个远程仓库(即历史仓库)并进行抓取(fetch)。

$ git clone https://github.com/schacon/project
$ cd project

$ git log --oneline master
e146b5f Fifth commit
81a708d Fourth commit
622e88e Get history from blah blah blah

$ git remote add project-history https://github.com/schacon/project-history
$ git fetch project-history
From https://github.com/schacon/project-history
 * [new branch]      master     -> project-history/master

现在,协作者的 master 分支中拥有近期提交,而 project-history/master 分支中拥有历史提交。

$ git log --oneline master
e146b5f Fifth commit
81a708d Fourth commit
622e88e Get history from blah blah blah

$ git log --oneline project-history/master
c6e1e95 Fourth commit
9c68fdc Third commit
945704c Second commit
c1822cf First commit

要结合它们,只需调用 git replace,指定要替换的提交和用于替换的提交。我们要将 master 分支中的“第四个”提交替换为 project-history/master 分支中的“第四个”提交。

$ git replace 81a708d c6e1e95

现在,如果你查看 master 分支的历史,它看起来是这样的

$ git log --oneline master
e146b5f Fifth commit
81a708d Fourth commit
9c68fdc Third commit
945704c Second commit
c1822cf First commit

很酷,对吧?无需更改上游所有的 SHA-1 值,我们就能够将历史中的一个提交替换为完全不同的提交,并且所有常用的工具(bisectblame 等)都能如预期般工作。

Combining the commits with `git replace`
图 167. 使用 git replace 合并提交

有趣的是,它显示的仍然是 81a708d 这个 SHA-1,即使它实际上使用的是我们替换后的 c6e1e95 提交数据。即使你运行像 cat-file 这样的命令,它也会向你展示被替换后的数据。

$ git cat-file -p 81a708d
tree 7bc544cf438903b65ca9104a1e30345eee6c083d
parent 9c68fdceee073230f19ebb8b5e7fc71b479c0252
author Scott Chacon <schacon@gmail.com> 1268712581 -0700
committer Scott Chacon <schacon@gmail.com> 1268712581 -0700

fourth commit

请记住,81a708d 实际的父节点是我们的占位符提交(622e88e),而不是此处显示的 9c68fdce

另一个有趣的事情是,这些数据被保存在我们的引用(references)中。

$ git for-each-ref
e146b5f14e79d4935160c0e83fb9ebe526b8da0d commit	refs/heads/master
c6e1e95051d41771a649f3145423f8809d1a74d4 commit	refs/remotes/history/master
e146b5f14e79d4935160c0e83fb9ebe526b8da0d commit	refs/remotes/origin/HEAD
e146b5f14e79d4935160c0e83fb9ebe526b8da0d commit	refs/remotes/origin/master
c6e1e95051d41771a649f3145423f8809d1a74d4 commit	refs/replace/81a708dd0e167a3f691541c7a6463343bc457040

这意味着我们可以很容易地与他人共享我们的替换方案,因为我们可以将其推送到服务器,其他人可以轻松下载。虽然这在我们上面讨论的历史嫁接场景中作用不大(因为大家反正都会下载完整的两个历史,何必分开呢?),但在其他情况下可能会很有用。