Close four small ADR-0020-retrofit follow-ups (#125, #127, #113, #118) #130

Merged
Defame1297 merged 12 commits from fix/adr0020-followups into main 2026-09-09 18:23:00 +00:00
24 changed files with 753 additions and 49 deletions
Showing only changes of commit ed8c99efbd - Show all commits

View File

@@ -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)

View File

@@ -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 <subcommand>` 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 <name>` | 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 <branch>`.
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 <branch>`" 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 <branch> <path> <remote>/<branch>` — always correct. `git worktree add
<path> <branch>` 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.

View File

@@ -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 <branch>`" 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 <path>
<branch>` `` 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),

View File

@@ -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 <name>` and `rtk git tag --list <name>`; 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/<name>` or `refs/tags/<name>`.
- **A branch and a tag can carry the same name.** Detect it before acting — `git branch --list <name>` (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 <name>`; 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/<name>` or `refs/tags/<name>`.
- **`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

View File

@@ -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 "<message>"`. 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 <branch> stash@{n}` creates a branch at the commit the

View File

@@ -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.

View File

@@ -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 <subcommand>`, never bare `git`** — org convention, in `&&` chains too.
- **Run git as `rtk git <subcommand>`, 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.

View File

@@ -21,9 +21,9 @@ Prefer this whenever a commit is written to be folded, because git does the mark
1. `rtk git commit --fixup=<commit>` keeps the target's message; `rtk git commit --squash=<commit>` 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/<branch>.` 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/<branch>.` 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

View File

@@ -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 <start>,<end>:<file>` or `rtk git log -L :<function>:<file>`. Confirm the range resolves before reporting on it — an off-by-one silently omits the target.
- **A line or function**: `git log -L <start>,<end>:<file>` or `git log -L :<function>:<file>` — 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 -- <file>`. 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)"`.

View File

@@ -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: <added>\t<deleted>\t<p
### --name-only / --name-status
Bare `git`, not `rtk git`: rtk appends a blank line and a `Changes:` trailer, so
the output is no longer one record per line.
```bash
rtk git diff --name-only # only filenames, one per line
rtk git diff --name-status # status letter + filename per line
git diff --name-only # bare per ADR-0023; only filenames, one per line
git diff --name-status # bare per ADR-0023; status letter + filename per line
```
`--name-status` uses the same status letters as `--diff-filter`.
### --word-diff
Bare `git`, not `rtk git`: rtk replaces the word-diff with its own diffstat
renderer and emits none of the `[-removed-] {+added+}` markers.
```bash
rtk git diff --word-diff # inline word-level diff with [-removed-] {+added+} markers
rtk git diff --word-diff=color # color only, no markers
rtk git diff --word-diff=porcelain # machine-readable: +/- prefixed lines, ~ for newlines
rtk git diff --word-diff-regex=<re> # 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=<re> # bare per ADR-0023; define what counts as a "word"
```
### Whitespace Flags

View File

@@ -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

View File

@@ -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
```

View File

@@ -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 <branch> <path> <remote>/<branch>` — always correct. `git worktree add <path> <branch>` expands to exactly this, but **only** under the conditions in `references/worktrees.md` |
| Throwaway experiment, no branch | `rtk git worktree add -d <path>` — detached HEAD |
| **Never** `git worktree add <path> <remote>/<branch>` | 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 <str>] <path>` / `rtk git worktree unlock <path>` |
| Move | `rtk git worktree move <from> <to>` |
| Remove | `rtk git worktree remove <path>` |
@@ -62,6 +62,7 @@ worktrees:
lock_reason: <reason or empty>
```
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`.

View File

@@ -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.

View File

@@ -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 <subcommand>` 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 <branch>`.
- **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

View File

@@ -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

View File

@@ -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."

View File

@@ -48,7 +48,7 @@ create_branch owner: <owner> repo: <repo> branch: <new-name> old_branch: <source
Default dispatch: if the user gives a base ("branch off of X", "from X"), pass it as `old_branch`.
If they don't specify a base and you're mid-task on a local branch, pass your current branch
(`git branch --show-current`) as `old_branch` so the new branch forks from where you're actually
(`rtk git branch --show-current`) as `old_branch` so the new branch forks from where you're actually
working, rather than silently falling back to the repo default. If neither applies (e.g. a fresh
top-level request with no working branch context), omit `old_branch` and let it default server-side.

View File

@@ -14,7 +14,7 @@ compatibility: Requires Gitea MCP server configured with write:issue and write:r
metadata:
category: integration
version: "0.1.3"
version: "0.1.4"
source_keys:
- gitea-mcp-repo
- gitea-mcp-slim-go
@@ -36,7 +36,7 @@ allowed-tools: Bash mcp__gitea__list_issues mcp__gitea__issue_read mcp__gitea__i
An orchestrating caller may pass `owner` and `repo` in already, and the `search` row is cross-repository and needs only a query — both skip this step. Otherwise, before any tool call:
```bash
git remote get-url origin
rtk git remote get-url origin
```
If origin is unset or the URL is not a Gitea URL, stop and report: "No Gitea remote found — set origin to your Gitea instance URL."

View File

@@ -16,7 +16,7 @@ metadata:
- gitea-mcp-slim-go
- context7-websites-gitea
- context7-gitea-tea-cli
version: "0.1.4"
version: "0.1.5"
allowed-tools: Bash mcp__gitea__label_read mcp__gitea__label_write mcp__gitea__milestone_read mcp__gitea__milestone_write
---
@@ -32,7 +32,7 @@ allowed-tools: Bash mcp__gitea__label_read mcp__gitea__label_write mcp__gitea__m
Before any tool call, extract `owner` and `repo` from the git remote (skip this if an orchestrating caller already passed them in):
```bash
git remote get-url origin
rtk git remote get-url origin
```
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."

View File

@@ -19,7 +19,7 @@ metadata:
- gitea-mcp-slim-go
- context7-websites-gitea
- context7-gitea-tea-cli
version: "0.1.2"
version: "0.1.3"
allowed-tools: Bash mcp__gitea__list_pull_requests mcp__gitea__pull_request_read mcp__gitea__pull_request_write mcp__gitea__pull_request_review_write
---
@@ -34,7 +34,7 @@ allowed-tools: Bash mcp__gitea__list_pull_requests mcp__gitea__pull_request_read
Extract them from the git remote before any tool call, skipping this when an orchestrating caller already passed them in:
```bash
git remote get-url origin
rtk git remote get-url origin
```
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."

View File

@@ -14,7 +14,7 @@ compatibility: Requires Gitea MCP server configured with a token with write:repo
metadata:
category: integration
version: "0.1.0"
version: "0.1.1"
source_keys:
- gitea-mcp-repo
- gitea-mcp-slim-go
@@ -36,7 +36,7 @@ allowed-tools: Bash mcp__gitea__list_releases mcp__gitea__get_release mcp__gitea
`owner` and `repo` are required on every tool below. Extract them from the git remote, unless an orchestrating caller passed them in already:
```bash
git remote get-url origin
rtk git remote get-url origin
```
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."

198
scripts/check-rtk-prefix.sh Executable file
View File

@@ -0,0 +1,198 @@
#!/usr/bin/env bash
set -euo pipefail
# ADR-0023 clause 1, and ONLY clause 1: an executable, instructed local git
# command in plugin skill or agent content is written `rtk git`, never bare
# `git`.
#
# WHY THIS IS NARROW ON PURPOSE. ADR-0023 has three clauses, and only the first
# is machine-decidable:
#
# 1. executable + instructed -> `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 <branch>`" 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 <path> <branch>` 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<f>`{3,}|~{3,})\s*(?P<info>[^\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 <subcommand>` (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

View File

@@ -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 <branch>` — 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 <b> <p> <r>/<b>` — always correct. `git worktree add <p> <b>` expands to exactly this |' \
"a referential bare mention AFTER the instructed command in a Run cell"
expect clean '| Operation | Run |
|---|---|
| **Never** `git worktree add <p> <r>/<b>` | 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 ]]