Git如何回滚一次错误的合并

36,252 阅读7分钟

原文发表在知乎专栏 前端杂货铺, 欢迎关注我的专栏,转载请注明出处

今天不说前端,来聊聊git吧。 发现现在的小孩,玩框架一套一套的,等到玩点实质的工程化的东西就不行了。 git 这么好的工具,培训班怎么可以忽视他的重要性呢?

再来聊聊git的工作流程

很多人对Git究竟是一个怎样的系统,还是一知半解。 在这里强烈建议大家先理解git的核心思想和工作原理,有过subversion或者perforce使用经验的人更是需要摒弃之前所见所学,重新接受这样一个新思想。 我们不再这里赘述其几本原理,我们来介绍一下其简单工作流程。 Git以一个自有的思维框架管理着三个不同的盒子Commit HistoryINDEXWorking Directory

  • Commit History 历史记录,存储着所有提交的版本快照,并由当前分支引用的指针HEAD指向该分支最新一条提交。
  • INDEX 索引,也叫暂存区域。它是一个文件,保存着即将提交的文件列表快照。
  • Working Directory 工作目录,是从git仓库压缩数据当前版本中解包出来的文件列表。所以你在本地磁盘看到的你项目源码的文件列表,其实就是git开放给你的一个沙盒。在你将文件的修改天道到暂存区域并将快照记录到历史之前,你可以随意更改。

理解了这三者的含义后,我们试着来理解一下git的工作流程。 一切的开始,混沌之间,我们要干一件大事,在terminal里面敲打了几下键盘

git init 

混沌初开,幻化三界:HEADINDEXWorking Directory。这就是世界最开始的样子git仓库仿佛就是掌管三界之神。而Working Directory就是他分配给你生产和工作的地方,你可以在这里肆意的创造。而为了安全和管理的有序我们需要把我们的添加与修改的文件交给git仓库。Git首先会将修改的文件标记起来放入暂存区、然后git找到暂存区域的文件内容将其永久性的存储为快照到git仓库,此时HEAD的指针指向这个最新的快照。

如图,总结下三个步骤

  1. 在工作目录中修改文件。
  2. 暂存文件,将文件的快照放入暂存区域。git add
  3. 提交更新,找到暂存区域的文件,将快照永久性存储到 Git 仓库目录 git commit

git 的基本工作流程就是在不断的重复这三个步骤,最终git仓库目录形成了一个快照堆栈,每产生一次新的版本,HEAD就会指向这个版本。

这里我们创建了下面这些文件:

├── README.md
├── v1.js
├── v2.js
└── v3.js

形成了下图的提交历史


3aa5dfb v3  (<- HEAD)
        |
5aab391 v2
        |
ff7b88e v1
        |
95d7816 init commit

下面我们来看看怎么利用checkout、reset、revert 来操作这个仓库目录

checkout 、reset 还是 revert ?

checkout

版本控制系统背后的思想就是「安全」地储存项目的拷贝,这样你永远不用担心什么时候不可复原地破坏了你的代码库。当你建立了项目历史之后,git checkout 是一种便捷的方式,来将保存的快照「解包」到你的工作目录上去。 git checkout 可以检出提交、也可以检出单个文件甚至还可以检出分支(此处省略)。

git checkout 5aab391

检出v2,当前工作目录和5aab391完全一致,你可以查看这个版本的文件编辑、运行、测试都不会被保存到git仓库里面。你可以git checkout master 或者 git checkout -回到原来的工作状态上来。

git checkout 5aab391 v1.js

以检出v2版本对于v1.js的改动,只针对v1.js这个文件检出到5aab391版本。所以 它会影响你当前的工作状态,它会把当前状态的v1.js文件内容覆盖为5aab391版本。所以除非你清楚你在做什么,最好不要轻易的做这个操作。但这个操作对于舍弃我当前的所有改动很有用:比如当前我在v1.js上面做了一些改动,但我又不想要这些改动了,而我又不想一个个去还原,那么我可以git checkout HEAD v1.js 或者 git checkout -- v1.js

reset 重置

git checkout 一样, git reset 有很多用法。

git reset <file>

从暂存区移除特定文件,但不改变工作目录。它会取消这个文件的缓存,而不覆盖任何更改。

git reset

重置暂存区,匹配最近的一次提交,但工作目录不变。它会取消所有文件的暂存,而不会覆盖任何修改,给你了一个重设暂存快照的机会。

git reset --hard

加上--hard标记后会告诉git要重置缓存区和工作目录的更改,就是说:先将你的暂存区清除掉,然后将你所有未暂存的更改都清除掉,所以在使用前确定你想扔掉所有的本地工作。

git reset <commit>

将当前分支的指针HEAD移到 ,将缓存区重设到这个提交,但不改变工作目录。所有 之后的更改会保留在工作目录中,这允许你用更干净、原子性的快照重新提交项目历史。

git reset --hard <commit>

将当前分支的指针HEAD移到 ,将缓存区和工作目录都重设到这个提交。它不仅清除了未提交的更改,同时还清除了 之后的所有提交。

可以看出,git reset 通过取消缓存或者取消一系列提交的操作会摒弃一些你当前工作目录上的更改,这样的操作带有一定的危险性。下面我们开始介绍一种相对稳妥的方式 revert

revert 撤销

git revert被用来撤销一个已经提交的快照。但实现上和reset是完全不同的。通过搞清楚如何撤销这个提交引入的更改,然后在最后加上一个撤销了更改的 新 提交,而不是从项目历史中移除这个提交。

git revert <commit>

生成一个撤消了 引入的修改的新提交,然后应用到当前分支。

例如:

81f734d commit after bug
        |
3a395af bug
        |
3aa5dfb v3  (<- HEAD)
        |
5aab391 v2
        |
ff7b88e v1
        |
95d7816 init commit

我们在3a395af 引入了一个bug,我们明确是由于3a395af造成的bug的时候,以其我们通过新的提交来fix这个bug,不如git revert , 让他来帮你剔除这个bug。

git revert 3a395af

得到结果

cfb71fc Revert "bug"
        |
81f734d commit after bug
        |
3a395af bug
        |
3aa5dfb v3  (<- HEAD)
        |
5aab391 v2
        |
ff7b88e v1
        |
95d7816 init commit

这个时候bug的改动被撤销了,产生了一个新的commit,但是commit after bug没有被清初。

所以相较于resetrevert不会改变项目历史,对那些已经发布到共享仓库的提交来说这是一个安全的操作。其次git revert 可以将提交历史中的任何一个提交撤销、而reset会把历史上某个提交及之后所有的提交都移除掉,这太野蛮了。

另外revert的设计,还有一个考量,那就是撤销一个公共仓库的提交。至于为什么不能用reset,你们可以自己思考一下。 下面我们就用一个麻烦事(回滚一个错误的合并),来讲解这个操作。

合并操作

相对于常规的commit,当使用git merge <branch>合并两个分支的时候,你会得到一个新的merge commit. 当我们git show <commit>的时候会出现类似信息:

commit 6dd0e2b9398ca8cd12bfd1faa1531d86dc41021a
Merge: d24d3b4 11a7112
Author: 前端杂货铺 
...............

Merge: d24d3b4 11a7112 这行表明了两个分支在合并时,所处的parent的版本线索。

比如在上述项目中我们开出了一个dev分支并做了一些操作,现在分支的样子变成了这样:

init -> v1 -> v2 -> v3  (master)
           \      
            d1 -> d2  (dev)

当我们在dev开发的差不多了

#git:(dev)
git checkout master 
#git:(master)
git merge dev

这个时候形成了一个Merge Commit faulty merge

init -> v1 -> v2 -> v3 -- faulty merge  (master)
           \            /
            d1  -->  d2  (dev)

此时faulty merge有两个parent 分别是v3 和 d2。

回滚错误的合并

这个merge之后还继续在dev开发,另一波人也在从别的分支往master合并代码。变成这样:

init -> v1 -> v2 -> v3 -- faulty merge -> v4 -> vc3 (master)
        \  \            /                     /
         \  d1  -->  d2  --> d3 --> d4  (dev)/
          \                                 / 
           c1  -->  c2 -------------------c3 (other)

这个时候你发现, 妈也上次那个merge 好像给共享分支master引入了一个bug。这个bug导致团队其他同学跑不通测试,或者这是一个线上的bug,如果不及时修复老板要骂街了。

这个时候第一想到的肯定是回滚代码,但怎么回滚呢。用reset?不现实,因为太流氓不说,还会把别人的代码也干掉,所以只能用revert。而revert它最初被设计出来就是干这个活的。

怎么操作呢?首先想到的是上面所说的 git revert <commit> ,但是貌似不太行。

git revert faulty merge
error: Commit faulty merge is a merge but no -m option was given.
fatal: revert failed

这是因为试图撤销两个分支的合并的时候Git不知道要保留哪一个分支上的修改。所以我们需要告诉git我们保留那个分支m 或者mainline.

git revert -m 1 faulty merge

-m后面带的参数值 可以是1或者2,对应着parent的顺序.上面列子:1代表v3,2代表d2 所以该操作会保留master分支的修改,而撤销dev分支合并过来的修改。

提交历史变为

init -> v1 -> v2 -> v3 -- faulty merge -> v4 -> vc3 -> rev3 (master)
          \            /                     
           d1  -->  d2  --> d3 --> d4  (dev)

此处rev3是一个常规commit,其内容包含了之前在faulty merge撤销掉的dev合并过来的commit的【反操作】的合集。

到这个时候还没完,我们要记住,因为我们抛弃过之前dev合并过来的commit,下次dev再往master合并,之前抛弃过的其实是不包含在里面的。那怎么办呢?

恢复之前的回滚

很简单我们把之前master那个带有【反操作】的commit给撤销掉不就好了?

git checkout master
git revert rev3
git merge dev

此时提交历史变成了

init -> v1 -> v2 -> v3 -- faulty merge -> v4 -> vc3 -> rev3 -> rev3` -> final merge (master)
          \            /                                               /
           d1  -->  d2  --> d3 --> d4  --------------------------------(dev)

总结

以上就是我想要讲的关于git回滚代码的一些操作,有不对的地方还望指正。另Git 是一门艺术,是一种非常精妙的设计,当你使用上手后,你会发现越来越多好玩的东西,并为设计git的人默默点个赞。也希望在前端领域不论是初学还是深凿者,在追逐流行框架的时候,都不要忘了学习这些基础的工具。

参考