bstack 0.2.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +14 -26
  2. package/dist/bstack.js +155 -85
  3. package/package.json +4 -1
package/README.md CHANGED
@@ -1,22 +1,23 @@
1
1
  # bstack
2
2
 
3
- `bstack` turns a series of commits on a local branch into native GitHub stacked pull requests.
3
+ Convert series of commits in a local branch into native GitHub stacked pull requests.
4
4
 
5
5
  ## Install
6
6
 
7
- Requirements:
7
+ ```bash
8
+ npm install -g bstack
9
+ ```
8
10
 
9
- - Node.js 20+
10
- - `gh cli` plus the `github/gh-stack` extension (authenticated)
11
+ Install and authenticate the GitHub CLI with the `github/gh-stack` extension:
11
12
 
12
- ```bash
13
+ ```
14
+ gh auth login
13
15
  gh extension install github/gh-stack
14
- npm install bstack -g
15
16
  ```
16
17
 
17
18
  ## Use
18
19
 
19
- Create one commit per reviewable change, then publish the stack:
20
+ Create one commit per change, then publish the stack:
20
21
 
21
22
  ```bash
22
23
  git switch -c my-feature
@@ -25,7 +26,9 @@ git commit -am "feat: add the API"
25
26
  bstack
26
27
  ```
27
28
 
28
- Run `bstack` again after amending or rebasing commits. New PRs are drafts unless you pass `--open`.
29
+ Run `bstack` again after amending or rebasing commits.
30
+
31
+ New PRs are ready for review by default. Pass `--draft` to create draft PRs.
29
32
 
30
33
  Checkout a stack through one of its PRs:
31
34
 
@@ -34,24 +37,9 @@ bstack checkout 123
34
37
  bstack checkout https://github.com/owner/repo/pull/123
35
38
  ```
36
39
 
37
- The first publish adds a stable `Bstack-Id` trailer to each commit and rewrites their hashes. The working tree must be clean. Merge commits and signed commits that need trailers are not supported.
38
-
39
40
  Use `--dry-run` to inspect without publishing and `--quiet` to hide progress logs.
40
41
 
41
- ## Develop
42
-
43
- Install dependencies and run the checks with pnpm:
42
+ ## References
44
43
 
45
- ```bash
46
- pnpm install
47
- pnpm run type-check
48
- pnpm test
49
- pnpm run lint
50
- ```
51
-
52
- Build the Node.js CLI with tsdown:
53
-
54
- ```bash
55
- pnpm run build
56
- node dist/bstack.js --help
57
- ```
44
+ - [ezyang/ghstack](https://github.com/ezyang/ghstack)
45
+ - [github/gh-stack](https://github.com/github/gh-stack)
package/dist/bstack.js CHANGED
@@ -2,10 +2,11 @@
2
2
  import { parseArgs } from "node:util";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { randomUUID } from "node:crypto";
5
+ import { z } from "zod";
5
6
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
6
7
  import { dirname } from "node:path";
7
8
  //#region package.json
8
- var version = "0.2.2";
9
+ var version = "1.0.0";
9
10
  //#endregion
10
11
  //#region src/command.ts
11
12
  var CommandError = class extends Error {
@@ -82,14 +83,14 @@ function checkoutStack(repository, github, options) {
82
83
  }
83
84
  //#endregion
84
85
  //#region src/identity.ts
85
- const trailerPattern = /^Bstack-Id:\s*(\S+)\s*$/gim;
86
+ const trailerPattern = /^bstack-id:\s*(\S+)\s*$/gm;
86
87
  function readChangeId(message) {
87
88
  const matches = [...message.matchAll(trailerPattern)];
88
- if (matches.length > 1) throw new Error("A commit contains more than one Bstack-Id trailer");
89
+ if (matches.length > 1) throw new Error("A commit contains more than one bstack-id trailer");
89
90
  return matches[0]?.[1];
90
91
  }
91
92
  function addChangeId(message, changeId) {
92
- return `${message.trimEnd()}\n\nBstack-Id: ${changeId}\n`;
93
+ return `${message.trimEnd()}\n\nbstack-id: ${changeId}\n`;
93
94
  }
94
95
  function newChangeId() {
95
96
  return randomUUID().replaceAll("-", "");
@@ -128,7 +129,7 @@ function rewriteCommit(commit, parent, message) {
128
129
  return `${rewrittenHeaders.join("\n")}\n\n${message}`;
129
130
  }
130
131
  function splitCommitMessage(message) {
131
- const [subject = "Untitled change", ...bodyLines] = message.split("\n").filter((line) => !/^Bstack-Id:\s*\S+\s*$/i.test(line)).join("\n").trim().split("\n");
132
+ const [subject = "Untitled change", ...bodyLines] = message.split("\n").filter((line) => !/^bstack-id:\s*\S+\s*$/.test(line)).join("\n").trim().split("\n");
132
133
  return {
133
134
  subject,
134
135
  body: bodyLines.join("\n").trim()
@@ -230,7 +231,7 @@ var GitRepository = class {
230
231
  oid
231
232
  ]).stdout));
232
233
  }
233
- ensureChangeIds(commits, dryRun) {
234
+ ensureChangeIds(commits, dryRun, userLogin) {
234
235
  const assigned = commits.map((commit) => commit.changeId ?? newChangeId());
235
236
  const needsRewrite = commits.some((commit) => commit.changeId === void 0);
236
237
  let parent = commits[0]?.parent;
@@ -268,7 +269,7 @@ var GitRepository = class {
268
269
  oid,
269
270
  subject,
270
271
  body,
271
- remoteBranch: `bstack/${id}`
272
+ remoteBranch: `bstack/${userLogin}/${id}`
272
273
  };
273
274
  });
274
275
  }
@@ -313,6 +314,19 @@ var GitRepository = class {
313
314
  };
314
315
  //#endregion
315
316
  //#region src/github.ts
317
+ const pullRequestSchema = z.object({
318
+ number: z.number(),
319
+ url: z.string(),
320
+ state: z.enum([
321
+ "OPEN",
322
+ "CLOSED",
323
+ "MERGED"
324
+ ]),
325
+ title: z.string(),
326
+ body: z.string(),
327
+ isDraft: z.boolean()
328
+ });
329
+ const stackSchema = z.object({ number: z.number() });
316
330
  var GhPlatform = class {
317
331
  cwd;
318
332
  runner;
@@ -331,6 +345,14 @@ var GhPlatform = class {
331
345
  ]);
332
346
  this.gh(["stack", "--version"]);
333
347
  }
348
+ currentUserLogin() {
349
+ return z.string().min(1).parse(this.gh([
350
+ "api",
351
+ "user",
352
+ "--jq",
353
+ ".login"
354
+ ]).stdout.trim());
355
+ }
334
356
  defaultBranch() {
335
357
  return this.gh([
336
358
  "repo",
@@ -354,9 +376,8 @@ var GhPlatform = class {
354
376
  "--json",
355
377
  "number,url,state,title,body,isDraft"
356
378
  ]).stdout;
357
- const candidates = JSON.parse(raw);
358
- const selected = candidates.find((pr) => pr.state === "OPEN") ?? candidates.find((pr) => pr.state === "MERGED");
359
- return selected ? normalizePullRequest(selected) : void 0;
379
+ const candidates = pullRequestSchema.array().parse(JSON.parse(raw));
380
+ return candidates.find((pr) => pr.state === "OPEN") ?? candidates.find((pr) => pr.state === "MERGED");
360
381
  }
361
382
  pullRequest(number) {
362
383
  const raw = this.gh([
@@ -366,9 +387,9 @@ var GhPlatform = class {
366
387
  "--json",
367
388
  "number,url,state,title,body,isDraft"
368
389
  ]).stdout;
369
- return normalizePullRequest(JSON.parse(raw));
390
+ return pullRequestSchema.parse(JSON.parse(raw));
370
391
  }
371
- createPullRequest(change, base, open) {
392
+ createPullRequest(change, base, draft) {
372
393
  const args = [
373
394
  "pr",
374
395
  "create",
@@ -381,13 +402,13 @@ var GhPlatform = class {
381
402
  "--body",
382
403
  change.body
383
404
  ];
384
- if (!open) args.push("--draft");
405
+ if (draft) args.push("--draft");
385
406
  this.gh(args);
386
407
  const created = this.pullRequestForBranch(change.remoteBranch);
387
408
  if (!created) throw new Error(`GitHub did not return the PR created for ${change.remoteBranch}`);
388
409
  return created;
389
410
  }
390
- linkStack(branches, base, remote, open) {
411
+ linkStack(branches, base, remote, draft) {
391
412
  const args = [
392
413
  "stack",
393
414
  "link",
@@ -396,18 +417,18 @@ var GhPlatform = class {
396
417
  "--remote",
397
418
  remote
398
419
  ];
399
- if (open) args.push("--open");
420
+ if (!draft) args.push("--open");
400
421
  args.push(...branches);
401
422
  this.gh(args);
402
423
  }
403
- appendToStack(stackNumber, branches, remote, open) {
424
+ appendToStack(stackNumber, branches, remote, draft) {
404
425
  const args = [
405
426
  "stack",
406
427
  "link",
407
428
  "--remote",
408
429
  remote
409
430
  ];
410
- if (open) args.push("--open");
431
+ if (!draft) args.push("--open");
411
432
  args.push(String(stackNumber), ...branches);
412
433
  this.gh(args);
413
434
  }
@@ -418,6 +439,15 @@ var GhPlatform = class {
418
439
  String(stackNumber)
419
440
  ]);
420
441
  }
442
+ editPullRequestBase(pr, base) {
443
+ this.gh([
444
+ "pr",
445
+ "edit",
446
+ String(pr.number),
447
+ "--base",
448
+ base
449
+ ]);
450
+ }
421
451
  editPullRequest(pr, change) {
422
452
  if (pr.title === change.subject && pr.body === change.body) return;
423
453
  this.gh([
@@ -432,7 +462,7 @@ var GhPlatform = class {
432
462
  }
433
463
  stackNumberForPullRequest(prNumber) {
434
464
  const raw = this.gh(["api", `repos/{owner}/{repo}/stacks?pull_request=${prNumber}`]).stdout;
435
- return JSON.parse(raw)[0]?.number;
465
+ return stackSchema.array().parse(JSON.parse(raw))[0]?.number;
436
466
  }
437
467
  pullRequestHead(reference) {
438
468
  return this.gh([
@@ -453,13 +483,6 @@ var GhPlatform = class {
453
483
  ]);
454
484
  }
455
485
  };
456
- function normalizePullRequest(pr) {
457
- if (pr.state !== "OPEN" && pr.state !== "CLOSED" && pr.state !== "MERGED") throw new Error(`GitHub returned an unknown PR state: ${pr.state}`);
458
- return {
459
- ...pr,
460
- state: pr.state
461
- };
462
- }
463
486
  //#endregion
464
487
  //#region src/reporter.ts
465
488
  var ConsoleReporter = class {
@@ -477,6 +500,22 @@ const emptyState = () => ({
477
500
  schemaVersion: 1,
478
501
  stacks: []
479
502
  });
503
+ const storedChangeSchema = z.object({
504
+ id: z.string(),
505
+ remoteBranch: z.string(),
506
+ pullRequest: z.number(),
507
+ url: z.string()
508
+ });
509
+ const storedStackSchema = z.object({
510
+ remote: z.string(),
511
+ base: z.string(),
512
+ stackNumber: z.number().optional(),
513
+ changes: storedChangeSchema.array()
514
+ });
515
+ const stateSchema = z.object({
516
+ schemaVersion: z.literal(1),
517
+ stacks: storedStackSchema.array()
518
+ });
480
519
  var StateStore = class {
481
520
  path;
482
521
  constructor(path) {
@@ -484,11 +523,21 @@ var StateStore = class {
484
523
  }
485
524
  read() {
486
525
  try {
487
- const parsed = JSON.parse(readFileSync(this.path, "utf8"));
488
- if (!isState(parsed)) throw new Error(`Unsupported bstack state in ${this.path}`);
489
- return parsed;
526
+ return {
527
+ schemaVersion: 1,
528
+ stacks: stateSchema.parse(JSON.parse(readFileSync(this.path, "utf8"))).stacks.map((stack) => {
529
+ const stored = {
530
+ remote: stack.remote,
531
+ base: stack.base,
532
+ changes: stack.changes
533
+ };
534
+ if (stack.stackNumber !== void 0) stored.stackNumber = stack.stackNumber;
535
+ return stored;
536
+ })
537
+ };
490
538
  } catch (error) {
491
- if (isMissingFile(error)) return emptyState();
539
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return emptyState();
540
+ if (error instanceof z.ZodError) throw new Error(`Unsupported bstack state in ${this.path}`, { cause: error });
492
541
  throw error;
493
542
  }
494
543
  }
@@ -504,14 +553,6 @@ var StateStore = class {
504
553
  return matches[0];
505
554
  }
506
555
  };
507
- function isMissingFile(error) {
508
- return error instanceof Error && "code" in error && error.code === "ENOENT";
509
- }
510
- function isState(value) {
511
- if (typeof value !== "object" || value === null) return false;
512
- const candidate = value;
513
- return candidate.schemaVersion === 1 && Array.isArray(candidate.stacks);
514
- }
515
556
  //#endregion
516
557
  //#region src/sync.ts
517
558
  function syncStack(repository, github, options) {
@@ -521,7 +562,9 @@ function syncStack(repository, github, options) {
521
562
  github.assertReady();
522
563
  const remote = repository.resolveRemote(options.remote);
523
564
  const base = options.base ?? github.defaultBranch();
565
+ const userLogin = github.currentUserLogin();
524
566
  reporter.progress(`Using ${remote} as the remote and ${base} as the stack base`);
567
+ reporter.progress(`Using ${userLogin} as the remote branch namespace`);
525
568
  reporter.progress(`Fetching ${remote}/${base}`);
526
569
  const remoteBase = repository.fetchBase(remote, base);
527
570
  const baseOid = repository.mergeBase("HEAD", remoteBase);
@@ -531,7 +574,7 @@ function syncStack(repository, github, options) {
531
574
  const rewritten = commits.some((commit) => commit.changeId === void 0);
532
575
  if (rewritten) reporter.progress(options.dryRun ? "Stable change IDs would be added to the commits" : "Adding stable change IDs to the commits");
533
576
  else reporter.progress("All commits already have stable change IDs");
534
- const changes = repository.ensureChangeIds(commits, options.dryRun);
577
+ const changes = repository.ensureChangeIds(commits, options.dryRun, userLogin);
535
578
  if (options.dryRun) {
536
579
  reporter.progress("Dry run complete; no commits or remote branches were changed");
537
580
  return {
@@ -545,7 +588,7 @@ function syncStack(repository, github, options) {
545
588
  const store = new StateStore(repository.statePath());
546
589
  const state = store.read();
547
590
  const previous = store.findByChangeIds(state, new Set(changes.map((change) => change.id)));
548
- const evolution = analyzeEvolution(previous, changes, github);
591
+ const evolution = analyzeEvolution(previous, changes, github, repository.currentBranch() === "");
549
592
  reporter.progress(`Publishing ${changes.length} protected remote ref${changes.length === 1 ? "" : "s"}`);
550
593
  repository.pushChanges(remote, changes);
551
594
  reporter.progress("Looking up existing pull requests");
@@ -554,28 +597,32 @@ function syncStack(repository, github, options) {
554
597
  if (changes.length === 1) {
555
598
  if (evolution.kind === "partial") reporter.progress("Updating this down-stack prefix while preserving higher pull requests");
556
599
  reporter.progress(existing[0] ? "Using the existing pull request" : "Creating a pull request");
557
- pullRequests = [existing[0] ?? github.createPullRequest(changes[0], base, options.open)];
600
+ const pullRequest = existing[0] ?? github.createPullRequest(changes[0], base, options.draft);
601
+ if (evolution.kind === "collapse") {
602
+ reporter.progress(`Removing omitted pull requests from stack #${evolution.stackNumber}`);
603
+ github.unstack(evolution.stackNumber);
604
+ try {
605
+ github.editPullRequestBase(pullRequest, base);
606
+ } catch (error) {
607
+ restorePreviousStack(github, previous, base, remote, reporter, error);
608
+ }
609
+ }
610
+ pullRequests = [pullRequest];
558
611
  } else {
559
612
  if (evolution.kind === "full") {
560
613
  reporter.progress(`Linking ${changes.length} pull requests as a native GitHub stack`);
561
- github.linkStack(changes.map((change) => change.remoteBranch), base, remote, options.open);
614
+ github.linkStack(changes.map((change) => change.remoteBranch), base, remote, options.draft);
562
615
  } else if (evolution.kind === "rebuild") {
563
- reporter.progress(`Rebuilding stack #${evolution.stackNumber} to insert pull requests`);
616
+ reporter.progress(`Rebuilding stack #${evolution.stackNumber} to ${evolution.action} pull requests`);
564
617
  github.unstack(evolution.stackNumber);
565
618
  try {
566
- github.linkStack(changes.map((change) => change.remoteBranch), base, remote, options.open);
619
+ github.linkStack(changes.map((change) => change.remoteBranch), base, remote, options.draft);
567
620
  } catch (error) {
568
- reporter.progress("Rebuild failed; restoring the previous native GitHub stack");
569
- try {
570
- github.linkStack(previous.changes.map((change) => change.remoteBranch), base, remote, false);
571
- } catch (rollbackError) {
572
- throw new Error(`Stack rebuild failed: ${errorMessage(error)}\nRestoring the previous stack also failed: ${errorMessage(rollbackError)}`);
573
- }
574
- throw error;
621
+ restorePreviousStack(github, previous, base, remote, reporter, error);
575
622
  }
576
623
  } else if (evolution.kind === "append") {
577
624
  reporter.progress(`Appending ${evolution.branches.length} pull request${evolution.branches.length === 1 ? "" : "s"} to stack #${evolution.stackNumber}`);
578
- github.appendToStack(evolution.stackNumber, evolution.branches, remote, options.open);
625
+ github.appendToStack(evolution.stackNumber, evolution.branches, remote, options.draft);
579
626
  } else if (evolution.kind === "partial") reporter.progress("Updating this down-stack prefix while preserving higher pull requests");
580
627
  else reporter.progress("The native GitHub stack already has the correct members");
581
628
  pullRequests = changes.map((change, index) => {
@@ -589,20 +636,20 @@ function syncStack(repository, github, options) {
589
636
  github.editPullRequest(pr, changes[index]);
590
637
  reporter.progress(`PR #${pr.number}: ${changes[index].subject}`);
591
638
  }
592
- const stackNumber = evolution.kind === "rebuild" ? github.stackNumberForPullRequest(pullRequests[0].number) : previous?.stackNumber ?? (pullRequests.length > 1 ? github.stackNumberForPullRequest(pullRequests[0].number) : void 0);
639
+ const stackNumber = evolution.kind === "rebuild" ? github.stackNumberForPullRequest(pullRequests[0].number) : evolution.kind === "collapse" ? void 0 : previous?.stackNumber ?? (pullRequests.length > 1 ? github.stackNumberForPullRequest(pullRequests[0].number) : void 0);
593
640
  const synchronizedChanges = changes.map((change, index) => ({
594
641
  id: change.id,
595
642
  remoteBranch: change.remoteBranch,
596
643
  pullRequest: pullRequests[index].number,
597
644
  url: pullRequests[index].url
598
645
  }));
599
- const storedChanges = evolution.kind === "partial" && previous ? [...synchronizedChanges, ...previous.changes.slice(evolution.previousOffset + changes.length)] : synchronizedChanges;
600
- writeUpdatedState(store, state, previous, {
646
+ const stored = {
601
647
  remote,
602
648
  base,
603
- ...stackNumber === void 0 ? {} : { stackNumber },
604
- changes: storedChanges
605
- });
649
+ changes: evolution.kind === "partial" && previous ? [...synchronizedChanges, ...previous.changes.slice(evolution.previousOffset + changes.length)] : synchronizedChanges
650
+ };
651
+ if (stackNumber !== void 0) stored.stackNumber = stackNumber;
652
+ writeUpdatedState(store, state, previous, stored);
606
653
  reporter.progress("Saved the local stack state");
607
654
  return {
608
655
  base,
@@ -614,46 +661,69 @@ function syncStack(repository, github, options) {
614
661
  }))
615
662
  };
616
663
  }
617
- function analyzeEvolution(previous, changes, github) {
664
+ function analyzeEvolution(previous, changes, github, preserveHigherChanges) {
618
665
  if (!previous) return { kind: "full" };
619
666
  const previousIds = previous.changes.map((change) => change.id);
620
667
  const currentIds = changes.map((change) => change.id);
621
- if (isSubsequence(previousIds, currentIds)) {
668
+ const previousIdSet = new Set(previousIds);
669
+ const currentIdSet = new Set(currentIds);
670
+ if (!sameValues(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");
671
+ const removed = previous.changes.filter((change) => !currentIdSet.has(change.id));
672
+ const added = changes.filter((change) => !previousIdSet.has(change.id));
673
+ if (removed.length === 0) {
622
674
  if (previousIds.every((id, index) => currentIds[index] === id)) return { kind: "full" };
623
675
  const stackNumber = previous.stackNumber ?? github.stackNumberForPullRequest(previous.changes[0].pullRequest);
624
676
  return stackNumber === void 0 ? { kind: "full" } : {
625
677
  kind: "rebuild",
626
- stackNumber
678
+ stackNumber,
679
+ action: "insert"
627
680
  };
628
681
  }
629
682
  const firstCurrentIndex = previousIds.indexOf(currentIds[0]);
630
- if (firstCurrentIndex === -1) throw new Error("The current commits do not continue the previously submitted stack");
631
- const removedPrefix = previous.changes.slice(0, firstCurrentIndex);
632
- for (const removed of removedPrefix) if (github.pullRequest(removed.pullRequest).state !== "MERGED") throw new Error("Submitted commits may only disappear from the bottom after their pull requests are merged");
633
- const surviving = previousIds.slice(firstCurrentIndex);
634
- const sharedLength = Math.min(surviving.length, currentIds.length);
635
- for (let index = 0; index < sharedLength; index++) if (surviving[index] !== currentIds[index]) throw new Error("Reordering or removing submitted commits is not supported yet. Restore the original order before syncing");
636
- if (currentIds.length < surviving.length) return {
637
- kind: "partial",
638
- previousOffset: firstCurrentIndex
683
+ const isPreviousSlice = firstCurrentIndex >= 0 && currentIds.every((id, index) => previousIds[firstCurrentIndex + index] === id);
684
+ if (preserveHigherChanges && added.length === 0 && isPreviousSlice && firstCurrentIndex + currentIds.length < previousIds.length) {
685
+ if (previous.changes.slice(0, firstCurrentIndex).every((change) => github.pullRequest(change.pullRequest).state === "MERGED")) return {
686
+ kind: "partial",
687
+ previousOffset: firstCurrentIndex
688
+ };
689
+ }
690
+ const removedPrefixWasMerged = removed.every((change, index) => previous.changes[index] === change) && removed.every((change) => github.pullRequest(change.pullRequest).state === "MERGED");
691
+ const survivingIds = previousIds.slice(removed.length);
692
+ const onlyAppendedAfterMergedPrefix = removedPrefixWasMerged && survivingIds.every((id, index) => currentIds[index] === id) && currentIds.slice(survivingIds.length).every((id) => !previousIdSet.has(id));
693
+ if (removedPrefixWasMerged && added.length === 0) return { kind: "skip" };
694
+ if (onlyAppendedAfterMergedPrefix) {
695
+ if (previous.stackNumber === void 0) throw new Error("Cannot append after a merge because the native GitHub stack number is missing from local state");
696
+ return {
697
+ kind: "append",
698
+ stackNumber: previous.stackNumber,
699
+ branches: added.map((change) => change.remoteBranch)
700
+ };
701
+ }
702
+ const stackNumber = previous.stackNumber ?? github.stackNumberForPullRequest(previous.changes[0].pullRequest);
703
+ if (stackNumber === void 0) throw new Error("Cannot remove submitted commits because the native GitHub stack number is missing from local state");
704
+ if (changes.length === 1) return {
705
+ kind: "collapse",
706
+ stackNumber
639
707
  };
640
- const appended = changes.slice(surviving.length);
641
- if (removedPrefix.length === 0) return { kind: "full" };
642
- if (appended.length === 0) return { kind: "skip" };
643
- if (previous.stackNumber === void 0) throw new Error("Cannot append after a merge because the native GitHub stack number is missing from local state");
644
708
  return {
645
- kind: "append",
646
- stackNumber: previous.stackNumber,
647
- branches: appended.map((change) => change.remoteBranch)
709
+ kind: "rebuild",
710
+ stackNumber,
711
+ action: added.length === 0 ? "remove" : "update"
648
712
  };
649
713
  }
650
- function isSubsequence(expected, actual) {
651
- let expectedIndex = 0;
652
- for (const value of actual) if (value === expected[expectedIndex]) expectedIndex++;
653
- return expectedIndex === expected.length;
714
+ function sameValues(left, right) {
715
+ return left.length === right.length && left.every((value, index) => value === right[index]);
654
716
  }
655
- function errorMessage(error) {
656
- return error instanceof Error ? error.message : String(error);
717
+ function restorePreviousStack(github, previous, base, remote, reporter, rebuildError) {
718
+ const rebuildMessage = rebuildError instanceof Error ? rebuildError.message : String(rebuildError);
719
+ reporter.progress("Rebuild failed; restoring the previous native GitHub stack");
720
+ try {
721
+ github.linkStack(previous.changes.map((change) => change.remoteBranch), base, remote, true);
722
+ } catch (rollbackError) {
723
+ const rollbackMessage = rollbackError instanceof Error ? rollbackError.message : String(rollbackError);
724
+ throw new Error(`Stack rebuild failed: ${rebuildMessage}\nRestoring the previous stack also failed: ${rollbackMessage}`);
725
+ }
726
+ throw rebuildError;
657
727
  }
658
728
  function writeUpdatedState(store, state, previous, updated) {
659
729
  const stacks = previous ? state.stacks.map((stack) => stack === previous ? updated : stack) : [...state.stacks, updated];
@@ -673,7 +743,7 @@ Usage:
673
743
  Options:
674
744
  --base <branch> Stack trunk; defaults to the GitHub default branch
675
745
  --remote <name> Git remote; defaults to remote.pushDefault or origin
676
- --open Create PRs ready for review instead of drafts
746
+ --draft Create draft PRs instead of ready-for-review PRs
677
747
  --dry-run Inspect the stack without rewriting commits or pushing
678
748
  --quiet Hide progress logs; the final summary is still printed
679
749
  --same-base Refuse checkout if it would change the current merge base
@@ -687,7 +757,7 @@ function main() {
687
757
  options: {
688
758
  base: { type: "string" },
689
759
  remote: { type: "string" },
690
- open: {
760
+ draft: {
691
761
  type: "boolean",
692
762
  default: false
693
763
  },
@@ -746,7 +816,7 @@ function main() {
746
816
  const result = syncStack(repository, github, {
747
817
  base: values.base,
748
818
  remote: values.remote,
749
- open: values.open,
819
+ draft: values.draft,
750
820
  dryRun: values["dry-run"],
751
821
  reporter
752
822
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bstack",
3
- "version": "0.2.2",
3
+ "version": "1.0.0",
4
4
  "packageManager": "pnpm@11.5.1",
5
5
  "description": "Create native GitHub stacked pull requests from a linear series of commits",
6
6
  "repository": {
@@ -38,5 +38,8 @@
38
38
  "tsdown": "^0.22.14",
39
39
  "typescript": "^7.0.2",
40
40
  "vitest": "^4.0.0"
41
+ },
42
+ "dependencies": {
43
+ "zod": "^4.4.3"
41
44
  }
42
45
  }