Monorepo Branch Topology Jump to heading
A monorepo stores many independently releasable packages in one repository, and that single decision reshapes how branches, reviews, CI, and release tags must be organised. The topology that works for a single-package repository β a trunk plus a few feature branches β does not scale to hundreds of packages owned by dozens of teams unless you add structure along one more axis: ownership by path. This guide sits within the broader Git Workflow Architecture & Branching Strategies framework and shows how to keep a monorepo on a single line of history while still giving each team autonomous branches, scoped CI, per-directory review, and independent release cadence.
The counter-intuitive result is that the larger and more multi-team a repository becomes, the simpler its branch graph should be. The complexity moves out of the branch topology and into path-scoped tooling: prefixes, CODEOWNERS, path filters, sparse-checkout, and tag namespaces.
Prerequisites Jump to heading
Before designing a monorepo branch topology, verify your environment meets these requirements:
Branch and Ownership Topology Overview Jump to heading
The diagram below shows the target shape: one trunk, several ownership lanes defined by path prefix rather than by separate long-lived branches, short-lived namespaced branches that fork and merge back within a day, and per-package release tags applied on the trunk.
Step 1 β Commit to a Single-Trunk Topology Jump to heading
Intent: Preserve the atomic, cross-package commit that is the entire reason to adopt a monorepo.
The defining property of a monorepo is that one commit can change several packages at once and CI tests the integrated result. The moment you give each package its own long-lived branch, you lose that: a change touching payments and auth now spans two branches, cannot be reviewed as one unit, and cannot be tested together. So the first rule is the strongest β every package lives on one main. This is trunk-based development applied at scale, and the branch protection you configure there applies unchanged here.
Protect the single trunk with a ruleset, and require linear history so the graph stays bisectable across every package:
# Enforce a linear, single-trunk history (run once per repo)
git config --local pull.rebase true # never create merge bubbles on pull
git config --local rebase.autoStash true # stash a dirty tree during rebase
git config --local merge.ff only # refuse non-fast-forward local merges On the server side, encode protection as a repository ruleset so it is version-controlled:
# terraform/monorepo_protection.tf
resource "github_branch_protection" "main" {
repository_id = var.repository_id
pattern = "main"
enforce_admins = true
required_linear_history = true # no merge commits β keep bisect fast
allows_force_pushes = false
allows_deletions = false
required_pull_request_reviews {
require_code_owner_reviews = true # path owners must approve (Step 4)
required_approving_review_count = 1
}
} Verify: Confirm the trunk is the only long-lived branch and that history is linear:
git branch -r --list 'origin/*' | grep -vE 'origin/(main|HEAD)' # expect: no long-lived branches
git log --merges origin/main | head # expect: empty on a linear trunk Step 2 β Namespace Branch Prefixes by Ownership Domain Jump to heading
Intent: Make every short-lived branch self-describing β which team owns it and which domain it touches β without inventing a separate branch line per package.
Because all work merges into one trunk, the branch name becomes the only place ownership metadata lives before the pull request opens. Adopt a two-segment prefix convention: <type>/<domain>/<description>. The domain segment mirrors the top-level package directory, so feat/payments/refund-api signals at a glance that the branch belongs to the payments team and touches packages/payments/.
# Branch naming convention β domain segment mirrors the package directory
git switch -c feat/payments/refund-api # feature in packages/payments
git switch -c fix/auth/token-expiry # bugfix in packages/auth
git switch -c chore/platform/bump-node-20 # cross-cutting platform change Enforce the convention with a client-side hook so malformed names never reach the remote. This complements the server-side rules rather than replacing them:
#!/usr/bin/env bash
# .git-hooks/pre-push β reject branch names that break the domain convention
branch="$(git symbolic-ref --short HEAD)"
pattern='^(feat|fix|chore|docs|refactor|test|perf)/[a-z0-9-]+/[a-z0-9._-]+$'
if [[ "$branch" != "main" && ! "$branch" =~ $pattern ]]; then
echo "error: branch '$branch' must match <type>/<domain>/<description>" >&2
exit 1
fi Point Git at the shared hook directory so the whole team picks it up from a tracked location:
git config --local core.hooksPath .git-hooks # version-controlled hooks, Git 2.30+
chmod +x .git-hooks/pre-push Verify: A non-conforming branch name is rejected before push:
git switch -c badname
git push origin badname
# Expected: error: branch 'badname' must match <type>/<domain>/<description> The domain prefix is deliberately aligned with directory names because the next three steps β CI triggers, review routing, and tags β all key off the same path structure. One naming decision drives the entire pipeline.
Step 3 β Wire Path-Scoped CI Triggers Jump to heading
Intent: Run only the pipelines that a given diff can possibly affect, so median CI time stays flat as the repository grows.
In a naive monorepo, every push runs every test, and pipeline latency grows without bound as packages are added. Path-scoped triggers cut that: a change confined to packages/payments/ runs only the payments matrix. The critical subtlety is the shared foundation β a change to a common library, the lockfile, or the CI workflow itself must fan out to everything, because it can affect any package.
# .github/workflows/payments.yml
name: payments-ci
on:
pull_request:
paths:
- "packages/payments/**" # this package
- "packages/shared-lib/**" # a dependency it consumes
- "pnpm-lock.yaml" # any lockfile change can shift resolution
- ".github/workflows/payments.yml"
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- run: pnpm install --frozen-lockfile
- run: pnpm --filter @acme/payments test # scope the run to one package For a large matrix, generate the affected-package set dynamically rather than hand-maintaining one workflow per package. A workspace tool that understands the dependency graph (Nx, Turborepo, or pnpm --filter) can compute exactly which packages a diff touches:
# .github/workflows/affected.yml β build only what changed since the merge base
name: affected-ci
on: [pull_request]
jobs:
affected:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # full history for a correct merge base
- uses: pnpm/action-setup@v4
- run: pnpm install --frozen-lockfile
# --filter '...[origin/main]' selects changed packages AND their dependents
- run: pnpm --filter "...[origin/main]" run test The mechanics of writing correct path filters, avoiding the shared-dependency trap, and keeping full history available for the merge base are covered in depth in Optimizing CI Triggers for Path-Specific Changes.
Verify: A diff limited to one package triggers only that packageβs workflow:
git switch -c test/payments/trigger-check
echo "// touch" >> packages/payments/src/index.ts
git commit -am "test: payments trigger check" && git push origin HEAD
# In the PR Checks tab: expect payments-ci to run, auth-ci to be skipped Safety Warning: A path filter that omits a shared dependency will silently skip tests for packages that consume it, letting a breaking change merge green. Always include
pnpm-lock.yamland any shared-library path in every packageβs trigger, and route lockfile-only changes to the full matrix.
Step 4 β Route Reviews with Path-Based CODEOWNERS Jump to heading
Intent: Guarantee that a change to a package cannot merge without approval from the team that owns that packageβs directory.
On a single trunk shared by many teams, review authority has to be expressed by path, because there is no per-team branch to protect. The CODEOWNERS file maps directory globs to teams; combined with the require_code_owner_reviews rule from Step 1, it turns each directory into an independently gated review boundary.
# .github/CODEOWNERS β evaluated top to bottom; the LAST matching rule wins
* @acme/platform # default owner (fallback)
/packages/payments/** @acme/payments
/packages/auth/** @acme/auth
/packages/shared-lib/** @acme/platform @acme/architecture
# Cross-cutting config needs two-team sign-off
/pnpm-lock.yaml @acme/platform
/.github/** @acme/platform Because the last matching pattern wins, order rules from most general to most specific. A common mistake is placing a broad /packages/** rule below a specific one, which then overrides it and hands review of every package to the wrong team.
The full walkthrough β nested ownership, team-of-teams escalation, handling files owned by no team, and auditing coverage β lives in Path-Based CODEOWNERS in a Monorepo.
Verify: Confirm the file parses and that a sample path resolves to the expected owner:
# GitHub CLI surfaces CODEOWNERS syntax errors in the repo's rules view
gh api "repos/:owner/:repo/codeowners/errors" # expect: {"errors":[]}
# Locally, confirm the payments path maps to the payments team
grep -nE '/packages/payments/' .github/CODEOWNERS # expect: @acme/payments Step 5 β Scale Clones with Sparse-Checkout and Partial Clone Jump to heading
Intent: Let an engineer work on one package without materialising the entire multi-gigabyte tree on disk or over the network.
As a monorepo grows past a few gigabytes, a full clone becomes a real tax on every laptop and CI runner. Two independent Git features address different halves of the cost. Partial clone (--filter=blob:none) defers downloading file contents until they are actually needed, shrinking the initial fetch. Sparse-checkout limits which paths appear in the working tree, so only the directories you care about are written to disk. They compose:
# Clone metadata only β blobs are fetched on demand (Git 2.30+)
git clone --filter=blob:none --no-checkout https://github.com/acme/monorepo.git
cd monorepo
# Restrict the working tree to one package plus its dependency, in cone mode
git sparse-checkout init --cone
git sparse-checkout set packages/payments packages/shared-lib
git checkout main The result is a working tree containing only packages/payments/ and packages/shared-lib/, backed by full history but with blobs for other packages never downloaded. Cone mode keeps the pattern matching fast by restricting patterns to whole directories.
To add a package to an existing sparse working tree without disturbing the current set:
git sparse-checkout add packages/notifications # append a directory to the cone
git sparse-checkout list # show the active set The complete treatment β measuring the savings, wiring sparse-checkout into CI runners, the blob:none vs tree:0 trade-off, and recovering when a build needs a path outside the cone β is in Sparse-Checkout for Large Monorepos.
Verify: Only the selected packages are present in the working tree, yet history is intact:
ls packages/ # expect: payments shared-lib (not the full set)
git log --oneline -1 main # expect: the latest trunk commit β history is complete
git sparse-checkout list # expect: packages/payments packages/shared-lib Safety Warning:
git sparse-checkout setwith a wrong pattern can appear to delete files β they are removed from the working tree, not from history. Recover the full tree withgit sparse-checkout disable, which restores every tracked path. Neverrmfiles that are merely outside the cone.
Step 6 β Tag Releases Per Package with Tag Prefixes Jump to heading
Intent: Give each package an independent version history on a shared trunk without tag collisions.
Git tags occupy one flat namespace for the whole repository, so v1.2.0 can exist only once β useless when twenty packages each need their own semantic version. The convention that solves this is a per-package tag prefix: payments-v1.2.0, auth-v3.0.1, shared-lib-v0.4.2. The prefix partitions the single tag namespace into one lane per package, and every release tool that supports monorepos reads and writes exactly this shape.
# Tag a package release on the trunk, annotated and signed
git tag -s payments-v1.2.0 -m "payments: refund API GA"
git push origin payments-v1.2.0
# List only one package's release history
git tag --list 'payments-v*' --sort=-version:refname | head
# describe the current commit relative to the payments lane only
git describe --tags --match 'payments-v*' # e.g. payments-v1.2.0-3-gab12cd3 Automate the version bump so the tag is derived from conventional-commit history scoped to the package path. With semantic-release or Changesets, each package is configured with its own tagFormat:
// packages/payments/release.config.json
{
"tagFormat": "payments-v${version}",
"extends": "semantic-release-monorepo",
"branches": ["main"]
} # .github/workflows/release-payments.yml β release only when payments changes
name: release-payments
on:
push:
branches: [main]
paths: ["packages/payments/**"] # scope releases the same way as CI
jobs:
release:
runs-on: ubuntu-latest
permissions: { contents: write }
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: pnpm/action-setup@v4
- run: pnpm install --frozen-lockfile
- run: pnpm --filter @acme/payments exec semantic-release
env: { GITHUB_TOKEN: "$" } The general tag-and-changelog automation β signing, annotated vs lightweight tags, monotonic version enforcement, and changelog generation β is documented in Release Tagging & Versioning; this section applies the prefix convention that adapts it to many packages in one repository.
Verify: The prefixed tag exists and resolves only within its package lane:
git tag --list 'payments-v*' # expect: payments-v1.2.0
git describe --tags --match 'auth-v*' # expect: an auth-* tag, never a payments one Safety Warning: Deleting a pushed release tag (
git push origin :refs/tags/payments-v1.2.0) breaks anyone who has pinned that version. Prefer publishing a new patch tag over moving or deleting an existing one; if a tag must be retracted, coordinate with every consumer first.
Integration with Adjacent Workflows Jump to heading
A monorepo branch topology is the composition point for several workflows that each own one dimension of the design.
Trunk discipline comes from Trunk-Based Development Setup. The single-trunk rule in Step 1 is that model applied at multi-team scale; the branch protection, short-lived-branch TTLs, and merge-gate configuration transfer directly. The only monorepo-specific addition is the domain prefix in the branch name.
Release cadence comes from Release Tagging & Versioning. A monorepo does not change how you compute a semantic version from commits β it changes the scope: each package gets its own prefixed tag lane and its own path-filtered release workflow, so packages ship independently from the same trunk.
Pipeline efficiency comes from Optimizing CI Triggers for Path-Specific Changes. Path filters are what make a large monorepoβs CI time depend on the size of the diff rather than the size of the repository β without them, the single-trunk model becomes unbearably slow as packages accumulate.
The boundary of responsibility is clean: the branch topology decides where work lives (one trunk, path-scoped ownership); CI triggers decide what runs for a given change; CODEOWNERS decides who approves; and tag prefixes decide what ships. Each dimension keys off the same directory structure, which is why aligning branch prefixes, filter paths, owner globs, and tag prefixes to the package layout pays off compoundingly.
Troubleshooting Jump to heading
| Symptom | Likely cause | Fix |
|---|---|---|
| CI runs every package on a one-package change | Path filters missing or paths: block absent from the workflow | Add a paths: filter scoped to the package and its shared dependencies; route lockfile-only diffs to the full matrix |
| A breaking change to a shared library merged green | Shared-lib path omitted from consuming packagesβ triggers | Add packages/shared-lib/** and pnpm-lock.yaml to every dependent packageβs paths: filter |
| Wrong team asked to review a change | CODEOWNERS rule order wrong β a broad pattern below a specific one overrides it | Reorder rules general-to-specific; the last matching pattern wins |
git describe returns a tag from another package | --match glob not scoped to the package prefix | Always pass --match 'payments-v*' to scope git describe to one lane |
Files vanished after sparse-checkout set | Working tree restricted to the cone; files are outside it, not deleted | Run git sparse-checkout add <path> or git sparse-checkout disable to restore the full tree |
semantic-release tags with the wrong version | Two packages sharing one tagFormat, so their histories collide | Give each package a distinct tagFormat prefix and semantic-release-monorepo config |
| Partial clone still downloads everything | --filter=blob:none omitted, or a full checkout forced all blobs | Re-clone with --filter=blob:none --no-checkout, then set the sparse cone before git checkout |
Frequently Asked Questions Jump to heading
Should a monorepo use one trunk or a branch per package? Jump to heading
One trunk. A branch per package recreates exactly the coordination problems a monorepo exists to eliminate: a change spanning payments and auth would live on two branches, could not be reviewed or tested as one atomic unit, and CI could no longer verify the integrated state. Keep every package on a single main and differentiate work with namespaced short-lived branches such as feat/payments/refund-api. Ownership is expressed by path, not by long-lived branches.
How do per-package version tags coexist in one repository? Jump to heading
Give each package a distinct tag prefix, such as payments-v1.2.0 and auth-v3.0.1. Git tags share a single flat namespace across the whole repository, so the prefix is what keeps package release histories from colliding β v1.2.0 alone can exist only once. Tools like semantic-release (with semantic-release-monorepo) and Changesets read and write these prefixed tags automatically, and git describe --match 'payments-v*' scopes version lookups to one lane.
Do path-scoped CI triggers reduce test coverage? Jump to heading
Not when configured correctly. Path filters decide which package pipelines run for a given diff; they do not remove any test. The one rule you must not break is that shared foundations β a common library, the lockfile, or the CI config itself β have to trigger the full matrix, because a change there can affect any package. The goal is to skip provably unaffected packages, never to skip integration checks.
Can sparse-checkout break a build that expects the whole tree? Jump to heading
Yes, if a package imports files that live outside its sparse cone. Sparse-checkout only changes which files are present in the working tree; it does not change the dependency graph. Include every path a package actually reads at build time β its own directory plus any shared libraries it consumes β and verify with a clean build inside the cone before relying on it. If a build fails on a missing path, git sparse-checkout add that path or disable sparse mode entirely.
How does CODEOWNERS interact with branch protection in a monorepo? Jump to heading
Enable require_code_owner_reviews in branch protection so a change to a given path cannot merge without approval from the team that owns that path. CODEOWNERS maps directory globs to teams; branch protection makes those mappings enforceable on the shared trunk. Together they give you per-directory review gates without needing a separate branch per team β the single line of history stays intact while each package retains an independent review boundary.
Related Jump to heading
- Trunk-Based Development Setup β the single-trunk discipline this topology scales to many teams, including branch protection and short-lived-branch policies
- Release Tagging & Versioning β the semantic-version and changelog automation that the per-package tag-prefix convention builds on
- Optimizing CI Triggers for Path-Specific Changes β writing correct path filters so CI time scales with the diff, not the repository
- Path-Based CODEOWNERS in a Monorepo β nested ownership, rule ordering, and auditing per-directory review coverage
- Sparse-Checkout for Large Monorepos β partial clone and sparse working trees for engineers who work on one package of many