分支模型的取舍:先看发布节奏,再看团队规模
gitflow 用 `main`(稳定发布)、`develop`(集成)、`feature/*`、`release/*`、`hotfix/*` 五类分支来表达不同成熟度的代码。它的价值在于「什么分支可以进生产」这件事没有任何歧义,因此适合确实存在多版本长期维护的软件:需要同时支持 2.0 和 3.0,每个补丁版本要单独发版,发布过程有严格的窗口和质量门槛。
它的成本是分支寿命。特性分支从创建到合并可能横跨两周,期间它与 main 的差异持续扩大,合并冲突的面积随之增长,合并时需要一次人工验证才能确信「这批改动在主干上仍然正确」。对于每天发布多次的互联网业务,这些管理成本往往超过它带来的秩序收益。
trunk-based development 的核心是「短命分支 + 频繁合并」:开发者的分支通常只活一到两天,通过特性开关(feature flag)控制未完成功能的可见性,合并到主干的门槛是自动化测试通过加代码评审。好处是冲突面积极小、集成频繁暴露问题;代价是主干上的代码可能处于「不完整但不可见」的状态,依赖特性开关和良好的测试覆盖。
折中方案是 GitHub Flow:一条主干加短命特性分支,加环境(preview environment)而不是版本分支。它在绝大多数 Web 项目里是最佳默认值。选型时的实际判断标准有三条:发布是否需要同时维护多个已发布版本(需要则考虑 gitflow)、团队是否具备自动化测试与特性开关能力(没有则 trunk-based 风险高)、以及合并到生产的审批复杂度有多高。
# gitflow 典型分支
main # 只接受 hotfix 与 release 的合并,只指向已发布版本
develop # 集成分支,日常合并目标
feature/xxx # 特性分支,从 develop 切出,合回 develop
release/1.4 # 只接受 bugfix,从 develop 切出,合回 develop 与 main
hotfix/1.3.2 # 从 main 切出,同时合回 main 与 develop
# GitHub Flow 典型分支(默认推荐)
main # 始终可发布
feature/checkout-retry # 活 1-2 天,通过 PR 合回 main
# trunk-based:主干 + 特性开关
main
feature/new-search # 短命分支,代码合并但功能由 flag 控制可见性
# 本地查看分支拓扑
# git log --graph --oneline --all --decorate -30rebase 与 merge 的边界:本地改写,共享不改写
两者的语义差别只有一个,但后果完全不同。merge 保留两条线的历史,生成一个有两个父提交的合并提交;rebase 把你的提交「摘下来」在新的基点上重放,历史变成一条直线。历史整洁度的收益是真实的:一个特性分支上的五个中间提交如果不必要,`git rebase -i` 压成一个提交后再合并,主干上的历史就只剩下「一个功能对应一个提交」。
规则可以用一句话说清:只 rebase 从未推送给别人、也从未被他人基于其开发的提交;一旦提交已经出现在共享分支上,就只 merge 不 rebase。原因是 rebase 会重写提交哈希,任何已经拉取过该分支的人,其本地历史与远端分叉,再同步时会产生重复提交与无休止的冲突。
例行流程长这样:在自己的特性分支上定期 `git fetch origin`,然后 `git rebase origin/main`,把最新的主干变化吸收进来,解决冲突后用 `git push --force-with-lease` 更新自己的远端分支。这里的 `--force-with-lease` 比 `--force` 安全得多——它只在远端分支仍处于你上次 fetch 到的状态时才覆盖,如果别人在此期间推送过,命令会拒绝执行。裸 `--force` 会无声地抹掉同事的提交。
如果 rebase 已经推送出去且被他人拉取,修复方式是把引用备份好再动:`git reflog` 能找到 rebase 前的位置,用 `git reset --hard <sha>` 回退,然后改用 merge 策略重新同步。团队规范里应当写明「共享分支禁止 force push」,并通过服务端保护规则(protected branch、禁止强推的分支设置)强制执行,而不是靠口头约定。
# 交互式 rebase:压成一个提交、改写信息、删除无用提交
# git rebase -i origin/main
# pick 3a1f2c8 feat: 增加购物车合并逻辑
# squash 7b9e0d4 fix: 修正空购物车时的边界判断
# fixup c4d5e6a chore: 调整变量命名
# 规整本地历史
# git commit --fixup=<目标提交> 生成 fixup! 提交,后续 autosquash
# git rebase -i --autosquash origin/main
# 更新自己已推送的远端分支(安全覆盖)
# git push --force-with-lease
# rebase 出错后回到 rebase 之前的状态
# git rebase --abort 尚未完成时放弃
# git reflog 找到 rebase 前的 sha
# git reset --hard <sha>
# 查冲突残留(三阶段条目应全部清空才算解决)
# git diff --check
# git ls-files -u冲突的常见成因:多数不是「同时改同一行」
第一种成因是真正的并行开发:两个人在同一批文件的同一区域做了不同的改动。解法不是更好的工具而是更好的模块划分——如果两个人经常在同一文件冲突,通常说明这个文件承担了过多职责,或者它的目录位置不对。
第二种成因是格式化工具。两个人用不同的 Prettier 配置或不同版本的格式化器改同一批文件,会产生大量与逻辑无关的冲突。这类冲突必须用 `.gitattributes` 解决:把某些目录或文件标记为 `-merge`(例如 `package-lock.json`、`*.snap` 这类机器生成文件声明为 `linguist-generated` 或 `-diff`),对需要保留的自动生成文件用合并驱动而不是文本三方合并。
第三种成因是文件名的重命名与大小写变化。macOS 与 Windows 默认文件系统不区分大小写,在 Linux 上把 `UserService.ts` 改成 `userservice.ts` 会呈现为「删除 + 新增」,Git 很容易把这理解为「别人删了文件」而报出冲突。解决办法是先用 `git mv` 做重命名并单独提交,再在后续提交里做内容修改;团队统一用 kebab-case 之类的单一命名规范,能消灭这一整类问题。
第四种成因是 `.gitignore` 的误用:先添加了忽略规则却已经跟踪了文件,后续修改仍然会显示为冲突。解决方式是对已跟踪文件使用 `git update-index --skip-worktree` 或 `git rm --cached` 加忽略,而不是只加一行忽略规则。另外,Windows 下的换行符(CRLF/LF)差异也会制造整文件冲突,用 `core.autocrlf` 与 `.gitattributes` 中的 `text=auto eol=lf` 统一处理。
# .gitattributes:把自动生成文件交给合并驱动,而不是文本三方合并
# package-lock.json linguist-generated=true -diff
# *.snap linguist-generated=true -diff
# *.min.js linguist-generated=true -diff
# 文本文件统一换行符
* text=auto eol=lf
*.bat text eol=crlf
*.ps1 text eol=crlf
# 重命名分两步:先 git mv 提交,再改内容
# git mv src/user/UserService.ts src/user/user-service.ts
# git commit -m "refactor(user): 重命名 UserService 为 kebab-case"
# 已跟踪文件需要真正停止跟踪时
# git update-index --skip-worktree .env.local
# git rm --cached -r logs/ && echo "logs/" >> .gitignore
# 检测大小写敏感差异(在默认不区分大小写的系统上排查重命名问题时)
# git ls-files | grep -i "userservice"提交信息与历史可读性
提交信息的唯一消费者是六个月后的你和同事。有效的信息包含三部分:做了什么(动词开头,祈使句,如 `fix`、`add`、`refactor`)、为什么(常常比做什么更重要,尤其是那些「看起来多余但确实必要」的改动)、以及关联(工单号、PR 链接)。常见的一个错误是用 `fix bug`、`update`、`asdf` 这类无信息量的描述,它让 `git log` 变成一串噪音,也让 `git bisect` 变得不可能。
推荐 Conventional Commits 约定:`<type>(<scope>): <subject>`,type 取 feat、fix、docs、style、refactor、test、chore、perf、build、ci 之类的有限集合,subject 用祈使句且不加句号。它带来两个直接收益:`git log --oneline --grep` 可以按类型过滤,版本发布时的变更日志可以自动生成。
提交粒度的判断标准是「这个提交能否独立回滚而不破坏系统」。一个提交如果混合了重构和功能改动,评审的人无法只批准其中一半,回滚时也无法只退掉功能而保留重构。遇到这种情况,`git rebase -i` 拆分是标准做法;提交前用 `git diff --stat` 看一眼改动规模,如果一次提交动了四五十个文件,通常说明该拆。
历史可读性的另一个手段是「噪声不进历史」。功能开关的开关、调试用的临时打印、以及与功能无关的格式化改动,都应该是独立的提交,最好还能在后续提交里被撤销。`.gitattributes` 与生成的锁文件不进入 diff 审查(前面已经配置),本地开发工具产生的临时文件应该保持在工作区而不是被提交。判断历史是否健康的信号很简单:`git log --oneline -20` 如果你读不出这二十个提交做了什么,那历史就需要治理了。
# 提交前自检:看改动范围与潜在空白/冲突问题
# git diff --stat
# git diff --check
# 拆分提交:把当前改动分成「暂存」与「未暂存」两组分别提交
# git add -p src/parser.ts
# git commit -m "fix(parser): 修正空输入时的越界读取"
# git commit -m "test(parser): 补充空输入的回归用例"
# 历史检索
# git log --oneline --grep="fix(parser)" --since="2 weeks ago"
# git log --oneline --no-merges -20 跳过合并提交看真实变更
# git log --stat -1 看单个提交动了哪些文件
# 定位问题提交
# git bisect start
# git bisect bad
# git bisect good <已知正常的提交>
# git bisect reset把规则变成可执行的流程
任何写在 wiki 里但不被工具强制的规范,都会在交付压力下被绕开。真正有效的是把这些规则下沉到钩子和服务端配置里:提交前的格式化钩子、分支命名校验、提交信息格式校验、以及保护分支禁止强推。这些都不需要额外依赖,Git 自带的钩子就够。
一个实用的做法是把「快进合并」作为硬性要求:设置 `git config --add merge.ff only` 让 `git pull` 只允许快进,遇到需要合并的分支立刻暴露出来,避免本地产生一串无用的合并提交。配合 `--no-rebase` 或显式的 `git pull --rebase` 策略,团队每个人的本地历史都能保持整洁。
大文件要单独治理。用 `.gitattributes` 或者 `git lfs` 处理二进制资源;已经误提交的大文件需要用 `git filter-repo` 改写历史,而这类操作必须全队协调(所有人重新克隆或重置),绝不能一个人在本地悄悄执行。
最后把大仓库操作纳入规范。定期 `git gc --aggressive` 之外,更重要的是避免把构建产物、依赖目录、缓存提交进仓库——这些既撑大历史又制造冲突源。在 pre-commit 钩子里加一条针对常见产物的检查,比在 code review 里靠人眼发现要可靠得多。
# .git/hooks/pre-commit(示例:拦产物 + 校验提交信息)
# #!/bin/sh
# staged=$(git diff --cached --name-only)
# if echo "$staged" | grep -Eq "(^|/)(node_modules|dist|\.next|\.nuxt|coverage)/|\.log$"; then
# echo "拒绝提交构建产物或依赖目录"
# exit 1
# fi
# msg=$(git log -1 --pretty=%s)
# case "$msg" in
# feat\(*\):*|fix\(*\):*|docs\(*\):*|chore\(*\):*|refactor\(*\):*) ;;
# *) echo "提交信息需符合 <type>(<scope>): <subject>,当前为: $msg"; exit 1 ;;
# esac
# 团队级配置(可用 git config --global 或 includeIf 按目录区分)
# git config --global merge.ff only
# git config --global pull.rebase true
# git config --global core.autocrlf input
# git config --global init.defaultBranch main
# 大文件治理
# git lfs install
# git lfs track "*.psd"
# 已误提交的大文件:需全队协调,用 git filter-repo 改写历史