Add per-repo worktree base paths

This commit is contained in:
Neil
2026-05-31 05:06:56 -07:00
parent 0cfb3d3962
commit 1ad6f503fe
21 changed files with 668 additions and 112 deletions
+17 -4
View File
@@ -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
}
+12
View File
@@ -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
+76
View File
@@ -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', () => {
+115 -29
View File
@@ -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<GlobalSettings, 'nestWorkspaces' | 'workspaceDir'>
type WorktreeBasePathRepo = Pick<Repo, 'path' | 'worktreeBasePath'>
/**
* 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<Repo, 'worktreeBasePath'>): 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<typeof posix, 'basename' | 'isAbsolute' | 'join' | 'normalize'> {
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<Repo, 'worktreeBasePath'>): 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)
}
/**
+23 -26
View File
@@ -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<CreateWorktreeResult> {
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 } : {}),
+50 -6
View File
@@ -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
})
+2 -4
View File
@@ -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,
+13 -1
View File
@@ -527,7 +527,7 @@ function readLegacySidekickFlag(parsed: PersistedState | undefined): boolean | u
}
function sanitizeRepoUpdatesForPersistence<
T extends Partial<Pick<Repo, 'badgeColor' | 'repoIcon'>>
T extends Partial<Pick<Repo, 'badgeColor' | 'repoIcon' | 'worktreeBasePath'>>
>(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
+1 -1
View File
@@ -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({
+20 -16
View File
@@ -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,
+1
View File
@@ -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(),
+1
View File
@@ -662,6 +662,7 @@ export type PreloadApi = {
| 'repoIcon'
| 'hookSettings'
| 'worktreeBaseRef'
| 'worktreeBasePath'
| 'kind'
| 'issueSourcePreference'
| 'externalWorktreeVisibility'
@@ -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', () => {
@@ -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<string | null>(null)
const [copiedTemplate, setCopiedTemplate] = useState(false)
const copiedTemplateResetTimerRef = useRef<number | null>(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({
</SearchableSetting>
{!isFolder ? (
<SearchableSetting
title="Default Worktree Base"
description="Default base branch or ref when creating worktrees."
keywords={[repo.displayName, 'base ref', 'branch']}
className="space-y-3"
forceVisible={forceFullPaneForRepoMatch}
>
<Label className="text-sm font-semibold">Default Worktree Base</Label>
<BaseRefPicker
repoId={repo.id}
currentBaseRef={repo.worktreeBaseRef}
onSelect={(ref) => updateRepo(repo.id, { worktreeBaseRef: ref })}
onUsePrimary={() => updateRepo(repo.id, { worktreeBaseRef: undefined })}
/>
</SearchableSetting>
<>
<SearchableSetting
title="Default Worktree Base"
description="Default base branch or ref when creating worktrees."
keywords={[repo.displayName, 'base ref', 'branch']}
className="space-y-3"
forceVisible={forceFullPaneForRepoMatch}
>
<Label className="text-sm font-semibold">Default Worktree Base</Label>
<BaseRefPicker
repoId={repo.id}
currentBaseRef={repo.worktreeBaseRef}
onSelect={(ref) => updateRepo(repo.id, { worktreeBaseRef: ref })}
onUsePrimary={() => updateRepo(repo.id, { worktreeBaseRef: undefined })}
/>
</SearchableSetting>
<SearchableSetting
title="Worktree Location"
description="Project-specific directory for new worktrees."
keywords={[
repo.displayName,
'worktree path',
'workspace path',
'directory',
'relative',
'../worktrees'
]}
className="space-y-2"
forceVisible={forceFullPaneForRepoMatch}
>
<div className="flex items-center justify-between gap-3">
<Label className="text-sm font-semibold">Worktree Location</Label>
{repo.worktreeBasePath ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => updateRepo(repo.id, { worktreeBasePath: undefined })}
>
Use Global
</Button>
) : null}
</div>
<Input
value={repo.worktreeBasePath ?? ''}
placeholder={settings?.workspaceDir ?? ''}
onChange={(e) =>
updateRepo(repo.id, {
worktreeBasePath: e.target.value.trim() ? e.target.value : undefined
})
}
className="h-9 text-sm"
/>
<p className="text-xs text-muted-foreground">
Relative paths resolve from this project root.
</p>
</SearchableSetting>
</>
) : null}
</section>
) : null,
@@ -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.',
+4
View File
@@ -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
}
+22 -1
View File
@@ -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)
})
})
+78
View File
@@ -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 }
}
+2
View File
@@ -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
@@ -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> = {}): 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<Worktree, 'path' | 'isMainWorktree'> {
return { path, isMainWorktree: false }
}
function makeSettings(overrides: Partial<GlobalSettings> = {}): 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)
})
})
+55 -5
View File
@@ -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<GlobalSettings, 'workspaceDir' | 'nestWorkspaces' | 'workspaceDirHistory'>,
repo?: Pick<Repo, 'path' | 'connectionId'>
repo?: Pick<Repo, 'path' | 'connectionId' | 'worktreeBasePath'>
): 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<Repo, 'worktreeBasePath'> | 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<Repo, 'path' | 'connectionId'> | undefined,
layoutPath: string
): boolean {
return !repo?.connectionId || !isRuntimePathAbsoluteForRepo(repo.path, layoutPath)
}
function buildWslWorkspaceLayouts(
repoPath: string,
settings: Pick<GlobalSettings, 'nestWorkspaces' | 'workspaceDirHistory'>