Skip to content

Repository files navigation

dotdrift

A CLI tool for managing Linux configuration through git-backed profiles.

Principles

  • Presence = managed: every modules/<id>/module.toml is selected; no enable list.
  • Apply always resumes: dotdrift apply continues from the first incomplete step.
  • Mise owns files: symlink, copy, template, and partial-edit operations are performed by mise.
  • Selection precedence: module → host → user; user wins, disable is unioned across layers.

Installation

From a release (recommended)

Run the installer with curl | bash. It resolves the latest release, verifies the SHA-256 checksum, and installs the binary to ~/.local/bin:

curl -fsSL https://raw.githubusercontent.com/thedataflows/dotdrift/main/install.sh | bash

Pin a specific release, or install somewhere else:

curl -fsSL https://raw.githubusercontent.com/thedataflows/dotdrift/main/install.sh | bash -s v0.4.0
curl -fsSL https://raw.githubusercontent.com/thedataflows/dotdrift/main/install.sh | DOTDRIFT_BINDIR=/usr/local/bin bash

From a clone, run it directly: ./install.sh [version]. It needs curl, tar, and sha256sum, and supports Linux on amd64/arm64. Each release ships prebuilt binaries with an SHA-256 checksum.

From source

With Go 1.26 or newer:

go install github.com/thedataflows/dotdrift@latest

Runtime dependency

dotdrift requires mise ≥ 2026.8.2 on PATH (needed for mise bootstrap declarative convergence). When missing or too old, it installs a user-local copy via https://mise.run.

Quick start

# Create a profile
dotdrift init ./my-profile
cd ./my-profile

# Onboard an existing config path into a module
dotdrift onboard ~/.bashrc

# See what would change
dotdrift plan

# Apply the profile (always resumes)
dotdrift apply --yes

# Show current state
dotdrift status

Profile layout

profile/
├── dotdrift.toml
├── modules/
│   └── <id>/
│       ├── module.toml
│       └── home/...          # files referenced by dotfile entries
├── hosts/<hostname>/
│   ├── dotdrift.toml           # host layer: disable list unioned
│   └── modules/<id>/...        # host overlays
└── users/<username>/
    ├── dotdrift.toml           # user layer: disable list unioned
    └── modules/<id>/...        # user overlays (highest precedence)

Each layer's dotdrift.toml may carry a [modules] disable list. Disables are unioned across the base, host, and user layers.

module.toml is validated strictly: an unknown key (typo, misplaced table) fails every command at load with <path>/module.toml:<line>: unknown key "<key>", never a silent zero value. The full attribute set is documented in profile layout.

Hooks

A module may declare shell commands to run around the apply pipeline:

# modules/<id>/module.toml
[hooks]
pre = ["echo about to apply"]
post = ["systemctl --user daemon-reload"]
  • When they runpre runs as the hooks-pre step, before packages are installed; post runs as hooks-post, after dotfiles.
  • How they run — commands execute as mise tasks from the profile root, with DOTDRIFT_PROFILE, DOTDRIFT_HOSTNAME, DOTDRIFT_USERNAME, DOTDRIFT_OS, and DOTDRIFT_BACKEND in the environment.
  • Interactive commands — when dotdrift apply has a terminal on stdin, hook tasks are generated with interactive = true, so an interactive command in a hook (e.g. sudo) connects to the terminal and can disable echo on its password prompt instead of echoing it. With no terminal (piped/CI), hooks run normally.
  • How they merge — unlike other sections, hooks are appended across layers (base → host → user) and modules, in selection order.
  • Failure and resume — a failing hook fails its step, and resume re-runs it. Write post hooks to be idempotent.
  • Visibility — hooks are listed in dotdrift plan. Skip them with dotdrift apply --no-hooks or DOTDRIFT_NO_HOOKS=1.

Scope

A module applies to the home directory by default. Set scope = "system" in its module.toml to manage system dotfiles (e.g. targets under /etc):

# modules/<id>/module.toml
scope = "system"

[dotfiles]
"/etc/demo.conf" = { source = "demo.conf", mode = "copy" }

A single dotdrift apply covers both scopes. User dotfiles apply as usual; system dotfiles converge in a dotfiles-system step that pre-checks whether every system target is user-writable, and elevates via sudo when any target is not writable.

  • At most one password prompt per apply (sudo timestamp cache) — none at all when already running as root or when the system files are already converged.
  • System-scope entries are marked [system] in dotdrift plan.
  • Packages self-elevate via the distro backend, and hooks carry their own inline privilege, so neither needs scope machinery.

Edit entries

A [dotfiles] entry can be a partial edit to a file something else owns — keyed by <file-path>/<edit-id> (the last slash splits file path from edit id). Four forms:

# modules/<id>/module.toml
[dotfiles]
# Ensure an exact line exists (idempotent)
"~/.profile/mise" = { line = 'eval "$(mise activate bash)"' }

# A marker-delimited block, re-applied in place; comment is optional (default #)
"~/.zshrc/aliases" = { block = "alias ll='ls -l'", comment = "#" }

# A block loaded from a source file (raw contents, no rendering).
# Avoids multi-line inline content in module.toml.
"~/.zshrc/aliases" = { source = "aliases.sh", mode = "edit" }

# A rendered block (mise renders the template at apply time)
"~/.gitconfig/user" = { source = "git-user.tmpl", template = "tera" }
  • line ensures an exact line exists in the file.
  • block wraps content in mise marker delimiters (>>> mise:<id> >>> / <<< mise:<id> <<<); re-apply updates the block in place. comment sets the comment prefix.
  • source + mode = "edit" reads the source file's raw contents and uses them as a block (dotdrift convenience — keeps multi-line snippets out of module.toml; resolved across layers at plan time).
  • source + template renders the source via the named engine (e.g. tera) and inserts it as a block (mise renders at apply time).
  • Either scope — like whole-file entries. User-scope edits apply as the invoking user; system-scope edits (e.g. /etc/hosts/dev) apply via elevated mise dotfiles apply (sudo).
  • status driftline is checked by exact match, block (including mode = "edit") by content between markers, template by marker presence only (rendering needs mise's engine).

See docs/product/profile-layout.md for the full validation rules and examples/simple/ for a worked block-edit example.

Generate

dotdrift generate renders a derived module from declarations in module.toml:

  • generate mounts — writes a system-scoped module holding systemd .mount units (plus .service/.timer when --startat is set).
  • generate smb — writes a module holding shares.conf and a one-time smb.conf seed.

Mounts and shares are declared as [mounts.<name>] and [smb] / [smb.shares.<name>] tables in the module's module.toml, merged whole-entry by name across layers. The rendered files are derived artifacts.

  • Interactive vs strict mode — on a terminal with no input flags, an interactive wizard runs (byte-identical result to the flag mode); with any input flag, strict CLI mode applies.
  • Layer selection--layer base|host|user picks where the module lands. A host-layer-only module needs no base stub.
  • Batching — one CLI run writes one mount, since the [mounts] section is replaced wholesale. Use the wizard's "add another mount?" loop, or one --module per mount, for several mounts. --share is repeatable.
  • Activation — the regular apply pipeline places the files via mise and activates them in the conditional mounts / smb steps (unit enablement, timers, samba group/users/service).

See docs/product/cli-surface.md for the full flag reference, and docs/product/migrate-pimp-my-cachyos.md for a worked migration. See examples/simple/ for a minimal single-module profile, and examples/profile/ for a multi-layer example with host and user overlays.

Note: dotdrift apply stores the resume cursor while applying (and generated mise config) under the XDG state directory ($XDG_STATE_HOME/dotdrift/, defaulting to ~/.local/state/dotdrift/), so the profile directory is never polluted with runtime state. The cursor file is deleted on completion, so a successful apply leaves no state file. dotdrift onboard does the same (.../profiles/<hash>/onboard/mise.toml); pass --yes to answer mise prompts non-interactively.

sudo warning: dotdrift resolves the username from the OS account, not $USER. Running sudo dotdrift apply selects root's overlays and writes into root's HOME. To manage your own dotfiles, run dotdrift as your normal user; use sudo only if you intentionally maintain a users/root/ overlay.

Commands

Command Purpose
dotdrift init [path&#124;git-url] Create a new profile (git-initialized) or clone a profile repo.
dotdrift detect Print host/user/os/kernel/distro/gpu/backend facts.
dotdrift modules [modules...] List selected and skipped modules (optionally limited to the listed modules).
dotdrift plan [--json] [modules...] Print the effective plan without side effects (--json for machine-readable output; optionally limited to the listed modules).
dotdrift apply [--yes] [--no-hooks] [--verbose] [--diff[=tool]] [modules...] Run the full pipeline and resume from the last successful step (the cursor file is deleted on completion; optionally limited to the listed modules). --diff shows colored unified diffs for differing copy-mode dotfiles before applying; bare = internal diff, --diff=delta uses the named tool with <target> <source> args.
dotdrift status [-v] [-j N] [--diff[=tool]] [modules...] Show drift between the profile and the live system (packages, tools, dotfiles, mounts, smb), plus the resume cursor. Probes run concurrently (-j N workers; default: CPUs); -v streams per-probe progress to stderr; --diff shows colored unified diffs after the report. Exits 0 even when drift is found.
dotdrift onboard [--verbose] <path>... Copy live paths into a module and apply; re-running updates the module (refresh files, merge module.toml).
dotdrift generate mounts&#124;smb Generate a mounts module (systemd units) or smb module (samba shares) into a profile layer; interactive wizard on a terminal, strict flag mode otherwise.

-v / --verbose (also DD_VERBOSE=1) streams package manager and mise output live on apply and onboard, echoing each command line set -x-style to stderr immediately before it runs — e.g. + paru -S --needed --noconfirm jq — and runs mise itself in verbose mode (MISE_VERBOSE=1) so its DEBUG logging streams alongside; without it child-process output is captured and only surfaced in errors.

--no-color (or the NO_COLOR environment variable, per no-color.org) disables ANSI colors in all dotdrift output and propagates NO_COLOR=1 to child processes (mise, paru). On a TTY, the status report colors findings by issue type (orange = missing, red = not-a-symlink/unknown, yellow = content/version diff, green = all-OK) and diffs are colored (green additions, red deletions); piped output is always plain.

# Generate an NFS mount module with a nightly timer, then two samba shares
dotdrift generate mounts --name syn01 --source nas:/volume1/syn01 \
  --destination /mnt/synology/syn01 --type nfs --startat "*-*-* 18:05:00"
dotdrift generate smb --share media=/srv/media --share data=/mnt/data --no-avahi

Module filter

modules, plan, apply, and status accept optional positional module ids that limit the command's scope to those modules. Ids are space- or comma-separated (both forms mix freely):

dotdrift apply vim git      # only vim and git
dotdrift apply vim,git      # same
dotdrift plan shell --json  # plan for shell only
dotdrift status vim         # drift for vim only

With no ids the behavior is unchanged.

- **Unknown id** — a loud error naming the unknown ids and listing the valid module ids.
- **Selected but skipped** — naming a module that exists but is not selected (disabled via `[modules] disable` or excluded by a `when` filter) is also an error, naming it and its skip reason. The filter never resurrects skipped modules.

> **Resume caveat:** the resume cursor is a bare step name. A filtered apply that resumes from another run's cursor cannot detect the scope change, so it may skip steps the filtered run would have executed — delete the state file (path shown by `dotdrift status`) to force a full run.

## Testing

```bash
go test ./...

Unit tests run offline. Integration tests against real tools can be added with //go:build integration.

Integration tests

./tests/e2e/run.sh

Builds and runs a Docker end-to-end suite across three distros — the debian family (debian:bookworm-slim, ubuntu:24.04) and CachyOS (cachyos/cachyos). It requires Docker and network access.

Each container builds dotdrift from this repo, onboards a live file with a real mise.run bootstrap, and runs dotdrift apply against a fixture profile. The suite then asserts:

  • a real package install (apt on the debian family, pacman/paru on CachyOS) of a leaf package (jq);
  • dotfile symlinking;
  • pre/post hooks executed as mise tasks;
  • a second apply runs the full pipeline again;
  • no state file left on disk; and
  • no runtime pollution inside the profile.

The CachyOS image is the first coverage of dotdrift's Arch backend (paru -S install + pacman -Q idempotency); it ships paru from the CachyOS binary repo, so no AUR build is needed. The suite runs on push to main via .github/workflows/e2e.yml; the offline go test ./... gate is unchanged.


License

MIT License