-
Notifications
You must be signed in to change notification settings - Fork 0
feat: prepare-commit-msg hook for passive AI-Agent trailer injection (GIT-70) #41
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,149 @@ | ||||||
| /** | ||||||
| * prepare-commit-msg git hook | ||||||
| * | ||||||
| * Installs/uninstalls a git hook that injects AI-Agent trailers | ||||||
| * into commit messages when an AI-assisted session is detected. | ||||||
| * | ||||||
| * Detection heuristics (checked in order): | ||||||
| * - $GIT_MEM_AGENT env var (explicit, user-defined agent string) | ||||||
| * - $CLAUDE_CODE env var (Claude Code session) | ||||||
| * | ||||||
| * The hook uses `git interpret-trailers` for proper formatting. | ||||||
| */ | ||||||
|
|
||||||
| import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync, chmodSync } from 'fs'; | ||||||
| import { join, resolve } from 'path'; | ||||||
| import { execFileSync } from 'child_process'; | ||||||
|
|
||||||
| /** Fingerprint comment used to detect our hook. */ | ||||||
| const HOOK_FINGERPRINT = '# git-mem:prepare-commit-msg v1'; | ||||||
|
|
||||||
| /** | ||||||
| * The shell hook script. | ||||||
| * Uses `git interpret-trailers` for correct trailer formatting. | ||||||
| */ | ||||||
| const HOOK_SCRIPT = `#!/bin/sh | ||||||
| ${HOOK_FINGERPRINT} | ||||||
| # Injects AI-Agent trailer when an AI-assisted session is detected. | ||||||
|
|
||||||
| COMMIT_MSG_FILE="$1" | ||||||
| COMMIT_SOURCE="$2" | ||||||
|
|
||||||
| # Skip merge/squash/amend commits | ||||||
| case "$COMMIT_SOURCE" in | ||||||
| merge|squash) exit 0 ;; | ||||||
| esac | ||||||
|
coderabbitai[bot] marked this conversation as resolved.
|
||||||
|
|
||||||
| # Detect AI agent | ||||||
| AGENT="" | ||||||
| if [ -n "$GIT_MEM_AGENT" ]; then | ||||||
| AGENT="$GIT_MEM_AGENT" | ||||||
| elif [ -n "$CLAUDE_CODE" ]; then | ||||||
| AGENT="Claude-Code" | ||||||
| fi | ||||||
|
|
||||||
| # No agent detected — exit silently | ||||||
| [ -z "$AGENT" ] && exit 0 | ||||||
|
|
||||||
| # Skip if AI-Agent trailer already present | ||||||
| grep -q "^AI-Agent:" "$COMMIT_MSG_FILE" && exit 0 | ||||||
|
|
||||||
| # Append trailer using git's built-in formatter | ||||||
| git interpret-trailers --in-place --trailer "AI-Agent: $AGENT" "$COMMIT_MSG_FILE" | ||||||
| `; | ||||||
|
|
||||||
| /** | ||||||
| * Find the .git directory for the repository at cwd. | ||||||
| * Handles worktrees where .git is a file pointing to the real git dir. | ||||||
| */ | ||||||
| function findGitDir(cwd: string): string { | ||||||
| try { | ||||||
| const gitDir = execFileSync( | ||||||
| 'git', ['rev-parse', '--git-dir'], | ||||||
| { encoding: 'utf8', cwd, stdio: ['pipe', 'pipe', 'pipe'] }, | ||||||
| ).trim(); | ||||||
| // git rev-parse may return a relative path — resolve it against cwd | ||||||
| return resolve(cwd, gitDir); | ||||||
| } catch { | ||||||
| return join(cwd, '.git'); | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * Check if an existing hook file was installed by git-mem. | ||||||
| */ | ||||||
| function isGitMemHook(hookPath: string): boolean { | ||||||
| if (!existsSync(hookPath)) return false; | ||||||
| const content = readFileSync(hookPath, 'utf8'); | ||||||
| return content.includes(HOOK_FINGERPRINT); | ||||||
| } | ||||||
|
|
||||||
| export interface IHookInstallResult { | ||||||
| /** Whether the hook was installed (false if already present). */ | ||||||
| readonly installed: boolean; | ||||||
| /** Whether an existing user hook was wrapped. */ | ||||||
| readonly wrapped: boolean; | ||||||
| /** Path to the installed hook. */ | ||||||
| readonly hookPath: string; | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * Install the prepare-commit-msg hook. | ||||||
| * Idempotent: re-running is safe. | ||||||
| * Wraps existing non-git-mem hooks by renaming them to .user-backup. | ||||||
| */ | ||||||
| export function installHook(cwd?: string): IHookInstallResult { | ||||||
| const gitDir = findGitDir(cwd || process.cwd()); | ||||||
| const hooksDir = join(gitDir, 'hooks'); | ||||||
| const hookPath = join(hooksDir, 'prepare-commit-msg'); | ||||||
| const backupPath = join(hooksDir, 'prepare-commit-msg.user-backup'); | ||||||
|
|
||||||
| // Already installed — idempotent | ||||||
| if (isGitMemHook(hookPath)) { | ||||||
| return { installed: false, wrapped: false, hookPath }; | ||||||
| } | ||||||
|
|
||||||
| let wrapped = false; | ||||||
|
|
||||||
| // Existing non-git-mem hook — wrap it | ||||||
| if (existsSync(hookPath)) { | ||||||
| renameSync(hookPath, backupPath); | ||||||
| wrapped = true; | ||||||
|
|
||||||
| // Create a wrapper that calls the user's hook first, then ours | ||||||
| const wrapperScript = HOOK_SCRIPT.replace( | ||||||
| '#!/bin/sh', | ||||||
| `#!/bin/sh\n# Wrapped existing hook — original saved as prepare-commit-msg.user-backup\nif [ -x "${backupPath}" ]; then\n "${backupPath}" "$@" || exit $?\nfi`, | ||||||
|
||||||
| `#!/bin/sh\n# Wrapped existing hook — original saved as prepare-commit-msg.user-backup\nif [ -x "${backupPath}" ]; then\n "${backupPath}" "$@" || exit $?\nfi`, | |
| `#!/bin/sh\n# Wrapped existing hook — original saved as prepare-commit-msg.user-backup\nBACKUP_HOOK="$(dirname "$0")/prepare-commit-msg.user-backup"\nif [ -x "$BACKUP_HOOK" ]; then\n "$BACKUP_HOOK" "$@" || exit $?\nfi`, |
Copilot
AI
Feb 13, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The installHook function assumes the hooks directory exists, but this may not always be true (e.g., in bare repositories or corrupted git directories). Consider adding mkdirSync(hooksDir, { recursive: true }) before line 109 to ensure the directory exists before attempting to write files to it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The comment says "Skip merge/squash/amend commits" but the case statement only skips merge and squash. If amend commits should also be skipped, add it to the case statement. Otherwise, update the comment to match the actual behavior.