ktx/packages/cli/test/context/project/project.test.ts
Andrey Avtomonov 1a6da14f62 fix(git): give each ktx project its own git repo root
A ktx project assumes its config dir is its own git working-tree root: writes,
session worktrees, squash-merges, and reindex scans all resolve relative to it.
GitService.initialize() gated on checkIsRepo() (IN_TREE), which is also
satisfied by an *enclosing* repository — so a project nested inside another git
working tree silently operated against the outer repo. Worktree/ingest writes
landed at the outer root (e.g. <outer>/wiki/global/) while reindex scanned
<projectDir>/wiki/global/, so the wiki was seeded but never indexed:
wiki_search returned nothing and knowledge_pages stayed empty, with no error.
Semantic-layer and raw-sources had the same divergence.

Gate initialization on checkIsRepo('root') instead: require the repo root to be
the config dir itself, and initialize a dedicated repository there when it is
not (logging clearly when nesting inside an existing repo). This restores the
one-repo-per-project invariant at the shared git layer, fixing all artifacts at
once, and keeps ktx's commits out of the enclosing repository.
2026-06-09 12:14:07 +02:00

92 lines
3.9 KiB
TypeScript

import { mkdtemp, readFile, realpath, rm, stat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { createSimpleGit } from '../../../src/context/core/git-env.js';
import { initKtxProject, loadKtxProject } from '../../../src/context/project/project.js';
describe('KTX local project runtime', () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'ktx-project-runtime-'));
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
it('initializes the standalone project layout and commits it', async () => {
const projectDir = join(tempDir, 'warehouse');
const result = await initKtxProject({
projectDir,
authorName: 'Agent',
authorEmail: 'agent@example.com',
});
expect(result.projectDir).toBe(projectDir);
expect(result.commitHash).toMatch(/^[0-9a-f]{40}$/);
await expect(readFile(join(projectDir, 'ktx.yaml'), 'utf-8')).resolves.not.toContain('project:');
const gitignore = await readFile(join(projectDir, '.ktx/.gitignore'), 'utf-8');
expect(gitignore).toContain('cache/');
expect(gitignore).toContain('db.sqlite');
expect(gitignore).toContain('db.sqlite-*');
expect(gitignore).toContain('ingest-transcripts/');
expect(gitignore).toContain('secrets/');
expect(gitignore).toContain('setup/');
expect(gitignore).toContain('agents/');
await expect(stat(join(projectDir, 'wiki/global/.gitkeep'))).resolves.toBeDefined();
await expect(stat(join(projectDir, 'semantic-layer/.gitkeep'))).resolves.toBeDefined();
await expect(stat(join(projectDir, '_schema/.gitkeep'))).rejects.toMatchObject({ code: 'ENOENT' });
await expect(stat(join(projectDir, 'raw-sources/.gitkeep'))).resolves.toBeDefined();
await expect(stat(join(projectDir, '.git'))).resolves.toBeDefined();
});
it('creates its own git repo when the project dir is nested inside another repository', async () => {
// An enclosing repository, like a user's application repo.
const enclosing = createSimpleGit(tempDir);
await enclosing.init();
await enclosing.addConfig('user.name', 'Enclosing');
await enclosing.addConfig('user.email', 'enclosing@example.com');
await enclosing.commit('enclosing root', { '--allow-empty': null });
const projectDir = join(tempDir, 'subproj');
await initKtxProject({ projectDir, authorName: 'Agent', authorEmail: 'agent@example.com' });
// ktx must own a dedicated repo at the project dir rather than committing into the
// enclosing repo, so writes and reindex scans share one working-tree root.
await expect(stat(join(projectDir, '.git'))).resolves.toBeDefined();
const toplevel = (await createSimpleGit(projectDir).revparse(['--show-toplevel'])).trim();
expect(await realpath(toplevel)).toBe(await realpath(projectDir));
});
it('loads an initialized project with a working file store', async () => {
const projectDir = join(tempDir, 'warehouse');
await initKtxProject({ projectDir });
const loaded = await loadKtxProject({ projectDir });
await loaded.fileStore.writeFile(
'wiki/global/revenue.md',
'# Revenue\n',
'Agent',
'agent@example.com',
'Add revenue page',
);
await expect(loaded.fileStore.readFile('wiki/global/revenue.md')).resolves.toMatchObject({
content: '# Revenue\n',
});
});
it('rejects reinitializing an existing project unless force is set', async () => {
const projectDir = join(tempDir, 'warehouse');
await initKtxProject({ projectDir });
await expect(initKtxProject({ projectDir })).rejects.toThrow('Project already contains ktx.yaml');
await expect(initKtxProject({ projectDir, force: true })).resolves.toMatchObject({
configPath: join(projectDir, 'ktx.yaml'),
});
});
});