Files
metona-ai-desktop/electron/harness/tools/built-in/git.ts
T
thzxx 5b9d4d19b3
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m16s
CI / 全量测试 (Electron ABI) (push) Failing after 6m4s
CI / 产物编译验证 (push) Successful in 11m1s
feat: v0.8.0 流语义补全 · 会话可靠 · 恢复力 — finish_reason 全链路贯通根治"思考中停止" · 2445 用例全量回归
P0 会话可靠性收口(根治"模型思考着会话就停止"):
- P0-1 finish_reason 全链路贯通:DONE 事件与 IterationStep 新增 finishReason,OpenAI 共享 SSE / Anthropic message_delta.stop_reason / Ollama done_reason 三路采集,TRACE 层弃用硬编码 'stop' 记录真值
- P0-2 空响应守卫 + 降级重试:零产出流→可重试错误走退避;思考耗尽输出预算(reasoning-only + length)→自动关闭思考降级重试一次;仍失败→OUTPUT_LENGTH_EXCEEDED 结构化错误 + 故障转移;附带根治 abort 恰逢零工具调用轮被 COMPLETED 抢占的真实缺陷
- P0-3 思考×能力×预算三对齐:DeepSeek/MiMo/Agnes/Ollama 四家 supportsThinking=false 强制不发思考参数;小输出预算告警;设置页联动提示
- P0-4 渲染层可见性:截断/空完成/友好错误三类提示,i18n 全部出层
- P0-5 回归四件套:reasoning-only 终止判定、集成级空闲超时、504 引擎重试归类、思考中 abort→USER_INTERRUPT、P4-2 强制收尾路径

FEAT-1:LLM 设置新增「最大输出上限」——Provider 支持矩阵显隐 + 模型上限钳制提示 + 超限保存警告 + llm.maxTokens 热生效

P1 修复面收口:
- 渲染层三缺陷根治:后台会话回放缓冲(2000 条/4MB 有界 + agent:getReplayState + 事件总线)+ abort 双层自愈 + sendMessage 收尾兜底 + 中断卡片清扫
- 工具 abort 信号全覆盖:web_search/web_fetch/http_request/code_search/git 系列/delegate_task 全部接入引擎中断;web_search 时间预算收敛(720s→≤240s);移除伪造 ToolExecutionContext 与死代码
- 安全:本地 Pinned CONNECT 代理根治浏览器通道 DNS rebinding(校验期 IP pinning,可注入 resolver 表测);配置 URL 域名解析深校验(DeepCheckSoftFailure 软失败);SSE 空 error 帧防御修复;Ollama generate/embed AbortSignal.any 合并
- 缺陷清单:UTF-16 BOM 读取、tmp 同毫秒碰撞(nanoid 后缀)、code_search JS 回退参数对称(case_sensitive/前后文独立)、list_directory include_node_modules、崩溃自愈退避(60s 窗 ≥3 次停 reload)、MemoryViewer/Sidebar i18n 收口

P2 能力演进:
- 会话回收站:SCHEMA_VERSION 3 + 迁移 10(deleted_at,存在性守卫),软删除/恢复/彻底删除/30 天自动清理(启动+24h),searchMessages 聚合剔除,Sidebar 回收站面板
- 会话回放播放器:sessions:listRecordings/readRecording(白名单+目录边界+20MB 上限),SessionReplayPlayer 时间轴/步进/变速,Trace 面板入口
- electron-updater 自动更新:双轨(手动 feed 比对保留),生产环境启动静默检查 + update:status 广播 + app:updateInstall + LogsSettings UpdatePanel + builder publish 配置
- @ 文件提及:workspace.listFiles/readFileClip(边界/512KB/NUL 拒绝/MEMORY.md 保护),ChatInput Fuse 联想+键盘导航+附件管线注入
- MCP Resources/Prompts 发现:可选能力 try/catch 降级,mcp:listServerContents,MCPSettings 展开视图
- 文档对齐:内部 API 标准 HTML(Adapter 清单补 MiMo/已实现注记/STREAM_RESET/DONE.finishReason/ repetition_truncation 映射);README v0.8.0 亮点表

P3 测试基建:
- 新增 4 个测试文件:engine-stream-contract(6)、engine-stream-reliability(4:集成空闲超时/504 重试/思考中 abort/P4-2 强制收尾)、thinking-capability-gate(7)、pinned-proxy(9,含深校验 5)、session-trash(5,DB 域)、use-agent-stream hook 级(5)、agent.test 回放缓冲(2)
- 契约更新:orchestrator 被中断 SubAgent success=false(abort 优先级修复语义)、SSE 空 error 帧、UTF-16 正常读取、DeepSeek 未配置思考显式 disabled、迁移矩阵 v2→3
- 弱断言根治:registry WEBP 单向断言、hooks-contracts 自比恒真、memory 空 token 补强

全量验证:typecheck 0 错误 / lint 0 问题 / 系统 Node 2144 通过(301 DB 用例按 ABI 跳过)/ Electron ABI 2445/2445 全量通过 0 跳过
2026-09-05 20:06:26 +08:00

485 lines
17 KiB
TypeScript
Raw 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 报错而非截断返回)。
*
* v0.8.0 P1-2: 新增可选 signal —— 引擎中断(用户停止/超时)时立即 kill git
* 子进程(Node execFile 原生支持 AbortSignal 选项),各工具经 context 透传。
*/
async function runGit(
args: string[],
cwd: string,
timeout = 10_000,
maxBuffer = 1024 * 1024, // 默认 1MB
signal?: AbortSignal,
): Promise<string> {
const { stdout } = await execFileAsync('git', args, {
cwd,
maxBuffer,
timeout,
encoding: 'utf-8',
...(signal ? { signal } : {}),
});
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);
// #19 修复: 校验 pathspec 在工作空间内,防止读取任意仓库差异
// 攻击场景:pathspec 传 "../../../other-repo/" 或绝对路径可越权读取工作空间外的 git 差异
// resolve 后 isPathWithinWorkspace 校验,glob 字符(*?)不影响前缀校验
if (pathspec) {
// 审查修复 M18: git magic pathspec(以 : 开头,如 :(glob)**/*.ts)跳过 resolve 校验。
// resolve 会把 magic 前缀当普通字符拼接到路径,导致 pathspec 被污染;
// git magic 语法本身不构成路径越权风险(git 自身解析 magic 前缀,不指向工作空间外)。
if (pathspec.startsWith(':')) {
// git magic pathspec,跳过 resolve 校验直接放行
} else {
const resolved = resolve(context.workspacePath, pathspec);
if (!isPathWithinWorkspace(resolved, context.workspacePath)) {
return { error: `Path outside workspace: ${pathspec}`, success: false };
}
}
}
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) };
}
}
}