Dotfiles Across Machines
Overview
One git repo, symlinked into place by an idempotent installer. The installer — not the docs — encodes every machine-specific decision, so a new machine is git clone && ./install.sh && exec zsh.
Installer rules (the core)
Write install.sh with a single link src dst helper and these behaviors:
- Never overwrite: an existing regular file moves to
<name>.orig, never deleted.link() { local src=$repo/$1 dst=$2 mkdir -p "$(dirname "$dst")" if [[ -L $dst ]]; then [[ $(readlink -f "$dst") == "$src" ]] && { echo "ok $dst"; return; } rm "$dst" elif [[ -e $dst ]]; then mv "$dst" "$dst.orig"; echo "backup $dst -> $dst.orig" fi ln -s "$src" "$dst"; echo "linked $dst -> $src" } - Idempotent: re-running prints
oklines and changes nothing. Re-run after moving the repo. - Opt-in for machine-local files: some files legitimately differ per machine (
~/.ssh/config). Skip them if a real file exists; adopting the repo copy is an explicit act (rm ~/.ssh/config && ./install.sh), never automatic. This must be in place BEFORE running the installer on a second machine. In code — a guard beforelink, not alinkvariant:if [[ -e $HOME/.ssh/config && ! -L $HOME/.ssh/config ]]; then echo "skipped $HOME/.ssh/config (machine-local -- rm it to adopt the repo copy)" else link ssh/config "$HOME/.ssh/config" fi - Copy, don't symlink, when the consumer requires a real file — e.g. syncthing reads
.stignorefrom the folder root and doesn't sync it; each machine needs its own copy. A wrong/missing ignore rule syncs exactly what it was meant to exclude. - Fix permissions the tool demands: ssh refuses group-writable config; repo umask checks files out 664 →
chmod 600after linking. Same idea for~/.ssh(700). - Alias files point at the installed sibling, not the repo: e.g.
CLAUDE.md -> ~/.claude/AGENTS.md(one copy of content, ever).
Secrets and machine-local config
Never commit secrets. Ship *.example templates (secrets.zsh.example, zshrc.local.example) that the real zshrc sources if present:
[[ -f ~/.zshrc.local ]] && source ~/.zshrc.local
Second-machine adoption order
- Commit + push from machine 1.
- On machine 2:
git clone(a plaingit pullinto a dir with untracked files collides — clone fresh orgit stash -ufirst). - Run
./install.sh, read thebackup/skippedlines — they are the diff between machines. exec zsh; diff any.origfiles for local bits worth folding back into the repo.
Extras that pay off
- SSH-aware prompt: a prompt segment showing
user@hostonly over SSH makes remote shells visually distinct. Tune it down if too loud. - SSH key hygiene while you're there: list keys per machine, retire unused ones, one key per machine rather than one key everywhere.
- PATH dedup:
typeset -U pathin zsh kills duplicate entries from re-sourced configs.
Common mistakes
- Running the installer on machine 2 before the opt-in rule exists — it clobbers machine-local ssh config.
- Symlinking a file the consuming tool must own as a real file (syncthing
.stignore). - Replacing a real directory with a symlink inside a Syncthing-replicated folder. Syncthing does not track symlinks, so the swap reads as a deletion and propagates — the other machine loses the content. Tempting when a subtree is also a git clone and you want one copy instead of two; don't. Check
syncthing/config.xmlfor the folder paths first, and confirm withcurl -H "X-API-Key: $KEY" 'localhost:8384/rest/db/status?folder=<id>'— a non-zeroglobalDeletedright after the change is the tell. Recovery is to restore the real directories; sync re-adds them. - Editing
~/.zshrcdirectly on one machine "just for now" — it's a symlink; the change is already in the repo working tree. Commit or revert, don't fork.