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