Skip to main content
9d012b39136ff6edf03c
·13 min read

Why I Moved My Dotfiles from GNU Stow to Mise

I migrated my Linux setup from GNU Stow to mise. What started as a dotfiles manager now handles much more: packages, tools, repositories, services, bootstrapping, and even dotfile history. This is how I restructured the repo, replaced Stow's implicit directory mapping with explicit configuration, and turned my entire machine setup into a single declarative mise.toml.

dotfiles
mise
linux
developer-tools
system-setup
automation
bootstrap
shell
terminal
arch
macos
3ef4f885272bc6323209

Sohan R. Emon

Developer, Learner, Tech Enthusiast

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.

~/~/dotfiles
# 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:

pkg_safelist.toml
[[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:

toml
[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 ():

ModeWhat happensUse when
symlinkCreate one link to the source (default)Edits flow back to the repo
copyCopy the file, overwriting the targetThe app rewrites its own config
symlink-eachLink each file inside a directoryThe target directory also holds files mise should leave alone
templateRender the source with TeraOutput depends on machine variables or secrets

Two details that matter in practice:

  • .npmrc and .taskrc are copy, because npm and taskwarrior rewrite them in place. A symlink would point the app's writes into the repo.
  • ~/.ssh/config is copy, 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 services

The config

mise.toml declares the whole machine. The source of truth for the migration:

~/mise.toml
[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.

repo layout
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/

toml
[tools]
gh = "latest"
neovim = "nightly"

mise install handles these. No separate asdf, nvm, or manual installation.

toml
[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.

toml
[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.

toml
[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.

toml
[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:

  1. [bootstrap.packages] and [bootstrap.services] are converged
  2. [bootstrap.repos] are cloned or updated
  3. [dotfiles] are applied via mise dot apply
  4. shell activation is configured
  5. [tools] are installed
  6. the bootstrap task 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

ActionCommand
Dry-run, don't touch anythingmise -C ~/dotfiles bootstrap --dry-run
Inspect statemise -C ~/dotfiles bootstrap status
Show pending diffmise -C ~/dotfiles bootstrap dotfiles diff
Apply everythingmise -C ~/dotfiles bootstrap --yes
Re-apply dotfiles after editsmise -C ~/dotfiles bootstrap dotfiles apply --force --yes
Apply only piecesmise bootstrap --only dotfiles,tools
Add a new dotfileedit 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 ():

sh
curl https://mise.run | sh

Then the whole machine:

sh
git clone <your-dotfiles-repo> ~/dotfiles
mise -C ~/dotfiles trust
mise -C ~/dotfiles bootstrap --yes
exec zsh

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

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:

sh
mise dot track ~/.zshrc

Turn on the , and edits save themselves while you work:

toml
[bootstrap.services.mise-history]
builtin = "history-watch"
sh
mise bootstrap services apply

Every save is a Git commit you can :

sh
mise dot history            # browse checkpoint commits
mise dot rollback ~/.zshrc  # restore the last saved version
mise dot undo               # reverse a rollback

Checkpoints 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:

sh
mise dot add ~/.zshrc
mise dot add --changed   # capture every changed copy-mode file

One config per machine

The same path can have different content per OS or environment ():

toml
[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 ():

toml
[dotfiles]
"~/.gitconfig" = { source = "dotfiles/gitconfig.tera", mode = "template" }

Templates have access to env, vars, exec(), and declared :

toml
[bootstrap.secrets]
gh_token = "description"

[dotfiles]
"~/.config/app/credentials" = { source = "dotfiles/credentials.tera", mode = "template" }
tera
token = "{{ secret(name="gh_token") }}"

Version your dotfile history

Because history is Git, sharing is a remote ():

sh
mise dot origin set https://github.com/you/setup.git --sync sync

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

~/~/dotfiles
# 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.

References

Found this useful? Share!

Similar Reads

More like this