现在你还是那种「会 add/commit/push、但出了任何岔子就完全不知道发生了什么」的状态。一周后你能不靠搜索引擎自己推理出「这条命令会动哪几个区、改哪个指针」。这篇文章将带你实现这种可能。
先说大多数 Git 教程的问题
上来就教命令:git add 是加文件,git commit 是提交,git push 是推送……
然后某天你改坏了什么,打算撤销,一搜一堆 reset、revert、restore、checkout,完全不知道选哪个。或者同事让你 rebase 一下,你 rebase 完发现历史乱了,只能删了重建。
根本原因是:你装的是命令列表,不是心智模型。
有了心智模型,遇到没见过的情况能自己推理。没有的话,每个新命令都是新的记忆负担。
第一个模型:Git 存的是「快照」,不是「差异」
这是最需要改掉的错误直觉。
很多人(尤其用过 SVN 的)下意识觉得 Git 每次提交存的是「这次改了哪几行」——一条条 diff 叠加起来组成历史。
不是的。
每次 commit,Git 给你整个项目当时的样子拍了一张完整的照片。 差异(diff)是 Git 临时算给你看的,不是它存的东西。
这个区别决定了你对 rebase、reset、cherry-pick 的理解对不对。后面会反复用到。
第二个模型:本地有三个区,你的文件在它们之间流动
工作区(你编辑的文件) --add--> 暂存区(下一张快照的草稿) --commit--> 版本库(快照历史)
^ |
|_______________________________ checkout/restore _________________________|
- 工作区:你在编辑器里改的那些文件,Git 不一定知道你在改。
- 暂存区(Index):你
git add之后的内容,是「下一次 commit 会拍进去的草稿」。 - 版本库:commit 完的历史,一旦进了这里就很难真正消失。
git add 把改动从工作区推到暂存区,git commit 把暂存区当前的样子拍成快照存进版本库。commit 拍的是暂存区,不是工作区。
这条话值得反复念:改了文件没有 add,那个改动不会进 commit。
验证你是否真的理解了
问自己:改了 3 个文件,只 add 了其中 1 个,然后 commit,结果是什么?
答案:只有那 1 个文件的改动进了这次提交,另外两个改动还留在工作区,下次 add + commit 才会进历史。
git status 是你观察三区的窗口
# 文件同时出现在绿色(暂存区里)和红色(工作区里)
$ git status -s
MM a.txt # 第一列 M=已暂存的改动,第二列 M=add 之后又改了但还没 add
第一列看暂存区,第二列看工作区。 MM 说明暂存区里有一个版本,工作区里又有个更新的版本——add 之后再改的部分不会自动跟进去。
第三个模型:commit 是带父指针的快照,分支是便利贴,HEAD 是你的位置
这三句话把 Git 的核心数据结构说清楚了:
commit 是什么:一个不可变的对象,存着快照(tree)、指向父 commit 的指针(parent)、以及作者/信息(metadata)。用 40 位哈希唯一标识。内容变一个字节,hash 就完全不同。
分支是什么:就是一个贴在某个 commit 上的指针,是个 41 字节的文本文件(.git/refs/heads/<分支名>,里面就是一个 40 位哈希)。建分支几乎零成本——Git 鼓励你随手开分支就是这个原因。
HEAD 是什么:「你现在站在哪」的指针。通常 HEAD 指向一个分支名,分支再指向一个 commit。你每次 commit,只有 HEAD 所指的那个分支会往前移,其他分支纹丝不动。
$ git log --oneline --graph --all
* z9y8x7w (HEAD -> main) C4
* a1b2c3d (feature) C3 # feature 分支还停在 C3
* d4e5f6a C2
* 7g8h9i0 C1
养成习惯,什么时候不确定就看这张图。 git log --oneline --graph --all 是最值得形成肌肉记忆的命令,没有之一。
一个重要推论
git branch feature 只建便利贴,不切过去。你人还站在原来的分支上。
要「建了立刻切」用 git switch -c feature。
分支合并:两种情况完全不同
merge 的结果取决于两个分支有没有真正分叉。
情况一:fast-forward(快进合并)
你的 feature 分支比 main 领先,main 这段时间一步没动。这种情况 Git 直接把 main 指针往前挪,不造任何新 commit,历史是一条直线。
情况二:三方合并(true merge)
两个分支各自都有对方没有的提交,真正分叉了。Git 找到两者的共同祖先,三方对比,生成一个有两个父 commit 的 merge commit。历史看起来像两条线汇合成一点。
git log --oneline --graph 里那种分叉再汇合的图形,就是三方合并的痕迹。
冲突只在三方合并里发生,且两端改了同一个地方才会冲突。解决冲突三步:
- 手动编辑文件,清掉所有
<<<<<<</=======/>>>>>>>标记 git add <冲突文件>(告诉 Git 这个文件你解决好了)git commit
一个容易踩的坑:解决完冲突后忘了删 ======= 标记就 add 了,这些标记是普通文本,Git 不会替你清,会被当成代码提交进去。
撤销:三区视角下的 reset
很多人对 reset 的三个模式云里雾里,因为没有用三区来理解它。
git reset [模式] <commit> 做的核心事:把当前分支指针移动到 <commit>,模式决定之后暂存区和工作区要不要一起拉回来。
| 模式 | HEAD 移动 | 暂存区 | 工作区 | 一句话 |
|---|---|---|---|---|
--soft | ✅ | 不动 | 不动 | 撤提交,改动留暂存区 |
--mixed(默认) | ✅ | 重置 | 不动 | 撤提交,改动退回工作区 |
--hard | ✅ | 重置 | 重置 | 三区全拉齐,工作区改动销毁 |
记法:从 --soft 到 --hard,重置的范围从小到大。只有 --hard 会丢你没提交的工作。
三个典型场景:
# 提交信息写错了,想重新提交
git reset --soft HEAD~1
# 改动还在暂存区,直接重新 commit
# 想重新挑选哪些文件进这次提交
git reset HEAD~1 # 等价 --mixed
# 改动退到工作区,重新 add
# 这次提交是垃圾,连改动都不要了
git reset --hard HEAD~1
# ⚠️ 工作区改动一起没了,谨慎
restore 负责单个文件
reset 动的是分支指针,restore 只动文件:
git restore <file> # 撤工作区未暂存的改动(不可逆!)
git restore --staged <file> # 把文件从暂存区撤下来(取消 add,工作区不动,安全)
revert 是公共分支的唯一选择
reset 改历史——把指针往回挪,假装那些提交没发生过。只能对没推送的本地提交用。
revert 加历史——新建一个「反向提交」把改动抵消,原提交还在历史里。是已推送/公共分支撤销的唯一安全选项。
别在已推送的公共分支上 reset。 你改了历史,别人手里还是旧历史,下次 pull/push 会一片混乱。
reflog:「你把提交搞丢了」的终极后悔药
这是今天最重要的一节。
Git 有一条几乎没人告诉你的保证:只要一个提交曾经存在过,它短期内几乎不会真的消失。 commit 对象落到 .git/objects 后,就算没有任何分支指向它,也要等 30 天的宽限期才会被 GC 清掉。
reflog 记录了 HEAD 每一次移动的历史:
$ git reflog
a1b2c3d HEAD@{0}: reset: moving to HEAD~1
e4f5a6b HEAD@{1}: commit: 你以为丢了的那个提交 ← 它在这里
...
reset --hard 之后发现搞错了?不慌:
git reflog # 找到那个提交的 hash
git reset --hard e4f5a6b # 把分支拉回去
唯一真的会丢的东西:从没进入过任何提交的工作区改动。 被 reset --hard、git restore、git clean -fd 覆盖/删掉的工作区改动,reflog 也救不回来,因为它们从没被 Git 记录过。
rebase:整洁历史,但有一条铁律
merge 保留分叉的真实历史,历史图看起来像麻花。rebase 把你的提交搬到目标分支顶端,历史变成一条直线。
# 之前
D---E feature
/
A---B---C main
# rebase main 之后
A---B---C---D'---E' feature
注意 D' 和 E'——rebase 不是「移动」原提交,是「按照原提交重做一批新提交」。父提交变了,hash 就变了。旧的 D E 成为没人引用的孤儿(reflog 里还能找到)。
交互式 rebase 整理历史
本地开发时提交得很随意(wip、fix typo、又改一点),推送前用 rebase -i 整理:
git rebase -i HEAD~3
编辑器里:
pick 9af33c1 wip
fixup b2c7a05 fix typo
fixup e4d9f1a 又改一点点
fixup 把提交揉进上一个并丢弃自己的信息,squash 同样揉进去但保留信息供你编辑。
注意:编辑器里从上到下是从旧到新(和 git log 相反)。
黄金法则
只 rebase「只有你自己有」的提交。已经推送、别人可能基于它工作的提交,绝不 rebase。
原因:rebase 换了 hash,别人手里还是旧 hash,历史对不上,强推只会把别人的工作覆盖掉或引发大量莫名冲突。
远端:push 为什么会被拒绝
理解了这一点,push 被拒就不再神秘。
push 成功的前提是快进(fast-forward):远端那个分支的当前位置,必须是你本地历史的祖先——你只是往前接龙,没有绕过或改写远端已有的提交。
一旦远端有了你本地没有的提交(别人先推了),push 就会被拒:
! [rejected] main -> main (non-fast-forward)
这不是 bug,是 Git 在保护别人的提交不被你悄悄覆盖。
解法:先 git fetch,把远端状态拉下来,再 git merge origin/main(或 git rebase origin/main),处理完冲突,再 push。
git pull 只是 git fetch + git merge 的快捷方式。
--force-with-lease,不是裸 --force
如果你整理了本地历史(rebase 了自己的 feature 分支),需要强推,用 --force-with-lease 而不是 --force:
git push --force-with-lease
区别:--force 不管远端现在是什么状态,直接覆盖。--force-with-lease 会先检查远端是否和你上次 fetch 时一样——如果别人在你 rebase 期间又推了新提交,这个命令会拒绝,保护你不会误覆盖别人的工作。
七个常见翻车
1. 「commit 了,改动没进去?」
大概率是改了文件没 add 就 commit 了。commit 拍的是暂存区,不是工作区。提交前先 git diff --staged 核对一眼。
2. git branch feature 之后以为切过去了
它只建指针,不切。git switch -c feature 才是「建了立刻切」。
3. git diff 是空的但你明明改了东西
你 add 过了,改动跑到暂存区(绿色)那边了,裸 git diff 只看工作区和暂存区之间的差(红色)。用 git diff --staged 看已暂存的部分,或 git diff HEAD 一把看全。
4. .gitignore 写了但没用
文件早就被跟踪了。.gitignore 只对「还没被跟踪」的文件生效。先 git rm --cached <file> 停止跟踪,再加进 .gitignore。
5. detached HEAD:「提交消失了」
git checkout <某个commit-hash> 会让 HEAD 直接指向那个 commit,不在任何分支上。这时 commit 的话,切走之后这些 commit 就没有分支引用,git log 找不到(reflog 里还有)。解法:立刻 git switch -c rescue 给这些提交钉上一个分支名。
6. reset --hard 后发现炸错了
git reflog + git reset --hard <hash> 找回来。
7. rebase 对公共分支动手 这个没有简单的善后方法,需要通知团队每个人重新 reset 本地分支。事前避免,事后麻烦。
进阶三件套(简短版)
git stash:工作区有没提交的改动,但现在需要切分支去处理别的事。git stash 把当前工作区和暂存区的改动临时存起来,切走再切回来用 git stash pop 恢复。
git bisect:二分法找 bug 引入点。告诉 Git 哪个 commit 是好的、哪个是坏的,它帮你二分缩小范围,最终定位到引入 bug 的那次提交。测试套件越完善,这个命令越好用。
git worktree:在不同目录同时签出同一仓库的不同分支,不需要 stash 也不需要切换。适合「一边在 feature 开发,一边需要修 hotfix」的场景。
一句话总结每一天学到的东西
- 三区模型:Git 存快照不存差异,
add到暂存区、commit到版本库,改动没 add 就不进提交。 - 指针三件套:commit 是带父指针的快照,分支是便利贴,HEAD 是当前位置——三者组合成 DAG。
- 合并两种:fast-forward 挪指针,三方合并造新 commit——merge 永远不改老 commit 的 hash。
- 撤销三区法:
reset的 soft/mixed/hard 是「重置到几层」,restore单文件,revert公共历史。 - reflog 是后悔药:已提交的几乎都能找回,没提交的 hard 之后真没了。
- rebase 一条铁律:整洁历史的代价是改写 hash,只对没推送的提交动手。
- push 被拒的原因:远端有你没有的提交,你需要先 fetch + merge/rebase 再 push。
七天实际上是七个心智模型,不是七组命令。建立了这套框架,遇到没见过的 Git 场景,你能自己推理出发生了什么、该怎么处理。这才算真正地「学扎实」。