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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
TonyCasey marked this conversation as resolved.

## [0.3.0] - 2026-02-12

### Added
Expand Down
57 changes: 36 additions & 21 deletions src/application/handlers/CommitMsgHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,16 +42,16 @@ export class CommitMsgHandler implements IEventHandler<ICommitMsgEvent> {
// 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 };
}
Expand All @@ -70,8 +70,8 @@ export class CommitMsgHandler implements IEventHandler<ICommitMsgEvent> {
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);
Expand Down Expand Up @@ -99,40 +99,55 @@ export class CommitMsgHandler implements IEventHandler<ICommitMsgEvent> {

/**
* 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;
}

/**
* Build all AI-* trailers from the analysis result.
* Skips Agent/Model if they already exist (from prepare-commit-msg).
*/
private buildTrailers(
analysis: ReturnType<ICommitAnalyzer['analyze']>,
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 });
}
}
Comment on lines 100 to 151
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Optional: dedupe Agent/Model trailer construction.

Both trailer builders reimplement the same Agent/Model logic; a small helper would prevent divergence.

♻️ Possible refactor
+  private buildAgentModelTrailers(existingMessage: string): ITrailer[] {
+    const trailers: ITrailer[] = [];
+    const hasAgent = existingMessage.includes('AI-Agent:');
+    const hasModel = existingMessage.includes('AI-Model:');
+
+    if (!hasAgent) {
+      const agent = this.agentResolver.resolveAgent();
+      if (agent) trailers.push({ key: AI_TRAILER_KEYS.AGENT, value: agent });
+    }
+    if (!hasModel) {
+      const model = this.agentResolver.resolveModel();
+      if (model) trailers.push({ key: AI_TRAILER_KEYS.MODEL, value: model });
+    }
+    return trailers;
+  }
+
   private buildBasicTrailers(existingMessage: string): ITrailer[] {
-    const trailers: ITrailer[] = [];
-    const hasAgent = existingMessage.includes('AI-Agent:');
-    const hasModel = existingMessage.includes('AI-Model:');
-    ...
-    return trailers;
+    return this.buildAgentModelTrailers(existingMessage);
   }
 
   private buildTrailers(..., existingMessage: string): ITrailer[] {
-    const trailers: ITrailer[] = [];
-    const hasAgent = existingMessage.includes('AI-Agent:');
-    const hasModel = existingMessage.includes('AI-Model:');
-    ...
-    return trailers;
+    const trailers: ITrailer[] = [
+      ...this.buildAgentModelTrailers(existingMessage),
+    ];
+    ...
+    return trailers;
   }
🤖 Prompt for AI Agents
In `@src/application/handlers/CommitMsgHandler.ts` around lines 100 - 151, Both
buildBasicTrailers and buildTrailers duplicate the same Agent/Model presence
check and resolution logic; extract a small helper (e.g.,
addAgentAndModelTrailers or appendAgentModelIfMissing) that accepts the
existingMessage and the trailers array (or returns an array) and encapsulates
calls to this.agentResolver.resolveAgent(), this.agentResolver.resolveModel(),
and pushes { key: AI_TRAILER_KEYS.AGENT|MODEL, value } only when missing; then
replace the duplicated blocks in buildBasicTrailers and buildTrailers with calls
to that helper to keep logic DRY and avoid future divergence.


// Add type-specific trailer if we detected a type
Expand Down
3 changes: 1 addition & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <n>', 'Number of commits to extract (default: 10)', parseInt)
.action((options) => initCommand(options, logger));
Expand Down
54 changes: 51 additions & 3 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -26,7 +27,6 @@ import { createStderrProgressHandler } from './progress';

interface IInitCommandOptions {
yes?: boolean;
hooks?: boolean;
uninstallHooks?: boolean;
extract?: boolean;
commitCount?: number;
Expand Down Expand Up @@ -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'],
});
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Read ANTHROPIC_API_KEY value from .env file.
* Returns the value if set, null otherwise.
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)' : ''}`);
Expand All @@ -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) ────────────────────────
Expand Down
6 changes: 3 additions & 3 deletions src/hooks/commit-msg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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')
Expand Down
10 changes: 5 additions & 5 deletions tests/integration/hooks/hook-commit-msg.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand All @@ -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', () => {
Expand Down
6 changes: 3 additions & 3 deletions tests/unit/hooks/commit-msg.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
});

Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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');
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Second install should be idempotent
Expand Down