主题
界面
主题
《Git 基础与 cherry-pick》 讲了"提交与推送"这些日常操作。本节讲出事之后怎么办:
日常: 改代码 → commit → push (S0-004)
出事: 合并冲突了?提交错了?回退错了? (本节)Git 真正的价值不在"能提交",而在"每一步都留下了痕迹,而且可以撤销" ✓
本节最重要的一句话(很多人学完 Git 都不知道):
在 Git 里,"删除"几乎都不是真的删除——只要提交过,就能找回来。 ✓
完成本节后,你应该能够:
merge 与 rebase 整合分支,并说出两者的取舍;reset / revert / checkout 之间正确选择(依据:是否已推送);reflog 把"回退错了"的提交救回来 ★;git add / commit / push / pull 吗?(《Git 基础与 cherry-pick》)HEAD 是什么吗?(《Git 基础与 cherry-pick》)先看三个真实的"崩溃时刻":
| 场景 | 不会 Git 的人 | 会 Git 的人 |
|---|---|---|
| 合并时冲突了 | 手动把别人的代码删掉重来 ✗ | 读懂冲突标记,3 分钟解决 ✓ |
| 刚提交的东西想撤销 | 手工把代码改回去,再提交一次"撤销" ✗ | reset / revert,干净利落 ✓ |
| 回退错了,把别人的提交也删了 ✗✗ | 以为完蛋了 | reflog 找回来 ✓ |
第三个场景最典型:吓出一身冷汗,然后发现 Git 一直记得 ✓
本节的目标:让你在遇到这三件事时,知道该敲什么命令,以及为什么。
工作区(Working Directory)
│ git add
▼
暂存区(Staging Area / Index)
│ git commit
▼
版本库(Repository / .git)
│ git push
▼
远程仓库(Remote)四个方向的命令(记住这张图,Git 的一半命令就懂了):
| 方向 | 命令 | 作用 |
|---|---|---|
| 工作区 → 暂存区 | git add | 把改动放进"待提交清单" |
| 暂存区 → 版本库 | git commit | 生成一个提交对象 |
| 版本库 → 工作区 | git checkout -- <file> | 丢弃工作区的改动 ✗ |
| 暂存区 → 工作区 | git restore --staged <file>(或 git reset HEAD <file>) | 把文件移出暂存区(改动还在)✓ |
"暂存区"的意义(很多人不用它,直接 git commit -am):
它让你"选择一部分改动"进入这次提交 ✓
例如你改了 3 个文件,但想分成两个提交(一个修 bug、一个加功能)——用
git add分两次 ✓
这是本节第一个需要"改变认知"的点:
分支不是"一份代码的副本"——它只是一个指向某个提交的、可移动的指针。 ✓
main → a1b2c3d
feature → a1b2c3d ← 刚创建时,两个指针指向同一个提交所以"创建分支"极快(只是新建一个 41 字节的文件),切换分支也极快(只移动 HEAD 指针 + 更新工作区)。
HEAD 是什么:它指向"当前所在的分支"(而分支指向某个提交)——所以是"二级指针" ✓
HEAD → main → a1b2c3d实测:分叉后的提交图(本项目演示仓库的真实输出):
* 509de3a (HEAD -> main) fix: main 上修复问题
| * 42162d5 (feature/user-name) feat: feature 上追加第三行
|/
* ccbb563 feat: 追加第二行
* aa9d4e7 feat: 初始化项目读这张图:
| 符号 | 含义 |
|---|---|
* xxx | 一个提交 |
| ` | /` / / |
(HEAD -> main) | HEAD 指向 main,main 指向 509de3a |
(feature/user-name) | 该分支指向 42162d5 |
中间的 |/ 就是"分叉点"(ccbb563)——两个分支从那里分开 ✓
两者都是"把一个分支的改动整合进来",但方式不同。
* 02b8c0f (HEAD -> main) merge: 合并 feature/user-name 并解决冲突
|\ ← 分叉汇合
| * 42162d5 (feature/user-name) feat: feature 上追加第三行
* | 509de3a fix: main 上修复问题
|/ ← 分叉点
* ccbb563 feat: 追加第二行
* aa9d4e7 feat: 初始化项目实测输出:这就是上面那张图的"合并后"版本 ✓
实测:rebase 前
* 78ce3b2 (HEAD -> main) chore: main 再次前进
| * a0de035 (feature/rebase-demo) feat: rebase 分支的提交
|/
* 02b8c0f merge: 合并 feature/user-name 并解决冲突实测:rebase 后
Rebasing (1/1)
Successfully rebased and updated refs/heads/feature/rebase-demo.
Updating 78ce3b2..e60ed1a
Fast-forward
* e60ed1a (HEAD -> main, feature/rebase-demo) feat: rebase 分支的提交
* 78ce3b2 chore: main 再次前进
* 02b8c0f merge: 合并 feature/user-name 并解决冲突★ 关键观察:提交 id 从 a0de035 变成了 e60ed1a ✓✓
这就是"rebase 改写历史"的直接证据——它创建了内容相同但 id 不同的新提交 ✓
两者的取舍:
| merge | rebase | |
|---|---|---|
| 历史 | 保留分叉(真实记录发生了什么)✓ | 变成直线(更整洁)✓ |
| 提交 id | 不变 ✓ | 变(新建提交)✗ |
| 是否改写历史 | ❌ 不改写 ✓ | ✅ 改写 ✗ |
| 冲突处理 | 一次解决 | 可能要逐个提交解决 ⚠️ |
| 适用场景 | 公共分支、已推送的提交 ✓ | 本地未推送的提交 ✓ |
| 可追溯性 | 能看出"这是个合并" | 看不出分支曾经分叉过 ⚠️ |
⚠️ 黄金法则:
只对"还没有推送给别人"的提交做 rebase。 ✓
已经推送的提交,用 merge。
理由:rebase 改写历史后,你的提交 id 与别人本地的不一致 → 别人再推送时会产生混乱(需要
--force,而强推会覆盖别人的 work)✗✗
一句话记法:
merge 是"记录历史",rebase 是"整理历史"。历史已经公开了(推送了)就不能再整理 ✓
实测:冲突时 Git 的输出
Auto-merging app.txt
CONFLICT (content): Merge conflict in app.txt
Automatic merge failed; fix conflicts and then commit the result.实测:冲突文件的内容
第一行
第二行
<<<<<<< HEAD
第四行(main)
=======
第三行(feature)
>>>>>>> feature/user-name三个标记的含义(必须记住):
| 标记 | 含义 |
|---|---|
<<<<<<< HEAD | 冲突开始;下面是**当前分支(你所在分支)**的内容 |
======= | 分隔线 |
>>>>>>> feature/user-name | 冲突结束;上面是要合并进来的分支的内容 |
解决冲突的四个步骤:
① 打开文件,读懂两边的内容(**不要盲目删掉一边** ✗)
② 手工改成你想要的结果(**可能是"两边都要",也可能是"融合"**)
③ 删除那三行标记 ★ 忘了删会导致代码里出现 <<<<<<< 语法错误
④ git add <file> && git commit⚠️ 最常犯的错误:直接删掉别人的改动("我的才是对的")✗
正确做法:
git merge后要问"为什么两边都改了这里"——很可能是两个人解决了同一个问题的不同部分,需要真正融合 ✓不确定时,去看那两个提交的说明,或者直接问对方 ✓
减少冲突的三个实践:
| 做法 | 说明 |
|---|---|
| 小步提交、频繁合并 | 分支偏离越久,冲突越多 ✓ |
| 不要格式化整个文件 | 一次"全文件重新格式化"会让后续所有合并都冲突 ✗✗ |
| 公共文件(如配置、常量)要谨慎 | 大家都改它 → 冲突高发 ⚠️ |
这是本节最需要"做对选择"的地方。
| 命令 | 做什么 | 历史 | 用在 |
|---|---|---|---|
git reset | 把分支指针往前移("这一段不存在") | 改写 ✗ | 本地未推送的提交 ✓ |
git revert | 新增一个"反向提交"(把改动抵消掉) | 不改写 ✓ | 已推送的公共分支 ✓✓ |
git checkout -- <file> | 丢弃工作区某个文件的改动 | 不涉及 | 还没 commit 的改动 |
实测(本项目演示仓库,撤销"追加第五行"那次提交):
===== 5. reset --soft HEAD~1 =====
M app.txt ← 暂存区里有改动(第一列 M)
最后一行:第五行 ← 文件内容还在
===== 6. reset --mixed HEAD =====
Unstaged changes after reset:
M app.txt
M app.txt ← 工作区有改动(第二列 M),暂存区没有了
最后一行:第五行 ← 文件内容还在
===== 7. reset --hard HEAD =====
HEAD is now at 02b8c0f merge: 合并 feature/user-name 并解决冲突
最后一行:第四行(main) ← ✗ 文件内容没了三种模式的对照:
| 模式 | HEAD(提交) | 暂存区 | 工作区 | 文件内容 |
|---|---|---|---|---|
--soft | 移动 | 保留 | 保留 | ✅ 还在 |
--mixed(默认) | 移动 | 清空 | 保留 | ✅ 还在 |
--hard | 移动 | 清空 | 清空 | ❌ 没了 ✗ |
⚠️ --hard 是唯一会丢数据的——用之前先确认"这些改动真的不要了" ✗
怎么选:
| 我想…… | 命令 |
|---|---|
| "刚才的提交信息写错了,重新提交" | git reset --soft HEAD~1(改动还在暂存区,直接重提)✓ |
| "刚才提交的内容要改一改" | git reset --mixed HEAD~1(改动回到工作区,改完再提)✓ |
| "这次改动完全不要了" | git reset --hard HEAD~1 ⚠️ |
实测:
===== 8. revert(新增反向提交,历史不改写)=====
[main 8f1f9eb] Revert "feat: 追加第六行"
1 file changed, 1 deletion(-)
8f1f9eb Revert "feat: 追加第六行" ← ★ 新增的"反向提交"
d473fa5 feat: 追加第六行 ← ★ 原来的提交**还在**
e60ed1a feat: rebase 分支的提交
最后一行:第四行(main) ← 内容确实回退了关键:被 revert 的提交依然在历史里 ✓ ——这就是为什么它对"已推送"的场景安全:别人的本地历史不会与你的冲突 ✓
reset vs revert 的选择流程:
这个提交推送了吗?
├─ 没有 → reset(干净)✓
└─ 推送了(别人可能已经拉了)→ revert(安全)✓✓这是本节最实用的一招。
场景:你 reset --hard 过头了,把一个重要的提交也删掉了 ✗
实测:误操作
===== 9. 误操作:reset --hard HEAD~2 =====
HEAD is now at d473fa5 feat: 追加第六行
d473fa5 feat: 追加第六行
e60ed1a feat: rebase 分支的提交
← "一个很重要的提交"不见了 ✗实测:reflog(Git 的"操作日记")
d473fa5 HEAD@{0}: reset: moving to HEAD~2
1d71c1e HEAD@{1}: commit: feat: 一个很重要的提交(等下误删) ← ★ 它在这里!
8f1f9eb HEAD@{2}: revert: Revert "feat: 追加第六行"
d473fa5 HEAD@{3}: commit: feat: 追加第六行
e60ed1a HEAD@{4}: merge feature/rebase-demo: Fast-forward
78ce3b2 HEAD@{5}: checkout: moving from feature/rebase-demo to main实测:救回来
===== 救回:reset --hard 1d71c1e =====
HEAD is now at 1d71c1e feat: 一个很重要的提交(等下误删)
最后一行:第七行 ← ★ 数据回来了reflog 的原理:
每次 HEAD 移动(commit / reset / checkout / merge / rebase),Git 都会在
.git/logs/HEAD里记一条 ✓所以"回退"这个动作本身也被记录了 —— 你总能回到"回退之前" ✓
两条重要的实践:
| 要点 | 说明 |
|---|---|
reflog 是本地的 | 不会推送到远程,换台机器就没了 ✗ |
| 有过期时间 | 默认 90 天(gc.reflogExpire)✓ 所以出事要尽快处理 |
git fsck --lost-found | reflog 也丢了时的最后手段(找"悬空对象")⚠️ |
一句话:
"我删掉了"在 Git 里通常意味着"我暂时看不见它了"。 ✓
git checkout main
git cherry-pick a1b2c3d # 把 a1b2c3d 这个提交"复制"到当前分支| 场景 | 说明 |
|---|---|
| 把 hotfix 摘到多个分支 | 修了个紧急 bug,要同时应用到 main 与 release ✓ |
| 误提交到了错误的分支 | 把那个提交摘到正确的分支,再在原分支删掉 ✓ |
| 只想要对方分支的某一次修改 | 不想合并整个分支时 ✓ |
⚠️ 注意:cherry-pick 会创建一个新提交(id 不同)——所以"同一个改动"在两个分支上有两个不同的 id,之后合并时可能产生冲突 ⚠️
实践建议:cherry-pick 是"补救手段",不能当常规流程 ✓ 常规流程应该是"从共同祖先切分支、合并" ✓
git reset --soft HEAD~1 # 改动还在,重新提交即可
git commit -m "fix: 写对的提交信息"git revert a1b2c3d # 新增反向提交
git pushgit reset --hard HEAD~1
git push --force # ✗✗ 危险后果:
| 后果 | 说明 |
|---|---|
| 别人的本地历史与远程不一致 | 他们 pull 会报错或产生奇怪的合并 ✗ |
| 可能覆盖别人的提交 | 如果别人在你 reset 之后推送过 ✗✗ |
| 代码丢失 | --hard 丢的改动没有备份 ✗ |
什么时候可以强推:
| 情况 | 可以吗 |
|---|---|
| 自己的 feature 分支(只有你一个人在用) | ✅ 可以(但先确认没人基于它工作)✓ |
| main / develop 等公共分支 | ❌ 绝对不行 ✗✗ |
| 不确定 | 不要强推,改用 revert ✓ |
如果必须强推,用更安全的版本:
git push --force-with-lease # ✓ 只在"远程没有被别人更新过"时才推
--force-with-lease是--force的安全替代——它会在"有人推过新东西"时拒绝推送 ✓把
--force换成--force-with-lease应当成为习惯 ✓
本节没有单独的 labs 代码——本仓库自己就是最好的实验场(多模块、几十次提交、多个分支)。
⚠️ 但练习"回退"这类操作前,务必先复制一份:
# 在系统临时目录里建一个演示仓库(**不要拿真实项目练 reset --hard**)
$repo = "$env:TEMP\kb-git-demo"
Remove-Item -Recurse -Force $repo -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Path $repo | Out-Null
cd $repo
git init -b main
git config user.email "demo@example.com"
git config user.name "Git Demo"
# 提交两次
"第一行" | Set-Content -Encoding utf8 app.txt
git add . ; git commit -m "feat: 初始化项目"
"第二行" | Add-Content -Encoding utf8 app.txt
git add . ; git commit -m "feat: 追加第二行"
git log --oneline预期输出:
ccbb563 feat: 追加第二行
aa9d4e7 feat: 初始化项目然后造分叉(在特性分支与 main 上各提交一次):
git checkout -b feature/user-name
"第三行(feature)" | Add-Content -Encoding utf8 app.txt
git add . ; git commit -m "feat: feature 上追加第三行"
git checkout main
"第四行(main)" | Add-Content -Encoding utf8 app.txt
git add . ; git commit -m "fix: main 上修复问题"
git log --graph --oneline --all --decorate预期输出(分叉图):
* 509de3a (HEAD -> main) fix: main 上修复问题
| * 42162d5 (feature/user-name) feat: feature 上追加第三行
|/
* ccbb563 feat: 追加第二行
* aa9d4e7 feat: 初始化项目合并并解决冲突:
git merge feature/user-name
# CONFLICT (content): Merge conflict in app.txt
Get-Content -Encoding utf8 app.txt # 看冲突标记
# 手工改成想要的内容(并删掉 <<<<<<< ======= >>>>>>> 三行)
Set-Content -Encoding utf8 app.txt -Value @("第一行","第二行","第三行(feature)","第四行(main)")
git add app.txt
git commit -m "merge: 合并 feature/user-name 并解决冲突"
git log --graph --oneline --all --decorate预期输出(汇合图):
* 02b8c0f (HEAD -> main) merge: 合并 feature/user-name 并解决冲突
|\
| * 42162d5 (feature/user-name) feat: feature 上追加第三行
* | 509de3a fix: main 上修复问题
|/
* ccbb563 feat: 追加第二行
* aa9d4e7 feat: 初始化项目最后试一次"误删 + reflog 救回"(本节最值得亲手做的一遍):
"第六行" | Add-Content -Encoding utf8 app.txt
git add . ; git commit -m "feat: 追加第六行"
git revert --no-edit HEAD # 反做,历史保留
"第七行" | Add-Content -Encoding utf8 app.txt
git add . ; git commit -m "feat: 一个很重要的提交(等下误删)"
$imp = git rev-parse HEAD # 先记下这个 id
git reset --hard HEAD~2 # 误删
git log --oneline # 重要提交不见了
git reflog # ★ 在日记里找到它
git reset --hard $imp # ★ 救回来预期输出:
===== 误操作:reset --hard HEAD~2 =====
HEAD is now at d473fa5 feat: 追加第六行
d473fa5 feat: 追加第六行
e60ed1a feat: rebase 分支的提交
===== reflog(操作日记)=====
d473fa5 HEAD@{0}: reset: moving to HEAD~2
1d71c1e HEAD@{1}: commit: feat: 一个很重要的提交(等下误删) ← 它在这里
8f1f9eb HEAD@{2}: revert: Revert "feat: 追加第六行"
...
===== 救回:reset --hard 1d71c1e =====
HEAD is now at 1d71c1e feat: 一个很重要的提交(等下误删)
最后一行:第七行 ← 回来了 ✓.git/refs/heads/ 目录:每个分支就是一个文件,内容是提交的 id ——这就是"分支是指针"的物理证据 ★;git add:观察 git status 的变化(从 "both modified" 变成 "modified");git log --graph --oneline --all:这是理解分支结构最有用的命令,养成习惯 ✓;git status 与文件内容:亲手确认三种模式的差别(不要只背表格)✓;git reflog 在每次操作后看一眼:理解"Git 记录了什么" ✓。见 5.3、6.3 节。这是团队协作里最容易引发事故的操作 ✗✗
记忆法:"推送过的提交,是别人的历史的一部分" ✓
reset --hard 前不做备份 修复:至少先记下当前 HEAD(git rev-parse HEAD),或者先建一个临时分支:
git branch backup-before-reset # ✓ 一条命令,多一份保险
git reset --hard HEAD~3这条习惯的价值:就算 reflog 过期了,分支还在 ✓
见 5.4 节。
症状:代码里出现 <<<<<<< HEAD → 编译错误(或运行时诡异行为)✗
预防:git add 前用 git diff --check ✓
git diff --check # 会报出残留的冲突标记# 在 main 上提交了一个本该在 feature 上的改动修复(三步):
git log --oneline -1 # ① 记下这个提交的 id
git checkout feature/xxx
git cherry-pick <id> # ② 摘到正确的分支
git checkout main
git reset --hard HEAD~1 # ③ 在 main 上删掉(**未推送时**)⚠️ 如果已经推送了:第 ③ 步改用 git revert ✓
git checkout . 丢掉了所有未提交的改动 git checkout . # ✗ 丢弃工作区所有改动,无法恢复修复(预防):重要的临时改动先 git stash ✓
git stash # 存起来
git stash pop # 取回来stash 的价值:它是"改动的安全暂存处"——比"先复制文件"可靠得多 ✓
目标:把"分叉、合并、冲突、回退、救回"亲手做一遍。
步骤:
git log --graph 对比两次的结果(注意 rebase 那次的提交 id 变了)✓;--force-with-lease:建一个"远程"(另一个目录 git init --bare)→ 推两次 → 在别处改一下 → 观察 --force 与 --force-with-lease 的差别;stash:在一个仓库里改了文件但不提交 → git stash → git status(干净了)→ git stash pop(回来了)✓;.git 目录:Get-ChildItem .git\refs\heads —— 确认"分支就是一个文件" ✓;git log --graph --oneline --all --decorate | Select-Object -First 20 —— 看看真实项目的提交图长什么样 ✓。验收方式:能说出第 2 步"提交 id 为什么变"、第 5 步看到的内容。
扩展任务:模拟一次"团队协作事故"并修复:
reset --hard 后强推了公共分支(在演示仓库里做);pull 后会怎样?观察报错;reflog + 正确的操作把状态恢复;基础题
reset --soft 会保留 ______ 与 ______;reset --hard 会连 ______ 一起清掉。reset 还是 revert?为什么?代码阅读题
git checkout main
git pull
git checkout -b feature/login
# ... 改了 3 个文件
git add LoginController.java
git commit -m "feat: 登录接口"
git add LoginService.java UserService.java
git commit -m "feat: 登录业务逻辑"
git push -u origin feature/login编程题
feat: / fix: / docs: / refactor: / test: / chore:);git commit -am 'fix'"这种提交信息是有害的?综合任务
提示 1(方向):第 1 题的方向是"分支只是一个指针"。
提示 2(关键点):第 5 题的两种方案——一种改写历史、一种不改写,取决于"这个分支有几个人在用"。
提示 3(接近答案):第 7 题事故 ② 的解法不只是"记得合",而是让流程本身保证它(比如 hotfix 必须同时提两个 PR,或用工具自动同步)。
证据(第 8 节的 Debug 点):.git/refs/heads/feature 就是一个 41 字节的文件,里面是一个提交 id ✓
这个认知带来什么:
| 推论 | 说明 |
|---|---|
| 建分支极快 | 只是新建文件,不复制代码 ✓ |
| 切分支极快 | 只移动 HEAD + 更新工作区 ✓ |
| 分支可以很"轻" | 可以随手建、随手删 ✓ |
| "分支多"不是负担 | 与 SVN 的"目录副本"完全不同 ✓ |
一句话:
"分支是指针"是理解 Git 的第一道门——理解它,rebase、reset、detached HEAD 这些概念都会变得自然 ✓
完整对照:
| 模式 | HEAD | 暂存区 | 工作区 |
|---|---|---|---|
--soft | 移 | 保留 | 保留 |
--mixed | 移 | 清 | 保留 |
--hard | 移 | 清 | 清 ✗ |
记忆法:"soft 最温柔(都留着),hard 最狠(都清掉),mixed 中间" ✓
revert ✓理由:
revert | reset | |
|---|---|---|
| 历史 | 新增反向提交(原提交还在)✓ | 抹掉提交 ✗ |
| 别人的本地副本 | 不受影响 ✓ | 会不一致 ✗✗ |
| 是否需要强推 | ❌ 不需要 ✓ | ✅ 需要 ✗ |
判断流程:
提交推送了吗?
├─ 没推送 → reset(干净利落)✓
└─ 已推送 → revert(安全)✓✓
└─ 例外:这是只有你一个人在用的 feature 分支 → 可以 reset + force-with-lease ⚠️⚠️ 一个容易忽略的点:"推送了"包括"推送到只有自己用的远程分支" ✓ ——只要远程上存在,原则上就别 reset ✓
第 4 题:隐患是"没有先 pull 再 push,且 feature 分支可能与 main 有冲突" ⚠️
具体地说,这段操作里有两个问题:
| # | 问题 | 说明 |
|---|---|---|
| 1 | git pull 只做了一次,且在切分支之前 | 之后 main 又前进了 → 你的 feature 分支基于旧的 main ⚠️ |
| 2 | 直接 git push 自己的分支 | 如果远程已有这个分支且别人推过 → push 被拒绝(需要 pull --rebase 或直接 push 前先同步) |
第 1 条更重要:切分支的时机决定了"起点"。如果同事在你切分支之后推了新东西到 main,你的分支就落后了 ——合并时可能冲突(尤其改了同一批文件)。
正确的习惯:
git checkout main
git pull # ★ 切分支前先同步
git checkout -b feature/login
# ... 开发
git fetch origin # ★ 提交前看看远程有没有新东西
git rebase origin/main # ★ 把自己的提交"挪"到最新的 main 上(**未推送时安全** ✓)
git push -u origin feature/login最后那个 rebase origin/main 很关键:
它让"你的分支基于最新的 main" —— 提交 PR 时冲突最少,且 CI 跑的是"合并后"的真实状态 ✓
这就是 5.3 节"rebase 用于本地未推送提交"的实际应用 ✓
第 5 题:两种方案。
| 方案 | 做法 | 适用 |
|---|---|---|
| ① revert(推荐) | git revert <那个提交> 然后 push | PR 已经开了 / 别人可能看到了这个分支 ✓✓ |
| ② reset + force-with-lease | git reset --hard HEAD~1(或 git rebase -i 删掉那个提交)→ git push --force-with-lease | 只有你一个人在用这个分支 ✓ |
取舍:
| revert | reset | |
|---|---|---|
| 历史 | 多一个"Revert"提交(略显啰嗦)⚠️ | 干净 ✓ |
| 别人拉过这个分支 | ✅ 安全 | ❌ 会不一致 ✗ |
| PR 里的讨论 | 能看到"曾经提交过又撤销了" | 看不出曾经有过 ⚠️ |
实践建议:
PR 还没开、分支只有你自己用 → reset(干净);PR 已经开了、有人评论了 → revert(安全的做法是"追加"而不是"改写") ✓
理由:PR 的讨论、CI 的记录都可能引用了旧的提交 id ——改写历史后它们会"指向不存在的东西" ✗
还有第三种更简单的方案(如果那个临时改动很小):
# 直接再提交一次"删掉临时改动"
git commit -m "chore: 移除调试用的临时代码"这本质上就是"手工 revert" ✓ ——效果相同,只是提交信息更明确 ✓
原则:在"已经公开"的地方,优先选择"追加"而不是"改写" ✓
#!/usr/bin/env bash
# .git/hooks/commit-msg —— 提交信息的格式校验(Git 原生钩子)
#
# 安装:把本文件放到 .git/hooks/commit-msg 并 chmod +x
set -euo pipefail
MSG_FILE="$1"
MSG="$(head -n 1 "$MSG_FILE")"
# Conventional Commits 的简化版:type(scope)?!: description
PATTERN='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-zA-Z0-9_./-]+\))?!?: .{1,72}$'
if [[ ! "$MSG" =~ $PATTERN ]]; then
echo ""
echo "✗ 提交信息格式不正确:"
echo " $MSG"
echo ""
echo "正确格式:<type>(<scope>): <描述>"
echo " type 允许:feat fix docs style refactor perf test build ci chore revert"
echo " 示例:feat(login): 支持手机号登录"
echo " 示例:fix: 修复订单金额计算错误"
exit 1
fi
# 描述不要以句号结尾(风格统一)
if [[ "$MSG" =~ [。..]$ ]]; then
echo "✗ 提交信息的描述不要以句号结尾:"
echo " $MSG"
exit 1
fi
echo "✓ 提交信息格式检查通过"为什么"提交信息要规范"(三条核心理由):
| 理由 | 说明 |
|---|---|
| ① 能自动生成变更日志 ★ | feat: / fix: 可以直接生成 CHANGELOG(很多工具支持,如 standard-version)✓ |
| ② 能自动判断版本号 | 有 feat → 次版本号 +1;只有 fix → 修订号 +1(语义化版本)✓ |
| ③ 排查问题时能看懂 ★★ | git log / git blame 时,"fix: 修复金额计算错误"比"改一下"有用一万倍 ✓ |
第 ③ 条最重要:
半年后你要找"那次改动的背景"
git log --grep="金额"—— 规范的提交信息能让你 30 秒找到;不规范的只能逐个看 diff ✓
放在哪里执行:
| 位置 | 优点 | 缺点 |
|---|---|---|
commit-msg 钩子(本地)★ | 最早反馈(提交时就拦住)✓ | 需要每个人安装(.git/hooks 不进版本库)⚠️ |
pre-push 钩子(本地) | 能检查"多个提交" | 反馈更晚 |
| CI(服务端)★ | 强制生效(本地绕过也没用)✓ | 反馈晚(已经推上去了) |
| 两者都做 ✓ | 本地早发现 + CI 兜底 | — |
推荐:用工具管理钩子(如 husky + commitlint,或 Java 侧的 pre-commit 框架)——让钩子能随仓库分发,而不是靠文档告诉大家"请手动复制" ✓
"git commit -am 'fix' 为什么有害":
| 害处 | 说明 |
|---|---|
| 看不出改了什么 | "fix" 修的什么?哪个模块?三个月后没人知道 ✗ |
git log 无法检索 | 想找"登录相关的改动"搜不到 ✗ |
git blame 时没有上下文 | 看到一行奇怪的代码,提交信息是"fix"——不知道当时为什么这么写 ✗✗ |
| 无法生成 CHANGELOG | 自动化工具不认识它 ✗ |
| 回滚时不敢下手 | 不知道这个提交里有没有别的东西 ✗ |
一个真实的对比:
✗ "fix"
→ 半年后有人想 revert 这个提交,但不知道它改了什么,不敢动
✓ "fix(order): 修复满减时金额少算 1 分的问题"
→ 一看就知道:① 影响范围(订单)② 修的是什么(金额计算)③ 严重程度(少算 1 分,不紧急)还有一条"软性"的理由:
提交信息是写给"未来的同事(或未来的自己)"的信 ✓
写的时候多花 10 秒,读的时候能省 10 分钟 ✓
分支模型(Git Flow 的简化版,适合 5 人团队):
| 分支 | 从哪切 | 合回哪 | 用途 | 生命周期 |
|---|---|---|---|---|
main | — | — | 生产环境的代码 | 永久 ✓ |
develop | main | — | 集成分支(下一个版本的内容) | 永久 ✓ |
feature/* | develop | develop | 一个新功能 | 功能完成后删除 ✓ |
release/* | develop | main + develop | 发布准备(只修 bug、改版本号) | 发布后删除 ✓ |
hotfix/* | main | main + develop ★ | 线上紧急修复 | 修复后删除 ✓ |
⚠️ 注意 hotfix 那行:它必须合回两个分支 ✓ ——这正是事故 ② 的根因 ✓
针对事故 ①(有人在 main 上直接开发并强推):
| 措施 | 怎么做 | 在哪配 |
|---|---|---|
| 保护 main 分支 ★★ | 禁止直接 push,只能走 PR | GitHub/GitLab 的 Branch Protection |
| 禁止强推 ★ | 关闭 "Allow force pushes" | 同上 |
| 必须 PR + 至少 1 人 approve | Require pull request reviews | 同上 |
| CI 必须通过 | Require status checks to pass | 同上 |
| 禁止直接删分支 | 同保护规则 | 同上 |
关键:这些必须是"平台层面"的规则,而不是"口头约定" ✓
"请大家不要强推 main"是靠不住的 ——人在紧急时会忘、会图快 ✗
而"平台拒绝你的 push"是绝对的 ✓
这与 《Filter、Listener 与会话管理》 的"默认拒绝 + 白名单"、《参数校验与全局异常处理》 的"统一异常处理"是同一条原则: 把约定变成机制,而不是靠人记得。 ✓
针对事故 ②(hotfix 忘了合回 develop):
三种解法(从弱到强):
| 方案 | 做法 | 强度 |
|---|---|---|
| ① 流程约定 | "hotfix 必须提两个 PR" | ⚠️ 靠人 ✗ |
| ② PR 模板里加检查项 ★ | hotfix 的 PR 模板里写"□ 已同步到 develop",不勾就不能合并 | ✅ 中 |
| ③ 用工具自动同步 ★★ | CI 在 hotfix 合并到 main 后,自动开一个"同步到 develop"的 PR | ✅✅ 强 |
推荐 ② + ③ 组合:模板提醒(成本低)+ 自动 PR(兜底) ✓
方案 ③ 的实现思路(GitHub Actions 简化示意):
name: hotfix-backmerge
on:
pull_request:
types: [closed]
branches: [main]
jobs:
backmerge:
if: github.event.pull_request.merged && startsWith(github.head_ref, 'hotfix/')
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- name: 自动开一个"合回 develop"的 PR
run: gh pr create --base develop --head main --title "chore: 同步 hotfix 回 develop" --body "由 CI 自动创建,请检查后合并"核心思路:让"必须做的事"变成"系统帮你做了第一步" ✓ ——人只需要确认,不需要记得 ✓
merge 与 rebase 在这个规范里各自用在哪:
| 场景 | 用 | 理由 |
|---|---|---|
| feature → develop | merge(或 "Squash and merge")✓ | 都在公共分支上,不改写历史 ✓ |
| develop → feature(同步最新代码) | rebase ✓ | 在自己的分支上、未推送 → 安全,且保持历史线性 ✓ |
| hotfix → main | merge ✓ | 公共分支 |
| feature 内部的多次提交整理 | rebase -i(交互式) ✓ | 只在自己分支上、未推送时 ✓ |
| main → develop(回合) | merge ✓ | 公共分支 |
一条好记的规则:
"往下同步用 rebase(develop → feature),往上合入用 merge(feature → develop)" ✓
PR 的描述里应该写什么:
| 必写 | 说明 |
|---|---|
| ① 做了什么(一句话) | 让 reviewer 立刻知道背景 ✓ |
| ② 为什么 | 关联需求/issue(Closes #123)✓ |
| ③ 怎么测的 | 测了哪些场景(含边界)✓ |
| ④ 有没有风险/需要特别注意的地方 ★ | 如"改了金额计算逻辑,需要回归对账" ✓ |
| ⑤ 截图/日志(UI 改动时) | 让 reviewer 不用自己跑起来看 ✓ |
⚠️ 第 ④ 条最容易被忽略但最重要:
"我改了什么"容易说,"我可能影响了什么"难说——而后者才是 reviewer 最需要知道的 ✓
如果团队节奏很快(每天多次发布):
Git Flow 会太重(release 分支、develop 分支的维护成本 > 收益)✗
改用 GitHub Flow / Trunk-Based:
【GitHub Flow】
main(永远可发布)
└─ feature/*(从 main 切,PR 后合回 main,然后立即部署)
【Trunk-Based】
main(主干,所有人每天往里合)
└─ 短期分支(存活 < 1~2 天)或直接往 main 提交
└─ 用 Feature Flag 控制"未完成的功能"不生效对比:
| Git Flow | GitHub Flow | Trunk-Based | |
|---|---|---|---|
| 分支数 | 多(5 类) | 少(2 类) | 最少(主干为主) |
| 发布频率 | 版本化(周/月)✓ | 随时 ✓ | 持续 ✓ |
| 维护成本 | 高 ✗ | 低 ✓ | 最低 ✓ |
| 需要 Feature Flag | ❌ | ⚠️ 有时需要 | ✅ 必须 |
| 适合 | 版本化产品(客户端、嵌入式) | SaaS / Web ✓ | 高频发布的成熟团队 |
建议:
按发布频率选,而不是按"哪个更高级" ✓
- 一天多次发布 → Trunk-Based + Feature Flag + 强 CI(每次合入都要能部署,靠自动化保证)✓
- 一周一次 → GitHub Flow ✓
- 有明确版本、要维护多个版本 → Git Flow ✓
这与 《MySQL 性能优化基础》 的"按场景选方案"、《JSP 与 JSTL(技术演进理解)》 的"按场景选渲染方案"是同一种判断方式 ✓
.git/refs/heads 证明;git log --graph 的提交图;--force-with-lease 优于 --force;《Git Flow 与团队协作》:把分支模型与协作流程串起来。