Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

DotR is a dotfiles manager that is as dear as a daughter.

It keeps a repository of your configuration files (“dotfiles”) separate from where they’re actually used on disk, and manages copying — or symlinking — them into place. The repository is the source of truth; your ~/.bashrc, ~/.config/nvim/, and friends are deployments of it.

Why DotR?

  • Plain files, not magic. Your dotfiles repository is just files and directories under dotfiles/, described by a single config.toml.
  • Copy or symlink, your choice. Deploy as copies for a snapshot-based workflow, or as symlinks for live-editing.
  • Environment-aware. Profiles let the same repository describe different machines — work, home, server — with different packages, variables, and destinations.
  • Templated when you need it. Config files can embed Tera template syntax, compiled at deploy time with live variables, so the same template can render differently per machine or profile.
  • Hooks for the messy parts. Pre/post actions run shell commands around deployment — installing dependencies, reloading a service, fixing permissions.
  • Safe by default. Dry-run mode previews every operation, diff shows exactly what would change, and deploys only touch files that actually changed.

How it fits together

  1. You import an existing file or directory into the repository: dotr import ~/.bashrc. DotR copies it into dotfiles/ and registers it as a package in config.toml.
  2. On a new machine, you deploy: dotr deploy. DotR copies (or symlinks) every package from the repository to its destination.
  3. After editing a deployed file, you update: dotr update. DotR copies the changed file back into the repository, so dotfiles/ always reflects what’s actually deployed.

Everything else in this book — profiles, variables, templating, actions, prompts, symlinks, clean mode, ignoring files, dependencies — builds on that loop.

Where to go next

Installation

Homebrew (macOS and Linux)

Supports both Apple Silicon and Intel Macs.

brew tap uroybd/tap
brew install dotr

Cargo

cargo install dotr-dear

This installs a binary named dotr (the crate is published as dotr-dear since dotr was already taken on crates.io).

From source

cargo install --git https://github.com/uroybd/DotR

Pre-built binaries

Download the latest release for your platform from the releases page:

PlatformArchive
Apple Silicon (M1/M2/M3)dotr-aarch64-apple-darwin.tar.gz
Intel Macdotr-x86_64-apple-darwin.tar.gz
Linux (x86_64)dotr-x86_64-unknown-linux-gnu.tar.gz
Linux (aarch64)dotr-aarch64-unknown-linux-gnu.tar.gz
Linux musl (x86_64)dotr-x86_64-unknown-linux-musl.tar.gz
Linux musl (aarch64)dotr-aarch64-unknown-linux-musl.tar.gz

Extract and move the binary onto your PATH:

tar xzf dotr-*.tar.gz
sudo mv dotr /usr/local/bin/

Man pages

man dotr (and man dotr-deploy, man dotr-packages, etc. for every subcommand) is available if you installed via Homebrew — the formula installs them automatically. The release tarballs also bundle the same .1 files alongside the binary; move them into your local man path to use them:

tar xzf dotr-*.tar.gz
sudo mv dotr /usr/local/bin/
sudo mv dotr*.1 /usr/local/share/man/man1/

cargo install doesn’t carry man pages — cargo has no mechanism for installing them — so they aren’t available that way.

Shell completions

Unlike man pages, completions are generated at runtime by dotr itself, so they work the same way no matter how you installed it:

# Bash
dotr completions bash > ~/.local/share/bash-completion/completions/dotr

# Zsh (any directory on your $fpath)
dotr completions zsh > ~/.zfunc/_dotr

# Fish
dotr completions fish > ~/.config/fish/completions/dotr.fish

# Nushell
dotr completions nushell > ~/.config/nushell/completions/dotr.nu

# Elvish and PowerShell are supported too — see their own docs for where
# to put a generated completion script.

Completions cover every command, including the nested dotr packages and dotr profiles subcommands. See the CLI reference for details.

Dynamic package/profile name completion (Carapace)

The scripts above are static — they know every command and flag, but --packages/--profile values aren’t completed with real names from your config.toml, since that would require shelling out to dotr at completion time.

If you use Carapace as your completion engine (it supports bash, zsh, fish, nushell, elvish, powershell, and more from one binary), dotr completions carapace prints a Carapace spec that adds real, live completion of package and profile names by calling dotr packages list --plain / dotr profiles list --plain under the hood. Save it directly into Carapace’s specs directory:

# macOS
dotr completions carapace > \
  ~/Library/Application\ Support/carapace/specs/dotr.yaml

# Linux
dotr completions carapace > ~/.config/carapace/specs/dotr.yaml

Unlike the other shells, this spec isn’t generated from dotr’s own argument definitions (Carapace has no Rust/clap integration to hook into), so it’s hand-maintained in the DotR source and simply embedded into the binary — regenerate it the same way after upgrading dotr to pick up any new commands or flags.

carapace --help prints the exact specs directory it’s currently using if you’re unsure. Once installed, dotr deploy --packages <TAB> (and the equivalent --profile flag, and dotr remove <TAB>) complete with the actual packages and profiles in the repository at your current working directory, in any shell Carapace runs your completions in.

Bitwarden-backed prompts (optional)

If you set prompt_backend = "bitwarden" (see Prompts), you’ll need the Bitwarden CLI (bw) installed and reachable on PATH:

brew install bitwarden-cli

Nothing else to set up ahead of time — dotr drives bw login/bw unlock interactively the first time it’s needed, and creates its secure note automatically.

Verifying the install

dotr --version
dotr --help

Quick Start

1. Initialize a repository

dotr init

This creates, in the current directory:

  • config.toml — the repository’s configuration (packages, profiles, variables, prompts)
  • dotfiles/ — where imported files actually live
  • .gitignore — pre-populated to exclude .uservariables.toml (your local secrets) and deployed (the symlink staging directory, see Symlinks)

Run this inside a git repository you intend to push somewhere, so the dotfiles themselves are version-controlled.

2. Import your existing dotfiles

dotr import ~/.bashrc
dotr import ~/.config/nvim/

# Import as a symlink instead of a copy (live-editing workflow)
dotr import ~/.config/nvim/ --symlink

# Import into a specific profile
dotr import ~/.ssh/config --profile work

Each import copies the file or directory into dotfiles/, and registers a package for it in config.toml with a source and destination — see Packages.

3. Deploy dotfiles on a (new) machine

# Deploy every package
dotr deploy

# Deploy only packages in the "work" profile
dotr deploy --profile work

# Deploy specific packages by name
dotr deploy --packages nvim,tmux

# Preview what would happen, without touching disk
dotr deploy --dry-run

4. Check what would change before deploying

dotr diff
dotr diff --packages nvim,bashrc
dotr diff --profile work

diff shows a colored, line-by-line diff between what’s in the repository and what’s currently deployed.

5. Pull local edits back into the repository

dotr update
dotr update --profile work
dotr update --dry-run

If you edited a deployed file directly (e.g. tweaked ~/.bashrc by hand), update copies those changes back into dotfiles/ so the repository stays the source of truth.

6. Manage packages and profiles

# Packages
dotr packages list
dotr packages list --verbose
dotr packages remove nvim
dotr packages remove nvim --remove-orphans

# Profiles
dotr profiles list
dotr profiles list --verbose
dotr profiles add laptop
dotr profiles remove work
dotr profiles remove work --remove-orphans

Next steps

Packages

A package is a single managed unit — one file or one directory — with a source inside the repository and a destination on disk. Every package lives under [packages.<name>] in config.toml.

[packages.nvim]
src = "dotfiles/nvim"
dest = "~/.config/nvim/"
  • src is relative to the repository root (the directory containing config.toml).
  • dest is the deployment target. ~ is expanded to the user’s home directory.

Creating packages

Packages are usually created with dotr import, which copies a file or directory into dotfiles/ and writes the corresponding [packages.*] entry:

dotr import ~/.bashrc
dotr import ~/.config/nvim/ --name neovim

The package name defaults to a sanitized version of the path (leading . stripped, ./- replaced with _, directories prefixed with d_, files with f_), or you can set it explicitly with --name.

Fields

FieldTypePurpose
srcstringPath to the file/directory in the repository
deststringDeployment destination
dependencieslist of stringsOther packages that must be deployed alongside this one — see Dependencies
variablestablePackage-scoped variables — see Variables
pre_actionslist of stringsShell commands run before deploying — see Actions
post_actionslist of stringsShell commands run after deploying — see Actions
targetstable (profile/platform → path)Per-profile (or per-platform) destination override, see below
skipboolIf true, excluded from profile-driven deploys (see below)
promptstablePackage-scoped prompts — see Prompts
ignorelist of glob patternsFiles to exclude from deployment/cleaning — see Ignoring Files
symlinkboolDeploy as a symlink instead of a copy; overrides the global symlink setting either way when set explicitly — see Symlinks
unfold_symlinkboolSymlink individual files rather than the whole directory — see Symlinks
cleanbool (default true)Remove stray files in the destination — see Clean Mode

Per-profile destinations (targets)

A package can deploy to a different path depending on the active profile:

[packages.gitconfig]
src = "dotfiles/gitconfig"
dest = "~/.gitconfig"

[packages.gitconfig.targets]
work = "~/.gitconfig-work"

When the work profile is active, gitconfig deploys to ~/.gitconfig-work instead of the default dest. Any profile not listed in targets falls back to dest.

Sharing a target across profiles by platform

targets can also be keyed by a profile’s platform value instead of its name, so multiple profiles that set the same platform share one override without repeating it under each profile’s own name:

[profiles.home]
platform = "macos"

[profiles.work]
platform = "macos"

[packages.gitconfig.targets]
macos = "~/.gitconfig-mac"

Both home and work deploy gitconfig to ~/.gitconfig-mac. If a package’s targets has entries for both a profile’s own name and its platform, the profile-name entry wins — it’s the more specific override.

Skipping a package by default (skip)

[packages.experimental]
src = "dotfiles/experimental"
dest = "~/.config/experimental"
skip = true

A package with skip = true is left out when packages are selected implicitly (via a profile, i.e. no --packages flag). It’s still deployed if you name it explicitly: dotr deploy --packages experimental.

Operating on packages directly

The packages subcommand groups import/deploy/update/diff/remove/ list under one namespace — each behaves the same as its top-level equivalent (dotr import, dotr deploy, … dotr remove):

dotr packages list
dotr packages list --verbose
dotr packages import ~/.tmux.conf
dotr packages deploy --packages nvim
dotr packages update --packages nvim
dotr packages diff --packages nvim
dotr packages remove nvim
dotr packages remove nvim --remove-orphans

remove (equivalently, top-level dotr remove) deletes the package’s entry from config.toml and its files from dotfiles/. It refuses to remove a package that other packages or profiles still depend on unless you pass --force. --remove-orphans additionally removes any of its dependencies that are no longer referenced elsewhere.

Profiles

A profile describes an environment — work, home, a particular server — as a set of packages, variables, and prompts. The same repository can deploy differently depending on which profile is active.

Every repository has a default profile, created automatically by dotr init.

[profiles.work]
dependencies = ["nvim", "git"]

[profiles.work.variables]
GIT_EMAIL = "[email protected]"

[profiles.home]
dependencies = ["nvim", "gaming"]

[profiles.home.variables]
GIT_EMAIL = "[email protected]"

Selecting a profile

Most commands accept -P/--profile:

dotr deploy --profile work
dotr import ~/.ssh/config --profile work
dotr update --profile work
dotr diff --profile work

If no --profile is given, DotR resolves the active profile in this order:

  1. A DOTR_PROFILE variable, if set — .uservariables.toml wins over the environment if both are set (the reverse of DOTR_BITWARDEN_NOTE’s precedence).
  2. Otherwise, default.

Referencing a profile that doesn’t exist is an error (except default, which is created on the fly if missing).

Whichever value wins is also folded into variables as a low-priority fallback, so DOTR_PROFILE shows up in dotr print-vars and can be referenced in templates as {{ DOTR_PROFILE }} — a profile, package, or user variable of the same name still overrides it.

Fields

FieldTypePurpose
dependencieslist of stringsPackages deployed when this profile is active and no --packages is given
variablestableProfile-scoped variables — override package/config/env variables, see Variables
promptstableProfile-scoped prompts — see Prompts
prompt_backendstringOverrides the top-level prompt_backend when this profile is active — see Prompts
bitwarden_notestringOverrides the top-level bitwarden_note when this profile is active — see Prompts
platformstringShares a package’s targets destination with every other profile that sets the same value

How a profile decides which packages deploy

When you run dotr deploy (or update/diff) without --packages, DotR deploys every package listed in the active profile’s dependencies that doesn’t have skip = true (see Packages).

When you pass --packages explicitly, the profile’s dependencies list is irrelevant to selection — only the named packages (and their own dependencies) are used — but the profile still supplies variables, prompts, and any targets override.

Managing profiles

dotr profiles list
dotr profiles list --verbose
dotr profiles add laptop
dotr profiles add laptop --set-as-current
dotr profiles remove work
dotr profiles remove work --remove-orphans

--set-as-current writes DOTR_PROFILE = "laptop" into .uservariables.toml, so laptop becomes the implicit profile on this machine without needing --profile laptop on every command.

profiles remove deletes the profile from config.toml. --remove-orphans additionally removes any packages that were only referenced by that profile’s dependencies and no others.

Removing the default profile is not allowed.

Variables

Variables are the values available to templates and actions — things like {{ EDITOR }} or {{ git.email }}.

Sources

Variables come from five places:

  • Config-level[variables] in config.toml, available everywhere.
  • Environment variables — every variable in your shell environment.
  • Package-level[packages.<name>.variables], scoped to that package.
  • Profile-level[profiles.<name>.variables], active only when that profile is selected.
  • User variables — everything in the gitignored .uservariables.toml: values you add there by hand, plus answers to prompts that use the file backend. Prompted answers from the keychain/bitwarden backends are resolved into user variables too, at the same priority, but stored elsewhere and never written into this file — see Prompts § Where answers go.

DOTR_PROFILE and DOTR_BITWARDEN_NOTE are a special case: whichever value wins during profile or Bitwarden note resolution is folded into the config-level tier too, so it’s visible like any other variable — but they’re never user variables (declaring them as prompts isn’t supported).

[variables]
EDITOR = "nvim"

[variables.git]
name = "Your Name"
email = "[email protected]"

Used in a template as {{ EDITOR }} and {{ git.email }}. Nested tables and arrays are supported.

Priority

When the same key is defined in more than one place, the more specific source wins:

user variables  >  profile variables  >  package variables  >  environment variables  >  config variables

In other words: a package’s own [packages.<name>.variables] can override [variables] in config.toml or a same-named environment variable; the active profile’s [profiles.<name>.variables] can override the package; and anything answered via a prompt (stored in .uservariables.toml) wins over all of it.

Environment variables sit above config-level variables but below package/profile/user variables — if your shell exports a variable with the same name as a [variables] entry in config.toml, the environment wins there, but a package or profile can still override it.

Viewing resolved variables

dotr print-vars
dotr print-vars --profile work

Shows every variable currently resolved for that profile, useful for debugging why a template rendered the way it did.

.uservariables.toml

The gitignored (by dotr init), machine-local home for anything that shouldn’t be committed to the repository — secrets, personal emails, machine-specific paths. Every key in it becomes a user variable, whether you typed it in yourself or it’s a saved prompt answer:

# .uservariables.toml — edit freely, no [prompts] entry required
EDITOR_OVERRIDE = "hx"

Answers to prompts only land here for the file backend (the default). With keychain or bitwarden configured, an answer is stored in that backend instead and never appears in this file in plaintext — but it still resolves into a user variable at the same priority, so templates and dotr print-vars see it either way.

Templating

DotR compiles files through the Tera template engine at deploy time, using the resolved variables for that package and profile.

# a config file with Tera templates
[user]
name = "{{ git.name }}"
email = "{{ git.email }}"

{% if HOME %}
[paths]
data = "{{ HOME }}/Data"
{% endif %}
  • {{ variable }} — variable substitution
  • {% if condition %}...{% endif %} — conditionals
  • {# comment #} — comments, stripped from output

Any construct Tera supports (loops, filters, macros) works, since DotR hands the file straight to Tera’s renderer.

Detection is automatic

A file is treated as templated if it contains any of {{, }}, {%, %}, {#, or #} (including Tera’s whitespace-trimming variants like {%- and -%}) anywhere in its content — no extension, header, or config flag required. Regular and templated files can coexist in the same package or directory.

Destination paths are templated too

Not just file contents — the dest path (and any per-profile targets override) is also run through Tera before use, so a destination can depend on variables:

[packages.ssh_config]
src = "dotfiles/ssh_config"
dest = "{{ HOME }}/.ssh/config"

Templated files are never backed up

dotr update normally copies a deployed file’s changes back into the repository. For a templated file, that would overwrite the template source with rendered output — so update (and the underlying backup step) skips templated files, leaving the template as the single source of truth. You’ll see a message like:

Skipping backup for templated file 'dotfiles/d_nvim/init.lua'

This is per-file, not per-package: in a directory package, only the templated files are skipped, while regular files alongside them are still backed up normally. For example, if dotfiles/d_nushell/env.nu contains {{ HOME }} but the other files in dotfiles/d_nushell/ don’t have any template markers, update backs up every file except env.nu.

If you need to change a templated file, edit the template in dotfiles/ directly and redeploy.

Actions

Actions are shell commands run around a package’s deployment — useful for installing dependencies, reloading a service, fixing permissions, or anything else that isn’t just “put this file here.”

[packages.nvim]
src = "dotfiles/nvim"
dest = "~/.config/nvim/"

pre_actions = ["mkdir -p ~/.local/share/nvim"]
post_actions = ["nvim --headless +PluginInstall +qall"]
  • pre_actions run before the package’s files are copied/symlinked.
  • post_actions run after.
  • Both are lists — multiple actions run in order, each waiting for the previous one to finish.

Execution details

  • Each action string is compiled through Tera first, so {{ variable }} interpolation works exactly like in file templates.
  • Actions run via $SHELL -c "<action>", falling back to /bin/sh if $SHELL isn’t set.
  • The working directory is the repository root (the directory containing config.toml), not the package’s src/dest.
  • If an action exits non-zero, the whole deploy/update operation fails immediately — later actions and the rest of that package’s deployment do not run, unless --ignore-errors was passed (which moves on to the next package, not the next action within a failed one).
[packages.aws]
src = "dotfiles/aws"
dest = "~/.aws/"
variables = { PROFILE = "default" }
pre_actions = ["echo 'Using AWS profile: {{ PROFILE }}'"]

Skipping actions

dotr deploy (and dotr packages deploy) accept flags to skip actions for that invocation without editing config.toml:

# Skip both pre- and post-actions
dotr deploy --skip-actions

# Skip only pre-actions
dotr deploy --skip-pre-actions

# Skip only post-actions
dotr deploy --skip-post-actions

This is useful when actions are expensive (e.g. reinstalling plugins) and you only want to sync files, or when debugging a failing action by first confirming the file deployment itself is fine.

Dry run

Under --dry-run, actions are not executed — each one is printed as (Dry Run) Would execute action: <command> instead. See Dry Run Mode.

Prompts

Prompts ask for a value interactively the first time it’s needed, then remember the answer — so secrets and machine-specific values never have to be hard-coded in config.toml.

# Config-level (global)
[prompts]
GIT_EMAIL = "Enter your git email"

# Package-level
[packages.aws]
[packages.aws.prompts]
AWS_ACCESS_KEY = "Enter AWS access key"

# Profile-level
[profiles.work]
[profiles.work.prompts]
WORK_EMAIL = "Enter work email"

Each entry maps a variable name to the message shown when prompting for it.

When prompts run

Prompts are collected — from config-level, the active profile, and every package being operated on — and checked before any command that reads variables: deploy, update, diff, import (including packages import), packages list, print-vars, and dump-user-vars — plus their packages equivalents. remove and the profiles commands never touch prompts, since neither reads variables. Any key not already answered (via whichever backend is configured) triggers an interactive prompt; the rest are skipped silently.

Where answers go: backends

By default, answers are saved to .uservariables.toml in the repository root, which dotr init adds to .gitignore. That’s fine for low-stakes values, but real secrets (API tokens, passwords) then sit in plaintext on disk indefinitely. Two other backends are available:

prompt_backend = "keychain"   # or "bitwarden" — repo-wide default

[profiles.work]
prompt_backend = "bitwarden"  # overrides the repo-wide default for this profile
  • file (the default) — .uservariables.toml, as above.
  • keychain — the OS keychain (macOS Keychain, Windows Credential Manager, Linux Secret Service), with entries namespaced per-repository so two dotr repos on one machine never collide.
  • bitwarden — a single Bitwarden secure note shared by every bitwarden-backed variable in the repository, via the bw CLI. The note is named by the bitwarden_note setting (default "dotr-secrets", also overridable per-profile the same way as prompt_backend) and created automatically the first time it’s needed. If bw isn’t logged in or the vault is locked, dotr drives bw login/bw unlock interactively right there in your terminal, syncs the latest vault data down before reading or writing (so a note edited on another device or the web vault isn’t missed), and locks the vault back up when the command finishes — but only if it was the one that unlocked it; a session you already had open (e.g. an exported BW_SESSION) is left alone.

Machine-local override for bitwarden_note

bitwarden_note (config- and profile-level) picks the note by policy — the same for every machine using that profile. But which note a given machine should use can differ even within the same profile — e.g. a personal note on your laptop vs. a work note on a work machine sharing the same work profile. For that, set DOTR_BITWARDEN_NOTE either as an environment variable, or as a key in .uservariables.toml (so it persists without exporting it every session — same idea as the DOTR_PROFILE override):

# .uservariables.toml — gitignored, machine-local
DOTR_BITWARDEN_NOTE = "my-work-laptop-secrets"

Resolution order, highest priority first: environment variable → .uservariables.toml → profile’s bitwarden_note → config’s bitwarden_note → built-in default "dotr-secrets".

Whichever value wins is also folded into variables as a low-priority fallback, so DOTR_BITWARDEN_NOTE shows up in dotr print-vars and can be referenced in templates as {{ DOTR_BITWARDEN_NOTE }} — a profile, package, or user variable of the same name still overrides it. This is separate from bitwarden_note itself (the config-/profile-level setting), which isn’t a variable.

Whichever backend is active, the answer becomes a user variable — the highest-priority source in variable resolution, overriding profile, package, environment, and config variables. See Variables. Only file-backend answers ever appear in .uservariables.toml; keychain/Bitwarden values are resolved fresh each run and never written to the plaintext file. Use dotr dump-user-vars to export every prompted variable — any backend — to a portable TOML file, e.g. to migrate a value from one backend to another.

Symlinks

By default, DotR copies files from the repository to their destination. For configs you edit frequently and want to reflect immediately — no update step — a package can instead be deployed as a symlink.

[packages.nvim]
src = "dotfiles/nvim"
dest = "~/.config/nvim/"
symlink = true
dotr import ~/.config/nvim/ --symlink

--symlink sets symlink = true on the new package and immediately deploys it (a plain dotr import only copies into the repository and registers the package, without deploying).

Enabling symlinking globally

Setting symlink = true per package works well for a handful of them, but if you want symlinking everywhere, set it once at the top level of config.toml instead:

symlink = true

[packages.nvim]
src = "dotfiles/nvim"
dest = "~/.config/nvim/"
# no symlink field needed here - the global setting covers it

Every directory package deploys as a symlink, without needing symlink = true written into each one individually.

Opting a package out

A package’s own symlink setting always wins over the global one, in either direction — set symlink = false explicitly on a package to exclude it from a global symlink = true:

symlink = true

[packages.dotfiles_only]
src = "dotfiles/dotfiles_only"
dest = "~/.dotfiles_only/"
symlink = false   # opts out; deployed as a plain copy despite the global flag

A package that doesn’t mention symlink at all just follows whatever the global flag says. Only an explicit true or false on the package overrides it.

How it works

  1. import copies the source into dotfiles/<name>/ in the repository, as normal.
  2. deploy copies those files again into deployed/<name>/ (a staging directory at the repository root — dotr init adds deployed to .gitignore, since it’s derived, not source, content).
  3. deploy then creates a symlink at the package’s dest pointing at deployed/<name>/.
dotfiles/nvim/   (repository — source of truth, git-tracked)
      │  deploy copies files
      ▼
deployed/nvim/   (staging — gitignored)
      ▲  symlink
      │
~/.config/nvim/  (dest — symlinked to deployed/nvim/)

Editing files at ~/.config/nvim/ edits deployed/nvim/ directly (it’s a symlink), so changes take effect immediately. Run dotr update to copy those changes back into dotfiles/nvim/ when you’re ready to commit them.

This whole-directory symlink is called folding — the entire destination becomes one symlink. It’s the default for any directory package with symlink = true.

Folding means the destination directory doesn’t really exist on disk anymore — it’s entirely replaced by a symlink. That’s fine as long as dest only ever holds files DotR manages, but it can’t hold anything else: there’s nowhere for an untracked, local-only file to live alongside the managed ones.

Setting unfold_symlink = true on a directory package switches to the opposite granularity: dest stays a real directory, and DotR instead creates an individual symlink for each file inside it.

[packages.nix]
src = "dotfiles/nix"
dest = "~/.config/nix/"
symlink = true
unfold_symlink = true
~/.config/nix/            (dest — a real directory)
├── nix.conf   -> ../../deployed/nix/nix.conf   (managed, symlinked)
└── local-only.conf                              (untouched, real file)

With this, you can drop local-only.conf straight into ~/.config/nix/ and DotR will never touch it — it isn’t part of dotfiles/nix/, so it’s simply never in scope for anything DotR does there.

A non-empty ignore list implies unfolding, even without setting unfold_symlink explicitly. This is automatic because the two don’t make sense apart: an ignored file has nowhere to go if the whole directory is one symlink to a fully-populated staging copy — unfolding is what actually makes “leave this path alone” meaningful for a symlinked package.

[packages.nix]
src = "dotfiles/nix"
dest = "~/.config/nix/"
symlink = true
ignore = ["secrets.conf"]   # implies unfold_symlink, even though it's unset

Safety guarantees

  • Deploying only ever creates or replaces symlinks for files that are actually part of the package’s source tree. A path that isn’t — a foreign file — is never inspected, never removed, never overwritten.
  • Cleaning (when a source file is deleted and you redeploy) only removes symlinks it recognizes as its own — ones that resolve back into that package’s deployed/<name>/ staging directory. A real file, or a symlink pointing anywhere else, is left alone. A directory is only removed once it’s completely empty, never forced.
  • dotr update only pulls changes back from paths that are managed symlinks. A foreign file sitting in the same real directory is never copied into the tracked repository.

Redeploying

If dest already exists (as a symlink, a directory, or a plain file) when deploy runs, it’s removed first and replaced with the fresh symlink — so re-running dotr deploy after changing symlink = true/false correctly switches a package between copy-mode and symlink-mode. The same applies to switching unfold_symlink on: pre-existing content at a managed file’s path is replaced with a symlink the same way whole-directory folding replaces it, since that’s a one-time transition into symlink management — after that, only foreign paths (paths outside the package entirely) are ever left alone.

Clean Mode

By default, deploy and update remove files found in the destination that don’t exist in the repository — keeping deployed configs in sync with dotfiles/, rather than merely additive.

# Deploy with cleaning (default)
dotr deploy

# Deploy without cleaning extra files
dotr deploy --clean=false

# Update with cleaning (default)
dotr update

# Update without cleaning
dotr update --clean=false

What’s protected from cleaning

  • Backup files (.dotrbak extension, see below) are never removed.
  • Files matching an ignore pattern for that package are left alone.
  • Anything that is part of the current deployment, obviously.

Per-package configuration

[packages.nvim]
src = "dotfiles/nvim"
dest = "~/.config/nvim/"
clean = false  # disable cleaning for this package specifically

clean defaults to true. The --clean CLI flag, when passed, overrides whatever the package specifies for that one invocation; when omitted, the package’s own setting is used.

Backups

When a file at the destination would be overwritten, DotR writes a per-file backup (<file>.dotrbak) alongside it before copying — a lightweight safety net, distinct from dotr update’s job of syncing intentional edits back into the repository. Backup files are always excluded from cleaning, so they won’t be swept away by the same operation that created them.

Ignoring Files

A package can exclude specific files within its src directory from both deployment and cleaning, using glob patterns matched against each file’s path relative to the package root.

[packages.nvim]
src = "dotfiles/nvim"
dest = "~/.config/nvim/"
ignore = ["*.log", "cache/*", ".DS_Store"]
  • Patterns are matched with glob-match semantics (*, **, ?, [...], etc.) against the path relative to the package’s src — not against absolute paths.
  • A matched file is skipped entirely during deploy/update: it isn’t copied, and if it already exists at the destination it isn’t removed by clean mode either — ignore means “hands off,” not “delete this.”
  • ignore only applies to directory packages (it has no effect on a package whose src is a single file).

This is separate from your top-level .gitignore (which controls what git tracks in the repository) — ignore controls what DotR itself touches at deploy time.

Dependencies

A package can declare other packages it depends on. Whenever it’s selected for an operation, its dependencies are pulled in automatically.

[packages.nvim]
src = "dotfiles/nvim"
dest = "~/.config/nvim/"
dependencies = ["fonts"]

[packages.fonts]
src = "dotfiles/fonts"
dest = "~/.local/share/fonts/"

Deploying just nvim

dotr deploy --packages nvim

…also deploys fonts, since nvim depends on it. This applies to deploy, update, and diff — anywhere packages are selected.

Skipping dependency resolution

Pass --ignore-dependencies to select only the named (or profile-driven) packages, without pulling in anything they depend on:

dotr deploy --packages nvim --ignore-dependencies

This is currently available on deploy (dotr deploy and dotr packages deploy).

Removing a package with dependents

dotr packages remove (and top-level dotr remove) refuses to remove a package that another package’s dependencies — or a profile’s dependencies — still references, to avoid leaving a dangling reference in config.toml:

Package 'fonts' cannot be removed because it is depended on by profiles: [] and packages: ["nvim"]. Use --force to override.

Pass --force to remove it anyway. See also --remove-orphans, which does the reverse: clean up dependencies that are no longer referenced by anything after a removal.

Dry Run Mode

--dry-run previews a deploy or update without changing anything on disk:

dotr deploy --dry-run
dotr update --dry-run

# Combine with other flags
dotr deploy --dry-run --profile work --packages nvim,bashrc
dotr deploy --dry-run --clean=false

Under --dry-run:

  • No files are written. Deployments print what would be copied or symlinked instead of touching disk.
  • No backups are created.
  • Pre/post actions are not executed — each is printed as (Dry Run) Would execute action: <command> instead. See Actions.
  • Clean mode still previews its removals — it runs by default and reports (Dry Run) Would remove file: <path> for anything it would clean up, without actually deleting it. Pass --clean=false alongside --dry-run if you don’t want to see those either.

Dry run is the safest way to check what a deploy or update will do before committing to it — especially useful after changing dest, targets, or ignore patterns, or before deploying to a machine for the first time. For comparing file contents rather than previewing an operation, see dotr diff in the CLI Reference.

Configuration File Reference

Everything DotR manages is described in a single config.toml at the repository root, created by dotr init.

Editor support (schema validation)

dotr init writes a taplo #:schema directive as the first line of the generated config.toml:

#:schema https://raw.githubusercontent.com/uroybd/DotR/main/schema/config.schema.json

This associates the file with a JSON Schema describing every field on this page — giving you inline validation, autocomplete, and hover documentation as you edit, in any editor that uses taplo as its TOML language server:

  • VS Code — install the Even Better TOML extension; it picks up the directive automatically.
  • Neovim — via nvim-lspconfig’s built-in taplo preset: require('lspconfig').taplo.setup {}.
  • Vim, Emacs, Helix, Sublime — any setup that runs taplo as the TOML language server (via vim-lsp/coc.nvim, lsp-mode, etc.) picks it up the same way, since the directive is parsed by taplo itself, not by a particular editor plugin.

This isn’t published on SchemaStoreconfig.toml is too generic a filename for filename-based catalog matching (Hugo’s own config.toml is SchemaStore’s canonical example of a pattern they reject for exactly this reason). The inline directive avoids that ambiguity entirely, since it points at a specific schema explicitly rather than relying on the filename.

If you have a config.toml from before this was added, add the line above to the top of the file yourself, or configure your editor’s schema association for the file manually (e.g. evenBetterToml.schema.associations in VS Code settings).

Top level

banner = true
symlink = false
prompt_backend = "file"
bitwarden_note = "dotr-secrets"

[variables]
# ...

[prompts]
# ...

[packages.<name>]
# ...

[profiles.<name>]
# ...
FieldTypeDefaultPurpose
bannerbooltruePrint the DotR ASCII banner on commands. Set false for quiet output.
symlinkboolfalseDeploy every directory package as a symlink, without setting symlink = true on each one individually — see Symlinks.
variablestable{}Config-level variables — see Variables.
promptstable{}Config-level prompts — see Prompts.
prompt_backend"file" | "keychain" | "bitwarden"unset (behaves as "file")Repo-wide default storage backend for every prompt — see Prompts. A profile’s own prompt_backend overrides this.
bitwarden_notestring"dotr-secrets"Name of the Bitwarden secure note used when prompt_backend = "bitwarden" — see Prompts. Can be overridden per-machine via DOTR_BITWARDEN_NOTE — see machine-local override.
packagestable{}Package definitions, keyed by name — see below.
profilestable{ default = {} }Profile definitions, keyed by name — see below. A default profile always exists.

[packages.<name>]

[packages.nvim]
src = "dotfiles/nvim"
dest = "~/.config/nvim/"
dependencies = ["fonts"]
pre_actions = ["mkdir -p ~/.local/share/nvim"]
post_actions = ["nvim --headless +PluginInstall +qall"]
skip = false
symlink = false
unfold_symlink = false
clean = true
ignore = ["*.log"]

[packages.nvim.variables]
THEME = "gruvbox"

[packages.nvim.targets]
work = "~/work-config/nvim/"

[packages.nvim.prompts]
NVIM_TOKEN = "Enter your plugin registry token"
FieldTypeDefaultPurpose
srcstringPath to the file/directory in the repository. Required.
deststringDeployment destination. ~ and template variables are expanded. Required.
dependencieslist of stringsnoneOther packages deployed alongside this one — Dependencies.
variablestable{}Package-scoped variables — Variables.
pre_actionslist of strings[]Shell commands run before deploy — Actions.
post_actionslist of strings[]Shell commands run after deploy — Actions.
targetstable (profile/platform → path){}Per-profile (or per-platform) destination override — Packages.
skipboolfalseExcluded from profile-driven (implicit) selection — Packages.
promptstable{}Package-scoped prompts — Prompts.
ignorelist of glob patterns[]Files excluded from deploy/clean — Ignoring Files.
symlinkboolunset (follows the global symlink setting)Deploy as a symlink instead of a copy. An explicit true/false here always overrides the global flag — Symlinks.
unfold_symlinkboolfalseSymlink individual files instead of the whole directory, so untracked content can coexist at dest — implied by a non-empty ignoreSymlinks.
cleanbooltrueRemove stray files at the destination — Clean Mode.

[profiles.<name>]

[profiles.work]
dependencies = ["nvim", "git"]
prompt_backend = "keychain"
platform = "macos"

[profiles.work.variables]
GIT_EMAIL = "[email protected]"

[profiles.work.prompts]
WORK_TOKEN = "Enter your work VPN token"
FieldTypeDefaultPurpose
dependencieslist of strings[]Packages deployed when this profile is active and no --packages is given — Profiles.
variablestable{}Profile-scoped variables — Variables.
promptstable{}Profile-scoped prompts — Prompts.
prompt_backend"file" | "keychain" | "bitwarden"unset (follows the top-level prompt_backend)Overrides the repo-wide default backend while this profile is active — Prompts.
bitwarden_notestringunset (follows the top-level bitwarden_note)Overrides which Bitwarden secure note this profile’s bitwarden-backed prompts use — Prompts. Can itself be overridden per-machine via DOTR_BITWARDEN_NOTE — see machine-local override.
platformstringunsetShares a package’s targets destination with every other profile that sets the same value — Packages.

Other files DotR creates

FileTracked in git?Purpose
config.tomlYesThe configuration described above.
dotfiles/YesPackage sources — the actual file/directory content for each package.
.gitignoreYesWritten by dotr init; excludes .uservariables.toml and deployed.
.uservariables.tomlNoAnswers to prompts — secrets live here.
deployed/NoStaging directory for symlinked packages.

CLI Reference

dotr [OPTIONS] [COMMAND]

Global options

FlagDescription
-w, --working-dir <PATH>Run as if invoked from <PATH> instead of the current directory. Accepted by every command.
-h, --helpPrint help for the current command.
-V, --versionPrint the dotr version (top-level only).

dotr init

Initialize a dotfiles repository in the working directory: writes config.toml, creates dotfiles/, and writes a .gitignore. Safe to re-run — if config.toml already exists, it’s left untouched.

dotr import <PATH>

Copy a file or directory into the repository and register it as a package.

FlagDescription
-s, --symlinkDeploy as a symlink instead of a copy, and deploy immediately. See Symlinks.
-n, --name <NAME>Override the auto-derived package name.
-p, --profile <NAME>Add the package to this profile’s dependencies instead of default.

dotr deploy

Deploy packages from the repository to their destinations.

FlagDescription
-p, --packages <NAMES>...Deploy only these packages (plus their dependencies, unless --ignore-dependencies). Omit to deploy the active profile’s packages.
-P, --profile <NAME>Use this profile instead of the resolved default. See Profiles.
--ignore-errorsKeep deploying remaining packages if one fails, instead of aborting.
--clean <true|false>Override the clean mode setting for this run.
--dry-runPreview without changing anything — see Dry Run Mode.
--skip-actionsSkip both pre- and post-actions. See Actions.
--skip-pre-actionsSkip only pre-actions.
--skip-post-actionsSkip only post-actions.
--ignore-dependenciesDeploy only the named/selected packages, without pulling in dependencies. See Dependencies.

dotr update

Copy changes from deployed files back into the repository.

FlagDescription
-p, --packages <NAMES>...Update only these packages. Omit for the active profile’s packages.
-P, --profile <NAME>Use this profile instead of the resolved default.
--ignore-errorsKeep updating remaining packages if one fails.
--clean <true|false>Override clean mode for this run.
--dry-runPreview without changing anything.

Templated packages are skipped by update — see Templating.

dotr diff

Show a colored, line-by-line diff between the repository and what’s currently deployed.

FlagDescription
-p, --packages <NAMES>...Diff only these packages.
-P, --profile <NAME>Use this profile instead of the resolved default.
--ignore-errorsKeep diffing remaining packages if one fails.

dotr remove [PACKAGES]...

Remove one or more managed packages: deletes their config.toml entry and their files under dotfiles/. Equivalent to dotr packages remove.

FlagDescription
-f, --forceRemove even if another package or profile still depends on it.
--remove-orphansAlso remove dependencies that end up unreferenced after this removal. See Dependencies.
--dry-runPreview what would be removed.
-P, --profile <NAME>Profile context for dependency checks.

dotr print-vars

Print every variable resolved for the given (or default) profile — see Variables.

FlagDescription
-p, --profile <NAME>Resolve variables for this profile.

dotr dump-user-vars

Resolves every prompted (user) variable for the given (or default) profile — prompting for anything not yet answered, through whichever backend is configured — and writes the full set as TOML to stdout, or to --output if given. Unlike .uservariables.toml, this includes keychain- and Bitwarden-backed values too, so it’s an escape hatch for backup or for migrating a value from one backend to another: copy an entry into .uservariables.toml and drop prompt_backend (or set it to "file") to move it there.

FlagDescription
-p, --profile <NAME>Resolve variables for this profile.
--packages <NAMES>...Only resolve prompts relevant to these packages.
-o, --output <PATH>Write the dump to this file instead of stdout.
dotr dump-user-vars > backup.toml
dotr dump-user-vars -o backup.toml

dotr packages

Groups package-scoped commands under one namespace. Each behaves the same as its top-level equivalent, plus list:

dotr packages list [-v|--verbose] [--plain]
dotr packages import <IMPORT_PATH> [-s|--symlink] [-n|--name <NAME>] [-p|--profile <NAME>]
dotr packages deploy [same flags as `dotr deploy`]
dotr packages update [same flags as `dotr update`]
dotr packages diff   [same flags as `dotr diff`]
dotr packages remove [same flags as `dotr remove`]

dotr packages itself also accepts -P, --profile <NAME> as context for its subcommands. packages list --verbose additionally prints each package’s src, dest, dependencies, and other fields. packages list --plain prints just the bare package names, one per line, with no banner and no other output — meant for scripting and shell completion (see Shell completions). --plain and --verbose are mutually exclusive.

dotr profiles

Manage profiles — see Profiles.

dotr profiles list [-v|--verbose] [--plain]
dotr profiles add <PROFILE_NAME> [--set-as-current]
dotr profiles remove <PROFILE_NAME> [--dry-run] [--remove-orphans]

profiles list --plain behaves the same way as packages list --plain, printing bare profile names only.

  • add --set-as-current writes DOTR_PROFILE into .uservariables.toml, making the new profile the implicit default on this machine.
  • remove cannot remove the default profile. --remove-orphans also removes packages that were only referenced by the removed profile.

dotr completions <SHELL>

Prints a shell completion script to stdout, covering every command shown on this page — including the nested packages and profiles subcommands. SHELL is one of bash, zsh, fish, elvish, powershell, nushell, carapace.

dotr completions bash > ~/.local/share/bash-completion/completions/dotr
dotr completions zsh > ~/.zfunc/_dotr
dotr completions fish > ~/.config/fish/completions/dotr.fish
dotr completions nushell > ~/.config/nushell/completions/dotr.nu
dotr completions carapace > ~/.config/carapace/specs/dotr.yaml

The script always reflects whatever version of dotr generated it — regenerate it after upgrading if a new command doesn’t show up in completions yet.

carapace is not a shell — it prints a spec file for the Carapace completion engine, which adds dynamic completion of real package/profile names on top of the static commands and flags. See Dynamic package/profile name completion.