Initial open-source release

This commit is contained in:
Andrey Avtomonov 2026-05-10 23:12:26 +02:00
commit 1a42152e6f
1199 changed files with 257054 additions and 0 deletions

View file

@ -0,0 +1,34 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { resolveKloConfigReference, resolveKloHomePath } from './config-reference.js';
describe('KLO config references', () => {
it('resolves env references without returning empty values', () => {
expect(resolveKloConfigReference('env:AI_GATEWAY_API_KEY', { AI_GATEWAY_API_KEY: ' gateway-key ' })).toBe(
'gateway-key',
);
expect(resolveKloConfigReference('env:AI_GATEWAY_API_KEY', { AI_GATEWAY_API_KEY: ' ' })).toBeUndefined();
expect(resolveKloConfigReference('env:AI_GATEWAY_API_KEY', {})).toBeUndefined();
});
it('resolves file references and trims file content', async () => {
const dir = join(tmpdir(), `klo-config-reference-${process.pid}`);
await mkdir(dir, { recursive: true });
const keyPath = join(dir, 'gateway-key.txt');
await writeFile(keyPath, 'file-gateway-key\n', 'utf8');
expect(resolveKloConfigReference(`file:${keyPath}`, {})).toBe('file-gateway-key');
});
it('returns literal values unchanged after trimming blank-only values', () => {
expect(resolveKloConfigReference('provider/model', {})).toBe('provider/model');
expect(resolveKloConfigReference(' ', {})).toBeUndefined();
expect(resolveKloConfigReference(undefined, {})).toBeUndefined();
});
it('resolves home-prefixed paths', () => {
expect(resolveKloHomePath('~/klo/key.txt')).toContain('/klo/key.txt');
});
});

View file

@ -0,0 +1,36 @@
import { readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { resolve } from 'node:path';
export function resolveKloHomePath(path: string): string {
if (path === '~') {
return homedir();
}
if (path.startsWith('~/')) {
return resolve(homedir(), path.slice(2));
}
return resolve(path);
}
export function resolveKloConfigReference(value: string | undefined, env: NodeJS.ProcessEnv): string | undefined {
if (!value) {
return undefined;
}
if (value.startsWith('env:')) {
const envName = value.slice('env:'.length).trim();
const envValue = env[envName];
return envValue && envValue.trim().length > 0 ? envValue.trim() : undefined;
}
if (value.startsWith('file:')) {
const filePath = resolveKloHomePath(value.slice('file:'.length).trim());
const fileValue = readFileSync(filePath, 'utf8').trim();
return fileValue.length > 0 ? fileValue : undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}

View file

@ -0,0 +1,42 @@
export interface KloStorageConfig {
configDir?: string;
homeDir?: string;
worktreesDir?: string;
}
export interface KloGitConfig {
userName: string;
userEmail: string;
bootstrapMessage?: string;
bootstrapAuthor?: string;
bootstrapAuthorEmail?: string;
}
export interface KloCoreConfig {
storage: KloStorageConfig;
git: KloGitConfig;
}
export interface KloLogger {
debug(message: string): void;
log(message: string): void;
warn(message: string): void;
error(message: string, error?: unknown): void;
}
export const noopLogger: KloLogger = {
debug: () => undefined,
log: () => undefined,
warn: () => undefined,
error: () => undefined,
};
export function resolveConfigDir(config: KloCoreConfig): string {
const homeDir = config.storage.homeDir ?? '/tmp';
return config.storage.configDir ?? `${homeDir}/klo/config`;
}
export function resolveWorktreesDir(config: KloCoreConfig): string {
const homeDir = config.storage.homeDir ?? '/tmp';
return config.storage.worktreesDir ?? `${homeDir}/.worktrees`;
}

View file

@ -0,0 +1,5 @@
export interface KloEmbeddingPort {
maxBatchSize: number;
computeEmbedding(text: string): Promise<number[]>;
computeEmbeddingsBulk(texts: string[]): Promise<number[][]>;
}

View file

@ -0,0 +1,43 @@
export interface KloFileWriteResult {
commitHash?: string | null;
[key: string]: unknown;
}
export interface KloFileReadResult {
content: string;
[key: string]: unknown;
}
export interface KloFileListResult {
files: string[];
}
export interface KloFileHistoryEntry {
sha?: string;
message?: string;
author?: string;
date?: string | Date;
[key: string]: unknown;
}
export interface KloFileStorePort<TSelf = unknown> {
writeFile(
path: string,
content: string,
author: string,
authorEmail: string,
commitMessage: string,
options?: { skipLock?: boolean },
): Promise<KloFileWriteResult>;
readFile(path: string): Promise<KloFileReadResult>;
deleteFile(
path: string,
author: string,
authorEmail: string,
commitMessage: string,
options?: { skipLock?: boolean },
): Promise<KloFileWriteResult | null>;
listFiles(path: string, recursive?: boolean): Promise<KloFileListResult>;
getFileHistory(path: string): Promise<KloFileHistoryEntry[] | unknown>;
forWorktree(workdir: string): TSelf;
}

View file

@ -0,0 +1,29 @@
import { simpleGit, type SimpleGit } from 'simple-git';
const GIT_HOOK_ENV_KEYS = [
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
'GIT_DIR',
'GIT_INDEX_FILE',
'GIT_OBJECT_DIRECTORY',
'GIT_PREFIX',
'GIT_QUARANTINE_PATH',
'GIT_WORK_TREE',
'GIT_EDITOR',
'GIT_EXEC_PATH',
'GIT_PAGER',
'PAGER',
'VISUAL',
'EDITOR',
] as const;
function sanitizedGitEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
const sanitized = { ...env };
for (const key of GIT_HOOK_ENV_KEYS) {
delete sanitized[key];
}
return sanitized;
}
export function createSimpleGit(baseDir: string): SimpleGit {
return simpleGit({ baseDir }).env(sanitizedGitEnv());
}

View file

@ -0,0 +1,75 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { SimpleGit } from 'simple-git';
import type { KloCoreConfig } from './config.js';
import { createSimpleGit } from './git-env.js';
import { GitService } from './git.service.js';
describe('GitService.assertWorktreeClean', () => {
let workdir: string;
let git: SimpleGit;
let gitService: GitService;
beforeEach(async () => {
workdir = await mkdtemp(join(tmpdir(), 'gitsvc-clean-'));
git = createSimpleGit(workdir);
await git.init();
await git.addConfig('user.email', 't@test');
await git.addConfig('user.name', 'Test');
await writeFile(join(workdir, 'init'), 'init');
await git.add('.');
await git.commit('init');
const coreConfig: KloCoreConfig = {
storage: { configDir: workdir, homeDir: workdir },
git: { userName: 'Test', userEmail: 't@test' },
};
gitService = new GitService(coreConfig);
(gitService as any).git = git;
(gitService as any).configDir = workdir;
});
afterEach(async () => rm(workdir, { recursive: true, force: true }));
it('does not throw on a clean worktree', async () => {
await expect(gitService.assertWorktreeClean()).resolves.toBeUndefined();
});
it('throws when MERGE_HEAD exists', async () => {
await writeFile(join(workdir, '.git', 'MERGE_HEAD'), 'deadbeef\n');
await expect(gitService.assertWorktreeClean()).rejects.toThrow(/MERGE_HEAD/);
});
it('throws when CHERRY_PICK_HEAD exists', async () => {
await writeFile(join(workdir, '.git', 'CHERRY_PICK_HEAD'), 'deadbeef\n');
await expect(gitService.assertWorktreeClean()).rejects.toThrow(/CHERRY_PICK_HEAD/);
});
it('throws when REVERT_HEAD exists', async () => {
await writeFile(join(workdir, '.git', 'REVERT_HEAD'), 'deadbeef\n');
await expect(gitService.assertWorktreeClean()).rejects.toThrow(/REVERT_HEAD/);
});
it('throws when sequencer/todo exists (interrupted multi-commit revert/cherry-pick)', async () => {
await mkdir(join(workdir, '.git', 'sequencer'), { recursive: true });
await writeFile(join(workdir, '.git', 'sequencer', 'todo'), 'pick deadbeef foo\n');
await expect(gitService.assertWorktreeClean()).rejects.toThrow(/sequencer/);
});
it('throws when the index has unmerged paths', async () => {
await git.checkoutLocalBranch('a');
await writeFile(join(workdir, 'shared'), 'A version');
await git.add('.');
await git.commit('a');
await git.checkout('master').catch(() => git.checkout('main'));
await git.checkoutLocalBranch('b');
await writeFile(join(workdir, 'shared'), 'B version');
await git.add('.');
await git.commit('b');
await git.raw(['merge', 'a']).catch(() => undefined);
await expect(gitService.assertWorktreeClean()).rejects.toThrow();
});
});

View file

@ -0,0 +1,78 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { SimpleGit } from 'simple-git';
import type { KloCoreConfig } from './config.js';
import { createSimpleGit } from './git-env.js';
import { GitService } from './git.service.js';
describe('GitService.deleteDirectories', () => {
let workdir: string;
let git: SimpleGit;
let gitService: GitService;
beforeEach(async () => {
workdir = await mkdtemp(join(tmpdir(), 'gitsvc-dd-'));
git = createSimpleGit(workdir);
await git.init();
await git.addConfig('user.email', 't@test');
await git.addConfig('user.name', 'Test');
await writeFile(join(workdir, 'keep'), 'k');
await git.add('.');
await git.commit('init');
const coreConfig: KloCoreConfig = {
storage: { configDir: workdir, homeDir: workdir },
git: { userName: 'Test', userEmail: 't@test' },
};
gitService = new GitService(coreConfig);
(gitService as any).git = git;
(gitService as any).configDir = workdir;
});
afterEach(async () => rm(workdir, { recursive: true, force: true }));
it('removes multiple directories in a single commit', async () => {
for (const name of ['a', 'b', 'c']) {
await mkdir(join(workdir, name), { recursive: true });
await writeFile(join(workdir, name, 'f.txt'), name);
}
await git.add('.');
await git.commit('seed 3 dirs');
const beforeCommits = (await git.log()).total;
const result = await gitService.deleteDirectories(['a', 'b'], 'gc: drop a+b', 'System User', 'system@example.com');
expect(result.commitHash).toBeTruthy();
const entries = await readdir(workdir);
expect(entries).not.toContain('a');
expect(entries).not.toContain('b');
expect(entries).toContain('c');
const afterCommits = (await git.log()).total;
expect(afterCommits).toBe(beforeCommits + 1);
});
it('no-ops and returns a null hash when the input list is empty', async () => {
const result = await gitService.deleteDirectories([], 'empty', 'X', 'x@example.com');
expect(result.commitHash).toBe('');
expect(result.created).toBe(false);
});
it('ignores paths that have already been deleted — commits only the remaining ones', async () => {
await mkdir(join(workdir, 'stale'), { recursive: true });
await writeFile(join(workdir, 'stale', 'x'), 'x');
await git.add('.');
await git.commit('seed stale');
const result = await gitService.deleteDirectories(
['stale', 'missing'],
'gc: drop stale + missing',
'System User',
'system@example.com',
);
expect(result.commitHash).toBeTruthy();
const entries = await readdir(workdir);
expect(entries).not.toContain('stale');
});
});

View file

@ -0,0 +1,56 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { SimpleGit } from 'simple-git';
import type { KloCoreConfig } from './config.js';
import { createSimpleGit } from './git-env.js';
import { GitService } from './git.service.js';
describe('GitService.resetHardTo', () => {
let workdir: string;
let git: SimpleGit;
let gitService: GitService;
beforeEach(async () => {
workdir = await mkdtemp(join(tmpdir(), 'gitsvc-reset-'));
git = createSimpleGit(workdir);
await git.init();
await git.addConfig('user.email', 't@test');
await git.addConfig('user.name', 'Test');
await writeFile(join(workdir, 'init'), 'init');
await git.add('.');
await git.commit('init');
const coreConfig: KloCoreConfig = {
storage: { configDir: workdir, homeDir: workdir },
git: { userName: 'Test', userEmail: 't@test' },
};
gitService = new GitService(coreConfig);
(gitService as any).git = git;
(gitService as any).configDir = workdir;
});
afterEach(async () => rm(workdir, { recursive: true, force: true }));
it('rewinds HEAD to the target SHA, removing later commits and their files', async () => {
const baseSha = (await git.revparse(['HEAD'])).trim();
await writeFile(join(workdir, 'a'), 'a1');
await git.add('.');
await git.commit('a');
await writeFile(join(workdir, 'b'), 'b1');
await git.add('.');
await git.commit('b');
await gitService.resetHardTo(baseSha);
expect((await git.revparse(['HEAD'])).trim()).toBe(baseSha);
expect(await readFile(join(workdir, 'a'), 'utf-8').catch(() => null)).toBeNull();
expect(await readFile(join(workdir, 'b'), 'utf-8').catch(() => null)).toBeNull();
});
it('is a no-op when target SHA equals current HEAD', async () => {
const sha = (await git.revparse(['HEAD'])).trim();
await gitService.resetHardTo(sha);
expect((await git.revparse(['HEAD'])).trim()).toBe(sha);
});
});

View file

@ -0,0 +1,358 @@
import { mkdtemp, realpath, rm, 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 { KloCoreConfig } from './config.js';
import { GitService } from './git.service.js';
// These tests drive a real git repo inside a temp directory — simple-git shells out to the
// system `git` binary. They are fast enough to run as unit tests and catch real issues that
// would be invisible with mocked git.
describe('GitService', () => {
let service: GitService;
let tempDir: string;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'git-service-spec-'));
const coreConfig: KloCoreConfig = {
storage: { configDir: tempDir, homeDir: tempDir },
git: {
userName: 'Test User',
userEmail: 'test@example.com',
bootstrapMessage: 'Initialize test config repo',
bootstrapAuthor: 'test-system',
bootstrapAuthorEmail: 'system@example.com',
},
};
service = new GitService(coreConfig);
await service.onModuleInit();
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
const writeAndCommit = async (filePath: string, content: string, message = 'msg') => {
await writeFile(join(tempDir, filePath), content, 'utf-8');
return service.commitFile(filePath, message, 'Test', 'test@example.com');
};
describe('cold-start bootstrap commit', () => {
it('writes an empty commit on init so HEAD always resolves', async () => {
// beforeEach already ran onModuleInit() against an empty temp dir.
const head = await service.revParseHead();
expect(head).toMatch(/^[0-9a-f]{40}$/);
});
it('does not double-commit when re-initialized', async () => {
const before = await service.revParseHead();
await service.onModuleInit();
const after = await service.revParseHead();
expect(after).toBe(before);
});
});
describe('commitFile `created` flag', () => {
it('is true for a real commit', async () => {
const info = await writeAndCommit('a.md', '# Hello');
expect(info.created).toBe(true);
});
it('is false on a no-op write (content unchanged)', async () => {
await writeAndCommit('a.md', '# Hello');
const second = await writeAndCommit('a.md', '# Hello', 'unused');
expect(second.created).toBe(false);
});
});
describe('addNote / getNote', () => {
it('attaches a note and reads it back', async () => {
const info = await writeAndCommit('a.md', '# Hello');
await service.addNote(info.commitHash, 'Rich message from LLM');
expect(await service.getNote(info.commitHash)).toBe('Rich message from LLM');
});
it('returns undefined when no note exists', async () => {
const info = await writeAndCommit('a.md', '# Hello');
expect(await service.getNote(info.commitHash)).toBeUndefined();
});
it('overwrites an existing note (idempotent retries)', async () => {
const info = await writeAndCommit('a.md', '# Hello');
await service.addNote(info.commitHash, 'First');
await service.addNote(info.commitHash, 'Second');
expect(await service.getNote(info.commitHash)).toBe('Second');
});
it('skips empty/whitespace messages silently', async () => {
const info = await writeAndCommit('a.md', '# Hello');
await service.addNote(info.commitHash, ' ');
expect(await service.getNote(info.commitHash)).toBeUndefined();
});
});
describe('getFileHistory', () => {
it('surfaces enhancedMessage when a note is present', async () => {
const info = await writeAndCommit('a.md', '# Hello');
await service.addNote(info.commitHash, 'Note body');
const history = await service.getFileHistory('a.md');
expect(history[0]?.enhancedMessage).toBe('Note body');
});
it('leaves enhancedMessage undefined when no note is attached', async () => {
await writeAndCommit('a.md', '# Hello');
const history = await service.getFileHistory('a.md');
expect(history[0]?.enhancedMessage).toBeUndefined();
});
});
describe('getCommitDiff', () => {
it('returns the patch scoped to the requested path', async () => {
const info = await writeAndCommit('a.md', '# Hello');
const diff = await service.getCommitDiff(info.commitHash, 'a.md');
expect(diff).toContain('diff --git');
expect(diff).toContain('Hello');
});
it('handles the repository initial commit without throwing', async () => {
const info = await writeAndCommit('first.md', 'first');
await expect(service.getCommitDiff(info.commitHash, 'first.md')).resolves.toBeDefined();
});
});
describe('squashTo', () => {
const writeAsSystem = async (filePath: string, content: string, message = 'msg') => {
await writeFile(join(tempDir, filePath), content, 'utf-8');
return service.commitFile(filePath, message, 'System User', 'system@example.com');
};
it('collapses 3 commits after preHead into a single commit', async () => {
const pre = await writeAsSystem('a.md', 'v1');
const preHead = pre.commitHash;
await writeAsSystem('b.md', 'b', 'add b');
await writeAsSystem('c.md', 'c', 'add c');
await writeAsSystem('a.md', 'v2', 'update a');
const result = await service.squashTo(preHead, {
message: 'Ingest: bundle 3 writes',
author: 'System User',
authorEmail: 'system@example.com',
});
expect(result.squashed).toBe(true);
expect(result.squashedCount).toBe(3);
expect(result.commitHash).toBeTruthy();
expect(result.commitHash).not.toBe(preHead);
const commitHash = result.commitHash;
if (!commitHash) {
throw new Error('Expected squash commit hash');
}
// The squashed commit should preserve the final tree state.
const fileAtSquash = await service.getFileAtCommit('a.md', commitHash);
expect(fileAtSquash).toBe('v2');
const bAtSquash = await service.getFileAtCommit('b.md', commitHash);
expect(bAtSquash).toBe('b');
});
it('is a no-op when preHead equals HEAD', async () => {
const pre = await writeAsSystem('a.md', 'v1');
const result = await service.squashTo(pre.commitHash, {
message: 'nothing to squash',
author: 'System User',
authorEmail: 'system@example.com',
});
expect(result.squashed).toBe(false);
expect(result.commitHash).toBe(pre.commitHash);
});
it('skips squash when a foreign-author commit sits between preHead and HEAD', async () => {
const pre = await writeAsSystem('a.md', 'v1');
const preHead = pre.commitHash;
await writeAsSystem('b.md', 'from us', 'ours');
// Foreign commit
await writeAndCommit('c.md', 'from someone else', 'foreign');
await writeAsSystem('d.md', 'ours again', 'ours 2');
const result = await service.squashTo(preHead, {
message: 'should be skipped',
author: 'System User',
authorEmail: 'system@example.com',
});
expect(result.squashed).toBe(false);
expect(result.reason).toContain('foreign');
expect(result.squashedCount).toBe(3);
});
it('returns cleanly when preHead is empty (no starting commit)', async () => {
const result = await service.squashTo('', {
message: 'would have squashed',
author: 'System User',
authorEmail: 'system@example.com',
});
expect(result.squashed).toBe(false);
expect(result.commitHash).toBeNull();
});
});
describe('worktree lifecycle', () => {
// macOS canonicalizes tmp paths (/var/folders → /private/var/folders) when git
// returns them from `worktree list`. Resolve through realpath() before comparing.
const canonicalSiblingPath = async (suffix: string): Promise<string> => {
const parent = await realpath(join(tempDir, '..'));
return join(parent, `wt-${Date.now()}-${suffix}`);
};
it('addWorktree creates a branch + directory at the given startSha', async () => {
const { commitHash } = await writeAndCommit('seed.md', 'seed');
const wtDir = await canonicalSiblingPath('add');
await service.addWorktree(wtDir, 'session/alpha', commitHash);
const list = await service.listWorktrees();
expect(list.find((e) => e.path === wtDir && e.branch === 'refs/heads/session/alpha')).toBeTruthy();
await service.removeWorktree(wtDir).catch(() => undefined);
await rm(wtDir, { recursive: true, force: true }).catch(() => undefined);
});
it('removeWorktree detaches the worktree entry', async () => {
const { commitHash } = await writeAndCommit('seed.md', 'seed');
const wtDir = await canonicalSiblingPath('rm');
await service.addWorktree(wtDir, 'session/beta', commitHash);
await service.removeWorktree(wtDir);
const list = await service.listWorktrees();
expect(list.find((e) => e.path === wtDir)).toBeFalsy();
});
it('deleteBranch removes a branch ref', async () => {
const { commitHash } = await writeAndCommit('seed.md', 'seed');
const wtDir = await canonicalSiblingPath('br');
await service.addWorktree(wtDir, 'session/gamma', commitHash);
await service.removeWorktree(wtDir);
await service.deleteBranch('session/gamma', true);
const branches = await (service as unknown as { git: import('simple-git').SimpleGit }).git.branchLocal();
expect(branches.all).not.toContain('session/gamma');
await rm(wtDir, { recursive: true, force: true }).catch(() => undefined);
});
});
describe('forWorktree', () => {
it('returns a GitService whose operations run inside the given worktree', async () => {
const { commitHash } = await writeAndCommit('seed.md', 'seed');
const parent = await realpath(join(tempDir, '..'));
const wtDir = join(parent, `wt-${Date.now()}-fw`);
await service.addWorktree(wtDir, 'session/delta', commitHash);
const scoped = service.forWorktree(wtDir);
expect(await scoped.revParseHead()).toBe(commitHash);
await service.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');
const parent = await realpath(join(tempDir, '..'));
const wtDir = join(parent, `wt-${Date.now()}-sm`);
await service.addWorktree(wtDir, 'session/happy', baseSha);
const scoped = service.forWorktree(wtDir);
await writeFile(join(wtDir, 'a.yaml'), 'one: 1\n', 'utf-8');
await scoped.commitFile('a.yaml', 'wip a', 'System User', 'system@example.com');
await writeFile(join(wtDir, 'b.yaml'), 'two: 2\n', 'utf-8');
await scoped.commitFile('b.yaml', 'wip b', 'System User', 'system@example.com');
const result = await service.squashMergeIntoMain(
'session/happy',
'System User',
'system@example.com',
'Memory capture: 2 files [chat=abcd1234]',
);
expect(result.ok).toBe(true);
if (!result.ok) {
throw new Error('unreachable');
}
expect(result.squashSha).toMatch(/^[0-9a-f]{40}$/);
expect(result.touchedPaths.sort()).toEqual(['a.yaml', 'b.yaml']);
const mainHead = await service.revParseHead();
expect(mainHead).toBe(result.squashSha);
expect(mainHead).not.toBe(baseSha);
await service.removeWorktree(wtDir).catch(() => undefined);
await rm(wtDir, { recursive: true, force: true }).catch(() => undefined);
});
it('returns ok with empty touchedPaths when the session branch has no diff vs main', async () => {
const { commitHash: baseSha } = await writeAndCommit('seed.md', 'seed');
const parent = await realpath(join(tempDir, '..'));
const wtDir = join(parent, `wt-${Date.now()}-sm-empty`);
await service.addWorktree(wtDir, 'session/empty', baseSha);
const result = await service.squashMergeIntoMain(
'session/empty',
'System User',
'system@example.com',
'should be a no-op',
);
expect(result.ok).toBe(true);
if (!result.ok) {
throw new Error('unreachable');
}
expect(result.touchedPaths).toEqual([]);
expect(result.squashSha).toBe(baseSha);
await service.removeWorktree(wtDir).catch(() => undefined);
await rm(wtDir, { recursive: true, force: true }).catch(() => undefined);
});
it('returns conflict=true and leaves main clean when session+main touched same file differently', async () => {
await writeAndCommit('shared.yaml', 'base\n');
const base = await service.revParseHead();
if (!base) {
throw new Error('no base head');
}
const parent = await realpath(join(tempDir, '..'));
const wtDir = join(parent, `wt-${Date.now()}-conf`);
await service.addWorktree(wtDir, 'session/conf', base);
const scoped = service.forWorktree(wtDir);
await writeFile(join(wtDir, 'shared.yaml'), 'session-edit\n', 'utf-8');
await scoped.commitFile('shared.yaml', 'session edit', 'System User', 'system@example.com');
// Main edits the same file a different way, after the session branched.
await writeAndCommit('shared.yaml', 'main-edit\n');
const result = await service.squashMergeIntoMain(
'session/conf',
'System User',
'system@example.com',
'Memory capture: 1 file [chat=dead1234]',
);
expect(result.ok).toBe(false);
if (result.ok) {
throw new Error('unreachable');
}
expect(result.conflict).toBe(true);
expect(result.conflictPaths).toContain('shared.yaml');
const status = await (service as unknown as { git: import('simple-git').SimpleGit }).git.status();
expect(status.isClean()).toBe(true);
await service.removeWorktree(wtDir).catch(() => undefined);
await rm(wtDir, { recursive: true, force: true }).catch(() => undefined);
});
});
});

View file

@ -0,0 +1,855 @@
import { promises as fs } from 'node:fs';
import { join } from 'node:path';
import type { SimpleGit } from 'simple-git';
import { noopLogger, resolveConfigDir, type KloCoreConfig, type KloLogger } from './config.js';
import { createSimpleGit } from './git-env.js';
export interface GitCommitInfo {
commitHash: string;
shortHash: string;
message: string;
author: string;
authorEmail: string;
timestamp: string;
committedDate: string;
/**
* True if this call produced a new commit. False when the file was already up-to-date
* and the returned info describes the pre-existing HEAD commit (no-op write).
*/
created: boolean;
/** Async LLM-generated commit summary attached as a git note. Undefined if no note present. */
enhancedMessage?: string;
}
export interface WorktreeEntry {
path: string;
branch: string | null;
head: string | null;
}
export type SquashMergeResult =
| { ok: true; squashSha: string; touchedPaths: string[] }
| { ok: false; conflict: true; conflictPaths: string[] };
export class GitService {
private readonly logger: KloLogger;
private git!: SimpleGit;
private configDir: string;
constructor(
private readonly config: KloCoreConfig,
logger?: KloLogger,
) {
this.logger = logger ?? noopLogger;
this.configDir = resolveConfigDir(config);
}
async onModuleInit(): Promise<void> {
// Ensure config directory exists
await fs.mkdir(this.configDir, { recursive: true });
this.logger.log(`Config directory ensured at: ${this.configDir}`);
// Initialize simple-git
this.git = createSimpleGit(this.configDir);
// Initialize git repository
await this.initialize();
}
private async initialize(): Promise<void> {
try {
// Check if already initialized
const isRepo = await this.git.checkIsRepo();
if (!isRepo) {
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');
}
// Ensure HEAD always resolves to a commit so callers (e.g., the memory-agent squash flow)
// can rely on `revParseHead()` returning a SHA. Idempotent: skip if HEAD already exists.
const head = await this.revParseHead();
if (!head) {
await this.git.commit(this.config.git.bootstrapMessage ?? 'Initialize klo project repository', {
'--allow-empty': null,
'--author': `${this.config.git.bootstrapAuthor ?? 'klo system'} <${
this.config.git.bootstrapAuthorEmail ?? 'system@klo.local'
}>`,
});
this.logger.log('Wrote bootstrap commit to config repo');
}
} catch (error) {
this.logger.error('Failed to initialize git repository', error);
throw new Error('Failed to initialize git repository');
}
}
async commitFile(
filePath: string,
commitMessage: string,
author: string,
authorEmail: string,
): Promise<GitCommitInfo> {
try {
// Stage the file
await this.git.add(filePath);
// Check if there are any staged changes to commit
const stagedChanges = await this.git.diff(['--cached', '--name-only']);
if (!stagedChanges.trim()) {
// No changes to commit, file already matches what's in git
this.logger.debug(`No changes to commit for ${filePath}, file already up to date`);
// Return info about the current HEAD commit
const log = await this.git.log({ maxCount: 1 });
const commit = log.latest;
if (!commit) {
throw new Error('Failed to retrieve commit details');
}
return {
commitHash: commit.hash,
shortHash: commit.hash.substring(0, 8),
message: commit.message,
author: commit.author_name,
authorEmail: commit.author_email,
timestamp: commit.date,
committedDate: new Date(commit.date).toISOString(),
created: false,
};
}
// There are changes to commit
const result = await this.git.commit(commitMessage, {
'--author': `${author} <${authorEmail}>`,
});
if (!result.commit) {
throw new Error('No commit hash returned');
}
// Get commit details
const log = await this.git.log({ maxCount: 1 });
const commit = log.latest;
if (!commit) {
throw new Error('Failed to retrieve commit details');
}
return {
commitHash: commit.hash,
shortHash: commit.hash.substring(0, 8),
message: commit.message,
author: commit.author_name,
authorEmail: commit.author_email,
timestamp: commit.date,
committedDate: new Date(commit.date).toISOString(),
created: true,
};
} catch (error) {
this.logger.error(`Failed to commit file ${filePath}`, error);
throw new Error(`Failed to commit file: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Stage multiple files and produce a single commit. Mirrors `commitFile` but batches
* N paths into one atomic commit used by the SL capture agent to commit all edits at once.
*/
async commitFiles(
filePaths: string[],
commitMessage: string,
author: string,
authorEmail: string,
): Promise<GitCommitInfo> {
try {
for (const filePath of filePaths) {
await this.git.add(filePath);
}
const stagedChanges = await this.git.diff(['--cached', '--name-only']);
if (!stagedChanges.trim()) {
this.logger.debug(`No changes to commit for ${filePaths.length} file(s), already up to date`);
const log = await this.git.log({ maxCount: 1 });
const commit = log.latest;
if (!commit) {
throw new Error('Failed to retrieve commit details');
}
return {
commitHash: commit.hash,
shortHash: commit.hash.substring(0, 8),
message: commit.message,
author: commit.author_name,
authorEmail: commit.author_email,
timestamp: commit.date,
committedDate: new Date(commit.date).toISOString(),
created: false,
};
}
const result = await this.git.commit(commitMessage, {
'--author': `${author} <${authorEmail}>`,
});
if (!result.commit) {
throw new Error('No commit hash returned');
}
const log = await this.git.log({ maxCount: 1 });
const commit = log.latest;
if (!commit) {
throw new Error('Failed to retrieve commit details');
}
return {
commitHash: commit.hash,
shortHash: commit.hash.substring(0, 8),
message: commit.message,
author: commit.author_name,
authorEmail: commit.author_email,
timestamp: commit.date,
committedDate: new Date(commit.date).toISOString(),
created: true,
};
} catch (error) {
this.logger.error(`Failed to batch commit ${filePaths.length} file(s)`, error);
throw new Error(`Failed to batch commit: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Revert working-tree changes for the given paths (equivalent to `git checkout -- <paths>`).
* Used to roll back dirty files when validation fails.
*/
async checkoutFiles(filePaths: string[]): Promise<void> {
if (filePaths.length === 0) {
return;
}
try {
await this.git.checkout(['--', ...filePaths]);
} catch (error) {
this.logger.warn(
`Failed to checkout ${filePaths.length} file(s): ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* Read the content of `filePath` as it existed at `commitHash`. Equivalent to
* `git show <sha>:<path>`. Reads from git object storage, so it's safe against
* concurrent working-tree mutations.
*/
async getFileAtCommit(filePath: string, commitHash: string): Promise<string> {
try {
return await this.git.show([`${commitHash}:${filePath}`]);
} catch (error) {
this.logger.error(`Failed to read ${filePath} at ${commitHash}`, error);
throw new Error(`Failed to read file at commit: ${error instanceof Error ? error.message : String(error)}`);
}
}
async getFileHistory(filePath: string, limit: number = 50): Promise<GitCommitInfo[]> {
try {
const log = await this.git.log({
file: filePath,
maxCount: limit,
});
// N+1 fetch of notes is fine here: capped at 100 commits, cold UI path.
return Promise.all(
log.all.map(async (commit) => ({
commitHash: commit.hash,
shortHash: commit.hash.substring(0, 8),
message: commit.message,
author: commit.author_name,
authorEmail: commit.author_email,
timestamp: commit.date,
committedDate: new Date(commit.date).toISOString(),
created: true,
enhancedMessage: await this.getNote(commit.hash),
})),
);
} catch (error) {
this.logger.error(`Failed to get history for ${filePath}`, error);
throw new Error(`Failed to retrieve file history: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Attach or overwrite an LLM-generated summary note on a commit.
* Uses `-f` so retries overwrite rather than fail on existing notes (idempotent).
* Callers are responsible for holding `config:repo` Redlock notes writes mutate
* `.git/refs/notes/commits` and must serialize with commits.
*/
async addNote(commitHash: string, message: string): Promise<void> {
const trimmed = message.trim();
if (!trimmed) {
return;
}
try {
await this.git.raw(['notes', 'add', '-f', '-m', trimmed, commitHash]);
} catch (error) {
this.logger.error(`Failed to attach note to ${commitHash}`, error);
throw new Error(`Failed to attach git note: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Read the LLM-generated note for a commit, or undefined if none present.
* Swallows `simple-git`'s "no note found" error so callers can treat it as optional.
*/
async getNote(commitHash: string): Promise<string | undefined> {
try {
const note = await this.git.raw(['notes', 'show', commitHash]);
const trimmed = note.trim();
return trimmed ? trimmed : undefined;
} catch {
// `git notes show` exits non-zero when no note exists — treat as "no note".
return undefined;
}
}
/**
* Return the patch for a commit, optionally scoped to a single path.
* Strips the commit header above the first `diff --git` so only the patch body remains,
* and clips to 12 KB to bound LLM token cost. Returns '' if the commit changed nothing
* on the requested path (e.g. a commit that only touched other files).
*/
async getCommitDiff(commitHash: string, path?: string): Promise<string> {
const args = ['show', '--format=', '--no-color', '--patch', commitHash];
if (path) {
args.push('--', path);
}
try {
const raw = await this.git.raw(args);
const diffStart = raw.indexOf('diff --git');
const body = diffStart >= 0 ? raw.slice(diffStart) : raw.trim();
const MAX_DIFF_BYTES = 12_000;
return body.length > MAX_DIFF_BYTES ? `${body.slice(0, MAX_DIFF_BYTES)}\n… [diff truncated]` : body;
} catch (error) {
this.logger.error(`Failed to read diff for ${commitHash}`, error);
throw new Error(`Failed to read commit diff: ${error instanceof Error ? error.message : String(error)}`);
}
}
async deleteFile(
filePath: string,
commitMessage: string,
author: string,
authorEmail: string,
): Promise<GitCommitInfo> {
try {
// Remove the file from git
await this.git.rm(filePath);
// Commit the deletion
const result = await this.git.commit(commitMessage, {
'--author': `${author} <${authorEmail}>`,
});
if (!result.commit) {
throw new Error('No commit hash returned');
}
// Get commit details
const log = await this.git.log({ maxCount: 1 });
const commit = log.latest;
if (!commit) {
throw new Error('Failed to retrieve commit details');
}
return {
commitHash: commit.hash,
shortHash: commit.hash.substring(0, 8),
message: commit.message,
author: commit.author_name,
authorEmail: commit.author_email,
timestamp: commit.date,
committedDate: new Date(commit.date).toISOString(),
created: true,
};
} catch (error) {
this.logger.error(`Failed to delete file ${filePath}`, error);
throw new Error(`Failed to delete file: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Resolve HEAD to a full commit SHA. Returns the empty string if the repo has no commits yet
* (a freshly-init'd repo before any writes), so callers can treat that as "nothing to reconcile".
*/
async revParseHead(): Promise<string> {
try {
const sha = await this.git.revparse(['HEAD']);
return sha.trim();
} catch {
return '';
}
}
/**
* Verify a commit object exists in the local repo. Used by the reconciler to detect
* the "history was rewritten / partial clone" case before attempting `git diff $sha..HEAD`.
*/
async commitExists(commitHash: string): Promise<boolean> {
if (!commitHash) {
return false;
}
try {
await this.git.raw(['cat-file', '-e', `${commitHash}^{commit}`]);
return true;
} catch {
return false;
}
}
/**
* `git diff --name-status $from..$to -- $pathSpec`. Returns one entry per changed path.
* Renames (`R{score}\told\tnew`) are split into a `D` for the old path plus an `A` for
* the new the reconciler treats each path independently and the new path's row will
* upsert with whatever content the file actually has.
*/
async diffNameStatus(
from: string,
to: string,
pathSpec?: string,
): Promise<Array<{ status: 'A' | 'M' | 'D'; path: string }>> {
const args = ['diff', '--name-status', '-z', `${from}..${to}`];
if (pathSpec) {
args.push('--', pathSpec);
}
const raw = await this.git.raw(args);
if (!raw) {
return [];
}
// -z output: NUL-separated fields. For A/M/D: "<status>\0<path>\0". For R/C: "<status>\0<old>\0<new>\0".
const fields = raw.split('\0').filter((f) => f.length > 0);
const out: Array<{ status: 'A' | 'M' | 'D'; path: string }> = [];
let i = 0;
while (i < fields.length) {
const status = fields[i];
const code = status[0];
if (code === 'R' || code === 'C') {
const oldPath = fields[i + 1];
const newPath = fields[i + 2];
out.push({ status: 'D', path: oldPath });
out.push({ status: 'A', path: newPath });
i += 3;
} else if (code === 'A' || code === 'M' || code === 'D') {
out.push({ status: code, path: fields[i + 1] });
i += 2;
} else {
// Unknown status (T type-change, U unmerged, X unknown) — treat as modify, skip if no path
if (fields[i + 1]) {
out.push({ status: 'M', path: fields[i + 1] });
}
i += 2;
}
}
return out;
}
/**
* List all paths under the working tree that match `pathSpec`, scoped to HEAD.
* Used for the reconciler's first-ever run when there's no watermark to diff from.
*/
async listFilesAtHead(pathSpec: string): Promise<string[]> {
try {
const raw = await this.git.raw(['ls-tree', '-r', '-z', '--name-only', 'HEAD', '--', pathSpec]);
if (!raw) {
return [];
}
return raw.split('\0').filter((f) => f.length > 0);
} catch {
return [];
}
}
/**
* Collapse all commits between `preHead` and current HEAD into a single commit with the given
* message. Used by the memory agent to squash N per-tool-call commits into one ingest commit.
*
* Author-check guard: if any commit between preHead..HEAD has an author other than
* `expectedAuthor`, skips the squash and returns `{ squashed: false, reason: ... }`. This
* prevents accidentally collapsing another writer's commits if writes interleaved with ours.
*
* Caller is responsible for holding the `config:repo` lock so writes and squash serialize.
*/
async squashTo(
preHead: string,
options: { message: string; author: string; authorEmail: string; expectedAuthor?: string },
): Promise<{ squashed: boolean; commitHash: string | null; reason?: string; squashedCount?: number }> {
const { message, author, authorEmail } = options;
const expectedAuthor = options.expectedAuthor ?? author;
if (!preHead) {
return { squashed: false, commitHash: null, reason: 'no pre-head recorded (empty repo at start)' };
}
let currentHead: string;
try {
currentHead = (await this.git.revparse(['HEAD'])).trim();
} catch {
return { squashed: false, commitHash: null, reason: 'no HEAD (repo is empty)' };
}
if (currentHead === preHead) {
return { squashed: false, commitHash: preHead, reason: 'no new commits' };
}
try {
const log = await this.git.log({ from: preHead, to: 'HEAD' });
const commits = log.all;
if (commits.length === 0) {
return { squashed: false, commitHash: preHead, reason: 'no new commits' };
}
const foreign = commits.find((c) => c.author_name !== expectedAuthor);
if (foreign) {
this.logger.warn(
`Skipping squash: commit ${foreign.hash.substring(0, 8)} authored by "${foreign.author_name}" ` +
`differs from expected "${expectedAuthor}". Leaving ${commits.length} commit(s) as-is.`,
);
return {
squashed: false,
commitHash: currentHead,
reason: `foreign commit by ${foreign.author_name}`,
squashedCount: commits.length,
};
}
// Soft reset to preHead, then produce a single commit with all the staged changes.
await this.git.reset(['--soft', preHead]);
const staged = await this.git.diff(['--cached', '--name-only']);
if (!staged.trim()) {
// All intervening commits cancelled each other out — return to preHead and commit nothing.
return { squashed: true, commitHash: preHead, reason: 'no net changes', squashedCount: commits.length };
}
await this.git.commit(message, { '--author': `${author} <${authorEmail}>` });
const newHead = (await this.git.revparse(['HEAD'])).trim();
this.logger.log(
`squashTo: collapsed ${commits.length} commit(s) into ${newHead.substring(0, 8)} (was ${currentHead.substring(0, 8)})`,
);
return { squashed: true, commitHash: newHead, squashedCount: commits.length };
} catch (error) {
this.logger.error('Failed to squash commits', error);
throw new Error(`Failed to squash commits: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Squash-merge `branch` into the currently-checked-out branch of THIS worktree (the
* main worktree, when called on the root GitService instance). Produces a single
* commit whose tree equals the source branch's tree, with the given message/author.
* Returns `{ ok: false, conflict: true, conflictPaths }` and leaves the main worktree
* clean if git reports merge conflicts.
*
* Caller must hold the `config:repo` lock so interactive writes don't race against the
* merge window.
*/
async squashMergeIntoMain(
branch: string,
author: string,
authorEmail: string,
commitMessage: string,
): Promise<SquashMergeResult> {
// Diff of HEAD..branch (two dots) lists commits/files reachable from `branch` that
// aren't on HEAD — i.e. exactly what the squash would apply. Three dots (HEAD...branch)
// is symmetric difference and would mis-classify cases where main moved ahead.
const diff = await this.git.raw(['diff', '--name-only', `HEAD..${branch}`]);
const touchedPaths = diff
.split('\n')
.map((l) => l.trim())
.filter(Boolean);
if (touchedPaths.length === 0) {
const head = (await this.git.revparse(['HEAD'])).trim();
return { ok: true, squashSha: head, touchedPaths: [] };
}
// `git merge --squash` may NOT throw on a textual conflict — it stages the clean
// hunks and leaves conflicted paths unmerged in the index. simple-git may also
// throw if the underlying git exits non-zero. Handle both: try the merge, then
// independently inspect the index for unmerged paths before committing.
let mergeError: unknown = null;
try {
await this.git.raw(['merge', '--squash', branch]);
} catch (error) {
mergeError = error;
}
const unmergedOut = await this.git.raw(['diff', '--name-only', '--diff-filter=U']).catch(() => '');
const conflictPaths = unmergedOut
.split('\n')
.map((l) => l.trim())
.filter(Boolean);
if (conflictPaths.length > 0 || mergeError !== null) {
// `merge --abort` only works for an in-progress merge; squash sets MERGE_MSG but not
// MERGE_HEAD, so fall back to a hard reset which clears the index and worktree.
await this.git.raw(['merge', '--abort']).catch(() => undefined);
await this.git.raw(['reset', '--hard', 'HEAD']).catch(() => undefined);
this.logger.warn(
`squashMergeIntoMain: conflict merging ${branch} — aborted. conflictPaths=${conflictPaths.join(',')}` +
(mergeError ? ` error=${mergeError instanceof Error ? mergeError.message : String(mergeError)}` : ''),
);
return { ok: false, conflict: true, conflictPaths };
}
await this.git.commit(commitMessage, { '--author': `${author} <${authorEmail}>` });
const squashSha = (await this.git.revparse(['HEAD'])).trim();
return { ok: true, squashSha, touchedPaths };
}
/**
* Rewinds the current branch's HEAD to `targetSha`, discarding all later commits and any
* uncommitted worktree changes. Used by Stage-3 to back out a failed work-unit's commits
* on the session worktree - simpler and more robust than `git revert` over a multi-commit
* range, which can pause the sequencer on conflicts.
*/
async resetHardTo(targetSha: string): Promise<void> {
await this.git.raw(['reset', '--hard', targetSha]);
}
/**
* Throws if the worktree is in a state that would make a downstream merge unsafe: an
* in-progress merge, rebase, cherry-pick, revert, interrupted sequencer operation, or
* unmerged paths in the index.
*/
async assertWorktreeClean(): Promise<void> {
const inProgressMarkers: ReadonlyArray<{ relPath: string; label: string }> = [
{ relPath: 'MERGE_HEAD', label: 'MERGE_HEAD' },
{ relPath: 'REBASE_HEAD', label: 'REBASE_HEAD' },
{ relPath: 'CHERRY_PICK_HEAD', label: 'CHERRY_PICK_HEAD' },
{ relPath: 'REVERT_HEAD', label: 'REVERT_HEAD' },
{ relPath: 'sequencer/todo', label: 'sequencer (interrupted multi-commit op)' },
];
for (const { relPath, label } of inProgressMarkers) {
const gitPath = (await this.git.raw(['rev-parse', '--git-path', relPath])).trim();
const fullPath = gitPath.startsWith('/') ? gitPath : join(this.configDir, gitPath);
if (await this.fileExists(fullPath)) {
throw new Error(
`Worktree has in-progress git operation (${label} present at ${fullPath}); refusing to proceed`,
);
}
}
const unmerged = (await this.git.raw(['diff', '--name-only', '--diff-filter=U']).catch(() => ''))
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
if (unmerged.length > 0) {
throw new Error(
`Worktree has ${unmerged.length} unmerged path(s): ${unmerged.slice(0, 5).join(', ')}; refusing to proceed`,
);
}
}
private async fileExists(path: string): Promise<boolean> {
try {
await fs.access(path);
return true;
} catch {
return false;
}
}
/**
* Create a new worktree at `path` with a new branch `branch` pointing at `startSha`.
* Used by the memory agent to isolate per-session writes from interactive saves on main.
*/
async addWorktree(path: string, branch: string, startSha: string): Promise<void> {
try {
await this.git.raw(['worktree', 'add', '-b', branch, path, startSha]);
} catch (error) {
throw new Error(`Failed to add worktree at ${path}: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Remove the worktree entry and its on-disk directory. Uses `--force` because session
* worktrees are klo-internal a clean working tree is not required.
*/
async removeWorktree(path: string): Promise<void> {
try {
await this.git.raw(['worktree', 'remove', '--force', path]);
} catch (error) {
this.logger.warn(
`removeWorktree failed for ${path}: ${error instanceof Error ? error.message : String(error)} — attempting prune`,
);
await this.git.raw(['worktree', 'prune']).catch(() => undefined);
}
}
/**
* List all worktrees attached to this repo, parsed from `worktree list --porcelain`.
* The main worktree is included.
*/
async listWorktrees(): Promise<WorktreeEntry[]> {
const out = await this.git.raw(['worktree', 'list', '--porcelain']);
const entries: WorktreeEntry[] = [];
let current: Partial<WorktreeEntry> = {};
for (const line of out.split('\n')) {
if (line.startsWith('worktree ')) {
if (current.path) {
entries.push({
path: current.path,
branch: current.branch ?? null,
head: current.head ?? null,
});
}
current = { path: line.slice('worktree '.length) };
} else if (line.startsWith('HEAD ')) {
current.head = line.slice('HEAD '.length);
} else if (line.startsWith('branch ')) {
current.branch = line.slice('branch '.length);
}
}
if (current.path) {
entries.push({
path: current.path,
branch: current.branch ?? null,
head: current.head ?? null,
});
}
return entries;
}
async deleteBranch(branch: string, force = false): Promise<void> {
await this.git.raw(['branch', force ? '-D' : '-d', branch]);
}
/**
* Lightweight factory returning a GitService instance whose simple-git client is scoped
* to `workdir`. Used by memory-agent session worktrees. The returned instance shares
* config and the logger with the parent; it does NOT run `onModuleInit`
* (the main instance has already initialized the repo).
*/
forWorktree(workdir: string): GitService {
const scoped = new GitService(this.config, this.logger);
scoped.git = createSimpleGit(workdir);
scoped.configDir = workdir;
return scoped;
}
async deleteDirectory(
directoryPath: string,
commitMessage: string,
author: string,
authorEmail: string,
): Promise<GitCommitInfo> {
try {
// Remove the directory recursively from git
await this.git.rm(['-r', directoryPath]);
// Commit the deletion
const result = await this.git.commit(commitMessage, {
'--author': `${author} <${authorEmail}>`,
});
if (!result.commit) {
throw new Error('No commit hash returned');
}
// Get commit details
const log = await this.git.log({ maxCount: 1 });
const commit = log.latest;
if (!commit) {
throw new Error('Failed to retrieve commit details');
}
return {
commitHash: commit.hash,
shortHash: commit.hash.substring(0, 8),
message: commit.message,
author: commit.author_name,
authorEmail: commit.author_email,
timestamp: commit.date,
committedDate: new Date(commit.date).toISOString(),
created: true,
};
} catch (error) {
this.logger.error(`Failed to delete directory ${directoryPath}`, error);
throw new Error(`Failed to delete directory: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Remove multiple directories recursively and commit them as one change.
* Paths that don't exist in the working tree are skipped silently (useful for GC
* where the DB-known path has already been evicted by a previous run).
* Returns a GitCommitInfo with created=false and an empty commitHash when no
* paths were actually removed.
*/
async deleteDirectories(
directoryPaths: string[],
commitMessage: string,
author: string,
authorEmail: string,
): Promise<GitCommitInfo> {
if (directoryPaths.length === 0) {
return {
commitHash: '',
shortHash: '',
message: commitMessage,
author,
authorEmail,
timestamp: new Date().toISOString(),
committedDate: new Date().toISOString(),
created: false,
};
}
const removed: string[] = [];
for (const path of directoryPaths) {
try {
await this.git.rm(['-r', path]);
removed.push(path);
} catch (error) {
this.logger.warn(
`deleteDirectories: skipping ${path}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
if (removed.length === 0) {
return {
commitHash: '',
shortHash: '',
message: commitMessage,
author,
authorEmail,
timestamp: new Date().toISOString(),
committedDate: new Date().toISOString(),
created: false,
};
}
const result = await this.git.commit(commitMessage, { '--author': `${author} <${authorEmail}>` });
if (!result.commit) {
throw new Error('No commit hash returned from deleteDirectories');
}
const log = await this.git.log({ maxCount: 1 });
const commit = log.latest;
if (!commit) {
throw new Error('Failed to retrieve commit details after deleteDirectories');
}
return {
commitHash: commit.hash,
shortHash: commit.hash.substring(0, 8),
message: commit.message,
author: commit.author_name,
authorEmail: commit.author_email,
timestamp: commit.date,
committedDate: new Date(commit.date).toISOString(),
created: true,
};
}
}

View file

@ -0,0 +1,27 @@
export type { KloCoreConfig, KloGitConfig, KloLogger, KloStorageConfig } from './config.js';
export { noopLogger, resolveConfigDir, resolveWorktreesDir } from './config.js';
export { resolveKloConfigReference, resolveKloHomePath } from './config-reference.js';
export type { KloEmbeddingPort } from './embedding.js';
export {
REDACTED_KLO_CREDENTIAL_VALUE,
redactKloSensitiveMetadata,
redactKloSensitiveText,
redactKloSensitiveValue,
} from './redaction.js';
export type {
KloFileHistoryEntry,
KloFileListResult,
KloFileReadResult,
KloFileStorePort,
KloFileWriteResult,
} from './file-store.js';
export type { GitCommitInfo, SquashMergeResult, WorktreeEntry } from './git.service.js';
export { GitService } from './git.service.js';
export type {
SentinelPayload,
SessionOutcome,
SessionWorktree,
SessionWorktreeServiceDeps,
WorktreeConfigPort,
} from './session-worktree.service.js';
export { SessionWorktreeService } from './session-worktree.service.js';

View file

@ -0,0 +1,47 @@
export const REDACTED_KLO_CREDENTIAL_VALUE = '<redacted>';
const SENSITIVE_FIELD_NAME = /(password|secret|token|api[_-]?key|private[_-]?key|passphrase|credential|authorization|url)/i;
const URL_CREDENTIAL_PATTERN = /([a-z][a-z0-9+.-]*:\/\/[^:\s/@]+:)([^@\s/]+)(@)/gi;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isSensitiveField(key: string): boolean {
return SENSITIVE_FIELD_NAME.test(key);
}
export function redactKloSensitiveValue(key: string, value: unknown): unknown {
if (isSensitiveField(key)) {
return REDACTED_KLO_CREDENTIAL_VALUE;
}
if (Array.isArray(value)) {
return value.map((item) => redactKloSensitiveValue(key, item));
}
if (isRecord(value)) {
return redactKloSensitiveMetadata(value);
}
return value;
}
export function redactKloSensitiveMetadata(metadata: Record<string, unknown>): Record<string, unknown> {
const redacted: Record<string, unknown> = {};
for (const [key, value] of Object.entries(metadata)) {
if (Array.isArray(value)) {
redacted[key] = value.map((item) =>
isRecord(item) ? redactKloSensitiveMetadata(item) : redactKloSensitiveValue(key, item),
);
continue;
}
if (isRecord(value)) {
redacted[key] = redactKloSensitiveValue(key, value);
continue;
}
redacted[key] = redactKloSensitiveValue(key, value);
}
return redacted;
}
export function redactKloSensitiveText(value: string): string {
return value.replace(URL_CREDENTIAL_PATTERN, `$1${REDACTED_KLO_CREDENTIAL_VALUE}$3`);
}

View file

@ -0,0 +1,124 @@
import { mkdtemp, realpath, rm, stat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { KloCoreConfig } from './config.js';
import { GitService } from './git.service.js';
import { SessionWorktreeService, type WorktreeConfigPort } from './session-worktree.service.js';
interface TestWorktreeConfig extends WorktreeConfigPort<TestWorktreeConfig> {
workdir?: string;
}
// SessionWorktreeService glues a real GitService to a scoped config adapter.
describe('SessionWorktreeService', () => {
let sessionService: SessionWorktreeService<TestWorktreeConfig>;
let gitService: GitService;
let homeDir: string;
beforeEach(async () => {
homeDir = await mkdtemp(join(tmpdir(), 'sws-spec-'));
homeDir = await realpath(homeDir);
const coreConfig: KloCoreConfig = {
storage: { configDir: homeDir, homeDir },
git: {
userName: 'System User',
userEmail: 'system@example.com',
bootstrapMessage: 'Initialize test config repo',
bootstrapAuthor: 'test-system',
bootstrapAuthorEmail: 'system@example.com',
},
};
gitService = new GitService(coreConfig);
await gitService.onModuleInit();
const configService: TestWorktreeConfig = {
forWorktree: vi.fn(
(workdir: string): TestWorktreeConfig => ({ workdir, forWorktree: configService.forWorktree }),
),
};
sessionService = new SessionWorktreeService({
coreConfig,
gitService,
configService,
});
});
afterEach(async () => {
await rm(homeDir, { recursive: true, force: true });
});
describe('create', () => {
it('creates a worktree + branch and returns scoped services', async () => {
const baseSha = await gitService.revParseHead();
if (!baseSha) {
throw new Error('no base sha');
}
const session = await sessionService.create('chat-abc', baseSha);
expect(session.workdir).toBe(join(homeDir, '.worktrees', 'session-chat-abc'));
expect(session.branch).toBe('session/chat-abc');
expect(session.baseSha).toBe(baseSha);
const stats = await stat(session.workdir);
expect(stats.isDirectory()).toBe(true);
// Scoped git instance reports the worktree's HEAD (= baseSha at creation time).
expect(await session.git.revParseHead()).toBe(baseSha);
const list = await gitService.listWorktrees();
expect(list.find((e) => e.path === session.workdir)).toBeTruthy();
});
it('appends a timestamp suffix when the primary dir already exists', async () => {
const baseSha = await gitService.revParseHead();
if (!baseSha) {
throw new Error('no base sha');
}
const first = await sessionService.create('chat-dup', baseSha);
const second = await sessionService.create('chat-dup', baseSha);
expect(first.workdir).not.toBe(second.workdir);
expect(second.branch).toMatch(/^session\/chat-dup-\d+$/);
});
});
describe('cleanup', () => {
it('success removes the worktree dir and deletes the branch', async () => {
const baseSha = await gitService.revParseHead();
if (!baseSha) {
throw new Error('no base sha');
}
const session = await sessionService.create('chat-cleanup-ok', baseSha);
await sessionService.cleanup(session, 'success');
const list = await gitService.listWorktrees();
expect(list.find((e) => e.path === session.workdir)).toBeFalsy();
await expect(stat(session.workdir)).rejects.toThrow();
});
it('conflict keeps the worktree and writes a sentinel file', async () => {
const baseSha = await gitService.revParseHead();
if (!baseSha) {
throw new Error('no base sha');
}
const session = await sessionService.create('chat-cleanup-conflict', baseSha);
await sessionService.cleanup(session, 'conflict', { conflictPaths: ['shared.yaml'] });
// Dir still exists.
await expect(stat(session.workdir)).resolves.toBeTruthy();
const { readFile } = await import('node:fs/promises');
const raw = await readFile(join(session.workdir, '.klo-outcome'), 'utf-8');
const parsed = JSON.parse(raw);
expect(parsed.outcome).toBe('conflict');
expect(parsed.chatId).toBe('chat-cleanup-conflict');
expect(parsed.conflictPaths).toEqual(['shared.yaml']);
expect(typeof parsed.at).toBe('string');
});
});
});

View file

@ -0,0 +1,113 @@
import { mkdir, stat, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { noopLogger, resolveWorktreesDir, type KloCoreConfig, type KloLogger } from './config.js';
import { GitService } from './git.service.js';
export type SessionOutcome = 'success' | 'empty' | 'conflict' | 'crash';
export interface SentinelPayload {
outcome: SessionOutcome;
at: string;
chatId: string;
baseSha: string;
conflictPaths?: string[];
}
export interface WorktreeConfigPort<TConfig> {
forWorktree(workdir: string): TConfig;
}
export interface SessionWorktree<TConfig> {
chatId: string;
workdir: string;
branch: string;
baseSha: string;
createdAt: Date;
git: GitService;
config: TConfig;
}
export interface SessionWorktreeServiceDeps<TConfig extends WorktreeConfigPort<TConfig>> {
coreConfig: KloCoreConfig;
gitService: GitService;
configService: TConfig;
logger?: KloLogger;
}
export class SessionWorktreeService<TConfig extends WorktreeConfigPort<TConfig> = WorktreeConfigPort<never>> {
private readonly logger: KloLogger;
private readonly worktreesRoot: string;
constructor(private readonly deps: SessionWorktreeServiceDeps<TConfig>) {
this.logger = deps.logger ?? noopLogger;
this.worktreesRoot = resolveWorktreesDir(deps.coreConfig);
}
async create(sessionKey: string, baseSha: string): Promise<SessionWorktree<TConfig>> {
await mkdir(this.worktreesRoot, { recursive: true });
let dirName = `session-${sessionKey}`;
let branch = `session/${sessionKey}`;
let workdir = join(this.worktreesRoot, dirName);
try {
await stat(workdir);
const suffix = Date.now().toString();
dirName = `session-${sessionKey}-${suffix}`;
branch = `session/${sessionKey}-${suffix}`;
workdir = join(this.worktreesRoot, dirName);
this.logger.warn(`session worktree collision for key=${sessionKey}; using suffix ${suffix}`);
} catch {
// no collision: primary name is free
}
await this.deps.gitService.addWorktree(workdir, branch, baseSha);
return {
chatId: sessionKey,
workdir,
branch,
baseSha,
createdAt: new Date(),
git: this.deps.gitService.forWorktree(workdir),
config: this.deps.configService.forWorktree(workdir),
};
}
async cleanup(
session: SessionWorktree<TConfig>,
outcome: SessionOutcome,
extra?: { conflictPaths?: string[] },
): Promise<void> {
if (outcome === 'success' || outcome === 'empty') {
try {
await this.deps.gitService.removeWorktree(session.workdir);
await this.deps.gitService.deleteBranch(session.branch, true);
} catch (error) {
this.logger.warn(
`cleanup(${outcome}) failed for ${session.chatId}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
return;
}
const payload: SentinelPayload = {
outcome,
at: new Date().toISOString(),
chatId: session.chatId,
baseSha: session.baseSha,
...(extra?.conflictPaths ? { conflictPaths: extra.conflictPaths } : {}),
};
try {
await writeFile(join(session.workdir, '.klo-outcome'), JSON.stringify(payload, null, 2), 'utf-8');
} catch (error) {
this.logger.warn(
`cleanup(${outcome}) failed to write sentinel for ${session.chatId}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
}