8.重写项目历史

重写项目历史

概述

Git 的主要职责是保证你不会丢失提交的修改。但是,它同样被设计成让你完全掌控开发工作流。这包括了让你自定义你的项目历史,而这也创造了丢失提交的可能性。Git 提供了可以重写项目历史的命令,但也警告你这些命令可能会让你丢失内容。

这份教程讨论了重写提交快照的一些常见原因,并告诉你如何避免不好的影响。

git commit –amend

git commit --amend 命令是修复最新提交的便捷方式。它允许你将缓存的修改和之前的提交合并到一起,而不是提交一个全新的快照。它还可以用来简单地编辑上一次提交的信息而不改变快照。

Git Tutorial: git commit --amend

但是,amend 不只是修改了最新的提交——它进行了一次替换。对于 Git 来说,这看上去像一个全新的提交,即上图中用星号表示的那一个。在公共仓库工作时一定要牢记这一点。

用法

1
git commit --amend

合并缓存的修改和上一次的提交,用新的快照替换上一个提交。缓存区没有文件时运行这个命令可以用来编辑上次提交的提交信息,而不会更改快照。

讨论

仓促的提交在你日常开发过程中时常会发生。很容易就忘记了缓存一个文件或者弄错了提交信息的格式。--amend 标记是修复这些小意外的便捷方式。

不要修复公共提交

git reset这节中,我们说过永远不要重设和其他开发者共享的提交。对于修复也是一样:永远不要修复一个已经推送到公共仓库中的提交。

修复过的提交事实上是全新的提交,之前的提交会被移除出项目历史。这和重设公共快照的后果是一样的。如果你修复了其他开发者在之后继续开发的一个提交,看上去他们的工作基础从项目历史中消失了一样。对于在这上面的开发者来说这是很困惑的,而且很难恢复。

栗子

下面这个🌰展示了 Git 开发工作流中的一个常见情形。我们编辑了一些希望在同一个快照中提交的文件,但我们忘记添加了其中的一个。修复错误只需要缓存那个文件并且用 --amend 标记提交:

1
2
3
4
5
6
7
# 编辑 hello.py 和 main.py
git add hello.py
git commit

# 意识到你忘记添加 main.py 的更改
git add main.py
git commit --amend --no-edit

编辑器会弹出上一次提交的信息,加入 --no-edit 标记会修复提交但不修改提交信息。需要的话你可以修改,不然的话就像往常一样保存并关闭文件。完整的提交会替换之前不完整的提交,看上去就像我们在同一个快照中提交了 hello.pymain.py

git rebase

变基(rebase, 事实上这个名字十分诡异, 所以在大多数时候直接用英文术语)是将分支移到一个新的基提交的过程。过程一般如下所示:

Git Tutorial: Rebase to maintain a linear project history.

从内容的角度来看,rebase 只不过是将分支从一个提交移到了另一个。但从内部机制来看,Git 是通过在选定的基上创建新提交来完成这件事的——它事实上重写了你的项目历史。理解这一点很重要,尽管分支看上去是一样的,但它包含了全新的提交。

用法

1
git rebase <base>

将当前分支 rebase 到 <base>,这里可以是任何类型的提交引用(ID、分支名、标签,或是 HEAD 的相对引用)。

讨论

rebase 的主要目的是为了保持一个线性的项目历史。比如说,当你在 feature 分支工作时 master 分支取得了一些进展:

Git Rebase Branch onto Master

要将你的 feature 分支整合进 master 分支,你有两个选择:直接 merge,或者先 rebase 后 merge。前者会产生一个三路合并(3-way merge)和一个合并提交,而后者产生的是一个快速向前的合并以及完美的线性历史。下图展示了为什么 rebase 到 master 分支会促成一个快速向前的合并。

Git Tutorial: Fast-forward merge

rebase 是将上游更改合并进本地仓库的通常方法。你每次想查看上游进展时,用 git merge 拉取上游更新会导致一个多余的合并提交。在另一方面,rebase 就好像是说「我想将我的更改建立在其他人的进展之上」。

不要 rebase 公共历史

和我们讨论过的 git commit --amendgit reset 一样,你永远不应该 rebase 那些已经推送到公共仓库的提交。rebase 会用新的提交替换旧的提交,你的项目历史会像突然消失了一样。

栗子

下面这个🌰同时使用 git rebase 和 git merge 来保持线性的项目历史。这是一个确认你的合并都是快速向前的方法。

1
2
3
4
# 开始新的功能分支
git checkout -b new-feature master
# 编辑文件
git commit -a -m "Start developing a feature"

在 feature 分支开发了一半的时候,我们意识到项目中有一个安全漏洞:

1
2
3
4
5
6
7
8
# 基于master分支创建一个快速修复分支
git checkout -b hotfix master
# 编辑文件
git commit -a -m "Fix security hole"
# 合并回master
git checkout master
git merge hotfix
git branch -d hotfix

将 hotfix 分支并回之后 master,我们有了一个分叉的项目历史。我们用 rebase 整合 feature 分支以获得线性的历史,而不是使用普通的 git merge。

1
2
git checkout new-feature
git rebase master

它将 new-feature 分支移到了 master 分支的末端,现在我们可以在 master 上进行标准的快速向前合并了:

1
2
git checkout master
git merge new-feature

git rebase -i

-i 标记运行 git rebase 开始交互式 rebase。交互式 rebase 给你在过程中修改单个提交的机会,而不是盲目地将所有提交都移到新的基上。你可以移除、分割提交,更改提交的顺序。它就像是打了鸡血的 git commit --amend 一样。

用法

1
git rebase -i <base>

将当前分支 rebase 到 base,但使用可交互的形式。它会打开一个编辑器,你可以为每个将要 rebase 的提交输入命令(见后文)。这些命令决定了每个提交将会怎样被转移到新的基上去。你还可以对这些提交进行排序。

讨论

交互式 rebase 给你了控制项目历史的完全掌控。它给了开发人员很大的自由,因为他们可以提交一个「混乱」的历史而只需专注于写代码,然后回去恢复干净。

大多数开发者喜欢在并入主代码库之前用交互式 rebase 来完善他们的 feature 分支。他们可以将不重要的提交合在一起,删除不需要的,确保所有东西在提交到「正式」的项目历史前都是整齐的。对其他人来说,这个功能的开发看上去是由一系列精心安排的提交组成的。

栗子

下面这个🌰是 非交互式rebase 一节中🌰的可交互升级版本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 开始新的功能分支
git checkout -b new-feature master
# 编辑文件
git commit -a -m "Start developing a feature"
# 编辑更多文件
git commit -a -m "Fix something from the previous commit"

# 直接在 master 上添加文件
git checkout master
# 编辑文件
git commit -a -m "Fix security hole"

# 开始交互式 rebase
git checkout new-feature
git rebase -i master

最后的那个命令会打开一个编辑器,包含 new-feature 的两个提交,和一些指示:

1
2
pick 32618c4 Start developing a feature
pick 62eed47 Fix something from the previous commit

你可以更改每个提交前的 pick 命令来决定在 rebase 时提交移动的方式。在我们的例子中,我们只需要用 squash 命令把两个提交并在一起就可以了:

1
2
pick 32618c4 Start developing a feature
squash 62eed47 Fix something from the previous commit

保存并关闭编辑器以开始 rebase。另一个编辑器会打开,询问你合并后的快照的提交信息。在定义了提交信息之后,rebase 就完成了,你可以在 git log 输出中看到那个提交。整个过程可以用下图可视化:

Git Tutorial: git rebase -i example

注意缩并的提交和原来的两个提交的 ID 都不一样,告诉我们这确实是个新的提交。

最后,你可以执行一个快速向前的合并,来将完善的 feature 分支整合进主代码库:

1
2
git checkout master
git merge new-feature

交互式 rebase 强大的能力可以从整合后的 master 分支看出——额外的 62eed47 提交找不到了。对其他人来说,你就像是一个天才,用完美数量的提交完成了 new-feature。这就是交互式提交如何保持项目历史干净和合意。

git reflog

Git 用引用日志这种机制来记录分支顶端的更新。它允许你回到那些不被任何分支或标签引用的更改。在重写历史后,引用日志包含了分支旧状态的信息,有需要的话你可以回到这个状态。

用法

1
git reflog

显示本地仓库的引用日志。

1
git reflog --relative-date

用相对的日期显示引用日志。(如 2 周前)。

讨论

每次当前的 HEAD 更新时(如切换分支、拉取新更改、重写历史或只是添加新的提交),引用日志都会添加一个新条目。

栗子

为了理解 git reflog,我们来看一个🌰。

1
2
3
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 的 branch 之间的相互切换。还有对一个更老的提交的强制重设。最近的活动用 HEAD@{0} 标记在上方显示。

如果事实上你是不小心切换回去的,引用日志包含了你意外地丢掉两个提交之前 master 指向的提交 0254ea7。

1
git reset --hard 0254ea7

使用 git reset,就有可能能将master变回之前的那个提交。它提供了一张安全网,以防历史发生意外更改。

务必记住,引用日志提供的安全网只对提交到本地仓库的更改有效,而且只有移动操作会被记录。

这篇文章参考于「git-recipes」