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)
501 lines
18 KiB
TypeScript
501 lines
18 KiB
TypeScript
/**
|
||
* Git 四工具真实夹具套件(v0.7.0 覆盖补齐 → v0.7.5 扩充)
|
||
* 临时仓库内走真实 git —— porcelain 解析、diff 截断、log NULL 字段、
|
||
* commit 白名单路径、amend 防 hang、runGit 参数数组防注入。
|
||
*/
|
||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
|
||
import { tmpdir } from 'os';
|
||
import { join } from 'path';
|
||
import { execFileSync } from 'child_process';
|
||
|
||
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 runGitSilent = (...a: string[]): void => {
|
||
execFileSync('git', ['-C', ws, ...a], { stdio: ['ignore', 'ignore', 'pipe'] });
|
||
};
|
||
|
||
beforeAll(() => {
|
||
ws = mkdtempSync(join(tmpdir(), 'metona-git-'));
|
||
runGitSilent('init', '-q');
|
||
runGitSilent('config', 'user.email', 'test@metona.local');
|
||
runGitSilent('config', 'user.name', 'Metona Test');
|
||
writeFileSync(join(ws, 'base.txt'), 'line1\nline2\n');
|
||
runGitSilent('add', '.');
|
||
runGitSilent('commit', '-q', '-m', 'chore: initial');
|
||
});
|
||
|
||
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;
|
||
};
|
||
expect(String(r.branch)).not.toBe('');
|
||
expect(r.staged).toHaveLength(0);
|
||
expect(r.unstaged).toHaveLength(0);
|
||
expect(r.clean).toBe(true);
|
||
});
|
||
|
||
it('新文件 → untracked;git 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');
|
||
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;
|
||
};
|
||
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 () => {
|
||
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);
|
||
|
||
// files 白名单校验:越界文件被拒(WARN-1 路径校验)
|
||
const evil = await new GitCommitTool().execute(
|
||
{ message: 'x', files: ['../outside.txt'] },
|
||
ctxOf(),
|
||
);
|
||
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 HEAD:patch 含 hunk 与 filesChanged;pathspec 只看指定文件', async () => {
|
||
writeFileSync(join(ws, 'base2.txt'), 'orig\n');
|
||
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;
|
||
};
|
||
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;
|
||
};
|
||
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());
|
||
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);
|
||
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);
|
||
});
|
||
|
||
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 () => {
|
||
writeFileSync(join(ws, 'solo.txt'), 'solo\n');
|
||
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),
|
||
);
|
||
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 });
|
||
}
|
||
});
|
||
});
|