TLDR; I migrated my dotfiles into a single chezmoi-managed repo at github.com/stevecrawshaw/dotfiles. One source tree is rendered per machine by Go templates: a Windows 11 work desktop (bash, Git Bash) and a Lenovo T490 Ubuntu laptop (zsh, Ghostty) that exists mainly to test AI coding harnesses like DeepSeek and Pi.

Why I needed this

For years my configuration lived in roughly three places, none of them authoritative:

  • a .bashrc for the work desktop, hand-edited over several jobs’ worth of cruft
  • a .zshrc on the new Ubuntu laptop, written from scratch and already diverging
  • ~/.claude as its own git repo — which was fine until I started building skills, agents and hooks, and the repo stopped being a clean mirror of reality

The trigger was the laptop. I’d just set up a Lenovo T490 with Ubuntu as a dedicated testbed for AI coding harnesses, and within a month the two machines’ configs had drifted so far that “copy the file across” wasn’t working anymore. Editor settings, shell aliases, git config — everything was subtly different, and nothing was the source of truth.

The laptop’s job: an AI harness testbed

It’s worth being explicit about what the personal laptop is for, because it shaped the whole migration. The laptop is a testing ground for AI coding harnesses — DeepSeek, Pi, Claude Code, whatever new coding agent shows up next. I install a harness there first, point it at my repos, and run it against real tasks before trusting it anywhere near work.

That role drives three decisions in the dotfiles:

  1. The entire ~/.claude tree is version-controlled — agents, skills, hooks, commands, rules. When an experiment breaks something (and they do), I can diff against the last good state and re-provision in seconds instead of reconstructing it from memory.
  2. The shell is tuned for watching agents work — fast zsh startup, fzf for history and file jumping, an oh-my-posh prompt with Nerd Font glyphs in Ghostty. The terminal is the dashboard for the experiments.
  3. The bootstrap provisions exactly what a coding harness needs on a fresh machine: uv, gh, ripgrep, fzf, git, plus the R and Python toolchains my data work needs. A harness is useless if it can’t run code.

The joke is that the migration I’m writing about is because of the AI testing, not in spite of it — version-controlled config is what makes harness experimentation reversible.

Why chezmoi

I considered the usual options first:

  • A bare git repo with symlinks — the classic approach, but symlinks need privileges on Windows, which is a whole conversation I didn’t want to have with the IT department.
  • GNU Stow — same symlink problem, and no templating.
  • Dotbot or a custom script — reinventing the wheel, badly.

chezmoi won on three concrete points:

  1. It copies files rather than symlinking them. This sidesteps the Windows symlink-privilege problem entirely. No admin rights, no developer mode, no junction-point hacks.
  2. Go templates. One source file can render differently per machine — the gitconfig’s core.editor path and autocrlf setting, the OneDrive path on the work box, machine-specific PATH entries. Machine identity is derived from the OS, so there’s nothing to prompt for.
  3. run_onchange_* scripts. Provisioning is declarative and idempotent: install packages before dotfiles are written, replay editor extension manifests when they change. Re-running is safe.

The repo layout

.chezmoi.toml.tmpl                 generates per-machine config on `chezmoi init`
.chezmoiignore                     per-OS exclusions (target paths, not source names)
.chezmoitemplates/                 shared content included by several targets
  vscode-settings.json             the real VS Code settings
  positron-settings.json           the real Positron settings
dot_bashrc.tmpl                    -> ~/.bashrc     (Windows)
dot_zshrc.tmpl                     -> ~/.zshrc      (Ubuntu)
dot_gitconfig.tmpl                 -> ~/.gitconfig
dot_config/shell/aliases.sh.tmpl   -> ~/.config/shell/aliases.sh   (both shells)
dot_config/shell/env.sh.tmpl       -> ~/.config/shell/env.sh       (both shells)
dot_config/ghostty/config          -> ~/.config/ghostty/config     (Ubuntu)
dot_claude/                        -> ~/.claude     (Claude Code config)
AppData/Roaming/Code|Positron/     -> editor config (Windows)
dot_config/Code|Positron/          -> editor config (Ubuntu)
*-extensions.txt                   extension manifests, replayed by run_onchange_
run_onchange_*                     provisioning scripts

A few things worth calling out:

  • The dot_ prefix is chezmoi’s convention — a source file called dot_zshrc.tmpl becomes ~/.zshrc.
  • Editor settings live in exactly one place. VS Code reads from %APPDATA%\Code\User on Windows and ~/.config/Code/User on Linux, so both trees exist in the repo — but each is a one-line file that includes the same shared template from .chezmoitemplates/. .chezmoiignore excludes the irrelevant tree per OS.
  • What’s templated is only what actually diverges — aliases (OneDrive paths, start msedge, winpty wrappers are work-only), env (R/rv paths), gitconfig (autocrlf false vs input, the gh credential helper on Linux only). VS Code keys with .windows / .linux suffixes are left untemplated because VS Code selects those at runtime.
  • ~/.claude is the biggest subtree — ten agents, thirty-plus skills, hooks, commands and rules. That’s the AI-testing payload riding along with the shell config.

Bootstrapping the Ubuntu laptop

The whole point of a dotfiles repo is that a new machine gets set up without you remembering anything. The laptop bootstrap is three commands:

sh -c "$(curl -fsLS get.chezmoi.io)" -- -b "$HOME/.local/bin"
export PATH="$HOME/.local/bin:$PATH"
chezmoi init --apply --source="$HOME/projects/dotfiles" https://github.com/stevecrawshaw/dotfiles.git

chezmoi init generates ~/.config/chezmoi/chezmoi.toml from .chezmoi.toml.tmpl, deriving machine = "personal" from the OS (the work box gets machine = "work" from Windows). Because the run_onchange_before_00-ubuntu-packages.sh script runs before any dotfiles are written, the shells and tools the rc files expect already exist:

  • apt packages — zsh, git, fzf, ripgrep, build tooling, R, gh (from GitHub’s own apt repo, because Ubuntu’s build lags)
  • uv, radian (the R console VS Code uses), oh-my-posh, and the JetBrainsMono Nerd Font the prompt theme needs
  • chezmoi itself, plus reminders to run gh auth login and chsh -s zsh

The script is honest about what it can’t do: VS Code, Positron and Ghostty need manual downloads (it prints the links). Install those, run cza again, and the extension-sync script picks up the editor CLIs and replays the manifests.

Daily workflow

The aliases make the loop short enough to be habit:

czd     # chezmoi diff     -- what would change on this machine
cza     # chezmoi apply    -- write source -> $HOME
czu     # chezmoi update   -- git pull + apply. The everyday sync.
czs     # chezmoi status
czr     # chezmoi re-add   -- pull edits you made directly in $HOME back to source
czcd    # cd to this repo
aka     # edit the shared aliases file

The typical loop after changing something:

czr && czcd && git add -A && git commit -m "feat: ..." && git push
# then on the other machine
czu
**The one rule:** do not edit a managed file in `$HOME` and then run `cza` — apply overwrites `$HOME` from source. Run `czr` first to capture the edit. `czd` tells you which direction you're out of sync in.

czr is the alias I use most, because the AI harnesses edit config too. When Claude Code adds a skill or tweaks a rule under ~/.claude, that edit starts life in $HOMEczr pulls it back into source so the next czu on the other machine picks it up.

Secrets

Nothing in the repo is encrypted, so nothing secret goes in it. Per-machine secrets live in ~/.env.local, which is untracked and sourced by both .bashrc and .zshrc if present:

# ~/.env.local
export SOME_API_TOKEN="..."

Also deliberately excluded by .chezmoiignore: ~/.claude/.credentials.json, settings.local.json (per-machine permission grants), and every Claude Code runtime directory (projects/, sessions/, tasks/, plugins/, history.jsonl). Those are machine state, not configuration — they’d conflict on every sync. If a real secret ever needs to live in the repo, the escape hatch is chezmoi add --encrypt with age.

Gotchas

  • ~/.claude is no longer its own git repo. It was previously tracked at github.com/stevecrawshaw/.claude, now archived (read-only) with the full pre-chezmoi history. This repo is the only source of truth — editing a skill now needs czr like any other managed file.
  • alias bash="code ~/bash.sh" had to go. It shadowed the bash binary, which is the kind of footgun you only notice at the worst moment.
  • R_HOME costs ~0.5s per interactive shell because the env script invokes R to discover it. That’s the tradeoff for never hardcoding a versioned path; the env file documents how to hardcode it if startup drags.
  • chezmoi init regenerates ~/.config/chezmoi/chezmoi.toml. Never hand-edit that file — edit .chezmoi.toml.tmpl instead.
  • The old Git-for-Windows alias file is stubbed out (original kept as .pre-chezmoi.bak beside it). A Git upgrade may restore the stock version — harmless, since ~/.bashrc is sourced afterwards and wins.

Learnings

The migration paid off in the first month, mostly in ways I didn’t predict:

  • Templates earn their keep at the edges. 90% of the config is identical on both machines; the templating handles the 10% that isn’t (paths, autocrlf, credential helpers) without forking the files.
  • Copy-not-symlink removes an entire class of Windows problems. I never once thought about symlink privileges after switching. That alone justified chezmoi over the alternatives.
  • Provisioning as code makes “nuke and reinstall” a legitimate strategy. The bootstrap script is idempotent and re-runnable, so the AI-testing laptop can be rebuilt without fear — which is exactly the property you want on a machine whose whole job is running experimental software.
  • AI tooling is the most valuable thing in the repo. The Claude Code skills and agents took months to accumulate. Version-controlling them was the real motivation, and the shell config was the vehicle.

If you’re running more than one machine — and especially if one of them exists to break things on purpose — a chezmoi repo is worth the afternoon it takes to set up. The czu ritual is now the single best habit in my tooling.

Resources


<
Previous Post
Building a Claude Code skill from an OpenAPI spec with mcp2cli
>
Blog Archive
Archive of all previous blog posts