#!/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