Probably Legit blog

A Pedantic Nix Shell

Kicking off any new project means first grabbing a bunch of tools. Compilers, linters, build systems, utilities, whatever. In order for others and/or your future self to be able to work on the project, the required versions of those tools need to be tracked somehow. The manual approach — i.e. an install script or even just some notes in a README — might be sufficient here. But there are plenty of options for automating it too. Some of those options are tool-specific (e.g. nvm and rustup) and some are more general (e.g. asdf). My go-to for this problem is Nix.

If you’re not already familiar with it, I won’t be introducing it here. There are plenty of resources that do a better job of that than I can (maybe start here?).

If you’re familiar with it and hate it, that’s fair and I’m not here to change your mind. I would defend that it’s fundamentally a Good Idea™, but the developer experience has a lot to be desired. And that quite rightly puts a lot of people off. If this is you, maybe check out one of the many projects that try to deliver the benefits of Nix without the Nix (devbox , nixpacks , flox, to name a few).

If you’ve drunk the Kool-Aid and want to use Nix with your next project, or integrate it into an existing project, this one’s for you. And if you’re fussy about code formatting and style, this one’s really for you.

🥱

TL;DR install Nix, clone the repo and you’re off. Take a look at the branches for language-specific templates to copypasta.

Let’s go 🚀

    $ mkdir ~/shiny-new-project && cd $_
  

A Minimal Nix Shell 🤏

Here’s some basic flake.nix boilerplate to get us going:

flake.nix
    {
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
  };
  outputs = inputs@{ flake-parts, ... }:
    flake-parts.lib.mkFlake { inherit inputs; } {
      systems = [
        "x86_64-linux"
        "aarch64-linux"
        "aarch64-darwin"
        "x86_64-darwin"
      ];
      imports = [
      ];
      perSystem = { pkgs, ... }:
        {
          devShells.default =
            let
              tools = [
                pkgs.fortune
                pkgs.cowsay
                pkgs.lolcat
              ];
              welcomeShellHook = ''
                fortune | cowsay | lolcat
              '';
            in
            pkgs.mkShell {
              nativeBuildInputs = tools;
              shellHook = ''
                ${welcomeShellHook}
              '';
            };
        };
    };
}
  
A note on Nix flakes

Nix is still young, and is churning quite a bit. One recent change in the Nix world, which has unfortunately opened up a bit of a rift, is the move towards flakes and the single nix command. There is still plenty of documentation for using a shell.nix and/or default.nix along with the nix-shell command, but the use of flakes is preferred. It reminds me a lot of the (still ongoing?) python2 to python3 transition…

This flake defines a development shell with some tools (fortune, cowsay, and lolcat) installed. It also defines some initialisation code to run before entering the shell, which makes use of those packages. We can enter the shell by running nix develop:

A screenshot of a terminal in which the minimal nix flake is enabled

By default this drops us into a plain bash shell, when we’d probably prefer to stay in our current shell. This can be fixed by passing something like --command "$SHELL" to nix develop, but we also don’t want to have to remember to do this every time we spin up a new terminal. Enter direnv, which solves both these problems…

direnv is an extension for your shell. It augments existing shells with a new feature that can load and unload environment variables depending on the current directory.

If you have direnv installed, you can activate the Nix development shell whenever you enter the directory by creating an .envrc:

.envrc
    use flake .
  
💡

I recommend installing nix-direnv to make this work a bit faster and nicer. 👌

Adding Formatting

The first thing we’ll add to our humble Nix dev environment is code formatting.

I like to enforce consistent code formatting from the get-go. Otherwise you’ll end up with the dreaded git commit -m 'format the codebase' screwing with your git history.

Most languages these days have a blessed formatter. And we’re at a point where almost every file under source control can be overseen by a formatting tool.

But remembering the CLI spell for each of these formatters can be a pain. Enter treefmt, which solves this problem…

treefmt is a formatting tool that saves you time: it provides developers with a universal way to trigger all formatters needed for the project in one place.

treefmt also integrates nicely with Nix. So let’s go ahead and add it to our flake.nix:

    diff --git a/flake.nix b/flake.nix
index 92d2de0..9181bb0 100644
--- a/flake.nix
+++ b/flake.nix
@@ -1,6 +1,9 @@
 {
   inputs = {
     nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
+    flake-root.url = "github:srid/flake-root";
+    treefmt-nix.url = "github:numtide/treefmt-nix";
+    treefmt-nix.inputs.nixpkgs.follows = "nixpkgs";
   };
   outputs = inputs@{ flake-parts, ... }:
     flake-parts.lib.mkFlake { inherit inputs; } {
@@ -11,16 +14,28 @@
         "x86_64-darwin"
       ];
       imports = [
+        inputs.flake-root.flakeModule
+        inputs.treefmt-nix.flakeModule
       ];
-      perSystem = { pkgs, ... }:
+      perSystem = { pkgs, config, ... }:
         {
+          treefmt.config = {
+            inherit (config.flake-root) projectRootFile;
+            package = pkgs.treefmt;
+            programs = {
+              nixpkgs-fmt.enable = true;
+            };
+          };
           devShells.default =
             let
+              treefmt = config.treefmt.build.wrapper;
+              treefmt-programs = builtins.attrValues config.treefmt.build.programs;
               tools = [
+                treefmt
                 pkgs.fortune
                 pkgs.cowsay
                 pkgs.lolcat
-              ];
+              ] ++ treefmt-programs;
               welcomeShellHook = ''
                 fortune | cowsay | lolcat
               '';

  

There’s a few things going on here, so let’s break it down.

  1. We add some dependencies (i.e. inputs) to our flake. flake-root is a simple, pure-nix helper to give us the location of the project root. treefmt-nix is a wrapper around treefmt that let’s us configure the tool with Nix. Note that we tell the treefmt-nix to share our nixpkgs package set (via treefmt-nix.inputs.nixpkgs.follows = "nixpkgs" ) to avoid any surprises with tool versions.
  2. We import those dependencies via their flakeModule attributes.
  3. We configure treefmt to format *.nix files with nixpkgs-fmt.
  4. We add treefmt and all it’s associated formatting “programs” (i.e. just nixpkgs-fmt at this stage) to our list of tools. Adding the individual programs means that tool-specific editor integrations can still be used.

After making this change direnv should kick in, pin the new inputs, and add them to the environment:

    direnv: loading ~/shiny-new-project/.envrc
direnv: using flake .
warning: Git tree '~shiny-new-project' is dirty
warning: updating lock file '~/shiny-new-project/flake.lock':
• Added input 'flake-root':
    'github:srid/flake-root/<some rev>'
• Added input 'treefmt-nix':
    'github:numtide/treefmt-nix/<some rev>'
• Added input 'treefmt-nix/nixpkgs':
    follows 'nixpkgs'

$ treefmt --version
treefmt x.x.x
$ nixpkgs-fmt --version
nixpkgs-fmt x.x.x
  

We can now format all our files by running treefmt:

    $ treefmt
[INFO ] #nixpkgs-fmt: 1 files processed in 4.54ms
0 files changed in 7ms (found 2, matched 1, cache misses 1)
  

Sweet. This makes formatting easy. But I don’t want to have to remember to run it all the time. Ideally, I want to be prevented from committing unformatted code…

Adding Git Hooks

Checking code quality on each commit is a great idea. Not only does it prevent git commit -m 'placate some linter' commits, but it can also save you from wasted CI/CD runs, which cost both in time and money.

Working with plain git commit hooks is fine, but can get a bit fiddly. Enter pre-commit, which solves this problem…

A framework for managing and maintaining multi-language pre-commit hooks.

And much like treefmt, it also has a neat Nix integration. Let’s add it to our shell:

    diff --git a/flake.nix b/flake.nix
index 9181bb0..84c3062 100644
--- a/flake.nix
+++ b/flake.nix
@@ -4,6 +4,8 @@
     flake-root.url = "github:srid/flake-root";
     treefmt-nix.url = "github:numtide/treefmt-nix";
     treefmt-nix.inputs.nixpkgs.follows = "nixpkgs";
+    pre-commit-hooks-nix.url = "github:cachix/pre-commit-hooks.nix";
+    pre-commit-hooks-nix.inputs.nixpkgs.follows = "nixpkgs";
   };
   outputs = inputs@{ flake-parts, ... }:
     flake-parts.lib.mkFlake { inherit inputs; } {
@@ -16,6 +18,7 @@
       imports = [
         inputs.flake-root.flakeModule
         inputs.treefmt-nix.flakeModule
+        inputs.pre-commit-hooks-nix.flakeModule
       ];
       perSystem = { pkgs, config, ... }:
         {
@@ -26,6 +29,9 @@
               nixpkgs-fmt.enable = true;
             };
           };
+          pre-commit.settings.hooks = {
+            # TODO: add some hooks
+          };
           devShells.default =
             let
               treefmt = config.treefmt.build.wrapper;
@@ -43,6 +49,7 @@
             pkgs.mkShell {
               nativeBuildInputs = tools;
               shellHook = ''
+                ${config.pre-commit.installationScript}
                 ${welcomeShellHook}
               '';
             };

  

This adds the dependency and the installation script. The installation script generates a .pre-commit-config.yaml, which you can go ahead and .gitignore:

    diff --git a/.gitignore b/.gitignore
index 9b42106..410621b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,2 @@
 .direnv/
+.pre-commit-config.yaml

  

No actual hooks are configured yet. Let’s enable the treefmt hook, using our pre-configured treefmt:

    diff --git a/flake.nix b/flake.nix
index 84c3062..473962c 100644
--- a/flake.nix
+++ b/flake.nix
@@ -30,7 +30,8 @@
             };
           };
           pre-commit.settings.hooks = {
-            # TODO: add some hooks
+            treefmt.enable = true;
+            treefmt.package = config.treefmt.build.wrapper;
           };
           devShells.default =
             let

  
💡

See the project README for the full list of builtin hooks. Also note that it’s trivial to add your own custom hooks.

Now when we commit this change we should see treefmt being run on the staged file:

    $ git commit -m 'Enable treefmt pre-commit hook'
treefmt..................................................................Passed
[main 21467c3] Enable treefmt pre-commit hook
 1 file changed, 2 insertions(+), 1 deletion(-)
  

And if we attempt to commit some unformatted code it should fail:

    $ git commit -m 'Commit unformatted code'
treefmt..................................................................Failed
- hook id: treefmt
- exit code: 1
- files were modified by this hook

[INFO ] #nixpkgs-fmt: 1 files processed in 2.90ms
1 files changed in 4ms (found 1, matched 1, cache misses 1)

formatted files:
#nixpkgs-fmt:
- ~/shiny-new-project/flake.nix
[ERROR] fail-on-change
  

Wrapping up

The next step would be to add some language-specific tooling.

Rather than bloat this article with a bunch of examples for NodeJS, Rust, etc, I’ve added some language-specific examples to the template repo.

Feel free to open an issue if your language and/or toolchain isn’t there and you’d like it to be. Also feel free to contribute if you think any of the templates can be improved. 🙏

Appendix

What about devenv / devshell / lorri / niv

There has been a lot of effort put into making Nix-based developer environments more accessible. And you should probably check these projects out if you haven’t already. I personally prefer sticking to good ol’ nix develop.

The direnv standard library

It’s worth getting to know the direnv standard library, as it provides some useful functions.

I often use PATH_add to make scripts available, for example:

.envrc
    use flake .
PATH_add ./scripts
PATH_add ./node_modules/.bin
  

Adding secret environment variables 🔓

If you want to add secret environment variables to the project, I suggest adding them to a (gitignored!) .envrc.local and sourcing that file from the checked-in .envrc, i.e:

.envrc
    use flake .
[[ -f .envrc.local ]] && source_env .envrc.local
  
💡

See this issue for the ongoing discussion on how direnv should support this natively.

You might also want to check out Doppler, which integrates nicely with direnv.

Bypassing pre-commit hooks 🤠

There will inevitably be times when you need to ignore failing commit hooks. When this happens, it’s good to know that

    $ git commit --no-verify
  

Will commit without running hooks.

Adding a pre-push hook

Sometimes there are checks (e.g. expensive integration tests) that are too heavy to run on each commit, but you’d like to run before pushing. Try something like:

    diff --git a/flake.nix b/flake.nix
index 473962c..4a1441c 100644
--- a/flake.nix
+++ b/flake.nix
@@ -32,6 +32,14 @@
           pre-commit.settings.hooks = {
             treefmt.enable = true;
             treefmt.package = config.treefmt.build.wrapper;
+            e2e-tests = {
+              enable = true;
+              name = "e2e-tests";
+              entry = "echo test all the things";
+              language = "system";
+              pass_filenames = false;
+              stages = [ "pre-push" ];
+            };
           };
           devShells.default =
             let

  

What about Codespaces

I like the idea, but I also like working from places with unstable network connections.