mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-25 08:48:08 +02:00
Update setup and ingest flows
This commit is contained in:
parent
b3dcb577d9
commit
c82989119b
29 changed files with 1253 additions and 66 deletions
|
|
@ -256,6 +256,31 @@ describe('GitService', () => {
|
|||
await service.removeWorktree(wtDir).catch(() => undefined);
|
||||
await rm(wtDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
});
|
||||
|
||||
it('serializes concurrent commits from scoped services targeting the same worktree', async () => {
|
||||
const { commitHash } = await writeAndCommit('seed.md', 'seed');
|
||||
const parent = await realpath(join(tempDir, '..'));
|
||||
const wtDir = join(parent, `wt-${Date.now()}-fw-concurrent`);
|
||||
await service.addWorktree(wtDir, 'session/concurrent', commitHash);
|
||||
|
||||
const first = service.forWorktree(wtDir);
|
||||
const second = service.forWorktree(wtDir);
|
||||
await writeFile(join(wtDir, 'a.md'), 'a\n', 'utf-8');
|
||||
await writeFile(join(wtDir, 'b.md'), 'b\n', 'utf-8');
|
||||
|
||||
const [a, b] = await Promise.all([
|
||||
first.commitFile('a.md', 'add a', 'System User', 'system@example.com'),
|
||||
second.commitFile('b.md', 'add b', 'System User', 'system@example.com'),
|
||||
]);
|
||||
|
||||
expect(a.commitHash).toMatch(/^[0-9a-f]{40}$/);
|
||||
expect(b.commitHash).toMatch(/^[0-9a-f]{40}$/);
|
||||
await expect(first.getFileAtCommit('a.md', a.commitHash)).resolves.toBe('a\n');
|
||||
await expect(second.getFileAtCommit('b.md', b.commitHash)).resolves.toBe('b\n');
|
||||
|
||||
await service.removeWorktree(wtDir).catch(() => undefined);
|
||||
await rm(wtDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('squashMergeIntoMain', () => {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ export type SquashMergeResult =
|
|||
| { ok: false; conflict: true; conflictPaths: string[] };
|
||||
|
||||
export class GitService {
|
||||
private static readonly mutationQueues = new Map<string, Promise<void>>();
|
||||
|
||||
private readonly logger: KtxLogger;
|
||||
private git!: SimpleGit;
|
||||
private configDir: string;
|
||||
|
|
@ -92,6 +94,15 @@ export class GitService {
|
|||
commitMessage: string,
|
||||
author: string,
|
||||
authorEmail: string,
|
||||
): Promise<GitCommitInfo> {
|
||||
return this.withMutationQueue(() => this.commitFileUnlocked(filePath, commitMessage, author, authorEmail));
|
||||
}
|
||||
|
||||
private async commitFileUnlocked(
|
||||
filePath: string,
|
||||
commitMessage: string,
|
||||
author: string,
|
||||
authorEmail: string,
|
||||
): Promise<GitCommitInfo> {
|
||||
try {
|
||||
// Stage the file
|
||||
|
|
@ -166,6 +177,15 @@ export class GitService {
|
|||
commitMessage: string,
|
||||
author: string,
|
||||
authorEmail: string,
|
||||
): Promise<GitCommitInfo> {
|
||||
return this.withMutationQueue(() => this.commitFilesUnlocked(filePaths, commitMessage, author, authorEmail));
|
||||
}
|
||||
|
||||
private async commitFilesUnlocked(
|
||||
filePaths: string[],
|
||||
commitMessage: string,
|
||||
author: string,
|
||||
authorEmail: string,
|
||||
): Promise<GitCommitInfo> {
|
||||
try {
|
||||
for (const filePath of filePaths) {
|
||||
|
|
@ -231,6 +251,10 @@ export class GitService {
|
|||
if (filePaths.length === 0) {
|
||||
return;
|
||||
}
|
||||
return this.withMutationQueue(() => this.checkoutFilesUnlocked(filePaths));
|
||||
}
|
||||
|
||||
private async checkoutFilesUnlocked(filePaths: string[]): Promise<void> {
|
||||
try {
|
||||
await this.git.checkout(['--', ...filePaths]);
|
||||
} catch (error) {
|
||||
|
|
@ -292,6 +316,10 @@ export class GitService {
|
|||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
return this.withMutationQueue(() => this.addNoteUnlocked(commitHash, trimmed));
|
||||
}
|
||||
|
||||
private async addNoteUnlocked(commitHash: string, trimmed: string): Promise<void> {
|
||||
try {
|
||||
await this.git.raw(['notes', 'add', '-f', '-m', trimmed, commitHash]);
|
||||
} catch (error) {
|
||||
|
|
@ -343,6 +371,15 @@ export class GitService {
|
|||
commitMessage: string,
|
||||
author: string,
|
||||
authorEmail: string,
|
||||
): Promise<GitCommitInfo> {
|
||||
return this.withMutationQueue(() => this.deleteFileUnlocked(filePath, commitMessage, author, authorEmail));
|
||||
}
|
||||
|
||||
private async deleteFileUnlocked(
|
||||
filePath: string,
|
||||
commitMessage: string,
|
||||
author: string,
|
||||
authorEmail: string,
|
||||
): Promise<GitCommitInfo> {
|
||||
try {
|
||||
// Remove the file from git
|
||||
|
|
@ -485,6 +522,13 @@ export class GitService {
|
|||
async squashTo(
|
||||
preHead: string,
|
||||
options: { message: string; author: string; authorEmail: string; expectedAuthor?: string },
|
||||
): Promise<{ squashed: boolean; commitHash: string | null; reason?: string; squashedCount?: number }> {
|
||||
return this.withMutationQueue(() => this.squashToUnlocked(preHead, options));
|
||||
}
|
||||
|
||||
private async squashToUnlocked(
|
||||
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;
|
||||
|
|
@ -560,6 +604,15 @@ export class GitService {
|
|||
author: string,
|
||||
authorEmail: string,
|
||||
commitMessage: string,
|
||||
): Promise<SquashMergeResult> {
|
||||
return this.withMutationQueue(() => this.squashMergeIntoMainUnlocked(branch, author, authorEmail, commitMessage));
|
||||
}
|
||||
|
||||
private async squashMergeIntoMainUnlocked(
|
||||
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)
|
||||
|
|
@ -615,7 +668,7 @@ export class GitService {
|
|||
* range, which can pause the sequencer on conflicts.
|
||||
*/
|
||||
async resetHardTo(targetSha: string): Promise<void> {
|
||||
await this.git.raw(['reset', '--hard', targetSha]);
|
||||
await this.withMutationQueue(() => this.git.raw(['reset', '--hard', targetSha]));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -667,6 +720,10 @@ export class GitService {
|
|||
* 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> {
|
||||
await this.withMutationQueue(() => this.addWorktreeUnlocked(path, branch, startSha));
|
||||
}
|
||||
|
||||
private async addWorktreeUnlocked(path: string, branch: string, startSha: string): Promise<void> {
|
||||
try {
|
||||
await this.git.raw(['worktree', 'add', '-b', branch, path, startSha]);
|
||||
} catch (error) {
|
||||
|
|
@ -679,6 +736,10 @@ export class GitService {
|
|||
* worktrees are ktx-internal — a clean working tree is not required.
|
||||
*/
|
||||
async removeWorktree(path: string): Promise<void> {
|
||||
await this.withMutationQueue(() => this.removeWorktreeUnlocked(path));
|
||||
}
|
||||
|
||||
private async removeWorktreeUnlocked(path: string): Promise<void> {
|
||||
try {
|
||||
await this.git.raw(['worktree', 'remove', '--force', path]);
|
||||
} catch (error) {
|
||||
|
|
@ -724,7 +785,7 @@ export class GitService {
|
|||
}
|
||||
|
||||
async deleteBranch(branch: string, force = false): Promise<void> {
|
||||
await this.git.raw(['branch', force ? '-D' : '-d', branch]);
|
||||
await this.withMutationQueue(() => this.git.raw(['branch', force ? '-D' : '-d', branch]));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -745,6 +806,15 @@ export class GitService {
|
|||
commitMessage: string,
|
||||
author: string,
|
||||
authorEmail: string,
|
||||
): Promise<GitCommitInfo> {
|
||||
return this.withMutationQueue(() => this.deleteDirectoryUnlocked(directoryPath, commitMessage, author, authorEmail));
|
||||
}
|
||||
|
||||
private async deleteDirectoryUnlocked(
|
||||
directoryPath: string,
|
||||
commitMessage: string,
|
||||
author: string,
|
||||
authorEmail: string,
|
||||
): Promise<GitCommitInfo> {
|
||||
try {
|
||||
// Remove the directory recursively from git
|
||||
|
|
@ -795,6 +865,17 @@ export class GitService {
|
|||
commitMessage: string,
|
||||
author: string,
|
||||
authorEmail: string,
|
||||
): Promise<GitCommitInfo> {
|
||||
return this.withMutationQueue(() =>
|
||||
this.deleteDirectoriesUnlocked(directoryPaths, commitMessage, author, authorEmail),
|
||||
);
|
||||
}
|
||||
|
||||
private async deleteDirectoriesUnlocked(
|
||||
directoryPaths: string[],
|
||||
commitMessage: string,
|
||||
author: string,
|
||||
authorEmail: string,
|
||||
): Promise<GitCommitInfo> {
|
||||
if (directoryPaths.length === 0) {
|
||||
return {
|
||||
|
|
@ -852,4 +933,27 @@ export class GitService {
|
|||
created: true,
|
||||
};
|
||||
}
|
||||
|
||||
private async withMutationQueue<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const key = this.configDir;
|
||||
const previous = GitService.mutationQueues.get(key) ?? Promise.resolve();
|
||||
let release: () => void = () => {};
|
||||
const current = previous.catch(() => undefined).then(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
GitService.mutationQueues.set(key, current);
|
||||
|
||||
await previous.catch(() => undefined);
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
release();
|
||||
if (GitService.mutationQueues.get(key) === current) {
|
||||
GitService.mutationQueues.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue