现在关注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>]

首先介绍第一类用法,可以用指定的提交版本(例如前一个版本 HEAD^ )的快照刷新 index 中的对应文件,但是不影响工作目录的内容

1
git reset HEAD^ filename

如果选择HEAD,则相当于用HEAD刷新index中的对应文件,效果相当于撤销了git add filename

1
git reset HEAD filename

然后介绍第二类用法,现在是一个整体性的版本切换或撤销操作:假设我们希望将 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指向的分支,仍然指向HEAD,只是利用它进行一次撤销操作

  • 使用--mixed 选项,使用 HEAD 指向的提交快照来刷新 index,这相当于撤销了所有的 git add 操作

    1
    git reset HEAD

  • 利用-hard选项,使用 HEAD 指向的提交快照来刷新 index 和工作目录,这相当于完全回到上一次正式提交的状态

    1
    git reset --hard HEAD

(此时如果使用--soft是没有任何效果的,没有移动分支)

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命令会对某个具体的项,使用参考源的快照来刷新目的地的对应项(如果参考源没有这一项,而目的地存在,则会在目的地移除它)

关于刷新的参考源:

  • 在不使用--staged选项时,参考源为 index
  • 在使用--staged选项时,基于当前 HEAD 指向的提交结点(因为这个选项意味着 index 也要被刷新)
  • 也可以使用 --source=... 指定其他版本。

关于刷新的目的地:(通常为index或工作目录)

  • 默认情形只刷新工作目录,相当于使用--worktree
  • 如果使用--staged选项,只刷新 index
  • 如果使用--staged--worktree,则刷新 index 和工作目录

除了指定刷新的参考源和目的地,还必须限制作用到具体文件上,而不是全部,例如下面的命令用 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 刷新工作目录的这个文件

    1
    git checkout -- filename

  • 如果指定 HEAD,则会用 HEAD 的状态刷新index和工作目录的这个文件

    1
    git checkout HEAD -- filename

注:

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

Git 撤销操作

前文介绍了几个撤销相关的命令的原理,现在从需求的角度记录一下应该如何使用这些命令。

撤销单个文件

撤销工作目录的单个文件(基于index)

  • 基于git restore实现

    1
    git restore filename

  • (过时)基于git checkout当前分支实现,必须加上--,这个操作是老版本的建议,新版本建议是使用git restore

    1
    git checkout -- filename

撤销index的单个文件(基于HEAD),但不改动工作目录

  • 基于git restore实现,这里的 --staged 是必要的

    1
    git restore --staged filename

  • 基于git reset HEAD实现

    1
    2
    git reset HEAD filename
    git reset filename

撤销工作目录和index的单个文件(基于HEAD)

  • 基于git restore实现,这里的 --staged--worktree 是必要的

    1
    git restore --staged --worktree filename

  • (过时)基于git checkout实现

    1
    git checkout HEAD -- filename

撤销所有文件

撤销工作目录的所有文件(基于index):

  • 使用 git restore 默认基于 index 刷新工作目录,但是需要加范围. 代表所有文件
    1
    git restore .

撤销index的所有文件(基于HEAD)

  • git reset不添加文件名,撤销整个 index 的修改,默认是--mixed级别,默认基于 HEAD 进行刷新

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

  • git restore加额外选项才能基于HEAD刷新 index,需要加范围. 代表所有文件

    1
    git restore --staged .

撤销工作目录和index的所有文件(基于HEAD)

  • git reset--hard级别可以对工作目录和 index 的刷新

    1
    git reset --hard HEAD

  • git restore加额外选项才能基于HEAD刷新 index 和工作目录,需要加范围. 代表所有文件

    1
    git restore --staged --worktree .

修正上一次提交

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

1
2
3
git commit -m "last commit"
git add something_forgitten
git commit --amend

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

注意:这个操作只建议在本地进行,如果已经推送到了远程仓库,那么重新添加一个提交比修正更合适,因为会出现合并冲突。

回退上一次提交

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

1
git reset --soft HEAD^

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