Claude Skill

git-workflow

Use when establishing branching strategies, implementing Conventional Commits, creating or reviewing PRs, resolving PR review comments, merging PRs (including CI verification, auto-merge queues, and post-merge cleanup), managing PR review threads, merging PRs with signed commits,

LLM Mart · 0 points · 0 views 21 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download netresearch-git-workflow-skill-skills_git-workflow-1dc9b7f.zip · 248 KB

Install

skills CLI npx skills add https://github.com/netresearch/git-workflow-skill/tree/main/skills/git-workflow
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install netresearch-git-workflow-skill@llmmart
Git git clone https://github.com/netresearch/git-workflow-skill.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole netresearch/git-workflow-skill collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Git Workflow Skill

Not Here

Releases: github-release. BLOCKED-PR diagnosis: github-project.

GitHub only. Everything below the branching and commit sections — pr-status.sh, pr-merge.sh, the merge gate, review threads — speaks GitHub GraphQL. For a GitLab merge request the equivalent glab calls are in references/pull-request-workflow.md § GitLab: the same gate with glab.

Critical Rules (Non-Negotiable)

  1. No direct push to main — always open a PR.
  2. No merge before all threads resolved (references/pull-request-workflow.md).
  3. No squash unless asked — preserves atomic commits, signatures, bisection.
  4. No "tested/verified/working" without pasted command output — else say so.
  5. No edits to installed skill/plugin cache paths (~/.claude/skills/, ~/.claude/plugins/cache/, **/.bare/**) — always the repo worktree, verified by pwd.
  6. Force-push only with --force-with-lease — never plain --force.
  7. Commit before rebase — add → commit → fetch → rebase → push (a dirty tree aborts it).
  8. No editorializing — state what changed, not how good it is (references/no-editorializing.md).

Reference Files

Reference Content Triggers
references/commit-conventions.md Conventional commits, DCO sign-off
references/pull-request-workflow.md PR merge gate, signed rebase
references/ci-cd-integration.md CI watching, git mirrors
references/advanced-git.md Rebase, cherry-pick, bisect, stash, worktrees, reflog
references/github-releases.md → github-release skill
references/git-hooks-setup.md Hook frameworks, hooks per stage
references/claude-code-hooks.md settings.json merge gate, cache-path rejection, auto-lint
references/code-quality-tools.md shellcheck, shfmt, git-absorb, difftastic
references/merge-gate-watcher.md Waiting on a gate, a watcher the harness killed, merge-driver loop, check taxonomy, stale-SHA rerun
references/spec-cleanup.md Planning artifacts off the base branch
references/no-editorializing.md No self-praise, no narrating the expected

Conventional Commits

<type>[scope]: <description>

feat MINOR, fix PATCH; full type list and DCO sign-off in references/commit-conventions.md. Breaking: ! after type, or BREAKING CHANGE: in the footer. Branches: feature/TICKET-123-description, release/1.2.0

Hook Detection

ls lefthook.yml .lefthook.yml captainhook.json .pre-commit-config.yaml .husky/pre-commit 2>/dev/null || echo "No hooks"

Install commands per framework: references/git-hooks-setup.md.

PR Merge Requirements

Before merging: threads resolved, CI green (incl. annotations), rebased, signed, and reviewed on the current head — by a human, a bot, or the author through pr-merge.sh --self-reviewed. A review is mandatory; a bot review is not, and one is waited for only while it is actually in flight. Rebase-only + signed: git merge --ff-only.

Verification

./scripts/verify-git-workflow.sh /path/to/repository
# Gate state, next action:
./scripts/pr-status.sh [-R owner/repo] [PR] [--json] [--watch]
# Merge; refuses when the gate is shut:
./scripts/pr-merge.sh [-R owner/repo] [PR] [--dry-run|--self-reviewed]
# What a repository expects, before the first artifact:
./scripts/repo-contribution-preflight.sh [--repo <dir>] [--section docs|templates|packaging|ci|tools]
# Which copy is installed here (every script answers this):
./scripts/pr-status.sh --version

Contributing: https://github.com/netresearch/git-workflow-skill

Files (git-workflow-skill)
  • evals
    • evals.json 33.6 KB
      [
        {
          "name": "setup_conventional_commits",
          "prompt": "Set up conventional commits for this project with commit message validation",
          "assertions": [
            {
              "type": "content",
              "pattern": "(conventional commit|commitlint|commit-msg|feat:|fix:)"
            },
            {
              "type": "content",
              "pattern": "(hook|husky|pre-commit|lefthook|captainhook|validation)"
            }
          ]
        },
        {
          "name": "create_and_merge_pr",
          "prompt": "Create and merge a PR for the current branch changes",
          "assertions": [
            {
              "type": "tool_use",
              "tool": "Bash",
              "pattern": "gh pr (create|merge)"
            },
            {
              "type": "content",
              "pattern": "(pull request|PR|merge)"
            }
          ]
        },
        {
          "name": "choose_branching_strategy",
          "prompt": "We're a small team doing continuous deployment of a web app. What branching strategy should we use?",
          "assertions": [
            {
              "type": "content",
              "pattern": "(GitHub Flow|trunk.based)"
            },
            {
              "type": "content",
              "pattern": "(continuous deploy|small team|feature branch)"
            }
          ]
        },
        {
          "name": "write_breaking_change_commit",
          "prompt": "Write a commit message for removing the deprecated /api/v1 endpoints. This is a breaking change.",
          "assertions": [
            {
              "type": "content",
              "pattern": "(feat!|fix!|BREAKING CHANGE)"
            },
            {
              "type": "content",
              "pattern": "(v1|deprecated|endpoint|API)"
            }
          ]
        },
        {
          "name": "resolve_pr_review_threads",
          "prompt": "A reviewer left 3 comments on my PR #42. I've fixed all the issues. How do I reply to and resolve the review threads?",
          "assertions": [
            {
              "type": "content",
              "pattern": "(gh api|graphql|resolveReviewThread|addPullRequestReviewThreadReply)"
            },
            {
              "type": "content",
              "pattern": "(thread|resolve|reply)"
            }
          ]
        },
        {
          "name": "setup_github_actions_ci",
          "prompt": "Set up a basic CI pipeline with GitHub Actions for a Node.js project that runs lint, test, and build.",
          "assertions": [
            {
              "type": "content",
              "pattern": "(github.com/actions|actions/checkout|actions/setup-node|workflows)"
            },
            {
              "type": "content",
              "pattern": "(lint|test|build)"
            }
          ]
        },
        {
          "name": "cherry_pick_hotfix",
          "prompt": "I need to backport a bug fix commit abc1234 from main to the release/1.0 branch. How?",
          "assertions": [
            {
              "type": "content",
              "pattern": "cherry-pick"
            },
            {
              "type": "content",
              "pattern": "(release|backport|branch)"
            }
          ]
        },
        {
          "name": "recover_deleted_branch",
          "prompt": "I accidentally deleted my feature branch. How do I recover it?",
          "assertions": [
            {
              "type": "content",
              "pattern": "reflog"
            },
            {
              "type": "content",
              "pattern": "(recover|checkout|branch)"
            }
          ]
        },
        {
          "name": "fetch_prune_with_refspec",
          "prompt": "In my bare-repo worktree layout I ran `git -C .bare fetch origin main:refs/remotes/origin/main --prune` and now origin/main is gone and origin/HEAD is dangling. What happened and how do I get it back?",
          "assertions": [
            {
              "type": "content",
              "pattern": "(shorthand|unqualified|`?main`?[^\\n]{0,40}(not|does not)[^\\n]{0,30}match|refs/heads/main)"
            },
            {
              "type": "content",
              "pattern": "prune[^\\n]{0,80}(refspec|command line)|(refspec|command line)[^\\n]{0,80}prune"
            },
            {
              "type": "content",
              "pattern": "(without|drop|omit|no|remove)[^\\n]{0,30}`?--prune`?"
            },
            {
              "type": "content",
              "pattern": "(prune[^\\n]{0,40}(separate|own) call)|((separate|own) call[^\\n]{0,40}prune)|(qualif\\w*[^\\n]{0,40}refs/heads/)"
            }
          ]
        },
        {
          "name": "immutable_release_recovery",
          "prompt": "I created a GitHub release v1.2.3 but the TER publish failed. I deleted the release to recreate it and now I can't. What happened and how do I fix it?",
          "assertions": [
            {
              "type": "content",
              "pattern": "(immutable|permanently blocked|cannot.*reuse)"
            },
            {
              "type": "content",
              "pattern": "(v?1\\.2\\.4|next version|skip|bump)"
            }
          ]
        },
        {
          "name": "detect_git_hooks_framework",
          "prompt": "How do I detect which git hooks framework is configured in a project?",
          "assertions": [
            {
              "type": "content",
              "pattern": "(lefthook\\.yml|captainhook\\.json|pre-commit-config\\.yaml|\\.husky)"
            },
            {
              "type": "content",
              "pattern": "(ls |detect|check|find)"
            }
          ]
        },
        {
          "name": "recommend_hooks_go",
          "prompt": "What git hooks should I set up for a Go project? Give me a ready-to-use config.",
          "assertions": [
            {
              "type": "content",
              "pattern": "(lefthook|pre-commit)"
            },
            {
              "type": "content",
              "pattern": "(gofmt|golangci-lint|go vet)"
            }
          ]
        },
        {
          "name": "recommend_hooks_php",
          "prompt": "What git hooks framework should I use for a TYPO3 PHP extension?",
          "assertions": [
            {
              "type": "content",
              "pattern": "captainhook"
            },
            {
              "type": "content",
              "pattern": "(php-cs-fixer|phpstan|composer)"
            }
          ]
        },
        {
          "name": "squash_vs_rebase_merge",
          "prompt": "When should I use squash merge vs rebase merge for PRs?",
          "assertions": [
            {
              "type": "content",
              "pattern": "(squash|--squash)"
            },
            {
              "type": "content",
              "pattern": "(rebase|--rebase|linear history|messy|clean)"
            }
          ]
        },
        {
          "name": "signed_commits_rebase_merge",
          "prompt": "My repo requires signed commits and only allows rebase merge. gh pr merge --rebase fails with 'Base branch requires signed commits'. How do I merge?",
          "assertions": [
            {
              "type": "content",
              "pattern": "(fast-forward|--ff-only|local.*merge)"
            },
            {
              "type": "content",
              "pattern": "(sign|signature|GPG|SSH)"
            }
          ]
        },
        {
          "name": "setup_codeowners",
          "prompt": "Set up a CODEOWNERS file for our project. Frontend is in src/components/, backend in src/api/, and DevOps owns .github/ and docker/.",
          "assertions": [
            {
              "type": "content",
              "pattern": "CODEOWNERS"
            },
            {
              "type": "content",
              "pattern": "(src/components|src/api|.github|docker)"
            }
          ]
        },
        {
          "name": "git_bisect_find_bug",
          "prompt": "Tests pass on v2.0.0 but fail on current HEAD. How do I find the commit that introduced the bug?",
          "assertions": [
            {
              "type": "content",
              "pattern": "bisect"
            },
            {
              "type": "content",
              "pattern": "(good|bad|start|run)"
            }
          ]
        },
        {
          "name": "multi_branch_release_latest",
          "prompt": "I maintain TYPO3 v12 and v13 branches. When I release v12.0.5 after v13.5.0, the Latest badge moves to v12. How do I prevent this?",
          "assertions": [
            {
              "type": "content",
              "pattern": "--latest=false"
            },
            {
              "type": "content",
              "pattern": "(maintenance|non-default|badge)"
            }
          ]
        },
        {
          "name": "setup_shellcheck_shfmt",
          "prompt": "Add shellcheck and shfmt to our CI and pre-commit hooks for shell script quality.",
          "assertions": [
            {
              "type": "content",
              "pattern": "shellcheck"
            },
            {
              "type": "content",
              "pattern": "shfmt"
            }
          ]
        },
        {
          "name": "git_absorb_workflow",
          "prompt": "I have review feedback requiring changes across 3 different commits in my branch. How do I fix each commit cleanly without manual interactive rebase?",
          "assertions": [
            {
              "type": "content",
              "pattern": "(git.absorb|git absorb|--fixup)"
            },
            {
              "type": "content",
              "pattern": "(autosquash|rebase|fixup)"
            }
          ]
        },
        {
          "name": "pr_merge_checklist",
          "prompt": "What should I verify before merging a PR? Give me the complete checklist.",
          "assertions": [
            {
              "type": "content",
              "pattern": "(CI|checks|status|pass)"
            },
            {
              "type": "content",
              "pattern": "(review|thread|resolved|approved)"
            },
            {
              "type": "content",
              "pattern": "(rebase|signed|annotation)"
            },
            {
              "type": "content",
              "pattern": "(in.?flight review|pending review|review in progress|review still running|wait for the review|review to land|in-flight or pending review)"
            }
          ]
        },
        {
          "name": "merge_blocked_by_inflight_review",
          "prompt": "gh pr view says mergeStateStatus is CLEAN and reviewRequests is empty, but I just re-requested the Copilot reviewer a moment ago. Is it safe to merge now?",
          "assertions": [
            {
              "type": "content",
              "pattern": "(do not merge|don.t merge|not safe to merge|should not merge|hold off|don.t merge yet|do not merge yet|wait for the review|wait until the review)"
            },
            {
              "type": "content",
              "pattern": "(in.?flight|still reviewing|pending review|review in progress|hasn.t submitted|has not submitted|not yet submitted|announced review|review you just requested|review you requested)"
            },
            {
              "type": "content",
              "pattern": "(CLEAN is not sufficient|CLEAN isn.t sufficient|CLEAN alone|CLEAN can be stale|CLEAN doesn.t mean|not sufficient to merge|review_on_push|empty reviewRequests doesn|reviewRequests is not)"
            }
          ]
        },
        {
          "name": "old_head_review_clean_timeline_check",
          "prompt": "PR has all checks green and mergeStateStatus CLEAN, but the only Copilot review is on the previous commit; I pushed a docs-only commit afterwards. Can I merge?",
          "assertions": [
            {
              "type": "content",
              "pattern": "(?i)timeline"
            },
            {
              "type": "content",
              "pattern": "(?i)review.?request"
            },
            {
              "type": "content",
              "pattern": "(?i)(after|since|following).{0,50}(push|commit|head)"
            },
            {
              "type": "content",
              "pattern": "(?i)(mergeable|safe to merge|can merge|merge it).{0,160}(no (new |further )?review|nothing (was )?announced|not.{0,20}announced)|((no (new |further )?review|nothing (was )?announced).{0,160}(mergeable|safe to merge|can merge))"
            }
          ]
        },
        {
          "name": "merge_gate_separate_invocation",
          "prompt": "PR #42 has green CI and mergeStateStatus CLEAN, but its review threads may still be open. Run the merge gate check and merge it in one go.",
          "assertions": [
            {
              "type": "content",
              "pattern": "(?i)(separate|two|distinct).{0,40}(invocation|command|step)|(read|inspect|check).{0,60}output.{0,80}(then|before).{0,40}merge"
            },
            {
              "type": "content",
              "pattern": "(?i)(never|don.t|do not|won.t|refus|can.t|cannot|should not|shouldn.t|must not|mustn.t).{0,60}(chain|&&|one (go|command|compound)|same command|single command)"
            },
            {
              "type": "content",
              "pattern": "(?i)(exit.{0,12}(code|status|0|zero)|CLEAN (does not|doesn.t).{0,40}(unresolved|thread|conversation)|unresolved (review )?(thread|conversation))"
            }
          ]
        },
        {
          "name": "spec_cleanup_guard_before_merge",
          "prompt": "I'm finishing a PR with /pr-finish. My feature branch committed the superpowers plan and spec under docs/superpowers/ while I was working. What should happen before this merges into the base branch?",
          "assertions": [
            {
              "type": "content",
              "pattern": "(?i)(spec.?cleanup|intermediate|working).{0,40}(artifact|spec|plan|file)|docs/superpowers"
            },
            {
              "type": "content",
              "pattern": "(?i)(not (reach|land|merge)|keep .{0,20}out of|must not).{0,40}(base|main|develop)|(remove|delete|clean).{0,40}before.{0,20}merge"
            },
            {
              "type": "content",
              "pattern": "(?i)(ADR|capture|convert|durable doc|documentation).{0,40}(then|before).{0,40}(remove|delete|clean)|guard"
            }
          ]
        },
        {
          "name": "spec_cleanup_recoverable_removal",
          "prompt": "My branch has an untracked docs/superpowers/plan.md I no longer need before merging. How do I get rid of it safely?",
          "assertions": [
            {
              "type": "content",
              "pattern": "(?i)(never|don.t|do not|avoid).{0,30}(bare )?(rm|delete).{0,30}untracked|untracked.{0,40}(irrecoverable|not in git|cannot.{0,10}recover|unrecoverable)"
            },
            {
              "type": "content",
              "pattern": "(?i)(stage|git add|commit).{0,60}(then|before).{0,20}(git rm|remove|delete)|recoverable.{0,30}(removal|history)|enters? .{0,10}history"
            }
          ]
        },
        {
          "name": "detect_upstream_work_dropped_by_merge",
          "prompt": "This branch carries a 'Merge branch develop' commit. Before I merge it back, how do I check that the merge did not silently drop changes develop had made?",
          "assertions": [
            {
              "type": "content",
              "pattern": "(merge-file|--diff3)"
            },
            {
              "type": "content",
              "pattern": "(rev-list --merges|\\^1|\\^2|parents of (that|the) merge|per merge commit)"
            },
            {
              "type": "content",
              "pattern": "(dropped|discarded|reverted|silently)"
            }
          ]
        },
        {
          "name": "rebase_long_branch_with_reference_merge",
          "prompt": "I need to rebase a 35-commit branch onto a base that moved a lot. How do I make sure the result is correct?",
          "assertions": [
            {
              "type": "content",
              "pattern": "(worktree|scratch|throwaway|up front|end state|target (tree|state)|reference (merge|commit))"
            },
            {
              "type": "content",
              "pattern": "git diff[^|]*(ref-target|reference|target-state|REF )"
            },
            {
              "type": "content",
              "pattern": "((must|should|has to).{0,25}(print|be).{0,12}(nothing|empty)|no output|is empty)"
            }
          ]
        },
        {
          "name": "rebase_invalidated_the_review",
          "prompt": "I rebased my PR because the base moved, and now there is no review on the new head. The rebase was clean and I changed nothing. Do I have to get it reviewed again?",
          "assertions": [
            {
              "type": "content",
              "pattern": "range-diff"
            },
            {
              "type": "content",
              "pattern": "origin/[a-z]+\\.\\.\\.|\\.\\.\\."
            },
            {
              "type": "content",
              "pattern": "(=|equal|identical|unchanged).{0,60}(patch|commit)|(patch|commit).{0,60}(identical|unchanged)"
            },
            {
              "type": "content",
              "pattern": "(<|>|differ|changed).{0,80}(read|review|inspect)"
            }
          ]
        },
        {
          "name": "pr_body_must_match_current_diff",
          "prompt": "This MR has been open for eight months and I just rebased it. Update the description.",
          "assertions": [
            {
              "type": "content",
              "pattern": "git diff .*\\.\\.\\.?(head)"
            },
            {
              "type": "content",
              "pattern": "(re-?derive|verify each|against the (current )?diff|no longer (true|matches))"
            },
            {
              "type": "content",
              "pattern": "((delete|drop|remove)[a-z]*[^.]{0,60}sections?|sections?[^.]{0,60}(delete|drop|remove))"
            }
          ]
        },
        {
          "name": "check_ci_enabled_before_watching_pipeline",
          "prompt": "I pushed 20 minutes ago and no pipeline has appeared for the merge request. Keep waiting?",
          "assertions": [
            {
              "type": "content",
              "pattern": "(jobs_enabled|builds_access_level)"
            },
            {
              "type": "content",
              "pattern": "ci( ?/ ?cd)? (is |was |be )?(switched off|disabled|turned off)"
            },
            {
              "type": "content",
              "pattern": "(nothing will run|no pipeline,? (will )?ever|never .{0,30}(created|occur|arrive)|cannot .{0,30}(be created|occur))"
            }
          ]
        },
        {
          "name": "verify_branch_split_by_blob_identity",
          "prompt": "I split one big branch into six smaller branches. How do I prove nothing was left behind?",
          "assertions": [
            {
              "type": "content",
              "pattern": "rev-parse[^|]*:[^ |]"
            },
            {
              "type": "content",
              "pattern": "(blob|object id)"
            },
            {
              "type": "content",
              "pattern": "(not carried|carried by (no|none)|missing from (every|all|any)|nobody has)"
            }
          ]
        },
        {
          "name": "copilot_code_review_rule_is_not_a_merge_gate",
          "prompt": "My repo's ruleset has a copilot_code_review rule and I just pushed a new commit to my PR. Does that rule block merging until Copilot re-reviews the new head, and how do I confirm a re-request actually registered?",
          "assertions": [
            {
              "type": "content",
              "pattern": "(?i)(does not block|will not block|not a (merge )?gate|doesn.t block|no.{0,12}block)"
            },
            {
              "type": "content",
              "pattern": "review_on_push"
            },
            {
              "type": "content",
              "pattern": "(?i)timeline"
            },
            {
              "type": "content",
              "pattern": "(?i)(required approvals|COMMENT review|comment.only)"
            }
          ]
        },
        {
          "name": "signing_readiness_probe_ssh",
          "prompt": "Before a commit-heavy run I want to confirm git commit -S will actually sign. I use gpg.format=ssh and have not set gpg.ssh.allowedSignersFile. What probe should I use, and why does git log --show-signature say 'No signature'?",
          "assertions": [
            {
              "type": "content",
              "pattern": "(?i)cat-file commit[^\\n]{0,120}gpgsig"
            },
            {
              "type": "content",
              "pattern": "(?i)allowed.?signers"
            },
            {
              "type": "content",
              "pattern": "(?i)(is signed|was signed|correctly signed|local verification|verification config)"
            },
            {
              "type": "content",
              "pattern": "(?i)(throwaway|--allow-empty|reset --soft)"
            }
          ]
        },
        {
          "name": "script_check_commit_is_signed",
          "prompt": "In a script I need to decide whether HEAD is signed. Can I test `git log -1 --format='%G?'` = G and treat every other result as unsigned?",
          "assertions": [
            {
              "type": "content",
              "pattern": "(?i)(unreliable|not reliable|not (a )?(reliable|sufficient|safe)|false negative|does ?n.?t (prove|mean)|no proof|(don.t|do not) (reach for|rely on|use)[^\\n]{0,60}(%G\\?|show-signature))"
            },
            {
              "type": "content",
              "pattern": "(?i)allowed.?signers"
            },
            {
              "type": "content",
              "pattern": "(?i)cat-file commit[^\\n]{0,120}gpgsig"
            },
            {
              "type": "content",
              "pattern": "(?i)(blank line|first blank|sed -n)"
            }
          ]
        },
        {
          "name": "worktree_cleanup_spares_the_primary_directory",
          "prompt": "Clean up this repository's worktrees: several feature worktrees are fully merged into main with a clean tree. One of them is the repo's main/ directory, which was switched to a feature branch that has since merged. Which do you remove?",
          "assertions": [
            {
              "type": "content",
              "pattern": "(but not|except|other than|apart from|keep|leave|spare|exclude|never|do not|dont|not) [^.]{0,70}(main/|master/|primary|default.branch)|(main/|master/|primary|default.branch)[^.]{0,70}(is not|must not|never|stays|keep|leave|spare|exclude)"
            },
            {
              "type": "content",
              "pattern": "(switch|check ?out) [^.]{0,40}(main|master|default)"
            },
            {
              "type": "content",
              "pattern": "(remove|delete|prune) [^.]{0,70}(feature|merged|other)|(feature|merged|other) [^.]{0,70}(can go|be removed|removed|deleted)"
            }
          ],
          "samples": {
            "passing": "Remove the merged feature worktrees, but not project/main: that directory is the repository primary worktree and only looks disposable because it was left on a feature branch. Switch it back to main, fetch, then fast-forward it.",
            "failing": [
              "All of them are merged into main and the tree is clean, so remove every one of them with git worktree remove, then git worktree prune and delete the local branches afterwards.",
              "The main/ directory is a worktree like any other; its branch is merged, so delete it too and re-add it later if you need it.",
              "Switch each of them back to main first, then remove all of the worktrees including project/main so the repository is tidy.",
              "Remove the ones whose branches are merged. That includes project/main, since its feature branch merged as well."
            ]
          }
        },
        {
          "name": "review_pr_reads_standing_reviews_first",
          "prompt": "Review pull request #42 in this repository and post your findings as review comments",
          "assertions": [
            {
              "type": "content",
              "pattern": "(get_reviews|existing review|prior review|standing review|review threads?)"
            },
            {
              "type": "content",
              "pattern": "(diff|changed files)"
            }
          ]
        },
        {
          "name": "write_breaking_change_commit_api_removal",
          "prompt": "Write a conventional commit message for removing the deprecated /api/v1 endpoints.",
          "assertions": [
            {
              "type": "content",
              "pattern": "(feat!|fix!|BREAKING[ -]CHANGE)"
            },
            {
              "type": "content",
              "pattern": "(migration|migrate|replacement|/api/v2)"
            }
          ]
        },
        {
          "name": "resolve_review_threads_graphql",
          "prompt": "How do I programmatically reply to and resolve PR review threads using the gh CLI?",
          "assertions": [
            {
              "type": "content",
              "pattern": "resolveReviewThread"
            },
            {
              "type": "content",
              "pattern": "(addPullRequestReviewThreadReply|gh api graphql)"
            }
          ]
        },
        {
          "name": "signed_commits_rebase_blocked",
          "prompt": "gh pr merge --rebase fails with 'Base branch requires signed commits'. How do I merge this PR?",
          "assertions": [
            {
              "type": "content",
              "pattern": "--ff-only"
            },
            {
              "type": "content",
              "pattern": "(cannot sign|does not sign|re-?sign|signature)"
            }
          ]
        },
        {
          "name": "latest_badge_on_older_branch",
          "prompt": "When I release v11.0.17 after v13.5.0, the Latest badge moves to v11. How to prevent?",
          "assertions": [
            {
              "type": "content",
              "pattern": "--latest=false"
            },
            {
              "type": "content",
              "pattern": "gh release edit"
            }
          ]
        },
        {
          "name": "git_bisect_run_automated",
          "prompt": "Tests pass on v1.0 but fail on HEAD. How do I automate finding the breaking commit?",
          "assertions": [
            {
              "type": "content",
              "pattern": "git bisect run"
            },
            {
              "type": "content",
              "pattern": "git bisect (start|good|bad)"
            }
          ]
        },
        {
          "name": "cherry_pick_security_backport",
          "prompt": "How do I backport a security fix from main to the release/2.x branch?",
          "assertions": [
            {
              "type": "content",
              "pattern": "cherry-pick"
            },
            {
              "type": "content",
              "pattern": "-x\\b"
            }
          ]
        },
        {
          "name": "pr_merge_lifecycle_end_to_end",
          "prompt": "Walk me through the complete PR merge lifecycle from CI verification to post-merge cleanup.",
          "assertions": [
            {
              "type": "content",
              "pattern": "(gh pr checks|status.*check)"
            },
            {
              "type": "content",
              "pattern": "(thread|conversation).*(resolv|unresolved)"
            },
            {
              "type": "content",
              "pattern": "(annotation|delete-branch|branch cleanup)"
            }
          ]
        },
        {
          "name": "branch_protection_two_reviews_signed",
          "prompt": "Set up branch protection for main requiring 2 reviews, CI checks, and signed commits.",
          "assertions": [
            {
              "type": "content",
              "pattern": "required_approving_review"
            },
            {
              "type": "content",
              "pattern": "(required_status_checks|required_signatures)"
            }
          ]
        },
        {
          "name": "git_absorb_instead_of_interactive_rebase",
          "prompt": "I need to fix code in 3 different commits from review feedback. How do I avoid messy interactive rebase?",
          "assertions": [
            {
              "type": "content",
              "pattern": "git.absorb"
            },
            {
              "type": "content",
              "pattern": "(autosquash|--autosquash|fixup)"
            }
          ]
        },
        {
          "name": "worktree_review_without_stashing",
          "prompt": "How do I review a PR without stashing my current work?",
          "assertions": [
            {
              "type": "content",
              "pattern": "git worktree"
            },
            {
              "type": "content",
              "pattern": "(separate|second) (working )?(directory|tree|checkout)"
            }
          ]
        },
        {
          "name": "pr_body_needs_captured_before_after",
          "prompt": "I changed the wording of an error message in our CLI. Write the pull request description.",
          "assertions": [
            {
              "type": "content",
              "pattern": "(before.*after|old.*new).*(output|transcript)"
            },
            {
              "type": "content",
              "pattern": "git checkout .*(base|main)"
            }
          ]
        },
        {
          "name": "unchanged_claim_needs_the_same_proof",
          "prompt": "My commit message says the fallback branch keeps its existing wording. Anything to check before I push?",
          "assertions": [
            {
              "type": "content",
              "pattern": "(claim|assert).*(proof|evidence|verif)"
            },
            {
              "type": "content",
              "pattern": "(exercise|run|trigger).*(fallback|path)"
            }
          ]
        },
        {
          "name": "gitlab_capability_read_the_flag_not_the_history",
          "prompt": "On this self-managed GitLab the last 20 merge requests all came from in-repo branches and I see no forks. Can outsiders contribute?",
          "assertions": [
            {
              "type": "content",
              "pattern": "forking_access_level"
            },
            {
              "type": "content",
              "pattern": "(issues_enabled|merge_requests_enabled)"
            },
            {
              "type": "content",
              "pattern": "(authenticated|token|-L\\b)"
            }
          ]
        },
        {
          "name": "issue_template_keeps_its_order",
          "prompt": "I found the root cause of a bug in an upstream project and have a three-line patch. File the issue using their template.",
          "assertions": [
            {
              "type": "content",
              "pattern": "(Summary|summary section)"
            },
            {
              "type": "content",
              "pattern": "(keep|preserve|intact).*(template|section)"
            }
          ]
        },
        {
          "name": "green_job_is_not_a_green_test_report",
          "prompt": "The pipeline on my merge request is green. Is it safe to merge?",
          "assertions": [
            {
              "type": "content",
              "pattern": "(test report|failed_count|junit)"
            },
            {
              "type": "content",
              "pattern": "(allow_failure|required)"
            }
          ]
        },
        {
          "name": "clean_merge_can_drop_entries",
          "prompt": "I merged a long-running upstream branch into my feature branch. All conflicts are resolved and the tests still pass. Anything left to check?",
          "assertions": [
            {
              "type": "content",
              "pattern": "(no conflict marker|without a conflict|clean(ly)? (auto-?)?merge)"
            },
            {
              "type": "content",
              "pattern": "(composer\\.json|package\\.json|manifest)"
            }
          ]
        },
        {
          "name": "conflict_resolution_reads_the_body",
          "prompt": "Six merge conflicts all look the same: upstream made a minimal nullability fix to a signature my branch already rewrote further. Can I resolve them all by taking my side?",
          "assertions": [
            {
              "type": "content",
              "pattern": "(each|per).*(function|body|hunk)"
            },
            {
              "type": "content",
              "pattern": "(surrounding|rest of the|outside the conflict|each (hunk|file|case))"
            }
          ]
        },
        {
          "name": "verbatim_material_is_not_edited",
          "prompt": "A reviewer points out a spelling mistake in my pull request: an example transcript says 'occured'. Fix it?",
          "assertions": [
            {
              "type": "content",
              "pattern": "(verbatim|captured|transcript|actual output)"
            },
            {
              "type": "content",
              "pattern": "(evidence|approximation|reproduc)"
            }
          ]
        },
        {
          "name": "review_fix_traced_through_the_callers",
          "prompt": "A reviewer says the test setup should not build its clock from time(). I'll replace it with a fixed timestamp on that line.",
          "assertions": [
            {
              "type": "content",
              "pattern": "(fixture|surrounding|caller|rest of)"
            },
            {
              "type": "content",
              "pattern": "(expired|inconsistent|both|together)"
            }
          ]
        },
        {
          "name": "enqueued_pr_still_reads_clean",
          "prompt": "I queued my PR for merge ten minutes ago. The status check now says mergeState CLEAN, every check green, no open threads, NEXT: merge. Should I run the merge again?",
          "assertions": [
            {
              "type": "content",
              "pattern": "mergeQueueEntry"
            },
            {
              "type": "content",
              "pattern": "(position|estimatedTimeToMerge|state)"
            }
          ]
        },
        {
          "name": "queued_is_not_running",
          "prompt": "My PR has been waiting on CI for forty minutes and the status tool says the required checks are still running. Should I enqueue it for merge so it goes in as soon as they finish?",
          "assertions": [
            {
              "type": "content",
              "pattern": "queued"
            },
            {
              "type": "content",
              "pattern": "(in_progress|runner|capacity)"
            }
          ]
        },
        {
          "name": "blocked_by_an_unsigned_commit",
          "prompt": "My PR is mergeStateStatus BLOCKED. Every check passes, no review thread is open, the ruleset needs zero approvals and every required context is present. What is holding it?",
          "assertions": [
            {
              "type": "content",
              "pattern": "(unsigned|required_signatures|signature)"
            },
            {
              "type": "content",
              "pattern": "(one commit|any commit|somewhere on the branch|per-commit)"
            }
          ]
        },
        {
          "name": "dequeued_silently_look_at_the_queue_branch",
          "prompt": "My PR entered the merge queue and left it 85 seconds later. It is still OPEN, mergeStateStatus is CLEAN, there is no bot comment and no failing check on the PR. Why was it dequeued?",
          "assertions": [
            {
              "type": "content",
              "pattern": "gh-readonly-queue"
            },
            {
              "type": "content",
              "pattern": "(queue branch|dequeu|invisible|PR-scoped)"
            }
          ]
        },
        {
          "name": "is_that_red_check_required",
          "prompt": "A check on my PR is red and the PR is BLOCKED. Should I fix the red check first?",
          "assertions": [
            {
              "type": "content",
              "pattern": "isRequired"
            },
            {
              "type": "content",
              "pattern": "(graphql|rollup|required context)"
            }
          ]
        },
        {
          "name": "ten_prs_one_integration_branch",
          "prompt": "I have ten independent PRs, all green, on a repo with a merge queue. They all touch CHANGELOG.md so each merge conflicts the next. How do I get them in?",
          "assertions": [
            {
              "type": "content",
              "pattern": "(integration branch|single branch|one branch)"
            },
            {
              "type": "content",
              "pattern": "(CHANGELOG|accumulator|resolve once)"
            }
          ]
        },
        {
          "name": "and_chain_commits_after_a_failed_resolver",
          "prompt": "My resolver script exits 1 when it finds a conflict it cannot handle. I run it and then `git add -A && git commit` in the same command. Is that safe?",
          "assertions": [
            {
              "type": "content",
              "pattern": "(&&|exit (status|code)|non-?zero)"
            },
            {
              "type": "content",
              "pattern": "(conflict marker|<<<<<<<|gate|pre-commit)"
            }
          ]
        },
        {
          "name": "shallow_fetch_truncates_repository",
          "prompt": "A CI job fetches other branch heads with `git fetch --depth=1 origin '+refs/heads/*:refs/remotes/origin/*'` and then a later step checks whether a commit is an ancestor of main. The check passes but appears to verify nothing. Why?",
          "assertions": [
            {
              "type": "content",
              "pattern": "(shallow|\\.git/shallow|truncat)"
            },
            {
              "type": "content",
              "pattern": "(whole repository|entire repository|not (just|only) the refs|all ancestry)"
            },
            {
              "type": "content",
              "pattern": "(GIT_DEPTH: ?0|fetch-depth: ?0|is-shallow-repository)"
            },
            {
              "type": "content",
              "pattern": "(no --depth|order|later fetch|again)"
            }
          ]
        },
        {
          "name": "is_branch_merged_under_rebase_merge",
          "prompt": "On a GitLab project with merge_method rebase_merge, `git merge-base --is-ancestor my-branch origin/main` says no, but the branch's changes are clearly in main. How do I decide whether the branch can be deleted?",
          "assertions": [
            {
              "type": "content",
              "pattern": "git cherry"
            },
            {
              "type": "content",
              "pattern": "(patch.?id|patch ID)"
            },
            {
              "type": "content",
              "pattern": "(rebase|new (objects|SHAs|commits)|rewritten)"
            },
            {
              "type": "content",
              "pattern": "(whitespace|patch.?id normalis|git diff --quiet|tree comparison)"
            }
          ]
        },
        {
          "name": "read_commit_trailers_safely",
          "prompt": "I want a CI check to honour an opt-in written as a git trailer in the commit message. How should the check read that trailer?",
          "assertions": [
            {
              "type": "content",
              "pattern": "git interpret-trailers"
            },
            {
              "type": "content",
              "pattern": "--no-divider"
            },
            {
              "type": "content",
              "pattern": "(code (block|fence)|regex|squash)"
            },
            {
              "type": "content",
              "pattern": "(divider line|line beginning with)"
            }
          ]
        },
        {
          "name": "merge_pr_you_did_not_author",
          "prompt": "I need to finish and merge PR #57 in netresearch/concourse-ci-skill. It is green, no threads, but no review on the head and both review bots are unavailable. The PR was opened by a colleague, not by me.",
          "assertions": [
            {
              "type": "content",
              "pattern": "(gh pr review|--approve)"
            },
            {
              "type": "content",
              "pattern": "(not the author|did not author|author is|belongs to the author)"
            }
          ]
        }
      ]
      
  • references
    • advanced-git.md 83.8 KB
      # Advanced Git Operations
      
      ## Shallow Fetches
      
      ### `--depth=1` truncates the WHOLE repository, not the refs you asked for
      
      `git fetch --depth=1 origin '+refs/heads/*:refs/remotes/origin/*'` looks like it
      scopes to the refs in its refspec. It does not. It writes `.git/shallow` and
      every ancestry query in that checkout answers from a one-commit graft
      afterwards.
      
      ```bash
      git clone --no-local file://$REPO probe && cd probe
      git rev-list --count HEAD          # 366
      git fetch --quiet --depth=1 origin '+refs/heads/*:refs/remotes/origin/*'
      git rev-parse --is-shallow-repository   # true
      git rev-list --count HEAD          # 1
      ```
      
      Why it matters more than it looks: a shallow repository does not merely lack
      objects, it **truncates ancestry**, so `git merge-base --is-ancestor X main`
      answers "no" for a commit that genuinely is on `main`. Anything built on that
      answer — a guard, a changelog generator, a release check — goes quietly wrong
      rather than failing.
      
      The usual shape is a CI job that fetches other branch heads cheaply, two lines
      above the command that needs history. Both look innocent; only the pair is
      wrong. If a job needs ancestry, it needs the history:
      
      - GitLab: `GIT_DEPTH: 0` on that job, **and no `--depth` in any fetch the script
        runs**. The second half is the one that gets missed: a job can start with full
        history and lose it to a `--depth=1` line of its own three commands later.
      - GitHub Actions: `actions/checkout` with `fetch-depth: 0`, same caveat.
      
      `git fetch --unshallow` does recover a shallow checkout — with or without a ref
      argument, and even where the remote's configured refspec is a single ref
      (measured both ways). It is not a defence against the above, because a later
      `--depth` fetch simply makes the repository shallow again; order decides.
      
      A tool that depends on ancestry should say which state it is in rather than
      answer from a truncated graph — `git rev-parse --is-shallow-repository` is one
      call, and "I could not check" is a different answer from "this is fine".
      
      ## Rewriting History
      
      ### After ANY reset-based rebuild: the commit takes the INDEX, not the worktree
      
      `git reset --soft <base>` + `git commit` is the standard way to collapse a
      branch into one clean commit — and it commits whatever is **staged**, which
      is the old tree, not the files as they lie on disk. Edits made after the last
      `git add` (every editor/tool write) silently stay out, the commit "succeeds",
      and only CI notices that the pushed tree is the stale one (2026-08-13: a
      history rewrite meant to purge two files shipped without the accompanying
      test/docs edits; the red CI on removed-file assertions was the first signal).
      
      After the rebuild, before pushing:
      
      ```bash
      git status --porcelain          # anything listed = NOT in the commit you just made
      git grep -l <purged-artifact> HEAD   # verify against the COMMIT, not the worktree
      ```
      
      Run the second command bare — behind a pipe (`| head`) `$?` is the pipe
      tail's, and a "found it" exit code reads as clean.
      
      ### Interactive Rebase
      
      ```bash
      # Rebase last N commits
      git rebase -i HEAD~5
      
      # Rebase from a specific commit
      git rebase -i abc1234^
      
      # Commands available:
      # p, pick   - use commit
      # r, reword - edit commit message
      # e, edit   - stop for amending
      # s, squash - combine with previous (keep message)
      # f, fixup  - combine with previous (discard message)
      # d, drop   - remove commit
      # x, exec   - run shell command
      ```
      
      ### Replaying Only the Tip Onto a Moved Base (`--onto`)
      
      Use when a long-lived branch is *N bootstrap commits + a few real commits*, and
      the base branch has since absorbed that bootstrap work through a different path
      (different SHAs). A plain `git rebase <base>` replays **all** N commits and hits
      an add/add conflict on every file the base already recreated — and even after
      resolving them the result is wrong.
      
      ```bash
      # Symptom: the MR/PR diff shows an ENTIRE file as newly added (@@ -0,0 +1,N @@)
      # even though the base already has that file. The merge-base predates the file,
      # so base and branch each "add" it → add/add conflict. Don't trust the
      # diff-vs-base; inspect the branch's own tip commit instead:
      git show <tip>                        # the real change this branch introduces
      git cherry -v <base> <branch>         # '+' = unique to branch, '-' = already in base
                                            #   (patch-id match; plain `log <base>..<branch>`
                                            #   still lists absorbed commits under new SHAs)
      
      # Same command answers a second question, and it is the one that bites at
      # cleanup time: "is this branch merged?" On a project that merges by REBASE
      # (GitLab `rebase_merge`, GitHub "Rebase and merge"), the commits that land are
      # new objects, so `merge-base --is-ancestor <branch> main` says NO for a branch
      # whose content is entirely in main. Deleting on that answer feels unsafe;
      # keeping on it leaves dead branches forever. `git cherry` compares patch-ids
      # and gives the content answer:
      git cherry origin/main <branch> | grep -c '^+'   # 0 = no commit carries a patch main lacks
      
      # `git cherry` compares patch-ids, and patch-id NORMALISES WHITESPACE. Two
      # commits that differ only in indentation have the same patch-id, so a branch
      # whose sole change is a reformat reports 0 outstanding while its tree really
      # does differ. Confirm with a tree comparison before deleting anything:
      git diff --quiet origin/main...<branch> || echo "trees differ — do NOT delete on cherry alone"
      git merge-base <base> <branch>        # confirm how far back it forks
      
      # Replay ONLY the commits after <keep-base> onto the current base, dropping the
      # redundant bootstrap history:
      git rebase --onto origin/main <keep-base> <branch>
      #            └ new base       └ everything up to AND INCLUDING this is dropped
      
      # For a single tip commit, <keep-base> is its parent:
      git rebase --onto origin/main <tip>~1 <branch>
      ```
      
      Each replayed commit is 3-way merged against its own parent tree, so as long as
      the lines it touches still exist verbatim in the new base it applies cleanly no
      matter how far the base has moved. Verify the result is exactly the intended
      change and nothing else:
      
      ```bash
      git rev-list --count origin/main..HEAD   # == the number of real commits you kept
      git diff origin/main..HEAD               # == the intended delta only
      ```
      
      This is equivalent to cherry-picking just the tip commits onto the new base;
      `--onto` does it in one step and preserves author and author-date. Force-push the
      rewritten branch with `--force-with-lease`.
      
      ### Squashing Commits
      
      ```bash
      # Squash last 3 commits
      git rebase -i HEAD~3
      # Change 'pick' to 'squash' for commits to combine
      
      # Squash into a specific commit
      git rebase -i <commit-before-first-to-squash>^
      
      # Auto-squash fixup commits
      git commit --fixup=<commit-hash>
      git rebase -i --autosquash main
      ```
      
      ### Splitting Commits
      
      ```bash
      # Start interactive rebase
      git rebase -i HEAD~3
      
      # Mark commit to split with 'edit'
      # When stopped at that commit:
      git reset HEAD^
      git add file1.js
      git commit -m "feat: first change"
      git add file2.js
      git commit -m "feat: second change"
      git rebase --continue
      ```
      
      ### Reordering Commits
      
      ```bash
      # Interactive rebase
      git rebase -i HEAD~5
      
      # In editor, reorder lines to reorder commits
      # Example:
      # pick abc1234 feat: feature A
      # pick def5678 feat: feature B
      # Changes to:
      # pick def5678 feat: feature B
      # pick abc1234 feat: feature A
      ```
      
      ## Cherry-Picking
      
      ### Basic Cherry-Pick
      
      ```bash
      # Pick a single commit
      git cherry-pick abc1234
      
      # Pick multiple commits
      git cherry-pick abc1234 def5678 ghi9012
      
      # Pick a range
      git cherry-pick abc1234^..def5678
      
      # Cherry-pick without committing
      git cherry-pick -n abc1234
      ```
      
      ### Cherry-Pick Options
      
      ```bash
      # Keep original author
      git cherry-pick -x abc1234
      
      # Sign off
      git cherry-pick -s abc1234
      
      # Edit commit message
      git cherry-pick -e abc1234
      
      # Continue after conflict
      git cherry-pick --continue
      
      # Abort cherry-pick
      git cherry-pick --abort
      ```
      
      ### Cherry-Pick Workflow
      
      ```bash
      # Backport fix to release branch
      git checkout release/1.0
      git cherry-pick abc1234  # Fix from main
      git push origin release/1.0
      
      # Apply multiple fixes
      git cherry-pick abc1234 def5678
      # Or create a cherry-pick branch
      git checkout -b cherry-pick-fixes release/1.0
      git cherry-pick abc1234 def5678
      git checkout release/1.0
      git merge --no-ff cherry-pick-fixes
      ```
      
      ## Stashing
      
      ### A probe command is read-only
      
      Comparing two revisions, checking what a script used to do, reproducing a bug
      against an older build — that work is exploratory, and it must not move the
      working tree. Never put `git stash`, `git reset`, `git checkout --` or
      `git restore` inside a command whose purpose is to find something out.
      
      The failure is silent, which is what makes it worth a rule. A probe that
      stashes uncommitted work looks exactly like a probe that found nothing: the
      command prints its output, the tree is quietly a different tree, and the next
      edit lands on top of a state nobody chose. Recovery is `git stash pop`, but
      only if you notice.
      
      Extract the other revision instead of moving to it:
      
      ```bash
      # ✅ Compare against another revision without touching the tree
      git show origin/main:path/to/script.sh > /tmp/old-script.sh
      bash /tmp/old-script.sh --check
      
      # ✅ A whole old tree, still without moving
      git worktree add /tmp/old-tree origin/main
      
      # ❌ Wrong — mutates the working tree in the middle of an inspection
      old=$(cd src && git stash -q; ./script.sh; true)
      ```
      
      If a probe genuinely needs a clean tree, commit first (see the sibling rules on
      selective revert and selective commit) — do not stash your way there.
      
      ### A control run must prove the change is gone, not assume the removal worked
      
      The sibling rule above keeps stash out of a probe. This one is about the
      opposite direction: using stash *deliberately* to remove your change so you can
      measure the baseline. `git stash push -- <paths>` captures *uncommitted* work
      for those paths — staged entries as well as unstaged edits, clearing the index
      entry in the process — and nothing else. Once your change is committed there is
      nothing left for it to take: it stashes nothing, exits 0, and prints a message
      you skim past. The "control" then runs the very code it was supposed to
      exclude.
      
      That produces the most dangerous shape a measurement can have — a result that
      confirms what you hoped, for a reason that has nothing to do with the code.
      Both runs report identical numbers, you write "behaviour is unchanged", and the
      evidence is a tautology. The only tell is that the matching `git stash pop`
      fails with `No stash entries found`, at the end, after the conclusion is
      already formed.
      
      ```bash
      # ❌ Silently a no-op when the change is committed — both runs are identical
      #    because both runs are the SAME code
      git stash push -- config/app.php
      ./vendor/bin/phpunit                 # "baseline"
      git stash pop
      
      # ✅ For a committed change, take the file back to the base revision.
      #    `git checkout <rev> -- <path>` overwrites index AND worktree for that path,
      #    and the restore below returns it to HEAD — not to edits you had in flight.
      #    So start from a clean tree, or do this in a throwaway worktree.
      set -euo pipefail   # a failed checkout or status must abort, not be stepped over
      
      st=$(git status --porcelain)
      [ -z "$st" ] || { echo 'dirty tree, refusing'; exit 1; }
      
      git checkout <base-sha> -- config/app.php
      
      # grep exits 0 = found, 1 = absent, 2 = could not read. Only 1 proves absence,
      # so branch on the code: `grep -q … && fail` treats a read error as "absent"
      # and waves the baseline through, and a bare `grep -n` succeeds when the
      # construct is still THERE, which is the same hole facing the other way.
      set +e; grep -q 'theConstructYouRemoved' config/app.php; rc=$?; set -e
      [ "$rc" -eq 1 ] || { echo "still present or unreadable (grep exit $rc)"; exit 1; }
      
      ./vendor/bin/phpunit                              # real baseline
      
      git checkout HEAD -- config/app.php               # restore
      st=$(git status --porcelain)
      [ -z "$st" ] || { echo 'tree not restored'; exit 1; }
      ```
      
      Whatever mechanism you use, assert the removal before you measure and assert the
      restoration afterwards, and make both assertions fail loudly — a check that
      exits 0 whether or not the condition holds is decoration. Neither costs a
      second, and without them a green A/B says nothing at all.
      
      ### Basic Stash Operations
      
      ```bash
      # Stash current changes
      git stash
      
      # Stash with message
      git stash save "Work in progress on feature X"
      
      # List stashes
      git stash list
      
      # Apply latest stash (keep in stash list)
      git stash apply
      
      # Apply and remove from stash list
      git stash pop
      
      # Apply specific stash
      git stash apply stash@{2}
      
      # Drop a stash
      git stash drop stash@{1}
      
      # Clear all stashes
      git stash clear
      ```
      
      ### Advanced Stashing
      
      ```bash
      # Stash including untracked files
      git stash -u
      
      # Stash including ignored files
      git stash -a
      
      # Stash specific files
      git stash push -m "message" file1.js file2.js
      
      # Create branch from stash
      git stash branch new-branch stash@{0}
      
      # Show stash contents
      git stash show stash@{0}
      git stash show -p stash@{0}  # With diff
      
      # Partial stash (interactive)
      git stash -p
      ```
      
      ## Bisecting
      
      ### Finding Bug Introduction
      
      ```bash
      # Start bisect
      git bisect start
      
      # Mark current as bad
      git bisect bad
      
      # Mark known good commit
      git bisect good v1.0.0
      
      # Git will checkout middle commit
      # Test, then mark:
      git bisect good  # If bug not present
      git bisect bad   # If bug present
      
      # Continue until found
      # Git reports: "abc1234 is the first bad commit"
      
      # End bisect
      git bisect reset
      ```
      
      ### Automated Bisect
      
      ```bash
      # Run script at each step
      git bisect start HEAD v1.0.0
      git bisect run npm test
      
      # With custom script
      git bisect run ./test-for-bug.sh
      
      # Exit codes:
      # 0     - good
      # 1-124 - bad
      # 125   - skip (can't test this commit)
      # 126+  - abort bisect
      ```
      
      ### Bisect Log
      
      ```bash
      # Show bisect log
      git bisect log
      
      # Save bisect log
      git bisect log > bisect.log
      
      # Replay bisect
      git bisect replay bisect.log
      ```
      
      ## Reflog
      
      ### Understanding Reflog
      
      ```bash
      # Show reflog
      git reflog
      
      # Show reflog for specific ref
      git reflog show main
      git reflog show HEAD
      
      # Output:
      # abc1234 HEAD@{0}: commit: feat: add feature
      # def5678 HEAD@{1}: checkout: moving from main to feature
      # ghi9012 HEAD@{2}: commit: fix: bug fix
      ```
      
      ### Recovering Lost Commits
      
      ```bash
      # Find lost commit in reflog
      git reflog
      
      # Recover commit
      git checkout abc1234
      git checkout -b recovered-branch
      
      # Or cherry-pick
      git cherry-pick abc1234
      
      # Recover after bad reset
      git reflog
      git reset --hard HEAD@{2}
      ```
      
      ### Reflog Expiration
      
      ```bash
      # Default: 90 days for reachable, 30 for unreachable
      git config gc.reflogExpire 90.days
      git config gc.reflogExpireUnreachable 30.days
      
      # Expire reflog manually
      git reflog expire --expire=now --all
      git gc --prune=now
      ```
      
      ## Worktrees
      
      ### Multiple Working Directories
      
      ```bash
      # Add worktree
      git worktree add ../project-feature feature-branch
      
      # Add worktree with new branch
      git worktree add -b new-feature ../project-new-feature main
      
      # List worktrees
      git worktree list
      
      # Remove worktree
      git worktree remove ../project-feature
      
      # Prune stale worktree info
      git worktree prune
      ```
      
      ### Leave the worktree before you remove it
      
      `git worktree remove` deletes the directory, including the one your shell may be
      sitting in. The removal itself succeeds, so nothing looks wrong — the damage
      shows up in the *next* commands, which run in a directory that no longer exists:
      
      ```
      fatal: not a git repository (or any of the parent directories): .git
      pwd: error retrieving current directory: getcwd: cannot access parent directories
      ```
      
      Both read like a broken repository or a bad `-C` path, and that is the trap:
      the repository is fine, the shell's cwd is a ghost. It bites hardest in agent
      and script sessions, where `cd` persists between calls and the removal often sits
      several commands before the failure.
      
      Make the cleanup end somewhere that still exists:
      
      ```bash
      cd /path/to/project/main            # or any surviving directory
      git worktree remove /path/to/project/feature-x
      ```
      
      Give the removal an absolute path, not a relative one resolved from inside the
      target. Leave `--force` off unless you mean it: without the flag the command
      refuses a worktree with uncommitted changes, which is the check that catches a
      removal you did not intend. If a later command already failed this way, `cd` to a real directory
      and re-run it — do not start diagnosing the repository.
      
      **That check only covers tracked files.** Everything `.gitignore` matches is
      deleted without a prompt and without `--force` being involved: build output,
      caches, a local `.env`, and whatever a job or a script wrote into the worktree.
      Those are exactly the files that exist in no other copy, and git offers nothing
      to recover them from — they were never in the object store.
      
      Measured on git 2.55.0: a worktree holding an ignored `.env` and an ignored
      `build/out.txt` was removed by a plain `git worktree remove` with exit 0, no
      warning, and both files gone. Read what would be lost before removing:
      
      ```bash
      git -C <worktree> status --porcelain --ignored   # `!!` lines are what dies silently
      ```
      
      #### A background process keeps the cwd it started with
      
      Moving the shell out first does not save a process that is already running
      there. A watcher started earlier — a pipeline waiter, a `tail -f`, a poll loop —
      holds the removed directory as its cwd, and the failure surfaces at the wrong
      moment and in the wrong place: it exits non-zero with the same `getcwd` message
      *after* it has already printed its result, so the harness reports the task as
      failed while the thing being watched succeeded.
      
      ```
      tick 5: 265071 success
      TERMINAL: 265071 success
      pwd: error retrieving current directory: getcwd: cannot access parent directories
      [exited with code 1]
      ```
      
      Two habits. Start anything that outlives one command from a directory the work
      will not remove — put the `cd` inside the script rather than relying on the
      caller's cwd. And when a background task reports failure, read the last lines of
      its output before the exit code becomes a statement about what it was watching.
      
      #### A worktree containing submodules needs `--force`
      
      The plain form refuses a worktree that has submodules checked out:
      
      ```
      $ git worktree remove ../feature-x
      fatal: working trees containing submodules cannot be moved or removed
      ```
      
      The message reads like a prohibition, and the branch cannot be deleted while the
      worktree stands (`error: cannot delete branch 'x' used by worktree at …`), so it
      is easy to conclude the worktree has to be torn down by hand. It does not:
      `--force` removes it (measured on git 2.55.0; the refusal is the submodule
      check, and `--force` lifts it).
      
      ```bash
      git -C <worktree> status --porcelain                 # must be empty
      git -C <worktree> log --oneline origin/main..HEAD    # must be empty
      git -C <worktree> stash list                         # must be empty
      git -C <worktree> submodule foreach --quiet --recursive \
          'git stash list'                                 # must be empty — see below
      
      git worktree remove --force /path/to/project/feature-x
      git branch -d feature-x
      git fetch origin --prune       # only where the remote branch is already gone
      ```
      
      `--prune` drops `origin/feature-x` only if that branch no longer exists on the
      remote — it removes refs whose upstream is gone, and nothing above deletes
      anything on `origin`. After a merge on a forge that removes the source branch
      (GitLab's `remove_source_branch_after_merge`, GitHub's auto-delete) the ref is
      already stale and the prune tidies it; otherwise the remote branch is still
      live and the ref belongs there.
      
      **Never put `--prune` on a fetch whose refspec names its source in shorthand.**
      The two are individually correct and destructive together: `--prune` prunes
      against the refspec of the *invocation*, so that one mapping becomes the entire
      set of refs git considers current — and an unqualified source (`main`) is not
      matched against the ref the remote advertises (`refs/heads/main`), so the
      destination looks stale. Git deletes the very ref the same command is about to
      update, and the update then fails because its destination no longer resolves:
      
      ```console
      $ git -C .bare fetch origin main:refs/remotes/origin/main --prune
       - [deleted]         (none)     -> origin/main
         refs/remotes/origin/HEAD has become dangling after refs/remotes/origin/main was deleted
      error: cannot lock ref 'refs/remotes/origin/main': unable to resolve reference 'refs/remotes/origin/main'
       ! 3098286..5872596  main       -> origin/main  (unable to update local ref)
      ```
      
      Afterward `origin/main` is deleted and `refs/remotes/origin/HEAD` survives as a
      **dangling symbolic ref**: the file still points at `refs/remotes/origin/main`,
      which no longer resolves. `for-each-ref refs/remotes/origin` then prints
      nothing at all, because it skips refs it cannot resolve — do not read that as
      both refs having been removed.
      
      Reproduced on git 2.55.0 in a throwaway bare clone. Three neighbouring forms,
      each differing in one thing, all leave both refs intact — so it is the shorthand
      source specifically, not `--prune` and not a single-branch refspec:
      
      ```bash
      git fetch origin --prune                                          # configured refspec — fine
      git fetch origin '+refs/heads/*:refs/remotes/origin/*' --prune    # glob refspec — fine
      git fetch origin refs/heads/main:refs/remotes/origin/main --prune # qualified source — fine
      ```
      
      `git-fetch(1)` states the principle under PRUNING — pruning works "as a function
      of the refspec of the remote" — but spells out only the tag version of the trap
      (`refs/tags/*:refs/tags/*` deleting local tags), not this one.
      
      The combination is easy to reach by accident in a bare-repo layout, because the
      explicit-refspec fetch is the form that layout otherwise requires: `origin/*`
      does not update on its own there, so `fetch origin <branch>:refs/remotes/origin/<branch>`
      is the habit — written with the branch name, which is the shorthand form — and
      adding `--prune` to tidy up after a merge looks like two safe things at once.
      **Repair** is the same fetch without `--prune`; `origin/HEAD` starts resolving
      again by itself once its target exists, so it needs no separate `symbolic-ref`.
      Prune in its own call, or qualify the source as `refs/heads/<branch>`.
      
      The reads are not optional here. Everywhere else `--force` is the flag you
      leave off so the uncommitted-changes check can catch a removal you did not
      intend; with submodules you need it for an unrelated reason, and that check goes
      with it. Run the reads yourself before the flag, not instead of them.
      
      The fourth read is the one that is easy to leave out, and it is the one that
      loses work. A stash belongs to the repository it was created in, so a stash made
      *inside* an initialized submodule is not in the superproject's `refs/stash` — and
      because stashing reverted the change, the submodule is clean from the
      superproject's side too. Measured on git 2.55.0, with a stash sitting in
      `vendor/lib`:
      
      ```
      git -C <worktree> stash list        (empty)
      git -C <worktree> status --porcelain (empty)
      git -C <worktree>/vendor/lib stash list
        stash@{0}: On (no branch): work in the submodule
      ```
      
      All three superproject reads say "clean", and `git worktree remove --force`
      then returns 0 and takes the linked worktree's submodule gitdir
      (`$GIT_COMMON_DIR/worktrees/<id>/modules/<name>/`) with it, so the stash is gone
      with no ref left to recover it from. `submodule foreach --recursive` is what
      sees it; `--quiet` suppresses the `Entering '<path>'` lines so that empty output
      means an empty stash rather than a header.
      
      ### "Merged and clean" is not the whole test — check the worktree's role
      
      A cleanup sweep classifies worktrees by branch state: HEAD contained in
      `origin/main`, tree clean, therefore safe to remove. That test is necessary and
      not sufficient. It says nothing about *which* worktree it matched, and a
      worktree the rest of the setup treats as the primary one can satisfy it too —
      in the bare-repository layout (`project/.bare` plus one directory per branch,
      `project/main`, `project/feature-x`), a `main/` directory that was switched to
      a feature branch and left there stays behind after that branch merges, and then
      classifies exactly like a disposable feature worktree. Git itself knows no such
      role; the convention lives in the directory name, which is precisely why a
      state-only classifier cannot see it.
      
      Removing it deletes the directory every other tool, script and shell in that
      repository points at. The fix for that case is a switch, not a removal:
      
      ```bash
      git -C /path/to/project/main switch main     # fails if another worktree
                                                   # already has main checked out
      git -C /path/to/project/main fetch origin main
      git -C /path/to/project/main merge --ff-only origin/main
      git -C /path/to/project/main branch -d <merged-feature-branch>
      ```
      
      The `fetch` is not optional: bare clones under this layout are often missing
      `remote.origin.fetch`, so `origin/main` never moves on its own and the
      `merge --ff-only` then silently does nothing while looking like it worked. If
      the `switch` reports that `main` is checked out elsewhere, that other worktree
      *is* the primary one — leave both alone and re-read the layout.
      
      So the classifier needs two predicates, not one: the branch state **and** the
      directory's role. Treat the worktree whose basename is `main`, `master` or the
      repository's default-branch name as never-removable; only the others are
      candidates for `git worktree remove`. In one sweep on 2026-08-15 over 171
      bare-layout repositories holding 210 worktrees, 78 sat on a non-default branch;
      12 of those passed the merged-and-clean test, and 9 of the 12 were primary
      directories sitting on a merged feature branch — a role check that ran second
      would have been a role check that ran too late.
      
      The same shape applies to any cleanup rule that matches on state: state answers
      whether the artifact is *finished*, never whether it is *disposable*.
      
      ### A stale worktree is a stale source
      
      A worktree checked out days ago can be many commits behind `origin` — its
      `composer.json`, its "what's on `main`", its API signatures are all whatever they
      were at that checkout, not now. Before basing a **decision** on what a repo
      contains — a dependency version constraint, whether a fix already landed on
      `main`, a class/method signature you're about to code against — read the *current*
      state, not the stale worktree:
      
      ```bash
      git -C <repo> fetch origin && git -C <repo> log --oneline origin/main -3   # or:
      git worktree add ../fresh origin/main                                      # read from a fresh tree
      ```
      
      Three recurring failure modes:
      
      - **Reporting a stale value as fact.** Reading `"^0.13"` from an un-fetched
        worktree and stating "the constraint needs bumping" — when current `main` already
        says `"^0.17 || ^0.18 || ^0.19"`. Verify against `origin/main` before writing the
        claim into a design or PR.
      - **A subagent silently reads a stale checkout.** When you dispatch an agent to
        "read the source" for a decision, name the ref/worktree it must read, and
        re-verify its structural claims (config keys, signatures) against the current
        tree before building on them — half a report can come from an outdated path.
      - **Running a *tool* from the stale worktree, not just reading it.** A script you
        merged an hour ago does not exist in a reference worktree that has not been
        fast-forwarded, so invoking it by path fails with `exit 127` — which reads as a
        broken command, a bad `PATH`, a typo, anything but "the tree is old". The
        remedy is one line, and it belongs at the end of a merge rather than at the
        start of the next debugging session:
      
        ```bash
        git -C <repo>/.bare fetch origin --prune
        git -C <repo>/main merge --ff-only origin/main   # reference worktree usable again
        ```
      
        Note `--ff-only`: a reference worktree that cannot fast-forward has local
        commits and is not a reference worktree any more, which is worth finding out
        loudly. Note also that the fetch above carries no refspec: adding the usual
        `main:refs/remotes/origin/main` next to `--prune` deletes `origin/main`
        outright (see *Never put `--prune` on a fetch whose refspec names its source
        in shorthand* above). In a bare-repo layout `origin/*`
        does not update on its own, so the fetch is not optional — and after a merge the *branch* worktree is usually gone,
        which is exactly when scripts start being invoked from `main/` instead.
      
      Confirm the constructor/signature at the **resolved** dependency version (the one
      installed in `vendor`/`.Build`), not the library's `main` branch — they drift
      (e.g. a value object gaining a required constructor arg between minor releases).
      
      ### Bare-Repo Layouts
      
      With the bare-clone convention (`project/.bare` + one directory per branch),
      relative `worktree add` paths resolve from *inside* `.bare` — see the detailed
      path-resolution rules and recovery steps in the bare-repo section below.
      
      Before nesting a `.bare` into an existing directory, check whether it already
      holds a **plain clone** — mixing the two layouts leaves a repo checkout *and* a
      worktree side by side in one directory. If it already does, see "Consolidating a
      plain clone into the bare layout" below for how to resolve it without losing
      anything.
      
      A reuse guard like `[ -d .bare ] || git clone --bare <url> .bare` silently *keeps*
      whatever `.bare` is already there — which may point at a **different remote** than
      you intend. Two repos can share a short name across hosts (GitHub
      `netresearch/renovate-config` vs GitLab `renovate/renovate-config`) with entirely
      different content, so a worktree off the wrong bare reads the wrong repo. After
      reusing or creating a `.bare`, verify the remote before trusting anything read
      from it:
      
      ```bash
      git -C .bare remote get-url origin   # must match the intended remote
      ```
      
      **`git clone --bare` leaves `remote.origin.fetch` empty, and that breaks
      `--force-with-lease`.** Without a refspec the clone creates no `refs/remotes/*`
      at all, so the lease has no recorded remote state to compare against and the
      push is rejected:
      
      ```text
       ! [rejected]        HEAD -> feature (stale info)
      ```
      
      The message reads as "somebody else pushed", which is the trap — nobody did, and
      the reflex it invites is `--force`, dropping the protection entirely. Two
      remedies, both measured on git 2.55.0. Give the lease its value explicitly:
      
      ```bash
      SHA=$(git ls-remote origin refs/heads/<branch> | cut -f1)
      git push --force-with-lease=<branch>:"$SHA" origin HEAD:<branch>
      ```
      
      Or repair the clone once, after which the ordinary form works and `origin/*`
      starts tracking:
      
      ```bash
      git -C .bare config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*'
      git fetch origin
      ```
      
      The second is what a long-lived bare layout wants: with no refspec, `git fetch
      origin` updates nothing, so every later "is my branch behind?" question is
      answered from refs that never move.
      
      Skipping this once meant building an ADR off a *different* repo's config until a
      version/branch mismatch exposed it.
      
      ### Consolidating a plain clone into the bare layout
      
      When a directory holds both — a plain clone *and* a `.bare` added later —
      consolidating means deleting one repository's object store. The checks that make
      that safe are not the ones `git status` offers.
      
      **Resolve the project path to an absolute one first.** `git -C "$bare"` makes
      `.bare` git's working directory, so every relative path in these commands gains a
      second `<project>` component — the same trap as `worktree add` below, and here it
      would fetch from nowhere and put the worktree inside `.bare`.
      
      **A ref is preserved when some ref in the survivor contains it**, which is not
      the same as being an ancestor of `origin/main`. An unmerged side branch preserves
      a sha perfectly well, and the ancestry test would condemn it — the mirror of the
      squash-merge case in "Is This Branch Safe to Delete?" below.
      
      **Enumerate every ref, not just `refs/heads`.** A commit can be held by a tag or
      the stash and by no branch at all: `branch --contains` reports it as absent in
      both repositories, so a branch-only sweep never asks about it and the loss is
      silent. `for-each-ref --contains` searches all refs and names the one that
      answers; empty output is the finding.
      
      **Notes are a different shape and no ancestry check sees them.** A notes ref is
      its own history, not an ancestor of the commit it annotates, so it is accounted
      for and rescued as a ref in its own right — the loop below lists it, and the
      rescue needs its own refspec.
      
      ```bash
      project=$(cd <project> && pwd)          # absolute — see above
      old="$project/.git"; bare="$project/.bare"
      
      git --git-dir="$old" for-each-ref --format='%(refname)' \
          refs/heads refs/tags refs/notes refs/stash |
        while read -r ref; do
          sha=$(git --git-dir="$old" rev-parse --quiet --verify "$ref^{commit}") || continue
          printf '%-45s %s\n' "$ref" \
            "$(git --git-dir="$bare" for-each-ref --contains "$sha" --format='%(refname)' | head -1)"
        done
      # a blank second column is a ref that dies with the old repository
      ```
      
      `refs/stash` covers only `stash@{0}`; the deeper entries are reflog, so the loop
      above sees one of them however many there are. `git stash list` is what counts
      them, and the rescue below is what preserves them.
      
      **Three things `git status` will not tell you.** A stash is not in the working
      tree and dies with the repository — `git stash list`, then `git stash show
      --stat` on each; tool-generated churn is the common case, but that is a finding,
      not an assumption. An ignored build artefact never shows without
      `--ignored`. And a worktree registered here may live anywhere on disk.
      
      **`prunable` is a broken registration, not an absent directory.** `git worktree
      list` marks an entry prunable when it cannot resolve it; the directory may still
      be sitting there with uncommitted work. `ls -d` each path before believing the
      marker.
      
      **Rescue before discarding.** The doomed `.git` is a valid fetch source, so
      nothing has to reach the remote first:
      
      ```bash
      git -C "$bare" fetch "$old" '+refs/heads/<branch>:refs/heads/<branch>'
      git -C "$bare" fetch "$old" '+refs/tags/*:refs/tags/*'    # a refs/heads refspec carries no tags
      git -C "$bare" fetch "$old" '+refs/notes/*:refs/notes/*'  # nor notes
      
      # Stashes: EVERY entry, not just the top one. `+refs/stash:refs/stash` carries
      # stash@{0} alone, and `stash branch` consumes entries one at a time while
      # shifting the rest — so turn each into its own ref before touching the reflog.
      git -C "$project" stash list --format='%H' | nl -ba | while read -r n sha; do
        git -C "$project" branch "rescue-stash-$n" "$sha"
      done
      git -C "$bare" fetch "$old" '+refs/heads/rescue-stash-*:refs/heads/rescue-stash-*'
      ```
      
      **Park, don't delete.** The reflog is the one thing that is not in the other
      repository. `find` moves dotfiles too and leaves the directory itself in place,
      so a shell sitting in it survives:
      
      ```bash
      find "$project" -mindepth 1 -maxdepth 1 ! -name .bare -exec mv {} <parked>/ \;
      # GNU mv batches the same thing: -exec mv -t <parked>/ {} +
      ```
      
      **The worktree you then add is stale and has no upstream.** `clone --bare` writes
      no `[branch]` section, so `git pull` there has nothing to pull from, and the
      local branch sits wherever the bare clone found it:
      
      ```bash
      git -C "$bare" worktree add "$project/main" main
      git -C "$project/main" merge --ff-only origin/main
      git -C "$project/main" branch --set-upstream-to=origin/main main
      ```
      
      `tests/test_advanced_git_recipes.sh` runs this sequence end to end.
      
      ### Bare-Worktree Project Layout (Recommended)
      
      **One directory per branch; never switch branches in the same folder.**
      
      Rationale: IDEs that index the tree (gopls, IntelliJ, VS Code) choke on in-place branch switches, and running parallel work on feature branches without losing the main-branch state is painful. Using a bare repo with per-branch subdirectories gives you parallel checkouts, cheap hotfix spin-ups, and a main checkout that's never "dirty because I was exploring".
      
      ```
      /projects/<repo>/
      ├── .bare/          # bare git repository (clone --bare)
      ├── main/           # main branch worktree
      ├── feature-x/      # optional feature branch worktree
      └── bugfix-y/       # optional bugfix branch worktree
      ```
      
      **`main/` is reference only — all work happens in fresh worktrees.** `main/` exists for reading code, running `git fetch`, and serving as the base for new worktrees. Never commit, edit, or develop directly in it. Every task — feature, fix, release, experiment — gets its own fresh worktree on its own branch, cut from a freshly fetched `origin/main` (in this layout `origin/*` does NOT auto-update, so always fetch first):
      
      ```bash
      git -C /projects/<repo>/.bare fetch origin
      git -C /projects/<repo>/.bare worktree add -b <branch> /projects/<repo>/<branch> origin/main
      ```
      
      Remove the worktree and the local branch once the PR merges.
      
      **Set up a new project this way:**
      
      ```bash
      cd ~/projects
      mkdir <repo> && cd <repo>
      git clone --bare <repository-url> .bare
      
      # Make the bare clone behave like a regular origin fetch target.
      cd .bare && git config remote.origin.fetch "+refs/heads/*:refs/remotes/origin/*" && cd ..
      
      # Check out main into a named subdirectory.
      git -C .bare worktree add ../main main
      ```
      
      **Work on a new branch = create a new folder:**
      
      ```bash
      git -C .bare worktree add ../feature-x feature-x    # or -b for a new branch
      cd feature-x
      # ... edit, commit, push ...
      cd ..
      git -C .bare worktree list          # audit trail of what's checked out
      git -C .bare worktree remove ../feature-x   # clean up when the PR merges
      ```
      
      **Any relative path argument is resolved relative to `.bare/`, not your shell's current directory** — `git -C <dir>` makes `<dir>` git's working directory for the whole command, including how it interprets the `<path>` argument to `worktree add`. This applies to every form of the command, regardless of whether `-b` comes before or after the path:
      
      ```bash
      # WRONG — both of these land INSIDE the bare repo
      git -C .bare worktree add -b feature-x feature-x origin/main
      git -C .bare worktree add feature-x -b feature-x origin/main
      # → creates .bare/feature-x as a worktree of the bare repo — the
      #   worktree is functional (it has a .git file pointing at .bare),
      #   but it violates the sibling-layout convention and confuses any
      #   tooling that walks up looking for the repository root
      ```
      
      Note on the branch argument: plain `worktree add <path> <branch>` requires the branch to already exist. To create a fresh branch at the same time, use `worktree add -b <branch> <path> <start>` as shown above, or create the branch separately first. Both forms have the same path-resolution behaviour.
      
      **Prefer absolute paths.** They're unambiguous regardless of where the command runs from — important when scripts, agents, or `/loop`-style sessions construct the command without a fixed cwd. Sibling-relative `../` works for humans typing from the repo parent but is brittle anywhere else.
      
      ```bash
      # RIGHT — absolute path (preferred; works from any cwd)
      git -C /projects/<repo>/.bare worktree add -b feature-x /projects/<repo>/feature-x origin/main
      
      # Also fine when you're certain of cwd — sibling-relative resolves
      # against .bare/, so '..' lands next to it.
      git -C .bare worktree add -b feature-x ../feature-x origin/main
      ```
      
      Use `origin/main` as the start point, not local `main` — local `main` is only current if you explicitly fast-forwarded it after the fetch.
      
      **Recovery if you already created the worktree in the wrong place:**
      
      ```bash
      # Use absolute paths for BOTH source and destination. The -C .bare
      # flag makes `worktree move` resolve relative paths against .bare/,
      # so `.bare/feature-x` would be interpreted as `.bare/.bare/feature-x`
      # and wouldn't find the misplaced worktree.
      git -C /projects/<repo>/.bare worktree move \
        /projects/<repo>/.bare/feature-x \
        /projects/<repo>/feature-x
      ```
      
      (Alternatively, drop `-C .bare` and run from the repo parent; then the
      source `.bare/feature-x` resolves against that parent rather than
      against `.bare/`.)
      
      When removing a worktree leaves a dangling branch reference (e.g., after deleting the physical directory manually), `git worktree prune` in `.bare/` cleans up the metadata.
      
      **Batch cleanup after a session of PRs:**
      
      ```bash
      # For each branch whose PR landed, delete the worktree + local branch:
      for wt in feature-x bugfix-y sync/template-foo; do
        git -C /projects/<repo>/.bare worktree remove --force /projects/<repo>/$wt
        git -C /projects/<repo>/main branch -D "$wt" 2>&1 | tail -1
      done
      
      # Remote-side pruning (delete stale remote-tracking refs):
      git -C /projects/<repo>/main fetch --prune origin
      ```
      
      **The explicit list is the point — never replace it with a directory glob.**
      `for wt in feature-x bugfix-y …` is the record of what *this* session created.
      A pattern like `for d in */release-v*` looks like the same loop with less
      typing, but it cannot tell your worktrees from ones that were already there,
      and `worktree remove --force` plus `branch -D` is not undoable from the shell.
      Build the list as you create each worktree and iterate over that.
      
      (Observed 2026-08-06: a 19-repo release sweep cleaned up with `*/release-v*`
      and took out a leftover `release-v1.12.0` worktree and its branch from an
      unrelated release months earlier. Nothing was lost only because that branch had
      already been merged and tagged — `git branch -D` reports the dangling sha, but a
      sweep that discards its output never sees it.)
      
      Two habits make the blast radius survivable when the list is wrong anyway:
      
      ```bash
      # 1. See what would go, before anything goes.
      git -C /projects/<repo>/.bare worktree list
      
      # 2. Prefer plain -d over -D: it refuses to delete an unmerged branch.
      git -C /projects/<repo>/main branch -d "$wt" || echo "unmerged, kept: $wt"
      ```
      
      ### "Is This Branch Safe to Delete?" Is Not an Ancestry Question
      
      `git merge-base --is-ancestor <branch> origin/main` answers *is this commit an
      ancestor* — which is not the same question as *is this work safe to delete*. A
      squash-merged PR puts the content on `main` under a single new commit, so the
      branch's own SHAs are nowhere in `main`'s history and the ancestry test says
      "unmerged" about work that shipped weeks ago. Rebase-merge does the same.
      
      Both directions of that mistake matter:
      
      - **Reporting it** as unmerged inflates the number of branches that look like
        unsaved work, and buries the few that genuinely are. (Observed 2026-08-06: a
        cleanup reported 18 unmerged branches; 10 had merged PRs, 6 had deliberately
        closed ones, and exactly 2 commits existed nowhere else — one of them a real
        bug fix that had never landed.)
      - **Acting on it** is only safe in the conservative direction. Ancestry is a
        sufficient condition for "already on main", never a necessary one, so using it
        to *keep* is safe and using it to *delete* is not what makes deletion safe —
        what makes it safe is that ancestry never produces a false positive.
      
      Ask the question you actually mean:
      
      ```bash
      # Authoritative: what happened to the PR this branch belongs to?
      gh pr list --repo "$R" --head "$B" --state all --json number,state --jq '.[].state'
      glab api "projects/$P/merge_requests?source_branch=$B&state=all" | jq -r '.[].state'
      
      # No PR, or the host is unreachable: is every commit patch-equivalent upstream?
      git cherry origin/main "$B"     # "-" = an equivalent patch is upstream, "+" = not
      [ "$(git cherry origin/main "$B" | grep -c '^+')" -eq 0 ] && echo "nothing unique"
      ```
      
      `git cherry` compares patch-ids, so it sees through a rebase and through a
      squash of a single-commit branch. It does *not* see through a squash of several
      commits into one — there the PR state is the only honest answer. A branch whose
      PR is `CLOSED` (not merged) holds work somebody deliberately dropped: that is a
      judgment call for a human, not a mechanical delete.
      
      ### Sync the Base Before Branching (Stale-Base Trap)
      
      A per-branch worktree layout makes it easy to branch from a checkout that is
      weeks behind the remote. A feature branch's green pipeline only proves
      correctness **against its base** — if the base has moved, a clean auto-merge can
      combine your change with newer code in a way no pipeline ever tested, landing a
      regression on the default branch.
      
      Guard against it at both ends of the work:
      
      ```bash
      # At the START of work in any worktree — a stale checkout is not proof
      # a file is missing. Sync the base, then branch from the fresh tip.
      git -C <worktree> status -sb
      git -C <worktree> fetch origin main         # update origin/main directly, don't touch the current branch
      git -C <worktree> switch -c feature/x origin/main
      
      # BEFORE merging — rebase onto the current remote base so CI validates the
      # REAL merge result, not the branch against a stale base.
      git fetch origin
      git rebase origin/main
      ```
      
      **Note for bare-repo layouts:** a bare clone often lacks
      `remote.origin.fetch`, so `git fetch origin` never updates `origin/<base>`.
      Fetch the base branch explicitly — `git fetch origin main:refs/remotes/origin/main`
      — or set the refspec once (see the bare-worktree setup above).
      
      After any merge, verify structural invariants on the **merged base** (e.g. that
      every cross-reference still resolves), not just on the branch — that is the only
      check that catches a regression introduced by the merge itself.
      
      ### Cross-Worktree Static Analysis Gives False Positives
      
      Running a static analyzer (Rector, php-cs-fixer, PHPStan, ESLint) from ONE
      worktree against source files in ANOTHER resolves symbols through the *running*
      worktree's autoloader/vendor, not the branch under test. When the other branch
      changed a method signature, added an interface method, or renamed a symbol, the
      analyzer sees a mismatch that does not exist on that branch — e.g. Rector's
      `RemoveExtraParametersRector` "removing" arguments that match the branch's own
      wider signature, or a type-resolution rule firing (or staying silent) because a
      new interface method is absent from — or present only in — the running worktree.
      
      This bites hardest in bare+worktree layouts where only one worktree has a built
      `.Build/vendor` (or `node_modules`), so cross-worktree runs are the only local
      option. Treat each such finding as suspect: real, or an artifact of resolving
      against the wrong tree? CI runs each branch in isolation with its own install,
      so **CI is authoritative** — reproduce a doubtful finding by building deps inside
      the target worktree, or defer to CI, rather than "fixing" a phantom.
      
      ### Corrupt Per-Worktree Index: `fatal: unable to read <sha>`
      
      When `git status`, `git diff --staged`, or a pre-commit hook in a linked
      worktree dies with `fatal: unable to read <sha>`, and `git fsck` reports
      `invalid sha1 pointer in cache-tree of worktrees/<wt>/index` plus "missing
      blob"s that only the index references (phantom entries for files in neither
      HEAD nor the working tree), the worktree's **private index file** is corrupt —
      the object store and your edits are fine.
      
      The complete fix, losing nothing (working-tree files, including uncommitted
      edits, are untouched):
      
      ```bash
      rm <gitdir>/worktrees/<wt>/index     # e.g. .bare/worktrees/release-1.8.0/index
      git -C <worktree> read-tree HEAD     # rebuild the index from HEAD
      git -C <worktree> status             # edits reappear as modified; re-stage them
      ```
      
      Per-path repair (`git restore --staged <path>`) is NOT enough when the
      cache-tree itself is broken: the next command fails on the next missing blob.
      Deleting the index is the whole fix. (Observed twice in one 2026-08-27 fleet
      sweep, in freshly created release worktrees; the first repair attempt went
      per-path and hit the second phantom blob immediately.)
      
      ### Use Cases
      
      ```bash
      # Work on hotfix while keeping feature work
      git worktree add ../project-hotfix hotfix/critical-bug
      cd ../project-hotfix
      # Fix bug
      git commit -am "fix: critical bug"
      cd ../project-main
      
      # Review PR without stashing
      git worktree add ../pr-review origin/feature-branch
      cd ../pr-review
      # Review code
      ```
      
      ### Pushing to Fork Remotes (Multiple Remotes Pitfall)
      
      When using worktrees with multiple remotes (e.g., `origin` = upstream, `fork` = your fork),
      `git push fork main` can silently say "Everything up-to-date" even when the fork is behind.
      
      **Why it fails:**
      - Local `main` tracks `origin/main` (upstream), not `fork/main`
      - `git push fork main` resolves the tracking ref, which may already match what git considers current
      - The fork remote never receives the new commits
      
      **Fix: Use explicit refspec with `HEAD:main`**
      
      ```bash
      # WRONG - may silently do nothing
      git push fork main
      
      # CORRECT - explicitly pushes current HEAD to fork's main
      git push fork HEAD:main
      ```
      
      **Full pattern for syncing a fork:**
      
      ```bash
      # In a worktree where origin=upstream, fork=your-fork
      git fetch origin
      git merge --ff-only origin/main   # Update local main from upstream
      git push fork HEAD:main            # Explicitly push to fork
      ```
      
      **Rule:** When pushing to a non-tracking remote, always use explicit refspec
      (`HEAD:<branch>` or `<local-branch>:<remote-branch>`) to avoid silent no-ops.
      
      ### Rebasing a PR Whose Head Lives on a Fork
      
      The section above assumes you know which remote to push to. For an open pull
      request you do not: the head branch may live in the upstream repo, in your
      personal fork, or in an organisation fork, and `origin` is none of those by
      default. Pushing a rebased branch to `origin` does not fail — it **creates a new
      branch in the upstream repository** and leaves the pull request untouched, so
      the rebase looks done and nothing about the PR changed.
      
      Resolve the head repository before the push:
      
      ```bash
      gh pr view <n> --repo OWNER/REPO \
        --json number,headRepositoryOwner,headRepository,headRefName \
        --jq '"\(.headRepositoryOwner.login)/\(.headRepository.name) \(.headRefName)"'
      # -> some-org/repo refactor/modernize-float-classes
      ```
      
      Add that repository as its own remote and push there:
      
      ```bash
      git -C .bare remote add nrfork git@github.com:some-org/repo.git
      git -C <worktree> push --force-with-lease=<branch>:<sha> nrfork HEAD:<branch>
      ```
      
      **The lease needs the remote's real SHA, not a short one you expanded.** With
      `--force-with-lease=<branch>:<sha>` git compares that value against the remote
      ref; a SHA typed out from an abbreviated one is simply a different object name
      and the push is rejected with `stale info`, which reads like someone else
      pushed. Take the value from a fetch:
      
      ```bash
      git -C .bare fetch nrfork <branch>
      git -C .bare rev-parse nrfork/<branch>      # use exactly this in the lease
      ```
      
      **If you pushed to the wrong remote:** the branch is a stray in the upstream
      repo, delete it (`git push origin --delete <branch>`) and push again to the
      head repository. The pull request never saw the wrong push, so nothing else
      needs repairing — but check `gh pr view <n> --json headRefOid` afterwards to
      confirm the PR now points at the rebased commit.
      
      **Fetch every fork before a batch of rebases.** With three PRs on two forks,
      one `git fetch --all` up front turns three lease lookups into local reads and
      makes "which of these is behind main?" a single loop over `git merge-base`.
      
      ## Submodules
      
      ### Adding Submodules
      
      ```bash
      # Add submodule
      git submodule add https://github.com/org/repo.git libs/repo
      
      # Add at specific branch
      git submodule add -b main https://github.com/org/repo.git libs/repo
      
      # Initialize submodules after clone
      git submodule init
      git submodule update
      
      # Clone with submodules
      git clone --recurse-submodules https://github.com/org/main-repo.git
      ```
      
      ### Updating Submodules
      
      ```bash
      # Update all submodules to latest
      git submodule update --remote
      
      # Update specific submodule
      git submodule update --remote libs/repo
      
      # Update and merge
      git submodule update --remote --merge
      
      # Pull in main repo and submodules
      git pull --recurse-submodules
      ```
      
      ### Submodule Commands
      
      ```bash
      # Run command in all submodules
      git submodule foreach 'git pull origin main'
      
      # Check status
      git submodule status
      
      # Remove submodule
      git submodule deinit libs/repo
      git rm libs/repo
      rm -rf .git/modules/libs/repo
      ```
      
      ## Git Hooks
      
      > **Comprehensive guide**: See [`git-hooks-setup.md`](git-hooks-setup.md) for hook framework
      > comparison (lefthook, captainhook, husky, pre-commit), detection logic, and agent rules.
      
      ### Client-Side Hooks
      
      ```bash
      # .git/hooks/pre-commit
      #!/bin/bash
      npm run lint
      npm run test
      
      # .git/hooks/commit-msg
      #!/bin/bash
      # Validate commit message format
      
      # .git/hooks/pre-push
      #!/bin/bash
      npm run test:e2e
      ```
      
      ### Server-Side Hooks
      
      ```bash
      # hooks/pre-receive
      #!/bin/bash
      # Validate pushes before accepting
      
      # hooks/post-receive
      #!/bin/bash
      # Deploy after push accepted
      
      # hooks/update
      #!/bin/bash
      # Per-branch validation
      ```
      
      ### Hook Management with Husky (Node.js)
      
      ```json
      // package.json
      {
        "husky": {
          "hooks": {
            "pre-commit": "lint-staged",
            "commit-msg": "commitlint -E HUSKY_GIT_PARAMS",
            "pre-push": "npm test"
          }
        },
        "lint-staged": {
          "*.{js,ts}": ["eslint --fix", "prettier --write"]
        }
      }
      ```
      
      Other frameworks: **lefthook** (Go, `lefthook.yml`), **captainhook** (PHP, `captainhook.json`),
      **pre-commit** (Python, `.pre-commit-config.yaml`). See [`git-hooks-setup.md`](git-hooks-setup.md).
      
      ## Advanced Merging
      
      ### Merge Strategies
      
      ```bash
      # Recursive (default)
      git merge feature
      
      # Ours (keep our changes)
      git merge -s ours feature
      
      # Subtree (merge into subdirectory)
      git merge -s subtree --allow-unrelated-histories other-repo/main
      
      # Octopus (merge multiple branches)
      git merge feature1 feature2 feature3
      ```
      
      ### Merge Options
      
      ```bash
      # No fast-forward
      git merge --no-ff feature
      
      # Squash merge
      git merge --squash feature
      
      # Merge with message
      git merge -m "Merge feature X" feature
      
      # Abort merge
      git merge --abort
      ```
      
      ### Rerere (Reuse Recorded Resolution)
      
      ```bash
      # Enable rerere
      git config rerere.enabled true
      
      # After resolving conflict, it's recorded
      # Next time same conflict occurs, auto-resolved
      
      # View recorded resolutions
      git rerere status
      
      # Forget resolution
      git rerere forget path/to/file
      ```
      
      ## Git Attributes
      
      ### Line Endings
      
      ```bash
      # .gitattributes
      * text=auto
      *.sh text eol=lf
      *.bat text eol=crlf
      *.png binary
      ```
      
      ### Diff and Merge
      
      ```bash
      # .gitattributes
      *.min.js binary
      *.lock -diff
      *.pdf diff=pdf
      
      # Custom diff driver
      [diff "pdf"]
        textconv = pdftotext -layout
      ```
      
      ### Export Ignore
      
      ```bash
      # .gitattributes
      .gitignore export-ignore
      .github export-ignore
      tests/ export-ignore
      ```
      
      `export-ignore` removes a path from `git archive` — and from the tarball the
      package registries build from it. It does not affect clones, checkouts or
      worktrees.
      
      **Never build a test workspace with `git archive`.** What a repository marks
      `export-ignore` is what a *consumer* does not need, which is very close to what
      a *test run* does need: `tests/`, the analyser and fixer configs, the CI config.
      A workspace unpacked from `git archive` is missing exactly those, and the tools
      do not agree on how loudly to say so.
      
      ```bash
      mkdir -p /tmp/ws
      
      # Wrong: silently drops every export-ignore'd path
      git archive HEAD | tar -x -C /tmp/ws
      
      # Right: the working tree, minus what the run reinstalls itself
      tar -c --exclude=vendor --exclude=node_modules --exclude=.git . | tar -x -C /tmp/ws
      (cd /tmp/ws && composer install --no-interaction)   # or npm ci, etc.
      ```
      
      The exclusions are what the run reinstalls, so the install is part of the
      recipe, not an afterthought: copy the tree without `vendor`, then put `vendor`
      back. Skipping that second step leaves a workspace where no gate can run at all,
      which is a different failure from the one this section is about.
      
      Measured on such a workspace (2026-09-18, `TYPO3-Documentation/guides-php-domain`
      at `5b4a38c`, PHP 8.2.30, vendor copied in from a full checkout so only the
      export-ignored files were absent):
      
      | Gate | Exit | What it did |
      |---|---|---|
      | `phpunit --testsuite=unit` | 2 | `Test directory "/app/tests/unit/" not found` |
      | `phpstan --configuration=phpstan.neon` | 1 | config file gone |
      | `php-cs-fixer check` | **0** | checked nothing, **wrote `.php-cs-fixer.dist.php` and `.gitignore` into the workspace**, printed `Config file created, re-run the command to put it in action.` |
      
      The first two are loud. The third is the one that matters: a green exit from a
      run that inspected zero files. Its output does say `Config file created`, so it
      is not invisible to someone reading the log — but a gate that decides on the
      exit status alone, which is most of them, cannot tell it from a clean run. It
      also leaves the generated config behind, so the *second* run reports 33 of 49
      files needing fixes against rules the project never chose. A tool that falls
      back to defaults when its config is missing turns a truncated workspace into a
      passing verification.
      
      **The check, before the workspace is used for anything:** read the export rules,
      then list the tracked files that did not arrive.
      
      ```bash
      git check-attr export-ignore -- tests/ phpstan.neon   # "export-ignore: set" -> archive drops it
      
      git ls-files -z | while IFS= read -r -d '' f; do
        [ -e "/tmp/ws/$f" ] || printf '%s\n' "$f"
      done
      ```
      
      Ask it in that direction — *which tracked files are missing* — rather than
      diffing two file listings. A plain
      `diff <(git ls-files) <(cd /tmp/ws && find . -type f)` drowns: run against the
      archive workspace above it produced **8256** lines, of which 8134 were `vendor/`
      and other untracked files present in the tree but not in git, and only 116 were
      the answer. The loop prints those 116 and nothing else, and prints nothing at
      all for a workspace copied with `tar`.
      
      ## Performance Optimization
      
      ### Large Repositories
      
      ```bash
      # Shallow clone
      git clone --depth 1 https://github.com/org/repo.git
      
      # Sparse checkout
      git clone --filter=blob:none --sparse https://github.com/org/repo.git
      cd repo
      git sparse-checkout set src/
      
      # Partial clone
      git clone --filter=blob:none https://github.com/org/repo.git
      ```
      
      ### Git LFS
      
      ```bash
      # Install LFS
      git lfs install
      
      # Track large files
      git lfs track "*.psd"
      git lfs track "*.zip"
      
      # View tracked patterns
      git lfs track
      
      # View LFS files
      git lfs ls-files
      
      # Pull LFS files
      git lfs pull
      ```
      
      #### Removing LFS without a history rewrite
      
      LFS earns its keep for assets that are large *and* churn. For a working set
      that is neither — 238 files / 39 MB in one demo repository — it costs the
      org's monthly LFS bandwidth allowance instead: every `actions/checkout` with
      `lfs: true` and every deploy-side `git lfs pull` fetches the whole set again,
      and ~20 CI runs a day turned 39 MB into 1.5 GB/day (netresearch/typo3-demo#231,
      2026-08-29). Moving the files back into plain git needs no `git lfs migrate
      export` and no force-push:
      
      ```bash
      # 1. Drop the tracking rules (delete the file if LFS was all it held).
      git rm .gitattributes            # or: git lfs untrack '<pattern>' per rule
      
      # 2. Re-store every tracked file as a plain blob. --renormalize is the point:
      #    the stat cache still calls the files clean, so a bare `git add` stores
      #    nothing; renormalize re-runs the (now absent) clean filter.
      git add --renormalize .
      
      # 3. Prove the conversion byte for byte: staged blob sha256 == LFS object id.
      #    Process substitution, not a pipe — a `| while` runs in a subshell and
      #    loses the counter, so nothing could ever fail. shasum covers macOS.
      sha=$(command -v sha256sum >/dev/null && echo sha256sum || echo 'shasum -a 256')
      bad=0
      while read -r oid _ path; do
        [ "$(git cat-file blob ":$path" | $sha | cut -d' ' -f1)" = "$oid" ] \
          || { echo "MISMATCH $path"; bad=$((bad + 1)); }
      done < <(git lfs ls-files -l)
      [ "$bad" -eq 0 ] || { echo "$bad file(s) are not the LFS content — do not commit"; exit 1; }
      ```
      
      Then strip every LFS *step* the repository carries — `lfs: true` on each
      `actions/checkout` (grep the workflows: a reusable build w
    • ci-cd-integration.md 20.6 KB
      # CI/CD Integration
      
      ## Watching CI from the CLI
      
      When waiting on PR CI from the command line (or an agent), use the native
      watchers — do not hand-roll a poll loop:
      
      ```bash
      # Wait for all PR checks; exits non-zero if any required check fails.
      gh pr checks <pr> --repo <owner/repo> --watch --fail-fast
      
      # Watch a single workflow run by ID (when you have the run, not the PR).
      gh run watch <run-id> --repo <owner/repo> --exit-status
      ```
      
      Gate on the **exit code**, not on parsed output. `gh pr checks` and
      `gh run watch` already handle pending-state representation, the appearance of
      newly-triggered runs, and refresh.
      
      ### `$(gh api … || echo "")` captures the ERROR BODY as data
      
      On an HTTP error (404, 403 rate limit, 5xx) `gh api` prints the JSON error
      body to **stdout** and does not apply `--jq` to it — so the classic fallback
      capture poisons the variable instead of emptying it:
      
      ```bash
      # Wrong: on a 404, $rel holds '{"message":"Not Found",…}' — non-empty,
      # and every [[ -n "$rel" ]] downstream believes it is real data
      rel=$(gh api "repos/$R/releases/latest" --jq .tag_name 2>/dev/null || echo "")
      
      # Right: output reaches the variable only when the call SUCCEEDED
      if out=$(gh api "repos/$R/releases/latest" 2>/dev/null); then
        rel=$(jq -r '.tag_name // empty' <<<"$out")
      else
        rel=""
      fi
      ```
      
      Verified with gh 2.97 (2026-08-13): a fleet survey using the wrong form
      classified never-released repos from their own error bodies. The same trap
      applies to `glab api` — gate on the exit code, never on non-empty stdout.
      
      ### `gh run watch` takes the RUN id — a check-run's `details_url` ends in the JOB id
      
      Reaching for `gh run watch` from a check-run means extracting an id from its
      `details_url`, which looks like `…/actions/runs/<RUN>/job/<JOB>`. Taking the
      trailing number hands over the **job** id, and that failure is silent in the
      worst way: the API answers `404`, `gh run watch` prints `failed to get run` and
      **still exits 0**. Under `--exit-status`, in a background waiter, that reads as
      "the run finished, and it passed" — the waiter never waited at all.
      
      ```bash
      # Wrong: trailing number is the job id
      rid=$(gh api "repos/$R/check-runs/$ID" --jq '.details_url' | grep -oE '[0-9]+$')
      
      # Right: name the segment
      rid=$(gh api "repos/$R/check-runs/$ID" --jq '.details_url' \
            | grep -oE 'runs/[0-9]+' | cut -d/ -f2)
      
      # Or take the run id straight from the commit's check-runs
      gh api "repos/$R/commits/$SHA/check-runs?per_page=100" --paginate \
        --jq '.check_runs[] | select(.status != "completed")
              | "\(.name)\trun=\(.details_url | capture("runs/(?<r>[0-9]+)").r)"'
      ```
      
      Because the exit code cannot distinguish "watched and passed" from "never
      found the run", assert the run exists before watching it, or re-read the
      check's state afterwards rather than trusting the watcher's return.
      
      ### `commits/<sha>/check-runs` returns one entry per run, not per check
      
      A second workflow run on the **same commit** — a closed-and-reopened pull
      request, a `workflow_dispatch`, an `on: schedule` firing — adds another set of
      check-runs beside the first. The old ones keep their old conclusion, so a
      counter over that endpoint reports failures that no longer exist:
      
      ```
      All security checks:           success   22:13:53
      All security checks:           failure   22:10:01   <- previous run, same SHA
      fuzz / Preflight (event gate): success   22:11:32
      fuzz / Preflight (event gate): failure   22:09:34   <- previous run, same SHA
      ```
      
      Measured on a pull request whose fuzz preflight was red until an upstream
      workflow was fixed, then closed and reopened. On that one commit:
      
      | Query | Failures reported |
      | --- | --- |
      | every check-run with `conclusion == "failure"` | 2 |
      | newest run per name only | 0 |
      | `gh pr checks` | 0 |
      
      Note which names duplicate: the two that had actually failed. Checks that were
      green in both runs duplicate too, but silently — so a spot check on a green
      name shows two identical entries and looks harmless.
      
      `gh run rerun` does **not** do this — it updates the existing check-run in
      place. Verified on two pull requests where a rerun turned a red check green:
      one entry each, conclusion `success`.
      
      So when the question is "is this pull request green now":
      
      ```bash
      # Right: gh pr checks reports the latest attempt per check
      gh pr checks "$PR" --repo "$R" --json name,bucket
      
      # If you must use the API, keep only the newest run per name
      gh api "repos/$R/commits/$SHA/check-runs?per_page=100" --paginate \
        --jq '[.check_runs[]] | group_by(.name)
              | map(max_by(.started_at)) | map(select(.conclusion == "failure")) | length'
      ```
      
      ### Ask which step failed before reading any log
      
      The jobs API names it in one call, and the name is usually enough to reproduce
      the failure locally:
      
      ```bash
      gh api repos/$R/actions/jobs/$JOB \
        --jq '.steps[] | select(.conclusion=="failure") | .name'
      ```
      
      Reaching for the log first costs rounds that return nothing, because the
      failing step's output is often not in what you get back (see the next section)
      and every keyword filter then matches the surrounding noise instead —
      provisioning, `tar` invocations, a `harden-runner` audit stream, and in one case
      a validator's own green `Errors: 0` summary. Four such calls produced no
      information about a failure whose step was called `Python lint`; the API call
      above answered on the first try.
      
      #### Reproduce with the version CI runs, not the one your cache holds
      
      "Usually enough to reproduce locally" has a condition attached: the same tool at
      the same version. A step written as `uvx ruff check .` or `npx <tool>` resolves
      to the newest release on every CI run, while the local cache can be months old
      and answer green on the identical file. The local pass then reads as "the check
      is wrong" or "it is an infrastructure problem", and the actual finding is never
      seen.
      
      Read the version out of the workflow file, and when it is unpinned force the
      current one:
      
      ```bash
      grep -nE 'uvx |npx |pip install|--version' .github/workflows/<file>.yml
      uvx ruff@latest check .          # what an unpinned CI step actually runs
      uvx ruff@0.16.0 check .          # or the pin, where there is one
      ```
      
      Measured: a required check went red on scripts that had not changed, reporting
      `FURB105`, `EXE001` and `BLE001`, while the locally cached ruff passed the same
      files. All three rules are non-preview in current ruff, so the CI runner had
      simply moved to a release that enforces them and the cache had not. Three real
      findings, invisible until the versions matched.
      
      ### A green pre-commit run is not a green CI lint step
      
      Two distinct reasons, and the second is the one that is easy to miss.
      
      **The run was not green.** `pre-commit` reports a reformatting hook as a
      failure, and the report is easy to discard: `pre-commit run --files … | grep -vE
      'Skipped|Passed' | tail -4` printed `- files were modified by this hook` and `2
      files reformatted`, which was read as success. Never filter or truncate
      `pre-commit` output — `files were modified by this hook` is a failed run, and
      the hook names are how you tell which.
      
      **The versions differ.** Even a genuinely green run proves only what the
      *pinned* version thinks. `.pre-commit-config.yaml` pins
      `astral-sh/ruff-pre-commit` by `rev`, while the CI step pins its own
      (`uvx ruff@0.16.0 check`). Measured on the same file: ruff 0.15.14, the
      pre-commit pin, reported `All checks passed!` while ruff 0.16.0, the CI pin,
      failed it on `SIM905`. The rule was newer than the hook. So keep the two pins in
      parity and let dependency updates move them together; when they disagree, the
      CI's version is the one that decides, and running the linter merely "by name" is
      not enough.
      
      ### The step output of a failed job comes from the RUN's log archive
      
      `gh api repos/$R/actions/jobs/$JOB/logs` and `gh run view --job $JOB --log`
      routinely hand back only the runner's own preamble — image provisioning,
      hardening-agent chatter, `Cleaning up orphan processes` — with the failing
      step's output absent entirely. One of them can also return an empty body and
      exit 0. Filtering that noise then produces nothing and reads as "the log says
      nothing about why it failed", which sends you diagnosing the wrong layer.
      
      Download the **run's** archive instead. It contains one text file per job, with
      the real step output:
      
      ```bash
      gh api "repos/$R/actions/runs/$RUN/logs" > /tmp/logs.zip
      unzip -q -o /tmp/logs.zip -d /tmp/logs
      grep -rn '##\[error\]' /tmp/logs | grep -viE 'armour|agentservice|pam_unix'
      ```
      
      On 2026-08-12 this was the difference between three unexplained merge-queue
      ejections and one line — `[ERROR] Undefined constant …PHPUnitSetList::PHPUNIT_110`
      — that named the cause outright. The job-level calls had been tried first and
      showed nothing but setup.
      
      **While the run is still in progress, the archive does not exist yet** —
      `gh run view --log` answers `run … is still in progress; logs will be
      available when it is complete`, and so does the run-archive endpoint. A job
      that has already FAILED inside that running run is still readable through the
      job endpoint, with two traps: `gh api` refuses a body containing terminal
      escape sequences unless told otherwise, and the body arrives ANSI-colored:
      
      ```bash
      gh api "repos/$R/actions/jobs/$JOB/logs" --allow-escape-sequences \
        | sed 's/\x1b\[[0-9;]*m//g'
      ```
      
      Without the flag the only output is
      `the response contains terminal escape sequences; pass --allow-escape-sequences
      to output it anyway` — one line, exit 0, easy to mistake for an empty log.
      Measured on 2026-08-18 (a matrix leg had failed while the rest of the run was
      still executing): the job-level body carried the full PHPUnit failure, so "job
      logs show only preamble" (above) is a per-job lottery, not a law — try the job
      endpoint first while the run lives, fall back to the run archive once it is
      complete.
      
      Note the archive is per *run*, so for a queue ejection you need the
      `gh-readonly-queue/*` run, not the pull request's own:
      
      ```bash
      gh run list --repo "$R" --limit 20 --json headBranch,databaseId,conclusion \
        --jq '.[] | select(.headBranch | startswith("gh-readonly-queue"))'
      ```
      
      Hand-rolled `gh pr checks | jq` poll loops re-derive those semantics from
      undocumented field shapes (a running check's `conclusion` may be `""`, `null`,
      or absent) and are a recurring source of bugs. The sharpest one: a poll run
      **immediately after a push, reopen, or re-trigger** reads "0 pending" *before*
      the freshly-queued run has registered, so the loop reports a false "all green"
      and you act prematurely. A bare `[ "$pending" -eq 0 ] && break` snapshot is true
      both before runs start and after they finish — it cannot tell the two apart.
      
      If you must hand-roll (e.g. watching something with no native watcher), gate on
      a **named required check reaching a terminal `pass`/`fail` state**, never on a
      zero-pending count, and confirm the run belongs to the current head SHA first.
      
      `pr-status.sh --json` answers this in one field: **`checks_settled`** is true
      only when nothing is pending *and* every required context has reported at
      least once. Gate on that rather than re-deriving it — and note the `NEXT:` line
      does not carry it, because the review branches of the ladder outrank every CI
      branch. On a repo with the `copilot_code_review` ruleset, `NEXT:` says
      `request-review` from the second a commit lands and keeps saying it; the CI
      state now rides along in that answer's `why`, but a script should read the
      field.
      
      **`gh run list --jq` is not `jq`: it takes no `--arg`.** The filter is evaluated
      by `gh`'s embedded jq, which accepts the expression and nothing else, so
      `--jq --arg s "$SHA" '… select(.headSha==$s) …'` exits with
      `unknown command "s" for "gh run list"`. Inside a poll loop that failure is
      silent in the worst way: `pending=$(gh run list … )` captures the empty output,
      the `[ "$pending" = "0" ]` test is false forever, and the loop reports "still
      running" until its timeout — describing a condition it never actually
      evaluated. Filter with the flag instead of a jq variable:
      
      ```bash
      gh run list --repo "$R" --commit "$SHA" --json name,status,conclusion \
        --jq '.[] | "\(.status) \(if (.conclusion // "") == "" then "-" else .conclusion end) \(.name)"'
      ```
      
      **`//` does not catch the empty string, and a running run's `conclusion` is `""`.**
      jq's alternative operator only replaces `null` and `false`, so `\(.conclusion // "-")`
      renders a running run as an empty field, and a loop that treats "conclusion is
      non-empty" — or `.conclusion // .status` — as "finished" reports a run that is
      still going as done. Test the emptiness explicitly, as above, or filter on
      `.status != "completed"`. This cost a false "all green" on three workflows that
      were still queued.
      
      `--commit` (`-c`) is the supported way to scope runs to one SHA, and it is what
      the merge-triggered runs on the base branch need after a merge — `--branch main`
      alone also matches the runs of every earlier merge.
      
      The general rule behind it: **a polling loop must distinguish "the query failed"
      from "the condition is not met yet"**, and it has to *act* on the difference.
      Counting instead of testing emptiness is not enough on its own — a failed query
      still yields the empty string, and `[ "$pending" = "0" ]` reads that as "not met"
      and keeps polling. Gate on the exit status, or refuse a value that is not a
      number:
      
      ```bash
      if ! out=$(gh run list --repo "$R" --commit "$SHA" --json status 2>&1); then
        echo "query failed: $out" >&2; exit 1
      fi
      pending=$(printf '%s' "$out" | jq '[.[] | select(.status != "completed")] | length')
      case $pending in ''|*[!0-9]*) echo "unusable count: ${pending@Q}" >&2; exit 1 ;; esac
      [ "$pending" -eq 0 ] && break
      ```
      
      A loop that cannot fail loudly waits out its whole timeout and then reports on a
      condition it never evaluated.
      
      ## Before you add or edit a workflow file: read the repo's Actions policy
      
      A workflow that violates the repository's Actions policy fails at **`Set up job`**
      — before a single step runs — so the log shows no step output and the failure
      looks unrelated to the change. One API call answers it:
      
      ```bash
      gh api repos/$R/actions/permissions --jq '{allowed_actions, sha_pinning_required}'
      gh api orgs/${R%/*}/actions/permissions --jq '{allowed_actions, sha_pinning_required}'
      ```
      
      Check the **org** as well: it can require pinning that the repo's own setting
      does not mention. With `sha_pinning_required: true`, every `uses:` needs a full
      commit SHA — a tag ref (`actions/checkout@v6`) is refused:
      
      ```bash
      gh api repos/actions/checkout/git/ref/tags/v6 --jq '.object.sha'   # then: @<sha> # v6
      ```
      
      The same policy is what the workflow-security linters enforce, so the cost of
      skipping this check is not one red check but several: adding a job with two
      unpinned `uses:` turned **six** checks red at once — `Set up job`, zizmor,
      Opengrep, CodeQL, SonarCloud and the aggregate security gate — all reporting the
      same two lines. Prefer dropping an action over pinning it where the runner
      already provides the tool (`setup-php` for a script that needs no extensions,
      `setup-node` for a plain `npx`).
      
      ## A CI linter's finding is not refuted by a differently-scoped local run
      
      Running the same linter locally and getting the same *number* of findings is not
      evidence that they are the same findings. Two axes differ routinely:
      
      - **Scope.** A CI integration usually reports only what the pull request
        *changed*; a local invocation lints the whole file or the whole tree.
      - **Policy source.** The CI action may load a config, a baseline, or an
        organisation policy the local binary does not see — and vice versa.
      
      Observed: a local `zizmor` run flagged three unpinned `uses:` and the same three
      appeared on the base branch, which read as "pre-existing, not mine". CI's zizmor
      reports changed code only, and its three findings were the author's own two new
      lines plus a note on them. Same count, disjoint findings, and the wrong
      conclusion was stated publicly before the alerts were read.
      
      Compare **locations**, not counters:
      
      ```bash
      gh api "repos/$R/code-scanning/alerts?tool_name=<tool>&pr=$PR&state=open" \
        --jq '.[] | "\(.rule.id)\t\(.most_recent_instance.location.path):\(.most_recent_instance.location.start_line)"'
      ```
      
      If the paths and lines are yours, the finding is yours — whatever the local run
      says.
      
      ## A presence check is not a correctness check: assert the count, not the existence
      
      A pipeline gate written as "the thing is there" passes for every wrong number of
      the thing. One repository ended its publish job with
      
      ```bash
      grep -q '<select name="repository"' public/index.html \
        || { echo "dropdown injection failed" >&2; exit 1; }
      ```
      
      which is satisfied by one copy and equally by the 190 that had silently
      accumulated, because the injecting script was not idempotent and each run
      appended another. The gate existed precisely to catch a broken injection and was
      structurally blind to the failure that happened. It shipped 496 KB index pages
      for months.
      
      Where a build injects, generates or de-duplicates something, the assertion is a
      count and an equality, across every artifact rather than the one that happens to
      be convenient:
      
      ```bash
      seen=0
      while IFS= read -r -d '' f; do
        seen=$((seen + 1))
        n=$(grep -o '<select name="repository"' "$f" | wc -l)
        [ "$n" -eq 1 ] || { echo "$f carries $n, expected exactly 1" >&2; exit 1; }
      done < <(find public -name index.html -print0)
      [ "$seen" -gt 0 ] || { echo "no index.html found — nothing was checked" >&2; exit 1; }
      ```
      
      Three shapes in that loop, each of which has silently broken a gate:
      
      - **`-print0` with a NUL-delimited read**, not `for f in $(find …)`. The
        unquoted substitution word-splits, so a path containing a space becomes two
        paths and the gate checks neither.
      - **Process substitution, not a pipe.** `find … | while …` puts the loop in a
        subshell, where `exit 1` ends the subshell and leaves the caller running — the
        gate then reports its failure to nobody, unless `set -e` and `pipefail` happen
        to be on in whatever copied it.
      - **Fail closed on an empty list.** Finding no artifacts is not a pass. A gate
        that validated zero files is exactly as green as one that validated all of
        them, which is how a renamed output directory goes unnoticed.
      
      Two habits follow:
      
      - **`grep -q` answers "at least one".** Count instead — but count *matches*:
        `grep -c` counts matching **lines**, so generated or minified output that puts
        several occurrences on one line reads as 1 and the gate passes on exactly the
        artifact most likely to be wrong. `grep -o … | wc -l` is the one that answers
        the question asked.
      - **Prove the guard fails.** Disable the fix, run the gate, watch it exit
        non-zero with the message you expect, restore. A gate never seen red is a
        claim; the run that reddens it is the evidence. The same session's idempotency
        fix passed its own count check while still growing the file by one byte per
        run — the count was right and the artifact was not, which only a
        byte-comparison of two consecutive runs surfaced.
      
      ## Git Mirror Repositories
      
      Use `git clone --mirror` + `git push --mirror` to keep a target repository in sync with an
      upstream source — for example, mirroring a public TYPO3 repository into a private GitLab
      instance or creating read-only forks for controlled distribution.
      
      ```bash
      git clone --mirror "$SOURCE_URL" repo.git
      cd repo.git
      git push --mirror "$TARGET_URL"
      ```
      
      ### Default Branch Requirement
      
      `git push --mirror` pushes **all** refs from the source and **deletes** any ref at the target
      that no longer exists in the source. GitLab and GitHub will refuse to delete their repository's
      default branch, causing the push to fail with an error like:
      
      ```
      remote: GitLab: You can only delete protected branches using the web interface.
      error: failed to push some refs to 'git@gitlab.example.com:org/repo.git'
      ```
      
      **Root cause**: the target was initialised with a default branch (e.g. `main`) that does not
      exist in the upstream (e.g. source uses `12.4`). Every mirror run tries to delete `main` and
      GitLab refuses.
      
      **Fix — preferred**: create the target as an **empty project** (no README, no initial commit).
      The default branch is then set automatically when the first `git push --mirror` runs.
      
      **Fix — existing repo**: change the default branch in Settings before mirroring. On GitLab:
      *Settings → Repository → Default branch*. On GitHub: *Settings → Branches → Default branch*.
      
      ### Notes-Ref Gotcha
      
      `git push --mirror` deletes any ref at the target that does not exist in the upstream. If you
      store cache data (e.g. split commit maps) in `refs/notes/*` on the mirror target, those refs
      will be wiped on every sync run because they are absent from the upstream.
      
      Do not rely on `refs/notes/*` for persistent caching in mirror repositories. Store such state
      in a separate repository, a file in object storage, or a CI/CD cache artifact.
      
      ### Example CI Job (GitLab)
      
      ```yaml
      mirror-sync:
        image: alpine/git:2.43.0
        script:
          - git clone --mirror "$SOURCE_URL" repo.git
          - cd repo.git
          - git push --mirror "$TARGET_URL"
        only:
          - schedules
      ```
      
    • claude-code-hooks.md 18.8 KB
      # Claude Code Hooks for Workflow Enforcement
      
      Ready-to-drop `settings.json` hook recipes that enforce the critical rules from `SKILL.md` at tool-invocation time. These run in the Claude Code harness, not in git — they catch violations before the command executes.
      
      For git-side hooks (pre-commit, pre-push), see `references/git-hooks-setup.md` instead.
      
      ## Where to put these
      
      | Scope | File | When to use |
      |-------|------|-------------|
      | Personal, all projects | `~/.claude/settings.json` | Enforce your own rules everywhere |
      | Team, committed to repo | `.claude/settings.json` | Enforce team rules for this project |
      | Personal, one project | `.claude/settings.local.json` | Overrides for this project only |
      
      Merge carefully — read the existing `hooks:` block, add to arrays, never replace wholesale.
      
      ## Recipe 1: Block `gh pr merge` When Review Threads Are Open
      
      Blocks any `gh pr merge` invocation that would merge a PR with unresolved review threads or a non-CLEAN merge state.
      
      The merge-gate logic is non-trivial, so it ships as an external script — `scripts/merge-gate.sh` in this skill — rather than inline JSON. Install it and reference it:
      
      ```bash
      cp <skill>/scripts/merge-gate.sh ~/.claude/hooks/merge-gate.sh
      chmod +x ~/.claude/hooks/merge-gate.sh
      ```
      
      ```json
      {
        "hooks": {
          "PreToolUse": [
            {
              "matcher": "Bash",
              "hooks": [
                {
                  "type": "command",
                  "if": "Bash(gh pr merge *)",
                  "command": "~/.claude/hooks/merge-gate.sh"
                }
              ]
            }
          ]
        }
      }
      ```
      
      
      The hook denies `gh pr merge` when review threads are unresolved or `mergeStateStatus != CLEAN` (`UNSTABLE` — a non-required check red — is still a deny). It deliberately does not gate on `reviewDecision`: repos without a required-approval rule report `""` and merge legitimately when CLEAN. It parses the three PR-reference forms `gh pr merge` accepts (plain number — with long/short and `=`-joined flags before it — full URL, `owner/repo#N`), paginates the thread list, and honors an explicit `--repo`/`-R` flag — without that, the PR number resolves against the CWD repo and produces false denials when the command targets another repo. If parsing or the PR lookup fails, the hook allows the call rather than producing false-positive denies. Two usage caveats: the hook evaluates at Bash-call time, so never chain a wait-until-CLEAN loop and the merge in one invocation (wait in one call, merge in the next); and it is a personal-harness gate — server-side branch protection remains the authoritative enforcement.
      
      ## Recipe 2: Reject Edits to Installed Cache Paths
      
      Prevents `Write` / `Edit` / `MultiEdit` from targeting `~/.claude/skills/...`, `~/.claude/plugins/cache/...`, or any `.bare/` path — which would be silently clobbered on the next update.
      
      ```json
      {
        "hooks": {
          "PreToolUse": [
            {
              "matcher": "Write|Edit|MultiEdit",
              "hooks": [
                {
                  "type": "command",
                  "command": "jq -r '.tool_input.file_path // \"\"' | { read -r P; case \"$P\" in */.claude/skills/*|*/.claude/plugins/cache/*|*/.claude/plugins/marketplaces/*|*/.bare/*) echo \"{\\\"hookSpecificOutput\\\":{\\\"hookEventName\\\":\\\"PreToolUse\\\",\\\"permissionDecision\\\":\\\"deny\\\",\\\"permissionDecisionReason\\\":\\\"cache path rejected: $P — edit the source worktree instead\\\"}}\";; esac; }"
                }
              ]
            }
          ]
        }
      }
      ```
      
      ## Recipe 3: Warn on Unauthorized Squash
      
      Does not block — just emits a warning. Squash is legitimate on repos with a squash policy; full blocking would be too noisy. The warning is enough to prompt the user to confirm intent.
      
      ```json
      {
        "hooks": {
          "PreToolUse": [
            {
              "matcher": "Bash",
              "hooks": [
                {
                  "type": "command",
                  "if": "Bash(gh pr merge *)",
                  "command": "jq -r '.tool_input.command' | grep -qE -- '--squash\\b' && echo '{\"systemMessage\":\"⚠ squash merge requested — confirm the repo uses squash policy; default is atomic commits\"}' || true"
                }
              ]
            }
          ]
        }
      }
      ```
      
      ## Recipe 4: Auto-Lint Go Files After Write/Edit
      
      Runs `golangci-lint` on the file's directory after any write. Silent-success; logs only on failure.
      
      ```json
      {
        "hooks": {
          "PostToolUse": [
            {
              "matcher": "Write|Edit|MultiEdit",
              "hooks": [
                {
                  "type": "command",
                  "command": "jq -r '.tool_input.file_path // .tool_response.filePath // \"\"' | { read -r F; [ -z \"$F\" ] && exit 0; case \"$F\" in *.go) cd \"$(dirname \"$F\")\" && golangci-lint run --fast 2>&1 | head -40 || true;; esac; } 2>/dev/null"
                }
              ]
            }
          ]
        }
      }
      ```
      
      Swap `golangci-lint` for your project's linter of choice. For PHP: `vendor/bin/php-cs-fixer fix --dry-run -- "$F"`. For JS/TS: `bunx eslint "$F"`.
      
      ## Recipe 5: Sentinel on "Verified" Claims Without Tool Output
      
      Experimental — uses a `prompt` hook to audit assistant messages declaring pass/verified. Only runs on Stop events (end of assistant turn).
      
      ```json
      {
        "hooks": {
          "Stop": [
            {
              "hooks": [
                {
                  "type": "prompt",
                  "prompt": "Check the just-ended assistant turn. If it contains any of: 'verified', 'tested', 'all green', 'tests pass', 'should work now', 'try again' — AND no Bash/Read tool result in the same turn shows actual command output substantiating the claim — emit a systemMessage warning. Otherwise stay silent.\n\n$ARGUMENTS"
                }
              ]
            }
          ]
        }
      }
      ```
      
      This is a soft guardrail — the hook can't block a past message, only flag it to the user. Useful as a "you said tested but didn't run anything" reminder.
      
      ## Deploying Hooks
      
      After editing `settings.json`:
      
      ```bash
      # Validate JSON syntax first — broken JSON silently disables ALL settings
      jq -e '.hooks' .claude/settings.json
      
      # Reload config — open and close the /hooks menu in Claude Code, or restart
      ```
      
      The settings watcher only picks up new hook files if `.claude/` existed at session start. If you created `.claude/settings.json` during a session, open `/hooks` once to reload.
      
      ## Recipe 6: Block `git commit` While Staged Content Has Conflict Markers
      
      A resolver that exits non-zero does **not** stop a following `git add -A && git commit` in the same Bash call — the `&&` chain starts fresh after the failed command, so the commit lands with `<<<<<<<` in the tree and nothing reports it until a later gate run. `verify-git-workflow.sh` finds markers on demand; this denies the commit that would create them.
      
      ```bash
      cp <skill>/scripts/conflict-marker-gate.py ~/.claude/hooks/
      chmod +x ~/.claude/hooks/conflict-marker-gate.py
      ```
      
      ```json
      {
        "hooks": {
          "PreToolUse": [
            {
              "matcher": "Bash",
              "hooks": [
                { "type": "command", "command": "python3 $HOME/.claude/hooks/conflict-marker-gate.py" }
              ]
            }
          ]
        }
      }
      ```
      
      It fires only when the command actually runs `git commit` (`git -C <dir> commit` and `git -c k=v commit` included) and only when staged blobs carry a marker. It matches `<<<<<<<` and `>>>>>>>` (each followed by a space) at line start but deliberately **not** a bare `=======`, which is an ordinary RST section underline and would otherwise deny every docs commit. Any error, non-repo cwd or unreadable diff allows the command — the gate never blocks on its own failure.
      
      Verify it against both directions before relying on it: stage a file containing a real marker (must deny) and a `.rst` file with a `=======` underline (must allow).
      
      ## Recipe 7: Block a git write inside the reference `main/` worktree
      
      `references/advanced-git.md` states that `main/` is reference only. The rule
      gets broken anyway because breaking it needs no intent: one Bash call omits its
      `cd`, inherits the working directory of an earlier call, and the commit lands on
      the shared branch locally — where a later push or a `jj git export` moves it
      under every sibling worktree. Prose cannot catch an inherited working directory;
      a gate reads it out of the payload.
      
      ```bash
      cp <skill>/scripts/reference-worktree-gate.py ~/.claude/hooks/
      chmod +x ~/.claude/hooks/reference-worktree-gate.py
      ```
      
      ```json
      {
        "hooks": {
          "PreToolUse": [
            {
              "matcher": "Bash",
              "hooks": [
                { "type": "command", "command": "python3 $HOME/.claude/hooks/reference-worktree-gate.py" }
              ]
            }
          ]
        }
      }
      ```
      
      It denies `commit`, `add`, `merge`, `rebase`, `cherry-pick`, `revert`, `am`,
      `stash` and `reset --hard` when the effective directory is a `main/` (or
      `master/`) that sits beside a `.bare`. It deliberately does **not** deny
      `git -C <path> …` or a call that starts with `cd <path> &&` — both name where the
      write lands, which is the shape it is steering toward — nor reads, `git fetch`,
      or `git worktree add`, which are what the directory is for.
      
      Nor `git merge --ff-only` / `git pull --ff-only`: the reference checkout has to
      be *current* to serve as a base, so refreshing it is the directory's purpose and
      a fast-forward can neither create a commit nor discard work. `reset --hard`
      reaches the same state by discarding and stays refused. (That exemption is not
      theory — the gate blocked its own author's `reset --hard` in `main/` minutes
      after installation, which is how the over-block was found.) Roots default to
      `~/projects` and `~/p`; set `GIT_WORKTREE_ROOTS` to a colon-separated list for
      others.
      
      Already have a Bash gate? Extend it instead of adding a second hook — the
      directory test is three lines (`basename in {main,master}`, parent under a known
      root, `.bare` sibling exists) and reusing one hook keeps the denial messages in
      one place.
      
      Verify both directions before relying on it: `python3 scripts/test_reference_worktree_gate.py`
      builds a throwaway layout and checks that a commit in `main/` denies while
      `git -C <branch> commit` and a commit in a branch worktree pass.
      
      ## Recipe 8: Deny a blanket `git add` while untracked files are lying around
      
      Shipped in `scripts/validate_git_command.py` as `blanket_git_add()` (PreToolUse, Bash). `git add -A`, `git add --all` and `git add .` stage every untracked, non-ignored file in the tree; the rule "add the paths you changed by name" was written down and still violated — a `var/` DI-container cache (40k lines) rode into a commit and had to be amended and force-pushed out (2026-08-20). The gate reads the repository the command names (`git -C <dir>`) or the payload's `cwd`, asks `git status --porcelain --untracked-files=all`, and denies only when `??` entries exist, listing them. A clean tree, a tree with only tracked edits, a named path and `git add -u` pass. Escape hatch, shared with the destructive-git gate: `DESTRUCTIVE_GIT_GATE_OFF=1`. The deny message is the specification (see below): it names the files, the rule, the incident and the way out.
      
      ## Recipe 9: Deny a git write that does not name its directory
      
      Shipped in `scripts/validate_git_command.py` as `git_write_without_named_dir()` (PreToolUse, Bash). `cd` survives between tool calls, so a `git commit`, `push`, `reset`, `rebase`, `cherry-pick`, `worktree` or any other write that omits its directory runs wherever the previous call stopped — and in the wrong repository it does not fail, it succeeds there. The rule "every call names its directory" was written down and still lapsed under a long chain of commands: on 2026-09-03 a `git push` ran in a simulator checkout instead of the addon worktree, and only a branch that did not exist there kept it from pushing. Reads had an advisory for the same shape, delivered once per session; a write cannot afford a warning its author may not read, so writes are denied until they name where they run, with `git -C /abs/path …` or a `cd /abs/path &&` in the same scope (a `cd` inside a quoted payload counts, as it does for the advisory). Reads keep the advisory; sharper gates (blanket add, conflict markers) report first. The listing forms of subcommands that otherwise write pass, because they change nothing: `git stash list`, `git stash show`, `git worktree list`, and `git branch` / `git tag` without an operand or with `-l`/`--list` — unless a writing flag (`-d`, `-D`, `-m`, `-f`, …) rides along. Denying them sent `git stash list` in a cleanup sweep back as a write (2026-09-14).
      
      The write is found by scanning tokens rather than by one pattern, because a pattern that knew only `-c` missed `git --no-pager push` and one that required a statement separator missed `env X=1 git push`. Global options are skipped until the subcommand; `-C` and `--git-dir` among them count as naming the directory, but only with a real operand, since git reads `git -C "" push` as no directory change at all (checked against git 2.55.0). A `cd` likewise counts only with a real operand. `cd && git push` leaves the inherited directory in place; `cd -` does change it, to `$OLDPWD`, but names no directory the reader of the command can see, which is what the gate is asking for.
      
      Quoting decides whether text is a command. A quoted run is blanked before the scan, so `git -C /r commit -m "fix git push"` is not read as a second, unnamed write. The exception is a payload a local shell will run: `bash -c 'git push'` is the write it looks like and is denied, while `bash -c 'cd /repo && git push'` passes because the `cd` is in the payload's own scope. `ssh host '…'` is deliberately not inspected — its working directory is on another machine, which is not what this gate is about.
      
      A heredoc body is data, not a command. `cat > doc.md <<'EOF' … git commit … EOF` writes text ABOUT a commit; it makes none. Scanning it denied writing documentation, release notes and the gate's own test fixtures — the same false denial the harness-local ancestor of this gate produced, from the same cause. Bodies are blanked before the scan, with one exception that decides the rule: an UNQUOTED body expands, so a `$( … )` or backtick span inside one really does run and stays in, while the prose around it does not. In a quoted body nothing expands, so the whole body goes. The blanking is length-preserving, because the scan reasons about offsets when it looks backwards for a `cd`.
      
      The same blind spot bit a harness-local attribution gate on the same day, in its other form: it read a `git commit` inside `bash -c '…'` as text and let an undisclosed commit through. Both are the one question — does this span reach a shell? — and a hook that answers it in one place should answer it in the other.
      
      The escape hatch counts only where a shell would read it as an environment assignment, and only on the statement it prefixes: as a substring it disabled the gate from inside a commit message that merely named it (which the messages in this repository do), and as a whole-command test `DESTRUCTIVE_GIT_GATE_OFF=1 true; git push origin main` disabled it for a write the assignment never prefixed. That predicate is shared with `blanket_git_add()`, which had the same weakness.
      
      Escape hatch, shared with the destructive-git gate: `DESTRUCTIVE_GIT_GATE_OFF=1`. Verify both directions: `python3 -m unittest discover -s scripts -p 'test_*.py'` denies `git push origin x` and passes `git -C /repo push origin x`.
      
      ## Anti-Patterns
      
      | Anti-pattern | Why wrong | Fix |
      |--------------|-----------|-----|
      | Using `xargs` on stdin JSON | xargs splits on spaces; breaks paths with spaces | `{ read -r F; ... "$F"; }` pattern |
      | Forgetting `2>/dev/null \|\| true` on PostToolUse | Hook failure pollutes transcript | Wrap non-blocking hooks |
      | `Write\|Edit` matcher without file-path extraction | Hook runs on wrong files | `jq -r '.tool_input.file_path'` |
      | Blocking hooks that hit flaky services | One GitHub-API outage blocks all merges | Soft-fail: warn instead of deny for infra-dependent gates |
      | Per-hook large shell scripts inline in JSON | Unreadable, un-testable | Keep inline ≤3 lines; call external script for more |
      | An exception the gate reads from the environment | The hook is its own process; a `VAR=1 cmd` prefix never reaches it, so the documented way out is inert | Read it off the command text, anchored as a leading assignment, and copy the mechanism from the gate already shipping in that hook |
      | A deny text whose promises nothing tests | The message is the contract; an untested promise is usually the case the gate gets wrong | One test per clause of the message — see below |
      | A gate that scans a heredoc body | A body is data unless it expands; scanning it denies writing any document that mentions the command | Blank the body, length-preserving, keeping `$( … )` spans in an unquoted one |
      | Blanking from a `<<` on sight | `<<` is also an arithmetic left shift, `<<<` a here-string, and a `<<EOF` inside a quoted argument is prose — each looks like an opener, so blanking from there swallows every invocation that follows and the gate goes quiet exactly where it should fire | Recognise an opener only outside quotes, and blank only up to a terminator that is actually present; exclude `<<<`, and leave the lines as script when no terminator follows. Do not mask quoted runs first — a delimiter may carry its own quotes (`<<'EOF'`) and masking them stops the opener matching at all |
      | A gate built from an incident that covers one command shape | An incident is plural; the shape you remember is rarely the only one it used | Extract every command shape from the transcript and assert the predicate on each |
      
      ### The deny message is a specification
      
      Whatever a denial says is what its author will be held to, and the exception
      a message carves out is the case most likely to be wrong — it is the one the
      implementation had to think about separately. Turn each clause into a test
      before shipping the gate.
      
      The forge-body language gate is the worked example. Its first message made
      three claims, and the gate honoured one of them:
      
      | Clause of the message | Test | First version |
      |---|---|---|
      | a German body is denied | German prose → `deny` | held |
      | quoting a German string inside an English body "is fine" | English body with a German stack trace → not `deny` | denied it |
      | `FORGE_LANGUAGE_GATE_OFF=1` exempts a German repository | the documented command verbatim → not `deny` | inert: read from the environment, which a Bash prefix never reaches |
      
      Both failures were found by writing the tests from the message rather than
      from the implementation. A fourth clause — that the exemption is a prefix and
      not a mention — entered the message only with the fix, which is itself the
      pattern: sharpening a promise is how the missing case gets named, so
      re-derive the tests whenever the message changes.
      
      A gate that denies also needs its threshold calibrated against a negative
      corpus wider than the documents that motivated it. In that gate a German
      function-word marker list contained `mit`, which is the licence every skill
      repository names. A false deny blocks legitimate work, so in a denying gate a
      marker that fires on the negative corpus is worse than a missing one — and
      the calibration belongs in a test rather than a comment, because a marker
      list is a decision and a comment cannot fail when the decision is reversed.
      
    • code-quality-tools.md 12.5 KB
      # Code Quality Tools
      
      Shell linting, formatting, smart fixup commits, and structural diffs.
      
      ## shellcheck - Shell Script Linter
      
      ### Rule Categories
      
      | Prefix | Category | Examples |
      |--------|----------|----------|
      | SC1xxx | Syntax errors | SC1009 (missing `$` on variable), SC1073 (couldn't parse) |
      | SC2xxx | Suggestions/warnings | SC2086 (double quote to prevent globbing), SC2046 (quote to prevent splitting) |
      | SC3xxx | Portability | SC3010 (`[[ ]]` not POSIX), SC3030 (arrays not POSIX) |
      | SC4xxx | Deprecation | Deprecated features that should be avoided |
      
      ### .shellcheckrc Configuration
      
      Place `.shellcheckrc` in the project root:
      
      ```ini
      # Default shell dialect
      shell=bash
      
      # Globally disabled rules
      disable=SC1091  # Don't follow sourced files
      disable=SC2154  # Referenced but not assigned (common with env vars)
      
      # Enable optional checks
      enable=require-variable-braces
      enable=check-unassigned-uppercase
      ```
      
      ### Common Rules and Fixes
      
      ```bash
      # SC2086: Double quote to prevent globbing and word splitting
      # Bad:
      echo $var
      # Good:
      echo "$var"
      
      # SC2046: Quote command substitution to prevent splitting
      # Bad:
      files=$(find . -name "*.sh")
      # Good:
      files="$(find . -name "*.sh")"
      
      # SC2155: Declare and assign separately to avoid masking return values
      # Bad:
      local foo="$(mycmd)"
      # Good:
      local foo
      foo="$(mycmd)"
      
      # SC2164: Use cd ... || exit to handle cd failure
      # Bad:
      cd /some/dir
      # Good:
      cd /some/dir || exit 1
      ```
      
      ### Gotcha: Command Substitution with Empty Output
      
      `set -e` fires when `var=$(failing_cmd)` — the assignment propagates the non-zero exit code and
      aborts the script. **However**, `set -e` cannot detect a command that exits 0 but produces
      **no output**. An empty variable is a valid result from the shell's point of view. No shellcheck
      rule covers this case for regular (non-`local`) variables.
      
      This means downstream code that uses `$var` without checking can silently misbehave. A common
      consequence: an empty variable interpolated into a refspec becomes an accidental delete operation.
      
      ```bash
      # Silent failure — git commit-tree succeeds but produces no output
      # (e.g. tree already exists, permission issue, or wrong arguments).
      # set -e does NOT fire here. commit is empty string "".
      commit=$(git commit-tree "$tree" -p "$parent" -m "msg")
      
      # Downstream: the empty commit SHA becomes a delete refspec.
      # This pushes ":refs/tags/v1.0" — which DELETES the tag on the remote.
      git push origin "${commit}:refs/tags/v1.0"
      ```
      
      Always add an explicit empty-value guard after command substitution whose output is load-bearing:
      
      ```bash
      tag="v1.0"
      commit=$(git commit-tree "$tree" -p "$parent" -m "msg")
      if [ -z "${commit}" ]; then
        echo "ERROR: git commit-tree produced no output for tag ${tag}" >&2
        exit 1   # or: continue, if inside a loop
      fi
      git push origin "${commit}:refs/tags/${tag}"
      ```
      
      Note: SC2155 (`Declare and assign separately to avoid masking return values`) only applies to
      `local var=$(cmd)` — where the `local` builtin masks the exit code. For plain `var=$(cmd)`, the
      exit code is propagated and `set -e` works normally. The empty-output case is distinct and not
      covered by any shellcheck rule.
      
      ### Gotcha: Capturing an exit code, in a script that runs under `set -e`
      
      A shell test runner — one that asserts "this input must exit 1, that one must exit 0" — walks into
      two traps that both fail in the same misleading direction: **every case collapses at once**, so the
      runner looks wholly broken rather than subtly wrong.
      
      **`env` cannot call a shell function.** `env VAR=1 my_func` execs a *program*; the function is
      invisible to it, so the run dies with `127` and every case fails identically. Set the variable in a
      subshell instead of reaching for `env`:
      
      ```bash
      guard() { python3 ./scripts/guard.py "$@"; }
      
      # Wrong: env execs a binary, never the function -> 127 on every case.
      env -u FLAG FLAG=1 guard "$fixture"
      ```
      
      **`set -e` inside `$( )` aborts before the exit code is reported — under `sh`, not under `bash`.**
      The substitution's subshell inherits `set -e`, so the case that is *supposed* to fail kills it
      before the `echo $?` that was going to capture the code. The shell decides whether this bites, and
      the split is what makes it expensive: the same file, unchanged, run three ways —
      
      ```text
      sh   probe.sh   exit=1   "suite"                    # dies after the header
      dash probe.sh   exit=1   "suite"                    # same
      bash probe.sh   exit=0   "suite | ok | ok | ende"   # runs clean
      ```
      
      So a runner written with `#!/usr/bin/env sh` passes while you develop it with `bash runner.sh` and
      dies in CI, where `sh` is dash or busybox. `set +e` inside the subshell fixes all three:
      
      ```bash
      actual=$(
        set +e                                    # or the next non-zero line ends the subshell
        unset FLAG_A FLAG_B                       # each case starts from a known environment
        if [ -n "$assignment" ]; then
          # shellcheck disable=SC2163  # exporting NAME=value, not the variable name
          export "$assignment"
        fi
        guard "$fixture" >/dev/null 2>&1
        echo $?
      )
      ```
      
      Both were hit while writing a guard's regression suite: the first turned 16 passing cases into 16
      failures, the second produced a runner that printed its header and nothing else. Neither is caught
      by shellcheck — SC2163 fires on the deliberate `export "$assignment"`, which is the one line in the
      block that is correct as written.
      
      Reproducing the second one takes a faithful structure, not a minimal one. `bash -c 'set -e;
      out=$(false; echo $?)'` prints `1` and suggests there is nothing here; the trap needs the real
      shape — a function returning the code, inside `$( )`, inside a script under `set -e`, run as `sh`.
      A probe that is too small is how a real trap gets written off as folklore.
      
      ### CI Integration
      
      ```yaml
      # GitHub Actions - shellcheck
      - name: Run shellcheck
        uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # v2.0.0
        with:
          scandir: './scripts'
          severity: warning
      ```
      
      ---
      
      ## shfmt - Shell Script Formatter
      
      ### Flag Reference
      
      | Flag | Description | Example |
      |------|-------------|---------|
      | `-w` | Write result to file (in-place) | `shfmt -w script.sh` |
      | `-d` | Display diff (error if not formatted) | `shfmt -d script.sh` |
      | `-i N` | Indent with N spaces (0 = tabs) | `shfmt -i 2 script.sh` |
      | `-bn` | Binary ops like `&&` and `\|` may start a line | `shfmt -bn script.sh` |
      | `-ci` | Indent switch cases | `shfmt -ci script.sh` |
      | `-sr` | Redirect operators followed by a space | `shfmt -sr script.sh` |
      | `-fn` | Function opening brace on a separate line | `shfmt -fn script.sh` |
      | `-ln` | Language variant: `bash`, `posix`, `mksh`, `bats` | `shfmt -ln bash script.sh` |
      
      ### EditorConfig Integration
      
      shfmt reads `.editorconfig` settings automatically:
      
      ```ini
      # .editorconfig
      [*.sh]
      indent_style = space
      indent_size = 2
      shell_variant = bash
      binary_next_line = true
      switch_case_indent = true
      space_redirects = true
      ```
      
      When an `.editorconfig` is present, run `shfmt -w .` without explicit flags.
      
      ### CI Integration
      
      ```yaml
      # GitHub Actions - shfmt
      - name: Check shell formatting
        uses: mvdan/sh@8202166b7d1e3473a7c65eeac53ddbdb55d5b808 # v3.12.0
        with:
          sh-version: latest
          args: '-d -i 2 .'
      ```
      
      ---
      
      ## git-absorb - Smart Fixup Commits
      
      ### How It Works
      
      1. You stage changes with `git add` (the fixes you want to absorb)
      2. `git absorb` analyzes the staged hunks
      3. For each hunk, it finds the most recent commit that modified those exact lines
      4. It creates `fixup!` commits targeting those parent commits
      5. `git rebase --autosquash` folds the fixup commits into their targets
      
      ### Typical Workflow
      
      ```bash
      # 1. You have a feature branch with 5 commits
      # 2. Code review requests changes to lines in commits 2 and 4
      # 3. Make the fixes in your working tree
      # 4. Stage them
      git add -p
      
      # 5. Let git-absorb figure out which commits they belong to
      git absorb
      
      # 6. Verify the fixup commits look correct
      git log --oneline main..HEAD
      
      # 7. Squash fixups into their parent commits
      git rebase --autosquash main
      
      # 8. Force-push the cleaned-up branch
      git push --force-with-lease
      ```
      
      ### Comparison with Manual Fixup Workflow
      
      | Step | Manual | git-absorb |
      |------|--------|------------|
      | Identify target commit | `git log --oneline`, find SHA | Automatic |
      | Create fixup commit | `git commit --fixup=<SHA>` | `git absorb` |
      | Apply fixups | `git rebase --autosquash main` | `git absorb --and-rebase` or same |
      | Multiple fixes | Repeat per commit | Single command handles all |
      
      ### Limitations
      
      - Only works with staged changes (use `git add -p` for partial staging)
      - Cannot absorb changes to lines that were not in any prior commit (new code)
      - Ambiguous hunks (lines modified in multiple commits) are skipped with a warning
      - Requires a clean working tree for `--and-rebase`
      
      ### Installation
      
      ```bash
      # macOS
      brew install git-absorb
      
      # Arch Linux
      pacman -S git-absorb
      
      # Cargo (any platform)
      cargo install git-absorb
      
      # Ubuntu/Debian (via cargo or from releases)
      cargo install git-absorb
      ```
      
      ---
      
      ## difft (difftastic) - Structural Diff
      
      ### Language Support
      
      difft supports 50+ languages including: Bash, C, C++, C#, CSS, Dart, Elixir, Elm, Go, Haskell, HTML, Java, JavaScript, JSON, Kotlin, Lua, Nix, OCaml, PHP, Python, Ruby, Rust, Scala, SQL, Swift, TOML, TypeScript, YAML, and more.
      
      ### Configuration Options
      
      ```bash
      # Set as default git diff tool (persistent)
      git config --global diff.external difft
      
      # Set display width
      git config --global difftool.difftastic.cmd 'difft --width 80 "$LOCAL" "$REMOTE"'
      
      # Color output
      export DFT_COLOR=always  # always | never | auto
      
      # Display mode
      export DFT_DISPLAY=side-by-side-show-both  # side-by-side | side-by-side-show-both | inline
      
      # Syntax highlighting
      export DFT_SYNTAX_HIGHLIGHT=on  # on | off
      
      # Context lines around changes
      export DFT_CONTEXT=3
      ```
      
      ### Integration with Git
      
      ```bash
      # One-off usage with git diff
      GIT_EXTERNAL_DIFF=difft git diff
      
      # With git log
      GIT_EXTERNAL_DIFF=difft git log -p --ext-diff
      
      # With git show
      GIT_EXTERNAL_DIFF=difft git show --ext-diff HEAD
      
      # As a difftool (interactive)
      git config --global difftool.difftastic.cmd 'difft "$LOCAL" "$REMOTE"'
      git difftool --tool=difftastic
      ```
      
      ### Integration with delta
      
      If you use `delta` as your git pager, you can combine them:
      
      ```bash
      # Use difft for structural diff, delta for everything else
      # In .gitconfig:
      [diff]
          external = difft
      [pager]
          diff = delta
          log = delta
          show = delta
      ```
      
      Note: When `diff.external` is set, `delta` won't process diff output since difft handles it directly. Use `--no-ext-diff` to bypass difft and use delta instead when needed:
      
      ```bash
      git diff --no-ext-diff  # Uses delta pager instead of difft
      ```
      
      ### Installation
      
      ```bash
      # macOS
      brew install difftastic
      
      # Arch Linux
      pacman -S difftastic
      
      # Cargo
      cargo install --locked difftastic
      
      # Ubuntu/Debian (from GitHub releases)
      curl -Lo difft.tar.gz https://github.com/Wilfred/difftastic/releases/latest/download/difft-x86_64-unknown-linux-gnu.tar.gz
      tar xf difft.tar.gz && sudo mv difft /usr/local/bin/
      ```
      
      ---
      
      ## Pre-commit Hook Integration
      
      Integrate all tools into a pre-commit hook:
      
      ```bash
      #!/usr/bin/env bash
      # .git/hooks/pre-commit or via pre-commit framework
      
      set -euo pipefail
      
      # Find staged shell scripts
      staged_scripts=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(sh|bash)$' || true)
      
      if [[ -n "$staged_scripts" ]]; then
        echo "Running shellcheck..."
        echo "$staged_scripts" | xargs shellcheck || {
          echo "shellcheck failed. Fix issues before committing."
          exit 1
        }
      
        echo "Running shfmt..."
        echo "$staged_scripts" | xargs shfmt -d -i 2 || {
          echo "shfmt: formatting issues found. Run 'shfmt -w -i 2' on the files above."
          exit 1
        }
      fi
      ```
      
      ### Using pre-commit Framework
      
      ```yaml
      # .pre-commit-config.yaml
      repos:
        - repo: https://github.com/koalaman/shellcheck-precommit
          rev: v0.10.0
          hooks:
            - id: shellcheck
        - repo: https://github.com/scop/pre-commit-shfmt
          rev: v3.12.0-2
          hooks:
            - id: shfmt
              args: ['-i', '2', '-w']
      ```
      
      ---
      
      ## GitHub Actions Workflow
      
      Complete workflow integrating all shell quality tools:
      
      ```yaml
      name: Shell Quality
      on:
        pull_request:
          paths:
            - '**/*.sh'
            - '**/*.bash'
            - '.shellcheckrc'
            - '.editorconfig'
      
      jobs:
        shell-quality:
          runs-on: ubuntu-latest
          steps:
            - uses: actions/checkout@v4
      
            - name: Install tools
              run: |
                sudo apt-get update
                sudo apt-get install -y shellcheck
                go install mvdan.cc/sh/v3/cmd/shfmt@latest
                echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
      
            - name: shellcheck
              run: |
                git ls-files '*.sh' '*.bash' | xargs --no-run-if-empty shellcheck
      
            - name: shfmt
              run: |
                shfmt -d -i 2 .
      ```
      
    • commit-conventions.md 19.9 KB
      # Commit Conventions
      
      ## Conventional Commits
      
      ### Specification
      
      ```
      <type>[optional scope]: <description>
      
      [optional body]
      
      [optional footer(s)]
      ```
      
      ### Commit Types
      
      | Type | Description | Version Bump |
      |------|-------------|--------------|
      | `feat` | New feature | MINOR |
      | `fix` | Bug fix | PATCH |
      | `docs` | Documentation only | - |
      | `style` | Code style (formatting) | - |
      | `refactor` | Code refactoring | - |
      | `perf` | Performance improvement | PATCH |
      | `test` | Adding/updating tests | - |
      | `build` | Build system changes | - |
      | `ci` | CI configuration | - |
      | `chore` | Maintenance tasks | - |
      | `revert` | Reverting changes | - |
      
      ### The type is not the whole answer for the version
      
      The table maps the *intent* of a change. The version has to describe what a
      **consumer** observes, and the two come apart in one recurring case:
      
      > **If a change takes away something the consumer was getting before, it is at
      > least MINOR — even when the commit is a `fix:`.**
      
      `fix:` says *why* you are doing it. It does not say that nobody depended on
      the behaviour you removed. Fixing a defect that was quietly doing someone a
      favour still changes what they get.
      
      A worked case, because a rule without the case it came from reads as an
      opinion:
      
      ```
      fix(apt): never upgrade docker-ce or containerd.io unattended
      ```
      
      Genuinely a defect — the role pulled from every APT origin including
      third-party repositories, so unattended-upgrades restarted `dockerd` on an
      ingress node and on both halves of a DNS resolver pair, outside any window.
      The sibling platform had been scoped correctly all along, so this was
      repairing an oversight, and it was tagged as a PATCH.
      
      But hosts that had been getting third-party packages automatically stopped
      getting them. Nothing in the version said so. A consumer upgrading a patch
      level does not read release notes, which is the entire point of patch levels.
      
      **What to do instead:** bump MINOR, and say in the tag or release notes what
      stops happening. If a patch tag is already published, do not re-tag the same
      commit — two tags on one commit create two truths for one change, which is
      worse in hindsight than a version that was one step too low. Put the
      behaviour change where the next reader will look and move on.
      
      `BREAKING CHANGE:` is still reserved for what breaks a consumer outright. This
      rule covers the quieter case in between: nothing breaks, something stops.
      
      ### Examples
      
      ```bash
      # Simple feature
      feat: add user authentication
      
      # Feature with scope
      feat(auth): add OAuth2 login support
      
      # Bug fix
      fix: resolve null pointer in user service
      
      # Bug fix with issue reference
      fix(api): handle empty response from external service
      
      Fixes #123
      
      # Breaking change
      feat!: remove deprecated v1 API endpoints
      
      BREAKING CHANGE: The /api/v1/* endpoints have been removed.
      Migrate to /api/v2/* before upgrading.
      
      # Multiple footers
      fix(security): patch XSS vulnerability in comment parser
      
      Reviewed-by: John Doe
      Refs: #456
      ```
      
      ### Scope Guidelines
      
      Scopes should be consistent across the project:
      
      ```bash
      # By feature area
      feat(auth): ...
      feat(payment): ...
      feat(notification): ...
      
      # By layer
      fix(api): ...
      fix(db): ...
      fix(ui): ...
      
      # By component
      style(button): ...
      refactor(modal): ...
      ```
      
      ## Commit Message Best Practices
      
      ### Body
      
      **Write multi-line or special-char bodies to a file, not inline `-m`.** The shell parses a double-quoted `-m "..."` before git sees it: an *unescaped* `"` in the body closes the string early, after which a bare `&` backgrounds the fragment and the message silently truncates (a tell-tale `... command not found` may scroll past). Even with the quotes balanced, the shell still expands `$var`, `` `cmd` ``/`$(...)`, and backslashes inside double quotes, so a body containing those is altered. A file or a *single-quoted* heredoc (`<<'EOF'`) sidesteps all of it — the text is passed verbatim:
      
      ```bash
      git commit -S --signoff -F - <<'EOF'
      fix: prevent race condition in order processing
      
      Body may contain "quotes", & ampersands, `backticks` — all literal.
      EOF
      ```
      
      **Hard-wrap the commit *body* (~72 cols); don't hard-wrap prose whose wrapping
      you don't control.** The 72-column convention is for commit message bodies —
      which is exactly why a commit body must not be piped into a PR description
      unchanged; see *The rule breaks at the pipe* in `no-editorializing.md`. For
      **PR/MR & issue descriptions, release notes, review comments, and chat**, write
      one line per paragraph (and per list item) and let the renderer soft-wrap — a
      hard break mid-paragraph there does nothing for the output. For **committed
      Markdown files**, follow the repo's existing convention: many hard-wrap prose
      docs for readable diffs, so match the surrounding files rather than mixing two
      styles in one tree.
      
      ## Reading Trailers Back: `git interpret-trailers`, Never a Regex
      
      Anything that makes a decision from a commit trailer — a release note generator,
      an audit, a CI gate honouring an opt-in — must ask git what a trailer is. A
      `^Name:` regex over `%B` matches text that is not a trailer at all:
      
      ```bash
      git log --format='%B' -1 | git interpret-trailers --parse --no-divider
      ```
      
      Three ways the regex is wrong, all reproduced rather than imagined:
      
      - **Inside a fenced code block.** A commit documenting the trailer format, whose
        prose says "this is only an example", hands out a real one.
      - **In the quoted body of a `Revert "..."`** that a person pasted in. (A real
        `git revert` does not carry the reverted commit's trailers forward — that path
        is safe. A hand-written message is not.)
      - **Case.** With `re.I`, `allow-policy-narrowing:` works where the documentation
        says `Allow-Policy-Narrowing:`. git looks trailers up case-insensitively, but
        an opt-in should be written the way it is documented — match the name as
        written.
      
      `--no-divider` is not optional for this. Without it a **divider line** ends the
      parse and every trailer after it disappears — that is a line beginning with
      `---` followed by a space or the end of the line, so a markdown rule or the
      `---` git itself puts above a diffstat both count. Measured: `--- a note` and a
      bare `---` both swallow the trailer; `----` and a `---` in the middle of a line
      do not. A pasted patch or a rule in the body therefore drops a trailer that is
      really there. Failing closed is right; failing closed *silently* sends the
      author to debug the wrong thing.
      
      Two properties worth knowing before designing around trailers:
      
      - What counts as the trailer block is a **heuristic**. A prose line directly
        above can put a trailer outside it; a `Signed-off-by` footer usually keeps it
        in. Check with the command above rather than by eye.
      - A **squash merge does not reliably preserve them**. Both forges compose that
        message from the pull/merge request rather than by aggregating the source
        commits' trailers; GitLab can inject some metadata through
        `squash_commit_template`, but neither platform guarantees that a particular
        source trailer survives. So a mechanism that reads a trailer on the target
        branch cannot depend on one being there after a squash. If the mechanism
        matters, turn squash off for the project (`squash_option: never` on GitLab)
        rather than documenting the hazard.
      
      ## Signed Commits + DCO Sign-Off (Required)
      
      Run every commit with both flags explicit:
      
      ```bash
      git commit -S --signoff -m "feat: add login endpoint"
      ```
      
      **Why explicit `-S`.** Git honors `commit.gpgsign=true` only when the configuration is actually loaded. Subprocess environments (CI runners, some IDEs, tools that set their own `$HOME` or scrub env) can miss the global config — and without the config, git doesn't even *try* to sign. Git records the commit as unsigned with no error, because from its perspective signing was never requested. Explicit `-S` pins the requirement to the invocation: now git *always* attempts to sign, and if the signing agent (gpg-agent, or ssh-agent when using `gpg.format=ssh`) or its pinentry prompt is unreachable, the commit aborts noisily. You find out now, not when branch protection rejects the push later.
      
      **Why `--signoff`.** Adds the `Signed-off-by:` trailer. Required for DCO compliance on any repo that has the DCO check enabled (most netresearch repos do).
      
      **`--signoff` on an amend doubles the sign-off when other trailers follow it.** Git leaves the line out only when the *last* trailer already is that exact `Signed-off-by:`. A message whose trailer block reads `Signed-off-by:`, `Assisted-by:`, `Agent-Session:` gets a second `Signed-off-by:` below the rest — so rewording a commit with `git commit --amend -s -F msg.txt`, where `msg.txt` came from `git log -1 --format=%B`, ships the sign-off twice. Measured with git 2.55.0: sign-off as the last trailer → one line, sign-off followed by another trailer → two. When the message file already carries its trailers, amend without `-s`, and count before pushing:
      
      ```bash
      git log -1 --format=%B | git interpret-trailers --parse --no-divider | grep -c '^Signed-off-by:'
      ```
      
      **Sign-off identity must match `git config user.{name,email}`.** Mismatched identities fail the DCO check with an unhelpful "signoff required" error. Validate before the first commit in a new worktree — and specifically check that the values are not swapped (an email address in `user.name` is a silent misconfiguration that produces a malformed `Signed-off-by:` trailer):
      
      ```bash
      git config user.name   # must look like "Firstname Lastname", NOT an email address
      git config user.email  # must contain "@", NOT a plain name
      
      # Fix if swapped:
      git config --global user.name "Firstname Lastname"
      git config --global user.email "you@example.com"
      ```
      
      **Commit locally to satisfy verified-signature branch protection — not via the host's Contents API.** Commits created through the GitHub Contents API (`PUT /repos/.../contents/...`), or any host-side write that supplies its own `committer`/`author`, come out **unsigned** (`verification.verified=false`, `reason: unsigned`) — the host does not web-flow-sign them. A repo that enforces verified signatures rejects the merge, and when admin enforcement is on you cannot bypass it. So when scripting commits across many repos, check the signature requirement up front alongside required checks and reviews:
      
      ```bash
      gh api repos/"$R"/branches/main/protection \
        --jq '.required_signatures?.enabled? // false'   # $R="owner/repo"; also inspect rulesets
      ```
      
      For any repo that enforces signatures, create the commit **locally** with signing configured (`git commit -S --signoff`) rather than through the API — a locally SSH/GPG-signed commit verifies on the host, an API-authored one does not.
      
      **SSH signing keys on GitHub: auth keys ≠ signing keys.** An SSH key registered under *Settings → SSH and GPG keys → Authentication Key* cannot verify commits. It must also be added as a *Signing Key* (same page, different Key type dropdown). GitHub reports unsigned-with-known-key commits as `reason: unknown_key` in the commits API — identical to an unregistered key. Check before the first push to a repo with verified-signature branch protection:
      
      ```bash
      # Verify the key is registered as a signing key (requires admin:ssh_signing_key token scope):
      gh auth refresh -h github.com -s admin:ssh_signing_key
      gh api /user/ssh_signing_keys --jq '.[].key'
      
      # Or check commit verification after pushing one commit:
      gh api /repos/{owner}/{repo}/commits/HEAD --jq '.commit.verification | {verified, reason}'
      # "reason":"valid"        → OK
      # "reason":"unknown_key"  → key not registered as signing key
      # "reason":"unsigned"     → -S flag not used or signing config missing
      ```
      
      **Trust the inherited ssh-agent; don't hunt for sockets.** When commits are SSH-signed through a running ssh-agent, the `SSH_AUTH_SOCK` already present in the inherited shell environment normally holds the signing key and works transparently. Do **not** reflexively export a hardcoded `SSH_AUTH_SOCK` or probe the filesystem for agent sockets — a stale or wrong socket path overrides the working inherited one and breaks signing that would otherwise have succeeded. If signing actually fails (`failed to write commit object`, `error: gpg failed to sign the data`, or a `publickey` error), stop and surface the error to the operator rather than autonomously searching for the "right" socket.
      
      **A signing key dropped from the agent mid-session breaks signing *and* SSH git at once.** If the agent had the key earlier but `ssh-add -l` now prints `The agent has no identities.` (a passphrase-protected key can time out or be evicted), then `git commit -S` aborts (`ssh_askpass: … No such file`, "incorrect passphrase", `failed to write commit object`) **and** `git fetch`/`git push` over an SSH remote fail with `Permission denied (publickey)`. The push half has a fallback — route it over HTTPS with the `gh` token (`gh auth setup-git`, then `git push https://github.com/owner/repo.git <branch>`) — but **signing has no such fallback**: the SSH key file must be re-added. Do not bypass signing to get unblocked; ask the operator to re-add it (`ssh-add <signing-key>`, e.g. `~/.ssh/id_ed25519`, which prompts for the passphrase). Both signing and SSH git recover once it is loaded.
      
      **Never amend a commit with pre-commit-hook failures.** If the pre-commit hook fails, the commit **did not happen**. Running `git commit --amend` then modifies the PREVIOUS commit, which can destroy work. Fix the hook issue, re-stage, and create a new commit.
      
      **Prove the commit landed before reporting it — compare HEAD, not the last commit.** A reformatting hook (`ruff format`, `black`, `isort`, `prettier`) rewrites the staged files and *aborts* the commit. Every check that reads "the last commit" then describes the commit before yours and reports success:
      
      ```bash
      BEFORE=$(git rev-parse HEAD)
      git add -- path/to/file path/to/other
      git commit -s -F msg.txt
      [ "$(git rev-parse HEAD)" != "$BEFORE" ] || { echo "commit aborted — re-stage and retry" >&2; exit 1; }
      git log -1 --format='%h %s'           # now describes YOUR commit
      ```
      
      Checking `git commit`'s own exit code works, but only if nothing reads it first — and in practice the next thing in the block is a status line that answers from the *previous* commit and looks right. `git log -1 --format='%G?'` prints the same value it printed before the aborted commit, and the `git push` that follows succeeds while pushing nothing new, because the branch never moved. HEAD is the one value the aborted commit did not leave intact, which is why comparing it answers the question the others only appear to.
      
      **Never skip hooks** unless explicitly told to. `--no-verify` bypasses hook enforcement that exists for good reasons. If a hook fails, diagnose the root cause. The one exception is a throwaway *probe* commit that is deleted moments later and never enters history — `signing-preflight.sh` retries with `--no-verify` there, and only to tell a hook rejection apart from a signing failure.
      
      **Never bypass signing** unless explicitly told to. `--no-gpg-sign` and `-c commit.gpgsign=false` disable commit signing; the result will fail branch-protection or policy checks that require signed commits later.
      
      **Verify signing capability without committing on `main`.** To check that signing
      actually works (right key, agent reachable), do **not** create a probe commit on the
      default branch — even an immediately-reset `git commit --allow-empty -S` on `main` is a
      transient commit on a protected branch and violates "no direct commits to main". Inspect
      configuration instead, no commit required:
      
      ```bash
      git config commit.gpgsign     # expect: true
      git config gpg.format         # ssh (SSH signing) or empty (GPG)
      git config user.signingkey    # the key/path that will be used
      ```
      
      If you must actually exercise the signing path, run `scripts/signing-preflight.sh`
      — it does the whole dance below and cleans up after itself. By hand, do it on a
      throwaway branch and discard it:
      
      ```bash
      git switch -c tmp/sign-probe
      # conventional msg so a commit-msg hook won't reject it; header, not %G? — see "Detecting a Signed Commit" below
      git commit --allow-empty -S -m "chore: signing probe" \
        && (git cat-file commit HEAD | sed -n '/^$/q;p' | grep -qE '^gpgsig(-sha256)? ' && echo signed || echo "NOT signed") \
        || echo "NOT signed — commit failed"   # keep the chain: unchained, the check reads the parent commit
      git switch - && git branch -D tmp/sign-probe
      ```
      
      ### Verifying a Signed Tag
      
      A tag needs two properties, and they fail independently:
      
      ```bash
      git for-each-ref refs/tags/v1.2.3 --format='%(objecttype)'   # expect: tag, not commit
      git tag -v v1.2.3
      ```
      
      `objecttype: commit` means a **lightweight** tag — created without `-a`/`-s`,
      carrying no signature and no tagger. Many release pipelines reject these
      outright, and `git tag -v` on one reports an error that reads like a bad
      signature rather than a missing one.
      
      **Do not grep for `Good signature`.** The wording depends on the signing
      backend, and an SSH-signed tag does not use that phrase:
      
      | Backend | Output |
      |---|---|
      | GPG | `Good signature from "Name <mail>"` |
      | SSH (`gpg.format=ssh`) | `Good "git" signature for mail with ED25519 key SHA256:…` |
      
      With `gpg.format=ssh` set — increasingly the default in this org — a check for
      `Good signature` returns nothing and a correctly signed tag looks unsigned.
      Match on both, or on the object type plus a looser pattern:
      
      ```bash
      git tag -v v1.2.3 2>&1 | grep -qE 'Good ("git" )?signature' && echo signed
      ```
      
      `git tag -v` needs `gpg.ssh.allowedSignersFile` as well. Without it, it prints
      `error: gpg.ssh.allowedSignersFile needs to be configured and exist for SSH
      signature verification`, exits 1, and the pattern above matches nothing (git
      2.54.0) — so a failed match means "not verifiable *here*", not "unsigned".
      
      ### Detecting a Signed Commit
      
      Signedness and verification are different questions, and only the first has a
      stable local answer: whether a commit *carries* a signature is a property of the
      object, whether *this machine* trusts that signature depends on local config.
      The check below answers the first one only.
      
      For that question, do not reach for `git log --format='%G?'`: it is
      backend-independent but *not* verification-config-independent. Under
      `gpg.format=ssh` with no `gpg.ssh.allowedSignersFile`, `%G?` returns `N` and
      `--show-signature` prints `No signature` on a correctly signed commit (git
      2.54.0) — `N` is indistinguishable from unsigned, and setting that config flips
      the identical commit to `G`. Read the commit header instead, which both backends
      write (`-----BEGIN SSH SIGNATURE-----` / `-----BEGIN PGP SIGNATURE-----`) and no
      local config gates:
      
      ```bash
      scripts/signing-preflight.sh --check-commit HEAD      # exit 0 signed, 1 unsigned
      # by hand:
      git cat-file commit HEAD | sed -n '/^$/q;p' | grep -qE '^gpgsig(-sha256)? ' && echo signed
      ```
      
      `gpgsig-sha256` is the header name in SHA-256 repositories; the alternation
      covers both. Match the header name and the space, so nothing else in the header
      block can pass.
      
      Cut the header at the first blank line — over the whole object, `^gpgsig` also
      matches a message body line starting with `gpgsig` and reports an unsigned commit
      as signed. Whether the *host* accepts the key is a separate question, answered
      only by the commits API check above.
      
      ## Atomic Commits
      
      Each commit should be a **single, self-contained logical change** that builds and passes tests independently.
      
      **Good:**
      
      - `feat: add user authentication endpoint` (one feature, complete)
      - `fix: correct SAML attribute name mapping` (one bug, fixed)
      - `chore(deps): bump go-ldap/ldap/v3 from 3.4.8 to 3.4.11` (one bump)
      
      **Bad:**
      
      - `feat: add auth + fix typo + update deps` (three unrelated concerns)
      - `wip` / `fixup` (leftover scratch commits)
      
      Rewrite messy history before opening the PR:
      
      ```bash
      git rebase -i main        # interactive, squash / reword / reorder
      git rebase -i --autosquash main   # auto-pick fixup!/squash! commits
      ```
      
      ## Push Upstream on First Push
      
      When pushing a new branch for the first time, set upstream tracking with `-u`:
      
      ```bash
      git push -u origin feature-branch
      ```
      
      This makes subsequent `git pull` / `git push` work without specifying remote+branch. Without `-u`, everyone who clones the branch later has to set it up themselves.
      
    • git-hooks-setup.md 15.2 KB
      # Git Hooks Setup
      
      ## Why Hooks Matter
      
      Git hooks catch issues before they reach CI — faster feedback, fewer wasted CI runs.
      For autonomous agents, hooks are essential: they enforce commit message format,
      prevent secrets, and ensure code quality without requiring the agent to "remember" rules.
      
      ## Hook Frameworks
      
      | Framework | Language | Config File | Install |
      |-----------|----------|-------------|---------|
      | **lefthook** | Go binary | `lefthook.yml` | `go install github.com/evilmartians/lefthook@latest && lefthook install` |
      | **captainhook** | PHP | `captainhook.json` | `composer install` (auto via plugin) |
      | **husky** | Node.js | `.husky/` | `npm install` (auto via prepare) |
      | **pre-commit** | Python | `.pre-commit-config.yaml` | `pip install pre-commit && pre-commit install` |
      
      ## Detection — One Command
      
      ```bash
      ls lefthook.yml .lefthook.yml captainhook.json .pre-commit-config.yaml .husky/pre-commit 2>/dev/null || echo "No hook framework configured"
      ```
      
      Then install based on what's found:
      - `lefthook.yml` → `lefthook install` (or `make setup`)
      - `captainhook.json` → `composer install` (auto)
      - `.husky/` → `npm install` (auto)
      - `.pre-commit-config.yaml` → `pre-commit install`
      - Nothing → suggest adding one based on project language
      
      ## Updating Hook Versions (`pre-commit autoupdate`)
      
      `pre-commit autoupdate` bumps every hook's `rev` to the repo's **latest tag —
      including pre-releases**. It happily pins a beta (observed: isort
      `8.0.1 → 9.0.0b1`). After running it, review each bumped `rev` and pin any
      `aN`/`bN`/`rcN` back to the latest stable tag before committing. When a hook
      mirrors a locked dev dependency (black, isort), keep the hook `rev` aligned
      with the lockfile version instead of blindly taking the newest tag.
      
      ### Nobody bumps a `rev` unless you arrange it
      
      `autoupdate` is a manual command, so a repository that never runs it keeps its
      hooks forever. Renovate can do it, but **its pre-commit manager is disabled by
      default** — enable it explicitly:
      
      ```json
      { "extends": ["config:recommended"], "pre-commit": { "enabled": true } }
      ```
      
      Without that line no hook is ever offered an update, and nothing reports it:
      an absent pull request looks exactly like being up to date. Measured across one
      fleet of 37 repositories, the six without the manager sat on a validator tag 15
      minor versions behind while the other 31 tracked current — the correlation was
      complete.
      
      **A stale `rev` keeps enforcing the rule that version had.** Those six failed
      commits with `SKILL.md is 501 words (max 500)` months after the word cap had
      been replaced by a line-based one. When a pinned checker rejects your change,
      read the pin before you edit the file to satisfy it: an error message names the
      rule the *pinned* version enforces, which is not necessarily the rule that
      applies. Shortening prose to pass a retired cap is work spent against nothing.
      
      **Do not reach for a moving ref to escape this.** `rev: main`, or a `v1` branch
      that tracks main, is worse rather than better:
      
      ```
      [WARNING] The 'rev' field ... appears to be a mutable reference (moving tag /
      branch).  Mutable references are never updated after first install and are not
      supported.
      ```
      
      pre-commit clones a mutable ref once and never refreshes it, so the running
      version freezes per machine, invisibly and differently for each developer. An
      immutable tag in the file plus a bot that moves it is the supported shape.
      
      ## Recommended Hooks by Stage
      
      ### pre-commit (fast, <5s)
      - Code formatting (gofmt, php-cs-fixer, prettier)
      - Import sorting
      - YAML/JSON validation
      - Secret detection
      
      ### commit-msg
      - Conventional commits validation
      - DCO sign-off enforcement
      - Minimum message length
      
      ### pre-push (can be slower)
      - Full linting (golangci-lint, phpstan)
      - Smoke tests
      - Security scanning
      
      ## Rules for Agents
      
      - NEVER skip hooks with `--no-verify`
      - If a hook fails, fix the underlying issue
      - If hooks aren't installed, install them before first commit
      - If no hook framework exists, suggest adding one in the PR
      
      ## Hooks Are Fast Feedback — CI Is the Gate
      
      Local hooks are per-checkout, individually bypassable, and **absent in fresh
      clones and in most secondary worktrees**. Treat them as fast feedback, not as an
      enforcement boundary. The actual gate must be CI: every substantive check a hook
      runs locally (format, lint, static analysis, tests, secret scan, commit-message
      validation) needs an equivalent job in the pipeline. A check that runs only in a
      local hook is silently unenforced for anyone who never installed it. When
      auditing a repo, confirm that parity — a missing local hook is only a real gap
      if CI doesn't cover the same check.
      
      This also means it is legitimate to deliberately leave a repo with **no** local
      hooks when the hook can't run reliably outside its container (e.g. a pre-commit
      that needs a Docker-only service) — provided CI runs the equivalent check.
      Forcing such a hook onto every checkout just turns it into a landmine.
      
      ## Auditing Installed Hooks (`--git-path` cwd gotcha)
      
      To find where a checkout's hooks live, prefer the path git computes itself:
      
      ```bash
      cd "$repo" && git rev-parse --git-path hooks
      ```
      
      **Run it from inside the repo.** For a plain (non-worktree) clone,
      `git rev-parse --git-path hooks` returns a path **relative to the current
      directory** (`.git/hooks`). Resolve or `find` it from elsewhere and you look in
      the wrong place and false-report "no hooks installed". (Worktrees and a
      configured `core.hooksPath` return absolute paths, which masks the bug — so it
      only bites on ordinary clones.) `cd` into the repo first, or resolve the path
      against the repo root, before inspecting it.
      
      ## Troubleshooting
      
      ### CaptainHook + git worktrees (FAQ)
      
      - **Symptom**: `composer install` fails with
        `Shiver me timbers! CaptainHook could not install yer git hooks! (invalid .git path)`
        when run in a secondary git worktree.
      - **Cause**: Git worktrees use a `.git` *pointer file* (e.g. `gitdir: /path/to/bare/worktrees/NAME`),
        not a directory. `captainhook/hook-installer` ≤ 1.x does not resolve the pointer correctly
        and aborts.
      - **Fix (recommended)**: `mkdir -p "$(git rev-parse --git-path hooks)" && composer install` —
        creates the hooks dir at the effective hooks path (honors `core.hooksPath` if configured,
        falls back to `<git-dir>/hooks` otherwise). Works with captainhook's plugin in place, so other
        Composer plugins (phpstan/extension-installer, TYPO3 composer installers, etc.) continue to
        auto-register normally.
      - **Fix (scoped alternative)**: `composer config extra.captainhook.disable-plugin true`
        before the install disables ONLY the captainhook plugin — all other Composer plugins
        (phpstan/extension-installer, TYPO3 installers, …) keep working. It edits `composer.json`,
        so revert it before committing (`git checkout composer.json`); useful for one-off
        installs in throwaway worktrees where hooks are not wanted anyway.
      - **Fix (last-resort fallback)**: `composer install --no-plugins` — only if the hooks-dir
        workaround above doesn't resolve it. Be aware this disables *all* Composer plugins for that
        install, which has broader side effects: phpstan extensions won't auto-register, TYPO3
        composer installers won't place extensions, and captainhook itself won't install hooks. Hooks
        still work in the primary worktree where `.git` is a real directory.
      - **When this matters**: Repos using a bare-repo + worktrees layout (see
        [git-worktree(1)](https://git-scm.com/docs/git-worktree)) hit this on every `composer install`
        in a secondary worktree, since `.git` is a pointer file rather than a directory.
      - **Cross-reference**: The `netresearch/typo3-ci-workflows` meta-package bundles
        `captainhook/hook-installer`; its README section "Git Worktree + captainhook Workaround"
        is the canonical source.
      
      ### Hooks fail in worktrees / hang on host-unreachable services (FAQ)
      
      - **Symptom A**: `git commit` in a secondary worktree fails with
        `./bin/captainhook: not found` — even though `--no-verify` was passed to
        `git commit`.
      - **Cause A**: hooks installed by the primary checkout run with the worktree as
        CWD and reference `./bin/captainhook` relatively; the worktree has no
        `vendor/`/`bin/`. The commit-level `--no-verify` has a blind spot:
        it skips only `pre-commit` and `commit-msg` — **`prepare-commit-msg` always
        runs**, so a broken hook of that type still fails the commit. (`git push`
        has its *own* `--no-verify`, which does skip `pre-push` entirely; the
        commit-level flag simply does not cover it.)
      - **Symptom B**: a pre-commit hook that runs the test suite hangs forever on
        the host because the tests need a docker-only service (e.g. a test DB only
        resolvable inside the compose network). Killing the runner can leave a
        zombie process holding the index lock. Note that in a worktree `.git` is a
        pointer **file**, not a directory — the lock lives at
        `$(git rev-parse --git-dir)/index.lock`. First confirm no git or hook
        process is still alive (`pgrep -fl 'git|captainhook'`); only then remove
        the stale lock and retry — deleting it under a live process corrupts the
        index.
      - **Controlled bypass** (the only sanctioned exception to "never skip hooks"):
        first run the hook's checks *manually via equivalent commands* (linters and
        static analysis on the changed files, the test suite inside its docker
        environment), then bypass the broken hook explicitly and disclose it:
        `git -c core.hooksPath="$(mktemp -d)" commit -s ...` (an empty hooks
        directory disables all hook types for that one command and — unlike
        `/dev/null` — is portable to Windows) and `git push --no-verify`. Never
        make the bypass the default; fix the hook environment or commit from the
        primary checkout when possible.
      
      ### Distinguish "the hook is broken" from "the check failed"
      
      The bypass above is for a hook that cannot *run*. A hook that ran fine and
      reported a genuine failure is a different situation with the same exit code, and
      conflating them turns the sanctioned bypass into a habit.
      
      Third case, easy to misread as either: the hook ran, the check ran, and the
      check failed for a reason that lives in the **environment**, not the change.
      Tells:
      
      - `Doctrine\DBAL\Exception\DriverException: could not find driver` — the host
        has no `pdo_mysql`; the container does.
      - `Container /path/var/cache/dev/App_KernelDevDebugContainer.xml does not exist`
        — `phpstan-symfony` needs a warmed container: `php bin/console cache:warmup`
        before the analysis, not a baseline entry.
      - A failure count that does not move when you revert your change.
      
      The fix is the **venue**, not the flag. Run the identical gate where the
      environment is complete — usually the project's own compose service:
      
      ```bash
      docker compose run --rm app-e2e php -d memory_limit=1G bin/phpunit --testsuite unit --no-coverage
      ```
      
      Only after that passes does the shim bypass apply, and say in the commit or the
      PR which gate ran where. Reaching for `--no-verify` on this class hides a
      result you could have had.
      
      Watch for a cold-start race when you script this: a suite started immediately
      after `docker compose up -d` can fail one or two DB-dependent tests while the
      database is still accepting connections. Re-run once against the warm stack
      before believing the failure.
      
      ### Fresh worktree: missing deps and stale `hooksPath`
      
      A freshly-created worktree often breaks pre-commit/pre-push hooks — and can even
      abort the worktree creation itself:
      
      - It has no `vendor/`/`node_modules`, so a hook binary (`vendor/bin/grumphp`,
        `captainhook`, a husky script) is missing.
      - A stale or broken `core.hooksPath` points at a nonexistent binary in **another**
        worktree.
      
      Either failure aborts worktree-add, commits, and pushes. Bypass cleanly and
      **scoped** — don't disable hooks globally:
      
      ```bash
      git -c core.hooksPath=/dev/null commit -s ...   # scoped to this one command
      git push --no-verify
      ```
      
      (`core.hooksPath="$(mktemp -d)"` is the Windows-portable equivalent — see the
      controlled-bypass note above.) CI is authoritative for these repos (see "Hooks
      Are Fast Feedback — CI Is the Gate"), so a scoped bypass here is safe. Also: in a
      fresh worktree, `Read` a file before `Write`/`Edit` — there is no cached state
      for a path the tooling has not seen yet.
      
      ### pre-commit first run: slow env build, lost stash on interrupt
      
      The **first** commit that triggers `pre-commit` builds a fresh virtualenv for
      every hook repo (ruff, markdownlint, shellcheck, …). That can take minutes, and
      it is easy to interrupt or time out that commit before it finishes.
      
      `pre-commit` stashes your **unstaged** changes for the duration of the run, then
      restores them at the end. If the run is killed mid-way, that restore may not
      happen — the unstaged edits silently vanish from the working tree. So **always
      `git status` after an interrupted commit**, and if files reverted, reapply the
      stash patch pre-commit left behind:
      
      ```bash
      # pre-commit's cache dir is configurable: PRE_COMMIT_HOME, else XDG_CACHE_HOME/pre-commit,
      # else ~/.cache/pre-commit (macOS/other platforms may differ).
      PCH="${PRE_COMMIT_HOME:-${XDG_CACHE_HOME:-$HOME/.cache}/pre-commit}"
      PATCH=$(ls -t "$PCH"/patch* 2>/dev/null | head -1)   # newest patch<epoch>-<pid>
      git apply "$PATCH"
      ```
      
      Then re-run the commit (the hook envs are now built, so it is fast).
      
      ### Formatter hooks rewrite files — the first commit always bounces
      
      A hook that *reformats* rather than merely reports (`black`, `isort`,
      `ruff format`, `prettier`, `shfmt`, `end-of-file-fixer`) rewrites the staged
      file and then **fails the commit**, because what it wrote is not what you
      staged:
      
      ```
      black....................................................................Failed
      - hook id: black
      - files were modified by this hook
      reformatted tests/test_completion.py
      ```
      
      The commit did **not** happen. The fix is `git add` the now-reformatted files
      and commit again — the second run passes because the file is already formatted.
      Budget two `commit` invocations, and never `--amend` here (see
      "Never amend a commit with pre-commit-hook failures" in
      `commit-conventions.md`): the first commit does not exist, so `--amend` would
      rewrite the *previous* one.
      
      To avoid the bounce entirely, run the formatter before committing — matching
      whatever the repo actually uses, which is not always the one you expect:
      
      ```bash
      # read the hook ids the repo declares, then run those
      yq '.repos[].hooks[].id' .pre-commit-config.yaml
      pre-commit run black isort --files <changed files>   # or: ruff format . && ruff check .
      ```
      
      Checking the config matters: a project can use `black`+`isort` where you assumed
      `ruff format`, and a habit built around only one of them still bounces on every
      commit in the other.
      
      ### `--no-verify` only after confirming CI parity
      
      A local hook the CI pipeline does **not** run is not a real merge gate (see
      "Hooks Are Fast Feedback — CI Is the Gate"). If such a hook (say a repo-only
      `black`, `isort`, or `shellcheck`) reports the tree dirty and running its
      formatter would rewrite **hundreds of unrelated lines**, bypassing it with
      `--no-verify` is defensible — but only *after* you have read the CI workflow and
      confirmed it does not enforce that check. Concretely: verify the pipeline's
      `flake8` uses a narrow `--select`, that there is no `shellcheck`/`black` job,
      etc. A hook enforced only locally is already silently unenforced — don't let it
      dictate a massive diff that has nothing to do with your change. If CI *does* run
      the check, it is a real gate: fix the finding instead of bypassing it.
      
    • github-releases.md 599 B
      # GitHub Releases
      
      Release creation and management moved to the
      [`github-release`](https://github.com/netresearch/github-release-skill) skill,
      the canonical owner of this content. Invoke that skill for:
      
      - GitHub Immutable Releases (deleted-tag reuse is permanently blocked)
      - `--latest=false` for non-default-branch releases
      - Multi-branch release sequencing and recovery after a failed publish
      
      This skill (`git-workflow`) still owns the git side of a release — signed
      commits, PR merge, and tagging conventions. See
      `references/commit-conventions.md` and `references/pull-request-workflow.md`.
      
    • merge-gate-watcher.md 41.8 KB
      # Merge-Gate Watcher
      
      Canonical polling loop to drive a PR to merge once review threads are handled. Hand-rolling this per PR invites classification bugs (a soft check counted as hard ⇒ false HOLD; a missed one ⇒ premature merge).
      
      ## Driving many PRs at once
      
      One `pr-status.sh --watch` per PR, all in parallel in one background command — each instance returns at *its* first actionable event, so a slow PR never delays acting on a fast one:
      
      ```bash
      d=$(mktemp -d)   # fresh dir: no interleaved writes, no stale files from a prior run
      for p in "owner/repo-a 11" "owner/repo-b 22"; do
        ( set -- $p
          pr-status.sh -R "$1" "$2" --watch | grep '^NEXT:' | tail -1 | sed "s|^|$1#$2 |" > "$d/${1//\//_}-$2" ) &
      done
      wait; cat "$d"/*
      ```
      
      Then act per line: `NEXT: merge` → `pr-merge.sh` in a **new** invocation (merge-gate hooks evaluate at call time — never chain the wait and the merge in one command); `resolve-threads` / `address-review` → handle that PR individually while the rest keep going. Do not write a bespoke driver that re-reads PR state in a loop and dispatches on it — that re-implements `pr-status.sh` badly, waits for the one outcome it was told about, and sleeps through the rest (verified 2026-08-03: a 7-repo release sweep completed on this pattern with zero hand-rolled polling).
      
      ## Check taxonomy
      
      Classify every failing check BEFORE reacting:
      
      | Class | Examples | Reaction |
      |-------|----------|----------|
      | **Hard** | unit/integration/E2E tests, lint, build | HOLD and fix — except known infra flakes (Docker Hub pull timeout, buildx setup): one `gh run rerun <id> --failed` |
      | **Soft, self-healing** | `codecov/*` while sibling jobs still run (partial uploads) | Ignore while `pending > 0`; if persisting after completion: one full `gh run rerun <id>` |
      | **Soft, structural** | SonarCloud PR gate on refactor PRs | Introspect before deciding (below) |
      
      ## One shard red in a sharded suite: flake vs. real regression
      
      When one of N test shards fails, read its **first** error before you reach for a rerun — whether the Hard-class failure above is a flake or a real bug turns on it:
      
      - **Infra flake** — the first error is a stack-boot / health-check line (`App failed to start within timeout`, DB-not-ready, a 5xx from the app root). Every assertion failure below it is collateral: there was no app to talk to. Only that one shard is red; the siblings pass. Reaction: one `gh run rerun <id> --failed` (a rebase + push also re-triggers a clean run).
      - **Real regression** — *typically* the same spec(s) fail **across all shards deterministically**, and the first error is an assertion (or an actionability timeout), not a boot line. A regression in shared code does not politely confine itself to one shard. (The Playwright case below is the exception — a real regression that can surface on a single shard.)
      
      Playwright tell: `locator.check` / `locator.click: Test timeout` is an **actionability** failure — the element never became visible / stable / hit-testable — usually a CSS or DOM change that broke a hit target. Treat it as a real regression to investigate even when it surfaces on a single shard, not as a flake to rerun. (Seen: a `.field-check-row` restyle moved a label out of the node a spec located by, so `getByText`-anchored `.check()` hung 30s — red on shard 1 only, looked exactly like a boot flake, was a real DOM regression.)
      
      ## Sonar gate introspection
      
      Never merge on a red Sonar gate without knowing *why* it is red:
      
      ```bash
      AUTH="Authorization: Bearer $SONAR_TOKEN"
      curl -s -H "$AUTH" "https://sonarcloud.io/api/qualitygates/project_status?projectKey=$KEY&pullRequest=$PR" \
        | jq -r '[.projectStatus.conditions[]|select(.status!="OK")|.metricKey]|join(",")'
      curl -s -H "$AUTH" "https://sonarcloud.io/api/issues/search?componentKeys=$KEY&pullRequest=$PR&resolved=false&ps=1" | jq .total
      ```
      
      Merge-despite is defensible only when the sole failing condition is a touched-line **re-attribution** metric, open PR issues are 0, and the PR body documents the rationale. Real findings: fix them.
      
      **`new_duplicated_lines_density` is only sometimes that metric — check which case you are in before invoking the exemption.** It is re-attribution when the PR *moved or touched* existing lines and Sonar consequently charged the surrounding, already-duplicated block to the diff. It is a real finding when the PR *added* files: three new sibling classes written in one sitting are copy-paste, and the metric is measuring exactly that. `api/issues/search` cannot tell them apart — duplication is a measure, not an issue, so it returns 0 in both cases and the "open issues are 0" half of the exemption is satisfied either way.
      
      Ask which files carry the new duplicated lines, then read the blocks:
      
      ```bash
      curl -s -H "$AUTH" "https://sonarcloud.io/api/measures/component_tree?component=$KEY&pullRequest=$PR&metricKeys=new_duplicated_lines&ps=200" \
        | jq -r '.components[] | (.measures[0] | (.value // .periods[0].value // "0")) as $v
                 | select($v != "0") | select(.qualifier=="FIL") | "\($v)\t\(.path)"'
      curl -s -H "$AUTH" "https://sonarcloud.io/api/duplications/show?key=$KEY%3A<path>&pullRequest=$PR" \
        | jq '.duplications[].blocks | map("\(.from)-\(.from + .size - 1)")'
      ```
      
      Two shapes to get right or the first command prints nothing: a **`new_*`** metric
      carries its value under `periods[0].value`, not `.value`, and the response lists
      directories as well as files, so filter on `qualifier=="FIL"` to get paths you
      can pass to `duplications/show`.
      
      If the files listed are ones the PR *added*, dedupe them — the exemption does not apply. `typo3-testing-skill/references/sonarcloud.md` ("Gotchas: new-code duplication") carries the same rule from the analyzer side and the recovery patterns for it.
      
      ## Watcher skeleton
      
      ```bash
      R=owner/repo; PR=123; BR=branch; RERUN_DONE=0
      for i in $(seq 1 100); do
        sleep 30
        STATE=$(gh pr view $PR --repo $R --json state,mergeStateStatus) || continue
        [ "$(jq -r .state <<<"$STATE")" = "MERGED" ] && exit 0
        MS=$(jq -r .mergeStateStatus <<<"$STATE")
        UNRES=$(gh api graphql -f query="{repository(owner:\"${R%/*}\",name:\"${R#*/}\"){pullRequest(number:$PR){reviewThreads(first:100){nodes{isResolved}}}}}" \
          --jq '[.data.repository.pullRequest.reviewThreads.nodes[]|select(.isResolved|not)]|length') || continue
        CHECKS=$(gh pr checks $PR --repo $R 2>/dev/null)
        PENDING=$(grep -c -E "pending|in_progress" <<<"$CHECKS" || true)
        HARD=$(grep "fail" <<<"$CHECKS" | grep -v -c -E "codecov|SonarCloud Code Analysis" || true)
        SOFT=$(grep "fail" <<<"$CHECKS" | grep -c -E "codecov|SonarCloud Code Analysis" || true)
        [ "$MS" = "BLOCKED" ] && [ "$UNRES" -gt 0 ] && { echo "HOLD: $UNRES threads"; exit 1; }
        if [ "$HARD" -gt 0 ] && [ "$PENDING" -eq 0 ]; then
          # one rerun for infra flakes only, then HOLD
          if [ "$RERUN_DONE" -eq 0 ] && grep "fail" <<<"$CHECKS" | grep -qE "E2E|Integration|docker"; then
            # Resolve first and check it: an empty id, or a rerun that fails, must
            # NOT burn the single retry — otherwise every later iteration skips the
            # rerun that never happened.
            RID=$(gh run list --repo $R --branch $BR --workflow ci.yml --limit 1 \
                    --json databaseId --jq '.[0].databaseId') || RID=""
            if [ -n "$RID" ] && gh run rerun "$RID" --repo $R --failed; then
              RERUN_DONE=1; sleep 60; continue
            fi
            echo "HOLD: rerun could not be dispatched (id='${RID:-none}')"; exit 1
          fi
          echo "HOLD: hard fails"; grep fail <<<"$CHECKS"; exit 1
        fi
        if [ "$PENDING" -eq 0 ] && [ "$UNRES" -eq 0 ] && { [ "$MS" = "CLEAN" ] || [ "$MS" = "UNSTABLE" ]; } && [ "$HARD" -eq 0 ] && [ "$SOFT" -eq 0 ]; then
          gh pr merge $PR --repo $R --merge && exit 0
        fi
      done
      ```
      
      Pitfalls baked in: `grep -c` exits 1 on zero matches (`|| true`); decide hard-fail only at `PENDING -eq 0` (codecov posts transient FAILURE mid-run); never count a check class you did not explicitly list.
      
      **On a merge-queue repo, drop the strategy flag.** `gh pr merge $PR --merge` (or `--squash`/`--rebase`) on a repo whose `main` uses a merge queue prints `! The merge strategy for main is set by the merge queue` and ignores the flag — but it still **enqueues** the PR, so that line is a notice, not a failure. Confirm via the queue-entry check (below), not the command's output. Call `gh pr merge $PR` without a strategy flag there and let the queue decide; keep the explicit strategy only for non-queue repos.
      
      **Right after a queue merge, the REST view lags.** `gh pr view --json state` can still answer `OPEN` (with `mergeQueueEntry` already `null`) for a short window after the queue merged the PR — which reads exactly like an ejection. The GraphQL state the watcher polls is the settled verdict; when the two disagree, re-query before reporting either. (Observed 2026-08-25: a merged PR was reported "thrown out of the queue" off the stale REST answer and the report had to be corrected minutes later.)
      
      ## Two facts the loop depends on
      
      **`gh run rerun` reuses the original `GITHUB_SHA`.** For `pull_request` events that is the merge commit computed at first run — a rerun after a base-branch fix still tests against the broken base. Rerun is only for flakes; to pick up a repaired base, rebase the branch and push.
      
      **Review bots converge over multiple rounds.** A `copilot_code_review` rule is not a merge gate: Copilot leaves a `COMMENT` review, which per GitHub's docs does "not count toward required approvals and will not block merging". Nor does every push invalidate the standing review — re-review on push is the rule's `review_on_push` parameter, and when it is unset "Copilot will only review the pull request once". Read the rule's `parameters`, not just its presence: `gh api repos/$R/rules/branches/$BRANCH --jq '.[] | select(.type=="copilot_code_review")'`. Re-request when you want fresh feedback on a new head: `gh api repos/$R/pulls/$PR/requested_reviewers -X POST -f 'reviewers[]=copilot-pull-request-reviewer[bot]'`, then confirm the request registered off the timeline, not off `requested_reviewers` — a reviewer that has *started* drops off that list without having submitted (see `references/pull-request-workflow.md`, "Review on an earlier head + `CLEAN`"). Later rounds may flag UNCHANGED lines adjacent to the diff (latent legacy bugs) — triage each finding on its merits; expect 3–6 rounds on large refactor PRs, with finding severity decreasing per round. Re-arm the watcher after every push.
      
      **A bot review can be a failure notice, not a review — read the body, not the state.** `copilot-pull-request-reviewer` posts its quota and capacity failures as an ordinary `COMMENTED` review whose body is `Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.` Every state-based check reads that as a satisfied gate: `reviews` is non-empty, `reviewThreads` is `0`, inline `comments` is `0`, and `mergeStateStatus` is `CLEAN` — indistinguishable from a clean review that found nothing. Before treating a bot review as landed, read the body:
      
      ```bash
      gh pr view $PR --repo $R --json reviews \
        --jq '.reviews[] | select(.author.login|test("copilot")) | .body'
      ```
      
      Treat `unable to review` as **no review** and re-request; if the re-request returns the same notice the quota is still exhausted, and merging means merging unreviewed. Check the repo's recent merged PRs the same way before concluding that a bot review is the local norm — a quota outage can span every PR in a window, so "the last three merged PRs also show COMMENTED" is not evidence they were reviewed.
      
      Once you know the quota is exhausted, mind *when* `--watch` returns: `pr-status.sh --watch` (and any read whose `NEXT` is `request-review`) fires on that review-state event **immediately, even while CI is still running** — the event is independent of check completion, so it returns before the gate can be `CLEAN`. Do not merge on that first return. After deciding to proceed unreviewed, re-arm with `--watch --ignore-action request-review` — it holds through the quota-dead review state and returns once the checks settle (`SETTLED: NEXT is still the ignored action`, exit 0) or something else becomes actionable — and merge only at `mergeState=CLEAN`; only `CLEAN` passes the merge gate — `BLOCKED` means checks or threads are outstanding and `UNSTABLE` means a non-required check is still pending (seen 2026-08-12: a docs PR cycled `BLOCKED → UNSTABLE → CLEAN` across three re-arms while the bot stayed quota-dead).
      
      **On a docs/prose PR the loop does not decay — it must be actively terminated.** The bot re-reads the whole changed file each round and keeps surfacing a *new cosmetic* nit (wording, an illustrative example value, a spelling), so pushing a fix just triggers another round almost indefinitely. To converge: once a finding is purely cosmetic and defensible, **reply on the thread and resolve it *without* a new commit** — no push means no re-review means no new nit. Reserve fresh pushes for substantive findings; batch several real fixes into one push rather than one-per-thread.
      
      ## A polling watcher must emit on the transition, not on the state
      
      The skeleton above `exit`s when it is done, so it reports each outcome once. A
      watcher that instead *streams* events — a `Monitor`-style loop whose stdout lines
      become notifications — has no such protection: an emit condition written as a
      **state** (`pending == 0 && failures == 0`) is true on every subsequent poll and
      republishes the same line every cycle until something stops it. Three identical
      "checks complete" notifications for one PR is the usual first symptom, and the
      noise buries the event that actually changed.
      
      Latch the last emitted message and print only on change:
      
      ```bash
      last=""
      while true; do
        s=$(gh pr view "$PR" --repo "$R" --json state,statusCheckRollup) || { sleep 60; continue; }
        [ "$(jq -r .state <<<"$s")" != "OPEN" ] && { echo "PR#$PR $(jq -r .state <<<"$s")"; break; }
        fail=$(jq -r '[.statusCheckRollup[]?|select(.conclusion=="FAILURE" or .conclusion=="TIMED_OUT")]|length' <<<"$s")
        pend=$(jq -r '[.statusCheckRollup[]?|select(.status!="COMPLETED")]|length' <<<"$s")
        if   [ "$fail" != 0 ]; then msg="PR#$PR RED"
        elif [ "$pend" = 0 ];  then msg="PR#$PR checks complete, 0 failures"
        else msg=""; fi
        [ -n "$msg" ] && [ "$msg" != "$last" ] && { echo "$msg"; last="$msg"; }
        sleep 60
      done
      ```
      
      Watching several PRs in one loop needs one latch **per PR** (`declare -A seen`),
      not one shared variable — otherwise two PRs reaching the same state alternate and
      each re-emits.
      
      Two more traps sit in the fan-out shape, both measured, and both produce a
      *confident* wrong answer rather than an error:
      
      **A per-PR output file keyed by `owner/repo` writes into a directory that does
      not exist.** `out="$DIR/gate-$1-$2.txt"` with `$1=netresearch/foo` expands to
      `$DIR/gate-netresearch/foo-12.txt`; the redirect fails, every watcher in the
      fan-out dies at once, and the only evidence is seven `No such file or directory`
      lines buried above the summary. Slugify the whole identity — **not** the
      basename: `${1##*/}` drops the owner, so `alice/api` and `bob/api` both land on
      `gate-api-12.txt` and one silently overwrites the other, which is the same class
      of loss one level down.
      
      **Do not end the fan-out with an `echo` that asserts a state you have not
      read.** `for …; do … & done; wait; echo "all gates settled"` prints that line
      whether the watchers ran, died on a bad redirect, or were never started —
      it is the loop's own claim about itself, not a measurement. Print the
      per-PR results, or a count of the result files actually produced:
      
      ```bash
      PRS=("netresearch/foo 11" "netresearch/bar 12")
      dir=$(mktemp -d)
      for p in "${PRS[@]}"; do
          set -- $p
          slug=${1//[^A-Za-z0-9._-]/_}              # netresearch/foo -> netresearch_foo
          pr-status.sh -R "$1" "$2" --watch > "$dir/gate-$slug-$2.txt" 2>&1 &
      done
      wait
      ls "$dir"/gate-*.txt | wc -l                  # what exists, not what was intended
      ```
      
      Both fired in one session: the fan-out over seven pull requests produced no
      files and reported that every gate had settled.
      
      ### Running `pr-status.sh --watch` under a Monitor
      
      `pr-status.sh --watch` already exits on the first actionable event, so it is the
      better producer for a streaming watcher than a hand-rolled loop — but it prints a
      `waiting:` line on every poll, and those lines carry advisory prose. A filter that
      matches on a bare `error` fires on text such as "delete that file if it was
      recorded in error" and turns every poll into a notification. Drop the heartbeat
      first, then keep only the lines you act on:
      
      ```bash
      pr-status.sh -R OWNER/REPO "$PR" --watch 2>&1 \
        | grep --line-buffered -vE '^waiting' \
        | grep --line-buffered -E '^(ACTIONABLE|TIMEOUT|SETTLED|NEXT)|^\s*(failing|checks)|pr-status:'
      ```
      
      `ACTIONABLE`, `TIMEOUT`, `SETTLED` (the return of `--ignore-action` once checks
      settle) and `pr-status:` (the tool's own failures, including the
      `pr-status: UNREADABLE —` line for a gate that cannot be queried at all, with
      the cause on stderr) cover every terminal state, so silence still means
      "waiting", never "crashed unseen". Anything added to the producer has to carry
      one of these prefixes or be added here — a state the filter drops is a state
      nobody sees.
      
      Prefer the Monitor tool over a backgrounded shell for these long waits. Two
      backgrounded `pr-status.sh --watch` processes in one session were stopped by the
      harness with "the system is running low on memory" while CI queued for hours; the
      same watch under Monitor ran to its event. A watch that was killed reports
      nothing, so after such a stop, read the gate once directly
      (`pr-status.sh -R OWNER/REPO "$PR"`) before arming the next one.
      
      ### Key the verdict on `--json .next.action`, never on the `NEXT:` prose
      
      The rule above is about noise; this one is about a wrong verdict, and it is the
      same mechanism one step worse. A hand-rolled loop that polls `pr-status.sh` and
      decides by matching the human `NEXT:` line matches the **explanation**, not the
      state: the `why` text for `request-review` reads *"no review on the current head
      … do not merge unreviewed"*, so a `case "$line" in *merge*)` arm fires on a gate
      that is shut and announces the opposite of the truth.
      
      ```bash
      # ❌ matches the reason, not the state — "do not merge unreviewed" contains "merge"
      line=$(pr-status.sh -R "$R" "$PR" | grep '^NEXT:')
      case "$line" in *merge*) echo "GATE OPEN";; esac
      
      # ✅ the state is a field
      act=$(pr-status.sh -R "$R" "$PR" --json | jq -r '.next.action')
      [ "$act" = "merge" ] && echo "GATE OPEN"
      ```
      
      The human line is for a human; `--json` is the machine contract, and
      `.next.action` is one token from a fixed vocabulary. Do not copy that vocabulary
      into your loop — it grows with the script (18 values at 1.32.4, from `merge` and
      `ready` through `await-capacity` and `rules-unavailable`). Match the handful you
      act on by exact string, and treat every other value as "keep waiting": an
      unrecognised action then delays you, where a substring match on prose reports
      success.
      
      ### A watcher is armed on a head; stop it when the head moves
      
      A loop polling a pull request keeps polling after a force-push, and its next
      notification describes a state that no longer exists — while the watcher you
      armed on the *new* head reports beside it. Two watchers on one PR disagreeing is
      indistinguishable from a flapping gate. Stop the old one when you rewrite the
      branch (`TaskStop`, or whatever ends the producer), and name the head in the
      watcher's description so a stale event is recognisable as stale.
      
      ## Check the producer is switched on before arming the watcher
      
      A watch whose event can never be produced is indistinguishable from one whose
      event has not arrived yet: both are silence. Before waiting on a pipeline,
      confirm the host will create one at all — a project can have CI switched off
      entirely, and then no push, force-push or retarget produces anything to watch.
      
      A `403` confined to one endpoint family while everything else answers `200` with
      the same token is *consistent with* that feature being switched off, and equally
      with the token lacking the scope for it. Rule the rate limits out by their body
      first — `API rate limit exceeded` or `You have exceeded a secondary rate limit`,
      per "Watcher cost" below, which is also a 403 and is *not* always global. Then
      read the flags, which separate the remaining two:
      
      ```bash
      # Capture, then parse: `glab … | jq …` exits with jq's status, and jq on empty
      # input exits 0, so a || fallback on the pipeline can never fire.
      out=$(glab api "projects/:id") \
        || { echo "probe refused — that is the access case, not the feature case"; }
      printf '%s' "$out" | jq '{jobs_enabled, builds_access_level}'
      # {"jobs_enabled": false, "builds_access_level": "disabled"} -> nothing will run
      ```
      
      `projects/:id` resolves from the current clone's remote, so run it inside one.
      
      Observed cost: two watchers armed across ~40 minutes for a merge request whose
      project had `builds_access_level: disabled`, reported to the user as "no
      pipeline yet" when the correct answer was "no pipeline, ever, until someone
      re-enables CI". Give a wait a stop condition it can actually reach, and when a
      watch stays silent past the expected window, re-check the producer rather than
      extending the timeout.
      
      ### Armed in the same breath as the push: "no checks reported", exit 0
      
      The producer can be switched on and the runs still not exist yet. `gh pr checks
      <n> --watch` started seconds after a push prints
      
      ```
      no checks reported on the 'task/whatever' branch
      ```
      
      and **exits 0** — GitHub had not registered the workflow runs for the new head
      when the first poll landed. Exit 0 plus no failures reads exactly like "all
      green", which is the dangerous part: nothing in that output says the watcher
      never watched anything.
      
      Two consequences for the loop:
      
      - Read the watcher's output, not only its exit code. A run that ends on the
        "no checks reported" line watched nothing and has to be re-armed.
      - Give the runs a moment to appear, or arm the watcher on a read that has
        already seen at least one check. A plain `gh pr checks <n>` right after the
        push shows whether anything is `pending` yet; arm `--watch` once it does.
      
      Seen 2026-09-18 on a rebase-and-force-push: the watcher returned immediately,
      `gh pr checks` a moment later listed seven checks all `pending`, and the
      re-armed watcher then reported them green.
      
      ## Auto-merge armed + CLEAN but never enqueued: disable/re-enable to nudge
      
      On a merge-queue repo a PR can sit `CLEAN` with auto-merge **armed** and every required check green, yet never gets a `mergeQueueEntry` — it silently fails to enter the queue, so the watcher just times out. Confirm the symptom, then re-arm to force GitHub to re-evaluate enqueue-readiness:
      
      ```bash
      gh pr view $PR --repo $R --json mergeStateStatus,autoMergeRequest \
        --jq '{merge:.mergeStateStatus, autoMerge:(.autoMergeRequest!=null)}'   # CLEAN + true
      gh api graphql -F o="${R%/*}" -F r="${R#*/}" -F p=$PR -f query='query($o:String!,$r:String!,$p:Int!){repository(owner:$o,name:$r){pullRequest(number:$p){mergeQueueEntry{state}}}}' \
        --jq '.data.repository.pullRequest.mergeQueueEntry // "not queued"'      # "not queued" = stalled
      
      gh pr merge $PR --repo $R --disable-auto     # then re-arm
      gh pr merge $PR --repo $R --auto             # → now enters the queue (QUEUED)
      ```
      
      This is distinct from a PR that entered the queue and was then **dequeued/cancelled** (that one *was* `QUEUED` and dropped — usually a transient queue check failure; re-arm `--auto` there too). Both recover by re-arming; neither is fixed by `--admin`. Renovate/Dependabot PRs arm auto-merge via the deps workflow — a rebase onto current base (they lag) plus this nudge is the non-hand-merge way to complete them.
      
      ## Post-merge: confirm merge-triggered jobs by commit SHA, not by run list
      
      After merge, the base branch (`main`) fires its own runs (CI, release, deploy). To confirm those, query the **commit's** checks keyed on the merge SHA — never filter `gh run list` by `headSha`:
      
      ```bash
      SHA=$(gh pr view $PR --repo $R --json mergeCommit --jq '.mergeCommit?.oid')
      gh api repos/$R/commits/$SHA/check-runs --jq '.check_runs[]?|{name,status,conclusion}'
      gh api repos/$R/commits/$SHA/status      --jq '{state, total:(.statuses|length)}'   # legacy commit statuses (Sonar/codecov)
      ```
      
      `gh run list --json … --jq 'select(.headSha=="'$SHA'")'` is unreliable here: the list window is small and time-ordered, so a still-running `main` job scrolls out behind unrelated activity and the filter returns empty — which then feeds a `gh run view ""` (HTTP 404) and tempts a hand-rolled `sleep`-poll loop that just times out. The check-runs/status API is authoritative and SHA-addressed. For PR-head checks, `gh pr checks $PR --watch` already blocks to completion — prefer it over any custom loop.
      
      **Pre-existing red ≠ your regression.** If a post-merge gate (e.g. SonarCloud "Quality Gate failed" on N Security Hotspots) is red, check the *prior* base commit before owning it: `gh api repos/$R/commits/<prev-sha>/check-runs --jq '.check_runs[]?|select(.name=="<gate>")|.conclusion'`. Identical red on the parent + a diff that touched no relevant code = a pre-existing backlog to report, not a regression to fix.
      
      ### A check run is named after the JOB, not the workflow
      
      Looking for a workflow by its own name in `commits/$SHA/check-runs` finds nothing, and the natural conclusion — "that run does not exist for this commit" — is wrong. The entry is there under the **job** name.
      
      Measured on `netresearch/typo3-demo`: workflow run 32536686040 is `Deploy (Update)` (`event=workflow_run`, `check_suite_id=88200944968`); its check run 96938832284 is named `deploy`. Same check suite, and the check run's `html_url` points back at `/actions/runs/32536686040/job/96938832284`. Reproduced 4/4 there and 2/2 on `netresearch/ofelia`, where `Verify Release` appears as `Verify / Verify Release`.
      
      So match on the check suite or on the job names you expect, not on the workflow name — and never infer absence from a name miss.
      
      ### When you want run-level state, filter server-side with `head_sha=`
      
      The workflow run's own `status`/`conclusion` (as opposed to its jobs') lives on the runs endpoint. Do **not** reach back for a windowed list and filter it yourself — `actions/runs` takes the SHA as a query parameter and filters on the server:
      
      ```bash
      [ -n "$SHA" ] || { echo "no SHA — refusing to watch"; exit 1; }
      gh api "repos/$R/actions/runs?head_sha=$SHA&per_page=50" \
        --jq '.workflow_runs[] | "\(.name)=\(.status)/\(.conclusion // "-")"'
      ```
      
      The guard on the first line is not decoration. An **empty** `$SHA` drops the filter silently and the API answers with the unfiltered list — 4506 runs on one repository measured this way, against 0 for a syntactically valid unknown SHA and 0 for outright garbage. A watcher whose SHA lookup failed then extracts some unrelated run's state and reports it as the watched commit's; the extraction is not empty, so the value guard below cannot catch it.
      
      `repos/$R/actions/runs?per_page=20` piped into a client-side `select(.head_sha==$s)` has the same window defect as `gh run list`, with one difference that makes it worse in a loop: **it works on the first tick.** The commit is recent, so it sits inside the window; twenty minutes later unrelated runs have pushed it out and every subsequent tick sees nothing. A watcher built that way reports progress, then goes quiet, and quiet is indistinguishable from "still running" — it will sit out its full budget without ever emitting. (Observed 2026-08-21; the first tick listed five runs, later ticks listed none.)
      
      ### The emptiness guard must test the extracted value, not the response
      
      The natural guard is on the API call:
      
      ```bash
      json="$(gh api "…" 2>/dev/null || echo '')"
      if [ -z "$json" ]; then sleep 45; continue; fi     # never fires
      ```
      
      That checks the wrong thing. When the window scrolls past the commit the response is a perfectly valid `{"workflow_runs": []}` — non-empty, well-formed, and about a different set of runs. Guard on the value you actually extracted, and make a persistent extraction failure say so, because a watcher that has gone blind must not look like a watcher that is waiting:
      
      ```bash
      state="$(printf '%s' "$json" | jq -r '…')"
      if [ -z "$state" ]; then
        errors=$((errors + 1))
        [ "$errors" = "5" ] && echo "query has returned nothing for 5 rounds — this watch is blind"
        sleep 45; continue
      fi
      errors=0
      ```
      
      ### A file read right after a merge can still return the pre-merge content
      
      `contents/<path>?ref=<branch>` has been observed answering with pre-merge content immediately after a merge. A post-merge verification that reads the branch ref can therefore report the merged change as **absent** — an invented regression, produced by measuring too early rather than by anything being wrong. The mechanism is not established here (no attempt was made to reproduce it against a controlled merge); what is established is that the branch ref answered stale and the merge commit answered correctly. Address the merge commit, the same way the rest of this section does:
      
      ```bash
      MC=$(gh api "repos/$R/pulls/$PR" --jq '.merge_commit_sha')
      gh api "repos/$R/contents/<path>?ref=$MC" --jq '.content' | base64 -d | grep -q '<marker>'
      ```
      
      Observed 2026-08-21: a watcher fired "the trigger is NOT on main" the moment the PR merged; a direct read at the merge SHA a minute later showed it present, at the expected lines.
      
      ## Delete the branch/worktree only after the merge is CONFIRMED, never on watcher exit
      
      A merge-gate watcher loop can exit for reasons that are **not** "merged": the
      PR went `BLOCKED`, an auto-merge was cancelled, or the loop's own condition
      tripped on unresolved review threads. Deleting the local branch (or removing the
      worktree) the moment the watcher returns — before reading the PR's actual
      state — throws away work that is not yet on `main`.
      
      Gate the cleanup on the merge itself, not on the loop returning:
      
      ```bash
      STATE=$(gh pr view $PR --repo $R --json state --jq .state)
      [ "$STATE" = "MERGED" ] || { echo "not merged ($STATE) — keep the branch"; exit 0; }
      git -C .bare worktree remove <dir>
      git -C .bare branch -D <branch>
      ```
      
      If the branch was already deleted prematurely, it is usually recoverable from
      the remote (`git fetch origin` then re-add the worktree tracking
      `origin/<branch>`) — but only while the remote ref still exists (a merged PR's
      branch is often auto-deleted). The discipline is cheaper than the recovery:
      **confirm `state == MERGED` before any destructive cleanup.**
      
      ## A queued PR can silently leave the merge queue
      
      A PR queued via `gh pr merge --auto` on a merge-queue repo can drop back out with no visible event: `isInMergeQueue` flips to `false`, `mergeStateStatus` reads `CLEAN`, and nothing merges. Verify the real queue state via GraphQL (`state` / `merged` / `isInMergeQueue` / `mergeStateStatus`) — a status read that only looks at `mergeStateStatus` reports a dropped PR as merge-ready. Re-arm once (`gh pr merge --disable-auto`, then `--auto`, which forces the queue to re-evaluate); if it drops again, diagnose the queue's required contexts instead of re-arming repeatedly.
      
      ### The dequeue reason is on the `gh-readonly-queue` branch, never on the PR
      
      The queue runs the required checks on its own branch, `gh-readonly-queue/<base>/pr-<n>-<sha>`, and a failure there dequeues the entry **silently**: no bot comment, no failed check on the PR, `mergeStateStatus` unchanged. Every PR-scoped query therefore answers "ready and waiting" for a PR that was already thrown out. The runs on that branch are the only record:
      
      ```bash
      R=owner/repo; PR=123
      # --jq is gh's built-in filter and takes no --arg; pipe to real jq when you need one.
      gh api "repos/$R/actions/runs?per_page=40" \
        | jq -r --arg p "gh-readonly-queue/main/pr-$PR-" '
            .workflow_runs[] | select(.head_branch | startswith($p))
            | "\(.created_at) \(.name) \(.status)/\(.conclusion)"' | sort -r
      ```
      
      Then open the failing run's jobs and steps:
      
      ```bash
      RID=<id from above>
      gh api "repos/$R/actions/runs/$RID/jobs" --jq '.jobs[] | select(.conclusion=="failure") | .name'
      gh api "repos/$R/actions/jobs/<job-id>/logs"      # the step output, for the actual cause
      ```
      
      Two consequences for the diagnosis:
      
      - **Time-box the branch filter.** A re-queued PR produces a *second* run set on a branch whose name shares the `pr-<n>-` prefix. A filter matching only the prefix returns the old failed run alongside the new one and reads as a fresh failure. Add `select(.created_at > $since)` with the re-queue time.
      - **A dequeue is not evidence of a defect in the PR.** Observed 2026-08-09 (netresearch/t3x-nr-llm#686): five of six workflows green, `Checks` red on one job — `composer audit` exited 100 because `https://packagist.org/api/security-advisories/` answered HTTP 502. The identical workflow had passed on the previous queue branch 30 minutes earlier. Read the step log before concluding anything about the branch; a network-dependent step in a required check turns any upstream outage into a dequeue.
      
      ## Watcher cost: GraphQL and REST rate limits are separate budgets
      
      `gh pr view --json statusCheckRollup` is a GraphQL query and an expensive one. Two watchers polling it every 60 s exhausted the **GraphQL** budget (29 of 5000 left) while the REST **core** budget still showed 4614 of 5000 — and once that happened, plain REST calls also began returning `403 API rate limit exceeded`. That combination (one resource drained, the other healthy, both refused) is the **secondary** limit reacting to request density, not the quota. Its 403 body says `API rate limit exceeded` or `You have exceeded a secondary rate limit`, which is what tells it apart from an authorization 403 — read the body, not just the status. Read the resources separately rather than trusting a single number:
      
      ```bash
      gh api rate_limit --jq '.resources | to_entries[] | "\(.key): \(.value.remaining)/\(.value.limit)"'
      ```
      
      Three rules follow, and they cost nothing:
      
      - **One watcher per subject.** Two loops on the same PR double the spend and tell you the same thing.
      - **Poll REST, not GraphQL, for liveness.** `gh api repos/$R/pulls/$PR` and `gh api repos/$R/commits/$SHA/check-runs` answer state and checks from the cheaper budget.
      - **180 s, not 60 s.** A merge queue does not resolve in a minute; the faster interval buys nothing and is what drains the budget.
      
      Recovery is waiting: `gh api rate_limit --jq '.resources.graphql.reset'` is an epoch timestamp — sleep to it in **one** background command rather than retrying into the limit.
      
      ### What still works while GraphQL is drained
      
      `pr-status.sh` is GraphQL end to end, so it exits `GraphQL query failed` and the
      whole finish flow appears blocked. Most of it is not. `rate_limit` can even
      report `5000/5000` for both resources while GraphQL refuses — the counter is not
      where a secondary limit shows up, so probe it directly instead of believing the
      number, and classify the failure rather than assuming the worst: a 401, a DNS
      error and a throttle all make `gh api` exit non-zero, and only one of them is
      worth waiting an hour for.
      
      ```bash
      err=$(gh api graphql -f query='{viewer{login}}' 2>&1 >/dev/null) && echo up \
        || case "$err" in
             *RATE_LIMIT*|*"rate limit"*|*"secondary rate"*) echo throttled ;;
             *) echo "graphql down, not throttled: $err" ;;
           esac
      ```
      
      Answerable from REST, which is usually still healthy:
      
      | Question | REST call |
      |---|---|
      | head SHA, base, draft state | `gh api repos/$R/pulls/$PR` |
      | check runs on the head | `gh api "repos/$R/commits/$SHA/check-runs?per_page=100" --paginate` |
      | legacy commit statuses | `gh api "repos/$R/commits/$SHA/status?per_page=100" --paginate` |
      | reviews submitted | `gh api "repos/$R/pulls/$PR/reviews?per_page=100" --paginate` |
      | reviewers still requested | `gh api repos/$R/pulls/$PR/requested_reviewers` |
      | effective rulesets on the base | `gh api "repos/$R/rules/branches/$ENC_BASE?per_page=100" --paginate` |
      | merge (direct only) | `gh api repos/$R/pulls/$PR/merge -X PUT -f sha="$SHA" -f merge_method=merge` |
      
      Four details the table hides.
      
      **Checks are two endpoints, not one.** `check-runs` does not carry legacy commit
      statuses, so a repository whose required contexts are classic statuses looks
      green with none of them read. Query both.
      
      **Every one of these paginates.** Default page size is 30 — `reviews`,
      `check-runs` and the rulesets array all truncate silently, which is the same
      trap this file warns about elsewhere.
      
      **The rulesets path takes the branch as a path segment**, so a base like
      `release/2.1` must be URI-encoded first (`pr-status.sh` encodes it for exactly
      this reason):
      
      ```bash
      ENC_BASE=$(printf %s "$BASE" | jq -sRr @uri)
      ```
      
      **Pin the head when merging through REST.** Without `-f sha="$SHA"` the endpoint
      merges whatever the head is at the moment it runs, so a push that lands between
      the gate check and the call is merged unreviewed. This row is also
      direct-merge-only: the endpoint has no merge-queue mode, so a queue repository
      needs its own flow rather than this fallback.
      
      The one thing with **no** REST equivalent is converting a draft to ready
      (`markPullRequestReadyForReview`); review-thread resolution
      (`resolveReviewThread`) is GraphQL-only too. Requesting a review is not on that
      list — `POST /pulls/$PR/requested_reviewers` works on a draft, and Copilot does
      review drafts, so a bot-review ruleset is not automatically a hard stop while
      the budget is out. Confirm the request off the **timeline**, not off
      `requested_reviewers`: the response body does not echo a bot reviewer, and a
      reviewer that has started drops off that list without having submitted.
      
      ### "Has it merged yet?" costs nothing — ask git, not the API
      
      The three rules above make a watcher cheaper. This one makes the commonest watcher free. Once a PR is queued the only question left is whether its head landed on the base, and git answers that with no API budget at all:
      
      ```bash
      git fetch origin main --quiet
      git merge-base --is-ancestor "$HEAD_SHA" origin/main && echo MERGED
      ```
      
      `$HEAD_SHA` is the PR head you pushed, which you already know. This keeps working while both budgets are exhausted, which is exactly when a watcher is most likely to be running. Reserve `pr-status.sh` for the merge *gate* — checks, threads, reviews, the `NEXT` line — and use git for the merge *fact*.
      
      **Ancestry answers only where the merge preserves the commit.** `--merge` and `--rebase` do; **squash does not** — it writes one new commit with a new hash, so the original head is never an ancestor and the check reads "not merged" forever. In a squash-merge repo, accept one cheap REST call instead: `gh api repos/$R/pulls/$PR --jq .merged`. Know which strategy the repo allows before relying on ancestry — `pr-status.sh` prints it as `merge: methods=[…]`.
      
      **`git log --grep="#<pr>"` is not that test.** It is the tempting one-liner and it produces false positives: `--grep` searches the whole commit *message*, and a dependency bump carries its upstream changelog in the body — including that upstream's issue numbers, from a different repository. Observed 2026-08-13: a watcher on PR #765 reported `MERGED` on its first tick, seven months after the commit it matched. That commit was `chore(deps): bump actions/attest-build-provenance` from January, whose embedded changelog links `actions/attest-build-provenance` issue #765. The PR being watched was in fact `CONFLICTING` and needed a rebuild.
      
      Ancestry is a fact about the graph; `--grep` is a text search over prose that nobody wrote for you to parse.
      
      The same asymmetry as the empty-result rule, inverted: an empty result is first a broken query, and a *positive* result from a text search is first a coincidence.
      
      ### When GraphQL is exhausted, REST still opens the PR
      
      `gh pr create` and `gh pr view` are GraphQL; the two budgets drain independently, so `graphql: 0/5000` with `core: 4700/5000` leaves the whole `gh pr *` surface dead while REST is untouched. The REST endpoint takes the same arguments:
      
      ```bash
      gh api repos/$OWNER/$REPO/pulls -X POST \
        -f title="…" -f head="<branch>" -f base=main -F body=@body.md --jq '.html_url'
      ```
      
      `gh api repos/$OWNER/$REPO/issues/$PR/comments -X POST -F body=@file` posts a PR comment the same way. Both worked on 2026-08-13 while `gh pr create` returned `GraphQL: API rate limit already exceeded`.
      
      One flag trap while you are there: `gh api --paginate --slurp` is **rejected** together with `--jq` (`the --slurp option is not supported with --jq or --template`). Write the paginated JSON to a file first, then run `jq` over it.
      
      ### `gh api` writes its error to stdout — test a field, never emptiness
      
      On a 404 (or any error) `gh api` prints a JSON error object to **stdout** and exits non-zero. A watcher that decides on "did I get output?" reads the error as the answer:
      
      ```bash
      rel=$(gh api repos/$R/releases/tags/$TAG --jq '.tag_name' 2>/dev/null)
      [ -n "$rel" ] && echo "release exists"       # WRONG — fires on the 404 body
      case "$rel" in "$TAG") echo "release exists";; esac   # right — tests the value
      ```
      
      Observed 2026-08-09: a release watcher announced "release published" while the API was still answering 404 and the workflow was mid-run. Match the value you expect, or add `-q` handling that distinguishes exit status from output.
      
      ### Pass `--workflow` a file name — a display name resolves only while it is unique
      
      `--workflow` accepts a name, an id or a file name, so `gh run list --workflow CI`
      is valid — right up to the moment the selector matches more than one workflow.
      Then `gh` refuses with
      
      ```
      could not resolve to a unique workflow; found: codeql.yml codeql
      ```
      
      and prints it on **stderr with a zero-length stdout**. Inside an `until` loop
      that compares a captured count, the empty string is not the terminating value,
      so the loop never exits — it spins on the error for as long as it is left
      running, and from the outside that is indistinguishable from "the run has not
      finished yet". Observed on `netresearch/.github`, where a watcher polled a
      resolution error for hours while the run it was waiting for had long since
      completed.
      
      Prefer the file name — `--workflow codeql.yml`, `--workflow ci.yml` — which is
      unique by construction and cannot start colliding later when somebody adds a
      workflow. And when a loop's condition is built from a command that can fail,
      make the failure terminate it rather than feed it: check the exit status, or
      `|| break`.
      
    • no-editorializing.md 6.5 KB
      # No editorializing — inform, don't sell (tone, not wordlist)
      
      Applies to every written artifact: commit messages, PR/MR descriptions, review
      comments, issue/ticket text, chat — and code comments, docstrings,
      documentation, README and changelog files.
      
      Editorializing is a matter of **tone and intent, not specific words** — no
      banned-word list catches it, and the same word can be fine or not depending on
      whether it carries a fact. The failure is writing about *how good, clean, or
      careful the work is* instead of *what it does*. The reader has the diff and the
      artifact; anything that only flatters the work or reassures them adds nothing,
      and to a reviewer it reads as salesmanship — it provokes a counter-reaction
      before they reach the substance.
      
      Apply three tests before a sentence stays:
      
      1. **Deletion** — remove the phrase. Did the reader lose a fact? If not, cut it.
      2. **Subject** — is the sentence about the change, or about *you / your work*
         (its quality, your diligence)? The latter goes.
      3. **Voice** — would a terse maintainer write this, or does it read like a
         cover letter?
      
      Two recurring failure modes:
      
      - **Announcing the expected.** Passing tests, clean linters, "documented", "no
        regressions", "works as expected" are the baseline — do not narrate them.
        State a check's status only to flag an *exception* (something knowingly
        failing or skipped). In a test/verification list, say what was *added or
        covered*, not that it is green.
      
      - **Self-praise and reassurance.** Grading your own output ("clean", "robust",
        "elegant", "foolproof", "tidy", "genuinely new", "production-ready"); framings
        that reassure ("the honest breaking change", "deliberately scoped, not
        hidden", "where it belongs"); and the diligence humble-brag ("I carefully…",
        "I made sure to…", "thoroughly tested"). These describe the author, not the
        change. Show the fact; drop the framing. (The words are only symptoms — judge
        by the three tests above, not by the word.)
      
      Use plain labels, not graded ones: "Breaking change", "Tests", "Limitations" —
      not "Tests (all green)" or "Breaking change (honest)". If a limitation's cause
      matters, it is already stated in the item.
      
      ## Line wrapping — GitHub comment surfaces vs `.md` files
      
      Separate from tone: the artifacts above render through two different Markdown
      pipelines, and hard-wrapping prose is right in one and wrong in the other.
      
      - **`.md` files** (README, CHANGELOG, docs) render as CommonMark: a single
        newline inside a paragraph collapses to a **space**, so hard-wrapping the
        source at ~80 columns reflows invisibly on render. Match the file's existing
        wrap.
      - **GitHub comment surfaces** — PR/MR descriptions, review comments, issue/ticket
        bodies, and **release notes** — render with `breaks: true`: every single
        newline becomes a `<br>`. Hard-wrapping there carries the breaks into the
        rendered page as ragged mid-sentence line breaks. Write each paragraph and list
        item as ONE long line; use blank lines only for real paragraph/item boundaries.
      
      Do not wrap a release body or PR/comment the way you wrap a `.md` file. Fix one
      already published with hard-wraps via `gh release edit <tag> --notes-file <file>`
      (release bodies stay editable) or by editing the PR/comment.
      
      ### The rule breaks at the pipe, not at the keyboard
      
      Knowing the rule is not enough, because the way it gets violated does not feel
      like writing prose at all:
      
      ```bash
      git log -1 --format=%b > /tmp/body.md
      gh pr create --body-file /tmp/body.md      # <-- wrapped, every time
      ```
      
      A commit body is *correctly* hard-wrapped at ~72 columns — `commit-conventions.md`
      says so. Piping it into a PR body moves that text across the boundary this
      section is about, and nothing in the command looks wrong. Observed on six of six
      pull requests created that way in one session, while every body and comment the
      same author typed by hand in the same session was clean: the rule was held for
      writing and lost for plumbing.
      
      The same applies to any other correctly-wrapped source: a CHANGELOG entry, an
      ADR paragraph, a quoted issue body.
      
      **Join the paragraphs before posting**, and strip the trailers while you are
      there — a commit body carries `Signed-off-by`, which is noise in a PR
      description:
      
      ```bash
      git log -1 --format=%b \
        | sed '/^Signed-off-by:/d' \
        | awk 'BEGIN{RS="";ORS="\n\n"} {gsub(/\n/," "); gsub(/  +/," "); print}' > /tmp/body.md
      ```
      
      That `awk` joins each blank-line-separated paragraph onto one line. It is
      deliberately naive — it will also join a fenced code block or a list, so read
      the result before posting rather than trusting it on a body that has either.
      
      **The fingerprint.** `Signed-off-by:` visible in a rendered PR description means
      that description came from a commit message, which means it is hard-wrapped.
      Grep for it when auditing:
      
      ```bash
      gh pr view "$PR" --repo "$R" --json body --jq .body | grep -c '^Signed-off-by:'
      ```
      
      ## A negative capability claim needs the call and the response that produced it
      
      "X cannot be done" is the most expensive sentence to get wrong in a skill,
      because nobody re-tests it. Every reader after you inherits a limitation that
      may not exist, and the ones who would have discovered otherwise are precisely
      the ones who now do not try.
      
      It is also the sentence that hardens fastest. One session went, across three
      restatements and about ninety minutes:
      
      1. an observation — a request returned an empty body;
      2. a hedge in chat — "apparently the request did not register";
      3. a flat statement merged into a public skill — "a review cannot be requested
         on a draft, so the ruleset is a hard stop".
      
      A reviewer refuted it the same day, and the counter-example was **in that very
      session**: a review had been requested on a draft and delivered. The empty body
      was the API not echoing a bot reviewer; the timeline showed the request the
      whole time.
      
      So, when writing a limitation into a reference:
      
      - **Cite the call and the response.** The command, the status, the body. A claim
        with its evidence attached invites correction; one without it invites belief.
      - **Distinguish "did not work here" from "cannot be done".** An empty response,
        a 404 and a silent no-op are all observations about one attempt.
      - **Write the hedge if it is a hedge.** "Observed once, not confirmed" costs one
        clause and keeps the next reader looking.
      - **Prefer the positive form.** "Confirm the request off the timeline" is both
        true and useful; "requests do not register on drafts" was neither.
      
      The same applies to a limitation you are about to route around: if the
      workaround is expensive, spend two minutes proving the limitation first.
      
    • pull-request-workflow.md 198.3 KB
      # Pull Request Workflow
      
      Covers the PR lifecycle for Netresearch repos: branch and tooling checks
      before opening a PR, commit discipline, merge strategies, review-thread
      resolution, and the merge gate. See `references/commit-conventions.md` for
      commit message formatting.
      
      ## Start Here: `scripts/pr-status.sh`
      
      **Do not probe the merge gate one endpoint at a time.** `mergeStateStatus:
      BLOCKED` never says *why*, and the reason lives in five different places:
      checks, rulesets, review threads, whether a review exists on the *current*
      head, and the repository's allowed merge methods.
      
      The script is not on `PATH`, and you run it from the repository under
      inspection, so a relative `./scripts/…` misses. Bind the skill directory once,
      then call it by that path:
      
      ```bash
      # sort -V, not plain sort: 1.10.0 must beat 1.9.0. A directly installed
      # skill sorts after the cache paths and so wins when both exist.
      GW=$(ls -d ~/.claude/skills/git-workflow \
              ~/.claude/plugins/cache/*/git-workflow/*/skills/git-workflow \
              2>/dev/null | sort -V | tail -1)
      
      bash "$GW/scripts/pr-status.sh" -R owner/repo 123        # human summary + NEXT action
      bash "$GW/scripts/pr-status.sh" -R owner/repo 123 --json # for scripts and merge drivers
      bash "$GW/scripts/pr-status.sh" -R owner/repo 123 --watch
      ```
      
      Two API calls (a third, admin-only, when the PR is review-blocked — it reads
      classic branch protection, whose review gates like `require_last_push_approval`
      are invisible to the rules endpoint), and the output ends in a computed `NEXT:` — rebase, fix-ci,
      triage-ci, resolve-threads, address-comments, request-review, wait, or merge (with the method
      this repo actually allows and a warning when a merge queue is active). The
      JSON form carries each unresolved thread's `threadId` *and* `commentId`, which
      is everything needed to reply and resolve without another query.
      
      `address-comments` covers the channel the other two miss. A reviewer who writes
      under the pull request instead of on a line of the diff produces an *issue
      comment*, which lives in neither `reviewThreads` nor the check rollup — so a
      report built from those alone says `threads: 0 unresolved` while the findings
      sit unread. Anything posted after the last word of the author counts as
      unanswered; comments from bots are shown but never drive the `NEXT:` line, so a
      Renovate note cannot push its own pull request off the auto-merge rung.
      
      Measured on a 40-PR rollout that did not have it: **183 of 370 shell calls
      were PR-status probing**, the rulesets endpoint was queried exactly once, and
      `copilot_code_review` blocked four merges by surprise.
      
      **GitHub only.** The script speaks GitHub GraphQL, so it cannot answer for a
      GitLab merge request — `-R git.netresearch.de/group/project` is accepted at the
      command line and then fails with a bare `pr-status: GraphQL query failed`, and
      `--watch` produces nothing at all until it is killed. Nothing in the name says
      so, which is how a GitLab MR ended up behind a watcher that could never fire.
      For GitLab the equivalent is one `glab api` call:
      
      ```bash
      P=$(printf %s 'group/project' | jq -sRr @uri)   # the path must be URI-encoded
      glab api "projects/$P/merge_requests/123" \
        | jq '{state, detailed_merge_status, pipeline: .head_pipeline.status}'
      glab api "projects/$P/merge_requests/123/discussions" \
        | jq '[.[] | select(.notes[0].resolvable==true and .notes[0].resolved==false)] | length'
      ```
      
      `detailed_merge_status` is GitLab's counterpart to `mergeStateStatus` and,
      unlike it, does name the reason.
      
      ## Then Merge: `scripts/pr-merge.sh`
      
      ```bash
      ./scripts/pr-merge.sh -R owner/repo 123
      ./scripts/pr-merge.sh -R owner/repo 123 --dry-run   # print the command only
      ```
      
      It reads `pr-status.sh --json` and refuses unless `NEXT` is `merge`, printing
      the gate that is shut instead. When it does merge it uses the method the
      repository allows and drops `--delete-branch` where a merge queue is active,
      where the head branch lives in a fork (*Taking over a contributor's fork pull
      request* below), or where another open pull request is based on that branch
      (*Stacked PRs: retarget before you merge* below) — it asks
      `gh pr list --base <head-branch> --state open` and keeps the branch when
      anything comes back, naming the dependent pull requests so they can be
      retargeted first. A query that fails, and a `pr-status.sh` too old to report
      the head branch, both count as "something is stacked": an empty answer from a
      question that was never answered reads exactly like "nothing is", and here the
      difference decides whether somebody else's pull request survives.
      Afterwards it reads the PR back and reports only what it observed — `merged`
      when the state says so, `queued` when the PR really holds a queue entry, and a
      failure with exit 2 otherwise. `gh pr merge` exiting 0 proves nothing on a
      merge-queue repo; see *A pending auto-merge request silently swallows the
      enqueue* below.
      
      Both of those are silent traps for a hand-written `gh pr merge --merge
      --delete-branch`. A repository with `allow_merge_commit: false` answers
      "Merge commits are not allowed on this repository"; one with a merge queue
      answers "Cannot use `--delete-branch` when merge queue enabled". A 54-repository
      rollout hit each of them three times before the detection was written down
      once. Squash is never used — it discards the atomic commits and their
      signatures.
      
      ### `--watch` returns on the first actionable event, not at full settle
      
      An `until [ pending == 0 ]` loop learns nothing until the slowest matrix job
      ends — long after the first failure was visible and workable. Across sessions,
      45 such loops were written against 2 of any other shape. `--watch` returns as
      soon as a check fails, a thread needs an answer, a review is missing, or the
      required checks conclude. **Start fixing what is already red instead of
      waiting for green checks you do not need.**
      
      The first-event rule has one blind spot: an action already set at invocation
      that retrying cannot clear — a `request-review` held open by an exhausted
      Copilot quota — makes every re-arm return the same line within a second
      (#165). Once you have seen that action and decided not to take it, re-arm
      with `--watch --ignore-action <action>` (repeatable): the watch holds through
      it, still returns on every other actionable event, and answers
      `SETTLED: NEXT is still the ignored action` (exit 0) once the checks settle
      with the ignored action still on top.
      
      ### Never merge an unreviewed PR
      
      If `pr-status.sh` reports `reviews: NONE on current head`, do not merge.
      Request one and say so:
      
      ```bash
      gh api repos/OWNER/REPO/pulls/N/requested_reviewers -X POST \
        -f "reviewers[]=copilot-pull-request-reviewer[bot]"
      ```
      
      A force-push invalidates a prior review: the old review stays attached to the
      old commit, so a repo with a `copilot_code_review` rule goes back to BLOCKED
      and needs a fresh request against the new head.
      
      It also throws away a review that has been *requested and not yet delivered*,
      which is the more expensive half because nothing reports it. **Rebase before
      requesting, never after.** On 2026-09-09 three reviews were requested, each
      acknowledged, and the branches were then rebased onto a merged sibling: the
      force-push discarded all three runs and no review ever arrived. The PRs sat
      `CLEAN` with `reviews: NONE on current head` and nothing said why.
      
      #### CodeRabbit answers when it declines, and does not catch up afterwards
      
      Two properties decide whether waiting for CodeRabbit is worth anything:
      
      - **It reviews on events, not on request backlog.** Its own wording: *"CodeRabbit
        is an incremental review system and does not re-review already reviewed
        commits."* A review that did not happen at push or ready-for-review time is not
        pending — it is not going to happen, and waiting produces nothing.
      - **A rate limit is an answer, not silence.** The reply to `@coderabbitai review`
        is a comment reading `Review rate limited.` under an **`⚠️ Action not
        completed`** heading. Read it: a request that was refused looks identical to
        one still running if you only count `reviews: []`. The allowance is hourly and
        applies to public repositories independently of the organisation's plan — the
        same notice reports `Plan: Advanced`, `up to 1 included review per hour` and
        "you've used all free OSS reviews for now" together, so a paid plan is no
        reason to assume this does not apply.
      
      - **A clean review leaves no review object.** With nothing to report it posts a
        summary comment reading `No actionable comments were generated in the recent
        review.` and submits no review and no inline comments. `reviews[]` stays empty
        and `pr-status.sh` still says `reviews: NONE on current head` — identical to
        never having run.
      
      So `reviews[]` alone cannot distinguish *refused*, *never triggered* and
      *reviewed, nothing found*. This is the delivery-side twin of the request-side
      trap below — an empty `requested_reviewers` has three producers of its own and
      does not establish the Copilot wall. Same shape, different array, different
      bot: neither array carries the reason it is empty. The summary comment is the
      only place all three are told apart, and each is terminal — a wait loop keyed
      on "a review will appear" spins forever through all of them:
      
      **Read it by commit range, not by grepping for a phrase.** CodeRabbit keeps
      **one** summary comment and edits it in place — `created_at` differs from
      `updated_at`, there is no second comment — and it accumulates a block per push,
      each naming the range it covers:
      
      ```text
      <!-- rate limited by coderabbit.ai -->
      > Reviewing files that changed … between 78e7936d and 0dcc7ea1     <- current head, REFUSED
      <!-- end of auto-generated comment: rate limited by coderabbit.ai -->
      
      No actionable comments were generated in the recent review. 🎉
      Reviewing files that changed … between 8b867a4d and 78e7936d       <- previous head, clean
      ```
      
      So the same body carries "rate limited" *and* "No actionable comments" at once,
      about different commits. A grep for either phrase answers about whichever push
      happened to leave it — on the body above, matching `No actionable comments`
      reports the head as reviewed and clean when it was in fact refused, which is the
      direction that authorises a merge it should not.
      
      The sound read is to locate the block whose range **ends at the current head**
      and see which marker encloses it:
      
      ```bash
      R=owner/repo; PR=123
      H=$(gh pr view "$PR" --repo "$R" --json headRefOid --jq .headRefOid)
      gh api "repos/$R/issues/$PR/comments" > /tmp/cr.json
      
      # 1. the comment that NAMES $H, if there is one — not the newest (see below)
      jq -r --arg h "$H" '[.[] | select(.user.login=="coderabbitai[bot]")
                               | select(.body | contains($h))] | last | .body' /tmp/cr.json \
        | grep -nE "rate limited by coderabbit|Currently processing new changes|No actionable comments|and ${H}\."
      
      # 2. nothing named $H -> the short-sha shape may still cover it. Search EVERY
      #    CodeRabbit comment for it; never fall back to the newest comment, which can
      #    be an `Already reviewed the last commit` reply describing a different head.
      jq -r '.[] | select(.user.login=="coderabbitai[bot]") | .body' /tmp/cr.json \
        | grep -oE 'up to `[0-9a-f]+`'
      # a hit here is NOT a verdict: resolve the sha below before believing it
      ```
      
      Three of those markers are terminal states of a range-shaped block: refused,
      reviewed-clean, never triggered. None of them will change by waiting. The fourth,
      `Merge Risk`, is the shape that carries no range at all and is read differently.
      `Currently processing new changes` is the one state that is **not** terminal — a
      review in flight, which the merge gate says never to merge over.
      
      **The gap is asymmetric, which is what makes it confusing.** When CodeRabbit has
      findings it posts inline comments, and those DO register as a review — `reviews:
      coderabbitai=COMMENTED`, plus an unresolved thread the gate already counts. It is
      the **clean** pass that leaves nothing but the comment, so the only case where
      `reviews: NONE on current head` is actively misleading is the case where there
      was nothing to say. A reviewed-clean head and a never-reviewed head look
      identical on that line; that is what `coderabbit_on_head` separates.
      
      **`last` alone reads the wrong comment.** "It keeps one comment" is true of the
      summary; a reply to `@coderabbitai review` is a second comment by the same author
      and it is the *newer* one, so taking the last CodeRabbit comment lands on
      `Already reviewed the last commit` and reports a reviewed head as never reviewed.
      Select the comment that names `$H`, as the filter above now does — measured on
      `netresearch/matrix-skill#151`, a 512-character reply sitting after a
      7334-character summary.
      
      **A fourth shape says which head was assessed without naming a range at all.**
      The summary can carry a verdict block instead of the `between X and Y` line:
      
      ```text
      **Merge Risk:** _🔵 Low_ · up to `2cf7a`
      ```
      
      That `up to` sha is the head the assessment covers, and it is a **short** sha, so
      a grep built from the full `$H` above misses it and reports "never reviewed" for
      a pull request that was in fact reviewed — at an older commit.
      
      Do not compare a fixed number of characters: five hex digits is short enough that
      two commits in the repository can share them, and a prefix match then reports an
      older assessment as covering the current head — the one direction that authorises
      a merge it should not. Resolve it instead, and require it to name **exactly one**
      commit:
      
      ```bash
      SHORT=$(gh api "repos/$R/issues/$PR/comments" \
        | jq -r '[.[] | select(.user.login=="coderabbitai[bot]")] | last | .body' \
        | grep -oE 'up to `[0-9a-f]+`' | tail -1 | tr -d '`' | awk '{print $3}')
      git rev-parse --verify --quiet "$SHORT^{commit}" >/dev/null \
        && [ "$(git rev-parse "$SHORT")" = "$H" ] \
        && echo "the block covers the current head"
      # ambiguous, unknown, or a different commit -> treat this head as unreviewed
      ```
      
      Run it where the commit is fetched — from a checkout of the pull request, not
      from a clone that has never seen the branch, or an unfetched sha resolves to
      nothing and reads as the ambiguous case. That fallback is the safe one, but it
      is the wrong reason.
      
      The marker is advisory, so every outcome other than "one commit, and it is `$H`"
      falls back to unreviewed. When it names an earlier commit the automatic pass will
      not come back for the current head — `@coderabbitai review` is the only way to
      ask, and it may answer with the rate limit above. Observed 2026-09-17 on
      `netresearch/skill-repo-skill#322`: `up to 2cf7a` while the head was `889fc34`,
      two commits later.
      
      `pr-status.sh` runs the **range-shaped** half of this for you and reports it as
      `coderabbit_on_head` (`clean` · `findings` · `in-progress` · `rate-limited` ·
      `unknown` · `none`),
      also on the prose `reviews` line as `coderabbit=…`. It does not resolve the
      short-sha shape — that needs a checkout — so it answers `unknown` when a
      `up to \`…\`` marker is the only thing naming a head, and the resolution above is
      still yours to run. Nothing gates on the field: a comment is not a review, so
      `has_review_on_head` and the merge gate are unchanged, and the value exists so a
      self-review note can say what the bots did rather than claiming they said nothing.
      
      When *both* reviewers are walled — Copilot out of monthly quota, CodeRabbit rate
      limited — no bot review is obtainable and the documented path is to read the diff
      yourself and merge on the attestation (`pr-merge.sh --self-reviewed`), noting in
      the PR that the bot review was unavailable. That is not a shortcut around the
      gate; it is the gate's own fallback, and it is worth doing properly: in the same
      session, the one PR CodeRabbit *did* review returned a genuine defect, and the
      hand review of the remaining three found four more.
      
      **On a DRAFT PR the request is silently dropped.** The REST call above answers
      200, but the returned object's `requested_reviewers` stays `[]` and no review
      ever starts — nothing errors, the request just does not take. A
      `copilot_code_review` ruleset triggers its review only when the PR leaves
      draft (`gh pr ready`). So do not diagnose a "broken" reviewer request on a
      draft: mark ready first, then check `requested_reviewers` / the reviews list.
      The 2xx-is-not-proof rule applies — read the response body, not the status.
      (Observed 2026-08-26, netresearch/ldap-manager#659: two 200-acknowledged
      requests on the draft, zero effect; the ruleset review fired within a minute
      of `gh pr ready`.)
      
      #### A failed Copilot review looks exactly like a delivered one
      
      Copilot reports its own failures *as a review* — a normal `COMMENTED` row whose
      body is the error:
      
      ```text
      Copilot encountered an error and was unable to review this pull request.
      Copilot was unable to review this pull request because the user who requested
      the review has reached their quota limit.
      ```
      
      Nothing in the review's `state` distinguishes that from a real review, so
      "a review exists on the head" is not the same as "this PR was reviewed".
      `pr-status.sh` detects it and reports `copilot_review_errored: true` with
      `NEXT: request-review` and a `copilot_error_count`. From the second failure on a
      head it drops the retry command and tells you to review it yourself — in every
      repo, with or without the `copilot_code_review` ruleset, since the generic
      review gate re-requests the same bot. By hand, read the review **body**, not
      just its state.
      
      The action stays `request-review` even then, deliberately. The tool cannot
      observe that a human read the diff — a review by the PR author is excluded from
      the review gate by design — so a distinct "you are done now" action would be one
      nothing could ever satisfy, and it would re-fire forever on the operator it was
      written for.
      
      The failure is also a failing `copilot-pull-request-reviewer` check-run — but
      only in the REST `check-runs` API. It is **absent from GraphQL
      `statusCheckRollup`**, so a rollup-based check cannot see it.
      
      Re-request once. If it fails a second time, stop retrying and **review it
      yourself**: an outage may clear, but a quota ceiling does not clear by asking
      again, and the alternatives — merging unreviewed or waiting indefinitely on
      third-party infrastructure — are both worse than a self-review that says so.
      
      A body naming the **quota** is not an outage, so it costs no second strike:
      `pr-status.sh` withholds the retry command on the first failure and remembers
      the wall in
      `${XDG_CACHE_HOME:-~/.cache}/pr-status/copilot-quota-exhausted-YYYY-MM`. The
      quota is account-wide and monthly while the evidence is per PR — a pull request
      nobody ever requested a review on carries none at all — so without that file
      the next PR in the next repo is handed a `requested_reviewers` POST the same
      exhausted quota rejects (`netresearch/maint` #52 and #53, hours after the wall
      was proven elsewhere). Later runs read the marker, report
      `copilot_quota_exhausted: true` and answer with the self-review guidance and no
      command. The month is in the **filename**, so the marker stops applying at the
      reset rather than being aged out; delete the file to undo a verdict recorded in
      error.
      
      The quota wall has a second, earlier face: the `requested_reviewers` POST
      itself can be **silently dropped** — HTTP 200, but no pending request appears
      and no errored review follows either (five requests across four repos were
      swallowed this way on 2026-08-18 before a later errored review named the
      quota).
      
      An empty `requested_reviewers` does **not** establish that, and reading it as
      the quota tell states a swallowed request where there was none. The array has
      three producers and cannot tell them apart: the response body never echoes a
      **bot** reviewer at all, a reviewer that has **started** drops off the list
      without having submitted, and only the third case is a genuinely dropped POST.
      Confirm off the **timeline** or the reviews list instead, as
      `merge-gate-watcher.md` § *"What still works while GraphQL is drained"*
      prescribes — an errored Copilot row on the head is the same wall, said out
      loud, and `pr-status.sh` reports it as `copilot_quota_hit`. Once the wall is
      established by either route, stop requesting everywhere and go to the
      self-review path above.
      
      ### Putting the self-review on the record (#203)
      
      "Review it yourself and say so in the PR" used to end outside the tooling: the
      review-note comment satisfied the policy while `pr-merge.sh` still refused the
      merge, so the merge ran as raw `gh pr merge` — three times in one sweep, past
      the very script that exists to be the safe path. The fallback is now a
      first-class input:
      
      ```bash
      pr-merge.sh -R owner/repo 123 --self-reviewed
      ```
      
      posts a PR comment whose body carries the line `Self-review: <head-sha>` (as
      the PR author — the flag refuses any other authenticated user; hand-written
      markers need at least the first 12 sha chars, since an 8-char prefix is
      grindable by vanity-sha tools), then re-reads the gate and merges.
      `pr-status.sh` reads the attestation back from the last 100 comments and
      honours it where the refusing branch stamped a reason the author may satisfy in
      person: `next.reason: review-required`, the ordinary case where the policy wants
      a review and does not care whose, or `next.reason: bot-review-unsatisfiable`,
      where the demanded bot review cannot arrive at all. **A review is mandatory; a
      bot review is not.** Until 17 September 2026 the attestation counted only in the
      second case, which tied the whole self-review path to Copilot — a bot most
      repositories neither require nor have the quota for — and left a repository
      without the `copilot_code_review` ruleset unable to merge a reviewed pull
      request at all. What the policy demands is that somebody read the diff, and the
      author reading it is somebody.
      
      The one thing still worth waiting for is a bot review actually in flight: a
      pending Copilot request keeps the attestation inert and the ladder answers
      `await-review`, because requesting a reviewer commits you to waiting for its
      answer. The flag keys on the stamped reason, never on the account-global quota
      state, so a satisfiable refusal (say, classic `require_last_push_approval`)
      can never receive a false attestation, and the comment body states which of the
      two demands it satisfies rather than claiming an unsatisfiable bot where none
      was demanded. That keying is necessary
      and was not sufficient: until #214 the refusing branch itself stamped the reason
      while an `APPROVED` review sat on the very same head, and seven approved PRs in
      one sweep got the attestation anyway. The bot branches now also require that no
      approval is on the current head — checked against the approval list rather than
      `has_review_on_head`, which a failed Copilot review satisfies by being an
      ordinary `COMMENTED` row. With a bot review in flight
      the attestation changes nothing, a non-author comment never counts, a human
      `CHANGES_REQUESTED` or a host-required approval keeps it inert, `--dry-run`
      previews the comment without posting it, and the next push invalidates the
      attestation because the sha stops matching. This is an explicit operator assertion the tool reads back, not a
      state it claims to observe — the same reason the old "review-yourself"
      *action* was removed. The attestation comment is permanent PR history: post
      it only when the diff was actually reviewed, and say what was looked at in
      the review-note comment beside it.
      
      ### After a rebase, `range-diff` is what says the review still holds
      
      The attestation dies with the next push, and a rebase is a push. Under
      `strict_required_status_checks_policy: true` every merge into the base makes the
      remaining PRs stale, so a sweep of sibling PRs rebases each one in turn and
      arrives at a fresh head with no review on it — while the review that *was* done
      usually still describes the code exactly. Re-reading the whole diff to re-attest
      is the honest expensive answer. The cheap one is to prove the patches did not
      change:
      
      ```bash
      git range-diff origin/main...<old-head> origin/main...<new-head>
      ```
      
      Every commit printed with `=` is byte-identical in its patch; a `<`/`>` pair
      with a diff-of-diffs beneath it is a commit the rebase altered, and that one
      needs reading. A series that comes back all `=` means the rebase moved the base
      and nothing else, so the earlier review — yours, a bot's, a human's — still
      applies to the new head and can be cited as the basis for the attestation.
      Naming the old and new sha in the review note is what makes that checkable
      afterwards.
      
      This answers a different question from the reference-merge check under *"I need
      to rebase a long branch onto a base that moved"*: that one proves the resulting
      **tree** is the intended end state, this one proves the **patches** are still
      the ones somebody read. A rebase can land the right tree through changed
      commits, and reviewers read commits.
      
      It is also the check to run before reusing a bot review across a rebase.
      CodeRabbit and Copilot both refuse heads while rate-limited or out of quota, and
      a refusal is not a review — `range-diff` says whether the review you already
      have is still about this code, which is the only part you can establish
      yourself.
      
      ### A pull request you did not author takes the other path (#280)
      
      The attestation is an assertion by the author, so it is unavailable on any pull
      request that is not yours. A Renovate or Dependabot one is the obvious case —
      nobody can authenticate as the bot — but the common one is finishing a
      colleague's pull request: you are a perfectly good reviewer of it and you still
      cannot post its author's attestation, and `--self-reviewed` refuses. That
      leaves the ordinary path, which was open the whole time and went unnamed — a
      human `APPROVED` review on the current head satisfies the
      never-merge-unreviewed policy on its own, in the `copilot_code_review` branch
      as well as the generic one:
      
      ```bash
      gh pr review 123 --repo owner/repo --approve   # after reading the diff
      pr-merge.sh -R owner/repo 123                  # no flag
      ```
      
      Being someone other than the author is what makes that approval *count*: a
      review by the author is excluded from the gate, so on your own pull request the
      attestation is the only self-service route and on everyone else's the approval
      is. The two cases never overlap.
      
      A `COMMENTED` review is not enough — that is what a CodeRabbit note or a
      thread reply registers as, and the gate reads the approval list, not
      `has_review_on_head`. `pr-status.sh` reports `viewer`, `viewer_is_author` and
      `attestation_available`, and swaps the attestation advice for this command
      whenever the attestation cannot be posted — bot author or simply not you;
      `pr-merge.sh --self-reviewed` names it in its refusal. Nothing about the gate is relaxed: a third party still cannot mint
      an attestation for someone else's pull request, and the approval is a real
      review on the record rather than a flag. This is the case `deps-no-automerge`
      and `deps-major` route to a human by design — `netresearch/.github`'s
      `auto-merge-deps.yml` excludes both labels, and its own documentation says
      majors are approved but left for a human to merge.
      
      **On a bot PR the `address-comments` rung measures against YOUR last word, not
      the author's (#319).** Everywhere else it measures against the author: you
      opened the pull request, somebody wrote under it, you answer. A bot author posts
      once at creation and never again, so measuring against that would count every
      later comment for the life of the pull request — the repository's own status
      notes from Codecov, SonarCloud and Dependency Review, a CodeRabbit "review
      skipped, bot user detected", and the approval note you just wrote — with no
      action left that clears it, because answering adds one more comment that is
      counted too. Observed before the fix on `netresearch/t3x-contexts_geolocation#56`:
      `CLEAN`, 62 checks green, zero unresolved threads, an `APPROVED` review on the
      head, and `pr-merge.sh` refusing.
      
      The guarantee that does not change: a comment posted by somebody else *after*
      your last word still raises the rung, on a bot pull request as anywhere else.
      
      ### Taking over a contributor's fork pull request (#308)
      
      A contributor opens a pull request from their fork, CI cannot be satisfied from
      their side, and they hand it over. `maintainerCanModify` is what makes the
      takeover possible without a cherry-pick — check it first, because it decides
      whether their authorship survives:
      
      ```bash
      gh pr view <n> --repo OWNER/REPO --json maintainerCanModify,headRepositoryOwner,headRefName
      git -C .bare remote add <their-login> https://github.com/<their-login>/<repo>.git
      git -C .bare fetch <their-login>
      git -C .bare worktree add ../pr-<n> -b <local-branch> <their-login>/<head-branch>
      # rebase, then your fixes as your OWN commits on top — never amended into theirs
      git -C ../pr-<n> push <their-login> HEAD:<head-branch> --force-with-lease
      ```
      
      Their commits keep their author and their `Signed-off-by`; yours carry yours.
      The rebase re-signs every commit with your key, which is expected — the author
      field is what attribution rests on, not the signature.
      
      Three things about the merge differ from an ordinary pull request:
      
      1. **The head branch is not yours to delete.** `gh pr merge --delete-branch`
         deletes it in the *fork* when your token has push rights there, and the
         contributor loses the branch their work sits on. `pr-merge.sh` reads
         `cross_repository` from `pr-status.sh` and drops the flag; a hand-written
         `gh pr merge` must drop it too.
      2. **`--self-reviewed` is not available to you.** The attestation is an
         assertion by the author, and on a takeover you are not the author. The path
         is the one the bot-authored case above names: read the diff, `gh pr review
         <n> --approve` as yourself, then `pr-merge.sh` with no flag. Say in the
         approval which commits are your own and therefore had no second reader.
      3. **Your own comments used to keep `address-comments` shut.** The rung
         measures against the *author*, the contributor has often stopped answering,
         and every comment you write is newer than their last word — so the counter
         rose with each one, including the comment answering the bot notices. The
         viewer is now excluded from it the way bots are. A reply from the
         contributor still raises the rung.
      
      Pin the head commit on the merge, since a takeover has usually pushed twice:
      `gh pr merge <n> --repo OWNER/REPO --merge --match-head-commit <full-sha>`.
      Read it and answer it.
      
      Where `viewer` is missing from the GraphQL response — an older `gh`, a stubbed
      one — the rung falls back to the author and behaves as it did before, which is
      the safe direction: it can hold a merge, never open one.
      
      **One bot account reaches you under three logins**, so any check written
      against a hardcoded name is wrong for two of them. Measured on
      `netresearch/github-release-skill#110`, all three naming the same Renovate
      install: GraphQL answers `renovate` with `__typename: Bot`, `gh pr view --json
      author` answers `app/renovate` with `is_bot: true`, and the webhook payload
      (what `auto-merge-deps.yml` matches on) answers `renovate[bot]`. `__typename`
      is the only authority — prefer it, and anchor every login pattern you fall back
      to, or `renovate-maintainer` reads as a bot and that person is refused
      `--self-reviewed` on their own pull request.
      
      ### Before believing a script cannot do something: ask which copy is running
      
      Every script in `scripts/` answers `--version`, and prints the resolved path beneath it:
      
      ```
      $ pr-merge.sh --version
      pr-merge.sh 1.27.0
      path: /home/you/.agents/skills/git-workflow/scripts/pr-merge.sh
      ```
      
      The version is read from the `SKILL.md` next to the script, never from a checkout elsewhere, so a cached copy reports the number it was packaged with. The path is there because the number alone is not enough: two installations on one machine declared the same version while shipping different scripts, and `--self-reviewed` existed in only one of them (#209). The flag was reported as missing — which reads exactly like a feature that was never built — and about a dozen PRs were merged by hand instead of through the attestation flow.
      
      So when a documented flag is rejected as unknown, that is a question about the installation before it is a question about the tool. `--version` needs no repository, no network and no `gh`: it is the one thing that must still answer when everything else is broken.
      
      ## A Rebase Conflict Can Mean the PR Is Superseded
      
      When `NEXT: rebase` turns into a conflict, look at **what the main side of the
      hunk contains** before resolving anything. If main's side already implements
      what the PR implements — the same feature, ported independently or landed via a
      sibling PR — the conflict is not a merge problem, it is the discovery that the
      PR is superseded. This is the normal fate of a fix PR that sat for days while
      the incident it came from was also worked elsewhere.
      
      The reflexive resolution — keep the PR's side, it is what you came to merge —
      is exactly wrong here: main's version has usually moved on (hardening, an extra
      call, review feedback the PR never saw), and preferring the PR side silently
      **reverts** that. Observed 2026-08-18 on `netresearch/jira-skill` #194: main's
      copy of the identical stdin feature had a mention-gate call integrated
      (`check_mentions_cli`); taking the PR's hunk would have merged green and
      dropped the gate.
      
      What to do instead: diff the PR's intent against current main and keep only the
      delta main lacks (`git reset --hard origin/main`, re-apply just that delta,
      reword the commit) — or close the PR outright if nothing remains. Either way,
      update the PR title and body to describe what it now is; a re-scoped PR wearing
      its old description misleads its reviewer. And when reviewing a PR that is more
      than a couple of days old, check `mergeable`/`mergeStateStatus` first — a
      `CONFLICTING` docs-or-fix PR is a "has this landed already?" prompt before it
      is a content-review task.
      
      ## Check the Default Branch Before Operating
      
      Not every repo uses `main` — older repos often use `master`, and some use
      `develop` or `trunk`. Before pushing, opening a PR, or scripting across many
      repos, resolve the actual default branch instead of assuming:
      
      ```bash
      gh repo view OWNER/REPO --json defaultBranchRef --jq '.defaultBranchRef.name'
      ```
      
      Assuming the wrong name silently pushes to (or creates) the wrong branch, or
      targets a PR at a branch that isn't the integration branch.
      
      ## After a Detour to Another PR, Switch Back — and Verify
      
      Working two PRs at once, a fix for PR B often means checking out B's branch
      mid-task. Nothing switches you back afterwards, and the next edits land on B
      while you believe you are on A. Because `git status` looks normal — modified
      tracked files, no conflict — the mistake surfaces only later, e.g. when a value
      you "already added" reads back as absent.
      
      Re-assert the branch before resuming edits, and again before staging:
      
      ```bash
      git branch --show-current    # cheap; run it after ANY cross-PR detour
      ```
      
      If edits did land on the wrong branch, move them rather than redoing them:
      
      ```bash
      git stash push -m "misplaced work" -- <paths>   # path-scoped: leaves the branch's own work alone
      git checkout <intended-branch>
      git stash pop
      ```
      
      Prefer a separate worktree per PR (`references/advanced-git.md`) when the two
      are worked in parallel — then no checkout is shared and the detour cannot
      misplace anything.
      
      ## Prefer the `gh` CLI / GitHub MCP Over Raw API or Web UI
      
      For GitHub operations (PRs, issues, reviews, releases), reach for `gh` or the
      GitHub MCP tools before hand-rolling `curl`/REST calls or clicking through the
      web UI: consistent authentication, structured `--json` output, and clearer
      errors. Drop to raw `gh api` only for endpoints the porcelain commands don't
      cover yet.
      
      ### Two `gh` shapes that read as bugs and are not
      
      `gh pr view --json merged` fails with `Unknown JSON field: "merged"` and then
      prints the whole valid-field list starting at `additions`, which buries the
      answer. There is no `merged` boolean: ask for `state` (`MERGED`), `mergedAt`,
      or `mergeCommit`.
      
      `gh repo fork <owner/repo> --remote=false` is refused outright —
      `the --remote flag is unsupported when a repository argument is provided`. The
      flag only applies when forking the repo you are standing in. To fork something
      else without touching local remotes, pass only `--clone=false`.
      
      Neither is worth a second attempt with the same shape, and they fail for
      different reasons: the first names a JSON field that does not exist, the second
      combines a flag with an argument it is not valid alongside.
      
      ### The quotas are session-shared pools — act, don't re-preflight
      
      Every `gh` call in a session draws from a shared pool — REST 5,000/h and
      GraphQL 5,000 points/h are SEPARATE pools, each shared across watchers,
      agents and scripts, plus short burst limits on top. A heavy session (fleet
      survey, repeated preflight batteries, parallel reviewers) usually kills the
      **GraphQL pool first** — and `gh pr merge`, `gh pr view --json`, and
      `pr-status.sh` are GraphQL-backed, so exactly the merge you verified
      everything for stops working (observed 2026-08-13, between "all gates green"
      and the merge).
      
      Two practices keep this from biting:
      
      - **When the full gate was verified on head X and nothing was pushed since,
        the action is ONE call — not another preflight battery.** The REST merge
        endpoint takes a `sha` pin whose 409 on mismatch IS the freshness check:
      
        ```bash
        gh api -X PUT "repos/$R/pulls/$PR/merge" \
          -f merge_method=merge -f "sha=$(git rev-parse HEAD)"
        ```
      
        Take the SHA from local git, never by retyping a short SHA into a long one
        — the pin rejects a fabricated tail exactly as it rejects a moved head.
      - **GraphQL dead ≠ blocked.** The REST twins keep answering: merge (above),
        branch delete (`gh api -X DELETE repos/$R/git/refs/heads/<branch>`; a 422
        means auto-delete beat you to it), reviews and comments lists. `gh api
        rate_limit` is exempt and tells you both pools' reset times. Prefer one
        `--watch` over repeated status reads, and stop any watcher whose answer
        you already have.
      - **`rate_limit` can read full while GraphQL refuses you.** A mutation
        answering `{"type":"RATE_LIMIT","code":"graphql_rate_limit","message":"API
        rate limit already exceeded for user ID <n>"}` is a per-user wall, not the
        points pool `rate_limit` reports — that endpoint showed `graphql.remaining:
        5000` in the same minute. So do not read `rate_limit` as clearance, and do
        not conclude the token lost a scope: the shape of the error body is what
        tells them apart. It lifts on its own; here in under 20 minutes.
      - **Answering a review thread has a REST twin; resolving it does not.** Reply
        with `gh api repos/$R/pulls/$PR/comments/<comment_id>/replies -X POST -f
        body=…` (the id is the *first* comment of the thread, from
        `pulls/$PR/comments`), which lands in the same thread as the GraphQL
        `addPullRequestReviewThreadReply`. `resolveReviewThread` has no REST
        equivalent, so when GraphQL is walled the only move is to retry it — put the
        retry in the background and carry on rather than holding the session on it.
      
      ## Describing the Change (PR Body, Commit Message, Issue)
      
      The rest of this file is about getting a change *merged*. This section is about
      making it *reviewable*. A reviewer who cannot see what changed has to reproduce
      your work before they can judge it.
      
      ### Show before/after for anything observable
      
      If the change alters output, an error message, a rendered page, a CLI line or an
      API response, the PR body carries **both states**. Not a description of them —
      the captured text:
      
      ```markdown
      **Before**
      
          Reason: An error occured on handling the request.
      
      **After**
      
          Reason: An error occured on handling the request. (HTTP 500, code 1603956982)
      ```
      
      Produce them the same way: run the old and the new code against the *same*
      input. A stub server, a fixture, or `git checkout <base> -- <file>` for one run
      and back again all work, and take a couple of minutes:
      
      ```bash
      run_it > after.txt
      git checkout <base> -- src/Thing.php   # old behaviour; <base> is the PR's target, not always main
      run_it > before.txt
      git checkout HEAD -- src/Thing.php     # restore; verify with git status
      ```
      
      Say in the body that the transcripts are captured output rather than
      illustrations — the difference matters to a reviewer deciding how much to trust
      them.
      
      Cover the shapes the change can meet, not only the motivating one. A table of
      input → before → after exposes the cases you would otherwise never run.
      
      ### "Unchanged" is a claim, and needs the same proof
      
      Negative claims escape verification because nothing looks wrong when you skip
      them. "The fallback keeps its wording", "existing callers are unaffected",
      "no behaviour change for empty input" — each asserts the result of a run you
      have to actually perform.
      
      Build the before/after table *before* writing the summary sentence. Filling in
      the rows is what catches the case you assumed was untouched; writing the
      sentence first only records the assumption.
      
      ### On a small, gated diff, point review rounds at the claims, not the code
      
      A two-file change that every gate has already passed has very little room left
      for a code defect, and a great deal of room for a wrong sentence about it. The
      prose is the part nothing checks: no linter reads the commit message, no test
      runs the PR body, and a maintainer decides from exactly those.
      
      The asymmetry is easy to measure once you look for it. Across four review
      rounds on a two-file config fix, the tree never changed — same hash through
      four commit revisions — while every round found something: a mechanism claimed
      backwards, a blast radius copied from an issue and never traced, a verification
      matrix that claimed more coverage than was run, a `grep` quoted in a form that
      returns different results than stated, a cited line number pointing at a file
      the installed version no longer ships.
      
      So when the diff is small and the gates are green, brief the reviewer on the
      text: every number, every cited file and line, every version range, every
      "affects X", every "I ran Y". Ask for the claim to be checked against the
      source, not for an opinion on the code. Two things make this cheap and worth
      repeating:
      
      - Give the reviewer the SHA and require it back. That does not stop a finding
        from describing a revision you have already replaced — it makes the mismatch
        visible, which is the part you can act on.
      - Verify each finding yourself before acting on it. A reviewer's premise can be
        wrong too, and a confidently wrong correction is worse than the error it
        replaces.
      
      Stop when a round returns only wording, not substance. Rounds that converge on
      phrasing have found the floor.
      
      ### A PR body describes the branch it had, not the branch it has
      
      A body written months ago documents a state the branch has since left. Every
      rebase, revert and upstream merge invalidates part of it, and nothing in the
      tooling notices. Before publishing an update, re-derive each factual claim from
      `git diff <target>...HEAD` — image tags, memory limits, thresholds, and above
      all the New/Changed column: a job listed as **New** that the target already has
      hides whatever your version alters about it.
      
      Observed in one MR: a "switched coverage to PCOV" section after PCOV had been
      reverted, `php:8.3` images that were `8.4`, `MSI 70/80` against an actual
      `19/77`, and a job marked **New** that existed upstream — which concealed that
      the change also dropped its memory limit from 2G to 512M.
      
      When a change is gone, **delete its section**. Rewording it to "X stays the
      coverage driver" replaces a false statement with a true but empty one: the body
      is the delta against the target, and something that does not change has no row
      in it.
      
      **Elapsed time is the obvious cause; a review is the frequent one, and it fires
      within the hour.** The body was written to describe the first version of the
      change, the review found that version wrong, and the fix replaced the approach
      the body still explains. Seen on one PR the same day it opened: the body
      documented an escape hatch read from the environment that the head commit had
      already replaced with one read off the command line — the mechanism was gone,
      the explanation was not.
      
      Re-derive the body after every push that answers a review, not only after a
      rebase, and **re-measure every number it states rather than copying it
      forward**. Figures survive edits that invalidate them: the same PR claimed a
      "63k-word corpus" while the code comment beside the measurement said 60k. Both
      had been measured — of different corpora, weeks apart in reading order and
      minutes apart in writing — which is exactly why the disagreement is worth
      catching. Two figures for one quantity means at least one is answering a
      question you are no longer asking.
      
      **A state change invalidates the body as surely as a push, and is easier to
      miss.** A push makes you re-read the body because you just wrote the change;
      flipping a flag does not. Yet the body is the one place that *asserts* the state
      in words, so it is the one place that can contradict it: a PR marked ready for
      review while the body still reads "still in draft while it waits for a look"
      says two different things to whoever opens it, and the sentence is the one they
      believe.
      
      Treat `gh pr ready`, `gh pr ready --undo` and `gh pr reopen` as edits to the
      body's factual content. After each, search your own text for the words naming
      the state you just left:
      
      ```bash
      gh pr view "$PR" --json body --jq .body \
        | grep -niE 'draft|WIP|work in progress|waiting on|blocked on|once .* lands'
      ```
      
      Observed on a PR whose body stayed accurate through three force-pushes and then
      went stale the moment it was marked ready — the one change that did not involve
      writing a diff.
      
      ### Write the body in its own tool call, after the push
      
      A body that cites a commit can only be written once that commit is on the
      remote, and the hash belongs in it by lookup — `git rev-parse --short HEAD` or
      `git log -1 --format=%h` — never typed from memory. A reader who follows a
      wrong hash finds nothing, and the claim it supported becomes unverifiable.
      
      That ordering has a second, sharper reason: **a denied command loses every side
      effect it contained.** Bundling the push, a heredoc that writes the body file,
      and `gh pr edit --body-file` into one shell invocation means a gate that rejects
      any part of it rejects all of it — and the parts that already ran do not roll
      back. Observed: the push landed, the heredoc never ran because the same call was
      rejected for naming a not-yet-pushed hash, and the following command failed with
      `no such file or directory` — a confusing error two steps downstream of the
      actual refusal.
      
      ```bash
      # ✅ Three calls, each with one job
      git push
      git rev-parse --short HEAD          # take the hash from here
      # …write body.md…
      gh pr edit 123 --body-file body.md
      
      # ❌ One call: the gate rejects the whole thing, the push already happened,
      #    and the body file that the next step needs was never created
      git push && cat > body.md <<'EOF' … EOF && gh pr edit 123 --body-file body.md
      ```
      
      The general rule: never bundle a file write with a push or an API call. Keep
      state-changing steps separable, so a refusal costs you the step and not the
      scaffolding around it.
      
      ### When you already have the fix, lead with it
      
      Issue templates order evidence before solution — they are written for reports
      where the cause is still unknown. When you arrive with a diagnosis *and* a
      patch, that ordering buries the actionable part under everything that proves it,
      and a maintainer reading top-down never reaches it.
      
      State the fix in the summary, then keep the evidence below it:
      
      ```markdown
      ### Summary
      
      <symptom, one paragraph>
      
      **The fix is to <X>** — <why it is safe>. Diff under [Possible fixes](#possible-fixes)
      below; everything in between is the evidence for the diagnosis.
      ```
      
      ### A revert PR's first fact is whether the break is in a release
      
      Before arguing that something must be reverted — a regression, a removed class,
      a changed default — establish whether the change ever shipped. The answer is one
      command, it changes the argument, and it decides whether a backport is part of
      the work:
      
      ```bash
      SHA=<the merge commit being reverted>
      git fetch origin --tags
      git tag --contains "$SHA"            # empty  -> not in any tag
      gh release list --repo "$OWNER/$REPO" --limit 5
      jq -r '.targetBranchChoices[]' .backportrc.json 2>/dev/null   # if the repo backports
      git branch -r --list "origin/<that branch>"                   # does it exist?
      ```
      
      Each answer writes a different paragraph:
      
      - **In no tag.** Say so, and say what follows from it: merging before the next
        release means no published version ever carried the break, and no backport is
        needed. This is usually the strongest sentence such a PR has, and it is also
        perishable — it names a deadline (the next tag) that a maintainer weighing the
        revert needs to know about.
      - **In a tag.** Name the version the consumer is on today, and say whether the
        revert needs to reach a maintenance branch as well as the default branch. A
        reviewer asks this within one comment of reading "BC break"; answering it
        first saves the round-trip.
      - **A backport branch is configured but does not exist.** Say that too — it
        stops the reviewer from looking for one.
      
      Observed 2026-09-18 on phpDocumentor/guides#1397, a revert of a merged
      `[TASK]` PR that had removed a class downstream test suites construct. The
      merge was three months newer than the latest tag, so nothing was released and
      the repo's `.backportrc.json` target branch did not exist upstream at all —
      two facts that only reached the PR body on the second pass, because nobody had
      asked the question in the first.
      
      Keep the template's sections — reviewers navigate by them — and add the pointer
      rather than reordering them.
      
      ## Atomic Commits (Default — No Squash Unless Asked)
      
      **The project default is atomic commits preserved end-to-end.** Squash is destructive: it loses GPG signatures, collapses bisection granularity, and destroys narrative. Never squash unless the user asks for it in this task.
      
      ### What "atomic" means
      
      - One commit = one self-contained logical change
      - Each commit builds and passes tests independently
      - No "WIP", "fixup", or "oops" commits in final history — rebase them away before merge
      - Mixed changes get split (`git add -p`, `git commit --fixup`, `git rebase --autosquash`)
      
      ### Preferred merge strategies (in order)
      
      1. **Rebase + merge commit** (`gh pr merge --merge` after `git rebase origin/main`): linear feature history with an explicit merge point. Preserves signatures. This is the default for Netresearch repos.
      2. **Fast-forward merge** (local `git merge --ff-only`): when signed commits are required AND only rebase is allowed (see "Signed Commits with Rebase Merge" below).
      3. **Squash**: only when the user explicitly asks.
      
      ### If you catch yourself typing `--squash`
      
      Stop. Re-read the task. Did the user say "squash"? If not, use `--merge` or `--rebase` (with the signed-commits caveat). The correction "no squash! atomic commits!" is a repeat interruption — prevent it by defaulting to merge-commit.
      
      ## Reviewing a PR: Read the Standing Review State First
      
      Before composing review findings — your own or a subagent's — fetch the PR's
      existing reviews and review threads in the same batch as the diff
      (`gh pr view --json reviews` plus the unresolved-thread GraphQL query, or MCP
      `pull_request_read: get_reviews` / `get_review_comments`). It costs no extra
      round-trip, and the diff alone is not the review context:
      
      - **Dedupe against standing feedback.** A finding another reviewer already
        raised gets referenced or skipped, not re-posted as new. Reposting reads as
        noise to the contributor and hides which comment is the actionable one.
      - **A moved head may BE the response to a prior review.** When the latest
        commits were pushed after a CHANGES_REQUESTED review, new findings are often
        regressions introduced by exactly the changes that review demanded — which
        reframes severity and tone, and the connection is invisible from the diff
        alone.
      - **The merge gate is the standing blocking review, not your new comment.** An
        open CHANGES_REQUESTED review keeps blocking after its points are addressed;
        the next action is that reviewer dismissing or re-reviewing, and a review
        posted without knowing that mis-states what happens next.
      
      Observed (usercentrics-widgets#143): a four-comment review posted straight from
      the diff duplicated one point of the repo owner's own standing CHANGES_REQUESTED
      review and missed that the head under review was the contributor's response to
      it — the "new" case-sensitivity finding was a regression introduced by that
      review's requested guards, and the real next step (re-review of the standing
      blocker) surfaced only after posting.
      
      ### Feedback sits on three surfaces — query all of them
      
      "Read the comments on this PR" is three separate API calls, and each one returns
      an empty list when the feedback lives on another surface:
      
      | Surface | Query | Holds |
      |---|---|---|
      | issue comments | `gh api repos/$R/issues/$PR/comments --paginate` | the plain comment box under the PR |
      | reviews | `gh api repos/$R/pulls/$PR/reviews --paginate` | a review's own body, including `suggestion` blocks written there |
      | review comments | `gh api repos/$R/pulls/$PR/comments --paginate` | the inline comments anchored to diff lines |
      
      A maintainer can put a whole review — prose, reasoning, several `suggestion`
      blocks — into the review **body** rather than onto a diff line. Both other
      queries then return `[]`, and "there are no comments on this PR" is wrong while
      a full review is waiting. Observed on
      TYPO3-Documentation/TYPO3CMS-Reference-CoreApi#6992, 2026-09-18: issue comments
      0, review comments 0, and one `COMMENTED` review carrying two suggestions.
      
      Two consequences for the reply. The committable-suggestion control belongs to a
      **line comment on the Files-changed tab** — GitHub's own instruction is "to
      suggest a specific change to the line or lines, click [the suggestion icon],
      then edit the text within the suggestion block"
      ([Commenting on a pull request](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests/commenting-on-a-pull-request)).
      A `suggestion` block typed into a review body is therefore ordinary fenced
      code: the change is made by hand, and "I committed your suggestion" mis-states
      what happened. And the review body is not a thread, so there is nothing to
      resolve — answer with a PR comment citing the commit SHA.
      
      ### Subagent findings: verify line anchors against the diff you fetched
      
      Inline comments anchor to diff positions. A file:line pair reported by a review
      subagent is a claim, not a coordinate — recompute it from the hunk headers
      before posting (observed: a reported `:185` pointed at a context `}`; the
      finding's code sat at 184). A wrong anchor lands the comment on an unrelated
      line or fails the review submission outright.
      
      ## Review Thread Resolution (SHA Citation Required)
      
      **Never reply with "Addressed" or "Fixed" without citing the resolving commit SHA.** Review threads are resolved on GitHub's side, not by agent assertion.
      
      ### Correct reply pattern
      
      ```bash
      # After pushing the fix
      SHA=$(git rev-parse HEAD)
      
      gh api graphql -f query='
        mutation($body: String!, $id: ID!) {
          addPullRequestReviewThreadReply(input: {body: $body, pullRequestReviewThreadId: $id}) {
            comment { id }
          }
        }' \
        -f body="Fixed in ${SHA:0:7} — <1-sentence explanation of what changed and why>." \
        -f id="PRRT_xxx"
      
      # Then resolve the thread
      gh api graphql -f query='mutation { resolveReviewThread(input: {threadId: "PRRT_xxx"}) { thread { isResolved } } }'
      ```
      
      ### Resolving is a claim about the whole ask, not about the file you edited
      
      A finding often names more than one target — two config files, "the table *and*
      the examples", every caller of a symbol. Fixing the one you opened, replying with
      its SHA and resolving reads exactly like completion, and the thread then carries
      a green mark that stops anyone looking. Nothing in the API checks the claim.
      
      So before `resolveReviewThread`, re-read the comment and enumerate the targets it
      names — by noun, not by impression — and confirm each. A reviewer that writes
      "update the bootstrap table and all affected enforcement and maturity examples"
      has named three things; a commit touching the first two satisfies neither the ask
      nor the thread.
      
      Two habits make this cheap: quote the ask's own list back in the reply and mark
      each item, and grep for the defect's shape across the repository rather than
      across the file you happened to open — the second file is usually the one the
      reviewer could see and you could not.
      
      Where you deliberately do *not* act on part of an ask, say which part and why in
      the reply. "Resolved" with a silent omission is the failure this section exists
      for; an explicit decline is a legitimate outcome the reviewer can argue with.
      
      ### Refusing the lazy pattern
      
      These replies are banned:
      - `Addressed` (no SHA, no explanation)
      - `Fixed — merged` (merged what? where?)
      - `Done` (done how?)
      - `Good point, updated` (updated what, in which commit?)
      
      Every resolving reply must include: commit SHA (7+ chars), one sentence of what changed, one sentence of why if not obvious from the diff.
      
      ### Verifying AI-reviewer claims before acting
      
      AI reviewers (GitHub Copilot, Gemini Code Assist, SonarCloud) mix correct findings with confident hallucinations. Before applying **or** declining a review comment, verify its load-bearing factual claim against an authoritative source — the framework/library code, official docs, or a quick local probe — not the reviewer's assertion alone.
      
      - **Applying blindly** ships wrong code (e.g. an edit based on a false API claim, which may also fail your own linter/type-checker).
      - **Declining blindly** dismisses real bugs — the same reviewer is often right about the next comment.
      
      Reply citing the evidence either way. When you applied a change, the reply must still carry the commit SHA and the what/why required above (e.g. `Verified against <source>: <fact> — applied in <SHA>, which …`); when you declined, state the source and fact (e.g. `Verified against <source>: <fact> — declining.`). When the suggestion is a code change, run the project's checks (lint, types, tests) on it before resolving, so the reply cites a green result rather than a guess.
      
      **A bot's risk banner is a claim about CI, and CI answers it in one call.** The
      section above is about review *comments*; the same reviewers also post a summary
      comment with a merge-risk line and a pre-merge-checks table. That channel opens
      no thread, so nothing forces a reply, and its assertions read as observations
      rather than as claims. They are claims. CodeRabbit on
      `netresearch/t3x-nr-passkeys-be#148` wrote *"Two CI matrix jobs fail during
      dependency installation before tests run; regenerate the lock file before
      merging"* against a head where all 24 matrix cells were green and the repository
      tracks no lock file at all:
      
      ```bash
      # What actually concluded on this head — tally before believing a banner
      gh api "repos/$R/commits/$SHA/check-runs?per_page=100" --paginate \
        --jq '[.check_runs[] | .conclusion] | group_by(.) | map({(.[0]//"pending"): length}) | add'
      gh api "repos/$R/commits/$SHA/check-runs?per_page=100" --paginate \
        --jq '.check_runs[] | select(.conclusion=="failure") | "\(.name)\t\(.html_url)"'
      ```
      
      The second call is the one that matters: a `failure` line naming
      `copilot-pull-request-reviewer` is the review quota, not a test job, and a
      banner that counts it as "CI failing" is describing something el
    • spec-cleanup.md 6.1 KB
      # Spec-Cleanup — Keep Intermediate Planning Artifacts Out of the Base Branch
      
      Dev-time planning artifacts — superpowers specs/plans (`docs/superpowers/**`),
      ad-hoc `PLAN.md` scratch files, output from other planning tools — are throwaway
      working notes. When they get committed to a feature branch they ride into the
      base branch on merge, rot there, and pollute history (the HMKG-2227 incident:
      ~5000 lines of specs/plans dragged across branches via `develop`).
      
      This capability has two layers, wired into the merge gate and `/pr-finish`:
      
      - **Guard** — deterministic, **read-only**. Detects the artifacts and blocks the
        gate. Safe to run in CI.
      - **Capture** — interactive, in-session. Distils durable knowledge into an ADR
        (proposed as a reviewable diff), then removes the raw files **recoverably**.
      
      ## Guard — `scripts/spec-cleanup-guard.sh`
      
      Read-only **invariant**: the Guard never deletes, stages, or modifies any file.
      It detects the *presence* of configured intermediate paths in three states and
      exits non-zero if any are found. It is branch-local (presence ⇒ fail), so there
      is no base-branch resolution to get wrong.
      
      | State | Mechanism |
      |-------|-----------|
      | tracked (committed) | `git ls-files` |
      | staged | `git diff --cached --name-only --diff-filter=AM` |
      | untracked | `git ls-files --others --exclude-standard` |
      
      ```bash
      bash skills/git-workflow/scripts/spec-cleanup-guard.sh            # enforce: exit 1 on matches
      bash skills/git-workflow/scripts/spec-cleanup-guard.sh --dry-run  # manifest only, exit 0
      bash skills/git-workflow/scripts/spec-cleanup-guard.sh --selftest # internal fixtures
      ```
      
      Config globs are normalized to **git pathspecs** (these are NOT shell globs):
      a directory glob `docs/superpowers/**` → directory pathspec `docs/superpowers/`
      (recurses); a suffix glob → `:(glob)docs/superpowers/**/*.plan.md` (anchored —
      a bare `**/*.plan.md` is forbidden because it sweeps in real files like
      `src/foo.plan.md`). `exclude:` entries become `:(exclude)` pathspecs (or
      `:(exclude,glob)` when the pattern contains glob characters).
      
      ## Config — `.spec-cleanup.yml` (optional)
      
      Single machine-readable source of truth for paths. See
      `.spec-cleanup.yml.example`. Without a config the baked-in defaults apply
      (`docs/superpowers/**`, `claudedocs/**`, `docs/working/**`,
      `docs/superpowers/**/*.plan.md`). If the file **is** present but `yq`
      (mikefarah v4) is not installed, the Guard **fails closed** (exit 2) rather than
      silently under-enforcing.
      
      ```yaml
      intermediate_paths:
        - docs/superpowers/**
      exclude:
        - docs/superpowers/specs/**/KEEP-*.md
      capture_targets:
        adr: docs/adr/          # v1 target (append new file)
      mode: convert             # convert | remove
      ```
      
      ## Capture — the `/pr-finish` spec-cleanup step (before Rebase)
      
      Runs in-session so the branch is clean before the gate. Sequence:
      
      1. **Manifest + confirm.** Print every match grouped by tracked/staged/untracked.
         The untracked subset is called out separately — it is the only class not
         recoverable from git by default. Require explicit confirmation.
      2. **Convert (default).** A sub-agent reads the artifacts and **proposes an ADR
         diff** into `capture_targets.adr`. Routing heuristic for v1: *a decision with
         alternatives considered → ADR*. (PRD update-in-place and user-doc stubs are
         deferred phase-2 targets.) Human reviews the diff; on accept, commit the docs.
      3. **Verify before removing.** Assert the docs commit exists and contains the
         intended paths (`git show --stat HEAD`) and the tree no longer reports them
         uncommitted. On any failure (blocked hook, signing failure, empty diff),
         **abort removal** and surface the error.
      4. **Recoverable removal.** Stage **only the manifested artifact paths**
         (`git add -- <path>`, never `git add -A/-u`, so unrelated working-tree changes
         are not absorbed) so they enter history, then commit their deletion as
         `chore: remove working specs/plans (captured in <ADR>)`. Recoverability holds
         **only** for merge/rebase-merged PRs (never squashed); in a squash-merge repo
         the captured ADR is the sole durable record — confirm it landed first. **Bare
         `rm` of untracked content is forbidden** — every removal is a git deletion of a
         tracked file.
      5. **Acknowledge path.** If nothing is durable (or `mode: remove`), skip step 2;
         still manifest+confirm and removal, and record a `Spec-Cleanup: acknowledged`
         trailer on the removal commit listing the removed paths. The acknowledging
         actor is whoever runs `/pr-finish`; the trailer commit is signed/DCO-compliant.
      
      **Mode precedence:** `mode` is the default offered; a human may escalate to a
      stricter outcome (decline conversion → remove) but the tooling never silently
      loosens.
      
      **Discovery vs enforcement:** the Guard (and CI) enforce only the declared
      globs. In-session, Capture may additionally surface session-known artifacts that
      match no glob (ad-hoc `PLAN.md`, other-tool output) and propose **adding them to
      `.spec-cleanup.yml`** so the deterministic Guard catches them thereafter — it
      never silently deletes a non-globbed path. CI cannot see session-only paths;
      that residual gap is closed only once discovery registers them in config.
      
      ## Wiring
      
      - **Merge gate** — a Pre-Merge Checklist item + command recipe in
        `references/pull-request-workflow.md` runs the Guard and blocks on non-zero.
      - **Optional runtime block** — extend the `merge-gate.sh` PreToolUse hook recipe
        in `references/claude-code-hooks.md`, or add an **off-by-default** git
        `pre-commit` template under `Build/hooks/` (a *git* hook — distinct from the
        Claude-hook config in `hooks/hooks.json`). Off by default because committing
        working notes mid-branch is fine; only reaching the base branch is not.
      
      ## Adoption
      
      Other repos adopt by shipping their own `.spec-cleanup.yml` (own globs + ADR
      target). No forked logic; composes with existing QA agents (e.g.
      `oro-qa-reviewer`). Note: the `git-workflow-skill` repo ships only
      `.spec-cleanup.yml.example` (no active config), so the Guard is not wired into its
      own merge gate/CI. Run manually with the baked-in defaults it *does* flag the
      dogfooded design spec under `docs/superpowers/specs/` — the intended
      self-demonstration (design §8).
      
  • scripts
    • conflict-marker-gate.py 3.9 KB
      #!/usr/bin/env python3
      """PreToolUse hook for Bash: deny `git commit` while staged content still carries
      merge-conflict markers.
      
      Why this exists: a resolver script that aborts does NOT stop a following
      `git add -A && git commit` in the same Bash call — the `&&` chain starts fresh
      after the failed command, so the commit lands with `<<<<<<<` in the tree.
      Observed twice in one session (2026-08-09, netresearch/t3x-nr-llm) during a
      batch-merge, caught only by a later gate run and costing a full test cycle each
      time.
      
      verify-git-workflow.sh checks the working tree on demand; this is the
      commit-time gate, and it looks at staged content for every file extension.
      
      Wire it as a PreToolUse hook with matcher "Bash" — see
      references/claude-code-hooks.md, Recipe 6.
      
      Fails open: any error, non-repo cwd, or unreadable diff allows the command.
      """
      
      import json
      import re
      import shlex
      import subprocess
      import sys
      
      # Real markers are at line start. `=======` alone is excluded on purpose: it is
      # a legitimate RST section underline and would fire on every docs commit.
      MARKER = re.compile(r"^(<<<<<<< |>>>>>>> )", re.MULTILINE)
      
      GUIDANCE = (
          "Staged content still contains merge-conflict markers ({files}). "
          "A resolver that exits non-zero does not stop a following `git add && git commit` "
          "— the chain starts fresh. Resolve the markers, re-stage, then commit. "
          "Check with: git diff --cached | grep -n '^<<<<<<< \\|^>>>>>>> '"
      )
      
      
      def is_git_commit(command):
          """True when the command runs `git commit` in any segment of the shell line."""
          try:
              lexer = shlex.shlex(command, posix=True, punctuation_chars=True)
              lexer.whitespace_split = True
              tokens = list(lexer)
          except ValueError:  # unbalanced quotes — nothing reliable to say
              return False
      
          for i, token in enumerate(tokens):
              if token != "git":
                  continue
              # Skip global options and their values (-C <dir>, -c <k=v>, --git-dir=…)
              j = i + 1
              while j < len(tokens) and tokens[j].startswith("-"):
                  if tokens[j] in ("-C", "-c", "--git-dir", "--work-tree"):
                      j += 1
                  j += 1
              if j < len(tokens) and tokens[j] == "commit":
                  return True
          return False
      
      
      def staged_conflict_files():
          """Names of staged files whose staged content carries conflict markers."""
          try:
              names = subprocess.run(
                  ["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR"],
                  capture_output=True,
                  text=True,
                  timeout=10,
                  check=False,
              )
              if names.returncode != 0:
                  return []
              hits = []
              for name in names.stdout.split("\n"):
                  name = name.strip()
                  if not name:
                      continue
                  blob = subprocess.run(
                      ["git", "show", f":{name}"],
                      capture_output=True,
                      text=True,
                      timeout=10,
                      check=False,
                  )
                  if blob.returncode == 0 and MARKER.search(blob.stdout):
                      hits.append(name)
                  if len(hits) >= 5:
                      break
              return hits
          except (subprocess.SubprocessError, OSError):
              return []
      
      
      def main():
          try:
              payload = json.load(sys.stdin)
          except (json.JSONDecodeError, ValueError):
              return 0
      
          command = (payload.get("tool_input") or {}).get("command") or ""
          if not command or not is_git_commit(command):
              return 0
      
          files = staged_conflict_files()
          if not files:
              return 0
      
          print(
              json.dumps(
                  {
                      "hookSpecificOutput": {
                          "hookEventName": "PreToolUse",
                          "permissionDecision": "deny",
                          "permissionDecisionReason": GUIDANCE.format(files=", ".join(files)),
                      }
                  }
              )
          )
          return 0
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • merge-gate.sh 5.8 KB
      #!/usr/bin/env bash
      # PreToolUse gate for `gh pr merge`. Reads the Claude Code hook payload on stdin
      # and emits a deny if the target PR has unresolved review threads or a non-CLEAN
      # merge state — the exact gap that caused repeated mis-merges/mis-diagnoses.
      #
      # NOTE: deliberately does NOT gate on reviewDecision. NR repos routinely have
      # reviewDecision "" (no human-approval rule) and merge legitimately when CLEAN;
      # mergeStateStatus==CLEAN already encodes GitHub's required-approval gate. Gating
      # on reviewDecision!=APPROVED would false-positive-block every such repo.
      set -euo pipefail
      
      # --version answers "which copy am I running" without diffing installations.
      # Two installations can declare the SAME number while shipping different
      # scripts (netresearch/git-workflow-skill#209 measured exactly that, and the
      # missing flag read as a missing feature for a dozen merges). So the resolved
      # path is printed beside the version: the number says what the copy claims to
      # be, the path says which file actually answered.
      #
      # The version is read from the SKILL.md NEXT TO the script, never from a
      # checkout elsewhere -- a cached copy must report the number it was packaged
      # with, or the answer is worse than none. \042 and \047 are the quote
      # characters by octal code, so this awk program contains no quote of its own
      # to terminate the single-quoted string it lives in.
      skill_version() {
          local here skill v
          here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
          skill="$here/../SKILL.md"
          v="unknown"
          if [ -f "$skill" ]; then
              v="$(awk '/^[ \t]*version:/ {
                       s = $0
                       sub(/^[ \t]*version:[ \t]*/, "", s)
                       gsub(/[\042\047]/, "", s)
                       gsub(/[ \t\r]+$/, "", s)
                       if (s != "") { print s; exit }
                   }' "$skill" 2>/dev/null)" || v=""
              [ -n "$v" ] || v="unknown"
          fi
          printf '%s %s\n' "$(basename "${BASH_SOURCE[0]}")" "$v"
          printf 'path: %s/%s\n' "$here" "$(basename "${BASH_SOURCE[0]}")"
      }
      
      [ "${1:-}" = "--version" ] && { skill_version; exit 0; }
      CMD=$(jq -r '.tool_input.command // ""')
      
      # Parse the PR id from the three `gh pr merge` reference forms. Flags before
      # the number may be long or short, with a separate or =-joined value
      # (--merge, --repo owner/repo, --repo=owner/repo, -R owner/repo).
      PR=""; REPO_FLAG=()
      if [[ "$CMD" =~ gh[[:space:]]+pr[[:space:]]+merge[[:space:]]+((-{1,2}[A-Za-z][A-Za-z-]*(=[^[:space:]]*)?([[:space:]]+[^-][^[:space:]]*)?)[[:space:]]+)*([0-9]+)([[:space:]]|$) ]]; then
        PR="${BASH_REMATCH[5]}"
      elif [[ "$CMD" =~ gh[[:space:]]+pr[[:space:]]+merge[[:space:]]+.*https?://github\.com/([^/]+/[^/]+)/pull/([0-9]+) ]]; then
        PR="${BASH_REMATCH[2]}"; REPO_FLAG=(--repo "${BASH_REMATCH[1]}")
      elif [[ "$CMD" =~ gh[[:space:]]+pr[[:space:]]+merge[[:space:]]+.*([^/[:space:]]+/[^#[:space:]]+)#([0-9]+) ]]; then
        PR="${BASH_REMATCH[2]}"; REPO_FLAG=(--repo "${BASH_REMATCH[1]}")
      fi
      # Honor an explicit --repo/-R owner/repo if present (overrides URL/shorthand).
      # Both spellings, space- or =-separated: without this the PR number was
      # resolved against the CWD repo and produced false denials (2026-08-03).
      if [[ "$CMD" =~ (--repo|-R)([[:space:]]+|=)([^[:space:]]+) ]]; then
        REPO_FLAG=(--repo "${BASH_REMATCH[3]}")
      fi
      
      # Could not parse a PR id — allow rather than false-positive block.
      [[ -z "$PR" ]] && exit 0
      
      # NB: `reviewThreads` is NOT a valid `gh pr view --json` field — gh errors
      # "Unknown JSON field". Fetch mergeStateStatus + url here, then get thread
      # resolution via GraphQL (owner/repo/number parsed from the url).
      INFO=$(gh pr view "$PR" "${REPO_FLAG[@]}" --json mergeStateStatus,url 2>/dev/null) || exit 0
      MSS=$(echo "$INFO" | jq -r '.mergeStateStatus // "null"')
      # UNKNOWN usually means GitHub is still computing the state (fresh push, or a
      # merge landed elsewhere moments ago). Re-poll briefly before evaluating, so a
      # legitimate merge isn't denied on a transient; a persistent UNKNOWN still
      # denies below (fail-closed). This is a bounded pre-check inside a PreToolUse
      # hook, not a merge-driver loop — pr-status.sh --watch remains the watcher.
      tries=0
      while [[ "$MSS" == "UNKNOWN" && $tries -lt 3 ]]; do
        sleep 3
        INFO=$(gh pr view "$PR" "${REPO_FLAG[@]}" --json mergeStateStatus,url 2>/dev/null) || exit 0
        MSS=$(echo "$INFO" | jq -r '.mergeStateStatus // "null"')
        tries=$((tries + 1))
      done
      URL=$(echo "$INFO" | jq -r '.url // ""')
      [[ "$URL" =~ github\.com/([^/]+)/([^/]+)/pull/([0-9]+) ]] || exit 0
      O="${BASH_REMATCH[1]}"; RN="${BASH_REMATCH[2]}"; NUM="${BASH_REMATCH[3]}"
      # Paginate the thread list — first:100 alone silently under-counts on PRs
      # with more threads (a round count is a truncation smell).
      UNRES=0; CURSOR=""
      while :; do
        ARGS=(-F owner="$O" -F name="$RN" -F num="$NUM")
        [[ -n "$CURSOR" ]] && ARGS+=(-F cursor="$CURSOR")
        # shellcheck disable=SC2016  # $-names are GraphQL variables, not shell
        PAGE=$(gh api graphql "${ARGS[@]}" -f query='query($owner:String!,$name:String!,$num:Int!,$cursor:String){repository(owner:$owner,name:$name){pullRequest(number:$num){reviewThreads(first:100,after:$cursor){pageInfo{hasNextPage endCursor}nodes{isResolved}}}}}' 2>/dev/null) || break
        N=$(jq -r '[.data.repository.pullRequest.reviewThreads.nodes[]? | select(.isResolved==false)] | length' <<<"$PAGE" 2>/dev/null) || break
        UNRES=$((UNRES + N))
        [[ "$(jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage' <<<"$PAGE")" == "true" ]] || break
        CURSOR=$(jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.endCursor' <<<"$PAGE")
      done
      
      if [[ "${UNRES:-0}" -gt 0 || "$MSS" != "CLEAN" ]]; then
        jq -cn --arg r "merge-gate: unresolved-threads=$UNRES, mergeState=$MSS — resolve threads / clear the block (check the ruleset & thread state) before merging" \
          '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: $r}}'
      fi
      
    • pr-merge.sh 16.9 KB
      #!/usr/bin/env bash
      # pr-merge.sh — merge a pull request with the method the repository allows,
      # and only when the merge gate is actually open.
      #
      # Why this exists: `gh pr merge --merge --delete-branch` is wrong in several
      # repository configurations and gives no useful error until it fails. Two of
      # them answer with a message; the other two are silent. A repo
      # with `allow_merge_commit: false` answers "Merge commits are not allowed on
      # this repository"; a repo with a merge queue answers "Cannot use --delete-branch
      # when merge queue enabled". Hand-rolling the detection per call site is how a
      # 54-repository rollout hit both, three times each. The silent two are a fork
      # head, which is not ours to delete, and a branch another open pull request is
      # based on, whose deletion closes that pull request. pr-status.sh already knows
      # most of the answer — this reads it instead of guessing.
      #
      # Squash is never used: it discards atomic commits and their signatures.
      #
      # Usage:
      #   pr-merge.sh                       # PR for the current branch
      #   pr-merge.sh 123
      #   pr-merge.sh -R owner/repo 123
      #   pr-merge.sh -R owner/repo 123 --dry-run   # print the command, run nothing
      #   pr-merge.sh -R owner/repo 123 --self-reviewed
      #                                     # the review the gate demands is one an
      #                                     # exhausted Copilot quota (or two failed
      #                                     # reviews on this head) makes
      #                                     # unsatisfiable, and the operator has
      #                                     # reviewed the diff instead: post the
      #                                     # on-the-record `Self-review: <head-sha>`
      #                                     # attestation comment as the PR author,
      #                                     # then merge. Refused whenever a live
      #                                     # review path still exists, and whenever
      #                                     # the authenticated gh user is not the
      #                                     # PR author (#203).
      #
      # Exit codes: 0 merged (or queued) AND confirmed by reading the PR back, 1 the
      # gate is shut and nothing was attempted, 2 an error — usage, lookup, the merge
      # call itself failed, or it exited 0 while nothing merged and nothing entered
      # the queue. 1 is retryable later; 2 needs a human.
      set -uo pipefail
      
      # --version answers "which copy am I running" without diffing installations.
      # Two installations can declare the SAME number while shipping different
      # scripts (netresearch/git-workflow-skill#209 measured exactly that, and the
      # missing flag read as a missing feature for a dozen merges). So the resolved
      # path is printed beside the version: the number says what the copy claims to
      # be, the path says which file actually answered.
      #
      # The version is read from the SKILL.md NEXT TO the script, never from a
      # checkout elsewhere -- a cached copy must report the number it was packaged
      # with, or the answer is worse than none. \042 and \047 are the quote
      # characters by octal code, so this awk program contains no quote of its own
      # to terminate the single-quoted string it lives in.
      skill_version() {
          local here skill v
          here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
          skill="$here/../SKILL.md"
          v="unknown"
          if [ -f "$skill" ]; then
              v="$(awk '/^[ \t]*version:/ {
                       s = $0
                       sub(/^[ \t]*version:[ \t]*/, "", s)
                       gsub(/[\042\047]/, "", s)
                       gsub(/[ \t\r]+$/, "", s)
                       if (s != "") { print s; exit }
                   }' "$skill" 2>/dev/null)" || v=""
              [ -n "$v" ] || v="unknown"
          fi
          printf '%s %s\n' "$(basename "${BASH_SOURCE[0]}")" "$v"
          printf 'path: %s/%s\n' "$here" "$(basename "${BASH_SOURCE[0]}")"
      }
      
      REPO=""; PR=""; DRY=0; SELF_REVIEWED=0
      SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
      
      die() { printf 'pr-merge: %s\n' "$1" >&2; exit 2; }
      need() { [ $# -ge 2 ] && [ -n "${2:-}" ] || die "$1 requires a value"; }
      
      while [ $# -gt 0 ]; do
        case "$1" in
          -R|--repo) need "$@"; REPO="$2"; shift 2 ;;
          --dry-run) DRY=1; shift ;;
          --self-reviewed) SELF_REVIEWED=1; shift ;;
          --version) skill_version; exit 0 ;;
          -h|--help) awk 'NR>1 && /^#/ {sub(/^# ?/,""); print; next} NR>1 {exit}' "$0"; exit 0 ;;
          -*) die "unknown flag: $1" ;;
          *)  PR="$1"; shift ;;
        esac
      done
      
      command -v gh >/dev/null || die "gh not found"
      command -v jq >/dev/null || die "jq not found"
      [ -x "$SCRIPT_DIR/pr-status.sh" ] || die "pr-status.sh not found next to this script"
      
      read_status() {
        STATUS=$("$SCRIPT_DIR/pr-status.sh" ${REPO:+-R "$REPO"} ${PR:+"$PR"} --json) \
          || die "pr-status.sh failed"
      
        # Tab-separated, read with a tab-only IFS: the default IFS splits on spaces
        # too, which would tear `why` apart, and it collapses an empty field so every
        # later variable shifts by one. `jq -e` turns a schema change or truncated
        # output into a failure here rather than an empty ACTION further down.
        FIELDS=$(printf '%s' "$STATUS" | jq -er '
          [ .next.action,
            (.next.why // "-"),
            .repo,
            (.number|tostring),
            (.queue_active|tostring),
            (.merge_methods|join(",")),
            (.cross_repository|tostring),
            (.head // "")
          ] | @tsv') || die "pr-status.sh returned unexpected JSON"
      
        IFS=$'\t' read -r ACTION WHY REPO PR QUEUE METHODS CROSS HEADREF <<EOF
      $FIELDS
      EOF
        [ -n "$ACTION" ] && [ -n "$REPO" ] && [ -n "$PR" ] || die "pr-status.sh returned no action"
      }
      
      read_status
      
      # --self-reviewed clears a review demand that the author may satisfy in person.
      # Two refusals qualify: `review-required`, where the policy wants a review and
      # does not care whose, and `bot-review-unsatisfiable`, where the demanded bot
      # review cannot arrive at all. Both are demands for A review; neither is a
      # host gate. A pending bot request is not on the list, because requesting a
      # reviewer commits you to waiting for its answer, and pr-status.sh keeps the
      # attestation inert while one is in flight.
      #
      # The flag does not open the gate directly — it posts the on-the-record
      # `Self-review: <head-sha>` attestation comment pr-status.sh reads back, then
      # asks again. Everything else about the gate stays exactly as strict: any
      # other refusal, a non-author caller, or a live review path leaves the flag
      # without effect.
      if [ "$SELF_REVIEWED" = "1" ] && [ "$ACTION" = "request-review" ]; then
        SR_FIELDS=$(printf '%s' "$STATUS" | jq -er '
          [ (.next.reason // "-"),
            ((.self_review_on_head // false)|tostring),
            (.headOid // ""), (.author // ""),
            ((.author_is_bot // false)|tostring)
          ] | @tsv') || die "pr-status.sh returned unexpected JSON"
        IFS=$'\t' read -r SR_REASON SR_HAVE SR_HEAD SR_AUTHOR SR_AUTHOR_BOT <<EOF
      $SR_FIELDS
      EOF
        # Keyed on the reason the REFUSING BRANCH stamped, never re-derived from the
        # account-global quota state: a request-review can also come from e.g. the
        # classic require_last_push_approval gate — a satisfiable human approval —
        # and with the monthly quota marker set on this machine a global test would
        # post a factually false "unsatisfiable" attestation into permanent PR
        # history there.
        case "$SR_REASON" in
          review-required|bot-review-unsatisfiable) ;;
          *)
            die "--self-reviewed refused: this request-review is not one the author may satisfy in person (reason: $SR_REASON) — a live review path exists, use it" ;;
        esac
        # A bot-authored pull request can never satisfy the author check below, so
        # the generic refusal would send the operator looking for a way to become
        # renovate. Named separately, with the path that does exist: approving the
        # head as yourself satisfies the same gate, and pr-merge then merges without
        # any flag (#280). No relaxation — a third party still cannot mint an
        # attestation for someone else's pull request.
        if [ "$SR_AUTHOR_BOT" = "true" ]; then
          die "--self-reviewed refused: $SR_AUTHOR is a bot, and the attestation is an assertion by the PR author — a bot never reads the diff, so this flag has no meaning here. Review the diff and approve it as yourself: gh pr review $PR --repo $REPO --approve, then re-run this script without --self-reviewed."
        fi
        VIEWER=$(gh api user --jq .login 2>/dev/null) || die "--self-reviewed: could not resolve the authenticated gh user"
        if [ "$VIEWER" != "$SR_AUTHOR" ]; then
          die "--self-reviewed refused: the attestation must come from the PR author ($SR_AUTHOR); gh is authenticated as $VIEWER"
        fi
        if [ "$SR_HAVE" != "true" ]; then
          # The body states which of the two demands is being satisfied. Writing
          # "unsatisfiable" where the truth is "a review was required and I wrote it"
          # puts a false claim about a bot into permanent pull-request history.
          if [ "$SR_REASON" = "bot-review-unsatisfiable" ]; then
            WHY_TEXT='The bot review this pull request demands is unsatisfiable (Copilot quota wall or repeated bot failures on this head).'
          else
            WHY_TEXT='This pull request requires a review and no bot review is in flight; a review by the author satisfies that requirement.'
          fi
          BODY=$(printf 'Self-review: %s\n\n%s The diff on this head was reviewed by the PR author; this comment is the on-the-record attestation the merge gate reads back. It stops matching on the next push.' "$SR_HEAD" "$WHY_TEXT")
          if [ "$DRY" = "1" ]; then
            # A dry run must not flip persistent gate state: the attestation comment
            # IS the gate-opening write, so it is previewed, never posted.
            printf 'pr-merge: dry-run — would post the Self-review attestation for %s on %s#%s, re-read the gate and merge\n' "${SR_HEAD:0:12}" "$REPO" "$PR"
            exit 0
          fi
          gh pr comment "$PR" --repo "$REPO" --body "$BODY" >/dev/null \
            || die "--self-reviewed: posting the attestation comment failed"
          printf 'pr-merge: posted Self-review attestation for %s on %s#%s\n' "${SR_HEAD:0:12}" "$REPO" "$PR" >&2
        fi
        read_status
      fi
      
      if [ "$ACTION" != "merge" ]; then
        printf 'pr-merge: not merging %s#%s — %s: %s\n' "$REPO" "$PR" "$ACTION" "$WHY" >&2
        exit 1
      fi
      
      # pr-status reports the method it picked; fall back to the allowed list. Squash
      # is excluded on purpose even when it is the only method the repo offers — in
      # that case pr-status already answers `blocked` and we never get here.
      METHOD=$(printf '%s' "$STATUS" | jq -r '.next.method // ""')
      if [ -z "$METHOD" ]; then
        case ",$METHODS," in
          *,merge,*)  METHOD="--merge" ;;
          *,rebase,*) METHOD="--rebase" ;;
          *) die "repo allows only [$METHODS] — enable merge or rebase, squash is not used" ;;
        esac
      fi
      
      # A merge queue rejects --delete-branch outright, and deletes the branch itself
      # once the entry merges. On a pull request from a FORK the head branch is not
      # ours to delete: `gh` happily deletes it when the token carries push rights
      # through maintainerCanModify, and the contributor loses the branch their work
      # is on — the one case where the flag destroys somebody else's state rather
      # than tidying our own.
      # Third case: the head branch is the BASE of another open pull request. Deleting
      # it CLOSES that pull request — and a closed pull request's base cannot be
      # retargeted, so recovery is pushing the branch back, reopening, retargeting and
      # deleting again, in that order. (GitHub documents retargeting dependent pull
      # requests when a merged head branch is deleted; on
      # netresearch/ldap-selfservice-password-changer#685 it closed instead, so the
      # branch is kept rather than the documented behaviour trusted.)
      #
      # An empty answer from a FAILED query would look exactly like "nothing is
      # stacked", so the query's exit status decides, and a failure keeps the branch.
      STACKED=""
      STACKED_UNKNOWN=""
      TMPERR=$(mktemp)
      trap 'rm -f "$TMPERR"' EXIT
      if [ "$QUEUE" != "true" ] && [ "$CROSS" != "true" ]; then
        if [ -z "$HEADREF" ]; then
          # Nothing to ask about: an older pr-status.sh that does not report the head
          # branch leaves the same doubt a failed query does, and is answered the
          # same way.
          STACKED_UNKNOWN="pr-status.sh reported no head branch"
        elif ! STACKED=$(gh pr list --repo "$REPO" --base "$HEADREF" --state open \
            --json number --jq '[.[].number | "#" + tostring] | join(" ")' 2>"$TMPERR"); then
          STACKED=""
          STACKED_UNKNOWN=$(tr '\n' ' ' < "$TMPERR")
          [ -n "$STACKED_UNKNOWN" ] || STACKED_UNKNOWN="gh pr list failed"
        fi
      fi
      
      CMD=(gh pr merge "$PR" --repo "$REPO" "$METHOD")
      if [ "$QUEUE" != "true" ] && [ "$CROSS" != "true" ] && [ -z "$STACKED" ] && [ -z "$STACKED_UNKNOWN" ]; then
        CMD+=(--delete-branch)
      fi
      
      if [ -n "$STACKED" ]; then
        printf 'pr-merge: keeping branch %s — open pull requests are based on it: %s. Retarget them, then delete it.\n' \
          "$HEADREF" "$STACKED" >&2
      elif [ -n "$STACKED_UNKNOWN" ]; then
        printf 'pr-merge: keeping branch %s — could not check for dependent pull requests: %s\n' \
          "${HEADREF:-(unknown)}" "$STACKED_UNKNOWN" >&2
      fi
      
      if [ "$DRY" = "1" ]; then
        printf '%q ' "${CMD[@]}"; printf '\n'
        exit 0
      fi
      
      OUT=$("${CMD[@]}" 2>&1); RC=$?
      if [ "$RC" -ne 0 ]; then
        printf 'pr-merge: %s#%s failed: %s\n' "$REPO" "$PR" "$(printf '%s' "$OUT" | tr '\n' ' ')" >&2
        exit 2
      fi
      
      # Exiting 0 is not proof that anything happened. On a repository with a merge
      # queue, `gh pr merge --merge` prints its usual success line and adds NOTHING to
      # the queue when an auto-merge request is already attached to the PR: the
      # pending request swallows the enqueue. Observed twice on one repository —
      # immediately after the call `isInMergeQueue` was false and the queue was empty;
      # after `gh pr merge <n> --disable-auto` the identical call put the PR at
      # position 1. Relaying the exit status as "queued" asserted a state that did not
      # exist and cost hours of misdiagnosis, so the outcome is read back before it is
      # claimed. This script reports; clearing the auto-merge request is the caller's
      # decision, not a side effect of asking to merge.
      OWNER="${REPO%%/*}"; NAME="${REPO##*/}"
      
      outcome() {
        # shellcheck disable=SC2016  # $owner/$name/$pr are GraphQL variables, not shell
        gh api graphql -f owner="$OWNER" -f name="$NAME" -F pr="$PR" -f query='
        query($owner:String!,$name:String!,$pr:Int!){
          repository(owner:$owner,name:$name){
            pullRequest(number:$pr){
              state isInMergeQueue autoMergeRequest{ enabledBy{ login } }
            }
          }
        }' 2>/dev/null
      }
      
      # A queue entry is registered asynchronously, so the first read can legitimately
      # answer false for an entry that lands a second later. The poll is bounded: a PR
      # that has not appeared by then does not appear on its own, and a longer wait
      # would be paid by every successful merge too.
      STATE=""; INQ=""; AUTOBY="-"; PROBED=0; ATTEMPT=0
      while :; do
        ATTEMPT=$((ATTEMPT + 1))
        if Q=$(outcome) && F=$(printf '%s' "$Q" | jq -er '.data.repository.pullRequest
              | [ .state, (.isInMergeQueue | tostring),
                  (.autoMergeRequest.enabledBy.login // "-") ] | @tsv'); then
          IFS=$'\t' read -r STATE INQ AUTOBY <<EOF
      $F
      EOF
          PROBED=1
          [ "$STATE" = "MERGED" ] && break
          # Keyed on what the PR reports, not on the repo-level QUEUE flag: an entry
          # that exists is an entry, whatever the configuration said a moment ago.
          [ "$INQ" = "true" ] && break
        fi
        [ "$ATTEMPT" -ge 5 ] && break
        sleep 2
      done
      
      if [ "$STATE" = "MERGED" ]; then
        printf 'pr-merge: %s#%s merged (%s)\n' "$REPO" "$PR" "$METHOD"
        exit 0
      fi
      if [ "$INQ" = "true" ]; then
        printf 'pr-merge: %s#%s queued (%s, strategy set by the queue)\n' "$REPO" "$PR" "$METHOD"
        exit 0
      fi
      
      # Nothing landed. A probe that failed is kept apart from a probe that succeeded
      # and saw nothing: an unreachable API says something about the request, not
      # about the PR, and folding the two together would put a transport failure on
      # the record as a fact about GitHub state. Both exit 2 — what this block exists
      # for is to stop reporting an outcome that was never confirmed.
      if [ "$PROBED" = "0" ]; then
        {
          printf 'pr-merge: %s#%s: the merge call exited 0 but the outcome could not be\n' "$REPO" "$PR"
          printf '  read back — %d GraphQL attempt(s) failed. Whether it merged or was\n' "$ATTEMPT"
          printf '  enqueued is unknown; establish that before reporting anything:\n'
          printf '    gh pr view %s --repo %s --json state,mergedAt\n' "$PR" "$REPO"
        } >&2
        exit 2
      fi
      
      if [ "$QUEUE" = "true" ]; then
        OBSERVED="not in the merge queue (state=$STATE, isInMergeQueue=$INQ)"
      else
        OBSERVED="not merged (state=$STATE)"
      fi
      {
        printf 'pr-merge: %s#%s failed: gh pr merge %s exited 0 but the PR is %s\n' \
          "$REPO" "$PR" "$METHOD" "$OBSERVED"
        if [ "$AUTOBY" != "-" ]; then
          printf '  An auto-merge request enabled by %s is attached to this PR. A pending\n' "$AUTOBY"
          printf '  auto-merge request swallows the enqueue: the call succeeds, the queue\n'
          printf '  stays empty, and neither gh pr checks nor mergeStateStatus shows it.\n'
          printf '  Clear it, then run this script again:\n'
          printf '    gh pr merge %s --repo %s --disable-auto\n' "$PR" "$REPO"
        else
          printf '  No auto-merge request is attached, so the usual cause (a pending one\n'
          printf '  swallowing the enqueue) is ruled out here. Re-read the gate before\n'
          printf '  retrying:\n'
          printf '    pr-status.sh -R %s %s\n' "$REPO" "$PR"
        fi
      } >&2
      exit 2
      
    • pr-status.sh 94.3 KB
      #!/usr/bin/env bash
      # pr-status.sh — one-shot, complete merge-readiness picture for a pull request,
      # plus the next valid action.
      #
      # Why this exists: `mergeStateStatus: BLOCKED` never says *why*. Discovering the
      # reason one API call at a time (checks, then rulesets, then threads, then the
      # allowed merge methods, then the queue) burns round-trips and still misses
      # gates — in one 40-PR rollout, 183 of 370 shell calls were PR-status probing,
      # the rulesets endpoint was queried exactly once, and `copilot_code_review`
      # blocked four merges by surprise.
      #
      # Two API calls on the green path: one GraphQL document for the PR, its
      # checks, reviews, threads and the repository's merge configuration; one REST
      # call for the effective branch rules (which is the only place rulesets show
      # up). A review-blocked PR costs a third, admin-only call for classic branch
      # protection, whose review gates (require_last_push_approval, approval count,
      # code-owner reviews) no other endpoint exposes.
      #
      # Usage:
      #   pr-status.sh                      # PR for the current branch
      #   pr-status.sh 123                  # PR number in the current repo
      #   pr-status.sh -R owner/repo 123
      #   pr-status.sh --json               # machine-readable, no prose
      #   pr-status.sh --watch              # return on the FIRST actionable event
      #   pr-status.sh --watch --ignore-action request-review
      #                                     # hold through a standing action you have
      #                                     # decided not to take (repeatable)
      #
      # --watch deliberately does not wait for every check to finish. It returns as
      # soon as something can be worked on: a failing check, a new annotation, or all
      # required checks concluded. Waiting for `pending == 0` means learning nothing
      # until the slowest matrix job ends, long after the first failure was visible.
      #
      # --ignore-action exists for the standing state the first-event rule cannot
      # serve (#165): when `.next.action` is already actionable at invocation and
      # retrying cannot clear it — a request-review made unsatisfiable by an
      # exhausted Copilot quota — every re-arm of --watch returns the same line
      # within a second, and the watch cannot be used to wait for anything else.
      # Naming that action holds the watch through it: the watch still returns on
      # every OTHER actionable event, and once the checks settle while NEXT still
      # names the ignored action it returns `SETTLED: NEXT is still the ignored
      # action` (exit 0) instead of idling into the timeout. A new check failure
      # still returns, except when the ignored action IS the CI action
      # (fix-ci/triage-ci).
      #
      # Copilot review quota
      #
      # The quota is per ACCOUNT and per MONTH, but the only evidence a single pull
      # request carries is an errored review body on its own head — a PR nobody ever
      # requested a review on carries none at all, and used to be answered with a
      # re-request command the exhausted quota rejects. So the wall proven on one PR
      # is written to ${XDG_CACHE_HOME:-~/.cache}/pr-status/, one file per calendar
      # month, and every later invocation reads it before offering that command.
      # The month lives in the FILENAME, so a marker from an earlier month is ignored
      # rather than carried over the reset; delete the file to undo the effect.
      #
      # When the wall stands, the documented fallback (review the diff yourself,
      # note it in the PR, decide) can be put on the record so the merge gate reads
      # it back: a PR comment BY THE PR AUTHOR containing the line
      # `Self-review: <head-sha>` (at least the first 12 chars). The ladder honours it
      # ONLY while the demanded bot review is unsatisfiable — quota wall, or two
      # failed reviews on this head — and the attestation dies with the next push,
      # because the sha stops matching. `pr-merge.sh --self-reviewed` posts the
      # comment and merges in one step. This is an explicit operator assertion the
      # tool reads back, not a state it claims to observe (#203).
      #
      # --json contract
      #
      # One object, and its field names are THIS SCRIPT'S — not the GraphQL names
      # `gh pr view` uses. `mergeState`, not `mergeStateStatus`. `base`, not
      # `baseRefName`. Guessing costs more than reading: jq answers a missing key
      # with `null` and says nothing, so a loop waiting for
      # `.mergeStateStatus == "CLEAN"` never fires and reads as "still running"
      # forever. Top-level keys:
      #
      #   state mergeable mergeState draft number title repo author author_is_bot
      #   viewer viewer_is_author attestation_available
      #   base head headOid cross_repository
      #   checks checks_settled threads unresolved_threads
      #   unanswered_comments unanswered_human unanswered_by unanswered_urls
      #   reviewDecision reviews_on_head has_review_on_head coderabbit_on_head
      #   has_copilot_review_on_head copilot_latest_on_head_ok copilot_review_errored
      #   copilot_error_count copilot_quota_hit copilot_quota_exhausted
      #   self_review_on_head self_review_url
      #   requested_reviewers
      #   merge_methods auto_merge_allowed queue_active queue_entry
      #   rulesets rules_fetched required_contexts undispatched unsigned
      #   classic_protection awaiting_approval
      #   next
      #
      # `checks` is {total,pass,fail,pending,skip,...} and `next` is
      # {action,why,cmd} — the same two things the prose rendering leads with.
      #
      # Before writing a jq filter against any of these, consider whether --watch
      # already answers the question; it usually does, and a hand-rolled poll loop is
      # how the wrong field name gets guessed in the first place.
      #
      # tests/test_pr_status_json_contract.sh fails when this list and the emitted
      # object drift apart, in either direction.
      set -uo pipefail
      
      # --version answers "which copy am I running" without diffing installations.
      # Two installations can declare the SAME number while shipping different
      # scripts (netresearch/git-workflow-skill#209 measured exactly that, and the
      # missing flag read as a missing feature for a dozen merges). So the resolved
      # path is printed beside the version: the number says what the copy claims to
      # be, the path says which file actually answered.
      #
      # The version is read from the SKILL.md NEXT TO the script, never from a
      # checkout elsewhere -- a cached copy must report the number it was packaged
      # with, or the answer is worse than none. \042 and \047 are the quote
      # characters by octal code, so this awk program contains no quote of its own
      # to terminate the single-quoted string it lives in.
      skill_version() {
          local here skill v
          here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
          skill="$here/../SKILL.md"
          v="unknown"
          if [ -f "$skill" ]; then
              v="$(awk '/^[ \t]*version:/ {
                       s = $0
                       sub(/^[ \t]*version:[ \t]*/, "", s)
                       gsub(/[\042\047]/, "", s)
                       gsub(/[ \t\r]+$/, "", s)
                       if (s != "") { print s; exit }
                   }' "$skill" 2>/dev/null)" || v=""
              [ -n "$v" ] || v="unknown"
          fi
          printf '%s %s\n' "$(basename "${BASH_SOURCE[0]}")" "$v"
          printf 'path: %s/%s\n' "$here" "$(basename "${BASH_SOURCE[0]}")"
      }
      
      REPO=""; PR=""; JSON=0; WATCH=0; INTERVAL=20; MAXWAIT=3600; IGNORE=""
      
      # Every action the watch loop returns on. --ignore-action accepts exactly
      # these: any other value could never have fired the loop, so accepting one
      # would let a typo behave as if the flag were absent. The waiting actions
      # (wait, await-*, rules-unavailable) heartbeat instead of returning, so they
      # are deliberately not in here. tests/test_pr_status_draft_watch.sh pins the
      # two lists against every action literal this script can emit — a new action
      # must land in one of them.
      ACTIONABLE="fix-ci triage-ci resolve-threads address-comments request-review rebase resolve-conflicts merge blocked none fix-signatures ready investigate approve-workflow-runs"
      
      die() { printf 'pr-status: %s\n' "$1" >&2; exit 2; }
      
      need() { [ $# -ge 2 ] && [ -n "${2:-}" ] || die "$1 requires a value"; }
      
      in_list() { case " $1 " in *" $2 "*) return 0 ;; *) return 1 ;; esac; }
      is_ignored() { in_list "$IGNORE" "$1"; }
      
      while [ $# -gt 0 ]; do
        case "$1" in
          -R|--repo)  need "$@"; REPO="$2"; shift 2 ;;
          --json)     JSON=1; shift ;;
          --watch)    WATCH=1; shift ;;
          --interval) need "$@"; INTERVAL="$2"; shift 2 ;;
          --max-wait) need "$@"; MAXWAIT="$2"; shift 2 ;;
          --ignore-action) need "$@"
                           # Whitespace first: "fix-ci triage-ci" happens to be a
                           # substring of ACTIONABLE and would slip past in_list.
                           case "$2" in (*[[:space:]]*)
                             die "--ignore-action: one action per flag (got '$2')" ;;
                           esac
                           in_list "$ACTIONABLE" "$2" \
                             || die "--ignore-action: unknown action '$2' (one of: $ACTIONABLE)"
                           IGNORE="$IGNORE $2"; shift 2 ;;
          # Prints the whole leading comment block rather than a fixed line range:
          # a hard-coded range silently truncates --help the moment the header
          # grows, which is how a documented contract stops being visible.
          --version) skill_version; exit 0 ;;
          -h|--help) awk 'NR>1 && /^#/ {sub(/^# ?/,""); print; next} NR>1 {exit}' "$0"; exit 0 ;;
          -*) die "unknown flag: $1" ;;
          *)  PR="$1"; shift ;;
        esac
      done
      
      if [ -n "$IGNORE" ] && [ "$WATCH" = "0" ]; then
        die "--ignore-action is only meaningful with --watch"
      fi
      
      command -v gh  >/dev/null || die "gh not found"
      command -v jq  >/dev/null || die "jq not found"
      
      if [ -z "$REPO" ]; then
        REPO=$(gh repo view --json nameWithOwner --jq .nameWithOwner 2>/dev/null) \
          || die "not in a repo; pass -R owner/repo"
      fi
      if [ -z "$PR" ]; then
        PR=$(gh pr view --repo "$REPO" --json number --jq .number 2>/dev/null) \
          || die "no PR for the current branch; pass a number"
      fi
      OWNER="${REPO%%/*}"; NAME="${REPO##*/}"
      
      # This script speaks GitHub GraphQL and nothing else. A GitLab project reads
      # host/group/project, which parses here without complaint and then dies deep in
      # the query as a bare "GraphQL query failed" — and --watch heartbeats into its
      # timeout on a query that can never succeed. Refuse it up front and name where
      # the answer lives instead (#250).
      case "$REPO" in
        */*/*)
          die "\"$REPO\" is not owner/repo. pr-status.sh is GitHub-only; for a GitLab merge request see references/pull-request-workflow.md § \"GitLab: the same gate with glab\"" ;;
      esac
      case "${OWNER}" in
        *.*)
          die "\"$OWNER\" looks like a host, not a GitHub owner. pr-status.sh is GitHub-only; for a GitLab merge request see references/pull-request-workflow.md § \"GitLab: the same gate with glab\"" ;;
      esac
      
      # --------------------------------------------------------- quota marker -----
      # The Copilot review quota is account-wide and monthly; the evidence for it is
      # not. It arrives as an error body on ONE pull request, and every other PR of
      # that account looks untouched — so the ladder below kept offering a
      # re-request command that the same exhausted quota would reject (observed on
      # netresearch/maint#52 and #53, hours after the wall was proven on another
      # repo). One file per calendar month carries the fact across invocations.
      QUOTA_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/pr-status"
      # The month is in the NAME, not in the mtime: any later write would refresh an
      # mtime and make a marker outlive the reset it describes.
      QUOTA_MARKER="$QUOTA_DIR/copilot-quota-exhausted-$(date -u +%Y-%m)"
      # ... and the marker expires, because the wall does not last the month. Measured
      # on one machine: recorded 2026-08-18, a normal Copilot review delivered
      # 2026-08-29, the wall back ten minutes later. A marker believed until the month
      # rolls over turns one transient error into weeks of suppressed bot review, and
      # nothing re-probes. After the TTL the next run asks again and either re-arms it
      # or leaves it gone (#255). remember_quota_hit() never rewrites an existing
      # marker, so the mtime stays the moment the wall was first proven.
      QUOTA_TTL_HOURS="${PR_STATUS_QUOTA_TTL_HOURS:-6}"
      
      quota_marker_seen() {
        [ -f "$QUOTA_MARKER" ] || { echo false; return; }
        if [ -n "$(find "$QUOTA_MARKER" -mmin "+$((QUOTA_TTL_HOURS * 60))" 2>/dev/null)" ]; then
          rm -f "$QUOTA_MARKER" 2>/dev/null || :
          echo false; return
        fi
        echo true
      }
      
      # A delivered Copilot review is the only positive evidence the wall is gone.
      # Acting on it beats waiting out the TTL: the very run that sees the review
      # would otherwise still route to self-review.
      forget_quota_hit() { rm -f "$QUOTA_MARKER" 2>/dev/null || :; }
      
      # Best-effort by design: the marker only saves a wasted re-request, so a
      # read-only or full cache directory must never turn a status query into a
      # failure. Content is for the human who wonders where the verdict came from.
      remember_quota_hit() {
        [ -f "$QUOTA_MARKER" ] && return 0
        mkdir -p "$QUOTA_DIR" 2>/dev/null || return 0
        printf 'copilot review quota exhausted; proven on %s#%s at %s\n' \
          "$REPO" "$PR" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >"$QUOTA_MARKER" 2>/dev/null || return 0
      }
      
      # ---------------------------------------------------------------- data ------
      # Swallows gh's chatter on success -- a deprecation notice is not this tool's
      # news -- but re-emits it verbatim when the call FAILS, because that body is the
      # only thing separating an expired token from a rate limit from a network drop.
      # Callers point the operator at stderr for the cause; before this, stderr held
      # nothing but `pr-status: GraphQL query failed`.
      collect() {
        local out err rc
        err=$(mktemp)
        out=$(collect_raw 2>"$err"); rc=$?
        [ "$rc" -eq 0 ] || cat "$err" >&2
        rm -f "$err"
        printf '%s' "$out"
        return "$rc"
      }
      
      collect_raw() {
        # shellcheck disable=SC2016  # $owner/$name/$pr are GraphQL variables, not shell
        gh api graphql -f owner="$OWNER" -f name="$NAME" -F pr="$PR" -f query='
        query($owner:String!,$name:String!,$pr:Int!){
          # Who is running this. Needed only for a bot-authored pull request, where
          # the answering party is the reviewer rather than the author (#319). Costs
          # no extra call: it rides along in the query that was going out anyway.
          viewer{ login }
          repository(owner:$owner,name:$name){
            nameWithOwner
            mergeCommitAllowed rebaseMergeAllowed squashMergeAllowed autoMergeAllowed
            pullRequest(number:$pr){
              number title state isDraft mergeable mergeStateStatus reviewDecision
              mergeQueueEntry{ state position estimatedTimeToMerge }
              author{login __typename}
              baseRefName headRefName headRefOid isCrossRepository
              # The last page, not the first: a Self-review attestation (see the
              # header) is posted at the end of a conversation, and an old page
              # would go blind on exactly the PRs long enough to need one.
              comments(last:100){ nodes{ author{login __typename} body url createdAt } }
              reviews(last:50){ nodes{ author{login} state commit{oid} body } }
              reviewRequests(first:20){ nodes{ requestedReviewer{
                ... on User{login} ... on Bot{login} ... on Team{slug} } } }
              reviewThreads(first:100){ nodes{ id isResolved isOutdated
                comments(first:1){ nodes{ databaseId author{login} path } } } }
              # One unsigned commit anywhere on the branch shuts a required_signatures
              # ruleset, and GitHub surfaces that only as mergeStateStatus BLOCKED —
              # no red check, nothing in the rollup. Without this the tool can only
              # say "investigate".
              allCommits: commits(first:100){ nodes{ commit{ oid signature{ isValid } } } }
              commits(last:1){ nodes{ commit{ oid statusCheckRollup{ state
                contexts(first:100){ nodes{
                  __typename
                  ... on CheckRun{ name conclusion status detailsUrl startedAt }
                  ... on StatusContext{ context state targetUrl }
                } } } } } }
            }
          }
        }'
      }
      
      # A branch legitimately has no rules and answers `[]`, so an empty result is NOT
      # an error — but a failed call must never be folded into the same value:
      # "could not fetch" silently becoming "no required checks" would make this tool
      # report a PR as more mergeable than it is, the one direction it must not get
      # wrong. The fetch is inline rather than a function because a `$( )` subshell
      # would discard the status flag.
      
      evaluate() {
        local gql="$1" rules="$2" ok="$3" marker="$4" prot="${5:-null}" runs="${6:-[]}"
        jq -n --argjson g "$gql" --argjson r "$rules" --argjson ok "$ok" \
              --argjson marker "$marker" --arg marker_path "$QUOTA_MARKER" \
              --argjson prot "$prot" --argjson runs "$runs" '
          # Defined once and used by BOTH the error list and the $head_reviews filter.
          # Two hand-kept copies would have to stay byte-identical: loosening one to
          # match a third error body and not the other puts the row back into
          # $head_reviews, which is exactly the merge-on-an-unread-PR bug again.
          def is_errored_copilot_review:
            (.author.login | test("copilot"; "i"))
            and ((.body // "") | test("^Copilot\\b.*unable to review"; "i"));
          ($g.data.repository) as $repo
          | ($repo.pullRequest) as $p
          | ($p.commits.nodes[0].commit.oid) as $head
          | ([$p.commits.nodes[0].commit.statusCheckRollup.contexts.nodes[]?
              | if .__typename == "CheckRun"
                # QUEUED and IN_PROGRESS are kept apart. Collapsing both into
                # "pending" reads as "CI is running" when in truth nothing has
                # started — and that is a different situation with a different
                # answer: a merge queue drops an entry whose required check never
                # starts, so "wait" is the wrong advice.
                # CANCELLED is kept apart from FAIL for the same reason: a run
                # superseded by the next push is cancelled, not failed, and its rows
                # stay on the commit forever. Counting them as failures reports a
                # CLEAN pull request as red and answers fix-ci for a run nobody can
                # fix. Which of the two it is depends on whether the context reported
                # again, so it is decided below and not here.
                then {name, state: (if .status == "QUEUED" then "QUEUED"
                                    elif .status != "COMPLETED" then "PENDING"
                                    elif .conclusion == "SUCCESS" then "PASS"
                                    elif .conclusion == "SKIPPED" or .conclusion == "NEUTRAL" then "SKIP"
                                    elif .conclusion == "CANCELLED" then "CANCEL"
                                    else "FAIL" end), url: .detailsUrl, started: .startedAt}
                else {name: .context, state: (if .state == "SUCCESS" then "PASS"
                                              elif .state == "PENDING" then "PENDING"
                                              else "FAIL" end), url: .targetUrl}
                end]) as $checks
          # One head can carry several check-runs of the SAME name: a close/reopen,
          # a workflow_dispatch or a `gh run rerun` of a superseded run starts a new
          # run whose rows join the old ones in the rollup instead of replacing
          # them. The GitHub merge state reads only the newest row per name; counting
          # the old one answered triage-ci on a CLEAN pull request, and pr-merge.sh
          # refused a merge the gate had already opened (t3x-nr-image-optimize#173:
          # fuzz red at 15:03, green at 18:30, one SHA). Keep the newest row per
          # name — and a row that has not finished outranks every finished one
          # regardless of timestamp: a re-run sits in the queue with startedAt
          # null, and ranking it by "" would hand the name back to the finished
          # row it is about to replace. Status contexts carry no startedAt and are
          # unique per context, so the fallback only ever compares a row with
          # itself.
          | ($checks | group_by(.name)
                     | map(max_by([(if .state == "QUEUED" or .state == "PENDING" then 1 else 0 end),
                                   (.started // "")]))) as $checks
          # Effective required contexts are the UNION of both sources. The rules
          # endpoint misses classic branch protection and classic protection misses
          # rulesets, and a repository can carry either or both. Taking only the
          # rules endpoint reported `required_contexts: []` on a repo whose five
          # required checks all came from classic protection, which then read as
          # "nothing is required" and fell through to `investigate` with everything
          # green — the question the tool exists to answer (#329,
          # netresearch/orocommerce-skill#20).
          #
          # $prot is fetched lazily, only when the PR is BLOCKED or a review is
          # demanded — which is exactly the state an unreported required context
          # produces, so the classic half is present whenever it decides anything.
          | (([$r[]? | select(.type=="required_status_checks")
               | .parameters.required_status_checks[]?.context]
              + (if ($prot | type) == "object"
                 then ($prot.required_status_checks.contexts? // [])
                 else [] end))
             | unique) as $required
          # Required contexts with no check-run at all. A context that never
          # reported is invisible on the PR page — the rollup only lists what ran —
          # so this reads as BLOCKED with everything green.
          | ([$required[] | select(. as $c | ($checks | map(.name) | index($c)) == null)]) as $undispatched
          | ([$r[]? | .type] | unique) as $ruletypes
          | (($ruletypes | index("copilot_code_review")) != null) as $needs_copilot
          | ($p.author.login) as $author
          # A bot author can never post the attestation below: it authenticates as
          # nobody and reviews nothing, so the whole `Self-review:` path is closed on
          # a Renovate or Dependabot pull request and the advice has to name a
          # different one (#280). Same predicate as $unanswered_human further down —
          # __typename is the authority, the login patterns are the fallback for a
          # REST-shaped author and for App-backed User accounts.
          # Every alternative is anchored, and each covers a form measured on a real
          # Renovate pull request: GraphQL answers login `renovate` with __typename
          # Bot, `gh pr view --json author` answers `app/renovate`, and the webhook
          # payload answers `renovate[bot]`. An unanchored `^renovate` would also
          # read the human login `renovate-maintainer` as a bot, which refuses that
          # person --self-reviewed on their own pull request.
          | ((($p.author.__typename // "") == "Bot")
             or ($author | test("\\[bot\\]$|^app/|^(dependabot|renovate)$"; "i"))) as $author_is_bot
          # A bot author is one way the attestation becomes unavailable; finishing
          # a pull request you did not author is the other, and it was the one this advice
          # used to miss. pr-merge.sh --self-reviewed refuses EVERY authenticated user
          # who is not the author, so recommending it to a non-author recommends an
          # action that cannot succeed — measured on netresearch/concourse-ci-skill#57,
          # author aseemann, viewer CybotTM, where the advice was printed on every read
          # and then refused at merge time. An empty viewer (an older gh, a stubbed
          # response) keeps the previous assumption that the operator is the author.
          | (($g.data.viewer.login // "")) as $viewer
          | ($viewer == "" or $viewer == $author) as $viewer_is_author
          | ((($author_is_bot | not) and $viewer_is_author)) as $attestation_available
          # Self-review attestation (#203). An EXPLICIT operator assertion, not an
          # observation: a PR comment BY THE AUTHOR whose body carries a line
          # `Self-review: <sha>` prefix-matching the current head. This is the
          # deliberate difference from the removed "review-yourself" action (see the
          # ladder comment below): the tool still observes nothing about whether a
          # human read the diff — the author asserts it, on the record, and the
          # record dies with the next push because the sha stops matching. Whether
          # the attestation may satisfy the review gate is decided in the ladder,
          # and only where the demanded review is one an exhausted Copilot quota
          # makes unsatisfiable — with a live review path it changes nothing.
          # At least the first 12 chars of the head sha (48 bits): an 8-char prefix
          # is a 32-bit binding that vanity-sha grinders defeat in minutes, which
          # would let anyone with push access keep an attestation alive across an
          # unreviewed push. pr-merge posts the full 40-char oid.
          | ([$p.comments.nodes[]? | select(.author.login == $author)
              | select((.body // "") | test("(^|\\n)Self-review: " + $head[0:12]))]) as $self_review_comments
          | (($self_review_comments | length) > 0) as $self_review_on_head
          # A review by the PR author is not a review. Replying to a thread registers
          # as COMMENTED by the author, which would otherwise satisfy the gate.
          | ([$p.reviews.nodes[]? | select(.commit.oid == $head)
                                 | select(.author.login != $author)]) as $reviews_raw
          # A Copilot review that FAILED still arrives as an ordinary COMMENTED row.
          # Counting it as a review reports NEXT=merge for a PR nothing has read —
          # the exact gate this script exists to close. Two observed bodies:
          #   "Copilot encountered an error and was unable to review this pull request."
          #   "Copilot was unable to review this pull request because the user who
          #    requested the review has reached their quota limit."
          # Both are matched by "starts with Copilot … unable to review", which a real
          # review body does not (in jq, ^ anchors the string, not each line). The same
          # failure is ALSO a failing copilot-pull-request-reviewer check-run — but
          # that one is absent from GraphQL statusCheckRollup (it is only in the REST
          # check-runs API), so $checks cannot see it and using it would cost a third
          # API call.
          | ([$reviews_raw[] | select(is_errored_copilot_review)]) as $copilot_errored
          | (($copilot_errored | length) > 0) as $copilot_review_errored
          # Bound rather than written twice: copilot_quota_hit reports the evidence
          # found HERE, copilot_quota_exhausted folds in the marker, and a second
          # copy of the test would let the two drift into disagreeing about the same
          # review body.
          | (([$copilot_errored[] | select((.body // "") | test("quota"; "i"))] | length) > 0) as $quota_hit
          # Drop the errored rows from $head_reviews itself, not just from the Copilot
          # view: has_review_on_head feeds the generic "no review on the current head"
          # gate, so filtering only the Copilot list would leave every repo WITHOUT
          # the copilot_code_review ruleset still merging on an error row.
          | ([$reviews_raw[] | select(is_errored_copilot_review | not)]) as $head_reviews
          | ([$head_reviews[] | select(.author.login | test("copilot"; "i"))]) as $copilot_on_head
          | ([$p.reviewThreads.nodes[]? | select(.isResolved == false)]) as $unresolved
          # Prose written under the pull request is an ISSUE comment, not a review
          # thread and not a review. It appears in neither reviewThreads nor the
          # check rollup, so a report built from those alone says "0 unresolved"
          # while findings from a maintainer sit unread. Anything posted after the
          # last word of the author is unanswered by construction; if the author
          # never commented, every comment by someone else is.
          #
          # Whose last word, though. Normally the one from the author: you opened the
          # pull request, someone wrote under it, you answer. On a BOT-authored pull
          # request that breaks. The author posts once at creation and never again, so
          # every later comment stays counted for the life of the PR, the approval
          # note from the reviewing human included, and answering adds one more
          # comment that is counted too. The rung then never clears and pr-merge.sh
          # refuses a pull request that is CLEAN, green, thread-free and approved
          # (#319, observed on netresearch/t3x-contexts_geolocation#56). The question
          # the rung asks is "did I leave something unanswered", so on a bot PR the
          # answering party is the viewer. $author_is_bot is the same predicate the
          # attestation path above uses. Falling back to $author keeps the old
          # behaviour when viewer is absent — an older gh, or a stubbed response —
          # rather than making every comment unanswered by comparing against "".
          | (if $author_is_bot and $viewer != "" then $viewer else $author end) as $responder
          | (([$p.comments.nodes[]? | select(.author.login == $responder) | .createdAt] | max) // "") as $responder_last_comment
          | ([$p.comments.nodes[]? | select(.author.login != $responder)
                                  | select(.createdAt > $responder_last_comment)]) as $unanswered_comments
          # Bots are reported but never drive the ladder: a Renovate or Dependabot
          # note must not push its own PR off the auto-merge rung it exists to reach.
          # __typename is the authority, not the login. GraphQL returns Bot for an
          # App and strips the [bot] suffix REST appends, so a login test alone reads
          # "github-actions" and "sonarqubecloud" as people — measured on this very
          # pull request, whose CI comments took it off the merge rung on the first
          # attempt. The login patterns stay as a fallback for callers that supply a
          # REST-shaped author, and for App-backed User accounts.
          # A comment written by the person this report is addressed to is answered by
          # construction, so the viewer is dropped the way bots are (#308). The rung
          # measures against the AUTHOR everywhere the author is a person, and on a
          # fork pull request finished by a maintainer the author often never comments
          # at all: every maintainer comment is then newer than an empty
          # $responder_last_comment, the counter rises with each one written, and
          # answering the bot notices raises it again. Observed on
          # netresearch/usercentrics-widgets#143 and netresearch/retro-skill#106 —
          # CLEAN, green, thread-free, APPROVED, and pr-merge.sh refusing. What this
          # does NOT touch: a comment by somebody else after the last word still
          # raises the rung, which is the case the rung exists for.
          | ([$unanswered_comments[]
              | select(((.author.__typename // "") == "Bot") | not)
              | select((.author.login | test("\\[bot\\]$|^(dependabot|renovate|copilot)"; "i")) | not)
              | select($viewer == "" or .author.login != $viewer)]) as $unanswered_human
          # The CodeRabbit verdict is an ISSUE COMMENT, never a review, so every
          # review-shaped field above is blank on a pull request it cleared minutes
          # ago and the report reads "NONE on current head". It keeps ONE comment and
          # edits it in place, accumulating a block per push, each naming the range it
          # covers — so the marker ABOVE the line naming a head is the verdict for
          # that head, and a head named by no line was never reviewed. Same algorithm as
          # references/pull-request-workflow.md, which until now only a human could
          # run. REPORTED, NOT MERGED ON: has_review_on_head and the merge gate are
          # untouched, because a comment is not a review and widening that test here
          # would let every bot comment clear the gate.
          # "It keeps ONE comment" holds for the SUMMARY only. A reply to
          # `@coderabbitai review` is a second comment by the same author, and it is
          # the later one — so taking the last CodeRabbit comment reads the reply
          # ("Already reviewed the last commit") and reports a reviewed head as never
          # reviewed. Measured against netresearch/matrix-skill#151, where the
          # 512-character reply sat after the 7334-character summary. Pick the comment
          # that names the head instead of the newest one. GraphQL returns the login
          # without the [bot] suffix REST appends, hence the prefix match.
          | ([$p.comments.nodes[]? | select(.author.login | test("^coderabbitai"; "i"))
                                  | .body // ""]) as $cr_bodies
          | (([$cr_bodies[] | select(test($p.headRefOid))] | last // "") | split("\n")) as $cr_lines
          | ([$cr_lines | to_entries[] | select(.value | test($p.headRefOid)) | .key] | first) as $cr_idx
          # The summary also comes in a shape that names the head it assessed as a
          # SHORT sha and carries no range at all ("Merge Risk: … up to `2cf7a`").
          # Resolving it needs `git rev-parse` against a checkout that has fetched the
          # branch, which this script cannot assume, and a prefix compare is exactly
          # the wrong shortcut — five hex digits collide, and a collision reports an
          # older assessment as covering the current head. So the presence of that
          # marker downgrades "none" to "unknown" and the reader is sent to the
          # reference to resolve it. Never to "clean": unknown authorises nothing.
          # Searched across every CodeRabbit comment, not only the one naming the
          # head: when no comment names it, there are no $cr_lines to search.
          | (([$cr_bodies[] | select(test("up to `[0-9a-f]+`"))] | length) > 0) as $cr_short_shape
          | (if ($cr_bodies | length) == 0 then "none"
             elif $cr_idx == null then (if $cr_short_shape then "unknown" else "none" end)
             else (([$cr_lines[0:$cr_idx][]
                     | if test("rate limited by coderabbit") then "rate-limited"
                       elif test("Currently processing new changes") then "in-progress"
                       elif test("No actionable comments") then "clean"
                       elif test("Actionable comments posted") then "findings"
                       else empty end] | last) as $positional
                    # The positional rule stays authoritative: one body carries a
                    # block per push, so "which marker is nearest above this head" is
                    # the only way to keep an older block from answering for a newer
                    # one. Walkthrough is a separate FALLBACK, reached only when no
                    # block marker precedes the head at all — the shape a finished
                    # review WITH findings has: a walkthrough, a risk table, and no
                    # "No actionable comments" line anywhere. Measured on
                    # netresearch/git-workflow-skill#312. A clean summary carries a
                    # walkthrough too, but BELOW the line naming its range, so the two
                    # shapes are distinguishable by position; keeping this out of the
                    # scan is about not mixing a section heading in with the block
                    # markers, not about a collision that exists today.
                    | if $positional != null then $positional
                      elif ([$cr_lines[0:$cr_idx][] | select(test("Walkthrough"))] | length) > 0
                        then "findings"
                      else "unknown" end)
             end) as $cr_verdict
          # A cancelled context that reported again under the same name is STALE:
          # the later row is the answer and the cancelled one is a leftover. One that
          # never reported again is genuinely unmet and still shuts the gate — but it
          # needs a re-run, not a fix, so it is named separately either way.
          | ([$checks[] | select(.state=="PASS" or .state=="SKIP" or .state=="FAIL") | .name]) as $reported
          | ($checks | map(select(.state=="CANCEL" and (.name as $n | $reported | index($n)))))     as $stale
          | ($checks | map(select(.state=="CANCEL" and (.name as $n | $reported | index($n)) == null))) as $cancelled
          | (($checks | map(select(.state=="FAIL"))) + $cancelled) as $failing
          | ($checks | map(select(.state=="QUEUED"))) as $queued
          | ($checks | map(select(.state=="PENDING"))) as $running
          # "pending" downstream keeps meaning "not finished", queued or running.
          | ($queued + $running) as $pending
          | ($failing | map(select(.name as $n | $required | index($n)))) as $failing_required
          | ($pending | map(select(.name as $n | $required | index($n)))) as $pending_required
          | ($queued  | map(select(.name as $n | $required | index($n)))) as $queued_required
          # Right after a push every check is queued and none is running, which is
          # normal for a few seconds and says nothing about runner capacity. Age the
          # signal before acting on it. The 5-minute threshold is this script owning
          # a conservative freshness floor — NOT the merge queue tolerance: that is
          # check_response_timeout_minutes, which the REST docs give no default for
          # and which is configured per ruleset (30 and 60 on the two netresearch
          # repos checked, #233).
          # No apostrophes in here — the whole jq program sits in a single-quoted
          # shell string, and one would end it.
          | (($queued_required | map(.started // empty) | min) // null) as $oldest_q
          | (if $oldest_q == null then 0
             else (((now - ($oldest_q | fromdateiso8601)) / 60) | floor) end) as $queued_minutes
          | {
              repo: $repo.nameWithOwner, number: $p.number, title: $p.title,
              state: $p.state, draft: $p.isDraft,
              mergeable: $p.mergeable, mergeState: $p.mergeStateStatus,
              reviewDecision: ($p.reviewDecision // ""),
              base: $p.baseRefName, head: $p.headRefName, headOid: $head,
              # The head branch of a fork pull request lives in another repository,
              # which decides whether the merge may delete it. (No apostrophes in
              # this block: the jq program is a single-quoted shell string.)
              cross_repository: ($p.isCrossRepository // false),
              checks: {
                # Stale rows are excluded so pass+fail+pending+skip still adds up to
                # total; the stale count is reported on its own line.
                total: (($checks|length) - ($stale|length)),
                pass:  ($checks|map(select(.state=="PASS"))|length),
                fail:  ($failing|length),
                pending:($pending|length),
                queued: ($queued|length),
                running:($running|length),
                skip:  ($checks|map(select(.state=="SKIP"))|length),
                stale: ($stale|length),
                # Flattened: where the name: of a workflow is an unevaluated expression the
                # check-run name arrives with newlines in it and would break the
                # single-line summary into fragments.
                cancelled: ($cancelled|map(.name|gsub("\\s+";" ")|.[0:90])),
                failing: ($failing|map(.name)),
                failing_required: ($failing_required|map(.name)),
                pending_required: ($pending_required|map(.name)),
                queued_required: ($queued_required|map(.name)),
                queued_minutes: $queued_minutes,
                failing_urls: ($failing|map(.url))
              },
              required_contexts: $required,
              rules_fetched: ($ok == 1),
              unsigned: [$p.allCommits.nodes[]?.commit
                         | select((.signature.isValid // false) | not)
                         | .oid[0:8]],
              undispatched: $undispatched,
              # Whether the check set has finished registering AND finished running.
              # The review branches of the NEXT ladder sit above every CI branch, so
              # on a repo with the copilot_code_review ruleset NEXT answers
              # request-review from the second a commit is pushed and never mentions
              # CI at all. A caller that automates enqueueing needs the CI answer on
              # its own, and the checks counts alone do not give it: right after a
              # push "0 pending" is true because one context has registered and the
              # other ninety have not.
              # null, not false, when the rules could not be fetched: $required is
              # empty then, so $undispatched is empty for the wrong reason and a
              # caller gating on this would act on a set it never verified.
              checks_settled: (if $ok == 1
                               then (($pending|length) == 0 and ($undispatched|length) == 0
                                     and ($checks|length) > 0)
                               else null end),
              rulesets: $ruletypes,
              # Review gates from CLASSIC branch protection, which the rules
              # endpoint never shows (require_last_push_approval, approval count,
              # code-owner reviews). Admin-only endpoint, fetched lazily and only
              # when the PR looks review-blocked — null means "not fetched or not
              # visible", never "no classic protection".
              # Workflow runs on this head that a maintainer has not approved yet.
              # GitHub demands that approval on a fork pull request after EVERY new
              # SHA, and an unapproved run is not a check run at all: it contributes
              # nothing to the rollup, so the rung below reported `0 pending` and
              # pointed at the review gate while eight required contexts had not
              # started (#310). Empty on a same-repository PR, where the fetch is
              # not made.
              awaiting_approval: $runs,
              classic_protection: (if $prot == null or ($prot | type) != "object"
                                   then null
                                   else ($prot.required_pull_request_reviews // null
                                         | if . == null then null else {
                                             approvals_required: (.required_approving_review_count // 0),
                                             last_push_approval: (.require_last_push_approval // false),
                                             code_owner_reviews: (.require_code_owner_reviews // false)
                                           } end) end),
              # Every distinct state per author, not just the last one. `add` over
              # map({login: state}) overwrites, so a reviewer who approves and then
              # replies to a thread displayed as COMMENTED and the approval vanished
              # from the only surface that shows it — beside decision=APPROVED, which
              # then reads as an approval on an older commit. Shape is unchanged
              # (login -> string) so consumers indexing by login still work.
              # Dedupe keeping the LAST occurrence, not unique and not keep-first.
              # unique sorts alphabetically, so APPROVED would lead even when a later
              # CHANGES_REQUESTED on the same commit superseded it; keep-first has the
              # same flaw once a state recurs (CHANGES_REQUESTED, APPROVED,
              # CHANGES_REQUESTED would end on the withdrawn APPROVED).
              # What this guarantees is exactly: each distinct state once, ordered by
              # its LAST occurrence. Not "the trailing entry is the current state" —
              # a CHANGES_REQUESTED followed by a thread reply renders
              # CHANGES_REQUESTED+COMMENTED, and the blocking state is the first one.
              # This field is for display; the decision path reads $head_reviews.
              reviews_on_head: ($head_reviews
                                | group_by(.author.login)
                                | map({(.[0].author.login):
                                       (map(.state)
                                        | reduce .[] as $st ([]; (. - [$st]) + [$st])
                                        | join("+"))})
                                | add // {}),
              has_review_on_head: (($head_reviews|length) > 0),
              # clean | findings | in-progress | rate-limited | unknown | none.
              # Display and advice only — nothing downstream gates on it. "unknown"
              # covers the short-sha shape, which only a checkout can resolve, and a
              # block whose marker this script does not know.
              coderabbit_on_head: $cr_verdict,
              has_copilot_review_on_head: (($copilot_on_head|length) > 0),
              # Which of the two came last, not merely which exists. reviews(last:50)
              # is chronological, so the final Copilot row on this head decides: a
              # delivered review after an errored quota row means the wall lifted, and
              # an errored row after a delivered one means it is back. Asking only
              # "is there a review" would clear the marker in the second case too.
              copilot_latest_on_head_ok:
                (([$reviews_raw[] | select(.author.login | test("copilot"; "i"))] | last)
                 | if . == null then false else (is_errored_copilot_review | not) end),
              # True when Copilot answered on this head with an error body rather than
              # a review. Derived from the review body alone — see the comment above
              # $copilot_errored for why the check-run is not consulted.
              copilot_review_errored: $copilot_review_errored,
              copilot_error_count: ($copilot_errored|length),
              # True when an errored Copilot review body ON THIS PR names the quota
              # limit — the evidence, not the verdict.
              copilot_quota_hit: $quota_hit,
              # The verdict, and what the NEXT ladder reads: the quota is MONTHLY and
              # account-wide, so it is equally exhausted on a PR that carries no
              # evidence of its own. True when this PR proves it, or when an earlier
              # invocation this calendar month wrote the marker.
              copilot_quota_exhausted: ($quota_hit or $marker),
              # True when the PR author posted a `Self-review: <head-sha>` comment
              # for the CURRENT head — the explicit attestation, not a review row.
              # Whether it satisfies anything is the ladder decision; this field
              # only reports that the record exists.
              self_review_on_head: $self_review_on_head,
              self_review_url: (if $self_review_on_head
                                then ($self_review_comments | last | .url)
                                else null end),
              author: $author,
              author_is_bot: $author_is_bot,
              viewer: $viewer,
              viewer_is_author: $viewer_is_author,
              attestation_available: $attestation_available,
              requested_reviewers: [$p.reviewRequests.nodes[]?.requestedReviewer|(.login // .slug)],
              unresolved_threads: ($unresolved|length),
              unanswered_comments: ($unanswered_comments|length),
              unanswered_human: ($unanswered_human|length),
              unanswered_by: ([$unanswered_comments[]|.author.login]|unique),
              unanswered_urls: ([$unanswered_comments[]|.url]),
              threads: [$unresolved[]|{threadId: .id,
                                       commentId: .comments.nodes[0].databaseId,
                                       author: .comments.nodes[0].author.login,
                                       path: .comments.nodes[0].path,
                                       outdated: .isOutdated}],
              merge_methods: ([ (if $repo.mergeCommitAllowed then "merge" else empty end),
                                (if $repo.rebaseMergeAllowed then "rebase" else empty end),
                                (if $repo.squashMergeAllowed then "squash" else empty end) ]),
              auto_merge_allowed: $repo.autoMergeAllowed,
              queue_active: (($p.mergeStateStatus == "BLOCKED" or $p.mergeStateStatus == "CLEAN")
                             and ($ruletypes | index("merge_queue")) != null),
              # queue_active describes the REPO — a queue exists and this PR would go
              # through it. queue_entry describes THIS PR: non-null only while it is
              # actually sitting in the queue. Without the second one, an enqueued PR
              # still reads as CLEAN and gets answered "merge", which re-enqueues it.
              queue_entry: (if $p.mergeQueueEntry then
                              {state: $p.mergeQueueEntry.state,
                               position: $p.mergeQueueEntry.position,
                               eta: $p.mergeQueueEntry.estimatedTimeToMerge}
                            else null end)
            }
          # ---- next valid action, highest-priority first -------------------------
          | . as $s
          # Bound once: two branches below suppress the retry command on it (the
          # ruleset one and the generic one). As two hand-kept copies, raising the
          # threshold in one place only would quietly restore the unbounded
          # re-request loop in the other — the same trap `is_errored_copilot_review`
          # is factored out to avoid.
          | ($s.copilot_error_count >= 2) as $copilot_exhausted
          # A review is mandatory; a BOT review is not. The attestation used to count
          # only where a Copilot review was unsatisfiable — a quota wall or two
          # failures on this head — which tied the whole self-review path to a bot
          # that most repositories neither require nor have the quota for, and left
          # a repository without the copilot_code_review ruleset with no way to
          # merge a reviewed pull request at all. What the policy demands is that
          # somebody read the diff, and the author reading it is somebody.
          #
          # The one thing still worth waiting for is a bot review actually in
          # flight: requesting a reviewer commits you to waiting for its answer, so
          # a pending Copilot request keeps the attestation inert. Nothing else
          # does, because nothing else is coming.
          #
          # reviewDecision guards: a human CHANGES_REQUESTED is a live review saying
          # no, and REVIEW_REQUIRED means the HOST demands an approval the
          # attestation could never satisfy — in both states the attestation stays
          # inert so the ladder keeps reporting the honest review state instead of
          # falling through to a merge attempt (CHANGES_REQUESTED with no open
          # thread leaves mergeState CLEAN) or to "investigate".
          | ($s.self_review_on_head
             and (($s.requested_reviewers|map(test("copilot";"i"))|any) | not)
             and ($s.reviewDecision != "CHANGES_REQUESTED")
             and ($s.reviewDecision != "REVIEW_REQUIRED")) as $self_attested
          # Hoisted so BOTH exhausted variants can append it. The two branches below
          # serve disjoint repo populations — with the copilot_code_review ruleset
          # active, has_copilot_review_on_head implies has_review_on_head, so the
          # generic branch is unreachable there — which is why fixing one of them
          # left the other silently unwarned.
          # An APPROVED decision sits on an OLDER commit only when nothing APPROVED
          # the current head. has_review_on_head is the wrong test: it is true for any
          # non-author review including a COMMENTED one, and a thread reply registers
          # as exactly that (see the $reviews_raw comment), so one reply after the
          # last push would drop this warning while the approval is still stale.
          # Read the LIST, not reviews_on_head: that field joins the states per author
          # into one string for display, so testing it would mean substring-matching
          # "APPROVED" out of e.g. "APPROVED+CHANGES_REQUESTED" and calling a
          # superseded approval current. The list carries each review as its own row.
          | ([$head_reviews[] | select(.state == "APPROVED")] | length == 0) as $no_current_approval
          | (if ($s.reviewDecision == "APPROVED") and $no_current_approval
             then "; the existing APPROVED review sits on an older commit and this repo does not dismiss it"
             else "" end) as $stale_approval
          # One source for the phrase; the branches differ only in what follows it.
          # The copilot branch is not gated on has_review_on_head, so it must not
          # assert this when a review does exist on the head.
          # The CodeRabbit clause is appended to the phrase rather than replacing it:
          # a comment does not lift the gate, but omitting it sends the operator to
          # re-derive by hand what this run already read, or to write a self-review
          # note claiming no bot review existed when one did.
          | (if $s.coderabbit_on_head == "clean"
               then " — note CodeRabbit reviewed THIS head and generated no actionable comments;"
                    + " that is a comment, not a review, so the gate stands, but say so rather than"
                    + " claiming no bot review was obtainable"
             elif $s.coderabbit_on_head == "findings"
               then " — note CodeRabbit posted actionable comments on THIS head; read them before anything else"
             elif $s.coderabbit_on_head == "rate-limited"
               then " — CodeRabbit refused THIS head as rate limited; it will not catch up on its own"
             elif $s.coderabbit_on_head == "in-progress"
               then " — CodeRabbit is still reviewing THIS head; wait for it rather than re-triggering,"
                    + " and do not merge over a review in flight"
             elif $s.coderabbit_on_head == "unknown"
               then " — a CodeRabbit summary names the head it assessed as a short sha; resolve it with"
                    + " git rev-parse per references/pull-request-workflow.md before treating this head"
                    + " as reviewed or as unreviewed"
             else "" end) as $cr_note
          | "no review on the current head (\($s.headOid[0:8])) — do not merge unreviewed\($cr_note)" as $no_review
          | (if ($s.has_review_on_head | not) then "\($no_review). " else "" end) as $unreviewed
          # One quota sentence for every branch that would otherwise hand back a
          # re-request command. Where the evidence came from is stated rather than
          # assumed: on a PR that carries no Copilot row at all, saying the error
          # body names the limit would describe a review that is not there — and the
          # operator could not tell a fresh reading from a remembered one, nor find
          # the file to undo it.
          | (if $s.copilot_quota_hit
             then "the error body on this pull request says the requesting user reached the quota limit"
             else "an earlier run recorded the wall in \($marker_path) — delete that file if it was recorded in error"
             end) as $quota_evidence
          | ("Copilot is OUT OF REVIEW QUOTA — \($quota_evidence). The quota is account-wide, so"
             + " re-requesting on another PR will not get around it right now. When it comes back"
             + " is not something this tool can predict: it has been seen to return within the"
             + " same month and go again minutes later, so the record above expires on its own and"
             + " is dropped as soon as a Copilot review is observed. Review the diff yourself, note"
             + " in the PR that the bot review was unavailable, and decide on that."
             # The attestation is an assertion BY THE AUTHOR, so nobody else can make it
             # and pr-merge.sh --self-reviewed refuses. That is true of a bot-authored
             # pull request (#280) and equally of one you did not author, which this used to read
             # as the attestation path. What is left in both cases is the ordinary review
             # a human can give: an APPROVED review on this head satisfies the policy on
             # its own, in this branch and in the generic one below.
             + (if ($s.attestation_available | not)
                then " The pull request is authored by \($author)"
                     + (if $s.author_is_bot then ", a bot that never authenticates and never reads a diff"
                        else ", not by you (\($s.viewer))" end)
                     + ", so the self-review attestation is not available on it: that attestation is an"
                     + " assertion by the author, and pr-merge.sh --self-reviewed refuses every other"
                     + " authenticated user. Review the diff and approve it as yourself instead — an"
                     + " approval on this head satisfies the gate, and pr-merge.sh then merges without"
                     + " any flag: gh pr review \($s.number) --repo \($s.repo) --approve"
                else " To proceed on a documented self-review, post a PR comment (as the PR author)"
                     + " containing the line `Self-review: <head-sha>` with at least the first 12"
                     + " chars of \($s.headOid[0:12]) — pr-merge.sh --self-reviewed posts it and merges in"
                     + " one step; the attestation is honoured only while this wall stands and dies with"
                     + " the next push. The placeholder here is deliberate: this very advice gets pasted"
                     + " into PR comments, and a paste must never mint an attestation, so the accepting"
                     + " sequence never appears in it"
                end)) as $quota_why
          | .next =
              (if $s.state != "OPEN" then
                 {action:"none", why:"PR is \($s.state)"}
               elif ($s.rules_fetched|not) then
                 {action:"rules-unavailable",
                  why:"could not read repos/\($s.repo)/rules/branches/\($s.base) — the required-check list is unknown, so no merge verdict is possible from here"}
               elif $s.mergeable == "CONFLICTING" then
                 {action:"resolve-conflicts", why:"merge conflict with \($s.base)"}
               elif $s.mergeState == "BEHIND" then
                 {action:"rebase", why:"branch is behind \($s.base)",
                  cmd:"git fetch origin \($s.base):refs/remotes/origin/\($s.base) && git rebase origin/\($s.base) && git push --force-with-lease"}
               elif ($s.checks.failing_required|length) > 0 then
                 {action:"fix-ci", why:"required check(s) failing: \($s.checks.failing_required|join(", "))",
                  urls:$s.checks.failing_urls}
               # Only once every required check has concluded. While one is still
               # running, a red non-required check is information: it cannot be what
               # keeps the gate shut yet, and returning an action here ends a --watch
               # that has nothing to act on. The wait branch below names it instead.
               elif ($s.checks.fail > 0 and ($s.checks.pending_required|length) == 0) then
                 {action:"triage-ci", why:"non-required check(s) failing: \($s.checks.failing|join(", ")) — not merge-blocking on their own, but UNSTABLE keeps the gate shut",
                  urls:$s.checks.failing_urls}
               elif $s.unresolved_threads > 0 then
                 {action:"resolve-threads", why:"\($s.unresolved_threads) unresolved review thread(s)",
                  threads:$s.threads}
               # Below resolve-threads, which names a precise line and a thread id,
               # and above draft: a comment left unanswered is real work no matter
               # how the PR is parked. Human comments only — see $unanswered_human.
               elif $s.unanswered_human > 0 then
                 {action:"address-comments",
                  why:"\($s.unanswered_human) comment(s) posted after your last word by \($s.unanswered_by|join(", ")) — issue comments live outside reviewThreads, so \"0 unresolved\" above does not cover them",
                  urls:$s.unanswered_urls}
               # Draft sits BELOW the branches that report real work — a conflict, a
               # stale base, a red check, an open thread all stay worth doing while
               # the PR is deliberately parked as draft (the back-to-draft-on-resume
               # convention) — and ABOVE every review and merge branch, whose advice
               # is meaningless for a draft. While checks still run there is nothing
               # to act on yet: report wait, so --watch holds through the parked
               # state and returns on the first real event instead of answering
               # "ready" on every poll (#228). Only a settled draft is actionable,
               # and that action belongs to the operator: mark it ready. (No single
               # quotes in this block — the jq program lives in a single-quoted
               # shell string, same trap the fix-signatures cmd below notes.)
               elif $s.draft then
                 # checks_settled is deliberately NOT the gate here: it demands at
                 # least one registered context and zero undispatched required ones,
                 # which a draft often cannot satisfy — workflows that skip drafts
                 # or trigger on ready_for_review leave contexts unregistered, and a
                 # fork draft has runs sitting unapproved. Holding the watch on
                 # those waits for an event that only readying can produce. So only
                 # checks actually RUNNING hold the wait; everything else is the
                 # operator call this state exists for: mark it ready.
                 (if ($s.checks.pending > 0) then
                    {action:"wait", why:"draft — \($s.checks.pending) check(s) still running"}
                  else
                    {action:"ready",
                     why:("draft — nothing running, mark ready when the work is done"
                          + (if ($s.undispatched|length) > 0
                             then " (\($s.undispatched|length) required context(s) not reported — dispatch happens on ready, or the runs await approval)"
                             else "" end)),
                     cmd:"gh pr ready \($s.number) --repo \($s.repo)"}
                  end)
               # Sits after the branches that report real work (failing checks,
               # open threads) — those stay worth doing while queued, and a queue
               # entry that fails its own checks is dropped anyway. It sits before
               # every review and merge branch: GitHub already let this PR past the
               # rulesets when it accepted the entry, so "request a review" or
        
    • repo-contribution-preflight.sh 13.2 KB
      #!/usr/bin/env bash
      # repo-contribution-preflight.sh — what this repository expects, before the first artifact.
      #
      # A repository's binding rules are scattered: some in the README, some in
      # contribution docs two links deep, some in issue and PR templates, in
      # .gitattributes export rules, in the CI matrix, and in pinned tool versions.
      # Each is one command; together they are fifteen, which is why they get skipped
      # and then discovered afterwards — with three artifacts already public and a
      # maintainer watching the retrofit.
      #
      # The README is checked, not skipped. A small repository often states the whole
      # contract there — target branch, sign-off, the command CI runs — and never
      # writes a CONTRIBUTING at all. This script therefore reports the README
      # headings that carry rules, fence-aware, so a "# Contributing" line inside a
      # fenced example is not mistaken for a section.
      #
      # This returns all of it in one call. It reads; it changes nothing.
      #
      # Usage: repo-contribution-preflight.sh [--repo <dir>] [--section <name>]
      #
      #   --repo <dir>      repository to inspect (default: current directory)
      #   --section <name>  one of: docs, templates, packaging, ci, tools
      #
      # Exit status is 0 whenever the repository could be read. Absence of a file is a
      # finding, not an error — "no CONTRIBUTING" is the answer to the question asked.
      
      set -uo pipefail
      
      # --version answers "which copy am I running" without diffing installations.
      # Two installations can declare the SAME number while shipping different
      # scripts (netresearch/git-workflow-skill#209 measured exactly that, and the
      # missing flag read as a missing feature for a dozen merges). So the resolved
      # path is printed beside the version: the number says what the copy claims to
      # be, the path says which file actually answered.
      #
      # The version is read from the SKILL.md NEXT TO the script, never from a
      # checkout elsewhere -- a cached copy must report the number it was packaged
      # with, or the answer is worse than none. \042 and \047 are the quote
      # characters by octal code, so this awk program contains no quote of its own
      # to terminate the single-quoted string it lives in.
      skill_version() {
          local here skill v
          here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
          skill="$here/../SKILL.md"
          v="unknown"
          if [ -f "$skill" ]; then
              v="$(awk '/^[ \t]*version:/ {
                       s = $0
                       sub(/^[ \t]*version:[ \t]*/, "", s)
                       gsub(/[\042\047]/, "", s)
                       gsub(/[ \t\r]+$/, "", s)
                       if (s != "") { print s; exit }
                   }' "$skill" 2>/dev/null)" || v=""
              [ -n "$v" ] || v="unknown"
          fi
          printf '%s %s\n' "$(basename "${BASH_SOURCE[0]}")" "$v"
          printf 'path: %s/%s\n' "$here" "$(basename "${BASH_SOURCE[0]}")"
      }
      
      REPO="."
      SECTION="all"
      
      while [ $# -gt 0 ]; do
          case "$1" in
              --repo) REPO="${2:?--repo needs a directory}"; shift 2 ;;
              --section) SECTION="${2:?--section needs a name}"; shift 2 ;;
              --version) skill_version; exit 0 ;;
              -h|--help) awk 'NR>1 && /^#/ {sub(/^# ?/,""); print; next} NR>1 {exit}' "$0"; exit 0 ;;
              *) echo "unknown argument: $1" >&2; exit 2 ;;
          esac
      done
      
      cd "$REPO" || { echo "cannot enter $REPO" >&2; exit 2; }
      git rev-parse --git-dir >/dev/null 2>&1 || { echo "not a git repository: $REPO" >&2; exit 2; }
      
      want() { [ "$SECTION" = "all" ] || [ "$SECTION" = "$1" ]; }
      head_() { printf '\n=== %s ===\n' "$1"; }
      none()  { printf '  (none)\n'; }
      
      # --- Contribution docs, and the pages they link -----------------------------
      if want docs; then
          head_ "Contribution docs"
          found=0
          # -iname rather than a fixed list: GitHub resolves these case-insensitively,
          # so a repository carrying contributing.md is not one carrying nothing. The
          # community-health set is here in full (SUPPORT, GOVERNANCE, SECURITY,
          # CODE_OF_CONDUCT) because each can carry a rule that decides whether a
          # contribution is accepted, and each is served from .github/ or docs/ just
          # as often as from the root.
          while IFS= read -r f; do
              [ -f "$f" ] || continue
              # The name globs are deliberately broad, so the extension decides. Without
              # this, -iname SECURITY* claims .github/workflows/security.yml as a
              # community-health document -- found by running the section against this
              # repository, which has exactly that workflow. An extensionless
              # CONTRIBUTING or SECURITY is a real document and stays.
              case "${f##*/}" in
                  *.md|*.markdown|*.rst|*.txt|*.adoc) ;;
                  *.*) continue ;;
                  *) ;;
              esac
              found=1
              printf '  %-34s %s lines\n' "${f#./}" "$(grep -c "" "$f")"
              # A short CONTRIBUTING is usually a signpost: surface what it points at.
              # shellcheck disable=SC2016  # backticks and $ are regex syntax here, not shell
              links=$(grep -oE '\[[^]]+\]\([^)]+\)|<https?://[^>]+>|`[^`]+\.(md|rst)`' "$f" 2>/dev/null)
              [ -n "$links" ] || continue
              printf '%s\n' "$links" | head -8 | sed 's/^/      -> /'
              n=$(printf '%s\n' "$links" | grep -c "")
              # Never truncate silently: a capped list reads as a complete one.
              [ "$n" -gt 8 ] && printf '      -> (%s more link(s) not shown — read the file)\n' "$((n - 8))"
          done < <(find . -maxdepth 3 \
                        \( -path ./.git -o -path ./node_modules -o -path ./vendor \) -prune -o \
                        -type f \( -iname 'CONTRIBUTING*' -o -iname 'CODE_OF_CONDUCT*' \
                                   -o -iname 'SUPPORT*'    -o -iname 'GOVERNANCE*' \
                                   -o -iname 'SECURITY*'   -o -iname 'AGENTS.md' \
                                   -o -iname 'CLAUDE.md' \) -print 2>/dev/null | sort)
          if [ "$found" = 0 ]; then
              # Deliberately not "(none)". GitHub serves CONTRIBUTING, CODE_OF_CONDUCT,
              # SUPPORT, SECURITY and both template kinds from the OWNER's .github
              # repository whenever the repository itself has none, and those defaults
              # bind exactly like local ones. This script does not go to the network,
              # so it names the query rather than answering it: an unchecked fallback
              # must never be reported as an absence.
              printf '  none IN THIS REPOSITORY — the owner default may still supply them\n'
              owner=$(git remote get-url origin 2>/dev/null \
                      | sed -E 's#^[^:]*://[^/]+/##; s#^[^:]*:##; s#/.*$##')
              if [ -n "$owner" ]; then
                  printf '    -> gh api repos/%s/.github/contents --jq ".[].name"\n' "$owner"
              else
                  printf '    -> gh api repos/<owner>/.github/contents --jq ".[].name"\n'
              fi
          fi
      fi
      
      # --- The README, which is where a small repository states the whole contract -
      if want docs; then
          head_ "README sections that carry rules"
          found=0
          while IFS= read -r f; do
              [ -f "$f" ] || continue
              # Fence-aware on purpose: a heading regex is blind to code blocks, and a
              # README demonstrating `## Contributing` inside a fenced example would
              # otherwise be reported as having that section. Handles ATX (#) and the
              # underline form used by RST and setext Markdown.
              hits=$(awk '
                  function rulebearing(h) {
                      return tolower(h) ~ /contribut|pull request|merge request|code of conduct|commit|sign|coding|style|standard|develop|hacking|test|build|releas|governance|support|securit|licen[cs]e|getting started|setup|workflow|branch|patch/
                  }
                  /^[ \t]*(```|~~~)/ { fence = !fence; prev = ""; next }
                  fence { prev = $0; next }
                  /^[ \t]*#{1,6}[ \t]+/ {
                      h = $0
                      sub(/^[ \t]*#+[ \t]+/, "", h)
                      sub(/[ \t]*#*[ \t]*$/, "", h)
                      if (rulebearing(h)) printf "    %5d  %s\n", NR, h
                      prev = $0; next
                  }
                  /^[ \t]*[=~^*+#"-]{3,}[ \t]*$/ {
                      if (prev ~ /[^ \t]/) {
                          h = prev
                          gsub(/^[ \t]+|[ \t]+$/, "", h)
                          if (rulebearing(h)) printf "    %5d  %s\n", NR - 1, h
                      }
                      prev = $0; next
                  }
                  { prev = $0 }
              ' "$f")
              [ -n "$hits" ] || continue
              found=1
              printf '  %s\n' "${f#./}"
              printf '%s\n' "$hits"
          done < <(find . -maxdepth 2 \
                        \( -path ./.git -o -path ./node_modules -o -path ./vendor \) -prune -o \
                        -type f -iname 'README*' -print 2>/dev/null | sort)
          if [ "$found" = 0 ]; then
              printf '  no rule-bearing headings in the README\n'
              printf '    -> absence of a heading is not absence of a rule: a short README\n'
              printf '       can state the target branch or the sign-off in running prose\n'
          fi
      fi
      
      # --- Issue and PR templates -------------------------------------------------
      if want templates; then
          head_ "Issue / PR templates"
          found=0
          for f in .github/ISSUE_TEMPLATE/config.yml .github/ISSUE_TEMPLATE/config.yaml; do
              [ -f "$f" ] || continue
              found=1
              printf '  %s\n' "$f"
              # blank_issues_enabled: false means a template is mandatory.
              grep -E 'blank_issues_enabled' "$f" | sed 's/^/    /'
          done
          # PULL_REQUEST_TEMPLATE has a DIRECTORY form for multiple templates, and
          # both template kinds are also honoured at the root and under docs/ — a
          # .github-only list reports "no template" for repositories that have one.
          for f in .github/ISSUE_TEMPLATE/*.yml .github/ISSUE_TEMPLATE/*.yaml \
                   .github/ISSUE_TEMPLATE/*.md .github/PULL_REQUEST_TEMPLATE.md \
                   .github/pull_request_template.md .github/PULL_REQUEST_TEMPLATE/* \
                   .github/ISSUE_TEMPLATE.md .github/issue_template.md \
                   ISSUE_TEMPLATE.md PULL_REQUEST_TEMPLATE.md pull_request_template.md \
                   docs/ISSUE_TEMPLATE.md docs/PULL_REQUEST_TEMPLATE.md \
                   docs/pull_request_template.md \
                   .gitlab/issue_templates/* \
                   .gitlab/merge_request_templates/*; do
              [ -f "$f" ] || continue
              case "$f" in */config.y*ml) continue ;; esac
              found=1
              printf '  %s\n' "$f"
          done
          [ "$found" = 1 ] || none
      fi
      
      # --- What a published package actually ships --------------------------------
      if want packaging; then
          head_ "Packaging (what ships, what is a separate package)"
      
          # export-ignore decides whether tests/docs exist in the published artifact.
          found=0
          while IFS= read -r f; do
              [ -f "$f" ] || continue
              ignored=$(grep -E 'export-ignore' "$f" | awk '{print $1}' | tr '\n' ' ')
              [ -n "$ignored" ] || continue
              found=1
              printf '  %-44s export-ignore: %s\n' "$f" "$ignored"
          done < <(printf '%s\n' .gitattributes packages/*/.gitattributes */.gitattributes 2>/dev/null)
          [ "$found" = 1 ] || printf '  no export-ignore rules\n'
      
          # Sub-splitting makes per-package metadata load-bearing rather than cosmetic.
          splits=""
          for f in .github/workflows/*split*; do
              [ -f "$f" ] && splits="$splits $f"
          done
          if [ -n "$splits" ]; then
              printf '  sub-split workflow present:%s\n' "$splits"
              printf '    -> each packages/* is published on its own; its composer.json is the contract\n'
          fi
      
          # An empty production autoload at the root makes root-level checks blind.
          if [ -f composer.json ] && command -v jq >/dev/null 2>&1; then
              root_autoload=$(jq -r 'if (.autoload | type) == "object" then "present" else "ABSENT" end' composer.json)
              printf '  root composer.json production autoload: %s\n' "$root_autoload"
              [ "$root_autoload" = "ABSENT" ] && \
                  printf '    -> checks run at the root scan nothing; run them per package\n'
          fi
      fi
      
      # --- The matrix that actually runs ------------------------------------------
      if want ci; then
          head_ "CI"
          found=0
          for f in .github/workflows/*.y*ml .gitlab-ci.yml Jenkinsfile .concourse/*.y*ml; do
              [ -f "$f" ] || continue
              found=1
              printf '  %s\n' "$f"
              if command -v yq >/dev/null 2>&1; then
                  case "$f" in
                    *.yml|*.yaml)
                      # `//` not an if-expression: the go-yq lexer rejects inline `if` here.
                      yq -r '(.jobs // {}) | to_entries[] | "    " + .key + " -> " + (.value.uses // "inline")' \
                          "$f" 2>/dev/null | head -12
                      ;;
                  esac
              fi
          done
          [ "$found" = 1 ] || none
          printf '\n  The matrix a reusable workflow expands to is only visible on a real run:\n'
          printf '    gh api "repos/<owner>/<repo>/commits/<sha>/check-runs?per_page=100" --jq ".check_runs[].name" | sort -u\n'
      fi
      
      # --- Pinned tool versions ---------------------------------------------------
      if want tools; then
          head_ "Pinned tools"
          found=0
          if [ -f .phive/phars.xml ]; then
              found=1
              printf '  .phive/phars.xml\n'
              grep -oE 'name="[^"]+" version="[^"]+"( installed="[^"]+")?' .phive/phars.xml \
                  | sed 's/^/    /'
              printf '    -> a pinned analyser can be older than the code it parses; an abort\n'
              printf '       can print nothing and read exactly like a clean run\n'
          fi
          for f in .tool-versions .php-version .nvmrc rust-toolchain.toml; do
              [ -f "$f" ] || continue
              found=1
              printf '  %-24s %s\n' "$f" "$(tr '\n' ' ' < "$f")"
          done
          [ "$found" = 1 ] || none
      fi
      
      printf '\n'
      exit 0
      
    • signing-preflight.sh 8.3 KB
      #!/usr/bin/env bash
      # signing-preflight.sh — does `git commit -S` actually produce a signature here?
      #
      # Answers the one question a commit-heavy run needs answered up front, and
      # answers it from the commit object rather than from local verification:
      #
      #   git log --show-signature / %G?  →  "can THIS MACHINE verify the signature"
      #   gpgsig header in the object     →  "did git WRITE a signature"
      #
      # Those differ. With gpg.format=ssh and no gpg.ssh.allowedSignersFile, git
      # 2.54.0 reports `%G? = N` and `No signature` for a perfectly signed commit —
      # indistinguishable from unsigned, and it exits 0 while printing the error, so
      # a driver reading $? sees success. Whether the *host* accepts the key is a
      # third question again, answered only by the GitHub commits API.
      #
      # The probe never touches the working tree, the index or the current branch:
      # it commits on a throwaway branch through a temporary index and removes both.
      #
      # Usage: signing-preflight.sh [--config-only] [--check-commit <rev>]
      #                             [--repo <dir>] [--quiet]
      #
      #   (default)         probe: prove that `git commit -S` signs, here, now
      #   --config-only     report the signing config without creating a commit
      #   --check-commit    report whether an existing commit carries a signature
      #
      # Exit codes:
      #   0  READY / signed
      #   1  NOT READY / unsigned — no signature, or the commit aborted with hooks
      #                     already ruled out by the --no-verify retry
      #   2  INCONCLUSIVE — --config-only cannot answer from the config alone
      #   3  USAGE        — not a git repository / bad arguments
      
      set -uo pipefail
      
      # --version answers "which copy am I running" without diffing installations.
      # Two installations can declare the SAME number while shipping different
      # scripts (netresearch/git-workflow-skill#209 measured exactly that, and the
      # missing flag read as a missing feature for a dozen merges). So the resolved
      # path is printed beside the version: the number says what the copy claims to
      # be, the path says which file actually answered.
      #
      # The version is read from the SKILL.md NEXT TO the script, never from a
      # checkout elsewhere -- a cached copy must report the number it was packaged
      # with, or the answer is worse than none. \042 and \047 are the quote
      # characters by octal code, so this awk program contains no quote of its own
      # to terminate the single-quoted string it lives in.
      skill_version() {
          local here skill v
          here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
          skill="$here/../SKILL.md"
          v="unknown"
          if [ -f "$skill" ]; then
              v="$(awk '/^[ \t]*version:/ {
                       s = $0
                       sub(/^[ \t]*version:[ \t]*/, "", s)
                       gsub(/[\042\047]/, "", s)
                       gsub(/[ \t\r]+$/, "", s)
                       if (s != "") { print s; exit }
                   }' "$skill" 2>/dev/null)" || v=""
              [ -n "$v" ] || v="unknown"
          fi
          printf '%s %s\n' "$(basename "${BASH_SOURCE[0]}")" "$v"
          printf 'path: %s/%s\n' "$here" "$(basename "${BASH_SOURCE[0]}")"
      }
      
      CONFIG_ONLY=0
      QUIET=0
      REPO_DIR="."
      CHECK_REV=""
      
      while [ $# -gt 0 ]; do
          case "$1" in
              --config-only)  CONFIG_ONLY=1 ;;
              --check-commit) shift; CHECK_REV="${1:-}" ;;
              --quiet)        QUIET=1 ;;
              --repo)         shift; REPO_DIR="${1:-}" ;;
              --version) skill_version; exit 0 ;;
              -h|--help)      awk 'NR>1 && /^#/ {sub(/^# ?/,""); print; next} NR>1 {exit}' "$0"; exit 0 ;;
              *)              echo "unknown argument: $1" >&2; exit 3 ;;
          esac
          shift
      done
      
      say() { [ "$QUIET" -eq 1 ] || printf '%s\n' "$*"; }
      
      cd "$REPO_DIR" 2>/dev/null || { echo "no such directory: $REPO_DIR" >&2; exit 3; }
      git rev-parse --git-dir >/dev/null 2>&1 || { echo "not a git repository: $REPO_DIR" >&2; exit 3; }
      
      # --- the check itself -------------------------------------------------------
      # Both backends write the signature as a commit *header*: `gpgsig` (SHA-1
      # repositories) or `gpgsig-sha256` (SHA-256), carrying `-----BEGIN SSH
      # SIGNATURE-----` or `-----BEGIN PGP SIGNATURE-----`. No local config gates it.
      #
      # Cut the object at the first blank line before matching: over the whole
      # object, `^gpgsig` also matches a message *body* line that starts with
      # `gpgsig`, which reports an unsigned commit as signed.
      commit_carries_signature() {
          git cat-file commit "$1" 2>/dev/null | sed -n '/^$/q;p' | grep -qE '^gpgsig(-sha256)? '
      }
      
      if [ -n "$CHECK_REV" ]; then
          git rev-parse --verify --quiet "${CHECK_REV}^{commit}" >/dev/null \
              || { echo "no such commit: $CHECK_REV" >&2; exit 3; }
          if commit_carries_signature "$CHECK_REV"; then
              say "signed: $CHECK_REV carries a signature header"
              exit 0
          fi
          say "unsigned: $CHECK_REV carries no signature header"
          exit 1
      fi
      
      # --- configuration ----------------------------------------------------------
      gpgsign=$(git config --get commit.gpgsign || echo "")
      gpgformat=$(git config --get gpg.format || echo "openpgp")
      signingkey=$(git config --get user.signingkey || echo "")
      
      say "config: commit.gpgsign=${gpgsign:-<unset>} gpg.format=$gpgformat user.signingkey=${signingkey:-<unset>}"
      
      if [ "$gpgformat" = "ssh" ] && command -v ssh-add >/dev/null 2>&1; then
          if ! ssh-add -l >/dev/null 2>&1; then
              # Not fatal: user.signingkey may name a private key file, which signs
              # without an agent. Worth reporting, since a dropped agent key is the
              # most common cause of a mid-run signing failure.
              say "note: ssh-agent holds no identities — signing works only if user.signingkey names a private key file"
          fi
      fi
      
      if [ "$CONFIG_ONLY" -eq 1 ]; then
          if [ -z "$signingkey" ] && [ "$gpgformat" = "ssh" ]; then
              say "INCONCLUSIVE — gpg.format=ssh without user.signingkey; config cannot tell whether signing works"
              exit 2
          fi
          say "config present — run without --config-only to prove signing actually works"
          exit 0
      fi
      
      # --- probe ------------------------------------------------------------------
      PROBE_BRANCH="tmp/sign-probe-$$"
      ORIG_REF=$(git symbolic-ref -q --short HEAD || git rev-parse --verify HEAD 2>/dev/null || echo "")
      TMP_INDEX=$(mktemp)
      STDERR_LOG=$(mktemp)
      
      # shellcheck disable=SC2329  # invoked by the EXIT trap below
      cleanup() {
          # Restore first, then drop the branch: a branch cannot be deleted while
          # it is checked out.
          if [ -n "$ORIG_REF" ]; then
              git checkout -q "$ORIG_REF" -- 2>/dev/null || git checkout -q "$ORIG_REF" 2>/dev/null || true
          fi
          git branch -q -D "$PROBE_BRANCH" 2>/dev/null || true
          rm -f "$TMP_INDEX" "$STDERR_LOG"
      }
      trap cleanup EXIT
      
      # A temporary index seeded from HEAD keeps the real index untouched — without
      # it, `commit --allow-empty` sweeps whatever the user had staged into the
      # throwaway commit and unstages it when the branch is deleted.
      export GIT_INDEX_FILE="$TMP_INDEX"
      if git rev-parse --verify HEAD >/dev/null 2>&1; then
          git read-tree HEAD || { echo "could not seed temporary index" >&2; exit 3; }
      fi
      
      git checkout -q -b "$PROBE_BRANCH" 2>/dev/null || { echo "could not create probe branch" >&2; exit 3; }
      
      # Conventional message: a commit-msg hook rejecting `probe` aborts the commit
      # and is indistinguishable from a signing failure at the exit code.
      probe_commit() { git commit -S --allow-empty "$@" -m "chore: signing probe" >/dev/null 2>"$STDERR_LOG"; }
      
      HOOK_BLOCKED=0
      if ! probe_commit; then
          # Retry with hooks off. If it now commits, the first failure was a hook,
          # not signing — a distinction the plain probe cannot make. --no-verify is
          # confined to this throwaway commit, which is deleted moments later.
          if probe_commit --no-verify; then
              HOOK_BLOCKED=1
              say "note: a hook rejected the probe commit; retried with --no-verify to isolate signing"
          else
              # --no-verify skips pre-commit and commit-msg, so hooks are ruled out
              # by the retry: what remains is signing, or the repository itself.
              say "NOT READY — git commit -S aborted with hooks disabled:"
              say "$(sed 's/^/  /' "$STDERR_LOG")"
              exit 1
          fi
      fi
      
      if commit_carries_signature HEAD; then
          say "SIGNING READY — the probe commit carries a signature header"
          [ "$HOOK_BLOCKED" -eq 1 ] && say "  (hooks reject a plain probe here; real commits must satisfy them)"
          exit 0
      fi
      
      say "NOT READY — the probe commit was created without a signature"
      say "  commit.gpgsign is ${gpgsign:-unset}; pass -S explicitly or set commit.gpgsign=true"
      exit 1
      
    • spec-cleanup-guard.sh 10.9 KB
      #!/usr/bin/env bash
      #
      # spec-cleanup-guard.sh — deterministic, READ-ONLY gate for intermediate
      # planning artifacts (superpowers specs/plans, scratch plans, planning-tool
      # output) that must not land in the base branch.
      #
      # Invariant: this script NEVER deletes, stages, or modifies any file. It detects
      # and reports. Only the interactive Capture step (/pr-finish) removes files.
      #
      # Detection is branch-local and state-based: it flags the PRESENCE of configured
      # intermediate paths in three states — committed/tracked, staged, untracked —
      # independent of any base branch (intermediate paths must never be tracked, so
      # presence => fail). This sidesteps base-branch resolution entirely.
      #
      # Exit codes: 0 clean · 1 intermediate artifacts found · 2 usage/config error.
      #
      # Usage:
      #   spec-cleanup-guard.sh            enforce: list matches, exit 1 if any
      #   spec-cleanup-guard.sh --dry-run  list matches, always exit 0 (manifest only)
      #   spec-cleanup-guard.sh --selftest run internal fixtures, exit 0/1
      #   spec-cleanup-guard.sh --help
      #
      # Config: .spec-cleanup.yml at repo root (optional). Reads intermediate_paths[]
      # and exclude[]. If the file is present but `yq` is unavailable the guard FAILS
      # CLOSED (exit 2) rather than silently under-enforcing. Without a config file the
      # baked-in defaults below apply.
      set -euo pipefail
      
      # --version answers "which copy am I running" without diffing installations.
      # Two installations can declare the SAME number while shipping different
      # scripts (netresearch/git-workflow-skill#209 measured exactly that, and the
      # missing flag read as a missing feature for a dozen merges). So the resolved
      # path is printed beside the version: the number says what the copy claims to
      # be, the path says which file actually answered.
      #
      # The version is read from the SKILL.md NEXT TO the script, never from a
      # checkout elsewhere -- a cached copy must report the number it was packaged
      # with, or the answer is worse than none. \042 and \047 are the quote
      # characters by octal code, so this awk program contains no quote of its own
      # to terminate the single-quoted string it lives in.
      skill_version() {
          local here skill v
          here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
          skill="$here/../SKILL.md"
          v="unknown"
          if [ -f "$skill" ]; then
              v="$(awk '/^[ \t]*version:/ {
                       s = $0
                       sub(/^[ \t]*version:[ \t]*/, "", s)
                       gsub(/[\042\047]/, "", s)
                       gsub(/[ \t\r]+$/, "", s)
                       if (s != "") { print s; exit }
                   }' "$skill" 2>/dev/null)" || v=""
              [ -n "$v" ] || v="unknown"
          fi
          printf '%s %s\n' "$(basename "${BASH_SOURCE[0]}")" "$v"
          printf 'path: %s/%s\n' "$here" "$(basename "${BASH_SOURCE[0]}")"
      }
      
      DEFAULT_PATHS=(
        "docs/superpowers/**"
        "claudedocs/**"
        "docs/working/**"
        "docs/superpowers/**/*.plan.md"
      )
      DEFAULT_EXCLUDES=()
      
      CONFIG_FILE=".spec-cleanup.yml"
      
      die() { printf 'spec-cleanup-guard: %s\n' "$*" >&2; exit 2; }
      
      # Normalize a config glob to a git pathspec.
      #   dir glob  "docs/superpowers/**"          -> "docs/superpowers/"   (recurses)
      #   suffix    "docs/superpowers/**/*.plan.md" -> ":(glob)docs/.../*.plan.md"
      #   plain     "docs/x.md"                     -> "docs/x.md"
      normalize_pathspec() {
        local g="$1" base
        if [[ "$g" == */\*\* ]]; then
          base="${g%/\*\*}"
          if [[ "$base" != *[\*\?\[]* ]]; then printf '%s/' "$base"; return; fi
        fi
        if [[ "$g" != *[\*\?\[]* ]]; then printf '%s' "$g"; return; fi
        printf ':(glob)%s' "$g"
      }
      
      # Normalize an exclude glob to an exclude pathspec.
      normalize_exclude() {
        local g="$1"
        if [[ "$g" == *[\*\?\[]* ]]; then printf ':(exclude,glob)%s' "$g"
        else printf ':(exclude)%s' "$g"; fi
      }
      
      load_config() {
        PATHS=() EXCLUDES=()
        if [[ -f "$CONFIG_FILE" ]]; then
          if [[ "${SPEC_CLEANUP_FAKE_NO_YQ:-0}" == "1" ]] || ! command -v yq >/dev/null 2>&1; then
            die "$CONFIG_FILE present but 'yq' is not installed; refusing to under-enforce (install yq)."
          fi
          local p
          while IFS= read -r p; do [[ -n "$p" ]] && PATHS+=("$p"); done \
            < <(yq '.intermediate_paths[]' "$CONFIG_FILE" 2>/dev/null || true)
          while IFS= read -r p; do [[ -n "$p" && "$p" != "null" ]] && EXCLUDES+=("$p"); done \
            < <(yq '.exclude[]' "$CONFIG_FILE" 2>/dev/null || true)
          [[ ${#PATHS[@]} -gt 0 ]] || die "$CONFIG_FILE has no intermediate_paths."
        else
          PATHS=("${DEFAULT_PATHS[@]}")
          EXCLUDES=("${DEFAULT_EXCLUDES[@]+"${DEFAULT_EXCLUDES[@]}"}")
        fi
        # Reject unanchored leading-glob patterns (e.g. **/*.plan.md, *.md): they match
        # arbitrary project files. Anchor under a directory (docs/superpowers/**/*.plan.md).
        local g
        for g in "${PATHS[@]}"; do
          if [[ "$g" == '*'* ]]; then
            die "unanchored glob '$g' in intermediate_paths — anchor it under a directory (e.g. docs/superpowers/**/*.plan.md) so it cannot match unrelated files."
          fi
        done
      }
      
      build_pathspecs() {
        PATHSPECS=()
        local g
        for g in "${PATHS[@]}"; do PATHSPECS+=("$(normalize_pathspec "$g")"); done
        for g in "${EXCLUDES[@]+"${EXCLUDES[@]}"}"; do PATHSPECS+=("$(normalize_exclude "$g")"); done
      }
      
      # Populate TRACKED / STAGED / UNTRACKED arrays (each file in exactly one bucket).
      detect() {
        mapfile -t STAGED    < <(git -c core.quotePath=false diff --cached --name-only --diff-filter=ACMR -- "${PATHSPECS[@]}" 2>/dev/null | sort -u)
        mapfile -t UNTRACKED < <(git -c core.quotePath=false ls-files --others --exclude-standard -- "${PATHSPECS[@]}" 2>/dev/null | sort -u)
        # Committed-tracked = index entries matching, minus anything already reported as a
        # staged change, so a newly-staged file is not double-listed as "committed".
        # ls-files (not ls-tree) is required here: ls-tree rejects the :(glob)/:(exclude)
        # pathspec magic the guard relies on.
        if ((${#STAGED[@]})); then
          mapfile -t TRACKED < <(git -c core.quotePath=false ls-files -- "${PATHSPECS[@]}" 2>/dev/null | sort -u | grep -vxF "$(printf '%s\n' "${STAGED[@]}")" || true)
        else
          mapfile -t TRACKED < <(git -c core.quotePath=false ls-files -- "${PATHSPECS[@]}" 2>/dev/null | sort -u)
        fi
      }
      
      print_group() {
        local title="$1"; shift
        (( $# == 0 )) && return
        printf '  %s:\n' "$title"
        printf '    %s\n' "$@"
      }
      
      run() {
        local dry="$1"
        git rev-parse --is-inside-work-tree >/dev/null 2>&1 || die "not inside a git work tree."
        # Anchor config lookup + pathspecs at the repo root so the gate cannot silently
        # pass when invoked from a subdirectory (e.g. a hook or CI step with a different CWD).
        cd "$(git rev-parse --show-toplevel)" || die "cannot cd to repo root."
        load_config
        build_pathspecs
        detect
        local n=$(( ${#TRACKED[@]} + ${#STAGED[@]} + ${#UNTRACKED[@]} ))
        if (( n == 0 )); then
          echo "spec-cleanup-guard: clean — no intermediate planning artifacts."
          return 0
        fi
        echo "spec-cleanup-guard: found $n intermediate planning artifact(s) that must not reach the base branch:"
        print_group "tracked (committed)" "${TRACKED[@]+"${TRACKED[@]}"}"
        print_group "staged"              "${STAGED[@]+"${STAGED[@]}"}"
        print_group "untracked"           "${UNTRACKED[@]+"${UNTRACKED[@]}"}"
        echo "Resolve via /pr-finish: convert (propose ADR) · remove · acknowledge."
        echo "Untracked files are listed so you can confirm before any removal."
        [[ "$dry" == "1" ]] && return 0
        return 1
      }
      
      # --------------------------- selftest ---------------------------
      git_q() { git -c commit.gpgsign=false -c user.email=t@example.com -c user.name=test "$@"; }
      st_pass=0 st_fail=0
      st_assert() { # desc expected_rc actual_rc
        if [[ "$2" == "$3" ]]; then st_pass=$((st_pass+1)); printf '  ok   %s (rc=%s)\n' "$1" "$3"
        else st_fail=$((st_fail+1)); printf '  FAIL %s (want rc=%s, got %s)\n' "$1" "$2" "$3"; fi
      }
      st_rc() { local rc=0; "$@" >/dev/null 2>&1 || rc=$?; printf '%s' "$rc"; }
      
      selftest() {
        local self; self="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")"
        local t; t="$(mktemp -d "${TMPDIR:-/tmp}/scg-selftest.XXXXXX")"
        trap 'rm -rf "$t"' RETURN
        ( cd "$t"
          git_q init -q
          mkdir -p docs/superpowers/specs/a/b src docs/working
          echo readme > README.md
          echo real   > src/real.plan.md            # real file, NON-intermediate path
          git_q add README.md src/real.plan.md; git_q commit -qm init
      
          echo "T4 no config — default guard:"
          st_assert "T6 clean branch -> 0" 0 "$(st_rc bash "$self")"
      
          echo c > docs/superpowers/specs/a/b/c.md   # nested, untracked
          st_assert "T2/T3 nested untracked -> 1" 1 "$(st_rc bash "$self")"
          st_assert "T3 nested caught also in --dry-run (rc 0)" 0 "$(st_rc bash "$self" --dry-run)"
          mkdir -p sub/deeper
          st_assert "GC-1 caught when run from subdirectory -> 1" 1 "$(cd sub/deeper && st_rc bash "$self")"
      
          git_q add docs/superpowers/specs/a/b/c.md             # now staged
          st_assert "T_staged staged intermediate -> 1" 1 "$(st_rc bash "$self")"
          # A newly-staged file must appear ONLY under "staged", never "tracked (committed)".
          staged_out="$(bash "$self" 2>/dev/null || true)"
          if printf '%s' "$staged_out" | grep -q 'staged:' && ! printf '%s' "$staged_out" | grep -q 'tracked (committed):'; then
            st_assert "staged file not double-listed as tracked" 0 0
          else
            st_assert "staged file not double-listed as tracked" 0 1
          fi
          git_q commit -qm spec                                  # now tracked
          st_assert "T1 tracked intermediate -> 1" 1 "$(st_rc bash "$self")"
      
          git_q rm -q docs/superpowers/specs/a/b/c.md; git_q commit -qm rm
          st_assert "T6b removed -> clean 0" 0 "$(st_rc bash "$self")"
      
          echo "T_overmatch real src/*.plan.md must NOT be flagged by default:"
          st_assert "default ignores src/real.plan.md -> 0" 0 "$(st_rc bash "$self")"
      
          echo "T5 exclude + T7 yq-fail-closed (config present):"
          if command -v yq >/dev/null 2>&1; then
            printf 'intermediate_paths:\n  - docs/superpowers/**\nexclude:\n  - docs/superpowers/keep/**\n' > .spec-cleanup.yml
            mkdir -p docs/superpowers/keep
            echo k > docs/superpowers/keep/wanted.md     # untracked but excluded
            st_assert "T5 excluded path -> clean 0" 0 "$(st_rc bash "$self")"
            echo f > docs/superpowers/flagme.md
            st_assert "T5b non-excluded under config -> 1" 1 "$(st_rc bash "$self")"
            st_assert "T7 config present + no yq -> 2 (fail closed)" 2 \
              "$(SPEC_CLEANUP_FAKE_NO_YQ=1 st_rc bash "$self")"
            printf 'intermediate_paths:\n  - "**/*.plan.md"\n' > .spec-cleanup.yml
            st_assert "GC-2 unanchored glob in config -> 2 (rejected)" 2 "$(st_rc bash "$self")"
            rm -f .spec-cleanup.yml docs/superpowers/flagme.md
            rm -rf docs/superpowers/keep
          else
            echo "  skip T5/T7 (yq not installed)"
          fi
          echo "selftest: $st_pass passed, $st_fail failed"
          [[ "$st_fail" == "0" ]]   # subshell exit status reflects failures
        )
      }
      
      # ------------------------------ main ----------------------------
      case "${1:-}" in
        --help|-h) awk 'NR==1{next} /^#/{sub(/^# ?/,"");print;next} {exit}' "$0"; exit 0 ;;
        --selftest) selftest ;;
        --dry-run) run 1 ;;
        "") run 0 ;;
        --version) skill_version; exit 0 ;;
        *) die "unknown argument: $1 (try --help)";;
      esac
      
    • verify-git-workflow.sh 11.9 KB
      #!/bin/bash
      # Git Workflow Verification Script
      # Checks repository for git workflow best practices
      
      set -e
      
      # --version answers "which copy am I running" without diffing installations.
      # Two installations can declare the SAME number while shipping different
      # scripts (netresearch/git-workflow-skill#209 measured exactly that, and the
      # missing flag read as a missing feature for a dozen merges). So the resolved
      # path is printed beside the version: the number says what the copy claims to
      # be, the path says which file actually answered.
      #
      # The version is read from the SKILL.md NEXT TO the script, never from a
      # checkout elsewhere -- a cached copy must report the number it was packaged
      # with, or the answer is worse than none. \042 and \047 are the quote
      # characters by octal code, so this awk program contains no quote of its own
      # to terminate the single-quoted string it lives in.
      skill_version() {
          local here skill v
          here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
          skill="$here/../SKILL.md"
          v="unknown"
          if [ -f "$skill" ]; then
              v="$(awk '/^[ \t]*version:/ {
                       s = $0
                       sub(/^[ \t]*version:[ \t]*/, "", s)
                       gsub(/[\042\047]/, "", s)
                       gsub(/[ \t\r]+$/, "", s)
                       if (s != "") { print s; exit }
                   }' "$skill" 2>/dev/null)" || v=""
              [ -n "$v" ] || v="unknown"
          fi
          printf '%s %s\n' "$(basename "${BASH_SOURCE[0]}")" "$v"
          printf 'path: %s/%s\n' "$here" "$(basename "${BASH_SOURCE[0]}")"
      }
      
      [ "${1:-}" = "--version" ] && { skill_version; exit 0; }
      
      REPO_DIR="${1:-.}"
      ERRORS=0
      WARNINGS=0
      
      echo "=== Git Workflow Verification ==="
      echo "Repository: $REPO_DIR"
      echo ""
      
      # Change to repo directory
      cd "$REPO_DIR"
      
      # Check if it's a git repository. Asking git, not testing for a `.git`
      # directory: in a worktree `.git` is a *file* pointing at the real gitdir, so
      # the directory test refused to run in every worktree — including the layout
      # this skill's own references recommend.
      if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
          echo "❌ Not a git repository"
          exit 1
      fi
      
      # Check branch naming
      echo "=== Branch Naming Convention ==="
      BRANCHES=$(git branch -a 2>/dev/null | sed 's/^[* ]*//' | grep -v "HEAD" | sed 's/remotes\/origin\///' | sort -u)
      
      VALID_PATTERN="^(main|master|develop|feature\/|fix\/|bugfix\/|hotfix\/|release\/|chore\/|docs\/|test\/|refactor\/)"
      INVALID_BRANCHES=""
      
      for branch in $BRANCHES; do
          if ! echo "$branch" | grep -qE "$VALID_PATTERN"; then
              INVALID_BRANCHES="$INVALID_BRANCHES $branch"
          fi
      done
      
      if [[ -n "$INVALID_BRANCHES" ]]; then
          echo "⚠️  Non-standard branch names found:"
          echo "  $INVALID_BRANCHES"
          echo "   Expected: main, develop, feature/*, fix/*, release/*, hotfix/*"
          WARNINGS=$((WARNINGS + 1))
      else
          echo "✅ All branch names follow conventions"
      fi
      
      # Check commit message format
      echo ""
      echo "=== Commit Message Format ==="
      RECENT_COMMITS=$(git log --oneline -20 2>/dev/null | head -20)
      
      CONV_PATTERN="^[a-f0-9]+ (feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?(!)?: .+"
      INVALID_COMMITS=0
      VALID_COMMITS=0
      
      while IFS= read -r commit; do
          if echo "$commit" | grep -qE "$CONV_PATTERN"; then
              VALID_COMMITS=$((VALID_COMMITS + 1))
          else
              # Allow merge commits
              if ! echo "$commit" | grep -qE "^[a-f0-9]+ Merge"; then
                  INVALID_COMMITS=$((INVALID_COMMITS + 1))
              fi
          fi
      done <<< "$RECENT_COMMITS"
      
      TOTAL_COMMITS=$((VALID_COMMITS + INVALID_COMMITS))
      if [[ $TOTAL_COMMITS -gt 0 ]]; then
          PERCENT=$((VALID_COMMITS * 100 / TOTAL_COMMITS))
          if [[ $PERCENT -ge 80 ]]; then
              echo "✅ $PERCENT% of commits follow Conventional Commits format"
          elif [[ $PERCENT -ge 50 ]]; then
              echo "⚠️  $PERCENT% of commits follow Conventional Commits format"
              WARNINGS=$((WARNINGS + 1))
          else
              echo "⚠️  Only $PERCENT% of commits follow Conventional Commits format"
              WARNINGS=$((WARNINGS + 1))
          fi
      fi
      
      # Check for .gitignore
      echo ""
      echo "=== .gitignore Check ==="
      if [[ -f ".gitignore" ]]; then
          echo "✅ .gitignore exists"
      
          # Check for common patterns
          COMMON_IGNORES=("node_modules" ".env" "*.log" "dist" "build" ".DS_Store")
          MISSING_IGNORES=""
      
          for pattern in "${COMMON_IGNORES[@]}"; do
              if ! grep -q "$pattern" .gitignore 2>/dev/null; then
                  MISSING_IGNORES="$MISSING_IGNORES $pattern"
              fi
          done
      
          if [[ -n "$MISSING_IGNORES" ]]; then
              echo "   ℹ️  Consider adding:$MISSING_IGNORES"
          fi
      else
          echo "⚠️  No .gitignore file found"
          WARNINGS=$((WARNINGS + 1))
      fi
      
      # Check for hooks
      echo ""
      echo "=== Git Hooks ==="
      if [[ -d ".git/hooks" ]]; then
          ACTIVE_HOOKS=$(find .git/hooks -type f ! -name "*.sample" 2>/dev/null | wc -l)
          if [[ $ACTIVE_HOOKS -gt 0 ]]; then
              echo "✅ Found $ACTIVE_HOOKS active hook(s)"
              find .git/hooks -type f ! -name "*.sample" -exec basename {} \; 2>/dev/null | sed 's/^/   /'
          else
              echo "ℹ️  No active git hooks"
          fi
      fi
      
      # Check for husky
      if [[ -d ".husky" ]]; then
          echo "✅ Husky hooks directory found"
      fi
      
      # Check for commitlint
      if [[ -f "commitlint.config.js" ]] || [[ -f ".commitlintrc" ]] || [[ -f ".commitlintrc.json" ]]; then
          echo "✅ Commitlint configuration found"
      fi
      
      # Check for branch protection (via CODEOWNERS)
      echo ""
      echo "=== Code Ownership ==="
      if [[ -f "CODEOWNERS" ]] || [[ -f ".github/CODEOWNERS" ]] || [[ -f "docs/CODEOWNERS" ]]; then
          echo "✅ CODEOWNERS file found"
      else
          echo "ℹ️  No CODEOWNERS file (optional)"
      fi
      
      # Check for PR template
      echo ""
      echo "=== PR Templates ==="
      if [[ -f ".github/PULL_REQUEST_TEMPLATE.md" ]] || [[ -d ".github/PULL_REQUEST_TEMPLATE" ]]; then
          echo "✅ PR template(s) found"
      else
          echo "ℹ️  No PR template (recommended)"
      fi
      
      # Check for CI/CD configuration
      echo ""
      echo "=== CI/CD Configuration ==="
      CI_FOUND=false
      
      if [[ -d ".github/workflows" ]]; then
          WORKFLOW_COUNT=$(find .github/workflows -name "*.yml" -o -name "*.yaml" 2>/dev/null | wc -l)
          if [[ $WORKFLOW_COUNT -gt 0 ]]; then
              echo "✅ GitHub Actions: $WORKFLOW_COUNT workflow(s)"
              CI_FOUND=true
          fi
      fi
      
      if [[ -f ".gitlab-ci.yml" ]]; then
          echo "✅ GitLab CI configuration found"
          CI_FOUND=true
      fi
      
      if [[ -f "Jenkinsfile" ]]; then
          echo "✅ Jenkinsfile found"
          CI_FOUND=true
      fi
      
      if [[ -f ".circleci/config.yml" ]]; then
          echo "✅ CircleCI configuration found"
          CI_FOUND=true
      fi
      
      if [[ -f "azure-pipelines.yml" ]]; then
          echo "✅ Azure Pipelines configuration found"
          CI_FOUND=true
      fi
      
      if [[ "$CI_FOUND" == "false" ]]; then
          echo "⚠️  No CI/CD configuration found"
          WARNINGS=$((WARNINGS + 1))
      fi
      
      # Check for semantic release
      echo ""
      echo "=== Release Configuration ==="
      if [[ -f ".releaserc" ]] || [[ -f ".releaserc.json" ]] || [[ -f ".releaserc.yml" ]] || [[ -f "release.config.js" ]]; then
          echo "✅ Semantic release configuration found"
      fi
      
      # Check for CHANGELOG
      if [[ -f "CHANGELOG.md" ]] || [[ -f "CHANGELOG" ]]; then
          echo "✅ CHANGELOG found"
      else
          echo "ℹ️  No CHANGELOG (recommended for releases)"
      fi
      
      # Check for versioning
      if [[ -f "package.json" ]]; then
          VERSION=$(grep '"version"' package.json | head -1 | sed 's/.*: *"\([^"]*\)".*/\1/')
          if [[ -n "$VERSION" ]]; then
              echo "✅ Package version: $VERSION"
          fi
      fi
      
      # Unreleased commits since the last tag. This was checkpoint GW-15, which the
      # runner's allowlist rejected outright — a rev-range needs `<tag>..HEAD` and the
      # count needs command substitution, and `..` and `$(` are both refused. It never
      # ran once. Here a full shell is available, so the rule survives intact.
      LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || true)
      if [[ -n "$LAST_TAG" ]]; then
          UNRELEASED=$(git rev-list "${LAST_TAG}..HEAD" --count 2>/dev/null || echo 0)
          if [[ "$UNRELEASED" -le 20 ]]; then
              echo "✅ $UNRELEASED commit(s) since $LAST_TAG"
          else
              echo "⚠️  $UNRELEASED commits since $LAST_TAG — cut a release"
              WARNINGS=$((WARNINGS + 1))
          fi
      else
          echo "ℹ️  No tags yet"
      fi
      
      # Check current branch
      echo ""
      echo "=== Current State ==="
      CURRENT_BRANCH=$(git branch --show-current 2>/dev/null)
      echo "Current branch: $CURRENT_BRANCH"
      
      # Check for uncommitted changes
      if git diff --quiet 2>/dev/null && git diff --cached --quiet 2>/dev/null; then
          echo "✅ Working directory clean"
      else
          CHANGES=$(git status --porcelain 2>/dev/null | wc -l)
          echo "⚠️  $CHANGES uncommitted change(s)"
      fi
      
      # Check if up to date with remote
      if git remote | grep -q "origin" 2>/dev/null; then
          git fetch origin --quiet 2>/dev/null || true
          # --verify --quiet, because plain `git rev-parse origin/<branch>` echoes the
          # ref NAME back on stdout when it does not resolve. The literal string then
          # passed the -n test, the rev-list below failed on it, and `set -e` killed
          # the script three sections early — silently, on every unpushed branch.
          LOCAL=$(git rev-parse --verify --quiet "$CURRENT_BRANCH" || true)
          REMOTE=$(git rev-parse --verify --quiet "origin/$CURRENT_BRANCH" || true)
      
          if [[ -n "$LOCAL" && -n "$REMOTE" ]]; then
              if [[ "$LOCAL" == "$REMOTE" ]]; then
                  echo "✅ Up to date with origin/$CURRENT_BRANCH"
              else
                  BEHIND=$(git rev-list --count "$LOCAL..$REMOTE" 2>/dev/null || echo "?")
                  AHEAD=$(git rev-list --count "$REMOTE..$LOCAL" 2>/dev/null || echo "?")
                  echo "ℹ️  Branch is $AHEAD ahead, $BEHIND behind origin/$CURRENT_BRANCH"
              fi
          elif [[ -z "$REMOTE" ]]; then
              echo "ℹ️  Branch not pushed to origin yet"
          fi
      fi
      
      # Check for merge conflicts markers
      echo ""
      echo "=== Conflict Markers ==="
      # Tracked files of ANY type: markers land in .md/.rst/.yaml just as often as in
      # code, and an extension allow-list silently passes those. Anchored at line
      # start, and WITHOUT a bare "=======" branch — that is an ordinary RST section
      # underline and would report every docs tree as conflicted.
      CONFLICT_FILES=$(git grep -lE '^(<<<<<<< |>>>>>>> )' -- . 2>/dev/null | head -5)
      if [[ -z "$CONFLICT_FILES" ]] && ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
          CONFLICT_FILES=$(grep -rlE '^(<<<<<<< |>>>>>>> )' . 2>/dev/null | grep -v node_modules | grep -v vendor | head -5)
      fi
      if [[ -n "$CONFLICT_FILES" ]]; then
          echo "❌ Conflict markers found in files:"
          while IFS= read -r conflict_file; do echo "   $conflict_file"; done <<< "$CONFLICT_FILES"
          ERRORS=$((ERRORS + 1))
      else
          echo "✅ No conflict markers found"
      fi
      
      echo ""
      echo "=== Commit Signing ==="
      
      # Read the signature from the commit object, not from `git log --show-signature`
      # or `%G?`: those answer "can this machine verify it", and report a correctly
      # signed commit as unsigned whenever gpg.format=ssh is set without
      # gpg.ssh.allowedSignersFile. Same rule as checkpoint GW-17 and
      # signing-preflight.sh. Cut at the first blank line so a `gpgsig` line in the
      # message body cannot pass as a signature.
      UNSIGNED=""
      while read -r sha; do
          [[ -z "$sha" ]] && continue
          if ! git cat-file commit "$sha" 2>/dev/null | sed -n '/^$/q;p' | grep -qE '^gpgsig(-sha256)? '; then
              UNSIGNED="$UNSIGNED $sha"
          fi
      done < <(git log -10 --format=%H 2>/dev/null)
      
      if [[ -z "$UNSIGNED" ]]; then
          echo "✅ Last 10 commits all carry a signature"
      else
          UNSIGNED_COUNT=$(wc -w <<< "$UNSIGNED")
          echo "⚠️  $UNSIGNED_COUNT of the last 10 commits carry no signature:"
          for sha in $UNSIGNED; do echo "   ${sha:0:8}"; done
          echo "   Signature presence only — whether a signature verifies is the host's"
          echo "   answer, not this check's. Probe your own setup with signing-preflight.sh."
          WARNINGS=$((WARNINGS + 1))
      fi
      
      # Summary
      echo ""
      echo "=== Summary ==="
      echo "Errors: $ERRORS"
      echo "Warnings: $WARNINGS"
      
      if [[ $ERRORS -gt 0 ]]; then
          echo "❌ Verification FAILED"
          exit 1
      elif [[ $WARNINGS -gt 3 ]]; then
          echo "⚠️  Verification completed with warnings"
          exit 0
      else
          echo "✅ Verification PASSED"
          exit 0
      fi
      
  • checkpoints.yaml 25.5 KB
    # Checkpoints for git-workflow skill
    # Focuses on git repository hygiene and workflow best practices
    
    version: 1
    skill_id: git-workflow
    
    mechanical:
      # === GITIGNORE ===
      - id: GW-01
        type: file_exists
        target: .gitignore
        severity: error
        desc: ".gitignore must exist to prevent committing unwanted files"
    
      - id: GW-02
        type: contains
        target: .gitignore
        pattern: ".env"
        severity: warning
        desc: ".gitignore should exclude .env files"
    
      - id: GW-03
        type: contains
        target: .gitignore
        pattern: "vendor"
        severity: warning
        desc: ".gitignore should exclude vendor directory"
    
      - id: GW-04
        type: contains
        target: .gitignore
        pattern: "node_modules"
        severity: info
        desc: ".gitignore should exclude node_modules if using npm"
    
      # === PR TEMPLATE ===
      # GitHub resolves a PR template from the repository root, `.github/` or
      # `docs/`, in either spelling and in a single-file or a directory form —
      # and, when the repository provides none, from the OWNER's `.github`
      # repository, which serves one org-wide. Checking only the two repo-local
      # `.github/` file paths reported netresearch/git-workflow-skill as missing a
      # PR template while netresearch/.github/pull_request_template.md was serving
      # it to every repo in the org.
      #
      # A `file_exists` target cannot reach the second source. `org_provides:` can,
      # but takes a single path and returns *fail* — not skip — when gh is absent
      # or unauthenticated (run-checkpoints.sh, check_org_provides), which trades
      # one false failure for another. So the check is inlined as a script.
      #
      # Multi-line on purpose: the runner screens a block-scalar body with
      # is_safe_script_text (run-checkpoints.sh:895-916) and runs it from a temp
      # file. That path drops the whitelist and the `; && || $()` ban, both of
      # which describe the SINGLE-LINE analyzer (is_safe_eval_command). Same
      # vehicle as GH-31 in github-project, spelled `pattern:` rather than
      # `target:` so validate-checkpoints.sh accepts it.
      - id: GW-05
        type: command
        pattern: |
          # Repo-local first — all six single-file locations GitHub honours.
          for f in pull_request_template.md PULL_REQUEST_TEMPLATE.md \
                   .github/pull_request_template.md .github/PULL_REQUEST_TEMPLATE.md \
                   docs/pull_request_template.md docs/PULL_REQUEST_TEMPLATE.md; do
            test -f "$f" && exit 0
          done
          # ...then the DIRECTORY form, which holds several templates the author
          # picks between. This skill's own scripts/verify-git-workflow.sh and
          # scripts/repo-contribution-preflight.sh both accept it; a repo using it
          # has a working template and must not be reported as missing one.
          for d in .github/PULL_REQUEST_TEMPLATE PULL_REQUEST_TEMPLATE docs/PULL_REQUEST_TEMPLATE; do
            for t in "$d"/*; do
              test -f "$t" && exit 0
            done
          done
          # No local template: the org may still provide one. Without gh, without
          # auth, or on a non-github remote that half is unknowable — pass quietly
          # rather than report a template as missing on evidence we do not have.
          command -v gh >/dev/null 2>&1 || exit 0
          gh auth status >/dev/null 2>&1 || exit 0
          R=$(gh repo view --json nameWithOwner --jq .nameWithOwner 2>/dev/null) || exit 0
          test -n "$R" || exit 0
          O=${R%%/*}
          for f in pull_request_template.md PULL_REQUEST_TEMPLATE.md \
                   .github/pull_request_template.md .github/PULL_REQUEST_TEMPLATE.md \
                   docs/pull_request_template.md docs/PULL_REQUEST_TEMPLATE.md \
                   .github/PULL_REQUEST_TEMPLATE PULL_REQUEST_TEMPLATE \
                   docs/PULL_REQUEST_TEMPLATE; do
            gh api "repos/$O/.github/contents/$f" >/dev/null 2>&1 && exit 0
          done
          exit 1
        severity: warning
        desc: >-
          A PR template should exist — in this repository (root, .github/ or
          docs/, single file or PULL_REQUEST_TEMPLATE/ directory) or org-wide in
          OWNER/.github. Passes quietly when gh is unavailable or
          unauthenticated: the org half cannot be read there.
    
      # === CODEOWNERS ===
      # GitHub looks for CODEOWNERS in this order: .github/CODEOWNERS, root,
      # docs/CODEOWNERS. Either of the first two satisfies the requirement;
      # `.github/CODEOWNERS` is the Netresearch standard. The previous GW-07
      # ("root CODEOWNERS as fallback") was redundant info noise — collapsed
      # into GW-06's brace-target.
      - id: GW-06
        type: file_exists
        target: "{.github/CODEOWNERS,CODEOWNERS,docs/CODEOWNERS}"
        severity: warning
        desc: >-
          CODEOWNERS should exist for automatic review assignments.
          Netresearch standard: .github/CODEOWNERS (GitHub's primary lookup
          location). Root and docs/ are fallbacks per GitHub docs.
    
      # === BRANCH PROTECTION INDICATORS ===
      # A branch is protected by classic branch protection, by a RULESET, or by
      # both, and the classic endpoint is blind to rulesets. Reading it alone
      # reports a correctly protected branch as unprotected: on
      # netresearch/t3x-nr-temporal-cache `repos/…/branches/main/protection`
      # carries no `required_status_checks` key at all, while ruleset
      # `t3x-baseline` enforces eight contexts (All security checks, CodeQL, DCO,
      # Opengrep OSS, betterleaks, "ci / All CI checks", scorecard, zizmor). On a
      # repository governed by rulesets ALONE the classic endpoint 404s and the
      # gh_api type turned that into "GitHub API call failed" — a finding about
      # the reader, not the repository.
      #
      # So GW-08/09/10/11 read `repos/OWNER/REPO/rules/branches/BRANCH`, which
      # returns the EFFECTIVE rules, and pass when either source enforces the
      # requirement. GH-31 in github-project is the same dual-source shape.
      #
      # Two further defects go with it: the branch was hardcoded to `main` (404 on
      # a repo whose default is `master`/`develop`), and gh_api's "skip when gh is
      # missing" becomes a quiet pass here — a `command` checkpoint has no skip
      # status, and a pass on unreadable evidence beats a fabricated failure.
      #
      # Multi-line on purpose — see the note on GW-05 for why a block-scalar body
      # may use `$()` and `||` where a one-line pattern may not.
      - id: GW-08
        type: command
        pattern: |
          command -v gh >/dev/null 2>&1 || exit 0
          gh auth status >/dev/null 2>&1 || exit 0
          R=$(gh repo view --json nameWithOwner --jq .nameWithOwner 2>/dev/null) || exit 0
          B=$(gh repo view --json defaultBranchRef --jq .defaultBranchRef.name 2>/dev/null) || exit 0
          # A default branch may contain '/' (release/1.0); unencoded it splits the
          # REST path and both endpoints below answer for something else. Encode
          # into a separate variable: assigning straight back to B would turn a
          # missing jq into an empty B, which the -n guard below then treats as
          # "nothing to check" and passes — a protection gap reported as protected.
          command -v jq >/dev/null 2>&1 || exit 0
          B_ENC=$(printf %s "$B" | jq -sRr @uri 2>/dev/null) || exit 0
          test -n "$B_ENC" || exit 0
          B="$B_ENC"
          test -n "$R" || exit 0
          test -n "$B" || exit 0
          classic=$(gh api "repos/$R/branches/$B/protection" \
            --jq '((.required_status_checks.checks // .required_status_checks.contexts // []) | length) > 0' 2>/dev/null || echo false)
          ruleset=$(gh api "repos/$R/rules/branches/$B" \
            --jq 'any(.[]?; .type == "required_status_checks"
                            and ((.parameters.required_status_checks // []) | length) > 0)' 2>/dev/null || echo false)
          [ "$classic" = "true" ] || [ "$ruleset" = "true" ]
        severity: warning
        desc: >-
          Default branch should require status checks, enforced by classic branch
          protection or by a ruleset. Counts the contexts rather than the presence
          of the block: a protection object listing zero checks enforces nothing.
    
      - id: GW-09
        type: command
        pattern: |
          command -v gh >/dev/null 2>&1 || exit 0
          gh auth status >/dev/null 2>&1 || exit 0
          R=$(gh repo view --json nameWithOwner --jq .nameWithOwner 2>/dev/null) || exit 0
          B=$(gh repo view --json defaultBranchRef --jq .defaultBranchRef.name 2>/dev/null) || exit 0
          # A default branch may contain '/' (release/1.0); unencoded it splits the
          # REST path and both endpoints below answer for something else. Encode
          # into a separate variable: assigning straight back to B would turn a
          # missing jq into an empty B, which the -n guard below then treats as
          # "nothing to check" and passes — a protection gap reported as protected.
          command -v jq >/dev/null 2>&1 || exit 0
          B_ENC=$(printf %s "$B" | jq -sRr @uri 2>/dev/null) || exit 0
          test -n "$B_ENC" || exit 0
          B="$B_ENC"
          test -n "$R" || exit 0
          test -n "$B" || exit 0
          classic=$(gh api "repos/$R/branches/$B/protection" \
            --jq '.required_pull_request_reviews != null' 2>/dev/null || echo false)
          ruleset=$(gh api "repos/$R/rules/branches/$B" \
            --jq 'any(.[]?; .type == "pull_request")' 2>/dev/null || echo false)
          [ "$classic" = "true" ] || [ "$ruleset" = "true" ]
        severity: warning
        desc: >-
          Default branch should require a pull request before merging, enforced by
          classic branch protection or by a ruleset. Asks for the requirement's
          presence only — `required_approving_review_count: 0` still passes, as it
          did before; whether the count is high enough is GH-22's question.
    
      - id: GW-10
        type: command
        pattern: |
          command -v gh >/dev/null 2>&1 || exit 0
          gh auth status >/dev/null 2>&1 || exit 0
          R=$(gh repo view --json nameWithOwner --jq .nameWithOwner 2>/dev/null) || exit 0
          B=$(gh repo view --json defaultBranchRef --jq .defaultBranchRef.name 2>/dev/null) || exit 0
          # A default branch may contain '/' (release/1.0); unencoded it splits the
          # REST path and both endpoints below answer for something else. Encode
          # into a separate variable: assigning straight back to B would turn a
          # missing jq into an empty B, which the -n guard below then treats as
          # "nothing to check" and passes — a protection gap reported as protected.
          command -v jq >/dev/null 2>&1 || exit 0
          B_ENC=$(printf %s "$B" | jq -sRr @uri 2>/dev/null) || exit 0
          test -n "$B_ENC" || exit 0
          B="$B_ENC"
          test -n "$R" || exit 0
          test -n "$B" || exit 0
          # enforce_admins is a CLASSIC-protection field with no field-for-field
          # ruleset counterpart (a ruleset spells the same idea as an empty
          # bypass_actors list). So: no classic protection means not applicable,
          # not a finding — read the bypass_actors of the ruleset by hand.
          gh api "repos/$R/branches/$B/protection" >/dev/null 2>&1 || exit 0
          enforced=$(gh api "repos/$R/branches/$B/protection" \
            --jq '.enforce_admins.enabled // false' 2>/dev/null || echo false)
          [ "$enforced" = "true" ]
        severity: info
        desc: >-
          Classic branch protection may apply to admins too. Org default at
          Netresearch leaves this off so admins keep bypass for emergency
          response; tighten to true only if your policy requires admin
          enforcement. Says nothing about a ruleset-governed branch — read
          `gh api repos/OWNER/REPO/rulesets/ID --jq .bypass_actors` for that.
    
      # === SIGNED COMMITS REQUIREMENT ===
      - id: GW-11
        type: command
        pattern: |
          command -v gh >/dev/null 2>&1 || exit 0
          gh auth status >/dev/null 2>&1 || exit 0
          R=$(gh repo view --json nameWithOwner --jq .nameWithOwner 2>/dev/null) || exit 0
          B=$(gh repo view --json defaultBranchRef --jq .defaultBranchRef.name 2>/dev/null) || exit 0
          # A default branch may contain '/' (release/1.0); unencoded it splits the
          # REST path and both endpoints below answer for something else. Encode
          # into a separate variable: assigning straight back to B would turn a
          # missing jq into an empty B, which the -n guard below then treats as
          # "nothing to check" and passes — a protection gap reported as protected.
          command -v jq >/dev/null 2>&1 || exit 0
          B_ENC=$(printf %s "$B" | jq -sRr @uri 2>/dev/null) || exit 0
          test -n "$B_ENC" || exit 0
          B="$B_ENC"
          test -n "$R" || exit 0
          test -n "$B" || exit 0
          classic=$(gh api "repos/$R/branches/$B/protection" \
            --jq '.required_signatures.enabled // false' 2>/dev/null || echo false)
          ruleset=$(gh api "repos/$R/rules/branches/$B" \
            --jq 'any(.[]?; .type == "required_signatures")' 2>/dev/null || echo false)
          [ "$classic" = "true" ] || [ "$ruleset" = "true" ]
        severity: info
        desc: >-
          Default branch should require signed commits, enforced by classic branch
          protection or by a ruleset (`required_signatures` in both spellings).
    
      # === EDITORCONFIG ===
      - id: GW-12
        type: file_exists
        target: .editorconfig
        severity: info
        desc: ".editorconfig should exist for consistent formatting across editors"
    
      # === CONTRIBUTING GUIDE ===
      - id: GW-13
        type: file_exists
        target: CONTRIBUTING.md
        severity: info
        desc: "CONTRIBUTING.md should exist with workflow guidelines"
    
      # === GIT HOOKS ===
      # Accept all common git-hook framework locations including the Netresearch
      # standard `Build/captainhook.json` (configured via composer.json
      # extra.captainhook.config — the official captainhook/hook-installer
      # plugin pattern). Same brace target as TT-71 in typo3-testing-skill.
      - id: GW-14
        type: file_exists
        target: "{Build/captainhook.json,captainhook.json,.captainhook/captainhook.json,lefthook.yml,.lefthook.yml,.husky,.pre-commit-config.yaml}"
        severity: warning
        desc: >-
          A git hook framework config should exist. Netresearch standard for
          TYPO3 extensions: Build/captainhook.json (declared in composer.json
          extra.captainhook.config). Also accepts root captainhook.json,
          lefthook, husky, pre-commit.
    
      - id: GW-14a
        type: contains
        target: composer.json
        pattern: "captainhook/hook-installer"
        severity: info
        desc: >-
          If captainhook/hook-installer is in composer.json, document the
          git-worktree workaround (composer install --no-plugins) in README —
          see references/git-hooks-setup.md 'CaptainHook + git worktrees'
    
      # === UNRELEASED COMMITS ===
      # GW-15 was removed rather than fixed: counting commits since the last tag
      # needs a rev-range (`<tag>..HEAD`) and command substitution, and the runner's
      # SINGLE-LINE allowlist rejects both — `..` as path traversal, `$(` as command
      # chaining. It never ran. (A block-scalar body is screened by
      # is_safe_script_text instead, which permits both — GW-08 uses that vehicle —
      # so restoring GW-15 is now possible; it has not been done.) The rule itself
      # is intact and lives where a full shell is available, in
      # scripts/verify-git-workflow.sh ("Unreleased Commits").
    
      # === INTERMEDIATE PLANNING ARTIFACTS ===
      # `! … | grep -q .` rather than `test -z "$(…)"`: same verdict, no command
      # substitution. The runner strips a leading `!` before checking the base
      # command, so the negated pipeline is allowlist-conform.
      - id: GW-16
        type: command
        pattern: '! git ls-files -- docs/superpowers/ claudedocs/ docs/working/ | grep -q .'
        severity: warning
        desc: >-
          Intermediate planning artifacts (superpowers specs/plans, claudedocs,
          working notes) should not be tracked on the base branch — convert durable
          decisions to an ADR and remove the raw files. See
          references/spec-cleanup.md and spec-cleanup-guard.sh.
    
      # Signature *presence*, read from the commit object. Deliberately not `%G?`
      # or `--show-signature`: under gpg.format=ssh with no
      # gpg.ssh.allowedSignersFile both report a correctly signed commit as
      # unsigned, which fails a fully signed repository. Whether a signature
      # verifies is the host's answer (GW-11 / the commits API), not this one's.
      # Kept in step with signing-preflight.sh by tests/test_signing_preflight.sh.
      #
      # HEAD only, single line, and no `$(`/`;`/`&&`: for a one-line pattern the
      # checkpoint runner's allowlist (is_safe_eval_command in
      # automated-assessment's run-checkpoints.sh) rejects command-chaining
      # metacharacters outright. A loop over the last five commits does not fit
      # that, so this mirrors the rule rather than reproducing it — the same
      # arrangement as SR-37 in skill-repo. The full sweep lives in
      # verify-git-workflow.sh (last 10) and signing-preflight.sh.
      # `sed -n -e '/^$/q' -e p` is the semicolon-free spelling of the header cut.
      - id: GW-17
        type: command
        pattern: "git cat-file commit HEAD | sed -n -e '/^$/q' -e p | grep -qE '^gpgsig(-sha256)? '"
        severity: info
        desc: "HEAD should carry a GPG/SSH signature"
    
    llm_reviews:
      # === CONVENTIONAL COMMITS ===
      - id: GW-20
        domain: git-workflow
        prompt: |
          Analyze the recent commit history (last 20 commits) for conventional commit format usage.
    
          Conventional commits follow the pattern: <type>(<scope>): <description>
          Types include: feat, fix, docs, style, refactor, perf, test, chore, ci, build
    
          Check:
          1. What percentage of commits follow conventional commit format?
          2. Are commit messages descriptive (not just "fix", "update", etc.)?
          3. Are there any empty or single-word commit messages?
    
          Use `git log --oneline -20` to analyze.
    
          Report:
          - Pass if >80% follow conventional format
          - Warn if 50-80% follow format
          - Fail if <50% follow format
        severity: warning
        desc: "Recent commits should follow conventional commit format"
    
      # mechanical-counterpart: GW-17
      - id: GW-21
        domain: git-workflow
        prompt: |
          Check whether the repository's commits carry signatures. This checkpoint
          asks about signature *presence*, not about whether the signatures verify —
          that is the host's answer, not the local keyring's.
    
          Read the commit object — a signed commit carries a `gpgsig` header:
    
          ```
          git log -5 --format=%H | while read -r c; do
            git cat-file commit "$c" | sed -n '/^$/q;p' | grep -qE '^gpgsig(-sha256)? ' \
              && echo "$c signed" || echo "$c UNSIGNED"
          done
          ```
    
          Do NOT use `git log --show-signature` or `%G?` here: under `gpg.format=ssh`
          with no `gpg.ssh.allowedSignersFile` they report `No signature` / `N` on
          correctly signed commits, which fails a fully signed repository.
    
          Report:
          - How many of the last 5 commits are signed?
          - Are all commits from the same author signed?
          - Pass if all commits are signed, warn if some, fail if none
        severity: info
        desc: "Commits should carry a GPG/SSH signature"
    
      - id: GW-22
        domain: git-workflow
        prompt: |
          Analyze commit message quality in the last 10 commits.
    
          Good commit messages:
          - Start with a verb in imperative mood (Add, Fix, Update, etc.)
          - Are 50-72 characters for the subject line
          - Explain "why" not just "what"
          - Reference issues when applicable (Fixes #123, Closes #456)
    
          Use `git log --format="%s" -10` to get subject lines.
    
          Report on overall quality and specific improvements needed.
        severity: info
        desc: "Commit messages should be clear and follow best practices"
    
      - id: GW-23
        domain: git-workflow
        prompt: |
          Review the CODEOWNERS file if it exists.
    
          Check:
          1. Does it cover critical paths (src/, .github/, etc.)?
          2. Are the owners valid GitHub usernames or teams?
          3. Is the syntax correct (path followed by @owner)?
          4. Are there any overly broad patterns like "* @owner"?
    
          Report issues or confirm proper setup.
        severity: warning
        desc: "CODEOWNERS should properly cover critical code paths"
    
      - id: GW-24
        domain: git-workflow
        prompt: |
          Review the PR template if it exists — in any location GW-05 accepts
          (root, .github/ or docs/, either spelling, single file or
          PULL_REQUEST_TEMPLATE/ directory), or org-wide in OWNER/.github.
    
          A good PR template should include:
          - Description/Summary section
          - Type of change (feature, fix, breaking, etc.)
          - Testing checklist
          - Related issues/tickets
          - Review guidelines or checklist
    
          Report on completeness and suggest improvements.
        severity: info
        desc: "PR template should guide contributors effectively"
    
      - id: GW-25
        domain: git-workflow
        prompt: |
          Check if the repository has git hooks configured with a hook framework
          (lefthook, captainhook, husky, or pre-commit).
    
          Look for config files: lefthook.yml, .lefthook.yml, captainhook.json,
          .husky/ directory, .pre-commit-config.yaml.
    
          If hooks are configured, verify they cover at minimum:
          1. pre-commit stage (formatting, linting, or validation)
          2. commit-msg stage (message format validation)
    
          Report:
          - Pass if both pre-commit and commit-msg hooks are configured
          - Warn if only one stage is covered
          - Fail if no hook framework is found
        severity: warning
        desc: "Git hooks should cover pre-commit and commit-msg stages at minimum"
    
      # === RELEASE HYGIENE ===
      - id: GW-26
        domain: release
        severity: warning
        desc: "Feature and fix commits on main should be released promptly"
        prompt: |
          Audit unreleased commits on main:
          1. Run `git describe --tags --abbrev=0 2>/dev/null` to find latest tag.
             If git describe fails (no tags), the check passes — skip remaining steps.
          2. Run `git log <tag>..HEAD --oneline` to see unreleased commits
          3. How many days since the last tag? (use `git log -1 --format=%ci <tag>`)
          4. Do unreleased commits contain feat: or fix: types?
          5. Flag as warning if feat/fix unreleased >7 days, error if >30 days
    
      - id: GW-27
        domain: release
        severity: warning
        desc: "GitHub releases should have non-duplicated content and meaningful descriptions"
        prompt: |
          Audit the 3 most recent GitHub releases:
          1. Run `gh release list --limit 3 --json body` to fetch release bodies in one call
          2. Check for DUPLICATE CONTENT (body repeated twice - common with generate_release_notes + body)
          3. Check QUALITY: narrative summary vs just auto-generated lists
          4. Flag duplicate content as error, missing narrative as info
    
      - id: GW-28
        domain: release
        severity: warning
        desc: "Release workflows should include workflow_dispatch for manual re-triggering"
        prompt: |
          Scan all workflow files in .github/workflows/.
          Identify release workflows by filename (release, publish) or content (gh release create, semantic-release).
          For each, check for workflow_dispatch trigger.
          Flag missing workflow_dispatch as warning.
    
      # === BARE-WORKTREE LAYOUT ===
      - id: GW-29
        domain: git-workflow
        severity: warning
        desc: "Bare-worktree layout: never switch branches in an existing worktree — use `git worktree add`"
        prompt: |
          Detect bare-worktree layout: a `.bare/` directory at the repo root
          AND at least one sibling worktree directory (e.g. `main/`, `feat-*/`).
          Standard discovery: `ls -d .bare/ 2>/dev/null && git -C .bare worktree list`.
    
          If the layout is in use, verify the working pattern:
          1. Recent reflog of any worktree should NOT show in-place branch switch
             events such as `checkout: moving from <a> to <b>` or
             `switch: moving from <a> to <b>` (these are recorded by `git checkout`
             and `git switch`, including the `-b`/`-c` create-and-switch variants).
          2. New branches should appear as new worktree directories, registered via
             `git worktree add <path> -b <branch>`.
    
          Use `git reflog show HEAD -n 50` inside each worktree to inspect entries.
          Note: the reflog records the resulting move, not the CLI flags — so
          `checkout -b feat/x` and `switch feat/x` both surface as
          `checkout:`/`switch: moving from … to …` lines.
    
          Pass when no in-place branch switching is found in the last ~50 reflog
          entries across worktrees. Warn when in-place switch events exist —
          they violate the convention and produce branches that are confusing to
          reason about (working tree vs branch tip diverge for the original
          worktree's intended branch).
    
          Skip cleanly (pass) when the repo is a regular clone (`.git/` is a
          directory, no `.bare/` sibling) — the convention does not apply.
    
      # === STAGING BRANCH COMPOSER.LOCK MERGE STRATEGY ===
      # mechanical-counterpart: none (the commands gather the staging lockfile; the
      # judgement — which merge strategy applies — is the checkpoint)
      - id: GW-30
        domain: git-workflow
        severity: warning
        desc: >-
          When merging into a staging branch that pins dev-branch Composer packages,
          take --ours on composer.lock then re-run `composer update <packages>` to
          apply only the intended upgrades. Taking --theirs overwrites staging's
          dev-* pins and may deploy wrong package versions.
        prompt: |
          Check if the repository has a `staging` branch and a `composer.lock`
          containing `dev-` versioned packages (e.g. `dev-staging`, `dev-main`).
    
          Run:
            git fetch origin staging:refs/remotes/origin/staging --depth=1 2>/dev/null || true
            (git show origin/staging:composer.lock || git show staging:composer.lock) 2>/dev/null | grep '"version": "dev-' | head -5
    
          If dev-versioned packages exist, verify that the project's contributing
          guide or AGENTS.md documents the staging merge strategy:
          - On merge conflict in composer.lock: take `--ours` (staging base) first
          - Then run `composer update <targeted-packages>` to apply only the
            intended version changes
          - Do NOT take `--theirs` — it overwrites all dev-* pins
    
          Report as warning if dev-* packages exist but the strategy is not documented.
    
  • SKILL.md 4.9 KB
    ---
    name: git-workflow
    description: "Use when establishing branching strategies, implementing Conventional Commits, creating or reviewing PRs, resolving PR review comments, merging PRs (including CI verification, auto-merge queues, and post-merge cleanup), managing PR review threads, merging PRs with signed commits, handling merge conflicts, verifying a merge didn't silently drop changes, syncing a long-diverged branch (e.g. master into integration), rebasing a long-lived branch onto a moved base or splitting one into several PRs, integrating Git with CI/CD, setting up git hooks (lefthook, captainhook, husky, pre-commit), debugging hook-install failures in git worktrees, or working in a bare-repo plus per-branch-worktree layout, including consolidating a directory that holds both a plain clone and a .bare. The pull-request tooling is GitHub-only; references/pull-request-workflow.md carries the GitLab equivalents. Not for creating releases (use github-release) or diagnosing BLOCKED/won't-merge PRs (use github-project)."
    license: "(MIT AND CC-BY-SA-4.0). See LICENSE-MIT and LICENSE-CC-BY-SA-4.0"
    compatibility: "Requires git, gh CLI; yq for .spec-cleanup.yml."
    metadata:
      author: Netresearch DTT GmbH
      version: "1.34.1"
      repository: https://github.com/netresearch/git-workflow-skill
    allowed-tools: Bash(git:*) Bash(gh:*) Read Write
    ---
    
    # Git Workflow Skill
    
    ## Not Here
    
    Releases: `github-release`. BLOCKED-PR diagnosis: `github-project`.
    
    **GitHub only.** Everything below the branching and commit sections — `pr-status.sh`, `pr-merge.sh`, the merge gate, review threads — speaks GitHub GraphQL. For a GitLab merge request the equivalent `glab` calls are in `references/pull-request-workflow.md` § *GitLab: the same gate with `glab`*.
    
    ## Critical Rules (Non-Negotiable)
    
    1. **No direct push to main** — always open a PR.
    2. **No merge before all threads resolved** (`references/pull-request-workflow.md`).
    3. **No squash unless asked** — preserves atomic commits, signatures, bisection.
    4. **No "tested/verified/working" without pasted command output** — else say so.
    5. **No edits to installed skill/plugin cache paths** (`~/.claude/skills/`, `~/.claude/plugins/cache/`, `**/.bare/**`) — always the repo worktree, verified by `pwd`.
    6. **Force-push only with `--force-with-lease`** — never plain `--force`.
    7. **Commit before rebase** — `add → commit → fetch → rebase → push` (a dirty tree aborts it).
    8. **No editorializing** — state what changed, not how good it is (`references/no-editorializing.md`).
    
    ## Reference Files
    
    | Reference | Content Triggers |
    |-----------|-----------------|
    | `references/commit-conventions.md` | Conventional commits, DCO sign-off |
    | `references/pull-request-workflow.md` | PR merge gate, signed rebase |
    | `references/ci-cd-integration.md` | CI watching, git mirrors |
    | `references/advanced-git.md` | Rebase, cherry-pick, bisect, stash, worktrees, reflog |
    | `references/github-releases.md` | → `github-release` skill |
    | `references/git-hooks-setup.md` | Hook frameworks, hooks per stage |
    | `references/claude-code-hooks.md` | `settings.json` merge gate, cache-path rejection, auto-lint |
    | `references/code-quality-tools.md` | shellcheck, shfmt, git-absorb, difftastic |
    | `references/merge-gate-watcher.md` | Waiting on a gate, a watcher the harness killed, merge-driver loop, check taxonomy, stale-SHA rerun |
    | `references/spec-cleanup.md` | Planning artifacts off the base branch |
    | `references/no-editorializing.md` | No self-praise, no narrating the expected |
    
    ## Conventional Commits
    
    ```
    <type>[scope]: <description>
    ```
    
    `feat` MINOR, `fix` PATCH; full type list and DCO sign-off in `references/commit-conventions.md`.
    **Breaking**: `!` after type, or `BREAKING CHANGE:` in the footer.
    **Branches**: `feature/TICKET-123-description`, `release/1.2.0`
    
    ## Hook Detection
    
    ```bash
    ls lefthook.yml .lefthook.yml captainhook.json .pre-commit-config.yaml .husky/pre-commit 2>/dev/null || echo "No hooks"
    ```
    
    Install commands per framework: `references/git-hooks-setup.md`.
    
    ## PR Merge Requirements
    
    Before merging: threads resolved, CI green (incl. annotations), rebased, signed, **and reviewed** on the current head — by a human, a bot, or the author through `pr-merge.sh --self-reviewed`. A review is mandatory; a *bot* review is not, and one is waited for only while it is actually in flight. Rebase-only + signed: `git merge --ff-only`.
    
    ## Verification
    
    ```bash
    ./scripts/verify-git-workflow.sh /path/to/repository
    # Gate state, next action:
    ./scripts/pr-status.sh [-R owner/repo] [PR] [--json] [--watch]
    # Merge; refuses when the gate is shut:
    ./scripts/pr-merge.sh [-R owner/repo] [PR] [--dry-run|--self-reviewed]
    # What a repository expects, before the first artifact:
    ./scripts/repo-contribution-preflight.sh [--repo <dir>] [--section docs|templates|packaging|ci|tools]
    # Which copy is installed here (every script answers this):
    ./scripts/pr-status.sh --version
    ```
    
    ---
    
    > **Contributing:** <https://github.com/netresearch/git-workflow-skill>
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related