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:
Andrey Avtomonov 2026-06-10 14:12:25 +02:00 committed by GitHub
parent f3f893bf01
commit 2877b85adc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 412 additions and 78 deletions

View file

@ -6,14 +6,10 @@ 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';
// Regression for the production exception "Failed to initialize git repository"
// (PostHog issue 019ea9df-96d6-7882-98e2-6b892bf9c1ab, ktx 0.10.0, darwin).
//
// Repro: the project directory is ALREADY a git repo with no commits (the user ran
// `git init` first, or ktx is pointed at an empty repo), AND the machine has no configured
// git identity (a fresh Mac with no ~/.gitconfig). GitService only set the committer identity
// on the path where it created the repo itself, so the bootstrap commit failed with
// "Committer identity unknown" and was rethrown opaquely.
// Regression for bootstrapping a marked ktx repo on a machine with no configured
// git identity. A foreign pre-existing repo is rejected by the ownership rule;
// this test covers the still-valid path where the repo is already ktx-managed
// but has no HEAD yet.
describe('GitService.initialize without a configured git identity', () => {
let repoDir: string;
let homeDir: string;
@ -61,8 +57,12 @@ describe('GitService.initialize without a configured git identity', () => {
delete process.env[key];
}
// Pre-create an empty repo: checkIsRepo() will be true, but there is no HEAD yet.
execFileSync('git', ['init'], { cwd: repoDir, env: process.env, stdio: 'ignore' });
execFileSync('git', ['config', '--local', 'ktx.managed', 'true'], {
cwd: repoDir,
env: process.env,
stdio: 'ignore',
});
});
afterEach(async () => {

View file

@ -0,0 +1,160 @@
import { execFileSync } from 'node:child_process';
import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { KtxCoreConfig } from '../../../src/context/core/config.js';
import { classifyKtxRepoOwnership, GitService } from '../../../src/context/core/git.service.js';
function coreConfig(configDir: string): KtxCoreConfig {
return {
storage: { configDir, homeDir: configDir },
git: {
userName: 'Test User',
userEmail: 'test@example.com',
bootstrapMessage: 'Initialize test config repo',
bootstrapAuthor: 'test-system',
bootstrapAuthorEmail: 'system@example.com',
},
};
}
function git(cwd: string, args: string[]): string {
return execFileSync('git', args, {
cwd,
encoding: 'utf-8',
env: {
...process.env,
GIT_AUTHOR_NAME: 'Parent User',
GIT_AUTHOR_EMAIL: 'parent@example.com',
GIT_COMMITTER_NAME: 'Parent User',
GIT_COMMITTER_EMAIL: 'parent@example.com',
},
}).trim();
}
describe('GitService repository ownership', () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'git-service-isolation-'));
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
it('creates and commits inside its own repo when nested in an enclosing repo', async () => {
const parentDir = join(tempDir, 'parent');
const projectDir = join(parentDir, '.ktx-project');
await mkdir(projectDir, { recursive: true });
git(parentDir, ['init']);
await writeFile(join(parentDir, 'README.md'), '# Parent\n', 'utf-8');
git(parentDir, ['add', 'README.md']);
git(parentDir, ['commit', '-m', 'parent baseline']);
const parentHeadBefore = git(parentDir, ['rev-parse', 'HEAD']);
const service = new GitService(coreConfig(projectDir));
await service.onModuleInit();
expect(git(projectDir, ['config', '--local', '--get', 'ktx.managed'])).toBe('true');
expect(git(parentDir, ['rev-parse', 'HEAD'])).toBe(parentHeadBefore);
expect(await realpath(git(projectDir, ['rev-parse', '--show-toplevel']))).toBe(await realpath(projectDir));
await writeFile(join(projectDir, 'wiki.md'), '# Wiki\n', 'utf-8');
await service.commitFile('wiki.md', 'Add wiki page', 'Test User', 'test@example.com');
expect(git(parentDir, ['rev-parse', 'HEAD'])).toBe(parentHeadBefore);
expect(git(projectDir, ['log', '--oneline', '--max-count=1'])).toContain('Add wiki page');
expect(git(parentDir, ['status', '--short'])).toContain('?? .ktx-project/');
});
it('rejects a foreign repo rooted at the project dir', async () => {
const projectDir = join(tempDir, 'foreign');
await mkdir(projectDir, { recursive: true });
git(projectDir, ['init']);
const configBefore = await readFile(join(projectDir, '.git', 'config'), 'utf-8');
const service = new GitService(coreConfig(projectDir));
await expect(service.onModuleInit()).rejects.toThrow(/already a git repository that ktx did not create/);
expect(await readFile(join(projectDir, '.git', 'config'), 'utf-8')).toBe(configBefore);
});
it('rejects a gitfile at the project dir as foreign', async () => {
const projectDir = join(tempDir, 'linked-worktree');
await mkdir(projectDir, { recursive: true });
await writeFile(join(projectDir, '.git'), 'gitdir: ../actual.git\n', 'utf-8');
const service = new GitService(coreConfig(projectDir));
await expect(service.onModuleInit()).rejects.toThrow(/already a git repository that ktx did not create/);
});
it('accepts a marked ktx repo and does not create a second bootstrap commit', async () => {
const projectDir = join(tempDir, 'owned');
const service = new GitService(coreConfig(projectDir));
await service.onModuleInit();
const before = await service.revParseHead();
const second = new GitService(coreConfig(projectDir));
await second.onModuleInit();
expect(await second.revParseHead()).toBe(before);
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');
});
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');
});
});

View file

@ -1,3 +1,4 @@
import { execFileSync } from 'node:child_process';
import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
@ -63,6 +64,11 @@ describe('GitService', () => {
// beforeEach already ran onModuleInit() against an empty temp dir.
const head = await service.revParseHead();
expect(head).toMatch(/^[0-9a-f]{40}$/);
const marker = execFileSync('git', ['config', '--local', '--get', 'ktx.managed'], {
cwd: tempDir,
encoding: 'utf-8',
}).trim();
expect(marker).toBe('true');
});
it('does not double-commit when re-initialized', async () => {

View file

@ -165,7 +165,6 @@ async function writeHistoricSqlProject(project: KtxLocalProject): Promise<KtxLoc
' state: sqlite',
' search: sqlite-fts5',
' git:',
' auto_commit: false',
' author: KTX Test <system@ktx.local>',
'',
].join('\n'),

View file

@ -521,7 +521,6 @@ describe('canonical local ingest', () => {
' state: sqlite',
' search: sqlite-fts5',
' git:',
' auto_commit: false',
' author: KTX Test <system@ktx.local>',
'',
].join('\n'),
@ -683,7 +682,6 @@ describe('canonical local ingest', () => {
' state: sqlite',
' search: sqlite-fts5',
' git:',
' auto_commit: false',
' author: KTX Test <system@ktx.local>',
'',
].join('\n'),
@ -767,7 +765,6 @@ describe('canonical local ingest', () => {
' state: sqlite',
' search: sqlite-fts5',
' git:',
' auto_commit: false',
' author: KTX Test <system@ktx.local>',
'',
].join('\n'),
@ -811,7 +808,6 @@ describe('canonical local ingest', () => {
' state: sqlite',
' search: sqlite-fts5',
' git:',
' auto_commit: false',
' author: KTX Test <system@ktx.local>',
'',
].join('\n'),

View file

@ -7,6 +7,8 @@ import {
validateKtxProjectConfig,
} from '../../../src/context/project/config.js';
const removedAutoCommitKey = ['auto', 'commit'].join('_');
describe('KTX project config', () => {
it.each(['status', 'replay', 'run', 'watch'])('accepts former ingest subcommand name "%s" as a connection id', (connectionId) => {
expect(
@ -29,7 +31,6 @@ connections:
state: 'sqlite',
search: 'sqlite-fts5',
git: {
auto_commit: true,
author: 'ktx <ktx@example.com>',
},
},
@ -70,9 +71,6 @@ connections:
default_toolset: ['sl_query', 'wiki_search', 'sl_read_source'],
},
},
memory: {
auto_commit: true,
},
scan: {
enrichment: {
mode: 'none',
@ -93,6 +91,28 @@ connections:
});
});
it('rejects removed auto-commit config keys', () => {
expect(() =>
parseKtxProjectConfig(`
storage:
git:
${removedAutoCommitKey}: false
`),
).toThrow(new RegExp(`storage\\.git\\.${removedAutoCommitKey}`));
expect(() =>
parseKtxProjectConfig(`
memory:
${removedAutoCommitKey}: false
`),
).toThrow(/memory/);
expect(validateKtxProjectConfig(`storage:\n git:\n ${removedAutoCommitKey}: false\n`)).toMatchObject({
ok: false,
issues: [expect.objectContaining({ path: `storage.git.${removedAutoCommitKey}` })],
});
});
it('round-trips through YAML with stable defaults', () => {
const serialized = serializeKtxProjectConfig(buildDefaultKtxProjectConfig());
const parsed = parseKtxProjectConfig(serialized);
@ -595,7 +615,7 @@ describe('generateKtxProjectConfigJsonSchema', () => {
it('exposes every top-level ktx.yaml section under properties', () => {
const properties = schema.properties as Record<string, unknown>;
expect(Object.keys(properties).sort()).toEqual(['agent', 'connections', 'ingest', 'llm', 'memory', 'scan', 'setup', 'storage'].sort());
expect(Object.keys(properties).sort()).toEqual(['agent', 'connections', 'ingest', 'llm', 'scan', 'setup', 'storage'].sort());
});
it('does not require any top-level fields', () => {