fix(verification): stop QA freshness loop in submodule monorepos#654
fix(verification): stop QA freshness loop in submodule monorepos#654halindrome wants to merge 7 commits into
Conversation
…ess in monorepos
In a submodule monorepo the real code lives inside the submodules, but
verification_is_stale() ran `git status` over the whole parent tree. The parent's
gitlink POINTERS (submodule HEAD drift) and untracked files (e.g. .env.*, build
markers) keep the parent tree perpetually dirty during development, so every
VERIFICATION.md was reported `working_tree_changed` -> stale -> QA re-runs forever,
even when all real code is committed. Observed live: a fully-clean monorepo still
looped because of 5 submodule pointer entries + 3 untracked files.
When `.gitmodules` is present, the dirty-tree check now:
- ignores parent gitlink pointer drift (`--ignore-submodules=all`) and untracked
files (`--untracked-files=no`) on the top-level repo, and
- treats uncommitted tracked CONTENT inside any submodule (git submodule foreach,
recursive) -- or in non-submodule top-level files -- as the dirty signal.
So freshness tracks actual uncommitted code wherever it lives, not pointer
bookkeeping. Single-repo projects (no .gitmodules) keep the original whole-tree
behavior unchanged. Git errors still fail closed to stale.
Adds tests/verification-freshness.bats with real git submodule fixtures covering:
single-repo untracked->stale (unchanged), monorepo clean->fresh,
untracked-parent->fresh, pointer-drift->fresh, in-submodule content->stale.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ecks `.claude/settings.local.json` (and `.claude/settings.json`) is Claude Code's own per-project config -- it churns during a session (permission grants, etc.) and is never the verified product, yet the freshness dirty-check counted it as `working_tree_changed`, re-triggering QA. Observed live in iot_stream_injector: a modified .claude/settings.local.json kept the verification stale with no real code change. Exclude those two settings files everywhere the freshness pathspec is used (dirty-check + verified_at_commit baseline) alongside .vbw-planning and CLAUDE.md, and mirror the SAME exclusion in write-verification.sh so the recorded verified_at_commit stays consistent with the read side. Scoped deliberately to the settings files only -- project deliverables under .claude/ (commands, hooks, agents, skills) still count toward freshness, so Claude-tooling projects (e.g. plugin/skill repos) are not silently skipped. Extends tests/verification-freshness.bats: .claude/settings.local.json churn -> fresh; a tracked .claude/commands/*.md change -> still stale. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Round 1 QA review found one major correctness gap and two minor ones in the submodule-monorepo freshness logic; all addressed: - Finding 1 (major): the submodule dirty-check used `git submodule foreach ... git status --untracked-files=no`, so a brand-new uncommitted (untracked) source file inside a submodule was reported FRESH — the symmetric false-negative to the swt-labs#652 loop (real work the verification never covered escaped QA). Switched the foreach to `--untracked-files=normal`, which still honors each submodule's .gitignore (--exclude-standard) and excludes the same .claude/settings*.json local-config noise as the parent/single-repo branches. New uncommitted source now correctly marks stale. - Finding 2 (minor): the monorepo branch was gated on `.gitmodules` merely existing, so an empty/stale .gitmodules silently flipped a single repo onto the monorepo path (untracked top-level: stale -> ignored). Gated instead on `git submodule status` returning at least one entry — actual submodules, not a leftover file. - Finding 3 (minor): documented and tested the intended monorepo asymmetry (parent untracked treated as noise) alongside the new submodule-untracked behavior. Tests: untracked-new-file-in-submodule -> stale; gitignored-untracked and .claude/settings.local.json churn in submodule -> fresh; empty .gitmodules -> single-repo behavior. 11/11 freshness bats pass; shellcheck + bash -n clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Round 2 QA review came back clean (zero major/critical) with one optional test-coverage suggestion, now addressed: add a bats case for a declared-but-uninitialized submodule (clone without --recurse-submodules). `git submodule status` lists it (so the monorepo path is taken), but `git submodule foreach` silently skips uninitialized submodules — the clean clone correctly reports fresh and does not spuriously fail closed to stale. Locks that behavior against future regressions. 12/12 pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Round 3 QA review came back clean for merge (zero major/critical). The one minor finding is the inherent, deliberate boundary of the swt-labs#652 fix: work COMMITTED inside a submodule but not yet pointer-bumped in the parent reads as fresh, because submodule pointer drift is intentionally ignored to break the loop. Treating drift as stale would reintroduce swt-labs#652, so the boundary is accepted; once the pointer-bump commit lands the verified_at_commit baseline correctly reports stale. Documented this re-verify-after-bump boundary in the dirty-check comment so the trade-off is explicit. Behavior unchanged; 12/12 bats pass, shellcheck + bash -n clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
QA Review — Round 1 (against the base fix; addressed in Addressed in |
|
QA Review — Round 2 (fresh review against Addressed in |
|
QA Review — Round 3 (fresh review against Addressed in |
What
Stop the QA verification lifecycle from looping forever in git-submodule monorepos.
verification_is_stale()(scripts/verification-freshness.sh) no longer treats submodule gitlink pointer drift, untracked parent files, or Claude Code's own local config as "the verified product changed."Closes #652.
Why
In a submodule monorepo the real code lives inside the submodules, so the parent tree is perpetually dirty from two sources that are not the verified work:
M <submodule>..env.*, build markers, and notably.claude/settings.local.json(Claude Code's own per-project config, rewritten on permission grants).The old whole-parent-tree
git status --porcelain --untracked-files=normaltherefore never returned empty, so everyVERIFICATION.mdwas judgedworking_tree_changed→ stale → QA re-runs → repeat, even on a fully committed repo. Observed live in a 6-submodule monorepo: with zero uncommitted code, all 7VERIFICATION.mdfiles still reportedSTALE [working_tree_changed]from 5 pointer entries + 3 untracked files.How
.gitmodulesis present, the dirty check tracks real content, not parent bookkeeping: ignore parent gitlink pointers (--ignore-submodules=all) and untracked files on the top-level repo, and treat uncommitted tracked content inside submodules (git submodule foreach --quiet --recursive 'git status --porcelain --untracked-files=no') — or in non-submodule top-level files — as the dirty signal..claude/settings.local.json,.claude/settings.json) everywhere the freshness pathspec is used and inwrite-verification.sh'sverified_at_commit(so write/read stay consistent). Scoped to the settings files only — project deliverables under.claude/(commands, hooks, agents, skills) still count toward freshness, so Claude-tooling repos aren't silently skipped.Design note (deliberate): the monorepo branch is gated on auto-detection of
.gitmodules, not on the existingmonorepo_routingconfig key. Freshness correctness must not depend on a routing toggle a user could disable while still having submodules — a submodule monorepo withmonorepo_routing=falsewould otherwise still loop.Testing
tests/verification-freshness.bats— 7 behavioral cases built on real git submodule fixtures: single-repo untracked → stale (original behavior preserved); monorepo clean → fresh; untracked parent → fresh; pointer drift → fresh; in-submodule content → stale;.claude/settings.local.jsonchurn → fresh; tracked.claude/commands/*.mdchange → stale.bash -n+shellcheck -S warningclean on both changed scripts.🤖 Generated with Claude Code