Worked examples for each kind of change. Concepts: README; lookup tables: REFERENCE.md.
A small repo — README.md, docs/guide.md, services/api/main.go and
services/web/app.ts — whose README.md nobody owns in particular:
$ cat .github/CODEOWNERS
* @org/everyone
/services/api/ @org/api-teamDocs team should co-own the README. State that in a policy file, so the change is reviewable as a diff before it is reviewable as a CODEOWNERS diff:
{
"version": 1,
"name": "docs team co-owns the README",
"ops": ["add_owner(README.md, @org/docs-team)"]
}Look before you leap — check reads no repository, --dry-run writes nothing:
$ codeowners-tool check --policy ownership.json
ok: ownership.json — 1 op(s), no policy errors
$ codeowners-tool sync --policy ownership.json --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 — the same two lines, and then:
$ cat .github/CODEOWNERS
* @org/everyone
README.md @org/everyone @org/docs-team
/services/api/ @org/api-teamThree things happened that are worth noticing:
@org/everyonewas carried onto the new line — they ownedREADME.mdvia*, andadd_ownermeans co-own, so the new rule restates them or they'd be dropped. The carry is a fact about the tree, so a declared rule gets none (cost).- 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.- Running it again changes nothing: the second run reports
unchanged, zero bytes.
For those same four files with no CODEOWNERS at all, "create": true 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 — so it is safe to leave set for
a fleet where only some repos have a file (why it lives in the policy, not a
flag):
{
"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" }
]
}$ codeowners-tool check --policy bootstrap.json
ok: bootstrap.json — 4 op(s), no policy errors
ops[0] on_zero_match: require (built-in)
ops[1] on_zero_match: require (built-in)
ops[2] on_zero_match: require (built-in)
ops[3] on_zero_match: declare
$ codeowners-tool sync --policy bootstrap.json
applied: 4 op(s) applied, 0 skipped; 4 line change(s), 4 path(s) change owners
ops[0] applied (proven: tree)
ops[1] applied (proven: tree)
ops[2] applied (proven: tree)
ops[3] applied (proven: structural)
created a new CODEOWNERS fileTwo things that will bite you on the first try:
- Use
add_ownerfor the catch-all, notset_owners.add_ownerops commute, so any number can share one run;set_owners(*, …)overlaps every other scope and doesn't, so the batch is refused at exit 3 (R-8). Run it alone first, previewed with--dry-run. - A rule for files that don't exist yet needs
on_zero_match: "declare". The defaultrequiretreats a scope matching nothing as a typo, because it usually is.declarewrites the rule at the end of the file for files added later and reportsproven: structural— see what it costs.
Same command; the interesting part is what it protects you from. Starting from the two-line file above, each row is a policy with that one op, and the second column the line it leaves behind — original spacing intact:
| The op in your policy | /services/api/ afterwards |
|---|---|
add_owner(/services/api/, @org/platform) |
/services/api/ @org/api-team @org/platform |
set_owners(/services/api/, [@org/platform, @org/api-team]) |
/services/api/ @org/platform @org/api-team |
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 needs an explicit on_empty — there is deliberately no default for
what happens when a removal empties a rule's owner set. In a policy this is settled before
any repository is opened:
$ codeowners-tool check --policy remove.json
error: remove.json:1:21: ops[0]: this op is a remove_owner, so the policy must set a top-level "on_empty" ("error", "inherit", "unowned"); leaving it unset settles R-6 lazily, on whichever repo first has a removal empty an owner set
this is a policy error — it will fail identically on every repo; fix the policy, do not retryThat is the argument for the policy file in one message: the same omission passed as
--op surfaces only when some repo first hits an emptied rule.
"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.
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:
$ 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:
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/One hygiene rule makes that proof trustworthy: snapshot reads the committed
CODEOWNERS at --branch, so commit before taking the "after" snapshot. Files the two
refs differ on — including the evidence files, if you commit them — surface as
added:/removed: lines and do not fail the check.
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:
$ 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
exceptclause:add_owner(infra/ except infra/README.md, @org/infra)— see OPERATIONS.md.
A same-owners set_owners does not help: it changes no ownership, so the run reports
unchanged. Exit 2 means this repo needs a human; exit 3 means the policy is
broken everywhere — the split that makes a hundred-repo run survivable (FLEET.md).