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.22.1",
"version": "0.22.2",
"description": "Cross-repository Git/GitHub fleet discovery, evidence rollup, and a gated apply verb that executes a prior fleet action plan behind one confirmation. Audit stays read-only and confidence-tiered; apply mutates only with --apply plus interactive confirmation or --yes.",
"author": {
"name": "Melodic Software",
Expand Down
19 changes: 19 additions & 0 deletions plugins/repo-fleet-hygiene/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,25 @@
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.22.2]

### Fixed

- **Exact-OID merged evidence on a protected branch is no longer computed and discarded (#2687).**
A branch carrying a `MERGED` PR whose `headRefOid` equals the local tip fell through both arms
of the match block when it was also protected: `merged-worktree` requires a non-main worktree,
and `merged-local-branch` requires `protected=false`, so a branch checked out in the main
worktree — or the canonical checkout's current branch — satisfied neither and emitted nothing.
The weaker `merged-pr-tip-drift` below carries no protection guard and did emit, so silence on
the strong path read as "nothing merged" rather than "merged, but protected". A new `HIGH`
`merged-protected-branch` finding reports it and names which protection applies — `HIGH` because
the evidence is the same successful `MERGED` PR with an exact `headRefOid` match that the
sibling kinds carry, and the confidence model separates evidence strength from disposition. The
protection rule is unchanged: the kind is absent from `branch_action_kind()`, so it never becomes
a cleanup candidate, inflates a rollup count, or enters an action plan. Only the main-worktree
and current-branch protections are reachable; the default branch is excluded from merge-evidence
collection upstream and never reaches this classification.

## [0.22.1]

### Fixed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ failure rather than a discovery.
|---|---|---|---|
| `merged-local-branch` | GitHub `MERGED` PR for this repository + branch and `headRefOid` equals local tip; branch is not current/default/worktree-attached | `HIGH` | Candidate handoff to `/repo-hygiene:clean git` |
| `merged-worktree` | Same merged-PR/tip evidence, branch is attached to a non-main registered worktree | `HIGH` | Candidate handoff to `/source-control:worktree cleanup --dry-run` first |
| `merged-protected-branch` | Same merged-PR/tip evidence as `merged-local-branch`, but the branch is attached to the main worktree or is the canonical checkout's current branch (the default branch never reaches this classification — it is excluded from merge-evidence collection) | `HIGH` | Informational only; protected branches are never branch-cleanup candidates. `HIGH` is the evidence tier, not a cleanup signal — the disposition carries the protection. Reported so exact-OID merge evidence is never computed and then silently discarded: absent this kind, a protected branch's strongest evidence produced no finding while the weaker `merged-pr-tip-drift` still emitted, so silence read as "nothing merged" |
| `merged-pr-tip-drift` | GitHub merged PR exists, but local tip differs from every returned `headRefOid` | `MEDIUM` | Manual review; never delete from this evidence |
| `merged-remote-branch` | GitHub `MERGED` PR for this repository + branch and `headRefOid` equals the last-fetched remote-tracking tip, **and** `git ls-remote --heads` confirms the same tip still exists on the remote (so `delete_branch_on_merge` was not enabled or was blocked). When ls-remote fails, the same cached match is reported at `MEDIUM` as an unverified local remote-tracking observation. Empty ls-remote (head already deleted upstream) emits no finding. | `HIGH` when ls-remote confirms; `MEDIUM` when ls-remote fails | Optional `git push --delete --dry-run` preview handoff; separate from local cleanup. Enabling GitHub `delete_branch_on_merge` is complementary (stops the class accruing), not a substitute for this finding — never changed by this audit |
| `local-ancestry-only` | Local tip is an ancestor of the remote-tracking default branch, with no matching GitHub merged PR evidence | `LOW` | Informational only |
Expand Down
28 changes: 27 additions & 1 deletion plugins/repo-fleet-hygiene/skills/audit/scripts/audit-fleet.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1663,7 +1663,7 @@ analyze_repo() {
local pr_num pr_branch pr_oid pr_merged pr_url attached wt_index branch_index is_main ancestry_status
local ref_record branch_status=1 branch_inventory_valid=true
local remote_ref_record remote_branch_status=1 remote_branch_short
local repo_pr_rows="" repo_pr_available=false protected=false
local repo_pr_rows="" repo_pr_available=false protected=false protection_reason=""
local remote_inventory_failed=false
local gql_owner="" gql_name="" gql_owner_esc="" gql_name_esc="" gql_query="" gql_page_rows=""
local gql_page_start=0 gql_page_end=0 gql_alias_i=0 gql_bi=0 gql_branch_esc=""
Expand Down Expand Up @@ -2231,6 +2231,32 @@ analyze_repo() {
emit_finding HIGH merged-local-branch "$canonical :: $branch" \
"GitHub PR #$pr_num MERGED; headRefOid $pr_oid equals local tip ($pr_url)" \
"Candidate per-repository branch-audit handoff" "Run /repo-hygiene:clean git in $canonical"
else
# Protected AND exact-OID merged. Without this arm the evidence is computed
# and then discarded: neither branch above fires, so the strongest merge
# evidence the collector has produces no finding at all. The weaker
# merged-pr-tip-drift below carries no protection guard and DOES emit, so
# silence here reads as "nothing merged" rather than "merged but protected".
# Reported, never a cleanup candidate -- the protection rule is unchanged
# and this kind is deliberately absent from branch_action_kind().
# The default branch cannot reach here: pr_match is populated only under
# [[ "$branch" != "$default_branch" ]] at the collection guard above, and is
# reset every iteration, so a default branch never enters this block. Only
# the current-branch and main-worktree protections are reachable.
protection_reason="branch is protected"
if [[ "$is_main" == "true" ]]; then
protection_reason="attached to the main worktree"
elif [[ "$branch" == "$current_branch" ]]; then
protection_reason="current branch of the canonical checkout"
fi
# HIGH, matching merged-local-branch and merged-worktree: the evidence is the
# same successful MERGED PR with an exact headRefOid match. The confidence
# model separates evidence strength from disposition, so protection belongs in
# the disposition, not in a downgraded tier.
emit_finding HIGH merged-protected-branch "$canonical :: $branch" \
"GitHub PR #$pr_num MERGED; headRefOid $pr_oid equals local tip ($pr_url); $protection_reason" \
"Informational only; protected branches are never branch-cleanup candidates" \
"Switch off this branch in $canonical, then rerun to reclassify it"
fi
elif [[ -n "$pr_any" && "$branch" != "$default_branch" ]]; then
IFS='|' read -r pr_num pr_oid pr_merged pr_url <<<"$pr_any"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,13 @@ worktree)
[[ "${1:-}" == "list" ]] || exit 97
case "$base" in
canonical-a)
printf 'worktree %s\0HEAD main-a\0branch refs/heads/main\0\0' "$TEST_ROOT/canonical-a"
# F2: the MAIN worktree (record 0 => is_main) checked out on a branch that is
# NOT the default branch. It is protected via is_main, so merged-local-branch
# declines it; merged-worktree requires a NON-main worktree, so that declines it
# too. Both the default branch and a non-first record fail to reach that arm --
# the default branch is excluded from merge-evidence collection upstream, and a
# later record has is_main=false, which routes to merged-worktree instead.
printf 'worktree %s\0HEAD main-attached-tip\0branch refs/heads/feature/main-attached\0\0' "$TEST_ROOT/canonical-a"
Comment thread
kyle-sexton marked this conversation as resolved.
Comment thread
kyle-sexton marked this conversation as resolved.
printf 'worktree %s\0HEAD sha-a\0branch refs/heads/feature/shared\0\0' "$TEST_ROOT/wt-a"
printf 'worktree %s\0HEAD mismatch\0branch refs/heads/feature/mismatch\0\0' "$TEST_ROOT/wt-mismatch"
printf 'worktree %s\0HEAD evil\0prunable missing\0\0' "$EVIL_PATH"
Expand Down Expand Up @@ -372,7 +378,7 @@ for-each-ref)
# stale/gone: GraphQL returns a merged PR at a different OID (drift) and the branch has no
# remote-tracking ref -- the drift finding must state tip/headRefOid differ without claiming
# the commits were never pushed (they may still be on the remote).
printf 'main\tmain-a\0\nfeature/shared\tsha-a\0\nstale/changed\tdrift-tip\0\nfeature/mismatch\tmismatch\0\nstale/gone\tgone-tip\0\n'
printf 'main\tmain-a\0\nfeature/shared\tsha-a\0\nstale/changed\tdrift-tip\0\nfeature/mismatch\tmismatch\0\nstale/gone\tgone-tip\0\nfeature/main-attached\tmain-attached-tip\0\n'
;;
repo-b)
printf 'main\tmain-b\0\nfeature/shared\tsha-b\0\n'
Expand Down Expand Up @@ -512,6 +518,8 @@ api)
feature/remote-only) printf '44|remote-only-tip|2026-07-04T00:00:00Z|https://github.com/acme/repo-a/pull/44' ;;
feature/stale-cached) printf '45|stale-cached-tip|2026-07-05T00:00:00Z|https://github.com/acme/repo-a/pull/45' ;;
feature/ls-fail) printf '46|ls-fail-tip|2026-07-06T00:00:00Z|https://github.com/acme/repo-a/pull/46' ;;
# F2: exact-OID merged evidence on a main-worktree-attached branch.
feature/main-attached) printf '47|main-attached-tip|2026-07-07T00:00:00Z|https://github.com/acme/repo-a/pull/47' ;;
*) printf '' ;;
esac
;;
Expand Down Expand Up @@ -728,6 +736,25 @@ assert_kind_targets "moved-remote still emits merged-worktree on resolved identi
assert_kind_targets "moved-remote still emits merged-local-branch on resolved identity" \
merged-local-branch "old-repo :: feature/moved-local" "new-clone"

# F2: exact-OID merged evidence on a PROTECTED branch must be reported, not discarded.
# Neither the merged-worktree arm (needs a non-main worktree) nor the
# merged-local-branch arm (needs protected=false) accepts a main-worktree-attached
# branch, so before the else arm existed the collector's strongest evidence produced
# no finding at all -- while the weaker merged-pr-tip-drift, which carries no
# protection guard, still emitted. Silence read as "nothing merged".
assert_contains "protected branch with exact-OID merge evidence is reported" \
"Finding: merged-protected-branch"
assert_contains "merged-protected-branch names the protection reason" \
"attached to the main worktree"
# The protection rule is unchanged: it must never become a cleanup candidate.
if grep -A6 -F "Finding: merged-protected-branch" "$output" |
grep -Fq "Run /repo-hygiene:clean git"; then
printf 'FAIL: merged-protected-branch must not route to branch cleanup\n' >&2
failures=$((failures + 1))
else
printf 'PASS: merged-protected-branch does not route to branch cleanup\n'
fi

# #2607: remote heads that still exist after a MERGED PR are a distinct finding from local cleanup.
assert_contains "merged remote-tracking head is reported" "Finding: merged-remote-branch"
assert_kind_targets "remote-only merged head is reported without a local branch" \
Expand Down
Loading