Git 里很少真的丢东西
Git 题考的是心智模型清不清楚:三个区域、一个指针、以及"哪些操作会改写历史"。理解这一层,reset、rebase、cherry-pick 的取舍都不用背。
01先看动不动历史改历史的操作别用在共享分支上,这条能避开八成事故。
02记住 reflogreset --hard 和删分支都能救回来,前提是操作后别再乱跑命令。
03选模型看发布节奏有版本号的客户端用 Git Flow,持续部署的 Web 用短分支。
1
三个区域与四种状态★
所有 git 命令都在挪一个指针
Git 的心智模型很窄:工作区、暂存区、本地仓库三个区域,以及 HEAD 指针。所有命令都是在"哪些文件、进哪个区、指针动不动"。想清楚这一层,reset / checkout / restore 的区别就不用背了。
工作区你编辑的文件,git status 显示 modified
→
暂存区 indexgit add 之后,下次 commit 会包含的内容
→
本地仓库git commit 之后,HEAD 指向的新提交
→
远程仓库git push 之后,别人能看到的
命令拆解:谁动、动哪个区git add <file> 工作区 → 暂存区
git restore <file> 暂存区 → 覆盖工作区(丢弃修改)
git restore --staged 暂存区 → 撤回,变回未暂存(工作区不变)
git commit 暂存区 → 本地仓库,HEAD 前进一格
git reset --soft H HEAD 回退,暂存区和工作区不变
git reset --mixed H HEAD 回退,暂存区也回退,工作区不变(默认)
git reset --hard H HEAD 回退,暂存区和工作区都回退(丢数据)
git revert H 新建一个"反向提交",历史保留
记忆:--soft 只动 HEAD;--mixed 再动暂存区;--hard 三样全动
- HEAD 是"当前在哪"的指针:通常指向一个分支(如 master),分支再指向提交。所以
git reset动的是分支指针,工作区是否变化看参数。 - "已追踪但被修改"和"未追踪"是两件事:
git checkout -- file只能还原已追踪文件;未追踪的新文件它管不了,要手动删。 - 暂存区不是可有可无的中间态:它让你能"只提交文件的一部分修改"(
git add -p),这是做出干净提交的关键手段。
面试怎么答Git 只有三个区域加一个指针:工作区、暂存区、本地仓库,以及指向当前分支的 HEAD。所有命令都是在挪文件或者挪指针。reset 的三个模式就是回退范围不同,soft 只动 HEAD,mixed 再动暂存区,hard 三样全动所以会丢数据。restore 是"用暂存区覆盖工作区",加 --staged 则是把暂存区退回未暂存。
2
merge 与 rebase★
本质是"历史要不要保留分叉"
唯一区别:merge 保留分叉,生成一个合并提交;rebase 把提交"复制"到新基底上,历史变成一条直线。所以判断标准是这个分支有没有别人在用——共享分支用 merge,自己的本地分支用 rebase。
◐ 可交互:同一个分支,merge 和 rebase 各留下什么历史
main
feature
merge 保留分叉:feature 上的 D、E 还是原来的提交,多出一个合并提交 M。
同一个场景,两种结果初始:A - B - C (main)
\
D - E (feature)
【git merge main】在 feature 上执行
A - B - C ------- M (feature)
\ /
D - E ---
M 是合并提交,两个父节点
历史保留了"这两条线什么时候汇合"
【git rebase main】在 feature 上执行
A - B - C - D' - E' (feature)
D 和 E 被重新应用,换了新的 hash
历史变成直线,好像一直在 main 上开发
注意:D' 和 E' 不是原来的提交,
hash 变了,所以共享分支上 rebase 会让别人的历史错乱
- 黄金法则:不要在已经推送、别人可能基于它开发的提交上 rebase。改写历史会导致别人 pull 时产生大量重复提交和冲突。
- 团队常见组合:本地开发时用
git rebase main保持自己的分支整洁,合并进 main 时用git merge --no-ff生成合并提交保留边界。 - 为什么变基后冲突要重复解决:rebase 是逐个提交重新应用,同一个冲突可能在多个提交里各出现一遍。用
git rerere可以记住解决方案自动复用。 - rebase -i 是做干净提交的关键:
squash合并、reword改信息、drop删提交。提 PR 前整理成几个有意义的提交,比几十个 fix typo 好太多。
面试怎么答merge 和 rebase 的区别是历史要不要保留分叉。merge 生成一个有两个父节点的合并提交,完整保留了分支什么时候创建、什么时候汇合;rebase 把提交复制到新基底上,hash 会变,历史变成一条直线。判断标准是这个分支有没有别人在用,共享分支必须用 merge,因为改写历史会让别人 pull 时产生重复提交和冲突;自己的本地分支可以 rebase 保持整洁。
3
reset / revert / restore
名字像,用途完全不重叠
三个词对应三种意图:reset 是"把分支指针挪回去"(改历史)、revert 是"新建一个反向提交"(不改历史,适合已推送)、restore 是"用某处内容覆盖文件"(不动提交)。
按意图选命令我想撤掉还没提交的修改
git restore file 丢弃工作区修改
git restore --staged file 从暂存区撤出,修改还在
我想撤掉最近一次提交,但保留改动
git reset --soft HEAD~1
我想撤掉最近一次提交,连改动也不要
git reset --hard HEAD~1 ⚠️ 工作区数据丢失
我想撤掉已经推送的提交
git revert <hash> 生成反向提交,安全
❌ 不要用 reset + push -f 会改写别人看到的历史
我想把某个文件恢复到某个提交的样子
git restore --source=<hash> file
git checkout <hash> -- file 老写法,等价
- 已推送到共享分支的提交只能用 revert:reset 会改写历史,别人再 pull 就会冲突或者重复。除非你确定只有你一个人在用这个分支,否则
push -f是团队事故的常见来源。 - revert 一个合并提交要带
-m:合并提交有两个父节点,要指定"以哪一边为主",git revert -m 1 <hash>。不带会报错,这是高频卡点。 - restore 是 Git 2.23 引入的:把原来
checkout的两个职责(切分支、还原文件)拆开了。新版本应该用 restore 和 switch,checkout 保留兼容。 reset --hard真的会丢数据:未提交的修改直接没了。但如果之前git add过,还能从git fsck --lost-found里捞回来。所以"先 add 再 reset"是个救命习惯。
面试怎么答三个命令对应三种意图。reset 是挪分支指针,会改写历史,只适合本地未推送的提交;revert 是新建一个反向提交,历史保留,是撤销已推送提交的唯一安全方式;restore 是用暂存区或某个提交的内容覆盖文件,不动提交。要注意 revert 合并提交必须带 -m 指定主父节点,reset --hard 会真的丢掉未提交的修改。
4
cherry-pick:复制而不是移动
挑一个提交过来的代价
cherry-pick 是把某个提交的改动"复制"到当前分支,生成一个新的提交(新 hash)。所以原提交还在原分支上,两边 hash 不同。这带来一个长期麻烦:以后 merge 时同一个改动可能出现两次冲突。典型场景与它的后患场景:hotfix 分支修了 bug,要合到 main 和 release
git switch release
git cherry-pick <hotfix-hash>
结果:
hotfix 上的原提交还在
release 上多了一个内容相同但 hash 不同的提交
后患:
以后 hotfix 合进 release 时,
Git 不知道这两个提交是"同一件事",
可能报冲突,或者产生一个空的合并提交
缓解:
合并时用 git merge 而不是再 cherry-pick
或者记录来源,在合并时用 -s ours 标记已合入
- 什么时候适合用:热修复要同步到多个版本分支、把误提到错误分支的一个提交挪走、从别人的分支里挑一个工具函数改动。
- 什么时候不该用:整条功能分支要合并时——那应该 merge 或 rebase,cherry-pick 会把一条连续的开发历史打散成孤立的提交,丢失上下文。
- 批量挑用区间:
git cherry-pick A..B挑 A 之后到 B 的所有提交(不含 A)。A^..B才包含 A。这个细节经常搞错。 - 冲突处理:解决后
git cherry-pick --continue;想放弃用--abort;只想跳过当前这个用--skip。
面试怎么答cherry-pick 是把某个提交的改动复制到当前分支,生成一个新的提交,所以 hash 和原提交不同,原提交还在原分支上。它适合热修复同步到多个版本分支、或者把误提到错误分支的提交挪走。后患是以后两个分支合并时 Git 不知道这两个提交是同一件事,可能重复冲突。整条功能分支合并应该用 merge 或 rebase,不要用 cherry-pick,那会把连续的历史打散。
5
冲突处理:rebase 冲突为什么难缠
因为你可能要解好几遍
冲突本身不难,难的是rebase 过程中同一个冲突要重复解决多次——因为它是逐个提交重新应用,每个提交都可能撞上同一处改动。
git rerere 就是为解决这个而生的。两种冲突的流程差异【merge 冲突】只解一次
git merge main
→ 冲突标记写进文件
→ 改完 git add <file>
→ git merge --continue(或 git commit)
【rebase 冲突】每个提交解一次
git rebase main
→ 停在某个提交,报冲突
→ 改完 git add <file>
→ git rebase --continue
→ 下一个提交可能又冲突,再来一遍
→ 想跳过这个提交:git rebase --skip
→ 整个放弃回原状:git rebase --abort
【推荐开启】
git config --global rerere.enabled true
解决过的同类冲突会被记住,下次自动套用
- 冲突标记长什么样:
<<<<<<< HEAD和>>>>>>> other之间是两边的版本,中间的=======分隔。三方合并时还会有一个 base 段,看它才能判断"哪边是真改动"。 - 不要用"接受一边"草率解决:把
<<<<<全删掉留一边,很容易丢掉对方的功能。尤其别人的改动你不要替他决定。 - 解冲突后一定要跑一遍:Git 只知道文本层面合并成功,不知道语义是否成立。两个分支各改了一个函数的不同部分,合并后能编译但逻辑错了,这种情况只能靠测试。
- 冲突太大就放弃 rebase:
git rebase --abort回到原状,改用 merge。硬啃几十个提交的 rebase 冲突不如直接合并。
面试怎么答冲突本身不难,rebase 难在同一个冲突要解好几遍,因为它逐个提交重新应用,每个提交都可能撞上同一处改动。解决办法是开启 git rerere,让它记住解决过的冲突自动套用。解冲突时不要草率地"接受一边",因为很容易丢掉对方的功能。Git 只保证文本层面合并成功,语义是否正确它不知道,所以解完一定要跑构建和测试。冲突太多就直接 rebase --abort 改用 merge。
6
分支模型:三种主流做法
选模型看发布节奏
三种主流:Git Flow(多分支、适合有明确版本的客户端产品)、GitHub Flow(只有 main + 短分支,适合持续部署的 Web)、Trunk Based(几乎都在 main 上,靠特性开关控制发布)。Web 服务绝大多数用后两种。
三种模型的差别【Git Flow】
main / develop / feature/* / release/* / hotfix/*
优点:版本发布可控,hotfix 路径清晰
缺点:分支多,合并路径复杂,不适合一天多次发布
适合:客户端、SDK、有版本号的产品
【GitHub Flow】
main + feature/*
PR 合并到 main 即部署
优点:简单,一条规则
缺点:需要完善的 CI 和回滚能力
适合:持续部署的 Web 服务
【Trunk Based】
所有人直接提交到 main,未完成的用特性开关关掉
优点:没有长命分支,没有大合并
缺点:要求测试和开关治理到位
适合:成熟的工程团队、高频发布
- 分支活多久是关键指标:超过两三天就要合并一次,或者拆小。长命分支的最终合并一定痛苦,这是必然的,不是运气问题。
- 特性开关能替代长分支:功能没做完就合并进 main,用开关控制不对用户展示。好处是永远没有大合并,坏处是开关要定期清理,否则代码里全是死分支。
- hotfix 怎么走:Git Flow 里从 main 拉 hotfix 再合回 main 和 develop;GitHub Flow 里直接从 main 拉一个小分支,修完 PR 合并、打 tag。后者更简单但要保证能快速回滚。
- 面试怎么答:不要说"我们用的是 Git Flow"就完了,要说"我们是持续部署的 Web 服务,用的是短分支加 main,PR 合并即发布,rollback 靠 tag 重新部署"——把模型和发布节奏关联起来。
面试怎么答分支模型要看发布节奏。Git Flow 有 develop、release、hotfix 多条分支,适合有明确版本的客户端产品;GitHub Flow 只有 main 加短分支,PR 合并即部署,适合持续部署的 Web 服务;Trunk Based 几乎都在 main 上开发,未完成功能用特性开关关掉,适合成熟团队高频发布。关键指标是分支存活时间,超过两三天就该合并,长命分支的合并一定痛苦。
7
reflog:误删之后怎么救★
Git 几乎不会真的丢东西
git reflog 记录了HEAD 的每一次移动,包括 reset、rebase、checkout、commit。所以只要提交过,即使 reset --hard 或者删了分支,都能在 reflog 里找到那个 hash 救回来。这是最该记住的救火技能。三个救火场景【reset --hard 之后反悔】
git reflog
a1b2c3d HEAD@{0}: reset: moving to HEAD~1
e4f5g6h HEAD@{1}: commit: 加了导出功能 ← 就是这个
git reset --hard e4f5g6h
【删了还没合并的分支】
git reflog 找到分支最后一次提交的 hash
git switch -c feature-lost e4f5g6h
【rebase 解到一半发现搞错方向】
git reflog 找到 rebase 开始前的 hash
git reset --hard <那个 hash>
(或者直接 git rebase --abort,如果还在 rebase 过程中)
【reflog 也有期限】
默认 90 天,gc 会清理。
已 add 但没 commit 的内容不在 reflog 里,
要用 git fsck --lost-found 找 dangling blob
- reflog 是本地记录:只在你的机器上,别人看不到。所以"我的提交丢了同事能不能找回"——不能,得在你机器上找。
- 没 commit 过的东西 reflog 管不了:工作区被覆盖的内容不在任何提交里。
git add过的还有救(blob 已写入对象库),完全没 add 的就真没了。 - 为什么说 Git 很难丢数据:只有真正执行 gc 清理了不可达对象才会丢。所以出事的正确顺序是"先 reflog,别急着别的操作"——尤其不要接着乱跑 reset。
- 一个习惯:做危险操作(rebase、reset --hard、filter-branch)之前,先
git branch backup-$(date +%s)打个备份分支,比事后救快得多。
面试怎么答reflog 记录 HEAD 的每一次移动,包括 reset、rebase、checkout,所以只要提交过,即使 reset --hard 或者删了分支都能从里面找到 hash 救回来。这是最实用的救火技能。但要注意 reflog 是本地记录,别人的机器上看不到;而且没 commit 过的东西它管不了,add 过的还能用 fsck --lost-found 找 dangling blob,完全没 add 的就真丢了。
8
stash 与临时切换
别用 stash 当长期存储
stash 是"把工作区和暂存区的改动收起来,回到干净状态"。适合临时切分支修急事。不适合长期存放——它不是备份,容易忘,而且容易和后来的改动冲突。- 默认不包含未追踪文件:新建的文件
git stash不管,要加-u。这是最常见的"我的新文件呢"的原因。 - stash 冲突比 merge 更烦:因为 stash 没有版本历史,冲突时看不到 base,完全靠人判断。所以不要存太久。
- 更好的替代:临时要切分支,直接建一个 WIP 提交(
git commit -m "wip"),切回来再reset --soft。有提交记录可查,比 stash 可靠得多。 - 团队约定:不要把
git stash当成"把活存起来明天做",第二天你很可能忘了它在哪,甚至换了电脑就没了。
面试怎么答stash 适合临时切分支修急事,把工作区和暂存区的改动收起来。要注意它默认不包含未追踪的新文件,要加 -u。它不适合长期存放,因为没有版本历史,冲突时看不到 base 很难解,而且很容易忘。如果只是临时切分支,更可靠的做法是直接建一个 wip 提交,回来再 reset --soft,这样有记录可查。
9
提交信息与 PR 规范
让历史能被人读
提交信息不是格式洁癖,它决定了能不能用 git log 追问题。约定式提交(Conventional Commits)的价值在于能被机器解析:自动生成 changelog、自动决定版本号、以及让人一眼看出这个提交改了什么性质的东西。
提交信息的结构<type>(<scope>): <subject>
← 一行摘要,不超过 50 字,不加句号
<正文> ← 为什么改,不是改了什么(代码已经说明)
每行不超过 72 字
<footer> ← BREAKING CHANGE / Closes #123
常见 type:
feat 新功能(进 MINOR 版本)
fix bug 修复(进 PATCH 版本)
refactor 重构,不改行为
perf 性能优化
docs 文档
test 测试
chore 构建、依赖、工具
ci CI 配置
- 为什么"改了什么"不重要:
git diff已经说明改了什么。提交信息真正有价值的是为什么——当时的约束、被否决的方案、以及这个改动的坑。半年后只有这条信息能救你。 - BREAKING CHANGE 是版本号的关键:写在 footer 里,语义化版本工具会据此跳 MAJOR。漏写就是给使用者埋雷。
- 一个提交只做一件事:修 bug 和格式化混在一个提交里,review 时根本看不出真正的改动。用
git add -p拆开。 - PR 描述要写"怎么验证":改了哪些文件 review 时能看,但"我怎么确认它是对的"只有作者知道。写清测试步骤、影响范围、回滚方式,比写一堆背景有用。
- commit 还是 PR 描述:commit 记录"每一步做了什么",PR 描述记录"整体为什么这么做"。合并时如果 squash,PR 描述就是最终的提交信息。
面试怎么答提交信息决定能不能用 log 追问题。约定式提交的价值是能被机器解析,自动生成 changelog、决定版本号。结构是一行 type 加 scope 加摘要,正文写为什么改而不是改了什么,因为 diff 已经说明了。footer 里写 BREAKING CHANGE 和关联 issue,漏写会导致版本号跳错。另外要保证一个提交只做一件事,用 git add -p 拆开,PR 描述重点写怎么验证和怎么回滚。
10
大仓库的几件实事
clone 太慢、status 太卡
仓库变大后有三个具体痛点:clone 慢、status 卡、log 慢。对应的解法是浅克隆 / 稀疏检出、文件系统监控、以及 commit-graph。这些都是配置层面的,不用改工作方式。
四个实用配置【浅克隆】只要最近的历史
git clone --depth=1 <url>
优点:clone 快几十倍
缺点:看不到历史、不能切老分支
适合:CI 机器、只需要构建的场景
【部分克隆】按需拉对象
git clone --filter=blob:none <url>
只拉提交和树,文件内容用到才拉
适合:大仓库日常开发
【稀疏检出】只要部分目录
git sparse-checkout set apps/web packages/ui
适合:monorepo 里只关心自己那部分
【加速 status】
git config core.fsmonitor true
git config core.untrackedCache true
大仓库里 git status 从几秒降到几百毫秒
- 为什么 status 会变慢:它要遍历工作区所有文件跟索引比对。文件几十万时,即使有缓存也慢。
fsmonitor让文件系统主动告诉 Git 哪些变了,不用全量扫。 - commit-graph 是什么:把提交的父子关系预计算成一张表,
git log和merge-base会快很多。新版本 clone 时默认带,老仓库可以手动git commit-graph write --reachable。 - 二进制大文件不要进 Git:设计稿、视频、数据集应该用 Git LFS 或者对象存储。一旦进历史就很难清了(要 filter-repo 改历史,所有人都得重新 clone)。
- monorepo 里更要紧的是构建缓存:Git 层面的优化只解决 clone 和 status,构建慢要靠 Turborepo / Nx 的远程缓存。这两件事不要混为一谈。
面试怎么答大仓库的三个痛点是 clone 慢、status 卡、log 慢。clone 用浅克隆或部分克隆,只拉需要的对象;status 慢开 core.fsmonitor 让文件系统主动通知变更,不用全量扫;log 慢靠 commit-graph 预计算父子关系。另外二进制大文件不要进 Git,进了历史就很难清,要用 LFS 或对象存储。monorepo 里构建慢是另一件事,要靠 Turborepo 这类工具的远程缓存。
11
速答清单
考前十分钟只看这一页
01Git 的三个区域是什么?
工作区、暂存区、本地仓库,加上指向当前分支的 HEAD 指针。所有命令都是在挪文件或挪指针。
02reset 三个模式的区别?
soft 只动 HEAD;mixed 再回退暂存区,工作区不变;hard 三样全动,未提交的修改会丢。
03reset 和 revert 怎么选?
本地未推送用 reset 改历史;已推送共享分支必须用 revert 生成反向提交,reset + push -f 会影响别人。
04revert 合并提交为什么要带 -m?
合并提交有两个父节点,-m 1 表示以第一个父节点为主去反向应用,不带会报错。
05restore 和 checkout 的关系?
Git 2.23 把 checkout 的职责拆成 restore 和 switch,还原文件用 restore,切分支用 switch。
06merge 和 rebase 的取舍?
merge 保留分叉生成合并提交;rebase 把提交复制到新基底,hash 会变、历史变直线。共享分支用 merge。
07rebase 的黄金法则?
不要在已推送、别人可能基于它开发的提交上 rebase。改写历史会让别人 pull 时产生重复提交和冲突。
08rebase 时同一冲突要解多次怎么办?
开启 git config --global rerere.enabled true,它会记住解决过的冲突自动套用。
09cherry-pick 有什么后患?
它复制改动生成新 hash,原提交还在。以后两分支合并时 Git 不知道是同一件事,可能重复冲突。
10解完冲突就可以提交了吗?
不行。Git 只保证文本层面合并成功,语义是否正确它不知道,必须跑构建和测试。
11Git Flow 适合什么场景?
有明确版本号的客户端、SDK 产品。Web 服务更适合 GitHub Flow 或 Trunk Based,分支少、发布快。
12分支该活多久?
两三天内合并,或者拆小。长命分支的最终合并一定痛苦,这是必然的。
13reset --hard 之后还能救回来吗?
能,只要提交过。git reflog 找到那个 hash,再 reset --hard 回去。但没 commit 过的东西 reflog 管不了。
14reflog 能跨机器用吗?
不能。它是本地记录,只在你的机器上。别人看到的只有推上去的提交。
15没 add 过的文件误删能恢复吗?
不能。add 过的还有救,用 git fsck --lost-found 找 dangling blob;完全没 add 过的 Git 里没有记录。
16stash 为什么容易出问题?
默认不含未追踪文件要加 -u;没有版本历史所以冲突时看不到 base;容易忘。临时切分支更推荐建 wip 提交。
17提交信息该写什么?
写为什么改,不写改了什么,因为 diff 已经说明。BREAKING CHANGE 必须写在 footer,否则版本号会跳错。
18clone 大仓库怎么加速?
浅克隆 --depth=1 或部分克隆 --filter=blob:none;日常开发还可以用 sparse-checkout 只检出自己关心的目录。