Git workflow: commits, branches and pull requests
Read when: about to commit, reword, split work, open or update a pull request, answer a review, or merge one. Owns: the commit format, types, scopes, bodies, footers, breaking changes, splitting, rewording; PR titles, bodies, review replies and merge messages.
Conventional Commits (v1.0.0, checked 2026-11-03) for any repository. Commit, push, open a PR or merge only when the user asks; many owners review and commit themselves.
Where each topic lives
| Topic | File |
|---|---|
| Format, types, scope, body, footers, splitting, rewording | this file |
| The 16 rules of the spec, SemVer mapping, reverts, edge cases | references/spec.md |
| Branches, PR title and body, linking issues, review replies, updating, merging | references/pull-requests.md |
TL;DR
type(scope): description: imperative, lowercase start, no trailing period, under ~72 chars.- The body explains why, not what. The diff already shows the what.
- One commit per reason to change. Test: can it be reverted alone without taking something unrelated with it?
- Breaking change:
!before the colon and/or aBREAKING CHANGE:footer. - The scope names the area the change is about, never a code layer. Learn the repo's scopes from its history before inventing one.
- The build (with its type check) passes before the commit, not after.
- Reword only commits that are not pushed, unless the owner says otherwise.
- A PR title is a commit subject; the PR rules are in references/pull-requests.md.
Before you write
git status
git diff --staged # what is actually going in
git log --oneline -20 # the house style
git log --format=%s | grep -oE '^[a-z]+(\([^)]+\))?!?:' | sort | uniq -c | sort -rn | head -20
The last line lists the types and scopes the repo already uses. Check that the history really follows the convention: commits from before it was adopted are not a model.
Format
<type>[optional scope][optional !]: <description>
[optional body]
[optional footer(s)]
The colon and the space after it are required. The description starts right after them.
Types
feat and fix are the only two the spec defines. The rest are convention.
| Type | Use for |
|---|---|
feat |
new behaviour a user can observe |
fix |
a defect. Say what was broken, not only what changed |
docs |
documentation, READMEs, agent instructions and skills |
refactor |
behaviour identical, structure different |
perf |
a measured improvement. Quote the before and after |
style |
formatting only, zero behaviour change |
test |
adding or changing tests only. Skip it in a repo with no tests |
build |
build tooling, containers, web server config, dependencies |
ci |
CI workflows |
chore |
housekeeping that fits nothing above. Reach for it last |
revert |
reverting an earlier commit; Refs: footer with its SHA (how) |
Pick by what the change does for a user of the code, not by the files touched. A bug fix in a build
script is fix(build) if it was broken, build if it only changes how the build works. A dependency bump
that fixes a security advisory is fix(deps); a routine bump is chore(deps) or build(deps), whichever
the history uses.
Scope
A noun naming the area the change is about, so git log reads by area: a business area
(invoices, orders, auth), an app or panel (admin, api), or a tool (deps, ci, docker).
Optional, but in a repo with clear seams it usually writes itself.
- Never a code layer (
controllers,views,migrations,domain-actions). Why: the diff already shows which files changed; the scope should say what part of the product moved. - One spelling per area. Business areas are plural kebab-case nouns (
orders,payment-requests); areas with no plural keep their name (billing,tracking). Use the spellings the history already has, and never two for one area (orderandorders). - Pick the narrowest true area. Tooling first (
deps,ci), then an app or panel when every changed file belongs to it, otherwise the business area. - A change that spans unrelated areas is two commits. If it cannot be split, scope it to the area it is mainly about.
Description
Imperative mood, lowercase start, no trailing period, under ~72 characters. It completes the sentence "If applied, this commit will …".
feat(invoices): add a due-date reminder email
fix(orders): count each line's price once in the order total
refactor(payments): move refund totals into one place
Not:
Added the CSV export. (past tense, full stop)
fix: bug (says nothing)
feat(blocks): update files (says nothing)
Follow the repo if it uses a ticket prefix or a different case; consistency with the log beats this file.
Body
Optional. One blank line after the description, then free-form paragraphs wrapped at ~72.
Write one when the change is not self-evident from the diff. Explain the why:
- the failure mode being prevented;
- the constraint that forced this approach;
- the alternative you rejected, and the reason.
When a commit touches a convention the codebase depends on but nothing enforces (a naming rule another file greps for, a glob, a module-level singleton, a "null means hidden" rule), say so in the body. Those are the ones that break silently.
Don't narrate the session ("first I tried…"), list files, or restate the diff.
Footers
One blank line after the body. A token, then : or #, then the value. Tokens use - instead
of spaces (Reviewed-by, Refs), which is what separates a footer from another body paragraph.
BREAKING CHANGE is the only token allowed to contain a space.
Use the attribution line (Co-Authored-By: …) that the environment or the owner specifies, and
only that one. Don't invent one.
Breaking changes
Either a ! before the colon, or a BREAKING CHANGE: footer, or both. BREAKING-CHANGE is a
synonym. The token must be uppercase; everything else in the format is case-insensitive.
With !, the BREAKING CHANGE: footer may be left out, and the description itself must then say what
breaks. Prefer both: the ! for people scanning the log, the footer for the migration note.
A change is breaking when it forces work outside the commit: a public URL people were given changes, an API or event contract changes, the shape of stored data or content changes, a config key or environment variable is renamed, a supported version is dropped. A change that only affects the app's own rendering is not breaking, however visible it is.
refactor(api)!: rename the customer endpoints to plural nouns
BREAKING CHANGE: /api/customer/* moves to /api/customers/*. The old paths
answer 301 for one release, then 404. Clients must update their base URL.
Splitting work
- One commit per reason to change. Could this commit be reverted on its own without undoing something unrelated?
- Content and code go in separate commits. A content update (
feat(content): add the pricing FAQ) should revert without undoing a component fix that landed the same afternoon. - A refactor and the feature built on it are two commits: the refactor first, behaviour unchanged.
- Formatting churn gets its own
stylecommit, or stays out of the diff. - Stage by hunk when one file holds two reasons:
git add -p <file>.
Rewording
| Situation | Do |
|---|---|
| last commit, not pushed | git commit --amend (message only: --amend -m "…", or --amend with no staged changes) |
| an older commit, not pushed | git rebase -i <base> and mark it reword. Without an interactive terminal, script both editors (GIT_SEQUENCE_EDITOR turns pick <sha> into reword <sha>, GIT_EDITOR writes the new message), or ask the owner to do it |
| already pushed to a shared branch | don't rewrite. Ask the owner; a force-push rewrites everyone's history |
Amending changes the commit's SHA. Never amend to "fix" a commit that a hook rejected: the commit did not happen, so amending would change the previous one. Fix the problem and commit again.
Before committing
- The build passes. If the build runs a type check first (for example
vue-tscbeforevite build), template errors surface only there. A commit that does not build is not finished. - Lint and format the files you touched.
- For anything visual, check it in a real browser before committing, not after.
- Stage files by name, not
git add -Aorgit add ., which sweep in untracked secrets and large files. Never stage.env/.env.*, credential or key files, logs, dumps, or generated output that is not meant to be tracked. A secret that was committed must be rotated: deleting it later leaves it in history. - Never bypass hooks (
--no-verify). When a hook fails, fix what it reports and commit again. - Read
git diff --stagedonce more. Nothing temporary, no stray debug output.
Pull requests
A PR title is a commit subject, and with squash merge it becomes one. Branch names, the body, review replies, updating and merging: references/pull-requests.md.