Files
thzxx 058ee2de36 feat: 升级至 v0.3.12 — 文件与代码类工具全面优化(16 项)
【阶段一 · 紧急修复 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)
2026-07-21 16:08:39 +08:00

437 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 模式必须有 messageamend 模式可省略(保留原 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) };
}
}
}