git 工作原理与撤销操作图解

「如何撤销 git 提交」是绝大部分程序员一定搜索过的问题,就算你可以用某个答案中的命令暂时过关,再次遇到类似问题时又可能一头雾水:为什么这次搜到的命令不一样?git checkoutgit resetgit revert 这些命令我到底该用哪个?

之所以这样,是因为你的需求并不能简单地描述为「撤销」某次提交,而可能是:

  • 我在本地修改了一些文件还未提交,但我想放弃某些文件的更改。
  • 我不小心 git add 了错误的文件,现在我不想把它和其他文件一起提交了。
  • 我刚刚执行的提交添加了不该提交的文件,我想取消这次提交,但保留(或不保留)对本地文件所作的修改。
  • 我不小心在一个提交中引入了 Bug 并且还推送到了远程分支,现在想回滚到原来的状态。

对于各种复杂的情形,Git 都提供了对应的方案来解决,但由于命令很多且同一命令还有很多选项,想要记住它们不是一件容易的事情。以 resetcheckout 为例,在添加不同选项并对不同参数执行后,就能实现 6 种不同但又很常用的效果。

对于复杂的问题,我们应该尝试去了解其背后的本质。带着这种想法,我们来看看执行这些操作时究竟发生了什么,希望你在阅读本文后,能够对 Git 的撤销操作运用自如,解决大部分与撤销相关的实际问题(实际上本文还非系统地介绍了 Git 的内部对象和工作原理)。

Git 中的撤销

首先需要明确的是,Git 中并没有真正意义上传统文本处理软件都会提供的 undo (撤销)功能,Git 本身也不是一个文本处理软件,它是一个内容寻址文件系统,你所提交的更改都会被保存到系统中。虽然不能 undo ,但它就像时光机一样,可以将保存的文件恢复到过去的某个状态。

然而,Git 同时管理着三颗不同的「树」的状态,当我们讨论「撤销」这个操作时,除了选择需要恢复到的时间点,还需要明确想更改哪几颗树。

取决于你想操作的树,你需要用到 checkoutresetrevert 等不同的命令。因此在了解具体的命令之前,我们先来认识一下这三棵树。

Git 的三棵树

这三棵树分别是:

  • 工作区(Working Directory)
  • 暂存区(Staging Index)
  • 提交历史(Commit History)

虽然我们用树来形容它们,但需要先明确的一点是,树并不代表它们真实的数据结构。「树」在这里的实际意思是「文件的集合」,而不是指特定的数据结构。在文中我们不会去深入探究它们的底层实现,而是重点了解它们的概念及相互关系。

工作区

工作区即存放当前操作文件的本地文件系统目录。

我们可以把它当成一个沙盒,在其中随意地添加或编辑文件,然后再将修改后的文件添加到暂存区并记录到提交历史中。

Git 可以把工作区中的文件处理、压缩成一个提交对象(稍后会解释这一概念),也能将取得的提交对象解包成文件同步到工作区中。

暂存区

暂存区保存着下一次执行 git commit 时将加入到提交历史中的内容。

Git 把它作为工作区与提交历史之间的中间区域,方便我们对提交内容进行组织:我们可能会在工作区同时更改多个完全不相干的文件,这时可以将它们分别放入暂存区,并在不同的提交中加入提交历史。此外暂存区还用于合并冲突时存放文件的不同版本。

除非是一个刚刚初始化的 Git 仓库,否则暂存区并不是空的,它会填充最近一次提交所对应的文件快照,因此当我们基于最近一次提交在工作区做了一些修改之后,git status 会将工作区的文件与暂存区的文件快照进行对比, 并提示我们有哪些做了修改的文件尚未加入暂存区。

Index 文件

暂存区并不像工作区有可见的文件系统目录,或者像提交历史一样通过 .git/objects 目录保存着所有提交对象,它没有实际存在的目录或文件夹,它的实体是位于 .git 目录的 index 文件。 index 是一个二进制文件,包含着一个由路径名称、权限和 blob 对象的 SHA-1 值组成的有序列表。

我们可以通过 git ls-files 命令查看 index 中的内容:

1
2
3
$ git ls-files --stage
100644 30d74d258442c7c65512eafab474568dd706c430 0       README.md
100644 9c1cab9a57432098de869e202ed73161af33d182 0       main.py

index 中记录了暂存区文件的路径名称和 SHA-1 ID,文件内容已经作为 blob 对象保存到了 .git/objects 目录中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
$ tree .git/objects -L 2
.git/objects
├── 30
│   └── d74d258442c7c65512eafab474568dd706c430
├── 9c
│   └── 1cab9a57432098de869e202ed73161af33d182
├── info
└── pack

4 directories, 2 files

blob 对象是 Git 用来保存文件数据的二进制对象,我们可以通过 ID 取得对应的 blob 对象,用 git cat-file 命令打印其内容:

1
2
$ git cat-file -p 30d74d258442c7c65512eafab474568dd706c430
This is a README file.

当我们将一个修改过的文件加入暂存区后,如果又在工作区对文件进行了新的修改,需要重新将其加入暂存区,因为暂存区以 blob 对象保存的只是文件加入时的内容。

index 文件中,还记录了每一个文件的创建时间和最后修改时间等元信息,它通过引用实际的数据对象包含了一份完整的文件快照,因此可以通过对比 SHA-1 校验和实现与工作区文件之间的快速比较。

提交历史

提交历史是工作区文件在不同时间的文件快照(快照即文件或文件夹在特定时间点的状态,包括内容和元信息)。

我们可以通过 git log 命令查看当前分支的提交历史:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
$ git log
commit ea4c48a0984880bda4031f0713229229c12793e4 (HEAD -> master)
Author: Waynerv <waynerv@notmyemail.com>
Date:   Wed Jan 6 21:05:44 2021 +0800

    add main file

commit b15cc74d6d85435660fcacce1305a54273880479
Author: Waynerv <waynerv@notmyemail.com>
Date:   Wed Jan 6 21:05:06 2021 +0800

    add ignore file

commit e137e9b81cc5dfc5b1c9c7d06b861553d5c42491
Author: Waynerv <waynerv@notmyemail.com>
Date:   Wed Jan 6 21:04:39 2021 +0800

    first commit

每一个提交都会有一个 40 位的「ID」:

1
ea4c48a0984880bda4031f0713229229c12793e4

Git 通过「提交对象」来储存每一次提交。这个 ID 是以对象内容进行 SHA-1 计算得到的哈希值,不同的内容一定会得到不同的结果,Git 既把它作为每一个对象(不仅仅是提交对象)的唯一标识符,也用作 .git/objects 目录中的地址(其中存储着实际的二进制文件),我们可以用 ID 找到对应的对象并打印其内容:

1
2
3
4
5
6
7
$ git cat-file -p ea4c48a0984880bda4031f0713229229c12793e4
tree 9e761342b98484aac2d8734f45fc2d0fde3e29db
parent b15cc74d6d85435660fcacce1305a54273880479
author Waynerv <waynerv@notmyemail.com> 1609938344 +0800
committer Waynerv <waynerv@notmyemail.com> 1609938344 +0800

add main application file

这个提交对象的内容包含三部分:

  • 对应的 tree 对象的 ID
  • 父提交对象的 ID
  • 作者、提交者及提交信息等元信息

tree 对象主要由其他 tree 对象和 blob 对象的 ID 以及路径名称组成:

1
2
3
4
5
$ git ls-tree 9e761342b98484aac2d8734f45fc2d0fde3e29db
100644 blob 723ef36f4e4f32c4560383aa5987c575a30c6535    .gitignore
100644 blob 30d74d258442c7c65512eafab474568dd706c430    README.md
100644 blob 9c1cab9a57432098de869e202ed73161af33d182    main.py
040000 tree 556af47de72b597f532f63b63983be433f137e57    tests

就像目录递归地包含其他目录和文件一样,一个 tree 对象即可表示整个工作区中所有已提交目录及文件的内容,也就是说提交历史中的每一个提交都包含着一份完整的某一时刻的文件快照,并通过保存上一次提交的引用形成连续的文件快照历史。

工作流程

在继续前,我们需要简单了解下分支和 HEAD。

在 Git 中我们将 SHA-1 值用做提交对象(以及 treeblob 对象)的 ID,通过 ID 操作提交对象以及提交对象引用的文件快照。但大部分时候,记住一个 ID 是非常困难的,因此 Git 用一个文件来保存 SHA-1 值,这个文件的名字即作为「引用(refs)」来替代原始的 SHA-1 值。

这类包含 SHA-1 值的文件保存在 .git/refs 目录下,我们可以在 .git/refs/heads 目录中找到代表各个分支引用的文件,尝试打印 master 文件的内容:

1
2
$ cat .git/refs/heads/master
ea4c48a0984880bda4031f0713229229c12793e4

这基本就是 Git 分支的本质:一个指向某一系列提交之首的指针或引用。

我们还用 HEAD 来指向最近的一次提交,HEAD 文件通常是一个符号引用(symbolic reference),指向目前所在的分支。 所谓符号引用,表示它是一个指向其他引用的引用:

1
2
$ cat .git/HEAD
ref: refs/heads/master

但在某些情况下,HEAD 文件可能会包含一个 git 对象的 SHA-1 值。 当你在检出一个标签、提交或远程分支,让你的仓库变成 「分离 HEAD」状态时,就会出现这种情况。

1
2
3
$ git checkout ea4c48a0984880bda4031f0713229229c12793e4
$ cat .git/HEAD
ea4c48a0984880bda4031f0713229229c12793e4

最后,让我们来看一下上文介绍的三棵树之间的工作流程:

Untitled

  1. 假设我们进入到一个新目录,其中有一个 README 文件。此时暂存区为空,提交历史为空,HEAD 引用指向未创建的 master 分支。
  2. 现在我们想提交该文件,首先需要通过 git add 将其添加到暂存区。此时 Git 将在 .git/objects 目录中以该文件的内容生成一个 blob 对象,并将 blob 对象的信息添加到 .git/index 文件中。
  3. 接着运行 git commit ,它会取得暂存区中的内容生成一个 tree 对象,该 tree 对象即为工作区文件的永久快照,然后创建一个指向该 tree 对象的提交对象,最后更新 master 指向本次提交。
  4. 假如我们在工作区编辑了文件,Git 会将其与暂存区现有文件快照进行比较,在 git add 了更改的文件后,根据文件当前内容生成新的 blob 对象并更新 .git/index 文件中的引用 ID。git commit 的过程与之前类似,但是新的提交对象会以 HEAD 引用指向的提交作为父提交,然后更新其引用的 master 指向新创建的提交。
  5. 当我们 git checkout 一个分支或提交时,它会修改 HEAD 指向新的分支引用或提交,将暂存区填充为该次提交的文件快照,然后将暂存区的内容解包复制到工作区中。

常见的「撤销」命令

接下来我们将使用如下的 Git 仓库作为基准示例,介绍一些常见的「撤销」命令。假设工作区中已存在这些文件,且开始介绍每个命令时示例仓库都会回到初始状态:

1
2
3
4
5
6
7
8
$ git init
$ git add README.md && git commit -m "first commit"
$ git add .gitignore && git commit -m "add ignore file"
$ git add main.py && git commit -m "add main file"
$ git log --pretty=oneline
ea4c48a (HEAD -> master) add main file
b15cc74 add ignore file
e137e9b first commit

为了方便展示我们将只取 SHA-1 ID 的前 7 位,但 Git 依然能准确的找到对应的提交。

git checkout

checkout 有两种工作方式:在命令参数中带文件路径与不带。两种方式的具体行为有很大区别。

不带路径

不带路径的git checkout [commit or branch] 用于「检出」某个提交或分支,检出可以理解为「拿出来查看」,因此这个操作对工作区是安全的。git checkout [commit] 会更新所有的三棵树,使其和 [commit] 的状态保持一致,但保留工作区和暂存区所做的更改。

假如我们在工作区新增了 tests/test.py 文件,并加入到了暂存区中,然后 checkout 到上一个提交:

1
2
$ git add tests/test.py
$ git checkout b15cc74

checkout 命令的执行过程如以下动图所示:

Jan-11-2021-git-checkout

  1. 首先 HEAD 会直接指向 b15cc74 提交,进入分离 HEAD 状态,即不再指向分支引用:

    1
    2
    
    $ cat .git/HEAD
    b15cc74
    
  2. 然后将提取 b15cc74 提交的文件快照依次更新到暂存区以及工作区。

  3. 若工作区与暂存区存在未提交的本地更改,checkout 还会尝试将文件快照与本地更改做简单的合并,若合并失败,将会中止操作并恢复到 checkout 之前的状态。因此checkout 对工作区是安全的,它不会丢弃工作区所做的更改。

git checkout [branch] 的执行过程与上面类似,但是 HEAD 会指向 [branch] 这个分支引用。

带路径

git checkout 像下面这样在命令参数中带文件路径时:

1
$ git checkout b15cc74 README.md

执行过程如如以下动图所示:

Jan-11-2021-git-checkout-file

  1. 它会找到该提交,并在该提交的文件快照中匹配文件路径对应的文件,但并不会移动 HEAD:

    1
    2
    
    $ cat .git/HEAD
    ref: refs/heads/master
    
  2. 将匹配到的文件快照覆盖到暂存区以及工作区。

  3. 若工作区与暂存区存在对该文件的本地更改,该更改将会丢失。因此checkout 带文件路径时对工作区是不安全的,它会丢弃工作区对该文件所做的更改。

git reset

git reset 的主要作用是将 HEAD 重置为指定的提交。与 checkout 的区别在于,它对提交历史的更改并不仅仅只是更新 HEAD 本身,如果 HEAD 原来指向某个分支引用,则会将分支引用也更新为指向新的提交。

它的工作方式更多了,有 —soft--mixed--hard 三种主要的命令选项,分别对应更新不同数量的树:

iShot2021-01-07 22.21.30

--soft

当命令行选项为 --soft 时,git reset 只会对提交历史进行重置:

1
2
3
4
5
6
7
8
9
$ git checkout master && cat .git/refs/heads/master
已经位于 'master'
ea4c48a
$ git reset --soft b15cc74
$ git status
位于分支 master
要提交的变更:
  (使用 "git restore --staged <文件>..." 以取消暂存)
        新文件:   main.py

执行过程如以下动图所示:

Jan-11-2021-git-reset-soft

  1. 首先将 HEAD 及其指向的分支引用指向 b15cc74 提交,本示例中 HEAD 原本指向 master ,执行操作之后依然指向 master

    1
    2
    
    $ cat .git/HEAD
    ref: refs/heads/master
    

    master 分支引用却从原来指向 ea4c48a 变成了指向 b15cc74

    1
    2
    
    $ cat .git/refs/heads/master
    b15cc74
    

    若 HEAD 原本处于分离 HEAD 状态,则只会更新 HEAD 本身。

  2. reset --soft 到此就已经结束了,它不会再对暂存区以及工作区进行任何更改,暂存区和工作区依然保留着原来的 ea4c48a 提交之后的文件快照与文件,因此运行 git status 我们将看到暂存区中有待提交的变更,工作区和暂存区中的本地更改也都会得到保留。

--mixed

--mixed 选项是 git reset 命令的默认选项,git reset [commit] 即等同于 git reset --mixed [commit]。它除了重置提交历史,还会更新暂存区:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$ git checkout master && cat .git/refs/heads/master
已经位于 'master'
ea4c48a
$ git reset --mixed b15cc74
$ git status
位于分支 master
未跟踪的文件:
  (使用 "git add <文件>..." 以包含要提交的内容)
        main.py

提交为空,但是存在尚未跟踪的文件(使用 "git add" 建立跟踪)

执行过程如以下动图所示:

Jan-11-2021-git-reset-mixed

  1. 更新 HEAD 指向 b15cc74 提交,重置提交历史的过程与 --soft 完全相同。
  2. 之后还会更新暂存区,将其填充为 b15cc74 提交的文件快照,暂存区中的原有内容将会丢失。
  3. 不会对工作区进行任何更改,工作区依然保留着原来的 ea4c48a 提交之后的文件,因此运行 git status 我们将看到有未跟踪的文件待加入暂存区,工作区中的本地更改也会得到保留。

--hard

--hardreset直接、最危险以及最常用的选项。 git reset —hard [commit] 会将所有的三棵树都更新为指定提交的状态,工作区和暂存区中所有未提交的更改都会永久丢失,但被重置的提交仍有办法找回。

我们同样执行如下操作:

1
2
3
4
5
6
7
8
$ git checkout master && cat .git/refs/heads/master
已经位于 'master'
ea4c48a
$ git reset --hard b15cc74
HEAD 现在位于 b15cc74 add gitignore file
$ git status
位于分支 master
无文件要提交,干净的工作区

执行过程如以下动图所示:

Jan-11-2021-git-reset-hard

  1. 更新 HEAD 指向 b15cc74 提交,重置提交历史的过程与 --soft--mixed 选项相同。
  2. 更新暂存区,将其填充为 b15cc74 提交的文件快照,暂存区中的原有内容将会丢失。
  3. 更新工作区,将其填充为 b15cc74 提交的文件快照,工作区中的原有内容将会丢失。

正如上面所说,reset —hard 会将工作区、暂存区和提交历史都重置为刚刚新增了 b15cc74 提交时的状态,并简单粗暴地覆盖掉工作区和暂存区的原有内容。这是一个非常危险的操作,因为工作区和暂存区的未提交更改丢失后无法再通过 Git 找回。

找回提交历史

reset 后丢失的提交历史仍然能够恢复,因为我们只是更新了 HEAD 指向的提交,而没有对实际的提交对象做任何更改。我们可以通过 git reflog 找到 HEAD 曾经指向过的提交:

1
2
3
4
$ git reflog
b15cc74 (HEAD -> master) HEAD@{0}: reset: moving to b15cc74
ea4c48a HEAD@{1}: checkout: moving from master to master
......

从中可以找到 master 原来所指向的 ea4c48a 提交,再执行 git reset --hard ea4c48a 就能恢复原来的提交历史。

不要 reset 公共分支

另一个关于 reset的实践是,不要在公共分支上执行 reset。公共分支是指你与其他团队成员协作开发的分支。

当任何提交被推送到公共分支后,必须假设其他开发者已经依赖它。删除其他人已经在继续开发的提交,会给协作带来严重的问题。而且你需要强制推送才能将你 reset 后的分支提交到远程仓库,当其他人拉取这个公共分支时,他们的提交历史会突然消失一部分。

因此,请确保在本地的实验分支上使用 git reset,而不要重置已经发布到公共分支的提交。如果你需要修复一个公共提交引入的问题,请看之后将介绍的专门为此目的设计的 git revert

取消暂存文件

checkout 一样,git reset 也能对文件路径执行,常用于将已加入暂存区的指定文件或文件集合取消暂存。

假设我们在工作区新增了 hello.pyworld.py 两个文件,并同时加入了暂存区:

1
$ git add . 

现在我们意识到这两个文件不应该放在一个提交中,因此需要将其中一个文件取消暂存:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
$ git reset world.py
$ git status
位于分支 master
您的分支与上游分支 'origin/master' 一致。

要提交的变更:
  (使用 "git restore --staged <文件>..." 以取消暂存)
        新文件:   hello.py

未跟踪的文件:
  (使用 "git add <文件>..." 以包含要提交的内容)
        world.py

此时暂存区中只有 hello.py 文件了,我们可以分别提交它们:

1
2
3
4
$ git commit -m "add hello.py" 
# 在另一个提交中提交 world.py
$ git add world.py 
$ git commit -m "add world.py"

实际上 reset 带文件路径命令的完整形式是下面这样的:

1
git reset [<tree-ish>] <pathspec>…

该操作的实质,是从 <tree-ish> 提取 <pathspec> 对应的文件快照更新到暂存区,<tree-ish>可以是提交或分支,默认值为 HEAD,因此默认会将暂存区的指定路径恢复到 HEAD 提交的状态。 git reset world.py 命令的实际过程是:

  1. 从 HEAD 提交中匹配 world.py 对应的文件快照。
  2. 将匹配到的文件快照复制到暂存区。

因此,当我们修改了某个文件添加到暂存区,reset 后会被替换成原本的文件版本;新增的文件会从暂存区中移除(因为上一次提交中没有该文件),实际实现了将文件取消暂存的效果。

git revert

git revert 命令用于回滚某一个(或多个)提交引入的更改。

其他的「撤销」命令如 git checkoutgit reset,会将 HEAD 或分支引用重新指向到指定的提交,git revert 命令也可以接受一个指定的提交,但并不会将任何引用移动到这个提交上。revert 操作会接收指定的提交,反转该提交引入的更改,并创建一个新的「回滚提交」记录反转更改,然后更新分支引用,使其指向该提交。如以下动图所示:

Jan-11-2021-git-revert

相比 resetrevert 会在提交历史中增加一个新的提交,而不会对之前的提交进行任何更改。 默认情况下 revert 会自动执行如下步骤:

  • 将反转指定提交的更改合并到工作区
  • 将更改添加到暂存区
  • 创建新的提交

因此它要求我们提供一个干净的暂存区(即和 HEAD 提交状态一致),且要求工作区的本地更改不会被合并操作覆盖,否则回滚会失败。我们可以添加 --no-commit 命令选项来进入交互模式手动执行「创建新的提交」,此时 revert 操作会将反转的更改应用到工作区和暂存区等待提交,且不要求暂存区与 HEAD 一致。

我们通过示例来演示这一过程,现在我们想回滚 b15cc74 这个提交,这个提交中加入了 .gitignore 文件,预期的结果是会新增一个删除该文件的提交:

1
$ git revert b15cc74

在终端执行该命令后将直接跳转到一个编辑器界面,可以修改新提交的提交信息:

1
2
3
4
5
6
7
Revert "add gitignore file"

This reverts commit b15cc74d6d85435660fcacce1305a54273880479.

# 请为您的变更输入提交说明。以 '#' 开始的行将被忽略,而一个空的提交
# 说明将会终止提交。
......

保存后 revert 命令执行结束,并输出以下结果:

1
2
3
4
删除 .gitignore
[master 6bb25da] Revert "add gitignore file"
 1 file changed, 1 deletion(-)
 delete mode 100644 .gitignore

结果符合预期,新增了一个删除 .gitignore 文件的 6bb25da 提交,并且 master 当前指向了该提交。

但如果我们在一开始对工作区中的文件做过更改且加入到了暂存区,执行 revert 的结果如下:

1
2
3
4
$ git revert b15cc74
error: 您的本地修改将被还原覆盖。
提示:提交您的修改或贮藏后再继续。
fatal: 还原失败

revert 的优势

虽然效果与 reset 相似,但使用 revert 有以下优势:

  • 它不会改变之前的提交历史,这使得 revert 对于已经推送到共享仓库的提交是一个「安全」的操作,它会完整的记录某个提交被加入及回滚的过程。
  • 它可以回滚提交历史上任意一个(或多个)点的提交,而 reset 只能重置从指定提交起之后的所有历史。

使用场景

我们分别介绍了 checkoutresetrevert 三个命令的主要用法,下面的表格概括了它们的常见使用场景:

命令作用对象常用场景
git reset提交放弃私人分支上的提交或者还未提交的本地更改
git reset文件将一个文件取消暂存
git checkout提交切换分支或者查看一个之前的提交
git checkout文件将文件恢复到指定提交时的状态并丢弃在工作区中对该文件的更改
git revert提交在公共分支上撤销一个提交
git revert文件

其他替代命令

我们介绍了 checkoutresetrevert 三个命令共 7 种和撤销相关的用法,而这些命令还有许多其他的选项和用途,在使用这些命令时,即使是老手也可能需要不时地对照手册。也许是意识到了这个问题,Git 在 2.23 版本中又发布了 resetoreswitch 两个新命令,新命令能替代上面的部分用法且用途更为专一。

git restore

restore 命令用于还原工作区或暂存区中的指定文件或文件集合:

1
git restore [--source=<tree>] [--staged] [--worktree] <pathspec>…

从定义和命令行形式来理解:

  • 还原即恢复到过去某一状态,意味着该命令需要指定已有的某个文件快照(提交、分支等)作为数据源,通过 source 选项设置。
  • 可以选择对工作区(--worktree )、暂存区(--staged )或两者同时生效,默认值为仅工作区。当指定的位置为工作区时,默认数据源为暂存区的文件快照;当指定的位置包含暂存区时,默认数据源为 HEAD。
  • 可以选择对指定的文件或一些文件生效,通过 <pathspec> 参数指定。

我们继续使用之前的 Git 仓库作为示例,假设我们修改了 main.py 并已经加入到了暂存区:

  1. 我们想将 main.py 取消暂存,即将暂存区中的 main.py 还原为 HEAD 中的内容,此时 HEAD 是默认的 source ,因此可执行如下命令:

    1
    
    git restore --staged main.py
    

    该文件将被取消暂存。

  2. 现在我们想放弃工作区中对该文件的更改,可以选择将其还原为暂存区中的内容,因为此时暂存区中的内容和 HEAD 相同:

    1
    
    git restore main.py
    

这只是最基础的用法,还可以指定 --source 为任意提交 ID 将文件还原为该提交中的状态。

git restore [--source=<tree-ish>] --staged <pathspec>...git reset [<tree-ish>] <pathspec> 在使用上是等价的。较新版本的 Git 会在命令行中提示使用 restore 命令来取消暂存或丢弃工作区的改动。

git switch

git switch 命令专门用于切换分支,可以用来替代 checkout 的部分用途。

创建并切换到指定分支( -C 大小写皆可):

1
git switch -C <new-branch>

切换到已有分支:

1
git switch <branch>

checkout 一样, switch 对工作区是安全的,它会尝试合并工作区和暂存区中的本地更改,如果无法完成合并则会中止操作,本地更改会被保留。

switch 的使用方式简单且专一,它无法像 checkout 一样对指定提交使用:

1
2
$ git switch ea4c48a
fatal: 期望一个分支,得到提交 'ea4c48a'

常见问题及解决方案

撤销本地分支提交

使用 git reset ,取决于你是否需要保留该提交之后的更改,添加 --soft—hard 等选项。

回滚远程主干分支上的提交

使用 git revert

修改上一次提交的内容

如果该提交还未进入公共分支,最直接的方式是使用 git commit --amend。如果该提交已经位于公共分支,应该使用 git revert

暂存更改后再恢复

一个很常见的场景是,我们在当前分支修改了一些文件,但还不足以组织成提交或者包含了多个提交的内容,突然有紧急情况需要开始一项新的任务,此时我们希望可以将工作区和暂存区的本地更改暂时保存起来,以备在其他工作完成后可以从这里继续。

我们当然可以创建一个临时的分支然后重置或合并来实现目的,但那样复杂而繁琐。而 git stash 命令则可以很好的满足需求,它会将本地更改保存起来,并将工作区和暂存区恢复到与 HEAD 提交相匹配的状态。此时我们可以切换到其他分支或者继续在当前分支完成其他任务,之后再将暂存的内容取回。

git stash 的基本用法如下:

1
2
3
4
5
# 保存当前更改(添加 -u 选项以包括未跟踪的新文件)
$ git stash -u
# 完成其他任务......
# 恢复暂存的更改
$ git stash pop

stash 的实质也是将本地更改保存为一次新的提交,然后再将该提交恢复到工作区和暂存区,但它不会影响当前的提交历史。stash 还有更多进阶用法,比如指定暂存的文件路径、暂存多次并择一恢复等。

总结

在本文中我们首先了解了一些必要的 Git 内部机制:

  • 使用 blobtree 和提交对象等内部对象保存数据,每次提交都是一份完整的文件快照。
  • SHA-1 ID、分支引用及 HEAD 的实质。
  • 管理三棵树的状态:工作区、暂存区、提交历史。
  • 创建一次提交的完整工作流程。

然后通过示例分别介绍了 checkoutresetrevert 的基本用法与区别:

  • checkout
    • 不带路径:将工作区、暂存区更新为指定提交的状态,但会保留本地更改。
    • 带路径:将指定文件更新为指定提交的状态,不会保留本地更改
  • reset
    • —soft:仅将 HEAD 及其指向的分支引用移动到指定提交。
    • —mixed:除了更改提交历史,还将暂存区也更新为指定提交的内容。
    • —hard:除了更改提交历史和暂存区,还将工作区也更新为指定提交的内容,工作区的本地更改会永久丢失。
    • 对文件路径使用可以将文件取消暂存。
  • revert
    • 创建一个新的提交以撤销指定提交引入的更改。

还介绍了两个新版本引入的更专一的命令:

  • restore:将工作区或暂存区的指定文件还原为指定提交时的状态。
  • switch:切换到已有分支或者创建并切换到新的分支。

最后给出了一些常见问题的解决方案并介绍了 git stash 的用法。

参考链接

updatedupdated2022-08-032022-08-03