Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .github/workflows/test-git-hooks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
name: Test git hooks

on:
push:
paths:
- 'git-hooks/**'
- '.github/workflows/test-git-hooks.yml'
pull_request:
paths:
- 'git-hooks/**'
- '.github/workflows/test-git-hooks.yml'

jobs:
test:
name: Pester tests (${{ matrix.os }})
runs-on: ${{ matrix.os }}
permissions:
contents: read
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]

steps:
- uses: actions/checkout@v4

- name: Install Pester
shell: pwsh
run: |
if (-not (Get-Module -ListAvailable -Name Pester | Where-Object { $_.Version -ge '5.0' })) {
Install-Module -Name Pester -MinimumVersion 5.0 -Force -SkipPublisherCheck -Scope CurrentUser
}
Import-Module Pester -MinimumVersion 5.0

- name: Make bash hook executable
if: runner.os != 'Windows'
run: chmod +x git-hooks/commit-msg

- name: Run Pester tests
shell: pwsh
run: |
$config = New-PesterConfiguration
$config.Run.Path = 'git-hooks/tests/commit-msg.Tests.ps1'
$config.Output.Verbosity = 'Detailed'
$config.TestResult.Enabled = $true
$config.TestResult.OutputPath = 'test-results.xml'
$config.Run.Exit = $true
Invoke-Pester -Configuration $config

- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results-${{ matrix.os }}
path: test-results.xml
49 changes: 49 additions & 0 deletions git-hooks/commit-msg
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# commit-msg git hook
# Strips AI-generated trailer lines from commit messages while preserving
# human Co-authored-by, Signed-off-by, and all other content.
#
# Handles (case-insensitive) lines such as:
# Made-with: Cursor
# Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
# Co-authored-by: GitHub Copilot <noreply@github.com>
# Co-authored-by: Amazon Q <q@amazon.com>
# Generated-by: GitHub Copilot

COMMIT_MSG_FILE="$1"
[ -f "$COMMIT_MSG_FILE" ] || exit 0

# 1. Trailer keys that always indicate AI-generated content
MARKER_PATTERN='^[[:space:]]*(made-with|generated-by|suggested-by)[[:space:]]*:'

# 2. Co-authored-by lines where the author name starts with a known AI tool
# The trailing ([[:space:]]|<|$) ensures we match whole words (e.g. "Claude"
# but also "Claude Opus 4.6" while not matching a hypothetical "Claudette").
AI_NAMES_PATTERN='^[[:space:]]*co-authored?-by[[:space:]]*:[[:space:]]*(github[[:space:]]+copilot|copilot|claude|amazon[[:space:]]+q|amazon[[:space:]]+codewhisperer|codewhisperer|gemini|chatgpt|gpt-?[[:digit:]]|codeium|tabnine|windsurf|opencode)([[:space:]]|<|$)'

# 3. Co-authored-by lines whose email domain belongs to a known AI provider
AI_EMAIL_PATTERN='^[[:space:]]*co-authored?-by[[:space:]]*:.*<[^>]*@(anthropic\.com|cursor\.sh|codeium\.com|cognition\.ai)[^>]*>'

tmp=$(mktemp) || exit 1
trap 'rm -f "$tmp"' EXIT

while IFS= read -r line || [ -n "$line" ]; do
# Strip trailing carriage return to handle CRLF input safely
line="${line%$'\r'}"

if printf '%s\n' "$line" | grep -qiE "$MARKER_PATTERN"; then
continue
fi
if printf '%s\n' "$line" | grep -qiE "$AI_NAMES_PATTERN"; then
continue
fi
if printf '%s\n' "$line" | grep -qiE "$AI_EMAIL_PATTERN"; then
continue
fi
printf '%s\n' "$line"
done < "$COMMIT_MSG_FILE" > "$tmp"

# Remove trailing blank lines to avoid leaving a dangling blank when all
# trailers were stripped, then overwrite the original commit-msg file.
awk '/[^[:space:]]/{last=NR} {lines[NR]=$0} END{for(i=1;i<=last;i++) print lines[i]}' \
"$tmp" > "$COMMIT_MSG_FILE"
69 changes: 69 additions & 0 deletions git-hooks/commit-msg.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
#Requires -Version 5.1
# commit-msg git hook (PowerShell version)
# Strips AI-generated trailer lines from commit messages while preserving
# human Co-authored-by, Signed-off-by, and all other content.
#
# Handles (case-insensitive) lines such as:
# Made-with: Cursor
# Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
# Co-authored-by: GitHub Copilot <noreply@github.com>
# Co-authored-by: Amazon Q <q@amazon.com>
# Generated-by: GitHub Copilot

function Remove-AITrailers {
<#
.SYNOPSIS
Filters AI-generated trailer lines from an array of commit-message lines.
.PARAMETER Lines
The lines of the commit message to process.
.OUTPUTS
String[] – the filtered lines with trailing blank lines removed.
#>
[OutputType([string[]])]
param(
[Parameter(Mandatory)][AllowEmptyCollection()][AllowEmptyString()][string[]]$Lines
)

# 1. Trailer keys that always indicate AI-generated content
$markerPattern = '^\s*(made-with|generated-by|suggested-by)\s*:'

# 2. Co-authored-by where the name starts with a known AI tool
# The trailing (\s|<|$) keeps whole-word matching so "Claude" still
# matches "Claude Opus 4.6" but won't silently drop "Claudette Smith".
$aiNamesPattern = '^\s*co-authored?-by\s*:\s*(github\s+copilot|copilot|claude|amazon\s+q|amazon\s+codewhisperer|codewhisperer|gemini|chatgpt|gpt-?\d+|codeium|tabnine|windsurf|opencode)(\s|<|$)'

# 3. Co-authored-by whose email domain belongs to a known AI provider
$aiEmailPattern = '^\s*co-authored?-by\s*:.*<[^>]*@(anthropic\.com|cursor\.sh|codeium\.com|cognition\.ai)[^>]*>'

$filtered = [System.Collections.Generic.List[string]]::new()
foreach ($line in $Lines) {
if ($line -imatch $markerPattern) { continue }
if ($line -imatch $aiNamesPattern) { continue }
if ($line -imatch $aiEmailPattern) { continue }
$filtered.Add($line)
}

# Remove trailing blank lines
while ($filtered.Count -gt 0 -and [string]::IsNullOrWhiteSpace($filtered[$filtered.Count - 1])) {
$filtered.RemoveAt($filtered.Count - 1)
}

return $filtered.ToArray()
}

# Run as a git hook when executed directly (not dot-sourced by tests)
if ($MyInvocation.InvocationName -ne '.') {
$commitMsgFile = if ($args.Count -gt 0) { $args[0] } else { $null }
if ($commitMsgFile -and (Test-Path $commitMsgFile)) {
$lines = Get-Content -Path $commitMsgFile -Encoding UTF8
if ($null -eq $lines) { $lines = @() }
$filtered = Remove-AITrailers -Lines $lines
# Write back with LF line endings and a final newline
$content = if ($filtered.Count -gt 0) { ($filtered -join "`n") + "`n" } else { '' }
[System.IO.File]::WriteAllText(
$commitMsgFile,
$content,
[System.Text.Encoding]::UTF8
)
}
}
41 changes: 41 additions & 0 deletions git-hooks/install.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#Requires -Version 5.1
# Installs the global git commit-msg hook on Windows.
# Configures git to use %LOCALAPPDATA%\git\hooks as the global hooks directory.
#
# Two installation modes are supported:
# 1. Bash hook – copies commit-msg (works via Git for Windows' bundled bash)
# 2. PowerShell hook – copies commit-msg.ps1 plus a thin bash shim named
# commit-msg that calls pwsh so native PowerShell handles the filtering.
#
# By default, mode 1 (bash) is used. Pass -UsePowerShell to use mode 2.

param(
[switch]$UsePowerShell
)

$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$HooksDir = Join-Path $env:LOCALAPPDATA 'git\hooks'

if (-not (Test-Path $HooksDir)) {
New-Item -ItemType Directory -Path $HooksDir -Force | Out-Null
}

if ($UsePowerShell) {
# Copy the PowerShell hook script
Copy-Item "$ScriptDir\commit-msg.ps1" "$HooksDir\commit-msg.ps1" -Force

# Write a minimal bash shim that delegates to pwsh
$shim = @'
#!/usr/bin/env bash
pwsh -NoProfile -NonInteractive -ExecutionPolicy Bypass \
-File "$(dirname "$0")/commit-msg.ps1" "$1"
'@
[System.IO.File]::WriteAllText("$HooksDir\commit-msg", $shim, [System.Text.Encoding]::UTF8)
Write-Host "Installed PowerShell commit-msg hook → $HooksDir"
} else {
Copy-Item "$ScriptDir\commit-msg" "$HooksDir\commit-msg" -Force
Write-Host "Installed bash commit-msg hook → $HooksDir"
}

git config --global core.hooksPath $HooksDir
Write-Host "Global git hooks path set to: $HooksDir"
17 changes: 17 additions & 0 deletions git-hooks/install.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Installs the global git commit-msg hook on macOS / Linux.
# Configures git to use ~/.config/git/hooks as the global hooks directory.

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HOOKS_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/git/hooks"

mkdir -p "$HOOKS_DIR"
cp "$SCRIPT_DIR/commit-msg" "$HOOKS_DIR/commit-msg"
chmod +x "$HOOKS_DIR/commit-msg"

git config --global core.hooksPath "$HOOKS_DIR"

echo "Installed commit-msg hook → $HOOKS_DIR/commit-msg"
echo "Global git hooks path set to: $HOOKS_DIR"
Loading