import * as fs from 'node:fs/promises' import { execFile as execFileCallback } from 'node:child_process' import * as path from 'node:path' import { promisify } from 'node:util' import { diffLines } from 'diff' import type { MessageEntry } from './sessionService.js' import type { FileHistorySnapshot } from '../../utils/fileHistory.js' import { getClaudeConfigHomeDir } from '../../utils/envUtils.js' const MAX_PREVIEW_BYTES = 1024 * 1024 const MAX_UNTRACKED_STAT_BYTES = 256 * 1024 const GIT_TIMEOUT_MS = 5_000 const MAX_GIT_BUFFER_BYTES = 2_000_000 const execFile = promisify(execFileCallback) const LANGUAGE_MAP: Record = { cjs: 'javascript', css: 'css', go: 'go', html: 'html', java: 'java', js: 'javascript', json: 'json', jsx: 'jsx', md: 'markdown', mjs: 'javascript', py: 'python', rs: 'rust', sh: 'bash', sql: 'sql', ts: 'typescript', tsx: 'tsx', txt: 'text', xml: 'xml', yaml: 'yaml', yml: 'yaml', } const IMAGE_MIME_BY_EXTENSION: Record = { apng: 'image/apng', avif: 'image/avif', gif: 'image/gif', jpeg: 'image/jpeg', jpg: 'image/jpeg', png: 'image/png', svg: 'image/svg+xml', webp: 'image/webp', } export type WorkspaceFileStatus = | 'modified' | 'added' | 'deleted' | 'renamed' | 'untracked' | 'copied' | 'type_changed' | 'unknown' export type WorkspaceChangedFile = { path: string oldPath?: string status: WorkspaceFileStatus additions: number deletions: number } export type WorkspaceStatusResult = { state: 'ok' | 'not_git_repo' | 'missing_workdir' | 'error' workDir: string repoName: string | null branch: string | null isGitRepo: boolean changedFiles: WorkspaceChangedFile[] error?: string } export type WorkspaceReadFileResult = { state: 'ok' | 'binary' | 'too_large' | 'missing' | 'error' path: string previewType?: 'text' | 'image' content?: string dataUrl?: string mimeType?: string language: string size: number truncated?: boolean readBytes?: number error?: string } export type WorkspaceTreeEntry = { name: string path: string isDirectory: boolean } export type WorkspaceTreeResult = { state: 'ok' | 'missing' | 'error' path: string entries: WorkspaceTreeEntry[] error?: string } export type WorkspaceDiffResult = { state: 'ok' | 'missing' | 'not_git_repo' | 'error' path: string diff?: string error?: string } type StatusEntry = { path: string oldPath?: string code: string status: WorkspaceFileStatus } type ScopedStatusEntry = { repoPath: string repoOldPath?: string path: string oldPath?: string status: WorkspaceFileStatus absolutePath: string canonicalWorkspaceRoot: string } type GitRepoInfo = | { kind: 'not_git_repo' } | { kind: 'ok' repoRoot: string branch: string | null } | { kind: 'error' message: string } type WorkspacePathResolution = { requestedPath: string relativePath: string absolutePath: string workspaceRoot: string canonicalWorkspaceRoot: string canonicalTargetPath: string } type WorkspaceStatResult = | { kind: 'ok' stat: Awaited> } | { kind: 'missing' } | { kind: 'error' message: string } type GitCommandResult = { stdout: string stderr: string code: number } type DiffStatsResult = | { kind: 'ok' additions: number deletions: number } | { kind: 'error' message: string } type DiffStatsByRepoPathResult = | { kind: 'ok' statsByRepoPath: Map } | { kind: 'error' message: string } type UntrackedDiffResult = | { kind: 'ok' diff: string } | { kind: 'missing' } | { kind: 'error' message: string } type SessionFileChange = WorkspaceChangedFile & { diff?: string } export function parseStatus(code: string): WorkspaceFileStatus { const x = code[0] ?? ' ' const y = code[1] ?? ' ' if (code === '??') return 'untracked' if (x === 'R' || y === 'R') return 'renamed' if (x === 'C' || y === 'C') return 'copied' if (x === 'T' || y === 'T') return 'type_changed' if (x === 'D' || y === 'D') return 'deleted' if (x === 'A' || y === 'A') return 'added' if (x === 'M' || y === 'M') return 'modified' return 'unknown' } export class WorkspaceService { constructor( private readonly resolveSessionWorkDir: ( sessionId: string, ) => Promise, private readonly resolveSessionMessages: ( sessionId: string, ) => Promise = async () => [], private readonly resolveSessionFileHistorySnapshots: ( sessionId: string, ) => Promise = async () => [], ) {} async getStatus(sessionId: string): Promise { const workDir = await this.requireWorkDir(sessionId) const workspaceInfo = await this.getWorkspaceRoot(workDir) if (workspaceInfo.kind === 'missing') { return { state: 'missing_workdir', workDir, repoName: null, branch: null, isGitRepo: false, changedFiles: [], } } if (workspaceInfo.kind === 'error') { return { state: 'error', workDir, repoName: null, branch: null, isGitRepo: false, changedFiles: [], error: workspaceInfo.message, } } const repoInfo = await this.getGitRepoInfo(workDir) const sessionChanges = this.mergeSessionFileChanges( [ ...await this.getSessionFileChanges( sessionId, workspaceInfo.workspaceRoot, ), ...await this.getFileHistoryChanges( sessionId, workspaceInfo.workspaceRoot, ), ], ) if (repoInfo.kind === 'not_git_repo') { sessionChanges.sort((a, b) => a.path.localeCompare(b.path)) return { state: 'ok', workDir, repoName: path.basename(workspaceInfo.workspaceRoot), branch: null, isGitRepo: false, changedFiles: sessionChanges.map(({ diff: _diff, ...change }) => change), } } if (repoInfo.kind === 'error') { return { state: 'error', workDir, repoName: null, branch: null, isGitRepo: false, changedFiles: [], error: repoInfo.message, } } const statusEntries = await this.getStatusEntries(repoInfo.repoRoot) if (statusEntries.kind === 'error') { return { state: 'error', workDir, repoName: path.basename(repoInfo.repoRoot), branch: repoInfo.branch, isGitRepo: true, changedFiles: [], error: statusEntries.message, } } const scopedEntries = this.scopeStatusEntries( statusEntries.entries, repoInfo.repoRoot, workspaceInfo.canonicalWorkspaceRoot, ) const trackedStats = await this.getTrackedDiffStats(repoInfo.repoRoot, scopedEntries) if (trackedStats.kind === 'error') { return { state: 'error', workDir, repoName: path.basename(repoInfo.repoRoot), branch: repoInfo.branch, isGitRepo: true, changedFiles: [], error: trackedStats.message, } } const changedFiles = await Promise.all( scopedEntries.map(async (entry) => { const stats = entry.status === 'untracked' ? await this.getDiffStats(repoInfo.repoRoot, entry) : { kind: 'ok' as const, ...(trackedStats.statsByRepoPath.get(entry.repoPath) ?? { additions: 0, deletions: 0, }), } if (stats.kind === 'error') { throw new Error(stats.message) } return { path: entry.path, oldPath: entry.oldPath, status: entry.status, additions: stats.additions, deletions: stats.deletions, } satisfies WorkspaceChangedFile }), ).catch((error) => error as Error) if (changedFiles instanceof Error) { return { state: 'error', workDir, repoName: path.basename(repoInfo.repoRoot), branch: repoInfo.branch, isGitRepo: true, changedFiles: [], error: changedFiles.message, } } changedFiles.sort((a, b) => a.path.localeCompare(b.path)) const changedFileByPath = new Map(changedFiles.map((file) => [file.path, file])) for (const change of sessionChanges) { if (!changedFileByPath.has(change.path)) { changedFileByPath.set(change.path, { path: change.path, oldPath: change.oldPath, status: change.status, additions: change.additions, deletions: change.deletions, }) } } const mergedChangedFiles = [...changedFileByPath.values()] .sort((a, b) => a.path.localeCompare(b.path)) return { state: 'ok', workDir, repoName: path.basename(repoInfo.repoRoot), branch: repoInfo.branch, isGitRepo: true, changedFiles: mergedChangedFiles, } } async readFile( sessionId: string, filePath: string, ): Promise { const resolvedPath = await this.resolveWorkspacePath(sessionId, filePath) const stat = await this.safeStat(resolvedPath.absolutePath) if (stat.kind === 'error') { return { state: 'error', path: resolvedPath.relativePath, language: this.detectLanguage(resolvedPath.absolutePath), size: 0, error: stat.message, } } if (stat.kind === 'missing' || !stat.stat.isFile()) { return { state: 'missing', path: resolvedPath.relativePath, language: this.detectLanguage(resolvedPath.absolutePath), size: 0, } } const language = this.detectLanguage(resolvedPath.absolutePath) const imageMimeType = this.detectImageMimeType(resolvedPath.absolutePath) let content: Buffer try { if (!imageMimeType && stat.stat.size > MAX_PREVIEW_BYTES) { const fileHandle = await fs.open(resolvedPath.absolutePath, 'r') try { const previewBuffer = Buffer.alloc(MAX_PREVIEW_BYTES) const { bytesRead } = await fileHandle.read(previewBuffer, 0, MAX_PREVIEW_BYTES, 0) content = previewBuffer.subarray(0, bytesRead) } finally { await fileHandle.close() } } else { content = await fs.readFile(resolvedPath.absolutePath) } } catch (error) { return { state: 'error', path: resolvedPath.relativePath, language, size: stat.stat.size, error: this.formatFsError( 'Failed to read workspace file', resolvedPath.absolutePath, error, ), } } if (imageMimeType) { return { state: 'ok', path: resolvedPath.relativePath, previewType: 'image', dataUrl: `data:${imageMimeType};base64,${content.toString('base64')}`, mimeType: imageMimeType, language: 'image', size: stat.stat.size, } } if (content.includes(0)) { return { state: 'binary', path: resolvedPath.relativePath, language: 'binary', size: stat.stat.size, } } return { state: 'ok', path: resolvedPath.relativePath, previewType: 'text', content: content.toString('utf8'), language, size: stat.stat.size, truncated: content.length < stat.stat.size, readBytes: content.length, } } async readTree( sessionId: string, treePath = '', ): Promise { const resolvedPath = await this.resolveWorkspacePath(sessionId, treePath) const stat = await this.safeStat(resolvedPath.absolutePath) if (stat.kind === 'error') { return { state: 'error', path: resolvedPath.relativePath, entries: [], error: stat.message, } } if (stat.kind === 'missing' || !stat.stat.isDirectory()) { return { state: 'missing', path: resolvedPath.relativePath, entries: [] } } let entries: import('node:fs').Dirent[] try { entries = await fs.readdir(resolvedPath.absolutePath, { withFileTypes: true }) } catch (error) { return { state: 'error', path: resolvedPath.relativePath, entries: [], error: this.formatFsError( 'Failed to read workspace directory', resolvedPath.absolutePath, error, ), } } const visibleEntries = entries .filter((entry) => !entry.name.startsWith('.')) .sort((a, b) => { if (a.isDirectory() !== b.isDirectory()) { return a.isDirectory() ? -1 : 1 } return a.name.localeCompare(b.name) }) .map((entry) => ({ name: entry.name, path: this.normalizeRelativePath( path.relative( resolvedPath.workspaceRoot, path.join(resolvedPath.absolutePath, entry.name), ), ), isDirectory: entry.isDirectory(), })) return { state: 'ok', path: resolvedPath.relativePath, entries: visibleEntries, } } async getDiff( sessionId: string, filePath: string, ): Promise { let resolvedPath: WorkspacePathResolution try { resolvedPath = await this.resolveWorkspacePath(sessionId, filePath) } catch (error) { return { state: 'error', path: this.normalizeRequestedPath(filePath), error: error instanceof Error ? error.message : String(error), } } const sessionDiff = await this.getSessionDiff(sessionId, resolvedPath.relativePath) if (sessionDiff) { return { state: 'ok', path: resolvedPath.relativePath, diff: sessionDiff } } const fileHistoryDiff = await this.getFileHistoryDiff( sessionId, resolvedPath.workspaceRoot, resolvedPath.relativePath, ) if (fileHistoryDiff) { return { state: 'ok', path: resolvedPath.relativePath, diff: fileHistoryDiff } } const repoInfo = await this.getGitRepoInfo(resolvedPath.workspaceRoot) if (repoInfo.kind === 'not_git_repo') { return { state: 'not_git_repo', path: resolvedPath.relativePath } } if (repoInfo.kind === 'error') { return { state: 'error', path: resolvedPath.relativePath, error: repoInfo.message, } } const statusEntries = await this.getStatusEntries(repoInfo.repoRoot) if (statusEntries.kind === 'error') { return { state: 'error', path: resolvedPath.relativePath, error: statusEntries.message, } } const scopedEntries = this.scopeStatusEntries( statusEntries.entries, repoInfo.repoRoot, resolvedPath.canonicalWorkspaceRoot, ) const repoRelativePath = this.toRepoRelativePath( repoInfo.repoRoot, resolvedPath.canonicalTargetPath, ) const statusEntry = scopedEntries.find( (entry) => entry.repoPath === repoRelativePath || entry.repoOldPath === repoRelativePath, ) if (!statusEntry) { return { state: 'missing', path: resolvedPath.relativePath } } if (statusEntry.status === 'untracked') { const diff = await this.buildUntrackedDiff( resolvedPath.canonicalTargetPath, resolvedPath.relativePath, ) if (diff.kind === 'missing') { return { state: 'missing', path: resolvedPath.relativePath } } if (diff.kind === 'error') { return { state: 'error', path: resolvedPath.relativePath, error: diff.message, } } return { state: 'ok', path: resolvedPath.relativePath, diff: diff.diff } } const targetPath = statusEntry.repoPath const diff = await this.runGitDiff(repoInfo.repoRoot, targetPath) if (diff.kind === 'error') { return { state: 'error', path: resolvedPath.relativePath, error: diff.message, } } if (!diff.diff.trim()) { return { state: 'missing', path: resolvedPath.relativePath } } return { state: 'ok', path: resolvedPath.relativePath, diff: diff.diff } } private async getSessionDiff( sessionId: string, relativePath: string, ): Promise { const workDir = await this.requireWorkDir(sessionId) const changes = await this.getSessionFileChanges(sessionId, workDir) const change = changes.find((entry) => entry.path === relativePath) if (!change) return null if (change.diff?.trim()) return change.diff const file = await this.readFile(sessionId, relativePath) if (file.state !== 'ok' || file.previewType === 'image' || typeof file.content !== 'string') { return null } return this.buildSyntheticDiff('/dev/null', relativePath, '', file.content) } private async getSessionFileChanges( sessionId: string, workspaceRoot: string, ): Promise { let messages: MessageEntry[] try { messages = await this.resolveSessionMessages(sessionId) } catch { return [] } const changes = new Map() for (const message of messages) { if (message.type !== 'tool_use' || !Array.isArray(message.content)) continue for (const block of message.content) { if (!block || typeof block !== 'object') continue const record = block as Record if (record.type !== 'tool_use' || typeof record.name !== 'string') continue const input = record.input if (!input || typeof input !== 'object') continue for (const change of this.extractSessionChangesFromTool( record.name, input as Record, workspaceRoot, )) { const existing = changes.get(change.path) if (!existing) { changes.set(change.path, change) continue } changes.set(change.path, { ...existing, status: existing.status === 'added' ? existing.status : change.status, additions: existing.additions + change.additions, deletions: existing.deletions + change.deletions, diff: [existing.diff, change.diff].filter(Boolean).join('\n'), }) } } } return [...changes.values()] } private async getFileHistoryChanges( sessionId: string, workspaceRoot: string, ): Promise { let snapshots: FileHistorySnapshot[] try { snapshots = await this.resolveSessionFileHistorySnapshots(sessionId) } catch { return [] } if (snapshots.length === 0) return [] const changes: SessionFileChange[] = [] const trackedPaths = this.collectFileHistoryTrackedPaths(snapshots) for (const trackingPath of trackedPaths) { const relativePath = this.resolveFileHistoryRelativePath(trackingPath, workspaceRoot) if (!relativePath) continue const beforeContent = await this.readFileHistoryBackupContent( sessionId, this.getEarliestFileHistoryBackupName(trackingPath, snapshots), ) if (beforeContent === undefined) continue const absolutePath = path.resolve(workspaceRoot, relativePath) const afterContent = await this.readTextFileOrNull(absolutePath) if (beforeContent === afterContent) continue const stats = this.countDiffStats(beforeContent ?? '', afterContent ?? '') changes.push({ path: relativePath, status: beforeContent === null ? 'added' : afterContent === null ? 'deleted' : 'modified', additions: stats.additions, deletions: stats.deletions, diff: this.buildSyntheticDiff( beforeContent === null ? '/dev/null' : relativePath, afterContent === null ? '/dev/null' : relativePath, beforeContent ?? '', afterContent ?? '', ), }) } return changes } private async getFileHistoryDiff( sessionId: string, workspaceRoot: string, relativePath: string, ): Promise { const changes = await this.getFileHistoryChanges(sessionId, workspaceRoot) return changes.find((change) => change.path === relativePath)?.diff ?? null } private mergeSessionFileChanges(changes: SessionFileChange[]): SessionFileChange[] { const merged = new Map() for (const change of changes) { const existing = merged.get(change.path) if (!existing) { merged.set(change.path, change) continue } merged.set(change.path, { ...existing, status: change.status, additions: change.additions, deletions: change.deletions, diff: change.diff ?? existing.diff, }) } return [...merged.values()] } private collectFileHistoryTrackedPaths(snapshots: FileHistorySnapshot[]): Set { const trackedPaths = new Set() for (const snapshot of snapshots) { for (const trackingPath of Object.keys(snapshot.trackedFileBackups)) { trackedPaths.add(trackingPath) } } return trackedPaths } private getEarliestFileHistoryBackupName( trackingPath: string, snapshots: FileHistorySnapshot[], ): string | null | undefined { for (const snapshot of snapshots) { const backup = snapshot.trackedFileBackups[trackingPath] if (backup !== undefined) { return backup.backupFileName } } return undefined } private resolveFileHistoryRelativePath( trackingPath: string, workspaceRoot: string, ): string | null { const absolutePath = path.isAbsolute(trackingPath) ? path.resolve(trackingPath) : path.resolve(workspaceRoot, trackingPath) if (!this.isWithinRoot(absolutePath, workspaceRoot)) return null return this.normalizeRelativePath(path.relative(workspaceRoot, absolutePath)) } private async readFileHistoryBackupContent( sessionId: string, backupFileName: string | null | undefined, ): Promise { if (backupFileName === undefined) return undefined if (backupFileName === null) return null return await this.readTextFileOrNull( path.join(getClaudeConfigHomeDir(), 'file-history', sessionId, backupFileName), ) } private async readTextFileOrNull(filePath: string): Promise { try { const content = await fs.readFile(filePath) if (content.includes(0)) return null return content.toString('utf8') } catch { return null } } private countDiffStats(oldContent: string, newContent: string): { additions: number; deletions: number } { let additions = 0 let deletions = 0 for (const change of diffLines(oldContent, newContent)) { if (change.added) additions += change.count || 0 if (change.removed) deletions += change.count || 0 } return { additions, deletions } } private extractSessionChangesFromTool( toolName: string, input: Record, workspaceRoot: string, ): SessionFileChange[] { const normalizedToolName = toolName.toLowerCase() if (normalizedToolName === 'write') { const filePath = this.resolveSessionToolPath(input.file_path ?? input.path, workspaceRoot) if (!filePath) return [] const content = typeof input.content === 'string' ? input.content : '' return [{ path: filePath, status: 'added', additions: this.countChangedLines(content), deletions: 0, diff: this.buildSyntheticDiff('/dev/null', filePath, '', content), }] } if (normalizedToolName === 'edit') { const filePath = this.resolveSessionToolPath(input.file_path ?? input.path, workspaceRoot) if (!filePath) return [] return [this.buildEditSessionChange(filePath, input)] } if (normalizedToolName === 'multiedit') { const filePath = this.resolveSessionToolPath(input.file_path ?? input.path, workspaceRoot) if (!filePath || !Array.isArray(input.edits)) return [] return input.edits .filter((edit): edit is Record => !!edit && typeof edit === 'object') .map((edit) => this.buildEditSessionChange(filePath, edit)) } if (normalizedToolName === 'notebookedit') { const filePath = this.resolveSessionToolPath( input.notebook_path ?? input.file_path ?? input.path, workspaceRoot, ) if (!filePath) return [] const oldString = typeof input.old_source === 'string' ? input.old_source : '' const newString = typeof input.new_source === 'string' ? input.new_source : '' return [{ path: filePath, status: oldString ? 'modified' : 'added', additions: this.countChangedLines(newString), deletions: this.countChangedLines(oldString), diff: this.buildSyntheticDiff(filePath, filePath, oldString, newString), }] } if (normalizedToolName === 'apply_patch') { return this.extractApplyPatchSessionChanges(input.patch, workspaceRoot) } return [] } private buildEditSessionChange( filePath: string, input: Record, ): SessionFileChange { const oldString = typeof input.old_string === 'string' ? input.old_string : '' const newString = typeof input.new_string === 'string' ? input.new_string : '' return { path: filePath, status: oldString ? 'modified' : 'added', additions: this.countChangedLines(newString), deletions: this.countChangedLines(oldString), diff: this.buildSyntheticDiff(filePath, filePath, oldString, newString), } } private extractApplyPatchSessionChanges( patch: unknown, workspaceRoot: string, ): SessionFileChange[] { if (typeof patch !== 'string') return [] const changes: SessionFileChange[] = [] for (const line of patch.split('\n')) { const match = line.match(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/) if (!match?.[1]) continue const filePath = this.resolveSessionToolPath(match[1], workspaceRoot) if (!filePath) continue const status: WorkspaceFileStatus = line.includes('Add File') ? 'added' : line.includes('Delete File') ? 'deleted' : 'modified' changes.push({ path: filePath, status, additions: 0, deletions: 0, }) } return changes } private resolveSessionToolPath( filePath: unknown, workspaceRoot: string, ): string | null { if (typeof filePath !== 'string' || !filePath.trim()) return null const absolutePath = path.isAbsolute(filePath) ? path.resolve(filePath) : path.resolve(workspaceRoot, filePath) if (!this.isWithinRoot(absolutePath, workspaceRoot)) return null return this.normalizeRelativePath(path.relative(workspaceRoot, absolutePath)) } private countChangedLines(value: string): number { if (!value) return 0 return value.endsWith('\n') ? value.split('\n').length - 1 : value.split('\n').length } private buildSyntheticDiff( oldPath: string, newPath: string, oldContent: string, newContent: string, ): string { const oldLines = oldContent ? oldContent.split('\n') : [] const newLines = newContent ? newContent.split('\n') : [] if (oldLines.at(-1) === '') oldLines.pop() if (newLines.at(-1) === '') newLines.pop() return [ `diff --session ${ oldPath === '/dev/null' ? '/dev/null' : `a/${oldPath}` } ${ newPath === '/dev/null' ? '/dev/null' : `b/${newPath}` }`, `--- ${oldPath === '/dev/null' ? '/dev/null' : `a/${oldPath}`}`, `+++ ${newPath === '/dev/null' ? '/dev/null' : `b/${newPath}`}`, `@@ -1,${oldLines.length} +1,${newLines.length} @@`, ...oldLines.map((line) => `-${line}`), ...newLines.map((line) => `+${line}`), ].join('\n') } private async requireWorkDir(sessionId: string): Promise { const workDir = await this.resolveSessionWorkDir(sessionId) if (!workDir) { throw new Error(`Session not found: ${sessionId}`) } return path.resolve(workDir) } private async getWorkspaceRoot( workDir: string, ): Promise< | { kind: 'ok'; workspaceRoot: string; canonicalWorkspaceRoot: string } | { kind: 'missing' } | { kind: 'error'; message: string } > { const stat = await this.safeStat(workDir) if (stat.kind === 'missing') { return { kind: 'missing' } } if (stat.kind === 'error') { return { kind: 'error', message: stat.message } } try { return { kind: 'ok', workspaceRoot: workDir, canonicalWorkspaceRoot: await fs.realpath(workDir), } } catch (error) { return { kind: 'error', message: this.formatFsError( 'Failed to canonicalize workspace root', workDir, error, ), } } } private async resolveWorkspacePath( sessionId: string, requestedPath: string, ): Promise { const workDir = await this.requireWorkDir(sessionId) const workspaceRoot = await this.getWorkspaceRoot(workDir) if (workspaceRoot.kind === 'missing') { throw new Error(`Workspace root is missing: ${workDir}`) } if (workspaceRoot.kind === 'error') { throw new Error(workspaceRoot.message) } const absolutePath = path.resolve(workDir, requestedPath || '.') if (!this.isWithinRoot(absolutePath, workDir)) { throw new Error(`Path is outside workspace: ${requestedPath}`) } const canonicalTargetPath = await this.resolveCanonicalTargetPath( workspaceRoot.canonicalWorkspaceRoot, absolutePath, requestedPath, ) return { absolutePath, requestedPath, workspaceRoot: workspaceRoot.workspaceRoot, canonicalWorkspaceRoot: workspaceRoot.canonicalWorkspaceRoot, canonicalTargetPath, relativePath: this.normalizeRelativePath( path.relative(workspaceRoot.workspaceRoot, absolutePath), ), } } private async validateCanonicalWorkspacePath( canonicalWorkspaceRoot: string, absolutePath: string, requestedPath: string, ): Promise< | { kind: 'ok'; canonicalTargetPath: string } | { kind: 'error'; message: string } > { try { return { kind: 'ok', canonicalTargetPath: await this.resolveCanonicalTargetPath( canonicalWorkspaceRoot, absolutePath, requestedPath, ), } } catch (error) { return { kind: 'error', message: error instanceof Error ? error.message : String(error), } } } private async resolveCanonicalTargetPath( canonicalWorkspaceRoot: string, absolutePath: string, requestedPath: string, ): Promise { let probePath = absolutePath const missingSuffix: string[] = [] for (;;) { try { const canonicalBase = await fs.realpath(probePath) const canonicalTarget = path.resolve(canonicalBase, ...missingSuffix) if (!this.isWithinRoot(canonicalTarget, canonicalWorkspaceRoot)) { throw new Error(`Path is outside workspace: ${requestedPath}`) } return canonicalTarget } catch (error) { const err = error as NodeJS.ErrnoException if (err.code === 'ENOENT' || err.code === 'ENOTDIR') { if (probePath === canonicalWorkspaceRoot) { const candidate = path.resolve(canonicalWorkspaceRoot, ...missingSuffix) if (!this.isWithinRoot(candidate, canonicalWorkspaceRoot)) { throw new Error(`Path is outside workspace: ${requestedPath}`) } return candidate } missingSuffix.unshift(path.basename(probePath)) const parentPath = path.dirname(probePath) if (parentPath === probePath) { throw err } probePath = parentPath continue } throw new Error( this.formatFsError( 'Failed to canonicalize workspace path', absolutePath, error, ), ) } } } private isWithinRoot(targetPath: string, rootPath: string): boolean { const target = this.normalizeComparableAbsolutePath(targetPath) const root = this.normalizeComparableAbsolutePath(rootPath) return target === root || target.startsWith(`${root}${path.sep}`) } private normalizeComparableAbsolutePath(filePath: string): string { const resolved = path.resolve(filePath) return process.platform === 'win32' ? resolved.toLowerCase() : resolved } private normalizeRelativePath(filePath: string): string { if (!filePath || filePath === '.') return '' return filePath.split(path.sep).join('/') } private normalizeRequestedPath(filePath: string): string { if (!filePath) return '' return filePath.split(path.sep).join('/') } private scopeStatusEntries( entries: StatusEntry[], repoRoot: string, workDir: string, ): ScopedStatusEntry[] { const workDirFromRepo = this.normalizeRelativePath(path.relative(repoRoot, workDir)) return entries.flatMap((entry) => { const scopedPath = this.rebaseRepoPathToWorkspacePath(entry.path, workDirFromRepo) if (scopedPath === null) { return [] } const scopedOldPath = entry.oldPath ? this.rebaseRepoPathToWorkspacePath(entry.oldPath, workDirFromRepo) : undefined return [{ repoPath: entry.path, repoOldPath: entry.oldPath, path: scopedPath, oldPath: scopedOldPath ?? undefined, status: entry.status, absolutePath: path.resolve(repoRoot, entry.path), canonicalWorkspaceRoot: workDir, }] }) } private rebaseRepoPathToWorkspacePath( repoPath: string, workDirFromRepo: string, ): string | null { const normalizedRepoPath = this.normalizeRelativePath(repoPath) if (!workDirFromRepo) { return normalizedRepoPath } const rebasedPath = path.posix.relative(workDirFromRepo, normalizedRepoPath) if ( !rebasedPath || rebasedPath === '.' || rebasedPath === '..' || rebasedPath.startsWith('../') ) { return null } return rebasedPath } private toRepoRelativePath( repoRoot: string, canonicalTargetPath: string, ): string { return this.normalizeRelativePath(path.relative(repoRoot, canonicalTargetPath)) } private detectLanguage(filePath: string): string { const ext = path.extname(filePath).slice(1).toLowerCase() return LANGUAGE_MAP[ext] || 'text' } private detectImageMimeType(filePath: string): string | null { const ext = path.extname(filePath).slice(1).toLowerCase() return IMAGE_MIME_BY_EXTENSION[ext] ?? null } private async safeStat(targetPath: string): Promise { try { return { kind: 'ok', stat: await fs.stat(targetPath), } } catch (error) { const err = error as NodeJS.ErrnoException if (err.code === 'ENOENT' || err.code === 'ENOTDIR') { return { kind: 'missing' } } return { kind: 'error', message: this.formatFsError('Failed to stat workspace path', targetPath, error), } } } private async getGitRepoInfo(workDir: string): Promise { const rootResult = await this.runGit(workDir, ['rev-parse', '--show-toplevel']) if (rootResult.code !== 0) { const stderr = rootResult.stderr.trim() if (stderr.includes('not a git repository')) { return { kind: 'not_git_repo' } } return { kind: 'error', message: this.formatGitError( 'Failed to inspect git repository', ['rev-parse', '--show-toplevel'], workDir, rootResult, ), } } let repoRoot: string try { repoRoot = await fs.realpath(path.resolve(rootResult.stdout.trim())) } catch (error) { return { kind: 'error', message: this.formatFsError( 'Failed to canonicalize git repository root', path.resolve(rootResult.stdout.trim()), error, ), } } const branchResult = await this.runGit(workDir, [ 'rev-parse', '--abbrev-ref', 'HEAD', ]) return { kind: 'ok', repoRoot, branch: branchResult.code === 0 ? branchResult.stdout.trim() || null : null, } } private async getStatusEntries( workDir: string, ): Promise<{ kind: 'ok'; entries: StatusEntry[] } | { kind: 'error'; message: string }> { const result = await this.runGit(workDir, [ 'status', '--porcelain=v1', '-z', '--untracked-files=all', ]) if (result.code !== 0) { return { kind: 'error', message: this.formatGitError( 'Failed to read git status', ['status', '--porcelain=v1', '-z', '--untracked-files=all'], workDir, result, ), } } const parts = result.stdout.split('\0') const entries: StatusEntry[] = [] for (let i = 0; i < parts.length; i++) { const record = parts[i] if (!record) continue const code = record.slice(0, 2) const currentPath = this.normalizeRelativePath(record.slice(3)) const status = parseStatus(code) if (status === 'renamed' || status === 'copied') { const oldPath = this.normalizeRelativePath(parts[++i] || '') entries.push({ path: currentPath, oldPath, code, status, }) continue } entries.push({ path: currentPath, code, status, }) } return { kind: 'ok', entries } } private async getDiffStats( workDir: string, entry: ScopedStatusEntry, ): Promise { if (entry.status === 'untracked') { const validatedPath = await this.validateCanonicalWorkspacePath( entry.canonicalWorkspaceRoot, entry.absolutePath, entry.path, ) if (validatedPath.kind === 'error') { return { kind: 'error', message: validatedPath.message } } return this.getUntrackedStats(validatedPath.canonicalTargetPath) } const result = await this.runGit(workDir, [ 'diff', '--numstat', '--find-renames', '--find-copies', 'HEAD', '--', entry.repoPath, ]) if (result.code !== 0) { return { kind: 'error', message: this.formatGitError( 'Failed to read git diff stats', [ 'diff', '--numstat', '--find-renames', '--find-copies', 'HEAD', '--', entry.repoPath, ], workDir, result, ), } } const line = result.stdout.trim().split('\n').find(Boolean) if (!line) { return { kind: 'ok', additions: 0, deletions: 0, } } const [additions, deletions] = line.split('\t') return { kind: 'ok', additions: additions === '-' ? 0 : parseInt(additions || '0', 10) || 0, deletions: deletions === '-' ? 0 : parseInt(deletions || '0', 10) || 0, } } private async getTrackedDiffStats( workDir: string, entries: ScopedStatusEntry[], ): Promise { if (!entries.some((entry) => entry.status !== 'untracked')) { return { kind: 'ok', statsByRepoPath: new Map() } } const result = await this.runGit(workDir, [ 'diff', '--numstat', '--find-renames', '--find-copies', 'HEAD', '--', ]) if (result.code !== 0) { return { kind: 'error', message: this.formatGitError( 'Failed to read git diff stats', [ 'diff', '--numstat', '--find-renames', '--find-copies', 'HEAD', '--', ], workDir, result, ), } } const statsByRepoPath = new Map() for (const line of result.stdout.trim().split('\n')) { if (!line) continue const [additions, deletions, repoPath] = line.split('\t') if (!repoPath) continue statsByRepoPath.set(this.normalizeRelativePath(repoPath), { additions: additions === '-' ? 0 : parseInt(additions || '0', 10) || 0, deletions: deletions === '-' ? 0 : parseInt(deletions || '0', 10) || 0, }) } return { kind: 'ok', statsByRepoPath } } private async getUntrackedStats( absolutePath: string, ): Promise { try { const stat = await fs.stat(absolutePath) if (!stat.isFile()) { return { kind: 'ok', additions: 0, deletions: 0 } } if (stat.size > MAX_UNTRACKED_STAT_BYTES) { return { kind: 'ok', additions: 0, deletions: 0 } } const content = await fs.readFile(absolutePath, 'utf8') return { kind: 'ok', additions: this.countTextLines(content), deletions: 0, } } catch (error) { return { kind: 'error', message: this.formatFsError( 'Failed to read untracked workspace file', absolutePath, error, ), } } } private countTextLines(content: string): number { if (!content) return 0 const lines = content.split(/\r\n|\r|\n/) if (lines[lines.length - 1] === '') { lines.pop() } return lines.length } private async runGitDiff( workDir: string, relativePath: string, ): Promise<{ kind: 'ok'; diff: string } | { kind: 'error'; message: string }> { const result = await this.runGit(workDir, [ 'diff', '--no-ext-diff', '--binary', '--find-renames', '--find-copies', 'HEAD', '--', relativePath, ]) if (result.code !== 0) { return { kind: 'error', message: this.formatGitError( 'Failed to read git diff', [ 'diff', '--no-ext-diff', '--binary', '--find-renames', '--find-copies', 'HEAD', '--', relativePath, ], workDir, result, ), } } return { kind: 'ok', diff: result.stdout } } private async runGit( workDir: string, args: string[], ): Promise { try { const result = await execFile('git', args, { cwd: workDir, timeout: GIT_TIMEOUT_MS, maxBuffer: MAX_GIT_BUFFER_BYTES, encoding: 'utf8', }) return { stdout: result.stdout, stderr: result.stderr, code: 0, } } catch (error) { const err = error as NodeJS.ErrnoException & { stdout?: string | Buffer stderr?: string | Buffer code?: number | string } return { stdout: typeof err.stdout === 'string' ? err.stdout : Buffer.isBuffer(err.stdout) ? err.stdout.toString('utf8') : '', stderr: typeof err.stderr === 'string' ? err.stderr : Buffer.isBuffer(err.stderr) ? err.stderr.toString('utf8') : '', code: typeof err.code === 'number' ? err.code : 1, } } } private async buildUntrackedDiff( absolutePath: string, relativePath: string, ): Promise { const stat = await this.safeStat(absolutePath) if (stat.kind === 'error') { return { kind: 'error', message: stat.message } } if (stat.kind === 'missing' || !stat.stat.isFile()) { return { kind: 'missing' } } let buffer: Buffer try { buffer = await fs.readFile(absolutePath) } catch (error) { return { kind: 'error', message: this.formatFsError( 'Failed to read untracked workspace file', absolutePath, error, ), } } if (buffer.includes(0)) { return { kind: 'ok', diff: [ `diff --git a/${relativePath} b/${relativePath}`, 'new file mode 100644', `Binary files /dev/null and b/${relativePath} differ`, '', ].join('\n'), } } const content = buffer.toString('utf8') const lines = content.split(/\r\n|\r|\n/) if (lines[lines.length - 1] === '') { lines.pop() } const hunkLines = lines.map((line) => `+${line}`) if (hunkLines.length === 0) { hunkLines.push('+') } return { kind: 'ok', diff: [ `diff --git a/${relativePath} b/${relativePath}`, 'new file mode 100644', '--- /dev/null', `+++ b/${relativePath}`, `@@ -0,0 +1,${hunkLines.length} @@`, ...hunkLines, '', ].join('\n'), } } private formatFsError( prefix: string, targetPath: string, error: unknown, ): string { const err = error as NodeJS.ErrnoException const code = err.code ? `${err.code}: ` : '' return `${prefix} (${targetPath}): ${code}${err.message || 'unknown error'}` } private formatGitError( prefix: string, args: string[], workDir: string, result: GitCommandResult, ): string { const stderr = result.stderr.trim() || result.stdout.trim() || 'unknown git failure' return `${prefix} (git ${args.join(' ')} in ${workDir}): ${stderr}` } }