A safe, symlink-based dotfiles manager (CLI + TUI) written in Rust, designed
to live inside your dotfiles repository as a git submodule mounted at
dotfiles/. Files you manage are moved into the repo's home/ (mirroring
your $HOME hierarchy) and replaced by symlinks, so everything keeps working
while the content lives in version control.
The layout of your dotfiles repository (which the tool discovers by walking
up from its own binary to find home/ + manifest.toml):
<your-dotfiles-repo>/
├── dotfiles/ ← this tool (submodule)
├── home/ ← managed files (+ home@<platform>/ overlays)
├── staged/ ← parked content awaiting promote/merge
├── misc/ ← opt-in linked storage
├── scripts/ ← setup scripts referenced by software.toml
├── manifest.toml ← managed roots (mirrors the layer dirs exactly)
├── software.toml ← declared software (cargo/uv/volta/script)
├── .dotignore ← never-enter-the-repo patterns
└── install.sh ← thin bootstrapper delegating to ours
~/myscript.sh -> <repo>/home/myscript.sh
~/.config/abc/xyz -> <repo>/home/.config/abc (whole-dir symlink)
git submodule add <this-repo-url> dotfiles
cp dotfiles/templates/manifest.toml dotfiles/templates/dotignore . # adjust
mv dotignore .dotignore
mkdir -p home scripts
printf '/dotfiles/target/\n/.backups/\n/.staging/\n/.journal/\n/ignored.toml\n/*.toml.tmp\n' >> .gitignoreThen keep a thin install.sh at your repo root that delegates here (see the
one this tool ships for its own development, or copy the pattern below):
#!/usr/bin/env sh
set -eu
REPO="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P)"
[ -f "$REPO/dotfiles/install.sh" ] || git -C "$REPO" submodule update --init -- dotfiles
exec "$REPO/dotfiles/install.sh" "$@"Once your repo is set up, the whole fresh-machine story is two commands:
git clone --recurse-submodules <your-repo> ~/dotfiles
~/dotfiles/install.sh # builds the tool, links ~/.local/bin/dotfiles,
# and offers to run `dotfiles install` right away(Forgot --recurse-submodules? install.sh notices and offers to
initialize the tool submodule for you.) Then optionally:
dotfiles software sync # install declared software (per-item consent)dotfiles add ~/.zshrc ~/.config/nvim # move into the repo + symlink back
dotfiles remove ~/.config/nvim # revert: restore real content in $HOME;
# repo copy parks in versioned staged/
dotfiles audit [--json] # status of every managed entry
dotfiles install [--on-collision=...] # (re)create all symlinks (new machine)
dotfiles recover # repair after an interrupted operation
dotfiles info # platform, hostname, layers, item
# counts, provider tool availability
dotfiles # interactive TUI (tree + file picker)--dry-run is a GLOBAL flag: prefix any mutating command
(dotfiles --dry-run add ~/.zshrc, ... --dry-run software sync, even
... --dry-run recover) and it prints exactly what would happen — the same
planning code runs, nothing is written.
Layers are keyed by OS or OS-arch (linux, macos, linux-x86_64) — never
hostnames. Content location IS the scope, and the manifest mirrors the
directories exactly:
home/ ↔ entries = [...] # applies everywhere
home@linux/ ↔ [platform.linux] entries # linux content / overrides
home@macos/ ↔ [platform.macos] entries
staged@linux/ # parked linux content
Resolution is most-specific-wins: home@linux-x86_64/ → home@linux/ →
home/. An override is the same entry appearing in two layers; a
platform-only entry appears in just one. [platform.<key>] exclude = [...]
subtracts a common entry from matching platforms (recorded automatically by
remove, cleared by re-add/promote).
Day-to-day flow — no flags needed:
dotfiles add ~/.zshrc→ common, as always.dotfiles add ~/.zshrcagain → detects your platform and offers the next more-specific override (common → os → os-arch), seeded from the current content; the$HOMElink re-points to the new layer.--platform <key>forces a layer explicitly (must match the machine).dotfiles removeacts on the resolved layer: an override parks tostaged@<key>/and records an exclude (common keeps serving other platforms); a common entry asks whether to unmanage on this platform only (content stays) or everywhere. With no platform layers in play, behavior is exactly the classic single-platform flow.install/auditresolve per machine: the same manifest linkshome@linux/.zshrcon your linux box andhome/.zshrcon the mac. Audit shows the active layer per entry and mirror-checks every layer directory (including other platforms') against its manifest section.
Every destructive-ish choice archives to .backups/ (gitignored, never
pruned): install collisions, add --keep-backup, stage remove, promote
displacements, ... Now you can get things back:
dotfiles backups list [path] # newest first, with source op + time
dotfiles backups restore <path> # copy the newest backup back to $HOME
dotfiles backups restore <path> --id 1781… # or a specific oneRestore copies (the backup is kept) and goes through the collision prompt
if something occupies the target. The "installed over my configs and regret
it" recipe: dotfiles ignore <path> (unmanage here, repo untouched) then
dotfiles backups restore <path> (original back). Note the asymmetry:
remove restores the repo's content — backups restore is how you get a
pre-install original back. The Overwrite collision choice remains the
one true deletion: it never writes a backup, which is why it double-confirms.
"Don't manage this entry on THIS machine" — without touching the repo copy (your safety backup) or the manifest:
dotfiles ignore ~/.zshrc # unlink (real copy left in place), record locally
dotfiles ignored list # what's ignored here, and where it's recorded
dotfiles ignored save # promote to <hostname>-ignored.toml (committed)
dotfiles ignored hide # demote back to local-only ignored.toml
dotfiles ignored delete .zshrc # un-ignore, then install relinks it (with prompts)ignored.toml is gitignored (purely local); dotfiles ignored save moves the
list into <hostname>-ignored.toml, which is committed so a re-clone on the
same host keeps it. Ignored entries vanish from install/audit (shown as an
informational list), and install's collision prompt offers [i] ignore so
"keep my local file here, forever" is one keystroke. Works preemptively too:
ignoring a not-yet-managed path guarantees it never gets linked here.
Declare the tools your dotfiles expect; the tool detects what's missing and installs only with per-item consent (CLI-only):
[env]
# applied to every command sync runs (detection, checks, requires, installs);
# a script cannot `export` to its siblings, so cross-item env lives here.
CARGO_HOME = "~/.cargo" # values support a leading ~/
path = ["~/.cargo/bin"] # special key: prepended to PATH, so
# freshly bootstrapped tools are found
# uniform syntax: every item is a [provider.name] section, optional body
[cargo.ripgrep] # empty body: installs with --locked (default)
[cargo.cargo-update]
bin = "cargo-install-update" # detection hint when binary != crate
[cargo.fresh-example]
locked = false # per-item opt-out of --locked
[uv.ruff]
[volta.node]
version = "20" # installs node@20; detection matches "node"
[volta.yarn]
[script.rust]
check = "command -v cargo"
run = "scripts/install_rust.sh" # bootstrap: installs cargo itself
[script.zig]
check = "command -v zig" # exit 0 = installed
run = "scripts/install_zig.sh" # repo-relative script or shell command
platforms = ["linux"] # optional os / os-arch filter (any item)
requires = ["cargo"] # waits until these binaries existsync runs in passes until nothing more can progress: items whose provider
is missing (cargo not installed yet) or whose requires aren't satisfied are
deferred and re-detected after each pass — so the rust bootstrap script runs
first, then the cargo packages and requires = ["cargo"] scripts in the next
pass. Anything still blocked at the end is reported with the reason.
dotfiles software status # installed / missing / other platform
dotfiles software sync # asks before each command
dotfiles software sync --yes --only cargo
dotfiles software sync --upgrade # also cargo install --force / uv tool upgrademisc/ holds miscellaneous things that should live in the repo but have no
automatic destination. Items are never linked automatically; you can
explicitly map a direct child of misc/ to a target of your choosing:
dotfiles misc link wallpapers ~/Pictures/wallpapers # map + symlink
dotfiles misc list # mappings + status
dotfiles misc unlink wallpapers [--keep-mapping] # remove the link
dotfiles misc fixup # repair/clean, asking per changeTargets are stored in manifest.toml ([misc], ~/-relative when under
home). audit reports misc status; unlinked items are a normal state. In the
TUI, misc items appear under a misc/ node: Enter/m sets a target, d
unlinks, f runs fixup.
staged/ holds content parked in the repo (versioned, mirroring $HOME
layout) that is not yet managed — typically items that need reconciling with
a live $HOME copy first. Like misc/, nothing here is ever acted on
automatically:
dotfiles stage status # new / identical / differs / managed
dotfiles stage promote scripts/x.sh # -> home/ + manifest + symlink
dotfiles stage promote --merge .zshrc # differs: opens $DOTFILES_DIFFTOOL
# (default vimdiff) staged-vs-live;
# edit the STAGED side, then promote
dotfiles stage remove scripts/x.sh # the full "gone": delete from staged/
# (a copy still goes to .backups/)Promote backs up any live copy to .backups/ before linking. Unmanaging is
deliberately two-step, like a trash can: dotfiles remove only ever parks
the repo copy under staged/<rel> (journaled, still in git), and
dotfiles stage remove is the explicit second step that deletes it — with a
final archive to .backups/. In the TUI, staged items appear under a
staged/ node: p promotes (differs items direct you to the CLI merge
flow), d discards a staged item / removes a managed entry.
Global flags: --dry-run (print steps, change nothing), --repo <path>
(override repo discovery). Env: DOTFILES_BIN_PATH (directory containing the
binary, used as the repo-discovery starting point), DOTFILES_FORCE_COPY=1
(force the cross-filesystem copy path).
| Path | Purpose |
|---|---|
home/ |
managed files, mirroring $HOME |
misc/ |
unmanaged storage; opt-in per-item symlinks |
staged/ |
parked content: pending promotion, or parked by remove |
manifest.toml |
list of managed roots (one symlink each) |
.dotignore |
gitignore-syntax patterns that must never enter the repo |
.backups/ |
timestamped backups from destructive choices (never pruned) |
.journal/ |
write-ahead journals for in-flight operations |
dotfiles/ |
the Rust crate |
- Every mutating operation is journaled (write-ahead, fsynced) and follows copy → byte-verify → atomic swap → delete: a complete verified copy of your data exists at every instant.
- A crash at any point (including
kill -9) leaves a journal; every command then refuses to run untildotfiles recoverrepairs state — rolling forward when the swap already happened, rolling back otherwise. Recovery never deletes the only copy of anything. - Cross-filesystem moves are explicit: staging happens on the destination
filesystem so the visible swap is always an atomic same-fs
rename. - Nothing is ever overwritten silently: collisions prompt (backup is the
default),
--on-collision=skip|backup|abortcovers non-interactive use. One provably-lossless exception: if the occupant is byte-identical to the repo copy,installrelinks it without asking. .dotignorematches are excluded onadd(with a prompt for directories containing them) andauditwarns if matches ever appear insidehome/(e.g. an app wrote a token into a managed directory).
The repo IS the trust boundary. software.toml executes code by design —
run commands, check probes, and requires lookups all pass through
sh -c (and software status, though "read-only" for your filesystem,
runs the check commands). Cloning somebody else's dotfiles repo and
running dotfiles software sync — or even status — runs their code,
exactly as piping their install script to sh would. Review before running;
the per-item confirmation shows each exact command first. Script paths are
confined to the repo's scripts/ directory with no symlinks permitted in
their resolution, so a reviewed repo can't quietly execute files from
elsewhere. The one intentionally destructive escape hatch remains the
Overwrite collision choice (and its --on-collision=overwrite policy,
which skips the interactive double-confirm — that is what the flag is for).
Fully commented templates for every config file — all options and syntax —
live in templates/: manifest.toml
(entries, platform layers, excludes, misc), software.toml (env, cargo,
uv, scripts, requires), ignored.toml, and dotignore.
cargo test # unit + integration + TUI tests
DOTFILES_FORCE_COPY=1 cargo test # exercise the cross-device path
cargo clippy --all-targets
cargo fmt --checkLicensed under the MIT license.