diff --git a/src/application/handlers/PostCommitHandler.ts b/src/application/handlers/PostCommitHandler.ts new file mode 100644 index 00000000..29ba17c0 --- /dev/null +++ b/src/application/handlers/PostCommitHandler.ts @@ -0,0 +1,121 @@ +/** + * PostCommitHandler + * + * Handles the git:commit event by writing session metadata as a git note. + * Preserves any existing memories on the commit while adding/updating + * the session field with agent, model, and timestamp. + */ + +import type { IPostCommitHandler } from '../interfaces/IPostCommitHandler'; +import type { IGitCommitEvent } from '../../domain/events/HookEvents'; +import type { IEventResult } from '../../domain/interfaces/IEventResult'; +import type { INotesService } from '../../domain/interfaces/INotesService'; +import type { ILogger } from '../../domain/interfaces/ILogger'; +import { resolveAgent, resolveModel } from '../../infrastructure/detect-agent'; + +/** + * Session metadata stored in the note's `session` field. + */ +interface ISessionMetadata { + agent: string; + model?: string; + timestamp: string; +} + +/** + * Note payload structure. + * The `memories` array is managed by MemoryRepository. + * The `session` field is managed by this handler. + */ +interface INotePayload { + memories?: unknown[]; + session?: ISessionMetadata; +} + +export class PostCommitHandler implements IPostCommitHandler { + constructor( + private readonly notesService: INotesService, + private readonly logger?: ILogger, + ) {} + + async handle(event: IGitCommitEvent): Promise { + try { + const agent = resolveAgent(); + const model = resolveModel(); + + // No agent detected — exit silently (not an AI-assisted commit) + if (!agent) { + this.logger?.debug('No AI agent detected, skipping session note'); + return { + handler: 'PostCommitHandler', + success: true, + }; + } + + this.logger?.info('Post-commit handler invoked', { + sha: event.sha, + agent, + model, + }); + + // Read existing note payload + const payload = this.readPayload(event.sha, event.cwd); + + // Add/update session metadata + payload.session = { + agent, + model, + timestamp: new Date().toISOString(), + }; + + // Write back + this.writePayload(event.sha, payload, event.cwd); + + this.logger?.info('Session note written', { + sha: event.sha, + hasMemories: Array.isArray(payload.memories) && payload.memories.length > 0, + }); + + return { + handler: 'PostCommitHandler', + success: true, + }; + } catch (error) { + this.logger?.error('Post-commit handler failed', { error }); + return { + handler: 'PostCommitHandler', + success: false, + error: error instanceof Error ? error : new Error(String(error)), + }; + } + } + + /** + * Read and parse the existing note payload. + * Returns empty object if no note exists or parsing fails. + */ + private readPayload(sha: string, cwd: string): INotePayload { + const raw = this.notesService.read(sha, undefined, cwd); + if (!raw) return {}; + + try { + const parsed = JSON.parse(raw); + if (typeof parsed === 'object' && parsed !== null) { + return parsed as INotePayload; + } + return {}; + } catch { + // Malformed JSON — return empty and let write overwrite + this.logger?.warn('Malformed note JSON, will overwrite', { sha }); + return {}; + } + } + + /** + * Serialize and write the payload to the note. + */ + private writePayload(sha: string, payload: INotePayload, cwd: string): void { + const json = JSON.stringify(payload, null, 2); + this.notesService.write(sha, json, undefined, cwd); + } +} diff --git a/src/application/interfaces/IPostCommitHandler.ts b/src/application/interfaces/IPostCommitHandler.ts new file mode 100644 index 00000000..e7537640 --- /dev/null +++ b/src/application/interfaces/IPostCommitHandler.ts @@ -0,0 +1,10 @@ +/** + * IPostCommitHandler + * + * Application interface for the post-commit hook handler. + */ + +import type { IEventHandler } from '../../domain/interfaces/IEventHandler'; +import type { IGitCommitEvent } from '../../domain/events/HookEvents'; + +export type IPostCommitHandler = IEventHandler; diff --git a/src/commands/hook.ts b/src/commands/hook.ts index d1c0b2c0..69279452 100644 --- a/src/commands/hook.ts +++ b/src/commands/hook.ts @@ -24,6 +24,7 @@ export interface IHookInput { prompt?: string; cwd?: string; hook_event_name?: string; + sha?: string; } /** Map CLI event names to internal event bus types. */ @@ -31,14 +32,16 @@ export const EVENT_MAP: Record = { 'session-start': 'session:start', 'session-stop': 'session:stop', 'prompt-submit': 'prompt:submit', + 'post-commit': 'git:commit', }; /** Map CLI event names to the config section that controls them. */ -type ConfigKey = 'sessionStart' | 'sessionStop' | 'promptSubmit'; +type ConfigKey = 'sessionStart' | 'sessionStop' | 'promptSubmit' | 'postCommit'; const CONFIG_KEY_MAP: Record = { 'session-start': 'sessionStart', 'session-stop': 'sessionStop', 'prompt-submit': 'promptSubmit', + 'post-commit': 'postCommit', }; /** Extra enabled check for session-stop (must also have autoExtract). */ @@ -73,6 +76,8 @@ export function buildEvent(eventType: HookEventType, input: IHookInput): HookEve return { type: 'session:stop', ...base }; case 'prompt:submit': return { type: 'prompt:submit', ...base, prompt: input.prompt ?? '' }; + case 'git:commit': + return { type: 'git:commit', sha: input.sha ?? 'HEAD', cwd: base.cwd }; default: { const exhaustiveCheck: never = eventType; throw new Error(`Unhandled HookEventType in buildEvent: ${exhaustiveCheck as string}`); @@ -85,6 +90,7 @@ const STDERR_LABELS: Record 'session:start': { success: 'Memory loaded.', prefix: 'Memory loaded' }, 'session:stop': { success: 'Session capture complete.', prefix: 'Session capture complete' }, 'prompt:submit': { success: 'Prompt context loaded.', prefix: 'Prompt context loaded' }, + 'git:commit': { success: 'Session note written.', prefix: 'Session note' }, }; export async function hookCommand(eventName: string, _logger?: ILogger): Promise { diff --git a/src/commands/init-hooks.ts b/src/commands/init-hooks.ts index 3dd0f708..6a61f993 100644 --- a/src/commands/init-hooks.ts +++ b/src/commands/init-hooks.ts @@ -204,6 +204,9 @@ export function buildGitMemConfig(): Record { recordPrompts: false, surfaceContext: true, }, + postCommit: { + enabled: true, + }, }, }; } diff --git a/src/commands/init.ts b/src/commands/init.ts index ea23c687..aac0712e 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -19,6 +19,7 @@ import { } from './init-hooks'; import { buildMcpConfig } from './init-mcp'; import { installHook, uninstallHook } from '../hooks/prepare-commit-msg'; +import { installPostCommitHook, uninstallPostCommitHook } from '../hooks/post-commit'; import { createContainer } from '../infrastructure/di'; import { createStderrProgressHandler } from './progress'; @@ -120,11 +121,17 @@ export async function initCommand(options: IInitCommandOptions, logger?: ILogger // ── Git hook uninstall (early exit) ───────────────────────────── if (options.uninstallHooks) { - const removed = uninstallHook(cwd); - if (removed) { + const removedPrepare = uninstallHook(cwd); + const removedPost = uninstallPostCommitHook(cwd); + + if (removedPrepare) { console.log('✓ Removed prepare-commit-msg hook'); - } else { - console.log('No git-mem prepare-commit-msg hook found.'); + } + if (removedPost) { + console.log('✓ Removed post-commit hook'); + } + if (!removedPrepare && !removedPost) { + console.log('No git-mem hooks found.'); } return; } @@ -200,14 +207,21 @@ export async function initCommand(options: IInitCommandOptions, logger?: ILogger console.log('✓ Created .git-mem.json'); } - // ── Git hook (prepare-commit-msg) ───────────────────────────── + // ── Git hooks (prepare-commit-msg + post-commit) ───────────────── if (options.hooks) { - const hookResult = installHook(cwd); - if (hookResult.installed) { - console.log(`✓ Installed prepare-commit-msg hook${hookResult.wrapped ? ' (wrapped existing hook)' : ''}`); + const prepareResult = installHook(cwd); + if (prepareResult.installed) { + console.log(`✓ Installed prepare-commit-msg hook${prepareResult.wrapped ? ' (wrapped existing hook)' : ''}`); } else { console.log('✓ prepare-commit-msg hook already installed (skipped)'); } + + const postResult = installPostCommitHook(cwd); + if (postResult.installed) { + console.log(`✓ Installed post-commit hook${postResult.wrapped ? ' (wrapped existing hook)' : ''}`); + } else { + console.log('✓ post-commit hook already installed (skipped)'); + } } // ── MCP config (skip if already exists) ──────────────────────── diff --git a/src/domain/events/HookEvents.ts b/src/domain/events/HookEvents.ts index 8906f33b..ab25f19b 100644 --- a/src/domain/events/HookEvents.ts +++ b/src/domain/events/HookEvents.ts @@ -33,8 +33,17 @@ export interface IPromptSubmitEvent { readonly cwd: string; } +/** Event emitted after a git commit is created. */ +export interface IGitCommitEvent { + readonly type: 'git:commit'; + /** The commit SHA. */ + readonly sha: string; + /** Working directory of the repository. */ + readonly cwd: string; +} + /** Union of all hook events. */ -export type HookEvent = ISessionStartEvent | ISessionStopEvent | IPromptSubmitEvent; +export type HookEvent = ISessionStartEvent | ISessionStopEvent | IPromptSubmitEvent | IGitCommitEvent; /** String literal union of all hook event types. */ export type HookEventType = HookEvent['type']; diff --git a/src/domain/interfaces/IHookConfig.ts b/src/domain/interfaces/IHookConfig.ts index 76667669..c8dcdee5 100644 --- a/src/domain/interfaces/IHookConfig.ts +++ b/src/domain/interfaces/IHookConfig.ts @@ -31,11 +31,16 @@ export interface IPromptSubmitConfig { readonly surfaceContext: boolean; } +export interface IPostCommitConfig { + readonly enabled: boolean; +} + export interface IHooksConfig { readonly enabled: boolean; readonly sessionStart: ISessionStartConfig; readonly sessionStop: ISessionStopConfig; readonly promptSubmit: IPromptSubmitConfig; + readonly postCommit: IPostCommitConfig; } export interface IHookConfig { diff --git a/src/hooks/post-commit.ts b/src/hooks/post-commit.ts new file mode 100644 index 00000000..d35ff269 --- /dev/null +++ b/src/hooks/post-commit.ts @@ -0,0 +1,162 @@ +/** + * post-commit git hook + * + * Installs/uninstalls a git hook that writes session metadata as a git note + * when an AI-assisted session is detected. + * + * The hook pipes the commit SHA to `git-mem hook post-commit`, which dispatches + * to the PostCommitHandler via the EventBus. + */ + +import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync, chmodSync } from 'fs'; +import { join, resolve } from 'path'; +import { execFileSync } from 'child_process'; + +/** Prefix used to detect any version of our hook. */ +const HOOK_FINGERPRINT_PREFIX = '# git-mem:post-commit'; + +/** Full fingerprint with version — used for upgrade detection. */ +const HOOK_FINGERPRINT = `${HOOK_FINGERPRINT_PREFIX} v1`; + +/** + * The shell hook script. + * Pipes the commit SHA as JSON to git-mem hook post-commit. + */ +const HOOK_SCRIPT = `#!/bin/sh +${HOOK_FINGERPRINT} +# Writes session metadata as a git note when an AI-assisted session is detected. + +# Get the commit SHA +SHA=$(git rev-parse HEAD) + +# Pipe JSON to git-mem hook handler +echo "{\\"sha\\":\\"$SHA\\"}" | git-mem hook post-commit 2>/dev/null || true +`; + +/** + * 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 (any version). + */ +function isGitMemHook(hookPath: string): boolean { + if (!existsSync(hookPath)) return false; + const content = readFileSync(hookPath, 'utf8'); + return content.includes(HOOK_FINGERPRINT_PREFIX); +} + +/** + * Check if an installed hook is the current version. + */ +function isCurrentVersion(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 post-commit hook. + * Idempotent: re-running is safe. + * Wraps existing non-git-mem hooks by renaming them to .user-backup. + * Upgrades outdated git-mem hooks in-place. + */ +export function installPostCommitHook(cwd?: string): IHookInstallResult { + const gitDir = findGitDir(cwd || process.cwd()); + const hooksDir = join(gitDir, 'hooks'); + const hookPath = join(hooksDir, 'post-commit'); + const backupPath = join(hooksDir, 'post-commit.user-backup'); + + // Already installed and up-to-date — idempotent + if (isGitMemHook(hookPath) && isCurrentVersion(hookPath)) { + return { installed: false, wrapped: false, hookPath }; + } + + // Outdated git-mem hook — upgrade in-place + if (isGitMemHook(hookPath) && !isCurrentVersion(hookPath)) { + writeFileSync(hookPath, HOOK_SCRIPT); + chmodSync(hookPath, 0o755); + return { installed: true, wrapped: false, hookPath }; + } + + let wrapped = false; + + // Existing non-git-mem hook — wrap it + if (existsSync(hookPath)) { + // Guard: don't overwrite an existing backup from a failed previous install + if (existsSync(backupPath)) { + throw new Error( + `Backup hook already exists at ${backupPath}. ` + + 'Remove it manually or run --uninstall-hooks first.', + ); + } + renameSync(hookPath, backupPath); + wrapped = true; + + // Create a wrapper that calls the user's hook first, then ours. + // Uses a path relative to the hook's directory so it survives repo moves. + const wrapperScript = HOOK_SCRIPT.replace( + '#!/bin/sh', + '#!/bin/sh\n# Wrapped existing hook — original saved as post-commit.user-backup\n' + + 'BACKUP_HOOK="$(dirname "$0")/post-commit.user-backup"\n' + + 'if [ -x "$BACKUP_HOOK" ]; then\n "$BACKUP_HOOK" "$@" || exit $?\nfi', + ); + writeFileSync(hookPath, wrapperScript); + } else { + writeFileSync(hookPath, HOOK_SCRIPT); + } + + chmodSync(hookPath, 0o755); + return { installed: true, wrapped, hookPath }; +} + +/** + * Uninstall the post-commit hook. + * Restores wrapped user hooks if a backup exists. + */ +export function uninstallPostCommitHook(cwd?: string): boolean { + const gitDir = findGitDir(cwd || process.cwd()); + const hooksDir = join(gitDir, 'hooks'); + const hookPath = join(hooksDir, 'post-commit'); + const backupPath = join(hooksDir, 'post-commit.user-backup'); + + if (!isGitMemHook(hookPath)) { + return false; + } + + try { + unlinkSync(hookPath); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + // File was already removed — proceed to restore backup if needed + } + + // Restore user's original hook if it was wrapped + if (existsSync(backupPath)) { + renameSync(backupPath, hookPath); + } + + return true; +} diff --git a/src/hooks/utils/config.ts b/src/hooks/utils/config.ts index b912b947..1ad7a381 100644 --- a/src/hooks/utils/config.ts +++ b/src/hooks/utils/config.ts @@ -16,6 +16,7 @@ const DEFAULTS: IHookConfig = { sessionStart: { enabled: true, memoryLimit: 20 }, sessionStop: { enabled: true, autoExtract: true, threshold: 3 }, promptSubmit: { enabled: false, recordPrompts: false, surfaceContext: true }, + postCommit: { enabled: true }, }, }; @@ -48,6 +49,10 @@ export function loadHookConfig(cwd?: string): IHookConfig { ...DEFAULTS.hooks.promptSubmit, ...(rawHooks.promptSubmit ?? {}), }, + postCommit: { + ...DEFAULTS.hooks.postCommit, + ...(rawHooks.postCommit ?? {}), + }, }, }; } catch { diff --git a/src/infrastructure/di/container.ts b/src/infrastructure/di/container.ts index f466b7d6..ce3d2298 100644 --- a/src/infrastructure/di/container.ts +++ b/src/infrastructure/di/container.ts @@ -39,6 +39,7 @@ import { SessionCaptureService } from '../../application/services/SessionCapture import { SessionStartHandler } from '../../application/handlers/SessionStartHandler'; import { SessionStopHandler } from '../../application/handlers/SessionStopHandler'; import { PromptSubmitHandler } from '../../application/handlers/PromptSubmitHandler'; +import { PostCommitHandler } from '../../application/handlers/PostCommitHandler'; export function createContainer(options?: IContainerOptions): AwilixContainer { const container = createAwilixContainer({ @@ -76,6 +77,10 @@ export function createContainer(options?: IContainerOptions): AwilixContainer>, + overrides?: Partial>, ): void { const defaults = { hooks: { @@ -92,6 +93,7 @@ export function writeGitMemConfig( sessionStart: { enabled: true, memoryLimit: 20 }, sessionStop: { enabled: true, autoExtract: true, threshold: 3 }, promptSubmit: { enabled: false, recordPrompts: false, surfaceContext: true }, + postCommit: { enabled: true }, }, }; @@ -106,6 +108,7 @@ export function writeGitMemConfig( sessionStart: { ...defaults.hooks.sessionStart, ...(overrides.sessionStart as object) }, sessionStop: { ...defaults.hooks.sessionStop, ...(overrides.sessionStop as object) }, promptSubmit: { ...defaults.hooks.promptSubmit, ...(overrides.promptSubmit as object) }, + postCommit: { ...defaults.hooks.postCommit, ...(overrides.postCommit as object) }, }, }; writeFileSync(join(dir, '.git-mem.json'), JSON.stringify(merged, null, 2) + '\n'); diff --git a/tests/integration/hooks/hook-post-commit.test.ts b/tests/integration/hooks/hook-post-commit.test.ts new file mode 100644 index 00000000..9efb6418 --- /dev/null +++ b/tests/integration/hooks/hook-post-commit.test.ts @@ -0,0 +1,220 @@ +/** + * Integration test: post-commit hook + * + * Exercises `git-mem hook post-commit` end-to-end against a real + * git repo. Verifies session metadata is written to git notes. + */ + +import { describe, it, before, after, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'child_process'; // Used by readNote() +import { + runHook, + createTestRepo, + writeGitMemConfig, + cleanupRepo, + addCommit, + git, +} from './helpers'; + +function readNote(sha: string, cwd: string): string | null { + try { + return execFileSync('git', ['notes', '--ref=refs/notes/mem', 'show', sha], { + encoding: 'utf8', + cwd, + }).trim(); + } catch { + return null; + } +} + +describe('Integration: hook post-commit', () => { + let repoDir: string; + let commitSha: string; + let originalEnv: NodeJS.ProcessEnv; + + before(() => { + const repo = createTestRepo('git-mem-hook-post-commit-'); + repoDir = repo.dir; + commitSha = repo.sha; + writeGitMemConfig(repoDir); + }); + + after(() => { + cleanupRepo(repoDir); + }); + + beforeEach(() => { + originalEnv = { ...process.env }; + }); + + afterEach(() => { + // Remove keys that were added during the test + for (const key of Object.keys(process.env)) { + if (!(key in originalEnv)) { + delete process.env[key]; + } + } + // Restore original values + Object.assign(process.env, originalEnv); + }); + + describe('when agent is detected', () => { + it('should write session note with agent and model', () => { + // Set env vars for agent detection + process.env.GIT_MEM_AGENT = 'TestAgent/2.0'; + process.env.GIT_MEM_MODEL = 'test-model-integration'; + + const result = runHook('post-commit', { + sha: commitSha, + cwd: repoDir, + }); + + assert.equal(result.status, 0, `Hook should exit 0, stderr: ${result.stderr}`); + + // Verify note was written + const noteContent = readNote(commitSha, repoDir); + assert.ok(noteContent, 'Note should exist on commit'); + + const payload = JSON.parse(noteContent); + assert.ok(payload.session, 'Note should have session field'); + assert.equal(payload.session.agent, 'TestAgent/2.0'); + assert.equal(payload.session.model, 'test-model-integration'); + assert.ok(payload.session.timestamp, 'Session should have timestamp'); + }); + + it('should exit successfully with status 0', () => { + process.env.GIT_MEM_AGENT = 'TestAgent/2.0'; + + const newSha = addCommit(repoDir, 'file2.txt', 'content', 'feat: second commit'); + + const result = runHook('post-commit', { + sha: newSha, + cwd: repoDir, + }); + + assert.equal(result.status, 0); + // Note: post-commit handler doesn't output to stdout, so no stderr summary is printed + // Verify note was written instead + const noteContent = readNote(newSha, repoDir); + assert.ok(noteContent, 'Note should exist on commit'); + }); + + it('should preserve existing memories when adding session', () => { + process.env.GIT_MEM_AGENT = 'TestAgent/2.0'; + + // Create a commit and add a memory to it first + const sha = addCommit(repoDir, 'file3.txt', 'content', 'feat: commit with memory'); + + // Write a note with existing memories + const existingPayload = JSON.stringify({ + memories: [ + { id: 'mem-1', content: 'Existing memory', type: 'fact' }, + ], + }); + execFileSync('git', ['notes', '--ref=refs/notes/mem', 'add', '-f', '-m', existingPayload, sha], { + cwd: repoDir, + }); + + // Run post-commit hook + const result = runHook('post-commit', { + sha, + cwd: repoDir, + }); + + assert.equal(result.status, 0); + + // Verify both memories and session exist + const noteContent = readNote(sha, repoDir); + assert.ok(noteContent); + + const payload = JSON.parse(noteContent); + assert.ok(payload.memories, 'Should preserve memories'); + assert.equal(payload.memories.length, 1); + assert.equal(payload.memories[0].id, 'mem-1'); + assert.ok(payload.session, 'Should have session'); + assert.equal(payload.session.agent, 'TestAgent/2.0'); + }); + }); + + describe('when no agent is detected', () => { + beforeEach(() => { + delete process.env.GIT_MEM_AGENT; + delete process.env.GIT_MEM_MODEL; + delete process.env.CLAUDECODE; + delete process.env.CLAUDE_CODE; + delete process.env.ANTHROPIC_MODEL; + }); + + it('should exit successfully without writing note', () => { + const sha = addCommit(repoDir, 'noagent.txt', 'content', 'chore: no agent commit'); + + const result = runHook('post-commit', { + sha, + cwd: repoDir, + }); + + assert.equal(result.status, 0); + + // No note should be written + const noteContent = readNote(sha, repoDir); + assert.equal(noteContent, null, 'Should not write note when no agent'); + }); + }); + + describe('with CLAUDECODE env var', () => { + it('should detect Claude Code agent from env var', () => { + delete process.env.GIT_MEM_AGENT; + process.env.CLAUDECODE = '1'; + process.env.ANTHROPIC_MODEL = 'claude-opus-4-6'; + + const sha = addCommit(repoDir, 'claude.txt', 'content', 'feat: claude commit'); + + const result = runHook('post-commit', { + sha, + cwd: repoDir, + }); + + assert.equal(result.status, 0); + + const noteContent = readNote(sha, repoDir); + assert.ok(noteContent); + + const payload = JSON.parse(noteContent); + assert.ok(payload.session.agent.includes('Claude-Code')); + assert.equal(payload.session.model, 'claude-opus-4-6'); + }); + }); + + describe('when hook is disabled', () => { + it('should exit without action when postCommit.enabled is false', () => { + const disabledRepo = createTestRepo('git-mem-hook-post-commit-disabled-'); + + try { + writeGitMemConfig(disabledRepo.dir, { postCommit: { enabled: false } }); + process.env.GIT_MEM_AGENT = 'TestAgent/2.0'; + + const sha = addCommit(disabledRepo.dir, 'disabled.txt', 'content', 'feat: disabled hook'); + + const result = runHook('post-commit', { + sha, + cwd: disabledRepo.dir, + }); + + assert.equal(result.status, 0); + + // Should indicate hook was skipped + assert.ok( + result.stderr.includes('disabled') || result.stderr === '', + `stderr should indicate skipped or be empty, got: ${result.stderr}`, + ); + + // No note should be written + const noteContent = readNote(sha, disabledRepo.dir); + assert.equal(noteContent, null, 'Should not write note when hook disabled'); + } finally { + cleanupRepo(disabledRepo.dir); + } + }); + }); +}); diff --git a/tests/integration/hooks/post-commit-install.test.ts b/tests/integration/hooks/post-commit-install.test.ts new file mode 100644 index 00000000..da1c24f3 --- /dev/null +++ b/tests/integration/hooks/post-commit-install.test.ts @@ -0,0 +1,241 @@ +/** + * post-commit hook — unit tests + * + * Tests installPostCommitHook / uninstallPostCommitHook 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 { installPostCommitHook, uninstallPostCommitHook } from '../../../src/hooks/post-commit'; + +function git(args: string[], cwd: string): string { + return execFileSync('git', args, { encoding: 'utf8', cwd }).trim(); +} + +describe('installPostCommitHook', () => { + let repoDir: string; + + before(() => { + repoDir = mkdtempSync(join(tmpdir(), 'git-mem-post-commit-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 = installPostCommitHook(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:post-commit v1')); + assert.ok(content.includes('git-mem hook post-commit')); + }); + + it('should be idempotent — second install is a no-op', () => { + const result = installPostCommitHook(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-post-commit-wrap-')); + git(['init'], freshRepo); + + const hooksDir = join(freshRepo, '.git', 'hooks'); + const hookPath = join(hooksDir, 'post-commit'); + const userHook = '#!/bin/sh\necho "user post-commit running"\n'; + writeFileSync(hookPath, userHook); + chmodSync(hookPath, 0o755); + + try { + const result = installPostCommitHook(freshRepo); + + assert.equal(result.installed, true); + assert.equal(result.wrapped, true); + + // Backup should exist + const backupPath = join(hooksDir, 'post-commit.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:post-commit v1')); + assert.ok(content.includes('user-backup')); + } finally { + rmSync(freshRepo, { recursive: true, force: true }); + } + }); + + it('should throw when backup already exists', () => { + const freshRepo = mkdtempSync(join(tmpdir(), 'git-mem-post-commit-backup-exists-')); + git(['init'], freshRepo); + + const hooksDir = join(freshRepo, '.git', 'hooks'); + const hookPath = join(hooksDir, 'post-commit'); + const backupPath = join(hooksDir, 'post-commit.user-backup'); + + // Create both a user hook and a leftover backup + writeFileSync(hookPath, '#!/bin/sh\necho "user hook"\n'); + chmodSync(hookPath, 0o755); + writeFileSync(backupPath, '#!/bin/sh\necho "old backup"\n'); + + try { + assert.throws( + () => installPostCommitHook(freshRepo), + /Backup hook already exists/, + ); + } finally { + rmSync(freshRepo, { recursive: true, force: true }); + } + }); + + it('should upgrade older hook version in-place', () => { + const freshRepo = mkdtempSync(join(tmpdir(), 'git-mem-post-commit-upgrade-')); + git(['init'], freshRepo); + + const hooksDir = join(freshRepo, '.git', 'hooks'); + const hookPath = join(hooksDir, 'post-commit'); + + // Write a hypothetical v0 hook (old fingerprint) + const v0Hook = '#!/bin/sh\n# git-mem:post-commit v0\n# Old hook version\nexit 0\n'; + mkdirSync(hooksDir, { recursive: true }); + writeFileSync(hookPath, v0Hook); + chmodSync(hookPath, 0o755); + + try { + const result = installPostCommitHook(freshRepo); + + // Should have upgraded in-place + assert.equal(result.installed, true); + assert.equal(result.wrapped, false); + + const content = readFileSync(hookPath, 'utf8'); + assert.ok(content.includes('git-mem:post-commit v1'), 'Should be upgraded to v1'); + assert.ok(content.includes('git-mem hook post-commit'), 'Should have current command'); + + // Second install should be idempotent + const result2 = installPostCommitHook(freshRepo); + assert.equal(result2.installed, false); + } finally { + rmSync(freshRepo, { recursive: true, force: true }); + } + }); +}); + +describe('uninstallPostCommitHook', () => { + it('should remove git-mem hook', () => { + const repoDir = mkdtempSync(join(tmpdir(), 'git-mem-post-commit-uninstall-')); + git(['init'], repoDir); + + try { + // Install first + installPostCommitHook(repoDir); + + // Uninstall + const removed = uninstallPostCommitHook(repoDir); + assert.equal(removed, true); + + const hookPath = join(repoDir, '.git', 'hooks', 'post-commit'); + 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-post-commit-uninstall-none-')); + git(['init'], repoDir); + + try { + const removed = uninstallPostCommitHook(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-post-commit-restore-')); + git(['init'], repoDir); + + const hooksDir = join(repoDir, '.git', 'hooks'); + const hookPath = join(hooksDir, 'post-commit'); + const userHook = '#!/bin/sh\necho "user post-commit"\n'; + writeFileSync(hookPath, userHook); + chmodSync(hookPath, 0o755); + + try { + installPostCommitHook(repoDir); + const removed = uninstallPostCommitHook(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, 'post-commit.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-post-commit-foreign-')); + git(['init'], repoDir); + + const hooksDir = join(repoDir, '.git', 'hooks'); + const hookPath = join(hooksDir, 'post-commit'); + const foreignHook = '#!/bin/sh\necho "foreign post-commit hook"\n'; + writeFileSync(hookPath, foreignHook); + chmodSync(hookPath, 0o755); + + try { + const removed = uninstallPostCommitHook(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('post-commit hook script content', () => { + it('should pipe SHA as JSON to git-mem hook post-commit', () => { + const repoDir = mkdtempSync(join(tmpdir(), 'git-mem-post-commit-script-')); + git(['init'], repoDir); + + try { + const result = installPostCommitHook(repoDir); + const content = readFileSync(result.hookPath, 'utf8'); + + // Verify script structure + assert.ok(content.includes('SHA=$(git rev-parse HEAD)'), 'should capture commit SHA'); + assert.ok(content.includes('echo'), 'should echo JSON'); + assert.ok(content.includes('git-mem hook post-commit'), 'should pipe to git-mem'); + assert.ok(content.includes('2>/dev/null'), 'should suppress stderr'); + assert.ok(content.includes('|| true'), 'should not fail on error'); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/integration/mcp-context.test.ts b/tests/integration/mcp-context.test.ts index ab10e6e0..272f68cf 100644 --- a/tests/integration/mcp-context.test.ts +++ b/tests/integration/mcp-context.test.ts @@ -7,102 +7,22 @@ import { describe, it, before, after } from 'node:test'; import assert from 'node:assert/strict'; -import { spawn, execFileSync } from 'child_process'; -import { mkdtempSync, writeFileSync, rmSync } from 'fs'; +import { writeFileSync } from 'fs'; import { join } from 'path'; -import { tmpdir } from 'os'; - -const SERVER_PATH = join(__dirname, '..', '..', 'dist', 'mcp-server.js'); - -function git(args: string[], cwd: string): string { - return execFileSync('git', args, { encoding: 'utf8', cwd }).trim(); -} - -function mcpSession(cwd: string, requests: object[]): Promise { - return new Promise((resolve, reject) => { - const proc = spawn('node', [SERVER_PATH], { - stdio: ['pipe', 'pipe', 'pipe'], - cwd, - }); - - let stdout = ''; - const responses: object[] = []; - const expectedResponses = 1 + requests.length; - - proc.stdout.on('data', (data: Buffer) => { - stdout += data.toString(); - const lines = stdout.split('\n').filter(Boolean); - - for (const line of lines) { - try { - const parsed = JSON.parse(line); - if (parsed.id !== undefined) { - responses.push(parsed); - } - } catch { - // Incomplete line - } - } - stdout = ''; - - if (responses.length >= expectedResponses) { - proc.kill(); - resolve(responses); - } - }); - - proc.on('error', reject); - - const timeout = setTimeout(() => { - proc.kill(); - reject(new Error(`MCP session timed out. Got ${responses.length}/${expectedResponses} responses`)); - }, 10000); - - proc.on('close', () => { - clearTimeout(timeout); - }); - - proc.stdin.write(JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { - protocolVersion: '2024-11-05', - capabilities: {}, - clientInfo: { name: 'test', version: '1.0' }, - }, - }) + '\n'); - - proc.stdin.write(JSON.stringify({ - jsonrpc: '2.0', - method: 'notifications/initialized', - }) + '\n'); - - for (let i = 0; i < requests.length; i++) { - proc.stdin.write(JSON.stringify({ - jsonrpc: '2.0', - id: i + 2, - ...requests[i], - }) + '\n'); - } - }); -} +import { mcpSession, git, createTestRepo, cleanupRepo } from './mcp/helpers'; describe('Integration: MCP Tool — context', () => { let repoDir: string; before(() => { - repoDir = mkdtempSync(join(tmpdir(), 'git-mem-mcp-context-')); - git(['init'], repoDir); - git(['config', 'user.email', 'test@test.com'], repoDir); - git(['config', 'user.name', 'Test User'], repoDir); + const repo = createTestRepo('git-mem-mcp-context-'); + repoDir = repo.dir; writeFileSync(join(repoDir, 'auth.ts'), 'export function login() {}'); writeFileSync(join(repoDir, 'db.ts'), 'export function connect() {}'); git(['add', '.'], repoDir); git(['commit', '-m', 'feat: initial app'], repoDir); - // Store memories via CLI (using the remember tool via MCP would create a chicken-and-egg) - // Use git notes directly for setup + // Store memories via git notes directly for setup const sha = git(['rev-parse', 'HEAD'], repoDir); const memory = { memories: [ @@ -136,7 +56,7 @@ describe('Integration: MCP Tool — context', () => { }); after(() => { - rmSync(repoDir, { recursive: true, force: true }); + cleanupRepo(repoDir); }); it('should return no-staged-changes message when nothing is staged', async () => { diff --git a/tests/integration/mcp-e2e.test.ts b/tests/integration/mcp-e2e.test.ts index 5d9b1394..b8f0ff91 100644 --- a/tests/integration/mcp-e2e.test.ts +++ b/tests/integration/mcp-e2e.test.ts @@ -7,99 +7,16 @@ import { describe, it, before, after } from 'node:test'; import assert from 'node:assert/strict'; -import { spawn, execFileSync } from 'child_process'; -import { mkdtempSync, writeFileSync, rmSync } from 'fs'; +import { writeFileSync } from 'fs'; import { join } from 'path'; -import { tmpdir } from 'os'; - -const SERVER_PATH = join(__dirname, '..', '..', 'dist', 'mcp-server.js'); - -function git(args: string[], cwd: string): string { - return execFileSync('git', args, { encoding: 'utf8', cwd }).trim(); -} - -/** - * Send a sequence of MCP messages and collect responses. - */ -function mcpSession(cwd: string, requests: object[]): Promise { - return new Promise((resolve, reject) => { - const proc = spawn('node', [SERVER_PATH], { - stdio: ['pipe', 'pipe', 'pipe'], - cwd, - }); - - let stdout = ''; - const responses: object[] = []; - const expectedResponses = 1 + requests.length; - - proc.stdout.on('data', (data: Buffer) => { - stdout += data.toString(); - const lines = stdout.split('\n').filter(Boolean); - - for (const line of lines) { - try { - const parsed = JSON.parse(line); - if (parsed.id !== undefined) { - responses.push(parsed); - } - } catch { - // Incomplete line - } - } - stdout = ''; - - if (responses.length >= expectedResponses) { - proc.kill(); - resolve(responses); - } - }); - - proc.on('error', reject); - - const timeout = setTimeout(() => { - proc.kill(); - reject(new Error(`MCP session timed out. Got ${responses.length}/${expectedResponses} responses`)); - }, 15000); - - proc.on('close', () => { - clearTimeout(timeout); - }); - - // Initialize - proc.stdin.write(JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { - protocolVersion: '2024-11-05', - capabilities: {}, - clientInfo: { name: 'e2e-test', version: '1.0' }, - }, - }) + '\n'); - - proc.stdin.write(JSON.stringify({ - jsonrpc: '2.0', - method: 'notifications/initialized', - }) + '\n'); - - for (let i = 0; i < requests.length; i++) { - proc.stdin.write(JSON.stringify({ - jsonrpc: '2.0', - id: i + 2, - ...requests[i], - }) + '\n'); - } - }); -} +import { mcpSession, git, createTestRepo, cleanupRepo } from './mcp/helpers'; describe('Integration: MCP End-to-End Session', () => { let repoDir: string; before(() => { - repoDir = mkdtempSync(join(tmpdir(), 'git-mem-mcp-e2e-')); - git(['init'], repoDir); - git(['config', 'user.email', 'test@test.com'], repoDir); - git(['config', 'user.name', 'Test User'], repoDir); + const repo = createTestRepo('git-mem-mcp-e2e-'); + repoDir = repo.dir; // Create initial commit with heuristic-extractable pattern writeFileSync(join(repoDir, 'auth.ts'), 'export function login() {}'); @@ -109,7 +26,7 @@ describe('Integration: MCP End-to-End Session', () => { }); after(() => { - rmSync(repoDir, { recursive: true, force: true }); + cleanupRepo(repoDir); }); it('should complete a full session: list → remember → recall → context → extract', async () => { diff --git a/tests/integration/mcp-extract.test.ts b/tests/integration/mcp-extract.test.ts index aa441225..e5dfe70a 100644 --- a/tests/integration/mcp-extract.test.ts +++ b/tests/integration/mcp-extract.test.ts @@ -7,95 +7,16 @@ import { describe, it, before, after } from 'node:test'; import assert from 'node:assert/strict'; -import { spawn, execFileSync } from 'child_process'; -import { mkdtempSync, writeFileSync, rmSync } from 'fs'; +import { writeFileSync } from 'fs'; import { join } from 'path'; -import { tmpdir } from 'os'; - -const SERVER_PATH = join(__dirname, '..', '..', 'dist', 'mcp-server.js'); - -function git(args: string[], cwd: string): string { - return execFileSync('git', args, { encoding: 'utf8', cwd }).trim(); -} - -function mcpSession(cwd: string, requests: object[]): Promise { - return new Promise((resolve, reject) => { - const proc = spawn('node', [SERVER_PATH], { - stdio: ['pipe', 'pipe', 'pipe'], - cwd, - }); - - let stdout = ''; - const responses: object[] = []; - const expectedResponses = 1 + requests.length; - - proc.stdout.on('data', (data: Buffer) => { - stdout += data.toString(); - const lines = stdout.split('\n').filter(Boolean); - - for (const line of lines) { - try { - const parsed = JSON.parse(line); - if (parsed.id !== undefined) { - responses.push(parsed); - } - } catch { - // Incomplete line - } - } - stdout = ''; - - if (responses.length >= expectedResponses) { - proc.kill(); - resolve(responses); - } - }); - - proc.on('error', reject); - - const timeout = setTimeout(() => { - proc.kill(); - reject(new Error(`MCP session timed out. Got ${responses.length}/${expectedResponses} responses`)); - }, 10000); - - proc.on('close', () => { - clearTimeout(timeout); - }); - - proc.stdin.write(JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { - protocolVersion: '2024-11-05', - capabilities: {}, - clientInfo: { name: 'test', version: '1.0' }, - }, - }) + '\n'); - - proc.stdin.write(JSON.stringify({ - jsonrpc: '2.0', - method: 'notifications/initialized', - }) + '\n'); - - for (let i = 0; i < requests.length; i++) { - proc.stdin.write(JSON.stringify({ - jsonrpc: '2.0', - id: i + 2, - ...requests[i], - }) + '\n'); - } - }); -} +import { mcpSession, git, createTestRepo, cleanupRepo } from './mcp/helpers'; describe('Integration: MCP Tool — extract', () => { let repoDir: string; before(() => { - repoDir = mkdtempSync(join(tmpdir(), 'git-mem-mcp-extract-')); - git(['init'], repoDir); - git(['config', 'user.email', 'test@test.com'], repoDir); - git(['config', 'user.name', 'Test User'], repoDir); + const repo = createTestRepo('git-mem-mcp-extract-'); + repoDir = repo.dir; // Create commits with heuristic-extractable patterns writeFileSync(join(repoDir, 'auth.ts'), 'export function login() {}'); @@ -108,7 +29,7 @@ describe('Integration: MCP Tool — extract', () => { }); after(() => { - rmSync(repoDir, { recursive: true, force: true }); + cleanupRepo(repoDir); }); it('should run extract in dry-run mode via MCP', async () => { diff --git a/tests/integration/mcp-server.test.ts b/tests/integration/mcp-server.test.ts index c3ee00da..9c88d6d3 100644 --- a/tests/integration/mcp-server.test.ts +++ b/tests/integration/mcp-server.test.ts @@ -7,48 +7,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { spawn } from 'child_process'; -import { join } from 'path'; - -function sendMcpRequest(request: object): Promise { - return new Promise((resolve, reject) => { - const serverPath = join(__dirname, '..', '..', 'dist', 'mcp-server.js'); - const proc = spawn('node', [serverPath], { - stdio: ['pipe', 'pipe', 'pipe'], - }); - - let stdout = ''; - - proc.stdout.on('data', (data: Buffer) => { - stdout += data.toString(); - // MCP responses are newline-delimited JSON - const lines = stdout.split('\n').filter(Boolean); - if (lines.length > 0) { - try { - const parsed = JSON.parse(lines[0]); - proc.kill(); - resolve(parsed); - } catch { - // Not complete yet, wait for more data - } - } - }); - - proc.on('error', reject); - - const timeout = setTimeout(() => { - proc.kill(); - reject(new Error('MCP server did not respond within 5 seconds')); - }, 5000); - - proc.on('close', () => { - clearTimeout(timeout); - }); - - proc.stdin.write(JSON.stringify(request) + '\n'); - proc.stdin.end(); - }); -} +import { sendMcpRequest } from './mcp/helpers'; describe('Integration: MCP Server', () => { it('should respond to initialize with server info', async () => { diff --git a/tests/integration/mcp-tools.test.ts b/tests/integration/mcp-tools.test.ts index 2043d61b..b2a09c2c 100644 --- a/tests/integration/mcp-tools.test.ts +++ b/tests/integration/mcp-tools.test.ts @@ -7,111 +7,23 @@ import { describe, it, before, after } from 'node:test'; import assert from 'node:assert/strict'; -import { spawn, execFileSync } from 'child_process'; -import { mkdtempSync, writeFileSync, rmSync } from 'fs'; +import { writeFileSync } from 'fs'; import { join } from 'path'; -import { tmpdir } from 'os'; - -const SERVER_PATH = join(__dirname, '..', '..', 'dist', 'mcp-server.js'); - -function git(args: string[], cwd: string): string { - return execFileSync('git', args, { encoding: 'utf8', cwd }).trim(); -} - -/** - * Send a sequence of MCP messages and collect responses. - * Always sends initialize + initialized notification first. - */ -function mcpSession(cwd: string, requests: object[]): Promise { - return new Promise((resolve, reject) => { - const proc = spawn('node', [SERVER_PATH], { - stdio: ['pipe', 'pipe', 'pipe'], - cwd, - }); - - let stdout = ''; - const responses: object[] = []; - // We expect 1 response for initialize + 1 per request - const expectedResponses = 1 + requests.length; - - proc.stdout.on('data', (data: Buffer) => { - stdout += data.toString(); - const lines = stdout.split('\n').filter(Boolean); - - for (const line of lines) { - try { - const parsed = JSON.parse(line); - if (parsed.id !== undefined) { - responses.push(parsed); - } - } catch { - // Incomplete line, skip - } - } - // Remove processed lines - stdout = ''; - - if (responses.length >= expectedResponses) { - proc.kill(); - resolve(responses); - } - }); - - proc.on('error', reject); - - const timeout = setTimeout(() => { - proc.kill(); - reject(new Error(`MCP session timed out. Got ${responses.length}/${expectedResponses} responses`)); - }, 10000); - - proc.on('close', () => { - clearTimeout(timeout); - }); - - // Send initialize - proc.stdin.write(JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { - protocolVersion: '2024-11-05', - capabilities: {}, - clientInfo: { name: 'test', version: '1.0' }, - }, - }) + '\n'); - - // Send initialized notification - proc.stdin.write(JSON.stringify({ - jsonrpc: '2.0', - method: 'notifications/initialized', - }) + '\n'); - - // Send tool requests with IDs starting from 2 - for (let i = 0; i < requests.length; i++) { - proc.stdin.write(JSON.stringify({ - jsonrpc: '2.0', - id: i + 2, - ...requests[i], - }) + '\n'); - } - }); -} +import { mcpSession, git, createTestRepo, cleanupRepo } from './mcp/helpers'; describe('Integration: MCP Tools — remember + recall', () => { let repoDir: string; before(() => { - repoDir = mkdtempSync(join(tmpdir(), 'git-mem-mcp-tools-')); - git(['init'], repoDir); - git(['config', 'user.email', 'test@test.com'], repoDir); - git(['config', 'user.name', 'Test User'], repoDir); + const repo = createTestRepo('git-mem-mcp-tools-'); + repoDir = repo.dir; writeFileSync(join(repoDir, 'app.ts'), 'console.log("hello");'); git(['add', '.'], repoDir); - git(['commit', '-m', 'feat: initial app'], repoDir); + git(['commit', '-m', 'feat: add app'], repoDir); }); after(() => { - rmSync(repoDir, { recursive: true, force: true }); + cleanupRepo(repoDir); }); it('should store a memory via git_mem_remember', async () => { diff --git a/tests/integration/mcp/helpers.ts b/tests/integration/mcp/helpers.ts new file mode 100644 index 00000000..4b5e7c2a --- /dev/null +++ b/tests/integration/mcp/helpers.ts @@ -0,0 +1,198 @@ +/** + * Shared helpers for MCP integration tests. + * + * Provides child-process wrappers to spawn the MCP server via tsx + * and send JSON-RPC requests through stdio. + */ + +import { spawn, execFileSync } from 'child_process'; +import { mkdtempSync, writeFileSync, rmSync } from 'fs'; +import { join, resolve } from 'path'; +import { tmpdir } from 'os'; + +const PROJECT_ROOT = resolve(__dirname, '../../..'); +const SERVER_PATH = resolve(PROJECT_ROOT, 'src/mcp-server.ts'); + +// Use tsx binary from project node_modules — works even when cwd is a temp dir +// On Windows, spawn() needs the .cmd wrapper; on Unix, use the shell script +const TSX_BIN = resolve( + PROJECT_ROOT, + 'node_modules/.bin', + process.platform === 'win32' ? 'tsx.cmd' : 'tsx', +); + +export interface IMcpResponse { + jsonrpc: string; + id?: number; + result?: unknown; + error?: { code: number; message: string }; +} + +/** + * Send a single MCP request and get a response. + * Spawns the server, sends the request, waits for response, kills server. + */ +export function sendMcpRequest(request: object): Promise { + return new Promise((resolve, reject) => { + const proc = spawn(TSX_BIN, [SERVER_PATH], { + stdio: ['pipe', 'pipe', 'pipe'], + }); + + let stdout = ''; + let resolved = false; + + proc.stdout.on('data', (data: Buffer) => { + stdout += data.toString(); + // MCP responses are newline-delimited JSON + const lines = stdout.split('\n').filter(Boolean); + if (lines.length > 0 && !resolved) { + try { + const parsed = JSON.parse(lines[0]) as IMcpResponse; + resolved = true; + proc.kill(); + resolve(parsed); + } catch { + // Not complete yet, wait for more data + } + } + }); + + proc.on('error', (err) => { + if (!resolved) { + resolved = true; + reject(err); + } + }); + + const timeout = setTimeout(() => { + if (!resolved) { + resolved = true; + proc.kill(); + reject(new Error('MCP server did not respond within 5 seconds')); + } + }, 5000); + + proc.on('close', () => { + clearTimeout(timeout); + }); + + proc.stdin.write(JSON.stringify(request) + '\n'); + proc.stdin.end(); + }); +} + +/** + * Send a sequence of MCP messages and collect responses. + * Always sends initialize + initialized notification first. + */ +export function mcpSession(cwd: string, requests: object[]): Promise { + return new Promise((resolve, reject) => { + const proc = spawn(TSX_BIN, [SERVER_PATH], { + stdio: ['pipe', 'pipe', 'pipe'], + cwd, + }); + + let stdout = ''; + const responses: IMcpResponse[] = []; + // We expect 1 response for initialize + 1 per request + const expectedResponses = 1 + requests.length; + let resolved = false; + + proc.stdout.on('data', (data: Buffer) => { + stdout += data.toString(); + const lines = stdout.split('\n'); + // Keep the last partial line for the next chunk + stdout = lines.pop() ?? ''; + + for (const line of lines.filter(Boolean)) { + try { + const parsed = JSON.parse(line) as IMcpResponse; + if (parsed.id !== undefined) { + responses.push(parsed); + } + } catch { + // Incomplete line, skip + } + } + + if (responses.length >= expectedResponses && !resolved) { + resolved = true; + proc.kill(); + resolve(responses); + } + }); + + proc.on('error', (err) => { + if (!resolved) { + resolved = true; + reject(err); + } + }); + + const timeout = setTimeout(() => { + if (!resolved) { + resolved = true; + proc.kill(); + reject(new Error(`MCP session timed out. Got ${responses.length}/${expectedResponses} responses`)); + } + }, 10000); + + proc.on('close', () => { + clearTimeout(timeout); + }); + + // Send initialize + proc.stdin.write(JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'test', version: '1.0' }, + }, + }) + '\n'); + + // Send initialized notification + proc.stdin.write(JSON.stringify({ + jsonrpc: '2.0', + method: 'notifications/initialized', + }) + '\n'); + + // Send tool requests with IDs starting from 2 + for (let i = 0; i < requests.length; i++) { + proc.stdin.write(JSON.stringify({ + jsonrpc: '2.0', + id: i + 2, + ...requests[i], + }) + '\n'); + } + proc.stdin.end(); + }); +} + +/** Helper to run git commands. */ +export function git(args: string[], cwd: string): string { + return execFileSync('git', args, { encoding: 'utf8', cwd }).trim(); +} + +/** Create a temp git repo with an initial commit. Returns dir and HEAD sha. */ +export function createTestRepo(prefix = 'git-mem-mcp-'): { dir: string; sha: string } { + const dir = mkdtempSync(join(tmpdir(), prefix)); + + git(['init'], dir); + git(['config', 'user.email', 'test@test.com'], dir); + git(['config', 'user.name', 'Test User'], dir); + + writeFileSync(join(dir, 'file.txt'), 'initial content'); + git(['add', '.'], dir); + git(['commit', '-m', 'feat: initial commit'], dir); + const sha = git(['rev-parse', 'HEAD'], dir); + + return { dir, sha }; +} + +/** Remove a temp directory. */ +export function cleanupRepo(dir: string): void { + rmSync(dir, { recursive: true, force: true }); +} diff --git a/tests/unit/application/handlers/PostCommitHandler.test.ts b/tests/unit/application/handlers/PostCommitHandler.test.ts new file mode 100644 index 00000000..6d58dd04 --- /dev/null +++ b/tests/unit/application/handlers/PostCommitHandler.test.ts @@ -0,0 +1,295 @@ +/** + * PostCommitHandler unit tests + */ + +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { PostCommitHandler } from '../../../../src/application/handlers/PostCommitHandler'; +import type { INotesService } from '../../../../src/domain/interfaces/INotesService'; +import type { ILogger } from '../../../../src/domain/interfaces/ILogger'; +import type { IGitCommitEvent } from '../../../../src/domain/events/HookEvents'; + +function createEvent(overrides?: Partial): IGitCommitEvent { + return { + type: 'git:commit', + sha: 'abc123', + cwd: '/tmp/test-repo', + ...overrides, + }; +} + +function createMockNotesService(readResult?: string | null): INotesService & { writeCalls: Array<{ sha: string; content: string; ref?: string; cwd?: string }> } { + const writeCalls: Array<{ sha: string; content: string; ref?: string; cwd?: string }> = []; + return { + writeCalls, + read: () => readResult ?? null, + write: (sha: string, content: string, ref?: string, cwd?: string) => { + writeCalls.push({ sha, content, ref, cwd }); + }, + }; +} + +function createMockLogger(): ILogger & { logs: Array<{ level: string; msg: string; data?: unknown }> } { + const logs: Array<{ level: string; msg: string; data?: unknown }> = []; + return { + logs, + child: (_bindings: Record) => createMockLogger(), + trace: (message: string, context?: Record) => logs.push({ level: 'trace', msg: message, data: context }), + debug: (message: string, context?: Record) => logs.push({ level: 'debug', msg: message, data: context }), + info: (message: string, context?: Record) => logs.push({ level: 'info', msg: message, data: context }), + warn: (message: string, context?: Record) => logs.push({ level: 'warn', msg: message, data: context }), + error: (message: string, context?: Record) => logs.push({ level: 'error', msg: message, data: context }), + fatal: (message: string, context?: Record) => logs.push({ level: 'fatal', msg: message, data: context }), + isLevelEnabled: () => true, + }; +} + +describe('PostCommitHandler', () => { + // Save and restore env vars around tests + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + originalEnv = { ...process.env }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe('when agent is detected', () => { + beforeEach(() => { + process.env.GIT_MEM_AGENT = 'TestAgent/1.0'; + process.env.GIT_MEM_MODEL = 'test-model-1'; + }); + + it('should write session note with agent and model', async () => { + const notesService = createMockNotesService(null); + const handler = new PostCommitHandler(notesService); + + const result = await handler.handle(createEvent({ sha: 'def456' })); + + assert.equal(result.success, true); + assert.equal(result.handler, 'PostCommitHandler'); + assert.equal(notesService.writeCalls.length, 1); + + const written = notesService.writeCalls[0]; + assert.equal(written.sha, 'def456'); + assert.equal(written.cwd, '/tmp/test-repo'); + + const payload = JSON.parse(written.content); + assert.equal(payload.session.agent, 'TestAgent/1.0'); + assert.equal(payload.session.model, 'test-model-1'); + assert.ok(payload.session.timestamp, 'should have timestamp'); + }); + + it('should preserve existing memories when adding session', async () => { + const existingPayload = JSON.stringify({ + memories: [ + { id: 'mem-1', content: 'Existing memory', type: 'fact' }, + { id: 'mem-2', content: 'Another memory', type: 'decision' }, + ], + }); + const notesService = createMockNotesService(existingPayload); + const handler = new PostCommitHandler(notesService); + + const result = await handler.handle(createEvent()); + + assert.equal(result.success, true); + assert.equal(notesService.writeCalls.length, 1); + + const payload = JSON.parse(notesService.writeCalls[0].content); + assert.equal(payload.memories.length, 2); + assert.equal(payload.memories[0].id, 'mem-1'); + assert.equal(payload.memories[1].id, 'mem-2'); + assert.ok(payload.session.agent); + }); + + it('should update existing session field', async () => { + const existingPayload = JSON.stringify({ + session: { + agent: 'OldAgent/0.1', + model: 'old-model', + timestamp: '2020-01-01T00:00:00Z', + }, + }); + const notesService = createMockNotesService(existingPayload); + const handler = new PostCommitHandler(notesService); + + const result = await handler.handle(createEvent()); + + assert.equal(result.success, true); + const payload = JSON.parse(notesService.writeCalls[0].content); + assert.equal(payload.session.agent, 'TestAgent/1.0'); + assert.equal(payload.session.model, 'test-model-1'); + assert.notEqual(payload.session.timestamp, '2020-01-01T00:00:00Z'); + }); + + it('should pass cwd to notes service', async () => { + const notesService = createMockNotesService(null); + const handler = new PostCommitHandler(notesService); + + await handler.handle(createEvent({ cwd: '/custom/repo/path' })); + + assert.equal(notesService.writeCalls[0].cwd, '/custom/repo/path'); + }); + + it('should log success when logger provided', async () => { + const notesService = createMockNotesService(null); + const logger = createMockLogger(); + const handler = new PostCommitHandler(notesService, logger); + + await handler.handle(createEvent({ sha: 'xyz789' })); + + const infoLogs = logger.logs.filter((l) => l.level === 'info'); + assert.ok(infoLogs.length >= 1); + assert.ok(infoLogs.some((l) => l.msg.includes('Post-commit handler invoked'))); + }); + }); + + describe('when no agent is detected', () => { + beforeEach(() => { + delete process.env.GIT_MEM_AGENT; + delete process.env.CLAUDECODE; + delete process.env.CLAUDE_CODE; + }); + + it('should return success without writing note', async () => { + const notesService = createMockNotesService(null); + const handler = new PostCommitHandler(notesService); + + const result = await handler.handle(createEvent()); + + assert.equal(result.success, true); + assert.equal(result.handler, 'PostCommitHandler'); + assert.equal(notesService.writeCalls.length, 0, 'should not write any notes'); + }); + + it('should log debug message when logger provided', async () => { + const notesService = createMockNotesService(null); + const logger = createMockLogger(); + const handler = new PostCommitHandler(notesService, logger); + + await handler.handle(createEvent()); + + const debugLogs = logger.logs.filter((l) => l.level === 'debug'); + assert.ok(debugLogs.some((l) => l.msg.includes('No AI agent detected'))); + }); + }); + + describe('error handling', () => { + beforeEach(() => { + process.env.GIT_MEM_AGENT = 'TestAgent/1.0'; + }); + + it('should return failure when notes service read throws', async () => { + const notesService: INotesService = { + read: () => { throw new Error('read failed'); }, + write: () => {}, + }; + const handler = new PostCommitHandler(notesService); + + const result = await handler.handle(createEvent()); + + assert.equal(result.success, false); + assert.equal(result.handler, 'PostCommitHandler'); + assert.ok(result.error instanceof Error); + assert.equal(result.error!.message, 'read failed'); + }); + + it('should return failure when notes service write throws', async () => { + const notesService: INotesService = { + read: () => null, + write: () => { throw new Error('write failed'); }, + }; + const handler = new PostCommitHandler(notesService); + + const result = await handler.handle(createEvent()); + + assert.equal(result.success, false); + assert.ok(result.error instanceof Error); + assert.equal(result.error!.message, 'write failed'); + }); + + it('should handle non-Error throws', async () => { + const notesService: INotesService = { + read: () => { throw 'string error'; }, + write: () => {}, + }; + const handler = new PostCommitHandler(notesService); + + const result = await handler.handle(createEvent()); + + assert.equal(result.success, false); + assert.ok(result.error instanceof Error); + assert.equal(result.error!.message, 'string error'); + }); + + it('should log error when logger provided', async () => { + const notesService: INotesService = { + read: () => { throw new Error('oops'); }, + write: () => {}, + }; + const logger = createMockLogger(); + const handler = new PostCommitHandler(notesService, logger); + + await handler.handle(createEvent()); + + const errorLogs = logger.logs.filter((l) => l.level === 'error'); + assert.ok(errorLogs.some((l) => l.msg.includes('Post-commit handler failed'))); + }); + + it('should handle malformed JSON in existing note', async () => { + const notesService = createMockNotesService('not valid json {{{'); + const logger = createMockLogger(); + const handler = new PostCommitHandler(notesService, logger); + + const result = await handler.handle(createEvent()); + + assert.equal(result.success, true); + assert.equal(notesService.writeCalls.length, 1); + + // Should have logged warning about malformed JSON + const warnLogs = logger.logs.filter((l) => l.level === 'warn'); + assert.ok(warnLogs.some((l) => l.msg.includes('Malformed note JSON'))); + + // Should write fresh payload with just session + const payload = JSON.parse(notesService.writeCalls[0].content); + assert.ok(payload.session); + assert.ok(!payload.memories, 'should not have memories from malformed JSON'); + }); + }); + + describe('CLAUDECODE env var detection', () => { + beforeEach(() => { + delete process.env.GIT_MEM_AGENT; + delete process.env.GIT_MEM_MODEL; + }); + + it('should detect agent from CLAUDECODE env var', async () => { + process.env.CLAUDECODE = '1'; + const notesService = createMockNotesService(null); + const handler = new PostCommitHandler(notesService); + + const result = await handler.handle(createEvent()); + + assert.equal(result.success, true); + assert.equal(notesService.writeCalls.length, 1); + + const payload = JSON.parse(notesService.writeCalls[0].content); + assert.ok(payload.session.agent.includes('Claude-Code')); + }); + + it('should detect model from ANTHROPIC_MODEL env var', async () => { + process.env.CLAUDECODE = '1'; + process.env.ANTHROPIC_MODEL = 'claude-opus-4-6'; + const notesService = createMockNotesService(null); + const handler = new PostCommitHandler(notesService); + + const result = await handler.handle(createEvent()); + + assert.equal(result.success, true); + const payload = JSON.parse(notesService.writeCalls[0].content); + assert.equal(payload.session.model, 'claude-opus-4-6'); + }); + }); +});