The mental model of the dotfiles system, written 2026-08-30 after building it in one sitting: enough to rebuild or debug it from scratch. The implementation reference lives beside the code in ~/dotfiles/docs/how-it-works.md; this note is the picture that file assumes. Related: ssh-keys-and-github · ssh-multiple-github-accounts · git-daily-workflow · ansible
The big picture
- One idea: keep the real files in a git repo, and make the paths the tools expect point back at them.
- Edit only in
~/dotfiles. Symlinks are live;~/.ssh/configis a copy, so re-run./bootstrap.shafter changing it. - Two layers: Ansible installs software (root + network), then runs
bootstrap.sh, which links config (no root, filesystem only). - When something is broken, run
./bootstrap.shfirst.
1. The problem
Two computers (home and work) must behave identically, but each tool insists on a fixed path:
- Claude Code →
~/.claude/CLAUDE.md,~/.claude/settings.json - SSH →
~/.ssh/config· git →~/.gitconfig· bash →~/.bashrc/~/.bash_aliases - None of those is a git repo, and a file can’t be in two places at once → hence symlinks.
2. Setting up a new machine
1. apt install git
2. ssh-keygen + add .pub to GitHub
3. minimal ~/.ssh/config with github-personal
4. git clone dotfiles
5. apt install pipx, pipx install ansible
6. ansible-playbook provision.yml -K # installs packages, then runs bootstrap.sh
7. machine matches- Steps 1–3 are permanently manual, and that’s not a flaw. You can’t clone a private repo without git and a working SSH key, and those can’t come from the repo you’re cloning (chicken-and-egg). Everything after is automated.
3. Four ways to shape Claude’s behaviour
Not interchangeable; choosing wrong is the most common mistake.
| Mechanism | What it is | Applies | In git? |
|---|---|---|---|
| Memory | facts recalled across sessions | automatically, when relevant | no, lives outside the repo |
| CLAUDE.md | instructions loaded every session | always on | yes, plain file |
| Skill | a procedure invoked with /name | only when asked | yes, plain files |
| Hook | a script the harness runs before a tool call | enforced by the harness | yes, script + settings |
- Fact about you or the project → memory · rule that always applies → CLAUDE.md
- Procedure you run deliberately → skill · must hold even if Claude forgets → hook
- CLAUDE.md is instruction; a hook is enforcement. A rule is obeyed (reliable, but a rule); a hook intercepts the tool call before the command runs, whatever Claude believes at that moment.
4. The five design decisions
Each had a simpler-looking alternative. Why it was rejected is what you can’t recover from the code.
4.1 Symlink by default, copy for SSH
- Symlinks: one file, two paths. Edit either side, nothing can drift.
- SSH is the exception: a symlink points at a file that only exists on branches containing it. Check out an older branch → link dangles → SSH loses its host aliases →
git pullcan’t reach GitHub to fetch the fix. It happened (bug 5.4). - Rule: anything git itself depends on must not live behind a symlink into a git working tree.
- Cost: editing
~/.ssh/configdirectly doesn’t last. Edit~/dotfiles/ssh/config, re-run./bootstrap.sh. Fine for a file that changes about once a year.
4.2 No global git identity
[user]
useConfigOnly = true # refuse rather than guess
[includeIf "gitdir:~/work/"]
path = ~/.gitconfig-work # trailing slash = "and everything under it"~/.gitconfighas nouser.email. Repos under~/work/get the work identity viaincludeIf; elsewhere git refuses to commit until you set--local.- Rejected: a global personal email. With two accounts it silently stamps the personal address onto work commits wherever you forgot to override, permanently, in history.
useConfigOnlyalone isn’t enough: it only stops git guessing from the hostname; an explicit global email is used happily. The safety exists only if there’s nothing to fall back to. See ssh-multiple-github-accounts.
4.3 The hook warns, it does not block
- The PreToolUse hook emits a reminder and lets the command through.
- It’s a regex tripwire, not a shell parser. It has fired on: a heredoc documenting
git add(fixed) ·git stash list, read-only (fixed) ·git merge-base, sincemergeis a prefix (open) ·git checkout:inside anecho(inherent, unfixable). - Blocking would have made it impossible to write documentation about git. Warning costs a spurious line; blocking costs the ability to work.
- Principle: where a check can’t be exact, prefer a warning you can ignore over a wall you can’t get past.
4.4 Ansible installs, bootstrap links
| Installs software | Links config | |
|---|---|---|
| Ansible | yes: root + network | no |
bootstrap.sh | no | yes: no root, filesystem only |
- Rejected: growing
bootstrap.shto install packages: it would need sudo, network handling and hand-written idempotency per package. - Every guard hand-written in
bootstrap.sh(the-Lchecks,cmp -s, the “already registered?” test) Ansible gives free:
| shell script | Ansible | |
|---|---|---|
| You write | steps to perform | the state you want |
| Run twice | you hand-code guards | idempotent by construction |
| Reports changes | only if you echo | always: changed vs ok |
| Dry run | no | --check |
bootstrap.shsurvives because it needs no root and runs before Ansible exists on a fresh machine.
4.5 Two repos, not one
~/dotfiles(machine config) and~/notes(personal knowledge) stay separate.- Rejected: one repo. Fewer clones, but it entangles config with notes, and if the dotfiles ever become shareable, the notes come along.
5. The six bugs, and what each taught
Every bug encodes a constraint invisible in the finished code.
sync.shpulled before committing.git pull --rebaserefuses with uncommitted changes, so it deadlocked the first time it had anything to save. → Commit, then pull, then push. Found by running it, not reading it.apt update && apt install && snap install. The first failed,&&short-circuited, nothing installed, with one error where three commands should have run. →&&chains hide which steps never ran. Use separate lines to see each result.- The hook fired on documentation. A README containing
git addtripped it: command string and file content are the same string to a regex. → Fixed for heredocs by stripping bodies; unfixable in general, hence warn, not block (4.3). - The symlink lockout.
git switch main→ branch predatedssh/→ symlink dangled → SSH lost its aliases →git pullfailed with a DNS error. → Produced 4.1. A misleading error is worse than a clear failure: “Could not resolve hostname” pointed at the network; the fault was a checkout. pipx list --short. The playbook reinstalled Ansible on every run:--shortdoesn’t exist in pipx 1.0.0 (Ubuntu 22.04’s) → exit2with a usage error →failed_when: falseswallowed it → empty stdout → guard always true. →failed_when: falsehides bugs; check a CLI flag against the installed version;changed=0is a test result, not cosmetics. Only visible because it ran on an already-configured machine (on a fresh one every task legitimately changes something).- The phantom modified file.
git statusshowedMon a byte-identical file: Obsidian had touched it (mtime changed, content didn’t). → Git uses mtime as a fast pre-filter, then compares content and clears the flag: a stat-dirty entry. Recognise it instead of hunting for a change that isn’t there.
The pattern across all six
None were visible by inspection. Each surfaced by running the thing: in a scratch directory, a fake
HOME, or a dry run. That habit is worth more than any file in the repo.
6. Extending it
- A rule for Claude → edit
~/dotfiles/claude/CLAUDE.md. Live immediately (symlink). - A
/command→ createclaude/skills/<name>/SKILL.mdwithname+descriptionfrontmatter, then./bootstrap.sh(a new directory needs linking). - A hook → script into
claude/hooks/,chmod +x, add an entry tosettings-fragment.json,./bootstrap.sh. Pipe-test it first (below). - A package → add to
apt_packagesinansible/provision.yml. - Another config file → add it to the repo, then a
link_it(orcopy_it) line inbootstrap.sh.
# test a hook with the JSON it will actually receive: output = warns, silence = allows
echo '{"tool_name":"Bash","tool_input":{"command":"git commit -m x"}}' \
| python3 ~/.claude/hooks/git-workflow-reminder.py
ansible-playbook provision.yml --check --diff -K # test a playbook without touching the machine
HOME=/tmp/faketest ./bootstrap.sh # rehearse a fresh machine--checkskipscommand/shelltasks: it validates package tasks only. A smoke test, not a proof.- The fake
HOMErun is a complete, side-effect-free rehearsal, becausebootstrap.shonly ever writes under$HOME.
Troubleshooting
| Symptom | Likely cause | Check / fix |
|---|---|---|
| Claude ignores the git rule | CLAUDE.md not loaded | ls -la ~/.claude/CLAUDE.md: does the link resolve? |
Could not resolve hostname github-personal | ~/.ssh/config missing or wrong | cat ~/.ssh/config, then ./bootstrap.sh |
Bad owner or permissions on ssh config | mode too open | stat -c %a ~/.ssh/config must be 600 |
| git: “unable to auto-detect email” | no identity: working as designed | git config --local user.email "...", or move the repo under ~/work/ |
ansible: command not found | ~/.local/bin not on PATH | echo $PATH; ls -la ~/.bash_aliases; source ~/.bashrc |
| Hook never fires | settings not loaded | python3 -c "import json;json.load(open('$HOME/.claude/settings.json'))", then /hooks or restart |
| Authenticated as the wrong GitHub account | URL didn’t use the alias | git remote -v must be github-personal:..., never [email protected]: |
| Anything dangling after a branch switch | symlink target not on this branch | ./bootstrap.sh repairs dangling links |
Playbook reports changed every run | a command/shell task has no guard | find a task without when: or changed_when: |
./bootstrap.shis the single most useful recovery command: idempotent, backs up anything it replaces, repairs dangling symlinks, audits them at the end.
SSH broken, so
git pullcan't fetch the fix
git pull= fetch + merge. Iforigin/mainwas already fetched, run only the merge half, which needs no network:git merge --ff-only origin/main
Quick reference
./bootstrap.sh # link config; first thing to run when broken
ansible-playbook provision.yml -K # install packages, then run bootstrap.sh
ansible-playbook provision.yml --check --diff -K # dry run (skips command/shell tasks)
HOME=/tmp/faketest ./bootstrap.sh # rehearse a fresh machine
git merge --ff-only origin/main # offline "pull" when SSH is broken
ls -la ~/.claude/CLAUDE.md # does the link resolve?
git remote -v # must use github-personal:...