第五章 Git 历险记
一
王志远和 Git 的关系,可以用四个字来形容:相爱相杀。
大学的时候,他用 Git 的方式是这样的:写完代码,git add .,git commit -m "update",git push。三板斧,一把梭。提交信息永远是"update",偶尔心情好了会写"fix bug",再偶尔会写"final version"——然后第二天还有"final final version"和"final final final version"。
入职之后,他发现公司的 Git 工作流跟他想象的完全不一样。
首先,不能直接往 main 分支提交代码。main 分支是受保护的,只有通过 Pull Request 并且 Code Review 通过之后才能合入。
其次,每个人开发新功能或修 bug 都要从 develop 分支拉一个新的特性分支(feature branch),分支命名有规范:feature/JIRA编号-简短描述,比如 feature/PROJ-123-add-export-pdf。
第三,提交信息也有规范,要用 Conventional Commits 格式:feat: 添加PDF导出功能、fix: 修复金额精度问题、refactor: 重构用户导出模块。
这些规矩让王志远头大。他觉得自己以前用的 Git 和公司用的 Git 好像不是同一个软件。
二
出事是在入职第五周的一个周三下午。
王志远在做一个小功能——给后台管理页面加一个"批量删除"按钮。功能不复杂,他写了大概两个小时就搞定了。
然后他习惯性地打开终端:
git add .
git commit -m "add batch delete"
git push
推送成功。他满意地靠在椅背上。
三分钟后,钉钉群炸了。
CI Bot:❌ Build Failed on branch
main— 3 test failures苏薇:谁动了 main?构建挂了
陈锐:我的 PR 刚才还是绿的,现在合不进去了
马德华:???
王志远看着群消息,手心一下子出了汗。他打开 GitLab,看了一眼 main 分支的提交记录——最新一条:
add batch delete — 王志远, 2 minutes ago
他直接把代码推到了 main 分支。
他的脸一瞬间变成了猪肝色。
三
事情是这样的:他在本地开发的时候,忘了切分支。他以为自己在 feature/PROJ-156-batch-delete 分支上,实际上一直在 main 分支上写代码。而 main 分支的保护规则,不知道什么时候被运维同事临时关掉了(为了修一个紧急的配置问题),还没来得及重新开启。
于是他的 git push 就这么畅通无阻地推上去了。
推上去的代码本身没有大问题,但他的批量删除功能依赖一个新的数据库字段,而这个字段的迁移脚本还没有执行。CI 在跑集成测试的时候,测试环境的数据库里没有这个字段,三个测试用例直接报错。
整个团队的 CI 流水线被阻塞了——所有人的 PR 都合不进去,因为 main 分支的构建是红的。
赵北辰从会议室走出来,看了一眼群消息,走到王志远工位旁边。
"你推到 main 了?"
王志远点了点头,声音像蚊子:"我忘了切分支……"
赵北辰没有发火。他拉了把椅子坐下来,说:"先别慌。第一步,把你的提交从 main 上回退掉。"
"怎么回退?"
"用 git revert。不要用 git reset --hard 然后 force push——main 分支上绝对不能 force push,那会把别人的提交也搞丢。git revert 会生成一个新的提交来撤销你的改动,安全且可追溯。"
赵北辰指导他敲了命令:
git revert HEAD --no-edit
git push origin main
一分钟后,CI 重新跑了一遍,绿了。群里安静了下来。
苏薇发了一条:"好了,谁干的?以后注意。"
陈锐发了一条:"建议加强分支保护规则,不要随便关。"——这话说得没错,但王志远总觉得他是在借机踩自己。
四
危机解除后,赵北辰没有让王志远回去继续写代码,而是把他带到了会议室。
"今天这个事故,根本原因是什么?"
"我忘了切分支。"
"对,但更深层的原因是——你不理解 Git 的工作流。你把 Git 当成了一个'保存文件'的工具,但 Git 不是网盘,它是一个协作系统。"
赵北辰在白板上画了一张图:
main(主分支)
│
├── develop(开发分支)
│ │
│ ├── feature/PROJ-156-batch-delete(你的特性分支)
│ │
│ ├── feature/PROJ-157-user-search(陈锐的特性分支)
│ │
│ └── feature/PROJ-158-report-export(苏薇的特性分支)
│
├── release/v2.1.0(发布分支)
│
└── hotfix/fix-payment-timeout(热修复分支)
"这是我们用的 Git 分支策略,叫 Git Flow。核心思想是:不同的事情在不同的分支上做,互不干扰。"
他逐一解释:
- main 分支:生产环境的代码,永远是可发布的状态。只有经过测试的代码才能合入,绝对不能直接推送。
- develop 分支:开发主线,所有特性分支从这里拉出去,开发完了合回来。
- feature 分支:每个新功能一个分支,命名规范
feature/JIRA编号-描述。开发完了提 PR 合入 develop。 - release 分支:准备发版时从 develop 拉出来,只做 bug 修复和文档更新,不加新功能。测试通过后合入 main 和 develop。
- hotfix 分支:线上紧急 bug 修复,从 main 拉出来,修完后合入 main 和 develop。
"你今天的问题就是——你在 main 分支上直接开发,跳过了整个流程。就好比你在高速公路的应急车道上开车,平时可能没事,但一旦出事就是大事。"
五
"那我以后每次写代码之前,先检查一下当前在哪个分支?"王志远问。
"对,这是最基本的。"赵北辰说。"养成习惯——每次打开终端,先敲 git branch 或者看一眼 IDE 右下角的分支名。确认自己在正确的分支上,再开始写代码。"
"但万一我又忘了呢?"
"可以加一个保险。"赵北辰打开终端,演示了一下:"你可以在 shell 的提示符里显示当前分支名。用 Oh My Zsh 或者在 .bashrc 里配置一下,终端提示符会变成这样:"
walter@macbook ~/project (feature/PROJ-156-batch-delete) $
"括号里就是当前分支名。每次你敲命令的时候,一眼就能看到。"
"另外,"赵北辰补充道,"GitLab 上的分支保护规则要重新开启,而且要加一条:main 和 develop 分支禁止直接 push,只能通过 PR 合入。这样即使你忘了切分支,push 的时候也会被拒绝。"
王志远把这些都记在了笔记本上。
六
"接下来说说 Pull Request。"赵北辰继续说。
"PR 我知道,就是提交代码让别人 Review 嘛。"
"不只是 Review。PR 是整个协作流程的核心环节。"赵北辰在白板上画了一个流程:
1. 从 develop 拉特性分支
└── git checkout -b feature/PROJ-156-batch-delete develop
2. 在特性分支上开发,多次提交
└── git commit -m "feat: 添加批量删除按钮"
└── git commit -m "feat: 实现批量删除后端接口"
└── git commit -m "test: 添加批量删除单元测试"
3. 开发完成,推送到远程
└── git push origin feature/PROJ-156-batch-delete
4. 在 GitLab 上创建 Pull Request
└── 标题:feat: 添加批量删除功能 (PROJ-156)
└── 描述:改了什么、为什么改、怎么测试
└── 指定 Reviewer(至少一人)
5. Code Review
└── Reviewer 提出修改意见
└── 开发者修改后重新提交
└── 所有 Reviewer 批准
6. CI 检查通过(自动化测试、代码扫描)
7. 合入 develop 分支
"每一步都有它的道理。"赵北辰说。"分支把你的改动隔开,PR 让别人帮你把把关,CI 自动跑一遍质量检查,最后合进去才算真正'完事儿'。"
"那提交信息的规范呢?为什么不能写 'update'?"
赵北辰笑了:"你想象一下,半年后线上出了一个 bug,你需要查是哪次提交引入的。你打开 Git 日志,看到的是这样的:"
update
fix
update again
final
final2
fix bug
"你能看出哪次提交改了什么吗?"
王志远摇了摇头。
"但如果提交信息是这样的:"
feat: 添加批量删除功能
fix: 修复批量删除时未校验权限的问题
refactor: 重构删除接口,提取公共方法
test: 补充批量删除的边界测试用例
"一目了然。每条提交做了什么、为什么做,清清楚楚。半年后你回来看,不用读代码就知道每次改动的意图。"
他在白板上写下 Conventional Commits 的常用前缀:
feat: 新功能
fix: 修复 bug
refactor: 重构(不改变功能)
test: 添加或修改测试
docs: 文档更新
style: 代码格式调整(不影响逻辑)
chore: 构建、依赖等杂项
perf: 性能优化
ci: CI/CD 配置修改
"提交信息是写给未来的自己和队友看的。你现在多花十秒钟写一条好的提交信息,未来可能省下十个小时的排查时间。"
七
那个周末,王志远没有出门。
他在出租屋里花了两天时间系统地学习 Git。他找了一个叫 "Learn Git Branching" 的在线交互教程,一关一关地打,从最基础的 commit、branch、merge 开始,一直打到 rebase、cherry-pick、reflog。
他终于搞明白了几个之前一直困惑的概念:
merge vs rebase——这是他最头疼的问题。
merge 是把两个分支的历史合在一起,会生成一个新的合并提交(merge commit)。好处是保留了完整的历史,坏处是如果频繁合并,Git 日志会变得很乱,到处都是交叉的线。
rebase 是把你的提交"搬"到目标分支的最新提交后面,就好像你是从最新的代码开始开发的一样。好处是历史线性、干净,坏处是它改写了提交历史——如果你 rebase 的是已经推送到远程的分支,别人拉取的时候会出问题。
赵北辰的建议是:在自己的特性分支上用 rebase 保持历史干净,合入 develop 时用 merge(或者 squash merge)保留合并记录。已经推送到远程的公共分支,绝对不要 rebase。
reset vs revert——这是他今天犯错时学到的。
reset 是把分支指针往回移,相当于"假装后面的提交没发生过"。如果用 --hard,工作区的改动也会丢失。在本地分支上用没问题,但在公共分支上用了再 force push,会把别人的提交搞丢。
revert 是生成一个新的提交来"反向操作"之前的提交。历史记录完整保留,安全且可追溯。在公共分支上撤销改动,永远用 revert。
stash——这个他之前完全不知道。
有时候你正在写代码,突然要切到另一个分支去修一个紧急 bug。但当前的改动还没写完,不想提交。这时候可以用 git stash 把当前的改动暂存起来,切到别的分支干完活,再切回来用 git stash pop 恢复。
他在笔记本上画了一张表:
| 场景 | 命令 | 注意事项 |
|---|---|---|
| 撤销本地未提交的改动 | git checkout -- <file> |
改动会丢失,慎用 |
| 撤销本地已提交但未推送的改动 | git reset --soft HEAD~1 |
改动保留在暂存区 |
| 撤销已推送到公共分支的提交 | git revert <commit> |
安全,生成新提交 |
| 临时保存未完成的改动 | git stash |
切分支前用 |
| 恢复暂存的改动 | git stash pop |
切回来后用 |
八
周一上班,王志远做的第一件事是检查自己的终端配置。
他按照赵北辰说的,在 .zshrc 里配置了 Git 分支显示。现在他的终端提示符长这样:
wangzhiyuan@mac ~/cloud-smart (feature/PROJ-156-batch-delete) $
括号里的分支名清清楚楚。他再也不会搞错自己在哪个分支上了。
然后他重新开始做批量删除功能。这次他严格按照流程来:
- 先确认自己在
develop分支上:git branch - 拉取最新代码:
git pull origin develop - 创建特性分支:
git checkout -b feature/PROJ-156-batch-delete - 开发、提交,每次提交都写规范的提交信息
- 开发完成后推送到远程,创建 PR
- 等 Review 和 CI 通过后合入
整个过程他做得小心翼翼,每一步都确认了两遍。
PR 提交后,苏薇 Review 时只提了一条:"批量删除的数量上限要加个限制,不然有人一次删一万条,数据库扛不住。"
改完之后,PR 顺利合入。
这是他第一次完整地走完了一个标准的 Git 工作流。没有事故,没有回退,没有群里炸锅。
平平淡淡的,但他觉得这种平淡比什么都好。
九
过了几天,又发生了一件小事,让王志远对 Git 的理解更深了一层。
他在做一个新功能的时候,发现自己的特性分支和 develop 分支产生了冲突——他改了一个文件的第 42 行,苏薇也改了同一个文件的第 42 行,苏薇的 PR 先合入了。
他打开冲突的文件,看到了 Git 标记的冲突区域:
<<<<<<< HEAD
private static final int MAX_RETRY = 3;
=======
private static final int MAX_RETRY_COUNT = 5;
>>>>>>> develop
他的版本是 MAX_RETRY = 3,苏薇的版本是 MAX_RETRY_COUNT = 5。
他不确定该用哪个,于是去问苏薇。
苏薇说:"我改成 5 是因为线上有个场景重试 3 次不够,需要 5 次。变量名改成 MAX_RETRY_COUNT 是因为 MAX_RETRY 不够清晰。你用我的版本就行。"
王志远解决了冲突,保留了苏薇的版本,然后提交。
这件事让他明白了一个道理:冲突不是错误,是协作的正常现象。两个人同时改了同一个地方,Git 不知道该用谁的,所以让你来决定。解决冲突的关键不是技术,是沟通——你得知道对方为什么这么改,才能做出正确的选择。
十
那天晚上,王志远打开电脑,在 "Learn Git Branching" 上又打了两关。
屏幕上的分支线条交错延伸,像一棵不断生长的树。
他忽然想起下午赵北辰说的那句话——"Git 工作流就像交通规则,平时觉得麻烦,出了事故才知道它的价值。"
流程不是束缚,是保护。保护你,也保护队友。
他觉得自己也在某个分支上,正在慢慢地、一步一步地往前走。不知道最终会合入哪个分支,但至少方向是对的。
:::{admonition} 小胖的笔记 :class: tip
本章知识卡片:Git 工作流与协作规范
1. Git Flow 分支策略
main ─────────●───────────────●──────────── 生产环境
│ ↑
│ release/v2.1
│ ↑
develop ──●───●───●───●───●───●───●──────── 开发主线
│ │ │
│ │ └── feature/C
│ └── feature/B
└── feature/A
hotfix ──────────────────────────●──── 紧急修复
│
main ────────────────────────────●──── 合入 main
| 分支 | 用途 | 谁能合入 | 直接 push? |
|---|---|---|---|
| main | 生产代码 | 只能从 release/hotfix 合入 | ❌ 禁止 |
| develop | 开发主线 | 从 feature 分支通过 PR 合入 | ❌ 禁止 |
| feature/* | 新功能开发 | 开发者自己的分支 | ✅ 可以 |
| release/* | 发版准备 | 只修 bug,不加功能 | 受限 |
| hotfix/* | 紧急修复 | 修完合入 main + develop | 受限 |
2. Conventional Commits(约定式提交)
格式:<类型>: <描述>
feat: 新功能 fix: 修复 bug
refactor: 重构 test: 测试相关
docs: 文档 style: 格式调整
chore: 杂项 perf: 性能优化
ci: CI/CD 配置
好的提交信息 vs 坏的提交信息:
| ❌ 坏 | ✅ 好 |
|---|---|
| update | feat: 添加批量删除功能 |
| fix | fix: 修复批量删除时未校验权限的问题 |
| done | refactor: 提取删除接口的公共校验方法 |
| final version | test: 补充批量删除的边界测试用例 |
3. 常用 Git 操作速查
| 场景 | 命令 |
|---|---|
| 查看当前分支 | git branch |
| 创建并切换分支 | git checkout -b feature/xxx develop |
| 暂存当前改动 | git stash |
| 恢复暂存改动 | git stash pop |
| 撤销公共分支上的提交 | git revert <commit> |
| 撤销本地未推送的提交 | git reset --soft HEAD~1 |
| 把特性分支变基到最新 develop | git rebase develop(仅限本地分支) |
| 查看提交历史 | git log --oneline --graph |
4. merge vs rebase
| merge | rebase | |
|---|---|---|
| 原理 | 合并两个分支,生成合并提交 | 把提交"搬"到目标分支后面 |
| 历史 | 保留完整分支历史(有交叉线) | 线性历史(干净) |
| 安全性 | 安全,不改写历史 | 改写历史,公共分支禁用 |
| 适用场景 | 合入 develop/main | 本地特性分支同步最新代码 |
黄金法则:只对本地的、未推送的提交做 rebase。已经推送到远程的公共分支,永远不要 rebase。
5. 解决冲突的步骤
git merge develop(或git rebase develop)触发冲突- 打开冲突文件,找到
<<<<<<<和>>>>>>>标记 - 跟对方沟通,理解双方改动的意图
- 手动选择保留哪个版本(或合并两者)
- 删除冲突标记,保存文件
git add <file>→git commit(merge)或git rebase --continue(rebase)
记住:解决冲突的关键不是技术,是沟通。
:::