Skip to content

feat(zsh): order fpath deliberately (brew appended) and pin lookup order with a test - #362

Merged
laurigates merged 4 commits into
mainfrom
docs/zshrc-brew-fpath-rationale
Aug 19, 2026
Merged

feat(zsh): order fpath deliberately (brew appended) and pin lookup order with a test#362
laurigates merged 4 commits into
mainfrom
docs/zshrc-brew-fpath-rationale

Conversation

@laurigates

@laurigates laurigates commented Aug 18, 2026

Copy link
Copy Markdown
Owner

What

Puts brew's zsh site-functions back on fpathappended, never via brew shellenv — fixes a precedence skew that predates it, finishes the ~/.bun/bin half of #360, and adds a regression test so none of it can be broken silently again.

Started as a comment recording why the directory was off fpath. The archaeology showed the omission was collateral rather than a decision, and that the real hazard is the prepend, not the directory — so the comment turned into a change.

Why it was off fpath

fpath+="$(brew --prefix)/share/zsh/site-functions" was deleted in 1aae7e5 (2025-03-03), in the same hunk that deleted eval "$(brew shellenv)". The surviving note — "brew completions are messed up" — was authored against the commented-out shellenv line in the PATH block. Nothing ever stated a reason for the fpath half; it went out because it lived inside the block being removed.

Re-derived against current brew, which emits fpath[1,0]=… plus export FPATH. Both halves bite, and both indict the prepend specifically:

  • Position 1 puts brew ahead of ~/.zfunc and /usr/share/zsh/*/functions. First match wins, so brew's build-time snapshots would displace 20 of the completers ~/.zfunc generates fresh, and brew's _git would displace zsh's native one.
  • export FPATH propagates that order to child shells: after the eval, a bare zsh -f — which reads no config at all — resolves _git to the brew copy.

Appending has neither property.

fpath order

Now ~/.zfunc → zsh's own → brew, each position load-bearing:

Position Reason
~/.zfunc first Generated from the binary that actually runs. It was last, so zsh's bundled _npm, _pip, _luarocks (5.9 = 2022) were shadowing the generated copies. Completion-side mirror of the PATH rule.
zsh's own next The only names shared with brew are _git and _luarocks. Brew's _git is git's bash-completion wrapper; ahead of zsh's native one it would replace it wholesale and make dot_zfunc/__git_branch_names (#361) inert.
brew last Reaches ~30 completers nothing else ships (_bat, _delta, _k9s, _yq, _zoxide, …) while displacing nothing.

Verified after apply: _git/usr/share/zsh/5.9/functions/_git, _npm~/.zfunc/_npm, _yq/_zoxide/_bat → brew.

Also removes the stale unmanaged ~/.zfunc/_git, a Feb-2025 copy of that same bash wrapper. Harmless while ~/.zfunc sorted last; promoting the directory would have silently swapped git completion. Backed up first.

PATH: mise > brew > system

Twelve commands violated it. mise activate prepends, so anything prepending after it wins:

  • Ten were bin symlinks in ~/.bun/bin pointing into ~/node_modules — leftovers from chore(mise): migrate npm-only CLI tools from ~/package.json #360, which moved the declarations into mise but left the old install behind (the same cleanup that PR did by hand for ccr). The duplicated deps and the ten now-dangling symlinks are gone; happy-coder, which mise does not manage, is untouched. All ten resolve to mise now.
  • Two remain and are allowlisted by name, with the reason and the fix in the test: kubectl (gcloud's path.zsh.inc prepends after mise activate and bundles its own) and jnv (cargo install; ~/.cargo/bin is prepended in zshenv).

The test

tests/test-shell-precedence.sh, wired into mise run test as test:shell-precedence. It reads a live zsh -i, because both invariants are properties of a shell rather than of the source tree, and skips loudly on a machine without mise or brew.

What makes it trustworthy rather than decorative:

  • Verified in both directions. It passes on this config and fails on the pre-change one — rendered from origin/main, not a hand-written bypass — with exactly the two expected failures.
  • Self-check. It feeds the tier assertion a scrambled PATH and fails if that is reported clean, so the suite cannot go green with a neutered assertion.
  • Exceptions are printed, never silently subtracted.
  • Two bugs found in the test itself while validating it: find does not descend a symlinked starting point without -L, and mise's install dirs are symlinks (latest -> ./1.36.2) — the first version silently missed 20 tools, kubectl among them. And basename(1) forked per file: 58s → 1.6s with ${f##*/}.

Also recorded

A 2023-era comment (7491c8c) blamed brew completions for fzf-tab misbehaving on kubectl. That was a load-order bug, root-caused and repaired in d4fb8c5 "fix(zsh): load fzf-tab after compinit". The comment says so, so the next reader does not count an already-repaired bug as part of the rationale.

Note

Builds on the same file as #361 but is independent of it; #361's completion override was re-verified working under the new fpath order.

The block carried only "Avoid using brew shellenv for setup, because brew
completions are messed up" — a verdict with no mechanism, which is not enough
to decide whether it still holds.

Recovered from history: the `fpath+="$(brew --prefix)/share/zsh/site-functions"`
line was dropped in 1aae7e5 (2025-03-03) in the same hunk that dropped
`eval "$(brew shellenv)"`, so the surviving comment covers PATH setup and never
stated a reason for the fpath half. Re-derived and confirmed against the current
brew, which emits `fpath[1,0]=...` plus `export FPATH`:

- Position 1 places brew ahead of ~/.zfunc and /usr/share/zsh/*/functions.
  First match in fpath wins (verified with a flipped-order control), so brew's
  build-time snapshots would shadow 20 of the 38 completers ~/.zfunc generates
  fresh, and brew's `_git` would shadow zsh's native one.
- `export FPATH` propagates that order to child shells: after eval, a bare
  `zsh -f` resolves _git to the brew copy.

So the conclusion still holds and now says why. Also notes that the other
historical suspicion — a 2023 comment blaming brew completions for fzf-tab
misbehaving on kubectl — was root-caused as a load-order bug and fixed in
d4fb8c5, so it is not part of the rationale.

Comment only; no behaviour change.
@github-actions

Copy link
Copy Markdown
Contributor

🤖 Claude analyzed the CI failures but determined no code changes are needed.

Failed workflow: https://github.com/laurigates/dotfiles/actions/runs/32120090592

This may indicate:

  • Flaky tests
  • Infrastructure issues
  • Transient failures
  • Configuration problems outside the codebase

Please review the failure logs manually.

… test

Puts brew's zsh site-functions back on fpath — appended, never via
`brew shellenv` — and fixes a precedence skew that predates it. Adds a
regression test so neither order can be broken silently again.

fpath is now ~/.zfunc > zsh's own functions > brew:

- ~/.zfunc moves ahead of the system dirs. It is generated from the binary
  that actually runs, so it should win; it did not. _npm, _pip and _luarocks
  exist in both places, and zsh's bundled copies (5.9, 2022) were shadowing
  the generated ones. This is the completion-side mirror of the PATH rule.
- brew goes last, reaching ~30 completers nothing else ships (_bat, _delta,
  _k9s, _yq, _zoxide, ...) while displacing nothing. First match wins, verified
  with a flipped-order control.
- brew after zsh's own, not before: the only two names in both are _git and
  _luarocks, and brew's _git is git's bash-completion wrapper, which would
  displace zsh's native one and make dot_zfunc/__git_branch_names inert.

Removes the stale unmanaged ~/.zfunc/_git (a Feb-2025 copy of that same
wrapper). Harmless while ~/.zfunc sorted last; promoting the directory would
have silently swapped git completion. Backed up before deleting.

PATH invariant mise > brew > system was violated by 12 commands. `mise
activate` prepends, so anything prepending after it wins. Ten were bin symlinks
in ~/.bun/bin pointing into ~/node_modules — leftovers from #360, which moved
the declarations into mise but left the old install in place. Removed the
duplicated deps and the ten now-dangling symlinks; happy-coder, which mise does
not manage, is untouched. All ten commands now resolve to mise.

tests/test-shell-precedence.sh pins both orders against a live `zsh -i`:

- Verified in both directions: it passes on this config and fails on the
  pre-change one (rendered from origin/main) with exactly the two expected
  failures, rather than being trusted by construction.
- A self-check feeds the tier assertion a scrambled PATH and fails if it
  reports it clean, so the suite cannot go green with a neutered assertion.
- The two remaining violations, kubectl (gcloud's path.zsh.inc prepends and
  bundles its own) and jnv (cargo), are allowlisted by name with the reason
  and the fix, and printed on every run rather than silently subtracted.
- `find -L`: mise install dirs are symlinks (`latest -> ./1.36.2`) and find
  will not descend a symlinked starting point without it. The first version
  silently missed 20 tools, kubectl among them.
- Uses ${f##*/} rather than basename(1), which forked per file: 58s -> 1.6s.

Comments at `mise activate` and at each of the three prepends that run after it
state the invariant and name the test, so the next edit that adds a PATH entry
has the reason in front of it.
@github-actions github-actions Bot added size/m and removed size/s labels Aug 18, 2026
@laurigates laurigates changed the title docs(zsh): record why brew's site-functions is kept off fpath feat(zsh): order fpath deliberately (brew appended) and pin lookup order with a test Aug 18, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🤖 Claude analyzed the CI failures but determined no code changes are needed.

Failed workflow: https://github.com/laurigates/dotfiles/actions/runs/32127075127

This may indicate:

  • Flaky tests
  • Infrastructure issues
  • Transient failures
  • Configuration problems outside the codebase

Please review the failure logs manually.

Copy link
Copy Markdown
Owner Author

Build (Ubuntu) here is not this PR's doing — it is a main breakage, red on every commit since 2026-08-04. Root cause and fix in #364; this PR should go green once that merges.

chezmoi: .chezmoiignore:9: /justfile: invalid path

chezmoi ≥2.72 rejects leading-slash patterns (they resolve outside the destination dir); older releases tolerated them. CI installs chezmoi unpinned via brew install, so an upstream release changed the rule with no commit here. /justfile has been in the file since #308 and was green for a month.

Worth flagging for anyone reading the two "no code changes are needed" bot comments above: the error is fatal and totalapply, ignored and managed all refuse to run — and chezmoi reports only the first offending line, so seven bad patterns across .chezmoiignore and .chezmoiremove presented as a single justfile problem. That is also why every other check stayed green.

The fix is slash-free patterns, which is what actually root-anchors (justfile matches the source-root copy only, leaving ~/.config/just/justfile managed; **/justfile would have swallowed the global one). Verified against a locally built chezmoi v2.72.0 — details in #364.

No changes needed on this branch.


Generated by Claude Code

laurigates added a commit that referenced this pull request Aug 18, 2026
## What

Unbreaks `Build (Ubuntu)`, which has failed on **`main`** since
2026-08-04 — every commit, not just PRs.

```
chezmoi: /home/runner/work/dotfiles/dotfiles/.chezmoiignore:9: /justfile: invalid path
```

## Why now, when nothing in the repo changed

chezmoi matches patterns against the target path **relative to the
destination dir**, so a leading slash makes the path absolute — outside
the destination. Newer chezmoi (≥2.72) rejects that; older releases
tolerated it silently. CI installs chezmoi unpinned (`brew install
chezmoi`), so a release changed the rule underneath us. `/justfile`
itself has been in the file since #308 (2026-07-07) and was green for a
month.

The failure is worth understanding because it is **fatal and total**:
`apply`, `ignored` and `managed` all refuse to run, and chezmoi reports
only the **first** offending line — so seven bad patterns presented as
one. That is why the job dies at `chezmoi apply` while `Linters`,
`Benchmarks`, `SBOM` and the rest stay green, and why it looks like an
isolated `justfile` problem rather than a whole-file one.

## The fix, and why bare patterns are correct

A slash-free pattern **is** the root anchor — it was never the leading
slash doing that work. Verified against a locally built chezmoi v2.72.0:

| Pattern | Matches | |
|---|---|---|
| `justfile` | source-root `justfile` only — `.config/just/justfile`
stays managed | ✅ what we want |
| `/justfile` | nothing — fatal parse error | ❌ |
| `**/justfile` | every `justfile` at any depth, **including the managed
global one** | ❌ would break `just -g` |

Directory patterns behave the same: `docs/` ignores only the root
`docs/`, leaving `~/.config/nvim/docs/` managed. So all seven edits
preserve the existing intent exactly — this is a syntax repair, not a
semantics change.

Both invariants re-checked against the real source tree after the
change:

- `chezmoi managed` still lists `.config/just/justfile` — the global
justfile survives.
- `chezmoi managed` still does **not** list `justfile` — the repo-root
copy stays out of `$HOME`.
- `chezmoi apply -v --dry-run --source=. --exclude=scripts` (CI's exact
command) exits 0.

## `.chezmoiremove`

The second offender, surfaced only once line 9 stopped aborting the
parse:

```
.chezmoiremove:14: /tmp/claude_status_hub.log: invalid path
```

Deleted rather than de-slashed. `.chezmoiremove` can only name targets
under `$HOME`, so a genuine `/tmp` path is not expressible at all — and
`tmp/claude_status_hub.log` would silently re-point it at `~/tmp`, a
scratch dir `.chezmoiignore` deliberately ignores, deleting an unrelated
file. Nothing else in the tree references that log; it is dead weight
from a 2025-09-30 cleanup. A comment records the reasoning so the entry
is not "repaired" back into existence.

## Docs

`.claude/rules/chezmoi-conventions.md` documented `/justfile` as *the*
root-anchoring idiom — following the rule as written re-breaks CI.
Corrected to the slash-free form, with the anchoring table above and a
note that a leading slash is now a hard error.

The `.chezmoiignore` comment block also claimed the global justfile is
`dot_user.justfile.tmpl` → `~/.user.justfile`, which the same rule file
elsewhere explains is wrong (`~/.user.justfile` is not on `just`'s
search path). Corrected to `private_dot_config/just/justfile` →
`~/.config/just/justfile` while touching those lines.

## Note

Found while investigating the red check on #362. It is unrelated to that
PR's diff — split out here so it unblocks every PR rather than riding
inside a zsh change. #362 stays red until this merges.

## Follow-up worth considering (not in this PR)

CI installs chezmoi unpinned, which is what let an upstream release
break `main` silently. Pinning it (mise already manages `chezmoi =
"latest"`) would turn this class of breakage into a deliberate upgrade.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Sa3o7Pia4UZ1zKH5NFRQ24)_

Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Broken Links Detected

Summary

Status Count
🔍 Total 170
✅ Successful 160
⏳ Timeouts 0
🔀 Redirected 0
👻 Excluded 9
❓ Unknown 0
🚫 Errors 1

Errors per input

Errors in docs/macos-automation.md


Please fix the broken links before merging.

The `Test zsh shell initialization` step wraps `source ~/.zshrc` in
`timeout 10s`. That budget was already almost exhausted before this
branch touched anything: the step took ~6s on a cold runner on
2026-07-30 and ~9s on 2026-08-18 — 90% of the limit — so any change
that adds startup cost turns it red, and exit code 124 reads as a hang
rather than as "one second slower than last week".

Adding brew's site-functions to fpath gives compinit a fourth directory
to scan, which was enough to cross it.

The timeout exists to catch a config change that BLOCKS on input, not to
police startup time, so it is raised to 60s — still fast to fail on a
real hang, with room for normal drift. The comment records the measured
baselines so it is not tightened back on the assumption that 10s was
ever comfortable.

Worth stating plainly: this does not make shell startup faster, and the
6s -> 9s drift on main is not explained by this branch. Nothing in CI
currently watches that number — the benchmarks workflow times a minimal
/tmp/test.zshrc, not the applied ~/.zshrc — so the drift is invisible by
design. Noted in the comment as a separate problem rather than folded
into this one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sa3o7Pia4UZ1zKH5NFRQ24
@github-actions github-actions Bot added the ci label Aug 18, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Broken Links Detected

Summary

Status Count
🔍 Total 170
✅ Successful 160
⏳ Timeouts 0
🔀 Redirected 0
👻 Excluded 9
❓ Unknown 0
🚫 Errors 1

Errors per input

Errors in docs/adrs/0012-justfile-command-runner.md


Please fix the broken links before merging.

@github-actions

Copy link
Copy Markdown
Contributor

🤖 Claude analyzed the CI failures but determined no code changes are needed.

Failed workflow: https://github.com/laurigates/dotfiles/actions/runs/32179632608

This may indicate:

  • Flaky tests
  • Infrastructure issues
  • Transient failures
  • Configuration problems outside the codebase

Please review the failure logs manually.

@laurigates
laurigates merged commit 57ef46a into main Aug 19, 2026
8 of 9 checks passed
@laurigates
laurigates deleted the docs/zshrc-brew-fpath-rationale branch August 19, 2026 05:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants