Reduce Git Repository Size with git-filter-repo

Deepak Prasad
Tested on Red Hat Enterprise Linux 10.2 (Coughlan)
Package git 2.52.0
git-filter-repo 2.47.0
Applies to Linux, macOS, and Windows with Git and git-filter-repo; GitHub, GitLab, Bitbucket, or self-hosted remotes
Privilege Read or clone access for analysis; push access to the remote when you publish rewritten history
Scope Shrink a bloated .git folder by removing unwanted paths with git-filter-repo or replacing old ancestry with a baseline plus recent commits; does not cover whole-repository migration or a full Git LFS migration guide
Related guides Git tutorial
git remove file from tracking
Short on time?Beta Get the gist of this 16-minute read in seconds, then ask follow-up questions

Measure a bloated Git repository, find the large blobs still stored in history, and remove them with git-filter-repo on a disposable clone. When individual paths are not worth preserving, you can keep a recent commit window instead. Verify the smaller .git folder locally before you decide whether to publish rewritten history. To move selected paths into another repository while keeping their history, use move content from one Git repo to another instead of --invert-paths stripping.

A large working tree and a large Git history are different problems. Deleting a large file from the current version does not remove copies stored in older commits, so the repository can remain large and clones can stay slow. Replace REPO_URL and paths such as backups/ or path/to/large-file.zip with your own remote and file names.


Quick answer: why is my Git repository so large?

Problem What to do
Large blobs are still in history git-filter-repo
Unreachable objects use disk space git gc
Large binaries must remain in the project Git LFS or object storage
You do not know what consumes space git filter-repo --analyze
Keep only recent commits Create a baseline and replay commits after a cutoff
Only the working tree is large Remove or move current files; a history rewrite may not help
A secret or credential was committed Revoke first, then clean history

This tutorial focuses on the common case: large unwanted files remain in Git history and make clones and the .git object database larger than necessary.

Use this flow when you are not sure whether history rewriting is required:

┌──────────────────────────────┐
│ Git repository is too large  │
└──────────────┬───────────────┘
               ▼
┌────────────────────────────────────────┐
│ Measure .git and run --analyze first   │
└───────────────────┬────────────────────┘
                    ▼
┌────────────────────────────────────────┐
│ Are the large objects still reachable? │
└────────────┬───────────────────┬───────┘
           No│                   │Yes
             ▼                   ▼
┌─────────────────────┐  ┌─────────────────────────────┐
│ Repack with git gc  │  │ Must the large files stay?  │
└─────────────────────┘  └──────────┬───────────┬──────┘
                                 Yes│           │No
                                    ▼           ▼
                       ┌──────────────────┐  ┌──────────────────────────┐
                       │ Git LFS or object│  │ Know the unwanted paths? │
                       │ storage          │  └──┬────────────────┬──────┘
                       └──────────────────┘  Yes│                │No
                                                ▼                ▼
                                      ┌─────────────────┐  ┌───────────────────────┐
                                      │ git-filter-repo │  │ Keep recent commits?  │
                                      │ --invert-paths  │  └───┬─────────────┬─────┘
                                      └─────────────────┘   Yes│             │No
                                                               ▼             ▼
                                                   ┌────────────────────┐  ┌─────────────────┐
                                                   │ Baseline + recent  │  │ Current snapshot│
                                                   │ commit window      │  │ only            │
                                                   └────────────────────┘  └─────────────────┘

Check how large the repository is

Change into your repository, then measure the on-disk size of the object database:

bash
du -sh .git

The df and du command reports human-readable totals for directories such as .git.

output
116M    .git

Check how many Git objects are stored and how much space the packed objects use:

bash
git count-objects -vH
output
count: 0
size: 0 bytes
in-pack: 32
packs: 1
size-pack: 115.04 MiB
prune-packable: 0
garbage: 0
size-garbage: 0 bytes

du -sh .git shows filesystem usage. git count-objects -vH reports object counts and packed size inside Git. Local .git size is not always identical to the storage number shown by GitHub or GitLab.


Will git gc reduce the repository size?

git gc reclaims unreachable objects and improves packing. It cannot delete a large file that is still reachable from a branch or tag.

If large unwanted blobs are still referenced by old commits, rewrite history. If you already made objects unreachable, git gc may reclaim local disk space.

To show why git gc is not enough when large files are still reachable, run a normal garbage collection and measure again:

bash
git gc

Measure .git again after garbage collection:

bash
du -sh .git

In a test where .git was about 116M before git gc, it stayed at 116M because every large blob was still reachable. Repacking cannot remove reachable history. Do not treat git gc --aggressive or immediate pruning with --prune=now as the first fix for historical bloat. See the git prune command if you need to understand when immediate pruning is appropriate.


Find what is making the Git repository large

This cleanup uses git-filter-repo. Check that it is installed:

bash
git filter-repo --version

If the command is missing, install it. On RHEL and Fedora, use dnf with sudo:

bash
sudo dnf install git-filter-repo

On Debian and Ubuntu, use apt:

bash
sudo apt update && sudo apt install git-filter-repo

On macOS with Homebrew:

bash
brew install git-filter-repo

Run analysis on a fresh clone of REPO_URL. Analysis does not rewrite history:

bash
git clone REPO_URL repo-cleanup

Change into the new clone before you analyze it:

bash
cd repo-cleanup

Run analysis without rewriting history:

bash
git filter-repo --analyze

Git writes reports under .git/filter-repo/analysis/. Useful files include:

  • blob-shas-and-paths.txt — blob IDs, unpacked/packed sizes, and paths associated with each blob
  • path-all-sizes.txt — paths ranked by accumulated size
  • directories-all-sizes.txt and extensions-all-sizes.txt — directory and extension totals
  • renames.txt — rename hints when a blob appears under multiple paths

A trimmed excerpt from blob-shas-and-paths.txt might look like this:

text
  64c2e34f...   31457280   31466895 builds/release.iso
  9765d214...   26214400   26222415 backups/db-dump-v1.sql
  29d136f5...   26214400   26222415 backups/db-dump-v2.sql
  ee18d9fa...   20971520   20977935 [artifacts/archive.zip, builds/vendor-bundle.zip]
  c02091db...   15728640   15733455 builds/application.bin

The numeric columns show unpacked and packed size in bytes; the final column shows the path or paths associated with the blob.

Typical offenders are database dumps, ZIP archives, ISO images, generated binaries, and vendor artifacts committed by mistake.


Back up and coordinate before rewriting history

Rewriting history changes the commit IDs in the cleaned repository. Branches, tags, and commit or tag signatures can also be affected. After the rewrite is published, the safest option for collaborators is to delete their old clone and clone the repository again. Advanced users can rebase or reset existing work carefully, but they must not merge the old history into the rewritten history.

Work on a fresh disposable clone, not your everyday working copy. Keep a mirror backup until the cleanup is verified:

bash
git clone --mirror REPO_URL repo-backup.git
WARNING
Do not run destructive filtering in the repository you use every day. If git filter-repo refuses to run, that usually means the clone is not fresh enough. Make a new clone instead of reaching for --force unless you understand the risk.

Keep only recent Git history

Path filtering works when you know which unwanted files or directories caused the bloat. If you no longer need any old commit-by-commit history, you can instead keep a recent time window. The cleaned branch starts with one baseline snapshot, followed by the ordinary commits made after your cutoff date.

This method removes every older version, including deleted files whose old paths you may not know. It also keeps the first recent commit readable as a normal diff. Making the first recent commit a root commit would preserve the final files, but that commit would appear to add the entire repository.

Run these steps only in the disposable clone. Record the current branch tree before changing any commits:

bash
ORIGINAL_TREE=$(git rev-parse main^{tree})

Choose the cutoff in ISO 8601 format. Include the offset so the boundary does not depend on the machine's local timezone:

bash
CUTOFF='2026-01-01T00:00:00+00:00'

Find the oldest first-parent commit after the cutoff:

bash
FIRST=$(git rev-list --first-parent --reverse --since="$CUTOFF" main | sed -n '1p')

Inspect that boundary before continuing:

bash
git show --no-patch --format='%H%n%aI%n%s' "$FIRST"

Stop if FIRST is empty or names a commit you did not intend to retain. Once the boundary is correct, record its parent and the parent's tree:

bash
PARENT=$(git rev-parse "${FIRST}^")
bash
TREE=$(git rev-parse "${PARENT}^{tree}")

Create a root commit from that tree. This is the baseline immediately before the retained commits begin:

bash
BASE=$(printf '%s\n' 'Repository baseline before retained history' | GIT_AUTHOR_DATE="$CUTOFF" GIT_COMMITTER_DATE="$CUTOFF" git commit-tree "$TREE")

Replay the cutoff window onto the new baseline:

bash
git rebase --rebase-merges --committer-date-is-author-date --onto "$BASE" "$PARENT" main

Rebase stops if a retained commit conflicts. Resolve the conflict only when you understand why it occurred; otherwise abort with git rebase --abort and return to the disposable clone's original state.

Compare the final tree with the value recorded before the rewrite:

bash
test "$(git rev-parse main^{tree})" = "$ORIGINAL_TREE"

A zero exit status means the current files, paths, modes, and blob contents are identical. Count what remains and inspect the rewritten boundary:

bash
git rev-list --count main
bash
git log --reverse --oneline main | sed -n '1,5p'

Only after the tree comparison and project tests pass should you expire the disposable clone's reflogs and repack it:

bash
git reflog expire --expire=now --all
bash
git gc --prune=now

This rewrites every retained commit ID because each commit ultimately points to the new baseline. Old commit links, signatures, pull-request diffs, and blame history before the cutoff no longer apply. Keep the mirror backup until the rewritten remote and a fresh clone have both passed verification.


Remove large files from Git history with git-filter-repo

WARNING

Warning — this rewrites Git history

git filter-repo changes commits throughout the repository, which changes commit IDs and can remove files, commits, branches, tags, and signatures depending on the filter. Run it only on the disposable clone created earlier, and keep the backup until the cleanup is complete.

Do not push the rewritten history yet. First verify that the files you intended to remove are gone and that the project still contains everything it needs. Replacing the remote history later requires coordination with everyone using the repository.

All examples below assume you are inside a fresh disposable clone such as repo-cleanup.

Remove one file from every commit

--invert-paths means remove the listed paths instead of keeping only them:

bash
git filter-repo --path path/to/large-file.zip --invert-paths

Remove a directory from history

Pass a trailing slash when you want every file under that directory removed:

bash
git filter-repo --path backups/ --invert-paths

Remove multiple files or directories

List every path to drop in one command:

bash
git filter-repo --path backups/ --path builds/release.iso --invert-paths

After removing backups/, builds/release.iso, and both historical names of a renamed vendor bundle, .git dropped from about 116M to about 16M in testing.

Remove every blob above a size limit

When many unrelated large files are present, you can remove blobs above a size threshold instead of listing every path:

bash
git filter-repo --strip-blobs-bigger-than 50M

This is useful when many large files slipped into history, but it can remove blobs you did not review individually. Prefer explicit --path filters when you need precise control.

git-filter-repo removes the origin remote as a safety measure so you do not accidentally push rewritten history to the wrong place. That is expected.


If the large file was renamed or moved

git-filter-repo does not follow historical renames automatically. If analysis shows the same blob under two paths, include every historical name:

bash
git filter-repo --path artifacts/archive.zip --path builds/vendor-bundle.zip --invert-paths

Review renames.txt and path-all-sizes.txt for older names or locations before filtering.


Verify the repository after filtering

Run analysis again after the rewrite:

bash
git filter-repo --analyze

Measure size again:

bash
du -sh .git
output
16M     .git

Check packed object totals after the rewrite:

bash
git count-objects -vH
output
count: 0
size: 0 bytes
in-pack: 13
packs: 1
size-pack: 15.01 MiB

Confirm the unwanted paths no longer appear in reachable history:

bash
git log --all --oneline -- backups/db-dump-v1.sql

An empty result confirms that the pathname no longer appears in the reachable history being checked. Also review the new --analyze reports to confirm the unwanted large blobs are no longer part of the rewritten history. For a path that had older names, check every historical pathname:

bash
git log --all --oneline -- artifacts/archive.zip builds/vendor-bundle.zip

Confirm the current tree still contains every file the project needs, then run your build or tests. A smaller .git folder is not success if you deleted required artifacts.


Publish the rewritten history safely

Consider merging or closing open pull or merge requests before replacing history. Rewritten commit IDs can invalidate their diffs and comments, and provider-specific PR or MR refs may continue referencing the old history.

git-filter-repo removes origin on purpose. After local verification, re-add the remote you intend to replace:

bash
git remote add origin REPO_URL

When you rewrote the branches and tags that should exist on the remote, push them back with force and prune refs that disappeared during filtering:

IMPORTANT

Danger — this replaces history on the remote repository

The command below can overwrite remote branches and tags and delete refs covered by the push when they no longer exist locally. Any new commits pushed by other users after you created the cleanup clone can be lost.

Before running it, stop writes to the repository, verify the rewritten clone and backup, resolve branch and tag protection, and make sure collaborators know they must switch to the new history.

bash
git push --force --branches --tags --prune origin

--force is required because existing remote refs still point at the old commit IDs. --prune removes destination refs covered by the push refspecs when their corresponding local refs no longer exist, so stale remote branches and tags are not left pointing to the old history.

If some protected refs reject the push while others succeed, the remote can be left with a mixture of old and rewritten history.

The required push strategy depends on which refs you rewrote. The command above fits the common case where you filtered the same branches and tags you plan to keep. It is not a universal recipe for every host or every cleanup.

GitHub

GitHub may take time to recalculate repository size after a force push. Forks, pull request refs, and cached objects can retain old data until GitHub garbage-collects them.

For comprehensive sensitive-data cleanup, GitHub's official procedure may use git push --force --mirror origin because that case aims to replace every writable ref. Protected or read-only refs can still reject the push and need provider-side handling. See GitHub: removing sensitive data from a repository.

GitLab

GitLab can retain objects behind internal refs even after you rewrite normal branches. Use GitLab's repository cleanup or purge workflows when storage metrics do not drop after a successful local rewrite. See GitLab: reducing repository size.

Hosting providers may keep internal refs that still reference old objects. A cleaned local clone and provider-reported repository storage are not always the same number immediately. Do not merge old history back into the rewritten default branch after the push.


When should you use Git LFS instead?

Use Git LFS when large binary files legitimately belong in the project but normal Git object storage is a poor fit. Examples include media assets, machine-learning models, and versioned binary deliverables.

Do not use LFS merely to hide accidental historical bloat. LFS storage is billed and managed separately from .git history. When migration to LFS is the actual goal, see git lfs migrate in the Git LFS documentation.


Removing secrets is a different problem

If you discover a password, token, or private key in history:

  1. Revoke or rotate the secret immediately.
  2. Remove or redact it from history.
  3. Coordinate cleanup of forks, clones, and provider caches.

Deleting the file from Git does not make an exposed secret safe again. Follow your provider's incident guidance, such as GitHub: removing sensitive data.


Prevent the repository from growing again

  • Add generated artifacts, dumps, and build output to .gitignore.
  • Store backups and release binaries in artifact or package repositories.
  • Use Git LFS only where large binaries truly belong in version control.
  • Reject oversized blobs in CI, pre-commit, or pre-receive hooks.
  • Run git filter-repo --analyze periodically on repositories that tend to accumulate binaries.
  • When old history has no operational value, retain a dated baseline and a recent commit window instead of carrying every historical blob indefinitely.

Common problems

Problem Reason Fix
Repository did not shrink after deleting a file Historical versions remain reachable Remove the path from every commit with --invert-paths, not just the latest commit
git gc did not help Large blobs are still reachable Rewrite history or stop tracking the files going forward
Old versions of a renamed file remain Historical path names were omitted Include every old path from --analyze output
Local repo shrank but GitHub or GitLab did not Server refs, forks, or delayed GC Follow provider cleanup docs; wait for storage recalculation
Force push rejected Protected branch or tag, or missing permission Stop the rewrite push, resolve protection and permission requirements for all affected refs, then retry the coordinated update
Collaborator pushed old commits back Mixed old and rewritten histories Ask collaborators to re-clone; do not merge the old line
LFS quota did not shrink LFS object storage is separate Follow your hosting provider's LFS object-removal and storage-quota procedure

git-filter-repo vs BFG vs filter-branch

Tool Recommendation
git-filter-repo Recommended default for new history rewriting
BFG Repo-Cleaner Specialized alternative, especially for large-file cleanup
git filter-branch Legacy; do not use for new cleanup workflows
git gc Maintenance only; not a replacement for removing reachable history

Final verification checklist

Before you rewrite history:

  • A mirror or other recovery copy exists
  • Collaborators know a force push or re-clone may be required
  • Large objects and paths are identified, including renamed paths

After filtering:

  • Unwanted paths no longer appear in git log --all -- <path>
  • .git size and git count-objects -vH look reasonable
  • The project still builds or tests cleanly
  • Required branches and tags are handled on the remote
  • Provider storage has been rechecked when the host still reports old size
  • Future large artifacts are excluded or moved to LFS or artifact storage

Summary

Measure .git with du and git count-objects -vH, then use git filter-repo --analyze to find large reachable blobs. Remove known paths with --invert-paths, strip reviewed oversized blobs, or replace old ancestry with a baseline plus a recent commit window. Verify the current tree and the smaller repository locally before you re-add origin and coordinate a force push.

git gc alone cannot remove blobs that commits still reference. Git LFS, provider cleanup tools, and prevention rules handle different parts of the problem.


References


Frequently Asked Questions

Why is my Git repository still large after deleting a large file?

Deleting the file from the latest commit leaves older blobs in history. Remove the path from every commit with git filter-repo --path <path> --invert-paths on a fresh clone, or stop tracking the file going forward if you do not need to rewrite history.

How do I find the largest files in Git history?

Run git filter-repo --analyze and read .git/filter-repo/analysis/blob-shas-and-paths.txt and path-all-sizes.txt.

Does git gc reduce repository size?

git gc reclaims unreachable objects and repacks data. It cannot remove large files that branches and tags still reference. Use history rewriting when old commits still store the blobs you want gone.

How do I remove a large file from all Git commits?

Clone REPO_URL into a disposable directory, then run git filter-repo --path path/to/large-file --invert-paths. Verify with git log --all -- path/to/large-file before you push rewritten history.

How do I reduce the size of the .git folder?

Find large reachable blobs, remove them with git-filter-repo on a fresh clone, then measure again with du -sh .git and git count-objects -vH.

Should I use git filter-repo or git filter-branch?

Use git-filter-repo for new work. Git documents filter-branch as slow, hard to use safely, and unsuitable for new history-rewriting tasks.

Does git-filter-repo change commit hashes?

Yes. Rewriting history creates new commit IDs (object IDs). Tags and signed commits are affected the same way.

Why did my local repository shrink but GitHub or GitLab still shows the old size?

Provider metrics, internal refs, forks, and delayed garbage collection can lag behind your local clone. Follow the host's repository cleanup guidance and allow time for storage recalculation.

Should I use Git LFS for large files?

Use Git LFS when large binaries legitimately belong in the project. Do not treat LFS as a substitute for cleaning accidental historical bloat.

Can I reduce repository size without rewriting Git history?

Only when wasted space comes from unreachable objects or packing, or when you only need to stop tracking large files going forward. Removing reachable old blobs requires changing the commits that reference them.

Can I keep only the last month of Git history?

Yes. Create a root baseline from the tree immediately before the cutoff, then rebase the commits after that date onto the baseline. Verify that the final tree ID matches the original branch before you publish the rewritten history.

Finished reading?Beta Get the key takeaways in seconds, then ask follow-up questions
Steve Alila

Specializes in web design, WordPress development, and data analysis, with proficiency in Python, JavaScript, and data extraction tools. Additionally, he excels in web API development, AI integration, and data presentation using Matplotlib and Plotly.

  • Git
  • JavaScript
  • Laravel
  • Python (programming language)
  • Machine Learning