249 lines
10 KiB
JavaScript
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
|