diff --git a/packages/cli/src/context/core/git.service.ts b/packages/cli/src/context/core/git.service.ts index f9942dac..63da5f8c 100644 --- a/packages/cli/src/context/core/git.service.ts +++ b/packages/cli/src/context/core/git.service.ts @@ -48,8 +48,11 @@ function isNodeErrnoException(error: unknown): error is NodeJS.ErrnoException { /** * Classify whether ktx may own a git repository rooted exactly at `dir`. * - * - `unowned`: no `/.git` exists → ktx can `git init` here. Covers a fresh - * standalone directory and a fresh directory nested inside a parent repo. + * - `unowned`: there is no git repository for ktx to avoid here → ktx may + * `git init`. Covers a fresh standalone directory, a fresh directory nested + * inside a parent repo, a path that does not exist yet, and a path that is not + * a directory at all (whether the path is a usable project directory is a + * separate concern for the caller to validate). * - `ktx-managed`: `/.git` is a directory carrying ktx's ownership marker. * - `foreign`: a repo ktx did not create — a `.git` directory without the marker, * or a `.git` *file* (a linked worktree). ktx must never adopt or mutate it. @@ -64,7 +67,9 @@ export async function classifyKtxRepoOwnership(dir: string): Promise/.git` is absent. ENOTDIR: `` itself is a file, so it + // can hold no repo. Either way there is nothing for ktx to avoid here. + if (isNodeErrnoException(error) && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) { return 'unowned'; } throw error; diff --git a/packages/cli/test/context/core/git.service.repo-isolation.test.ts b/packages/cli/test/context/core/git.service.repo-isolation.test.ts index 0061c8ff..f5493ad3 100644 --- a/packages/cli/test/context/core/git.service.repo-isolation.test.ts +++ b/packages/cli/test/context/core/git.service.repo-isolation.test.ts @@ -151,4 +151,10 @@ describe('classifyKtxRepoOwnership', () => { await writeFile(join(dir, '.git'), 'gitdir: ../actual.git\n', 'utf-8'); expect(await classifyKtxRepoOwnership(dir)).toBe('foreign'); }); + + it('reports unowned when the path is itself a file', async () => { + const filePath = join(tempDir, 'notes.txt'); + await writeFile(filePath, 'a file, not a folder\n', 'utf-8'); + expect(await classifyKtxRepoOwnership(filePath)).toBe('unowned'); + }); }); diff --git a/packages/cli/test/setup-project.test.ts b/packages/cli/test/setup-project.test.ts index ba9a625b..1f0eae4d 100644 --- a/packages/cli/test/setup-project.test.ts +++ b/packages/cli/test/setup-project.test.ts @@ -344,6 +344,24 @@ describe('setup project step', () => { await expect(stat(join(projectDir, 'ktx.yaml'))).rejects.toThrow(); }); + it('rejects a custom path that points at an existing file without crashing', async () => { + const startDir = join(tempDir, 'start'); + await mkdir(startDir, { recursive: true }); + await writeFile(join(startDir, 'notes.txt'), 'a file, not a folder\n', 'utf-8'); + const prompts = makePromptAdapter({ choices: ['new-custom'], textValue: 'notes.txt' }); + const testIo = makeIo({ stdoutIsTty: true }); + + await expect( + runKtxSetupProjectStep( + { projectDir: startDir, mode: 'auto', inputMode: 'auto', yes: false }, + testIo.io, + { prompts }, + ), + ).resolves.toMatchObject({ status: 'missing-input', projectDir: startDir }); + + expect(testIo.stderr()).toContain('exists and is not a directory'); + }); + it('prompts to exit and returns cancelled in interactive auto mode', async () => { const projectDir = join(tempDir, 'warehouse'); const prompts = makePromptAdapter({ choice: 'exit' });