关于 Revert 的那些“坑”
起因很简单,我需要把最近的几个 commit 给撤销掉,但又不能用 reset --hard
这种暴力手段,因为分支已经推到远程了。我想当然地以为 git revert
应该很轻松,结果...呵呵。
这篇笔记记录了我从抓狂到“原来如此”的全过程。
踩坑一:我以为 Revert 是删除,结果它是在“添乱”?
一开始,我的目标是回退到 ed44ae7
这个 commit:
8f5ff83 (HEAD -> main) stage
9b23011 stage
ed44ae7 Overhaul for production deployment
我下意识地想把 8f5ff83
和 9b23011
这俩哥们从历史上抹掉。差点就 reset --hard
了。
还好理智战胜了冲动。我记起来 revert
才是团队协作的安全选择。但它的工作方式和我想的完全不一样。
顿悟 #1:git revert
不是删除,而是用一个新的 commit 来“抵消”一个旧的 commit。 你的历史不会减少,反而会增加。这保证了历史的完整性,别人拉代码时不会被搞得一团糟。
踩坑二:说好的一起回滚,你怎么只滚了一个就停了?
知道了原理,我开始操作。我想回滚 8f5ff83
和 9b23011
,于是我找到了它们的父提交 ed44ae7
,然后敲下了自以为很帅的命令:
# 意思是从 ed44ae7 之后,到 8f5ff83 为止,全给我 revert 了!
git revert ed44ae7..8f5ff83
然后,诡异的事情发生了。它弹了个编辑器让我确认第一次 revert 的信息,然后...就没然后了。git log
一看,只多了一个 Revert "stage"
的提交。
我当时就???剩下那个呢?被你吃了?
顿悟 #2:git revert <范围>
不是原子操作! 它是一个循环,会从最新的 commit 开始一个一个地 revert。如果在 revert 第一个 commit 时就遇到了冲突,整个过程就会立即暂停,等你来擦屁股。
我的救星 git status
证实了这一点,它告诉我正处于冲突状态。
解决方案:
- 手动解决冲突文件里的
<<<<<<<
和>>>>>>>
。 git add .
告诉 Git 我搞定了。git revert --continue
让它继续去处理下一个。- 如果不想干了,
git revert --abort
可以随时跑路。
踩坑三:终极 BOSS - cannot lock ref 'HEAD'
正当我以为掌握了一切,准备再次大展拳脚时,一个更诡异的错误出现了:
hint: Waiting for your editor to close the file...
fatal: cannot lock ref 'HEAD': is at b548b33... but expected 8f5ff83...
这是什么鬼?我明明什么都没干,就是等编辑器关掉而已,HEAD
怎么自己“叛变”了?
我查了半天,差点以为是 Git 坏了。
顿悟 #3:元凶是后台的“小动作”,通常来自 IDE! 在我用命令行执行 revert
,Git 暂停并等待我关闭编辑器时,我的 VS Code 也没闲着。它自带的 Git 插件在后台默默地执行 git fetch
之类的操作来刷新状态。这个后台操作和我的前台操作发生了竞态条件,把 HEAD
的引用文件搞乱了。
终极解决方案(我的新圣经): 既然问题出在“等待编辑器”这个环节,那我就不给你等待的机会!
先用
git revert --abort
清理战场。用
--no-commit
(或-n
) 参数!bashgit revert -n ed44ae7..8f5ff83
这个
-n
参数是真正的宝藏!它的作用是:计算出所有 revert 需要的更改,把它们放到暂存区,但是不自动提交!自己手动提交。 因为没有自动提交,也就没有打开编辑器、没有暂停等待。后台的小动作根本没机会捣乱。
bash# 看一眼状态,所有更改都已经在暂存区了 git status # 然后,帅气地一次性提交! git commit -m "Revert: 回滚了两个 stage 提交,修复了某某问题"
我的最终笔记 & 新的工作流
以后再也不一个一个 revert
然后处理冲突了。我的新流程是:
- 想好要 revert 的范围
<start>..<end>
。 - 直接运行
git revert -n <start>..<end>
。 - 处理可能出现的冲突(如果有的话,一次性处理完)。
git add .
git commit -m "一个清晰的理由"
。git push
收工。
这个流程不仅避免了所有诡异的错误,还把一堆 revert 操作合并成了一个干净的 commit。完美。
管理私有开发库和公开发布库
最近有个需求,需要将一个内部开发的项目开源,但又不想把所有凌乱的、包含内部信息的 commit 历史都公开出去。解决方案就是用两个远程仓库。
核心思路:本地仓库同时连接两个远程库,一个私有(origin
),一个公开(public
)。向 origin
推送完整的开发历史,向 public
只推送“净化”过的历史。
最推荐的策略:合并提交 (Squash) 这个方法最简单安全。在私有库里随意提交,发布时,将一个版本的所有开发 commit 合并成一个干净的 commit,再推到公开库。
操作流程:
准备工作:在本地仓库添加公开库为远程。
bash# public 是我们给公开库起的名字 git remote add public git@github.com:your-username/my-project-public.git
合并历史:这是将一个分支上所有 commit 合并成一个的终极大法!
bash# 假设你在 main 分支 # --root 表示从第一个 commit 开始 rebase git rebase -i --root
在打开的编辑器里,保留第一行的
pick
,把其他所有行的pick
都改成s
(squash 的缩写)。保存后,再为这个“巨型”commit 写一个新的、清晰的 commit message。推送到公开库:
bash# 将当前分支强制推送到 public 远程的 main 分支 # --force 是必须的,因为公开库的历史被我们完全重写了 git push public HEAD:main --force
警告:这个操作尤其适合项目首次开源。如果公开库已经有别人在用,强制推送前一定要和所有人沟通好!
版本管理:如何表示开发中的版本
除了 v1.0
这样的正式版,我们怎么表示一个正在开发中 (WIP) 的版本呢?
功能分支 (Feature Branches):最推荐的方式。分支名本身就说明了一切。
feature/user-login
:开发新功能。bugfix/login-error
:修复 bug。wip/refactor-database
:明确告知这是个未完成的大改造。
开发分支 (
develop
):对于有明确发布周期的项目,可以维护一个develop
分支作为所有功能合并的主开发线,它永远代表“下一个版本”的开发状态。预发布版本号 (Pre-release Tags):对于库和包来说,这是最专业的做法。
1.2.0-alpha.1
(内部测试版)1.2.0-beta.2
(公开测试版)1.2.0-rc.1
(发布候选版)
核心命令辨析:reset
, checkout
的爱恨情仇
git reset
和 git checkout
功能强大,但也容易混淆。
git reset
:回滚到最后一次提交
如果你想撤销最后一次提交,reset
是你的好朋友,但它有三种不同的“脾气”:
git reset --soft HEAD~1
:最温柔。撤销 commit,但所有代码改动都保留在暂存区(像已经git add
了一样)。最适合修改 commit message 或把更多改动加进去。git reset HEAD~1
(默认模式是--mixed
):有点脾气。撤销 commit,代码改动保留在工作区,但需要重新git add
。适合“我再想想这些代码要不要”的场景。git reset --hard HEAD~1
:极度暴力。撤销 commit,代码改动全部永久删除。工作区和暂存区都变得干干净净。用前请三思,数据无价!
git checkout
的“身份危机”
checkout
曾经是个“瑞士军刀”,既能切换分支,又能恢复文件,容易让人迷惑。
- 切换分支:
git checkout develop
- 创建并切换:
git checkout -b feature/new-idea
- 恢复文件:
git checkout -- config.yml
(丢弃对config.yml
的修改)
为了让命令更清晰,Git 2.23 版本后引入了两个新命令:
git switch <分支>
:专门用来切换分支。git restore <文件>
:专门用来恢复文件。
建议:新项目或新习惯可以拥抱 switch
和 restore
,它们的意图更明确。但 checkout
无处不在,理解它的全部功能仍然是必备技能。