Administrator
发布于 2018-04-05 / 352 阅读
6

Git 分支管理与误合并回滚实战

我把没测完的 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 三板斧。上面这些命令我建议每人在本地建个测试仓库敲一遍,出事的时候手不抖。

参考