Emacs Projectile in a Monorepo

In a monorepo, Projectile determines the project root to be the monorepo, not the subproject that you're currently in. In this article I update Projectile to instead prioritize the most specific project it can find.

To jump to the solution, go to Solution.

Two Kinds of Monorepos

I've seen two kinds of monorepos:

  1. A single git repository to hold several, self-contained projects.

    In this case, the repo is basically a dumping ground for smaller projects. The motivation here is to stave off an explosion of small repositories, and to collect issues and PRs in one place where a team can review them.

  2. A single git repository with common libraries and multiple independently deployable services.

    This is what the term "monorepo" is supposed to connote - a single commit describes a working version of several services that may need to work together and share code.

Always in case 1, and sometimes in case 2, I would rather projectile consider a subproject as a "project root". One could still use Projectile on the whole monorepo by navigating to a file at the root of the monorepo.

Example of the Problem

Consider the following monorepo:

tree -F -a /tmp/repo
/tmp/repo/
├── .git
├── go/
│   ├── projectA/
│   │   └── go.mod
│   └── projectB/
│       └── go.mod
└── python/
    ├── projectC/
    │   └── setup.py
    └── projectD/
        └── setup.py

7 directories, 5 files

Now when I open a file in go/projectA, Projectile says that the project root is the monorepo:

(let ((default-directory "/tmp/repo/go/projectA"))
  (projectile-project-root))
#+RESULTS:
/private/tmp/repo/

(Don't worry about the /private/ – it's because MacOS symlinks /tmp/private/tmp).

I want Projectile to say that the project root is the Go subject: /tmp/repo/go/projectA. But alas, Projectile instead found the monorepo root: /tmp/repo/.

How does projectile determine that project root?

About Projectile Project Detection

The relevant documentation is here: Customizing Project Detection. Projectile has a few strategies for finding a project root, and it tries each strategy until one returns a result. The order is defined by this variable:

projectile-project-root-functions
projectile-root-local
projectile-root-marked
projectile-root-bottom-up
projectile-root-top-down
projectile-root-top-down-recurring

In our example, the function projectile-root-bottom-up is the culprit. We can try it out interactively:

(projectile-root-bottom-up "/tmp/repo/go/projectA")
#+RESULTS:
/tmp/repo/

Yup – it found the monorepo, not the subproject. To understand why this is, let's look at the source! Here it is:

(defun projectile-root-bottom-up (dir &optional list)
  "Identify a project root in DIR by bottom-up search for files in LIST.
If LIST is nil, use `projectile-project-root-files-bottom-up' instead.
Return the first (bottommost) matched directory or nil if not found."
  (projectile-locate-dominating-file
   dir
   (lambda (directory)
     (let ((files (mapcar (lambda (file) (expand-file-name file directory))
                          (or list projectile-project-root-files-bottom-up))))
       (cl-some (lambda (file) (and file (file-exists-p file))) files)))))

In regular words, this function starts at the current directory and looks for any of the marker files in the variable projectile-project-root-files-bottom-up. If none exist in the current directory, go up one directory, etc.

And what are these "marker files"?

projectile-project-root-files-bottom-up
.git .hg .fslckout FOSSIL .bzr _darcs .pijul

So, assuming we're somewhere in our monorepo, Projectile starts by looking for any of those files between the current directory and root.

To drive this point home, say we append go.mod to that list of marker files:

(setq projectile-project-root-files-bottom-up
      '(".git" ".hg" ".fslckout"
        "_FOSSIL_" ".bzr" "_darcs" "go.mod"))
.git .hg .fslckout FOSSIL .bzr _darcs go.mod

Projectile still won't find our Go subproject, because .git comes earlier in the list of marker files.

(projectile-root-bottom-up "/tmp/repo/go/projectA")
#+RESULTS:
/tmp/repo/go/projectA/

That works! So we just need to update projectile-project-root-files-bottom-up.

Updating projectile-project-root-files-bottom-up

The problem is:

The variable projectile-project-root-files-bottom-up doesn't have go.mod or setup.py in it.

We just need to add setup.py and go.mod to the list of marker files. While we're at it, let's add every other filename that indicates a project root. Projectile already has a variable for this, documented in File markers:

projectile-project-root-files
dune-project Project.toml elm.json pubspec.yaml
info.rkt Cargo.toml stack.yaml DESCRIPTION
Eldev Cask shard.yml Gemfile
.bloop deps.edn build.boot project.clj
build.sc build.sbt application.yml gradlew
build.gradle pom.xml pyproject.toml poetry.lock
Pipfile tox.ini setup.py requirements.txt
manage.py angular.json package.json gulpfile.js
Gruntfile.js mix.exs rebar.config composer.json
Taskfile.yml CMakeLists.txt GNUMakefile Makefile
debian/control WORKSPACE flake.nix default.nix
meson.build SConstruct ?*.nimble ?*.sln
?*.fsproj ?*.csproj GTAGS TAGS

Decent start, but it doesn't have go.mod, so we should add that. Might as well also add all the files in projectile-project-root-files-bottom-up (which has .git, etc).

So the solution is…

Solution

(setq projectile-project-root-files-bottom-up
  (-concat
   '("go.mod")
   projectile-project-root-files-bottom-up
   projectile-project-root-files))

That creates a pretty complete list of marker files that can indicate project roots.

Test

After setting the variable above, let's see what Projectile says the project roots are for our monorepo.

Go

Can we correctly identify a Go project?

(let ((default-directory "/tmp/repo/go/projectA"))
  (projectile-project-root))
#+RESULTS:
/private/tmp/repo/go/projectA/

✔ Works!

Python

Can we correctly identify a Python project?

(let ((default-directory "/tmp/repo/python/projectC"))
  (projectile-project-root))
#+RESULTS:
/private/tmp/repo/python/projectC/

✔ Works!

Force Root Higher

Can we force the project root to a higher level by creating a .projectile file?

touch /tmp/repo/.projectile
(let ((default-directory "/tmp/repo/go/projectA"))
  (projectile-project-root))
#+RESULTS:
/private/tmp/repo/

✔ Works!