分支:Git 的灵魂——像开平行宇宙一样开发
# 第3章 · 分支本质
# 一、两线作战
下午 3 点,小李正在做一个「用户积分系统」。他把代码改得满目疮痍——user.js 改了 200 行,points.js 是全新的文件,数据库表结构也重构了一半。还没 commit。现在整个项目处于一种「拆了一半、跑不起来」的状态。
突然,Leader 在群里 @ 他:
"线上登录崩了,所有用户都登不进去。紧急!你 15 分钟内搞定。"
小李愣住了。他的大脑飞速运转:
- 先把现在的改动 commit 了? → 不行,代码跑不起来,commit 一个 broken commit 太丢人。
- 把现在的改动全丢掉? → 不行,改了一下午,丢了等于白干。
- 把现在的项目复制一份? → 两个目录来回切?上次这么干,复制了 3GB 的
node_modules,硬盘差点冒烟。
他卡住了。手里正在做的功能不能丢,线上 bug 必须要修。两条时间线在同一个工作区里打架。
这不是小李一个人的困境。 任何一个程序员,只要同时做两件事,都会遇到这个问题。好消息是:Git 从设计的第一天起,就把这个问题变成了它的核心卖点。
# 二、分支本质
# 2.1 哲学问题
你正在读一本书的第 100 页。现在你想做一个实验:
「如果第 50 页的主角做了不同的选择,后面的故事会怎么发展?」
你怎么办?你不会撕了后半本重新写。你会在第 50 页夹一个书签,然后拿一本空白的笔记本,从第 50 页开始「写自己的版本」。想回到原故事线?把笔记本合上,翻到第 100 页继续读。
这个书签,就是 Git 里的分支。 它只是一个「你现在读到哪一页了」的标记。
# 2.2 四十字节指针
打开终端,亲眼看看:
mkdir branch-lab && cd branch-lab
git init
# 创建第一个 commit
echo "第1章" > story.txt
git add . && git commit -m "第1章"
echo "第2章" >> story.txt
git add . && git commit -m "第2章"
# 分支到底是什么?
cat .git/refs/heads/master
# 输出类似:8f3a2b1c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a
# ↑ 就是一个 40 字符的 hash!
这就是分支的全部内容:一个写着 commit hash 的文件。 创建分支只是写一个 40 字符的文件——毫秒级。这也是为什么 Git 的分支既快又廉价。
.git/refs/heads/
├── master → "8f3a2b1c4d5e..."
└── dev → "7e2d1c0b9a8f..."
现在你理解了第 1 章那句话:「分支只是一个可以移动的指针。」
# 2.3 时间岔路
# 当前历史:
# a1b2c3d 第2章 ← master 指向这里
# b1c2d3e 第1章
# 创建新分支:就像在第1章和第2章之间放了一个分岔路口
git branch feature/魔法世界
# 看看分支列表
git branch
# 输出:
# feature/魔法世界
# * master ← * 表示你当前在哪个分支
# 切换到新分支
git switch feature/魔法世界
# 或 git checkout feature/魔法世界(旧版命令)
git log --oneline
# a1b2c3d 第2章 ← 两个分支目前指向同一个 commit
# b1c2d3e 第1章
# 在新分支上写内容
echo "第3章(魔法世界线):主角获得了魔法" >> story.txt
git add . && git commit -m "第3章:发现魔法"
# 切回 master
git switch master
# 在 master 上写完全不同的内容
echo "第3章(现实世界线):主角考上了大学" >> story.txt
git add . && git commit -m "第3章:考上大学"
# 现在看全貌
git log --oneline --graph --all
输出:
* ccccccc 第3章:考上大学 ← master
| * ddddddd 第3章:发现魔法 ← feature/魔法世界
|/
* a1b2c3d 第2章 ← 分岔点
* b1c2d3e 第1章
同一天下午 3 点,你的项目分裂成了两个平行宇宙。 一个世界里主角考上了大学,另一个世界里主角成为了魔法师。而这一切,只花了几个 40 字节的指针。
# 三、日常操作
# 3.1 创建切换
git switch -c feature/用户积分 # 创建 + 切换(推荐)
# 等价旧命令:git checkout -b feature/用户积分
# 3.2 切换须知
# 切换前,工作区必须是干净的(或者用 stash,第 6 章讲)
git status
# 如果有未 commit 的修改,Git 会阻止切换
# 查看所有分支(带最新 commit)
git branch -v
# 查看哪些分支已经合并到当前分支
git branch --merged
# 这些可以安全删除
# 查看尚未合并的分支
git branch --no-merged
# 删之前想一想
# 3.3 删除分支
git branch -d feature/魔法世界 # 安全删除(已合并的)
git branch -D feature/魔法世界 # 强制删除(没合并也删)
# 3.4 命名惯例
好的文件名让人一眼看懂,好的分支名也一样:
feature/用户积分 # 新功能
hotfix/登录崩溃 # 紧急修复
release/v1.2.0 # 发布准备
bugfix/分页失效 # bug 修复
experiment/新算法测试 # 实验性分支
# 四、合并分支
分支分开了,总得合回来。Git 合并有两种截然不同的方式。
# 先准备实验环境
mkdir merge-lab && cd merge-lab && git init
echo "const app = (() => {" > app.js
echo " return { name: 'My App', version: '1.0' };" >> app.js
echo "})();" >> app.js
git add . && git commit -m "init: 应用骨架"
# 从 master 开出 feature 分支
git switch -c feature/添加问候
echo "function greet() { return 'Hello!'; }" >> app.js
git add . && git commit -m "feat: 添加问候函数"
# 切回 master,做点别的
git switch master
echo "function version() { return '1.0'; }" >> app.js
git add . && git commit -m "feat: 添加版本函数"
现在的历史:
* eeeeeee feat: 添加版本函数 ← master
| * fffffff feat: 添加问候函数 ← feature/添加问候
|/
* aaaaaaa init: 应用骨架
# 4.1 快进合并
当 master 没有自己的新 commit 时(即 master 直接是 feature 的祖先),Git 只需要把 master 指针「快进」到 feature 的位置。
# 先模拟一个"纯净"场景:master 没有新提交,feature 有
git switch master
git reset --hard aaaaaaa # 退回到 init
git switch -c feature/简单功能
echo "const DEBUG = true;" >> app.js
git add . && git commit -m "feat: 添加调试开关"
git switch master
git merge feature/简单功能
# 输出:Fast-forward
# 没有新的 merge commit!master 直接移到了 feature 的位置
Fast-forward 的结果:历史是一条直线,没有合并痕迹。 干净,但你看不出「这里曾经有个分支」。
# 4.2 三方合并
回到之前的场景——master 和 feature 各自有独立的提交:
git switch master
git merge feature/添加问候
这次 Git 会弹出一个编辑器让你写 merge commit message。因为它不能简单地「快进」——master 和 feature 分叉后各自前行了,Git 需要把两个分支的改动综合起来。
git log --oneline --graph
# 输出:
# * ggggggg Merge branch 'feature/添加问候' ← 新的合并 commit
# |\
# | * fffffff feat: 添加问候函数
# * | eeeeeee feat: 添加版本函数
# |/
# * aaaaaaa init: 应用骨架
这个结果比 Fast-forward 多了一个「合并提交」。它完整记录了:branch 什么时候创建的、在它上面做了什么、什么时候合回来的。
# 4.3 推荐操作
git merge --no-ff feature/简单功能
即使可以快进,也强制生成一个 merge commit。好处:以后看历史,一眼就知道「这里曾经有个 feature 分支」。团队协作强烈推荐。
# 五、合并冲突
# 5.1 冲突原因
冲突的本质很简单:Git 很聪明,但没那么聪明。
- ✅ Git 能自动合并:两个分支改了不同的文件,或同一文件的不同行。
- ❌ Git 不能自动合并:两个分支改了同一文件的同一行。Git 不知道该保留谁的,只能举手投降:"你来决定。"
# 5.2 制造冲突
mkdir conflict-lab && cd conflict-lab && git init
echo "let config = {" > config.js
echo " theme: 'light'," >> config.js
echo " lang: 'zh'" >> config.js
echo "};" >> config.js
git add . && git commit -m "init: 基础配置"
# 创建两个分支,各自改同一行
git switch -c feature/暗黑模式
# 把 theme 改成 dark
sed -i '' "s/theme: 'light'/theme: 'dark'/" config.js
git add . && git commit -m "feat: 默认暗黑模式"
git switch master
# master 上把 theme 改成 auto
sed -i '' "s/theme: 'light'/theme: 'auto'/" config.js
git add . && git commit -m "feat: 自适应主题"
现在试试合并:
git merge feature/暗黑模式
输出:
Auto-merging config.js
CONFLICT (content): Merge conflict in config.js
Automatic merge failed; fix conflicts and then commit the result.
别慌! 这不是错误,这只是 Git 在说「你来决定」。
# 5.3 冲突模样
cat config.js
let config = {
<<<<<<< HEAD
theme: 'auto', ← master 的版本(HEAD 是你当前所在分支)
=======
theme: 'dark', ← feature/暗黑模式 的版本
>>>>>>> feature/暗黑模式
lang: 'zh'
};
解读:
<<<<<<< HEAD到=======之间:当前分支(master)的内容=======到>>>>>>> feature/暗黑模式之间:被合并分支的内容- 你的任务:把这段混乱的标记,改成一个"综合了两边优点"的最终版本。
# 5.4 解决策略
策略 A:保留 master 的
# 手动编辑 config.js,删掉冲突标记,只保留 theme: 'auto'
编辑 config.js:
let config = {
theme: 'auto',
lang: 'zh'
};
策略 B:保留 feature 的
let config = {
theme: 'dark',
lang: 'zh'
};
策略 C:融合两边的(最常用的真实场景)
let config = {
theme: 'auto', // 默认自动检测,但我们给暗黑模式选项
darkMode: true, // 新增暗黑模式开关
lang: 'zh'
};
# 5.5 完成合并
# 解决完冲突后
git add config.js # 标记「冲突已解决」
git status
# 输出:
# All conflicts fixed but you are still merging.
# (use "git commit" to conclude merge)
git commit -m "merge: 合并暗黑模式,保留自适应主题并添加暗黑开关"
# 冲突解决完成!
# 5.6 放弃合并
git merge --abort
# 一键回到合并前的干净状态。毫无痕迹,就像没操作过。
💡 心态转变:不要怕冲突。冲突不是 Git 的 bug,是 Git 的诚实——它在告诉你「这件事需要人工判断」。害怕冲突 → 不分支 → 所有代码混在一条线上 → 更混乱。拥抱冲突 → 频繁分支 → 每次合并都小且可控。
# 六、变基操作
# 6.1 历史混乱
想象你维护一个功能分支两周,每天都有别人往 master 合新代码:
* merge #14
|\
| * feature commit #3
* | master commit #14
| * feature commit #2
* | master commit #13
| * feature commit #1
|/
三个月后,没人能看懂这张蜘蛛网。rebase 的使命就是把这个蜘蛛网捋成一条直线。
# 6.2 工作原理
rebase 的本质:把你分支上的 commit "搬"到目标分支的最新 commit 之后,然后一个个重新应用。
mkdir rebase-lab && cd rebase-lab && git init
echo "第一版" > code.txt
git add . && git commit -m "v1"
git switch -c feature
echo "功能A" >> code.txt
git add . && git commit -m "feat: A"
echo "功能B" >> code.txt
git add . && git commit -m "feat: B"
git switch master
# 模拟别人在 master 上推了新代码
echo "线上修复" >> code.txt
git add . && git commit -m "hotfix: 紧急修复"
当前历史(使用 git log --oneline --graph --all):
* hhhhhhh hotfix: 紧急修复 ← master
| * ggggggg feat: B ← feature
| * fffffff feat: A
|/
* aaaaaaa v1
开始 rebase:
git switch feature
git rebase master
Git 做的三件事:
- 🚚 把 feature 上的 A 和 B 「拆下来」(暂时保存)
- 📍 把 feature 的指针移到 master 的最新点
- 📥 把 A 和 B 按照顺序「重新安装」上去
结果:
* ggggggg' feat: B ← feature(重新应用,hash 变了)
* fffffff' feat: A ← 重新应用
* hhhhhhh hotfix: 紧急修复 ← master
* aaaaaaa v1
历史变成了一条直线——和线性开发一模一样,看不出曾经分叉过。
# 6.3 黄金法则
绝不对已经 push 到公共仓库的 commit 执行 rebase。
原因很简单:rebase 会改写 commit hash。如果你改了已经 push 的 commit,别人的本地仓库就会和你产生不可调和的冲突。团队协作中,破坏公共历史的代价远大于历史不整洁的代价。
✅ 私有分支上 rebase → 整理好再 push → 完美
❌ 公共分支上 rebase → 别人的仓库全乱 → 灾难
# 七、交互变基
你做了一个 feature,提交了 5 次——这 5 次里有「修 typo」「加注释」「真正功能」「临时 debug」「去掉 debug」。推给团队的时候,应该是一个干净、专业的提交列表,而不是你的草稿本。
# 7.1 整理分支
mkdir rebase-i-lab && cd rebase-i-lab && git init
echo "const user = {};" > user.js
git add . && git commit -m "init"
# 模拟混乱的开发过程
echo "function login() {}" >> user.js
git add . && git commit -m "开始写登录"
echo "function logout() {}" >> user.js
git add . && git commit -m "加一个登出 忘了写"
# 写错了
echo "asdfasdf" >> user.js
git add . && git commit -m "tmp: debug日志 等下删"
# 修复
# 删除 debug 日志
sed -i '' '/asdfasdf/d' user.js
git add . && git commit -m "修一下"
echo "function validate() {}" >> user.js
git add . && git commit -m "feat: 添加输入校验"
现在的历史:
e9e9e9e feat: 添加输入校验
d8d8d8d 修一下
c7c7c7c tmp: debug日志 等下删 ← 不该存在
b6b6b6b 加一个登出 忘了写 ← message 太随意
a5a5a5a 开始写登录
0000000 init
用交互式 rebase 整理:
git rebase -i HEAD~5
弹出编辑器:
pick a5a5a5a 开始写登录
pick b6b6b6b 加一个登出 忘了写
pick c7c7c7c tmp: debug日志 等下删
pick d8d8d8d 修一下
pick e9e9e9e feat: 添加输入校验
# 7.2 四种操作
修改为:
pick a5a5a5a 开始写登录
squash b6b6b6b 加一个登出 忘了写 ← squash:合并到上一个 commit
drop c7c7c7c tmp: debug日志 等下删 ← drop:直接删除
squash d8d8d8d 修一下 ← 也合并进去
reword e9e9e9e feat: 添加输入校验 ← reword:改 message
保存退出后,Git 会让你编辑合并后的 commit message:
# 第一个合并后的 commit
feat: 实现用户登录和登出功能 ← 新的、专业的 message
# 第二个
feat: 添加输入校验
最终结果:
git log --oneline
# 输出:
# xxxxxxx feat: 添加输入校验
# yyyyyyy feat: 实现用户登录和登出功能
# 0000000 init
5 个凌乱的 commit → 2 个干净的 commit。推给团队时,就像你一次性就写对了。
# 7.3 操作速查表
| 操作 | 含义 | 什么时候用 |
|---|---|---|
pick | 保留这个 commit 不变 | 默认 |
reword | 改 commit message | message 写得不好 |
squash | 合并到上一个 commit,保留 message | 把多个小 commit 合成一个 |
fixup | 合并到上一个 commit,丢弃 message | 把「修 typo」合并到「真正功能」 |
drop | 删除这个 commit | 临时 debug commit 删掉 |
edit | 停下来让你手动改这个 commit 的内容 | 只想改其中几个文件 |
# 八、终极对比
| 维度 | merge | rebase |
|---|---|---|
| 历史形态 | 保留真实分叉记录 | 线性历史,看起来像没分过叉 |
| commit hash | 不变 | 重新生成(变了) |
| 冲突解决方式 | 一次性解决全部冲突 | 每个 commit 依次解决冲突 |
| 适合场景 | 公共分支合并、PR 合并 | 整理私有分支、同步上游 |
| 可追溯性 | 保留了「何时分支的」 | 丢失了分支信息 |
| 安全性 | 安全 | 遵循黄金法则才安全 |
一句话建议:日常往团队分支合代码用 merge,整理自己的分支历史用 rebase。
# 九、综合实战
# 场景设定
你是小李,公司电商平台要开发「优惠券系统」。同时,线上报了一个紧急 bug:商品详情页的图片全部 404。
# 实战开始
# ==================== Phase 1:初始化项目 ====================
mkdir ecommerce && cd ecommerce && git init
echo 'const app = {
name: "电商平台",
version: "1.0.0"
};' > app.js
echo 'const products = [
{ id: 1, name: "iPhone", price: 6999 },
{ id: 2, name: "MacBook", price: 12999 }
];' > products.js
echo 'function getProductImage(id) {
return `/images/products/${id}.jpg`;
}' > images.js
git add . && git commit -m "init: 电商平台基础骨架"
# ==================== Phase 2:开始开发优惠券功能 ====================
git switch -c feature/优惠券系统
echo '
// ===== 优惠券模块 =====
const coupons = [];
function createCoupon(code, discount, minAmount) {
coupons.push({ code, discount, minAmount });
return coupons[coupons.length - 1];
}
function applyCoupon(code, orderAmount) {
const coupon = coupons.find(c => c.code === code);
if (!coupon) return { valid: false, reason: "优惠券不存在" };
if (orderAmount < coupon.minAmount) {
return { valid: false, reason: `订单金额需满${coupon.minAmount}` };
}
return { valid: true, discountedAmount: orderAmount - coupon.discount };
}
' > coupon.js
git add . && git commit -m "feat: 优惠券创建与应用逻辑"
# 继续开发...
echo '
function listCoupons() {
return coupons.map(c => ({
...c,
status: c.expired ? "已过期" : "可用"
}));
}
' >> coupon.js
git add . && git commit -m "feat: 优惠券列表查询"
# 还没开发完,优惠券的过期判断和前端页面都还没写
# 代码处于"半成品"状态
# ==================== Phase 3:紧急!线上图片 404 ====================
# Leader 紧急通知:所有商品图片 404!
# 小李检查 images.js:
cat images.js
# 发现路径写错了——应该是 /static/images/products/ 而不是 /images/products/
# 但你现在在 feature 分支上,优惠券写到一半,不能提交...
# 🎯 第一步:保存现场
git stash # 暂时保存工作现场(第 6 章细讲)
git status # 确认:working tree clean
# 🎯 第二步:切到 master,创建 hotfix 分支
git switch master
git switch -c hotfix/图片路径修复
# 🎯 第三步:修复 bug
# 把路径修正
sed -i '' 's|/images/products/|/static/images/products/|g' images.js
cat images.js # 确认修改
git add images.js
git commit -m "hotfix: 修复商品图片路径 404 问题"
# 🎯 第四步:合回 master
git switch master
git merge --no-ff hotfix/图片路径修复 -m "merge: 合并图片路径 hotfix"
# 发布!线上恢复了!
git log --oneline --graph
# 输出:
# * merge: 合并图片路径 hotfix
# |\
# | * hotfix: 修复商品图片路径 404 问题
# |/
# * init: 电商平台基础骨架
# ==================== Phase 4:回到优惠券开发 ====================
git switch feature/优惠券系统
git stash pop # 恢复现场!
# 继续开发...
echo '
function checkExpiry(coupon) {
return new Date() > new Date(coupon.expireDate);
}
' >> coupon.js
git add . && git commit -m "feat: 优惠券过期校验"
# ==================== Phase 5:同步 master 的最新代码(含 hotfix) ====================
# 你开发了两天,master 上已经合了那个 hotfix
# 需要把 master 的修复同步到你这边
git merge master
# 如果没有冲突(优惠券和图片路径是不同文件),直接成功
# 如果有冲突 → 按照 5.3 节的方法解决
git log --oneline --graph
# 输出:
# * merge: 合并 master 到 feature/优惠券系统
# |\
# | * merge: 合并图片路径 hotfix ← master
# | |\
# | | * hotfix: 修复商品图片路径 404 问题
# | |/
# | * init: 电商平台基础骨架
# * | feat: 优惠券过期校验 ← feature
# * | feat: 优惠券列表查询
# * | feat: 优惠券创建与应用逻辑
# |/
# * init: 电商平台基础骨架
# ==================== Phase 6:整理 feature 分支的 commit ====================
# 推给团队之前,把 3 个 commit 整理成 1 个
git rebase -i HEAD~3
# 把后两个改成 squash:
# pick aaaaaaa feat: 优惠券创建与应用逻辑
# squash bbbbbbb feat: 优惠券列表查询
# squash ccccccc feat: 优惠券过期校验
git log --oneline
# 输出:
# xxxxxxx feat: 实现完整优惠券系统(创建、查询、过期校验)
# ...
# ==================== Phase 7:提 PR,合并到 master ====================
git push origin feature/优惠券系统
# 去 GitHub 提 Pull Request → Code Review → Merge
# 时间线回顾
3:00 PM 3:05 PM 3:20 PM 3:30 PM
──────┼──────────────────┼────────────────────┼────────────────────┼──────→
│ │ │ │
接到线上 bug 通知 git stash 保存现场 切 hotfix 修 bug bug 修复完成
优惠券写到一半 创建 hotfix 分支 改一行代码 合回 master
发布!
3:35 PM 之后的两天 两天后
──────┼──────────────────┼────────────────────┼──────→
│ │ │
git stash pop 回现场 继续开发优惠券 rebase 整理历史
继续写优惠券 把 master 合并进来 提 PR 等待审查
如果没有分支,小李只有两个选择:要么丢掉优惠券的半成品去修 bug,要么硬着头皮把半成品提交上去。无论哪种都是灾难。有了分支,两条时间线互不干扰,各自干净。
# 十、本章回顾
| 我学会了什么 | 一句话总结 |
|---|---|
| 分支的本质 | 一个 40 字节的指针文件,指向某个 commit |
| 创建/切换分支 | git switch -c <name> 创建并切换 |
| Fast-forward 合并 | 没有分叉时,master 指针直接快进 |
| 三方合并 | 分叉后各自有提交,生成 merge commit |
| 冲突的本质 | 同一行被两个分支改了,Git 不知道用哪个 |
| 解决冲突 | 编辑文件 → 删除标记 → git add → git commit |
| rebase 的原理 | 把 commit 拆下来,搬到目标分支最新点,重新安装 |
| rebase 黄金法则 | 绝不对已 push 的公共 commit 做 rebase |
| 交互式 rebase | rebase -i:squash / drop / reword / fixup |
| merge vs rebase | 公共分支用 merge,私有分支用 rebase |
🎯 核心心法:分支是 Git 最强大的武器。不要怕分支,不要怕冲突。分支多 = 隔离好 = 出问题影响面小 = 你敢放心试。 频繁开分支、及时合分支、定期清理分支,是高级工程师的基本操作。
🏃 下一章预告:现在你一个人能玩转多条时间线了。下一步,连接世界——远程仓库 & 多人协作。你会学到:
push/pull/fetch的区别、为什么fetch + merge比pull更安全、以及 Pull Request 到底是怎么回事。
# 📎 本章涉及的命令速查
# === 分支操作 ===
git branch # 查看所有本地分支
git branch -v # 带最新 commit 信息
git branch <name> # 创建分支
git switch -c <name> # 创建 + 切换(推荐)
git switch <name> # 切换分支
git branch -d <name> # 安全删除(已合并)
git branch -D <name> # 强制删除
git branch --merged # 查看已合并的分支
git branch --no-merged # 查看未合并的分支
# === 合并 ===
git merge <branch> # 标准合并
git merge --no-ff <branch> # 推荐:保留分支痕迹
git merge --abort # 放弃正在进行的合并
# === 变基 ===
git rebase <target-branch> # 把当前分支 rebase 到目标分支
git rebase --abort # 放弃正在进行的 rebase
git rebase -i HEAD~N # 交互式整理最近 N 个 commit
# === rebase -i 操作 ===
# pick → 保留这个 commit
# reword → 改 commit message
# squash → 合并到上一个 commit(保留 message)
# fixup → 合并到上一个 commit(丢弃 message)
# drop → 删除这个 commit
# edit → 停下来手改 commit 内容
# === 底层探索 ===
cat .git/refs/heads/<branch-name> # 看分支指向哪个 commit
cat .git/HEAD # 看当前在哪个分支