bstack 1.1.3 → 1.1.4

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 CHANGED
@@ -94,3 +94,4 @@ command before it runs.
94
94
 
95
95
  - [ezyang/ghstack](https://github.com/ezyang/ghstack)
96
96
  - [github/gh-stack](https://github.com/github/gh-stack)
97
+ - [stacking.dev](https://www.stacking.dev/)
@@ -0,0 +1,898 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+ import { spawnSync } from "node:child_process";
4
+ import { randomUUID } from "node:crypto";
5
+ import * as v from "valibot";
6
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
7
+ import { dirname } from "node:path";
8
+ //#region package.json
9
+ var version = "1.1.4";
10
+ //#endregion
11
+ //#region src/command.ts
12
+ var CommandError = class extends Error {
13
+ command;
14
+ result;
15
+ constructor(command, result) {
16
+ const detail = result.stderr.trim() || result.stdout.trim();
17
+ super(`${command.join(" ")} failed with exit code ${result.exitCode}${detail ? `\n${detail}` : ""}`);
18
+ this.command = command;
19
+ this.result = result;
20
+ }
21
+ };
22
+ var NodeCommandRunner = class {
23
+ logger;
24
+ constructor(logger) {
25
+ this.logger = logger;
26
+ }
27
+ run(command, options) {
28
+ const [executable, ...args] = command;
29
+ if (!executable) throw new Error("Cannot run an empty command");
30
+ this.logger?.(command);
31
+ const result = spawnSync(executable, args, {
32
+ cwd: options.cwd,
33
+ input: options.stdin,
34
+ encoding: "utf8",
35
+ env: options.env === void 0 ? process.env : {
36
+ ...process.env,
37
+ ...options.env
38
+ }
39
+ });
40
+ const commandResult = {
41
+ stdout: result.stdout ?? "",
42
+ stderr: result.stderr ?? result.error?.message ?? "",
43
+ exitCode: result.status ?? 1
44
+ };
45
+ if (commandResult.exitCode !== 0 && !options.allowFailure) throw new CommandError(command, commandResult);
46
+ return commandResult;
47
+ }
48
+ };
49
+ function formatCommand(command) {
50
+ return command.map(formatArgument).join(" ");
51
+ }
52
+ function formatArgument(argument) {
53
+ if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(argument)) return argument;
54
+ return `'${argument.replaceAll("'", `'"'"'`)}'`;
55
+ }
56
+ //#endregion
57
+ //#region src/checkout.ts
58
+ function checkoutStack(dependencies, options) {
59
+ const { repository, github, reporter } = dependencies;
60
+ reporter.progress("Checking the repository and GitHub prerequisites");
61
+ repository.assertReady();
62
+ github.assertReady();
63
+ const remote = repository.resolveRemote(options.remote);
64
+ reporter.progress(`Looking up pull request ${options.reference}`);
65
+ const headRef = github.pullRequestHead(options.reference);
66
+ if (!headRef.startsWith("bstack/")) {
67
+ reporter.progress("This is not a bstack pull request; delegating to gh pr checkout");
68
+ github.checkoutPullRequest(options.reference);
69
+ return {
70
+ headRef,
71
+ delegated: true
72
+ };
73
+ }
74
+ let currentBase;
75
+ let remoteBase;
76
+ if (options.sameBase) {
77
+ const base = options.base ?? github.defaultBranch();
78
+ reporter.progress(`Checking that the merge base remains on ${remote}/${base}`);
79
+ remoteBase = repository.fetchBase(remote, base);
80
+ currentBase = repository.mergeBase("HEAD", remoteBase);
81
+ }
82
+ reporter.progress(`Fetching ${remote}/${headRef}`);
83
+ const target = repository.fetchRemoteBranch(remote, headRef);
84
+ if (currentBase && remoteBase) {
85
+ const targetBase = repository.mergeBase(target, remoteBase);
86
+ if (currentBase !== targetBase) throw new Error(`Checkout would change the merge base from ${currentBase.slice(0, 8)} to ${targetBase.slice(0, 8)}`);
87
+ }
88
+ reporter.progress(`Checking out ${remote}/${headRef} in detached HEAD state`);
89
+ repository.checkout(target);
90
+ reporter.progress("Checkout complete; amend the commits and run bstack to sync updates");
91
+ return {
92
+ headRef,
93
+ delegated: false
94
+ };
95
+ }
96
+ //#endregion
97
+ //#region src/commit.ts
98
+ const CHANGE_ID_TRAILER = "bstack-id";
99
+ const changeIdTrailerPattern = new RegExp(`^${CHANGE_ID_TRAILER}:\\s*(\\S+)\\s*$`, "gm");
100
+ const changeIdTrailerLinePattern = new RegExp(`^${CHANGE_ID_TRAILER}:\\s*\\S+\\s*$`);
101
+ function readChangeId(message) {
102
+ const matches = [...message.matchAll(changeIdTrailerPattern)];
103
+ if (matches.length > 1) throw new Error(`A commit contains more than one ${CHANGE_ID_TRAILER} trailer`);
104
+ return matches[0]?.[1];
105
+ }
106
+ function addChangeId(message, changeId) {
107
+ return `${message.trimEnd()}\n\n${CHANGE_ID_TRAILER}: ${changeId}\n`;
108
+ }
109
+ function generateChangeId() {
110
+ return randomUUID().replaceAll("-", "");
111
+ }
112
+ function parseRawCommit(oid, raw) {
113
+ const boundary = raw.indexOf("\n\n");
114
+ if (boundary === -1) throw new Error(`Commit ${oid} has an invalid object format`);
115
+ const headers = raw.slice(0, boundary).split("\n");
116
+ const treeLine = headers.find((line) => line.startsWith("tree "));
117
+ const parents = headers.filter((line) => line.startsWith("parent "));
118
+ if (!treeLine || parents.length !== 1) throw new Error(`Commit ${oid} must have exactly one parent; merge and root commits are not supported`);
119
+ if (headers.some((line) => line.startsWith("gpgsig "))) throw new Error(`Commit ${oid} is signed. bstack cannot add an identity trailer without replacing its signature`);
120
+ const message = raw.slice(boundary + 2);
121
+ return {
122
+ oid,
123
+ tree: treeLine.slice(5),
124
+ parent: parents[0].slice(7),
125
+ message,
126
+ headers,
127
+ changeId: readChangeId(message)
128
+ };
129
+ }
130
+ function rewriteCommit(commit, parent, message) {
131
+ const rewrittenHeaders = [];
132
+ let replacedParent = false;
133
+ for (const header of commit.headers) {
134
+ if (header.startsWith("parent ")) {
135
+ if (!replacedParent) {
136
+ rewrittenHeaders.push(`parent ${parent}`);
137
+ replacedParent = true;
138
+ }
139
+ continue;
140
+ }
141
+ rewrittenHeaders.push(header);
142
+ }
143
+ return `${rewrittenHeaders.join("\n")}\n\n${message}`;
144
+ }
145
+ function splitCommitMessage(message) {
146
+ const [subject = "Untitled change", ...bodyLines] = message.split("\n").filter((line) => !changeIdTrailerLinePattern.test(line)).join("\n").trim().split("\n");
147
+ return {
148
+ subject,
149
+ body: bodyLines.join("\n").trim()
150
+ };
151
+ }
152
+ //#endregion
153
+ //#region src/git.ts
154
+ var GitCliRepository = class {
155
+ cwd;
156
+ runner;
157
+ constructor(cwd, runner) {
158
+ this.cwd = cwd;
159
+ this.runner = runner;
160
+ }
161
+ git(args, options = {}) {
162
+ return this.runner.run(["git", ...args], {
163
+ cwd: this.cwd,
164
+ ...options
165
+ });
166
+ }
167
+ assertReady() {
168
+ this.git(["rev-parse", "--show-toplevel"]);
169
+ if (this.git(["status", "--porcelain"]).stdout.trim()) throw new Error("The working tree must be clean before bstack rewrites commits or pushes branches");
170
+ }
171
+ currentBranch() {
172
+ return this.git([
173
+ "symbolic-ref",
174
+ "--quiet",
175
+ "--short",
176
+ "HEAD"
177
+ ], { allowFailure: true }).stdout.trim();
178
+ }
179
+ resolveRemote(requested) {
180
+ if (requested) {
181
+ if (!this.remotes().includes(requested)) throw new Error(`Git remote ${requested} does not exist`);
182
+ return requested;
183
+ }
184
+ const configured = this.configuredPushRemote();
185
+ if (configured) return configured;
186
+ const remotes = this.remotes();
187
+ if (remotes.length === 1) return remotes[0];
188
+ if (remotes.includes("origin")) return "origin";
189
+ throw new Error("Cannot choose a Git remote. Pass --remote or configure remote.pushDefault");
190
+ }
191
+ fetchBase(remote, base) {
192
+ const destination = `refs/remotes/${remote}/${base}`;
193
+ this.git([
194
+ "fetch",
195
+ "--no-tags",
196
+ remote,
197
+ `refs/heads/${base}:${destination}`
198
+ ]);
199
+ return destination;
200
+ }
201
+ fetchRemoteBranch(remote, branch) {
202
+ const destination = `refs/remotes/${remote}/${branch}`;
203
+ this.git([
204
+ "fetch",
205
+ "--no-tags",
206
+ remote,
207
+ `+refs/heads/${branch}:${destination}`
208
+ ]);
209
+ return destination;
210
+ }
211
+ checkout(ref) {
212
+ this.git([
213
+ "checkout",
214
+ "--detach",
215
+ ref
216
+ ]);
217
+ }
218
+ mergeBase(left, right) {
219
+ return this.git([
220
+ "merge-base",
221
+ left,
222
+ right
223
+ ]).stdout.trim();
224
+ }
225
+ commitsSince(baseOid) {
226
+ return this.git([
227
+ "rev-list",
228
+ "--reverse",
229
+ "--first-parent",
230
+ `${baseOid}..HEAD`
231
+ ]).stdout.split("\n").filter(Boolean).map((oid) => parseRawCommit(oid, this.git([
232
+ "cat-file",
233
+ "commit",
234
+ oid
235
+ ]).stdout));
236
+ }
237
+ rewriteCommits(rewrites) {
238
+ if (rewrites.length === 0) return [];
239
+ let parent = rewrites[0].commit.parent;
240
+ const rewrittenOids = [];
241
+ for (const rewrite of rewrites) {
242
+ const raw = rewriteCommit(rewrite.commit, parent, rewrite.message);
243
+ const oid = this.git([
244
+ "hash-object",
245
+ "-t",
246
+ "commit",
247
+ "-w",
248
+ "--stdin"
249
+ ], { stdin: raw }).stdout.trim();
250
+ rewrittenOids.push(oid);
251
+ parent = oid;
252
+ }
253
+ const oldHead = rewrites.at(-1).commit.oid;
254
+ const newHead = rewrittenOids.at(-1);
255
+ this.git([
256
+ "update-ref",
257
+ "HEAD",
258
+ newHead,
259
+ oldHead
260
+ ]);
261
+ return rewrittenOids;
262
+ }
263
+ pushBranches(remote, branches) {
264
+ const existing = this.remoteBranchOids(remote, branches.map((branch) => branch.name));
265
+ const leases = [];
266
+ const refspecs = [];
267
+ for (const branch of branches) {
268
+ const expected = existing.get(branch.name) ?? "";
269
+ leases.push(`--force-with-lease=refs/heads/${branch.name}:${expected}`);
270
+ refspecs.push(`${branch.oid}:refs/heads/${branch.name}`);
271
+ }
272
+ this.git([
273
+ "push",
274
+ "--atomic",
275
+ ...leases,
276
+ remote,
277
+ ...refspecs
278
+ ]);
279
+ }
280
+ statePath() {
281
+ return this.git([
282
+ "rev-parse",
283
+ "--path-format=absolute",
284
+ "--git-path",
285
+ "bstack/state.json"
286
+ ]).stdout.trim();
287
+ }
288
+ remotes() {
289
+ return this.git(["remote"]).stdout.split("\n").filter(Boolean);
290
+ }
291
+ configuredPushRemote() {
292
+ const result = this.git([
293
+ "config",
294
+ "--get",
295
+ "remote.pushDefault"
296
+ ], { allowFailure: true });
297
+ return result.exitCode === 0 ? result.stdout.trim() || void 0 : void 0;
298
+ }
299
+ remoteBranchOids(remote, branches) {
300
+ if (branches.length === 0) return /* @__PURE__ */ new Map();
301
+ const result = this.git([
302
+ "ls-remote",
303
+ "--heads",
304
+ remote,
305
+ ...branches.map((branch) => `refs/heads/${branch}`)
306
+ ]);
307
+ return new Map(result.stdout.split("\n").filter(Boolean).map((line) => {
308
+ const [oid, ref] = line.split(/\s+/, 2);
309
+ return [ref.replace("refs/heads/", ""), oid];
310
+ }));
311
+ }
312
+ };
313
+ //#endregion
314
+ //#region src/github.ts
315
+ const pullRequestSchema = v.object({
316
+ number: v.number(),
317
+ url: v.string(),
318
+ state: v.picklist([
319
+ "OPEN",
320
+ "CLOSED",
321
+ "MERGED"
322
+ ]),
323
+ title: v.string(),
324
+ body: v.string(),
325
+ isDraft: v.boolean()
326
+ });
327
+ const stackSchema = v.object({ number: v.number() });
328
+ var GitHubCliPlatform = class {
329
+ cwd;
330
+ runner;
331
+ constructor(cwd, runner) {
332
+ this.cwd = cwd;
333
+ this.runner = runner;
334
+ }
335
+ gh(args) {
336
+ return this.runner.run(["gh", ...args], { cwd: this.cwd });
337
+ }
338
+ assertReady() {
339
+ this.gh([
340
+ "auth",
341
+ "status",
342
+ "--active"
343
+ ]);
344
+ this.gh(["stack", "--version"]);
345
+ }
346
+ currentUserLogin() {
347
+ const login = this.gh([
348
+ "api",
349
+ "user",
350
+ "--jq",
351
+ ".login"
352
+ ]).stdout.trim();
353
+ return v.parse(v.pipe(v.string(), v.minLength(1)), login);
354
+ }
355
+ defaultBranch() {
356
+ return this.gh([
357
+ "repo",
358
+ "view",
359
+ "--json",
360
+ "defaultBranchRef",
361
+ "--jq",
362
+ ".defaultBranchRef.name"
363
+ ]).stdout.trim();
364
+ }
365
+ pullRequestForBranch(branch) {
366
+ const raw = this.gh([
367
+ "pr",
368
+ "list",
369
+ "--head",
370
+ branch,
371
+ "--state",
372
+ "all",
373
+ "--limit",
374
+ "20",
375
+ "--json",
376
+ "number,url,state,title,body,isDraft"
377
+ ]).stdout;
378
+ const candidates = v.parse(v.array(pullRequestSchema), JSON.parse(raw));
379
+ return candidates.find((pr) => pr.state === "OPEN") ?? candidates.find((pr) => pr.state === "MERGED");
380
+ }
381
+ pullRequest(number) {
382
+ const raw = this.gh([
383
+ "pr",
384
+ "view",
385
+ String(number),
386
+ "--json",
387
+ "number,url,state,title,body,isDraft"
388
+ ]).stdout;
389
+ return v.parse(pullRequestSchema, JSON.parse(raw));
390
+ }
391
+ createPullRequest(change, base, draft) {
392
+ const args = [
393
+ "pr",
394
+ "create",
395
+ "--base",
396
+ base,
397
+ "--head",
398
+ change.remoteBranch,
399
+ "--title",
400
+ change.subject,
401
+ "--body",
402
+ change.body
403
+ ];
404
+ if (draft) args.push("--draft");
405
+ this.gh(args);
406
+ const created = this.pullRequestForBranch(change.remoteBranch);
407
+ if (!created) throw new Error(`GitHub did not return the PR created for ${change.remoteBranch}`);
408
+ return created;
409
+ }
410
+ linkStack(branches, base, remote, draft) {
411
+ const args = [
412
+ "stack",
413
+ "link",
414
+ "--base",
415
+ base,
416
+ "--remote",
417
+ remote
418
+ ];
419
+ if (!draft) args.push("--open");
420
+ args.push(...branches);
421
+ this.gh(args);
422
+ }
423
+ appendToStack(stackNumber, branches, remote, draft) {
424
+ const args = [
425
+ "stack",
426
+ "link",
427
+ "--remote",
428
+ remote
429
+ ];
430
+ if (!draft) args.push("--open");
431
+ args.push(String(stackNumber), ...branches);
432
+ this.gh(args);
433
+ }
434
+ unstack(stackNumber) {
435
+ this.gh([
436
+ "stack",
437
+ "unstack",
438
+ String(stackNumber)
439
+ ]);
440
+ }
441
+ editPullRequestBase(pr, base) {
442
+ this.gh([
443
+ "pr",
444
+ "edit",
445
+ String(pr.number),
446
+ "--base",
447
+ base
448
+ ]);
449
+ }
450
+ editPullRequest(pr, change) {
451
+ if (pr.title === change.subject && pr.body === change.body) return;
452
+ this.gh([
453
+ "pr",
454
+ "edit",
455
+ String(pr.number),
456
+ "--title",
457
+ change.subject,
458
+ "--body",
459
+ change.body
460
+ ]);
461
+ }
462
+ stackNumberForPullRequest(prNumber) {
463
+ const raw = this.gh(["api", `repos/{owner}/{repo}/stacks?pull_request=${prNumber}`]).stdout;
464
+ return v.parse(v.array(stackSchema), JSON.parse(raw))[0]?.number;
465
+ }
466
+ pullRequestHead(reference) {
467
+ return this.gh([
468
+ "pr",
469
+ "view",
470
+ reference,
471
+ "--json",
472
+ "headRefName",
473
+ "--jq",
474
+ ".headRefName"
475
+ ]).stdout.trim();
476
+ }
477
+ checkoutPullRequest(reference) {
478
+ this.gh([
479
+ "pr",
480
+ "checkout",
481
+ reference
482
+ ]);
483
+ }
484
+ };
485
+ //#endregion
486
+ //#region src/reporter.ts
487
+ var ConsoleReporter = class {
488
+ progress(message) {
489
+ process.stderr.write(`[bstack] ${message}\n`);
490
+ }
491
+ command(command) {
492
+ this.progress(`$ ${formatCommand(command)}`);
493
+ }
494
+ };
495
+ //#endregion
496
+ //#region src/state.ts
497
+ const emptyState = () => ({
498
+ schemaVersion: 1,
499
+ stacks: []
500
+ });
501
+ const storedChangeSchema = v.object({
502
+ id: v.string(),
503
+ remoteBranch: v.string(),
504
+ pullRequest: v.number(),
505
+ url: v.string()
506
+ });
507
+ const storedStackSchema = v.object({
508
+ remote: v.string(),
509
+ base: v.string(),
510
+ stackNumber: v.optional(v.number()),
511
+ changes: v.array(storedChangeSchema)
512
+ });
513
+ const stateSchema = v.object({
514
+ schemaVersion: v.literal(1),
515
+ stacks: v.array(storedStackSchema)
516
+ });
517
+ var FileStateStore = class {
518
+ path;
519
+ constructor(path) {
520
+ this.path = path;
521
+ }
522
+ read() {
523
+ try {
524
+ return {
525
+ schemaVersion: 1,
526
+ stacks: v.parse(stateSchema, JSON.parse(readFileSync(this.path, "utf8"))).stacks.map((stack) => {
527
+ const stored = {
528
+ remote: stack.remote,
529
+ base: stack.base,
530
+ changes: stack.changes
531
+ };
532
+ if (stack.stackNumber !== void 0) stored.stackNumber = stack.stackNumber;
533
+ return stored;
534
+ })
535
+ };
536
+ } catch (error) {
537
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return emptyState();
538
+ if (v.isValiError(error)) throw new Error(`Unsupported bstack state in ${this.path}`, { cause: error });
539
+ throw error;
540
+ }
541
+ }
542
+ write(state) {
543
+ mkdirSync(dirname(this.path), { recursive: true });
544
+ const temporary = `${this.path}.tmp`;
545
+ writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`);
546
+ renameSync(temporary, this.path);
547
+ }
548
+ };
549
+ //#endregion
550
+ //#region src/stack.ts
551
+ var Stack = class Stack {
552
+ changes;
553
+ rewritten;
554
+ rewrites;
555
+ constructor(changes, rewritten = false, rewrites = []) {
556
+ this.changes = changes;
557
+ this.rewritten = rewritten;
558
+ this.rewrites = rewrites;
559
+ if (changes.length === 0) throw new Error("A stack must contain at least one change");
560
+ }
561
+ static fromChanges(changes) {
562
+ return new Stack(changes);
563
+ }
564
+ static fromCommits(commits, userLogin) {
565
+ const ids = commits.map((commit) => commit.changeId ?? generateChangeId());
566
+ const rewritten = commits.some((commit) => commit.changeId === void 0);
567
+ const rewrites = rewritten ? commits.map((commit, index) => ({
568
+ commit,
569
+ message: commit.changeId ? commit.message : addChangeId(commit.message, ids[index])
570
+ })) : [];
571
+ const changes = commits.map((commit, index) => {
572
+ const id = ids[index];
573
+ const { subject, body } = splitCommitMessage(commit.message);
574
+ return {
575
+ id,
576
+ oid: commit.oid,
577
+ subject,
578
+ body,
579
+ remoteBranch: `bstack/${userLogin}/${id}`
580
+ };
581
+ });
582
+ return new Stack(changes, rewritten, rewrites);
583
+ }
584
+ writeChangeIds(repository) {
585
+ if (!this.rewritten) return this;
586
+ const rewrittenOids = repository.rewriteCommits(this.rewrites);
587
+ if (rewrittenOids.length !== this.changes.length) throw new Error(`Git rewrote ${rewrittenOids.length} commits for a stack with ${this.changes.length} changes`);
588
+ const changes = this.changes.map((change, index) => ({
589
+ ...change,
590
+ oid: rewrittenOids[index]
591
+ }));
592
+ return new Stack(changes, true);
593
+ }
594
+ findPrevious(state) {
595
+ const ids = new Set(this.changes.map((change) => change.id));
596
+ const matches = state.stacks.filter((stack) => stack.changes.some((change) => ids.has(change.id)));
597
+ if (matches.length > 1) throw new Error("The current commits match more than one stored bstack stack");
598
+ return matches[0];
599
+ }
600
+ transitionFrom(previous, options) {
601
+ if (!previous) return { kind: "full" };
602
+ const previousIds = previous.changes.map((change) => change.id);
603
+ const currentIds = this.changes.map((change) => change.id);
604
+ const previousIdSet = new Set(previousIds);
605
+ const currentIdSet = new Set(currentIds);
606
+ if (!sameSequence(previousIds.filter((id) => currentIdSet.has(id)), currentIds.filter((id) => previousIdSet.has(id)))) throw new Error("Submitted commits cannot be reordered. Restore their original relative order before syncing");
607
+ const removed = previous.changes.filter((change) => !currentIdSet.has(change.id));
608
+ const added = this.changes.filter((change) => !previousIdSet.has(change.id));
609
+ if (removed.length === 0) {
610
+ if (previousIds.every((id, index) => currentIds[index] === id)) return { kind: "full" };
611
+ const stackNumber = previous.stackNumber ?? options.lookups.stackNumberForPullRequest(previous.changes[0].pullRequest);
612
+ return stackNumber === void 0 ? { kind: "full" } : {
613
+ kind: "rebuild",
614
+ stackNumber,
615
+ action: "insert"
616
+ };
617
+ }
618
+ const firstCurrentIndex = previousIds.indexOf(currentIds[0]);
619
+ const isPreviousSlice = firstCurrentIndex >= 0 && currentIds.every((id, index) => previousIds[firstCurrentIndex + index] === id);
620
+ if (options.preserveHigherChanges && added.length === 0 && isPreviousSlice && firstCurrentIndex + currentIds.length < previousIds.length) {
621
+ if (previous.changes.slice(0, firstCurrentIndex).every((change) => options.lookups.pullRequestState(change.pullRequest) === "MERGED")) return {
622
+ kind: "partial",
623
+ previousOffset: firstCurrentIndex
624
+ };
625
+ }
626
+ const removedPrefixWasMerged = removed.every((change, index) => previous.changes[index] === change) && removed.every((change) => options.lookups.pullRequestState(change.pullRequest) === "MERGED");
627
+ const survivingIds = previousIds.slice(removed.length);
628
+ const onlyAppendedAfterMergedPrefix = removedPrefixWasMerged && survivingIds.every((id, index) => currentIds[index] === id) && currentIds.slice(survivingIds.length).every((id) => !previousIdSet.has(id));
629
+ if (removedPrefixWasMerged && added.length === 0) return { kind: "skip" };
630
+ if (onlyAppendedAfterMergedPrefix) {
631
+ if (previous.stackNumber === void 0) throw new Error("Cannot append after a merge because the native GitHub stack number is missing from local state");
632
+ return {
633
+ kind: "append",
634
+ stackNumber: previous.stackNumber,
635
+ branches: added.map((change) => change.remoteBranch)
636
+ };
637
+ }
638
+ const stackNumber = previous.stackNumber ?? options.lookups.stackNumberForPullRequest(previous.changes[0].pullRequest);
639
+ if (stackNumber === void 0) throw new Error("Cannot remove submitted commits because the native GitHub stack number is missing from local state");
640
+ if (this.changes.length === 1) return {
641
+ kind: "collapse",
642
+ stackNumber
643
+ };
644
+ return {
645
+ kind: "rebuild",
646
+ stackNumber,
647
+ action: added.length === 0 ? "remove" : "update"
648
+ };
649
+ }
650
+ };
651
+ function sameSequence(left, right) {
652
+ return left.length === right.length && left.every((value, index) => value === right[index]);
653
+ }
654
+ //#endregion
655
+ //#region src/sync.ts
656
+ function syncStack(dependencies, options) {
657
+ const { repository, github, stateStore, reporter } = dependencies;
658
+ reporter.progress("Checking the repository and GitHub prerequisites");
659
+ repository.assertReady();
660
+ github.assertReady();
661
+ const remote = repository.resolveRemote(options.remote);
662
+ const base = options.base ?? github.defaultBranch();
663
+ const userLogin = github.currentUserLogin();
664
+ reporter.progress(`Using ${remote} as the remote and ${base} as the stack base`);
665
+ reporter.progress(`Using ${userLogin} as the remote branch namespace`);
666
+ reporter.progress(`Fetching ${remote}/${base}`);
667
+ const remoteBase = repository.fetchBase(remote, base);
668
+ const baseOid = repository.mergeBase("HEAD", remoteBase);
669
+ const commits = repository.commitsSince(baseOid);
670
+ if (commits.length === 0) throw new Error(`No commits found between ${base} and HEAD`);
671
+ reporter.progress(`Found ${commits.length} local change${commits.length === 1 ? "" : "s"}`);
672
+ const pendingStack = Stack.fromCommits(commits, userLogin);
673
+ if (pendingStack.rewritten) reporter.progress(options.dryRun ? "Stable change IDs would be added to the commits" : "Adding stable change IDs to the commits");
674
+ else reporter.progress("All commits already have stable change IDs");
675
+ const stack = options.dryRun ? pendingStack : pendingStack.writeChangeIds(repository);
676
+ const { changes, rewritten } = stack;
677
+ if (options.dryRun) {
678
+ reporter.progress("Dry run complete; no commits or remote branches were changed");
679
+ return {
680
+ base,
681
+ remote,
682
+ rewritten,
683
+ changes: [...changes]
684
+ };
685
+ }
686
+ reporter.progress("Reading the previous stack state");
687
+ const state = stateStore.read();
688
+ const previous = stack.findPrevious(state);
689
+ const transition = stack.transitionFrom(previous, {
690
+ preserveHigherChanges: repository.currentBranch() === "",
691
+ lookups: {
692
+ pullRequestState: (pullRequest) => github.pullRequest(pullRequest).state,
693
+ stackNumberForPullRequest: (pullRequest) => github.stackNumberForPullRequest(pullRequest)
694
+ }
695
+ });
696
+ reporter.progress(`Pushing ${changes.length} remote branch${changes.length === 1 ? "" : "es"}`);
697
+ repository.pushBranches(remote, changes.map((change) => ({
698
+ name: change.remoteBranch,
699
+ oid: change.oid
700
+ })));
701
+ reporter.progress("Looking up existing pull requests");
702
+ const existing = changes.map((change) => github.pullRequestForBranch(change.remoteBranch));
703
+ let pullRequests;
704
+ if (changes.length === 1) {
705
+ if (transition.kind === "partial") reporter.progress("Updating this down-stack prefix while preserving higher pull requests");
706
+ reporter.progress(existing[0] ? "Using the existing pull request" : "Creating a pull request");
707
+ const pullRequest = existing[0] ?? github.createPullRequest(changes[0], base, options.draft);
708
+ if (transition.kind === "collapse") {
709
+ reporter.progress(`Removing omitted pull requests from stack #${transition.stackNumber}`);
710
+ github.unstack(transition.stackNumber);
711
+ try {
712
+ github.editPullRequestBase(pullRequest, base);
713
+ } catch (error) {
714
+ restorePreviousStack(github, previous, base, remote, reporter, error);
715
+ }
716
+ }
717
+ pullRequests = [pullRequest];
718
+ } else {
719
+ if (transition.kind === "full") {
720
+ reporter.progress(`Linking ${changes.length} pull requests as a native GitHub stack`);
721
+ github.linkStack(changes.map((change) => change.remoteBranch), base, remote, options.draft);
722
+ } else if (transition.kind === "rebuild") {
723
+ reporter.progress(`Rebuilding stack #${transition.stackNumber} to ${transition.action} pull requests`);
724
+ github.unstack(transition.stackNumber);
725
+ try {
726
+ github.linkStack(changes.map((change) => change.remoteBranch), base, remote, options.draft);
727
+ } catch (error) {
728
+ restorePreviousStack(github, previous, base, remote, reporter, error);
729
+ }
730
+ } else if (transition.kind === "append") {
731
+ reporter.progress(`Appending ${transition.branches.length} pull request${transition.branches.length === 1 ? "" : "s"} to stack #${transition.stackNumber}`);
732
+ github.appendToStack(transition.stackNumber, transition.branches, remote, options.draft);
733
+ } else if (transition.kind === "partial") reporter.progress("Updating this down-stack prefix while preserving higher pull requests");
734
+ else reporter.progress("The native GitHub stack already has the correct members");
735
+ pullRequests = changes.map((change, index) => {
736
+ const pr = existing[index] ?? github.pullRequestForBranch(change.remoteBranch);
737
+ if (!pr) throw new Error(`GitHub did not return a PR for ${change.remoteBranch}`);
738
+ return pr;
739
+ });
740
+ }
741
+ reporter.progress("Synchronizing pull request titles and descriptions");
742
+ for (const [index, pr] of pullRequests.entries()) {
743
+ github.editPullRequest(pr, changes[index]);
744
+ reporter.progress(`PR #${pr.number}: ${changes[index].subject}`);
745
+ }
746
+ const stackNumber = transition.kind === "rebuild" ? github.stackNumberForPullRequest(pullRequests[0].number) : transition.kind === "collapse" ? void 0 : previous?.stackNumber ?? (pullRequests.length > 1 ? github.stackNumberForPullRequest(pullRequests[0].number) : void 0);
747
+ const synchronizedChanges = changes.map((change, index) => ({
748
+ id: change.id,
749
+ remoteBranch: change.remoteBranch,
750
+ pullRequest: pullRequests[index].number,
751
+ url: pullRequests[index].url
752
+ }));
753
+ const updatedStack = {
754
+ remote,
755
+ base,
756
+ changes: transition.kind === "partial" && previous ? [...synchronizedChanges, ...previous.changes.slice(transition.previousOffset + changes.length)] : synchronizedChanges
757
+ };
758
+ if (stackNumber !== void 0) updatedStack.stackNumber = stackNumber;
759
+ writeUpdatedState(stateStore, state, previous, updatedStack);
760
+ reporter.progress("Saved the local stack state");
761
+ return {
762
+ base,
763
+ remote,
764
+ rewritten,
765
+ changes: changes.map((change, index) => ({
766
+ ...change,
767
+ pullRequest: pullRequests[index]
768
+ }))
769
+ };
770
+ }
771
+ function restorePreviousStack(github, previous, base, remote, reporter, rebuildError) {
772
+ const rebuildMessage = rebuildError instanceof Error ? rebuildError.message : String(rebuildError);
773
+ reporter.progress("Rebuild failed; restoring the previous native GitHub stack");
774
+ try {
775
+ github.linkStack(previous.changes.map((change) => change.remoteBranch), base, remote, true);
776
+ } catch (rollbackError) {
777
+ const rollbackMessage = rollbackError instanceof Error ? rollbackError.message : String(rollbackError);
778
+ throw new Error(`Stack rebuild failed: ${rebuildMessage}\nRestoring the previous stack also failed: ${rollbackMessage}`);
779
+ }
780
+ throw rebuildError;
781
+ }
782
+ function writeUpdatedState(store, state, previous, updated) {
783
+ const stacks = previous ? state.stacks.map((stack) => stack === previous ? updated : stack) : [...state.stacks, updated];
784
+ store.write({
785
+ schemaVersion: 1,
786
+ stacks
787
+ });
788
+ }
789
+ //#endregion
790
+ //#region src/cli.ts
791
+ const help = `bstack - turn a linear commit series into a native GitHub stack of PRs
792
+
793
+ Usage:
794
+ bstack [sync] [options]
795
+ bstack checkout <PR-number-or-URL> [options]
796
+
797
+ Options:
798
+ --base <branch> Stack base; defaults to the GitHub default branch
799
+ --remote <name> Git remote; defaults to remote.pushDefault or origin
800
+ --draft Create draft PRs instead of ready-for-review PRs
801
+ --dry-run Inspect the stack without rewriting commits or pushing
802
+ --verbose Show each git and gh command before it runs
803
+ --same-base Refuse checkout if it would change the current merge base
804
+ -v, --version Show the installed version
805
+ -h, --help Show this help
806
+ `;
807
+ function main() {
808
+ const { values, positionals } = parseArgs({
809
+ args: process.argv.slice(2),
810
+ allowPositionals: true,
811
+ options: {
812
+ base: { type: "string" },
813
+ remote: { type: "string" },
814
+ draft: {
815
+ type: "boolean",
816
+ default: false
817
+ },
818
+ "dry-run": {
819
+ type: "boolean",
820
+ default: false
821
+ },
822
+ verbose: {
823
+ type: "boolean",
824
+ default: false
825
+ },
826
+ "same-base": {
827
+ type: "boolean",
828
+ default: false
829
+ },
830
+ version: {
831
+ type: "boolean",
832
+ short: "v",
833
+ default: false
834
+ },
835
+ help: {
836
+ type: "boolean",
837
+ short: "h",
838
+ default: false
839
+ }
840
+ }
841
+ });
842
+ if (values.help) {
843
+ process.stdout.write(help);
844
+ return;
845
+ }
846
+ if (values.version) {
847
+ console.log(version);
848
+ return;
849
+ }
850
+ const reporter = new ConsoleReporter();
851
+ const runner = new NodeCommandRunner(values.verbose ? (invocation) => reporter.command(invocation) : void 0);
852
+ const cwd = process.cwd();
853
+ const repository = new GitCliRepository(cwd, runner);
854
+ const github = new GitHubCliPlatform(cwd, runner);
855
+ const command = positionals[0] ?? "sync";
856
+ if (command === "checkout") {
857
+ const reference = positionals[1];
858
+ if (!reference || positionals.length > 2) throw new Error(`Usage: bstack checkout <PR-number-or-URL> [options]`);
859
+ const result = checkoutStack({
860
+ repository,
861
+ github,
862
+ reporter
863
+ }, {
864
+ reference,
865
+ base: values.base,
866
+ remote: values.remote,
867
+ sameBase: values["same-base"]
868
+ });
869
+ console.log(result.delegated ? `Checked out pull request ${reference}` : `Checked out ${result.headRef} from pull request ${reference}`);
870
+ return;
871
+ }
872
+ if (command !== "sync" || positionals.length > 1) throw new Error(`Unknown command: ${positionals.join(" ")}\n\n${help}`);
873
+ const result = syncStack({
874
+ repository,
875
+ github,
876
+ stateStore: new FileStateStore(repository.statePath()),
877
+ reporter
878
+ }, {
879
+ base: values.base,
880
+ remote: values.remote,
881
+ draft: values.draft,
882
+ dryRun: values["dry-run"]
883
+ });
884
+ console.log(`${values["dry-run"] ? "Would sync" : "Synced"} ${result.changes.length} change${result.changes.length === 1 ? "" : "s"} against ${result.base}:`);
885
+ for (const change of result.changes) {
886
+ const destination = change.pullRequest ? ` ${change.pullRequest.url}` : "";
887
+ console.log(` ${change.oid.slice(0, 8)} ${change.subject}${destination}`);
888
+ }
889
+ }
890
+ try {
891
+ main();
892
+ } catch (error) {
893
+ const message = error instanceof Error ? error.message : String(error);
894
+ console.error(`bstack: ${message}`);
895
+ process.exitCode = 1;
896
+ }
897
+ //#endregion
898
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bstack",
3
- "version": "1.1.3",
3
+ "version": "1.1.4",
4
4
  "description": "Create native GitHub stacked pull requests from a linear series of commits",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -8,10 +8,10 @@
8
8
  "url": "git+https://github.com/wsehl/bstack.git"
9
9
  },
10
10
  "bin": {
11
- "bstack": "./dist/bstack.js"
11
+ "bstack": "./dist/bstack.mjs"
12
12
  },
13
13
  "files": [
14
- "dist/bstack.js"
14
+ "dist/bstack.mjs"
15
15
  ],
16
16
  "type": "module",
17
17
  "publishConfig": {
@@ -24,7 +24,7 @@
24
24
  "lint": "oxlint .",
25
25
  "prepack": "pnpm run build",
26
26
  "prepublishOnly": "pnpm run type-check && pnpm test",
27
- "start": "pnpm run build && node dist/bstack.js",
27
+ "start": "pnpm run build && node dist/bstack.mjs",
28
28
  "test": "vitest run",
29
29
  "type-check": "tsc --noEmit"
30
30
  },