diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ef3d3cc..71b9b2a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -346,6 +346,21 @@ repos: # records for Vale warnings. Costs nothing on a clean file: the script # prints only findings. + - id: check-rtk-prefix + stages: ['pre-commit'] + name: ADR-0023 rtk prefix on executable git commands + description: Enforce ADR-0023 clause 1 -- an executable, instructed git command in a shell code fence or a dispatch-table Run cell is written `rtk git`. Clauses 2 and 3 are not machine-decidable; a deliberately bare command opts out with the literal string ADR-0023 on its own line + entry: scripts/check-rtk-prefix.sh + language: script + files: '^plugins/[^/]+/\.apm/(skills/.*\.md|agents/.*\.agent\.md)$' + # README.md is excluded on purpose, not by oversight. A skill-directory + # README is consumer-facing prose that no agent ever loads, and the + # `git clone` lines in the seven tests/README.md files are setup + # instructions for a third party who has no rtk installed. Prefixing + # those would be actively wrong -- see ADR-0023's consumer section. + exclude: '(^|/)README\.md$' + pass_filenames: true + - id: vale-audit-prefilter-skill stages: ['pre-commit'] name: Vale audit prefilter (SKILL.md) diff --git a/docs/adr/0023-rtk-prefix-marks-executable-commands-only.md b/docs/adr/0023-rtk-prefix-marks-executable-commands-only.md new file mode 100644 index 0000000..3fa666f --- /dev/null +++ b/docs/adr/0023-rtk-prefix-marks-executable-commands-only.md @@ -0,0 +1,168 @@ +# The `rtk` prefix marks executable commands only, and is repo-wide + +**Status: accepted (2026-09-08).** + +## Context + +`CLAUDE.md` states the org convention as a golden rule: "Always prefix commands with `rtk`. If RTK +has a dedicated filter, it uses it. If not, it passes through unchanged. This means RTK is always +safe to use." Issue #113 observed that the rule had never been written down for skill *prose*, where +a `git ` mention can be either an instruction to execute or a reference to the concept, +and that the corpus had drifted into carrying both spellings with no stated rule. PR #130 swept the +`git` plugin and recorded a two-way split in `plugins/git/README.md`. + +Review found two defects in that sweep, and both are in the premise rather than the execution. + +**RTK is not output-transparent.** `rtk git --help` enumerates twelve filtered subcommands — `diff`, +`log`, `status`, `show`, `add`, `commit`, `push`, `pull`, `branch`, `fetch`, `stash`, `worktree`. +Everything else is a true passthrough. Inside that set the filter is not a formatting preference; it +changes what the command *reports*. Measured against rtk 0.42.4: + +| Command | What rtk does to it | +|---|---| +| `worktree list --porcelain -z` | discards both flags; no NUL separators, no `locked`/`lock_reason` field at all | +| `worktree list -v` | abbreviates `/root/…` to `~/…`, collapses column alignment | +| `branch --list ` | emits a phantom `* ` line even on no match | +| `diff --name-only` / `--name-status` | appends a blank line and a `Changes:` trailer | +| `diff --word-diff[=color\|=porcelain]` | emits no `[-removed-] {+added+}` markers; substitutes a diffstat | +| `log -L` | truncates each diff body line at ~72 characters with an ellipsis | +| `stash pop` (on conflict) | prints only `FAILED: git stash pop`, swallowing `CONFLICT`, `Unmerged paths` and the retained-entry notice | +| `stash list` (empty) | prints `No stashes` where git prints nothing | + +Every one of those falsified a skill that was written against the bare output. `git-worktrees`'s +Step 2 required `locked` and `lock_reason` from a command whose rtk rendering has never carried +them; `git-log-format.md` documented `[-removed-] {+added+}` markers beside a command that no longer +produces them. The two-way split could not see any of this, because both halves of it are about what +a *sentence* is doing and none of it is about what the *command* does. + +**The rule is not `git`-plugin-scoped.** `plugins/git/README.md` claimed the `gitea-*` skills +"contain no `git`/`rtk` mentions at all". Five `gitea-*` SKILL.md files run `git remote get-url +origin` in a fenced ```bash Step block — the README's own canonical example of "executable, +instructed" — plus `git branch --show-current` in a reference file and three `git remote -v` in +`gitea-orchestrate.agent.md`. A convention stated inside one plugin's README is invisible from the +plugin next door, which is how those eight sites stayed bare through the sweep that existed to find +them. + +## Decision + +**One rule, three clauses, repo-wide** — every `plugins/*/.apm/skills/**` and +`plugins/*/.apm/agents/**` file, not the `git` plugin alone. + +1. **Executable and instructed → `rtk git`.** Anything telling the agent to run a command now: an + imperative step, a dispatch-table "Run" cell, a fenced code-block procedure. + `rtk git push -u origin `. +2. **Illustrative or referential → bare `git`.** Naming a flag's behaviour, quoting a doc's own + heading, describing a command in the abstract, warning against an anti-pattern. "`git switch` + refuses rather than clobbering conflicting local edits." +3. **Machine-parsed or interactive → bare `git`, and say why inline.** A command whose output the + skill parses, where rtk is in the filtered set above; or a command that hands control to an + interactive child process. + +Clause 3 is the new one and it looks arbitrary without the table in Context, which is why the +measurements are recorded here rather than left in a PR thread. It is applied per subcommand and per +flag, not per skill: `tag --list` stays prefixed because rtk passes it through byte-identically, +while `branch --list` two words away goes bare because it does not. `git remote get-url origin`, +`git remote -v`, `git branch --show-current`, `git log --oneline -1` and `git add -u` were all +re-measured as byte-identical passthroughs and are therefore prefixed, parsing notwithstanding. + +Two consequences of that per-subcommand basis are worth stating, because both are load-bearing and +neither is comfortable: + +- **rtk's filtered set is a moving target.** `git rebase` and `git mergetool` are passthroughs on + 0.42.4 — verified under `script(1)`, both inherit a real TTY, contradicting an earlier report that + they did not. They stay bare anyway, on the interactive limb: a token filter has nothing to offer a + command that hands control to an editor, and the prefix would only buy exposure to whatever a later + rtk version decides to do with those subcommands. The same reasoning makes the *inner* call in + `` `rtk git remote add origin-push $(git config remote.origin.url)` `` bare while the outer stays + prefixed — `config` passes through cleanly today, but its stdout becomes a remote URL that is then + force-pushed to, and that is not a blast radius to lend to a future filter change. +- **`branch --show-current` sits on the sharp edge.** It is in the filtered set, it is parsed, and it + is prefixed — on a measurement, in a subcommand whose sibling `--list` is exactly the defect clause + 3 exists for. If rtk's `branch` filter is ever extended, that is the first site to break. It is + called out rather than hedged, because a rule whose exceptions are unrecorded is the state this ADR + is replacing. + +**A clause-3 site says so inline, in a few words.** "bare, not `rtk`: rtk prints a phantom `* ` line +even on no match". Without it the next sweep re-prefixes the command, which is how #113 recurs. + +**The rule lives here, and `docs/spec/gates.md` carries the gate.** `plugins/git/README.md` is +reduced to a pointer. It had also cited `git-workflow/references/hard-rules.md` as a place the rule +was written down; that file contains no occurrence of "rtk", and the citation is removed rather than +repaired. + +**Clause 1 is enforced by a `check-rtk-prefix` pre-commit hook; clauses 2 and 3 are not enforceable +and are not gated.** The hook checks the two places a `git` mention is unambiguously an instruction — +a line in a shell-tagged code fence, and the opening backticked span of a "Run" column cell — and a +deliberately-bare command opts out with the literal string `ADR-0023` on its own line. Its coverage +limits are recorded in `docs/spec/gates.md`, not smoothed over. + +## Considered options + +**Add `compatibility:` frontmatter to every skill.** These six plugins are installable by third +parties, and a consumer who installs `git` from the marketplace has no `rtk` on their PATH. Every +prefixed command in the corpus is a plain `git` invocation with a word in front of it, so the prefix +is *droppable*: delete `rtk ` and the command is correct. A `compatibility:` line per skill would +state that in a machine-readable field. Rejected on cost. It is 39 lines of frontmatter restating one +sentence, it is preloaded into every agent's context every session under ADR-0020's budget — the +field is not free the way a line in a doc is — and it has no consumer: nothing reads +`compatibility:`, so the field would be a comment with a colon in it. The consumer situation is +documented here and in `plugins/git/README.md` instead, which is where a human installing a plugin +actually looks. The same two-line note is owed to the other five plugin READMEs and is not yet +written. + +**Move rtk to the execution layer entirely.** Skills instruct bare `git` throughout; `CLAUDE.md`'s +session rule handles prefixing at the point of execution. This is the strongest rejected option and +it deserves the space: it closes the consumer gap and all eight output defects at once, because the +executing agent knows what it is about to parse and the skill does not have to predict it. It also +removes clause 3 entirely — there is nothing to except. Rejected because the prefix is lost wherever +an agent copies a command literally, which is the common case for a fenced procedure block and the +whole reason dispatch tables exist. The org convention's value is that the prefix is *already there* +in the text the agent lifts; a rule that relies on the agent remembering to add it is the rule that +produced the drift in the first place. Worth revisiting if rtk ever ships a shell shim, which would +make the execution layer transparent and this trade different. + +**Keep the two-way split and fix the eight sites by hand.** Rejected: the split has no vocabulary for +"this command is executable, instructed, and must still be bare", so the eight sites would be +unexplained exceptions and the next sweep re-prefixes them. That is the failure this ADR exists to +stop, not a smaller version of it. + +**Gate clauses 2 and 3 as well.** Rejected as undecidable. "Run `git switch `" and "`git +switch` refuses rather than clobbering local edits" are the same token sequence; separating them is a +judgement about what a sentence is doing. A gate that guessed would fire on correct content, and a +gate that fires on correct content gets added to `SKIP`, which disarms clause 1 along with it. + +## The boundary the rule does not decide + +Two shapes in the corpus resisted the two-way split. The three-clause rule resolves one and does not +resolve the other; both are recorded so an author meeting a third one knows which kind it is. + +**`git-worktrees/SKILL.md`'s tracking row carries both spellings in one Run cell** — `rtk git +worktree add --track -b /` — always correct. `git worktree add + ` expands to exactly this. **Resolved: the clauses apply per mention, not per row, +per cell or per file.** The first is the instruction (clause 1), the second names what the first +expands to (clause 2), and one table cell can hold one of each. The rule needed no change; the +*gate* did, and it checks only a Run cell's opening span for exactly this reason. + +**`git-submodules/references/setup-and-update.md:80` has a git command inside a quoted argument to +another command** — `rtk git submodule foreach 'git pull origin main || :'`. **Not resolved: all +three clauses describe a command the reading agent executes, and the inner `git pull` is not one.** +It is the literal text of an argument that `git submodule foreach` hands to a subshell running inside +each submodule's own working tree, where the local convention does not reach. The file already gets +this right and already justifies it in prose two lines below ("the git calls in it are the +submodule's own — that is the one place a bare `git` is correct"). **An author meeting this shape +should do the same: leave the inner command bare and justify it inline.** It is deliberately not +promoted to a fourth clause on one instance. The gate does not decide it either — it happens to pass +this line, because the segment containing the inner command begins with `rtk`, and that is an +accident of the split rather than an understanding of quoting. + +## Consequences + +Eleven sites in `plugins/git/.apm/skills/**` revert to bare `git` under clause 3, each carrying a +short inline reason. Eight sites across `plugins/gitea/.apm/skills/**` and +`plugins/gitea/.apm/agents/gitea-orchestrate.agent.md` gain the prefix under clause 1, and one in +`pc-run/SKILL.md` that the #130 sweep's grep missed because the backtick opens with `SKIP=` rather +than `git `. `plugins/git/README.md`'s Conventions section becomes a pointer here, minus a paragraph +that was false about the `gitea-*` skills and a citation to a file that does not carry the rule. +A `check-rtk-prefix` pre-commit hook and `tests/test-check-rtk-prefix.sh` land with it; the test runs +the gate against the pre-sweep corpus on `main` and asserts it fails there, because a gate that only +passes on the fixed tree proves nothing about the drift it was written for. diff --git a/docs/spec/gates.md b/docs/spec/gates.md index 7280c1b..f16c0f8 100644 --- a/docs/spec/gates.md +++ b/docs/spec/gates.md @@ -561,6 +561,91 @@ pre-commit run --all-files # size AND Vale Scoping a retrofit off `skill-size-check` output alone leaves you blocked at the second gate. +## The `rtk` prefix gate (ADR-0023) + +`check-rtk-prefix` is a `repo: local` pre-commit hook running `scripts/check-rtk-prefix.sh` over +`^plugins/[^/]+/\.apm/(skills/.*\.md|agents/.*\.agent\.md)$`, with `README.md` excluded. It enforces +**ADR-0023 clause 1 and nothing else**: an executable, instructed local git command in plugin skill +or agent content is written `rtk git`. + +It is wider in file scope than the ADR-0020 hooks — every markdown file under a plugin's +`.apm/skills/` and `.apm/agents/`, not `SKILL.md` alone — because the rule it enforces is about +commands an agent runs, and most of those live in `references/`, which the ADR-0020 gates do not +reach ([the `references/` blind spot](#the-blind-spot-references-is-unlinted-for-two-independent-reasons)). + +### What it can decide, and what it declines to + +ADR-0023 has three clauses and only the first is a pattern: + +| Clause | Rule | Gated | +|---|---|---| +| 1 | executable + instructed → `rtk git` | yes | +| 2 | illustrative / referential → bare `git` | no — undecidable | +| 3 | machine-parsed or interactive → bare `git` | no — opt-out marker | + +Clause 2 is a judgement about what a sentence is *doing*. "Run `git switch `" and "`git +switch` refuses rather than clobbering local edits" are the same token sequence. A gate that guessed +would fire on correct prose, and **a gate that fires on correct content gets added to `SKIP`** — +which disarms clause 1 along with it. So the hook looks only at the two contexts where a `git` +mention is unambiguously an instruction to execute: + +- a line inside a fenced code block whose info string names a shell — `bash`, `sh`, `shell`, `zsh`, + `console`, `shell-session`. Fences tagged `text`, `yaml`, `json`, or tagged with nothing, are **not** + checked; +- the **opening** backticked span of a "Run" column cell in a markdown dispatch table, and only the + opening span. + +That last narrowing is not fussiness. A Run cell routinely carries a command followed by prose about +it, and the prose is clause 2. `git-worktrees/SKILL.md` has both shapes on adjacent rows — one cell +reading `` `rtk git worktree add --track …` `` — always correct. `` `git worktree add +` `` expands to exactly this (instruction, then reference), and a `**Never** …` row whose Run +cell is entirely explanation containing a bare `git push`. Checking every backticked span flags both; +checking only a leading span flags neither, and still catches the ordinary +`` | List | `git worktree list -v` | `` case the gate exists for. + +### The clause-3 opt-out + +A command that is deliberately bare — because rtk rewrites the output the skill parses, or because +the command is interactive — is exempted by putting the literal string `ADR-0023` **on the same +line**: in a shell comment for a code line, in the cell text for a table row. + +Per line, never per block. A fenced procedure routinely mixes `rtk git` steps with one deliberately +bare command (`git-remotes/references/push.md` does exactly that), and a block-level marker would +silently disarm every checked line around the marked one. The cost is a repeated `# bare per +ADR-0023` in the three blocks of `git-log-format.md` where every line is deliberately bare; that +repetition is the price of the marked line being the only line the marker speaks for. + +The marker is a plain substring match, so a line that mentions `ADR-0023` for an unrelated reason is +also exempt. Accepted deliberately: the marker records an author's opt-out, it is not a security +boundary, and a stricter form would only move the same trust to a different string. + +### What it deliberately does not cover + +- **Clause 2.** Nothing checks that an illustrative mention stayed bare. A sweep that re-prefixes a + referential `git` passes this gate. The inline reasons ADR-0023 requires on clause-3 sites are the + only defence, and they are prose. +- **Prose bullets.** Most of `branch-operations.md`, `merging.md` and `rewrite-history.md` instruct + in list items, not fences. Those are clause-1 sites the gate cannot see, because it cannot + distinguish them from clause-2 mentions in the same list. +- **`README.md`, excluded by pattern.** A skill-directory README is consumer-facing prose no agent + loads, and the `git clone https://github.com/bats-core/…` lines in the seven `tests/README.md` + files are setup instructions for a third party who has no `rtk`. Prefixing those would be actively + wrong, not merely noisy — see ADR-0023's consumer section. +- **Quoting.** The line splitter breaks on `;`, `|`, `&&`, `||`, `$(` and backticks without tracking + quotes, so a git command inside a quoted argument is decided by accident. + `rtk git submodule foreach 'git pull origin main || :'` passes because the segment holding the + inner command begins with `rtk` — the right answer for the wrong reason. Write + `foreach 'git a; git b'` and the second inner command is a false positive needing the marker. + ADR-0023 records this shape as one the rule itself does not decide. +- **Non-git commands.** Only `git` is checked. `rtk` fronts `gh`, `docker`, `kubectl` and others; no + gate covers those, and the corpus does not currently instruct them. + +`tests/test-check-rtk-prefix.sh` pins all of it, including the false-positive cases. Its first case +reconstructs the plugin corpus as it stood on `main` before the #113 sweep and asserts the gate +fails there with at least 20 findings, one of them the `gitea-*` `git remote get-url origin` drift +the sweep missed — a gate that only passes on the already-fixed tree proves nothing about the drift +it was written for. + ## Vale Install the `vale` binary — `brew install vale` (macOS), `snap install vale` (Linux), diff --git a/plugins/git/.apm/skills/git-branches/SKILL.md b/plugins/git/.apm/skills/git-branches/SKILL.md index 2bb809a..325dd96 100644 --- a/plugins/git/.apm/skills/git-branches/SKILL.md +++ b/plugins/git/.apm/skills/git-branches/SKILL.md @@ -9,7 +9,7 @@ description: > Not a Gitea remote's branches -> `gitea-branches`. metadata: - version: "1.0.0" + version: "1.0.1" category: git source_keys: - context7-git-htmldocs @@ -21,7 +21,7 @@ metadata: ## Gotchas - **Uncommitted changes abort a switch.** `git switch` refuses rather than clobbering conflicting local edits. Offer to stash and retry — forcing the checkout past it is how work disappears. -- **A branch and a tag can carry the same name.** Detect it before acting — `rtk git branch --list ` and `rtk git tag --list `; output from both means the name is ambiguous. Prefer `git switch` over `git checkout`, and where a command accepts either ref, disambiguate with `refs/heads/` or `refs/tags/`. +- **A branch and a tag can carry the same name.** Detect it before acting — `git branch --list ` (bare, not `rtk`: rtk prints a phantom `* ` line even on no match, which reports every name as ambiguous — ADR-0023) and `rtk git tag --list `; output from both means the name is ambiguous. Prefer `git switch` over `git checkout`, and where a command accepts either ref, disambiguate with `refs/heads/` or `refs/tags/`. - **`main` and `master` are a refusal, not a gate.** Force-pushing, force-deleting, or renaming them is rejected even when the caller passes `confirm: true` — no flag makes the remote's history recoverable. Offer a new branch instead. ## Step 1 — Determine the branching pattern diff --git a/plugins/git/.apm/skills/git-branches/references/branch-operations.md b/plugins/git/.apm/skills/git-branches/references/branch-operations.md index 394dd7f..b0d785c 100644 --- a/plugins/git/.apm/skills/git-branches/references/branch-operations.md +++ b/plugins/git/.apm/skills/git-branches/references/branch-operations.md @@ -43,9 +43,12 @@ past it: it shelves the working tree and index so the branch pointer can move. - **save** — `rtk git stash push -m ""`. Add `-u` to include untracked files; verified on Git 2.39.5, a plain `push` leaves them in place, and a plain `push` with *only* untracked changes reports `No local changes to save` and stashes nothing. Bare `git stash` is `push` with no message. -- **restore** — `rtk git stash pop` applies the newest entry and deletes it. `rtk git stash apply stash@{n}` +- **restore** — `git stash pop` applies the newest entry and deletes it. Bare, not `rtk`: on a + conflict rtk prints only `FAILED: git stash pop` and swallows the conflict report the paragraph + below tells you to read (ADR-0023). `rtk git stash apply stash@{n}` applies without deleting, for replaying one shelf onto more than one branch. -- **list** — `rtk git stash list`; `rtk git stash show -p stash@{n}` prints that entry's diff. +- **list** — `git stash list` — bare, not `rtk`: rtk prints `No stashes` where git prints nothing, + so an empty-output test misfires (ADR-0023). `rtk git stash show -p stash@{n}` prints that entry's diff. - **drop** — `rtk git stash drop stash@{n}` deletes one entry. `rtk git stash clear` deletes all of them and nothing recovers them — confirm before running it. - **branch from a stash** — `rtk git stash branch stash@{n}` creates a branch at the commit the diff --git a/plugins/git/.apm/skills/git-branches/references/merging.md b/plugins/git/.apm/skills/git-branches/references/merging.md index 44ec0ac..3b122c0 100644 --- a/plugins/git/.apm/skills/git-branches/references/merging.md +++ b/plugins/git/.apm/skills/git-branches/references/merging.md @@ -26,5 +26,6 @@ list the conflicted files, edit each to resolve its markers, then `rtk git add < `rtk git merge --continue`. - `rtk git merge --abort` restores the pre-merge state. -- `rtk git mergetool` opens the configured merge tool. +- `git mergetool` opens the configured merge tool — bare, not `rtk`: it hands control to an + interactive child process, and a token filter has nothing to offer there (ADR-0023). - `rtk git diff --diff-filter=U` shows only the still-conflicted files. diff --git a/plugins/git/.apm/skills/git-commits/SKILL.md b/plugins/git/.apm/skills/git-commits/SKILL.md index 59ca602..f1ec375 100644 --- a/plugins/git/.apm/skills/git-commits/SKILL.md +++ b/plugins/git/.apm/skills/git-commits/SKILL.md @@ -8,7 +8,7 @@ description: > Not branch lifecycle -> `git-branches`. metadata: - version: "0.1.3" + version: "0.1.4" category: git source_keys: - conventional-commits-spec @@ -21,7 +21,7 @@ allowed-tools: Bash ## Gotchas -- **Run git as `rtk git `, never bare `git`** — org convention, in `&&` chains too. +- **Run git as `rtk git `, never bare `git`** — org convention, in `&&` chains too. Exceptions: ADR-0023 clause 3. - **Refuse to force-push `main`/`master`** — a rewrite leaves the branch diverged and the reflex is to force it back; safe only where nobody else has based work on it. - **`reset --hard` is a confirmation gate, not a default.** It overwrites the working tree, and uncommitted edits it discards were never in git, so no reflog recovers them. Name what will be lost and offer a stash first. - **Never add `--no-verify`** — using it when a hook fails bypasses the QA gate the pipeline depends on. Only on the user's explicit demand, with a warning. diff --git a/plugins/git/.apm/skills/git-commits/references/rewrite-history.md b/plugins/git/.apm/skills/git-commits/references/rewrite-history.md index 88bc993..c86097e 100644 --- a/plugins/git/.apm/skills/git-commits/references/rewrite-history.md +++ b/plugins/git/.apm/skills/git-commits/references/rewrite-history.md @@ -21,9 +21,9 @@ Prefer this whenever a commit is written to be folded, because git does the mark 1. `rtk git commit --fixup=` keeps the target's message; `rtk git commit --squash=` lets you edit the combined message later. Both prefix the message with `fixup!`/`squash!` and name the target commit. 2. Get explicit approval — the rebase still rewrites history. -3. Run `rtk git rebase -i --autosquash HEAD~N`. Git pre-fills the todo list with the tagged commits already reordered against their targets; save it unchanged to apply. +3. Run `git rebase -i --autosquash HEAD~N` — bare, not `rtk`: `-i` opens an interactive sequence editor (ADR-0023). Git pre-fills the todo list with the tagged commits already reordered against their targets; save it unchanged to apply. -**`-i` is not optional here.** On Git 2.39.5, `rtk git rebase --autosquash HEAD~N` without `-i` prints `Successfully rebased and updated refs/heads/.` and exits 0 while leaving the `fixup!` commit in place at its original SHA — `--autosquash` is honoured only by the interactive machinery, and the false success is the trap: the fold is reported as done, and the surviving `fixup!` subject then fails the Conventional Commits `commit-msg` hook. Later Git versions taught the non-interactive rebase to honour the flag, but `-i --autosquash` is correct on every version, so always write that. +**`-i` is not optional here.** On Git 2.39.5, `git rebase --autosquash HEAD~N` without `-i` prints `Successfully rebased and updated refs/heads/.` and exits 0 while leaving the `fixup!` commit in place at its original SHA — `--autosquash` is honoured only by the interactive machinery, and the false success is the trap: the fold is reported as done, and the surviving `fixup!` subject then fails the Conventional Commits `commit-msg` hook. Later Git versions taught the non-interactive rebase to honour the flag, but `-i --autosquash` is correct on every version, so always write that. ## Squash by hand (interactive rebase) @@ -31,7 +31,7 @@ Use this when the commits were not tagged at commit time. **Interactive rebase h 1. Identify the commits to squash — typically the last N on the current branch. 2. Get explicit approval. -3. Run `rtk git rebase -i HEAD~N`, marking the older commits `squash` to keep their messages for editing, or `fixup` to discard them. +3. Run `git rebase -i HEAD~N` — bare, not `rtk`, for the same interactive-editor reason — marking the older commits `squash` to keep their messages for editing, or `fixup` to discard them. 4. Compose the combined message when the rebase stops to ask. For a non-trivial combined message, follow the structure in `references/commit-template.md`. ## When a rebase halts on a conflict diff --git a/plugins/git/.apm/skills/git-history/SKILL.md b/plugins/git/.apm/skills/git-history/SKILL.md index ae6cf27..2a7f62f 100644 --- a/plugins/git/.apm/skills/git-history/SKILL.md +++ b/plugins/git/.apm/skills/git-history/SKILL.md @@ -8,7 +8,7 @@ description: > `git-commits`. Not a Gitea server's history -> `gitea-branches`. metadata: - version: "1.0.0" + version: "1.0.1" category: git source_keys: - git-scm-bisect-docs @@ -38,7 +38,7 @@ allowed-tools: Bash Default to `rtk git log --oneline`, then narrow by whatever is known: - **Content**: `rtk git log -S"string"`, or `-G"regex"` to match any diff line. `--pickaxe-regex` makes the `-S` argument a POSIX ERE; `--pickaxe-all` shows every file in a matching changeset. -- **A line or function**: `rtk git log -L ,:` or `rtk git log -L ::`. Confirm the range resolves before reporting on it — an off-by-one silently omits the target. +- **A line or function**: `git log -L ,:` or `git log -L ::` — bare, not `rtk`: rtk truncates each diff line at ~72 characters (ADR-0023). Confirm the range resolves before reporting on it — an off-by-one silently omits the target. - **A file across renames**: `rtk git log --follow -- `. Without `--follow` the history stops at the rename boundary. - **Mainline only**: `--first-parent` follows the integration branch and skips commits merged in from side branches. - **Structured output**: `rtk git log --format="%h | %s | %an (%ar)"`. diff --git a/plugins/git/.apm/skills/git-history/references/git-log-format.md b/plugins/git/.apm/skills/git-history/references/git-log-format.md index 0b511f3..2481e09 100644 --- a/plugins/git/.apm/skills/git-history/references/git-log-format.md +++ b/plugins/git/.apm/skills/git-history/references/git-log-format.md @@ -161,11 +161,15 @@ rtk git log --diff-filter=M # only show commits with modified files Traces the evolution of a specific range of lines or a named function through commits. Implies `--patch`. +Bare `git`, not `rtk git`, on every `-L` form below: rtk truncates each diff body +line at roughly 72 characters with an ellipsis, on the one query whose whole point +is showing line content. + ```bash -rtk git log -L 10,20:file.txt -rtk git log -L /start_pattern/,/end_pattern/:file.txt -rtk git log -L :myfunction:src/app.c -rtk git log -L /init/,+15:config.py # 15 lines after first match of /init/ +git log -L 10,20:file.txt # bare per ADR-0023 +git log -L /start_pattern/,/end_pattern/:file.txt # bare per ADR-0023 +git log -L :myfunction:src/app.c # bare per ADR-0023 +git log -L /init/,+15:config.py # bare per ADR-0023; 15 lines after first /init/ match ``` Range formats: @@ -205,20 +209,26 @@ rtk git diff --numstat # machine-readable: \t\t

# define what counts as a "word" +git diff --word-diff # bare per ADR-0023; inline word-level diff, [-removed-] {+added+} markers +git diff --word-diff=color # bare per ADR-0023; color only, no markers +git diff --word-diff=porcelain # bare per ADR-0023; machine-readable: +/- prefixed lines, ~ for newlines +git diff --word-diff-regex= # bare per ADR-0023; define what counts as a "word" ``` ### Whitespace Flags diff --git a/plugins/git/.apm/skills/git-remotes/SKILL.md b/plugins/git/.apm/skills/git-remotes/SKILL.md index f88024d..181768e 100644 --- a/plugins/git/.apm/skills/git-remotes/SKILL.md +++ b/plugins/git/.apm/skills/git-remotes/SKILL.md @@ -10,7 +10,7 @@ description: > Not submodule pointers -> `git-submodules`. metadata: - version: "1.0.0" + version: "1.0.1" category: git source_keys: - git-scm-remote-docs diff --git a/plugins/git/.apm/skills/git-remotes/references/push.md b/plugins/git/.apm/skills/git-remotes/references/push.md index fecc521..8fc10fb 100644 --- a/plugins/git/.apm/skills/git-remotes/references/push.md +++ b/plugins/git/.apm/skills/git-remotes/references/push.md @@ -48,13 +48,15 @@ Two mitigations: # Option 1 — dedicated push-only remote: background tools fetch `origin`, you push # through a separate remote that nothing else touches, so its tracking ref can't be # poisoned by an unrelated fetch. -rtk git remote add origin-push $(rtk git config remote.origin.url) +# The inner `git config` is bare: its stdout becomes a remote URL, so any +# output rewriting would poison the remote silently. +rtk git remote add origin-push $(git config remote.origin.url) # inner bare per ADR-0023 rtk git push --force-with-lease origin-push # Option 2 — explicit SHA via a local tag, unaffected by tracking-branch state rtk git fetch rtk git tag base master -rtk git rebase -i master +git rebase -i master # bare, not `rtk` (ADR-0023): interactive sequence editor rtk git push --force-with-lease=master:base master:master ``` diff --git a/plugins/git/.apm/skills/git-worktrees/SKILL.md b/plugins/git/.apm/skills/git-worktrees/SKILL.md index 0e6c9c7..a72332c 100644 --- a/plugins/git/.apm/skills/git-worktrees/SKILL.md +++ b/plugins/git/.apm/skills/git-worktrees/SKILL.md @@ -8,7 +8,7 @@ description: > Not interactive multi-step git guidance -> `git-workflow`. metadata: - version: "1.0.0" + version: "1.0.1" category: git source_keys: - git-scm-worktree-docs @@ -32,7 +32,7 @@ metadata: | Create a local branch tracking a remote one | `rtk git worktree add --track -b /` — always correct. `git worktree add ` expands to exactly this, but **only** under the conditions in `references/worktrees.md` | | Throwaway experiment, no branch | `rtk git worktree add -d ` — detached HEAD | | **Never** `git worktree add /` | That ref resolves, so the shortcut never fires and you get **a detached HEAD, no branch, no upstream**. Commits there go unreachable once HEAD moves, and `git push` needs an explicit refspec. Use the tracking row above | -| List | `rtk git worktree list -v`, or `--porcelain -z` to parse | +| List | `git worktree list -v` to read, or `git worktree list --porcelain -z` to parse — both bare per ADR-0023: rtk re-renders the output and drops the porcelain flags | | Lock or unlock | `rtk git worktree lock [--reason ] ` / `rtk git worktree unlock ` | | Move | `rtk git worktree move ` | | Remove | `rtk git worktree remove ` | @@ -62,6 +62,7 @@ worktrees: lock_reason: ``` -Derive those fields from `rtk git worktree list --porcelain -z`. For a single +Derive those fields from `git worktree list --porcelain -z` — bare, not `rtk`: +rtk drops both flags and never emits `locked`/`lock_reason` (ADR-0023). For a single operation, report its outcome instead — `created: true`, `moved: true`, `removed: true`. diff --git a/plugins/git/.apm/skills/pc-run/SKILL.md b/plugins/git/.apm/skills/pc-run/SKILL.md index c81484a..2e58fa5 100644 --- a/plugins/git/.apm/skills/pc-run/SKILL.md +++ b/plugins/git/.apm/skills/pc-run/SKILL.md @@ -8,7 +8,7 @@ description: > compatibility: Requires pre-commit installed and available on PATH. metadata: - version: "1.0.0" + version: "1.0.1" category: devtools source_keys: - context7-pre-commit-com @@ -19,7 +19,7 @@ allowed-tools: Bash Read ## Gotchas -- The `SKIP` env var takes exact hook `id` values, comma-separated with no spaces: `SKIP=check-yaml,gitleaks git commit -m "msg"`. A space after a comma silently skips nothing instead of erroring. +- The `SKIP` env var takes exact hook `id` values, comma-separated with no spaces: `SKIP=check-yaml,gitleaks rtk git commit -m "msg"`. A space after a comma silently skips nothing instead of erroring. - Never bypass a failing hook with `git commit --no-verify` (or `-n`). Hooks are the automated QA gate, so a bypassed commit pushes the failure downstream where it costs more — diagnose it instead. - `- files were modified by this hook` is not a bug. A fixer hook rewrote a staged file, so the staged snapshot is stale and the commit is blocked on purpose. Re-stage and re-run the same commit: `rtk git add -u && rtk git commit`. Do NOT reach for `pre-commit install -f` here — it overwrites `.git/hooks/` and has nothing to do with re-staging. diff --git a/plugins/git/README.md b/plugins/git/README.md index 54a7d53..8f7a2a3 100644 --- a/plugins/git/README.md +++ b/plugins/git/README.md @@ -30,12 +30,9 @@ copilot plugin install ./plugins/git ## Conventions -Every skill in this plugin runs local git commands through the org's `rtk` wrapper, never bare `git` — see `git-commits/SKILL.md`'s Gotchas and `git-workflow/references/hard-rules.md`. Issue #113 made that rule precise for skill *prose*, where a `git ` mention can be either an instruction to execute or just a reference to the concept: +Skills here run local git commands through the org's `rtk` wrapper. **When a command is prefixed, when it stays bare, and why some executable commands must stay bare are all decided by ADR-0023** (`docs/adr/0023-rtk-prefix-marks-executable-commands-only.md`), which is repo-wide and not specific to this plugin. `check-rtk-prefix` enforces the part of it that is machine-decidable. -- **Executable, instructed commands** — anything telling the agent to run a command right now (an imperative step, a dispatch-table "Run" cell, a fenced code-block procedure) — use `rtk git`. Example: `rtk git push -u origin `. -- **Illustrative or referential mentions** — naming a flag's behavior, quoting a doc's own heading, describing what a command does in the abstract, or warning against an anti-pattern — stay bare `git`. Example: "`git switch` refuses rather than clobbering conflicting local edits." - -This applies within this plugin's own skill files (`git-*`, `pc-*`) — it does not generalize to other plugins. The `gitea-*` skills, for instance, talk to a remote Gitea server through the `gitea` MCP tools and contain no `git`/`rtk` mentions at all; the distinction has nothing to hold onto there. +Installing this plugin without `rtk`? Every prefixed command is a plain `git` invocation with a word in front of it — drop the `rtk ` and it is correct. ## Contents diff --git a/plugins/gitea/.apm/agents/gitea-orchestrate.agent.md b/plugins/gitea/.apm/agents/gitea-orchestrate.agent.md index 50e3493..92223ef 100644 --- a/plugins/gitea/.apm/agents/gitea-orchestrate.agent.md +++ b/plugins/gitea/.apm/agents/gitea-orchestrate.agent.md @@ -14,7 +14,7 @@ disallowedTools: Edit, Write, NotebookEdit You are the orchestrator for the gitea plugin — a composable workflow dispatcher designed for other agents to invoke multi-step Gitea operations reliably. Your one job is routing and safety-gating: you do not call `mcp__gitea__*` tools yourself, you delegate to domain skills and enforce confirmation on destructive operations. You never edit files. Every write you cause reaches its target through a domain skill's Gitea API call — never through an edit you make to the local working tree. -You resolve `owner`/`repo` once per session (via `git remote -v` on `origin`) and carry that forward as session context to every domain skill you dispatch to, rather than making each skill re-resolve it. +You resolve `owner`/`repo` once per session (via `rtk git remote -v` on `origin`) and carry that forward as session context to every domain skill you dispatch to, rather than making each skill re-resolve it. **Scope:** this orchestrator routes Gitea-object operations across the six domain skills only: `gitea-issues`, `gitea-labels-milestones`, `gitea-prs`, `gitea-branches`, `gitea-files`, `gitea-releases`. `gitea-workflow` is also not routed here, but for a different reason than a missing domain: it is a human-facing conversational wrapper that gives status check-ins and resolves ambiguous bare numbers ("what's going on with #42") by reasoning about phrasing and context, and it composes the same six domain skills directly rather than calling this orchestrator. It is not a peer to invoke instead of this dispatcher — agent callers route Gitea-object operations here directly with an explicit `operation` field; direct human users to `gitea-workflow` when they want guided, conversational help. Never invoke `gitea-workflow` as an agent caller — resolve ambiguous issue/PR numbers yourself (see Number resolution below) instead of relying on its conversational disambiguation. @@ -30,7 +30,7 @@ These are non-negotiable regardless of `confirm` or any skill-local override: - Issues and PRs share one number space. Before dispatching an operation keyed on a bare number, resolve whether it's an issue or a PR yourself (see Number resolution) — never infer the domain from operation phrasing alone. - `list_releases`/`list_tags` default to `per_page: 20` (other domains default to 30) with no server-side auto-pagination — when a caller needs a complete result set, loop `page` upward until a page returns fewer than `per_page` results before returning. - Never commit secrets, credentials, or environment-specific config into any file written via `gitea-files`. -- You are read-only against the local working tree. Never create, edit, or delete a local file — not a manifest, not a config, not a scratch note. Local state is the caller's, and you only read it (e.g. `git remote -v`) to resolve context. +- You are read-only against the local working tree. Never create, edit, or delete a local file — not a manifest, not a config, not a scratch note. Local state is the caller's, and you only read it (e.g. `rtk git remote -v`) to resolve context. ### Number resolution @@ -68,7 +68,7 @@ When invoked, you: 1. Validate the request structure and check if `operation` is known 2. Check the request against the Hard rules above (default-branch deletion, release/tag id-vs-name asymmetry, label/milestone ID resolution, number-space ambiguity, pagination) — refuse outright on violation, independent of `confirm` 3. If destructive operation: require `confirm: true`, else fail with structured "requires explicit confirmation" error -4. Resolve `owner`/`repo` via `git remote -v` on `origin` if not already present in `context`, and reuse the resolution for the remainder of the request +4. Resolve `owner`/`repo` via `rtk git remote -v` on `origin` if not already present in `context`, and reuse the resolution for the remainder of the request 5. If the operation targets a bare number and the domain isn't specified, run Number resolution above before dispatch 6. Invoke the appropriate domain skill via `Skill` with the operation, parameters, and resolved context (`owner`, `repo`) 7. Catch and handle Gitea errors: disambiguate 404s (not-found vs. permission-hidden), retry transient failures, loop pagination for `list_releases`/`list_tags` until exhausted diff --git a/plugins/gitea/.apm/skills/gitea-branches/SKILL.md b/plugins/gitea/.apm/skills/gitea-branches/SKILL.md index a0a3a0d..47c113a 100644 --- a/plugins/gitea/.apm/skills/gitea-branches/SKILL.md +++ b/plugins/gitea/.apm/skills/gitea-branches/SKILL.md @@ -12,7 +12,7 @@ compatibility: Requires Gitea MCP server configured with a token with write:repo metadata: category: integration - version: "0.1.2" + version: "0.1.3" source_keys: - gitea-mcp-repo - gitea-mcp-slim-go @@ -32,7 +32,7 @@ allowed-tools: Bash mcp__gitea__list_branches mcp__gitea__create_branch mcp__git Before any tool call, extract `owner` and `repo` from the git remote: ```bash -git remote get-url origin +rtk git remote get-url origin ``` `get_me` and `list_my_repos` are blocked under the token scope this skill assumes, so the remote is the only source. If origin is not set or the URL is not a Gitea URL, stop and report: "No Gitea remote found — set origin to your Gitea instance URL." diff --git a/plugins/gitea/.apm/skills/gitea-branches/references/branches.md b/plugins/gitea/.apm/skills/gitea-branches/references/branches.md index d15cb8a..d4643a3 100644 --- a/plugins/gitea/.apm/skills/gitea-branches/references/branches.md +++ b/plugins/gitea/.apm/skills/gitea-branches/references/branches.md @@ -48,7 +48,7 @@ create_branch owner: repo: branch: old_branch: `rtk git` <- this hook +# 2. illustrative/referential -> bare `git` <- undecidable, not gated +# 3. machine-parsed or interactive -> bare `git`, marked <- opt-out, below +# +# Clause 2 is a judgement about what a sentence is doing, not a pattern. "`git +# switch` refuses rather than clobbering conflicting local edits" and "run `git +# switch `" are the same token sequence in prose. A gate that guessed +# would fire on every doc paragraph in the corpus, and a gate that fires on +# correct content gets disabled. So this hook looks only at the two places where +# a `git` mention is unambiguously an instruction to execute: +# +# (a) a line inside a fenced code block whose info string names a shell +# (bash / sh / shell / zsh / console); +# (b) the OPENING backticked span of a "Run" column cell in a markdown +# dispatch table -- and only the opening span. +# +# (b) is that narrow because a Run cell routinely carries a command followed by +# prose about it, and that prose is clause 2. git-worktrees/SKILL.md has both +# shapes on adjacent rows: a cell reading `rtk git worktree add --track ...` -- +# always correct. `git worktree add ` expands to exactly this +# (instruction first, reference second), and a `**Never** ...` row whose Run cell +# is entirely explanatory prose containing a bare `git push`. Checking every span +# flags both; checking only a leading span flags neither, and still catches the +# ordinary `| List | `git worktree list -v` |` case this gate exists for. +# +# Prose bullets, prose-leading Run cells, table cells outside a Run column, and +# fences tagged `text`, `yaml`, `json` etc. are NOT checked. That is a real +# coverage gap, recorded in docs/spec/gates.md rather than papered over. +# +# CLAUSE-3 OPT-OUT. A site that is deliberately bare because rtk rewrites the +# output the skill parses, or because the command is interactive, is exempted by +# putting the literal string `ADR-0023` on the SAME LINE — in a shell comment for +# a code line, in the cell text for a table row. Per-line, never per-block: a +# fenced block routinely mixes `rtk git` steps with one deliberately-bare +# command (references/push.md does exactly that), and a block-level marker would +# silently disarm the checked lines around the marked one. +# +# The marker is a bare substring match, so a line that merely *mentions* +# ADR-0023 for an unrelated reason is also exempt. That is accepted: the marker +# is an author's deliberate opt-out, not a security boundary, and a stricter +# form would only move the same trust to a different string. + +if [[ $# -eq 0 ]]; then + exit 0 +fi + +if ! command -v python3 > /dev/null 2>&1; then + echo "ERROR: python3 is required for the ADR-0023 rtk-prefix gate but was not found on PATH." >&2 + echo " Fix: install python3 (pre-commit itself is a Python application, so it is almost certainly already present)." >&2 + exit 1 +fi + +# No PyYAML here, unlike skill-size-check.sh: this gate never reads frontmatter, +# only the markdown body, so it has no folded scalar to measure. +exec python3 -u - "$@" <<'PY' +import re +import sys + +MARKER = "ADR-0023" +SHELL_INFO = {"bash", "sh", "shell", "zsh", "console", "shell-session"} + +FENCE_OPEN = re.compile(r"^\s*(?P`{3,}|~{3,})\s*(?P[^\s`]*)") +ASSIGNMENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=\S*\s+") +# Shell separators that begin a fresh command word. +SPLIT = re.compile(r"(?:\|\||&&|[;|&\n]|\$\(|`|\()") +BACKTICKED = re.compile(r"`([^`]+)`") + + +def strip_shell_comment(line: str) -> str: + """Drop a trailing `#` comment, ignoring `#` inside single/double quotes.""" + out = [] + quote = None + prev = "" + for ch in line: + if quote: + if ch == quote and prev != "\\": + quote = None + elif ch in "'\"": + quote = ch + elif ch == "#" and (not out or out[-1].isspace()): + break + out.append(ch) + prev = ch + return "".join(out) + + +def bare_git_in_shell(code: str) -> bool: + for segment in SPLIT.split(code): + seg = segment.lstrip() + if seg.startswith("$ "): # a copied prompt + seg = seg[2:].lstrip() + while True: # VAR=x VAR2=y git ... + m = ASSIGNMENT.match(seg) + if not m: + break + seg = seg[m.end():] + if re.match(r"git(\s|$)", seg): + return True + return False + + +def run_column(header: str): + """Index of the 'Run' column in a markdown header row, or None.""" + cells = [c.strip().strip("`*_ ").lower() for c in header.strip().strip("|").split("|")] + return cells.index("run") if "run" in cells else None + + +def check(path: str): + problems = [] + try: + lines = open(path, encoding="utf-8").read().splitlines() + except (OSError, UnicodeDecodeError) as exc: + # Unreadable is an error, never a silent pass. + return [(0, f"could not read file: {exc}")] + + fence = None # closing marker of the open fence, or None + fence_is_shell = False + run_col = None # active Run-column index, or None + pending_header = None + + for n, raw in enumerate(lines, start=1): + if fence is not None: + if re.match(r"^\s*" + re.escape(fence) + r"\s*$", raw): + fence, fence_is_shell = None, False + continue + if fence_is_shell and MARKER not in raw: + if bare_git_in_shell(strip_shell_comment(raw)): + problems.append((n, raw.strip())) + continue + + m = FENCE_OPEN.match(raw) + if m: + fence = m.group("f") + fence_is_shell = m.group("info").lower() in SHELL_INFO + run_col, pending_header = None, None + continue + + stripped = raw.strip() + if not stripped.startswith("|"): + run_col, pending_header = None, None + continue + + # A markdown table: header row, delimiter row, then data rows. + if run_col is None: + if pending_header is not None and set(stripped) <= set("|-: "): + run_col = run_column(pending_header) + pending_header = None + else: + pending_header = stripped + run_col = None + continue + + if MARKER in raw: + continue + cells = stripped.strip("|").split("|") + if run_col >= len(cells): + continue + cell = cells[run_col].strip() + # Only a cell that OPENS with a backticked command is a dispatch entry. + # A cell opening with prose is explanation, and explanation is clause 2. + if not cell.startswith("`"): + continue + opening = BACKTICKED.match(cell) + if opening and re.match(r"git(\s|$)", opening.group(1).strip()): + problems.append((n, opening.group(1).strip())) + + return problems + + +failed = False +for path in sys.argv[1:]: + for line_no, text in check(path): + failed = True + print( + f"ADR-0023: {path}:{line_no}: executable git command is not prefixed with `rtk`: {text}", + file=sys.stderr, + ) + +if failed: + print("", file=sys.stderr) + print( + "Fix: write `rtk git ` (ADR-0023 clause 1). If this command must stay bare\n" + "because rtk rewrites output the skill parses, or because it is interactive (clause 3),\n" + "say so inline and put the literal string ADR-0023 on the same line to record the opt-out.", + file=sys.stderr, + ) + sys.exit(1) +PY diff --git a/tests/test-check-rtk-prefix.sh b/tests/test-check-rtk-prefix.sh new file mode 100644 index 0000000..580bc4f --- /dev/null +++ b/tests/test-check-rtk-prefix.sh @@ -0,0 +1,224 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Tests for scripts/check-rtk-prefix.sh — ADR-0023 clause 1. +# +# Case 1 is the one that earns the rest: it runs the gate against the corpus as +# it stood on `main` BEFORE the #113 sweep, and asserts it fails there. A gate +# that only passes on the already-fixed tree proves nothing about whether it +# would have caught the drift it was written for. +# +# Everything after that is synthetic. The false-NEGATIVE cases (a bare command +# the gate must catch) and the false-POSITIVE cases (correct content the gate +# must leave alone) carry equal weight: this hook's failure mode is not missing +# a violation, it is firing on deliberately-bare clause-2 and clause-3 content +# until someone adds it to SKIP. + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SCRIPT="$REPO_ROOT/scripts/check-rtk-prefix.sh" +PASS=0 +FAIL=0 + +pass() { echo " PASS: $1"; PASS=$((PASS + 1)); } +fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); } + +if ! command -v python3 >/dev/null 2>&1; then + echo "SKIP: python3 is not installed — the script under test fails closed on it, so every case here would only re-assert the missing-dependency guard" + exit 77 +fi + +RUN_TMP="$(mktemp -d)" +trap 'rm -rf "$RUN_TMP"' EXIT + +# Writes $2 to a .md file and asserts the gate's verdict. $1 is "clean" or +# "dirty"; $3 is the case description. +expect() { + local want="$1" body="$2" desc="$3" + local f="$RUN_TMP/case.md" + printf '%s\n' "$body" > "$f" + local rc=0 + bash "$SCRIPT" "$f" > "$RUN_TMP/out" 2>&1 || rc=$? + if [[ "$want" == "dirty" && $rc -eq 0 ]]; then + fail "$desc — expected a violation, exited 0" + elif [[ "$want" == "clean" && $rc -ne 0 ]]; then + fail "$desc — expected no violation, exited $rc" + sed 's/^/ /' "$RUN_TMP/out" + else + pass "$desc" + fi +} + +echo "--- the pre-#113 corpus on main trips the gate ---" + +# Derived from git, not hardcoded: the point is "the drift this gate exists for", +# and a hardcoded path list goes stale the moment a file is renamed. +if ! git -C "$REPO_ROOT" rev-parse --verify -q main >/dev/null; then + echo "SKIP: no local 'main' ref — the historical-corpus case cannot be reconstructed" + exit 77 +fi + +PRE="$RUN_TMP/pre" +# A read loop, not the bash-4 array builtin: tests/test-vale-wrap.sh scans every +# script under tests/ for bash-4-only constructs, because these run on macOS's +# bash 3.2. Same reason for the `[@]+` guards on every array expansion below. +PRE_FILES=() +PRE_PATHS=() +while IFS= read -r PRE_REL; do + PRE_FILES+=("$PRE_REL") + PRE_PATHS+=("$PRE/$PRE_REL") +done < <( + git -C "$REPO_ROOT" ls-tree -r --name-only main -- 'plugins' \ + | grep -E '^plugins/[^/]+/\.apm/(skills/.*\.md|agents/.*\.agent\.md)$' \ + | grep -v '/README\.md$' +) +if [[ ${#PRE_FILES[@]} -eq 0 ]]; then + fail "no plugin skill/agent files found on main — the historical case checked nothing" +else + for f in ${PRE_FILES[@]+"${PRE_FILES[@]}"}; do + mkdir -p "$PRE/$(dirname "$f")" + git -C "$REPO_ROOT" show "main:$f" > "$PRE/$f" + done + if bash "$SCRIPT" ${PRE_PATHS[@]+"${PRE_PATHS[@]}"} > "$RUN_TMP/pre.out" 2>&1; then + fail "the pre-sweep corpus passed — the gate would not have caught the #113 drift" + else + hits="$(grep -c '^ADR-0023: ' "$RUN_TMP/pre.out" || true)" + if [[ "$hits" -lt 20 ]]; then + fail "the pre-sweep corpus produced only $hits findings — too few to be the known drift" + elif ! grep -q 'gitea-issues/SKILL.md.*git remote get-url origin' "$RUN_TMP/pre.out"; then + fail "the pre-sweep corpus failed, but not on the known gitea drift" + sed 's/^/ /' "$RUN_TMP/pre.out" | head -5 + else + pass "the pre-sweep corpus fails with $hits findings, including the gitea sweep gap" + fi + fi +fi + +echo "" +echo "--- shell fences: what must fail ---" + +expect dirty '```bash +git commit -m "x" +```' "a bare git command in a bash fence" + +expect dirty '```sh +rtk git add -u && git commit -m "x" +```' "a bare git after && on a line that starts with rtk git" + +expect dirty '```bash +SKIP=check-yaml git commit -m "x" +```' "a bare git behind an environment-variable prefix" + +expect dirty '```bash +url=$(git config remote.origin.url) +```' "a bare git inside a command substitution" + +expect dirty '```console +$ git status +```' "a bare git behind a copied shell prompt" + +echo "" +echo "--- shell fences: what must NOT fail ---" + +expect clean '```bash +rtk git commit -m "x" +rtk git push +```' "prefixed commands" + +expect clean '```bash +git stash list # bare per ADR-0023: rtk prints "No stashes" where git prints nothing +```' "a bare command carrying the ADR-0023 opt-out marker" + +expect clean '```text +git worktree list --porcelain -z +```' "a non-shell fence (text) is out of scope" + +expect clean '```yaml +entry: git +```' "a yaml fence is out of scope" + +expect clean '```bash +# never reach for git commit --no-verify here +rtk git commit +```' "a bare git inside a shell comment" + +expect clean 'Run `git switch ` — no wait, this is prose, not a fence.' \ + "a bare git in a prose line outside any fence" + +echo "" +echo "--- dispatch tables ---" + +expect dirty '| Operation | Run | +|---|---| +| List | `git worktree list -v` |' "a bare command opening a Run cell" + +expect clean '| Operation | Run | +|---|---| +| List | `rtk git worktree list -v` |' "a prefixed command in a Run cell" + +expect clean '| Operation | Run | +|---|---| +| List | `git worktree list -v` — bare per ADR-0023 |' \ + "a bare Run cell carrying the opt-out marker" + +# The clause-2 shapes that made an every-span check unusable. Both are real rows +# from git-worktrees/SKILL.md. +expect clean '| Operation | Run | +|---|---| +| Track | `rtk git worktree add --track -b

/` — always correct. `git worktree add

` expands to exactly this |' \ + "a referential bare mention AFTER the instructed command in a Run cell" + +expect clean '| Operation | Run | +|---|---| +| **Never** `git worktree add

/` | That ref resolves, so `git push` needs an explicit refspec |' \ + "an anti-pattern row whose Run cell opens with prose" + +expect clean '| Flag | Meaning | +|---|---| +| `-L` | as in `git log -L` |' "a table with no Run column is out of scope" + +echo "" +echo "--- degenerate inputs ---" + +expect clean '' "an empty file" + +expect clean '```bash +rtk git status' "an unterminated fence does not crash the parser" + +if bash "$SCRIPT" > "$RUN_TMP/noargs.out" 2>&1; then + pass "no filenames exits 0 rather than erroring" +else + fail "no filenames should exit 0 — pre-commit calls hooks with an empty file list" +fi + +if bash "$SCRIPT" "$RUN_TMP/does-not-exist.md" > "$RUN_TMP/missing.out" 2>&1; then + fail "an unreadable file exited 0 — unreadable must be an error, never a silent pass" +elif ! grep -q 'could not read file' "$RUN_TMP/missing.out"; then + fail "an unreadable file failed for the wrong reason" + sed 's/^/ /' "$RUN_TMP/missing.out" +else + pass "an unreadable file exits 1 and says so" +fi + +echo "" +echo "--- the live corpus is clean ---" + +LIVE=() +while IFS= read -r LIVE_REL; do + LIVE+=("$LIVE_REL") +done < <( + git -C "$REPO_ROOT" ls-files -- 'plugins' \ + | grep -E '^plugins/[^/]+/\.apm/(skills/.*\.md|agents/.*\.agent\.md)$' \ + | grep -v '/README\.md$' +) +if [[ ${#LIVE[@]} -eq 0 ]]; then + fail "no plugin skill/agent files matched the hook's files: pattern" +elif (cd "$REPO_ROOT" && bash "$SCRIPT" ${LIVE[@]+"${LIVE[@]}"} > "$RUN_TMP/live.out" 2>&1); then + pass "the ${#LIVE[@]} in-scope corpus files pass" +else + fail "the live corpus has ADR-0023 clause-1 violations" + sed 's/^/ /' "$RUN_TMP/live.out" +fi + +echo "" +echo "Results: $PASS passed, $FAIL failed" +[[ $FAIL -eq 0 ]]