task-manager has come a long way. It has commit messages that explain the why, a clean history with an agreed integration policy, the right files inside and properly treated, and its secrets outside. One last problem of the module remains, far less dramatic than the previous one but increasingly annoying:
"Every
git statustakes three seconds. The clone of the repository weighs 900 MB and the code is four. And yesterday, cloning onto Carla's new laptop took eight minutes."
A slow repository does not break anything, but it silently degrades everything else: if git status takes three seconds, you stop running it; if the clone takes eight minutes, CI becomes expensive; if git log crawls, you stop consulting the history. Slowness erodes the habits we have built up across the whole module.
This lesson teaches you to measure before optimising, and then to act on whatever the measurement points to. It focuses on the normal-sized repository that has become slow through neglect. Genuinely enormous repositories and monorepos have techniques of their own — partial clones, shallow clones, a sparse index — and they are the content of lesson 10-04.
Contents
- Measure before optimising
git count-objects -vH: the snapshot of the repository- Finding the largest objects in the history
- Packing: loose objects and packfiles
git gc: what it really doesgit maintenance: the modern replacement- Speeding up
git status:fsmonitoranduntrackedCache feature.manyFilesand other settings- The cost of large binary files
- Reference hygiene:
pruneandpacked-refs - Other slow commands and their causes
- Good habits that avoid the problem
- Measure before optimising
The rule is the same as in any optimisation: your intuition about what is slow is nearly always wrong. Before touching anything, measure.
Git ships with a tracing mechanism that says exactly where the time goes:
# Basic trace: timings by stage
GIT_TRACE=1 git status
# Performance trace, far more detailed
GIT_TRACE_PERFORMANCE=1 git status12:04:31.882 read-cache.c:2402 performance: 0.412 s: read cache .git/index 12:04:32.741 name-hash.c:610 performance: 0.856 s: init name hash 12:04:34.102 dir.c:2419 performance: 1.361 s: directory traversal 12:04:34.180 trace.c:487 performance: 2.298 s: git command: git status
There is the complete diagnosis: 1.36 seconds walking directories and 0.41 reading the index. The problem is not the history: it is the number of files in the working copy. That points to section 7, not to section 5.
A more structured approach, with the modern trace in readable form:
And to compare before and after a change, measure several times:
What to measure for each symptom
| Symptom | Likely cause | Section |
|---|---|---|
Slow git status |
Many files in the working copy | 7 |
Slow git clone or an enormous clone |
Large objects in the history | 3, 9 |
Slow git log |
A very long history, or bad packing | 4, 5 |
Slow git fetch/push |
Many stale remote references | 10 |
| Everything a bit slow after a lot of activity | Many loose, unpacked objects | 4, 5, 6 |
Slow git checkout/switch |
Many files, or large files | 7, 9 |
git count-objects -vH: the snapshot of the repository
git count-objects -vH: the snapshot of the repositoryIt is the first command to run. It gives you the state of the object database on one screen:
count: 8432 size: 156.42 MiB in-pack: 214893 packs: 7 size-pack: 743.18 MiB prune-packable: 0 garbage: 0 size-garbage: 0 bytes
How to read it:
| Field | What it means | When to worry |
|---|---|---|
count |
Loose objects (one file per object in .git/objects/XX/) |
More than a few thousand: packing is overdue |
size |
The space the loose ones take up | If it is a significant fraction of the total |
in-pack |
Objects inside packfiles | Informational |
packs |
Number of packfiles | More than 5 or 10: worth consolidating |
size-pack |
The space taken by the packfiles | The repository's real figure |
prune-packable |
Loose objects that are also in a pack (duplicates) | Any high value: gc cleans them up |
garbage |
Files Git does not recognise in .git/objects |
Anything other than 0: something odd happened |
In the example: 743 MB of packfiles for a project whose code takes up 4 MB. There is the problem, and section 3 says where it comes from.
A comparison with the real size:
# What the whole .git takes up
du -sh .git
# What the working copy takes up (without .git)
du -sh --exclude=.git .A 200-to-1 ratio between the history and the current content is an unmistakable sign that there is something in the history that should not be there.
- Finding the largest objects in the history
This is the key diagnosis, and it is worth understanding piece by piece. The recipe combines two low-level commands you already know from lesson 01-04.
#!/usr/bin/env bash
# large-objects.sh — the N largest blobs in the history, with their path
#
# How it works:
# 1. rev-list --objects --all
# Lists ALL the objects reachable from any reference, in the format
# "<sha> <path>". The path only appears on blobs and trees.
# 2. cat-file --batch-check
# Receives SHAs on standard input and, for each one, prints the
# requested format without dumping the content (which would be very
# expensive). %(rest) returns whatever came after the SHA: the path.
# 3. awk / sort / head
# Keeps the blobs, sorts by size and shows the largest ones.
N=${1:-20}
git rev-list --objects --all \
| git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize:disk) %(objectsize) %(rest)' \
| awk '$1 == "blob" { print $3, $4, $2, $5 }' \
| sort -rn \
| head -n "$N" \
| while read -r on_disk actual sha path; do
printf '%8s on disk %8s actual %s %s\n' \
"$(numfmt --to=iec --suffix=B "$on_disk")" \
"$(numfmt --to=iec --suffix=B "$actual")" \
"${sha:0:10}" "$path"
done214MiB on disk 240MiB actual a1b2c3d4e5 demo/presentation-video.mp4 96MiB on disk 112MiB actual b2c3d4e5f6 design/mockups.psd 84MiB on disk 84MiB actual c3d4e5f6a7 data/test-dump.sql 71MiB on disk 88MiB actual d4e5f6a7b8 demo/presentation-video.mp4 52MiB on disk 52MiB actual e5f6a7b8c9 design/mockups.psd 31MiB on disk 36MiB actual f6a7b8c9d0 demo/screenshots.zip 2.1MiB on disk 8.4MiB actual a7b8c9d0e1 node_modules/.package-lock.json 1.8MiB on disk 1.9MiB actual b8c9d0e1f2 package-lock.json
The diagnosis leaps out at you:
presentation-video.mp4appears twice, at 214 and 71 MB. They are two versions of the same file: somebody updated it and Git keeps both in full.mockups.psdalso appears twice. The same thing.- An 84 MB database dump that should never have been versioned.
- And a
node_modules/.package-lock.json, a leftover of the contamination we cleaned up in lesson 08-03.
The two sizes the script prints are important:
objectsize(actual): the size of the uncompressed content.objectsize:disk(on disk): what it really takes up in the packfile, after compression and deltas.
The fact that they are similar for the videos and the images says it all: already-compressed formats do not compress further and do not admit useful deltas. That is section 9.
Useful variants
# Only what is reachable from HEAD (what a fresh clone of the current branch would take)
git rev-list --objects HEAD \
| git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' \
| awk '$1=="blob" {print $3, $4}' | sort -rn | head
# Sum by extension: which type of file weighs most?
git rev-list --objects --all \
| git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' \
| awk '$1=="blob" && $4 != "" {
n = split($4, p, "."); ext = (n > 1) ? p[n] : "(no ext)";
total[ext] += $3
}
END { for (e in total) printf "%12d %s\n", total[e], e }' \
| sort -rn | head -15This view by extension is the most useful for making decisions: it tells you which category of file is inflating the repository.
What to do with what you find
Locating the problem is half the work; the other half is section 9, and it anticipates the conclusion: deleting the file from the tip frees nothing. Getting it out of the history demands the same rewrite we saw in lesson 08-05, with the same consequences for the whole team.
- Packing: loose objects and packfiles
To understand gc you have to understand how Git stores objects. There are two ways:
Loose objects
Each object is a separate file in .git/objects/, compressed with zlib, in a directory named after the first two characters of its SHA:
It is the format Git uses when creating new objects: fast to write, very inefficient to store. If you change one line of a 1 MB file and commit, a brand new, complete 1 MB blob is created.
Packfiles
A packfile is a single file containing many objects, with two optimisations:
- Joint compression, more effective than compressing each object separately.
- Deltas: instead of storing every version in full, Git stores one complete version and the rest as differences from it.
flowchart LR
subgraph S["Loose objects"]
A1["blob v1<br/>1 MB"]
A2["blob v2<br/>1 MB"]
A3["blob v3<br/>1 MB"]
end
subgraph P["Packfile"]
B1["blob v3 complete<br/>1 MB"]
B2["delta v2 ← v3<br/>2 KB"]
B3["delta v1 ← v2<br/>3 KB"]
end
S -->|"git gc"| P
From 3 MB to a little over 1 MB. With a history of hundreds of versions of a text file, the saving is one or two orders of magnitude.
Details worth knowing:
- Git deltas against the most similar object, not necessarily against the previous version. It can chain deltas (with a configurable depth limit).
- Deltas are computed by content, not by history. Two similar files from distant commits can serve as each other's base.
- Already-compressed formats (JPG, PNG, MP4, ZIP, PSD) do not admit useful deltas. A tiny change in the image changes the whole compressed byte stream. That is why in section 3 the size on disk and the actual size matched.
# See the packfiles and their content
ls -lh .git/objects/pack/
# Statistics of a packfile: the depth of the delta chains
git verify-pack -v .git/objects/pack/pack-*.idx | tail -5When a packfile is created: on git gc (manual or automatic), on git clone or git fetch (what travels over the network is always a pack), and when the gc.auto thresholds are reached.
git gc: what it really does
git gc: what it really doesgit gc (garbage collection) is the classic maintenance command. It does five things:
- Packs the loose objects into packfiles.
- Consolidates several packfiles into fewer.
- Removes unreachable objects that have passed the grace period.
- Packs the references into
.git/packed-refs(section 10). - Expires old reflog entries (90 days by default for what is reachable, 30 for the rest).
# Before # After count: 8432 count: 0 size: 156.42 MiB size: 0 bytes in-pack: 214893 in-pack: 223325 packs: 7 packs: 1 size-pack: 743.18 MiB size-pack: 698.44 MiB
The 8,432 loose objects have gone, the 7 packfiles have been consolidated into 1, and the total has come down. Note that there are still 698 MB: gc reorganises and compresses, but it cannot remove objects that are still reachable from some reference. The videos from section 3 are still there because they are in commits that are still in the history.
Automatic gc
Git already runs gc --auto on its own after certain commands (commit, merge, rebase, receive-pack). It only acts if some thresholds are exceeded:
# Thresholds, with their default values
git config --get gc.auto # 6700 loose objects
git config --get gc.autoPackLimit # 50 packfiles
# Adjusting them
git config --global gc.auto 256
# Disabling automatic gc (if you prefer git maintenance, section 6)
git config --global gc.auto 0--aggressive: why it is almost never needed
--aggressive discards the existing deltas and recomputes everything from scratch, with a much larger search window. It is extremely expensive and the benefit is usually marginal, because the deltas that were already there were reasonably good.
| Situation | --aggressive? |
|---|---|
| Periodic maintenance | No. Plain git gc, or better still git maintenance |
After a filter-repo that rewrote everything |
Yes, once |
| After importing from another version control system | Yes, once |
| "Just in case", every week | No. Hours of CPU for nothing |
| The repository is slow and I do not know why | No. Measure first (section 1) |
If you really want a thorough repack, this is more controllable than --aggressive:
-a: everything in a single pack.-d: delete the old packs.-f: recompute the deltas.--window: how many objects to consider as a delta base (more = better and slower).--depth: the maximum length of the delta chain (more = smaller and slower to read).
The important warning
gc can remove unreachable objects, and with them the possibility of recovering lost commits:
After those two commands, a commit you had lost with an unfortunate reset can no longer be recovered. Recovering lost commits and the role of the reflog are the content of lesson 09-04; until you have seen it, do not run --prune=now unless you know exactly what you are doing (for example, after the filter-repo of lesson 08-05, where it is precisely what you want).
git maintenance: the modern replacement
git maintenance: the modern replacementSince Git 2.29 there has been git maintenance, designed to replace both manual and automatic gc. Its advantages:
- It is scheduled in the background, so it does not block your commands.
- Separate tasks with different frequencies, instead of a monolithic
gc. - Incremental: it repacks bit by bit instead of all at once.
- It does not expire the reflog by surprise.
That registers the repository and creates the system's scheduled tasks (cron, systemd, launchd or the Windows task scheduler, depending on the platform). From that moment on:
| Task | Frequency | What it does |
|---|---|---|
prefetch |
Hourly | Downloads objects from the remote in the background; your later fetch calls are almost instant |
commit-graph |
Hourly | Updates the commit graph, which massively speeds up log, merge-base and reachability calculations |
loose-objects |
Daily | Packs loose objects incrementally |
incremental-repack |
Daily | Consolidates packfiles bit by bit |
gc |
Disabled | The tasks above replace it |
# See what is registered
git config --get-all maintenance.repo --global
# Run a task manually
git maintenance run --task=commit-graph
git maintenance run --task=incremental-repack
# Run everything now
git maintenance run
# Turn it off
git maintenance stop
# Remove the repository from the register
git maintenance unregisterThe commit-graph, the hidden gem
It is probably the most rewarding improvement in this lesson. The commit-graph file is an index with the structural information of the commits — parents, dates, generation numbers — which avoids having to read and decompress every commit object in order to walk the history.
# Generate it by hand
git commit-graph write --reachable
# And have it maintained automatically
git config --global fetch.writeCommitGraph trueThe effect on a repository with a long history:
# Without commit-graph
time git log --oneline --graph --all > /dev/null # 4.8 s
# With commit-graph
time git log --oneline --graph --all > /dev/null # 0.3 sIt speeds up git log --graph, git merge-base, git branch --contains, git tag --contains, git bisect (lesson 06-02) and everything that needs to compute reachability. It is free and it has no downsides.
The recommended configuration
# Modern maintenance instead of manual gc
git maintenance start
git config --global gc.auto 0 # keep automatic gc out of the way
git config --global fetch.writeCommitGraph true
- Speeding up
git status: fsmonitor and untrackedCache
git status: fsmonitor and untrackedCacheThis is Carla's problem, and it is worth understanding because the cause is not the one people assume.
Why git status is slow
git status has to answer three questions:
- Which tracked files have changed? → compare the index with the disk.
- What is staged? → compare the index with
HEAD. - Which untracked files are there? → walk every directory of the working copy.
The third is the expensive one. Walking 40,000 files — node_modules included, even though it is ignored, because Git has to look in order to know that it ignores it — costs thousands of system calls. On Linux it is fast; on Windows and macOS, noticeably slower, and that is why Carla suffers more than Ana.
core.fsmonitor (Git 2.37+)
Instead of walking the tree, Git asks the operating system's file watching service what has changed since last time. Since Git 2.37 there is a built-in monitor, with no external tools needed:
The first run starts a background daemon:
The effect is dramatic:
core.untrackedCache
It caches the result of the directory walk, using each directory's modification timestamp to know which ones have to be looked at again.
# Check whether your file system supports it
git update-index --test-untracked-cache
# Turn it on
git config core.untrackedCache trueIt requires the file system to update directory mtime reliably. The test command verifies that; if it fails, do not turn it on.
When each one helps
| Setting | It helps when | It does not help when | Cost |
|---|---|---|---|
core.fsmonitor |
Many files in the working copy (>10,000); Windows or macOS | Small repositories; network file systems | One background daemon per repository |
core.untrackedCache |
Many directories with untracked files | The file system does not give reliable directory mtime |
A slightly larger index |
index.version 4 |
Very large indexes (it compresses the path names) | Small indexes | Incompatible with very old versions of Git |
core.preloadIndex |
Multi-core systems (on by default) | — | None |
| Reducing the number of files | Always | — | It requires changing the project |
The last row is the one that pays off most and the one least often applied: if node_modules has 40,000 files, no Git optimisation is going to be as good as not having them. Modern dependency systems with a central store and links reduce that number a great deal.
A simple trick for measuring how much the untracked-file walk costs:
If the difference is large, your problem is the directory walk and fsmonitor is the solution.
feature.manyFiles and other settings
feature.manyFiles and other settingsGit groups recommended configurations into feature "macros":
It is equivalent to turning on, all at once:
| Setting | Effect |
|---|---|
index.version 4 |
Compressed index format: a smaller index, faster to read |
core.untrackedCache true |
The cache from section 7 |
index.skipHash true |
Skips computing the index hash when writing it (Git 2.40+) |
It is designed for working copies with many files. It does not touch the history: it does not help with a repository that is large because of its history, only because of its current number of files.
Other settings with a good benefit-to-cost ratio:
# Write the commit-graph on fetch (section 6)
git config --global fetch.writeCommitGraph true
# Write the bitmap index when repacking: it speeds up clone and fetch a lot
git config --global repack.writeBitmaps true
# Parallelise compression across all the cores
git config --global pack.threads 0
# Limit the memory packing uses (useful on machines with little RAM)
git config --global pack.windowMemory 256m
git config --global pack.packSizeLimit 2gAnd a setting you should not touch lightly:
It makes the repository take up considerably more in exchange for a marginal improvement. It only makes sense in very specific cases, and almost never in yours.
- The cost of large binary files
Let us go back to the diagnosis in section 3: 357 MB of videos and 154 MB of PSD files. This is the structural problem of task-manager, and it is worth understanding properly why it is so serious.
Why they hurt so much
1. They do not compress. An MP4 or a PSD is already compressed. Git applies zlib on top and the result is practically the same size.
2. They do not admit useful deltas. Changing one frame of a video alters the whole compressed byte stream that follows. Git finds no usable similarity and stores every version in full.
3. Every version is stored whole. A 200 MB video updated five times is 1 GB in the history, for ever.
4. Everybody downloads it. git clone brings the complete history. Carla downloads all five videos even though she only needs the last one. So does CI, on every run that does not use a cache.
5. It cannot be merged. A conflict in a binary can only be resolved by choosing one whole version (lesson 08-04, the binary attribute).
A comparison with a text file:
app.js (200 KB, 500 versions) |
video.mp4 (200 MB, 5 versions) |
|
|---|---|---|
| Logical content | 100 MB | 1,000 MB |
| Real space in the pack | ~3 MB (deltas) | ~1,000 MB (no deltas) |
| Cost of the clone | Negligible | Minutes |
Why deleting them does not fix it
git rm demo/presentation-video.mp4
git commit -m "chore: remove the demo video"
git count-objects -vH # the size does NOT go downIt is exactly the same mechanism we saw with secrets in lesson 08-05: the new commit does not include the file, but the earlier commits still reference the blobs, which are still reachable and which gc therefore never touches.
To recover the space for real you have to rewrite the history:
# On a --mirror clone, with a backup taken first (lesson 08-05)
git filter-repo --path demo/ --path design/ --path data/test-dump.sql --invert-paths
# Or by size: strips any blob larger than 10 MB
git filter-repo --strip-blobs-bigger-than 10MWith exactly the same consequences as in the previous lesson: every SHA changes, every clone becomes obsolete, you have to coordinate with the team, and the forks (Diego's) do not clean themselves up. Rewriting the history to save space is a serious decision, not a maintenance chore. Nearly always it is better to learn the lesson and not do it again.
The right solution: Git LFS
For binaries you do have to version — designs, a game's graphical assets, master documents — the answer is Git LFS (Large File Storage). Remember from section 13 of lesson 08-04 that it is activated with a filter in .gitattributes:
The repository stores a 130-byte text pointer instead of the file, and the content lives in a separate store that is only downloaded when it is needed.
The full mechanism, the storage server, the costs, git lfs migrate for converting an existing history and the limitations you have to know about before adopting it are the content of lesson 10-03: Git LFS for Large Files.
- Reference hygiene:
prune and packed-refs
prune and packed-refsReferences — branches, tags, tracking branches — are 41-byte files (lesson 03-01). Individually they weigh nothing; accumulated by the thousand, they do matter.
Stale remote branches
When somebody deletes a branch on the server, your local copy of that reference does not disappear on its own:
1,247 remote branches, of which perhaps 8 still exist. Every fetch processes them and every git branch -a lists them.
# See what would be removed, without doing it
git remote prune origin --dry-run
# Clean up
git fetch --prune
# And make it the default behaviour, for ever
git config --global fetch.prune true
git config --global fetch.pruneTags false # with tags, better to be conservativePicking up on lesson 03-06: fetch.prune true should be in everybody's global configuration. It is one line that avoids a silent accumulation nobody ever looks at.
And local branches that are already merged:
# See which ones are fully integrated into main
git branch --merged main | grep -vE '^\*|main|develop'
# Delete them
git branch --merged main | grep -vE '^\*|main|develop' | xargs -r git branch -dBeware of
--mergedif your integration policy is squash (lesson 08-02): a squashed branch does not appear as merged, because its commits are not inmain. Check before deleting, or go by the remote branch that the platform removes on merge.
packed-refs
With thousands of loose references, each one is a tiny file and reading them all costs thousands of disk operations:
git pack-refs consolidates them into a single .git/packed-refs file:
# pack-refs with: peeled fully-peeled sorted a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0 refs/heads/main b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1 refs/remotes/origin/main c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2 refs/tags/v1.5.0 ^d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3
git gc does it automatically, so you rarely have to run it by hand. References that change are written out again as loose files and are repacked at the next gc.
Other references that accumulate
# Tags: if the team tags every build, thousands pile up
git tag | wc -l
# Note, stash and worktree references
ls .git/refs/One specific case that takes people by surprise: the worktrees of lesson 06-06 leave behind references and metadata. If you create and delete worktrees often:
- Other slow commands and their causes
| Slow command | Usual cause | Solution |
|---|---|---|
git log --graph --all |
Walking the whole history computing reachability | git commit-graph write --reachable (section 6) |
git blame on a large file |
It walks the history line by line | Narrow it down with -L 100,200 or with a commit range |
git clone |
A complete history with large binaries | Reduce the history (section 9); or a partial clone, lesson 10-04 |
git checkout / switch |
Writing many files to disk | core.fsmonitor; fewer files |
git fetch |
Many references, or missing bitmaps | fetch.prune true, repack.writeBitmaps true |
git push |
Working out what is missing on the server | repack.writeBitmaps true |
git grep |
Searching the whole working copy | git grep --cached (searches the index, far faster) |
git bisect |
Many steps, each one with a full checkout | commit-graph; bisect --first-parent (lesson 08-02) |
git diff on large files |
Computing the diff | -diff in .gitattributes if it is binary (lesson 08-04) |
One specific case deserves a mention, because it links to lesson 06-05: submodules slow git status down noticeably, because Git has to go into each one and check its state.
# Do not check the internal state of the submodules
git config --global status.submoduleSummary false
git config diff.ignoreSubmodules dirtyWith ui-components as a submodule, those two settings are noticeable on every git status.
- Good habits that avoid the problem
Everything above is treatment. This is prevention, which is infinitely cheaper:
1. A correct .gitignore from the very first commit (lesson 08-03). Almost every enormous repository is enormous because of something that should never have gone in.
2. Never version large binaries without thinking about it. Before adding a file of more than a few MB, ask yourself: is it going to change? How many times? If the answer is "yes, many", you need Git LFS (lesson 10-03) or an external store.
3. Set a limit and check it in the pre-commit hook (lesson 06-01):
#!/usr/bin/env bash
# .githooks/pre-commit — blocks files that are too large
LIMIT=$((5 * 1024 * 1024)) # 5 MB
failures=0
while IFS= read -r file; do
[ -f "$file" ] || continue
size=$(wc -c < "$file")
if [ "$size" -gt "$LIMIT" ]; then
echo "BLOCKED: '$file' takes up $((size / 1024 / 1024)) MB (limit: 5 MB)." >&2
echo " If it really has to be versioned, use Git LFS." >&2
failures=1
fi
done < <(git diff --cached --name-only --diff-filter=ACM)
exit $failuresAnd the same check in CI (lesson 07-06), because --no-verify exists.
4. Small, atomic commits (lesson 08-02). A history of small commits compresses better and produces more efficient deltas than one of gigantic commits.
5. fetch.prune true and a sensible gc.auto in the whole team's global configuration.
6. git maintenance start in every repository you work with daily.
7. Measure from time to time. A quarterly git count-objects -vH catches the problem while it is still easy to fix.
8. Do not version tool output. Minified files, generated documentation, screenshots of failed tests. They can be regenerated.
9. Beware of database dumps. As well as being large, they usually contain personal data (lesson 08-05).
A quarterly review script
#!/usr/bin/env bash
# repo-review.sh — repository health report
echo "=== Size ==="
du -sh .git
git count-objects -vH
echo ""
echo "=== The 10 largest objects in the history ==="
git rev-list --objects --all \
| git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' \
| awk '$1=="blob" {printf "%10.1f MB %s\n", $3/1048576, $4}' \
| sort -rn | head -10
echo ""
echo "=== References ==="
echo "Local branches: $(git branch | wc -l)"
echo "Remote branches: $(git branch -r | wc -l)"
echo "Tags: $(git tag | wc -l)"
echo "Loose refs: $(find .git/refs -type f | wc -l)"
echo ""
echo "=== Remote branches that no longer exist on the server ==="
git remote prune origin --dry-run
echo ""
echo "=== Speed of git status ==="
/usr/bin/time -f " with untracked: %e s" git status > /dev/null
/usr/bin/time -f " without untracked: %e s" git status --untracked-files=no > /dev/null
echo ""
echo "=== Maintenance ==="
echo "commit-graph: $([ -f .git/objects/info/commit-graph ] && echo 'yes' || echo 'NO — run git commit-graph write --reachable')"
echo "fsmonitor: $(git config --get core.fsmonitor || echo 'not configured')"
echo "fetch.prune: $(git config --get fetch.prune || echo 'not configured')"Common Mistakes and Tips
Mistake 1: optimising without measuring. Running gc --aggressive because "the repository is slow" when the problem is the untracked-file walk burns hours of CPU for nothing. Measure first with GIT_TRACE_PERFORMANCE=1.
Mistake 2: gc --aggressive as a routine. It is expensive and its benefit is marginal except after a complete rewrite of the history or an import.
Mistake 3: believing git gc will shrink a repository inflated by binaries. gc reorganises and compresses, but it cannot remove reachable objects. The binaries are still there because they are still in the history.
Mistake 4: deleting a large file and expecting the repository to slim down. The same mechanism as with secrets: git rm does not touch the history. You need filter-repo, with all its consequences.
Mistake 5: git reflog expire --expire=now --all && git gc --prune=now without knowing what you are doing. It destroys the safety net that allows lost commits to be recovered (lesson 09-04).
Mistake 6: not turning on fetch.prune. Thousands of ghost remote branches that slow down every fetch and clutter every listing.
Mistake 7: versioning large binaries "because there are only a few". Five versions of a 200 MB video are 1 GB permanently, for everybody who clones, for ever.
Mistake 8: ignoring the commit-graph. It is the optimisation with the best benefit-to-cost ratio in the whole lesson and almost nobody turns it on.
Tip 1: git maintenance start in your working repositories. It replaces manual gc, it runs in the background and it keeps the commit-graph up to date.
Tip 2: core.fsmonitor true if you have many files, especially on Windows or macOS. It is the difference between 2.8 s and 0.15 s on every git status.
Tip 3: measure the cost of untracked files with git status --untracked-files=no. If the difference is large, you know where to act.
Tip 4: keep the script from section 3. Finding the largest objects in the history is the question you ask once a year and always have to look up again.
Tip 5: set a size limit in pre-commit and in CI. It costs ten lines and it avoids the whole problem.
Tip 6: a two-minute quarterly review. The script in section 12 catches the problems while they are still cheap to fix.
Exercises
Exercise 1: diagnosing an inflated repository
- Create a repository and commit a small
app.js. - Add a large randomly generated binary file and commit it:
head -c 20000000 /dev/urandom > demo/video.bin - Modify it three times (by regenerating it) and commit each version.
- Run
git count-objects -vHand notesize-pack. - Run the script from section 3 and identify the large objects.
- Delete the file with
git rm, commit, rungit gcand look atcount-objectsagain. Explain the result. - Compare the size with the same experiment done on a 20 MB text file modified three times. Explain the difference.
Exercise 2: measuring and speeding up git status
- In a test repository, create 20,000 small files in subdirectories:
for i in $(seq 1 200); do mkdir -p "dir$i" for j in $(seq 1 100); do echo "content $i-$j" > "dir$i/f$j.txt"; done done - Commit them and time
git statusthree times. - Time
git status --untracked-files=noand work out what percentage of the time goes on the directory walk. - Turn on
core.fsmonitor true, rungit statustwice (the first one starts the daemon) and time it again. - Try
git config feature.manyFiles trueand time it again. - Generate the
commit-graphand comparegit log --graph --allbefore and after.
Exercise 3: reference hygiene and maintenance
- Create a repository with 50 local branches, half of them merged into
main. - Count the loose references with
find .git/refs -type f | wc -l. - Run
git pack-refs --alland count again. Examine.git/packed-refs. - Delete the merged branches with
git branch --merged. Explain what would happen if the integration policy were squash. - Turn on
git maintenance startand check which tasks are registered withgit config --get-all maintenance.repo --global. - Write the review script from section 12 into a file, run it and interpret each block of the output.
Solutions
Solution 1:
mkdir -p /tmp/practice-perf/demo && cd /tmp/practice-perf && git init -b main
echo "console.log('task-manager');" > app.js
git add . && git commit -m "chore: initial commit"# 2 and 3. The binary, in four versions
for v in 1 2 3 4; do
head -c 20000000 /dev/urandom > demo/video.bin
git add . && git commit -q -m "chore: demo video, version $v"
done76 MB for a project whose code is 30 bytes. The four versions are stored whole: there is no compression possible on random data, and no usable deltas.
# 5. The large objects
git rev-list --objects --all \
| git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' \
| awk '$1=="blob" {printf "%8.1f MB %s %s\n", $3/1048576, substr($2,1,10), $4}' \
| sort -rn | head 19.1 MB a1b2c3d4e5 demo/video.bin
19.1 MB b2c3d4e5f6 demo/video.bin
19.1 MB c3d4e5f6a7 demo/video.bin
19.1 MB d4e5f6a7b8 demo/video.bin
0.0 MB e5f6a7b8c9 app.jsFour different blobs with the same path: the four versions, each one complete.
# 6. Deleting fixes nothing
git rm demo/video.bin && git commit -q -m "chore: remove the video"
git gc -q --prune=now
git count-objects -vHThe size has not gone down by a single byte. The four blobs are still reachable from the earlier commits, which are still in main's history. gc --prune=now only removes unreachable objects, and these are not unreachable. It is exactly the same mechanism that stopped us deleting a secret in lesson 08-05: Git's objects are immutable and the old trees still point at them.
To recover the space you would need:
git filter-repo --path demo/video.bin --invert-paths --force
git count-objects -vH # now it does go down# 7. The comparison with text
mkdir /tmp/practice-text && cd /tmp/practice-text && git init -b main
# 20 MB of text: about 400,000 lines
seq 1 400000 | sed 's/$/ line of text from the task manager/' > data.txt
git add . && git commit -q -m "data v1"
for v in 2 3 4; do
sed -i "1000s/.*/$v MODIFIED line/" data.txt
git commit -q -am "data v$v"
done
git gc -q
git count-objects -vH5.7 MB against 76 MB. With 80 MB of logical content in both cases. Two mechanisms explain the difference:
- Compression: repetitive text compresses down to a fraction of its size; random data (like a video or an already-compressed image) does not compress at all.
- Deltas: versions 2, 3 and 4 of the text are stored as differences of a few lines from the complete version. In the binary, each version is a completely different byte stream and Git finds no usable similarity.
This is the underlying technical reason why Git is excellent for text and terrible for large binaries, and why Git LFS exists (lesson 10-03).
Solution 2:
mkdir /tmp/practice-status && cd /tmp/practice-status && git init -b main
for i in $(seq 1 200); do
mkdir -p "dir$i"
for j in $(seq 1 100); do echo "content $i-$j" > "dir$i/f$j.txt"; done
done
git add . && git commit -q -m "chore: 20,000 files"# 3. Without walking the untracked files
/usr/bin/time -f "%e s" git status --untracked-files=no > /dev/nullThe directory walk costs 0.71 s out of 0.92 s: 77 % of the time. The diagnosis is clear and it points to fsmonitor.
# 4. fsmonitor
git config core.fsmonitor true
git status > /dev/null # starts the daemon; this first one is still slow
for i in 1 2 3; do /usr/bin/time -f "%e s" git status > /dev/null; doneFrom 0.92 s to 0.12 s: almost eight times faster. The daemon receives change notifications from the operating system, so Git no longer needs to walk anything.
# 5. feature.manyFiles
git config feature.manyFiles true
git config --list | grep -E 'index.version|untrackedCache|skipHash'The additional improvement is small when fsmonitor is already on, because the main bottleneck is already solved. Without fsmonitor, untrackedCache on its own usually gives a noticeable improvement (of the order of 40-60 % on the walk).
# 6. commit-graph
for i in $(seq 1 300); do echo "$i" >> app.js; git commit -q -am "commit $i"; done
/usr/bin/time -f "%e s" git log --oneline --graph --all > /dev/null
git commit-graph write --reachable
/usr/bin/time -f "%e s" git log --oneline --graph --all > /dev/nullOn a history of 300 commits the difference is small; with tens of thousands it is an order of magnitude. The reason: without the commit-graph, Git has to read and decompress every commit object to find out its parents and its date; with it, that information is in a flat binary index.
Solution 3:
mkdir /tmp/practice-refs && cd /tmp/practice-refs && git init -b main
echo "start" > app.js && git add . && git commit -q -m "chore: start"
# 1. 50 branches, 25 of them merged
for i in $(seq 1 50); do
git switch -q -c "GT-$i" main
echo "change $i" >> app.js
git commit -q -am "feat: change $i"
done
git switch -q main
for i in $(seq 1 25); do git merge -q --no-ff "GT-$i" -m "Merge GT-$i" 2>/dev/null; done# pack-refs with: peeled fully-peeled sorted a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0 refs/heads/GT-1 b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1 refs/heads/GT-10 c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2 refs/heads/GT-11
51 tiny files turned into a single sorted one. With thousands of references, the difference when reading them is noticeable.
What would happen with a squash policy (lesson 08-02): git branch --merged checks whether the branch's tip commit is reachable from main. With a squash merge, the branch's original commits never enter main: a new commit is created with the same content but a different SHA and a different lineage. Therefore:
git branch --merged mainwould not list any of those branches, even though their work is entirely inmain.git branch -dwould refuse to delete them ("not fully merged"), forcing you to use-D, which deletes without checking anything.
With squash, the reliable criterion is not --merged but the remote branch: the platform removes it when the PR is merged, and git fetch --prune cleans up your local copy. It is one more reason to have fetch.prune true in your global configuration.
maintenance.auto=false disables the automatic gc after each command: it is no longer needed, because the scheduled tasks take care of it in the background.
# 6. The review script
# (save the script from section 12 as repo-review.sh)
chmod +x repo-review.sh && ./repo-review.shHow to interpret each block:
| Block | What to look for |
|---|---|
| Size | That .git is not disproportionate to the code. A ratio >20:1 is suspicious |
| Large objects | Any blob of more than a few MB, and above all the same file repeated (successive versions of a binary) |
| References | Remote branches far above the real number; loose refs in the thousands |
| Ghost branches | Everything listed by remote prune --dry-run is accumulated rubbish |
git status |
If the difference with --untracked-files=no is large, turn on fsmonitor |
| Maintenance | commit-graph present, fetch.prune configured |
Conclusion
The essentials of this lesson:
- Measure before optimising.
GIT_TRACE_PERFORMANCE=1says exactly where the time goes, andgit count-objects -vHgives you the snapshot of the repository on one screen. Intuition about what is slow nearly always fails. git rev-list --objects --all+git cat-file --batch-checkis the recipe for finding the largest objects in the history. Seeing the same file repeated several times is the unmistakable symptom of a versioned binary.- Git stores objects loose (fast to write, inefficient) and in packfiles (jointly compressed and with deltas).
git gcpacks, consolidates and cleans up what is unreachable, but it cannot remove what is still in the history.--aggressiveis almost never needed: only after a complete rewrite or an import. git maintenance startis the modern replacement for manualgc: separate tasks, scheduled and in the background. And it brings with it thecommit-graph, which is the optimisation with the best benefit-to-cost ratio in the whole lesson.git statusis slow because of the untracked-file walk, not because of the history. It is measured with--untracked-files=noand fixed withcore.fsmonitor(Git 2.37+) andcore.untrackedCache.feature.manyFilesgroups the settings for working copies with many files.- Large binaries are expensive because they do not compress, they do not admit deltas, they are stored whole in every version and everybody downloads them. And deleting them does not free space, for exactly the same reason that deleting a secret does not remove it: you have to rewrite the history, with all its consequences. The right solution is Git LFS, in lesson 10-03.
- Reference hygiene:
fetch.prune truein everybody's global configuration, deleting merged branches (carefully if the policy is squash) andpacked-refs, whichgcmaintains on its own. - And above all, prevention: a
.gitignorefrom the first commit, a size limit in thepre-commithook and in CI, small commits, and a two-minute quarterly review.
For what goes beyond this — monorepos, partial clones with --filter=blob:none, shallow clones, a sparse index — there is lesson 10-04.
The module, in one idea
Module 7 ended by saying that task-manager was lacking habits. Now it has all of them.
Messages that explain the why and from which the version and the changelog can be derived (08-01). A history that is readable, bisectable and reversible, with a written, agreed integration policy (08-02). Only what should be inside (08-03), treated as it deserves, with Carla's line endings sorted out once and for all (08-04). Secrets outside, and a written procedure for the day it happens again (08-05). And a repository that is fast, measured and maintained (08-06).
Ana, Bruno, Carla and Diego now have the tool, the process and the habits. They have done everything right.
And that is exactly what does not happen in real life.
Because somebody is going to run git reset --hard over three days of uncommitted work. Somebody is going to commit on the wrong branch, or with the wrong user, and will not notice until after publishing. Somebody is going to pull on a branch that has diverged and will find themselves with a tangle they do not know how to undo. Somebody is going to delete a branch that did matter and will discover, in a panic, that git log can no longer find it. And one day, a corrupt .git/index is going to make Git refuse to do absolutely anything.
All of that has a solution, and nearly always a simpler one than it looks in the moment of panic. Learning how to get out of trouble — undoing changes, resolving divergence with the remote, recovering lost commits with the reflog, repairing a broken repository and diagnosing whatever does not add up — is the whole of module 9.
We start with the catalogue of the problems everybody runs into sooner or later, in lesson 09-01: Common Git Problems.
Mastering Git: From Beginner to Advanced
Module 1: Introduction to Git
- What Is Git?
- Installing Git
- Basic Git Terminology
- The Git Data Model
- Configuring Git
- Initial Configuration
Module 2: Basic Git Operations
- Creating a Repository
- Cloning a Repository
- The Basic Git Workflow
- Staging and Committing Changes
- Inspecting Changes with git diff
- Viewing Commit History
Module 3: Branching and Merging
- Understanding Branches
- Creating and Switching Branches
- Merging Branches
- Merge Strategies
- Resolving Merge Conflicts
- Branch Management
Module 4: Working with Remote Repositories
- Understanding Remote Repositories
- Adding a Remote Repository
- Authenticating with Remote Repositories
- Fetching and Pulling Changes
- Pushing Changes
- Tracking Branches
Module 5: Advanced Git Operations
Module 6: Git Tools and Techniques
- Using Git Hooks
- Git Bisect
- Git Blame
- Git Log and Aliases
- Git Submodules
- Multiple Working Copies with git worktree
Module 7: Collaboration and Workflow Strategies
- Forks and Pull Requests
- Code Reviews with Git
- The Git Flow Workflow
- GitHub Flow
- Trunk Based Development
- Continuous Integration with Git
Module 8: Git Best Practices and Tips
- Writing Good Commit Messages
- Keeping a Clean History
- Ignoring Files with .gitignore
- File Attributes with .gitattributes
- Security Best Practices
- Performance Tips
Module 9: Troubleshooting and Debugging
- Common Git Problems
- Undoing Changes
- Resolving Divergence with the Remote
- Recovering Lost Commits
- Dealing with Corrupted Repositories
- Advanced Debugging Techniques
