diff --git a/plugins/repo-fleet-hygiene/.claude-plugin/plugin.json b/plugins/repo-fleet-hygiene/.claude-plugin/plugin.json index 88b8c72b2..deda28602 100644 --- a/plugins/repo-fleet-hygiene/.claude-plugin/plugin.json +++ b/plugins/repo-fleet-hygiene/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "repo-fleet-hygiene", - "version": "0.13.2", + "version": "0.13.4", "description": "Read-only Git/GitHub fleet audit for merged local branches, orphaned or mismatched worktree registrations, and repository transfers or renames. Findings are confidence-tiered and hand off exact targets to existing per-repository cleanup tools; this plugin never deletes branches or worktrees.", "author": { "name": "Melodic Software", diff --git a/plugins/repo-fleet-hygiene/CHANGELOG.md b/plugins/repo-fleet-hygiene/CHANGELOG.md index 0587e4fd4..2dd3753df 100644 --- a/plugins/repo-fleet-hygiene/CHANGELOG.md +++ b/plugins/repo-fleet-hygiene/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to `repo-fleet-hygiene` are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.13.4] + +### Fixed + +- **`reclaimable-worktree` no longer treats ignored files as invisible (#2601).** Classification now + uses `git status --porcelain --ignored --untracked-files=normal`. Every reclaimable finding names + ignored entries or states there are none; regenerable-only ignored paths (`node_modules/`, + `target/`, `.venv/`, `bin/`, `obj/`, `dist/`) keep the reclaimable disposition. Non-regenerable + ignored content emits `worktree-ignored-content` instead of a safe-to-delete reclaimable handoff. + Stash wording now states that `refs/stash` is repository-global and unaffected by worktree + removal; stash state is collected once per repository. Local `status.showUntrackedFiles=no` + cannot hide untracked or ignored evidence from this probe. + ## [0.13.2] ### Fixed diff --git a/plugins/repo-fleet-hygiene/README.md b/plugins/repo-fleet-hygiene/README.md index cf97b87e5..7af6fbea7 100644 --- a/plugins/repo-fleet-hygiene/README.md +++ b/plugins/repo-fleet-hygiene/README.md @@ -101,9 +101,12 @@ successfully audited. "Can I delete this repository safely?" is deletion triage — an inventory of dirty files, stashes, and unpushed branches — and belongs to `/repo-hygiene:clean` (its scan/stash/git tiers), which owns per-repository disposability analysis. This audit is a read-only cross-repository evidence REPORT; -it names candidates and hands off. It can report `reclaimable-worktree` when a linked worktree's -`git status --porcelain` is empty, but that is working-tree evidence only — not proof a checkout is -still wanted, and not repository deletion triage. +it names candidates and hands off. It can report `reclaimable-worktree` when a linked worktree has +no tracked/untracked changes under `git status --porcelain --ignored` and either no ignored entries +or only regenerable ones, but that is working-tree evidence only — not proof a checkout is still +wanted, and not repository deletion triage. Non-regenerable ignored content is reported as +`worktree-ignored-content` rather than offered as safe-to-delete. Stashes are repository-global and +unaffected by worktree removal. ## Requirements diff --git a/plugins/repo-fleet-hygiene/skills/audit/SKILL.md b/plugins/repo-fleet-hygiene/skills/audit/SKILL.md index c1b749103..20c57dabd 100644 --- a/plugins/repo-fleet-hygiene/skills/audit/SKILL.md +++ b/plugins/repo-fleet-hygiene/skills/audit/SKILL.md @@ -114,10 +114,15 @@ The bundled collector is authoritative for classifications. Preserve its evidenc never worktree evidence. Compare each existing registered path's actual `--git-common-dir` with the canonical checkout's expected common dir. A mismatch is `HIGH` evidence of an administrative linkage problem but **manual review only**. Missing/prunable registrations never trigger pruning. - Linked registrations with reliable admin and an empty `git status --porcelain` at the work-tree - root emit `MEDIUM` `reclaimable-worktree` — working-tree cleanliness only, not proof the - checkout is still wanted; stash list is out of scope. A failed status probe emits `UNKNOWN` - `worktree-disposability-unverifiable`. If either inventory command fails or emits malformed/partial output, discard it, emit `UNKNOWN`, + Linked registrations with reliable admin and no tracked/untracked changes under + `git status --porcelain --ignored` emit `MEDIUM` `reclaimable-worktree` when ignored entries are + absent or regenerable only (`node_modules/`, `target/`, `.venv/`, `bin/`, `obj/`, `dist/`) — + working-tree cleanliness only, not proof the checkout is still wanted; every such finding names + its ignored entries or states there are none. Non-regenerable ignored content emits `MEDIUM` + `worktree-ignored-content` instead, naming what directory removal would destroy. Stashes are + repository-global (`refs/stash` in the common gitdir), so they are unaffected by worktree + removal; the collector records stash state once per repository and never per worktree. A failed + status probe emits `UNKNOWN` `worktree-disposability-unverifiable`. If either inventory command fails or emits malformed/partial output, discard it, emit `UNKNOWN`, stop local branch/worktree classification, and do not count that repository as successfully audited; an empty/failed inventory never means no branches are attached. 5. **Protection:** current/default/worktree-attached branches are never emitted as standalone branch @@ -179,10 +184,11 @@ do not turn "no verified finding" into "fleet is clean". |---|---| | `merged-local-branch` | Run `/repo-hygiene:clean git` in the named canonical repository | | `merged-worktree`, `prunable-worktree`, `missing-worktree`, `reclaimable-worktree` | Run `/source-control:worktree cleanup --dry-run` in the canonical repository | +| `worktree-ignored-content` | Inspect the named ignored paths before any worktree cleanup; cleanliness alone is not safe-to-delete | | `worktree-admin-mismatch` | Manual inspection; `git worktree repair` is an option only after validating which administrative directory is authoritative | | `worktree-not-a-root` | Manual inspection of the registered path; a `git -C` probe of it describes the CONTAINING repository, so no cleanup handoff is safe until the path is resolved | | `worktree-root-unverifiable` | Manual inspection of the registered path. Root-ness is unproven here rather than disproven — the probe itself failed — so infer nothing about the path in either direction | -| `worktree-disposability-unverifiable` | Manual inspection of the registered path; reclaimability was not checked because `git status --porcelain` failed | +| `worktree-disposability-unverifiable` | Manual inspection of the registered path; reclaimability was not checked because `git status --porcelain --ignored` failed | | `worktree-nested-in-repository` | Recreate at an external root with `/source-control:worktree create`, then remove the nested one | | `worktree-placement-unverifiable` | Inspect the canonical checkout; placement was not checked for any of its worktrees, so their placement is unknown rather than confirmed | | `github-remote-moved` | Human-reviewed `git remote set-url`; this plugin never changes remotes | diff --git a/plugins/repo-fleet-hygiene/skills/audit/evals/evals.json b/plugins/repo-fleet-hygiene/skills/audit/evals/evals.json index b49b55a46..7910dfa9a 100644 --- a/plugins/repo-fleet-hygiene/skills/audit/evals/evals.json +++ b/plugins/repo-fleet-hygiene/skills/audit/evals/evals.json @@ -153,15 +153,27 @@ { "id": 13, "name": "reclaimable-worktree-is-working-tree-evidence-not-wantedness", - "prompt": "The audit reports a MEDIUM reclaimable-worktree finding for a linked worktree whose git status --porcelain is empty. Does that mean nobody is using it and I can delete it?", - "expected_output": "No. reclaimable-worktree means only that the working tree has no uncommitted changes at audit time; it is not evidence that the checkout is still wanted. The handoff is a dry-run through /source-control:worktree cleanup, and the human decides.", + "prompt": "The audit reports a MEDIUM reclaimable-worktree finding for a linked worktree whose git status --porcelain --ignored shows no tracked/untracked changes and no non-regenerable ignored content. Does that mean nobody is using it and I can delete it?", + "expected_output": "No. reclaimable-worktree means only that the working tree has no uncommitted tracked/untracked changes and no non-regenerable ignored content at audit time; it is not evidence that the checkout is still wanted. Stashes are repository-global and unaffected by worktree removal. The handoff is a dry-run through /source-control:worktree cleanup, and the human decides.", "files": [], "expectations": [ - "Declines to equate an empty porcelain status with nobody still needing the checkout", + "Declines to equate porcelain cleanliness with nobody still needing the checkout", "Names reclaimable-worktree as working-tree evidence only", "Does not infer disuse from directory naming, age, or activity", "Routes the decision to the worktree cleanup dry-run and the human" ] + }, + { + "id": 14, + "name": "non-regenerable-ignored-content-is-not-silent-reclaimable", + "prompt": "A linked worktree has an empty ordinary git status --porcelain, but git status --porcelain --ignored shows !! .work/handoffs/. The audit emitted worktree-ignored-content instead of reclaimable-worktree. Why, and can I delete the worktree directory?", + "expected_output": "Ordinary porcelain cannot see ignored files, and removing the worktree directory would destroy them. worktree-ignored-content names the non-regenerable ignored paths and is manual review, not a safe-to-delete reclaimable handoff. Inspect those paths before any cleanup.", + "files": [], + "expectations": [ + "Explains that plain porcelain misses ignored content that directory removal destroys", + "Treats worktree-ignored-content as a warning, not a reclaimable/safe-to-delete signal", + "Requires inspecting the named ignored paths before cleanup" + ] } ] } diff --git a/plugins/repo-fleet-hygiene/skills/audit/reference/confidence-model.md b/plugins/repo-fleet-hygiene/skills/audit/reference/confidence-model.md index 6a877f87e..fc0e065e9 100644 --- a/plugins/repo-fleet-hygiene/skills/audit/reference/confidence-model.md +++ b/plugins/repo-fleet-hygiene/skills/audit/reference/confidence-model.md @@ -34,8 +34,9 @@ failure rather than a discovery. | `worktree-not-a-root` | Registered path exists but `git rev-parse --show-prefix` is non-empty, so it is a subdirectory of a work tree rather than its root — `git -C` answers for the CONTAINING repository at exit 0, which is indistinguishable from a healthy clean worktree | `HIGH` | Manual review; never read a `git -C` probe of the path as this worktree's own state | | `worktree-root-unverifiable` | `git rev-parse --show-prefix` failed at the registered path, so root-ness is unproven | `UNKNOWN` | Stop worktree classification for that registration; do not infer either way | | `worktree-nested-in-repository` | A non-main registration's root is inside the canonical checkout's own working tree, rather than at an external root outside every repository | `MEDIUM` | Manual placement decision; never auto-move or auto-remove | -| `reclaimable-worktree` | A linked, unlocked registration with reliable admin has an empty `git status --porcelain` at its work-tree root | `MEDIUM` | Candidate worktree dry-run handoff; emptiness is working-tree evidence only, not proof nobody still needs the checkout | -| `worktree-disposability-unverifiable` | `git status --porcelain` failed at a linked registration whose admin and root-ness were otherwise reliable | `UNKNOWN` | Do not infer whether the worktree is reclaimable | +| `reclaimable-worktree` | A linked, unlocked registration with reliable admin has no tracked/untracked `git status --porcelain --ignored` changes at its work-tree root, and either no ignored entries or only regenerable ones (`node_modules/`, `target/`, `.venv/`, `bin/`, `obj/`, `dist/`). Evidence names the ignored entries or states there are none | `MEDIUM` | Candidate worktree dry-run handoff; cleanliness is working-tree evidence only, not proof nobody still needs the checkout. Stashes are repository-global (`refs/stash` in the common gitdir) and are unaffected by worktree removal | +| `worktree-ignored-content` | A linked, unlocked registration with reliable admin has no tracked/untracked porcelain changes, but non-regenerable ignored content is present and would be destroyed by removing the worktree directory | `MEDIUM` | Manual review; do not treat cleanliness alone as safe-to-delete — the finding names the destroyable ignored paths | +| `worktree-disposability-unverifiable` | `git status --porcelain --ignored` failed at a linked registration whose admin and root-ness were otherwise reliable | `UNKNOWN` | Do not infer whether the worktree is reclaimable | | `worktree-placement-unverifiable` | A non-bare canonical checkout gave no working-tree root, so no registration under it could be placement-checked. A BARE hub is not this finding — it has no working tree for a worktree to be nested inside, so the check is legitimately skipped rather than unanswered | `UNKNOWN` | Do not infer that this repository's worktrees are correctly placed | | `github-remote-moved` | GitHub REST resolves the requested `owner/repo` to a different canonical `full_name`. Branch and worktree analysis continues against the resolved identity; this finding does not stop local classification | `HIGH` | Human-reviewed remote update; local classification is not deferred | | `duplicate-checkout` | Two or more distinct checkouts resolve to one normalized GitHub identity | `LOW` | Informational only; same-identity clones legitimately diverge | diff --git a/plugins/repo-fleet-hygiene/skills/audit/reference/security-review.md b/plugins/repo-fleet-hygiene/skills/audit/reference/security-review.md index a4abd0e8c..98a50fce6 100644 --- a/plugins/repo-fleet-hygiene/skills/audit/reference/security-review.md +++ b/plugins/repo-fleet-hygiene/skills/audit/reference/security-review.md @@ -67,8 +67,12 @@ is explicit authenticated GitHub metadata lookup initiated by the user-invoked a - Repository/config/worktree-derived report values containing newlines or control/ANSI bytes are rendered as a single `%q`-encoded field, so they cannot forge report labels or terminal controls. - A worktree-looking directory cannot become a finding without Git porcelain membership. -- `git status --porcelain` at a registered work-tree root is read-only local metadata; it never - transmits content and cannot mutate. A failed status probe cannot be mistaken for a clean tree. +- `git status --porcelain --ignored` at a registered work-tree root is read-only local metadata; it + never transmits content and cannot mutate. A failed status probe cannot be mistaken for a clean + tree. Ignored entries are classified so regenerable build artifacts do not block reclaimability + while non-regenerable ignored content cannot be destroyed without being named. +- `git stash list` is read once per repository (never per worktree): `refs/stash` is shared in the + common gitdir, so per-worktree collection would imply an isolation that does not exist. - A high-confidence finding still cannot mutate because the script has no apply mode. ## Deferred verification diff --git a/plugins/repo-fleet-hygiene/skills/audit/scripts/audit-fleet.sh b/plugins/repo-fleet-hygiene/skills/audit/scripts/audit-fleet.sh index 9fbbde25a..e5821be14 100755 --- a/plugins/repo-fleet-hygiene/skills/audit/scripts/audit-fleet.sh +++ b/plugins/repo-fleet-hygiene/skills/audit/scripts/audit-fleet.sh @@ -150,7 +150,17 @@ git_probe_allowed() { [[ $# -eq 4 && "$4" == "--show-current" ]] ;; status) - [[ $# -eq 4 && "$4" == "--porcelain" ]] + # --ignored surfaces content plain --porcelain cannot see; reclaimability must not treat a + # tree that only holds ignored files as empty of destroyable working-tree content (#2601). + # --untracked-files=normal forces untracked entries into the probe even when the repository + # sets status.showUntrackedFiles=no, which would otherwise hide both untracked and ignored + # paths and falsely emit reclaimable-worktree. + [[ $# -eq 6 && "$4" == "--porcelain" && "$5" == "--ignored" && "$6" == "--untracked-files=normal" ]] + ;; + stash) + # refs/stash is repository-global (shared across worktrees). Allow only a bare list, and + # callers must invoke it once per repository — never per worktree (#2601). + [[ $# -eq 4 && "$4" == "list" ]] ;; log) [[ $# -eq 6 && "$4" == "-1" && "$5" == "--format=%ct" && "$6" == "HEAD" ]] @@ -868,6 +878,40 @@ IDENT_PATHS=() # emits an explicit "Findings: none" marker so clean output is distinguishable from truncation. REPO_FINDING_COUNT=0 +# Well-known regenerable ignored path prefixes (#2601). Anything else under !! is treated as +# non-regenerable content that directory removal would destroy without ordinary porcelain warning. +is_regenerable_ignored_path() { + local path="$1" + path="${path%/}" + case "$path" in + node_modules | node_modules/* | target | target/* | .venv | .venv/* | bin | bin/* | obj | obj/* | \ + dist | dist/*) + return 0 + ;; + *) + return 1 + ;; + esac +} + +# Join path names for evidence lines. Caps length so a large ignored tree cannot flood the report. +join_ignored_evidence() { + local -a items=("$@") + local i out="" limit=8 + ((${#items[@]} == 0)) && { + printf '' + return 0 + } + out="${items[0]}" + for ((i = 1; i < ${#items[@]} && i < limit; i++)); do + out+=", ${items[i]}" + done + if ((${#items[@]} > limit)); then + out+=", …(+$((${#items[@]} - limit)) more)" + fi + printf '%s' "$out" +} + emit_finding() { local confidence="$1" kind="$2" target="$3" evidence="$4" disposition="$5" handoff="$6" REPO_FINDING_COUNT=$((REPO_FINDING_COUNT + 1)) @@ -1048,6 +1092,23 @@ analyze_repo() { "Inspect the canonical checkout, then rerun" fi + # refs/stash lives in the common gitdir and is shared across every worktree (git-worktree(1) + # REFS). Removing a worktree directory cannot orphan a stash, so stash state is not a + # reclaimability loss vector — but collecting it once per repository (never per worktree) + # keeps the report from implying an isolation that does not exist (#2601). + local stash_output stash_count stash_field + if stash_output="$(run_git_probe -C "$canonical" stash list 2>/dev/null)"; then + if [[ -z "$stash_output" ]]; then + stash_field="none (repository-global refs/stash; unaffected by worktree removal)" + else + stash_count="$(printf '%s\n' "$stash_output" | grep -c . || true)" + stash_field="${stash_count} (repository-global refs/stash; unaffected by worktree removal)" + fi + else + stash_field="unverifiable (repository-global refs/stash; unaffected by worktree removal)" + fi + print_field Stashes "$stash_field" + # Parse the stable NUL-delimited porcelain format. Only these registrations are worktree evidence. local wt_path="" wt_branch="" wt_prunable="false" wt_locked="false" field worktree_status=1 local wt_prefix @@ -1088,6 +1149,9 @@ analyze_repo() { WT_LOCKED+=("$wt_locked") fi + local status_line ignored_path status_dirty ignored_regen_text ignored_keep_text + local -a ignored_regen ignored_keep + for ((wt_index = 0; wt_index < ${#WT_PATHS[@]}; wt_index++)); do wt_path="${WT_PATHS[$wt_index]}" wt_branch="${WT_BRANCHES[$wt_index]}" @@ -1152,19 +1216,55 @@ analyze_repo() { "Manual administrative-directory decision; never auto-repair/remove" \ "Inspect both repositories; consider git worktree repair only after choosing the authority" elif [[ "$is_main" == "false" && "${WT_LOCKED[$wt_index]}" != "true" ]]; then - # Reclaimability is working-tree evidence only: an empty porcelain status means no uncommitted - # changes at audit time, not that nobody still needs the checkout. Stash list is deliberately - # out of scope — it would widen the read surface and still would not prove wantedness. - if wt_status_output="$(run_git_probe -C "$wt_path" status --porcelain 2>/dev/null)"; then - if [[ -z "$wt_status_output" ]]; then - emit_finding MEDIUM reclaimable-worktree "$wt_path${wt_branch:+ ($wt_branch)}" \ - "git status --porcelain is empty at the registered worktree root" \ - "Candidate worktree dry-run handoff; emptiness is working-tree evidence only, not proof nobody still needs the checkout" \ - "Run /source-control:worktree cleanup --dry-run in $canonical" + # Reclaimability is working-tree evidence only: no tracked/untracked porcelain changes means + # no ordinary uncommitted edits at audit time, not that nobody still needs the checkout. + # Plain --porcelain cannot see ignored files, and removing the worktree directory destroys + # them — so the probe is --ignored, regenerable ignored paths stay reclaimable, and + # non-regenerable ignored content is named rather than offered as safe-to-delete (#2601). + # Stashes are repository-global (collected once above); they are unaffected by worktree + # removal and are not consulted here. + status_dirty="false" + ignored_regen=() + ignored_keep=() + if wt_status_output="$(run_git_probe -C "$wt_path" status --porcelain --ignored --untracked-files=normal 2>/dev/null)"; then + while IFS= read -r status_line || [[ -n "$status_line" ]]; do + [[ -n "$status_line" ]] || continue + if [[ "$status_line" == '!! '* ]]; then + ignored_path="${status_line#\!\! }" + if is_regenerable_ignored_path "$ignored_path"; then + ignored_regen+=("$ignored_path") + else + ignored_keep+=("$ignored_path") + fi + else + status_dirty="true" + fi + done <<<"$wt_status_output" + if [[ "$status_dirty" == "false" ]]; then + if ((${#ignored_keep[@]} > 0)); then + ignored_keep_text="$(join_ignored_evidence "${ignored_keep[@]}")" + emit_finding MEDIUM worktree-ignored-content "$wt_path${wt_branch:+ ($wt_branch)}" \ + "git status --porcelain --ignored --untracked-files=normal reports no tracked/untracked changes, but non-regenerable ignored content would be destroyed by removing the worktree directory: $ignored_keep_text" \ + "Manual review; do not treat this worktree as safe to delete from cleanliness alone" \ + "Inspect the named ignored paths before any worktree cleanup in $canonical" + else + if ((${#ignored_regen[@]} > 0)); then + ignored_regen_text="$(join_ignored_evidence "${ignored_regen[@]}")" + emit_finding MEDIUM reclaimable-worktree "$wt_path${wt_branch:+ ($wt_branch)}" \ + "git status --porcelain --ignored --untracked-files=normal reports no tracked/untracked changes; ignored entries are regenerable only: $ignored_regen_text" \ + "Candidate worktree dry-run handoff; emptiness is working-tree evidence only, not proof nobody still needs the checkout" \ + "Run /source-control:worktree cleanup --dry-run in $canonical" + else + emit_finding MEDIUM reclaimable-worktree "$wt_path${wt_branch:+ ($wt_branch)}" \ + "git status --porcelain --ignored --untracked-files=normal reports no tracked/untracked changes and no ignored entries at the registered worktree root" \ + "Candidate worktree dry-run handoff; emptiness is working-tree evidence only, not proof nobody still needs the checkout" \ + "Run /source-control:worktree cleanup --dry-run in $canonical" + fi + fi fi else emit_finding UNKNOWN worktree-disposability-unverifiable "$wt_path${wt_branch:+ ($wt_branch)}" \ - "git status --porcelain failed at the registered worktree root" \ + "git status --porcelain --ignored --untracked-files=normal failed at the registered worktree root" \ "Do not infer whether this worktree is reclaimable" \ "Inspect the registered path and Git metadata, then rerun" fi diff --git a/plugins/repo-fleet-hygiene/skills/audit/scripts/audit-fleet.test.sh b/plugins/repo-fleet-hygiene/skills/audit/scripts/audit-fleet.test.sh index 6c156f5d1..2a06f5c64 100755 --- a/plugins/repo-fleet-hygiene/skills/audit/scripts/audit-fleet.test.sh +++ b/plugins/repo-fleet-hygiene/skills/audit/scripts/audit-fleet.test.sh @@ -13,7 +13,7 @@ mkdir -p "$MOCK_BIN" "$TMP/config" "$TMP/discovered-a" "$TMP/canonical-a" "$TMP/ "$TMP/root/acme/root-repo/.git" \ "$TMP/emptyroot" \ "$TMP/wt-a" "$TMP/wt-mismatch" \ - "$TMP/wt-status-fail" \ + "$TMP/wt-status-fail" "$TMP/wt-regen" "$TMP/wt-ignored" \ "$TMP/wt-old" \ "$TMP/discovered-c" "$TMP/canonical-c" "$TMP/gone-repo" "$TMP/lost-repo" "$TMP/net-repo" \ "$TMP/wt-root/aaa-linked" "$TMP/wt-root/bbb-linked" "$TMP/wt-root/zzz-canonical/.git" \ @@ -90,6 +90,8 @@ rev-parse) wt-a) printf '%s\n' "$TEST_ROOT/wt-a" ;; wt-mismatch) printf '%s\n' "$TEST_ROOT/wt-mismatch" ;; wt-status-fail) printf '%s\n' "$TEST_ROOT/wt-status-fail" ;; + wt-regen) printf '%s\n' "$TEST_ROOT/wt-regen" ;; + wt-ignored) printf '%s\n' "$TEST_ROOT/wt-ignored" ;; *) exit 1 ;; esac ;; @@ -133,7 +135,7 @@ rev-parse) net-repo) printf '%s\n' "$TEST_ROOT/net-repo/.git" ;; nested) printf '%s\n' "$TEST_ROOT/canonical-a/.git" ;; prefix-fail) printf '%s\n' "$TEST_ROOT/canonical-a/.git" ;; - wt-a | wt-mismatch | wt-status-fail) printf '%s\n' "$TEST_ROOT/canonical-a/.git" ;; + wt-a | wt-mismatch | wt-status-fail | wt-regen | wt-ignored) printf '%s\n' "$TEST_ROOT/canonical-a/.git" ;; aaa-linked | bbb-linked | zzz-canonical) printf '%s\n' "$TEST_ROOT/wt-root/zzz-canonical/.git" ;; sub-wt) printf '%s\n' "$TEST_ROOT/wt-admin/sub-admin" ;; sep-wt) printf '%s\n' "$TEST_ROOT/wt-admin/sep-gitdir" ;; @@ -186,9 +188,12 @@ worktree) printf 'worktree %s\0HEAD husk\0branch refs/heads/feature/husk\0\0' "$TEST_ROOT/canonical-a/husk" # A registered path whose root-ness probe FAILS outright. printf 'worktree %s\0HEAD pfail\0branch refs/heads/feature/prefix-fail\0\0' "$TEST_ROOT/prefix-fail" - # Linked worktrees for reclaimable-worktree: wt-a is clean, wt-mismatch is dirty, wt-status-fail - # cannot answer status --porcelain. + # Linked worktrees for reclaimable-worktree: wt-a is clean with no ignored entries, wt-regen + # is clean with regenerable ignored only, wt-ignored has non-regenerable ignored content, + # wt-mismatch is dirty, wt-status-fail cannot answer status --porcelain --ignored. printf 'worktree %s\0HEAD status-fail\0branch refs/heads/feature/status-fail\0\0' "$TEST_ROOT/wt-status-fail" + printf 'worktree %s\0HEAD regen\0branch refs/heads/feature/regen\0\0' "$TEST_ROOT/wt-regen" + printf 'worktree %s\0HEAD ignored\0branch refs/heads/feature/ignored\0\0' "$TEST_ROOT/wt-ignored" ;; repo-b) printf 'worktree %s\0HEAD main-b\0branch refs/heads/main\0\0' "$TEST_ROOT/repo-b" @@ -308,15 +313,30 @@ for-each-ref) ;; merge-base) exit 1 ;; status) - [[ "${1:-}" == "--porcelain" ]] || exit 96 + [[ "${1:-}" == "--porcelain" && "${2:-}" == "--ignored" && "${3:-}" == "--untracked-files=normal" ]] || exit 96 case "$base" in wt-a) printf '' ;; + wt-regen) printf '!! node_modules/\n' ;; + wt-ignored) printf '!! .work/handoffs/\n!! node_modules/\n' ;; wt-mismatch) printf ' M file.txt\n' ;; wt-status-fail) exit 7 ;; wt-old) printf '' ;; *) printf '' ;; esac ;; +stash) + [[ "${1:-}" == "list" ]] || exit 96 + # Once-per-repository collection: answer only for canonical checkouts. A per-worktree call + # would be a collector bug; leave linked worktree bases failing closed so the suite notices. + case "$base" in + canonical-a) printf '' ;; + repo-b | old-repo | root-repo | wt-fail | ref-fail | rref-fail | dup-a | new-clone | canonical-c | \ + gone-repo | lost-repo | net-repo | zzz-canonical | sub-wt | sep-wt) + printf '' + ;; + *) exit 7 ;; + esac + ;; log) [[ "${1:-}" == "-1" && "${2:-}" == "--format=%ct" && "${3:-}" == "HEAD" ]] || exit 96 case "$base" in @@ -481,8 +501,14 @@ assert_contains "merged worktree evidence" "Finding: merged-worktree" assert_contains "tip drift manual review" "Finding: merged-pr-tip-drift" assert_contains "worktree common-dir mismatch" "Finding: worktree-admin-mismatch" assert_contains "clean linked worktree is reclaimable" "Finding: reclaimable-worktree" +assert_contains "reclaimable evidence names absence of ignored entries" \ + "no tracked/untracked changes and no ignored entries" assert_contains "failed status probe is disposability-unverifiable" \ "Finding: worktree-disposability-unverifiable" +assert_contains "non-regenerable ignored content is reported" "Finding: worktree-ignored-content" +assert_contains "ignored-content evidence names the destroyable path" ".work/handoffs/" +assert_contains "stash state is collected once per repository" \ + "Stashes: none (repository-global refs/stash; unaffected by worktree removal)" assert_contains "worktree nested in its own repository is reported" "Finding: worktree-nested-in-repository" assert_contains "nested finding names the containing checkout" \ "registered worktree root is inside the canonical checkout's own working tree ($TMP/canonical-a)" @@ -516,6 +542,19 @@ assert_kind_targets "reclaimable names wt-a and not dirty or status-fail sibling reclaimable-worktree "wt-a" "wt-mismatch" assert_kind_targets "reclaimable does not include status-fail sibling" \ reclaimable-worktree "wt-a" "wt-status-fail" +assert_kind_targets "reclaimable includes regenerable-ignored worktree" \ + reclaimable-worktree "wt-regen" "wt-ignored" +assert_kind_targets "ignored-content names non-regenerable worktree and not regenerable sibling" \ + worktree-ignored-content "wt-ignored" "wt-regen" +assert_kind_targets "ignored-content does not include clean wt-a" \ + worktree-ignored-content "wt-ignored" "wt-a" +# Regenerable-only evidence must appear on a reclaimable finding (not only in ignored-content). +if grep -A5 -F "Finding: reclaimable-worktree" "$output" | grep -Fq "ignored entries are regenerable only: node_modules/"; then + printf 'PASS: regenerable ignored entries are named on reclaimable-worktree\n' +else + printf 'FAIL: regenerable ignored entries are named on reclaimable-worktree\n' >&2 + failures=$((failures + 1)) +fi assert_contains "moved repository detected" "Target: origin (old/repo -> new/repo)" assert_contains "moved-remote finding states analysis continues" \ "branch and worktree analysis continues against that resolved identity" @@ -1020,7 +1059,9 @@ run_bounded_gh pr merge --repo github.com/acme/repo-b >/dev/null 2>&1 && forbidd run_bounded_gh alias set pr '!touch /tmp/pwned' >/dev/null 2>&1 && forbidden_rejected=false calls_after="$(wc -l <"$CALL_LOG")" allowed_status=true -run_git_probe -C "$TMP/wt-a" status --porcelain >/dev/null 2>&1 || allowed_status=false +run_git_probe -C "$TMP/wt-a" status --porcelain --ignored --untracked-files=normal >/dev/null 2>&1 || allowed_status=false +allowed_stash=true +run_git_probe -C "$TMP/canonical-a" stash list >/dev/null 2>&1 || allowed_stash=false allowed_log=true run_git_probe -C "$TMP/wt-a" log -1 --format=%ct HEAD >/dev/null 2>&1 || allowed_log=false if [[ "$forbidden_rejected" != "true" || "$calls_before" != "$calls_after" ]]; then @@ -1029,11 +1070,41 @@ if [[ "$forbidden_rejected" != "true" || "$calls_before" != "$calls_after" ]]; t else printf 'PASS: exact command allowlist rejected Git/gh mutation and config-injection vectors\n' fi -if [[ "$allowed_status" != "true" || "$allowed_log" != "true" ]]; then - printf 'FAIL: status/log probes were not admitted by the Git allowlist\n' >&2 +if [[ "$allowed_status" != "true" || "$allowed_log" != "true" || "$allowed_stash" != "true" ]]; then + printf 'FAIL: status/log/stash probes were not admitted by the Git allowlist\n' >&2 + failures=$((failures + 1)) +else + printf 'PASS: status --ignored --untracked-files=normal, stash list, and log probes are admitted by the Git allowlist\n' +fi +# Plain status --porcelain (without --ignored) must no longer be admitted — reclaimability depends +# on the ignored-aware probe (#2601). +plain_status_rejected=true +run_git_probe -C "$TMP/wt-a" status --porcelain >/dev/null 2>&1 && plain_status_rejected=false +if [[ "$plain_status_rejected" != "true" ]]; then + printf 'FAIL: plain status --porcelain was admitted after the --ignored require\n' >&2 + failures=$((failures + 1)) +else + printf 'PASS: plain status --porcelain is rejected; --ignored is required\n' +fi +ignored_only_rejected=true +run_git_probe -C "$TMP/wt-a" status --porcelain --ignored >/dev/null 2>&1 && ignored_only_rejected=false +if [[ "$ignored_only_rejected" != "true" ]]; then + printf 'FAIL: status --ignored without --untracked-files=normal was admitted\n' >&2 + failures=$((failures + 1)) +else + printf 'PASS: status --ignored requires --untracked-files=normal\n' +fi +# Stash must not be callable from a linked worktree path in this suite's mock (collector uses the +# canonical once). The allowlist itself still admits stash list under -C; the once-per-repo +# contract is the collector's, asserted via a single Stashes field above. +stash_per_wt_calls_before="$(grep -c ' stash list$' "$CALL_LOG" || true)" +run_git_probe -C "$TMP/canonical-a" stash list >/dev/null 2>&1 || true +stash_per_wt_calls_after="$(grep -c ' stash list$' "$CALL_LOG" || true)" +if [[ "$stash_per_wt_calls_after" -le "$stash_per_wt_calls_before" ]]; then + printf 'FAIL: allowlisted stash list did not reach the mock from the canonical path\n' >&2 failures=$((failures + 1)) else - printf 'PASS: status and log probes are admitted by the Git allowlist\n' + printf 'PASS: stash list reaches the mock from the canonical path\n' fi # Force the portable watchdog and prove a TERM-ignoring gh cannot outlive the finite KILL grace.