Files
ai-terminal-assistant/tests/git/git.test.ts
T
kurihada 2208179514 feat: 实现 Git 深度集成
参考 aider 的实现,添加 Git 深度集成功能:

- GitRepo: 封装 simple-git 的基础 Git 操作
- AutoCommitManager: 支持 immediate/batch/manual 三种自动提交模式
- MessageGenerator: 智能生成 conventional/simple/detailed 格式的 commit message
- UndoManager: 安全的 undo 机制,仅允许撤销 AI 生成的提交

主要特性:
- 文件变更后自动提交 (batch 模式默认 3 秒延迟)
- 智能检测变更类型 (feat/fix/docs/test/chore 等)
- 自动检测 scope (从文件路径推断)
- 多重安全检查的 undo 机制
- 追踪 AI 生成的提交,防止误撤销用户提交
- 与 Hook 系统集成

添加 simple-git 依赖
编写完整测试用例 (26 个测试)
2025-12-11 23:41:51 +08:00

433 lines
12 KiB
TypeScript

/**
* Git 深度集成测试
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as path from 'path';
import * as fs from 'fs/promises';
import * as os from 'os';
import { execSync } from 'child_process';
import {
GitRepo,
GitManager,
MessageGenerator,
UndoManager,
initGitManager,
getGitManager,
resetGitManager,
DEFAULT_GIT_CONFIG,
type DiffResult,
} from '../../src/git/index.js';
describe('MessageGenerator', () => {
const generator = new MessageGenerator({
style: 'conventional',
includeFileList: true,
maxLength: 72,
useAI: false,
});
it('should generate conventional commit message', () => {
const diff: DiffResult = {
files: [
{ path: 'src/test.ts', status: 'modified', hunks: [], binary: false },
],
stats: { filesChanged: 1, insertions: 10, deletions: 5 },
};
const message = generator.generate(diff, ['src/test.ts']);
expect(message).toMatch(/^(feat|fix|chore|test|docs|style|refactor)/);
expect(message).toContain('test');
});
it('should detect test type for test files', () => {
const diff: DiffResult = {
files: [
{ path: 'tests/foo.test.ts', status: 'added', hunks: [], binary: false },
],
stats: { filesChanged: 1, insertions: 50, deletions: 0 },
};
const message = generator.generate(diff, ['tests/foo.test.ts']);
expect(message).toMatch(/^test/);
});
it('should detect docs type for markdown files', () => {
const diff: DiffResult = {
files: [
{ path: 'README.md', status: 'modified', hunks: [], binary: false },
],
stats: { filesChanged: 1, insertions: 20, deletions: 10 },
};
const message = generator.generate(diff, ['README.md']);
expect(message).toMatch(/^docs/);
});
it('should include scope when files are in same directory', () => {
const diff: DiffResult = {
files: [
{ path: 'src/git/repo.ts', status: 'modified', hunks: [], binary: false },
{ path: 'src/git/types.ts', status: 'modified', hunks: [], binary: false },
],
stats: { filesChanged: 2, insertions: 30, deletions: 20 },
};
const message = generator.generate(diff, ['src/git/repo.ts', 'src/git/types.ts']);
expect(message).toContain('(git)');
});
it('should generate simple format message', () => {
const simpleGenerator = new MessageGenerator({
style: 'simple',
includeFileList: false,
maxLength: 72,
useAI: false,
});
const diff: DiffResult = {
files: [
{ path: 'src/index.ts', status: 'modified', hunks: [], binary: false },
],
stats: { filesChanged: 1, insertions: 5, deletions: 2 },
};
const message = simpleGenerator.generate(diff, ['src/index.ts']);
expect(message).toContain('index');
expect(message.length).toBeLessThanOrEqual(72);
});
});
describe('GitRepo', () => {
let tempDir: string;
let repo: GitRepo;
beforeEach(async () => {
// 创建临时目录
tempDir = path.join(os.tmpdir(), `git-test-${Date.now()}`);
await fs.mkdir(tempDir, { recursive: true });
// 初始化 Git 仓库
execSync('git init', { cwd: tempDir, stdio: 'ignore' });
execSync('git config user.email "test@test.com"', { cwd: tempDir, stdio: 'ignore' });
execSync('git config user.name "Test User"', { cwd: tempDir, stdio: 'ignore' });
// 创建初始提交
await fs.writeFile(path.join(tempDir, 'README.md'), '# Test');
execSync('git add .', { cwd: tempDir, stdio: 'ignore' });
execSync('git commit -m "Initial commit"', { cwd: tempDir, stdio: 'ignore' });
repo = new GitRepo(tempDir, DEFAULT_GIT_CONFIG);
await repo.initialize();
});
afterEach(async () => {
try {
await fs.rm(tempDir, { recursive: true, force: true });
} catch {
// ignore cleanup errors
}
});
it('should initialize and detect git repo', async () => {
const isRepo = await repo.initialize();
expect(isRepo).toBe(true);
});
it('should get status', async () => {
const status = await repo.getStatus();
expect(status.branch).toMatch(/^(main|master)$/);
expect(status.isDirty).toBe(false);
});
it('should detect dirty files', async () => {
await fs.writeFile(path.join(tempDir, 'new.txt'), 'content');
const isDirty = await repo.isDirty();
expect(isDirty).toBe(true);
});
it('should get diff', async () => {
await fs.writeFile(path.join(tempDir, 'README.md'), '# Test\n\nUpdated');
const diff = await repo.getDiff();
expect(diff.files.length).toBe(1);
expect(diff.files[0].path).toBe('README.md');
});
it('should commit changes', async () => {
await fs.writeFile(path.join(tempDir, 'new.txt'), 'new content');
const result = await repo.commit({
files: ['new.txt'],
message: 'Add new file',
aiEdits: true,
});
expect(result.success).toBe(true);
expect(result.shortHash).toBeDefined();
expect(repo.isAICommit(result.shortHash!)).toBe(true);
});
it('should get recent commits', async () => {
const commits = await repo.getRecentCommits(5);
expect(commits.length).toBeGreaterThanOrEqual(1);
expect(commits[0].message).toBe('Initial commit');
});
it('should get HEAD commit', async () => {
const head = await repo.getHeadCommit();
expect(head).toBeDefined();
expect(head!.length).toBe(40);
});
it('should get current branch', async () => {
const branch = await repo.getCurrentBranch();
expect(['main', 'master']).toContain(branch);
});
});
describe('UndoManager', () => {
let tempDir: string;
let repo: GitRepo;
let undoManager: UndoManager;
beforeEach(async () => {
tempDir = path.join(os.tmpdir(), `undo-test-${Date.now()}`);
await fs.mkdir(tempDir, { recursive: true });
execSync('git init', { cwd: tempDir, stdio: 'ignore' });
execSync('git config user.email "test@test.com"', { cwd: tempDir, stdio: 'ignore' });
execSync('git config user.name "Test User"', { cwd: tempDir, stdio: 'ignore' });
await fs.writeFile(path.join(tempDir, 'README.md'), '# Test');
execSync('git add .', { cwd: tempDir, stdio: 'ignore' });
execSync('git commit -m "Initial commit"', { cwd: tempDir, stdio: 'ignore' });
repo = new GitRepo(tempDir, DEFAULT_GIT_CONFIG);
await repo.initialize();
undoManager = new UndoManager(repo, { maxHistory: 50 });
});
afterEach(async () => {
try {
await fs.rm(tempDir, { recursive: true, force: true });
} catch {
// ignore
}
});
it('should return error when nothing to undo', async () => {
const result = await undoManager.undo();
expect(result.success).toBe(false);
expect(result.message).toContain('Nothing to undo');
});
it('should record and undo AI commit', async () => {
// 创建 AI 提交
await fs.writeFile(path.join(tempDir, 'new.txt'), 'content');
const commitResult = await repo.commit({
files: ['new.txt'],
message: 'Add new file',
aiEdits: true,
});
expect(commitResult.success).toBe(true);
// 记录到 undo 历史
undoManager.recordCommit(
commitResult.hash!,
commitResult.shortHash!,
commitResult.message!,
['new.txt']
);
// 执行 undo
const undoResult = await undoManager.undo();
expect(undoResult.success).toBe(true);
expect(undoResult.commitHash).toBe(commitResult.shortHash);
});
it('should reject undo when repository changed', async () => {
// 创建 AI 提交
await fs.writeFile(path.join(tempDir, 'new.txt'), 'content');
const commitResult = await repo.commit({
files: ['new.txt'],
message: 'Add new file',
aiEdits: true,
});
undoManager.recordCommit(
commitResult.hash!,
commitResult.shortHash!,
commitResult.message!,
['new.txt']
);
// 在 AI 提交后又创建了一个手动提交
await fs.writeFile(path.join(tempDir, 'manual.txt'), 'manual');
execSync('git add .', { cwd: tempDir, stdio: 'ignore' });
execSync('git commit -m "Manual commit"', { cwd: tempDir, stdio: 'ignore' });
// 尝试 undo
const undoResult = await undoManager.undo();
expect(undoResult.success).toBe(false);
expect(undoResult.message).toContain('does not match');
});
it('should get undo preview', () => {
// 没有记录时返回 null
expect(undoManager.getUndoPreview()).toBeNull();
// 记录后可以获取预览
undoManager.recordCommit('abc1234', 'abc1234', 'Test commit', ['test.txt']);
const preview = undoManager.getUndoPreview();
expect(preview).not.toBeNull();
expect(preview!.message).toBe('Test commit');
});
it('should check if can undo', async () => {
let canUndo = await undoManager.canUndo();
expect(canUndo.canUndo).toBe(false);
// 创建并记录 AI 提交
await fs.writeFile(path.join(tempDir, 'new.txt'), 'content');
const commitResult = await repo.commit({
files: ['new.txt'],
message: 'Add new file',
aiEdits: true,
});
undoManager.recordCommit(
commitResult.hash!,
commitResult.shortHash!,
commitResult.message!,
['new.txt']
);
canUndo = await undoManager.canUndo();
expect(canUndo.canUndo).toBe(true);
});
});
describe('GitManager', () => {
let tempDir: string;
beforeEach(async () => {
resetGitManager();
tempDir = path.join(os.tmpdir(), `manager-test-${Date.now()}`);
await fs.mkdir(tempDir, { recursive: true });
execSync('git init', { cwd: tempDir, stdio: 'ignore' });
execSync('git config user.email "test@test.com"', { cwd: tempDir, stdio: 'ignore' });
execSync('git config user.name "Test User"', { cwd: tempDir, stdio: 'ignore' });
await fs.writeFile(path.join(tempDir, 'README.md'), '# Test');
execSync('git add .', { cwd: tempDir, stdio: 'ignore' });
execSync('git commit -m "Initial commit"', { cwd: tempDir, stdio: 'ignore' });
});
afterEach(async () => {
resetGitManager();
try {
await fs.rm(tempDir, { recursive: true, force: true });
} catch {
// ignore
}
});
it('should initialize git manager', async () => {
const manager = await initGitManager(tempDir);
expect(manager).not.toBeNull();
expect(manager!.isInitialized()).toBe(true);
});
it('should return null for non-git directory', async () => {
const nonGitDir = path.join(os.tmpdir(), `non-git-${Date.now()}`);
await fs.mkdir(nonGitDir, { recursive: true });
const manager = await initGitManager(nonGitDir);
expect(manager).toBeNull();
await fs.rm(nonGitDir, { recursive: true, force: true });
});
it('should get global manager', async () => {
expect(getGitManager()).toBeNull();
await initGitManager(tempDir);
expect(getGitManager()).not.toBeNull();
});
it('should get status', async () => {
const manager = await initGitManager(tempDir);
const status = await manager!.getStatus();
expect(status.branch).toMatch(/^(main|master)$/);
expect(status.isDirty).toBe(false);
});
it('should commit manually', async () => {
const manager = await initGitManager(tempDir);
await fs.writeFile(path.join(tempDir, 'new.txt'), 'content');
const result = await manager!.commit({
files: ['new.txt'],
message: 'Add new file',
});
expect(result.success).toBe(true);
expect(result.shortHash).toBeDefined();
});
it('should undo AI commit', async () => {
const manager = await initGitManager(tempDir);
// 创建并提交文件
await fs.writeFile(path.join(tempDir, 'new.txt'), 'content');
const commitResult = await manager!.commit({
files: ['new.txt'],
message: 'Add new file',
});
expect(commitResult.success).toBe(true);
// 执行 undo
const undoResult = await manager!.undo();
expect(undoResult.success).toBe(true);
});
it('should get undo history', async () => {
const manager = await initGitManager(tempDir);
expect(manager!.getUndoHistory()).toHaveLength(0);
await fs.writeFile(path.join(tempDir, 'new.txt'), 'content');
await manager!.commit({
files: ['new.txt'],
message: 'Add new file',
});
expect(manager!.getUndoHistory()).toHaveLength(1);
});
it('should emit events', async () => {
const manager = await initGitManager(tempDir);
const events: any[] = [];
manager!.addEventListener((event) => events.push(event));
await fs.writeFile(path.join(tempDir, 'new.txt'), 'content');
await manager!.commit({
files: ['new.txt'],
message: 'Add new file',
});
expect(events.length).toBe(1);
expect(events[0].type).toBe('commit');
});
});