fix(cli): own a dedicated git repo at the project dir when nested in an enclosing repo (#282)

GitService.initialize() used checkIsRepo(), which is true whenever the project
dir sits anywhere inside a git working tree. So when a ktx project lived in a
subdirectory of an enclosing repo, ktx skipped `git init` and silently adopted
the enclosing repo as its store.

Every ktx relative path assumes the project dir IS the working-tree root. During
ingest, wiki/SL pages are written through a session worktree (whose root is the
worktree dir, so the page is recorded at repo-relative `wiki/global/<key>.md`)
and then squash-merged into the main worktree. With an adopted enclosing repo,
the main worktree's root is the enclosing git root, so the merge wrote the page
to `<gitRoot>/wiki/global/` — outside the project dir. reindex scans
`<projectDir>/wiki/global/`, found nothing, and wiki_search silently returned
empty (knowledge_pages = 0) even though ingest reported success.

Detect the project dir's own root with checkIsRepo(IS_REPO_ROOT) and initialize
a dedicated repo there unless the project dir is already a repo root. This keeps
adopting a user-created repo when the project dir IS that repo's root, fixes the
silent wiki/SL/memory divergence at its source for every writer, and stops ktx
from committing its scaffold into the user's enclosing repo.

Regression tests cover both layers: a project nested in an enclosing repo gets
its own .git (and the enclosing repo stays untouched), and a wiki page written
through a session worktree + squash-merge lands in the project dir and is
discovered by reindex.
This commit is contained in:
Andrey Avtomonov 2026-06-09 23:37:24 +02:00 committed by GitHub
parent 65de75ebd7
commit fd18caa26a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 101 additions and 5 deletions

View file

@ -1,4 +1,5 @@
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
import { execFileSync } from 'node:child_process';
import { mkdir, 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';
@ -60,6 +61,30 @@ describe('KTX local project runtime', () => {
});
});
it('initializes a dedicated git repo at the project dir even when nested inside an enclosing repo', async () => {
// A ktx project dir living below an existing git working tree (e.g. an analytics
// subfolder of an app repo). ktx must own its own repo rooted at the project dir,
// not silently adopt the enclosing repo — otherwise worktree writes resolve against
// the enclosing root and land outside the project dir.
const enclosing = join(tempDir, 'enclosing');
await mkdir(enclosing, { recursive: true });
execFileSync('git', ['init', '-q'], { cwd: enclosing });
const projectDir = join(enclosing, 'analytics');
await initKtxProject({ projectDir, authorName: 'Agent', authorEmail: 'agent@example.com' });
await expect(stat(join(projectDir, '.git'))).resolves.toBeDefined();
const toplevel = execFileSync('git', ['rev-parse', '--show-toplevel'], {
cwd: projectDir,
encoding: 'utf-8',
}).trim();
expect(await realpath(toplevel)).toBe(await realpath(projectDir));
// ktx must not write its scaffold commits into the user's enclosing repo.
const enclosingTracked = execFileSync('git', ['ls-files'], { cwd: enclosing, encoding: 'utf-8' });
expect(enclosingTracked).not.toContain('ktx.yaml');
});
it('rejects reinitializing an existing project unless force is set', async () => {
const projectDir = join(tempDir, 'warehouse');
await initKtxProject({ projectDir });