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:
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.
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))
/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")
/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")
/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 havego.mod
orsetup.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))
/private/tmp/repo/go/projectA/
✔ Works!
Python
Can we correctly identify a Python project?
(let ((default-directory "/tmp/repo/python/projectC")) (projectile-project-root))
/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))
/private/tmp/repo/
✔ Works!