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

THE REPOIN ~ (FIXED PATHS)THE TOOLSGitHub · private reposource of truthclone / pull~/dotfiles/the only place you editlinks made by ./bootstrap.sh(Ansible runs it last)~/.claude/ ~/.gitconfig~/.bash_aliases · symlinks~/.ssh/configa copy, not a linkClaude Code · git · bashread their fixed pathssshgit uses it to reach GitHubsymlinkcopyread byread by
  • 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/config is a copy, so re-run ./bootstrap.sh after 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.sh first.

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.

MechanismWhat it isAppliesIn git?
Memoryfacts recalled across sessionsautomatically, when relevantno, lives outside the repo
CLAUDE.mdinstructions loaded every sessionalways onyes, plain file
Skilla procedure invoked with /nameonly when askedyes, plain files
Hooka script the harness runs before a tool callenforced by the harnessyes, 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.

  • 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 pull can’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/config directly 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"
  • ~/.gitconfig has no user.email. Repos under ~/work/ get the work identity via includeIf; 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.
  • useConfigOnly alone 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, since merge is a prefix (open) · git checkout: inside an echo (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.
Installs softwareLinks config
Ansibleyes: root + networkno
bootstrap.shnoyes: no root, filesystem only
  • Rejected: growing bootstrap.sh to install packages: it would need sudo, network handling and hand-written idempotency per package.
  • Every guard hand-written in bootstrap.sh (the -L checks, cmp -s, the “already registered?” test) Ansible gives free:
shell scriptAnsible
You writesteps to performthe state you want
Run twiceyou hand-code guardsidempotent by construction
Reports changesonly if you echoalways: changed vs ok
Dry runno--check
  • bootstrap.sh survives 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.

  1. sync.sh pulled before committing. git pull --rebase refuses 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.
  2. 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.
  3. The hook fired on documentation. A README containing git add tripped 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).
  4. The symlink lockout. git switch main → branch predated ssh/ → symlink dangled → SSH lost its aliases → git pull failed 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.
  5. pipx list --short. The playbook reinstalled Ansible on every run: --short doesn’t exist in pipx 1.0.0 (Ubuntu 22.04’s) → exit 2 with a usage error → failed_when: false swallowed it → empty stdout → guard always true. → failed_when: false hides bugs; check a CLI flag against the installed version; changed=0 is a test result, not cosmetics. Only visible because it ran on an already-configured machine (on a fresh one every task legitimately changes something).
  6. The phantom modified file. git status showed M on 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 → create claude/skills/<name>/SKILL.md with name + description frontmatter, then ./bootstrap.sh (a new directory needs linking).
  • A hook → script into claude/hooks/, chmod +x, add an entry to settings-fragment.json, ./bootstrap.sh. Pipe-test it first (below).
  • A package → add to apt_packages in ansible/provision.yml.
  • Another config file → add it to the repo, then a link_it (or copy_it) line in bootstrap.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
  • --check skips command/shell tasks: it validates package tasks only. A smoke test, not a proof.
  • The fake HOME run is a complete, side-effect-free rehearsal, because bootstrap.sh only ever writes under $HOME.

Troubleshooting

SymptomLikely causeCheck / fix
Claude ignores the git ruleCLAUDE.md not loadedls -la ~/.claude/CLAUDE.md: does the link resolve?
Could not resolve hostname github-personal~/.ssh/config missing or wrongcat ~/.ssh/config, then ./bootstrap.sh
Bad owner or permissions on ssh configmode too openstat -c %a ~/.ssh/config must be 600
git: “unable to auto-detect email”no identity: working as designedgit config --local user.email "...", or move the repo under ~/work/
ansible: command not found~/.local/bin not on PATHecho $PATH; ls -la ~/.bash_aliases; source ~/.bashrc
Hook never firessettings not loadedpython3 -c "import json;json.load(open('$HOME/.claude/settings.json'))", then /hooks or restart
Authenticated as the wrong GitHub accountURL didn’t use the aliasgit remote -v must be github-personal:..., never [email protected]:
Anything dangling after a branch switchsymlink target not on this branch./bootstrap.sh repairs dangling links
Playbook reports changed every runa command/shell task has no guardfind a task without when: or changed_when:
  • ./bootstrap.sh is the single most useful recovery command: idempotent, backs up anything it replaces, repairs dangling symlinks, audits them at the end.

SSH broken, so git pull can't fetch the fix

git pull = fetch + merge. If origin/main was 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:...