{"slug": "migrating-from-gnu-stow-to-chezmoi", "title": "Migrating from GNU Stow to Chezmoi", "summary": "Developer Redowan Delowar migrated his dotfile management from GNU Stow to Chezmoi after experiencing issues with symlink conflicts across multiple Macs. Chezmoi's approach of storing real files instead of symlinks and its built-in support for templates and scripts resolved the pain points of keeping three machines in sync and bootstrapping new devices.", "body_md": "[Redowan's Reflections](/)\n\n# Migrating from GNU stow to chezmoi\n\n## Table of contents\n\nI’ve been managing my dotfiles with [GNU stow](https://www.gnu.org/software/stow/)\nfor a few years. I even wrote [a piece with a\ncorny title](/misc/dotfile-stewardship-for-the-indolent/)\nabout that setup back in 2023. Stow served me well, but managing symlinks\nacross multiple devices slowly became a pain in the butt.\n\nSo I started looking around for a better tool and even considered writing my own. Then a\ncolleague pointed me to [chezmoi](https://www.chezmoi.io/)\n, and so far I’m liking it a lot. It does everything I\nneed, and I’ve started tracking my agent skill files with it too.\n\n## The machines\n\nI run three Macs: a MacBook Pro for work, a MacBook Air for personal use, and a Mac Mini that acts as a small personal server. The Mini mostly gets SSHed into from the other two. It’s still a Mac with my shell on it, so the same dotfiles apply.\n\nI also keep a few Linux VMs around, but I rarely need my dotfiles on servers. Ansible provisions those. This workflow is strictly for the desktop machines.\n\n## When I outgrew stow\n\nStow’s model is symlinking. The config files live in a git repo, grouped into directories that stow calls packages, and stowing a package links its files into the home directory. For a single machine it still holds up. The commands are idempotent and there’s almost nothing to learn.\n\nThe trouble is that symlinks cut both ways. Every edit on every machine writes straight through the link into that machine’s clone of the repo. Months later I’d find dirty working trees on the Air with changes I had no memory of making. Half of them conflicted with whatever the Pro had already pushed. Keeping three clones converged turned into a chore.\n\nFresh machines were the other half of the problem. Stow won’t link over a real file. By the\ntime Homebrew and a couple of tools have run on a new Mac, files like `~/.zprofile`\n\nand\n`~/.gitconfig`\n\nalready exist. Bootstrapping meant cloning the repo, deleting the conflicting\nfiles by hand, and restowing every package while trying to remember what I’d named them. And\nstow only does files. Homebrew packages and macOS settings lived in separate scripts that I\nhad to remember to run in the right order.\n\n## How chezmoi works\n\nChezmoi keeps a source directory at `~/.local/share/chezmoi`\n\n, which is a regular git repo.\n`chezmoi add ~/.zshrc`\n\ncopies the live file into it and names the copy `dot_zshrc`\n\n. Adding\n`~/.config/gh/config.yml`\n\ncreates `dot_config/gh/config.yml`\n\n, parent directories included. I\nnever create those names by hand since `chezmoi add`\n\nderives them from the real paths. The\ntree ends up mirroring the home directory, with every leading dot spelled out as a `dot_`\n\nprefix.\n\n`dot_`\n\nis one of several [attributes](https://www.chezmoi.io/reference/source-state-attributes/)\nthat chezmoi encodes into file names. A `private_`\n\nprefix strips group and world permissions from the file. A `.tmpl`\n\nsuffix turns the file\ninto a Go template that can read per-machine data. I use templates sparingly, and every one\nof them shows up later in this post.\n\n`chezmoi apply`\n\ngoes the other way. It writes every tracked file back to the home path its\nname spells out, so `dot_zshrc`\n\nlands at `~/.zshrc`\n\n. The copies are real files, not\nsymlinks. The source directory is the single source of truth. When a file in the home\ndirectory stops matching its source copy, `chezmoi diff`\n\nshows the difference and the next\napply puts it back.\n\nLosing the automatic write-through of symlinks turned out to be the thing I like most. Nothing changes in the repo unless I deliberately put the change there.\n\n## What I track\n\nAll of it sits in that source directory. `chezmoi cd`\n\ndrops me into a subshell there, and\nhere’s the entire tree:\n\n```\n~/.local/share/chezmoi\n├── .chezmoi.toml.tmpl\n├── .chezmoiignore\n├── .chezmoiscripts\n│   └── macos\n│       ├── run_onchange_after_disable-macos-animations.sh\n│       ├── run_onchange_after_init-macos-machine.sh.tmpl\n│       └── run_onchange_before_install-homebrew-bundle.sh.tmpl\n├── .gitignore\n├── Brewfile\n├── README.md\n├── dot_agents\n│   └── skills\n│       ├── go-modernize\n│       ├── go-styleguide\n│       └── meatspeak\n├── dot_claude\n│   ├── settings.json\n│   └── symlink_skills.tmpl\n├── dot_codex\n│   └── private_config.toml\n├── dot_config\n│   ├── gh\n│   │   ├── config.yml\n│   │   └── private_hosts.yml\n│   └── ghostty\n│       └── config\n├── dot_gitconfig\n├── dot_gitconfig-pers\n├── dot_gitconfig-werk\n├── dot_shellcheckrc\n├── dot_zsh_aliases\n└── dot_zshrc\n```\n\nThe list is short because I dislike customizing tools and stick to defaults where I can. The\ndotfiles proper are the zsh, git, shellcheck, [ghostty](https://ghostty.org/)\n, and GitHub CLI configs. I track\nClaude Code’s `settings.json`\n\nand Codex’s `config.toml`\n\ntoo, so the agents behave the same\non every machine. The `private_`\n\nprefix on gh’s `hosts.yml`\n\nand the Codex config keeps those\ntwo at `0600`\n\n. I’ll talk about the skills under `dot_agents`\n\nat the end.\n\nThe three gitconfigs split my identities. All my projects live under two directories,\n`~/canvas/werk/`\n\nfor work and `~/canvas/pers/`\n\nfor everything personal, and both exist on\nevery machine. The main gitconfig routes identity by where a repo lives:\n\n```\n[includeIf \"gitdir:~/canvas/pers/\"]\n    path = ~/.gitconfig-pers\n\n[includeIf \"gitdir:~/canvas/werk/\"]\n    path = ~/.gitconfig-werk\n```\n\nRepos under `~/canvas/pers/`\n\nget my personal email and repos under `~/canvas/werk/`\n\nget the\nwork one. That’s a plain git feature, not chezmoi templating, but chezmoi guarantees all\nthree files exist on every machine.\n\nThat `.chezmoi.toml.tmpl`\n\nat the top is chezmoi’s own config template. It asks for the\nmachine’s name once and remembers the answer in `~/.config/chezmoi/chezmoi.toml`\n\n:\n\n```\n{{- $machineName := promptStringOnce . \"machineName\" \"machineName\" .chezmoi.hostname -}}\n\n[data]\n    machineName = {{ $machineName | quote }}\n```\n\nThe machine setup script reads that value to set the hostname. It’s the only per-machine data in the whole repo. Everything else is identical everywhere. I keep it this way partly for simplicity and partly because I’m not a big fan of Go’s template syntax, so the less I have to muck around with it, the better.\n\n`.chezmoiignore`\n\nlists `README.md`\n\n, the `Brewfile`\n\n, and `Brewfile.lock.json`\n\n, so all three\nstay in the source directory without ever being written to the home directory. A plain\n`.gitignore`\n\nkeeps the lock file out of version control. I’ll cover the `Brewfile`\n\nand the\nscripts under `.chezmoiscripts`\n\nin the next section.\n\n## Bootstrapping a new Mac\n\nHomebrew goes on first, and then the whole setup is two commands:\n\n```\nbrew install chezmoi\nchezmoi init --apply \\\n    --promptString machineName=mini \\\n    https://github.com/rednafi/dotfiles.git\n```\n\n`chezmoi init`\n\nclones the repo into `~/.local/share/chezmoi`\n\n, and `--apply`\n\nwrites every\ntracked file into place right away. The `--promptString`\n\nflag pre-answers the config\ntemplate’s question. Without it, chezmoi asks interactively. Scripts run as part of the same\napply.\n\nAnything under `.chezmoiscripts/`\n\ngets [executed during apply](https://www.chezmoi.io/user-guide/use-scripts-to-perform-actions/#understand-how-scripts-work)\n, and the file names control\nthe timing:\n\n- A\n`before`\n\nscript runs before chezmoi writes any files. - An\n`after`\n\nscript runs once they’re all in place. - The\n`run_onchange_`\n\nprefix makes a script fire on the first apply and after that only when its contents change.\n\nOn a fresh machine that works out to: install the Homebrew packages, lay down the dotfiles,\nthen configure macOS itself. The `onchange`\n\npart enables a trick that comes [straight from\nthe chezmoi docs](https://www.chezmoi.io/user-guide/use-scripts-to-perform-actions/#run-a-script-when-the-contents-of-another-file-changes)\n. Here’s the Homebrew script, trimmed:\n\n``` bash\n#!/usr/bin/env bash\n# Brewfile checksum: {{ include \"Brewfile\" | sha256sum }}\n\n# ... elided\n\nbrewfile={{ joinPath .chezmoi.sourceDir \"Brewfile\" | quote }}\n\n\"$brew_bin\" bundle check --no-upgrade --file \"$brewfile\" >/dev/null 2>&1 \\\n    || \"$brew_bin\" bundle install --no-upgrade --file \"$brewfile\"\n```\n\nThe elided lines locate the Homebrew binary and store its path in `$brew_bin`\n\n. The template\ninlines a hash of the `Brewfile`\n\ninto a comment. Adding a package to the `Brewfile`\n\nchanges\nthe hash, which changes the rendered script, which makes chezmoi run it again on the next\napply. So [brew bundle](https://docs.brew.sh/Brew-Bundle-and-Brewfile)\nfires exactly when the package list changes and stays quiet\notherwise. The `--no-upgrade`\n\nflag keeps it from touching packages that are already\ninstalled. Upgrades stay manual since I want to see what’s about to change first.\n\nThe `Brewfile`\n\nis about sixty lines long. An excerpt:\n\n```\nbrew \"chezmoi\"\nbrew \"fzf\"\nbrew \"gh\"\nbrew \"micro\"\nbrew \"ripgrep\"\nbrew \"uv\"\n\ncask \"claude-code\"\ncask \"codex\"\ncask \"ghostty\"\ncask \"raycast\"\n```\n\nTwo more scripts run after the files land. The first sets the hostname from `machineName`\n\nand writes every macOS default I’d otherwise set by clicking through System Settings on each\nnew machine. The second turns off most of the UI animations. Both are long lists of plain\n`defaults write`\n\ncalls, and the details are in the repo.\n\nEvery script starts with a Darwin check and exits early anywhere else, so nothing fires if I ever apply this on a Linux box. I used to keep all of this in a setup script that I’d forget to run. Now it’s part of apply and I can’t forget.\n\n## Day to day\n\nThe whole routine is about five commands.\n\nEdits usually start at the source. `chezmoi edit`\n\nopens the source copy behind a home file,\nand `--apply`\n\nwrites it through when I close the editor:\n\n```\nchezmoi edit --apply ~/.zshrc\n```\n\nSometimes the edit happens in the other direction. An installer appends to `~/.zshrc`\n\n, or I\ntweak the live file directly out of habit. Now the home directory is ahead of the source,\nand `chezmoi diff`\n\nwill show that an apply would undo my change. When the change should\nstick, I import the live file back into the source:\n\n```\nchezmoi add ~/.zshrc\n```\n\nWhen several home files have moved ahead of their sources like this, `chezmoi re-add`\n\nre-imports them all in one go.\n\nOnce the source state looks right, sharing it is plain git from inside the source repo:\n\n```\nchezmoi cd\ngit add -A\ngit commit -m \"Update dotfiles\"\ngit push\nexit\n```\n\nOn the other machines, catching up is one command:\n\n```\nchezmoi update --verbose\n```\n\nThat pulls the repo and applies it in one shot. When I want to inspect what’s coming, I split it up and read the diff first:\n\n```\nchezmoi git pull -- --autostash --rebase\nchezmoi diff\nchezmoi apply --verbose\n```\n\nPackages can fall out of sync with the `Brewfile`\n\ntoo. `brew bundle check`\n\nreports anything\nthe `Brewfile`\n\nexpects but the machine lacks, `brew outdated --greedy`\n\nshows what’s stale,\nand `brew bundle cleanup`\n\nlists what’s installed but untracked:\n\n```\nbrew bundle check --no-upgrade --file \"$(chezmoi source-path)/Brewfile\"\nbrew outdated --greedy\nbrew bundle cleanup --file \"$(chezmoi source-path)/Brewfile\"\n```\n\n## Tracking agent skills\n\nThe newest additions to the repo are [skills for LLM agents](https://www.anthropic.com/engineering/equipping-agents-for-the-real-world-with-agent-skills)\n. A skill is a folder with a\n`SKILL.md`\n\nand whatever reference files it needs. The `SKILL.md`\n\ncarries name and\ndescription frontmatter followed by instructions. The layout comes straight from the [Agent\nSkills](https://agentskills.io/home)\nspec, an open standard that started at Anthropic and has been adopted by a growing\nlist of agent products.\n\nBecause the format is standard, one copy should work everywhere. I use both [Claude Code](https://code.claude.com/docs/en/skills)\nand [Codex](https://developers.openai.com/codex/)\n, and the skills live in `~/.agents/skills`\n\n, which Codex picks up by default. In\nchezmoi terms that’s a regular directory at `dot_agents/skills/`\n\n, tracked like any other\nconfig.\n\nClaude Code hasn’t caught up with that convention yet. It looks for personal skills in\n`~/.claude/skills`\n\nand knows nothing about `~/.agents`\n\n. The fix is a one-line file in the\nsource repo at `dot_claude/symlink_skills.tmpl`\n\n:\n\n```\n{{ .chezmoi.homeDir }}/.agents/skills\n```\n\nThree name parts work together here:\n\n- The\n`dot_claude/`\n\ndirectory and the file name map the target to`~/.claude/skills`\n\n, the same way`dot_zshrc`\n\nmaps to`~/.zshrc`\n\n. - The\n`symlink_`\n\nprefix tells chezmoi to create that target as a symlink instead of a regular file, pointing wherever the file’s content says. - The\n`.tmpl`\n\nsuffix makes chezmoi render the content first, so`{{ .chezmoi.homeDir }}`\n\nexpands to the right home directory on whichever machine is applying.\n\nAfter an apply:\n\n```\nls -ld ~/.claude/skills\nphp\nlrwxr-xr-x 1 rednafi staff 29 Jun 11 17:37 /Users/rednafi/.claude/skills -> /Users/rednafi/.agents/skills\n```\n\nThere’s a mild irony in leaving stow to escape symlinks and then having chezmoi manage the\none symlink I still need. But that’s on Anthropic being a baby and not following the\nconvention other agents already follow. Now both agents read the same skill files, git holds\na single copy, and a new machine picks all of it up from the same `chezmoi init`\n\nas\neverything else.\n\nEverything here lives in my [dotfiles repo](https://github.com/rednafi/dotfiles)\n. Steal whatever looks useful.", "url": "https://wpnews.pro/news/migrating-from-gnu-stow-to-chezmoi", "canonical_source": "https://rednafi.com/misc/chezmoi/", "published_at": "2026-06-18 17:09:53+00:00", "updated_at": "2026-06-18 17:31:27.619604+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["GNU Stow", "Chezmoi", "Redowan Delowar", "MacBook Pro", "MacBook Air", "Mac Mini", "Homebrew", "Ansible"], "alternates": {"html": "https://wpnews.pro/news/migrating-from-gnu-stow-to-chezmoi", "markdown": "https://wpnews.pro/news/migrating-from-gnu-stow-to-chezmoi.md", "text": "https://wpnews.pro/news/migrating-from-gnu-stow-to-chezmoi.txt", "jsonld": "https://wpnews.pro/news/migrating-from-gnu-stow-to-chezmoi.jsonld"}}