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 |
Render templates, then place symlinks. Only touches what needs changing. --dry-run to preview. |
sennit rollback |
Put back files that the last apply moved aside. |
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 verify |
Verify that everything declared actually resolves on this machine. |
sennit audit |
Cross-check declarations against shell history, to find ones nothing uses. |
sennit sync |
Install declared packages that are missing. |
sennit compare |
Diff two verify --export reports, to see how two machines differ. |
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 not meant to be committed. apply renders before it links, so a fresh
clone produces them, and adding them to git would only mean the same change showing up
twice in every diff. It also keeps secrets out: a template can pull from 1Password, and
nothing it produces reaches the repository.
# .config/something.tmpl
= "{{ op://Personal/GitHub/token }}"
Any {{ op://... }} is read through the 1Password CLI at render time — but only when you
ask for it. apply skips those templates and says so; apply --secrets renders them.
That split is not a convenience. 1Password needs a person to sign in, enable the CLI
integration, and unlock the app, none of which can happen partway through an unattended
install, and none of which exist at all on a headless Linux box or inside a container. If
secrets were rendered by default, the first run on a new machine would always fail. This
way apply always succeeds, and the secret-bearing files simply are not there until you
run it again with --secrets.
A template that references op:// also makes check require the op command, so the
dependency shows up the moment it exists rather than on whichever machine first tries to
use it.
What apply will not do
apply never destroys a file you wrote. When something that is not a symlink is sitting
where a link should go, it is moved to <name>.sennit-backup rather than deleted, and
sennit rollback puts it back. --no-backup opts out, and is the only way to lose
anything.
It also remembers what it linked. A path that was linked last time and is no longer
declared gets its symlink removed, so dropping a config from the repository does not leave
a dangling link behind in $HOME.
Running things after placing them
Placing a file is often only half of it. .config/bat/themes/ is useless until
bat cache --build has registered what is in it, and until then BAT_THEME silently does
not resolve and everything downstream falls back to default colours. That relationship
lived in a shell script, ran unconditionally, and was written down nowhere.
[]
= [".config/bat/themes"]
= "bat cache --build"
apply runs a hook after linking, and only when what it watches has changed. A hook with
no when-changed runs every time.
Per-machine variation
os = ["darwin"] restricts a declaration to one platform. profiles restricts it to a
purpose:
[]
= "brew-cask"
= ["work"]
The profile comes from SENNIT_PROFILE, which takes a comma-separated list. A declaration
with no profiles always applies; one with profiles applies only when it overlaps, so
an unset SENNIT_PROFILE installs less rather than more.
Templates see the same context, alongside whatever is in your data files:
{{ sennit.os }} |
darwin or linux |
{{ sennit.hostname }} |
short hostname |
{{ sennit.profile }} |
the current profile list |
{{ env.ANYTHING }} |
environment variables |
[data] in sennit.toml lists which files to read; it defaults to theme.toml alone.
File modes
A config holding a token still works perfectly at mode 0644, which is exactly why nobody
notices. Declare what it should be and apply sets it, verify checks it:
[]
= "600"
The longest matching prefix wins, so a directory can be declared once and one file inside it overridden. Rendered output containing a secret is set to 0600 even without a declaration, since the default umask would otherwise publish it.
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
Detectors are written per format, so a format nobody wrote one for is invisible. Rather than wait for that, a config can say so itself, in whatever passes for a comment there:
# sennit: requires command hunk
# sennit: requires font "Hack Nerd Font Mono"
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.
check also looks the other way, at fonts and editor extensions that are declared but
that nothing references. That is the drift you get when a config stops using something and
the package stays behind — easy to miss, because everything keeps working. Commands are
left out of this direction on purpose: plenty of them (bat, fd, rg) are used daily
from the shell without appearing in any config file.
Three ways of being wrong
Declarations go stale in three different directions, and each needs a different kind of evidence:
| question | evidence | |
|---|---|---|
check |
is everything the configs need declared? | the repository |
verify |
does everything declared actually exist here? | this machine |
audit |
does anything actually use it? | shell history |
check is static and machine-independent, so it can gate CI. verify catches a
declaration that names something wrong — Homebrew's formula is gnupg, not gpg, and the
difference is invisible until you look at the machine. It only judges what can be judged:
commands on PATH and installed font families. GUI applications and libraries are counted
and skipped, because their absence from PATH means nothing.
audit covers the gap the other two cannot see: tools you only ever type. rg and fd
appear in no config file, so removing their declarations breaks nothing that check can
notice. It cross-references history with the configs, so a tool that runs automatically —
starship, delta — is not mistaken for an unused one. It never fails the build: history
is per machine and gets trimmed, so absence is a prompt to look, not proof.
None of the three can answer "what breaks if I remove this". That needs removing it and running the install, which is a job for CI rather than for this binary.
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.
Managers run in dependency order — apt/yay first, since distribution packages are what
Homebrew sits on, then brew, brew-cask, and mise last because brew is what installs
it.
apt needs root. sennit works out how to get it before running anything: as root it calls
apt-get directly, otherwise it uses sudo when that is passwordless or when there is a
terminal for sudo to prompt on. When neither holds — a script or a container build where
sudo would block on a password nobody can type — it stops with an explanation rather than
hanging. yay is never run through sudo, since it refuses to run as root.
sync does not replace your bootstrap script: something still has to install Homebrew,
or sudo, before sennit can run at all.
Status
v0.6. 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