git reset 命令是一种用于撤销更改的复杂、多功能工具。它有三种主要的调用形式。这些形式分别对应命令行参数 --soft、--mixed、--hard。这三个参数分别对应 Git 的三个内部状态管理机制:提交树 (HEAD)、暂存索引和工作目录。

Git 重置和 Git 的三棵树

要正确理解 git reset 的用法,我们必须首先了解 Git 的内部状态管理系统。有时这些机制被称为 Git 的“三棵树”。树可能不是特别恰当,因为严格来说它们不是传统的树数据结构。但是,它们是基于节点和指针的数据结构,Git 使用它们来跟踪编辑时间线。演示这些机制最好的方法是在存储库中创建一个变更集,然后通过三棵树进行跟进。

首先,我们将使用以下命令创建一个新的存储库:

$ mkdir git_reset_test
$ cd git_reset_test/
$ git init .
Initialized empty Git repository in /git_reset_test/.git/
$ touch reset_lifecycle_file
$ git add reset_lifecycle_file
$ git commit -m"initial commit"
[main (root-commit) d386d86] initial commit
1 file changed, 0 insertions(+), 0 deletions(-)
create mode 100644 reset_lifecycle_file

上面的示例代码创建了一个新的 git 存储库,其中包含一个空文件 reset_lifecycle_file。此时,示例存储库在添加 reset_lifecycle_file 后有一次提交 (d386d86)。

工作目录

我们要研究的第一棵树是“工作目录”。该树与本地文件系统同步,代表对文件和目录中的内容所做的即时更改。


$ echo 'hello git reset' > reset_lifecycle_file
$ git status 
On branch main
Changes not staged for commit: 
(use "git add ..." to update what will be committed) 
(use "git checkout -- ..." to discard changes in working directory) 
modified: reset_lifecycle_file

在我们的演示存储库中,我们对 reset_lifecycle_file 进行了修改并添加了一些内容。调用 git status 表明 Git 知道文件的更改。这些更改目前是第一棵树“工作目录”的一部分。Git status 可用于显示对工作目录的更改。它们将以红色显示,带有“已修改”前缀。

暂存索引

接下来是“暂存索引”树。这棵树正在跟踪工作目录更改,这些更改已通过 git add 提升,以便存储在下一次提交中。这棵树是一种复杂的内部缓存机制。Git 通常会尝试向用户隐藏暂存索引的实现细节。

要准确查看暂存索引的状态,我们必须使用鲜为人知的 Git 命令 git ls-filesgit ls-files 命令本质上是一个用于检查暂存索引树状态的调试实用程序。

git ls-files -s
100644 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 0   reset_lifecycle_file

这里我们使用 -s--stage 选项执行了 git ls-files。如果没有 -s 选项,git ls-files 输出只是一个文件名和路径列表,这些文件名和路径目前是索引的一部分。-s 选项显示暂存索引中文件的其他元数据。此元数据是暂存内容的模式位、对象名称和阶段号。这里我们关注的是对象名称,即第二个值 (d7d77c1b04b5edd5acfc85de0b592449e5303770)。这是一个标准的 Git 对象 SHA-1 哈希。它是文件内容的哈希。提交历史记录存储自己的对象 SHA,用于识别指向提交和引用的指针,而暂存索引有自己的对象 SHA,用于跟踪索引中文件的版本。

接下来,我们将把修改后的 reset_lifecycle_file 提升到暂存索引中。


$ git add reset_lifecycle_file 

$ git status 

On branch main Changes to be committed: 

(use "git reset HEAD ..." to unstage) 

modified: reset_lifecycle_file

这里我们调用了 git add reset_lifecycle_file,它将文件添加到暂存索引中。调用 git status 现在在“要提交的更改”下以绿色显示 reset_lifecycle_file。需要注意的是,git status 不是暂存索引的真实表现。git status 命令输出显示提交历史记录和暂存索引之间的变更。现在我们来看看暂存索引的内容。

 $ git ls-files -s 100644 d7d77c1b04b5edd5acfc85de0b592449e5303770 0 reset_lifecycle_file

我们可以看到,reset_lifecycle_file 的对象 SHA 已从 e69de29bb2d1d6434b29ae775ad8c2e48c5391 更新为 d7d77c1b04b5acfc85de0b592449e530370

提交历史记录

最后一棵树是提交历史记录。git commit 命令将更改添加到提交历史记录中的永久快照中。此快照还包括提交时暂存索引的状态。

$ git commit -am"update content of reset_lifecycle_file"
[main dc67808] update content of reset_lifecycle_file
1 file changed, 1 insertion(+)
$ git status
On branch main
nothing to commit, working tree clean

这里,我们创建了一个新的提交,其中包含 "update content of resetlifecyclefile"。变更集已添加到提交历史记录中。此时调用 git status 表明任何树都没有待处理的更改。执行 git log 将显示提交历史记录。现在我们已经通过三棵树跟踪了这个变更集,我们可以开始使用 git reset 了。

工作原理

在表面层面,git reset 的行为与 git checkout 类似。当 git checkout 仅在 HEAD 引用指针上运行时,git reset 将移动 HEAD 引用指针和当前分支引用指针。要更好地演示这个行为,请看下方示例:

4 个节点,最后一个是“主节点”

此示例演示了 main 分支上的一系列提交。HEAD 引用和 main 分支引用当前指向提交 d。现在我们来执行并比较 git checkout bgit reset b

git checkout b

4 个节点,main 指向最后一个节点,head 指向第 2 个节点

使用 git checkout 时,main 引用仍然指向 dHEAD 引用已被移动,现在指向提交 b。该代码存储库现在处于“游离的 HEAD”状态。

git reset b

2 组 2 个节点,head,main 指向第 1 组中的第 2 个

相比之下,git reset 会将 HEAD 和分支引用都移到指定的提交中。

除了更新提交引用指针外,git reset 还将修改三棵树的状态。引用指针修改总是会发生,是对第三棵树提交树的更新。命令行参数 --soft、--mixed--hard 指导如何修改暂存索引和工作目录树。

主要选项

git reset 的默认调用具有 --mixedHEAD 的隐式参数。这意味着执行 git reset 等同于执行 git reset --mixed HEAD。在这种形式中,HEAD 是指定的提交。可以使用任何 Git SHA-1 提交哈希代替 HEAD

git 重置的范围示意图

--hard

这是最直接、最危险和最常用的选项。传递 --hard 时,提交历史记录引用指针将更新为指定的提交。然后,将重置暂存索引和工作目录以匹配指定提交的暂存索引和工作目录。暂存索引和工作目录任何先前待处理的更改都将被重置以匹配提交树的状态。这意味着在暂存索引和工作目录中挂起的所有待处理工作都将丢失。

为了演示这一点,我们继续使用之前建立的三棵树示例代码存储库。首先,我们对代码存储库进行一些修改。在示例代码存储库中执行以下命令:

$ echo 'new file content' > new_file
$ git add new_file
$ echo 'changed content' >> reset_lifecycle_file
 

这些命令创建了一个名为 new_file 的新文件并将其添加到代码存储库中。此外,reset_lifecycle_file 的内容将被修改。有了这些更改,现在我们使用 git status 来检查代码存储库的状态。

$ git status
On branch main
Changes to be committed:
   (use "git reset HEAD ..." to unstage)

new file: new_file

Changes not staged for commit:
   (use "git add ..." to update what will be committed)
   (use "git checkout -- ..." to discard changes in working directory)

modified: reset_lifecycle_file

我们可以看到,代码存储库现在有待处理更改。暂存索引树有一项关于添加 new_file 的待处理更改,工作目录有一项关于修改 reset_lifecycle_file 的待处理更改。

继续操作之前,我们还要检查暂存索引的状态:

$ git ls-files -s
100644 8e66654a5477b1bf4765946147c49509a431f963 0 new_file
100644 d7d77c1b04b5edd5acfc85de0b592449e5303770 0 reset_lifecycle_file

我们可以看到 new_file 已添加到索引中。我们已经对 reset_lifecycle_file 进行了更新,但暂存索引 SHA (d7d77c1b04b5edd5acfc85de0b592449e5303770) 保持不变。这是预期的行为,因为尚未使用 git add 将这些更改提升到暂存索引中。这些更改位于工作目录中。

现在,我们来执行 git reset --hard 并检查存储库的当前状态。

$ git reset --hard
HEAD is now at dc67808 update content of reset_lifecycle_file
$ git status
On branch main
nothing to commit, working tree clean
$ git ls-files -s
100644 d7d77c1b04b5edd5acfc85de0b592449e5303770 0 reset_lifecycle_file

这里我们使用 --hard 选项执行了“硬重置”。Git 显示的输出表明 HEAD 指向最新提交 dc67808。接下来,我们使用 git status 检查存储库的状态。Git 表示没有待处理的更改。我们还检查了暂存索引的状态,发现它已被重置到添加 new_file 之前的某个点。我们对 reset_lifecycle_file 的修改和添加的 new_file 已被销毁。这种数据丢失无法撤销,这一点需要注意。

--mixed

这是默认的操作模式。引用指针已更新。暂存索引重置为指定提交状态。从暂存索引中撤销的所有更改都将移至工作目录。我们继续。

$ echo 'new file content' > new_file
$ git add new_file
$ echo 'append content' >> reset_lifecycle_file
$ git add reset_lifecycle_file
$ git status
On branch main
Changes to be committed:
    (use "git reset HEAD ..." to unstage)

new file: new_file
modified: reset_lifecycle_file


$ git ls-files -s
100644 8e66654a5477b1bf4765946147c49509a431f963 0 new_file
100644 7ab362db063f9e9426901092c00a3394b4bec53d 0 reset_lifecycle_file

在上面的示例中,我们对存储库进行了一些修改。同样,我们添加了一个 new_file 并修改了 reset_lifecycle_file 的内容。然后,使用 git add 将这些更改应用于暂存索引。当代码存储库处于这种状态时,我们现在将执行重置。

$ git reset --mixed
$ git status
On branch main
Changes not staged for commit:
    (use "git add ..." to update what will be committed)
    (use "git checkout -- ..." to discard changes in working directory)

modified: reset_lifecycle_file

Untracked files:
    (use "git add ..." to include in what will be committed)

new_file


no changes added to commit (use "git add" and/or "git commit -a")
$ git ls-files -s
100644 d7d77c1b04b5edd5acfc85de0b592449e5303770 0 reset_lifecycle_file

这里我们执行了“混合重置”。重申一下,--mixed 是默认模式,其效果与执行 git reset 的效果相同。通过检查 git statusgit ls-files 的输出,可以看出,暂存索引已被重置为索引中唯一文件为 reset_lifecycle_file 的状态。reset_lifecycle_file 的对象 SHA 已重置为以前的版本。

这里需要注意的重要事项是,git status 向我们展示了对 reset_lifecycle_file 的修改,并且有一个未跟踪的文件 new_file。这是显式的 --mixed 行为。暂存索引已重置,待处理更改已移至工作目录。将此与 --hard 重置案例进行比较,在这种情况下,暂存索引被重置,工作目录也被重置,从而丢失了这些更新。

--soft

传递 --soft 参数时,ref 指针会更新,重置就此停止。暂存索引和工作目录保持不变。这种行为可能很难清楚地展示出来。我们继续我们的演示代码存储库,为软重置做好准备。


$ git add reset_lifecycle_file 

$ git ls-files -s 

100644 67cc52710639e5da6b515416fd779d0741e3762e 0 reset_lifecycle_file 

$ git status 

On branch main

Changes to be committed: 

(use "git reset HEAD ..." to unstage) 

modified: reset_lifecycle_file 

Untracked files: 

(use "git add ..." to include in what will be committed) 

new_file

在这里,我们再次使用 git add 将修改后的 reset_lifecycle_file 提升到暂存索引中。我们确认索引已使用 git ls-files 输出进行了更新。git status 的输出现在以绿色显示“要提交的更改”。我们之前示例中的 new_file 作为未跟踪文件浮动在工作目录中。我们快速执行 rm new_file 来删除文件,因为在接下来的示例中我们不需要它。

当存储库处于这种状态时,我们现在执行软重置。

$ git reset --soft
$ git status
On branch main
Changes to be committed:
    (use "git reset HEAD ..." to unstage)

modified: reset_lifecycle_file
$ git ls-files -s
100644 67cc52710639e5da6b515416fd779d0741e3762e 0 reset_lifecycle_file

我们已经执行了“软重置”。使用 git statusgit ls-files 检查代码存储库状态显示没有任何更改。这是预期的行为。软重置只会重置提交历史记录。默认情况下,使用 HEAD 作为目标提交调用 git reset。既然我们的提交历史记录已经在 HEAD 上了,并且我们隐式重置为 HEAD,所以实际上什么也没发生。

为了更好地理解和利用 --soft,我们需要一个不是 HEAD 的目标提交。我们在暂存索引中有 reset_lifecycle_file 在等待。我们来创建一个新提交。

$ git commit -m"prepend content to reset_lifecycle_file"

此时,我们的代码存储库应该有三次提交。我们将回到第一次提交。为此,我们需要第一次提交的 ID。这可以通过查看 git log 的输出找到。

$ git log
commit 62e793f6941c7e0d4ad9a1345a175fe8f45cb9df
Author: bitbucket 
Date: Fri Dec 1 15:03:07 2017 -0800
prepend content to reset_lifecycle_file

commit dc67808a6da9f0dec51ed16d3d8823f28e1a72a
Author: bitbucket 
Date: Fri Dec 1 10:21:57 2017 -0800

update content of reset_lifecycle_file

commit 780411da3b47117270c0e3a8d5dcfd11d28d04a4

Author: bitbucket 
Date: Thu Nov 30 16:50:39 2017 -0800

initial commit

请记住,提交历史记录 ID 对于每个系统都是唯一的。这意味着本示例中的提交 ID 将与您在个人计算机上看到的不同。在本例中,我们关注的提交 ID 是 780411da3b47117270c0e3a8d5dcfd11d04a4。这是与“初始提交”相对应的 ID。找到此 ID 后,我们将使用它作为软重置的目标。

回到过去之前,我们先检查一下代码存储库的当前状态。

$ git status && git ls-files -s
On branch main
nothing to commit, working tree clean
100644 67cc52710639e5da6b515416fd779d0741e3762e 0 reset_lifecycle_file

这里我们执行 git statusgit ls-files -s 的组合命令,这表明暂存索引中的代码存储库和 reset_lifecycle_file 的版本为 67cc52710639e5da6b515416fd779d0741e3762e。考虑到这一点,我们执行软重置回第一次提交。

$git reset --soft 780411da3b47117270c0e3a8d5dcfd11d28d04a4
$ git status && git ls-files -s
On branch main
Changes to be committed:
    (use "git reset HEAD ..." to unstage)

modified: reset_lifecycle_file
100644 67cc52710639e5da6b515416fd779d0741e3762e 0 reset_lifecycle_file

The code above executes a "soft reset" and also invokes the git status and git ls-files combo command, which outputs the state of the repository. We can examine the repo state output and note some interesting observations. First, git status indicates there are modifications to reset_lifecycle_file and highlights them indicating they are changes staged for the next commit. Second, the git ls-files output indicates that the Staging Index has not changed and retains the SHA 67cc52710639e5da6b515416fd779d0741e3762e we had earlier.

为了进一步阐明此次重置中发生了什么,我们看一下 git log

 $ git log commit 780411da3b47117270c0e3a8d5dcfd11d28d04a4 Author: bitbucket  Date: Thu Nov 30 16:50:39 2017 -0800 initial commit

现在,日志输出显示提交历史记录中只有一次提交。这有助于清楚地说明 --soft 做了什么。与所有 git reset 调用一样,重置采取的第一个操作是重置提交树。我们之前使用 --hard--mixed 的例子都违背了 HEAD,也没有让提交树回到过去。这就是软重置期间的所有情况。

这可能会让人困惑为什么 git status 显示有修改过的文件。--soft 不触及暂存索引,因此我们暂存索引的更新会跟随我们在提交历史记录中回到过去。这可以通过 git ls-files-s 的输出来证实,该输出显示 reset_lifecycle_file 的 SHA 保持不变。提醒一下,git status 不显示“三棵树”的状态,它本质上显示了它们之间的差异。在这种情况下,它显示暂存索引领先于提交历史记录中的更改,就好像我们已经暂存了它们一样。

重置与还原

如果 git revert 是撤销更改的“安全”方法,那么您可以将 git reset 视为危险的方法。使用 git reset 确实存在丢失工作的风险。Git reset 永远不会删除提交,但是,提交可能会变成“孤立”,这意味着没有从引用直接访问它们的路径。这些孤立的提交通常可以使用 git reflog 找到并恢复。Git 将在运行内部垃圾回收器后永久删除所有孤立的提交。默认情况下,Git 配置为每 30 天运行一次垃圾回收器。提交历史记录是“三个 git 树”之一,另外两个暂存索引和工作目录不像提交这样持久。使用此工具时必须小心,因为它是少数可能丢失工作的 Git 命令之一。

还原旨在安全地撤销公共提交,而 git reset 旨在撤销对暂存索引和工作目录的本地更改。由于它们的目标不同,这两个命令的实现方式不同:重置会完全删除变更集,而还原会保留原始变更集并使用新的提交来应用撤销。

不要重置公共历史记录

<提交>后的任何快照推送到公共存储库时,切勿使用 git reset 。发布提交后,您必须假设其他开发人员依赖它。

删除其他团队成员持续开发的提交会给协作带来严重问题。当他们尝试与您的代码库同步时,看起来像是一块项目历史记录突然消失。下面一系列图形演示了当您尝试重置公共提交时会发生的情况。origin/main 分支是本地 main 分支的中央代码库的版本。

4 组节点,origin/main 指向最后一个节点

只要您在重置后添加新提交,Git 就会认为您的本地历史记录与 origin/main 有所不同,同步存储库所需的合并提交可能会让您的团队感到困惑和沮丧。

关键是,确保您在本地实验(出错了)中使用 git reset ,而不是在已发布的更改上使用。如果您需要修复公共提交,git revert 命令是专门为此目的而设计的。

示例

 git reset <file>

从暂存区移除指定文件,但保持工作目录不变。这会在不覆盖任何更改的情况下取消暂存文件。

 git reset

重置暂存区以匹配最近的提交,但保持工作目录不变。这会在不覆盖任何更改的情况下取消暂存所有文件,从而使您有机会从头开始重新构建暂存的快照。

 git reset --hard

重置暂存区和工作目录以匹配最近的提交。除了取消暂存更改外,--hard 标记还告诉 Git 覆盖工作目录中的所有更改。换句话说:这会抹去所有未提交的更改,因此在使用之前,请确保您真的想丢弃本地开发项目。

 git reset  

将当前分支提示向后移动到 commit,将暂存区域重置为匹配,但不更改工作目录。 后所做的所有更改都将保存在工作目录中,这样您可以使用更简洁、更具原子性的快照重新提交项目历史记录。

 git reset --hard  

将当前分支提示向后移动到 ,并将暂存区域和工作目录均重置为匹配。这不仅会删除未提交的更改,还会删除之后的所有提交。

取消暂存文件

准备暂存快照时经常会遇到 git reset 命令。下个示例假设您已经将两个名为 hello.pymain.py 的文件添加到存储库中。

# Edit both hello.py and main.py

# Stage everything in the current directory
git add .

# Realize that the changes in hello.py and main.py
# should be committed in different snapshots

# Unstage main.py
git reset main.py

# Commit only hello.py
git commit -m "Make some changes to hello.py"

# Commit main.py in a separate snapshot
git add main.py
git commit -m "Edit main.py"

如您所见,git reset 允许您取消暂存与下一次提交无关的更改,从而帮助您保持高度集中的提交。

移除本地提交

下个示例显示了一个更高级的用例。它演示了当您做一个新实验已有一段时间,但在提交了几张快照后决定将其完全丢弃时会发生什么。

# Create a new file called `foo.py` and add some code to it

# Commit it to the project history
git add foo.py
git commit -m "Start developing a crazy feature"

# Edit `foo.py` again and change some other tracked files, too

# Commit another snapshot
git commit -a -m "Continue my crazy feature"

# Decide to scrap the feature and remove the associated commits
git reset --hard HEAD~2

git reset HEAD~2 命令将当前分支向后移动两次提交,实际上从项目历史记录中删除了我们刚刚创建的两个快照。请记住,这种重置只能用于未发布的提交。如果您已经将提交推送到共享存储库,则切勿执行上述操作。

摘要

回顾一下,git reset 是一个强大的命令,用于撤销对 Git 代码存储库状态的本地更改。Git reset在“Git 的三棵树”上运行。这些树分别是提交历史记录 (HEAD)、暂存索引和工作目录。有三个命令行选项分别对应于这三棵树。选项 --soft、--mixed--hard 可以传递给 git reset

在本文中,我们利用了其他几个 Git 命令来帮助演示重置流程。如需详细了解这些命令,请访问这些命令各自的页面:git statusgit loggit addgit checkoutgit reflog git revert

准备好学习 git 重置了吗?

试用本交互式教程。

立即开始