Skip to main content

Documentation Index

Fetch the complete documentation index at: https://factory-docs-auto-sync-jp-docs.mintlify.app/llms.txt

Use this file to discover all available pages before exploring further.

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