diff --git a/src/main/__tests__/worktree-stress.integration.test.ts b/src/main/__tests__/worktree-stress.integration.test.ts new file mode 100644 index 0000000..3108204 --- /dev/null +++ b/src/main/__tests__/worktree-stress.integration.test.ts @@ -0,0 +1,322 @@ +/** + * Worktree Isolation — Real-environment stress tests. + * + * These tests use actual wsl.exe + git against real repos on disk. + * They test the full WorktreeManager lifecycle under concurrency + * and rapid create/destroy cycles. + * + * Requires: WSL2 with git installed, ~/agentdeck-test/git-project exists. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest' +import { execFileSync } from 'child_process' +import * as fs from 'fs' +import * as path from 'path' +import { createWslGitPort } from '../git-port' +import { createWorktreeManager, type WorktreeManager } from '../worktree-manager' + +const TEST_REPO = '/home/rooty/agentdeck-test/git-project' +const REGISTRY_DIR = path.join(process.env['TEMP'] ?? '/tmp', `agentdeck-stress-${Date.now()}`) +const WSL_WORKTREE_DIR = `/tmp/agentdeck-stress-wt-${Date.now()}` + +function wslAvailable(): boolean { + try { + execFileSync('wsl.exe', ['echo', 'ok'], { timeout: 5000, encoding: 'utf-8' }) + return true + } catch { + return false + } +} + +function wslExec(cmd: string): string { + return execFileSync('wsl.exe', ['bash', '-lc', cmd], { + timeout: 15000, + encoding: 'utf-8', + }).trim() +} + +function listWorktrees(): string[] { + const output = wslExec(`git -C ${TEST_REPO} worktree list --porcelain`) + return output + .split('\n') + .filter((l) => l.startsWith('worktree ')) + .map((l) => l.replace('worktree ', '')) +} + +function listBranches(): string[] { + const output = wslExec(`git -C ${TEST_REPO} branch --list 'agentdeck/*'`) + return output + .split('\n') + .map((l) => l.replace(/^[*+ ]+/, '').trim()) + .filter(Boolean) +} + +const canRun = wslAvailable() +const describeIf = canRun ? describe : describe.skip + +describeIf('Worktree Stress Tests (real git)', () => { + let git: ReturnType + let mgr: WorktreeManager + + beforeAll(() => { + // Ensure test repo exists and is clean + const repoExists = wslExec(`test -d ${TEST_REPO}/.git && echo yes || echo no`) + if (repoExists !== 'yes') { + throw new Error(`Test repo not found at ${TEST_REPO}. Run the test project setup first.`) + } + // Clean up any leftover agentdeck branches/worktrees from prior runs + try { + wslExec(`git -C ${TEST_REPO} worktree prune`) + } catch { + /* ignore */ + } + const branches = listBranches() + for (const b of branches) { + try { + wslExec(`git -C ${TEST_REPO} branch -D '${b}'`) + } catch { + /* ignore */ + } + } + }) + + beforeEach(() => { + // Order matters: delete dirs first so git worktree prune can release branches + try { + wslExec(`rm -rf /tmp/agentdeck-stress-wt-*`) + } catch { + /* ignore */ + } + try { + wslExec(`git -C ${TEST_REPO} worktree prune`) + } catch { + /* ignore */ + } + for (const b of listBranches()) { + try { + wslExec(`git -C ${TEST_REPO} branch -D '${b}'`) + } catch { + /* ignore */ + } + } + // Clean registry + try { + fs.rmSync(REGISTRY_DIR, { recursive: true, force: true }) + } catch { + /* ignore */ + } + + // Fresh manager for each test + git = createWslGitPort() + fs.mkdirSync(REGISTRY_DIR, { recursive: true }) + mgr = createWorktreeManager(git, () => TEST_REPO, REGISTRY_DIR, WSL_WORKTREE_DIR) + }) + + afterAll(() => { + // Cleanup registry dir + try { + fs.rmSync(REGISTRY_DIR, { recursive: true, force: true }) + } catch { + /* ignore */ + } + // Cleanup WSL worktree dirs + try { + wslExec(`rm -rf ${WSL_WORKTREE_DIR}`) + } catch { + /* ignore */ + } + // Prune git worktree references + try { + wslExec(`git -C ${TEST_REPO} worktree prune`) + } catch { + /* ignore */ + } + // Remove agentdeck branches + const branches = listBranches() + for (const b of branches) { + try { + wslExec(`git -C ${TEST_REPO} branch -D '${b}'`) + } catch { + /* ignore */ + } + } + }) + + it('10 concurrent acquires → exactly 1 primary, 9 worktrees', async () => { + const projectId = 'stress-proj-1' + const sessionIds = Array.from({ length: 10 }, (_, i) => `stress-session-${i}`) + + const results = await Promise.all(sessionIds.map((sid) => mgr.acquire(projectId, sid))) + + const primaries = results.filter((r) => !r.isolated) + const worktrees = results.filter((r) => r.isolated) + + expect(primaries).toHaveLength(1) + expect(worktrees).toHaveLength(9) + expect(primaries[0]?.path).toBe(TEST_REPO) + + // Verify all worktree paths are unique + const paths = new Set(worktrees.map((w) => w.path)) + expect(paths.size).toBe(9) + + // Verify all worktree branches exist in git + const branches = listBranches() + for (const wt of worktrees) { + expect(branches.some((b) => b === wt.branch)).toBe(true) + } + + // Cleanup + for (const sid of sessionIds.slice(1)) { + await mgr.discard(sid) + } + }, 60000) + + it('rapid acquire-discard cycles (10x) leave no orphans', async () => { + const projectId = 'stress-proj-2' + + for (let i = 0; i < 10; i++) { + const primary = `cycle-primary-${i}` + const secondary = `cycle-secondary-${i}` + + const r1 = await mgr.acquire(projectId, primary) + expect(r1.isolated).toBe(false) + + const r2 = await mgr.acquire(projectId, secondary) + expect(r2.isolated).toBe(true) + + await mgr.discard(secondary) + mgr.releasePrimary(projectId, primary) + } + + // No agentdeck branches should remain + const branches = listBranches() + expect(branches).toHaveLength(0) + + // No worktree dirs should remain (only the main worktree) + const worktrees = listWorktrees() + expect(worktrees).toHaveLength(1) // just the main repo + }, 120000) + + it('inspect detects real uncommitted changes', async () => { + const projectId = 'stress-proj-3' + + const r1 = await mgr.acquire(projectId, 'inspect-primary') + expect(r1.isolated).toBe(false) + + const r2 = await mgr.acquire(projectId, 'inspect-secondary') + expect(r2.isolated).toBe(true) + + // Create a file in the worktree + wslExec(`touch ${r2.path}/stress-test-file.txt`) + + const inspection = await mgr.inspect('inspect-secondary') + expect(inspection.hasChanges).toBe(true) + expect(inspection.branch).toBeDefined() + + // Cleanup + await mgr.discard('inspect-secondary') + mgr.releasePrimary(projectId, 'inspect-primary') + }, 30000) + + it('inspect detects committed-but-unmerged work', async () => { + const projectId = 'stress-proj-4' + + await mgr.acquire(projectId, 'commit-primary') + const r2 = await mgr.acquire(projectId, 'commit-secondary') + expect(r2.isolated).toBe(true) + + // Commit a file in the worktree + wslExec( + `cd ${r2.path} && echo "stress" > stress-commit.txt && git add . && git commit -m "stress test commit"`, + ) + + const inspection = await mgr.inspect('commit-secondary') + expect(inspection.hasUnmerged).toBe(true) + + // Cleanup + await mgr.discard('commit-secondary') + mgr.releasePrimary(projectId, 'commit-primary') + }, 30000) + + it('keep preserves branch but removes worktree dir', async () => { + const projectId = 'stress-proj-5' + + await mgr.acquire(projectId, 'keep-primary') + const r2 = await mgr.acquire(projectId, 'keep-secondary') + expect(r2.isolated).toBe(true) + + // Commit something so the branch has content + wslExec(`cd ${r2.path} && echo "keep me" > kept.txt && git add . && git commit -m "keep test"`) + + await mgr.keep('keep-secondary') + + // Worktree dir should be removed + const dirExists = wslExec(`test -d ${r2.path} && echo yes || echo no`) + expect(dirExists).toBe('no') + + // Branch should still exist + const branches = listBranches() + expect(branches.some((b) => b === r2.branch)).toBe(true) + + // Cleanup + mgr.releasePrimary(projectId, 'keep-primary') + wslExec(`git -C ${TEST_REPO} branch -D '${r2.branch}'`) + }, 30000) + + it('MAX_WORKTREES cap prevents unbounded creation', async () => { + const projectId = 'stress-proj-6' + await mgr.acquire(projectId, 'cap-primary') + + // Create 20 worktrees (hitting the cap at 20 entries) + const results: Array<{ isolated: boolean }> = [] + for (let i = 0; i < 20; i++) { + try { + const r = await mgr.acquire(projectId, `cap-session-${i}`) + results.push(r) + } catch { + results.push({ isolated: false }) // cap reached + break + } + } + + // Should have hit the cap before creating 20 + const isolated = results.filter((r) => r.isolated) + expect(isolated.length).toBeLessThanOrEqual(20) + + // Cleanup all + for (let i = 0; i < isolated.length; i++) { + await mgr.discard(`cap-session-${i}`) + } + mgr.releasePrimary(projectId, 'cap-primary') + }, 120000) + + it('releasePrimary allows new session to claim primary', async () => { + const projectId = 'stress-proj-7' + + const r1 = await mgr.acquire(projectId, 'rp-session-1') + expect(r1.isolated).toBe(false) // primary + + mgr.releasePrimary(projectId, 'rp-session-1') + + const r2 = await mgr.acquire(projectId, 'rp-session-2') + expect(r2.isolated).toBe(false) // should get primary, not worktree + expect(r2.path).toBe(TEST_REPO) + + mgr.releasePrimary(projectId, 'rp-session-2') + }, 15000) + + it('non-git project returns original path without error', async () => { + const nonGitMgr = createWorktreeManager( + git, + () => '/home/rooty/agentdeck-test/non-git-project', + REGISTRY_DIR, + WSL_WORKTREE_DIR, + ) + + const r1 = await nonGitMgr.acquire('non-git', 'ng-session-1') + expect(r1.isolated).toBe(false) + expect(r1.path).toBe('/home/rooty/agentdeck-test/non-git-project') + + const r2 = await nonGitMgr.acquire('non-git', 'ng-session-2') + expect(r2.isolated).toBe(false) // can't isolate non-git + }, 15000) +}) diff --git a/src/main/git-port.test.ts b/src/main/git-port.test.ts new file mode 100644 index 0000000..7d56108 --- /dev/null +++ b/src/main/git-port.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect } from 'vitest' +import { + parseGitVersion, + parseStatusPorcelain, + parseAheadCount, + hashId, + makeBranchName, +} from './git-port' + +describe('parseGitVersion', () => { + it('parses normal git version output', () => { + expect(parseGitVersion('git version 2.43.0\n')).toEqual({ major: 2, minor: 43 }) + }) + + it('parses git version with platform suffix (windows extra text)', () => { + expect(parseGitVersion('git version 2.39.1.windows.1\n')).toEqual({ major: 2, minor: 39 }) + }) + + it('parses git version with additional build metadata', () => { + expect(parseGitVersion('git version 2.40.0 (Apple Git-128)\n')).toEqual({ major: 2, minor: 40 }) + }) + + it('throws on unparseable output', () => { + expect(() => parseGitVersion('not a git version string')).toThrow() + }) + + it('throws on empty string', () => { + expect(() => parseGitVersion('')).toThrow() + }) +}) + +describe('parseStatusPorcelain', () => { + it('returns hasChanges=true for non-empty output', () => { + expect(parseStatusPorcelain(' M src/main.ts\n')).toEqual({ hasChanges: true }) + }) + + it('returns hasChanges=true for multiple changed files', () => { + expect(parseStatusPorcelain(' M file1.ts\n?? file2.ts\n')).toEqual({ hasChanges: true }) + }) + + it('returns hasChanges=false for empty output', () => { + expect(parseStatusPorcelain('')).toEqual({ hasChanges: false }) + }) + + it('returns hasChanges=false for whitespace-only output', () => { + expect(parseStatusPorcelain(' \n \n')).toEqual({ hasChanges: false }) + }) +}) + +describe('parseAheadCount', () => { + it('parses a normal number', () => { + expect(parseAheadCount('3\n')).toBe(3) + }) + + it('parses zero', () => { + expect(parseAheadCount('0\n')).toBe(0) + }) + + it('returns 0 for empty string', () => { + expect(parseAheadCount('')).toBe(0) + }) + + it('returns 0 for non-numeric output', () => { + expect(parseAheadCount('not a number')).toBe(0) + }) + + it('returns 0 for whitespace-only output', () => { + expect(parseAheadCount(' ')).toBe(0) + }) +}) + +describe('hashId', () => { + it('returns an 8-character hex string', () => { + const result = hashId('some-id') + expect(result).toHaveLength(8) + expect(result).toMatch(/^[0-9a-f]{8}$/) + }) + + it('returns a deterministic result for the same input', () => { + expect(hashId('my-project')).toBe(hashId('my-project')) + }) + + it('returns different results for different inputs', () => { + expect(hashId('project-a')).not.toBe(hashId('project-b')) + }) +}) + +describe('makeBranchName', () => { + it('returns the correct format without suffix', () => { + const branch = makeBranchName('project-id', 'session-id') + expect(branch).toMatch(/^agentdeck\/p-[0-9a-f]{8}\/s-[0-9a-f]{8}$/) + }) + + it('returns the correct format with suffix', () => { + const branch = makeBranchName('project-id', 'session-id', 2) + expect(branch).toMatch(/^agentdeck\/p-[0-9a-f]{8}\/s-[0-9a-f]{8}-2$/) + }) + + it('is deterministic for the same project and session', () => { + expect(makeBranchName('proj', 'sess')).toBe(makeBranchName('proj', 'sess')) + }) + + it('produces different branch names for different session IDs', () => { + expect(makeBranchName('proj', 'sess-a')).not.toBe(makeBranchName('proj', 'sess-b')) + }) + + it('produces different branch names for different project IDs', () => { + expect(makeBranchName('proj-a', 'sess')).not.toBe(makeBranchName('proj-b', 'sess')) + }) +}) diff --git a/src/main/git-port.ts b/src/main/git-port.ts new file mode 100644 index 0000000..2ce9055 --- /dev/null +++ b/src/main/git-port.ts @@ -0,0 +1,190 @@ +import { execFile, type ExecFileOptions } from 'child_process' +import { createHash } from 'crypto' +import { createLogger } from './logger' +import { toWslPath } from './wsl-utils' + +const log = createLogger('git-port') + +const EXEC_TIMEOUT_MS = 15_000 + +// ─── Interface ──────────────────────────────────────────────────────────────── + +export interface GitPort { + isGitRepo(path: string): Promise + getRepoRoot(path: string): Promise + addWorktree(repoRoot: string, worktreePath: string, branch: string): Promise + removeWorktree(repoRoot: string, worktreePath: string): Promise + pruneWorktrees(repoRoot: string): Promise + deleteBranch(repoRoot: string, branch: string): Promise + status(path: string): Promise<{ hasChanges: boolean }> + aheadCount(path: string, baseOid: string): Promise + currentOid(path: string): Promise + gitVersion(): Promise<{ major: number; minor: number }> +} + +// ─── Parser functions (exported for unit testing) ───────────────────────────── + +/** + * Extracts major/minor from "git version 2.43.0" (or platform variant). + * Throws if the output doesn't contain a recognisable version. + */ +export function parseGitVersion(output: string): { major: number; minor: number } { + const match = output.match(/git version (\d+)\.(\d+)/) + if (!match || !match[1] || !match[2]) { + throw new Error(`Cannot parse git version from output: ${JSON.stringify(output)}`) + } + return { major: parseInt(match[1], 10), minor: parseInt(match[2], 10) } +} + +/** + * Non-empty trimmed porcelain output means there are changes. + */ +export function parseStatusPorcelain(output: string): { hasChanges: boolean } { + return { hasChanges: output.trim().length > 0 } +} + +/** + * Parses the integer output of `git rev-list --count`. + * Returns 0 for empty or non-numeric output. + */ +export function parseAheadCount(output: string): number { + const n = parseInt(output.trim(), 10) + return Number.isFinite(n) ? n : 0 +} + +// ─── Helpers (exported) ─────────────────────────────────────────────────────── + +/** + * Returns the first 8 hex characters of the SHA-256 digest of `id`. + */ +export function hashId(id: string): string { + return createHash('sha256').update(id).digest('hex').slice(0, 8) +} + +/** + * Builds a branch name in the form `agentdeck/p-<8hex>/s-<8hex>[-N]`. + */ +export function makeBranchName(projectId: string, sessionId: string, suffix?: number): string { + const p = hashId(projectId) + const s = hashId(sessionId) + const base = `agentdeck/p-${p}/s-${s}` + return suffix !== undefined ? `${base}-${suffix}` : base +} + +// ─── WSL implementation ─────────────────────────────────────────────────────── + +/** + * Runs `wsl.exe git ` in the given working directory. + * Resolves with trimmed stdout; rejects with a descriptive Error on non-zero exit. + */ +function wslExec(args: string[], cwd?: string): Promise { + return new Promise((resolve, reject) => { + const opts: ExecFileOptions & { encoding: 'utf8' } = { + encoding: 'utf8', + timeout: EXEC_TIMEOUT_MS, + ...(cwd !== undefined ? { cwd } : {}), + } + execFile('wsl.exe', ['git', ...args], opts, (err, stdout, stderr) => { + if (err) { + const detail = stderr.trim() || String(err) + log.debug('wsl git failed', { args, detail }) + reject(new Error(`wsl git ${args[0] ?? ''}: ${detail}`)) + return + } + resolve(stdout.trim()) + }) + }) +} + +/** Ensure a path is in WSL format (convert Windows paths like C:\... to /mnt/c/...) */ +function ensureWslPath(p: string): string { + // Already a WSL/Unix path + if (p.startsWith('/')) return p + return toWslPath(p) +} + +/** + * Creates a GitPort that shells out to `wsl.exe git ...` for all operations. + */ +export function createWslGitPort(): GitPort { + return { + async isGitRepo(path: string): Promise { + try { + await wslExec(['-C', ensureWslPath(path), 'rev-parse', '--git-dir']) + return true + } catch { + return false + } + }, + + async getRepoRoot(path: string): Promise { + return wslExec(['-C', ensureWslPath(path), 'rev-parse', '--show-toplevel']) + }, + + async addWorktree(repoRoot: string, worktreePath: string, branch: string): Promise { + await wslExec([ + '-C', + ensureWslPath(repoRoot), + 'worktree', + 'add', + '-b', + branch, + ensureWslPath(worktreePath), + ]) + log.info('worktree added', { repoRoot, worktreePath, branch }) + }, + + async removeWorktree(repoRoot: string, worktreePath: string): Promise { + await wslExec([ + '-C', + ensureWslPath(repoRoot), + 'worktree', + 'remove', + '--force', + ensureWslPath(worktreePath), + ]) + log.info('worktree removed', { repoRoot, worktreePath }) + }, + + async pruneWorktrees(repoRoot: string): Promise { + await wslExec(['-C', ensureWslPath(repoRoot), 'worktree', 'prune']) + }, + + async deleteBranch(repoRoot: string, branch: string): Promise { + await wslExec(['-C', ensureWslPath(repoRoot), 'branch', '-D', branch]) + log.info('branch deleted', { repoRoot, branch }) + }, + + async status(path: string): Promise<{ hasChanges: boolean }> { + const output = await wslExec([ + '-C', + ensureWslPath(path), + 'status', + '--porcelain=v2', + '-z', + '--untracked-files=normal', + ]) + return parseStatusPorcelain(output) + }, + + async aheadCount(path: string, baseOid: string): Promise { + const output = await wslExec([ + '-C', + ensureWslPath(path), + 'rev-list', + '--count', + `${baseOid}..HEAD`, + ]) + return parseAheadCount(output) + }, + + async currentOid(path: string): Promise { + return wslExec(['-C', ensureWslPath(path), 'rev-parse', 'HEAD']) + }, + + async gitVersion(): Promise<{ major: number; minor: number }> { + const output = await wslExec(['version']) + return parseGitVersion(output) + }, + } +} diff --git a/src/main/index.ts b/src/main/index.ts index 048b4c1..e862ef7 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -8,6 +8,8 @@ import { initLogger, createLogger, closeLogger } from './logger' import { seedWorkflows } from './workflow-seeds' import { createWorkflowEngine } from './workflow-engine' import type { WorkflowEngine } from './workflow-engine' +import { createWorktreeManager, type WorktreeManager } from './worktree-manager' +import { createWslGitPort } from './git-port' import { registerPtyHandlers, registerWindowHandlers, @@ -16,6 +18,7 @@ import { registerWorkflowHandlers, registerUtilHandlers, registerSkillHandlers, + registerWorktreeHandlers, } from './ipc' const log = createLogger('app') @@ -24,6 +27,7 @@ let mainWindow: BrowserWindow | null = null let ptyManager: PtyManager | null = null let workflowEngine: WorkflowEngine | null = null let appStore: AppStore | null = null +let worktreeManager: WorktreeManager | null = null // --- Crash cleanup handlers (REL-4) --- process.on('uncaughtException', (err) => { @@ -155,6 +159,7 @@ function registerIpcHandlers(store: AppStore): void { }, ) registerUtilHandlers() + registerWorktreeHandlers(() => worktreeManager) } app @@ -167,6 +172,45 @@ app seedTemplates(appStore) seedRoles(appStore) await seedWorkflows(appStore) + + const gitPort = createWslGitPort() + // Resolve WSL $HOME for worktree storage (can't use ~ — Node treats it literally) + let wslHome: string | null = null + try { + const { execFile: execFileCb } = await import('child_process') + wslHome = await new Promise((resolve, reject) => { + execFileCb( + 'wsl.exe', + ['bash', '-lc', 'echo $HOME'], + { timeout: 5000, encoding: 'utf-8' }, + (err, stdout) => { + if (err) reject(err) + else resolve(stdout.trim()) + }, + ) + }) + } catch (err) { + log.warn('Could not resolve WSL $HOME — worktree isolation disabled', { + err: String(err), + }) + } + + const registryDir = join(app.getPath('userData'), 'worktree-registry') + if (wslHome) { + const wslWorktreeDir = `${wslHome}/.agentdeck/worktrees` + worktreeManager = createWorktreeManager( + gitPort, + (id) => { + const projects = appStore?.get('projects') ?? [] + return projects.find((p) => p.id === id)?.path + }, + registryDir, + wslWorktreeDir, + ) + } else { + log.warn('Worktree manager not created — WSL $HOME unknown') + } + registerIpcHandlers(appStore) createWindow() @@ -180,6 +224,11 @@ app }) } + // Prune orphaned worktrees from previous sessions (fire-and-forget). + worktreeManager?.pruneOrphans().catch((err: unknown) => { + log.warn('Worktree prune failed', { err: String(err) }) + }) + // Check WSL2 availability asynchronously after the window is shown, // then push the result to the renderer via IPC. if (mainWindow) { diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 2d35a83..b3afec0 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -13,3 +13,4 @@ export { registerProjectHandlers } from './ipc-projects' export { registerWorkflowHandlers } from './ipc-workflows' export { registerUtilHandlers } from './ipc-utils' export { registerSkillHandlers } from './ipc-skills' +export { registerWorktreeHandlers } from './ipc-worktree' diff --git a/src/main/ipc/ipc-worktree.ts b/src/main/ipc/ipc-worktree.ts new file mode 100644 index 0000000..969adb0 --- /dev/null +++ b/src/main/ipc/ipc-worktree.ts @@ -0,0 +1,55 @@ +import { ipcMain } from 'electron' +import type { WorktreeManager } from '../worktree-manager' + +const SAFE_ID_RE = /^[a-zA-Z0-9_-]+$/ + +/** + * Worktree IPC handlers: acquire, inspect, discard, keep. + * + * Uses a getter for worktreeManager because the instance is created after module load. + */ +export function registerWorktreeHandlers(getWorktreeManager: () => WorktreeManager | null): void { + ipcMain.handle('worktree:acquire', async (_, projectId: unknown, sessionId: unknown) => { + if (typeof projectId !== 'string' || !SAFE_ID_RE.test(projectId)) + throw new Error('Invalid projectId: must match /^[a-zA-Z0-9_-]+$/') + if (typeof sessionId !== 'string' || !SAFE_ID_RE.test(sessionId)) + throw new Error('Invalid sessionId: must match /^[a-zA-Z0-9_-]+$/') + const mgr = getWorktreeManager() + if (!mgr) throw new Error('WorktreeManager not initialized') + return mgr.acquire(projectId, sessionId) + }) + + ipcMain.handle('worktree:inspect', async (_, sessionId: unknown) => { + if (typeof sessionId !== 'string' || !sessionId) + throw new Error('Invalid sessionId: must be a non-empty string') + const mgr = getWorktreeManager() + if (!mgr) throw new Error('WorktreeManager not initialized') + return mgr.inspect(sessionId) + }) + + ipcMain.handle('worktree:discard', async (_, sessionId: unknown) => { + if (typeof sessionId !== 'string' || !sessionId) + throw new Error('Invalid sessionId: must be a non-empty string') + const mgr = getWorktreeManager() + if (!mgr) throw new Error('WorktreeManager not initialized') + return mgr.discard(sessionId) + }) + + ipcMain.handle('worktree:keep', async (_, sessionId: unknown) => { + if (typeof sessionId !== 'string' || !sessionId) + throw new Error('Invalid sessionId: must be a non-empty string') + const mgr = getWorktreeManager() + if (!mgr) throw new Error('WorktreeManager not initialized') + return mgr.keep(sessionId) + }) + + ipcMain.handle('worktree:releasePrimary', async (_, projectId: unknown, sessionId: unknown) => { + if (typeof projectId !== 'string' || !SAFE_ID_RE.test(projectId)) + throw new Error('Invalid projectId: must match /^[a-zA-Z0-9_-]+$/') + if (typeof sessionId !== 'string' || !SAFE_ID_RE.test(sessionId)) + throw new Error('Invalid sessionId: must match /^[a-zA-Z0-9_-]+$/') + const mgr = getWorktreeManager() + if (!mgr) throw new Error('WorktreeManager not initialized') + mgr.releasePrimary(projectId, sessionId) + }) +} diff --git a/src/main/worktree-manager.test.ts b/src/main/worktree-manager.test.ts new file mode 100644 index 0000000..cf3bc82 --- /dev/null +++ b/src/main/worktree-manager.test.ts @@ -0,0 +1,531 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import * as fs from 'fs' +import type { GitPort } from './git-port' +import { createWorktreeManager } from './worktree-manager' + +// ── fs mock ────────────────────────────────────────────────────────────────── + +// vi.hoisted runs before vi.mock hoisting, so fsStore is available in the factory +const { fsStore } = vi.hoisted(() => { + const fsStore = new Map() + return { fsStore } +}) + +vi.mock('fs', () => { + return { + readFileSync: vi.fn((filepath: string) => { + const data = fsStore.get(filepath) + if (data === undefined) { + const err = new Error(`ENOENT: no such file`) as NodeJS.ErrnoException + err.code = 'ENOENT' + throw err + } + return data + }), + writeFileSync: vi.fn((filepath: string, data: string) => { + fsStore.set(filepath, data) + }), + renameSync: vi.fn((src: string, dest: string) => { + const data = fsStore.get(src) + if (data !== undefined) { + fsStore.delete(src) + fsStore.set(dest, data) + } + }), + mkdirSync: vi.fn(), + } +}) + +// ── logger mock ────────────────────────────────────────────────────────────── + +vi.mock('./logger', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})) + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function createMockGit(overrides: Partial = {}): GitPort { + return { + isGitRepo: vi.fn(async () => true), + getRepoRoot: vi.fn(async (p: string) => p), + addWorktree: vi.fn(async () => {}), + removeWorktree: vi.fn(async () => {}), + pruneWorktrees: vi.fn(async () => {}), + deleteBranch: vi.fn(async () => {}), + status: vi.fn(async () => ({ hasChanges: false })), + aheadCount: vi.fn(async () => 0), + currentOid: vi.fn(async () => 'abc123def456abc123def456abc123def456abcd'), + gitVersion: vi.fn(async () => ({ major: 2, minor: 43 })), + ...overrides, + } +} + +const REGISTRY_DIR = '/tmp/test-worktrees' + +function createLookup(map: Record): (projectId: string) => string | undefined { + return (projectId: string) => map[projectId] +} + +describe('WorktreeManager — acquire', () => { + beforeEach(() => { + fsStore.clear() + vi.mocked(fs.readFileSync).mockClear() + vi.mocked(fs.writeFileSync).mockClear() + vi.mocked(fs.renameSync).mockClear() + vi.mocked(fs.mkdirSync).mockClear() + }) + + it('first session gets original path (primary)', async () => { + const git = createMockGit() + const lookup = createLookup({ proj1: '/home/user/project-a' }) + const mgr = createWorktreeManager(git, lookup, REGISTRY_DIR) + + const result = await mgr.acquire('proj1', 'sess-1') + + expect(result.path).toBe('/home/user/project-a') + expect(result.isolated).toBe(false) + expect(result.branch).toBeUndefined() + }) + + it('second session on same project gets worktree', async () => { + const git = createMockGit() + const lookup = createLookup({ proj1: '/home/user/project-a' }) + const mgr = createWorktreeManager(git, lookup, REGISTRY_DIR) + + // First session claims primary + const r1 = await mgr.acquire('proj1', 'sess-1') + expect(r1.isolated).toBe(false) + + // Second session gets isolated worktree + const r2 = await mgr.acquire('proj1', 'sess-2') + expect(r2.isolated).toBe(true) + expect(r2.path).toContain('proj1') + expect(r2.path).toContain('sess-2') + expect(r2.branch).toBeDefined() + expect(r2.branch!.startsWith('agentdeck/')).toBe(true) + expect(git.addWorktree).toHaveBeenCalledTimes(1) + }) + + it('idempotent: same sessionId returns same result', async () => { + const git = createMockGit() + const lookup = createLookup({ proj1: '/home/user/project-a' }) + const mgr = createWorktreeManager(git, lookup, REGISTRY_DIR) + + // Primary + await mgr.acquire('proj1', 'sess-1') + // Worktree session + const first = await mgr.acquire('proj1', 'sess-2') + // Same session again — should not call addWorktree again + const second = await mgr.acquire('proj1', 'sess-2') + + expect(second.path).toBe(first.path) + expect(second.branch).toBe(first.branch) + expect(second.isolated).toBe(true) + // Only one addWorktree call (from the first acquire of sess-2) + expect(git.addWorktree).toHaveBeenCalledTimes(1) + }) + + it('non-git repo returns original path, isolated: false', async () => { + const git = createMockGit({ + isGitRepo: vi.fn(async () => false), + }) + const lookup = createLookup({ proj1: '/home/user/plain-dir' }) + const mgr = createWorktreeManager(git, lookup, REGISTRY_DIR) + + const result = await mgr.acquire('proj1', 'sess-1') + + expect(result.path).toBe('/home/user/plain-dir') + expect(result.isolated).toBe(false) + expect(git.addWorktree).not.toHaveBeenCalled() + }) + + it('git version < 2.17 throws for non-primary', async () => { + const git = createMockGit({ + gitVersion: vi.fn(async () => ({ major: 2, minor: 15 })), + }) + const lookup = createLookup({ proj1: '/home/user/project-a' }) + const mgr = createWorktreeManager(git, lookup, REGISTRY_DIR) + + // Primary succeeds (no version check needed) + await mgr.acquire('proj1', 'sess-1') + + // Non-primary needs worktree — should fail on version check + await expect(mgr.acquire('proj1', 'sess-2')).rejects.toThrow( + 'Git 2.17+ required for worktree isolation', + ) + }) + + it('fails closed when worktree add fails', async () => { + const git = createMockGit({ + addWorktree: vi.fn(async () => { + throw new Error('fatal: worktree path already exists') + }), + }) + const lookup = createLookup({ proj1: '/home/user/project-a' }) + const mgr = createWorktreeManager(git, lookup, REGISTRY_DIR) + + // Primary + await mgr.acquire('proj1', 'sess-1') + + // All retry attempts fail — should propagate error + await expect(mgr.acquire('proj1', 'sess-2')).rejects.toThrow( + /Failed to create worktree after 3 attempts/, + ) + }) + + it('throws when projectPath cannot be resolved', async () => { + const git = createMockGit() + const lookup = createLookup({}) // empty — no projects + const mgr = createWorktreeManager(git, lookup, REGISTRY_DIR) + + await expect(mgr.acquire('nonexistent', 'sess-1')).rejects.toThrow( + 'Cannot resolve project path for projectId: nonexistent', + ) + }) + + it('serializes concurrent acquires (Promise.all, exactly 1 primary)', async () => { + const git = createMockGit() + const lookup = createLookup({ proj1: '/home/user/project-a' }) + const mgr = createWorktreeManager(git, lookup, REGISTRY_DIR) + + // Fire multiple acquires concurrently for the same project + const results = await Promise.all([ + mgr.acquire('proj1', 'sess-1'), + mgr.acquire('proj1', 'sess-2'), + mgr.acquire('proj1', 'sess-3'), + ]) + + // Exactly one should be primary (isolated: false) + const primaries = results.filter((r) => !r.isolated) + const worktrees = results.filter((r) => r.isolated) + + expect(primaries).toHaveLength(1) + expect(worktrees).toHaveLength(2) + expect(primaries[0]!.path).toBe('/home/user/project-a') + + // Each worktree should have a unique path + const wtPaths = worktrees.map((r) => r.path) + expect(new Set(wtPaths).size).toBe(2) + }) + + it('cross-project acquires are concurrent (both get primary)', async () => { + const git = createMockGit() + const lookup = createLookup({ + proj1: '/home/user/project-a', + proj2: '/home/user/project-b', + }) + const mgr = createWorktreeManager(git, lookup, REGISTRY_DIR) + + // Fire acquires for different projects concurrently + const [r1, r2] = await Promise.all([ + mgr.acquire('proj1', 'sess-1'), + mgr.acquire('proj2', 'sess-2'), + ]) + + // Both should be primaries (no worktree needed) + expect(r1.isolated).toBe(false) + expect(r1.path).toBe('/home/user/project-a') + expect(r2.isolated).toBe(false) + expect(r2.path).toBe('/home/user/project-b') + + // No worktrees created + expect(git.addWorktree).not.toHaveBeenCalled() + }) +}) + +// ── Helper to set up a primary + worktree session ──────────────────────────── + +async function setupWorktreeSession( + git: ReturnType, + projectPath: string = '/home/user/project-a', +): Promise<{ + mgr: ReturnType + primaryId: string + worktreeId: string +}> { + const lookup = createLookup({ proj1: projectPath }) + const mgr = createWorktreeManager(git, lookup, REGISTRY_DIR) + + // Session 1 gets primary + await mgr.acquire('proj1', 'sess-primary') + // Session 2 gets worktree + await mgr.acquire('proj1', 'sess-worktree') + + return { mgr, primaryId: 'sess-primary', worktreeId: 'sess-worktree' } +} + +// ── inspect ─────────────────────────────────────────────────────────────────── + +describe('WorktreeManager — inspect', () => { + beforeEach(() => { + fsStore.clear() + }) + + it('detects uncommitted changes', async () => { + const git = createMockGit({ + status: vi.fn(async () => ({ hasChanges: true })), + aheadCount: vi.fn(async () => 0), + }) + const { mgr, worktreeId } = await setupWorktreeSession(git) + + const result = await mgr.inspect(worktreeId) + + expect(result.hasChanges).toBe(true) + expect(result.hasUnmerged).toBe(false) + expect(result.branch).toBeDefined() + expect(result.branch.startsWith('agentdeck/')).toBe(true) + }) + + it('detects unmerged commits', async () => { + const git = createMockGit({ + status: vi.fn(async () => ({ hasChanges: false })), + aheadCount: vi.fn(async () => 3), + }) + const { mgr, worktreeId } = await setupWorktreeSession(git) + + const result = await mgr.inspect(worktreeId) + + expect(result.hasChanges).toBe(false) + expect(result.hasUnmerged).toBe(true) + expect(git.aheadCount).toHaveBeenCalledWith( + expect.stringContaining('sess-worktree'), + expect.any(String), + ) + }) + + it('reports clean when no changes and no unmerged commits', async () => { + const git = createMockGit({ + status: vi.fn(async () => ({ hasChanges: false })), + aheadCount: vi.fn(async () => 0), + }) + const { mgr, worktreeId } = await setupWorktreeSession(git) + + const result = await mgr.inspect(worktreeId) + + expect(result.hasChanges).toBe(false) + expect(result.hasUnmerged).toBe(false) + }) + + it('throws for unknown sessionId', async () => { + const git = createMockGit() + const lookup = createLookup({ proj1: '/home/user/project-a' }) + const mgr = createWorktreeManager(git, lookup, REGISTRY_DIR) + + await expect(mgr.inspect('nonexistent-session')).rejects.toThrow( + 'No worktree entry found for sessionId: nonexistent-session', + ) + }) +}) + +// ── discard ─────────────────────────────────────────────────────────────────── + +describe('WorktreeManager — discard', () => { + beforeEach(() => { + fsStore.clear() + }) + + it('removes worktree, deletes branch, and evicts registry entry', async () => { + const git = createMockGit() + const { mgr, worktreeId } = await setupWorktreeSession(git) + + // Verify entry exists before discard + await expect(mgr.inspect(worktreeId)).resolves.toBeDefined() + + await mgr.discard(worktreeId) + + expect(git.removeWorktree).toHaveBeenCalledTimes(1) + expect(git.deleteBranch).toHaveBeenCalledTimes(1) + + // Entry should be gone — inspect should throw + await expect(mgr.inspect(worktreeId)).rejects.toThrow( + `No worktree entry found for sessionId: ${worktreeId}`, + ) + }) + + it('marks pendingCleanup when removeWorktree rejects', async () => { + const git = createMockGit({ + removeWorktree: vi.fn(async () => { + throw new Error('fatal: not a git worktree') + }), + }) + const { mgr, worktreeId } = await setupWorktreeSession(git) + + // discard should NOT throw — it degrades gracefully + await expect(mgr.discard(worktreeId)).resolves.toBeUndefined() + + // deleteBranch should NOT have been called — discard returned early + expect(git.deleteBranch).not.toHaveBeenCalled() + + // Entry is still in the registry (pendingCleanup=true), so inspect still works + const result = await mgr.inspect(worktreeId) + expect(result).toBeDefined() + expect(result.branch).toBeDefined() + }) + + it('is a no-op for unknown sessionId', async () => { + const git = createMockGit() + const lookup = createLookup({ proj1: '/home/user/project-a' }) + const mgr = createWorktreeManager(git, lookup, REGISTRY_DIR) + + // Should resolve without throwing + await expect(mgr.discard('nonexistent-session')).resolves.toBeUndefined() + + expect(git.removeWorktree).not.toHaveBeenCalled() + expect(git.deleteBranch).not.toHaveBeenCalled() + }) +}) + +// ── keep ────────────────────────────────────────────────────────────────────── + +describe('WorktreeManager — keep', () => { + beforeEach(() => { + fsStore.clear() + }) + + it('marks the entry as kept so pruneOrphans skips it', async () => { + const git = createMockGit() + const { mgr, worktreeId } = await setupWorktreeSession(git) + + await mgr.keep(worktreeId) + + // Even if the entry is old, pruneOrphans should skip it + // Simulate an old lastUsed by discarding and re-checking prune count + // pruneOrphans returns 0 — kept entries are never pruned + const pruned = await mgr.pruneOrphans() + expect(pruned).toBe(0) + + // The entry is still inspectable + const result = await mgr.inspect(worktreeId) + expect(result).toBeDefined() + }) + + it('throws for unknown sessionId', async () => { + const git = createMockGit() + const lookup = createLookup({ proj1: '/home/user/project-a' }) + const mgr = createWorktreeManager(git, lookup, REGISTRY_DIR) + + await expect(mgr.keep('nonexistent-session')).rejects.toThrow( + 'No worktree entry found for sessionId: nonexistent-session', + ) + }) +}) + +// ── pruneOrphans ────────────────────────────────────────────────────────────── + +describe('WorktreeManager — pruneOrphans', () => { + beforeEach(() => { + fsStore.clear() + }) + + it('returns 0 when there are no stale entries', async () => { + const git = createMockGit() + const { mgr } = await setupWorktreeSession(git) + + // Fresh worktree — lastUsed is now, not old enough to prune + const pruned = await mgr.pruneOrphans() + expect(pruned).toBe(0) + }) + + it('returns 0 when the only worktree entry is kept', async () => { + const git = createMockGit() + const { mgr, worktreeId } = await setupWorktreeSession(git) + + await mgr.keep(worktreeId) + // keep() removes the worktree directory (but preserves the branch) + expect(git.removeWorktree).toHaveBeenCalledTimes(1) + vi.mocked(git.removeWorktree).mockClear() + + const pruned = await mgr.pruneOrphans() + expect(pruned).toBe(0) + // pruneOrphans should NOT have called removeWorktree for a kept entry + expect(git.removeWorktree).not.toHaveBeenCalled() + }) + + it('retries and removes a pendingCleanup entry on success', async () => { + // First removeWorktree call fails (simulating original discard failure), + // second call succeeds (pruneOrphans retry). + let callCount = 0 + const git = createMockGit({ + removeWorktree: vi.fn(async () => { + callCount++ + if (callCount === 1) throw new Error('transient error') + // second call succeeds + }), + }) + const { mgr, worktreeId } = await setupWorktreeSession(git) + + // Trigger the initial failure to mark pendingCleanup + await mgr.discard(worktreeId) + expect(callCount).toBe(1) + + // pruneOrphans should retry and succeed + const pruned = await mgr.pruneOrphans() + expect(pruned).toBe(1) + expect(callCount).toBe(2) + + // Entry should be gone + await expect(mgr.inspect(worktreeId)).rejects.toThrow( + `No worktree entry found for sessionId: ${worktreeId}`, + ) + }) +}) + +// ── releasePrimary ─────────────────────────────────────────────────────────── + +describe('WorktreeManager — releasePrimary', () => { + beforeEach(() => { + fsStore.clear() + }) + + it('after releasePrimary, next acquire for same project gets primary (not worktree)', async () => { + const git = createMockGit() + const lookup = createLookup({ proj1: '/home/user/project-a' }) + const mgr = createWorktreeManager(git, lookup, REGISTRY_DIR) + + // sess-1 claims primary + const r1 = await mgr.acquire('proj1', 'sess-1') + expect(r1.isolated).toBe(false) + + // Release primary for sess-1 + mgr.releasePrimary('proj1', 'sess-1') + + // sess-2 should now get primary (not a worktree) + const r2 = await mgr.acquire('proj1', 'sess-2') + expect(r2.isolated).toBe(false) + expect(r2.path).toBe('/home/user/project-a') + + // No worktrees should have been created + expect(git.addWorktree).not.toHaveBeenCalled() + }) + + it('is a no-op when sessionId does not match current primary', async () => { + const git = createMockGit() + const lookup = createLookup({ proj1: '/home/user/project-a' }) + const mgr = createWorktreeManager(git, lookup, REGISTRY_DIR) + + // sess-1 claims primary + await mgr.acquire('proj1', 'sess-1') + + // Release with wrong sessionId — should be a no-op + mgr.releasePrimary('proj1', 'sess-wrong') + + // sess-2 should get a worktree (primary is still held by sess-1) + const r2 = await mgr.acquire('proj1', 'sess-2') + expect(r2.isolated).toBe(true) + expect(git.addWorktree).toHaveBeenCalledTimes(1) + }) + + it('is a no-op for unknown projectId', () => { + const git = createMockGit() + const lookup = createLookup({}) + const mgr = createWorktreeManager(git, lookup, REGISTRY_DIR) + + // Should not throw + expect(() => mgr.releasePrimary('nonexistent', 'sess-1')).not.toThrow() + }) +}) diff --git a/src/main/worktree-manager.ts b/src/main/worktree-manager.ts new file mode 100644 index 0000000..f71b2a0 --- /dev/null +++ b/src/main/worktree-manager.ts @@ -0,0 +1,448 @@ +import * as fs from 'fs' +import * as path from 'path' +import type { GitPort } from './git-port' +import { makeBranchName } from './git-port' +import { createLogger } from './logger' + +const log = createLogger('worktree-manager') + +const MAX_BRANCH_RETRIES = 3 +const MAX_WORKTREES = 20 +const ORPHAN_AGE_MS = 24 * 60 * 60 * 1000 // 24 hours + +const VALID_OID_RE = /^[0-9a-f]{40}$/ +function isValidOid(oid: string): boolean { + return VALID_OID_RE.test(oid) +} + +// ─── Public types ──────────────────────────────────────────────────────────── + +export interface WorktreeResult { + path: string + isolated: boolean + branch?: string | undefined +} + +export interface WorktreeInspection { + hasChanges: boolean + hasUnmerged: boolean + branch: string +} + +export interface WorktreeEntry { + projectId: string + sessionId: string + path: string + branch: string + repoRoot: string + baseOid: string + createdAt: number + lastUsed: number + kept: boolean + pendingCleanup: boolean +} + +export interface WorktreeManager { + acquire(projectId: string, sessionId: string): Promise + inspect(sessionId: string): Promise + discard(sessionId: string): Promise + keep(sessionId: string): Promise + releasePrimary(projectId: string, sessionId: string): void + pruneOrphans(): Promise +} + +// ─── Registry persistence ──────────────────────────────────────────────────── + +interface RegistryData { + entries: WorktreeEntry[] +} + +function registryPath(registryDir: string): string { + return path.join(registryDir, 'registry.json') +} + +function loadRegistry(registryDir: string): WorktreeEntry[] { + const file = registryPath(registryDir) + try { + const raw = fs.readFileSync(file, 'utf-8') + const data = JSON.parse(raw) as RegistryData + if (!Array.isArray(data.entries)) { + log.warn('Registry file malformed — resetting', { file }) + return [] + } + const valid = data.entries.filter((e) => isValidOid(e.baseOid)) + if (valid.length < data.entries.length) { + log.warn('Filtered registry entries with invalid baseOid', { + file, + removed: data.entries.length - valid.length, + }) + } + return valid + } catch (err) { + const code = (err as NodeJS.ErrnoException).code + if (code !== 'ENOENT') { + log.warn('Failed to read registry — resetting', { file, err: String(err) }) + } + return [] + } +} + +function saveRegistry(registryDir: string, entries: WorktreeEntry[]): void { + const file = registryPath(registryDir) + const dir = path.dirname(file) + fs.mkdirSync(dir, { recursive: true }) + + const data: RegistryData = { entries } + const json = JSON.stringify(data, null, 2) + const tmpFile = file + '.tmp' + fs.writeFileSync(tmpFile, json, 'utf-8') + fs.renameSync(tmpFile, file) +} + +// ─── Per-key mutex (promise-based, same pattern as project-store) ──────────── + +function createMutexMap(): { + serialized: (key: string, fn: () => Promise) => Promise +} { + const locks = new Map>() + + function serialized(key: string, fn: () => Promise): Promise { + const prev = locks.get(key) ?? Promise.resolve() + const p = prev.then( + () => fn(), + () => fn(), + ) + locks.set(key, p) + return p.finally(() => { + if (locks.get(key) === p) locks.delete(key) + }) + } + + return { serialized } +} + +// ─── Factory ───────────────────────────────────────────────────────────────── + +export function createWorktreeManager( + git: GitPort, + lookupProjectPath: (projectId: string) => string | undefined, + registryDir: string, + wslWorktreeDir?: string | undefined, +): WorktreeManager { + const worktreeBaseDir = wslWorktreeDir ?? registryDir + let entries: WorktreeEntry[] = loadRegistry(registryDir) + + // Primary tracking: projectId -> sessionId. NOT persisted. + // Reconstructed: sessions in the registry are worktree sessions, not primaries. + const primaries = new Map() + + const { serialized } = createMutexMap() + + function persistEntries(): void { + saveRegistry(registryDir, entries) + } + + function findEntry(sessionId: string): WorktreeEntry | undefined { + return entries.find((e) => e.sessionId === sessionId) + } + + function removeEntry(sessionId: string): void { + entries = entries.filter((e) => e.sessionId !== sessionId) + persistEntries() + } + + function updateEntry(sessionId: string, update: Partial): void { + const entry = findEntry(sessionId) + if (entry) { + Object.assign(entry, update) + persistEntries() + } + } + + // ── acquire ────────────────────────────────────────────────── + + async function acquire(projectId: string, sessionId: string): Promise { + const projectPath = lookupProjectPath(projectId) + if (projectPath === undefined) { + throw new Error(`Cannot resolve project path for projectId: ${projectId}`) + } + + return serialized(projectId, async () => { + // Idempotency: already in registry means this is a worktree session + const existing = findEntry(sessionId) + if (existing) { + existing.lastUsed = Date.now() + persistEntries() + return { path: existing.path, isolated: true, branch: existing.branch } + } + + // Already primary for this project + if (primaries.get(projectId) === sessionId) { + return { path: projectPath, isolated: false } + } + + // Not a git repo — return original path, no isolation + const isRepo = await git.isGitRepo(projectPath) + if (!isRepo) { + return { path: projectPath, isolated: false } + } + + // No primary yet — claim primary + if (!primaries.has(projectId)) { + primaries.set(projectId, sessionId) + return { path: projectPath, isolated: false } + } + + // Non-primary: need worktree — enforce cap first + if (entries.length >= MAX_WORKTREES) { + throw new Error(`Worktree limit reached (${MAX_WORKTREES}). Close existing sessions first.`) + } + + // Check git version + const ver = await git.gitVersion() + if (ver.major < 2 || (ver.major === 2 && ver.minor < 17)) { + throw new Error('Git 2.17+ required for worktree isolation') + } + + const repoRoot = await git.getRepoRoot(projectPath) + const baseOid = await git.currentOid(projectPath) + const worktreePath = path.posix.join(worktreeBaseDir, projectId, sessionId) + if (!worktreePath.startsWith(worktreeBaseDir + '/')) { + throw new Error('Worktree path escapes base directory') + } + + // Try creating worktree, retry with suffix on branch collision + let branch = '' + let created = false + for (let attempt = 0; attempt < MAX_BRANCH_RETRIES; attempt++) { + branch = makeBranchName(projectId, sessionId, attempt === 0 ? undefined : attempt) + try { + await git.addWorktree(repoRoot, worktreePath, branch) + created = true + break + } catch (err) { + if (attempt === MAX_BRANCH_RETRIES - 1) { + throw new Error( + `Failed to create worktree after ${MAX_BRANCH_RETRIES} attempts: ${String(err)}`, + ) + } + log.warn('Worktree add failed, retrying with suffix', { + attempt, + branch, + err: String(err), + }) + } + } + + if (!created) { + throw new Error('Failed to create worktree — all retry attempts exhausted') + } + + const now = Date.now() + const entry: WorktreeEntry = { + projectId, + sessionId, + path: worktreePath, + branch, + repoRoot, + baseOid, + createdAt: now, + lastUsed: now, + kept: false, + pendingCleanup: false, + } + entries.push(entry) + persistEntries() + + log.info('Worktree acquired', { projectId, sessionId, branch, worktreePath }) + return { path: worktreePath, isolated: true, branch } + }) + } + + // ── inspect ────────────────────────────────────────────────── + + async function inspect(sessionId: string): Promise { + const entry = findEntry(sessionId) + if (!entry) { + throw new Error(`No worktree entry found for sessionId: ${sessionId}`) + } + + const statusResult = await git.status(entry.path) + + if (!isValidOid(entry.baseOid)) { + // Can't reliably check ahead count — treat as dirty + return { hasChanges: statusResult.hasChanges, hasUnmerged: true, branch: entry.branch } + } + + const ahead = await git.aheadCount(entry.path, entry.baseOid) + + return { + hasChanges: statusResult.hasChanges, + hasUnmerged: ahead > 0, + branch: entry.branch, + } + } + + // ── discard ────────────────────────────────────────────────── + + async function discard(sessionId: string): Promise { + const entry = findEntry(sessionId) + if (!entry) { + log.warn('Discard called for unknown session', { sessionId }) + return + } + + try { + await git.removeWorktree(entry.repoRoot, entry.path) + } catch (err) { + log.error('Failed to remove worktree — marking pendingCleanup', { + sessionId, + err: String(err), + }) + updateEntry(sessionId, { pendingCleanup: true }) + return + } + + // Prune git's internal worktree list so the branch is no longer locked + try { + await git.pruneWorktrees(entry.repoRoot) + } catch { + // Best-effort — branch delete below may still succeed + } + + try { + await git.deleteBranch(entry.repoRoot, entry.branch) + } catch (err) { + log.warn('Failed to delete branch after worktree removal', { + sessionId, + branch: entry.branch, + err: String(err), + }) + } + + removeEntry(sessionId) + + // Release primary slot if this project has no more active sessions + if (primaries.get(entry.projectId) === sessionId) { + primaries.delete(entry.projectId) + } + + log.info('Worktree discarded', { sessionId, branch: entry.branch }) + } + + // ── keep ───────────────────────────────────────────────────── + + async function keep(sessionId: string): Promise { + const entry = findEntry(sessionId) + if (!entry) { + throw new Error(`No worktree entry found for sessionId: ${sessionId}`) + } + + // Remove worktree directory to free disk space. The branch persists in the + // repo's refs regardless of whether the worktree directory exists. + try { + await git.removeWorktree(entry.repoRoot, entry.path) + } catch (err) { + log.warn('Failed to remove worktree dir on keep', { + sessionId, + path: entry.path, + err: String(err), + }) + } + + updateEntry(sessionId, { kept: true, lastUsed: Date.now() }) + log.info('Worktree kept', { sessionId, branch: entry.branch }) + } + + // ── pruneOrphans ───────────────────────────────────────────── + + async function pruneOrphans(): Promise { + const now = Date.now() + let pruned = 0 + + // Work on a snapshot to avoid mutation issues during iteration + const snapshot = [...entries] + + for (const entry of snapshot) { + // Retry entries marked pendingCleanup + if (entry.pendingCleanup) { + try { + await git.removeWorktree(entry.repoRoot, entry.path) + await git.deleteBranch(entry.repoRoot, entry.branch).catch(() => { + // Best-effort branch delete + }) + removeEntry(entry.sessionId) + pruned++ + log.info('Pruned pendingCleanup worktree', { sessionId: entry.sessionId }) + continue + } catch { + log.warn('PendingCleanup retry failed, will try again later', { + sessionId: entry.sessionId, + }) + continue + } + } + + // Skip kept entries + if (entry.kept) continue + + // Skip entries not old enough + if (now - entry.lastUsed < ORPHAN_AGE_MS) continue + + // Check if branch has unpushed commits — skip dirty branches + if (!isValidOid(entry.baseOid)) { + // Can't reliably check ahead count — treat as dirty, skip + log.warn('Skipping prune of worktree with invalid baseOid', { + sessionId: entry.sessionId, + }) + continue + } + try { + const ahead = await git.aheadCount(entry.path, entry.baseOid) + if (ahead > 0) { + log.warn('Skipping prune of dirty worktree', { + sessionId: entry.sessionId, + ahead, + }) + continue + } + } catch { + // If we can't check, skip to be safe + log.warn('Cannot check ahead count for orphan — skipping', { + sessionId: entry.sessionId, + }) + continue + } + + // Clean and old enough — remove + try { + await git.removeWorktree(entry.repoRoot, entry.path) + await git.deleteBranch(entry.repoRoot, entry.branch).catch(() => { + // Best-effort + }) + removeEntry(entry.sessionId) + pruned++ + log.info('Pruned orphan worktree', { sessionId: entry.sessionId }) + } catch (err) { + log.warn('Failed to prune orphan — marking pendingCleanup', { + sessionId: entry.sessionId, + err: String(err), + }) + updateEntry(entry.sessionId, { pendingCleanup: true }) + } + } + + return pruned + } + + // ── releasePrimary ──────────────────────────────────────────── + + function releasePrimary(projectId: string, sessionId: string): void { + if (primaries.get(projectId) === sessionId) { + primaries.delete(projectId) + log.info('Primary slot released', { projectId, sessionId }) + } + } + + return { acquire, inspect, discard, keep, releasePrimary, pruneOrphans } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 5a4d87f..8dcccee 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -192,6 +192,31 @@ contextBridge.exposeInMainWorld('agentDeck', { return () => ipcRenderer.removeListener('security:encryption-unavailable', listener) }, }, + worktree: { + acquire: ( + projectId: string, + sessionId: string, + ): Promise<{ path: string; isolated: boolean; branch?: string }> => + ipcRenderer.invoke('worktree:acquire', projectId, sessionId) as Promise<{ + path: string + isolated: boolean + branch?: string + }>, + inspect: ( + sessionId: string, + ): Promise<{ hasChanges: boolean; hasUnmerged: boolean; branch: string }> => + ipcRenderer.invoke('worktree:inspect', sessionId) as Promise<{ + hasChanges: boolean + hasUnmerged: boolean + branch: string + }>, + discard: (sessionId: string): Promise => + ipcRenderer.invoke('worktree:discard', sessionId) as Promise, + keep: (sessionId: string): Promise => + ipcRenderer.invoke('worktree:keep', sessionId) as Promise, + releasePrimary: (projectId: string, sessionId: string): Promise => + ipcRenderer.invoke('worktree:releasePrimary', projectId, sessionId) as Promise, + }, onFileDrop: (cb: (wslPaths: string[]) => void) => { const listener = (_event: Electron.IpcRendererEvent, wslPaths: string[]): void => cb(wslPaths) ipcRenderer.on('file-dropped', listener) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index ee1bb94..3a21bf0 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -10,6 +10,7 @@ import { CommandPalette } from './components/CommandPalette/CommandPalette' import { AboutDialog } from './components/AboutDialog/AboutDialog' import { ShortcutsDialog } from './components/ShortcutsDialog/ShortcutsDialog' import { NotificationToast } from './components/NotificationToast/NotificationToast' +import { ConfirmDialog } from './components/shared/ConfirmDialog' import { HexGrid } from './components/shared/HexGrid' import { EnergyVein } from './components/shared/EnergyVein' import { AmbientGlow } from './components/shared/AmbientGlow' @@ -81,6 +82,12 @@ export function App(): React.JSX.Element { const openShortcuts = useCallback(() => setShortcutsOpen(true), []) const closeShortcuts = useCallback(() => setShortcutsOpen(false), []) + const [worktreeCloseDialog, setWorktreeCloseDialog] = useState<{ + sessionId: string + branch: string + message: string + } | null>(null) + const handleNewTerminal = useCallback(() => { const sessionId = `terminal-${Date.now()}` addSession(sessionId, '') @@ -123,10 +130,9 @@ export function App(): React.JSX.Element { [addSession, updateProject], ) - const handleCloseTab = useCallback( + /** Kill PTY + remove session (non-worktree path, or after worktree cleanup). */ + const closeSessionImmediate = useCallback( (sessionId: string) => { - // Kill PTY immediately on explicit close — don't rely only on - // TerminalPane cleanup which may race with React's unmount timing. window.agentDeck.pty.kill(sessionId).catch((err: unknown) => { window.agentDeck.log.send('debug', 'pty', 'Kill failed', { err: String(err) }) }) @@ -135,6 +141,107 @@ export function App(): React.JSX.Element { [removeSession], ) + const handleCloseTab = useCallback( + (sessionId: string) => { + // Read worktree state fresh from store to avoid stale closure + const wt = useAppStore.getState().worktreePaths[sessionId] + // Non-worktree session — release primary slot and close immediately. + if (!wt?.isolated) { + const projectId = useAppStore.getState().sessions[sessionId]?.projectId + if (projectId) { + window.agentDeck.worktree.releasePrimary(projectId, sessionId).catch((err: unknown) => { + window.agentDeck.log.send('debug', 'worktree', 'releasePrimary failed', { + err: String(err), + }) + }) + } + closeSessionImmediate(sessionId) + return + } + + // Worktree session — inspect before closing. + window.agentDeck.worktree + .inspect(sessionId) + .then((result) => { + if (result.hasChanges || result.hasUnmerged) { + // Dirty worktree — show confirmation dialog. + const parts: string[] = [] + if (result.hasChanges) parts.push('uncommitted changes') + if (result.hasUnmerged) parts.push('unmerged commits') + setWorktreeCloseDialog({ + sessionId, + branch: result.branch, + message: `Branch "${result.branch}" has ${parts.join(' and ')}.\nDiscard will delete the worktree and branch.`, + }) + } else { + // Clean worktree — discard silently. + window.agentDeck.pty.kill(sessionId).catch((err: unknown) => { + window.agentDeck.log.send('debug', 'pty', 'Kill failed', { err: String(err) }) + }) + window.agentDeck.worktree.discard(sessionId).catch((err: unknown) => { + useAppStore + .getState() + .addNotification( + 'warning', + 'Failed to clean up worktree — it may need manual removal', + ) + window.agentDeck.log.send('warn', 'worktree', 'Discard failed', { + err: String(err), + }) + }) + useAppStore.getState().clearWorktreePath(sessionId) + removeSession(sessionId) + } + }) + .catch((err: unknown) => { + // Inspect failed — fall back to normal close to avoid blocking. + window.agentDeck.log.send('warn', 'worktree', 'Inspect failed, closing anyway', { + err: String(err), + }) + closeSessionImmediate(sessionId) + }) + }, + [removeSession, closeSessionImmediate], + ) + + /** Worktree dialog: "Discard" — delete branch + worktree. */ + const handleWorktreeDiscard = useCallback(() => { + if (!worktreeCloseDialog) return + const { sessionId } = worktreeCloseDialog + window.agentDeck.pty.kill(sessionId).catch((err: unknown) => { + window.agentDeck.log.send('debug', 'pty', 'Kill failed', { err: String(err) }) + }) + window.agentDeck.worktree.discard(sessionId).catch((err: unknown) => { + useAppStore + .getState() + .addNotification('warning', 'Failed to clean up worktree — it may need manual removal') + window.agentDeck.log.send('warn', 'worktree', 'Discard failed', { err: String(err) }) + }) + useAppStore.getState().clearWorktreePath(sessionId) + removeSession(sessionId) + setWorktreeCloseDialog(null) + }, [worktreeCloseDialog, removeSession]) + + /** Worktree dialog: "Keep Branch" — preserve branch, remove worktree. */ + const handleWorktreeKeep = useCallback(() => { + if (!worktreeCloseDialog) return + const { sessionId } = worktreeCloseDialog + window.agentDeck.pty.kill(sessionId).catch((err: unknown) => { + window.agentDeck.log.send('debug', 'pty', 'Kill failed', { err: String(err) }) + }) + window.agentDeck.worktree.keep(sessionId).catch((err: unknown) => { + window.agentDeck.log.send('warn', 'worktree', 'Keep failed', { err: String(err) }) + }) + useAppStore.getState().clearWorktreePath(sessionId) + removeSession(sessionId) + setWorktreeCloseDialog(null) + }, [worktreeCloseDialog, removeSession]) + + /** Worktree dialog: "Cancel" — abort close, keep session alive. */ + const handleWorktreeCancel = useCallback(() => { + setWorktreeCloseDialog(null) + }, []) + const handleAddTab = useCallback(() => { useAppStore.getState().openCommandPalette() }, []) @@ -443,6 +550,15 @@ export function App(): React.JSX.Element { /> {aboutOpen && } {shortcutsOpen && } + ) diff --git a/src/renderer/components/SplitView/PaneTopbar.css b/src/renderer/components/SplitView/PaneTopbar.css index cd8a07f..39f3988 100644 --- a/src/renderer/components/SplitView/PaneTopbar.css +++ b/src/renderer/components/SplitView/PaneTopbar.css @@ -127,3 +127,15 @@ .pane-btn.primary:hover { background: var(--amber-glow); } + +.pane-worktree-badge { + display: inline-flex; + align-items: center; + gap: 3px; + font-size: 10px; + color: var(--accent); + opacity: 0.8; + margin-left: 8px; + font-family: var(--font-mono); + flex-shrink: 0; +} diff --git a/src/renderer/components/SplitView/PaneTopbar.tsx b/src/renderer/components/SplitView/PaneTopbar.tsx index edaa228..a8d8c2f 100644 --- a/src/renderer/components/SplitView/PaneTopbar.tsx +++ b/src/renderer/components/SplitView/PaneTopbar.tsx @@ -1,4 +1,5 @@ import { memo, useCallback } from 'react' +import { GitBranch } from 'lucide-react' import { useAppStore } from '../../store/appStore' import { HexDot } from '../shared/HexDot' import './PaneTopbar.css' @@ -15,6 +16,7 @@ export const PaneTopbar = memo(function PaneTopbar({ const status = useAppStore((s) => s.sessions[sessionId]?.status ?? 'exited') const projectId = useAppStore((s) => s.sessions[sessionId]?.projectId) const projects = useAppStore((s) => s.projects) + const worktreeInfo = useAppStore((s) => s.worktreePaths[sessionId]) const restartSession = useAppStore((s) => s.restartSession) const project = projectId ? projects.find((p) => p.id === projectId) : undefined @@ -34,17 +36,40 @@ export const PaneTopbar = memo(function PaneTopbar({ // Only show path separately if it adds info beyond the name const showPath = projectPath !== '' && projectPath !== rawName + const clearWorktreePath = useAppStore((s) => s.clearWorktreePath) + const handleRestart = useCallback(() => { + // If the old session has an isolated worktree, clean it up before restart + const wt = useAppStore.getState().worktreePaths[sessionId] + const cleanupPromise = + wt?.isolated === true + ? window.agentDeck.worktree.discard(sessionId).then( + () => clearWorktreePath(sessionId), + (err: unknown) => { + window.agentDeck.log.send('warn', 'worktree', 'Discard before restart failed', { + err: String(err), + }) + clearWorktreePath(sessionId) + }, + ) + : Promise.resolve() + // Kill old PTY, then swap in a fresh session for the same project - void window.agentDeck.pty.kill(sessionId).then(() => { + void Promise.all([window.agentDeck.pty.kill(sessionId), cleanupPromise]).then(() => { restartSession(sessionId) }) - }, [sessionId, restartSession]) + }, [sessionId, restartSession, clearWorktreePath]) return (
{displayName} + {worktreeInfo?.isolated === true && worktreeInfo.branch !== undefined && ( + + + {worktreeInfo.branch.split('/').pop()} + + )} {showPath && ( <> > diff --git a/src/renderer/components/StatusBar/StatusBar.css b/src/renderer/components/StatusBar/StatusBar.css index 530de17..1b323f0 100644 --- a/src/renderer/components/StatusBar/StatusBar.css +++ b/src/renderer/components/StatusBar/StatusBar.css @@ -108,3 +108,7 @@ color: var(--text3); font-size: 9px; } + +.status-worktree { + color: var(--accent); +} diff --git a/src/renderer/components/StatusBar/StatusBar.tsx b/src/renderer/components/StatusBar/StatusBar.tsx index 21ac409..8ca56c5 100644 --- a/src/renderer/components/StatusBar/StatusBar.tsx +++ b/src/renderer/components/StatusBar/StatusBar.tsx @@ -35,6 +35,7 @@ export function StatusBar({ onAboutClick, onShortcutsClick }: StatusBarProps): R const openCommandPalette = useAppStore((s) => s.openCommandPalette) const zoomFactor = useAppStore((s) => s.zoomFactor) const wslDistro = useAppStore((s) => s.wslDistro) + const hasWorktree = useAppStore((s) => Object.values(s.worktreePaths).some((w) => w.isolated)) const [appVersion, setAppVersion] = useState('') @@ -61,6 +62,12 @@ export function StatusBar({ onAboutClick, onShortcutsClick }: StatusBarProps): R
|
WSL2{wslDistro ? ` \u00b7 ${wslDistro}` : ''}
+ {hasWorktree && ( + <> + | + Worktree + + )} {activeProjectName && ( <> | diff --git a/src/renderer/components/Terminal/TerminalPane.tsx b/src/renderer/components/Terminal/TerminalPane.tsx index 427b8ff..2aeb7b5 100644 --- a/src/renderer/components/Terminal/TerminalPane.tsx +++ b/src/renderer/components/Terminal/TerminalPane.tsx @@ -102,6 +102,11 @@ export function TerminalPane({ const writeRafRef = useRef(0) const setSessionStatus = useAppStore((s) => s.setSessionStatus) const removeSession = useAppStore((s) => s.removeSession) + const setWorktreePath = useAppStore((s) => s.setWorktreePath) + const clearWorktreePath = useAppStore((s) => s.clearWorktreePath) + // Look up projectId from session (stable per sessionId lifetime) + const projectId = useAppStore((s) => s.sessions[sessionId]?.projectId ?? '') + const projectIdRef = useRef(projectId) /** * Schedule a single coalesced fit in the next animation frame. @@ -363,23 +368,64 @@ export function TerminalPane({ // Only spawn on first mount — reattached terminals already have a live PTY if (!isReattached) { - spawnTimestamp = Date.now() - const { cols, rows } = term - window.agentDeck.pty - .spawn( - sessionId, - cols, - rows, - projectPathRef.current, - startupRef.current, - envRef.current, - agentRef.current, - agentFlagsRef.current, - ) - .then(() => { + const doSpawn = async (): Promise => { + if (cancelled) return + + // Resolve worktree path for project sessions (bare terminals use projectPath directly) + let spawnPath = projectPathRef.current + const pid = projectIdRef.current + if (pid) { + try { + const result = await window.agentDeck.worktree.acquire(pid, sessionId) + if (cancelled) { + // Tab closed while acquire was in-flight — discard the orphaned worktree + if (result.isolated) { + window.agentDeck.worktree.discard(sessionId).catch((err: unknown) => { + window.agentDeck.log.send('warn', 'terminal', 'Orphan worktree cleanup failed', { + sessionId, + err: String(err), + }) + }) + } + return + } + setWorktreePath(sessionId, result) + spawnPath = result.path + } catch (err: unknown) { + if (cancelled) return + window.agentDeck.log.send( + 'error', + 'terminal', + `Worktree acquire failed for ${sessionId}`, + { err: String(err) }, + ) + try { + term.write( + '\r\n\x1b[31m Failed to acquire worktree. Falling back to project path.\x1b[0m\r\n', + ) + } catch { + /* terminal disposed */ + } + // Fall back to original project path + } + } + + if (cancelled) return + spawnTimestamp = Date.now() + const { cols, rows } = term + try { + await window.agentDeck.pty.spawn( + sessionId, + cols, + rows, + spawnPath, + startupRef.current, + envRef.current, + agentRef.current, + agentFlagsRef.current, + ) if (!cancelled) setSessionStatus(sessionId, 'running') - }) - .catch((err: unknown) => { + } catch (err: unknown) { if (cancelled) return window.agentDeck.log.send('error', 'terminal', `PTY spawn failed for ${sessionId}`, { err: String(err), @@ -390,7 +436,13 @@ export function TerminalPane({ /* terminal disposed */ } setSessionStatus(sessionId, 'exited') + } + } + doSpawn().catch((err: unknown) => { + window.agentDeck.log.send('error', 'terminal', `Spawn sequence failed for ${sessionId}`, { + err: String(err), }) + }) } // Buffer data received while hidden, batch visible writes per animation frame. @@ -567,6 +619,7 @@ export function TerminalPane({ }) } else { // Session removed → dispose everything + clearWorktreePath(sessionId) try { webglAddon?.dispose() } catch { @@ -582,7 +635,7 @@ export function TerminalPane({ }) } } - }, [sessionId, setSessionStatus, removeSession, scheduleFit]) + }, [sessionId, setSessionStatus, removeSession, scheduleFit, setWorktreePath, clearWorktreePath]) // Clear search decorations when search is dismissed via Ctrl+Shift+F toggle // (Escape already clears in the TerminalSearchBar component) diff --git a/src/renderer/components/shared/ConfirmDialog.tsx b/src/renderer/components/shared/ConfirmDialog.tsx index d3df970..b4dc753 100644 --- a/src/renderer/components/shared/ConfirmDialog.tsx +++ b/src/renderer/components/shared/ConfirmDialog.tsx @@ -9,6 +9,8 @@ interface ConfirmDialogProps { confirmLabel?: string | undefined onConfirm: () => void onCancel: () => void + /** Optional third action button (rendered between Cancel and Confirm). */ + extraAction?: { label: string; onClick: () => void } | undefined } export function ConfirmDialog({ @@ -18,6 +20,7 @@ export function ConfirmDialog({ confirmLabel, onConfirm, onCancel, + extraAction, }: ConfirmDialogProps): React.JSX.Element | null { const trapRef = useFocusTrap() @@ -69,6 +72,15 @@ export function ConfirmDialog({ + {extraAction && ( + + )} diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index 8f4948d..216597f 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -112,6 +112,18 @@ declare global { security: { onEncryptionUnavailable: (cb: () => void) => () => void } + worktree: { + acquire( + projectId: string, + sessionId: string, + ): Promise<{ path: string; isolated: boolean; branch?: string }> + inspect( + sessionId: string, + ): Promise<{ hasChanges: boolean; hasUnmerged: boolean; branch: string }> + discard(sessionId: string): Promise + keep(sessionId: string): Promise + releasePrimary(projectId: string, sessionId: string): Promise + } pickFolder: () => Promise log: { send: (level: string, mod: string, message: string, data?: unknown) => Promise diff --git a/src/renderer/store/slices/ui.ts b/src/renderer/store/slices/ui.ts index c608a8c..104f9ea 100644 --- a/src/renderer/store/slices/ui.ts +++ b/src/renderer/store/slices/ui.ts @@ -63,6 +63,14 @@ export interface UiSlice { // Theme theme: string setTheme: (name: string) => void + + // Worktree isolation paths (per-session) + worktreePaths: Record + setWorktreePath: ( + sessionId: string, + result: { path: string; isolated: boolean; branch?: string | undefined }, + ) => void + clearWorktreePath: (sessionId: string) => void } export const createUiSlice: StateCreator = (set) => ({ @@ -229,4 +237,14 @@ export const createUiSlice: StateCreator = (set) => ( window.agentDeck.theme.set(name) set({ theme: name }) }, + + // Worktree isolation paths + worktreePaths: {}, + setWorktreePath: (sessionId, result) => + set((s) => ({ worktreePaths: { ...s.worktreePaths, [sessionId]: result } })), + clearWorktreePath: (sessionId) => + set((s) => { + const { [sessionId]: _, ...rest } = s.worktreePaths + return { worktreePaths: rest } + }), })