Files
ersatztv/scripts/refresh-shared-checkout.sh
timothy 92bf8b083d
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 10s
PR Gates / Docs update reminder (pull_request) Successful in 14s
PR Gates / decisions lifecycle (pull_request) Successful in 17s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m22s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m23s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m34s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 15m8s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m55s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
chore(541): fast-forward the shared checkout at session end (H13)
A session was handed docs/handoffs/chicorytv-issue-queue.md pasted out of
/Users/timothy/ersatztv while that tree was 81 commits behind, so it still
described the queue protocol #520 retired the day before (read tracker
command was ever run against that tree, so every existing "never read its
HEAD" guard was irrelevant: a stale checkout serves stale FILES, and docs
are what a kickoff depends on. Nothing broke only because selection went
through scripts/select-queue.sh.

The lore bullet on that tree already prescribed the shape of the fix for
its earlier failure modes — "a design flaw, not a discipline failure; a
check does not stay true" — so this removes the stale condition instead of
adding another check.

scripts/refresh-shared-checkout.sh fast-forwards the tree to origin/main
and reinstalls web/node_modules when the lockfile moved. It is deliberately
timid: it refuses and changes nothing when the tree is not on main, is
dirty, is ahead, or is mid-rebase/merge, and it never switches branches,
stashes or discards. A NO-OP is a normal outcome.

Uses npm ci rather than npm install — the first version used install,
which rewrote package-lock.json and left the tree dirty, i.e. the exact
state the next run refuses on, so it would have disabled itself after one
use. Asserts the tree is clean at exit.

refs #541
2026-07-21 20:02:57 +02:00

111 lines
5.6 KiB
Bash
Executable File

#!/usr/bin/env bash
# H13 (ersatztv#541) — session-end: leave the SHARED checkout at /Users/timothy/ersatztv fresh, so
# the next session's kickoff paste is current.
#
# Why this is a script and not a rule. The standing lore already says the shared tree is not a `main`
# mirror and its HEAD must never be read as truth — but on 2026-07-21 the trap arrived through a
# route that rule doesn't cover: a session pasted `docs/handoffs/chicorytv-issue-queue.md` FROM that
# tree, which was 81 commits behind, and the file still described a queue protocol retired the day
# before (read tracker #237 — since closed, and now explicitly "do not read for queue state"). No
# `git log` was consulted, so no discipline check would have fired. A stale checkout silently
# serves stale FILES, and the only durable fix is to stop it being stale.
#
# What it does: fast-forwards the shared tree to origin/main, and reinstalls web/node_modules when
# the lockfile moved (worktrees copy that directory; a stale copy has broken typecheck before).
#
# What it will NOT do — it is deliberately timid, because the tree is shared and another session may
# be mid-flight in it. It refuses (loudly, exit 0) and leaves the WORKING TREE untouched when it is
# not on `main`, is dirty, has local commits, or is mid-rebase/merge. It never switches branches,
# never stashes, never discards. (A refusal reached after the fetch will have advanced
# refs/remotes/origin/main — remote-tracking refs only, never your files.) A refusal is a normal
# outcome, not a failure.
#
# Run at session end (kickoff session-end protocol). Safe to run at session start too — it is
# idempotent. Fail-OPEN: a missing/unreachable tree prints a notice and exits 0.
#
# ETV_SHARED_CHECKOUT overrides the target directory.
set -uo pipefail
shared="${ETV_SHARED_CHECKOUT:-/Users/timothy/ersatztv}"
skip() {
echo "refresh-shared-checkout: NO-OP — $1"
echo " (nothing was changed)"
exit 0
}
[ -d "$shared/.git" ] || skip "no git checkout at $shared"
cd "$shared" || skip "cannot cd to $shared"
# $shared/.git existing is NOT proof $shared is the repo root: if it is an invalid/emptied .git dir
# and some ancestor IS a repo, git walks UP and every command below would silently operate on that
# ancestor instead — the worst possible outcome for a script that fast-forwards and reinstalls.
toplevel="$(git rev-parse --show-toplevel 2>/dev/null)" || skip "not a git repo: $shared"
[ "$toplevel" = "$(pwd -P)" ] || skip "$shared is not a repo root (git resolves it to $toplevel)"
# mid-operation? never interfere
git_dir="$(git rev-parse --git-dir 2>/dev/null)" || skip "not a git repo: $shared"
# NOTE: keep the `|| true` — without it this loop's `&&` tail would abort the script if anyone
# ever adds `set -e` (a non-match returns 1 as the body's last status).
for state in rebase-merge rebase-apply MERGE_HEAD CHERRY_PICK_HEAD BISECT_LOG REVERT_HEAD; do
{ [ -e "$git_dir/$state" ] && skip "a $state is in progress"; } || true
done
branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null)"
[ "$branch" = "main" ] || skip "on branch '$branch', not main — that is another session's work"
if [ -n "$(git status --porcelain 2>/dev/null)" ]; then
skip "working tree is dirty (uncommitted changes)"
fi
git fetch origin main --quiet || skip "could not fetch origin/main"
ahead="$(git rev-list --count origin/main..HEAD 2>/dev/null)" || skip "cannot compare against origin/main"
behind="$(git rev-list --count HEAD..origin/main 2>/dev/null)" || skip "cannot compare against origin/main"
if [ "$ahead" != "0" ]; then
skip "$ahead local commit(s) not on origin/main — someone committed here; resolve by hand"
fi
if [ "$behind" = "0" ]; then
echo "refresh-shared-checkout: already current with origin/main ($(git rev-parse --short HEAD))."
exit 0
fi
lock_before=""
[ -f web/package-lock.json ] && lock_before="$(git hash-object web/package-lock.json 2>/dev/null)"
if ! git merge --ff-only origin/main --quiet; then
skip "fast-forward to origin/main failed"
fi
echo "refresh-shared-checkout: fast-forwarded $behind commit(s) to $(git rev-parse --short HEAD)."
lock_after=""
[ -f web/package-lock.json ] && lock_after="$(git hash-object web/package-lock.json 2>/dev/null)"
if [ -n "$lock_after" ] && [ "$lock_before" != "$lock_after" ]; then
echo "refresh-shared-checkout: web/package-lock.json changed — refreshing node_modules..."
# `npm ci`, not `npm install`: install can REWRITE the lockfile, which would leave this shared
# tree dirty — and a dirty tree is exactly what the next run (and every worktree-hygiene check)
# refuses on. ci installs strictly from the lockfile and never writes it.
# Accepted trade-off: `ci` DELETES node_modules first, so a session copying it at that instant gets
# a partial copy. Bounded and rare — everything removed is regenerable from the lockfile, no human
# work can be lost, and this only runs on a merge that actually moved the lockfile.
if (cd web && npm ci --no-audit --no-fund >/dev/null 2>&1); then
echo "refresh-shared-checkout: node_modules refreshed."
else
echo "refresh-shared-checkout: WARNING — npm ci failed; worktrees copying web/node_modules"
echo " from here may fail typecheck. Run 'npm ci' in $shared/web by hand."
fi
fi
# belt and braces: leaving the shared tree dirty is the one outcome that would make this script a
# net negative, so assert it and say so loudly rather than exiting quietly on a bad state
if [ -n "$(git status --porcelain 2>/dev/null)" ]; then
echo "refresh-shared-checkout: WARNING — the shared tree is DIRTY after refresh:"
git status --short | sed 's/^/ /'
echo " Nothing was discarded. Clean it by hand before the next session pastes files from here."
fi