diff --git a/src/index.test.ts b/src/index.test.ts index 5f1ee44..1146c0c 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -418,6 +418,245 @@ describe('GitMemory', () => { expect(addCall).toContain('New message') }) + // --------------------------------------------------------------------------- + // Amend tests + // --------------------------------------------------------------------------- + + test('amend carries forward note from old commit', async () => { + const OLD_HASH = 'old111' + const NEW_HASH = 'new222' + let revParseCount = 0 + const amendShell = (strings: TemplateStringsArray, ...values: unknown[]) => { + let cmd = '' + strings.forEach((s, i) => { + cmd += s + if (i < values.length) cmd += String(values[i]) + }) + if (cmd.includes('git log')) { + return { text: () => Promise.resolve('1710600000\n') } + } + if (cmd.includes('rev-parse')) { + revParseCount++ + // First call (before) returns old hash, second (after) returns new + return { text: () => Promise.resolve(revParseCount === 1 ? `${OLD_HASH}\n` : `${NEW_HASH}\n`) } + } + if (cmd.includes('show') && cmd.includes(NEW_HASH)) { + return { text: () => Promise.reject(new Error('no note')) } + } + if (cmd.includes('show') && cmd.includes(OLD_HASH)) { + return { text: () => Promise.resolve('Old commit note\n') } + } + if (cmd.includes('add')) { + return { text: () => Promise.resolve('') } + } + return { text: () => Promise.reject(new Error(`unexpected: ${cmd.trim()}`)) } + } + + const messages: MessageWithParts[] = [ + { + info: { role: 'user', time: { created: 1710600001000 } }, + parts: [{ type: 'text', text: 'Amend message' }], + }, + ] + const mockClient = createMockClient(messages) + const hooks = await GitMemory({ + client: mockClient as any, + $: amendShell as any, + project: {} as any, + directory: '/test', + worktree: '/test', + serverUrl: new URL('http://localhost:4096'), + }) + + // Before: capture pre-commit HEAD + await hooks['tool.execute.before']!( + { tool: 'bash', callID: 'c1' } as any, + { args: { command: 'git commit --amend -m "amended"' } } as any, + ) + // After: should carry forward old note + await hooks['tool.execute.after']!( + { tool: 'bash', sessionID: 'sess-1', callID: 'c1', args: { command: 'git commit --amend -m "amended"' } }, + { title: '', output: '[main new222] amended', metadata: {} }, + ) + + expect(mockClient.session.messages).toHaveBeenCalledTimes(1) + }) + + test('amend with no old note works like normal commit', async () => { + const OLD_HASH = 'old111' + const NEW_HASH = 'new222' + let revParseCount = 0 + const calls: string[] = [] + const amendShell = (strings: TemplateStringsArray, ...values: unknown[]) => { + let cmd = '' + strings.forEach((s, i) => { + cmd += s + if (i < values.length) cmd += String(values[i]) + }) + calls.push(cmd.trim()) + if (cmd.includes('git log')) { + return { text: () => Promise.resolve('1710600000\n') } + } + if (cmd.includes('rev-parse')) { + revParseCount++ + return { text: () => Promise.resolve(revParseCount === 1 ? `${OLD_HASH}\n` : `${NEW_HASH}\n`) } + } + if (cmd.includes('show')) { + return { text: () => Promise.reject(new Error('no note')) } + } + if (cmd.includes('add')) { + return { text: () => Promise.resolve('') } + } + return { text: () => Promise.reject(new Error(`unexpected: ${cmd.trim()}`)) } + } + + const messages: MessageWithParts[] = [ + { + info: { role: 'user', time: { created: 1710600001000 } }, + parts: [{ type: 'text', text: 'Amend msg' }], + }, + ] + const mockClient = createMockClient(messages) + const hooks = await GitMemory({ + client: mockClient as any, + $: amendShell as any, + project: {} as any, + directory: '/test', + worktree: '/test', + serverUrl: new URL('http://localhost:4096'), + }) + + await hooks['tool.execute.before']!( + { tool: 'bash', callID: 'c1' } as any, + { args: { command: 'git commit --amend -m "amended"' } } as any, + ) + await hooks['tool.execute.after']!( + { tool: 'bash', sessionID: 'sess-1', callID: 'c1', args: { command: 'git commit --amend -m "amended"' } }, + { title: '', output: '[main new222] amended', metadata: {} }, + ) + + const addCall = calls.find(c => c.includes('git notes') && c.includes('add')) + expect(addCall).toBeDefined() + // Should only contain transcript, no old note separator + expect(addCall).toContain('Amend msg') + }) + + test('amend with both old note and existing note on new hash', async () => { + const OLD_HASH = 'old111' + const NEW_HASH = 'new222' + let revParseCount = 0 + const calls: string[] = [] + const amendShell = (strings: TemplateStringsArray, ...values: unknown[]) => { + let cmd = '' + strings.forEach((s, i) => { + cmd += s + if (i < values.length) cmd += String(values[i]) + }) + calls.push(cmd.trim()) + if (cmd.includes('git log')) { + return { text: () => Promise.resolve('1710600000\n') } + } + if (cmd.includes('rev-parse')) { + revParseCount++ + return { text: () => Promise.resolve(revParseCount === 1 ? `${OLD_HASH}\n` : `${NEW_HASH}\n`) } + } + if (cmd.includes('show') && cmd.includes(NEW_HASH)) { + return { text: () => Promise.resolve('Existing new note\n') } + } + if (cmd.includes('show') && cmd.includes(OLD_HASH)) { + return { text: () => Promise.resolve('Old commit note\n') } + } + if (cmd.includes('add')) { + return { text: () => Promise.resolve('') } + } + return { text: () => Promise.reject(new Error(`unexpected: ${cmd.trim()}`)) } + } + + const messages: MessageWithParts[] = [ + { + info: { role: 'user', time: { created: 1710600001000 } }, + parts: [{ type: 'text', text: 'Triple note' }], + }, + ] + const mockClient = createMockClient(messages) + const hooks = await GitMemory({ + client: mockClient as any, + $: amendShell as any, + project: {} as any, + directory: '/test', + worktree: '/test', + serverUrl: new URL('http://localhost:4096'), + }) + + await hooks['tool.execute.before']!( + { tool: 'bash', callID: 'c1' } as any, + { args: { command: 'git commit --amend -m "amended"' } } as any, + ) + await hooks['tool.execute.after']!( + { tool: 'bash', sessionID: 'sess-1', callID: 'c1', args: { command: 'git commit --amend -m "amended"' } }, + { title: '', output: '[main new222] amended', metadata: {} }, + ) + + const addCall = calls.find(c => c.includes('git notes') && c.includes('add')) + expect(addCall).toBeDefined() + // Should contain all three: old note, existing note, transcript + expect(addCall).toContain('Old commit note') + expect(addCall).toContain('Existing new note') + expect(addCall).toContain('Triple note') + }) + + test('non-amend commit does not read old hash note', async () => { + const { shell, calls } = createMockShell({ + 'git log': '1710600000\n', + 'git rev-parse HEAD': 'abc123\n', + 'git notes --ref=refs/notes/opencode add': '', + }) + // Make show throw (no existing note) + const patchedShell = (strings: TemplateStringsArray, ...values: unknown[]) => { + let cmd = '' + strings.forEach((s, i) => { + cmd += s + if (i < values.length) cmd += String(values[i]) + }) + if (cmd.includes('show')) { + return { text: () => Promise.reject(new Error('no note')) } + } + return shell(strings, ...values) + } + + const messages: MessageWithParts[] = [ + { + info: { role: 'user', time: { created: 1710600001000 } }, + parts: [{ type: 'text', text: 'Normal commit' }], + }, + ] + const mockClient = createMockClient(messages) + const hooks = await GitMemory({ + client: mockClient as any, + $: patchedShell as any, + project: {} as any, + directory: '/test', + worktree: '/test', + serverUrl: new URL('http://localhost:4096'), + }) + + await hooks['tool.execute.before']!( + { tool: 'bash', callID: 'c1' } as any, + { args: { command: 'git commit -m "normal"' } } as any, + ) + await hooks['tool.execute.after']!( + { tool: 'bash', sessionID: 'sess-1', callID: 'c1', args: { command: 'git commit -m "normal"' } }, + { title: '', output: '[main abc123] normal', metadata: {} }, + ) + + // For non-amend, show should only be called once (for the new hash), not for an old hash + const showCalls = calls.filter(c => c.includes('show')) + expect(showCalls.length).toBe(0) // show throws so not captured, but git notes add should work + const addCall = calls.find(c => c.includes('git notes') && c.includes('add')) + expect(addCall).toBeDefined() + expect(addCall).toContain('Normal commit') + }) + test('handles repo with no commits at init', async () => { const failInitShell = (strings: TemplateStringsArray, ...values: unknown[]) => { let cmd = '' diff --git a/src/index.ts b/src/index.ts index 5d95bcd..79fff9b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ import type { CommitInfo } from './notes-reader.ts' const NOTES_REF = 'refs/notes/opencode' const GIT_COMMIT_RE = /\bgit\s+commit\b/ +const GIT_AMEND_RE = /--amend\b/ // eslint-disable-next-line @typescript-eslint/no-explicit-any function log(client: any, level: string, message: string) { @@ -36,6 +37,9 @@ export const GitMemory: Plugin = async ({ client, $ }) => { // (tool.execute.after input may not include args in older SDK versions) let lastBashCommand = '' + // Capture HEAD before a commit so we can detect amends and carry forward old notes + let lastPreCommitHash = '' + // --- Read path: cached commit index for system prompt --- let cachedCommits: CommitInfo[] | null = null let dirty = true @@ -133,6 +137,14 @@ export const GitMemory: Plugin = async ({ client, $ }) => { 'tool.execute.before': async (input, output) => { if (input.tool === 'bash') { lastBashCommand = String(output.args?.command ?? '') + // Capture HEAD before commit so we can detect amends + if (GIT_COMMIT_RE.test(lastBashCommand)) { + try { + lastPreCommitHash = (await $`git rev-parse HEAD`.text()).trim() + } catch { + lastPreCommitHash = '' + } + } } }, @@ -184,7 +196,7 @@ export const GitMemory: Plugin = async ({ client, $ }) => { const transcript = renderTranscript(newMessages, sessionID) - // Read existing note (if any) + // Read existing note on new hash (if any — e.g. multiple commits in one session) let existingNote = '' try { existingNote = ( @@ -194,9 +206,25 @@ export const GitMemory: Plugin = async ({ client, $ }) => { // No existing note } - const fullNote = existingNote - ? existingNote + '\n\n---\n\n' + transcript - : transcript + // If this was an amend, carry forward the note from the old (replaced) commit + let oldNote = '' + if ( + GIT_AMEND_RE.test(cmd) && + lastPreCommitHash && + lastPreCommitHash !== newHash + ) { + try { + oldNote = ( + await $`git notes --ref=${NOTES_REF} show ${lastPreCommitHash}`.text() + ).trim() + } catch { + // No note on old commit + } + } + + const fullNote = [oldNote, existingNote, transcript] + .filter(Boolean) + .join('\n\n---\n\n') await $`git notes --ref=${NOTES_REF} add -f -m ${fullNote} ${newHash}`