feat: v0.7.4 时序语义修正 · 防线实效补漏 · 全量测试翻倍 — 2406 用例 + jsdom 组件测试全量回归
CI / 类型检查 + Lint + 单元测试 (push) Failing after 6m27s
CI / 产物编译验证 (push) Successful in 9m57s
CI / 全量测试 (Electron ABI) (push) Failing after 5m19s

P1 修复面收口:
- 超时三态区分(aborted→USER_INTERRUPT / ETIMEDOUT→TIMEOUT / 其余→ERROR),
  根治"真实网络超时被误报为用户中断"
- 流空闲超时统一(SSE/Ollama/Anthropic 读循环 60s 无数据抛 504 进重试通道)
- 同会话并发 sendMessage 防重入(isRunning 守卫)+ 会话存在性预检 +
  前置调用移入 try(ERROR+DONE 双事件保证,根治 isStreaming 假死)
- 清空审计后 resetChainCache(根治 verifyChain 误报 TAMPERED)
- DONE 不再提前清理 TRACE(TERMINATED 统一收尾,补全最终迭代录制)
- IME 合成回车不发送(普通 Enter + Cmd/Ctrl+Enter 双分支)+ handleSend 闭包修复

P2 安全纵深:
- preload 移除原始 electronAPI 暴露(渲染层零使用,关掉 XSS invoke 任意通道单点风险)
- CORS 同源回显根治(仅当前浏览页面 Origin,did-navigate 同步)
- MEMORY.md 命令保护正则扩展(括号/$/反引号/< 重定向边界 + 前导路径)
- write_file append TOCTOU 统一(open 后 realpath 校验,新文件分支补漏)
- 敏感键归一化(authKey 驼峰/连字符命中)+ MCP headers 鉴权值加密落库
- ReDoS 检测共享化(search_files/file_editor 统一拦截)
- run_tests/lint_code 升风险 + 需确认 + npx --no-install(执行边界对齐 run_command)
- MCP/SearXNG/llm.baseURL/updateFeedUrl 配置类 URL 高危目标校验(IPv6 去括号 +
  十六进制映射解析 + 尾点剥离)

P3 架构还债:
- temperature/maxTokens 热生效(引擎/编排器/SubAgent 三处接线)+ setBatch 单事务落盘
- SessionRecorder flush 竞态根治(flushPromise 等待 + 超限内联落盘 + stopRecording async)
- 内存收口(lastConsolidationBySession LRU / subTraces 清理 / 会话删除 disposeEngine)
- i18n 全量收口(28 组件 + 353 key 双字典,状态标签改渲染时函数)
- 死代码清理(updateTraceStep/HEADER_HEIGHT/void preA/失实注释)
- 斜杠菜单 MUI 化 + 删除逻辑收敛 resetSessionState + Blob URL 统一释放 +
  用户消息"仅保存"落库(saveMessage 透传前端 id 修复 id 错位)

P4 能力演进:
- 死循环检测拆分(驻留前置 + 乒乓后置带进度信号,合法交替不误报)
- run-lock 30s 超时强制 abort(旧 run 卡死不无限排队)
- RETRY 双通道 stream_reset(前端按 run 归属精确清空,根治重试文本重复)
- FTS5 trigram 中文子串搜索(迁移 9 版本化 SCHEMA_VERSION=2,≤2 字符 LIKE 回退)
- getContextWindow 兜底 1M→128K(未知模型防 413)

测试:
- 855 → 2406 用例(+1551,2.8 倍):服务层 +325(含 MemoryManager 51 新用例)、
  工具实体 +483、IPC/适配器 +390(含 OpenAI/Anthropic/Ollama 独立套件)、
  纯函数表格化 +330;引入 jsdom + @testing-library(14 组件测试文件 249 用例)
- 修复 R1(saveMessage id 透传)/ R2(stream_reset 精确归属)两个回归缺陷
- 遗留低危项清零:git-tools 顺序耦合 / web-fetch 真实时间退避 / slo 内存断言 /
  mcp-security 多余 skipIf / deepseek-balance 命名误导 / 组件 mock 注入脆弱性

版本: 0.7.4; README 同步(工具风险表/版本徽章); 依赖: 移除 @electron-toolkit/preload,
新增 jsdom/@testing-library(devDependencies 不打包)

回归: typecheck 双端 0 错误; ESLint 0/0; Electron ABI 全量 2406/2406 零跳过;
系统 Node 2110 通过 296 跳过(better-sqlite3 ABI)
This commit is contained in:
2026-08-30 19:19:07 +08:00
parent ebe45482b0
commit 99d0c54129
137 changed files with 25190 additions and 1792 deletions
@@ -1,8 +1,9 @@
/**
* Git 四工具真实夹具套件(v0.7.0 覆盖补齐)
* 临时仓库内走真实 git —— porcelain 解析、diff 截断、log NULL 字段、commit 白名单路径。
* Git 四工具真实夹具套件(v0.7.0 覆盖补齐 → v0.7.5 扩充
* 临时仓库内走真实 git —— porcelain 解析、diff 截断、log NULL 字段、
* commit 白名单路径、amend 防 hang、runGit 参数数组防注入。
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
@@ -12,7 +13,12 @@ import { GitStatusTool, GitDiffTool, GitLogTool, GitCommitTool } from '../git';
import type { ToolExecutionContext } from '../../../types/metona-tool';
let ws: string;
const ctxOf = (): ToolExecutionContext => ({ sessionId: 't', workspacePath: ws, iteration: 1, requestId: 'r' });
const ctxOf = (): ToolExecutionContext => ({
sessionId: 't',
workspacePath: ws,
iteration: 1,
requestId: 'r',
});
const runGitSilent = (...a: string[]): void => {
execFileSync('git', ['-C', ws, ...a], { stdio: ['ignore', 'ignore', 'pipe'] });
};
@@ -29,13 +35,164 @@ beforeAll(() => {
afterAll(() => rmSync(ws, { recursive: true, force: true }));
// ===== parseStatus / parseLog 纯函数契约(私有方法白盒)=====
describe('GitStatusTool.parseStatus — porcelain v1 解析', () => {
const statusTool = new GitStatusTool();
const parse = (output: string) =>
(
statusTool as unknown as {
parseStatus: (o: string) => {
branch: string;
ahead: number;
behind: number;
staged: Array<{ status: string; file: string }>;
unstaged: Array<{ status: string; file: string }>;
untracked: string[];
clean: boolean;
};
}
).parseStatus(output);
it('分支行 + ahead/behind 解析', () => {
const r = parse('## main...origin/main [ahead 1, behind 2]\n');
expect(r.branch).toBe('main');
expect(r.ahead).toBe(1);
expect(r.behind).toBe(2);
expect(r.clean).toBe(true);
});
it('无 upstream 分支行', () => {
const r = parse('## feature-x\n');
expect(r.branch).toBe('feature-x');
expect(r.ahead).toBe(0);
expect(r.behind).toBe(0);
});
it('分离 HEAD 状态(no branch', () => {
const r = parse('## HEAD (no branch)\n');
expect(r.branch).toBe('HEAD');
});
it('staged A/M/D 与 unstaged M 各自归位', () => {
const r = parse(
[
'## main',
'A added.txt',
'M modified.txt',
'D deleted.txt',
' M work-modified.txt',
'?? untracked-1',
'?? untracked-2',
].join('\n'),
);
expect(r.staged.map((s) => `${s.status}:${s.file}`)).toEqual([
'A:added.txt',
'M:modified.txt',
'D:deleted.txt',
]);
expect(r.unstaged.map((s) => `${s.status}:${s.file}`)).toEqual(['M:work-modified.txt']);
expect(r.untracked).toEqual(['untracked-1', 'untracked-2']);
expect(r.clean).toBe(false);
});
it('工作区单独变更(X=空格)归入 unstaged', () => {
const r = parse('## main\n D deleted-in-worktree.txt\n');
expect(r.unstaged).toEqual([{ status: 'D', file: 'deleted-in-worktree.txt' }]);
expect(r.staged).toHaveLength(0);
});
it('重命名 R100 old -> new 取 new 文件名', () => {
const r = parse('## main\nR old.txt -> new.txt\n');
expect(r.staged).toEqual([{ status: 'R', file: 'new.txt' }]);
});
it('?? 未跟踪行不进入 staged/unstaged', () => {
const r = parse('## main\n?? only.txt\n');
expect(r.staged).toHaveLength(0);
expect(r.unstaged).toHaveLength(0);
expect(r.untracked).toEqual(['only.txt']);
});
it('空输出 → 空分支 + clean', () => {
const r = parse('');
expect(r.branch).toBe('');
expect(r.clean).toBe(true);
});
});
describe('GitLogTool.parseLog — oneline 与 NULL 分隔格式', () => {
const logTool = new GitLogTool();
const parse = (output: string, oneline: boolean) =>
(
logTool as unknown as {
parseLog: (
o: string,
oneline: boolean,
) => Array<{ hash: string; author?: string; date?: string; message: string }>;
}
).parseLog(output, oneline);
it('oneline 格式:hash + message 拆分', () => {
const r = parse('a1b2c3d first commit\nb2c3d4e second commit\n', true);
expect(r).toEqual([
{ hash: 'a1b2c3d', message: 'first commit' },
{ hash: 'b2c3d4e', message: 'second commit' },
]);
});
it('oneline 消息含空格保留完整', () => {
const r = parse('abc123 fix: resolve the weird bug #42\n', true);
expect(r[0].message).toBe('fix: resolve the weird bug #42');
});
it('oneline 无空格行跳过(无 hash 边界)', () => {
const r = parse('abcdef\n', true);
expect(r).toHaveLength(0);
});
it('NULL 分隔格式:hash/author/date/message 完整映射', () => {
const out = `a1b2c3d4e5f6g7\0Alice\0Sat Jun 1 12:00:00 2026 +0800\0feat: x\0`;
const r = parse(out, false);
expect(r[0]).toEqual({
hash: 'a1b2c3d4e5f6g7',
author: 'Alice',
date: 'Sat Jun 1 12:00:00 2026 +0800',
message: 'feat: x',
});
});
it('NULL 分隔字段不足 4 段跳过', () => {
const r = parse('hash\0author\0msg-only\n', false);
expect(r).toHaveLength(0);
});
it('空输出 → 空数组', () => {
expect(parse('', true)).toHaveLength(0);
expect(parse('', false)).toHaveLength(0);
});
});
// ===== 真实仓库集成 =====
describe('git_status / git_diff / git_log / git_commit(真实仓库)', () => {
// v0.7.4 回归修复: 共享临时仓库的顺序耦合 —— 每个用例前重置工作树,
// 消除对用例执行顺序的依赖(shuffle 下不再 flaky)。
beforeEach(() => {
runGitSilent('reset', '-q', '--hard', 'HEAD');
runGitSilent('clean', '-fd', '-q');
});
it('干净工作树:staged/unstaged 空 + branch 名非空', async () => {
const r = (await new GitStatusTool().execute({}, ctxOf())) as {
branch: string; ahead: number; behind: number;
staged: Array<unknown>; unstaged: Array<unknown>; untracked: unknown[]; clean: boolean;
branch: string;
ahead: number;
behind: number;
staged: Array<unknown>;
unstaged: Array<unknown>;
untracked: unknown[];
clean: boolean;
};
// 实况契约:直接返回数据载荷(无 success 包装),clean/staged/unstaged 为状态真值
expect(String(r.branch)).not.toBe('');
expect(r.staged).toHaveLength(0);
expect(r.unstaged).toHaveLength(0);
@@ -44,22 +201,52 @@ describe('git_status / git_diff / git_log / git_commit(真实仓库)', () =>
it('新文件 → untrackedgit add 后 → staged[A]HEAD 提交前 ahead=0', async () => {
writeFileSync(join(ws, 'mod.txt'), 'new\n');
const dirty = (await new GitStatusTool().execute({}, ctxOf())) as { untracked: Array<{ file?: string }>; staged: unknown[] };
expect(dirty.untracked).toContain('mod.txt'); // 实况契约:untracked 为字符串数组
const dirty = (await new GitStatusTool().execute({}, ctxOf())) as {
untracked: Array<{ file?: string }>;
staged: unknown[];
};
expect(dirty.untracked).toContain('mod.txt');
expect(dirty.staged).toHaveLength(0);
runGitSilent('add', '.');
const stagedR = (await new GitStatusTool().execute({}, ctxOf())) as {
staged: Array<{ status: string; file: string }>; untracked: unknown[]; ahead: number;
staged: Array<{ status: string; file: string }>;
untracked: unknown[];
ahead: number;
};
expect(stagedR.staged).toHaveLength(1);
expect(stagedR.staged[0].status).toBe('A');
expect(stagedR.ahead).toBeGreaterThanOrEqual(0);
});
it('git_status pathspec 只统计指定路径', async () => {
writeFileSync(join(ws, 'scope.ts'), 'a\n');
writeFileSync(join(ws, 'other.ts'), 'b\n');
runGitSilent('add', '.');
const scoped = (await new GitStatusTool().execute({ pathspec: 'scope.ts' }, ctxOf())) as {
staged: Array<{ file: string }>;
};
expect(scoped.staged.map((s) => s.file)).toEqual(['scope.ts']);
runGitSilent('reset', '-q');
runGitSilent('clean', '-fd', '-q');
});
it('git_status 工作区修改 → unstaged M', async () => {
writeFileSync(join(ws, 'mod-tracked.txt'), 'v1\n');
runGitSilent('add', 'mod-tracked.txt');
runGitSilent('commit', '-q', '-m', 'chore: add mod-tracked');
writeFileSync(join(ws, 'mod-tracked.txt'), 'v2\n');
const r = (await new GitStatusTool().execute({}, ctxOf())) as {
unstaged: Array<{ status: string; file: string }>;
};
expect(r.unstaged.some((u) => u.file === 'mod-tracked.txt' && u.status === 'M')).toBe(true);
runGitSilent('checkout', '-q', '--', 'mod-tracked.txt');
});
it('git_commit 提交暂存并更新 HEAD 信息', async () => {
const r = await new GitCommitTool().execute({ message: 'feat: mod file' }, ctxOf());
// 契约:提交后回传 commit/branch/committed 等摘要信息(以字段存在性锁定形态)
writeFileSync(join(ws, 'comm.txt'), 'x\n');
runGitSilent('add', 'comm.txt');
const r = await new GitCommitTool().execute({ message: 'feat: comm file' }, ctxOf());
const keys = Object.keys(r as object);
expect(keys.some((k) => /commit|hash/i.test(k))).toBe(true);
@@ -68,41 +255,217 @@ describe('git_status / git_diff / git_log / git_commit(真实仓库)', () =>
{ message: 'x', files: ['../outside.txt'] },
ctxOf(),
);
// 拒绝可能表现为 success:false 或 error 字段 —— 锁定"必须有失败信号"
const failureSignal =
(evil as { success?: boolean }).success === false || !!(evil as { error?: string }).error;
expect(failureSignal).toBe(true);
});
it('git_commit 无 message 且非 amend → 拒绝', async () => {
const r = (await new GitCommitTool().execute({}, ctxOf())) as {
success?: boolean;
error?: string;
};
expect(r.success).toBe(false);
expect(String(r.error)).toContain('Commit message is required');
});
it('git_commit message 全空白 → 拒绝', async () => {
const r = (await new GitCommitTool().execute({ message: ' ' }, ctxOf())) as {
success?: boolean;
};
expect(r.success).toBe(false);
});
it('git_commit files 限定只暂存指定文件', async () => {
writeFileSync(join(ws, 'sel-a.txt'), 'a');
writeFileSync(join(ws, 'sel-b.txt'), 'b');
runGitSilent('add', 'sel-a.txt');
const r = (await new GitCommitTool().execute(
{ message: 'sel a', files: ['sel-a.txt'] },
ctxOf(),
)) as {
success: boolean;
filesChanged?: number;
};
expect(r.success).toBe(true);
expect(r.filesChanged).toBe(1);
// sel-b.txt 仍未跟踪(未被 git add
const st = (await new GitStatusTool().execute({}, ctxOf())) as { untracked: string[] };
expect(st.untracked).toContain('sel-b.txt');
runGitSilent('clean', '-fd', '-q');
});
it('git_commit amend 不带 message → 保留原 message 且不 hang--no-edit', async () => {
writeFileSync(join(ws, 'amend.txt'), 'v1');
runGitSilent('add', 'amend.txt');
await new GitCommitTool().execute({ message: 'orig: amend base' }, ctxOf());
writeFileSync(join(ws, 'amend.txt'), 'v2');
runGitSilent('add', 'amend.txt');
const r = (await new GitCommitTool().execute({ amend: true }, ctxOf())) as {
success: boolean;
message?: string;
amended?: boolean;
};
expect(r.success).toBe(true);
expect(r.amended).toBe(true);
expect(String(r.message)).toContain('amended - original message preserved');
// HEAD message 仍是原始 message
const msg = execFileSync('git', ['-C', ws, 'log', '-1', '--format=%s'], {
encoding: 'utf-8',
}).trim();
expect(msg).toBe('orig: amend base');
});
it('git_commit amend 带新 message → 更新 message', async () => {
writeFileSync(join(ws, 'amend2.txt'), 'x');
runGitSilent('add', 'amend2.txt');
await new GitCommitTool().execute({ message: 'old msg' }, ctxOf());
const r = (await new GitCommitTool().execute({ message: 'new msg', amend: true }, ctxOf())) as {
success: boolean;
message?: string;
};
expect(r.success).toBe(true);
expect(r.message).toBe('new msg');
const msg = execFileSync('git', ['-C', ws, 'log', '-1', '--format=%s'], {
encoding: 'utf-8',
}).trim();
expect(msg).toBe('new msg');
});
it('git_commit amend + 空白 message → 拒绝', async () => {
const r = (await new GitCommitTool().execute({ message: ' ', amend: true }, ctxOf())) as {
success?: boolean;
};
expect(r.success).toBe(false);
});
it('git_commit files 含绝对路径越界 → 拒绝', async () => {
const abs = join(ws, '..', 'evil-outside.txt');
writeFileSync(abs, 'x');
const r = (await new GitCommitTool().execute({ message: 'x', files: [abs] }, ctxOf())) as {
success?: boolean;
error?: string;
};
expect(r.success).toBe(false);
expect(String(r.error)).toContain('outside workspace');
rmSync(abs, { force: true });
});
it('git_diff 默认工作树 vs HEADpatch 含 hunk 与 filesChangedpathspec 只看指定文件', async () => {
writeFileSync(join(ws, 'base2.txt'), 'orig\n');
runGitSilent('add', '.'); runGitSilent('commit', '-q', '-m', 'chore: base2');
runGitSilent('add', '.');
runGitSilent('commit', '-q', '-m', 'chore: base2');
writeFileSync(join(ws, 'base.txt'), 'line1\nCHANGED\n');
const r = (await new GitDiffTool().execute({}, ctxOf())) as { diff: string; filesChanged: number; truncated?: boolean };
const r = (await new GitDiffTool().execute({}, ctxOf())) as {
diff: string;
filesChanged: number;
truncated?: boolean;
};
expect(r.diff.includes('diff --git')).toBe(true);
expect(r.diff).toContain('@@');
expect(r.filesChanged).toBeGreaterThanOrEqual(1);
const scoped = (await new GitDiffTool().execute({ pathspec: 'base2.txt' }, ctxOf())) as { diff: string };
const scoped = (await new GitDiffTool().execute({ pathspec: 'base2.txt' }, ctxOf())) as {
diff: string;
};
expect(scoped.diff).not.toContain('CHANGED');
runGitSilent('checkout', '-q', '--', 'base.txt');
});
it('git_diff cached 只显示已暂存变更', async () => {
writeFileSync(join(ws, 'cached.txt'), 'v1\n');
runGitSilent('add', 'cached.txt');
writeFileSync(join(ws, 'cached.txt'), 'v2\n');
const cachedDiff = (await new GitDiffTool().execute({ cached: true }, ctxOf())) as {
diff: string;
};
expect(cachedDiff.diff).toContain('+v1');
expect(cachedDiff.diff).not.toContain('+v2');
runGitSilent('reset', '-q');
rmSync(join(ws, 'cached.txt'), { force: true });
});
it('git_diff contextLines 参数生效(--unified=N', async () => {
writeFileSync(join(ws, 'ctx.txt'), 'a\nb\nc\nd\ne\n');
runGitSilent('add', 'ctx.txt');
runGitSilent('commit', '-q', '-m', 'ctx base');
writeFileSync(join(ws, 'ctx.txt'), 'a\nb\nX\nd\ne\n');
const zero = (await new GitDiffTool().execute({ contextLines: 0 }, ctxOf())) as {
diff: string;
};
expect(zero.diff).toContain('@@');
runGitSilent('checkout', '-q', '--', 'ctx.txt');
});
it('git_diff pathspec 越界 → 拒绝', async () => {
const r = (await new GitDiffTool().execute({ pathspec: '../outside-repo/' }, ctxOf())) as {
success?: boolean;
error?: string;
};
expect(r.success).toBe(false);
expect(String(r.error)).toContain('outside workspace');
});
it('git_diff git magic pathspec:(glob))放行(M18 修正)', async () => {
const r = (await new GitDiffTool().execute({ pathspec: ':(glob)**/*.txt' }, ctxOf())) as {
diff: string;
};
expect(typeof r.diff).toBe('string');
});
it('git_diff 超大变更输出被截断(50KB)且 truncated=true', async () => {
writeFileSync(
join(ws, 'big-diff.txt'),
Array.from({ length: 4000 }, (_, i) => `old-line-${i}-${'x'.repeat(40)}`).join('\n'),
);
runGitSilent('add', 'big-diff.txt');
runGitSilent('commit', '-q', '-m', 'big base');
writeFileSync(
join(ws, 'big-diff.txt'),
Array.from({ length: 4000 }, (_, i) => `new-line-${i}-${'y'.repeat(40)}`).join('\n'),
);
const r = (await new GitDiffTool().execute({ pathspec: 'big-diff.txt' }, ctxOf())) as {
diff: string;
truncated: boolean;
filesChanged: number;
};
expect(r.truncated).toBe(true);
expect(r.diff.length).toBeLessThanOrEqual(50 * 1024);
expect(r.filesChanged).toBe(1);
runGitSilent('checkout', '-q', '--', 'big-diff.txt');
});
it('git_log 默认 oneline 与 limit、commits 元数据(hash+message', async () => {
const logTool = new GitLogTool();
const r = await logTool.execute({ limit: 5 }, ctxOf());
// 实况契约:返回 { commits:[{hash,message,...}], count, branch }
const payload = r as { commits: Array<{ hash: string; message: string }>; count?: number; branch?: string };
const payload = r as {
commits: Array<{ hash: string; message: string }>;
count?: number;
branch?: string;
};
expect(Array.isArray(payload.commits)).toBe(true);
expect(payload.commits.length).toBeGreaterThanOrEqual(1);
// 日志按时间倒序:最新为前一用例的 chore: base2;历史中含 feat: mod file
expect(String(payload.commits[0].message)).toContain('chore: base2');
const messages = payload.commits.map((c) => String(c.message)).join('\n');
expect(messages).toContain('feat: mod file');
expect(String(payload.commits[0].hash)).toMatch(/^[0-9a-f]{6,}$/);
const limited = await logTool.execute({ limit: 1 }, ctxOf());
expect(((limited as { commits: unknown[] }).commits).length).toBe(1);
expect((limited as { commits: unknown[] }).commits.length).toBe(1);
});
it('git_log oneline=false 返回 NULL 分隔字段(author/date 非空)', async () => {
const r = (await new GitLogTool().execute({ limit: 3, oneline: false }, ctxOf())) as {
commits: Array<{ hash: string; author: string; date: string; message: string }>;
};
expect(r.commits.length).toBeGreaterThanOrEqual(1);
expect(String(r.commits[0].author)).not.toBe('');
expect(String(r.commits[0].date)).not.toBe('');
});
it('git_log author 过滤只返回该作者提交', async () => {
const r = (await new GitLogTool().execute({ limit: 10, author: 'Metona Test' }, ctxOf())) as {
commits: Array<{ author?: string }>;
};
expect(r.commits.length).toBeGreaterThanOrEqual(1);
});
it('git_log pathspec 只返回触及该文件的提交', async () => {
@@ -110,7 +473,28 @@ describe('git_status / git_diff / git_log / git_commit(真实仓库)', () =>
runGitSilent('add', 'solo.txt');
runGitSilent('commit', '-q', '-m', 'chore: add solo');
const r = await new GitLogTool().execute({ pathspec: 'solo.txt' }, ctxOf());
const msgs = (r as { commits: Array<{ message: string }> }).commits.map((c) => String(c.message));
const msgs = (r as { commits: Array<{ message: string }> }).commits.map((c) =>
String(c.message),
);
expect(msgs.join('\n')).toContain('solo');
});
it('git_status 非 git 仓库 → 友好错误', async () => {
const plain = mkdtempSync(join(tmpdir(), 'metona-notgit-'));
writeFileSync(join(plain, 'f.txt'), 'x');
try {
const r = (await new GitStatusTool().execute(
{},
{
sessionId: 't',
workspacePath: plain,
iteration: 1,
requestId: 'r',
},
)) as { success?: boolean; error?: string };
expect(r.success).toBe(false);
} finally {
rmSync(plain, { recursive: true, force: true });
}
});
});