维护与数据恢复

大约 11 分钟

# 维护

Git 会不定时地自动运行一个叫做 “auto gc” 的命令。 大多数时候,这个命令并不会产生效果。 然而,如果有太多松散对象(不在包文件中的对象)或者太多包文件,Git 会运行一个完整的 git gc 命令,我们可以通过修改 gc.autogc.autopacklimit 的设置来改动这些限制数值。

“gc” 代表垃圾回收,这个命令会做以下事情:收集所有松散对象并将它们放置到包文件中, 将多个包文件合并为一个大的包文件,移除与任何提交都不相关的陈旧对象。

首先,我们查看当前对象列表,有一堆松散数据对象:

$ find .git/objects -type f
.git/objects/54/68f3283bfc7fd3ae3cf17e89210b744d106e48
.git/objects/af/f46a8086171b70b0146a1b44e6380ba242c022
.git/objects/cd/32d11c26f2909e3204e327bbd2685a87baf660
.git/objects/ce/e1c35858358f7438920ae1b7f7d86f5cc2119d
.git/objects/e5/32abd82431e55f3f03a2971b1af88002a26810
.git/objects/f3/858d435146e27364574fa97936b45bec65663e
.git/objects/f5/abd87faf2abc34e7d1ac18e7e020f7d4e1c547

然后我们执行 git gc 命令:

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

再次查看对象列表,发现内容已打包:

$ find .git/objects -type f
.git/objects/54/68f3283bfc7fd3ae3cf17e89210b744d106e48
.git/objects/info/packs
.git/objects/pack/pack-5035b9fb775a699fecee6ac3c3df69734a095758.idx
.git/objects/pack/pack-5035b9fb775a699fecee6ac3c3df69734a095758.pack

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
e201e95d4814f8c5c7522755ae6151b298b9ea4a (HEAD -> master) update submit.sh
421f3b6e9f05a97f05b845cb0905c44ed107acaf delete patch,add new file
24de949bb7c23ff052d5b39d01d809e86dd43fd2 git apply
ea561d5fded583caca9a81d8cdefede2f77240b2 (origin/featureA) insert
d3c8035e27d3efdb4333867757f34c1ba966a852 git merge
ca671b5cd1739aa6aa887e5ef9d0a04bcbd65a05 init

现在,我们将 master 分支硬重置到第三次提交:

$ git reset --hard 24de949bb7c23ff052d5b39d01d809e86dd43fd2
HEAD is now at 24de949 git apply
$ git log --pretty=oneline
24de949bb7c23ff052d5b39d01d809e86dd43fd2 git apply
ea561d5fded583caca9a81d8cdefede2f77240b2 (origin/featureA) insert
d3c8035e27d3efdb4333867757f34c1ba966a852 git merge
ca671b5cd1739aa6aa887e5ef9d0a04bcbd65a05 init

现在顶部的两个提交已经丢失了 —— 没有分支指向这些提交。你需要找出最后一次提交的 SHA-1 然后增加一个指向它的分支。

最方便,也是最常用的方法,是使用一个名叫 git reflog 的工具。当你正在工作时,Git 会默默地记录每一次你改变 HEAD 时它的值。每一次你提交或改变分支,引用日志都会被更新。引用日志(reflog)也可以通过 git update-ref 命令更新。你可以在任何时候通过执行 git reflog 命令来了解你曾经做过什么:

$ git reflog
24de949 HEAD@{0}: reset: moving to 24de949bb7c23ff052d5b39d01d809e86dd43fd2
e201e95 HEAD@{1}: commit: update submit.sh
421f3b6 HEAD@{2}: commit: delete patch,add new file
24de949 HEAD@{3}: clone: from https://gitee.com/willcoder/fake-project.git

这里可以看到我们已经检出的两次提交,然而并没有足够多的信息。为了使显示的信息更加有用,我们可以执行 git log -g,这个命令会以标准日志的格式输出引用日志。

$ git log -g
commit 24de949bb7c23ff052d5b39d01d809e86dd43fd2
Reflog: HEAD@{0} (will <willcoder@example.com>)
Reflog message: reset: moving to 24de949bb7c23ff052d5b39d01d809e86dd43fd2
Author: will <willcoder@example.com>
Date:   Mon Aug 24 23:09:28 2020 +0800
    git apply
commit e201e95d4814f8c5c7522755ae6151b298b9ea4a
Reflog: HEAD@{1} (will <willcoder@example.com>)
Reflog message: commit: update submit.sh
Author: will <willcoder@example.com>
Date:   Thu Sep 3 00:11:06 2020 +0800
    update submit.sh
commit 421f3b6e9f05a97f05b845cb0905c44ed107acaf
Reflog: HEAD@{2} (will <willcoder@example.com>)
Reflog message: commit: delete patch,add new file
Author: will <willcoder@example.com>
Date:   Thu Sep 3 00:09:18 2020 +0800
    delete patch,add new file
commit 24de949bb7c23ff052d5b39d01d809e86dd43fd2 
Reflog: HEAD@{3} (will <willcoder@example.com>)
Reflog message: clone: from https://gitee.com/willcoder/fake-project.git
Author: will <willcoder@example.com>
Date:   Mon Aug 24 23:09:28 2020 +0800
    git apply

看起来下面的那个就是你丢失的提交,你可以通过创建一个新的分支指向这个提交来恢复它。例如,你可以创建一个名为 recover-branch 的分支指向这个提交(e201e9):

$ git branch recover-branch e201e9
$ git log --pretty=oneline recover-branch
e201e95d4814f8c5c7522755ae6151b298b9ea4a (recover-branch) update submit.sh
421f3b6e9f05a97f05b845cb0905c44ed107acaf delete patch,add new file
24de949bb7c23ff052d5b39d01d809e86dd43fd2 git apply
ea561d5fded583caca9a81d8cdefede2f77240b2 (origin/featureA) insert
d3c8035e27d3efdb4333867757f34c1ba966a852 git merge
ca671b5cd1739aa6aa887e5ef9d0a04bcbd65a05 init

不错,现在有一个名为 recover-branch 的分支是你的 master 分支曾经指向的地方,再一次使得前两次提交可到达了。接下来,假设你丢失的提交因为某些原因不在引用日志中,那么我们可以通过移除 recover-branch 分支并删除引用日志来模拟这种情况。现在前两次提交又不被任何分支指向了:

# 删除分支
$ git branch -D recover-branch
Deleted branch recover-branch (was e201e95).

# 删除日志
$ rm -Rf .git/logs/

# 使用 git fsck --full 获取丢失的提交 SHA-1
$ git fsck --full
Checking object directories: 100% (256/256), done.
Checking objects: 100% (25/25), done.
dangling commit e201e95d4814f8c5c7522755ae6151b298b9ea4a

在这个例子中,你可以在 “dangling commit” 后看到你丢失的提交。

# 移除对象

git clone 会下载整个项目的历史,包括每一个文件的每一个版本。如果所有的东西都是源代码那么这很好,因为 Git 被高度优化来有效地存储这种数据。如果某个人在之前向项目添加了一个大小特别大的文件,即使你将这个文件从项目中移除了,每次克隆还是都要强制的下载这个大文件。之所以会产生这个问题,是因为这个文件在历史中是存在的,它会永远在那里。

当你迁移 Subversion 或 Perforce 仓库到 Git 的时候,这会是一个严重的问题。因为这些版本控制系统并不下载所有的历史文件,所以这种文件所带来的问题比较少。如果你从其他的版本控制系统迁移到 Git 时发现仓库比预期的大得多,那么你就需要找到并移除这些大文件。

警告:这个操作对提交历史的修改是破坏性的。 它会从你必须修改或移除一个大文件引用最早的树对象开始重写每一次提交。

为了演示,我们将添加一个大文件到测试仓库中,并在下一次提交中删除它,现在我们需要找到它,并将它从仓库中永久删除。首先,添加一个大文件到仓库中:

$ curl 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

哎呀 —— 其实这个项目并不需要这个巨大的压缩文件。现在我们将它移除:

$ git rm git.tgz
rm 'git.tgz'
$ git commit -m 'oops - removed large tarball'
[master dadf725] oops - removed 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 的数值指的是你的包文件以 KB 为单位计算的大小,所以你大约占用了 5MB 的空间。在最后一次提交前,使用了不到 2KB —— 显然,从之前的提交中移除文件并不能从历史中移除它。每一次有人克隆这个仓库时,他们将必须克隆所有的 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、数据对象的 SHA-1 和与它们相关联的文件路径。可以使用以下命令来找出你的数据对象的名字:

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

现在,你只需要从过去所有的树中移除这个文件。使用以下命令可以轻松地查看哪些提交对这个文件产生改动:

$ git log --oneline --branches -- git.tgz
dadf725 oops - removed 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 选项,不过这个选项并不会让命令将修改在硬盘上检出的文件,而只是修改在暂存区或索引中的文件。

你必须使用 git rm --cached 命令来移除文件,而不是通过类似 rm file 的命令 —— 因为你需要从索引中移除它,而不是磁盘中。还有一个原因是速度 —— Git 在运行过滤器时,并不会检出每个修订版本到磁盘中,所以这个过程会非常快。如果愿意的话,你也可以通过 --tree-filter 选项来完成同样的任务。git rm 命令的 --ignore-unmatch 选项告诉命令:如果尝试删除的模式不存在时,不提示错误。最后,使用 filter-branch 选项来重写自 7b30847 提交以来的历史,也就是这个问题产生的地方。否则,这个命令会从最旧的提交开始,这将会花费许多不必要的时间。

你的历史中将不再包含对那个文件的引用。不过,你的引用日志和你在 .git/refs/original 通过 filter-branch 选项添加的新引用中还存有对这个文件的引用,所以你必须移除它们然后重新打包数据库。在重新打包前需要移除任何包含指向那些旧提交的指针的文件:

$ 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 的值看出,这个大文件还在你的松散对象中,并没有消失;但是它不会在推送或接下来的克隆中出现,这才是最重要的。如果真的想要删除它,可以通过有 --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
上次编辑于: 2024年5月11日 01:53