Files
orca/src/main/ipc/worktree-logic.ts
T
NeilandOrca 46646d7ff1 chore(lint): upgrade oxlint to 1.71 + enable 7 new rules (autofixed backlog) (#6841)
* chore(lint): upgrade oxlint to 1.71 and enable 7 new rules

Upgrade oxlint 1.67.0 -> 1.71.0 (1.72 was blocked by the repo's 3-day
minimum-release-age supply-chain guard; nothing here needs it). The
bump is a no-op on the existing config.

Enable 3 error rules (backlog autofixed to zero in this commit) and
4 warn rules (surface signal without gating CI):

error (autofixed, behavior-preserving):
- unicorn/prefer-node-protocol        (~1531 sites: bare builtin -> node:)
- typescript/no-import-type-side-effects (~36: all-inline-type -> import type)
- unicorn/no-array-reverse            (19: copy-then-reverse -> toReversed)

warn (real signal, current fires are test-only/correct):
- unicorn/no-array-fill-with-reference-type  (aliasing footgun guard)
- typescript/no-unsafe-function-type         (bans bare Function type)
- unicorn/prefer-array-flat-map              (map().flat() -> flatMap())
- unicorn/prefer-regexp-test                 (.match() in bool ctx -> .test())

mobile/.oxlintrc.json extends root, so it inherits all 7; the autofix
ran from root and covered mobile/ too.

Verification (all green): oxlint 0 errors (root+mobile+aux configs),
oxfmt clean, typecheck (node+cli+web), vitest 22795 passed / 0 failed,
builds (electron-vite + web + cli) succeed. node: rewrites confirmed to
skip embedded SSH/CLI string payloads (AST-only); all toReversed sites
verified to operate on fresh copies or write-once locals.

* chore(lint): bump mobile oxlint to 1.71 so inherited rules parse

mobile/ is a standalone pnpm project pinning its own oxlint@1.67, which
lacks unicorn/no-array-fill-with-reference-type (needs >=1.70). Since
mobile/.oxlintrc.json extends the root config, mobile CI's 'cd mobile &&
oxlint' failed to parse the new rule. Bump mobile to match root (1.71).

Verified in mobile/: oxlint 0 errors, oxfmt --check clean, tsc --noEmit
pass, vitest 978 passed / 0 failed.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-06-29 22:38:29 -07:00

343 lines
12 KiB
TypeScript

import { resolve, relative, isAbsolute, posix, sep, win32 } from 'node:path'
import type { GlobalSettings, OrcaWorkspaceLayout, Repo } from '../../shared/types'
import { resolveRuntimePath } from '../../shared/cross-platform-path'
import { isWslUncPath } from '../../shared/wsl-paths'
import { splitWorktreeId } from '../../shared/worktree-id'
import { getWslHome, parseWslPath } from '../wsl'
type WorktreePathSettings = Pick<GlobalSettings, 'nestWorkspaces' | 'workspaceDir'>
type WorktreeBasePathRepo = Pick<Repo, 'path' | 'worktreeBasePath'>
export { computeBranchName, getConfiguredBranchPrefix } from './worktree-branch-name'
export { mergeWorktree } from './worktree-metadata-merge'
/**
* 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.
*/
export function sanitizeWorktreeName(input: string): string {
// Why: keep Unicode letters/numbers (CJK, accented Latin, etc.) so users can
// name workspaces in their own language. Git ref-format permits non-ASCII
// bytes, and modern filesystems handle UTF-8 paths. Only strip characters
// git or the filesystem actually rejects.
const sanitized = input
.trim()
.replace(/[^\p{L}\p{N}._-]+/gu, '-')
.replace(/-+/g, '-')
// Why: git check-ref-format rejects any ref containing `..`, so a prompt
// like "../../foo" that survives slugification as `..-..-foo` would
// produce a branch name git refuses to create. Collapse runs of dots
// to a single dot before the leading/trailing trim so internal `..`
// sequences can't reach git.
.replace(/\.{2,}/g, '.')
.replace(/^[.-]+|[.-]+$/g, '')
if (!sanitized || sanitized === '.' || sanitized === '..') {
throw new Error('Invalid worktree name')
}
return sanitized
}
export function sanitizeWorktreeDisplayName(input: string): string | undefined {
const withoutControls = Array.from(input, (char) => {
const code = char.charCodeAt(0)
return code <= 0x1f || (code >= 0x7f && code <= 0x9f) ? ' ' : char
}).join('')
const sanitized = withoutControls
// Why: titles come from external systems. Strip bidi override controls so a
// malicious title cannot visually reorder adjacent sidebar text.
.replace(/[\u202a-\u202e\u2066-\u2069]/g, '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 120)
.trim()
return sanitized || undefined
}
/**
* Ensure a target path is within the workspace directory (prevent path traversal).
*/
export function ensurePathWithinWorkspace(targetPath: string, workspaceDir: string): string {
const resolvedWorkspaceDir = resolve(workspaceDir)
const resolvedTargetPath = resolve(targetPath)
const rel = relative(resolvedWorkspaceDir, resolvedTargetPath)
if (isAbsolute(rel) || rel === '..' || rel.startsWith(`..${sep}`)) {
throw new Error('Invalid worktree path')
}
return resolvedTargetPath
}
/**
* Compute the filesystem path where the worktree directory will be created.
*
* Why WSL special case: when the repo lives on a WSL filesystem, worktrees
* must also live on the WSL filesystem. Creating them on the Windows side
* (/mnt/c/...) would be extremely slow due to cross-filesystem I/O and
* the terminal would open a Windows shell instead of WSL. We mirror the
* Windows workspace layout inside ~/orca/workspaces on the WSL filesystem
* (e.g. \\wsl.localhost\Ubuntu\home\user\orca\workspaces\repo\feature).
*/
export function computeWorktreePath(
sanitizedName: string,
repoPath: string,
settings: WorktreePathSettings
): string {
const workspaceRoot = computeWorkspaceRoot(repoPath, settings)
const pathOps = getRuntimePathOps(repoPath, workspaceRoot)
if (settings.nestWorkspaces) {
const repoName = pathOps.basename(repoPath).replace(/\.git$/, '')
return pathOps.join(workspaceRoot, repoName, 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(
leftPath: string,
rightPath: string,
platform = process.platform
): boolean {
if (looksLikePosixAbsolutePath(leftPath) || looksLikePosixAbsolutePath(rightPath)) {
// Why: local WSL projects run POSIX paths on a Windows desktop; comparing
// them with win32 rules can delete or dedupe the wrong runtime-owned path.
if (!looksLikePosixAbsolutePath(leftPath) || !looksLikePosixAbsolutePath(rightPath)) {
return false
}
const left = normalizePosixWorktreePathForComparison(leftPath, platform)
const right = normalizePosixWorktreePathForComparison(rightPath, platform)
return left === right
}
if (platform === 'win32' || looksLikeWindowsPath(leftPath) || looksLikeWindowsPath(rightPath)) {
const left = win32.normalize(win32.resolve(leftPath))
const right = win32.normalize(win32.resolve(rightPath))
// Why: `git worktree list` can report the same Windows path with different
// slash styles or drive-letter casing than the path we computed before
// creation. Orca must treat those as the same worktree or a successful
// create spuriously fails until the next full reload repopulates state.
return left.toLowerCase() === right.toLowerCase()
}
const left = normalizePosixWorktreePathForComparison(leftPath, platform)
const right = normalizePosixWorktreePathForComparison(rightPath, platform)
return left === right
}
function looksLikeWindowsPath(pathValue: string): boolean {
return (
/^[A-Za-z]:[\\/]/.test(pathValue) || pathValue.startsWith('\\\\') || pathValue.startsWith('//')
)
}
function looksLikePosixAbsolutePath(pathValue: string): boolean {
return pathValue.startsWith('/') && !pathValue.startsWith('//')
}
function normalizePosixWorktreePathForComparison(
pathValue: string,
platform: NodeJS.Platform
): string {
const normalized = posix.normalize(posix.resolve(pathValue))
if (platform !== 'darwin') {
return normalized
}
if (normalized === '/private/tmp') {
return '/tmp'
}
return normalized.startsWith('/private/tmp/') ? normalized.slice('/private'.length) : normalized
}
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 !isWslUncPath(workspaceDir)
}
/**
* Determine whether a display name should be persisted.
* A display name is set only when the user's requested name differs from
* both the branch name and the sanitized name (i.e. it was modified).
*/
export function shouldSetDisplayName(
requestedName: string,
branchName: string,
sanitizedName: string
): boolean {
return !(branchName === requestedName && sanitizedName === requestedName)
}
/**
* Parse a composite worktreeId ("repoId::worktreePath") into its parts.
*/
export function parseWorktreeId(worktreeId: string): { repoId: string; worktreePath: string } {
const parsed = splitWorktreeId(worktreeId)
if (!parsed) {
throw new Error(`Invalid worktreeId: ${worktreeId}`)
}
return parsed
}
/**
* Check whether a git error indicates the worktree is no longer tracked by git.
* This happens when a worktree's internal git tracking is removed (e.g. via
* `git worktree prune`) but the directory still exists on disk.
*/
export function isOrphanedWorktreeError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false
}
const msg = (error as { stderr?: string }).stderr || error.message
return /is not a working tree/.test(msg)
}
export function isWindowsLongPathWorktreeRemovalError(
error: unknown,
platform: NodeJS.Platform = process.platform
): boolean {
if (platform !== 'win32' || typeof error !== 'object' || error === null) {
return false
}
const errorWithDetails = error as { message?: unknown; stderr?: unknown; stdout?: unknown }
const details = [errorWithDetails.stderr, errorWithDetails.stdout, errorWithDetails.message]
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.join('\n')
// Why: Git for Windows has reported this failure through both stderr and the
// thrown message, with wording that varies between "filename" and "path".
return /(?:file ?name|path).{0,40}too long|too long.{0,40}(?:file ?name|path)/i.test(details)
}
export function isOrphanCompatiblePreflightError(error: unknown): boolean {
if (isOrphanedWorktreeError(error)) {
return true
}
if (!(error instanceof Error)) {
return false
}
const errorWithDetails = error as Error & { code?: unknown; stderr?: string; stdout?: string }
const details = [
errorWithDetails.stderr,
errorWithDetails.stdout,
errorWithDetails.message,
typeof errorWithDetails.code === 'string' ? errorWithDetails.code : undefined
]
.filter((value): value is string => Boolean(value))
.join('\n')
return /not a git repository/i.test(details) || /\bENOENT\b/i.test(details)
}
/**
* Format a human-readable error message for worktree removal failures.
*/
export function formatWorktreeRemovalError(
error: unknown,
worktreePath: string,
force: boolean
): string {
const fallback = force
? `Failed to force delete worktree at ${worktreePath}.`
: `Failed to delete worktree at ${worktreePath}.`
if (!(error instanceof Error)) {
return fallback
}
const errorWithStreams = error as Error & { stderr?: string; stdout?: string }
const details = [errorWithStreams.stderr, errorWithStreams.stdout, error.message]
.map((value) => value?.trim())
.find(Boolean)
return details ? `${fallback} ${details}` : fallback
}