【阶段一 · 紧急修复 F1】 - F1-1 file_editor regex 计数 bug:强制 g 标志 + 同一 RegExp 实例做 match+replace - F1-2 code_search 统一 safeResolvePath(含路径遍历 + MEMORY.md 拦截) - F1-3 git_commit amend 模式:message 可选 + --no-edit 防止编辑器 hang - F1-4 write_file append 模式原子化:显式 open(O_APPEND)+write+fsync+close 【阶段二 · 增强现有工具 F2】 - F2-1 read_file 智能编码检测:BOM(UTF-8/UTF-16 LE/BE) + GBK 降级 - F2-2 read_file 支持 tail 模式(读取末尾 N 行,适用日志) - F2-3 search_files 二进制过滤 + 智能编码检测 - F2-4 search_files 多 glob 匹配(逗号分隔,如 *.ts,*.js) - F2-5 file_editor 新增 find_replace 操作(字面量替换,规避正则歧义) - F2-6 file_editor regex 支持跨行匹配(multiline 参数) - F2-7 file_editor 新增 backup 参数(编辑前 .bak 备份) 【阶段三 · 新增工具 F3】 - F3-1 file_move:双路径校验 + overwrite + 自动建父目录 + rename 原子 - F3-2 file_info:大小/时间/类型/编码/二进制/权限位 【阶段四 · 优化 F4】 - F4-1 diff_viewer Uint32Array→Uint16Array(省一半内存)+ safeResolvePath + 智能编码 - F4-2 错误处理统一:diff-viewer/file-editor/code-search catch 块改用 extractErrorMessage 【阶段五 · 验证 F5】 - tsc --noEmit 类型检查通过 - 人工审查通过:路径校验/错误处理/原子性/资源释放/权限策略/导出注册 工具数量:13 → 15(新增 file_move / file_info)
437 lines
16 KiB
TypeScript
437 lines
16 KiB
TypeScript
/**
|
||
* Git 工具集(4 个)
|
||
*
|
||
* git_status, git_diff, git_log, git_commit
|
||
*
|
||
* 使用 execFile 执行 git 命令,防止命令注入。
|
||
* 所有命令在 context.workspacePath 下执行。
|
||
*/
|
||
|
||
import { execFile } from 'child_process';
|
||
import { promisify } from 'util';
|
||
import { resolve } from 'path';
|
||
import { isPathWithinWorkspace } from './file-guard';
|
||
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
|
||
import type { MetonaToolDef } from '../../../harness/types';
|
||
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
|
||
|
||
const execFileAsync = promisify(execFile);
|
||
|
||
/** diff 输出截断阈值(50KB) */
|
||
const MAX_DIFF_SIZE = 50 * 1024;
|
||
|
||
/**
|
||
* 执行 git 命令的统一入口
|
||
*
|
||
* 使用 execFile(非 exec)+ 参数数组,从底层杜绝命令注入。
|
||
* 显式设置 encoding: 'utf-8',git 默认输出 UTF-8。
|
||
*
|
||
* v0.3.1 修复 WARN-4: 允许调用方覆盖 maxBuffer,默认 1MB;
|
||
* git_diff 传 5MB 以支持超大变更集(避免 maxBuffer exceeded 报错而非截断返回)。
|
||
*/
|
||
async function runGit(
|
||
args: string[],
|
||
cwd: string,
|
||
timeout = 10_000,
|
||
maxBuffer = 1024 * 1024, // 默认 1MB
|
||
): Promise<string> {
|
||
const { stdout } = await execFileAsync('git', args, {
|
||
cwd,
|
||
maxBuffer,
|
||
timeout,
|
||
encoding: 'utf-8',
|
||
});
|
||
return stdout;
|
||
}
|
||
|
||
/** 判断错误是否为 "不是 git 仓库" 或 git 不可用 */
|
||
function isNotGitRepoError(error: unknown): boolean {
|
||
if (error instanceof Error) {
|
||
const stderr = (error as Error & { stderr?: string }).stderr ?? '';
|
||
const msg = `${error.message} ${stderr}`;
|
||
return /not a git repository/i.test(msg) || /git: not found|command not found/i.test(msg);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/** 提取错误信息字符串 */
|
||
function extractErrorMessage(error: unknown): string {
|
||
if (error instanceof Error) {
|
||
const stderr = (error as Error & { stderr?: string }).stderr ?? '';
|
||
return stderr ? `${error.message}\n${stderr}` : error.message;
|
||
}
|
||
return String(error);
|
||
}
|
||
|
||
/** 获取当前分支名(失败时返回 'unknown',不抛错) */
|
||
async function getCurrentBranch(cwd: string): Promise<string> {
|
||
try {
|
||
const branch = (await runGit(['rev-parse', '--abbrev-ref', 'HEAD'], cwd)).trim();
|
||
return branch;
|
||
} catch {
|
||
return 'unknown';
|
||
}
|
||
}
|
||
|
||
/** 处理 porcelain v1 重命名格式 "old -> new" → 返回 new */
|
||
function extractRenamedFile(file: string): string {
|
||
const idx = file.indexOf(' -> ');
|
||
return idx >= 0 ? file.slice(idx + 4) : file;
|
||
}
|
||
|
||
// ===== 1. git_status =====
|
||
|
||
export class GitStatusTool implements IMetonaTool {
|
||
readonly definition: MetonaToolDef = {
|
||
name: 'git_status',
|
||
description: 'Show the working tree status. Returns current branch, ahead/behind counts, and lists of staged, unstaged, and untracked files.',
|
||
parameters: {
|
||
type: 'object',
|
||
properties: {
|
||
pathspec: { type: 'string', description: 'Limit to a path scope, e.g. "src/" or "package.json"' },
|
||
},
|
||
},
|
||
category: MetonaToolCategory.FILESYSTEM,
|
||
riskLevel: MetonaRiskLevel.SAFE,
|
||
requiresPermission: false,
|
||
timeoutMs: 15_000,
|
||
};
|
||
|
||
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||
const pathspec = args.pathspec as string | undefined;
|
||
const gitArgs = ['status', '--porcelain=v1', '-b'];
|
||
if (pathspec) {
|
||
gitArgs.push('--', pathspec);
|
||
}
|
||
|
||
try {
|
||
const output = await runGit(gitArgs, context.workspacePath);
|
||
return this.parseStatus(output);
|
||
} catch (error) {
|
||
if (isNotGitRepoError(error)) {
|
||
return { error: 'Not a git repository or git not available', success: false };
|
||
}
|
||
return { error: extractErrorMessage(error), success: false };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 解析 `git status --porcelain=v1 -b` 输出
|
||
*
|
||
* 分支行格式: `## <branch>...<upstream> [ahead N, behind M]`
|
||
* 文件行格式: `XY <space> <file>` (X=暂存区状态, Y=工作区状态)
|
||
*/
|
||
private parseStatus(output: string): unknown {
|
||
const lines = output.split('\n').filter((l) => l.length > 0);
|
||
const staged: Array<{ status: string; file: string }> = [];
|
||
const unstaged: Array<{ status: string; file: string }> = [];
|
||
const untracked: string[] = [];
|
||
|
||
let branch = '';
|
||
let ahead = 0;
|
||
let behind = 0;
|
||
|
||
for (const line of lines) {
|
||
// 分支行: ## main...origin/main [ahead 2, behind 1]
|
||
if (line.startsWith('## ')) {
|
||
const header = line.slice(3);
|
||
const bracketIdx = header.indexOf('[');
|
||
const branchPart = bracketIdx >= 0 ? header.slice(0, bracketIdx).trim() : header.trim();
|
||
// branchPart 形如 "main...origin/main" / "main" / "HEAD (no branch)"
|
||
const dotIdx = branchPart.indexOf('...');
|
||
branch = dotIdx >= 0 ? branchPart.slice(0, dotIdx) : branchPart.split(' ')[0];
|
||
|
||
if (bracketIdx >= 0) {
|
||
const meta = header.slice(bracketIdx + 1, header.lastIndexOf(']'));
|
||
const aheadMatch = meta.match(/ahead (\d+)/);
|
||
const behindMatch = meta.match(/behind (\d+)/);
|
||
if (aheadMatch) ahead = parseInt(aheadMatch[1], 10);
|
||
if (behindMatch) behind = parseInt(behindMatch[1], 10);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// porcelain v1 文件行: XY <space> <file>
|
||
const x = line[0];
|
||
const y = line[1];
|
||
const file = line.slice(3);
|
||
|
||
// 未跟踪: ??
|
||
if (x === '?' && y === '?') {
|
||
untracked.push(file);
|
||
continue;
|
||
}
|
||
|
||
// 暂存区变更 (X 非 ' ' 且非 '?')
|
||
if (x !== ' ' && x !== '?') {
|
||
staged.push({ status: x, file: extractRenamedFile(file) });
|
||
}
|
||
// 工作区变更 (Y 非 ' ' 且非 '?')
|
||
if (y !== ' ' && y !== '?') {
|
||
unstaged.push({ status: y, file: extractRenamedFile(file) });
|
||
}
|
||
}
|
||
|
||
const clean = staged.length === 0 && unstaged.length === 0 && untracked.length === 0;
|
||
|
||
return {
|
||
branch,
|
||
ahead,
|
||
behind,
|
||
staged,
|
||
unstaged,
|
||
untracked,
|
||
clean,
|
||
};
|
||
}
|
||
}
|
||
|
||
// ===== 2. git_diff =====
|
||
|
||
export class GitDiffTool implements IMetonaTool {
|
||
readonly definition: MetonaToolDef = {
|
||
name: 'git_diff',
|
||
description: 'Show changes between the working tree and the index (or staged changes). Returns unified diff text. Output is truncated at 50KB.',
|
||
parameters: {
|
||
type: 'object',
|
||
properties: {
|
||
cached: { type: 'boolean', description: 'Show only staged (cached) changes (default false)' },
|
||
pathspec: { type: 'string', description: 'Limit to a path scope, e.g. "src/"' },
|
||
contextLines: { type: 'number', description: 'Number of context lines (default 3)' },
|
||
},
|
||
},
|
||
category: MetonaToolCategory.FILESYSTEM,
|
||
riskLevel: MetonaRiskLevel.SAFE,
|
||
requiresPermission: false,
|
||
timeoutMs: 15_000,
|
||
};
|
||
|
||
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||
const cached = (args.cached as boolean) ?? false;
|
||
const pathspec = args.pathspec as string | undefined;
|
||
const contextLines = Math.max(0, (args.contextLines as number) ?? 3);
|
||
|
||
const gitArgs = ['diff'];
|
||
if (cached) gitArgs.push('--cached');
|
||
gitArgs.push(`--unified=${contextLines}`);
|
||
if (pathspec) gitArgs.push('--', pathspec);
|
||
|
||
try {
|
||
// v0.3.1 修复 WARN-4: 使用 5MB maxBuffer 支持超大 diff,避免 maxBuffer exceeded 报错
|
||
// 工具内部仍会截断到 50KB 返回,避免 LLM 上下文溢出
|
||
const diff = await runGit(gitArgs, context.workspacePath, 10_000, 5 * 1024 * 1024);
|
||
const truncated = diff.length > MAX_DIFF_SIZE;
|
||
const truncatedDiff = truncated ? diff.slice(0, MAX_DIFF_SIZE) : diff;
|
||
const filesChanged = this.countFilesChanged(diff);
|
||
|
||
return {
|
||
diff: truncatedDiff,
|
||
filesChanged,
|
||
truncated,
|
||
};
|
||
} catch (error) {
|
||
if (isNotGitRepoError(error)) {
|
||
return { error: 'Not a git repository or git not available', success: false };
|
||
}
|
||
return { error: extractErrorMessage(error), success: false };
|
||
}
|
||
}
|
||
|
||
/** 统计 diff 中变更的文件数(每变更文件一个 `diff --git` 头) */
|
||
private countFilesChanged(diff: string): number {
|
||
let count = 0;
|
||
for (const line of diff.split('\n')) {
|
||
if (line.startsWith('diff --git ')) count++;
|
||
}
|
||
return count;
|
||
}
|
||
}
|
||
|
||
// ===== 3. git_log =====
|
||
|
||
export class GitLogTool implements IMetonaTool {
|
||
readonly definition: MetonaToolDef = {
|
||
name: 'git_log',
|
||
description: 'Show commit history. Returns commits with hash, message, and optionally author/date. Supports filtering by author and path.',
|
||
parameters: {
|
||
type: 'object',
|
||
properties: {
|
||
limit: { type: 'number', description: 'Number of entries to return (default 20, max 100)' },
|
||
oneline: { type: 'boolean', description: 'Use compact one-line format (default true)' },
|
||
pathspec: { type: 'string', description: 'Limit to a path scope' },
|
||
author: { type: 'string', description: 'Filter by author name/email' },
|
||
},
|
||
},
|
||
category: MetonaToolCategory.FILESYSTEM,
|
||
riskLevel: MetonaRiskLevel.SAFE,
|
||
requiresPermission: false,
|
||
timeoutMs: 15_000,
|
||
};
|
||
|
||
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||
const limit = Math.min(100, Math.max(1, (args.limit as number) ?? 20));
|
||
const oneline = (args.oneline as boolean) ?? true;
|
||
const pathspec = args.pathspec as string | undefined;
|
||
const author = args.author as string | undefined;
|
||
|
||
const gitArgs = ['log', `-n${limit}`];
|
||
if (oneline) {
|
||
gitArgs.push('--oneline');
|
||
} else {
|
||
// 用 NULL 字节分隔字段,避免 author/message 含 '|' 导致解析错误
|
||
gitArgs.push('--format=%H%x00%an%x00%ad%x00%s');
|
||
}
|
||
// v0.3.1 修复 WARN-8: 拆分为独立参数,避免特殊字符干扰 git 解析
|
||
if (author) gitArgs.push('--author', author);
|
||
if (pathspec) gitArgs.push('--', pathspec);
|
||
|
||
try {
|
||
// 并行获取日志和当前分支名
|
||
const [output, branch] = await Promise.all([
|
||
runGit(gitArgs, context.workspacePath),
|
||
getCurrentBranch(context.workspacePath),
|
||
]);
|
||
|
||
const commits = this.parseLog(output, oneline);
|
||
return { commits, count: commits.length, branch };
|
||
} catch (error) {
|
||
if (isNotGitRepoError(error)) {
|
||
return { error: 'Not a git repository or git not available', success: false };
|
||
}
|
||
return { error: extractErrorMessage(error), success: false };
|
||
}
|
||
}
|
||
|
||
private parseLog(
|
||
output: string,
|
||
oneline: boolean,
|
||
): Array<{ hash: string; author?: string; date?: string; message: string }> {
|
||
const lines = output.split('\n').filter((l) => l.length > 0);
|
||
const commits: Array<{ hash: string; author?: string; date?: string; message: string }> = [];
|
||
|
||
for (const line of lines) {
|
||
if (oneline) {
|
||
// <short-hash> <space> <message>
|
||
const spaceIdx = line.indexOf(' ');
|
||
if (spaceIdx < 0) continue;
|
||
commits.push({
|
||
hash: line.slice(0, spaceIdx),
|
||
message: line.slice(spaceIdx + 1),
|
||
});
|
||
} else {
|
||
// <hash>\0<author>\0<date>\0<message>
|
||
const parts = line.split('\0');
|
||
if (parts.length < 4) continue;
|
||
commits.push({
|
||
hash: parts[0],
|
||
author: parts[1],
|
||
date: parts[2],
|
||
message: parts[3],
|
||
});
|
||
}
|
||
}
|
||
|
||
return commits;
|
||
}
|
||
}
|
||
|
||
// ===== 4. git_commit =====
|
||
|
||
export class GitCommitTool implements IMetonaTool {
|
||
readonly definition: MetonaToolDef = {
|
||
name: 'git_commit',
|
||
description: 'Stage files and create a git commit. Supports amending the previous commit. In amend mode, message is optional (omitting it keeps the original commit message). Requires user confirmation.',
|
||
parameters: {
|
||
type: 'object',
|
||
properties: {
|
||
message: { type: 'string', description: 'Commit message. Required for new commits. Optional in amend mode (omitting keeps original message).' },
|
||
files: {
|
||
type: 'array',
|
||
items: { type: 'string', description: 'File path to stage' },
|
||
description: 'Files to stage (default: all changes via git add -A)',
|
||
},
|
||
amend: { type: 'boolean', description: 'Amend the previous commit instead of creating a new one (default false)' },
|
||
},
|
||
// F1-3: message 不再硬性 required — amend 模式下可省略(保留原 message)
|
||
// 校验在 execute 内根据 amend 标志动态进行
|
||
},
|
||
category: MetonaToolCategory.FILESYSTEM,
|
||
riskLevel: MetonaRiskLevel.MEDIUM,
|
||
requiresPermission: true,
|
||
timeoutMs: 30_000,
|
||
};
|
||
|
||
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||
const message = args.message as string | undefined;
|
||
const files = (args.files as string[] | undefined) ?? [];
|
||
const amend = (args.amend as boolean) ?? false;
|
||
|
||
// F1-3: 非 amend 模式必须有 message;amend 模式可省略(保留原 message)
|
||
if (!amend && (!message || message.trim().length === 0)) {
|
||
return { success: false, error: 'Commit message is required (use amend: true to reuse the previous message)' };
|
||
}
|
||
// amend 模式下若提供 message,需校验非空字符串
|
||
if (amend && message !== undefined && message.trim().length === 0) {
|
||
return { success: false, error: 'Commit message must not be empty (or omit message field to keep original)' };
|
||
}
|
||
|
||
try {
|
||
// 1. 暂存文件(指定文件 → git add -- <files>;否则 git add -A)
|
||
// v0.3.1 修复 WARN-1: 校验每个 file 路径在工作空间内
|
||
if (files.length > 0) {
|
||
for (const f of files) {
|
||
const resolved = resolve(context.workspacePath, f);
|
||
if (!isPathWithinWorkspace(resolved, context.workspacePath)) {
|
||
return {
|
||
success: false,
|
||
error: `File path outside workspace: ${f}`,
|
||
commit: '', branch: '', message: '', filesChanged: 0,
|
||
};
|
||
}
|
||
}
|
||
await runGit(['add', '--', ...files], context.workspacePath);
|
||
} else {
|
||
await runGit(['add', '-A'], context.workspacePath);
|
||
}
|
||
|
||
// 2. 统计暂存文件数(commit 前取样)
|
||
const stagedOutput = await runGit(['diff', '--cached', '--name-only'], context.workspacePath);
|
||
const filesChanged = stagedOutput.split('\n').filter((l) => l.length > 0).length;
|
||
|
||
// 3. 提交(commit 可能触发 hooks,给予更长超时)
|
||
// F1-3: amend 模式下若未提供 message,不加 -m 参数(保留原 message)
|
||
const commitArgs = ['commit'];
|
||
if (amend) commitArgs.push('--amend');
|
||
if (message && message.trim().length > 0) {
|
||
commitArgs.push('-m', message);
|
||
} else if (amend) {
|
||
// amend + 无 message + 无 --no-edit → git 会打开编辑器要求输入
|
||
// 加 --no-edit 明确表示保留原 message(防止 hang 等待编辑器)
|
||
commitArgs.push('--no-edit');
|
||
}
|
||
await runGit(commitArgs, context.workspacePath, 20_000);
|
||
|
||
// 4. 获取提交 hash 和当前分支
|
||
const [commit, branch] = await Promise.all([
|
||
runGit(['rev-parse', 'HEAD'], context.workspacePath).then((s) => s.trim()),
|
||
getCurrentBranch(context.workspacePath),
|
||
]);
|
||
|
||
return {
|
||
success: true,
|
||
commit,
|
||
branch,
|
||
// F1-3: amend 模式下若未提供 message,返回值为空字符串(实际 message 保留原值)
|
||
message: message ?? (amend ? '(amended - original message preserved)' : ''),
|
||
filesChanged,
|
||
amended: amend,
|
||
};
|
||
} catch (error) {
|
||
if (isNotGitRepoError(error)) {
|
||
return { error: 'Not a git repository or git not available', success: false };
|
||
}
|
||
return { success: false, error: extractErrorMessage(error) };
|
||
}
|
||
}
|
||
}
|