现在关注Git的常见撤销操作。 撤销操作在某些情形下是危险的,这指的不是 Git 数据库中的内容丢失(这几乎很难办到),而是最新的修改可能丢失:

  • 如果修改被正式提交了,那么即使回滚了版本,也可以通过哈希值或历史记录来恢复
  • 如果使用git add添加到 index,那么即使撤回了,也可以通过哈希值或历史记录来恢复
  • 如果最新的修改没有添加到 index,那么这些本地修改确实有可能直接被 Git 的某个切换操作或撤销操作覆盖,导致修改的丢失

Git 撤销选项

reset

git reset 是一个非常重要的重置操作,原型如下

1
2
3
4
git reset [-q] [<tree-ish>] [--] <pathspec>…​
git reset [-q] [--pathspec-from-file=<file> [--pathspec-file-nul]] [<tree-ish>]
git reset (--patch | -p) [<tree-ish>] [--] [<pathspec>…​]
git reset [--soft | --mixed [-N] | --hard | --merge | --keep] [-q] [<commit>]

reset 命令主要关注 HEAD 移动所对应的版本切换或撤销操作:假设我们希望将 HEAD 指向的分支(例如 main)移动到其他结点(例如回滚到前一个结点,记作 HEAD^ ), 那么除了修改分支的指向,还需要考虑 index 和工作目录是否随之改变,可以进行从低到高三个级别的操作:

  • --soft:不影响 index 和工作目录,只是 HEAD 指向分支的移动

    1
    git reset --soft HEAD^

  • --mixed(默认)移动分支之后,重置 index,但是不影响工作目录

    1
    2
    git reset HEAD^
    git reset --mixed HEAD^

  • --hard 移动分支之后,重置 index 和工作目录,这很危险,因为可能丢失工作目录中的数据

    1
    git reset --hard HEAD^

如果我们让 HEAD 的指向不变,此时如果使用--soft是没有任何效果的,没有移动分支,但是如果使用其它两个选项,就相当于利用它进行一次撤销操作:

  • 使用--mixed 选项,使用 HEAD 指向的提交快照来重置 index,这相当于撤销了所有的 git add 操作(HEAD \(\Rightarrow\) index

    1
    git reset HEAD

  • 利用-hard选项,使用 HEAD 指向的提交快照来重置 index 和工作目录,这相当于完全回到上一次正式提交的状态(HEAD \(\Rightarrow\) index \(\Rightarrow\) Working Directory

    1
    git reset --hard HEAD

注意:git reset 的命令都是从 HEAD 出发的,因此无法做到基于 index 重置工作目录的效果。

上述操作都是针对仓库整体,如果限制在具体的目录或文件上,那么并不支持这么灵活的控制,实际上只能基于某个快照重置 index,例如:

  • 用指定的提交版本(例如前一个版本 HEAD^ )的快照重置 index 中的对应文件,但是不影响工作目录的内容,效果相当于撤销了当前版本对文件的修改

    1
    git reset HEAD^ filename

  • 用 HEAD 重置 index 中的对应文件,效果相当于撤销了git add filename

    1
    git reset HEAD filename

git reset 在全局的操作中更常见,局部的撤销行为更建议使用下面的 git restore 命令。

restore

git restore是另一个撤销操作,原型如下

1
2
3
git restore [<options>] [--source=<tree>] [--staged] [--worktree] [--] <pathspec>…​
git restore [<options>] [--source=<tree>] [--staged] [--worktree] --pathspec-from-file=<file> [--pathspec-file-nul]
git restore (-p|--patch) [<options>] [--source=<tree>] [--staged] [--worktree] [--] [<pathspec>…​]

git restore 在逻辑上比 git reset 更清晰,使用更加友好:

  • 它不支持 HEAD 指向分支的移动,只是专门用于撤销操作;
  • 没有默认的范围,必须指定具体的文件或路径(当然,使用 . 也相当于对所有项进行操作)。

git restore 命令会对某个具体的项,使用参考源的快照来重置目的地的对应项(如果参考源没有这一项,而目的地存在,会在目的地将其移除)

关于重置的参考源和目的地:

  • --worktree:(默认)index \(\Rightarrow\) Working Directory
  • --stagedHEAD \(\Rightarrow\) index
  • --staged--worktreeHEAD \(\Rightarrow\) index \(\Rightarrow\) Working Directory

除了指定重置的参考源和目的地,还必须限制作用到具体文件上,限制范围不能省略。 例如下面的命令用 HEAD 重置 index 的指定文件,相当于撤回了 git add filename

1
git restore --staged filename

git restore其实是从git checkout剥离出来的新命令,由于原本的git checkout过于复杂,在逻辑上明显包括了分支切换和撤销两个部分,在较新的Git版本中,这两个命令被单独拆分给了两个新命令:git switchgit restore

简单记录一下git checkout在撤销时的语法:如果 git checkout 命令没有加上分支名称,会被视为一种撤销操作,此时HEAD指向的分支不发生变化,但是可能将 index 或工作目录的某个文件撤回到某个正式提交的状态,例如

  • 对单个文件,撤销在工作目录中的修改,用 index 重置工作目录的这个文件(index \(\Rightarrow\) Working Directory

    1
    git checkout -- filename

  • 如果指定 HEAD,则会用 HEAD 的状态重置 index 和工作目录的这个文件(HEAD \(\Rightarrow\) index \(\Rightarrow\) Working Directory

    1
    git checkout HEAD -- filename

注:

  • -- 在 Git 中通常用于区分选项和参数的消歧义,在其后的项不会被理解为选项,而是视作文件名或路径名称,这里如果缺少 -- ,filename 会被理解为分支名称,并尝试进行分支切换
  • 这里撤回的参考源默认是HEAD指向的状态或index,也可以是其他的正式提交结点
  • 如果参考源没有指定的项,git checkout 可能会报错(这一点与 git restore 不同,后者会在目的地删除这一项)

Git 撤销操作

前文介绍了几个撤销相关的命令的各种操作,现在从需求的角度记录一下应该如何使用这些命令,这里省略了过时的 git checkout 命令的相关操作。

撤销工作目录的修改

\[ \color{red} \text{index} \Rightarrow \text{Working \,Directory} \]

  • 使用 git restore 默认基于 index 重置工作目录,作用范围不可省略
    1
    2
    git restore filename
    git restore .

撤销 index 的修改

\[ \color{red} \text{HEAD} \Rightarrow \text{index} \]

  • git reset 撤销整个 index 的修改,默认是 --mixed 级别,默认基于 HEAD 进行重置

    1
    2
    3
    git reset --mixed HEAD
    git reset HEAD
    git reset

  • git restore--staged 选项才能基于 HEAD 重置 index,作用范围不可省略

    1
    2
    git restore --staged filename
    git restore --staged .

撤销工作目录和 index 的修改

\[ \color{red} \text{HEAD} \Rightarrow \text{index} \Rightarrow \text{Working \,Directory} \]

  • git reset--hard 级别可以对工作目录和 index 进行重置

    1
    git reset --hard HEAD

  • git restore 加两个选项才能基于 HEAD 重置 index 和工作目录,作用范围不可省略

    1
    2
    git restore --staged --worktree filename
    git restore --staged --worktree .

修正上一次提交

git commit 会生成新的提交对象,并移动 HEAD 和分支到最新的提交上, 在进行了一次正式提交之后,如果发现某些内容遗漏,或者只是想要修改提交的 message,都可以使用 --amend 选项对 HEAD 指向的提交进行修正

1
2
3
4
5
6
7
git commit -m "last commit"

git add something_new
git commit --amend

# or just modify the message
git commit --amend

再次生成的提交会完全替换上一次的提交,上一次的提交不会出现在日志中。(但是仍然在数据库中,处于游离状态)

注意:这个操作只建议在本地进行,如果已经推送到了远程仓库,那么重新添加一个提交比修正更合适,因为会出现合并冲突,而且远程分支通常会有保护措施,不允许强制推送。

回退上一次提交

如果需要完全舍弃最近的一次提交,可以通过 git reset 实现撤销提交,回到上一次的提交状态

1
git reset --soft HEAD^

由于使用了--soft选项,这里只是对 HEAD 和分支的移动,并没有用 HEAD^ 的内容重置 index 和工作目录。此时如果重新提交,或者对 index 进行处理后再提交,也可以达到 git commit --amend 的效果。