bstack 1.1.6 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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.0";
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) {
@@ -606,7 +622,6 @@ var Stack = class Stack {
606
622
  const currentIds = this.changes.map((change) => change.id);
607
623
  const previousIdSet = new Set(previousIds);
608
624
  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
625
  const removed = previous.changes.filter((change) => !currentIdSet.has(change.id));
611
626
  const added = this.changes.filter((change) => !previousIdSet.has(change.id));
612
627
  if (removed.length === 0) {
@@ -615,7 +630,7 @@ var Stack = class Stack {
615
630
  return stackNumber === void 0 ? { kind: "full" } : {
616
631
  kind: "rebuild",
617
632
  stackNumber,
618
- action: "insert"
633
+ action: added.length === 0 ? "reorder" : "insert"
619
634
  };
620
635
  }
621
636
  const firstCurrentIndex = previousIds.indexOf(currentIds[0]);
@@ -651,9 +666,6 @@ var Stack = class Stack {
651
666
  };
652
667
  }
653
668
  };
654
- function sameSequence(left, right) {
655
- return left.length === right.length && left.every((value, index) => value === right[index]);
656
- }
657
669
  //#endregion
658
670
  //#region src/sync.ts
659
671
  function syncStack(dependencies, options) {
@@ -690,24 +702,44 @@ function syncStack(dependencies, options) {
690
702
  const state = stateStore.read();
691
703
  const previous = stack.findPrevious(state);
692
704
  const transition = stack.transitionFrom(previous, {
693
- preserveHigherChanges: repository.currentBranch() === "",
705
+ preserveHigherChanges: repository.currentBranch() === void 0,
694
706
  lookups: {
695
707
  pullRequestState: (pullRequest) => github.pullRequest(pullRequest).state,
696
708
  stackNumberForPullRequest: (pullRequest) => github.stackNumberForPullRequest(pullRequest)
697
709
  }
698
710
  });
711
+ const isReorder = transition.kind === "rebuild" && transition.action === "reorder";
712
+ if (isReorder) {
713
+ reporter.progress(`Preparing stack #${transition.stackNumber} for reordered branches`);
714
+ github.unstack(transition.stackNumber);
715
+ try {
716
+ for (const change of previous.changes) github.editPullRequestBase(github.pullRequest(change.pullRequest), base);
717
+ } catch (error) {
718
+ restorePreviousStack(github, previous, base, remote, reporter, error);
719
+ }
720
+ }
699
721
  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
- })));
722
+ try {
723
+ repository.pushBranches(remote, changes.map((change) => ({
724
+ name: change.remoteBranch,
725
+ oid: change.oid
726
+ })));
727
+ } catch (error) {
728
+ if (isReorder) restorePreviousStack(github, previous, base, remote, reporter, error);
729
+ throw error;
730
+ }
704
731
  reporter.progress("Looking up existing pull requests");
705
732
  const existing = changes.map((change) => github.pullRequestForBranch(change.remoteBranch));
706
- let pullRequests;
733
+ const pullRequests = changes.map((change, index) => {
734
+ const current = existing[index];
735
+ if (current) return current;
736
+ const pullRequestBase = index === 0 ? base : changes[index - 1].remoteBranch;
737
+ reporter.progress(`Creating pull request: ${change.subject}`);
738
+ return github.createPullRequest(change, pullRequestBase, options.draft);
739
+ });
707
740
  if (changes.length === 1) {
708
741
  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);
742
+ const pullRequest = pullRequests[0];
711
743
  if (transition.kind === "collapse") {
712
744
  reporter.progress(`Removing omitted pull requests from stack #${transition.stackNumber}`);
713
745
  github.unstack(transition.stackNumber);
@@ -717,30 +749,27 @@ function syncStack(dependencies, options) {
717
749
  restorePreviousStack(github, previous, base, remote, reporter, error);
718
750
  }
719
751
  }
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
- });
743
- }
752
+ } else if (transition.kind === "full") {
753
+ reporter.progress(`Linking ${changes.length} pull requests as a native GitHub stack`);
754
+ github.linkStack(pullRequests.map((pullRequest) => pullRequest.number), base, remote, options.draft);
755
+ } else if (transition.kind === "rebuild") {
756
+ reporter.progress(`Rebuilding stack #${transition.stackNumber} to ${transition.action} pull requests`);
757
+ if (!isReorder) github.unstack(transition.stackNumber);
758
+ try {
759
+ github.linkStack(pullRequests.map((pullRequest) => pullRequest.number), base, remote, options.draft);
760
+ } catch (error) {
761
+ restorePreviousStack(github, previous, base, remote, reporter, error);
762
+ }
763
+ } else if (transition.kind === "append") {
764
+ reporter.progress(`Appending ${transition.branches.length} pull request${transition.branches.length === 1 ? "" : "s"} to stack #${transition.stackNumber}`);
765
+ github.appendToStack(transition.stackNumber, transition.branches.map((branch) => {
766
+ const index = changes.findIndex((change) => change.remoteBranch === branch);
767
+ const pullRequest = pullRequests[index];
768
+ if (!pullRequest) throw new Error(`Missing pull request for ${branch}`);
769
+ return pullRequest.number;
770
+ }), remote, options.draft);
771
+ } else if (transition.kind === "partial") reporter.progress("Updating this down-stack prefix while preserving higher pull requests");
772
+ else reporter.progress("The native GitHub stack already has the correct members");
744
773
  reporter.progress("Synchronizing pull request titles and descriptions");
745
774
  for (const [index, pr] of pullRequests.entries()) {
746
775
  github.editPullRequest(pr, changes[index]);
@@ -775,7 +804,7 @@ function restorePreviousStack(github, previous, base, remote, reporter, rebuildE
775
804
  const rebuildMessage = rebuildError instanceof Error ? rebuildError.message : String(rebuildError);
776
805
  reporter.progress("Rebuild failed; restoring the previous native GitHub stack");
777
806
  try {
778
- github.linkStack(previous.changes.map((change) => change.remoteBranch), base, remote, true);
807
+ github.linkStack(previous.changes.map((change) => change.pullRequest), base, remote, true);
779
808
  } catch (rollbackError) {
780
809
  const rollbackMessage = rollbackError instanceof Error ? rollbackError.message : String(rollbackError);
781
810
  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.0",
4
4
  "description": "Create native GitHub stacked pull requests from a linear series of commits",
5
5
  "license": "MIT",
6
6
  "repository": {