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,35 @@
|
||||
/**
|
||||
* 检查点系统模块
|
||||
*
|
||||
* 提供工作区快照和回滚功能,使用 Shadow Git 架构
|
||||
* 参考 Cline 的实现
|
||||
*/
|
||||
|
||||
// 检查点管理器
|
||||
export {
|
||||
CheckpointManager,
|
||||
getCheckpointManager,
|
||||
initCheckpointManager,
|
||||
resetCheckpointManager,
|
||||
} from './manager.js';
|
||||
|
||||
// Shadow Git
|
||||
export { ShadowGit, createShadowGit, hashWorkingDir } from './shadow-git.js';
|
||||
|
||||
// 类型
|
||||
export type {
|
||||
CheckpointMetadata,
|
||||
CheckpointConfig,
|
||||
CheckpointTrigger,
|
||||
FileChange,
|
||||
FileChangeType,
|
||||
DiffInfo,
|
||||
FileDiff,
|
||||
RollbackOptions,
|
||||
RollbackResult,
|
||||
CheckpointEvent,
|
||||
CheckpointEventType,
|
||||
CheckpointEventListener,
|
||||
} from './types.js';
|
||||
|
||||
export { DEFAULT_CHECKPOINT_CONFIG } from './types.js';
|
||||
@@ -0,0 +1,613 @@
|
||||
/**
|
||||
* 检查点管理器
|
||||
* 管理检查点的创建、回滚、清理等操作
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { ShadowGit, createShadowGit } from './shadow-git.js';
|
||||
import type {
|
||||
CheckpointMetadata,
|
||||
CheckpointConfig,
|
||||
CheckpointTrigger,
|
||||
RollbackOptions,
|
||||
RollbackResult,
|
||||
DiffInfo,
|
||||
FileDiff,
|
||||
CheckpointEvent,
|
||||
CheckpointEventListener,
|
||||
DEFAULT_CHECKPOINT_CONFIG,
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
* 检查点提交消息前缀
|
||||
*/
|
||||
const CHECKPOINT_PREFIX = 'checkpoint:';
|
||||
|
||||
/**
|
||||
* 检查点管理器
|
||||
*/
|
||||
export class CheckpointManager {
|
||||
private shadowGit: ShadowGit;
|
||||
private config: CheckpointConfig;
|
||||
private workDir: string;
|
||||
private checkpointsIndex: Map<string, CheckpointMetadata> = new Map();
|
||||
private initialized = false;
|
||||
private lastCheckpointTime = 0;
|
||||
private eventListeners: Set<CheckpointEventListener> = new Set();
|
||||
|
||||
// 防止重复创建检查点的最小间隔 (毫秒)
|
||||
private static readonly MIN_CHECKPOINT_INTERVAL = 1000;
|
||||
|
||||
constructor(workDir: string, config: Partial<CheckpointConfig> = {}) {
|
||||
this.workDir = path.resolve(workDir);
|
||||
this.config = {
|
||||
enabled: true,
|
||||
autoCheckpoint: {
|
||||
beforeWrite: true,
|
||||
beforeEdit: true,
|
||||
beforeDelete: true,
|
||||
beforeMove: true,
|
||||
beforeBash: false,
|
||||
},
|
||||
maxCheckpoints: 100,
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||
storageDir: path.join(os.homedir(), '.ai-assist', 'checkpoints'),
|
||||
...config,
|
||||
};
|
||||
|
||||
this.shadowGit = createShadowGit(this.workDir, this.config.storageDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化检查点管理器
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
if (!this.config.enabled) {
|
||||
this.initialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// 初始化 Shadow Git
|
||||
await this.shadowGit.initialize();
|
||||
|
||||
// 加载检查点索引
|
||||
await this.loadCheckpointsIndex();
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载检查点索引
|
||||
*/
|
||||
private async loadCheckpointsIndex(): Promise<void> {
|
||||
try {
|
||||
const commits = await this.shadowGit.getCommits(this.config.maxCheckpoints);
|
||||
|
||||
for (const commit of commits) {
|
||||
if (commit.message.startsWith(CHECKPOINT_PREFIX)) {
|
||||
try {
|
||||
const jsonStr = commit.message.slice(CHECKPOINT_PREFIX.length);
|
||||
const metadata = JSON.parse(jsonStr) as CheckpointMetadata;
|
||||
metadata.commitHash = commit.hash;
|
||||
this.checkpointsIndex.set(metadata.id, metadata);
|
||||
} catch {
|
||||
// 解析失败,跳过
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 仓库可能是空的
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否应该为指定工具创建检查点
|
||||
*/
|
||||
shouldCreateCheckpoint(tool: string): boolean {
|
||||
if (!this.config.enabled) return false;
|
||||
|
||||
const { autoCheckpoint } = this.config;
|
||||
|
||||
switch (tool) {
|
||||
case 'write_file':
|
||||
return autoCheckpoint.beforeWrite;
|
||||
case 'edit_file':
|
||||
return autoCheckpoint.beforeEdit;
|
||||
case 'delete_file':
|
||||
return autoCheckpoint.beforeDelete;
|
||||
case 'move_file':
|
||||
case 'copy_file':
|
||||
return autoCheckpoint.beforeMove;
|
||||
case 'bash':
|
||||
return autoCheckpoint.beforeBash;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在工具执行前创建检查点
|
||||
*/
|
||||
async beforeToolExecution(
|
||||
tool: string,
|
||||
params: Record<string, unknown>
|
||||
): Promise<string | null> {
|
||||
if (!this.shouldCreateCheckpoint(tool)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 防止过于频繁的检查点创建
|
||||
const now = Date.now();
|
||||
if (now - this.lastCheckpointTime < CheckpointManager.MIN_CHECKPOINT_INTERVAL) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const checkpoint = await this.createCheckpoint({
|
||||
trigger: `tool:${tool}` as CheckpointTrigger,
|
||||
toolCall: { tool, params },
|
||||
description: this.generateDescription(tool, params),
|
||||
});
|
||||
|
||||
this.lastCheckpointTime = now;
|
||||
return checkpoint.id;
|
||||
} catch (error) {
|
||||
console.warn('Failed to create checkpoint:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成检查点描述
|
||||
*/
|
||||
private generateDescription(
|
||||
tool: string,
|
||||
params: Record<string, unknown>
|
||||
): string {
|
||||
switch (tool) {
|
||||
case 'write_file':
|
||||
return `Write file: ${params.file_path || params.path}`;
|
||||
case 'edit_file':
|
||||
return `Edit file: ${params.file_path || params.path}`;
|
||||
case 'delete_file':
|
||||
return `Delete file: ${params.file_path || params.path}`;
|
||||
case 'move_file':
|
||||
return `Move: ${params.source} -> ${params.destination}`;
|
||||
case 'copy_file':
|
||||
return `Copy: ${params.source} -> ${params.destination}`;
|
||||
case 'bash':
|
||||
return `Bash: ${String(params.command).slice(0, 50)}`;
|
||||
default:
|
||||
return `Tool: ${tool}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建检查点
|
||||
*/
|
||||
async createCheckpoint(options: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
trigger?: CheckpointTrigger;
|
||||
toolCall?: { tool: string; params: Record<string, unknown> };
|
||||
}): Promise<CheckpointMetadata> {
|
||||
await this.initialize();
|
||||
|
||||
if (!this.config.enabled) {
|
||||
throw new Error('Checkpoint system is disabled');
|
||||
}
|
||||
|
||||
const id = nanoid(10);
|
||||
const timestamp = Date.now();
|
||||
|
||||
// 创建元数据
|
||||
const metadata: CheckpointMetadata = {
|
||||
id,
|
||||
name: options.name,
|
||||
description: options.description,
|
||||
timestamp,
|
||||
trigger: options.trigger || 'manual',
|
||||
toolCall: options.toolCall,
|
||||
commitHash: '', // 待填充
|
||||
filesChanged: 0, // 待填充
|
||||
};
|
||||
|
||||
// 获取变更文件数
|
||||
try {
|
||||
const diff = await this.shadowGit.getWorkingDirDiff();
|
||||
metadata.filesChanged = diff.files.length;
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
|
||||
// 创建 commit
|
||||
const commitMessage = CHECKPOINT_PREFIX + JSON.stringify(metadata);
|
||||
const commitHash = await this.shadowGit.createCommit(commitMessage);
|
||||
metadata.commitHash = commitHash;
|
||||
|
||||
// 更新索引
|
||||
this.checkpointsIndex.set(id, metadata);
|
||||
|
||||
// 触发事件
|
||||
this.emitEvent({
|
||||
type: 'created',
|
||||
checkpoint: metadata,
|
||||
timestamp,
|
||||
});
|
||||
|
||||
// 异步清理
|
||||
this.cleanupAsync();
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建命名检查点
|
||||
*/
|
||||
async createNamedCheckpoint(name: string, description?: string): Promise<CheckpointMetadata> {
|
||||
return this.createCheckpoint({
|
||||
name,
|
||||
description,
|
||||
trigger: 'manual',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有检查点
|
||||
*/
|
||||
async listCheckpoints(): Promise<CheckpointMetadata[]> {
|
||||
await this.initialize();
|
||||
|
||||
return Array.from(this.checkpointsIndex.values()).sort(
|
||||
(a, b) => b.timestamp - a.timestamp
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定检查点
|
||||
*/
|
||||
async getCheckpoint(idOrHash: string): Promise<CheckpointMetadata | null> {
|
||||
await this.initialize();
|
||||
|
||||
// 先按 ID 查找
|
||||
if (this.checkpointsIndex.has(idOrHash)) {
|
||||
return this.checkpointsIndex.get(idOrHash)!;
|
||||
}
|
||||
|
||||
// 再按 commit hash 查找
|
||||
for (const checkpoint of this.checkpointsIndex.values()) {
|
||||
if (checkpoint.commitHash.startsWith(idOrHash)) {
|
||||
return checkpoint;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最近的检查点
|
||||
*/
|
||||
async getLatestCheckpoint(): Promise<CheckpointMetadata | null> {
|
||||
const checkpoints = await this.listCheckpoints();
|
||||
return checkpoints[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取检查点与当前工作区的差异
|
||||
*/
|
||||
async getDiff(checkpointId: string): Promise<DiffInfo> {
|
||||
await this.initialize();
|
||||
|
||||
const checkpoint = await this.getCheckpoint(checkpointId);
|
||||
if (!checkpoint) {
|
||||
throw new Error(`Checkpoint not found: ${checkpointId}`);
|
||||
}
|
||||
|
||||
return this.shadowGit.getDiffSummary(checkpoint.commitHash, 'HEAD');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取两个检查点之间的差异
|
||||
*/
|
||||
async getDiffBetween(fromId: string, toId: string): Promise<DiffInfo> {
|
||||
await this.initialize();
|
||||
|
||||
const fromCheckpoint = await this.getCheckpoint(fromId);
|
||||
const toCheckpoint = await this.getCheckpoint(toId);
|
||||
|
||||
if (!fromCheckpoint) {
|
||||
throw new Error(`Checkpoint not found: ${fromId}`);
|
||||
}
|
||||
if (!toCheckpoint) {
|
||||
throw new Error(`Checkpoint not found: ${toId}`);
|
||||
}
|
||||
|
||||
return this.shadowGit.getDiffSummary(
|
||||
fromCheckpoint.commitHash,
|
||||
toCheckpoint.commitHash
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件差异详情
|
||||
*/
|
||||
async getFileDiff(checkpointId: string, filePath: string): Promise<FileDiff> {
|
||||
await this.initialize();
|
||||
|
||||
const checkpoint = await this.getCheckpoint(checkpointId);
|
||||
if (!checkpoint) {
|
||||
throw new Error(`Checkpoint not found: ${checkpointId}`);
|
||||
}
|
||||
|
||||
const head = await this.shadowGit.getHead();
|
||||
return this.shadowGit.getFileDiff(checkpoint.commitHash, head, filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 回滚到检查点
|
||||
*/
|
||||
async rollback(options: RollbackOptions): Promise<RollbackResult> {
|
||||
await this.initialize();
|
||||
|
||||
const checkpoint = await this.getCheckpoint(options.target);
|
||||
if (!checkpoint) {
|
||||
throw new Error(`Checkpoint not found: ${options.target}`);
|
||||
}
|
||||
|
||||
// 获取当前 HEAD 用于可能的撤销
|
||||
const previousCommit = await this.shadowGit.getHead();
|
||||
|
||||
// 预览模式
|
||||
if (options.dryRun) {
|
||||
const diff = await this.getDiff(checkpoint.id);
|
||||
return {
|
||||
success: true,
|
||||
restoredFiles: diff.files.map((f) => f.path),
|
||||
errors: [],
|
||||
previousCommit,
|
||||
};
|
||||
}
|
||||
|
||||
const result: RollbackResult = {
|
||||
success: true,
|
||||
restoredFiles: [],
|
||||
errors: [],
|
||||
previousCommit,
|
||||
};
|
||||
|
||||
try {
|
||||
if (options.files && options.files.length > 0) {
|
||||
// 选择性回滚
|
||||
await this.shadowGit.checkoutFiles(checkpoint.commitHash, options.files);
|
||||
result.restoredFiles = options.files;
|
||||
} else {
|
||||
// 完整回滚
|
||||
await this.shadowGit.resetHard(checkpoint.commitHash);
|
||||
|
||||
// 获取恢复的文件列表
|
||||
const diff = await this.shadowGit.getDiffSummary(
|
||||
previousCommit,
|
||||
checkpoint.commitHash
|
||||
);
|
||||
result.restoredFiles = diff.files.map((f) => f.path);
|
||||
}
|
||||
|
||||
// 触发事件
|
||||
this.emitEvent({
|
||||
type: 'restored',
|
||||
checkpoint,
|
||||
timestamp: Date.now(),
|
||||
details: {
|
||||
files: result.restoredFiles,
|
||||
previousCommit,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
result.success = false;
|
||||
result.errors.push({
|
||||
file: '*',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销操作 (回滚到上一个检查点)
|
||||
*/
|
||||
async undo(): Promise<RollbackResult> {
|
||||
const latest = await this.getLatestCheckpoint();
|
||||
if (!latest) {
|
||||
throw new Error('No checkpoints available');
|
||||
}
|
||||
|
||||
// 找到倒数第二个检查点
|
||||
const checkpoints = await this.listCheckpoints();
|
||||
if (checkpoints.length < 2) {
|
||||
// 只有一个检查点,回滚到它
|
||||
return this.rollback({ target: latest.id });
|
||||
}
|
||||
|
||||
// 回滚到倒数第二个检查点
|
||||
return this.rollback({ target: checkpoints[1].id });
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除检查点
|
||||
*/
|
||||
async deleteCheckpoint(checkpointId: string): Promise<boolean> {
|
||||
await this.initialize();
|
||||
|
||||
if (!this.checkpointsIndex.has(checkpointId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const checkpoint = this.checkpointsIndex.get(checkpointId)!;
|
||||
this.checkpointsIndex.delete(checkpointId);
|
||||
|
||||
// 触发事件
|
||||
this.emitEvent({
|
||||
type: 'deleted',
|
||||
checkpoint,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步清理过期检查点
|
||||
*/
|
||||
private async cleanupAsync(): Promise<void> {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await this.cleanup();
|
||||
} catch (error) {
|
||||
console.warn('Checkpoint cleanup failed:', error);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期检查点
|
||||
*/
|
||||
async cleanup(): Promise<number> {
|
||||
await this.initialize();
|
||||
|
||||
const checkpoints = await this.listCheckpoints();
|
||||
const now = Date.now();
|
||||
let deletedCount = 0;
|
||||
|
||||
// 按时间过期清理
|
||||
for (const checkpoint of checkpoints) {
|
||||
if (now - checkpoint.timestamp > this.config.maxAge) {
|
||||
await this.deleteCheckpoint(checkpoint.id);
|
||||
deletedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 按数量限制清理
|
||||
const remaining = checkpoints.length - deletedCount;
|
||||
if (remaining > this.config.maxCheckpoints) {
|
||||
const toDelete = checkpoints.slice(this.config.maxCheckpoints);
|
||||
for (const checkpoint of toDelete) {
|
||||
if (this.checkpointsIndex.has(checkpoint.id)) {
|
||||
await this.deleteCheckpoint(checkpoint.id);
|
||||
deletedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedCount > 0) {
|
||||
// 触发清理事件
|
||||
this.emitEvent({
|
||||
type: 'cleanup',
|
||||
timestamp: now,
|
||||
details: { deletedCount },
|
||||
});
|
||||
|
||||
// 运行 git gc
|
||||
await this.shadowGit.cleanup(this.config.maxCheckpoints);
|
||||
}
|
||||
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取检查点存储统计
|
||||
*/
|
||||
async getStats(): Promise<{
|
||||
count: number;
|
||||
oldestTimestamp: number | null;
|
||||
newestTimestamp: number | null;
|
||||
}> {
|
||||
const checkpoints = await this.listCheckpoints();
|
||||
|
||||
return {
|
||||
count: checkpoints.length,
|
||||
oldestTimestamp: checkpoints.length > 0
|
||||
? checkpoints[checkpoints.length - 1].timestamp
|
||||
: null,
|
||||
newestTimestamp: checkpoints.length > 0 ? checkpoints[0].timestamp : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加事件监听器
|
||||
*/
|
||||
addEventListener(listener: CheckpointEventListener): void {
|
||||
this.eventListeners.add(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除事件监听器
|
||||
*/
|
||||
removeEventListener(listener: CheckpointEventListener): void {
|
||||
this.eventListeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发事件
|
||||
*/
|
||||
private emitEvent(event: CheckpointEvent): void {
|
||||
for (const listener of this.eventListeners) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch (error) {
|
||||
console.warn('Checkpoint event listener error:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否启用
|
||||
*/
|
||||
isEnabled(): boolean {
|
||||
return this.config.enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置
|
||||
*/
|
||||
getConfig(): CheckpointConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
}
|
||||
|
||||
// 全局检查点管理器实例
|
||||
let globalCheckpointManager: CheckpointManager | null = null;
|
||||
|
||||
/**
|
||||
* 获取全局检查点管理器实例
|
||||
*/
|
||||
export function getCheckpointManager(): CheckpointManager {
|
||||
if (!globalCheckpointManager) {
|
||||
globalCheckpointManager = new CheckpointManager(process.cwd());
|
||||
}
|
||||
return globalCheckpointManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化全局检查点管理器
|
||||
*/
|
||||
export async function initCheckpointManager(
|
||||
workDir: string,
|
||||
config?: Partial<CheckpointConfig>
|
||||
): Promise<CheckpointManager> {
|
||||
globalCheckpointManager = new CheckpointManager(workDir, config);
|
||||
await globalCheckpointManager.initialize();
|
||||
return globalCheckpointManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置全局检查点管理器 (用于测试)
|
||||
*/
|
||||
export function resetCheckpointManager(): void {
|
||||
globalCheckpointManager = null;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* 检查点系统类型定义
|
||||
* 基于 Cline 的 Shadow Git 架构
|
||||
*/
|
||||
|
||||
/**
|
||||
* 检查点触发类型
|
||||
*/
|
||||
export type CheckpointTrigger =
|
||||
| 'auto' // 自动创建
|
||||
| 'manual' // 用户手动
|
||||
| 'tool:write_file' // 写文件前
|
||||
| 'tool:edit_file' // 编辑文件前
|
||||
| 'tool:delete_file' // 删除文件前
|
||||
| 'tool:move_file' // 移动文件前
|
||||
| 'tool:copy_file' // 复制文件前
|
||||
| 'tool:bash' // bash 命令前
|
||||
| 'task_start' // 任务开始
|
||||
| 'task_complete'; // 任务完成
|
||||
|
||||
/**
|
||||
* 检查点元数据
|
||||
*/
|
||||
export interface CheckpointMetadata {
|
||||
/** 唯一标识 */
|
||||
id: string;
|
||||
/** 用户可读名称 */
|
||||
name?: string;
|
||||
/** 描述信息 */
|
||||
description?: string;
|
||||
/** 创建时间戳 */
|
||||
timestamp: number;
|
||||
/** 触发类型 */
|
||||
trigger: CheckpointTrigger;
|
||||
/** 关联的工具调用 */
|
||||
toolCall?: {
|
||||
tool: string;
|
||||
params: Record<string, unknown>;
|
||||
};
|
||||
/** Git commit hash */
|
||||
commitHash: string;
|
||||
/** 受影响的文件数 */
|
||||
filesChanged: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查点配置
|
||||
*/
|
||||
export interface CheckpointConfig {
|
||||
/** 是否启用检查点系统 */
|
||||
enabled: boolean;
|
||||
/** 自动检查点配置 */
|
||||
autoCheckpoint: {
|
||||
/** 写文件前创建检查点 */
|
||||
beforeWrite: boolean;
|
||||
/** 编辑文件前创建检查点 */
|
||||
beforeEdit: boolean;
|
||||
/** 删除文件前创建检查点 */
|
||||
beforeDelete: boolean;
|
||||
/** 移动/复制文件前创建检查点 */
|
||||
beforeMove: boolean;
|
||||
/** bash 命令前创建检查点 */
|
||||
beforeBash: boolean;
|
||||
};
|
||||
/** 最大保留检查点数量 */
|
||||
maxCheckpoints: number;
|
||||
/** 检查点最大保留时间 (毫秒) */
|
||||
maxAge: number;
|
||||
/** Shadow Git 存储目录 */
|
||||
storageDir: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认配置
|
||||
*/
|
||||
export const DEFAULT_CHECKPOINT_CONFIG: CheckpointConfig = {
|
||||
enabled: true,
|
||||
autoCheckpoint: {
|
||||
beforeWrite: true,
|
||||
beforeEdit: true,
|
||||
beforeDelete: true,
|
||||
beforeMove: true,
|
||||
beforeBash: false,
|
||||
},
|
||||
maxCheckpoints: 100,
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 天
|
||||
storageDir: '.ai-assist/checkpoints',
|
||||
};
|
||||
|
||||
/**
|
||||
* 文件变更类型
|
||||
*/
|
||||
export type FileChangeType = 'added' | 'modified' | 'deleted' | 'renamed';
|
||||
|
||||
/**
|
||||
* 文件变更信息
|
||||
*/
|
||||
export interface FileChange {
|
||||
/** 文件路径 */
|
||||
path: string;
|
||||
/** 变更类型 */
|
||||
type: FileChangeType;
|
||||
/** 旧路径 (重命名时) */
|
||||
oldPath?: string;
|
||||
/** 添加的行数 */
|
||||
insertions?: number;
|
||||
/** 删除的行数 */
|
||||
deletions?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 差异信息
|
||||
*/
|
||||
export interface DiffInfo {
|
||||
/** 源检查点/commit */
|
||||
from: string;
|
||||
/** 目标检查点/commit (HEAD 表示当前工作区) */
|
||||
to: string;
|
||||
/** 变更的文件列表 */
|
||||
files: FileChange[];
|
||||
/** 总添加行数 */
|
||||
totalInsertions: number;
|
||||
/** 总删除行数 */
|
||||
totalDeletions: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件内容差异
|
||||
*/
|
||||
export interface FileDiff {
|
||||
/** 文件路径 */
|
||||
path: string;
|
||||
/** 变更类型 */
|
||||
type: FileChangeType;
|
||||
/** 旧内容 */
|
||||
oldContent?: string;
|
||||
/** 新内容 */
|
||||
newContent?: string;
|
||||
/** 差异补丁 (unified diff 格式) */
|
||||
patch?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 回滚选项
|
||||
*/
|
||||
export interface RollbackOptions {
|
||||
/** 检查点 ID 或 commit hash */
|
||||
target: string;
|
||||
/** 只回滚指定文件 */
|
||||
files?: string[];
|
||||
/** 预览模式 (不实际执行) */
|
||||
dryRun?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 回滚结果
|
||||
*/
|
||||
export interface RollbackResult {
|
||||
/** 是否成功 */
|
||||
success: boolean;
|
||||
/** 恢复的文件列表 */
|
||||
restoredFiles: string[];
|
||||
/** 错误列表 */
|
||||
errors: Array<{ file: string; error: string }>;
|
||||
/** 回滚前的 commit hash (用于撤销回滚) */
|
||||
previousCommit?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查点事件类型
|
||||
*/
|
||||
export type CheckpointEventType =
|
||||
| 'created' // 检查点已创建
|
||||
| 'restored' // 已回滚到检查点
|
||||
| 'deleted' // 检查点已删除
|
||||
| 'cleanup'; // 清理过期检查点
|
||||
|
||||
/**
|
||||
* 检查点事件
|
||||
*/
|
||||
export interface CheckpointEvent {
|
||||
type: CheckpointEventType;
|
||||
checkpoint?: CheckpointMetadata;
|
||||
timestamp: number;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查点事件监听器
|
||||
*/
|
||||
export type CheckpointEventListener = (event: CheckpointEvent) => void;
|
||||
Reference in New Issue
Block a user