How to manage projects, the *NIX way!

The itch
My projects never lived in one language, one editor, or even one sensible directory. A robotics experiment might need Python, a compiler project wants Rust and LLVM, and an old blog tool still expects something I installed three years ago and immediately forgot about. The code is usually easy to find. Reconstructing the exact environment is the annoying part.
I originally called my little project manager Proji. That repository is retired now, but the useful idea was much smaller than the program: keep projects in predictable places, store the environment beside the code, and generate the launcher menu from data instead of maintaining it by hand.
The current version of that workflow uses four boring pieces:
- Git owns the project.
- Nix describes the tools.
- direnv activates them when I enter the directory.
- A tiny index feeds Rofi,
fzf, or any other menu.
No database is required. This is project management in the literal sense of “where did I put the thing?”
Give projects a home
I use a shallow directory tree grouped by broad purpose:
~/code/
├── experiments/
├── research/
├── tools/
└── websites/
The categories are not taxonomy. They are there so find ~/code -maxdepth 3 remains useful. A project should not move every time my opinion about it changes.
For a small setup, the filesystem itself can be the registry:
find "$HOME/code" -mindepth 2 -maxdepth 2 -type d -name .git -printf '%h\n'
I prefer an explicit file because it lets me hide archived repositories and attach a short label. Save this as ~/.config/projects.tsv:
faqe /home/me/code/tools/faqe
website /home/me/code/websites/website
plant-model /home/me/code/research/plant-model
Tabs separate the label and path. That is enough structure for a menu and still easy to edit without a special client.
Put the environment in the repository
Here is a minimal flake.nix for a Rust project:
{
description = "project development shell";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs = { self, nixpkgs }:
let
system = "x86_64-linux";
pkgs = import nixpkgs { inherit system; };
in {
devShells.${system}.default = pkgs.mkShell {
packages = with pkgs; [
cargo
rustc
rustfmt
pkg-config
];
};
};
}
Run nix flake lock once and commit both files. The lock file pins the inputs; without it, “use nixpkgs unstable” means a different package set later.
Enter manually with:
nix develop
For an older project that uses shell.nix, the equivalent is:
let
nixpkgs = fetchTarball "https://github.com/NixOS/nixpkgs/tarball/nixos-26.05";
pkgs = import nixpkgs {};
in
pkgs.mkShell {
packages = with pkgs; [ git jq ];
}
Then run nix-shell. Flakes and shell.nix solve the same immediate problem here: the repository states which tools belong in its development shell.
Let direnv do the repetitive bit
Install direnv and hook it into the interactive shell. For Bash:
eval ""
For Zsh:
eval ""
With nix-direnv installed, a flake-backed .envrc is one line:
use flake
For shell.nix:
use nix
Approve it after reading it:
direnv allow
Now cd into the project. The tools appear. Leave it and they disappear from PATH. When flake.nix, flake.lock, or shell.nix changes, direnv reloads the environment.
The approval step matters because .envrc is executable shell code. Never auto-approve every repository you clone. That would turn a useful safety check into theatre.
Build the launcher
This script reads the registry, asks Rofi to choose a label, and opens the selected project in a terminal. Save it as ~/.local/bin/project-menu:
#!/usr/bin/env bash
set -euo pipefail
registry="/projects.tsv"
choice=""
test -n "$choice" || exit 0
path=""
test -d "$path" || {
printf 'project path does not exist: %s\n' "$path" >&2
exit 1
}
exec foot --working-directory="$path"
Replace foot with the terminal you actually use. For example, Kitty accepts kitty --directory "$path", while Alacritty accepts alacritty --working-directory "$path".
Make it executable:
chmod +x ~/.local/bin/project-menu
Bind that script to a desktop shortcut. I use the menu for old projects more than active ones; active projects are already sitting in a terminal somewhere.
If Rofi is not available, the same registry works with fzf:
choice=""
Add projects without editing TSV by hand
A helper keeps the registry free of duplicate labels:
#!/usr/bin/env bash
set -euo pipefail
name=""
path=""
registry="/projects.tsv"
mkdir -p ""
if test -f "$registry" && cut -f1 "$registry" | grep -Fxq "$name"; then
printf 'project already exists: %s\n' "$name" >&2
exit 1
fi
printf '%s\t%s\n' "$name" "$path" >> "$registry"
sort -o "$registry" "$registry"
There is deliberately no clone command in it. Cloning a repository, choosing its directory, and deciding whether to trust its .envrc should remain visible operations.
What this setup does not do
It does not track tasks, issues, time, or project status. GitHub and GitLab already do some of that, and plain text does the rest. It also does not make a Nix shell perfectly reproducible by itself. Pin the package source, commit the lock file, and remember that external services and mutable data still exist.
What it does give me is a repeatable entrance. Pick a project, land in its directory, and get the tools it declared. Proji was the first version of that idea. The tiny version has survived longer because there is almost nothing to break.