Repository navigation
fix(repos): keep trailing-space directory names that really exist #25446
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
9d1cbf1
1d4ca14
ea7ed7a
259fdc2
0fa50b2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<string> { | ||
| const trimmed = requestedPath.trim() | ||
| if (!trimmed || trimmed === requestedPath) { | ||
| return trimmed | ||
| } | ||
| try { | ||
| await stat(requestedPath) | ||
| return requestedPath | ||
|
Comment on lines
+46
to
+47
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } catch (error) { | ||
| if (!isENOENT(error)) { | ||
| throw error | ||
| } | ||
| return trimmed | ||
| } | ||
| } | ||
|
|
||
| export class RuntimeRepositoryRegistrationController { | ||
| constructor(private readonly deps: RuntimeRepositoryRegistrationDependencies) {} | ||
|
|
||
|
|
@@ -124,29 +144,37 @@ 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') | ||
| } | ||
| 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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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') | ||
| } | ||
| }) | ||
| }) |
Uh oh!
There was an error while loading. Please reload this page.