Skip to content

Git 分支、合并与回退 ​

S3-030必学进阶约 90 分钟JDK 21企业开发基础 · 工程工具与规范

1. 本节定位 ​

《Git 基础与 cherry-pick》 讲了"提交与推送"这些日常操作。本节讲出事之后怎么办:

text
日常:    改代码 → commit → push                    (S0-004)
出事:    合并冲突了?提交错了?回退错了?            (本节)

Git 真正的价值不在"能提交",而在"每一步都留下了痕迹,而且可以撤销" ✓

本节最重要的一句话(很多人学完 Git 都不知道):

在 Git 里,"删除"几乎都不是真的删除——只要提交过,就能找回来。 ✓

2. 学习目标 ​

完成本节后,你应该能够:

  • 说出 Git 的三个区域(工作区 / 暂存区 / 版本库)以及文件在它们之间的流转;
  • 说出"分支"的本质(它是什么数据结构);
  • 用 merge 与 rebase 整合分支,并说出两者的取舍;
  • 读懂并解决合并冲突(含冲突标记的含义);
  • 在 reset / revert / checkout 之间正确选择(依据:是否已推送);
  • 用 reflog 把"回退错了"的提交救回来 ★;
  • 说出"不要对已推送的提交做 rebase/reset"这条黄金法则及其理由。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

先看三个真实的"崩溃时刻":

场景不会 Git 的人会 Git 的人
合并时冲突了手动把别人的代码删掉重来 ✗读懂冲突标记,3 分钟解决 ✓
刚提交的东西想撤销手工把代码改回去,再提交一次"撤销" ✗reset / revert,干净利落 ✓
回退错了,把别人的提交也删了 ✗✗以为完蛋了reflog 找回来 ✓

第三个场景最典型:吓出一身冷汗,然后发现 Git 一直记得 ✓

本节的目标:让你在遇到这三件事时,知道该敲什么命令,以及为什么。

5. 核心概念 ​

5.1 三个区域 ​

text
工作区(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 分两次 ✓

5.2 分支的本质 ​

这是本节第一个需要"改变认知"的点:

分支不是"一份代码的副本"——它只是一个指向某个提交的、可移动的指针。 ✓

text
main     →  a1b2c3d
feature  →  a1b2c3d        ← 刚创建时,两个指针指向同一个提交

所以"创建分支"极快(只是新建一个 41 字节的文件),切换分支也极快(只移动 HEAD 指针 + 更新工作区)。

HEAD 是什么:它指向"当前所在的分支"(而分支指向某个提交)——所以是"二级指针" ✓

text
HEAD → main → a1b2c3d

实测:分叉后的提交图(本项目演示仓库的真实输出):

text
* 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)——两个分支从那里分开 ✓

5.3 merge vs rebase ​

两者都是"把一个分支的改动整合进来",但方式不同。

merge:保留分叉,生成一个合并提交 ​

text
*   02b8c0f (HEAD -> main) merge: 合并 feature/user-name 并解决冲突
|\                                    ← 分叉汇合
| * 42162d5 (feature/user-name) feat: feature 上追加第三行
* | 509de3a fix: main 上修复问题
|/                                    ← 分叉点
* ccbb563 feat: 追加第二行
* aa9d4e7 feat: 初始化项目

实测输出:这就是上面那张图的"合并后"版本 ✓

rebase:把分支"挪"到目标分支顶端,得到线性历史 ​

实测:rebase 前

text
* 78ce3b2 (HEAD -> main) chore: main 再次前进
| * a0de035 (feature/rebase-demo) feat: rebase 分支的提交
|/
*   02b8c0f merge: 合并 feature/user-name 并解决冲突

实测:rebase 后

text
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 不同的新提交 ✓

两者的取舍:

mergerebase
历史保留分叉(真实记录发生了什么)✓变成直线(更整洁)✓
提交 id不变 ✓变(新建提交)✗
是否改写历史❌ 不改写 ✓✅ 改写 ✗
冲突处理一次解决可能要逐个提交解决 ⚠️
适用场景公共分支、已推送的提交 ✓本地未推送的提交 ✓
可追溯性能看出"这是个合并"看不出分支曾经分叉过 ⚠️

⚠️ 黄金法则:

只对"还没有推送给别人"的提交做 rebase。 ✓

已经推送的提交,用 merge。

理由:rebase 改写历史后,你的提交 id 与别人本地的不一致 → 别人再推送时会产生混乱(需要 --force,而强推会覆盖别人的 work)✗✗

一句话记法:

merge 是"记录历史",rebase 是"整理历史"。历史已经公开了(推送了)就不能再整理 ✓

5.4 冲突:怎么产生、怎么解决 ​

实测:冲突时 Git 的输出

text
Auto-merging app.txt
CONFLICT (content): Merge conflict in app.txt
Automatic merge failed; fix conflicts and then commit the result.

实测:冲突文件的内容

text
第一行
第二行
<<<<<<< HEAD
第四行(main)
=======
第三行(feature)
>>>>>>> feature/user-name

三个标记的含义(必须记住):

标记含义
<<<<<<< HEAD冲突开始;下面是**当前分支(你所在分支)**的内容
=======分隔线
>>>>>>> feature/user-name冲突结束;上面是要合并进来的分支的内容

解决冲突的四个步骤:

text
① 打开文件,读懂两边的内容(**不要盲目删掉一边** ✗)
② 手工改成你想要的结果(**可能是"两边都要",也可能是"融合"**)
③ 删除那三行标记           ★ 忘了删会导致代码里出现 <<<<<<< 语法错误
④ git add <file> && git commit

⚠️ 最常犯的错误:直接删掉别人的改动("我的才是对的")✗

正确做法:git merge 后要问"为什么两边都改了这里"——很可能是两个人解决了同一个问题的不同部分,需要真正融合 ✓

不确定时,去看那两个提交的说明,或者直接问对方 ✓

减少冲突的三个实践:

做法说明
小步提交、频繁合并分支偏离越久,冲突越多 ✓
不要格式化整个文件一次"全文件重新格式化"会让后续所有合并都冲突 ✗✗
公共文件(如配置、常量)要谨慎大家都改它 → 冲突高发 ⚠️

5.5 回退三选一:reset / revert / checkout ​

这是本节最需要"做对选择"的地方。

命令做什么历史用在
git reset把分支指针往前移("这一段不存在")改写 ✗本地未推送的提交 ✓
git revert新增一个"反向提交"(把改动抵消掉)不改写 ✓已推送的公共分支 ✓✓
git checkout -- <file>丢弃工作区某个文件的改动不涉及还没 commit 的改动

reset 的三种模式(实测对比) ​

实测(本项目演示仓库,撤销"追加第五行"那次提交):

text
===== 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 ⚠️

revert:公共分支上的正确做法 ​

实测:

text
===== 8. revert(新增反向提交,历史不改写)=====
[main 8f1f9eb] Revert "feat: 追加第六行"
 1 file changed, 1 deletion(-)

8f1f9eb Revert "feat: 追加第六行"      ← ★ 新增的"反向提交"
d473fa5 feat: 追加第六行                ← ★ 原来的提交**还在**
e60ed1a feat: rebase 分支的提交
最后一行:第四行(main)                 ← 内容确实回退了

关键:被 revert 的提交依然在历史里 ✓ ——这就是为什么它对"已推送"的场景安全:别人的本地历史不会与你的冲突 ✓

reset vs revert 的选择流程:

text
这个提交推送了吗?
  ├─ 没有 → reset(干净)✓
  └─ 推送了(别人可能已经拉了)→ revert(安全)✓✓

5.6 reflog:误操作的后悔药 ★ ​

这是本节最实用的一招。

场景:你 reset --hard 过头了,把一个重要的提交也删掉了 ✗

实测:误操作

text
===== 9. 误操作:reset --hard HEAD~2 =====
HEAD is now at d473fa5 feat: 追加第六行

d473fa5 feat: 追加第六行
e60ed1a feat: rebase 分支的提交
        ← "一个很重要的提交"不见了 ✗

实测:reflog(Git 的"操作日记")

text
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

实测:救回来

text
===== 救回: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-foundreflog 也丢了时的最后手段(找"悬空对象")⚠️

一句话:

"我删掉了"在 Git 里通常意味着"我暂时看不见它了"。 ✓

5.7 cherry-pick:只摘一个提交 ​

bash
git checkout main
git cherry-pick a1b2c3d        # 把 a1b2c3d 这个提交"复制"到当前分支
场景说明
把 hotfix 摘到多个分支修了个紧急 bug,要同时应用到 main 与 release ✓
误提交到了错误的分支把那个提交摘到正确的分支,再在原分支删掉 ✓
只想要对方分支的某一次修改不想合并整个分支时 ✓

⚠️ 注意:cherry-pick 会创建一个新提交(id 不同)——所以"同一个改动"在两个分支上有两个不同的 id,之后合并时可能产生冲突 ⚠️

实践建议:cherry-pick 是"补救手段",不能当常规流程 ✓ 常规流程应该是"从共同祖先切分支、合并" ✓

6. 递进示例 ​

6.1 最小示例:撤销一次未推送的提交 ​

bash
git reset --soft HEAD~1          # 改动还在,重新提交即可
git commit -m "fix: 写对的提交信息"

6.2 业务示例:撤销一次已推送的提交 ​

bash
git revert a1b2c3d               # 新增反向提交
git push

6.3 反例:对已推送的提交做 reset + 强推 ​

bash
git reset --hard HEAD~1
git push --force                 # ✗✗ 危险

后果:

后果说明
别人的本地历史与远程不一致他们 pull 会报错或产生奇怪的合并 ✗
可能覆盖别人的提交如果别人在你 reset 之后推送过 ✗✗
代码丢失--hard 丢的改动没有备份 ✗

什么时候可以强推:

情况可以吗
自己的 feature 分支(只有你一个人在用)✅ 可以(但先确认没人基于它工作)✓
main / develop 等公共分支❌ 绝对不行 ✗✗
不确定不要强推,改用 revert ✓

如果必须强推,用更安全的版本:

bash
git push --force-with-lease      # ✓ 只在"远程没有被别人更新过"时才推

--force-with-lease 是 --force 的安全替代——它会在"有人推过新东西"时拒绝推送 ✓

把 --force 换成 --force-with-lease 应当成为习惯 ✓

7. 完整可运行命令 ​

本节没有单独的 labs 代码——本仓库自己就是最好的实验场(多模块、几十次提交、多个分支)。

⚠️ 但练习"回退"这类操作前,务必先复制一份:

powershell
# 在系统临时目录里建一个演示仓库(**不要拿真实项目练 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

预期输出:

text
ccbb563 feat: 追加第二行
aa9d4e7 feat: 初始化项目

然后造分叉(在特性分支与 main 上各提交一次):

powershell
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

预期输出(分叉图):

text
* 509de3a (HEAD -> main) fix: main 上修复问题
| * 42162d5 (feature/user-name) feat: feature 上追加第三行
|/
* ccbb563 feat: 追加第二行
* aa9d4e7 feat: 初始化项目

合并并解决冲突:

powershell
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

预期输出(汇合图):

text
*   02b8c0f (HEAD -> main) merge: 合并 feature/user-name 并解决冲突
|\
| * 42162d5 (feature/user-name) feat: feature 上追加第三行
* | 509de3a fix: main 上修复问题
|/
* ccbb563 feat: 追加第二行
* aa9d4e7 feat: 初始化项目

最后试一次"误删 + reflog 救回"(本节最值得亲手做的一遍):

powershell
"第六行" | 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                                    # ★ 救回来

预期输出:

text
===== 误操作: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: 一个很重要的提交(等下误删)
最后一行:第七行                                          ← 回来了 ✓

8. Debug 观察点 ​

  1. 看 .git/refs/heads/ 目录:每个分支就是一个文件,内容是提交的 id ——这就是"分支是指针"的物理证据 ★;
  2. 在冲突文件里加一行然后 git add:观察 git status 的变化(从 "both modified" 变成 "modified");
  3. git log --graph --oneline --all:这是理解分支结构最有用的命令,养成习惯 ✓;
  4. reset 前后各看一次 git status 与文件内容:亲手确认三种模式的差别(不要只背表格)✓;
  5. git reflog 在每次操作后看一眼:理解"Git 记录了什么" ✓。

9. 常见错误 ​

错误 1:对已推送的提交做 rebase 或 reset ​

见 5.3、6.3 节。这是团队协作里最容易引发事故的操作 ✗✗

记忆法:"推送过的提交,是别人的历史的一部分" ✓

错误 2:reset --hard 前不做备份 ​

修复:至少先记下当前 HEAD(git rev-parse HEAD),或者先建一个临时分支:

bash
git branch backup-before-reset      # ✓ 一条命令,多一份保险
git reset --hard HEAD~3

这条习惯的价值:就算 reflog 过期了,分支还在 ✓

错误 3:解决冲突时直接删掉别人的改动 ​

见 5.4 节。

错误 4:忘了删冲突标记 ​

症状:代码里出现 <<<<<<< HEAD → 编译错误(或运行时诡异行为)✗

预防:git add 前用 git diff --check ✓

bash
git diff --check        # 会报出残留的冲突标记

错误 5:在错误的分支上提交了 ​

bash
# 在 main 上提交了一个本该在 feature 上的改动

修复(三步):

bash
git log --oneline -1                    # ① 记下这个提交的 id
git checkout feature/xxx
git cherry-pick <id>                    # ② 摘到正确的分支
git checkout main
git reset --hard HEAD~1                 # ③ 在 main 上删掉(**未推送时**)

⚠️ 如果已经推送了:第 ③ 步改用 git revert ✓

错误 6:git checkout . 丢掉了所有未提交的改动 ​

bash
git checkout .            # ✗ 丢弃工作区所有改动,无法恢复

修复(预防):重要的临时改动先 git stash ✓

bash
git stash                 # 存起来
git stash pop             # 取回来

stash 的价值:它是"改动的安全暂存处"——比"先复制文件"可靠得多 ✓

10. 动手实验 ​

目标:把"分叉、合并、冲突、回退、救回"亲手做一遍。

步骤:

  1. 按第 7 节建演示仓库,完整跑一遍(重点做最后那次"误删 + reflog 救回");
  2. 对比 merge 与 rebase:在同一个演示仓库里,再建两个分支,一个用 merge 一个用 rebase,用 git log --graph 对比两次的结果(注意 rebase 那次的提交 id 变了)✓;
  3. 验证 --force-with-lease:建一个"远程"(另一个目录 git init --bare)→ 推两次 → 在别处改一下 → 观察 --force 与 --force-with-lease 的差别;
  4. 练习 stash:在一个仓库里改了文件但不提交 → git stash → git status(干净了)→ git stash pop(回来了)✓;
  5. 看 .git 目录:Get-ChildItem .git\refs\heads —— 确认"分支就是一个文件" ✓;
  6. 用本仓库练"只读"操作:git log --graph --oneline --all --decorate | Select-Object -First 20 —— 看看真实项目的提交图长什么样 ✓。

验收方式:能说出第 2 步"提交 id 为什么变"、第 5 步看到的内容。

扩展任务:模拟一次"团队协作事故"并修复:

  • 假设你 reset --hard 后强推了公共分支(在演示仓库里做);
  • 另一个"同事"(另一个 clone 目录)pull 后会怎样?观察报错;
  • 用 reflog + 正确的操作把状态恢复;
  • 说明:真实项目里遇到这种情况的正确处理流程是什么?(提示:先通知所有人暂停推送 → 用 reflog 恢复 → 让所有人重新对齐)

11. 练习题 ​

基础题

  1. 判断:Git 的分支是"一份代码的完整副本"。(对 / 错)
  2. 填空:reset --soft 会保留 ______ 与 ______;reset --hard 会连 ______ 一起清掉。
  3. 简答:已经推送到远程的提交,想撤销该用 reset 还是 revert?为什么?

代码阅读题

bash
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
  1. 这段操作里有一个隐患是什么?(提示:第二个 commit 之后直接 push)
  2. 如果在 push 之后发现"第二个提交里混进了一个调试用的临时改动",该怎么处理?给出两种方案并说明取舍。

编程题

  1. 设计一个"提交规范"并落地成检查:
    • 要求:
      • 提交信息采用 Conventional Commits(feat: / fix: / docs: / refactor: / test: / chore:);
      • 用脚本自动校验(写一个检查脚本:读取最近一次提交的信息,不符合格式就报错退出);
      • 说明为什么"提交信息要规范"(至少三条理由);
      • 说明这个检查放在哪里执行(commit 后?push 前?CI?),以及各自的优劣;
    • 回答:为什么"git commit -am 'fix'"这种提交信息是有害的?

综合任务

  1. 为团队设计一套"分支与协作规范",并说明每条的理由:
    • 背景:5 人小团队,一个 Web 项目,之前出过两次事故—— ① 有人在 main 上直接开发并强推; ② 一次 hotfix 只合进了 main,忘了合进 develop,导致下个版本又出现同样的 bug;
    • 要求:
      • 设计分支模型(有哪几类分支、各自从哪切、合回哪);
      • 针对事故 ①:设计保护规则(禁止强推 main、需要 PR、需要几人 approve);
      • 针对事故 ②:设计流程保证"hotfix 一定回到开发分支";
      • 说明 merge 与 rebase 在这个规范里各自用在哪;
      • 说明"PR 的描述里应该写什么";
    • 额外要求:说明"如果团队节奏很快(每天多次发布)",你的规范要怎么简化?(提示:GitHub Flow / Trunk-Based)
分级提示

提示 1(方向):第 1 题的方向是"分支只是一个指针"。

提示 2(关键点):第 5 题的两种方案——一种改写历史、一种不改写,取决于"这个分支有几个人在用"。

提示 3(接近答案):第 7 题事故 ② 的解法不只是"记得合",而是让流程本身保证它(比如 hotfix 必须同时提两个 PR,或用工具自动同步)。

12. 参考答案 ​

基础题
  1. 错。分支只是一个指向提交的可移动指针 ✓

证据(第 8 节的 Debug 点):.git/refs/heads/feature 就是一个 41 字节的文件,里面是一个提交 id ✓

这个认知带来什么:

推论说明
建分支极快只是新建文件,不复制代码 ✓
切分支极快只移动 HEAD + 更新工作区 ✓
分支可以很"轻"可以随手建、随手删 ✓
"分支多"不是负担与 SVN 的"目录副本"完全不同 ✓

一句话:

"分支是指针"是理解 Git 的第一道门——理解它,rebase、reset、detached HEAD 这些概念都会变得自然 ✓

  1. 暂存区的改动、工作区的改动;工作区的改动。

完整对照:

模式HEAD暂存区工作区
--soft移保留保留
--mixed移清保留
--hard移清清 ✗

记忆法:"soft 最温柔(都留着),hard 最狠(都清掉),mixed 中间" ✓

  1. 用 revert ✓

理由:

revertreset
历史新增反向提交(原提交还在)✓抹掉提交 ✗
别人的本地副本不受影响 ✓会不一致 ✗✗
是否需要强推❌ 不需要 ✓✅ 需要 ✗

判断流程:

text
提交推送了吗?
  ├─ 没推送 → reset(干净利落)✓
  └─ 已推送 → revert(安全)✓✓
     └─ 例外:这是只有你一个人在用的 feature 分支 → 可以 reset + force-with-lease ⚠️

⚠️ 一个容易忽略的点:"推送了"包括"推送到只有自己用的远程分支" ✓ ——只要远程上存在,原则上就别 reset ✓

代码阅读题(第 4、5 题)

第 4 题:隐患是"没有先 pull 再 push,且 feature 分支可能与 main 有冲突" ⚠️

具体地说,这段操作里有两个问题:

#问题说明
1git pull 只做了一次,且在切分支之前之后 main 又前进了 → 你的 feature 分支基于旧的 main ⚠️
2直接 git push 自己的分支如果远程已有这个分支且别人推过 → push 被拒绝(需要 pull --rebase 或直接 push 前先同步)

第 1 条更重要:切分支的时机决定了"起点"。如果同事在你切分支之后推了新东西到 main,你的分支就落后了 ——合并时可能冲突(尤其改了同一批文件)。

正确的习惯:

bash
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 <那个提交> 然后 pushPR 已经开了 / 别人可能看到了这个分支 ✓✓
② reset + force-with-leasegit reset --hard HEAD~1(或 git rebase -i 删掉那个提交)→ git push --force-with-lease只有你一个人在用这个分支 ✓

取舍:

revertreset
历史多一个"Revert"提交(略显啰嗦)⚠️干净 ✓
别人拉过这个分支✅ 安全❌ 会不一致 ✗
PR 里的讨论能看到"曾经提交过又撤销了"看不出曾经有过 ⚠️

实践建议:

PR 还没开、分支只有你自己用 → reset(干净);PR 已经开了、有人评论了 → revert(安全的做法是"追加"而不是"改写") ✓

理由:PR 的讨论、CI 的记录都可能引用了旧的提交 id ——改写历史后它们会"指向不存在的东西" ✗

还有第三种更简单的方案(如果那个临时改动很小):

bash
# 直接再提交一次"删掉临时改动"
git commit -m "chore: 移除调试用的临时代码"

这本质上就是"手工 revert" ✓ ——效果相同,只是提交信息更明确 ✓

原则:在"已经公开"的地方,优先选择"追加"而不是"改写" ✓

编程题(第 6 题)
bash
#!/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自动化工具不认识它 ✗
回滚时不敢下手不知道这个提交里有没有别的东西 ✗

一个真实的对比:

text
✗ "fix"
  → 半年后有人想 revert 这个提交,但不知道它改了什么,不敢动

✓ "fix(order): 修复满减时金额少算 1 分的问题"
  → 一看就知道:① 影响范围(订单)② 修的是什么(金额计算)③ 严重程度(少算 1 分,不紧急)

还有一条"软性"的理由:

提交信息是写给"未来的同事(或未来的自己)"的信 ✓

写的时候多花 10 秒,读的时候能省 10 分钟 ✓

综合任务(第 7 题)

分支模型(Git Flow 的简化版,适合 5 人团队):

分支从哪切合回哪用途生命周期
main——生产环境的代码永久 ✓
developmain—集成分支(下一个版本的内容)永久 ✓
feature/*developdevelop一个新功能功能完成后删除 ✓
release/*developmain + develop发布准备(只修 bug、改版本号)发布后删除 ✓
hotfix/*mainmain + develop ★线上紧急修复修复后删除 ✓

⚠️ 注意 hotfix 那行:它必须合回两个分支 ✓ ——这正是事故 ② 的根因 ✓

针对事故 ①(有人在 main 上直接开发并强推):

措施怎么做在哪配
保护 main 分支 ★★禁止直接 push,只能走 PRGitHub/GitLab 的 Branch Protection
禁止强推 ★关闭 "Allow force pushes"同上
必须 PR + 至少 1 人 approveRequire 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 简化示意):

yaml
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 → developmerge(或 "Squash and merge")✓都在公共分支上,不改写历史 ✓
develop → feature(同步最新代码)rebase ✓在自己的分支上、未推送 → 安全,且保持历史线性 ✓
hotfix → mainmerge ✓公共分支
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:

text
【GitHub Flow】
  main(永远可发布)
    └─ feature/*(从 main 切,PR 后合回 main,然后立即部署)

【Trunk-Based】
  main(主干,所有人每天往里合)
    └─ 短期分支(存活 < 1~2 天)或直接往 main 提交
    └─ 用 Feature Flag 控制"未完成的功能"不生效

对比:

Git FlowGitHub FlowTrunk-Based
分支数多(5 类)少(2 类)最少(主干为主)
发布频率版本化(周/月)✓随时 ✓持续 ✓
维护成本高 ✗低 ✓最低 ✓
需要 Feature Flag❌⚠️ 有时需要✅ 必须
适合版本化产品(客户端、嵌入式)SaaS / Web ✓高频发布的成熟团队

建议:

按发布频率选,而不是按"哪个更高级" ✓

  • 一天多次发布 → Trunk-Based + Feature Flag + 强 CI(每次合入都要能部署,靠自动化保证)✓
  • 一周一次 → GitHub Flow ✓
  • 有明确版本、要维护多个版本 → Git Flow ✓

这与 《MySQL 性能优化基础》 的"按场景选方案"、《JSP 与 JSTL(技术演进理解)》 的"按场景选渲染方案"是同一种判断方式 ✓

13. 掌握标准 ​

  • [ ] 能说出三个区域与文件在它们之间的流转;
  • [ ] 知道"分支是指针",并能用 .git/refs/heads 证明;
  • [ ] 会读 git log --graph 的提交图;
  • [ ] 能解决合并冲突(记住三行冲突标记的含义);
  • [ ] 能说出 merge 与 rebase 的取舍,并知道"已推送提交不能 rebase";
  • [ ] 能说出 reset 三种模式对"暂存区/工作区"的影响;
  • [ ] 能在 reset 与 revert 之间正确选择(依据:是否推送);
  • [ ] 会用 reflog 救回误删的提交 ★;
  • [ ] 知道 --force-with-lease 优于 --force;
  • [ ] 知道"提交信息规范"的价值与落地方式。

14. 扩展资源 ​

15. 下一步学习 ​

《Git Flow 与团队协作》:把分支模型与协作流程串起来。

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引