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
121 changes: 121 additions & 0 deletions src/application/handlers/PostCommitHandler.ts
Original file line number Diff line number Diff line change
@@ -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';

Comment on lines +14 to +15
Copy link
Copy Markdown

@coderabbitai coderabbitai bot Feb 14, 2026

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Decouple application layer from infrastructure utilities.
PostCommitHandler imports resolveAgent/resolveModel from src/infrastructure, which breaks the application→domain-only dependency rule. Please inject an application-friendly abstraction (e.g., IAgentResolver in domain) and bind it in the DI container, or relocate agent detection to a domain/application utility.

As per coding guidelines, "src/{application,infrastructure}/**/*.ts: Implement domain interfaces in src/infrastructure/ and depend on domain layer only from src/application/."

🤖 Prompt for AI Agents
In `@src/application/handlers/PostCommitHandler.ts` around lines 14 - 15,
PostCommitHandler currently imports resolveAgent/resolveModel from
infrastructure; remove those direct imports and inject a domain-facing
abstraction (e.g., IAgentResolver) instead. Define IAgentResolver in the domain
layer with methods matching resolveAgent/resolveModel, update PostCommitHandler
to accept an IAgentResolver via constructor or DI token and call its methods
(replace direct resolveAgent/resolveModel calls). Implement the concrete
resolver in src/infrastructure (class name like AgentResolver) and bind it to
IAgentResolver in the DI container so PostCommitHandler depends only on the
domain interface. Ensure any tests or callers are updated to provide the
IAgentResolver mock or binding.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Acknowledged. For this PR, keeping the direct import since resolveAgent/resolveModel are pure functions with no external dependencies (just process.env reads). Creating an abstraction layer would add complexity without clear benefit for these simple utilities. Will consider for future refactoring if this pattern spreads.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@TonyCasey, understood! That's a reasonable pragmatic call for these simple environment readers. If they grow dependencies or need different behavior in tests down the line, the abstraction can be introduced then. Thanks for considering it for future refactoring if the pattern expands. 👍


🧠 Learnings used
Learnt from: TonyCasey
Repo: TonyCasey/git-mem PR: 20
File: src/commands/liberate.ts:17-25
Timestamp: 2026-02-12T13:28:25.372Z
Learning: In src/commands/**/*.ts and src/mcp/tools/**/*.ts: Use the Awilix DI container pattern via createContainer() instead of manual dependency construction. Command and tool handlers should create a container with appropriate options (logger, scope, enrich) and resolve services from container.cradle (e.g., const { memoryService, logger: log } = container.cradle).

Learnt from: CR
Repo: TonyCasey/git-mem PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-02-14T09:43:48.659Z
Learning: Applies to src/{application,infrastructure}/**/*.ts : Implement domain interfaces in src/infrastructure/ and depend on domain layer only from src/application/

Learnt from: TonyCasey
Repo: TonyCasey/git-mem PR: 21
File: src/commands/init-hooks.ts:69-75
Timestamp: 2026-02-12T15:35:31.520Z
Learning: In src/commands/**/*.ts: Commands that require domain services (IMemoryService, IContextService, ILiberateService, etc.) should use the Awilix DI container pattern via createContainer() and resolve services from container.cradle. Simple configuration/initialization commands that only perform file I/O operations (e.g., init-mcp, init-hooks) should accept an optional ILogger parameter directly without DI container overhead.
</learning>

<!-- [remove_learning]
e6547207-68e3-47f5-b1b7-4eedeeeec6ea

Learnt from: CR
Repo: TonyCasey/git-mem PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-02-12T13:30:15.764Z
Learning: Applies to src/commands/**/*.ts : Use manual dependency construction instead of a DI container. Each command/tool handler must construct its dependency chain fresh per invocation: NotesService → MemoryRepository → MemoryService

Learnt from: CR
Repo: TonyCasey/lisa PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-30T20:19:52.042Z
Learning: Applies to src/lib/**/*.{ts,tsx} : Domain layer must have no dependencies on infrastructure or application layers. Application layer depends only on domain interfaces.

Learnt from: TonyCasey
Repo: TonyCasey/git-mem PR: 21
File: src/hooks/session-start.ts:11-13
Timestamp: 2026-02-12T15:34:53.411Z
Learning: In src/**/*.{ts,tsx}: Internal relative imports (e.g., '../infrastructure/di', './utils/stdin') should NOT use `.js` extensions. The project is CommonJS and only requires `.js` extensions when importing external ESM dependencies like modelcontextprotocol/sdk with esModuleInterop: true.

Learnt from: CR
Repo: TonyCasey/lisa PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-30T20:19:52.042Z
Learning: For new features: 1) Define domain interface in `src/lib/domain/`, 2) Create handler in `src/lib/application/handlers/`, 3) Implement infrastructure in `src/lib/infrastructure/`, 4) Wire up DI in `src/lib/services.ts`, 5) Add CLI command if needed, 6) Write tests mirroring source structure.

/**
* 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<IEventResult> {
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 {};
Comment on lines +97 to +110
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

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

PostCommitHandler.readPayload assumes the existing git note JSON is an object shaped like {memories?: ...}. However, MemoryRepository explicitly supports legacy note formats where the note is a bare array (memories[]) or a single memory object. In those cases this handler will either (a) fail to persist the new session field because JSON.stringify on an array drops custom properties, or (b) corrupt the note by turning the single memory object into a payload that MemoryRepository later treats as a memory (including the session field). Consider normalizing parsed notes the same way as MemoryRepository.readPayload (wrap arrays/single objects into a { memories: [...] } payload) before adding/updating session, so session metadata is reliably written without breaking existing memory notes.

Copilot uses AI. Check for mistakes.
}
}

/**
* 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);
}
}
10 changes: 10 additions & 0 deletions src/application/interfaces/IPostCommitHandler.ts
Original file line number Diff line number Diff line change
@@ -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<IGitCommitEvent>;
8 changes: 7 additions & 1 deletion src/commands/hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,21 +24,24 @@ export interface IHookInput {
prompt?: string;
cwd?: string;
hook_event_name?: string;
sha?: string;
}

/** Map CLI event names to internal event bus types. */
export const EVENT_MAP: Record<string, HookEventType> = {
'session-start': 'session:start',
'session-stop': 'session:stop',
'prompt-submit': 'prompt:submit',
'post-commit': 'git:commit',
};
Comment on lines 30 to 36
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

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

The top-of-file usage comment for the hook command still lists only session-start/session-stop/prompt-submit, but this PR adds the 'post-commit' event to EVENT_MAP. Consider updating the usage/examples in the header comment to include git-mem hook post-commit so docs stay in sync with supported events.

Copilot uses AI. Check for mistakes.

/** 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<string, ConfigKey> = {
'session-start': 'sessionStart',
'session-stop': 'sessionStop',
'prompt-submit': 'promptSubmit',
'post-commit': 'postCommit',
};

/** Extra enabled check for session-stop (must also have autoExtract). */
Expand Down Expand Up @@ -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}`);
Expand All @@ -85,6 +90,7 @@ const STDERR_LABELS: Record<HookEventType, { success: string; prefix: string }>
'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<void> {
Expand Down
3 changes: 3 additions & 0 deletions src/commands/init-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,9 @@ export function buildGitMemConfig(): Record<string, unknown> {
recordPrompts: false,
surfaceContext: true,
},
postCommit: {
enabled: true,
},
},
};
}
Expand Down
30 changes: 22 additions & 8 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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) ────────────────────────
Expand Down
11 changes: 10 additions & 1 deletion src/domain/events/HookEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
5 changes: 5 additions & 0 deletions src/domain/interfaces/IHookConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading