Branch Name Enforcement Hook
Husky pre-push hook that rejects branch names not matching a prefix convention (feat/, fix/, chore/, gsd/) — runs on push so local scratch branches stay free-form.
5 min read · Updated 11 days ago · Recently updated · 👍 0
A branch-name enforcement hook checks the current branch against a prefix convention — feat/..., fix/..., chore/..., docs/..., refactor/..., test/..., gsd/..., plus a small protected list (main, develop) — and rejects the push if the name doesn't match. The point isn't to constrain creativity, it's to make git log and the PR list legible at a glance. A team that uses prefixes consistently can read a 40-PR backlog and group it by intent without opening anything. A team that doesn't ends up with a mix of wip-stuff, johns-branch, and temp-fix-pls-review that takes ten times longer to triage.
The check exits zero for protected branches and convention-matching branches, exits non-zero with a helpful message for everything else. It ships as the first stage of the pre-push validation hook — installing that single artifact gives you branch enforcement plus the typecheck/lint/test chain in one .husky/pre-push, with the branch check running first so a misnamed branch fails fast. This page explains the convention and the rationale behind it; see the Coexisting with the Validation Chain section below for how the two checks compose.
#// Where in the Hook Chain
The two reasonable places for this check are pre-commit (catches the name on the first commit to the branch) and pre-push (catches the name when it's about to be published). They have very different feels:
set -e
branch=$(git symbolic-ref --short HEAD 2>/dev/null || echo "")
# Allow detached-HEAD pushes (rare, but happens for `git push origin <sha>:refs/...`)
[ -z "$branch" ] && exit 0Pre-commit feels stricter on paper — "you can't even commit on a non-conforming branch" sounds disciplined. In practice, it produces a noisy hook that fires fifty times for a single bad name (one rejection per commit attempt) and trains developers to ignore the message because they're going to rename eventually. Pre-push fires once, at the moment the name actually matters, and either lets the push through or asks for a rename. The signal-to-noise ratio is the entire reason to prefer it.
// decision
Pre-push, not pre-commit, for branch-name enforcement
- Enforce on pre-commit: Catches the name earlier; produces 10-50x the noise per non-conforming branch, training developers to ignore the hook
- Enforce on remote with a server-side hook: Most precise but requires server-side access (GitHub branch protection won't reject by name pattern); pushes a remote-side rejection that's harder to recover from than a local one
#// The Convention
The matching is a single case statement with one branch per allowed prefix, plus a protected-branch fallthrough:
case "$branch" in
main|master|develop|staging|production)
# Protected branches — push allowed (though force-push protection should
# live in remote config, not here).
exit 0
;;
feat/*|fix/*|chore/*|docs/*|refactor/*|test/*|perf/*|build/*|ci/*|revert/*)
# Conventional-prefix branches — push allowed.
exit 0
;;
gsd/*)
# GSD-workflow branches — push allowed.
exit 0
;;
esacThe prefix list mirrors the Conventional Commits types (feat, fix, chore, etc.) for symmetry — a feat/... branch produces feat: commits, which is the same vocabulary the team is already using in their commit history. The gsd/... prefix is a project-specific addition for branches that follow the GSD workflow (one branch per phase). Teams that don't use that workflow can drop the line; the rest stay.
#// The Failure Message
Hostile hooks fail with a single line of regex output. Helpful hooks fail with a sentence-and-an-example that tells the developer what's wrong and how to fix it:
# If we got here, the branch didn't match any allowed pattern.
echo "" >&2
echo "✘ branch-name-enforcement: '$branch' violates the convention" >&2
echo "" >&2
echo " Allowed prefixes: feat/, fix/, chore/, docs/, refactor/," >&2
echo " test/, perf/, build/, ci/, revert/, gsd/" >&2
echo " Protected: main, master, develop, staging, production" >&2
echo "" >&2
echo " Rename with: git branch -m $branch <new-name>" >&2
echo " Then re-push: git push -u origin <new-name>" >&2
exit 1The rename command is the load-bearing part. A developer who reads "your branch name is wrong" and has to look up the rename syntax is more likely to --no-verify past the hook than to fix the name. A developer who sees git branch -m current-branch new-name literally in the error output renames it. Hooks are a UX problem dressed up as a process problem.
#// Coexisting with the Validation Chain
Branch enforcement and the expensive validation chain (typecheck, lint, tests) both want to own .husky/pre-push. Rather than ship two artifacts that silently overwrite each other, they're combined into a single hook — the pre-push validation artifact — with the branch check running first:
# 1. Branch-name check (fail fast)
branch=$(git symbolic-ref --short HEAD 2>/dev/null || echo "")
if [ -n "$branch" ]; then
protected_regex='^(main|master|develop|staging|production)$'
prefix_regex='^(feat|fix|chore|docs|refactor|test|perf|build|ci|revert|gsd)/.+'
if ! printf '%s' "$branch" | grep -qE "$protected_regex" &&
! printf '%s' "$branch" | grep -qE "$prefix_regex"; then
echo "✘ '$branch' violates branch-name convention" >&2
exit 1
fi
fi
# 2. The expensive validation chain (typecheck, lint, tests)
# ... pre-push-validation logic below ...Putting the branch check first is deliberate: it's the fastest check, and a failure here means the slower typecheck/lint/test pass would have wasted ten seconds on a branch that's getting renamed anyway. The standalone case-statement form shown in The Convention and the combined grep -qE form are equivalent — the combined hook just continues to the next check on success instead of exit 0.
#// Team-vs-solo Trade-offs
Solo developers can skip this hook entirely — there's no PR backlog to triage, no team-shared git log to keep legible, and the discipline of writing well-named branches comes free with the discipline of writing good commit messages. The hook starts paying its keep at the moment a second developer joins, and the payoff scales linearly with team size. By the time a team is at 5-10 developers actively pushing, an inconsistent branch namespace becomes a measurable productivity drag — code reviewers can't tell whether temp-debug is the urgent fix or the throwaway exploration without opening the PR.
The protected-branch list is the other axis to tune per team. The default (main, master, develop, staging, production) covers the standard branching models. Trunk-based teams can drop develop/staging/production from the list (they don't have those branches) without changing anything else. GitFlow teams should add release/* and hotfix/* to the allowed-prefix list, since those are part of the workflow's vocabulary.
#// What This Hook Deliberately Doesn't Do
The hook does not enforce ticket-ID prefixes (feat/JIRA-1234-add-foo style). That convention exists, but it's tightly coupled to a specific issue tracker — a hook that enforces it has to be re-customized per repo, which defeats the "drop in and go" point of a shared hook. Teams that want the discipline can extend the matching with their own pattern. The default stays vocabulary-only, which fits any team that uses Conventional Commits regardless of issue tracker.
The hook also does not enforce a maximum branch-name length. Git has its own limits (255 chars on most filesystems), and shorter-is-better is a stylistic preference, not a correctness one. If a developer wants to call their branch feat/the-most-elaborate-and-thoroughly-described-feature-name-imaginable, the hook lets them — the prefix is what matters.
// decisions
Enforce on pre-push, not pre-commit or branch create
Local branches are scratch space — half of them never leave the laptop, get renamed twice before the first push, or are throwaway WIP that the developer rebases away. Enforcing at branch creation produces friction with no payoff for those cases. Enforcing on pre-push catches the moment the name actually starts to matter, which is when it lands on a ref the team will see.