From 1a6da14f62a39c30cc811bee47ece7157fe91641 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Tue, 9 Jun 2026 12:14:07 +0200 Subject: [PATCH] fix(git): give each ktx project its own git repo root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. /wiki/global/) while reindex scanned /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. --- .../content/docs/guides/reviewing-context.mdx | 8 ++ packages/cli/src/context/core/git.service.ts | 24 ++++-- .../cli/test/context/core/git.service.test.ts | 84 ++++++++++++++++++- .../cli/test/context/project/project.test.ts | 21 ++++- 4 files changed, 130 insertions(+), 7 deletions(-) diff --git a/docs-site/content/docs/guides/reviewing-context.mdx b/docs-site/content/docs/guides/reviewing-context.mdx index 63d4fceb..f60adbf3 100644 --- a/docs-site/content/docs/guides/reviewing-context.mdx +++ b/docs-site/content/docs/guides/reviewing-context.mdx @@ -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 diff --git a/packages/cli/src/context/core/git.service.ts b/packages/cli/src/context/core/git.service.ts index 216183ff..cc88c4c9 100644 --- a/packages/cli/src/context/core/git.service.ts +++ b/packages/cli/src/context/core/git.service.ts @@ -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 { 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 diff --git a/packages/cli/test/context/core/git.service.test.ts b/packages/cli/test/context/core/git.service.test.ts index 8b19ed09..4c06d070 100644 --- a/packages/cli/test/context/core/git.service.test.ts +++ b/packages/cli/test/context/core/git.service.test.ts @@ -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'); diff --git a/packages/cli/test/context/project/project.test.ts b/packages/cli/test/context/project/project.test.ts index 668fa264..8cd49337 100644 --- a/packages/cli/test/context/project/project.test.ts +++ b/packages/cli/test/context/project/project.test.ts @@ -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 });