【Git 学习笔记_18】第八章 从错误版本中恢复(下)

8.6 恢复 -- 撤销由于提交引入的变更

Git 中的恢复(Revert)主要用途,是在不重写历史记录的情况下,撤消历史记录中已发布(或已推送)的 commit 版本;而在不修改历史记录的前提下,前面提到的修改(amend)或重置(reset)都无法满足要求。

先来看一个最简单的演示:

bash 复制代码
$ git clone https://github.com/PacktPublishing/Git-Version-Control-Cookbook-Second-Edition_hello_world_cookbook.git hello
$ cd hello
$ git log --oneline 
dc26ea1 (HEAD -> master, origin/master, origin/HEAD) remove $5 campaign
55c7ae4 add $5 campaign
680ab8a Adds Java version of 'hello world'
8609e81 Fixes compiler warnings
21e3c2b Initial commit, K&R hello world
$ git revert 8609e81 
# this brings up the default editor...

执行 git revert 后弹出的编辑器窗口如下:

直接保存后退出,对比 git log 的结果:

bash 复制代码
$ git revert 8609e81 
[master 832eda7] Revert "Fixes compiler warnings"
 1 file changed, 1 insertion(+), 5 deletions(-)
$ git log --oneline
832eda7 (HEAD -> master) Revert "Fixes compiler warnings"
dc26ea1 (origin/master, origin/HEAD) remove $5 campaign
55c7ae4 add $5 campaign
680ab8a Adds Java version of 'hello world'
8609e81 Fixes compiler warnings
21e3c2b Initial commit, K&R hello world

可以看到,git revert 命令将手动指定的 commit8609e81)的补丁,逆向应用到了当前的 HEAD 上,产生了一个新的 commit 节点。

此外,revert 还可以恢复多个连续版本:

bash 复制代码
$ git revert master~6..master~2

这表示将 master~6master~2 的版本反补丁到当前 HEAD 节点。这里的 master~6..master~2 表示将从 master 底部的第 6 次提交恢复到 master 底部的第 3 次提交(都包括在内):

bash 复制代码
# prepare commits in advance
$ echo "+1" >> Readme.md; git commit -aqm '+1 test';
$ echo "+2" >> Readme.md; git commit -aqm '+2 test';
$ echo "+3" >> Readme.md; git commit -aqm '+3 test';
$ echo "+4" >> Readme.md; git commit -aqm '+4 test';
$ echo "+5" >> Readme.md; git commit -aqm '+5 test';
$ echo "+6" >> Readme.md; git commit -aqm '+6 test';
$ echo "+7" >> Readme.md; git commit -aqm '+7 test';
$ echo "+8" >> Readme.md; git commit -aqm '+8 test';
# check git log
$ git log --oneline
e890b92 (HEAD -> master) +8 test
ed28ca0 +7 test
3df0adc +6 test
f3b01a3 +5 test
8919876 +4 test
217fcfa +3 test
90b0f48 +2 test
e58efb7 +1 test
dc26ea1 (origin/master, origin/HEAD) remove $5 campaign
55c7ae4 add $5 campaign
680ab8a Adds Java version of 'hello world'
8609e81 Fixes compiler warnings
21e3c2b Initial commit, K&R hello world
$ git log --oneline master~6..master~2
3df0adc +6 test
f3b01a3 +5 test
8919876 +4 test
217fcfa +3 test

注意 master~6..master~2 的输出结果。

如果恢复时不希望创建新版本,可以加 -n 标记,这样相关补丁就会只应用在工作区和暂存区。

8.7 恢复一个合并提交

执行 revert 恢复时,合并提交(merge commits)是一个特例。恢复一个合并提交时,必须指定一个需要保留的父级。记住,Git 虽然能通过 revert 实现文件内容的撤回,但 Git 历史是无法撤回的------这就意味着,由于之前的合并所产生的任何变更,后续将不再对指定的目标分支产生任何影响。如果再次合并执行过 revert 的、被舍弃的分支,则新加入的变更将不包含上一次合并引入的变更,如下图所示:

如上所述,图中进行了两次合并,中间包含一次 revert 恢复,且恢复的版本是第一次合并;第二次合并(M2 节点)将只会引入 C7C8 产生的新变更,C4C5 的历史变更 不会 并入主分支。

本节将演示如何恢复一个合并提交,以及如何再次合并------通过恢复 "执行过 git revert" 的那次提交(W1),来引入原分支上的所有变更(C4 + C5 + C7 + C8)。

bash 复制代码
$ git clone https://github.com/PacktPublishing/Git-Version-Control-Cookbook-Second-Edition_hello_world_cookbook.git 
$ cd Git-Version-Control-Cookbook-Second-Edition_hello_world_cookbook
$ git branch -f feature/p-lang origin/feature/p-lang 
$ git checkout develop 
$ git reset --hard origin/develop 
# Imagine a bug is detected in hello_world.pl
# perl hello_world.pl failed
# Check git log before reverting
$ git log --oneline --graph -5 
* aaf61e9 (HEAD -> develop, origin/develop) Adds Groovy hello world
*   211421b Merge branch 'feature/p-lang' into develop
|\
| * 66016b5 php version added
| * ff22d4e Adds perl hello_world script
* | a3af57c Hello world shell script
|/
# check HEAD structure
$ git ls-tree --abbrev HEAD
100644 blob 28f40d8     helloWorld.groovy
100644 blob 881ef55     hello_world.c
100644 blob 5dd01c1     hello_world.php
100755 blob ae06973     hello_world.pl
100755 blob f3d7a14     hello_world.py
100755 blob 9f3f770     hello_world.sh
# Revert the merge, keeping the history of the first parent
$ git revert -m 1 211421b 
hint: Waiting for your editor to close the file...
# Save commit messge without modification
[develop 84a7744] Revert "Merge branch 'feature/p-lang' into develop"
 2 files changed, 4 deletions(-)
 delete mode 100644 hello_world.php
 delete mode 100755 hello_world.pl
$ git ls-tree --abbrev HEAD 
100644 blob 28f40d8     helloWorld.groovy
100644 blob 881ef55     hello_world.c
100755 blob f3d7a14     hello_world.py
100755 blob 9f3f770     hello_world.sh

正如所料,执行 revert 后,之前合并引入的两个文件(PerlPHP 文件)已经被移除了。

执行原理

revert 命令会读取拟撤回版本的补丁(patches),然后将其反向作用于(即逆向补丁)当前工作区。如若进展顺利(即没有造成版本冲突),git 将自动产生一个新的 commit 版本。在恢复一个合并提交时,指定主线(mainline,由 -m 标记指定)引入的变更会被保留;而另一条支线上引入的变更则会从工作区撤回。

拓展

刚才的示例演示了如何恢复一个合并提交,看起来还比较容易,如果后续要重新并入那条支线呢?比如被撇开的那个支线,如果上面的问题已经修复了,可以并入之前的主线了,应该如何操作?显然,直接合并将丢失一部分支线版本,因为在恢复上一次合并版本时,属于支线的那部分变更已经被还原了,除非手动干预,否则后续的合并,都不会 包含上次恢复时被排开的那部分变更。

本节就将对此进行演示,看看怎样才能按预期的那样,在第二次合并时恢复 所有的 支线变更。先看看不作任何调整,直接重新合并:

bash 复制代码
$ git merge --no-edit feature/p-lang 
CONFLICT (modify/delete): hello_world.pl deleted in HEAD and modified in feature/p-lang.  Version feature/p-lang of hello_world.pl left in tree.
Automatic merge failed; fix conflicts and then commit the result.
$ git add hello_world.pl 
$ git commit 

从第 2 行可知,文件 hello_world.php 引发了冲突。这也说得通:受此前恢复合并提交的影响,引入该文件的 commit 版本被撤回了;而新并入的变更又需要该文件,因此出现了冲突。此时只需要执行 git add 加入该文件即可。提交后弹出的编辑器窗口如下:

不作任何修改,直接保存退出:

bash 复制代码
$ git commit
[develop 28f93de] Merge branch 'feature/p-lang' into develop
$ git ls-tree --abbrev HEAD 
100644 blob 28f40d8     helloWorld.groovy
100644 blob 881ef55     hello_world.c
100755 blob 6611b8e     hello_world.pl
100755 blob f3d7a14     hello_world.py
100755 blob 9f3f770     hello_world.sh

可以看到,合并后的文件中并没有 hello_world.php。要想得到再次合并的正确结果,必须先撤回那次 git revert 操作,即对恢复合并那次版本再执行一次恢复;然后重新合并。这样就能重新引入支线上的所有变更。

先撤回刚才的直接合并:

bash 复制代码
# reset to the initial status
$ git reset --hard HEAD^ 
HEAD is now at 84a7744 Revert "Merge branch 'feature/p-lang' into develop"
# revert the reverting commit
$ git revert HEAD 
[develop 7c1cacd] Revert "Revert "Merge branch 'feature/p-lang' into develop""
 2 files changed, 4 insertions(+)
 create mode 100644 hello_world.php
 create mode 100755 hello_world.pl
# merge again
$ git merge feature/p-lang 
Merge made by the 'ort' strategy.
 hello_world.pl | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
# check the files
$ git ls-tree --abbrev HEAD 
100644 blob 28f40d8     helloWorld.groovy
100644 blob 881ef55     hello_world.c
100644 blob 5dd01c1     hello_world.php
100755 blob 6611b8e     hello_world.pl
100755 blob f3d7a14     hello_world.py
100755 blob 9f3f770     hello_world.sh

这样才是完整的重新合并。

更多参考资料详见:

8.8 用 git reflog 查看 Git 的历史动作

reflog 命令保存了 HEAD 在各个分支变动的情况,与之相对应的是 git log,记录的是各个分支的各个父级节点的情况。reflog 提供的是本地你自己的提交历史:怎样在分支间切换的,怎么新增或重置一次提交等等。通常,任何触发 HEAD 指针发生变动的情况都会记录在 reflog 中,通过该命令可以找到 git 库中不被任何分支引用的丢失的版本节点。这不失为寻找遗失版本节点的一个好的切入点。

沿用上一节的 hello 库:

bash 复制代码
# Use the repo from the last section
$ cd hello
$ git checkout master 
# Check the latest 7 ref logs
$ git reflog -7
dc26ea1 (HEAD -> master, origin/master, origin/HEAD) HEAD@{0}: checkout: moving from develop to master
13749c4 (develop) HEAD@{1}: merge feature/p-lang: Merge made by the 'ort' strategy.
7c1cacd HEAD@{2}: revert: Revert "Revert "Merge branch 'feature/p-lang' into develop""
84a7744 HEAD@{3}: reset: moving to HEAD^
28f93de HEAD@{4}: commit (merge): Merge branch 'feature/p-lang' into develop
84a7744 HEAD@{5}: revert: Revert "Merge branch 'feature/p-lang' into develop"
aaf61e9 (origin/develop) HEAD@{6}: reset: moving to origin/develop

由于实测时的用户名和电子邮箱不同,得到的输出结果也和原书略有不同,但提交内容的大致顺序应该是一致的。

注意第 10 行的输出,HEAD@{4} 对应的合并节点并未被分支引用:

bash 复制代码
$ git show 28f93de 
commit 28f93de7da30e0924b423a7e42237ea31debd460
Merge: 84a7744 06e4ae2
Author: SafeWinter <zandong_19@aliyun.com>
Date:   Fri Dec 24 10:11:24 2021 +0800

    Merge branch 'feature/p-lang' into develop
# Check by git ls-tree
$ git ls-tree --abbrev 28f93de
100644 blob 28f40d8     helloWorld.groovy
100644 blob 881ef55     hello_world.c
100755 blob 6611b8e     hello_world.pl
100755 blob f3d7a14     hello_world.py
100755 blob 9f3f770     hello_world.sh

可以看到,这正是上一节中我们临时测试后废弃的版本。

Git 提供了多种方法找回一个丢失的版本:既可以用 git-showgit cat-file 查看详情,也可以签出一个 commit,用 git checkout 新建一个分支,还可以基于 commit 中的某个文件进行签出,语法格式为:checkout -- path/to/file SHA-1

小结

HEAD 指针的每次变更,Git 都会存储其指向的 commit 以及切换到该状态所执行的操作。 这些操作可以是一次提交(commit)、签出(checkout)、重置(reset)、还原(revert)、合并(merge)、变基(rebase)等。 这些信息是 git 本地的信息,因此不会在推送(push)、获取(fetch)和克隆(clone)时共享出去。如果记得要检索的内容,或者创建目标版本的大致时间,则使用 reflog 命令查找丢失的版本会非常轻松。 如果有很多 reflog 历史记录、许多提交、切换分支等,由于对 HEAD 的多次更新产生的杂音,则较难通过 reflog 命令进行定位。

8.9 用 git fsck 找出遗失的变更

除了 git reflog,另一个可以帮你定位丢失的版本、甚至是丢失 blob 的命令,是 git fsck。该命令检测 git 的对象数据库,并验证目标对象的 SHA-1 以及它们创建的连接,可用于查找不被其他具名引用使用的对象。这些对象都存放于 .git/objects 文件夹。

本节沿用 hello_world 库,但需执行本书主页上的脚本(04_undo_dirty.sh,无法获取)。按照示例内容来看,应该是借用了 git stash 和 stash pop 的操作示例,模拟一次工作区的变更,然后临时放到 stash 缓存,同时执行一次 reset,再把缓存内容还原到工作区。示例将考察此时的被丢失对象:

bash 复制代码
$ git clone https://github.com/PacktPublishing/Git-Version-Control-Cookbook-Second-Edition_hello_world_cookbook.git ch8-9
$ cd ch8-9
# Edit a file
$ subl hello_world.c

SublimeText 编辑 hello_world.c。文件内容如下:

接着将当前变更存入 stash,并临时重置到上一个 commit 版本:

bash 复制代码
# Stash changes
$ git stash
Saved working directory and index state WIP on master: dc26ea1 remove $5 campaign
# Run a reset
$ git reset --hard HEAD^
HEAD is now at 55c7ae4 add $5 campaign
# Retrieve data from stash pop
$ git stash pop
On branch master
Your branch is behind 'origin/master' by 1 commit, and can be fast-forwarded.
  (use "git pull" to update your local branch)

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
        modified:   hello_world.c

no changes added to commit (use "git add" and/or "git commit -a")
Dropped refs/stash@{0} (5390ad6a3c4b04249b182a4a074f90feea7e064f)
$ git fsck --unreachable
Checking object directories: 100% (256/256), done.
Checking objects: 100% (36/36), done.
unreachable commit 8778a93894e984fae9654b93c68fe4e9b40c9311
unreachable tree 14cf51045199a00925c446df753a649dce5022de
unreachable blob c41a2cb3b76d775a0bfbb87a2f0c85540c983c74
unreachable commit 5390ad6a3c4b04249b182a4a074f90feea7e064f
# Check the missing file SHA-1 (c41a2cb3b76d775a0bfbb87a2f0c85540c983c74)
$ git show c41a2cb3b76d775a0bfbb87a2f0c85540c983c74
#include <stdio.h>
void say_hello(void) {
  printf("hello, worldn");
}

int main(void){
  say_hello();
   return 0;
}
# Check the missing commit SHA-1 (5390ad6a3c4b04249b182a4a074f90feea7e064f)
$ git show 5390ad6a3c4b04249b182a4a074f90feea7e064f
commit 5390ad6a3c4b04249b182a4a074f90feea7e064f
Merge: dc26ea1 8778a93
Author: SafeWinter <zandong_19@aliyun.com>
Date:   Fri Dec 24 12:09:13 2021 +0800

    WIP on master: dc26ea1 remove $5 campaign

diff --cc hello_world.c
index 881ef55,881ef55..c41a2cb
--- a/hello_world.c
+++ b/hello_world.c
@@@ -1,7 -1,7 +1,9 @@@
--#include <stdio.h>
--
--int main(void){
--      printf("hello, world\n");
--
--      return 0;
--}
++#include <stdio.h>
++void say_hello(void) {
++  printf("hello, worldn");
++}
++
++int main(void){
++  say_hello();
++   return 0;
++}

可以看到,经过 stash 缓存、执行 reset、释放缓存后,工作区内并未发起过一次提交;但缓存到 git 库的命令确实将文件写入了 git 数据库,因此只要在 git 的垃圾回收介入前,git 的对象库内始终能找到这个 blob 对象。如果被其他对象引用,那么它将永久有效。

由于示例库后期加入了一个促销活动(5 美元促销),后来又下架了活动,这些变更使得实操结果和原书大不相同,但原理是共通的:通过 stash 操作涉及的变动都会作为不可及的丢失对象,出现在 git fsck --unreachable 的查询结果中。

git fsck 命令会查看 .git/objects 文件夹内的所有对象,加上 --unreachable 标记后,返回的是任何其他引用对象都无法访问到的 git 对象。这些引用可以是分支(branch)、标签(tag)、提交(commit)、目录树(tree)、reflog,抑或是 stash 操作涉及的变动。

相关推荐
EterNity_TiMe_23 分钟前
【论文复现】(CLIP)文本也能和图像配对
python·学习·算法·性能优化·数据分析·clip
sanguine__26 分钟前
java学习-集合
学习
lxlyhwl26 分钟前
【STK学习】part2-星座-目标可见性与覆盖性分析
学习
nbsaas-boot27 分钟前
如何利用ChatGPT加速开发与学习:以BPMN编辑器为例
学习·chatgpt·编辑器
dr李四维30 分钟前
iOS构建版本以及Hbuilder打iOS的ipa包全流程
前端·笔记·ios·产品运营·产品经理·xcode
CV学术叫叫兽1 小时前
一站式学习:害虫识别与分类图像分割
学习·分类·数据挖掘
我们的五年1 小时前
【Linux课程学习】:进程程序替换,execl,execv,execlp,execvp,execve,execle,execvpe函数
linux·c++·学习
和你一起去月球2 小时前
TypeScript - 函数(下)
javascript·git·typescript
一棵开花的树,枝芽无限靠近你2 小时前
【PPTist】添加PPT模版
前端·学习·编辑器·html
VertexGeek2 小时前
Rust学习(八):异常处理和宏编程:
学习·算法·rust