---
url: /blog/n1r2b3pi/index.md
---
## 理解 Git 的工作流程

**英文原文：**[Understanding the Git Workflow](http://sandofsky.com/blog/git-workflow.html) By Benjamin Sandofsky

如果你不理解 Git 的设计动机，那你就会处处碰壁。知道足够多的命令和参数后，你就会强行让 Git 按你想的来工作，而不是按 Git 自己的方式来。这就像把螺丝刀当锤子用，也能把活干完，但肯定干的差极了，花费很长时间，还会弄坏螺丝刀。

想想常见的 Git 工作流程是怎么失效的吧。

1. 从 master 分支创建一个新分支
2. 在该分支进行工作
3. 完成工作后将其合并回 master 分支

多数时候这样做的效果会如你所愿，因为从你创建分支到合并回去之间，master 一般都会有些变动。然后，有一天当你想把一个功能（feature）分支合并进 Master 的时候，而 Master 并没有像以往那样有变动，问题来了：这时 Git 不会进行合并 commit，而是将 Master 指向功能分支上的最新 commit。称为 `fast-forward`

![git工作流程](../../../50_ARCHIVES/Blog_Published/2025/了解Git工作流程.assets/3906369e8c7b405f9315042a9a8b7afa.png)

不幸的是，你的功能分支有用来备份代码的 commit（作者称之为`checkpoint commit`），这些经常进行的 commit 对应的代码可能处于不稳定状态！而这些 commit 现在没法和 Master 上那些稳定的 commit 区分开来了。当你想回滚的时候，很容易发生灾难性后果。

于是你就记住了：“当合并功能分支的时候，加上 `-no-ff` 选项强制进行一次全新的 commit。”嗯，这么做好像解决问题了，那么继续。

然后一天你在线上环境中发现了一个严重 bug，这时你需要追溯下这个 bug 是什么时候引入的。你运行了 bisect 命令，但却总是追溯到一些不稳定的 commit。因此你不得不放弃，改用人肉检查。

最后你将 bug 范围缩小到一个文件。你运行 blame 命令查看这个文件在过去 48 小时里的变动。然后 blame 告诉你这个文件已经好几周没有被修改过了 —— 你知道根本不可能没有变动。哦，原来是因为 blame 计算变动是从第一次 commit 算起，而不是 merge 的时候。你在几周前的一次 commit 中改动了这个文件，但这个变动今天才被 merge 回来。

用 no-ff 来救急，bisect 又临时失效，blame 的运作机制又那么模糊，所有这些现象都说明一件事儿，那就是你正在把螺丝刀当锤子用。

### 　 1. **反思版本控制**

版本控制的存在是因为两个原因。

首先，版本控制是用来辅助写代码的。因为你要和同事同步代码，并经常备份自己的代码。当然了，把文件压缩后发邮件也行，不过工程大了大概就不好办了。

其次，就是辅助配置管理工作。其中就包括并行开发的管理，比如一边给线上版本修复 bug，一边开发下一个版本。配置管理也可以帮助弄清楚变动发生的具体时间，在追溯 bug 中是一个很好的工具。

一般说来，这两个原因是冲突的。

在开发一个功能的时候，你应该经常做备份性的 commit。然而，这些 commit 经常会让软件没法编译。

理想情况是，你的版本更新历史中的每一次变化都是明确且稳定的，不会有备份性 commit 带来的噪声，也不会有超过一万行代码变动的超大 commit。一个清晰的版本历史让回滚和选择性 merge 都变得相当容易，而且也方便以后的检查和分析。然而，要维护这样一个干净的历史版本库，也许意味着总是要等到代码完善之后才能提交变动。

那么，经常性的 commit 和干净的历史，你选择哪一个？

如果你是在刚起步的创业公司中，干净的历史没有太大帮助。你可以放心地把所有东西都往 Master 中提交，感觉不错的时候随时发布。

如果团队规模变大或是用户规模扩大了，你就需要些工具和技巧来做约束，包括自动化测试，代码检查，以及干净的版本历史。

功能分支貌似是一个不错的折中选择，能够解决基本的并行开发问题。当你写代码的时候，可以不用怎么在意集成的问题，但它总有烦到你的时候。

当你的项目规模足够大的时候，简单的` branch/commit/merge` 工作流程就出问题了。缝缝补补已经不行了。这时你需要一个干净的版本历史库。

Git 之所以是革命性的，就是因为它能同时给你这两方面的好处。你可以在原型开发过程中经常备份变动，而搞定后只需要交付一个干净的版本历史。

### **2. 工作流程**

考虑两种分支：公共的和私有的。

公共分支是项目的权威性历史库。在公共分支中，每一个 commit 都应该确保简洁、原子性，并且有完善的提交信息。此分支应该尽可能线性，且不能更改。公共分支包括 Master 和发行版的分支。

私有分支是供你自己使用的，就像解决问题时的草稿纸。

安全起见，把私有分支只保存在本地。如果你确实需要 push 到服务器的话（比如要同步你在家和办公室的电脑），最好告诉同事这是私有的，不要基于这个分支展开工作。

绝不要直接用 merge 命令把私有分支合并到公共分支中。要先用 reset、rebase、squash merges、commit amending 等工具把你的分支清理一下。

把你自己看做一个作者，每一次的 commit 视为书中的一章。作者不会出版最初的草稿，就像 Michael Crichton 说的，“伟大的书都不是写出来——而是改出来的”（“Great books aren’t written – they’re rewritten.”）。

如果你没接触过 Git，那么修改历史对你来说好像是种禁忌。你习惯于认为提交过的所有东西都应该像刻在石头上一样不能抹去。但如果按这种逻辑，我们在文本处理软件器中也不应该使用“撤销”功能了。

实用主义者们直到变化变为噪音的时候才关注变化。对于配置管理来说，我们关注宏观的变化。日常 commit（checkpoint commits）只是备份于云端的用于“撤销”的缓冲。

如果你保持公共历史版本库的简洁，那么所谓的 fast-forward merge 就不仅安全而且可取了，它能保证版本变更历史的线性和易于追溯。

关于 -no-ff 仅剩的争论就只剩“文档证明”了。人们可能会先 merge 再 commit，以此代表最新的线上部署版本。不过，这是反模式的。用 tag 吧。

### **3. 规则和例子**

根据改变的多少、持续工作时间的长短，以及分支分叉了多远，我使用三种基本的方法。

1）短期工作

绝大多数时间，我做清理时只用 squash merge 命令。

假设我创建了一个功能分支，并且在接下来一个小时里进行了一系列的 checkpoint commit。

```cmd
git checkout -b private_feature_branch
touch file1.txt
git add file1.txt
git commit -am "WIP"
```

完成开发后，我不是直接执行 git merge 命令，而是这样：

```cmd
git checkout master
git merge --squash private_feature_branch #并不会像普通git merge xx一样自动提交，需要后面手动提交
git commit -v #打开新的编辑器编辑这一次提交记录
```

然后我会花一分钟时间写个详细的 commit 日志。

2）较大的工作

有时候一个功能可以延续好几天，伴有大量的小的 commit。

我认为这些改变应该被分解为一些更小粒度的变更，所以 squash 作为工具来说就有点儿太糙了。（根据经验我一般会问，“这样能让阅读代码更容易吗？”）

如果我的 checkpoint commits 之后有合理的更新，我可以使用 rebase 的交互模式。

交互模式很强大。你可以用它来编辑、分解、重新排序、合并以前的 commit。

在我的功能分支上：

`git rebase --interactive master ` 或者 `git rebase -i master `

然后会打开一个编辑器，里边是 commit 列表。每一行上依次是：要执行的操作、commit 的 SHA1 值、当前 commit 的注释。并且提供了包含所有可用命令列表的图例。

默认情况下，每个 commit 的操作都是“pick”，即不会修改 commit。

```cmd
pick ccd6e62 Work on back button
pick 1c83feb Bug fixes
pick f9d0c33 Start work on toolbar
```

我把第二行修改为“squash”，这样第二个 commit 就会合并到第一个上去。

```cmd
pick ccd6e62 Work on back button
squash 1c83feb Bug fixes
pick f9d0c33 Start work on toolbar
```

保存并退出，会弹出一个新的编辑器窗口，让我为本次合并 commit 做注释。就这样。

### **4. 舍弃分支**

也许我的功能分支已经存在了很久很久，我不得不将好几个分支合并进这个功能分支中，以便当我写代码时这个分支是足够新的的。版本历史让人费解。最简单的办法是创建一个新的分支。

```cmd
git checkout master
git checkout -b cleaned_up_branch
git merge --squash private_feature_branch
git reset
```

现在，我就有了一个包含我所有修改且不含之前分支历史的工作目录。这样我就可以手动添加和 commit 我的变更了。

### **5. 总结**

如果你在与 Git 的默认设置背道而驰，先问问为什么。

将公共分支历史看做不可变的、原子性的、容易追溯的。将私有分支历史看做一次性的、可编辑的。

推荐的工作流程是：

1. 基于公共分支创建一个私有分支。
2. 经常向这个私有分支 commit 代码。
3. 一旦你的代码完善了，就清理掉私有分支的历史。
4. 将干净的私有分支 merge 到公共分支中。

## 私有分支

使用 `tag` 而不是 `--no-ff`：例如某个私有的特性分支 `feature/feat1`工作完毕后，需要将 `feat1` 合并到 `dev`的场景

1. 合并

```
git checkout dev
git merge feat1  # 默认 --ff  形成线性记录
```

2. 打 Tag 标记合并

```
git tag -a "merge-feat1-$(date +%Y%m%d)" -m "Merged feat1 into main"
```

3. 推送 Tag 到远程

```
git push origin --tags
```

4. 查看合并记录

```
git log --oneline --graph --decorate --all
```
