一场真实的Git事故
凌晨1:47,上线窗口只剩40分钟。我把feature/order-v2合并到release分支,git merge报出12个冲突文件。手比脑子快,执行了git reset --hard origin/release,然后整个人清醒了——本地feature分支上3天的工作,包括昨天刚写完的订单拆单逻辑,全部消失。
第一反应是找备份,没有。第二反应是翻IDE的Local History,只恢复了几行。最后查了Git官方文档,用git reflog找回了所有提交,从慌乱到恢复用了7分钟。这篇文章把这套方法完整记录下来,包括后来我处理过的上百次冲突和丢失场景。
问题:为什么Git会丢代码
Git丢代码总共分两种:
- 你没提交,工作区的修改被覆盖
- 你提交了,但reset/checkout/branch -D把引用删了
第一种靠git stash还能捞,第二种所有人都以为没救了。实际上Git的设计里,已提交的对象在垃圾回收之前一直都在,丢的只是「引用」。先理解这个,后面才有底气。
冲突的本质,是Git不知道你俩谁说了算。举个实际例子:
# 最终会制造出冲突
cd /tmp && rm -rf git-rescue-demo && mkdir git-rescue-demo && cd git-rescue-demo
git init -b main
git config user.name "developer"
git config user.email "dev@example.com"
echo "v1: 订单状态" > order.php
echo "status: pending" >> order.php
git add order.php && git commit -m "init: order v1"
git checkout -b feature/order-v2
echo "v2: 订单状态" > order.php
echo "status: paid" >> order.php
echo "extra: 拆单逻辑" >> order.php
git commit -am "feat: 拆单逻辑"
git checkout main
echo "v1: 订单状态" > order.php
echo "status: shipping" >> order.php
git commit -am "fix: 发货状态"
两条分支在同一行上动了同一个文件,这就是冲突的根源。
方案一:merge,简单但是乱
git merge生成一个合并提交,保留两边的历史。适合release分支收feature,不适合长期开发分支。
git checkout main
git merge feature/order-v2
# 输出:Auto-merging order.php CONFLICT (content): Merge conflict in order.php
三种处理方式,按推荐度排序:
1. merge --abort 先下车
git merge --abort
# 回到merge之前,worktree干净
git status
# On branch main, nothing to commit
看不懂冲突时,abort永远是对的。别硬刚。
2. checkout --ours/--theirs 快速站队
# 站在当前分支的角度,丢掉对方的改动
git checkout --ours -- order.php
git add order.php
git commit -m "merge: 保留当前分支的状态"
# 或站在对方分支的角度
git checkout --theirs -- order.php
git add order.php
git commit -m "merge: 采用feature/order-v2"
3. 手动改冲突文件,这是常态
vim order.php
# <<<<<<< HEAD
# v1: 订单状态
# status: shipping
# =======
# v2: 订单状态
# status: paid
# extra: 拆单逻辑
# >>>>>>> feature/order-v2
# 改成:
# v2: 订单状态
# status: paid
# extra: 拆单逻辑
# 并加上对shipping状态的处理
git add order.php
git commit -m "merge: 手动解决订单状态冲突"
方案二:rebase,历史干净但危险
git rebase把你的提交「摘下来」,接到目标分支的最前面。提交历史是一条直线,但没有merge提交,无法看出合并时间点。
git checkout feature/order-v2
git rebase main
# 同样会冲突
vim order.php
# 解决方式同上,但:
git add order.php
git rebase --continue
# 注意:rebase过程中不产生额外的merge commit
rebase遇到冲突的逃生门:
git rebase --abort
# 完全回到rebase之前,你的提交一个都不会少
merge和rebase的实操数据对比
我用同一份代码在两个环境分别执行merge和rebase,Git 2.43.0,Ubuntu 22.04,仓库530个提交:
| 操作 | 冲突文件数 | 解决耗时 | 最终提交数 | 历史形态 |
|---|---|---|---|---|
| git merge | 12 | 手动改+验证:2.5小时 | 531+1个merge节点 | 有分叉,直观 |
| git rebase | 12 | 同上:2.5小时 | 被操作的分支提交全部重写 | 直线,但每个commit的hash变了 |
| git cherry-pick | 3(分4次挑选) | 47分钟 | 挑选个数 | 完全可控 |
结论很明确:merge适合看协作关系,rebase适合一个人收拾自己的分支,cherry-pick适合从别人的分支里精准捞提交。
方案三:cherry-pick,精准捞提交
如果feature分支上一共提交了20次,但只有最后3次是你要的,用merge会把20次全部拉进来。cherry-pick可以只挑那3个:
# 查看feature分支最近5次提交
git log --oneline -5 feature/order-v2
# 09f3c21 fix: 拆单边界问题
# 2b7e9a1 test: 拆单接口验证
# 4d6c8f0 feat: 拆单主逻辑
# 8a1b3c5 refactor: 订单状态枚举
# 77ff2e1 docs: 订单注释
git checkout main
git cherry-pick 4d6c8f0 2b7e9a1 09f3c21
# 只带这三笔,不带refactor和docs
# 如果冲突:
git cherry-pick --abort # 放弃整批
git cherry-pick --skip # 跳过当前这一个
git cherry-pick --continue # 解决冲突后继续
cherry-pick还有两个实用变体:
# 只拣合并提交里的某一个改动
git cherry-pick -m 1
# 连续一段提交,取两端hash
git cherry-pick 4d6c8f0^..09f3c21
丢了的提交怎么找回来
处理完冲突,回到文章开头的场景。所有在本地执行过的命令,都被记录在.git/logs/HEAD里,这就是reflog。看:
git reflog --date=iso
# 20f0c12 HEAD@{2024-01-15 01:47:33 +0800}: reset: moving to origin/release
# 9a3b7d2 HEAD@{2024-01-15 01:41:55 +0800}: merge feature/order-v2: Merge made by the 'ort' strategy.
# 7c02b88 HEAD@{2024-01-15 01:38:11 +0800}: checkout: moving from feature/order-v2 to main
# 6e1f4c5 HEAD@{2024-01-15 01:29:02 +0800}: commit: feat: 拆单逻辑
# 5f0a9b3 HEAD@{2024-01-15 00:56:40 +0800}: commit: fix: 并发库存扣减
# 2c4d7a1 HEAD@{2024-01-15 00:31:22 +0800}: commit: refactor: 状态机抽取
看到了吗?被我reset掉的9a3b7d2,那个包含3天工作的merge提交,正躺在reflog里。
恢复命令就一句:
git reset --hard 9a3b7d2
# HEAD is now at 9a3b7d2 整合所有改动
git log --oneline -3
# 9a3b7d2 (HEAD) Merge feature/order-v2
# 6e1f4c5 feat: 拆单逻辑
# 5f0a9b3 fix: 并发库存扣减
一切回来了。
reflog失效场景:git gc之后
reflog默认保留90天,过期对象会被git gc清掉。如果公司有定时gc任务,或者你手滑执行了git gc --prune=now,reflog救不了你。这世上还有最后一道防线——git fsck:
git fsck --lost-found --no-reflogs
# dangling commit 9a3b7d2
# dangling blob 4f8eaa1
# dangling commit 6e1f4c5
然后逐个找回:
git show --stat 9a3b7d2
git merge 9a3b7d2 # 或者 cherry-pick
fsck扫描对象库里所有悬空的commit和blob,只要没被gc物理清除,就有救。
提交在,工作区被reset --hard清了怎么办
# 先搞清楚你最后一次提交的hash
git reflog | head -20
# 假设最后一次commit是6e1f4c5
# 但工作区里还没add的修改没了,这真的救不了。
# 唯一能救的是stash过的:
git stash list
git stash show -p stash@{0}
git stash apply stash@{0}
rerere:提前替你把冲突解决了
一个冷门但救命的功能:rerere(reuse recorded resolution),会把你的冲突解法记录下来,下次同样的冲突自动应用。
# 开启(写进全局配置)
git config --global rerere.enabled true
# 用法:什么额外操作都不用做
# 第一次merge冲突,手动解决,add,commit
# 下次同样的两个分支/改动再merge时,Git自动应用上次的解法
# 在merge、rebase、cherry-pick里都生效
我用它处理过最典型的情况:同一个feature反复对main做rebase,每次都在同一个文件的同一个位置冲突。第一次手动改完,后面全部自动解决,一次rebase从40分钟压缩到12分钟。
效果数据:整个救援流程的耗时
| 场景 | 操作 | 耗时 | 结果 |
|---|---|---|---|
| reset --hard 丢commit | git reflog + reset | 7分钟(其中5分钟在确认hash) | 完整恢复,零丢失 |
| gc后丢commit | git fsck --lost-found | 15分钟 | 恢复95%的提交,丢了几个blob |
| 大分支冲突12个文件 | rerere自动解决 | 第二次merge耗时从2.5h降到12分钟 | 全部自动应用 |
| 误删未提交的工作区修改 | 无解 | - | 无法恢复,只能认栽 |
避坑指南
这些坑我全踩过,代价是真实的加班和线上事故。
- reset --hard之前没有任何提示。它不问你「确定吗」,直接丢。养成习惯:reset前先
git stash list、git reflog看一眼有没有东西。或者干脆用git reset --hard前先打一个tag。 - rebase是重写历史,push到远程后绝对别rebase。你会把同事的提交和自己的提交全搞乱,然后被迫用
git push --force-with-lease擦屁股。这个命令同样危险,团队里应约定只在自己一个人的分支上用。 - cherry-pick复制的是改动,不是上下文。它会把源提交的patch应用到当前分支,但和之前的关联提交(比如依赖的commit)可能丢失,导致逻辑不完整。挑选后跑一遍测试,别信代码审查者的感觉。
- CI/CD里用yaml配置时,别直接跑git pull --rebase。很多Docker容器里没配置user.email,rebase会报错中断。在Jenkins或GitLab Runner里用
git fetch && git reset --hard origin/main代替,干净且不产生merge commit。 - 裸仓库(bare repo)没有reflog。你推送到远程分支的提交如果被force push覆盖,reflog在远程仓库里存在,但本地看不到。处理方式:任何一个拉了旧代码的同事,他本地的reflog里都留着旧提交,问他拿。
- .git目录被误删,一切白搭。reflog、fsck全部失效。这题无解,奉劝大家别用
rm -rf .git来「清理」。平时给.git目录做备份的人,我是真没见过一个。 - git filter-repo或filter-branch改写历史后,如无必要,别找旧hash。改写后的旧commit还在对象库里,但所有引用都没了,fsck可能捞到一堆历史垃圾。确认不再需要时,用
git gc --prune=now清掉,省的日后误用。
最后说点原理
Git保存的是对象(commit/blob/tree),分支、tag、HEAD都只是指向对象的引用。reflog记录的是引用变化的历史,它属于本地文件,不会推到远程,也不会被merge/rebase/checkout清掉,唯一的敌人是gc。所以,只要你的本地对象库里还留着那个commit,你就有机会把它捞回来。
git merge和git rebase的区别也不难记:merge的结果是「两条路汇成一个节点」,rebase的结果是「把一条路上的脚印全部挪到另一条路的终点」。说白了,看你的团队怎么理解历史。
记住两条实操法则:提交频率高一点,每个commit只干一件事;reset前先reflog看一眼。这篇文章里的所有命令在Git 2.43.0上验证过,老版本(2.20以下)reflog用法一致,fsck的参数不带任何变化。保存命令清单,下次遇到直接抄。