原文链接: Rewriting history
Git 的主要工作是确保你不会丢失提交的更改,但是它也给了我们对于开发工作流程的完整控制权。Git 既能让我们确切定义项目历史,又包含了丢失提交的潜在可能。Git 提供了重写历史的免责声明,表示使用这些命令可能会导致项目代码的丢失。
本教程讨论了一些常见的重写已提交快照的原因,并展示了如何避免这样操作可能带来的陷阱。
git commit --amend
命令是一个修复最近一次提交的便捷方法。这个命令可以帮助我们将暂存区的更改与先前一次提交合并,而不必重新提交一次新快照。当然这一命令也简单的用作编辑先前提交的提交信息而不更改其提交快照。
但是该命令不仅是 修改 最近的提交,而是 完全替换 掉最近的提交。对于 Git 来说,这次提交看起来就像一个全新的提交(在上图中我们用星号 *
标注了一下)。在公共仓库中进行开发时,请牢记这一点。
git commit --amend
该命令表示将暂存区域与最近一次提交合并,并用合并后的快照替换最近一次的提交。如果暂存区为空时运行该命令,我们可以修改之前提交的提交信息而不对其快照进行改动。
在我们的日常开发过程中,经常会进行一些马虎的提交 —— 例如忘记暂存文件啊、提交信息有误啊什么的。 --amend
参数是弥补这些过失的便捷方式。
在git reset 这一节,我们讨论了不能重置公共提交的原因。对于修改来说也是一样的: 永远不要修改已经推送到公共仓库的提交 。
修改后的提交实际上是一个全新的提交,而之前的提交就从项目历史中被删除了。这与重置公共快照的结果是一样的。如果你修改了一个其他开发者依赖的提交,他们就会感觉该提交好像从项目历史中消失了一样。这会给开发者造成混乱,而且恢复起来十分复杂。
下面的例子展示了基于 Git 开发的场景。我们编辑了一些文件,然后准备将其作为一个快照提交,但是我们在第一次提交的时候漏掉了一个文件。修复这个错误只需要将这个文件暂存,然后使用 --amend
参数进行提交。
# 编辑了 hello.py 文件和 main.py 文件 git add hello.py git commit # 突然发现忘记提交 main.py 文件 git add main.py git commit --amend --no-edit
运行命令时编辑器会显示上次的提交信息,如果加了 --no-edit
参数我们就可以追加提交而不修改提交信息。当然我们也可以修改提交的信息,或者你不加这个参数打开了编辑器,然后直接保存关闭也是不会修改提交信息的。最后生成的提交会替换掉之前那个不完整的提交,然后我们提交的对于 hello.py
和 main.py
的改动看起来就会在一个单独的提交快照中。
衍合是将一个分支移动到一个新的基准提交的过程。大致的过程如图所示:
从内容上看,衍合仅仅是上一个分支从一个提交上嫁接到另一个提交上。但是从内部实现上看,Git 是通过创建新的提交然后将其应用的特定的基础提交上来实现这一过程的,这一操作确实重写了我们的项目历史。即使分支看来其和之前相同,但是它确实有全新的提交组成的,请务必理解这一点。
git rebase <base>
该命令表示将当前分支衍合到 <base>
上。这里的 <base>
可以是任何形式的提交引用(ID、分支名称、tag标签、或者是指向最近提交的 HEAD 指针)。
使用衍合的主要原因是为了保持线性的项目历史。比方我们可以思考这样一个场景:当你开始开发一个功能的时候, master
分支也增加了几个提交:
我们有两种将开发的功能整合到 master
分支上的选择:直接合并(merge)或者先衍合再合并。前一种选择会生成一个 3-way 合并 【注1】 ,以及一个合并提交;而后者则会生成一个快进合并(fast-forward merge)并产生一个完美的线性历史。下图演示了为何衍合到 master
上会促成一个快进合并。
衍合是一个常用的将上游 【注2】 更改整合到本地仓库中的方式。当你想查看一下远端项目进展的时候,使用 git merge
拉取远端会生成一个溶于的何必跟提交。而另一方面,衍合好像在说:“我想以大家已经完成的部分为基准 【注3】 追加我的改动。”
正如我们讨论的 git commit --amend
和 git reset
一样,我们不应衍合已经提交到公共仓库中的提交。衍合会使用新提交替换旧有提交,这会导致项目历史的一部分好像突然消失了一样。
下面的例子结合了 git rebase
和 git merge
命令以保持线性提交历史。这是一个快捷简便的手段来保证我们的合并是快进合并。
# 开始一个新功能 git checkout -b new-feature master # 编辑文件 git commit -a -m "开始开发新功能"
整到一半儿我们发现我们的项目里有个安全漏洞:
# 基于master分支建立一个修复(hotfix)分支 git checkout -b hotfix master # 编辑文件 git commit -a -m "Fix security hole" # 并入master分支 git checkout master git merge hotfix git branch -d hotfix
在将修复分支合并进主干分支之后,我们的项目历史产生了 分岔(forked) 。我们并不采取简单的合并的方式,而是使用衍合来整合新功能分支以保持线性的项目历史:
git checkout new-feature git rebase master
这就将新功能移动到主干分支的顶端,我们就可以在主干分支上进行一次教科书式的快进合并:
git checkout master git merge new-feature
在运行 git rebase
命令时添加参数 -i
会打开一个衍合的交互式会话。与盲目的将所有改动提交到新基准上不同,交互式的衍合允许我们在会话中替换个别提交。这允许我们通过删除、合并和替换已存在提交序列的方式来清理项目历史。这就像一个升级版的 git commit --amend
。
git rebase -i <bare>
该命令表示将当前分支采用交互式衍合会话的方式衍合到 <base>
基准上。这一操作会打开一个编辑器,我们可以在编辑器上输入命令(下文会有详述)来操作每一个需要衍合的提交。这些命令决定了特定的提交如何被嫁接到新基准上。通过改变交互式编辑器中提交列表的顺序,我们就可以改变提交本身的顺序。
交互式的衍合让我们可以完全控制项目历史的样子。这给予了开发者极大的自由,使得我们可以在专注于编码的时候先提交一坨混乱的历史,然后事后再进行历史的整理。
大多数开发者喜欢在将特性分支提交到主干分支之前,采用交互式衍合来润色分支提交。开发者在提交到“官方”项目历史之前,可以合并细碎的提交、删除废弃的提交以及让其他的提交显得井然有序。在旁人看来,这个新特性的提交就像由一系列经过缜密规划的提交组成的。
# 开始开发新特性 git checkout -b new-feature master # 编辑文件 git commit -a -m "开始开发新特性" # 编辑更多的文件 git commit -a -m "修复之前提交的问题" # 在主干分支上直接整一个提交 git checkout master # 编辑文件 git commit -a -m "修复安全漏洞" # 开始交互式衍合会话 git checkout new-feature git rebase -i master
最后一个命令会打开一个编辑器,编辑器上写有新特性分支上的两个提交和一些命令介绍:
pick 32618c4 开始开发新特性 pick 62eed47 修复之前提交的问题
我们可以修改每个提交前面的 pick
命令,来决定在衍合过程中如何移动这些提交。在我们的例子中,我们仅仅使用 压缩(squash) 命令来合并两个提交。
pick 32618c4 开始开发新特性 squash 62eed47 修复之前提交的问题
保存并关闭编辑器就会开始衍合。这会打开一个新的编辑器要求给合并的快照填写提交信息。填写完信息之后衍合就完成了。我们可以通过 git log
的输出查看合并的提交。整个过程如图所示:
注意到合并的提交与两个原始提交拥有的 ID 均不同,这告诉我们它确实是一个新提交。
最后我们可以将我们润色后的分支采用快进合并的方式整合到主干分支上:
git checkout master git merge new-feature
交互式提交的真正强大之处,在主干分支的历史中可窥一斑 —— 62eed47
这个提交(就是上文“修复之前提交的问题”这个提交)俨然无迹可寻。在旁人看来,我们就像精明的开发者,第一次提交就恰到好处的完成了 new-feature
分支的实现。交互式衍合就是这样让一个项目的历史记录整洁而有意义。
Git 使用一种叫做 引用日志(reflog) 的机制记录分支顶端更新的踪迹。这可以使我们回溯到那些没有被任何分支或 tag 号引用的修改集中。重写历史之后,引用日志保留了旧有分支状态的信息,并允许我们在需要的时候回溯到旧有状态。
git reflog
该命令表示展示本地仓库的引用日志。
git reflog --relative-date
该命令表示展示引用日志的相关日期信息(例如会展示 2 week ago,两星期之前)。
每当当前 HEAD 指针更新时(例如切换分支、拉取更改、重写历史或是简单的添加新提交),一个新的内容就会被加入引用日志。
我们通过运行一个示例来理解 git reflog
。
0a2e358 HEAD@{0}: reset: moving to HEAD~2 0254ea7 HEAD@{1}: checkout: moving from 2.2 to master c10f740 HEAD@{2}: checkout: moving from master to 2.2
上文的引用日志展示了从 master 分支切换到 2.2 分支上,然后又切换回来,然后在此基础上重置了当前分支到一个旧有提交上。最新的活动位于日志最顶端并标记为 HEAD@{0}
。
如果你是不小心切换回主干分支上的,引用日志会包含在你不小心丢弃的两个提前之前的主干分支的引用(0254ea7)。
git reset --hard 0254ea7
使用 git reset
命令就会把主干分支改回之前的样子。这样就提供了一个防止误修改历史的安全举措。
重要的一点是,引用日志仅仅在当改动已经被提交到本地仓库中时才会提供这一安全保障,而且它也仅记录 HEAD 指针的动向。