@brainervirus/workit-core 0.9.0 → 0.9.1
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 +227 -35
package/package.json
CHANGED
package/src/core/branch.ts
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
|
-
import {
|
|
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";
|
|
@@ -186,11 +199,10 @@ export const docsBranch = ({
|
|
|
186
199
|
return { error: `cannot resolve docs branch from HEAD ${JSON.stringify(current)}` };
|
|
187
200
|
};
|
|
188
201
|
|
|
189
|
-
//
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
return { ok: false, error: "not in a git repository" };
|
|
202
|
+
// Read-only half of ensureBaseBranch (fetch --prune + show-ref origin/base):
|
|
203
|
+
// safe to run before any mutation so a missing origin/<base> fails before a
|
|
204
|
+
// stash push empties the tree.
|
|
205
|
+
const originBaseReady = (cwd: string, base: string): { ok: boolean; error?: string } => {
|
|
194
206
|
const run = (args: string[]) =>
|
|
195
207
|
execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] });
|
|
196
208
|
try {
|
|
@@ -199,20 +211,32 @@ export const ensureBaseBranch = (cwd: string, base: string): { ok: boolean; erro
|
|
|
199
211
|
} catch {
|
|
200
212
|
run(["fetch", "origin", "--prune"]);
|
|
201
213
|
}
|
|
202
|
-
let hasOriginBase = true;
|
|
203
214
|
try {
|
|
204
215
|
execFileSync("git", ["show-ref", "--verify", "--quiet", `refs/remotes/origin/${base}`], {
|
|
205
216
|
cwd,
|
|
206
217
|
stdio: "pipe",
|
|
207
218
|
});
|
|
208
219
|
} catch {
|
|
209
|
-
hasOriginBase = false;
|
|
210
|
-
}
|
|
211
|
-
if (!hasOriginBase)
|
|
212
220
|
return {
|
|
213
221
|
ok: false,
|
|
214
222
|
error: `origin/${base} missing — push ${base} before creating feature/* or bugfix/* branches`,
|
|
215
223
|
};
|
|
224
|
+
}
|
|
225
|
+
return { ok: true };
|
|
226
|
+
} catch (error) {
|
|
227
|
+
return {
|
|
228
|
+
ok: false,
|
|
229
|
+
error: error instanceof Error ? error.message : "ensure-base-branch failed",
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
// Mutating half of ensureBaseBranch: fast-forwards the local base (creating
|
|
235
|
+
// it from origin/<base> if needed). Only safe on a clean tree.
|
|
236
|
+
const fastForwardBase = (cwd: string, base: string): { ok: boolean; error?: string } => {
|
|
237
|
+
const run = (args: string[]) =>
|
|
238
|
+
execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] });
|
|
239
|
+
try {
|
|
216
240
|
let hasLocalBase = true;
|
|
217
241
|
try {
|
|
218
242
|
execFileSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${base}`], {
|
|
@@ -241,6 +265,82 @@ export const ensureBaseBranch = (cwd: string, base: string): { ok: boolean; erro
|
|
|
241
265
|
}
|
|
242
266
|
};
|
|
243
267
|
|
|
268
|
+
export const ensureBaseBranch = (cwd: string, base: string): { ok: boolean; error?: string } => {
|
|
269
|
+
const git = gitContext(cwd);
|
|
270
|
+
if (!git.branch || git.branch === "unknown")
|
|
271
|
+
return { ok: false, error: "not in a git repository" };
|
|
272
|
+
const ready = originBaseReady(cwd, base);
|
|
273
|
+
if (!ready.ok) return ready;
|
|
274
|
+
return fastForwardBase(cwd, base);
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
// CA-05: flow-state snapshots live under the OS tempdir scoped by a hash of
|
|
278
|
+
// the workspace path — never inside the repository or docs/.
|
|
279
|
+
export const snapshotFlowState = (cwd: string): string => {
|
|
280
|
+
const root = path.join(
|
|
281
|
+
tmpdir(),
|
|
282
|
+
`workit-flow-guard-${createHash("sha256").update(path.resolve(cwd)).digest("hex").slice(0, 16)}`,
|
|
283
|
+
);
|
|
284
|
+
rmSync(root, { recursive: true, force: true }); // drop a stale guard from a crashed run
|
|
285
|
+
mkdirSync(root, { recursive: true });
|
|
286
|
+
const docsDir = path.join(path.resolve(cwd), "docs");
|
|
287
|
+
let slugs: string[] = [];
|
|
288
|
+
try {
|
|
289
|
+
slugs = readdirSync(docsDir);
|
|
290
|
+
} catch {
|
|
291
|
+
return root; // no docs/ yet — zero-file snapshot
|
|
292
|
+
}
|
|
293
|
+
for (const slug of slugs) {
|
|
294
|
+
const src = path.join(docsDir, slug, "sdd", "flow.json");
|
|
295
|
+
try {
|
|
296
|
+
if (!statSync(src).isFile()) continue;
|
|
297
|
+
} catch {
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
const dest = path.join(root, "docs", slug, "sdd", "flow.json");
|
|
301
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
302
|
+
cpSync(src, dest);
|
|
303
|
+
}
|
|
304
|
+
return root;
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
// CA-04: restore-if-missing keeps the newest working-tree bytes; the snapshot
|
|
308
|
+
// root is removed only after every file is handled and retained on failure.
|
|
309
|
+
// A caught failure must not vanish: the message is returned so callers can
|
|
310
|
+
// surface it to operators as a warning.
|
|
311
|
+
export const restoreFlowSnapshot = (snapDir: string, cwd: string): string | undefined => {
|
|
312
|
+
const walk = (dir: string, rel: string): string[] =>
|
|
313
|
+
readdirSync(dir, { withFileTypes: true }).flatMap((entry) =>
|
|
314
|
+
entry.isDirectory()
|
|
315
|
+
? walk(path.join(dir, entry.name), path.join(rel, entry.name))
|
|
316
|
+
: [path.join(rel, entry.name)],
|
|
317
|
+
);
|
|
318
|
+
try {
|
|
319
|
+
const workspace = path.resolve(cwd);
|
|
320
|
+
for (const rel of walk(snapDir, "")) {
|
|
321
|
+
const dest = path.join(workspace, rel);
|
|
322
|
+
if (existsSync(dest)) continue;
|
|
323
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
324
|
+
// Atomic publish: a crash mid-copy must never leave a truncated flow.json
|
|
325
|
+
// at the destination.
|
|
326
|
+
const tmpDest = `${dest}.tmp-${process.pid}`;
|
|
327
|
+
try {
|
|
328
|
+
copyFileSync(path.join(snapDir, rel), tmpDest);
|
|
329
|
+
renameSync(tmpDest, dest);
|
|
330
|
+
} catch (error) {
|
|
331
|
+
rmSync(tmpDest, { force: true });
|
|
332
|
+
throw error;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
rmSync(snapDir, { recursive: true, force: true });
|
|
336
|
+
return undefined;
|
|
337
|
+
} catch (error) {
|
|
338
|
+
return `flow state snapshot restore failed: ${
|
|
339
|
+
error instanceof Error ? error.message : String(error)
|
|
340
|
+
}`;
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
|
|
244
344
|
// Port of scripts/branch/setup-branch.sh
|
|
245
345
|
export const branchSetup = ({
|
|
246
346
|
action,
|
|
@@ -275,19 +375,35 @@ export const branchSetup = ({
|
|
|
275
375
|
const writeManifest = (data: Record<string, unknown>) =>
|
|
276
376
|
writeFileSync(manifestPath, JSON.stringify(data, null, 2) + "\n", "utf8");
|
|
277
377
|
|
|
378
|
+
let snapDir: string | null = null;
|
|
379
|
+
const restoreWithWarning = (dir: string | null): string[] => {
|
|
380
|
+
const warning = dir ? restoreFlowSnapshot(dir, cwd) : undefined;
|
|
381
|
+
return warning ? [warning] : [];
|
|
382
|
+
};
|
|
383
|
+
|
|
278
384
|
if (action === "reapply_stash") {
|
|
279
385
|
const manifest = readManifest();
|
|
280
386
|
const ref = manifest.stash_ref;
|
|
281
387
|
if (!ref) return { error: "no stash_ref in manifest" };
|
|
388
|
+
// D-03: guard flow.json across the stash pop window.
|
|
389
|
+
snapDir = snapshotFlowState(cwd);
|
|
282
390
|
try {
|
|
283
391
|
exec(["stash", "pop", String(ref)]);
|
|
284
392
|
} catch (error) {
|
|
285
|
-
|
|
393
|
+
// CA-03: the snapshot ran before the pop — a failing pop must still
|
|
394
|
+
// restore a mid-window-wiped flow.json before returning.
|
|
395
|
+
const [warning] = restoreWithWarning(snapDir);
|
|
396
|
+
return {
|
|
397
|
+
error: `${error instanceof Error ? error.message : "stash pop failed"}${
|
|
398
|
+
warning ? `; ${warning}` : ""
|
|
399
|
+
}`,
|
|
400
|
+
};
|
|
286
401
|
}
|
|
287
402
|
delete manifest.stash_ref;
|
|
288
403
|
delete manifest.stash_created_at;
|
|
289
404
|
writeManifest(manifest);
|
|
290
|
-
|
|
405
|
+
const warnings = restoreWithWarning(snapDir);
|
|
406
|
+
return { action: "reapply_stash", ok: true, ...(warnings.length > 0 ? { warnings } : {}) };
|
|
291
407
|
}
|
|
292
408
|
|
|
293
409
|
const target = target_branch ?? "";
|
|
@@ -296,17 +412,65 @@ export const branchSetup = ({
|
|
|
296
412
|
if (!allowedBranch(cwd, target))
|
|
297
413
|
return { error: `target branch ${target} is not allowed by the branch policy` };
|
|
298
414
|
|
|
415
|
+
// CA-02: resolve the base up front so an unresolvable base fails before
|
|
416
|
+
// any mutation. The origin/<base> validation runs after the stash gate
|
|
417
|
+
// below but still BEFORE any mutation (no snapshot, no stash push).
|
|
418
|
+
let base: string | undefined;
|
|
419
|
+
let targetExists = true;
|
|
420
|
+
try {
|
|
421
|
+
exec(["rev-parse", "--verify", "--quiet", `refs/heads/${target}`]);
|
|
422
|
+
} catch {
|
|
423
|
+
targetExists = false;
|
|
424
|
+
}
|
|
425
|
+
if (!targetExists) {
|
|
426
|
+
const baseResolved = baseBranch(cwd);
|
|
427
|
+
if ("error" in baseResolved) return { error: baseResolved.error };
|
|
428
|
+
base = baseResolved.base;
|
|
429
|
+
}
|
|
430
|
+
|
|
299
431
|
let stash_ref: string | undefined;
|
|
432
|
+
// Best-effort restore; if the pop itself fails, the caller's error gains a
|
|
433
|
+
// suffix pointing at the stash so stranded work stays discoverable.
|
|
434
|
+
const failAfterStash = (message: string): { error: string } => {
|
|
435
|
+
let suffix = "";
|
|
436
|
+
if (stash_ref) {
|
|
437
|
+
try {
|
|
438
|
+
exec(["stash", "pop", stash_ref]);
|
|
439
|
+
stash_ref = undefined;
|
|
440
|
+
} catch {
|
|
441
|
+
suffix = " (changes preserved in stash)";
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
// CA-03: the snapshot ran before the stash push, so every error return
|
|
445
|
+
// here must still restore a mid-window-wiped flow.json and drop the
|
|
446
|
+
// guard root (a retained root is destroyed by the next run's
|
|
447
|
+
// stale-root rmSync). Never masks the original error.
|
|
448
|
+
const [warning] = restoreWithWarning(snapDir);
|
|
449
|
+
return { error: `${message}${suffix}${warning ? `; ${warning}` : ""}` };
|
|
450
|
+
};
|
|
300
451
|
if (current !== target) {
|
|
301
452
|
const dirty = Boolean(gitContext(cwd).status_short.trim());
|
|
453
|
+
if (dirty && stash !== "yes") {
|
|
454
|
+
return {
|
|
455
|
+
error:
|
|
456
|
+
"dirty working tree — ask with native question, then call workit_branch_setup with stash=yes",
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
// CA-02: validate origin/<base> before ANY mutation (no snapshot, no
|
|
460
|
+
// stash push, no checkout) so a missing origin/<base> fails with the
|
|
461
|
+
// tree untouched. The mutating fast-forward stays below: it checks out
|
|
462
|
+
// the base branch — unsafe on a dirty tree.
|
|
463
|
+
let validatedBase: string | undefined;
|
|
464
|
+
if (!targetExists && base !== undefined) {
|
|
465
|
+
const ready = originBaseReady(cwd, base);
|
|
466
|
+
if (!ready.ok) return { error: ready.error ?? "ensure-base-branch failed" };
|
|
467
|
+
validatedBase = base;
|
|
468
|
+
}
|
|
302
469
|
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
470
|
try {
|
|
471
|
+
// CA-03: snapshot before the stash push so flow.json survives the
|
|
472
|
+
// stash/checkout window even if the pathspec exclusion misses.
|
|
473
|
+
snapDir = snapshotFlowState(cwd);
|
|
310
474
|
exec(["stash", "push", "-u", "-m", `workit: pre-checkout ${target}`, "--", ":!docs/*/sdd"]);
|
|
311
475
|
} catch (error) {
|
|
312
476
|
return { error: error instanceof Error ? error.message : "stash push failed" };
|
|
@@ -318,32 +482,59 @@ export const branchSetup = ({
|
|
|
318
482
|
} catch (error) {
|
|
319
483
|
const message = error instanceof Error ? error.message : "checkout failed";
|
|
320
484
|
if (/worktree/i.test(message)) {
|
|
321
|
-
return
|
|
322
|
-
|
|
323
|
-
|
|
485
|
+
return failAfterStash(
|
|
486
|
+
`branch ${target} is locked by an existing git worktree — remove it first (we do not use worktrees)`,
|
|
487
|
+
);
|
|
324
488
|
}
|
|
325
489
|
try {
|
|
326
|
-
|
|
327
|
-
if (
|
|
328
|
-
|
|
329
|
-
|
|
490
|
+
let effectiveBase = base;
|
|
491
|
+
if (effectiveBase === undefined) {
|
|
492
|
+
const lateResolved = baseBranch(cwd);
|
|
493
|
+
if ("error" in lateResolved) return failAfterStash(lateResolved.error);
|
|
494
|
+
effectiveBase = lateResolved.base;
|
|
495
|
+
}
|
|
496
|
+
// Pre-validated above when the target was missing: only the
|
|
497
|
+
// fast-forward mutation remains post-stash.
|
|
498
|
+
const baseResult =
|
|
499
|
+
validatedBase !== undefined
|
|
500
|
+
? fastForwardBase(cwd, effectiveBase)
|
|
501
|
+
: ensureBaseBranch(cwd, effectiveBase);
|
|
502
|
+
if (!baseResult.ok) return failAfterStash(baseResult.error ?? "ensure-base-branch failed");
|
|
330
503
|
exec(["checkout", "-b", target]);
|
|
331
504
|
} catch (createError) {
|
|
332
|
-
return
|
|
333
|
-
|
|
334
|
-
|
|
505
|
+
return failAfterStash(
|
|
506
|
+
createError instanceof Error ? createError.message : "branch create failed",
|
|
507
|
+
);
|
|
335
508
|
}
|
|
336
509
|
}
|
|
337
510
|
}
|
|
338
511
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
512
|
+
try {
|
|
513
|
+
const manifest = readManifest();
|
|
514
|
+
manifest.branch = target;
|
|
515
|
+
manifest.previous_branch = current;
|
|
516
|
+
if (stash_ref) {
|
|
517
|
+
manifest.stash_ref = stash_ref;
|
|
518
|
+
manifest.stash_created_at = new Date().toISOString();
|
|
519
|
+
}
|
|
520
|
+
writeManifest(manifest);
|
|
521
|
+
} catch (error) {
|
|
522
|
+
const result = failAfterStash(
|
|
523
|
+
error instanceof Error
|
|
524
|
+
? `manifest update failed: ${error.message}`
|
|
525
|
+
: "manifest update failed",
|
|
526
|
+
);
|
|
527
|
+
// After a successful pop, don't strand HEAD on the half-created target:
|
|
528
|
+
// return to the originating branch (best-effort; a conflicting tree can
|
|
529
|
+
// still refuse the checkout and keeps the popped state).
|
|
530
|
+
if (stash_ref === undefined && gitContext(cwd).branch !== current) {
|
|
531
|
+
try {
|
|
532
|
+
exec(["checkout", current]);
|
|
533
|
+
} catch {}
|
|
534
|
+
}
|
|
535
|
+
return result;
|
|
345
536
|
}
|
|
346
|
-
|
|
537
|
+
const warnings = restoreWithWarning(snapDir);
|
|
347
538
|
return {
|
|
348
539
|
action: "setup",
|
|
349
540
|
ok: true,
|
|
@@ -351,5 +542,6 @@ export const branchSetup = ({
|
|
|
351
542
|
previous_branch: current,
|
|
352
543
|
stash_ref: stash_ref ?? null,
|
|
353
544
|
manifest: manifestPath,
|
|
545
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
354
546
|
};
|
|
355
547
|
};
|