5e32375f0e
架构变更: - 采用 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 - 配置管理
577 lines
13 KiB
TypeScript
577 lines
13 KiB
TypeScript
/**
|
|
* 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);
|
|
}
|