/** * 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 { 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 { // 创建目录 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 { 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 { 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 { const nestedDirs: string[] = []; const walk = async (dir: string, depth = 0): Promise => { 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 { 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 { await this.initialize(); const { stdout } = await this.git(['rev-parse', 'HEAD']); return stdout.trim(); } /** * 重置到指定 commit */ async resetHard(commitHash: string): Promise { 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 { 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 { 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 { 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 { 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 { await this.initialize(); try { const { stdout } = await this.git(['show', `${commitHash}:${filePath}`]); return stdout; } catch { return null; } } /** * 清理旧的 commit(保留最近 N 个) */ async cleanup(keepCount: number): Promise { 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 { await this.initialize(); const { stdout } = await this.git(['status', '--porcelain']); return stdout.trim().length > 0; } /** * 获取工作目录与 HEAD 的差异 */ async getWorkingDirDiff(): Promise { 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); }