Skip to main content
This cookbook shows how to use hooks to enforce Git workflows, validate commits, protect branches, and automate changelog generation.

How it works

Git workflow hooks can:
  1. Validate commits: Check commit messages follow conventions
  2. Protect branches: Prevent accidental commits to main/production
  3. Generate changelogs: Auto-create changelog entries from commits
  4. Run pre-push checks: Validate code before pushing
  5. Enforce PR requirements: Check branch names, linear issues, etc.

Prerequisites

Basic Git tools:
git --version

Basic Git hooks

Commit message validation

Enforce conventional commit format. Create .factory/hooks/validate-commit-msg.sh:
#!/bin/bash

input=$(cat)
tool_name=$(echo "$input" | jq -r '.tool_name')

# Only validate Bash commands that look like git commit
if [ "$tool_name" != "Bash" ]; then
  exit 0
fi

command=$(echo "$input" | jq -r '.tool_input.command')

# Check if this is a git commit command
if ! echo "$command" | grep -qE "^git commit"; then
  exit 0
fi

# Extract commit message from command
if echo "$command" | grep -qE "git commit -m"; then
  # Extract message from -m flag
  commit_msg=$(echo "$command" | sed -E 's/.*git commit.*-m[= ]*["\x27]([^"\x27]+)["\x27].*/\1/')
else
  # Allow commits without -m (will open editor)
  exit 0
fi

# Validate conventional commit format
# Format: type(scope): description
# Example: feat(auth): add login functionality

if ! echo "$commit_msg" | grep -qE "^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?:.+"; then
  echo "❌ Invalid commit message format" >&2
  echo "" >&2
  echo "Commit message must follow Conventional Commits format:" >&2
  echo "  type(scope): description" >&2
  echo "" >&2
  echo "Valid types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert" >&2
  echo "" >&2
  echo "Examples:" >&2
  echo "  feat(auth): add user login" >&2
  echo "  fix(api): handle null values" >&2
  echo "  docs: update README" >&2
  exit 2
fi

# Check for Linear issue reference
if ! echo "$commit_msg" | grep -qE "FAC-[0-9]+"; then
  echo "⚠️ No Linear issue reference found" >&2
  echo "Consider adding issue reference like: feat(auth): add login FAC-123" >&2
  # Warning only, don't block
fi

exit 0
chmod +x .factory/hooks/validate-commit-msg.sh
Add to .factory/settings.json:
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "\"$DROID_PROJECT_DIR\"/.factory/hooks/validate-commit-msg.sh",
            "timeout": 3
          }
        ]
      }
    ]
  }
}

Branch protection

Prevent commits directly to protected branches. Create .factory/hooks/protect-branches.sh:
#!/bin/bash

input=$(cat)
tool_name=$(echo "$input" | jq -r '.tool_name')
command=$(echo "$input" | jq -r '.tool_input.command // ""')

# Only check git commit commands
if [ "$tool_name" != "Bash" ] || ! echo "$command" | grep -qE "^git (commit|push)"; then
  exit 0
fi

cwd=$(echo "$input" | jq -r '.cwd')
cd "$cwd"

# Check if we're in a git repo
if [ ! -d ".git" ]; then
  exit 0
fi

current_branch=$(git branch --show-current)

# Protected branches that cannot be committed to directly
protected_branches=("main" "master" "production" "prod")

for branch in "${protected_branches[@]}"; do
  if [ "$current_branch" = "$branch" ]; then
    echo "❌ Cannot commit directly to protected branch: $branch" >&2
    echo "" >&2
    echo "Please create a feature branch instead:" >&2
    echo "  git checkout -b feature/your-feature-name" >&2
    echo "" >&2
    echo "Then create a pull request to merge your changes." >&2
    exit 2
  fi
done

exit 0
chmod +x .factory/hooks/protect-branches.sh

Enforce branch naming

Require feature branches to follow naming conventions: Create .factory/hooks/validate-branch-name.sh:
#!/bin/bash

input=$(cat)
tool_name=$(echo "$input" | jq -r '.tool_name')
command=$(echo "$input" | jq -r '.tool_input.command // ""')

# Only check git checkout -b commands
if [ "$tool_name" != "Bash" ] || ! echo "$command" | grep -qE "^git checkout -b"; then
  exit 0
fi

# Extract branch name
branch_name=$(echo "$command" | sed -E 's/.*git checkout -b[= ]*([^ ]+).*/\1/')

# Valid patterns:
# - feature/FAC-123-description
# - fix/FAC-123-description
# - hotfix/FAC-123-description

if ! echo "$branch_name" | grep -qE "^(feature|fix|hotfix|docs|refactor)/[A-Z]+-[0-9]+-[a-z0-9-]+$"; then
  echo "❌ Invalid branch name format" >&2
  echo "" >&2
  echo "Branch names must follow the pattern:" >&2
  echo "  type/ISSUE-123-description" >&2
  echo "" >&2
  echo "Examples:" >&2
  echo "  feature/FAC-123-add-user-auth" >&2
  echo "  fix/FAC-456-fix-login-bug" >&2
  echo "  hotfix/FAC-789-critical-security-fix" >&2
  exit 2
fi

exit 0
chmod +x .factory/hooks/validate-branch-name.sh

Advanced Git automation

Auto-generate changelog entries

Automatically create changelog entries from commits: Create .factory/hooks/update-changelog.sh:
#!/bin/bash
set -e

input=$(cat)
tool_name=$(echo "$input" | jq -r '.tool_name')
command=$(echo "$input" | jq -r '.tool_input.command // ""')

# Only run after git commit
if [ "$tool_name" != "Bash" ] || ! echo "$command" | grep -qE "^git commit"; then
  exit 0
fi

cwd=$(echo "$input" | jq -r '.cwd')
cd "$cwd"

# Get the last commit message
last_commit=$(git log -1 --pretty=format:"%s")

# Parse conventional commit
if echo "$last_commit" | grep -qE "^(feat|fix)(\(.+\))?:"; then
  commit_type=$(echo "$last_commit" | sed -E 's/^([^:(]+).*/\1/')
  commit_msg=$(echo "$last_commit" | sed -E 's/^[^:]+: (.+)/\1/')
  
  # Determine changelog section
  if [ "$commit_type" = "feat" ]; then
    section="### Features"
  elif [ "$commit_type" = "fix" ]; then
    section="### Bug Fixes"
  else
    exit 0
  fi
  
  # Create/update CHANGELOG.md
  if [ ! -f "CHANGELOG.md" ]; then
    cat > CHANGELOG.md << EOF
# Changelog

All notable changes to this project will be documented in this file.

## [Unreleased]

$section

- $commit_msg

EOF
  else
    # Insert into Unreleased section
    if grep -q "## \[Unreleased\]" CHANGELOG.md; then
      # Check if section exists
      if grep -q "^$section" CHANGELOG.md; then
        # Add to existing section
        sed -i.bak "/^$section/a\\
- $commit_msg
" CHANGELOG.md
      else
        # Create new section
        sed -i.bak "/## \[Unreleased\]/a\\
\\
$section\\
\\
- $commit_msg
" CHANGELOG.md
      fi
      rm CHANGELOG.md.bak 2>/dev/null || true
    fi
  fi
  
  echo "βœ“ Updated CHANGELOG.md"
fi

exit 0
chmod +x .factory/hooks/update-changelog.sh
Add to PostToolUse:
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "\"$DROID_PROJECT_DIR\"/.factory/hooks/update-changelog.sh",
            "timeout": 5
          }
        ]
      }
    ]
  }
}

Pre-push validation

Run tests and checks before allowing git push: Create .factory/hooks/pre-push-check.sh:
#!/bin/bash
set -e

input=$(cat)
tool_name=$(echo "$input" | jq -r '.tool_name')
command=$(echo "$input" | jq -r '.tool_input.command // ""')

# Only check git push commands
if [ "$tool_name" != "Bash" ] || ! echo "$command" | grep -qE "^git push"; then
  exit 0
fi

cwd=$(echo "$input" | jq -r '.cwd')
cd "$cwd"

echo "πŸ” Running pre-push checks..."

# Check for uncommitted changes
if [ -n "$(git status --porcelain)" ]; then
  echo "⚠️ You have uncommitted changes" >&2
  echo "Commit or stash them before pushing" >&2
  git status --short >&2
  exit 2
fi

# Run linter
if [ -f "package.json" ] && grep -q '"lint"' package.json; then
  echo "Running linter..."
  if ! npm run lint 2>&1; then
    echo "❌ Linting failed" >&2
    echo "Fix lint errors before pushing" >&2
    exit 2
  fi
  echo "βœ“ Linting passed"
fi

# Run tests
if [ -f "package.json" ] && grep -q '"test"' package.json; then
  echo "Running tests..."
  if ! npm test 2>&1; then
    echo "❌ Tests failed" >&2
    echo "Fix failing tests before pushing" >&2
    exit 2
  fi
  echo "βœ“ Tests passed"
fi

# Check for merge conflicts markers
if git grep -qE "^(<<<<<<<|=======|>>>>>>>)" 2>/dev/null; then
  echo "❌ Merge conflict markers found in files" >&2
  git grep -l "^(<<<<<<<|=======|>>>>>>>)" >&2
  exit 2
fi

echo "βœ“ All pre-push checks passed"

exit 0
chmod +x .factory/hooks/pre-push-check.sh

Auto-create PR when pushing new branch

Automatically open a PR when pushing a feature branch: Create .factory/hooks/auto-create-pr.sh:
#!/bin/bash
set -e

input=$(cat)
tool_name=$(echo "$input" | jq -r '.tool_name')
command=$(echo "$input" | jq -r '.tool_input.command // ""')

# Only run after successful git push of a new branch
if [ "$tool_name" != "Bash" ] || ! echo "$command" | grep -qE "^git push.*-u origin"; then
  exit 0
fi

cwd=$(echo "$input" | jq -r '.cwd')
cd "$cwd"

# Check if gh CLI is available
if ! command -v gh &> /dev/null; then
  exit 0
fi

current_branch=$(git branch --show-current)

# Don't create PR for main/master branches
if [[ "$current_branch" =~ ^(main|master|dev|develop)$ ]]; then
  exit 0
fi

# Check if PR already exists
if gh pr view &>/dev/null; then
  echo "ℹ️ PR already exists for this branch"
  exit 0
fi

# Extract issue number from branch name
issue_number=""
if [[ $current_branch =~ ([A-Z]+-[0-9]+) ]]; then
  issue_number="${BASH_REMATCH[1]}"
fi

# Generate PR title from branch name or commits
pr_title="$current_branch"
if [ -n "$issue_number" ]; then
  pr_title="$issue_number: $(echo "$current_branch" | sed -E 's/^[^/]+\/[A-Z]+-[0-9]+-//; s/-/ /g')"
fi

# Create PR
echo "πŸ”„ Creating pull request..."
if gh pr create --title "$pr_title" --body "Closes $issue_number" --web; then
  echo "βœ“ Pull request created and opened in browser"
else
  echo "⚠️ Could not create PR automatically"
fi

exit 0
chmod +x .factory/hooks/auto-create-pr.sh

Enforce co-authorship in commits

Add co-author trailers to commits: Create .factory/hooks/add-coauthor.sh:
#!/bin/bash
set -e

input=$(cat)
tool_name=$(echo "$input" | jq -r '.tool_name')
command=$(echo "$input" | jq -r '.tool_input.command // ""')

# Only modify git commit commands
if [ "$tool_name" != "Bash" ] || ! echo "$command" | grep -qE "^git commit.*-m"; then
  exit 0
fi

# Extract commit message
commit_msg=$(echo "$command" | sed -E 's/.*git commit.*-m[= ]*["\x27]([^"\x27]+)["\x27].*/\1/')

# Check if co-author is already present
if echo "$commit_msg" | grep -qE "Co-authored-by:"; then
  exit 0
fi

# Add factory droid co-author
coauthor="Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>"

# Modify command to include co-author
modified_msg="$commit_msg

$coauthor"

# Return modified command via JSON output
cat << EOF
{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "allow",
    "permissionDecisionReason": "Adding co-author to commit",
    "updatedInput": {
      "command": "$(echo "$command" | sed -E "s/(git commit.*-m[= ]*)[\"']([^\"']+)[\"']/\1\"$modified_msg\"/")"
    }
  }
}
EOF

exit 0
chmod +x .factory/hooks/add-coauthor.sh

Real-world examples

Example 1: Monorepo commit validation

Ensure commits only touch one package: Create .factory/hooks/validate-monorepo-scope.sh:
#!/bin/bash

input=$(cat)
tool_name=$(echo "$input" | jq -r '.tool_name')
command=$(echo "$input" | jq -r '.tool_input.command // ""')

if [ "$tool_name" != "Bash" ] || ! echo "$command" | grep -qE "^git commit"; then
  exit 0
fi

cwd=$(echo "$input" | jq -r '.cwd')
cd "$cwd"

# Get staged files
staged_files=$(git diff --cached --name-only)

if [ -z "$staged_files" ]; then
  exit 0
fi

# Check if changes span multiple packages
packages=$(echo "$staged_files" | grep -E "^(packages|apps)/" | cut -d/ -f1-2 | sort -u)
package_count=$(echo "$packages" | wc -l | tr -d ' ')

if [ "$package_count" -gt 1 ]; then
  echo "❌ Commit spans multiple packages" >&2
  echo "" >&2
  echo "Changed packages:" >&2
  echo "$packages" | sed 's/^/  - /' >&2
  echo "" >&2
  echo "Please commit changes to each package separately for clearer history." >&2
  exit 2
fi

exit 0

Example 2: Release automation

Auto-tag releases when version changes: Create .factory/hooks/auto-tag-release.sh:
#!/bin/bash
set -e

input=$(cat)
tool_name=$(echo "$input" | jq -r '.tool_name')

# Only run after commits
if [ "$tool_name" != "Bash" ]; then
  exit 0
fi

command=$(echo "$input" | jq -r '.tool_input.command // ""')
if ! echo "$command" | grep -qE "^git commit"; then
  exit 0
fi

cwd=$(echo "$input" | jq -r '.cwd')
cd "$cwd"

# Check if package.json version changed in last commit
if ! git diff HEAD~1 HEAD --name-only | grep -q "package.json"; then
  exit 0
fi

# Check if version field changed
if git diff HEAD~1 HEAD -- package.json | grep -q "^+.*\"version\""; then
  # Get new version
  new_version=$(jq -r '.version' package.json)
  
  echo "πŸ“¦ Version bump detected: v$new_version"
  echo "Creating git tag..."
  
  # Create and push tag
  if git tag "v$new_version" && git push origin "v$new_version"; then
    echo "βœ“ Created and pushed tag v$new_version"
  fi
fi

exit 0

Best practices

1

Use PreToolUse for prevention

Block bad commits before they happen:
# In PreToolUse hook
if invalid_commit; then
  echo "❌ Cannot proceed" >&2
  exit 2  # Blocks the git commit
fi
2

Use PostToolUse for automation

Automate followup actions after successful commits:
# In PostToolUse hook
if git_commit_successful; then
  update_changelog
  create_pr
fi
3

Provide clear error messages

Tell users exactly what’s wrong and how to fix it:
echo "❌ Commit message must include issue reference" >&2
echo "Example: feat(auth): add login FAC-123" >&2
4

Make hooks configurable

Allow teams to customize behavior:
PROTECTED_BRANCHES="${DROID_PROTECTED_BRANCHES:-main,master,production}"
REQUIRE_ISSUE_REF="${DROID_REQUIRE_ISSUE:-true}"
5

Test hooks with dry-run

Test without making actual commits:
# Simulate a commit
echo '{"tool_name":"Bash","tool_input":{"command":"git commit -m \"test\""}}' | \
  .factory/hooks/validate-commit-msg.sh

Troubleshooting

Problem: Validation too strict Solution: Add escape hatches:
# Allow bypass with special prefix
if echo "$commit_msg" | grep -q "^WIP:"; then
  echo "⚠️ WIP commit allowed"
  exit 0
fi
Problem: Both Droid hooks and .git/hooks running Solution: Coordinate or choose one:
# In .git/hooks/pre-commit
if [ -n "$DROID_SESSION" ]; then
  exit 0  # Let Droid hooks handle it
fi
Problem: Tests take too long Solution: Run quick checks only:
# Run fast subset of tests
npm run test:unit  # Skip slow integration tests

# Or run in parallel with push
npm test &
git push

See also