cc-haha/src/server/services/workspaceService.ts

1671 lines
45 KiB
TypeScript

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}`
}
}