@@ -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)
|
||||
|
||||
168
docs/adr/0023-rtk-prefix-marks-executable-commands-only.md
Normal file
168
docs/adr/0023-rtk-prefix-marks-executable-commands-only.md
Normal 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.
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)"`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
198
scripts/check-rtk-prefix.sh
Executable 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
|
||||
224
tests/test-check-rtk-prefix.sh
Normal file
224
tests/test-check-rtk-prefix.sh
Normal 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 ]]
|
||||
Reference in New Issue
Block a user