Add git_commit_message, written from the staged diff
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
co-authored by
Sisyphus
parent
6b67465723
commit
e8b8b06a4b
@@ -0,0 +1,74 @@
|
|||||||
|
import { tool, generateText, type LanguageModel } from 'ai';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { git } from './tools-git';
|
||||||
|
|
||||||
|
/** Recent subjects shown to the model, so the message matches the repository's style. */
|
||||||
|
const SUBJECTS = 15;
|
||||||
|
/** The staged diff is the bulk of the call; beyond this it is cut with a note. */
|
||||||
|
const MAX_DIFF = 24_000;
|
||||||
|
|
||||||
|
export const COMMIT_TOOL_NAME = 'git_commit_message';
|
||||||
|
|
||||||
|
/** A reply's wrapping — fenced blocks, surrounding prose, leading/trailing quotes. */
|
||||||
|
const unwrap = (reply: string): string => {
|
||||||
|
const fenced = /```[a-z]*\n([\s\S]*?)```/i.exec(reply);
|
||||||
|
const body = fenced ? fenced[1]! : reply;
|
||||||
|
const line = body.split('\n').find((l) => l.trim().length > 0) ?? '';
|
||||||
|
return line.trim().replace(/^["'`]|["'`]$/g, '').slice(0, 72);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a commit message from the staged changes with one nested model call.
|
||||||
|
*
|
||||||
|
* The model sees two things: the staged diff, and the repository's own recent
|
||||||
|
* subjects, because a message that ignores the established style reads as foreign
|
||||||
|
* no matter how accurate it is. The subject style of this repository — plain
|
||||||
|
* imperative, no conventional-commit prefix — is one example; the sample keeps
|
||||||
|
* the choice local to whatever the history actually says.
|
||||||
|
*
|
||||||
|
* It never commits. Generating the message is safe to auto-approve; running the
|
||||||
|
* commit is not, and that stays on the gated `bash` path where the user sees the
|
||||||
|
* message and the command together.
|
||||||
|
*/
|
||||||
|
export function createCommitMessageTool(opts: { model: LanguageModel; cwd?: string }) {
|
||||||
|
return tool({
|
||||||
|
description:
|
||||||
|
'Generate a commit message from the staged changes, in one nested model call. Reads the ' +
|
||||||
|
'staged diff and the recent commit subjects so the message matches the repository\'s style. ' +
|
||||||
|
'It does not commit — it returns the message only. Use git_diff first to see what is staged, ' +
|
||||||
|
'and run the commit through bash where the user approves it.',
|
||||||
|
inputSchema: z.object({}),
|
||||||
|
execute: async () => {
|
||||||
|
const cwd = opts.cwd ?? process.cwd();
|
||||||
|
|
||||||
|
const staged = await git(['diff', '--staged', '--no-color'], cwd);
|
||||||
|
if (!staged.ok) throw new Error(staged.message);
|
||||||
|
const diff = staged.stdout.trim();
|
||||||
|
if (diff.length === 0) return 'Nothing is staged. Stage the change first, then ask again.';
|
||||||
|
|
||||||
|
const subjects = await git(
|
||||||
|
['log', `-n${SUBJECTS}`, '--pretty=format:%s'],
|
||||||
|
cwd,
|
||||||
|
);
|
||||||
|
const history = subjects.ok && subjects.stdout.trim().length > 0 ? subjects.stdout : '(no commits yet)';
|
||||||
|
|
||||||
|
const shown =
|
||||||
|
diff.length > MAX_DIFF ? `${diff.slice(0, MAX_DIFF)}\n... [truncated ${diff.length - MAX_DIFF} chars]` : diff;
|
||||||
|
|
||||||
|
const { text } = await generateText({
|
||||||
|
model: opts.model,
|
||||||
|
system:
|
||||||
|
'You write one commit message for the staged diff below. Match the subject style of the ' +
|
||||||
|
'recent commits listed after it: same language, same capitalisation, same prefix convention ' +
|
||||||
|
'or lack of one. One line, no body, no quotes, no backticks, no prefix like "commit:". ' +
|
||||||
|
'Describe what the change does, not what files it touches.',
|
||||||
|
prompt: `Staged diff:\n\n${shown}\n\nRecent commit subjects:\n${history}`,
|
||||||
|
maxRetries: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = unwrap(text);
|
||||||
|
if (message.length === 0) throw new Error('the model returned no message; ask again or write one yourself');
|
||||||
|
return message;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { afterEach, beforeEach, expect, test } from 'bun:test';
|
||||||
|
import { MockLanguageModelV4, simulateReadableStream } from 'ai/test';
|
||||||
|
import type { LanguageModelV4CallOptions, LanguageModelV4StreamPart } from '@ai-sdk/provider';
|
||||||
|
import { mkdtempSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { variantByName } from '../src/agents';
|
||||||
|
import { createCommitMessageTool, COMMIT_TOOL_NAME } from '../src/commit';
|
||||||
|
import { Permissions } from '../src/permission';
|
||||||
|
import { TOOL_DOCS, systemPrompt } from '../src/prompt';
|
||||||
|
import { Session } from '../src/session';
|
||||||
|
import { disabledToolNames, toolSetOf } from '../src/tools';
|
||||||
|
import { GIT_TOOL_NAMES } from '../src/tools-git';
|
||||||
|
|
||||||
|
const usage = {
|
||||||
|
inputTokens: { total: 10, noCache: 10, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
outputTokens: { total: 5 },
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const stream = (parts: LanguageModelV4StreamPart[]) => ({
|
||||||
|
stream: simulateReadableStream({ chunks: parts, chunkDelayInMs: null, initialDelayInMs: null }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = (body: string): LanguageModelV4StreamPart[] => [
|
||||||
|
{ type: 'text-start', id: '0' },
|
||||||
|
{ type: 'text-delta', id: '0', delta: body },
|
||||||
|
{ type: 'text-end', id: '0' },
|
||||||
|
{ type: 'finish', finishReason: { unified: 'stop', raw: 'stop' }, usage },
|
||||||
|
];
|
||||||
|
|
||||||
|
let dir: string;
|
||||||
|
let origCwd: string;
|
||||||
|
|
||||||
|
const run = <T>(t: { execute?: (input: T, opts: any) => unknown }, input: T) =>
|
||||||
|
Promise.resolve(t.execute!(input, { toolCallId: 't1', messages: [] })) as Promise<string>;
|
||||||
|
|
||||||
|
async function git(...args: string[]): Promise<void> {
|
||||||
|
const proc = Bun.spawn(['git', ...args], { cwd: dir, stdout: 'pipe', stderr: 'pipe' });
|
||||||
|
const code = await proc.exited;
|
||||||
|
if (code !== 0) throw new Error(`git ${args.join(' ')} failed: ${await new Response(proc.stderr).text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
origCwd = process.cwd();
|
||||||
|
dir = mkdtempSync(join(tmpdir(), 'shiro-commit-'));
|
||||||
|
process.chdir(dir);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.chdir(origCwd);
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
async function repoWithHistory(): Promise<void> {
|
||||||
|
await git('init', '-b', 'main');
|
||||||
|
await git('config', 'user.email', 'test@example.com');
|
||||||
|
await git('config', 'user.name', 'Test');
|
||||||
|
await Bun.write(join(dir, 'app.ts'), 'export const port = 8080;\n');
|
||||||
|
await git('add', '.');
|
||||||
|
await git('commit', '-m', 'add the server port');
|
||||||
|
await Bun.write(join(dir, 'log.ts'), 'export function log() {}\n');
|
||||||
|
await git('add', '.');
|
||||||
|
await git('commit', '-m', 'add the log helper');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A model that records every call, so assertions can be made on the wire. */
|
||||||
|
function recordingModel(
|
||||||
|
reply: string,
|
||||||
|
): { model: MockLanguageModelV4; seen: LanguageModelV4CallOptions[] } {
|
||||||
|
const seen: LanguageModelV4CallOptions[] = [];
|
||||||
|
const model = new MockLanguageModelV4({
|
||||||
|
doStream: async (o) => {
|
||||||
|
seen.push(o);
|
||||||
|
return stream(text(reply));
|
||||||
|
},
|
||||||
|
doGenerate: async (o) => {
|
||||||
|
seen.push(o);
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: reply }],
|
||||||
|
finishReason: { unified: 'stop', raw: 'stop' },
|
||||||
|
usage,
|
||||||
|
warnings: [],
|
||||||
|
} as any;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { model, seen };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the tool is named and registered under the git set', () => {
|
||||||
|
expect(COMMIT_TOOL_NAME).toBe('git_commit_message');
|
||||||
|
expect(GIT_TOOL_NAMES).toContain('git_commit_message');
|
||||||
|
expect(toolSetOf('git_commit_message')).toBe('git');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nothing staged is a stated error, not a model call', async () => {
|
||||||
|
await repoWithHistory();
|
||||||
|
const { model, seen } = recordingModel('should not be called');
|
||||||
|
|
||||||
|
const message = await run(createCommitMessageTool({ model }), {});
|
||||||
|
expect(message).toContain('Nothing is staged');
|
||||||
|
expect(seen).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a staged change generates a message from the diff and the recent subjects', async () => {
|
||||||
|
await repoWithHistory();
|
||||||
|
await Bun.write(join(dir, 'app.ts'), 'export const port = 9090;\n');
|
||||||
|
await git('add', 'app.ts');
|
||||||
|
const { model, seen } = recordingModel('bump the server port to 9090');
|
||||||
|
|
||||||
|
const message = await run(createCommitMessageTool({ model }), {});
|
||||||
|
|
||||||
|
expect(message).toContain('bump the server port to 9090');
|
||||||
|
// The nested call carries both halves of the evidence: the staged diff and the
|
||||||
|
// repository's own subject style.
|
||||||
|
const prompt = JSON.stringify(seen[0]?.prompt);
|
||||||
|
expect(seen).toHaveLength(1);
|
||||||
|
expect(prompt).toContain('+export const port = 9090;');
|
||||||
|
expect(prompt).toContain('add the server port');
|
||||||
|
expect(prompt).toContain('add the log helper');
|
||||||
|
const system = JSON.stringify(seen[0]?.prompt.find((m) => m.role === 'system'));
|
||||||
|
expect(system).toContain('commit message');
|
||||||
|
expect(system).toContain('subject style');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the tool never commits: the repository is untouched after a generation', async () => {
|
||||||
|
await repoWithHistory();
|
||||||
|
await Bun.write(join(dir, 'app.ts'), 'export const port = 9090;\n');
|
||||||
|
await git('add', 'app.ts');
|
||||||
|
const { model } = recordingModel('bump the server port');
|
||||||
|
|
||||||
|
await run(createCommitMessageTool({ model }), {});
|
||||||
|
|
||||||
|
const head = await new Response(
|
||||||
|
Bun.spawn(['git', 'log', '-1', '--pretty=format:%s'], { cwd: dir, stdout: 'pipe', stderr: 'pipe' }).stdout,
|
||||||
|
).text();
|
||||||
|
expect(head).toBe('add the log helper');
|
||||||
|
const status = await new Response(
|
||||||
|
Bun.spawn(['git', 'status', '--porcelain'], { cwd: dir, stdout: 'pipe', stderr: 'pipe' }).stdout,
|
||||||
|
).text();
|
||||||
|
expect(status).toContain('app.ts');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an oversized diff is truncated before it reaches the model', async () => {
|
||||||
|
await repoWithHistory();
|
||||||
|
await Bun.write(join(dir, 'big.ts'), 'x'.repeat(200_000));
|
||||||
|
await git('add', 'big.ts');
|
||||||
|
const { model, seen } = recordingModel('add big.ts');
|
||||||
|
|
||||||
|
await run(createCommitMessageTool({ model }), {});
|
||||||
|
|
||||||
|
const prompt = JSON.stringify(seen[0]?.prompt);
|
||||||
|
expect(prompt.length).toBeLessThan(200_000);
|
||||||
|
expect(prompt).toContain('truncated');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the model is told the convention and returns only the subject', async () => {
|
||||||
|
await repoWithHistory();
|
||||||
|
await Bun.write(join(dir, 'app.ts'), 'export const port = 9090;\n');
|
||||||
|
await git('add', 'app.ts');
|
||||||
|
const { model, seen } = recordingModel('Add the server port');
|
||||||
|
|
||||||
|
const message = await run(createCommitMessageTool({ model }), {});
|
||||||
|
|
||||||
|
// The reply is normalised: stripped of a model's wrapping prose and fenced
|
||||||
|
// blocks, so what reaches the caller can be used as -m verbatim.
|
||||||
|
expect(message).toBe('Add the server port');
|
||||||
|
const system = String(seen[0]?.prompt.find((m) => m.role === 'system')?.content ?? '');
|
||||||
|
expect(system).toContain('One line');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wiring: the tool is free, read-only, documented, and offered by the session', async () => {
|
||||||
|
// Permission: no rule and no prompt, like the other git tools.
|
||||||
|
const bare = new Permissions();
|
||||||
|
expect(bare.check('git_commit_message', {}).decision).toBe('allow');
|
||||||
|
|
||||||
|
// Variants: plan and review keep it.
|
||||||
|
for (const name of ['plan', 'review']) {
|
||||||
|
expect(variantByName(name)!.allowTools).toContain('git_commit_message');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prompt: guidance exists and only the offered tools are described.
|
||||||
|
expect(TOOL_DOCS.map((d) => d.name)).toContain('git_commit_message');
|
||||||
|
const prompt = systemPrompt({ cwd: dir, availableTools: ['git_commit_message'] });
|
||||||
|
expect(prompt).toContain('git_commit_message');
|
||||||
|
|
||||||
|
// Gating: switching the git set off withholds it from the wire.
|
||||||
|
expect(disabledToolNames(['core', 'edit-plus'])).toContain('git_commit_message');
|
||||||
|
expect(disabledToolNames(['core', 'edit-plus', 'git'])).not.toContain('git_commit_message');
|
||||||
|
|
||||||
|
// Session end to end: the tool is offered and produces its message.
|
||||||
|
const { model, seen } = recordingModel('refactor the port into a constant');
|
||||||
|
const session = new Session({
|
||||||
|
model,
|
||||||
|
askApproval: async () => 'deny',
|
||||||
|
extraTools: { git_commit_message: createCommitMessageTool({ model }) },
|
||||||
|
autoApprove: ['git_commit_message'],
|
||||||
|
});
|
||||||
|
|
||||||
|
for await (const _ of session.send('suggest a commit message')) void _;
|
||||||
|
const offered = (seen.find((o) => (o.tools ?? []).length > 0)?.tools ?? []).map((t) => t.name);
|
||||||
|
expect(offered).toContain('git_commit_message');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user