From 724a2acb904c16c5bd562e2244a7adcda655b1b1 Mon Sep 17 00:00:00 2001 From: Tony Casey Date: Fri, 13 Feb 2026 10:50:21 +0000 Subject: [PATCH] feat: prepare-commit-msg hook for passive AI-Agent trailer injection (GIT-70) Adds a git hook that automatically injects AI-Agent trailers into commit messages when an AI-assisted session is detected (via $GIT_MEM_AGENT or $CLAUDE_CODE env vars). Install with `git mem init --hooks`, remove with `git mem init --uninstall-hooks`. Wraps existing hooks safely. Co-Authored-By: Claude Opus 4.6 --- src/cli.ts | 2 + src/commands/init.ts | 26 +- src/domain/entities/ITrailer.ts | 1 + src/hooks/prepare-commit-msg.ts | 149 +++++++++++ tests/unit/hooks/prepare-commit-msg.test.ts | 281 ++++++++++++++++++++ 5 files changed, 458 insertions(+), 1 deletion(-) create mode 100644 src/hooks/prepare-commit-msg.ts create mode 100644 tests/unit/hooks/prepare-commit-msg.test.ts diff --git a/src/cli.ts b/src/cli.ts index c143394c..716e35a5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -26,6 +26,8 @@ program .description('Set up git-mem: hooks, MCP config, .gitignore, and liberate history') .option('-y, --yes', 'Accept defaults without prompting') .option('--commit-count ', 'Number of commits to liberate', '100') + .option('--hooks', 'Install prepare-commit-msg git hook for AI-Agent trailers') + .option('--uninstall-hooks', 'Remove the prepare-commit-msg git hook') .action((options) => initCommand(options, logger)); program diff --git a/src/commands/init.ts b/src/commands/init.ts index e95a58b8..8277babe 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -18,12 +18,15 @@ import { deepMergeGitMemConfig, } from './init-hooks'; import { buildMcpConfig } from './init-mcp'; +import { installHook, uninstallHook } from '../hooks/prepare-commit-msg'; import { createContainer } from '../infrastructure/di'; import { createStderrProgressHandler } from './progress'; interface IInitCommandOptions { yes?: boolean; commitCount?: string; + hooks?: boolean; + uninstallHooks?: boolean; } // ── Pure helpers (exported for testing) ────────────────────────────── @@ -112,7 +115,18 @@ export async function initCommand(options: IInitCommandOptions, logger?: ILogger const log = logger?.child({ command: 'init' }); const cwd = process.cwd(); - log?.info('Command invoked', { yes: options.yes, commitCount: options.commitCount }); + log?.info('Command invoked', { yes: options.yes, commitCount: options.commitCount, hooks: options.hooks, uninstallHooks: options.uninstallHooks }); + + // ── Git hook uninstall (early exit) ───────────────────────────── + if (options.uninstallHooks) { + const removed = uninstallHook(cwd); + if (removed) { + console.log('✓ Removed prepare-commit-msg hook'); + } else { + console.log('No git-mem prepare-commit-msg hook found.'); + } + return; + } // ── Prompts (skipped with --yes) ─────────────────────────────── let commitCount = options.commitCount ? parseInt(options.commitCount, 10) : 100; @@ -171,6 +185,16 @@ export async function initCommand(options: IInitCommandOptions, logger?: ILogger console.log('✓ Created .git-mem.json'); } + // ── Git hook (prepare-commit-msg) ───────────────────────────── + if (options.hooks) { + const hookResult = installHook(cwd); + if (hookResult.installed) { + console.log(`✓ Installed prepare-commit-msg hook${hookResult.wrapped ? ' (wrapped existing hook)' : ''}`); + } else { + console.log('✓ prepare-commit-msg hook already installed (skipped)'); + } + } + // ── MCP config (skip if already exists) ──────────────────────── const mcpPath = join(cwd, '.mcp.json'); if (existsSync(mcpPath)) { diff --git a/src/domain/entities/ITrailer.ts b/src/domain/entities/ITrailer.ts index f8605cb2..6ceee8d4 100644 --- a/src/domain/entities/ITrailer.ts +++ b/src/domain/entities/ITrailer.ts @@ -32,6 +32,7 @@ export const AI_TRAILER_KEYS = { TAGS: 'AI-Tags', LIFECYCLE: 'AI-Lifecycle', MEMORY_ID: 'AI-Memory-Id', + AGENT: 'AI-Agent', } as const; /** diff --git a/src/hooks/prepare-commit-msg.ts b/src/hooks/prepare-commit-msg.ts new file mode 100644 index 00000000..12e11fc9 --- /dev/null +++ b/src/hooks/prepare-commit-msg.ts @@ -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 + +# 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`, + ); + writeFileSync(hookPath, wrapperScript); + } else { + writeFileSync(hookPath, HOOK_SCRIPT); + } + + chmodSync(hookPath, 0o755); + return { installed: true, wrapped, hookPath }; +} + +/** + * Uninstall the prepare-commit-msg hook. + * Restores wrapped user hooks if a backup exists. + */ +export function uninstallHook(cwd?: string): boolean { + 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'); + + if (!isGitMemHook(hookPath)) { + return false; + } + + unlinkSync(hookPath); + + // Restore user's original hook if it was wrapped + if (existsSync(backupPath)) { + renameSync(backupPath, hookPath); + } + + return true; +} diff --git a/tests/unit/hooks/prepare-commit-msg.test.ts b/tests/unit/hooks/prepare-commit-msg.test.ts new file mode 100644 index 00000000..7bfcf820 --- /dev/null +++ b/tests/unit/hooks/prepare-commit-msg.test.ts @@ -0,0 +1,281 @@ +/** + * prepare-commit-msg hook — unit tests + * + * Tests installHook / uninstallHook against real temp git repos. + */ + +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'child_process'; +import { mkdtempSync, writeFileSync, readFileSync, existsSync, chmodSync, rmSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { installHook, uninstallHook } from '../../../src/hooks/prepare-commit-msg'; + +function git(args: string[], cwd: string): string { + return execFileSync('git', args, { encoding: 'utf8', cwd }).trim(); +} + +describe('installHook', () => { + let repoDir: string; + + before(() => { + repoDir = mkdtempSync(join(tmpdir(), 'git-mem-hook-install-')); + git(['init'], repoDir); + git(['config', 'user.email', 'test@test.com'], repoDir); + git(['config', 'user.name', 'Test User'], repoDir); + }); + + after(() => { + rmSync(repoDir, { recursive: true, force: true }); + }); + + it('should install hook into .git/hooks', () => { + const result = installHook(repoDir); + + assert.equal(result.installed, true); + assert.equal(result.wrapped, false); + assert.ok(existsSync(result.hookPath)); + + const content = readFileSync(result.hookPath, 'utf8'); + assert.ok(content.includes('#!/bin/sh')); + assert.ok(content.includes('git-mem:prepare-commit-msg v1')); + assert.ok(content.includes('git interpret-trailers')); + assert.ok(content.includes('AI-Agent')); + }); + + it('should be idempotent — second install is a no-op', () => { + const result = installHook(repoDir); + + assert.equal(result.installed, false); + assert.equal(result.wrapped, false); + }); + + it('should wrap an existing non-git-mem hook', () => { + // Create a fresh repo with a user hook + const freshRepo = mkdtempSync(join(tmpdir(), 'git-mem-hook-wrap-')); + git(['init'], freshRepo); + + const hooksDir = join(freshRepo, '.git', 'hooks'); + const hookPath = join(hooksDir, 'prepare-commit-msg'); + const userHook = '#!/bin/sh\necho "user hook running"\n'; + writeFileSync(hookPath, userHook); + chmodSync(hookPath, 0o755); + + try { + const result = installHook(freshRepo); + + assert.equal(result.installed, true); + assert.equal(result.wrapped, true); + + // Backup should exist + const backupPath = join(hooksDir, 'prepare-commit-msg.user-backup'); + assert.ok(existsSync(backupPath)); + assert.equal(readFileSync(backupPath, 'utf8'), userHook); + + // Installed hook should contain both fingerprint and wrapper reference + const content = readFileSync(hookPath, 'utf8'); + assert.ok(content.includes('git-mem:prepare-commit-msg v1')); + assert.ok(content.includes('user-backup')); + } finally { + rmSync(freshRepo, { recursive: true, force: true }); + } + }); +}); + +describe('uninstallHook', () => { + it('should remove git-mem hook', () => { + const repoDir = mkdtempSync(join(tmpdir(), 'git-mem-hook-uninstall-')); + git(['init'], repoDir); + + try { + // Install first + installHook(repoDir); + + // Uninstall + const removed = uninstallHook(repoDir); + assert.equal(removed, true); + + const hookPath = join(repoDir, '.git', 'hooks', 'prepare-commit-msg'); + assert.equal(existsSync(hookPath), false); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } + }); + + it('should return false when no git-mem hook is present', () => { + const repoDir = mkdtempSync(join(tmpdir(), 'git-mem-hook-uninstall-')); + git(['init'], repoDir); + + try { + const removed = uninstallHook(repoDir); + assert.equal(removed, false); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } + }); + + it('should restore wrapped user hook on uninstall', () => { + const repoDir = mkdtempSync(join(tmpdir(), 'git-mem-hook-restore-')); + git(['init'], repoDir); + + const hooksDir = join(repoDir, '.git', 'hooks'); + const hookPath = join(hooksDir, 'prepare-commit-msg'); + const userHook = '#!/bin/sh\necho "user hook"\n'; + writeFileSync(hookPath, userHook); + chmodSync(hookPath, 0o755); + + try { + installHook(repoDir); + const removed = uninstallHook(repoDir); + assert.equal(removed, true); + + // User hook should be restored + assert.ok(existsSync(hookPath)); + assert.equal(readFileSync(hookPath, 'utf8'), userHook); + + // Backup should be gone + const backupPath = join(hooksDir, 'prepare-commit-msg.user-backup'); + assert.equal(existsSync(backupPath), false); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } + }); + + it('should not remove a non-git-mem hook', () => { + const repoDir = mkdtempSync(join(tmpdir(), 'git-mem-hook-foreign-')); + git(['init'], repoDir); + + const hooksDir = join(repoDir, '.git', 'hooks'); + const hookPath = join(hooksDir, 'prepare-commit-msg'); + const foreignHook = '#!/bin/sh\necho "foreign hook"\n'; + writeFileSync(hookPath, foreignHook); + chmodSync(hookPath, 0o755); + + try { + const removed = uninstallHook(repoDir); + assert.equal(removed, false); + + // Foreign hook should still be there + assert.ok(existsSync(hookPath)); + assert.equal(readFileSync(hookPath, 'utf8'), foreignHook); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } + }); +}); + +describe('hook integration — commit message modification', () => { + let repoDir: string; + + before(() => { + repoDir = mkdtempSync(join(tmpdir(), 'git-mem-hook-commit-')); + git(['init'], repoDir); + git(['config', 'user.email', 'test@test.com'], repoDir); + git(['config', 'user.name', 'Test User'], repoDir); + + installHook(repoDir); + }); + + after(() => { + rmSync(repoDir, { recursive: true, force: true }); + }); + + it('should add AI-Agent trailer when GIT_MEM_AGENT is set', () => { + writeFileSync(join(repoDir, 'test.txt'), 'hello'); + git(['add', '.'], repoDir); + + execFileSync('git', ['commit', '-m', 'feat: test commit'], { + encoding: 'utf8', + cwd: repoDir, + env: { ...process.env, GIT_MEM_AGENT: 'TestAgent/1.0' }, + }); + + const message = git(['log', '-1', '--format=%B'], repoDir); + assert.ok(message.includes('AI-Agent: TestAgent/1.0'), `Expected AI-Agent trailer in: ${message}`); + }); + + it('should add AI-Agent trailer when CLAUDE_CODE is set', () => { + writeFileSync(join(repoDir, 'test2.txt'), 'world'); + git(['add', '.'], repoDir); + + execFileSync('git', ['commit', '-m', 'fix: another commit'], { + encoding: 'utf8', + cwd: repoDir, + env: { ...process.env, CLAUDE_CODE: '1', GIT_MEM_AGENT: '' }, + }); + + const message = git(['log', '-1', '--format=%B'], repoDir); + assert.ok(message.includes('AI-Agent: Claude-Code'), `Expected Claude-Code trailer in: ${message}`); + }); + + it('should not add trailer when no AI env vars are set', () => { + writeFileSync(join(repoDir, 'test3.txt'), 'plain'); + git(['add', '.'], repoDir); + + // Strip AI-related env vars + const cleanEnv = { ...process.env }; + delete cleanEnv.GIT_MEM_AGENT; + delete cleanEnv.CLAUDE_CODE; + + execFileSync('git', ['commit', '-m', 'chore: plain commit'], { + encoding: 'utf8', + cwd: repoDir, + env: cleanEnv, + }); + + const message = git(['log', '-1', '--format=%B'], repoDir); + assert.ok(!message.includes('AI-Agent:'), `Should not have AI-Agent trailer in: ${message}`); + }); + + it('should not duplicate AI-Agent trailer if already present', () => { + writeFileSync(join(repoDir, 'test4.txt'), 'dedup'); + git(['add', '.'], repoDir); + + const msgWithTrailer = 'feat: already has trailer\n\nAI-Agent: ExistingAgent'; + execFileSync('git', ['commit', '-m', msgWithTrailer], { + encoding: 'utf8', + cwd: repoDir, + env: { ...process.env, GIT_MEM_AGENT: 'ShouldNotAppear' }, + }); + + const message = git(['log', '-1', '--format=%B'], repoDir); + const agentCount = message.split('\n').filter((l: string) => l.startsWith('AI-Agent:')).length; + assert.equal(agentCount, 1, `Should have exactly one AI-Agent trailer, got: ${message}`); + assert.ok(message.includes('ExistingAgent')); + }); + + it('should skip merge commits', () => { + // Determine the default branch name before creating feature branch + const defaultBranch = git(['rev-parse', '--abbrev-ref', 'HEAD'], repoDir); + + // Create a branch, make a commit, merge back + git(['checkout', '-b', 'feature-merge-test'], repoDir); + writeFileSync(join(repoDir, 'feature.txt'), 'feature'); + git(['add', '.'], repoDir); + execFileSync('git', ['commit', '-m', 'feat: feature branch'], { + encoding: 'utf8', + cwd: repoDir, + env: { ...process.env, GIT_MEM_AGENT: '' }, + }); + + git(['checkout', defaultBranch], repoDir); + writeFileSync(join(repoDir, 'main-change.txt'), 'main'); + git(['add', '.'], repoDir); + execFileSync('git', ['commit', '-m', 'chore: main change'], { + encoding: 'utf8', + cwd: repoDir, + env: { ...process.env, GIT_MEM_AGENT: '' }, + }); + + // Merge (--no-ff forces merge commit) + execFileSync('git', ['merge', '--no-ff', 'feature-merge-test', '-m', 'Merge feature-merge-test'], { + encoding: 'utf8', + cwd: repoDir, + env: { ...process.env, GIT_MEM_AGENT: 'ShouldNotAppear' }, + }); + + const message = git(['log', '-1', '--format=%B'], repoDir); + assert.ok(!message.includes('AI-Agent:'), `Merge commit should not have AI-Agent trailer: ${message}`); + }); +});