@brainervirus/workit-core 0.9.0 → 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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/core/branch.ts +381 -35
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainervirus/workit-core",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "private": false,
5
5
  "description": "Workit — workflow rails for agentic coding: specs, plans, YouTrack, CI-gated commits (shared core)",
6
6
  "keywords": [
@@ -1,5 +1,18 @@
1
- import { readFileSync, mkdirSync, writeFileSync } from "node:fs";
1
+ import {
2
+ copyFileSync,
3
+ cpSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ readdirSync,
8
+ renameSync,
9
+ rmSync,
10
+ statSync,
11
+ writeFileSync,
12
+ } from "node:fs";
2
13
  import { execFileSync } from "node:child_process";
14
+ import { createHash } from "node:crypto";
15
+ import { tmpdir } from "node:os";
3
16
  import path from "node:path";
4
17
  import { gitContext } from "./git";
5
18
  import { readConfig, resolveBranchPolicy } from "./config";
@@ -22,6 +35,10 @@ const baseBranch = (cwd: string): { base: string } | { error: string } => {
22
35
  };
23
36
  const DECLARE_RE = /^\s*\*+Branch:\*+\s*`?([^`\s|]+)`?\s*$/gim;
24
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("/");
25
42
  const readSafe = (p: string): string | null => {
26
43
  try {
27
44
  return readFileSync(p, "utf8");
@@ -186,11 +203,10 @@ export const docsBranch = ({
186
203
  return { error: `cannot resolve docs branch from HEAD ${JSON.stringify(current)}` };
187
204
  };
188
205
 
189
- // Port of scripts/lib/ensure-develop-base.sh
190
- export const ensureBaseBranch = (cwd: string, base: string): { ok: boolean; error?: string } => {
191
- const git = gitContext(cwd);
192
- if (!git.branch || git.branch === "unknown")
193
- return { ok: false, error: "not in a git repository" };
206
+ // Read-only half of ensureBaseBranch (fetch --prune + show-ref origin/base):
207
+ // safe to run before any mutation so a missing origin/<base> fails before a
208
+ // stash push empties the tree.
209
+ const originBaseReady = (cwd: string, base: string): { ok: boolean; error?: string } => {
194
210
  const run = (args: string[]) =>
195
211
  execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] });
196
212
  try {
@@ -199,20 +215,32 @@ export const ensureBaseBranch = (cwd: string, base: string): { ok: boolean; erro
199
215
  } catch {
200
216
  run(["fetch", "origin", "--prune"]);
201
217
  }
202
- let hasOriginBase = true;
203
218
  try {
204
219
  execFileSync("git", ["show-ref", "--verify", "--quiet", `refs/remotes/origin/${base}`], {
205
220
  cwd,
206
221
  stdio: "pipe",
207
222
  });
208
223
  } catch {
209
- hasOriginBase = false;
210
- }
211
- if (!hasOriginBase)
212
224
  return {
213
225
  ok: false,
214
226
  error: `origin/${base} missing — push ${base} before creating feature/* or bugfix/* branches`,
215
227
  };
228
+ }
229
+ return { ok: true };
230
+ } catch (error) {
231
+ return {
232
+ ok: false,
233
+ error: error instanceof Error ? error.message : "ensure-base-branch failed",
234
+ };
235
+ }
236
+ };
237
+
238
+ // Mutating half of ensureBaseBranch: fast-forwards the local base (creating
239
+ // it from origin/<base> if needed). Only safe on a clean tree.
240
+ const fastForwardBase = (cwd: string, base: string): { ok: boolean; error?: string } => {
241
+ const run = (args: string[]) =>
242
+ execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] });
243
+ try {
216
244
  let hasLocalBase = true;
217
245
  try {
218
246
  execFileSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${base}`], {
@@ -241,6 +269,123 @@ export const ensureBaseBranch = (cwd: string, base: string): { ok: boolean; erro
241
269
  }
242
270
  };
243
271
 
272
+ export const ensureBaseBranch = (cwd: string, base: string): { ok: boolean; error?: string } => {
273
+ const git = gitContext(cwd);
274
+ if (!git.branch || git.branch === "unknown")
275
+ return { ok: false, error: "not in a git repository" };
276
+ const ready = originBaseReady(cwd, base);
277
+ if (!ready.ok) return ready;
278
+ return fastForwardBase(cwd, base);
279
+ };
280
+
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.
306
+ export const snapshotFlowState = (cwd: string): string => {
307
+ purgeStaleFlowGuardRoots(Date.now());
308
+ const root = path.join(
309
+ tmpdir(),
310
+ `workit-flow-guard-${createHash("sha256").update(path.resolve(cwd)).digest("hex").slice(0, 16)}.${
311
+ process.pid
312
+ }.${process.hrtime.bigint()}`,
313
+ );
314
+ mkdirSync(root, { recursive: true });
315
+ const docsDir = path.join(path.resolve(cwd), "docs");
316
+ let slugs: string[] = [];
317
+ try {
318
+ slugs = readdirSync(docsDir);
319
+ } catch {
320
+ return root; // no docs/ yet — zero-file snapshot
321
+ }
322
+ for (const slug of slugs) {
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);
337
+ }
338
+ }
339
+ return root;
340
+ };
341
+
342
+ // CA-04: restore-if-missing keeps the newest working-tree bytes; the snapshot
343
+ // root is removed only after every file is handled and retained on failure.
344
+ // A caught failure must not vanish: the message is returned so callers can
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 } => {
352
+ const walk = (dir: string, rel: string): string[] =>
353
+ readdirSync(dir, { withFileTypes: true }).flatMap((entry) =>
354
+ entry.isDirectory()
355
+ ? walk(path.join(dir, entry.name), path.join(rel, entry.name))
356
+ : [path.join(rel, entry.name)],
357
+ );
358
+ const restored: string[] = [];
359
+ try {
360
+ const workspace = path.resolve(cwd);
361
+ for (const rel of walk(snapDir, "")) {
362
+ const dest = path.join(workspace, rel);
363
+ if (existsSync(dest)) continue;
364
+ mkdirSync(path.dirname(dest), { recursive: true });
365
+ // Atomic publish: a crash mid-copy must never leave a truncated flow.json
366
+ // at the destination.
367
+ const tmpDest = `${dest}.tmp-${process.pid}`;
368
+ try {
369
+ copyFileSync(path.join(snapDir, rel), tmpDest);
370
+ renameSync(tmpDest, dest);
371
+ } catch (error) {
372
+ rmSync(tmpDest, { force: true });
373
+ throw error;
374
+ }
375
+ restored.push(toPosix(rel));
376
+ }
377
+ rmSync(snapDir, { recursive: true, force: true });
378
+ return { restored };
379
+ } catch (error) {
380
+ return {
381
+ restored,
382
+ warning: `flow state snapshot restore failed: ${
383
+ error instanceof Error ? error.message : String(error)
384
+ }`,
385
+ };
386
+ }
387
+ };
388
+
244
389
  // Port of scripts/branch/setup-branch.sh
245
390
  export const branchSetup = ({
246
391
  action,
@@ -248,12 +393,14 @@ export const branchSetup = ({
248
393
  target_branch,
249
394
  stash,
250
395
  workspace_root,
396
+ log,
251
397
  }: {
252
398
  action?: string;
253
399
  sdd_dir?: string;
254
400
  target_branch?: string;
255
401
  stash?: string;
256
402
  workspace_root: string;
403
+ log?: (message: string) => void;
257
404
  }) => {
258
405
  const cwd = path.resolve(workspace_root);
259
406
  const exec = (args: string[]): string =>
@@ -275,19 +422,92 @@ export const branchSetup = ({
275
422
  const writeManifest = (data: Record<string, unknown>) =>
276
423
  writeFileSync(manifestPath, JSON.stringify(data, null, 2) + "\n", "utf8");
277
424
 
425
+ let snapDir: string | null = null;
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] : [] };
480
+ };
481
+
278
482
  if (action === "reapply_stash") {
279
483
  const manifest = readManifest();
280
484
  const ref = manifest.stash_ref;
281
485
  if (!ref) return { error: "no stash_ref in manifest" };
486
+ // D-03: guard flow.json across the stash pop window.
487
+ snapDir = snapshotFlowState(cwd);
488
+ journalSnapshot();
489
+ journal(`pre-pop: ${String(ref)}`);
282
490
  try {
283
491
  exec(["stash", "pop", String(ref)]);
284
492
  } catch (error) {
285
- return { error: error instanceof Error ? error.message : "stash pop failed" };
493
+ // CA-03: the snapshot ran before the pop a failing pop must still
494
+ // restore a mid-window-wiped flow.json before returning.
495
+ journal("pop: failed");
496
+ const {
497
+ warnings: [warning],
498
+ } = restoreWithWarning(snapDir);
499
+ return {
500
+ error: `${error instanceof Error ? error.message : "stash pop failed"}${
501
+ warning ? `; ${warning}` : ""
502
+ }`,
503
+ };
286
504
  }
505
+ journal("pop: ok");
287
506
  delete manifest.stash_ref;
288
507
  delete manifest.stash_created_at;
289
508
  writeManifest(manifest);
290
- return { action: "reapply_stash", ok: true };
509
+ const { warnings } = restoreWithWarning(snapDir);
510
+ return { action: "reapply_stash", ok: true, ...(warnings.length > 0 ? { warnings } : {}) };
291
511
  }
292
512
 
293
513
  const target = target_branch ?? "";
@@ -296,54 +516,179 @@ export const branchSetup = ({
296
516
  if (!allowedBranch(cwd, target))
297
517
  return { error: `target branch ${target} is not allowed by the branch policy` };
298
518
 
519
+ // CA-02: resolve the base up front so an unresolvable base fails before
520
+ // any mutation. The origin/<base> validation runs after the stash gate
521
+ // below but still BEFORE any mutation (no snapshot, no stash push).
522
+ let base: string | undefined;
523
+ let targetExists = true;
524
+ try {
525
+ exec(["rev-parse", "--verify", "--quiet", `refs/heads/${target}`]);
526
+ } catch {
527
+ targetExists = false;
528
+ }
529
+ if (!targetExists) {
530
+ const baseResolved = baseBranch(cwd);
531
+ if ("error" in baseResolved) return { error: baseResolved.error };
532
+ base = baseResolved.base;
533
+ }
534
+ journal(`entry: current=${current} target=${target} base=${base ?? "-"}`);
535
+
299
536
  let stash_ref: string | undefined;
537
+ // Best-effort restore; if the pop itself fails, the caller's error gains a
538
+ // suffix pointing at the stash so stranded work stays discoverable.
539
+ const failAfterStash = (message: string): { error: string } => {
540
+ let suffix = "";
541
+ if (stash_ref) {
542
+ journal(`pre-pop: ${stash_ref}`);
543
+ try {
544
+ exec(["stash", "pop", stash_ref]);
545
+ stash_ref = undefined;
546
+ journal("pop: ok");
547
+ } catch {
548
+ journal("pop: failed");
549
+ suffix = " (changes preserved in stash)";
550
+ }
551
+ }
552
+ // CA-03: the snapshot ran before the stash push, so every error return
553
+ // here must still restore a mid-window-wiped flow.json and drop the
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);
559
+ return { error: `${message}${suffix}${warning ? `; ${warning}` : ""}` };
560
+ };
300
561
  if (current !== target) {
301
562
  const dirty = Boolean(gitContext(cwd).status_short.trim());
563
+ if (dirty && stash !== "yes") {
564
+ return {
565
+ error:
566
+ "dirty working tree — ask with native question, then call workit_branch_setup with stash=yes",
567
+ };
568
+ }
569
+ // CA-02: validate origin/<base> before ANY mutation (no snapshot, no
570
+ // stash push, no checkout) so a missing origin/<base> fails with the
571
+ // tree untouched. The mutating fast-forward stays below: it checks out
572
+ // the base branch — unsafe on a dirty tree.
573
+ let validatedBase: string | undefined;
574
+ if (!targetExists && base !== undefined) {
575
+ const ready = originBaseReady(cwd, base);
576
+ if (!ready.ok) return { error: ready.error ?? "ensure-base-branch failed" };
577
+ validatedBase = base;
578
+ }
302
579
  if (dirty) {
303
- if (stash !== "yes") {
304
- return {
305
- error:
306
- "dirty working tree — ask with native question, then call workit_branch_setup with stash=yes",
307
- };
308
- }
309
580
  try {
581
+ // CA-03: snapshot before the stash push so flow.json survives the
582
+ // stash/checkout window even if the pathspec exclusion misses.
583
+ snapDir = snapshotFlowState(cwd);
584
+ journalSnapshot();
310
585
  exec(["stash", "push", "-u", "-m", `workit: pre-checkout ${target}`, "--", ":!docs/*/sdd"]);
311
586
  } catch (error) {
312
587
  return { error: error instanceof Error ? error.message : "stash push failed" };
313
588
  }
314
589
  stash_ref = "stash@{0}";
590
+ journal(`stash push: ${stash_ref}`);
315
591
  }
316
592
  try {
317
593
  exec(["checkout", target]);
594
+ journalPresence("post-checkout");
318
595
  } catch (error) {
319
596
  const message = error instanceof Error ? error.message : "checkout failed";
320
597
  if (/worktree/i.test(message)) {
321
- return {
322
- error: `branch ${target} is locked by an existing git worktree — remove it first (we do not use worktrees)`,
323
- };
598
+ return failAfterStash(
599
+ `branch ${target} is locked by an existing git worktree — remove it first (we do not use worktrees)`,
600
+ );
324
601
  }
325
602
  try {
326
- const baseResolved = baseBranch(cwd);
327
- if ("error" in baseResolved) return { error: baseResolved.error };
328
- const baseResult = ensureBaseBranch(cwd, baseResolved.base);
329
- if (!baseResult.ok) return { error: baseResult.error };
603
+ let effectiveBase = base;
604
+ if (effectiveBase === undefined) {
605
+ const lateResolved = baseBranch(cwd);
606
+ if ("error" in lateResolved) return failAfterStash(lateResolved.error);
607
+ effectiveBase = lateResolved.base;
608
+ }
609
+ // Pre-validated above when the target was missing: only the
610
+ // fast-forward mutation remains post-stash.
611
+ const baseResult =
612
+ validatedBase !== undefined
613
+ ? fastForwardBase(cwd, effectiveBase)
614
+ : ensureBaseBranch(cwd, effectiveBase);
615
+ if (!baseResult.ok) return failAfterStash(baseResult.error ?? "ensure-base-branch failed");
330
616
  exec(["checkout", "-b", target]);
617
+ journalPresence("post-create");
331
618
  } catch (createError) {
332
- return {
333
- error: createError instanceof Error ? createError.message : "branch create failed",
334
- };
619
+ return failAfterStash(
620
+ createError instanceof Error ? createError.message : "branch create failed",
621
+ );
335
622
  }
336
623
  }
337
624
  }
338
625
 
339
- const manifest = readManifest();
340
- manifest.branch = target;
341
- manifest.previous_branch = current;
342
- if (stash_ref) {
343
- manifest.stash_ref = stash_ref;
344
- manifest.stash_created_at = new Date().toISOString();
626
+ try {
627
+ const manifest = readManifest();
628
+ manifest.branch = target;
629
+ manifest.previous_branch = current;
630
+ if (stash_ref) {
631
+ manifest.stash_ref = stash_ref;
632
+ manifest.stash_created_at = new Date().toISOString();
633
+ }
634
+ writeManifest(manifest);
635
+ } catch (error) {
636
+ const result = failAfterStash(
637
+ error instanceof Error
638
+ ? `manifest update failed: ${error.message}`
639
+ : "manifest update failed",
640
+ );
641
+ // After a successful pop, don't strand HEAD on the half-created target:
642
+ // return to the originating branch (best-effort; a conflicting tree can
643
+ // still refuse the checkout and keeps the popped state).
644
+ if (stash_ref === undefined && gitContext(cwd).branch !== current) {
645
+ try {
646
+ exec(["checkout", current]);
647
+ } catch {}
648
+ }
649
+ return result;
650
+ }
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
+ }
345
691
  }
346
- writeManifest(manifest);
347
692
  return {
348
693
  action: "setup",
349
694
  ok: true,
@@ -351,5 +696,6 @@ export const branchSetup = ({
351
696
  previous_branch: current,
352
697
  stash_ref: stash_ref ?? null,
353
698
  manifest: manifestPath,
699
+ ...(warnings.length > 0 ? { warnings } : {}),
354
700
  };
355
701
  };