Architecture
A symlink farm with a single source of truth, and a bootstrap that's careful never to
fight the machine it's setting up. Runs on macOS, Linux, and WSL2 — the installer's phase
registry OS-gates each phase; the ones whose entire purpose is macOS state (iTerm2,
macos.sh, the Dock, VS Code settings) skip themselves on Linux/WSL2, and every other
phase runs everywhere.
home/ mirrors $HOME
The repo's home/ directory is a literal mirror of $HOME.
GNU Stow walks it and creates a symlink in $HOME
for every file, so the file in the repo is the file on disk:
Editing either side edits the same bytes. There's no copy step, no "apply" command, no drift between "the repo" and "my machine."
Why Stow
One stow home creates the whole symlink farm and is itself idempotent. It refuses
to overwrite an existing real file — so a conflict is a loud error, never silent data
loss. Before stowing, the installer clears only known tool-generated real files (never
existing symlinks): a copy byte-identical to the repo's is removed, a divergent one is
backed up to a numbered *.pre-stow.bak (never destroyed), so Stow can take the path over.
Repository layout
| Path | Role |
|---|---|
home/ |
Everything symlinked into $HOME |
Brewfile |
Baseline formulae + casks, installed by brew bundle |
Brewfile.d/ |
Opt-in <name>.brewfile bundles (e.g. personal, homelab) layered on the baseline per machine |
uv_tools.txt |
Python CLIs, one uv tool install arg-list per line |
claude_mcp.json |
Claude Code MCP servers, replayed into ~/.claude.json |
vscode_settings.json |
VS Code settings.json baseline, deep-merged with a machine-local overlay at install time |
install.sh |
Thin entry-point shim — guards on macOS/Linux (WSL2 reports as Linux), bootstraps uv, then hands off to the installer |
src/dotfiles_install/ |
The idempotent installer: a Python package (the dotfiles-install console script) that walks an ordered phase registry |
macos.sh · dock.sh |
macOS defaults & declarative Dock |
iterm2/ |
iTerm2 prefs (pointed at directly — see below) |
scripts/ |
Helper scripts referenced by hooks |
tests/ |
bats (fish + zsh function behavior) + pytest (config validity) |
docs/ |
This documentation site |
What isn't stowed — and why
Some paths can't be a symlink into the repo — a tool rewrites them, they hold machine-local state, or they're generated from a baseline + overlay. Each is handled by its own installer phase instead of Stow.
| Not stowed | Why |
|---|---|
| iTerm2 preferences | iTerm is pointed at the iterm2/ folder directly via defaults. It rewrites its plist on quit, which would clobber a symlink — so it owns a real folder in the repo instead. |
~/.claude.json |
Machine-local state (project history, OAuth). The declarative source of truth is claude_mcp.json, replayed in idempotently rather than symlinked. |
~/.claude/settings.json |
Generated, not symlinked: Claude Code's /config and /plugin write live state through it, so the tracked claude_settings.json baseline is deep-merged with a machine-local overlay and written as a real file. See Machine-local overlays below. |
VS Code settings.json |
Generated with the same mechanism as Claude Code's, for a machine-drift reason rather than a live-rewrite one: the todo-tree extension's bundled-ripgrep path moves between VS Code releases and isn't expressible in a single tracked, cross-machine baseline. So the tracked vscode_settings.json baseline is deep-merged with a machine-local overlay — auto-seeded with this machine's Homebrew rg path — and written as a real file. |
| Machine-local overlays | A family of untracked files outside the repo (~/.ssh/config.local, ~/.gitconfig_local, and more) that the tracked config Includes or sources — seeded with commented examples, never symlinked. See below. |
Machine-local overlays
This is how I run one repo across all of my machines — a laptop, a work box, a homelab
node — from a single branch. Anything machine-specific lives in untracked files outside
the repo that the tracked config reads, so git pull never conflicts and nothing
machine-specific leaks into the public repo. I don't keep a per-machine fork or a
work/personal branch — and once you've forked, the same pattern means you won't need to
either.
| Tool | Tracked baseline | Untracked overlay | Wiring |
|---|---|---|---|
| SSH | home/.ssh/config |
~/.ssh/config.local |
Included by the tracked config |
| git | home/.gitconfig |
~/.gitconfig_local |
[include]d last by the tracked config (overlay wins for every key, incl. identity); a pre-existing ~/.gitconfig is backed up + folded in |
| fish | home/.config/fish/** |
~/.config/dotfiles/local.fish |
sourced by conf.d/zzz-local.fish |
| zsh | home/.config/zsh/**, home/.zshenv |
~/.config/dotfiles/local.zsh |
sourced by conf.d/zzz-local.zsh; always stowed alongside fish — only the login shell is selected (see Per-machine setup) |
| Homebrew | Brewfile + Brewfile.d/*.brewfile |
~/.config/dotfiles/Brewfile.local |
loaded by the installer's brew-bundle phase |
| Claude memory | home/.claude/CLAUDE.md |
~/.config/dotfiles/CLAUDE.local.md |
@-imported by the tracked CLAUDE.md |
| Claude settings | claude_settings.json |
~/.config/dotfiles/claude_settings.local.json |
deep-merged by the installer (arrays union) into a real ~/.claude/settings.json |
| Claude MCP | claude_mcp.json |
~/.config/dotfiles/claude_mcp.local.json |
deep-merged by the installer |
| VS Code settings | vscode_settings.json |
~/.config/dotfiles/vscode_settings.local.json |
deep-merged by the installer (arrays union) into a real settings.json; auto-seeded with this machine's rg path |
| macOS defaults | macos.sh |
~/.config/dotfiles/macos.local.sh |
sourced by macos.sh |
The bootstrap seeds each overlay with a commented example on first run — idempotently, so it
never clobbers content you've added — so the includes never dangle and the format is
self-documenting. This is also how 1Password stays optional: the baseline keeps it as the
personal default, but a machine without it gets graceful fallbacks (commit signing disabled,
the GitHub MCP token read from the environment) and skips the opt-in 1password bundle.
Opt-in Brewfile bundles
Software not wanted on every machine lives in tracked Brewfile.d/<name>.brewfile
bundles. The installer shows an fzf multi-select of
the available ones — pre-seeded with your current pick on a re-run, so it's ready to
amend — and persists the result to ~/.config/dotfiles/bundles. Pass --bundle NAME
(repeatable) or --no-bundles to skip the picker for scripted runs, or --keep-bundles
to skip it while reusing the saved pick unchanged; non-interactive runs (CI) just read
the saved file. This repo ships personal, homelab, and 1password; a machine that
picks nothing gets just the baseline.
An extension, not a filename
brew bundle ignores the filename entirely; the .brewfile extension exists so
hawkeye auto-maintains each bundle's SPDX header
from one .config/licenserc.toml mapping (no hand-written headers), and the editor
highlights them as the Ruby DSL they are.
Per-directory git identity
The cleanest way to use a work email/signing key only in work repos — set in
~/.gitconfig_local:
XDG-native git config
The global git ignore lives at home/.config/git/ignore — git reads ~/.config/git/ignore
automatically when core.excludesFile is unset, so there's no custom path to configure. A
copy of those rules is also mirrored into the repo's own .gitignore for one specific reason:
Why the duplicate
VS Code's explorer.excludeGitIgnore only reads an in-workspace .gitignore, never the
global core.excludesFile. Mirroring the global rules into the repo's .gitignore makes
the editor's file tree hide exactly what git ignores. Git behavior is unchanged — the
global was already in effect.
The idempotency contract
- Adopt, don't clobber.
brew bundleadopts a cask whose app is already in/Applicationsin place — no redownload, no "install fails, adopt later" dance. - Guarded mutations. Every step checks before it acts:
/etc/shellsgets the resolved shell's line (fish by default, or zsh when selected) only if absent, MCP servers are removed-then-added, the Ollama model pulls are skipped when already present. - Re-run is the test. The real idempotency check is running
install.sha second time: exit 0, and no step reports re-doing work — no duplicate shells entry, no stow conflict, no duplicate MCP server. (The declarativemacos.shanddock.shsteps re-assert state by design — they converge rather than no-op.)