Skip to content

mobiuscog/dotfiles-tool

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

25 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

dotfiles-tool

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)

Adding the tool to your dotfiles repo

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' >> .gitignore

Then 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" "$@"

Install (fresh machine)

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)

Usage

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.

Platform overlays (OS/arch)

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 ~/.zshrc again → detects your platform and offers the next more-specific override (common → os → os-arch), seeded from the current content; the $HOME link re-points to the new layer. --platform <key> forces a layer explicitly (must match the machine).
  • dotfiles remove acts on the resolved layer: an override parks to staged@<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/audit resolve per machine: the same manifest links home@linux/.zshrc on your linux box and home/.zshrc on the mac. Audit shows the active layer per entry and mirror-checks every layer directory (including other platforms') against its manifest section.

Backups: list and restore

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 one

Restore 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.

Machine-local ignores

"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.

Software specs (software.toml)

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 exist

sync 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 upgrade

misc/ — opt-in storage

misc/ 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 change

Targets 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/ — reconcile-then-promote workbench

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).

Layout

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

Safety model

  • 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 until dotfiles recover repairs 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|abort covers non-interactive use. One provably-lossless exception: if the occupant is byte-identical to the repo copy, install relinks it without asking.
  • .dotignore matches are excluded on add (with a prompt for directories containing them) and audit warns if matches ever appear inside home/ (e.g. an app wrote a token into a managed directory).

Trust model

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).

Templates

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.

Development

cargo test                       # unit + integration + TUI tests
DOTFILES_FORCE_COPY=1 cargo test # exercise the cross-device path
cargo clippy --all-targets
cargo fmt --check

Licensed under the MIT license.

About

A dotfiles management tool (Co-authored by Claude)

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors