@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.
- package/package.json +1 -1
- package/src/core/branch.ts +381 -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";
|
|
@@ -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
|
-
//
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
327
|
-
if (
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
334
|
-
|
|
619
|
+
return failAfterStash(
|
|
620
|
+
createError instanceof Error ? createError.message : "branch create failed",
|
|
621
|
+
);
|
|
335
622
|
}
|
|
336
623
|
}
|
|
337
624
|
}
|
|
338
625
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
};
|