第五章 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) $

括号里的分支名清清楚楚。他再也不会搞错自己在哪个分支上了。

然后他重新开始做批量删除功能。这次他严格按照流程来:

  1. 先确认自己在 develop 分支上:git branch
  2. 拉取最新代码:git pull origin develop
  3. 创建特性分支:git checkout -b feature/PROJ-156-batch-delete
  4. 开发、提交,每次提交都写规范的提交信息
  5. 开发完成后推送到远程,创建 PR
  6. 等 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. 解决冲突的步骤

  1. git merge develop(或 git rebase develop)触发冲突
  2. 打开冲突文件,找到 <<<<<<< 和 >>>>>>> 标记
  3. 跟对方沟通,理解双方改动的意图
  4. 手动选择保留哪个版本(或合并两者)
  5. 删除冲突标记,保存文件
  6. git add <file> → git commit(merge)或 git rebase --continue(rebase)

记住:解决冲突的关键不是技术,是沟通。

:::