diff --git a/src/main/ipc/filesystem-auth.ts b/src/main/ipc/filesystem-auth.ts index 6fff60633e1..3e4a84598c4 100644 --- a/src/main/ipc/filesystem-auth.ts +++ b/src/main/ipc/filesystem-auth.ts @@ -1,8 +1,12 @@ +/* eslint-disable max-lines -- Why: filesystem authorization keeps root +discovery, canonicalization, and registered-worktree cache checks together so +the security boundary is auditable end to end. */ import { resolve, relative, dirname, basename, isAbsolute, sep } from 'path' import { realpathSync } from 'fs' import { realpath } from 'fs/promises' import type { Store } from '../persistence' import { isRepoRoot, listRepoWorktrees } from '../repo-worktrees' +import { computeWorkspaceRoot, getWorktreePathSettings } from './worktree-logic' export const PATH_ACCESS_DENIED_MESSAGE = 'Access denied: path resolves outside allowed directories. If this blocks a legitimate workflow, please file a GitHub issue.' @@ -56,10 +60,19 @@ export function isDescendantOrEqual(resolvedTarget: string, resolvedBase: string } export function getAllowedRoots(store: Store): string[] { - const roots = getLocalRepos(store).map((repo) => resolve(repo.path)) - const workspaceDir = store.getSettings().workspaceDir - if (workspaceDir) { - roots.push(resolve(workspaceDir)) + const localRepos = getLocalRepos(store) + const settings = store.getSettings() + const roots = localRepos.map((repo) => resolve(repo.path)) + if (settings.workspaceDir) { + if (localRepos.length === 0) { + roots.push(resolve(settings.workspaceDir)) + } else { + for (const repo of localRepos) { + roots.push( + resolve(computeWorkspaceRoot(repo.path, getWorktreePathSettings(repo, settings))) + ) + } + } } return roots } diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index 719fcfd0af0..e8046d594e8 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -916,6 +916,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v | 'repoIcon' | 'hookSettings' | 'worktreeBaseRef' + | 'worktreeBasePath' | 'kind' | 'symlinkPaths' | 'issueSourcePreference' @@ -956,6 +957,14 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v delete updates.symlinkPaths } } + if ('worktreeBasePath' in updates && updates.worktreeBasePath !== undefined) { + const v = updates.worktreeBasePath as unknown + if (typeof v !== 'string') { + delete updates.worktreeBasePath + } else { + updates.worktreeBasePath = v.trim() || undefined + } + } if ('repoIcon' in updates) { const repoIcon = sanitizeRepoIcon(updates.repoIcon) if (repoIcon === undefined) { @@ -1000,6 +1009,9 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v } const updated = store.updateRepo(args.repoId, updates) if (updated) { + if ('worktreeBasePath' in updates) { + invalidateAuthorizedRootsCache() + } notifyReposChanged(mainWindow) } return updated diff --git a/src/main/ipc/worktree-logic.test.ts b/src/main/ipc/worktree-logic.test.ts index 0f1c96dab32..16f73eb9c32 100644 --- a/src/main/ipc/worktree-logic.test.ts +++ b/src/main/ipc/worktree-logic.test.ts @@ -9,6 +9,10 @@ import { ensurePathWithinWorkspace, computeBranchName, computeWorktreePath, + computeRemoteWorktreePath, + computeWorkspaceRoot, + getWorktreeCreationLayout, + getWorktreePathSettings, shouldSetDisplayName, mergeWorktree, parseWorktreeId, @@ -174,6 +178,78 @@ describe('computeWorktreePath', () => { }) ).toBe(join('/workspaces', 'my-project', 'feature')) }) + + it('resolves relative workspace directories from the repo path', () => { + expect(computeWorkspaceRoot('/projects/app/repo', { workspaceDir: '../worktrees' })).toBe( + resolve('/projects/app/worktrees') + ) + expect( + computeWorktreePath('feature', '/projects/app/repo', { + nestWorkspaces: false, + workspaceDir: '../worktrees' + }) + ).toBe(resolve('/projects/app/worktrees/feature')) + }) + + it('scopes the same relative repo override to each repo root', () => { + const settings = { nestWorkspaces: false, workspaceDir: '/global/workspaces' } + const repoA = { path: '/projects/a/repo', worktreeBasePath: '../worktrees' } + const repoB = { path: '/projects/b/repo', worktreeBasePath: '../worktrees' } + + expect( + computeWorktreePath('feature', repoA.path, getWorktreePathSettings(repoA, settings)) + ).toBe(resolve('/projects/a/worktrees/feature')) + expect( + computeWorktreePath('feature', repoB.path, getWorktreePathSettings(repoB, settings)) + ).toBe(resolve('/projects/b/worktrees/feature')) + expect(getWorktreeCreationLayout(repoA, settings)).toEqual({ + path: '../worktrees', + nestWorkspaces: false + }) + }) + + it('resolves Windows-style relative workspace directories with Windows separators', () => { + expect( + computeWorktreePath('feature', 'C:\\Projects\\app\\repo', { + nestWorkspaces: false, + workspaceDir: '..\\worktrees' + }) + ).toBe('C:\\Projects\\app\\worktrees\\feature') + }) + + it('keeps legacy SSH sibling paths for global absolute workspace directories', () => { + expect( + computeRemoteWorktreePath('feature', '/remote/repo', { + nestWorkspaces: false, + workspaceDir: '/local/workspaces' + }) + ).toBe('/remote/feature') + }) + + it('applies repo-specific SSH workspace directories on the remote path', () => { + expect( + computeRemoteWorktreePath( + 'feature', + '/remote/project/repo', + { + nestWorkspaces: false, + workspaceDir: '../worktrees' + }, + { useConfiguredAbsolutePath: true } + ) + ).toBe('/remote/project/worktrees/feature') + expect( + computeRemoteWorktreePath( + 'feature', + 'C:\\Remote\\repo', + { + nestWorkspaces: false, + workspaceDir: '..\\worktrees' + }, + { useConfiguredAbsolutePath: true } + ) + ).toBe('C:\\Remote\\worktrees\\feature') + }) }) describe('areWorktreePathsEqual', () => { diff --git a/src/main/ipc/worktree-logic.ts b/src/main/ipc/worktree-logic.ts index 6aabba36680..9e32c2650b2 100644 --- a/src/main/ipc/worktree-logic.ts +++ b/src/main/ipc/worktree-logic.ts @@ -1,9 +1,20 @@ -import { basename, join, resolve, relative, isAbsolute, posix, sep, win32 } from 'path' -import type { GitWorktreeInfo, Worktree, WorktreeMeta } from '../../shared/types' +import { basename, resolve, relative, isAbsolute, posix, sep, win32 } from 'path' +import type { + GitWorktreeInfo, + GlobalSettings, + OrcaWorkspaceLayout, + Repo, + Worktree, + WorktreeMeta +} from '../../shared/types' +import { resolveRuntimePath } from '../../shared/cross-platform-path' import { splitWorktreeId } from '../../shared/worktree-id' import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' import { getWslHome, parseWslPath } from '../wsl' +type WorktreePathSettings = Pick +type WorktreeBasePathRepo = Pick + /** * Sanitize a worktree name for use in branch names and directory paths. * Strips unsafe characters and collapses runs of special chars to a single hyphen. @@ -95,37 +106,73 @@ export function computeBranchName( export function computeWorktreePath( sanitizedName: string, repoPath: string, - settings: { nestWorkspaces: boolean; workspaceDir: string } + settings: WorktreePathSettings ): string { - const pathOps = - looksLikeWindowsPath(repoPath) || looksLikeWindowsPath(settings.workspaceDir) - ? win32 - : { basename, join } - - const wsl = parseWslPath(repoPath) - if (wsl) { - const wslHome = getWslHome(wsl.distro) - if (wslHome) { - // Why: WSL UNC paths are still Windows paths from Node's perspective. - // On Linux CI, the default path helpers use POSIX semantics and would - // treat `\\wsl.localhost\...` as a plain string, producing mixed-separator - // paths like `\\wsl.localhost\Ubuntu\home\jin/orca/...`. Use win32 path - // operations whenever a Windows/UNC path is involved so behavior matches - // the Windows production runtime. - const wslWorkspaceDir = win32.join(wslHome, 'orca', 'workspaces') - if (settings.nestWorkspaces) { - const repoName = win32.basename(repoPath).replace(/\.git$/, '') - return win32.join(wslWorkspaceDir, repoName, sanitizedName) - } - return win32.join(wslWorkspaceDir, sanitizedName) - } - } + const workspaceRoot = computeWorkspaceRoot(repoPath, settings) + const pathOps = getRuntimePathOps(repoPath, workspaceRoot) if (settings.nestWorkspaces) { const repoName = pathOps.basename(repoPath).replace(/\.git$/, '') - return pathOps.join(settings.workspaceDir, repoName, sanitizedName) + return pathOps.join(workspaceRoot, repoName, sanitizedName) } - return pathOps.join(settings.workspaceDir, sanitizedName) + return pathOps.join(workspaceRoot, sanitizedName) +} + +export function computeWorkspaceRoot(repoPath: string, settings: { workspaceDir: string }): string { + const wsl = parseWslPath(repoPath) + if (wsl && shouldMirrorWorkspaceDirInsideWsl(repoPath, settings.workspaceDir)) { + const wslHome = getWslHome(wsl.distro) + if (wslHome) { + // Why: WSL UNC paths are still Windows paths from Node's perspective. + // Mirror absolute local desktop workspace roots inside the distro so + // terminals stay on the WSL filesystem; repo-relative roots can resolve + // directly against the WSL repo path. + return win32.join(wslHome, 'orca', 'workspaces') + } + } + return resolveWorkspaceDirForRepo(repoPath, settings.workspaceDir) +} + +export function computeRemoteWorktreePath( + sanitizedName: string, + repoPath: string, + settings: WorktreePathSettings, + options: { useConfiguredAbsolutePath?: boolean } = {} +): string { + if ( + options.useConfiguredAbsolutePath || + isWorkspaceDirRelativeToRepo(repoPath, settings.workspaceDir) + ) { + return computeWorktreePath(sanitizedName, repoPath, settings) + } + // Why: absolute global workspaceDir values belong to the desktop machine. + // SSH worktrees keep the legacy repo-sibling root unless a repo-specific + // path opts into a remote-host location. + return getRuntimePathOps(repoPath, repoPath).join(repoPath, '..', sanitizedName) +} + +export function getWorktreePathSettings( + repo: WorktreeBasePathRepo, + settings: WorktreePathSettings +): WorktreePathSettings { + return { + nestWorkspaces: settings.nestWorkspaces, + workspaceDir: getEffectiveWorktreeBasePath(repo, settings) + } +} + +export function getWorktreeCreationLayout( + repo: WorktreeBasePathRepo, + settings: WorktreePathSettings +): OrcaWorkspaceLayout { + return { + path: getEffectiveWorktreeBasePath(repo, settings), + nestWorkspaces: settings.nestWorkspaces + } +} + +export function hasRepoWorktreeBasePath(repo: Pick): boolean { + return getRepoWorktreeBasePath(repo) !== undefined } export function areWorktreePathsEqual( @@ -148,7 +195,46 @@ export function areWorktreePathsEqual( } function looksLikeWindowsPath(pathValue: string): boolean { - return /^[A-Za-z]:[\\/]/.test(pathValue) || pathValue.startsWith('\\\\') + return ( + /^[A-Za-z]:[\\/]/.test(pathValue) || pathValue.startsWith('\\\\') || pathValue.startsWith('//') + ) +} + +function getRuntimePathOps( + repoPath: string, + workspaceDir: string +): Pick { + return looksLikeWindowsPath(repoPath) || looksLikeWindowsPath(workspaceDir) ? win32 : posix +} + +function resolveWorkspaceDirForRepo(repoPath: string, workspaceDir: string): string { + const pathOps = getRuntimePathOps(repoPath, workspaceDir) + return pathOps.isAbsolute(workspaceDir) + ? pathOps.normalize(workspaceDir) + : resolveRuntimePath(repoPath, workspaceDir) +} + +function isWorkspaceDirRelativeToRepo(repoPath: string, workspaceDir: string): boolean { + return !getRuntimePathOps(repoPath, workspaceDir).isAbsolute(workspaceDir) +} + +function getEffectiveWorktreeBasePath( + repo: WorktreeBasePathRepo, + settings: WorktreePathSettings +): string { + return getRepoWorktreeBasePath(repo) ?? settings.workspaceDir +} + +function getRepoWorktreeBasePath(repo: Pick): string | undefined { + const trimmed = repo.worktreeBasePath?.trim() + return trimmed || undefined +} + +function shouldMirrorWorkspaceDirInsideWsl(repoPath: string, workspaceDir: string): boolean { + if (isWorkspaceDirRelativeToRepo(repoPath, workspaceDir)) { + return false + } + return !parseWslPath(workspaceDir) } /** diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 4690f5126e8..cfb11b5a301 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -8,7 +8,7 @@ // cohesive flow would split awkwardly. import type { BrowserWindow } from 'electron' -import { join, posix, win32 } from 'path' +import { posix, win32 } from 'path' import { existsSync } from 'fs' import { randomUUID } from 'crypto' import type { Store } from '../persistence' @@ -30,7 +30,6 @@ import { gitExecFileAsync } from '../git/runner' import { parseGitHubOwnerRepo } from '../github/gh-utils' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import type { RemoteFetchResult, RemoteTrackingBase } from '../runtime/orca-runtime' -import { isWslPath, parseWslPath, getWslHome } from '../wsl' import { buildPosixRunnerScript, buildWindowsRunnerScript, @@ -55,7 +54,12 @@ import { sanitizeWorktreeDisplayName, computeBranchName, computeWorktreePath, + computeRemoteWorktreePath, + computeWorkspaceRoot, ensurePathWithinWorkspace, + getWorktreeCreationLayout, + getWorktreePathSettings, + hasRepoWorktreeBasePath, shouldSetDisplayName, mergeWorktree, areWorktreePathsEqual @@ -331,12 +335,6 @@ function isAllowedPushTargetRemoteConflict( ) } -function remoteSiblingWorktreePath(repoPath: string, sanitizedName: string): string { - return isWindowsAbsolutePathLike(repoPath) - ? win32.join(win32.dirname(repoPath), sanitizedName) - : `${repoPath}/../${sanitizedName}` -} - async function remotePathExists( fsProvider: IFilesystemProvider | null | undefined, pathValue: string @@ -880,6 +878,7 @@ export async function createRemoteWorktree( const fsProvider = getSshFilesystemProvider(repo.connectionId!) const settings = store.getSettings() + const worktreePathSettings = getWorktreePathSettings(repo, settings) let effectiveRequestedName = args.name const sanitizedName = sanitizeWorktreeName(args.name) let effectiveSanitizedName = sanitizedName @@ -900,7 +899,9 @@ export async function createRemoteWorktree( username ) - let remotePath = remoteSiblingWorktreePath(repo.path, effectiveSanitizedName) + let remotePath = computeRemoteWorktreePath(sanitizedName, repo.path, worktreePathSettings, { + useConfiguredAbsolutePath: hasRepoWorktreeBasePath(repo) + }) // Determine base branch // Why: previously fell back to a hardcoded 'origin/main' when @@ -949,7 +950,14 @@ export async function createRemoteWorktree( : args.name.trim() ? `${args.name}-${suffix}` : effectiveSanitizedName - remotePath = remoteSiblingWorktreePath(repo.path, effectiveSanitizedName) + remotePath = computeRemoteWorktreePath( + effectiveSanitizedName, + repo.path, + worktreePathSettings, + { + useConfiguredAbsolutePath: hasRepoWorktreeBasePath(repo) + } + ) if (!(await remotePathExists(fsProvider, remotePath))) { remotePathResolved = true break @@ -1147,10 +1155,7 @@ export async function createRemoteWorktree( createdAt: now, orcaCreatedAt: now, orcaCreationSource: 'ssh', - orcaCreationWorkspaceLayout: { - path: settings.workspaceDir, - nestWorkspaces: settings.nestWorkspaces - }, + orcaCreationWorkspaceLayout: getWorktreeCreationLayout(repo, settings), baseRef: baseBranch, ...(checkoutExistingBranch ? { preserveBranchOnDelete: true } : {}), ...(configuredPushTarget ? { pushTarget: configuredPushTarget } : {}), @@ -1243,6 +1248,7 @@ export async function createLocalWorktree( runtime?: OrcaRuntimeService ): Promise { const settings = store.getSettings() + const worktreePathSettings = getWorktreePathSettings(repo, settings) const username = getGitUsername(repo.path) const requestedName = args.name @@ -1304,13 +1310,7 @@ export async function createLocalWorktree( .catch(() => undefined) emitCreateWorktreeProgress(mainWindow, 'fetching') } - // Why: WSL worktrees live under ~/orca/workspaces inside the WSL - // filesystem. Validate against that root, not the Windows workspace dir. - // If WSL home lookup fails, keep using the configured workspace root so - // the path traversal guard still runs on the fallback path. - const wslInfo = isWslPath(repo.path) ? parseWslPath(repo.path) : null - const wslHome = wslInfo ? getWslHome(wslInfo.distro) : null - const workspaceRoot = wslHome ? join(wslHome, 'orca', 'workspaces') : settings.workspaceDir + const workspaceRoot = computeWorkspaceRoot(repo.path, worktreePathSettings) // Why: this validation does not depend on remote refs, so it can overlap a // required remote-tracking base refresh. @@ -1442,7 +1442,7 @@ export async function createLocalWorktree( } worktreePath = ensurePathWithinWorkspace( - computeWorktreePath(effectiveSanitizedName, repo.path, settings), + computeWorktreePath(effectiveSanitizedName, repo.path, worktreePathSettings), workspaceRoot ) if (existsSync(worktreePath)) { @@ -1575,10 +1575,7 @@ export async function createLocalWorktree( createdAt: now, orcaCreatedAt: now, orcaCreationSource: 'desktop', - orcaCreationWorkspaceLayout: { - path: settings.workspaceDir, - nestWorkspaces: settings.nestWorkspaces - }, + orcaCreationWorkspaceLayout: getWorktreeCreationLayout(repo, settings), baseRef: baseBranch, ...(checkoutExistingBranch ? { preserveBranchOnDelete: true } : {}), ...(configuredPushTarget ? { pushTarget: configuredPushTarget } : {}), diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 2d8d1317937..9e3e8accdec 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -502,6 +502,50 @@ describe('registerWorktreeHandlers', () => { }) }) + it('uses a repo-specific worktree base path when creating local worktrees', async () => { + store.getRepo.mockReturnValue({ + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + worktreeBaseRef: null, + worktreeBasePath: '../worktrees' + }) + listWorktreesMock.mockResolvedValue([ + { + path: '../worktrees/feature', + head: 'abc123', + branch: 'feature', + isBare: false, + isMainWorktree: false + } + ]) + + await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'feature' + }) + + expect(computeWorktreePathMock).toHaveBeenCalledWith('feature', '/workspace/repo', { + nestWorkspaces: false, + workspaceDir: '../worktrees' + }) + expect(addWorktreeMock).toHaveBeenCalledWith( + '/workspace/repo', + '../worktrees/feature', + 'feature', + 'origin/main', + false + ) + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + 'repo-1::../worktrees/feature', + expect.objectContaining({ + orcaCreationWorkspaceLayout: { path: '../worktrees', nestWorkspaces: false } + }) + ) + }) + it('uses branchNameOverride for the git branch while keeping the sanitized worktree path', async () => { listWorktreesMock.mockResolvedValue([ { @@ -1637,20 +1681,20 @@ describe('registerWorktreeHandlers', () => { expect(provider.addWorktree).toHaveBeenCalledWith( '/remote/repo', 'sparse-dashboard', - '/remote/repo/../sparse-dashboard', + '/remote/sparse-dashboard', { base: 'origin/main', noCheckout: true } ) expect(provider.exec).toHaveBeenCalledWith( ['sparse-checkout', 'init', '--cone'], - '/remote/repo/../sparse-dashboard' + '/remote/sparse-dashboard' ) expect(provider.exec).toHaveBeenCalledWith( ['sparse-checkout', 'set', '--', 'apps/mobile', 'packages/shared'], - '/remote/repo/../sparse-dashboard' + '/remote/sparse-dashboard' ) expect(provider.exec).toHaveBeenCalledWith( ['checkout', 'sparse-dashboard'], - '/remote/repo/../sparse-dashboard' + '/remote/sparse-dashboard' ) expect(store.setWorktreeMeta).toHaveBeenCalledWith( 'repo-ssh::/remote/sparse-dashboard', @@ -1911,9 +1955,9 @@ describe('registerWorktreeHandlers', () => { expect(provider.exec).toHaveBeenCalledWith( ['config', '--local', '--unset-all', 'branch.sparse-dashboard.base'], - '/remote/repo/../sparse-dashboard' + '/remote/sparse-dashboard' ) - expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/repo/../sparse-dashboard', true, { + expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/sparse-dashboard', true, { deleteBranch: true, forceBranchDelete: true }) diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 8546528288b..8e40e0c252f 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -400,7 +400,7 @@ function buildDetectedGitWorktrees( gitWorktrees: GitWorktreeInfo[] ): DetectedWorktree[] { const settings = store.getSettings() - const knownOrcaLayouts = repo.connectionId ? [] : buildKnownOrcaWorkspaceLayouts(settings, repo) + const knownOrcaLayouts = buildKnownOrcaWorkspaceLayouts(settings, repo) const isLegacyRepoForVisibility = isLegacyRepoForExternalWorktreeVisibility(repo) return gitWorktrees.map((gitWorktree) => { const worktreeId = `${repo.id}::${gitWorktree.path}` @@ -1018,9 +1018,7 @@ export function registerWorktreeHandlers( if (!registeredWorktree) { const fsProvider = repo.connectionId ? getSshFilesystemProvider(repo.connectionId) : null let canCleanOrphanedDirectory = false - const knownOrcaLayouts = repo.connectionId - ? [] - : buildKnownOrcaWorkspaceLayouts(store.getSettings(), repo) + const knownOrcaLayouts = buildKnownOrcaWorkspaceLayouts(store.getSettings(), repo) if ( canCleanupUnregisteredOrcaWorktreeDirectory({ meta: removedMeta, diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 5c0fbdfe788..35e5251ff91 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -527,7 +527,7 @@ function readLegacySidekickFlag(parsed: PersistedState | undefined): boolean | u } function sanitizeRepoUpdatesForPersistence< - T extends Partial> + T extends Partial> >(updates: T): T { const sanitized = { ...updates } if ('badgeColor' in sanitized) { @@ -546,6 +546,13 @@ function sanitizeRepoUpdatesForPersistence< sanitized.repoIcon = repoIcon } } + if ('worktreeBasePath' in sanitized && sanitized.worktreeBasePath !== undefined) { + if (typeof sanitized.worktreeBasePath === 'string') { + sanitized.worktreeBasePath = sanitized.worktreeBasePath.trim() || undefined + } else { + delete sanitized.worktreeBasePath + } + } return sanitized } @@ -2320,6 +2327,7 @@ export class Store { | 'repoIcon' | 'hookSettings' | 'worktreeBaseRef' + | 'worktreeBasePath' | 'kind' | 'symlinkPaths' | 'issueSourcePreference' @@ -2367,6 +2375,10 @@ export class Store { delete repo.issueSourcePreference delete sanitizedUpdates.issueSourcePreference } + if ('worktreeBasePath' in sanitizedUpdates && sanitizedUpdates.worktreeBasePath === undefined) { + delete repo.worktreeBasePath + delete sanitizedUpdates.worktreeBasePath + } if ( 'externalWorktreeVisibility' in sanitizedUpdates && repo.externalWorktreeVisibilityLegacy === undefined diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 0619a8a5c12..2bbc3dd6408 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1820,7 +1820,7 @@ describe('OrcaRuntimeService', () => { expect(provider.addWorktree).toHaveBeenCalledWith( '/remote/repo', 'mobile-feature', - '/remote/repo/../mobile-feature', + '/remote/mobile-feature', { base: 'origin/main' } ) expect(result.worktree).toMatchObject({ diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 3bd64012b98..75469a0cd0a 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -15,7 +15,6 @@ import { deriveValidatedClonePath, getClonePathComparisonKey } from '../git/repo-clone-path' -import { isWslPath, parseWslPath, getWslHome } from '../wsl' import { createHash, randomUUID } from 'crypto' import { isAbsolute, join } from 'path' import { mkdir, readFile, readdir, rm, stat } from 'fs/promises' @@ -377,8 +376,11 @@ import { AgentDetector } from '../stats/agent-detector' import { computeBranchName, computeWorktreePath, + computeWorkspaceRoot, ensurePathWithinWorkspace, formatWorktreeRemovalError, + getWorktreeCreationLayout, + getWorktreePathSettings, isOrphanCompatiblePreflightError, isOrphanedWorktreeError, mergeWorktree, @@ -5768,6 +5770,7 @@ export class OrcaRuntimeService { | 'repoIcon' | 'hookSettings' | 'worktreeBaseRef' + | 'worktreeBasePath' | 'kind' | 'symlinkPaths' | 'issueSourcePreference' @@ -5783,10 +5786,17 @@ export class OrcaRuntimeService { throw new Error('runtime_unavailable') } const repo = await this.resolveRepoSelector(repoSelector) - const updated = this.store.updateRepo(repo.id, omitUndefinedProperties(updates)) + const sanitizedUpdates = omitUndefinedProperties(updates) + if ('worktreeBasePath' in updates && updates.worktreeBasePath === undefined) { + sanitizedUpdates.worktreeBasePath = undefined + } + const updated = this.store.updateRepo(repo.id, sanitizedUpdates) if (!updated) { throw new Error('repo_not_found') } + if ('worktreeBasePath' in updates) { + invalidateAuthorizedRootsCache() + } this.invalidateResolvedWorktreeCache() this.notifier?.reposChanged() return updated @@ -7206,7 +7216,7 @@ export class OrcaRuntimeService { worktree, meta: this.store?.getWorktreeMeta(worktree.id), settings, - knownOrcaLayouts: repo.connectionId ? [] : buildKnownOrcaWorkspaceLayouts(settings, repo), + knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo), isLegacyRepoForVisibility: isLegacyRepoForExternalWorktreeVisibility(repo) }) } @@ -7642,6 +7652,7 @@ export class OrcaRuntimeService { args.lineage || args.comment ? { ...args.lineage, comment: args.comment } : undefined const lineageResolution = await this.resolveLineageForWorktreeCreate(lineageInput) const settings = createSettings + const worktreePathSettings = getWorktreePathSettings(repo, settings) let effectiveRequestedName = args.name const requestedDisplayName = args.displayName?.trim() || undefined const sanitizedName = sanitizeWorktreeName(args.name) @@ -7708,12 +7719,10 @@ export class OrcaRuntimeService { } } + const workspaceRoot = computeWorkspaceRoot(repo.path, worktreePathSettings) // Why: CLI-managed WSL worktrees live under ~/orca/workspaces inside the - // distro filesystem. If home lookup fails, still validate against the - // configured workspace dir so the traversal guard is never bypassed. - const wslInfo = isWslPath(repo.path) ? parseWslPath(repo.path) : null - const wslHome = wslInfo ? getWslHome(wslInfo.distro) : null - const workspaceRoot = wslHome ? join(wslHome, 'orca', 'workspaces') : settings.workspaceDir + // distro filesystem through computeWorkspaceRoot. If home lookup fails, + // still validate against the effective workspace dir. let worktreePath = '' let worktreePathResolved = !args.branchNameOverride for (let suffix = 1; suffix < 100; suffix += 1) { @@ -7725,7 +7734,7 @@ export class OrcaRuntimeService { ? `${args.name}-${suffix}` : effectiveSanitizedName worktreePath = ensurePathWithinWorkspace( - computeWorktreePath(effectiveSanitizedName, repo.path, settings), + computeWorktreePath(effectiveSanitizedName, repo.path, worktreePathSettings), workspaceRoot ) if (!args.branchNameOverride || !(await pathExists(worktreePath))) { @@ -7861,10 +7870,7 @@ export class OrcaRuntimeService { createdAt: now, orcaCreatedAt: now, orcaCreationSource: 'runtime', - orcaCreationWorkspaceLayout: { - path: settings.workspaceDir, - nestWorkspaces: settings.nestWorkspaces - }, + orcaCreationWorkspaceLayout: getWorktreeCreationLayout(repo, settings), ...displayNameMeta, baseRef: baseBranch, ...(checkoutExistingBranch ? { preserveBranchOnDelete: true } : {}), @@ -9188,9 +9194,7 @@ export class OrcaRuntimeService { ) if (!registeredWorktree) { let canCleanOrphanedDirectory = false - const knownOrcaLayouts = repo.connectionId - ? [] - : buildKnownOrcaWorkspaceLayouts(store.getSettings(), repo) + const knownOrcaLayouts = buildKnownOrcaWorkspaceLayouts(store.getSettings(), repo) if ( canCleanupUnregisteredOrcaWorktreeDirectory({ meta: removedMeta, diff --git a/src/main/runtime/rpc/methods/repo.ts b/src/main/runtime/rpc/methods/repo.ts index 611ed827ac5..83a2b6061da 100644 --- a/src/main/runtime/rpc/methods/repo.ts +++ b/src/main/runtime/rpc/methods/repo.ts @@ -54,6 +54,7 @@ const RepoUpdate = RepoSelector.extend({ .optional(), hookSettings: z.unknown().optional(), worktreeBaseRef: OptionalString, + worktreeBasePath: OptionalString, kind: z.enum(['git', 'folder']).optional(), symlinkPaths: z.array(z.string()).optional(), issueSourcePreference: z.enum(['auto', 'upstream', 'origin']).optional(), diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index c2b988d800a..08ef1ea6a71 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -662,6 +662,7 @@ export type PreloadApi = { | 'repoIcon' | 'hookSettings' | 'worktreeBaseRef' + | 'worktreeBasePath' | 'kind' | 'issueSourcePreference' | 'externalWorktreeVisibility' diff --git a/src/renderer/src/components/settings/RepositoryPane.test.ts b/src/renderer/src/components/settings/RepositoryPane.test.ts index 227c89ba083..f83ce0583a6 100644 --- a/src/renderer/src/components/settings/RepositoryPane.test.ts +++ b/src/renderer/src/components/settings/RepositoryPane.test.ts @@ -31,6 +31,8 @@ describe('RepositoryPane search entries', () => { expect(matchesSettingsSearch('advanced', entries)).toBe(true) expect(matchesSettingsSearch('command source', entries)).toBe(true) expect(matchesSettingsSearch('local settings scripts', entries)).toBe(true) + expect(matchesSettingsSearch('../worktrees', entries)).toBe(true) + expect(matchesSettingsSearch('worktree path', entries)).toBe(true) }) it('matches project identity searches on display name and path only', () => { diff --git a/src/renderer/src/components/settings/RepositoryPane.tsx b/src/renderer/src/components/settings/RepositoryPane.tsx index 58fb94b87e0..9999b74ea25 100644 --- a/src/renderer/src/components/settings/RepositoryPane.tsx +++ b/src/renderer/src/components/settings/RepositoryPane.tsx @@ -52,7 +52,8 @@ export function RepositoryPane({ }: RepositoryPaneProps): React.JSX.Element { const isFolder = isFolderRepo(repo) const searchQuery = useAppStore((state) => state.settingsSearchQuery) - const symlinksEnabled = useAppStore((state) => state.settings?.experimentalWorktreeSymlinks) + const settings = useAppStore((state) => state.settings) + const symlinksEnabled = settings?.experimentalWorktreeSymlinks const [confirmingRemove, setConfirmingRemove] = useState(null) const [copiedTemplate, setCopiedTemplate] = useState(false) const copiedTemplateResetTimerRef = useRef(null) @@ -117,9 +118,13 @@ export function RepositoryPane({ const allEntries = getRepositoryPaneSearchEntries(repo) const identityEntries = allEntries.filter((entry) => - ['Display Name', 'Project Icon', 'Default Worktree Base', 'Remove Project'].includes( - entry.title - ) + [ + 'Display Name', + 'Project Icon', + 'Default Worktree Base', + 'Worktree Location', + 'Remove Project' + ].includes(entry.title) ) const sparsePresetEntries = allEntries.filter((entry) => ['Sparse Checkout Presets'].includes(entry.title) @@ -243,21 +248,65 @@ export function RepositoryPane({ {!isFolder ? ( - - - updateRepo(repo.id, { worktreeBaseRef: ref })} - onUsePrimary={() => updateRepo(repo.id, { worktreeBaseRef: undefined })} - /> - + <> + + + updateRepo(repo.id, { worktreeBaseRef: ref })} + onUsePrimary={() => updateRepo(repo.id, { worktreeBaseRef: undefined })} + /> + + + +
+ + {repo.worktreeBasePath ? ( + + ) : null} +
+ + updateRepo(repo.id, { + worktreeBasePath: e.target.value.trim() ? e.target.value : undefined + }) + } + className="h-9 text-sm" + /> +

+ Relative paths resolve from this project root. +

+
+ ) : null} ) : null, diff --git a/src/renderer/src/components/settings/repository-search.ts b/src/renderer/src/components/settings/repository-search.ts index 92fada2c1ac..3d13edd7bdc 100644 --- a/src/renderer/src/components/settings/repository-search.ts +++ b/src/renderer/src/components/settings/repository-search.ts @@ -32,6 +32,18 @@ export function getRepositoryPaneSearchEntries(repo: Repo): SettingsSearchEntry[ description: 'Default base branch or ref when creating worktrees.', keywords: [repo.displayName, 'base ref', 'branch'] }, + { + title: 'Worktree Location', + description: 'Project-specific directory for new worktrees.', + keywords: [ + repo.displayName, + 'worktree path', + 'workspace path', + 'directory', + 'relative', + '../worktrees' + ] + }, { title: 'Sparse Checkout Presets', description: 'Saved directory sets for sparse worktree creation.', diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index bd03bc5c4b6..d0a1e3e7d88 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -30,6 +30,7 @@ type RepoUpdate = Partial< | 'repoIcon' | 'hookSettings' | 'worktreeBaseRef' + | 'worktreeBasePath' | 'kind' | 'symlinkPaths' | 'issueSourcePreference' @@ -59,6 +60,9 @@ function sanitizeRepoUpdate(updates: RepoUpdate): RepoUpdate { sanitized.repoIcon = repoIcon } } + if ('worktreeBasePath' in sanitized && sanitized.worktreeBasePath !== undefined) { + sanitized.worktreeBasePath = sanitized.worktreeBasePath.trim() || undefined + } return sanitized } diff --git a/src/shared/cross-platform-path.test.ts b/src/shared/cross-platform-path.test.ts index ea805b7a788..085bf56825c 100644 --- a/src/shared/cross-platform-path.test.ts +++ b/src/shared/cross-platform-path.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest' -import { isPathInsideOrEqual, relativePathInsideRoot } from './cross-platform-path' +import { + isPathInsideOrEqual, + isRuntimePathAbsolute, + relativePathInsideRoot, + resolveRuntimePath +} from './cross-platform-path' describe('cross-platform path containment', () => { it('keeps POSIX sibling prefixes outside the root', () => { @@ -23,4 +28,20 @@ describe('cross-platform path containment', () => { ) expect(isPathInsideOrEqual('\\\\Server\\Share\\Repo', '\\\\server\\share\\repo2')).toBe(false) }) + + it('resolves POSIX relative paths without using the process cwd', () => { + expect(resolveRuntimePath('/repos/app/repo', '../worktrees/feature')).toBe( + '/repos/app/worktrees/feature' + ) + expect(resolveRuntimePath('/repos/app/repo', '/custom/worktrees')).toBe('/custom/worktrees') + expect(isRuntimePathAbsolute('../worktrees')).toBe(false) + }) + + it('resolves Windows relative paths with Windows semantics', () => { + expect(resolveRuntimePath('C:\\Repos\\app\\repo', '..\\worktrees\\feature')).toBe( + 'C:/Repos/app/worktrees/feature' + ) + expect(resolveRuntimePath('C:\\Repos\\app\\repo', 'D:\\worktrees')).toBe('D:/worktrees') + expect(isRuntimePathAbsolute('/remote/worktrees', 'windows')).toBe(true) + }) }) diff --git a/src/shared/cross-platform-path.ts b/src/shared/cross-platform-path.ts index b40d6e7b028..0bf7061f51a 100644 --- a/src/shared/cross-platform-path.ts +++ b/src/shared/cross-platform-path.ts @@ -15,6 +15,28 @@ export function normalizeRuntimePathForComparison(value: string): string { return isWindowsAbsolutePathLike(value) ? normalized.toLowerCase() : normalized } +export function isRuntimePathAbsolute( + value: string, + pathFlavor: 'posix' | 'windows' = isWindowsPathFlavor(value) ? 'windows' : 'posix' +): boolean { + if (pathFlavor === 'windows') { + return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\') || value.startsWith('/') + } + return value.startsWith('/') +} + +export function resolveRuntimePath(basePath: string, targetPath: string): string { + const pathFlavor = + isWindowsPathFlavor(basePath) || isWindowsPathFlavor(targetPath) ? 'windows' : 'posix' + if (isRuntimePathAbsolute(targetPath, pathFlavor)) { + return normalizeRuntimePathDots(targetPath, pathFlavor) + } + return normalizeRuntimePathDots( + `${trimRuntimePathTrailingSlash(normalizeRuntimePathSeparators(basePath))}/${targetPath}`, + pathFlavor + ) +} + export function getRuntimePathBasename(value: string): string { const trimmed = value.replace(/[\\/]+$/g, '') if (!trimmed) { @@ -63,3 +85,59 @@ function trimRuntimePathTrailingSlash(value: string): string { } return value.replace(/\/+$/, '') } + +function isWindowsPathFlavor(value: string): boolean { + return /^[A-Za-z]:[\\/]/.test(value) || value.includes('\\') || value.startsWith('//') +} + +function normalizeRuntimePathDots(value: string, pathFlavor: 'posix' | 'windows'): string { + const normalized = normalizeRuntimePathSeparators(value) + const { root, rest } = splitRuntimePathRoot(normalized, pathFlavor) + const segments: string[] = [] + for (const segment of rest.split('/')) { + if (!segment || segment === '.') { + continue + } + if (segment === '..') { + if (segments.length > 0 && segments.at(-1) !== '..') { + segments.pop() + } else if (!root) { + segments.push(segment) + } + continue + } + segments.push(segment) + } + const suffix = segments.join('/') + if (!root) { + return suffix || '.' + } + return suffix ? `${root}${suffix}` : trimRuntimePathTrailingSlash(root) +} + +function splitRuntimePathRoot( + value: string, + pathFlavor: 'posix' | 'windows' +): { root: string; rest: string } { + if (pathFlavor === 'windows') { + const drive = value.match(/^([A-Za-z]:)(?:\/|$)/) + if (drive) { + return { root: `${drive[1]}/`, rest: value.slice(drive[0].length) } + } + if (value.startsWith('//')) { + const parts = value.slice(2).split('/') + if (parts.length >= 2 && parts[0] && parts[1]) { + const root = `//${parts[0]}/${parts[1]}/` + return { root, rest: parts.slice(2).join('/') } + } + return { root: '//', rest: value.slice(2) } + } + if (value.startsWith('/')) { + return { root: '/', rest: value.slice(1) } + } + } + if (value.startsWith('/')) { + return { root: '/', rest: value.slice(1) } + } + return { root: '', rest: value } +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 4ff2d78f121..c3a8452db45 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -86,6 +86,8 @@ export type Repo = { kind?: RepoKind gitUsername?: string worktreeBaseRef?: string + /** Optional repo-scoped workspace root override. Relative paths resolve from `path`. */ + worktreeBasePath?: string hookSettings?: RepoHookSettings /** SSH target ID for remote repos. null/undefined = local. */ connectionId?: string | null diff --git a/src/shared/worktree-ownership-worktree-base-path.test.ts b/src/shared/worktree-ownership-worktree-base-path.test.ts new file mode 100644 index 00000000000..968b1f3dab2 --- /dev/null +++ b/src/shared/worktree-ownership-worktree-base-path.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest' +import type { GlobalSettings, Repo, Worktree } from './types' +import { buildKnownOrcaWorkspaceLayouts, classifyWorktreeOwnership } from './worktree-ownership' + +function makeRepo(overrides: Partial = {}): Repo { + return { + id: 'repo-1', + path: '/projects/a/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: Date.UTC(2026, 4, 24), + kind: 'git', + ...overrides + } +} + +function makeWorktree(path: string): Pick { + return { path, isMainWorktree: false } +} + +function makeSettings(overrides: Partial = {}): GlobalSettings { + return { + workspaceDir: '/global/workspaces', + nestWorkspaces: true, + workspaceDirHistory: [], + ...overrides + } as GlobalSettings +} + +describe('repo-specific worktree ownership layouts', () => { + it('resolves the same relative base path from each repo root', () => { + const settings = makeSettings() + const repoA = makeRepo({ path: '/projects/a/repo', worktreeBasePath: '../worktrees' }) + const repoB = makeRepo({ path: '/projects/b/repo', worktreeBasePath: '../worktrees' }) + + expect(buildKnownOrcaWorkspaceLayouts(settings, repoA)[0]).toEqual({ + path: '/projects/a/worktrees', + nestWorkspaces: true + }) + expect(buildKnownOrcaWorkspaceLayouts(settings, repoB)[0]).toEqual({ + path: '/projects/b/worktrees', + nestWorkspaces: true + }) + expect( + classifyWorktreeOwnership({ + repo: repoA, + settings, + worktree: makeWorktree('/projects/a/worktrees/repo/feature'), + knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repoA) + }) + ).toBe('orca-managed') + expect( + classifyWorktreeOwnership({ + repo: repoB, + settings, + worktree: makeWorktree('/projects/a/worktrees/repo/feature'), + knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repoB) + }) + ).toBe('external') + }) + + it('uses repo-specific nested layouts for Windows-style paths', () => { + const repo = makeRepo({ + path: 'C:\\projects\\App\\repo', + worktreeBasePath: '..\\worktrees' + }) + const settings = makeSettings({ workspaceDir: 'D:\\global' }) + + expect( + classifyWorktreeOwnership({ + repo, + settings, + worktree: makeWorktree('C:\\projects\\App\\worktrees\\repo\\Feature'), + knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) + }) + ).toBe('orca-managed') + }) + + it('includes relative global layouts for SSH repos without applying absolute desktop paths', () => { + const repo = makeRepo({ path: '/remote/repo', connectionId: 'ssh-1' }) + const relativeSettings = makeSettings({ workspaceDir: '../worktrees' }) + const absoluteSettings = makeSettings({ workspaceDir: '/local/worktrees' }) + + expect(buildKnownOrcaWorkspaceLayouts(relativeSettings, repo)[0]).toEqual({ + path: '/remote/worktrees', + nestWorkspaces: true + }) + expect( + buildKnownOrcaWorkspaceLayouts(absoluteSettings, repo).some( + (layout) => layout.path === '/local/worktrees' + ) + ).toBe(false) + }) +}) diff --git a/src/shared/worktree-ownership.ts b/src/shared/worktree-ownership.ts index 813accb7f5c..dce5d9ff752 100644 --- a/src/shared/worktree-ownership.ts +++ b/src/shared/worktree-ownership.ts @@ -1,9 +1,11 @@ import { getRuntimePathBasename, + isRuntimePathAbsolute, isWindowsAbsolutePathLike, normalizeRuntimePathForComparison, normalizeRuntimePathSeparators, - relativePathInsideRoot + relativePathInsideRoot, + resolveRuntimePath } from './cross-platform-path' import { parseWslUncPath } from './wsl-paths' import type { @@ -44,12 +46,32 @@ export function effectiveExternalWorktreeVisibility( export function buildKnownOrcaWorkspaceLayouts( settings: Pick, - repo?: Pick + repo?: Pick ): OrcaWorkspaceLayout[] { const layouts: OrcaWorkspaceLayout[] = [] - if (!repo?.connectionId && settings.workspaceDir) { - layouts.push({ path: settings.workspaceDir, nestWorkspaces: settings.nestWorkspaces }) - appendWorkspaceLayouts(layouts, settings.workspaceDirHistory ?? []) + const repoBasePath = getRepoWorktreeBasePath(repo) + if (repo && repoBasePath) { + layouts.push({ + path: resolveWorkspaceLayoutPath(repo.path, repoBasePath), + nestWorkspaces: settings.nestWorkspaces + }) + } + if (settings.workspaceDir && shouldIncludeWorkspaceLayout(repo, settings.workspaceDir)) { + layouts.push({ + path: repo + ? resolveWorkspaceLayoutPath(repo.path, settings.workspaceDir) + : settings.workspaceDir, + nestWorkspaces: settings.nestWorkspaces + }) + appendWorkspaceLayouts( + layouts, + (settings.workspaceDirHistory ?? []) + .filter((layout) => shouldIncludeWorkspaceLayout(repo, layout.path)) + .map((layout) => ({ + ...layout, + path: repo ? resolveWorkspaceLayoutPath(repo.path, layout.path) : layout.path + })) + ) } const wslLayouts = repo ? buildWslWorkspaceLayouts(repo.path, settings) : [] @@ -77,6 +99,34 @@ function appendWorkspaceLayouts( } } +function getRepoWorktreeBasePath( + repo: Pick | undefined +): string | undefined { + const trimmed = repo?.worktreeBasePath?.trim() + return trimmed || undefined +} + +function resolveWorkspaceLayoutPath(repoPath: string, layoutPath: string): string { + return isRuntimePathAbsoluteForRepo(repoPath, layoutPath) + ? normalizeRuntimePathSeparators(layoutPath) + : resolveRuntimePath(repoPath, layoutPath) +} + +function isRuntimePathAbsoluteForRepo(repoPath: string, layoutPath: string): boolean { + const pathFlavor = + isWindowsAbsolutePathLike(repoPath) || isWindowsAbsolutePathLike(layoutPath) + ? 'windows' + : 'posix' + return isRuntimePathAbsolute(layoutPath, pathFlavor) +} + +function shouldIncludeWorkspaceLayout( + repo: Pick | undefined, + layoutPath: string +): boolean { + return !repo?.connectionId || !isRuntimePathAbsoluteForRepo(repo.path, layoutPath) +} + function buildWslWorkspaceLayouts( repoPath: string, settings: Pick