diff --git a/exact_dot_claude/rules/claude-code-auto-mode.md b/exact_dot_claude/rules/claude-code-auto-mode.md index 4b02b38..3ededf7 100644 --- a/exact_dot_claude/rules/claude-code-auto-mode.md +++ b/exact_dot_claude/rules/claude-code-auto-mode.md @@ -44,6 +44,41 @@ oversight in the non-auto modes. Under auto mode the allow-list's only job is skipping the classifier on safe, high-frequency commands — which wants narrow entries. +## Some tools ALWAYS reach the classifier — only a whole-tool allow rule clears them + +Auto mode usually short-circuits *before* the classifier: it re-runs the +tool's own `checkPermissions` with the mode forced to `acceptEdits`, and an +`allow` there skips the classifier entirely. A **hardcoded set of tools is +excluded from that fast path** and is classified on every single call: + +`Agent` · `CronCreate` · `RemoteTrigger` · `ScheduleWakeup` · `SendFile` + +(`CronCreate` and `RemoteTrigger` also return `passthrough` — "requires +classifier review" — in auto mode, where every other mode gets a plain +`allow`.) The symptom is a steady drip of approve/deny prompts for the +check-back-in machinery — arm a cron, arm a claude.ai routine, notify when CI +finishes — which stalls unattended sessions. No amount of `autoMode` prose +fixes it; the classifier is behaving as designed. + +**The lever is a whole-tool allow rule**, which resolves ahead of the +classifier. Bare tool name, no scoping: + + "allow": ["CronCreate", "CronDelete", "CronList", + "PushNotification", "RemoteTrigger", "ScheduleWakeup"] + +- **Bare names only.** These tools declare no `ruleContentField`, so a scoped + rule like `RemoteTrigger(create)` **silently never matches** — it is + all-actions-or-nothing per tool. +- **Survives the strip above**, which targets arbitrary code execution + (`Bash`, `Agent(*)`); non-exec tool grants carry over into auto mode. +- A tool can opt out via `ignoresWholeToolAllowRule`; none of these do. +- **`Monitor` is deliberately excluded** — it runs arbitrary shell, so it + belongs with `Bash`, not with the benign schedulers. +- **Web/remote needs the project file.** `~/.claude` is unreachable there, so + the same entries must also live in each repo's committed + `.claude/settings.json` — `just -g claude-perms-sweep ` sweeps the + fleet (same global-vs-committed split as `claude-plugins-freshness.md`). + ## `deny` is a hard backstop `permissions.deny` resolves before the classifier and cannot be overridden in diff --git a/exact_dot_claude/rules/git-hazards.md b/exact_dot_claude/rules/git-hazards.md index 4f09334..87135b3 100644 --- a/exact_dot_claude/rules/git-hazards.md +++ b/exact_dot_claude/rules/git-hazards.md @@ -1,38 +1,10 @@ # Git Hazards — Verify the Content, Not the Exit Code -Eight traps sharing one law: **a green git command is not proof the result is -correct.** "Merge went well", "PR merged", and exit 0 are claims about -mechanics, not content. Each hazard below: the trap, the 5-second check, the -fix. (Consolidated 2026-07 from six separate incident rules; full narratives -are in git history.) - -## 1. `--merged` misses squash-merged branches - -A squash-merge collapses a branch into one fresh-SHA commit on `main`, so the -branch's own commits are never ancestors — `git branch --merged` (and any -ancestry check) reports it **unmerged**. "Files identical to main" also fails -once `main` drifts the same files. - -- **Check**, in order of authority: - - `gh pr list --state all --head --json state` → a MERGED PR is - **authoritative**. Reach for this first; the git-side checks below are all - one-way. - - `git cherry main ` → marks a commit `-` when a patch-equivalent - commit is already upstream, `+` when it is not. Survives squash **and** - cherry-pick, and does not care that `main` drifted. - - `git merge-tree --write-tree main ` equals `git rev-parse main^{tree}` - → contained. **A match proves containment; a non-match proves nothing.** -- **Not immune to drift** (corrected 2026-07): once `main` moves on over the same - files, merging an already-merged branch back would re-introduce its older - versions, so the trees differ and merge-tree reports **not contained** for work - that fully landed. Observed reporting three merged branches as unmerged. Same - trap as "files identical to main". Use the PR state or `git cherry` to decide; - keep merge-tree only as a positive-containment shortcut. -- **Fix**: use the encoded recipe rather than re-deriving: `just -g branch-audit` - (in `private_dot_config/just/git.just`) prints MERGED vs REVIEW + a paste-ready delete. -- A non-match is "review", **not** proof of unmerged — don't force the count to zero. - -## 2. Commits added after a squash-merge are orphaned +Five local-git traps, one law: **a green git command is not proof the result is +correct** — exit 0 is a claim about mechanics, not content. Each: the trap, the +5-second check, the fix. Sibling: `pr-merge-hazards.md` (GitHub PR/merge). + +## 1. Commits added after a squash-merge are orphaned Anything committed to a branch **after** its squash-merge is in neither `main` nor the squash commit. A fresh branch off `origin/main` silently lacks that @@ -43,35 +15,7 @@ work; the first symptom is an ImportError far downstream. - **Fix**: replay only the orphans: `git rebase --onto origin/main `, then verify `git log --oneline origin/main..HEAD` shows only the orphaned + new commits. -## 3. Merging a stacked base auto-CLOSES the child PR - -When PR B is based on PR A's branch, merging A and deleting its branch -auto-closes B (GitHub does **not** retarget it), and a closed PR whose base -branch is gone **cannot be reopened**. - -- **Fix — order matters**: retarget the child **first**, while the base PR is open: - 1. `gh pr edit --base main` - 2. `gh pr merge --squash --delete-branch` - 3. `git rebase --onto origin/main ` (drops the - already-squashed base commits) + `git push --force-with-lease` - 4. merge the child. -- **If already auto-closed**: the head branch survives — rebase as above, - `gh pr create` fresh, comment "Superseded by #new" on the closed one. -- **Nothing tells you this happened.** The auto-close is silent: no failed - check, no notification, and the PR list just looks one shorter. claude-plugins - #2049 sat stranded for a day; a sweep then found 26 dead branches, two carrying - work that had **never had a PR opened at all** (so no event ever fired for - them either). A scheduled sweep is the only thing that finds this class — - an event handler on `pull_request: closed` is too late by construction (the - base ref is already deleted, so the reopen window is gone) and is blind to - never-PR'd branches. `claude-plugins scripts/check-stranded-work.sh` is the - encoded audit; it takes `--repo`, so one run sweeps the portfolio. -- **Telling an accident from a decision**: a closed-unmerged PR whose base ref - **404s** was auto-closed; one whose base ref is still **alive** was closed by a - human (duplicate/superseded). That single check is the discriminator — 11 of - those 26 branches were deliberate closes and must not be resurrected. - -## 4. Unpushed commits on local `main` ride into new branches +## 2. Unpushed commits on local `main` ride into new branches Branching off local `main` inherits whatever it is ahead of `origin/main` by; the PR then bundles stray commits under an unrelated title (squash hides it — @@ -81,7 +25,7 @@ visible only in the file list). `git fetch origin && git switch -c origin/main`. - **Check** when unsure: `git log --oneline origin/main..main` — empty means clean. -## 5. A clean textual merge can duplicate identical additions +## 3. A clean textual merge can duplicate identical additions When two branches each add the **same** helper/import/enum arm in non-adjacent spots, `git merge` sees no overlapping hunk, reports success, and keeps **both @@ -93,7 +37,7 @@ lax languages). - **Fix**: hand-resolve to a single combined definition; never trust "Automatic merge went well" as a verdict on content. -## 6. `git add` aborts atomically on a bad pathspec +## 4. `git add` aborts atomically on a bad pathspec `git add fileA nonexistent` stages **nothing** — not "fileA plus a warning". Classic trip: `git mv old new`, edit `new`, then `git add new old` → the stale @@ -106,51 +50,16 @@ Classic trip: `git mv old new`, edit `new`, then `git add new old` → the stale - **Recovery**: the edit is still unstaged in the working tree; add and commit/amend — don't redo the work. -## 7. Stacked-chain merges: push by SHA, never `HEAD:` — and expect auto-close races - -Working down a stacked-PR chain (retarget child → merge base → rebase child → -force-push → merge, per #3) has three traps of its own (observed 2026-07, -claude-plugins #1979→#1987): - -- **`HEAD:` in a push refspec is a race in a shared checkout.** HEAD is - process-global repo state; a coworker session can move it *between two of - your Bash calls*. Observed: rebase left HEAD at the child's new tip; by the - next call HEAD was `main`'s tip, so `git push --force-with-lease origin - HEAD:` overwrote the branch with main. Resolve the tip to an - **explicit SHA in the same command that creates it** and push - `git push --force-with-lease origin :`. -- **An empty-diff force-push auto-closes the PR — and a closed PR whose *head* - moved after closing cannot be reopened.** Sibling of #3's - base-branch-deleted variant. GitHub saw the branch == main, closed the PR, - and refused `gh pr reopen` because the head ref had moved since closing. -- **A single mergeability read after a force-push is a race.** GitHub - recomputes `mergeable` asynchronously; `gh pr merge` right after a push - fails with "not mergeable" on a perfectly clean PR. Poll - `gh pr view --json mergeable` until it leaves `UNKNOWN`. -- **Waiting for CI races check *registration*, not just completion.** A loop on - "zero pending checks" can exit **immediately** after a push/`update-branch`: - zero pending is trivially true before the jobs are registered. Observed - 2026-07: `state=CLEAN` on a **single** check while three CI jobs had not yet - appeared — merging there merges untested. Gate on **both** nothing-pending - **and** `--jq 'length'` ≥ the expected check count. Same root cause as the - mergeability race above: an async field read once, too early. - -- **Check** before every force-push: `git log --oneline origin/main..` — - expect *exactly* the child's commits, nothing more, never empty. -- **Recovery** when auto-closed: the rebased commits survive in local objects - (`git reflog`) — `git push --force-with-lease origin :`, open a - fresh PR from the branch, comment "Superseded by #new" on the closed one. - -## 8. A "vanished" staged file in a shared checkout was probably committed by a coworker - -Sibling of #7's HEAD race: the **index and HEAD are process-global**, so a -coworker session's commit lands between two of your Bash calls with no -warning. Observed 2026-07 (dotfiles): a file another session had staged (`A `) -disappeared from `git status`, then `ls` said it didn't exist, then status -flapped `A ` → `M ` across consecutive calls. The wrong theory ("pre-commit's -stash dance ate it") was nearly acted on; the truth was the coworker had -committed the file to `main` mid-flight — every observation was a stale read -of state the coworker kept moving. +## 5. A "vanished" staged file in a shared checkout was probably committed by a coworker + +Sibling of the HEAD race in `pr-merge-hazards.md` #3: the **index and HEAD are +process-global**, so a coworker session's commit lands between two of your Bash +calls with no warning. Observed 2026-07 (dotfiles): a file another session had +staged (`A `) disappeared from `git status`, then `ls` said it didn't exist, +then status flapped `A ` → `M ` across consecutive calls. The wrong theory +("pre-commit's stash dance ate it") was nearly acted on; the truth was the +coworker had committed the file to `main` mid-flight — every observation was a +stale read of state the coworker kept moving. - **Check first, before any recovery**: `git log --oneline -3` — did HEAD move? — and `git log -1 -- `; a fresh commit touching the path is the @@ -163,4 +72,4 @@ of state the coworker kept moving. - **Status flapping between consecutive calls is itself the tell** that a coworker is active — stop mutating shared state (index, HEAD, branch switches) until the flapping stops; re-read state fresh in the same command - that acts on it (same instinct as #7's push-by-SHA). + that acts on it (same instinct as push-by-SHA). diff --git a/exact_dot_claude/rules/pr-merge-hazards.md b/exact_dot_claude/rules/pr-merge-hazards.md new file mode 100644 index 0000000..e2aa414 --- /dev/null +++ b/exact_dot_claude/rules/pr-merge-hazards.md @@ -0,0 +1,105 @@ +# PR & Merge Hazards — Exit 0 Is a Claim About Mechanics + +Four traps in GitHub's merge machinery, one law: "PR merged" says nothing about +*content* — and a red check is not proof of failure. Each: the trap, the +5-second check, the fix. Sibling: `git-hazards.md` (local git). + +## 1. `--merged` misses squash-merged branches + +A squash-merge collapses a branch into one fresh-SHA commit on `main`, so the +branch's own commits are never ancestors — `git branch --merged` (and any +ancestry check) reports it **unmerged**. "Files identical to main" also fails +once `main` drifts the same files. + +- **Check**, in order of authority: + - `gh pr list --state all --head --json state` → a MERGED PR is + **authoritative**. Reach for this first; the git-side checks below are all + one-way. + - `git cherry main ` → marks a commit `-` when a patch-equivalent + commit is already upstream, `+` when it is not. Survives squash **and** + cherry-pick, and does not care that `main` drifted. + - `git merge-tree --write-tree main ` equals `git rev-parse main^{tree}` + → contained. **A match proves containment; a non-match proves nothing.** +- **Not immune to drift** (corrected 2026-07): once `main` moves on over the same + files, merging an already-merged branch back would re-introduce its older + versions, so the trees differ and merge-tree reports **not contained** for work + that fully landed. Observed reporting three merged branches as unmerged. Same + trap as "files identical to main". Use the PR state or `git cherry` to decide; + keep merge-tree only as a positive-containment shortcut. +- **Fix**: use the encoded recipe rather than re-deriving: `just -g branch-audit` + (in `private_dot_config/just/git.just`) prints MERGED vs REVIEW + a paste-ready delete. +- A non-match is "review", **not** proof of unmerged — don't force the count to zero. + +## 2. Merging a stacked base auto-CLOSES the child PR + +When PR B is based on PR A's branch, merging A and deleting its branch +auto-closes B (GitHub does **not** retarget it), and a closed PR whose base +branch is gone **cannot be reopened**. + +- **Fix — order matters**: retarget the child **first**, while the base PR is open: + 1. `gh pr edit --base main` + 2. `gh pr merge --squash --delete-branch` + 3. `git rebase --onto origin/main ` (drops the + already-squashed base commits) + `git push --force-with-lease` + 4. merge the child. +- **If already auto-closed**: the head branch survives — rebase as above, + `gh pr create` fresh, comment "Superseded by #new" on the closed one. +- **Nothing tells you this happened.** The auto-close is silent: no failed + check, no notification, and the PR list just looks one shorter. claude-plugins + #2049 sat stranded for a day; a sweep then found 26 dead branches, two carrying + work that had **never had a PR opened at all** (so no event ever fired for + them either). A scheduled sweep is the only thing that finds this class — + an event handler on `pull_request: closed` is too late by construction (the + base ref is already deleted, so the reopen window is gone) and is blind to + never-PR'd branches. `claude-plugins scripts/check-stranded-work.sh` is the + encoded audit; it takes `--repo`, so one run sweeps the portfolio. +- **Telling an accident from a decision**: a closed-unmerged PR whose base ref + **404s** was auto-closed; one whose base ref is still **alive** was closed by a + human (duplicate/superseded). That single check is the discriminator — 11 of + those 26 branches were deliberate closes and must not be resurrected. + +## 3. Stacked-chain merges: push by SHA, never `HEAD:` — and expect auto-close races + +Working down a stacked-PR chain (retarget child → merge base → rebase child → +force-push → merge, per #2) has three traps of its own (observed 2026-07, +claude-plugins #1979→#1987): + +- **`HEAD:` in a push refspec is a race in a shared checkout.** HEAD is + process-global repo state; a coworker session can move it *between two of + your Bash calls*. Observed: rebase left HEAD at the child's new tip; by the + next call HEAD was `main`'s tip, so `git push --force-with-lease origin + HEAD:` overwrote the branch with main. Resolve the tip to an + **explicit SHA in the same command that creates it** and push + `git push --force-with-lease origin :`. +- **An empty-diff force-push auto-closes the PR — and a closed PR whose *head* + moved after closing cannot be reopened.** Sibling of #2's + base-branch-deleted variant. GitHub saw the branch == main, closed the PR, + and refused `gh pr reopen` because the head ref had moved since closing. +- **A single mergeability read after a force-push is a race.** GitHub + recomputes `mergeable` asynchronously; `gh pr merge` right after a push + fails with "not mergeable" on a perfectly clean PR. Poll + `gh pr view --json mergeable` until it leaves `UNKNOWN`. +- **Waiting for CI races check *registration*, not just completion.** A loop on + "zero pending checks" can exit **immediately** after a push/`update-branch`: + zero pending is trivially true before the jobs are registered. Observed + 2026-07: `state=CLEAN` on a **single** check while three CI jobs had not yet + appeared — merging there merges untested. Gate on **both** nothing-pending + **and** `--jq 'length'` ≥ the expected check count. Same root cause as the + mergeability race above: an async field read once, too early. + +- **Check** before every force-push: `git log --oneline origin/main..` — + expect *exactly* the child's commits, nothing more, never empty. +- **Recovery** when auto-closed: the rebased commits survive in local objects + (`git reflog`) — `git push --force-with-lease origin :`, open a + fresh PR from the branch, comment "Superseded by #new" on the closed one. + +## 4. A red PR may still be mergeable — `UNSTABLE` is not `BLOCKED` + +`mergeStateStatus` separates **required** failing checks (`BLOCKED` — merge +refused) from merely-present ones (`UNSTABLE` — plain `gh pr merge` works), so +`--admin` on an `UNSTABLE` PR takes a privilege you didn't need. Read it first. + +Merging over red needs **two** checks: `--json files` (config/docs can't break +a compile) **and** the same check already failing on `main`. Either alone is a +guess — and a stale-green `main` lies, so check `createdAt` (2026-07: a "green" +run was 21 days old; main hadn't compiled for three weeks). diff --git a/private_dot_config/just/claude.just b/private_dot_config/just/claude.just index 3b6cf1b..29d766a 100644 --- a/private_dot_config/just/claude.just +++ b/private_dot_config/just/claude.just @@ -181,3 +181,22 @@ settings-audit: if [[ $((critical + high)) -gt 0 ]]; then exit 1 fi + +# Sweep permission allow-rules into every repo's committed .claude/settings.json +# (dry-run by default; pass --apply to write, --pr to also open PRs). +# +# Committed project settings are the ONLY place permission rules reach Claude +# Code web/remote — ~/.claude is unreachable there. A rule added to the chezmoi +# overlay alone therefore covers local sessions and nothing else, which is the +# same global-vs-committed split plugins-audit handles for enabledPlugins. +# +# Goes through the GitHub API, not local checkouts: a portfolio sweep meets +# dirty trees and feature-branch checkouts, and the API leaves both alone while +# reading true remote state. Repos with no committed settings.json are skipped, +# never created — an absent project config is a deliberate state. +# +# just -g claude-perms-sweep laurigates "CronCreate,RemoteTrigger" +# just -g claude-perms-sweep fvh "CronCreate,CronDelete" --pr +[group: "claude"] +claude-perms-sweep scope rules *flags: + @python3 "$HOME/.local/share/chezmoi/scripts/claude-perms-sweep.py" "{{scope}}" --rules "{{rules}}" {{flags}} diff --git a/scripts/CLAUDE.md b/scripts/CLAUDE.md index 66970cc..fab06f6 100644 --- a/scripts/CLAUDE.md +++ b/scripts/CLAUDE.md @@ -12,5 +12,6 @@ Utility scripts for Claude Code infrastructure automation. | `update-command-references.sh` | Update markdown references after namespace migration (supports `--dry-run`) | | `smoke-test-docker.sh` | Docker-based smoke tests for dotfiles | | `check-doc-references.py` | Flag docs that reference scripts/paths/links no longer in the repo (advisory pre-commit hook; strict via `mise run lint:docs`). See `.doc-reference-allow` for scoping and `scripts/tests/test-check-doc-references.sh` for the contract. | +| `claude-perms-sweep.py` | Ensure permission allow-rules exist in every repo's **committed** `.claude/settings.json` — the only place rules reach Claude Code web/remote. Discovers repos from GitHub (not local clones) and patches via the API, so dirty trees and feature-branch checkouts are untouched. Dry-run by default; driven by `just -g claude-perms-sweep `. | All shell scripts support `--help`. Run `shellcheck scripts/*.sh` to lint. diff --git a/scripts/claude-perms-sweep.py b/scripts/claude-perms-sweep.py new file mode 100755 index 0000000..5f914f9 --- /dev/null +++ b/scripts/claude-perms-sweep.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +"""Sweep permission allow-rules into every repo's committed .claude/settings.json. + +Committed project settings are the ONLY place permission rules reach Claude Code +web/remote sessions -- ~/.claude is unreachable there (see the global rules +claude-code-auto-mode.md and claude-plugins-freshness.md). Keeping a rule in the +chezmoi overlay alone therefore covers local sessions and nothing else. + +Everything goes through the GitHub API rather than local checkouts: a portfolio +sweep routinely meets dirty working trees and repos parked on feature branches, +and the API both leaves those untouched and reads the *true* remote state +instead of a possibly-drifted local file. + + claude-perms-sweep.py --rules A,B,C [--apply] [--pr] + + scope laurigates | fvh | all (or an explicit owner/name list) + --rules comma-separated bare tool names or rule strings to ensure present + --apply actually write; default is a dry-run plan + --pr also open a PR per repo (implies --apply) + +Repos with no committed .claude/settings.json are reported and skipped -- this +never creates the file, because an absent project config is a deliberate state +(the repo may not use Claude Code at all). +""" + +import argparse +import base64 +import json +import re +import subprocess +import sys + +ORGS = {"laurigates": "laurigates", "fvh": "ForumViriumHelsinki"} +PATH = ".claude/settings.json" +BRANCH = "chore/claude-perms-sweep" + + +def gh(*args, check=True): + p = subprocess.run(("gh",) + args, capture_output=True, text=True) + if check and p.returncode != 0: + raise RuntimeError(p.stderr.strip()[:300] or "gh failed") + return p.stdout.strip() + + +def repos_for(scope): + owners = list(ORGS.values()) if scope == "all" else [ORGS[scope]] + out = [] + for o in owners: + raw = gh( + "repo", + "list", + o, + "--limit", + "300", + "--no-archived", + "--json", + "nameWithOwner", + "--jq", + ".[].nameWithOwner", + ) + out += [r for r in raw.splitlines() if r] + return sorted(out) + + +def fetch(slug, ref): + p = subprocess.run( + ("gh", "api", f"repos/{slug}/contents/{PATH}?ref={ref}"), + capture_output=True, + text=True, + ) + if p.returncode != 0: + return None, None + d = json.loads(p.stdout) + return base64.b64decode(d["content"]).decode(), d["sha"] + + +def transform(text, rules): + """Insert any missing rules. Returns (new_text, mode); new_text None if a no-op. + + Surgical string insertion, NOT a json.load/dump round-trip: a reformat would + bury the real change in a whole-file diff and fight whatever style the repo + already uses. + """ + missing = [r for r in rules if not re.search(r'"%s"' % re.escape(r), text)] + if not missing: + return None, "already-present" + + m = re.search(r'("allow"\s*:\s*\[)(\s*\n)?', text) + if m: + first = text[m.end() :].split("\n")[0] if m.group(2) else "" + im = re.match(r"[ \t]*", first) + indent = im.group(0) if (m.group(2) and im.group(0)) else " " + block = "".join(f'\n{indent}"{r}",' for r in missing) + # Insert right after '[' so the closing bracket and the existing last + # element's trailing-comma state are never touched. + return text[: m.end(1)] + block + text[m.end(1) :], "append-to-allow" + + # settings.json exists but has no permissions.allow: add the block after the + # opening brace, or after a leading "$schema" so the pointer stays first. + m = re.match(r'(\s*\{\s*\n[ \t]*"\$schema"[^\n]*\n)', text) or re.match( + r"(\s*\{\s*\n)", text + ) + if not m: + raise RuntimeError("unrecognised JSON shape") + entries = ",\n".join(f' "{r}"' for r in rules) + block = f' "permissions": {{\n "allow": [\n{entries}\n ]\n }},\n' + return text[: m.end(1)] + block + text[m.end(1) :], "create-permissions" + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("scope") + ap.add_argument("--rules", required=True) + ap.add_argument("--apply", action="store_true") + ap.add_argument("--pr", action="store_true") + ap.add_argument( + "--title", default="chore(claude): sweep Claude Code permission allow-rules" + ) + ap.add_argument("--body-file") + a = ap.parse_args() + + rules = [r.strip() for r in a.rules.split(",") if r.strip()] + write = a.apply or a.pr + slugs = ( + repos_for(a.scope) + if a.scope in ORGS or a.scope == "all" + else [s.strip() for s in a.scope.split(",")] + ) + + changed = skipped = failed = 0 + for slug in slugs: + name = slug.split("/")[-1] + try: + default = gh("api", f"repos/{slug}", "--jq", ".default_branch") + text, sha = fetch(slug, default) + if text is None: + print(f"{name:34} skip no committed {PATH}") + skipped += 1 + continue + new, how = transform(text, rules) + if new is None: + print(f"{name:34} skip all rules already present") + skipped += 1 + continue + json.loads(new) # hard gate: never write invalid JSON + if not write: + print(f"{name:34} {how:20} would patch on {default}") + changed += 1 + continue + + head = gh( + "api", f"repos/{slug}/git/ref/heads/{default}", "--jq", ".object.sha" + ) + subprocess.run( + ( + "gh", + "api", + "-X", + "POST", + f"repos/{slug}/git/refs", + "-f", + f"ref=refs/heads/{BRANCH}", + "-f", + f"sha={head}", + ), + capture_output=True, + text=True, + ) + gh( + "api", + "-X", + "PUT", + f"repos/{slug}/contents/{PATH}", + "-f", + f"message={a.title}", + "-f", + f"content={base64.b64encode(new.encode()).decode()}", + "-f", + f"sha={sha}", + "-f", + f"branch={BRANCH}", + ) + note = f"committed to {BRANCH}" + if a.pr: + cmd = [ + "pr", + "create", + "--repo", + slug, + "--head", + BRANCH, + "--title", + a.title, + ] + cmd += ( + ["--body-file", a.body_file] if a.body_file else ["--body", a.title] + ) + note = gh(*cmd).splitlines()[-1] + print(f"{name:34} {how:20} {note}") + changed += 1 + except Exception as e: + print(f"{name:34} ERROR {e}") + failed += 1 + + print( + f"\nCHANGED={changed} SKIPPED={skipped} FAILED={failed} " + f"MODE={'apply' if write else 'dry-run'}" + ) + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test-claude-context-budget.sh b/tests/test-claude-context-budget.sh index 55784e6..85deb39 100755 --- a/tests/test-claude-context-budget.sh +++ b/tests/test-claude-context-budget.sh @@ -60,7 +60,7 @@ MARKERS='\.chezmoidata|dot_zshrc|mise run lint|exact_dot_claude/|\.chezmoiignore # content). Format: one rule filename per line. Adding to this list is a # conscious decision — prefer migrating the rule instead. MARKER_ALLOWLIST=( - git-hazards.md # points at the global justfile recipe source + pr-merge-hazards.md # points at the global justfile recipe source claude-plugins-freshness.md # names the overlay file as source of truth path-scoped-rules.md # chezmoi globs as frontmatter *examples* zsh-pattern-expansion-extended-glob.md # names dot_zshrc.tmpl as one scope example