diff --git a/src/tools-git.ts b/src/tools-git.ts index 58ed705..6926a22 100644 --- a/src/tools-git.ts +++ b/src/tools-git.ts @@ -16,7 +16,7 @@ type GitResult = { ok: true; stdout: string } | { ok: false; message: string }; * an injection. Spawning the binary directly with a fixed argv removes that entirely, * which is also why these tools can be auto-approved. */ -async function git(args: string[], cwd: string, timeout = 30_000): Promise { +export async function git(args: string[], cwd: string, timeout = 30_000): Promise { let proc: Bun.Subprocess<'ignore', 'pipe', 'pipe'>; try { proc = Bun.spawn(['git', ...args], { cwd, stdout: 'pipe', stderr: 'pipe', timeout }); @@ -152,13 +152,38 @@ export const gitBlameTool = tool({ }, }); +export const gitBranchTool = tool({ + description: + 'Branches in this repository, newest commit first, with the current one marked. Pass remote to include ' + + 'remote-tracking branches. Use it before proposing a branch name, so a name already taken is obvious.', + inputSchema: z.object({ + remote: z.boolean().optional().describe('Include remote-tracking branches'), + }), + execute: async ({ remote }) => { + const args = [ + 'branch', + '--list', + '--sort=-committerdate', + '--format=%(if)%(HEAD)%(then)* %(else) %(end)%(refname:short) %(committerdate:short) %(contents:subject)', + ]; + if (remote) args.push('--all'); + return run(args, 'No branches yet.'); + }, +}); + export const gitTools = { git_status: gitStatusTool, git_diff: gitDiffTool, git_log: gitLogTool, git_show: gitShowTool, git_blame: gitBlameTool, + git_branch: gitBranchTool, }; -/** Read-only, so none of these ever prompt for approval. */ -export const GIT_TOOL_NAMES = Object.keys(gitTools); +/** + * Read-only, so none of these ever prompt for approval. + * + * `git_commit_message` is built in `src/commit.ts` and wired in `cli.tsx`, because it + * needs the model at construction. It belongs to this set for gating like the rest. + */ +export const GIT_TOOL_NAMES = [...Object.keys(gitTools), 'git_commit_message']; diff --git a/test/tools-git.test.ts b/test/tools-git.test.ts index 3cac7da..434c9c1 100644 --- a/test/tools-git.test.ts +++ b/test/tools-git.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path'; import { GIT_TOOL_NAMES, gitBlameTool, + gitBranchTool, gitDiffTool, gitLogTool, gitShowTool, @@ -45,8 +46,34 @@ async function repoWithOneCommit(): Promise { } test('every git tool is registered and named consistently', () => { - expect(GIT_TOOL_NAMES.sort()).toEqual(['git_blame', 'git_diff', 'git_log', 'git_show', 'git_status']); - expect(Object.keys(gitTools).sort()).toEqual(GIT_TOOL_NAMES.sort()); + // git_commit_message is built in src/commit.ts with the model at construction, + // so it is not part of the static gitTools object — but it belongs to the set. + expect(GIT_TOOL_NAMES.sort()).toEqual([ + 'git_blame', + 'git_branch', + 'git_commit_message', + 'git_diff', + 'git_log', + 'git_show', + 'git_status', + ]); + expect([...Object.keys(gitTools), 'git_commit_message'].sort()).toEqual(GIT_TOOL_NAMES.sort()); +}); + +test('git_branch marks the current branch and lists the others', async () => { + await repoWithOneCommit(); + const one = await run(gitBranchTool, {}); + expect(one).toContain('* main'); + + await git('branch', 'feature/pagination'); + const two = await run(gitBranchTool, {}); + expect(two).toContain('* main'); + expect(two).toContain('feature/pagination'); + expect(two).toContain('add the server port'); +}); + +test('git_branch outside a repository says so', async () => { + expect(run(gitBranchTool, {})).rejects.toThrow(/not a git repository/); }); test('git_status names the branch and describes each change', async () => {