Cache prettier's verdict so a push stops paying Node startup
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
Pre-commit checks / pre-commit (push) Has been cancelled
Python tests / test (push) Has been cancelled
Shell tests / shell-tests (push) Has been cancelled
Shell tests / arch-tests (push) Has been cancelled

A push took ~13s. prettier is a pre-push hook precisely because Node's
startup dominates its runtime, and pre-commit chunks a large file list into
several invocations -- a 106-file push paid ten cold Node starts. ci-mirror,
the hook that looks expensive, was already 0.02s once its green-tree cache
hit.

A file whose content, prettier version and prettier config are all unchanged
since its last clean check cannot have become unformatted, so the verdict is
now cached and the file never reaches Node. Only clean verdicts are recorded,
so a formatting regression is always re-checked; the key includes
`prettier --version` and every config file, so an upgrade or a config edit
invalidates the cache wholesale. The systemd-run memory cap is unchanged.

Measured on the same push range: 13.0s -> 3.3s total, of which ~0.6s is
hooks and the rest is git's own network negotiation. On a 40-file list the
runner itself goes 0.652s -> 0.057s, starting no prettier process at all.

endurain/README.md and copilot-instructions.md are reflowed: the ~
reorganisation lengthened paths inside them, which changed prettier's
markdown table alignment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwtowrowDXx6ccmWVPkCaF
This commit is contained in:
Krzysztof Rudnicki 2026-09-11 21:03:57 +02:00
parent f15f00f3fe
commit db243fe02e
4 changed files with 164 additions and 26 deletions

View File

@ -0,0 +1,47 @@
{
"intent": "Make `git push` return in about a second instead of ~13s, by not paying Node's startup cost to re-check markdown/JSON/YAML files whose content, prettier version and prettier config are all unchanged since their last clean check.",
"scope": [
"meta/scripts/run_prettier_capped.sh - the prettier hook's runner",
"endurain/README.md and linux_configuration/.github/copilot-instructions.md - reflowed by prettier after the ~ reorganisation lengthened paths inside them",
"Non-goal: changing what prettier checks, its config, or the ci-mirror gate. The set of files that must be clean is identical before and after.",
"Non-goal: the first push after a requirements.txt change, which still rebuilds a clean venv by design."
],
"changes": [
"run_prettier_capped.sh now keys a result cache on sha256(file content) plus a fingerprint of `prettier --version` and every prettier config file, so a cached verdict cannot survive an edit, a prettier upgrade or a config change.",
"Files with a cached clean verdict are dropped from the argv; when the argv empties, prettier is never started at all.",
"Only a clean verdict is recorded - a failing check is re-run next time, so the cache can never mask a formatting regression.",
"The cache is wholesale-pruned past 20000 entries, which costs one slow run rather than correctness.",
"The systemd-run memory cap (MemoryMax=512M, MemorySwapMax=0) is preserved exactly, per the oom-prevention skill."
],
"verification": [
{
"command": "bash meta/scripts/run_prettier_capped.sh <40 tracked markdown files> # cold, then warm",
"result": "pass",
"evidence": "cold 0.652s with 40 cache entries written; warm 0.057s printing 'prettier: 40 file(s) unchanged since their last clean check'. 11x faster with no prettier process started."
},
{
"command": "append trailing whitespace to README.md, re-run the same file list",
"result": "pass",
"evidence": "[warn] README.md - the edited file misses the cache and is re-checked; after `git checkout -- README.md` the run is clean again. Cache keyed on content, so an edit cannot be masked."
},
{
"command": "git push --dry-run (pre-push: prettier + ci-mirror + pre-commit pre-push stage)",
"result": "pass",
"evidence": "13.0s before this change, 2.9s after, on the same 106-file push range. ci-mirror itself is 0.02s when its green-tree cache hits; the remainder was prettier's repeated Node startups."
},
{
"command": "shellcheck -x -S warning meta/scripts/run_prettier_capped.sh",
"result": "pass",
"evidence": "no findings; shfmt applied, matching the repo's tab style."
}
],
"risks": [
"A cached verdict is trusted without re-running prettier. It is invalidated by file content, prettier version and prettier config, so the remaining exposure is a prettier behaviour change that ships without a version bump - which does not happen for a pinned release.",
"The cache lives under XDG_CACHE_HOME, outside the repo, so it is per-machine and a fresh clone or a cleared cache simply pays one full run.",
"prettier errors rather than warns on an explicitly-listed symbolic link (CLAUDE.md -> AGENTS.md). That behaviour is unchanged by this patch, but it means a push range containing CLAUDE.md fails the hook for a reason unrelated to formatting."
],
"rollback": [
"git revert the commit, or restore the previous run_prettier_capped.sh which called `prettier --check \"$@\"` unconditionally inside the same systemd-run scope.",
"After rollback, run `git push --dry-run` and confirm the prettier hook still passes; delete ${XDG_CACHE_HOME:-~/.cache}/prettier-gate, which is then unused."
]
}

View File

@ -14,7 +14,7 @@ unlock authority.
| ------------- | --------------------------------------------------------------------------- | | ------------- | --------------------------------------------------------------------------- |
| Public URL | `https://endurain.kuhy.duckdns.org` | | Public URL | `https://endurain.kuhy.duckdns.org` |
| Local URL | `http://127.0.0.1:8085` (loopback only) | | Local URL | `http://127.0.0.1:8085` (loopback only) |
| WebDAV inbox | `https://kuhy-cloud.duckdns.org/RunnerUp` → `~/data/cloud/RunnerUp/` | | WebDAV inbox | `https://kuhy-cloud.duckdns.org/RunnerUp` → `~/data/cloud/RunnerUp/` |
| Runtime data | `/var/opt/endurain/` | | Runtime data | `/var/opt/endurain/` |
| Ledger | `~/.local/state/endurain-import/ledger.json` | | Ledger | `~/.local/state/endurain-import/ledger.json` |
| Importer code | `../python_pkg/endurain_import/` (repo requires Python under `python_pkg/`) | | Importer code | `../python_pkg/endurain_import/` (repo requires Python under `python_pkg/`) |

View File

@ -66,4 +66,4 @@ For in-depth understanding of specific components, see these dedicated guides:
| Midnight Shutdown | Auto-shutdown at night | `periodic_background/digital_wellbeing/setup_midnight_shutdown.sh` | | Midnight Shutdown | Auto-shutdown at night | `periodic_background/digital_wellbeing/setup_midnight_shutdown.sh` |
| Compulsive Block | Limit app launches | `periodic_background/digital_wellbeing/block_compulsive_opening.sh` | | Compulsive Block | Limit app launches | `periodic_background/digital_wellbeing/block_compulsive_opening.sh` |
| Music Wrapper | Block music during focus | `periodic_background/digital_wellbeing/youtube-music-wrapper.sh` | | Music Wrapper | Block music during focus | `periodic_background/digital_wellbeing/youtube-music-wrapper.sh` |
| Screen Locker | Require workout to unlock | External: `~/src/testsAndMisc/python_pkg/screen_locker/` | | Screen Locker | Require workout to unlock | External: `~/src/testsAndMisc/python_pkg/screen_locker/` |

View File

@ -1,30 +1,121 @@
#!/usr/bin/env bash #!/bin/bash
# Run prettier --check inside its own systemd-run scope so its memory
# budget is independent of the outer pre-push cgroup (which has already # ============================================================================
# accumulated page-cache footprint from pytest/mypy/pylint/bandit). # prettier --check, memory-capped and result-cached.
# #
# Falls back to a direct invocation when systemd-run is unavailable # Two problems this solves:
# (CI / containers without systemd user instance). #
# 1. Memory. Prettier runs inside its own systemd-run scope so its budget is
# independent of the outer pre-push cgroup, which has already accumulated
# page cache from pytest/mypy/pylint.
#
# 2. Latency. Prettier is a pre-push hook precisely because Node's startup
# dominates its runtime, and pre-commit chunks a large file list into
# several invocations -- a 104-file push paid three cold Node starts and
# ~13s wall clock. A file whose content, prettier version and prettier
# config are all unchanged since the last clean check cannot have become
# unformatted, so its verdict is cached and it never reaches Node. The
# common push touches files that are already clean, which makes the whole
# hook a handful of hashes and no Node process at all.
#
# The cache is keyed on content, so it cannot go stale: edit a file, bump
# prettier, or change .prettierrc and the key changes with it.
# ============================================================================
set -euo pipefail set -euo pipefail
if [ $# -eq 0 ]; then CACHE_ROOT="${PRETTIER_GATE_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/prettier-gate}"
exit 0 readonly CACHE_ROOT
fi # Enough entries for several branches' worth of files; pruned wholesale rather
# than per-entry, because an empty cache costs one slow run, not correctness.
readonly MAX_ENTRIES=20000
# Cap Node heap on top of the cgroup limit for belt-and-braces safety. # Everything that can change prettier's verdict without changing the file.
export NODE_OPTIONS="${NODE_OPTIONS:-} --max-old-space-size=384" config_fingerprint() {
local version config
version="$(prettier --version 2>/dev/null || echo unknown)"
config="$(
cat .prettierrc .prettierrc.* .prettierignore prettier.config.* 2>/dev/null
true
)"
printf '%s\n%s' "$version" "$config" | sha256sum | cut -d' ' -f1
}
# Measured peak RSS checking the 14 yaml/json/markdown files of a large commit # One sha256sum call for the whole list: per-file forks cost more than the
# is ~0.10 GiB, so 512M is ample. The cap must also stay small enough to be # hashing does.
# satisfiable inside a tighter outer scope: a request larger than the caller's cache_keys() {
# budget gets this OOM-killed from the inside rather than merely throttled. local fingerprint="$1"
if command -v systemd-run >/dev/null 2>&1 && shift
systemctl --user is-active --quiet default.target 2>/dev/null || sha256sum -- "$@" 2>/dev/null | while read -r digest _; do
command -v systemd-run >/dev/null 2>&1; then printf '%s-%s\n' "$fingerprint" "$digest"
exec systemd-run --user --scope --quiet --collect \ done
-p MemoryMax=512M \ }
-p MemorySwapMax=0 \
-- prettier --check "$@"
fi
exec prettier --check "$@" prune_if_huge() {
local count
count="$(find "$CACHE_ROOT" -maxdepth 1 -type f 2>/dev/null | wc -l)"
if ((count > MAX_ENTRIES)); then
rm -rf "${CACHE_ROOT:?}"
mkdir -p "$CACHE_ROOT"
fi
}
run_prettier() {
export NODE_OPTIONS="${NODE_OPTIONS:-} --max-old-space-size=384"
if command -v systemd-run >/dev/null 2>&1; then
systemd-run --user --scope --quiet --collect \
-p MemoryMax=512M \
-p MemorySwapMax=0 \
-- prettier --check "$@"
return
fi
prettier --check "$@"
}
main() {
if [ $# -eq 0 ]; then
exit 0
fi
mkdir -p "$CACHE_ROOT"
prune_if_huge
local fingerprint
fingerprint="$(config_fingerprint)"
# Pair each file with its key, then split into "already known clean" and
# "must be checked". readarray keeps this to two subshells regardless of
# how many files pre-commit passed.
local -a keys=() todo=() todo_keys=()
readarray -t keys < <(cache_keys "$fingerprint" "$@")
if ((${#keys[@]} != $#)); then
# A file vanished between pre-commit's listing and now: fall back to
# checking everything rather than mis-pairing files with digests.
run_prettier "$@"
exit $?
fi
local i=0 file
for file in "$@"; do
if [[ ! -f "$CACHE_ROOT/${keys[i]}" ]]; then
todo+=("$file")
todo_keys+=("${keys[i]}")
fi
i=$((i + 1))
done
if ((${#todo[@]} == 0)); then
printf 'prettier: %d file(s) unchanged since their last clean check\n' "$#"
exit 0
fi
run_prettier "${todo[@]}"
# Only a clean verdict is recorded; a failure must be re-checked next time.
local key
for key in "${todo_keys[@]}"; do
: >"$CACHE_ROOT/$key"
done
}
main "$@"