dubstack 0.4.0 → 0.5.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/dist/index.js CHANGED
@@ -23,96 +23,7 @@ var init_errors = __esm({
23
23
  }
24
24
  });
25
25
 
26
- // src/lib/skills.ts
27
- function getSkillRemote(name) {
28
- return AVAILABLE_SKILLS[name];
29
- }
30
- var AVAILABLE_SKILLS;
31
- var init_skills = __esm({
32
- "src/lib/skills.ts"() {
33
- "use strict";
34
- AVAILABLE_SKILLS = {
35
- dubstack: "wiseiodev/dubstack/skills/dubstack",
36
- "dub-flow": "wiseiodev/dubstack/skills/dub-flow"
37
- };
38
- }
39
- });
40
-
41
- // src/commands/skills.ts
42
- var skills_exports = {};
43
- __export(skills_exports, {
44
- addSkills: () => addSkills,
45
- removeSkills: () => removeSkills
46
- });
47
- import chalk from "chalk";
48
- import { execa as execa3 } from "execa";
49
- function validateSkills(skills) {
50
- const invalidSkills = skills.filter((s) => !(s in AVAILABLE_SKILLS));
51
- if (invalidSkills.length > 0) {
52
- throw new DubError(
53
- `Unknown skill(s): ${invalidSkills.join(", ")}. Available skills: ${Object.keys(AVAILABLE_SKILLS).join(", ")}`
54
- );
55
- }
56
- return skills;
57
- }
58
- async function addSkills(skills, options = {}) {
59
- const targets = skills.length > 0 ? validateSkills(skills) : Object.keys(AVAILABLE_SKILLS);
60
- console.log(chalk.blue(`Adding ${targets.length} skill(s)...`));
61
- for (const skill of targets) {
62
- const remote = getSkillRemote(skill);
63
- const args = ["skills", "add", remote];
64
- if (options.global) args.push("--global");
65
- const command = `npx ${args.join(" ")}`;
66
- console.log(chalk.dim(`Running: ${command}`));
67
- if (!options.dryRun) {
68
- try {
69
- await execa3("npx", args, { stdio: "inherit" });
70
- console.log(chalk.green(`\u2714 Added skill: ${skill}`));
71
- } catch (error) {
72
- console.error(chalk.red(`\u2716 Failed to add skill: ${skill}`));
73
- throw error;
74
- }
75
- }
76
- }
77
- }
78
- async function removeSkills(skills, options = {}) {
79
- const targets = skills.length > 0 ? validateSkills(skills) : Object.keys(AVAILABLE_SKILLS);
80
- console.log(chalk.blue(`Removing ${targets.length} skill(s)...`));
81
- for (const skill of targets) {
82
- const args = ["skills", "remove", skill];
83
- if (options.global) args.push("--global");
84
- const command = `npx ${args.join(" ")}`;
85
- console.log(chalk.dim(`Running: ${command}`));
86
- if (!options.dryRun) {
87
- try {
88
- await execa3("npx", args, { stdio: "inherit" });
89
- console.log(chalk.green(`\u2714 Removed skill: ${skill}`));
90
- } catch (error) {
91
- console.error(chalk.red(`\u2716 Failed to remove skill: ${skill}`));
92
- throw error;
93
- }
94
- }
95
- }
96
- }
97
- var init_skills2 = __esm({
98
- "src/commands/skills.ts"() {
99
- "use strict";
100
- init_errors();
101
- init_skills();
102
- }
103
- });
104
-
105
- // src/index.ts
106
- import { createRequire } from "module";
107
- import chalk2 from "chalk";
108
- import { Command } from "commander";
109
-
110
- // src/commands/checkout.ts
111
- init_errors();
112
- import search from "@inquirer/search";
113
-
114
26
  // src/lib/git.ts
115
- init_errors();
116
27
  import { execa } from "execa";
117
28
  async function isGitRepo(cwd) {
118
29
  try {
@@ -300,6 +211,69 @@ async function commitStaged(message, cwd) {
300
211
  );
301
212
  }
302
213
  }
214
+ async function commit(cwd, options) {
215
+ const args = ["commit"];
216
+ if (options?.message) {
217
+ args.push("-m", options.message);
218
+ }
219
+ if (options?.noEdit) {
220
+ args.push("--no-edit");
221
+ }
222
+ try {
223
+ await execa("git", args, { cwd, stdio: "inherit" });
224
+ } catch {
225
+ throw new DubError(
226
+ "Commit failed. Ensure there are staged changes and git hooks pass."
227
+ );
228
+ }
229
+ }
230
+ async function amendCommit(cwd, options) {
231
+ const args = ["commit", "--amend"];
232
+ if (options?.message) {
233
+ args.push("-m", options.message);
234
+ }
235
+ if (options?.noEdit) {
236
+ args.push("--no-edit");
237
+ }
238
+ try {
239
+ await execa("git", args, { cwd, stdio: "inherit" });
240
+ } catch (e) {
241
+ throw new DubError(
242
+ `Amend failed: ${e instanceof Error ? e.message : String(e)}`
243
+ );
244
+ }
245
+ }
246
+ async function interactiveRebase(base, cwd) {
247
+ try {
248
+ await execa("git", ["rebase", "-i", base], { cwd, stdio: "inherit" });
249
+ } catch {
250
+ throw new DubError("Interactive rebase failed or was cancelled.");
251
+ }
252
+ }
253
+ async function interactiveStage(cwd) {
254
+ try {
255
+ await execa("git", ["add", "-p"], { cwd, stdio: "inherit" });
256
+ } catch {
257
+ throw new DubError("Interactive staging failed.");
258
+ }
259
+ }
260
+ async function stageUpdate(cwd) {
261
+ try {
262
+ await execa("git", ["add", "-u"], { cwd });
263
+ } catch {
264
+ throw new DubError("Failed to stage updates.");
265
+ }
266
+ }
267
+ async function getDiff(cwd, staged) {
268
+ try {
269
+ const args = ["diff"];
270
+ if (staged) args.push("--cached");
271
+ const { stdout } = await execa("git", args, { cwd });
272
+ return stdout;
273
+ } catch {
274
+ return "";
275
+ }
276
+ }
303
277
  async function listBranches(cwd) {
304
278
  try {
305
279
  const { stdout } = await execa(
@@ -312,9 +286,113 @@ async function listBranches(cwd) {
312
286
  throw new DubError("Failed to list branches.");
313
287
  }
314
288
  }
289
+ async function fetchBranches(branches, cwd, remote = "origin") {
290
+ if (branches.length === 0) return;
291
+ for (const branch of branches) {
292
+ try {
293
+ await execa("git", ["fetch", remote, branch], { cwd });
294
+ } catch (error) {
295
+ const stderr = typeof error?.stderr === "string" ? error.stderr : "";
296
+ const stdout = typeof error?.stdout === "string" ? error.stdout : "";
297
+ const output2 = `${stderr}
298
+ ${stdout}`;
299
+ if (output2.includes("couldn't find remote ref")) {
300
+ continue;
301
+ }
302
+ throw new DubError(`Failed to fetch branches from '${remote}'.`);
303
+ }
304
+ }
305
+ }
306
+ async function remoteBranchExists(branch, cwd, remote = "origin") {
307
+ try {
308
+ await execa("git", ["rev-parse", "--verify", `${remote}/${branch}`], {
309
+ cwd
310
+ });
311
+ return true;
312
+ } catch {
313
+ return false;
314
+ }
315
+ }
316
+ async function getRefSha(ref, cwd) {
317
+ try {
318
+ const { stdout } = await execa("git", ["rev-parse", ref], { cwd });
319
+ return stdout.trim();
320
+ } catch {
321
+ throw new DubError(`Failed to read ref '${ref}'.`);
322
+ }
323
+ }
324
+ async function isAncestor(ancestor, descendant, cwd) {
325
+ try {
326
+ await execa("git", ["merge-base", "--is-ancestor", ancestor, descendant], {
327
+ cwd
328
+ });
329
+ return true;
330
+ } catch (error) {
331
+ const exitCode = error.exitCode;
332
+ if (exitCode === 1) return false;
333
+ throw new DubError(
334
+ `Failed to compare ancestry between '${ancestor}' and '${descendant}'.`
335
+ );
336
+ }
337
+ }
338
+ async function checkoutRemoteBranch(branch, cwd, remote = "origin") {
339
+ try {
340
+ await execa("git", ["checkout", "-B", branch, `${remote}/${branch}`], {
341
+ cwd
342
+ });
343
+ } catch {
344
+ throw new DubError(
345
+ `Failed to create local branch '${branch}' from '${remote}/${branch}'.`
346
+ );
347
+ }
348
+ }
349
+ async function hardResetBranchToRef(branch, ref, cwd) {
350
+ try {
351
+ const current = await getCurrentBranch(cwd).catch(() => null);
352
+ if (current !== branch) {
353
+ await checkoutBranch(branch, cwd);
354
+ }
355
+ await execa("git", ["reset", "--hard", ref], { cwd });
356
+ } catch {
357
+ throw new DubError(`Failed to hard reset '${branch}' to '${ref}'.`);
358
+ }
359
+ }
360
+ async function fastForwardBranchToRef(branch, ref, cwd) {
361
+ try {
362
+ const current = await getCurrentBranch(cwd).catch(() => null);
363
+ if (current !== branch) {
364
+ await checkoutBranch(branch, cwd);
365
+ }
366
+ await execa("git", ["merge", "--ff-only", ref], { cwd });
367
+ return true;
368
+ } catch {
369
+ return false;
370
+ }
371
+ }
372
+ async function rebaseBranchOntoRef(branch, ref, cwd) {
373
+ try {
374
+ const current = await getCurrentBranch(cwd).catch(() => null);
375
+ if (current !== branch) {
376
+ await checkoutBranch(branch, cwd);
377
+ }
378
+ await execa("git", ["rebase", ref], { cwd });
379
+ return true;
380
+ } catch {
381
+ try {
382
+ await execa("git", ["rebase", "--abort"], { cwd });
383
+ } catch {
384
+ }
385
+ return false;
386
+ }
387
+ }
388
+ var init_git = __esm({
389
+ "src/lib/git.ts"() {
390
+ "use strict";
391
+ init_errors();
392
+ }
393
+ });
315
394
 
316
395
  // src/lib/state.ts
317
- init_errors();
318
396
  import * as crypto from "crypto";
319
397
  import * as fs from "fs";
320
398
  import * as path from "path";
@@ -333,7 +411,7 @@ async function readState(cwd) {
333
411
  }
334
412
  try {
335
413
  const raw = fs.readFileSync(statePath, "utf-8");
336
- return JSON.parse(raw);
414
+ return normalizeState(JSON.parse(raw));
337
415
  } catch {
338
416
  throw new DubError(
339
417
  "State file is corrupted. Delete .git/dubstack and run 'dub init' to re-initialize."
@@ -377,6 +455,12 @@ function findStackForBranch(state, name) {
377
455
  (stack) => stack.branches.some((b) => b.name === name)
378
456
  );
379
457
  }
458
+ function getParent(state, branchName) {
459
+ const stack = findStackForBranch(state, branchName);
460
+ if (!stack) return void 0;
461
+ const branch = stack.branches.find((b) => b.name === branchName);
462
+ return branch?.parent ?? void 0;
463
+ }
380
464
  function addBranchToStack(state, child, parent) {
381
465
  if (findStackForBranch(state, child)) {
382
466
  throw new DubError(`Branch '${child}' is already tracked in a stack.`);
@@ -385,7 +469,10 @@ function addBranchToStack(state, child, parent) {
385
469
  name: child,
386
470
  parent,
387
471
  pr_number: null,
388
- pr_link: null
472
+ pr_link: null,
473
+ last_submitted_version: null,
474
+ last_synced_at: null,
475
+ sync_source: null
389
476
  };
390
477
  const existingStack = findStackForBranch(state, parent);
391
478
  if (existingStack) {
@@ -396,7 +483,10 @@ function addBranchToStack(state, child, parent) {
396
483
  type: "root",
397
484
  parent: null,
398
485
  pr_number: null,
399
- pr_link: null
486
+ pr_link: null,
487
+ last_submitted_version: null,
488
+ last_synced_at: null,
489
+ sync_source: null
400
490
  };
401
491
  state.stacks.push({
402
492
  id: crypto.randomUUID(),
@@ -404,6 +494,22 @@ function addBranchToStack(state, child, parent) {
404
494
  });
405
495
  }
406
496
  }
497
+ function normalizeState(state) {
498
+ return {
499
+ stacks: state.stacks.map((stack) => ({
500
+ ...stack,
501
+ branches: stack.branches.map((branch) => normalizeBranch(branch))
502
+ }))
503
+ };
504
+ }
505
+ function normalizeBranch(branch) {
506
+ return {
507
+ ...branch,
508
+ last_submitted_version: branch.last_submitted_version ?? null,
509
+ last_synced_at: branch.last_synced_at ?? null,
510
+ sync_source: branch.sync_source ?? null
511
+ };
512
+ }
407
513
  function topologicalOrder(stack) {
408
514
  const result = [];
409
515
  const root = stack.branches.find((b) => b.type === "root");
@@ -426,39 +532,481 @@ function topologicalOrder(stack) {
426
532
  }
427
533
  return result;
428
534
  }
429
-
430
- // src/commands/checkout.ts
431
- function getTrackedBranches(state) {
432
- const names = /* @__PURE__ */ new Set();
433
- for (const stack of state.stacks) {
434
- for (const branch of stack.branches) {
435
- names.add(branch.name);
436
- }
535
+ var init_state = __esm({
536
+ "src/lib/state.ts"() {
537
+ "use strict";
538
+ init_errors();
539
+ init_git();
437
540
  }
438
- return [...names].sort();
541
+ });
542
+
543
+ // src/lib/undo-log.ts
544
+ import * as fs2 from "fs";
545
+ import * as path2 from "path";
546
+ async function getUndoPath(cwd) {
547
+ const dubDir = await getDubDir(cwd);
548
+ return path2.join(dubDir, "undo.json");
439
549
  }
440
- function getValidBranches(tracked, local) {
441
- const localSet = new Set(local);
442
- return tracked.filter((b) => localSet.has(b));
550
+ async function saveUndoEntry(entry, cwd) {
551
+ const undoPath = await getUndoPath(cwd);
552
+ fs2.writeFileSync(undoPath, `${JSON.stringify(entry, null, 2)}
553
+ `);
443
554
  }
444
- async function checkout(name, cwd) {
445
- await checkoutBranch(name, cwd);
446
- return { branch: name };
555
+ async function readUndoEntry(cwd) {
556
+ const undoPath = await getUndoPath(cwd);
557
+ if (!fs2.existsSync(undoPath)) {
558
+ throw new DubError("Nothing to undo.");
559
+ }
560
+ const raw = fs2.readFileSync(undoPath, "utf-8");
561
+ return JSON.parse(raw);
447
562
  }
448
- async function interactiveCheckout(cwd) {
563
+ async function clearUndoEntry(cwd) {
564
+ const undoPath = await getUndoPath(cwd);
565
+ if (fs2.existsSync(undoPath)) {
566
+ fs2.unlinkSync(undoPath);
567
+ }
568
+ }
569
+ var init_undo_log = __esm({
570
+ "src/lib/undo-log.ts"() {
571
+ "use strict";
572
+ init_errors();
573
+ init_state();
574
+ }
575
+ });
576
+
577
+ // src/commands/restack.ts
578
+ import * as fs4 from "fs";
579
+ import * as path4 from "path";
580
+ async function restack(cwd) {
449
581
  const state = await readState(cwd);
450
- const trackedBranches = getTrackedBranches(state);
451
- const localBranches = await listBranches(cwd);
452
- const validBranches = getValidBranches(trackedBranches, localBranches);
453
- if (validBranches.length === 0) {
582
+ if (!await isWorkingTreeClean(cwd)) {
454
583
  throw new DubError(
455
- "No valid tracked branches found. Run 'dub create' first."
584
+ "Working tree has uncommitted changes. Commit or stash them before restacking."
456
585
  );
457
586
  }
458
- let currentBranch = null;
459
- try {
460
- currentBranch = await getCurrentBranch(cwd);
461
- } catch {
587
+ const originalBranch = await getCurrentBranch(cwd);
588
+ const targetStacks = getTargetStacks(state.stacks, originalBranch);
589
+ if (targetStacks.length === 0) {
590
+ throw new DubError(
591
+ `Branch '${originalBranch}' is not part of any stack. Run 'dub create' first.`
592
+ );
593
+ }
594
+ const allBranches = targetStacks.flatMap((s) => s.branches);
595
+ for (const branch of allBranches) {
596
+ if (!await branchExists(branch.name, cwd)) {
597
+ throw new DubError(
598
+ `Branch '${branch.name}' is tracked in state but no longer exists in git.
599
+ Remove it from the stack or recreate it before restacking.`
600
+ );
601
+ }
602
+ }
603
+ const branchTips = {};
604
+ for (const branch of allBranches) {
605
+ branchTips[branch.name] = await getBranchTip(branch.name, cwd);
606
+ }
607
+ const steps = await buildRestackSteps(targetStacks, cwd);
608
+ if (steps.length === 0) {
609
+ return { status: "up-to-date", rebased: [] };
610
+ }
611
+ await saveUndoEntry(
612
+ {
613
+ operation: "restack",
614
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
615
+ previousBranch: originalBranch,
616
+ previousState: structuredClone(state),
617
+ branchTips,
618
+ createdBranches: []
619
+ },
620
+ cwd
621
+ );
622
+ const progress = { originalBranch, steps };
623
+ await writeProgress(progress, cwd);
624
+ return executeRestackSteps(progress, cwd);
625
+ }
626
+ async function restackContinue(cwd) {
627
+ const progress = await readProgress(cwd);
628
+ if (!progress) {
629
+ throw new DubError("No restack in progress. Run 'dub restack' to start.");
630
+ }
631
+ await rebaseContinue(cwd);
632
+ const conflictedStep = progress.steps.find((s) => s.status === "conflicted");
633
+ if (conflictedStep) {
634
+ conflictedStep.status = "done";
635
+ }
636
+ return executeRestackSteps(progress, cwd);
637
+ }
638
+ async function executeRestackSteps(progress, cwd) {
639
+ const rebased = [];
640
+ for (const step of progress.steps) {
641
+ if (step.status !== "pending") {
642
+ if (step.status === "done") rebased.push(step.branch);
643
+ continue;
644
+ }
645
+ const parentNewTip = await getBranchTip(step.parent, cwd);
646
+ if (parentNewTip === step.parentOldTip) {
647
+ step.status = "skipped";
648
+ await writeProgress(progress, cwd);
649
+ continue;
650
+ }
651
+ try {
652
+ await rebaseOnto(parentNewTip, step.parentOldTip, step.branch, cwd);
653
+ step.status = "done";
654
+ rebased.push(step.branch);
655
+ await writeProgress(progress, cwd);
656
+ } catch (error) {
657
+ if (error instanceof DubError && error.message.includes("Conflict")) {
658
+ step.status = "conflicted";
659
+ await writeProgress(progress, cwd);
660
+ return { status: "conflict", rebased, conflictBranch: step.branch };
661
+ }
662
+ throw error;
663
+ }
664
+ }
665
+ await clearProgress(cwd);
666
+ await checkoutBranch(progress.originalBranch, cwd);
667
+ const allSkipped = progress.steps.every(
668
+ (s) => s.status === "skipped" || s.status === "done"
669
+ );
670
+ return {
671
+ status: rebased.length === 0 && allSkipped ? "up-to-date" : "success",
672
+ rebased
673
+ };
674
+ }
675
+ function getTargetStacks(stacks, currentBranch) {
676
+ const rootStacks = stacks.filter(
677
+ (s) => s.branches.some((b) => b.name === currentBranch && b.type === "root")
678
+ );
679
+ if (rootStacks.length > 0) return rootStacks;
680
+ const stack = stacks.find(
681
+ (s) => s.branches.some((b) => b.name === currentBranch)
682
+ );
683
+ return stack ? [stack] : [];
684
+ }
685
+ async function buildRestackSteps(stacks, cwd) {
686
+ const steps = [];
687
+ for (const stack of stacks) {
688
+ const ordered = topologicalOrder(stack);
689
+ for (const branch of ordered) {
690
+ if (branch.type === "root" || !branch.parent) continue;
691
+ const mergeBase = await getMergeBase(branch.parent, branch.name, cwd);
692
+ steps.push({
693
+ branch: branch.name,
694
+ parent: branch.parent,
695
+ parentOldTip: mergeBase,
696
+ status: "pending"
697
+ });
698
+ }
699
+ }
700
+ return steps;
701
+ }
702
+ async function getProgressPath(cwd) {
703
+ const dubDir = await getDubDir(cwd);
704
+ return path4.join(dubDir, "restack-progress.json");
705
+ }
706
+ async function writeProgress(progress, cwd) {
707
+ const progressPath = await getProgressPath(cwd);
708
+ fs4.writeFileSync(progressPath, `${JSON.stringify(progress, null, 2)}
709
+ `);
710
+ }
711
+ async function readProgress(cwd) {
712
+ const progressPath = await getProgressPath(cwd);
713
+ if (!fs4.existsSync(progressPath)) return null;
714
+ const raw = fs4.readFileSync(progressPath, "utf-8");
715
+ return JSON.parse(raw);
716
+ }
717
+ async function clearProgress(cwd) {
718
+ const progressPath = await getProgressPath(cwd);
719
+ if (fs4.existsSync(progressPath)) {
720
+ fs4.unlinkSync(progressPath);
721
+ }
722
+ }
723
+ var init_restack = __esm({
724
+ "src/commands/restack.ts"() {
725
+ "use strict";
726
+ init_errors();
727
+ init_git();
728
+ init_state();
729
+ init_undo_log();
730
+ }
731
+ });
732
+
733
+ // src/lib/skills.ts
734
+ function getSkillRemote(name) {
735
+ return AVAILABLE_SKILLS[name];
736
+ }
737
+ var AVAILABLE_SKILLS;
738
+ var init_skills = __esm({
739
+ "src/lib/skills.ts"() {
740
+ "use strict";
741
+ AVAILABLE_SKILLS = {
742
+ dubstack: "wiseiodev/dubstack/skills/dubstack",
743
+ "dub-flow": "wiseiodev/dubstack/skills/dub-flow"
744
+ };
745
+ }
746
+ });
747
+
748
+ // src/commands/skills.ts
749
+ var skills_exports = {};
750
+ __export(skills_exports, {
751
+ addSkills: () => addSkills,
752
+ removeSkills: () => removeSkills
753
+ });
754
+ import chalk from "chalk";
755
+ import { execa as execa3 } from "execa";
756
+ function validateSkills(skills) {
757
+ const invalidSkills = skills.filter((s) => !(s in AVAILABLE_SKILLS));
758
+ if (invalidSkills.length > 0) {
759
+ throw new DubError(
760
+ `Unknown skill(s): ${invalidSkills.join(", ")}. Available skills: ${Object.keys(AVAILABLE_SKILLS).join(", ")}`
761
+ );
762
+ }
763
+ return skills;
764
+ }
765
+ async function addSkills(skills, options = {}) {
766
+ const targets = skills.length > 0 ? validateSkills(skills) : Object.keys(AVAILABLE_SKILLS);
767
+ console.log(chalk.blue(`Adding ${targets.length} skill(s)...`));
768
+ for (const skill of targets) {
769
+ const remote = getSkillRemote(skill);
770
+ const args = ["skills", "add", remote];
771
+ if (options.global) args.push("--global");
772
+ const command = `npx ${args.join(" ")}`;
773
+ console.log(chalk.dim(`Running: ${command}`));
774
+ if (!options.dryRun) {
775
+ try {
776
+ await execa3("npx", args, { stdio: "inherit" });
777
+ console.log(chalk.green(`\u2714 Added skill: ${skill}`));
778
+ } catch (error) {
779
+ console.error(chalk.red(`\u2716 Failed to add skill: ${skill}`));
780
+ throw error;
781
+ }
782
+ }
783
+ }
784
+ }
785
+ async function removeSkills(skills, options = {}) {
786
+ const targets = skills.length > 0 ? validateSkills(skills) : Object.keys(AVAILABLE_SKILLS);
787
+ console.log(chalk.blue(`Removing ${targets.length} skill(s)...`));
788
+ for (const skill of targets) {
789
+ const args = ["skills", "remove", skill];
790
+ if (options.global) args.push("--global");
791
+ const command = `npx ${args.join(" ")}`;
792
+ console.log(chalk.dim(`Running: ${command}`));
793
+ if (!options.dryRun) {
794
+ try {
795
+ await execa3("npx", args, { stdio: "inherit" });
796
+ console.log(chalk.green(`\u2714 Removed skill: ${skill}`));
797
+ } catch (error) {
798
+ console.error(chalk.red(`\u2716 Failed to remove skill: ${skill}`));
799
+ throw error;
800
+ }
801
+ }
802
+ }
803
+ }
804
+ var init_skills2 = __esm({
805
+ "src/commands/skills.ts"() {
806
+ "use strict";
807
+ init_errors();
808
+ init_skills();
809
+ }
810
+ });
811
+
812
+ // src/commands/modify.ts
813
+ var modify_exports = {};
814
+ __export(modify_exports, {
815
+ modify: () => modify
816
+ });
817
+ async function modify(cwd, options) {
818
+ const currentBranch = await getCurrentBranch(cwd);
819
+ const state = await readState(cwd);
820
+ if (options.interactiveRebase) {
821
+ const parent = getParent(state, currentBranch);
822
+ if (!parent) {
823
+ throw new DubError(
824
+ `Could not determine parent branch for '${currentBranch}'. Cannot start interactive rebase.`
825
+ );
826
+ }
827
+ const parentTip = await getBranchTip(parent, cwd);
828
+ console.log(`Starting interactive rebase on top of '${parent}'...`);
829
+ await interactiveRebase(parentTip, cwd);
830
+ await restackChildren(cwd);
831
+ return;
832
+ }
833
+ if (options.patch) {
834
+ await interactiveStage(cwd);
835
+ } else if (options.all) {
836
+ await stageAll(cwd);
837
+ } else if (options.update) {
838
+ await stageUpdate(cwd);
839
+ }
840
+ await printVerboseDiff(cwd, options.verbose ?? 0);
841
+ const hasStaged = await hasStagedChanges(cwd);
842
+ const shouldCreateNew = options.commit;
843
+ const message = normalizeMessage(options.message);
844
+ const noEdit = !options.edit && !!message;
845
+ if (shouldCreateNew) {
846
+ if (!hasStaged) {
847
+ throw new DubError("No staged changes to commit.");
848
+ }
849
+ await commit(cwd, { message, noEdit: !options.edit });
850
+ } else {
851
+ await amendCommit(cwd, { message, noEdit });
852
+ }
853
+ await restackChildren(cwd);
854
+ }
855
+ function normalizeMessage(message) {
856
+ if (Array.isArray(message)) {
857
+ const chunks = message.map((part) => part.trim()).filter(Boolean);
858
+ return chunks.length > 0 ? chunks.join("\n\n") : void 0;
859
+ }
860
+ return message;
861
+ }
862
+ async function printVerboseDiff(cwd, level) {
863
+ if (level < 1) return;
864
+ const staged = await getDiff(cwd, true);
865
+ console.log(staged || "(no staged diff)");
866
+ if (level > 1) {
867
+ const unstaged = await getDiff(cwd, false);
868
+ console.log(unstaged || "(no unstaged diff)");
869
+ }
870
+ }
871
+ async function restackChildren(cwd) {
872
+ try {
873
+ await restack(cwd);
874
+ } catch (e) {
875
+ if (e instanceof DubError && e.message.includes("Conflict")) {
876
+ console.log(
877
+ "\u26A0 Modify successful, but auto-restacking encountered conflicts."
878
+ );
879
+ console.log(" Run 'dub restack --continue' to resolve.");
880
+ } else {
881
+ console.log("\u26A0 Modify successful, but auto-restacking failed.");
882
+ console.log(` ${e instanceof Error ? e.message : String(e)}`);
883
+ }
884
+ }
885
+ }
886
+ var init_modify = __esm({
887
+ "src/commands/modify.ts"() {
888
+ "use strict";
889
+ init_errors();
890
+ init_git();
891
+ init_state();
892
+ init_restack();
893
+ }
894
+ });
895
+
896
+ // src/index.ts
897
+ import { createRequire } from "module";
898
+ import chalk2 from "chalk";
899
+ import { Command } from "commander";
900
+
901
+ // src/commands/branch.ts
902
+ init_git();
903
+ init_state();
904
+ function findRootName(stack) {
905
+ return stack.branches.find((branch) => branch.type === "root")?.name ?? null;
906
+ }
907
+ function getChildren(stack, branchName) {
908
+ return stack.branches.filter((branch) => branch.parent === branchName).map((branch) => branch.name).sort();
909
+ }
910
+ async function branchInfo(cwd, branchName) {
911
+ const state = await readState(cwd);
912
+ const resolvedBranch = branchName ?? await getCurrentBranch(cwd);
913
+ const stack = findStackForBranch(state, resolvedBranch);
914
+ if (!stack) {
915
+ return {
916
+ currentBranch: resolvedBranch,
917
+ tracked: false,
918
+ stackId: null,
919
+ root: null,
920
+ parent: null,
921
+ children: []
922
+ };
923
+ }
924
+ const current = stack.branches.find(
925
+ (branch) => branch.name === resolvedBranch
926
+ );
927
+ return {
928
+ currentBranch: resolvedBranch,
929
+ tracked: true,
930
+ stackId: stack.id,
931
+ root: findRootName(stack),
932
+ parent: current?.parent ?? null,
933
+ children: getChildren(stack, resolvedBranch)
934
+ };
935
+ }
936
+ function formatBranchInfo(info) {
937
+ if (!info.tracked) {
938
+ return [
939
+ `Branch: ${info.currentBranch}`,
940
+ "Tracked: no",
941
+ "Status: not tracked by DubStack"
942
+ ].join("\n");
943
+ }
944
+ const childrenLabel = info.children.length > 0 ? info.children.join(", ") : "(none)";
945
+ return [
946
+ `Branch: ${info.currentBranch}`,
947
+ "Tracked: yes",
948
+ `Stack ID: ${info.stackId ?? "(unknown)"}`,
949
+ `Root: ${info.root ?? "(unknown)"}`,
950
+ `Parent: ${info.parent ?? "(root)"}`,
951
+ `Children: ${childrenLabel}`
952
+ ].join("\n");
953
+ }
954
+
955
+ // src/commands/checkout.ts
956
+ init_errors();
957
+ init_git();
958
+ init_state();
959
+ import search from "@inquirer/search";
960
+ function getTrackedBranches(state) {
961
+ const names = /* @__PURE__ */ new Set();
962
+ for (const stack of state.stacks) {
963
+ for (const branch of stack.branches) {
964
+ names.add(branch.name);
965
+ }
966
+ }
967
+ return [...names].sort();
968
+ }
969
+ function getValidBranches(tracked, local) {
970
+ const localSet = new Set(local);
971
+ return tracked.filter((b) => localSet.has(b));
972
+ }
973
+ function getStackRelativeBranches(state, branchName) {
974
+ const stack = findStackForBranch(state, branchName);
975
+ if (!stack) return [];
976
+ return [...new Set(stack.branches.map((branch) => branch.name))].sort();
977
+ }
978
+ async function resolveCheckoutTrunk(cwd) {
979
+ const state = await readState(cwd);
980
+ const currentBranch = await getCurrentBranch(cwd);
981
+ const stack = findStackForBranch(state, currentBranch);
982
+ const trackedRoot = stack?.branches.find((branch) => branch.type === "root")?.name ?? null;
983
+ if (trackedRoot) return trackedRoot;
984
+ if (await branchExists("main", cwd)) return "main";
985
+ if (await branchExists("master", cwd)) return "master";
986
+ throw new DubError(
987
+ `Could not determine trunk branch for '${currentBranch}'.`
988
+ );
989
+ }
990
+ async function checkout(name, cwd) {
991
+ await checkoutBranch(name, cwd);
992
+ return { branch: name };
993
+ }
994
+ async function interactiveCheckout(cwd, options = {}) {
995
+ const state = await readState(cwd);
996
+ const localBranches = await listBranches(cwd);
997
+ const currentBranch = await getCurrentBranch(cwd).catch(() => null);
998
+ const trackedBranches = getTrackedBranches(state);
999
+ const stackBranches = currentBranch ? getStackRelativeBranches(state, currentBranch) : [];
1000
+ let branchCandidates = options.showUntracked ? [...new Set(localBranches)].sort() : getValidBranches(trackedBranches, localBranches);
1001
+ if (options.stack && stackBranches.length > 0) {
1002
+ const stackSet = new Set(stackBranches);
1003
+ branchCandidates = branchCandidates.filter((name) => stackSet.has(name));
1004
+ }
1005
+ const validBranches = branchCandidates;
1006
+ if (validBranches.length === 0) {
1007
+ throw new DubError(
1008
+ "No valid tracked branches found. Run 'dub create' first."
1009
+ );
462
1010
  }
463
1011
  const controller = new AbortController();
464
1012
  const onKeypress = (_str, key) => {
@@ -499,39 +1047,14 @@ async function interactiveCheckout(cwd) {
499
1047
 
500
1048
  // src/commands/create.ts
501
1049
  init_errors();
502
-
503
- // src/lib/undo-log.ts
504
- init_errors();
505
- import * as fs2 from "fs";
506
- import * as path2 from "path";
507
- async function getUndoPath(cwd) {
508
- const dubDir = await getDubDir(cwd);
509
- return path2.join(dubDir, "undo.json");
510
- }
511
- async function saveUndoEntry(entry, cwd) {
512
- const undoPath = await getUndoPath(cwd);
513
- fs2.writeFileSync(undoPath, `${JSON.stringify(entry, null, 2)}
514
- `);
515
- }
516
- async function readUndoEntry(cwd) {
517
- const undoPath = await getUndoPath(cwd);
518
- if (!fs2.existsSync(undoPath)) {
519
- throw new DubError("Nothing to undo.");
520
- }
521
- const raw = fs2.readFileSync(undoPath, "utf-8");
522
- return JSON.parse(raw);
523
- }
524
- async function clearUndoEntry(cwd) {
525
- const undoPath = await getUndoPath(cwd);
526
- if (fs2.existsSync(undoPath)) {
527
- fs2.unlinkSync(undoPath);
528
- }
529
- }
530
-
531
- // src/commands/create.ts
1050
+ init_git();
1051
+ init_state();
1052
+ init_undo_log();
532
1053
  async function create(name, cwd, options) {
533
- if (options?.all && !options.message) {
534
- throw new DubError("'-a' requires '-m'. Pass a commit message.");
1054
+ if ((options?.all || options?.update || options?.patch) && !options.message) {
1055
+ throw new DubError(
1056
+ "'--all', '--update', and '--patch' require '-m'. Pass a commit message."
1057
+ );
535
1058
  }
536
1059
  const state = await ensureState(cwd);
537
1060
  const parent = await getCurrentBranch(cwd);
@@ -539,8 +1062,12 @@ async function create(name, cwd, options) {
539
1062
  throw new DubError(`Branch '${name}' already exists.`);
540
1063
  }
541
1064
  if (options?.message) {
542
- if (options.all) {
1065
+ if (options.patch) {
1066
+ await interactiveStage(cwd);
1067
+ } else if (options.all) {
543
1068
  await stageAll(cwd);
1069
+ } else if (options.update) {
1070
+ await stageUpdate(cwd);
544
1071
  }
545
1072
  if (!await hasStagedChanges(cwd)) {
546
1073
  const hint = options.all ? "No changes to commit." : "No staged changes. Stage files with 'git add' or use '-a' to stage all.";
@@ -577,6 +1104,8 @@ async function create(name, cwd, options) {
577
1104
 
578
1105
  // src/commands/init.ts
579
1106
  init_errors();
1107
+ init_git();
1108
+ init_state();
580
1109
  import * as fs3 from "fs";
581
1110
  import * as path3 from "path";
582
1111
  async function init(cwd) {
@@ -608,6 +1137,8 @@ async function init(cwd) {
608
1137
  }
609
1138
 
610
1139
  // src/commands/log.ts
1140
+ init_git();
1141
+ init_state();
611
1142
  async function log(cwd) {
612
1143
  const state = await readState(cwd);
613
1144
  if (state.stacks.length === 0) {
@@ -675,160 +1206,148 @@ async function renderNode(branch, currentBranch, childMap, prefix, isRoot, isLas
675
1206
  }
676
1207
  }
677
1208
 
678
- // src/commands/restack.ts
1209
+ // src/commands/navigate.ts
679
1210
  init_errors();
680
- import * as fs4 from "fs";
681
- import * as path4 from "path";
682
- async function restack(cwd) {
683
- const state = await readState(cwd);
684
- if (!await isWorkingTreeClean(cwd)) {
1211
+ init_git();
1212
+ init_state();
1213
+ function getBranchByName(stack, name) {
1214
+ return stack.branches.find((branch) => branch.name === name);
1215
+ }
1216
+ function getChildren2(stack, parent) {
1217
+ return stack.branches.filter((branch) => branch.parent === parent).map((branch) => branch.name);
1218
+ }
1219
+ function getTrackedStackOrThrow(stateBranch, stack) {
1220
+ if (!stack) {
685
1221
  throw new DubError(
686
- "Working tree has uncommitted changes. Commit or stash them before restacking."
1222
+ `Current branch '${stateBranch}' is not tracked by DubStack.`
687
1223
  );
688
1224
  }
689
- const originalBranch = await getCurrentBranch(cwd);
690
- const targetStacks = getTargetStacks(state.stacks, originalBranch);
691
- if (targetStacks.length === 0) {
692
- throw new DubError(
693
- `Branch '${originalBranch}' is not part of any stack. Run 'dub create' first.`
694
- );
1225
+ return stack;
1226
+ }
1227
+ async function upBySteps(cwd, steps) {
1228
+ if (!Number.isInteger(steps) || steps < 1) {
1229
+ throw new DubError("'steps' must be a positive integer.");
695
1230
  }
696
- const allBranches = targetStacks.flatMap((s) => s.branches);
697
- for (const branch of allBranches) {
698
- if (!await branchExists(branch.name, cwd)) {
1231
+ const state = await readState(cwd);
1232
+ const current = await getCurrentBranch(cwd);
1233
+ const stack = getTrackedStackOrThrow(
1234
+ current,
1235
+ findStackForBranch(state, current)
1236
+ );
1237
+ let target = current;
1238
+ for (let i = 0; i < steps; i++) {
1239
+ const children = getChildren2(stack, target);
1240
+ if (children.length === 0) {
1241
+ throw new DubError(`No branch above '${target}' in the current stack.`);
1242
+ }
1243
+ if (children.length > 1) {
699
1244
  throw new DubError(
700
- `Branch '${branch.name}' is tracked in state but no longer exists in git.
701
- Remove it from the stack or recreate it before restacking.`
1245
+ `Branch '${target}' has multiple children; 'dub up' requires a linear stack path.`
702
1246
  );
703
1247
  }
1248
+ target = children[0];
704
1249
  }
705
- const branchTips = {};
706
- for (const branch of allBranches) {
707
- branchTips[branch.name] = await getBranchTip(branch.name, cwd);
708
- }
709
- const steps = await buildRestackSteps(targetStacks, cwd);
710
- if (steps.length === 0) {
711
- return { status: "up-to-date", rebased: [] };
1250
+ await checkoutBranch(target, cwd);
1251
+ return { branch: target, changed: target !== current };
1252
+ }
1253
+ async function downBySteps(cwd, steps) {
1254
+ if (!Number.isInteger(steps) || steps < 1) {
1255
+ throw new DubError("'steps' must be a positive integer.");
712
1256
  }
713
- await saveUndoEntry(
714
- {
715
- operation: "restack",
716
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
717
- previousBranch: originalBranch,
718
- previousState: structuredClone(state),
719
- branchTips,
720
- createdBranches: []
721
- },
722
- cwd
1257
+ const state = await readState(cwd);
1258
+ const current = await getCurrentBranch(cwd);
1259
+ const stack = getTrackedStackOrThrow(
1260
+ current,
1261
+ findStackForBranch(state, current)
723
1262
  );
724
- const progress = { originalBranch, steps };
725
- await writeProgress(progress, cwd);
726
- return executeRestackSteps(progress, cwd);
1263
+ let target = current;
1264
+ for (let i = 0; i < steps; i++) {
1265
+ const branch = getBranchByName(stack, target);
1266
+ if (!branch) {
1267
+ throw new DubError(
1268
+ `Current branch '${target}' is not tracked by DubStack.`
1269
+ );
1270
+ }
1271
+ if (!branch.parent) {
1272
+ throw new DubError(
1273
+ `Already at the bottom of the stack (root branch '${target}').`
1274
+ );
1275
+ }
1276
+ target = branch.parent;
1277
+ }
1278
+ await checkoutBranch(target, cwd);
1279
+ return { branch: target, changed: target !== current };
727
1280
  }
728
- async function restackContinue(cwd) {
729
- const progress = await readProgress(cwd);
730
- if (!progress) {
731
- throw new DubError("No restack in progress. Run 'dub restack' to start.");
1281
+ async function top(cwd) {
1282
+ const state = await readState(cwd);
1283
+ const current = await getCurrentBranch(cwd);
1284
+ const stack = getTrackedStackOrThrow(
1285
+ current,
1286
+ findStackForBranch(state, current)
1287
+ );
1288
+ let target = current;
1289
+ while (true) {
1290
+ const children = getChildren2(stack, target);
1291
+ if (children.length === 0) break;
1292
+ if (children.length > 1) {
1293
+ throw new DubError(
1294
+ `Branch '${target}' has multiple children; 'dub top' requires a linear stack path.`
1295
+ );
1296
+ }
1297
+ target = children[0];
732
1298
  }
733
- await rebaseContinue(cwd);
734
- const conflictedStep = progress.steps.find((s) => s.status === "conflicted");
735
- if (conflictedStep) {
736
- conflictedStep.status = "done";
1299
+ if (target !== current) {
1300
+ await checkoutBranch(target, cwd);
737
1301
  }
738
- return executeRestackSteps(progress, cwd);
1302
+ return { branch: target, changed: target !== current };
739
1303
  }
740
- async function executeRestackSteps(progress, cwd) {
741
- const rebased = [];
742
- for (const step of progress.steps) {
743
- if (step.status !== "pending") {
744
- if (step.status === "done") rebased.push(step.branch);
745
- continue;
1304
+ async function bottom(cwd) {
1305
+ const state = await readState(cwd);
1306
+ const current = await getCurrentBranch(cwd);
1307
+ const stack = getTrackedStackOrThrow(
1308
+ current,
1309
+ findStackForBranch(state, current)
1310
+ );
1311
+ const branch = getBranchByName(stack, current);
1312
+ if (!branch) {
1313
+ throw new DubError(
1314
+ `Current branch '${current}' is not tracked by DubStack.`
1315
+ );
1316
+ }
1317
+ let target = current;
1318
+ if (!branch.parent) {
1319
+ const children = getChildren2(stack, current);
1320
+ if (children.length === 0) {
1321
+ throw new DubError(
1322
+ `No branch above root '${current}' in the current stack.`
1323
+ );
746
1324
  }
747
- const parentNewTip = await getBranchTip(step.parent, cwd);
748
- if (parentNewTip === step.parentOldTip) {
749
- step.status = "skipped";
750
- await writeProgress(progress, cwd);
751
- continue;
1325
+ if (children.length > 1) {
1326
+ throw new DubError(
1327
+ `Root branch '${current}' has multiple children; 'dub bottom' requires a linear stack path.`
1328
+ );
752
1329
  }
753
- try {
754
- await rebaseOnto(parentNewTip, step.parentOldTip, step.branch, cwd);
755
- step.status = "done";
756
- rebased.push(step.branch);
757
- await writeProgress(progress, cwd);
758
- } catch (error) {
759
- if (error instanceof DubError && error.message.includes("Conflict")) {
760
- step.status = "conflicted";
761
- await writeProgress(progress, cwd);
762
- return { status: "conflict", rebased, conflictBranch: step.branch };
1330
+ target = children[0];
1331
+ } else {
1332
+ let node = branch;
1333
+ while (node.parent) {
1334
+ const parent = getBranchByName(stack, node.parent);
1335
+ if (!parent) {
1336
+ break;
763
1337
  }
764
- throw error;
765
- }
766
- }
767
- await clearProgress(cwd);
768
- await checkoutBranch(progress.originalBranch, cwd);
769
- const allSkipped = progress.steps.every(
770
- (s) => s.status === "skipped" || s.status === "done"
771
- );
772
- return {
773
- status: rebased.length === 0 && allSkipped ? "up-to-date" : "success",
774
- rebased
775
- };
776
- }
777
- function getTargetStacks(stacks, currentBranch) {
778
- const rootStacks = stacks.filter(
779
- (s) => s.branches.some((b) => b.name === currentBranch && b.type === "root")
780
- );
781
- if (rootStacks.length > 0) return rootStacks;
782
- const stack = stacks.find(
783
- (s) => s.branches.some((b) => b.name === currentBranch)
784
- );
785
- return stack ? [stack] : [];
786
- }
787
- async function buildRestackSteps(stacks, cwd) {
788
- const steps = [];
789
- for (const stack of stacks) {
790
- const ordered = topologicalOrder(stack);
791
- for (const branch of ordered) {
792
- if (branch.type === "root" || !branch.parent) continue;
793
- const mergeBase = await getMergeBase(branch.parent, branch.name, cwd);
794
- steps.push({
795
- branch: branch.name,
796
- parent: branch.parent,
797
- parentOldTip: mergeBase,
798
- status: "pending"
799
- });
1338
+ if (parent.parent === null) {
1339
+ target = node.name;
1340
+ break;
1341
+ }
1342
+ node = parent;
800
1343
  }
801
1344
  }
802
- return steps;
803
- }
804
- async function getProgressPath(cwd) {
805
- const dubDir = await getDubDir(cwd);
806
- return path4.join(dubDir, "restack-progress.json");
807
- }
808
- async function writeProgress(progress, cwd) {
809
- const progressPath = await getProgressPath(cwd);
810
- fs4.writeFileSync(progressPath, `${JSON.stringify(progress, null, 2)}
811
- `);
812
- }
813
- async function readProgress(cwd) {
814
- const progressPath = await getProgressPath(cwd);
815
- if (!fs4.existsSync(progressPath)) return null;
816
- const raw = fs4.readFileSync(progressPath, "utf-8");
817
- return JSON.parse(raw);
818
- }
819
- async function clearProgress(cwd) {
820
- const progressPath = await getProgressPath(cwd);
821
- if (fs4.existsSync(progressPath)) {
822
- fs4.unlinkSync(progressPath);
1345
+ if (target !== current) {
1346
+ await checkoutBranch(target, cwd);
823
1347
  }
1348
+ return { branch: target, changed: target !== current };
824
1349
  }
825
1350
 
826
- // src/commands/submit.ts
827
- init_errors();
828
- import * as fs5 from "fs";
829
- import * as os from "os";
830
- import * as path5 from "path";
831
-
832
1351
  // src/lib/github.ts
833
1352
  init_errors();
834
1353
  import { execa as execa2 } from "execa";
@@ -871,6 +1390,58 @@ async function getPr(branch, cwd) {
871
1390
  throw new DubError(`Failed to parse PR info for branch '${branch}'.`);
872
1391
  }
873
1392
  }
1393
+ async function getBranchPrLifecycleState(branch, cwd) {
1394
+ const info = await getBranchPrSyncInfo(branch, cwd);
1395
+ return info.state;
1396
+ }
1397
+ async function getBranchPrSyncInfo(branch, cwd) {
1398
+ const { stdout } = await execa2(
1399
+ "gh",
1400
+ [
1401
+ "pr",
1402
+ "list",
1403
+ "--head",
1404
+ branch,
1405
+ "--state",
1406
+ "all",
1407
+ "--json",
1408
+ "state,mergedAt,baseRefName",
1409
+ "--jq",
1410
+ ".[0]"
1411
+ ],
1412
+ { cwd }
1413
+ );
1414
+ const trimmed = stdout.trim();
1415
+ if (!trimmed || trimmed === "null") {
1416
+ return { state: "NONE", baseRefName: null };
1417
+ }
1418
+ try {
1419
+ const parsed = JSON.parse(trimmed);
1420
+ if (parsed.mergedAt) {
1421
+ return {
1422
+ state: "MERGED",
1423
+ baseRefName: parsed.baseRefName ?? null
1424
+ };
1425
+ }
1426
+ if (parsed.state === "CLOSED") {
1427
+ return {
1428
+ state: "CLOSED",
1429
+ baseRefName: parsed.baseRefName ?? null
1430
+ };
1431
+ }
1432
+ if (parsed.state === "OPEN") {
1433
+ return {
1434
+ state: "OPEN",
1435
+ baseRefName: parsed.baseRefName ?? null
1436
+ };
1437
+ }
1438
+ return { state: "NONE", baseRefName: parsed.baseRefName ?? null };
1439
+ } catch {
1440
+ throw new DubError(
1441
+ `Failed to parse PR lifecycle state for branch '${branch}'.`
1442
+ );
1443
+ }
1444
+ }
874
1445
  async function createPr(branch, base, title, bodyFile, cwd) {
875
1446
  let stdout;
876
1447
  try {
@@ -929,6 +1500,39 @@ async function updatePrBody(prNumber, bodyFile, cwd) {
929
1500
  throw new DubError(`Failed to update PR #${prNumber}: ${message}`);
930
1501
  }
931
1502
  }
1503
+ async function openPrInBrowser(cwd, target) {
1504
+ const args = target ? ["pr", "view", target, "--web"] : ["pr", "view", "--web"];
1505
+ try {
1506
+ await execa2("gh", args, { cwd, stdio: "inherit" });
1507
+ } catch (error) {
1508
+ const message = error instanceof Error ? error.message : String(error);
1509
+ if (message.toLowerCase().includes("no pull requests")) {
1510
+ throw new DubError(
1511
+ target ? `No PR found for '${target}'.` : "No PR found for the current branch."
1512
+ );
1513
+ }
1514
+ throw new DubError(
1515
+ target ? `Failed to open PR for '${target}': ${message}` : `Failed to open PR: ${message}`
1516
+ );
1517
+ }
1518
+ }
1519
+
1520
+ // src/commands/pr.ts
1521
+ async function pr(cwd, branch) {
1522
+ await ensureGhInstalled();
1523
+ await checkGhAuth();
1524
+ await openPrInBrowser(cwd, branch);
1525
+ }
1526
+
1527
+ // src/index.ts
1528
+ init_restack();
1529
+
1530
+ // src/commands/submit.ts
1531
+ init_errors();
1532
+ init_git();
1533
+ import * as fs5 from "fs";
1534
+ import * as os from "os";
1535
+ import * as path5 from "path";
932
1536
 
933
1537
  // src/lib/pr-body.ts
934
1538
  var DUBSTACK_START = "<!-- dubstack:start -->";
@@ -988,6 +1592,7 @@ function composePrBody(existingBody, stackTable, metadataBlock) {
988
1592
  }
989
1593
 
990
1594
  // src/commands/submit.ts
1595
+ init_state();
991
1596
  async function submit(cwd, dryRun) {
992
1597
  await ensureGhInstalled();
993
1598
  await checkGhAuth();
@@ -1043,12 +1648,23 @@ async function submit(cwd, dryRun) {
1043
1648
  if (!dryRun) {
1044
1649
  await updateAllPrBodies(nonRootBranches, prMap, stack.id, cwd);
1045
1650
  for (const branch of nonRootBranches) {
1046
- const pr = prMap.get(branch.name);
1047
- if (pr) {
1651
+ const pr2 = prMap.get(branch.name);
1652
+ if (pr2) {
1048
1653
  const stateBranch = stack.branches.find((b) => b.name === branch.name);
1049
1654
  if (stateBranch) {
1050
- stateBranch.pr_number = pr.number;
1051
- stateBranch.pr_link = pr.url;
1655
+ stateBranch.pr_number = pr2.number;
1656
+ stateBranch.pr_link = pr2.url;
1657
+ const headSha = await getBranchTip(branch.name, cwd);
1658
+ const baseSha = await getBranchTip(branch.parent, cwd);
1659
+ stateBranch.last_submitted_version = {
1660
+ head_sha: headSha,
1661
+ base_sha: baseSha,
1662
+ base_branch: branch.parent,
1663
+ version_number: null,
1664
+ source: "submit"
1665
+ };
1666
+ stateBranch.last_synced_at = (/* @__PURE__ */ new Date()).toISOString();
1667
+ stateBranch.sync_source = "submit";
1052
1668
  }
1053
1669
  }
1054
1670
  }
@@ -1074,30 +1690,30 @@ function validateLinearStack(ordered) {
1074
1690
  async function updateAllPrBodies(branches, prMap, stackId, cwd) {
1075
1691
  const tableEntries = /* @__PURE__ */ new Map();
1076
1692
  for (const branch of branches) {
1077
- const pr = prMap.get(branch.name);
1078
- if (pr) {
1079
- tableEntries.set(branch.name, { number: pr.number, title: pr.title });
1693
+ const pr2 = prMap.get(branch.name);
1694
+ if (pr2) {
1695
+ tableEntries.set(branch.name, { number: pr2.number, title: pr2.title });
1080
1696
  }
1081
1697
  }
1082
1698
  for (let i = 0; i < branches.length; i++) {
1083
1699
  const branch = branches[i];
1084
- const pr = prMap.get(branch.name);
1085
- if (!pr) continue;
1700
+ const pr2 = prMap.get(branch.name);
1701
+ if (!pr2) continue;
1086
1702
  const prevPr = i > 0 ? prMap.get(branches[i - 1].name)?.number ?? null : null;
1087
1703
  const nextPr = i < branches.length - 1 ? prMap.get(branches[i + 1].name)?.number ?? null : null;
1088
1704
  const stackTable = buildStackTable(branches, tableEntries, branch.name);
1089
1705
  const metadataBlock = buildMetadataBlock(
1090
1706
  stackId,
1091
- pr.number,
1707
+ pr2.number,
1092
1708
  prevPr,
1093
1709
  nextPr,
1094
1710
  branch.name
1095
1711
  );
1096
- const existingBody = pr.body;
1712
+ const existingBody = pr2.body;
1097
1713
  const finalBody = composePrBody(existingBody, stackTable, metadataBlock);
1098
1714
  const tmpFile = writeTempBody(finalBody);
1099
1715
  try {
1100
- await updatePrBody(pr.number, tmpFile, cwd);
1716
+ await updatePrBody(pr2.number, tmpFile, cwd);
1101
1717
  } finally {
1102
1718
  cleanupTempFile(tmpFile);
1103
1719
  }
@@ -1116,8 +1732,612 @@ function cleanupTempFile(filePath) {
1116
1732
  }
1117
1733
  }
1118
1734
 
1735
+ // src/commands/sync.ts
1736
+ init_errors();
1737
+ init_git();
1738
+ import { stdin as input, stdout as output } from "process";
1739
+ import * as readline from "readline/promises";
1740
+ init_state();
1741
+
1742
+ // src/lib/sync/branch-status.ts
1743
+ function classifyBranchSyncStatus(input2) {
1744
+ if (!input2.hasRemote) return "missing-remote";
1745
+ if (!input2.hasLocal) return "missing-local";
1746
+ if (input2.localSha && input2.remoteSha && input2.localSha === input2.remoteSha) {
1747
+ if (!input2.hasSubmittedBaseline) {
1748
+ return "updated-outside-dubstack-but-up-to-date";
1749
+ }
1750
+ return "up-to-date";
1751
+ }
1752
+ if (!input2.hasSubmittedBaseline) return "unsubmitted";
1753
+ if (input2.localBehind) return "needs-remote-sync-safe";
1754
+ if (input2.remoteBehind) return "local-ahead";
1755
+ return "reconcile-needed";
1756
+ }
1757
+
1758
+ // src/lib/sync/cleanup.ts
1759
+ async function buildCleanupPlan(input2) {
1760
+ const toDelete = [];
1761
+ const skipped = [];
1762
+ for (const branch of input2.branches) {
1763
+ const prState = await input2.getPrStatus(branch);
1764
+ if (prState !== "MERGED" && prState !== "CLOSED") {
1765
+ continue;
1766
+ }
1767
+ const mergedIntoRoot = await input2.isMergedIntoAnyRoot(branch);
1768
+ if (!mergedIntoRoot) {
1769
+ skipped.push({ branch, reason: "commits-not-in-trunk" });
1770
+ continue;
1771
+ }
1772
+ toDelete.push(branch);
1773
+ }
1774
+ return { toDelete, skipped };
1775
+ }
1776
+
1777
+ // src/lib/sync/reconcile.ts
1778
+ async function resolveReconcileDecision(input2) {
1779
+ if (input2.force) return "take-remote";
1780
+ if (!input2.interactive) return "skip";
1781
+ const raw = await input2.promptChoice();
1782
+ if (raw === "take-remote" || raw === "keep-local" || raw === "reconcile" || raw === "skip") {
1783
+ return raw;
1784
+ }
1785
+ return "skip";
1786
+ }
1787
+
1788
+ // src/lib/sync/report.ts
1789
+ function printBranchOutcome(outcome) {
1790
+ console.log(outcome.message);
1791
+ }
1792
+ function printSyncSummary(result) {
1793
+ const synced = result.branches.filter((b) => b.action === "synced").length;
1794
+ const skipped = result.branches.filter((b) => b.action === "skipped").length;
1795
+ const keptLocal = result.branches.filter(
1796
+ (b) => b.action === "kept-local"
1797
+ ).length;
1798
+ console.log(
1799
+ `\u2714 Sync complete: ${synced} synced, ${keptLocal} kept-local, ${skipped} skipped, ${result.cleaned.length} cleaned`
1800
+ );
1801
+ }
1802
+
1803
+ // src/commands/sync.ts
1804
+ init_restack();
1805
+ function isInteractiveShell() {
1806
+ return Boolean(process.stdout.isTTY && process.stdin.isTTY);
1807
+ }
1808
+ async function confirm(question) {
1809
+ const rl = readline.createInterface({ input, output });
1810
+ try {
1811
+ const answer = await rl.question(`${question} [Y/n] `);
1812
+ const normalized = answer.trim().toLowerCase();
1813
+ return normalized === "" || normalized === "y" || normalized === "yes";
1814
+ } finally {
1815
+ rl.close();
1816
+ }
1817
+ }
1818
+ async function choose(question, choices) {
1819
+ const rl = readline.createInterface({ input, output });
1820
+ try {
1821
+ console.log(question);
1822
+ for (let i = 0; i < choices.length; i++) {
1823
+ console.log(` ${i + 1}. ${choices[i].label}`);
1824
+ }
1825
+ const answer = await rl.question("Select option: ");
1826
+ const idx = Number.parseInt(answer.trim(), 10) - 1;
1827
+ if (Number.isNaN(idx) || idx < 0 || idx >= choices.length) {
1828
+ return choices[choices.length - 1].value;
1829
+ }
1830
+ return choices[idx].value;
1831
+ } finally {
1832
+ rl.close();
1833
+ }
1834
+ }
1835
+ async function sync(cwd, rawOptions = {}) {
1836
+ await ensureGhInstalled();
1837
+ await checkGhAuth();
1838
+ const options = {
1839
+ restack: rawOptions.restack ?? true,
1840
+ force: rawOptions.force ?? false,
1841
+ all: rawOptions.all ?? false,
1842
+ interactive: rawOptions.interactive ?? isInteractiveShell()
1843
+ };
1844
+ const state = await readState(cwd);
1845
+ const originalBranch = await getCurrentBranch(cwd);
1846
+ const scopeStacks = options.all ? state.stacks : (() => {
1847
+ const stack = findStackForBranch(state, originalBranch);
1848
+ if (!stack) {
1849
+ throw new DubError(
1850
+ `Branch '${originalBranch}' is not part of any stack. Run 'dub create' first.`
1851
+ );
1852
+ }
1853
+ return [stack];
1854
+ })();
1855
+ const stateBranchMap = new Map(
1856
+ scopeStacks.flatMap((stack) => stack.branches.map((b) => [b.name, b]))
1857
+ );
1858
+ const roots = Array.from(
1859
+ new Set(
1860
+ scopeStacks.flatMap((s) => s.branches).filter((b) => b.type === "root").map((b) => b.name)
1861
+ )
1862
+ );
1863
+ const stackBranches = Array.from(
1864
+ new Set(
1865
+ scopeStacks.flatMap((s) => s.branches).filter((b) => b.type !== "root").map((b) => b.name)
1866
+ )
1867
+ );
1868
+ const result = {
1869
+ fetched: [],
1870
+ trunksSynced: [],
1871
+ cleaned: [],
1872
+ branches: [],
1873
+ restacked: false
1874
+ };
1875
+ const rootHasRemote = /* @__PURE__ */ new Map();
1876
+ console.log("\u{1F332} Fetching branches from remote...");
1877
+ const toFetch = [.../* @__PURE__ */ new Set([...roots, ...stackBranches])];
1878
+ if (toFetch.length > 0) {
1879
+ await fetchBranches(toFetch, cwd);
1880
+ result.fetched = toFetch;
1881
+ }
1882
+ for (const root of roots) {
1883
+ const remoteRef = `origin/${root}`;
1884
+ const hasRemoteRoot = await remoteBranchExists(root, cwd);
1885
+ rootHasRemote.set(root, hasRemoteRoot);
1886
+ if (!hasRemoteRoot) continue;
1887
+ const ff = await fastForwardBranchToRef(root, remoteRef, cwd);
1888
+ if (ff) {
1889
+ result.trunksSynced.push(root);
1890
+ continue;
1891
+ }
1892
+ if (options.force) {
1893
+ await hardResetBranchToRef(root, remoteRef, cwd);
1894
+ result.trunksSynced.push(root);
1895
+ continue;
1896
+ }
1897
+ if (options.interactive) {
1898
+ const takeRemote = await confirm(
1899
+ `Trunk '${root}' cannot be fast-forwarded. Overwrite local trunk with '${remoteRef}'?`
1900
+ );
1901
+ if (takeRemote) {
1902
+ await hardResetBranchToRef(root, remoteRef, cwd);
1903
+ result.trunksSynced.push(root);
1904
+ }
1905
+ }
1906
+ }
1907
+ console.log("\u{1F9F9} Cleaning up branches with merged/closed PRs...");
1908
+ const localTrackedBranches = [];
1909
+ for (const branch of stackBranches) {
1910
+ const hasLocal = await branchExists(branch, cwd);
1911
+ if (hasLocal) localTrackedBranches.push(branch);
1912
+ }
1913
+ const cleanupPlan = await buildCleanupPlan({
1914
+ branches: localTrackedBranches,
1915
+ getPrStatus: (branch) => getBranchPrLifecycleState(branch, cwd),
1916
+ isMergedIntoAnyRoot: async (branch) => {
1917
+ for (const root of roots) {
1918
+ const compareRef = rootHasRemote.get(root) ? `origin/${root}` : root;
1919
+ if (await isAncestor(branch, compareRef, cwd)) return true;
1920
+ }
1921
+ return false;
1922
+ }
1923
+ });
1924
+ const excludedFromSync = /* @__PURE__ */ new Set();
1925
+ for (const skipped of cleanupPlan.skipped) {
1926
+ if (skipped.reason === "commits-not-in-trunk") {
1927
+ excludedFromSync.add(skipped.branch);
1928
+ for (const child of getDescendants(scopeStacks, skipped.branch)) {
1929
+ excludedFromSync.add(child);
1930
+ }
1931
+ }
1932
+ }
1933
+ for (const branch of cleanupPlan.toDelete) {
1934
+ if (excludedFromSync.has(branch)) continue;
1935
+ let shouldDelete = options.force;
1936
+ if (!shouldDelete && options.interactive) {
1937
+ shouldDelete = await confirm(
1938
+ `Branch '${branch}' has merged/closed PR and is in trunk. Delete local branch?`
1939
+ );
1940
+ }
1941
+ if (shouldDelete) {
1942
+ await checkoutBranch(roots[0] ?? originalBranch, cwd);
1943
+ await deleteBranch(branch, cwd);
1944
+ removeBranchFromState(scopeStacks, branch);
1945
+ result.cleaned.push(branch);
1946
+ }
1947
+ }
1948
+ for (const skipped of cleanupPlan.skipped) {
1949
+ console.log(
1950
+ `\u2022 Skipped cleanup for '${skipped.branch}' (${skipped.reason}).`
1951
+ );
1952
+ }
1953
+ for (const excluded of excludedFromSync) {
1954
+ console.log(
1955
+ `\u2022 Excluding '${excluded}' from sync because its stack is not cleanable yet.`
1956
+ );
1957
+ }
1958
+ console.log("\u{1F504} Syncing branches...");
1959
+ for (const branch of stackBranches) {
1960
+ if (result.cleaned.includes(branch) || excludedFromSync.has(branch))
1961
+ continue;
1962
+ const hasRemote = await remoteBranchExists(branch, cwd);
1963
+ const hasLocal = await branchExists(branch, cwd);
1964
+ let outcome;
1965
+ const remoteRef = `origin/${branch}`;
1966
+ const localSha = hasLocal ? await getRefSha(branch, cwd) : null;
1967
+ const remoteSha = hasRemote ? await getRefSha(remoteRef, cwd) : null;
1968
+ const localBehind = hasLocal && hasRemote ? await isAncestor(branch, remoteRef, cwd) : false;
1969
+ const remoteBehind = hasLocal && hasRemote ? await isAncestor(remoteRef, branch, cwd) : false;
1970
+ let status = classifyBranchSyncStatus({
1971
+ hasRemote,
1972
+ hasLocal,
1973
+ localSha,
1974
+ remoteSha,
1975
+ localBehind,
1976
+ remoteBehind,
1977
+ hasSubmittedBaseline: stateBranchMap.get(branch)?.last_submitted_version != null
1978
+ });
1979
+ const prSyncInfo = hasRemote ? await getBranchPrSyncInfo(branch, cwd) : { state: "NONE", baseRefName: null };
1980
+ const localParent = stateBranchMap.get(branch)?.parent ?? null;
1981
+ if (hasRemote && hasLocal && localSha !== remoteSha && prSyncInfo.baseRefName && localParent && prSyncInfo.baseRefName !== localParent) {
1982
+ status = "needs-remote-sync";
1983
+ }
1984
+ if (status === "missing-remote") {
1985
+ outcome = {
1986
+ branch,
1987
+ status,
1988
+ action: "skipped",
1989
+ message: `\u26A0 Skipped '${branch}' (missing on remote).`
1990
+ };
1991
+ result.branches.push(outcome);
1992
+ printBranchOutcome(outcome);
1993
+ continue;
1994
+ }
1995
+ if (status === "missing-local") {
1996
+ await checkoutRemoteBranch(branch, cwd);
1997
+ outcome = {
1998
+ branch,
1999
+ status,
2000
+ action: "synced",
2001
+ message: `\u2714 Restored '${branch}' from remote.`
2002
+ };
2003
+ result.branches.push(outcome);
2004
+ printBranchOutcome(outcome);
2005
+ const restoredSha = await getRefSha(branch, cwd);
2006
+ await markBranchSynced(stateBranchMap, branch, restoredSha, cwd, {
2007
+ source: "sync",
2008
+ baseBranch: stateBranchMap.get(branch)?.parent ?? null
2009
+ });
2010
+ continue;
2011
+ }
2012
+ if (status === "up-to-date") {
2013
+ outcome = {
2014
+ branch,
2015
+ status,
2016
+ action: "none",
2017
+ message: `\u2022 '${branch}' is up to date.`
2018
+ };
2019
+ result.branches.push(outcome);
2020
+ printBranchOutcome(outcome);
2021
+ await markBranchSynced(
2022
+ stateBranchMap,
2023
+ branch,
2024
+ localSha ?? remoteSha ?? null,
2025
+ cwd,
2026
+ {
2027
+ source: "sync",
2028
+ baseBranch: stateBranchMap.get(branch)?.parent ?? null
2029
+ }
2030
+ );
2031
+ continue;
2032
+ }
2033
+ if (status === "updated-outside-dubstack-but-up-to-date") {
2034
+ outcome = {
2035
+ branch,
2036
+ status,
2037
+ action: "none",
2038
+ message: `\u2022 '${branch}' is up to date but was previously unmanaged by DubStack sync metadata.`
2039
+ };
2040
+ result.branches.push(outcome);
2041
+ printBranchOutcome(outcome);
2042
+ await markBranchSynced(
2043
+ stateBranchMap,
2044
+ branch,
2045
+ localSha ?? remoteSha ?? null,
2046
+ cwd,
2047
+ {
2048
+ source: "imported",
2049
+ baseBranch: stateBranchMap.get(branch)?.parent ?? null
2050
+ }
2051
+ );
2052
+ continue;
2053
+ }
2054
+ if (status === "needs-remote-sync-safe") {
2055
+ await hardResetBranchToRef(branch, remoteRef, cwd);
2056
+ outcome = {
2057
+ branch,
2058
+ status,
2059
+ action: "synced",
2060
+ message: `\u2714 Synced '${branch}' to remote head.`
2061
+ };
2062
+ result.branches.push(outcome);
2063
+ printBranchOutcome(outcome);
2064
+ await markBranchSynced(stateBranchMap, branch, remoteSha, cwd, {
2065
+ source: "sync",
2066
+ baseBranch: stateBranchMap.get(branch)?.parent ?? null
2067
+ });
2068
+ continue;
2069
+ }
2070
+ if (status === "local-ahead") {
2071
+ outcome = {
2072
+ branch,
2073
+ status,
2074
+ action: "kept-local",
2075
+ message: `\u2022 Kept local '${branch}' (local commits ahead of remote).`
2076
+ };
2077
+ result.branches.push(outcome);
2078
+ printBranchOutcome(outcome);
2079
+ continue;
2080
+ }
2081
+ if (status === "unsubmitted") {
2082
+ if (options.force) {
2083
+ await hardResetBranchToRef(branch, remoteRef, cwd);
2084
+ outcome = {
2085
+ branch,
2086
+ status,
2087
+ action: "synced",
2088
+ message: `\u2714 Synced unsubmitted branch '${branch}' to remote with --force.`
2089
+ };
2090
+ await markBranchSynced(stateBranchMap, branch, remoteSha, cwd, {
2091
+ source: "sync",
2092
+ baseBranch: localParent
2093
+ });
2094
+ } else if (!options.interactive) {
2095
+ outcome = {
2096
+ branch,
2097
+ status,
2098
+ action: "skipped",
2099
+ message: `\u26A0 Skipped unsubmitted branch '${branch}' (use --force or interactive mode).`
2100
+ };
2101
+ } else {
2102
+ const takeRemote = await confirm(
2103
+ `Branch '${branch}' has no DubStack submit baseline. Overwrite local with remote version?`
2104
+ );
2105
+ if (takeRemote) {
2106
+ await hardResetBranchToRef(branch, remoteRef, cwd);
2107
+ outcome = {
2108
+ branch,
2109
+ status,
2110
+ action: "synced",
2111
+ message: `\u2714 Synced unsubmitted branch '${branch}' to remote.`
2112
+ };
2113
+ await markBranchSynced(stateBranchMap, branch, remoteSha, cwd, {
2114
+ source: "sync",
2115
+ baseBranch: localParent
2116
+ });
2117
+ } else {
2118
+ outcome = {
2119
+ branch,
2120
+ status,
2121
+ action: "kept-local",
2122
+ message: `\u2022 Kept local unsubmitted branch '${branch}'.`
2123
+ };
2124
+ }
2125
+ }
2126
+ result.branches.push(outcome);
2127
+ printBranchOutcome(outcome);
2128
+ continue;
2129
+ }
2130
+ if (status === "needs-remote-sync") {
2131
+ if (options.force) {
2132
+ await hardResetBranchToRef(branch, remoteRef, cwd);
2133
+ if (prSyncInfo.baseRefName && localParent !== prSyncInfo.baseRefName) {
2134
+ const stateBranch = stateBranchMap.get(branch);
2135
+ if (stateBranch) stateBranch.parent = prSyncInfo.baseRefName;
2136
+ }
2137
+ outcome = {
2138
+ branch,
2139
+ status,
2140
+ action: "synced",
2141
+ message: `\u2714 Synced '${branch}' to remote and adopted remote parent '${prSyncInfo.baseRefName ?? "unknown"}'.`
2142
+ };
2143
+ await markBranchSynced(stateBranchMap, branch, remoteSha, cwd, {
2144
+ source: "sync",
2145
+ baseBranch: prSyncInfo.baseRefName ?? localParent
2146
+ });
2147
+ } else if (!options.interactive) {
2148
+ outcome = {
2149
+ branch,
2150
+ status,
2151
+ action: "skipped",
2152
+ message: `\u26A0 Skipped '${branch}' parent-mismatch sync (run interactively or with --force).`
2153
+ };
2154
+ } else {
2155
+ const parentDecision = await choose(
2156
+ `Branch '${branch}' parent differs locally ('${localParent}') vs remote ('${prSyncInfo.baseRefName}').`,
2157
+ [
2158
+ { label: "Take remote version and remote parent", value: "remote" },
2159
+ { label: "Keep local branch and parent", value: "local" },
2160
+ { label: "Skip for now", value: "skip" }
2161
+ ]
2162
+ );
2163
+ if (parentDecision === "remote") {
2164
+ await hardResetBranchToRef(branch, remoteRef, cwd);
2165
+ const stateBranch = stateBranchMap.get(branch);
2166
+ if (stateBranch && prSyncInfo.baseRefName) {
2167
+ stateBranch.parent = prSyncInfo.baseRefName;
2168
+ }
2169
+ outcome = {
2170
+ branch,
2171
+ status,
2172
+ action: "synced",
2173
+ message: `\u2714 Synced '${branch}' to remote and adopted remote parent.`
2174
+ };
2175
+ await markBranchSynced(stateBranchMap, branch, remoteSha, cwd, {
2176
+ source: "sync",
2177
+ baseBranch: prSyncInfo.baseRefName ?? localParent
2178
+ });
2179
+ } else if (parentDecision === "local") {
2180
+ outcome = {
2181
+ branch,
2182
+ status,
2183
+ action: "kept-local",
2184
+ message: `\u2022 Kept local parent and local state for '${branch}'.`
2185
+ };
2186
+ } else {
2187
+ outcome = {
2188
+ branch,
2189
+ status,
2190
+ action: "skipped",
2191
+ message: `\u26A0 Skipped '${branch}' parent-mismatch sync by user choice.`
2192
+ };
2193
+ }
2194
+ }
2195
+ result.branches.push(outcome);
2196
+ printBranchOutcome(outcome);
2197
+ continue;
2198
+ }
2199
+ const decision = await resolveReconcileDecision({
2200
+ branch,
2201
+ force: options.force,
2202
+ interactive: options.interactive,
2203
+ promptChoice: () => choose(
2204
+ `Branch '${branch}' diverged from remote. How should sync proceed?`,
2205
+ [
2206
+ {
2207
+ label: "Take remote version (discard local divergence)",
2208
+ value: "take-remote"
2209
+ },
2210
+ { label: "Keep local version", value: "keep-local" },
2211
+ {
2212
+ label: "Attempt reconciliation and keep local commits",
2213
+ value: "reconcile"
2214
+ },
2215
+ { label: "Skip this branch", value: "skip" }
2216
+ ]
2217
+ )
2218
+ });
2219
+ if (decision === "take-remote") {
2220
+ await hardResetBranchToRef(branch, remoteRef, cwd);
2221
+ outcome = {
2222
+ branch,
2223
+ status: "reconcile-needed",
2224
+ action: "synced",
2225
+ message: `\u2714 Synced '${branch}' to remote version.`
2226
+ };
2227
+ await markBranchSynced(stateBranchMap, branch, remoteSha, cwd, {
2228
+ source: "sync",
2229
+ baseBranch: stateBranchMap.get(branch)?.parent ?? null
2230
+ });
2231
+ } else if (decision === "keep-local") {
2232
+ outcome = {
2233
+ branch,
2234
+ status: "reconcile-needed",
2235
+ action: "kept-local",
2236
+ message: `\u2022 Kept local '${branch}' (remote divergence ignored).`
2237
+ };
2238
+ } else if (decision === "reconcile") {
2239
+ const reconciled = await rebaseBranchOntoRef(branch, remoteRef, cwd);
2240
+ outcome = {
2241
+ branch,
2242
+ status: "reconcile-needed",
2243
+ action: reconciled ? "synced" : "kept-local",
2244
+ message: reconciled ? `\u2714 Reconciled '${branch}' by rebasing local commits onto remote.` : `\u26A0 Could not auto-reconcile '${branch}'. Kept local state; reconcile manually.`
2245
+ };
2246
+ if (reconciled) {
2247
+ const newSha = await getRefSha(branch, cwd);
2248
+ await markBranchSynced(stateBranchMap, branch, newSha, cwd, {
2249
+ source: "sync",
2250
+ baseBranch: stateBranchMap.get(branch)?.parent ?? null
2251
+ });
2252
+ }
2253
+ } else {
2254
+ outcome = {
2255
+ branch,
2256
+ status: "reconcile-needed",
2257
+ action: "skipped",
2258
+ message: options.interactive ? `\u26A0 Skipped '${branch}' by user choice.` : `\u26A0 Skipped '${branch}' (diverged from remote; rerun with --force or interactive).`
2259
+ };
2260
+ }
2261
+ result.branches.push(outcome);
2262
+ printBranchOutcome(outcome);
2263
+ }
2264
+ if (options.restack) {
2265
+ console.log("\u{1F95E} Restacking branches...");
2266
+ const rootsToRestack = options.all ? roots : [roots[0]].filter(Boolean);
2267
+ for (const root of rootsToRestack) {
2268
+ await checkoutBranch(root, cwd);
2269
+ await restack(cwd);
2270
+ }
2271
+ result.restacked = true;
2272
+ }
2273
+ await writeState(state, cwd);
2274
+ await checkoutBranch(originalBranch, cwd);
2275
+ printSyncSummary(result);
2276
+ return result;
2277
+ }
2278
+ async function markBranchSynced(branchMap, branchName, headSha, cwd, options) {
2279
+ if (!headSha) return;
2280
+ const entry = branchMap.get(branchName);
2281
+ if (!entry) return;
2282
+ const priorBaseline = entry.last_submitted_version;
2283
+ const resolvedBaseBranch = options.baseBranch ?? priorBaseline?.base_branch ?? null;
2284
+ let resolvedBaseSha = priorBaseline?.base_sha ?? null;
2285
+ if (resolvedBaseBranch) {
2286
+ try {
2287
+ resolvedBaseSha = await getRefSha(resolvedBaseBranch, cwd);
2288
+ } catch {
2289
+ }
2290
+ }
2291
+ if (!resolvedBaseBranch || !resolvedBaseSha) return;
2292
+ entry.last_submitted_version = {
2293
+ head_sha: headSha,
2294
+ base_sha: resolvedBaseSha,
2295
+ base_branch: resolvedBaseBranch,
2296
+ version_number: priorBaseline?.version_number ?? null,
2297
+ source: options.source
2298
+ };
2299
+ entry.last_synced_at = (/* @__PURE__ */ new Date()).toISOString();
2300
+ entry.sync_source = options.source;
2301
+ }
2302
+ function getDescendants(stacks, branch) {
2303
+ const descendants = [];
2304
+ const childMap = /* @__PURE__ */ new Map();
2305
+ for (const stack of stacks) {
2306
+ for (const node of stack.branches) {
2307
+ if (!node.parent) continue;
2308
+ const children = childMap.get(node.parent) ?? [];
2309
+ children.push(node.name);
2310
+ childMap.set(node.parent, children);
2311
+ }
2312
+ }
2313
+ const queue = [...childMap.get(branch) ?? []];
2314
+ while (queue.length > 0) {
2315
+ const next = queue.shift();
2316
+ if (!next) break;
2317
+ descendants.push(next);
2318
+ queue.push(...childMap.get(next) ?? []);
2319
+ }
2320
+ return descendants;
2321
+ }
2322
+ function removeBranchFromState(stacks, branch) {
2323
+ for (const stack of stacks) {
2324
+ const deleted = stack.branches.find((b) => b.name === branch);
2325
+ if (!deleted) continue;
2326
+ const newParent = deleted.parent;
2327
+ for (const child of stack.branches) {
2328
+ if (child.parent === branch) {
2329
+ child.parent = newParent;
2330
+ }
2331
+ }
2332
+ stack.branches = stack.branches.filter((b) => b.name !== branch);
2333
+ }
2334
+ }
2335
+
1119
2336
  // src/commands/undo.ts
1120
2337
  init_errors();
2338
+ init_git();
2339
+ init_state();
2340
+ init_undo_log();
1121
2341
  async function undo(cwd) {
1122
2342
  const entry = await readUndoEntry(cwd);
1123
2343
  if (!await isWorkingTreeClean(cwd)) {
@@ -1183,7 +2403,10 @@ Examples:
1183
2403
  console.log(chalk2.yellow("\u26A0 DubStack already initialized"));
1184
2404
  }
1185
2405
  });
1186
- program.command("create").argument("<branch-name>", "Name of the new branch to create").description("Create a new branch stacked on top of the current branch").option("-m, --message <message>", "Commit staged changes with this message").option("-a, --all", "Stage all changes before committing (requires -m)").addHelpText(
2406
+ program.command("create").argument("<branch-name>", "Name of the new branch to create").description("Create a new branch stacked on top of the current branch").option("-m, --message <message>", "Commit staged changes with this message").option("-a, --all", "Stage all changes before committing (requires -m)").option(
2407
+ "-u, --update",
2408
+ "Stage tracked file updates before committing (requires -m)"
2409
+ ).option("-p, --patch", "Pick hunks to stage before committing (requires -m)").addHelpText(
1187
2410
  "after",
1188
2411
  `
1189
2412
  Examples:
@@ -1194,7 +2417,9 @@ Examples:
1194
2417
  async (branchName, options) => {
1195
2418
  const result = await create(branchName, process.cwd(), {
1196
2419
  message: options.message,
1197
- all: options.all
2420
+ all: options.all,
2421
+ update: options.update,
2422
+ patch: options.patch
1198
2423
  });
1199
2424
  if (result.committed) {
1200
2425
  console.log(
@@ -1211,16 +2436,78 @@ Examples:
1211
2436
  }
1212
2437
  }
1213
2438
  );
1214
- program.command("log").description("Display an ASCII tree of the current stack").addHelpText(
2439
+ program.command("log").alias("l").description("Display an ASCII tree of the current stack").addHelpText(
1215
2440
  "after",
1216
2441
  `
1217
2442
  Examples:
1218
2443
  $ dub log Show the branch tree with current branch highlighted`
1219
2444
  ).action(async () => {
1220
- const output = await log(process.cwd());
1221
- const styled = output.replace(/\*(.+?) \(Current\)\*/g, chalk2.bold.cyan("$1 (Current)")).replace(/⚠ \(missing\)/g, chalk2.yellow("\u26A0 (missing)"));
1222
- console.log(styled);
2445
+ await printLog(process.cwd());
2446
+ });
2447
+ program.command("ls").description("Display an ASCII tree of the current stack").action(async () => {
2448
+ await printLog(process.cwd());
2449
+ });
2450
+ program.command("up").argument("[steps]", "Number of levels to traverse upstack").option("-n, --steps <count>", "Number of levels to traverse upstack").description("Checkout the child branch directly above the current branch").action(async (stepsArg, options) => {
2451
+ const steps = parseSteps(stepsArg, options.steps);
2452
+ const result = await upBySteps(process.cwd(), steps);
2453
+ if (result.changed) {
2454
+ console.log(chalk2.green(`\u2714 Switched up to '${result.branch}'`));
2455
+ } else {
2456
+ console.log(chalk2.yellow(`\u26A0 Already at top branch '${result.branch}'`));
2457
+ }
2458
+ });
2459
+ program.command("down").argument("[steps]", "Number of levels to traverse downstack").option("-n, --steps <count>", "Number of levels to traverse downstack").description("Checkout the parent branch directly below the current branch").action(async (stepsArg, options) => {
2460
+ const steps = parseSteps(stepsArg, options.steps);
2461
+ const result = await downBySteps(process.cwd(), steps);
2462
+ if (result.changed) {
2463
+ console.log(chalk2.green(`\u2714 Switched down to '${result.branch}'`));
2464
+ } else {
2465
+ console.log(
2466
+ chalk2.yellow(`\u26A0 Already at bottom branch '${result.branch}'`)
2467
+ );
2468
+ }
2469
+ });
2470
+ program.command("top").description("Checkout the topmost branch in the current stack path").action(async () => {
2471
+ const result = await top(process.cwd());
2472
+ if (result.changed) {
2473
+ console.log(chalk2.green(`\u2714 Switched to top branch '${result.branch}'`));
2474
+ } else {
2475
+ console.log(chalk2.yellow(`\u26A0 Already at top branch '${result.branch}'`));
2476
+ }
2477
+ });
2478
+ program.command("bottom").description(
2479
+ "Checkout the first branch above the root in the current stack path"
2480
+ ).action(async () => {
2481
+ const result = await bottom(process.cwd());
2482
+ if (result.changed) {
2483
+ console.log(
2484
+ chalk2.green(`\u2714 Switched to bottom stack branch '${result.branch}'`)
2485
+ );
2486
+ } else {
2487
+ console.log(
2488
+ chalk2.yellow(`\u26A0 Already at bottom stack branch '${result.branch}'`)
2489
+ );
2490
+ }
1223
2491
  });
2492
+ program.command("branch").description("Show DubStack branch metadata").addCommand(
2493
+ new Command("info").description("Show tracked stack info for the current branch").argument("[branch]", "Branch to inspect (defaults to current branch)").action(async (branch) => {
2494
+ const info = await branchInfo(process.cwd(), branch);
2495
+ console.log(formatBranchInfo(info));
2496
+ })
2497
+ );
2498
+ program.command("info").argument("[branch]", "Branch to inspect (defaults to current branch)").description("Show tracked stack info for a branch").action(async (branch) => {
2499
+ const info = await branchInfo(process.cwd(), branch);
2500
+ console.log(formatBranchInfo(info));
2501
+ });
2502
+ program.command("sync").description("Sync tracked branches with remote and reconcile divergence").option(
2503
+ "--restack",
2504
+ "Restack branches after sync (disable with --no-restack)",
2505
+ true
2506
+ ).option("-f, --force", "Skip prompts for destructive sync decisions").option("-a, --all", "Sync all tracked stacks across trunks").option("--no-interactive", "Disable prompts and use deterministic behavior").action(
2507
+ async (options) => {
2508
+ await sync(process.cwd(), options);
2509
+ }
2510
+ );
1224
2511
  program.command("restack").description("Rebase all branches in the stack onto their updated parents").option("--continue", "Continue restacking after resolving conflicts").addHelpText(
1225
2512
  "after",
1226
2513
  `
@@ -1268,17 +2555,36 @@ Examples:
1268
2555
  $ dub submit --dry-run Preview what would happen`
1269
2556
  ).action(runSubmit);
1270
2557
  program.command("ss").description("Submit the current stack (alias for submit)").option("--dry-run", "Print what would happen without executing").action(runSubmit);
1271
- program.command("co").argument("[branch]", "Branch to checkout (interactive if omitted)").description("Checkout a branch (interactive picker if no name given)").action(async (branch) => {
1272
- if (branch) {
1273
- const result = await checkout(branch, process.cwd());
1274
- console.log(chalk2.green(`\u2714 Switched to '${result.branch}'`));
1275
- } else {
1276
- const result = await interactiveCheckout(process.cwd());
1277
- if (result) {
2558
+ program.command("checkout").alias("co").argument("[branch]", "Branch to checkout (interactive if omitted)").option("-t, --trunk", "Checkout the current trunk").option(
2559
+ "-u, --show-untracked",
2560
+ "Include untracked branches in interactive selection"
2561
+ ).option(
2562
+ "-s, --stack",
2563
+ "Only show ancestors and descendants of current branch in interactive selection"
2564
+ ).option(
2565
+ "-a, --all",
2566
+ "Show branches across all tracked stacks in interactive selection"
2567
+ ).description("Checkout a branch (interactive picker if no name given)").action(
2568
+ async (branch, options) => {
2569
+ if (branch) {
2570
+ const result = await checkout(branch, process.cwd());
1278
2571
  console.log(chalk2.green(`\u2714 Switched to '${result.branch}'`));
2572
+ } else if (options.trunk) {
2573
+ const trunk = await resolveCheckoutTrunk(process.cwd());
2574
+ const result = await checkout(trunk, process.cwd());
2575
+ console.log(chalk2.green(`\u2714 Switched to '${result.branch}'`));
2576
+ } else {
2577
+ const result = await interactiveCheckout(process.cwd(), {
2578
+ showUntracked: options.showUntracked,
2579
+ stack: options.stack,
2580
+ all: options.all
2581
+ });
2582
+ if (result) {
2583
+ console.log(chalk2.green(`\u2714 Switched to '${result.branch}'`));
2584
+ }
1279
2585
  }
1280
2586
  }
1281
- });
2587
+ );
1282
2588
  program.command("skills").description("Manage DubStack agent skills").addCommand(
1283
2589
  new Command("add").description("Install agent skills (e.g. dubstack, dub-flow)").argument("[skills...]", "Names of skills to install (default: all)").option("-g, --global", "Install skills globally").option("--dry-run", "Preview actions without installing").action(async (skills, options) => {
1284
2590
  const { addSkills: addSkills2 } = await Promise.resolve().then(() => (init_skills2(), skills_exports));
@@ -1290,6 +2596,32 @@ program.command("skills").description("Manage DubStack agent skills").addCommand
1290
2596
  await removeSkills2(skills, options);
1291
2597
  })
1292
2598
  );
2599
+ program.command("modify").alias("m").description(
2600
+ "Modify the current branch by amending commits or creating new ones"
2601
+ ).option("-a, --all", "Stage all changes before committing").option("-c, --commit", "Create a new commit instead of amending").option("-e, --edit", "Open editor to edit the commit message").option(
2602
+ "-m, --message <message>",
2603
+ "Message for the new or amended commit",
2604
+ (value, previous = []) => [...previous, value],
2605
+ []
2606
+ ).option("-p, --patch", "Pick hunks to stage before committing").option("-u, --update", "Stage all updates to tracked files").option(
2607
+ "-v, --verbose",
2608
+ "Show staged diff before modify (repeat for unstaged diff too)",
2609
+ (_value, previous = 0) => previous + 1,
2610
+ 0
2611
+ ).option(
2612
+ "--interactive-rebase",
2613
+ "Start an interactive rebase on the branch commits"
2614
+ ).action(async (options) => {
2615
+ const { modify: modify2 } = await Promise.resolve().then(() => (init_modify(), modify_exports));
2616
+ const normalizedOptions = {
2617
+ ...options,
2618
+ message: Array.isArray(options.message) && options.message.length === 1 ? options.message[0] : options.message
2619
+ };
2620
+ await modify2(process.cwd(), normalizedOptions);
2621
+ });
2622
+ program.command("pr").argument("[branch]", "Branch name or PR number to open").description("Open a branch PR in your browser").action(async (branch) => {
2623
+ await pr(process.cwd(), branch);
2624
+ });
1293
2625
  async function runSubmit(options) {
1294
2626
  const result = await submit(process.cwd(), options.dryRun ?? false);
1295
2627
  if (result.pushed.length > 0) {
@@ -1303,6 +2635,20 @@ async function runSubmit(options) {
1303
2635
  }
1304
2636
  }
1305
2637
  }
2638
+ async function printLog(cwd) {
2639
+ const output2 = await log(cwd);
2640
+ const styled = output2.replace(/\*(.+?) \(Current\)\*/g, chalk2.bold.cyan("$1 (Current)")).replace(/⚠ \(missing\)/g, chalk2.yellow("\u26A0 (missing)"));
2641
+ console.log(styled);
2642
+ }
2643
+ function parseSteps(positional, option) {
2644
+ const raw = option ?? positional;
2645
+ if (!raw) return 1;
2646
+ const parsed = Number.parseInt(raw, 10);
2647
+ if (!Number.isInteger(parsed) || parsed < 1) {
2648
+ throw new DubError("Steps must be a positive integer.");
2649
+ }
2650
+ return parsed;
2651
+ }
1306
2652
  async function main() {
1307
2653
  try {
1308
2654
  await program.parseAsync(process.argv);