很多年里,我一直觉得自己挺会用 Git。
能克隆仓库,能创建分支,能提交修改,能推送代码,还能发起 Pull Request(合并请求),甚至能解决冲突。
真遇到什么严重问题,我也知道那个仿佛能解决一切的命令:
git reset --hard
问题解决了。至少……当时我是这么想的。
后来才发现,我只是背下了一些 Git 命令,并没有真正理解 Git。这两件事,差得很远。
我一直把 Git 当成一个存放代码的地方。身边那些资深开发者,却似乎把它当成了另一种东西:一个管理变更、协作和历史的系统。
理解了这个区别之后,我的工作方式也跟着变了。
我以前的 Git 工作流,基本就这么几步
开始干活,先建个分支:
git checkout -b feature/new-feature
接着,埋头写几个小时代码。然后提交:
git add .
git commit -m "changes"
又改了一些地方:
git add .
git commit -m "fix"
继续:
git add .
git commit -m "final fix"
再继续:
git add .
git commit -m "final final fix"
最后:
git push
我的 Git 历史,看起来像流水账:
fix
changes
updated
fix issue
final
final fix
oops
fix again
那时候,我总觉得:"无所谓吧,代码能跑就行。"但问题恰恰出在这里。
Git 历史,也是工程交付的一部分。 六个月后,很可能有人需要靠这些记录弄明白当时发生了什么。那个人,说不定就是你自己。
1. 资深开发者不是提交得少,而是每次提交都有目的
我对 Git 的使用习惯发生变化,首先是因为明白了一件事:commit 不只是一个保存按钮。
一次提交,应该对应一个有意义的变更单元。与其留下这些记录:
fix
update
changes
test
final
不如让历史长成这样:
添加供应商识别中间件
添加按供应商隔离的API过滤
为SSR添加租户上下文
添加供应商隔离测试
现在想象一下,有人六个月后打开提交历史。不用逐个翻文件,他也能大致理解当时做了什么。区别就在这里。
一条好的提交说明,应该回答:改了什么?为什么要改? 不需要写成一篇长文,但至少要传递有效信息。例如:
git commit -m "Fix vendor data leaking across tenants"
意思是"修复供应商数据跨租户泄露问题",它显然比下面这句有用得多:
git commit -m "fix"
排查生产环境问题时,这种差别会格外明显。你可以运行:
git log --oneline
或者:
git log --oneline --graph --decorate --all
然后真正读懂这个仓库一路发生过哪些变化。
2. git add . 并不总是你的好帮手
这是另一个我后来才改掉的习惯。以前做完一件事,我马上就会执行:
git add .
确实方便,但也容易把不该提交的东西一起带进去。比如,我可能改动了这些文件:
src/components/Header.tsx
src/pages/dashboard.tsx
.env
package-lock.json
debug.log
可我真正想提交的,只有前两个。git add . 不知道我的打算,它只会把指定范围内的改动统统放进暂存区。
资深开发者往往会有意识地使用暂存区。例如:
git add src/components/Header.tsx
git add src/pages/dashboard.tsx
或者,进一步使用:
git add -p
-p 选项可以让你按修改块,选择哪些内容进入暂存区。一个文件里同时包含几处互不相关的修改时,这个功能尤其好用。
暂存区并不是一个让人嫌麻烦的中间步骤,它恰恰是 Git 最有价值的功能之一。你可以借助它,组织出自己真正想提交的那一次变更。
3. 我以前很怕 rebase
很长一段时间,rebase 在我眼里都属于那种命令:资深工程师在旁边操作,其他人默默看着。
git rebase
我知道它可能产生冲突,也听过别人反复提醒:"不要对共享分支做 rebase。"于是,我基本能躲就躲。
后来才明白,rebase 本身并没有那么可怕。真正容易出问题的,是不加考虑地重写共享历史。
rebase 做的事情,可以理解为:把一组提交拿出来,在新的基点上重新应用一遍。
假设最初的提交关系是:
main:A → B → C
feature:从 B 分出,再提交 D → E
与此同时,main 又向前走了几步:A → B → C → F → G。这时,你的功能分支仍然基于项目较早的状态。
你可以执行:
git switch feature
git rebase main
Git 就会把功能分支上的提交,重新应用到新的基点之后:A → B → C → F → G → D′ → E′。
注意 D 和 E 后面的撇号——它们是新的提交。这是理解 rebase 的关键:rebase 会重写提交历史。
Git 官方文档也提醒,应当区分本地历史和已经推送、被其他人共享的历史:整理本地工作很有用,但重写别人可能已经依赖的提交,会给协作带来混乱。这一点,在企业级 DevOps 全栈实践课程中也有系统性的讲解,从 GitLab CI/CD 到 GitOps 自动化发布,都离不开对历史完整性的正确理解。
理解这一点后,Git 对我来说清楚了很多:自己的本地历史,可以按需要整理。涉及其他人的共享历史,就必须格外谨慎。
4. merge 和 rebase
网上总有人争论:"rebase 更好"或者"永远不要 rebase"。这两种说法,都把事情讲得太简单了。
不同做法,各有合理的使用场景。
普通 merge 会保留分支历史,并通过合并提交明确记录汇合的位置。Squash merge 会把整个 Pull Request 的变更压成一个提交。Rebase merge 则会形成线性历史,同时保留各次提交的划分。
例如,普通 merge 中,主分支和功能分支分别向前发展,最后汇合到一个合并提交 M。线性历史则更像这样:A → B → C → D → E → F。
单看历史有没有分叉,判断不出哪种方式更好。关键是:你的团队希望仓库历史传达什么信息?
如果一个小功能分支里堆满了零散的开发中提交,squash merge 可以让主分支更清晰。如果某个长期分支里的每次提交都有独立意义,那么保留这些提交可能更有价值。
成熟的做法不是一句"所有情况都用 rebase",而是:先弄清楚,团队希望保留怎样的历史。 GitHub 同时支持普通合并、压缩合并和变基合并,也是因为这些需求确实不同。
5. reset、revert 和 restore,不能混着理解
这大概是我以前在 Git 上犯过的最大错误之一。当时我的想法很简单:"出问题了?reset 一下。"
但 Git 提供不同工具,是因为你面对的问题本来就不同。
git reset
它可以根据使用方式,移动 HEAD 指向,并调整暂存区。当你还在本地工作,想重新组织最近几次提交时,reset 很有用。比如:
git reset --soft HEAD~1
它可以撤销最后一次提交,同时把相关修改保留在暂存区。不过,如果这个提交已经共享给其他人,直接重写历史就未必合适了。这时候:
git revert <commit>
往往是更合适的选择。它不会把之前的提交当成没发生过,而是新增一次提交,抵消那次修改的影响。
这是两种完全不同的思路。reset 改变的是历史指向哪里,revert 则是在历史中再增加一条撤销已有变更的记录。理解了这个区别,Git 就不再像一堆互不相关、只能死记硬背的命令。
6. git reflog 让我不再那么怕犯错
这可能是我最喜欢的 Git 功能。
你有没有执行过:
git reset --hard HEAD~3
然后马上意识到:"坏了。"我当然有过。有一段时间,我以为那些提交从此就没了。直到后来,我知道了:
git reflog
Git 会通过 reflog 记录 HEAD 等引用之前指向过的位置。你可能看到这样的输出:
a91c2d1 HEAD@{0}: reset: moving to HEAD~3
f82ab42 HEAD@{1}: commit: Add payment validation
c71de19 HEAD@{2}: commit: Fix checkout flow
之前的提交,可能仍然可以找回来。你可以先检查它:
git show f82ab42
确认后,也可能借此恢复分支:
git reset --hard f82ab42
当然,这并不意味着可以随意使用 reset --hard。它仍然会覆盖相关的未提交修改。但理解 reflog 之后,有件事确实变了:Git 一出问题,我不再立刻慌了。
资深开发者并不一定从来不犯 Git 错误。很多时候,他们只是知道犯错之后应该怎样恢复。
7. 我不再用一个巨大分支装下所有事情
另一个老毛病,是让分支越长越大。刚建分支时,我通常想着:"就做一个小功能。"三周后:
feature/new-dashboard
里面已经塞进了:
仪表盘重新设计
API修改
身份认证修改
顺手做的重构
依赖升级
Bug修复
三项互不相关的界面优化
最后,Pull Request 变成了 4000 行。谁都不想审。
小分支的好处并不只是容易合并,它也更容易让人理解。一个聚焦的分支可以只处理:
feature/vendor-resolution
另一个处理:
fix/checkout-validation
再一个处理:
refactor/api-client
逻辑单元越小,审查、测试、撤销和讨论就越容易。 如果你希望更系统地掌握分支管理与合并请求的实战技巧,这门 Git 与 GitLab 企业实战课程值得一看,它覆盖了从本地仓库到团队协作工作流的完整链路。
8. 我不再把 Pull Request 当成倒代码的地方
这也是一个很大的转变。
Pull Request 不应该只是"我这段时间做的东西,都在这儿了"。它更应该表达:"这里是一项完整、明确的变更。这是修改原因,这是验证方式。"
好的 PR,会让审查者少费很多力气。与其只写"更新了仪表盘",不如写成:
本次 PR 添加了根据供应商上下文渲染仪表盘的能力。
系统根据请求的主机名识别租户,并将租户上下文传递到服务端数据获取层。客户端无法获取租户上下文时,过滤逻辑采用默认拒绝策略,避免返回不应展示的数据。
已验证:
- 供应商 A 的域名
- 供应商 B 的域名
- 未知域名
- 本地开发环境
这样一来,审查者还没打开第一个文件,就已经知道这次修改想解决什么问题。这不仅是 Git 使用能力,也是沟通能力。
9. 我不再看都不看就 pull
以前我有一个习惯:
git pull
只要觉得本地代码可能旧了,就运行一下,然后继续干活。但 git pull 本质上是先 fetch,再整合远端变更。具体怎样整合,还取决于你的配置。
更有意识的工作流,往往是先执行:
git fetch origin
再看看有什么新提交:
git log HEAD..origin/main --oneline
这样,决定如何整合之前,你已经知道远端发生了什么。也许你需要:
git rebase origin/main
也许更适合:
git merge origin/main
也有可能,远端的修改恰好影响了你正在处理的功能。重点是:先看清楚,再整合。资深开发者使用 Git 时,通常更清楚每一步的目的,而不是机械地重复操作。
10. 我开始把 Git 当成调试工具
Git 不只是协作工具,它还是一个调查问题的工具。
某个功能突然坏了?可以先查看历史:
git log
想知道某一行什么时候被改过:
git blame <file>
想搜索某类提交:
git log --grep="payment"
想比较两个版本:
git diff <commit1> <commit2>
还有一个经常被低估的工具:
git bisect
当你只知道"上周某个时候还是好的,现在坏了",你未必需要手动检查 100 次提交。Git 可以通过二分查找,逐步帮你定位引入问题的那次提交。
于是,Git 在你眼里不再只是"存放代码的东西",而是"帮我理解代码为什么变成今天这样的工具"。这样的理解,能让它发挥更大的作用。
11. 资深开发者并不害怕本地历史有点乱
这件事,我花了很长时间才想明白。
开发过程中,本地 Git 历史没必要一直保持漂亮。你完全可能留下这些提交:
wip
fix
oops
try again
debug
fix tests
final
它们出现在开发阶段并不奇怪,这很正常。真正需要考虑的是:你希望别人看到怎样的历史?
Git 的交互式 rebase 可以帮助你在共享之前,重新排序、编辑、合并、拆分或删除提交。例如:
git rebase -i HEAD~5
你可以把这些记录:
添加组件
修复组件
修正拼写
再次修复组件
添加测试
整理成更有意义的两次提交:
添加可复用的客户表单组件
添加客户表单校验测试
这时,历史就能讲清楚一件事了。你不是为了好看硬把真实过程改成另一种样子,而是在去掉那些对其他人没有帮助的噪声。
12. 但别把清晰的历史,误解成精心修饰的历史
这也是 Git 使用是否成熟的一个分界点。
完全线性的历史并不会自动更好,仓库也不是写作比赛。有时候,合并提交里包含有价值的信息;有时候,每次独立提交都应该保留;有时候,压缩成一个提交更合理;有时候,保留分支结构反而更有用。
GitHub 也指出,合并策略应该取决于团队更看重完整历史、简洁历史,还是线性历史。
目标不是"让 Git 历史看起来漂亮",而是:让 Git 历史真正有用。 这两者并不是一回事。
13. 保护 main,别只指望大家自觉
资深团队最终往往都会意识到:人会犯错。所以,别把整个流程建立在"所有人都能一直记住规则"之上。
重要分支需要保护。GitHub 的分支保护机制可以要求:
- Pull Request 审查
- 状态检查通过
- 审查讨论全部解决
- 提交签名
- 线性历史
- 部署成功
- 使用合并队列
它还可以限制强制推送和分支删除。这背后是一个不只适用于 Git 的工程原则:那些不希望被人无意打破的规则,尽量交给系统执行。
如果 main 对生产环境至关重要,就别只跟大家说"麻烦不要直接往上推"。让仓库本身落实这条限制。
这些 Git 命令,我真希望早点理解
不需要几百条命令,先理解这些就很有帮助:
git status
弄清楚仓库现在处于什么状态。
git diff
看清楚具体改了什么。
git add -p
控制哪些修改进入一次提交。
git log --oneline --graph --decorate --all
理解提交历史及其关系。
git fetch
获取远端变化,先更新认知,再决定如何整合。
git rebase
理解怎样整理本地提交,或者把它们重新应用到新的基点。
git reset
理解怎样调整本地历史的指向。
git revert
理解怎样通过新增提交,撤销已经共享的变更。
git reflog
操作失误后,知道从哪里寻找恢复线索。
git bisect
知道怎样定位引入回归问题的提交。
还有,也许是最重要的一条:
git status
再看一次。真的。很多 Git 问题归根结底,都是因为你没弄清楚自己当前处于什么状态。
对我来说,真正改变的是什么?
以前我总是问:"我现在该敲哪条 Git 命令?"现在我会先问:"仓库现在是什么状态?我想让它变成什么状态?"
这已经是另一种思考方式了。
想撤销本地修改是一个问题,想撤销生产环境中的某次变更是另一个问题;想整理自己的提交又是一个问题,想整合其他开发者的工作也有它自己的处理方式;不小心弄丢了东西,则需要考虑恢复。
当你不再只背命令,而是开始理解状态、历史和操作目的,Git 就会容易很多。
最后一点体会
很多年里,我以为 Git 的核心是命令。后来才明白,没那么简单。
Git 的核心,是变更。谁改了什么,什么时候改的,为什么要改,这些修改之间有什么关系。还有,也许最重要的一件事:当我们操作失误时,怎样恢复。
理解这些之后,rebase、reset、revert、stash、cherry-pick、bisect 和 reflog 就不再是一串神秘的命令。它们只是面对不同变更问题时,各自适用的工具。
比起记住更多命令,理解这一点,才真正让一个人更会用 Git。
如果你也在用 Git,但总觉得哪里不对劲,不妨去云栈社区和更多开发者一起聊聊,很多看似玄学的问题,聊开了就清楚了。