bstack 0.3.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +3 -3
  2. package/dist/bstack.js +165 -93
  3. package/package.json +4 -1
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # bstack
2
2
 
3
- Convert series of commits in a local branch into native GitHub stacked pull requests.
3
+ Convert a series of commits in a local branch into a native GitHub stack of pull requests.
4
4
 
5
5
  ## Install
6
6
 
@@ -17,7 +17,7 @@ gh extension install github/gh-stack
17
17
 
18
18
  ## Use
19
19
 
20
- Create one commit per change, then publish the stack:
20
+ Create one commit per change, then sync the stack:
21
21
 
22
22
  ```bash
23
23
  git switch -c my-feature
@@ -37,7 +37,7 @@ bstack checkout 123
37
37
  bstack checkout https://github.com/owner/repo/pull/123
38
38
  ```
39
39
 
40
- Use `--dry-run` to inspect without publishing and `--quiet` to hide progress logs.
40
+ Use `--dry-run` to inspect without syncing and `--quiet` to hide progress logs.
41
41
 
42
42
  ## References
43
43
 
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.3.0";
9
+ var version = "1.0.1";
9
10
  //#endregion
10
11
  //#region src/command.ts
11
12
  var CommandError = class extends Error {
@@ -74,24 +75,26 @@ function checkoutStack(repository, github, options) {
74
75
  }
75
76
  reporter.progress(`Checking out ${remote}/${headRef} in detached HEAD state`);
76
77
  repository.checkout(target);
77
- reporter.progress("Checkout complete; amend the commits and run bstack to publish updates");
78
+ reporter.progress("Checkout complete; amend the commits and run bstack to sync updates");
78
79
  return {
79
80
  headRef,
80
81
  delegated: false
81
82
  };
82
83
  }
83
84
  //#endregion
84
- //#region src/identity.ts
85
- const trailerPattern = /^Bstack-Id:\s*(\S+)\s*$/gim;
85
+ //#region src/commit.ts
86
+ const CHANGE_ID_TRAILER = "bstack-id";
87
+ const changeIdTrailerPattern = new RegExp(`^${CHANGE_ID_TRAILER}:\\s*(\\S+)\\s*$`, "gm");
88
+ const changeIdTrailerLinePattern = new RegExp(`^${CHANGE_ID_TRAILER}:\\s*\\S+\\s*$`);
86
89
  function readChangeId(message) {
87
- const matches = [...message.matchAll(trailerPattern)];
88
- if (matches.length > 1) throw new Error("A commit contains more than one Bstack-Id trailer");
90
+ const matches = [...message.matchAll(changeIdTrailerPattern)];
91
+ if (matches.length > 1) throw new Error(`A commit contains more than one ${CHANGE_ID_TRAILER} trailer`);
89
92
  return matches[0]?.[1];
90
93
  }
91
94
  function addChangeId(message, changeId) {
92
- return `${message.trimEnd()}\n\nBstack-Id: ${changeId}\n`;
95
+ return `${message.trimEnd()}\n\n${CHANGE_ID_TRAILER}: ${changeId}\n`;
93
96
  }
94
- function newChangeId() {
97
+ function generateChangeId() {
95
98
  return randomUUID().replaceAll("-", "");
96
99
  }
97
100
  function parseRawCommit(oid, raw) {
@@ -128,7 +131,7 @@ function rewriteCommit(commit, parent, message) {
128
131
  return `${rewrittenHeaders.join("\n")}\n\n${message}`;
129
132
  }
130
133
  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");
134
+ const [subject = "Untitled change", ...bodyLines] = message.split("\n").filter((line) => !changeIdTrailerLinePattern.test(line)).join("\n").trim().split("\n");
132
135
  return {
133
136
  subject,
134
137
  body: bodyLines.join("\n").trim()
@@ -151,7 +154,7 @@ var GitRepository = class {
151
154
  }
152
155
  assertReady() {
153
156
  this.git(["rev-parse", "--show-toplevel"]);
154
- if (this.git(["status", "--porcelain"]).stdout.trim()) throw new Error("The working tree must be clean before bstack rewrites or publishes commits");
157
+ if (this.git(["status", "--porcelain"]).stdout.trim()) throw new Error("The working tree must be clean before bstack rewrites commits or pushes branches");
155
158
  }
156
159
  currentBranch() {
157
160
  return this.git([
@@ -230,8 +233,8 @@ var GitRepository = class {
230
233
  oid
231
234
  ]).stdout));
232
235
  }
233
- ensureChangeIds(commits, dryRun) {
234
- const assigned = commits.map((commit) => commit.changeId ?? newChangeId());
236
+ ensureChangeIds(commits, dryRun, userLogin) {
237
+ const assigned = commits.map((commit) => commit.changeId ?? generateChangeId());
235
238
  const needsRewrite = commits.some((commit) => commit.changeId === void 0);
236
239
  let parent = commits[0]?.parent;
237
240
  const rewrittenOids = [];
@@ -268,7 +271,7 @@ var GitRepository = class {
268
271
  oid,
269
272
  subject,
270
273
  body,
271
- remoteBranch: `bstack/${id}`
274
+ remoteBranch: `bstack/${userLogin}/${id}`
272
275
  };
273
276
  });
274
277
  }
@@ -313,7 +316,20 @@ var GitRepository = class {
313
316
  };
314
317
  //#endregion
315
318
  //#region src/github.ts
316
- var GhPlatform = class {
319
+ const pullRequestSchema = z.object({
320
+ number: z.number(),
321
+ url: z.string(),
322
+ state: z.enum([
323
+ "OPEN",
324
+ "CLOSED",
325
+ "MERGED"
326
+ ]),
327
+ title: z.string(),
328
+ body: z.string(),
329
+ isDraft: z.boolean()
330
+ });
331
+ const stackSchema = z.object({ number: z.number() });
332
+ var GitHubCliPlatform = class {
317
333
  cwd;
318
334
  runner;
319
335
  constructor(cwd, runner) {
@@ -331,6 +347,14 @@ var GhPlatform = class {
331
347
  ]);
332
348
  this.gh(["stack", "--version"]);
333
349
  }
350
+ currentUserLogin() {
351
+ return z.string().min(1).parse(this.gh([
352
+ "api",
353
+ "user",
354
+ "--jq",
355
+ ".login"
356
+ ]).stdout.trim());
357
+ }
334
358
  defaultBranch() {
335
359
  return this.gh([
336
360
  "repo",
@@ -354,9 +378,8 @@ var GhPlatform = class {
354
378
  "--json",
355
379
  "number,url,state,title,body,isDraft"
356
380
  ]).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;
381
+ const candidates = pullRequestSchema.array().parse(JSON.parse(raw));
382
+ return candidates.find((pr) => pr.state === "OPEN") ?? candidates.find((pr) => pr.state === "MERGED");
360
383
  }
361
384
  pullRequest(number) {
362
385
  const raw = this.gh([
@@ -366,7 +389,7 @@ var GhPlatform = class {
366
389
  "--json",
367
390
  "number,url,state,title,body,isDraft"
368
391
  ]).stdout;
369
- return normalizePullRequest(JSON.parse(raw));
392
+ return pullRequestSchema.parse(JSON.parse(raw));
370
393
  }
371
394
  createPullRequest(change, base, draft) {
372
395
  const args = [
@@ -418,6 +441,15 @@ var GhPlatform = class {
418
441
  String(stackNumber)
419
442
  ]);
420
443
  }
444
+ editPullRequestBase(pr, base) {
445
+ this.gh([
446
+ "pr",
447
+ "edit",
448
+ String(pr.number),
449
+ "--base",
450
+ base
451
+ ]);
452
+ }
421
453
  editPullRequest(pr, change) {
422
454
  if (pr.title === change.subject && pr.body === change.body) return;
423
455
  this.gh([
@@ -432,7 +464,7 @@ var GhPlatform = class {
432
464
  }
433
465
  stackNumberForPullRequest(prNumber) {
434
466
  const raw = this.gh(["api", `repos/{owner}/{repo}/stacks?pull_request=${prNumber}`]).stdout;
435
- return JSON.parse(raw)[0]?.number;
467
+ return stackSchema.array().parse(JSON.parse(raw))[0]?.number;
436
468
  }
437
469
  pullRequestHead(reference) {
438
470
  return this.gh([
@@ -453,13 +485,6 @@ var GhPlatform = class {
453
485
  ]);
454
486
  }
455
487
  };
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
488
  //#endregion
464
489
  //#region src/reporter.ts
465
490
  var ConsoleReporter = class {
@@ -477,6 +502,22 @@ const emptyState = () => ({
477
502
  schemaVersion: 1,
478
503
  stacks: []
479
504
  });
505
+ const storedChangeSchema = z.object({
506
+ id: z.string(),
507
+ remoteBranch: z.string(),
508
+ pullRequest: z.number(),
509
+ url: z.string()
510
+ });
511
+ const storedStackSchema = z.object({
512
+ remote: z.string(),
513
+ base: z.string(),
514
+ stackNumber: z.number().optional(),
515
+ changes: storedChangeSchema.array()
516
+ });
517
+ const stateSchema = z.object({
518
+ schemaVersion: z.literal(1),
519
+ stacks: storedStackSchema.array()
520
+ });
480
521
  var StateStore = class {
481
522
  path;
482
523
  constructor(path) {
@@ -484,11 +525,21 @@ var StateStore = class {
484
525
  }
485
526
  read() {
486
527
  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;
528
+ return {
529
+ schemaVersion: 1,
530
+ stacks: stateSchema.parse(JSON.parse(readFileSync(this.path, "utf8"))).stacks.map((stack) => {
531
+ const stored = {
532
+ remote: stack.remote,
533
+ base: stack.base,
534
+ changes: stack.changes
535
+ };
536
+ if (stack.stackNumber !== void 0) stored.stackNumber = stack.stackNumber;
537
+ return stored;
538
+ })
539
+ };
490
540
  } catch (error) {
491
- if (isMissingFile(error)) return emptyState();
541
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return emptyState();
542
+ if (error instanceof z.ZodError) throw new Error(`Unsupported bstack state in ${this.path}`, { cause: error });
492
543
  throw error;
493
544
  }
494
545
  }
@@ -504,14 +555,6 @@ var StateStore = class {
504
555
  return matches[0];
505
556
  }
506
557
  };
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
558
  //#endregion
516
559
  //#region src/sync.ts
517
560
  function syncStack(repository, github, options) {
@@ -521,7 +564,9 @@ function syncStack(repository, github, options) {
521
564
  github.assertReady();
522
565
  const remote = repository.resolveRemote(options.remote);
523
566
  const base = options.base ?? github.defaultBranch();
567
+ const userLogin = github.currentUserLogin();
524
568
  reporter.progress(`Using ${remote} as the remote and ${base} as the stack base`);
569
+ reporter.progress(`Using ${userLogin} as the remote branch namespace`);
525
570
  reporter.progress(`Fetching ${remote}/${base}`);
526
571
  const remoteBase = repository.fetchBase(remote, base);
527
572
  const baseOid = repository.mergeBase("HEAD", remoteBase);
@@ -531,7 +576,7 @@ function syncStack(repository, github, options) {
531
576
  const rewritten = commits.some((commit) => commit.changeId === void 0);
532
577
  if (rewritten) reporter.progress(options.dryRun ? "Stable change IDs would be added to the commits" : "Adding stable change IDs to the commits");
533
578
  else reporter.progress("All commits already have stable change IDs");
534
- const changes = repository.ensureChangeIds(commits, options.dryRun);
579
+ const changes = repository.ensureChangeIds(commits, options.dryRun, userLogin);
535
580
  if (options.dryRun) {
536
581
  reporter.progress("Dry run complete; no commits or remote branches were changed");
537
582
  return {
@@ -545,38 +590,42 @@ function syncStack(repository, github, options) {
545
590
  const store = new StateStore(repository.statePath());
546
591
  const state = store.read();
547
592
  const previous = store.findByChangeIds(state, new Set(changes.map((change) => change.id)));
548
- const evolution = analyzeEvolution(previous, changes, github);
549
- reporter.progress(`Publishing ${changes.length} protected remote ref${changes.length === 1 ? "" : "s"}`);
593
+ const transition = analyzeStackTransition(previous, changes, github, repository.currentBranch() === "");
594
+ reporter.progress(`Pushing ${changes.length} remote branch${changes.length === 1 ? "" : "es"}`);
550
595
  repository.pushChanges(remote, changes);
551
596
  reporter.progress("Looking up existing pull requests");
552
597
  const existing = changes.map((change) => github.pullRequestForBranch(change.remoteBranch));
553
598
  let pullRequests;
554
599
  if (changes.length === 1) {
555
- if (evolution.kind === "partial") reporter.progress("Updating this down-stack prefix while preserving higher pull requests");
600
+ if (transition.kind === "partial") reporter.progress("Updating this down-stack prefix while preserving higher pull requests");
556
601
  reporter.progress(existing[0] ? "Using the existing pull request" : "Creating a pull request");
557
- pullRequests = [existing[0] ?? github.createPullRequest(changes[0], base, options.draft)];
602
+ const pullRequest = existing[0] ?? github.createPullRequest(changes[0], base, options.draft);
603
+ if (transition.kind === "collapse") {
604
+ reporter.progress(`Removing omitted pull requests from stack #${transition.stackNumber}`);
605
+ github.unstack(transition.stackNumber);
606
+ try {
607
+ github.editPullRequestBase(pullRequest, base);
608
+ } catch (error) {
609
+ restorePreviousStack(github, previous, base, remote, reporter, error);
610
+ }
611
+ }
612
+ pullRequests = [pullRequest];
558
613
  } else {
559
- if (evolution.kind === "full") {
614
+ if (transition.kind === "full") {
560
615
  reporter.progress(`Linking ${changes.length} pull requests as a native GitHub stack`);
561
616
  github.linkStack(changes.map((change) => change.remoteBranch), base, remote, options.draft);
562
- } else if (evolution.kind === "rebuild") {
563
- reporter.progress(`Rebuilding stack #${evolution.stackNumber} to insert pull requests`);
564
- github.unstack(evolution.stackNumber);
617
+ } else if (transition.kind === "rebuild") {
618
+ reporter.progress(`Rebuilding stack #${transition.stackNumber} to ${transition.action} pull requests`);
619
+ github.unstack(transition.stackNumber);
565
620
  try {
566
621
  github.linkStack(changes.map((change) => change.remoteBranch), base, remote, options.draft);
567
622
  } 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, true);
571
- } catch (rollbackError) {
572
- throw new Error(`Stack rebuild failed: ${errorMessage(error)}\nRestoring the previous stack also failed: ${errorMessage(rollbackError)}`);
573
- }
574
- throw error;
623
+ restorePreviousStack(github, previous, base, remote, reporter, error);
575
624
  }
576
- } else if (evolution.kind === "append") {
577
- 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.draft);
579
- } else if (evolution.kind === "partial") reporter.progress("Updating this down-stack prefix while preserving higher pull requests");
625
+ } else if (transition.kind === "append") {
626
+ reporter.progress(`Appending ${transition.branches.length} pull request${transition.branches.length === 1 ? "" : "s"} to stack #${transition.stackNumber}`);
627
+ github.appendToStack(transition.stackNumber, transition.branches, remote, options.draft);
628
+ } else if (transition.kind === "partial") reporter.progress("Updating this down-stack prefix while preserving higher pull requests");
580
629
  else reporter.progress("The native GitHub stack already has the correct members");
581
630
  pullRequests = changes.map((change, index) => {
582
631
  const pr = existing[index] ?? github.pullRequestForBranch(change.remoteBranch);
@@ -589,20 +638,20 @@ function syncStack(repository, github, options) {
589
638
  github.editPullRequest(pr, changes[index]);
590
639
  reporter.progress(`PR #${pr.number}: ${changes[index].subject}`);
591
640
  }
592
- const stackNumber = evolution.kind === "rebuild" ? github.stackNumberForPullRequest(pullRequests[0].number) : previous?.stackNumber ?? (pullRequests.length > 1 ? github.stackNumberForPullRequest(pullRequests[0].number) : void 0);
641
+ 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);
593
642
  const synchronizedChanges = changes.map((change, index) => ({
594
643
  id: change.id,
595
644
  remoteBranch: change.remoteBranch,
596
645
  pullRequest: pullRequests[index].number,
597
646
  url: pullRequests[index].url
598
647
  }));
599
- const storedChanges = evolution.kind === "partial" && previous ? [...synchronizedChanges, ...previous.changes.slice(evolution.previousOffset + changes.length)] : synchronizedChanges;
600
- writeUpdatedState(store, state, previous, {
648
+ const updatedStack = {
601
649
  remote,
602
650
  base,
603
- ...stackNumber === void 0 ? {} : { stackNumber },
604
- changes: storedChanges
605
- });
651
+ changes: transition.kind === "partial" && previous ? [...synchronizedChanges, ...previous.changes.slice(transition.previousOffset + changes.length)] : synchronizedChanges
652
+ };
653
+ if (stackNumber !== void 0) updatedStack.stackNumber = stackNumber;
654
+ writeUpdatedState(store, state, previous, updatedStack);
606
655
  reporter.progress("Saved the local stack state");
607
656
  return {
608
657
  base,
@@ -614,46 +663,69 @@ function syncStack(repository, github, options) {
614
663
  }))
615
664
  };
616
665
  }
617
- function analyzeEvolution(previous, changes, github) {
666
+ function analyzeStackTransition(previous, changes, github, preserveHigherChanges) {
618
667
  if (!previous) return { kind: "full" };
619
668
  const previousIds = previous.changes.map((change) => change.id);
620
669
  const currentIds = changes.map((change) => change.id);
621
- if (isSubsequence(previousIds, currentIds)) {
670
+ const previousIdSet = new Set(previousIds);
671
+ const currentIdSet = new Set(currentIds);
672
+ 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");
673
+ const removed = previous.changes.filter((change) => !currentIdSet.has(change.id));
674
+ const added = changes.filter((change) => !previousIdSet.has(change.id));
675
+ if (removed.length === 0) {
622
676
  if (previousIds.every((id, index) => currentIds[index] === id)) return { kind: "full" };
623
677
  const stackNumber = previous.stackNumber ?? github.stackNumberForPullRequest(previous.changes[0].pullRequest);
624
678
  return stackNumber === void 0 ? { kind: "full" } : {
625
679
  kind: "rebuild",
626
- stackNumber
680
+ stackNumber,
681
+ action: "insert"
627
682
  };
628
683
  }
629
684
  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
685
+ const isPreviousSlice = firstCurrentIndex >= 0 && currentIds.every((id, index) => previousIds[firstCurrentIndex + index] === id);
686
+ if (preserveHigherChanges && added.length === 0 && isPreviousSlice && firstCurrentIndex + currentIds.length < previousIds.length) {
687
+ if (previous.changes.slice(0, firstCurrentIndex).every((change) => github.pullRequest(change.pullRequest).state === "MERGED")) return {
688
+ kind: "partial",
689
+ previousOffset: firstCurrentIndex
690
+ };
691
+ }
692
+ const removedPrefixWasMerged = removed.every((change, index) => previous.changes[index] === change) && removed.every((change) => github.pullRequest(change.pullRequest).state === "MERGED");
693
+ const survivingIds = previousIds.slice(removed.length);
694
+ const onlyAppendedAfterMergedPrefix = removedPrefixWasMerged && survivingIds.every((id, index) => currentIds[index] === id) && currentIds.slice(survivingIds.length).every((id) => !previousIdSet.has(id));
695
+ if (removedPrefixWasMerged && added.length === 0) return { kind: "skip" };
696
+ if (onlyAppendedAfterMergedPrefix) {
697
+ if (previous.stackNumber === void 0) throw new Error("Cannot append after a merge because the native GitHub stack number is missing from local state");
698
+ return {
699
+ kind: "append",
700
+ stackNumber: previous.stackNumber,
701
+ branches: added.map((change) => change.remoteBranch)
702
+ };
703
+ }
704
+ const stackNumber = previous.stackNumber ?? github.stackNumberForPullRequest(previous.changes[0].pullRequest);
705
+ if (stackNumber === void 0) throw new Error("Cannot remove submitted commits because the native GitHub stack number is missing from local state");
706
+ if (changes.length === 1) return {
707
+ kind: "collapse",
708
+ stackNumber
639
709
  };
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
710
  return {
645
- kind: "append",
646
- stackNumber: previous.stackNumber,
647
- branches: appended.map((change) => change.remoteBranch)
711
+ kind: "rebuild",
712
+ stackNumber,
713
+ action: added.length === 0 ? "remove" : "update"
648
714
  };
649
715
  }
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;
716
+ function sameSequence(left, right) {
717
+ return left.length === right.length && left.every((value, index) => value === right[index]);
654
718
  }
655
- function errorMessage(error) {
656
- return error instanceof Error ? error.message : String(error);
719
+ function restorePreviousStack(github, previous, base, remote, reporter, rebuildError) {
720
+ const rebuildMessage = rebuildError instanceof Error ? rebuildError.message : String(rebuildError);
721
+ reporter.progress("Rebuild failed; restoring the previous native GitHub stack");
722
+ try {
723
+ github.linkStack(previous.changes.map((change) => change.remoteBranch), base, remote, true);
724
+ } catch (rollbackError) {
725
+ const rollbackMessage = rollbackError instanceof Error ? rollbackError.message : String(rollbackError);
726
+ throw new Error(`Stack rebuild failed: ${rebuildMessage}\nRestoring the previous stack also failed: ${rollbackMessage}`);
727
+ }
728
+ throw rebuildError;
657
729
  }
658
730
  function writeUpdatedState(store, state, previous, updated) {
659
731
  const stacks = previous ? state.stacks.map((stack) => stack === previous ? updated : stack) : [...state.stacks, updated];
@@ -664,14 +736,14 @@ function writeUpdatedState(store, state, previous, updated) {
664
736
  }
665
737
  //#endregion
666
738
  //#region src/cli.ts
667
- const help = `bstack - turn a linear commit series into native GitHub stacked PRs
739
+ const help = `bstack - turn a linear commit series into a native GitHub stack of PRs
668
740
 
669
741
  Usage:
670
742
  bstack [sync] [options]
671
743
  bstack checkout <PR-number-or-URL> [options]
672
744
 
673
745
  Options:
674
- --base <branch> Stack trunk; defaults to the GitHub default branch
746
+ --base <branch> Stack base; defaults to the GitHub default branch
675
747
  --remote <name> Git remote; defaults to remote.pushDefault or origin
676
748
  --draft Create draft PRs instead of ready-for-review PRs
677
749
  --dry-run Inspect the stack without rewriting commits or pushing
@@ -726,7 +798,7 @@ function main() {
726
798
  const runner = new NodeCommandRunner();
727
799
  const cwd = process.cwd();
728
800
  const repository = new GitRepository(cwd, runner);
729
- const github = new GhPlatform(cwd, runner);
801
+ const github = new GitHubCliPlatform(cwd, runner);
730
802
  const reporter = new ConsoleReporter(!values.quiet);
731
803
  const command = positionals[0] ?? "sync";
732
804
  if (command === "checkout") {
@@ -750,7 +822,7 @@ function main() {
750
822
  dryRun: values["dry-run"],
751
823
  reporter
752
824
  });
753
- console.log(`${values["dry-run"] ? "Would publish" : "Published"} ${result.changes.length} change${result.changes.length === 1 ? "" : "s"} against ${result.base}:`);
825
+ console.log(`${values["dry-run"] ? "Would sync" : "Synced"} ${result.changes.length} change${result.changes.length === 1 ? "" : "s"} against ${result.base}:`);
754
826
  for (const change of result.changes) {
755
827
  const destination = change.pullRequest ? ` ${change.pullRequest.url}` : "";
756
828
  console.log(` ${change.oid.slice(0, 8)} ${change.subject}${destination}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bstack",
3
- "version": "0.3.0",
3
+ "version": "1.0.1",
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
  }