Tool-Enforced Practices¶
Git's flexibility is also its weakness: every team builds its own conventions on top of the same primitives, and the conventions only matter to the extent that engineers actually follow them. The reliable way to make a practice stick is to make a tool enforce it.
This page covers the practices the framework can support concretely — pre-commit hooks, repository-local hook configuration, commit-message structure enforcement, branch protection, and lockfile discipline — each backed by a specific tool or git configuration that produces deterministic behavior.
Scope of this page
Tool-enforced practices only. Conventions about what to write in a commit message, when to rebase versus merge, how to structure a pull request description — these belong in a team's engineering handbook, not in a tooling framework. The framework's job is to make the right thing easy and the wrong thing visible; the team's job is to decide what "right" means in the cases where reasonable engineers disagree.
Pre-Commit Hooks via lefthook¶
The framework's recommended pre-commit hook runner is lefthook
(evilmartians/lefthook), pinned in mise.toml alongside the
project's other tools. lefthook is fast (Go binary, parallel execution
by default), language-agnostic, and configured via a single
lefthook.yml committed to the project root — making the team's hook
policy version-controlled and PR-reviewable.
Why lefthook over alternatives¶
- vs.
pre-commit(the Python tool) — pre-commit installs hooks in isolated Python environments, which is fine for Python projects but adds latency for non-Python projects. lefthook runs your project's existing tooling directly. - vs.
husky(the Node tool) — husky is fine for Node projects but binds you to npm/yarn/bun for hook management. lefthook is independent of any package manager. - vs. raw
.git/hooks/— raw hooks aren't shareable; each engineer has to install them manually. The standard escape hatch (core.hooksPath) addresses sharing but loses the parallel execution and the staged-files-only optimization that lefthook provides.
Reference lefthook.yml¶
# lefthook.yml — commit at project root
#
# After installing: `lefthook install` (once per clone) registers the
# hooks with git. The framework's bootstrap doesn't run this for you;
# each project does it as part of its own setup script or README.
pre-commit:
parallel: true
commands:
# Lint only the files staged for commit — fast feedback,
# no full-project sweep.
biome:
glob: "*.{js,jsx,ts,tsx,json}"
run: bunx biome check --error-on-warnings {staged_files}
ruff:
glob: "*.py"
run: uvx ruff check {staged_files}
rubocop:
glob: "*.rb"
run: bundle exec rubocop --force-exclusion {staged_files}
# Verify Markdown links don't break (cheap, runs on .md changes only)
markdown-links:
glob: "*.md"
run: bunx markdown-link-check {staged_files}
# Block commits that include the string "DO NOT COMMIT" (a marker
# convention for "I'm leaving this in temporarily").
no-do-not-commit:
run: |
if git diff --cached | grep -E "DO NOT COMMIT"; then
echo "Found 'DO NOT COMMIT' in staged changes."
exit 1
fi
pre-push:
commands:
# Run the full test suite before a push to main.
# Use `git push --no-verify` to skip in true emergencies.
test:
run: mise run test
skip:
- merge
- rebase
Two important lefthook design choices
First: hooks run on staged files only (the {staged_files}
token), not the whole project. A repository-wide lint sweep on
every commit is a known pre-commit antipattern — it makes
commits slow, encourages people to skip the hook, and produces
lint failures unrelated to the change being made. Stage-scoped
is faster and more focused.
Second: parallel: true runs all commands concurrently. On a
multi-core machine a 10-command pre-commit sweep finishes in
roughly the time of the slowest command, not the sum of all of
them. The serial alternative — the default in pre-commit and
husky — is meaningfully slower in practice.
Hooks-on-Clone via core.hooksPath¶
Even with lefthook, there's a coordination problem: lefthook install
has to be run once per clone, by each engineer, on each machine.
Engineers who clone a repo and start working without reading the
README never run it, and their commits skip the project's hook
policy.
The framework's recommended pattern: make hook installation part of
the project's one-time setup task in mise.toml:
# In the project's mise.toml [tasks] section:
[tasks.setup]
description = "One-time project setup; run after `git clone`"
run = """
mise install
lefthook install
bundle install
uv sync
"""
Engineers run mise run setup (or make setup via the wrapper) once
after cloning. The README's "Getting started" section opens with this
command.
Some teams go further and auto-install lefthook on first commit
using a small shell wrapper. This is a deliberate tradeoff: it's
friendlier for new contributors but adds a tiny amount of magic to
the first commit. The framework's recommendation is the explicit
mise run setup pattern — less magical, equally reliable, and the
setup step is a useful place for other one-time project initialization
(database migrations, seed data, dependency installation).
Conventional Commit Hooks (Optional)¶
If the team has chosen to use Conventional Commits (feat:, fix:,
chore:, BREAKING CHANGE:, etc.), a commit-msg hook can enforce the
format. The framework is neutral on whether to use Conventional
Commits — it's a team norm, covered in the engineering handbook —
but if the team has chosen to, lefthook can validate the format on
every commit:
# In lefthook.yml
commit-msg:
commands:
conventional-format:
run: |
# Only enforce on commits to feature branches, not merges or rebases
head_commit=$(cat {1})
if echo "$head_commit" | head -1 | \
grep -qE '^(feat|fix|chore|docs|style|refactor|perf|test|build|ci|revert)(\(.+\))?: .+'; then
exit 0
fi
echo "Commit message must follow Conventional Commits format."
echo " Examples: feat: add user search"
echo " fix(auth): correct OAuth redirect URL"
echo " chore!: drop Node 18 support"
exit 1
On commit-msg hooks specifically
Commit-msg hooks are the most controversial of the hook types because they reject commits at the moment the engineer has just finished writing them. The friction is real, and it falls disproportionately on engineers new to the project. If you adopt this hook, ship it with a clear error message that explains the format and links to the team's commit-message documentation. The hook should be a teaching tool, not just a gatekeeper.
Lockfile Discipline via .gitattributes¶
Lockfiles (Gemfile.lock, uv.lock, bun.lock, package-lock.json,
Cargo.lock) are generated artifacts that change with every dependency
update and produce noisy PR diffs. The framework's global
.gitattributes marks them as linguist-generated, which collapses
them by default in GitHub's PR-review UI.
# Mark generated lockfiles
*.lock linguist-generated=true
Gemfile.lock linguist-generated=true
yarn.lock linguist-generated=true -diff
package-lock.json linguist-generated=true -diff
bun.lock linguist-generated=true -diff
For lockfiles where the format is genuinely opaque (package-lock.json,
yarn.lock, bun.lock), also add -diff to disable diff rendering
entirely — git prints "Binary files differ" rather than attempting to
render the diff. This is faster on commits that touch lockfiles and
avoids merge conflicts that look like meaningful changes but aren't.
Note the deliberate omission: Gemfile.lock and uv.lock are not
marked -diff. They're text-based, occasionally human-readable, and
reviewing their diffs sometimes catches genuine issues (a transitive
dependency upgrade that pulls in a sketchy package, a version
downgrade nobody intended). The framework keeps these as diffable text
but collapsed by default — the right blend of discoverability and
noise reduction.
Branch Protection via gh CLI¶
GitHub's branch protection rules — require PR review, require status
checks to pass, restrict force-push, etc. — are typically configured
through the GitHub web UI. They can also be configured through the
gh CLI, which makes them scriptable, reproducible, and committable
as configuration.
The framework's recommended pattern: a .github/branch-protection.sh
script that sets the team's policy on the protected branch:
#!/bin/sh
# .github/branch-protection.sh
# Set branch protection on main to match the team's policy.
# Run once after repo creation or after policy changes.
set -eu
REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner)
gh api \
--method PUT \
-H "Accept: application/vnd.github+json" \
"/repos/${REPO}/branches/main/protection" \
-f required_status_checks[strict]=true \
-f required_status_checks[contexts][]=ci/test \
-f required_status_checks[contexts][]=ci/lint \
-f enforce_admins=false \
-f required_pull_request_reviews[required_approving_review_count]=1 \
-f required_pull_request_reviews[dismiss_stale_reviews]=true \
-f required_pull_request_reviews[require_code_owner_reviews]=false \
-f restrictions=null \
-f allow_force_pushes=false \
-f allow_deletions=false \
-f required_linear_history=true
echo "Branch protection set on ${REPO}:main"
What this enforces:
- PRs require at least one approving review before merge.
- Status checks (CI: test, CI: lint) must pass before merge.
- Stale reviews are dismissed when new commits land — a reviewer who approved a PR three commits ago must re-review the latest version.
- Force-push to main is blocked. Engineers cannot accidentally rewrite shared history.
- Branch deletion is blocked. The main branch cannot be removed even by a repo admin.
- Linear history is required — PRs must be rebased or squash-merged,
not merge-committed. This keeps
git log --onelinereadable indefinitely.
On linear history specifically
Linear history (required_linear_history=true) is mildly
opinionated. Some teams prefer merge commits as a way of
preserving the historical fact of "these commits were on a
branch." The framework's recommendation toward linear history is
for projects where the linear git log is the primary
historical artifact. For projects where the merge-commit
topology genuinely matters (release branches, long-lived feature
branches with their own histories), linear history may be the
wrong choice. This is a team decision; the script above can be
adjusted.
Useful Patterns That Aren't Strict Enforcement¶
Two patterns worth mentioning that are encouraged by configuration but not strictly enforced:
-
commit.verbose = true(in main git config) shows the diff of staged changes in the commit-message editor. This makes engineers more likely to write accurate commit messages because the change is right there while they type — they can refer to specific lines, notice things they didn't intend to commit, and produce more useful messages. -
rebase.autoStash = true(also in main config) automatically stashes uncommitted changes before a rebase and reapplies them after. The alternative — "rebase failed because of uncommitted changes, you must stash manually" — is a small but persistent annoyance. autoStash makes the common case of "pull and rebase while I have local changes" just work.
Both are configuration, not enforcement — engineers can override either in their personal config. The framework's stance: the team's main config sets sensible defaults, individual engineers customize where they have personal preferences, and PR review catches the cases where customization produced something the team doesn't want.