Skip to content
Open
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
239 changes: 239 additions & 0 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ''
Expand Down
36 changes: 32 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -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 = ''
}
}
}
},

Expand Down Expand Up @@ -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 = (
Expand All @@ -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}`

Expand Down
Loading