fix: let chezmoi apply run unattended on assist, and name which half of pai-git-sync failed - #23
Conversation
…of pai-git-sync failed
Two small changes with one cause.
`pai-git-sync.service` runs `chezmoi apply` from a systemd user unit on assist, a
long-running headless VM. Four of the mail configs are templated with `rbw` calls
that resolve at RENDER time, so applying them needs an unlocked Bitwarden agent
and a working pinentry. Under systemd there is neither:
rbw get: failed to read password from pinentry:
pinentry error: Inappropriate ioctl for device <Pinentry>
The assist ignore block already excluded three of those four — `.config/aerc`,
`.config/emails` and `.msmtprc` — for exactly this reason. `.config/vdirsyncer`
was the omission, so it is added alongside its siblings. Ignoring leaves any
already-rendered file in place (ignore means unmanaged, not deleted), and
vdirsyncer's timer is disabled on assist, where it last synced in May.
The durable fix for vdirsyncer is a runtime credential read rather than a
render-time one, as torpedo already does via `LoadCredentialEncrypted=` on
`vdirsyncer.service`. That cannot be copied to assist as-is: systemd-creds there
(systemd 255.4) has no `--user` option and the VM has no TPM, so user-scoped
credential encryption is unavailable. Verified rather than assumed — vdirsyncer's
`expand_fetch_params` is key-agnostic, so `client_id.fetch` etc. would work, but
there is nowhere on that host to seal the credential for a user unit. That design
belongs to the assist MUA rollout, not to this gate.
Second change: `sync_repo` now returns 2 for a post-sync hook failure instead of
collapsing it into the same exit 1 as a git failure, and the caller reports the
two halves separately. This unit is named for git sync, so a bare exit 1 from
`chezmoi apply` reads as a git problem — on 2026-08-31 that cost a detour into
the git half of a unit whose git half was already clean. The summary lines are
emitted on success too, so the journal states the outcome instead of leaving it
to be inferred from an absence of errors.
shellcheck clean.
✅MegaLinter analysis: Success
See detailed reports in MegaLinter artifacts
|
The provenance belongs in this PR and the commit above it, not inline: dropped the systemd-255/no-TPM explanation, the dates, and the pointer to the MUA rollout from .chezmoiignore, and the 2026-08-31 incident story from the script. Kept only what a future reader needs at the point of edit: that the templates resolve rbw at render time under an unattended apply, that ignoring is not a delete, and that 2 is a distinct return code. 18 added comment lines -> 7. shellcheck clean.
_MREVIEW — Automated ReviewPlatform: github | Ref: #23 | CI: pass (2 checks, both pinned to Verdict: approved; three points below, none blocking. The stated cause is not Premise checkConfirmed on
Important
Minor
Checked and clean: the Posted by |
…hook state Three findings from the review, all confirmed before changing anything. 1. errexit is suppressed inside sync_repo. Reproduced: with `set -euo pipefail`, a subshell on the left of `||` runs with errexit off, and the suppression reaches into the function body. So the two unguarded commands could fail silently, and the new summary line would then assert git=ok over it. The failing case is a PARTIAL add, not a broken repo. With an unreadable file in the tree, `git add -A` exits 128 having staged nothing; `git diff --cached --quiet` then returns 0, so the commit block is skipped and rebase and push both no-op successfully. Measured on a scratch repo: before this commit sync_repo returned 0 and printed "Everything up-to-date" while a readable, modified file was never committed; after it, 1. A corrupted index, by contrast, was already caught downstream by the commit guard — so the guard earns its place on the partial case specifically. `git add -A` now returns 1 on failure. `git config core.hooksPath` is checked but deliberately non-fatal: an unset lint gate degrades commit quality without making the sync wrong, and failing there would wedge every repo's sync over a lint concern. 2. post-sync-hook=ok printed even when the hook never ran. A git-stage failure in the chezmoi repo skips `chezmoi apply` entirely, so the old two-state flag reported the apply half healthy while the rendered targets were stale — the worst case to mislabel. Now three states, derived from that repo's own return code rather than a new flag: 0 -> ok, 2 -> FAILED, anything else -> not-run. Verified across five cases (both ok / hook failed / chezmoi git failed / other repo git failed / chezmoi missing .git) with exit codes 0,1,1,1,1. 3. The .chezmoiignore comment named files that do not resolve rbw at render time. Only aerc and vdirsyncer do; .msmtprc uses passwordeval, .mbsyncrc uses PassCmd, both imapnotify templates use passwordCmd, and .config/emails has no rbw reference at all. Narrowed to the two that are true. Also corrected the loop comment, which claimed the subshell keeps the loop alive. It does not: `|| rc=$?` does that, the subshell contains sync_repo's `cd`, and the pair of them is what switches errexit off. shellcheck clean.
Review addressed —
|
sync_repo returns |
output | readable.txt committed? |
|
|---|---|---|---|
| before | 0 | Everything up-to-date |
no |
| after | 1 | fatal: adding files failed |
no |
So the old code reported success for a run that silently dropped a modified file, and the git=ok line this PR adds would have asserted it. That is the defect.
git add -A is now guarded and returns 1.
One deliberate deviation from the suggestion: git config core.hooksPath is checked but non-fatal — it notifies and continues. An unset lint gate degrades commit quality without making the sync wrong, and returning 1 there would wedge the sync of every repo over a lint concern. Flagging it rather than silently diverging; happy to make it fatal if you'd rather.
2. post-sync-hook=ok when the hook never ran — fixed
Correct, and it was the worst case to mislabel. Now three states, derived from the chezmoi repo's own return code rather than a new flag — 0 → ok, 2 → FAILED, anything else → not-run:
| scenario | output | exit |
|---|---|---|
| both ok | git=ok / post-sync-hook=ok |
0 |
| hook failed | git=ok / post-sync-hook=FAILED |
1 |
| chezmoi git-stage failed | git=FAILED (chezmoi) / post-sync-hook=not-run |
1 |
| other repo git failed | git=FAILED (.claude) / post-sync-hook=ok |
1 |
chezmoi missing .git/ |
git=FAILED (chezmoi) / post-sync-hook=not-run |
1 |
Row 3 is the one you identified; it previously read post-sync-hook=ok.
3. .chezmoiignore comment overstated the file list — fixed
Verified per file: .msmtprc render-time 0 / runtime 2 (passwordeval), .mbsyncrc 0 / 3 (PassCmd), both imapnotify templates use passwordCmd, and .config/emails has no rbw reference at all. Only aerc and vdirsyncer resolve at render time. Comment narrowed to those two, with the rest attributed to the ordinary headless-box reason.
Also
Corrected the loop comment, which claimed the subshell keeps the loop alive — it doesn't. || rc=$? does that, the subshell contains sync_repo's cd, and the pair is what switches errexit off. The comment now says so, since that's the footgun a future editor needs at that line.
shellcheck clean.
_MREVIEW — Round 2 (
|
Round 2 flagged this as needing no change, but the misattribution is the kind that causes harm: it said the subshell and the `||` together suppress errexit, so a future editor wanting errexit back would remove the subshell — and lose the cwd isolation the loop depends on, since sync_repo does a cd. Measured: `f || rc=$?` and `if f; then` both print past a failing command and yield 0, while a bare `(f)` with no `||` aborts. The `||` is the sole cause; the subshell contributes nothing to the suppression. Comment now says so, and says not to drop the subshell.
22 added comment lines -> 12, six blocks -> one or two lines each. The errexit fact is now stated once at the call site instead of repeated at each guard; a guarded command needs no comment explaining that it is guarded, only the ones whose reason is non-obvious keep a line: why hooksPath is non-fatal, why a bare add would produce a false success, why 2 is a distinct code, why the hook has a third state, and not to drop the subshell. Provenance and the measurements stay in the commit log and the PR thread.
Round 2 addressed —
|
gwarf
left a comment
There was a problem hiding this comment.
Verdict: approved at 26402ba0. Nothing open.
GitHub refuses an approving review here (own PR, reviews post with your token), so this is the verdict on the record instead.
Rounds 1 and 2 are in the thread above; the short version:
- The stated cause is reproduced, not assumed — 12 occurrences in 14 days in the journal on
assist, alwaysprivate_readonly_config.tmpl:65:16, the branch that host takes. Sweeping for render-time credential calls returns exactly two files,aerc/private_operas.yaml.tmpl(already ignored) and vdirsyncer (added here), so the ignore entry closes the class rather than one file. git add -Aandgit config core.hooksPathare now guarded, which is what errexit could never do for them from inside that||context. Non-fatal oncore.hooksPathis the right call: capture failing is worse than a commit that skipped a lint pass.- The three-state
hook_stateis correct on every path I traced, including the one that motivated it — a chezmoi git-stage failure now readsnot-runinstead ofok. 26402ba0and9a028064are comment-only: code stripped of comments is byte-identical (98 lines each) to the head I reviewed at round 2, so the verdict carries unchanged.

fix: let chezmoi apply run unattended on assist, and name which half of pai-git-sync failed
Two small changes with one cause.
pai-git-sync.servicerunschezmoi applyfrom a systemd user unit on assist, along-running headless VM. Four of the mail configs are templated with
rbwcallsthat resolve at RENDER time, so applying them needs an unlocked Bitwarden agent
and a working pinentry. Under systemd there is neither:
The assist ignore block already excluded three of those four —
.config/aerc,.config/emailsand.msmtprc— for exactly this reason..config/vdirsyncerwas the omission, so it is added alongside its siblings. Ignoring leaves any
already-rendered file in place (ignore means unmanaged, not deleted), and
vdirsyncer's timer is disabled on assist, where it last synced in May.
The durable fix for vdirsyncer is a runtime credential read rather than a
render-time one, as torpedo already does via
LoadCredentialEncrypted=onvdirsyncer.service. That cannot be copied to assist as-is: systemd-creds there(systemd 255.4) has no
--useroption and the VM has no TPM, so user-scopedcredential encryption is unavailable. Verified rather than assumed — vdirsyncer's
expand_fetch_paramsis key-agnostic, soclient_id.fetchetc. would work, butthere is nowhere on that host to seal the credential for a user unit. That design
belongs to the assist MUA rollout, not to this gate.
Second change:
sync_reponow returns 2 for a post-sync hook failure instead ofcollapsing it into the same exit 1 as a git failure, and the caller reports the
two halves separately. This unit is named for git sync, so a bare exit 1 from
chezmoi applyreads as a git problem — on 2026-08-31 that cost a detour intothe git half of a unit whose git half was already clean. The summary lines are
emitted on success too, so the journal states the outcome instead of leaving it
to be inferred from an absence of errors.
shellcheck clean.