Skip to content

Git Monorepos: Complete Engineering Guide

Lesson 1 of 10Advanced16 min readGit at Scale & Enterprise Engineering · Large RepositoriesVerified: Git sparse-checkout and maintenance documentation, and GitHub repository limits, September 2026

A monorepo is not a big repository. Those two things are correlated and they are not the same claim, and conflating them causes teams to adopt monorepo tooling for a problem it does not solve.

A monorepo is one Git repository containing multiple logical projects or components that an organisation chooses to version together.

The operative word is chooses. A ten-year-old application with 400,000 commits is enormous and contains one project. It has Git-performance problems, and nothing in this article applies to it. A repository with five small services clones in three seconds and is a monorepo.

One repository, several logical projects

A node labelled one repository branching to six contents: service A, service B, library C, frontend, infrastructure and tooling.

One Repository │ ├── Service A → deployed independently ├── Service B → deployed independently ├── Library C → consumed by A and B ├── Frontend → different language, different pipeline ├── Infrastructure → the environments they run in └── Tooling → the build and dev scripts

The components have independent lifecycles. Service A deploys on Tuesday and Service B on Thursday. They share a repository and not a release.

That independence is the point and the difficulty. One repository means one commit history, one set of refs, one clone — and several deployment cadences, several owners, several test suites.

Almost every argument for a monorepo reduces to one thing: atomic cross-component change.

A change to Library C and its two consumers is one commit, one pull request, one review, one merge. The repository never contains a state where the library changed and its callers did not.

In a polyrepo the same change is three pull requests, with an ordering somebody must get right, a version bump in between, and a window during which the library has shipped and its consumers have not.

Everything else follows from that, and the secondary benefits are real:

One version of everything. No repository is on an eighteen-month-old copy of the shared library, because there is only one copy.

Refactoring is possible. Renaming a function used by forty components is a single mechanical change. In a polyrepo it is a migration programme with a deprecation period.

Discovery is trivial. Every engineer has every project on disk. Grep works.

One toolchain. One linter configuration, one CI system, one way to run tests.

The central point of this article. Git provides version control. It provides none of the following, and a monorepo without them is worse than the polyrepo it replaced.

The apparatus a monorepo requires

Six supporting systems listed: CODEOWNERS, sparse checkout, partial clone, path-aware CI, build graph and repository policy.

Git gives you: one history, one clone, atomic commits You must build: CODEOWNERS → who reviews which paths Sparse Checkout → what a developer materialises on disk Partial Clone → what gets downloaded at all Path-Aware CI → what runs for a given change Build Graph → what genuinely needs rebuilding Repository Policy → what is permitted, per path

Each is a real engineering investment, and skipping any one produces a recognisable failure.

The first thing to break, and the one that breaks silently.

In a polyrepo, ownership is the repository. Who reviews a change to the payments service? Whoever has access to the payments repository. The boundary does the work.

In a monorepo everybody can change everything, which is the point and which removes the implicit reviewer assignment. Without a replacement, a pull request touching payments code gets reviewed by whoever was available, and that person does not know the payments domain.

CODEOWNERS is the replacement, and in a monorepo it is load-bearing rather than a convenience:

# Default — the platform team sees anything unclaimed
* @example-org/platform
/services/payments/ @example-org/payments
/services/checkout/ @example-org/checkout
/libs/shared-auth/ @example-org/security @example-org/platform
/frontend/ @example-org/web
/infrastructure/ @example-org/platform
/tooling/ @example-org/developer-experience
# Cross-cutting, wherever it appears
**/*.tf @example-org/platform
**/Dockerfile @example-org/platform
/.github/workflows/ @example-org/platform-leads

Only the last matching pattern applies — the rules do not combine. A file matched by both /services/payments/ and **/Dockerfile gets whichever pattern appears later, which is why the cross-cutting rules sit at the bottom.

Routing is not enforcement. CODEOWNERS requests reviewers; a ruleset requiring code owner review is what makes it a control. Teams add the file, watch reviewers get requested, and reasonably conclude a control exists when none does.

A shared library needs two owners. /libs/shared-auth/ above requires both the security team and the platform team, because a change there reaches every consumer.

The second thing to break, and it breaks visibly.

Every engineer gets every file. A frontend developer checks out the machine-learning training pipeline, the infrastructure definitions and eleven services they will never open. git status walks all of it.

Sparse checkout is the answer, and deploying it to hundreds of engineers is a programme rather than a command — which is why it has its own article.

The short version of the design: define personas, generate a cone per persona, ship the configuration in the onboarding tooling, and measure whether checkout times actually improved.

Terminal window
git sparse-checkout set --cone \
services/payments \
libs/shared-auth \
tooling

Cone mode is the mode to use. Git’s documentation is direct that non-cone mode is deprecated and that users should switch to cone mode.

The complement, and the one most often confused with sparse checkout.

Sparse checkout changes what is written to disk. Partial clone changes what is downloaded. A sparse checkout of a 40 GB repository still downloads 40 GB.

Terminal window
git clone --filter=blob:none --sparse https://github.com/example-org/monorepo.git

--filter=blob:none fetches commits and trees but no file contents; blobs arrive on demand. History remains complete, which matters because shallow clone would not preserve it.

Together these are the standard monorepo developer setup: full history, few objects downloaded, few files on disk.

The third thing to break, and the most expensive.

In a polyrepo, CI scope is free. A push to the payments repository runs the payments pipeline. Nothing else knows.

In a monorepo every push is a push to everything, and the naive implementation runs every test suite for every change. A one-line frontend change triggers the machine-learning integration tests.

Path filtering is the first fix:

on:
pull_request:
paths:
- 'services/payments/**'
- 'libs/shared-auth/**'
- '.github/workflows/payments.yml'

And it is not sufficient, because a change to libs/shared-auth/ affects every consumer, and path filters do not know the dependency graph. That is the shared-code problem, and there are two answers: maintain an explicit dependency map, or run everything when shared paths change. The second is cruder and much harder to get wrong.

A build graph is the real answer at scale. A build system that understands which targets depend on which files can compute the affected set precisely, and cache the rest. That is a genuine investment, and it is the point at which a monorepo requires a build-system decision rather than just a CI decision.

The economics. A polyrepo pays a small CI cost per repository per change. A monorepo pays either a large cost per change or the cost of building the machinery to avoid it. There is no configuration that makes it free, and the machinery is the cheaper of the two at any meaningful scale — but it is a capital cost paid up front against an operating cost paid continuously, and organisations consistently underestimate how quickly the second overtakes the first.

Clone cost compounds too. Every CI run clones the monorepo, which is the largest repository the organisation has. That is the specific case CI clone optimization addresses, and in a monorepo it is frequently the single largest line in the CI bill.

A constraint people meet later than they expect.

GitHub documents a recommended maximum on-disk repository size of 10 GB for the compressed .git directory, a hard 100 MB limit on individual objects, a 2 GB push limit, and a 5,000-branch maximum. It also documents recommended maximums of 3,000 entries in a single directory and 50 levels of directory depth.

A monorepo approaches several of these simultaneously, and the directory-width one surprises people — a services/ directory with 3,000 entries is a plausible large-organisation shape.

The 5,000-branch limit matters more than it sounds. A monorepo receives every team’s branches. An organisation with 400 engineers and no branch cleanup reaches four figures quickly, and stale branch pruning becomes an operational task rather than tidiness. Repository health covers the full set and the remediation options.

An honest cost, and the one most often glossed over.

In a polyrepo, repository access is the boundary. A contractor with access to one repository cannot read the others. It is coarse and it is genuinely enforced.

In a monorepo everybody with read access reads everything. There is no path-level read restriction in Git, and none in GitHub. A repository is readable or it is not.

What this means practically:

Contractors and vendors are a problem. If they need one service, a monorepo gives them all of them — every other team’s code, every internal tool, and whatever is in the infrastructure directory. The usual answer is that such work stays out of the monorepo in a separate repository, which is a real constraint on how you organise and one that arrives unexpectedly the first time somebody wants to engage an agency.

Secrets in history are worse. A leaked credential in a monorepo’s history is visible to everybody with repository access — which is the whole engineering organisation rather than one team.

Write restrictions do exist through CODEOWNERS plus required review, and through push rulesets restricting which paths a push may touch. Read restrictions do not.

The mitigation is not to try to partition reads. It is to accept that a monorepo’s read boundary is the organisation, and to keep genuinely restricted material — regulated data pipelines, security-sensitive tooling — in separate repositories. Most monorepo organisations have a small number of repositories outside the monorepo for exactly this reason.

A misunderstanding worth correcting.

One repository does not mean one release. Components in a monorepo are deployed independently — that is the normal arrangement, not a workaround.

What changes is how a release is identified. In a polyrepo, v2.4.1 of the payments service is a tag in the payments repository. In a monorepo, tags are repository-wide, so the convention becomes prefixed tags:

payments/v2.4.1
checkout/v1.9.0
frontend/v3.2.2

Or version identity moves out of Git entirely, into the artifact: the image digest is what identifies what was deployed, and the commit SHA is recorded as a label. That is the approach Pillar 7 recommends and it works particularly well here, because a commit SHA in a monorepo is a much less useful release identifier than in a single-project repository.

Tag count feeds the ref limit. Prefixed tags across forty components with weekly releases accumulate, and tag retention becomes another operational task.

The part that decides whether a monorepo succeeds, and it is not technical.

A monorepo makes the organisation’s coupling visible. Teams that were implicitly coupled through a shared library now share a repository, a CI pipeline and a review surface. That coupling always existed; the monorepo stops hiding it.

It requires a platform team. Somebody must own the build system, the CI scoping, the sparse checkout tooling and the repository’s health. In a polyrepo that work is distributed and mostly not done; in a monorepo it is concentrated and unavoidable. An organisation without the appetite to fund that team should not adopt a monorepo.

It changes what “my code” means. Engineers accustomed to owning a repository now own a directory. Some find that liberating and some experience it as loss of control, and the transition is a cultural change rather than a migration task.

It centralises decisions that were local. Which linter, which test framework, which language version. In a polyrepo each team chose; in a monorepo the shared toolchain implies a shared answer, and the negotiation is real.

It makes cross-team contribution possible and does not make it happen. The barrier drops from “get access to another repository” to “open a pull request”, and teams still need a reason to do it. This is the innersource problem, and a monorepo helps with the mechanics and not the culture.

Conway’s law applies in both directions. An organisation with strongly independent teams and a monorepo will experience constant friction; one with genuinely interdependent teams and a polyrepo will experience constant coordination overhead. The repository structure that works is the one matching how the organisation actually collaborates, which is the subject of the next article.

A programme, and the ordering matters more than the mechanics.

  1. Build the tooling first. Sparse checkout configuration, path-aware CI, the build graph if you are having one. A monorepo that receives code before the machinery exists is a bad experience that colours everything after.

  2. Decide the directory structure, and get it reviewed by the teams moving in. Renaming a top-level directory later invalidates every sparse checkout configuration and every CODEOWNERS path.

  3. Write CODEOWNERS before the first migration, not after. It is the review model, and a period without one teaches people that anybody reviews anything.

  4. Move one team first, ideally a willing one with a self-contained component. Learn from it.

  5. Preserve history where it matters. git subtree or a filter tool can bring a repository’s history into a subdirectory. Whether it is worth it depends on whether anybody uses git log on that code — for a five-year-old service, usually yes.

  6. Do not migrate abandoned repositories. A monorepo migration is a good moment to archive rather than move. Bringing dead code in makes it permanently everybody’s.

  7. Move teams in waves, with the tooling improving between them.

  8. Leave genuinely restricted material out. Regulated pipelines, security tooling, anything a contractor must not read.

  9. Archive the source repositories rather than deleting them, once nothing references them.

Step 1 is the one that gets skipped, because migrating code is visible progress and building tooling is not. Every organisation that skipped it reports the same thing: the first six months were unpleasant and several teams campaigned to go back.

Calling a large single-project repository a monorepo. Different problem, different remedies.

Adopting a monorepo without the apparatus. One repository, no ownership model, no path-aware CI — the costs without the benefits.

Assuming code sharing happens automatically. A monorepo makes sharing possible; teams still have to do it, and vendored copies inside a monorepo are common.

Confusing sparse checkout with partial clone. Enabling one and wondering why the other problem persists.

Path-filtered CI that ignores shared code. A library change appears to affect nothing.

No branch or tag hygiene. The 5,000-branch limit is reachable.

Expecting path-level read restrictions. They do not exist. Read access is repository-wide.

CODEOWNERS without required review. Routing, not enforcement.

Migrating everything at once. A monorepo migration is a programme, and the tooling should exist before the code arrives.

Governance changes shape when one repository holds everything.

Repository-level settings become organisation-wide settings. A branch protection rule on the monorepo’s default branch applies to every team’s code. A required status check applies to every change. There is no per-directory equivalent.

Push rulesets can restrict paths. A ruleset can restrict which file paths a push may modify, which is the closest thing to path-scoped write control. Combined with CODEOWNERS and required review, it gives a workable write model even without read partitioning.

Required checks become a negotiation. In a polyrepo each team chose its own required checks. In a monorepo, a check required on the default branch runs for everybody — so the required set is either the intersection of what every team wants (very small) or a source of complaint.

The usual resolution is a small mandatory set applied repository-wide — no force push, required review, code owner approval, secret scanning — plus per-path checks that only run when relevant paths change, and are required only conditionally. Getting that conditional-requirement behaviour right is one of the harder CI configurations in a monorepo.

Bypass becomes higher-stakes. A bypass actor on the monorepo can bypass protections on everybody’s code. The list should be shorter than it would be for a single-team repository, and its use should be monitored.

The cross-link worth making: everything in enterprise rulesets applies, and a monorepo is the case where getting the layering right matters most, because there is no repository boundary to contain a mistake.

The cases where the trade does not pay, stated plainly.

Genuinely independent products. Two businesses sharing an engineering department but no code. A monorepo gives them a shared CI pipeline, a shared review surface and a shared 5,000-branch budget in exchange for nothing.

Strict read-access requirements. Regulated code, contractor-developed components, anything where “the whole engineering organisation can read this” is unacceptable. There is no path-level read control, and designing around its absence is harder than keeping the code elsewhere.

No platform capacity. The apparatus is a real ongoing cost. An organisation that cannot fund somebody to own the build system and CI scoping will get the costs and none of the benefits.

Wildly different toolchains. A repository containing a Rust service, an iOS app, a data warehouse and a Windows desktop client shares almost no tooling, and the single-toolchain benefit evaporates while the coordination cost remains.

Open-source components. Code intended for public release inside a private monorepo requires an export process, and export processes drift.

An organisation in the middle of a reorganisation. The directory structure encodes an ownership model, and encoding one that is about to change is expensive to undo.

The honest summary: monorepos suit organisations whose components genuinely depend on each other and whose teams genuinely need to change each other’s code. Where those are not true, the boundary the repository provided was doing useful work, and removing it is a cost with no corresponding benefit.

A monorepo is a decision to trade repository boundaries for atomic change, and to rebuild in tooling everything the boundary was previously doing for free.

Ownership was the boundary; now it is CODEOWNERS. Access control was the boundary; now it is partly gone. CI scope was the boundary; now it is a build graph. Working-tree size was bounded by the boundary; now it is sparse checkout.

The trade is frequently worth making. It is never free, and organisations that adopt a monorepo expecting the benefits without building the replacements get a large repository and a worse experience than they started with.

  • A monorepo contains multiple logical projects versioned together; size alone does not make one
  • Atomic cross-component change is the property that justifies it; everything else follows
  • The repository provides none of the ownership, CI scoping, or working-tree management it requires
  • CODEOWNERS replaces the repository boundary for review routing, and only the last matching pattern applies
  • Sparse checkout manages the working tree; partial clone manages the download; they are different
  • Path-filtered CI misses shared-code dependencies unless you handle that explicitly
  • GitHub’s limits — 10 GB, 5,000 branches, 3,000 directory entries — are all reachable in a monorepo
  • There are no path-level read restrictions; a monorepo’s read boundary is the organisation
  • Components still release independently, using prefixed tags or artifact-based identity

Use a disposable repository. No production repositories.

  1. Create a repository with services/a/, services/b/, libs/shared/, frontend/ and tooling/, each containing a few files.

  2. Add a CODEOWNERS with a default rule, per-directory rules, and a cross-cutting **/Dockerfile rule at the bottom. Add a Dockerfile under services/a/.

  3. Open a pull request touching that Dockerfile. Predict: which owner is requested — the service team, the platform team, or both?

  4. Move the cross-cutting rule above the service rules and repeat. Predict: does the routing change?

  5. Clone the repository with --filter=blob:none --sparse, then git sparse-checkout set --cone services/a libs/shared. List the working tree. Predict: what is present?

  6. Run git log --oneline in the sparse clone. Predict: is the full history available?

  7. Add a workflow with a paths: filter for services/a/**. Change a file in libs/shared/. Predict: does the service A workflow run, and should it?

  8. Delete the repository.

Engineering Team Onboarding SystemA 30-day Git and GitHub programme with standards templates, assessments and governance checklists.