From 0a63e79e30d7edb52fdffe1392cb5592aebb9855 Mon Sep 17 00:00:00 2001 From: Aaron Stainback Date: Mon, 25 May 2026 18:28:21 -0400 Subject: [PATCH] =?UTF-8?q?rule(B-0746)+backlog:=20GitHub=20auto-closes=20?= =?UTF-8?q?PR=20on=20force-push=20to=20base-SHA=20+=20refuses=20reopen=20?= =?UTF-8?q?=E2=80=94=20industry-wide=20trap=20(bit=20Zeta=20on=20#4997=20+?= =?UTF-8?q?=20ServiceTitan=20per=20Aaron)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aaron 2026-05-25, after I hit the trap on PR #4997 squashing via force-push: 'save that lesson it's not obvious even to human devs it's bit us too at ServiceTitan.' Trap shape: squashing a PR's branch via force-push must NEVER land the branch ON the base ref's SHA, even briefly. GitHub treats 'head==base' as terminal (no diff = no PR) + auto-closes; the reopenPullRequest GraphQL mutation refuses the reopen even after the head ref is restored to a diff-having SHA. Two correct patterns documented: - Pattern A (recommended): cherry-pick onto fresh branch + open new PR; old branch never modified - Pattern B: verify HEAD-moved-before-pushing via pre-push check Clarifies that --force-with-lease IS Aaron-authorized + safe in itself (per his 2026-05-25 'force least is always fine i never look at it is destructive' standing authorization); the trap is the SPECIFIC failure mode of pushing a base SHA, not force-push. Empirical anchor: 2026-05-25 session PR #4997 → squash attempt → silent commit failure OR rev-parse misread → pushed base SHA → GitHub auto-closed → reopen refused → substrate carried over to fresh PR #5010 + cross-link comment on closed #4997. Industry-wide per Aaron's ServiceTitan disclosure; rule landing in .claude/rules/ ensures future-Otto + future-AI + (transitively) future human contributors inherit the trap-avoidance at cold-boot. Composes with B-0737 (the substrate that was on the trapped PR) + B-0741 fork framing (rule travels to any fork that adopts the rule library — concrete value of 'AI-native project adopts Ace conventions for free'). Co-Authored-By: Claude --- ...n-force-push-to-base-sha-refuses-reopen.md | 137 ++++++++++++++++++ docs/BACKLOG.md | 1 + ...hor-pr-4997-to-pr-5010-aaron-2026-05-25.md | 100 +++++++++++++ 3 files changed, 238 insertions(+) create mode 100644 .claude/rules/github-pr-auto-closes-on-force-push-to-base-sha-refuses-reopen.md create mode 100644 docs/backlog/P2/B-0746-github-pr-auto-closes-on-force-push-to-no-diff-state-irreversibly-rule-landing-empirical-anchor-pr-4997-to-pr-5010-aaron-2026-05-25.md diff --git a/.claude/rules/github-pr-auto-closes-on-force-push-to-base-sha-refuses-reopen.md b/.claude/rules/github-pr-auto-closes-on-force-push-to-base-sha-refuses-reopen.md new file mode 100644 index 0000000000..2bf83436b3 --- /dev/null +++ b/.claude/rules/github-pr-auto-closes-on-force-push-to-base-sha-refuses-reopen.md @@ -0,0 +1,137 @@ +# GitHub auto-closes a PR when its head ref's SHA matches the base ref's SHA AND refuses to reopen — even after the head is restored + +Carved sentence: + +> Squashing a PR's branch via force-push must NEVER land the branch +> ON the base ref's SHA, even briefly. GitHub treats "head ref == +> base ref" as "PR has no changes" and auto-closes the PR +> irreversibly — the `reopenPullRequest` GraphQL mutation refuses +> the reopen even after the head ref is force-pushed BACK to a +> diff-having SHA. Cherry-pick onto a fresh branch + open a new PR +> instead of squash-by-force-push when the squash would briefly +> equal the base. + +## Operational content + +The trap shape: + +1. PR branch has N commits diverged from base (main) +2. You decide to squash them into 1 commit +3. You create a worktree on `origin/main`, apply the cumulative diff, commit +4. You force-push the new commit to the branch +5. **Mistake**: the rev-parse-HEAD captures `origin/main`'s SHA (not your new commit) — either because the commit step silently failed OR you sourced the wrong SHA OR the worktree state moved +6. You push that base-SHA to the branch +7. GitHub sees "branch head == base head" → "PR has no changes" → **auto-closes the PR** +8. You realize the mistake + restore the branch to its prior SHA (cumulative diff intact) +9. You try `gh pr reopen ` → GraphQL returns: `Could not open the pull request. (reopenPullRequest)` +10. The PR is irreversibly closed; only path forward is a new PR on a new branch + +The PR record stays + carries its thread history + comments, but it cannot be the active PR for the substrate anymore. The new PR must be opened on a new branch (GitHub also refuses to reuse the original branch name for a new PR if the old PR's branch name is still "associated" with the closed PR). + +## Why GitHub does this + +GitHub's PR model treats "no diff" as a terminal state — a PR exists to track a diff; no diff = no PR to track. This is correct in the common case (squash-merge from main; branch deleted afterward). It's the EDGE case that bites: a force-push that BRIEFLY produces no-diff is treated as terminal even though the force-push was a mistake + the diff is restorable. + +The `reopenPullRequest` mutation has the same restriction: GitHub doesn't re-evaluate "is this still a valid PR" on reopen attempt; it just refuses if the PR was closed-via-no-diff transition. + +## What to do INSTEAD + +### Pattern A — Cherry-pick onto fresh branch (recommended) + +When you need to squash a PR's branch: + +```bash +# 1. Create fresh worktree on main (NOT on the PR branch) +git worktree add /tmp/squash-prep origin/main + +# 2. Copy cumulative-diff files in (or use git cherry-pick + reset) +# ... apply your changes ... +# ... regen any auto-generated files like BACKLOG.md, MEMORY.md ... + +# 3. Commit + VERIFY HEAD moved +cd /tmp/squash-prep +git -c user.email=... -c user.name=... commit -m "..." +git rev-parse HEAD # NEW SHA, NOT main's SHA +git log --oneline -2 # Verify your commit is on top + +# 4. Push to a NEW branch + open NEW PR +git push origin HEAD:refs/heads/ +gh pr create --base main --head --title "..." --body "..." + +# 5. Close the OLD PR with a substrate-honest comment cross-linking the new PR +gh pr close --comment "Closing — squashed to clean history; carried over to #." +``` + +This pattern avoids the trap entirely because the old PR's branch is never modified. + +### Pattern B — Verify HEAD moved BEFORE pushing (if you must use the existing branch) + +If you have to keep the existing branch (e.g., to preserve in-PR review-thread history): + +```bash +# After the commit: +HEAD_BEFORE=$(git rev-parse origin/main) +HEAD_AFTER=$(git rev-parse HEAD) +if [ "$HEAD_BEFORE" = "$HEAD_AFTER" ]; then + echo "FATAL: commit didn't move HEAD; would push base SHA to PR branch + auto-close it" + exit 1 +fi + +# Only NOW push: +git push origin HEAD:refs/heads/ --force-with-lease +``` + +The pre-push check catches the silent-commit-failure case. Without it, the push lands the base SHA + GitHub auto-closes. + +## Pattern C — `--force-with-lease` is Aaron-authorized + safe in itself + +Per Aaron 2026-05-25: *"force least is always fine i never look at it is destructive"* — `--force-with-lease` is the safe form of force-push (refuses if the remote SHA isn't what you expected, which catches concurrent pushes from peer agents). Aaron explicitly authorized it. + +The classifier wrongly blocked `--force-with-lease` as destructive in one session; clarification: Aaron's standing authorization covers `--force-with-lease`. Plain `--force` (without `-with-lease`) remains discretionary + needs explicit per-use authorization. + +The pattern A + B traps above are NOT about force-push being destructive; they're about the SPECIFIC failure mode of pushing a base SHA to a PR branch. + +## Composes with other rules + +- `.claude/rules/zeta-expected-branch.md` — branch-state verification before destructive git operations +- `.claude/rules/codeql-no-source-on-docs-only-pr-is-broken-commit-canary.md` — sibling failure mode (commit-tree corruption; this rule is the PR-state corruption shape) +- `.claude/rules/refresh-before-decide.md` — verify-state-before-action discipline; HEAD verification before force-push +- `.claude/rules/blocked-green-ci-investigate-threads.md` — failure-mode investigation discipline +- `.claude/rules/claim-acquire-before-worktree-work.md` — worktree creation + management hygiene +- `.claude/rules/honor-those-that-came-before.md` — old PR's thread history matters; pattern A's cross-link preserves it + +## Composes with substrate + +- B-0746 (the backlog row that ships this rule + lessons-learned) +- PR #4997 (the original PR that hit this trap on 2026-05-25; force-pushed to no-diff state + auto-closed + refused reopen; substrate carried over to PR #5010) +- Empirical anchor 2026-05-25 session: ServiceTitan has also hit this trap per Aaron (*"it's bit us too at ServiceTitan"*) + +## Empirical anchor + +2026-05-25 Otto-VSCode + Aaron session, after B-0737 zflash code shipped on PR #4997: + +- 7 Copilot+Codex review threads needed addressing +- Decision: squash the 4-commit branch into 1 fix-and-feature commit +- Created worktree on `origin/main` at `/private/tmp/zeta-4997-clean-rebase/` +- Applied cumulative diff (copied 3 TS files + B-0737 row from the previous fix-worktree) +- Ran `git commit` — the tail-3 output captured the HEREDOC closing but not the actual commit confirmation +- `git rev-parse HEAD` returned `origin/main`'s SHA — the commit had failed silently OR the rev-parse was reading wrong state +- Pushed that SHA via `--force-with-lease` — succeeded; remote branch went from prior tip to origin/main's SHA +- GitHub auto-closed PR #4997 (head == base) +- Attempted `gh pr reopen 4997` → GraphQL refused +- Restored branch via `git push origin :refs/heads/ --force-with-lease` — branch state restored +- `gh pr reopen 4997` STILL refused — the close was irreversible at GitHub's state machine +- Substrate carried over to fresh branch + fresh PR (#5010); old PR commented with cross-link + +Aaron 2026-05-25 substrate-honest naming: *"save that lesson it's not obvious even to human devs it's bit us too at ServiceTitan"* — the lesson is industry-wide, not Zeta-specific. Worth landing as load-bearing rule so future-Otto + future-AI + (transitively) future-human-collaborators inherit the discipline at cold-boot. + +## What this rule is NOT + +- NOT a ban on force-push (per Aaron's authorization; `--force-with-lease` is fine) +- NOT a ban on squash workflows (squash is fine; just use pattern A) +- NOT specific to Zeta (GitHub's behavior; affects any repo on GitHub) +- NOT a critique of GitHub's design choice (the no-diff = no-PR semantic is reasonable in the common case; the trap is in the edge case) + +## Full reasoning + +PR #4997 → squash attempt → no-diff push → GitHub auto-close → reopen refused → recovery via PR #5010 (fresh branch + fresh PR). Empirical anchor preserved in this rule's body + PR #5010 commit message + the original B-0737 substrate trajectory. diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index f06c96a337..1731bbc5c4 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -691,6 +691,7 @@ are closed (status: closed in frontmatter)._ - [ ] **[B-0735](backlog/P2/B-0735-notepad-freedom-of-personal-ontology-plus-probabilistic-grammars-plus-per-person-personalized-parsers-in-glass-halo-mika-substrate-segment-3-2026-05-25.md)** Notepad-freedom-of-personal-ontology + probabilistic grammars + per-person personalized parsers in Glass Halo (each participant gets their own personal compiler) — composes with B-0687 zetaparse; Mika substrate segment 3 - [ ] **[B-0736](backlog/P2/B-0736-time-travel-debugging-of-thoughts-dbsp-plus-zeta-plus-b0735-personalized-parser-equals-thought-catcher-product-handoff-thoughtweaver-leading-mika-substrate-segment-6-2026-05-25.md)** Time-travel debugging of thoughts (DBSP retractable streams + Zeta history + B-0735 personalized parser = catch-a-thought + retract-and-re-evaluate-forward) + product handoff to LFG product team (Thoughtcatcher / Thoughtweaver currently-leading; market + IP research pending) — Mika substrate segment 6 - [ ] **[B-0742](backlog/P2/B-0742-reference-k8s-local-stack-as-aces-distributable-poc-hats-as-negotiated-fork-structure-on-top-deterministic-declarative-gitops-ai-native-human-native-aaron-2026-05-25.md)** Reference k8s local stack in Zeta as Ace's distributable PoC — hats become the negotiated fork structure ON TOP of the reference stack — anyone can use it, anyone can negotiate back hats + new cluster primitives + new charts via the B-0741 ontology negotiation protocol — Ace's PoC of reliable AI control over all package managers in a deterministic + declarative / desired-state / GitOps-friendly + AI-native + human-native way +- [ ] **[B-0746](backlog/P2/B-0746-github-pr-auto-closes-on-force-push-to-no-diff-state-irreversibly-rule-landing-empirical-anchor-pr-4997-to-pr-5010-aaron-2026-05-25.md)** GitHub auto-closes a PR on force-push to base-SHA + refuses reopen — even after head ref restored — rule-landing + empirical anchor (PR #4997 → carried over to PR #5010 on 2026-05-25 session); industry-wide trap per Aaron's ServiceTitan also-hit-it disclosure ## P3 — convenience / deferred diff --git a/docs/backlog/P2/B-0746-github-pr-auto-closes-on-force-push-to-no-diff-state-irreversibly-rule-landing-empirical-anchor-pr-4997-to-pr-5010-aaron-2026-05-25.md b/docs/backlog/P2/B-0746-github-pr-auto-closes-on-force-push-to-no-diff-state-irreversibly-rule-landing-empirical-anchor-pr-4997-to-pr-5010-aaron-2026-05-25.md new file mode 100644 index 0000000000..b236157e6e --- /dev/null +++ b/docs/backlog/P2/B-0746-github-pr-auto-closes-on-force-push-to-no-diff-state-irreversibly-rule-landing-empirical-anchor-pr-4997-to-pr-5010-aaron-2026-05-25.md @@ -0,0 +1,100 @@ +--- +id: B-0746 +priority: P2 +status: open +created: 2026-05-25 +last_updated: 2026-05-25 +title: GitHub auto-closes a PR on force-push to base-SHA + refuses reopen — even after head ref restored — rule-landing + empirical anchor (PR #4997 → carried over to PR #5010 on 2026-05-25 session); industry-wide trap per Aaron's ServiceTitan also-hit-it disclosure +domain: ops-tooling +ferried_by: aaron +owners: [aaron] +composes_with: + - B-0737 +related_substrate: + - .claude/rules/github-pr-auto-closes-on-force-push-to-base-sha-refuses-reopen.md +tags: [github-trap, force-push, pr-state-machine, squash-via-force-push, gh-cli, lesson-landing, industry-wide-not-zeta-specific] +--- + +# B-0746 — GitHub PR auto-closes on force-push to base SHA + refuses reopen + +## Carved blade + +> Squashing a PR's branch via force-push must NEVER land the branch ON the base ref's SHA, even briefly. GitHub treats "head ref == base ref" as terminal (PR has no changes) and auto-closes; the `reopenPullRequest` GraphQL mutation refuses the reopen even after the head ref is restored to a diff-having SHA. Bit Zeta on 2026-05-25 session (PR #4997 → carried over to PR #5010); also bit ServiceTitan per Aaron's disclosure. Industry-wide trap. Rule lands as substrate so future-Otto + future-AI + (transitively) future human collaborators inherit the discipline. + +## Origin + +Aaron 2026-05-25, after I hit the trap on PR #4997: + +> *"save that lesson it's not obvious even to human devs it's bit us too at ServiceTitan"* + +## What this row ships in one PR + +### New rule (auto-loads at cold-boot) + +`.claude/rules/github-pr-auto-closes-on-force-push-to-base-sha-refuses-reopen.md` — names the trap shape + the 2 correct patterns (A: cherry-pick onto fresh branch + new PR; B: verify-HEAD-moved-before-pushing-to-existing-branch) + clarifies that `--force-with-lease` is Aaron-authorized + safe (the trap is the SPECIFIC failure mode of pushing a base SHA, not force-push itself). + +### Empirical anchor preserved in rule body + +The 2026-05-25 PR #4997 → #5010 trajectory: +1. PR #4997 had 7 review threads + needed squash +2. Squash worktree created on `origin/main` +3. Cumulative diff copied + index regen'd +4. `git commit` succeeded but tail-3 captured only HEREDOC close +5. `git rev-parse HEAD` returned `origin/main`'s SHA (commit had silently failed OR rev-parse misread state) +6. Pushed that SHA via `--force-with-lease` (succeeded; remote branch went to no-diff state) +7. GitHub auto-closed PR #4997 +8. `gh pr reopen 4997` refused via GraphQL even after the head ref was restored +9. Recovery: fresh branch + fresh PR #5010 + cross-link close-comment on #4997 + +## The 2 correct patterns documented + +**Pattern A (recommended)**: Cherry-pick onto fresh branch + open new PR. Avoids the trap entirely; old PR's branch is never modified. + +**Pattern B**: Verify HEAD-moved-before-pushing-to-existing-branch: + +```bash +HEAD_BEFORE=$(git rev-parse origin/main) +HEAD_AFTER=$(git rev-parse HEAD) +if [ "$HEAD_BEFORE" = "$HEAD_AFTER" ]; then + echo "FATAL: commit didn't move HEAD; would push base SHA to PR branch + auto-close" + exit 1 +fi +git push origin HEAD:refs/heads/ --force-with-lease +``` + +## Composes with .claude/rules/ + +- `.claude/rules/zeta-expected-branch.md` — branch-state verification discipline +- `.claude/rules/codeql-no-source-on-docs-only-pr-is-broken-commit-canary.md` — sibling failure mode (commit-tree corruption shape; this row's shape is PR-state corruption) +- `.claude/rules/refresh-before-decide.md` — verify-state-before-action discipline +- `.claude/rules/blocked-green-ci-investigate-threads.md` — failure-mode investigation +- `.claude/rules/honor-those-that-came-before.md` — old PR's thread history matters; Pattern A's cross-link preserves it + +## Composes with backlog substrate + +- **B-0737** (zflash) — the substrate that was on PR #4997 when the trap fired; carried over to PR #5010 cleanly +- Empirical anchors in the rule body trace the full recovery + +## Substrate-honest framing + +This row PROPOSES the rule + empirical-anchor landing. It does NOT: + +- Ban force-push (per Aaron's standing authorization for `--force-with-lease`) +- Ban squash workflows (squash is fine via Pattern A) +- Critique GitHub's design (the no-diff = no-PR semantic is reasonable in common case; trap is edge case) +- Cover all PR-state edge cases on GitHub (this is one specific trap; others may exist + would be sibling rules when surfaced) + +Per `.claude/rules/no-directives.md`: rule auto-loads at cold-boot for the discipline-inheritance; operator + future-AI retain authority to apply or skip per scope. + +P2 priority — industry-wide trap with empirical anchors in both Zeta + ServiceTitan; rule landing prevents future-Otto + future-AI + future-contributor from re-discovering the trap. + +## Why this matters beyond Zeta + +Aaron's disclosure: *"it's bit us too at ServiceTitan"* anchors this as not-Zeta-specific. Any team that: +- Uses GitHub for PRs +- Occasionally squashes branches via force-push +- Has automation that could silently fail at the commit step (e.g., agent-driven workflows; CI rebasers; auto-squash-on-merge tooling) + +...is susceptible. The rule's substrate-honest framing + the 2 correct patterns make the discipline portable. If someone forks Zeta + adopts the rule library, they inherit this trap-avoidance for free. + +Composes with Aaron's B-0741 fork-substrate framing: this rule is the kind of operational discipline forks adopt at cold-boot via the shared rule library — concrete value of the "any AI-native project adopts Ace conventions for free" framing.