为什么我的git仓库这么大?

159

145M = .git/objects/pack/

我编写了一个脚本,从每个分支的末尾向后退,将每个提交及其前一个提交之间的差异大小相加。我得到了129MB的结果,这是未经压缩的,并且没有考虑跨分支相同文件和分支之间的共同历史。

Git会考虑所有这些因素,因此我期望存储库要小得多。那么为什么.git这么大呢?

我已经执行了以下操作:

git fsck --full
git gc --prune=today --aggressive
git repack

关于有多少个文件/提交,我有19个分支,每个分支大约有40个文件。共287次提交,可使用以下命令查找:

git log --oneline --all|wc -l

存储这些信息不应该占用数十兆字节的空间。


6
Linus建议使用这种方法代替过于激进的垃圾回收。这样做会产生明显的差异吗?git repack -a -d --depth=250 --window=250 - Greg Bacon
谢谢gbacon,但没有区别。 - Ian Kelling
2
这是因为你缺少了-f选项。http://metalinguist.wordpress.com/2007/12/06/the-woes-of-git-gc-aggressive-and-how-git-deltas-work/ - spuder
1
git repack -a -d 将我的 956MB 仓库压缩至 250MB。非常成功!谢谢! - AlexGrafe
我发现的一个警告是,如果你有git子模块,则子模块的.git存储库会显示在超级模块的.git目录中,因此du可能会误导您认为超级模块很大,而实际上是一个子模块,下面的答案需要在子模块目录中运行。 - esmit
14个回答

184

我使用的一些脚本:

git-fatfiles

git rev-list --all --objects | \
    sed -n $(git rev-list --objects --all | \
    cut -f1 -d' ' | \
    git cat-file --batch-check | \
    grep blob | \
    sort -n -k 3 | \
    tail -n40 | \
    while read hash type size; do 
         echo -n "-e s/$hash/$size/p ";
    done) | \
    sort -n -k1
...
89076 images/screenshots/properties.png
103472 images/screenshots/signals.png
9434202 video/parasite-intro.avi

如果需要更多代码行,请查看附近答案中的Perl版本:https://dev59.com/nXNA5IYBdhLWcg3wQ7i4#45366030

git-eradicate(针对video/parasite.avi):

git filter-branch -f  --index-filter \
    'git rm --force --cached --ignore-unmatch video/parasite-intro.avi' \
     -- --all
rm -Rf .git/refs/original && \
    git reflog expire --expire=now --all && \
    git gc --aggressive && \
    git prune

注意:第二个脚本旨在完全从Git中删除信息(包括来自引用日志的所有信息)。请谨慎使用。


3
最终...具有讽刺意味的是,在我的搜索中我早就看到了这个答案,但它看起来太复杂了...在尝试其他方法之后,这个答案开始变得有意义,然后就完成了! - msanteler
@msanteler,之前的(git-fatfiles)脚本是在我在IRC(Freenode/#git)上提问时出现的。我将最好的版本保存到了一个文件中,然后在这里发布了答案。(虽然我在IRC日志中找不到原始作者)。 - Vi.
这最初运行得非常好。但是当我再次从远程获取或拉取时,它只是将所有大文件复制回档案。我该如何防止这种情况发生? - pir
1
@felbo,那么问题可能不仅存在于您的本地存储库中,而且还存在于其他存储库中。也许您需要在所有地方执行该过程,或者强制每个人放弃原始分支并切换到重写分支。这对于大团队来说并不容易,并且需要开发人员之间和/或经理干预的合作。有时候,只是将磁石留在里面可能是更好的选择。 - Vi.
1
这个函数很好,但是它的速度慢得难以想象。即使我删除了40行限制,它也无法在我的电脑上完成。顺便说一下,我刚刚添加了一个更高效的版本的答案。如果你想在大型代码库上使用这个逻辑,或者想看到每个文件或每个文件夹的大小总和,请查看它。 - piojo
显示剩余7条评论

72

我最近将错误的远程存储库拉到了本地存储库中(使用git remote add...git remote update)。尽管删除了不需要的远程引用、分支和标签,但仍有1.4GB的浪费空间。只有通过使用git clone file:///path/to/repository来克隆存储库才能摆脱这种情况。注意,在克隆本地存储库时,file://的作用非常重要-只有被引用的对象被复制,而不是整个目录结构。

编辑: 这是Ian的一行代码,在新存储库中重新创建所有分支:

d1=#original repo
d2=#new repo (must already exist)
cd $d1
for b in $(git branch | cut -c 3-)
do
    git checkout $b
    x=$(git rev-parse HEAD)
    cd $d2
    git checkout -b $b $x
    cd $d1
done

1
哇,谢谢。.git现在是15M了!克隆后,这里有一个小技巧可以保留你之前的分支。d1=#原始仓库;d2=#新仓库;cd $d1;for b in $(git branch | cut -c 3-); do git checkout $b;x=$(git rev-parse HEAD);cd $d2;git checkout -b $b $x;cd $d1;done - Ian Kelling
3
我愚蠢地将大量视频文件添加到我的代码库中,不得不执行“reset --soft HEAD ^”并重新提交。这之后“.git/objects”目录非常庞大,这是唯一的方法。但是,我不喜欢单行代码更改了分支名称(显示了origin/branchname而不仅仅是branchname)。所以我进一步进行了一些冒险操作——我从克隆库中删除了原始.git/objects目录,并将其替换为克隆库里的目录。这样做解决了问题,保持所有原有的分支、引用等完好无损,现在看起来一切都正常(祈祷)。 - Jack Senechal
1
谢谢你提供的关于 file:// 克隆的提示,这对我很有帮助。 - adam.wulf
3
如果你将一个硬连接链接到一个文件,并删除原始文件,除了引用计数从2减少到1之外,什么也不会发生。只有当该计数器减少为0时,该空间才会被释放以供文件系统上的其他文件使用。因此,即使这些文件是硬链接的,如果原始文件被删除,也不会发生任何事情。 - stefreak
哦,天啊!不确定为什么这个有效,但这太棒了。 - Dennis
显示剩余3条评论

69

git gc已经执行了git repack,所以手动重新打包没有意义,除非你将传递一些特殊选项。

第一步是查看大部分空间是否(通常情况下)用于对象数据库。

git count-objects -v

这应该会提供一个报告,显示你的代码库中有多少未打包的对象、它们占用了多少空间、你有多少个打包文件以及它们占用了多少空间。

理想情况下,在重新打包后,你将不再有未打包的对象,只剩下一个打包文件,但仍然存在一些未被当前分支直接引用的对象,它们仍然存在且未被打包。

如果你有一个单独的大型打包文件,并且你想知道是什么占用了空间,那么你可以列出组成该打包文件的对象以及它们的存储方式。

git verify-pack -v .git/objects/pack/pack-*.idx

注意,verify-pack接受索引文件而不是打包文件本身。这将提供有关包中每个对象的报告,其真实大小和压缩大小以及有关它是否已被“delta化”以及如果是的话,增量链的起源的信息。

要查看存储库中是否有任何异常大的对象,您可以在第三或第四列上按数字顺序对输出进行排序(例如,| sort -k3n)。

从此输出中,您将能够使用git show命令查看任何对象的内容,尽管无法精确查看对象在存储库的提交历史记录中的位置。如果您需要执行此操作,请尝试此问题的某些内容。


1
这个发现了大对象很好。被接受的答案摆脱了它们。 - Ian Kelling
2
根据Linus Torvalds的说法,git gc和git repack之间的区别。http://metalinguist.wordpress.com/2007/12/06/the-woes-of-git-gc-aggressive-and-how-git-deltas-work/ - spuder

42

顺带一提,你可能保留不想要的对象的最大原因是git维护了reflog。

当你意外删除主分支或以某种方式灾难性地损坏存储库时,reflog就在那里为你解围。

修复这个问题最简单的方法是在压缩之前截断reflogs(只需确保您永远不想返回reflog中的任何提交)。

git gc --prune=now --aggressive
git repack

这与git gc --prune=today不同,因为它会立即过期整个引用日志。


2
这个对我很有用!我的文件从大约5GB压缩到了32MB。 - Hawkee
这个答案看起来很简单,但不幸的是对我没有用。在我的情况下,我正在处理一个刚克隆的存储库。那是原因吗? - Mert

18
如果您想查找占用git存储库空间的文件,可以运行以下命令:
``` git verify-pack -v .git/objects/pack/*.idx | sort -k 3 -n | tail -5 ```
然后提取占用最多空间的blob引用(最后一行),并检查占用这么多空间的文件名。
``` git rev-list --objects --all | grep ```
这甚至可能是您使用 `git rm` 删除的文件,但由于标签、远程和reflog等仍然存在对它的引用,因此git会记住它。
一旦您知道要删除的文件,建议使用 `git forget-blob`。
参考链接:https://ownyourbits.com/2017/01/18/completely-remove-a-file-from-a-git-repository-with-git-forget-blob/ 使用起来很简单,只需执行以下命令:
``` git forget-blob file-to-forget ```
这将从git中删除每个引用,从历史的每个提交中删除blob,并运行垃圾回收以释放空间。

8

如果您想查看所有blob的大小,Vi的答案中的git-fatfiles脚本非常好用,但它太慢了,无法使用。我删除了40行输出限制,然后它试图使用我计算机的所有RAM而不是完成任务。此外,当对输出求和以查看文件使用的所有空间时,它会给出不准确的结果。

我用Rust重写了它,我发现这比其他语言更少出错。如果传递了--directories标志,则我还添加了将各个目录中所有提交使用的空间总和起来的功能。可以提供路径以限制搜索某些文件或目录。

src/main.rs:

use std::{
    collections::HashMap,
    io::{self, BufRead, BufReader, Write},
    path::{Path, PathBuf},
    process::{Command, Stdio},
    thread,
};

use bytesize::ByteSize;
use structopt::StructOpt;

#[derive(Debug, StructOpt)]
#[structopt()]
pub struct Opt {
    #[structopt(
        short,
        long,
        help("Show the size of directories based on files committed in them.")
    )]
    pub directories: bool,

    #[structopt(help("Optional: only show the size info about certain paths."))]
    pub paths: Vec<String>,
}

/// The paths list is a filter. If empty, there is no filtering.
/// Returns a map of object ID -> filename.
fn get_revs_for_paths(paths: Vec<String>) -> HashMap<String, PathBuf> {
    let mut process = Command::new("git");
    let mut process = process.arg("rev-list").arg("--all").arg("--objects");

    if !paths.is_empty() {
        process = process.arg("--").args(paths);
    };

    let output = process
        .output()
        .expect("Failed to execute command git rev-list.");

    let mut id_map = HashMap::new();
    for line in io::Cursor::new(output.stdout).lines() {
        if let Some((k, v)) = line
            .expect("Failed to get line from git command output.")
            .split_once(' ')
        {
            id_map.insert(k.to_owned(), PathBuf::from(v));
        }
    }
    id_map
}

/// Returns a map of object ID to size.
fn get_sizes_of_objects(ids: Vec<&String>) -> HashMap<String, u64> {
    let mut process = Command::new("git")
        .arg("cat-file")
        .arg("--batch-check=%(objectname) %(objecttype) %(objectsize:disk)")
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .spawn()
        .expect("Failed to execute command git cat-file.");
    let mut stdin = process.stdin.expect("Could not open child stdin.");

    let ids: Vec<String> = ids.into_iter().cloned().collect(); // copy data for thread

    // Stdin will block when the output buffer gets full, so it needs to be written
    // in a thread:
    let write_thread = thread::spawn(|| {
        for obj_id in ids {
            writeln!(stdin, "{}", obj_id).expect("Could not write to child stdin");
        }
        drop(stdin);
    });

    let output = process
        .stdout
        .take()
        .expect("Could not get output of command git cat-file.");

    let mut id_map = HashMap::new();
    for line in BufReader::new(output).lines() {
        let line = line.expect("Failed to get line from git command output.");

        let line_split: Vec<&str> = line.split(' ').collect();

        // skip non-blob objects
        if let [id, "blob", size] = &line_split[..] {
            id_map.insert(
                id.to_string(),
                size.parse::<u64>().expect("Could not convert size to int."),
            );
        };
    }
    write_thread.join().unwrap();
    id_map
}

fn main() {
    let opt = Opt::from_args();

    let revs = get_revs_for_paths(opt.paths);
    let sizes = get_sizes_of_objects(revs.keys().collect());

    // This skips directories (they have no size mapping).
    // Filename -> size mapping tuples. Files are present in the list more than once.
    let file_sizes: Vec<(&Path, u64)> = sizes
        .iter()
        .map(|(id, size)| (revs[id].as_path(), *size))
        .collect();

    // (Filename, size) tuples.
    let mut file_size_sums: HashMap<&Path, u64> = HashMap::new();
    for (mut path, size) in file_sizes.into_iter() {
        if opt.directories {
            // For file path "foo/bar", add these bytes to path "foo/"
            let parent = path.parent();
            path = match parent {
                Some(parent) => parent,
                _ => {
                    eprint!("File has no parent directory: {}", path.display());
                    continue;
                }
            };
        }

        *(file_size_sums.entry(path).or_default()) += size;
    }
    let sizes: Vec<(&Path, u64)> = file_size_sums.into_iter().collect();

    print_sizes(sizes);
}

fn print_sizes(mut sizes: Vec<(&Path, u64)>) {
    sizes.sort_by_key(|(_path, size)| *size);
    for file_size in sizes.iter() {
        // The size needs some padding--a long size is as long as a tabstop
        println!("{:10}{}", ByteSize(file_size.1), file_size.0.display())
    }
}

Cargo.toml:

[package]
name = "git-fatfiles"
version = "0.1.0"
edition = "2018"
[dependencies]
structopt = { version = "0.3"}
bytesize = {version = "1"}

选项:

USAGE:
    git-fatfiles [FLAGS] [paths]...

FLAGS:
    -d, --directories    Show the size of directories based on files committed in them.
    -h, --help           Prints help information

ARGS:
    <paths>...    Optional: only show the size info about certain paths.

提醒一下,这个程序无法正确处理带有空格的路径。你可以在这里看到我的修复:https://github.com/truist/settings/commit/8b7ead712cbe3d7447031466f0adf111fc27a66b - Nathan Arthur
@NathanArthur感谢您的信息!我刚刚用Rust重写了脚本,并链接到了您的GitHub作为原始版本。如果您不希望在回答中链接到它,请告诉我。在工作时,我还注意到%fileSizes不应该是哈希,因为文件名在数据中出现了多次。 Rust版本已经修复,但是当文件在数据中出现多次时,我不确定Perl版本的语义应该是什么。我使“--sum”不可选,这样可以澄清语义。 - piojo
我花了一些时间研究%fileSizes的问题,我不认为它是错误的。你的新实现(以及旧实现中的--sum)将告诉文件在其历史中使用的累计大小。但这可能会掩盖历史中可能存在的巨大文件;一个经常更改的小文件可能具有巨大的累计大小。两个版本都很有用。在我的本地示例中,最糟糕的单个文件是第20大文件(累计),其他19个文件只是具有大量更改的源文件。 - Nathan Arthur
(另外,值得一提的是,Perl脚本比Rust脚本更容易复制和运行。我不得不安装Rust并学习许多关于Rust包管理的知识才能运行它。) - Nathan Arthur
在这段代码的原始版本中(包括你的版本),脚本每次运行都会给出不同的结果。对于%fileSizes使用哈希似乎只有在每次迭代进行比较并且仅在新大小更大时更新才可以。对于安装 Rust 给您带来的不便,我感到非常抱歉。这是我选择的权衡,以减少错误并提高可读性。至少它比 dotnet 或 java 项目设置更容易。我将使项目文件命名更加明确。 - piojo
我开始调查这个问题,结果发现了一堆错误。你说的 %fileSizes 是正确的,但它会完全破坏 --sum(和 --directories)。我从头开始重写了脚本,并在 一个新答案 中描述了我的发现。新脚本与旧脚本位于同一 URL。 - Nathan Arthur

4

您确定您只计算了.pack文件而没有计算.id文件吗?它们位于与.pack文件相同的目录中,但不包含任何存储库数据(因为扩展名指示它们仅仅是对应pack文件的索引 - 实际上,如果您知道正确的命令,可以轻松从pack文件重新创建它们,当克隆时,Git自己也会这样做,因为只有一个pack文件使用本地Git协议传输)。

作为代表性样本,我查看了我的linux-2.6存储库的本地克隆。

$ du -c *.pack
505888  total

$ du -c *.idx
34300   total

这表明大约应该普遍存在约7%的扩展。

还有一些位于objects/之外的文件;根据我的个人经验,其中indexgitk.cache往往是最大的文件(在我克隆的linux-2.6仓库中总共为11M)。


3

存储在.git中的其他git对象包括树、提交和标签。提交和标签很小,但如果您的仓库中有大量小文件,则树可能会变得很大。您有多少个文件和多少个提交?


好问题。每个分支大约有40个文件,共有19个分支。git count-objects -v显示“in-pack:1570”。不确定确切含义或如何计算我的提交数量。我猜可能有几百个提交。 - Ian Kelling
好的,那么这似乎不是答案。与 145MB 相比,几百个微不足道。 - Greg Hewgill

2

好问题。我也这样做了,我还有印象 git gc 也会这样做? - Ian Kelling
它使用 git gc --auto 进行操作,不确定你使用了什么。 - baudtack

2
在执行 git filter-branch 和 git gc 命令之前,您需要查看存储库中存在的标签。任何具有自动标记功能(如持续集成和部署)的真实系统都会使不需要的对象仍然被这些标签引用,因此 gc 无法将其删除,您仍然会想知道为什么存储库的大小如此之大。
摆脱所有不需要的内容的最佳方法是运行 git-filter 和 git gc,然后将主分支推送到一个新的裸存储库。新的裸存储库将拥有清理过的树。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接