Commit and PR Conventions¶
Git Workflow¶
We use a rebase workflow. All changes are made on a branch, then
rebased onto main before being merged. This keeps a clean, linear
history.
The goal is a meaningful history on main: every commit on main
should be a deliberate, self-contained unit of change. Working commits
("WIP", "fix typo", "address review feedback") do not belong on main.
- Rebase-merge: PRs are integrated using rebase-merge (not squash or merge commits). Every commit on the branch lands on main verbatim — so the branch history is the main history. There is no squash-on-merge safety net; whatever you leave on the branch is what ships.
- The history's reader is a coding agent. This is the leading
principle for cleanup. An agent reaches a commit through
git blame,git log -- <path>andgit log -S, and the commit message is where it learns why the code is the way it is. Curate the branch for that reader: one commit per module per slice, with a body that explains the end state. See Commit Organization. - Clean up before merging (mandatory): before a PR is merged, rewrite the branch (interactive rebase) to that shape. Squash aggressively, in service of that principle: fold working commits, fixups and trial-and-error into the commit they belong to, reword messages, and reorder as needed.
Stacked PRs¶
When a PR depends on another open PR, stack them with
gh stack (gh extension install github/gh-stack),
not by retargeting PR bases by hand. GitHub then knows the PRs form a stack, and
the tool keeps the branches rebased on each other.
- A stack is one chain, bottom to top. Do not base two PRs on the same PR: only
stack work that really depends on the PR below it. Independent work gets its
own PR from
main. - Start with
gh stack init <branch>and add layers withgh stack add <branch>.gh stack init <bottom> <top>adopts existing branches. gh stack submitpushes the branches, opens or updates the PRs and links them.gh stack rebasecascades a rebase through the stack, andgh stack syncalso fetches and pushes.- Merge with
gh stack merge --rebase. The repository allows rebase merges only. - The commit rules above apply to every layer: each PR is curated before merge,
and its commits land on
mainverbatim.
Commit message schema¶
We use Conventional Commits. These prefixes drive release-please to determine SemVer bumps and generate the changelog — using the correct prefix is required, not optional.
type(scope): subject
body
Every commit has a type and a scope — both are mandatory (see Scopes). This is the whole table; there are exactly eight types:
| Type | Meaning | Changelog section | Version bump |
|---|---|---|---|
feat |
New user-visible functionality | Features | minor |
fix |
Bug fix (see What fix: means) |
Bug Fixes | patch |
perf |
Performance improvement | Performance Improvements | patch |
refactor |
Code restructuring, no behavior change | Code Refactoring | patch |
docs |
Documentation | Documentation | patch |
test |
Adding or refactoring tests | Tests | patch |
build |
Build system or dependencies | Build System | patch |
chore |
Miscellaneous maintenance | Miscellaneous Chores | patch |
Every type is visible in the release notes, in its own section, rendered in the
order above. Because the scope is mandatory, every changelog line reads
concern: subject. This table is the single human source for the type
vocabulary; the machine source is
.github/release-please/config.json.
Breaking changes take a ! suffix (or a BREAKING CHANGE: footer) and
bump the major version:
feat(cache)!: remove the deprecated cache-purge endpoint
Example:
feat(jwt): support more authentication methods
The release-please config sentence-cases subjects in the changelog, so
write the subject in normal prose case (feat(cache): add dual-limit
eviction, not feat(cache): Add ...).
Removed types¶
revert, style, and ci are not valid types — the commit-lint gate
rejects them, and release-please hides them if one ever slips onto main.
- CI/build automation →
chore(ci): ...(lands in Miscellaneous Chores). Dividing CI into concerns is not worthwhile, and a dedicated Continuous Integration changelog section is noise; it belongs under maintenance. - Formatting-only changes → fold into the functional commit, or
chore(<scope>): ....rustfmt/clang-formatare enforced gates, so a standalone formatting commit should be rare. - Reverts →
fix(<scope>): revert ...orchore(<scope>): revert ...depending on whether it corrects released behavior. (release-please's built-in revert auto-negation no longer applies; reverts are rare and handled by hand.)
What fix: means¶
A fix: corrects behavior that exists on main — a bug a deployer could
hit today, or that a released version shipped. It earns a "Bug Fixes"
changelog line precisely because deployers need to know. The same change
filed as chore: or refactor: still appears in the release notes, but
under a section nobody scanning for a regression will read.
A bug you introduced earlier in the same branch is not a fix:. Fold
it into the commit that introduced it (git commit --fixup=<sha>, then
git rebase --autosquash) so it never lands on main and never generates
a changelog entry for a bug nobody ever saw.
Corollary: if you discover a genuine pre-existing main bug while doing
unrelated work, give it its own fix: commit — don't bury it inside
the feat:/refactor: you happened to be writing.
Scopes¶
A scope is mandatory. Every commit is type(scope): subject, so every
release-notes line reads concern: subject. There is no scopeless form.
The scope names the concern the change serves — the module whose
responsibility it belongs to, not the directory the edited files happen to
sit in. Telemetry code under src/server/rust/ is observability; an ffi
seam field whose only purpose is to carry a metric across is observability
too. Scope by what the change is about, not where it landed. The canonical
module list — which is also the scope vocabulary — lives in
CONVENTIONS.md § Module Layout. Use one of
these names, lowercase:
- Module scopes:
image,format_handlers,metadata,iiifparser,scripting,util,jwt,cache,memory-budget,memory,observability,logging,cli,ffi,lua,server(scripting= the C++ Lua runtime insrc/scripting/;luastays the Lua scripts/config inscripts/+config/*.lua; the retiredhandlersscope folded intoiiifparserper DUNE-014) - Test-layer scopes (
e2e,approval) — for changes to a test layer's own harness or fixtures. A test about a specific concern takes that concern's scope (test(observability): ...), not the layer: thetesttype already says it is a test, so the scope is free to name the concern. A unit-test change takes the scope of the module under test. - Cross-cutting scopes (changes not tied to one module):
deps,bazel,ci,nix,docker,docs. Usedocsfor the agent-context / domain documentation that spans no single module — the rootCLAUDE.md/CONVENTIONS.md/CONTEXT.md/UBIQUITOUS_LANGUAGE.mdand the ADRs. A doc change about one module still takes that module's scope.
Rules:
- Lowercase, kebab-case.
cinotCI;bazelnotbazel-build. - A commit that spans several modules may list them comma-separated:
refactor(cli,server): .... - Concern over location. When code for one module's responsibility lives
under another module's directory, scope by the responsibility, not the
enclosing directory.
server/rust/src/metrics.rs→observability. - No catch-all. There is no
repo/allscope. If none of the enumerated scopes genuinely fits, ask the maintainer before inventing one — new scopes are added to the canonical list inCONVENTIONS.mddeliberately, not ad hoc.
The commit-lint gate enforces only that a scope is present (it does not yet restrict which scope — there is no closed vocabulary). The list above is the advisory vocabulary; keep to it.
Enforcement¶
Commit messages are gated in CI by
commitlint-rs via the
commit-lint job in ci.yml (a composite
action at .github/actions/commit-lint). The rules live in
.commitlintrc.yml: the type allowlist above is
mandatory, scope-empty makes the scope mandatory, and the subject must be
non-empty.
Run the same check locally before pushing:
just commit-lint # lints origin/main..HEAD
just commit-lint <base-ref> # lints <base-ref>..HEAD
commitlint-rs is in the Nix dev shell (nix develop); CI installs it with
cargo install.
Merge commits are skipped by the linter (message rules do not apply to them). The
rebase workflow is enforced separately by the require linear history
branch-protection rule, which blocks any merge commit from landing on main.
Commit Organization¶
Principle¶
Shape the history for the agent that will read it. An agent asks two
questions of it: "why is this line the way it is?" (git blame, then the
commit body) and "how did this module get here?" (git log -- <path>).
Both are answered best by one commit per module per slice:
- Too fine (a commit per fix, per review round, per working step) makes
git log -- <path>a list of steps the agent must replay to learn the current state, and each body explains one step rather than the design. - Too coarse (the whole PR as one commit when it spans several slices)
makes
git blameland every line on one message that cannot explain any of them.
A slice is the unit the plan delivered: a phase, or a feature within it.
Inside a slice, split by module (the scopes in
CONVENTIONS.md § Module Layout), so each commit
stays within one module's paths and its scope names that module. A shared
piece a slice adds goes in its own commit ahead of the modules that use it,
so no commit uses something that lands after it. A small PR that is one
slice in one module is one commit.
The body is written for an agent¶
The subject says what changed. The body carries what the diff cannot:
- the decisions taken and the alternatives rejected, with the reason;
- the invariants the code relies on and where they are enforced;
- the gotchas a later change would trip over;
- the stable sources behind them (an ADR, a
DEV-id, a doc page).
It describes the end state of the slice, never the journey to it: no "first tried", no review-round history, no test counts or benchmark runs, no behaviour that existed only on the branch. A long body is fine; an agent reads all of it.
Rules¶
- Each
feat:orfix:commit = one changelog entry in the sections developers deploying Vitrinli read first. - Internal work (
build:,ci:,refactor:,docs:,chore:,test:) lands in its own changelog section further down. Squash aggressively, in service of the agent reader: fold every working, fixup and trial-and-error commit until each commit is one module in one slice, and stop there. Squashing past that point makes the history too coarse forgit blame. - Ask: "would a developer deploying Vitrinli care about this change?"
If yes →
feat:orfix:. If no → an internal-work prefix. - Debugging journeys (trial-and-error, reverts of in-branch mistakes,
iterative fixes) belong in the PR description, not the commit history.
See What
fix:means.
Where context lives¶
| Layer | Audience | Content |
|---|---|---|
| Commit messages | Release notes readers | Every change; feat:/fix: carry the user-visible ones |
| PR description | Reviewers + future developers | Full context including challenges |
| Learnings docs | Future Claude + engineers | Structured, searchable knowledge |
| Code comments | Code readers | "Why not the obvious approach" |
PR Description Format¶
The repository ships a .github/PULL_REQUEST_TEMPLATE.md
that pre-populates this structure when you open a PR.
Template¶
Fixes LINEAR-ID, LINEAR-ID, ...
## Motivation
Why this work was needed. What problem it solves for users.
## Summary
1-3 bullet points of user-visible changes.
## Key Changes
### [Topic]
- change details
## Challenges and Decisions
What was tried, what failed, and key architecture decisions.
Structure as sub-sections when multiple challenges exist:
### [Challenge title]
**Problem:** description of the issue encountered
**Tried:** approaches that didn't work and why
**Solution:** what worked and why it's the right approach
## Gotchas
Things future developers should know. Each gotcha should be
actionable — not just "this is hard" but "do X instead of Y".
## Test Plan
- [ ] verification steps
Use Part of LINEAR-ID instead of Fixes LINEAR-ID when the PR advances
an umbrella issue it does not close.
Why this format matters¶
The "Challenges and Decisions" section captures the debugging journey
that the curated commit history deliberately omits. The
/eng:workflows:compound skill reads PR descriptions to generate
structured learnings — well-structured challenges become high-quality
learnings automatically.
What goes where¶
| Information | Put it in... |
|---|---|
| New feature / breaking change | Commit message (feat: / feat!:) |
| Bug fix | Commit message (fix:) |
| Build/CI/refactor details | Commit message (build: / ci: / refactor:) |
| Why the work was needed | PR Motivation section |
| What was tried and failed | PR Challenges section |
| Architecture decisions + rationale | PR Challenges section |
| Things to watch out for | PR Gotchas section |
| Structured, searchable knowledge | Learnings doc (dasch-specs) |