From 93f9abb6633f2d2e256a544b82dc8297a6f5241e Mon Sep 17 00:00:00 2001 From: Diogo Silva Sena <227921880+DougBTW@users.noreply.github.com> Date: Tue, 27 Oct 2026 04:30:26 -0400 Subject: [PATCH 1/5] wip(3.10.9): retarget a fix PR whose kept landing branch was merged (ledger lib/autofix-ship.sh:658) _fi_af_mwg_landed retargets to the default branch when the landing branch's tip is still the head that merged; _fi_af_mwg_squashed also reads a manual BaseRefChangedEvent. tests/autofix-merge-landed-base.bats 6/6, related merge files 43/43. Not yet: rebase onto fix/mwg-stale-landing (#290), version bump, CHANGELOG, full suite, live e2e. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MVx3HWoDptxRUFfRDGQbYE --- lib/autofix-ship.sh | 52 +++++++++- tests/autofix-merge-landed-base.bats | 143 +++++++++++++++++++++++++++ 2 files changed, 191 insertions(+), 4 deletions(-) create mode 100644 tests/autofix-merge-landed-base.bats diff --git a/lib/autofix-ship.sh b/lib/autofix-ship.sh index 6ec5600..14da71a 100644 --- a/lib/autofix-ship.sh +++ b/lib/autofix-ship.sh @@ -492,11 +492,17 @@ fi_af_merge_when_green() { [[ "$settle" =~ ^[0-9]+$ ]] || settle=120 [[ -n "$slug" ]] || slug="$(fi_repo_id 2>/dev/null || true)" [[ -n "$slug" ]] || { fi_err "autofix: merge-when-green needs a GitHub repo (--repo owner/name)"; return 1; } - FI_AF_MWG_FIXES=0 + FI_AF_MWG_FIXES=0 FI_AF_MWG_DEFAULT="" + local lrc for (( i = 0; i < polls; i++ )); do v="$(gh pr view "$n" --repo "$slug" --json state,statusCheckRollup,mergeable,baseRefName,headRefName,headRefOid --jq "$jqf" 2>/dev/null || true)" IFS='|' read -r st ck mg ho br hr <<<"$v" || true [[ "$st $ck" == "OPEN fail" && "$mg" != CONFLICTING ]] || red_sha="" + if [[ "$st" == OPEN ]]; then + lrc=0; _fi_af_mwg_landed "$n" "$slug" "$br" "$guard" || lrc=$? + (( lrc == 2 )) && return 1 + (( lrc == 0 )) && { nones=0; sleep "$pause"; continue; } + fi if [[ "$st" == OPEN && "$mg" == CONFLICTING ]]; then _fi_af_mwg_unconflict "$n" "$slug" "$br" "$hr" || return 1 nones=0; sleep "$pause"; continue @@ -588,6 +594,43 @@ _fi_af_guard_unlock() { return 0 } +FI_AF_MWG_DEFAULT="" + +# 3.10.9 (ledger lib/autofix-ship.sh:658): a fix PR's landing branch was +# merged into the default branch but kept (delete_branch_on_merge off), so +# GitHub never retargeted the PR (sayciao #320 into gsd/phase-24-resume after +# #318). When the branch's tip is still exactly the head that merged, the PR +# is retargeted to the default branch and the usual flow goes on: a conflict +# brings in _fi_af_mwg_squashed, which reads the manual base change. A branch +# with work after its merge stays the base (sayciao's moved on: a fix merged +# there reaches main with its next PR); a deleted one GitHub retargets itself. +# rc 0 = retargeted; rc 1 = nothing to do; rc 2 = the retarget failed. +_fi_af_mwg_landed() { + local n="$1" slug="$2" base="$3" guard="${4:-0}" def m oid tip enc + [[ -n "$base" ]] || return 1 + if [[ -z "$FI_AF_MWG_DEFAULT" ]]; then + FI_AF_MWG_DEFAULT="$(gh repo view "$slug" --json defaultBranchRef --jq '.defaultBranchRef.name // ""' 2>/dev/null || true)" + [[ -n "$FI_AF_MWG_DEFAULT" ]] || FI_AF_MWG_DEFAULT="-" # unknown: no retarget, no second lookup + fi + def="$FI_AF_MWG_DEFAULT" + [[ "$def" != "-" && "$base" != "$def" ]] || return 1 + m="$(gh pr list --repo "$slug" --head "$base" --base "$def" --state merged --limit 1 --json number,headRefOid,baseRefName \ + --jq '.[0] // {} | "\(.number // "")|\(.headRefOid // "")|\(.baseRefName // "")"' 2>/dev/null || true)" + [[ "${m##*|}" == "$def" ]] || return 1 + m="${m%|*}" oid="${m#*|}" m="${m%%|*}" + [[ "$m" =~ ^[0-9]+$ && "$oid" =~ ^[0-9a-f]{40}$ ]] || return 1 + enc="$(jq -rn --arg b "$base" '$b | @uri')" + tip="$(gh api "repos/$slug/branches/$enc" --jq '.commit.sha // ""' 2>/dev/null || true)" + [[ "$tip" == "$oid" ]] || return 1 + if ! gh pr edit "$n" --repo "$slug" --base "$def" >/dev/null 2>&1; then + fi_err "autofix: PR #$n: its landing branch $base was merged into $def (PR #$m), and retargeting it to $def failed — not merging"; return 2 + fi + printf 'PR #%s: its landing branch %s was merged into %s (PR #%s) with nothing since; retargeted to %s\n' "$n" "$base" "$def" "$m" "$def" + # A base change can drop an armed auto-merge; the guard arms it again. + if (( guard )); then gh pr merge "$n" --auto --squash --repo "$slug" >/dev/null 2>&1 || true; fi + return 0 +} + # Squash-merge ; a refusal's message is kept in FI_AF_MWG_ERR. _fi_af_mwg_merge() { local n="$1" slug="$2" @@ -707,7 +750,8 @@ _fi_af_is_ledger() { # 3.10.7 (ledger lib/autofix-ship.sh:656): GitHub retargets a fix PR to the # default branch when the session branch it was cut from merges and is -# deleted. After a squash merge the PR still carries that branch's commits, +# deleted (3.10.9: or _fi_af_mwg_landed retargets it when the branch was +# kept, a BaseRefChangedEvent). After a squash merge the PR still carries that branch's commits, # so merging the base in conflicts with more than the PR's own annotation. # Then the PR's own change since it was cut (the merge base of the merged # PR's head and this PR's head) is rebuilt as one commit on the base, the @@ -718,9 +762,9 @@ _fi_af_is_ledger() { _fi_af_mwg_squashed() { local n="$1" slug="$2" wt="$3" base="$4" head="$5" ev old m oid cut was why f lrc subj cmd local ann="(PR: $slug#$n)" code=() ledgers=() tlog="${3%/*}/rebuild-tests.log" - local q='query($o:String!,$r:String!,$n:Int!){repository(owner:$o,name:$r){pullRequest(number:$n){timelineItems(itemTypes:[AUTOMATIC_BASE_CHANGE_SUCCEEDED_EVENT],last:1){nodes{... on AutomaticBaseChangeSucceededEvent{oldBase newBase}}}}}}' + local q='query($o:String!,$r:String!,$n:Int!){repository(owner:$o,name:$r){pullRequest(number:$n){timelineItems(itemTypes:[AUTOMATIC_BASE_CHANGE_SUCCEEDED_EVENT,BASE_REF_CHANGED_EVENT],last:1){nodes{... on AutomaticBaseChangeSucceededEvent{oldBase newBase} ... on BaseRefChangedEvent{previousRefName currentRefName}}}}}}' ev="$(gh api graphql -f query="$q" -f o="${slug%%/*}" -f r="${slug#*/}" -F n="$n" \ - --jq '.data.repository.pullRequest.timelineItems.nodes[-1] // {} | "\(.oldBase // "")|\(.newBase // "")"' 2>/dev/null || true)" + --jq '.data.repository.pullRequest.timelineItems.nodes[-1] // {} | "\(.oldBase // .previousRefName // "")|\(.newBase // .currentRefName // "")"' 2>/dev/null || true)" old="${ev%%|*}" [[ -n "$old" && "$old" != "$base" && "${ev#*|}" == "$base" ]] || return 1 # Only the PR that merged that branch into this base (a reused name or a diff --git a/tests/autofix-merge-landed-base.bats b/tests/autofix-merge-landed-base.bats new file mode 100644 index 0000000..0ae8b13 --- /dev/null +++ b/tests/autofix-merge-landed-base.bats @@ -0,0 +1,143 @@ +#!/usr/bin/env bats +# 3.10.9 (ledger lib/autofix-ship.sh:658): a fix PR's landing branch was +# squash-merged into main but kept (delete_branch_on_merge off), so GitHub +# never retargeted the PR and it would merge into a branch already merged +# (sayciao #320 into gsd/phase-24-resume after #318). When the landing branch +# has nothing since its merged head, merge-when-green retargets the PR to the +# default branch and the 3.10.7 rebuild applies. A branch that moved on +# (sayciao's did, 2026-10-27) stays the base. + +load 'helpers' +load 'autofix-helpers' + +# A gh over the fixture: the PR's base lives in $LB_BASE_FILE (pr edit changes +# it); mergeable = the PR branch contains its base; `api graphql` reports a +# BaseRefChangedEvent once the PR was retargeted; `pr list --head phase` +# reports the merged phase PR #5; `api .../branches/` reads the remote. +lb_gh() { + mkdir -p "$TMP/lbbin" + cat >"$TMP/lbbin/gh" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "$*" >>"$GH_MOCK_TRACE" +jq_filter=""; prev="" +for a in "$@"; do [[ "$prev" == --jq ]] && jq_filter="$a"; prev="$a"; done +base="$(cat "$LB_BASE_FILE")" +if git -C "$LB_REMOTE" merge-base --is-ancestor "$base" "$LB_HEAD" 2>/dev/null; then m=MERGEABLE; else m=CONFLICTING; fi +case "$1 $2" in + "pr view") + printf '{"state":"OPEN","mergeable":"%s","baseRefName":"%s","headRefName":"%s","statusCheckRollup":[]}' "$m" "$base" "$LB_HEAD" | jq -r "$jq_filter" ;; + "pr merge") + [[ " $* " == *" --auto "* ]] && exit 0 + [[ "$m" == CONFLICTING ]] && { echo "GraphQL: Pull Request has merge conflicts (mergePullRequest)" >&2; exit 1; } + exit 0 ;; + "pr edit") + [[ -n "${LB_EDIT_FAIL:-}" ]] && { echo "GraphQL: edit refused" >&2; exit 1; } + nb=""; prev=""; for a in "$@"; do [[ "$prev" == --base ]] && nb="$a"; prev="$a"; done + printf '%s' "$nb" >"$LB_BASE_FILE" ;; + "repo view") + printf '{"defaultBranchRef":{"name":"main"}}' | jq -r "$jq_filter" ;; + "pr list") + if [[ " $* " == *" --head phase "* && -z "${LB_NO_MERGED:-}" ]]; then + printf '[{"number":5,"headRefOid":"%s","baseRefName":"main"}]' "$LB_OLD_TIP" | jq -r "$jq_filter" + else printf '[]' | jq -r "$jq_filter"; fi ;; + "api graphql") + if [[ "$base" == main ]]; then nodes='[{"previousRefName":"phase","currentRefName":"main"}]'; else nodes='[]'; fi + printf '{"data":{"repository":{"pullRequest":{"timelineItems":{"nodes":%s}}}}}' "$nodes" | jq -r "$jq_filter" ;; + "api "*) + ep=""; for a in "${@:2}"; do case "$a" in -*|.*) ;; *) [[ -z "$ep" ]] && ep="$a" ;; esac; done + b="${ep#repos/foo/bar/branches/}"; b="${b//%2F//}" + sha="$(git -C "$LB_REMOTE" rev-parse --verify -q "refs/heads/$b")" || { echo "gh: Not Found (HTTP 404)" >&2; exit 1; } + printf '{"commit":{"sha":"%s"}}' "$sha" | jq -r "$jq_filter" ;; + *) exit 1 ;; +esac +EOF + chmod +x "$TMP/lbbin/gh" + export LB_REMOTE="$TMP/remote.git" LB_HEAD="fi/autofix/x" LB_BASE_FILE="$TMP/pr-base" + printf 'phase' >"$LB_BASE_FILE" + export PATH="$TMP/lbbin:$PATH" +} + +setup() { + fi_setup_tmp; fi_af_fixture + export GH_MOCK_TRACE="$TMP/gh.trace" FOUND_ISSUES_AUTOFIX_MERGE_SLEEP=0 FOUND_ISSUES_AUTOFIX_MERGE_POLLS=8 FOUND_ISSUES_AUTOFIX_GUARD_SLEEP=0 + : >"$GH_MOCK_TRACE" + lb_gh + git config found-issues.autofix.testCommand 'true' + L=docs/found-issues.md + printf -- '- [open] 2026-10-02 src/a.sh:1 — a breaks (fix: small)\n- [open] 2026-10-03 src/b.sh:1 — b breaks (fix: small)\n' >>"$L" + git commit -qam "two more entries" && git push -q origin main + git switch -q -c phase + printf 'p() { :; }\n' >src/p.sh && git add src/p.sh + printf -- '- [open] 2026-10-04 src/p.sh:1 — p is new (fix: small)\n' >>"$L" + git commit -qam "phase work" && git push -q -u origin phase + git switch -q -c "$LB_HEAD" + printf 'a() { :; }\n' >src/a.sh && git add src/a.sh && git commit -qm "fix: a breaks" + sed -i.bak 's/a breaks (fix: small)$/a breaks (fix: small) (PR: foo\/bar#7)/' "$L" && rm -f "$L.bak" + git commit -qam "docs(found-issues): annotate src/a.sh:1 with PR 7" && git push -q -u origin "$LB_HEAD" + # PR #5 squash-merges phase into main; the branch is KEPT. + LB_OLD_TIP="$(git rev-parse phase)"; export LB_OLD_TIP + git -C "$TMP/remote.git" update-ref refs/pull/5/head "$LB_OLD_TIP" + git switch -q main + git merge -q --squash phase >/dev/null && git commit -qm "phase work (#5)" + sed -i.bak 's/b breaks (fix: small)$/b breaks (fix: small) (PR: foo\/bar#6)/' "$L" && rm -f "$L.bak" + git commit -qam "docs: annotate b (#6)" && git push -q origin main +} +teardown() { fi_teardown_tmp; } + +@test "landed base: a fix PR into a kept, squash-merged landing branch is retargeted, rebuilt on main and merged" { + run "$FI_BIN" autofix merge-when-green 7 --repo foo/bar + [ "$status" -eq 0 ] + [[ "$output" == *"retargeted to main"* ]] + [[ "$output" == *"squash-merged"* ]] + [[ "$output" == *"merged PR #7"* ]] + grep -q '^pr edit 7 --repo foo/bar --base main$' "$GH_MOCK_TRACE" + [ "$(git -C "$TMP/remote.git" rev-parse "$LB_HEAD~1")" = "$(git -C "$TMP/remote.git" rev-parse main)" ] + [ "$(git -C "$TMP/remote.git" diff --name-only main "$LB_HEAD" | sort | tr '\n' ' ')" = "docs/found-issues.md src/a.sh " ] + git -C "$TMP/remote.git" show "$LB_HEAD:$L" | grep -qx -- '- \[open\] 2026-10-02 src/a.sh:1 — a breaks (fix: small) (PR: foo/bar#7)' + grep -q '^pr merge 7 --squash --repo foo/bar$' "$GH_MOCK_TRACE" + # the landing branch itself is untouched + [ "$(git -C "$TMP/remote.git" rev-parse phase)" = "$LB_OLD_TIP" ] + [ "$(git worktree list | wc -l | tr -d ' ')" -eq 1 ] +} + +@test "landed base: a landing branch with work after its merge stays the base" { + git switch -q phase + printf 'q() { :; }\n' >src/q.sh && git add src/q.sh && git commit -qm "more phase work" && git push -q origin phase + run "$FI_BIN" autofix merge-when-green 7 --repo foo/bar + [ "$status" -eq 0 ] + ! grep -q '^pr edit' "$GH_MOCK_TRACE" || false + grep -q '^pr merge 7 --squash --repo foo/bar$' "$GH_MOCK_TRACE" + [ "$(cat "$LB_BASE_FILE")" = phase ] +} + +@test "landed base: a landing branch never merged stays the base" { + export LB_NO_MERGED=1 + run "$FI_BIN" autofix merge-when-green 7 --repo foo/bar + [ "$status" -eq 0 ] + ! grep -q '^pr edit' "$GH_MOCK_TRACE" || false + grep -q '^pr merge 7 --squash --repo foo/bar$' "$GH_MOCK_TRACE" +} + +@test "landed base: the default branch is looked up once per watch" { + export LB_NO_MERGED=1 + run "$FI_BIN" autofix merge-when-green 7 --repo foo/bar + [ "$status" -eq 0 ] + [ "$(grep -c '^repo view' "$GH_MOCK_TRACE")" -eq 1 ] +} + +@test "landed base: a refused retarget stops the watch without merging" { + export LB_EDIT_FAIL=1 + run "$FI_BIN" autofix merge-when-green 7 --repo foo/bar + [ "$status" -eq 1 ] + [[ "$output" == *"retargeting it to main failed"* ]] + ! grep -q '^pr merge 7 --squash --repo foo/bar$' "$GH_MOCK_TRACE" || false +} + +@test "landed base: the guard retargets, re-arms auto-merge and never merges itself" { + export FOUND_ISSUES_AUTOFIX_GUARD_POLLS=3 + run "$FI_BIN" autofix merge-when-green 7 --repo foo/bar --guard + grep -q '^pr edit 7 --repo foo/bar --base main$' "$GH_MOCK_TRACE" + grep -q '^pr merge 7 --auto --squash --repo foo/bar$' "$GH_MOCK_TRACE" + ! grep -q '^pr merge 7 --squash --repo foo/bar$' "$GH_MOCK_TRACE" || false + [ "$(git -C "$TMP/remote.git" rev-parse "$LB_HEAD~1")" = "$(git -C "$TMP/remote.git" rev-parse main)" ] +} From cc79123c8ea64133db2f73d1a6b8abb8edaf69db Mon Sep 17 00:00:00 2001 From: Diogo Silva Sena <227921880+DougBTW@users.noreply.github.com> Date: Tue, 27 Oct 2026 04:34:32 -0400 Subject: [PATCH 2/5] test: read git log into a variable before grep -q (ledger tests/autofix-sweep.bats:1067 SIGPIPE flake) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MVx3HTvihe7tJycZ7B7mjC --- tests/autofix-sweep.bats | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/autofix-sweep.bats b/tests/autofix-sweep.bats index d0a09e3..ad7f1d9 100644 --- a/tests/autofix-sweep.bats +++ b/tests/autofix-sweep.bats @@ -1064,9 +1064,13 @@ sweep_branch() { printf 'fi/sweep/%s-%s' "${SID%%-*}" "${SID##*-}"; } br2="$(sed -n 's/^branch=//p' "$c")" [ -n "$br2" ] # Batch 1 keeps today's behaviour: one annotation commit on its branch. - git -C "$TMP/remote.git" log --format=%s "main..$br1" | grep -q '^docs(found-issues): annotate 2 entries with PR 9' + # Logs are read into variables first: grep -q stops at its first match and, + # under pipefail, a git log still writing dies of SIGPIPE (status 141). + subj1="$(git -C "$TMP/remote.git" log --format=%s "main..$br1")" + subj2="$(git -C "$TMP/remote.git" log --format=%s "main..$br2")" + grep -q '^docs(found-issues): annotate 2 entries with PR 9' <<<"$subj1" # The continuation commits no ledger change at all. - ! git -C "$TMP/remote.git" log --format=%s "main..$br2" | grep -q 'docs(found-issues)' || false + ! grep -q 'docs(found-issues)' <<<"$subj2" || false [ -z "$(git -C "$TMP/remote.git" log --format=%H "main..$br2" -- docs/found-issues.md)" ] grep -q 'PR-branch ledger annotation skipped for a continuation batch' "$FI_AF_RUNS/${c##*/}.log" # The source checkout's ledger is annotated for the continuation batches' From bd26be69f4e137a6c73083fe980c31d8a927474f Mon Sep 17 00:00:00 2001 From: Diogo Silva Sena <227921880+DougBTW@users.noreply.github.com> Date: Tue, 27 Oct 2026 04:35:31 -0400 Subject: [PATCH 3/5] =?UTF-8?q?release:=20v3.10.9=20=E2=80=94=20a=20fix=20?= =?UTF-8?q?PR=20whose=20landing=20branch=20was=20merged=20and=20kept=20is?= =?UTF-8?q?=20retargeted=20to=20the=20default=20branch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MVx3HTvihe7tJycZ7B7mjC --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- CHANGELOG.md | 8 ++++++++ README.md | 2 +- bin/found-issues | 2 +- 5 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index c98f45e..8c37eec 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "found-issues", - "version": "3.10.8", + "version": "3.10.9", "description": "Markdown-based issue tracker for AI agents. Claude logs out-of-scope issues it spots while working; entries auto-flip to fixed on PR merge or commit-fix.", "author": { "name": "AltDoug", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 87545e9..5bb65d1 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "found-issues", - "version": "3.10.8", + "version": "3.10.9", "description": "Markdown-based issue tracker for AI agents. The agent logs out-of-scope issues it spots while working; entries auto-flip to fixed on PR merge or commit-fix.", "author": { "name": "AltDoug", diff --git a/CHANGELOG.md b/CHANGELOG.md index c4d1052..25f524a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.10.9] - 2026-10-27 + +A fix PR whose landing branch was merged but kept is moved to the default branch, instead of merging into a branch nothing reads any more. + +### Fixed +- **A fix PR no longer merges into a landing branch that was merged and kept** (ledger `lib/autofix-ship.sh:658`). A fix cut from a session's branch opens its PR into that branch. 3.10.7 handled the case where the session's PR merges and the branch is deleted, because GitHub then moves the fix PR to the default branch on its own. With `delete_branch_on_merge` off the branch stays, GitHub moves nothing, and the fix PR merged into a branch that had already landed, so the fix never reached the default branch. merge-when-green (and the `--guard` watcher for an armed auto-merge) now checks the PR's base: when it was merged into the default branch and its tip is still exactly the head that merged, the PR is retargeted to the default branch (`retargeted to ` in the log). After a squash merge the retargeted PR conflicts, and the 3.10.7 rebuild now also reads this manual base change, so only the fix's own change is rebuilt on the default branch. A landing branch with commits after its merge is still in use and stays the base (sessions reuse their branch). A retarget gh refuses stops the watch without merging; the guard arms auto-merge again after a retarget. +- **A sweep test no longer fails on SIGPIPE** (ledger `tests/autofix-sweep.bats:1067`). Test-only: `git log | grep -q` under `pipefail` could fail with status 141 when grep stopped at its first match; the log is now read into a variable first. + ## [3.10.8] - 2026-10-27 A fix PR whose checks fail once on a CI hiccup gets its failed runs re-run once instead of stopping for good. diff --git a/README.md b/README.md index 74f6856..971873f 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ [![Claude Code plugin](https://img.shields.io/badge/Claude%20Code-plugin-orange.svg)](https://docs.claude.com/en/docs/claude-code/plugins) [![Codex plugin](https://img.shields.io/badge/Codex-plugin-black.svg)](AGENTS.md#installing-for-codex) -**7 lifecycle hooks · 14 slash commands · 2033 tests on Linux/macOS · zero manual bookkeeping** +**7 lifecycle hooks · 14 slash commands · 2039 tests on Linux/macOS · zero manual bookkeeping** ![demo](hero.gif) diff --git a/bin/found-issues b/bin/found-issues index 2971087..db82cb5 100755 --- a/bin/found-issues +++ b/bin/found-issues @@ -28,7 +28,7 @@ set -euo pipefail -readonly FI_VERSION="3.10.8" +readonly FI_VERSION="3.10.9" # Statusline fast path (lib/segment-cache.sh): `status --format=segment` runs # every few seconds per session, so it is answered from a content-checked From 511eb7eee892eec7c515bc63ec64bfeb97ce4da7 Mon Sep 17 00:00:00 2001 From: Diogo Silva Sena <227921880+DougBTW@users.noreply.github.com> Date: Tue, 27 Oct 2026 04:45:51 -0400 Subject: [PATCH 4/5] =?UTF-8?q?fix(3.10.9):=20review=20=E2=80=94=20guard?= =?UTF-8?q?=20disarms=20on=20a=20refused=20retarget,=20a=20retarget=20is?= =?UTF-8?q?=20always=20brought=20up=20to=20date,=20protected=20and=20fork?= =?UTF-8?q?=20branches=20skipped,=20default=20lookup=20retried?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MVx3HTvihe7tJycZ7B7mjC --- CHANGELOG.md | 2 +- README.md | 2 +- lib/autofix-ship.sh | 72 ++++++++++++++++++++-------- tests/autofix-merge-landed-base.bats | 57 ++++++++++++++++++++-- 4 files changed, 106 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25f524a..dd82044 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). A fix PR whose landing branch was merged but kept is moved to the default branch, instead of merging into a branch nothing reads any more. ### Fixed -- **A fix PR no longer merges into a landing branch that was merged and kept** (ledger `lib/autofix-ship.sh:658`). A fix cut from a session's branch opens its PR into that branch. 3.10.7 handled the case where the session's PR merges and the branch is deleted, because GitHub then moves the fix PR to the default branch on its own. With `delete_branch_on_merge` off the branch stays, GitHub moves nothing, and the fix PR merged into a branch that had already landed, so the fix never reached the default branch. merge-when-green (and the `--guard` watcher for an armed auto-merge) now checks the PR's base: when it was merged into the default branch and its tip is still exactly the head that merged, the PR is retargeted to the default branch (`retargeted to ` in the log). After a squash merge the retargeted PR conflicts, and the 3.10.7 rebuild now also reads this manual base change, so only the fix's own change is rebuilt on the default branch. A landing branch with commits after its merge is still in use and stays the base (sessions reuse their branch). A retarget gh refuses stops the watch without merging; the guard arms auto-merge again after a retarget. +- **A fix PR no longer merges into a landing branch that was merged and kept** (ledger `lib/autofix-ship.sh:658`). A fix cut from a session's branch opens its PR into that branch. 3.10.7 handled the case where the session's PR merges and the branch is deleted, because GitHub then moves the fix PR to the default branch on its own. With `delete_branch_on_merge` off the branch stays, GitHub moves nothing, and the fix PR merged into a branch that had already landed, so the fix never reached the default branch. merge-when-green (and the `--guard` watcher for an armed auto-merge) now checks the PR's base: when it was merged into the default branch by a PR from this repo and its tip is still exactly the head that merged, the PR is retargeted to the default branch (`retargeted to ` in the log). The retargeted PR still carries the landing branch's own commits, and its checks ran against that branch, so it is then always brought up to date with the default branch, even when GitHub reports no conflict: after a squash merge only the fix's own change is rebuilt there (the 3.10.7 rebuild now also reads this manual base change), otherwise the default branch is merged in; either way checks run again before it merges. A landing branch with commits after its merge is still in use and stays the base (sessions reuse their branch), and so does a protected branch (a kept `develop` or release branch). A retarget gh refuses stops the watch without merging, and the guard also turns the PR's auto-merge off so GitHub cannot merge it into the old branch; after a retarget the guard arms auto-merge again. A failed default-branch lookup is tried again on the next poll. The 3.10.7 rebuild also ignores a fork PR merged from a branch of the same name. - **A sweep test no longer fails on SIGPIPE** (ledger `tests/autofix-sweep.bats:1067`). Test-only: `git log | grep -q` under `pipefail` could fail with status 141 when grep stopped at its first match; the log is now read into a variable first. ## [3.10.8] - 2026-10-27 diff --git a/README.md b/README.md index 971873f..590e57d 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ [![Claude Code plugin](https://img.shields.io/badge/Claude%20Code-plugin-orange.svg)](https://docs.claude.com/en/docs/claude-code/plugins) [![Codex plugin](https://img.shields.io/badge/Codex-plugin-black.svg)](AGENTS.md#installing-for-codex) -**7 lifecycle hooks · 14 slash commands · 2039 tests on Linux/macOS · zero manual bookkeeping** +**7 lifecycle hooks · 14 slash commands · 2044 tests on Linux/macOS · zero manual bookkeeping** ![demo](hero.gif) diff --git a/lib/autofix-ship.sh b/lib/autofix-ship.sh index 14da71a..e0aaff2 100644 --- a/lib/autofix-ship.sh +++ b/lib/autofix-ship.sh @@ -493,18 +493,36 @@ fi_af_merge_when_green() { [[ -n "$slug" ]] || slug="$(fi_repo_id 2>/dev/null || true)" [[ -n "$slug" ]] || { fi_err "autofix: merge-when-green needs a GitHub repo (--repo owner/name)"; return 1; } FI_AF_MWG_FIXES=0 FI_AF_MWG_DEFAULT="" - local lrc + local lrc landed=0 for (( i = 0; i < polls; i++ )); do v="$(gh pr view "$n" --repo "$slug" --json state,statusCheckRollup,mergeable,baseRefName,headRefName,headRefOid --jq "$jqf" 2>/dev/null || true)" IFS='|' read -r st ck mg ho br hr <<<"$v" || true [[ "$st $ck" == "OPEN fail" && "$mg" != CONFLICTING ]] || red_sha="" if [[ "$st" == OPEN ]]; then - lrc=0; _fi_af_mwg_landed "$n" "$slug" "$br" "$guard" || lrc=$? - (( lrc == 2 )) && return 1 - (( lrc == 0 )) && { nones=0; sleep "$pause"; continue; } + lrc=0; _fi_af_mwg_landed "$n" "$slug" "$br" || lrc=$? + if (( lrc == 2 )); then + # The guard's armed auto-merge would still merge into the dead branch. + (( guard )) && _fi_af_mwg_disarm "$n" "$slug" + return 1 + fi + (( lrc == 0 )) && { landed=1; nones=0; sleep "$pause"; continue; } fi - if [[ "$st" == OPEN && "$mg" == CONFLICTING ]]; then - _fi_af_mwg_unconflict "$n" "$slug" "$br" "$hr" || return 1 + # After a retarget the PR still carries the landing branch's own commits + # and checks run against that branch, so it is brought up to date with + # the default branch (rebuilt after a squash merge) even when GitHub + # reports no conflict. + if [[ "$st" == OPEN && ( "$mg" == CONFLICTING || "$landed" == 1 ) ]]; then + if ! _fi_af_mwg_unconflict "$n" "$slug" "$br" "$hr"; then + (( landed && guard )) && _fi_af_mwg_disarm "$n" "$slug" + return 1 + fi + if (( landed )); then + landed=0 + # A base change can drop an armed auto-merge; the guard arms it again. + if (( guard )) && ! gh pr merge "$n" --auto --squash --repo "$slug" >/dev/null 2>&1; then + fi_err "autofix: PR #$n: arming auto-merge again after the retarget failed — it will not merge on its own" + fi + fi nones=0; sleep "$pause"; continue fi case "$st $ck" in @@ -600,37 +618,49 @@ FI_AF_MWG_DEFAULT="" # merged into the default branch but kept (delete_branch_on_merge off), so # GitHub never retargeted the PR (sayciao #320 into gsd/phase-24-resume after # #318). When the branch's tip is still exactly the head that merged, the PR -# is retargeted to the default branch and the usual flow goes on: a conflict -# brings in _fi_af_mwg_squashed, which reads the manual base change. A branch -# with work after its merge stays the base (sayciao's moved on: a fix merged -# there reaches main with its next PR); a deleted one GitHub retargets itself. +# is retargeted to the default branch; the caller then brings it up to date +# (_fi_af_mwg_unconflict, whose _fi_af_mwg_squashed reads the manual base +# change and rebuilds after a squash merge). A branch with work after its +# merge stays the base (sayciao's moved on: a fix merged there reaches main +# with its next PR), and so does a protected one (a kept develop or release +# branch); a deleted one GitHub retargets itself. Fork PRs that share the +# branch name are ignored. # rc 0 = retargeted; rc 1 = nothing to do; rc 2 = the retarget failed. _fi_af_mwg_landed() { - local n="$1" slug="$2" base="$3" guard="${4:-0}" def m oid tip enc + local n="$1" slug="$2" base="$3" def m oid tip enc [[ -n "$base" ]] || return 1 if [[ -z "$FI_AF_MWG_DEFAULT" ]]; then + # Only a successful lookup is kept: a failed one is tried on the next poll. FI_AF_MWG_DEFAULT="$(gh repo view "$slug" --json defaultBranchRef --jq '.defaultBranchRef.name // ""' 2>/dev/null || true)" - [[ -n "$FI_AF_MWG_DEFAULT" ]] || FI_AF_MWG_DEFAULT="-" # unknown: no retarget, no second lookup + [[ -n "$FI_AF_MWG_DEFAULT" ]] || return 1 fi def="$FI_AF_MWG_DEFAULT" - [[ "$def" != "-" && "$base" != "$def" ]] || return 1 - m="$(gh pr list --repo "$slug" --head "$base" --base "$def" --state merged --limit 1 --json number,headRefOid,baseRefName \ - --jq '.[0] // {} | "\(.number // "")|\(.headRefOid // "")|\(.baseRefName // "")"' 2>/dev/null || true)" + [[ "$base" != "$def" ]] || return 1 + m="$(gh pr list --repo "$slug" --head "$base" --base "$def" --state merged --limit 5 --json number,headRefOid,baseRefName,isCrossRepository \ + --jq '[.[] | select(.isCrossRepository | not)][0] // {} | "\(.number // "")|\(.headRefOid // "")|\(.baseRefName // "")"' 2>/dev/null || true)" [[ "${m##*|}" == "$def" ]] || return 1 m="${m%|*}" oid="${m#*|}" m="${m%%|*}" [[ "$m" =~ ^[0-9]+$ && "$oid" =~ ^[0-9a-f]{40}$ ]] || return 1 enc="$(jq -rn --arg b "$base" '$b | @uri')" - tip="$(gh api "repos/$slug/branches/$enc" --jq '.commit.sha // ""' 2>/dev/null || true)" - [[ "$tip" == "$oid" ]] || return 1 + tip="$(gh api "repos/$slug/branches/$enc" --jq '"\(.commit.sha // "")|\(.protected // false)"' 2>/dev/null || true)" + [[ "${tip#*|}" == false && "${tip%%|*}" == "$oid" ]] || return 1 if ! gh pr edit "$n" --repo "$slug" --base "$def" >/dev/null 2>&1; then fi_err "autofix: PR #$n: its landing branch $base was merged into $def (PR #$m), and retargeting it to $def failed — not merging"; return 2 fi printf 'PR #%s: its landing branch %s was merged into %s (PR #%s) with nothing since; retargeted to %s\n' "$n" "$base" "$def" "$m" "$def" - # A base change can drop an armed auto-merge; the guard arms it again. - if (( guard )); then gh pr merge "$n" --auto --squash --repo "$slug" >/dev/null 2>&1 || true; fi return 0 } +# Turn off 's armed auto-merge when the guard gives up on a PR that would +# otherwise merge where it should not. +_fi_af_mwg_disarm() { + if gh pr merge "$1" --disable-auto --repo "$2" >/dev/null 2>&1; then + fi_err "autofix: PR #$1: auto-merge turned off" + else + fi_err "autofix: PR #$1: turning auto-merge off failed — it may still merge into its old base" + fi +} + # Squash-merge ; a refusal's message is kept in FI_AF_MWG_ERR. _fi_af_mwg_merge() { local n="$1" slug="$2" @@ -769,8 +799,8 @@ _fi_af_mwg_squashed() { [[ -n "$old" && "$old" != "$base" && "${ev#*|}" == "$base" ]] || return 1 # Only the PR that merged that branch into this base (a reused name or a # fork's branch of the same name is another PR). - m="$(gh pr list --repo "$slug" --head "$old" --base "$base" --state merged --limit 1 --json number,headRefOid,baseRefName \ - --jq '.[0] // {} | "\(.number // "")|\(.headRefOid // "")|\(.baseRefName // "")"' 2>/dev/null || true)" + m="$(gh pr list --repo "$slug" --head "$old" --base "$base" --state merged --limit 5 --json number,headRefOid,baseRefName,isCrossRepository \ + --jq '[.[] | select(.isCrossRepository | not)][0] // {} | "\(.number // "")|\(.headRefOid // "")|\(.baseRefName // "")"' 2>/dev/null || true)" [[ "${m##*|}" == "$base" ]] || return 1 m="${m%|*}" oid="${m#*|}" m="${m%%|*}" [[ "$m" =~ ^[0-9]+$ && "$oid" =~ ^[0-9a-f]{40}$ ]] || return 1 diff --git a/tests/autofix-merge-landed-base.bats b/tests/autofix-merge-landed-base.bats index 0ae8b13..7081d43 100644 --- a/tests/autofix-merge-landed-base.bats +++ b/tests/autofix-merge-landed-base.bats @@ -22,12 +22,12 @@ printf '%s\n' "$*" >>"$GH_MOCK_TRACE" jq_filter=""; prev="" for a in "$@"; do [[ "$prev" == --jq ]] && jq_filter="$a"; prev="$a"; done base="$(cat "$LB_BASE_FILE")" -if git -C "$LB_REMOTE" merge-base --is-ancestor "$base" "$LB_HEAD" 2>/dev/null; then m=MERGEABLE; else m=CONFLICTING; fi +if [[ -n "${LB_MERGEABLE:-}" ]] || git -C "$LB_REMOTE" merge-base --is-ancestor "$base" "$LB_HEAD" 2>/dev/null; then m=MERGEABLE; else m=CONFLICTING; fi case "$1 $2" in "pr view") printf '{"state":"OPEN","mergeable":"%s","baseRefName":"%s","headRefName":"%s","statusCheckRollup":[]}' "$m" "$base" "$LB_HEAD" | jq -r "$jq_filter" ;; "pr merge") - [[ " $* " == *" --auto "* ]] && exit 0 + [[ " $* " == *" --auto "* || " $* " == *" --disable-auto "* ]] && exit 0 [[ "$m" == CONFLICTING ]] && { echo "GraphQL: Pull Request has merge conflicts (mergePullRequest)" >&2; exit 1; } exit 0 ;; "pr edit") @@ -35,10 +35,14 @@ case "$1 $2" in nb=""; prev=""; for a in "$@"; do [[ "$prev" == --base ]] && nb="$a"; prev="$a"; done printf '%s' "$nb" >"$LB_BASE_FILE" ;; "repo view") + if [[ -n "${LB_REPO_VIEW_FAIL_ONCE:-}" && ! -e "$LB_REPO_VIEW_FAIL_ONCE" ]]; then + : >"$LB_REPO_VIEW_FAIL_ONCE"; echo "gh: HTTP 502" >&2; exit 1 + fi printf '{"defaultBranchRef":{"name":"main"}}' | jq -r "$jq_filter" ;; "pr list") if [[ " $* " == *" --head phase "* && -z "${LB_NO_MERGED:-}" ]]; then - printf '[{"number":5,"headRefOid":"%s","baseRefName":"main"}]' "$LB_OLD_TIP" | jq -r "$jq_filter" + fork='' ; [[ -n "${LB_FORK:-}" ]] && fork='{"number":9,"headRefOid":"0000000000000000000000000000000000000009","baseRefName":"main","isCrossRepository":true},' + printf '[%s{"number":5,"headRefOid":"%s","baseRefName":"main","isCrossRepository":false}]' "$fork" "$LB_OLD_TIP" | jq -r "$jq_filter" else printf '[]' | jq -r "$jq_filter"; fi ;; "api graphql") if [[ "$base" == main ]]; then nodes='[{"previousRefName":"phase","currentRefName":"main"}]'; else nodes='[]'; fi @@ -47,7 +51,7 @@ case "$1 $2" in ep=""; for a in "${@:2}"; do case "$a" in -*|.*) ;; *) [[ -z "$ep" ]] && ep="$a" ;; esac; done b="${ep#repos/foo/bar/branches/}"; b="${b//%2F//}" sha="$(git -C "$LB_REMOTE" rev-parse --verify -q "refs/heads/$b")" || { echo "gh: Not Found (HTTP 404)" >&2; exit 1; } - printf '{"commit":{"sha":"%s"}}' "$sha" | jq -r "$jq_filter" ;; + printf '{"commit":{"sha":"%s"},"protected":%s}' "$sha" "${LB_PROTECTED:-false}" | jq -r "$jq_filter" ;; *) exit 1 ;; esac EOF @@ -141,3 +145,48 @@ teardown() { fi_teardown_tmp; } ! grep -q '^pr merge 7 --squash --repo foo/bar$' "$GH_MOCK_TRACE" || false [ "$(git -C "$TMP/remote.git" rev-parse "$LB_HEAD~1")" = "$(git -C "$TMP/remote.git" rev-parse main)" ] } + +@test "landed base: the guard turns auto-merge off when the retarget is refused" { + export LB_EDIT_FAIL=1 FOUND_ISSUES_AUTOFIX_GUARD_POLLS=3 + run "$FI_BIN" autofix merge-when-green 7 --repo foo/bar --guard + [ "$status" -eq 1 ] + [[ "$output" == *"retargeting it to main failed"* ]] + [[ "$output" == *"auto-merge turned off"* ]] + grep -q '^pr merge 7 --disable-auto --repo foo/bar$' "$GH_MOCK_TRACE" +} + +@test "landed base: a retargeted PR GitHub calls mergeable is still rebuilt on main before it merges" { + export LB_MERGEABLE=1 + run "$FI_BIN" autofix merge-when-green 7 --repo foo/bar + [ "$status" -eq 0 ] + [[ "$output" == *"retargeted to main"* ]] + [[ "$output" == *"squash-merged"* ]] + [ "$(git -C "$TMP/remote.git" rev-parse "$LB_HEAD~1")" = "$(git -C "$TMP/remote.git" rev-parse main)" ] + [ "$(git -C "$TMP/remote.git" diff --name-only main "$LB_HEAD" | sort | tr '\n' ' ')" = "docs/found-issues.md src/a.sh " ] + grep -q '^pr merge 7 --squash --repo foo/bar$' "$GH_MOCK_TRACE" +} + +@test "landed base: a protected landing branch stays the base" { + export LB_PROTECTED=true + run "$FI_BIN" autofix merge-when-green 7 --repo foo/bar + [ "$status" -eq 0 ] + ! grep -q '^pr edit' "$GH_MOCK_TRACE" || false + [ "$(cat "$LB_BASE_FILE")" = phase ] +} + +@test "landed base: a fork PR merged from a branch of the same name is ignored" { + export LB_FORK=1 + run "$FI_BIN" autofix merge-when-green 7 --repo foo/bar + [ "$status" -eq 0 ] + [[ "$output" == *"(PR #5) with nothing since; retargeted to main"* ]] + [[ "$output" == *"squash-merged into main (PR #5)"* ]] + grep -q '^pr merge 7 --squash --repo foo/bar$' "$GH_MOCK_TRACE" +} + +@test "landed base: a failed default-branch lookup is tried again on the next poll" { + export LB_REPO_VIEW_FAIL_ONCE="$TMP/repo-view-failed" + run "$FI_BIN" autofix merge-when-green 7 --repo foo/bar + [ "$status" -eq 0 ] + [ "$(grep -c '^repo view' "$GH_MOCK_TRACE")" -eq 2 ] + [[ "$output" == *"retargeted to main"* ]] +} From da85306daa46e0cf160a5378077192a2d08881ec Mon Sep 17 00:00:00 2001 From: Diogo Silva Sena <227921880+DougBTW@users.noreply.github.com> Date: Tue, 27 Oct 2026 05:34:21 -0400 Subject: [PATCH 5/5] docs(found-issues): annotate lib/autofix-ship.sh:658 and tests/autofix-sweep.bats:1067 with #292 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MVx3HTvihe7tJycZ7B7mjC --- docs/found-issues.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/found-issues.md b/docs/found-issues.md index ae39e6d..0895b5d 100644 --- a/docs/found-issues.md +++ b/docs/found-issues.md @@ -58,5 +58,5 @@ Maintained automatically by Claude. See with git rebase --onto origin/ and force-push the fi/ branch before re-checking the ledger diff) (fix: medium) (PR: AltDoug/found-issues#287) (fixed: 2026-10-26) - [fixed] 2026-10-27 lib/autofix-ship.sh:511 — merge-when-green gives up on the first failed check ('checks failed — not merging') and nothing re-runs it, so a one-off CI infrastructure failure strands the fix PR for good; the message reaches only spawn.log while the item's run log says 'shipped'. Seen 2026-10-24 on ByteTechSoftwares/sayciao#320: supabase-legs failed on 'toomanyrequests: Data limit exceeded' pulling ECR images, app/zapier/static green, PR still open 2026-10-27 (suggested: on the first 'fail' re-run the head's failed GitHub Actions runs once with gh run rerun --failed and keep polling; a second fail stops as now; the guard does the same for an armed auto-merge) (fix: medium) (PR: AltDoug/found-issues#290) (fixed: 2026-10-27) -- [open] 2026-10-27 lib/autofix-ship.sh:658 — the 3.10.7 squash rebuild runs only after GitHub's AutomaticBaseChangeSucceededEvent, which needs the landing branch deleted; in a repo with delete_branch_on_merge off the landing branch survives its squash merge, so a fix PR still merges into that dead branch and never reaches the default branch. Seen on ByteTechSoftwares/sayciao#320 (base gsd/phase-24-resume squash-merged into main as #318 on 2026-10-25, branch kept, 49 ahead / 6 behind main) (suggested: before merging, if the base has a merged PR into the default branch whose squash is not an ancestor-carrier of the base tip, retarget with gh pr edit --base and reuse _fi_af_mwg_squashed with that PR as #M) (decide: When a fix PR's landing branch was squash-merged into the default branch but not deleted [no GitHub retarget], should merge-when-green retarget the PR to the default branch and rebuild it there, or stop and report?) -- [open] 2026-10-27 tests/autofix-sweep.bats:1067 — 'a continuation's PR leaves the PR branch ledger alone' flaked on the Mac 2026-10-27 with status 141: git log ... | grep -q under pipefail, grep -q exits on the first match and git log dies of SIGPIPE (suggested: capture the log into a variable first, then grep it, as other tests do) (fix: small) +- [open] 2026-10-27 lib/autofix-ship.sh:658 — the 3.10.7 squash rebuild runs only after GitHub's AutomaticBaseChangeSucceededEvent, which needs the landing branch deleted; in a repo with delete_branch_on_merge off the landing branch survives its squash merge, so a fix PR still merges into that dead branch and never reaches the default branch. Seen on ByteTechSoftwares/sayciao#320 (base gsd/phase-24-resume squash-merged into main as #318 on 2026-10-25, branch kept, 49 ahead / 6 behind main) (suggested: before merging, if the base has a merged PR into the default branch whose squash is not an ancestor-carrier of the base tip, retarget with gh pr edit --base and reuse _fi_af_mwg_squashed with that PR as #M) (decide: When a fix PR's landing branch was squash-merged into the default branch but not deleted [no GitHub retarget], should merge-when-green retarget the PR to the default branch and rebuild it there, or stop and report?) (PR: AltDoug/found-issues#292) +- [open] 2026-10-27 tests/autofix-sweep.bats:1067 — 'a continuation's PR leaves the PR branch ledger alone' flaked on the Mac 2026-10-27 with status 141: git log ... | grep -q under pipefail, grep -q exits on the first match and git log dies of SIGPIPE (suggested: capture the log into a variable first, then grep it, as other tests do) (fix: small) (PR: AltDoug/found-issues#292)