diff --git a/CLAUDE.md b/CLAUDE.md index 6e1f3b37b1..9b52fc9cfe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -289,6 +289,16 @@ nearest `.tool-versions` above the cwd, so a run typed inside a project would update that project's toolchain instead of the global one pinned in `~/.tool-versions`. `tests/update.bats` pins this. +**`update()` runs machine-local checks after the upgrades.** Every +`update.check` command in git config (repeatable, overlay-supplied — the +public repo names none) runs right after the no-sudo upgrades, while an +upgrade is still the obvious suspect for whatever it breaks: a service whose +networking a formula update changed, a cask a daemon reads secrets through. +A failing check never stops the update; each failure is repeated in a red +banner at the very end, past the softwareupdate and gem output that would +otherwise bury it. A value containing `;` must be quoted in the config file +(git reads a bare `;` as a comment) — `git config --add` does that for you. + **A half-installed cask is invisible to all of this.** A `.pkg` cask whose installer never ran still leaves a Caskroom entry, so `brew bundle` counts it as satisfied and never retries while the app is missing from `/Applications`. diff --git a/dot_functions b/dot_functions index 0f2f5db332..ce22d7d81a 100644 --- a/dot_functions +++ b/dot_functions @@ -2915,9 +2915,17 @@ function doctor() { # Get macOS Software Updates, and update installed Ruby gems, Homebrew, npm, uv, # and their installed packages. Runs from $HOME whatever directory it was typed # in, and puts the shell back afterwards. +# +# After the no-sudo upgrades it runs every `update.check` command from git +# config (repeatable, machine-local, overlay-supplied — this repo names none): +# whatever on this machine an upgrade can silently break gets to say so while +# the upgrade is the obvious suspect. A failing check never stops the update; +# failures are repeated in a red banner at the very end, past the +# softwareupdate and gem output that would otherwise bury them. function update() { setopt localoptions nomonitor nonotify; - local here=${PWD:-} keepalive_pid=; + local here=${PWD:-} keepalive_pid= check; + local -a failed; # Two reasons this never runs where it was typed. A directory that no # longer exists (a removed worktree is the usual way) is inherited by @@ -2964,6 +2972,10 @@ function update() { command -v npm > /dev/null 2>&1 && npm install npm -g; command -v npm > /dev/null 2>&1 && npm update -g; command -v uv > /dev/null 2>&1 && uv tool upgrade --all; + for check in ${(f)"$(command git config --get-all update.check 2>/dev/null)"}; do + print -r -- "== update.check: $check"; + sh -c "$check" || failed+=("$check"); + done; # One password prompt for the rest; keep the sudo timestamp alive # until the updates finish (same pattern as ~/.macos). Disowned (&!) @@ -2989,6 +3001,9 @@ function update() { # and does not fire on an interrupt at all, so a ^C'd update left the # keepalive polling sudo for the life of the shell. [[ -n $keepalive_pid ]] && kill "$keepalive_pid" 2>/dev/null; + for check in $failed; do + print -Pru2 -- "%F{red}%B✘ update.check failed:%b%f ${check//\%/%%}"; + done; cd -q -- "$here" 2>/dev/null || print -ru2 -- "update: $here vanished during the update; shell left in $PWD"; }; } diff --git a/tests/update.bats b/tests/update.bats index 4bcc1522a8..31e39b20b8 100644 --- a/tests/update.bats +++ b/tests/update.bats @@ -101,3 +101,25 @@ run_update() { ! grep -qF "sudo -n true" calls.log || { echo "keepalive started after sudo -v failed"; false; } ! grep -qF "sudo gem" calls.log || { echo "gem ran after sudo -v failed"; false; } } + +@test "update.check commands run after the upgrades; a failure is repeated at the end and never stops the update" { + export GIT_CONFIG_GLOBAL="$BATS_TEST_TMPDIR/gitconfig" GIT_CONFIG_NOSYSTEM=1 + git config --file "$GIT_CONFIG_GLOBAL" --add update.check 'echo first-check-ran' + git config --file "$GIT_CONFIG_GLOBAL" --add update.check 'echo 50% broken >&2; exit 3' + start="$BATS_TEST_TMPDIR/start"; mkdir -p "$start" + run_update "cd -q -- '$start'" + grep -q "^RC=0 PWD=$(cd "$start" && pwd -P)\$" out + grep -qF "first-check-ran" out + grep -qF "== update.check: echo 50% broken >&2; exit 3" out + # The banner comes last, after gem, and names the failing command verbatim. + [ "$(tail -n 1 err | sed $'s/\e\\[[0-9;]*m//g')" = "✘ update.check failed: echo 50% broken >&2; exit 3" ] + ! grep -q "update.check failed: echo first" err + grep -qF "sudo gem cleanup" calls.log +} + +@test "with no update.check configured, nothing extra is printed" { + export GIT_CONFIG_GLOBAL="$BATS_TEST_TMPDIR/gitconfig" GIT_CONFIG_NOSYSTEM=1 + : > "$GIT_CONFIG_GLOBAL" + run_update ":" + ! grep -q "update.check" out err +}