Files
2026-07-31 13:12:54 -04:00

249 lines
10 KiB
JavaScript

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