前端团队协作中的 Git 习惯:很多低级问题其实和工具本身无关
我们组的分支约定里有一条:合并到 main 之前必须 rebase 一次,不允许直接 merge 出一个多余的 merge commit。这条规则是一位同事定的,理由很简单——他之前维护过一段两派并存的历史,一半人 merge、一半人 rebase,git log --graph 打出来的线穿插得像电路图,出问题的时候连"这个改动到底是谁在什么时候引入的"都要看半天。规则统一之后,历史至少能顺着一条线读下去。
这类约定听起来是 Git 命令层面的事,实际决定它好不好用的,往往是团队怎么理解"提交"和"分支"这两个概念,而不是命令记没记熟。前端项目的协作还有一个特点:文件类型很杂,业务代码、样式、配置、锁文件、构建产物、静态资源、自动生成索引都可能出现在同一个仓库里。没有约定的话,Git 历史很快会被噪音填满,规则统一之后噪音才有地方去。
提交粒度太大,是很多协作问题的起点
很多人习惯攒一堆改动再一起提交,结果一个 commit 里同时包含新功能、样式调整、文案修改、顺手重构、调试代码删除。这样的提交在自己脑子里可能还是清楚的,但对其他协作者来说可读性会很差。
比如下面这种提交就比较难 review:
1feat: 完成首页
如果里面同时改了接口封装、首页组件、全局样式、路由配置,还顺手修了一个按钮 bug,review 的人很难判断每一块改动是否必要,也很难判断哪一块改动可以单独回滚。
更好的拆法可能是:
1feat: add home article list 2feat: add home category filter 3fix: align primary button loading state 4refactor: extract article query helper
提交粒度不是越小越好,而是要能表达独立意图。一个 commit 最好能回答:这一步如果单独回滚,会不会影响其他不相关改动?
我在中后台项目里踩过一个坑:一个"表格筛选优化"的提交里顺手改了接口层错误提示,还把几个公共样式一起调了。后来线上反馈筛选条件丢失,想回滚时才发现回滚会带走另一处已经验证过的样式修复。那次以后,我对提交粒度的判断标准更具体了:能单独解释、能单独 review、必要时能单独撤销。
提交信息是给后来的人看的
很多提交信息写成 fix、update、修改一下,这类信息当下图省事,但后面排查问题、查历史时几乎没有帮助。
好的提交信息不需要写成长文,但至少应该表达改了什么、为什么改。我们团队用的是接近 Conventional Commits 的格式:
1feat: add article search empty state 2fix: handle fetch error message fallback 3docs: update deployment note 4refactor: split sidebar data builder
格式本身不是重点,重点是让历史可扫描。feat、fix、docs、refactor 这些前缀能让人快速判断改动性质。为了让这个格式真正落地而不是靠自觉,我们在仓库里接了 commitlint 配合 husky 的 commit-msg 钩子,提交信息不符合格式直接在本地就拦下来,不用等 review 时被人指出:"这条提交信息看不懂,重写一下"。
1npm install --save-dev @commitlint/cli @commitlint/config-conventional husky 2npx husky add .husky/commit-msg 'npx --no -- commitlint --edit "$1"'
配置文件很简单:
1// commitlint.config.js 2module.exports = { 3 extends: ['@commitlint/config-conventional'], 4};
一开始团队里也有人抱怨这东西太较真,本地随手写个 fix 都会被拦。后来加了个 type-enum 的自定义规则,把 chore、style、perf 这些常用类型也加进去,抱怨就少了很多——问题出在规范列表和团队实际使用的场景对不上,跟"要不要写规范格式"本身没多大关系。
如果一次改动背后有上下文,可以在正文里补充:
1fix: avoid duplicate request when switching tabs 2 3The tab change handler and initial load were both calling loadArticles. 4Keep the initial load in one place so the list state remains stable.
以后排查重复请求时,这段说明会比一句 fix bug 有用得多。如果提交关联需求单或问题单,也可以把编号写进去:
1fix(search): keep filter state when switching tabs 2 3Refs: CRM-2147
这不是为了形式好看,而是为了几个月后查历史时,能从代码改动一路追到那次改动发生时的业务背景。
分支不要活得太久,rebase 和 merge 要选一种
一个功能分支拖得越久,越容易和主干偏离、冲突增多、难以 review、难以回滚。所以我更倾向短分支、小 PR。一个 PR 如果改了几十个文件、几千行代码,review 很容易变成走形式;更糟的是,等它合并时主干已经变化很多,冲突概率也随之上升。
一个实用的习惯是每天同步主干:
1git fetch origin 2git rebase origin/main
前面提到的团队约定就是从这里来的:本地分支同步主干统一用 rebase,保持自己这条线是线性的;合并回 main 用 GitHub 上的 squash merge,一个 PR 最终在主干上落成一条提交。这两件事分开看都很常见,放在一起说清楚才是关键——很多团队踩的坑不是"该用哪个",而是没有明确规定,结果一部分人一直 merge、一部分人一直 rebase,历史变得没法读。
有一条边界必须提前讲清楚:已经推送到公共分支、别人可能已经基于它开发的历史,不能再用 rebase 改写。我们组之前出过一次事——有人在 feature/list 分支上做了一次交互式 rebase 想把几个 wip 提交合并干净,但那时候另一个同事已经从这个分支切出了子分支继续开发。rebase 之后推送时用了 --force,那位同事拉取时整条历史对不上,本地分支直接乱掉。后来我们的约定是:个人独占的分支可以随便 rebase 整理;一旦有第二个人基于它工作,只能往前 merge,不能往回改写。
如果一个需求确实很大,我更倾向于拆成多个可合并的小 PR,而不是让一个分支活很久。比如先合接口封装和空状态,再合列表主体,再合批量操作,每一步都能独立验证,主干也不会突然吃进一大块未知风险。
PR 描述要给 review 留入口
很多 PR 难审,不是因为代码复杂,而是缺少入口。review 的人不知道应该重点看哪里,只能从第一个文件开始硬读。
我现在写 PR 描述通常会包含改动范围、为什么这么改、自测结果、需要重点看的地方、截图或录屏。例如:
1本次改动把客户列表的筛选状态放到 URL query 中,解决刷新后条件丢失的问题。 2 3自测: 4- 切换分页后筛选条件保留 5- 刷新页面后能恢复筛选条件 6- 清空筛选后 URL query 会同步删除 7 8重点看:useCustomerFilters 里的 query 同步逻辑。
这样的描述能明显降低沟通成本。前端 PR 里如果涉及 UI,截图和录屏也很重要,光看 diff 很难判断交互是否符合预期。
我们把 PR 模板固定进了仓库的 .github/PULL_REQUEST_TEMPLATE.md,同时接了 GitHub Actions 在 PR 上跑 lint 和单测,测试没过直接标红,review 的人不用先手动确认"这个改动能跑起来",可以直接看逻辑本身。这条流水线本身不复杂,但省下的沟通成本比想象中多——以前经常有 PR 卡在"我这边测试挂了,等我看一下",现在挂没挂在 PR 页面上一眼能看到。
合并前先自查
提交 PR 前,我通常会做三件事:看一遍 git diff、跑项目测试或 lint、检查有没有调试代码、临时文件、无关格式化。
1git diff --stat 2git diff
git diff 很重要,因为它能让你以 review 视角重新看自己的改动,很多无关改动、误删代码、临时日志都是在这一步发现的。如果项目有自动格式化,也最好让格式化规则稳定执行,不要在业务 PR 里顺手格式化半个仓库——格式化可以单独提交,否则会淹没真正的业务改动。
合并前的自查还包括看 staged 内容,很多误提交不是不会用 Git,而是没有看清楚自己到底加进去了什么。
1git diff --cached --stat 2git diff --cached
如果发现一个 PR 里混进了无关文件,宁愿当场拆掉,也不要想着"反正也不影响"。无关改动会降低 review 质量,后面排查问题时也会增加噪音。
拆提交时,我会先用 patch 模式挑改动,而不是直接 git add .:
1git add -p
它会一块一块问你是否暂存,适合把"功能改动"和"顺手重构"拆开。如果一个文件里两类改动混在一起,git add -p 能帮你只暂存其中一部分,提交前再看一遍 git diff --cached。这个习惯能挡住很多噪音提交,尤其是前端项目里格式化、生成文件、样式调整很容易跟业务逻辑混在一起,不拆清楚 review 会很累。
冲突并不可怕,可怕的是不理解改动就解决
很多人处理冲突时只想尽快让它过去,结果用的是"谁的代码看起来顺眼就保留谁"。这在简单文件上可能没问题,但如果涉及逻辑变更,很容易把一部分正确改动无意中覆盖掉。
处理冲突时我会先看冲突文件周围的业务意图,而不是只看标记之间哪段代码更多:
1<<<<<<< HEAD 2const pageSize = 20; 3======= 4const pageSize = 50; 5>>>>>>> feature/list
这种冲突表面上只是数字不同,背后可能是分页策略变化,也可能是某个人临时调试。应该结合提交记录、需求背景和调用方一起判断,如果冲突涉及不熟悉的模块,最好找对应改动人确认。快速解决冲突不是目标,正确保留双方意图才是目标。
解决冲突前,我会先分别看两边改动的来源:
1git log --oneline --left-right --merge 2git show --stat <commit> 3git show <commit> -- path/to/conflict-file
--left-right --merge 能列出参与冲突的两边提交,git show 能看每个提交当时到底想改什么。很多冲突不是纯文本选择题,而是两个需求都合理,只是改到了同一段,先看意图再动文件,出错概率会低很多。
如果冲突出现在多个文件、结构比较复杂的场景,命令行看 diff 有时候不够直观,我会用 git mergetool 接一个可视化工具,把左右两边和结果三栏并排摆开再判断。命令行适合快速确认意图,可视化工具适合冲突范围大、需要反复对照上下文的场景,两种方式配合用比死磕一种更省时间。
还有一类冲突容易被忽略:不是代码文本冲突,而是同一份数据结构被两边以不同方式扩展。比如 A 分支给某个表单对象加了一个 remark 字段,B 分支给同一个对象加了 attachments 字段,改的是文件的不同位置,Git 完全能自动合并,不会提示任何冲突。但如果这个对象在某处会被整体序列化或做浅拷贝,两个新字段凑在一起未必兼容——这种"无冲突的冲突"没有任何工具能帮你标出来,只能靠合并后跑一遍相关场景来发现。我现在遇到"两边都新增了字段"这种情况,即使 Git 顺利自动合并,也会习惯性把相关页面手动点一遍。
冲突解决完也别急着提交,先跑一遍相关场景,至少看一下冲突文件的最终 diff:
1git diff
我见过不少冲突是"编译能过,业务语义错了":两个分支各自新增了一个状态字段,合并时保留了字段却漏了对应判断。Git 只能告诉你文本冲突,判断业务是否完整还是人的责任。
前端项目尤其要注意产物文件
前端项目里常见一些自动生成内容,例如构建产物、锁文件、搜索索引、站点地图。这些文件是否应该进版本库、什么时候更新,团队最好有一致规则,否则很容易出现无意义冲突或脏提交。
例如锁文件通常应该提交,因为它保证依赖版本一致;构建产物通常不应该提交,除非项目部署方式明确依赖它;站点地图、搜索索引这类生成文件要看项目约定。
前端项目还要特别注意环境文件:
1.env 2.env.local 3.env.production
包含密钥或本地配置的文件不应该随便提交。可以提供 .env.example 说明需要哪些变量,但不要把真实 token、数据库地址、第三方密钥放进仓库。
锁文件也不要靠个人习惯处理。package-lock.json、yarn.lock,包括这两年开始有团队尝鲜的 pnpm-lock.yaml,应该由团队明确使用哪一种,不要混用。依赖升级最好单独提交,这样线上构建异常时能快速判断是不是依赖变化引起的。
这类文件冲突最好也提前有约定处理方式。锁文件的冲突几乎没有手动解决的价值——两边的差异通常是安装顺序和依赖树展开方式不同造成的,硬着头皮去比对文本没有意义。更稳妥的做法是冲突出现时直接删掉锁文件、按当前 package.json 重新安装生成一份,而不是试图手工拼出一份"融合版":
1rm package-lock.json 2npm install
这个操作要建立在 package.json 本身没有冲突或者冲突已经解决的前提下,否则生成出来的锁文件同样是错的。
自动生成文件也一样。比如博客项目里的 sitemap、RSS、搜索索引,如果项目约定它们要提交,那就应该在相关内容改动后一起更新;如果约定构建时生成,就不要把它们放进版本库。规则模糊时,冲突会非常频繁。
回滚要能落到具体提交
提交粒度清楚的另一个好处是回滚简单。如果一个线上问题来自某个明确 commit,可以直接:
1git revert <commit>
如果一个 commit 里混了多个功能和重构,回滚就会很痛苦:你想撤掉 bug,却可能把其他正常功能也撤掉。这也是为什么我不喜欢"大杂烩提交",它当时省了几分钟,后面排查和回滚可能浪费几个小时。
我们线上环境的部署流程接的是 GitHub Actions,main 分支有提交推送之后自动触发构建和部署。这套流程带来一个好处:revert 之后不需要额外操心"要不要手动重新部署",提交合并进去,流水线自动跑完,几分钟内线上就是回滚后的状态。前提是提交本身足够干净——如果 revert 的提交里连带影响了别的功能,流水线跑得再快也没用,回滚照样是错的。
不要把 Git 当成备份工具
有些人会把 commit 当保存按钮,每写一点就提交一个 wip。本地临时提交当然可以,但推到公共分支前最好整理一下。可以通过交互式 rebase、squash 或重新组织 commit,让最终历史更像一份清楚的变更说明。团队不一定追求完美线性历史,但至少要避免公共历史里充满无意义的 test、123、改一下。
如果只是想临时保存实验代码,可以用 stash、临时分支,或者最近开始试用的 worktree:
1git worktree add ../project-experiment experiment/demo
这样可以在不污染当前工作区的情况下尝试新方案,几个目录同时对应不同分支,切换实验不需要反复 stash 和 checkout。等方案确定后,再把真正需要的改动整理回主分支。实验和正式改动分开,Git 历史会干净很多。
回到开头那条"合并前必须 rebase 一次"的约定——它能立住不是因为写进了文档,而是提交粒度、分支存活时间、冲突处理这几件事团队里已经有了共识,规则才有地方落脚。命令记不记得住是小事,提交粒度清不清楚、分支拖不拖太久、冲突时是先理解还是先蒙眼睛选一边,这几个习惯才是决定协作顺不顺的地方。