sennit
A dotfiles manager that keeps symlink semantics, and adds templating and drift detection.
sennit — braided cordage, made by plaiting many strands into a single line.
Why another one?
Most dotfiles managers make you choose between two models, and both cost something:
- Symlink-based (GNU Stow, hand-rolled scripts) —
~/.config/nvim/init.luais the file in your repository. Edit it in place and it shows up ingit diff. But there is no templating, no way to preview what an apply will change, and nothing checks that the packages your configs depend on are actually installed. - Copy-based (chezmoi) — you get templating and per-machine variants, but the source
and the target are different files. Editing
~/.configdirectly is a mistake that gets silently reverted on the next apply.
sennit takes the position that this is a false choice. Templating and symlinking are only in tension for the files that actually need templating — usually a handful that carry a color palette. So sennit renders those into your repository, commits the result, and symlinks everything the same way. The live-edit loop survives for every other config.
Quick start
In the root of your dotfiles repository:
# sennit.toml — what to place
[]
= [".config", ".zshenv"]
= [".hammerspoon"]
= [".local"]
= ["*.tmpl"]
sennit looks for sennit.toml by walking up from the current directory, so it works
from anywhere inside your repository.
Directories are linked file by file rather than as a whole, so that a tool writing into
~/.config/something/ does not make untracked files appear inside your repository.
Commands
sennit apply |
Place symlinks. Only touches entries that need changing. --dry-run to preview. |
sennit diff |
Show what an apply would change, before it happens. |
sennit list |
Show the current state of every managed path. |
sennit render |
Expand templates from a single source of truth. --check fails if the committed output is stale. |
sennit check |
Verify that every dependency your configs reference is declared. |
sennit sync |
Install declared packages that are missing. |
Every path is classified as one of four states, and only the ones that need work are touched:
linked |
already pointing at the right file |
missing |
nothing is there |
wrong |
a symlink pointing somewhere else |
occupied |
a real file or directory is in the way |
Templating
Add a theme.toml (or any TOML file of values) and declare what is generated from what:
# sennit.toml
[]
= ".config/alacritty/alacritty.toml.tmpl"
# theme.toml
[]
= "#1a1b26"
= "#c0caf5"
# .config/alacritty/alacritty.toml.tmpl
[]
= "{{ ui.bg }}"
= "{{ ui.fg }}"
Templates do substitution and nothing else. There are deliberately no conditionals or loops: the moment a template gains control flow, it stops being readable as the config file it produces. An unknown variable is an error rather than an empty string, so a typo cannot quietly ship a broken config.
Generated files are meant to be committed. Run sennit render --check in CI to catch the
case where a template was edited but the output was not regenerated.
Drift detection
The problem check solves is specific: you update a config, and forget to update the
package list. The config references a tool that a fresh machine will never install, and
nothing tells you until you set up that machine months later.
# packages.toml
[]
= { = ["delta"] } # the command it installs is named differently
= { = ["nvim"] }
[]
= "brew-cask"
= "font"
= ["Hack Nerd Font Mono"]
[]
= true # known dependency, deliberately not installed automatically
[]
= ["brew", "git", "curl"] # provided by the system or the bootstrap
check reads your configs with format-aware detectors rather than grepping for strings:
- commands invoked from
git config(core.pager,interactive.diffFilter, credential helpers) - font families in terminal and editor settings
- editor extensions declared for auto-install
- commands used in shell startup files
- the mere existence of
.config/<tool>/, which is sometimes the only evidence of a dependency
Anything you deliberately do not install is declared optional = true, so the file records
what is intentional rather than hiding it in an ignore list.
Installing packages
sync reads the same packages.toml and installs what is missing:
It asks each manager what is already installed and only installs the difference, so idempotency does not depend on the manager's own behaviour.
| manager | |
|---|---|
brew |
default; works on Linux too via Homebrew on Linux |
brew-cask |
macOS only, except font casks which install on Linux as well |
mise |
runtimes |
apt / yay |
Linux; selected automatically from what the machine has |
Package names differ between distributions, so declare them where they do:
[]
= "libyaml-dev" # on Debian-like systems, use apt with this name
= "libyaml" # on Arch-like systems, use yay with this name
On Linux, an apt or yay entry decides both the manager and the name. Without one,
the default manager is used. Entries marked optional, or restricted to another OS via
os = ["darwin"], are skipped. Editor extensions are declared so that check knows about
them, but are installed by the editor itself.
sync does not replace your bootstrap script: something still has to install Homebrew,
or sudo, before sennit can run at all.
Status
v0.3. Minimum supported Rust version is 1.90.
The author uses it to manage ken109/dotfiles; if you
adopt it, start with sennit diff and --dry-run before the first apply, since apply
will replace whatever is currently sitting at a managed path.
License
MIT