/** * Git 四工具真实夹具套件(v0.7.0 覆盖补齐) * 临时仓库内走真实 git —— porcelain 解析、diff 截断、log NULL 字段、commit 白名单路径。 */ import { describe, it, expect, beforeAll, afterAll } 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 })); describe('git_status / git_diff / git_log / git_commit(真实仓库)', () => { 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; }; // 实况契约:直接返回数据载荷(无 success 包装),clean/staged/unstaged 为状态真值 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'); // 实况契约:untracked 为字符串数组 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_commit 提交暂存并更新 HEAD 信息', async () => { const r = await new GitCommitTool().execute({ message: 'feat: mod file' }, ctxOf()); // 契约:提交后回传 commit/branch/committed 等摘要信息(以字段存在性锁定形态) 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(), ); // 拒绝可能表现为 success:false 或 error 字段 —— 锁定"必须有失败信号" const failureSignal = (evil as { success?: boolean }).success === false || !!(evil as { error?: string }).error; expect(failureSignal).toBe(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'); }); 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 }; 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); }); 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'); }); });