429 lines
15 KiB
TypeScript
429 lines
15 KiB
TypeScript
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<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 {
|
|
identity: 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,
|
|
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<FileState> {
|
|
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<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> {
|
|
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<Map<string, StatusEntry> | 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<FileState> {
|
|
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<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.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<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);
|
|
}
|
|
}
|
|
}
|