mirror of
https://github.com/Kaelio/ktx.git
synced 2026-07-04 10:52:13 +02:00
Initial open-source release
This commit is contained in:
commit
1a42152e6f
1199 changed files with 257054 additions and 0 deletions
190
packages/context/src/project/local-git-file-store.ts
Normal file
190
packages/context/src/project/local-git-file-store.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import { promises as fs } from 'node:fs';
|
||||
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
||||
import type {
|
||||
GitCommitInfo,
|
||||
GitService,
|
||||
KloFileHistoryEntry,
|
||||
KloFileListResult,
|
||||
KloFileReadResult,
|
||||
KloFileStorePort,
|
||||
KloFileWriteResult,
|
||||
} from '../core/index.js';
|
||||
|
||||
export interface LocalGitFileStoreDeps {
|
||||
rootDir: string;
|
||||
git: GitService;
|
||||
}
|
||||
|
||||
function normalizeRelativePath(filePath: string): string {
|
||||
return filePath.replaceAll('\\', '/').replace(/^\.\/+/, '');
|
||||
}
|
||||
|
||||
function gitInfoToWriteResult(info: GitCommitInfo): KloFileWriteResult {
|
||||
return {
|
||||
success: true,
|
||||
commitHash: info.commitHash,
|
||||
commitMessage: info.message,
|
||||
author: info.author,
|
||||
authorEmail: info.authorEmail,
|
||||
timestamp: info.timestamp,
|
||||
created: info.created,
|
||||
};
|
||||
}
|
||||
|
||||
export class LocalGitFileStore implements KloFileStorePort<LocalGitFileStore> {
|
||||
private readonly rootDir: string;
|
||||
private readonly git: GitService;
|
||||
|
||||
constructor(deps: LocalGitFileStoreDeps) {
|
||||
this.rootDir = resolve(deps.rootDir);
|
||||
this.git = deps.git;
|
||||
}
|
||||
|
||||
forWorktree(workdir: string): LocalGitFileStore {
|
||||
return new LocalGitFileStore({ rootDir: workdir, git: this.git.forWorktree(workdir) });
|
||||
}
|
||||
|
||||
async writeFile(
|
||||
path: string,
|
||||
content: string,
|
||||
author: string,
|
||||
authorEmail: string,
|
||||
commitMessage: string,
|
||||
options?: { skipLock?: boolean },
|
||||
): Promise<KloFileWriteResult> {
|
||||
const relativePath = this.safeRelativePath(path);
|
||||
const absolutePath = this.absolutePath(relativePath);
|
||||
await fs.mkdir(dirname(absolutePath), { recursive: true });
|
||||
await fs.writeFile(absolutePath, content, 'utf-8');
|
||||
|
||||
if (options?.skipLock) {
|
||||
return { success: true, commitHash: null, path: relativePath, operation: 'write' };
|
||||
}
|
||||
|
||||
const info = await this.git.commitFile(relativePath, commitMessage, author, authorEmail);
|
||||
return { ...gitInfoToWriteResult(info), path: relativePath, operation: 'write' };
|
||||
}
|
||||
|
||||
async readFile(path: string): Promise<KloFileReadResult> {
|
||||
const relativePath = this.safeRelativePath(path);
|
||||
const absolutePath = this.absolutePath(relativePath);
|
||||
const content = await fs.readFile(absolutePath, 'utf-8');
|
||||
const stats = await fs.stat(absolutePath);
|
||||
return {
|
||||
path: relativePath,
|
||||
content,
|
||||
size: stats.size,
|
||||
modifiedAt: stats.mtime.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async deleteFile(
|
||||
path: string,
|
||||
author: string,
|
||||
authorEmail: string,
|
||||
commitMessage: string,
|
||||
options?: { skipLock?: boolean },
|
||||
): Promise<KloFileWriteResult | null> {
|
||||
const relativePath = this.safeRelativePath(path);
|
||||
const absolutePath = this.absolutePath(relativePath);
|
||||
try {
|
||||
await fs.access(absolutePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
await fs.unlink(absolutePath);
|
||||
|
||||
if (options?.skipLock) {
|
||||
return { success: true, commitHash: null, path: relativePath, operation: 'delete' };
|
||||
}
|
||||
|
||||
const info = await this.git.deleteFile(relativePath, commitMessage, author, authorEmail);
|
||||
return { ...gitInfoToWriteResult(info), path: relativePath, operation: 'delete' };
|
||||
}
|
||||
|
||||
async listFiles(path = '', stripPrefix = false): Promise<KloFileListResult> {
|
||||
const relativePath = path ? this.safeRelativePath(path) : '';
|
||||
const searchRoot = relativePath ? this.absolutePath(relativePath) : this.rootDir;
|
||||
let files: string[];
|
||||
|
||||
try {
|
||||
files = await this.walk(searchRoot);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return { files: [] };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const prefix = relativePath ? `${relativePath}/` : '';
|
||||
const relativeFiles = files
|
||||
.map((file) => normalizeRelativePath(relative(this.rootDir, file)))
|
||||
.filter((file) => !file.startsWith('.git/') && !file.includes('/.git/'))
|
||||
.filter((file) => !file.startsWith('.klo/cache/'))
|
||||
.map((file) => (stripPrefix && prefix && file.startsWith(prefix) ? file.slice(prefix.length) : file))
|
||||
.sort();
|
||||
|
||||
return { files: relativeFiles };
|
||||
}
|
||||
|
||||
async getFileHistory(path: string): Promise<KloFileHistoryEntry[]> {
|
||||
const relativePath = this.safeRelativePath(path);
|
||||
const history = await this.git.getFileHistory(relativePath);
|
||||
return history.map((entry) => ({
|
||||
sha: entry.commitHash,
|
||||
commitHash: entry.commitHash,
|
||||
shortHash: entry.shortHash,
|
||||
message: entry.message,
|
||||
author: entry.author,
|
||||
authorEmail: entry.authorEmail,
|
||||
timestamp: entry.timestamp,
|
||||
committedDate: entry.committedDate,
|
||||
created: entry.created,
|
||||
enhancedMessage: entry.enhancedMessage,
|
||||
}));
|
||||
}
|
||||
|
||||
private safeRelativePath(path: string): string {
|
||||
if (path.length === 0) {
|
||||
return '';
|
||||
}
|
||||
if (isAbsolute(path)) {
|
||||
throw new Error('Path must be relative');
|
||||
}
|
||||
|
||||
const normalized = normalizeRelativePath(path);
|
||||
if (normalized === '.git' || normalized.startsWith('.git/')) {
|
||||
throw new Error('Path cannot access .git');
|
||||
}
|
||||
|
||||
const absolute = resolve(this.rootDir, normalized);
|
||||
if (absolute !== this.rootDir && !absolute.startsWith(`${this.rootDir}${sep}`)) {
|
||||
throw new Error('Path escapes the project directory');
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private absolutePath(path: string): string {
|
||||
return path ? join(this.rootDir, path) : this.rootDir;
|
||||
}
|
||||
|
||||
private async walk(dir: string): Promise<string[]> {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
const files: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const absolute = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name !== '.git') {
|
||||
files.push(...(await this.walk(absolute)));
|
||||
}
|
||||
} else if (entry.isFile()) {
|
||||
files.push(absolute);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue