544 lines
18 KiB
TypeScript
544 lines
18 KiB
TypeScript
/**
|
||
* 光湖知识库 · 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 crypto from 'crypto';
|
||
import fs from 'fs/promises';
|
||
import fsSync from 'fs';
|
||
import {
|
||
applyChannelPatch as compileChannelPatch,
|
||
defaultChannelState,
|
||
normalizeChannelState,
|
||
type ChannelPatch,
|
||
type ChannelReceipt,
|
||
type ChannelState,
|
||
} from './channel-runtime.js';
|
||
|
||
// ─── 类型定义 ───
|
||
|
||
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;
|
||
private channelStatePath: string;
|
||
private channelReceiptsDir: string;
|
||
|
||
constructor(repoPath: string) {
|
||
this.repoPath = repoPath;
|
||
this.docsDir = path.join(repoPath, 'docs');
|
||
this.channelStatePath = path.join(repoPath, '.hololake', 'channel-state.json');
|
||
this.channelReceiptsDir = path.join(repoPath, '.hololake', 'receipts');
|
||
// simple-git 要求目录先存在,先同步创建
|
||
fsSync.mkdirSync(repoPath, { recursive: true });
|
||
this.git = simpleGit(repoPath);
|
||
}
|
||
|
||
async getChannelState(): Promise<ChannelState> {
|
||
try {
|
||
return normalizeChannelState(JSON.parse(await fs.readFile(this.channelStatePath, 'utf8')));
|
||
} catch {
|
||
return defaultChannelState();
|
||
}
|
||
}
|
||
|
||
async applyChannelPatch(patch: ChannelPatch): Promise<ChannelReceipt> {
|
||
const receipt = compileChannelPatch(await this.getChannelState(), patch);
|
||
await this.persistChannelReceipt(receipt, `channel: ${patch.operation} ${patch.moduleId}`);
|
||
return receipt;
|
||
}
|
||
|
||
async undoChannelPatch(receiptId: string): Promise<ChannelReceipt> {
|
||
if (!/^HL-CHANNEL-RCPT-[0-9a-f-]+$/i.test(receiptId)) throw new Error('无效的频道回执编号');
|
||
const receiptPath = path.join(this.channelReceiptsDir, `${receiptId}.json`);
|
||
const original = JSON.parse(await fs.readFile(receiptPath, 'utf8')) as ChannelReceipt;
|
||
const current = await this.getChannelState();
|
||
if (current.revision !== original.after.revision) throw new Error('频道状态已经继续变化,不能静默覆盖;请按当前状态重新操作');
|
||
const restored = {
|
||
...normalizeChannelState(original.before),
|
||
revision: current.revision + 1,
|
||
updatedAt: new Date().toISOString(),
|
||
};
|
||
const undoReceipt: ChannelReceipt = {
|
||
schema: 'hololake.channel-receipt/v1',
|
||
id: `HL-CHANNEL-RCPT-${crypto.randomUUID()}`,
|
||
operation: original.operation,
|
||
before: current,
|
||
after: restored,
|
||
createdAt: restored.updatedAt,
|
||
reversible: true,
|
||
};
|
||
await this.persistChannelReceipt(undoReceipt, `channel: undo ${receiptId}`);
|
||
return undoReceipt;
|
||
}
|
||
|
||
private async persistChannelReceipt(receipt: ChannelReceipt, message: string): Promise<void> {
|
||
await fs.mkdir(this.channelReceiptsDir, { recursive: true });
|
||
const receiptPath = path.join(this.channelReceiptsDir, `${receipt.id}.json`);
|
||
await fs.writeFile(this.channelStatePath, `${JSON.stringify(receipt.after, null, 2)}\n`, { mode: 0o600 });
|
||
await fs.writeFile(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`, { mode: 0o600 });
|
||
await this.git.add([this.channelStatePath, receiptPath]);
|
||
await this.git.commit(message, [this.channelStatePath, receiptPath]);
|
||
}
|
||
|
||
/** 初始化仓库(如果不存在则创建) */
|
||
async init(): Promise<void> {
|
||
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<RepositoryStatus> {
|
||
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<RepositoryStatus> {
|
||
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<RepositoryStatus> {
|
||
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<RepositoryStatus> {
|
||
this.assertRemoteName(remoteName);
|
||
await this.requireRemote(remoteName);
|
||
await this.git.fetch(remoteName, ['--prune']);
|
||
return this.getRepositoryStatus(remoteName);
|
||
}
|
||
|
||
/** 只允许快进拉取,避免客户端静默制造合并提交。 */
|
||
async pullRemote(remoteName = 'origin'): Promise<RepositoryStatus> {
|
||
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<RepositoryStatus> {
|
||
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<DocContent> {
|
||
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<DocContent> {
|
||
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<DocContent> {
|
||
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<void> {
|
||
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<void> {
|
||
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<DocTreeNode[]> {
|
||
return this.buildTree(this.docsDir, '');
|
||
}
|
||
|
||
private async buildTree(dirPath: string, relativePath: string): Promise<DocTreeNode[]> {
|
||
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<VersionEntry[]> {
|
||
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<string> {
|
||
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<DiffResult> {
|
||
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<SearchResult[]> {
|
||
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<void> {
|
||
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<void> {
|
||
const remotes = await this.git.getRemotes();
|
||
if (!remotes.some(remote => remote.name === name)) throw new Error('尚未配置 Forgejo 仓库地址');
|
||
}
|
||
}
|