I used GNU Stow to manage my dotfiles for a couple of years. It's the standard tool for the job, and for a long time it was enough: pick a folder per package, run stow, and the file layout mirrors $HOME.
Meanwhile I was already running as my version manager. Then one day I noticed the team had shipped a whole pile of features around it: , , , . The dotfiles system is one among them, and it replaced stow outright.
This is that migration: same files, different structure, and a machine that declares itself from one file.
What I had
Stow works by mirroring $HOME inside package directories. Each directory is a "stow package". Run stow shell and it symlinks everything inside shell/ to your home folder.
# Source init.zsh
source "$HOME/.config/zsh/init.zsh"The convention was simple: shell/.zshrc becomes ~/.zshrc, ui/.config/hypr/ becomes ~/.config/hypr/. No configuration needed. The directory structure is the mapping.
Package management was separate. A pkg_safelist.toml listed system packages, and a bootstrap script installed them:
[[package]]
name = "zsh"
[[package]]
name = "mise"
post = "mise install"
[[package]]
name = "stow"
[[package]]
name = "copyq"Services were managed manually. Tools were installed by hand. The dotfile symlinks worked, but everything else around them was glue code.
What bothered me
Stow does one thing: create symlinks. That's fine until you need:
- A package manager for system packages
- Tool version management (node, python, neovim)
- Service management (systemd units)
- A bootstrap story for fresh machines
- Repository cloning (tmux plugin manager, extensions)
Each of these was a separate tool, a separate config file, a separate thing to remember. My setup worked, but it was held together by shell scripts and institutional knowledge.
Enter mise
is a polyglot tool version manager. Alongside dev tools, it ships a and system that converges whole machines from one file.
How it works
The replaces stow. Each entry maps a target path to a source file with a mode:
[dotfiles]
"~/.zshrc" = { source = "config/shell/zshrc.zsh", mode = "symlink" }
"~/.ssh/config" = { source = "config/git/ssh/config", mode = "copy" }No more directory mirroring. The source path in the repo doesn't have to match the target path. config/shell/zshrc.zsh becomes ~/.zshrc. The mapping is explicit.
Each source path is relative to the file that declares it. Targets use absolute or ~/ paths.
Modes control how the target is created ():
| Mode | What happens | Use when |
|---|---|---|
symlink | Create one link to the source (default) | Edits flow back to the repo |
copy | Copy the file, overwriting the target | The app rewrites its own config |
symlink-each | Link each file inside a directory | The target directory also holds files mise should leave alone |
template | Render the source with Tera | Output depends on machine variables or secrets |
Two details that matter in practice:
.npmrcand.taskrcarecopy, because npm and taskwarrior rewrite them in place. A symlink would point the app's writes into the repo.~/.ssh/configiscopy, because ssh refuses to read a symlinked config file.
In copy mode, mise dot apply overwrites a changed target. To save live edits back to the source instead, use .
Stow vs Mise
# Directory structure mirrors $HOME
shell/.zshrc -> ~/.zshrc
shell/.config/zsh/ -> ~/.config/zsh/
ui/.config/hypr/ -> ~/.config/hypr/
utils/.config/nvim/ -> ~/.config/nvim/
# Install packages
yay -S stow
stow shell ui utils keymap
# Add a new dotfile
# 1. Create the file in the right package dir
# 2. Run stow again
# 3. Maintain a separate pkg_safelist.toml
# 4. Write bootstrap scripts for servicesThe config
mise.toml declares the whole machine. The source of truth for the migration:
[bootstrap.mise_shell_activate]
zprofile = "shims"
zshrc = "activate"
[bootstrap.repos]
"~/.tmux/plugins/tpm" = { url = "https://github.com/tmux-plugins/tpm" }
"./config/ui/omarchy/plugins/io.github.sohanemon.submap" = { url = "https://github.com/sohanemon/omarchy-submap.git" }
[bootstrap.packages]
"pacman:copyq" = "latest"
"pacman:lan-mouse" = "latest"
"aur:kanata-git" = "latest"
[tools]
gh = "latest"
node = "latest"
neovim = "nightly"
lazygit = "latest"
starship = "latest"
fzf = "latest"
yazi = "latest"
[dotfiles]
"~/.bin" = { source = "config/shell/bin", mode = "symlink" }
"~/.zshrc" = { source = "config/shell/zshrc.zsh", mode = "symlink" }
"~/.config/zsh" = { source = "config/shell/zsh", mode = "symlink" }
"~/.config/tmux" = { source = "config/shell/tmux", mode = "symlink" }
"~/.config/ghostty" = { source = "config/shell/ghostty", mode = "symlink" }
"~/.config/hypr" = { source = "config/ui/hypr", mode = "symlink" }
"~/.config/omarchy" = { source = "config/ui/omarchy", mode = "symlink" }
"~/.config/kanata" = { source = "config/keymap/kanata", mode = "symlink" }
"~/.config/nvim" = { source = "config/editor/nvim", mode = "symlink" }
"~/.config/opencode" = { source = "config/editor/opencode", mode = "symlink" }
"~/.config/lazygit" = { source = "config/utils/lazygit", mode = "symlink" }
"~/.config/yazi" = { source = "config/utils/yazi", mode = "symlink" }
"~/.gitconfig" = { source = "config/git/gitconfig.toml", mode = "symlink" }
"~/.npmrc" = { source = "config/git/npmrc.conf", mode = "copy" }
"~/.ssh/config" = { source = "config/git/ssh/config", mode = "copy" }
"~/.taskrc" = { source = "config/git/taskrc.toml", mode = "copy" }
"~/.agents" = { source = "assets/Agents", mode = "symlink" }
"~/Pictures/wallpapers" = { source = "assets/Pictures/wallpapers", mode = "symlink" }
[bootstrap.services]
earlyoom = { state = "running", enabled = true }
sshd = { state = "running", enabled = true }
copyq = { scope = "user", command = "/usr/bin/copyq", restart = "always", environment = { DISPLAY = ":0" } }
kanata = { scope = "user", command = "sh -c 'exec $(which kanata) --cfg ${HOME}/.config/kanata/config.kbd'", restart = "never", environment = { PATH = "/usr/local/bin:/usr/local/sbin:/usr/bin:/bin", DISPLAY = ":0" } }
lan-mouse = { scope = "user", command = "/usr/bin/lan-mouse daemon", restart = "always" }
voxtype = { scope = "user", command = "/usr/bin/voxtype daemon", restart = "always" }
[tasks.bootstrap]
run = [
"system/remove-blacklisted.sh",
"~/.tmux/plugins/tpm/bin/install_plugins",
"oneman-pkg-postinstaller-kanata",
"ya pkg install",
]Each entry is target = { source, mode }. Repo files live under config/{shell,ui,keymap,editor,utils,git} instead of stow packages, and the mapping is explicit rather than implied by directory position.
config/shell/zshrc.zsh -> ~/.zshrc
config/shell/tmux/ -> ~/.config/tmux/
config/ui/hypr/ -> ~/.config/hypr/
config/editor/nvim/ -> ~/.config/nvim/
config/git/ssh/config -> ~/.ssh/config (copy, not symlink)
assets/Pictures/ -> ~/Pictures/[tools]
gh = "latest"
neovim = "nightly"mise install handles these. No separate asdf, nvm, or manual installation.
[bootstrap.repos]
"~/.tmux/plugins/tpm" = { url = "https://github.com/tmux-plugins/tpm" }Repos are cloned or updated before dotfiles are applied. This matters on a fresh machine: the ~/.tmux/plugins/tpm target needs to exist before the symlink is created. Plugins that would end up inside a symlinked directory are cloned repo-internally so they survive a fresh install.
[bootstrap.packages]
"pacman:copyq" = "latest"
"pacman:lan-mouse" = "latest"
"aur:kanata-git" = "latest"Note: entries are keyed "manager:package" and mise routes each to the right system package manager (aur and pacman are both built in). "latest" accepts an already-installed version; it does not force an upgrade on every apply (). Entries are OS-filtered, so one config works across platforms.
This replaced pkg_safelist.toml. covers privileged paths for /etc/... and system/ targets separately.
[bootstrap.services]
copyq = { scope = "user", command = "/usr/bin/copyq", restart = "always", environment = { DISPLAY = ":0" } }User services on Linux, macOS, and Windows, plus existing systemd system units on Linux. Restart policies and environment variables are declared, not scripted.
[tasks.bootstrap]
run = [
"system/remove-blacklisted.sh",
"~/.tmux/plugins/tpm/bin/install_plugins",
]Anything that doesn't fit a declarative section. It runs on every bootstrap, so it must be idempotent.
Bootstrap order
mise bootstrap converges the phases in a fixed order (). For this config:
[bootstrap.packages]and[bootstrap.services]are converged[bootstrap.repos]are cloned or updated[dotfiles]are applied viamise dot apply- shell activation is configured
[tools]are installed- the
bootstraptask runs
Order matters. Repos land before dotfiles so destination directories exist. Tools install after dotfiles because the config may come from a symlinked path.
Day-to-day
| Action | Command |
|---|---|
| Dry-run, don't touch anything | mise -C ~/dotfiles bootstrap --dry-run |
| Inspect state | mise -C ~/dotfiles bootstrap status |
| Show pending diff | mise -C ~/dotfiles bootstrap dotfiles diff |
| Apply everything | mise -C ~/dotfiles bootstrap --yes |
| Re-apply dotfiles after edits | mise -C ~/dotfiles bootstrap dotfiles apply --force --yes |
| Apply only pieces | mise bootstrap --only dotfiles,tools |
| Add a new dotfile | edit mise.toml, then dotfiles apply --force --yes |
Edits through a symlinked path land in the repo, the same as stow. --dry-run is your friend before any apply.
Fresh install
Install mise itself ():
curl https://mise.run | shThen the whole machine:
git clone <your-dotfiles-repo> ~/dotfiles
mise -C ~/dotfiles trust
mise -C ~/dotfiles bootstrap --yes
exec zshmise trust gates project config. On a fresh machine the checkout and apply happen in one flow. For a machine that starts from nothing, clones a bootstrap repo and applies it in one command. For a tracked-dotfiles setup repo, restores files to their paths and runs the rest of the config.
Beyond symlinks
The migration replaced stow. The same tool goes further, and these are the parts worth stealing even if you don't move your dotfiles.
Dotfiles that save themselves
keeps a file where it lives and snapshots it to Git history on every edit:
mise dot track ~/.zshrcTurn on the , and edits save themselves while you work:
[bootstrap.services.mise-history]
builtin = "history-watch"mise bootstrap services applyEvery save is a Git commit you can :
mise dot history # browse checkpoint commits
mise dot rollback ~/.zshrc # restore the last saved version
mise dot undo # reverse a rollbackCheckpoints live in a bare repo at ~/.local/state/mise/history/repo.git, never touching the files you edit ().
Capture a live edit
adopts a config you're already using, moves it under dotfiles.root, writes the [dotfiles] entry, and applies it. One command, no editing TOML by hand:
mise dot add ~/.zshrc
mise dot add --changed # capture every changed copy-mode fileOne config per machine
The same path can have different content per OS or environment ():
[dotfiles]
"~/.zshrc" = {
mode = "track",
variants = [{ os = "macos" }, { os = "linux" }, { default = true }],
}profile variants key off mise -E <env>, so work and personal machines can keep separate histories for the same path.
Templates and secrets
template mode renders a source with ():
[dotfiles]
"~/.gitconfig" = { source = "dotfiles/gitconfig.tera", mode = "template" }Templates have access to env, vars, exec(), and declared :
[bootstrap.secrets]
gh_token = "description"
[dotfiles]
"~/.config/app/credentials" = { source = "dotfiles/credentials.tera", mode = "template" }token = "{{ secret(name="gh_token") }}"Version your dotfile history
Because history is Git, sharing is a remote ():
mise dot origin set https://github.com/you/setup.git --sync syncThe watcher pushes saved changes and pulls others' edits. Sync modes go from sync (fully automatic) to manual and fetch-only. Conflicts are surfaced as diffs, resolved with , --take-remote, or --keep-local. Sensitive files can be with age before they reach the repo.
Plan before you apply
reports what would change in dependency order, with --json for machines. Add --detailed-exitcode and you get 0 (nothing to do) or 2 (changes pending) for CI.
Same dotfiles, two structures
The stow editor at the top and the one below contain the same files. Only the structure changed.
Stow packages mirror $HOME, so each file sits wherever its package happens to land. mise groups sources by concern under config/, and a single [dotfiles] table in mise.toml declares every mapping explicitly.
# Source init.zsh
source "$HOME/.config/zsh/init.zsh"The two sidebars tell the real story. With stow the folders are the config map, scattered across packages. With mise the tree is one repo and the mapping is one table you can read top to bottom.



