| 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<string, string> = { |
| 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<string, string> = { |
| 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<ReturnType<typeof fs.stat>> |
| } |
| | { |
| 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<string, { additions: number; deletions: number }> |
| } |
| | { |
| 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<string | null>, |
| private readonly resolveSessionMessages: ( |
| sessionId: string, |
| ) => Promise<MessageEntry[]> = async () => [], |
| private readonly resolveSessionFileHistorySnapshots: ( |
| sessionId: string, |
| ) => Promise<FileHistorySnapshot[]> = async () => [], |
| ) {} |
|
|
| async getStatus(sessionId: string): Promise<WorkspaceStatusResult> { |
| 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<WorkspaceReadFileResult> { |
| 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<WorkspaceTreeResult> { |
| 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<WorkspaceDiffResult> { |
| 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<string | null> { |
| 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<SessionFileChange[]> { |
| let messages: MessageEntry[] |
| try { |
| messages = await this.resolveSessionMessages(sessionId) |
| } catch { |
| return [] |
| } |
|
|
| const changes = new Map<string, SessionFileChange>() |
|
|
| 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<string, unknown> |
| 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<string, unknown>, |
| 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<SessionFileChange[]> { |
| 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<string | null> { |
| 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<string, SessionFileChange>() |
| 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<string> { |
| const trackedPaths = new Set<string>() |
| 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<string | null | undefined> { |
| 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<string | null> { |
| 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<string, unknown>, |
| 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<string, unknown> => !!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<string, unknown>, |
| ): 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<string> { |
| 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<WorkspacePathResolution> { |
| 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<string> { |
| 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<WorkspaceStatResult> { |
| 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<GitRepoInfo> { |
| 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<DiffStatsResult> { |
| 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<DiffStatsByRepoPathResult> { |
| 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<string, { additions: number; deletions: number }>() |
| 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<DiffStatsResult> { |
| 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<GitCommandResult> { |
| 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<UntrackedDiffResult> { |
| 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}` |
| } |
| } |
|
|