mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-25 08:48:08 +02:00
fix(cli): isolate ktx-owned project repositories (#283)
* fix(cli): isolate ktx project git repos * fix(cli): remove inert auto commit config * test(cli): drop stale auto commit fixtures * docs: document isolated ktx project repos * test(cli): keep stale config grep clean * 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. * fix(cli): keep classifyKtxRepoOwnership total for non-directory paths The setup ownership guard runs before the existing not-a-directory check, so pointing a custom/--project-dir path at a file made classifyKtxRepoOwnership lstat `<file>/.git`, hit ENOTDIR, and throw — crashing the setup step instead of returning the friendly "path exists and is not a directory" result. A path that is a file (or missing) holds no git repo for ktx to avoid, so treat ENOTDIR like ENOENT and return 'unowned'. The downstream existingFolderState check still rejects a non-directory with its friendly message, and the classifier no longer throws raw errno for any caller.
This commit is contained in:
parent
f3f893bf01
commit
2877b85adc
20 changed files with 412 additions and 78 deletions
|
|
@ -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,59 @@ 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('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' });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue