Skip to content

Make PRs the convention (stacked via gh stack or Graphite when helpful) - #68

Draft
yulonglin wants to merge 11 commits into
mainfrom
pr-convention
Draft

Make PRs the convention (stacked via gh stack or Graphite when helpful)#68
yulonglin wants to merge 11 commits into
mainfrom
pr-convention

Conversation

@yulonglin

@yulonglin yulonglin commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Flips the repo's git convention from "direct pushes to main, PRs only for large merges" to PRs by default:

  • Reviewable changes go through a PR; direct pushes to main are reserved for trivial/mechanical changes (typo, version bump, doc touch-up).
  • Large changes that review better as a chain use stacked PRsgh stack (the official github/gh-stack extension, suggested by gh core since v2.90.0; installed gh here is 2.45.0, so one-time gh extension install github/gh-stack) or Graphite.

Files changed:

  • CLAUDE.md — Top Rules bullet flipped; Worktrees section notes that cwmerge/cwrm integrate 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 as unsure (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 bare git push reached 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:main from a feature branch still updates main), follows -C to 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, since pushurl can 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 as HEAD data; GitHub remotes use fixed api.github.com metadata; unsupported/failed lookups become unknown rather than trusting stale refs/remotes/<remote>/HEAD), routes bare pushes through git's documented resolution (branch.<name>.pushRemoteremote.pushDefaultbranch.<name>.remote → origin; push.default picks the ref, including tracked-name resolution for upstream), and surfaces force/delete markers including +refspec. The compound-safe fast path's git denylist patterns tolerate global options (git -C x push can 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 to unsure, the manual prompt. Bundled short flags (-fq/-dq), abbreviated force/delete options and +refspec retain 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 by tests/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 submit for 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.shinstall_gh_cli now 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 stack not 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, and gh pr create without --title hanging 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, +refspec force marker, cd/--git-dir redirection, --repo remote selection, -C cross-repo trust); round 7 caught the config layer (-c command-line overrides, push.default=matching, pushRemote/pushDefault routing, per-remote default branches, pushurl vs fetch URL) plus one real security issue — remote URLs with embedded HTTPS credentials would have been sent to the classifier backends, now redacted — and gh pr create missing --base in the merge-worktree skill's stacked case; round 8 caught prior shell-state mutation, push.default=upstream with 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>.remote precedence, 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 with gh 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 rejected git ls-remote for 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 under push.default=matching (where the push updates many branches) and to error on triangular remote.pushDefault setups (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 in config/aliases/claude.sh) still integrate locally without review — they could grow a guard or --pr path rather than relying on the doc note.

Testing & risk

  • Commands run: rg sweeps 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).
  • Risk: low for docs; the classifier rule change adds friction (manual confirm) to substantive direct-to-main pushes on personal repos — that is the intended behavior flip, but it takes effect globally on merge via the ~/.claude symlink. The classifier code change only fires on Bash commands matching git … 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 local HEAD data or a bounded fixed-host api.github.com metadata read — never Git transport. Every unsupported or failed resolution degrades to an absent/"unknown" block, which the rules map to unsure — 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

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant