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.
- package/dist/bstack.mjs +143 -67
- 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.
|
|
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
|
|
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
|
|
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}
|
|
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
|
-
|
|
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"))
|
|
667
|
-
|
|
668
|
-
|
|
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
|
|
674
|
-
|
|
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
|
|
863
|
+
const pushResult = repository.pushBranches(remote, changes.map((change) => ({
|
|
753
864
|
name: change.remoteBranch,
|
|
754
865
|
oid: change.oid
|
|
755
866
|
})));
|
|
756
|
-
|
|
867
|
+
reportPushResult(reporter, pushResult);
|
|
868
|
+
pushedBranches = new Set(pushResult.updated);
|
|
757
869
|
} catch (error) {
|
|
758
|
-
if (isReorder) restorePreviousStack(github, previous,
|
|
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
|
-
|
|
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
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|