@brainervirus/workit-core 0.9.1 → 0.9.2
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/package.json +1 -1
- package/src/core/branch.ts +181 -27
package/package.json
CHANGED
package/src/core/branch.ts
CHANGED
|
@@ -35,6 +35,10 @@ const baseBranch = (cwd: string): { base: string } | { error: string } => {
|
|
|
35
35
|
};
|
|
36
36
|
const DECLARE_RE = /^\s*\*+Branch:\*+\s*`?([^`\s|]+)`?\s*$/gim;
|
|
37
37
|
const USE_CURRENT_RE = /^\s*\*+Branch:\*+\s*use-current\s*$/im;
|
|
38
|
+
// Windows portability: journal lines and stash-coverage sets carry
|
|
39
|
+
// repo-relative paths, which must match git's POSIX separator output even
|
|
40
|
+
// though path.join emits platform separators.
|
|
41
|
+
const toPosix = (p: string) => p.split(path.sep).join("/");
|
|
38
42
|
const readSafe = (p: string): string | null => {
|
|
39
43
|
try {
|
|
40
44
|
return readFileSync(p, "utf8");
|
|
@@ -274,14 +278,39 @@ export const ensureBaseBranch = (cwd: string, base: string): { ok: boolean; erro
|
|
|
274
278
|
return fastForwardBase(cwd, base);
|
|
275
279
|
};
|
|
276
280
|
|
|
277
|
-
// CA-05:
|
|
278
|
-
//
|
|
281
|
+
// CA-05: crash-orphaned guard roots are garbage-collected at snapshot time.
|
|
282
|
+
// Fresh roots (any live invocation) and non-workit entries are never touched.
|
|
283
|
+
export const purgeStaleFlowGuardRoots = (now: number): void => {
|
|
284
|
+
const cutoff = now - 24 * 3600_000;
|
|
285
|
+
let entries: string[];
|
|
286
|
+
try {
|
|
287
|
+
entries = readdirSync(tmpdir());
|
|
288
|
+
} catch {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
for (const entry of entries) {
|
|
292
|
+
if (!entry.startsWith("workit-flow-guard-")) continue;
|
|
293
|
+
try {
|
|
294
|
+
if (statSync(path.join(tmpdir(), entry)).mtimeMs < cutoff)
|
|
295
|
+
rmSync(path.join(tmpdir(), entry), { recursive: true, force: true });
|
|
296
|
+
} catch {
|
|
297
|
+
/* raced removal or unreadable entry: skip */
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
// CA-04: flow-state snapshots live under the OS tempdir scoped by a hash of
|
|
303
|
+
// the workspace path — never inside the repository or docs/. The pid+hrtime
|
|
304
|
+
// suffix makes every invocation unique, so two concurrent setups never share
|
|
305
|
+
// (and never clobber) a root; CA-05 GC reclaims anything a crash orphaned.
|
|
279
306
|
export const snapshotFlowState = (cwd: string): string => {
|
|
307
|
+
purgeStaleFlowGuardRoots(Date.now());
|
|
280
308
|
const root = path.join(
|
|
281
309
|
tmpdir(),
|
|
282
|
-
`workit-flow-guard-${createHash("sha256").update(path.resolve(cwd)).digest("hex").slice(0, 16)}
|
|
310
|
+
`workit-flow-guard-${createHash("sha256").update(path.resolve(cwd)).digest("hex").slice(0, 16)}.${
|
|
311
|
+
process.pid
|
|
312
|
+
}.${process.hrtime.bigint()}`,
|
|
283
313
|
);
|
|
284
|
-
rmSync(root, { recursive: true, force: true }); // drop a stale guard from a crashed run
|
|
285
314
|
mkdirSync(root, { recursive: true });
|
|
286
315
|
const docsDir = path.join(path.resolve(cwd), "docs");
|
|
287
316
|
let slugs: string[] = [];
|
|
@@ -291,15 +320,21 @@ export const snapshotFlowState = (cwd: string): string => {
|
|
|
291
320
|
return root; // no docs/ yet — zero-file snapshot
|
|
292
321
|
}
|
|
293
322
|
for (const slug of slugs) {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
323
|
+
// The approval gate digests docs/<slug>/spec.md and docs/<slug>/plan.md,
|
|
324
|
+
// so those bytes must survive the stash window too: a stash push -u takes
|
|
325
|
+
// untracked spec/plan away and any concurrent effective flow read would
|
|
326
|
+
// classify document_missing drift and persist an approval-chain wipe.
|
|
327
|
+
for (const rel of [["sdd", "flow.json"], ["spec.md"], ["plan.md"]]) {
|
|
328
|
+
const src = path.join(docsDir, slug, ...rel);
|
|
329
|
+
try {
|
|
330
|
+
if (!statSync(src).isFile()) continue;
|
|
331
|
+
} catch {
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
const dest = path.join(root, "docs", slug, ...rel);
|
|
335
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
336
|
+
cpSync(src, dest);
|
|
299
337
|
}
|
|
300
|
-
const dest = path.join(root, "docs", slug, "sdd", "flow.json");
|
|
301
|
-
mkdirSync(path.dirname(dest), { recursive: true });
|
|
302
|
-
cpSync(src, dest);
|
|
303
338
|
}
|
|
304
339
|
return root;
|
|
305
340
|
};
|
|
@@ -307,14 +342,20 @@ export const snapshotFlowState = (cwd: string): string => {
|
|
|
307
342
|
// CA-04: restore-if-missing keeps the newest working-tree bytes; the snapshot
|
|
308
343
|
// root is removed only after every file is handled and retained on failure.
|
|
309
344
|
// A caught failure must not vanish: the message is returned so callers can
|
|
310
|
-
// surface it to operators as a warning.
|
|
311
|
-
|
|
345
|
+
// surface it to operators as a warning. `restored` lists ONLY the files this
|
|
346
|
+
// call actually wrote — present-at-restore files are skipped and excluded,
|
|
347
|
+
// which is what makes the redundant-stash coverage check honest.
|
|
348
|
+
export const restoreFlowSnapshot = (
|
|
349
|
+
snapDir: string,
|
|
350
|
+
cwd: string,
|
|
351
|
+
): { restored: string[]; warning?: string } => {
|
|
312
352
|
const walk = (dir: string, rel: string): string[] =>
|
|
313
353
|
readdirSync(dir, { withFileTypes: true }).flatMap((entry) =>
|
|
314
354
|
entry.isDirectory()
|
|
315
355
|
? walk(path.join(dir, entry.name), path.join(rel, entry.name))
|
|
316
356
|
: [path.join(rel, entry.name)],
|
|
317
357
|
);
|
|
358
|
+
const restored: string[] = [];
|
|
318
359
|
try {
|
|
319
360
|
const workspace = path.resolve(cwd);
|
|
320
361
|
for (const rel of walk(snapDir, "")) {
|
|
@@ -331,13 +372,17 @@ export const restoreFlowSnapshot = (snapDir: string, cwd: string): string | unde
|
|
|
331
372
|
rmSync(tmpDest, { force: true });
|
|
332
373
|
throw error;
|
|
333
374
|
}
|
|
375
|
+
restored.push(toPosix(rel));
|
|
334
376
|
}
|
|
335
377
|
rmSync(snapDir, { recursive: true, force: true });
|
|
336
|
-
return
|
|
378
|
+
return { restored };
|
|
337
379
|
} catch (error) {
|
|
338
|
-
return
|
|
339
|
-
|
|
340
|
-
|
|
380
|
+
return {
|
|
381
|
+
restored,
|
|
382
|
+
warning: `flow state snapshot restore failed: ${
|
|
383
|
+
error instanceof Error ? error.message : String(error)
|
|
384
|
+
}`,
|
|
385
|
+
};
|
|
341
386
|
}
|
|
342
387
|
};
|
|
343
388
|
|
|
@@ -348,12 +393,14 @@ export const branchSetup = ({
|
|
|
348
393
|
target_branch,
|
|
349
394
|
stash,
|
|
350
395
|
workspace_root,
|
|
396
|
+
log,
|
|
351
397
|
}: {
|
|
352
398
|
action?: string;
|
|
353
399
|
sdd_dir?: string;
|
|
354
400
|
target_branch?: string;
|
|
355
401
|
stash?: string;
|
|
356
402
|
workspace_root: string;
|
|
403
|
+
log?: (message: string) => void;
|
|
357
404
|
}) => {
|
|
358
405
|
const cwd = path.resolve(workspace_root);
|
|
359
406
|
const exec = (args: string[]): string =>
|
|
@@ -376,9 +423,60 @@ export const branchSetup = ({
|
|
|
376
423
|
writeFileSync(manifestPath, JSON.stringify(data, null, 2) + "\n", "utf8");
|
|
377
424
|
|
|
378
425
|
let snapDir: string | null = null;
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
426
|
+
// CA-01: flow-guard journal brackets the stash/checkout mutation window so a
|
|
427
|
+
// mid-window wipe is pinpointable between adjacent checkpoint lines. With no
|
|
428
|
+
// logger injected every call below is a no-op and nothing extra runs —
|
|
429
|
+
// behaviorally identical to the pre-journal code.
|
|
430
|
+
const journal = (message: string) => log?.(`flow-guard: ${message}`);
|
|
431
|
+
const snapshotRelPaths = (): string[] => {
|
|
432
|
+
const dir = snapDir;
|
|
433
|
+
// Not gated on log: the redundant-stash coverage check below needs this
|
|
434
|
+
// walk even without a logger (the walk is read-only over the tmpdir
|
|
435
|
+
// snapshot).
|
|
436
|
+
if (!dir) return [];
|
|
437
|
+
try {
|
|
438
|
+
const walk = (from: string, rel: string): string[] =>
|
|
439
|
+
readdirSync(from, { withFileTypes: true }).flatMap((entry) =>
|
|
440
|
+
entry.isDirectory()
|
|
441
|
+
? walk(path.join(from, entry.name), path.join(rel, entry.name))
|
|
442
|
+
: [path.join(rel, entry.name)],
|
|
443
|
+
);
|
|
444
|
+
return walk(dir, "").map(toPosix);
|
|
445
|
+
} catch {
|
|
446
|
+
return [];
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
const journalSnapshot = () => {
|
|
450
|
+
const dir = snapDir;
|
|
451
|
+
if (!dir || !log) return;
|
|
452
|
+
const rels = snapshotRelPaths();
|
|
453
|
+
journal(`snapshot: ${rels.length} file(s)`);
|
|
454
|
+
for (const rel of rels) {
|
|
455
|
+
let shortHash = "";
|
|
456
|
+
try {
|
|
457
|
+
shortHash = createHash("sha256")
|
|
458
|
+
.update(readFileSync(path.join(dir, rel)))
|
|
459
|
+
.digest("hex")
|
|
460
|
+
.slice(0, 8);
|
|
461
|
+
} catch {}
|
|
462
|
+
journal(`snapshot: ${rel} sha=${shortHash}`);
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
const journalPresence = (phase: string) => {
|
|
466
|
+
for (const rel of snapshotRelPaths()) {
|
|
467
|
+
journal(`${phase}: ${rel} ${existsSync(path.join(cwd, rel)) ? "present" : "MISSING"}`);
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
const restoreWithWarning = (dir: string | null): { restored: string[]; warnings: string[] } => {
|
|
471
|
+
if (!dir) return { restored: [], warnings: [] };
|
|
472
|
+
const total = log ? snapshotRelPaths().length : 0;
|
|
473
|
+
const { restored, warning } = restoreFlowSnapshot(dir, cwd);
|
|
474
|
+
if (log)
|
|
475
|
+
journal(
|
|
476
|
+
`restore: restored=${restored.length} skipped=${total - restored.length}${warning ? ` warning: ${warning}` : ""}`,
|
|
477
|
+
);
|
|
478
|
+
else if (warning) journal(`restore warning: ${warning}`);
|
|
479
|
+
return { restored, warnings: warning ? [warning] : [] };
|
|
382
480
|
};
|
|
383
481
|
|
|
384
482
|
if (action === "reapply_stash") {
|
|
@@ -387,22 +485,28 @@ export const branchSetup = ({
|
|
|
387
485
|
if (!ref) return { error: "no stash_ref in manifest" };
|
|
388
486
|
// D-03: guard flow.json across the stash pop window.
|
|
389
487
|
snapDir = snapshotFlowState(cwd);
|
|
488
|
+
journalSnapshot();
|
|
489
|
+
journal(`pre-pop: ${String(ref)}`);
|
|
390
490
|
try {
|
|
391
491
|
exec(["stash", "pop", String(ref)]);
|
|
392
492
|
} catch (error) {
|
|
393
493
|
// CA-03: the snapshot ran before the pop — a failing pop must still
|
|
394
494
|
// restore a mid-window-wiped flow.json before returning.
|
|
395
|
-
|
|
495
|
+
journal("pop: failed");
|
|
496
|
+
const {
|
|
497
|
+
warnings: [warning],
|
|
498
|
+
} = restoreWithWarning(snapDir);
|
|
396
499
|
return {
|
|
397
500
|
error: `${error instanceof Error ? error.message : "stash pop failed"}${
|
|
398
501
|
warning ? `; ${warning}` : ""
|
|
399
502
|
}`,
|
|
400
503
|
};
|
|
401
504
|
}
|
|
505
|
+
journal("pop: ok");
|
|
402
506
|
delete manifest.stash_ref;
|
|
403
507
|
delete manifest.stash_created_at;
|
|
404
508
|
writeManifest(manifest);
|
|
405
|
-
const warnings = restoreWithWarning(snapDir);
|
|
509
|
+
const { warnings } = restoreWithWarning(snapDir);
|
|
406
510
|
return { action: "reapply_stash", ok: true, ...(warnings.length > 0 ? { warnings } : {}) };
|
|
407
511
|
}
|
|
408
512
|
|
|
@@ -427,6 +531,7 @@ export const branchSetup = ({
|
|
|
427
531
|
if ("error" in baseResolved) return { error: baseResolved.error };
|
|
428
532
|
base = baseResolved.base;
|
|
429
533
|
}
|
|
534
|
+
journal(`entry: current=${current} target=${target} base=${base ?? "-"}`);
|
|
430
535
|
|
|
431
536
|
let stash_ref: string | undefined;
|
|
432
537
|
// Best-effort restore; if the pop itself fails, the caller's error gains a
|
|
@@ -434,18 +539,23 @@ export const branchSetup = ({
|
|
|
434
539
|
const failAfterStash = (message: string): { error: string } => {
|
|
435
540
|
let suffix = "";
|
|
436
541
|
if (stash_ref) {
|
|
542
|
+
journal(`pre-pop: ${stash_ref}`);
|
|
437
543
|
try {
|
|
438
544
|
exec(["stash", "pop", stash_ref]);
|
|
439
545
|
stash_ref = undefined;
|
|
546
|
+
journal("pop: ok");
|
|
440
547
|
} catch {
|
|
548
|
+
journal("pop: failed");
|
|
441
549
|
suffix = " (changes preserved in stash)";
|
|
442
550
|
}
|
|
443
551
|
}
|
|
444
552
|
// CA-03: the snapshot ran before the stash push, so every error return
|
|
445
553
|
// here must still restore a mid-window-wiped flow.json and drop the
|
|
446
|
-
// guard root (a retained root is
|
|
447
|
-
//
|
|
448
|
-
const
|
|
554
|
+
// guard root (a retained root is purged by the next run's 24h GC).
|
|
555
|
+
// Never masks the original error.
|
|
556
|
+
const {
|
|
557
|
+
warnings: [warning],
|
|
558
|
+
} = restoreWithWarning(snapDir);
|
|
449
559
|
return { error: `${message}${suffix}${warning ? `; ${warning}` : ""}` };
|
|
450
560
|
};
|
|
451
561
|
if (current !== target) {
|
|
@@ -471,14 +581,17 @@ export const branchSetup = ({
|
|
|
471
581
|
// CA-03: snapshot before the stash push so flow.json survives the
|
|
472
582
|
// stash/checkout window even if the pathspec exclusion misses.
|
|
473
583
|
snapDir = snapshotFlowState(cwd);
|
|
584
|
+
journalSnapshot();
|
|
474
585
|
exec(["stash", "push", "-u", "-m", `workit: pre-checkout ${target}`, "--", ":!docs/*/sdd"]);
|
|
475
586
|
} catch (error) {
|
|
476
587
|
return { error: error instanceof Error ? error.message : "stash push failed" };
|
|
477
588
|
}
|
|
478
589
|
stash_ref = "stash@{0}";
|
|
590
|
+
journal(`stash push: ${stash_ref}`);
|
|
479
591
|
}
|
|
480
592
|
try {
|
|
481
593
|
exec(["checkout", target]);
|
|
594
|
+
journalPresence("post-checkout");
|
|
482
595
|
} catch (error) {
|
|
483
596
|
const message = error instanceof Error ? error.message : "checkout failed";
|
|
484
597
|
if (/worktree/i.test(message)) {
|
|
@@ -501,6 +614,7 @@ export const branchSetup = ({
|
|
|
501
614
|
: ensureBaseBranch(cwd, effectiveBase);
|
|
502
615
|
if (!baseResult.ok) return failAfterStash(baseResult.error ?? "ensure-base-branch failed");
|
|
503
616
|
exec(["checkout", "-b", target]);
|
|
617
|
+
journalPresence("post-create");
|
|
504
618
|
} catch (createError) {
|
|
505
619
|
return failAfterStash(
|
|
506
620
|
createError instanceof Error ? createError.message : "branch create failed",
|
|
@@ -534,7 +648,47 @@ export const branchSetup = ({
|
|
|
534
648
|
}
|
|
535
649
|
return result;
|
|
536
650
|
}
|
|
537
|
-
|
|
651
|
+
// Files the guard actually wrote back, straight from the restore itself.
|
|
652
|
+
const { restored: restoredRels, warnings } = restoreWithWarning(snapDir);
|
|
653
|
+
// Round-1 fallout: the guard restores untracked spec/plan right after
|
|
654
|
+
// checkout, but the stash pushed the same files — a later reapply_stash pop
|
|
655
|
+
// refuses ("untracked working tree files would be overwritten"), stranding
|
|
656
|
+
// a stash_ref on every successful setup. When every stashed path was just
|
|
657
|
+
// restored byte-identical from the pre-stash snapshot, the stash is
|
|
658
|
+
// redundant: drop it and clear the manifest ref. Coverage counts ONLY
|
|
659
|
+
// actually-restored paths: a tracked-and-modified file survives its stash
|
|
660
|
+
// (reverted to HEAD bytes, still present), so restore skips it — counting
|
|
661
|
+
// it as covered would drop the only copy of the user's edit. Any stashed
|
|
662
|
+
// path not restored keeps the ref so reapply_stash stays available.
|
|
663
|
+
// Best-effort: any failure keeps today's keep-the-ref behavior.
|
|
664
|
+
if (stash_ref && snapDir && warnings.length === 0) {
|
|
665
|
+
const covered = new Set(restoredRels.map(toPosix));
|
|
666
|
+
try {
|
|
667
|
+
const stashed = exec([
|
|
668
|
+
"stash",
|
|
669
|
+
"show",
|
|
670
|
+
"--include-untracked",
|
|
671
|
+
"--name-only",
|
|
672
|
+
String(stash_ref),
|
|
673
|
+
])
|
|
674
|
+
.split("\n")
|
|
675
|
+
.map((line) => toPosix(line.trim()))
|
|
676
|
+
.filter(Boolean);
|
|
677
|
+
if (stashed.length > 0 && stashed.every((p) => covered.has(p))) {
|
|
678
|
+
exec(["stash", "drop", String(stash_ref)]);
|
|
679
|
+
stash_ref = undefined;
|
|
680
|
+
journal("dropped redundant stash (fully covered by snapshot restore)");
|
|
681
|
+
const manifest = readManifest();
|
|
682
|
+
delete manifest.stash_ref;
|
|
683
|
+
delete manifest.stash_created_at;
|
|
684
|
+
writeManifest(manifest);
|
|
685
|
+
} else {
|
|
686
|
+
journal(`kept stash ref ${stash_ref} (not fully covered by actual restores)`);
|
|
687
|
+
}
|
|
688
|
+
} catch {
|
|
689
|
+
/* keep the stash ref — reapply_stash stays available */
|
|
690
|
+
}
|
|
691
|
+
}
|
|
538
692
|
return {
|
|
539
693
|
action: "setup",
|
|
540
694
|
ok: true,
|