Skip to content

Commit 5ef0aa4

Browse files
committed
feat: post on dotfiles migration
1 parent 31b8a52 commit 5ef0aa4

1 file changed

Lines changed: 163 additions & 0 deletions

File tree

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
---
2+
layout: post
3+
title: "Migrating my dotfiles to chezmoi: one config for a work desktop and an AI-harness-testing laptop"
4+
date: 2026-08-18
5+
categories: [Development Tools]
6+
tags:
7+
- chezmoi
8+
- dotfiles
9+
- ubuntu
10+
- windows
11+
- claude
12+
- ai-coding
13+
- shell
14+
- dev-tools
15+
---
16+
17+
**TLDR; I migrated my dotfiles into a single [chezmoi](https://chezmoi.io)-managed repo at [github.com/stevecrawshaw/dotfiles](https://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.**
18+
19+
## Why I needed this
20+
21+
For years my configuration lived in roughly three places, none of them authoritative:
22+
23+
- a `.bashrc` for the work desktop, hand-edited over several jobs' worth of cruft
24+
- a `.zshrc` on the new Ubuntu laptop, written from scratch and already diverging
25+
- `~/.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
26+
27+
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.
28+
29+
## The laptop's job: an AI harness testbed
30+
31+
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.
32+
33+
That role drives three decisions in the dotfiles:
34+
35+
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.
36+
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.
37+
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.
38+
39+
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.
40+
41+
## Why chezmoi
42+
43+
I considered the usual options first:
44+
45+
- **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.
46+
- **GNU Stow** — same symlink problem, and no templating.
47+
- **Dotbot or a custom script** — reinventing the wheel, badly.
48+
49+
chezmoi won on three concrete points:
50+
51+
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.
52+
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.
53+
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.
54+
55+
## The repo layout
56+
57+
```text
58+
.chezmoi.toml.tmpl generates per-machine config on `chezmoi init`
59+
.chezmoiignore per-OS exclusions (target paths, not source names)
60+
.chezmoitemplates/ shared content included by several targets
61+
vscode-settings.json the real VS Code settings
62+
positron-settings.json the real Positron settings
63+
dot_bashrc.tmpl -> ~/.bashrc (Windows)
64+
dot_zshrc.tmpl -> ~/.zshrc (Ubuntu)
65+
dot_gitconfig.tmpl -> ~/.gitconfig
66+
dot_config/shell/aliases.sh.tmpl -> ~/.config/shell/aliases.sh (both shells)
67+
dot_config/shell/env.sh.tmpl -> ~/.config/shell/env.sh (both shells)
68+
dot_config/ghostty/config -> ~/.config/ghostty/config (Ubuntu)
69+
dot_claude/ -> ~/.claude (Claude Code config)
70+
AppData/Roaming/Code|Positron/ -> editor config (Windows)
71+
dot_config/Code|Positron/ -> editor config (Ubuntu)
72+
*-extensions.txt extension manifests, replayed by run_onchange_
73+
run_onchange_* provisioning scripts
74+
```
75+
76+
A few things worth calling out:
77+
78+
- **The `dot_` prefix is chezmoi's convention** — a source file called `dot_zshrc.tmpl` becomes `~/.zshrc`.
79+
- **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.
80+
- **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.
81+
- **`~/.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.
82+
83+
## Bootstrapping the Ubuntu laptop
84+
85+
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:
86+
87+
```bash
88+
sh -c "$(curl -fsLS get.chezmoi.io)" -- -b "$HOME/.local/bin"
89+
export PATH="$HOME/.local/bin:$PATH"
90+
chezmoi init --apply --source="$HOME/projects/dotfiles" https://github.com/stevecrawshaw/dotfiles.git
91+
```
92+
93+
`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:
94+
95+
- apt packages — zsh, git, fzf, ripgrep, build tooling, R, `gh` (from GitHub's own apt repo, because Ubuntu's build lags)
96+
- `uv`, radian (the R console VS Code uses), oh-my-posh, and the JetBrainsMono Nerd Font the prompt theme needs
97+
- chezmoi itself, plus reminders to run `gh auth login` and `chsh -s zsh`
98+
99+
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.
100+
101+
## Daily workflow
102+
103+
The aliases make the loop short enough to be habit:
104+
105+
```bash
106+
czd # chezmoi diff -- what would change on this machine
107+
cza # chezmoi apply -- write source -> $HOME
108+
czu # chezmoi update -- git pull + apply. The everyday sync.
109+
czs # chezmoi status
110+
czr # chezmoi re-add -- pull edits you made directly in $HOME back to source
111+
czcd # cd to this repo
112+
aka # edit the shared aliases file
113+
```
114+
115+
The typical loop after changing something:
116+
117+
```bash
118+
czr && czcd && git add -A && git commit -m "feat: ..." && git push
119+
# then on the other machine
120+
czu
121+
```
122+
123+
<div class="callout warning">
124+
**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.
125+
</div>
126+
127+
`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 `$HOME` — `czr` pulls it back into source so the next `czu` on the other machine picks it up.
128+
129+
## Secrets
130+
131+
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:
132+
133+
```bash
134+
# ~/.env.local
135+
export SOME_API_TOKEN="..."
136+
```
137+
138+
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.
139+
140+
## Gotchas
141+
142+
- **`~/.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.
143+
- **`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.
144+
- **`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.
145+
- **`chezmoi init` regenerates `~/.config/chezmoi/chezmoi.toml`.** Never hand-edit that file — edit `.chezmoi.toml.tmpl` instead.
146+
- **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.
147+
148+
## Learnings
149+
150+
The migration paid off in the first month, mostly in ways I didn't predict:
151+
152+
- **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.
153+
- **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.
154+
- **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.
155+
- **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.
156+
157+
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.
158+
159+
## Resources
160+
161+
- [chezmoi](https://chezmoi.io)
162+
- [stevecrawshaw/dotfiles](https://github.com/stevecrawshaw/dotfiles)
163+
- [chezmoi quick start](https://www.chezmoi.io/quick-start/)

0 commit comments

Comments
 (0)