No error message. No warning. To yadm, a hook in the wrong place and no hook at all are the same thing.
This was supposed to be a cleanup#
The plan was modest: go through all 82 tracked files and delete some stale configuration. What actually happened was closer to an archaeology dig, and every layer had something dead in it that looked alive from the surface. Five findings, in the order they turned up.
Finding 1: the hook on the dead path#
yadm 2.x read hooks from ~/.yadm/hooks/pre-commit, with a hyphen. yadm 3.x reads them from $YADM_DIR/hooks/pre_<command>, so a commit hook lives at ~/.config/yadm/hooks/pre_commit, with an underscore. My machine runs yadm 3.5. My hook was still parked at the 2.x path.
# yadm 2.x (dead, but the file was still there)
~/.yadm/hooks/pre-commit
# what yadm 3.x actually reads: $YADM_DIR/hooks/pre_<command>
~/.config/yadm/hooks/pre_commitA directory move and one character, hyphen to underscore, and a rule labeled “no exceptions” died quietly for about a year. The fix took a minute. The part worth keeping is the comment that now sits at the top of the hook, so the path contract is written where the next person (me) will trip over it:
#!/bin/bash
# yadm pre-commit hook (yadm 3.x reads $YADM_DIR/hooks/pre_<command>,
# i.e. this file must live at ~/.config/yadm/hooks/pre_commit).
# Runs the full test suite (which includes markdownlint) before
# allowing a commit. Bypass in emergencies: yadm commit --no-verifyFinding 2: thirteen CI jobs, zero teeth#
The repo’s CI had 13 jobs. Thirteen green circles is a comforting sight. Then I looked at which ones could actually stop a bad commit, and the answer was none that mattered: the critical steps had continue-on-error or a trailing || true scattered on them.
Thirteen jobs bought me a lot of reassurance and almost no protection. Whether a pipeline will actually refuse a bad commit is a separate property from how much of it there is, and nothing about the green circles tells you which one you have.
Finding 3: launchctl said yes and did nothing#
The daily-maintenance launch agent had been silently stopped since July 3. The root cause is a genuinely nasty little contract: launchctl load exits 0 even when the agent is disabled in the override database, so the command reports success while the service stays down.
# exits 0 even when the agent is disabled in the override database
launchctl load ~/Library/LaunchAgents/com.daily-maintenance.plist
# what actually works: enable explicitly, then bootstrap
launchctl enable gui/$UID/com.daily-maintenance
bash ~/install-daily-maintenance.sh“The command succeeded” and “the service is running” are separate claims. I now treat any launchd interaction that only checks exit codes as unverified.
Finding 4: the zombie updater#
A duplicate brew-update automation had been running since February 2023. Two and a half years of doing the same work twice, and nobody noticed, because both copies succeeded. Redundant success is invisible in a way redundant failure never is.
Finding 5: the editor frozen in time, with a full paper trail#
This one is the reason the post exists. Neovim had been frozen on the June 30 nightly build for five weeks before any symptom appeared. When it finally surfaced, it did so sideways: neo-tree called nvim_win_resize, an API added in a newer nightly than the one I was running, and blew up with “attempt to call a nil value”.
The causal chain took a while to untangle:
The wedged install I confirmed against bob’s own dev-branch source rather than guessing. And there is a general trap in here for anyone on nightlies:
" trap: every 0.13-dev nightly satisfies this condition,
" so it gates nothing, including the weeks before the API existed
if has("nvim-0.13")
" ...
endifhas("nvim-0.13") is true for every 0.13-dev build, first to last. As a version gate for a feature that landed mid-cycle, it is no gate at all.
The immediate fix was three parts: unwedge the stuck install, point PATH back at the real bob, and add a boot canary to the test suite:
# if the editor cannot start, this goes red today, not in five weeks
# when some plugin happens to call an API that is not there
nvim --headless -c qBut the part that actually stings is this: the failure was in the maintenance log every day for a month, described accurately each time. It just landed in a file nobody reads. So the structural fix went to the output side. The maintenance summary now sends a desktop notification whenever any task fails, and the message layout assumes it will be truncated: log location first, at most three failures listed, the rest folded into a count.
# log location first (notifications get truncated, so the most
# important information leads), max 3 failures, rest folded
body="Failures logged in $LOG"
[ "$total" -gt 3 ] && body="$body (+$((total - 3)) more)"What shipped#
The cleanup turned into 22 single-concern commits, one problem per commit so that any of them can be reverted alone.
| Metric | Before | After | Verified how |
|---|---|---|---|
| CI jobs | 13 | 5 | coverage went up, not down: fed the gates a broken .sh, a broken .md, and a broken KDL config; all three rejected |
| Local test suite | none | 74 checks | born in this audit; at 105 today |
| zsh startup | 398 ms | 254 ms | hyperfine --warmup 3 'zsh -i -c exit' |
| Working pre-commit gate | no | yes | commits with failing tests are refused |
Two of those rows deserve a sentence. The CI shrink is the point of finding 2: fewer jobs, strictly more protection, and the protection is proven by deliberately feeding the pipeline broken files rather than by looking at green circles. And the startup number comes from hyperfine because an unreproducible number is a mood, not a measurement.
Lessons#
- Automation you have never seen fail is indistinguishable from automation that does not exist. Their observable behavior is identical.
- Validate a gate by deliberately breaking something it should catch. A gate that has only ever seen good inputs is unproven by construction.
- Failures must arrive somewhere you actually look. A log line is not an alert; it is a diary entry.
- Counting CI jobs measures reassurance. Counting what CI refuses measures protection.
- When a tool renames its config path across a major version, every consumer of the old path fails silently. Write the path contract into the file itself.
References#
- yadm hooks documentation (the
pre_<command>naming and$YADM_DIRlookup) - hyperfine (startup-time benchmarking)
- bob (the Neovim version manager; wedge behavior confirmed against its dev-branch source)
- The repo this all happened in: nickboy/dotfiles
launchctlbehavior observed on macOS on this machine; seeman launchctlfor theenable/bootstrapsubcommands
