Git Auditor: Native Shell Hooks for a Minimum Viable Team Standard

When teams grow, commit styles drift and lint catches up too late. Git Auditor uses versioned shell hooks in .githooks/ to reinforce a minimum viable standard — locally, before CI.

EP

Ender Puentes

Context: a minimum viable standard for the team

When a team grows — or when you return to a repo after a while — the same friction shows up: everyone commits with a different style, branch names are hard to read at a glance, and lint or type issues surface late, on push or in CI. It is not bad intent; it is the lack of an agreed common floor.

This note describes how we address that with Git Auditor: native hooks in .githooks/ that reinforce a minimum viable standard — readable commits, traceable branches, lint and types before push — without making day-to-day work heavier.

The idea is simple: rules live in the repo, activate automatically when dependencies are installed, and help everyone write code in a similar way before the remote or CI has to step in.

How it is structured

The pattern splits three layers that should stay separate:

  • Init (prepare) · Activates hooks on each developer machine — Node (bootstrap only)
  • Hooks (.githooks/*) · Applies agreed rules on every commit/push — Shell + Git (universal)
  • Invoked commands · Run lint, types, dead-code checks, etc. — Project runtime (pnpm, go test, ruff, …)

Hooks are #!/bin/sh scripts. Lightweight policy — commit format, branch names, what runs on pre-commit vs pre-push — lives there with tools Git already runs. Heavier checks (linter, TypeScript, etc.) delegate to the project toolchain.

We chose native hooks with core.hooksPath because Git already provides the hook point; we version scripts in the repo and avoid another layer just to orchestrate the same thing. Husky, Commitlint, and similar tools can play a similar role; here we prioritize readable scripts, few dependencies for message validation, and a pattern reusable across stacks by changing only the commands each hook invokes.

Why .git/ does not version hooks (and why a script is required)

Git stores local repository metadata inside .git/. That directory is not pushed to the remote: each clone is independent. Hooks Git runs by default live in .git/hooks/ — also local, also outside version control.

In practice, a pre-commit copied by hand into .git/hooks/ does not reach the rest of the team on git pull. Someone who clones from scratch does not inherit those scripts. Sharing rules requires a different approach.

The solution is a two-piece pattern:

  • Audit scripts · .githooks/ at repo root — Yes — travel with the code
  • Link “use these scripts as hooks” · core.hooksPath in .git/config — No — local per clone

core.hooksPath tells Git: when running pre-commit, commit-msg, etc., look in .githooks/ in the working tree, not in .git/hooks/. The files are in the repo; the link is not. That is why an init script (setup:githooks:init) must run, at minimum:

bash
git config core.hooksPath .githooks

Without that step, the team shares rules in .githooks/ but Git never invokes them. The script closes that gap on each machine. Init variants may delegate to init.sh / init.bat on Windows or consolidate logic in a single init.js; the problem and solution are the same.

How activation works: the prepare script

In package.json:

json
"setup:githooks:init": "node .githooks/scripts/init.js",
"prepare": "pnpm run setup:githooks:init"

prepare is an npm/pnpm lifecycle script that runs automatically after `pnpm install`. Cloning the repo and installing dependencies already triggers init — no extra README step to remember.

On each run, init:

1. Reads local core.hooksPath; if it does not point to .githooks, configures it.

2. Verifies the .githooks/ directory exists.

3. On Unix, marks hooks executable (chmod +x).

If someone cloned before prepare existed, or cleared their .git/config, they can reactivate manually:

bash
pnpm run setup:githooks:init
# equivalent:
git config core.hooksPath .githooks

How rules apply on each machine

Worth distinguishing two things: the rules the repo defines and how they activate on each computer.

  • Versioned: content of .githooks/* — what to validate, error messages, which commands to run.
  • Local: activation (core.hooksPath) and execution on each developer machine on git commit or git push.

Every team member has their own .git/config. Two people on the same repo can be in different states: one with hooks active, another with an empty core.hooksPath if they never ran install/init. Auditing is not centralized like a remote pre-receive hook; it is a process agreement reinforced locally.

That implies explicit limits:

  • A developer can run git commit --no-verify or git push --no-verify and skip hooks.
  • They can edit or remove core.hooksPath in local config.
  • Updating .githooks/pre-push in the repo does not retroactively “install” anything on machines that do not run init or pnpm install again (where prepare revalidates the link).

Hooks are team standard support — better developer experience, not a security wall. They help early, in the usual flow, for anyone who completed init. CI and branch protection on the remote remain the safety net for what local hooks cannot enforce.

Why we use shell in hooks

Git runs a hook as a child process of the git binary. The contract is simple: an executable that exits with code 0 (ok) or non-zero (block the operation).

Shell meets that contract on Linux, macOS, and most dev environments without compiling anything:

  • Pattern portability: the same .githooks/ + prepare scheme works on any stack. Only the invoked commands change (pnpm lint, make test, cargo clippy, etc.).
  • Lightweight validation without a runtime: Conventional Commits rules, header length, branch name regex, or merge commit detection do not need Node. A grep -qE in commit-msg is enough and starts in milliseconds.
  • Single source of truth: .githooks/commit-msg is the source of truth for commit format; repo documentation points there.
  • Fewer failure points in the hook itself: if the hook depended on node node_modules/.bin/commitlint, an incomplete install or broken PATH would block commits before the message is validated.

Hooks do delegate to the project stack for the heavy part of the standard: linter, type checking, dead-code detection. Shell orchestrates; the repo language executes.

What a minimum viable standard can include

Concrete rules depend on the team and repo maturity. The pattern supports layering without changing mechanism:

  • pre-commit · Before creating the commit — Lint on staged files; formatting; quick tests. Skips merges.
  • commit-msg · When writing the message — Conventional Commits; max length; branch policy (main merge/hotfix only).
  • pre-push · Before pushing — Typecheck; full lint; dead-code analysis; branch name validation.

Explicit bypass (when needed): git commit --no-verify / git push --no-verify.

The init calls itself Git Auditor in the console: it summarizes the intent to channel the standard before code reaches the remote or CI.

The minimum viable standard grows with the team — more rules in pre-push as the repo matures, release windows, branch protection — but always under the same contract: policy versioned in `.githooks/`, local activation via script, shell execution, heavy checks delegated to the project toolchain.

When it makes sense (and when it does not)

Makes sense when:

  • You want the whole team to write code and commits under the same baseline rules, without relying on memory or constant manual review.
  • The team uses Git consistently and you can hook init to pnpm install (or equivalent prepare on npm/yarn).
  • You prefer readable, adjustable hooks without an extra framework for message validation.

Honest limitations (complement to local enforcement above):

  • Windows needs attention (sh, Git Bash, or .bat scripts in init).
  • If rules grow large, extract shared functions in .githooks/lib/ (still shell) before migrating to another tool.

Implementation guide

Create the directory tree at the repo root. Hook files are shell scripts (#!/bin/sh). The init script configures core.hooksPath and runs automatically after pnpm install via the prepare lifecycle. Replace pnpm commands with your project toolchain if needed.

Directory structure

text
.githooks/
├── README.md
├── pre-commit
├── commit-msg
├── pre-push
└── scripts/
    └── init.js

package.json

Add these scripts so every clone activates Git Auditor on install:

json
"setup:githooks:init": "node .githooks/scripts/init.js",
"prepare": "pnpm run setup:githooks:init"

Init script (.githooks/scripts/init.js)

javascript
#!/usr/bin/env node
/* eslint-disable @typescript-eslint/no-require-imports */
'use strict';

/**
 * Cross-platform Git hooks initialization.
 * Configures core.hooksPath and ensures hook scripts are executable.
 */

const { execFileSync } = require('child_process');
const fs = require('fs');
const path = require('path');

const HOOKS_PATH = '.githooks';
const root = path.resolve(__dirname, '../..');
const hooksDir = path.join(root, HOOKS_PATH);

function getHooksPath() {
  try {
    return execFileSync('git', ['config', '--get', 'core.hooksPath'], {
      cwd: root,
      encoding: 'utf8',
    }).trim();
  } catch {
    return '';
  }
}

const currentHooksPath = getHooksPath();

if (currentHooksPath !== HOOKS_PATH) {
  console.log('🔧 Initializing Git Auditor...');
  console.log(`   Configuring Git hooks path to ${HOOKS_PATH}...`);
  execFileSync('git', ['config', 'core.hooksPath', HOOKS_PATH], {
    cwd: root,
    stdio: 'inherit',
  });
  console.log('✅ Git Auditor initialized successfully');
} else {
  console.log('✅ Git Auditor already initialized');
}

if (!fs.existsSync(hooksDir)) {
  console.error(`⚠️  Warning: Hooks directory ${HOOKS_PATH} not found`);
  process.exit(1);
}

if (process.platform !== 'win32') {
  for (const entry of fs.readdirSync(hooksDir)) {
    const filePath = path.join(hooksDir, entry);
    if (fs.statSync(filePath).isFile()) {
      try {
        fs.chmodSync(filePath, 0o755);
      } catch {
        // Ignore chmod errors (e.g. read-only filesystem)
      }
    }
  }
}

console.log('');
console.log('Git Auditor is now active. Hooks will:');
console.log('  • Validate branch names (main, develop, or issue branches)');
console.log('  • Validate commit messages (Conventional Commits format)');
console.log('  • Run linting before commits');
console.log('  • Run type checking, knip, and linting before pushes');

pre-commit

Runs lint-staged on staged files. Skips merge commits.

shell
#!/bin/sh
set -e

# Allow merge commits
if [ -f .git/MERGE_HEAD ]; then
  exit 0
fi

# Function to check if error is due to missing dependencies
check_dependency_error() {
  error_output="$1"
  if echo "$error_output" | grep -qiE "(cannot find module|module not found|cannot resolve|missing|dependency|pnpm|npm|node_modules|command not found)" 2>/dev/null; then
    echo ""
    echo "⚠️  Possible missing dependencies detected!"
    echo "   Try running: pnpm install"
    echo ""
    return 0
  fi
  return 1
}

echo "🔍 Running lint-staged..."
if ! error_output=$(pnpm exec lint-staged 2>&1); then
  echo "$error_output"
  check_dependency_error "$error_output"
  echo "❌ Lint-staged failed"
  exit 1
fi

echo "✅ All pre-commit checks passed successfully."

commit-msg

Validates branch names (main, develop, or issue branches) and Conventional Commits. main allows merge commits and hotfix(scope): only.

shell
#!/bin/sh
set -e

# Get current branch
branch=$(git branch --show-current)

# Validate branch name: main, develop, or GitHub issue branches (e.g. 123-add-user-auth)
if [ -n "$branch" ]; then
  case "$branch" in
    main|develop) ;;
    *)
      if ! echo "$branch" | grep -qE '^[0-9]+-[a-z0-9]+(-+[a-z0-9]+)*$'; then
        echo ""
        echo "🌿 Invalid branch name: \"$branch\""
        echo ""
        echo "This project uses a simple naming standard to keep work traceable:"
        echo ""
        echo "  ✅ main                    — production branch"
        echo "  ✅ develop                 — integration branch"
        echo "  ✅ 123-add-user-auth       — issue branches (number + slug)"
        echo ""
        echo "To create a valid branch:"
        echo "  1. Open a GitHub Issue describing the work you want to do"
        echo "  2. Click \"Create a branch\" from the issue sidebar"
        echo "     (GitHub will suggest a name like 42-your-issue-title)"
        echo "  3. Check out that branch locally and start working"
        echo ""
        echo "Branch names like feat/*, fix/*, or other custom conventions"
        echo "don't match our workflow. Starting from an issue keeps the branch"
        echo "number linked to the ticket and helps the whole team stay aligned."
        echo ""
        exit 1
      fi
      ;;
  esac
fi

# Get commit message from file (first line only, trim whitespace)
commit_msg=$(head -n1 "$1" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')

# Validate that commit message is not empty
if [ -z "$commit_msg" ]; then
  echo "❌ Commit message cannot be empty"
  exit 1
fi

# Check if it's a merge commit
# Merge commits can have various formats:
# - "Merge branch 'xxx' into yyy"
# - "Merge remote-tracking branch 'origin/xxx' into yyy"
# - "Merge commit 'xxx' into yyy"
# - "Merge pull request #xxx"
is_merge=false
if echo "$commit_msg" | grep -qiE "^Merge (branch|remote-tracking branch|commit|pull request)"; then
  is_merge=true
fi

# Validation rules by branch
case "$branch" in
  main)
    # In main: allow hotfix, chore(release), or merge commits
    if [ "$is_merge" = true ]; then
      echo "✅ Merge commit allowed in main"
      exit 0
    fi
    
    # Check for hotfix prefix
    if echo "$commit_msg" | grep -qE "^hotfix\([^)]+\):"; then
      echo "✅ Hotfix commit allowed in main"
      exit 0
    fi
    
    echo "❌ Commits to 'main' must be:"
    echo "   - Merge commits from other branches"
    echo "   - Commits with prefix 'hotfix(scope):'"
    echo ""
    echo "Current commit message: $commit_msg"
    exit 1
    ;;
esac

# TEMP: develop allows Conventional Commits during initial repo setup.
# Restore merge-only policy in commit-msg when issue-branch workflow is active.

# Validate Conventional Commits format for other branches
# Format: type(scope): subject or type: subject
# Allowed types: feat, fix, hotfix, chore, test, docs, style, ci
# Max header length: 120 characters

# Skip validation for merge commits
if [ "$is_merge" = true ]; then
  exit 0
fi

# Check header length (max 120 characters)
# Use wc -m to count characters (not bytes) for proper UTF-8 support
header_length=$(printf '%s' "$commit_msg" | wc -m)
if [ "$header_length" -gt 120 ]; then
  echo "❌ Commit message header exceeds 120 characters (current: $header_length)"
  echo ""
  echo "Current commit message: $commit_msg"
  exit 1
fi

# Validate Conventional Commits format
# Pattern: type(scope): subject or type: subject
# Must start with type (no leading spaces), have optional scope in parentheses,
# followed by colon and space, then non-empty subject
# Allowed types: feat, fix, hotfix, chore, test, docs, style, ci
if ! echo "$commit_msg" | grep -qE "^(feat|fix|hotfix|chore|test|docs|style|ci)(\([^)]+\))?: .+"; then
  echo "❌ Invalid commit message format"
  echo ""
  echo "Commit messages must follow Conventional Commits format:"
  echo "  type(scope): subject"
  echo "  or"
  echo "  type: subject"
  echo ""
  echo "Rules:"
  echo "  - Must start with type (no leading spaces)"
  echo "  - Scope is optional but must be in parentheses if present"
  echo "  - Must have colon followed by space"
  echo "  - Subject cannot be empty"
  echo ""
  echo "Allowed types: feat, fix, hotfix, chore, test, docs, style, ci"
  echo ""
  echo "Examples:"
  echo "  ✅ feat(auth): add social login"
  echo "  ✅ fix(api): resolve timeout issue"
  echo "  ✅ chore: update dependencies"
  echo "  ❌ feat(auth):  (empty subject)"
  echo "  ❌ feat(auth):(no space after colon)"
  echo ""
  echo "Current commit message: $commit_msg"
  exit 1
fi

echo "✅ Commit message format is valid"

pre-push

Re-validates branch names, then runs typecheck, knip, and full lint before push.

shell
#!/bin/sh
set -e

branch=$(git branch --show-current)
if [ -n "$branch" ]; then
  case "$branch" in
    main|develop) ;;
    *)
      if ! echo "$branch" | grep -qE '^[0-9]+-[a-z0-9]+(-+[a-z0-9]+)*$'; then
        echo ""
        echo "🌿 Invalid branch name: \"$branch\""
        echo ""
        echo "This project uses a simple naming standard to keep work traceable:"
        echo ""
        echo "  ✅ main                    — production branch"
        echo "  ✅ develop                 — integration branch"
        echo "  ✅ 123-add-user-auth       — issue branches (number + slug)"
        echo ""
        echo "To create a valid branch:"
        echo "  1. Open a GitHub Issue describing the work you want to do"
        echo "  2. Click \"Create a branch\" from the issue sidebar"
        echo "     (GitHub will suggest a name like 42-your-issue-title)"
        echo "  3. Check out that branch locally and start working"
        echo ""
        echo "Branch names like feat/*, fix/*, or other custom conventions"
        echo "don't match our workflow. Starting from an issue keeps the branch"
        echo "number linked to the ticket and helps the whole team stay aligned."
        echo ""
        exit 1
      fi
      ;;
  esac
fi

# Function to check if error is due to missing dependencies
check_dependency_error() {
  error_output="$1"
  if echo "$error_output" | grep -qiE "(cannot find module|module not found|cannot resolve|missing|dependency|pnpm|npm|node_modules|command not found)" 2>/dev/null; then
    echo ""
    echo "⚠️  Possible missing dependencies detected!"
    echo "   Try running: pnpm install"
    echo ""
    return 0
  fi
  return 1
}

# ============================================================================
# Code Quality Checks
# ============================================================================

# TypeScript type checks (strict enforced)
echo "🔍 Running TypeScript type checks..."
if ! error_output=$(pnpm typecheck 2>&1); then
  echo "$error_output"
  check_dependency_error "$error_output"
  echo "❌ TypeScript (tsc) failed. Please fix the type errors before pushing."
  exit 1
fi

# Run knip
echo "🔍 Running knip..."
if ! error_output=$(pnpm knip 2>&1); then
  echo "$error_output"
  check_dependency_error "$error_output"
  echo "❌ Knip failed. Please fix the issues before pushing."
  exit 1
fi

# Run full lint (complements pre-commit hook)
echo "🔍 Running lint..."
if ! error_output=$(pnpm lint 2>&1); then
  echo "$error_output"
  check_dependency_error "$error_output"
  echo "❌ Lint failed. Please fix the issues before pushing."
  exit 1
fi

echo ""
echo "✅ All pre-push checks passed successfully."

On Unix, init.js marks hooks as executable (chmod +x). Verify with: git config --get core.hooksPath

Quick reference

text
Goal:         minimum viable coding standard, enforced locally
Activation:   pnpm install → prepare → setup:githooks:init
Git config:   git config core.hooksPath .githooks   (local, in .git/config)
Hooks:        .githooks/pre-commit | commit-msg | pre-push   (versioned)
Init:         .githooks/scripts/init.js
Docs:         .githooks/README.md

Manual verification:

bash
git config --get core.hooksPath   # should print: .githooks
pnpm run setup:githooks:init      # re-run init if needed