diff --git a/CHANGELOG.md b/CHANGELOG.md index 33dd9cd4..d6f2f9c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **Smart commit analysis** — commit-msg hook that automatically analyzes commit messages and adds AI-* trailers: + - Detects memory type (decision, gotcha, convention, fact) from heuristic patterns + - Infers tags from conventional commit scope and staged file paths + - Adds AI-Agent, AI-Model, AI-Confidence, AI-Lifecycle, AI-Memory-Id trailers + - Configurable via `.git-mem.json` (autoAnalyze, inferTags, requireType, defaultLifecycle) + ## [0.3.0] - 2026-02-12 ### Added diff --git a/src/application/handlers/CommitMsgHandler.ts b/src/application/handlers/CommitMsgHandler.ts index 63000303..3f6d020e 100644 --- a/src/application/handlers/CommitMsgHandler.ts +++ b/src/application/handlers/CommitMsgHandler.ts @@ -42,16 +42,16 @@ export class CommitMsgHandler implements IEventHandler { // 1. Read the commit message file const message = readFileSync(event.commitMsgPath, 'utf8'); - // 2. Check if AI-Agent trailer already exists (avoid duplicates from prepare-commit-msg) - if (message.includes('AI-Agent:')) { - this.logger.debug('AI trailers already present, skipping analysis'); + // 2. Check if full analysis already done (AI-Memory-Id indicates commit-msg hook has run) + if (message.includes('AI-Memory-Id:')) { + this.logger.debug('Full analysis already done, skipping'); return { handler: 'CommitMsgHandler', success: true }; } // 3. Check autoAnalyze config - if false, only add basic Agent/Model trailers if (!config.autoAnalyze) { this.logger.debug('autoAnalyze is false, adding only Agent/Model trailers'); - const basicTrailers = this.buildBasicTrailers(); + const basicTrailers = this.buildBasicTrailers(message); await this.appendTrailers(event.commitMsgPath, basicTrailers, event.cwd); return { handler: 'CommitMsgHandler', success: true }; } @@ -70,8 +70,8 @@ export class CommitMsgHandler implements IEventHandler { return { handler: 'CommitMsgHandler', success: true }; } - // 7. Build trailers (skip tags if inferTags is false) - const trailers = this.buildTrailers(analysis, config); + // 7. Build trailers (skip Agent/Model if already present from prepare-commit-msg) + const trailers = this.buildTrailers(analysis, config, message); // 8. Append trailers to the commit message using git interpret-trailers await this.appendTrailers(event.commitMsgPath, trailers, event.cwd); @@ -99,17 +99,24 @@ export class CommitMsgHandler implements IEventHandler { /** * Build basic AI-Agent and AI-Model trailers only (when autoAnalyze is false). + * Skips if already present from prepare-commit-msg. */ - private buildBasicTrailers(): ITrailer[] { + private buildBasicTrailers(existingMessage: string): ITrailer[] { const trailers: ITrailer[] = []; - const agent = this.agentResolver.resolveAgent(); - const model = this.agentResolver.resolveModel(); + const hasAgent = existingMessage.includes('AI-Agent:'); + const hasModel = existingMessage.includes('AI-Model:'); - if (agent) { - trailers.push({ key: AI_TRAILER_KEYS.AGENT, value: agent }); + if (!hasAgent) { + const agent = this.agentResolver.resolveAgent(); + if (agent) { + trailers.push({ key: AI_TRAILER_KEYS.AGENT, value: agent }); + } } - if (model) { - trailers.push({ key: AI_TRAILER_KEYS.MODEL, value: model }); + if (!hasModel) { + const model = this.agentResolver.resolveModel(); + if (model) { + trailers.push({ key: AI_TRAILER_KEYS.MODEL, value: model }); + } } return trailers; @@ -117,22 +124,30 @@ export class CommitMsgHandler implements IEventHandler { /** * Build all AI-* trailers from the analysis result. + * Skips Agent/Model if they already exist (from prepare-commit-msg). */ private buildTrailers( analysis: ReturnType, - config: ICommitMsgConfig + config: ICommitMsgConfig, + existingMessage: string ): ITrailer[] { const trailers: ITrailer[] = []; - // Always add Agent and Model via injected resolver - const agent = this.agentResolver.resolveAgent(); - const model = this.agentResolver.resolveModel(); + // Only add Agent and Model if not already present (prepare-commit-msg may have added them) + const hasAgent = existingMessage.includes('AI-Agent:'); + const hasModel = existingMessage.includes('AI-Model:'); - if (agent) { - trailers.push({ key: AI_TRAILER_KEYS.AGENT, value: agent }); + if (!hasAgent) { + const agent = this.agentResolver.resolveAgent(); + if (agent) { + trailers.push({ key: AI_TRAILER_KEYS.AGENT, value: agent }); + } } - if (model) { - trailers.push({ key: AI_TRAILER_KEYS.MODEL, value: model }); + if (!hasModel) { + const model = this.agentResolver.resolveModel(); + if (model) { + trailers.push({ key: AI_TRAILER_KEYS.MODEL, value: model }); + } } // Add type-specific trailer if we detected a type diff --git a/src/cli.ts b/src/cli.ts index fb2588f6..0c466f19 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -26,8 +26,7 @@ program .command('init') .description('Set up git-mem: hooks, MCP config, .gitignore') .option('-y, --yes', 'Accept defaults without prompting') - .option('--hooks', 'Install prepare-commit-msg git hook for AI-Agent trailers') - .option('--uninstall-hooks', 'Remove the prepare-commit-msg git hook') + .option('--uninstall-hooks', 'Remove all git-mem hooks') .option('--extract', 'Extract knowledge from commit history (use with --yes)') .option('--commit-count ', 'Number of commits to extract (default: 10)', parseInt) .action((options) => initCommand(options, logger)); diff --git a/src/commands/init.ts b/src/commands/init.ts index eea3ed27..78e8366f 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -6,6 +6,7 @@ */ import { existsSync, readFileSync, writeFileSync, appendFileSync, mkdirSync } from 'fs'; +import { execFileSync } from 'child_process'; import { join } from 'path'; import prompts from 'prompts'; import type { ILogger } from '../domain/interfaces/ILogger'; @@ -26,7 +27,6 @@ import { createStderrProgressHandler } from './progress'; interface IInitCommandOptions { yes?: boolean; - hooks?: boolean; uninstallHooks?: boolean; extract?: boolean; commitCount?: number; @@ -76,6 +76,49 @@ export function ensureGitignoreEntries(cwd: string, entries: string[]): void { appendFileSync(gitignorePath, append); } +/** + * Configure git to push notes automatically with regular pushes. + * Adds refs/notes/* to existing push refspecs, preserving any user-configured refspecs. + * Idempotent - safe to call multiple times. + */ +export function configureNotesPush(cwd: string): void { + let existingRefspecs: string[] = []; + + try { + // Get existing push refspecs + const existing = execFileSync('git', ['config', '--local', '--get-all', 'remote.origin.push'], { + cwd, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + + if (existing) { + existingRefspecs = existing.split('\n').filter(Boolean); + } + + // Already has notes configured - nothing to do + if (existingRefspecs.some((ref) => ref.includes('refs/notes'))) { + return; + } + } catch { + // Config doesn't exist yet, proceed to set it + } + + // If no existing refspecs, add heads first + if (existingRefspecs.length === 0) { + execFileSync('git', ['config', '--local', 'remote.origin.push', '+refs/heads/*:refs/heads/*'], { + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + }); + } + + // Add notes refspec (preserves existing refspecs) + execFileSync('git', ['config', '--local', '--add', 'remote.origin.push', '+refs/notes/*:refs/notes/*'], { + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + }); +} + /** * Read ANTHROPIC_API_KEY value from .env file. * Returns the value if set, null otherwise. @@ -118,7 +161,7 @@ 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, hooks: options.hooks, uninstallHooks: options.uninstallHooks }); + log?.info('Command invoked', { yes: options.yes, uninstallHooks: options.uninstallHooks }); // ── Git hook uninstall (early exit) ───────────────────────────── if (options.uninstallHooks) { @@ -213,7 +256,8 @@ export async function initCommand(options: IInitCommandOptions, logger?: ILogger } // ── Git hooks (prepare-commit-msg, commit-msg, post-commit) ───── - if (options.hooks) { + // Always install hooks during init (core functionality) + { const prepareResult = installHook(cwd); if (prepareResult.installed) { console.log(`✓ Installed prepare-commit-msg hook${prepareResult.wrapped ? ' (wrapped existing hook)' : ''}`); @@ -234,6 +278,10 @@ export async function initCommand(options: IInitCommandOptions, logger?: ILogger } else { console.log('✓ post-commit hook already installed (skipped)'); } + + // Configure git to push notes automatically with regular pushes + configureNotesPush(cwd); + console.log('✓ Configured git to push notes with commits'); } // ── MCP config (skip if already exists) ──────────────────────── diff --git a/src/hooks/commit-msg.ts b/src/hooks/commit-msg.ts index c1fdbaee..383f0bc5 100644 --- a/src/hooks/commit-msg.ts +++ b/src/hooks/commit-msg.ts @@ -17,7 +17,7 @@ import { execFileSync } from 'child_process'; const HOOK_FINGERPRINT_PREFIX = '# git-mem:commit-msg'; /** Full fingerprint with version — used for upgrade detection. */ -const HOOK_FINGERPRINT = `${HOOK_FINGERPRINT_PREFIX} v2`; +const HOOK_FINGERPRINT = `${HOOK_FINGERPRINT_PREFIX} v3`; /** * The shell hook script. @@ -32,8 +32,8 @@ COMMIT_MSG_FILE="$1" # Skip if no commit message file [ -z "$COMMIT_MSG_FILE" ] && exit 0 -# Skip if AI trailers already exist (likely from prepare-commit-msg or manual) -grep -q "^AI-Agent:" "$COMMIT_MSG_FILE" && exit 0 +# Skip if full analysis already done (AI-Memory-Id indicates commit-msg hook has run) +grep -q "^AI-Memory-Id:" "$COMMIT_MSG_FILE" && exit 0 # Escape values for safe JSON inclusion (handles quotes, backslashes) COMMIT_MSG_FILE_ESC=$(printf '%s' "$COMMIT_MSG_FILE" | sed 's/\\\\/\\\\\\\\/g; s/"/\\\\"/g') diff --git a/tests/integration/hooks/hook-commit-msg.test.ts b/tests/integration/hooks/hook-commit-msg.test.ts index fa5fef39..534e6326 100644 --- a/tests/integration/hooks/hook-commit-msg.test.ts +++ b/tests/integration/hooks/hook-commit-msg.test.ts @@ -278,10 +278,10 @@ describe('Integration: hook commit-msg', () => { }); describe('skip conditions', () => { - it('should skip if AI-Agent trailer already exists', () => { + it('should skip if AI-Memory-Id trailer already exists (full analysis done)', () => { process.env.GIT_MEM_AGENT = 'TestAgent/2.0'; - const commitMsg = 'feat: something\n\nAI-Agent: ExistingAgent/1.0'; + const commitMsg = 'feat: something\n\nAI-Memory-Id: abc12345'; const msgPath = createCommitMsgFile(repoDir, commitMsg); const result = runHook('commit-msg', { @@ -291,10 +291,10 @@ describe('Integration: hook commit-msg', () => { assert.equal(result.status, 0); - // Message should be unchanged + // Message should be unchanged - no new trailers added const modifiedMsg = readFileSync(msgPath, 'utf8'); - assert.ok(!modifiedMsg.includes('TestAgent'), 'Should not add duplicate trailers'); - assert.ok(modifiedMsg.includes('ExistingAgent'), 'Should preserve existing trailers'); + assert.ok(!modifiedMsg.includes('AI-Agent:'), 'Should not add trailers when already analyzed'); + assert.ok(modifiedMsg.includes('AI-Memory-Id: abc12345'), 'Should preserve existing trailers'); }); it('should exit successfully without changes when no agent detected', () => { diff --git a/tests/unit/hooks/commit-msg.test.ts b/tests/unit/hooks/commit-msg.test.ts index 3604e7f5..a4430a8c 100644 --- a/tests/unit/hooks/commit-msg.test.ts +++ b/tests/unit/hooks/commit-msg.test.ts @@ -39,7 +39,7 @@ describe('installCommitMsgHook', () => { const content = readFileSync(result.hookPath, 'utf8'); assert.ok(content.includes('#!/bin/sh')); - assert.ok(content.includes('git-mem:commit-msg v2')); + assert.ok(content.includes('git-mem:commit-msg v3')); assert.ok(content.includes('git-mem hook commit-msg')); }); @@ -74,7 +74,7 @@ describe('installCommitMsgHook', () => { // Installed hook should contain both fingerprint and wrapper reference const content = readFileSync(hookPath, 'utf8'); - assert.ok(content.includes('git-mem:commit-msg v2')); + assert.ok(content.includes('git-mem:commit-msg v3')); assert.ok(content.includes('user-backup')); } finally { rmSync(freshRepo, { recursive: true, force: true }); @@ -102,7 +102,7 @@ describe('installCommitMsgHook', () => { assert.equal(result.wrapped, false); const content = readFileSync(hookPath, 'utf8'); - assert.ok(content.includes('git-mem:commit-msg v2'), 'Should be upgraded to v1'); + assert.ok(content.includes('git-mem:commit-msg v3'), 'Should be upgraded to v3'); assert.ok(content.includes('git-mem hook commit-msg'), 'Should include git-mem command'); // Second install should be idempotent