Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/repo-fleet-hygiene/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
9 changes: 9 additions & 0 deletions plugins/repo-fleet-hygiene/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions plugins/repo-fleet-hygiene/skills/audit/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <this-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
Expand Down
3 changes: 2 additions & 1 deletion plugins/repo-fleet-hygiene/skills/audit/evals/evals.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" \
Expand Down Expand Up @@ -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" ;;
Expand Down Expand Up @@ -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" ;;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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' ;;
Expand All @@ -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' ;;
Expand All @@ -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
;;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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"
Expand Down