fix(cli): guide setup away from foreign repos at the project dir

ktx owns the git repo rooted at the project dir and refuses to adopt one it
did not create (the Finding 3 isolation invariant). But setup steered users
straight into that failure: the interactive menu offers "Current directory"
first, and `--no-input --yes --project-dir <repo-root>` created directly in
place — both then threw a generic "Failed to initialize git repository:"
wrapper from deep in GitService.initialize().

Extract the ownership rule into a shared `classifyKtxRepoOwnership(dir)` used by
both GitService.initialize() (the invariant) and the setup wizard (pre-flight
guidance), so the decision derives from one rule. Setup now detects a foreign
repo before constructing GitService and: interactively re-prompts (the user
picks the existing `ktx-project` subfolder), or non-interactively returns a
clean missing-input with the actionable message. The typed foreign-repo error
is also surfaced verbatim instead of being buried under the generic wrapper.

Empty/non-repo current directories still work — only foreign repos are blocked.
This commit is contained in:
Andrey Avtomonov 2026-06-09 23:38:01 +02:00
parent 133b879ed3
commit 4578b2d3a9
5 changed files with 183 additions and 32 deletions

View file

@ -5,7 +5,7 @@ import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { KtxCoreConfig } from '../../../src/context/core/config.js';
import { GitService } from '../../../src/context/core/git.service.js';
import { classifyKtxRepoOwnership, GitService } from '../../../src/context/core/git.service.js';
function coreConfig(configDir: string): KtxCoreConfig {
return {
@ -106,3 +106,49 @@ describe('GitService repository ownership', () => {
expect(git(projectDir, ['config', '--local', '--get', 'ktx.managed'])).toBe('true');
});
});
describe('classifyKtxRepoOwnership', () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'git-ownership-'));
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
it('reports unowned when no .git exists at the directory', async () => {
const dir = join(tempDir, 'fresh');
await mkdir(dir, { recursive: true });
expect(await classifyKtxRepoOwnership(dir)).toBe('unowned');
});
it('reports unowned for a fresh directory nested inside an enclosing repo', async () => {
const parentDir = join(tempDir, 'parent');
const nestedDir = join(parentDir, 'nested');
await mkdir(nestedDir, { recursive: true });
git(parentDir, ['init']);
expect(await classifyKtxRepoOwnership(nestedDir)).toBe('unowned');
});
it('reports ktx-managed for a repo ktx initialized', async () => {
const dir = join(tempDir, 'owned');
await new GitService(coreConfig(dir)).onModuleInit();
expect(await classifyKtxRepoOwnership(dir)).toBe('ktx-managed');
});
it('reports foreign for a repo ktx did not create', async () => {
const dir = join(tempDir, 'foreign');
await mkdir(dir, { recursive: true });
git(dir, ['init']);
expect(await classifyKtxRepoOwnership(dir)).toBe('foreign');
});
it('reports foreign for a .git file (linked worktree)', async () => {
const dir = join(tempDir, 'linked');
await mkdir(dir, { recursive: true });
await writeFile(join(dir, '.git'), 'gitdir: ../actual.git\n', 'utf-8');
expect(await classifyKtxRepoOwnership(dir)).toBe('foreign');
});
});

View file

@ -1,3 +1,4 @@
import { execFileSync } from 'node:child_process';
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
@ -44,6 +45,19 @@ function defaultSubfolderLabel(parentDir: string): string {
return `New subfolder (${gray(childDir.slice(0, -childName.length))}${childName})`;
}
function initForeignRepo(dir: string): void {
execFileSync('git', ['init'], {
cwd: dir,
env: {
...process.env,
GIT_AUTHOR_NAME: 'Foreign User',
GIT_AUTHOR_EMAIL: 'foreign@example.com',
GIT_COMMITTER_NAME: 'Foreign User',
GIT_COMMITTER_EMAIL: 'foreign@example.com',
},
});
}
describe('setup project step', () => {
let tempDir: string;
@ -295,6 +309,41 @@ describe('setup project step', () => {
await expect(stat(join(projectDir, 'ktx.yaml'))).resolves.toBeDefined();
});
it('refuses to create a project in a foreign git repo in non-interactive mode', async () => {
const projectDir = join(tempDir, 'app-repo');
await mkdir(projectDir, { recursive: true });
initForeignRepo(projectDir);
const testIo = makeIo();
await expect(
runKtxSetupProjectStep({ projectDir, mode: 'auto', inputMode: 'disabled', yes: true }, testIo.io),
).resolves.toMatchObject({ status: 'missing-input', projectDir });
expect(testIo.stderr()).toContain('already a git repository that ktx did not create');
await expect(stat(join(projectDir, 'ktx.yaml'))).rejects.toThrow();
});
it('re-prompts away from a foreign current directory and creates the project in a subfolder', async () => {
const projectDir = join(tempDir, 'app-repo');
await mkdir(projectDir, { recursive: true });
initForeignRepo(projectDir);
const subfolderDir = join(projectDir, 'ktx-project');
const prompts = makePromptAdapter({ choices: ['current', 'new-default', 'create'] });
const testIo = makeIo({ stdoutIsTty: true });
const result = await runKtxSetupProjectStep(
{ projectDir, mode: 'auto', inputMode: 'auto', yes: false },
testIo.io,
{ prompts },
);
expect(result.status).toBe('ready');
expect(result.projectDir).toBe(subfolderDir);
expect(testIo.stderr()).toContain('already a git repository that ktx did not create');
await expect(stat(join(subfolderDir, 'ktx.yaml'))).resolves.toBeDefined();
await expect(stat(join(projectDir, 'ktx.yaml'))).rejects.toThrow();
});
it('prompts to exit and returns cancelled in interactive auto mode', async () => {
const projectDir = join(tempDir, 'warehouse');
const prompts = makePromptAdapter({ choice: 'exit' });