dubstack 0.1.3 → 0.3.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.
Files changed (42) hide show
  1. package/README.md +22 -1
  2. package/dist/index.js +1180 -105
  3. package/dist/index.js.map +1 -1
  4. package/package.json +4 -2
  5. package/dist/commands/create.d.ts +0 -18
  6. package/dist/commands/create.d.ts.map +0 -1
  7. package/dist/commands/create.js +0 -35
  8. package/dist/commands/create.js.map +0 -1
  9. package/dist/commands/init.d.ts +0 -18
  10. package/dist/commands/init.d.ts.map +0 -1
  11. package/dist/commands/init.js +0 -41
  12. package/dist/commands/init.js.map +0 -1
  13. package/dist/commands/log.d.ts +0 -12
  14. package/dist/commands/log.d.ts.map +0 -1
  15. package/dist/commands/log.js +0 -77
  16. package/dist/commands/log.js.map +0 -1
  17. package/dist/commands/restack.d.ts +0 -33
  18. package/dist/commands/restack.d.ts.map +0 -1
  19. package/dist/commands/restack.js +0 -190
  20. package/dist/commands/restack.js.map +0 -1
  21. package/dist/commands/undo.d.ts +0 -21
  22. package/dist/commands/undo.d.ts.map +0 -1
  23. package/dist/commands/undo.js +0 -63
  24. package/dist/commands/undo.js.map +0 -1
  25. package/dist/index.d.ts +0 -20
  26. package/dist/index.d.ts.map +0 -1
  27. package/dist/lib/errors.d.ts +0 -15
  28. package/dist/lib/errors.d.ts.map +0 -1
  29. package/dist/lib/errors.js +0 -18
  30. package/dist/lib/errors.js.map +0 -1
  31. package/dist/lib/git.d.ts +0 -69
  32. package/dist/lib/git.d.ts.map +0 -1
  33. package/dist/lib/git.js +0 -184
  34. package/dist/lib/git.js.map +0 -1
  35. package/dist/lib/state.d.ts +0 -70
  36. package/dist/lib/state.d.ts.map +0 -1
  37. package/dist/lib/state.js +0 -110
  38. package/dist/lib/state.js.map +0 -1
  39. package/dist/lib/undo-log.d.ts +0 -33
  40. package/dist/lib/undo-log.d.ts.map +0 -1
  41. package/dist/lib/undo-log.js +0 -37
  42. package/dist/lib/undo-log.js.map +0 -1
package/dist/index.js CHANGED
@@ -1,125 +1,1200 @@
1
1
  #!/usr/bin/env node
2
- /**
3
- * DubStack CLI — manage stacked diffs with ease.
4
- *
5
- * A local-first tool for managing chains of dependent git branches
6
- * (stacked diffs) without manually dealing with complex rebase chains.
7
- *
8
- * @example
9
- * ```bash
10
- * dub init # Initialize in current repo
11
- * dub create feat/my-branch # Create stacked branch
12
- * dub log # View stack tree
13
- * dub restack # Rebase stack onto updated parent
14
- * dub undo # Undo last dub operation
15
- * ```
16
- *
17
- * @packageDocumentation
18
- */
19
- import { createRequire } from "node:module";
2
+
3
+ // src/index.ts
4
+ import { createRequire } from "module";
20
5
  import chalk from "chalk";
21
6
  import { Command } from "commander";
22
- import { create } from "./commands/create.js";
23
- import { init } from "./commands/init.js";
24
- import { log } from "./commands/log.js";
25
- import { restack, restackContinue } from "./commands/restack.js";
26
- import { undo } from "./commands/undo.js";
27
- import { DubError } from "./lib/errors.js";
28
- const require = createRequire(import.meta.url);
29
- const { version } = require("../package.json");
30
- const program = new Command();
31
- program
32
- .name("dub")
33
- .description("Manage stacked diffs (dependent git branches) with ease")
34
- .version(version);
35
- program
36
- .command("init")
37
- .description("Initialize DubStack in the current git repository")
38
- .addHelpText("after", `
39
- Examples:
40
- $ dub init Initialize DubStack, creating .git/dubstack/ and updating .gitignore`)
41
- .action(async () => {
42
- const result = await init(process.cwd());
43
- if (result.status === "created") {
44
- console.log(chalk.green("✔ DubStack initialized"));
7
+
8
+ // src/commands/checkout.ts
9
+ import search from "@inquirer/search";
10
+
11
+ // src/lib/errors.ts
12
+ var DubError = class extends Error {
13
+ constructor(message) {
14
+ super(message);
15
+ this.name = "DubError";
16
+ }
17
+ };
18
+
19
+ // src/lib/git.ts
20
+ import { execa } from "execa";
21
+ async function isGitRepo(cwd) {
22
+ try {
23
+ const { stdout } = await execa(
24
+ "git",
25
+ ["rev-parse", "--is-inside-work-tree"],
26
+ { cwd }
27
+ );
28
+ return stdout.trim() === "true";
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+ async function getRepoRoot(cwd) {
34
+ try {
35
+ const { stdout } = await execa("git", ["rev-parse", "--show-toplevel"], {
36
+ cwd
37
+ });
38
+ return stdout.trim();
39
+ } catch {
40
+ throw new DubError(
41
+ "Not a git repository. Run this command inside a git repo."
42
+ );
43
+ }
44
+ }
45
+ async function getCurrentBranch(cwd) {
46
+ try {
47
+ const { stdout } = await execa(
48
+ "git",
49
+ ["rev-parse", "--abbrev-ref", "HEAD"],
50
+ { cwd }
51
+ );
52
+ const branch = stdout.trim();
53
+ if (branch === "HEAD") {
54
+ throw new DubError(
55
+ "HEAD is detached. Checkout a branch before running this command."
56
+ );
45
57
  }
46
- else {
47
- console.log(chalk.yellow("⚠ DubStack already initialized"));
58
+ return branch;
59
+ } catch (error) {
60
+ if (error instanceof DubError) throw error;
61
+ throw new DubError(
62
+ "Repository has no commits. Make at least one commit first."
63
+ );
64
+ }
65
+ }
66
+ async function branchExists(name, cwd) {
67
+ try {
68
+ await execa("git", ["rev-parse", "--verify", `refs/heads/${name}`], {
69
+ cwd
70
+ });
71
+ return true;
72
+ } catch {
73
+ return false;
74
+ }
75
+ }
76
+ async function createBranch(name, cwd) {
77
+ if (await branchExists(name, cwd)) {
78
+ throw new DubError(`Branch '${name}' already exists.`);
79
+ }
80
+ await execa("git", ["checkout", "-b", name], { cwd });
81
+ }
82
+ async function checkoutBranch(name, cwd) {
83
+ try {
84
+ await execa("git", ["checkout", name], { cwd });
85
+ } catch {
86
+ throw new DubError(`Branch '${name}' not found.`);
87
+ }
88
+ }
89
+ async function deleteBranch(name, cwd) {
90
+ try {
91
+ await execa("git", ["branch", "-D", name], { cwd });
92
+ } catch {
93
+ throw new DubError(`Failed to delete branch '${name}'. It may not exist.`);
94
+ }
95
+ }
96
+ async function forceBranchTo(name, sha, cwd) {
97
+ try {
98
+ const current = await getCurrentBranch(cwd).catch(() => null);
99
+ if (current === name) {
100
+ await execa("git", ["reset", "--hard", sha], { cwd });
101
+ } else {
102
+ await execa("git", ["branch", "-f", name, sha], { cwd });
48
103
  }
49
- });
50
- program
51
- .command("create")
52
- .argument("<branch-name>", "Name of the new branch to create")
53
- .description("Create a new branch stacked on top of the current branch")
54
- .addHelpText("after", `
104
+ } catch (error) {
105
+ if (error instanceof DubError) throw error;
106
+ throw new DubError(`Failed to reset branch '${name}' to ${sha}.`);
107
+ }
108
+ }
109
+ async function isWorkingTreeClean(cwd) {
110
+ const { stdout } = await execa("git", ["status", "--porcelain"], { cwd });
111
+ return stdout.trim() === "";
112
+ }
113
+ async function rebaseOnto(newBase, oldBase, branch, cwd) {
114
+ try {
115
+ await execa("git", ["rebase", "--onto", newBase, oldBase, branch], { cwd });
116
+ } catch {
117
+ throw new DubError(
118
+ `Conflict while restacking '${branch}'.
119
+ Resolve conflicts, stage changes, then run: dub restack --continue`
120
+ );
121
+ }
122
+ }
123
+ async function rebaseContinue(cwd) {
124
+ try {
125
+ await execa("git", ["rebase", "--continue"], {
126
+ cwd,
127
+ env: { GIT_EDITOR: "true" }
128
+ });
129
+ } catch {
130
+ throw new DubError(
131
+ "Failed to continue rebase. Ensure all conflicts are resolved and staged."
132
+ );
133
+ }
134
+ }
135
+ async function getMergeBase(a, b, cwd) {
136
+ try {
137
+ const { stdout } = await execa("git", ["merge-base", a, b], { cwd });
138
+ return stdout.trim();
139
+ } catch {
140
+ throw new DubError(
141
+ `Could not find common ancestor between '${a}' and '${b}'.`
142
+ );
143
+ }
144
+ }
145
+ async function getBranchTip(name, cwd) {
146
+ try {
147
+ const { stdout } = await execa("git", ["rev-parse", name], { cwd });
148
+ return stdout.trim();
149
+ } catch {
150
+ throw new DubError(`Branch '${name}' not found.`);
151
+ }
152
+ }
153
+ async function getLastCommitMessage(branch, cwd) {
154
+ try {
155
+ const { stdout } = await execa(
156
+ "git",
157
+ ["log", "-1", "--format=%s", branch],
158
+ { cwd }
159
+ );
160
+ const message = stdout.trim();
161
+ if (!message) {
162
+ throw new DubError(`Branch '${branch}' has no commits.`);
163
+ }
164
+ return message;
165
+ } catch (error) {
166
+ if (error instanceof DubError) throw error;
167
+ throw new DubError(`Failed to read commit message for '${branch}'.`);
168
+ }
169
+ }
170
+ async function pushBranch(branch, cwd) {
171
+ try {
172
+ await execa("git", ["push", "--force-with-lease", "origin", branch], {
173
+ cwd
174
+ });
175
+ } catch {
176
+ throw new DubError(
177
+ `Failed to push '${branch}'. The remote ref may have been updated by someone else.`
178
+ );
179
+ }
180
+ }
181
+ async function stageAll(cwd) {
182
+ try {
183
+ await execa("git", ["add", "-A"], { cwd });
184
+ } catch {
185
+ throw new DubError("Failed to stage changes.");
186
+ }
187
+ }
188
+ async function hasStagedChanges(cwd) {
189
+ try {
190
+ await execa("git", ["diff", "--cached", "--quiet"], { cwd });
191
+ return false;
192
+ } catch (error) {
193
+ const exitCode = error.exitCode;
194
+ if (exitCode === 1) return true;
195
+ throw new DubError("Failed to check staged changes.");
196
+ }
197
+ }
198
+ async function commitStaged(message, cwd) {
199
+ try {
200
+ await execa("git", ["commit", "-m", message], { cwd });
201
+ } catch {
202
+ throw new DubError(
203
+ `Commit failed. Ensure there are staged changes and git hooks pass.`
204
+ );
205
+ }
206
+ }
207
+ async function listBranches(cwd) {
208
+ try {
209
+ const { stdout } = await execa(
210
+ "git",
211
+ ["branch", "--format=%(refname:short)"],
212
+ { cwd }
213
+ );
214
+ return stdout.trim().split("\n").filter(Boolean);
215
+ } catch {
216
+ throw new DubError("Failed to list branches.");
217
+ }
218
+ }
219
+
220
+ // src/lib/state.ts
221
+ import * as crypto from "crypto";
222
+ import * as fs from "fs";
223
+ import * as path from "path";
224
+ async function getStatePath(cwd) {
225
+ const root = await getRepoRoot(cwd);
226
+ return path.join(root, ".git", "dubstack", "state.json");
227
+ }
228
+ async function getDubDir(cwd) {
229
+ const root = await getRepoRoot(cwd);
230
+ return path.join(root, ".git", "dubstack");
231
+ }
232
+ async function readState(cwd) {
233
+ const statePath = await getStatePath(cwd);
234
+ if (!fs.existsSync(statePath)) {
235
+ throw new DubError("DubStack is not initialized. Run 'dub init' first.");
236
+ }
237
+ try {
238
+ const raw = fs.readFileSync(statePath, "utf-8");
239
+ return JSON.parse(raw);
240
+ } catch {
241
+ throw new DubError(
242
+ "State file is corrupted. Delete .git/dubstack and run 'dub init' to re-initialize."
243
+ );
244
+ }
245
+ }
246
+ async function writeState(state, cwd) {
247
+ const statePath = await getStatePath(cwd);
248
+ const dir = path.dirname(statePath);
249
+ if (!fs.existsSync(dir)) {
250
+ fs.mkdirSync(dir, { recursive: true });
251
+ }
252
+ fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}
253
+ `);
254
+ }
255
+ async function initState(cwd) {
256
+ const statePath = await getStatePath(cwd);
257
+ const dir = path.dirname(statePath);
258
+ if (fs.existsSync(statePath)) {
259
+ return "already_exists";
260
+ }
261
+ fs.mkdirSync(dir, { recursive: true });
262
+ const emptyState = { stacks: [] };
263
+ fs.writeFileSync(statePath, `${JSON.stringify(emptyState, null, 2)}
264
+ `);
265
+ return "created";
266
+ }
267
+ async function ensureState(cwd) {
268
+ try {
269
+ return await readState(cwd);
270
+ } catch (error) {
271
+ if (error instanceof DubError && error.message.includes("not initialized")) {
272
+ await initState(cwd);
273
+ return await readState(cwd);
274
+ }
275
+ throw error;
276
+ }
277
+ }
278
+ function findStackForBranch(state, name) {
279
+ return state.stacks.find(
280
+ (stack) => stack.branches.some((b) => b.name === name)
281
+ );
282
+ }
283
+ function addBranchToStack(state, child, parent) {
284
+ if (findStackForBranch(state, child)) {
285
+ throw new DubError(`Branch '${child}' is already tracked in a stack.`);
286
+ }
287
+ const childBranch = {
288
+ name: child,
289
+ parent,
290
+ pr_number: null,
291
+ pr_link: null
292
+ };
293
+ const existingStack = findStackForBranch(state, parent);
294
+ if (existingStack) {
295
+ existingStack.branches.push(childBranch);
296
+ } else {
297
+ const rootBranch = {
298
+ name: parent,
299
+ type: "root",
300
+ parent: null,
301
+ pr_number: null,
302
+ pr_link: null
303
+ };
304
+ state.stacks.push({
305
+ id: crypto.randomUUID(),
306
+ branches: [rootBranch, childBranch]
307
+ });
308
+ }
309
+ }
310
+ function topologicalOrder(stack) {
311
+ const result = [];
312
+ const root = stack.branches.find((b) => b.type === "root");
313
+ if (!root) return result;
314
+ const childMap = /* @__PURE__ */ new Map();
315
+ for (const branch of stack.branches) {
316
+ if (branch.parent) {
317
+ const children = childMap.get(branch.parent) ?? [];
318
+ children.push(branch);
319
+ childMap.set(branch.parent, children);
320
+ }
321
+ }
322
+ const queue = [root];
323
+ while (queue.length > 0) {
324
+ const current = queue.shift();
325
+ if (!current) break;
326
+ result.push(current);
327
+ const children = childMap.get(current.name) ?? [];
328
+ queue.push(...children);
329
+ }
330
+ return result;
331
+ }
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);
400
+ }
401
+ }
402
+
403
+ // src/lib/undo-log.ts
404
+ import * as fs2 from "fs";
405
+ import * as path2 from "path";
406
+ async function getUndoPath(cwd) {
407
+ const dubDir = await getDubDir(cwd);
408
+ return path2.join(dubDir, "undo.json");
409
+ }
410
+ async function saveUndoEntry(entry, cwd) {
411
+ const undoPath = await getUndoPath(cwd);
412
+ fs2.writeFileSync(undoPath, `${JSON.stringify(entry, null, 2)}
413
+ `);
414
+ }
415
+ async function readUndoEntry(cwd) {
416
+ const undoPath = await getUndoPath(cwd);
417
+ if (!fs2.existsSync(undoPath)) {
418
+ throw new DubError("Nothing to undo.");
419
+ }
420
+ const raw = fs2.readFileSync(undoPath, "utf-8");
421
+ return JSON.parse(raw);
422
+ }
423
+ async function clearUndoEntry(cwd) {
424
+ const undoPath = await getUndoPath(cwd);
425
+ if (fs2.existsSync(undoPath)) {
426
+ fs2.unlinkSync(undoPath);
427
+ }
428
+ }
429
+
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.");
434
+ }
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.`);
439
+ }
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);
447
+ }
448
+ }
449
+ await saveUndoEntry(
450
+ {
451
+ operation: "create",
452
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
453
+ previousBranch: parent,
454
+ previousState: structuredClone(state),
455
+ branchTips: {},
456
+ createdBranches: [name]
457
+ },
458
+ cwd
459
+ );
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 };
475
+ }
476
+
477
+ // src/commands/init.ts
478
+ import * as fs3 from "fs";
479
+ import * as path3 from "path";
480
+ async function init(cwd) {
481
+ if (!await isGitRepo(cwd)) {
482
+ throw new DubError(
483
+ "Not a git repository. Run this command inside a git repo."
484
+ );
485
+ }
486
+ const status = await initState(cwd);
487
+ const repoRoot = await getRepoRoot(cwd);
488
+ const gitignorePath = path3.join(repoRoot, ".gitignore");
489
+ const entry = ".git/dubstack";
490
+ let gitignoreUpdated = false;
491
+ if (fs3.existsSync(gitignorePath)) {
492
+ const content = fs3.readFileSync(gitignorePath, "utf-8");
493
+ const lines = content.split("\n");
494
+ if (!lines.some((line) => line.trim() === entry)) {
495
+ const separator = content.endsWith("\n") ? "" : "\n";
496
+ fs3.writeFileSync(gitignorePath, `${content}${separator}${entry}
497
+ `);
498
+ gitignoreUpdated = true;
499
+ }
500
+ } else {
501
+ fs3.writeFileSync(gitignorePath, `${entry}
502
+ `);
503
+ gitignoreUpdated = true;
504
+ }
505
+ return { status, gitignoreUpdated };
506
+ }
507
+
508
+ // src/commands/log.ts
509
+ async function log(cwd) {
510
+ const state = await readState(cwd);
511
+ if (state.stacks.length === 0) {
512
+ return "No stacks. Run 'dub create' to start.";
513
+ }
514
+ let currentBranch = null;
515
+ try {
516
+ currentBranch = await getCurrentBranch(cwd);
517
+ } catch {
518
+ }
519
+ const sections = [];
520
+ for (const stack of state.stacks) {
521
+ const tree = await renderStack(stack, currentBranch, cwd);
522
+ sections.push(tree);
523
+ }
524
+ return sections.join("\n\n");
525
+ }
526
+ async function renderStack(stack, currentBranch, cwd) {
527
+ const root = stack.branches.find((b) => b.type === "root");
528
+ if (!root) return "";
529
+ const childMap = /* @__PURE__ */ new Map();
530
+ for (const branch of stack.branches) {
531
+ if (branch.parent) {
532
+ const children = childMap.get(branch.parent) ?? [];
533
+ children.push(branch);
534
+ childMap.set(branch.parent, children);
535
+ }
536
+ }
537
+ const lines = [];
538
+ await renderNode(root, currentBranch, childMap, "", true, true, lines, cwd);
539
+ return lines.join("\n");
540
+ }
541
+ async function renderNode(branch, currentBranch, childMap, prefix, isRoot, isLast, lines, cwd) {
542
+ let label;
543
+ const exists = await branchExists(branch.name, cwd);
544
+ if (isRoot) {
545
+ label = `(${branch.name})`;
546
+ } else if (branch.name === currentBranch) {
547
+ label = `*${branch.name} (Current)*`;
548
+ } else if (!exists) {
549
+ label = `${branch.name} \u26A0 (missing)`;
550
+ } else {
551
+ label = branch.name;
552
+ }
553
+ if (isRoot) {
554
+ lines.push(label);
555
+ } else {
556
+ const connector = isLast ? "\u2514\u2500 " : "\u251C\u2500 ";
557
+ lines.push(`${prefix}${connector}${label}`);
558
+ }
559
+ const children = childMap.get(branch.name) ?? [];
560
+ const childPrefix = isRoot ? " " : `${prefix}${isLast ? " " : "\u2502 "}`;
561
+ for (let i = 0; i < children.length; i++) {
562
+ const isChildLast = i === children.length - 1;
563
+ await renderNode(
564
+ children[i],
565
+ currentBranch,
566
+ childMap,
567
+ childPrefix,
568
+ false,
569
+ isChildLast,
570
+ lines,
571
+ cwd
572
+ );
573
+ }
574
+ }
575
+
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)) {
582
+ throw new DubError(
583
+ "Working tree has uncommitted changes. Commit or stash them before restacking."
584
+ );
585
+ }
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
+ );
592
+ }
593
+ const allBranches = targetStacks.flatMap((s) => s.branches);
594
+ for (const branch of allBranches) {
595
+ if (!await branchExists(branch.name, cwd)) {
596
+ 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.`
599
+ );
600
+ }
601
+ }
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);
624
+ }
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";
634
+ }
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;
649
+ }
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;
662
+ }
663
+ }
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
+ };
673
+ }
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)
681
+ );
682
+ return stack ? [stack] : [];
683
+ }
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
+ });
697
+ }
698
+ }
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);
720
+ }
721
+ }
722
+
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
+ // src/lib/github.ts
729
+ import { execa as execa2 } from "execa";
730
+ async function ensureGhInstalled() {
731
+ try {
732
+ await execa2("gh", ["--version"]);
733
+ } catch {
734
+ throw new DubError("gh CLI not found. Install it: https://cli.github.com");
735
+ }
736
+ }
737
+ async function checkGhAuth() {
738
+ try {
739
+ await execa2("gh", ["auth", "status"]);
740
+ } catch {
741
+ throw new DubError("Not authenticated with GitHub. Run 'gh auth login'.");
742
+ }
743
+ }
744
+ async function getPr(branch, cwd) {
745
+ const { stdout } = await execa2(
746
+ "gh",
747
+ [
748
+ "pr",
749
+ "list",
750
+ "--head",
751
+ branch,
752
+ "--state",
753
+ "open",
754
+ "--json",
755
+ "number,url,title,body",
756
+ "--jq",
757
+ ".[0]"
758
+ ],
759
+ { cwd }
760
+ );
761
+ const trimmed = stdout.trim();
762
+ if (!trimmed || trimmed === "null") return null;
763
+ try {
764
+ return JSON.parse(trimmed);
765
+ } catch {
766
+ throw new DubError(`Failed to parse PR info for branch '${branch}'.`);
767
+ }
768
+ }
769
+ async function createPr(branch, base, title, bodyFile, cwd) {
770
+ let stdout;
771
+ try {
772
+ const result = await execa2(
773
+ "gh",
774
+ [
775
+ "pr",
776
+ "create",
777
+ "--head",
778
+ branch,
779
+ "--base",
780
+ base,
781
+ "--title",
782
+ title,
783
+ "--body-file",
784
+ bodyFile
785
+ ],
786
+ { cwd }
787
+ );
788
+ stdout = result.stdout;
789
+ } catch (error) {
790
+ const message = error instanceof Error ? error.message : String(error);
791
+ if (message.includes("403") || message.includes("insufficient")) {
792
+ throw new DubError(
793
+ "GitHub token lacks required permissions. Run 'gh auth login' with the 'repo' scope."
794
+ );
795
+ }
796
+ throw new DubError(`Failed to create PR for '${branch}': ${message}`);
797
+ }
798
+ const url = stdout.trim();
799
+ const numberMatch = url.match(/\/pull\/(\d+)$/);
800
+ if (!numberMatch) {
801
+ throw new DubError(`Unexpected output from 'gh pr create': ${url}`);
802
+ }
803
+ return {
804
+ number: Number.parseInt(numberMatch[1], 10),
805
+ url,
806
+ title,
807
+ body: ""
808
+ };
809
+ }
810
+ async function updatePrBody(prNumber, bodyFile, cwd) {
811
+ try {
812
+ await execa2(
813
+ "gh",
814
+ ["pr", "edit", String(prNumber), "--body-file", bodyFile],
815
+ { cwd }
816
+ );
817
+ } catch (error) {
818
+ const message = error instanceof Error ? error.message : String(error);
819
+ if (message.includes("403") || message.includes("insufficient")) {
820
+ throw new DubError(
821
+ "GitHub token lacks required permissions. Run 'gh auth login' with the 'repo' scope."
822
+ );
823
+ }
824
+ throw new DubError(`Failed to update PR #${prNumber}: ${message}`);
825
+ }
826
+ }
827
+
828
+ // src/lib/pr-body.ts
829
+ var DUBSTACK_START = "<!-- dubstack:start -->";
830
+ var DUBSTACK_END = "<!-- dubstack:end -->";
831
+ var METADATA_START = "<!-- dubstack-metadata";
832
+ var METADATA_END = "-->";
833
+ function buildStackTable(orderedBranches, prMap, currentBranch) {
834
+ const lines = orderedBranches.map((branch) => {
835
+ const entry = prMap.get(branch.name);
836
+ if (!entry) return `- ${branch.name}`;
837
+ const marker = branch.name === currentBranch ? " \u{1F448}" : "";
838
+ return `- #${entry.number} ${entry.title}${marker}`;
839
+ });
840
+ return [
841
+ DUBSTACK_START,
842
+ "---",
843
+ "### \u{1F95E} DubStack",
844
+ ...lines,
845
+ DUBSTACK_END
846
+ ].join("\n");
847
+ }
848
+ function buildMetadataBlock(stackId, prNumber, prevPr, nextPr, branch) {
849
+ const metadata = {
850
+ stack_id: stackId,
851
+ pr_number: prNumber,
852
+ prev_pr: prevPr,
853
+ next_pr: nextPr,
854
+ branch
855
+ };
856
+ return `${METADATA_START}
857
+ ${JSON.stringify(metadata, null, 2)}
858
+ ${METADATA_END}`;
859
+ }
860
+ function stripDubstackSections(body) {
861
+ let result = body;
862
+ const startIdx = result.indexOf(DUBSTACK_START);
863
+ const endIdx = result.indexOf(DUBSTACK_END);
864
+ if (startIdx !== -1 && endIdx !== -1) {
865
+ result = result.slice(0, startIdx) + result.slice(endIdx + DUBSTACK_END.length);
866
+ }
867
+ const metaStart = result.indexOf(METADATA_START);
868
+ if (metaStart !== -1) {
869
+ const metaEnd = result.indexOf(
870
+ METADATA_END,
871
+ metaStart + METADATA_START.length
872
+ );
873
+ if (metaEnd !== -1) {
874
+ result = result.slice(0, metaStart) + result.slice(metaEnd + METADATA_END.length);
875
+ }
876
+ }
877
+ return result.trimEnd();
878
+ }
879
+ function composePrBody(existingBody, stackTable, metadataBlock) {
880
+ const userContent = stripDubstackSections(existingBody);
881
+ const parts = [userContent, stackTable, metadataBlock].filter(Boolean);
882
+ return parts.join("\n\n");
883
+ }
884
+
885
+ // src/commands/submit.ts
886
+ async function submit(cwd, dryRun) {
887
+ await ensureGhInstalled();
888
+ await checkGhAuth();
889
+ const state = await readState(cwd);
890
+ const currentBranch = await getCurrentBranch(cwd);
891
+ const stack = findStackForBranch(state, currentBranch);
892
+ if (!stack) {
893
+ throw new DubError(
894
+ `Branch '${currentBranch}' is not part of any stack. Run 'dub create' first.`
895
+ );
896
+ }
897
+ const ordered = topologicalOrder(stack);
898
+ const currentEntry = ordered.find((b) => b.name === currentBranch);
899
+ if (currentEntry?.type === "root") {
900
+ throw new DubError(
901
+ "Cannot submit from a root branch. Checkout a stack branch first."
902
+ );
903
+ }
904
+ const nonRootBranches = ordered.filter((b) => b.type !== "root");
905
+ validateLinearStack(ordered);
906
+ const result = { pushed: [], created: [], updated: [] };
907
+ const prMap = /* @__PURE__ */ new Map();
908
+ for (const branch of nonRootBranches) {
909
+ if (dryRun) {
910
+ console.log(`[dry-run] would push ${branch.name}`);
911
+ } else {
912
+ await pushBranch(branch.name, cwd);
913
+ }
914
+ result.pushed.push(branch.name);
915
+ }
916
+ for (const branch of nonRootBranches) {
917
+ const base = branch.parent;
918
+ if (dryRun) {
919
+ console.log(`[dry-run] would check/create PR: ${branch.name} \u2192 ${base}`);
920
+ continue;
921
+ }
922
+ const existing = await getPr(branch.name, cwd);
923
+ if (existing) {
924
+ prMap.set(branch.name, existing);
925
+ result.updated.push(branch.name);
926
+ } else {
927
+ const title = await getLastCommitMessage(branch.name, cwd);
928
+ const tmpFile = writeTempBody("");
929
+ try {
930
+ const created = await createPr(branch.name, base, title, tmpFile, cwd);
931
+ prMap.set(branch.name, created);
932
+ result.created.push(branch.name);
933
+ } finally {
934
+ cleanupTempFile(tmpFile);
935
+ }
936
+ }
937
+ }
938
+ if (!dryRun) {
939
+ await updateAllPrBodies(nonRootBranches, prMap, stack.id, cwd);
940
+ for (const branch of nonRootBranches) {
941
+ const pr = prMap.get(branch.name);
942
+ if (pr) {
943
+ const stateBranch = stack.branches.find((b) => b.name === branch.name);
944
+ if (stateBranch) {
945
+ stateBranch.pr_number = pr.number;
946
+ stateBranch.pr_link = pr.url;
947
+ }
948
+ }
949
+ }
950
+ await writeState(state, cwd);
951
+ }
952
+ return result;
953
+ }
954
+ function validateLinearStack(ordered) {
955
+ const childCount = /* @__PURE__ */ new Map();
956
+ for (const branch of ordered) {
957
+ if (branch.parent) {
958
+ childCount.set(branch.parent, (childCount.get(branch.parent) ?? 0) + 1);
959
+ }
960
+ }
961
+ for (const [parent, count] of childCount) {
962
+ if (count > 1) {
963
+ throw new DubError(
964
+ `Branch '${parent}' has ${count} children. Branching stacks are not supported by submit. Ensure each branch has at most one child.`
965
+ );
966
+ }
967
+ }
968
+ }
969
+ async function updateAllPrBodies(branches, prMap, stackId, cwd) {
970
+ const tableEntries = /* @__PURE__ */ new Map();
971
+ 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 });
975
+ }
976
+ }
977
+ for (let i = 0; i < branches.length; i++) {
978
+ const branch = branches[i];
979
+ const pr = prMap.get(branch.name);
980
+ if (!pr) continue;
981
+ const prevPr = i > 0 ? prMap.get(branches[i - 1].name)?.number ?? null : null;
982
+ const nextPr = i < branches.length - 1 ? prMap.get(branches[i + 1].name)?.number ?? null : null;
983
+ const stackTable = buildStackTable(branches, tableEntries, branch.name);
984
+ const metadataBlock = buildMetadataBlock(
985
+ stackId,
986
+ pr.number,
987
+ prevPr,
988
+ nextPr,
989
+ branch.name
990
+ );
991
+ const existingBody = pr.body;
992
+ const finalBody = composePrBody(existingBody, stackTable, metadataBlock);
993
+ const tmpFile = writeTempBody(finalBody);
994
+ try {
995
+ await updatePrBody(pr.number, tmpFile, cwd);
996
+ } finally {
997
+ cleanupTempFile(tmpFile);
998
+ }
999
+ }
1000
+ }
1001
+ function writeTempBody(content) {
1002
+ const tmpDir = os.tmpdir();
1003
+ const tmpFile = path5.join(tmpDir, `dubstack-body-${Date.now()}.md`);
1004
+ fs5.writeFileSync(tmpFile, content);
1005
+ return tmpFile;
1006
+ }
1007
+ function cleanupTempFile(filePath) {
1008
+ try {
1009
+ fs5.unlinkSync(filePath);
1010
+ } catch {
1011
+ }
1012
+ }
1013
+
1014
+ // src/commands/undo.ts
1015
+ async function undo(cwd) {
1016
+ const entry = await readUndoEntry(cwd);
1017
+ if (!await isWorkingTreeClean(cwd)) {
1018
+ throw new DubError(
1019
+ "Working tree has uncommitted changes. Commit or stash them before undoing."
1020
+ );
1021
+ }
1022
+ const currentBranch = await getCurrentBranch(cwd);
1023
+ if (entry.operation === "create") {
1024
+ const needsCheckout = entry.createdBranches.includes(currentBranch);
1025
+ if (needsCheckout) {
1026
+ await checkoutBranch(entry.previousBranch, cwd);
1027
+ }
1028
+ for (const branch of entry.createdBranches) {
1029
+ await deleteBranch(branch, cwd);
1030
+ }
1031
+ if (!needsCheckout && currentBranch !== entry.previousBranch) {
1032
+ await checkoutBranch(entry.previousBranch, cwd);
1033
+ }
1034
+ await writeState(entry.previousState, cwd);
1035
+ await clearUndoEntry(cwd);
1036
+ return {
1037
+ undone: "create",
1038
+ details: `Deleted branch${entry.createdBranches.length > 1 ? "es" : ""} '${entry.createdBranches.join("', '")}'`
1039
+ };
1040
+ }
1041
+ await checkoutBranch(entry.previousBranch, cwd);
1042
+ for (const [name, sha] of Object.entries(entry.branchTips)) {
1043
+ if (name === entry.previousBranch) continue;
1044
+ await forceBranchTo(name, sha, cwd);
1045
+ }
1046
+ if (entry.branchTips[entry.previousBranch]) {
1047
+ await forceBranchTo(
1048
+ entry.previousBranch,
1049
+ entry.branchTips[entry.previousBranch],
1050
+ cwd
1051
+ );
1052
+ }
1053
+ await writeState(entry.previousState, cwd);
1054
+ await clearUndoEntry(cwd);
1055
+ return {
1056
+ undone: "restack",
1057
+ details: `Reset ${Object.keys(entry.branchTips).length} branches to pre-restack state`
1058
+ };
1059
+ }
1060
+
1061
+ // src/index.ts
1062
+ var require2 = createRequire(import.meta.url);
1063
+ var { version } = require2("../package.json");
1064
+ var program = new Command();
1065
+ program.name("dub").description("Manage stacked diffs (dependent git branches) with ease").version(version);
1066
+ program.command("init").description("Initialize DubStack in the current git repository").addHelpText(
1067
+ "after",
1068
+ `
55
1069
  Examples:
56
- $ dub create feat/api-endpoint Create a branch on top of current branch
57
- $ dub create feat/ui-component Stack another branch on top`)
58
- .action(async (branchName) => {
59
- const result = await create(branchName, process.cwd());
60
- console.log(chalk.green(`✔ Created branch '${result.branch}' on top of '${result.parent}'`));
1070
+ $ dub init Initialize DubStack, creating .git/dubstack/ and updating .gitignore`
1071
+ ).action(async () => {
1072
+ const result = await init(process.cwd());
1073
+ if (result.status === "created") {
1074
+ console.log(chalk.green("\u2714 DubStack initialized"));
1075
+ } else {
1076
+ console.log(chalk.yellow("\u26A0 DubStack already initialized"));
1077
+ }
61
1078
  });
62
- program
63
- .command("log")
64
- .description("Display an ASCII tree of the current stack")
65
- .addHelpText("after", `
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(
1080
+ "after",
1081
+ `
66
1082
  Examples:
67
- $ dub log Show the branch tree with current branch highlighted`)
68
- .action(async () => {
69
- const output = await log(process.cwd());
70
- // Apply chalk styling to the output
71
- const styled = output
72
- .replace(/\*(.+?) \(Current\)\*/g, chalk.bold.cyan("$1 (Current)"))
73
- .replace(/⚠ \(missing\)/g, chalk.yellow("⚠ (missing)"));
74
- console.log(styled);
1083
+ $ dub create feat/api Create branch only
1084
+ $ dub create feat/api -m "feat: add API" Create branch + commit staged
1085
+ $ dub create feat/api -am "feat: add API" Stage all + create + commit`
1086
+ ).action(
1087
+ async (branchName, options) => {
1088
+ const result = await create(branchName, process.cwd(), {
1089
+ message: options.message,
1090
+ all: options.all
1091
+ });
1092
+ if (result.committed) {
1093
+ console.log(
1094
+ chalk.green(
1095
+ `\u2714 Created '${result.branch}' on '${result.parent}' \u2022 ${result.committed}`
1096
+ )
1097
+ );
1098
+ } else {
1099
+ console.log(
1100
+ chalk.green(
1101
+ `\u2714 Created branch '${result.branch}' on top of '${result.parent}'`
1102
+ )
1103
+ );
1104
+ }
1105
+ }
1106
+ );
1107
+ program.command("log").description("Display an ASCII tree of the current stack").addHelpText(
1108
+ "after",
1109
+ `
1110
+ Examples:
1111
+ $ dub log Show the branch tree with current branch highlighted`
1112
+ ).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);
75
1116
  });
76
- program
77
- .command("restack")
78
- .description("Rebase all branches in the stack onto their updated parents")
79
- .option("--continue", "Continue restacking after resolving conflicts")
80
- .addHelpText("after", `
1117
+ program.command("restack").description("Rebase all branches in the stack onto their updated parents").option("--continue", "Continue restacking after resolving conflicts").addHelpText(
1118
+ "after",
1119
+ `
81
1120
  Examples:
82
1121
  $ dub restack Rebase the current stack
83
- $ dub restack --continue Continue after resolving conflicts`)
84
- .action(async (options) => {
85
- const result = options.continue
86
- ? await restackContinue(process.cwd())
87
- : await restack(process.cwd());
88
- if (result.status === "up-to-date") {
89
- console.log(chalk.green("✔ Stack is already up to date"));
90
- }
91
- else if (result.status === "conflict") {
92
- console.log(chalk.yellow(`⚠ Conflict while restacking '${result.conflictBranch}'`));
93
- console.log(chalk.dim(" Resolve conflicts, stage changes, then run: dub restack --continue"));
94
- }
95
- else {
96
- console.log(chalk.green(`✔ Restacked ${result.rebased.length} branch(es)`));
97
- for (const branch of result.rebased) {
98
- console.log(chalk.dim(` ↳ ${branch}`));
99
- }
1122
+ $ dub restack --continue Continue after resolving conflicts`
1123
+ ).action(async (options) => {
1124
+ const result = options.continue ? await restackContinue(process.cwd()) : await restack(process.cwd());
1125
+ if (result.status === "up-to-date") {
1126
+ console.log(chalk.green("\u2714 Stack is already up to date"));
1127
+ } else if (result.status === "conflict") {
1128
+ console.log(
1129
+ chalk.yellow(`\u26A0 Conflict while restacking '${result.conflictBranch}'`)
1130
+ );
1131
+ console.log(
1132
+ chalk.dim(
1133
+ " Resolve conflicts, stage changes, then run: dub restack --continue"
1134
+ )
1135
+ );
1136
+ } else {
1137
+ console.log(
1138
+ chalk.green(`\u2714 Restacked ${result.rebased.length} branch(es)`)
1139
+ );
1140
+ for (const branch of result.rebased) {
1141
+ console.log(chalk.dim(` \u21B3 ${branch}`));
100
1142
  }
1143
+ }
101
1144
  });
102
- program
103
- .command("undo")
104
- .description("Undo the last dub create or dub restack operation")
105
- .addHelpText("after", `
1145
+ program.command("undo").description("Undo the last dub create or dub restack operation").addHelpText(
1146
+ "after",
1147
+ `
106
1148
  Examples:
107
- $ dub undo Roll back the last dub operation`)
108
- .action(async () => {
109
- const result = await undo(process.cwd());
110
- console.log(chalk.green(`✔ Undid '${result.undone}': ${result.details}`));
1149
+ $ dub undo Roll back the last dub operation`
1150
+ ).action(async () => {
1151
+ const result = await undo(process.cwd());
1152
+ console.log(chalk.green(`\u2714 Undid '${result.undone}': ${result.details}`));
111
1153
  });
112
- async function main() {
113
- try {
114
- await program.parseAsync(process.argv);
1154
+ program.command("submit").description(
1155
+ "Push branches and create/update GitHub PRs for the current stack"
1156
+ ).option("--dry-run", "Print what would happen without executing").addHelpText(
1157
+ "after",
1158
+ `
1159
+ Examples:
1160
+ $ dub submit Push and create/update PRs
1161
+ $ dub submit --dry-run Preview what would happen`
1162
+ ).action(runSubmit);
1163
+ 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}'`));
115
1172
  }
116
- catch (error) {
117
- if (error instanceof DubError) {
118
- console.error(chalk.red(`✖ ${error.message}`));
119
- process.exit(1);
120
- }
121
- throw error;
1173
+ }
1174
+ });
1175
+ async function runSubmit(options) {
1176
+ const result = await submit(process.cwd(), options.dryRun ?? false);
1177
+ if (result.pushed.length > 0) {
1178
+ console.log(
1179
+ chalk.green(
1180
+ `\u2714 Pushed ${result.pushed.length} branch(es), created ${result.created.length} PR(s), updated ${result.updated.length} PR(s)`
1181
+ )
1182
+ );
1183
+ for (const branch of [...result.created, ...result.updated]) {
1184
+ console.log(chalk.dim(` \u21B3 ${branch}`));
1185
+ }
1186
+ }
1187
+ }
1188
+ async function main() {
1189
+ try {
1190
+ await program.parseAsync(process.argv);
1191
+ } catch (error) {
1192
+ if (error instanceof DubError) {
1193
+ console.error(chalk.red(`\u2716 ${error.message}`));
1194
+ process.exit(1);
122
1195
  }
1196
+ throw error;
1197
+ }
123
1198
  }
124
1199
  main();
125
1200
  //# sourceMappingURL=index.js.map