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' });