章节 ▾ 第二版

10.7 Git 内部原理 - 维护和数据恢复

维护和数据恢复

有时,您可能需要进行一些清理工作——使仓库更紧凑,清理导入的仓库,或恢复丢失的工作。本节将介绍其中一些场景。

维护

偶尔,Git 会自动运行一个名为“auto gc”的命令。大多数时候,这个命令什么也不做。但是,如果存在过多的孤立对象(不在 packfile 中的对象)或过多的 packfile,Git 就会启动一个完整的 git gc 命令。“gc”代表垃圾回收,该命令会执行多项操作:它会收集所有孤立对象并将它们放入 packfile 中,它会合并 packfile 成一个大的 packfile,并删除那些无法从任何提交中访问且已存在数月的对象。

您可以手动运行 auto gc,如下所示:

$ git gc --auto

同样,这通常什么也不做。您必须拥有大约 7,000 个孤立对象或超过 50 个 packfile,Git 才会启动真正的 gc 命令。您可以通过 gc.autogc.autopacklimit 配置设置分别修改这些限制。

gc 做的另一件事是将您的引用打包成一个单独的文件。假设您的仓库包含以下分支和标签:

$ find .git/refs -type f
.git/refs/heads/experiment
.git/refs/heads/master
.git/refs/tags/v1.0
.git/refs/tags/v1.1

如果您运行 git gc,您将不再在 refs 目录下看到这些文件。为了效率,Git 会将它们移动到一个名为 .git/packed-refs 的文件中,该文件看起来像这样:

$ cat .git/packed-refs
# pack-refs with: peeled fully-peeled
cac0cab538b970a37ea1e769cbbde608743bc96d refs/heads/experiment
ab1afef80fac8e34258ff41fc1b867c702daa24b refs/heads/master
cac0cab538b970a37ea1e769cbbde608743bc96d refs/tags/v1.0
9585191f37f7b0fb9444f35a9bf50de191beadc2 refs/tags/v1.1
^1a410efbd13591db07496601ebc7a059dd55cfe9

如果您更新一个引用,Git 不会编辑此文件,而是会写一个新文件到 refs/heads。为了获取给定引用的正确 SHA-1,Git 会先在 refs 目录中查找该引用,然后以 packed-refs 文件作为备用。因此,如果您在 refs 目录中找不到某个引用,它很可能在您的 packed-refs 文件中。

请注意文件中的最后一行,它以 ^ 开头。这表示上面的标签是一个带注释的标签,而这一行是带注释的标签所指向的提交。

数据恢复

在您的 Git 之旅的某个时刻,您可能会意外丢失一个提交。通常,这会发生在您强制删除一个包含工作的分支,但后来又发现您仍然需要该分支;或者您进行了硬重置(hard-reset)到一个分支,从而放弃了您想要从中获取内容的提交。假设这种情况发生了,您如何找回您的提交?

下面是一个示例,演示了如何在一个测试仓库中将 master 分支硬重置到一个旧提交,然后恢复丢失的提交。首先,让我们回顾一下此时您的仓库状态:

$ git log --pretty=oneline
ab1afef80fac8e34258ff41fc1b867c702daa24b Modify repo.rb a bit
484a59275031909e19aadb7c92262719cfcdf19a Create repo.rb
1a410efbd13591db07496601ebc7a059dd55cfe9 Third commit
cac0cab538b970a37ea1e769cbbde608743bc96d Second commit
fdf4fc3344e67ab068f836878b6c4951e3b15f3d First commit

现在,将 master 分支移回中间那个提交:

$ git reset --hard 1a410efbd13591db07496601ebc7a059dd55cfe9
HEAD is now at 1a410ef Third commit
$ git log --pretty=oneline
1a410efbd13591db07496601ebc7a059dd55cfe9 Third commit
cac0cab538b970a37ea1e769cbbde608743bc96d Second commit
fdf4fc3344e67ab068f836878b6c4951e3b15f3d First commit

您实际上已经丢失了顶部的两个提交——没有任何分支可以访问到这些提交。您需要找到最新的提交 SHA-1,然后添加一个指向它的分支。诀窍在于找到那个最新的提交 SHA-1——您不可能记住它,对吧?

通常,最快的方法是使用一个名为 git reflog 的工具。当您工作时,Git 会在每次更改 HEAD 时默默地记录下 HEAD 的位置。每次提交或切换分支时,reflog 都会更新。git update-ref 命令也会更新 reflog,这也是为什么建议使用它而不是直接将 SHA-1 值写入 ref 文件(如我们在 Git 引用 中所讲)。您可以通过运行 git reflog 查看您任何时候的活动记录:

$ git reflog
1a410ef HEAD@{0}: reset: moving to 1a410ef
ab1afef HEAD@{1}: commit: Modify repo.rb a bit
484a592 HEAD@{2}: commit: Create repo.rb

在这里,我们可以看到我们检出过的两个提交,但这里的信息不多。为了以更有用的方式查看相同的信息,我们可以运行 git log -g,它会为您提供 reflog 的正常日志输出。

$ git log -g
commit 1a410efbd13591db07496601ebc7a059dd55cfe9
Reflog: HEAD@{0} (Scott Chacon <schacon@gmail.com>)
Reflog message: updating HEAD
Author: Scott Chacon <schacon@gmail.com>
Date:   Fri May 22 18:22:37 2009 -0700

		Third commit

commit ab1afef80fac8e34258ff41fc1b867c702daa24b
Reflog: HEAD@{1} (Scott Chacon <schacon@gmail.com>)
Reflog message: updating HEAD
Author: Scott Chacon <schacon@gmail.com>
Date:   Fri May 22 18:15:24 2009 -0700

       Modify repo.rb a bit

看起来底部那个提交是您丢失的那个,因此您可以通过在该提交上创建一个新分支来恢复它。例如,您可以从该提交 (ab1afef) 开始创建一个名为 recover-branch 的分支:

$ git branch recover-branch ab1afef
$ git log --pretty=oneline recover-branch
ab1afef80fac8e34258ff41fc1b867c702daa24b Modify repo.rb a bit
484a59275031909e19aadb7c92262719cfcdf19a Create repo.rb
1a410efbd13591db07496601ebc7a059dd55cfe9 Third commit
cac0cab538b970a37ea1e769cbbde608743bc96d Second commit
fdf4fc3344e67ab068f836878b6c4951e3b15f3d First commit

很棒——现在您有一个名为 recover-branch 的分支,它指向您 master 分支曾经所在的位置,从而使前两个提交再次可访问。接下来,假设您的丢失由于某种原因不在 reflog 中——您可以通过删除 recover-branch 并删除 reflog 来模拟这种情况。现在前两个提交无法通过任何方式访问:

$ git branch -D recover-branch
$ rm -Rf .git/logs/

由于 reflog 数据保存在 .git/logs/ 目录中,您实际上没有 reflog。此时您如何恢复该提交?一种方法是使用 git fsck 工具,该工具会检查您的数据库的完整性。如果您使用 --full 选项运行它,它将显示所有未被其他对象指向的对象:

$ git fsck --full
Checking object directories: 100% (256/256), done.
Checking objects: 100% (18/18), done.
dangling blob d670460b4b4aece5915caf5c68d12f560a9fe3e4
dangling commit ab1afef80fac8e34258ff41fc1b867c702daa24b
dangling tree aea790b9a58f6cf6f2804eeac9f0abbe9631e4c9
dangling blob 7108f7ecb345ee9d0084193f147cdad4d2998293

在这种情况下,您可以在“dangling commit”字符串后看到您丢失的提交。您可以通过同样的方式恢复它,即添加一个指向该 SHA-1 的分支。

删除对象

Git 有很多优点,但一个可能引起问题的特性是 git clone 会下载项目的整个历史记录,包括每个文件的每个版本。如果整个项目都是源代码,这没关系,因为 Git 经过高度优化,可以有效地压缩这些数据。然而,如果您的项目历史中某处添加了一个巨大的文件,那么所有未来的克隆都将被迫下载这个大文件,即使它在下一个提交中就被从项目中删除了。因为它仍然可以从历史记录中访问到,所以它将永远存在。

当您将 Subversion 或 Perforce 仓库转换为 Git 时,这可能是一个巨大的问题。由于在那些系统中您不需要下载整个历史记录,这种添加几乎没有后果。如果您从另一个系统导入,或者发现您的仓库比预期的要大得多,以下是如何查找和删除大对象的说明。

警告:此技术会破坏您的提交历史。 它会重写自您需要修改的第一个树对象以来直到最新版本的所有提交对象,以移除大文件引用。如果您在导入后立即执行此操作,在任何人基于该提交开展工作之前,您就没事了——否则,您必须通知所有贡献者他们必须将他们的工作重新基础(rebase)到您的新提交上。

为了演示,您将在您的测试仓库中添加一个大文件,并在下一个提交中删除它,然后找到它并永久地从仓库中删除。首先,向您的历史记录中添加一个大对象:

$ curl -L https://linuxkernel.org.cn/pub/software/scm/git/git-2.1.0.tar.gz > git.tgz
$ git add git.tgz
$ git commit -m 'Add git tarball'
[master 7b30847] Add git tarball
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 git.tgz

糟糕——您不想将一个巨大的 tarball 添加到您的项目中。最好将其删除:

$ git rm git.tgz
rm 'git.tgz'
$ git commit -m 'Oops - remove large tarball'
[master dadf725] Oops - remove large tarball
 1 file changed, 0 insertions(+), 0 deletions(-)
 delete mode 100644 git.tgz

现在,gc 您的数据库并查看您使用了多少空间:

$ git gc
Counting objects: 17, done.
Delta compression using up to 8 threads.
Compressing objects: 100% (13/13), done.
Writing objects: 100% (17/17), done.
Total 17 (delta 1), reused 10 (delta 0)

您可以运行 count-objects 命令快速查看您使用了多少空间:

$ git count-objects -v
count: 7
size: 32
in-pack: 17
packs: 1
size-pack: 4868
prune-packable: 0
garbage: 0
size-garbage: 0

size-pack 条目是您的 packfile 的大小(以千字节为单位),所以您使用了近 5MB。在上一个提交之前,您使用的空间接近 2KB——很明显,从上一个提交中删除文件并没有将其从您的历史记录中移除。每次有人克隆此仓库时,他们都必须克隆全部 5MB 才能获得这个小型项目,因为您不小心添加了一个大文件。让我们将其删除。

首先您必须找到它。在这种情况下,您已经知道是哪个文件了。但假设您不知道;您将如何确定哪个文件或哪些文件占用了如此大的空间?如果您运行 git gc,所有对象都会在一个 packfile 中;您可以通过运行另一个名为 git verify-pack 的底层命令,并按输出的第三个字段(即文件大小)排序来识别大对象。您也可以通过 tail 命令管道化它,因为您只对最后几个最大的文件感兴趣:

$ git verify-pack -v .git/objects/pack/pack-29…69.idx \
  | sort -k 3 -n \
  | tail -3
dadf7258d699da2c8d89b09ef6670edb7d5f91b4 commit 229 159 12
033b4468fa6b2a9547a70d88d1bbe8bf3f9ed0d5 blob   22044 5792 4977696
82c99a3e86bb1267b236a4b6eff7868d97489af1 blob   4975916 4976258 1438

底部的大对象:5MB。要找出它是什么文件,您将使用 rev-list 命令,您在 强制特定的提交消息格式 中简要使用过它。如果您向 rev-list 传递 --objects,它将列出所有提交 SHA-1 以及 blob SHA-1 和与之关联的文件路径。您可以使用它来找到您的 blob 名称:

$ git rev-list --objects --all | grep 82c99a3
82c99a3e86bb1267b236a4b6eff7868d97489af1 git.tgz

现在,您需要从过去的所有树中删除此文件。您可以轻松地看到哪些提交修改了此文件:

$ git log --oneline --branches -- git.tgz
dadf725 Oops - remove large tarball
7b30847 Add git tarball

您必须重写 7b30847 之后的所有提交,才能将此文件完全从您的 Git 历史记录中删除。为此,您将使用 filter-branch,您在 重写历史记录 中使用过它:

$ git filter-branch --index-filter \
  'git rm --ignore-unmatch --cached git.tgz' -- 7b30847^..
Rewrite 7b30847d080183a1ab7d18fb202473b3096e9f34 (1/2)rm 'git.tgz'
Rewrite dadf7258d699da2c8d89b09ef6670edb7d5f91b4 (2/2)
Ref 'refs/heads/master' was rewritten

--index-filter 选项类似于我们在 重写历史记录 中使用的 --tree-filter 选项,不同之处在于,它不传递一个修改磁盘上签出文件的命令,而是每次修改您的暂存区或索引。

与其使用 rm file 这样的命令删除特定文件,不如使用 git rm --cached——您必须从索引中删除它,而不是从磁盘上删除。这样做是因为速度——Git 不需要先将每个版本检出到磁盘再运行您的过滤器,这样可以快得多。如果您愿意,也可以使用 --tree-filter 完成相同的任务。git rm--ignore-unmatch 选项告诉它,如果找不到要删除的模式,不要报错。最后,您要求 filter-branch 只从 7b30847 提交向上重写您的历史记录,因为您知道问题是从那里开始的。否则,它将从头开始,并且不必要地花费更长时间。

您的历史记录不再包含对该文件的引用。但是,您的 reflog 和 Git 在您执行 filter-branch 时在 .git/refs/original 下添加的新一组引用仍然包含它,所以您必须删除它们,然后重新打包数据库。在重新打包之前,您需要删除任何指向那些旧提交的引用:

$ rm -Rf .git/refs/original
$ rm -Rf .git/logs/
$ git gc
Counting objects: 15, done.
Delta compression using up to 8 threads.
Compressing objects: 100% (11/11), done.
Writing objects: 100% (15/15), done.
Total 15 (delta 1), reused 12 (delta 0)

让我们看看您节省了多少空间。

$ git count-objects -v
count: 11
size: 4904
in-pack: 15
packs: 1
size-pack: 8
prune-packable: 0
garbage: 0
size-garbage: 0

打包仓库的大小已降至 8KB,这比 5MB 好得多。从 size 值可以看出,大对象仍然在您的孤立对象中,所以它还没有消失;但它不会在推送或后续克隆时被传输,而这才是重要的。如果您真的想,可以通过运行 git prune--expire 选项来完全删除该对象:

$ git prune --expire now
$ git count-objects -v
count: 0
size: 0
in-pack: 15
packs: 1
size-pack: 8
prune-packable: 0
garbage: 0
size-garbage: 0