bstack 1.1.5 → 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.5";
9
+ var version = "1.2.0";
10
10
  //#endregion
11
11
  //#region src/command.ts
12
12
  var CommandError = class extends Error {
@@ -59,6 +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
+ if (!repository.isClean()) throw new Error("The working tree must be clean before checkout");
62
63
  github.assertReady();
63
64
  const remote = repository.resolveRemote(options.remote);
64
65
  reporter.progress(`Looking up pull request ${options.reference}`);
@@ -166,7 +167,9 @@ var GitCliRepository = class {
166
167
  }
167
168
  assertReady() {
168
169
  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
+ isClean() {
172
+ return this.git(["status", "--porcelain"]).stdout.trim() === "";
170
173
  }
171
174
  currentBranch() {
172
175
  return this.git([
@@ -174,7 +177,7 @@ var GitCliRepository = class {
174
177
  "--quiet",
175
178
  "--short",
176
179
  "HEAD"
177
- ], { allowFailure: true }).stdout.trim();
180
+ ], { allowFailure: true }).stdout.trim() || void 0;
178
181
  }
179
182
  resolveRemote(requested) {
180
183
  if (requested) {
@@ -324,6 +327,14 @@ const pullRequestSchema = v.object({
324
327
  body: v.string(),
325
328
  isDraft: v.boolean()
326
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
+ });
327
338
  const stackSchema = v.object({ number: v.number() });
328
339
  var GitHubCliPlatform = class {
329
340
  cwd;
@@ -389,25 +400,33 @@ var GitHubCliPlatform = class {
389
400
  return v.parse(pullRequestSchema, JSON.parse(raw));
390
401
  }
391
402
  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;
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
+ };
409
428
  }
410
- linkStack(branches, base, remote, draft) {
429
+ linkStack(pullRequests, base, remote, draft) {
411
430
  const args = [
412
431
  "stack",
413
432
  "link",
@@ -417,10 +436,10 @@ var GitHubCliPlatform = class {
417
436
  remote
418
437
  ];
419
438
  if (!draft) args.push("--open");
420
- args.push(...branches);
439
+ args.push(...pullRequests.map(String));
421
440
  this.gh(args);
422
441
  }
423
- appendToStack(stackNumber, branches, remote, draft) {
442
+ appendToStack(stackNumber, pullRequests, remote, draft) {
424
443
  const args = [
425
444
  "stack",
426
445
  "link",
@@ -428,7 +447,7 @@ var GitHubCliPlatform = class {
428
447
  remote
429
448
  ];
430
449
  if (!draft) args.push("--open");
431
- args.push(String(stackNumber), ...branches);
450
+ args.push(String(stackNumber), ...pullRequests.map(String));
432
451
  this.gh(args);
433
452
  }
434
453
  unstack(stackNumber) {
@@ -603,7 +622,6 @@ var Stack = class Stack {
603
622
  const currentIds = this.changes.map((change) => change.id);
604
623
  const previousIdSet = new Set(previousIds);
605
624
  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
625
  const removed = previous.changes.filter((change) => !currentIdSet.has(change.id));
608
626
  const added = this.changes.filter((change) => !previousIdSet.has(change.id));
609
627
  if (removed.length === 0) {
@@ -612,7 +630,7 @@ var Stack = class Stack {
612
630
  return stackNumber === void 0 ? { kind: "full" } : {
613
631
  kind: "rebuild",
614
632
  stackNumber,
615
- action: "insert"
633
+ action: added.length === 0 ? "reorder" : "insert"
616
634
  };
617
635
  }
618
636
  const firstCurrentIndex = previousIds.indexOf(currentIds[0]);
@@ -648,9 +666,6 @@ var Stack = class Stack {
648
666
  };
649
667
  }
650
668
  };
651
- function sameSequence(left, right) {
652
- return left.length === right.length && left.every((value, index) => value === right[index]);
653
- }
654
669
  //#endregion
655
670
  //#region src/sync.ts
656
671
  function syncStack(dependencies, options) {
@@ -687,24 +702,44 @@ function syncStack(dependencies, options) {
687
702
  const state = stateStore.read();
688
703
  const previous = stack.findPrevious(state);
689
704
  const transition = stack.transitionFrom(previous, {
690
- preserveHigherChanges: repository.currentBranch() === "",
705
+ preserveHigherChanges: repository.currentBranch() === void 0,
691
706
  lookups: {
692
707
  pullRequestState: (pullRequest) => github.pullRequest(pullRequest).state,
693
708
  stackNumberForPullRequest: (pullRequest) => github.stackNumberForPullRequest(pullRequest)
694
709
  }
695
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
+ }
696
721
  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
- })));
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
+ }
701
731
  reporter.progress("Looking up existing pull requests");
702
732
  const existing = changes.map((change) => github.pullRequestForBranch(change.remoteBranch));
703
- 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
+ });
704
740
  if (changes.length === 1) {
705
741
  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);
742
+ const pullRequest = pullRequests[0];
708
743
  if (transition.kind === "collapse") {
709
744
  reporter.progress(`Removing omitted pull requests from stack #${transition.stackNumber}`);
710
745
  github.unstack(transition.stackNumber);
@@ -714,30 +749,27 @@ function syncStack(dependencies, options) {
714
749
  restorePreviousStack(github, previous, base, remote, reporter, error);
715
750
  }
716
751
  }
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
- }
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");
741
773
  reporter.progress("Synchronizing pull request titles and descriptions");
742
774
  for (const [index, pr] of pullRequests.entries()) {
743
775
  github.editPullRequest(pr, changes[index]);
@@ -772,7 +804,7 @@ function restorePreviousStack(github, previous, base, remote, reporter, rebuildE
772
804
  const rebuildMessage = rebuildError instanceof Error ? rebuildError.message : String(rebuildError);
773
805
  reporter.progress("Rebuild failed; restoring the previous native GitHub stack");
774
806
  try {
775
- github.linkStack(previous.changes.map((change) => change.remoteBranch), base, remote, true);
807
+ github.linkStack(previous.changes.map((change) => change.pullRequest), base, remote, true);
776
808
  } catch (rollbackError) {
777
809
  const rollbackMessage = rollbackError instanceof Error ? rollbackError.message : String(rollbackError);
778
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.5",
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": {