GitWorking with Monorepos

Git in a Monorepo

A monorepo is a single Git repository that contains multiple distinct projects — frontend app, backend API, shared component library, mobile app, infrastructure code — all in one place. Companies like Google, Meta, and Microsoft have used monorepos at massive scale. Many teams of 5–50 developers also benefit from monorepos for entirely different reasons: atomic cross-package changes, unified CI, and no dependency version drift.

Git was not designed with monorepos in mind. With hundreds of packages and thousands of files, certain Git operations become slow, CI builds become bloated, and history becomes hard to navigate. This guide covers the specific Git techniques, tools, and patterns that make monorepos manageable.

What a Monorepo Is and Git-Specific Challenges

Challenge

Root cause

Solution

Slow git status / git fetch

Thousands of files to stat

git maintenance, fsmonitor, partial clone

Bloated CI builds

Every PR triggers full test suite

Path-based CI triggering

Hard to find package history

git log shows all packages mixed

git log -- packages/ui/

Large repository size

Binary assets, generated files

Git LFS, .gitignore build outputs

Long clone times for new developers

Full history of all packages

Shallow clone + sparse checkout

Version coordination

Which package version goes with which?

Changesets, Nx release, or repo-wide tags

Shallow Clone and Sparse Checkout

When a developer only needs to work on packages/ui, cloning all 20 years of history and all 50 packages is wasteful. Combine shallow clone (limited history depth) with sparse checkout (limited file tree) to make the working copy fast and small.

Shallow clone with sparse checkout for a specific package

Bash
# 1. Clone with no files checked out and minimal history
git clone --filter=blob:none --no-checkout --depth=1 \
  git@github.com:org/monorepo.git
cd monorepo

# 2. Enable sparse checkout
git sparse-checkout init --cone

# 3. Specify which directories you want
git sparse-checkout set packages/ui packages/design-tokens

# 4. Now check out main
git checkout main
# Only packages/ui and packages/design-tokens are on disk
# Everything else is available on-demand if needed

Add more paths to sparse checkout

Bash
# Later, if you need to work on another package:
git sparse-checkout add packages/api-client

# Or reset to get everything:
git sparse-checkout disable
Note
`--filter=blob:none` uses partial clone to omit file contents from the initial download. Git fetches blobs on-demand when you actually check out or read a file. This dramatically reduces clone size for repositories with many large files.
Path-Based CI Triggering

The biggest productivity win in a monorepo CI setup is only building and testing packages that were actually changed by a PR. Running the full test suite for all 20 packages when you only changed a CSS file in packages/ui is wasteful and slows down every developer.

.github/workflows/ci.yml — path-based triggers

YAML
name: CI

on:
  pull_request:
    branches: [main]

jobs:
  ui:
    name: Test packages/ui
    runs-on: ubuntu-latest
    if: |
      contains(github.event.pull_request.changed_files, 'packages/ui')
    # Simpler approach: use paths filter
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test --workspace=packages/ui

  # Better approach: use the paths key
  api:
    name: Test packages/api
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test --workspace=packages/api

.github/workflows/ci-ui.yml — dedicated workflow per package

YAML
name: UI Package CI

on:
  push:
    paths:
      - 'packages/ui/**'
      - 'packages/design-tokens/**'  # UI depends on this
      - '.github/workflows/ci-ui.yml'
  pull_request:
    paths:
      - 'packages/ui/**'
      - 'packages/design-tokens/**'

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm run lint --workspace=packages/ui
      - run: npm test --workspace=packages/ui
      - run: npm run build --workspace=packages/ui
Tip
Tools like Nx and Turborepo go further: they analyse the dependency graph of your packages and automatically determine which packages to rebuild based on what actually changed, including transitive dependencies. `nx affected --target=test` runs tests only for changed packages and anything that depends on them.
Commit Messages in a Monorepo

Conventional Commits shine in monorepos because the scope field maps naturally to package names. This enables automatic changelog generation per package.

Commit message with package scope

Text
# Scope = package name
feat(ui): add Button component with size variants
fix(api-client): handle 429 rate limit with exponential backoff
chore(design-tokens): update spacing scale to 8pt grid
docs(web-app): add deployment guide to README

# Multi-package change (rare — prefer separate commits)
feat(ui, web-app): integrate new Button into web app

# Infrastructure / repo-wide change (no scope)
chore: upgrade Node.js to 20 LTS across all packages
ci: add dependency caching to all workflows
Package-Specific History with git log

The -- path filter restricts git log to commits that touched a specific directory. This is invaluable in a monorepo for tracing the history of a single package.

Inspect history for a specific package

Bash
# All commits that touched packages/ui
git log --oneline -- packages/ui/
# a3f9c21 feat(ui): add Button component
# b1d8e34 fix(ui): correct focus ring on dark backgrounds
# c2e7f45 chore(ui): update peer dependencies

# Full diff of every commit that touched packages/ui
git log -p -- packages/ui/

# Who last changed each file in packages/ui
git log --oneline --follow -- packages/ui/src/Button/index.tsx

# Changes to a package between two releases
git log v1.0.0..v2.0.0 --oneline -- packages/ui/

# Stats: how many lines changed per commit in a package
git log --stat -- packages/ui/ | head -40
Tagging Strategies in a Monorepo

Strategy

Example tag

Pros

Cons

Repo-wide semver

v3.4.5

Simple, one version to communicate

All packages must release together

Per-package tags

@org/ui@1.2.0

Independent release cadence per package

More tags, complex CI release logic

Package + global

packages/ui@1.2.0 + repo v2026.5

Best of both worlds

Most complex setup

Per-package tagging

Bash
# Tag a specific package release
git tag -a "@org/ui@1.2.0" -m "Release @org/ui v1.2.0

- feat: add Button component
- fix: correct focus ring
- chore: update peer deps"

git push origin "@org/ui@1.2.0"

# List all tags for the ui package
git tag -l "@org/ui*"
# @org/ui@1.0.0
# @org/ui@1.1.0
# @org/ui@1.2.0
Monorepo Tooling

Tool

Purpose

Key feature

Nx

Build system & task orchestration

Affected detection, caching, distributed builds

Turborepo

Build orchestration

Fast incremental builds, remote caching

Changesets

Version management & changelogs

Tracks which packages changed, automates CHANGELOG + publish

Lerna

Package management

Classic monorepo tool, now delegates to Nx/Turborepo

Rush

Enterprise monorepo tool

Strict reproducibility, used at Microsoft scale

Workspaces (npm/yarn/pnpm)

Dependency hoisting

Links local packages to each other

Changesets workflow for versioning

Bash
# Install changesets
npm install --save-dev @changesets/cli
npx changeset init

# When making a change, add a changeset describing it
npx changeset
# ? Which packages would you like to include? packages/ui
# ? Is this a patch, minor, or major change? minor
# ? Summary: Add Button component with size variants

# This creates .changeset/happy-fox-sings.md

# On release: bump versions and generate changelogs
npx changeset version
# Bumps packages/ui from 1.1.0 to 1.2.0
# Updates CHANGELOG.md in packages/ui

# Publish all changed packages
npx changeset publish
# npm publish on each changed package
Repository Performance Optimisation

As a monorepo grows, Git operations slow down. Here are the most impactful performance improvements:

Enable background maintenance

Bash
# Run git maintenance on the repo (schedules background optimisations)
git maintenance start
# Enables prefetch, commit-graph, incremental-repack on a schedule

# Check what maintenance tasks are configured
git config --list | grep maintenance

Enable file system monitor (faster git status)

Bash
# Enable fsmonitor for faster git status
git config core.fsmonitor true
git config core.untrackedCache true

# Verify
git config --list | grep fsmonitor
# core.fsmonitor=true

Build commit graph for faster log/merge operations

Bash
# Build a commit-graph file (cached graph structure for faster traversal)
git commit-graph write --reachable

# Enable automatic commit-graph updates
git config fetch.writeCommitGraph true

Configure pack file optimisations

Bash
# Use multi-pack-index for better pack management
git multi-pack-index write

# Repack with delta compression
git repack -adf --window=250
Warning
Do not commit build output directories (`dist/`, `build/`, `.next/`) to the repository — even in a monorepo. Committed build artifacts bloat the repository size permanently and slow down every clone and fetch. Add them all to `.gitignore`.
Note
Git's sparse-checkout, partial clone, and commit-graph features were added specifically in response to the challenges discovered at Microsoft when migrating the Windows source tree (the largest monorepo in the world) to Git in 2017. The official blog post "The largest Git repo on the planet" is excellent background reading.