Git
章节 ▾ 第二版

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

维护与数据恢复

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

维护

Git偶尔会自动运行一个名为“auto gc”的命令。大多数情况下,此命令什么也不做。但是,如果存在太多松散对象(不在打包文件中的对象)或太多打包文件,Git将启动一个完整的git gc命令。“gc”代表垃圾回收,此命令执行许多操作:它收集所有松散对象并将它们放入打包文件,它将打包文件合并成一个大的打包文件,并删除无法从任何提交访问且已有几个月历史的对象。

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

$ git gc --auto

同样,这通常不会做任何事情。您必须拥有大约 7000 个松散对象或超过 50 个打包文件,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 旅程中,你可能会不小心丢失一个提交。通常,这是因为你强制删除了一个包含工作内容的分支,结果你又需要这个分支;或者你对一个分支进行了硬重置,从而放弃了你想要保留的提交。假设这种情况发生了,你如何找回你的提交呢?

这是一个硬重置测试仓库中 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 <[email protected]>)
Reflog message: updating HEAD
Author: Scott Chacon <[email protected]>
Date:   Fri May 22 18:22:37 2009 -0700

		Third commit

commit ab1afef80fac8e34258ff41fc1b867c702daa24b
Reflog: HEAD@{1} (Scott Chacon <[email protected]>)
Reflog message: updating HEAD
Author: Scott Chacon <[email protected]>
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 时,这可能是一个巨大的问题。因为在这些系统中你不会下载整个历史记录,所以这种类型的添加几乎没有后果。如果你从其他系统导入或以其他方式发现你的仓库比它应该大得多,以下是如何查找和删除大型对象的方法。

请注意:此技术会破坏你的提交历史记录。它会重写自你必须修改以删除大型文件引用的最早树以来的每个提交对象。如果你在导入后立即执行此操作,在任何人开始基于提交进行工作之前,你就可以安全地进行操作——否则,你必须通知所有贡献者,他们必须将他们的工作重新设定基础到你的新提交上。

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

$ curl -L https://www.kernel.org/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 条目是以千字节为单位的打包文件大小,所以你使用了近 5MB。在上次提交之前,你使用的是接近 2K——显然,从上一个提交中删除该文件并没有将其从你的历史记录中删除。每次有人克隆此仓库时,他们都必须克隆所有 5MB 才能获得这个小型项目,因为你意外地添加了一个大文件。让我们把它去掉。

首先你必须找到它。在这种情况下,你已经知道它是哪个文件。但假设你不知道;你将如何识别占用大量空间的文件?如果你运行 git gc,所有对象都位于一个打包文件中;你可以通过运行另一个管道命令 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 命令,你在 强制执行特定的提交消息格式 中简要使用过它。如果你将 --objects 传递给 rev-list,它将列出所有提交 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 和你在执行 filter-branch 时 Git 添加到 .git/refs/original 下的一组新的 ref 仍然包含它,所以你必须删除它们,然后重新打包数据库。在重新打包之前,你需要删除任何指向这些旧提交的指针。

$ 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

打包仓库大小降至 8K,这比 5MB 好得多。你可以从 size 值看到大型对象仍然存在于你的松散对象中,所以它并没有消失;但它不会在 push 或后续克隆中传输,这才是重要的。如果你真的想这样做,可以通过使用 --expire 选项运行 git prune 来完全删除该对象。

$ 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
scroll-to-top