如果你问我,新手学 Git 最容易栽在哪里,我的答案不是“命令记不住”,而是“脑子里没有一个指针模型”。我见过太多入门的人卡在同一个地方:命令背得滚瓜烂熟,一旦遇到 reset、rebase、cherry-pick 这种涉及“提交历史修改”的操作,就开始害怕,因为根本不知道这些命令在背后把东西挪到了哪里。
我这份笔记不是 Git 官方文档的翻译,更不是一本面面俱到的参考手册。它是我从“会用几个命令”到“敢在团队项目里放心操作 Git”这一路上,把踩过的坑、想通的原理、沉淀下来的操作习惯整理成的一份个人笔记。核心内容只有一个目标:让你建立能支撑后续所有操作的心智模型,然后用这个模型去理解日常开发里最高频的场景。适合刚接触 Git 的开发者,也适合那些会用 add、commit、push 但遇到分支合并、回滚就发怵的朋友。看完之后你不需要记住所有命令,只需要知道“我在哪个区、指针指向哪、我想去哪”,剩下的交给命令就好。
1. 先打通 Git 心智模型:三个区域和快照思维
1.1 三个工作区到底在说什么
我第一次学 Git 的时候,被“工作区”“暂存区”“版本库”这三个名词搞得很痛苦。当时带我的人反复说一句:“先 add 再 commit”,但没人告诉我为什么中间要隔一个“暂存区”。直到后来我用一个生活化的类比才彻底想通:
- 工作目录:你的草稿纸。你在上面随便写、随便改,Git 不管,只要你不主动告诉它,它也不关心你写了什么。
- 暂存区:你的“桌面打包区”。你从草稿纸里挑出某些文件,放进打包区,告诉 Git“这批东西我准备一起寄走”。
- 版本库:你的档案柜。每次 commit 就是把打包区里的内容封存成一份有编号的存档,以后随时可以拿出来对比或恢复。
所以git add的本质不是“保存”,而是“把改动从草稿纸放到打包区”;git commit才是真正“存档”。为什么设计这么一层?因为不是所有改动都应该进同一个存档。你可能一个小时里改了三个文件的五个 Bug,其中两个是功能相关的,另外三个是修格式的。你可以分两次 add,再分别 commit,这样提交历史里就是两条语义清晰的记录,而不是一锅粥。
我自己的经验是:大部分命令记不住没关系,但脑子里一定要能回答三个问题——我现在在哪个区?目标在哪个区?这步操作会移动什么东西?这三个问题想清楚了,Git 就学通了一半。
1.2 提交是快照,不是补丁
很多人对 Git 提交有一个错误认知:以为每次 commit 只存“改了几行”的差异。这是 SVN 时代的思路,Git 不是这么干的。Git 的每次提交都是一个完整的快照:它会把你暂存区里的整个项目状态拍一张照片,存成一个提交对象。
听起来很占空间?实际上 Git 做了优化:如果某个文件在这次提交和上次提交之间没有任何变化,它不会重新复制一份文件内容,而是直接引用上一次提交里的同一份文件对象。所以“快照”不等于“每份都一样大的全量副本”,它更像“一份完整的目录清单 + 指向真实内容的指针”。真正变化的文件才会存新内容,没变的文件只是多一个引用而已。
这个认知为什么重要?因为它解释了很多命令为什么“看起来那么奇怪”:
git checkout <提交>本质上是“把工作目录里的文件替换成那个提交快照里的内容”。git diff是在两个快照之间做比对,而不是在一个补丁文件上做运算。git reset --hard能把整个项目变成某个提交时的样子,因为只需把所有文件指针重新指向那个快照。
你如果把提交理解成“一串补丁”,压根没法解释为什么 reset 能瞬间让整个项目回到过去。但你把它理解成“一套可以整体切换的照片”,就顺理成章了。
2. 新手必须吃透的高频命令操作
2.1 起步:配置、初始化、克隆
新机器上装完 Git,第一件事不是建仓库,而是告诉 Git 你是谁。这不是可有可无的仪式,因为每次提交都会记录作者信息,团队协作时审代码的人靠这个信息知道该找谁。
git config --global user.name "你的名字" git config --global user.email "你的邮箱"把--global换成--local可以只对当前仓库生效,一般在公司项目和私人项目混在一台机器上时,我会按仓库分别设置。
然后是两个“建立仓库”的方式,它们的适用场景完全不同:
git init:从零开始一个新项目的时候用,会在当前目录里创建一个隐藏的.git文件夹,从此这个目录就被 Git 接管了。git clone <远程地址>:别人已经建好了仓库,你想把整个项目拿到本地来。它会自动帮你关联远程地址,并且建好默认分支。
我见过很多新人直接用git init去“接管”一个已经从远程下载好的项目,结果本地仓库和远程仓库之间没有任何关联,push 的时候就会遇到各种奇怪问题。正确做法是:已有远程仓库就用 clone,新项目才用 init。如果你确实用 init 初始化了一个目录,又想和某个远程仓库关联,用下面两条命令补上:
git remote add origin <远程地址> git pull origin <分支名>2.2 日常循环:add、commit、push、pull
一个开发者每天最核心的循环其实就这几步:改代码,看状态,暂存,提交,推送。我把每步的意图说清楚:
git status是最高频的检查命令。它会把当前仓库里“未跟踪的文件”“已修改但未暂存的文件”“已暂存待提交的文件”分门别类列出来。我见过新人不知道 status 的分区含义,以为看到红色就是报错。红色只是提示你有改动,不代表出错。绿色部分才是已经 add 到暂存区、等待提交的内容。
git add的参数非常灵活:
git add .把当前目录下所有未跟踪、已修改的文件都加入暂存区,最省事,但也最“无脑”。git add <文件路径>只暂存指定文件。git add -p是进阶用法,可以一次只暂存某个文件里的部分改动块。写代码时顺手改了两件不相干的事,用这个方法就可以拆成两次干净的提交。
git commit提交时我强烈建议写清晰的信息。我的习惯是参考社区常见约定:
git commit -m "feat: 新增用户登录接口" git commit -m "fix: 修复订单金额计算溢出问题" git commit -m "docs: 补充API文档"feat是新功能,fix是修复 Bug,docs是文档变更,refactor是重构,chore是杂务。别小看这个习惯,三个月后你想翻历史找某次改动的动机时,会感谢当时的自己。
git push之前一般先git pull。这里的pull等价于“先抓取远程最新状态,再合并到本地分支”。第一次往新分支推送时,需要告诉远程“我从本地分支推到远程哪个分支”:
git push -u origin main-u是--set-upstream的缩写,意思是“后面的提交我默认都推送到远程的 main 分支”。只推一次,之后直接git push就行。
2.3 看历史:log、diff、show
很多新人只看得到“当前状态”,却很少去翻历史,这其实浪费了 Git 最大的优势。
git log --oneline --graph --decorate--oneline把每次提交压缩成一行,--graph用字符画出行分支合并的拓扑图,--decorate会标出各分支和 HEAD 指向哪里。这一条命令基本是我每天用得最多的“导航仪”,它可以让你一眼看清整个仓库的演进脉络。
git diff有三个常见比较维度:
git diff比较的是工作目录和暂存区之间的差异,也就是“你还没 add 的改动”。git diff --cached比较的是暂存区和上一次提交之间的差异,也就是“你已经 add 但还没提交的改动”。git diff HEAD比较的是工作目录和版本库中最新提交之间的差异,相当于前面两者的总和。
我刚开始总是搞混,后来就记一句话:不带参数比“未暂存”,--cached比“已暂存”。配合git status一起用,基本能定位任何“改到哪里去了”的问题。
git show <提交ID>可以看某次提交具体改了哪些文件、每处改动是什么样子。代码评审、排查 Bug 的时候,靠它快速定位“这个行为是哪个提交引入的”,比在 IDE 里来回翻文件高效得多。
3. 分支与合并:真正用出 Git 优势的地方
3.1 分支其实就是一个会移动的指针
很多新手把分支想象成“平行的代码副本”,这种理解没错但不够准确。我的理解方式是这样的:分支本质上只是指向某一个提交的指针。
创建分支时,Git 不会复制任何文件,只是在当前提交上多贴一个标签。比如你站在线上主干的最新提交上,输入:
git branch feature/login这个命令只是新建了一个叫 feature/login 的指针,指向当前提交。你在 feature/login 上提交了一个新内容,这个指针就会自动向前移动;而 main 指针还停在原地。于是两个分支“分叉”了。这个心智模型能解释很多现象:
- 为什么 Git 创建分支特别快?因为真的只是贴了个标签。
- 为什么切换分支后工作目录会发生“巨大变化”?因为 HEAD 指针从分支 A 挪到了分支 B,工作目录里的文件就被替换成了分支 B 所指向的那个快照。
- 为什么删除分支不心疼?因为分支只是指针,删了指针不会删提交,提交还在那里。
分支命名规范方面,我的习惯是:功能分支叫feature/xxx,修复分支叫fix/xxx,紧急热修叫hotfix/xxx。团队里最好约定一套,否则每人一套风格,时间长了仓库里的分支列表会变成灾难。
3.2 merge 与 rebase 的取舍
分支合并是高频操作,而git merge和git rebase是两条完全不同的路。我用一个简单例子说明:假设你在 feature 分支上做了提交 B 和 C,main 分支在你创建 feature 之后又多了一个提交 D。
git merge的做法是:把 D 和 C 两个分叉点“汇合”,生成一个全新的合并提交 M。结果是历史呈网状,能看到“这个功能是怎么并行开发的”。
git rebase的做法是:把 B 和 C 两个提交拆下来,重新“播放”在 D 的后面,变成新的提交 B' 和 C',历史呈一条直线。看起来好像 feature 分支是在 D 之后才开始开发的,但提交 ID 全变了。
我的取舍规则如下:
| 维度 | merge | rebase |
|---|---|---|
| 是否产生合并提交 | 会 | 不会 |
| 历史形态 | 网状,保留真实并行关系 | 线性,看起来更整洁 |
| 适合场景 | 多人协作、公共分支合并 | 个人功能分支整理、往主分支跟进时 |
| 历史会被改写吗 | 不会 | 会,原提交被替换成新提交 |
| 已推送到远程的提交能否操作 | 可以 | 慎用,可能影响其他人 |
个人开发的短期功能分支,我习惯用 rebase 把本地提交整理成一条干净的线;多人协作的公共分支,老老实实用 merge。重点是要记住:已经推送到远程、且可能被别人拉取过的提交,不要随便 rebase。改写历史会让别人的仓库出现“两个同样内容但不同编号的提交”,协调起来非常痛苦。
3.3 冲突处理的完整过程
冲突是学习 Git 时最容易产生挫败感的地方。但冲突的本质并不复杂:两个分支在同一个文件的同一个位置,各自做出了不同的修改。Git 不知道哪边是对的,只能停下来让你决定。
产生冲突时,文件里会出现这样的标记:
<<<<<<< HEAD 当前分支的改动 ======= 另一个分支的改动 >>>>>>> feature/login<<<<<<< HEAD到=======之间是当前所在分支的内容,=======到>>>>>>> feature/login之间是即将合并进来的分支的内容。你需要手动把文件改成期望的样子,然后删掉这些标记行。
完整处理流程大概是:
git merge feature/login,提示冲突。- 打开有冲突的文件(用
git status查看哪些文件冲突),找到<<<<<<<标记。 - 根据需求决定保留哪边,或者两边都保留,并删掉所有冲突标记。
- 保存文件后执行
git add <文件>,告诉 Git“这个文件冲突已解决”。 - 所有冲突都处理完并
git add后,执行git commit,完成合并。
如果你改到一半发现不想合并了,可以用git merge --abort直接回到合并前的状态。rebase 过程中遇到冲突则是git rebase --abort。我见过不少人一遇到冲突就慌,但其实处理和平时改代码没啥区别,无非是文件里多了几行标记。
我自己的经验是:冲突标记里的内容按行读,保留需要保留的,删掉不需要的,最后把所有标记符号都要清干净。很多人只改了“留下哪边”,却忘了把<<<<<<<、=======、>>>>>>>这些行本身删掉,结果编译直接报错。
4. 回滚操作全解:reset、revert、checkout 的分工
4.1 HEAD 指针与“回滚”的本质
新手最怕的就是把项目搞坏,想“回滚”却不知道用哪个命令。要理解回滚,先得理解三个概念:HEAD、分支指针、提交历史。
- 每次
git commit产生一个提交对象,提交对象有唯一的哈希 ID。 - 提交之间有父子关系,串成一条条链。
- HEAD 是一个特殊指针,指向当前所在的提交。它通常指向某个分支,而分支再指向具体提交。
- 当你说“切到某个分支”时,本质是把 HEAD 指向那个分支指针;当你说“回滚到某个提交”时,本质是把当前分支指针移动到目标提交。
所以我后来学回滚,学的不是死记命令参数,而是问自己:我要把指针往哪移动?移动之后那些被我“跳过”的提交还在不在?答案是:它们还在仓库里,只是不再被分支指向,看起来“消失了”而已。只要没被垃圾回收,用git reflog还能找回来。
4.2 git reset 的三种模式怎么选
git reset的核心作用是移动当前分支指针,同时按参数决定是否同步重置暂存区和工作目录。三种模式我用一张表说清楚:
| 模式 | 移动 HEAD/分支指针 | 重置暂存区 | 重置工作目录 | 适合场景 |
|---|---|---|---|---|
--soft | 是 | 否 | 否 | 想撤销上一次 commit,但保留已经 add 的内容 |
--mixed(默认) | 是 | 是 | 否 | 撤销 commit 和暂存状态,但保留所有改动 |
--hard | 是 | 是 | 是 | 彻底回到某个提交的状态,丢弃之后的改动 |
举几个我真实遇到的场景:
场景一:我刚提交了一个 commit,突然发现漏了文件。此时我不想留两段记录,于是:
git commit --amend--amend会把你暂存区的改动合并进上一次提交,相当于“补进上一个存档”。
场景二:提交之后发现这次改动思路完全不对,想“撤销提交但保留代码”,重新整理后分步提交。这种情况用git reset --soft HEAD~1,提交被撤销,代码改动还在暂存区里。
场景三:改了一堆垃圾代码,想全部扔掉,让工作目录干净回到上次提交的样子:
git reset --hard HEAD这里要重点提醒:--hard是危险操作,会丢弃未提交的改动。我在带新人时专门强调过一件事:在用--hard之前,先运行git status看看有没有没提交的内容;如果是已经提交过的东西,哪怕后来 reset 掉了,也还有救。
4.3 已推送的提交:为什么 revert 比 reset 安全
如果你的提交已经推送到远程仓库,并且团队其他人已经基于它开始工作,那 reset 就不是好选择了——因为 reset 是“抹掉历史”,其他人的本地仓库里还留着那次提交,下一次 pull 就会出现大批冲突和混乱。
这时候要用git revert。它的原理很有意思:不是把历史抹掉,而是根据你想撤销的那个提交,自动生成一个反向的新提交。比如你提交过一次“增加了一段代码”,revert 会自动生成一个“删除这段代码”的提交。历史记录里清楚地写着:增加,然后删除,两个提交都在。这种“留痕”的方式对多人协作非常友好,因为其他人同步时看到的就是“又来了一个新提交”,不会出现历史分叉。
git revert <提交ID>revert 也可以指定一次撤销多个提交,但要注意顺序。命令执行后一般会进入提交信息编辑界面,你也可以直接指定-m参数来处理合并提交的 revert,但这属于进阶场景了。
我的总结很简单:本地没 push 的提交,想怎么改都行,reset 随便用;已经 push 了、且有人可能拉取过的提交,一律用 revert。
5. 协作实战中我踩过的坑
5.1 文件加了 .gitignore 还是被提交
刚学 Git 的时候,我最困惑的问题之一就是:我把.env、node_modules这些文件写了.gitignore,可它们还是出现在git status里,甚至被提交了进去。
后来才明白一个关键规则:.gitignore只对“尚未被跟踪”的文件生效。如果一个文件已经被 Git 跟踪过(比如之前不小心提交过一次),那之后再怎么往.gitignore里写,它都会被继续跟踪。Git 的逻辑是:你让我盯住这个文件了,我不能因为忽略规则就自动放它走。
正确做法分两步:
git rm --cached .env echo ".env" >> .gitignore git commit -m "chore: 停止跟踪 .env 并加入忽略列表"git rm --cached的意思是“从跟踪列表里移除,但保留磁盘文件”,这一步之后文件变成了未跟踪状态,.gitignore才能把它拦住。很多新人一上来直接git rm .env,把本地文件也删了,吓一跳,其实加--cached就不会动磁盘文件。
我建议每建一个仓库,第一时间把.gitignore写好,特别是语言生态里的依赖目录、本地配置文件、IDE 设定文件。真等到有人不小心提交进去,再清理就要多闹一次“历史改写的风险”,非常不值当。
5.2 push 被拒绝:non-fast-forward
第一次遇到! [rejected]提示时,我的第一反应是“远程是不是坏了”。后来才明白这个提示的含义:远程分支上有一些本地分支没有的提交,Git 担心你直接覆盖它们。
! [rejected] main -> main (non-fast-forward)这不是错误,是保护机制。处理步骤非常简单:
git pull --rebase origin main git pushpull --rebase会先把远程的新提交拉到本地,然后把你的本地提交“放到它们后面”,历史保持线性;再 push 就不会被拒绝。有人会问我:pull不带--rebase行不行?也行,会用 merge 方式生成合并提交,历史容易乱。
我也见过有人图省事直接git push -f(强制推送)。这条命令会把远程分支直接覆盖成你本地的样子,相当于“我的版本才是对的,我不在乎远程发生了什么”。在一个人独自维护的项目里可能无所谓,但在团队项目里,push -f等于无视别人已经提交的工作,极其危险。至少在我自己的协作规范里,这条命令默认是禁用的,除非所有人确认无误。
5.3 提交信息写错和少文件了怎么办
提交信息打错字、少加了一个文件,这是最日常也最尴尬的问题。分两种情况处理:
情况一:还没有 push,那直接用git commit --amend就解决了。它会打开提交信息编辑器,让你改上次的提交信息;如果只是信息打错,不需要重新 add 任何东西。
git commit --amend如果你少了一个文件,先把漏掉的文件 add 进暂存区,然后同样执行git commit --amend,这次提交会“吞掉”那个文件,历史里不会出现两条记录。
情况二:已经 push 到远程,那就不要再用 amend。因为 amend 本质上是把原来的提交替换成一个新提交,一旦 push 过,别人已经拉取过,你再 amend 就会在别人的仓库里造成历史分叉。正确做法是:在现有提交之上再补一个新的提交,比如:
git commit -m "fix: 补充上一个提交遗漏的配置文件" git push虽然看起来多了一条记录,但协作是顺的,不会给同事添麻烦。个人开发时我偶尔也会用commit --amend整理历史,但涉及共享分支时,我宁可多一次提交。干净的历史固然好,但让别人崩溃的代价更大。
最后分享一点我的实操习惯
学 Git 这件事,我的体会是:死记命令表远不如先建立一个可以推演的模型。当我脑子里有“分支是指针”“提交是快照”“reset 是移动指针、revert 是生成反向提交”这三个锚点之后,几乎所有命令都能自己推出来,再也没背过参数表。
我建议你动手建一个专门的测试仓库,然后反复练习这几组操作:在 main 上提交两次,切分支再提交两次,合并出冲突,用 reset 的三种模式来回切换,用 reflog 找回一次 reset --hard 丢掉的提交。这套动作做下来,远比刷十遍教程管用。我自己当初就是这么练出来的,到现在,每次在真实项目里动 reset 或 rebase 之前,我都会先在纸上画一遍“现在哪些指针指向哪里”,画完再敲命令,几乎没有翻过车。