fix(worktrees): expand a leading ~ in the worktree location (#35032)

A workspaceDir of "~/..." was never expanded, so it resolved relative to
each repo and worktrees were created under <repo>/~. Expand a leading ~
when settings are loaded and on every settings write, and share the
expansion with the floating-workspace path resolver.

Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
berce
2026-10-28 08:21:24 -07:00
committed by GitHub
co-authored by Neil
parent 4d8071abb4
commit db138baac0
7 changed files with 42 additions and 31 deletions
+11
View File
@@ -0,0 +1,11 @@
import { join, sep } from 'node:path'
export function expandLeadingHomePath(input: string, home: string): string {
if (input === '~') {
return home
}
if (input.startsWith(`~${sep}`) || (process.platform === 'win32' && input.startsWith('~/'))) {
return join(home, input.slice(2))
}
return input
}
+2 -16
View File
@@ -5,29 +5,15 @@ import { getAppEnvironment } from '../../shared/app-environment'
import type { GlobalSettings } from '../../shared/global-settings-types'
import type { FloatingTerminalCwdRequest } from '../../shared/ui-chrome-types'
import type { Store } from '../persistence'
import { resolveFloatingWorkspacePath } from '../persistence/restoring-sessions/floating-workspace-normalization'
type FloatingTerminalSettings = Pick<GlobalSettings, 'floatingTerminalTrustedCwds'>
const FLOATING_WORKSPACE_DIRNAME = 'floating-workspace'
function expandHomePath(input: string, home: string): string {
if (input === '~') {
return home
}
if (input.startsWith(`~${path.sep}`)) {
return path.join(home, input.slice(2))
}
if (process.platform === 'win32' && input.startsWith('~/')) {
return path.join(home, input.slice(2))
}
return input
}
function resolveFloatingWorkspaceInput(input: string): string {
// Headless runtime uses this path resolver without an Electron app.
const home = getAppEnvironment().getPath('home')
const expanded = expandHomePath(input, home)
return path.isAbsolute(expanded) ? path.resolve(expanded) : path.resolve(home, expanded)
return resolveFloatingWorkspacePath(input, getAppEnvironment().getPath('home'))
}
async function canonicalizeAccessibleDirectory(dirPath: string): Promise<string | null> {
@@ -12,7 +12,7 @@ import { makeTerminalTab } from '../persistence-session-fixtures'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { writeFileSync, rmSync, mkdtempSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { homedir, tmpdir } from 'node:os'
import type { GlobalSettings } from '../../../shared/global-settings-types'
import type { PersistedState } from '../../../shared/persisted-state-types'
import { getDefaultWorkspaceSession } from '../../../shared/constants'
@@ -112,6 +112,18 @@ describe('Store', () => {
).toBe(false)
})
it('expands a leading ~ in the worktree location on load and every settings write', async () => {
writeDataFile({ settings: { workspaceDir: '~/source/.orca/worktrees' } })
const store = await createStore()
expect(store.getSettings().workspaceDir).toBe(join(homedir(), 'source/.orca/worktrees'))
expect(store.updateSettings({ workspaceDir: '~' }).workspaceDir).toBe(homedir())
expect(store.updateSettings({ workspaceDir: '~/wt' }).workspaceDir).toBe(join(homedir(), 'wt'))
// Repo-relative locations and other names that start with ~ stay as written.
expect(store.updateSettings({ workspaceDir: '.worktrees' }).workspaceDir).toBe('.worktrees')
expect(store.updateSettings({ workspaceDir: '~wt' }).workspaceDir).toBe('~wt')
})
it('normalizes bot-author overrides on load and every settings write', async () => {
writeDataFile({
settings: {
@@ -42,6 +42,7 @@ import { normalizeNotificationSettings } from './onboarding-normalization'
import { retireLegacyInstructionsForClearedTextActionRecipes } from './source-control-settings'
import {
buildWorkspaceDirHistoryForUpdate,
expandWorkspaceDirHome,
stripRetiredGlobalSettings
} from './terminal-settings-migrations'
@@ -58,7 +59,7 @@ export function updateSettings(
updates: Partial<GlobalSettings>,
options: { notifyListeners?: boolean; originWebContentsId?: number } = {}
): GlobalSettings {
const sanitizedUpdates = stripRetiredGlobalSettings(updates)
const sanitizedUpdates = expandWorkspaceDirHome(stripRetiredGlobalSettings(updates))
const linkBehavior = updates.terminalLinkClickBehavior
if (linkBehavior === 'actions' || linkBehavior === 'open' || linkBehavior === 'none') {
// Loaded defaults are ambiguous; synchronize the legacy switch only on an explicit write.
@@ -1,3 +1,4 @@
import { homedir } from 'node:os'
import type { GlobalSettings, OrcaWorkspaceLayout } from '../../../shared/global-settings-types'
import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path'
import {
@@ -10,6 +11,14 @@ import {
normalizeTuiAgentArgsRecord,
normalizeTuiAgentEnvRecord
} from '../../../shared/tui-agent-launch-defaults'
import { expandLeadingHomePath } from '../../home-path-expansion'
/** Unexpanded, a leading ~ resolves inside each repo and worktrees land in <repo>/~ (#34944). */
export function expandWorkspaceDirHome(settings: Partial<GlobalSettings>): Partial<GlobalSettings> {
return typeof settings.workspaceDir === 'string'
? { ...settings, workspaceDir: expandLeadingHomePath(settings.workspaceDir, homedir()) }
: settings
}
export function buildWorkspaceDirHistoryForUpdate(
current: GlobalSettings,
@@ -11,6 +11,7 @@ import { projectSourceControlAiToLegacyCommitMessageAi } from '../../../shared/s
import { normalizeNativeChatShellEnvironmentVariables } from '../../../shared/native-chat/native-chat-shell-environment'
import { publishedLegacyChatPermissionMode } from '../../../shared/agent-chat-permission-mode'
import {
expandWorkspaceDirHome,
savedChatUiWithTerminalDefaultView,
stripRetiredGlobalSettings
} from '../applying-settings/terminal-settings-migrations'
@@ -64,7 +65,7 @@ export function normalizeLoadedGlobalSettings(
return {
...defaults.settings,
// Legacy stored booleans cannot distinguish opt-in from the old default; retain explicit true.
...stripRetiredGlobalSettings(parsed.settings),
...expandWorkspaceDirHome(stripRetiredGlobalSettings(parsed.settings)),
// Preserve terminal-first new tabs when retiring the old default-view switch.
...(savedChatUiWithTerminalDefaultView(parsed.settings)
? { experimentalNativeChat: false }
@@ -1,18 +1,9 @@
import { realpathSync, statSync } from 'node:fs'
import { isAbsolute, join, resolve, sep } from 'node:path'
export function expandFloatingWorkspaceHomePath(input: string, home: string): string {
if (input === '~') {
return home
}
if (input.startsWith(`~${sep}`) || (process.platform === 'win32' && input.startsWith('~/'))) {
return join(home, input.slice(2))
}
return input
}
import { isAbsolute, resolve } from 'node:path'
import { expandLeadingHomePath } from '../../home-path-expansion'
export function resolveFloatingWorkspacePath(input: string, home: string): string {
const expanded = expandFloatingWorkspaceHomePath(input, home)
const expanded = expandLeadingHomePath(input, home)
return isAbsolute(expanded) ? resolve(expanded) : resolve(home, expanded)
}