From 44bfe7f2cbd68ab7e14556eb849210e402de8848 Mon Sep 17 00:00:00 2001 From: Mao Nakamoto <41178744+maonakamoto@users.noreply.github.com> Date: Wed, 2 Sep 2026 06:07:13 +0200 Subject: [PATCH] chore: stop repairing the same checkout drift by hand every run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three separate sessions have now opened this repo, found the working copy silently wrong, and spent part of the run repairing it before any real work could start: 2026-08-29 local main held a commit origin never got, so `git pull --ff-only` failed for every run entering the repo. 2026-08-31 local main was 2 commits behind origin (#232, #233). 2026-09-02 local main was 3 commits behind (#234-#236), two squash-merged worktrees were still checked out, and frontend/node_modules predated the prettier devDependency from #233 — so the first gate of `npm run verify` died on "prettier: not found". A bare `git status` reports "clean" in all three cases, which is why each run rediscovers it the hard way instead of inheriting the last one's fix. Per the never-twice rule, the third instance gets the automation, not a fourth repair. `npm run preflight` checks the three invisible ones — divergence from origin (after an explicit fetch), merged worktree branches still checked out (compared by patch id via `git cherry`, since `git branch --merged` cannot see a squash), and node_modules older than its lockfile. Read-only by default, exit 1 on drift; `npm run preflight -- --fix` repairs what it finds and refuses to touch a worktree with uncommitted changes. Both paths exercised on this branch: the read-only run caught backend/ node_modules as stale against the #229/#230 security bumps, and --fix reinstalled it to clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PtVWdxno5WtK9zYLkrBz3P --- .claude/CLAUDE.md | 13 +++++ package.json | 1 + scripts/dev/preflight.sh | 106 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+) create mode 100755 scripts/dev/preflight.sh diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 477b76cc..c5d65156 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -65,6 +65,19 @@ npm run docker:dev npm run verify ``` +**Start every session with `npm run preflight`.** It is read-only and takes a +second; `npm run preflight -- --fix` repairs what it finds. It checks the three +things a bare `git status` reports as "clean" while they are silently broken: + +| Check | Why it exists | +|-------|---------------| +| `main` behind/ahead of origin (after an explicit `git fetch`) | Merges land as squashed PRs on GitHub, so the local checkout drifts without ever looking dirty. Repaired by hand three times: 2026-08-29 (local held a commit origin never got, breaking `git pull --ff-only` for every entering run), 2026-08-31 (2 behind), 2026-09-02 (3 behind). | +| Merged worktree branches still checked out | Agent worktrees under `.claude/worktrees/` survive their PR being squash-merged. `git branch --merged` misses them — the squash is a different commit — so the check compares patch ids with `git cherry`. | +| `node_modules` older than `package-lock.json` | The failure that reads most like a repo bug and never is. On 2026-09-02 a stale `frontend/node_modules` predated the prettier devDependency from #233, so `npm run verify` died on `prettier: not found` at its very first gate. | + +If `verify` fails in a way that has nothing to do with your change, run +`preflight` before debugging the code. + **Before declaring any change done, run `npm run verify`.** It runs the same hermetic gates as CI (`.github/workflows/ci.yml`: frontend lint + typecheck + vitest unit suite + build), so green locally means green on `main`. diff --git a/package.json b/package.json index f60f7115..1073730c 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "lint": "cd frontend && npm run lint", "install-frontend": "cd frontend && npm install", "setup": "npm run install-frontend", + "preflight": "bash scripts/dev/preflight.sh", "docker:dev": "docker-compose up --build", "docker:prod": "docker-compose -f docker-compose.prod.yml up --build", "docker:down": "docker-compose down", diff --git a/scripts/dev/preflight.sh b/scripts/dev/preflight.sh new file mode 100755 index 00000000..bb31ed74 --- /dev/null +++ b/scripts/dev/preflight.sh @@ -0,0 +1,106 @@ +#!/bin/bash + +# Preflight: make the working copy match reality before any work starts. +# +# Why this exists: three separate sessions burned part of a run repairing the +# same class of entry-time drift, each time discovering it the hard way — +# 2026-08-29 local main held a commit origin never got; `git pull --ff-only` +# failed for every run entering the repo. +# 2026-08-31 local main was 2 commits behind origin (#232, #233). +# 2026-09-02 local main was 3 commits behind (#234-#236), two merged +# worktrees were still checked out, and `frontend/node_modules` +# predated the prettier devDependency added in #233 — so the very +# first gate of `npm run verify` died on "prettier: not found", +# which looks like a repo bug and is not one. +# A bare `git status` reports "clean" in all three cases. This closes the class. +# +# Usage: npm run preflight (read-only by default) +# npm run preflight -- --fix (fast-forward, prune, reinstall) + +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$PROJECT_ROOT" + +FIX=0 +[[ "${1:-}" == "--fix" ]] && FIX=1 + +RED=$'\033[0;31m'; YELLOW=$'\033[1;33m'; GREEN=$'\033[0;32m'; NC=$'\033[0m' +drift=0 + +note() { printf '%s\n' " $1"; } +warn() { printf '%s\n' "${YELLOW}!${NC} $1"; drift=1; } +ok() { printf '%s\n' "${GREEN}✓${NC} $1"; } + +# 1. Divergence from origin. `git status` alone cannot see this without a fetch. +git fetch --quiet --prune origin +BRANCH="$(git rev-parse --abbrev-ref HEAD)" +if git rev-parse --verify --quiet "origin/$BRANCH" >/dev/null; then + behind="$(git rev-list --count "HEAD..origin/$BRANCH")" + ahead="$(git rev-list --count "origin/$BRANCH..HEAD")" + if [[ "$behind" != 0 || "$ahead" != 0 ]]; then + warn "$BRANCH is ${ahead} ahead / ${behind} behind origin/$BRANCH" + if [[ $FIX == 1 && "$ahead" == 0 ]]; then + git merge --ff-only "origin/$BRANCH" >/dev/null && note "fast-forwarded to origin/$BRANCH" + elif [[ "$ahead" != 0 ]]; then + note "${RED}ahead of origin — a prior run may have left unpushed commits; inspect before pushing${NC}" + fi + else + ok "$BRANCH in sync with origin" + fi +else + note "$BRANCH has no upstream yet — divergence check skipped" +fi + +# 2. Worktrees and branches whose commits already landed upstream (squash-merged +# PRs leave these behind; `git branch --merged` misses them because the squash +# is a different commit — `git cherry` compares patch ids instead). +stale=() +while read -r _ _ ref; do + [[ "$ref" =~ ^\[(.+)\]$ ]] || continue + b="${BASH_REMATCH[1]}" + [[ "$b" == "$BRANCH" ]] && continue + if [[ -z "$(git cherry "$BRANCH" "$b" 2>/dev/null | grep '^+' || true)" ]]; then + stale+=("$b") + fi +done < <(git worktree list) + +if ((${#stale[@]})); then + warn "merged worktree branches still checked out: ${stale[*]}" + if [[ $FIX == 1 ]]; then + for b in "${stale[@]}"; do + wt="$(git worktree list --porcelain | grep -B2 "branch refs/heads/$b\$" | head -1 | cut -d' ' -f2-)" + if [[ -n "$(git -C "$wt" status --porcelain)" ]]; then + note "${RED}$b has uncommitted changes — left alone${NC}" + continue + fi + git worktree remove "$wt" && git branch -D "$b" >/dev/null && note "removed $b" + done + git worktree prune + fi +else + ok "no stale worktrees" +fi + +# 3. Installed dependencies older than the lockfile. This is what breaks +# `npm run verify` in a way that reads as a repo bug (see #233/prettier). +for pkg in frontend backend; do + lock="$pkg/package-lock.json" + stamp="$pkg/node_modules/.package-lock.json" + [[ -f "$lock" ]] || continue + if [[ ! -d "$pkg/node_modules" || "$lock" -nt "$stamp" ]]; then + warn "$pkg/node_modules is stale or missing (older than $lock)" + if [[ $FIX == 1 ]]; then + # --legacy-peer-deps mirrors CI; frontend has unresolvable peers otherwise. + (cd "$pkg" && npm ci --legacy-peer-deps) && note "reinstalled $pkg" + fi + else + ok "$pkg/node_modules matches lockfile" + fi +done + +if [[ $drift == 1 && $FIX == 0 ]]; then + printf '\n%s\n' "${YELLOW}Drift found. Re-run with:${NC} npm run preflight -- --fix" + exit 1 +fi +printf '\n%s\n' "${GREEN}Preflight clean.${NC}"