skip to content
back to blog

Personal Monorepo and Agentic Engineering

blog

I recently moved all my personal projects into one repository, or monorepo. I wanted less maintenance across separate repositories. Having everything together has also made it easier for AI coding agents to find related code.

Why I moved

I had accumulated a Tauri desktop app, a Compose Multiplatform mobile app, a Rust command-line tool, this blog, and a few other projects. Each had its own GitHub repository. Switching between them was manageable. Keeping their automated checks and build jobs up to date was more work. Each repository had its own workflow files, code checks, and tools to install. Changing a shared practice, such as replacing a custom pre-commit runner with a central one, meant updating every repository separately.

Working with agents such as Claude Code gave me another reason to move. An agent helping with a feature could only see its current repository. It could not see that I had solved a similar problem elsewhere, or that two projects used an interface that needed to stay consistent. I had the same problem when I kept too many repositories open in separate editor windows.

Independent projects or shared modules

A monorepo can hold modules that depend on each other, or independent projects that simply share a home. That difference affects which tools are useful.

Large organizations often use the first arrangement. Projects share dependencies, build steps, and sometimes a language or framework. Bazel1 and Pants2 support this with builds that use only declared inputs, reuse the results of small build steps, and track dependencies across the repository. Because they know which modules depend on a changed file, they can rebuild and test only the affected code. In a large codebase, this can cut checks from hours to minutes.

Independent projects can use entirely different tools: Rust in one directory, Kotlin in another, TypeScript in a third. They do not share compiled output or a system for resolving dependencies. Keeping them together makes them easier to organize.

I have used Bazel and Pants professionally, and rebuilding and testing only the affected code is worth the investment at scale. They also take time to learn, require BUILD file maintenance, and need infrastructure if you want to share cached results or run builds remotely. For dozens of teams working on dependent modules, those costs pay off. For my handful of unrelated projects, they would be too much work.

My Tauri desktop app, Compose Multiplatform app, Next.js app, Rust command-line tool, and blog have separate builds. They share an owner, an automated build and check process (CI), and the benefit of being visible to each other. I wanted to keep each project's existing tools. git subtree and mise let me do that while putting the projects in one place.

Bringing projects in with git subtree

I chose git subtree over git submodule. A submodule stores a pointer to a commit in another repository, so I would still have to manage both histories and update the pointer. A subtree copies the project's files into the monorepo. Each project becomes an ordinary directory.

# Initial import of a project
git subtree add --prefix=chippy chippy main --squash
 
# Pull upstream changes
git subtree pull --prefix=chippy chippy main --squash
 
# Push monorepo changes back to upstream
git subtree push --prefix=chippy chippy main

Each project keeps its original upstream repository. The --squash flag combines the imported history into one merge commit, keeping the monorepo log readable.

Running checks for changed projects

A blog edit should not trigger checks for an unrelated Rust project. I wrote a reusable GitHub Action that chooses the projects to check from the files that changed.

A central catalog, projects.json, lists each project's directory, upstream repository, lockfile, and the CI jobs it participates in:

{
  "project": "chippy",
  "path": "chippy",
  "repo": "chippy",
  "lockfile": "chippy/Cargo.lock",
  "modes": ["pre-commit", "mirror"],
  "cachePaths": {
    "pre-commit": ["~/.cargo/registry", "chippy/src-tauri/target"]
  }
}

The project-matrix action compares the changed paths with the directories in the catalog and outputs a JSON job list containing only the affected projects. Later jobs use that list through strategy.matrix.include to check, test, or mirror those projects.

Updating the original repositories

Many of these projects are public and still need their own GitHub repositories. On every push to main, a mirror workflow uses git subtree push to send each changed project back to its upstream repository. People using those repositories see a normal standalone project.

Setting up tools with mise

I use mise3 to install the tools each project needs and define common tasks. Each project lists its requirements in a local mise.toml. For example, the Tauri app chippy needs Rust and Bun, while the Compose Multiplatform app airoute needs JDK 21 and ktlint:

# chippy/mise.toml
[tools]
bun = "latest"
rust = "latest"
 
# **/mise.toml
[tools]
java = "temurin-21"
ktlint = "latest"

The root mise.toml uses experimental_monorepo_root to have mise discover each subdirectory's configuration:

experimental_monorepo_root = true
 
[monorepo]
config_roots = ["chippy", "shadcn-treeview", ...]

When I cd into a project, mise selects its Rust version, JDK, or package manager automatically. The root configuration defines subtree tasks such as mise run pull and mise run push. Each project defines its own dev, test, and lint tasks.

The experimental monorepo feature also supports Bazel-like //project:task syntax. I can run a project's task from the workspace root:

mise run //chippy:lint

The root configuration can also combine tasks from several projects:

[tasks."check:all"]
depends = ["//chippy:check"]

This makes the separate projects easier to work with without combining their builds. On any machine, I can clone the monorepo and run mise install to set up all of them. I no longer have to look for separate installation scripts or setup instructions in each README. That has been especially useful when switching machines: one clone and the same configuration give me the same environment.

Giving coding agents more context

An agent working in one repository can only use the code available there. But a desktop and mobile app may share authentication rules, a command-line tool may call the same API as a web app, and a blog post may describe code in another project.

In the monorepo, Claude Code can search for related work across all my projects. It can follow an existing approach without me finding and copying the examples into the conversation. This post was written with help from an agent that could read both the blog's content structure and the CI configuration it describes.

I think this becomes more useful as coding agents improve. Writing correct code depends partly on seeing enough of the surrounding work to make a good decision. A monorepo makes that code available through the filesystem, with no extra infrastructure or cross-repository search index to maintain.

This resembles the reasons companies such as Google4 use monorepos: less coordination between repositories and easier changes across a large codebase. Agents can also search large amounts of code without getting tired. Giving them more relevant code to read helps them make better suggestions.

The extra work

Subtree merge commits make the Git history noisy. Looking at one project's history needs git log with -- <path> filtering. The CI configuration is more involved, and choosing jobs from changed files took a few attempts to get right. Collaborators still work in the upstream repositories, so I need to keep those synchronized.

For my personal projects, the reduced maintenance and shared CI have been worth that work. Having related code available to agents has helped too.

Footnotes

  1. Bazel is Google's build system for large repositories using multiple languages. It isolates build inputs and can share cached build results between machines. ↩

  2. Pants has similar goals, with an emphasis on ease of use and support for Python and JVM projects. ↩

  3. mise manages language runtimes and runs development tasks. ↩

  4. Rachel Potvin, Josh Levenberg. "Why Google Stores Billions of Lines of Code in a Single Repository." Communications of the ACM, 2016. ↩

comments