/** * 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 { 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 { 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, context: ToolExecutionContext): Promise { 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` 输出 * * 分支行格式: `## ... [ahead N, behind M]` * 文件行格式: `XY ` (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 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, context: ToolExecutionContext): Promise { 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, context: ToolExecutionContext): Promise { 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) { // const spaceIdx = line.indexOf(' '); if (spaceIdx < 0) continue; commits.push({ hash: line.slice(0, spaceIdx), message: line.slice(spaceIdx + 1), }); } else { // \0\0\0 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, context: ToolExecutionContext): Promise { 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 -- ;否则 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) }; } } }