/** * 光湖知识库 · Git 引擎层 * * 底层存储 = Git 仓库,不依赖任何数据库。 * 每个文档 = 一个 Markdown 文件 + frontmatter 元数据。 * 版本历史 = git log,diff = git diff,搜索 = grep 文件内容。 */ import simpleGit, { SimpleGit, LogResult } from 'simple-git'; import matter from 'gray-matter'; import { diffLines } from 'diff'; import path from 'path'; import fs from 'fs/promises'; import fsSync from 'fs'; // ─── 类型定义 ─── export interface DocMeta { id: string; // 文件路径即 ID(相对 docs/ 目录) title: string; slug: string; parentPath: string; // 父目录路径 createdAt: string; updatedAt: string; author: string; tags: string[]; } export interface DocContent { meta: DocMeta; body: string; // Markdown 正文(不含 frontmatter) } export interface DocTreeNode { name: string; path: string; type: 'folder' | 'document'; children?: DocTreeNode[]; } export interface VersionEntry { hash: string; shortHash: string; author: string; date: string; message: string; } export interface SearchResult { path: string; title: string; snippet: string; line: number; } export interface DiffResult { additions: number; deletions: number; hunks: { oldStart: number; newStart: number; lines: string[] }[]; } export interface RepositoryStatus { branch: string; head: string; clean: boolean; ahead: number; behind: number; remote: { name: string; url: string } | null; } // ─── Git 引擎 ─── export class GitEngine { private git: SimpleGit; private docsDir: string; private repoPath: string; constructor(repoPath: string) { this.repoPath = repoPath; this.docsDir = path.join(repoPath, 'docs'); // simple-git 要求目录先存在,先同步创建 fsSync.mkdirSync(repoPath, { recursive: true }); this.git = simpleGit(repoPath); } /** 初始化仓库(如果不存在则创建) */ async init(): Promise { const repoRoot = path.dirname(this.docsDir); try { await fs.access(path.join(repoRoot, '.git')); } catch { await this.git.init(); await fs.mkdir(this.docsDir, { recursive: true }); // 写入初始 README await fs.writeFile( path.join(this.docsDir, 'README.md'), '---\ntitle: 欢迎\nauthor: system\n---\n\n# 光湖知识库\n\n这是你的知识库根目录。\n' ); await this.git.add('.'); await this.git.commit('init: 初始化光湖知识库'); } // 确保 docs 目录存在 await fs.mkdir(this.docsDir, { recursive: true }); } // ─── Forgejo 远端仓库 ─── /** 返回本地 Git 与 Forgejo 远端的真实连接状态,不探测或修改网络。 */ async getRepositoryStatus(remoteName = 'origin'): Promise { this.assertRemoteName(remoteName); const [status, remotes, head] = await Promise.all([ this.git.status(), this.git.getRemotes(true), this.git.revparse(['HEAD']).catch(() => ''), ]); const remote = remotes.find(item => item.name === remoteName); return { branch: status.current || '未命名分支', head: head.trim(), clean: status.isClean(), ahead: status.ahead, behind: status.behind, remote: remote ? { name: remote.name, url: this.redactRemoteUrl(remote.refs.fetch) } : null, }; } /** 配置 Forgejo Git 远端。认证交给系统钥匙串或 SSH,不保存凭据。 */ async configureRemote(url: string, remoteName = 'origin'): Promise { this.assertRemoteName(remoteName); const safeUrl = this.validateRemoteUrl(url); const remotes = await this.git.getRemotes(true); if (remotes.some(item => item.name === remoteName)) { await this.git.remote(['set-url', remoteName, safeUrl]); } else { await this.git.addRemote(remoteName, safeUrl); } return this.getRepositoryStatus(remoteName); } async removeRemote(remoteName = 'origin'): Promise { this.assertRemoteName(remoteName); const remotes = await this.git.getRemotes(); if (remotes.some(remote => remote.name === remoteName)) { await this.git.removeRemote(remoteName); } return this.getRepositoryStatus(remoteName); } async fetchRemote(remoteName = 'origin'): Promise { this.assertRemoteName(remoteName); await this.requireRemote(remoteName); await this.git.fetch(remoteName, ['--prune']); return this.getRepositoryStatus(remoteName); } /** 只允许快进拉取,避免客户端静默制造合并提交。 */ async pullRemote(remoteName = 'origin'): Promise { this.assertRemoteName(remoteName); await this.requireRemote(remoteName); const status = await this.git.status(); if (!status.current) throw new Error('当前没有可拉取的分支'); if (!status.isClean()) throw new Error('本地有未提交变更,请先保存或提交后再拉取'); await this.git.pull(remoteName, status.current, { '--ff-only': null }); return this.getRepositoryStatus(remoteName); } /** 推送只能由显式 UI 操作调用,Agent 不注册此写操作。 */ async pushRemote(remoteName = 'origin'): Promise { this.assertRemoteName(remoteName); await this.requireRemote(remoteName); const status = await this.git.status(); if (!status.current) throw new Error('当前没有可推送的分支'); if (!status.isClean()) throw new Error('本地有未提交变更,请先保存后再推送'); await this.git.push(remoteName, status.current, ['--set-upstream']); return this.getRepositoryStatus(remoteName); } // ─── 文档 CRUD ─── /** 读取文档 */ async getDoc(docPath: string): Promise { const fullPath = this.resolvePath(docPath); const raw = await fs.readFile(fullPath, 'utf-8'); const { data, content } = matter(raw); return { meta: { id: docPath, title: (data.title as string) || path.basename(docPath, '.md'), slug: path.basename(docPath, '.md'), parentPath: path.dirname(docPath), createdAt: (data.createdAt as string) || new Date().toISOString(), updatedAt: (data.updatedAt as string) || new Date().toISOString(), author: (data.author as string) || 'anonymous', tags: (data.tags as string[]) || [], }, body: content.trim(), }; } /** 创建文档 */ async createDoc(docPath: string, title: string, body: string, author = 'anonymous'): Promise { const fullPath = this.resolvePath(docPath); await fs.mkdir(path.dirname(fullPath), { recursive: true }); const now = new Date().toISOString(); const frontmatter = matter.stringify(body, { title, author, createdAt: now, updatedAt: now, tags: [], }); await fs.writeFile(fullPath, frontmatter); await this.git.add(fullPath); await this.git.commit(`create: ${title}`, [fullPath]); return this.getDoc(docPath); } /** 更新文档 */ async updateDoc(docPath: string, title: string, body: string, author = 'anonymous'): Promise { const fullPath = this.resolvePath(docPath); const now = new Date().toISOString(); // 读取旧的 frontmatter 保留 createdAt let createdAt = now; try { const old = await fs.readFile(fullPath, 'utf-8'); const oldData = matter(old); createdAt = (oldData.data.createdAt as string) || now; } catch { /* 新文件 */ } const frontmatter = matter.stringify(body, { title, author, createdAt, updatedAt: now, tags: [], }); await fs.writeFile(fullPath, frontmatter); await this.git.add(fullPath); await this.git.commit(`update: ${title}`, [fullPath]); return this.getDoc(docPath); } /** 删除文档 */ async deleteDoc(docPath: string): Promise { const fullPath = this.resolvePath(docPath); await fs.rm(fullPath, { force: true }); await this.git.rm(fullPath); await this.git.commit(`delete: ${docPath}`); } /** 重命名/移动文档 */ async moveDoc(oldPath: string, newPath: string): Promise { const fullOld = this.resolvePath(oldPath); const fullNew = this.resolvePath(newPath); await fs.mkdir(path.dirname(fullNew), { recursive: true }); await fs.rename(fullOld, fullNew); await this.git.rm(fullOld); await this.git.add(fullNew); await this.git.commit(`move: ${oldPath} → ${newPath}`); } // ─── 文档树 ─── /** 构建文档树 */ async getTree(): Promise { return this.buildTree(this.docsDir, ''); } private async buildTree(dirPath: string, relativePath: string): Promise { const entries = await fs.readdir(dirPath, { withFileTypes: true }); const nodes: DocTreeNode[] = []; // 排序:文件夹在前,文件在后 const sorted = entries .filter(e => !e.name.startsWith('.') && !e.name.startsWith('_')) .sort((a, b) => { if (a.isDirectory() && !b.isDirectory()) return -1; if (!a.isDirectory() && b.isDirectory()) return 1; return a.name.localeCompare(b.name); }); for (const entry of sorted) { const childRelPath = relativePath ? `${relativePath}/${entry.name}` : entry.name; const childFullPath = path.join(dirPath, entry.name); if (entry.isDirectory()) { const children = await this.buildTree(childFullPath, childRelPath); nodes.push({ name: entry.name, path: childRelPath, type: 'folder', children, }); } else if (entry.name.endsWith('.md')) { // 读取标题 let title = path.basename(entry.name, '.md'); try { const raw = await fs.readFile(childFullPath, 'utf-8'); const { data } = matter(raw); title = (data.title as string) || title; } catch { /* 忽略 */ } nodes.push({ name: title, path: childRelPath, type: 'document', }); } } return nodes; } // ─── 版本历史 ─── /** 获取文档版本历史 */ async getHistory(docPath: string, maxCount = 50): Promise { const fullPath = docPath ? this.resolvePath(docPath) : '.'; const log: LogResult = await this.git.log({ file: fullPath, maxCount, }); return log.all.map(entry => ({ hash: entry.hash, shortHash: entry.hash.substring(0, 7), author: entry.author_name, date: entry.date, message: entry.message, })); } /** 获取某次提交的文档内容 */ async getDocAtVersion(docPath: string, commitHash: string): Promise { const relToRepo = `docs/${docPath}`; const content = await this.git.show([`${commitHash}:${relToRepo}`]); const { content: body } = matter(content); return body.trim(); } /** 对比两个版本 */ async diffVersions(docPath: string, fromHash: string, toHash: string): Promise { const relToRepo = `docs/${docPath}`; const [fromContent, toContent] = await Promise.all([ this.git.show([`${fromHash}:${relToRepo}`]).catch(() => ''), this.git.show([`${toHash}:${relToRepo}`]).catch(() => ''), ]); const fromBody = fromContent ? matter(fromContent).content : ''; const toBody = toContent ? matter(toContent).content : ''; const diffs = diffLines(fromBody, toBody); let additions = 0; let deletions = 0; const hunks: DiffResult['hunks'] = []; let currentLines: string[] = []; for (const part of diffs) { if (part.added) { const lines = part.value.split('\n').filter(l => l !== ''); additions += lines.length; lines.forEach(l => currentLines.push(`+ ${l}`)); } else if (part.removed) { const lines = part.value.split('\n').filter(l => l !== ''); deletions += lines.length; lines.forEach(l => currentLines.push(`- ${l}`)); } else { if (currentLines.length > 0) { hunks.push({ oldStart: 0, newStart: 0, lines: [...currentLines] }); currentLines = []; } // 保留上下文(最多3行) const lines = part.value.split('\n').filter(l => l !== ''); const ctx = lines.slice(-3); ctx.forEach(l => currentLines.push(` ${l}`)); } } if (currentLines.length > 0) { hunks.push({ oldStart: 0, newStart: 0, lines: currentLines }); } return { additions, deletions, hunks }; } // ─── 搜索 ─── /** 全文搜索(基于文件内容 grep) */ async search(query: string): Promise { const results: SearchResult[] = []; const queryLower = query.toLowerCase(); await this.searchDir(this.docsDir, '', queryLower, results); return results.slice(0, 50); // 最多 50 条 } private async searchDir( dirPath: string, relativePath: string, query: string, results: SearchResult[] ): Promise { const entries = await fs.readdir(dirPath, { withFileTypes: true }); for (const entry of entries) { if (entry.name.startsWith('.')) continue; const childRelPath = relativePath ? `${relativePath}/${entry.name}` : entry.name; const childFullPath = path.join(dirPath, entry.name); if (entry.isDirectory()) { await this.searchDir(childFullPath, childRelPath, query, results); } else if (entry.name.endsWith('.md')) { try { const raw = await fs.readFile(childFullPath, 'utf-8'); const { data, content } = matter(raw); const lines = content.split('\n'); for (let i = 0; i < lines.length; i++) { if (lines[i].toLowerCase().includes(query)) { results.push({ path: childRelPath, title: (data.title as string) || path.basename(entry.name, '.md'), snippet: lines[i].trim().substring(0, 200), line: i + 1, }); break; // 每个文件只取第一个匹配 } } } catch { /* 忽略不可读文件 */ } } } } // ─── 辅助 ─── private resolvePath(docPath: string): string { const normalized = path.normalize(docPath); const resolved = path.resolve(this.docsDir, normalized); const prefix = `${path.resolve(this.docsDir)}${path.sep}`; if (!resolved.startsWith(prefix) || !normalized.endsWith('.md')) { throw new Error('文档路径无效,只允许知识库 docs 目录内的 Markdown 文件'); } return resolved; } private assertRemoteName(name: string): void { if (!/^[A-Za-z0-9._-]{1,64}$/.test(name)) throw new Error('远端名称无效'); } private validateRemoteUrl(value: string): string { const url = value.trim(); if (!url) throw new Error('Forgejo 仓库地址不能为空'); if (/^https?:\/\//i.test(url)) { const parsed = new URL(url); if (parsed.username || parsed.password) throw new Error('仓库地址不能包含账号或密钥,请使用系统钥匙串'); return parsed.toString().replace(/\/$/, ''); } if (/^(ssh:\/\/|git@)[^\s]+$/i.test(url)) return url; throw new Error('仅支持 HTTPS 或 SSH Forgejo 仓库地址'); } private redactRemoteUrl(value: string): string { try { const parsed = new URL(value); parsed.username = ''; parsed.password = ''; return parsed.toString().replace(/\/$/, ''); } catch { return value.replace(/\/\/[^/@]+@/, '//***@'); } } private async requireRemote(name: string): Promise { const remotes = await this.git.getRemotes(); if (!remotes.some(remote => remote.name === name)) throw new Error('尚未配置 Forgejo 仓库地址'); } }