From 0e4fe63b47d3cb9ed76be88dbc1435456dcc3535 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 14 Aug 2026 22:52:40 +0000 Subject: [PATCH] fix(repo-fleet-hygiene): keep analysis on moved-identity checkouts github-remote-moved evidence now states that branch/worktree classification continues against the resolved full_name, and the collector fixture requires exact-OID merged findings from that identity so a silent skip cannot regress. Co-authored-by: Kyle Sexton --- .../.claude-plugin/plugin.json | 2 +- plugins/repo-fleet-hygiene/CHANGELOG.md | 9 ++++++ .../repo-fleet-hygiene/skills/audit/SKILL.md | 8 +++-- .../skills/audit/evals/evals.json | 3 +- .../audit/reference/confidence-model.md | 2 +- .../skills/audit/scripts/audit-fleet.sh | 8 +++-- .../skills/audit/scripts/audit-fleet.test.sh | 31 +++++++++++++++++-- 7 files changed, 52 insertions(+), 11 deletions(-) diff --git a/plugins/repo-fleet-hygiene/.claude-plugin/plugin.json b/plugins/repo-fleet-hygiene/.claude-plugin/plugin.json index 248e4f392b..88b8c72b2b 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.1", + "version": "0.13.2", "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 d70e70b366..0587e4fd49 100644 --- a/plugins/repo-fleet-hygiene/CHANGELOG.md +++ b/plugins/repo-fleet-hygiene/CHANGELOG.md @@ -3,6 +3,15 @@ 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.2] + +### Fixed + +- **Moved-identity checkouts keep branch and worktree analysis (#2600).** Emitting + `github-remote-moved` no longer reads as a silent stop: evidence states that classification + continues against the resolved `full_name`, and the collector regression fixture requires both + `merged-worktree` and `merged-local-branch` exact-OID findings from that resolved identity. + ## [0.13.1] ### Fixed diff --git a/plugins/repo-fleet-hygiene/skills/audit/SKILL.md b/plugins/repo-fleet-hygiene/skills/audit/SKILL.md index 6da2c7c8f8..c1b7491030 100644 --- a/plugins/repo-fleet-hygiene/skills/audit/SKILL.md +++ b/plugins/repo-fleet-hygiene/skills/audit/SKILL.md @@ -87,9 +87,11 @@ The bundled collector is authoritative for classifications. Preserve its evidenc resolve to the same GitHub repository, stops that repository's local audit before evidence combines. 2. **GitHub identity:** read the selected fetch remote with `git remote get-url`; accept only `github.com/owner/repo`; query `GET /repos/{owner}/{repo}`. If returned `full_name` differs, report - `HIGH` transfer/rename evidence. A 404/403/network error is `UNKNOWN`, never "deleted" or "moved". - A 404/403 on an identity listed in `fleet.ackUnavailable` is demoted to `ACKNOWLEDGED` — still - reported, never suppressed; acks never touch non-404/403 failures or successful-response evidence. + `HIGH` transfer/rename evidence and **continue** branch/worktree analysis against that resolved + identity — a moved remote is not a reason to skip local classification or merge evidence. A + 404/403/network error is `UNKNOWN`, never "deleted" or "moved". A 404/403 on an identity listed + in `fleet.ackUnavailable` is demoted to `ACKNOWLEDGED` — still reported, never suppressed; acks + never touch non-404/403 failures or successful-response evidence. 3. **Merged branch:** one batched `gh pr list --repo --state merged --limit 200` query per repository, matched locally by branch name — not a per-branch query. Two consequences a reader must not assume away. A repository with more merged PRs than the window loses the older diff --git a/plugins/repo-fleet-hygiene/skills/audit/evals/evals.json b/plugins/repo-fleet-hygiene/skills/audit/evals/evals.json index c84db559c2..b49b55a461 100644 --- a/plugins/repo-fleet-hygiene/skills/audit/evals/evals.json +++ b/plugins/repo-fleet-hygiene/skills/audit/evals/evals.json @@ -53,10 +53,11 @@ "id": 5, "name": "github-transfer-versus-404", "prompt": "Repo one remote says github.com/old/repo and GitHub resolves it successfully to new/repo. Repo two's lookup returns HTTP 404. Classify both.", - "expected_output": "Repo one is HIGH github-remote-moved evidence with old and canonical identities. Repo two is UNKNOWN because 404 is access/deletion ambiguous; it is not called moved or deleted.", + "expected_output": "Repo one is HIGH github-remote-moved evidence with old and canonical identities, and branch/worktree analysis still runs against the resolved identity. Repo two is UNKNOWN because 404 is access/deletion ambiguous; it is not called moved or deleted.", "files": [], "expectations": [ "Classifies successful different full_name resolution as HIGH moved/renamed evidence", + "Continues branch and worktree analysis against the resolved identity rather than silent-skipping", "Classifies HTTP 404 as UNKNOWN", "Does not infer moved or deleted from 404" ] 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 b0aad5e5c7..6a877f87e8 100644 --- a/plugins/repo-fleet-hygiene/skills/audit/reference/confidence-model.md +++ b/plugins/repo-fleet-hygiene/skills/audit/reference/confidence-model.md @@ -37,7 +37,7 @@ failure rather than a discovery. | `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 | | `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` | `HIGH` | Human-reviewed remote update | +| `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 | | `canonical-override-invalid` | An override target has a missing, ambiguous, credential-only, or non-`github.com` remote | `UNKNOWN` | Stop that repository; never combine evidence | | `canonical-identity-unverified` | An override target's GitHub identity could not be resolved for comparison against the discovered one | `UNKNOWN` | Stop that repository; never combine evidence | 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 3a3fa0da8d..9fbbde25a7 100755 --- a/plugins/repo-fleet-hygiene/skills/audit/scripts/audit-fleet.sh +++ b/plugins/repo-fleet-hygiene/skills/audit/scripts/audit-fleet.sh @@ -958,11 +958,15 @@ analyze_repo() { expected_default="$GH_ID_DEFAULT" expected_reason="$GH_ID_REASON" if [[ "$GH_ID_STATUS" == "OK" ]]; then + # Resolved identity is the PR/query authority even when the configured remote is stale. + # Do not return after github-remote-moved: local branch/worktree facts do not depend on the + # remote URL matching full_name, and a silent stop here would look identical to a clean repo. github_repo="$expected_actual" if [[ "$(lower "$expected_actual")" != "$(lower "$discovered_slug")" ]]; then emit_finding HIGH github-remote-moved "$discovered_remote ($discovered_slug -> $expected_actual)" \ - "GitHub REST resolved the configured remote identity to canonical full_name $expected_actual" \ - "Human-reviewed remote update" "Review git remote set-url for $discovered_remote in $discovered" + "GitHub REST resolved the configured remote identity to canonical full_name $expected_actual; branch and worktree analysis continues against that resolved identity" \ + "Human-reviewed remote update; local classification is not deferred" \ + "Review git remote set-url for $discovered_remote in $discovered" fi elif [[ "$expected_reason" == *"HTTP 404"* || "$expected_reason" == *"HTTP 403"* ]] && is_acked "$discovered_key"; then # Acked demotion applies ONLY to the foreseeable-inaccessible statuses; 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 d9b28329bf..6c156f5d14 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 @@ -14,6 +14,7 @@ mkdir -p "$MOCK_BIN" "$TMP/config" "$TMP/discovered-a" "$TMP/canonical-a" "$TMP/ "$TMP/emptyroot" \ "$TMP/wt-a" "$TMP/wt-mismatch" \ "$TMP/wt-status-fail" \ + "$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" \ "$TMP/wt-admin/sub-wt" "$TMP/wt-admin/sep-wt" \ @@ -59,6 +60,7 @@ rev-parse) canonical-a) printf '%s\n' "$TEST_ROOT/canonical-a" ;; repo-b) printf '%s\n' "$TEST_ROOT/repo-b" ;; old-repo) printf '%s\n' "$TEST_ROOT/old-repo" ;; + wt-old) printf '%s\n' "$TEST_ROOT/wt-old" ;; bad-discovered) printf '%s\n' "$TEST_ROOT/bad-discovered" ;; bad-canonical) printf '%s\n' "$TEST_ROOT/bad-canonical" ;; wt-fail) printf '%s\n' "$TEST_ROOT/wt-fail" ;; @@ -116,6 +118,7 @@ rev-parse) wt-mismatch) printf '%s\n' "$TEST_ROOT/other-repository/.git" ;; repo-b) printf '%s\n' "$TEST_ROOT/repo-b/.git" ;; old-repo) printf '%s\n' "$TEST_ROOT/old-repo/.git" ;; + wt-old) printf '%s\n' "$TEST_ROOT/old-repo/.git" ;; bad-discovered) printf '%s\n' "$TEST_ROOT/bad-discovered/.git" ;; bad-canonical) printf '%s\n' "$TEST_ROOT/bad-canonical/.git" ;; wt-fail) printf '%s\n' "$TEST_ROOT/wt-fail/.git" ;; @@ -191,7 +194,10 @@ worktree) printf 'worktree %s\0HEAD main-b\0branch refs/heads/main\0\0' "$TEST_ROOT/repo-b" ;; old-repo) + # Moved-identity fixture (#2600): local branch/worktree inventory must still be classified + # against the resolved GitHub identity (new/repo), not skipped after github-remote-moved. printf 'worktree %s\0HEAD old-main\0branch refs/heads/main\0\0' "$TEST_ROOT/old-repo" + printf 'worktree %s\0HEAD moved-merged-tip\0branch refs/heads/feature/moved-merged\0\0' "$TEST_ROOT/wt-old" ;; wt-fail) exit 7 ;; ref-fail) @@ -264,6 +270,9 @@ for-each-ref) canonical-a) printf 'origin\thead-a\0\norigin/main\tmain-a\0\norigin/feature/shared\tsha-a\0\norigin/stale/changed\tdrift-tip\0\n' ;; + # Moved-identity checkout (#2600): remote still advertises the feature head, so the privacy + # gate must not block the exact-OID merge match against the resolved identity. + old-repo) printf 'origin/main\told-main\0\norigin/feature/moved-merged\tmoved-merged-tip\0\norigin/feature/moved-local\tmoved-local-tip\0\n' ;; rref-fail) exit 9 ;; aaa-linked | bbb-linked | zzz-canonical) printf 'origin/main\tcanon-main\0\n' ;; sub-wt) printf 'origin/main\tsub-main\0\n' ;; @@ -281,7 +290,7 @@ for-each-ref) repo-b) printf 'main\tmain-b\0\nfeature/shared\tsha-b\0\n' ;; - old-repo) printf 'main\told-main\0\n' ;; + old-repo) printf 'main\told-main\0\nfeature/moved-merged\tmoved-merged-tip\0\nfeature/moved-local\tmoved-local-tip\0\n' ;; wt-fail) printf 'main\twt-main\0\nfeature/fail\tfail-tip\0\n' ;; ref-fail) printf 'main\tref-main\0\nfeature/partial\tpartial-tip\0'; exit 9 ;; rref-fail) printf 'main\trr-main\0\nfeature/gated\trr-tip\0\n' ;; @@ -304,13 +313,14 @@ status) wt-a) printf '' ;; wt-mismatch) printf ' M file.txt\n' ;; wt-status-fail) exit 7 ;; + wt-old) printf '' ;; *) printf '' ;; esac ;; log) [[ "${1:-}" == "-1" && "${2:-}" == "--format=%ct" && "${3:-}" == "HEAD" ]] || exit 96 case "$base" in - wt-a) printf '1700000000\n' ;; + wt-a | wt-old) printf '1700000000\n' ;; *) printf '1\n' ;; esac ;; @@ -370,7 +380,13 @@ pr) printf '42\tstale/changed\tmerged-tip\t2026-07-02T00:00:00Z\thttps://github.com/acme/repo-a/pull/42\n' printf '43\tstale/gone\tother-tip\t2026-07-03T00:00:00Z\thttps://github.com/acme/repo-a/pull/43\n' ;; - github.com/acme/repo-b | github.com/acme/root-repo | github.com/new/repo | github.com/acme/repo-c) ;; + github.com/acme/repo-b | github.com/acme/root-repo | github.com/acme/repo-c) ;; + # Resolved identity for the moved-remote fixture: merge evidence must be queried here, not under + # the stale configured remote (old/repo). Exact-OID rows prove branch/worktree analysis continued. + github.com/new/repo) + printf '7\tfeature/moved-merged\tmoved-merged-tip\t2026-07-04T00:00:00Z\thttps://github.com/new/repo/pull/7\n' + printf '8\tfeature/moved-local\tmoved-local-tip\t2026-07-05T00:00:00Z\thttps://github.com/new/repo/pull/8\n' + ;; # A FULL merged-PR window: gh returns at most --limit rows, so 200 rows means older merged PRs # were silently dropped. Every row is a branch this repository does not have locally, so the # truncation disclosure is the only thing this fixture can produce. @@ -501,6 +517,15 @@ assert_kind_targets "reclaimable names wt-a and not dirty or status-fail sibling assert_kind_targets "reclaimable does not include status-fail sibling" \ reclaimable-worktree "wt-a" "wt-status-fail" 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" +# #2600: github-remote-moved must not silent-skip local classification. The resolved identity +# (new/repo) supplies merge evidence for both an attached worktree branch and an unattached local. +assert_kind_targets "moved-remote still emits merged-worktree on resolved identity" \ + merged-worktree "old-repo :: feature/moved-merged" "new-clone" +assert_kind_targets "moved-remote still emits merged-local-branch on resolved identity" \ + merged-local-branch "old-repo :: feature/moved-local" "new-clone" +assert_contains "moved-remote worktree disposability still classified" "Target: $TMP/wt-old" assert_contains "non-GitHub canonical override fails closed" "canonical override has a missing, ambiguous, credential-only, or non-github.com remote" assert_contains "worktree inventory failure is unknown" "Finding: worktree-inventory-unavailable" assert_contains "branch inventory failure is unknown" "Finding: branch-inventory-unavailable"