mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-10 08:05:14 +02:00
fix(cli): isolate ktx project git repos
This commit is contained in:
parent
7b0023471e
commit
c2607de9b2
4 changed files with 173 additions and 13 deletions
|
|
@ -27,6 +27,24 @@ export interface WorktreeEntry {
|
|||
head: string | null;
|
||||
}
|
||||
|
||||
const KTX_MANAGED_GIT_CONFIG_KEY = 'ktx.managed';
|
||||
|
||||
type GitDirState = 'absent' | 'directory' | 'foreign';
|
||||
|
||||
class KtxForeignGitRepositoryError extends Error {
|
||||
constructor(configDir: string) {
|
||||
super(
|
||||
`${configDir} is already a git repository that ktx did not create. ` +
|
||||
'ktx maintains its context in a repository it owns; run ktx in a dedicated directory or move the existing repository aside.',
|
||||
);
|
||||
this.name = 'KtxForeignGitRepositoryError';
|
||||
}
|
||||
}
|
||||
|
||||
function isNodeErrnoException(error: unknown): error is NodeJS.ErrnoException {
|
||||
return error instanceof Error && 'code' in error;
|
||||
}
|
||||
|
||||
export type SquashMergeResult =
|
||||
| { ok: true; squashSha: string; touchedPaths: string[] }
|
||||
| { ok: false; conflict: true; conflictPaths: string[] };
|
||||
|
|
@ -96,14 +114,42 @@ export class GitService {
|
|||
await this.initialize();
|
||||
}
|
||||
|
||||
private async gitDirState(): Promise<GitDirState> {
|
||||
try {
|
||||
const stat = await fs.lstat(join(this.configDir, '.git'));
|
||||
return stat.isDirectory() ? 'directory' : 'foreign';
|
||||
} catch (error) {
|
||||
if (isNodeErrnoException(error) && error.code === 'ENOENT') {
|
||||
return 'absent';
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async hasKtxManagedMarker(): Promise<boolean> {
|
||||
try {
|
||||
const value = await this.git.raw(['config', '--local', '--get', KTX_MANAGED_GIT_CONFIG_KEY]);
|
||||
return value.trim() === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async initialize(): Promise<void> {
|
||||
try {
|
||||
// Check if already initialized
|
||||
const isRepo = await this.git.checkIsRepo();
|
||||
const gitDirState = await this.gitDirState();
|
||||
|
||||
if (!isRepo) {
|
||||
if (gitDirState === 'absent') {
|
||||
await this.git.init();
|
||||
this.logger.log('Initialized git repository');
|
||||
await this.git.addConfig(KTX_MANAGED_GIT_CONFIG_KEY, 'true');
|
||||
this.logger.log('Initialized ktx-managed git repository');
|
||||
} else if (gitDirState === 'directory') {
|
||||
const isManaged = await this.hasKtxManagedMarker();
|
||||
if (!isManaged) {
|
||||
throw new KtxForeignGitRepositoryError(this.configDir);
|
||||
}
|
||||
} else {
|
||||
throw new KtxForeignGitRepositoryError(this.configDir);
|
||||
}
|
||||
|
||||
// Keep any auto-maintenance triggered by writes in-process. Detached maintenance can
|
||||
|
|
|
|||
|
|
@ -6,14 +6,10 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|||
import type { KtxCoreConfig } from '../../../src/context/core/config.js';
|
||||
import { GitService } from '../../../src/context/core/git.service.js';
|
||||
|
||||
// Regression for the production exception "Failed to initialize git repository"
|
||||
// (PostHog issue 019ea9df-96d6-7882-98e2-6b892bf9c1ab, ktx 0.10.0, darwin).
|
||||
//
|
||||
// Repro: the project directory is ALREADY a git repo with no commits (the user ran
|
||||
// `git init` first, or ktx is pointed at an empty repo), AND the machine has no configured
|
||||
// git identity (a fresh Mac with no ~/.gitconfig). GitService only set the committer identity
|
||||
// on the path where it created the repo itself, so the bootstrap commit failed with
|
||||
// "Committer identity unknown" and was rethrown opaquely.
|
||||
// Regression for bootstrapping a marked ktx repo on a machine with no configured
|
||||
// git identity. A foreign pre-existing repo is rejected by the ownership rule;
|
||||
// this test covers the still-valid path where the repo is already ktx-managed
|
||||
// but has no HEAD yet.
|
||||
describe('GitService.initialize without a configured git identity', () => {
|
||||
let repoDir: string;
|
||||
let homeDir: string;
|
||||
|
|
@ -61,8 +57,12 @@ describe('GitService.initialize without a configured git identity', () => {
|
|||
delete process.env[key];
|
||||
}
|
||||
|
||||
// Pre-create an empty repo: checkIsRepo() will be true, but there is no HEAD yet.
|
||||
execFileSync('git', ['init'], { cwd: repoDir, env: process.env, stdio: 'ignore' });
|
||||
execFileSync('git', ['config', '--local', 'ktx.managed', 'true'], {
|
||||
cwd: repoDir,
|
||||
env: process.env,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,108 @@
|
|||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdir, mkdtemp, readFile, 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 { KtxCoreConfig } from '../../../src/context/core/config.js';
|
||||
import { GitService } from '../../../src/context/core/git.service.js';
|
||||
|
||||
function coreConfig(configDir: string): KtxCoreConfig {
|
||||
return {
|
||||
storage: { configDir, homeDir: configDir },
|
||||
git: {
|
||||
userName: 'Test User',
|
||||
userEmail: 'test@example.com',
|
||||
bootstrapMessage: 'Initialize test config repo',
|
||||
bootstrapAuthor: 'test-system',
|
||||
bootstrapAuthorEmail: 'system@example.com',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function git(cwd: string, args: string[]): string {
|
||||
return execFileSync('git', args, {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_AUTHOR_NAME: 'Parent User',
|
||||
GIT_AUTHOR_EMAIL: 'parent@example.com',
|
||||
GIT_COMMITTER_NAME: 'Parent User',
|
||||
GIT_COMMITTER_EMAIL: 'parent@example.com',
|
||||
},
|
||||
}).trim();
|
||||
}
|
||||
|
||||
describe('GitService repository ownership', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'git-service-isolation-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('creates and commits inside its own repo when nested in an enclosing repo', async () => {
|
||||
const parentDir = join(tempDir, 'parent');
|
||||
const projectDir = join(parentDir, '.ktx-project');
|
||||
await mkdir(projectDir, { recursive: true });
|
||||
|
||||
git(parentDir, ['init']);
|
||||
await writeFile(join(parentDir, 'README.md'), '# Parent\n', 'utf-8');
|
||||
git(parentDir, ['add', 'README.md']);
|
||||
git(parentDir, ['commit', '-m', 'parent baseline']);
|
||||
const parentHeadBefore = git(parentDir, ['rev-parse', 'HEAD']);
|
||||
|
||||
const service = new GitService(coreConfig(projectDir));
|
||||
await service.onModuleInit();
|
||||
|
||||
expect(git(projectDir, ['config', '--local', '--get', 'ktx.managed'])).toBe('true');
|
||||
expect(git(parentDir, ['rev-parse', 'HEAD'])).toBe(parentHeadBefore);
|
||||
expect(await realpath(git(projectDir, ['rev-parse', '--show-toplevel']))).toBe(await realpath(projectDir));
|
||||
|
||||
await writeFile(join(projectDir, 'wiki.md'), '# Wiki\n', 'utf-8');
|
||||
await service.commitFile('wiki.md', 'Add wiki page', 'Test User', 'test@example.com');
|
||||
|
||||
expect(git(parentDir, ['rev-parse', 'HEAD'])).toBe(parentHeadBefore);
|
||||
expect(git(projectDir, ['log', '--oneline', '--max-count=1'])).toContain('Add wiki page');
|
||||
expect(git(parentDir, ['status', '--short'])).toContain('?? .ktx-project/');
|
||||
});
|
||||
|
||||
it('rejects a foreign repo rooted at the project dir', async () => {
|
||||
const projectDir = join(tempDir, 'foreign');
|
||||
await mkdir(projectDir, { recursive: true });
|
||||
git(projectDir, ['init']);
|
||||
const configBefore = await readFile(join(projectDir, '.git', 'config'), 'utf-8');
|
||||
|
||||
const service = new GitService(coreConfig(projectDir));
|
||||
|
||||
await expect(service.onModuleInit()).rejects.toThrow(/already a git repository that ktx did not create/);
|
||||
expect(await readFile(join(projectDir, '.git', 'config'), 'utf-8')).toBe(configBefore);
|
||||
});
|
||||
|
||||
it('rejects a gitfile at the project dir as foreign', async () => {
|
||||
const projectDir = join(tempDir, 'linked-worktree');
|
||||
await mkdir(projectDir, { recursive: true });
|
||||
await writeFile(join(projectDir, '.git'), 'gitdir: ../actual.git\n', 'utf-8');
|
||||
|
||||
const service = new GitService(coreConfig(projectDir));
|
||||
|
||||
await expect(service.onModuleInit()).rejects.toThrow(/already a git repository that ktx did not create/);
|
||||
});
|
||||
|
||||
it('accepts a marked ktx repo and does not create a second bootstrap commit', async () => {
|
||||
const projectDir = join(tempDir, 'owned');
|
||||
const service = new GitService(coreConfig(projectDir));
|
||||
await service.onModuleInit();
|
||||
const before = await service.revParseHead();
|
||||
|
||||
const second = new GitService(coreConfig(projectDir));
|
||||
await second.onModuleInit();
|
||||
|
||||
expect(await second.revParseHead()).toBe(before);
|
||||
expect(git(projectDir, ['config', '--local', '--get', 'ktx.managed'])).toBe('true');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
|
@ -44,6 +45,11 @@ describe('GitService', () => {
|
|||
// beforeEach already ran onModuleInit() against an empty temp dir.
|
||||
const head = await service.revParseHead();
|
||||
expect(head).toMatch(/^[0-9a-f]{40}$/);
|
||||
const marker = execFileSync('git', ['config', '--local', '--get', 'ktx.managed'], {
|
||||
cwd: tempDir,
|
||||
encoding: 'utf-8',
|
||||
}).trim();
|
||||
expect(marker).toBe('true');
|
||||
});
|
||||
|
||||
it('does not double-commit when re-initialized', async () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue