baldart 4.7.0 → 4.8.0

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.
@@ -4,6 +4,7 @@ const UI = require('../utils/ui');
4
4
  const State = require('../utils/state');
5
5
  const Hooks = require('../utils/hooks');
6
6
  const UpdateNotifier = require('../utils/update-notifier');
7
+ const { captureAndVerify } = require('../utils/overlay-capture');
7
8
 
8
9
  function readEnabledTools(cwd = process.cwd()) {
9
10
  try {
@@ -115,6 +116,28 @@ function isBaldartManagedPath(p) {
115
116
  return BALDART_MANAGED_PATTERNS.some((rx) => rx.test(p));
116
117
  }
117
118
 
119
+ // Real preview (v4.8.0+) — the old `git log HEAD..FETCH_HEAD -- .framework/`
120
+ // is always empty under subtree-squash, so the user never saw what changed.
121
+ // Instead, read the UPSTREAM CHANGELOG and return every entry above the
122
+ // version we already have. Best-effort; returns null when unavailable.
123
+ async function changelogSinceInstalled(git, installedVersion) {
124
+ let text;
125
+ try { text = await git.git.raw(['show', 'FETCH_HEAD:CHANGELOG.md']); }
126
+ catch (_) { return null; }
127
+ const out = [];
128
+ let started = false;
129
+ for (const line of text.split('\n')) {
130
+ const m = /^##\s+\[?v?(\d+\.\d+\.\d+)\]?/.exec(line);
131
+ if (m) {
132
+ if (m[1] === installedVersion) break; // reached the version already on disk
133
+ started = true;
134
+ }
135
+ if (started) out.push(line);
136
+ if (out.length > 120) { out.push(' … (truncated — see .framework/CHANGELOG.md)'); break; }
137
+ }
138
+ return out.join('\n').trim() || null;
139
+ }
140
+
118
141
  async function postUpdateAutoCommit(git, newVersion, options) {
119
142
  if (options && options.commit === false) {
120
143
  UI.info('Auto-commit skipped (--no-commit).');
@@ -185,6 +208,129 @@ async function postUpdateAutoCommit(git, newVersion, options) {
185
208
  }
186
209
  }
187
210
 
211
+ // Single auto-stash engine (v4.8.0+). The ONE place that protects a dirty
212
+ // working tree before a `git subtree pull` (normal flow) OR a `--reset`
213
+ // reinstall. Uses a blanket `git stash push -u` with NO pathspec — a pathspec
214
+ // that lands on a `.gitignore`d directory is exactly what made the previous
215
+ // manual-stash workaround fail. Tracked-but-ignored files (a `.gitignore` that
216
+ // post-dates the file) are still stashed by plain stash; purely-ignored
217
+ // untracked files don't block the pull and aren't added by `git add -A`, so
218
+ // leaving them in place is harmless.
219
+ //
220
+ // Returns { stashRef } on success (or { stashRef: null } when already clean),
221
+ // { aborted: true } when the user declined, or { error } when the stash failed.
222
+ async function autoStashNonFramework(git, { autoStash, interactive }) {
223
+ const isClean = await git.hasCleanWorkingTree();
224
+ if (isClean) return { stashRef: null };
225
+
226
+ UI.newline();
227
+ UI.warning('Working tree has uncommitted changes — they will be stashed and re-applied after the update.');
228
+ const dirty = await git.git.status();
229
+ const dirtyPaths = [
230
+ ...dirty.not_added, ...dirty.modified, ...dirty.created,
231
+ ...dirty.deleted, ...dirty.renamed.map((r) => r.to), ...dirty.staged
232
+ ];
233
+ const unique = [...new Set(dirtyPaths)];
234
+ UI.info(`${unique.length} dirty path(s):`);
235
+ UI.list(unique.slice(0, 8), 'yellow');
236
+ if (unique.length > 8) UI.info(`…and ${unique.length - 8} more.`);
237
+
238
+ let action = 'stash';
239
+ if (!autoStash && interactive) {
240
+ action = await UI.select('How would you like to proceed?', [
241
+ { name: 'Auto-stash now and re-apply after the update (recommended)', value: 'stash' },
242
+ { name: 'Abort — I want to commit or stash manually first', value: 'abort' }
243
+ ]);
244
+ } else {
245
+ UI.info('Auto-stashing dirty paths.');
246
+ }
247
+ if (action === 'abort') return { aborted: true };
248
+
249
+ const stashRef = `baldart-pre-update-${new Date().toISOString().replace(/[:.]/g, '-')}`;
250
+ try {
251
+ await git.git.stash(['push', '-u', '-m', stashRef]);
252
+ UI.success(`Stashed working tree as "${stashRef}".`);
253
+ return { stashRef };
254
+ } catch (err) {
255
+ return { error: err.message };
256
+ }
257
+ }
258
+
259
+ // Seamless resolution of `overlay-able` divergence (v4.8.0+). The user's intent
260
+ // is always the same — update and preserve overlays — so this never dead-ends:
261
+ // - every divergent edit verifiably preserved by an overlay (existing or
262
+ // freshly captured) → reset-and-reapply (the `.framework/` commits are
263
+ // redundant; the overlays survive and re-apply on reinstall).
264
+ // - anything that can't be captured+verified (stale overlay, section deletion,
265
+ // frontmatter/preamble change, skill with no overlay, unmappable path) →
266
+ // stop ONCE with the exact list, so nothing is silently lost.
267
+ // On success this calls runReset (which exits the process). On an unrecoverable
268
+ // blocker it emits the single-pause refuse and exits.
269
+ //
270
+ // Pass the FULL touched set to captureAndVerify — never a pre-filtered subset.
271
+ // An overlay-able commit (per classifyCommitByPaths) only touches files under
272
+ // `.claude/{agents,commands,skills}/`, but some (nested agent files, etc.) are
273
+ // not overlay-mappable; pre-filtering them out would let the reset wipe them
274
+ // with no overlay to preserve them. captureAndVerify turns those into blockers.
275
+ async function resolveOverlayDivergenceSeamlessly(git, status, options, autoYes, overlayCommits, remoteVersion) {
276
+ const cwd = process.cwd();
277
+ const touched = [...new Set(overlayCommits.flatMap((c) => c.touched || []))];
278
+
279
+ UI.newline();
280
+ UI.info(`Verifying ${touched.length} divergent edit(s) are preserved by overlays (auto-capture + verify)…`);
281
+ const { captured, blockers } = await captureAndVerify({
282
+ cwd, git, files: touched, frameworkVersion: remoteVersion,
283
+ });
284
+ for (const o of captured) UI.success(`Overlay verified: ${o}`);
285
+
286
+ // Commit captured overlays NOW, before the reset. The reset auto-stashes the
287
+ // working tree, and the symlink/merge step reads overlays FROM DISK — an
288
+ // untracked, stashed overlay would be invisible to the merge and the
289
+ // regenerated agent would silently drop the customization. Committing pins
290
+ // them into HEAD so they survive the reset and feed the merge. (Already-
291
+ // committed, unchanged overlays stage to nothing and are skipped.)
292
+ if (captured.length) {
293
+ try {
294
+ await git.git.add(captured);
295
+ const st = await git.git.status();
296
+ if (st.staged.length) {
297
+ await git.git.commit(
298
+ 'chore(baldart): capture/verify overlays for divergent framework edits\n\n'
299
+ + captured.map((c) => ` - ${c}`).join('\n')
300
+ );
301
+ UI.success(`Committed ${st.staged.length} overlay file(s).`);
302
+ }
303
+ } catch (err) {
304
+ UI.warning(`Could not commit overlays: ${err.message} — they remain in the working tree.`);
305
+ }
306
+ }
307
+
308
+ if (blockers.length) {
309
+ UI.newline();
310
+ UI.error(`${blockers.length} edit(s) could not be preserved+verified — stopping so nothing is lost:`);
311
+ UI.list(blockers.map((b) => `${b.file} — ${b.reason}`), 'yellow');
312
+ UI.newline();
313
+ UI.info('Resolve these, then re-run `npx baldart update`:');
314
+ UI.list([
315
+ 'Author / reconcile the overlay with `/overlay` (Claude Code) or `npx baldart overlay scaffold`.',
316
+ 'Or contribute the edit upstream with `/baldart-push` if it is a generic improvement.',
317
+ 'Or keep the commits and merge with `npx baldart update --on-divergence pull`.',
318
+ ], 'cyan');
319
+ emitUpdateJson({ ok: false, action: 'divergence-capture-incomplete',
320
+ divergence_class: status.divergenceClass,
321
+ captured, blockers,
322
+ installed_before: status.installedVersion, remote_version: status.remoteVersion,
323
+ reason: `Preserved ${captured.length} edit(s); ${blockers.length} could not be captured+verified — stopped to avoid data loss.`,
324
+ next_command: 'npx baldart update --on-divergence pull' }, 1);
325
+ }
326
+
327
+ UI.newline();
328
+ UI.success('All divergent edits are preserved & verified in `.baldart/overlays/` — resolving via clean reinstall (overlays are re-applied).');
329
+ // iKnow is implied: the only files reset deletes inside `.framework/` are
330
+ // framework artifacts that get reinstalled, and every edit is now overlaid.
331
+ await runReset(git, { ...options, reset: true, iKnow: true }, autoYes);
332
+ }
333
+
188
334
  // --reset nuclear option (v3.26.0+).
189
335
  //
190
336
  // When the standard update keeps failing due to subtree divergence the user
@@ -220,13 +366,25 @@ async function runReset(git, options, autoYes) {
220
366
  '.claude/{agents,skills,commands}/*.md authored by you (non-framework symlinks)',
221
367
  ], 'cyan');
222
368
 
223
- // Safety gate #1: working tree MUST be clean.
224
- const clean = await git.hasCleanWorkingTree();
225
- if (!clean) {
226
- UI.error('Working tree is dirty. Commit or `git stash -u` first, then re-run.');
227
- UI.info('Reset cannot run with pending changes — too easy to lose work.');
369
+ // Safety gate #1: protect any non-framework work. The reset commits a
370
+ // `git add -A` (the .framework/ deletion), so a dirty tree would otherwise be
371
+ // swept into that commit — auto-stash it instead and re-apply at the end.
372
+ // Same engine the normal flow uses; honours the user's stash authorization
373
+ // instead of hard-refusing (the v4.8.0 fix).
374
+ const stashOutcome = await autoStashNonFramework(git, {
375
+ autoStash: autoYes || options.autoStash === true,
376
+ interactive: !autoYes,
377
+ });
378
+ if (stashOutcome.aborted) { UI.info('Reset aborted.'); process.exit(0); }
379
+ if (stashOutcome.error) {
380
+ UI.error(`Could not stash working tree before reset: ${stashOutcome.error}`);
381
+ if (JSON_MODE) {
382
+ emitUpdateJson({ ok: false, action: 'failed',
383
+ reason: `Could not stash working tree before reset: ${stashOutcome.error}` }, 1);
384
+ }
228
385
  process.exit(1);
229
386
  }
387
+ const resetStashRef = stashOutcome.stashRef;
230
388
 
231
389
  // Safety gate #2: any untracked / ignored files inside .framework/?
232
390
  let untrackedInsideFramework = '';
@@ -369,9 +527,38 @@ async function runReset(git, options, autoYes) {
369
527
  process.exit(1);
370
528
  }
371
529
 
530
+ const newVersion = await git.getFrameworkVersion();
531
+
532
+ // Finalizer (v4.8.0+): re-apply the stash and commit BALDART-managed
533
+ // artifacts so the reset path leaves a clean tree — exactly like the normal
534
+ // update flow. Without this, the freshly-regenerated agents + state.json were
535
+ // left uncommitted (the half-done handoff the seamless rewrite removes).
536
+ let stashConflict = false;
537
+ if (resetStashRef) {
538
+ try {
539
+ await git.git.stash(['pop']);
540
+ UI.success(`Re-applied pre-reset stash "${resetStashRef}".`);
541
+ } catch (err) {
542
+ stashConflict = true;
543
+ UI.warning(`Could not auto-re-apply stash "${resetStashRef}": ${err.message}`);
544
+ UI.info('Recover with: git stash list && git stash pop');
545
+ }
546
+ }
547
+ try {
548
+ await postUpdateAutoCommit(git, newVersion, options);
549
+ } catch (err) {
550
+ UI.warning(`Auto-commit step skipped: ${err.message}`);
551
+ }
552
+
553
+ if (JSON_MODE) {
554
+ emitUpdateJson({ ok: true, action: 'reset',
555
+ installed_after: newVersion, remote_version: newVersion,
556
+ backup_tag: backupTag, stash_conflict: stashConflict }, 0);
557
+ }
558
+
372
559
  UI.header('✓ RESET COMPLETE');
373
560
  UI.box('RESET RESULT', [
374
- `Framework: fresh install (${await git.getFrameworkVersion()})`,
561
+ `Framework: fresh install (${newVersion})`,
375
562
  `Backup tag: ${backupTag}`,
376
563
  `Rollback if needed: git reset --hard ${backupTag}`,
377
564
  '',
@@ -705,13 +892,10 @@ async function update(options = {}, unknownArgs = []) {
705
892
  // picks the cheap, correct resolution. (v3.33.0)
706
893
  const allCovered = overlayCommits.length > 0
707
894
  && overlayCommits.every((c) => c.overlayCovered === true);
708
- if (allCovered) {
709
- UI.newline();
710
- UI.success('These edits are already preserved in `.baldart/overlays/` — `--reset` is safe (your overlays are re-applied); scaffolding new overlays would be redundant.');
711
- UI.info('Fast path: `npx baldart update --reset --yes --i-know`.');
712
- }
713
895
 
714
- // Non-interactive resolution (agents / CI) via --on-divergence.
896
+ // Explicit, non-interactive overrides (agents / CI) via --on-divergence
897
+ // still win. With NO strategy the DEFAULT is now seamless (v4.8.0+):
898
+ // capture+verify any uncovered edit, then reset-and-reapply — no flag maze.
715
899
  if (divStrategy === 'scaffold-overlays') {
716
900
  UI.newline();
717
901
  scaffoldOverlaysForCommits(overlayCommits);
@@ -736,49 +920,14 @@ async function update(options = {}, unknownArgs = []) {
736
920
  }
737
921
  if (divStrategy === 'pull') {
738
922
  UI.info('Keeping the custom commits (--on-divergence pull). The subtree pull will attempt a merge.');
739
- // fall through
740
- } else if (autoYes) {
741
- // --yes without an explicit strategy: never silently overwrite. Tell
742
- // the (likely non-TTY) caller exactly which flag completes the update.
743
- UI.error('Custom edits present — refusing to resolve them unattended under --yes.');
744
- UI.info('Re-run with one of:');
745
- UI.list([
746
- allCovered
747
- ? '--reset --yes --i-know → SAFE here: these edits are already in `.baldart/overlays/` (recommended)'
748
- : '--on-divergence scaffold-overlays → auto-create overlay skeleton(s), then stop so you can fill them',
749
- '--on-divergence pull → keep the commits and merge the update (non-destructive)',
750
- allCovered
751
- ? '--on-divergence scaffold-overlays → would create redundant skeletons (edits already covered)'
752
- : '--reset --yes --i-know → discard .framework/ edits and reinstall clean (destructive)',
753
- ], 'cyan');
754
- emitUpdateJson({ ok: false, action: 'divergence-refused',
755
- divergence_class: status.divergenceClass, overlay_already_covered: allCovered,
756
- installed_before: status.installedVersion, remote_version: status.remoteVersion,
757
- reason: allCovered
758
- ? 'Custom edits present but already preserved in .baldart/overlays/ — `--reset --yes --i-know` is the safe resolution.'
759
- : 'Custom edits present — refusing to resolve them unattended under --yes.',
760
- next_command: allCovered
761
- ? 'npx baldart update --reset --yes --i-know'
762
- : 'npx baldart update --json --yes --on-divergence scaffold-overlays' }, 1);
923
+ // fall through to the subtree pull below
763
924
  } else {
764
- const choice = await UI.select('How would you like to proceed?', [
765
- { name: 'Scaffold overlay skeleton(s) now (recommended — then fill them in & re-run)', value: 'overlay' },
766
- { name: 'Keep the custom commits (subtree pull may conflict)', value: 'keep' },
767
- { name: 'Abort', value: 'abort' },
768
- ]);
769
- if (choice === 'abort') { UI.info('Update cancelled.'); process.exit(0); }
770
- if (choice === 'overlay') {
771
- UI.newline();
772
- scaffoldOverlaysForCommits(overlayCommits);
773
- UI.newline();
774
- UI.info('Next steps:');
775
- UI.list([
776
- 'Edit the overlay skeleton(s) above to capture your customisations (`/overlay` from Claude Code gives a guided flow).',
777
- 'Then complete the update with `npx baldart update --reset` — discards the redundant `.framework/` edits, reinstalls clean, re-applies your overlays.',
778
- ], 'cyan');
779
- process.exit(0);
780
- }
781
- // 'keep' → fall through, subtree pull will attempt merge
925
+ // DEFAULT seamless resolution (v4.8.0+). Captures+verifies any
926
+ // uncovered edit, then reset-and-reapply. Exits the process on
927
+ // success (reset) or stops once on an unrecoverable blocker.
928
+ await resolveOverlayDivergenceSeamlessly(
929
+ git, status, options, autoYes, overlayCommits, status.remoteVersion
930
+ );
782
931
  }
783
932
  } else if (status.divergenceClass === 'real-custom' || status.divergenceClass === 'mixed') {
784
933
  UI.warning(`Detected ${status.divergenceCommits.filter((c) => c.category === 'custom-other').length} custom commit(s) on .framework/ that are NOT auto-classifiable as overlays.`);
@@ -818,32 +967,47 @@ async function update(options = {}, unknownArgs = []) {
818
967
  if (divStrategy === 'pull') {
819
968
  UI.info('Pulling over the custom commits (--on-divergence pull). A merge commit will be created — resolve any conflicts that surface.');
820
969
  // fall through
821
- } else if (autoYes) {
822
- UI.error('Custom commits present refusing to pull over them unattended under --yes.');
823
- UI.info('Re-run with one of:');
970
+ } else {
971
+ // DEFAULT seamless (v4.8.0+): capture+verify the overlay-able subset
972
+ // so those edits are preserved, then stop ONCE. The custom-other
973
+ // commits touch non-overlayable paths (src/, CHANGELOG, …) and cannot
974
+ // be auto-resolved without risking that work — this is the single
975
+ // legitimate pause.
976
+ const cwd = process.cwd();
977
+ const overlayableTouched = [...new Set(overlayCommits.flatMap((c) => c.touched || []))];
978
+ let captured = [];
979
+ let blockers = [];
980
+ if (overlayableTouched.length) {
981
+ UI.newline();
982
+ UI.info(`Capturing ${overlayableTouched.length} overlay-able edit(s) into overlays…`);
983
+ ({ captured, blockers } = await captureAndVerify({
984
+ cwd, git, files: overlayableTouched, frameworkVersion: status.remoteVersion,
985
+ }));
986
+ for (const o of captured) UI.success(`Overlay verified: ${o}`);
987
+ for (const b of blockers) UI.warning(`Could not capture ${b.file} — ${b.reason}`);
988
+ // Persist the verified overlays so the user keeps them when re-running.
989
+ if (captured.length) {
990
+ try {
991
+ await git.git.add(captured);
992
+ const st = await git.git.status();
993
+ if (st.staged.length) {
994
+ await git.git.commit('chore(baldart): capture/verify overlays (overlay-able subset of mixed divergence)');
995
+ }
996
+ } catch (_) { /* non-fatal — overlays remain on disk */ }
997
+ }
998
+ }
999
+ UI.newline();
1000
+ UI.error('Custom commits touch non-overlayable paths (src/, CHANGELOG, …) — these cannot be auto-resolved.');
1001
+ UI.info('Resolve them, then re-run `npx baldart update`:');
824
1002
  UI.list([
825
- '--on-divergence pull → create a merge commit over your edits (non-destructive)',
826
- '--on-divergence scaffold-overlays → scaffold overlays for the overlay-able subset (custom-other still needs handling)',
827
- '--on-divergence abort → do nothing',
1003
+ 'Contribute generic improvements upstream with `/baldart-push` (recommended).',
1004
+ 'Or keep the commits and merge with `npx baldart update --on-divergence pull`.',
828
1005
  ], 'cyan');
829
- UI.info('Best practice: contribute generic improvements upstream first with `/baldart-push`.');
830
- emitUpdateJson({ ok: false, action: 'divergence-refused',
831
- divergence_class: status.divergenceClass,
1006
+ emitUpdateJson({ ok: false, action: 'divergence-capture-incomplete',
1007
+ divergence_class: status.divergenceClass, captured, blockers,
832
1008
  installed_before: status.installedVersion, remote_version: status.remoteVersion,
833
- reason: 'Custom commits present refusing to pull over them unattended under --yes.',
834
- next_command: 'npx baldart update --json --yes --on-divergence pull' }, 1);
835
- } else {
836
- const choice = await UI.select('How would you like to proceed?', [
837
- { name: 'Push these commits upstream first (recommended — `/baldart-push`)', value: 'push' },
838
- { name: 'Pull anyway (will create a merge commit — possible conflicts)', value: 'pull' },
839
- { name: 'Abort', value: 'abort' },
840
- ]);
841
- if (choice === 'abort') { UI.info('Update cancelled.'); process.exit(0); }
842
- if (choice === 'push') {
843
- UI.info('Run `/baldart-push` from Claude Code (or `npx baldart push`) to contribute upstream, then re-run update.');
844
- process.exit(0);
845
- }
846
- // 'pull' → fall through
1009
+ reason: 'Overlay-able subset captured; custom-other commit(s) touch non-overlayable paths and need /baldart-push or --on-divergence pull.',
1010
+ next_command: 'npx baldart update --on-divergence pull' }, 1);
847
1011
  }
848
1012
  }
849
1013
  // 'unknown' → conservative: continue silently, the subtree pull will
@@ -853,14 +1017,14 @@ async function update(options = {}, unknownArgs = []) {
853
1017
  // Step 3: Show changes preview
854
1018
  UI.header('STEP 3/5: Preview Changes');
855
1019
 
856
- const diff = await git.diffWithRemote();
857
- if (diff) {
858
- UI.info('Changes detected. Review recommended.');
859
- const showDiff = await confirm('Show detailed diff?', false);
860
-
861
- if (showDiff) {
862
- console.log(diff);
863
- }
1020
+ const changelog = await changelogSinceInstalled(git, status.installedVersion);
1021
+ if (changelog) {
1022
+ UI.info(`What's new (v${status.installedVersion} v${status.remoteVersion}):`);
1023
+ // Human mode only — keep STDOUT clean in --json mode (UI already routes
1024
+ // its own lines to STDERR there; a raw console.log would break the contract).
1025
+ if (!JSON_MODE) console.log(changelog);
1026
+ } else {
1027
+ UI.info(`Updating v${status.installedVersion} → v${status.remoteVersion} (CHANGELOG preview unavailable).`);
864
1028
  }
865
1029
 
866
1030
  const proceedUpdate = await confirm('Proceed with update?', true);
@@ -869,49 +1033,20 @@ async function update(options = {}, unknownArgs = []) {
869
1033
  process.exit(0);
870
1034
  }
871
1035
 
872
- // Pre-flight: git subtree pull --squash refuses a dirty worktree with
873
- // "fatal: working tree has modifications. Cannot add." Offer to stash
874
- // and re-apply after the update, so the user doesn't have to abort mid-flow.
875
- let stashRef = null;
1036
+ // Pre-flight: git subtree pull --squash refuses a dirty worktree. Auto-stash
1037
+ // via the single shared engine and re-apply after the update (v4.8.0+).
876
1038
  let stashConflict = false; // surfaced in the --json result (v3.32.0+)
877
- const isClean = await git.hasCleanWorkingTree();
878
- if (!isClean) {
879
- UI.newline();
880
- UI.warning('Working tree has uncommitted changes. `git subtree pull` requires a clean tree.');
881
- const dirty = await git.git.status();
882
- const dirtyPaths = [
883
- ...dirty.not_added, ...dirty.modified, ...dirty.created,
884
- ...dirty.deleted, ...dirty.renamed.map((r) => r.to), ...dirty.staged
885
- ];
886
- const unique = [...new Set(dirtyPaths)];
887
- UI.info(`${unique.length} dirty path(s):`);
888
- UI.list(unique.slice(0, 8), 'yellow');
889
- if (unique.length > 8) UI.info(`…and ${unique.length - 8} more.`);
890
-
891
- let action;
892
- if (autoStash) {
893
- UI.info('Auto-stashing dirty paths (--yes / --auto-stash).');
894
- action = 'stash';
895
- } else {
896
- action = await UI.select('How would you like to proceed?', [
897
- { name: 'Auto-stash now and re-apply after the update (recommended)', value: 'stash' },
898
- { name: 'Abort — I want to commit or stash manually first', value: 'abort' }
899
- ]);
900
- }
901
- if (action === 'abort') {
902
- UI.info('Aborted. Commit or `git stash -u`, then re-run `npx baldart update`.');
903
- process.exit(0);
904
- }
905
- stashRef = `baldart-pre-update-${new Date().toISOString().replace(/[:.]/g, '-')}`;
906
- try {
907
- await git.git.stash(['push', '-u', '-m', stashRef]);
908
- UI.success(`Stashed working tree as "${stashRef}".`);
909
- } catch (err) {
910
- UI.error(`Could not stash: ${err.message}`);
911
- emitUpdateJson({ ok: false, action: 'failed',
912
- reason: `Could not stash working tree before update: ${err.message}` }, 1);
913
- }
1039
+ const stashOutcome = await autoStashNonFramework(git, { autoStash, interactive: !autoYes });
1040
+ if (stashOutcome.aborted) {
1041
+ UI.info('Aborted. Commit or `git stash -u`, then re-run `npx baldart update`.');
1042
+ process.exit(0);
1043
+ }
1044
+ if (stashOutcome.error) {
1045
+ UI.error(`Could not stash: ${stashOutcome.error}`);
1046
+ emitUpdateJson({ ok: false, action: 'failed',
1047
+ reason: `Could not stash working tree before update: ${stashOutcome.error}` }, 1);
914
1048
  }
1049
+ const stashRef = stashOutcome.stashRef;
915
1050
 
916
1051
  // Step 4: Create backup
917
1052
  UI.header('STEP 4/5: Create Backup');