From 2071bb4f7a3aeb95c24da65d269b65753cc80807 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Fri, 1 May 2026 23:36:40 -0700 Subject: [PATCH 1/2] feat(providers): docker provider foundation (pty / filesystem / git) Adds Docker as a fourth provider triple alongside local and SSH. Pure plumbing: pty / filesystem / git providers + docker engine detection + image build + container lifecycle + bind-mount path resolution. No user-facing UI yet, no dispatch wiring. Mirrors the SSH provider triple's API. Cross-platform: macOS (Docker Desktop / Colima), Linux (Docker Engine, rootless), Windows (Docker Desktop WSL2 named pipe). Tests cover happy and error paths through a docker-engine-fake.ts recorder, so no real Docker daemon is required. Foundation PR for the docker isolation series. Follow-ups will add the user-facing toggle and dispatch wiring. --- .../docker/docker-container-lifecycle.test.ts | 104 ++++++ src/main/docker/docker-container-lifecycle.ts | 108 ++++++ src/main/docker/docker-engine-client.ts | 346 ++++++++++++++++++ src/main/docker/docker-engine-detect.test.ts | 75 ++++ src/main/docker/docker-engine-detect.ts | 106 ++++++ src/main/docker/docker-engine-fake.ts | 220 +++++++++++ src/main/docker/docker-image-build.test.ts | 80 ++++ src/main/docker/docker-image-build.ts | 91 +++++ src/main/docker/docker-mount.test.ts | 43 +++ src/main/docker/docker-mount.ts | 42 +++ src/main/docker/types.ts | 33 ++ .../docker-filesystem-provider.test.ts | 78 ++++ .../providers/docker-filesystem-provider.ts | 211 +++++++++++ .../providers/docker-git-provider.test.ts | 77 ++++ src/main/providers/docker-git-provider.ts | 286 +++++++++++++++ .../providers/docker-pty-provider.test.ts | 85 +++++ src/main/providers/docker-pty-provider.ts | 171 +++++++++ 17 files changed, 2156 insertions(+) create mode 100644 src/main/docker/docker-container-lifecycle.test.ts create mode 100644 src/main/docker/docker-container-lifecycle.ts create mode 100644 src/main/docker/docker-engine-client.ts create mode 100644 src/main/docker/docker-engine-detect.test.ts create mode 100644 src/main/docker/docker-engine-detect.ts create mode 100644 src/main/docker/docker-engine-fake.ts create mode 100644 src/main/docker/docker-image-build.test.ts create mode 100644 src/main/docker/docker-image-build.ts create mode 100644 src/main/docker/docker-mount.test.ts create mode 100644 src/main/docker/docker-mount.ts create mode 100644 src/main/docker/types.ts create mode 100644 src/main/providers/docker-filesystem-provider.test.ts create mode 100644 src/main/providers/docker-filesystem-provider.ts create mode 100644 src/main/providers/docker-git-provider.test.ts create mode 100644 src/main/providers/docker-git-provider.ts create mode 100644 src/main/providers/docker-pty-provider.test.ts create mode 100644 src/main/providers/docker-pty-provider.ts diff --git a/src/main/docker/docker-container-lifecycle.test.ts b/src/main/docker/docker-container-lifecycle.test.ts new file mode 100644 index 0000000000..f66077023c --- /dev/null +++ b/src/main/docker/docker-container-lifecycle.test.ts @@ -0,0 +1,104 @@ +import { mkdtemp, rm } from 'fs/promises' +import { tmpdir } from 'os' +import path from 'path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { DockerEngineFake } from './docker-engine-fake' +import { + attachDockerContainer, + hibernateDockerContainer, + spawnDockerContainer, + terminateDockerContainer +} from './docker-container-lifecycle' + +describe('docker-container-lifecycle', () => { + let repoPath: string + let worktreePath: string + let engine: DockerEngineFake + + beforeEach(async () => { + repoPath = await mkdtemp(path.join(tmpdir(), 'orca-docker-repo-')) + worktreePath = await mkdtemp(path.join(tmpdir(), 'orca-docker-worktree-')) + engine = new DockerEngineFake() + }) + + afterEach(async () => { + await rm(repoPath, { recursive: true, force: true }) + await rm(worktreePath, { recursive: true, force: true }) + }) + + it('builds an image, creates a bind-mounted container, and starts it', async () => { + const result = await spawnDockerContainer({ + repoPath, + worktreePath, + repoIdentity: 'repo-1', + engine, + platform: 'linux', + now: () => 456 + }) + + expect(result.container).toMatchObject({ + id: 'container-1', + imageId: 'sha256:fake-image-1', + startedAt: 456, + state: 'running' + }) + expect(engine.commands.map((command) => command.command)).toEqual([ + 'image.build', + 'container.create', + 'container.start' + ]) + expect(engine.commands[1]).toMatchObject({ + command: 'container.create', + options: { mounts: [{ source: worktreePath, target: '/workspace' }] } + }) + }) + + it('limits image builds to one concurrent build per repo identity', async () => { + engine.buildDelayMs = 20 + + await Promise.all([ + spawnDockerContainer({ repoPath, worktreePath, repoIdentity: 'same', engine }), + spawnDockerContainer({ repoPath, worktreePath, repoIdentity: 'same', engine }) + ]) + + expect(engine.commands.filter((command) => command.command === 'image.build')).toHaveLength(1) + }) + + it('attaches only running containers', async () => { + const spawned = await spawnDockerContainer({ repoPath, worktreePath, engine }) + + await expect( + attachDockerContainer(engine, spawned.container.id, () => 789) + ).resolves.toMatchObject({ + id: spawned.container.id, + state: 'running', + startedAt: 789 + }) + + await hibernateDockerContainer(engine, spawned.container) + await expect(attachDockerContainer(engine, spawned.container.id)).rejects.toThrow( + 'is not running' + ) + }) + + it('hibernates and terminates containers', async () => { + const spawned = await spawnDockerContainer({ repoPath, worktreePath, engine }) + + await expect(hibernateDockerContainer(engine, spawned.container)).resolves.toMatchObject({ + state: 'hibernated' + }) + await expect(terminateDockerContainer(engine, spawned.container)).resolves.toMatchObject({ + state: 'terminated' + }) + expect(engine.commands.map((command) => command.command)).toContain('container.rm') + }) + + it('surfaces build failures before creating a container', async () => { + engine.nextBuildError = new Error('image build fail') + + await expect(spawnDockerContainer({ repoPath, worktreePath, engine })).rejects.toThrow( + 'image build fail' + ) + expect(engine.commands.map((command) => command.command)).toEqual(['image.build']) + }) +}) diff --git a/src/main/docker/docker-container-lifecycle.ts b/src/main/docker/docker-container-lifecycle.ts new file mode 100644 index 0000000000..3b2403e6ed --- /dev/null +++ b/src/main/docker/docker-container-lifecycle.ts @@ -0,0 +1,108 @@ +import type { DockerEngineClientLike } from './docker-engine-client' +import { buildDockerImage } from './docker-image-build' +import { DEFAULT_CONTAINER_WORKDIR, resolveDockerBindMount } from './docker-mount' +import type { DockerContainerHandle, DockerImageHandle } from './types' + +const IMAGE_BUILD_TIMEOUT_MS = 60_000 +const repoBuildLocks = new Map>() + +export type SpawnDockerContainerOptions = { + repoPath: string + worktreePath: string + repoIdentity?: string + engine: DockerEngineClientLike + platform?: NodeJS.Platform + workdir?: string + now?: () => number +} + +export type SpawnDockerContainerResult = { + image: DockerImageHandle + container: DockerContainerHandle +} + +export async function spawnDockerContainer( + options: SpawnDockerContainerOptions +): Promise { + const workdir = options.workdir ?? DEFAULT_CONTAINER_WORKDIR + const image = await buildImageOncePerRepo(options) + const mount = resolveDockerBindMount({ + hostPath: options.worktreePath, + platform: options.platform, + containerPath: workdir + }) + const created = await options.engine.createContainer({ + imageId: image.id, + workdir, + mounts: [mount] + }) + await options.engine.startContainer(created.id) + + return { + image, + container: { + id: created.id, + imageId: image.id, + startedAt: (options.now ?? Date.now)(), + state: 'running' + } + } +} + +export async function attachDockerContainer( + engine: DockerEngineClientLike, + id: string, + now: () => number = Date.now +): Promise { + const info = await engine.inspectContainer(id) + if (!info.running) { + throw new Error(`Docker container ${id} is not running`) + } + return { + id: info.id, + imageId: info.imageId, + startedAt: now(), + state: 'running' + } +} + +export async function hibernateDockerContainer( + engine: DockerEngineClientLike, + container: DockerContainerHandle +): Promise { + await engine.stopContainer(container.id) + return { ...container, state: 'hibernated' } +} + +export async function terminateDockerContainer( + engine: DockerEngineClientLike, + container: DockerContainerHandle +): Promise { + if (container.state !== 'terminated') { + await engine.stopContainer(container.id) + await engine.removeContainer(container.id) + } + return { ...container, state: 'terminated' } +} + +async function buildImageOncePerRepo( + options: SpawnDockerContainerOptions +): Promise { + const key = options.repoIdentity ?? options.repoPath + const existing = repoBuildLocks.get(key) + if (existing) { + return existing + } + + const next = buildDockerImage({ + repoPath: options.repoPath, + repoIdentity: options.repoIdentity, + engine: options.engine, + timeoutMs: IMAGE_BUILD_TIMEOUT_MS, + now: options.now + }).finally(() => { + repoBuildLocks.delete(key) + }) + repoBuildLocks.set(key, next) + return next +} diff --git a/src/main/docker/docker-engine-client.ts b/src/main/docker/docker-engine-client.ts new file mode 100644 index 0000000000..62a4390dfc --- /dev/null +++ b/src/main/docker/docker-engine-client.ts @@ -0,0 +1,346 @@ +/* eslint-disable max-lines */ +import { spawn, execFile } from 'child_process' +import { mkdtemp, readFile, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import path from 'path' +import { promisify } from 'util' + +const execFileAsync = promisify(execFile) + +export type DockerExecResult = { + stdout: string + stderr: string + exitCode: number +} + +export type DockerBuildImageOptions = { + contextPath: string + dockerfilePath: string + tag: string + timeoutMs?: number + dockerfileContent?: string +} + +export type DockerCreateContainerOptions = { + imageId: string + workdir: string + mounts: { source: string; target: string; readonly?: boolean }[] + command?: string[] + env?: Record + name?: string +} + +export type DockerExecOptions = { + containerId: string + args: string[] + cwd?: string + env?: Record + input?: string + timeoutMs?: number +} + +export type DockerExecSessionOptions = { + containerId: string + args: string[] + cwd: string + env?: Record + cols: number + rows: number +} + +export type DockerExecSession = { + id: string + write(data: string): void + resize(cols: number, rows: number): void + shutdown(immediate: boolean): Promise + sendSignal(signal: string): Promise + getCwd(): Promise + getInitialCwd(): Promise + clearBuffer(): Promise + acknowledgeDataEvent(charCount: number): void + hasChildProcesses(): Promise + getForegroundProcess(): Promise + serialize(): Promise + revive(state: string): Promise + onData(callback: (data: string) => void): () => void + onReplay(callback: (data: string) => void): () => void + onExit(callback: (code: number) => void): () => void +} + +export type DockerEngineClientLike = { + buildImage(options: DockerBuildImageOptions): Promise<{ imageId: string }> + pullImage(image: string): Promise + createContainer(options: DockerCreateContainerOptions): Promise<{ id: string }> + startContainer(id: string): Promise + inspectContainer(id: string): Promise<{ id: string; imageId: string; running: boolean }> + exec(options: DockerExecOptions): Promise + spawnExec(options: DockerExecSessionOptions): Promise + stopContainer(id: string): Promise + removeContainer(id: string): Promise +} + +export class DockerEngineClient implements DockerEngineClientLike { + async buildImage(options: DockerBuildImageOptions): Promise<{ imageId: string }> { + const tempDir = await mkdtemp(path.join(tmpdir(), 'orca-docker-build-')) + const dockerfilePath = tempDir ? path.join(tempDir, 'Dockerfile') : options.dockerfilePath + + try { + if (options.dockerfileContent) { + await writeFile(dockerfilePath, options.dockerfileContent, 'utf-8') + } + + const iidFile = path.join(tempDir, 'iid') + const args = [ + 'build', + '--iidfile', + iidFile, + '-f', + options.dockerfileContent ? dockerfilePath : options.dockerfilePath, + '-t', + options.tag, + options.contextPath + ] + await execDocker(args, { timeoutMs: options.timeoutMs }) + const imageId = (await readFile(iidFile, 'utf-8')).trim() + return { imageId } + } finally { + await rm(tempDir, { recursive: true, force: true }) + } + } + + async pullImage(image: string): Promise { + await execDocker(['pull', image]) + } + + async createContainer(options: DockerCreateContainerOptions): Promise<{ id: string }> { + const args = ['create', '--workdir', options.workdir] + for (const mount of options.mounts) { + const flags = [`type=bind`, `source=${mount.source}`, `target=${mount.target}`] + if (mount.readonly) { + flags.push('readonly') + } + args.push('--mount', flags.join(',')) + } + for (const [key, value] of Object.entries(options.env ?? {})) { + args.push('--env', `${key}=${value}`) + } + if (options.name) { + args.push('--name', options.name) + } + args.push(options.imageId, ...(options.command ?? ['tail', '-f', '/dev/null'])) + const result = await execDocker(args) + return { id: result.stdout.trim() } + } + + async startContainer(id: string): Promise { + await execDocker(['start', id]) + } + + async inspectContainer(id: string): Promise<{ id: string; imageId: string; running: boolean }> { + const result = await execDocker([ + 'inspect', + '--format', + '{{.Id}} {{.Image}} {{.State.Running}}', + id + ]) + const [containerId, imageId, running] = result.stdout.trim().split(/\s+/) + return { id: containerId, imageId, running: running === 'true' } + } + + async exec(options: DockerExecOptions): Promise { + const args = buildExecArgs(options) + return execDocker(args, { input: options.input, timeoutMs: options.timeoutMs }) + } + + async spawnExec(options: DockerExecSessionOptions): Promise { + const id = `docker-exec-${Date.now()}-${Math.random().toString(16).slice(2)}` + const args = buildExecArgs({ + containerId: options.containerId, + args: options.args, + cwd: options.cwd, + env: { + ...options.env, + COLUMNS: String(options.cols), + LINES: String(options.rows) + } + }) + args.splice(1, 0, '-i') + const child = spawn('docker', args, { stdio: ['pipe', 'pipe', 'pipe'] }) + const dataListeners = new Set<(data: string) => void>() + const replayListeners = new Set<(data: string) => void>() + const exitListeners = new Set<(code: number) => void>() + let buffer = '' + let currentCwd = options.cwd + let exitCode: number | null = null + + child.stdout.setEncoding('utf-8') + child.stderr.setEncoding('utf-8') + child.stdout.on('data', (chunk: string) => { + buffer += chunk + for (const cb of dataListeners) { + cb(chunk) + } + }) + child.stderr.on('data', (chunk: string) => { + buffer += chunk + for (const cb of dataListeners) { + cb(chunk) + } + }) + child.on('close', (code) => { + exitCode = code ?? 0 + for (const cb of exitListeners) { + cb(exitCode) + } + }) + + return { + id, + write(data): void { + child.stdin.write(data) + }, + resize(cols, rows): void { + if (exitCode === null) { + child.kill('SIGWINCH') + } + void cols + void rows + }, + async shutdown(immediate): Promise { + child.kill(immediate ? 'SIGKILL' : 'SIGTERM') + }, + async sendSignal(signal): Promise { + if (exitCode === null) { + child.kill(signal as NodeJS.Signals) + } + }, + async getCwd(): Promise { + return currentCwd + }, + async getInitialCwd(): Promise { + return options.cwd + }, + async clearBuffer(): Promise { + buffer = '' + }, + acknowledgeDataEvent(_charCount): void {}, + async hasChildProcesses(): Promise { + return exitCode === null + }, + async getForegroundProcess(): Promise { + return exitCode === null ? path.basename(options.args[0] ?? 'sh') : null + }, + async serialize(): Promise { + return JSON.stringify({ cwd: currentCwd, buffer }) + }, + async revive(state): Promise { + try { + const parsed = JSON.parse(state) as { cwd?: string; buffer?: string } + currentCwd = parsed.cwd ?? currentCwd + if (parsed.buffer) { + buffer = parsed.buffer + for (const cb of replayListeners) { + cb(buffer) + } + } + } catch { + // Ignore stale serialized state from older builds. + } + }, + onData(callback): () => void { + dataListeners.add(callback) + return () => dataListeners.delete(callback) + }, + onReplay(callback): () => void { + replayListeners.add(callback) + return () => replayListeners.delete(callback) + }, + onExit(callback): () => void { + exitListeners.add(callback) + return () => exitListeners.delete(callback) + } + } + } + + async stopContainer(id: string): Promise { + await execDocker(['stop', id]) + } + + async removeContainer(id: string): Promise { + await execDocker(['rm', id]) + } +} + +function buildExecArgs(options: DockerExecOptions): string[] { + const args = ['exec'] + if (options.cwd) { + args.push('--workdir', options.cwd) + } + for (const [key, value] of Object.entries(options.env ?? {})) { + args.push('--env', `${key}=${value}`) + } + args.push(options.containerId, ...options.args) + return args +} + +async function execDocker( + args: string[], + options: { input?: string; timeoutMs?: number } = {} +): Promise { + if (options.input === undefined) { + const { stdout, stderr } = await execFileAsync('docker', args, { + encoding: 'utf-8', + timeout: options.timeoutMs, + maxBuffer: 20 * 1024 * 1024 + }) + return { stdout: stdout as string, stderr: stderr as string, exitCode: 0 } + } + + return new Promise((resolve, reject) => { + const child = spawn('docker', args, { stdio: ['pipe', 'pipe', 'pipe'] }) + let stdout = '' + let stderr = '' + let settled = false + const timeout = + options.timeoutMs === undefined + ? null + : setTimeout(() => { + settled = true + child.kill('SIGKILL') + reject(new Error(`docker ${args[0]} timed out after ${options.timeoutMs}ms`)) + }, options.timeoutMs) + + child.stdout.setEncoding('utf-8') + child.stderr.setEncoding('utf-8') + child.stdout.on('data', (chunk: string) => { + stdout += chunk + }) + child.stderr.on('data', (chunk: string) => { + stderr += chunk + }) + child.once('error', (error) => { + if (!settled) { + settled = true + if (timeout) { + clearTimeout(timeout) + } + reject(error) + } + }) + child.once('close', (code) => { + if (settled) { + return + } + settled = true + if (timeout) { + clearTimeout(timeout) + } + if (code && code !== 0) { + reject(new Error(stderr || `docker ${args[0]} exited with ${code}`)) + return + } + resolve({ stdout, stderr, exitCode: code ?? 0 }) + }) + child.stdin.end(options.input) + }) +} diff --git a/src/main/docker/docker-engine-detect.test.ts b/src/main/docker/docker-engine-detect.test.ts new file mode 100644 index 0000000000..aadb52b8fd --- /dev/null +++ b/src/main/docker/docker-engine-detect.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import { detectDockerEngine } from './docker-engine-detect' + +describe('detectDockerEngine', () => { + it('detects Colima on macOS before Docker Desktop', () => { + const result = detectDockerEngine({ + platform: 'darwin', + env: { HOME: '/Users/u' }, + existsSync: (candidate) => + candidate === '/Users/u/.colima/default/docker.sock' || candidate === '/var/run/docker.sock' + }) + expect(result).toMatchObject({ + flavor: 'colima', + socketPath: '/Users/u/.colima/default/docker.sock', + available: true + }) + }) + + it('falls back to Docker Desktop on macOS', () => { + const result = detectDockerEngine({ + platform: 'darwin', + env: { HOME: '/Users/u' }, + existsSync: (candidate) => candidate === '/var/run/docker.sock' + }) + expect(result).toMatchObject({ + flavor: 'docker-desktop-mac', + socketPath: '/var/run/docker.sock', + available: true + }) + }) + + it('detects Linux Docker Engine and rootless sockets', () => { + expect( + detectDockerEngine({ + platform: 'linux', + uid: 501, + existsSync: (candidate) => candidate === '/var/run/docker.sock' + }) + ).toMatchObject({ flavor: 'docker-engine-linux', available: true }) + + expect( + detectDockerEngine({ + platform: 'linux', + uid: 501, + existsSync: (candidate) => candidate === '/run/user/501/docker.sock' + }) + ).toMatchObject({ + flavor: 'docker-rootless-linux', + socketPath: '/run/user/501/docker.sock', + available: true + }) + }) + + it('detects Docker Desktop WSL2 named pipe on Windows', () => { + const result = detectDockerEngine({ + platform: 'win32', + existsSync: (candidate) => candidate === String.raw`\\.\pipe\docker_engine` + }) + expect(result).toMatchObject({ + flavor: 'docker-desktop-windows-wsl2', + socketPath: String.raw`\\.\pipe\docker_engine`, + available: true + }) + }) + + it('returns unavailable info when Docker is missing', () => { + expect( + detectDockerEngine({ platform: 'linux', uid: 501, existsSync: () => false }) + ).toMatchObject({ + flavor: 'docker-engine-linux', + available: false, + reason: 'Docker Engine socket was not found' + }) + }) +}) diff --git a/src/main/docker/docker-engine-detect.ts b/src/main/docker/docker-engine-detect.ts new file mode 100644 index 0000000000..1de409536a --- /dev/null +++ b/src/main/docker/docker-engine-detect.ts @@ -0,0 +1,106 @@ +import { existsSync as nodeExistsSync } from 'fs' +import path from 'path' +import { homedir, userInfo } from 'os' +import type { DockerEngineInfo } from './types' + +type DetectOptions = { + platform?: NodeJS.Platform + env?: NodeJS.ProcessEnv + uid?: number + existsSync?: (candidate: string) => boolean +} + +export function detectDockerEngine(options: DetectOptions = {}): DockerEngineInfo { + const platform = options.platform ?? process.platform + const env = options.env ?? process.env + const existsSync = options.existsSync ?? nodeExistsSync + const home = env.HOME ?? env.USERPROFILE ?? homedir() + + if (platform === 'darwin') { + const colimaSocket = path.join(home, '.colima', 'default', 'docker.sock') + if (existsSync(colimaSocket)) { + return { + flavor: 'colima', + socketPath: colimaSocket, + available: true + } + } + + const desktopSocket = path.join(path.sep, 'var', 'run', 'docker.sock') + if (existsSync(desktopSocket)) { + return { + flavor: 'docker-desktop-mac', + socketPath: desktopSocket, + available: true + } + } + + return { + flavor: 'docker-desktop-mac', + socketPath: desktopSocket, + available: false, + reason: 'Docker Desktop or Colima socket was not found' + } + } + + if (platform === 'linux') { + const engineSocket = path.join(path.sep, 'var', 'run', 'docker.sock') + if (existsSync(engineSocket)) { + return { + flavor: 'docker-engine-linux', + socketPath: engineSocket, + available: true + } + } + + const uid = options.uid ?? safeUid() + const rootlessSocket = path.join(path.sep, 'run', 'user', String(uid), 'docker.sock') + if (existsSync(rootlessSocket)) { + return { + flavor: 'docker-rootless-linux', + socketPath: rootlessSocket, + available: true + } + } + + return { + flavor: 'docker-engine-linux', + socketPath: engineSocket, + available: false, + reason: 'Docker Engine socket was not found' + } + } + + if (platform === 'win32') { + const pipeName = String.raw`\\.\pipe\docker_engine` + if (existsSync(pipeName)) { + return { + flavor: 'docker-desktop-windows-wsl2', + socketPath: pipeName, + available: true + } + } + + return { + flavor: 'docker-desktop-windows-wsl2', + socketPath: pipeName, + available: false, + reason: 'Docker Desktop WSL2 named pipe was not found' + } + } + + return { + flavor: 'docker-engine-linux', + socketPath: '', + available: false, + reason: `Unsupported platform: ${platform}` + } +} + +function safeUid(): number { + try { + return userInfo().uid + } catch { + return 0 + } +} diff --git a/src/main/docker/docker-engine-fake.ts b/src/main/docker/docker-engine-fake.ts new file mode 100644 index 0000000000..5be8bf3390 --- /dev/null +++ b/src/main/docker/docker-engine-fake.ts @@ -0,0 +1,220 @@ +import type { + DockerBuildImageOptions, + DockerCreateContainerOptions, + DockerEngineClientLike, + DockerExecOptions, + DockerExecResult, + DockerExecSession, + DockerExecSessionOptions +} from './docker-engine-client' + +export type DockerEngineFakeCommand = + | { command: 'image.pull'; image: string } + | { command: 'image.build'; options: DockerBuildImageOptions } + | { command: 'container.create'; options: DockerCreateContainerOptions } + | { command: 'container.start'; id: string } + | { command: 'container.inspect'; id: string } + | { command: 'container.exec'; options: DockerExecOptions } + | { command: 'container.exec.spawn'; options: DockerExecSessionOptions } + | { command: 'container.stop'; id: string } + | { command: 'container.rm'; id: string } + +type FakeContainer = { + id: string + imageId: string + running: boolean +} + +export class DockerEngineFake implements DockerEngineClientLike { + readonly commands: DockerEngineFakeCommand[] = [] + readonly containers = new Map() + readonly sessions = new Map() + buildDelayMs = 0 + nextBuildError: Error | null = null + nextExecError: Error | null = null + private imageCounter = 0 + private containerCounter = 0 + private sessionCounter = 0 + private execResults: DockerExecResult[] = [] + + enqueueExecResult(result: Partial & { stdout?: string }): void { + this.execResults.push({ + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + exitCode: result.exitCode ?? 0 + }) + } + + async buildImage(options: DockerBuildImageOptions): Promise<{ imageId: string }> { + this.commands.push({ command: 'image.build', options }) + if (this.nextBuildError) { + const error = this.nextBuildError + this.nextBuildError = null + throw error + } + if (this.buildDelayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, this.buildDelayMs)) + } + this.imageCounter += 1 + return { imageId: `sha256:fake-image-${this.imageCounter}` } + } + + async pullImage(image: string): Promise { + this.commands.push({ command: 'image.pull', image }) + } + + async createContainer(options: DockerCreateContainerOptions): Promise<{ id: string }> { + this.commands.push({ command: 'container.create', options }) + this.containerCounter += 1 + const id = `container-${this.containerCounter}` + this.containers.set(id, { id, imageId: options.imageId, running: false }) + return { id } + } + + async startContainer(id: string): Promise { + this.commands.push({ command: 'container.start', id }) + const container = this.containers.get(id) + if (!container) { + throw new Error(`Unknown container ${id}`) + } + container.running = true + } + + async inspectContainer(id: string): Promise<{ id: string; imageId: string; running: boolean }> { + this.commands.push({ command: 'container.inspect', id }) + const container = this.containers.get(id) + if (!container) { + throw new Error(`Unknown container ${id}`) + } + return { ...container } + } + + async exec(options: DockerExecOptions): Promise { + this.commands.push({ command: 'container.exec', options }) + if (this.nextExecError) { + const error = this.nextExecError + this.nextExecError = null + throw error + } + return this.execResults.shift() ?? { stdout: '', stderr: '', exitCode: 0 } + } + + async spawnExec(options: DockerExecSessionOptions): Promise { + this.commands.push({ command: 'container.exec.spawn', options }) + this.sessionCounter += 1 + const session = new FakeDockerExecSession(`session-${this.sessionCounter}`, options.cwd) + this.sessions.set(session.id, session) + return session + } + + async stopContainer(id: string): Promise { + this.commands.push({ command: 'container.stop', id }) + const container = this.containers.get(id) + if (container) { + container.running = false + } + } + + async removeContainer(id: string): Promise { + this.commands.push({ command: 'container.rm', id }) + this.containers.delete(id) + } +} + +export class FakeDockerExecSession implements DockerExecSession { + readonly id: string + readonly writes: string[] = [] + readonly resizes: { cols: number; rows: number }[] = [] + private cwd: string + private initialCwd: string + private buffer = '' + private dataListeners = new Set<(data: string) => void>() + private replayListeners = new Set<(data: string) => void>() + private exitListeners = new Set<(code: number) => void>() + private exited = false + + constructor(id: string, cwd: string) { + this.id = id + this.cwd = cwd + this.initialCwd = cwd + } + + write(data: string): void { + this.writes.push(data) + } + + resize(cols: number, rows: number): void { + this.resizes.push({ cols, rows }) + } + + async shutdown(_immediate: boolean): Promise { + this.crash(0) + } + + async sendSignal(_signal: string): Promise {} + + async getCwd(): Promise { + return this.cwd + } + + async getInitialCwd(): Promise { + return this.initialCwd + } + + async clearBuffer(): Promise { + this.buffer = '' + } + + acknowledgeDataEvent(_charCount: number): void {} + + async hasChildProcesses(): Promise { + return !this.exited + } + + async getForegroundProcess(): Promise { + return this.exited ? null : 'sh' + } + + async serialize(): Promise { + return this.buffer + } + + async revive(state: string): Promise { + this.buffer = state + for (const cb of this.replayListeners) { + cb(state) + } + } + + onData(callback: (data: string) => void): () => void { + this.dataListeners.add(callback) + return () => this.dataListeners.delete(callback) + } + + onReplay(callback: (data: string) => void): () => void { + this.replayListeners.add(callback) + return () => this.replayListeners.delete(callback) + } + + onExit(callback: (code: number) => void): () => void { + this.exitListeners.add(callback) + return () => this.exitListeners.delete(callback) + } + + emitData(data: string): void { + this.buffer += data + for (const cb of this.dataListeners) { + cb(data) + } + } + + crash(code: number): void { + if (this.exited) { + return + } + this.exited = true + for (const cb of this.exitListeners) { + cb(code) + } + } +} diff --git a/src/main/docker/docker-image-build.test.ts b/src/main/docker/docker-image-build.test.ts new file mode 100644 index 0000000000..1ffebf941c --- /dev/null +++ b/src/main/docker/docker-image-build.test.ts @@ -0,0 +1,80 @@ +import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises' +import { tmpdir } from 'os' +import path from 'path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { DockerEngineFake } from './docker-engine-fake' +import { + buildDockerImage, + computeDockerImageCacheKey, + resolveDockerfile +} from './docker-image-build' + +describe('docker-image-build', () => { + let repoPath: string + + beforeEach(async () => { + repoPath = await mkdtemp(path.join(tmpdir(), 'orca-docker-image-test-')) + }) + + afterEach(async () => { + await rm(repoPath, { recursive: true, force: true }) + }) + + it('prefers .devcontainer/Dockerfile over .orca/Dockerfile', async () => { + await mkdir(path.join(repoPath, '.devcontainer')) + await mkdir(path.join(repoPath, '.orca')) + await writeFile(path.join(repoPath, '.devcontainer', 'Dockerfile'), 'FROM node:24\n') + await writeFile(path.join(repoPath, '.orca', 'Dockerfile'), 'FROM ubuntu:24.04\n') + + const result = await resolveDockerfile(repoPath) + + expect(result).toMatchObject({ + dockerfilePath: path.join(repoPath, '.devcontainer', 'Dockerfile'), + content: 'FROM node:24\n', + isGenerated: false + }) + }) + + it('uses .orca/Dockerfile when no devcontainer Dockerfile exists', async () => { + await mkdir(path.join(repoPath, '.orca')) + await writeFile(path.join(repoPath, '.orca', 'Dockerfile'), 'FROM ubuntu:24.04\n') + + const result = await resolveDockerfile(repoPath) + + expect(result.dockerfilePath).toBe(path.join(repoPath, '.orca', 'Dockerfile')) + }) + + it('builds a generated default Dockerfile and returns a handle', async () => { + const engine = new DockerEngineFake() + + const image = await buildDockerImage({ + repoPath, + repoIdentity: 'stablyai/orca', + engine, + now: () => 123 + }) + + expect(image).toMatchObject({ + id: 'sha256:fake-image-1', + builtAt: 123, + dockerfilePath: 'auto-generated:orca-default' + }) + expect(engine.commands[0]).toMatchObject({ + command: 'image.build', + options: { contextPath: repoPath, dockerfileContent: expect.stringContaining('FROM ubuntu') } + }) + }) + + it('includes repo identity in the cache key', () => { + const a = computeDockerImageCacheKey({ dockerfileContent: 'FROM node\n', repoIdentity: 'a' }) + const b = computeDockerImageCacheKey({ dockerfileContent: 'FROM node\n', repoIdentity: 'b' }) + expect(a).not.toBe(b) + }) + + it('surfaces image build failures', async () => { + const engine = new DockerEngineFake() + engine.nextBuildError = new Error('build failed') + + await expect(buildDockerImage({ repoPath, engine })).rejects.toThrow('build failed') + }) +}) diff --git a/src/main/docker/docker-image-build.ts b/src/main/docker/docker-image-build.ts new file mode 100644 index 0000000000..1061560472 --- /dev/null +++ b/src/main/docker/docker-image-build.ts @@ -0,0 +1,91 @@ +import { existsSync } from 'fs' +import { readFile } from 'fs/promises' +import { createHash } from 'crypto' +import path from 'path' +import type { DockerEngineClientLike } from './docker-engine-client' +import type { DockerImageHandle } from './types' + +const DEFAULT_DOCKERFILE_CONTENT = `FROM ubuntu:24.04 +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl git nodejs npm ripgrep && rm -rf /var/lib/apt/lists/* +WORKDIR /workspace +` +const DEFAULT_DOCKERFILE_PATH = 'auto-generated:orca-default' + +export type ResolveDockerfileResult = { + dockerfilePath: string + content: string + isGenerated: boolean +} + +export type BuildDockerImageOptions = { + repoPath: string + repoIdentity?: string + engine: DockerEngineClientLike + timeoutMs?: number + now?: () => number +} + +export async function resolveDockerfile(repoPath: string): Promise { + const devcontainerDockerfile = path.join(repoPath, '.devcontainer', 'Dockerfile') + if (existsSync(devcontainerDockerfile)) { + return { + dockerfilePath: devcontainerDockerfile, + content: await readFile(devcontainerDockerfile, 'utf-8'), + isGenerated: false + } + } + + const orcaDockerfile = path.join(repoPath, '.orca', 'Dockerfile') + if (existsSync(orcaDockerfile)) { + return { + dockerfilePath: orcaDockerfile, + content: await readFile(orcaDockerfile, 'utf-8'), + isGenerated: false + } + } + + return { + dockerfilePath: DEFAULT_DOCKERFILE_PATH, + content: DEFAULT_DOCKERFILE_CONTENT, + isGenerated: true + } +} + +export async function buildDockerImage( + options: BuildDockerImageOptions +): Promise { + const dockerfile = await resolveDockerfile(options.repoPath) + const cacheKey = computeDockerImageCacheKey({ + dockerfileContent: dockerfile.content, + repoIdentity: options.repoIdentity ?? options.repoPath + }) + const tag = `orca-worktree:${cacheKey.slice(0, 24)}` + + const result = await options.engine.buildImage({ + contextPath: options.repoPath, + dockerfilePath: dockerfile.dockerfilePath, + dockerfileContent: dockerfile.isGenerated ? dockerfile.content : undefined, + tag, + timeoutMs: options.timeoutMs + }) + + return { + id: result.imageId, + cacheKey, + dockerfilePath: dockerfile.dockerfilePath, + builtAt: (options.now ?? Date.now)() + } +} + +export function computeDockerImageCacheKey(input: { + dockerfileContent: string + repoIdentity: string +}): string { + // Why: tying the image cache to both Dockerfile content and repo identity + // prevents two unrelated repos with identical Dockerfiles from sharing setup. + return createHash('sha256') + .update(input.repoIdentity) + .update('\0') + .update(input.dockerfileContent) + .digest('hex') +} diff --git a/src/main/docker/docker-mount.test.ts b/src/main/docker/docker-mount.test.ts new file mode 100644 index 0000000000..82c1ab66dd --- /dev/null +++ b/src/main/docker/docker-mount.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { + DEFAULT_CONTAINER_WORKDIR, + resolveDockerBindMount, + translateWindowsPathForWsl2 +} from './docker-mount' + +describe('docker-mount', () => { + it('keeps macOS paths unchanged', () => { + expect(resolveDockerBindMount({ hostPath: '/Users/me/repo', platform: 'darwin' })).toEqual({ + source: '/Users/me/repo', + target: DEFAULT_CONTAINER_WORKDIR, + readonly: undefined + }) + }) + + it('keeps Linux paths unchanged', () => { + expect( + resolveDockerBindMount({ hostPath: '/home/me/repo', platform: 'linux', readonly: true }) + ).toEqual({ + source: '/home/me/repo', + target: DEFAULT_CONTAINER_WORKDIR, + readonly: true + }) + }) + + it('translates Windows drive paths for Docker Desktop WSL2', () => { + expect(translateWindowsPathForWsl2('C:\\Users\\u\\repo')).toBe('/mnt/c/Users/u/repo') + expect( + resolveDockerBindMount({ + hostPath: 'D:\\code\\repo', + platform: 'win32', + containerPath: '/workspace' + }) + ).toMatchObject({ source: '/mnt/d/code/repo', target: '/workspace' }) + }) + + it('leaves non-drive Windows paths unchanged', () => { + expect(translateWindowsPathForWsl2('\\\\wsl.localhost\\Ubuntu\\home\\u\\repo')).toBe( + '\\\\wsl.localhost\\Ubuntu\\home\\u\\repo' + ) + }) +}) diff --git a/src/main/docker/docker-mount.ts b/src/main/docker/docker-mount.ts new file mode 100644 index 0000000000..0b9999ce05 --- /dev/null +++ b/src/main/docker/docker-mount.ts @@ -0,0 +1,42 @@ +import path from 'path' + +export type DockerBindMount = { + source: string + target: string + readonly?: boolean +} + +export type ResolveDockerBindMountOptions = { + hostPath: string + platform?: NodeJS.Platform + containerPath?: string + readonly?: boolean +} + +export const DEFAULT_CONTAINER_WORKDIR = path.posix.join(path.posix.sep, 'workspace') + +export function resolveDockerBindMount(options: ResolveDockerBindMountOptions): DockerBindMount { + const platform = options.platform ?? process.platform + const source = + platform === 'win32' ? translateWindowsPathForWsl2(options.hostPath) : options.hostPath + + return { + source, + target: options.containerPath ?? DEFAULT_CONTAINER_WORKDIR, + readonly: options.readonly + } +} + +export function translateWindowsPathForWsl2(hostPath: string): string { + const normalized = hostPath.replace(/\//g, '\\') + const driveMatch = normalized.match(/^([A-Za-z]):\\(.*)$/) + if (!driveMatch) { + return hostPath + } + + const drive = driveMatch[1].toLowerCase() + const rest = driveMatch[2].split('\\').filter(Boolean) + // Why: Docker Desktop's WSL2 backend sees Windows drives at /mnt/, + // so bind mounts must use that Linux path instead of the Win32 host path. + return path.posix.join(path.posix.sep, 'mnt', drive, ...rest) +} diff --git a/src/main/docker/types.ts b/src/main/docker/types.ts new file mode 100644 index 0000000000..a94681776f --- /dev/null +++ b/src/main/docker/types.ts @@ -0,0 +1,33 @@ +export type DockerImageHandle = { + id: string + cacheKey: string + dockerfilePath: string + builtAt: number +} + +export type DockerContainerHandle = { + id: string + imageId: string + startedAt: number + state: 'running' | 'hibernated' | 'terminated' +} + +export type DockerTarget = { + containerId: string + image: DockerImageHandle + workdir: string +} + +export type DockerEngineFlavor = + | 'docker-desktop-mac' + | 'colima' + | 'docker-engine-linux' + | 'docker-rootless-linux' + | 'docker-desktop-windows-wsl2' + +export type DockerEngineInfo = { + flavor: DockerEngineFlavor + socketPath: string + available: boolean + reason?: string +} diff --git a/src/main/providers/docker-filesystem-provider.test.ts b/src/main/providers/docker-filesystem-provider.test.ts new file mode 100644 index 0000000000..8735cb5351 --- /dev/null +++ b/src/main/providers/docker-filesystem-provider.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { DockerEngineFake } from '../docker/docker-engine-fake' +import type { DockerTarget } from '../docker/types' +import { DockerFilesystemProvider } from './docker-filesystem-provider' + +describe('DockerFilesystemProvider', () => { + let engine: DockerEngineFake + let provider: DockerFilesystemProvider + + beforeEach(() => { + engine = new DockerEngineFake() + const target: DockerTarget = { + containerId: 'container-1', + workdir: '/workspace', + image: { id: 'sha256:image', cacheKey: 'key', dockerfilePath: 'Dockerfile', builtAt: 1 } + } + provider = new DockerFilesystemProvider(target, engine) + }) + + it('reads directories through docker exec', async () => { + engine.enqueueExecResult({ + stdout: JSON.stringify([{ name: 'src', isDirectory: true, isSymlink: false }]) + }) + + await expect(provider.readDir('/workspace')).resolves.toEqual([ + { name: 'src', isDirectory: true, isSymlink: false } + ]) + expect(engine.commands[0]).toMatchObject({ + command: 'container.exec', + options: { + containerId: 'container-1', + args: ['node', '-e', expect.any(String), '/workspace'] + } + }) + }) + + it('writes files using stdin instead of shell interpolation', async () => { + await provider.writeFile('/workspace/a.txt', 'hello') + + expect(engine.commands[0]).toMatchObject({ + command: 'container.exec', + options: { input: 'hello' } + }) + }) + + it('returns stat, search, and file list results from JSON stdout', async () => { + engine.enqueueExecResult({ stdout: JSON.stringify({ size: 1, type: 'file', mtime: 2 }) }) + engine.enqueueExecResult({ + stdout: JSON.stringify({ files: [], totalMatches: 0, truncated: false }) + }) + engine.enqueueExecResult({ stdout: JSON.stringify(['src/index.ts']) }) + + await expect(provider.stat('/workspace/a.txt')).resolves.toMatchObject({ type: 'file' }) + await expect(provider.search({ rootPath: '/workspace', query: 'TODO' })).resolves.toMatchObject( + { + totalMatches: 0 + } + ) + await expect(provider.listFiles('/workspace')).resolves.toEqual(['src/index.ts']) + }) + + it('registers and unregisters watches without a real daemon watcher', async () => { + const callback = vi.fn() + const unwatch = await provider.watch('/workspace', callback) + + expect(engine.commands[0]).toMatchObject({ + command: 'container.exec', + options: { args: ['sh', '-lc', 'true'], cwd: '/workspace' } + }) + unwatch() + }) + + it('surfaces docker exec failures', async () => { + engine.nextExecError = new Error('container crashed') + + await expect(provider.readFile('/workspace/a.txt')).rejects.toThrow('container crashed') + }) +}) diff --git a/src/main/providers/docker-filesystem-provider.ts b/src/main/providers/docker-filesystem-provider.ts new file mode 100644 index 0000000000..3100b1fdc1 --- /dev/null +++ b/src/main/providers/docker-filesystem-provider.ts @@ -0,0 +1,211 @@ +import type { DockerEngineClientLike } from '../docker/docker-engine-client' +import { DockerEngineClient } from '../docker/docker-engine-client' +import type { DockerTarget } from '../docker/types' +import type { IFilesystemProvider, FileReadResult, FileStat } from './types' +import type { DirEntry, FsChangeEvent, SearchOptions, SearchResult } from '../../shared/types' + +export class DockerFilesystemProvider implements IFilesystemProvider { + private target: DockerTarget + private engine: DockerEngineClientLike + private watchListeners = new Map void>() + + constructor(target: DockerTarget, engine: DockerEngineClientLike = new DockerEngineClient()) { + this.target = target + this.engine = engine + } + + getConnectionId(): string { + return this.target.containerId + } + + async readDir(dirPath: string): Promise { + return this.execNodeJson(READ_DIR_SCRIPT, [dirPath]) + } + + async readFile(filePath: string): Promise { + return this.execNodeJson(READ_FILE_SCRIPT, [filePath]) + } + + async writeFile(filePath: string, content: string): Promise { + await this.execNodeVoid(WRITE_FILE_SCRIPT, [filePath], content) + } + + async stat(filePath: string): Promise { + return this.execNodeJson(STAT_SCRIPT, [filePath]) + } + + async deletePath(targetPath: string, recursive?: boolean): Promise { + await this.execNodeVoid(DELETE_PATH_SCRIPT, [targetPath, recursive ? '1' : '0']) + } + + async createFile(filePath: string): Promise { + await this.execNodeVoid(CREATE_FILE_SCRIPT, [filePath]) + } + + async createDir(dirPath: string): Promise { + await this.execNodeVoid(CREATE_DIR_SCRIPT, [dirPath]) + } + + async rename(oldPath: string, newPath: string): Promise { + await this.execNodeVoid(RENAME_SCRIPT, [oldPath, newPath]) + } + + async copy(source: string, destination: string): Promise { + await this.execNodeVoid(COPY_SCRIPT, [source, destination]) + } + + async realpath(filePath: string): Promise { + return this.execNodeJson(REALPATH_SCRIPT, [filePath]) + } + + async search(opts: SearchOptions): Promise { + return this.execNodeJson(SEARCH_SCRIPT, [JSON.stringify(opts)]) + } + + async listFiles(rootPath: string, options?: { excludePaths?: string[] }): Promise { + return this.execNodeJson(LIST_FILES_SCRIPT, [ + rootPath, + JSON.stringify(options?.excludePaths ?? []) + ]) + } + + async watch(rootPath: string, callback: (events: FsChangeEvent[]) => void): Promise<() => void> { + this.watchListeners.set(rootPath, callback) + await this.engine.exec({ + containerId: this.target.containerId, + args: ['sh', '-lc', 'true'], + cwd: rootPath + }) + return () => { + this.watchListeners.delete(rootPath) + } + } + + private async execNodeJson(script: string, args: string[], input?: string): Promise { + const result = await this.engine.exec({ + containerId: this.target.containerId, + args: ['node', '-e', script, ...args], + cwd: this.target.workdir, + input + }) + return JSON.parse(result.stdout) as T + } + + private async execNodeVoid(script: string, args: string[], input?: string): Promise { + await this.engine.exec({ + containerId: this.target.containerId, + args: ['node', '-e', script, ...args], + cwd: this.target.workdir, + input + }) + } +} + +const READ_DIR_SCRIPT = ` +const fs = require('fs'); +const entries = fs.readdirSync(process.argv[1], { withFileTypes: true }) + .map((entry) => ({ name: entry.name, isDirectory: entry.isDirectory(), isSymlink: entry.isSymbolicLink() })) + .sort((a, b) => a.isDirectory !== b.isDirectory ? (a.isDirectory ? -1 : 1) : a.name.localeCompare(b.name)); +process.stdout.write(JSON.stringify(entries)); +` +const READ_FILE_SCRIPT = ` +const fs = require('fs'); +const filePath = process.argv[1]; +const buffer = fs.readFileSync(filePath); +const isBinary = buffer.subarray(0, Math.min(buffer.length, 8192)).includes(0); +process.stdout.write(JSON.stringify({ content: isBinary ? '' : buffer.toString('utf8'), isBinary })); +` +const WRITE_FILE_SCRIPT = ` +const fs = require('fs'); +let input = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', chunk => input += chunk); +process.stdin.on('end', () => fs.writeFileSync(process.argv[1], input, 'utf8')); +` +const STAT_SCRIPT = ` +const fs = require('fs'); +const stat = fs.lstatSync(process.argv[1]); +process.stdout.write(JSON.stringify({ + size: stat.size, + type: stat.isDirectory() ? 'directory' : (stat.isSymbolicLink() ? 'symlink' : 'file'), + mtime: stat.mtimeMs +})); +` +const DELETE_PATH_SCRIPT = ` +const fs = require('fs'); +fs.rmSync(process.argv[1], { recursive: process.argv[2] === '1', force: true }); +` +const CREATE_FILE_SCRIPT = ` +const fs = require('fs'); +fs.closeSync(fs.openSync(process.argv[1], 'wx')); +` +const CREATE_DIR_SCRIPT = ` +const fs = require('fs'); +fs.mkdirSync(process.argv[1], { recursive: true }); +` +const RENAME_SCRIPT = ` +const fs = require('fs'); +fs.renameSync(process.argv[1], process.argv[2]); +` +const COPY_SCRIPT = ` +const fs = require('fs'); +const stat = fs.lstatSync(process.argv[1]); +if (stat.isDirectory()) fs.cpSync(process.argv[1], process.argv[2], { recursive: true }); +else fs.copyFileSync(process.argv[1], process.argv[2]); +` +const REALPATH_SCRIPT = ` +const fs = require('fs'); +process.stdout.write(JSON.stringify(fs.realpathSync(process.argv[1]))); +` +const LIST_FILES_SCRIPT = ` +const fs = require('fs'); +const path = require('path'); +const root = process.argv[1]; +const excludes = new Set(JSON.parse(process.argv[2])); +const out = []; +function walk(dir) { + if (excludes.has(dir)) return; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.name === '.git') continue; + const abs = path.join(dir, entry.name); + if (excludes.has(abs)) continue; + if (entry.isDirectory()) walk(abs); + else out.push(path.relative(root, abs).replace(/\\\\/g, '/')); + } +} +walk(root); +process.stdout.write(JSON.stringify(out.sort())); +` +const SEARCH_SCRIPT = ` +const fs = require('fs'); +const path = require('path'); +const opts = JSON.parse(process.argv[1]); +const query = opts.caseSensitive ? opts.query : opts.query.toLowerCase(); +const max = opts.maxResults || 2000; +const files = []; +let totalMatches = 0; +function visit(filePath) { + if (totalMatches >= max) return; + const stat = fs.statSync(filePath); + if (stat.isDirectory()) { + if (path.basename(filePath) === '.git') return; + for (const child of fs.readdirSync(filePath)) visit(path.join(filePath, child)); + return; + } + const text = fs.readFileSync(filePath, 'utf8'); + const haystack = opts.caseSensitive ? text : text.toLowerCase(); + const matches = []; + const lines = text.split(/\\r?\\n/); + for (let i = 0; i < lines.length && totalMatches < max; i++) { + const lineHaystack = opts.caseSensitive ? lines[i] : lines[i].toLowerCase(); + const column = lineHaystack.indexOf(query); + if (column >= 0) { + matches.push({ line: i + 1, column: column + 1, matchLength: opts.query.length, lineContent: lines[i] }); + totalMatches++; + } + } + if (matches.length) files.push({ filePath, relativePath: path.relative(opts.rootPath, filePath).replace(/\\\\/g, '/'), matches }); +} +visit(opts.rootPath); +process.stdout.write(JSON.stringify({ files, totalMatches, truncated: totalMatches >= max })); +` diff --git a/src/main/providers/docker-git-provider.test.ts b/src/main/providers/docker-git-provider.test.ts new file mode 100644 index 0000000000..6a1885c717 --- /dev/null +++ b/src/main/providers/docker-git-provider.test.ts @@ -0,0 +1,77 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { DockerEngineFake } from '../docker/docker-engine-fake' +import type { DockerTarget } from '../docker/types' +import { DockerGitProvider } from './docker-git-provider' + +describe('DockerGitProvider', () => { + let engine: DockerEngineFake + let provider: DockerGitProvider + + beforeEach(() => { + engine = new DockerEngineFake() + const target: DockerTarget = { + containerId: 'container-1', + workdir: '/workspace', + image: { id: 'sha256:image', cacheKey: 'key', dockerfilePath: 'Dockerfile', builtAt: 1 } + } + provider = new DockerGitProvider(target, engine) + }) + + it('routes status through git inside the container', async () => { + engine.enqueueExecResult({ + stdout: '1 M. N... 100644 100644 100644 abc abc src/app.ts\n? new.txt\n' + }) + engine.enqueueExecResult({ stdout: '.git\n' }) + + const result = await provider.getStatus('/workspace') + + expect(result.entries).toEqual([ + { path: 'src/app.ts', status: 'modified', area: 'staged' }, + { path: 'new.txt', status: 'untracked', area: 'untracked' } + ]) + expect(engine.commands[0]).toMatchObject({ + command: 'container.exec', + options: { + args: ['git', 'status', '--porcelain=v2', '--untracked-files=all'], + cwd: '/workspace' + } + }) + }) + + it('stages, unstages, and discards files with git commands', async () => { + await provider.stageFile('/workspace', 'a.ts') + await provider.unstageFile('/workspace', 'a.ts') + await provider.discardChanges('/workspace', 'a.ts') + + expect(engine.commands.map((command) => command.command)).toEqual([ + 'container.exec', + 'container.exec', + 'container.exec' + ]) + expect(engine.commands[0]).toMatchObject({ + command: 'container.exec', + options: { args: ['git', 'add', '--', 'a.ts'] } + }) + }) + + it('detects merge conflicts', async () => { + engine.enqueueExecResult({ stdout: '.git\n' }) + + await expect(provider.detectConflictOperation('/workspace')).resolves.toBe('merge') + }) + + it('checks git repo status asynchronously', async () => { + engine.enqueueExecResult({ stdout: '/workspace\n' }) + + await expect(provider.isGitRepoAsync('/workspace')).resolves.toEqual({ + isRepo: true, + rootPath: '/workspace' + }) + }) + + it('surfaces container crashes during git operations', async () => { + engine.nextExecError = new Error('container crashed') + + await expect(provider.stageFile('/workspace', 'a.ts')).rejects.toThrow('container crashed') + }) +}) diff --git a/src/main/providers/docker-git-provider.ts b/src/main/providers/docker-git-provider.ts new file mode 100644 index 0000000000..107b1f0952 --- /dev/null +++ b/src/main/providers/docker-git-provider.ts @@ -0,0 +1,286 @@ +import hostedGitInfo from 'hosted-git-info' +import type { DockerEngineClientLike } from '../docker/docker-engine-client' +import { DockerEngineClient } from '../docker/docker-engine-client' +import type { DockerTarget } from '../docker/types' +import type { IGitProvider } from './types' +import type { + GitBranchCompareResult, + GitConflictOperation, + GitDiffResult, + GitFileStatus, + GitStatusResult, + GitWorktreeInfo +} from '../../shared/types' + +export class DockerGitProvider implements IGitProvider { + private target: DockerTarget + private engine: DockerEngineClientLike + + constructor(target: DockerTarget, engine: DockerEngineClientLike = new DockerEngineClient()) { + this.target = target + this.engine = engine + } + + getConnectionId(): string { + return this.target.containerId + } + + async getStatus(worktreePath: string): Promise { + const [status, conflictOperation] = await Promise.all([ + this.git(['status', '--porcelain=v2', '--untracked-files=all'], worktreePath), + this.detectConflictOperation(worktreePath) + ]) + return { entries: parseStatus(status.stdout), conflictOperation } + } + + async getDiff(worktreePath: string, filePath: string, staged: boolean): Promise { + const args = staged ? ['diff', '--cached', '--', filePath] : ['diff', '--', filePath] + const result = await this.git(args, worktreePath) + return { + kind: 'text', + originalContent: '', + modifiedContent: result.stdout, + originalIsBinary: false, + modifiedIsBinary: false + } + } + + async stageFile(worktreePath: string, filePath: string): Promise { + await this.git(['add', '--', filePath], worktreePath) + } + + async unstageFile(worktreePath: string, filePath: string): Promise { + await this.git(['restore', '--staged', '--', filePath], worktreePath) + } + + async bulkStageFiles(worktreePath: string, filePaths: string[]): Promise { + await this.git(['add', '--', ...filePaths], worktreePath) + } + + async bulkUnstageFiles(worktreePath: string, filePaths: string[]): Promise { + await this.git(['restore', '--staged', '--', ...filePaths], worktreePath) + } + + async discardChanges(worktreePath: string, filePath: string): Promise { + await this.git(['restore', '--', filePath], worktreePath) + } + + async detectConflictOperation(worktreePath: string): Promise { + const gitDir = (await this.git(['rev-parse', '--git-dir'], worktreePath)).stdout.trim() + const checks = await Promise.all([ + this.pathExists(worktreePath, `${gitDir}/MERGE_HEAD`), + this.pathExists(worktreePath, `${gitDir}/CHERRY_PICK_HEAD`), + this.pathExists(worktreePath, `${gitDir}/rebase-merge`), + this.pathExists(worktreePath, `${gitDir}/rebase-apply`) + ]) + if (checks[0]) { + return 'merge' + } + if (checks[1]) { + return 'cherry-pick' + } + if (checks[2] || checks[3]) { + return 'rebase' + } + return 'unknown' + } + + async getBranchCompare(worktreePath: string, baseRef: string): Promise { + const mergeBase = (await this.git(['merge-base', baseRef, 'HEAD'], worktreePath)).stdout.trim() + const names = await this.git(['diff', '--name-status', mergeBase, 'HEAD'], worktreePath) + const entries = names.stdout + .split(/\r?\n/) + .filter(Boolean) + .map((line) => { + const [status, filePath, oldPath] = line.split('\t') + return { + path: filePath, + status: parseBranchStatus(status), + ...(oldPath ? { oldPath } : {}) + } + }) + return { + summary: { + baseRef, + baseOid: mergeBase, + compareRef: 'HEAD', + headOid: (await this.git(['rev-parse', 'HEAD'], worktreePath)).stdout.trim(), + mergeBase, + changedFiles: entries.length, + status: 'ready' + }, + entries + } + } + + async getBranchDiff( + worktreePath: string, + baseRef: string, + options?: { includePatch?: boolean; filePath?: string; oldPath?: string } + ): Promise { + const args = ['diff', baseRef, 'HEAD'] + if (options?.filePath) { + args.push('--', options.filePath) + } + const result = await this.git(args, worktreePath) + return [ + { + kind: 'text', + originalContent: '', + modifiedContent: result.stdout, + originalIsBinary: false, + modifiedIsBinary: false + } + ] + } + + async listWorktrees(repoPath: string): Promise { + const result = await this.git(['worktree', 'list', '--porcelain'], repoPath) + return parseWorktrees(result.stdout) + } + + async addWorktree( + repoPath: string, + branchName: string, + targetDir: string, + options?: { base?: string; track?: boolean } + ): Promise { + const args = ['worktree', 'add'] + if (options?.track) { + args.push('--track') + } + args.push(targetDir, branchName) + if (options?.base) { + args.push(options.base) + } + await this.git(args, repoPath) + } + + async removeWorktree(worktreePath: string, force?: boolean): Promise { + await this.git( + ['worktree', 'remove', ...(force ? ['--force'] : []), worktreePath], + this.target.workdir + ) + } + + async exec(args: string[], cwd: string): Promise<{ stdout: string; stderr: string }> { + return this.git(args, cwd) + } + + async isGitRepoAsync(dirPath: string): Promise<{ isRepo: boolean; rootPath: string | null }> { + try { + const rootPath = (await this.git(['rev-parse', '--show-toplevel'], dirPath)).stdout.trim() + return { isRepo: true, rootPath } + } catch { + return { isRepo: false, rootPath: null } + } + } + + isGitRepo(_path: string): boolean { + return true + } + + async getRemoteFileUrl( + worktreePath: string, + relativePath: string, + line: number + ): Promise { + let remoteUrl: string + try { + remoteUrl = (await this.exec(['remote', 'get-url', 'origin'], worktreePath)).stdout.trim() + } catch { + return null + } + const info = hostedGitInfo.fromUrl(remoteUrl) + if (!info) { + return null + } + const browseUrl = info.browseFile(relativePath, { committish: 'main' }) + return browseUrl ? `${browseUrl}#L${line}` : null + } + + private async git(args: string[], cwd: string): Promise<{ stdout: string; stderr: string }> { + const result = await this.engine.exec({ + containerId: this.target.containerId, + args: ['git', ...args], + cwd + }) + return { stdout: result.stdout, stderr: result.stderr } + } + + private async pathExists(cwd: string, targetPath: string): Promise { + try { + await this.engine.exec({ + containerId: this.target.containerId, + args: ['test', '-e', targetPath], + cwd + }) + return true + } catch { + return false + } + } +} + +function parseStatus(stdout: string): GitStatusResult['entries'] { + const entries: GitStatusResult['entries'] = [] + for (const line of stdout.split(/\r?\n/)) { + if (!line) { + continue + } + if (line.startsWith('? ')) { + entries.push({ path: line.slice(2), status: 'untracked', area: 'untracked' }) + continue + } + if (line.startsWith('1 ') || line.startsWith('2 ')) { + const parts = line.split(' ') + const xy = parts[1] + const filePath = line.startsWith('2 ') ? line.split('\t')[1] : parts.slice(8).join(' ') + if (xy[0] !== '.') { + entries.push({ path: filePath, status: parseFileStatus(xy[0]), area: 'staged' }) + } + if (xy[1] !== '.') { + entries.push({ path: filePath, status: parseFileStatus(xy[1]), area: 'unstaged' }) + } + } + } + return entries +} + +function parseFileStatus(char: string): GitFileStatus { + switch (char) { + case 'A': + return 'added' + case 'D': + return 'deleted' + case 'R': + return 'renamed' + case 'C': + return 'copied' + default: + return 'modified' + } +} + +function parseBranchStatus(char: string): 'modified' | 'added' | 'deleted' | 'renamed' | 'copied' { + return parseFileStatus(char[0]) as 'modified' | 'added' | 'deleted' | 'renamed' | 'copied' +} + +function parseWorktrees(stdout: string): GitWorktreeInfo[] { + const chunks = stdout.split(/\n\n+/).filter(Boolean) + return chunks.map((chunk, index) => { + const values = Object.fromEntries( + chunk.split(/\r?\n/).map((line) => { + const [key, ...rest] = line.split(' ') + return [key, rest.join(' ')] + }) + ) + return { + path: values.worktree, + head: values.HEAD, + branch: values.branch?.replace(/^refs\/heads\//, '') ?? '', + isBare: chunk.includes('\nbare'), + isMainWorktree: index === 0 + } + }) +} diff --git a/src/main/providers/docker-pty-provider.test.ts b/src/main/providers/docker-pty-provider.test.ts new file mode 100644 index 0000000000..4ce0937b36 --- /dev/null +++ b/src/main/providers/docker-pty-provider.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { DockerEngineFake } from '../docker/docker-engine-fake' +import type { DockerTarget } from '../docker/types' +import { DockerPtyProvider } from './docker-pty-provider' + +describe('DockerPtyProvider', () => { + let engine: DockerEngineFake + let provider: DockerPtyProvider + let target: DockerTarget + + beforeEach(() => { + engine = new DockerEngineFake() + target = { + containerId: 'container-1', + workdir: '/workspace', + image: { + id: 'sha256:image', + cacheKey: 'cache-key', + dockerfilePath: 'Dockerfile', + builtAt: 1 + } + } + provider = new DockerPtyProvider(target, engine) + }) + + it('returns the container id as connection id', () => { + expect(provider.getConnectionId()).toBe('container-1') + }) + + it('spawns a shell inside the Docker container', async () => { + const result = await provider.spawn({ cols: 80, rows: 24 }) + + expect(result).toEqual({ id: 'session-1' }) + expect(engine.commands[0]).toMatchObject({ + command: 'container.exec.spawn', + options: { + containerId: 'container-1', + args: ['/bin/sh'], + cwd: '/workspace', + cols: 80, + rows: 24 + } + }) + }) + + it('forwards writes, resizes, data, and exit events', async () => { + const data = vi.fn() + const exit = vi.fn() + provider.onData(data) + provider.onExit(exit) + const { id } = await provider.spawn({ cols: 80, rows: 24 }) + const session = engine.sessions.get(id)! + + provider.write(id, 'hello') + provider.resize(id, 120, 40) + session.emitData('output') + session.crash(137) + + expect(session.writes).toEqual(['hello']) + expect(session.resizes).toEqual([{ cols: 120, rows: 40 }]) + expect(data).toHaveBeenCalledWith({ id, data: 'output' }) + expect(exit).toHaveBeenCalledWith({ id, code: 137 }) + await expect(provider.hasChildProcesses(id)).resolves.toBe(false) + }) + + it('reattaches to an existing session', async () => { + const { id } = await provider.spawn({ cols: 80, rows: 24 }) + engine.sessions.get(id)!.emitData('buffered') + + await expect(provider.spawn({ cols: 80, rows: 24, sessionId: id })).resolves.toEqual({ + id, + isReattach: true, + replay: 'buffered' + }) + }) + + it('marks requested missing sessions as expired and starts fresh', async () => { + await expect( + provider.spawn({ cols: 80, rows: 24, sessionId: 'missing' }) + ).resolves.toMatchObject({ + id: 'session-1', + sessionExpired: true + }) + }) +}) diff --git a/src/main/providers/docker-pty-provider.ts b/src/main/providers/docker-pty-provider.ts new file mode 100644 index 0000000000..953b2e876f --- /dev/null +++ b/src/main/providers/docker-pty-provider.ts @@ -0,0 +1,171 @@ +import type { DockerEngineClientLike, DockerExecSession } from '../docker/docker-engine-client' +import { DockerEngineClient } from '../docker/docker-engine-client' +import type { DockerTarget } from '../docker/types' +import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from './types' + +type DataCallback = (payload: { id: string; data: string }) => void +type ReplayCallback = (payload: { id: string; data: string }) => void +type ExitCallback = (payload: { id: string; code: number }) => void + +export class DockerPtyProvider implements IPtyProvider { + private target: DockerTarget + private engine: DockerEngineClientLike + private sessions = new Map() + private dataListeners = new Set() + private replayListeners = new Set() + private exitListeners = new Set() + + constructor(target: DockerTarget, engine: DockerEngineClientLike = new DockerEngineClient()) { + this.target = target + this.engine = engine + } + + getConnectionId(): string { + return this.target.containerId + } + + async spawn(opts: PtySpawnOptions): Promise { + if (opts.sessionId) { + const existing = this.sessions.get(opts.sessionId) + if (existing) { + return { + id: opts.sessionId, + isReattach: true, + replay: await existing.serialize() + } + } + } + + const session = await this.engine.spawnExec({ + containerId: this.target.containerId, + args: [opts.command ?? '/bin/sh'], + cwd: opts.cwd ?? this.target.workdir, + env: opts.env, + cols: opts.cols, + rows: opts.rows + }) + this.sessions.set(session.id, session) + + session.onData((data) => { + for (const cb of this.dataListeners) { + cb({ id: session.id, data }) + } + }) + session.onReplay((data) => { + for (const cb of this.replayListeners) { + cb({ id: session.id, data }) + } + }) + session.onExit((code) => { + this.sessions.delete(session.id) + for (const cb of this.exitListeners) { + cb({ id: session.id, code }) + } + }) + + return { + id: session.id, + ...(opts.sessionId ? { sessionExpired: true } : {}) + } + } + + async attach(id: string): Promise { + if (!this.sessions.has(id)) { + throw new Error(`No Docker PTY session "${id}"`) + } + } + + write(id: string, data: string): void { + this.sessions.get(id)?.write(data) + } + + resize(id: string, cols: number, rows: number): void { + this.sessions.get(id)?.resize(cols, rows) + } + + async shutdown(id: string, immediate: boolean): Promise { + const session = this.sessions.get(id) + if (!session) { + return + } + await session.shutdown(immediate) + this.sessions.delete(id) + } + + async sendSignal(id: string, signal: string): Promise { + await this.sessions.get(id)?.sendSignal(signal) + } + + async getCwd(id: string): Promise { + return (await this.sessions.get(id)?.getCwd()) ?? this.target.workdir + } + + async getInitialCwd(id: string): Promise { + return (await this.sessions.get(id)?.getInitialCwd()) ?? this.target.workdir + } + + async clearBuffer(id: string): Promise { + await this.sessions.get(id)?.clearBuffer() + } + + acknowledgeDataEvent(id: string, charCount: number): void { + this.sessions.get(id)?.acknowledgeDataEvent(charCount) + } + + async hasChildProcesses(id: string): Promise { + return (await this.sessions.get(id)?.hasChildProcesses()) ?? false + } + + async getForegroundProcess(id: string): Promise { + return (await this.sessions.get(id)?.getForegroundProcess()) ?? null + } + + async serialize(ids: string[]): Promise { + const entries = await Promise.all( + ids.map(async (id) => [id, await this.sessions.get(id)?.serialize()] as const) + ) + return JSON.stringify(Object.fromEntries(entries.filter(([, state]) => state !== undefined))) + } + + async revive(state: string): Promise { + const parsed = JSON.parse(state) as Record + await Promise.all( + Object.entries(parsed).map(async ([id, sessionState]) => { + await this.sessions.get(id)?.revive(sessionState) + }) + ) + } + + async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> { + return Promise.all( + [...this.sessions.entries()].map(async ([id, session]) => ({ + id, + cwd: await session.getCwd(), + title: (await session.getForegroundProcess()) ?? 'shell' + })) + ) + } + + async getDefaultShell(): Promise { + return '/bin/sh' + } + + async getProfiles(): Promise<{ name: string; path: string }[]> { + return [{ name: 'sh', path: '/bin/sh' }] + } + + onData(callback: DataCallback): () => void { + this.dataListeners.add(callback) + return () => this.dataListeners.delete(callback) + } + + onReplay(callback: ReplayCallback): () => void { + this.replayListeners.add(callback) + return () => this.replayListeners.delete(callback) + } + + onExit(callback: ExitCallback): () => void { + this.exitListeners.add(callback) + return () => this.exitListeners.delete(callback) + } +} From 387576f7b491870786db2f4a39ef32ffd94ee05c Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Fri, 1 May 2026 23:47:23 -0700 Subject: [PATCH 2/2] fix(docker): address self-review findings - Allocate TTY for Docker PTY sessions via tty flag on spawnExec, so isatty() returns true for shells, vim, and ncurses programs. - Return originalContent + modifiedContent for working-tree diffs instead of returning a unified patch as modifiedContent. - Return blob contents for branch diffs and honor options.oldPath for renames, matching the local provider's GitDiffResult shape. - Preserve the new path as path and the old path as oldPath when parsing R100 rename entries (was reversed). - Surface git porcelain v2 u records as unresolved conflict entries so docker worktrees in conflict show their conflicted files. - Resolve repo default branch via origin/HEAD before browseFile, instead of hardcoding 'main' for remote file URLs. --- src/main/docker/docker-engine-client.ts | 3 +- .../providers/docker-git-provider.test.ts | 125 ++++++++++ src/main/providers/docker-git-provider.ts | 234 +++++++++++++++--- .../providers/docker-pty-provider.test.ts | 1 + src/main/providers/docker-pty-provider.ts | 1 + 5 files changed, 331 insertions(+), 33 deletions(-) diff --git a/src/main/docker/docker-engine-client.ts b/src/main/docker/docker-engine-client.ts index 62a4390dfc..12a83b5d05 100644 --- a/src/main/docker/docker-engine-client.ts +++ b/src/main/docker/docker-engine-client.ts @@ -44,6 +44,7 @@ export type DockerExecSessionOptions = { args: string[] cwd: string env?: Record + tty?: boolean cols: number rows: number } @@ -164,7 +165,7 @@ export class DockerEngineClient implements DockerEngineClientLike { LINES: String(options.rows) } }) - args.splice(1, 0, '-i') + args.splice(1, 0, ...(options.tty ? ['-i', '-t'] : ['-i'])) const child = spawn('docker', args, { stdio: ['pipe', 'pipe', 'pipe'] }) const dataListeners = new Set<(data: string) => void>() const replayListeners = new Set<(data: string) => void>() diff --git a/src/main/providers/docker-git-provider.test.ts b/src/main/providers/docker-git-provider.test.ts index 6a1885c717..af10bf7251 100644 --- a/src/main/providers/docker-git-provider.test.ts +++ b/src/main/providers/docker-git-provider.test.ts @@ -38,6 +38,80 @@ describe('DockerGitProvider', () => { }) }) + it('surfaces unmerged status records as unresolved conflicts', async () => { + engine.enqueueExecResult({ + stdout: 'u UU N... 100644 100644 100644 100644 abc def ghi src/conflicted file.ts\n' + }) + engine.enqueueExecResult({ stdout: '.git\n' }) + + const result = await provider.getStatus('/workspace') + + expect(result.entries).toEqual([ + { + path: 'src/conflicted file.ts', + status: 'modified', + area: 'unstaged', + conflictKind: 'both_modified', + conflictStatus: 'unresolved' + } + ]) + }) + + it('returns original and modified contents for staged diffs', async () => { + engine.enqueueExecResult({ stdout: 'head content\n' }) + engine.enqueueExecResult({ stdout: 'staged content\n' }) + + const result = await provider.getDiff('/workspace', 'src/app.ts', true) + + expect(result).toEqual({ + kind: 'text', + originalContent: 'head content\n', + modifiedContent: 'staged content\n', + originalIsBinary: false, + modifiedIsBinary: false + }) + expect(engine.commands.map((command) => command.command)).toEqual([ + 'container.exec', + 'container.exec' + ]) + expect(engine.commands[0]).toMatchObject({ + options: { args: ['git', 'show', 'HEAD:src/app.ts'] } + }) + expect(engine.commands[1]).toMatchObject({ + options: { args: ['git', 'show', ':src/app.ts'] } + }) + }) + + it('returns original and modified contents for unstaged diffs', async () => { + engine.enqueueExecResult({ stdout: 'index content\n' }) + engine.enqueueExecResult({ stdout: 'working content\n' }) + + const result = await provider.getDiff('/workspace', 'src/app.ts', false) + + expect(result).toMatchObject({ + originalContent: 'index content\n', + modifiedContent: 'working content\n' + }) + expect(engine.commands[0]).toMatchObject({ + options: { args: ['git', 'show', ':src/app.ts'] } + }) + expect(engine.commands[1]).toMatchObject({ + options: { args: ['cat', '--', 'src/app.ts'] } + }) + }) + + it('returns empty original content and working-tree content for untracked diffs', async () => { + engine.enqueueExecResult({ stdout: '' }) + engine.enqueueExecResult({ stdout: 'new file\n' }) + + const result = await provider.getDiff('/workspace', 'src/new.ts', false) + + expect(result).toMatchObject({ + originalContent: '', + modifiedContent: 'new file\n' + }) + }) + it('stages, unstages, and discards files with git commands', async () => { await provider.stageFile('/workspace', 'a.ts') await provider.unstageFile('/workspace', 'a.ts') @@ -60,6 +134,57 @@ describe('DockerGitProvider', () => { await expect(provider.detectConflictOperation('/workspace')).resolves.toBe('merge') }) + it('preserves the new path when parsing renamed branch entries', async () => { + engine.enqueueExecResult({ stdout: 'base-sha\n' }) + engine.enqueueExecResult({ stdout: 'R100\tsrc/old.ts\tsrc/new.ts\n' }) + engine.enqueueExecResult({ stdout: 'head-sha\n' }) + + const result = await provider.getBranchCompare('/workspace', 'origin/main') + + expect(result.entries).toEqual([ + { path: 'src/new.ts', oldPath: 'src/old.ts', status: 'renamed' } + ]) + }) + + it('returns blob contents for branch diffs and uses oldPath for renamed originals', async () => { + engine.enqueueExecResult({ stdout: 'old content\n' }) + engine.enqueueExecResult({ stdout: 'new content\n' }) + + const result = await provider.getBranchDiff('/workspace', 'base-sha', { + includePatch: true, + filePath: 'src/new.ts', + oldPath: 'src/old.ts' + }) + + expect(result).toEqual([ + { + kind: 'text', + originalContent: 'old content\n', + modifiedContent: 'new content\n', + originalIsBinary: false, + modifiedIsBinary: false + } + ]) + expect(engine.commands[0]).toMatchObject({ + options: { args: ['git', 'show', 'base-sha:src/old.ts'] } + }) + expect(engine.commands[1]).toMatchObject({ + options: { args: ['git', 'show', 'HEAD:src/new.ts'] } + }) + }) + + it('uses the resolved default branch for remote file URLs', async () => { + engine.enqueueExecResult({ stdout: 'git@github.com:stablyai/orca.git\n' }) + engine.enqueueExecResult({ stdout: 'refs/remotes/origin/master\n' }) + + const result = await provider.getRemoteFileUrl('/workspace', 'src/app.ts', 42) + + expect(result).toBe('https://github.com/stablyai/orca/blob/master/src/app.ts#L42') + expect(engine.commands[1]).toMatchObject({ + options: { args: ['git', 'symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'] } + }) + }) + it('checks git repo status asynchronously', async () => { engine.enqueueExecResult({ stdout: '/workspace\n' }) diff --git a/src/main/providers/docker-git-provider.ts b/src/main/providers/docker-git-provider.ts index 107b1f0952..b785a039da 100644 --- a/src/main/providers/docker-git-provider.ts +++ b/src/main/providers/docker-git-provider.ts @@ -1,13 +1,19 @@ +/* eslint-disable max-lines */ import hostedGitInfo from 'hosted-git-info' import type { DockerEngineClientLike } from '../docker/docker-engine-client' import { DockerEngineClient } from '../docker/docker-engine-client' import type { DockerTarget } from '../docker/types' +import { resolveDefaultBaseRefViaExec } from '../git/repo' import type { IGitProvider } from './types' import type { + GitBranchChangeEntry, GitBranchCompareResult, + GitBranchChangeStatus, + GitConflictKind, GitConflictOperation, GitDiffResult, GitFileStatus, + GitStatusEntry, GitStatusResult, GitWorktreeInfo } from '../../shared/types' @@ -34,15 +40,14 @@ export class DockerGitProvider implements IGitProvider { } async getDiff(worktreePath: string, filePath: string, staged: boolean): Promise { - const args = staged ? ['diff', '--cached', '--', filePath] : ['diff', '--', filePath] - const result = await this.git(args, worktreePath) - return { - kind: 'text', - originalContent: '', - modifiedContent: result.stdout, - originalIsBinary: false, - modifiedIsBinary: false - } + const originalBlob = staged + ? await this.readGitBlob(worktreePath, 'HEAD', filePath) + : await this.readUnstagedLeftBlob(worktreePath, filePath) + const modifiedContent = staged + ? (await this.readGitIndexBlob(worktreePath, filePath)).content + : await this.readWorkingTreeFile(worktreePath, filePath) + + return buildTextDiffResult(originalBlob.content, modifiedContent) } async stageFile(worktreePath: string, filePath: string): Promise { @@ -87,18 +92,15 @@ export class DockerGitProvider implements IGitProvider { async getBranchCompare(worktreePath: string, baseRef: string): Promise { const mergeBase = (await this.git(['merge-base', baseRef, 'HEAD'], worktreePath)).stdout.trim() - const names = await this.git(['diff', '--name-status', mergeBase, 'HEAD'], worktreePath) + const names = await this.git( + ['diff', '--name-status', '-M', '-C', mergeBase, 'HEAD'], + worktreePath + ) const entries = names.stdout .split(/\r?\n/) .filter(Boolean) - .map((line) => { - const [status, filePath, oldPath] = line.split('\t') - return { - path: filePath, - status: parseBranchStatus(status), - ...(oldPath ? { oldPath } : {}) - } - }) + .map(parseBranchChangeLine) + .filter((entry): entry is GitBranchChangeEntry => entry !== null) return { summary: { baseRef, @@ -118,20 +120,31 @@ export class DockerGitProvider implements IGitProvider { baseRef: string, options?: { includePatch?: boolean; filePath?: string; oldPath?: string } ): Promise { - const args = ['diff', baseRef, 'HEAD'] - if (options?.filePath) { - args.push('--', options.filePath) + const entries = options?.filePath + ? [ + { + path: options.filePath, + status: 'modified' as const, + ...(options.oldPath ? { oldPath: options.oldPath } : {}) + } + ] + : await this.loadBranchChanges(worktreePath, baseRef) + + if (options?.includePatch === false) { + return entries.map(() => buildTextDiffResult('', '')) } - const result = await this.git(args, worktreePath) - return [ - { - kind: 'text', - originalContent: '', - modifiedContent: result.stdout, - originalIsBinary: false, - modifiedIsBinary: false - } - ] + + return Promise.all( + entries.map(async (entry) => { + const originalContent = await this.readGitBlob( + worktreePath, + baseRef, + entry.oldPath ?? entry.path + ) + const modifiedContent = await this.readGitBlob(worktreePath, 'HEAD', entry.path) + return buildTextDiffResult(originalContent.content, modifiedContent.content) + }) + ) } async listWorktrees(repoPath: string): Promise { @@ -195,7 +208,14 @@ export class DockerGitProvider implements IGitProvider { if (!info) { return null } - const browseUrl = info.browseFile(relativePath, { committish: 'main' }) + const defaultBaseRef = await resolveDefaultBaseRefViaExec((argv) => + this.git(argv, worktreePath) + ) + if (!defaultBaseRef) { + return null + } + const defaultBranch = defaultBaseRef.replace(/^origin\//, '') + const browseUrl = info.browseFile(relativePath, { committish: defaultBranch }) return browseUrl ? `${browseUrl}#L${line}` : null } @@ -220,6 +240,76 @@ export class DockerGitProvider implements IGitProvider { return false } } + + private async loadBranchChanges( + worktreePath: string, + baseRef: string + ): Promise { + const result = await this.git( + ['diff', '--name-status', '-M', '-C', baseRef, 'HEAD'], + worktreePath + ) + return result.stdout + .split(/\r?\n/) + .filter(Boolean) + .map(parseBranchChangeLine) + .filter((entry): entry is GitBranchChangeEntry => entry !== null) + } + + private async readUnstagedLeftBlob( + worktreePath: string, + filePath: string + ): Promise { + const indexBlob = await this.readGitIndexBlob(worktreePath, filePath) + if (indexBlob.exists) { + return indexBlob + } + return this.readGitBlob(worktreePath, 'HEAD', filePath) + } + + private async readGitIndexBlob( + worktreePath: string, + filePath: string + ): Promise { + return this.readGitBlobSpec(worktreePath, `:${filePath}`) + } + + private async readGitBlob( + worktreePath: string, + ref: string, + filePath: string + ): Promise { + return this.readGitBlobSpec(worktreePath, `${ref}:${filePath}`) + } + + private async readGitBlobSpec( + worktreePath: string, + spec: string + ): Promise { + try { + return { content: (await this.git(['show', spec], worktreePath)).stdout, exists: true } + } catch { + return { content: '', exists: false } + } + } + + private async readWorkingTreeFile(worktreePath: string, filePath: string): Promise { + try { + const result = await this.engine.exec({ + containerId: this.target.containerId, + args: ['cat', '--', filePath], + cwd: worktreePath + }) + return result.stdout + } catch { + return '' + } + } +} + +type DockerGitBlobReadResult = { + content: string + exists: boolean } function parseStatus(stdout: string): GitStatusResult['entries'] { @@ -242,6 +332,13 @@ function parseStatus(stdout: string): GitStatusResult['entries'] { if (xy[1] !== '.') { entries.push({ path: filePath, status: parseFileStatus(xy[1]), area: 'unstaged' }) } + continue + } + if (line.startsWith('u ')) { + const entry = parseUnmergedStatusLine(line) + if (entry) { + entries.push(entry) + } } } return entries @@ -266,6 +363,79 @@ function parseBranchStatus(char: string): 'modified' | 'added' | 'deleted' | 're return parseFileStatus(char[0]) as 'modified' | 'added' | 'deleted' | 'renamed' | 'copied' } +function parseBranchChangeLine(line: string): GitBranchChangeEntry | null { + const parts = line.split('\t') + const rawStatus = parts[0] ?? '' + const status = parseBranchStatus(rawStatus) as GitBranchChangeStatus + + if (rawStatus.startsWith('R') || rawStatus.startsWith('C')) { + const oldPath = parts[1] + const filePath = parts[2] + if (!filePath) { + return null + } + return { path: filePath, oldPath, status } + } + + const filePath = parts[1] + if (!filePath) { + return null + } + return { path: filePath, status } +} + +function parseUnmergedStatusLine(line: string): GitStatusEntry | null { + const parts = line.split(' ') + const xy = parts[1] + const filePath = parts.slice(10).join(' ') + if (!xy || !filePath) { + return null + } + const conflictKind = parseConflictKind(xy) + if (!conflictKind) { + return null + } + + return { + path: filePath, + area: 'unstaged', + status: conflictKind === 'both_deleted' ? 'deleted' : 'modified', + conflictKind, + conflictStatus: 'unresolved' + } +} + +function parseConflictKind(xy: string): GitConflictKind | null { + switch (xy) { + case 'UU': + return 'both_modified' + case 'AA': + return 'both_added' + case 'DD': + return 'both_deleted' + case 'AU': + return 'added_by_us' + case 'UA': + return 'added_by_them' + case 'DU': + return 'deleted_by_us' + case 'UD': + return 'deleted_by_them' + default: + return null + } +} + +function buildTextDiffResult(originalContent: string, modifiedContent: string): GitDiffResult { + return { + kind: 'text', + originalContent, + modifiedContent, + originalIsBinary: false, + modifiedIsBinary: false + } +} + function parseWorktrees(stdout: string): GitWorktreeInfo[] { const chunks = stdout.split(/\n\n+/).filter(Boolean) return chunks.map((chunk, index) => { diff --git a/src/main/providers/docker-pty-provider.test.ts b/src/main/providers/docker-pty-provider.test.ts index 4ce0937b36..3d79deab5e 100644 --- a/src/main/providers/docker-pty-provider.test.ts +++ b/src/main/providers/docker-pty-provider.test.ts @@ -37,6 +37,7 @@ describe('DockerPtyProvider', () => { containerId: 'container-1', args: ['/bin/sh'], cwd: '/workspace', + tty: true, cols: 80, rows: 24 } diff --git a/src/main/providers/docker-pty-provider.ts b/src/main/providers/docker-pty-provider.ts index 953b2e876f..adf009baca 100644 --- a/src/main/providers/docker-pty-provider.ts +++ b/src/main/providers/docker-pty-provider.ts @@ -41,6 +41,7 @@ export class DockerPtyProvider implements IPtyProvider { args: [opts.command ?? '/bin/sh'], cwd: opts.cwd ?? this.target.workdir, env: opts.env, + tty: true, cols: opts.cols, rows: opts.rows })