From ee9b48d0f378945118354a170e671ecd6f3aa88f Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:33:50 -0400 Subject: [PATCH 1/4] fix(repo-fleet-hygiene): report merged evidence on a protected branch A branch carrying an exact-OID merged PR that is ALSO protected fell through both arms of the pr_match block and emitted nothing. The merged-worktree arm requires a non-main worktree; the merged-local-branch arm requires protected=false. A branch attached to the main worktree, or the canonical checkout's current branch, satisfies neither, so the strongest merge evidence the collector holds was computed and discarded. The tell that this was an oversight rather than a protection rule: the weaker merged-pr-tip-drift below carries no protection guard at all and does emit. Silence on the strong path therefore read as "nothing merged" rather than "merged, but protected". Adds a LOW merged-protected-branch finding naming which protection applies. The protection rule itself is unchanged -- the kind is deliberately absent from branch_action_kind(), so it is reported and never becomes a cleanup candidate or an action-plan entry. Note the default branch cannot reach this arm: it is excluded from merge evidence collection upstream. The reachable cases are a main-worktree attached branch and the current branch. --- .../audit/reference/confidence-model.md | 1 + .../skills/audit/scripts/audit-fleet.sh | 22 ++++++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) 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 fd005a06b..48487c9c4 100644 --- a/plugins/repo-fleet-hygiene/skills/audit/reference/confidence-model.md +++ b/plugins/repo-fleet-hygiene/skills/audit/reference/confidence-model.md @@ -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 the default branch, the canonical checkout's current branch, or attached to the main worktree | `LOW` | Informational only; protected branches are never branch-cleanup candidates. Reported so that 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 | 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 97b224f45..d873ca762 100755 --- a/plugins/repo-fleet-hygiene/skills/audit/scripts/audit-fleet.sh +++ b/plugins/repo-fleet-hygiene/skills/audit/scripts/audit-fleet.sh @@ -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="" @@ -2231,6 +2231,26 @@ 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(). + protection_reason="branch is protected" + if [[ "$branch" == "$default_branch" ]]; then + protection_reason="default branch" + elif [[ "$branch" == "$current_branch" ]]; then + protection_reason="current branch of the canonical checkout" + elif [[ "$is_main" == "true" ]]; then + protection_reason="attached to the main worktree" + fi + emit_finding LOW 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" From eb8271ef857889f75d97b6f2905c495347c69005 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sat, 15 Aug 2026 02:16:32 -0400 Subject: [PATCH 2/4] test(repo-fleet-hygiene): cover merged-protected-branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fleet-finding-test-coverage gate requires every emitted finding kind to carry a Finding: assertion in the collector's test file, and correctly red-lined the new kind. Fixture is a branch attached to the MAIN worktree that is not the default branch: merged-worktree needs a non-main worktree and merged-local-branch needs protected=false, so it reaches the new arm and nothing else. The default branch cannot stand in — merge evidence collection excludes it upstream. Asserts the finding is emitted, that it names the protection reason, and that it never routes to branch cleanup. --- .../skills/audit/scripts/audit-fleet.test.sh | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) 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 b8d5ed30e..562ad65e1 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 @@ -231,6 +231,11 @@ worktree) case "$base" in canonical-a) printf 'worktree %s\0HEAD main-a\0branch refs/heads/main\0\0' "$TEST_ROOT/canonical-a" + # F2: a branch attached to the MAIN worktree but NOT the default branch. It is + # protected (is_main), so merged-local-branch declines it, and merged-worktree + # requires a non-main worktree, so that declines it too. The default branch + # cannot stand in here — it is excluded from merge-evidence collection upstream. + printf 'worktree %s\0HEAD main-attached-tip\0branch refs/heads/feature/main-attached\0\0' "$TEST_ROOT/canonical-a" 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" @@ -372,7 +377,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' @@ -512,6 +517,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 ;; @@ -728,6 +735,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" \ From 50b05a51a92592472a6123f87eb59811f24c0d38 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sat, 15 Aug 2026 03:15:31 -0400 Subject: [PATCH 3/4] fix(repo-fleet-hygiene): reach the protected arm and bump to 0.22.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI ran the suite on Linux and showed the fixture never reached the new arm. is_main is `wt_index -eq 0` — the FIRST worktree record — so putting feature/main-attached at index 1 gave attached=true with is_main=false, which routes to merged-worktree instead. The branch is now the main worktree's own record. Three shapes fail to reach the arm and only the fourth works: the default branch (excluded from merge-evidence collection upstream), a non-first worktree record (routes to merged-worktree), an unattached non-current branch (routes to merged-local-branch), and the main worktree checked out on a non-default branch. Recorded in the fixture comment. Adds the version bump and CHANGELOG entry that check-changelog-parity requires whenever shipped plugin files change. The earlier no-bump approach was correct only while several PRs were staged in parallel and would have collided on --check-order; this is the sole open PR touching the plugin, so the bump is both required and safe. --- .../repo-fleet-hygiene/.claude-plugin/plugin.json | 2 +- plugins/repo-fleet-hygiene/CHANGELOG.md | 15 +++++++++++++++ .../skills/audit/scripts/audit-fleet.test.sh | 11 ++++++----- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/plugins/repo-fleet-hygiene/.claude-plugin/plugin.json b/plugins/repo-fleet-hygiene/.claude-plugin/plugin.json index bdee0494b..59a59d9e9 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.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", diff --git a/plugins/repo-fleet-hygiene/CHANGELOG.md b/plugins/repo-fleet-hygiene/CHANGELOG.md index e6967d327..95b111608 100644 --- a/plugins/repo-fleet-hygiene/CHANGELOG.md +++ b/plugins/repo-fleet-hygiene/CHANGELOG.md @@ -3,6 +3,21 @@ 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 `LOW` + `merged-protected-branch` finding reports it and names which protection applies. 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. + ## [0.22.1] ### Fixed 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 562ad65e1..1b26ea33b 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 @@ -230,11 +230,12 @@ 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: a branch attached to the MAIN worktree but NOT the default branch. It is - # protected (is_main), so merged-local-branch declines it, and merged-worktree - # requires a non-main worktree, so that declines it too. The default branch - # cannot stand in here — it is excluded from merge-evidence collection upstream. + # 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" 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" From 9bb907d0f9f0b630d6e20b5b423e56a4068d1960 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sat, 15 Aug 2026 03:46:43 -0400 Subject: [PATCH 4/4] =?UTF-8?q?fix(repo-fleet-hygiene):=20address=20review?= =?UTF-8?q?=20=E2=80=94=20HIGH=20tier,=20drop=20dead=20branch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two P2/Important review findings, both correct. Confidence: the evidence here is the same successful MERGED PR with an exact headRefOid match that gives merged-local-branch and merged-worktree HIGH. The confidence model separates evidence strength from disposition, so downgrading to LOW conflated the two. Raised to HIGH; the protection stays in the disposition, and the kind remains absent from branch_action_kind() so it is still never a cleanup candidate. Dead code: the default_branch protection-reason arm can never execute. pr_match is populated only under [[ "$branch" != "$default_branch" ]] and is reset each iteration, so a default branch never enters the enclosing block at all. I noted that reachability in the PR body and then left the unreachable branch in the code. Removed, with a comment recording why the case is impossible rather than merely absent. Reference table and CHANGELOG updated to match both. --- plugins/repo-fleet-hygiene/CHANGELOG.md | 12 ++++++++---- .../skills/audit/reference/confidence-model.md | 2 +- .../skills/audit/scripts/audit-fleet.sh | 16 +++++++++++----- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/plugins/repo-fleet-hygiene/CHANGELOG.md b/plugins/repo-fleet-hygiene/CHANGELOG.md index 95b111608..28bfc8a83 100644 --- a/plugins/repo-fleet-hygiene/CHANGELOG.md +++ b/plugins/repo-fleet-hygiene/CHANGELOG.md @@ -13,10 +13,14 @@ All notable changes to `repo-fleet-hygiene` are documented here. Format follows 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 `LOW` - `merged-protected-branch` finding reports it and names which protection applies. 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. + 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] 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 48487c9c4..c5a67e101 100644 --- a/plugins/repo-fleet-hygiene/skills/audit/reference/confidence-model.md +++ b/plugins/repo-fleet-hygiene/skills/audit/reference/confidence-model.md @@ -25,7 +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 the default branch, the canonical checkout's current branch, or attached to the main worktree | `LOW` | Informational only; protected branches are never branch-cleanup candidates. Reported so that 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-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 | 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 d873ca762..59e6b76db 100755 --- a/plugins/repo-fleet-hygiene/skills/audit/scripts/audit-fleet.sh +++ b/plugins/repo-fleet-hygiene/skills/audit/scripts/audit-fleet.sh @@ -2239,15 +2239,21 @@ analyze_repo() { # 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 [[ "$branch" == "$default_branch" ]]; then - protection_reason="default branch" + 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" - elif [[ "$is_main" == "true" ]]; then - protection_reason="attached to the main worktree" fi - emit_finding LOW merged-protected-branch "$canonical :: $branch" \ + # 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"