2208179514
参考 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 个测试)
433 lines
12 KiB
TypeScript
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');
|
|
});
|
|
});
|