bstack 1.4.0 → 1.5.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 (2) hide show
  1. package/dist/bstack.mjs +107 -59
  2. package/package.json +2 -1
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.4.0";
9
+ var version = "1.5.0";
10
10
  //#endregion
11
11
  //#region src/command.ts
12
12
  var CommandError = class extends Error {
@@ -265,9 +265,14 @@ var GitCliRepository = class {
265
265
  }
266
266
  pushBranches(remote, branches) {
267
267
  const existing = this.remoteBranchOids(remote, branches.map((branch) => branch.name));
268
+ const updates = branches.filter((branch) => existing.get(branch.name) !== branch.oid);
269
+ if (updates.length === 0) return {
270
+ checked: branches.length,
271
+ updated: []
272
+ };
268
273
  const leases = [];
269
274
  const refspecs = [];
270
- for (const branch of branches) {
275
+ for (const branch of updates) {
271
276
  const expected = existing.get(branch.name) ?? "";
272
277
  leases.push(`--force-with-lease=refs/heads/${branch.name}:${expected}`);
273
278
  refspecs.push(`${branch.oid}:refs/heads/${branch.name}`);
@@ -279,7 +284,10 @@ var GitCliRepository = class {
279
284
  remote,
280
285
  ...refspecs
281
286
  ]);
282
- return branches.filter((branch) => existing.get(branch.name) !== branch.oid).map((branch) => branch.name);
287
+ return {
288
+ checked: branches.length,
289
+ updated: updates.map((branch) => branch.name)
290
+ };
283
291
  }
284
292
  statePath() {
285
293
  return this.git([
@@ -513,7 +521,7 @@ var GitHubCliPlatform = class {
513
521
  //#region src/output.ts
514
522
  function formatSyncResult(result, dryRun) {
515
523
  const changeCount = `${result.changes.length} change${result.changes.length === 1 ? "" : "s"}`;
516
- const lines = [dryRun ? `Would sync ${changeCount} against ${result.base}:` : `Synced stack against ${result.base} (${changeCount}):`];
524
+ const lines = [dryRun ? `Would sync ${changeCount} against ${result.base}:` : `Synced ${result.changes.length}-commit stack against ${result.base}:`];
517
525
  if (dryRun) {
518
526
  for (const change of result.changes) lines.push(` ${change.oid.slice(0, 8)} ${change.subject}`);
519
527
  return lines.join("\n");
@@ -603,6 +611,13 @@ var Stack = class Stack {
603
611
  this.rewritten = rewritten;
604
612
  this.rewrites = rewrites;
605
613
  if (changes.length === 0) throw new Error("A stack must contain at least one change");
614
+ const seen = /* @__PURE__ */ new Set();
615
+ const duplicate = changes.find((change) => {
616
+ if (seen.has(change.id)) return true;
617
+ seen.add(change.id);
618
+ return false;
619
+ });
620
+ if (duplicate) throw new Error(`A stack cannot contain duplicate bstack-id ${duplicate.id}`);
606
621
  }
607
622
  static fromChanges(changes) {
608
623
  return new Stack(changes);
@@ -652,7 +667,17 @@ var Stack = class Stack {
652
667
  const removed = previous.changes.filter((change) => !currentIdSet.has(change.id));
653
668
  const added = this.changes.filter((change) => !previousIdSet.has(change.id));
654
669
  if (removed.length === 0) {
655
- if (previousIds.every((id, index) => currentIds[index] === id)) return { kind: "full" };
670
+ const previousIsPrefix = previousIds.every((id, index) => currentIds[index] === id);
671
+ if (previousIsPrefix && added.length === 0) return { kind: "skip" };
672
+ if (previousIsPrefix) {
673
+ const stackNumber = previous.stackNumber ?? options.lookups.stackNumberForPullRequest(previous.changes[0].pullRequest);
674
+ if (stackNumber !== void 0) return {
675
+ kind: "append",
676
+ stackNumber,
677
+ branches: added.map((change) => change.remoteBranch)
678
+ };
679
+ return { kind: "full" };
680
+ }
656
681
  const stackNumber = previous.stackNumber ?? options.lookups.stackNumberForPullRequest(previous.changes[0].pullRequest);
657
682
  return stackNumber === void 0 ? { kind: "full" } : {
658
683
  kind: "rebuild",
@@ -694,6 +719,73 @@ var Stack = class Stack {
694
719
  }
695
720
  };
696
721
  //#endregion
722
+ //#region src/sync-transition.ts
723
+ function prepareStackTransition(transition, github, previous, base, remote, reporter) {
724
+ if (transition.kind !== "rebuild" || transition.action !== "reorder") return;
725
+ reporter.progress(`Preparing stack #${transition.stackNumber} for reordered branches`);
726
+ github.unstack(transition.stackNumber);
727
+ try {
728
+ for (const change of previous.changes) github.editPullRequestBase(github.pullRequest(change.pullRequest), base);
729
+ } catch (error) {
730
+ restorePreviousStack(github, previous, base, remote, reporter, error);
731
+ }
732
+ }
733
+ function applyStackTransition(transition, github, previous, changes, pullRequests, base, remote, draft, reporter) {
734
+ if (changes.length === 1) {
735
+ if (transition.kind === "partial") reporter.progress("Updating this down-stack prefix while preserving higher pull requests");
736
+ if (transition.kind === "collapse") {
737
+ reporter.progress(`Removing omitted pull requests from stack #${transition.stackNumber}`);
738
+ github.unstack(transition.stackNumber);
739
+ try {
740
+ github.editPullRequestBase(pullRequests[0], base);
741
+ } catch (error) {
742
+ restorePreviousStack(github, previous, base, remote, reporter, error);
743
+ }
744
+ }
745
+ return;
746
+ }
747
+ if (transition.kind === "full") {
748
+ reporter.progress(`Linking ${changes.length} pull requests as a native GitHub stack`);
749
+ github.linkStack(pullRequests.map((pullRequest) => pullRequest.number), base, remote, draft);
750
+ return;
751
+ }
752
+ if (transition.kind === "rebuild") {
753
+ reporter.progress(`Rebuilding stack #${transition.stackNumber} to ${transition.action} pull requests`);
754
+ if (transition.action !== "reorder") github.unstack(transition.stackNumber);
755
+ try {
756
+ github.linkStack(pullRequests.map((pullRequest) => pullRequest.number), base, remote, draft);
757
+ } catch (error) {
758
+ restorePreviousStack(github, previous, base, remote, reporter, error);
759
+ }
760
+ return;
761
+ }
762
+ if (transition.kind === "append") {
763
+ reporter.progress(`Appending ${transition.branches.length} pull request${transition.branches.length === 1 ? "" : "s"} to stack #${transition.stackNumber}`);
764
+ github.appendToStack(transition.stackNumber, transition.branches.map((branch) => {
765
+ const pullRequest = pullRequests[changes.findIndex((change) => change.remoteBranch === branch)];
766
+ if (!pullRequest) throw new Error(`Missing pull request for ${branch}`);
767
+ return pullRequest.number;
768
+ }), remote, draft);
769
+ return;
770
+ }
771
+ if (transition.kind === "partial") {
772
+ reporter.progress("Updating this down-stack prefix while preserving higher pull requests");
773
+ return;
774
+ }
775
+ reporter.progress("The native GitHub stack already has the correct members");
776
+ }
777
+ function restorePreviousStack(github, previous, base, remote, reporter, rebuildError) {
778
+ const rebuildMessage = rebuildError instanceof Error ? rebuildError.message : String(rebuildError);
779
+ reporter.progress("Rebuild failed; restoring the previous native GitHub stack");
780
+ try {
781
+ github.linkStack(previous.changes.map((change) => change.pullRequest), base, remote, true);
782
+ } catch (rollbackError) {
783
+ const rollbackMessage = rollbackError instanceof Error ? rollbackError.message : String(rollbackError);
784
+ throw new Error(`Stack rebuild failed: ${rebuildMessage}\nRestoring the previous stack also failed: ${rollbackMessage}`);
785
+ }
786
+ throw rebuildError;
787
+ }
788
+ //#endregion
697
789
  //#region src/sync.ts
698
790
  function syncStack(dependencies, options) {
699
791
  const { repository, github, stateStore, reporter } = dependencies;
@@ -736,24 +828,16 @@ function syncStack(dependencies, options) {
736
828
  stackNumberForPullRequest: (pullRequest) => github.stackNumberForPullRequest(pullRequest)
737
829
  }
738
830
  });
831
+ prepareStackTransition(transition, github, previous, base, remote, reporter);
739
832
  const isReorder = transition.kind === "rebuild" && transition.action === "reorder";
740
- if (isReorder) {
741
- reporter.progress(`Preparing stack #${transition.stackNumber} for reordered branches`);
742
- github.unstack(transition.stackNumber);
743
- try {
744
- for (const change of previous.changes) github.editPullRequestBase(github.pullRequest(change.pullRequest), base);
745
- } catch (error) {
746
- restorePreviousStack(github, previous, base, remote, reporter, error);
747
- }
748
- }
749
- reporter.progress(`Pushing ${changes.length} remote branch${changes.length === 1 ? "" : "es"}`);
750
833
  let pushedBranches = /* @__PURE__ */ new Set();
751
834
  try {
752
- const updatedBranches = repository.pushBranches(remote, changes.map((change) => ({
835
+ const pushResult = repository.pushBranches(remote, changes.map((change) => ({
753
836
  name: change.remoteBranch,
754
837
  oid: change.oid
755
838
  })));
756
- pushedBranches = new Set(updatedBranches);
839
+ reportPushResult(reporter, pushResult);
840
+ pushedBranches = new Set(pushResult.updated);
757
841
  } catch (error) {
758
842
  if (isReorder) restorePreviousStack(github, previous, base, remote, reporter, error);
759
843
  throw error;
@@ -768,39 +852,7 @@ function syncStack(dependencies, options) {
768
852
  return github.createPullRequest(change, pullRequestBase, options.draft);
769
853
  });
770
854
  const createdBranches = new Set(changes.filter((_change, index) => existing[index] === void 0).map((change) => change.remoteBranch));
771
- if (changes.length === 1) {
772
- if (transition.kind === "partial") reporter.progress("Updating this down-stack prefix while preserving higher pull requests");
773
- const pullRequest = pullRequests[0];
774
- if (transition.kind === "collapse") {
775
- reporter.progress(`Removing omitted pull requests from stack #${transition.stackNumber}`);
776
- github.unstack(transition.stackNumber);
777
- try {
778
- github.editPullRequestBase(pullRequest, base);
779
- } catch (error) {
780
- restorePreviousStack(github, previous, base, remote, reporter, error);
781
- }
782
- }
783
- } else if (transition.kind === "full") {
784
- reporter.progress(`Linking ${changes.length} pull requests as a native GitHub stack`);
785
- github.linkStack(pullRequests.map((pullRequest) => pullRequest.number), base, remote, options.draft);
786
- } else if (transition.kind === "rebuild") {
787
- reporter.progress(`Rebuilding stack #${transition.stackNumber} to ${transition.action} pull requests`);
788
- if (!isReorder) github.unstack(transition.stackNumber);
789
- try {
790
- github.linkStack(pullRequests.map((pullRequest) => pullRequest.number), base, remote, options.draft);
791
- } catch (error) {
792
- restorePreviousStack(github, previous, base, remote, reporter, error);
793
- }
794
- } else if (transition.kind === "append") {
795
- reporter.progress(`Appending ${transition.branches.length} pull request${transition.branches.length === 1 ? "" : "s"} to stack #${transition.stackNumber}`);
796
- github.appendToStack(transition.stackNumber, transition.branches.map((branch) => {
797
- const index = changes.findIndex((change) => change.remoteBranch === branch);
798
- const pullRequest = pullRequests[index];
799
- if (!pullRequest) throw new Error(`Missing pull request for ${branch}`);
800
- return pullRequest.number;
801
- }), remote, options.draft);
802
- } else if (transition.kind === "partial") reporter.progress("Updating this down-stack prefix while preserving higher pull requests");
803
- else reporter.progress("The native GitHub stack already has the correct members");
855
+ applyStackTransition(transition, github, previous, changes, pullRequests, base, remote, options.draft, reporter);
804
856
  const currentIds = new Set(changes.map((change) => change.id));
805
857
  const omittedPullRequests = transition.kind === "partial" ? [] : (previous?.changes ?? []).filter((change) => !currentIds.has(change.id)).map((change) => github.pullRequest(change.pullRequest)).filter((pullRequest) => pullRequest.state === "OPEN");
806
858
  if (omittedPullRequests.length > 0) {
@@ -856,16 +908,12 @@ function changeOutcome(change, index, pullRequest, previous, changes, base, crea
856
908
  const metadataChanged = pullRequest.title !== change.subject || pullRequest.body !== change.body;
857
909
  return pushedBranches.has(change.remoteBranch) || previousBase !== currentBase || metadataChanged ? "updated" : "unchanged";
858
910
  }
859
- function restorePreviousStack(github, previous, base, remote, reporter, rebuildError) {
860
- const rebuildMessage = rebuildError instanceof Error ? rebuildError.message : String(rebuildError);
861
- reporter.progress("Rebuild failed; restoring the previous native GitHub stack");
862
- try {
863
- github.linkStack(previous.changes.map((change) => change.pullRequest), base, remote, true);
864
- } catch (rollbackError) {
865
- const rollbackMessage = rollbackError instanceof Error ? rollbackError.message : String(rollbackError);
866
- throw new Error(`Stack rebuild failed: ${rebuildMessage}\nRestoring the previous stack also failed: ${rollbackMessage}`);
911
+ function reportPushResult(reporter, result) {
912
+ if (result.updated.length === 0) {
913
+ reporter.progress(`All ${result.checked} remote branch${result.checked === 1 ? "" : "es"} already match`);
914
+ return;
867
915
  }
868
- throw rebuildError;
916
+ reporter.progress(`Updating ${result.updated.length} of ${result.checked} remote branch${result.checked === 1 ? "" : "es"}`);
869
917
  }
870
918
  function writeUpdatedState(store, state, previous, updated) {
871
919
  const stacks = previous ? state.stacks.map((stack) => stack === previous ? updated : stack) : [...state.stacks, updated];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bstack",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Create native GitHub stacked pull requests from a linear series of commits",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -32,6 +32,7 @@
32
32
  "valibot": "^1.4.2"
33
33
  },
34
34
  "devDependencies": {
35
+ "@total-typescript/shoehorn": "^0.1.2",
35
36
  "@types/node": "^26.0.0",
36
37
  "oxfmt": "^0.64.0",
37
38
  "oxlint": "^1.79.0",