Make PRs the convention (stacked via gh stack or Graphite when helpful) - #68
Draft
yulonglin wants to merge 11 commits into
Draft
Make PRs the convention (stacked via gh stack or Graphite when helpful)#68yulonglin wants to merge 11 commits into
yulonglin wants to merge 11 commits into
Conversation
Flip the repo's git convention: reviewable changes go through a PR by default; direct pushes to main are reserved for trivial/mechanical changes. Large changes that review better as a chain use stacked PRs — `gh stack` (the official github/gh-stack extension) or Graphite. Updates CLAUDE.md Top Rules and the "Personal repos: action over ceremony" section of claude/rules/refusal-alternatives.md, which previously told agents not to propose PRs on personal repos. Claude-Session: https://claude.ai/code/session_01Mm1WU9eHyEYQWVUPtq46Um
- approval_classifier_rules.md: direct-to-main pushes on personal repos are auto-allowed only for trivial/mechanical changes; substantive ones classify as unsure so the user confirms. Branch pushes stay allowed. - CLAUDE.md Worktrees: note that cwmerge/cwrm integrate locally without review — reviewable changes push the branch and open a PR instead. - CLAUDE.md Top Rules: gh stack needs a one-time `gh extension install github/gh-stack` (installed gh is 2.45.0). Claude-Session: https://claude.ai/code/session_01Mm1WU9eHyEYQWVUPtq46Um
… P1/P2) - approval_classifier_rules.md: "Git Push to Working Branch" no longer covers the default branch on any repo — a session that starts on main can no longer auto-allow a substantive main push through the broader rule; default-branch pushes route solely through the trivial-only rule. - commit-push-sync skill: Step 4 gains a PR-convention gate — substantive changes never push to the default branch (move to a topic branch or ask); feature-branch pushes open a draft PR if none exists; trivial default-branch pushes go straight through. Claude-Session: https://claude.ai/code/session_01Mm1WU9eHyEYQWVUPtq46Um
- approval_classifier_rules.md: replace hard-coded main/master with the resolved default branch (origin/HEAD) in the working-branch ALLOW rule, the trivial-push rule, and the shared-repo DENY rule — a `trunk` or `develop` default can no longer slip through as a "working branch". - commit-push-sync: the PR gate now runs BEFORE Step 2 commits (topic branch created first so the default branch never advances; ask before switching shared HEAD in the root checkout). Step 4 keeps a re-check and now requires an explicit PR body with commands run, host, and risk assessment per AGENTS.md instead of --fill. - merge-worktree and finishing-a-development-branch: direct local merge is now framed as the trivial-change path; reviewable changes default to push + PR (finishing skill recommends its existing option 2). Claude-Session: https://claude.ai/code/session_01Mm1WU9eHyEYQWVUPtq46Um
install_gh_cli now calls install_gh_stack_extension on both exit paths (fresh install and the already-authenticated early return). The helper is idempotent (skips when the extension is listed) and defers with a warning when gh isn't authenticated yet, matching the installer's deferred-auth philosophy — auth-setup then picks it up on the next run. Closes the round-1 Codex finding that the convention prescribed a tool the managed environment never installs. Claude-Session: https://claude.ai/code/session_01Mm1WU9eHyEYQWVUPtq46Um
Codex P1: the classifier received only the command, cwd, and recent messages for a bare `git push` — no branch, no default branch, no diff — so the new trivial-vs-substantive push rules had nothing to act on. approval_classifier.py now gathers a deterministic "Git context" block (current branch, origin/HEAD default, commits being pushed, diffstat) for push commands and injects it into both backends' shared user message. Degradation errs toward the manual prompt: the rules now say a missing/unknown block classifies `unsure`, and judge triviality from the block, not recent messages alone. Tests: tests/test_approval_classifier_git_context.py drives real temp repos (bare origin + clone) — default-branch push, upstream-less feature branch, nothing-ahead, degraded-to-unknown, and non-push/non-Bash inputs. 14/14 with the existing thinking suite. Three P2s, prose: - commit-push-sync: after the Step 2 gate switches to a topic branch, Step 1's sync classification is explicitly discarded (skip Step 3, push -u); gh pr create gets --title alongside --body-file so a non-interactive run can't hang on the title prompt. - merge-worktree: the PR-vs-local-merge choice now precedes the main-tree cleanliness check, which gates only the local-merge path — an unrelated dirty main checkout no longer blocks the PR path; its gh pr create also gets explicit --title/--body-file. Claude-Session: https://claude.ai/code/session_01Mm1WU9eHyEYQWVUPtq46Um
Codex P1 x2, both defeating the default-branch gate this PR builds: 1. Fast-path bypass: `git` sits in SAFE_SHELL_COMMANDS gated by UNSAFE_SHELL_PATTERNS, whose git patterns assumed the subcommand immediately follows `git` — so `git -C x push` or `git -c push.default=current push` were auto-allowed as safe compounds before classification. All seven git denylist patterns now tolerate global options via _git_unsafe() (`\bgit\b[^;&|]*\b<sub>`), staying within one shell statement. Over-matching (a commit message containing "push") only routes to the LLM — erring toward review. 2. Wrong-destination context: the injected block described HEAD, but `git push origin main` / `origin HEAD:main` from a feature branch updates main. _parse_push_statement() now resolves the actual destination (refspecs, HEAD aliasing, --delete, value-taking options like `-o ci.skip`, `-C` paths — the block now describes the repo the push targets, not the session cwd), surfaces force flags, and compares commits against the destination ref from the real source ref. Anything unresolvable (quoting through `bash -c`, multiple refspecs) degrades to an absent block or an explicit unknown destination — the rules map both to `unsure`, never to a guess. Rules text updated to key branch decisions on the push-destination line and to send force flags to `unsure`. Tests: 8 new cases (fast-path denial incl. read-only compounds staying allowed, HEAD:main, pushing another local branch, -C repo targeting, value-opt parsing, force/delete surfacing, degradation forms). 21/21 + 16/16 settings-validation passing. Claude-Session: https://claude.ai/code/session_01Mm1WU9eHyEYQWVUPtq46Um
… model Codex P1 x5, all forms where the context block would be authoritative but wrong: - Multi-ref modes: --all/--branches/--mirror/--tags/--prune and configured remote.<name>.push refspecs update refs beyond any single destination — report destination unknown (with the reason) and drop the commits section rather than naming one branch. - +refspec is git's force-update marker: `git push origin +main:main` now lands in the force-flags line instead of being silently stripped. - Directory/repo redirection (`cd x && git push`, --git-dir, --work-tree, GIT_DIR=/GIT_WORK_TREE= env prefixes): the probes can't follow — emit no block at all, which the rules map to `unsure`. - --repo <remote> is a remote selector when no positional names the repository; it's now parsed into the remote instead of defaulting the destination line to origin. (A positional still wins, matching git.) - Cross-repo trust: the block now always reports the target repo's remote URL (what the personal-repo rule actually keys on), and a `git -C` push into a different repository than the session cwd gets an explicit warning that working-directory trust does not transfer. Rules text updated to judge ownership from the remote-URL line. Tests: 7 new cases covering each form; the --repo test initially asserted `--repo upstream main` selects upstream, which is wrong — a positional repository takes precedence over --repo in git, and the parser matches that. 43/43 across the three suites. Claude-Session: https://claude.ai/code/session_01Mm1WU9eHyEYQWVUPtq46Um
Codex P1 x6 + P2 x1, all confirmed:
- `-c` / `--config-env` on the git command line can redefine the push
(e.g. `-c remote.origin.push=HEAD:main`) in config the probes never
see — the parser now returns no context for these forms, which the
rules map to `unsure`, same as --git-dir redirection.
- Bare-push destination now follows git's documented resolution:
branch.<name>.pushRemote, then remote.pushDefault, then the fetch
upstream's remote; push.default=simple/current name the current
branch, upstream follows the upstream ref (same remote only), and
matching/nothing/anything else degrade to unknown, since matching
updates every matching branch. `@{push}` was tried and rejected: it
resolves to a single ref under push.default=matching (where the push
updates many) and errors on triangular remote.pushDefault setups
(where the destination is well-defined) — measured on git 2.43.
- The default-branch line now reads the destination remote's HEAD, not
origin's: `git push upstream trunk` is judged against upstream/HEAD.
- The URL line now reports the push URL (`get-url --push --all`), not
the fetch URL — remote.<name>.pushurl can point pushes somewhere
else entirely; multiple push URLs are all listed and flagged, since
one push updates all of them.
- Remote URLs are credential-redacted (userinfo stripped) before they
enter the classifier prompt: the block is sent to the API/CLI
backends, and HTTPS remotes can embed tokens in the URL.
- merge-worktree's PR path passes --base <PARENT_BRANCH>: without it a
worktree stacked on a feature branch opens a PR against the default
branch carrying the parent's commits.
Rules text updated to key ownership on the push-URL line (all URLs
when multiple) and the destination remote's HEAD. 6 new tests; 49/49
across the three suites.
Claude-Session: https://claude.ai/code/session_01Mm1WU9eHyEYQWVUPtq46Um
Codex round 8 found four classifier correctness/performance gaps and two stacked-PR workflow gaps; the required focused code review then found three more parser bypasses and one test/workflow issue. All confirmed findings are fixed: - Only a direct `git ... push` in the first shell statement is modelled. Earlier state-changing commands, environment prefixes/wrappers, and tokens with shell expansion syntax now emit no context, which maps to `unsure`. - Bare-push remote precedence now includes branch.<name>.remote after pushRemote and remote.pushDefault. Explicit push.default=upstream resolves the tracked destination rather than incorrectly naming the local branch. - Bundled short flags (-fq/-dq), unique long-option abbreviations, and abbreviated multi-ref modes (--mir/--al/--ta/--pru) preserve their destructive semantics. --recurse-submodules consumes its spaced operand. - The rendered classifier message (including bounded Git probes) is built once and passed identically to the API and subscription backends, so the fallback does not rerun probes after its timeout budget is computed. - Stacked PR instructions now separate three mutually exclusive paths: gh-stack submit, push/update an existing PR, or create a new unstacked PR with an explicitly established base. The main worktree's current branch is never substituted for the worktree's actual base. The test suite now exercises the real API-to-subscription orchestration in addition to the parser regressions. 58/58 tests pass across the three suites; git diff --check and py_compile are clean. Claude-Session: https://claude.ai/code/session_01Mm1WU9eHyEYQWVUPtq46Um
…ecution The final Codex convergence review found six remaining safety gaps; the required focused review then caught that the first live-default fix used `git ls-remote`, which could execute repository-controlled SSH/credential/ remote-helper commands before the push was approved. The final design closes the classes rather than adding more Git special cases: - Specific fast allows cover one invocation only. Shell composition bypasses them and reaches the compound path, whose unsafe denylist runs first, so `codex exec ...; git push` cannot auto-allow the push. - Every value-taking push option is canonicalized through Git-accepted long abbreviations; ambiguous prefixes fail closed. Remote mirror config, abbreviated multi-ref flags, bundled destructive flags, --namespace and --bare are all surfaced or rejected before classification. - Default branches no longer trust stale remote-tracking symrefs and no longer run Git transport. Local remotes are read as HEAD data; GitHub remotes use a fixed api.github.com metadata request; every other endpoint (or failed lookup) reports unknown and requires manual confirmation. Slash-containing remote names are preserved because no string prefix partitioning is used. - Git probes share a 3-second aggregate deadline and the API timeout is capped by the remaining hook budget. Probe environments strip Git execution-routing variables; diff disables external diff/textconv helpers. 39 git-context tests, 65/65 across the three suites. py_compile and git diff --check are clean. Claude-Session: https://claude.ai/code/session_01Mm1WU9eHyEYQWVUPtq46Um
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Flips the repo's git convention from "direct pushes to main, PRs only for large merges" to PRs by default:
gh stack(the officialgithub/gh-stackextension, suggested by gh core since v2.90.0; installed gh here is 2.45.0, so one-timegh extension install github/gh-stack) or Graphite.Files changed:
CLAUDE.md— Top Rules bullet flipped; Worktrees section notes thatcwmerge/cwrmintegrate locally without review, so that path is for trivial changes and reviewable work pushes the branch + opens a PR.claude/rules/refusal-alternatives.md— the "action over ceremony" section previously told agents not to propose PRs on personal repos; PRs are now carved out as the exception.claude/hooks/approval_classifier_rules.md— direct pushes to the resolved destination remote's default branch (<remote>/HEAD, not hard-coded origin/main/master) are auto-allowed only for trivial/mechanical changes; substantive ones classify asunsure(manual confirm), and the broader working-branch allow rule can no longer cover a session that started on the default branch. This changes live permission behavior on merge (the file is symlinked into~/.claude/).claude/hooks/approval_classifier.py— for push commands the hook now injects a deterministic "Git context" block (current branch, destination remote's default branch, push destination, commits being pushed, diffstat) into the classifier prompt; previously a baregit pushreached the model with no branch or diff at all, making the rules above unenforceable. The block resolves the actual destination ref (git push origin main/origin HEAD:mainfrom a feature branch still updates main), follows-Cto describe the repo the push targets, reports that repo's credential-redacted push URL (what the personal-repo trust rule keys on — the push URL, not the fetch URL, sincepushurlcan differ, with userinfo stripped so embedded HTTPS tokens never reach the classifier backends), reads the default branch from the live destination push endpoint without executing Git transport (local remotes are read asHEADdata; GitHub remotes use fixedapi.github.commetadata; unsupported/failed lookups become unknown rather than trusting stalerefs/remotes/<remote>/HEAD), routes bare pushes through git's documented resolution (branch.<name>.pushRemote→remote.pushDefault→branch.<name>.remote→ origin;push.defaultpicks the ref, including tracked-name resolution forupstream), and surfaces force/delete markers including+refspec. The compound-safe fast path's git denylist patterns tolerate global options (git -C x pushcan no longer be auto-allowed as a safe compound). Anything the parser can't fully model fails closed: multi-ref modes and their Git-accepted abbreviations (--all/--mirror/--tags/configured push refspecs/push.default=matching) report an unknown destination, while directory/repo/config/shell-state redirection (cd x && git push, a prior state-changing statement, env/wrapper prefixes, shell-expanded arguments,--git-dir,GIT_DIR=,-c/--config-env) emits no block at all — both map tounsure, the manual prompt. Bundled short flags (-fq/-dq), abbreviated force/delete options and+refspecretain their destructive markers. The rendered block is built once and reused identically by both classifier backends, so API fallback does not rerun Git probes after its timeout budget is computed. Covered bytests/test_approval_classifier_git_context.py(real temp repos, 39 tests; 65/65 with the other suites).claude/skills/commit-push-sync/SKILL.md— PR-convention gate runs before Step 2 commits (topic branch first, so the default branch never advances; asks before switching shared HEAD in the root checkout), with a re-check at Step 4; PR creation has three mutually exclusive paths:gh stack submitfor a tracked stack, push/update an existing PR while preserving its base, or create a new unstacked PR with explicit--base/--title/--body-file; ambiguous parentage asks rather than substituting the repository default or whatever the main checkout currently has checked out.claude/skills/merge-worktree/SKILL.md,claude/skills/finishing-a-development-branch/SKILL.md— direct local merge reframed as the trivial-change path; reviewable changes default to push + PR, with the actual PR base established independently from the main worktree's current branch (the finishing skill recommends its existing option 2).scripts/shared/helpers.sh—install_gh_clinow provisions the gh-stack extension on both exit paths: idempotent (skips when listed), defers with a warning when gh isn't authenticated yet (matching the installer's deferred-auth philosophy).Eight rounds of Codex review plus one required focused code-review pass drove the scope, each converging on narrower issues: round 1 flagged workflows bypassing the convention, the classifier auto-allowing main pushes, and
gh stacknot provisioned; round 2 caught the working-branch allow-rule loophole and/commit-push-sync's missing PR path; round 3 caught hard-coded branch names, the gate firing after the commit already landed on main, and the two merge skills; round 4 caught that the classifier had no git state to classify pushes with (fixed with the injected Git context block above), plus three skill-workflow gaps (stale sync classification after the topic-branch switch, the dirty-main-tree check blocking the PR path in/merge-worktree, andgh pr createwithout--titlehanging non-interactive runs); round 5 caught two bypasses of the new gate — option-prefixed pushes (git -C x push) skipping classification via the fast path, and the context block naming HEAD instead of the push destination; round 6 caught five more push forms whose context would have been authoritative but wrong (--all/--mirror,+refspecforce marker,cd/--git-dirredirection,--reporemote selection,-Ccross-repo trust); round 7 caught the config layer (-ccommand-line overrides,push.default=matching,pushRemote/pushDefaultrouting, per-remote default branches,pushurlvs fetch URL) plus one real security issue — remote URLs with embedded HTTPS credentials would have been sent to the classifier backends, now redacted — andgh pr createmissing--basein the merge-worktree skill's stacked case; round 8 caught prior shell-state mutation,push.default=upstreamwith a differently named tracked branch, bundled destructive flags, duplicate Git probes consuming the fallback timeout, a missing value-option operand, and both skills still lacking a reliable stacked base. The focused pass then caught the final sibling cases:branch.<name>.remoteprecedence, shell-expanded destination tokens, abbreviated multi-ref flags, a test that did not exercise the actual fallback call site, and existing-PR/stack paths that were not mutually exclusive withgh pr create. The final convergence review then closed the last general classes: shell chains bypassing specific fast allows, abbreviated value-option operands,remote.<name>.mirror, stale local default-branch symrefs, slash-containing remote names, namespace/bare redirection, and aggregate probe timing. A final focused security pass rejectedgit ls-remotefor the live-default check because repository-controlled SSH/credential/remote-helper configuration could execute before approval; the shipped resolver performs no Git transport and fails closed outside local-data/GitHub-API endpoints. The parser now fails closed whenever it cannot prove a single static destination rather than trying to model arbitrary shell and Git programs. One implementation note:@{push}looked like the clean way to resolve bare-push destinations but was measured (git 2.43) to resolve to a single ref underpush.default=matching(where the push updates many branches) and to error on triangularremote.pushDefaultsetups (where the destination is well-defined) — so the hook mirrors Git's documented resolution order explicitly instead. One known follow-up, deliberately out of scope:cwmerge/cwrm(shell functions inconfig/aliases/claude.sh) still integrate locally without review — they could grow a guard or--prpath rather than relying on the doc note.Testing & risk
rgsweeps for stale statements of the old convention (none remain outside the changed files);uv run --with pytest python -m pytest tests/test_approval_classifier_git_context.py tests/test_approval_classifier_thinking.py tests/test_validate_claude_settings.py— 65/65 passing;python3 -m py_compile claude/hooks/approval_classifier.py;git diff --check;bash -n scripts/shared/helpers.sh+ shellcheck clean. Host: Ubuntu (Linux 6.8).~/.claudesymlink. The classifier code change only fires on Bash commands matchinggit … push; local read-only Git probes share a 3s aggregate budget, strip execution-routing environment variables, and disable external diff/textconv, while default-branch freshness uses only localHEADdata or a bounded fixed-hostapi.github.commetadata read — never Git transport. Every unsupported or failed resolution degrades to an absent/"unknown" block, which the rules map tounsure— i.e. a manual prompt, never a silent auto-allow.This PR is itself the first exercise of the convention. It's independent of #66 (no code dependency), so no stack needed.
https://claude.ai/code/session_01Mm1WU9eHyEYQWVUPtq46Um