Runbook: brew-sync — keep Brewfile.tmpl in step with brew install¶
When you run brew install, brew uninstall, brew tap, brew untap, or mas install outside the Brewfile.tmpl flow, the shell records the event to a journal. A separate interactive tool, chezmoi-brew-sync, merges the journal into Brewfile.tmpl under your review.
The tracked file is never edited behind your back: every change goes through chezmoi-brew-sync, which validates the resulting template across the full machine_type × arch matrix before replacing the file.
Pipeline¶
sequenceDiagram
autonumber
participant U as User
participant W as brew/mas wrapper<br/>(dot_zshrc)
participant R as chezmoi-brew-record
participant J as Journal<br/>~/.cache/.../journal.ndjson
participant B as Shell banner
participant S as chezmoi-brew-sync
participant T as Brewfile.tmpl
U->>W: brew install ffmpeg
W->>W: real brew runs
W-->>R: async call (preserves exit code)
R->>J: append NDJSON event
U->>U: new shell session
J->>B: read pending count
B-->>U: "1 brew event(s) pending"
U->>S: chezmoi-brew-sync
S->>J: read events
S->>S: dedup (install ⨯ uninstall cancel)
S->>S: classify (add / remove / skip)
U->>S: section + machine_type wrap
S->>T: write proposed file
S->>S: validate 4 matrix cells
S-->>U: diff -u
U->>S: confirm
S->>T: atomic mv (commit)
S->>J: truncate
S-->>U: "Journal cleared. Run chezmoi diff." Components¶
| Piece | Where | What |
|---|---|---|
brew() / mas() wrappers | dot_zshrc | After the real command exits, call chezmoi-brew-record async. Never alter the user-visible exit code. |
chezmoi-brew-record | ~/.local/bin/ | Append one NDJSON line per package to the journal. Skips failed installs (rc != 0). |
| Journal | ~/.cache/chezmoi-brew-inbox/journal.ndjson | Append-only NDJSON. One line per event. Truncated by chezmoi-brew-sync on confirmed merge. |
| Shell banner | dot_zshrc | Reads the journal on shell startup and prints "N brew event(s) pending — run chezmoi-brew-sync". Switches to red at ≥10 events. |
| Weekly notification | com.user.chezmoi-brew-inbox.plist | Posts a macOS notification every Monday 09:30 if the journal is non-empty. Prevents silent accumulation. |
chezmoi-brew-sync | ~/.local/bin/ | Interactive merge. Reads journal, dedupes install/uninstall pairs, prompts for placement, validates, shows diff, replaces Brewfile.tmpl atomically. |
Normal flow¶
$ brew install ffmpeg
# … brew runs normally …
# Next shell session:
$ zsh
chezmoi: 1 brew event(s) pending — run chezmoi-brew-sync
$ chezmoi-brew-sync
==> chezmoi-brew-sync — 1 pending action(s)
[1/1] add brew "ffmpeg"
Sections:
* 1) CLI Tools
2) Build / Dev dependencies
…
Section number [1] (or 's' to skip): 1
Wrap in {{ if eq .machine_type "personal" }} block? [y/N] n
→ brew "ffmpeg"
==> Validating template across machine_type × arch matrix...
✓ All 4 combinations render cleanly.
==> Diff vs current Brewfile.tmpl:
--- Brewfile.tmpl 2026-05-11 …
+++ /tmp/brewfile-sync.X 2026-05-11 …
@@ -14,6 +14,7 @@
brew "eza"
brew "fd"
brew "findutils"
+brew "ffmpeg"
brew "fnm"
…
Apply these changes and truncate journal? [y/N] y
✓ Brewfile.tmpl updated. Journal cleared.
Next: run 'chezmoi diff' then 'chezmoi apply' to install.
What chezmoi-brew-sync does in detail¶
Step-by-step internals (click to expand)
- Sanity gate — Requires
chezmoiandjq. Aborts ifBrewfile.tmplhas uncommitted changes (pass--forceto stash; never auto-pops). - Mutex —
mkdir-based lock at~/.cache/chezmoi-brew-inbox/.sync.lock. Two concurrent syncs cannot race. - Dedup — Reads the journal, collapses to the latest event per
(kind, name). Install-then-uninstall pairs cancel. - Classify — For each resolved event:
install+ not in file → propose add.install+ already in file → skip (no-op).uninstall+ in file → propose remove.uninstall+ not in file → skip (no-op).
- Prompt — For adds: section number + optional
{{ if eq .machine_type "personal" }}wrap. Section suggestions are seeded by kind (formula → CLI Tools, cask → keyword routing). For removes: confirm yes/no. - Apply — Writes proposed edits to a temp file. Insert positions preserve alphabetical order within each section; removes use line-anchored matches so only exact entries are deleted.
- Validate — Runs
chezmoi execute-templateacross all 4 machine_type × arch combos. Aborts with the temp file kept (for inspection) if any combo fails to render. - Diff —
diff -ubetween live and proposed files. - Commit — On
y, atomicmvof the proposed file over the live one, then truncate the journal.
Common situations¶
"Brewfile.tmpl has uncommitted changes"¶
chezmoi-brew-sync refuses by design — silently merging into your in-progress edit is the failure mode most likely to lose work.
- Commit or discard your changes, then re-run.
- Or
chezmoi-brew-sync --forceto stashBrewfile.tmplfirst. The stash is never auto-popped; rungit stash pop(inchezmoi cd) when you're ready to reconcile.
A pending entry should be wrapped in {{ if eq .machine_type "personal" }}¶
chezmoi-brew-sync asks per entry. Answer y when prompted. The line is wrapped in its own {{ if … -}} … {{ end -}} block at the chosen section's tail. You can later move it into an existing personal-only section by hand if you prefer the section-level grouping pattern.
The journal accumulated 20+ entries¶
Shell banner turns red at ≥10. Run chezmoi-brew-sync and walk through them. The dedup step usually collapses a lot — brew install foo && brew uninstall foo produces zero net actions.
"no tty available"¶
chezmoi-brew-sync must run from an interactive terminal — it prompts via /dev/tty. Cron/launchd cannot run it; the weekly launchd notification only reminds the user to run sync interactively.
A mas install <id> event needs the human-readable app name¶
The journal stores only the numeric App Store ID (that's all mas install takes). chezmoi-brew-sync prompts for the display name (look it up with mas info <id>). The resulting Brewfile line is mas "Name", id: <id>.
I want to forget what's in the journal¶
The next sync run will exit "journal empty — nothing to do".
chezmoi-brew-sync failed validation and bailed¶
The proposed file is kept at the temp path the script prints. Inspect it, fix Brewfile.tmpl by hand, then re-run sync to clear the journal.
What this loop does not do¶
- It does not auto-edit
Brewfile.tmplfrom the shell. The wrapper is a recorder; onlychezmoi-brew-syncwrites to the file. - It does not handle
brew upgradeevents — version pinning is not modelled inBrewfile.tmpl(the file describes "what should be installed", not "at what version"). - It does not handle VS Code extensions installed inside VS Code — those are managed separately by the weekly
update-vscode.ymlworkflow draft PR. - It does not auto-commit the resulting
Brewfile.tmplchange. Runchezmoi diffandgit commityourself.
See also¶
recover-from-drift.md— broader drift-recovery procedures (home files, Brewfile drift detection signals).new-machine.md— bootstrap procedure for a fresh machine.