import { z } from 'zod'; import { config } from '../../config.js'; import { runCommand } from '../../lib/runCommand.js'; import { resolveWorkspacePath } from '../../lib/sandbox.js'; import { errorResult, jsonResult, toErrorMessage } from '../shared.js'; async function git(args) { return runCommand('git', args, { cwd: config.workspaceRoot, timeoutMs: config.defaultCommandTimeoutMs, }); } export function registerGitStatusTool(server) { server.registerTool('git_status', { title: 'Git Status', description: 'Return the current branch, ahead/behind counts vs upstream (when available), and porcelain status lines. Read-only.', inputSchema: {}, }, async () => { try { const branchResult = await git(['rev-parse', '--abbrev-ref', 'HEAD']); const porcelain = await git(['status', '--porcelain=v1']); const aheadBehind = await git(['rev-list', '--left-right', '--count', '@{upstream}...HEAD']); let ahead = null; let behind = null; if (!aheadBehind.failed && aheadBehind.output.trim()) { const parts = aheadBehind.output.trim().split(/\s+/); behind = Number.parseInt(parts[0] ?? '0', 10); ahead = Number.parseInt(parts[1] ?? '0', 10); } return jsonResult({ branch: branchResult.output.trim() || null, ahead, behind, porcelain: porcelain.output .split(/\r?\n/) .map((line) => line.trimEnd()) .filter(Boolean), raw: porcelain.output, }); } catch (error) { return errorResult(toErrorMessage(error)); } }); } export function registerGitDiffTool(server) { server.registerTool('git_diff', { title: 'Git Diff', description: 'Show staged and/or unstaged diffs, optionally scoped to a workspace-relative path. Read-only; output is truncated.', inputSchema: { staged: z.boolean().optional().describe('If true, show only staged changes (`git diff --cached`).'), path: z.string().optional().describe('Optional workspace-relative file or directory to diff.'), }, }, async ({ staged, path: filePath }) => { try { const args = ['diff']; if (staged) args.push('--cached'); if (filePath) { const absolute = resolveWorkspacePath(config.workspaceRoot, filePath); args.push('--', absolute); } const result = await git(args); return jsonResult({ staged: Boolean(staged), path: filePath ?? null, exitCode: result.exitCode, failed: result.failed, output: result.output, }); } catch (error) { return errorResult(toErrorMessage(error)); } }); } export function registerGitDiffSummarizeTool(server) { server.registerTool('git_diff_summarize', { title: 'Git Diff Summarize', description: 'Summarize staged and/or unstaged changes: per-file insertions/deletions plus totals. Read-only; uses `git diff --numstat` (no full patch).', inputSchema: { staged: z.boolean().optional().describe('If true, summarize only staged changes (`git diff --cached --numstat`).'), path: z.string().optional().describe('Optional workspace-relative file or directory to summarize.'), }, }, async ({ staged, path: filePath }) => { try { const args = ['diff', '--numstat']; if (staged) args.push('--cached'); if (filePath) { const absolute = resolveWorkspacePath(config.workspaceRoot, filePath); args.push('--', absolute); } const result = await git(args); const files = summarizeNumstat(result.output); const insertions = files.reduce((sum, f) => sum + f.insertions, 0); const deletions = files.reduce((sum, f) => sum + f.deletions, 0); return jsonResult({ staged: Boolean(staged), path: filePath ?? null, fileCount: files.length, insertions, deletions, files, exitCode: result.exitCode, failed: result.failed, }); } catch (error) { return errorResult(toErrorMessage(error)); } }); } /** Parse `git diff --numstat` lines into structured file stats. Exported for tests. */ export function summarizeNumstat(output) { return output .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean) .map((line) => { const [insRaw, delRaw, ...pathParts] = line.split(/\t/); const filePath = pathParts.join('\t') || ''; const binary = insRaw === '-' || delRaw === '-'; return { path: filePath, insertions: binary ? 0 : Number.parseInt(insRaw ?? '0', 10) || 0, deletions: binary ? 0 : Number.parseInt(delRaw ?? '0', 10) || 0, binary, }; }) .filter((row) => row.path.length > 0); } export function registerGitLogTool(server) { server.registerTool('git_log', { title: 'Git Log', description: 'Show recent commits (hash, author, date, subject). Read-only.', inputSchema: { n: z.number().int().min(1).max(100).optional().default(10).describe('Number of commits to return.'), path: z.string().optional().describe('Optional workspace-relative path to limit history.'), }, }, async ({ n, path: filePath }) => { try { const args = ['log', `-n`, String(n ?? 10), '--pretty=format:%H%x09%an%x09%ad%x09%s', '--date=iso']; if (filePath) { const absolute = resolveWorkspacePath(config.workspaceRoot, filePath); args.push('--', absolute); } const result = await git(args); const commits = result.output .split(/\r?\n/) .filter(Boolean) .map((line) => { const [hash, author, date, ...subjectParts] = line.split('\t'); return { hash: hash ?? '', author: author ?? '', date: date ?? '', subject: subjectParts.join('\t'), }; }); return jsonResult({ n: n ?? 10, path: filePath ?? null, commits }); } catch (error) { return errorResult(toErrorMessage(error)); } }); } export function registerGitBranchTool(server) { server.registerTool('git_branch', { title: 'Git Branch', description: 'List local branches, or create a new branch. Creating a branch requires confirm: true. Never force-deletes or force-pushes.', inputSchema: { action: z.enum(['list', 'create']).default('list').describe('list (default) or create a branch.'), name: z.string().optional().describe('Branch name (required when action is create).'), confirm: z.boolean().optional().describe('Required as true when creating a branch.'), }, }, async ({ action, name, confirm }) => { try { if (action === 'list') { const result = await git(['branch', '--list', '--format=%(refname:short)%09%(HEAD)']); const branches = result.output .split(/\r?\n/) .filter(Boolean) .map((line) => { const [branchName, head] = line.split('\t'); return { name: branchName ?? '', current: head === '*' }; }); return jsonResult({ action: 'list', branches }); } if (!name || !name.trim()) { return errorResult('Branch name is required when action is "create".'); } if (confirm !== true) { return errorResult('Creating a branch requires confirm: true.'); } if (!/^[A-Za-z0-9._/-]+$/.test(name) || name.includes('..')) { return errorResult(`Invalid branch name "${name}".`); } const result = await git(['branch', name]); return jsonResult({ action: 'create', name, exitCode: result.exitCode, failed: result.failed, output: result.output, }); } catch (error) { return errorResult(toErrorMessage(error)); } }); } export function registerGitCommitTool(server) { server.registerTool('git_commit', { title: 'Git Commit', description: 'Stage workspace-relative paths (or all changes with paths: ["."]) and create a commit. Requires confirm: true. Does not amend, force, or push.', inputSchema: { message: z.string().min(1).describe('Commit message.'), paths: z .array(z.string()) .min(1) .describe('Workspace-relative paths to stage before committing. Use ["."] for all changes.'), confirm: z.boolean().optional().describe('Must be true to create a commit.'), }, }, async ({ message, paths, confirm }) => { try { if (confirm !== true) { return errorResult('git_commit requires confirm: true.'); } const absolutePaths = paths.map((p) => resolveWorkspacePath(config.workspaceRoot, p)); const addResult = await git(['add', '--', ...absolutePaths]); if (addResult.failed) { return errorResult(`git add failed:\n${addResult.output}`); } const commitResult = await git(['commit', '-m', message]); return jsonResult({ message, paths, staged: absolutePaths, exitCode: commitResult.exitCode, failed: commitResult.failed, output: commitResult.output, }); } catch (error) { return errorResult(toErrorMessage(error)); } }); } //# sourceMappingURL=gitTools.js.map