The previous lesson solved one of the three ways in which a repository becomes unmanageable: very large files. Two remain, and they are independent of each other. A repository can have a colossal amount of history even if every file is tiny, and it can have a colossal number of files even if the history is short. Each problem has its symptom and its remedy, and applying the wrong remedy fixes nothing.
This lesson delivers on two of the course's promises. Lesson 06-05 left open the comparison between submodules, subtree, packages and monorepo, deferring the monorepo until here. 08-06 taught how to measure and optimise an ordinary repository that has become slow, and referred genuinely enormous repositories here. And case 3 of 10-01 described the enterprise monorepo, explaining the why and leaving the how for this lesson.
Let us start by making one thing clear, because it is the most important of all: almost nothing in here is needed by task-manager, and you probably do not need it either. This is material for the day you inherit a repository of fifty thousand files, and so that you can recognise when that day has come. Applying these techniques to a healthy repository adds complexity, oddities and a new surface for mistakes, in exchange for nothing.
Contents
- The three dimensions of growth
- Measure before optimising (and what to measure in each case)
- Partial clones:
--filter=blob:noneand--filter=tree:0 - Partial clone versus shallow clone
- Sparse checkout and the sparse index
- Client-side accelerators
- Monorepo versus multi-repo
- What it takes for a monorepo to work
- When none of this is needed
- A recipe per symptom
- The three dimensions of growth
The most common mistake when faced with a slow repository is to treat it as one problem. It is not. There are three, they show up in different ways and they are fixed with different tools.
| Dimension | What it means | Typical symptoms | What does NOT fix it | Remedy |
|---|---|---|---|---|
| Lots of history | Hundreds of thousands or millions of commits; many years of life | git clone takes forever; git log --graph slow; git blame slow; git branch --contains endless |
Deleting files; LFS | commit-graph; partial clone --filter=tree:0; shallow clone for throwaway cases |
| Lots of files | Tens or hundreds of thousands of files in the working copy | git status takes seconds or minutes; git checkout slow; the editor indexes endlessly |
Reducing the history; gc |
sparse-checkout --cone + sparse index; fsmonitor; untrackedCache |
| Very large files | Binaries of tens or hundreds of MB, with many versions | Repository of gigabytes; extremely slow clone even with few commits; expensive gc |
sparse-checkout; commit-graph |
Git LFS (10-03); taking the binary out of the repository; partial clone --filter=blob:none |
The three are independent. A repository can suffer one, two or all three at once, and each one has to be diagnosed separately.
An example that clarifies the independence: a repository with twenty years of history and sixty text files suffers dimension 1 and nothing else. git clone takes ten minutes and git status is instantaneous. Applying sparse-checkout to it would achieve absolutely nothing.
flowchart TD
P["My repository is slow"] --> Q1{"Which command<br/>is slow?"}
Q1 -->|"git status<br/>git checkout"| D2["Dimension 2:<br/>lots of files"]
Q1 -->|"git log<br/>git blame<br/>git branch --contains"| D1["Dimension 1:<br/>lots of history"]
Q1 -->|"git clone<br/>git gc"| Q2{"Does the repository<br/>weigh a lot?"}
Q2 -->|"yes, GB"| D3["Dimension 3:<br/>large files"]
Q2 -->|"no, but there are<br/>many commits"| D1
D1 --> R1["commit-graph<br/>--filter=tree:0"]
D2 --> R2["sparse-checkout --cone<br/>fsmonitor"]
D3 --> R3["Git LFS (10-03)<br/>--filter=blob:none"]
- Measure before optimising (and what to measure in each case)
The rule from lesson 08-06 still stands and matters more here: intuition about what is slow is almost always wrong. Before applying any of these techniques, measure.
The general picture
count: 0 size: 0 bytes in-pack: 4128394 packs: 3 size-pack: 8.42 GiB prune-packable: 0 garbage: 0 size-garbage: 0 bytes
The three measurements that separate the dimensions
# Dimension 1: how much history is there?
git rev-list --count --all
git rev-list --count HEAD
# Dimension 2: how many files are there in the working copy?
git ls-files | wc -l
# Dimension 3: how much do the largest objects take up?
git lfs migrate info --everything --above=1Mb 2>/dev/null || \
git rev-list --objects --all \
| git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' \
| awk '$1=="blob" {print $3, $4}' | sort -rn | head -10Three numbers that steer the decision, with approximate orders of magnitude:
| Measurement | No problem | Starting to hurt | Needs this lesson's techniques |
|---|---|---|---|
Commits (rev-list --count --all) |
< 20,000 | 50,000 – 300,000 | > 500,000 |
Files (ls-files | wc -l) |
< 10,000 | 20,000 – 80,000 | > 100,000 |
Size (size-pack) |
< 500 MB | 1 – 3 GB | > 5 GB |
They are reference points, not exact thresholds: they depend heavily on the hardware, the filesystem and the operating system. A repository of 30,000 files runs sweetly on a modern laptop with Linux and can be miserable on Windows with an antivirus checking every access.
Where exactly the time goes
d0 | main | region_enter | r1 | 0.001 | index:do_read_index d0 | main | region_leave | r1 | 1.842 | index:do_read_index d0 | main | region_enter | r1 | 1.843 | dir:untracked d0 | main | region_leave | r1 | 9.104 | dir:untracked d0 | main | region_leave | r1 | 11.203 | status
Reading the diagnosis: 1.8 seconds reading the index and 9.1 seconds walking directories looking for untracked files. It is a pure dimension 2 problem. Neither the commit-graph nor a partial clone would do anything for it; what is needed is untrackedCache, fsmonitor and, if appropriate, sparse-checkout.
And task-manager, for reference:
git rev-list --count --all # 412
git ls-files | wc -l # 9
git count-objects -vH # size-pack: 2.14 MiBNone of the three dimensions. Nothing in this lesson applies to it.
- Partial clones:
--filter=blob:none and --filter=tree:0
--filter=blob:none and --filter=tree:0The partial clone is, by a wide margin, the most useful and least known technique in this lesson.
The idea
A normal clone downloads every reachable object: every commit, every tree and every blob of the whole history. A partial clone downloads the graph but omits certain objects, and asks the server for them on demand when some command needs them.
This requires the server to support it — the major platforms and modern versions of Git do — and it works over a persistent connection called a promisor remote: the remote "promises" to have the missing objects.
flowchart LR
subgraph normal["Full clone"]
A1["All the commits"]
A2["All the trees"]
A3["All the blobs<br/>of the whole history"]
end
subgraph partial["Clone with --filter=blob:none"]
B1["All the commits"]
B2["All the trees"]
B3["Only the blobs of<br/>the checked-out revision"]
B4["The rest: on demand"]
end
--filter=blob:none: without historical content
It downloads every commit and every tree, but no blobs other than those needed to check out the working revision.
What carries on working without downloading anything:
git log --oneline --graph --all # the graph is complete
git log --format=%s # the messages are in the commits
git branch -a --contains a1b2c3d # pure topology
git tag --contains a1b2c3d
git log --name-only # the names are in the trees
git rev-list --count --allWhat triggers an on-demand download:
git log -p # it needs the content for the diff
git diff HEAD~50 # it needs old blobs
git blame app.js # it needs every version of the file
git checkout v1.0.0 # it needs the blobs of that revisionSeeing it in action:
remote: Enumerating objects: 47, done.
remote: Counting objects: 100% (47/47), done.
Receiving objects: 100% (47/47), 182.41 KiB | 2.11 MiB/s, done.
4f8a2e6c (Ana Ferrer 2026-07-31 13:12:04 +0200 1) function calculatePending(tasks) {
...Git has gone off to fetch the 47 versions of the file it needed for the blame, and then answered. It is slower the first time and stays cached for subsequent ones.
The objects that have already been fetched stay put:
That --missing=print lists, with a ? in front, the objects that are missing locally. It is the way to know how much is left to download.
--filter=tree:0: commits only
More aggressive still: it omits the trees as well. Only commit objects are downloaded.
With this, git log --oneline, git log --graph, git rev-list and any purely topological or message-based query carry on working. But almost any operation on files triggers downloads, including git log --name-only, because the names live in the trees.
It is the option for very specific cases: analysing the graph of an enormous repository, counting commits, extracting authorship statistics, generating a changelog. For day-to-day work, --filter=blob:none is almost always the right choice.
Other filters
# Omit blobs over 1 MB (useful if there are stray binaries)
git clone --filter=blob:limit=1m https://git.example.com/team/project.git
# Apply the filter to an already-cloned repository
git config remote.origin.promisor true
git config remote.origin.partialclonefilter blob:noneblob:limit=1m is an interesting middle ground: it brings down all the code (which is small) and leaves out the heavy binaries. Without needing LFS.
The comparison in figures
For a hypothetical very large repository, with approximate orders of magnitude:
| Clone type | Downloaded | Relative time | Complete history |
|---|---|---|---|
| Full | ~8 GB | 100% | Yes |
--filter=blob:none |
~500 MB | ~8% | Yes (complete graph) |
--filter=tree:0 |
~150 MB | ~3% | Yes (commits only) |
--depth=1 |
~200 MB | ~4% | No |
The row that matters is the second: 8% of the download while keeping the complete history. That is what makes the partial clone the reasonable default for large repositories.
- Partial clone versus shallow clone
In lesson 02-02 we saw the shallow clone --depth=1. It is worth contrasting them, because they solve different things and the shallow one has traps the partial one does not.
--depth=N (shallow) |
--filter=blob:none (partial) |
|
|---|---|---|
| What it omits | Commits older than the given depth | Blobs not needed right now |
| The graph | Truncated: the old commits do not exist | Complete |
git log |
Only the last N commits | The whole history |
git blame |
Only up to the cut-off | Complete (by downloading) |
git bisect |
Unusable | Works |
git describe |
Fails or gives odd results | Works |
git merge-base with an old branch |
Fails: there is no common ancestor | Works |
git log v1.0.0..main |
Fails: the tag does not exist | Works |
| Merging / rebasing onto an old base | Problematic | Normal |
| Recovering what is missing | git fetch --unshallow (full download) |
Automatic and transparent |
| Server support | Universal | Requires a modern server |
| Typical use | CI that only builds the current revision | Daily work in large repositories |
Why the partial one is usually better
1. It does not break Git's semantics. A shallow clone is an incomplete repository: there are commits that simply do not exist, and any operation that needs to reach them fails. A partial clone is a complete repository with deferred content: everything exists, some things get downloaded when you ask for them.
This is the underlying argument. With --depth, Git will lie to you about the history and you will have to remember that it is truncated. With --filter, Git will tell you the truth and download what it is missing.
2. Recovery is transparent. If a shallow clone needs something old, you have to run git fetch --unshallow, which downloads the entire repository in one go. A partial clone fetches only the specific objects it needs, without you intervening.
3. The shallow clone's errors are confusing. This is the classic one in a CI pipeline:
Or worse, in a proposal comparison:
Both fail because the divergence point is outside the downloaded depth. It is the reason so many CI configurations carry fetch-depth: 0 (download everything), which solves the problem the expensive way. The good alternative:
- uses: actions/checkout@v4
with:
fetch-depth: 0
filter: blob:none # complete history, no historical contentComplete history for the comparisons and for describe, without downloading gigabytes of old blobs. This is the recommended CI configuration for large repositories.
When the shallow clone is still better
- The server does not support partial clones.
- A CI job that only builds the current revision and does not query anything from the history:
--depth=1is minimal and sufficient. - A deployment container where you only want the files of a specific version. Although for that,
git archiveis usually better still:
git archive --format=tar.gz --remote=https://git.example.com/team/project.git v1.4.0 > project.tar.gzThat creates no repository: it downloads only the files of that tag.
- Sparse checkout and the sparse index
The partial clone attacks what gets downloaded. sparse-checkout attacks what appears on disk, which is a different problem: dimension 2.
The problem
A monorepo with 300,000 files. Ana works only in services/tasks/, which is 400 of them. But her working copy has all 300,000, and consequently:
git statuswalks 300,000 entries.- The editor indexes 300,000 files.
- Every
git checkoutbetween branches checks 300,000 paths. - Text searches trawl through code that is of no interest to her.
The solution
# Switch on sparse mode, in cone mode
git sparse-checkout init --cone
# Declare which directories I want on disk
git sparse-checkout set services/tasks libraries/ui-components
# See what has been declared
git sparse-checkout list
# Add more without rewriting the previous set
git sparse-checkout add services/notifications
# Go back to the normal state
git sparse-checkout disableAfter this, the working copy contains only those directories (plus the files at the root), and git status goes from walking 300,000 entries to walking a few thousand.
And a more direct way of cloning with this already switched on:
git clone --filter=blob:none --sparse https://git.example.com/team/monorepo.git
cd monorepo
git sparse-checkout set services/tasksThat combination — partial clone + sparse checkout — is the standard setup for working in a large monorepo: you download little and materialise less.
Why cone mode is what scales
sparse-checkout has two modes, and the difference is crucial.
| Original mode (patterns) | Cone mode (--cone) |
|
|---|---|---|
| What it accepts | .gitignore-style patterns, arbitrarily complex |
Directory paths only |
| How each file is decided | By evaluating the pattern list file by file | By checking the directory prefix |
| Cost | O(files × patterns) | O(directories), with prefix lookup |
| Allows a sparse index | No | Yes |
| Expressiveness | High | Limited to complete directories |
Cone mode gives up expressiveness to gain a structural property: because the patterns are always whole directories, Git can reason about complete subtrees instead of about individual files. And that enables the genuinely important thing.
The sparse index
Recall from lesson 01-04 and from 08-06 that the index (.git/index) contains one entry per tracked file. In a monorepo of 300,000 files, the index has 300,000 entries and takes up tens of megabytes. Reading and writing it is the primary cause of git status being slow, and it happens even when the files are not on disk: the index still lists them.
The sparse index solves that. Instead of one entry per file, it stores a single entry per directory that lies outside the cone, pointing straight at its tree object.
Normal index (300,000 entries): services/tasks/app.js services/tasks/index.html ... services/billing/main.js <- outside the cone services/billing/model.js <- outside the cone ... (280,000 more, all outside the cone) Sparse index (~4,000 entries): services/tasks/app.js services/tasks/index.html ... services/billing/ (tree 8a1f6c3d) <- ONE entry services/payments/ (tree 2e9f4c7b) <- ONE entry
It is switched on like this:
And checked with:
git ls-files --sparse | grep '/$' | head
test-tool read-cache --table | wc -l # if you have the test toolsThe effect on git status in a large monorepo is an order of magnitude. And the reason is purely structural: a tree entry represents an entire subtree, thanks to the fact that a tree's hash summarises all its content (lesson 01-04). If the hash of services/billing/'s tree has not changed, Git knows with mathematical certainty that nothing in that subdirectory has changed, without looking at a single file.
This is a lovely example of why understanding the data model matters: the sparse index is not a trick, it is a direct consequence of content addressing.
The effect on git status and other commands
With sparse-checkout active, git status includes a notice:
On branch main You are in a sparse checkout with 3% of tracked files present. nothing to commit, working tree clean
That notice is important and you have to know how to read it. You are not seeing the whole repository. If you look for a file and it does not appear, perhaps it is not that it does not exist: it is outside your cone.
Commands that behave differently:
# Lists only what is in the cone
git ls-files
# Lists EVERYTHING tracked, inside and outside the cone
git ls-files --sparse
# A file outside the cone still exists in the history
git show HEAD:services/billing/main.js # works perfectly
# git grep searches only in what is materialised, by default
git grep "calculateTotal"And the oddity that disconcerts most: if you git checkout a branch that deletes a file outside your cone, you will see nothing, because that file was never on your disk. It is all consistent, but it requires having the model clear.
- Client-side accelerators
These already appeared in lesson 08-06; here they come with the nuance of scale.
commit-graph: speeding up walking the graph
It is an auxiliary index file that stores, precomputed, the topology of the history: for each commit, its parents, its date and its generation number (the distance to the root commit).
# Generate it
git commit-graph write --reachable --changed-paths
# Have it maintain itself
git config fetch.writeCommitGraph true
git config core.commitGraph trueWithout it, answering "is A an ancestor of B?" forces Git to read commit objects from disk and walk the graph. With it, the generation number allows whole branches of the walk to be discarded without reading anything.
The --changed-paths deserves special attention in enormous repositories: it stores a Bloom filter with the paths modified in each commit. It serves to drastically speed up:
In a monorepo, git log on a specific file goes from examining millions of commits to discarding almost all of them with a bit check. It is the difference between thirty seconds and half a second.
It is the dimension 1 optimisation with the best cost-to-benefit ratio, and it has no drawback whatsoever: it is a regenerable cache file that alters nothing in the repository.
fsmonitor: not walking the disk
fsmonitor starts a daemon that listens to filesystem notifications and keeps the list of what has changed. That way git status does not have to walk the directories: it asks the daemon.
untrackedCache stores in the index the last scan of each directory along with its timestamp, so as not to rescan the ones that have not been touched.
The combined effect in a repository of 100,000 files, as an order of magnitude: git status can go from several seconds to fractions of a second. The combination with the sparse index is multiplicative, because they attack different parts of the cost: fsmonitor reduces what has to be looked at on disk, the sparse index reduces what has to be read from the index.
A platform note: the built-in fsmonitor works on macOS and Windows out of the box; on Linux it depends on the Git version and may require additional configuration. Measure it before and after in your specific environment.
git maintenance: letting it look after itself
It schedules periodic tasks in the system scheduler: updating the commit-graph, packing loose objects incrementally, prefetching from the remotes and cleaning up references.
Compared with the traditional automatic gc, it has two advantages: it runs when you are not working rather than interrupting you, and it includes tasks gc does not do, such as the prefetch (which downloads in the background what others publish, so that your git fetch is instantaneous).
Recommended configuration for a large repository:
git maintenance register
git config maintenance.commit-graph.enabled true
git config maintenance.prefetch.enabled true
git config maintenance.incremental-repack.enabled true
git config maintenance.loose-objects.enabled true
git config maintenance.gc.enabled false # the ones above replace it
git maintenance startBundled settings
Git offers two configuration "packages" that switch several things on at once:
# Repositories with many files
git config feature.manyFiles true
# The version's recommended experimental settings
git config feature.experimental truefeature.manyFiles switches on index.version=4 (a more compact index format), core.untrackedCache=true and index.skipHash=true. It is the reasonable shortcut for dimension 2. Exactly what it does may change between Git versions, so it is worth checking with git help config.
- Monorepo versus multi-repo
Here the comparison that lesson 06-05 left open and that case 3 of 10-01 left pending is closed.
The two positions
Multi-repo: each project or library has its own repository. The relationship between them is established with published versions (packages), with submodules or with subtree.
Monorepo: a single repository contains many projects, which relate to each other by directory path and are built together.
flowchart TB
subgraph multi["Multi-repo"]
M1["repo: web-app"] -->|"depends on v2.1.0"| M4["repo: ui-components"]
M2["repo: data-service"] -->|"depends on v2.0.3"| M4
M3["repo: mobile-app"] -->|"depends on v1.9.0"| M4
end
subgraph mono["Monorepo"]
R["single repo"] --- A["/web-app"]
R --- B["/data-service"]
R --- C["/mobile-app"]
R --- D["/libraries/ui-components"]
end
Notice the versions in the left-hand diagram: three consumers using three different versions of the same library. That is normal in a multi-repo, and it is at once its greatest advantage (autonomy) and its greatest problem (drift).
The complete comparison
| Criterion | Monorepo | Multi-repo |
|---|---|---|
| Atomic change across projects | Yes: one commit changes the library and its 30 consumers. There is never an inconsistent state | No: 31 coordinated proposals, transitional versions, weeks |
| Global refactoring | Search and replace + one commit | A project in its own right |
| Versioning of internal dependencies | Does not exist: everybody uses main |
Each consumer pins a version; drift appears |
| Team autonomy | Lower: they share main, CI and tooling |
Higher: each team decides its own pace and releases |
| Code ownership | Needs CODEOWNERS by directory (10-01) |
Natural: by repository |
| Access control | Hard to make granular: whoever clones sees everything | Natural: permissions per repository |
| Continuous integration | Has to be selective, computing what is affected | Simple: each repository, its own |
| Repository size | Grows without limit; demands this lesson's techniques | Each one stays small |
| Tooling required | Build system with a dependency graph, selective CI, Git scaling | Package manager, artefact registry |
| Code discovery | Excellent: everything is searchable | Hard to know what exists and where |
| Onboarding newcomers | One (large) clone and you have everything | You have to work out which repositories to clone |
| Releasing | Continuous; there is no global version | Independent versions per project |
git bisect across projects |
Works: one single history | Impossible without manual coordination |
| History | Enormous and mixed; you have to filter by path | Clean and specific per project |
| Cost of starting | High: it does not work without investing in tooling | Low: it is what you get by default |
When to choose each
A monorepo makes sense when:
- The projects change together often: the unmistakable symptom is that a proposal needs coordinated changes in several repositories.
- There are shared internal libraries and version drift already hurts.
- Everybody can see all the code.
- There is capacity to invest in tooling and to maintain it.
- You want global refactoring to be possible.
A multi-repo makes sense when:
- The projects are genuinely independent and evolve at different rates.
- There are access requirements that force separation.
- The teams are autonomous and release on their own account.
- Some components are published outside the organisation.
- There is no capacity to maintain in-house tooling.
- You prefer the solution that works on its own.
The question that decides it
Of all the criteria, one weighs more than the rest:
How often does a change need to touch several repositories at once?
If the answer is "hardly ever", multi-repo, without hesitation. If it is "constantly, and it is our biggest bottleneck", the monorepo solves exactly that problem and is worth the investment.
And remember the conclusion of lesson 06-05: submodules do not solve this. They tie a specific version of a library to a consumer — they give traceability — but every update is still a commit in every consumer. They are a multi-repo with traceability, not a cheap monorepo.
The middle road
It is neither a binary nor an irreversible decision. The most common intermediate patterns:
-
Monorepo by domain. One repository per large area (
platform,product,data), not one per service nor one for everything. It captures most of the atomicity benefit without reaching scales that demand exotic tooling. It is the sweet spot for most medium-sized organisations. -
Monorepo for the shared libraries. Only the internal libraries are grouped, which are the ones suffering version drift, and the products stay separate. It is a reversible step with immediate benefit.
-
Gradual migration. One repository can be merged into another preserving its history:
cd ~/monorepo
git remote add ui-components https://git.example.com/team/ui-components.git
git fetch ui-components
# Bring in its history relocated under a subdirectory
git merge -s ours --no-commit --allow-unrelated-histories ui-components/main
git read-tree --prefix=libraries/ui-components/ -u ui-components/main
git commit -m "chore: GT-270 bring in ui-components as libraries/ui-components
The complete history of the original repository is preserved."The combination of merge -s ours + read-tree --prefix is the classic mechanism: the merge connects the two histories without bringing in content, and the read-tree places the other repository's tree under the given prefix. The result is that git log --follow libraries/ui-components/button.js carries on working backwards. It is also, essentially, what git subtree add does underneath (lesson 06-05).
- What it takes for a monorepo to work
If the decision is a monorepo, these pieces are not optional. A monorepo without them is simply a slow and chaotic repository.
- Git scaling (everything above)
# Standard clone for the team
git clone --filter=blob:none --sparse https://git.example.com/team/monorepo.git
cd monorepo
git sparse-checkout set services/tasks libraries/ui-components
# Accelerators
git config core.fsmonitor true
git config core.untrackedCache true
git config index.sparse true
git config feature.manyFiles true
git maintenance startThis is best put into an onboarding script in the repository itself, because nobody is going to remember six commands.
- Code ownership
The CODEOWNERS file we saw in lesson 10-01. Without it, the monorepo loses the clear ownership a multi-repo gives away for free.
- Selective continuous integration
We already saw the skeleton in 10-01. The version that matters here is the one based on the dependency graph, not on directories:
name: Monorepo selective CI
on: [pull_request]
jobs:
affected:
runs-on: ubuntu-latest
outputs:
projects: ${{ steps.calc.outputs.list }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
filter: blob:none
- id: calc
run: |
# Paths changed since the divergence point
CHANGES=$(git diff --name-only origin/main...HEAD)
# The build system translates paths -> affected projects,
# including those that depend on what has changed
AFFECTED=$(./tools/affected.sh $CHANGES)
echo "list=$AFFECTED" >> "$GITHUB_OUTPUT"
echo "Affected projects: $AFFECTED"
tests:
needs: affected
if: needs.affected.outputs.projects != ''
runs-on: ubuntu-latest
strategy:
matrix:
project: ${{ fromJson(needs.affected.outputs.projects) }}
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: |
${{ matrix.project }}
libraries
- run: ./tools/test.sh ${{ matrix.project }}Notice the sparse-checkout inside the CI job: each run materialises only the project it is going to test. It is the same technique as section 5, applied to the pipeline.
What affected.sh does is what distinguishes a good selective CI from a naive one: if libraries/ui-components changes, it is not enough to test that library; every project that depends on it has to be tested. That demands an explicit dependency graph, which the build system provides.
- A build system with a cache
In a monorepo, building everything on every change is unfeasible. You need a system that understands the dependency graph and that caches results by the hash of the inputs: if nothing going into a target has changed, the previous result is reused.
It is, curiously, the same principle that underpins Git: identify by the hash of the content and do not recompute what has not changed.
- Conventions and migration automation
With many projects in one repository, strong conventions are needed: a homogeneous directory structure, predictable names, and tools for applying mass changes ("rename this function in 200 places") automatically and reviewably.
- When none of this is needed
This section is as important as the previous ones.
task-manager needs nothing from this lesson. No partial clones, no sparse-checkout, no fsmonitor, no monorepo. It has nine files, 412 commits and weighs 2 MB. git status answers in milliseconds.
And it is not an exceptional case: the vast majority of the world's repositories are in that situation. A project with 5,000 files, 30,000 commits and 200 MB runs perfectly with Git out of the box on any modern computer.
The cost of optimising without needing to
| Technique | Cost if you do not need it |
|---|---|
| Partial clone | Unexpected latency in ordinary operations; strange behaviour offline; requires a compatible server |
sparse-checkout |
Files that "do not exist" and confuse everybody; tools that fail without explanation; grep that fails to find things that are there |
--depth |
Truncated history, bisect unusable, proposal comparisons that fail |
| Monorepo | A large repository with none of the advantages, because the projects were not changing together |
fsmonitor |
One more daemon consuming resources, with no measurable benefit |
The most dangerous technique on the list is sparse-checkout. A newcomer who inherits a sparse configuration without knowing it can spend hours failing to understand why a file they can see on the web is not on their disk.
The rule
If
git statusanswers in less than a second andgit clonetakes less than a minute, you do not have any problem these techniques solve.
Measure first (section 2). Apply the remedy of the dimension that hurts. Measure afterwards. And if nothing hurts, do nothing.
- A recipe per symptom
A quick reference table.
| Symptom | Dimension | Diagnosis | Remedy |
|---|---|---|---|
git clone takes forever and the repository weighs GB |
3 (and/or 1) | git count-objects -vH; look for large blobs |
LFS (10-03); --filter=blob:none; take binaries out |
git status takes seconds |
2 | GIT_TRACE2_PERF shows dir:untracked high |
fsmonitor, untrackedCache, feature.manyFiles; if it persists, sparse-checkout --cone --sparse-index |
git log -- path/file takes forever |
1 | Many commits | commit-graph write --reachable --changed-paths |
git blame takes forever |
1 | Many revisions of the file | commit-graph; blame -w -M does not help performance |
git checkout between branches is slow |
2 | Many files to update | sparse-checkout; fsmonitor |
| CI clones 8 GB fifty times a day | 1 and 3 | Pipeline configuration | filter: blob:none + fetch-depth: 0; repository cache; lfs: false (10-03) |
git gc takes an hour |
3 | Large incompressible objects | LFS; incremental git maintenance instead of a full gc |
| A change needs 12 coordinated proposals | Organisational | Multi-repo with strong coupling | Consider a monorepo or a monorepo by domain |
| The repository is fine | None | — | Do nothing |
Common Mistakes and Tips
Mistake 1: optimising without measuring. The central mistake of the lesson. Applying sparse-checkout to a history problem does nothing. Diagnose the dimension first.
Mistake 2: using --depth=1 in CI and then needing the history. It produces fatal: no merge base and fatal: No names found. Use fetch-depth: 0 with filter: blob:none.
Mistake 3: sparse-checkout without --cone. Pattern mode is slower, does not allow a sparse index and is far easier to misconfigure. Use --cone unless you have a very specific reason.
Mistake 4: forgetting that sparse-checkout is active. Document the sparse configuration in the README.md and remember that git status warns with "You are in a sparse checkout with N% of tracked files present".
Mistake 5: believing a partial clone works offline. If you work on a plane with a partial clone and run git log -p over old history, it will fail: it needs to go to the server. Before disconnecting, fetch what you are going to need:
Mistake 6: choosing a monorepo because it is fashionable. Without selective CI, without CODEOWNERS and without a build system with a cache, a monorepo is worse than what you had. The question is not "what do the big companies do?", but "how often does one of our changes touch several repositories?".
Mistake 7: believing submodules are a cheap monorepo. They are not: they do not give atomicity. Every update of the library still demands a commit in every consumer (lesson 06-05).
Tip 1: commit-graph for everybody. It is the only optimisation in the lesson with no drawback at all. Switch it on even if your repository is medium-sized:
Tip 2: git maintenance start on your large repositories. Better than waiting for the automatic gc, and without interrupting you.
Tip 3: measure with hyperfine or with a simple loop. Before and after, several times:
Tip 4: put an onboarding script in the repository. If your monorepo needs six configuration commands, put them in tools/setup.sh and mention it in the README.md.
Tip 5: CI can use sparse checkout too. Modern workflows allow you to declare which directories to materialise. In a monorepo, that reduces each job from minutes to seconds.
Exercises
Exercise 1: diagnosis by dimension
For each repository, say which dimension it suffers from, what would NOT fix it and what would:
A.
$ git rev-list --count --all 1847293 $ git ls-files | wc -l 1204 $ git count-objects -vH size-pack: 1.84 GiB $ time git status real 0m0.089s $ time git log --oneline -- src/core.c real 0m47.203s
B.
$ git rev-list --count --all 8420 $ git ls-files | wc -l 284917 $ git count-objects -vH size-pack: 3.21 GiB $ time git status real 0m41.887s
C.
$ git rev-list --count --all 1204 $ git ls-files | wc -l 89 $ git count-objects -vH size-pack: 6.72 GiB $ time git clone . real 8m12.443s
Exercise 2: setting up a workstation in a monorepo
Ana joins a team with a monorepo: 340,000 files, 2.1 million commits, 12 GB. She will work only in services/tasks/ and will use libraries/ui-components/.
Write the complete sequence of commands, from the clone to a working setup, explaining what each one attacks and which dimension. Include also what you would tell her about the oddities she is going to run into.
Exercise 3: deciding monorepo or multi-repo
A 120-person company has 23 repositories: 8 services, 6 client applications, 9 internal libraries.
The symptoms they report:
- Changing the interface of the
authenticationlibrary takes six weeks to reach all its consumers. - There are four different versions of
ui-componentsin production at once. - Nobody knows which services use which libraries.
- A new contributor takes three days to clone and configure what they need.
- Two of the services are maintained by an external company that must not see the rest of the code.
- The
chartsandutilslibraries are published publicly as open source.
Decide what you would recommend, with which steps and in which order. Point out explicitly what you would not put into the monorepo and why.
Solutions
Solution 1
A. Pure dimension 1: lots of history.
Evidence: 1.8 million commits but only 1,204 files. git status answers in 89 ms (dimension 2 does not exist). The repository weighs 1.84 GB, which is consistent with 1.8 million commits of text code, not with binaries.
The decisive symptom: git log on a file takes 47 seconds. Git has to walk millions of commits checking whether each one touched src/core.c.
What would NOT fix it: sparse-checkout (there are only 1,204 files and status is already instantaneous), LFS (there are no binaries), fsmonitor (nothing to walk on disk).
What would:
# The main remedy: Bloom filters of modified paths
git commit-graph write --reachable --changed-paths
git config core.commitGraph true
git config fetch.writeCommitGraph true
# Have it maintain itself
git maintenance start
# And for new clones, above all in CI
git clone --filter=blob:none https://git.example.com/team/project.gitWith --changed-paths, that 47-second git log -- src/core.c should drop to under a second: the Bloom filter discards the vast majority of the commits without opening their trees.
B. Dimension 2 dominant: lots of files.
Evidence: 285,000 files, only 8,420 commits (the history is short), and git status takes 42 seconds. The 3.21 GB is consistent with many files, not necessarily with large binaries.
What would NOT fix it: commit-graph (with 8,420 commits, the history is not the problem), a shallow clone (nor that).
What would, in order from least to most intrusive:
# 1. First the cheap things with no side effects
git config core.untrackedCache true
git config core.fsmonitor true
git config feature.manyFiles true
# 2. Measure again
for i in 1 2 3; do /usr/bin/time -f "%e s" git status >/dev/null; done
# 3. If it still hurts, materialise only what is needed
git sparse-checkout init --cone --sparse-index
git config index.sparse true
git sparse-checkout set my/work/areaThe order matters: steps 1 and 2 do not change what you see on disk and may resolve it. Step 3 does change it and has a comprehension cost for the whole team.
It is also worth checking whether those 3.21 GB hide a dimension 3 problem:
C. Pure dimension 3: very large files.
Unmistakable evidence: 89 files, 1,204 commits, 6.72 GB. The arithmetic leaves no room for doubt: 6.72 GB across 89 files is about 75 MB per file on average. They are binaries.
What would NOT fix it: absolutely nothing from sparse-checkout or commit-graph. Not even a shallow clone would help much, because the large blobs of the latest revision already weigh what they weigh.
What would:
# 1. Confirm the diagnosis
git lfs migrate info --everything --above=1Mb
# 2. Decide file by file (lesson 10-03):
# generated? -> .gitignore
# history not needed? -> outside the repository
# a product binary with history? -> LFS
# 3. Migrate, with all the coordination of lesson 10-03
git lfs migrate import --everything --include="*.psd,*.mp4,*.zip"
# 4. An immediate palliative while the migration is being decided
git clone --filter=blob:none https://git.example.com/team/project.gitStep 4 is a useful relief: the partial clone avoids downloading all the historical versions of the binaries, even though it does not fix the size of the repository on the server side.
Solution 2
# ============================================================
# 1. CLONE: partial + sparse from the outset
# Dimensions 1 and 3 (what gets downloaded)
# ============================================================
git clone --filter=blob:none --sparse \
https://git.example.com/team/monorepo.git
cd monorepo--filter=blob:none avoids downloading the historical content of 2.1 million commits: from 12 GB we go down to a small fraction, keeping the complete graph. --sparse makes the initial checkout materialise only the root, instead of writing 340,000 files to disk.
# ============================================================
# 2. CONE: what gets materialised on disk
# Dimension 2 (what is in the working copy)
# ============================================================
git sparse-checkout init --cone --sparse-index
git config index.sparse true
git sparse-checkout set services/tasks libraries/ui-components
git sparse-checkout list--cone allows the sparse index; --sparse-index and index.sparse make the index store one entry per directory outside the cone instead of one per file. From ~340,000 entries down to a few thousand.
# ============================================================
# 3. CLIENT-SIDE ACCELERATORS
# ============================================================
git config core.fsmonitor true # dimension 2: do not walk the disk
git config core.untrackedCache true # dimension 2: cache the scan
git config feature.manyFiles true # dimension 2: index v4, skipHash
git config core.commitGraph true # dimension 1: walking the graph
git config fetch.writeCommitGraph true
# ============================================================
# 4. AUTOMATIC MAINTENANCE
# ============================================================
git maintenance register
git config maintenance.prefetch.enabled true
git config maintenance.commit-graph.enabled true
git config maintenance.incremental-repack.enabled true
git maintenance start
# ============================================================
# 5. VERIFY
# ============================================================
git ls-files | wc -l # only what is in the cone
git ls-files --sparse | wc -l # real index entries
for i in 1 2 3; do /usr/bin/time -f "%e s" git status >/dev/null; doneWhat I would tell Ana about the oddities:
-
"You are not going to see most of the files."
git statuswill warn you: "You are in a sparse checkout with 2% of tracked files present". They are not deleted: they are not materialised.git show HEAD:services/billing/main.jsworks perfectly. -
"If you need another area, add it."
git sparse-checkout add services/notifications. There is no need to clone again. -
"Some operations will go to the network."
git log -pover old history,git blameon a file with many versions, orgit checkoutof an old tag will download blobs. The first time is slow; after that it stays cached. -
"Before going offline, fetch what you are going to need." A partial clone needs the server for what it does not have.
-
"Your editor and your searches only see your cone." That is good for performance, but if you search for a function and it does not appear, perhaps it is outside your area.
git grep --no-indexor searching on the repository's website are the way out. -
"If something behaves oddly, look at
git sparse-checkout listfirst." It is the prime suspect for nearly any oddity.
And I would hand her a tools/setup-workstation.sh script with all of the above, because nobody remembers fourteen commands.
Solution 3
Recommendation: a partial monorepo, in three phases, starting with the libraries.
The dominant symptom is unmistakable: six weeks to propagate a library change and four versions of ui-components in production. That is exactly the problem the monorepo eliminates at the root, and it confirms there is strong coupling between the libraries and their consumers.
But there are two constraints that rule out a total monorepo, and they have to be respected.
What does NOT go into the monorepo, and why:
| What | Why it stays outside |
|---|---|
| The 2 external company's services | Access control. A monorepo gives access to everything to whoever clones it. It is the hardest constraint and it is not negotiable: these are contractual or confidentiality requirements, not technical preferences. |
The charts and utils libraries |
They are published publicly. An open source project needs its own repository: a clean, specific history, its own issues and proposals, and no risk of exposing internal code. They enter the monorepo as a versioned external dependency, just like any third-party package. |
Phase 1: a monorepo of internal libraries (7 libraries).
It is the step with the best benefit-to-risk ratio, and it is reversible.
# Create the repository and absorb each library preserving its history
mkdir platform && cd platform && git init
for LIB in authentication ui-components data logging queues config validation; do
git remote add "$LIB" "https://git.example.com/team/$LIB.git"
git fetch "$LIB"
git merge -s ours --no-commit --allow-unrelated-histories "$LIB/main"
git read-tree --prefix="libraries/$LIB/" -u "$LIB/main"
git commit -m "chore: bring in $LIB preserving its history"
doneImmediate benefit: a change that touches authentication and data at once becomes one commit, and git bisect works across libraries.
These libraries carry on being published as versioned packages for their consumers, so nothing changes for the 12 products. That is why it is reversible.
Phase 2: absorb the most tightly coupled consumers.
Measure first which they are:
# Which proposals from the last six months touched several repositories
# on the same day and for the same ticket?
for REPO in service-a service-b web-app mobile-app; do
echo "== $REPO"
git -C "$REPO" log --since=6.months --format='%ad %s' --date=short \
| grep -oE '^[0-9-]+ .*(TICKET-[0-9]+)' | head
doneThe ones that turn up repeatedly alongside library changes are the candidates. Those get absorbed, not all of them.
Phase 3: consolidate the rest, if the experience has been good.
Compulsory investment before phase 2 (without this, the monorepo makes things worse):
CODEOWNERSby directory — recovers the ownership the multi-repo gave away for free.- Selective CI with a dependency graph — without it, every proposal runs the entire suite.
- A build system with caching by input hash — or the build times go through the roof.
- A workstation setup script — with
--filter=blob:none --sparse, as in solution 2.
What each reported symptom becomes:
| Symptom | How it ends up |
|---|---|
| Six weeks to propagate a library change | Solved: one atomic commit updates the library and its consumers |
Four versions of ui-components in production |
Solved: there are no internal versions, everybody uses main |
| Nobody knows what uses what | Solved: the dependency graph is explicit and searchable |
| Three days of initial setup | Solved: a partial, sparse clone, with a script |
| The external company must not see the rest | Respected: their 2 services stay outside |
charts and utils are public |
Respected: they stay outside, as versioned dependencies |
Final result: 1 monorepo (7 libraries + whichever internal products get absorbed), 2 private repositories for the external company, 2 public repositories. From 23 repositories down to 5, without violating any constraint.
What I would NOT do: put it all in at once. Phase 1 is reversible and gives benefit within weeks; a total migration of 23 repositories without tooling is a well-established way of ending up with a 40 GB repository, a two-hour CI and a furious team.
Conclusion
Scaling Git starts with no longer treating it as a single problem.
- There are three independent dimensions: lots of history (
git logandblameslow), lots of files (git statusslow) and very large files (a repository of gigabytes). Each one has its symptom, its measurement and its remedy, and applying the wrong remedy fixes nothing. They are diagnosed withgit rev-list --count --all,git ls-files | wc -landgit count-objects -vH, and refined withGIT_TRACE2_PERF. - Partial clones are the most useful and least known technique:
--filter=blob:nonedownloads the complete graph and omits the historical content, requesting it on demand;--filter=tree:0omits the trees as well. They preservelog,bisect,describeandmerge-base. - Compared with the shallow clone
--depth, the partial one is almost always better:--depthproduces an incomplete repository in which commits are missing and operations fail (fatal: no merge base), whereas the partial one produces a complete repository with deferred content. For CI:fetch-depth: 0+filter: blob:none. sparse-checkout --conelimits what gets materialised on disk, and the sparse index stores one entry per directory outside the cone instead of one per file. Cone mode gives up expressiveness in order to be able to reason about complete subtrees, something only possible because a tree's hash summarises all its content (lesson 01-04).- The client-side accelerators —
commit-graph --changed-paths,fsmonitor,untrackedCache,feature.manyFilesandgit maintenance— attack different parts of the cost and combine well. Thecommit-graphis the only one with no drawbacks: always switch it on. - Monorepo versus multi-repo is decided with a single question: how often does a change need to touch several repositories at once? The monorepo buys atomicity and global refactoring; it pays with in-house tooling,
CODEOWNERS, selective CI and Git scaling. And there are middle roads: monorepo by domain, a monorepo of libraries only, gradual migration withmerge -s ours+read-tree --prefix. - And most importantly: most repositories need none of this.
task-managerhas nine files, 412 commits and 2 MB. Applyingsparse-checkoutto it would be adding confusion in exchange for nothing.
The rule that sums up the lesson, and which is the one from 08-06 taken to another scale:
Measure, diagnose the dimension, apply that dimension's remedy, and measure again. If
git statusanswers in less than a second, you do not have any problem these techniques solve.
What is coming
We now know how to operate Git in repositories of any size. One last piece of the real world remains, and it is the one that turns Git into something more than a developers' tool.
In lesson 07-06 we drew a boundary: there we talked about continuous integration — automatically checking that what gets integrated works — and left for later how that code reaches users. In 07-04 too we deferred the mechanics of environments and promotion, and in 05-05, when tagging v1.4.0, we said that annotated tags would be the piece that triggers a deployment.
All of that converges here. In many organisations, Git is no longer used only by one person at a terminal: it is used by a hundred pipelines that clone, tag, build and publish with no human intervention. The repository stops containing only code and comes to contain the definition of the environment, the configuration and the infrastructure. And a git push to the right branch stops meaning "I have saved my work" and comes to mean "this is in production in four minutes".
Lesson 10-05: Git in DevOps delivers on that promise: Git as the single source of truth, the three rungs of CI, delivery and deployment, what triggers each one, why the artefact must be identified by the commit hash, what GitOps is, where secrets live in an automated world, and why rolling back in production is almost never a git revert.
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
