bstack 1.0.4 → 1.1.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/LICENSE +22 -0
- package/README.md +58 -7
- package/dist/bstack.js +243 -184
- package/package.json +13 -12
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alex Sehl
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN THE AUTHOR/HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -8,36 +8,87 @@ Convert a series of commits in a local branch into a native GitHub stack of pull
|
|
|
8
8
|
npm install -g bstack
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
Install and authenticate the GitHub CLI with the
|
|
11
|
+
Install and authenticate the [GitHub CLI](https://cli.github.com/) with the [stack](https://github.com/github/gh-stack) extension:
|
|
12
12
|
|
|
13
13
|
```
|
|
14
14
|
gh auth login
|
|
15
15
|
gh extension install github/gh-stack
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
##
|
|
18
|
+
## How to use
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
Write and edit commits locally. `bstack` handles the GitHub ops for you:
|
|
21
|
+
|
|
22
|
+
You
|
|
23
|
+
|
|
24
|
+
- Do not push your local feature branch.
|
|
25
|
+
- Do not open pull requests manually.
|
|
26
|
+
- Run `bstack` when your commits are ready. It pushes dedicated remote branches
|
|
27
|
+
and creates one pull request for each commit.
|
|
28
|
+
|
|
29
|
+
### Start a stack
|
|
30
|
+
|
|
31
|
+
Create a local branch from `main`, then make one commit per reviewable change:
|
|
21
32
|
|
|
22
33
|
```bash
|
|
34
|
+
git switch main
|
|
23
35
|
git switch -c my-feature
|
|
24
36
|
git commit -am "feat: add the model"
|
|
25
37
|
git commit -am "feat: add the API"
|
|
26
38
|
bstack
|
|
27
39
|
```
|
|
28
40
|
|
|
29
|
-
|
|
41
|
+
That is the whole publishing flow. Keep working on the same local branch and run `bstack` again whenever the stack changes.
|
|
30
42
|
|
|
31
|
-
|
|
43
|
+
### Add another pull request
|
|
32
44
|
|
|
33
|
-
|
|
45
|
+
Add another commit on top of the stack, then run `bstack`:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
git commit -am "feat: add validation"
|
|
49
|
+
bstack
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`bstack` keeps the existing pull requests and adds one for the new commit.
|
|
53
|
+
|
|
54
|
+
### Modify a pull request
|
|
55
|
+
|
|
56
|
+
Edit the corresponding commit, then run `bstack` again. For the latest commit:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
git commit --amend
|
|
60
|
+
bstack
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
For an older commit, use interactive rebase, mark that commit for editing, make
|
|
64
|
+
your changes, and continue the rebase:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
git rebase -i main
|
|
68
|
+
git commit --amend
|
|
69
|
+
git rebase --continue
|
|
70
|
+
bstack
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Stacks cannot contain merge commits.
|
|
74
|
+
When `main` moves, rebase your branch onto it instead of merging `main` into your branch.
|
|
75
|
+
|
|
76
|
+
### Checkout an existing stack
|
|
77
|
+
|
|
78
|
+
Use any pull request in the stack:
|
|
34
79
|
|
|
35
80
|
```bash
|
|
36
81
|
bstack checkout 123
|
|
37
82
|
bstack checkout https://github.com/owner/repo/pull/123
|
|
38
83
|
```
|
|
39
84
|
|
|
40
|
-
|
|
85
|
+
### Options
|
|
86
|
+
|
|
87
|
+
New pull requests are ready for review by default. Pass `--draft` to create
|
|
88
|
+
drafts instead.
|
|
89
|
+
|
|
90
|
+
Use `--dry-run` to inspect without syncing. Pass `--verbose` to print every
|
|
91
|
+
command before it runs.
|
|
41
92
|
|
|
42
93
|
## References
|
|
43
94
|
|
package/dist/bstack.js
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
import { parseArgs } from "node:util";
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
|
-
import
|
|
5
|
+
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.1.1";
|
|
10
10
|
//#endregion
|
|
11
11
|
//#region src/command.ts
|
|
12
12
|
var CommandError = class extends Error {
|
|
@@ -20,9 +20,14 @@ var CommandError = class extends Error {
|
|
|
20
20
|
}
|
|
21
21
|
};
|
|
22
22
|
var NodeCommandRunner = class {
|
|
23
|
+
logger;
|
|
24
|
+
constructor(logger) {
|
|
25
|
+
this.logger = logger;
|
|
26
|
+
}
|
|
23
27
|
run(command, options) {
|
|
24
28
|
const [executable, ...args] = command;
|
|
25
29
|
if (!executable) throw new Error("Cannot run an empty command");
|
|
30
|
+
this.logger?.(command);
|
|
26
31
|
const result = spawnSync(executable, args, {
|
|
27
32
|
cwd: options.cwd,
|
|
28
33
|
input: options.stdin,
|
|
@@ -41,10 +46,17 @@ var NodeCommandRunner = class {
|
|
|
41
46
|
return commandResult;
|
|
42
47
|
}
|
|
43
48
|
};
|
|
49
|
+
function formatCommand(command) {
|
|
50
|
+
return command.map(formatArgument).join(" ");
|
|
51
|
+
}
|
|
52
|
+
function formatArgument(argument) {
|
|
53
|
+
if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(argument)) return argument;
|
|
54
|
+
return `'${argument.replaceAll("'", `'"'"'`)}'`;
|
|
55
|
+
}
|
|
44
56
|
//#endregion
|
|
45
57
|
//#region src/checkout.ts
|
|
46
|
-
function checkoutStack(
|
|
47
|
-
const { reporter } =
|
|
58
|
+
function checkoutStack(dependencies, options) {
|
|
59
|
+
const { repository, github, reporter } = dependencies;
|
|
48
60
|
reporter.progress("Checking the repository and GitHub prerequisites");
|
|
49
61
|
repository.assertReady();
|
|
50
62
|
github.assertReady();
|
|
@@ -139,7 +151,7 @@ function splitCommitMessage(message) {
|
|
|
139
151
|
}
|
|
140
152
|
//#endregion
|
|
141
153
|
//#region src/git.ts
|
|
142
|
-
var
|
|
154
|
+
var GitCliRepository = class {
|
|
143
155
|
cwd;
|
|
144
156
|
runner;
|
|
145
157
|
constructor(cwd, runner) {
|
|
@@ -164,17 +176,6 @@ var GitRepository = class {
|
|
|
164
176
|
"HEAD"
|
|
165
177
|
], { allowFailure: true }).stdout.trim();
|
|
166
178
|
}
|
|
167
|
-
remotes() {
|
|
168
|
-
return this.git(["remote"]).stdout.split("\n").filter(Boolean);
|
|
169
|
-
}
|
|
170
|
-
configuredPushRemote() {
|
|
171
|
-
const result = this.git([
|
|
172
|
-
"config",
|
|
173
|
-
"--get",
|
|
174
|
-
"remote.pushDefault"
|
|
175
|
-
], { allowFailure: true });
|
|
176
|
-
return result.exitCode === 0 ? result.stdout.trim() || void 0 : void 0;
|
|
177
|
-
}
|
|
178
179
|
resolveRemote(requested) {
|
|
179
180
|
if (requested) {
|
|
180
181
|
if (!this.remotes().includes(requested)) throw new Error(`Git remote ${requested} does not exist`);
|
|
@@ -233,69 +234,40 @@ var GitRepository = class {
|
|
|
233
234
|
oid
|
|
234
235
|
]).stdout));
|
|
235
236
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
let parent = commits[0]?.parent;
|
|
237
|
+
rewriteCommits(rewrites) {
|
|
238
|
+
if (rewrites.length === 0) return [];
|
|
239
|
+
let parent = rewrites[0].commit.parent;
|
|
240
240
|
const rewrittenOids = [];
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
], { stdin: raw }).stdout.trim();
|
|
253
|
-
rewrittenOids.push(oid);
|
|
254
|
-
parent = oid;
|
|
255
|
-
}
|
|
256
|
-
const oldHead = commits.at(-1).oid;
|
|
257
|
-
const newHead = rewrittenOids.at(-1);
|
|
258
|
-
this.git([
|
|
259
|
-
"update-ref",
|
|
260
|
-
"HEAD",
|
|
261
|
-
newHead,
|
|
262
|
-
oldHead
|
|
263
|
-
]);
|
|
241
|
+
for (const rewrite of rewrites) {
|
|
242
|
+
const raw = rewriteCommit(rewrite.commit, parent, rewrite.message);
|
|
243
|
+
const oid = this.git([
|
|
244
|
+
"hash-object",
|
|
245
|
+
"-t",
|
|
246
|
+
"commit",
|
|
247
|
+
"-w",
|
|
248
|
+
"--stdin"
|
|
249
|
+
], { stdin: raw }).stdout.trim();
|
|
250
|
+
rewrittenOids.push(oid);
|
|
251
|
+
parent = oid;
|
|
264
252
|
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
subject,
|
|
273
|
-
body,
|
|
274
|
-
remoteBranch: `bstack/${userLogin}/${id}`
|
|
275
|
-
};
|
|
276
|
-
});
|
|
277
|
-
}
|
|
278
|
-
remoteBranchOids(remote, branches) {
|
|
279
|
-
if (branches.length === 0) return /* @__PURE__ */ new Map();
|
|
280
|
-
const result = this.git([
|
|
281
|
-
"ls-remote",
|
|
282
|
-
"--heads",
|
|
283
|
-
remote,
|
|
284
|
-
...branches.map((branch) => `refs/heads/${branch}`)
|
|
253
|
+
const oldHead = rewrites.at(-1).commit.oid;
|
|
254
|
+
const newHead = rewrittenOids.at(-1);
|
|
255
|
+
this.git([
|
|
256
|
+
"update-ref",
|
|
257
|
+
"HEAD",
|
|
258
|
+
newHead,
|
|
259
|
+
oldHead
|
|
285
260
|
]);
|
|
286
|
-
return
|
|
287
|
-
const [oid, ref] = line.split(/\s+/, 2);
|
|
288
|
-
return [ref.replace("refs/heads/", ""), oid];
|
|
289
|
-
}));
|
|
261
|
+
return rewrittenOids;
|
|
290
262
|
}
|
|
291
|
-
|
|
292
|
-
const existing = this.remoteBranchOids(remote,
|
|
263
|
+
pushBranches(remote, branches) {
|
|
264
|
+
const existing = this.remoteBranchOids(remote, branches.map((branch) => branch.name));
|
|
293
265
|
const leases = [];
|
|
294
266
|
const refspecs = [];
|
|
295
|
-
for (const
|
|
296
|
-
const expected = existing.get(
|
|
297
|
-
leases.push(`--force-with-lease=refs/heads/${
|
|
298
|
-
refspecs.push(`${
|
|
267
|
+
for (const branch of branches) {
|
|
268
|
+
const expected = existing.get(branch.name) ?? "";
|
|
269
|
+
leases.push(`--force-with-lease=refs/heads/${branch.name}:${expected}`);
|
|
270
|
+
refspecs.push(`${branch.oid}:refs/heads/${branch.name}`);
|
|
299
271
|
}
|
|
300
272
|
this.git([
|
|
301
273
|
"push",
|
|
@@ -313,22 +285,46 @@ var GitRepository = class {
|
|
|
313
285
|
"bstack/state.json"
|
|
314
286
|
]).stdout.trim();
|
|
315
287
|
}
|
|
288
|
+
remotes() {
|
|
289
|
+
return this.git(["remote"]).stdout.split("\n").filter(Boolean);
|
|
290
|
+
}
|
|
291
|
+
configuredPushRemote() {
|
|
292
|
+
const result = this.git([
|
|
293
|
+
"config",
|
|
294
|
+
"--get",
|
|
295
|
+
"remote.pushDefault"
|
|
296
|
+
], { allowFailure: true });
|
|
297
|
+
return result.exitCode === 0 ? result.stdout.trim() || void 0 : void 0;
|
|
298
|
+
}
|
|
299
|
+
remoteBranchOids(remote, branches) {
|
|
300
|
+
if (branches.length === 0) return /* @__PURE__ */ new Map();
|
|
301
|
+
const result = this.git([
|
|
302
|
+
"ls-remote",
|
|
303
|
+
"--heads",
|
|
304
|
+
remote,
|
|
305
|
+
...branches.map((branch) => `refs/heads/${branch}`)
|
|
306
|
+
]);
|
|
307
|
+
return new Map(result.stdout.split("\n").filter(Boolean).map((line) => {
|
|
308
|
+
const [oid, ref] = line.split(/\s+/, 2);
|
|
309
|
+
return [ref.replace("refs/heads/", ""), oid];
|
|
310
|
+
}));
|
|
311
|
+
}
|
|
316
312
|
};
|
|
317
313
|
//#endregion
|
|
318
314
|
//#region src/github.ts
|
|
319
|
-
const pullRequestSchema =
|
|
320
|
-
number:
|
|
321
|
-
url:
|
|
322
|
-
state:
|
|
315
|
+
const pullRequestSchema = v.object({
|
|
316
|
+
number: v.number(),
|
|
317
|
+
url: v.string(),
|
|
318
|
+
state: v.picklist([
|
|
323
319
|
"OPEN",
|
|
324
320
|
"CLOSED",
|
|
325
321
|
"MERGED"
|
|
326
322
|
]),
|
|
327
|
-
title:
|
|
328
|
-
body:
|
|
329
|
-
isDraft:
|
|
323
|
+
title: v.string(),
|
|
324
|
+
body: v.string(),
|
|
325
|
+
isDraft: v.boolean()
|
|
330
326
|
});
|
|
331
|
-
const stackSchema =
|
|
327
|
+
const stackSchema = v.object({ number: v.number() });
|
|
332
328
|
var GitHubCliPlatform = class {
|
|
333
329
|
cwd;
|
|
334
330
|
runner;
|
|
@@ -348,12 +344,13 @@ var GitHubCliPlatform = class {
|
|
|
348
344
|
this.gh(["stack", "--version"]);
|
|
349
345
|
}
|
|
350
346
|
currentUserLogin() {
|
|
351
|
-
|
|
347
|
+
const login = this.gh([
|
|
352
348
|
"api",
|
|
353
349
|
"user",
|
|
354
350
|
"--jq",
|
|
355
351
|
".login"
|
|
356
|
-
]).stdout.trim()
|
|
352
|
+
]).stdout.trim();
|
|
353
|
+
return v.parse(v.pipe(v.string(), v.minLength(1)), login);
|
|
357
354
|
}
|
|
358
355
|
defaultBranch() {
|
|
359
356
|
return this.gh([
|
|
@@ -378,7 +375,7 @@ var GitHubCliPlatform = class {
|
|
|
378
375
|
"--json",
|
|
379
376
|
"number,url,state,title,body,isDraft"
|
|
380
377
|
]).stdout;
|
|
381
|
-
const candidates =
|
|
378
|
+
const candidates = v.parse(v.array(pullRequestSchema), JSON.parse(raw));
|
|
382
379
|
return candidates.find((pr) => pr.state === "OPEN") ?? candidates.find((pr) => pr.state === "MERGED");
|
|
383
380
|
}
|
|
384
381
|
pullRequest(number) {
|
|
@@ -389,7 +386,7 @@ var GitHubCliPlatform = class {
|
|
|
389
386
|
"--json",
|
|
390
387
|
"number,url,state,title,body,isDraft"
|
|
391
388
|
]).stdout;
|
|
392
|
-
return
|
|
389
|
+
return v.parse(pullRequestSchema, JSON.parse(raw));
|
|
393
390
|
}
|
|
394
391
|
createPullRequest(change, base, draft) {
|
|
395
392
|
const args = [
|
|
@@ -464,7 +461,7 @@ var GitHubCliPlatform = class {
|
|
|
464
461
|
}
|
|
465
462
|
stackNumberForPullRequest(prNumber) {
|
|
466
463
|
const raw = this.gh(["api", `repos/{owner}/{repo}/stacks?pull_request=${prNumber}`]).stdout;
|
|
467
|
-
return
|
|
464
|
+
return v.parse(v.array(stackSchema), JSON.parse(raw))[0]?.number;
|
|
468
465
|
}
|
|
469
466
|
pullRequestHead(reference) {
|
|
470
467
|
return this.gh([
|
|
@@ -488,12 +485,11 @@ var GitHubCliPlatform = class {
|
|
|
488
485
|
//#endregion
|
|
489
486
|
//#region src/reporter.ts
|
|
490
487
|
var ConsoleReporter = class {
|
|
491
|
-
enabled;
|
|
492
|
-
constructor(enabled = true) {
|
|
493
|
-
this.enabled = enabled;
|
|
494
|
-
}
|
|
495
488
|
progress(message) {
|
|
496
|
-
|
|
489
|
+
process.stderr.write(`[bstack] ${message}\n`);
|
|
490
|
+
}
|
|
491
|
+
command(command) {
|
|
492
|
+
this.progress(`$ ${formatCommand(command)}`);
|
|
497
493
|
}
|
|
498
494
|
};
|
|
499
495
|
//#endregion
|
|
@@ -502,23 +498,23 @@ const emptyState = () => ({
|
|
|
502
498
|
schemaVersion: 1,
|
|
503
499
|
stacks: []
|
|
504
500
|
});
|
|
505
|
-
const storedChangeSchema =
|
|
506
|
-
id:
|
|
507
|
-
remoteBranch:
|
|
508
|
-
pullRequest:
|
|
509
|
-
url:
|
|
501
|
+
const storedChangeSchema = v.object({
|
|
502
|
+
id: v.string(),
|
|
503
|
+
remoteBranch: v.string(),
|
|
504
|
+
pullRequest: v.number(),
|
|
505
|
+
url: v.string()
|
|
510
506
|
});
|
|
511
|
-
const storedStackSchema =
|
|
512
|
-
remote:
|
|
513
|
-
base:
|
|
514
|
-
stackNumber:
|
|
515
|
-
changes:
|
|
507
|
+
const storedStackSchema = v.object({
|
|
508
|
+
remote: v.string(),
|
|
509
|
+
base: v.string(),
|
|
510
|
+
stackNumber: v.optional(v.number()),
|
|
511
|
+
changes: v.array(storedChangeSchema)
|
|
516
512
|
});
|
|
517
|
-
const stateSchema =
|
|
518
|
-
schemaVersion:
|
|
519
|
-
stacks:
|
|
513
|
+
const stateSchema = v.object({
|
|
514
|
+
schemaVersion: v.literal(1),
|
|
515
|
+
stacks: v.array(storedStackSchema)
|
|
520
516
|
});
|
|
521
|
-
var
|
|
517
|
+
var FileStateStore = class {
|
|
522
518
|
path;
|
|
523
519
|
constructor(path) {
|
|
524
520
|
this.path = path;
|
|
@@ -527,7 +523,7 @@ var StateStore = class {
|
|
|
527
523
|
try {
|
|
528
524
|
return {
|
|
529
525
|
schemaVersion: 1,
|
|
530
|
-
stacks:
|
|
526
|
+
stacks: v.parse(stateSchema, JSON.parse(readFileSync(this.path, "utf8"))).stacks.map((stack) => {
|
|
531
527
|
const stored = {
|
|
532
528
|
remote: stack.remote,
|
|
533
529
|
base: stack.base,
|
|
@@ -539,7 +535,7 @@ var StateStore = class {
|
|
|
539
535
|
};
|
|
540
536
|
} catch (error) {
|
|
541
537
|
if (error instanceof Error && "code" in error && error.code === "ENOENT") return emptyState();
|
|
542
|
-
if (error
|
|
538
|
+
if (v.isValiError(error)) throw new Error(`Unsupported bstack state in ${this.path}`, { cause: error });
|
|
543
539
|
throw error;
|
|
544
540
|
}
|
|
545
541
|
}
|
|
@@ -549,16 +545,116 @@ var StateStore = class {
|
|
|
549
545
|
writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`);
|
|
550
546
|
renameSync(temporary, this.path);
|
|
551
547
|
}
|
|
552
|
-
|
|
548
|
+
};
|
|
549
|
+
//#endregion
|
|
550
|
+
//#region src/stack.ts
|
|
551
|
+
var Stack = class Stack {
|
|
552
|
+
changes;
|
|
553
|
+
rewritten;
|
|
554
|
+
rewrites;
|
|
555
|
+
constructor(changes, rewritten = false, rewrites = []) {
|
|
556
|
+
this.changes = changes;
|
|
557
|
+
this.rewritten = rewritten;
|
|
558
|
+
this.rewrites = rewrites;
|
|
559
|
+
if (changes.length === 0) throw new Error("A stack must contain at least one change");
|
|
560
|
+
}
|
|
561
|
+
static fromChanges(changes) {
|
|
562
|
+
return new Stack(changes);
|
|
563
|
+
}
|
|
564
|
+
static fromCommits(commits, userLogin) {
|
|
565
|
+
const ids = commits.map((commit) => commit.changeId ?? generateChangeId());
|
|
566
|
+
const rewritten = commits.some((commit) => commit.changeId === void 0);
|
|
567
|
+
const rewrites = rewritten ? commits.map((commit, index) => ({
|
|
568
|
+
commit,
|
|
569
|
+
message: commit.changeId ? commit.message : addChangeId(commit.message, ids[index])
|
|
570
|
+
})) : [];
|
|
571
|
+
const changes = commits.map((commit, index) => {
|
|
572
|
+
const id = ids[index];
|
|
573
|
+
const { subject, body } = splitCommitMessage(commit.message);
|
|
574
|
+
return {
|
|
575
|
+
id,
|
|
576
|
+
oid: commit.oid,
|
|
577
|
+
subject,
|
|
578
|
+
body,
|
|
579
|
+
remoteBranch: `bstack/${userLogin}/${id}`
|
|
580
|
+
};
|
|
581
|
+
});
|
|
582
|
+
return new Stack(changes, rewritten, rewrites);
|
|
583
|
+
}
|
|
584
|
+
writeChangeIds(repository) {
|
|
585
|
+
if (!this.rewritten) return this;
|
|
586
|
+
const rewrittenOids = repository.rewriteCommits(this.rewrites);
|
|
587
|
+
if (rewrittenOids.length !== this.changes.length) throw new Error(`Git rewrote ${rewrittenOids.length} commits for a stack with ${this.changes.length} changes`);
|
|
588
|
+
const changes = this.changes.map((change, index) => ({
|
|
589
|
+
...change,
|
|
590
|
+
oid: rewrittenOids[index]
|
|
591
|
+
}));
|
|
592
|
+
return new Stack(changes, true);
|
|
593
|
+
}
|
|
594
|
+
findPrevious(state) {
|
|
595
|
+
const ids = new Set(this.changes.map((change) => change.id));
|
|
553
596
|
const matches = state.stacks.filter((stack) => stack.changes.some((change) => ids.has(change.id)));
|
|
554
597
|
if (matches.length > 1) throw new Error("The current commits match more than one stored bstack stack");
|
|
555
598
|
return matches[0];
|
|
556
599
|
}
|
|
600
|
+
transitionFrom(previous, options) {
|
|
601
|
+
if (!previous) return { kind: "full" };
|
|
602
|
+
const previousIds = previous.changes.map((change) => change.id);
|
|
603
|
+
const currentIds = this.changes.map((change) => change.id);
|
|
604
|
+
const previousIdSet = new Set(previousIds);
|
|
605
|
+
const currentIdSet = new Set(currentIds);
|
|
606
|
+
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");
|
|
607
|
+
const removed = previous.changes.filter((change) => !currentIdSet.has(change.id));
|
|
608
|
+
const added = this.changes.filter((change) => !previousIdSet.has(change.id));
|
|
609
|
+
if (removed.length === 0) {
|
|
610
|
+
if (previousIds.every((id, index) => currentIds[index] === id)) return { kind: "full" };
|
|
611
|
+
const stackNumber = previous.stackNumber ?? options.lookups.stackNumberForPullRequest(previous.changes[0].pullRequest);
|
|
612
|
+
return stackNumber === void 0 ? { kind: "full" } : {
|
|
613
|
+
kind: "rebuild",
|
|
614
|
+
stackNumber,
|
|
615
|
+
action: "insert"
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
const firstCurrentIndex = previousIds.indexOf(currentIds[0]);
|
|
619
|
+
const isPreviousSlice = firstCurrentIndex >= 0 && currentIds.every((id, index) => previousIds[firstCurrentIndex + index] === id);
|
|
620
|
+
if (options.preserveHigherChanges && added.length === 0 && isPreviousSlice && firstCurrentIndex + currentIds.length < previousIds.length) {
|
|
621
|
+
if (previous.changes.slice(0, firstCurrentIndex).every((change) => options.lookups.pullRequestState(change.pullRequest) === "MERGED")) return {
|
|
622
|
+
kind: "partial",
|
|
623
|
+
previousOffset: firstCurrentIndex
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
const removedPrefixWasMerged = removed.every((change, index) => previous.changes[index] === change) && removed.every((change) => options.lookups.pullRequestState(change.pullRequest) === "MERGED");
|
|
627
|
+
const survivingIds = previousIds.slice(removed.length);
|
|
628
|
+
const onlyAppendedAfterMergedPrefix = removedPrefixWasMerged && survivingIds.every((id, index) => currentIds[index] === id) && currentIds.slice(survivingIds.length).every((id) => !previousIdSet.has(id));
|
|
629
|
+
if (removedPrefixWasMerged && added.length === 0) return { kind: "skip" };
|
|
630
|
+
if (onlyAppendedAfterMergedPrefix) {
|
|
631
|
+
if (previous.stackNumber === void 0) throw new Error("Cannot append after a merge because the native GitHub stack number is missing from local state");
|
|
632
|
+
return {
|
|
633
|
+
kind: "append",
|
|
634
|
+
stackNumber: previous.stackNumber,
|
|
635
|
+
branches: added.map((change) => change.remoteBranch)
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
const stackNumber = previous.stackNumber ?? options.lookups.stackNumberForPullRequest(previous.changes[0].pullRequest);
|
|
639
|
+
if (stackNumber === void 0) throw new Error("Cannot remove submitted commits because the native GitHub stack number is missing from local state");
|
|
640
|
+
if (this.changes.length === 1) return {
|
|
641
|
+
kind: "collapse",
|
|
642
|
+
stackNumber
|
|
643
|
+
};
|
|
644
|
+
return {
|
|
645
|
+
kind: "rebuild",
|
|
646
|
+
stackNumber,
|
|
647
|
+
action: added.length === 0 ? "remove" : "update"
|
|
648
|
+
};
|
|
649
|
+
}
|
|
557
650
|
};
|
|
651
|
+
function sameSequence(left, right) {
|
|
652
|
+
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
653
|
+
}
|
|
558
654
|
//#endregion
|
|
559
655
|
//#region src/sync.ts
|
|
560
|
-
function syncStack(
|
|
561
|
-
const { reporter } =
|
|
656
|
+
function syncStack(dependencies, options) {
|
|
657
|
+
const { repository, github, stateStore, reporter } = dependencies;
|
|
562
658
|
reporter.progress("Checking the repository and GitHub prerequisites");
|
|
563
659
|
repository.assertReady();
|
|
564
660
|
github.assertReady();
|
|
@@ -573,26 +669,35 @@ function syncStack(repository, github, options) {
|
|
|
573
669
|
const commits = repository.commitsSince(baseOid);
|
|
574
670
|
if (commits.length === 0) throw new Error(`No commits found between ${base} and HEAD`);
|
|
575
671
|
reporter.progress(`Found ${commits.length} local change${commits.length === 1 ? "" : "s"}`);
|
|
576
|
-
const
|
|
577
|
-
if (rewritten) reporter.progress(options.dryRun ? "Stable change IDs would be added to the commits" : "Adding stable change IDs to the commits");
|
|
672
|
+
const pendingStack = Stack.fromCommits(commits, userLogin);
|
|
673
|
+
if (pendingStack.rewritten) reporter.progress(options.dryRun ? "Stable change IDs would be added to the commits" : "Adding stable change IDs to the commits");
|
|
578
674
|
else reporter.progress("All commits already have stable change IDs");
|
|
579
|
-
const
|
|
675
|
+
const stack = options.dryRun ? pendingStack : pendingStack.writeChangeIds(repository);
|
|
676
|
+
const { changes, rewritten } = stack;
|
|
580
677
|
if (options.dryRun) {
|
|
581
678
|
reporter.progress("Dry run complete; no commits or remote branches were changed");
|
|
582
679
|
return {
|
|
583
680
|
base,
|
|
584
681
|
remote,
|
|
585
682
|
rewritten,
|
|
586
|
-
changes
|
|
683
|
+
changes: [...changes]
|
|
587
684
|
};
|
|
588
685
|
}
|
|
589
686
|
reporter.progress("Reading the previous stack state");
|
|
590
|
-
const
|
|
591
|
-
const
|
|
592
|
-
const
|
|
593
|
-
|
|
687
|
+
const state = stateStore.read();
|
|
688
|
+
const previous = stack.findPrevious(state);
|
|
689
|
+
const transition = stack.transitionFrom(previous, {
|
|
690
|
+
preserveHigherChanges: repository.currentBranch() === "",
|
|
691
|
+
lookups: {
|
|
692
|
+
pullRequestState: (pullRequest) => github.pullRequest(pullRequest).state,
|
|
693
|
+
stackNumberForPullRequest: (pullRequest) => github.stackNumberForPullRequest(pullRequest)
|
|
694
|
+
}
|
|
695
|
+
});
|
|
594
696
|
reporter.progress(`Pushing ${changes.length} remote branch${changes.length === 1 ? "" : "es"}`);
|
|
595
|
-
repository.
|
|
697
|
+
repository.pushBranches(remote, changes.map((change) => ({
|
|
698
|
+
name: change.remoteBranch,
|
|
699
|
+
oid: change.oid
|
|
700
|
+
})));
|
|
596
701
|
reporter.progress("Looking up existing pull requests");
|
|
597
702
|
const existing = changes.map((change) => github.pullRequestForBranch(change.remoteBranch));
|
|
598
703
|
let pullRequests;
|
|
@@ -651,7 +756,7 @@ function syncStack(repository, github, options) {
|
|
|
651
756
|
changes: transition.kind === "partial" && previous ? [...synchronizedChanges, ...previous.changes.slice(transition.previousOffset + changes.length)] : synchronizedChanges
|
|
652
757
|
};
|
|
653
758
|
if (stackNumber !== void 0) updatedStack.stackNumber = stackNumber;
|
|
654
|
-
writeUpdatedState(
|
|
759
|
+
writeUpdatedState(stateStore, state, previous, updatedStack);
|
|
655
760
|
reporter.progress("Saved the local stack state");
|
|
656
761
|
return {
|
|
657
762
|
base,
|
|
@@ -663,59 +768,6 @@ function syncStack(repository, github, options) {
|
|
|
663
768
|
}))
|
|
664
769
|
};
|
|
665
770
|
}
|
|
666
|
-
function analyzeStackTransition(previous, changes, github, preserveHigherChanges) {
|
|
667
|
-
if (!previous) return { kind: "full" };
|
|
668
|
-
const previousIds = previous.changes.map((change) => change.id);
|
|
669
|
-
const currentIds = changes.map((change) => change.id);
|
|
670
|
-
const previousIdSet = new Set(previousIds);
|
|
671
|
-
const currentIdSet = new Set(currentIds);
|
|
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");
|
|
673
|
-
const removed = previous.changes.filter((change) => !currentIdSet.has(change.id));
|
|
674
|
-
const added = changes.filter((change) => !previousIdSet.has(change.id));
|
|
675
|
-
if (removed.length === 0) {
|
|
676
|
-
if (previousIds.every((id, index) => currentIds[index] === id)) return { kind: "full" };
|
|
677
|
-
const stackNumber = previous.stackNumber ?? github.stackNumberForPullRequest(previous.changes[0].pullRequest);
|
|
678
|
-
return stackNumber === void 0 ? { kind: "full" } : {
|
|
679
|
-
kind: "rebuild",
|
|
680
|
-
stackNumber,
|
|
681
|
-
action: "insert"
|
|
682
|
-
};
|
|
683
|
-
}
|
|
684
|
-
const firstCurrentIndex = previousIds.indexOf(currentIds[0]);
|
|
685
|
-
const isPreviousSlice = firstCurrentIndex >= 0 && currentIds.every((id, index) => previousIds[firstCurrentIndex + index] === id);
|
|
686
|
-
if (preserveHigherChanges && added.length === 0 && isPreviousSlice && firstCurrentIndex + currentIds.length < previousIds.length) {
|
|
687
|
-
if (previous.changes.slice(0, firstCurrentIndex).every((change) => github.pullRequest(change.pullRequest).state === "MERGED")) return {
|
|
688
|
-
kind: "partial",
|
|
689
|
-
previousOffset: firstCurrentIndex
|
|
690
|
-
};
|
|
691
|
-
}
|
|
692
|
-
const removedPrefixWasMerged = removed.every((change, index) => previous.changes[index] === change) && removed.every((change) => github.pullRequest(change.pullRequest).state === "MERGED");
|
|
693
|
-
const survivingIds = previousIds.slice(removed.length);
|
|
694
|
-
const onlyAppendedAfterMergedPrefix = removedPrefixWasMerged && survivingIds.every((id, index) => currentIds[index] === id) && currentIds.slice(survivingIds.length).every((id) => !previousIdSet.has(id));
|
|
695
|
-
if (removedPrefixWasMerged && added.length === 0) return { kind: "skip" };
|
|
696
|
-
if (onlyAppendedAfterMergedPrefix) {
|
|
697
|
-
if (previous.stackNumber === void 0) throw new Error("Cannot append after a merge because the native GitHub stack number is missing from local state");
|
|
698
|
-
return {
|
|
699
|
-
kind: "append",
|
|
700
|
-
stackNumber: previous.stackNumber,
|
|
701
|
-
branches: added.map((change) => change.remoteBranch)
|
|
702
|
-
};
|
|
703
|
-
}
|
|
704
|
-
const stackNumber = previous.stackNumber ?? github.stackNumberForPullRequest(previous.changes[0].pullRequest);
|
|
705
|
-
if (stackNumber === void 0) throw new Error("Cannot remove submitted commits because the native GitHub stack number is missing from local state");
|
|
706
|
-
if (changes.length === 1) return {
|
|
707
|
-
kind: "collapse",
|
|
708
|
-
stackNumber
|
|
709
|
-
};
|
|
710
|
-
return {
|
|
711
|
-
kind: "rebuild",
|
|
712
|
-
stackNumber,
|
|
713
|
-
action: added.length === 0 ? "remove" : "update"
|
|
714
|
-
};
|
|
715
|
-
}
|
|
716
|
-
function sameSequence(left, right) {
|
|
717
|
-
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
718
|
-
}
|
|
719
771
|
function restorePreviousStack(github, previous, base, remote, reporter, rebuildError) {
|
|
720
772
|
const rebuildMessage = rebuildError instanceof Error ? rebuildError.message : String(rebuildError);
|
|
721
773
|
reporter.progress("Rebuild failed; restoring the previous native GitHub stack");
|
|
@@ -747,7 +799,7 @@ Options:
|
|
|
747
799
|
--remote <name> Git remote; defaults to remote.pushDefault or origin
|
|
748
800
|
--draft Create draft PRs instead of ready-for-review PRs
|
|
749
801
|
--dry-run Inspect the stack without rewriting commits or pushing
|
|
750
|
-
--
|
|
802
|
+
--verbose Show each git and gh command before it runs
|
|
751
803
|
--same-base Refuse checkout if it would change the current merge base
|
|
752
804
|
-v, --version Show the installed version
|
|
753
805
|
-h, --help Show this help
|
|
@@ -767,7 +819,7 @@ function main() {
|
|
|
767
819
|
type: "boolean",
|
|
768
820
|
default: false
|
|
769
821
|
},
|
|
770
|
-
|
|
822
|
+
verbose: {
|
|
771
823
|
type: "boolean",
|
|
772
824
|
default: false
|
|
773
825
|
},
|
|
@@ -795,32 +847,39 @@ function main() {
|
|
|
795
847
|
console.log(version);
|
|
796
848
|
return;
|
|
797
849
|
}
|
|
798
|
-
const
|
|
850
|
+
const reporter = new ConsoleReporter();
|
|
851
|
+
const runner = new NodeCommandRunner(values.verbose ? (invocation) => reporter.command(invocation) : void 0);
|
|
799
852
|
const cwd = process.cwd();
|
|
800
|
-
const repository = new
|
|
853
|
+
const repository = new GitCliRepository(cwd, runner);
|
|
801
854
|
const github = new GitHubCliPlatform(cwd, runner);
|
|
802
|
-
const reporter = new ConsoleReporter(!values.quiet);
|
|
803
855
|
const command = positionals[0] ?? "sync";
|
|
804
856
|
if (command === "checkout") {
|
|
805
857
|
const reference = positionals[1];
|
|
806
858
|
if (!reference || positionals.length > 2) throw new Error(`Usage: bstack checkout <PR-number-or-URL> [options]`);
|
|
807
|
-
const result = checkoutStack(
|
|
859
|
+
const result = checkoutStack({
|
|
860
|
+
repository,
|
|
861
|
+
github,
|
|
862
|
+
reporter
|
|
863
|
+
}, {
|
|
808
864
|
reference,
|
|
809
865
|
base: values.base,
|
|
810
866
|
remote: values.remote,
|
|
811
|
-
sameBase: values["same-base"]
|
|
812
|
-
reporter
|
|
867
|
+
sameBase: values["same-base"]
|
|
813
868
|
});
|
|
814
869
|
console.log(result.delegated ? `Checked out pull request ${reference}` : `Checked out ${result.headRef} from pull request ${reference}`);
|
|
815
870
|
return;
|
|
816
871
|
}
|
|
817
872
|
if (command !== "sync" || positionals.length > 1) throw new Error(`Unknown command: ${positionals.join(" ")}\n\n${help}`);
|
|
818
|
-
const result = syncStack(
|
|
873
|
+
const result = syncStack({
|
|
874
|
+
repository,
|
|
875
|
+
github,
|
|
876
|
+
stateStore: new FileStateStore(repository.statePath()),
|
|
877
|
+
reporter
|
|
878
|
+
}, {
|
|
819
879
|
base: values.base,
|
|
820
880
|
remote: values.remote,
|
|
821
881
|
draft: values.draft,
|
|
822
|
-
dryRun: values["dry-run"]
|
|
823
|
-
reporter
|
|
882
|
+
dryRun: values["dry-run"]
|
|
824
883
|
});
|
|
825
884
|
console.log(`${values["dry-run"] ? "Would sync" : "Synced"} ${result.changes.length} change${result.changes.length === 1 ? "" : "s"} against ${result.base}:`);
|
|
826
885
|
for (const change of result.changes) {
|
package/package.json
CHANGED
|
@@ -1,25 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bstack",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"packageManager": "pnpm@11.5.1",
|
|
3
|
+
"version": "1.1.1",
|
|
5
4
|
"description": "Create native GitHub stacked pull requests from a linear series of commits",
|
|
5
|
+
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
8
|
"url": "git+https://github.com/wsehl/bstack.git"
|
|
9
9
|
},
|
|
10
|
-
"files": [
|
|
11
|
-
"dist/bstack.js"
|
|
12
|
-
],
|
|
13
10
|
"bin": {
|
|
14
11
|
"bstack": "./dist/bstack.js"
|
|
15
12
|
},
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
|
|
13
|
+
"files": [
|
|
14
|
+
"dist/bstack.js"
|
|
15
|
+
],
|
|
16
|
+
"type": "module",
|
|
19
17
|
"publishConfig": {
|
|
20
18
|
"access": "public"
|
|
21
19
|
},
|
|
22
|
-
"type": "module",
|
|
23
20
|
"scripts": {
|
|
24
21
|
"build": "tsdown",
|
|
25
22
|
"format": "oxfmt --write .",
|
|
@@ -31,6 +28,9 @@
|
|
|
31
28
|
"test": "vitest run",
|
|
32
29
|
"type-check": "tsc --noEmit"
|
|
33
30
|
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"valibot": "^1.4.2"
|
|
33
|
+
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"@types/node": "^26.0.0",
|
|
36
36
|
"oxfmt": "^0.64.0",
|
|
@@ -39,7 +39,8 @@
|
|
|
39
39
|
"typescript": "^7.0.2",
|
|
40
40
|
"vitest": "^4.0.0"
|
|
41
41
|
},
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
}
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=20"
|
|
44
|
+
},
|
|
45
|
+
"packageManager": "pnpm@11.5.1"
|
|
45
46
|
}
|