/** * 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; unstaged: Array; 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 }); } }); });