bstack 1.3.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 +143 -71
  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.3.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([
@@ -510,6 +518,25 @@ var GitHubCliPlatform = class {
510
518
  }
511
519
  };
512
520
  //#endregion
521
+ //#region src/output.ts
522
+ function formatSyncResult(result, dryRun) {
523
+ const changeCount = `${result.changes.length} change${result.changes.length === 1 ? "" : "s"}`;
524
+ const lines = [dryRun ? `Would sync ${changeCount} against ${result.base}:` : `Synced ${result.changes.length}-commit stack against ${result.base}:`];
525
+ if (dryRun) {
526
+ for (const change of result.changes) lines.push(` ${change.oid.slice(0, 8)} ${change.subject}`);
527
+ return lines.join("\n");
528
+ }
529
+ for (const outcome of result.outcomes) {
530
+ if (outcome.outcome === "closed") {
531
+ const { pullRequest } = outcome;
532
+ lines.push(` ${outcome.outcome.padEnd(9)} #${pullRequest.number} ${pullRequest.title} ${pullRequest.url}`);
533
+ continue;
534
+ }
535
+ lines.push(` ${outcome.outcome.padEnd(9)} #${outcome.pullRequest.number} ${outcome.change.subject} ${outcome.pullRequest.url}`);
536
+ }
537
+ return lines.join("\n");
538
+ }
539
+ //#endregion
513
540
  //#region src/reporter.ts
514
541
  var ConsoleReporter = class {
515
542
  progress(message) {
@@ -584,6 +611,13 @@ var Stack = class Stack {
584
611
  this.rewritten = rewritten;
585
612
  this.rewrites = rewrites;
586
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}`);
587
621
  }
588
622
  static fromChanges(changes) {
589
623
  return new Stack(changes);
@@ -633,7 +667,17 @@ var Stack = class Stack {
633
667
  const removed = previous.changes.filter((change) => !currentIdSet.has(change.id));
634
668
  const added = this.changes.filter((change) => !previousIdSet.has(change.id));
635
669
  if (removed.length === 0) {
636
- 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
+ }
637
681
  const stackNumber = previous.stackNumber ?? options.lookups.stackNumberForPullRequest(previous.changes[0].pullRequest);
638
682
  return stackNumber === void 0 ? { kind: "full" } : {
639
683
  kind: "rebuild",
@@ -675,6 +719,73 @@ var Stack = class Stack {
675
719
  }
676
720
  };
677
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
678
789
  //#region src/sync.ts
679
790
  function syncStack(dependencies, options) {
680
791
  const { repository, github, stateStore, reporter } = dependencies;
@@ -703,7 +814,8 @@ function syncStack(dependencies, options) {
703
814
  base,
704
815
  remote,
705
816
  rewritten,
706
- changes: [...changes]
817
+ changes: [...changes],
818
+ outcomes: []
707
819
  };
708
820
  }
709
821
  reporter.progress("Reading the previous stack state");
@@ -716,24 +828,16 @@ function syncStack(dependencies, options) {
716
828
  stackNumberForPullRequest: (pullRequest) => github.stackNumberForPullRequest(pullRequest)
717
829
  }
718
830
  });
831
+ prepareStackTransition(transition, github, previous, base, remote, reporter);
719
832
  const isReorder = transition.kind === "rebuild" && transition.action === "reorder";
720
- if (isReorder) {
721
- reporter.progress(`Preparing stack #${transition.stackNumber} for reordered branches`);
722
- github.unstack(transition.stackNumber);
723
- try {
724
- for (const change of previous.changes) github.editPullRequestBase(github.pullRequest(change.pullRequest), base);
725
- } catch (error) {
726
- restorePreviousStack(github, previous, base, remote, reporter, error);
727
- }
728
- }
729
- reporter.progress(`Pushing ${changes.length} remote branch${changes.length === 1 ? "" : "es"}`);
730
833
  let pushedBranches = /* @__PURE__ */ new Set();
731
834
  try {
732
- const updatedBranches = repository.pushBranches(remote, changes.map((change) => ({
835
+ const pushResult = repository.pushBranches(remote, changes.map((change) => ({
733
836
  name: change.remoteBranch,
734
837
  oid: change.oid
735
838
  })));
736
- pushedBranches = new Set(updatedBranches);
839
+ reportPushResult(reporter, pushResult);
840
+ pushedBranches = new Set(pushResult.updated);
737
841
  } catch (error) {
738
842
  if (isReorder) restorePreviousStack(github, previous, base, remote, reporter, error);
739
843
  throw error;
@@ -748,39 +852,7 @@ function syncStack(dependencies, options) {
748
852
  return github.createPullRequest(change, pullRequestBase, options.draft);
749
853
  });
750
854
  const createdBranches = new Set(changes.filter((_change, index) => existing[index] === void 0).map((change) => change.remoteBranch));
751
- if (changes.length === 1) {
752
- if (transition.kind === "partial") reporter.progress("Updating this down-stack prefix while preserving higher pull requests");
753
- const pullRequest = pullRequests[0];
754
- if (transition.kind === "collapse") {
755
- reporter.progress(`Removing omitted pull requests from stack #${transition.stackNumber}`);
756
- github.unstack(transition.stackNumber);
757
- try {
758
- github.editPullRequestBase(pullRequest, base);
759
- } catch (error) {
760
- restorePreviousStack(github, previous, base, remote, reporter, error);
761
- }
762
- }
763
- } else if (transition.kind === "full") {
764
- reporter.progress(`Linking ${changes.length} pull requests as a native GitHub stack`);
765
- github.linkStack(pullRequests.map((pullRequest) => pullRequest.number), base, remote, options.draft);
766
- } else if (transition.kind === "rebuild") {
767
- reporter.progress(`Rebuilding stack #${transition.stackNumber} to ${transition.action} pull requests`);
768
- if (!isReorder) github.unstack(transition.stackNumber);
769
- try {
770
- github.linkStack(pullRequests.map((pullRequest) => pullRequest.number), base, remote, options.draft);
771
- } catch (error) {
772
- restorePreviousStack(github, previous, base, remote, reporter, error);
773
- }
774
- } else if (transition.kind === "append") {
775
- reporter.progress(`Appending ${transition.branches.length} pull request${transition.branches.length === 1 ? "" : "s"} to stack #${transition.stackNumber}`);
776
- github.appendToStack(transition.stackNumber, transition.branches.map((branch) => {
777
- const index = changes.findIndex((change) => change.remoteBranch === branch);
778
- const pullRequest = pullRequests[index];
779
- if (!pullRequest) throw new Error(`Missing pull request for ${branch}`);
780
- return pullRequest.number;
781
- }), remote, options.draft);
782
- } else if (transition.kind === "partial") reporter.progress("Updating this down-stack prefix while preserving higher pull requests");
783
- else reporter.progress("The native GitHub stack already has the correct members");
855
+ applyStackTransition(transition, github, previous, changes, pullRequests, base, remote, options.draft, reporter);
784
856
  const currentIds = new Set(changes.map((change) => change.id));
785
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");
786
858
  if (omittedPullRequests.length > 0) {
@@ -807,15 +879,25 @@ function syncStack(dependencies, options) {
807
879
  if (stackNumber !== void 0) updatedStack.stackNumber = stackNumber;
808
880
  writeUpdatedState(stateStore, state, previous, updatedStack);
809
881
  reporter.progress("Saved the local stack state");
882
+ const synchronized = changes.map((change, index) => ({
883
+ ...change,
884
+ pullRequest: pullRequests[index]
885
+ }));
886
+ const outcomes = synchronized.map((change, index) => ({
887
+ outcome: changeOutcome(change, index, change.pullRequest, previous, changes, base, createdBranches, pushedBranches),
888
+ change,
889
+ pullRequest: change.pullRequest
890
+ }));
891
+ outcomes.push(...omittedPullRequests.map((pullRequest) => ({
892
+ outcome: "closed",
893
+ pullRequest
894
+ })));
810
895
  return {
811
896
  base,
812
897
  remote,
813
898
  rewritten,
814
- changes: changes.map((change, index) => ({
815
- ...change,
816
- pullRequest: pullRequests[index],
817
- outcome: changeOutcome(change, index, pullRequests[index], previous, changes, base, createdBranches, pushedBranches)
818
- }))
899
+ changes: synchronized,
900
+ outcomes
819
901
  };
820
902
  }
821
903
  function changeOutcome(change, index, pullRequest, previous, changes, base, createdBranches, pushedBranches) {
@@ -826,16 +908,12 @@ function changeOutcome(change, index, pullRequest, previous, changes, base, crea
826
908
  const metadataChanged = pullRequest.title !== change.subject || pullRequest.body !== change.body;
827
909
  return pushedBranches.has(change.remoteBranch) || previousBase !== currentBase || metadataChanged ? "updated" : "unchanged";
828
910
  }
829
- function restorePreviousStack(github, previous, base, remote, reporter, rebuildError) {
830
- const rebuildMessage = rebuildError instanceof Error ? rebuildError.message : String(rebuildError);
831
- reporter.progress("Rebuild failed; restoring the previous native GitHub stack");
832
- try {
833
- github.linkStack(previous.changes.map((change) => change.pullRequest), base, remote, true);
834
- } catch (rollbackError) {
835
- const rollbackMessage = rollbackError instanceof Error ? rollbackError.message : String(rollbackError);
836
- 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;
837
915
  }
838
- throw rebuildError;
916
+ reporter.progress(`Updating ${result.updated.length} of ${result.checked} remote branch${result.checked === 1 ? "" : "es"}`);
839
917
  }
840
918
  function writeUpdatedState(store, state, previous, updated) {
841
919
  const stacks = previous ? state.stacks.map((stack) => stack === previous ? updated : stack) : [...state.stacks, updated];
@@ -939,13 +1017,7 @@ function main() {
939
1017
  draft: values.draft,
940
1018
  dryRun: values["dry-run"]
941
1019
  });
942
- const changeCount = `${result.changes.length} change${result.changes.length === 1 ? "" : "s"}`;
943
- console.log(values["dry-run"] ? `Would sync ${changeCount} against ${result.base}:` : `Synced stack against ${result.base} (${changeCount}):`);
944
- for (const change of result.changes) {
945
- const outcome = change.outcome ? `${change.outcome.padEnd(9)} ` : "";
946
- const destination = change.pullRequest ? ` ${change.pullRequest.url}` : "";
947
- console.log(` ${outcome}${change.oid.slice(0, 8)} ${change.subject}${destination}`);
948
- }
1020
+ console.log(formatSyncResult(result, values["dry-run"]));
949
1021
  }
950
1022
  try {
951
1023
  main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bstack",
3
- "version": "1.3.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",