diff --git a/src/domain/interfaces/ITrailerService.ts b/src/domain/interfaces/ITrailerService.ts index 5faef0f3..a1f63b2d 100644 --- a/src/domain/interfaces/ITrailerService.ts +++ b/src/domain/interfaces/ITrailerService.ts @@ -58,4 +58,21 @@ export interface ITrailerService { * @returns Array of commits with matching trailers. */ queryTrailers(key: string, options?: ITrailerQueryOptions): ICommitTrailers[]; + + /** + * Amend HEAD commit to append AI-* trailers to the commit message. + * Preserves existing trailers and skips duplicates. + * @param trailers - Trailers to add. + * @param cwd - Working directory. + */ + addTrailers(trailers: readonly ITrailer[], cwd?: string): void; + + /** + * Build a commit message with trailers appended. + * Pure string operation — no git commands. + * @param message - Original commit message. + * @param trailers - Trailers to append. + * @returns Complete commit message with trailer block. + */ + buildCommitMessage(message: string, trailers: readonly ITrailer[]): string; } diff --git a/src/infrastructure/di/container.ts b/src/infrastructure/di/container.ts index ca97c947..fd1e6a42 100644 --- a/src/infrastructure/di/container.ts +++ b/src/infrastructure/di/container.ts @@ -21,6 +21,7 @@ import type { IGitClient } from '../../domain/interfaces/IGitClient'; import { NotesService } from '../services/NotesService'; import { GitClient } from '../git/GitClient'; import { MemoryRepository } from '../repositories/MemoryRepository'; +import { TrailerService } from '../services/TrailerService'; import { EventBus } from '../events/EventBus'; import { createLogger } from '../logging/factory'; import { createLLMClient } from '../llm/LLMClientFactory'; @@ -55,6 +56,7 @@ export function createContainer(options?: IContainerOptions): AwilixContainer { const bus = new EventBus(container.cradle.logger); diff --git a/src/infrastructure/di/types.ts b/src/infrastructure/di/types.ts index ea13a895..fd21c23b 100644 --- a/src/infrastructure/di/types.ts +++ b/src/infrastructure/di/types.ts @@ -23,6 +23,7 @@ import type { ILiberateService } from '../../application/interfaces/ILiberateSer import type { IMemoryContextLoader } from '../../domain/interfaces/IMemoryContextLoader'; import type { IContextFormatter } from '../../domain/interfaces/IContextFormatter'; import type { ISessionCaptureService } from '../../domain/interfaces/ISessionCaptureService'; +import type { ITrailerService } from '../../domain/interfaces/ITrailerService'; export interface ICradle { // Infrastructure @@ -30,6 +31,7 @@ export interface ICradle { notesService: INotesService; gitClient: IGitClient; memoryRepository: IMemoryRepository; + trailerService: ITrailerService; triageService: IGitTriageService; llmClient: ILLMClient | null; eventBus: IEventBus; diff --git a/src/infrastructure/services/TrailerService.ts b/src/infrastructure/services/TrailerService.ts index 402551f4..70a880a1 100644 --- a/src/infrastructure/services/TrailerService.ts +++ b/src/infrastructure/services/TrailerService.ts @@ -92,6 +92,61 @@ export class TrailerService implements ITrailerService { } } + addTrailers(trailers: readonly ITrailer[], cwd?: string): void { + // Only operate on AI-* trailers to match readTrailers/parseTrailerBlock behavior. + const aiTrailers = trailers.filter(t => t.key.startsWith(AI_TRAILER_PREFIX)); + if (aiTrailers.length === 0) return; + + // Read existing trailers to avoid duplicates + const existing = this.readTrailers('HEAD', cwd); + const existingKeys = new Set(existing.map(t => `${t.key}:${t.value}`)); + const newTrailers = aiTrailers.filter(t => !existingKeys.has(`${t.key}:${t.value}`)); + if (newTrailers.length === 0) return; + + // Get current commit message + const currentMessage = execFileSync( + 'git', + ['log', '-1', '--format=%B', 'HEAD'], + { encoding: 'utf8', cwd, stdio: ['pipe', 'pipe', 'pipe'] } + ).trimEnd(); + + // Build amended message with new trailers + const amended = this.buildCommitMessage(currentMessage, newTrailers); + + // Amend HEAD with the new message + execFileSync( + 'git', + ['commit', '--amend', '--no-edit', '-m', amended], + { encoding: 'utf8', cwd, stdio: ['pipe', 'pipe', 'pipe'] } + ); + } + + buildCommitMessage(message: string, trailers: readonly ITrailer[]): string { + if (trailers.length === 0) return message; + + const trailerBlock = this.formatTrailers(trailers); + const trimmed = message.trimEnd(); + + // Detect existing trailer block: lines after the last blank line + // must all match "Key: Value" format (git trailer convention). + if (this.hasTrailerBlock(trimmed)) { + return `${trimmed}\n${trailerBlock}\n`; + } + + return `${trimmed}\n\n${trailerBlock}\n`; + } + + private hasTrailerBlock(message: string): boolean { + const lastBlankIdx = message.lastIndexOf('\n\n'); + if (lastBlankIdx === -1) return false; + + const afterBlank = message.slice(lastBlankIdx + 2).trim(); + if (!afterBlank) return false; + + const lines = afterBlank.split('\n').filter(l => l.trim().length > 0); + return lines.length > 0 && lines.every(l => /^[\w-]+:\s/.test(l)); + } + private parseTrailerBlock(block: string): ITrailer[] { const trailers: ITrailer[] = []; const lines = block.split('\n'); diff --git a/tests/unit/infrastructure/di/container.test.ts b/tests/unit/infrastructure/di/container.test.ts index 65b10a60..4527fad7 100644 --- a/tests/unit/infrastructure/di/container.test.ts +++ b/tests/unit/infrastructure/di/container.test.ts @@ -41,6 +41,7 @@ describe('createContainer', () => { assert.ok(cradle.notesService); assert.ok(cradle.gitClient); assert.ok(cradle.memoryRepository); + assert.ok(cradle.trailerService); assert.ok(cradle.eventBus); assert.ok(cradle.triageService); assert.ok(cradle.memoryService); @@ -162,6 +163,24 @@ describe('createContainer', () => { }); }); + describe('trailerService', () => { + it('should resolve with expected interface', () => { + const container = createContainer(); + const { trailerService } = container.cradle; + + assert.equal(typeof trailerService.readTrailers, 'function'); + assert.equal(typeof trailerService.formatTrailers, 'function'); + assert.equal(typeof trailerService.queryTrailers, 'function'); + assert.equal(typeof trailerService.addTrailers, 'function'); + assert.equal(typeof trailerService.buildCommitMessage, 'function'); + }); + + it('should return singleton within container scope', () => { + const container = createContainer(); + assert.equal(container.cradle.trailerService, container.cradle.trailerService); + }); + }); + describe('hook services', () => { it('should resolve hook services with expected interfaces', () => { const container = createContainer(); diff --git a/tests/unit/infrastructure/services/TrailerService.test.ts b/tests/unit/infrastructure/services/TrailerService.test.ts index 704b9a72..ce25578e 100644 --- a/tests/unit/infrastructure/services/TrailerService.test.ts +++ b/tests/unit/infrastructure/services/TrailerService.test.ts @@ -127,4 +127,150 @@ describe('TrailerService', () => { assert.equal(results.length, 0); }); }); + + describe('buildCommitMessage', () => { + it('should append trailers with blank line separator', () => { + const result = service.buildCommitMessage('feat: add auth', [ + { key: 'AI-Decision', value: 'Use JWT' }, + ]); + assert.equal(result, 'feat: add auth\n\nAI-Decision: Use JWT\n'); + }); + + it('should append multiple trailers', () => { + const result = service.buildCommitMessage('feat: add auth', [ + { key: 'AI-Decision', value: 'Use JWT' }, + { key: 'AI-Confidence', value: 'high' }, + ]); + assert.equal(result, 'feat: add auth\n\nAI-Decision: Use JWT\nAI-Confidence: high\n'); + }); + + it('should append after existing trailers without extra blank line', () => { + const msg = 'feat: add auth\n\nCo-Authored-By: Someone '; + const result = service.buildCommitMessage(msg, [ + { key: 'AI-Decision', value: 'Use JWT' }, + ]); + assert.equal( + result, + 'feat: add auth\n\nCo-Authored-By: Someone \nAI-Decision: Use JWT\n' + ); + }); + + it('should handle message with body and no existing trailers', () => { + const msg = 'feat: add auth\n\nAdded JWT-based auth flow.'; + const result = service.buildCommitMessage(msg, [ + { key: 'AI-Decision', value: 'Use JWT' }, + ]); + assert.equal( + result, + 'feat: add auth\n\nAdded JWT-based auth flow.\n\nAI-Decision: Use JWT\n' + ); + }); + + it('should return original message when trailers array is empty', () => { + assert.equal(service.buildCommitMessage('feat: add auth', []), 'feat: add auth'); + }); + + it('should handle trailing whitespace in message', () => { + const result = service.buildCommitMessage('feat: add auth \n\n', [ + { key: 'AI-Fact', value: 'test' }, + ]); + assert.equal(result, 'feat: add auth\n\nAI-Fact: test\n'); + }); + }); + + describe('addTrailers', () => { + let writeRepoDir: string; + + before(() => { + writeRepoDir = mkdtempSync(join(tmpdir(), 'git-mem-trailer-write-')); + git(['init'], writeRepoDir); + git(['config', 'user.email', 'test@test.com'], writeRepoDir); + git(['config', 'user.name', 'Test User'], writeRepoDir); + }); + + after(() => { + rmSync(writeRepoDir, { recursive: true, force: true }); + }); + + it('should amend HEAD with new trailers', () => { + writeFileSync(join(writeRepoDir, 'a.txt'), 'content'); + git(['add', '.'], writeRepoDir); + git(['commit', '-m', 'feat: initial'], writeRepoDir); + + service.addTrailers( + [{ key: 'AI-Decision', value: 'Use Redis' }], + writeRepoDir + ); + + const trailers = service.readTrailers('HEAD', writeRepoDir); + const decision = trailers.find(t => t.key === 'AI-Decision'); + assert.ok(decision); + assert.equal(decision.value, 'Use Redis'); + }); + + it('should preserve existing trailers', () => { + writeFileSync(join(writeRepoDir, 'b.txt'), 'content'); + git(['add', '.'], writeRepoDir); + const msg = 'feat: with trailer\n\nAI-Gotcha: Watch out for nulls'; + git(['commit', '-m', msg], writeRepoDir); + + service.addTrailers( + [{ key: 'AI-Confidence', value: 'high' }], + writeRepoDir + ); + + const trailers = service.readTrailers('HEAD', writeRepoDir); + assert.ok(trailers.find(t => t.key === 'AI-Gotcha')); + assert.ok(trailers.find(t => t.key === 'AI-Confidence')); + }); + + it('should not duplicate existing trailers', () => { + writeFileSync(join(writeRepoDir, 'c.txt'), 'content'); + git(['add', '.'], writeRepoDir); + const msg = 'feat: dup test\n\nAI-Decision: Use Redis'; + git(['commit', '-m', msg], writeRepoDir); + + service.addTrailers( + [{ key: 'AI-Decision', value: 'Use Redis' }], + writeRepoDir + ); + + const trailers = service.readTrailers('HEAD', writeRepoDir); + const decisions = trailers.filter(t => t.key === 'AI-Decision'); + assert.equal(decisions.length, 1); + }); + + it('should be a no-op when trailers array is empty', () => { + writeFileSync(join(writeRepoDir, 'd.txt'), 'content'); + git(['add', '.'], writeRepoDir); + git(['commit', '-m', 'feat: empty test'], writeRepoDir); + const shaBefore = git(['rev-parse', 'HEAD'], writeRepoDir); + + service.addTrailers([], writeRepoDir); + + const shaAfter = git(['rev-parse', 'HEAD'], writeRepoDir); + assert.equal(shaBefore, shaAfter); + }); + + it('should add multiple trailers at once', () => { + writeFileSync(join(writeRepoDir, 'e.txt'), 'content'); + git(['add', '.'], writeRepoDir); + git(['commit', '-m', 'feat: multi trailer'], writeRepoDir); + + service.addTrailers( + [ + { key: 'AI-Decision', value: 'Use PostgreSQL' }, + { key: 'AI-Confidence', value: 'high' }, + { key: 'AI-Tags', value: 'db, infrastructure' }, + ], + writeRepoDir + ); + + const trailers = service.readTrailers('HEAD', writeRepoDir); + assert.equal(trailers.length, 3); + assert.ok(trailers.find(t => t.key === 'AI-Decision')); + assert.ok(trailers.find(t => t.key === 'AI-Confidence')); + assert.ok(trailers.find(t => t.key === 'AI-Tags')); + }); + }); });