mirror of
https://github.com/stablyai/orca.git
synced 2026-10-15 08:03:08 +00:00
Add per-repo worktree base paths
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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'>
|
||||
|
||||
Reference in New Issue
Block a user