1. 这一批命令为什么被单独拎出来讲
Git 的常用命令写到第四篇,前面几篇基本把"加法"讲完了:怎么建仓库、怎么提交、怎么拉分支、怎么合并。日常八九成的工作,靠add、commit、pull、push、merge这一套就能转起来。可真正让人半夜从床上爬起来开电脑的,从来不是这些命令,而是另一批:reset、rebase、cherry-pick、stash、reflog。它们不动"新增",专门动"已有的历史",所以我习惯把它们归到一类,叫历史改写与现场急救。
这篇随笔就围绕这批命令展开。适合已经能熟练提交、切换分支,但对"改错了怎么退回去""提交串乱了怎么整理""半成品代码怎么临时搁一下"还心里没底的人。文中所有命令都是在真实项目里反复用过的写法,我会把每一步为什么这么写、写错了会怎样,都尽量说透。
1.1 一次把提交"弄丢"的凌晨
先说个我自己的事。有一年做一个数据同步的小工具,连着改了七八个文件,提交了三次。临睡前想着"把最后一次提交撤掉重新组织一下",手指比脑子快,敲了git reset --hard HEAD~1。回车的那一秒我就反应过来了——工作区里那版还没提交的调试代码,跟着一起没了。
当时第一反应是完了,第二反应是打开终端敲git reflog。列表里赫然躺着刚才那个提交的哈希,git reset --hard <那个哈希>一回车,全回来了。整个过程不到一分钟。从那以后我对这批命令的态度就变了:它们不是"危险命令",而是危险但可控的命令,前提是你得知道每一条命令到底动了哪一层东西,以及出事后从哪儿捞。
这个认知很关键。很多人怕reset、怕rebase,本质上是因为把它们当成黑盒,不知道内部发生了什么,只能靠"听说很危险"来规避。可一旦你清楚它动的是指针还是文件,恐惧就消失了,剩下的只是纪律问题。
1.2 HEAD、索引、工作区三者的关系
要理解这批命令,绕不开 Git 的三层结构。我一般这么跟新人解释:
- 工作区:你能直接看见、直接编辑的那些文件,就是磁盘上的目录。
- 索引(暂存区):一个中间缓冲区,
git add就是把工作区的改动"登记"到这里。 - 版本库(HEAD 指向的提交):真正被记录进历史的那份快照。
git commit做的事,是把索引里的内容打包成一个新提交,然后让当前分支指针往前挪一格。所以一次提交并不是"把文件存起来",而是"给索引拍一张照片"。
理解了这一点,你就能明白为什么reset会有三种模式——它无非是在这三层之间选择"动几层"。rebase也无非是把一串提交快照依次"重放"到另一个基点上。stash则是把工作区和索引的当前状态打包塞进一个临时栈里,然后把这两层恢复到干净状态。
一个很实用的自查习惯:执行任何会改动历史的命令之前,先敲一次
git status。它会告诉你当前在哪个分支、工作区有没有未提交的改动。这一秒钟的成本,能挡掉后面八成的麻烦。
1.3 这批命令的共同特征
把这几个命令放一起看,会发现它们有个共同的脾气:都直接操作提交对象和指针,而不是操作文件内容。这正是它们威力大、也容易出事的原因。
merge是"加法",它产生一个新提交,把两条线接上,原有历史一个字节都不动,所以安全。rebase是"重排",它把提交复制一份贴到别处,原来的提交变成孤儿,所以历史被改写了。reset是"挪指针",指针一挪,后面那串提交就从分支上"看不见"了,但对象还在库里躺着,直到被垃圾回收。cherry-pick是"复制单点",把某个提交的差异在当前位置再打一遍。
这四种操作的本质区别,用一句话概括就是:merge 不改历史,rebase 重写历史,reset 移动历史,cherry-pick 复制历史。记住这句话,后面所有的细节都能挂上去。
2. reset 的三档力度:从"只挪指针"到"连锅端"
reset大概是这批命令里被误解最深的一个。很多人以为它是"撤销提交",其实它的字面意思是"重置"——重置当前分支指针到某个位置,顺带决定索引和工作区要不要跟着一起重置。参数不同,破坏力差着好几个数量级。
2.1 soft、mixed、hard 到底各自动了什么
三种模式的区别,本质就是"三层结构里动几层":
--soft只动第一层。分支指针挪到目标提交,索引和工作区原封不动。效果是:被撤掉的那个提交里的改动,全部变成"已暂存"状态,你可以直接重新commit。这是最温和的一档,适合"提交信息写错了想重写""几个提交想合成一个"这类场景。
--mixed是默认值,动前两层。指针挪走,索引也重置成目标提交的样子,但工作区不动。效果是:被撤掉的改动全部退回到"未暂存"状态,你得重新add才能提交。日常用得最多的就是这一档,比如"刚才add错文件了,想重新挑"。
--hard三层全动。指针、索引、工作区统统回到目标提交的状态。效果是:所有未提交的改动直接蒸发,且没有任何提示。这一档才是真正的"危险模式",因为工作区的东西不属于 Git 对象,reflog也救不回来。
我见过太多人把--hard当成万能撤销键,结果连着丢了两三次半天的活儿。记住一个原则:只要工作区有未提交的改动,用--hard之前必须先stash或者确认不要了。
2.2 用一张表看清三种模式的结果差异
光看文字容易糊,我整理了一张对照表,建议存下来。假设当前在main分支,最新提交是 C3,你想退到 C2:
| 命令 | HEAD 指向 | 索引 | 工作区 | 被撤掉的 C3 改动去向 |
|---|---|---|---|---|
reset --soft HEAD~1 | C2 | 保持 C3 状态 | 保持当前状态 | 全部在暂存区,可直接提交 |
reset --mixed HEAD~1 | C2 | 重置为 C2 | 保持当前状态 | 全部变为未暂存改动 |
reset --hard HEAD~1 | C2 | 重置为 C2 | 重置为 C2 | 工作区改动丢失,但 C3 对象仍可由 reflog 找回 |
注意最后一行那句"仍可由 reflog 找回"——这是很多人不知道的细节。--hard丢的是工作区里未提交的改动,而已经提交过的 C3,它作为一个对象还在版本库里,只是没有指针指向它了。只要没触发垃圾回收,你完全可以通过reflog找回来。这两件事一定要分清楚:丢提交和丢代码,是两回事。
2.3 三个高频实战场景的命令写法
场景一:提交信息写错了,想改。如果是最新一次提交,直接git commit --amend就够,不用reset。但如果是想改前两次的提交信息,就得走交互式 rebase(下一节讲)。reset --soft更适合"想重新组织提交内容"的情况:
# 把最近三次提交退回到暂存区,重新组织成一次提交 git reset --soft HEAD~3 git status # 确认暂存区内容 git commit -m "合并为一次完整的功能提交"场景二:add错了文件,但还没提交。这时候根本不需要reset,用git restore --staged <file>把文件从暂存区撤出来就行。老版本 Git 里的写法是git reset HEAD <file>,两种写法效果一样,新写法语义更清楚。
场景三:本地提交了一堆调试代码,想整体丢弃。这是--hard最正当的用法:
# 先看看要丢的是哪些提交,确认没夹带正经代码 git log --oneline -5 # 丢弃最近两个提交,回到干净状态 git reset --hard HEAD~2我个人的习惯是,敲--hard之前一定先看一眼git log --oneline,把这个哈希记下来或者截图。多花三秒,出事了有据可查。
2.4 reset 单个文件与撤销暂存
reset还有个不太常用的形式:不指定提交,只指定文件。此时它不会移动 HEAD,只把指定文件在索引里的状态重置回 HEAD 的样子。翻译成人话就是"把这个文件的暂存撤销掉,但不动工作区内容"。
# 撤销单个文件的暂存(等价于 restore --staged) git reset HEAD src/config.js这个用法在老教程里很常见,但现在更推荐git restore --staged,因为reset这个词容易让人误以为要动历史,心理负担大。Git 2.23 之后把restore和switch拆出来,就是为了让语义更干净——改文件的归 restore,切分支的归 switch,动历史的归 reset。
3. rebase:把提交串整体"移植"到新基点
rebase这个词听起来玄,其实中文翻译"变基"已经说得很直白了:改变基底。把一个分支上的一串提交,从原来的起点"剪"下来,"接"到另一个提交后面去。整个过程本质上是复制加丢弃——新提交挨个生成,旧提交废弃不用。所以变基之后哈希全会变,这是理解一切风险的起点。
3.1 rebase 与 merge 的取舍逻辑
同样是把feature分支合进main,merge和rebase出来的是两条完全不同的历史线。
merge会保留分叉,产生一个合并提交,历史像两条河汇成一条,你能清楚看到"这两块是并行开发的"。缺点是历史图会比较乱,尤其是多人频繁合并时,提交线像蜘蛛网。
rebase会把feature上的提交挨个复制到main的最新位置,历史变成一条直线,干净得像从未分叉过。缺点是丢失了"并行开发"的信息,而且所有提交哈希都变了。
我的选择习惯是:已经在团队共享分支上的提交,一律用 merge;只有自己本地还没推出去的提交,才用 rebase 整理。这条线划清楚,基本不会出大事。团队里如果有明确的规范(比如要求线性历史),那就统一用 rebase,但要保证所有人都遵守同一条规则,不能各自为政。
3.2 交互式 rebase 的六种动作
git rebase -i HEAD~4会打开一个编辑器,列出最近四个提交,每行前面有个动词。这个界面第一次看有点懵,其实动作就那么几个:
| 动作 | 缩写 | 作用 |
|---|---|---|
| pick | p | 保留这个提交 |
| reword | r | 保留提交但修改提交信息 |
| edit | e | 停下来,允许修改这次提交的内容 |
| squash | s | 合并到上一个提交,保留两条提交信息 |
| fixup | f | 合并到上一个提交,丢弃这条提交信息 |
| drop | d | 直接丢弃这个提交 |
最常用的是squash和fixup。开发一个功能时常常会提交五六次"修个拼写""补个样式"这类碎片,推之前用fixup合并成一次干净的提交,评审的人看着舒服,回滚的时候也方便。
有个细节值得提醒:squash会打开编辑器让你合并两条提交信息,fixup直接丢掉后面那条。如果后面那条信息里有关键说明,用squash,别图省事用fixup。
3.3 冲突卡住时的三条出路
rebase 过程中撞上冲突是家常便饭,因为它是一个提交一个提交地重放,每一站都可能打起来。卡住的时候 Git 会停下来,告诉你哪个文件冲突了。
处理流程是固定的三步:打开冲突文件,找到<<<<<<<、=======、>>>>>>>标记,手工改成你想要的样子,然后git add <file>,最后git rebase --continue。
如果中途发现搞错了,或者冲突太多不想继续,有两条后路:
# 完全放弃这次变基,回到变基之前的状态 git rebase --abort # 跳过当前这个提交,继续后面的(慎用,会丢改动) git rebase --skip--abort是我最推荐新手先记住的命令。它是真正的"后悔药",会把 HEAD、分支指针、工作区全部恢复到rebase开始之前,干干净净。我在带新人的时候,第一句话就是"rebase 卡住了别慌,先--abort"。
3.4 变基的边界:什么时候坚决不能用
有一条业内公认的铁律:不要对已经推送到共享分支的提交做 rebase。原因很直接——变基会重写哈希,别人本地还留着旧的提交,你推上去新的一串,两边历史就对不上了,接下来就是各种强推和手动修复的地狱。
判断标准很简单,敲一句:
git log --oneline origin/main..HEAD如果输出为空,说明你本地没有领先的提交,没什么可整理的。如果输出里有内容,再确认这些提交是不是只有你一个人在用。团队协作里,最稳妥的判断是看这个分支有没有别人在拉——主干分支、发布分支,一律不动;自己的功能分支,随便整。
还有一种情况也不建议 rebase:提交数量特别多、冲突特别密集的时候。这时候与其一个个解冲突,不如先merge一次把差异拉平,再考虑要不要整理。工具是为人服务的,不必为了"历史好看"把自己折腾半宿。
4. cherry-pick:只取一颗果子,不动整棵树
cherry-pick的名字起得特别形象——从一棵树上单独摘一颗樱桃下来。它的作用是:把某个分支上的某一个提交所包含的改动,复制到当前分支重新打一遍。注意是"某一个提交的差异",不是"那个分支的所有内容"。
4.1 什么时候该用 cherry-pick
这命令最典型的场景是紧急修复的移植。比如线上主干出了个 bug,你在main上改好提交了,但下个版本的开发分支还在两周后才发,需要把这个修复提前带过去。这时候在开发分支上cherry-pick那个修复提交,比把整个main合并过来安全得多。
另一个常见场景是误提交到错误分支。本来该提到feature-a的改动提到了feature-b,可以在正确的分支上cherry-pick过来,然后回到错误分支reset掉。
还有一个场景是恢复被误删的提交。某个提交在整理历史时被drop掉了,但它其实还有用,可以从reflog找到哈希,然后cherry-pick回来。
# 摘取单个提交 git cherry-pick a1b2c3d # 摘取一段连续提交(不含起点,含终点) git cherry-pick a1b2c3d..e4f5g6h # 摘取一段连续提交(含起点) git cherry-pick a1b2c3d^..e4f5g6h # 只应用到工作区,不自动提交,方便二次修改 git cherry-pick -n a1b2c3d区间写法里那个^很容易记混。我自己的记法是:A..B是"从 A 之后到 B",A^..B是"从 A 本身到 B"。多一个^就是往回多抓一个。
4.2 区间摘取与不自动提交
-n这个参数值得单独说。默认情况下cherry-pick每摘一个提交就自动生成一个新提交,提交信息原样保留。但有时候你摘过来是想改改再用,这时候加-n(--no-commit),改动只会进暂存区,你可以继续改、继续加文件,最后自己写一条合适的提交信息。
连续摘多个提交时,如果中间某个冲突了,Git 会停下来。这时候处理方式和 rebase 类似:解冲突、git add、git cherry-pick --continue。想放弃就--abort,想跳过当前这个就--skip。三个参数和 rebase 完全一致,学会一个就通用。
有一点要特别注意:cherry-pick复制的是差异,不是快照。如果原提交依赖它前面几个提交引入的代码,单独摘过来很可能编译不过。所以摘之前一定要确认依赖关系,别摘了个半成品。
4.3 cherry-pick 冲突的实际处理流程
举个具体例子。假设main上的提交a1b2c3d修改了src/utils/format.js里的一个函数,你现在在release分支上想把它摘过来:
git switch release git cherry-pick a1b2c3d # 报冲突 # Auto-merging src/utils/format.js # CONFLICT (content): Merge conflict in src/utils/format.js打开文件,会发现冲突标记。手工合并成想要的样子,保存,然后:
git add src/utils/format.js git cherry-pick --continue如果发现这个修复在release分支上根本不适用(比如函数已经被重构掉了),那就git cherry-pick --abort,别硬来。硬来的结果往往是留下一个语义错误的提交,后面排查问题时更头疼。
5. stash:把半成品临时收进抽屉
写到一半的功能、临时加的调试日志、还不确定要不要留的实验代码——这些东西扔了可惜,提交了又污染历史。stash就是为这个场景准备的:把工作区和暂存区的当前状态打包存起来,然后把这两层恢复成干净的样子,让你能安心切分支。
5.1 最常用的四条命令与实际差异
核心就四条,记住就够用了:
# 存起来,附带一句说明 git stash push -m "登录页样式调整,未完成" # 看存了哪些 git stash list # 取出来并从栈里删掉 git stash pop # 取出来但保留在栈里 git stash applypop和apply的区别,很多人踩过坑。pop是"取出并删除",apply是"取出但保留"。如果你不确定这次取出来能不能用,用apply更稳,用坏了还能再来一次。pop适合确定只要这一次的情况。
list的输出长这样,stash@{0}是最新的一条:
stash@{0}: On main: 登录页样式调整,未完成 stash@{1}: WIP on feature-a: 8f3d2a1 补充接口文档取指定的某一条用git stash apply stash@{1}。删除单条用git stash drop stash@{1},清空全部用git stash clear——最后这个命令没有确认提示,手别抖。
5.2 未跟踪文件、部分文件怎么存
默认的stash只存已被 Git 跟踪的文件的改动。新建的文件因为还没add,不在跟踪范围内,stash的时候会被留在原地。这个行为坑过不少人:切了分支发现新建的文件还在,轻则疑惑,重则把不该带的文件带到别的分支上。
解决办法是加参数:
# 连未跟踪文件一起存 git stash push -u -m "带上新文件" # 连被 .gitignore 忽略的文件一起存(很少用,慎用) git stash push -a -m "连忽略文件一起"-u是--include-untracked的缩写,日常基本用这个就够。-a会把node_modules这类东西也塞进去,除非你明确知道自己在干什么,否则别碰。
还有个更精细的用法是-p,交互式选择要存哪些改动块:
git stash push -p它会一个 hunk 一个 hunk 地问你y/n,适合"这个文件里一半改动要存、一半要留着继续改"的情况。这个功能我用得不多,但每次用都很救命。
5.3 stash 的坑:为什么 pop 之后冲突了
stash不是万能的隔离舱。它存的是相对于当时 HEAD 的差异,如果你stash之后又切换分支、又提交了新东西,再pop回来时,差异可能已经对不上了。
典型场景:你在main上stash了改动,切到feature改了同一个文件并提交,再切回main执行pop。这时候冲突就来了,因为feature上的改动已经通过某种方式进到了main,而 stash 里那份还是基于旧代码的。
处理方式和普通冲突一样:解标记、add、commit。但有个细节要注意——pop冲突时 stash 不会被删除,它会留在栈里,等你确认没问题后手动drop。这是 Git 的保护机制,别以为是 bug。
我的个人习惯是:stash一律带-m写说明,list里一眼能看出是哪个活儿。堆了超过五条就清理一次,别让它变成垃圾场。这东西本质是个临时抽屉,不是仓库。
6. reflog:误操作之后的后悔药
reflog是 Git 里最被低估的命令。它记录的是本地仓库里 HEAD 和分支引用的每一次移动,包括提交、切换分支、合并、变基、重置——哪怕是你以为"已经丢了"的操作,只要发生过,基本都在里面。
6.1 reflog 里到底记了什么
敲git reflog,输出大概是这样:
a1b2c3d HEAD@{0}: reset: moving to HEAD~1 e4f5g6h HEAD@{1}: commit: 补充数据校验逻辑 c7d8e9f HEAD@{2}: rebase (finish): returning to refs/heads/main b1c2d3e HEAD@{3}: checkout: moving from feature-a to main每一行是一条记录,格式是"哈希 + 引用名@{序号} + 操作类型 + 描述"。序号 0 是当前状态,数字越大越早。
几个关键认知:
第一,reflog是本地的,不随push同步,也不随clone带过来。所以它只能救你自己机器上的误操作,救不了别人。
第二,默认保留时间是 90 天。超过这个期限的记录会被清理,对应的孤儿对象也会被垃圾回收。真要抢救长期丢失的东西,得赶在回收之前。
第三,reflog记录的是引用的移动,不是文件内容。这一点决定了它的能力边界,下一小节细说。
6.2 一次完整的找回流程
假设你执行了git reset --hard HEAD~3,把三个提交甩掉了,现在想找回来。
第一步,看reflog,找到出事的那个节点:
git reflog # a1b2c3d HEAD@{0}: reset: moving to HEAD~3 # f9e8d7c HEAD@{1}: commit: 第三个提交 # ...第二步,确认HEAD@{1}确实是你想要的完整状态。可以先用git log --oneline HEAD@{1} -5看一眼:
git log --oneline HEAD@{1} -5第三步,把分支指针挪回去:
git reset --hard HEAD@{1}三步搞定。这比什么"检查对象库""跑 fsck"简单多了,是日常救火的标准流程。
如果连reflog都找不到(比如过了 90 天,或者换了机器),还有最后一招git fsck --lost-found。它会把所有没有被引用的"悬空对象"列出来,输出到.git/lost-found目录里。但这一步没有提交信息、没有时间排序,只能靠git show <哈希>一个个看内容来判断,效率极低。我实际只在这种场景下用过一次,还是帮别人找的。
6.3 reflog 也救不回来的情况
必须说清楚reflog的边界,否则容易产生虚假的安全感。
它救不回工作区里未提交的改动。因为这些内容从来没有变成 Git 对象,根本不在版本库里。reset --hard、checkout -- .、误删文件,这些操作丢掉的未提交内容,reflog一点办法都没有。
它也救不回stash被clear掉的内容。stash clear之后,那些 stash 提交会变为孤儿,理论上还能通过fsck找,但成功率很低,而且极费时间。
它救不回被--amend覆盖后又过了很久的旧提交。这个其实能救——旧的提交对象还在,reflog里也有记录,只要没触发回收就行。但如果你已经关机好几次、跑过多次gc,那就悬了。
所以真正的结论是:reflog是补救措施,不是预防措施。指望它兜底,不如养成"动历史之前先建个备份分支"的习惯:
# 动大手术之前,先打个标记 git branch backup-before-rebase这条命令成本几乎为零,却能提供百分之百的保险。我现在的习惯是,凡是超过三步的 rebase 或者涉及--hard的操作,第一步一定是建备份分支。清理的时候删掉就行,一点都不麻烦。
7. 把这些命令串成日常操作流
单个命令会用了,还得知道什么时候用哪个。这一节我把几个高频组合场景串一下,都是从实际项目里抠出来的流程。
7.1 三个典型组合场景
场景一:整理本地提交,推之前让历史干净。
git fetch origin git log --oneline origin/main..HEAD # 看看自己领先了几个提交 git rebase -i origin/main # 交互式整理 # 编辑器里把碎片提交改成 fixup,主提交保留 pick # 保存退出,如有冲突按流程解 git push这个流程的关键在第一步fetch。不先拉取远端最新状态,rebase 的基点就是旧的,推上去还是会有冲突。
场景二:线上紧急修复,同步到开发分支。
# 在 main 上修好并提交,拿到哈希 git log --oneline -1 # 切到开发分支摘过来 git switch develop git cherry-pick <修复提交哈希> # 有冲突就解,解完 continue git push场景三:改到一半要切分支救火。
git stash push -u -m "用户中心改到一半" git switch hotfix # 处理紧急问题 git switch - # 切回上一个分支 git stash pop这里git switch -是个很方便的写法,等价于git checkout -,直接回到上一个分支,不用记分支名。
7.2 团队协作下的命令边界
自己一个人开发,历史随便改,出事了reflog捞回来就行。但只要涉及多人协作,就得划一条清晰的线。我的划分方式是这样的:
| 分支类型 | 允许操作 | 禁止操作 |
|---|---|---|
| 个人功能分支(未推送) | 任意 rebase、reset、cherry-pick | 无 |
| 个人功能分支(已推送、仅自己用) | rebase 后强制推送需谨慎 | 在别人拉过之后 rebase |
| 团队共享分支 | merge、cherry-pick、revert | rebase、reset 后强推 |
| 主干 / 发布分支 | 只能通过合入 | 任何形式的历史改写 |
最后一行加粗都不为过。主干分支被 rebase 过一次,整个团队本地都得重新拉,这在协作里是灾难级的事故。真要撤销主干上的某次提交,用git revert——它生成一个反向提交,历史往前走而不是往回改,所有人都能平滑接受。
revert和reset的区别,用一句话说:reset是"这事没发生过",revert是"这事发生过,我现在撤回"。前者改历史,后者加历史。共享分支上只能选后者。
7.3 我踩过的几个坑
最后分享几个实打实的教训,都是文档里不太会写的。
第一个坑:rebase中途commit,导致提交串乱掉。有次解冲突解到一半,顺手敲了个git commit,结果多出来一个游离的合并提交,整个 rebase 流程卡死。正确做法是解完冲突只add,然后git rebase --continue,让 rebase 自己生成提交。记住了:rebase 过程中不要手工commit。
第二个坑:stash pop时以为冲突是 bug。前面说过,pop冲突时 stash 不会自动删除,很多人以为出错了又pop一次,结果把同样的改动打进两份。看到冲突提示先别急,解完再说。
第三个坑:用reset --hard清理分支,忘了自己还有未推送的提交。这个最冤。解决办法就是前面说的:动--hard之前先看一眼git log,或者干脆先建备份分支。三秒钟的事,能省一晚上。
第四个坑:cherry-pick摘了一个依赖别人的提交。摘过来编译不过,还得回头找它依赖的那个提交,一个个摘。后来我养成习惯,摘之前先用git show <哈希> --stat看一眼这个提交动了哪些文件,判断有没有上下文依赖。
这些坑没有一个是靠读文档能提前避开的,都是手滑过一次才记住的。好在 Git 的容错性比想象中强得多,只要你别在共享分支上乱来,绝大部分错误都有办法兜回来。