@brainervirus/workit-core 0.8.12 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainervirus/workit-core",
3
- "version": "0.8.12",
3
+ "version": "0.9.1",
4
4
  "private": false,
5
5
  "description": "Workit — workflow rails for agentic coding: specs, plans, YouTrack, CI-gated commits (shared core)",
6
6
  "keywords": [
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env bun
2
+ // AR-16: path-gated releases. Replaces message-only commit analysis: a
3
+ // releasable commit counts only when it touches a PRODUCT PATH (any of the
4
+ // four package dirs). Tooling-only merges produce no release at all.
5
+ import { execFileSync } from "node:child_process";
6
+ import { resolve } from "node:path";
7
+
8
+ export const RELEASE_PACKAGES = [
9
+ "workit-core",
10
+ "workit-opencode",
11
+ "workit-cursor",
12
+ "workit-cli",
13
+ ] as const;
14
+
15
+ const g = (root: string, args: string[]): string =>
16
+ execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim();
17
+
18
+ const SEMVER_TAG = /^v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
19
+
20
+ export function latestTag(root = process.cwd()): string | null {
21
+ const out = g(root, ["tag", "--list", "v*", "--sort=-v:refname"])
22
+ .split("\n")
23
+ .map((l) => l.trim())
24
+ .filter((l) => SEMVER_TAG.test(l));
25
+ return out[0] ?? null;
26
+ }
27
+
28
+ type Level = "major" | "minor" | "patch";
29
+ const LEVEL_RANK: Record<Level, number> = { patch: 1, minor: 2, major: 3 };
30
+ const TYPE_LEVEL: Record<string, Level> = { fix: "patch", perf: "patch", feat: "minor" };
31
+
32
+ const subjectLevel = (commit: string): Level | null => {
33
+ const firstLine = commit.split("\n")[0] ?? "";
34
+ const m = /^(?:fix|perf|feat)(?:\([^)]*\))?!?:/.exec(firstLine);
35
+ if (!m) return null;
36
+ if (m[0].includes("!")) return "major";
37
+ const body = commit.split("\n").slice(1).join("\n");
38
+ return /BREAKING[- ]CHANGE:/.test(body)
39
+ ? "major"
40
+ : TYPE_LEVEL[m[0].split("(")[0].replace("!", "")];
41
+ };
42
+
43
+ // Two-pass collection (sanctioned by the task brief): the single-pass
44
+ // `%H<NUL>%s%n%b` + `--name-only` interleave is brittle because execFileSync
45
+ // rejects NUL bytes inside arguments. Bounded by commit count; acceptable for
46
+ // this repo's cadence.
47
+ //
48
+ // diff-tree with -m unions files across a merge's parents (a plain `show`
49
+ // combined diff drops files identical to either parent — e.g. hotfix-branch
50
+ // back-merges), and -z returns raw NUL-delimited paths so spaces/non-ASCII
51
+ // are never C-quoted. NUL is fine in captured output, never in argv.
52
+ const commitsSince = (root: string, from: string): { message: string; files: string[] }[] => {
53
+ const hashes = g(root, ["log", "--reverse", "--format=%H", `${from}..HEAD`])
54
+ .split("\n")
55
+ .filter(Boolean);
56
+ return hashes.map((h) => ({
57
+ message: g(root, ["show", "-s", "--format=%B", h]),
58
+ files: g(root, ["diff-tree", "--no-commit-id", "--name-only", "-r", "-m", "--root", "-z", h])
59
+ .split("\0")
60
+ .filter(Boolean),
61
+ }));
62
+ };
63
+
64
+ export function analyzeReleaseScope(
65
+ root = process.cwd(),
66
+ ): { level: Level | null; productPkgs: string[] } {
67
+ const from = latestTag(root);
68
+ if (from === null) {
69
+ return { level: "minor", productPkgs: [...RELEASE_PACKAGES] };
70
+ }
71
+ const commits = commitsSince(root, from);
72
+ const levels: Level[] = [];
73
+ const pkgs = new Set<string>();
74
+ for (const { message, files } of commits) {
75
+ const touched = files.filter((f) => RELEASE_PACKAGES.some((p) => f.startsWith(`packages/${p}/`)));
76
+ if (touched.length === 0) continue;
77
+ const lvl = subjectLevel(message);
78
+ if (lvl) levels.push(lvl);
79
+ for (const f of touched) {
80
+ const pkg = RELEASE_PACKAGES.find((p) => f.startsWith(`packages/${p}/`));
81
+ if (pkg) pkgs.add(pkg);
82
+ }
83
+ }
84
+ if (levels.length === 0) return { level: null, productPkgs: [...pkgs] };
85
+ const level = levels.reduce<Level>((best, l) => (LEVEL_RANK[l] > LEVEL_RANK[best] ? l : best), "patch");
86
+ return { level, productPkgs: [...pkgs] };
87
+ }
88
+
89
+ if (import.meta.main) {
90
+ const root = process.argv[2] ? resolve(process.argv[2]) : process.cwd();
91
+ const { level } = analyzeReleaseScope(root);
92
+ if (level) process.stdout.write(`${level}\n`);
93
+ }
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env bun
2
+ // AR-16: selective publishing. Publishes only packages whose directory
3
+ // changed since the previous v* tag; logs an exact skip line per unchanged
4
+ // package so release logs answer "what shipped?" without leaving the terminal.
5
+ import { execFileSync } from "node:child_process";
6
+ import { resolve } from "node:path";
7
+ import { latestTag, RELEASE_PACKAGES } from "./analyze-release-scope";
8
+
9
+ const git = (root: string, args: string[]): string =>
10
+ execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim();
11
+
12
+ export function changedPackages(root: string, fromTag: string): string[] {
13
+ // Committed state only: <tag>..HEAD, never the working tree — unreviewed
14
+ // local edits must not decide what ships.
15
+ return RELEASE_PACKAGES.filter((pkg) => {
16
+ const out = git(root, ["diff", "--name-only", `${fromTag}..HEAD`, "--", `packages/${pkg}`]);
17
+ return out !== "";
18
+ });
19
+ }
20
+
21
+ export function publishChanged(opts: {
22
+ root: string;
23
+ dryRun?: boolean;
24
+ /**
25
+ * Base tag to diff against. Empty string means first-ever release (ship
26
+ * all). Default: latestTag(root). semantic-release creates the NEW release
27
+ * tag before publish plugins run, so production passes the PREVIOUS tag via
28
+ * `${lastRelease.gitTag}` — diffing against latestTag() there is always
29
+ * empty and would skip every package.
30
+ */
31
+ fromTag?: string;
32
+ run?: (cmd: string, args: string[], o: { cwd: string }) => unknown;
33
+ }): { published: string[]; skipped: string[]; tag: string | null } {
34
+ const { root, dryRun = false } = opts;
35
+ const run =
36
+ opts.run ??
37
+ ((cmd: string, args: string[], o: { cwd: string }) =>
38
+ execFileSync(cmd, args, { cwd: o.cwd, encoding: "utf8", stdio: "inherit" }));
39
+ const tag =
40
+ opts.fromTag !== undefined ? (opts.fromTag === "" ? null : opts.fromTag) : latestTag(root);
41
+ if (tag === null) {
42
+ // First-ever release: everything ships.
43
+ const published: string[] = [];
44
+ for (const pkg of RELEASE_PACKAGES) {
45
+ const cwd = resolve(root, "packages", pkg);
46
+ if (!dryRun) run("npm", ["publish", "--access", "public"], { cwd });
47
+ published.push(pkg);
48
+ console.log(`published ${pkg} @ ${cwd}`);
49
+ }
50
+ return { published, skipped: [], tag: null };
51
+ }
52
+ const changed = new Set(changedPackages(root, tag));
53
+ const published: string[] = [];
54
+ const skipped: string[] = [];
55
+ for (const pkg of RELEASE_PACKAGES) {
56
+ if (!changed.has(pkg)) {
57
+ skipped.push(pkg);
58
+ console.log(`skip ${pkg} (no payload change since ${tag})`);
59
+ continue;
60
+ }
61
+ const cwd = resolve(root, "packages", pkg);
62
+ try {
63
+ if (!dryRun) run("npm", ["publish", "--access", "public"], { cwd });
64
+ } catch (e) {
65
+ console.log(`publish failed ${pkg}: ${e instanceof Error ? e.message : String(e)}`);
66
+ throw e;
67
+ }
68
+ published.push(pkg);
69
+ console.log(`published ${pkg} @ ${cwd}`);
70
+ }
71
+ return { published, skipped, tag };
72
+ }
73
+
74
+ // @semantic-release/exec spawns this Cmd as a shell string whose ONLY optional
75
+ // positional arg is rendered from ${lastRelease.gitTag}: present when a
76
+ // previous release exists, absent on a first-ever release. The repo root is
77
+ // always the spawn cwd (release.config.cjs paths are repo-root-relative), so
78
+ // the CLI takes no root argument.
79
+ if (import.meta.main) {
80
+ publishChanged({
81
+ root: process.cwd(),
82
+ dryRun: process.env.PUBLISH_DRY_RUN === "1",
83
+ ...(process.argv[2] !== undefined ? { fromTag: process.argv[2] } : {}),
84
+ });
85
+ }
@@ -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";
@@ -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
- // 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" };
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
- return { error: error instanceof Error ? error.message : "stash pop failed" };
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
- return { action: "reapply_stash", ok: true };
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
- error: `branch ${target} is locked by an existing git worktree — remove it first (we do not use worktrees)`,
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
- 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 };
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
- error: createError instanceof Error ? createError.message : "branch create failed",
334
- };
505
+ return failAfterStash(
506
+ createError instanceof Error ? createError.message : "branch create failed",
507
+ );
335
508
  }
336
509
  }
337
510
  }
338
511
 
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();
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
- writeManifest(manifest);
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
  };