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

  1. The three dimensions of growth
  2. Measure before optimising (and what to measure in each case)
  3. Partial clones: --filter=blob:none and --filter=tree:0
  4. Partial clone versus shallow clone
  5. Sparse checkout and the sparse index
  6. Client-side accelerators
  7. Monorepo versus multi-repo
  8. What it takes for a monorepo to work
  9. When none of this is needed
  10. A recipe per symptom

  1. 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"]

  1. 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

# Size and composition of the repository (lesson 08-06)
git count-objects -vH
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 -10

Three 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

GIT_TRACE2_PERF_BRIEF=1 GIT_TRACE2_PERF=/dev/stdout git status 2>&1 | head -20
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 MiB

None of the three dimensions. Nothing in this lesson applies to it.

  1. Partial clones: --filter=blob:none and --filter=tree:0

The 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

git clone --filter=blob:none https://git.example.com/team/large-project.git

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 --all

What 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 revision

Seeing it in action:

git blame app.js
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:

git rev-list --objects --all --missing=print | grep '^?' | wc -l

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

git clone --filter=tree:0 https://git.example.com/team/large-project.git

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:none

blob: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.

  1. 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:

git clone --depth=1 https://git.example.com/team/project.git
cd project
git describe --tags
fatal: No names found, cannot describe anything.

Or worse, in a proposal comparison:

git diff origin/main...HEAD
fatal: no merge base

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 content

Complete 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=1 is minimal and sufficient.
  • A deployment container where you only want the files of a specific version. Although for that, git archive is usually better still:
git archive --format=tar.gz --remote=https://git.example.com/team/project.git v1.4.0 > project.tar.gz

That creates no repository: it downloads only the files of that tag.

  1. 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 status walks 300,000 entries.
  • The editor indexes 300,000 files.
  • Every git checkout between 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 disable

After 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/tasks

That 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:

git config index.sparse true
git sparse-checkout init --cone --sparse-index

And checked with:

git ls-files --sparse | grep '/$' | head
test-tool read-cache --table | wc -l   # if you have the test tools

The 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:

git status
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.

  1. 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 true

Without 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:

git log -- services/tasks/app.js
git log --follow path/to/file

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

git config core.fsmonitor true
git config core.untrackedCache true

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

git maintenance start

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 start

Bundled 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 true

feature.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.

  1. 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:

  1. 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.

  2. 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.

  3. 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).

  1. 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.

  1. 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 start

This is best put into an onboarding script in the repository itself, because nobody is going to remember six commands.

  1. 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.

  1. 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.

  1. 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.

  1. 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.

  1. 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 status answers in less than a second and git clone takes 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.

  1. 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:

git rev-list --objects --all --missing=print | grep -c '^?'

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:

git config --global fetch.writeCommitGraph true
git config --global core.commitGraph true

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:

for i in 1 2 3 4 5; do /usr/bin/time -f "%e s" git status >/dev/null; done

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 authentication library takes six weeks to reach all its consumers.
  • There are four different versions of ui-components in 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 charts and utils libraries 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.git

With --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/area

The 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:

git lfs migrate info --everything --above=5Mb

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.git

Step 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; done

What I would tell Ana about the oddities:

  1. "You are not going to see most of the files." git status will 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.js works perfectly.

  2. "If you need another area, add it." git sparse-checkout add services/notifications. There is no need to clone again.

  3. "Some operations will go to the network." git log -p over old history, git blame on a file with many versions, or git checkout of an old tag will download blobs. The first time is slow; after that it stays cached.

  4. "Before going offline, fetch what you are going to need." A partial clone needs the server for what it does not have.

  5. "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-index or searching on the repository's website are the way out.

  6. "If something behaves oddly, look at git sparse-checkout list first." 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"
done

Immediate 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
done

The 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):

  1. CODEOWNERS by directory — recovers the ownership the multi-repo gave away for free.
  2. Selective CI with a dependency graph — without it, every proposal runs the entire suite.
  3. A build system with caching by input hash — or the build times go through the roof.
  4. 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 log and blame slow), lots of files (git status slow) 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 with git rev-list --count --all, git ls-files | wc -l and git count-objects -vH, and refined with GIT_TRACE2_PERF.
  • Partial clones are the most useful and least known technique: --filter=blob:none downloads the complete graph and omits the historical content, requesting it on demand; --filter=tree:0 omits the trees as well. They preserve log, bisect, describe and merge-base.
  • Compared with the shallow clone --depth, the partial one is almost always better: --depth produces 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 --cone limits 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 acceleratorscommit-graph --changed-paths, fsmonitor, untrackedCache, feature.manyFiles and git maintenance — attack different parts of the cost and combine well. The commit-graph is 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 with merge -s ours + read-tree --prefix.
  • And most importantly: most repositories need none of this. task-manager has nine files, 412 commits and 2 MB. Applying sparse-checkout to 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 status answers 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

Module 2: Basic Git Operations

Module 3: Branching and Merging

Module 4: Working with Remote Repositories

Module 5: Advanced Git Operations

Module 6: Git Tools and Techniques

Module 7: Collaboration and Workflow Strategies

Module 8: Git Best Practices and Tips

Module 9: Troubleshooting and Debugging

Module 10: Git in the Real World

© Copyright 2026. All rights reserved