From fd18caa26ac78c8994528ff55dac3c900ae6eae9 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Tue, 9 Jun 2026 23:37:24 +0200 Subject: [PATCH] fix(cli): own a dedicated git repo at the project dir when nested in an enclosing repo (#282) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/.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 `/wiki/global/` — outside the project dir. reindex scans `/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. --- packages/cli/src/context/core/git.service.ts | 13 ++-- .../reindex.nested-git-root.test.ts | 66 +++++++++++++++++++ .../cli/test/context/project/project.test.ts | 27 +++++++- 3 files changed, 101 insertions(+), 5 deletions(-) create mode 100644 packages/cli/test/context/index-sync/reindex.nested-git-root.test.ts diff --git a/packages/cli/src/context/core/git.service.ts b/packages/cli/src/context/core/git.service.ts index 216c5460..a9638ea5 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'; @@ -98,10 +98,15 @@ export class GitService { private async initialize(): Promise { try { - // Check if already initialized - const isRepo = await this.git.checkIsRepo(); + // Adopt an existing repo ONLY when this directory is itself that repo's root. + // When it sits below an enclosing repo, a plain checkIsRepo() is true and ktx + // would silently piggyback on the enclosing tree — but every ktx relative path + // (file-store writes, session worktrees, squash-merges, reindex scans) assumes + // this directory IS the working-tree root. So treat "inside an enclosing repo" + // the same as "no repo" and initialize a dedicated repo rooted here. + const isRepoRoot = await this.git.checkIsRepo(CheckRepoActions.IS_REPO_ROOT); - if (!isRepo) { + if (!isRepoRoot) { await this.git.init(); this.logger.log('Initialized git repository'); } diff --git a/packages/cli/test/context/index-sync/reindex.nested-git-root.test.ts b/packages/cli/test/context/index-sync/reindex.nested-git-root.test.ts new file mode 100644 index 00000000..97efa993 --- /dev/null +++ b/packages/cli/test/context/index-sync/reindex.nested-git-root.test.ts @@ -0,0 +1,66 @@ +import { execFileSync } from 'node:child_process'; +import { mkdir, mkdtemp, 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 { reindexLocalIndexes } from '../../../src/context/index-sync/reindex.js'; +import { initKtxProject, type KtxLocalProject } from '../../../src/context/project/project.js'; + +const AUTHOR = 'Agent'; +const EMAIL = 'agent@example.com'; + +const WIKI_PAGE = '---\nsummary: Revenue\nusage_mode: auto\n---\n\nPaid orders.\n'; + +/** + * Regression for the "wiki silently unsearchable when the project dir is not the git root" + * bug: a ktx project initialized below an existing git working tree. ingest writes wiki + * pages through a session worktree and squash-merges into main, so the page must land + * inside the project dir (where reindex scans), not at the enclosing git root. + */ +describe('reindex with a ktx project nested inside an enclosing git repo', () => { + let tempDir: string; + let enclosing: string; + let projectDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'ktx-nested-git-root-')); + enclosing = join(tempDir, 'enclosing'); + await mkdir(enclosing, { recursive: true }); + execFileSync('git', ['init', '-q'], { cwd: enclosing }); + projectDir = join(enclosing, 'analytics'); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it('indexes a wiki page written through a session worktree and squash-merged into main', async () => { + const project: KtxLocalProject = await initKtxProject({ + projectDir, + authorName: AUTHOR, + authorEmail: EMAIL, + }); + + // Mirror the ingest write path: create a session worktree, write the page on its + // branch through the worktree-scoped file store, then squash-merge into main. + const mainHead = await project.git.revParseHead(); + const workdir = join(projectDir, '.ktx/worktrees/session-test'); + const branch = 'session/test'; + await project.git.addWorktree(workdir, branch, mainHead); + const worktreeStore = project.fileStore.forWorktree(workdir); + await worktreeStore.writeFile('wiki/global/revenue.md', WIKI_PAGE, AUTHOR, EMAIL, 'Add revenue page'); + const merge = await project.git.squashMergeIntoMain(branch, AUTHOR, EMAIL, 'Merge session'); + expect(merge.ok).toBe(true); + await project.git.removeWorktree(workdir); + await project.git.deleteBranch(branch, true); + + // The page must land inside the project dir, not the enclosing git root. + await expect(stat(join(projectDir, 'wiki/global/revenue.md'))).resolves.toBeDefined(); + await expect(stat(join(enclosing, 'wiki/global/revenue.md'))).rejects.toMatchObject({ code: 'ENOENT' }); + + // ...and reindex must discover and index it. + const summary = await reindexLocalIndexes(project, { force: false, embeddingService: null }); + const global = summary.scopes.find((scope) => scope.label === 'global'); + expect(global).toMatchObject({ scanned: 1, updated: 1 }); + }); +}); diff --git a/packages/cli/test/context/project/project.test.ts b/packages/cli/test/context/project/project.test.ts index 668fa264..1027d174 100644 --- a/packages/cli/test/context/project/project.test.ts +++ b/packages/cli/test/context/project/project.test.ts @@ -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 });