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.
This commit is contained in:
Andrey Avtomonov 2026-06-09 12:14:07 +02:00
parent 41acc5959c
commit 1a6da14f62
4 changed files with 130 additions and 7 deletions

View file

@ -59,6 +59,14 @@ and replay; it belongs in `.gitignore`. If your team wants a record of *why* a
change happened, link the transcript path in the PR description rather than
committing the file.
**ktx** maintains its own git repository at the project directory. When the
project lives inside an existing repository (for example a `data/ktx`
subdirectory of your application repo), **ktx** initializes a dedicated
repository at the project directory rather than committing into the enclosing
one — its ingest commits stay isolated, and writes and reindexing always share
the same working-tree root. Track the project directory in your outer repo as a
nested checkout (or keep it separate) depending on how you want to review it.
## A typical review session
The loop above describes the shape. In practice, one review session looks like

View file

@ -1,6 +1,6 @@
import { promises as fs } from 'node:fs';
import { dirname, join } from 'node:path';
import type { SimpleGit } from 'simple-git';
import { CheckRepoActions, type SimpleGit } from 'simple-git';
import { noopLogger, resolveConfigDir, type KtxCoreConfig, type KtxLogger } from './config.js';
import { createSimpleGit } from './git-env.js';
@ -94,15 +94,29 @@ export class GitService {
private async initialize(): Promise<void> {
try {
// Check if already initialized
const isRepo = await this.git.checkIsRepo();
// The ktx store assumes configDir is its own git working-tree root: writes, session
// worktrees, squash-merges, and reindex scans are all resolved relative to it. The
// default checkIsRepo() (IN_TREE) is also satisfied by an *enclosing* repository, so a
// project nested inside another git working tree would silently operate against that
// outer repo — ingest-written files land at the outer root while reindex scans configDir,
// leaving e.g. the wiki seeded but unindexed. Require configDir to be the repo root
// itself; otherwise initialize a dedicated repository here.
const isOwnRepoRoot = await this.git.checkIsRepo(CheckRepoActions.IS_REPO_ROOT);
if (!isRepo) {
if (!isOwnRepoRoot) {
const insideEnclosingRepo = await this.git.checkIsRepo(CheckRepoActions.IN_TREE);
await this.git.init();
const gitConfig = this.config.git;
await this.git.addConfig('user.name', gitConfig.userName);
await this.git.addConfig('user.email', gitConfig.userEmail);
this.logger.log('Initialized git repository');
if (insideEnclosingRepo) {
this.logger.log(
`Initialized a dedicated ktx git repository at ${this.configDir} (nested inside an existing ` +
'git repository); ktx commits stay in this repository and do not touch the outer one.',
);
} else {
this.logger.log('Initialized git repository');
}
}
// Keep any auto-maintenance triggered by writes in-process. Detached maintenance can

View file

@ -1,8 +1,9 @@
import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises';
import { mkdir, mkdtemp, readFile, realpath, rm, stat, 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 { createSimpleGit } from '../../../src/context/core/git-env.js';
import { GitService } from '../../../src/context/core/git.service.js';
// These tests drive a real git repo inside a temp directory — simple-git shells out to the
@ -318,6 +319,87 @@ describe('GitService', () => {
});
});
describe('nested inside an enclosing git repository', () => {
let enclosingRoot: string;
let nestedDir: string;
let nested: GitService;
const makeConfig = (dir: string): KtxCoreConfig => ({
storage: { configDir: dir, homeDir: dir },
git: {
userName: 'Test User',
userEmail: 'test@example.com',
bootstrapMessage: 'Initialize test config repo',
bootstrapAuthor: 'test-system',
bootstrapAuthorEmail: 'system@example.com',
},
});
beforeEach(async () => {
// An enclosing repo, like a user's application repository.
enclosingRoot = await mkdtemp(join(tmpdir(), 'git-service-enclosing-'));
const enclosing = new GitService(makeConfig(enclosingRoot));
await enclosing.onModuleInit();
// A ktx project living in a subdirectory of that repo.
nestedDir = join(enclosingRoot, 'subproj');
await mkdir(nestedDir, { recursive: true });
nested = new GitService(makeConfig(nestedDir));
await nested.onModuleInit();
});
afterEach(async () => {
await rm(enclosingRoot, { recursive: true, force: true });
});
it('initializes a dedicated repo at the config dir rather than using the enclosing repo', async () => {
const hasOwnGitDir = await stat(join(nestedDir, '.git'))
.then(() => true)
.catch(() => false);
expect(hasOwnGitDir).toBe(true);
const toplevel = (await createSimpleGit(nestedDir).revparse(['--show-toplevel'])).trim();
expect(await realpath(toplevel)).toBe(await realpath(nestedDir));
});
it('lands worktree squash-merges under the config dir, not the enclosing root', async () => {
// Seed so HEAD exists, then ingest a wiki page through the worktree+squash path
// exactly like memory/wiki ingest does.
await writeFile(join(nestedDir, 'seed.md'), 'seed', 'utf-8');
const { commitHash: baseSha } = await nested.commitFile('seed.md', 'seed', 'Test', 'test@example.com');
const wtParent = await realpath(join(enclosingRoot, '..'));
const wtDir = join(wtParent, `wt-${Date.now()}-nested`);
await nested.addWorktree(wtDir, 'session/wiki', baseSha);
const scoped = nested.forWorktree(wtDir);
await mkdir(join(wtDir, 'wiki', 'global'), { recursive: true });
await writeFile(join(wtDir, 'wiki', 'global', 'page.md'), '# Page\n', 'utf-8');
await scoped.commitFile('wiki/global/page.md', 'wip wiki', 'System User', 'system@example.com');
const result = await nested.squashMergeIntoMain(
'session/wiki',
'System User',
'system@example.com',
'Memory ingest (external_ingest): 1 wiki [chat=test1234]',
);
expect(result.ok).toBe(true);
// The page must materialize where reindex scans (under the project dir),
// not one level up at the enclosing repo root.
const underNested = await stat(join(nestedDir, 'wiki', 'global', 'page.md'))
.then(() => true)
.catch(() => false);
const underEnclosing = await stat(join(enclosingRoot, 'wiki', 'global', 'page.md'))
.then(() => true)
.catch(() => false);
expect(underNested).toBe(true);
expect(underEnclosing).toBe(false);
await nested.removeWorktree(wtDir).catch(() => undefined);
await rm(wtDir, { recursive: true, force: true }).catch(() => undefined);
});
});
describe('squashMergeIntoMain', () => {
it('merges a session branch as one commit on main, returning the new SHA + touched paths', async () => {
const { commitHash: baseSha } = await writeAndCommit('seed.md', 'seed');

View file

@ -1,7 +1,8 @@
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
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', () => {
@ -42,6 +43,24 @@ describe('KTX local project runtime', () => {
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 });