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,576 @@
|
||||
/**
|
||||
* Shadow Git 存储实现
|
||||
* 使用隔离的 Git 仓库存储检查点,不影响用户的主仓库
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import type { FileChange, DiffInfo, FileDiff } from './types.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* 计算工作目录哈希
|
||||
* 使用 31 进制哈希算法,与 Cline 保持一致
|
||||
*/
|
||||
export function hashWorkingDir(workingDir: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < workingDir.length; i++) {
|
||||
hash = ((hash * 31 + workingDir.charCodeAt(i)) >>> 0) % 2147483647;
|
||||
}
|
||||
return hash.toString().slice(0, 13).padStart(13, '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* 需要排除的目录列表
|
||||
*/
|
||||
const EXCLUDED_DIRS = [
|
||||
'node_modules',
|
||||
'.git',
|
||||
'dist',
|
||||
'build',
|
||||
'.next',
|
||||
'__pycache__',
|
||||
'.pytest_cache',
|
||||
'coverage',
|
||||
'.nyc_output',
|
||||
'.ai-assist',
|
||||
];
|
||||
|
||||
/**
|
||||
* Shadow Git 管理器
|
||||
*/
|
||||
export class ShadowGit {
|
||||
private workDir: string;
|
||||
private shadowGitDir: string;
|
||||
private initialized = false;
|
||||
private cwdHash: string;
|
||||
|
||||
constructor(workDir: string, storageBaseDir: string) {
|
||||
this.workDir = path.resolve(workDir);
|
||||
this.cwdHash = hashWorkingDir(this.workDir);
|
||||
this.shadowGitDir = path.join(storageBaseDir, this.cwdHash);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工作目录哈希
|
||||
*/
|
||||
getCwdHash(): string {
|
||||
return this.cwdHash;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Shadow Git 目录
|
||||
*/
|
||||
getShadowGitDir(): string {
|
||||
return this.shadowGitDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 Shadow Git 仓库
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
const gitDir = path.join(this.shadowGitDir, '.git');
|
||||
|
||||
try {
|
||||
await fs.access(gitDir);
|
||||
// 已存在,验证配置
|
||||
await this.verifyConfig();
|
||||
} catch {
|
||||
// 不存在,创建新仓库
|
||||
await this.createRepository();
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新的 Shadow Git 仓库
|
||||
*/
|
||||
private async createRepository(): Promise<void> {
|
||||
// 创建目录
|
||||
await fs.mkdir(this.shadowGitDir, { recursive: true });
|
||||
|
||||
// 初始化 Git 仓库
|
||||
await this.git(['init']);
|
||||
|
||||
// 配置用户信息
|
||||
await this.git(['config', 'user.name', 'AI Assistant Checkpoint']);
|
||||
await this.git(['config', 'user.email', 'checkpoint@ai-assist.local']);
|
||||
|
||||
// 配置工作目录
|
||||
await this.git(['config', 'core.worktree', this.workDir]);
|
||||
|
||||
// 禁用 GPG 签名
|
||||
await this.git(['config', 'commit.gpgsign', 'false']);
|
||||
|
||||
// 创建 .gitignore
|
||||
const gitignoreContent = EXCLUDED_DIRS.map((d) => `${d}/`).join('\n') + '\n';
|
||||
await fs.writeFile(
|
||||
path.join(this.shadowGitDir, '.gitignore'),
|
||||
gitignoreContent
|
||||
);
|
||||
|
||||
// 创建初始提交
|
||||
await this.git(['add', '.gitignore']);
|
||||
await this.git([
|
||||
'commit',
|
||||
'--allow-empty',
|
||||
'-m',
|
||||
'Initial checkpoint repository',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证现有配置
|
||||
*/
|
||||
private async verifyConfig(): Promise<void> {
|
||||
try {
|
||||
const { stdout } = await this.git(['config', 'core.worktree']);
|
||||
const configuredWorkDir = stdout.trim();
|
||||
|
||||
if (configuredWorkDir !== this.workDir) {
|
||||
// 更新工作目录配置
|
||||
await this.git(['config', 'core.worktree', this.workDir]);
|
||||
}
|
||||
} catch {
|
||||
// 配置不存在,添加
|
||||
await this.git(['config', 'core.worktree', this.workDir]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 Git 命令
|
||||
*/
|
||||
private async git(
|
||||
args: string[],
|
||||
options: { cwd?: string } = {}
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
const cwd = options.cwd || this.shadowGitDir;
|
||||
const gitDir = path.join(this.shadowGitDir, '.git');
|
||||
|
||||
try {
|
||||
const result = await execFileAsync(
|
||||
'git',
|
||||
['--git-dir', gitDir, '--work-tree', this.workDir, ...args],
|
||||
{
|
||||
cwd,
|
||||
maxBuffer: 50 * 1024 * 1024, // 50MB
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_TERMINAL_PROMPT: '0',
|
||||
},
|
||||
}
|
||||
);
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
// 某些 git 命令失败是正常的 (如空提交)
|
||||
if (error.stdout !== undefined) {
|
||||
return { stdout: error.stdout || '', stderr: error.stderr || '' };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建检查点提交
|
||||
*/
|
||||
async createCommit(message: string): Promise<string> {
|
||||
await this.initialize();
|
||||
|
||||
// 暂时禁用嵌套 .git 目录
|
||||
const nestedGitDirs = await this.findNestedGitDirs();
|
||||
await this.renameNestedGitDirs(nestedGitDirs, true);
|
||||
|
||||
try {
|
||||
// 添加所有文件
|
||||
await this.git(['add', '.', '--ignore-errors']);
|
||||
|
||||
// 创建提交
|
||||
await this.git([
|
||||
'commit',
|
||||
'--allow-empty',
|
||||
'--no-verify',
|
||||
'-m',
|
||||
message,
|
||||
]);
|
||||
|
||||
// 获取 commit hash
|
||||
const { stdout } = await this.git(['rev-parse', 'HEAD']);
|
||||
return stdout.trim();
|
||||
} finally {
|
||||
// 恢复嵌套 .git 目录
|
||||
await this.renameNestedGitDirs(nestedGitDirs, false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找嵌套的 .git 目录
|
||||
*/
|
||||
private async findNestedGitDirs(): Promise<string[]> {
|
||||
const nestedDirs: string[] = [];
|
||||
|
||||
const walk = async (dir: string, depth = 0): Promise<void> => {
|
||||
if (depth > 5) return; // 限制深度
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
|
||||
// 跳过排除的目录
|
||||
if (EXCLUDED_DIRS.includes(entry.name)) continue;
|
||||
|
||||
if (entry.name === '.git') {
|
||||
nestedDirs.push(fullPath);
|
||||
} else if (!entry.name.startsWith('.')) {
|
||||
await walk(fullPath, depth + 1);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 忽略无法访问的目录
|
||||
}
|
||||
};
|
||||
|
||||
await walk(this.workDir);
|
||||
return nestedDirs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重命名嵌套 .git 目录
|
||||
*/
|
||||
private async renameNestedGitDirs(
|
||||
dirs: string[],
|
||||
disable: boolean
|
||||
): Promise<void> {
|
||||
for (const dir of dirs) {
|
||||
const disabledName = dir + '_disabled';
|
||||
try {
|
||||
if (disable) {
|
||||
await fs.rename(dir, disabledName);
|
||||
} else {
|
||||
await fs.rename(disabledName, dir);
|
||||
}
|
||||
} catch {
|
||||
// 忽略错误
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前 HEAD commit hash
|
||||
*/
|
||||
async getHead(): Promise<string> {
|
||||
await this.initialize();
|
||||
const { stdout } = await this.git(['rev-parse', 'HEAD']);
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置到指定 commit
|
||||
*/
|
||||
async resetHard(commitHash: string): Promise<void> {
|
||||
await this.initialize();
|
||||
|
||||
// 暂时禁用嵌套 .git 目录
|
||||
const nestedGitDirs = await this.findNestedGitDirs();
|
||||
await this.renameNestedGitDirs(nestedGitDirs, true);
|
||||
|
||||
try {
|
||||
await this.git(['reset', '--hard', commitHash]);
|
||||
} finally {
|
||||
await this.renameNestedGitDirs(nestedGitDirs, false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 commit 列表
|
||||
*/
|
||||
async getCommits(limit = 100): Promise<
|
||||
Array<{
|
||||
hash: string;
|
||||
message: string;
|
||||
timestamp: number;
|
||||
}>
|
||||
> {
|
||||
await this.initialize();
|
||||
|
||||
const { stdout } = await this.git([
|
||||
'log',
|
||||
`--max-count=${limit}`,
|
||||
'--format=%H|%s|%ct',
|
||||
]);
|
||||
|
||||
if (!stdout.trim()) return [];
|
||||
|
||||
return stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
const [hash, message, timestamp] = line.split('|');
|
||||
return {
|
||||
hash,
|
||||
message,
|
||||
timestamp: parseInt(timestamp, 10) * 1000,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取两个 commit 之间的差异摘要
|
||||
*/
|
||||
async getDiffSummary(fromCommit: string, toCommit = 'HEAD'): Promise<DiffInfo> {
|
||||
await this.initialize();
|
||||
|
||||
const { stdout } = await this.git([
|
||||
'diff',
|
||||
'--stat',
|
||||
'--numstat',
|
||||
fromCommit,
|
||||
toCommit,
|
||||
]);
|
||||
|
||||
const files: FileChange[] = [];
|
||||
let totalInsertions = 0;
|
||||
let totalDeletions = 0;
|
||||
|
||||
// 解析 numstat 输出
|
||||
const lines = stdout.trim().split('\n');
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/);
|
||||
if (match) {
|
||||
const insertions = match[1] === '-' ? 0 : parseInt(match[1], 10);
|
||||
const deletions = match[2] === '-' ? 0 : parseInt(match[2], 10);
|
||||
const filePath = match[3];
|
||||
|
||||
// 检测重命名
|
||||
const renameMatch = filePath.match(/^(.+)\{(.+) => (.+)\}(.*)$/);
|
||||
if (renameMatch) {
|
||||
const prefix = renameMatch[1];
|
||||
const oldName = renameMatch[2];
|
||||
const newName = renameMatch[3];
|
||||
const suffix = renameMatch[4];
|
||||
files.push({
|
||||
path: prefix + newName + suffix,
|
||||
oldPath: prefix + oldName + suffix,
|
||||
type: 'renamed',
|
||||
insertions,
|
||||
deletions,
|
||||
});
|
||||
} else {
|
||||
// 获取文件状态
|
||||
const type = await this.getFileChangeType(fromCommit, toCommit, filePath);
|
||||
files.push({
|
||||
path: filePath,
|
||||
type,
|
||||
insertions,
|
||||
deletions,
|
||||
});
|
||||
}
|
||||
|
||||
totalInsertions += insertions;
|
||||
totalDeletions += deletions;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
from: fromCommit,
|
||||
to: toCommit,
|
||||
files,
|
||||
totalInsertions,
|
||||
totalDeletions,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件变更类型
|
||||
*/
|
||||
private async getFileChangeType(
|
||||
fromCommit: string,
|
||||
toCommit: string,
|
||||
filePath: string
|
||||
): Promise<FileChange['type']> {
|
||||
try {
|
||||
const { stdout } = await this.git([
|
||||
'diff',
|
||||
'--name-status',
|
||||
fromCommit,
|
||||
toCommit,
|
||||
'--',
|
||||
filePath,
|
||||
]);
|
||||
|
||||
const status = stdout.trim().charAt(0);
|
||||
switch (status) {
|
||||
case 'A':
|
||||
return 'added';
|
||||
case 'D':
|
||||
return 'deleted';
|
||||
case 'R':
|
||||
return 'renamed';
|
||||
default:
|
||||
return 'modified';
|
||||
}
|
||||
} catch {
|
||||
return 'modified';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件内容差异
|
||||
*/
|
||||
async getFileDiff(
|
||||
fromCommit: string,
|
||||
toCommit: string,
|
||||
filePath: string
|
||||
): Promise<FileDiff> {
|
||||
await this.initialize();
|
||||
|
||||
const type = await this.getFileChangeType(fromCommit, toCommit, filePath);
|
||||
|
||||
let oldContent: string | undefined;
|
||||
let newContent: string | undefined;
|
||||
let patch: string | undefined;
|
||||
|
||||
try {
|
||||
if (type !== 'added') {
|
||||
const { stdout } = await this.git(['show', `${fromCommit}:${filePath}`]);
|
||||
oldContent = stdout;
|
||||
}
|
||||
} catch {
|
||||
// 文件在旧 commit 中不存在
|
||||
}
|
||||
|
||||
try {
|
||||
if (type !== 'deleted') {
|
||||
const { stdout } = await this.git(['show', `${toCommit}:${filePath}`]);
|
||||
newContent = stdout;
|
||||
}
|
||||
} catch {
|
||||
// 文件在新 commit 中不存在
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await this.git([
|
||||
'diff',
|
||||
fromCommit,
|
||||
toCommit,
|
||||
'--',
|
||||
filePath,
|
||||
]);
|
||||
patch = stdout;
|
||||
} catch {
|
||||
// 无法生成 diff
|
||||
}
|
||||
|
||||
return {
|
||||
path: filePath,
|
||||
type,
|
||||
oldContent,
|
||||
newContent,
|
||||
patch,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 检出指定 commit 的特定文件
|
||||
*/
|
||||
async checkoutFiles(commitHash: string, files: string[]): Promise<void> {
|
||||
await this.initialize();
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
await this.git(['checkout', commitHash, '--', file]);
|
||||
} catch (error) {
|
||||
// 文件可能不存在于该 commit
|
||||
console.warn(`Failed to checkout ${file} from ${commitHash}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定 commit 中文件的内容
|
||||
*/
|
||||
async getFileContent(commitHash: string, filePath: string): Promise<string | null> {
|
||||
await this.initialize();
|
||||
|
||||
try {
|
||||
const { stdout } = await this.git(['show', `${commitHash}:${filePath}`]);
|
||||
return stdout;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理旧的 commit(保留最近 N 个)
|
||||
*/
|
||||
async cleanup(keepCount: number): Promise<number> {
|
||||
await this.initialize();
|
||||
|
||||
const commits = await this.getCommits(keepCount + 100);
|
||||
|
||||
if (commits.length <= keepCount) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 使用 git gc 清理
|
||||
try {
|
||||
await this.git(['gc', '--aggressive', '--prune=now']);
|
||||
} catch {
|
||||
// gc 可能失败,忽略
|
||||
}
|
||||
|
||||
return commits.length - keepCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有未提交的变更
|
||||
*/
|
||||
async hasChanges(): Promise<boolean> {
|
||||
await this.initialize();
|
||||
|
||||
const { stdout } = await this.git(['status', '--porcelain']);
|
||||
return stdout.trim().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工作目录与 HEAD 的差异
|
||||
*/
|
||||
async getWorkingDirDiff(): Promise<DiffInfo> {
|
||||
await this.initialize();
|
||||
|
||||
// 先添加所有文件到暂存区以检测新文件
|
||||
const nestedGitDirs = await this.findNestedGitDirs();
|
||||
await this.renameNestedGitDirs(nestedGitDirs, true);
|
||||
|
||||
try {
|
||||
await this.git(['add', '.', '--ignore-errors']);
|
||||
const result = await this.getDiffSummary('HEAD', '--staged');
|
||||
|
||||
// 重置暂存区
|
||||
await this.git(['reset', 'HEAD']);
|
||||
|
||||
return result;
|
||||
} finally {
|
||||
await this.renameNestedGitDirs(nestedGitDirs, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Shadow Git 实例
|
||||
*/
|
||||
export function createShadowGit(
|
||||
workDir: string,
|
||||
storageBaseDir: string
|
||||
): ShadowGit {
|
||||
return new ShadowGit(workDir, storageBaseDir);
|
||||
}
|
||||
Reference in New Issue
Block a user