我把没测完的 dev 合进了 master
这是上周四的事。手上同时开着三个分支,敲 git merge 的时候 tab 补全选错了分支,把还没测完的 dev 合到了 master,还 push 了。等我看到 CI 群里飘红,master 上已经多了 17 个提交。
那一刻脑子里闪过的念头是"要不要删库跑路"。还好师傅在旁边,一句"先别慌,别用 reset",把我从误操作的边缘拉了回来。
先搞清 reset 和 revert 的区别
这俩都能"撤销提交",但原理完全不同,用错了后果差别巨大。
git reset 是移动分支指针,把历史改写成没发生过:
git reset --hard HEAD~3
执行完那 3 个提交从历史里消失了。如果这些提交已经 push 到远程,你的本地历史就和远程分叉了,再 push 只能 git push -f 强推——而强推会覆盖掉别人在这期间推上去的代码,这是团队协作里的大忌。
git revert 是创建一个新的提交,内容是被撤销提交的反向操作:
git revert abc1234
历史完整地保留着,只是多了一条"我把 abc1234 的改动取消了"的记录。这条记录可以被正常 push,不动任何人的历史。
一句话总结:已经 push 的用 revert,还没 push 的随便 reset。
救回误合并:revert 一个 merge commit
我的情况是合并提交,比普通提交麻烦一点。直接 git revert <merge-commit> 会报错:
$ git revert 3f8a2b1
error: commit 3f8a2b1 is a merge but no -m option was given.
fatal: revert failed
因为 merge commit 有两个父提交,Git 不知道你要"回到"哪一个:
$ git show 3f8a2b1
commit 3f8a2b1...
Merge: a1b2c3d 9e8f7g6
-m 1 表示以第一个父提交(a1b2c3d)为准,也就是"我合并时所在的分支",通常就是 master 合并前的状态。-m 2 表示以第二个父提交(被合进来的 dev)为准,那就变成"放弃 master 的改动,保留 dev",正好相反。
我要的是撤销 dev 带来的改动,所以用 -m 1:
git revert -m 1 3f8a2b1
git push origin master
世界清净了。master 上的代码回到了合并前的样子,历史记录完整,同事的提交一个没丢。
一个后续坑:revert 之后再也合不进去
两天后 dev 测完了,要正式合进 master。我 git merge dev,结果 Git 说 Already up to date,但 dev 的改动明明没有!
原因:Git 判断"要不要合"看的是提交历史,不是文件内容。dev 上的那些提交在历史里已经存在于 master 了(虽然内容被 revert 掉了),所以 Git 认为没有新东西。
解决办法:把之前那个 revert 提交再 revert 掉,也就是"撤销撤销":
git revert 5c6d7e8 # 5c6d7e8 是当初那次 revert 产生的提交
git merge dev
这个坑我记了很久,因为它的表现实在反直觉。
reflog:Git 的操作日志,救命用的
另一次事故是我手滑执行了 git reset --hard HEAD~5,直接把昨天写了 4 小时的提交弄没了。git log 里翻不到,当时真的冷汗都下来了。
师傅让我敲:
$ git reflog
5c6d7e8 HEAD@{0}: reset: moving to HEAD~5
a1b2c3d HEAD@{1}: commit: 完成订单导出功能
9e8f7g6 HEAD@{2}: commit: 修复库存扣减的边界问题
...
git reflog 记录的是 HEAD 的每一次移动,包括被 reset 掉的、被删除的分支上的提交。找到出事前那个 commit hash,直接:
git reset --hard a1b2c3d
代码全回来了。
要注意的是 reflog 是本地的,不参与 push / clone,而且有过期时间(默认 90 天,裸露的提交 30 天)。所以它是"本地后悔药",不是备份——真正重要的代码还是要靠 push 到远程。
merge 还是 rebase
这个问题组里吵过好几次,我说说我现在的做法。
merge 保留完整的分叉历史,会多一个 merge commit。它的优点是不改写任何历史,安全;缺点是提交图会很乱,git log --graph 看过去全是蜘蛛网。
rebase 是把当前分支的提交"摘下来,重新接到目标分支顶端",历史是一条直线,非常干净。代价是它会改写提交——rebase 后那些提交的 hash 全变了。
所以我给自己定了条铁律:只对本地、还没 push 的提交做 rebase;已经推送到远程、可能有人在用的分支,绝对不 rebase。
日常流程是这样:
# 在 feature 分支上开发,期间 master 被别人推进了几个提交
git checkout feature
# 先把本地没推送的几个提交整理一下(合并、改 message)
git rebase -i HEAD~3
# 再把 master 的新提交同步过来,让自己的提交接在最新的 master 后面
git fetch origin
git rebase origin/master
# 解决冲突后,切回 master 合并,这时是快进合并,不会有 merge commit
git checkout master
git merge feature
git rebase -i 我主要用来合并那些"修复一下 typo"、"再改一点"之类的垃圾提交,推上去之前把 5 个提交压成 1 个,review 的人会舒服很多。
几条我用血泪换来的习惯
- push 之前一定
git status+git log -1看一眼,确认当前分支和改动。我的事故就是连这两步都省了。 - 危险操作前先建个备份分支:
git branch backup/20180405,成本一秒,能省一天。 git push -f一律改成git push --force-with-lease。后者在发现远程有你不知道的新提交时会拒绝强推,避免覆盖同事的代码。- merge 冲突时别急着删别人的代码,用
git log --merge -p看看两边改动的意图。
Git 这东西,看教程觉得简单,真出事的时候才发现自己只会 add/commit/push 三板斧。上面这些命令我建议每人在本地建个测试仓库敲一遍,出事的时候手不抖。