dubstack 0.5.0 → 0.6.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
@@ -100,6 +100,20 @@ async function deleteBranch(name, cwd) {
100
100
  throw new DubError(`Failed to delete branch '${name}'. It may not exist.`);
101
101
  }
102
102
  }
103
+ async function deleteLocalBranch(name, cwd, force = false) {
104
+ try {
105
+ await execa("git", ["branch", force ? "-D" : "-d", name], { cwd });
106
+ } catch {
107
+ if (force) {
108
+ throw new DubError(
109
+ `Failed to delete branch '${name}'. It may not exist or be checked out.`
110
+ );
111
+ }
112
+ throw new DubError(
113
+ `Branch '${name}' is not fully merged. Re-run with --force to delete it.`
114
+ );
115
+ }
116
+ }
103
117
  async function forceBranchTo(name, sha, cwd) {
104
118
  try {
105
119
  const current = await getCurrentBranch(cwd).catch(() => null);
@@ -139,6 +153,13 @@ async function rebaseContinue(cwd) {
139
153
  );
140
154
  }
141
155
  }
156
+ async function rebaseAbort(cwd) {
157
+ try {
158
+ await execa("git", ["rebase", "--abort"], { cwd });
159
+ } catch {
160
+ throw new DubError("Failed to abort rebase.");
161
+ }
162
+ }
142
163
  async function getMergeBase(a, b, cwd) {
143
164
  try {
144
165
  const { stdout } = await execa("git", ["merge-base", a, b], { cwd });
@@ -294,9 +315,9 @@ async function fetchBranches(branches, cwd, remote = "origin") {
294
315
  } catch (error) {
295
316
  const stderr = typeof error?.stderr === "string" ? error.stderr : "";
296
317
  const stdout = typeof error?.stdout === "string" ? error.stdout : "";
297
- const output2 = `${stderr}
318
+ const output5 = `${stderr}
298
319
  ${stdout}`;
299
- if (output2.includes("couldn't find remote ref")) {
320
+ if (output5.includes("couldn't find remote ref")) {
300
321
  continue;
301
322
  }
302
323
  throw new DubError(`Failed to fetch branches from '${remote}'.`);
@@ -461,25 +482,25 @@ function getParent(state, branchName) {
461
482
  const branch = stack.branches.find((b) => b.name === branchName);
462
483
  return branch?.parent ?? void 0;
463
484
  }
464
- function addBranchToStack(state, child, parent) {
485
+ function addBranchToStack(state, child, parent2) {
465
486
  if (findStackForBranch(state, child)) {
466
487
  throw new DubError(`Branch '${child}' is already tracked in a stack.`);
467
488
  }
468
489
  const childBranch = {
469
490
  name: child,
470
- parent,
491
+ parent: parent2,
471
492
  pr_number: null,
472
493
  pr_link: null,
473
494
  last_submitted_version: null,
474
495
  last_synced_at: null,
475
496
  sync_source: null
476
497
  };
477
- const existingStack = findStackForBranch(state, parent);
498
+ const existingStack = findStackForBranch(state, parent2);
478
499
  if (existingStack) {
479
500
  existingStack.branches.push(childBranch);
480
501
  } else {
481
502
  const rootBranch = {
482
- name: parent,
503
+ name: parent2,
483
504
  type: "root",
484
505
  parent: null,
485
506
  pr_number: null,
@@ -517,9 +538,9 @@ function topologicalOrder(stack) {
517
538
  const childMap = /* @__PURE__ */ new Map();
518
539
  for (const branch of stack.branches) {
519
540
  if (branch.parent) {
520
- const children = childMap.get(branch.parent) ?? [];
521
- children.push(branch);
522
- childMap.set(branch.parent, children);
541
+ const children2 = childMap.get(branch.parent) ?? [];
542
+ children2.push(branch);
543
+ childMap.set(branch.parent, children2);
523
544
  }
524
545
  }
525
546
  const queue = [root];
@@ -527,8 +548,8 @@ function topologicalOrder(stack) {
527
548
  const current = queue.shift();
528
549
  if (!current) break;
529
550
  result.push(current);
530
- const children = childMap.get(current.name) ?? [];
531
- queue.push(...children);
551
+ const children2 = childMap.get(current.name) ?? [];
552
+ queue.push(...children2);
532
553
  }
533
554
  return result;
534
555
  }
@@ -541,29 +562,29 @@ var init_state = __esm({
541
562
  });
542
563
 
543
564
  // src/lib/undo-log.ts
544
- import * as fs2 from "fs";
545
- import * as path2 from "path";
565
+ import * as fs3 from "fs";
566
+ import * as path3 from "path";
546
567
  async function getUndoPath(cwd) {
547
568
  const dubDir = await getDubDir(cwd);
548
- return path2.join(dubDir, "undo.json");
569
+ return path3.join(dubDir, "undo.json");
549
570
  }
550
571
  async function saveUndoEntry(entry, cwd) {
551
572
  const undoPath = await getUndoPath(cwd);
552
- fs2.writeFileSync(undoPath, `${JSON.stringify(entry, null, 2)}
573
+ fs3.writeFileSync(undoPath, `${JSON.stringify(entry, null, 2)}
553
574
  `);
554
575
  }
555
576
  async function readUndoEntry(cwd) {
556
577
  const undoPath = await getUndoPath(cwd);
557
- if (!fs2.existsSync(undoPath)) {
578
+ if (!fs3.existsSync(undoPath)) {
558
579
  throw new DubError("Nothing to undo.");
559
580
  }
560
- const raw = fs2.readFileSync(undoPath, "utf-8");
581
+ const raw = fs3.readFileSync(undoPath, "utf-8");
561
582
  return JSON.parse(raw);
562
583
  }
563
584
  async function clearUndoEntry(cwd) {
564
585
  const undoPath = await getUndoPath(cwd);
565
- if (fs2.existsSync(undoPath)) {
566
- fs2.unlinkSync(undoPath);
586
+ if (fs3.existsSync(undoPath)) {
587
+ fs3.unlinkSync(undoPath);
567
588
  }
568
589
  }
569
590
  var init_undo_log = __esm({
@@ -818,14 +839,14 @@ async function modify(cwd, options) {
818
839
  const currentBranch = await getCurrentBranch(cwd);
819
840
  const state = await readState(cwd);
820
841
  if (options.interactiveRebase) {
821
- const parent = getParent(state, currentBranch);
822
- if (!parent) {
842
+ const parent2 = getParent(state, currentBranch);
843
+ if (!parent2) {
823
844
  throw new DubError(
824
845
  `Could not determine parent branch for '${currentBranch}'. Cannot start interactive rebase.`
825
846
  );
826
847
  }
827
- const parentTip = await getBranchTip(parent, cwd);
828
- console.log(`Starting interactive rebase on top of '${parent}'...`);
848
+ const parentTip = await getBranchTip(parent2, cwd);
849
+ console.log(`Starting interactive rebase on top of '${parent2}'...`);
829
850
  await interactiveRebase(parentTip, cwd);
830
851
  await restackChildren(cwd);
831
852
  return;
@@ -898,6 +919,55 @@ import { createRequire } from "module";
898
919
  import chalk2 from "chalk";
899
920
  import { Command } from "commander";
900
921
 
922
+ // src/commands/abort.ts
923
+ init_errors();
924
+ init_git();
925
+
926
+ // src/lib/operation-state.ts
927
+ init_git();
928
+ init_state();
929
+ import * as fs2 from "fs";
930
+ import * as path2 from "path";
931
+ async function getRestackProgressPath(cwd) {
932
+ const dubDir = await getDubDir(cwd);
933
+ return path2.join(dubDir, "restack-progress.json");
934
+ }
935
+ async function hasRestackProgress(cwd) {
936
+ const progressPath = await getRestackProgressPath(cwd);
937
+ return fs2.existsSync(progressPath);
938
+ }
939
+ async function hasGitRebaseInProgress(cwd) {
940
+ const root = await getRepoRoot(cwd);
941
+ const rebaseMerge = path2.join(root, ".git", "rebase-merge");
942
+ const rebaseApply = path2.join(root, ".git", "rebase-apply");
943
+ return fs2.existsSync(rebaseMerge) || fs2.existsSync(rebaseApply);
944
+ }
945
+ async function detectActiveOperation(cwd) {
946
+ if (await hasRestackProgress(cwd)) return "restack";
947
+ if (await hasGitRebaseInProgress(cwd)) return "rebase";
948
+ return "none";
949
+ }
950
+ async function clearRestackProgress(cwd) {
951
+ const progressPath = await getRestackProgressPath(cwd);
952
+ if (!fs2.existsSync(progressPath)) return;
953
+ fs2.unlinkSync(progressPath);
954
+ }
955
+
956
+ // src/commands/abort.ts
957
+ async function abortCommand(cwd) {
958
+ const active = await detectActiveOperation(cwd);
959
+ if (active === "none") {
960
+ throw new DubError("No operation in progress. Nothing to abort.");
961
+ }
962
+ if (await hasGitRebaseInProgress(cwd)) {
963
+ await rebaseAbort(cwd);
964
+ }
965
+ if (active === "restack") {
966
+ await clearRestackProgress(cwd);
967
+ }
968
+ return { aborted: active };
969
+ }
970
+
901
971
  // src/commands/branch.ts
902
972
  init_git();
903
973
  init_state();
@@ -1045,6 +1115,42 @@ async function interactiveCheckout(cwd, options = {}) {
1045
1115
  }
1046
1116
  }
1047
1117
 
1118
+ // src/commands/children.ts
1119
+ init_errors();
1120
+ init_git();
1121
+ init_state();
1122
+ async function children(cwd, branchArg) {
1123
+ const branch = branchArg ?? await getCurrentBranch(cwd);
1124
+ const state = await readState(cwd);
1125
+ const stack = findStackForBranch(state, branch);
1126
+ if (!stack) {
1127
+ throw new DubError(
1128
+ `Branch '${branch}' is not tracked. Run 'dub track ${branch} --parent <branch>' first.`
1129
+ );
1130
+ }
1131
+ const childBranches = stack.branches.filter((entry) => entry.parent === branch).map((entry) => entry.name).sort();
1132
+ return { branch, children: childBranches };
1133
+ }
1134
+
1135
+ // src/commands/continue.ts
1136
+ init_errors();
1137
+ init_git();
1138
+ init_restack();
1139
+ async function continueCommand(cwd) {
1140
+ const active = await detectActiveOperation(cwd);
1141
+ if (active === "none") {
1142
+ throw new DubError(
1143
+ "No operation in progress. Start a restack or resolve a rebase first."
1144
+ );
1145
+ }
1146
+ if (active === "restack") {
1147
+ const restackResult = await restackContinue(cwd);
1148
+ return { continued: "restack", restackResult };
1149
+ }
1150
+ await rebaseContinue(cwd);
1151
+ return { continued: "rebase" };
1152
+ }
1153
+
1048
1154
  // src/commands/create.ts
1049
1155
  init_errors();
1050
1156
  init_git();
@@ -1057,7 +1163,7 @@ async function create(name, cwd, options) {
1057
1163
  );
1058
1164
  }
1059
1165
  const state = await ensureState(cwd);
1060
- const parent = await getCurrentBranch(cwd);
1166
+ const parent2 = await getCurrentBranch(cwd);
1061
1167
  if (await branchExists(name, cwd)) {
1062
1168
  throw new DubError(`Branch '${name}' already exists.`);
1063
1169
  }
@@ -1078,7 +1184,7 @@ async function create(name, cwd, options) {
1078
1184
  {
1079
1185
  operation: "create",
1080
1186
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1081
- previousBranch: parent,
1187
+ previousBranch: parent2,
1082
1188
  previousState: structuredClone(state),
1083
1189
  branchTips: {},
1084
1190
  createdBranches: [name]
@@ -1086,7 +1192,7 @@ async function create(name, cwd, options) {
1086
1192
  cwd
1087
1193
  );
1088
1194
  await createBranch(name, cwd);
1089
- addBranchToStack(state, name, parent);
1195
+ addBranchToStack(state, name, parent2);
1090
1196
  await writeState(state, cwd);
1091
1197
  if (options?.message) {
1092
1198
  try {
@@ -1097,17 +1203,267 @@ async function create(name, cwd, options) {
1097
1203
  `Branch '${name}' was created but commit failed: ${reason}. Run 'dub undo' to clean up.`
1098
1204
  );
1099
1205
  }
1100
- return { branch: name, parent, committed: options.message };
1206
+ return { branch: name, parent: parent2, committed: options.message };
1207
+ }
1208
+ return { branch: name, parent: parent2 };
1209
+ }
1210
+
1211
+ // src/commands/delete.ts
1212
+ import { stdin as input, stdout as output } from "process";
1213
+ import * as readline from "readline/promises";
1214
+
1215
+ // src/lib/delete.ts
1216
+ init_errors();
1217
+ init_git();
1218
+
1219
+ // src/lib/graph.ts
1220
+ init_errors();
1221
+ function buildChildMap(stack) {
1222
+ const childMap = /* @__PURE__ */ new Map();
1223
+ for (const branch of stack.branches) {
1224
+ if (!branch.parent) continue;
1225
+ const children2 = childMap.get(branch.parent) ?? [];
1226
+ children2.push(branch);
1227
+ childMap.set(branch.parent, children2);
1228
+ }
1229
+ return childMap;
1230
+ }
1231
+ function buildBranchMap(stack) {
1232
+ return new Map(stack.branches.map((branch) => [branch.name, branch]));
1233
+ }
1234
+ function getDescendants(stack, branchName) {
1235
+ const childMap = buildChildMap(stack);
1236
+ const descendants = [];
1237
+ const queue = [...childMap.get(branchName) ?? []];
1238
+ while (queue.length > 0) {
1239
+ const next = queue.shift();
1240
+ if (!next) break;
1241
+ descendants.push(next.name);
1242
+ queue.push(...childMap.get(next.name) ?? []);
1243
+ }
1244
+ return descendants;
1245
+ }
1246
+ function getAncestors(stack, branchName) {
1247
+ const branchMap = buildBranchMap(stack);
1248
+ const ancestors = [];
1249
+ const seen = /* @__PURE__ */ new Set();
1250
+ let current = branchMap.get(branchName);
1251
+ while (current?.parent) {
1252
+ if (seen.has(current.parent)) break;
1253
+ ancestors.push(current.parent);
1254
+ seen.add(current.parent);
1255
+ current = branchMap.get(current.parent);
1256
+ }
1257
+ return ancestors;
1258
+ }
1259
+ function assertAcyclic(stack) {
1260
+ const branchMap = buildBranchMap(stack);
1261
+ const visiting = /* @__PURE__ */ new Set();
1262
+ const visited = /* @__PURE__ */ new Set();
1263
+ function visit(name) {
1264
+ if (visited.has(name)) return;
1265
+ if (visiting.has(name)) {
1266
+ throw new DubError(
1267
+ `Invalid stack '${stack.id}': cycle detected at '${name}'.`
1268
+ );
1269
+ }
1270
+ visiting.add(name);
1271
+ const branch = branchMap.get(name);
1272
+ if (branch?.parent && branchMap.has(branch.parent)) {
1273
+ visit(branch.parent);
1274
+ }
1275
+ visiting.delete(name);
1276
+ visited.add(name);
1277
+ }
1278
+ for (const branch of stack.branches) {
1279
+ visit(branch.name);
1280
+ }
1281
+ }
1282
+
1283
+ // src/lib/invariants.ts
1284
+ init_errors();
1285
+ function assertStateInvariants(stacks) {
1286
+ for (const stack of stacks) {
1287
+ assertAcyclic(stack);
1288
+ const branchMap = new Map(
1289
+ stack.branches.map((branch) => [branch.name, branch])
1290
+ );
1291
+ for (const branch of stack.branches) {
1292
+ if (branch.type === "root") {
1293
+ if (branch.parent !== null) {
1294
+ throw new DubError(
1295
+ `Invalid stack '${stack.id}': root '${branch.name}' must have no parent.`
1296
+ );
1297
+ }
1298
+ continue;
1299
+ }
1300
+ if (!branch.parent || !branchMap.has(branch.parent)) {
1301
+ throw new DubError(
1302
+ `Invalid stack '${stack.id}': branch '${branch.name}' has missing parent '${branch.parent ?? "null"}'.`
1303
+ );
1304
+ }
1305
+ }
1101
1306
  }
1102
- return { branch: name, parent };
1307
+ }
1308
+
1309
+ // src/lib/delete.ts
1310
+ init_state();
1311
+ async function getDeletePreview(cwd, options) {
1312
+ const state = await readState(cwd);
1313
+ const stack = findStackForBranch(state, options.branch);
1314
+ if (!stack) {
1315
+ throw new DubError(
1316
+ `Branch '${options.branch}' is not tracked. Run 'dub track ${options.branch} --parent <branch>' first.`
1317
+ );
1318
+ }
1319
+ const targets = collectTargets(stack, options);
1320
+ return { branch: options.branch, targets };
1321
+ }
1322
+ async function deleteTrackedBranch(cwd, options) {
1323
+ const state = await readState(cwd);
1324
+ const stack = findStackForBranch(state, options.branch);
1325
+ if (!stack) {
1326
+ throw new DubError(
1327
+ `Branch '${options.branch}' is not tracked by DubStack.`
1328
+ );
1329
+ }
1330
+ const targets = collectTargets(stack, options);
1331
+ const deleteSet = new Set(targets);
1332
+ const currentBranch = await getCurrentBranch(cwd);
1333
+ if (deleteSet.has(currentBranch)) {
1334
+ const fallback = resolveFallbackBranch(stack, options.branch, deleteSet);
1335
+ await checkoutBranch(fallback, cwd);
1336
+ }
1337
+ for (const branch of targets) {
1338
+ await deleteLocalBranch(branch, cwd, options.force ?? false);
1339
+ }
1340
+ const deletedParent = /* @__PURE__ */ new Map();
1341
+ for (const branch of stack.branches) {
1342
+ if (deleteSet.has(branch.name)) {
1343
+ deletedParent.set(branch.name, branch.parent);
1344
+ }
1345
+ }
1346
+ stack.branches = stack.branches.filter(
1347
+ (branch) => !deleteSet.has(branch.name)
1348
+ );
1349
+ const reparented = [];
1350
+ for (const branch of stack.branches) {
1351
+ let parent2 = branch.parent;
1352
+ while (parent2 && deleteSet.has(parent2)) {
1353
+ parent2 = deletedParent.get(parent2) ?? null;
1354
+ }
1355
+ if (parent2 !== branch.parent) {
1356
+ branch.parent = parent2;
1357
+ reparented.push({ branch: branch.name, parent: branch.parent });
1358
+ }
1359
+ }
1360
+ state.stacks = state.stacks.filter(
1361
+ (candidate) => candidate.branches.length > 0
1362
+ );
1363
+ assertStateInvariants(state.stacks);
1364
+ await writeState(state, cwd);
1365
+ return {
1366
+ deleted: targets,
1367
+ reparented
1368
+ };
1369
+ }
1370
+ function collectTargets(stack, options) {
1371
+ const target = stack.branches.find(
1372
+ (branch) => branch.name === options.branch
1373
+ );
1374
+ if (!target) {
1375
+ throw new DubError(
1376
+ `Branch '${options.branch}' is missing from tracked stack.`
1377
+ );
1378
+ }
1379
+ if (target.type === "root") {
1380
+ throw new DubError(
1381
+ `Cannot delete root branch '${options.branch}' via dub delete.`
1382
+ );
1383
+ }
1384
+ const stackBranchMap = new Map(
1385
+ stack.branches.map((branch) => [branch.name, branch])
1386
+ );
1387
+ const targets = /* @__PURE__ */ new Set([options.branch]);
1388
+ if (options.upstack) {
1389
+ for (const descendant of getDescendants(stack, options.branch)) {
1390
+ targets.add(descendant);
1391
+ }
1392
+ }
1393
+ if (options.downstack) {
1394
+ for (const ancestor of getAncestors(stack, options.branch)) {
1395
+ if (stackBranchMap.get(ancestor)?.type === "root") continue;
1396
+ targets.add(ancestor);
1397
+ }
1398
+ }
1399
+ return [...targets].sort(
1400
+ (a, b) => getAncestors(stack, b).length - getAncestors(stack, a).length
1401
+ );
1402
+ }
1403
+ function resolveFallbackBranch(stack, targetBranch, deleteSet) {
1404
+ const ancestors = getAncestors(stack, targetBranch);
1405
+ for (const ancestor of ancestors) {
1406
+ if (!deleteSet.has(ancestor)) return ancestor;
1407
+ }
1408
+ const root = stack.branches.find((branch) => branch.type === "root")?.name;
1409
+ if (root && !deleteSet.has(root)) return root;
1410
+ throw new DubError(
1411
+ "Unable to determine a safe checkout target before deleting current branch."
1412
+ );
1413
+ }
1414
+
1415
+ // src/commands/delete.ts
1416
+ init_errors();
1417
+ init_git();
1418
+ function isInteractiveShell() {
1419
+ return Boolean(process.stdout.isTTY && process.stdin.isTTY);
1420
+ }
1421
+ async function confirmDelete(targets) {
1422
+ const rl = readline.createInterface({ input, output });
1423
+ try {
1424
+ const answer = await rl.question(
1425
+ `Delete ${targets.length} branch(es): ${targets.join(", ")}? [y/N] `
1426
+ );
1427
+ const normalized = answer.trim().toLowerCase();
1428
+ return normalized === "y" || normalized === "yes";
1429
+ } finally {
1430
+ rl.close();
1431
+ }
1432
+ }
1433
+ async function deleteCommand(cwd, branchArg, options = {}) {
1434
+ const branch = branchArg ?? await getCurrentBranch(cwd);
1435
+ const interactive = options.interactive ?? isInteractiveShell();
1436
+ const preview = await getDeletePreview(cwd, {
1437
+ branch,
1438
+ upstack: options.upstack,
1439
+ downstack: options.downstack
1440
+ });
1441
+ if (!options.force && !options.quiet) {
1442
+ if (!interactive) {
1443
+ throw new DubError(
1444
+ "Delete requires confirmation. Re-run with --force or interactively."
1445
+ );
1446
+ }
1447
+ const confirmed = await confirmDelete(preview.targets);
1448
+ if (!confirmed) {
1449
+ return { deleted: [], reparented: [], cancelled: true };
1450
+ }
1451
+ }
1452
+ const result = await deleteTrackedBranch(cwd, {
1453
+ branch,
1454
+ upstack: options.upstack ?? false,
1455
+ downstack: options.downstack ?? false,
1456
+ force: options.force ?? false
1457
+ });
1458
+ return { ...result, cancelled: false };
1103
1459
  }
1104
1460
 
1105
1461
  // src/commands/init.ts
1106
1462
  init_errors();
1107
1463
  init_git();
1108
1464
  init_state();
1109
- import * as fs3 from "fs";
1110
- import * as path3 from "path";
1465
+ import * as fs5 from "fs";
1466
+ import * as path5 from "path";
1111
1467
  async function init(cwd) {
1112
1468
  if (!await isGitRepo(cwd)) {
1113
1469
  throw new DubError(
@@ -1116,20 +1472,20 @@ async function init(cwd) {
1116
1472
  }
1117
1473
  const status = await initState(cwd);
1118
1474
  const repoRoot = await getRepoRoot(cwd);
1119
- const gitignorePath = path3.join(repoRoot, ".gitignore");
1475
+ const gitignorePath = path5.join(repoRoot, ".gitignore");
1120
1476
  const entry = ".git/dubstack";
1121
1477
  let gitignoreUpdated = false;
1122
- if (fs3.existsSync(gitignorePath)) {
1123
- const content = fs3.readFileSync(gitignorePath, "utf-8");
1478
+ if (fs5.existsSync(gitignorePath)) {
1479
+ const content = fs5.readFileSync(gitignorePath, "utf-8");
1124
1480
  const lines = content.split("\n");
1125
1481
  if (!lines.some((line) => line.trim() === entry)) {
1126
1482
  const separator = content.endsWith("\n") ? "" : "\n";
1127
- fs3.writeFileSync(gitignorePath, `${content}${separator}${entry}
1483
+ fs5.writeFileSync(gitignorePath, `${content}${separator}${entry}
1128
1484
  `);
1129
1485
  gitignoreUpdated = true;
1130
1486
  }
1131
1487
  } else {
1132
- fs3.writeFileSync(gitignorePath, `${entry}
1488
+ fs5.writeFileSync(gitignorePath, `${entry}
1133
1489
  `);
1134
1490
  gitignoreUpdated = true;
1135
1491
  }
@@ -1137,9 +1493,10 @@ async function init(cwd) {
1137
1493
  }
1138
1494
 
1139
1495
  // src/commands/log.ts
1496
+ init_errors();
1140
1497
  init_git();
1141
1498
  init_state();
1142
- async function log(cwd) {
1499
+ async function log(cwd, options = {}) {
1143
1500
  const state = await readState(cwd);
1144
1501
  if (state.stacks.length === 0) {
1145
1502
  return "No stacks. Run 'dub create' to start.";
@@ -1149,29 +1506,57 @@ async function log(cwd) {
1149
1506
  currentBranch = await getCurrentBranch(cwd);
1150
1507
  } catch {
1151
1508
  }
1509
+ let stacksToRender = state.stacks;
1510
+ if (options.stack && !options.all) {
1511
+ if (!currentBranch) {
1512
+ throw new DubError(
1513
+ "Cannot determine current branch for --stack mode. Checkout a branch first."
1514
+ );
1515
+ }
1516
+ const currentStack = findStackForBranch(state, currentBranch);
1517
+ if (!currentStack) {
1518
+ throw new DubError(
1519
+ `Current branch '${currentBranch}' is not tracked. Run 'dub track ${currentBranch} --parent <branch>' first.`
1520
+ );
1521
+ }
1522
+ stacksToRender = [currentStack];
1523
+ }
1524
+ if (options.reverse) {
1525
+ stacksToRender = [...stacksToRender].reverse();
1526
+ }
1152
1527
  const sections = [];
1153
- for (const stack of state.stacks) {
1154
- const tree = await renderStack(stack, currentBranch, cwd);
1528
+ for (const stack of stacksToRender) {
1529
+ const tree = await renderStack(stack, currentBranch, cwd, options);
1155
1530
  sections.push(tree);
1156
1531
  }
1157
1532
  return sections.join("\n\n");
1158
1533
  }
1159
- async function renderStack(stack, currentBranch, cwd) {
1534
+ async function renderStack(stack, currentBranch, cwd, options) {
1160
1535
  const root = stack.branches.find((b) => b.type === "root");
1161
1536
  if (!root) return "";
1162
1537
  const childMap = /* @__PURE__ */ new Map();
1163
1538
  for (const branch of stack.branches) {
1164
1539
  if (branch.parent) {
1165
- const children = childMap.get(branch.parent) ?? [];
1166
- children.push(branch);
1167
- childMap.set(branch.parent, children);
1540
+ const children2 = childMap.get(branch.parent) ?? [];
1541
+ children2.push(branch);
1542
+ childMap.set(branch.parent, children2);
1168
1543
  }
1169
1544
  }
1170
1545
  const lines = [];
1171
- await renderNode(root, currentBranch, childMap, "", true, true, lines, cwd);
1546
+ await renderNode(
1547
+ root,
1548
+ currentBranch,
1549
+ childMap,
1550
+ "",
1551
+ true,
1552
+ true,
1553
+ lines,
1554
+ cwd,
1555
+ options
1556
+ );
1172
1557
  return lines.join("\n");
1173
1558
  }
1174
- async function renderNode(branch, currentBranch, childMap, prefix, isRoot, isLast, lines, cwd) {
1559
+ async function renderNode(branch, currentBranch, childMap, prefix, isRoot, isLast, lines, cwd, options) {
1175
1560
  let label;
1176
1561
  const exists = await branchExists(branch.name, cwd);
1177
1562
  if (isRoot) {
@@ -1189,19 +1574,20 @@ async function renderNode(branch, currentBranch, childMap, prefix, isRoot, isLas
1189
1574
  const connector = isLast ? "\u2514\u2500 " : "\u251C\u2500 ";
1190
1575
  lines.push(`${prefix}${connector}${label}`);
1191
1576
  }
1192
- const children = childMap.get(branch.name) ?? [];
1577
+ const children2 = options.reverse ? [...childMap.get(branch.name) ?? []].reverse() : childMap.get(branch.name) ?? [];
1193
1578
  const childPrefix = isRoot ? " " : `${prefix}${isLast ? " " : "\u2502 "}`;
1194
- for (let i = 0; i < children.length; i++) {
1195
- const isChildLast = i === children.length - 1;
1579
+ for (let i = 0; i < children2.length; i++) {
1580
+ const isChildLast = i === children2.length - 1;
1196
1581
  await renderNode(
1197
- children[i],
1582
+ children2[i],
1198
1583
  currentBranch,
1199
1584
  childMap,
1200
1585
  childPrefix,
1201
1586
  false,
1202
1587
  isChildLast,
1203
1588
  lines,
1204
- cwd
1589
+ cwd,
1590
+ options
1205
1591
  );
1206
1592
  }
1207
1593
  }
@@ -1213,8 +1599,8 @@ init_state();
1213
1599
  function getBranchByName(stack, name) {
1214
1600
  return stack.branches.find((branch) => branch.name === name);
1215
1601
  }
1216
- function getChildren2(stack, parent) {
1217
- return stack.branches.filter((branch) => branch.parent === parent).map((branch) => branch.name);
1602
+ function getChildren2(stack, parent2) {
1603
+ return stack.branches.filter((branch) => branch.parent === parent2).map((branch) => branch.name);
1218
1604
  }
1219
1605
  function getTrackedStackOrThrow(stateBranch, stack) {
1220
1606
  if (!stack) {
@@ -1236,16 +1622,16 @@ async function upBySteps(cwd, steps) {
1236
1622
  );
1237
1623
  let target = current;
1238
1624
  for (let i = 0; i < steps; i++) {
1239
- const children = getChildren2(stack, target);
1240
- if (children.length === 0) {
1625
+ const children2 = getChildren2(stack, target);
1626
+ if (children2.length === 0) {
1241
1627
  throw new DubError(`No branch above '${target}' in the current stack.`);
1242
1628
  }
1243
- if (children.length > 1) {
1629
+ if (children2.length > 1) {
1244
1630
  throw new DubError(
1245
1631
  `Branch '${target}' has multiple children; 'dub up' requires a linear stack path.`
1246
1632
  );
1247
1633
  }
1248
- target = children[0];
1634
+ target = children2[0];
1249
1635
  }
1250
1636
  await checkoutBranch(target, cwd);
1251
1637
  return { branch: target, changed: target !== current };
@@ -1287,14 +1673,14 @@ async function top(cwd) {
1287
1673
  );
1288
1674
  let target = current;
1289
1675
  while (true) {
1290
- const children = getChildren2(stack, target);
1291
- if (children.length === 0) break;
1292
- if (children.length > 1) {
1676
+ const children2 = getChildren2(stack, target);
1677
+ if (children2.length === 0) break;
1678
+ if (children2.length > 1) {
1293
1679
  throw new DubError(
1294
1680
  `Branch '${target}' has multiple children; 'dub top' requires a linear stack path.`
1295
1681
  );
1296
1682
  }
1297
- target = children[0];
1683
+ target = children2[0];
1298
1684
  }
1299
1685
  if (target !== current) {
1300
1686
  await checkoutBranch(target, cwd);
@@ -1316,30 +1702,30 @@ async function bottom(cwd) {
1316
1702
  }
1317
1703
  let target = current;
1318
1704
  if (!branch.parent) {
1319
- const children = getChildren2(stack, current);
1320
- if (children.length === 0) {
1705
+ const children2 = getChildren2(stack, current);
1706
+ if (children2.length === 0) {
1321
1707
  throw new DubError(
1322
1708
  `No branch above root '${current}' in the current stack.`
1323
1709
  );
1324
1710
  }
1325
- if (children.length > 1) {
1711
+ if (children2.length > 1) {
1326
1712
  throw new DubError(
1327
1713
  `Root branch '${current}' has multiple children; 'dub bottom' requires a linear stack path.`
1328
1714
  );
1329
1715
  }
1330
- target = children[0];
1716
+ target = children2[0];
1331
1717
  } else {
1332
1718
  let node = branch;
1333
1719
  while (node.parent) {
1334
- const parent = getBranchByName(stack, node.parent);
1335
- if (!parent) {
1720
+ const parent2 = getBranchByName(stack, node.parent);
1721
+ if (!parent2) {
1336
1722
  break;
1337
1723
  }
1338
- if (parent.parent === null) {
1724
+ if (parent2.parent === null) {
1339
1725
  target = node.name;
1340
1726
  break;
1341
1727
  }
1342
- node = parent;
1728
+ node = parent2;
1343
1729
  }
1344
1730
  }
1345
1731
  if (target !== current) {
@@ -1348,6 +1734,26 @@ async function bottom(cwd) {
1348
1734
  return { branch: target, changed: target !== current };
1349
1735
  }
1350
1736
 
1737
+ // src/commands/parent.ts
1738
+ init_errors();
1739
+ init_git();
1740
+ init_state();
1741
+ async function parent(cwd, branchArg) {
1742
+ const branch = branchArg ?? await getCurrentBranch(cwd);
1743
+ const state = await readState(cwd);
1744
+ const stack = findStackForBranch(state, branch);
1745
+ if (!stack) {
1746
+ throw new DubError(
1747
+ `Branch '${branch}' is not tracked. Run 'dub track ${branch} --parent <branch>' first.`
1748
+ );
1749
+ }
1750
+ const entry = stack.branches.find((candidate) => candidate.name === branch);
1751
+ if (!entry || !entry.parent) {
1752
+ throw new DubError(`Branch '${branch}' is at the root and has no parent.`);
1753
+ }
1754
+ return { branch, parent: entry.parent };
1755
+ }
1756
+
1351
1757
  // src/lib/github.ts
1352
1758
  init_errors();
1353
1759
  import { execa as execa2 } from "execa";
@@ -1530,9 +1936,9 @@ init_restack();
1530
1936
  // src/commands/submit.ts
1531
1937
  init_errors();
1532
1938
  init_git();
1533
- import * as fs5 from "fs";
1939
+ import * as fs6 from "fs";
1534
1940
  import * as os from "os";
1535
- import * as path5 from "path";
1941
+ import * as path6 from "path";
1536
1942
 
1537
1943
  // src/lib/pr-body.ts
1538
1944
  var DUBSTACK_START = "<!-- dubstack:start -->";
@@ -1608,10 +2014,17 @@ async function submit(cwd, dryRun) {
1608
2014
  const currentEntry = ordered.find((b) => b.name === currentBranch);
1609
2015
  if (currentEntry?.type === "root") {
1610
2016
  throw new DubError(
1611
- "Cannot submit from a root branch. Checkout a stack branch first."
2017
+ "Cannot submit from a root branch. Run 'dub up' or 'dub checkout <branch>' first."
1612
2018
  );
1613
2019
  }
1614
2020
  const nonRootBranches = ordered.filter((b) => b.type !== "root");
2021
+ const rootBranch = ordered.find((branch) => branch.type === "root")?.name ?? "(unknown)";
2022
+ console.log(
2023
+ `Submitting ${nonRootBranches.length} branch(es) from '${currentBranch}' onto trunk '${rootBranch}'.`
2024
+ );
2025
+ if (dryRun) {
2026
+ console.log("[dry-run] no branches will be pushed or mutated.");
2027
+ }
1615
2028
  validateLinearStack(ordered);
1616
2029
  const result = { pushed: [], created: [], updated: [] };
1617
2030
  const prMap = /* @__PURE__ */ new Map();
@@ -1679,10 +2092,10 @@ function validateLinearStack(ordered) {
1679
2092
  childCount.set(branch.parent, (childCount.get(branch.parent) ?? 0) + 1);
1680
2093
  }
1681
2094
  }
1682
- for (const [parent, count] of childCount) {
2095
+ for (const [parent2, count] of childCount) {
1683
2096
  if (count > 1) {
1684
2097
  throw new DubError(
1685
- `Branch '${parent}' has ${count} children. Branching stacks are not supported by submit. Ensure each branch has at most one child.`
2098
+ `Branch '${parent2}' has ${count} children. Branching stacks are not supported by submit. Ensure each branch has at most one child. Use 'dub track <child> --parent <branch>' to re-parent branches and linearize the stack before submitting.`
1686
2099
  );
1687
2100
  }
1688
2101
  }
@@ -1721,13 +2134,13 @@ async function updateAllPrBodies(branches, prMap, stackId, cwd) {
1721
2134
  }
1722
2135
  function writeTempBody(content) {
1723
2136
  const tmpDir = os.tmpdir();
1724
- const tmpFile = path5.join(tmpDir, `dubstack-body-${Date.now()}.md`);
1725
- fs5.writeFileSync(tmpFile, content);
2137
+ const tmpFile = path6.join(tmpDir, `dubstack-body-${Date.now()}.md`);
2138
+ fs6.writeFileSync(tmpFile, content);
1726
2139
  return tmpFile;
1727
2140
  }
1728
2141
  function cleanupTempFile(filePath) {
1729
2142
  try {
1730
- fs5.unlinkSync(filePath);
2143
+ fs6.unlinkSync(filePath);
1731
2144
  } catch {
1732
2145
  }
1733
2146
  }
@@ -1735,36 +2148,36 @@ function cleanupTempFile(filePath) {
1735
2148
  // src/commands/sync.ts
1736
2149
  init_errors();
1737
2150
  init_git();
1738
- import { stdin as input, stdout as output } from "process";
1739
- import * as readline from "readline/promises";
2151
+ import { stdin as input2, stdout as output2 } from "process";
2152
+ import * as readline2 from "readline/promises";
1740
2153
  init_state();
1741
2154
 
1742
2155
  // 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) {
2156
+ function classifyBranchSyncStatus(input5) {
2157
+ if (!input5.hasRemote) return "missing-remote";
2158
+ if (!input5.hasLocal) return "missing-local";
2159
+ if (input5.localSha && input5.remoteSha && input5.localSha === input5.remoteSha) {
2160
+ if (!input5.hasSubmittedBaseline) {
1748
2161
  return "updated-outside-dubstack-but-up-to-date";
1749
2162
  }
1750
2163
  return "up-to-date";
1751
2164
  }
1752
- if (!input2.hasSubmittedBaseline) return "unsubmitted";
1753
- if (input2.localBehind) return "needs-remote-sync-safe";
1754
- if (input2.remoteBehind) return "local-ahead";
2165
+ if (!input5.hasSubmittedBaseline) return "unsubmitted";
2166
+ if (input5.localBehind) return "needs-remote-sync-safe";
2167
+ if (input5.remoteBehind) return "local-ahead";
1755
2168
  return "reconcile-needed";
1756
2169
  }
1757
2170
 
1758
2171
  // src/lib/sync/cleanup.ts
1759
- async function buildCleanupPlan(input2) {
2172
+ async function buildCleanupPlan(input5) {
1760
2173
  const toDelete = [];
1761
2174
  const skipped = [];
1762
- for (const branch of input2.branches) {
1763
- const prState = await input2.getPrStatus(branch);
2175
+ for (const branch of input5.branches) {
2176
+ const prState = await input5.getPrStatus(branch);
1764
2177
  if (prState !== "MERGED" && prState !== "CLOSED") {
1765
2178
  continue;
1766
2179
  }
1767
- const mergedIntoRoot = await input2.isMergedIntoAnyRoot(branch);
2180
+ const mergedIntoRoot = await input5.isMergedIntoAnyRoot(branch);
1768
2181
  if (!mergedIntoRoot) {
1769
2182
  skipped.push({ branch, reason: "commits-not-in-trunk" });
1770
2183
  continue;
@@ -1775,10 +2188,10 @@ async function buildCleanupPlan(input2) {
1775
2188
  }
1776
2189
 
1777
2190
  // 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();
2191
+ async function resolveReconcileDecision(input5) {
2192
+ if (input5.force) return "take-remote";
2193
+ if (!input5.interactive) return "skip";
2194
+ const raw = await input5.promptChoice();
1782
2195
  if (raw === "take-remote" || raw === "keep-local" || raw === "reconcile" || raw === "skip") {
1783
2196
  return raw;
1784
2197
  }
@@ -1802,11 +2215,11 @@ function printSyncSummary(result) {
1802
2215
 
1803
2216
  // src/commands/sync.ts
1804
2217
  init_restack();
1805
- function isInteractiveShell() {
2218
+ function isInteractiveShell2() {
1806
2219
  return Boolean(process.stdout.isTTY && process.stdin.isTTY);
1807
2220
  }
1808
2221
  async function confirm(question) {
1809
- const rl = readline.createInterface({ input, output });
2222
+ const rl = readline2.createInterface({ input: input2, output: output2 });
1810
2223
  try {
1811
2224
  const answer = await rl.question(`${question} [Y/n] `);
1812
2225
  const normalized = answer.trim().toLowerCase();
@@ -1816,7 +2229,7 @@ async function confirm(question) {
1816
2229
  }
1817
2230
  }
1818
2231
  async function choose(question, choices) {
1819
- const rl = readline.createInterface({ input, output });
2232
+ const rl = readline2.createInterface({ input: input2, output: output2 });
1820
2233
  try {
1821
2234
  console.log(question);
1822
2235
  for (let i = 0; i < choices.length; i++) {
@@ -1839,7 +2252,7 @@ async function sync(cwd, rawOptions = {}) {
1839
2252
  restack: rawOptions.restack ?? true,
1840
2253
  force: rawOptions.force ?? false,
1841
2254
  all: rawOptions.all ?? false,
1842
- interactive: rawOptions.interactive ?? isInteractiveShell()
2255
+ interactive: rawOptions.interactive ?? isInteractiveShell2()
1843
2256
  };
1844
2257
  const state = await readState(cwd);
1845
2258
  const originalBranch = await getCurrentBranch(cwd);
@@ -1925,7 +2338,7 @@ async function sync(cwd, rawOptions = {}) {
1925
2338
  for (const skipped of cleanupPlan.skipped) {
1926
2339
  if (skipped.reason === "commits-not-in-trunk") {
1927
2340
  excludedFromSync.add(skipped.branch);
1928
- for (const child of getDescendants(scopeStacks, skipped.branch)) {
2341
+ for (const child of getDescendants2(scopeStacks, skipped.branch)) {
1929
2342
  excludedFromSync.add(child);
1930
2343
  }
1931
2344
  }
@@ -2299,15 +2712,15 @@ async function markBranchSynced(branchMap, branchName, headSha, cwd, options) {
2299
2712
  entry.last_synced_at = (/* @__PURE__ */ new Date()).toISOString();
2300
2713
  entry.sync_source = options.source;
2301
2714
  }
2302
- function getDescendants(stacks, branch) {
2715
+ function getDescendants2(stacks, branch) {
2303
2716
  const descendants = [];
2304
2717
  const childMap = /* @__PURE__ */ new Map();
2305
2718
  for (const stack of stacks) {
2306
2719
  for (const node of stack.branches) {
2307
2720
  if (!node.parent) continue;
2308
- const children = childMap.get(node.parent) ?? [];
2309
- children.push(node.name);
2310
- childMap.set(node.parent, children);
2721
+ const children2 = childMap.get(node.parent) ?? [];
2722
+ children2.push(node.name);
2723
+ childMap.set(node.parent, children2);
2311
2724
  }
2312
2725
  }
2313
2726
  const queue = [...childMap.get(branch) ?? []];
@@ -2333,6 +2746,180 @@ function removeBranchFromState(stacks, branch) {
2333
2746
  }
2334
2747
  }
2335
2748
 
2749
+ // src/commands/track.ts
2750
+ init_errors();
2751
+ init_git();
2752
+ import { stdin as input3, stdout as output3 } from "process";
2753
+ import * as readline3 from "readline/promises";
2754
+
2755
+ // src/lib/track.ts
2756
+ init_errors();
2757
+ init_git();
2758
+ import * as crypto2 from "crypto";
2759
+ init_state();
2760
+ async function validateTrackParent(cwd, branch, parent2) {
2761
+ if (branch === parent2) {
2762
+ throw new DubError("Branch cannot be its own parent.");
2763
+ }
2764
+ if (!await branchExists(parent2, cwd)) {
2765
+ throw new DubError(`Parent branch '${parent2}' does not exist locally.`);
2766
+ }
2767
+ }
2768
+ async function trackBranch(cwd, options) {
2769
+ const { branch, parent: parent2 } = options;
2770
+ if (!await branchExists(branch, cwd)) {
2771
+ throw new DubError(`Branch '${branch}' does not exist locally.`);
2772
+ }
2773
+ await validateTrackParent(cwd, branch, parent2);
2774
+ const state = await ensureState(cwd);
2775
+ const sourceStack = findStackForBranch(state, branch);
2776
+ const destinationStack = findStackForBranch(state, parent2);
2777
+ if (!sourceStack) {
2778
+ addBranchToStack(state, branch, parent2);
2779
+ assertStateInvariants(state.stacks);
2780
+ await writeState(state, cwd);
2781
+ return { branch, parent: parent2, status: "tracked" };
2782
+ }
2783
+ const branchEntry = sourceStack.branches.find(
2784
+ (entry) => entry.name === branch
2785
+ );
2786
+ if (!branchEntry) {
2787
+ throw new DubError(`Branch '${branch}' is missing from tracked state.`);
2788
+ }
2789
+ if (branchEntry.type === "root") {
2790
+ throw new DubError(
2791
+ `Branch '${branch}' is a stack root and cannot be re-parented.`
2792
+ );
2793
+ }
2794
+ if (branchEntry.parent === parent2) {
2795
+ return { branch, parent: parent2, status: "unchanged" };
2796
+ }
2797
+ const descendants = new Set(getDescendants(sourceStack, branch));
2798
+ if (descendants.has(parent2)) {
2799
+ throw new DubError(
2800
+ `Cannot track '${branch}' onto '${parent2}' because it would create a cycle.`
2801
+ );
2802
+ }
2803
+ if (sourceStack.id === destinationStack?.id) {
2804
+ branchEntry.parent = parent2;
2805
+ assertStateInvariants(state.stacks);
2806
+ await writeState(state, cwd);
2807
+ return { branch, parent: parent2, status: "reparented" };
2808
+ }
2809
+ const movingNames = /* @__PURE__ */ new Set([branch, ...descendants]);
2810
+ const movingBranches = sourceStack.branches.filter(
2811
+ (entry) => movingNames.has(entry.name)
2812
+ );
2813
+ sourceStack.branches = sourceStack.branches.filter(
2814
+ (entry) => !movingNames.has(entry.name)
2815
+ );
2816
+ const movingRoot = movingBranches.find((entry) => entry.name === branch);
2817
+ if (!movingRoot) {
2818
+ throw new DubError(`Failed to move subtree for '${branch}'.`);
2819
+ }
2820
+ movingRoot.parent = parent2;
2821
+ movingRoot.type = void 0;
2822
+ if (destinationStack) {
2823
+ destinationStack.branches.push(...movingBranches);
2824
+ } else {
2825
+ state.stacks.push({
2826
+ id: crypto2.randomUUID(),
2827
+ branches: [
2828
+ {
2829
+ name: parent2,
2830
+ type: "root",
2831
+ parent: null,
2832
+ pr_number: null,
2833
+ pr_link: null,
2834
+ last_submitted_version: null,
2835
+ last_synced_at: null,
2836
+ sync_source: null
2837
+ },
2838
+ ...movingBranches
2839
+ ]
2840
+ });
2841
+ }
2842
+ state.stacks = state.stacks.filter((stack) => stack.branches.length > 0);
2843
+ assertStateInvariants(state.stacks);
2844
+ await writeState(state, cwd);
2845
+ return { branch, parent: parent2, status: "reparented" };
2846
+ }
2847
+
2848
+ // src/commands/track.ts
2849
+ function isInteractiveShell3() {
2850
+ return Boolean(process.stdout.isTTY && process.stdin.isTTY);
2851
+ }
2852
+ async function promptForParent(branch, suggestedParent) {
2853
+ const rl = readline3.createInterface({ input: input3, output: output3 });
2854
+ try {
2855
+ const suffix = suggestedParent ? ` [${suggestedParent}]` : "";
2856
+ const answer = await rl.question(
2857
+ `Parent branch for '${branch}'${suffix}: `
2858
+ );
2859
+ const parent2 = answer.trim() || suggestedParent;
2860
+ if (!parent2) {
2861
+ throw new DubError(
2862
+ `No parent selected for '${branch}'. Re-run with --parent <branch>.`
2863
+ );
2864
+ }
2865
+ return parent2;
2866
+ } finally {
2867
+ rl.close();
2868
+ }
2869
+ }
2870
+ async function resolveSuggestedParent(cwd, branch, currentBranch) {
2871
+ if (currentBranch !== branch) return currentBranch;
2872
+ if (await branchExists("main", cwd)) return "main";
2873
+ if (await branchExists("master", cwd)) return "master";
2874
+ return null;
2875
+ }
2876
+ async function track(cwd, branchArg, options = {}) {
2877
+ const currentBranch = await getCurrentBranch(cwd);
2878
+ const branch = branchArg ?? currentBranch;
2879
+ const interactive = options.interactive ?? isInteractiveShell3();
2880
+ let parent2 = options.parent;
2881
+ if (!parent2) {
2882
+ const suggestedParent = await resolveSuggestedParent(
2883
+ cwd,
2884
+ branch,
2885
+ currentBranch
2886
+ );
2887
+ if (interactive) {
2888
+ parent2 = await promptForParent(branch, suggestedParent);
2889
+ } else if (suggestedParent) {
2890
+ parent2 = suggestedParent;
2891
+ }
2892
+ }
2893
+ if (!parent2) {
2894
+ throw new DubError(
2895
+ `Could not infer parent for '${branch}'. Re-run with --parent <branch>.`
2896
+ );
2897
+ }
2898
+ return trackBranch(cwd, { branch, parent: parent2 });
2899
+ }
2900
+
2901
+ // src/commands/trunk.ts
2902
+ init_errors();
2903
+ init_git();
2904
+ init_state();
2905
+ async function trunk(cwd, branchArg) {
2906
+ const branch = branchArg ?? await getCurrentBranch(cwd);
2907
+ const state = await readState(cwd);
2908
+ const stack = findStackForBranch(state, branch);
2909
+ if (!stack) {
2910
+ throw new DubError(
2911
+ `Branch '${branch}' is not tracked. Run 'dub track ${branch} --parent <branch>' first.`
2912
+ );
2913
+ }
2914
+ const root = stack.branches.find((candidate) => candidate.type === "root");
2915
+ if (!root) {
2916
+ throw new DubError(
2917
+ `Stack for '${branch}' is missing a root branch. Re-run 'dub track' to repair metadata.`
2918
+ );
2919
+ }
2920
+ return { branch, trunk: root.name };
2921
+ }
2922
+
2336
2923
  // src/commands/undo.ts
2337
2924
  init_errors();
2338
2925
  init_git();
@@ -2384,6 +2971,106 @@ async function undo(cwd) {
2384
2971
  };
2385
2972
  }
2386
2973
 
2974
+ // src/commands/untrack.ts
2975
+ init_errors();
2976
+ init_git();
2977
+ import { stdin as input4, stdout as output4 } from "process";
2978
+ import * as readline4 from "readline/promises";
2979
+
2980
+ // src/lib/untrack.ts
2981
+ init_errors();
2982
+ init_state();
2983
+ async function getUntrackContext(cwd, branch) {
2984
+ const state = await readState(cwd);
2985
+ const stack = findStackForBranch(state, branch);
2986
+ if (!stack) {
2987
+ throw new DubError(
2988
+ `Branch '${branch}' is not tracked. Run 'dub track ${branch} --parent <branch>' first.`
2989
+ );
2990
+ }
2991
+ return {
2992
+ stack,
2993
+ branch,
2994
+ descendants: getDescendants(stack, branch)
2995
+ };
2996
+ }
2997
+ async function untrackBranch(cwd, options) {
2998
+ const state = await readState(cwd);
2999
+ const stack = findStackForBranch(state, options.branch);
3000
+ if (!stack) {
3001
+ throw new DubError(
3002
+ `Branch '${options.branch}' is not tracked by DubStack.`
3003
+ );
3004
+ }
3005
+ const entry = stack.branches.find((branch) => branch.name === options.branch);
3006
+ if (!entry) {
3007
+ throw new DubError(
3008
+ `Branch '${options.branch}' is missing from tracked stack.`
3009
+ );
3010
+ }
3011
+ const descendants = getDescendants(stack, options.branch);
3012
+ const removedSet = new Set(
3013
+ options.downstack ? [options.branch, ...descendants] : [options.branch]
3014
+ );
3015
+ if (entry.type === "root" && !options.downstack && descendants.length > 0) {
3016
+ throw new DubError(
3017
+ `Branch '${options.branch}' is a root with descendants. Use --downstack to untrack the whole subtree.`
3018
+ );
3019
+ }
3020
+ const reparented = [];
3021
+ if (!options.downstack) {
3022
+ for (const branch of stack.branches) {
3023
+ if (branch.parent !== options.branch) continue;
3024
+ branch.parent = entry.parent;
3025
+ reparented.push({ branch: branch.name, parent: branch.parent });
3026
+ }
3027
+ }
3028
+ stack.branches = stack.branches.filter(
3029
+ (branch) => !removedSet.has(branch.name)
3030
+ );
3031
+ state.stacks = state.stacks.filter(
3032
+ (candidate) => candidate.branches.length > 0
3033
+ );
3034
+ assertStateInvariants(state.stacks);
3035
+ await writeState(state, cwd);
3036
+ return {
3037
+ removed: [options.branch, ...options.downstack ? descendants : []],
3038
+ reparented
3039
+ };
3040
+ }
3041
+
3042
+ // src/commands/untrack.ts
3043
+ function isInteractiveShell4() {
3044
+ return Boolean(process.stdout.isTTY && process.stdin.isTTY);
3045
+ }
3046
+ async function confirmDownstack(branch, descendants) {
3047
+ const rl = readline4.createInterface({ input: input4, output: output4 });
3048
+ try {
3049
+ const answer = await rl.question(
3050
+ `Branch '${branch}' has descendants (${descendants.join(", ")}). Untrack them too? [y/N] `
3051
+ );
3052
+ const normalized = answer.trim().toLowerCase();
3053
+ return normalized === "y" || normalized === "yes";
3054
+ } finally {
3055
+ rl.close();
3056
+ }
3057
+ }
3058
+ async function untrack(cwd, branchArg, options = {}) {
3059
+ const branch = branchArg ?? await getCurrentBranch(cwd);
3060
+ const interactive = options.interactive ?? isInteractiveShell4();
3061
+ let downstack = options.downstack ?? false;
3062
+ const context = await getUntrackContext(cwd, branch);
3063
+ if (context.descendants.length > 0 && !downstack) {
3064
+ if (!interactive) {
3065
+ throw new DubError(
3066
+ `Branch '${branch}' has descendants (${context.descendants.join(", ")}). Re-run with --downstack or interactive mode.`
3067
+ );
3068
+ }
3069
+ downstack = await confirmDownstack(branch, context.descendants);
3070
+ }
3071
+ return untrackBranch(cwd, { branch, downstack });
3072
+ }
3073
+
2387
3074
  // src/index.ts
2388
3075
  init_errors();
2389
3076
  var require2 = createRequire(import.meta.url);
@@ -2436,17 +3123,21 @@ Examples:
2436
3123
  }
2437
3124
  }
2438
3125
  );
2439
- program.command("log").alias("l").description("Display an ASCII tree of the current stack").addHelpText(
3126
+ program.command("log").alias("l").description("Display an ASCII tree of the current stack").option("-s, --stack", "Only show the current stack").option("-a, --all", "Show all stacks (default)").option("-r, --reverse", "Reverse stack/child ordering").addHelpText(
2440
3127
  "after",
2441
3128
  `
2442
3129
  Examples:
2443
3130
  $ dub log Show the branch tree with current branch highlighted`
2444
- ).action(async () => {
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
- });
3131
+ ).action(
3132
+ async (options) => {
3133
+ await printLog(process.cwd(), options);
3134
+ }
3135
+ );
3136
+ program.command("ls").description("Display an ASCII tree of the current stack").option("-s, --stack", "Only show the current stack").option("-a, --all", "Show all stacks (default)").option("-r, --reverse", "Reverse stack/child ordering").action(
3137
+ async (options) => {
3138
+ await printLog(process.cwd(), options);
3139
+ }
3140
+ );
2450
3141
  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
3142
  const steps = parseSteps(stepsArg, options.steps);
2452
3143
  const result = await upBySteps(process.cwd(), steps);
@@ -2499,6 +3190,126 @@ program.command("info").argument("[branch]", "Branch to inspect (defaults to cur
2499
3190
  const info = await branchInfo(process.cwd(), branch);
2500
3191
  console.log(formatBranchInfo(info));
2501
3192
  });
3193
+ program.command("track").argument("[branch]", "Branch to track (defaults to current branch)").option("-p, --parent <branch>", "Parent branch for tracking").option(
3194
+ "--no-interactive",
3195
+ "Disable parent prompt and require deterministic behavior"
3196
+ ).description("Track a branch or update its parent relationship").addHelpText(
3197
+ "after",
3198
+ `
3199
+ Examples:
3200
+ $ dub track
3201
+ $ dub track feat/a --parent main`
3202
+ ).action(
3203
+ async (branch, options) => {
3204
+ const result = await track(process.cwd(), branch, {
3205
+ parent: options.parent,
3206
+ interactive: options.interactive
3207
+ });
3208
+ if (result.status === "tracked") {
3209
+ console.log(
3210
+ chalk2.green(`\u2714 Tracking '${result.branch}' on '${result.parent}'`)
3211
+ );
3212
+ return;
3213
+ }
3214
+ if (result.status === "reparented") {
3215
+ console.log(
3216
+ chalk2.green(
3217
+ `\u2714 Re-parented '${result.branch}' onto '${result.parent}'`
3218
+ )
3219
+ );
3220
+ console.log(
3221
+ chalk2.dim(
3222
+ " Run 'dub restack' if descendant branches now need rebasing."
3223
+ )
3224
+ );
3225
+ return;
3226
+ }
3227
+ console.log(
3228
+ chalk2.yellow(
3229
+ `\u26A0 '${result.branch}' is already tracked on '${result.parent}'.`
3230
+ )
3231
+ );
3232
+ }
3233
+ );
3234
+ program.command("untrack").argument("[branch]", "Branch to untrack (defaults to current branch)").option("--downstack", "Also untrack descendants recursively").option("--no-interactive", "Disable prompts and require explicit flags").description(
3235
+ "Remove branch metadata from DubStack without deleting git branches"
3236
+ ).addHelpText(
3237
+ "after",
3238
+ `
3239
+ Examples:
3240
+ $ dub untrack
3241
+ $ dub untrack feat/a --downstack`
3242
+ ).action(
3243
+ async (branch, options) => {
3244
+ const result = await untrack(process.cwd(), branch, {
3245
+ downstack: options.downstack,
3246
+ interactive: options.interactive
3247
+ });
3248
+ console.log(
3249
+ chalk2.green(
3250
+ `\u2714 Untracked ${result.removed.length} branch(es): ${result.removed.join(", ")}`
3251
+ )
3252
+ );
3253
+ for (const entry of result.reparented) {
3254
+ console.log(
3255
+ chalk2.dim(
3256
+ ` \u21B3 Re-parented '${entry.branch}' to '${entry.parent ?? "(none)"}'`
3257
+ )
3258
+ );
3259
+ }
3260
+ }
3261
+ );
3262
+ program.command("delete").argument("[branch]", "Branch to delete (defaults to current branch)").option("--upstack", "Also delete descendants of the target branch").option("--downstack", "Also delete ancestors toward trunk").option("-f, --force", "Delete branches even when not merged").option("-q, --quiet", "Skip confirmation prompts").option("--no-interactive", "Disable prompts and require explicit flags").description("Delete local branches and update DubStack metadata").addHelpText(
3263
+ "after",
3264
+ `
3265
+ Examples:
3266
+ $ dub delete feat/a
3267
+ $ dub delete feat/a --upstack -f -q`
3268
+ ).action(
3269
+ async (branch, options) => {
3270
+ const result = await deleteCommand(process.cwd(), branch, {
3271
+ upstack: options.upstack,
3272
+ downstack: options.downstack,
3273
+ force: options.force,
3274
+ quiet: options.quiet,
3275
+ interactive: options.interactive
3276
+ });
3277
+ if (result.cancelled) {
3278
+ console.log(chalk2.yellow("\u26A0 Delete cancelled."));
3279
+ return;
3280
+ }
3281
+ console.log(
3282
+ chalk2.green(
3283
+ `\u2714 Deleted ${result.deleted.length} branch(es): ${result.deleted.join(", ")}`
3284
+ )
3285
+ );
3286
+ for (const entry of result.reparented) {
3287
+ console.log(
3288
+ chalk2.dim(
3289
+ ` \u21B3 Re-parented '${entry.branch}' to '${entry.parent ?? "(none)"}'`
3290
+ )
3291
+ );
3292
+ }
3293
+ }
3294
+ );
3295
+ program.command("parent").argument("[branch]", "Branch to inspect (defaults to current branch)").description("Show the direct parent branch").action(async (branch) => {
3296
+ const result = await parent(process.cwd(), branch);
3297
+ console.log(result.parent);
3298
+ });
3299
+ program.command("children").argument("[branch]", "Branch to inspect (defaults to current branch)").description("Show direct child branches").action(async (branch) => {
3300
+ const result = await children(process.cwd(), branch);
3301
+ if (result.children.length === 0) {
3302
+ console.log("(none)");
3303
+ return;
3304
+ }
3305
+ for (const child of result.children) {
3306
+ console.log(child);
3307
+ }
3308
+ });
3309
+ program.command("trunk").argument("[branch]", "Branch to inspect (defaults to current branch)").description("Show trunk/root branch for the active stack").action(async (branch) => {
3310
+ const result = await trunk(process.cwd(), branch);
3311
+ console.log(result.trunk);
3312
+ });
2502
3313
  program.command("sync").description("Sync tracked branches with remote and reconcile divergence").option(
2503
3314
  "--restack",
2504
3315
  "Restack branches after sync (disable with --no-restack)",
@@ -2536,6 +3347,37 @@ Examples:
2536
3347
  }
2537
3348
  }
2538
3349
  });
3350
+ program.command("continue").description("Continue the active restack or git rebase operation").action(async () => {
3351
+ const result = await continueCommand(process.cwd());
3352
+ if (result.continued === "rebase") {
3353
+ console.log(chalk2.green("\u2714 Continued git rebase."));
3354
+ return;
3355
+ }
3356
+ if (result.restackResult?.status === "conflict") {
3357
+ console.log(
3358
+ chalk2.yellow(
3359
+ `\u26A0 Conflict while restacking '${result.restackResult.conflictBranch}'`
3360
+ )
3361
+ );
3362
+ console.log(
3363
+ chalk2.dim(" Resolve conflicts, stage changes, then run: dub continue")
3364
+ );
3365
+ return;
3366
+ }
3367
+ if (result.restackResult?.status === "up-to-date") {
3368
+ console.log(chalk2.green("\u2714 Stack is already up to date."));
3369
+ return;
3370
+ }
3371
+ console.log(chalk2.green("\u2714 Continued restack."));
3372
+ });
3373
+ program.command("abort").description("Abort the active restack or git rebase operation").action(async () => {
3374
+ const result = await abortCommand(process.cwd());
3375
+ if (result.aborted === "restack") {
3376
+ console.log(chalk2.green("\u2714 Aborted restack and cleared progress."));
3377
+ return;
3378
+ }
3379
+ console.log(chalk2.green("\u2714 Aborted git rebase."));
3380
+ });
2539
3381
  program.command("undo").description("Undo the last dub create or dub restack operation").addHelpText(
2540
3382
  "after",
2541
3383
  `
@@ -2570,8 +3412,8 @@ program.command("checkout").alias("co").argument("[branch]", "Branch to checkout
2570
3412
  const result = await checkout(branch, process.cwd());
2571
3413
  console.log(chalk2.green(`\u2714 Switched to '${result.branch}'`));
2572
3414
  } else if (options.trunk) {
2573
- const trunk = await resolveCheckoutTrunk(process.cwd());
2574
- const result = await checkout(trunk, process.cwd());
3415
+ const trunk2 = await resolveCheckoutTrunk(process.cwd());
3416
+ const result = await checkout(trunk2, process.cwd());
2575
3417
  console.log(chalk2.green(`\u2714 Switched to '${result.branch}'`));
2576
3418
  } else {
2577
3419
  const result = await interactiveCheckout(process.cwd(), {
@@ -2635,9 +3477,9 @@ async function runSubmit(options) {
2635
3477
  }
2636
3478
  }
2637
3479
  }
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)"));
3480
+ async function printLog(cwd, options = {}) {
3481
+ const output5 = await log(cwd, options);
3482
+ const styled = output5.replace(/\*(.+?) \(Current\)\*/g, chalk2.bold.cyan("$1 (Current)")).replace(/⚠ \(missing\)/g, chalk2.yellow("\u26A0 (missing)"));
2641
3483
  console.log(styled);
2642
3484
  }
2643
3485
  function parseSteps(positional, option) {