bstack 1.0.0 → 1.0.3
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 +3 -3
- package/dist/bstack.js +40 -38
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# bstack
|
|
2
2
|
|
|
3
|
-
Convert series of commits in a local branch into native GitHub
|
|
3
|
+
Convert a series of commits in a local branch into a native GitHub stack of pull requests.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -17,7 +17,7 @@ gh extension install github/gh-stack
|
|
|
17
17
|
|
|
18
18
|
## Use
|
|
19
19
|
|
|
20
|
-
Create one commit per change, then
|
|
20
|
+
Create one commit per change, then sync the stack:
|
|
21
21
|
|
|
22
22
|
```bash
|
|
23
23
|
git switch -c my-feature
|
|
@@ -37,7 +37,7 @@ bstack checkout 123
|
|
|
37
37
|
bstack checkout https://github.com/owner/repo/pull/123
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
Use `--dry-run` to inspect without
|
|
40
|
+
Use `--dry-run` to inspect without syncing and `--quiet` to hide progress logs.
|
|
41
41
|
|
|
42
42
|
## References
|
|
43
43
|
|
package/dist/bstack.js
CHANGED
|
@@ -6,7 +6,7 @@ import { z } from "zod";
|
|
|
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.0.
|
|
9
|
+
var version = "1.0.3";
|
|
10
10
|
//#endregion
|
|
11
11
|
//#region src/command.ts
|
|
12
12
|
var CommandError = class extends Error {
|
|
@@ -75,24 +75,26 @@ function checkoutStack(repository, github, options) {
|
|
|
75
75
|
}
|
|
76
76
|
reporter.progress(`Checking out ${remote}/${headRef} in detached HEAD state`);
|
|
77
77
|
repository.checkout(target);
|
|
78
|
-
reporter.progress("Checkout complete; amend the commits and run bstack to
|
|
78
|
+
reporter.progress("Checkout complete; amend the commits and run bstack to sync updates");
|
|
79
79
|
return {
|
|
80
80
|
headRef,
|
|
81
81
|
delegated: false
|
|
82
82
|
};
|
|
83
83
|
}
|
|
84
84
|
//#endregion
|
|
85
|
-
//#region src/
|
|
86
|
-
const
|
|
85
|
+
//#region src/commit.ts
|
|
86
|
+
const CHANGE_ID_TRAILER = "bstack-id";
|
|
87
|
+
const changeIdTrailerPattern = new RegExp(`^${CHANGE_ID_TRAILER}:\\s*(\\S+)\\s*$`, "gm");
|
|
88
|
+
const changeIdTrailerLinePattern = new RegExp(`^${CHANGE_ID_TRAILER}:\\s*\\S+\\s*$`);
|
|
87
89
|
function readChangeId(message) {
|
|
88
|
-
const matches = [...message.matchAll(
|
|
89
|
-
if (matches.length > 1) throw new Error(
|
|
90
|
+
const matches = [...message.matchAll(changeIdTrailerPattern)];
|
|
91
|
+
if (matches.length > 1) throw new Error(`A commit contains more than one ${CHANGE_ID_TRAILER} trailer`);
|
|
90
92
|
return matches[0]?.[1];
|
|
91
93
|
}
|
|
92
94
|
function addChangeId(message, changeId) {
|
|
93
|
-
return `${message.trimEnd()}\n\
|
|
95
|
+
return `${message.trimEnd()}\n\n${CHANGE_ID_TRAILER}: ${changeId}\n`;
|
|
94
96
|
}
|
|
95
|
-
function
|
|
97
|
+
function generateChangeId() {
|
|
96
98
|
return randomUUID().replaceAll("-", "");
|
|
97
99
|
}
|
|
98
100
|
function parseRawCommit(oid, raw) {
|
|
@@ -129,7 +131,7 @@ function rewriteCommit(commit, parent, message) {
|
|
|
129
131
|
return `${rewrittenHeaders.join("\n")}\n\n${message}`;
|
|
130
132
|
}
|
|
131
133
|
function splitCommitMessage(message) {
|
|
132
|
-
const [subject = "Untitled change", ...bodyLines] = message.split("\n").filter((line) =>
|
|
134
|
+
const [subject = "Untitled change", ...bodyLines] = message.split("\n").filter((line) => !changeIdTrailerLinePattern.test(line)).join("\n").trim().split("\n");
|
|
133
135
|
return {
|
|
134
136
|
subject,
|
|
135
137
|
body: bodyLines.join("\n").trim()
|
|
@@ -152,7 +154,7 @@ var GitRepository = class {
|
|
|
152
154
|
}
|
|
153
155
|
assertReady() {
|
|
154
156
|
this.git(["rev-parse", "--show-toplevel"]);
|
|
155
|
-
if (this.git(["status", "--porcelain"]).stdout.trim()) throw new Error("The working tree must be clean before bstack rewrites or
|
|
157
|
+
if (this.git(["status", "--porcelain"]).stdout.trim()) throw new Error("The working tree must be clean before bstack rewrites commits or pushes branches");
|
|
156
158
|
}
|
|
157
159
|
currentBranch() {
|
|
158
160
|
return this.git([
|
|
@@ -232,7 +234,7 @@ var GitRepository = class {
|
|
|
232
234
|
]).stdout));
|
|
233
235
|
}
|
|
234
236
|
ensureChangeIds(commits, dryRun, userLogin) {
|
|
235
|
-
const assigned = commits.map((commit) => commit.changeId ??
|
|
237
|
+
const assigned = commits.map((commit) => commit.changeId ?? generateChangeId());
|
|
236
238
|
const needsRewrite = commits.some((commit) => commit.changeId === void 0);
|
|
237
239
|
let parent = commits[0]?.parent;
|
|
238
240
|
const rewrittenOids = [];
|
|
@@ -327,7 +329,7 @@ const pullRequestSchema = z.object({
|
|
|
327
329
|
isDraft: z.boolean()
|
|
328
330
|
});
|
|
329
331
|
const stackSchema = z.object({ number: z.number() });
|
|
330
|
-
var
|
|
332
|
+
var GitHubCliPlatform = class {
|
|
331
333
|
cwd;
|
|
332
334
|
runner;
|
|
333
335
|
constructor(cwd, runner) {
|
|
@@ -588,19 +590,19 @@ function syncStack(repository, github, options) {
|
|
|
588
590
|
const store = new StateStore(repository.statePath());
|
|
589
591
|
const state = store.read();
|
|
590
592
|
const previous = store.findByChangeIds(state, new Set(changes.map((change) => change.id)));
|
|
591
|
-
const
|
|
592
|
-
reporter.progress(`
|
|
593
|
+
const transition = analyzeStackTransition(previous, changes, github, repository.currentBranch() === "");
|
|
594
|
+
reporter.progress(`Pushing ${changes.length} remote branch${changes.length === 1 ? "" : "es"}`);
|
|
593
595
|
repository.pushChanges(remote, changes);
|
|
594
596
|
reporter.progress("Looking up existing pull requests");
|
|
595
597
|
const existing = changes.map((change) => github.pullRequestForBranch(change.remoteBranch));
|
|
596
598
|
let pullRequests;
|
|
597
599
|
if (changes.length === 1) {
|
|
598
|
-
if (
|
|
600
|
+
if (transition.kind === "partial") reporter.progress("Updating this down-stack prefix while preserving higher pull requests");
|
|
599
601
|
reporter.progress(existing[0] ? "Using the existing pull request" : "Creating a pull request");
|
|
600
602
|
const pullRequest = existing[0] ?? github.createPullRequest(changes[0], base, options.draft);
|
|
601
|
-
if (
|
|
602
|
-
reporter.progress(`Removing omitted pull requests from stack #${
|
|
603
|
-
github.unstack(
|
|
603
|
+
if (transition.kind === "collapse") {
|
|
604
|
+
reporter.progress(`Removing omitted pull requests from stack #${transition.stackNumber}`);
|
|
605
|
+
github.unstack(transition.stackNumber);
|
|
604
606
|
try {
|
|
605
607
|
github.editPullRequestBase(pullRequest, base);
|
|
606
608
|
} catch (error) {
|
|
@@ -609,21 +611,21 @@ function syncStack(repository, github, options) {
|
|
|
609
611
|
}
|
|
610
612
|
pullRequests = [pullRequest];
|
|
611
613
|
} else {
|
|
612
|
-
if (
|
|
614
|
+
if (transition.kind === "full") {
|
|
613
615
|
reporter.progress(`Linking ${changes.length} pull requests as a native GitHub stack`);
|
|
614
616
|
github.linkStack(changes.map((change) => change.remoteBranch), base, remote, options.draft);
|
|
615
|
-
} else if (
|
|
616
|
-
reporter.progress(`Rebuilding stack #${
|
|
617
|
-
github.unstack(
|
|
617
|
+
} else if (transition.kind === "rebuild") {
|
|
618
|
+
reporter.progress(`Rebuilding stack #${transition.stackNumber} to ${transition.action} pull requests`);
|
|
619
|
+
github.unstack(transition.stackNumber);
|
|
618
620
|
try {
|
|
619
621
|
github.linkStack(changes.map((change) => change.remoteBranch), base, remote, options.draft);
|
|
620
622
|
} catch (error) {
|
|
621
623
|
restorePreviousStack(github, previous, base, remote, reporter, error);
|
|
622
624
|
}
|
|
623
|
-
} else if (
|
|
624
|
-
reporter.progress(`Appending ${
|
|
625
|
-
github.appendToStack(
|
|
626
|
-
} else if (
|
|
625
|
+
} else if (transition.kind === "append") {
|
|
626
|
+
reporter.progress(`Appending ${transition.branches.length} pull request${transition.branches.length === 1 ? "" : "s"} to stack #${transition.stackNumber}`);
|
|
627
|
+
github.appendToStack(transition.stackNumber, transition.branches, remote, options.draft);
|
|
628
|
+
} else if (transition.kind === "partial") reporter.progress("Updating this down-stack prefix while preserving higher pull requests");
|
|
627
629
|
else reporter.progress("The native GitHub stack already has the correct members");
|
|
628
630
|
pullRequests = changes.map((change, index) => {
|
|
629
631
|
const pr = existing[index] ?? github.pullRequestForBranch(change.remoteBranch);
|
|
@@ -636,20 +638,20 @@ function syncStack(repository, github, options) {
|
|
|
636
638
|
github.editPullRequest(pr, changes[index]);
|
|
637
639
|
reporter.progress(`PR #${pr.number}: ${changes[index].subject}`);
|
|
638
640
|
}
|
|
639
|
-
const stackNumber =
|
|
641
|
+
const stackNumber = transition.kind === "rebuild" ? github.stackNumberForPullRequest(pullRequests[0].number) : transition.kind === "collapse" ? void 0 : previous?.stackNumber ?? (pullRequests.length > 1 ? github.stackNumberForPullRequest(pullRequests[0].number) : void 0);
|
|
640
642
|
const synchronizedChanges = changes.map((change, index) => ({
|
|
641
643
|
id: change.id,
|
|
642
644
|
remoteBranch: change.remoteBranch,
|
|
643
645
|
pullRequest: pullRequests[index].number,
|
|
644
646
|
url: pullRequests[index].url
|
|
645
647
|
}));
|
|
646
|
-
const
|
|
648
|
+
const updatedStack = {
|
|
647
649
|
remote,
|
|
648
650
|
base,
|
|
649
|
-
changes:
|
|
651
|
+
changes: transition.kind === "partial" && previous ? [...synchronizedChanges, ...previous.changes.slice(transition.previousOffset + changes.length)] : synchronizedChanges
|
|
650
652
|
};
|
|
651
|
-
if (stackNumber !== void 0)
|
|
652
|
-
writeUpdatedState(store, state, previous,
|
|
653
|
+
if (stackNumber !== void 0) updatedStack.stackNumber = stackNumber;
|
|
654
|
+
writeUpdatedState(store, state, previous, updatedStack);
|
|
653
655
|
reporter.progress("Saved the local stack state");
|
|
654
656
|
return {
|
|
655
657
|
base,
|
|
@@ -661,13 +663,13 @@ function syncStack(repository, github, options) {
|
|
|
661
663
|
}))
|
|
662
664
|
};
|
|
663
665
|
}
|
|
664
|
-
function
|
|
666
|
+
function analyzeStackTransition(previous, changes, github, preserveHigherChanges) {
|
|
665
667
|
if (!previous) return { kind: "full" };
|
|
666
668
|
const previousIds = previous.changes.map((change) => change.id);
|
|
667
669
|
const currentIds = changes.map((change) => change.id);
|
|
668
670
|
const previousIdSet = new Set(previousIds);
|
|
669
671
|
const currentIdSet = new Set(currentIds);
|
|
670
|
-
if (!
|
|
672
|
+
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");
|
|
671
673
|
const removed = previous.changes.filter((change) => !currentIdSet.has(change.id));
|
|
672
674
|
const added = changes.filter((change) => !previousIdSet.has(change.id));
|
|
673
675
|
if (removed.length === 0) {
|
|
@@ -711,7 +713,7 @@ function analyzeEvolution(previous, changes, github, preserveHigherChanges) {
|
|
|
711
713
|
action: added.length === 0 ? "remove" : "update"
|
|
712
714
|
};
|
|
713
715
|
}
|
|
714
|
-
function
|
|
716
|
+
function sameSequence(left, right) {
|
|
715
717
|
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
716
718
|
}
|
|
717
719
|
function restorePreviousStack(github, previous, base, remote, reporter, rebuildError) {
|
|
@@ -734,14 +736,14 @@ function writeUpdatedState(store, state, previous, updated) {
|
|
|
734
736
|
}
|
|
735
737
|
//#endregion
|
|
736
738
|
//#region src/cli.ts
|
|
737
|
-
const help = `bstack - turn a linear commit series into native GitHub
|
|
739
|
+
const help = `bstack - turn a linear commit series into a native GitHub stack of PRs
|
|
738
740
|
|
|
739
741
|
Usage:
|
|
740
742
|
bstack [sync] [options]
|
|
741
743
|
bstack checkout <PR-number-or-URL> [options]
|
|
742
744
|
|
|
743
745
|
Options:
|
|
744
|
-
--base <branch> Stack
|
|
746
|
+
--base <branch> Stack base; defaults to the GitHub default branch
|
|
745
747
|
--remote <name> Git remote; defaults to remote.pushDefault or origin
|
|
746
748
|
--draft Create draft PRs instead of ready-for-review PRs
|
|
747
749
|
--dry-run Inspect the stack without rewriting commits or pushing
|
|
@@ -796,7 +798,7 @@ function main() {
|
|
|
796
798
|
const runner = new NodeCommandRunner();
|
|
797
799
|
const cwd = process.cwd();
|
|
798
800
|
const repository = new GitRepository(cwd, runner);
|
|
799
|
-
const github = new
|
|
801
|
+
const github = new GitHubCliPlatform(cwd, runner);
|
|
800
802
|
const reporter = new ConsoleReporter(!values.quiet);
|
|
801
803
|
const command = positionals[0] ?? "sync";
|
|
802
804
|
if (command === "checkout") {
|
|
@@ -820,7 +822,7 @@ function main() {
|
|
|
820
822
|
dryRun: values["dry-run"],
|
|
821
823
|
reporter
|
|
822
824
|
});
|
|
823
|
-
console.log(`${values["dry-run"] ? "Would
|
|
825
|
+
console.log(`${values["dry-run"] ? "Would sync" : "Synced"} ${result.changes.length} change${result.changes.length === 1 ? "" : "s"} against ${result.base}:`);
|
|
824
826
|
for (const change of result.changes) {
|
|
825
827
|
const destination = change.pullRequest ? ` ${change.pullRequest.url}` : "";
|
|
826
828
|
console.log(` ${change.oid.slice(0, 8)} ${change.subject}${destination}`);
|