在
Git
中没有真正的方法来做任何事情,这就是它的妙处!
前言
经常会听到别人说,如果时光可以倒流,我将会如何如何,可是现阶段的科技还达不到时光倒流的目的,或许在《三体》世界的四维裂缝里可以试一下。现实的世界中找不到后悔药,但是在代码的世界里却可以轻松实现,错误的BUG修改、砍掉的做了一半的功能都可以轻松回退,不留一丝痕迹,回滚之后一切又可以重新开始了。
代码回退
大型编程项目的开发往往伴随着版本工具的使用,其实引入代码版本控制工具,有一部分原因也是为了方便回退,回退操作每天都发生的,只是有时是我们显式的操作,有时却自然而然的进行着,我们切换着分支很可能就是从开发版本回退到一个稳定版本,我们查询日志,实际上是在记忆上回退我们整个的开发过程,找寻其中的问题和修改的内容。
Git管理下的各种文件状态
Git
的使用中,由于一个文件存在好几种状态的变化,所以处理起回退要分情况进行,有些各式各样的命令最终分析起来其实作用是一样的。
说起Git
常常会提到工作区、暂存区、版本库的概念,这是很通用的说法,其实工作区一般就是指我们能看到的文件、本地操作文件所在的目录,我们正常编写的代码文件、管理的资源文件都是在工作区里操作,这里的文件也不是全都平等的,又细分为受版本控制的文件和不受版本控制的文件。
提到暂存区就和index文件建立起了联系,工作区的新文件和已经修改的受版本控制的文件,使用 git add file_name
就可以加到暂存区,相当于登记报个名,以后提交到版本库的时候会把这些登记的文件都带上,实际上执行了 git add
命令的文件都生成了对应的 object 对象,放在.git/objects目录下,状态变成了 staged
, 当提交到版本库时,分支会引用这些对象。
版本库就是文件修改的目的地了,最终的修改会提交到版本库,这时提交的文件状态变成 committed
,其实也是一种 unmodified
状态,一路走来,版本库中记录了你的每一次提交,可以追溯你每一次修改的内容。
其实还有一个远程仓库的概念,一般确定本地仓库的修改没有问题了,或者要将本地代码远程备份时,可以将自己修改的分支推送到远程仓库,因为有时候我们也想回退已经推送到远程仓库的修改,所以这里先提一下远程仓库。
总结起来一个文件的状态通常可以分为:
- 不受版本控制的
untracked
状态 - 受版本控制并且已修改的
modified
状态 - 受版本控制已修改并提交到暂存区的
staged
状态 - 从暂存区已经提交到本地仓库的
committed
状态 - 提交到本地仓库未修改或者从远程仓库克隆下来的
unmodified
状态
Git回退命令
上面提到了在 Git
这个版本控制工具下文件的各种状态,其实回退操作就是通过命令实现这些文件状态的“倒退”,进而达到回退操作的目的,下面一起先来了解下这些可以实现回退的命令。
git checkout
这个命令又出现了,上次是总结 git branch
分支操作的时候,git checkout
可以用来新建或者切换分支,这次总结回退版本的命令,git checkout
也可以用来回退文件版本,很神奇吧。
其实这个命令的作用就是它单词的本义——检出,他的常用操作也取自这个意思,比如 git checkout branch_name
切换分支操作,实际上就是把指定分支在仓库中对应的所有文件检出来覆盖当前工作区,最终表现就是切换了分支。
而针对于文件的检出可以使用 git checkout -- file_name
,当不指定 commit id
就是将暂存区的内容恢复到工作区,也就可以达到回退本地修改的作用。
不过,这个身兼数职的 git checkout
命令现在可以轻松一些了,从 Git 2.23
版本开始引入了两个新的命令: git switch
用来切换分支,git restore
用来还原工作区的文件,这个后面还会提到。
git revert
revert 这个词的意思是:归还,复原,回退,它和后面即将提到的 restore 在意思上简直无法区分,为了区别他们两个这里可以把 git revert
看成归还的意思,对某次提交执行 git revert
命令就是对这次修改执行一个归还操作,其实就是反向再修改一次。
要理解 git revert
就要从反向修改的含义来看,当我们再一个文件中添加一行内容,并提交到版本库后,产生一个提交id——commit-id-a
,如果这时使用 git revert commit-id-a
命令,就相当于在工作区中的那个文件将刚在新加的一行内容删除掉,然后再进行一个提交。
注意,这个操作是会改变分支记录的,因为产生了新的提交。
git restore
这个命令是 Git 2.23
版本之后新加的,用来分担之前 git checkout
命令的功能,作用就是用暂存区或者版本库中的文件覆盖本地文件的修改可以达到回退修改的目的,同时也可以使用版本库中的文件覆盖暂存区的文件,达到回退git add
命令的目的。
注意,这个操作是不会影响分支记录的,就是相当于之前的 git checkout
命令重新检出一份文件来覆盖本地的修改。
git reset
reset 重新设置的意思,其实就是用来设置分支的头部指向,当进行了一系列的提交之后,忽然发现最近的几次提交有问题,想从提交记录中删除,这是就会用到 git reset
命令,这个命令后面跟 commit id
,表示当前分支回退到这个 commit id
对应的状态,之后的日志记录被删除,工作区中的文件状态根据参数的不同会恢复到不同的状态。
--soft
: 被回退的那些版本的修改会被放在暂存区,可以再次提交。--mixed
: 默认选项,被回退的那些版本的修改会放在工作目录,可以先加到暂存区,然后再提交。--hard
: 被回退的那些版本的修改会直接舍弃,好像它们没有来过一样。
这样来看,git set
命令好像是用来回退版本的,但是如果使用 git rest HEAD file_name
命令就可以将一个文件回退到 HEAD
指向版本所对应的状态,其实就是当前版本库中的状态,也就相当于还原了本地的修改。
git rm
临时插播的命令,本来删除不能算是回退,但是如果它和某些命令反着来就是一种回退,比如对一个新文件使用 git add newfile_name
命令,然后再使用 git rm --cached newfile_name
就可以将这个文件从暂存区移除掉,但是在工作区里没有消失,如果不加 --cached
参数,就会从工作区和版本库暂存区同时删除,相当于执行了 rm newfile_name
和 git add new_file
两条命令。
具体回退操作
说了这么多肯定有点懵,特别是一个相同的需求可以使用很多命令来实现的时候,接下来看一些具体需求,整个测试过程用上一篇总结《git branch常用分支操作》使用的 git 仓库来进行,远程地址是 git@gitee.com:myname/gitstart.git
,下面测试开始,我们看一下这些情况怎么进行还原:
初始状态
1 | albert@homepc MINGW64 /d/gitstart (dev) |
还原00:工作区中未加到暂存区和版本库的文件,还原今天所做的修改
实话实说,办不到,没有加到过暂存区就没有被追踪,它的任何修改是没有办法回退的,可是使用 Ctrl+Z
碰碰运气,没准就退回到了你想要的状态。
还原01:工作区中未加到暂存区和版本库的文件,执行了 git add
操作
这种情况可以使用git rm --cached newfile
、git restore --staged newfile
或者 git reset HEAD newfile
命令,使用后两个命令的时候不能是版本库的第一个文件。
git rm
1 | albert@homepc MINGW64 /d/gitstart (dev) |
git restore
1 | albert@homepc MINGW64 /d/gitstart (dev) |
git reset
1 | albert@homepc MINGW64 /d/gitstart (dev) |
还原02:版本库中的文件,修改或删除后未执行 git add
操作
我们直接修改 README.md 文件吧,删除刚才添加的未受版本管理的 new.txt,在 README.md 文件中添加内容,然后试着还原,这种情况常常出现在修改一个功能还未提交,但是先不要求修改了,可以直接还原。
这种情况可以使用git restore file_name
、git checkout -- file_name
或者 git reset --hard HEAD
命令,最后的git reset
命令带有 --hard
参数不能再加文件目录,只能将工作区全还原。
git restore
1 | albert@homepc MINGW64 /d/gitstart (dev) |
git checkout
1 | albert@homepc MINGW64 /d/gitstart (dev) |
git reset
1 | albert@homepc MINGW64 /d/gitstart (dev) |
还原03:版本库中的文件,修改或删除后执行了 git add
操作
使用了 git add
命令之后,文件的改变就放到了暂存区,这种情况可以使用git restore --staged file_name
或者 git reset HEAD file_name
命令。
git restore
执行 git restore --staged file_name
实际上是使用版本库中的文件覆盖暂存区中的数据,执行结束后文件状态变成了 <还原02>
中的情况。
1 | albert@homepc MINGW64 /d/gitstart (dev) |
git reset
git reset
命令如果加上 --hard
参数不能再加文件目录,只能将工作区全还原,如果不加默认参数为 --mixed
,执行之后修改的文件状态变成了 <还原02>
中的情况。
1 | albert@homepc MINGW64 /d/gitstart (dev) |
还原04:版本库中的文件,修改或删除后执行了 git add
、git commit
操作
git commit
命令一旦执行了之后就形成了“历史”,我们叫做提交日志,要想回退就得有篡改历史的能力,很幸运 Git
给了我们这种能力,其实提交之后我们可以把本地文件反向修改,然后再提交一次,但是我们说的还原,一般都是只倒退,既然是错误的提交,我们就像把这段“历史”抹去,这时就要用到 git reset HEAD^
命令。
执行这个命令之后,刚刚的提交记录就被抹掉了,文件状态就回到了 <还原02>
的情况,如果加上参数 --soft
就会回到 <还原03>
的情况,如果加上参数 --hard
,就不能添加 file_name
这个文件名,然后整个工作区倒退到上一次修改之前,其他两种参数 --mixed
和 --soft
就可以指定添加名字。
这里的 HEAD^
表示最新版本的前一版,也就是倒数第二版本,可以类推,HEAD^^
表示倒数第三版本,HEAD^^^
表示倒数第四版本。
另外还有另一种写法 HEAD~1
表示最新版本的前一版,也就是倒数第二版本,HEAD~2
表示倒数第三版本,HEAD~3
表示倒数第四版本。
其中 ^
和 ~
的含义并不相同,涉及到合并分支的概念,有兴趣的话可以多了解下,这里就不展开了,继续还原当前这种情况,我们选择 git reset HEAD^
命令,先提交看下:
1 | albert@homepc MINGW64 /d/gitstart (dev) |
然后再还原试试:
1 | albert@homepc MINGW64 /d/gitstart (dev) |
怎么样,历史被我们抹除了,需要注意的是,如果想还原“历史”,那么 git set
命令后面不能跟文件名,也就是说必须整个还原到上一版本,否则就相当于将单个文件简单反向修改添加到暂存区,而之前对文件的修改保留在本地,文件的日志并没有回退,具体的文件状态还得你自己操作感受一下。
还原05:版本库中的文件,修改或删除后执行了 git add
、git commit
、git push
操作
这种情况就是还原远程仓库的日志记录了,实际上操作步骤先按照 <还原04>
来处理,然后将本地分支情况推送到远程分支即可。
我们先把刚才的修改提交,然后推送到远程分支,使用 git status
可以看到本地分支已经领先远程分支了(Your branch is ahead of ‘origin/dev’ by 1 commit.), git push
操作之后两个分支同步了。
1 | albert@homepc MINGW64 /d/gitstart (dev) |
这时通过远程仓库的管理软件,你可以看到远程分支已经有了最新的提交,然后我们可以参考 <还原04>
的情况,先将本地日志还原,再推送到远程仓库。
1 | albert@homepc MINGW64 /d/gitstart (dev) |
和想象的不太一样的,这种情况是远程仓库的记录领先,无法直接推送,此时可以添加 -f
参数,用本地提交记录覆盖远程分支记录:
1 | albert@homepc MINGW64 /d/gitstart (dev) |
这次再查询远程分支记录,发现也被回退了,目的达成。
还原06:两次git commit
之后产生两条日志,只还原第一次提交
这种情况其实发生了两次修改和两次提交,和 <还原05>
情况不同的是要还原的提交不是最后一次,如果使用 git reset
命令必然将最后一次修改也还原了,虽然不能直接完成,但是给我们提供了解决问题的思路:
第一种方法:直接使用 git reset HEAD^^
命令还原两次提交,然后在工作区将文件按第二次修改再改一次进行提交,这种方法适用于想要抹除第一次提交历史的情况。
第二种方法:如果你不在意提交历史,只是想还原第一次修改,那么可以使用 git revert HEAD^
命令来反向修改那一次变化,修改之后会自动添加到暂存区,等待提交。
先来修改提交两次,产生两次记录:
1 | albert@homepc MINGW64 /d/gitstart (dev) |
然后使用 git revert HEAD^
还原第一次修改记录:
1 | albert@homepc MINGW64 /d/gitstart (dev) |
因为修改了同一个文件,还原的时候还产生了冲突,解决冲突之后才提交,看日志发现这是一条新的记录,在实际操作的过程中可能会发生比这还要麻烦的场景,多练就好了。
常用集合
使用 Git
进行版本管理时,遇到的回退情况远不止这么多,这只是我目前常见的,之后遇到还会补充,每种情况我们其实不止有一种解决方式,接下来对于每种情况给一个我个人常用的处理方式,因为 git checkout
的作用被逐渐拆分成更具体的 git switch
和 git restore
,我们尽量选择功能明确的命令:
- 还原00:工作区中未加到暂存区和版本库的文件,还原今天所做的修改
- 尝试下Ctrl+z吧,不行就找找自动保存的缓存文件,看看能不能找到之前版本
- 还原01:工作区中未加到暂存区和版本库的文件,执行了
git add
操作- 直接使用
git restore --staged file_name
命令,如果版本不支持则使用git rm --cached file_name
- 直接使用
- 还原02:版本库中的文件,修改或删除后未执行
git add
操作- 直接使用
git restore file_name
命令,如果版本不支持则使用git checkout -- file_name
- 直接使用
- 还原03:版本库中的文件,修改或删除后执行了
git add
操作- 直接使用
git restore --staged file_name
命令,按<还原02>
情况处理
- 直接使用
- 还原04:版本库中的文件,修改或删除后执行了
git add
、git commit
操作- 直接使用
git reset HEAD^
命令,按<还原02>
情况处理,或者使用git reset --soft HEAD^
命令,按<还原03>
情况处理
- 直接使用
- 还原05:版本库中的文件,修改或删除后执行了
git add
、git commit
、git push
操作- 先按照
<还原04>
情况处理,然后使用git push -f
命令
- 先按照
- 还原06:两次
git commit
之后产生两条日志,只还原第一次提交- 使用
git revert HEAD^
命令,解决冲突后提交,revert 后面跟具体的commit id
也可以。
- 使用
总结
- 参考这些具体的例子你会发现,很多操作选择在使用
git status
之后都有列举 - 所以说
git status
是一个可以提示你做选择的强大帮手,不知所措时可以试试它 - Git 2.23版本之后学会用
git switch
和git restore
命令,因为之前git checkout
背负了太多了 - 最后放一幅图吧,只画了主要的,没有画出全部情况,否则会很乱,可以对照着练习一下