From 9d1cbf12595b7935006e5b62e8fc0f1ce318d5a2 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Mon, 5 Oct 2026 14:49:22 +0800 Subject: [PATCH 1/6] fix(repos): keep trailing-space directory names that really exist A trailing space is a legal POSIX directory name (Google Drive exports "foo/" from the cloud as "foo " locally), but the create-project and floating-workspace paths trimmed it off before validating: the picker's verified path was resolved as its trimmed spelling, so Orca created or looked for the wrong directory and `spawn git` failed with ENOENT. Resolve those paths raw-first: if the requested spelling exists, use it as-is; the trim only applies when it does not, which keeps the forgiving behavior for a typed stray space. The renderer now sends the picker's path untrimmed for the same reason. Windows is naturally unaffected (NTFS forbids trailing spaces, so the raw probe and the trimmed spelling coincide there). The cross-machine selector_not_found half of the issue is not addressed here: the worktree selector serialization chain was audited end to end (porcelain parsing, id minting/splitting, selector matching, profile keys) and preserves trailing spaces at every point, so no defect to fix was found on that path. Fixes #25386 Generated-by: GLM-5.3-Flash (ZCode) --- .../ipc/floating-workspace-directory.test.ts | 25 +++++++++ src/main/ipc/floating-workspace-directory.ts | 27 +++++++--- src/main/ipc/repos-create.test.ts | 36 +++++++++++++ src/main/ipc/repos/repo-creation-handlers.ts | 19 ++++++- ...ktree-selector-trailing-space-path.test.ts | 54 +++++++++++++++++++ .../src/components/sidebar/useCreateRepo.ts | 9 ++-- 6 files changed, 157 insertions(+), 13 deletions(-) create mode 100644 src/main/runtime/worktree-selector-trailing-space-path.test.ts diff --git a/src/main/ipc/floating-workspace-directory.test.ts b/src/main/ipc/floating-workspace-directory.test.ts index f10f16efdd6e..f0a4117bff81 100644 --- a/src/main/ipc/floating-workspace-directory.test.ts +++ b/src/main/ipc/floating-workspace-directory.test.ts @@ -165,6 +165,31 @@ describe('floating workspace directory', () => { ).resolves.toBe(path.join(userDataDir, 'floating-workspace')) }) + // A trailing space is a legal POSIX directory name (Google Drive exports them), so a + // picker-verified path must survive trust, settings sanitization and cwd resolution. + // On Windows this passes before and after the fix: NTFS strips trailing spaces at + // creation, and Win32 path APIs normalize the spellings back together. + it('keeps a directory whose name ends in a space through trust and resolution', async () => { + const store = createStore() + const selectedDir = path.join(tempRoot, 'gdrive notes ') + await mkdir(selectedDir) + const canonicalSelectedDir = await realpath(selectedDir) + + await trustFloatingWorkspaceDirectory(store, selectedDir) + + expect(store.settings.floatingTerminalTrustedCwds).toEqual([canonicalSelectedDir]) + + await expect( + resolveFloatingTerminalCwd(store as never, { + path: selectedDir, + requireTrusted: true + }) + ).resolves.toBe(canonicalSelectedDir) + await expect( + sanitizeFloatingWorkspaceDirectorySetting(store as never, selectedDir) + ).resolves.toBe(canonicalSelectedDir) + }) + it('still resolves accessible ad hoc terminal directories when trust is not required', async () => { const store = createStore() const arbitraryDir = path.join(tempRoot, 'terminal-only') diff --git a/src/main/ipc/floating-workspace-directory.ts b/src/main/ipc/floating-workspace-directory.ts index 3b1c1bdc1c87..115727dff073 100644 --- a/src/main/ipc/floating-workspace-directory.ts +++ b/src/main/ipc/floating-workspace-directory.ts @@ -29,10 +29,12 @@ async function canonicalizeAccessibleDirectory(dirPath: string): Promise { return new Set( (settings.floatingTerminalTrustedCwds ?? []) - .map((trustedPath) => trustedPath.trim()) .filter((trustedPath) => trustedPath.length > 0) .map(resolveFloatingWorkspaceInput) ) @@ -72,17 +74,23 @@ export async function resolveFloatingTerminalCwd( store: { getSettings(): FloatingTerminalSettings }, args?: FloatingTerminalCwdRequest ): Promise { - const configuredPath = typeof args?.path === 'string' ? args.path.trim() : '' - if (!configuredPath) { + const configuredPath = typeof args?.path === 'string' ? args.path : '' + if (!configuredPath.trim()) { return args?.requireTrusted === true ? ensureDefaultFloatingWorkspacePath() : resolveFloatingWorkspaceInput('~') } - const cwd = resolveFloatingWorkspaceInput(configuredPath) - const canonicalCwd = await canonicalizeAccessibleDirectory(cwd) + // Why raw first: a trailing space is a legal POSIX directory name, so a stored + // picker-verified path must resolve as typed; the trim only cleans typed typos. + let cwd = resolveFloatingWorkspaceInput(configuredPath) + let canonicalCwd = await canonicalizeAccessibleDirectory(cwd) if (!canonicalCwd) { - return ensureDefaultFloatingWorkspacePath() + cwd = resolveFloatingWorkspaceInput(configuredPath.trim()) + canonicalCwd = await canonicalizeAccessibleDirectory(cwd) + if (!canonicalCwd) { + return ensureDefaultFloatingWorkspacePath() + } } // Only picker-approved directories may become terminal cwd. @@ -122,8 +130,11 @@ export async function sanitizeFloatingWorkspaceDirectorySetting( if (trimmed === '~') { return '~' } - const resolvedDir = resolveFloatingWorkspaceInput(trimmed) - const canonicalDir = await canonicalizeAccessibleDirectory(resolvedDir) + // Why raw first: a trailing space is a legal POSIX directory name, so a picked + // directory must sanitize as typed; the trim only cleans typed typos. + const canonicalDir = + (await canonicalizeAccessibleDirectory(resolveFloatingWorkspaceInput(dirPath))) ?? + (await canonicalizeAccessibleDirectory(resolveFloatingWorkspaceInput(trimmed))) if (!canonicalDir || !isTrustedFloatingWorkspaceDirectory(canonicalDir, store.getSettings())) { return '' } diff --git a/src/main/ipc/repos-create.test.ts b/src/main/ipc/repos-create.test.ts index bf95a2ee5f70..042ec19926b4 100644 --- a/src/main/ipc/repos-create.test.ts +++ b/src/main/ipc/repos-create.test.ts @@ -253,6 +253,42 @@ describe('repos:create', () => { expect(mkdirMock).toHaveBeenNthCalledWith(2, tmpPath('brand-new'), { recursive: false }) }) + // A trailing space is a legal POSIX directory name (Google Drive exports them), so a + // picked parent that exists exactly as typed must win over the typed-typo trim. + it('creates inside a picked parent whose name ends in a space', async () => { + const spacedParent = '/tmp/gdrive repo ' + accessMock.mockImplementation(async (probed: string) => { + if (probed === spacedParent) { + return undefined + } + throw new Error('ENOENT') + }) + + const result = await callCreate({ parentPath: spacedParent, name: 'proj', kind: 'folder' }) + + expect(mkdirMock).toHaveBeenNthCalledWith(1, spacedParent, { recursive: true }) + expect(mkdirMock).toHaveBeenNthCalledWith(2, join(spacedParent, 'proj'), { recursive: false }) + expect(mockStore.addRepo).toHaveBeenCalledWith( + expect.objectContaining({ path: join(spacedParent, 'proj') }) + ) + expect(result).toHaveProperty('repo.path', join(spacedParent, 'proj')) + }) + + // The flip side: a typed typo whose trimmed spelling exists keeps the old cleanup. + it('falls back to the trimmed spelling when the raw parent does not exist', async () => { + accessMock.mockImplementation(async (probed: string) => { + if (probed === '/tmp/typo') { + return undefined + } + throw new Error('ENOENT') + }) + + const result = await callCreate({ parentPath: '/tmp/typo ', name: 'proj', kind: 'folder' }) + + expect(mkdirMock).toHaveBeenNthCalledWith(1, '/tmp/typo', { recursive: true }) + expect(result).toHaveProperty('repo.path', join('/tmp/typo', 'proj')) + }) + it('creates a missing default parent before creating the project directory', async () => { const result = await callCreate({ parentPath: defaultProjectParent, diff --git a/src/main/ipc/repos/repo-creation-handlers.ts b/src/main/ipc/repos/repo-creation-handlers.ts index c543995a761b..155977f5714b 100644 --- a/src/main/ipc/repos/repo-creation-handlers.ts +++ b/src/main/ipc/repos/repo-creation-handlers.ts @@ -49,6 +49,21 @@ function getDefaultCreateProjectParent(store: Store): string { return join(home, 'orca', 'projects') } +// Why: a trailing space is a legal POSIX directory name (Google Drive exports them), so a +// picker-returned parent that exists exactly as typed wins; the trim stays only for typed typos. +async function resolveCreateParentPath(requestedPath: string): Promise { + const trimmed = requestedPath.trim() + if (!trimmed || trimmed === requestedPath) { + return trimmed + } + try { + await access(requestedPath) + return requestedPath + } catch { + return trimmed + } +} + export function registerRepoCreationHandlers(mainWindow: BrowserWindow, store: Store): void { ipcMain.handle('repos:isGitAvailable', () => probeLocalGitAvailability()) ipcMain.handle('repos:getDefaultCreateProjectParent', () => getDefaultCreateProjectParent(store)) @@ -81,8 +96,8 @@ export function registerRepoCreationHandlers(mainWindow: BrowserWindow, store: S args: { parentPath: string; name: string; kind: 'git' | 'folder' } ): Promise<{ repo: Repo } | RepoCreateFailure> => { const name = args.name?.trim() ?? '' - const parentPath = args.parentPath?.trim() ?? '' - // Untrusted IPC kinds must not skip Git initialization yet persist as Git projects. + const parentPath = await resolveCreateParentPath(args.parentPath ?? '') + // Why: IPC input is untrusted — coerce to the narrow union so a bogus kind can't skip git init yet persist in the store. const repoKind: 'git' | 'folder' = args.kind === 'folder' ? 'folder' : 'git' if (!name) { diff --git a/src/main/runtime/worktree-selector-trailing-space-path.test.ts b/src/main/runtime/worktree-selector-trailing-space-path.test.ts new file mode 100644 index 000000000000..2c8e68cff214 --- /dev/null +++ b/src/main/runtime/worktree-selector-trailing-space-path.test.ts @@ -0,0 +1,54 @@ +// A trailing space is a legal POSIX directory name (Google Drive exports them), so the +// worktree selector chain — scan parsing, id minting/parsing, selector matching — must +// preserve it end to end. Characterization: these pass today; a red here means a +// serialization layer started eating the space (#25386 cross-machine selector_not_found). +import { describe, expect, it } from 'vitest' +import { parseWorktreeList } from '../../shared/git-worktree-porcelain-parser' +import { + splitWorktreeId, + worktreeIdComparisonKey, + worktreeIdsEqual +} from '../../shared/worktree/id' +import { runtimePathsEqual } from './runtime-worktree-path-identity' + +const REPO_ID = '01234567-89ab-cdef-0123-456789abcdef' +const SPACED_REPO_PATH = '/Users/me/gdrive repo ' +const SPACED_WORKTREE_PATH = '/Users/me/orca/workspaces/gdrive repo /feature ' + +describe('trailing-space paths survive the selector chain', () => { + it('parses a porcelain scan without trimming the path', () => { + const output = + `worktree ${SPACED_REPO_PATH}\nHEAD abc\nbranch refs/heads/main\n\n` + + `worktree ${SPACED_WORKTREE_PATH}\nHEAD def\nbranch refs/heads/feature\n` + + const worktrees = parseWorktreeList(output) + + expect(worktrees.map((worktree) => worktree.path)).toEqual([ + SPACED_REPO_PATH, + SPACED_WORKTREE_PATH + ]) + }) + + it('mints and re-splits worktree ids with the space intact', () => { + const worktreeId = `${REPO_ID}::${SPACED_WORKTREE_PATH}` + + expect(splitWorktreeId(worktreeId)).toEqual({ + repoId: REPO_ID, + worktreePath: SPACED_WORKTREE_PATH + }) + expect(worktreeIdsEqual(worktreeId, `${REPO_ID}::${SPACED_WORKTREE_PATH}`)).toBe(true) + expect(worktreeIdComparisonKey(worktreeId)).toBe( + `${REPO_ID}::${SPACED_WORKTREE_PATH.normalize('NFC')}` + ) + }) + + it('matches path selectors byte-exact on the trailing space', () => { + // Interior duplicate slashes fold; the trailing space must survive the fold. + expect( + runtimePathsEqual(SPACED_WORKTREE_PATH, SPACED_WORKTREE_PATH.replace('/Users/', '/Users//')) + ).toBe(true) + expect(runtimePathsEqual(SPACED_WORKTREE_PATH, SPACED_WORKTREE_PATH.replace(/ $/, ''))).toBe( + false + ) + }) +}) diff --git a/src/renderer/src/components/sidebar/useCreateRepo.ts b/src/renderer/src/components/sidebar/useCreateRepo.ts index 5e310884f398..bb86b6a771e6 100644 --- a/src/renderer/src/components/sidebar/useCreateRepo.ts +++ b/src/renderer/src/components/sidebar/useCreateRepo.ts @@ -80,11 +80,14 @@ export function useCreateRepo( const handleCreate = useCallback(async () => { const name = createName.trim() - const parentPath = createParent.trim() - // Block unresolved hosts so creation cannot silently fall back to this computer. - if (!name || !parentPath || options.hostId === null) { + // Why: null means the chosen host is unresolved (e.g. its server is coming up); without a + // host id the request below would run on this computer. + if (!name || !createParent.trim() || options.hostId === null) { return } + // Why untrimmed: the picker returns a verified path and a trailing space can be the + // real directory name; main resolves raw-first and only trims typed typos. + const parentPath = createParent const requestHostToken = hostTokenRef.current const gen = ++createGenRef.current const isCurrent = (): boolean => From 1d4ca147c654b17ef32e5b4d2fe864d603a7eb27 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Mon, 5 Oct 2026 15:32:32 +0800 Subject: [PATCH 2/6] fix(repos): only treat ENOENT/ENOTDIR as path-absent when probing the raw parent The bare catch in resolveCreateParentPath treated every access failure as "the raw spelling does not exist", so an EACCES/EPERM/ELOOP silently fell back to the trimmed path and the project could be created in a directory the user did not pick. Non-absence errors now propagate and the handler surfaces them as its existing { error } shape; the fallback only applies to genuine absence. Message-based detection keeps the existing code-less ENOENT test mocks working. Generated-by: GLM-5.3-Flash (ZCode) --- src/main/ipc/repos-create.test.ts | 15 ++++++++++ src/main/ipc/repos/repo-creation-handlers.ts | 30 ++++++++++++++++++-- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/main/ipc/repos-create.test.ts b/src/main/ipc/repos-create.test.ts index 042ec19926b4..7405a9d6eecd 100644 --- a/src/main/ipc/repos-create.test.ts +++ b/src/main/ipc/repos-create.test.ts @@ -289,6 +289,21 @@ describe('repos:create', () => { expect(result).toHaveProperty('repo.path', join('/tmp/typo', 'proj')) }) + // A probe failure that is not "absent" (EACCES, ELOOP...) must surface as an error, + // not silently fall back to the trimmed spelling and create in an unchosen directory. + it('returns an error when probing the raw parent fails with EACCES', async () => { + const lockedParent = '/tmp/locked repo ' + accessMock.mockRejectedValue( + Object.assign(new Error('EACCES: permission denied, access'), { code: 'EACCES' }) + ) + + const result = await callCreate({ parentPath: lockedParent, name: 'proj', kind: 'folder' }) + + expect(result).toMatchObject({ error: expect.stringContaining('EACCES') }) + expect(mkdirMock).not.toHaveBeenCalled() + expect(mockStore.addRepo).not.toHaveBeenCalled() + }) + it('creates a missing default parent before creating the project directory', async () => { const result = await callCreate({ parentPath: defaultProjectParent, diff --git a/src/main/ipc/repos/repo-creation-handlers.ts b/src/main/ipc/repos/repo-creation-handlers.ts index 155977f5714b..395e14da1f33 100644 --- a/src/main/ipc/repos/repo-creation-handlers.ts +++ b/src/main/ipc/repos/repo-creation-handlers.ts @@ -49,6 +49,21 @@ function getDefaultCreateProjectParent(store: Store): string { return join(home, 'orca', 'projects') } +// Only ENOENT/ENOTDIR prove the probed path is absent (ENOTDIR: a parent component is a +// file); EACCES/ELOOP etc. must not trigger the trim fallback. Tests reject plain Errors +// without a code, so an ENOENT/ENOTDIR-looking message counts too. +function isAbsentPathError(err: unknown): boolean { + const code = + err && typeof err === 'object' && 'code' in err + ? (err as NodeJS.ErrnoException).code + : undefined + return ( + code === 'ENOENT' || + code === 'ENOTDIR' || + (code === undefined && err instanceof Error && /ENOENT|ENOTDIR/.test(err.message)) + ) +} + // Why: a trailing space is a legal POSIX directory name (Google Drive exports them), so a // picker-returned parent that exists exactly as typed wins; the trim stays only for typed typos. async function resolveCreateParentPath(requestedPath: string): Promise { @@ -59,7 +74,10 @@ async function resolveCreateParentPath(requestedPath: string): Promise { try { await access(requestedPath) return requestedPath - } catch { + } catch (err) { + if (!isAbsentPathError(err)) { + throw err + } return trimmed } } @@ -96,7 +114,15 @@ export function registerRepoCreationHandlers(mainWindow: BrowserWindow, store: S args: { parentPath: string; name: string; kind: 'git' | 'folder' } ): Promise<{ repo: Repo } | RepoCreateFailure> => { const name = args.name?.trim() ?? '' - const parentPath = await resolveCreateParentPath(args.parentPath ?? '') + let parentPath: string + try { + parentPath = await resolveCreateParentPath(args.parentPath ?? '') + } catch (err) { + // Why: an unexpected probe failure (EACCES...) must not fall back to the trimmed + // spelling and create the project in a directory the user did not pick. + const message = err instanceof Error ? err.message : String(err) + return { error: `Cannot access parent directory: ${message}` } + } // Why: IPC input is untrusted — coerce to the narrow union so a bogus kind can't skip git init yet persist in the store. const repoKind: 'git' | 'folder' = args.kind === 'folder' ? 'folder' : 'git' From ea7ed7aeb15a3b78c94de20015f32c01e5028b33 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Thu, 8 Oct 2026 21:57:02 +0800 Subject: [PATCH 3/6] fix(repos): preserve raw trailing-space parents in runtime and SSH creation useCreateRepo now sends the parent as typed, but the runtime registration controller and createRemoteRepo still trimmed it, so a valid parent whose name ends with a space resolved to a different directory. Both creation paths now probe the raw spelling first and fall back to the trim only when the raw path is genuinely absent (ENOENT); other probe failures surface instead of silently creating the project under the trimmed spelling. The SSH probe reuses isENOENT, which already covers relay errors that cross as canonical ENOENT messages, and re-expands ~ for both spellings. Also adds the SAFETY: rationale the changed-casting gate requires to an existing as-never in floating-workspace-directory.test.ts flagged by the same scan. Generated-by: GLM-5.3-Flash (ZCode) --- .../ipc/floating-workspace-directory.test.ts | 1 + ...time-repository-registration-controller.ts | 40 +++++++-- ...ime-repository-registration-create.test.ts | 84 +++++++++++++++++++ .../sidebar/repo-create-error-copy.ts | 6 ++ .../src/i18n/en-runtime-required.json | 1 + src/renderer/src/i18n/locales/de.json | 1 + src/renderer/src/i18n/locales/en.json | 1 + src/renderer/src/i18n/locales/es.json | 1 + src/renderer/src/i18n/locales/fr.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/pt.json | 1 + src/renderer/src/i18n/locales/ru.json | 1 + src/renderer/src/i18n/locales/tr.json | 1 + src/renderer/src/i18n/locales/zh-Hant.json | 1 + src/renderer/src/i18n/locales/zh.json | 1 + src/shared/repo-create-failure-copy.ts | 1 + 17 files changed, 138 insertions(+), 6 deletions(-) create mode 100644 src/main/runtime/runtime-repository-registration-create.test.ts diff --git a/src/main/ipc/floating-workspace-directory.test.ts b/src/main/ipc/floating-workspace-directory.test.ts index f0a4117bff81..be7614cb857d 100644 --- a/src/main/ipc/floating-workspace-directory.test.ts +++ b/src/main/ipc/floating-workspace-directory.test.ts @@ -186,6 +186,7 @@ describe('floating workspace directory', () => { }) ).resolves.toBe(canonicalSelectedDir) await expect( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the sanitizer only reads the settings members createStore() stubs. sanitizeFloatingWorkspaceDirectorySetting(store as never, selectedDir) ).resolves.toBe(canonicalSelectedDir) }) diff --git a/src/main/runtime/runtime-repository-registration-controller.ts b/src/main/runtime/runtime-repository-registration-controller.ts index 4da48b8d8a7c..0e1a8e0c6992 100644 --- a/src/main/runtime/runtime-repository-registration-controller.ts +++ b/src/main/runtime/runtime-repository-registration-controller.ts @@ -33,6 +33,26 @@ type RuntimeRepositoryRegistrationDependencies = { notifyReposChanged: () => void } +// Why: a trailing space is a legal directory name (Google Drive exports them), so a +// picker-returned parent that exists exactly as requested wins; the trim stays only +// for typed typos. Only a genuinely absent raw spelling may fall back to the trim — +// other probe failures must surface instead of silently relocating the project. +async function resolveCreateParentPathRawFirst(requestedPath: string): Promise { + const trimmed = requestedPath.trim() + if (!trimmed || trimmed === requestedPath) { + return trimmed + } + try { + await stat(requestedPath) + return requestedPath + } catch (error) { + if (!isENOENT(error)) { + throw error + } + return trimmed + } +} + export class RuntimeRepositoryRegistrationController { constructor(private readonly deps: RuntimeRepositoryRegistrationDependencies) {} @@ -124,7 +144,15 @@ export class RuntimeRepositoryRegistrationController { > { const store = this.requireStore() const trimmedName = name.trim() - const trimmedParentPath = parentPath.trim() + let parentDir: string + try { + parentDir = await resolveCreateParentPathRawFirst(parentPath) + } catch (error) { + // Why: an unexpected probe failure (EACCES...) must not fall back to the trimmed + // spelling and create the project in a directory the user did not pick. + const message = error instanceof Error ? error.message : String(error) + return repoCreateFailure('parentInaccessible', { detail: message }) + } const repoKind: 'git' | 'folder' = kind === 'folder' ? 'folder' : 'git' if (!trimmedName) { return repoCreateFailure('nameEmpty') @@ -132,21 +160,21 @@ export class RuntimeRepositoryRegistrationController { if (/[\\/]/.test(trimmedName) || trimmedName === '.' || trimmedName === '..') { return repoCreateFailure('nameInvalid') } - if (!trimmedParentPath) { + if (!parentDir) { return repoCreateFailure('parentRequired') } - if (!isAbsolute(trimmedParentPath)) { + if (!isAbsolute(parentDir)) { return repoCreateFailure('parentNotAbsolute') } try { - await refuseWslHostWindowsDrivePath(trimmedParentPath) + await refuseWslHostWindowsDrivePath(parentDir) } catch (error) { // An optional field: older clients read only `error`. return error instanceof WslHostWindowsDriveRefusalError ? { error: error.message, windowsDriveRefusal: error.data } : { error: error instanceof Error ? error.message : String(error) } } - const targetPath = join(trimmedParentPath, trimmedName) + const targetPath = join(parentDir, trimmedName) // Qualify by host: identical SSH paths name another machine's directory. const findLocalAtTarget = (): Repo | undefined => store @@ -162,7 +190,7 @@ export class RuntimeRepositoryRegistrationController { let createdDir = false try { - await mkdir(trimmedParentPath, { recursive: true }) + await mkdir(parentDir, { recursive: true }) const existingStat = await stat(targetPath).catch((error: unknown) => { if (isENOENT(error)) { return null diff --git a/src/main/runtime/runtime-repository-registration-create.test.ts b/src/main/runtime/runtime-repository-registration-create.test.ts new file mode 100644 index 000000000000..523e7fd03e5c --- /dev/null +++ b/src/main/runtime/runtime-repository-registration-create.test.ts @@ -0,0 +1,84 @@ +/** + * Pins the raw-first parent resolution of RuntimeRepositoryRegistrationController.create: + * a picker-returned parent with a trailing space (legal POSIX name) must be used as + * typed when it exists, with the trim kept only for typed typos (#25386). + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const { prepareLocalWorktreeRootForRepoMock, detectRepoIconAndUpstreamMock } = vi.hoisted(() => ({ + prepareLocalWorktreeRootForRepoMock: vi.fn(async () => undefined), + detectRepoIconAndUpstreamMock: vi.fn(async () => ({})) +})) + +vi.mock('../repo-icon-autodetect', () => ({ + detectRepoIconAndUpstream: detectRepoIconAndUpstreamMock +})) + +vi.mock('../worktree-root-preparation', () => ({ + prepareLocalWorktreeRootForRepo: prepareLocalWorktreeRootForRepoMock +})) + +import { RuntimeRepositoryRegistrationController } from './runtime-repository-registration-controller' + +function makeController(): RuntimeRepositoryRegistrationController { + return new RuntimeRepositoryRegistrationController({ + getStore: () => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: create() only reads the repo members stubbed here. + ({ + getRepos: () => [], + addRepo: () => undefined, + getRepo: () => undefined, + updateRepo: () => undefined + }) as never, + invalidateResolvedWorktrees: () => undefined, + invalidateWorktreeScan: () => undefined, + notifyReposChanged: () => undefined + }) +} + +describe('RuntimeRepositoryRegistrationController.create parent resolution', () => { + let base: string + + beforeEach(() => { + base = mkdtempSync(join(tmpdir(), 'orca-repo-create-')) + }) + + afterEach(() => { + rmSync(base, { recursive: true, force: true }) + }) + + it('creates inside a trailing-space parent that exists exactly as requested', async () => { + const parent = join(base, 'cloud dir ') + mkdirSync(parent) + const result = await makeController().create(parent, 'kid', 'folder') + expect('error' in result && result.error).toBeFalsy() + if (!('error' in result)) { + expect(result.repo.path).toBe(join(parent, 'kid')) + } + }) + + it('falls back to the trimmed spelling when the raw parent does not exist', async () => { + const requested = join(base, 'typo dir ') + const result = await makeController().create(requested, 'kid', 'folder') + expect('error' in result && result.error).toBeFalsy() + if (!('error' in result)) { + expect(result.repo.path).toBe(join(base, 'typo dir', 'kid')) + } + }) + + it('surfaces a non-absent probe failure instead of creating at the trimmed spelling', async () => { + const blocker = join(base, 'blocker') + writeFileSync(blocker, 'a file, not a directory') + const requested = join(blocker, 'cloud dir ') + const result = await makeController().create(requested, 'kid', 'folder') + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.error).toMatch(/Cannot access parent directory/) + expect(result.errorCode).toBe('parentInaccessible') + } + }) +}) diff --git a/src/renderer/src/components/sidebar/repo-create-error-copy.ts b/src/renderer/src/components/sidebar/repo-create-error-copy.ts index 0b32b726098e..8c0d32870a2e 100644 --- a/src/renderer/src/components/sidebar/repo-create-error-copy.ts +++ b/src/renderer/src/components/sidebar/repo-create-error-copy.ts @@ -14,6 +14,12 @@ const COPY: Record string | null> parentRequired: () => translate('sidebar.createProject.errors.parentRequired', EN.parentRequired), parentNotAbsolute: () => translate('sidebar.createProject.errors.parentNotAbsolute', EN.parentNotAbsolute), + parentInaccessible: ({ detail }) => + detail === null + ? null + : translate('sidebar.createProject.errors.parentInaccessible', EN.parentInaccessible, { + detail + }), targetInaccessible: ({ detail }) => detail === null ? null diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 6845fd48e782..27c6470722ef 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2235,6 +2235,7 @@ "initialCommitFailed": "Failed to create initial commit: {{detail}}", "nameEmpty": "Name cannot be empty", "nameInvalid": "Name cannot contain slashes or be \".\" / \"..\"", + "parentInaccessible": "Cannot access parent directory: {{detail}}", "parentNotAbsolute": "Parent directory must be an absolute path", "parentRequired": "Parent directory is required", "prepareDirectoryFailed": "Failed to prepare directory: {{detail}}", diff --git a/src/renderer/src/i18n/locales/de.json b/src/renderer/src/i18n/locales/de.json index c2ddf2a471d2..4be420d90996 100644 --- a/src/renderer/src/i18n/locales/de.json +++ b/src/renderer/src/i18n/locales/de.json @@ -109,6 +109,7 @@ "nameInvalid": "Name darf weder Schrägstriche enthalten noch \".\" / \"..\" sein", "parentRequired": "Übergeordnetes Verzeichnis ist erforderlich", "parentNotAbsolute": "Übergeordnetes Verzeichnis muss ein absoluter Pfad sein", + "parentInaccessible": "Zugriff auf übergeordnetes Verzeichnis nicht möglich: {{detail}}", "targetInaccessible": "Zugriff auf Zielpfad nicht möglich: {{detail}}", "targetNotEmpty": "„{{name}}“ existiert bereits an diesem Ort und ist nicht leer.", "targetNotFolder": "„{{name}}“ existiert bereits an diesem Ort und ist kein Ordner.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index b250a0bb90e4..55b5a0384279 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -109,6 +109,7 @@ "nameInvalid": "Name cannot contain slashes or be \".\" / \"..\"", "parentRequired": "Parent directory is required", "parentNotAbsolute": "Parent directory must be an absolute path", + "parentInaccessible": "Cannot access parent directory: {{detail}}", "targetInaccessible": "Cannot access target path: {{detail}}", "targetNotEmpty": "\"{{name}}\" already exists at this location and is not empty.", "targetNotFolder": "\"{{name}}\" already exists at this location and is not a folder.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 78e092680bca..8914f8dc81d5 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -20927,6 +20927,7 @@ "nameInvalid": "El nombre no puede contener barras ni ser \".\" / \"..\"", "parentRequired": "Se requiere un directorio padre", "parentNotAbsolute": "El directorio padre debe ser una ruta absoluta", + "parentInaccessible": "No se puede acceder al directorio padre: {{detail}}", "targetInaccessible": "No se puede acceder a la ruta de destino: {{detail}}", "targetNotEmpty": "\"{{name}}\" ya existe en esta ubicación y no está vacío.", "targetNotFolder": "\"{{name}}\" ya existe en esta ubicación y no es una carpeta.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 43b585c934b9..5c04bae8bf5c 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -20685,6 +20685,7 @@ "nameInvalid": "Le nom ne peut pas contenir de barres obliques ni être \".\" / \"..\"", "parentRequired": "Le répertoire parent est requis", "parentNotAbsolute": "Le répertoire parent doit être un chemin absolu", + "parentInaccessible": "Impossible d’accéder au répertoire parent : {{detail}}", "targetInaccessible": "Impossible d’accéder au chemin cible : {{detail}}", "targetNotEmpty": "« {{name}} » existe déjà à cet emplacement et n’est pas vide.", "targetNotFolder": "« {{name}} » existe déjà à cet emplacement et n’est pas un dossier.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index a113a54b4237..55b6977bec55 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -20009,6 +20009,7 @@ "nameInvalid": "名前にスラッシュは使用できず、\".\" / \"..\" にもできません", "parentRequired": "親ディレクトリは必須です", "parentNotAbsolute": "親ディレクトリは絶対パスである必要があります", + "parentInaccessible": "親ディレクトリにアクセスできません: {{detail}}", "targetInaccessible": "対象パスにアクセスできません: {{detail}}", "targetNotEmpty": "この場所にはすでに「{{name}}」が存在し、空ではありません。", "targetNotFolder": "この場所にはすでに「{{name}}」が存在し、フォルダーではありません。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 8075b7fff3e2..f93d1acfe513 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -20009,6 +20009,7 @@ "nameInvalid": "이름에 슬래시를 포함하거나 \".\" / \"..\"로 지정할 수 없습니다", "parentRequired": "상위 디렉터리가 필요합니다", "parentNotAbsolute": "상위 디렉터리는 절대 경로여야 합니다", + "parentInaccessible": "상위 디렉터리에 접근할 수 없습니다: {{detail}}", "targetInaccessible": "대상 경로에 접근할 수 없습니다: {{detail}}", "targetNotEmpty": "이 위치에 \"{{name}}\"이(가) 이미 있으며 비어 있지 않습니다.", "targetNotFolder": "이 위치에 \"{{name}}\"이(가) 이미 있으며 폴더가 아닙니다.", diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index 2e8eac2a3eac..08a8347acb22 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -462,6 +462,7 @@ "nameInvalid": "O nome não pode conter barras nem ser \".\" / \"..\"", "parentRequired": "O diretório pai é obrigatório", "parentNotAbsolute": "O diretório pai deve ser um caminho absoluto", + "parentInaccessible": "Não é possível acessar o diretório pai: {{detail}}", "targetInaccessible": "Não é possível acessar o caminho de destino: {{detail}}", "targetNotEmpty": "\"{{name}}\" já existe neste local e não está vazio.", "targetNotFolder": "\"{{name}}\" já existe neste local e não é uma pasta.", diff --git a/src/renderer/src/i18n/locales/ru.json b/src/renderer/src/i18n/locales/ru.json index bee849a6bd48..ab64f060f1d9 100644 --- a/src/renderer/src/i18n/locales/ru.json +++ b/src/renderer/src/i18n/locales/ru.json @@ -109,6 +109,7 @@ "nameInvalid": "Имя не может содержать косые черты или быть \".\" / \"..\"", "parentRequired": "Необходимо указать родительскую папку", "parentNotAbsolute": "Путь к родительской папке должен быть абсолютным", + "parentInaccessible": "Не удалось получить доступ к родительской папке: {{detail}}", "targetInaccessible": "Не удалось получить доступ к целевому пути: {{detail}}", "targetNotEmpty": "«{{name}}» уже существует в этом расположении и содержит данные.", "targetNotFolder": "«{{name}}» уже существует в этом расположении и не является папкой.", diff --git a/src/renderer/src/i18n/locales/tr.json b/src/renderer/src/i18n/locales/tr.json index f7063e33d347..23992b946073 100644 --- a/src/renderer/src/i18n/locales/tr.json +++ b/src/renderer/src/i18n/locales/tr.json @@ -109,6 +109,7 @@ "nameInvalid": "Ad eğik çizgi içeremez veya \".\" / \"..\" olamaz", "parentRequired": "Üst dizin gereklidir", "parentNotAbsolute": "Üst dizin mutlak bir yol olmalıdır", + "parentInaccessible": "Üst dizine erişilemiyor: {{detail}}", "targetInaccessible": "Hedef yola erişilemiyor: {{detail}}", "targetNotEmpty": "\"{{name}}\" bu konumda zaten var ve boş değil.", "targetNotFolder": "\"{{name}}\" bu konumda zaten var ve bir klasör değil.", diff --git a/src/renderer/src/i18n/locales/zh-Hant.json b/src/renderer/src/i18n/locales/zh-Hant.json index 241ad34cc053..1d601348b95b 100644 --- a/src/renderer/src/i18n/locales/zh-Hant.json +++ b/src/renderer/src/i18n/locales/zh-Hant.json @@ -109,6 +109,7 @@ "nameInvalid": "名稱不可包含斜線,也不可為 \".\" / \"..\"", "parentRequired": "必須指定上層目錄", "parentNotAbsolute": "上層目錄必須是絕對路徑", + "parentInaccessible": "無法存取上層目錄:{{detail}}", "targetInaccessible": "無法存取目標路徑:{{detail}}", "targetNotEmpty": "此位置已存在「{{name}}」,且內容不為空。", "targetNotFolder": "此位置已存在「{{name}}」,且不是資料夾。", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index cc9618aa98a6..5ed535c4e8f3 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -20010,6 +20010,7 @@ "nameInvalid": "名称不能包含斜杠,也不能是 \".\" / \"..\"", "parentRequired": "必须指定父目录", "parentNotAbsolute": "父目录必须为绝对路径", + "parentInaccessible": "无法访问父目录:{{detail}}", "targetInaccessible": "无法访问目标路径:{{detail}}", "targetNotEmpty": "此位置已存在“{{name}}”,且不为空。", "targetNotFolder": "此位置已存在“{{name}}”,且不是文件夹。", diff --git a/src/shared/repo-create-failure-copy.ts b/src/shared/repo-create-failure-copy.ts index e066e270136d..714284d97cb8 100644 --- a/src/shared/repo-create-failure-copy.ts +++ b/src/shared/repo-create-failure-copy.ts @@ -4,6 +4,7 @@ export const REPO_CREATE_FAILURE_COPY = { nameInvalid: 'Name cannot contain slashes or be "." / ".."', parentRequired: 'Parent directory is required', parentNotAbsolute: 'Parent directory must be an absolute path', + parentInaccessible: 'Cannot access parent directory: {{detail}}', targetInaccessible: 'Cannot access target path: {{detail}}', targetNotEmpty: '"{{name}}" already exists at this location and is not empty.', targetNotFolder: '"{{name}}" already exists at this location and is not a folder.', From 259fdc24b63b20cf942da631861d993f01bd6cdc Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Sun, 25 Oct 2026 22:07:22 +0800 Subject: [PATCH 4/6] test(repos): follow the worktreeIdentityKey export rename Upstream renamed worktreeIdComparisonKey to worktreeIdentityKey in shared/worktree/id; the characterization test now uses the current name. Generated-by: GLM-5.3-Flash (ZCode) --- .../runtime/worktree-selector-trailing-space-path.test.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/main/runtime/worktree-selector-trailing-space-path.test.ts b/src/main/runtime/worktree-selector-trailing-space-path.test.ts index 2c8e68cff214..9974bb06983a 100644 --- a/src/main/runtime/worktree-selector-trailing-space-path.test.ts +++ b/src/main/runtime/worktree-selector-trailing-space-path.test.ts @@ -4,11 +4,7 @@ // serialization layer started eating the space (#25386 cross-machine selector_not_found). import { describe, expect, it } from 'vitest' import { parseWorktreeList } from '../../shared/git-worktree-porcelain-parser' -import { - splitWorktreeId, - worktreeIdComparisonKey, - worktreeIdsEqual -} from '../../shared/worktree/id' +import { splitWorktreeId, worktreeIdentityKey, worktreeIdsEqual } from '../../shared/worktree/id' import { runtimePathsEqual } from './runtime-worktree-path-identity' const REPO_ID = '01234567-89ab-cdef-0123-456789abcdef' @@ -37,7 +33,7 @@ describe('trailing-space paths survive the selector chain', () => { worktreePath: SPACED_WORKTREE_PATH }) expect(worktreeIdsEqual(worktreeId, `${REPO_ID}::${SPACED_WORKTREE_PATH}`)).toBe(true) - expect(worktreeIdComparisonKey(worktreeId)).toBe( + expect(worktreeIdentityKey(worktreeId)).toBe( `${REPO_ID}::${SPACED_WORKTREE_PATH.normalize('NFC')}` ) }) From 0fa50b23c0e4a37ace3087bdb035bdf4495d9828 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Thu, 29 Oct 2026 21:01:34 +0800 Subject: [PATCH 5/6] fix(repos): report the raw-parent probe failure with the shared create-failure code Rebase adaptation for the i18n'd Create project failures: the IPC handler's raw-parent probe error now returns repoCreateFailure('parentInaccessible', ...) like the runtime controller, so the renderer can translate by code while older clients still read the same English `error` text. The EACCES regression test pins the coded shape. Generated-by: GLM-5.3-Flash (ZCode) --- src/main/ipc/repos-create.test.ts | 6 +++++- src/main/ipc/repos/repo-creation-handlers.ts | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/ipc/repos-create.test.ts b/src/main/ipc/repos-create.test.ts index 7405a9d6eecd..8fa8e4c50dd9 100644 --- a/src/main/ipc/repos-create.test.ts +++ b/src/main/ipc/repos-create.test.ts @@ -299,7 +299,11 @@ describe('repos:create', () => { const result = await callCreate({ parentPath: lockedParent, name: 'proj', kind: 'folder' }) - expect(result).toMatchObject({ error: expect.stringContaining('EACCES') }) + expect(result).toEqual({ + error: 'Cannot access parent directory: EACCES: permission denied, access', + errorCode: 'parentInaccessible', + errorDetail: 'EACCES: permission denied, access' + }) expect(mkdirMock).not.toHaveBeenCalled() expect(mockStore.addRepo).not.toHaveBeenCalled() }) diff --git a/src/main/ipc/repos/repo-creation-handlers.ts b/src/main/ipc/repos/repo-creation-handlers.ts index 395e14da1f33..d13c9b28f2a5 100644 --- a/src/main/ipc/repos/repo-creation-handlers.ts +++ b/src/main/ipc/repos/repo-creation-handlers.ts @@ -121,7 +121,7 @@ export function registerRepoCreationHandlers(mainWindow: BrowserWindow, store: S // Why: an unexpected probe failure (EACCES...) must not fall back to the trimmed // spelling and create the project in a directory the user did not pick. const message = err instanceof Error ? err.message : String(err) - return { error: `Cannot access parent directory: ${message}` } + return repoCreateFailure('parentInaccessible', { detail: message }) } // Why: IPC input is untrusted — coerce to the narrow union so a bogus kind can't skip git init yet persist in the store. const repoKind: 'git' | 'folder' = args.kind === 'folder' ? 'folder' : 'git' From ab0c44892b4d57e5dee1c582412cd2055a58ae96 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Fri, 30 Oct 2026 21:13:05 +0800 Subject: [PATCH 6/6] fix(repos): treat a raw parent that names a file as absent for the trim fallback On POSIX a typed path with a trailing space can probe as an existing file: the raw-first parent probe (stat/access) accepted any existing node, so a file at the raw spelling won and the later mkdir failed instead of falling back to the trimmed directory the user meant. Require isDirectory() on the raw probe in both the runtime controller and the repos:create IPC handler; non-absent probe failures keep surfacing instead of silently relocating the project. --- src/main/ipc/repos-create.test.ts | 37 +++++++++++++++++-- src/main/ipc/repos/repo-creation-handlers.ts | 11 ++++-- ...time-repository-registration-controller.ts | 12 +++--- ...ime-repository-registration-create.test.ts | 12 ++++++ 4 files changed, 60 insertions(+), 12 deletions(-) diff --git a/src/main/ipc/repos-create.test.ts b/src/main/ipc/repos-create.test.ts index 8fa8e4c50dd9..e1ec06df2596 100644 --- a/src/main/ipc/repos-create.test.ts +++ b/src/main/ipc/repos-create.test.ts @@ -8,6 +8,7 @@ const { mockStore, mkdirMock, accessMock, + statMock, readdirMock, rmMock, gitExecFileAsyncMock, @@ -26,6 +27,7 @@ const { }, mkdirMock: vi.fn(), accessMock: vi.fn(), + statMock: vi.fn(), readdirMock: vi.fn(), rmMock: vi.fn(), gitExecFileAsyncMock: vi.fn(), @@ -45,6 +47,7 @@ vi.mock('electron', () => ({ vi.mock('fs/promises', () => ({ mkdir: mkdirMock, access: accessMock, + stat: statMock, readdir: readdirMock, rm: rmMock })) @@ -122,6 +125,7 @@ describe('repos:create', () => { prepareLocalWorktreeRootForRepoMock.mockReset().mockResolvedValue(undefined) accessMock.mockReset().mockRejectedValue(new Error('ENOENT')) + statMock.mockReset().mockRejectedValue(new Error('ENOENT')) readdirMock.mockReset().mockResolvedValue([]) mkdirMock.mockReset().mockResolvedValue(undefined) rmMock.mockReset().mockResolvedValue(undefined) @@ -257,9 +261,9 @@ describe('repos:create', () => { // picked parent that exists exactly as typed must win over the typed-typo trim. it('creates inside a picked parent whose name ends in a space', async () => { const spacedParent = '/tmp/gdrive repo ' - accessMock.mockImplementation(async (probed: string) => { + statMock.mockImplementation(async (probed: string) => { if (probed === spacedParent) { - return undefined + return { isDirectory: () => true } } throw new Error('ENOENT') }) @@ -289,11 +293,38 @@ describe('repos:create', () => { expect(result).toHaveProperty('repo.path', join('/tmp/typo', 'proj')) }) + // A raw spelling that names a file must not win raw-first: only an existing + // directory may, so the trim can still reach the directory the user meant. + it('falls back to the trimmed directory when the raw parent names a file', async () => { + const rawParent = '/tmp/file parent ' + const trimmedParent = '/tmp/file parent' + statMock.mockImplementation(async (probed: string) => { + if (probed === rawParent) { + return { isDirectory: () => false } + } + throw new Error('ENOENT') + }) + accessMock.mockImplementation(async (probed: string) => { + if (probed === rawParent) { + // access() succeeds on any existing file — how the raw probe used to + // mistake a file for the picked parent. + return undefined + } + throw new Error('ENOENT') + }) + + const result = await callCreate({ parentPath: rawParent, name: 'proj', kind: 'folder' }) + + expect(mkdirMock).toHaveBeenNthCalledWith(1, trimmedParent, { recursive: true }) + expect(mkdirMock).toHaveBeenNthCalledWith(2, join(trimmedParent, 'proj'), { recursive: false }) + expect(result).toHaveProperty('repo.path', join(trimmedParent, 'proj')) + }) + // A probe failure that is not "absent" (EACCES, ELOOP...) must surface as an error, // not silently fall back to the trimmed spelling and create in an unchosen directory. it('returns an error when probing the raw parent fails with EACCES', async () => { const lockedParent = '/tmp/locked repo ' - accessMock.mockRejectedValue( + statMock.mockRejectedValue( Object.assign(new Error('EACCES: permission denied, access'), { code: 'EACCES' }) ) diff --git a/src/main/ipc/repos/repo-creation-handlers.ts b/src/main/ipc/repos/repo-creation-handlers.ts index d13c9b28f2a5..550e254ab8ce 100644 --- a/src/main/ipc/repos/repo-creation-handlers.ts +++ b/src/main/ipc/repos/repo-creation-handlers.ts @@ -1,7 +1,7 @@ import type { BrowserWindow } from 'electron' import { ipcMain } from 'electron' import { randomUUID } from 'node:crypto' -import { access, mkdir, readdir, rm } from 'node:fs/promises' +import { access, mkdir, readdir, rm, stat } from 'node:fs/promises' import { homedir } from 'node:os' import { isAbsolute, join } from 'node:path' import type { Store } from '../../persistence' @@ -65,15 +65,18 @@ function isAbsentPathError(err: unknown): boolean { } // Why: a trailing space is a legal POSIX directory name (Google Drive exports them), so a -// picker-returned parent that exists exactly as typed wins; the trim stays only for typed typos. +// picker-returned parent that exists as a directory exactly as typed wins; the trim stays +// only for typed typos and raw spellings naming a non-directory (a file). async function resolveCreateParentPath(requestedPath: string): Promise { const trimmed = requestedPath.trim() if (!trimmed || trimmed === requestedPath) { return trimmed } try { - await access(requestedPath) - return requestedPath + if ((await stat(requestedPath)).isDirectory()) { + return requestedPath + } + return trimmed } catch (err) { if (!isAbsentPathError(err)) { throw err diff --git a/src/main/runtime/runtime-repository-registration-controller.ts b/src/main/runtime/runtime-repository-registration-controller.ts index 0e1a8e0c6992..4f9948d324e1 100644 --- a/src/main/runtime/runtime-repository-registration-controller.ts +++ b/src/main/runtime/runtime-repository-registration-controller.ts @@ -34,17 +34,19 @@ type RuntimeRepositoryRegistrationDependencies = { } // Why: a trailing space is a legal directory name (Google Drive exports them), so a -// picker-returned parent that exists exactly as requested wins; the trim stays only -// for typed typos. Only a genuinely absent raw spelling may fall back to the trim — -// other probe failures must surface instead of silently relocating the project. +// picker-returned parent that exists as a directory exactly as requested wins; the +// trim stays only for typed typos and raw spellings naming a non-directory (a file). +// Other probe failures must surface instead of silently relocating the project. async function resolveCreateParentPathRawFirst(requestedPath: string): Promise { const trimmed = requestedPath.trim() if (!trimmed || trimmed === requestedPath) { return trimmed } try { - await stat(requestedPath) - return requestedPath + if ((await stat(requestedPath)).isDirectory()) { + return requestedPath + } + return trimmed } catch (error) { if (!isENOENT(error)) { throw error diff --git a/src/main/runtime/runtime-repository-registration-create.test.ts b/src/main/runtime/runtime-repository-registration-create.test.ts index 523e7fd03e5c..d2718d59246b 100644 --- a/src/main/runtime/runtime-repository-registration-create.test.ts +++ b/src/main/runtime/runtime-repository-registration-create.test.ts @@ -70,6 +70,18 @@ describe('RuntimeRepositoryRegistrationController.create parent resolution', () } }) + it('falls back to the trimmed directory when the raw spelling names a file', async () => { + const trimmedDir = join(base, 'real dir') + mkdirSync(trimmedDir) + // The raw spelling exists but is a file; only a directory parent may win raw-first. + writeFileSync(`${trimmedDir} `, 'a file, not a directory') + const result = await makeController().create(`${trimmedDir} `, 'kid', 'folder') + expect('error' in result && result.error).toBeFalsy() + if (!('error' in result)) { + expect(result.repo.path).toBe(join(trimmedDir, 'kid')) + } + }) + it('surfaces a non-absent probe failure instead of creating at the trimmed spelling', async () => { const blocker = join(base, 'blocker') writeFileSync(blocker, 'a file, not a directory')