Files
makelore/electron/coding-projects/conversation-change-tracker.ts

358 lines
13 KiB
TypeScript

import { spawn } from 'node:child_process';
import { readFile, stat } from 'node:fs/promises';
import path from 'node:path';
const MAX_CHANGED_FILES = 200;
const MAX_DIFF_BYTES = 64 * 1024;
const MAX_UNTRACKED_PREVIEW_BYTES = 8 * 1024;
const MAX_GIT_OUTPUT_BYTES = 8 * 1024 * 1024;
export type ConversationChangedFileStatus = 'added' | 'modified' | 'deleted' | 'renamed' | 'untracked';
export interface ConversationChangedFile {
path: string;
status: ConversationChangedFileStatus;
diff?: string;
preview?: string;
truncated?: boolean;
}
export interface ConversationChangesSnapshot {
conversationId: string;
runId: string;
git: boolean;
baselineHead: string | null;
files: ConversationChangedFile[];
}
export interface GitCommandResult {
code: number;
stdout: string;
}
export interface ConversationGitAdapter {
run(projectPath: string, args: readonly string[], signal?: AbortSignal): Promise<GitCommandResult>;
}
export class ProcessConversationGitAdapter implements ConversationGitAdapter {
run(projectPath: string, args: readonly string[], signal?: AbortSignal): Promise<GitCommandResult> {
return new Promise((resolve, reject) => {
const child = spawn('git', ['-C', projectPath, '--literal-pathspecs', ...args], {
windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe'],
signal,
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', GIT_PAGER: 'cat' },
});
const chunks: Buffer[] = [];
let bytes = 0;
const collect = (chunk: Buffer) => {
bytes += chunk.byteLength;
if (bytes > MAX_GIT_OUTPUT_BYTES) {
child.kill();
reject(new Error('Git output is too large'));
return;
}
chunks.push(chunk);
};
child.stdout.on('data', collect);
child.stderr.on('data', () => undefined);
child.once('error', reject);
child.once('close', (code) => resolve({
code: code ?? 1,
stdout: Buffer.concat(chunks).toString('utf8'),
}));
});
}
}
interface StatusEntry {
path: string;
status: ConversationChangedFileStatus;
signature: string;
}
interface FileState extends StatusEntry {
content: string;
preview?: string;
truncated?: boolean;
}
interface ChangeBaseline {
git: boolean;
head: string | null;
files: Map<string, FileState>;
}
interface ChangeRunRecord {
conversationId: string;
runId: string;
projectPath: string;
baseline: ChangeBaseline;
touchedPaths: Set<string>;
projectRefresh: boolean;
snapshot: ConversationChangesSnapshot;
}
function normalizeRelativePath(value: string): string {
const raw = value.trim();
const trimmed = raw.replaceAll('\\', '/');
if (!trimmed || trimmed.includes('\0') || path.isAbsolute(raw) || path.win32.isAbsolute(raw)
|| path.posix.isAbsolute(trimmed)) {
throw new Error('Changed file path must be project-relative');
}
const normalized = path.posix.normalize(trimmed).replace(/^\.\//, '');
if (!normalized || normalized === '.' || normalized === '..' || normalized.startsWith('../')) {
throw new Error('Changed file path escapes the project');
}
return normalized;
}
function statusFromSignature(signature: string, renamed: boolean): ConversationChangedFileStatus {
if (signature === '??') return 'untracked';
if (renamed || signature.includes('R')) return 'renamed';
if (signature.includes('D')) return 'deleted';
if (signature.includes('A')) return 'added';
return 'modified';
}
function parsePorcelainV2(value: string): StatusEntry[] {
const records = value.split('\0');
const entries: StatusEntry[] = [];
for (let index = 0; index < records.length; index += 1) {
const record = records[index];
if (!record || record.startsWith('! ')) continue;
if (record.startsWith('? ')) {
const filePath = normalizeRelativePath(record.slice(2));
entries.push({ path: filePath, status: 'untracked', signature: '??' });
continue;
}
const renamed = record.startsWith('2 ');
const match = renamed
? record.match(/^2 ([^ ]+) [^ ]+ [^ ]+ [^ ]+ [^ ]+ [^ ]+ [^ ]+ [^ ]+ (.*)$/s)
: record.match(/^1 ([^ ]+) [^ ]+ [^ ]+ [^ ]+ [^ ]+ [^ ]+ [^ ]+ (.*)$/s);
if (!match) continue;
const signature = match[1];
const filePath = normalizeRelativePath(match[2]);
entries.push({
path: filePath,
status: statusFromSignature(signature, renamed),
signature,
});
if (renamed) index += 1;
}
return entries.slice(0, MAX_CHANGED_FILES);
}
function boundedText(value: string, maxBytes: number): { text: string; truncated: boolean } {
const data = Buffer.from(value, 'utf8');
if (data.byteLength <= maxBytes) return { text: value, truncated: false };
return { text: data.subarray(0, maxBytes).toString('utf8'), truncated: true };
}
async function untrackedPreview(
projectPath: string,
relativePath: string,
): Promise<{ preview?: string; content: string; truncated?: boolean }> {
const target = path.resolve(projectPath, ...relativePath.split('/'));
const relative = path.relative(path.resolve(projectPath), target);
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error('Changed file path escapes the project');
}
try {
const file = await stat(target);
if (!file.isFile()) return { content: 'not-file' };
const data = await readFile(target);
const bounded = data.subarray(0, MAX_UNTRACKED_PREVIEW_BYTES);
if (bounded.includes(0)) return { content: `binary:${data.byteLength}` };
const preview = bounded.toString('utf8');
return {
preview,
content: `text:${data.byteLength}:${preview}`,
...(data.byteLength > bounded.byteLength ? { truncated: true } : {}),
};
} catch (error) {
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
return { content: 'missing' };
}
throw error;
}
}
export class ConversationChangeTracker {
private readonly records = new Map<string, ChangeRunRecord>();
private readonly queues = new Map<string, Promise<void>>();
constructor(private readonly git: ConversationGitAdapter = new ProcessConversationGitAdapter()) {}
async beginRun(input: {
conversationId: string;
runId: string;
projectPath: string;
}): Promise<ConversationChangesSnapshot> {
return await this.lock(input.conversationId, async () => {
const current = this.records.get(input.conversationId);
if (current?.runId === input.runId) return structuredClone(current.snapshot);
const baseline = await this.capture(input.projectPath);
const snapshot: ConversationChangesSnapshot = {
conversationId: input.conversationId,
runId: input.runId,
git: baseline.git,
baselineHead: baseline.head,
files: [],
};
this.records.set(input.conversationId, {
...input,
baseline,
touchedPaths: new Set(),
projectRefresh: false,
snapshot,
});
return structuredClone(snapshot);
});
}
async recordTouchedPaths(
conversationId: string,
runId: string,
paths: readonly string[],
refresh = true,
): Promise<ConversationChangesSnapshot> {
return await this.lock(conversationId, async () => {
const record = this.requireRun(conversationId, runId);
for (const filePath of paths) record.touchedPaths.add(normalizeRelativePath(filePath));
if (refresh) record.snapshot = await this.refresh(record, false);
return structuredClone(record.snapshot);
});
}
async markProjectRefresh(conversationId: string, runId: string): Promise<void> {
await this.lock(conversationId, async () => {
this.requireRun(conversationId, runId).projectRefresh = true;
});
}
async settleRun(conversationId: string, runId: string): Promise<ConversationChangesSnapshot | null> {
return await this.lock(conversationId, async () => {
const record = this.records.get(conversationId);
if (!record || record.runId !== runId) return null;
record.snapshot = await this.refresh(record, record.projectRefresh || record.touchedPaths.size === 0);
return structuredClone(record.snapshot);
});
}
getSnapshot(conversationId: string): ConversationChangesSnapshot | null {
const snapshot = this.records.get(conversationId)?.snapshot;
return snapshot ? structuredClone(snapshot) : null;
}
private async capture(projectPath: string): Promise<ChangeBaseline> {
const repository = await this.git.run(projectPath, ['rev-parse', '--is-inside-work-tree']);
if (repository.code !== 0 || repository.stdout.trim() !== 'true') {
return { git: false, head: null, files: new Map() };
}
const [headResult, statusResult] = await Promise.all([
this.git.run(projectPath, ['rev-parse', 'HEAD']),
this.git.run(projectPath, ['status', '--porcelain=v2', '-z', '--untracked-files=all', '--', '.']),
]);
if (statusResult.code !== 0) return { git: false, head: null, files: new Map() };
const entries = parsePorcelainV2(statusResult.stdout);
const files = new Map<string, FileState>();
for (const entry of entries) files.set(entry.path, await this.readState(projectPath, entry));
return {
git: true,
head: headResult.code === 0 ? headResult.stdout.trim() || null : null,
files,
};
}
private async currentStates(projectPath: string): Promise<Map<string, FileState>> {
const status = await this.git.run(
projectPath,
['status', '--porcelain=v2', '-z', '--untracked-files=all', '--', '.'],
);
if (status.code !== 0) return new Map();
const result = new Map<string, FileState>();
for (const entry of parsePorcelainV2(status.stdout)) {
result.set(entry.path, await this.readState(projectPath, entry));
}
return result;
}
private async readState(projectPath: string, entry: StatusEntry): Promise<FileState> {
if (entry.status === 'untracked') {
return { ...entry, ...await untrackedPreview(projectPath, entry.path) };
}
const [working, staged] = await Promise.all([
this.git.run(projectPath, ['diff', '--no-ext-diff', '--no-color', '--relative', '--', entry.path]),
this.git.run(projectPath, ['diff', '--cached', '--no-ext-diff', '--no-color', '--relative', '--', entry.path]),
]);
const bounded = boundedText(`${staged.stdout}${working.stdout}`, MAX_DIFF_BYTES);
return {
...entry,
content: bounded.text,
...(bounded.truncated ? { truncated: true } : {}),
};
}
private async refresh(record: ChangeRunRecord, projectWide: boolean): Promise<ConversationChangesSnapshot> {
if (!record.baseline.git) {
const files: ConversationChangedFile[] = [];
for (const filePath of [...record.touchedPaths].sort().slice(0, MAX_CHANGED_FILES)) {
const current = await untrackedPreview(record.projectPath, filePath);
files.push({
path: filePath,
status: current.content === 'missing' ? 'deleted' : 'modified',
...(current.preview !== undefined ? { preview: current.preview } : {}),
...(current.truncated ? { truncated: true } : {}),
});
}
return { ...record.snapshot, files };
}
const current = await this.currentStates(record.projectPath);
const candidates = projectWide
? [...current.keys(), ...record.baseline.files.keys()]
: [...record.touchedPaths];
const files: ConversationChangedFile[] = [];
for (const filePath of [...new Set(candidates)].sort().slice(0, MAX_CHANGED_FILES)) {
const next = current.get(filePath);
const baseline = record.baseline.files.get(filePath);
if (!next) {
if (baseline) {
files.push({
path: filePath,
status: baseline.status === 'untracked' ? 'deleted' : 'modified',
});
}
continue;
}
if (baseline?.signature === next.signature && baseline.content === next.content) continue;
files.push({
path: next.path,
status: next.status,
...(next.status === 'untracked' && next.preview !== undefined ? { preview: next.preview } : {}),
...(next.status !== 'untracked' && next.content ? { diff: next.content } : {}),
...(next.truncated ? { truncated: true } : {}),
});
}
return { ...record.snapshot, files };
}
private requireRun(conversationId: string, runId: string): ChangeRunRecord {
const record = this.records.get(conversationId);
if (!record || record.runId !== runId) throw new Error('Conversation change run is stale');
return record;
}
private async lock<T>(conversationId: string, operation: () => Promise<T>): Promise<T> {
const previous = this.queues.get(conversationId) ?? Promise.resolve();
const flight = previous.catch(() => undefined).then(operation);
const tail = flight.then(() => undefined, () => undefined);
this.queues.set(conversationId, tail);
try {
return await flight;
} finally {
if (this.queues.get(conversationId) === tail) this.queues.delete(conversationId);
}
}
}