dubstack 0.1.3 → 0.2.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 +1084 -105
  3. package/dist/index.js.map +1 -1
  4. package/package.json +3 -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,1104 @@
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/lib/errors.ts
9
+ var DubError = class extends Error {
10
+ constructor(message) {
11
+ super(message);
12
+ this.name = "DubError";
13
+ }
14
+ };
15
+
16
+ // src/lib/git.ts
17
+ import { execa } from "execa";
18
+ async function isGitRepo(cwd) {
19
+ try {
20
+ const { stdout } = await execa(
21
+ "git",
22
+ ["rev-parse", "--is-inside-work-tree"],
23
+ { cwd }
24
+ );
25
+ return stdout.trim() === "true";
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
30
+ async function getRepoRoot(cwd) {
31
+ try {
32
+ const { stdout } = await execa("git", ["rev-parse", "--show-toplevel"], {
33
+ cwd
34
+ });
35
+ return stdout.trim();
36
+ } catch {
37
+ throw new DubError(
38
+ "Not a git repository. Run this command inside a git repo."
39
+ );
40
+ }
41
+ }
42
+ async function getCurrentBranch(cwd) {
43
+ try {
44
+ const { stdout } = await execa(
45
+ "git",
46
+ ["rev-parse", "--abbrev-ref", "HEAD"],
47
+ { cwd }
48
+ );
49
+ const branch = stdout.trim();
50
+ if (branch === "HEAD") {
51
+ throw new DubError(
52
+ "HEAD is detached. Checkout a branch before running this command."
53
+ );
45
54
  }
46
- else {
47
- console.log(chalk.yellow("⚠ DubStack already initialized"));
55
+ return branch;
56
+ } catch (error) {
57
+ if (error instanceof DubError) throw error;
58
+ throw new DubError(
59
+ "Repository has no commits. Make at least one commit first."
60
+ );
61
+ }
62
+ }
63
+ async function branchExists(name, cwd) {
64
+ try {
65
+ await execa("git", ["rev-parse", "--verify", `refs/heads/${name}`], {
66
+ cwd
67
+ });
68
+ return true;
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+ async function createBranch(name, cwd) {
74
+ if (await branchExists(name, cwd)) {
75
+ throw new DubError(`Branch '${name}' already exists.`);
76
+ }
77
+ await execa("git", ["checkout", "-b", name], { cwd });
78
+ }
79
+ async function checkoutBranch(name, cwd) {
80
+ try {
81
+ await execa("git", ["checkout", name], { cwd });
82
+ } catch {
83
+ throw new DubError(`Branch '${name}' not found.`);
84
+ }
85
+ }
86
+ async function deleteBranch(name, cwd) {
87
+ try {
88
+ await execa("git", ["branch", "-D", name], { cwd });
89
+ } catch {
90
+ throw new DubError(`Failed to delete branch '${name}'. It may not exist.`);
91
+ }
92
+ }
93
+ async function forceBranchTo(name, sha, cwd) {
94
+ try {
95
+ const current = await getCurrentBranch(cwd).catch(() => null);
96
+ if (current === name) {
97
+ await execa("git", ["reset", "--hard", sha], { cwd });
98
+ } else {
99
+ await execa("git", ["branch", "-f", name, sha], { cwd });
48
100
  }
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", `
101
+ } catch (error) {
102
+ if (error instanceof DubError) throw error;
103
+ throw new DubError(`Failed to reset branch '${name}' to ${sha}.`);
104
+ }
105
+ }
106
+ async function isWorkingTreeClean(cwd) {
107
+ const { stdout } = await execa("git", ["status", "--porcelain"], { cwd });
108
+ return stdout.trim() === "";
109
+ }
110
+ async function rebaseOnto(newBase, oldBase, branch, cwd) {
111
+ try {
112
+ await execa("git", ["rebase", "--onto", newBase, oldBase, branch], { cwd });
113
+ } catch {
114
+ throw new DubError(
115
+ `Conflict while restacking '${branch}'.
116
+ Resolve conflicts, stage changes, then run: dub restack --continue`
117
+ );
118
+ }
119
+ }
120
+ async function rebaseContinue(cwd) {
121
+ try {
122
+ await execa("git", ["rebase", "--continue"], {
123
+ cwd,
124
+ env: { GIT_EDITOR: "true" }
125
+ });
126
+ } catch {
127
+ throw new DubError(
128
+ "Failed to continue rebase. Ensure all conflicts are resolved and staged."
129
+ );
130
+ }
131
+ }
132
+ async function getMergeBase(a, b, cwd) {
133
+ try {
134
+ const { stdout } = await execa("git", ["merge-base", a, b], { cwd });
135
+ return stdout.trim();
136
+ } catch {
137
+ throw new DubError(
138
+ `Could not find common ancestor between '${a}' and '${b}'.`
139
+ );
140
+ }
141
+ }
142
+ async function getBranchTip(name, cwd) {
143
+ try {
144
+ const { stdout } = await execa("git", ["rev-parse", name], { cwd });
145
+ return stdout.trim();
146
+ } catch {
147
+ throw new DubError(`Branch '${name}' not found.`);
148
+ }
149
+ }
150
+ async function getLastCommitMessage(branch, cwd) {
151
+ try {
152
+ const { stdout } = await execa(
153
+ "git",
154
+ ["log", "-1", "--format=%s", branch],
155
+ { cwd }
156
+ );
157
+ const message = stdout.trim();
158
+ if (!message) {
159
+ throw new DubError(`Branch '${branch}' has no commits.`);
160
+ }
161
+ return message;
162
+ } catch (error) {
163
+ if (error instanceof DubError) throw error;
164
+ throw new DubError(`Failed to read commit message for '${branch}'.`);
165
+ }
166
+ }
167
+ async function pushBranch(branch, cwd) {
168
+ try {
169
+ await execa("git", ["push", "--force-with-lease", "origin", branch], {
170
+ cwd
171
+ });
172
+ } catch {
173
+ throw new DubError(
174
+ `Failed to push '${branch}'. The remote ref may have been updated by someone else.`
175
+ );
176
+ }
177
+ }
178
+ async function stageAll(cwd) {
179
+ try {
180
+ await execa("git", ["add", "-A"], { cwd });
181
+ } catch {
182
+ throw new DubError("Failed to stage changes.");
183
+ }
184
+ }
185
+ async function hasStagedChanges(cwd) {
186
+ try {
187
+ await execa("git", ["diff", "--cached", "--quiet"], { cwd });
188
+ return false;
189
+ } catch (error) {
190
+ const exitCode = error.exitCode;
191
+ if (exitCode === 1) return true;
192
+ throw new DubError("Failed to check staged changes.");
193
+ }
194
+ }
195
+ async function commitStaged(message, cwd) {
196
+ try {
197
+ await execa("git", ["commit", "-m", message], { cwd });
198
+ } catch {
199
+ throw new DubError(
200
+ `Commit failed. Ensure there are staged changes and git hooks pass.`
201
+ );
202
+ }
203
+ }
204
+
205
+ // src/lib/state.ts
206
+ import * as crypto from "crypto";
207
+ import * as fs from "fs";
208
+ import * as path from "path";
209
+ async function getStatePath(cwd) {
210
+ const root = await getRepoRoot(cwd);
211
+ return path.join(root, ".git", "dubstack", "state.json");
212
+ }
213
+ async function getDubDir(cwd) {
214
+ const root = await getRepoRoot(cwd);
215
+ return path.join(root, ".git", "dubstack");
216
+ }
217
+ async function readState(cwd) {
218
+ const statePath = await getStatePath(cwd);
219
+ if (!fs.existsSync(statePath)) {
220
+ throw new DubError("DubStack is not initialized. Run 'dub init' first.");
221
+ }
222
+ try {
223
+ const raw = fs.readFileSync(statePath, "utf-8");
224
+ return JSON.parse(raw);
225
+ } catch {
226
+ throw new DubError(
227
+ "State file is corrupted. Delete .git/dubstack and run 'dub init' to re-initialize."
228
+ );
229
+ }
230
+ }
231
+ async function writeState(state, cwd) {
232
+ const statePath = await getStatePath(cwd);
233
+ const dir = path.dirname(statePath);
234
+ if (!fs.existsSync(dir)) {
235
+ fs.mkdirSync(dir, { recursive: true });
236
+ }
237
+ fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}
238
+ `);
239
+ }
240
+ async function initState(cwd) {
241
+ const statePath = await getStatePath(cwd);
242
+ const dir = path.dirname(statePath);
243
+ if (fs.existsSync(statePath)) {
244
+ return "already_exists";
245
+ }
246
+ fs.mkdirSync(dir, { recursive: true });
247
+ const emptyState = { stacks: [] };
248
+ fs.writeFileSync(statePath, `${JSON.stringify(emptyState, null, 2)}
249
+ `);
250
+ return "created";
251
+ }
252
+ async function ensureState(cwd) {
253
+ try {
254
+ return await readState(cwd);
255
+ } catch (error) {
256
+ if (error instanceof DubError && error.message.includes("not initialized")) {
257
+ await initState(cwd);
258
+ return await readState(cwd);
259
+ }
260
+ throw error;
261
+ }
262
+ }
263
+ function findStackForBranch(state, name) {
264
+ return state.stacks.find(
265
+ (stack) => stack.branches.some((b) => b.name === name)
266
+ );
267
+ }
268
+ function addBranchToStack(state, child, parent) {
269
+ if (findStackForBranch(state, child)) {
270
+ throw new DubError(`Branch '${child}' is already tracked in a stack.`);
271
+ }
272
+ const childBranch = {
273
+ name: child,
274
+ parent,
275
+ pr_number: null,
276
+ pr_link: null
277
+ };
278
+ const existingStack = findStackForBranch(state, parent);
279
+ if (existingStack) {
280
+ existingStack.branches.push(childBranch);
281
+ } else {
282
+ const rootBranch = {
283
+ name: parent,
284
+ type: "root",
285
+ parent: null,
286
+ pr_number: null,
287
+ pr_link: null
288
+ };
289
+ state.stacks.push({
290
+ id: crypto.randomUUID(),
291
+ branches: [rootBranch, childBranch]
292
+ });
293
+ }
294
+ }
295
+ function topologicalOrder(stack) {
296
+ const result = [];
297
+ const root = stack.branches.find((b) => b.type === "root");
298
+ if (!root) return result;
299
+ const childMap = /* @__PURE__ */ new Map();
300
+ for (const branch of stack.branches) {
301
+ if (branch.parent) {
302
+ const children = childMap.get(branch.parent) ?? [];
303
+ children.push(branch);
304
+ childMap.set(branch.parent, children);
305
+ }
306
+ }
307
+ const queue = [root];
308
+ while (queue.length > 0) {
309
+ const current = queue.shift();
310
+ if (!current) break;
311
+ result.push(current);
312
+ const children = childMap.get(current.name) ?? [];
313
+ queue.push(...children);
314
+ }
315
+ return result;
316
+ }
317
+
318
+ // src/lib/undo-log.ts
319
+ import * as fs2 from "fs";
320
+ import * as path2 from "path";
321
+ async function getUndoPath(cwd) {
322
+ const dubDir = await getDubDir(cwd);
323
+ return path2.join(dubDir, "undo.json");
324
+ }
325
+ async function saveUndoEntry(entry, cwd) {
326
+ const undoPath = await getUndoPath(cwd);
327
+ fs2.writeFileSync(undoPath, `${JSON.stringify(entry, null, 2)}
328
+ `);
329
+ }
330
+ async function readUndoEntry(cwd) {
331
+ const undoPath = await getUndoPath(cwd);
332
+ if (!fs2.existsSync(undoPath)) {
333
+ throw new DubError("Nothing to undo.");
334
+ }
335
+ const raw = fs2.readFileSync(undoPath, "utf-8");
336
+ return JSON.parse(raw);
337
+ }
338
+ async function clearUndoEntry(cwd) {
339
+ const undoPath = await getUndoPath(cwd);
340
+ if (fs2.existsSync(undoPath)) {
341
+ fs2.unlinkSync(undoPath);
342
+ }
343
+ }
344
+
345
+ // src/commands/create.ts
346
+ async function create(name, cwd, options) {
347
+ if (options?.all && !options.message) {
348
+ throw new DubError("'-a' requires '-m'. Pass a commit message.");
349
+ }
350
+ const state = await ensureState(cwd);
351
+ const parent = await getCurrentBranch(cwd);
352
+ if (await branchExists(name, cwd)) {
353
+ throw new DubError(`Branch '${name}' already exists.`);
354
+ }
355
+ if (options?.message) {
356
+ if (options.all) {
357
+ await stageAll(cwd);
358
+ }
359
+ if (!await hasStagedChanges(cwd)) {
360
+ const hint = options.all ? "No changes to commit." : "No staged changes. Stage files with 'git add' or use '-a' to stage all.";
361
+ throw new DubError(hint);
362
+ }
363
+ }
364
+ await saveUndoEntry(
365
+ {
366
+ operation: "create",
367
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
368
+ previousBranch: parent,
369
+ previousState: structuredClone(state),
370
+ branchTips: {},
371
+ createdBranches: [name]
372
+ },
373
+ cwd
374
+ );
375
+ await createBranch(name, cwd);
376
+ addBranchToStack(state, name, parent);
377
+ await writeState(state, cwd);
378
+ if (options?.message) {
379
+ try {
380
+ await commitStaged(options.message, cwd);
381
+ } catch (error) {
382
+ const reason = error instanceof DubError ? error.message : String(error);
383
+ throw new DubError(
384
+ `Branch '${name}' was created but commit failed: ${reason}. Run 'dub undo' to clean up.`
385
+ );
386
+ }
387
+ return { branch: name, parent, committed: options.message };
388
+ }
389
+ return { branch: name, parent };
390
+ }
391
+
392
+ // src/commands/init.ts
393
+ import * as fs3 from "fs";
394
+ import * as path3 from "path";
395
+ async function init(cwd) {
396
+ if (!await isGitRepo(cwd)) {
397
+ throw new DubError(
398
+ "Not a git repository. Run this command inside a git repo."
399
+ );
400
+ }
401
+ const status = await initState(cwd);
402
+ const repoRoot = await getRepoRoot(cwd);
403
+ const gitignorePath = path3.join(repoRoot, ".gitignore");
404
+ const entry = ".git/dubstack";
405
+ let gitignoreUpdated = false;
406
+ if (fs3.existsSync(gitignorePath)) {
407
+ const content = fs3.readFileSync(gitignorePath, "utf-8");
408
+ const lines = content.split("\n");
409
+ if (!lines.some((line) => line.trim() === entry)) {
410
+ const separator = content.endsWith("\n") ? "" : "\n";
411
+ fs3.writeFileSync(gitignorePath, `${content}${separator}${entry}
412
+ `);
413
+ gitignoreUpdated = true;
414
+ }
415
+ } else {
416
+ fs3.writeFileSync(gitignorePath, `${entry}
417
+ `);
418
+ gitignoreUpdated = true;
419
+ }
420
+ return { status, gitignoreUpdated };
421
+ }
422
+
423
+ // src/commands/log.ts
424
+ async function log(cwd) {
425
+ const state = await readState(cwd);
426
+ if (state.stacks.length === 0) {
427
+ return "No stacks. Run 'dub create' to start.";
428
+ }
429
+ let currentBranch = null;
430
+ try {
431
+ currentBranch = await getCurrentBranch(cwd);
432
+ } catch {
433
+ }
434
+ const sections = [];
435
+ for (const stack of state.stacks) {
436
+ const tree = await renderStack(stack, currentBranch, cwd);
437
+ sections.push(tree);
438
+ }
439
+ return sections.join("\n\n");
440
+ }
441
+ async function renderStack(stack, currentBranch, cwd) {
442
+ const root = stack.branches.find((b) => b.type === "root");
443
+ if (!root) return "";
444
+ const childMap = /* @__PURE__ */ new Map();
445
+ for (const branch of stack.branches) {
446
+ if (branch.parent) {
447
+ const children = childMap.get(branch.parent) ?? [];
448
+ children.push(branch);
449
+ childMap.set(branch.parent, children);
450
+ }
451
+ }
452
+ const lines = [];
453
+ await renderNode(root, currentBranch, childMap, "", true, true, lines, cwd);
454
+ return lines.join("\n");
455
+ }
456
+ async function renderNode(branch, currentBranch, childMap, prefix, isRoot, isLast, lines, cwd) {
457
+ let label;
458
+ const exists = await branchExists(branch.name, cwd);
459
+ if (isRoot) {
460
+ label = `(${branch.name})`;
461
+ } else if (branch.name === currentBranch) {
462
+ label = `*${branch.name} (Current)*`;
463
+ } else if (!exists) {
464
+ label = `${branch.name} \u26A0 (missing)`;
465
+ } else {
466
+ label = branch.name;
467
+ }
468
+ if (isRoot) {
469
+ lines.push(label);
470
+ } else {
471
+ const connector = isLast ? "\u2514\u2500 " : "\u251C\u2500 ";
472
+ lines.push(`${prefix}${connector}${label}`);
473
+ }
474
+ const children = childMap.get(branch.name) ?? [];
475
+ const childPrefix = isRoot ? " " : `${prefix}${isLast ? " " : "\u2502 "}`;
476
+ for (let i = 0; i < children.length; i++) {
477
+ const isChildLast = i === children.length - 1;
478
+ await renderNode(
479
+ children[i],
480
+ currentBranch,
481
+ childMap,
482
+ childPrefix,
483
+ false,
484
+ isChildLast,
485
+ lines,
486
+ cwd
487
+ );
488
+ }
489
+ }
490
+
491
+ // src/commands/restack.ts
492
+ import * as fs4 from "fs";
493
+ import * as path4 from "path";
494
+ async function restack(cwd) {
495
+ const state = await readState(cwd);
496
+ if (!await isWorkingTreeClean(cwd)) {
497
+ throw new DubError(
498
+ "Working tree has uncommitted changes. Commit or stash them before restacking."
499
+ );
500
+ }
501
+ const originalBranch = await getCurrentBranch(cwd);
502
+ const targetStacks = getTargetStacks(state.stacks, originalBranch);
503
+ if (targetStacks.length === 0) {
504
+ throw new DubError(
505
+ `Branch '${originalBranch}' is not part of any stack. Run 'dub create' first.`
506
+ );
507
+ }
508
+ const allBranches = targetStacks.flatMap((s) => s.branches);
509
+ for (const branch of allBranches) {
510
+ if (!await branchExists(branch.name, cwd)) {
511
+ throw new DubError(
512
+ `Branch '${branch.name}' is tracked in state but no longer exists in git.
513
+ Remove it from the stack or recreate it before restacking.`
514
+ );
515
+ }
516
+ }
517
+ const branchTips = {};
518
+ for (const branch of allBranches) {
519
+ branchTips[branch.name] = await getBranchTip(branch.name, cwd);
520
+ }
521
+ const steps = await buildRestackSteps(targetStacks, cwd);
522
+ if (steps.length === 0) {
523
+ return { status: "up-to-date", rebased: [] };
524
+ }
525
+ await saveUndoEntry(
526
+ {
527
+ operation: "restack",
528
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
529
+ previousBranch: originalBranch,
530
+ previousState: structuredClone(state),
531
+ branchTips,
532
+ createdBranches: []
533
+ },
534
+ cwd
535
+ );
536
+ const progress = { originalBranch, steps };
537
+ await writeProgress(progress, cwd);
538
+ return executeRestackSteps(progress, cwd);
539
+ }
540
+ async function restackContinue(cwd) {
541
+ const progress = await readProgress(cwd);
542
+ if (!progress) {
543
+ throw new DubError("No restack in progress. Run 'dub restack' to start.");
544
+ }
545
+ await rebaseContinue(cwd);
546
+ const conflictedStep = progress.steps.find((s) => s.status === "conflicted");
547
+ if (conflictedStep) {
548
+ conflictedStep.status = "done";
549
+ }
550
+ return executeRestackSteps(progress, cwd);
551
+ }
552
+ async function executeRestackSteps(progress, cwd) {
553
+ const rebased = [];
554
+ for (const step of progress.steps) {
555
+ if (step.status !== "pending") {
556
+ if (step.status === "done") rebased.push(step.branch);
557
+ continue;
558
+ }
559
+ const parentNewTip = await getBranchTip(step.parent, cwd);
560
+ if (parentNewTip === step.parentOldTip) {
561
+ step.status = "skipped";
562
+ await writeProgress(progress, cwd);
563
+ continue;
564
+ }
565
+ try {
566
+ await rebaseOnto(parentNewTip, step.parentOldTip, step.branch, cwd);
567
+ step.status = "done";
568
+ rebased.push(step.branch);
569
+ await writeProgress(progress, cwd);
570
+ } catch (error) {
571
+ if (error instanceof DubError && error.message.includes("Conflict")) {
572
+ step.status = "conflicted";
573
+ await writeProgress(progress, cwd);
574
+ return { status: "conflict", rebased, conflictBranch: step.branch };
575
+ }
576
+ throw error;
577
+ }
578
+ }
579
+ await clearProgress(cwd);
580
+ await checkoutBranch(progress.originalBranch, cwd);
581
+ const allSkipped = progress.steps.every(
582
+ (s) => s.status === "skipped" || s.status === "done"
583
+ );
584
+ return {
585
+ status: rebased.length === 0 && allSkipped ? "up-to-date" : "success",
586
+ rebased
587
+ };
588
+ }
589
+ function getTargetStacks(stacks, currentBranch) {
590
+ const rootStacks = stacks.filter(
591
+ (s) => s.branches.some((b) => b.name === currentBranch && b.type === "root")
592
+ );
593
+ if (rootStacks.length > 0) return rootStacks;
594
+ const stack = stacks.find(
595
+ (s) => s.branches.some((b) => b.name === currentBranch)
596
+ );
597
+ return stack ? [stack] : [];
598
+ }
599
+ async function buildRestackSteps(stacks, cwd) {
600
+ const steps = [];
601
+ for (const stack of stacks) {
602
+ const ordered = topologicalOrder(stack);
603
+ for (const branch of ordered) {
604
+ if (branch.type === "root" || !branch.parent) continue;
605
+ const mergeBase = await getMergeBase(branch.parent, branch.name, cwd);
606
+ steps.push({
607
+ branch: branch.name,
608
+ parent: branch.parent,
609
+ parentOldTip: mergeBase,
610
+ status: "pending"
611
+ });
612
+ }
613
+ }
614
+ return steps;
615
+ }
616
+ async function getProgressPath(cwd) {
617
+ const dubDir = await getDubDir(cwd);
618
+ return path4.join(dubDir, "restack-progress.json");
619
+ }
620
+ async function writeProgress(progress, cwd) {
621
+ const progressPath = await getProgressPath(cwd);
622
+ fs4.writeFileSync(progressPath, `${JSON.stringify(progress, null, 2)}
623
+ `);
624
+ }
625
+ async function readProgress(cwd) {
626
+ const progressPath = await getProgressPath(cwd);
627
+ if (!fs4.existsSync(progressPath)) return null;
628
+ const raw = fs4.readFileSync(progressPath, "utf-8");
629
+ return JSON.parse(raw);
630
+ }
631
+ async function clearProgress(cwd) {
632
+ const progressPath = await getProgressPath(cwd);
633
+ if (fs4.existsSync(progressPath)) {
634
+ fs4.unlinkSync(progressPath);
635
+ }
636
+ }
637
+
638
+ // src/commands/submit.ts
639
+ import * as fs5 from "fs";
640
+ import * as os from "os";
641
+ import * as path5 from "path";
642
+
643
+ // src/lib/github.ts
644
+ import { execa as execa2 } from "execa";
645
+ async function ensureGhInstalled() {
646
+ try {
647
+ await execa2("gh", ["--version"]);
648
+ } catch {
649
+ throw new DubError("gh CLI not found. Install it: https://cli.github.com");
650
+ }
651
+ }
652
+ async function checkGhAuth() {
653
+ try {
654
+ await execa2("gh", ["auth", "status"]);
655
+ } catch {
656
+ throw new DubError("Not authenticated with GitHub. Run 'gh auth login'.");
657
+ }
658
+ }
659
+ async function getPr(branch, cwd) {
660
+ const { stdout } = await execa2(
661
+ "gh",
662
+ [
663
+ "pr",
664
+ "list",
665
+ "--head",
666
+ branch,
667
+ "--state",
668
+ "open",
669
+ "--json",
670
+ "number,url,title,body",
671
+ "--jq",
672
+ ".[0]"
673
+ ],
674
+ { cwd }
675
+ );
676
+ const trimmed = stdout.trim();
677
+ if (!trimmed || trimmed === "null") return null;
678
+ try {
679
+ return JSON.parse(trimmed);
680
+ } catch {
681
+ throw new DubError(`Failed to parse PR info for branch '${branch}'.`);
682
+ }
683
+ }
684
+ async function createPr(branch, base, title, bodyFile, cwd) {
685
+ let stdout;
686
+ try {
687
+ const result = await execa2(
688
+ "gh",
689
+ [
690
+ "pr",
691
+ "create",
692
+ "--head",
693
+ branch,
694
+ "--base",
695
+ base,
696
+ "--title",
697
+ title,
698
+ "--body-file",
699
+ bodyFile
700
+ ],
701
+ { cwd }
702
+ );
703
+ stdout = result.stdout;
704
+ } catch (error) {
705
+ const message = error instanceof Error ? error.message : String(error);
706
+ if (message.includes("403") || message.includes("insufficient")) {
707
+ throw new DubError(
708
+ "GitHub token lacks required permissions. Run 'gh auth login' with the 'repo' scope."
709
+ );
710
+ }
711
+ throw new DubError(`Failed to create PR for '${branch}': ${message}`);
712
+ }
713
+ const url = stdout.trim();
714
+ const numberMatch = url.match(/\/pull\/(\d+)$/);
715
+ if (!numberMatch) {
716
+ throw new DubError(`Unexpected output from 'gh pr create': ${url}`);
717
+ }
718
+ return {
719
+ number: Number.parseInt(numberMatch[1], 10),
720
+ url,
721
+ title,
722
+ body: ""
723
+ };
724
+ }
725
+ async function updatePrBody(prNumber, bodyFile, cwd) {
726
+ try {
727
+ await execa2(
728
+ "gh",
729
+ ["pr", "edit", String(prNumber), "--body-file", bodyFile],
730
+ { cwd }
731
+ );
732
+ } catch (error) {
733
+ const message = error instanceof Error ? error.message : String(error);
734
+ if (message.includes("403") || message.includes("insufficient")) {
735
+ throw new DubError(
736
+ "GitHub token lacks required permissions. Run 'gh auth login' with the 'repo' scope."
737
+ );
738
+ }
739
+ throw new DubError(`Failed to update PR #${prNumber}: ${message}`);
740
+ }
741
+ }
742
+
743
+ // src/lib/pr-body.ts
744
+ var DUBSTACK_START = "<!-- dubstack:start -->";
745
+ var DUBSTACK_END = "<!-- dubstack:end -->";
746
+ var METADATA_START = "<!-- dubstack-metadata";
747
+ var METADATA_END = "-->";
748
+ function buildStackTable(orderedBranches, prMap, currentBranch) {
749
+ const lines = orderedBranches.map((branch) => {
750
+ const entry = prMap.get(branch.name);
751
+ if (!entry) return `- ${branch.name}`;
752
+ const marker = branch.name === currentBranch ? " \u{1F448}" : "";
753
+ return `- #${entry.number} ${entry.title}${marker}`;
754
+ });
755
+ return [
756
+ DUBSTACK_START,
757
+ "---",
758
+ "### \u{1F95E} DubStack",
759
+ ...lines,
760
+ DUBSTACK_END
761
+ ].join("\n");
762
+ }
763
+ function buildMetadataBlock(stackId, prNumber, prevPr, nextPr, branch) {
764
+ const metadata = {
765
+ stack_id: stackId,
766
+ pr_number: prNumber,
767
+ prev_pr: prevPr,
768
+ next_pr: nextPr,
769
+ branch
770
+ };
771
+ return `${METADATA_START}
772
+ ${JSON.stringify(metadata, null, 2)}
773
+ ${METADATA_END}`;
774
+ }
775
+ function stripDubstackSections(body) {
776
+ let result = body;
777
+ const startIdx = result.indexOf(DUBSTACK_START);
778
+ const endIdx = result.indexOf(DUBSTACK_END);
779
+ if (startIdx !== -1 && endIdx !== -1) {
780
+ result = result.slice(0, startIdx) + result.slice(endIdx + DUBSTACK_END.length);
781
+ }
782
+ const metaStart = result.indexOf(METADATA_START);
783
+ if (metaStart !== -1) {
784
+ const metaEnd = result.indexOf(
785
+ METADATA_END,
786
+ metaStart + METADATA_START.length
787
+ );
788
+ if (metaEnd !== -1) {
789
+ result = result.slice(0, metaStart) + result.slice(metaEnd + METADATA_END.length);
790
+ }
791
+ }
792
+ return result.trimEnd();
793
+ }
794
+ function composePrBody(existingBody, stackTable, metadataBlock) {
795
+ const userContent = stripDubstackSections(existingBody);
796
+ const parts = [userContent, stackTable, metadataBlock].filter(Boolean);
797
+ return parts.join("\n\n");
798
+ }
799
+
800
+ // src/commands/submit.ts
801
+ async function submit(cwd, dryRun) {
802
+ await ensureGhInstalled();
803
+ await checkGhAuth();
804
+ const state = await readState(cwd);
805
+ const currentBranch = await getCurrentBranch(cwd);
806
+ const stack = findStackForBranch(state, currentBranch);
807
+ if (!stack) {
808
+ throw new DubError(
809
+ `Branch '${currentBranch}' is not part of any stack. Run 'dub create' first.`
810
+ );
811
+ }
812
+ const ordered = topologicalOrder(stack);
813
+ const currentEntry = ordered.find((b) => b.name === currentBranch);
814
+ if (currentEntry?.type === "root") {
815
+ throw new DubError(
816
+ "Cannot submit from a root branch. Checkout a stack branch first."
817
+ );
818
+ }
819
+ const nonRootBranches = ordered.filter((b) => b.type !== "root");
820
+ validateLinearStack(ordered);
821
+ const result = { pushed: [], created: [], updated: [] };
822
+ const prMap = /* @__PURE__ */ new Map();
823
+ for (const branch of nonRootBranches) {
824
+ if (dryRun) {
825
+ console.log(`[dry-run] would push ${branch.name}`);
826
+ } else {
827
+ await pushBranch(branch.name, cwd);
828
+ }
829
+ result.pushed.push(branch.name);
830
+ }
831
+ for (const branch of nonRootBranches) {
832
+ const base = branch.parent;
833
+ if (dryRun) {
834
+ console.log(`[dry-run] would check/create PR: ${branch.name} \u2192 ${base}`);
835
+ continue;
836
+ }
837
+ const existing = await getPr(branch.name, cwd);
838
+ if (existing) {
839
+ prMap.set(branch.name, existing);
840
+ result.updated.push(branch.name);
841
+ } else {
842
+ const title = await getLastCommitMessage(branch.name, cwd);
843
+ const tmpFile = writeTempBody("");
844
+ try {
845
+ const created = await createPr(branch.name, base, title, tmpFile, cwd);
846
+ prMap.set(branch.name, created);
847
+ result.created.push(branch.name);
848
+ } finally {
849
+ cleanupTempFile(tmpFile);
850
+ }
851
+ }
852
+ }
853
+ if (!dryRun) {
854
+ await updateAllPrBodies(nonRootBranches, prMap, stack.id, cwd);
855
+ for (const branch of nonRootBranches) {
856
+ const pr = prMap.get(branch.name);
857
+ if (pr) {
858
+ const stateBranch = stack.branches.find((b) => b.name === branch.name);
859
+ if (stateBranch) {
860
+ stateBranch.pr_number = pr.number;
861
+ stateBranch.pr_link = pr.url;
862
+ }
863
+ }
864
+ }
865
+ await writeState(state, cwd);
866
+ }
867
+ return result;
868
+ }
869
+ function validateLinearStack(ordered) {
870
+ const childCount = /* @__PURE__ */ new Map();
871
+ for (const branch of ordered) {
872
+ if (branch.parent) {
873
+ childCount.set(branch.parent, (childCount.get(branch.parent) ?? 0) + 1);
874
+ }
875
+ }
876
+ for (const [parent, count] of childCount) {
877
+ if (count > 1) {
878
+ throw new DubError(
879
+ `Branch '${parent}' has ${count} children. Branching stacks are not supported by submit. Ensure each branch has at most one child.`
880
+ );
881
+ }
882
+ }
883
+ }
884
+ async function updateAllPrBodies(branches, prMap, stackId, cwd) {
885
+ const tableEntries = /* @__PURE__ */ new Map();
886
+ for (const branch of branches) {
887
+ const pr = prMap.get(branch.name);
888
+ if (pr) {
889
+ tableEntries.set(branch.name, { number: pr.number, title: pr.title });
890
+ }
891
+ }
892
+ for (let i = 0; i < branches.length; i++) {
893
+ const branch = branches[i];
894
+ const pr = prMap.get(branch.name);
895
+ if (!pr) continue;
896
+ const prevPr = i > 0 ? prMap.get(branches[i - 1].name)?.number ?? null : null;
897
+ const nextPr = i < branches.length - 1 ? prMap.get(branches[i + 1].name)?.number ?? null : null;
898
+ const stackTable = buildStackTable(branches, tableEntries, branch.name);
899
+ const metadataBlock = buildMetadataBlock(
900
+ stackId,
901
+ pr.number,
902
+ prevPr,
903
+ nextPr,
904
+ branch.name
905
+ );
906
+ const existingBody = pr.body;
907
+ const finalBody = composePrBody(existingBody, stackTable, metadataBlock);
908
+ const tmpFile = writeTempBody(finalBody);
909
+ try {
910
+ await updatePrBody(pr.number, tmpFile, cwd);
911
+ } finally {
912
+ cleanupTempFile(tmpFile);
913
+ }
914
+ }
915
+ }
916
+ function writeTempBody(content) {
917
+ const tmpDir = os.tmpdir();
918
+ const tmpFile = path5.join(tmpDir, `dubstack-body-${Date.now()}.md`);
919
+ fs5.writeFileSync(tmpFile, content);
920
+ return tmpFile;
921
+ }
922
+ function cleanupTempFile(filePath) {
923
+ try {
924
+ fs5.unlinkSync(filePath);
925
+ } catch {
926
+ }
927
+ }
928
+
929
+ // src/commands/undo.ts
930
+ async function undo(cwd) {
931
+ const entry = await readUndoEntry(cwd);
932
+ if (!await isWorkingTreeClean(cwd)) {
933
+ throw new DubError(
934
+ "Working tree has uncommitted changes. Commit or stash them before undoing."
935
+ );
936
+ }
937
+ const currentBranch = await getCurrentBranch(cwd);
938
+ if (entry.operation === "create") {
939
+ const needsCheckout = entry.createdBranches.includes(currentBranch);
940
+ if (needsCheckout) {
941
+ await checkoutBranch(entry.previousBranch, cwd);
942
+ }
943
+ for (const branch of entry.createdBranches) {
944
+ await deleteBranch(branch, cwd);
945
+ }
946
+ if (!needsCheckout && currentBranch !== entry.previousBranch) {
947
+ await checkoutBranch(entry.previousBranch, cwd);
948
+ }
949
+ await writeState(entry.previousState, cwd);
950
+ await clearUndoEntry(cwd);
951
+ return {
952
+ undone: "create",
953
+ details: `Deleted branch${entry.createdBranches.length > 1 ? "es" : ""} '${entry.createdBranches.join("', '")}'`
954
+ };
955
+ }
956
+ await checkoutBranch(entry.previousBranch, cwd);
957
+ for (const [name, sha] of Object.entries(entry.branchTips)) {
958
+ if (name === entry.previousBranch) continue;
959
+ await forceBranchTo(name, sha, cwd);
960
+ }
961
+ if (entry.branchTips[entry.previousBranch]) {
962
+ await forceBranchTo(
963
+ entry.previousBranch,
964
+ entry.branchTips[entry.previousBranch],
965
+ cwd
966
+ );
967
+ }
968
+ await writeState(entry.previousState, cwd);
969
+ await clearUndoEntry(cwd);
970
+ return {
971
+ undone: "restack",
972
+ details: `Reset ${Object.keys(entry.branchTips).length} branches to pre-restack state`
973
+ };
974
+ }
975
+
976
+ // src/index.ts
977
+ var require2 = createRequire(import.meta.url);
978
+ var { version } = require2("../package.json");
979
+ var program = new Command();
980
+ program.name("dub").description("Manage stacked diffs (dependent git branches) with ease").version(version);
981
+ program.command("init").description("Initialize DubStack in the current git repository").addHelpText(
982
+ "after",
983
+ `
55
984
  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}'`));
985
+ $ dub init Initialize DubStack, creating .git/dubstack/ and updating .gitignore`
986
+ ).action(async () => {
987
+ const result = await init(process.cwd());
988
+ if (result.status === "created") {
989
+ console.log(chalk.green("\u2714 DubStack initialized"));
990
+ } else {
991
+ console.log(chalk.yellow("\u26A0 DubStack already initialized"));
992
+ }
61
993
  });
62
- program
63
- .command("log")
64
- .description("Display an ASCII tree of the current stack")
65
- .addHelpText("after", `
994
+ 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(
995
+ "after",
996
+ `
66
997
  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);
998
+ $ dub create feat/api Create branch only
999
+ $ dub create feat/api -m "feat: add API" Create branch + commit staged
1000
+ $ dub create feat/api -am "feat: add API" Stage all + create + commit`
1001
+ ).action(
1002
+ async (branchName, options) => {
1003
+ const result = await create(branchName, process.cwd(), {
1004
+ message: options.message,
1005
+ all: options.all
1006
+ });
1007
+ if (result.committed) {
1008
+ console.log(
1009
+ chalk.green(
1010
+ `\u2714 Created '${result.branch}' on '${result.parent}' \u2022 ${result.committed}`
1011
+ )
1012
+ );
1013
+ } else {
1014
+ console.log(
1015
+ chalk.green(
1016
+ `\u2714 Created branch '${result.branch}' on top of '${result.parent}'`
1017
+ )
1018
+ );
1019
+ }
1020
+ }
1021
+ );
1022
+ program.command("log").description("Display an ASCII tree of the current stack").addHelpText(
1023
+ "after",
1024
+ `
1025
+ Examples:
1026
+ $ dub log Show the branch tree with current branch highlighted`
1027
+ ).action(async () => {
1028
+ const output = await log(process.cwd());
1029
+ const styled = output.replace(/\*(.+?) \(Current\)\*/g, chalk.bold.cyan("$1 (Current)")).replace(/⚠ \(missing\)/g, chalk.yellow("\u26A0 (missing)"));
1030
+ console.log(styled);
75
1031
  });
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", `
1032
+ program.command("restack").description("Rebase all branches in the stack onto their updated parents").option("--continue", "Continue restacking after resolving conflicts").addHelpText(
1033
+ "after",
1034
+ `
81
1035
  Examples:
82
1036
  $ 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
- }
1037
+ $ dub restack --continue Continue after resolving conflicts`
1038
+ ).action(async (options) => {
1039
+ const result = options.continue ? await restackContinue(process.cwd()) : await restack(process.cwd());
1040
+ if (result.status === "up-to-date") {
1041
+ console.log(chalk.green("\u2714 Stack is already up to date"));
1042
+ } else if (result.status === "conflict") {
1043
+ console.log(
1044
+ chalk.yellow(`\u26A0 Conflict while restacking '${result.conflictBranch}'`)
1045
+ );
1046
+ console.log(
1047
+ chalk.dim(
1048
+ " Resolve conflicts, stage changes, then run: dub restack --continue"
1049
+ )
1050
+ );
1051
+ } else {
1052
+ console.log(
1053
+ chalk.green(`\u2714 Restacked ${result.rebased.length} branch(es)`)
1054
+ );
1055
+ for (const branch of result.rebased) {
1056
+ console.log(chalk.dim(` \u21B3 ${branch}`));
100
1057
  }
1058
+ }
101
1059
  });
102
- program
103
- .command("undo")
104
- .description("Undo the last dub create or dub restack operation")
105
- .addHelpText("after", `
1060
+ program.command("undo").description("Undo the last dub create or dub restack operation").addHelpText(
1061
+ "after",
1062
+ `
106
1063
  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}`));
1064
+ $ dub undo Roll back the last dub operation`
1065
+ ).action(async () => {
1066
+ const result = await undo(process.cwd());
1067
+ console.log(chalk.green(`\u2714 Undid '${result.undone}': ${result.details}`));
111
1068
  });
112
- async function main() {
113
- try {
114
- await program.parseAsync(process.argv);
1069
+ program.command("submit").description(
1070
+ "Push branches and create/update GitHub PRs for the current stack"
1071
+ ).option("--dry-run", "Print what would happen without executing").addHelpText(
1072
+ "after",
1073
+ `
1074
+ Examples:
1075
+ $ dub submit Push and create/update PRs
1076
+ $ dub submit --dry-run Preview what would happen`
1077
+ ).action(runSubmit);
1078
+ program.command("ss").description("Submit the current stack (alias for submit)").option("--dry-run", "Print what would happen without executing").action(runSubmit);
1079
+ async function runSubmit(options) {
1080
+ const result = await submit(process.cwd(), options.dryRun ?? false);
1081
+ if (result.pushed.length > 0) {
1082
+ console.log(
1083
+ chalk.green(
1084
+ `\u2714 Pushed ${result.pushed.length} branch(es), created ${result.created.length} PR(s), updated ${result.updated.length} PR(s)`
1085
+ )
1086
+ );
1087
+ for (const branch of [...result.created, ...result.updated]) {
1088
+ console.log(chalk.dim(` \u21B3 ${branch}`));
115
1089
  }
116
- catch (error) {
117
- if (error instanceof DubError) {
118
- console.error(chalk.red(`✖ ${error.message}`));
119
- process.exit(1);
120
- }
121
- throw error;
1090
+ }
1091
+ }
1092
+ async function main() {
1093
+ try {
1094
+ await program.parseAsync(process.argv);
1095
+ } catch (error) {
1096
+ if (error instanceof DubError) {
1097
+ console.error(chalk.red(`\u2716 ${error.message}`));
1098
+ process.exit(1);
122
1099
  }
1100
+ throw error;
1101
+ }
123
1102
  }
124
1103
  main();
125
1104
  //# sourceMappingURL=index.js.map