Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
484 changes: 77 additions & 407 deletions README.md

Large diffs are not rendered by default.

418 changes: 417 additions & 1 deletion docs/BEHAVIOR.md

Large diffs are not rendered by default.

199 changes: 199 additions & 0 deletions docs/GUIDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
# Guide: making changes end to end

Worked examples for each kind of change. Concepts: [README](../README.md#basic-concepts);
every flag and exit code: [REFERENCE.md](REFERENCE.md).

## A basic example

A small repo, with a `README.md` nobody owns in particular:

```
README.md
docs/guide.md
services/api/main.go
services/web/app.ts
```

```console
$ cat .github/CODEOWNERS
* @org/everyone
/services/api/ @org/api-team
```

Docs team should co-own the README — look before you leap:
```console
$ codeowners-tool sync --op 'add_owner(README.md, @org/docs-team)' --dry-run
applied: 1 op(s) applied, 0 skipped; 1 line change(s), 1 path(s) change owners
ops[0] applied (proven: tree)
```

`proven: tree` means the claim was checked against the repo's real files, not just
reasoned about. Drop `--dry-run` to write it:

```console
$ codeowners-tool sync --op 'add_owner(README.md, @org/docs-team)'
applied: 1 op(s) applied, 0 skipped; 1 line change(s), 1 path(s) change owners
ops[0] applied (proven: tree)
$ cat .github/CODEOWNERS
* @org/everyone
README.md @org/everyone @org/docs-team
/services/api/ @org/api-team
```

Three things happened that are worth noticing:

- `@org/everyone` was **carried onto the new line** — they owned `README.md` via `*`,
and `add_owner` means co-own, so the new rule restates them or they'd be dropped.
- The line went **in the middle, not at the end** — directly after the rule it narrows,
which is what keeps out-of-scope ownership (INV-2) untouched.
- `/services/api/` was **not touched at all**, including its original spacing.

Run it again and nothing happens: the second run reports `unchanged`, zero bytes change.

> **Pattern note.** `README.md` is unanchored, so like gitignore it matches a
> `README.md` at *any* depth. Write `/README.md` if you mean only the one at the root.

## Writing a new CODEOWNERS file

For a repo with no CODEOWNERS at all, `--create` grants permission to write one at
`.github/CODEOWNERS`. It never overwrites an existing file, it's off by default, and a
run with nothing to write creates nothing. The smallest version is one op:

```console
$ codeowners-tool sync --op 'set_owners(*, [@org/everyone])' --create
applied: 1 op(s) applied, 0 skipped; 1 line change(s), 4 path(s) change owners
ops[0] applied (proven: tree)
created a new CODEOWNERS file
```

For a real file, put the ops in a policy so the whole shape is reviewable in one diff
(a policy run states `"create": true` in the file instead of the flag —
[why](REFERENCE.md#creating-a-file-r-23-and-not-creating-one)):

```json
{
"version": 1,
"name": "bootstrap ownership",
"create": true,
"ops": [
"add_owner(*, @org/everyone)",
"add_owner(/services/api/, @org/api-team)",
"add_owner(/docs/, @org/docs-team)",
{ "op": "add_owner(/.github/workflows/, @org/ci)", "on_zero_match": "declare" }
]
}
```

```console
$ codeowners-tool check --policy bootstrap.json
ok: bootstrap.json — 4 op(s), no policy errors
$ codeowners-tool sync --policy bootstrap.json
applied: 4 op(s) applied, 0 skipped; 4 line change(s), 4 path(s) change owners
created a new CODEOWNERS file
```

Two things that will bite you on the first try:

- **Use `add_owner` for the catch-all, not `set_owners`.** `add_owner` ops commute, so
any number can share one run. `set_owners(*, …)` overlaps every other scope and does
not commute with them, so the batch is refused at exit 3 (R-8) — run it on its own
first, previewed with `--dry-run`, since it *replaces* the owners of everything.
- **A rule for files that don't exist yet needs `on_zero_match: "declare"`.** The
default `require` treats a scope matching nothing as a problem with this repo,
because it usually is a typo. `declare` writes the rule at the end of the file for
files added later, and reports `proven: structural` — see
[what `declare` costs](REFERENCE.md#what-declare-costs).

## Modifying an existing file

Same command; the interesting part is what it protects you from. Starting from the
two-line file above, each row is one run, and the second column is the line it leaves
behind — original spacing intact:

| Run | `/services/api/` afterwards |
|---|---|
| `sync --op 'add_owner(/services/api/, @org/platform)'` | `/services/api/ @org/api-team @org/platform` |
| `sync --op 'set_owners(/services/api/, [@org/platform, @org/api-team])'` | `/services/api/ @org/platform @org/api-team` |
| `sync --op 'rename_owner(@org/api-team, @org/platform-api)'` | `/services/api/ @org/platform-api` |

The first is the common case and the one hand-editing gets wrong. The second is the
same edit stated deliberately. The third is what a reorg needs — a global identifier
substitution that can't change any rule's match set.

**Removing an owner** stops and asks when it would empty a rule's owner set — there is
deliberately no default:

```console
$ codeowners-tool sync --op 'remove_owner(/services/api/, @org/api-team)'
error: removing @org/api-team empties the owner set of "/services/api/"; an explicit --on-empty policy (error|inherit|unowned) is required — there is deliberately no default (R-6) (governing file: .github/CODEOWNERS)
```

`--on-empty inherit` deletes the rule and lets the preceding broader one take over;
`unowned` keeps the pattern with zero owners (GitHub's sanctioned substitute for `!`
negation); `error` refuses outright, and is the recommendation.

## Reviewing the change before it lands

`sync` is plan-assert-apply-validate in one step. Split it when you want the artifact
in the middle — a JSON plan with resolved ownership per path and the literal line diff:

```console
$ codeowners-tool plan --op 'add_owner(/services/web/, @org/web-team)' --out plan.json
plan written to plan.json
1 line change(s), 1 path(s) change owners, 58 → 101 bytes
$ jq '.ownership_rows, .diff' plan.json
[
{
"path": "services/web/app.ts",
"owners_before": ["@org/everyone"],
"owners_after": ["@org/everyone", "@org/web-team"]
}
]
"@ line 2\n+/services/web/ @org/everyone @org/web-team\n"
$ codeowners-tool apply --plan plan.json
applied: .github/CODEOWNERS (58 → 101 bytes)
```

Every change carries the reason it took that shape — the part a reviewer actually
wants. And to prove after the fact that a merged change moved nothing it didn't
declare:

```sh
codeowners-tool snapshot --branch main --out before.json
codeowners-tool snapshot --branch feature --out after.json
codeowners-tool verify --before before.json --after after.json --scope /services/api/
```

Two hygiene rules make that proof trustworthy. `snapshot` reads the **committed**
CODEOWNERS at `--branch` (default `HEAD`) — so commit the change before taking the
"after" snapshot. And leave the evidence files themselves uncommitted: a path that
enters the tracked tree counts as an ownership change, so `verify` will rightly flag
your own `before.json` as out of scope.

## When it refuses

Sometimes there is no line that does what you asked and nothing else. Given
`infra/main.tf` and `infra/README.md`, and a CODEOWNERS of exactly `infra/
@org/infra-legacy`:

```console
$ codeowners-tool sync --op 'add_owner(**/*.tf, @org/infra)'
error: refusing: rule "infra/" also governs paths outside scope "**/*.tf", and no sound narrowing pattern is derivable — amending would violate INV-2, appending would violate INV-1 (governing file: .github/CODEOWNERS)
```

In English: `infra/` covers the `.tf` file *and* the README. Editing that line would
change the README's owners, which you never asked for (INV-2). Adding a `**/*.tf` line
before it would be overridden by it (INV-1). So it stops.

This is a normal outcome for some repos, not a bug — the tool fails closed rather than
guessing. Two ways out, both stated in the op itself:

- **Narrow the scope** to something a sound rule *can* be written for — the concrete
paths, or a directory-local glob: `add_owner(/infra/*.tf, @org/infra)`.
- **Carve the conflicting paths out** with an `except` clause:
`add_owner(infra/ except infra/README.md, @org/infra)` — see [except.md](except.md).

(A same-owners `set_owners` does not help: it changes no ownership, so the tool reports
`unchanged` and writes nothing.) Exit `2` means *this repo* needs a human; exit `3` means
*the policy* is broken and will fail identically everywhere — the split that makes a
hundred-repo run survivable ([FLEET.md](FLEET.md)).
13 changes: 9 additions & 4 deletions docs/LINTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Two commands, and the difference between them is whether they write:
| What it does | Reports 12 checks (A-1 … A-12) | Repairs 3 of them |
| Writes? | **Never** | Yes, unless `--dry-run` |
| Reads | the file committed at `--branch` | the file **in your working tree** |
| Needs a token? | Only for the owner checks | **Always** |
| Needs a token? | Only for the owner checks | Yes, bar one offline mode ([below](#errors-you-will-actually-hit)) |

`audit --lint` is the older spelling of `lint` and still works — same code path.

Expand Down Expand Up @@ -39,7 +39,7 @@ Drop `--dry-run` to write it. Every flag is in
| Flag | Why |
|---|---|
| `--dry-run` | Report, write nothing. Exit 4 if anything is pending. |
| `--github-repo owner/name` | Required. Proves the token can see this repo. |
| `--github-repo owner/name` | Required, bar the offline mode. Proves the token can see this repo. |
| `--remove-stale-paths` | Also delete rules matching no files. Off by default. |
| `--on-empty error\|inherit\|unowned` | Required *only* if a removal would empty a rule. |
| `--file PATH` | For a CODEOWNERS you have not committed yet. |
Expand Down Expand Up @@ -125,7 +125,7 @@ One rule: **0 when the file needs nothing further from a person, 4 when it does.
| 2 | Refused — `--on-empty=error`, the size cap, wrong `--branch`, or `--repo` below the repo root |
| 3 | Invalid input — a missing `--on-empty`, a bad flag combination, or the file changed under the run |
| 4 | Still needs a person — pending fixes under `--dry-run`, an unrepairable line, or a case-only typo |
| 5 | Inconclusive — a lookup could not be answered, or no token. **Nothing written** |
| 5 | Inconclusive — a lookup could not be answered, or credentials missing (bar the offline mode below). **Nothing written** |
| 6 | Post-write validation failed; rolled back |

`lint` never returns 1. A file needing no repair is this command's success, and exiting
Expand All @@ -134,7 +134,12 @@ One rule: **0 when the file needs nothing further from a person, 4 when it does.
## Errors you will actually hit

**`lint needs a token … and --github-repo`** (exit 5). Owner existence is not decidable
offline, and that is the whole point of the command. It names whichever one you left out.
offline; the refusal names whichever credential you left out. One exception: with
`--remove-stale-paths` and *neither* credential given, the run proceeds offline and does
stage 3 alone — dead rules are removed against the tree, invalid lines are still reported
(exit 4), and no owner is verified, repaired, or removed; the skip is disclosed in the
output and as `owner_checks_skipped` in the JSON record. Supplying one credential without
the other still refuses — a run that named a repo or held a token asked for the full lint.

**`inconclusive: … no owner was removed and nothing was written`** (exit 5). One lookup
could not be answered — rate limit, expired token, an org your token cannot enumerate —
Expand Down
34 changes: 27 additions & 7 deletions docs/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ audit [--checks a1,a3,a6] [--fail-on any|warning|error|never] [--format json|
[--cache-dir D] [--cache-ttl DUR] [--repo DIR] [--branch REF] [--file PATH]
lint --github-repo owner/name [--token T | $GITHUB_TOKEN] [--api-url URL]
[--remove-stale-paths] [--on-empty error|inherit|unowned] [--dry-run]
[--repo DIR] [--branch REF] [--file PATH] [--format text|json]
[--policy FILE] [--repo DIR] [--branch REF] [--file PATH] [--format text|json]
snapshot [--repo DIR] [--branch REF] [--out snap.json]
verify --before before.json --after after.json [--scope PATTERN ...]
version print the build this binary was stamped with
Expand Down Expand Up @@ -272,15 +272,28 @@ codeowners-tool snapshot --branch feature --out after.json
codeowners-tool verify --before before.json --after after.json --scope /services/api/
```

## `snapshot` and `verify`

`snapshot` resolves the CODEOWNERS **committed at `--branch`** (default `HEAD`) against
that ref's tracked tree — an uncommitted edit is invisible to it, exactly as it is to
GitHub. In the `ownership` map, `[]` means a rule matches the path and deliberately
assigns no owners; `null` means no rule matches it at all.

`verify` compares two snapshots and exits `0` when every ownership change falls inside
a declared `--scope` (repeatable), `2` — printing each offending path — when any change
falls outside them (with no `--scope`, any change at all violates), and `3` for a
malformed snapshot. A path that enters or leaves the tracked tree counts as a change
(R-18), so don't commit the snapshot files themselves between the two snapshots.

## Operations

Scope is a directory, file path, or glob — same syntax as CODEOWNERS patterns.

| Op | Meaning |
|---|---|
| `add_owner(scope, owner)` | Owner becomes a **co-owner**; every pre-existing owner of every path in scope is retained. |
| `add_owner(scope, owner)` | Owner — or a bracketed list `[a, b]` (R-33) — becomes a **co-owner**; every pre-existing owner of every path in scope is retained. |
| `set_owners(scope, [owners])` | Exact owner set for every path in scope, displacing prior owners. `[]` is legal: it deliberately un-owns the scope. |
| `remove_owner(scope, owner)` | Owner stops owning every path in scope. If a rule's owner set would empty, an `--on-empty` policy is **required**. |
| `remove_owner(scope, owner)` | Owner — or a bracketed list (R-33) — stops owning every path in scope. If a rule's owner set would empty, an `--on-empty` policy is **required**. |
| `rename_owner(old, new)` | Global identifier substitution — the only op safe as pure text replacement (it can't change any rule's match set). |

The scope of `add_owner`, `set_owners` and `remove_owner` may carry an `except`
Expand Down Expand Up @@ -405,7 +418,7 @@ Under `inherit`/`unowned` the resulting reassignment is shown in the plan's owne

## Audit checks

Read-only **except `audit --lint`** ([below](#audit---lint)). Plain `audit` never writes —
Read-only **except `audit --lint`** ([below](#lint)). Plain `audit` never writes —
where a fix is expressible it emits op strings for a human to review and run through
`plan`/`apply`. Even under `--lint` the bytes reach disk only through `apply`, which
remains the system's single writer path.
Expand Down Expand Up @@ -470,8 +483,8 @@ rejected, because a subset of a whole-file repair is ambiguous rather than small

| Flag | Meaning |
|---|---|
| `--github-repo owner/name` | **Required.** Probed, so a token that cannot see the repo stops the run. |
| `--token` / `$GITHUB_TOKEN` | **Required.** Owner existence is not decidable offline. |
| `--github-repo owner/name` | **Required**, bar the offline mode below. Probed, so a token that cannot see the repo stops the run. |
| `--token` / `$GITHUB_TOKEN` | **Required**, bar the offline mode below. Owner existence is not decidable offline. |
| `--dry-run` | Compute and report; write nothing. Exit 4 if anything is pending. |
| `--remove-stale-paths` | Stage 3. Deletes rules matching nothing tracked **and** nothing on disk. |
| `--on-empty error\|inherit\|unowned` | R-6, required only when a removal would empty a rule. `inherit` deletes the line. |
Expand All @@ -482,6 +495,11 @@ rejected, because a subset of a whole-file repair is ambiguous rather than small
revalidation, and here that answer deletes an owner rather than printing a finding.
Lookups are still cached in memory per run.

**Offline tree-only mode:** with `--remove-stale-paths` and *neither* a token nor
`--github-repo`, the run does stage 3 alone — dead rules judged against the tree, invalid
lines still reported at exit 4, no owner verified, repaired, or removed — and the skip is
disclosed. One credential without the other still refuses at exit 5.

| # | Stage | Opt-in | What it does |
|---|---|---|---|
| 1 | Repair owner spacing | no | Rejoins an `@`handle split by whitespace: `@ org/team`, `@org/ team`, `@ org / team` → `@org/team`. Runs **before** stage 2 — those are one owner nobody has looked up, not two that are missing. |
Expand Down Expand Up @@ -511,7 +529,7 @@ when the two agree.
| 2 | Refused — `--on-empty=error`, size cap, or either repository guard |
| 3 | Invalid input — missing `--on-empty`, a rejected flag, an empty tree under `--remove-stale-paths`, or hash drift between read and write |
| 4 | Still needs a person — pending fixes under `--dry-run`, an unrepairable line, or a case-only typo |
| 5 | Inconclusive, or no token/`--github-repo`. Nothing written |
| 5 | Inconclusive, or missing credentials (bar the offline mode above). Nothing written |
| 6 | Post-write validation failed; rolled back |

`lint` never returns 1: a file needing no repair is its success, and "no-op" would make
Expand All @@ -521,6 +539,8 @@ every healthy repository in a fleet read as a failure under `set -e`.
`exit_code`, `actions[]` (`kind`, `line`, `owner`, `pattern`, `reason`), `unverifiable[]`,
`changes[]`, `ownership_rows[]`, `diff`, `warnings[]`. `needs_human` and `exit_code` come
from the same function that sets the process status, so `jq -e .needs_human` is the gate.
An offline run additionally carries `owner_checks_skipped: true` — the record says
nothing about whether the owners exist.
Unlike the `sync` record, `actions`, `changes` and `ownership_rows` are always present
(possibly empty).

Expand Down
Loading
Loading