import { spawn } from 'node:child_process'; import { open, stat } from 'node:fs/promises'; import path from 'node:path'; import type { ConversationChangedFile, ConversationChangesSnapshot, CodingProjectFileStatus, } from '../../shared/coding-product-tools'; export type { ConversationChangedFile, ConversationChangesSnapshot, } from '../../shared/coding-product-tools'; 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 = CodingProjectFileStatus; export interface GitCommandResult { code: number; stdout: string; } export interface ConversationGitAdapter { run(projectPath: string, args: readonly string[], signal?: AbortSignal): Promise; } export class ProcessConversationGitAdapter implements ConversationGitAdapter { run(projectPath: string, args: readonly string[], signal?: AbortSignal): Promise { 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 { identity: string; preview?: string; truncated?: boolean; } interface ChangeBaseline { git: boolean; head: string | null; files: Map; } interface ChangeRunRecord { conversationId: string; runId: string; projectPath: string; baseline: ChangeBaseline; touchedPaths: Set; 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, conflicted = false, ): ConversationChangedFileStatus { if (signature === '??') return 'untracked'; if (conflicted) return 'conflicted'; 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 conflicted = record.startsWith('u '); const match = conflicted ? record.match(/^u ([^ ]+) [^ ]+ [^ ]+ [^ ]+ [^ ]+ [^ ]+ [^ ]+ [^ ]+ [^ ]+ (.*)$/s) : 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, conflicted), 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; identity: 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 { identity: `not-file:${file.mtimeMs}` }; const byteLength = Math.min(file.size, MAX_UNTRACKED_PREVIEW_BYTES); const data = Buffer.alloc(byteLength); const handle = await open(target, 'r'); let bytesRead = 0; try { ({ bytesRead } = await handle.read(data, 0, byteLength, 0)); } finally { await handle.close(); } const bounded = data.subarray(0, bytesRead); const identity = `${file.size}:${file.mtimeMs}`; if (bounded.includes(0)) return { identity: `binary:${identity}` }; const preview = bounded.toString('utf8'); return { preview, identity: `text:${identity}`, ...(file.size > bounded.byteLength ? { truncated: true } : {}), }; } catch (error) { if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { return { identity: 'missing' }; } throw error; } } async function trackedIdentity(projectPath: string, entry: StatusEntry): Promise { const target = path.resolve(projectPath, ...entry.path.split('/')); try { const file = await stat(target); return { ...entry, identity: `${file.isFile() ? 'file' : 'not-file'}:${file.size}:${file.mtimeMs}`, }; } catch (error) { if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { return { ...entry, identity: 'missing' }; } throw error; } } export class ConversationChangeTracker { private readonly records = new Map(); private readonly queues = new Map>(); constructor(private readonly git: ConversationGitAdapter = new ProcessConversationGitAdapter()) {} async beginRun(input: { conversationId: string; runId: string; projectPath: string; }): Promise { 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 { 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 { await this.lock(conversationId, async () => { this.requireRun(conversationId, runId).projectRefresh = true; }); } async settleRun(conversationId: string, runId: string): Promise { 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 { let repository: GitCommandResult; try { repository = await this.git.run(projectPath, ['rev-parse', '--is-inside-work-tree']); } catch { return { git: false, head: null, files: new Map() }; } if (repository.code !== 0 || repository.stdout.trim() !== 'true') { return { git: false, head: null, files: new Map() }; } let headResult: GitCommandResult; let statusResult: GitCommandResult; try { [headResult, statusResult] = await Promise.all([ this.git.run(projectPath, ['rev-parse', 'HEAD']), this.git.run(projectPath, ['status', '--porcelain=v2', '-z', '--untracked-files=all', '--', '.']), ]); } catch { return { git: false, head: null, files: new Map() }; } if (statusResult.code !== 0) return { git: false, head: null, files: new Map() }; const entries = parsePorcelainV2(statusResult.stdout); const states = await Promise.all(entries.map((entry) => this.readState(projectPath, entry))); const files = new Map(states.map((state) => [state.path, state])); return { git: true, head: headResult.code === 0 ? headResult.stdout.trim() || null : null, files, }; } private async currentEntries(projectPath: string): Promise | null> { try { const status = await this.git.run( projectPath, ['status', '--porcelain=v2', '-z', '--untracked-files=all', '--', '.'], ); if (status.code !== 0) return null; return new Map(parsePorcelainV2(status.stdout).map((entry) => [entry.path, entry])); } catch { return null; } } private async readState(projectPath: string, entry: StatusEntry): Promise { if (entry.status === 'untracked') { return { ...entry, ...await untrackedPreview(projectPath, entry.path) }; } return await trackedIdentity(projectPath, entry); } private async readDiff(projectPath: string, relativePath: string): Promise<{ diff?: string; truncated?: boolean; }> { let working: GitCommandResult; let staged: GitCommandResult; try { [working, staged] = await Promise.all([ this.git.run(projectPath, ['diff', '--no-ext-diff', '--no-color', '--relative', '--', relativePath]), this.git.run(projectPath, ['diff', '--cached', '--no-ext-diff', '--no-color', '--relative', '--', relativePath]), ]); } catch { return {}; } if (working.code !== 0 || staged.code !== 0) return {}; const bounded = boundedText(`${staged.stdout}${working.stdout}`, MAX_DIFF_BYTES); return { ...(bounded.text ? { diff: bounded.text } : {}), ...(bounded.truncated ? { truncated: true } : {}), }; } private async refresh(record: ChangeRunRecord, projectWide: boolean): Promise { 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.identity === 'missing' ? 'deleted' : 'modified', ...(current.preview !== undefined ? { preview: current.preview } : {}), ...(current.truncated ? { truncated: true } : {}), }); } return { ...record.snapshot, files }; } const current = await this.currentEntries(record.projectPath); if (!current) { const files: ConversationChangedFile[] = []; for (const filePath of [...record.touchedPaths].sort().slice(0, MAX_CHANGED_FILES)) { const state = await untrackedPreview(record.projectPath, filePath); files.push({ path: filePath, status: state.identity === 'missing' ? 'deleted' : 'modified', ...(state.preview !== undefined ? { preview: state.preview } : {}), ...(state.truncated ? { truncated: true } : {}), }); } return { ...record.snapshot, git: false, files }; } const candidates = projectWide ? [...current.keys(), ...record.baseline.files.keys()] : [...record.touchedPaths]; const files: ConversationChangedFile[] = []; const candidatePaths = [...new Set(candidates)].sort().slice(0, MAX_CHANGED_FILES); const states = await Promise.all(candidatePaths.map(async (filePath) => { const entry = current.get(filePath); return { filePath, next: entry ? await this.readState(record.projectPath, entry) : undefined }; })); for (const { filePath, next } of states) { 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.identity === next.identity) continue; const diff = next.status === 'untracked' ? {} : await this.readDiff(record.projectPath, next.path); files.push({ path: next.path, status: next.status, ...(next.status === 'untracked' && next.preview !== undefined ? { preview: next.preview } : {}), ...diff, ...(next.truncated || diff.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(conversationId: string, operation: () => Promise): Promise { 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); } } }