dubstack 0.3.0 → 0.5.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.
package/dist/index.js CHANGED
@@ -1,20 +1,27 @@
1
1
  #!/usr/bin/env node
2
-
3
- // src/index.ts
4
- import { createRequire } from "module";
5
- import chalk from "chalk";
6
- import { Command } from "commander";
7
-
8
- // src/commands/checkout.ts
9
- import search from "@inquirer/search";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __esm = (fn, res) => function __init() {
5
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
6
+ };
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
10
11
 
11
12
  // src/lib/errors.ts
12
- var DubError = class extends Error {
13
- constructor(message) {
14
- super(message);
15
- this.name = "DubError";
13
+ var DubError;
14
+ var init_errors = __esm({
15
+ "src/lib/errors.ts"() {
16
+ "use strict";
17
+ DubError = class extends Error {
18
+ constructor(message) {
19
+ super(message);
20
+ this.name = "DubError";
21
+ }
22
+ };
16
23
  }
17
- };
24
+ });
18
25
 
19
26
  // src/lib/git.ts
20
27
  import { execa } from "execa";
@@ -204,6 +211,69 @@ async function commitStaged(message, cwd) {
204
211
  );
205
212
  }
206
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
+ }
207
277
  async function listBranches(cwd) {
208
278
  try {
209
279
  const { stdout } = await execa(
@@ -216,6 +286,111 @@ async function listBranches(cwd) {
216
286
  throw new DubError("Failed to list branches.");
217
287
  }
218
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
+ });
219
394
 
220
395
  // src/lib/state.ts
221
396
  import * as crypto from "crypto";
@@ -236,7 +411,7 @@ async function readState(cwd) {
236
411
  }
237
412
  try {
238
413
  const raw = fs.readFileSync(statePath, "utf-8");
239
- return JSON.parse(raw);
414
+ return normalizeState(JSON.parse(raw));
240
415
  } catch {
241
416
  throw new DubError(
242
417
  "State file is corrupted. Delete .git/dubstack and run 'dub init' to re-initialize."
@@ -280,6 +455,12 @@ function findStackForBranch(state, name) {
280
455
  (stack) => stack.branches.some((b) => b.name === name)
281
456
  );
282
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
+ }
283
464
  function addBranchToStack(state, child, parent) {
284
465
  if (findStackForBranch(state, child)) {
285
466
  throw new DubError(`Branch '${child}' is already tracked in a stack.`);
@@ -288,7 +469,10 @@ function addBranchToStack(state, child, parent) {
288
469
  name: child,
289
470
  parent,
290
471
  pr_number: null,
291
- pr_link: null
472
+ pr_link: null,
473
+ last_submitted_version: null,
474
+ last_synced_at: null,
475
+ sync_source: null
292
476
  };
293
477
  const existingStack = findStackForBranch(state, parent);
294
478
  if (existingStack) {
@@ -299,7 +483,10 @@ function addBranchToStack(state, child, parent) {
299
483
  type: "root",
300
484
  parent: null,
301
485
  pr_number: null,
302
- pr_link: null
486
+ pr_link: null,
487
+ last_submitted_version: null,
488
+ last_synced_at: null,
489
+ sync_source: null
303
490
  };
304
491
  state.stacks.push({
305
492
  id: crypto.randomUUID(),
@@ -307,6 +494,22 @@ function addBranchToStack(state, child, parent) {
307
494
  });
308
495
  }
309
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
+ }
310
513
  function topologicalOrder(stack) {
311
514
  const result = [];
312
515
  const root = stack.branches.find((b) => b.type === "root");
@@ -329,76 +532,13 @@ function topologicalOrder(stack) {
329
532
  }
330
533
  return result;
331
534
  }
332
-
333
- // src/commands/checkout.ts
334
- function getTrackedBranches(state) {
335
- const names = /* @__PURE__ */ new Set();
336
- for (const stack of state.stacks) {
337
- for (const branch of stack.branches) {
338
- names.add(branch.name);
339
- }
340
- }
341
- return [...names].sort();
342
- }
343
- function getValidBranches(tracked, local) {
344
- const localSet = new Set(local);
345
- return tracked.filter((b) => localSet.has(b));
346
- }
347
- async function checkout(name, cwd) {
348
- await checkoutBranch(name, cwd);
349
- return { branch: name };
350
- }
351
- async function interactiveCheckout(cwd) {
352
- const state = await readState(cwd);
353
- const trackedBranches = getTrackedBranches(state);
354
- const localBranches = await listBranches(cwd);
355
- const validBranches = getValidBranches(trackedBranches, localBranches);
356
- if (validBranches.length === 0) {
357
- throw new DubError(
358
- "No valid tracked branches found. Run 'dub create' first."
359
- );
360
- }
361
- let currentBranch = null;
362
- try {
363
- currentBranch = await getCurrentBranch(cwd);
364
- } catch {
365
- }
366
- const controller = new AbortController();
367
- const onKeypress = (_str, key) => {
368
- if (key && key.name === "escape") {
369
- controller.abort();
370
- }
371
- };
372
- process.stdin.on("keypress", onKeypress);
373
- try {
374
- const selected = await search(
375
- {
376
- message: "Checkout a branch (autocomplete or arrow keys)",
377
- source(term) {
378
- const filtered = term ? validBranches.filter(
379
- (b) => b.toLowerCase().includes(term.toLowerCase())
380
- ) : validBranches;
381
- return filtered.map((name) => ({
382
- name,
383
- value: name,
384
- disabled: name === currentBranch ? "(current)" : false
385
- }));
386
- }
387
- },
388
- { signal: controller.signal }
389
- );
390
- return checkout(selected, cwd);
391
- } catch (error) {
392
- if (error instanceof Error) {
393
- if (error.name === "ExitPromptError" || error.name === "AbortError" || error.name === "AbortPromptError") {
394
- return null;
395
- }
396
- }
397
- throw error;
398
- } finally {
399
- process.stdin.off("keypress", onKeypress);
535
+ var init_state = __esm({
536
+ "src/lib/state.ts"() {
537
+ "use strict";
538
+ init_errors();
539
+ init_git();
400
540
  }
401
- }
541
+ });
402
542
 
403
543
  // src/lib/undo-log.ts
404
544
  import * as fs2 from "fs";
@@ -426,55 +566,546 @@ async function clearUndoEntry(cwd) {
426
566
  fs2.unlinkSync(undoPath);
427
567
  }
428
568
  }
569
+ var init_undo_log = __esm({
570
+ "src/lib/undo-log.ts"() {
571
+ "use strict";
572
+ init_errors();
573
+ init_state();
574
+ }
575
+ });
429
576
 
430
- // src/commands/create.ts
431
- async function create(name, cwd, options) {
432
- if (options?.all && !options.message) {
433
- throw new DubError("'-a' requires '-m'. Pass a commit message.");
577
+ // src/commands/restack.ts
578
+ import * as fs4 from "fs";
579
+ import * as path4 from "path";
580
+ async function restack(cwd) {
581
+ const state = await readState(cwd);
582
+ if (!await isWorkingTreeClean(cwd)) {
583
+ throw new DubError(
584
+ "Working tree has uncommitted changes. Commit or stash them before restacking."
585
+ );
434
586
  }
435
- const state = await ensureState(cwd);
436
- const parent = await getCurrentBranch(cwd);
437
- if (await branchExists(name, cwd)) {
438
- throw new DubError(`Branch '${name}' already exists.`);
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
+ );
439
593
  }
440
- if (options?.message) {
441
- if (options.all) {
442
- await stageAll(cwd);
443
- }
444
- if (!await hasStagedChanges(cwd)) {
445
- const hint = options.all ? "No changes to commit." : "No staged changes. Stage files with 'git add' or use '-a' to stage all.";
446
- throw new DubError(hint);
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
+ );
447
601
  }
448
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
+ }
449
611
  await saveUndoEntry(
450
612
  {
451
- operation: "create",
613
+ operation: "restack",
452
614
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
453
- previousBranch: parent,
615
+ previousBranch: originalBranch,
454
616
  previousState: structuredClone(state),
455
- branchTips: {},
456
- createdBranches: [name]
617
+ branchTips,
618
+ createdBranches: []
457
619
  },
458
620
  cwd
459
621
  );
460
- await createBranch(name, cwd);
461
- addBranchToStack(state, name, parent);
462
- await writeState(state, cwd);
463
- if (options?.message) {
464
- try {
465
- await commitStaged(options.message, cwd);
466
- } catch (error) {
467
- const reason = error instanceof DubError ? error.message : String(error);
468
- throw new DubError(
469
- `Branch '${name}' was created but commit failed: ${reason}. Run 'dub undo' to clean up.`
470
- );
471
- }
472
- return { branch: name, parent, committed: options.message };
473
- }
474
- return { branch: name, parent };
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
+ );
1010
+ }
1011
+ const controller = new AbortController();
1012
+ const onKeypress = (_str, key) => {
1013
+ if (key && key.name === "escape") {
1014
+ controller.abort();
1015
+ }
1016
+ };
1017
+ process.stdin.on("keypress", onKeypress);
1018
+ try {
1019
+ const selected = await search(
1020
+ {
1021
+ message: "Checkout a branch (autocomplete or arrow keys)",
1022
+ source(term) {
1023
+ const filtered = term ? validBranches.filter(
1024
+ (b) => b.toLowerCase().includes(term.toLowerCase())
1025
+ ) : validBranches;
1026
+ return filtered.map((name) => ({
1027
+ name,
1028
+ value: name,
1029
+ disabled: name === currentBranch ? "(current)" : false
1030
+ }));
1031
+ }
1032
+ },
1033
+ { signal: controller.signal }
1034
+ );
1035
+ return checkout(selected, cwd);
1036
+ } catch (error) {
1037
+ if (error instanceof Error) {
1038
+ if (error.name === "ExitPromptError" || error.name === "AbortError" || error.name === "AbortPromptError") {
1039
+ return null;
1040
+ }
1041
+ }
1042
+ throw error;
1043
+ } finally {
1044
+ process.stdin.off("keypress", onKeypress);
1045
+ }
1046
+ }
1047
+
1048
+ // src/commands/create.ts
1049
+ init_errors();
1050
+ init_git();
1051
+ init_state();
1052
+ init_undo_log();
1053
+ async function create(name, cwd, options) {
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
+ );
1058
+ }
1059
+ const state = await ensureState(cwd);
1060
+ const parent = await getCurrentBranch(cwd);
1061
+ if (await branchExists(name, cwd)) {
1062
+ throw new DubError(`Branch '${name}' already exists.`);
1063
+ }
1064
+ if (options?.message) {
1065
+ if (options.patch) {
1066
+ await interactiveStage(cwd);
1067
+ } else if (options.all) {
1068
+ await stageAll(cwd);
1069
+ } else if (options.update) {
1070
+ await stageUpdate(cwd);
1071
+ }
1072
+ if (!await hasStagedChanges(cwd)) {
1073
+ const hint = options.all ? "No changes to commit." : "No staged changes. Stage files with 'git add' or use '-a' to stage all.";
1074
+ throw new DubError(hint);
1075
+ }
1076
+ }
1077
+ await saveUndoEntry(
1078
+ {
1079
+ operation: "create",
1080
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1081
+ previousBranch: parent,
1082
+ previousState: structuredClone(state),
1083
+ branchTips: {},
1084
+ createdBranches: [name]
1085
+ },
1086
+ cwd
1087
+ );
1088
+ await createBranch(name, cwd);
1089
+ addBranchToStack(state, name, parent);
1090
+ await writeState(state, cwd);
1091
+ if (options?.message) {
1092
+ try {
1093
+ await commitStaged(options.message, cwd);
1094
+ } catch (error) {
1095
+ const reason = error instanceof DubError ? error.message : String(error);
1096
+ throw new DubError(
1097
+ `Branch '${name}' was created but commit failed: ${reason}. Run 'dub undo' to clean up.`
1098
+ );
1099
+ }
1100
+ return { branch: name, parent, committed: options.message };
1101
+ }
1102
+ return { branch: name, parent };
475
1103
  }
476
1104
 
477
1105
  // src/commands/init.ts
1106
+ init_errors();
1107
+ init_git();
1108
+ init_state();
478
1109
  import * as fs3 from "fs";
479
1110
  import * as path3 from "path";
480
1111
  async function init(cwd) {
@@ -506,6 +1137,8 @@ async function init(cwd) {
506
1137
  }
507
1138
 
508
1139
  // src/commands/log.ts
1140
+ init_git();
1141
+ init_state();
509
1142
  async function log(cwd) {
510
1143
  const state = await readState(cwd);
511
1144
  if (state.stacks.length === 0) {
@@ -573,159 +1206,150 @@ async function renderNode(branch, currentBranch, childMap, prefix, isRoot, isLas
573
1206
  }
574
1207
  }
575
1208
 
576
- // src/commands/restack.ts
577
- import * as fs4 from "fs";
578
- import * as path4 from "path";
579
- async function restack(cwd) {
580
- const state = await readState(cwd);
581
- if (!await isWorkingTreeClean(cwd)) {
1209
+ // src/commands/navigate.ts
1210
+ init_errors();
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) {
582
1221
  throw new DubError(
583
- "Working tree has uncommitted changes. Commit or stash them before restacking."
1222
+ `Current branch '${stateBranch}' is not tracked by DubStack.`
584
1223
  );
585
1224
  }
586
- const originalBranch = await getCurrentBranch(cwd);
587
- const targetStacks = getTargetStacks(state.stacks, originalBranch);
588
- if (targetStacks.length === 0) {
589
- throw new DubError(
590
- `Branch '${originalBranch}' is not part of any stack. Run 'dub create' first.`
591
- );
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.");
592
1230
  }
593
- const allBranches = targetStacks.flatMap((s) => s.branches);
594
- for (const branch of allBranches) {
595
- 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) {
596
1244
  throw new DubError(
597
- `Branch '${branch.name}' is tracked in state but no longer exists in git.
598
- Remove it from the stack or recreate it before restacking.`
1245
+ `Branch '${target}' has multiple children; 'dub up' requires a linear stack path.`
599
1246
  );
600
1247
  }
1248
+ target = children[0];
601
1249
  }
602
- const branchTips = {};
603
- for (const branch of allBranches) {
604
- branchTips[branch.name] = await getBranchTip(branch.name, cwd);
605
- }
606
- const steps = await buildRestackSteps(targetStacks, cwd);
607
- if (steps.length === 0) {
608
- return { status: "up-to-date", rebased: [] };
609
- }
610
- await saveUndoEntry(
611
- {
612
- operation: "restack",
613
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
614
- previousBranch: originalBranch,
615
- previousState: structuredClone(state),
616
- branchTips,
617
- createdBranches: []
618
- },
619
- cwd
620
- );
621
- const progress = { originalBranch, steps };
622
- await writeProgress(progress, cwd);
623
- return executeRestackSteps(progress, cwd);
1250
+ await checkoutBranch(target, cwd);
1251
+ return { branch: target, changed: target !== current };
624
1252
  }
625
- async function restackContinue(cwd) {
626
- const progress = await readProgress(cwd);
627
- if (!progress) {
628
- throw new DubError("No restack in progress. Run 'dub restack' to start.");
629
- }
630
- await rebaseContinue(cwd);
631
- const conflictedStep = progress.steps.find((s) => s.status === "conflicted");
632
- if (conflictedStep) {
633
- conflictedStep.status = "done";
1253
+ async function downBySteps(cwd, steps) {
1254
+ if (!Number.isInteger(steps) || steps < 1) {
1255
+ throw new DubError("'steps' must be a positive integer.");
634
1256
  }
635
- return executeRestackSteps(progress, cwd);
636
- }
637
- async function executeRestackSteps(progress, cwd) {
638
- const rebased = [];
639
- for (const step of progress.steps) {
640
- if (step.status !== "pending") {
641
- if (step.status === "done") rebased.push(step.branch);
642
- continue;
643
- }
644
- const parentNewTip = await getBranchTip(step.parent, cwd);
645
- if (parentNewTip === step.parentOldTip) {
646
- step.status = "skipped";
647
- await writeProgress(progress, cwd);
648
- continue;
1257
+ const state = await readState(cwd);
1258
+ const current = await getCurrentBranch(cwd);
1259
+ const stack = getTrackedStackOrThrow(
1260
+ current,
1261
+ findStackForBranch(state, current)
1262
+ );
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
+ );
649
1270
  }
650
- try {
651
- await rebaseOnto(parentNewTip, step.parentOldTip, step.branch, cwd);
652
- step.status = "done";
653
- rebased.push(step.branch);
654
- await writeProgress(progress, cwd);
655
- } catch (error) {
656
- if (error instanceof DubError && error.message.includes("Conflict")) {
657
- step.status = "conflicted";
658
- await writeProgress(progress, cwd);
659
- return { status: "conflict", rebased, conflictBranch: step.branch };
660
- }
661
- throw error;
1271
+ if (!branch.parent) {
1272
+ throw new DubError(
1273
+ `Already at the bottom of the stack (root branch '${target}').`
1274
+ );
662
1275
  }
1276
+ target = branch.parent;
663
1277
  }
664
- await clearProgress(cwd);
665
- await checkoutBranch(progress.originalBranch, cwd);
666
- const allSkipped = progress.steps.every(
667
- (s) => s.status === "skipped" || s.status === "done"
668
- );
669
- return {
670
- status: rebased.length === 0 && allSkipped ? "up-to-date" : "success",
671
- rebased
672
- };
1278
+ await checkoutBranch(target, cwd);
1279
+ return { branch: target, changed: target !== current };
673
1280
  }
674
- function getTargetStacks(stacks, currentBranch) {
675
- const rootStacks = stacks.filter(
676
- (s) => s.branches.some((b) => b.name === currentBranch && b.type === "root")
677
- );
678
- if (rootStacks.length > 0) return rootStacks;
679
- const stack = stacks.find(
680
- (s) => s.branches.some((b) => b.name === currentBranch)
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)
681
1287
  );
682
- return stack ? [stack] : [];
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];
1298
+ }
1299
+ if (target !== current) {
1300
+ await checkoutBranch(target, cwd);
1301
+ }
1302
+ return { branch: target, changed: target !== current };
683
1303
  }
684
- async function buildRestackSteps(stacks, cwd) {
685
- const steps = [];
686
- for (const stack of stacks) {
687
- const ordered = topologicalOrder(stack);
688
- for (const branch of ordered) {
689
- if (branch.type === "root" || !branch.parent) continue;
690
- const mergeBase = await getMergeBase(branch.parent, branch.name, cwd);
691
- steps.push({
692
- branch: branch.name,
693
- parent: branch.parent,
694
- parentOldTip: mergeBase,
695
- status: "pending"
696
- });
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
+ );
1324
+ }
1325
+ if (children.length > 1) {
1326
+ throw new DubError(
1327
+ `Root branch '${current}' has multiple children; 'dub bottom' requires a linear stack path.`
1328
+ );
1329
+ }
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;
1337
+ }
1338
+ if (parent.parent === null) {
1339
+ target = node.name;
1340
+ break;
1341
+ }
1342
+ node = parent;
697
1343
  }
698
1344
  }
699
- return steps;
700
- }
701
- async function getProgressPath(cwd) {
702
- const dubDir = await getDubDir(cwd);
703
- return path4.join(dubDir, "restack-progress.json");
704
- }
705
- async function writeProgress(progress, cwd) {
706
- const progressPath = await getProgressPath(cwd);
707
- fs4.writeFileSync(progressPath, `${JSON.stringify(progress, null, 2)}
708
- `);
709
- }
710
- async function readProgress(cwd) {
711
- const progressPath = await getProgressPath(cwd);
712
- if (!fs4.existsSync(progressPath)) return null;
713
- const raw = fs4.readFileSync(progressPath, "utf-8");
714
- return JSON.parse(raw);
715
- }
716
- async function clearProgress(cwd) {
717
- const progressPath = await getProgressPath(cwd);
718
- if (fs4.existsSync(progressPath)) {
719
- fs4.unlinkSync(progressPath);
1345
+ if (target !== current) {
1346
+ await checkoutBranch(target, cwd);
720
1347
  }
1348
+ return { branch: target, changed: target !== current };
721
1349
  }
722
1350
 
723
- // src/commands/submit.ts
724
- import * as fs5 from "fs";
725
- import * as os from "os";
726
- import * as path5 from "path";
727
-
728
1351
  // src/lib/github.ts
1352
+ init_errors();
729
1353
  import { execa as execa2 } from "execa";
730
1354
  async function ensureGhInstalled() {
731
1355
  try {
@@ -766,6 +1390,58 @@ async function getPr(branch, cwd) {
766
1390
  throw new DubError(`Failed to parse PR info for branch '${branch}'.`);
767
1391
  }
768
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
+ }
769
1445
  async function createPr(branch, base, title, bodyFile, cwd) {
770
1446
  let stdout;
771
1447
  try {
@@ -824,6 +1500,39 @@ async function updatePrBody(prNumber, bodyFile, cwd) {
824
1500
  throw new DubError(`Failed to update PR #${prNumber}: ${message}`);
825
1501
  }
826
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";
827
1536
 
828
1537
  // src/lib/pr-body.ts
829
1538
  var DUBSTACK_START = "<!-- dubstack:start -->";
@@ -883,6 +1592,7 @@ function composePrBody(existingBody, stackTable, metadataBlock) {
883
1592
  }
884
1593
 
885
1594
  // src/commands/submit.ts
1595
+ init_state();
886
1596
  async function submit(cwd, dryRun) {
887
1597
  await ensureGhInstalled();
888
1598
  await checkGhAuth();
@@ -938,12 +1648,23 @@ async function submit(cwd, dryRun) {
938
1648
  if (!dryRun) {
939
1649
  await updateAllPrBodies(nonRootBranches, prMap, stack.id, cwd);
940
1650
  for (const branch of nonRootBranches) {
941
- const pr = prMap.get(branch.name);
942
- if (pr) {
1651
+ const pr2 = prMap.get(branch.name);
1652
+ if (pr2) {
943
1653
  const stateBranch = stack.branches.find((b) => b.name === branch.name);
944
1654
  if (stateBranch) {
945
- stateBranch.pr_number = pr.number;
946
- 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";
947
1668
  }
948
1669
  }
949
1670
  }
@@ -969,30 +1690,30 @@ function validateLinearStack(ordered) {
969
1690
  async function updateAllPrBodies(branches, prMap, stackId, cwd) {
970
1691
  const tableEntries = /* @__PURE__ */ new Map();
971
1692
  for (const branch of branches) {
972
- const pr = prMap.get(branch.name);
973
- if (pr) {
974
- 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 });
975
1696
  }
976
1697
  }
977
1698
  for (let i = 0; i < branches.length; i++) {
978
1699
  const branch = branches[i];
979
- const pr = prMap.get(branch.name);
980
- if (!pr) continue;
1700
+ const pr2 = prMap.get(branch.name);
1701
+ if (!pr2) continue;
981
1702
  const prevPr = i > 0 ? prMap.get(branches[i - 1].name)?.number ?? null : null;
982
1703
  const nextPr = i < branches.length - 1 ? prMap.get(branches[i + 1].name)?.number ?? null : null;
983
1704
  const stackTable = buildStackTable(branches, tableEntries, branch.name);
984
1705
  const metadataBlock = buildMetadataBlock(
985
1706
  stackId,
986
- pr.number,
1707
+ pr2.number,
987
1708
  prevPr,
988
1709
  nextPr,
989
1710
  branch.name
990
1711
  );
991
- const existingBody = pr.body;
1712
+ const existingBody = pr2.body;
992
1713
  const finalBody = composePrBody(existingBody, stackTable, metadataBlock);
993
1714
  const tmpFile = writeTempBody(finalBody);
994
1715
  try {
995
- await updatePrBody(pr.number, tmpFile, cwd);
1716
+ await updatePrBody(pr2.number, tmpFile, cwd);
996
1717
  } finally {
997
1718
  cleanupTempFile(tmpFile);
998
1719
  }
@@ -1011,7 +1732,612 @@ function cleanupTempFile(filePath) {
1011
1732
  }
1012
1733
  }
1013
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
+
1014
2336
  // src/commands/undo.ts
2337
+ init_errors();
2338
+ init_git();
2339
+ init_state();
2340
+ init_undo_log();
1015
2341
  async function undo(cwd) {
1016
2342
  const entry = await readUndoEntry(cwd);
1017
2343
  if (!await isWorkingTreeClean(cwd)) {
@@ -1059,6 +2385,7 @@ async function undo(cwd) {
1059
2385
  }
1060
2386
 
1061
2387
  // src/index.ts
2388
+ init_errors();
1062
2389
  var require2 = createRequire(import.meta.url);
1063
2390
  var { version } = require2("../package.json");
1064
2391
  var program = new Command();
@@ -1071,12 +2398,15 @@ Examples:
1071
2398
  ).action(async () => {
1072
2399
  const result = await init(process.cwd());
1073
2400
  if (result.status === "created") {
1074
- console.log(chalk.green("\u2714 DubStack initialized"));
2401
+ console.log(chalk2.green("\u2714 DubStack initialized"));
1075
2402
  } else {
1076
- console.log(chalk.yellow("\u26A0 DubStack already initialized"));
2403
+ console.log(chalk2.yellow("\u26A0 DubStack already initialized"));
1077
2404
  }
1078
2405
  });
1079
- 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(
1080
2410
  "after",
1081
2411
  `
1082
2412
  Examples:
@@ -1087,33 +2417,97 @@ Examples:
1087
2417
  async (branchName, options) => {
1088
2418
  const result = await create(branchName, process.cwd(), {
1089
2419
  message: options.message,
1090
- all: options.all
2420
+ all: options.all,
2421
+ update: options.update,
2422
+ patch: options.patch
1091
2423
  });
1092
2424
  if (result.committed) {
1093
2425
  console.log(
1094
- chalk.green(
2426
+ chalk2.green(
1095
2427
  `\u2714 Created '${result.branch}' on '${result.parent}' \u2022 ${result.committed}`
1096
2428
  )
1097
2429
  );
1098
2430
  } else {
1099
2431
  console.log(
1100
- chalk.green(
2432
+ chalk2.green(
1101
2433
  `\u2714 Created branch '${result.branch}' on top of '${result.parent}'`
1102
2434
  )
1103
2435
  );
1104
2436
  }
1105
2437
  }
1106
2438
  );
1107
- 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(
1108
2440
  "after",
1109
2441
  `
1110
2442
  Examples:
1111
2443
  $ dub log Show the branch tree with current branch highlighted`
1112
2444
  ).action(async () => {
1113
- const output = await log(process.cwd());
1114
- const styled = output.replace(/\*(.+?) \(Current\)\*/g, chalk.bold.cyan("$1 (Current)")).replace(/⚠ \(missing\)/g, chalk.yellow("\u26A0 (missing)"));
1115
- 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
+ }
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));
1116
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
+ );
1117
2511
  program.command("restack").description("Rebase all branches in the stack onto their updated parents").option("--continue", "Continue restacking after resolving conflicts").addHelpText(
1118
2512
  "after",
1119
2513
  `
@@ -1123,22 +2517,22 @@ Examples:
1123
2517
  ).action(async (options) => {
1124
2518
  const result = options.continue ? await restackContinue(process.cwd()) : await restack(process.cwd());
1125
2519
  if (result.status === "up-to-date") {
1126
- console.log(chalk.green("\u2714 Stack is already up to date"));
2520
+ console.log(chalk2.green("\u2714 Stack is already up to date"));
1127
2521
  } else if (result.status === "conflict") {
1128
2522
  console.log(
1129
- chalk.yellow(`\u26A0 Conflict while restacking '${result.conflictBranch}'`)
2523
+ chalk2.yellow(`\u26A0 Conflict while restacking '${result.conflictBranch}'`)
1130
2524
  );
1131
2525
  console.log(
1132
- chalk.dim(
2526
+ chalk2.dim(
1133
2527
  " Resolve conflicts, stage changes, then run: dub restack --continue"
1134
2528
  )
1135
2529
  );
1136
2530
  } else {
1137
2531
  console.log(
1138
- chalk.green(`\u2714 Restacked ${result.rebased.length} branch(es)`)
2532
+ chalk2.green(`\u2714 Restacked ${result.rebased.length} branch(es)`)
1139
2533
  );
1140
2534
  for (const branch of result.rebased) {
1141
- console.log(chalk.dim(` \u21B3 ${branch}`));
2535
+ console.log(chalk2.dim(` \u21B3 ${branch}`));
1142
2536
  }
1143
2537
  }
1144
2538
  });
@@ -1149,7 +2543,7 @@ Examples:
1149
2543
  $ dub undo Roll back the last dub operation`
1150
2544
  ).action(async () => {
1151
2545
  const result = await undo(process.cwd());
1152
- console.log(chalk.green(`\u2714 Undid '${result.undone}': ${result.details}`));
2546
+ console.log(chalk2.green(`\u2714 Undid '${result.undone}': ${result.details}`));
1153
2547
  });
1154
2548
  program.command("submit").description(
1155
2549
  "Push branches and create/update GitHub PRs for the current stack"
@@ -1161,36 +2555,106 @@ Examples:
1161
2555
  $ dub submit --dry-run Preview what would happen`
1162
2556
  ).action(runSubmit);
1163
2557
  program.command("ss").description("Submit the current stack (alias for submit)").option("--dry-run", "Print what would happen without executing").action(runSubmit);
1164
- program.command("co").argument("[branch]", "Branch to checkout (interactive if omitted)").description("Checkout a branch (interactive picker if no name given)").action(async (branch) => {
1165
- if (branch) {
1166
- const result = await checkout(branch, process.cwd());
1167
- console.log(chalk.green(`\u2714 Switched to '${result.branch}'`));
1168
- } else {
1169
- const result = await interactiveCheckout(process.cwd());
1170
- if (result) {
1171
- console.log(chalk.green(`\u2714 Switched to '${result.branch}'`));
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());
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
+ }
1172
2585
  }
1173
2586
  }
2587
+ );
2588
+ program.command("skills").description("Manage DubStack agent skills").addCommand(
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) => {
2590
+ const { addSkills: addSkills2 } = await Promise.resolve().then(() => (init_skills2(), skills_exports));
2591
+ await addSkills2(skills, options);
2592
+ })
2593
+ ).addCommand(
2594
+ new Command("remove").description("Remove agent skills").argument("[skills...]", "Names of skills to remove (default: all)").option("-g, --global", "Remove skills globally").option("--dry-run", "Preview actions without removing").action(async (skills, options) => {
2595
+ const { removeSkills: removeSkills2 } = await Promise.resolve().then(() => (init_skills2(), skills_exports));
2596
+ await removeSkills2(skills, options);
2597
+ })
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);
1174
2624
  });
1175
2625
  async function runSubmit(options) {
1176
2626
  const result = await submit(process.cwd(), options.dryRun ?? false);
1177
2627
  if (result.pushed.length > 0) {
1178
2628
  console.log(
1179
- chalk.green(
2629
+ chalk2.green(
1180
2630
  `\u2714 Pushed ${result.pushed.length} branch(es), created ${result.created.length} PR(s), updated ${result.updated.length} PR(s)`
1181
2631
  )
1182
2632
  );
1183
2633
  for (const branch of [...result.created, ...result.updated]) {
1184
- console.log(chalk.dim(` \u21B3 ${branch}`));
2634
+ console.log(chalk2.dim(` \u21B3 ${branch}`));
1185
2635
  }
1186
2636
  }
1187
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
+ }
1188
2652
  async function main() {
1189
2653
  try {
1190
2654
  await program.parseAsync(process.argv);
1191
2655
  } catch (error) {
1192
2656
  if (error instanceof DubError) {
1193
- console.error(chalk.red(`\u2716 ${error.message}`));
2657
+ console.error(chalk2.red(`\u2716 ${error.message}`));
1194
2658
  process.exit(1);
1195
2659
  }
1196
2660
  throw error;