feat: 重构为 Monorepo 架构并实现 HTTP Server
架构变更: - 采用 pnpm workspaces 实现 Monorepo 结构 - 将现有代码迁移到 packages/core - 新增 packages/server HTTP 服务层 Server 功能: - REST API: 会话管理、工具管理、配置管理 - WebSocket: 实时双向通信支持 - SSE: 服务端事件推送 - Hono + Bun 作为运行时 API 端点: - GET/POST /api/sessions - 会话 CRUD - GET/POST /api/sessions/:id/messages - 消息管理 - GET /api/sessions/:id/events - SSE 事件流 - WS /api/ws/:sessionId - WebSocket 连接 - GET/POST /api/tools - 工具管理 - GET/PUT /api/config - 配置管理
This commit is contained in:
@@ -0,0 +1,432 @@
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user