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