Like Home Manager, but for repositories.
This file is primarily for contributors. See the manual for user documentation (including how to write modules) or join the Matrix room to discuss any aspect of Project Manager.
Project Manager helps tame the various configurations that clutter every repository. After a while, you end up with files scattered around, unrelated to anything, that are used by various tools or online services to support your project.
Project Manager allows you to make all these part of your Nix configuration, unifying formats and hiding the results when possible. Fewer files end up committed to the repository. Ones that are have a clear provenance, and all those files can now be programmed, rather than having to duplicate data in various places.
This will also help you manage tooling like git hooks that are explicitly difficult to manage automatically.
Normally configuration files are scattered around your repository, without any connection to what commands they might affect. This allows you to associate configurations with the programs and services that they’re for. Making the layout of the repository easier to understand and more discoverable.
This is probably the biggest point here – your configurations can now effectively have a narrative based around the structure of your flake, making for easier on-boarding of new contributors. Or even just reminding yourself why you made that change last week …
Since the dawn of Unix epoch, configuration languages have grown to become programmable, despite the best efforts of their designers. This results in various shortcomings and awkwardness. Project Manager gives you that programmability in a couple ways. One is that the configurations in your repository can be templates, populated by Nix. But, to go even farther, the configurations can be arbitrary Nix expressions that produce a static configuration. And, given Nix’s various other tools, you can do all sorts of crazy stuff, like write all your configurations in Dhall or YAML, and then generate TOML and JSON from them as needed.
Project Manager encourages you to put configuration in $PROJECT_ROOT/.config/project/
(akin to the XDG Base Directory Specification). But you don’t have to – you can put it anywhere you like. The organization is up to you.
Other configuration files need to be in particular locations that the tools know to look in, and rarely do all those places fit together in a coherent repository.
Granted, after generation, those configuration files still exist in those locations, but 1. they’re often not committed the repository and 2. they’re ignored by the VCS (and there are many other tools that also ignore VCS-ignored files, so in some ways the generated configurations are still invisible.
For now, after cloning, etc. run
project-manager switch
to regenerate all the files you need.
For this to do anything, you need to add a projectConfigurations.${system}
output to your flake. This project has one itself, and you can view the contents in .config/project.
Once you define your projectConfigurations
, there are a number of helpful attributes to take advantage of
packages.activation
– rarely used directly, this is the derivation behindproject-manager switch
. It’s what sets up your generated environment.packages.path
has all the packages referenced by the project configuration, it’s used indevShells
, etc. to make sure the right versions of the right commands are available. You might use it directly to add the packages to another derivationpackages.sessionVariables
sets up the shell environment variables referenced by the project configuration.
Project manager provides various checks based on your configuration. For example,
checks.formatter
verifies that a Project Manager-configured formatter (see below) agrees with the current state of the code andchecks.project-manager-files
verifies that the “repository”-persisted files are all up-to-date.
You can pick and choose, or just include them all via something like
checks = self.projectConfigurations.${system}.checks // {
## more checks
};
There are two other attribute sets, sandboxedChecks
and unsandboxedChecks
, that partition checks
. I recommend having nixConfig.sandbox = true
in your flake and using checks
unless you know you have enabled some modules (like Vale) that fail in the sandbox.
If that's the case, you have a couple options. You can either
- use
sandboxedChecks
in the flake to only include sandboxed ones innix flake check
or - weaken
nixConfig.sandbox
to"relaxed"
, which will allow explicitly unsandboxed derivations (__noChroot = true
) to run, but keep the others sandboxed.
and run nix --no-sandbox develop .#unsandboxedChecks
to check the others.
A shell derivation that provides everything configured in your project configuration.
The following will set it up as the default shell. it can also be overridden to use as a basis of various shells.
devShells = self.projectConfigurations.${system}.devShells // {
## more devShells
};
You can choose to configure the formatter through Project Manager. (see the treefmt module for an example. One benefit of doing it this way is that you get a check included.
formatter = self.projectConfigurations.${system}.formatter;
Various source filters that clean up files generated by Project Manager that have been committed.
src = self.projectConfigurations.${system}.cleanRepositoryPersisted ./.;
You shouldn’t need to use the Except
variants, because modules that rely on Project Manager-generated files should be loading them from the store (regardless of their persistence), but if you have explicitly generated additional files, it can be easier to whitelist them with Except
than to wrap the various tools.
One of the ideas underlying the decluttering here is that of “persistence”. How do different files persist? There are three levels, from strongest to weakest:
This is what we see in projects – files committed to a repository that are always there. All your non-generated files are persisted this way, and traditionally, many generated files are too. We still need to commit generated files, but only ones that are used for services that process the repository without running anything within it. The generated files in this level are usually represented by hard links to the Nix store, but sometimes copies1.
These are files that don’t get committed (and thus are ignored by the VCS tooling), but still need to live in the working tree. These are often files needed by the build, formatters, etc. – configuration that’s useful while working on a repository. These are represented as symlinks, but may be hard links or copies sometimes1. For example, .gitignore isn’t a symlink, because git
can’t process it if it is.
This is the ideal – these are files that are only linked into the project while some operation is running. The code generation is done up-front, but only resides in the Nix store unless running. Since they only exist temporarily, it matters less (from the user’s perspective) how these are implemented, so we optimize for performance. If the store and working tree are both on the same volume, we use a hard link. If they’re not, we use a symlink unless that’s broken, in which case a copy.
Users have control over each file’s persistence, but you don’t have to worry about persistence in practice. Modules are careful to default to the weakest persistence that has the desired properties. When explicitly creating project.file
s, it defaults to “repository”, because that should work in all cases, even though it may often be stronger than necessary.
Finally, there is a project.commit-by-default
(which defaults to false
) and a commit-by-default
for each file (which defaults to project.commit-by-default
). These can be used to override the minimum-persistence
values and commit files that otherwise wouldn’t be. It can be helpful to set project.commit-by-default = true
when you have non-Nix-using contributors who use tooling that expects these files to exist outside of a Nix environment.
There are a couple other projects that apply Nix modules to your flakes. However, they differ from Project Manager in various ways, and so far I believe they’re all complementary. One thing that could be improved is making it easier to share different modules between the systems.
This is specifically a way to produce devShells
using Nix modules. Project Manager also produces devShells, but it does it incidentally to overall project configuration. For example., there is the project-manager
devShell that contains all the inputs and environment from the configuration of the project, which you may or may not want to expose via various devShells in your environment.
Project Manager should probably have a devenv module for defining devShells.
This is at the other end of the spectrum and is maybe more of a competitor to Project Manager. Flake Parts turns your entire flake into a module. But its purpose is to generate the flake itself. While Project Manager does generate some flake outputs, it also generates a lot of things outside of the flake (like formatter configurations, online service configurations, etc.). The tradeoff here is that (like many other tools, including Home Manager and Nixos) Project Manager has an activation package that needs to be run, while Flake Parts is pure.
Flake Parts should probably have a Project Manager module for defining projectConfiguration
outputs.
This is more than inspired by Home Manager, it’s basically a hacked-up copy (for now). That should change to depend on Home Manager for what it can and to do everything else its own way. But this is a pre-pre-alpha at the moment, so it’s hack city.
Footnotes
-
We try to use hard links instead of copies whenever possible. However, we need to use copies when the Nix store is on a separate volume from the working tree. In the case of “repository” persistence, it’s also the case that any files updated by a checkout will be copies until
project-manager
is run again (which can be done in apost-checkout
hook, avoiding those copies. ↩ ↩2