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,
Install
npx skills add https://github.com/netresearch/git-workflow-skill/tree/main/skills/git-workflow
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install netresearch-git-workflow-skill@llmmart
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)
- No direct push to main — always open a PR.
- No merge before all threads resolved (
references/pull-request-workflow.md). - No squash unless asked — preserves atomic commits, signatures, bisection.
- No "tested/verified/working" without pasted command output — else say so.
- No edits to installed skill/plugin cache paths (
~/.claude/skills/,~/.claude/plugins/cache/,**/.bare/**) — always the repo worktree, verified bypwd. - Force-push only with
--force-with-lease— never plain--force. - Commit before rebase —
add → commit → fetch → rebase → push(a dirty tree aborts it). - 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.
Reviews (0)
No reviews yet.
No comments yet.