bstack 1.1.6 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -73,6 +73,11 @@ git rebase --continue
73
73
  bstack
74
74
  ```
75
75
 
76
+ ### Reorder pull requests
77
+
78
+ Reorder the corresponding commits with Git, then run `bstack` again.
79
+ `bstack` keeps the existing pull request identities and rebuilds the native GitHub stack in the new commit order.
80
+
76
81
  Stacks cannot contain merge commits.
77
82
  When `main` moves, rebase your branch onto it instead of merging `main` into your branch.
78
83
 
package/dist/bstack.mjs CHANGED
@@ -6,7 +6,7 @@ import * as v from "valibot";
6
6
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
7
7
  import { dirname } from "node:path";
8
8
  //#region package.json
9
- var version = "1.1.6";
9
+ var version = "1.2.1";
10
10
  //#endregion
11
11
  //#region src/command.ts
12
12
  var CommandError = class extends Error {
@@ -59,7 +59,7 @@ function checkoutStack(dependencies, options) {
59
59
  const { repository, github, reporter } = dependencies;
60
60
  reporter.progress("Checking the repository and GitHub prerequisites");
61
61
  repository.assertReady();
62
- repository.assertClean();
62
+ if (!repository.isClean()) throw new Error("The working tree must be clean before checkout");
63
63
  github.assertReady();
64
64
  const remote = repository.resolveRemote(options.remote);
65
65
  reporter.progress(`Looking up pull request ${options.reference}`);
@@ -168,8 +168,8 @@ var GitCliRepository = class {
168
168
  assertReady() {
169
169
  this.git(["rev-parse", "--show-toplevel"]);
170
170
  }
171
- assertClean() {
172
- if (this.git(["status", "--porcelain"]).stdout.trim()) throw new Error("The working tree must be clean before checkout");
171
+ isClean() {
172
+ return this.git(["status", "--porcelain"]).stdout.trim() === "";
173
173
  }
174
174
  currentBranch() {
175
175
  return this.git([
@@ -177,7 +177,7 @@ var GitCliRepository = class {
177
177
  "--quiet",
178
178
  "--short",
179
179
  "HEAD"
180
- ], { allowFailure: true }).stdout.trim();
180
+ ], { allowFailure: true }).stdout.trim() || void 0;
181
181
  }
182
182
  resolveRemote(requested) {
183
183
  if (requested) {
@@ -327,6 +327,14 @@ const pullRequestSchema = v.object({
327
327
  body: v.string(),
328
328
  isDraft: v.boolean()
329
329
  });
330
+ const createdPullRequestSchema = v.object({
331
+ number: v.number(),
332
+ html_url: v.string(),
333
+ state: v.picklist(["open", "closed"]),
334
+ title: v.string(),
335
+ body: v.nullable(v.string()),
336
+ draft: v.boolean()
337
+ });
330
338
  const stackSchema = v.object({ number: v.number() });
331
339
  var GitHubCliPlatform = class {
332
340
  cwd;
@@ -392,25 +400,33 @@ var GitHubCliPlatform = class {
392
400
  return v.parse(pullRequestSchema, JSON.parse(raw));
393
401
  }
394
402
  createPullRequest(change, base, draft) {
395
- const args = [
396
- "pr",
397
- "create",
398
- "--base",
399
- base,
400
- "--head",
401
- change.remoteBranch,
402
- "--title",
403
- change.subject,
404
- "--body",
405
- change.body
406
- ];
407
- if (draft) args.push("--draft");
408
- this.gh(args);
409
- const created = this.pullRequestForBranch(change.remoteBranch);
410
- if (!created) throw new Error(`GitHub did not return the PR created for ${change.remoteBranch}`);
411
- return created;
403
+ const raw = this.gh([
404
+ "api",
405
+ "--method",
406
+ "POST",
407
+ "repos/{owner}/{repo}/pulls",
408
+ "--raw-field",
409
+ `base=${base}`,
410
+ "--raw-field",
411
+ `head=${change.remoteBranch}`,
412
+ "--raw-field",
413
+ `title=${change.subject}`,
414
+ "--raw-field",
415
+ `body=${change.body}`,
416
+ "--field",
417
+ `draft=${draft}`
418
+ ]).stdout;
419
+ const created = v.parse(createdPullRequestSchema, JSON.parse(raw));
420
+ return {
421
+ number: created.number,
422
+ url: created.html_url,
423
+ state: created.state === "open" ? "OPEN" : "CLOSED",
424
+ title: created.title,
425
+ body: created.body ?? "",
426
+ isDraft: created.draft
427
+ };
412
428
  }
413
- linkStack(branches, base, remote, draft) {
429
+ linkStack(pullRequests, base, remote, draft) {
414
430
  const args = [
415
431
  "stack",
416
432
  "link",
@@ -420,10 +436,10 @@ var GitHubCliPlatform = class {
420
436
  remote
421
437
  ];
422
438
  if (!draft) args.push("--open");
423
- args.push(...branches);
439
+ args.push(...pullRequests.map(String));
424
440
  this.gh(args);
425
441
  }
426
- appendToStack(stackNumber, branches, remote, draft) {
442
+ appendToStack(stackNumber, pullRequests, remote, draft) {
427
443
  const args = [
428
444
  "stack",
429
445
  "link",
@@ -431,7 +447,7 @@ var GitHubCliPlatform = class {
431
447
  remote
432
448
  ];
433
449
  if (!draft) args.push("--open");
434
- args.push(String(stackNumber), ...branches);
450
+ args.push(String(stackNumber), ...pullRequests.map(String));
435
451
  this.gh(args);
436
452
  }
437
453
  unstack(stackNumber) {
@@ -441,6 +457,13 @@ var GitHubCliPlatform = class {
441
457
  String(stackNumber)
442
458
  ]);
443
459
  }
460
+ closePullRequest(pr) {
461
+ this.gh([
462
+ "pr",
463
+ "close",
464
+ String(pr.number)
465
+ ]);
466
+ }
444
467
  editPullRequestBase(pr, base) {
445
468
  this.gh([
446
469
  "pr",
@@ -606,7 +629,6 @@ var Stack = class Stack {
606
629
  const currentIds = this.changes.map((change) => change.id);
607
630
  const previousIdSet = new Set(previousIds);
608
631
  const currentIdSet = new Set(currentIds);
609
- 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");
610
632
  const removed = previous.changes.filter((change) => !currentIdSet.has(change.id));
611
633
  const added = this.changes.filter((change) => !previousIdSet.has(change.id));
612
634
  if (removed.length === 0) {
@@ -615,7 +637,7 @@ var Stack = class Stack {
615
637
  return stackNumber === void 0 ? { kind: "full" } : {
616
638
  kind: "rebuild",
617
639
  stackNumber,
618
- action: "insert"
640
+ action: added.length === 0 ? "reorder" : "insert"
619
641
  };
620
642
  }
621
643
  const firstCurrentIndex = previousIds.indexOf(currentIds[0]);
@@ -651,9 +673,6 @@ var Stack = class Stack {
651
673
  };
652
674
  }
653
675
  };
654
- function sameSequence(left, right) {
655
- return left.length === right.length && left.every((value, index) => value === right[index]);
656
- }
657
676
  //#endregion
658
677
  //#region src/sync.ts
659
678
  function syncStack(dependencies, options) {
@@ -690,24 +709,44 @@ function syncStack(dependencies, options) {
690
709
  const state = stateStore.read();
691
710
  const previous = stack.findPrevious(state);
692
711
  const transition = stack.transitionFrom(previous, {
693
- preserveHigherChanges: repository.currentBranch() === "",
712
+ preserveHigherChanges: repository.currentBranch() === void 0,
694
713
  lookups: {
695
714
  pullRequestState: (pullRequest) => github.pullRequest(pullRequest).state,
696
715
  stackNumberForPullRequest: (pullRequest) => github.stackNumberForPullRequest(pullRequest)
697
716
  }
698
717
  });
718
+ const isReorder = transition.kind === "rebuild" && transition.action === "reorder";
719
+ if (isReorder) {
720
+ reporter.progress(`Preparing stack #${transition.stackNumber} for reordered branches`);
721
+ github.unstack(transition.stackNumber);
722
+ try {
723
+ for (const change of previous.changes) github.editPullRequestBase(github.pullRequest(change.pullRequest), base);
724
+ } catch (error) {
725
+ restorePreviousStack(github, previous, base, remote, reporter, error);
726
+ }
727
+ }
699
728
  reporter.progress(`Pushing ${changes.length} remote branch${changes.length === 1 ? "" : "es"}`);
700
- repository.pushBranches(remote, changes.map((change) => ({
701
- name: change.remoteBranch,
702
- oid: change.oid
703
- })));
729
+ try {
730
+ repository.pushBranches(remote, changes.map((change) => ({
731
+ name: change.remoteBranch,
732
+ oid: change.oid
733
+ })));
734
+ } catch (error) {
735
+ if (isReorder) restorePreviousStack(github, previous, base, remote, reporter, error);
736
+ throw error;
737
+ }
704
738
  reporter.progress("Looking up existing pull requests");
705
739
  const existing = changes.map((change) => github.pullRequestForBranch(change.remoteBranch));
706
- let pullRequests;
740
+ const pullRequests = changes.map((change, index) => {
741
+ const current = existing[index];
742
+ if (current) return current;
743
+ const pullRequestBase = index === 0 ? base : changes[index - 1].remoteBranch;
744
+ reporter.progress(`Creating pull request: ${change.subject}`);
745
+ return github.createPullRequest(change, pullRequestBase, options.draft);
746
+ });
707
747
  if (changes.length === 1) {
708
748
  if (transition.kind === "partial") reporter.progress("Updating this down-stack prefix while preserving higher pull requests");
709
- reporter.progress(existing[0] ? "Using the existing pull request" : "Creating a pull request");
710
- const pullRequest = existing[0] ?? github.createPullRequest(changes[0], base, options.draft);
749
+ const pullRequest = pullRequests[0];
711
750
  if (transition.kind === "collapse") {
712
751
  reporter.progress(`Removing omitted pull requests from stack #${transition.stackNumber}`);
713
752
  github.unstack(transition.stackNumber);
@@ -717,29 +756,32 @@ function syncStack(dependencies, options) {
717
756
  restorePreviousStack(github, previous, base, remote, reporter, error);
718
757
  }
719
758
  }
720
- pullRequests = [pullRequest];
721
- } else {
722
- if (transition.kind === "full") {
723
- reporter.progress(`Linking ${changes.length} pull requests as a native GitHub stack`);
724
- github.linkStack(changes.map((change) => change.remoteBranch), base, remote, options.draft);
725
- } else if (transition.kind === "rebuild") {
726
- reporter.progress(`Rebuilding stack #${transition.stackNumber} to ${transition.action} pull requests`);
727
- github.unstack(transition.stackNumber);
728
- try {
729
- github.linkStack(changes.map((change) => change.remoteBranch), base, remote, options.draft);
730
- } catch (error) {
731
- restorePreviousStack(github, previous, base, remote, reporter, error);
732
- }
733
- } else if (transition.kind === "append") {
734
- reporter.progress(`Appending ${transition.branches.length} pull request${transition.branches.length === 1 ? "" : "s"} to stack #${transition.stackNumber}`);
735
- github.appendToStack(transition.stackNumber, transition.branches, remote, options.draft);
736
- } else if (transition.kind === "partial") reporter.progress("Updating this down-stack prefix while preserving higher pull requests");
737
- else reporter.progress("The native GitHub stack already has the correct members");
738
- pullRequests = changes.map((change, index) => {
739
- const pr = existing[index] ?? github.pullRequestForBranch(change.remoteBranch);
740
- if (!pr) throw new Error(`GitHub did not return a PR for ${change.remoteBranch}`);
741
- return pr;
742
- });
759
+ } else if (transition.kind === "full") {
760
+ reporter.progress(`Linking ${changes.length} pull requests as a native GitHub stack`);
761
+ github.linkStack(pullRequests.map((pullRequest) => pullRequest.number), base, remote, options.draft);
762
+ } else if (transition.kind === "rebuild") {
763
+ reporter.progress(`Rebuilding stack #${transition.stackNumber} to ${transition.action} pull requests`);
764
+ if (!isReorder) github.unstack(transition.stackNumber);
765
+ try {
766
+ github.linkStack(pullRequests.map((pullRequest) => pullRequest.number), base, remote, options.draft);
767
+ } catch (error) {
768
+ restorePreviousStack(github, previous, base, remote, reporter, error);
769
+ }
770
+ } else if (transition.kind === "append") {
771
+ reporter.progress(`Appending ${transition.branches.length} pull request${transition.branches.length === 1 ? "" : "s"} to stack #${transition.stackNumber}`);
772
+ github.appendToStack(transition.stackNumber, transition.branches.map((branch) => {
773
+ const index = changes.findIndex((change) => change.remoteBranch === branch);
774
+ const pullRequest = pullRequests[index];
775
+ if (!pullRequest) throw new Error(`Missing pull request for ${branch}`);
776
+ return pullRequest.number;
777
+ }), remote, options.draft);
778
+ } else if (transition.kind === "partial") reporter.progress("Updating this down-stack prefix while preserving higher pull requests");
779
+ else reporter.progress("The native GitHub stack already has the correct members");
780
+ const currentIds = new Set(changes.map((change) => change.id));
781
+ const omittedPullRequests = transition.kind === "partial" ? [] : (previous?.changes ?? []).filter((change) => !currentIds.has(change.id)).map((change) => github.pullRequest(change.pullRequest)).filter((pullRequest) => pullRequest.state === "OPEN");
782
+ if (omittedPullRequests.length > 0) {
783
+ reporter.progress(`Closing ${omittedPullRequests.length} omitted pull request${omittedPullRequests.length === 1 ? "" : "s"}`);
784
+ for (const pullRequest of omittedPullRequests) github.closePullRequest(pullRequest);
743
785
  }
744
786
  reporter.progress("Synchronizing pull request titles and descriptions");
745
787
  for (const [index, pr] of pullRequests.entries()) {
@@ -775,7 +817,7 @@ function restorePreviousStack(github, previous, base, remote, reporter, rebuildE
775
817
  const rebuildMessage = rebuildError instanceof Error ? rebuildError.message : String(rebuildError);
776
818
  reporter.progress("Rebuild failed; restoring the previous native GitHub stack");
777
819
  try {
778
- github.linkStack(previous.changes.map((change) => change.remoteBranch), base, remote, true);
820
+ github.linkStack(previous.changes.map((change) => change.pullRequest), base, remote, true);
779
821
  } catch (rollbackError) {
780
822
  const rollbackMessage = rollbackError instanceof Error ? rollbackError.message : String(rollbackError);
781
823
  throw new Error(`Stack rebuild failed: ${rebuildMessage}\nRestoring the previous stack also failed: ${rollbackMessage}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bstack",
3
- "version": "1.1.6",
3
+ "version": "1.2.1",
4
4
  "description": "Create native GitHub stacked pull requests from a linear series of commits",
5
5
  "license": "MIT",
6
6
  "repository": {