mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-10 08:05:14 +02:00
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:
parent
41acc5959c
commit
1a6da14f62
4 changed files with 130 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue