Skip to content

What happens when you commit

Before a single change reaches the remote, git hooks run on your machine on every commit. They are installed by husky when you run npm run setup — a bare npm install wires nothing, because the repo blocks npm lifecycle scripts. Two hooks do the work: pre-commit and commit-msg. When one of them blocks you, how to read a rejected commit is the map.

The pre-commit hook

pre-commit runs lint-staged, which applies checks to your staged files only — so the cost scales with your change, not the repository. What runs depends on the file type. With the default configuration:

graph TD
    Commit["git commit"] --> Staged{"staged file types"}
    Staged -->|every file| SECRETS["scan for credentials"]
    Staged -->|docs / markdown| MD["regenerate TOC, format, lint markdown"]
    Staged -->|JavaScript / LWC| JS["format, lint, run related Jest tests"]
    Staged -->|Apex| APEX["format, lint (PMD)"]
    Staged -->|XML / YAML / JSON / CSS| OTHER["format"]
    SECRETS --> Build["npm run validate (build check)"]
    MD --> Build
    JS --> Build
    APEX --> Build
    OTHER --> Build
    Build --> Done["commit proceeds"]

Worth knowing:

  • The credential scan is the one step that ignores file type: every staged file is matched against the secret rules, and a hit on a line the commit introduces blocks the commit. It is the only hook step without an .opt-out key, because the same credential would be caught by the pull-request scan a few minutes later, when it is already on the remote and has to be rotated. Prevention here is cheap; rotation is not.
  • The JavaScript step runs only the Jest tests related to the files you changed (--findRelatedTests), so it stays fast.
  • After lint-staged, npm run validate runs a build check. Lint and tests are not repeated there — lint-staged already ran them per file — so the pre-commit pass does not lint or test the whole project.
  • A large commit is linted in concurrent chunks so that a thousand staged files take minutes, not an hour; one failing chunk cancels the rest. Reading that output is covered in how to read a rejected commit.

Lint failures are measured against a baseline

npm run lint does not demand a violation-free project. code-analyzer/code-analyzer.yml carries a generated baseline: for each folder and rule, a cap on how many pre-existing violations it may hold. A run passes while every folder stays at or under its caps and fails as soon as one goes over — so existing debt is tolerated, new debt is not. Caps match recursively, so a parent-folder entry already covers its descendants.

The baseline is meant to move in one direction. Regenerating it (npm run lint:createbaseline) records whatever the scan currently finds, which after genuine fixes means smaller caps, and the removed violations can then never creep back without failing the check. Regenerating on a red run would record the new violations too — which is why the Update Code Analyzer Baseline pipeline is scheduled against the default branch, where only green runs have merged: on that branch a regeneration can only tighten, so the schedule is an automatic ratchet rather than a loophole. The steps are in how to baseline the code analyzer.

Not every check is wired in: .opt-out

Every hook step is individually switchable through the .opt-out file at the repository root — one key per line; a listed key removes the corresponding step from the hook entirely. The project ships with two steps opted out by default:

  • createapextestsuiteprecommit — regenerating the CiTestSuite Apex test suite from the project's test classes. See curated test suites vs RunRelevantTests for what that suite is used for.
  • testapexprecommit — running Apex tests on commit.

Both are org-touching: Apex tests execute against your default org, so leaving them on would make every Apex commit assume an authenticated org and pay a per-commit org round-trip. The default therefore keeps commits fast and org-independent; the pipeline still runs the Apex tests where an org is guaranteed. Removing the keys from .opt-out turns the steps back on for projects that want the earlier feedback.

The same mechanism reaches beyond the pre-commit hook: keys exist for the formatting and lint steps, for the sub-steps of npm run lint and npm run test, and even for the commit-message check. The full key list is in the opt-out keys reference.

A failing check aborts the commit; the staged files stay staged, and the same git commit runs the checks again once the cause is fixed.

Bypassing the hooks only moves the failure

git commit --no-verify skips both hooks. What it does not skip is the pipeline: the same gates — and the org-touching ones the hooks leave out — run on every push and pull request, so a bypassed local rejection resurfaces as a CI failure minutes later, where it is slower to read and, for a credential, already on the remote. The local hooks are the cheap place to be told.

The commit-msg hook

commit-msg runs commitlint against your message, enforcing the Conventional Commits format defined in .commitlintrc.js. A message that does not conform aborts the commit. This check too honours .opt-out (key lintcommitmsg), though it is on by default. npm run cm opens a Commitizen prompt that assembles a conforming message interactively.

Why it runs locally at all

The hooks give you fast feedback on your own machine, before you push or open a pull request. They are the first gate in a chain: the pipeline re-runs these checks (and the org-touching ones the hooks skip) on every push and PR. A clean local commit is therefore unlikely to bounce later — which is the whole point of running them here first.