bstack 1.4.0 → 1.5.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 (2) hide show
  1. package/dist/bstack.mjs +143 -67
  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.1";
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);
@@ -651,8 +666,20 @@ var Stack = class Stack {
651
666
  const currentIdSet = new Set(currentIds);
652
667
  const removed = previous.changes.filter((change) => !currentIdSet.has(change.id));
653
668
  const added = this.changes.filter((change) => !previousIdSet.has(change.id));
669
+ const baseChanged = previous.base !== options.base;
654
670
  if (removed.length === 0) {
655
- if (previousIds.every((id, index) => currentIds[index] === id)) return { kind: "full" };
671
+ const previousIsPrefix = previousIds.every((id, index) => currentIds[index] === id);
672
+ if (previousIsPrefix && added.length === 0) return baseChanged ? this.transitionForChangedBase(previous, options) : { kind: "skip" };
673
+ if (previousIsPrefix) {
674
+ if (baseChanged) return this.transitionForChangedBase(previous, options);
675
+ const stackNumber = previous.stackNumber ?? options.lookups.stackNumberForPullRequest(previous.changes[0].pullRequest);
676
+ if (stackNumber !== void 0) return {
677
+ kind: "append",
678
+ stackNumber,
679
+ branches: added.map((change) => change.remoteBranch)
680
+ };
681
+ return { kind: "full" };
682
+ }
656
683
  const stackNumber = previous.stackNumber ?? options.lookups.stackNumberForPullRequest(previous.changes[0].pullRequest);
657
684
  return stackNumber === void 0 ? { kind: "full" } : {
658
685
  kind: "rebuild",
@@ -663,16 +690,21 @@ var Stack = class Stack {
663
690
  const firstCurrentIndex = previousIds.indexOf(currentIds[0]);
664
691
  const isPreviousSlice = firstCurrentIndex >= 0 && currentIds.every((id, index) => previousIds[firstCurrentIndex + index] === id);
665
692
  if (options.preserveHigherChanges && added.length === 0 && isPreviousSlice && firstCurrentIndex + currentIds.length < previousIds.length) {
666
- if (previous.changes.slice(0, firstCurrentIndex).every((change) => options.lookups.pullRequestState(change.pullRequest) === "MERGED")) return {
667
- kind: "partial",
668
- previousOffset: firstCurrentIndex
669
- };
693
+ if (previous.changes.slice(0, firstCurrentIndex).every((change) => options.lookups.pullRequestState(change.pullRequest) === "MERGED")) {
694
+ if (baseChanged) throw new Error("Cannot change the stack base while preserving higher pull requests from a detached checkout");
695
+ return {
696
+ kind: "partial",
697
+ previousOffset: firstCurrentIndex
698
+ };
699
+ }
670
700
  }
671
701
  const removedPrefixWasMerged = removed.every((change, index) => previous.changes[index] === change) && removed.every((change) => options.lookups.pullRequestState(change.pullRequest) === "MERGED");
672
702
  const survivingIds = previousIds.slice(removed.length);
673
- const onlyAppendedAfterMergedPrefix = removedPrefixWasMerged && survivingIds.every((id, index) => currentIds[index] === id) && currentIds.slice(survivingIds.length).every((id) => !previousIdSet.has(id));
674
- if (removedPrefixWasMerged && added.length === 0) return { kind: "skip" };
703
+ const survivingOrderIsUnchanged = survivingIds.every((id, index) => currentIds[index] === id);
704
+ const onlyAppendedAfterMergedPrefix = removedPrefixWasMerged && survivingOrderIsUnchanged && currentIds.slice(survivingIds.length).every((id) => !previousIdSet.has(id));
705
+ if (removedPrefixWasMerged && added.length === 0 && survivingOrderIsUnchanged) return baseChanged ? this.transitionForChangedBase(previous, options) : { kind: "skip" };
675
706
  if (onlyAppendedAfterMergedPrefix) {
707
+ if (baseChanged) return this.transitionForChangedBase(previous, options);
676
708
  if (previous.stackNumber === void 0) throw new Error("Cannot append after a merge because the native GitHub stack number is missing from local state");
677
709
  return {
678
710
  kind: "append",
@@ -689,11 +721,98 @@ var Stack = class Stack {
689
721
  return {
690
722
  kind: "rebuild",
691
723
  stackNumber,
692
- action: added.length === 0 ? "remove" : "update"
724
+ action: added.length === 0 && removedPrefixWasMerged ? "reorder" : added.length === 0 ? "remove" : "update"
725
+ };
726
+ }
727
+ transitionForChangedBase(previous, options) {
728
+ if (this.changes.length === 1) return { kind: "retarget" };
729
+ const stackNumber = previous.stackNumber ?? options.lookups.stackNumberForPullRequest(previous.changes[0].pullRequest);
730
+ return stackNumber === void 0 ? { kind: "full" } : {
731
+ kind: "rebuild",
732
+ stackNumber,
733
+ action: "change-base"
693
734
  };
694
735
  }
695
736
  };
696
737
  //#endregion
738
+ //#region src/sync-transition.ts
739
+ function prepareStackTransition(transition, github, previous, base, reporter) {
740
+ if (transition.kind !== "rebuild" || transition.action !== "reorder") return;
741
+ reporter.progress(`Preparing stack #${transition.stackNumber} for reordered branches`);
742
+ github.unstack(transition.stackNumber);
743
+ try {
744
+ for (const change of previous.changes) {
745
+ const pullRequest = github.pullRequest(change.pullRequest);
746
+ if (pullRequest.state === "OPEN") github.editPullRequestBase(pullRequest, base);
747
+ }
748
+ } catch (error) {
749
+ restorePreviousStack(github, previous, reporter, error);
750
+ }
751
+ }
752
+ function applyStackTransition(transition, github, previous, changes, pullRequests, base, remote, draft, reporter) {
753
+ if (transition.kind === "retarget") {
754
+ reporter.progress(`Updating the pull request base to ${base}`);
755
+ github.editPullRequestBase(pullRequests[0], base);
756
+ return;
757
+ }
758
+ if (changes.length === 1) {
759
+ if (transition.kind === "partial") reporter.progress("Updating this down-stack prefix while preserving higher pull requests");
760
+ if (transition.kind === "collapse") {
761
+ reporter.progress(`Removing omitted pull requests from stack #${transition.stackNumber}`);
762
+ github.unstack(transition.stackNumber);
763
+ try {
764
+ github.editPullRequestBase(pullRequests[0], base);
765
+ } catch (error) {
766
+ restorePreviousStack(github, previous, reporter, error);
767
+ }
768
+ }
769
+ return;
770
+ }
771
+ if (transition.kind === "full") {
772
+ reporter.progress(`Linking ${changes.length} pull requests as a native GitHub stack`);
773
+ github.linkStack(pullRequests.map((pullRequest) => pullRequest.number), base, remote, draft);
774
+ return;
775
+ }
776
+ if (transition.kind === "rebuild") {
777
+ const reason = transition.action === "change-base" ? `against ${base}` : `to ${transition.action} pull requests`;
778
+ reporter.progress(`Rebuilding stack #${transition.stackNumber} ${reason}`);
779
+ if (transition.action !== "reorder") github.unstack(transition.stackNumber);
780
+ try {
781
+ github.linkStack(pullRequests.map((pullRequest) => pullRequest.number), base, remote, draft);
782
+ } catch (error) {
783
+ restorePreviousStack(github, previous, reporter, error);
784
+ }
785
+ return;
786
+ }
787
+ if (transition.kind === "append") {
788
+ reporter.progress(`Appending ${transition.branches.length} pull request${transition.branches.length === 1 ? "" : "s"} to stack #${transition.stackNumber}`);
789
+ github.appendToStack(transition.stackNumber, transition.branches.map((branch) => {
790
+ const pullRequest = pullRequests[changes.findIndex((change) => change.remoteBranch === branch)];
791
+ if (!pullRequest) throw new Error(`Missing pull request for ${branch}`);
792
+ return pullRequest.number;
793
+ }), remote, draft);
794
+ return;
795
+ }
796
+ if (transition.kind === "partial") {
797
+ reporter.progress("Updating this down-stack prefix while preserving higher pull requests");
798
+ return;
799
+ }
800
+ reporter.progress("The native GitHub stack already has the correct members");
801
+ }
802
+ function restorePreviousStack(github, previous, reporter, rebuildError) {
803
+ const rebuildMessage = rebuildError instanceof Error ? rebuildError.message : String(rebuildError);
804
+ reporter.progress("Rebuild failed; restoring the previous native GitHub stack");
805
+ try {
806
+ const pullRequests = previous.changes.map((change) => github.pullRequest(change.pullRequest)).filter((pullRequest) => pullRequest.state === "OPEN");
807
+ if (pullRequests.length === 1) github.editPullRequestBase(pullRequests[0], previous.base);
808
+ else if (pullRequests.length > 1) github.linkStack(pullRequests.map((pullRequest) => pullRequest.number), previous.base, previous.remote, true);
809
+ } catch (rollbackError) {
810
+ const rollbackMessage = rollbackError instanceof Error ? rollbackError.message : String(rollbackError);
811
+ throw new Error(`Stack rebuild failed: ${rebuildMessage}\nRestoring the previous stack also failed: ${rollbackMessage}`);
812
+ }
813
+ throw rebuildError;
814
+ }
815
+ //#endregion
697
816
  //#region src/sync.ts
698
817
  function syncStack(dependencies, options) {
699
818
  const { repository, github, stateStore, reporter } = dependencies;
@@ -730,32 +849,25 @@ function syncStack(dependencies, options) {
730
849
  const state = stateStore.read();
731
850
  const previous = stack.findPrevious(state);
732
851
  const transition = stack.transitionFrom(previous, {
852
+ base,
733
853
  preserveHigherChanges: repository.currentBranch() === void 0,
734
854
  lookups: {
735
855
  pullRequestState: (pullRequest) => github.pullRequest(pullRequest).state,
736
856
  stackNumberForPullRequest: (pullRequest) => github.stackNumberForPullRequest(pullRequest)
737
857
  }
738
858
  });
859
+ prepareStackTransition(transition, github, previous, base, reporter);
739
860
  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
861
  let pushedBranches = /* @__PURE__ */ new Set();
751
862
  try {
752
- const updatedBranches = repository.pushBranches(remote, changes.map((change) => ({
863
+ const pushResult = repository.pushBranches(remote, changes.map((change) => ({
753
864
  name: change.remoteBranch,
754
865
  oid: change.oid
755
866
  })));
756
- pushedBranches = new Set(updatedBranches);
867
+ reportPushResult(reporter, pushResult);
868
+ pushedBranches = new Set(pushResult.updated);
757
869
  } catch (error) {
758
- if (isReorder) restorePreviousStack(github, previous, base, remote, reporter, error);
870
+ if (isReorder) restorePreviousStack(github, previous, reporter, error);
759
871
  throw error;
760
872
  }
761
873
  reporter.progress("Looking up existing pull requests");
@@ -768,39 +880,7 @@ function syncStack(dependencies, options) {
768
880
  return github.createPullRequest(change, pullRequestBase, options.draft);
769
881
  });
770
882
  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");
883
+ applyStackTransition(transition, github, previous, changes, pullRequests, base, remote, options.draft, reporter);
804
884
  const currentIds = new Set(changes.map((change) => change.id));
805
885
  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
886
  if (omittedPullRequests.length > 0) {
@@ -856,16 +936,12 @@ function changeOutcome(change, index, pullRequest, previous, changes, base, crea
856
936
  const metadataChanged = pullRequest.title !== change.subject || pullRequest.body !== change.body;
857
937
  return pushedBranches.has(change.remoteBranch) || previousBase !== currentBase || metadataChanged ? "updated" : "unchanged";
858
938
  }
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}`);
939
+ function reportPushResult(reporter, result) {
940
+ if (result.updated.length === 0) {
941
+ reporter.progress(`All ${result.checked} remote branch${result.checked === 1 ? "" : "es"} already match`);
942
+ return;
867
943
  }
868
- throw rebuildError;
944
+ reporter.progress(`Updating ${result.updated.length} of ${result.checked} remote branch${result.checked === 1 ? "" : "es"}`);
869
945
  }
870
946
  function writeUpdatedState(store, state, previous, updated) {
871
947
  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.1",
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",