trekoon 0.2.0 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/trekoon/SKILL.md +232 -297
- package/README.md +288 -16
- package/package.json +1 -1
- package/src/commands/arg-parser.ts +116 -0
- package/src/commands/dep.ts +197 -25
- package/src/commands/epic.ts +490 -28
- package/src/commands/error-utils.ts +111 -0
- package/src/commands/events.ts +23 -3
- package/src/commands/help.ts +83 -17
- package/src/commands/init.ts +115 -9
- package/src/commands/migrate.ts +11 -4
- package/src/commands/quickstart.ts +76 -30
- package/src/commands/session.ts +223 -0
- package/src/commands/skills.ts +100 -63
- package/src/commands/subtask.ts +224 -26
- package/src/commands/sync.ts +64 -17
- package/src/commands/task-readiness.ts +147 -0
- package/src/commands/task.ts +277 -168
- package/src/commands/wipe.ts +15 -5
- package/src/domain/mutation-service.ts +152 -0
- package/src/domain/tracker-domain.ts +503 -0
- package/src/domain/types.ts +80 -0
- package/src/runtime/cli-shell.ts +83 -5
- package/src/storage/database.ts +86 -0
- package/src/storage/migrations.ts +48 -0
- package/src/storage/path.ts +70 -21
- package/src/storage/schema.ts +9 -2
- package/src/storage/worktree-recovery.ts +376 -0
- package/src/sync/branch-db.ts +87 -35
- package/src/sync/git-context.ts +7 -2
- package/src/sync/service.ts +131 -95
- package/src/sync/types.ts +2 -0
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
|
|
4
|
+
import { dirname, relative, resolve, sep } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { DomainError } from "../domain/types";
|
|
7
|
+
import {
|
|
8
|
+
resolveLegacyWorktreeDatabaseFile,
|
|
9
|
+
TREKOON_DATABASE_FILENAME,
|
|
10
|
+
type StoragePaths,
|
|
11
|
+
} from "./path";
|
|
12
|
+
|
|
13
|
+
function formatShellPath(filePath: string): string {
|
|
14
|
+
return `'${filePath.replaceAll("'", `'\\''`)}'`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function formatSqliteDotCommandPath(filePath: string): string {
|
|
18
|
+
return `"${filePath.replaceAll('"', '""')}"`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type WorktreeRecoveryStatus =
|
|
22
|
+
| "no_legacy_state"
|
|
23
|
+
| "safe_auto_migrate"
|
|
24
|
+
| "ambiguous_recovery"
|
|
25
|
+
| "tracked_ignored_mismatch";
|
|
26
|
+
|
|
27
|
+
export interface WorktreeRecoveryDiagnostics {
|
|
28
|
+
readonly status: WorktreeRecoveryStatus;
|
|
29
|
+
readonly legacyDatabaseFiles: readonly string[];
|
|
30
|
+
readonly backupFiles: readonly string[];
|
|
31
|
+
readonly trackedStorageFiles: readonly string[];
|
|
32
|
+
readonly autoMigrated: boolean;
|
|
33
|
+
readonly importedFrom: string | null;
|
|
34
|
+
readonly operatorAction: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface WorktreeRecoveryOptions {
|
|
38
|
+
readonly applyRecovery?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readGitLines(workingDirectory: string, args: readonly string[]): string[] {
|
|
42
|
+
const result = spawnSync("git", args, {
|
|
43
|
+
cwd: workingDirectory,
|
|
44
|
+
encoding: "utf8",
|
|
45
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
if (result.status !== 0) {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const stdout: string = typeof result.stdout === "string" ? result.stdout : "";
|
|
53
|
+
|
|
54
|
+
return stdout
|
|
55
|
+
.split(/\r?\n/u)
|
|
56
|
+
.map((line: string) => line.trim())
|
|
57
|
+
.filter((line: string) => line.length > 0);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function listWorktreeRoots(paths: StoragePaths): string[] {
|
|
61
|
+
if (paths.repoCommonDir === null) {
|
|
62
|
+
return [paths.worktreeRoot];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const lines = readGitLines(paths.worktreeRoot, ["worktree", "list", "--porcelain"]);
|
|
66
|
+
const worktreeRoots = new Set<string>();
|
|
67
|
+
|
|
68
|
+
for (const line of lines) {
|
|
69
|
+
if (!line.startsWith("worktree ")) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const rawPath: string = line.slice("worktree ".length).trim();
|
|
74
|
+
if (!rawPath) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
worktreeRoots.add(realpathSync(rawPath));
|
|
80
|
+
} catch {
|
|
81
|
+
worktreeRoots.add(resolve(rawPath));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (worktreeRoots.size === 0) {
|
|
86
|
+
worktreeRoots.add(paths.worktreeRoot);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return [...worktreeRoots];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function listTrackedStorageFiles(paths: StoragePaths): string[] {
|
|
93
|
+
if (paths.repoCommonDir === null) {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const trackedFiles = new Set<string>();
|
|
98
|
+
|
|
99
|
+
for (const worktreeRoot of listWorktreeRoots(paths)) {
|
|
100
|
+
for (const entry of readGitLines(worktreeRoot, ["ls-files", "--cached", "--", ".trekoon"])) {
|
|
101
|
+
trackedFiles.add(resolve(worktreeRoot, entry));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return [...trackedFiles].sort();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function listLegacyDatabaseFiles(paths: StoragePaths): string[] {
|
|
109
|
+
const files = new Set<string>();
|
|
110
|
+
|
|
111
|
+
for (const worktreeRoot of listWorktreeRoots(paths)) {
|
|
112
|
+
const legacyDatabaseFile: string = resolveLegacyWorktreeDatabaseFile(worktreeRoot);
|
|
113
|
+
if (legacyDatabaseFile === paths.databaseFile || !existsSync(legacyDatabaseFile)) {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
files.add(legacyDatabaseFile);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return [...files].sort();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function fingerprintDatabaseFile(filePath: string): string {
|
|
124
|
+
const dumpResult = spawnSync("sqlite3", [filePath, ".dump"], {
|
|
125
|
+
cwd: dirname(filePath),
|
|
126
|
+
encoding: "utf8",
|
|
127
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
if (dumpResult.status === 0 && typeof dumpResult.stdout === "string") {
|
|
131
|
+
return createHash("sha256").update(dumpResult.stdout).digest("hex");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const content = readFileSync(filePath);
|
|
135
|
+
return createHash("sha256").update(content).digest("hex");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function createBackupFilePath(filePath: string): string {
|
|
139
|
+
const maxAttempts = 1000;
|
|
140
|
+
let attempt = 0;
|
|
141
|
+
|
|
142
|
+
while (attempt < maxAttempts) {
|
|
143
|
+
const suffix = attempt === 0 ? ".pre-shared-import.bak" : `.pre-shared-import.${attempt}.bak`;
|
|
144
|
+
const candidate = `${filePath}${suffix}`;
|
|
145
|
+
if (!existsSync(candidate)) {
|
|
146
|
+
return candidate;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
attempt += 1;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
throw new DomainError({
|
|
153
|
+
code: "legacy_import_failed",
|
|
154
|
+
message: `Unable to find available backup path after ${maxAttempts} attempts for ${filePath}`,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function createDatabaseSnapshot(sourcePath: string, targetPath: string): void {
|
|
159
|
+
const backupResult = spawnSync("sqlite3", [sourcePath, `.backup ${formatSqliteDotCommandPath(targetPath)}`], {
|
|
160
|
+
cwd: dirname(sourcePath),
|
|
161
|
+
encoding: "utf8",
|
|
162
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
if (backupResult.status !== 0) {
|
|
166
|
+
const stderr: string = typeof backupResult.stderr === "string" ? backupResult.stderr.trim() : "";
|
|
167
|
+
const stdout: string = typeof backupResult.stdout === "string" ? backupResult.stdout.trim() : "";
|
|
168
|
+
const detail: string = stderr || stdout || `sqlite3 exited with status ${backupResult.status ?? "unknown"}`;
|
|
169
|
+
|
|
170
|
+
throw new DomainError({
|
|
171
|
+
code: "legacy_import_failed",
|
|
172
|
+
message: `Failed to snapshot legacy database state from ${sourcePath} to ${targetPath}: ${detail}`,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function backupLegacyDatabaseFile(filePath: string): string {
|
|
178
|
+
const backupPath: string = createBackupFilePath(filePath);
|
|
179
|
+
mkdirSync(dirname(backupPath), { recursive: true });
|
|
180
|
+
createDatabaseSnapshot(filePath, backupPath);
|
|
181
|
+
return backupPath;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function resolveTrackedFileWorktreeRoot(paths: StoragePaths, trackedFilePath: string): string {
|
|
185
|
+
const worktreeRoots: string[] = listWorktreeRoots(paths).sort(
|
|
186
|
+
(left: string, right: string) => right.length - left.length,
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
for (const worktreeRoot of worktreeRoots) {
|
|
190
|
+
if (trackedFilePath === worktreeRoot || trackedFilePath.startsWith(`${worktreeRoot}${sep}`)) {
|
|
191
|
+
return worktreeRoot;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return paths.worktreeRoot;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function formatTrackedMismatchAction(paths: StoragePaths, trackedStorageFiles: readonly string[]): string {
|
|
199
|
+
const commandsByWorktree = new Map<string, string[]>();
|
|
200
|
+
|
|
201
|
+
for (const trackedFilePath of trackedStorageFiles) {
|
|
202
|
+
const worktreeRoot: string = resolveTrackedFileWorktreeRoot(paths, trackedFilePath);
|
|
203
|
+
const relativeTrackedPath: string = relative(worktreeRoot, trackedFilePath);
|
|
204
|
+
const trackedPathsForWorktree: string[] = commandsByWorktree.get(worktreeRoot) ?? [];
|
|
205
|
+
trackedPathsForWorktree.push(relativeTrackedPath);
|
|
206
|
+
commandsByWorktree.set(worktreeRoot, trackedPathsForWorktree);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const suggestedCommands: string = [...commandsByWorktree.entries()]
|
|
210
|
+
.map(([worktreeRoot, trackedPaths]: [string, string[]]) => (
|
|
211
|
+
`git -C ${formatShellPath(worktreeRoot)} rm --cached -- ${trackedPaths
|
|
212
|
+
.map((trackedPath: string) => formatShellPath(trackedPath))
|
|
213
|
+
.join(" ")}`
|
|
214
|
+
))
|
|
215
|
+
.join(" ; ");
|
|
216
|
+
|
|
217
|
+
return [
|
|
218
|
+
"Remove tracked .trekoon files from every worktree index before continuing.",
|
|
219
|
+
`Tracked path(s): ${trackedStorageFiles.map(formatShellPath).join(", ")}`,
|
|
220
|
+
`Suggested action: ${suggestedCommands}`,
|
|
221
|
+
"Commit the index cleanup, keep .trekoon ignored, then rerun trekoon init.",
|
|
222
|
+
].join(" ");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function formatAmbiguousRecoveryAction(paths: StoragePaths, legacyFiles: readonly string[]): string {
|
|
226
|
+
const sharedDatabaseFile: string = paths.databaseFile;
|
|
227
|
+
const firstLegacyFile: string = legacyFiles[0] ?? resolveLegacyWorktreeDatabaseFile(paths.worktreeRoot);
|
|
228
|
+
const sharedDatabaseDirectory: string = dirname(sharedDatabaseFile);
|
|
229
|
+
const remainingLegacyFiles: string[] = legacyFiles.filter((filePath: string) => filePath !== firstLegacyFile);
|
|
230
|
+
const reconciliationStep: string = remainingLegacyFiles.length === 0
|
|
231
|
+
? `After verifying ${sharedDatabaseFile}, remove the remaining divergent legacy database before rerunning trekoon init.`
|
|
232
|
+
: `After verifying ${sharedDatabaseFile}, remove or reconcile the other divergent legacy database files before rerunning trekoon init: ${remainingLegacyFiles.map(formatShellPath).join(", ")}.`;
|
|
233
|
+
|
|
234
|
+
return [
|
|
235
|
+
"Multiple divergent legacy databases were found.",
|
|
236
|
+
`Choose one source database, ensure ${sharedDatabaseDirectory} exists, then use sqlite3 .backup to create a WAL-safe snapshot at ${sharedDatabaseFile}.`,
|
|
237
|
+
`Example: mkdir -p ${formatShellPath(sharedDatabaseDirectory)} && sqlite3 ${formatShellPath(firstLegacyFile)} '.backup ${formatSqliteDotCommandPath(sharedDatabaseFile)}'`,
|
|
238
|
+
reconciliationStep,
|
|
239
|
+
].join(" ");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function assertNoSplitState(
|
|
243
|
+
paths: StoragePaths,
|
|
244
|
+
legacyDatabaseFiles: readonly string[],
|
|
245
|
+
trackedStorageFiles: readonly string[],
|
|
246
|
+
): void {
|
|
247
|
+
if (!existsSync(paths.databaseFile)) {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const sharedFingerprint: string = fingerprintDatabaseFile(paths.databaseFile);
|
|
252
|
+
const divergentLegacyFiles: string[] = legacyDatabaseFiles.filter(
|
|
253
|
+
(legacyDatabaseFile: string) => fingerprintDatabaseFile(legacyDatabaseFile) !== sharedFingerprint,
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
if (divergentLegacyFiles.length === 0) {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
throw new DomainError({
|
|
261
|
+
code: "ambiguous_legacy_state",
|
|
262
|
+
message: "Shared storage conflicts with divergent legacy worktree databases.",
|
|
263
|
+
details: {
|
|
264
|
+
status: "ambiguous_recovery",
|
|
265
|
+
legacyDatabaseFiles,
|
|
266
|
+
trackedStorageFiles,
|
|
267
|
+
operatorAction: formatAmbiguousRecoveryAction(paths, divergentLegacyFiles),
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function recoverWorktreeDatabaseState(paths: StoragePaths): WorktreeRecoveryDiagnostics {
|
|
273
|
+
return inspectWorktreeDatabaseState(paths, { applyRecovery: true });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function inspectWorktreeDatabaseState(
|
|
277
|
+
paths: StoragePaths,
|
|
278
|
+
options: WorktreeRecoveryOptions = {},
|
|
279
|
+
): WorktreeRecoveryDiagnostics {
|
|
280
|
+
const applyRecovery: boolean = options.applyRecovery ?? false;
|
|
281
|
+
const trackedStorageFiles: string[] = listTrackedStorageFiles(paths);
|
|
282
|
+
const legacyDatabaseFiles: string[] = listLegacyDatabaseFiles(paths);
|
|
283
|
+
|
|
284
|
+
if (trackedStorageFiles.length > 0) {
|
|
285
|
+
throw new DomainError({
|
|
286
|
+
code: "tracked_ignored_mismatch",
|
|
287
|
+
message: "Tracked .trekoon files conflict with ignored shared storage.",
|
|
288
|
+
details: {
|
|
289
|
+
status: "tracked_ignored_mismatch",
|
|
290
|
+
legacyDatabaseFiles,
|
|
291
|
+
trackedStorageFiles,
|
|
292
|
+
operatorAction: formatTrackedMismatchAction(paths, trackedStorageFiles),
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (legacyDatabaseFiles.length === 0) {
|
|
298
|
+
return {
|
|
299
|
+
status: "no_legacy_state",
|
|
300
|
+
legacyDatabaseFiles,
|
|
301
|
+
backupFiles: [],
|
|
302
|
+
trackedStorageFiles,
|
|
303
|
+
autoMigrated: false,
|
|
304
|
+
importedFrom: null,
|
|
305
|
+
operatorAction: "No legacy worktree-local database detected.",
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (existsSync(paths.databaseFile)) {
|
|
310
|
+
assertNoSplitState(paths, legacyDatabaseFiles, trackedStorageFiles);
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
status: "safe_auto_migrate",
|
|
314
|
+
legacyDatabaseFiles,
|
|
315
|
+
backupFiles: [],
|
|
316
|
+
trackedStorageFiles,
|
|
317
|
+
autoMigrated: false,
|
|
318
|
+
importedFrom: null,
|
|
319
|
+
operatorAction: "Shared database already exists and matches legacy worktree-local databases. Review and remove stale legacy worktree-local databases after verification.",
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const distinctHashes: string[] = [...new Set(legacyDatabaseFiles.map(fingerprintDatabaseFile))];
|
|
324
|
+
|
|
325
|
+
if (distinctHashes.length !== 1) {
|
|
326
|
+
throw new DomainError({
|
|
327
|
+
code: "ambiguous_legacy_state",
|
|
328
|
+
message: "Multiple divergent legacy worktree databases require explicit recovery.",
|
|
329
|
+
details: {
|
|
330
|
+
status: "ambiguous_recovery",
|
|
331
|
+
legacyDatabaseFiles,
|
|
332
|
+
trackedStorageFiles,
|
|
333
|
+
operatorAction: formatAmbiguousRecoveryAction(paths, legacyDatabaseFiles),
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const importSource: string | undefined = legacyDatabaseFiles[0];
|
|
339
|
+
if (importSource === undefined) {
|
|
340
|
+
throw new DomainError({
|
|
341
|
+
code: "legacy_import_failed",
|
|
342
|
+
message: "Legacy import could not determine a source database.",
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (!applyRecovery) {
|
|
347
|
+
return {
|
|
348
|
+
status: "safe_auto_migrate",
|
|
349
|
+
legacyDatabaseFiles,
|
|
350
|
+
backupFiles: [],
|
|
351
|
+
trackedStorageFiles,
|
|
352
|
+
autoMigrated: false,
|
|
353
|
+
importedFrom: null,
|
|
354
|
+
operatorAction: `Legacy worktree database can be imported into shared storage during init/open. Source: ${importSource}`,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const backupFiles: string[] = legacyDatabaseFiles.map(backupLegacyDatabaseFile);
|
|
359
|
+
mkdirSync(dirname(paths.databaseFile), { recursive: true });
|
|
360
|
+
createDatabaseSnapshot(importSource, paths.databaseFile);
|
|
361
|
+
|
|
362
|
+
return {
|
|
363
|
+
status: "safe_auto_migrate",
|
|
364
|
+
legacyDatabaseFiles,
|
|
365
|
+
backupFiles,
|
|
366
|
+
trackedStorageFiles,
|
|
367
|
+
autoMigrated: true,
|
|
368
|
+
importedFrom: importSource,
|
|
369
|
+
operatorAction: `Imported legacy worktree database into shared storage and backed up ${legacyDatabaseFiles.length} original file(s).`,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export function isLegacyDatabaseBackup(filePath: string): boolean {
|
|
374
|
+
return filePath.endsWith(`${TREKOON_DATABASE_FILENAME}.pre-shared-import.bak`)
|
|
375
|
+
|| /\.pre-shared-import\.\d+\.bak$/u.test(filePath);
|
|
376
|
+
}
|
package/src/sync/branch-db.ts
CHANGED
|
@@ -1,49 +1,101 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { tmpdir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
4
2
|
|
|
5
|
-
import { Database } from "bun:sqlite";
|
|
3
|
+
import { type Database } from "bun:sqlite";
|
|
6
4
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
readonly
|
|
11
|
-
|
|
5
|
+
import { DomainError } from "../domain/types";
|
|
6
|
+
|
|
7
|
+
export interface BranchEventRow {
|
|
8
|
+
readonly id: string;
|
|
9
|
+
readonly entity_kind: string;
|
|
10
|
+
readonly entity_id: string;
|
|
11
|
+
readonly operation: string;
|
|
12
|
+
readonly payload: string;
|
|
13
|
+
readonly git_branch: string | null;
|
|
14
|
+
readonly git_head: string | null;
|
|
15
|
+
readonly created_at: number;
|
|
16
|
+
readonly updated_at: number;
|
|
17
|
+
readonly version: number;
|
|
12
18
|
}
|
|
13
19
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
20
|
+
interface ParsedCursorToken {
|
|
21
|
+
readonly createdAt: number;
|
|
22
|
+
readonly id: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parseCursorToken(token: string): ParsedCursorToken {
|
|
26
|
+
const [createdAtRaw, idRaw] = token.split(":");
|
|
27
|
+
const createdAt: number = Number.parseInt(createdAtRaw ?? "0", 10);
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
createdAt: Number.isFinite(createdAt) ? createdAt : 0,
|
|
31
|
+
id: idRaw && idRaw.length > 0 ? idRaw : null,
|
|
32
|
+
};
|
|
19
33
|
}
|
|
20
34
|
|
|
21
|
-
export function
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
stderr: "pipe",
|
|
35
|
+
export function assertValidSourceRef(workingDirectory: string, sourceRef: string): void {
|
|
36
|
+
const verification = spawnSync("git", ["rev-parse", "--verify", "--quiet", `${sourceRef}^{commit}`], {
|
|
37
|
+
cwd: workingDirectory,
|
|
38
|
+
encoding: "utf8",
|
|
39
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
27
40
|
});
|
|
28
41
|
|
|
29
|
-
if (
|
|
30
|
-
|
|
42
|
+
if (verification.status === 0) {
|
|
43
|
+
return;
|
|
31
44
|
}
|
|
32
45
|
|
|
33
|
-
|
|
34
|
-
|
|
46
|
+
throw new DomainError({
|
|
47
|
+
code: "invalid_source",
|
|
48
|
+
message: `Source ref '${sourceRef}' was not found.`,
|
|
49
|
+
details: {
|
|
50
|
+
status: "invalid_source",
|
|
51
|
+
sourceBranch: sourceRef,
|
|
52
|
+
operatorAction: `Verify '${sourceRef}' exists with git rev-parse --verify --quiet ${sourceRef}^{commit} and rerun sync.`,
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
}
|
|
35
56
|
|
|
36
|
-
|
|
57
|
+
export function queryBranchEventsSince(db: Database, branch: string, cursorToken: string): BranchEventRow[] {
|
|
58
|
+
const cursor = parseCursorToken(cursorToken);
|
|
37
59
|
|
|
38
|
-
|
|
60
|
+
return db
|
|
61
|
+
.query(
|
|
62
|
+
`
|
|
63
|
+
SELECT id, entity_kind, entity_id, operation, payload, git_branch, git_head, created_at, updated_at, version
|
|
64
|
+
FROM events
|
|
65
|
+
WHERE git_branch = @branch
|
|
66
|
+
AND (
|
|
67
|
+
created_at > @createdAt
|
|
68
|
+
OR (created_at = @createdAt AND id > @id)
|
|
69
|
+
)
|
|
70
|
+
ORDER BY created_at ASC, id ASC;
|
|
71
|
+
`,
|
|
72
|
+
)
|
|
73
|
+
.all({
|
|
74
|
+
"@branch": branch,
|
|
75
|
+
"@createdAt": cursor.createdAt,
|
|
76
|
+
"@id": cursor.id ?? "",
|
|
77
|
+
}) as BranchEventRow[];
|
|
78
|
+
}
|
|
39
79
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
80
|
+
export function countBranchEventsSince(db: Database, branch: string, cursorToken: string): number {
|
|
81
|
+
const cursor = parseCursorToken(cursorToken);
|
|
82
|
+
const row = db
|
|
83
|
+
.query(
|
|
84
|
+
`
|
|
85
|
+
SELECT COUNT(*) AS count
|
|
86
|
+
FROM events
|
|
87
|
+
WHERE git_branch = @branch
|
|
88
|
+
AND (
|
|
89
|
+
created_at > @createdAt
|
|
90
|
+
OR (created_at = @createdAt AND id > @id)
|
|
91
|
+
);
|
|
92
|
+
`,
|
|
93
|
+
)
|
|
94
|
+
.get({
|
|
95
|
+
"@branch": branch,
|
|
96
|
+
"@createdAt": cursor.createdAt,
|
|
97
|
+
"@id": cursor.id ?? "",
|
|
98
|
+
}) as { count: number } | null;
|
|
99
|
+
|
|
100
|
+
return row?.count ?? 0;
|
|
49
101
|
}
|
package/src/sync/git-context.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type Database } from "bun:sqlite";
|
|
2
2
|
|
|
3
|
+
import { resolveStoragePaths } from "../storage/path";
|
|
3
4
|
import { type GitContextSnapshot } from "./types";
|
|
4
5
|
|
|
5
6
|
function runGit(args: readonly string[], cwd: string): string | null {
|
|
@@ -19,11 +20,12 @@ function runGit(args: readonly string[], cwd: string): string | null {
|
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
export function resolveGitContext(cwd: string): GitContextSnapshot {
|
|
23
|
+
const storagePaths = resolveStoragePaths(cwd);
|
|
22
24
|
const branchName: string | null = runGit(["branch", "--show-current"], cwd);
|
|
23
25
|
const headSha: string | null = runGit(["rev-parse", "HEAD"], cwd);
|
|
24
26
|
|
|
25
27
|
return {
|
|
26
|
-
worktreePath:
|
|
28
|
+
worktreePath: storagePaths.worktreeRoot,
|
|
27
29
|
branchName,
|
|
28
30
|
headSha,
|
|
29
31
|
};
|
|
@@ -36,6 +38,7 @@ export function persistGitContext(db: Database, git: GitContextSnapshot): void {
|
|
|
36
38
|
`
|
|
37
39
|
INSERT INTO git_context (
|
|
38
40
|
id,
|
|
41
|
+
metadata_scope,
|
|
39
42
|
worktree_path,
|
|
40
43
|
branch_name,
|
|
41
44
|
head_sha,
|
|
@@ -43,7 +46,8 @@ export function persistGitContext(db: Database, git: GitContextSnapshot): void {
|
|
|
43
46
|
updated_at,
|
|
44
47
|
version
|
|
45
48
|
) VALUES (
|
|
46
|
-
|
|
49
|
+
@worktreePath,
|
|
50
|
+
'worktree',
|
|
47
51
|
@worktreePath,
|
|
48
52
|
@branchName,
|
|
49
53
|
@headSha,
|
|
@@ -52,6 +56,7 @@ export function persistGitContext(db: Database, git: GitContextSnapshot): void {
|
|
|
52
56
|
1
|
|
53
57
|
)
|
|
54
58
|
ON CONFLICT(id) DO UPDATE SET
|
|
59
|
+
metadata_scope = excluded.metadata_scope,
|
|
55
60
|
worktree_path = excluded.worktree_path,
|
|
56
61
|
branch_name = excluded.branch_name,
|
|
57
62
|
head_sha = excluded.head_sha,
|