bstack 0.3.0 → 1.0.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/README.md +3 -3
- package/dist/bstack.js +165 -93
- package/package.json +4 -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
|
@@ -2,10 +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 { z } from "zod";
|
|
5
6
|
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
6
7
|
import { dirname } from "node:path";
|
|
7
8
|
//#region package.json
|
|
8
|
-
var version = "0.
|
|
9
|
+
var version = "1.0.1";
|
|
9
10
|
//#endregion
|
|
10
11
|
//#region src/command.ts
|
|
11
12
|
var CommandError = class extends Error {
|
|
@@ -74,24 +75,26 @@ function checkoutStack(repository, github, options) {
|
|
|
74
75
|
}
|
|
75
76
|
reporter.progress(`Checking out ${remote}/${headRef} in detached HEAD state`);
|
|
76
77
|
repository.checkout(target);
|
|
77
|
-
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");
|
|
78
79
|
return {
|
|
79
80
|
headRef,
|
|
80
81
|
delegated: false
|
|
81
82
|
};
|
|
82
83
|
}
|
|
83
84
|
//#endregion
|
|
84
|
-
//#region src/
|
|
85
|
-
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*$`);
|
|
86
89
|
function readChangeId(message) {
|
|
87
|
-
const matches = [...message.matchAll(
|
|
88
|
-
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`);
|
|
89
92
|
return matches[0]?.[1];
|
|
90
93
|
}
|
|
91
94
|
function addChangeId(message, changeId) {
|
|
92
|
-
return `${message.trimEnd()}\n\
|
|
95
|
+
return `${message.trimEnd()}\n\n${CHANGE_ID_TRAILER}: ${changeId}\n`;
|
|
93
96
|
}
|
|
94
|
-
function
|
|
97
|
+
function generateChangeId() {
|
|
95
98
|
return randomUUID().replaceAll("-", "");
|
|
96
99
|
}
|
|
97
100
|
function parseRawCommit(oid, raw) {
|
|
@@ -128,7 +131,7 @@ function rewriteCommit(commit, parent, message) {
|
|
|
128
131
|
return `${rewrittenHeaders.join("\n")}\n\n${message}`;
|
|
129
132
|
}
|
|
130
133
|
function splitCommitMessage(message) {
|
|
131
|
-
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");
|
|
132
135
|
return {
|
|
133
136
|
subject,
|
|
134
137
|
body: bodyLines.join("\n").trim()
|
|
@@ -151,7 +154,7 @@ var GitRepository = class {
|
|
|
151
154
|
}
|
|
152
155
|
assertReady() {
|
|
153
156
|
this.git(["rev-parse", "--show-toplevel"]);
|
|
154
|
-
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");
|
|
155
158
|
}
|
|
156
159
|
currentBranch() {
|
|
157
160
|
return this.git([
|
|
@@ -230,8 +233,8 @@ var GitRepository = class {
|
|
|
230
233
|
oid
|
|
231
234
|
]).stdout));
|
|
232
235
|
}
|
|
233
|
-
ensureChangeIds(commits, dryRun) {
|
|
234
|
-
const assigned = commits.map((commit) => commit.changeId ??
|
|
236
|
+
ensureChangeIds(commits, dryRun, userLogin) {
|
|
237
|
+
const assigned = commits.map((commit) => commit.changeId ?? generateChangeId());
|
|
235
238
|
const needsRewrite = commits.some((commit) => commit.changeId === void 0);
|
|
236
239
|
let parent = commits[0]?.parent;
|
|
237
240
|
const rewrittenOids = [];
|
|
@@ -268,7 +271,7 @@ var GitRepository = class {
|
|
|
268
271
|
oid,
|
|
269
272
|
subject,
|
|
270
273
|
body,
|
|
271
|
-
remoteBranch: `bstack/${id}`
|
|
274
|
+
remoteBranch: `bstack/${userLogin}/${id}`
|
|
272
275
|
};
|
|
273
276
|
});
|
|
274
277
|
}
|
|
@@ -313,7 +316,20 @@ var GitRepository = class {
|
|
|
313
316
|
};
|
|
314
317
|
//#endregion
|
|
315
318
|
//#region src/github.ts
|
|
316
|
-
|
|
319
|
+
const pullRequestSchema = z.object({
|
|
320
|
+
number: z.number(),
|
|
321
|
+
url: z.string(),
|
|
322
|
+
state: z.enum([
|
|
323
|
+
"OPEN",
|
|
324
|
+
"CLOSED",
|
|
325
|
+
"MERGED"
|
|
326
|
+
]),
|
|
327
|
+
title: z.string(),
|
|
328
|
+
body: z.string(),
|
|
329
|
+
isDraft: z.boolean()
|
|
330
|
+
});
|
|
331
|
+
const stackSchema = z.object({ number: z.number() });
|
|
332
|
+
var GitHubCliPlatform = class {
|
|
317
333
|
cwd;
|
|
318
334
|
runner;
|
|
319
335
|
constructor(cwd, runner) {
|
|
@@ -331,6 +347,14 @@ var GhPlatform = class {
|
|
|
331
347
|
]);
|
|
332
348
|
this.gh(["stack", "--version"]);
|
|
333
349
|
}
|
|
350
|
+
currentUserLogin() {
|
|
351
|
+
return z.string().min(1).parse(this.gh([
|
|
352
|
+
"api",
|
|
353
|
+
"user",
|
|
354
|
+
"--jq",
|
|
355
|
+
".login"
|
|
356
|
+
]).stdout.trim());
|
|
357
|
+
}
|
|
334
358
|
defaultBranch() {
|
|
335
359
|
return this.gh([
|
|
336
360
|
"repo",
|
|
@@ -354,9 +378,8 @@ var GhPlatform = class {
|
|
|
354
378
|
"--json",
|
|
355
379
|
"number,url,state,title,body,isDraft"
|
|
356
380
|
]).stdout;
|
|
357
|
-
const candidates = JSON.parse(raw);
|
|
358
|
-
|
|
359
|
-
return selected ? normalizePullRequest(selected) : void 0;
|
|
381
|
+
const candidates = pullRequestSchema.array().parse(JSON.parse(raw));
|
|
382
|
+
return candidates.find((pr) => pr.state === "OPEN") ?? candidates.find((pr) => pr.state === "MERGED");
|
|
360
383
|
}
|
|
361
384
|
pullRequest(number) {
|
|
362
385
|
const raw = this.gh([
|
|
@@ -366,7 +389,7 @@ var GhPlatform = class {
|
|
|
366
389
|
"--json",
|
|
367
390
|
"number,url,state,title,body,isDraft"
|
|
368
391
|
]).stdout;
|
|
369
|
-
return
|
|
392
|
+
return pullRequestSchema.parse(JSON.parse(raw));
|
|
370
393
|
}
|
|
371
394
|
createPullRequest(change, base, draft) {
|
|
372
395
|
const args = [
|
|
@@ -418,6 +441,15 @@ var GhPlatform = class {
|
|
|
418
441
|
String(stackNumber)
|
|
419
442
|
]);
|
|
420
443
|
}
|
|
444
|
+
editPullRequestBase(pr, base) {
|
|
445
|
+
this.gh([
|
|
446
|
+
"pr",
|
|
447
|
+
"edit",
|
|
448
|
+
String(pr.number),
|
|
449
|
+
"--base",
|
|
450
|
+
base
|
|
451
|
+
]);
|
|
452
|
+
}
|
|
421
453
|
editPullRequest(pr, change) {
|
|
422
454
|
if (pr.title === change.subject && pr.body === change.body) return;
|
|
423
455
|
this.gh([
|
|
@@ -432,7 +464,7 @@ var GhPlatform = class {
|
|
|
432
464
|
}
|
|
433
465
|
stackNumberForPullRequest(prNumber) {
|
|
434
466
|
const raw = this.gh(["api", `repos/{owner}/{repo}/stacks?pull_request=${prNumber}`]).stdout;
|
|
435
|
-
return JSON.parse(raw)[0]?.number;
|
|
467
|
+
return stackSchema.array().parse(JSON.parse(raw))[0]?.number;
|
|
436
468
|
}
|
|
437
469
|
pullRequestHead(reference) {
|
|
438
470
|
return this.gh([
|
|
@@ -453,13 +485,6 @@ var GhPlatform = class {
|
|
|
453
485
|
]);
|
|
454
486
|
}
|
|
455
487
|
};
|
|
456
|
-
function normalizePullRequest(pr) {
|
|
457
|
-
if (pr.state !== "OPEN" && pr.state !== "CLOSED" && pr.state !== "MERGED") throw new Error(`GitHub returned an unknown PR state: ${pr.state}`);
|
|
458
|
-
return {
|
|
459
|
-
...pr,
|
|
460
|
-
state: pr.state
|
|
461
|
-
};
|
|
462
|
-
}
|
|
463
488
|
//#endregion
|
|
464
489
|
//#region src/reporter.ts
|
|
465
490
|
var ConsoleReporter = class {
|
|
@@ -477,6 +502,22 @@ const emptyState = () => ({
|
|
|
477
502
|
schemaVersion: 1,
|
|
478
503
|
stacks: []
|
|
479
504
|
});
|
|
505
|
+
const storedChangeSchema = z.object({
|
|
506
|
+
id: z.string(),
|
|
507
|
+
remoteBranch: z.string(),
|
|
508
|
+
pullRequest: z.number(),
|
|
509
|
+
url: z.string()
|
|
510
|
+
});
|
|
511
|
+
const storedStackSchema = z.object({
|
|
512
|
+
remote: z.string(),
|
|
513
|
+
base: z.string(),
|
|
514
|
+
stackNumber: z.number().optional(),
|
|
515
|
+
changes: storedChangeSchema.array()
|
|
516
|
+
});
|
|
517
|
+
const stateSchema = z.object({
|
|
518
|
+
schemaVersion: z.literal(1),
|
|
519
|
+
stacks: storedStackSchema.array()
|
|
520
|
+
});
|
|
480
521
|
var StateStore = class {
|
|
481
522
|
path;
|
|
482
523
|
constructor(path) {
|
|
@@ -484,11 +525,21 @@ var StateStore = class {
|
|
|
484
525
|
}
|
|
485
526
|
read() {
|
|
486
527
|
try {
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
528
|
+
return {
|
|
529
|
+
schemaVersion: 1,
|
|
530
|
+
stacks: stateSchema.parse(JSON.parse(readFileSync(this.path, "utf8"))).stacks.map((stack) => {
|
|
531
|
+
const stored = {
|
|
532
|
+
remote: stack.remote,
|
|
533
|
+
base: stack.base,
|
|
534
|
+
changes: stack.changes
|
|
535
|
+
};
|
|
536
|
+
if (stack.stackNumber !== void 0) stored.stackNumber = stack.stackNumber;
|
|
537
|
+
return stored;
|
|
538
|
+
})
|
|
539
|
+
};
|
|
490
540
|
} catch (error) {
|
|
491
|
-
if (
|
|
541
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") return emptyState();
|
|
542
|
+
if (error instanceof z.ZodError) throw new Error(`Unsupported bstack state in ${this.path}`, { cause: error });
|
|
492
543
|
throw error;
|
|
493
544
|
}
|
|
494
545
|
}
|
|
@@ -504,14 +555,6 @@ var StateStore = class {
|
|
|
504
555
|
return matches[0];
|
|
505
556
|
}
|
|
506
557
|
};
|
|
507
|
-
function isMissingFile(error) {
|
|
508
|
-
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
509
|
-
}
|
|
510
|
-
function isState(value) {
|
|
511
|
-
if (typeof value !== "object" || value === null) return false;
|
|
512
|
-
const candidate = value;
|
|
513
|
-
return candidate.schemaVersion === 1 && Array.isArray(candidate.stacks);
|
|
514
|
-
}
|
|
515
558
|
//#endregion
|
|
516
559
|
//#region src/sync.ts
|
|
517
560
|
function syncStack(repository, github, options) {
|
|
@@ -521,7 +564,9 @@ function syncStack(repository, github, options) {
|
|
|
521
564
|
github.assertReady();
|
|
522
565
|
const remote = repository.resolveRemote(options.remote);
|
|
523
566
|
const base = options.base ?? github.defaultBranch();
|
|
567
|
+
const userLogin = github.currentUserLogin();
|
|
524
568
|
reporter.progress(`Using ${remote} as the remote and ${base} as the stack base`);
|
|
569
|
+
reporter.progress(`Using ${userLogin} as the remote branch namespace`);
|
|
525
570
|
reporter.progress(`Fetching ${remote}/${base}`);
|
|
526
571
|
const remoteBase = repository.fetchBase(remote, base);
|
|
527
572
|
const baseOid = repository.mergeBase("HEAD", remoteBase);
|
|
@@ -531,7 +576,7 @@ function syncStack(repository, github, options) {
|
|
|
531
576
|
const rewritten = commits.some((commit) => commit.changeId === void 0);
|
|
532
577
|
if (rewritten) reporter.progress(options.dryRun ? "Stable change IDs would be added to the commits" : "Adding stable change IDs to the commits");
|
|
533
578
|
else reporter.progress("All commits already have stable change IDs");
|
|
534
|
-
const changes = repository.ensureChangeIds(commits, options.dryRun);
|
|
579
|
+
const changes = repository.ensureChangeIds(commits, options.dryRun, userLogin);
|
|
535
580
|
if (options.dryRun) {
|
|
536
581
|
reporter.progress("Dry run complete; no commits or remote branches were changed");
|
|
537
582
|
return {
|
|
@@ -545,38 +590,42 @@ function syncStack(repository, github, options) {
|
|
|
545
590
|
const store = new StateStore(repository.statePath());
|
|
546
591
|
const state = store.read();
|
|
547
592
|
const previous = store.findByChangeIds(state, new Set(changes.map((change) => change.id)));
|
|
548
|
-
const
|
|
549
|
-
reporter.progress(`
|
|
593
|
+
const transition = analyzeStackTransition(previous, changes, github, repository.currentBranch() === "");
|
|
594
|
+
reporter.progress(`Pushing ${changes.length} remote branch${changes.length === 1 ? "" : "es"}`);
|
|
550
595
|
repository.pushChanges(remote, changes);
|
|
551
596
|
reporter.progress("Looking up existing pull requests");
|
|
552
597
|
const existing = changes.map((change) => github.pullRequestForBranch(change.remoteBranch));
|
|
553
598
|
let pullRequests;
|
|
554
599
|
if (changes.length === 1) {
|
|
555
|
-
if (
|
|
600
|
+
if (transition.kind === "partial") reporter.progress("Updating this down-stack prefix while preserving higher pull requests");
|
|
556
601
|
reporter.progress(existing[0] ? "Using the existing pull request" : "Creating a pull request");
|
|
557
|
-
|
|
602
|
+
const pullRequest = existing[0] ?? github.createPullRequest(changes[0], base, options.draft);
|
|
603
|
+
if (transition.kind === "collapse") {
|
|
604
|
+
reporter.progress(`Removing omitted pull requests from stack #${transition.stackNumber}`);
|
|
605
|
+
github.unstack(transition.stackNumber);
|
|
606
|
+
try {
|
|
607
|
+
github.editPullRequestBase(pullRequest, base);
|
|
608
|
+
} catch (error) {
|
|
609
|
+
restorePreviousStack(github, previous, base, remote, reporter, error);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
pullRequests = [pullRequest];
|
|
558
613
|
} else {
|
|
559
|
-
if (
|
|
614
|
+
if (transition.kind === "full") {
|
|
560
615
|
reporter.progress(`Linking ${changes.length} pull requests as a native GitHub stack`);
|
|
561
616
|
github.linkStack(changes.map((change) => change.remoteBranch), base, remote, options.draft);
|
|
562
|
-
} else if (
|
|
563
|
-
reporter.progress(`Rebuilding stack #${
|
|
564
|
-
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);
|
|
565
620
|
try {
|
|
566
621
|
github.linkStack(changes.map((change) => change.remoteBranch), base, remote, options.draft);
|
|
567
622
|
} catch (error) {
|
|
568
|
-
|
|
569
|
-
try {
|
|
570
|
-
github.linkStack(previous.changes.map((change) => change.remoteBranch), base, remote, true);
|
|
571
|
-
} catch (rollbackError) {
|
|
572
|
-
throw new Error(`Stack rebuild failed: ${errorMessage(error)}\nRestoring the previous stack also failed: ${errorMessage(rollbackError)}`);
|
|
573
|
-
}
|
|
574
|
-
throw error;
|
|
623
|
+
restorePreviousStack(github, previous, base, remote, reporter, error);
|
|
575
624
|
}
|
|
576
|
-
} else if (
|
|
577
|
-
reporter.progress(`Appending ${
|
|
578
|
-
github.appendToStack(
|
|
579
|
-
} 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");
|
|
580
629
|
else reporter.progress("The native GitHub stack already has the correct members");
|
|
581
630
|
pullRequests = changes.map((change, index) => {
|
|
582
631
|
const pr = existing[index] ?? github.pullRequestForBranch(change.remoteBranch);
|
|
@@ -589,20 +638,20 @@ function syncStack(repository, github, options) {
|
|
|
589
638
|
github.editPullRequest(pr, changes[index]);
|
|
590
639
|
reporter.progress(`PR #${pr.number}: ${changes[index].subject}`);
|
|
591
640
|
}
|
|
592
|
-
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);
|
|
593
642
|
const synchronizedChanges = changes.map((change, index) => ({
|
|
594
643
|
id: change.id,
|
|
595
644
|
remoteBranch: change.remoteBranch,
|
|
596
645
|
pullRequest: pullRequests[index].number,
|
|
597
646
|
url: pullRequests[index].url
|
|
598
647
|
}));
|
|
599
|
-
const
|
|
600
|
-
writeUpdatedState(store, state, previous, {
|
|
648
|
+
const updatedStack = {
|
|
601
649
|
remote,
|
|
602
650
|
base,
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
651
|
+
changes: transition.kind === "partial" && previous ? [...synchronizedChanges, ...previous.changes.slice(transition.previousOffset + changes.length)] : synchronizedChanges
|
|
652
|
+
};
|
|
653
|
+
if (stackNumber !== void 0) updatedStack.stackNumber = stackNumber;
|
|
654
|
+
writeUpdatedState(store, state, previous, updatedStack);
|
|
606
655
|
reporter.progress("Saved the local stack state");
|
|
607
656
|
return {
|
|
608
657
|
base,
|
|
@@ -614,46 +663,69 @@ function syncStack(repository, github, options) {
|
|
|
614
663
|
}))
|
|
615
664
|
};
|
|
616
665
|
}
|
|
617
|
-
function
|
|
666
|
+
function analyzeStackTransition(previous, changes, github, preserveHigherChanges) {
|
|
618
667
|
if (!previous) return { kind: "full" };
|
|
619
668
|
const previousIds = previous.changes.map((change) => change.id);
|
|
620
669
|
const currentIds = changes.map((change) => change.id);
|
|
621
|
-
|
|
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) {
|
|
622
676
|
if (previousIds.every((id, index) => currentIds[index] === id)) return { kind: "full" };
|
|
623
677
|
const stackNumber = previous.stackNumber ?? github.stackNumberForPullRequest(previous.changes[0].pullRequest);
|
|
624
678
|
return stackNumber === void 0 ? { kind: "full" } : {
|
|
625
679
|
kind: "rebuild",
|
|
626
|
-
stackNumber
|
|
680
|
+
stackNumber,
|
|
681
|
+
action: "insert"
|
|
627
682
|
};
|
|
628
683
|
}
|
|
629
684
|
const firstCurrentIndex = previousIds.indexOf(currentIds[0]);
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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
|
|
639
709
|
};
|
|
640
|
-
const appended = changes.slice(surviving.length);
|
|
641
|
-
if (removedPrefix.length === 0) return { kind: "full" };
|
|
642
|
-
if (appended.length === 0) return { kind: "skip" };
|
|
643
|
-
if (previous.stackNumber === void 0) throw new Error("Cannot append after a merge because the native GitHub stack number is missing from local state");
|
|
644
710
|
return {
|
|
645
|
-
kind: "
|
|
646
|
-
stackNumber
|
|
647
|
-
|
|
711
|
+
kind: "rebuild",
|
|
712
|
+
stackNumber,
|
|
713
|
+
action: added.length === 0 ? "remove" : "update"
|
|
648
714
|
};
|
|
649
715
|
}
|
|
650
|
-
function
|
|
651
|
-
|
|
652
|
-
for (const value of actual) if (value === expected[expectedIndex]) expectedIndex++;
|
|
653
|
-
return expectedIndex === expected.length;
|
|
716
|
+
function sameSequence(left, right) {
|
|
717
|
+
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
654
718
|
}
|
|
655
|
-
function
|
|
656
|
-
|
|
719
|
+
function restorePreviousStack(github, previous, base, remote, reporter, rebuildError) {
|
|
720
|
+
const rebuildMessage = rebuildError instanceof Error ? rebuildError.message : String(rebuildError);
|
|
721
|
+
reporter.progress("Rebuild failed; restoring the previous native GitHub stack");
|
|
722
|
+
try {
|
|
723
|
+
github.linkStack(previous.changes.map((change) => change.remoteBranch), base, remote, true);
|
|
724
|
+
} catch (rollbackError) {
|
|
725
|
+
const rollbackMessage = rollbackError instanceof Error ? rollbackError.message : String(rollbackError);
|
|
726
|
+
throw new Error(`Stack rebuild failed: ${rebuildMessage}\nRestoring the previous stack also failed: ${rollbackMessage}`);
|
|
727
|
+
}
|
|
728
|
+
throw rebuildError;
|
|
657
729
|
}
|
|
658
730
|
function writeUpdatedState(store, state, previous, updated) {
|
|
659
731
|
const stacks = previous ? state.stacks.map((stack) => stack === previous ? updated : stack) : [...state.stacks, updated];
|
|
@@ -664,14 +736,14 @@ function writeUpdatedState(store, state, previous, updated) {
|
|
|
664
736
|
}
|
|
665
737
|
//#endregion
|
|
666
738
|
//#region src/cli.ts
|
|
667
|
-
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
|
|
668
740
|
|
|
669
741
|
Usage:
|
|
670
742
|
bstack [sync] [options]
|
|
671
743
|
bstack checkout <PR-number-or-URL> [options]
|
|
672
744
|
|
|
673
745
|
Options:
|
|
674
|
-
--base <branch> Stack
|
|
746
|
+
--base <branch> Stack base; defaults to the GitHub default branch
|
|
675
747
|
--remote <name> Git remote; defaults to remote.pushDefault or origin
|
|
676
748
|
--draft Create draft PRs instead of ready-for-review PRs
|
|
677
749
|
--dry-run Inspect the stack without rewriting commits or pushing
|
|
@@ -726,7 +798,7 @@ function main() {
|
|
|
726
798
|
const runner = new NodeCommandRunner();
|
|
727
799
|
const cwd = process.cwd();
|
|
728
800
|
const repository = new GitRepository(cwd, runner);
|
|
729
|
-
const github = new
|
|
801
|
+
const github = new GitHubCliPlatform(cwd, runner);
|
|
730
802
|
const reporter = new ConsoleReporter(!values.quiet);
|
|
731
803
|
const command = positionals[0] ?? "sync";
|
|
732
804
|
if (command === "checkout") {
|
|
@@ -750,7 +822,7 @@ function main() {
|
|
|
750
822
|
dryRun: values["dry-run"],
|
|
751
823
|
reporter
|
|
752
824
|
});
|
|
753
|
-
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}:`);
|
|
754
826
|
for (const change of result.changes) {
|
|
755
827
|
const destination = change.pullRequest ? ` ${change.pullRequest.url}` : "";
|
|
756
828
|
console.log(` ${change.oid.slice(0, 8)} ${change.subject}${destination}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bstack",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"packageManager": "pnpm@11.5.1",
|
|
5
5
|
"description": "Create native GitHub stacked pull requests from a linear series of commits",
|
|
6
6
|
"repository": {
|
|
@@ -38,5 +38,8 @@
|
|
|
38
38
|
"tsdown": "^0.22.14",
|
|
39
39
|
"typescript": "^7.0.2",
|
|
40
40
|
"vitest": "^4.0.0"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"zod": "^4.4.3"
|
|
41
44
|
}
|
|
42
45
|
}
|