章节 ▾ 第二版

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 之旅中的某个时刻,你可能会意外丢失提交。 通常,发生这种情况是因为你强制删除了一个包含工作的分支,但结果是你最终还是想要该分支;或者你硬重置了一个分支,从而放弃了你想要从中获得某些东西的提交。 假设发生这种情况,你如何找回你的提交?

这里有一个例子,它将你的测试仓库中的 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 的情况。每次你提交或更改分支时,reflog 都会更新。reflog 也会被 git update-ref 命令更新,这也是使用它而不是直接将 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 时,这可能是一个巨大的问题。因为你不会在这些系统中下载整个历史记录,所以这种类型的添加几乎没有后果。如果你从另一个系统导入或发现你的仓库比它应该的大小大得多,以下是如何找到并删除大型对象的方法。

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

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

$ 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。在上一次提交之前,你使用的空间更接近 2K —— 显然,从之前的提交中删除该文件并没有将其从你的历史记录中删除。每次有人克隆这个仓库时,他们都必须克隆所有 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 命令,你在强制执行特定的提交消息格式中简要地使用过。如果你将 --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 和 Git 在执行 filter-branch 时在 .git/refs/original 下添加的一组新的 refs 仍然包含这些引用,所以你必须删除它们,然后重新打包数据库。在你重新打包之前,你需要删除任何指向旧提交的内容。

$ 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 好多了。你可以从大小值看到,大的对象仍然存在于你的松散对象中,所以它并没有消失;但是它不会在 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