bstack 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -0
- package/package.json +37 -0
- package/src/checkout.ts +67 -0
- package/src/cli.ts +100 -0
- package/src/command.ts +61 -0
- package/src/git.ts +197 -0
- package/src/github.ts +187 -0
- package/src/identity.ts +88 -0
- package/src/model.ts +44 -0
- package/src/reporter.ts +11 -0
- package/src/state.ts +52 -0
- package/src/sync.ts +251 -0
package/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# bstack
|
|
2
|
+
|
|
3
|
+
`bstack` turns a series of commits in local branch into native GitHub stacked separate pull requests.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
Requirements: Bun, Git, an authenticated GitHub CLI, and the `github/gh-stack` extension.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
gh extension install github/gh-stack
|
|
11
|
+
bun install --global bstack
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Use
|
|
15
|
+
|
|
16
|
+
Create one commit per reviewable change, then publish the stack:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
git switch -c my-feature
|
|
20
|
+
git commit -am "Add the model"
|
|
21
|
+
git commit -am "Add the API"
|
|
22
|
+
bstack
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Run `bstack` again after amending or rebasing commits. New PRs are drafts unless you pass `--open`.
|
|
26
|
+
|
|
27
|
+
Checkout a stack through one of its PRs:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
bstack checkout 123
|
|
31
|
+
bstack checkout https://github.com/owner/repo/pull/123
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The first publish adds a stable `Bstack-Id` trailer to each commit and rewrites their hashes. The working tree must be clean. Merge commits and signed commits that need trailers are not supported.
|
|
35
|
+
|
|
36
|
+
Use `--dry-run` to inspect without publishing and `--quiet` to hide progress logs.
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "bstack",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Create native GitHub stacked pull requests from a linear series of commits",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/wsehl/bstack.git"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src"
|
|
11
|
+
],
|
|
12
|
+
"bin": {
|
|
13
|
+
"bstack": "./src/cli.ts"
|
|
14
|
+
},
|
|
15
|
+
"engines": {
|
|
16
|
+
"bun": ">=1.3.0"
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"type": "module",
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "bun build ./src/cli.ts --compile --outfile ./dist/bstack",
|
|
24
|
+
"format": "oxfmt --write .",
|
|
25
|
+
"format:check": "oxfmt --check .",
|
|
26
|
+
"lint": "oxlint .",
|
|
27
|
+
"start": "bun run src/cli.ts",
|
|
28
|
+
"test": "bun test",
|
|
29
|
+
"type-check": "tsc --noEmit"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/bun": "^1.4.0",
|
|
33
|
+
"oxfmt": "^0.64.0",
|
|
34
|
+
"oxlint": "^1.79.0",
|
|
35
|
+
"typescript": "^7.0.2"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/checkout.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { GitRepository } from "./git";
|
|
2
|
+
import type { GitHubPlatform } from "./github";
|
|
3
|
+
import type { Reporter } from "./reporter";
|
|
4
|
+
|
|
5
|
+
export type CheckoutOptions = {
|
|
6
|
+
reference: string;
|
|
7
|
+
base: string | undefined;
|
|
8
|
+
remote: string | undefined;
|
|
9
|
+
sameBase: boolean;
|
|
10
|
+
reporter: Reporter;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type CheckoutResult = {
|
|
14
|
+
headRef: string;
|
|
15
|
+
delegated: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function checkoutStack(
|
|
19
|
+
repository: GitRepository,
|
|
20
|
+
github: GitHubPlatform,
|
|
21
|
+
options: CheckoutOptions,
|
|
22
|
+
): CheckoutResult {
|
|
23
|
+
const { reporter } = options;
|
|
24
|
+
reporter.progress("Checking the repository and GitHub prerequisites");
|
|
25
|
+
repository.assertReady();
|
|
26
|
+
github.assertReady();
|
|
27
|
+
|
|
28
|
+
const remote = repository.resolveRemote(options.remote);
|
|
29
|
+
reporter.progress(`Looking up pull request ${options.reference}`);
|
|
30
|
+
const headRef = github.pullRequestHead(options.reference);
|
|
31
|
+
if (!headRef.startsWith("bstack/")) {
|
|
32
|
+
reporter.progress(
|
|
33
|
+
"This is not a bstack pull request; delegating to gh pr checkout",
|
|
34
|
+
);
|
|
35
|
+
github.checkoutPullRequest(options.reference);
|
|
36
|
+
return { headRef, delegated: true };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let currentBase: string | undefined;
|
|
40
|
+
let remoteBase: string | undefined;
|
|
41
|
+
if (options.sameBase) {
|
|
42
|
+
const base = options.base ?? github.defaultBranch();
|
|
43
|
+
reporter.progress(
|
|
44
|
+
`Checking that the merge base remains on ${remote}/${base}`,
|
|
45
|
+
);
|
|
46
|
+
remoteBase = repository.fetchBase(remote, base);
|
|
47
|
+
currentBase = repository.mergeBase("HEAD", remoteBase);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
reporter.progress(`Fetching ${remote}/${headRef}`);
|
|
51
|
+
const target = repository.fetchRemoteBranch(remote, headRef);
|
|
52
|
+
if (currentBase && remoteBase) {
|
|
53
|
+
const targetBase = repository.mergeBase(target, remoteBase);
|
|
54
|
+
if (currentBase !== targetBase) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`Checkout would change the merge base from ${currentBase.slice(0, 8)} to ${targetBase.slice(0, 8)}`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
reporter.progress(`Checking out ${remote}/${headRef} in detached HEAD state`);
|
|
62
|
+
repository.checkout(target);
|
|
63
|
+
reporter.progress(
|
|
64
|
+
"Checkout complete; amend the commits and run bstack to publish updates",
|
|
65
|
+
);
|
|
66
|
+
return { headRef, delegated: false };
|
|
67
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { BunCommandRunner } from "./command";
|
|
5
|
+
import { checkoutStack } from "./checkout";
|
|
6
|
+
import { GitRepository } from "./git";
|
|
7
|
+
import { GhPlatform } from "./github";
|
|
8
|
+
import { ConsoleReporter } from "./reporter";
|
|
9
|
+
import { syncStack } from "./sync";
|
|
10
|
+
|
|
11
|
+
const help = `bstack - turn a linear commit series into native GitHub stacked PRs
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
bstack [sync] [options]
|
|
15
|
+
bstack checkout <PR-number-or-URL> [options]
|
|
16
|
+
|
|
17
|
+
Options:
|
|
18
|
+
--base <branch> Stack trunk; defaults to the GitHub default branch
|
|
19
|
+
--remote <name> Git remote; defaults to remote.pushDefault or origin
|
|
20
|
+
--open Create PRs ready for review instead of drafts
|
|
21
|
+
--dry-run Inspect the stack without rewriting commits or pushing
|
|
22
|
+
--quiet Hide progress logs; the final summary is still printed
|
|
23
|
+
--same-base Refuse checkout if it would change the current merge base
|
|
24
|
+
-h, --help Show this help
|
|
25
|
+
`;
|
|
26
|
+
|
|
27
|
+
function main(): void {
|
|
28
|
+
const { values, positionals } = parseArgs({
|
|
29
|
+
args: Bun.argv.slice(2),
|
|
30
|
+
allowPositionals: true,
|
|
31
|
+
options: {
|
|
32
|
+
base: { type: "string" },
|
|
33
|
+
remote: { type: "string" },
|
|
34
|
+
open: { type: "boolean", default: false },
|
|
35
|
+
"dry-run": { type: "boolean", default: false },
|
|
36
|
+
quiet: { type: "boolean", default: false },
|
|
37
|
+
"same-base": { type: "boolean", default: false },
|
|
38
|
+
help: { type: "boolean", short: "h", default: false },
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
if (values.help) {
|
|
43
|
+
process.stdout.write(help);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const runner = new BunCommandRunner();
|
|
47
|
+
const cwd = process.cwd();
|
|
48
|
+
const repository = new GitRepository(cwd, runner);
|
|
49
|
+
const github = new GhPlatform(cwd, runner);
|
|
50
|
+
const reporter = new ConsoleReporter(!values.quiet);
|
|
51
|
+
const command = positionals[0] ?? "sync";
|
|
52
|
+
|
|
53
|
+
if (command === "checkout") {
|
|
54
|
+
const reference = positionals[1];
|
|
55
|
+
if (!reference || positionals.length > 2) {
|
|
56
|
+
throw new Error(`Usage: bstack checkout <PR-number-or-URL> [options]`);
|
|
57
|
+
}
|
|
58
|
+
const result = checkoutStack(repository, github, {
|
|
59
|
+
reference,
|
|
60
|
+
base: values.base,
|
|
61
|
+
remote: values.remote,
|
|
62
|
+
sameBase: values["same-base"],
|
|
63
|
+
reporter,
|
|
64
|
+
});
|
|
65
|
+
console.log(
|
|
66
|
+
result.delegated
|
|
67
|
+
? `Checked out pull request ${reference}`
|
|
68
|
+
: `Checked out ${result.headRef} from pull request ${reference}`,
|
|
69
|
+
);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (command !== "sync" || positionals.length > 1) {
|
|
74
|
+
throw new Error(`Unknown command: ${positionals.join(" ")}\n\n${help}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const result = syncStack(repository, github, {
|
|
78
|
+
base: values.base,
|
|
79
|
+
remote: values.remote,
|
|
80
|
+
open: values.open,
|
|
81
|
+
dryRun: values["dry-run"],
|
|
82
|
+
reporter,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
console.log(
|
|
86
|
+
`${values["dry-run"] ? "Would publish" : "Published"} ${result.changes.length} change${result.changes.length === 1 ? "" : "s"} against ${result.base}:`,
|
|
87
|
+
);
|
|
88
|
+
for (const change of result.changes) {
|
|
89
|
+
const destination = change.pullRequest ? ` ${change.pullRequest.url}` : "";
|
|
90
|
+
console.log(` ${change.oid.slice(0, 8)} ${change.subject}${destination}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
main();
|
|
96
|
+
} catch (error) {
|
|
97
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
98
|
+
console.error(`bstack: ${message}`);
|
|
99
|
+
process.exitCode = 1;
|
|
100
|
+
}
|
package/src/command.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export type CommandOptions = {
|
|
2
|
+
cwd: string;
|
|
3
|
+
stdin?: string;
|
|
4
|
+
env?: Record<string, string>;
|
|
5
|
+
allowFailure?: boolean;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type CommandResult = {
|
|
9
|
+
stdout: string;
|
|
10
|
+
stderr: string;
|
|
11
|
+
exitCode: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export class CommandError extends Error {
|
|
15
|
+
constructor(
|
|
16
|
+
readonly command: readonly string[],
|
|
17
|
+
readonly result: CommandResult,
|
|
18
|
+
) {
|
|
19
|
+
const detail = result.stderr.trim() || result.stdout.trim();
|
|
20
|
+
super(
|
|
21
|
+
`${command.join(" ")} failed with exit code ${result.exitCode}${detail ? `\n${detail}` : ""}`,
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CommandRunner {
|
|
27
|
+
run(command: readonly string[], options: CommandOptions): CommandResult;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class BunCommandRunner implements CommandRunner {
|
|
31
|
+
run(command: readonly string[], options: CommandOptions): CommandResult {
|
|
32
|
+
const [executable, ...args] = command;
|
|
33
|
+
if (!executable) {
|
|
34
|
+
throw new Error("Cannot run an empty command");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const result = Bun.spawnSync([executable, ...args], {
|
|
38
|
+
cwd: options.cwd,
|
|
39
|
+
stdin:
|
|
40
|
+
options.stdin === undefined ? undefined : Buffer.from(options.stdin),
|
|
41
|
+
stdout: "pipe",
|
|
42
|
+
stderr: "pipe",
|
|
43
|
+
env:
|
|
44
|
+
options.env === undefined
|
|
45
|
+
? process.env
|
|
46
|
+
: { ...process.env, ...options.env },
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const commandResult = {
|
|
50
|
+
stdout: result.stdout.toString(),
|
|
51
|
+
stderr: result.stderr.toString(),
|
|
52
|
+
exitCode: result.exitCode,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
if (commandResult.exitCode !== 0 && !options.allowFailure) {
|
|
56
|
+
throw new CommandError(command, commandResult);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return commandResult;
|
|
60
|
+
}
|
|
61
|
+
}
|
package/src/git.ts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import type { CommandRunner } from "./command";
|
|
2
|
+
import {
|
|
3
|
+
addChangeId,
|
|
4
|
+
newChangeId,
|
|
5
|
+
parseRawCommit,
|
|
6
|
+
rewriteCommit,
|
|
7
|
+
splitCommitMessage,
|
|
8
|
+
} from "./identity";
|
|
9
|
+
import type { Change, Commit } from "./model";
|
|
10
|
+
|
|
11
|
+
export class GitRepository {
|
|
12
|
+
constructor(
|
|
13
|
+
readonly cwd: string,
|
|
14
|
+
private readonly runner: CommandRunner,
|
|
15
|
+
) {}
|
|
16
|
+
|
|
17
|
+
private git(
|
|
18
|
+
args: readonly string[],
|
|
19
|
+
options: { stdin?: string; allowFailure?: boolean } = {},
|
|
20
|
+
) {
|
|
21
|
+
return this.runner.run(["git", ...args], { cwd: this.cwd, ...options });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
assertReady(): void {
|
|
25
|
+
this.git(["rev-parse", "--show-toplevel"]);
|
|
26
|
+
const status = this.git(["status", "--porcelain"]).stdout;
|
|
27
|
+
if (status.trim()) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
"The working tree must be clean before bstack rewrites or publishes commits",
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
currentBranch(): string {
|
|
35
|
+
return this.git(["symbolic-ref", "--quiet", "--short", "HEAD"], {
|
|
36
|
+
allowFailure: true,
|
|
37
|
+
}).stdout.trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
remotes(): string[] {
|
|
41
|
+
return this.git(["remote"]).stdout.split("\n").filter(Boolean);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
configuredPushRemote(): string | undefined {
|
|
45
|
+
const result = this.git(["config", "--get", "remote.pushDefault"], {
|
|
46
|
+
allowFailure: true,
|
|
47
|
+
});
|
|
48
|
+
return result.exitCode === 0
|
|
49
|
+
? result.stdout.trim() || undefined
|
|
50
|
+
: undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
resolveRemote(requested?: string): string {
|
|
54
|
+
if (requested) {
|
|
55
|
+
if (!this.remotes().includes(requested)) {
|
|
56
|
+
throw new Error(`Git remote ${requested} does not exist`);
|
|
57
|
+
}
|
|
58
|
+
return requested;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const configured = this.configuredPushRemote();
|
|
62
|
+
if (configured) return configured;
|
|
63
|
+
const remotes = this.remotes();
|
|
64
|
+
if (remotes.length === 1) return remotes[0]!;
|
|
65
|
+
if (remotes.includes("origin")) return "origin";
|
|
66
|
+
throw new Error(
|
|
67
|
+
"Cannot choose a Git remote. Pass --remote or configure remote.pushDefault",
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
fetchBase(remote: string, base: string): string {
|
|
72
|
+
const destination = `refs/remotes/${remote}/${base}`;
|
|
73
|
+
this.git([
|
|
74
|
+
"fetch",
|
|
75
|
+
"--no-tags",
|
|
76
|
+
remote,
|
|
77
|
+
`refs/heads/${base}:${destination}`,
|
|
78
|
+
]);
|
|
79
|
+
return destination;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
fetchRemoteBranch(remote: string, branch: string): string {
|
|
83
|
+
const destination = `refs/remotes/${remote}/${branch}`;
|
|
84
|
+
this.git([
|
|
85
|
+
"fetch",
|
|
86
|
+
"--no-tags",
|
|
87
|
+
remote,
|
|
88
|
+
`+refs/heads/${branch}:${destination}`,
|
|
89
|
+
]);
|
|
90
|
+
return destination;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
checkout(ref: string): void {
|
|
94
|
+
this.git(["checkout", "--detach", ref]);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
mergeBase(left: string, right: string): string {
|
|
98
|
+
return this.git(["merge-base", left, right]).stdout.trim();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
commitsSince(baseOid: string): Commit[] {
|
|
102
|
+
const oids = this.git([
|
|
103
|
+
"rev-list",
|
|
104
|
+
"--reverse",
|
|
105
|
+
"--first-parent",
|
|
106
|
+
`${baseOid}..HEAD`,
|
|
107
|
+
])
|
|
108
|
+
.stdout.split("\n")
|
|
109
|
+
.filter(Boolean);
|
|
110
|
+
return oids.map((oid) =>
|
|
111
|
+
parseRawCommit(oid, this.git(["cat-file", "commit", oid]).stdout),
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
ensureChangeIds(commits: readonly Commit[], dryRun: boolean): Change[] {
|
|
116
|
+
const assigned = commits.map((commit) => commit.changeId ?? newChangeId());
|
|
117
|
+
const needsRewrite = commits.some(
|
|
118
|
+
(commit) => commit.changeId === undefined,
|
|
119
|
+
);
|
|
120
|
+
let parent = commits[0]?.parent;
|
|
121
|
+
const rewrittenOids: string[] = [];
|
|
122
|
+
|
|
123
|
+
if (needsRewrite && !dryRun) {
|
|
124
|
+
for (const [index, commit] of commits.entries()) {
|
|
125
|
+
const changeId = assigned[index]!;
|
|
126
|
+
const message = commit.changeId
|
|
127
|
+
? commit.message
|
|
128
|
+
: addChangeId(commit.message, changeId);
|
|
129
|
+
const raw = rewriteCommit(commit, parent!, message);
|
|
130
|
+
const oid = this.git(["hash-object", "-t", "commit", "-w", "--stdin"], {
|
|
131
|
+
stdin: raw,
|
|
132
|
+
}).stdout.trim();
|
|
133
|
+
rewrittenOids.push(oid);
|
|
134
|
+
parent = oid;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const oldHead = commits.at(-1)!.oid;
|
|
138
|
+
const newHead = rewrittenOids.at(-1)!;
|
|
139
|
+
this.git(["update-ref", "HEAD", newHead, oldHead]);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return commits.map((commit, index) => {
|
|
143
|
+
const id = assigned[index]!;
|
|
144
|
+
const oid = needsRewrite && !dryRun ? rewrittenOids[index]! : commit.oid;
|
|
145
|
+
const { subject, body } = splitCommitMessage(commit.message);
|
|
146
|
+
return { id, oid, subject, body, remoteBranch: `bstack/${id}` };
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
remoteBranchOids(
|
|
151
|
+
remote: string,
|
|
152
|
+
branches: readonly string[],
|
|
153
|
+
): Map<string, string> {
|
|
154
|
+
if (branches.length === 0) return new Map();
|
|
155
|
+
const result = this.git([
|
|
156
|
+
"ls-remote",
|
|
157
|
+
"--heads",
|
|
158
|
+
remote,
|
|
159
|
+
...branches.map((branch) => `refs/heads/${branch}`),
|
|
160
|
+
]);
|
|
161
|
+
return new Map(
|
|
162
|
+
result.stdout
|
|
163
|
+
.split("\n")
|
|
164
|
+
.filter(Boolean)
|
|
165
|
+
.map((line) => {
|
|
166
|
+
const [oid, ref] = line.split(/\s+/, 2);
|
|
167
|
+
return [ref!.replace("refs/heads/", ""), oid!] as const;
|
|
168
|
+
}),
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
pushChanges(remote: string, changes: readonly Change[]): void {
|
|
173
|
+
const existing = this.remoteBranchOids(
|
|
174
|
+
remote,
|
|
175
|
+
changes.map((change) => change.remoteBranch),
|
|
176
|
+
);
|
|
177
|
+
const leases: string[] = [];
|
|
178
|
+
const refspecs: string[] = [];
|
|
179
|
+
for (const change of changes) {
|
|
180
|
+
const expected = existing.get(change.remoteBranch) ?? "";
|
|
181
|
+
leases.push(
|
|
182
|
+
`--force-with-lease=refs/heads/${change.remoteBranch}:${expected}`,
|
|
183
|
+
);
|
|
184
|
+
refspecs.push(`${change.oid}:refs/heads/${change.remoteBranch}`);
|
|
185
|
+
}
|
|
186
|
+
this.git(["push", "--atomic", ...leases, remote, ...refspecs]);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
statePath(): string {
|
|
190
|
+
return this.git([
|
|
191
|
+
"rev-parse",
|
|
192
|
+
"--path-format=absolute",
|
|
193
|
+
"--git-path",
|
|
194
|
+
"bstack/state.json",
|
|
195
|
+
]).stdout.trim();
|
|
196
|
+
}
|
|
197
|
+
}
|
package/src/github.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import type { CommandRunner } from "./command";
|
|
2
|
+
import type { Change, PullRequest } from "./model";
|
|
3
|
+
|
|
4
|
+
type PullRequestJson = {
|
|
5
|
+
number: number;
|
|
6
|
+
url: string;
|
|
7
|
+
state: string;
|
|
8
|
+
title: string;
|
|
9
|
+
body: string;
|
|
10
|
+
isDraft: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type StackJson = { number: number };
|
|
14
|
+
|
|
15
|
+
export interface GitHubPlatform {
|
|
16
|
+
assertReady(): void;
|
|
17
|
+
defaultBranch(): string;
|
|
18
|
+
pullRequestForBranch(branch: string): PullRequest | undefined;
|
|
19
|
+
pullRequest(number: number): PullRequest;
|
|
20
|
+
createPullRequest(change: Change, base: string, open: boolean): PullRequest;
|
|
21
|
+
linkStack(
|
|
22
|
+
branches: readonly string[],
|
|
23
|
+
base: string,
|
|
24
|
+
remote: string,
|
|
25
|
+
open: boolean,
|
|
26
|
+
): void;
|
|
27
|
+
appendToStack(
|
|
28
|
+
stackNumber: number,
|
|
29
|
+
branches: readonly string[],
|
|
30
|
+
remote: string,
|
|
31
|
+
open: boolean,
|
|
32
|
+
): void;
|
|
33
|
+
editPullRequest(pr: PullRequest, change: Change): void;
|
|
34
|
+
stackNumberForPullRequest(prNumber: number): number | undefined;
|
|
35
|
+
pullRequestHead(reference: string): string;
|
|
36
|
+
checkoutPullRequest(reference: string): void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class GhPlatform implements GitHubPlatform {
|
|
40
|
+
constructor(
|
|
41
|
+
private readonly cwd: string,
|
|
42
|
+
private readonly runner: CommandRunner,
|
|
43
|
+
) {}
|
|
44
|
+
|
|
45
|
+
private gh(args: readonly string[]) {
|
|
46
|
+
return this.runner.run(["gh", ...args], { cwd: this.cwd });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
assertReady(): void {
|
|
50
|
+
this.gh(["auth", "status", "--active"]);
|
|
51
|
+
this.gh(["stack", "--version"]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
defaultBranch(): string {
|
|
55
|
+
return this.gh([
|
|
56
|
+
"repo",
|
|
57
|
+
"view",
|
|
58
|
+
"--json",
|
|
59
|
+
"defaultBranchRef",
|
|
60
|
+
"--jq",
|
|
61
|
+
".defaultBranchRef.name",
|
|
62
|
+
]).stdout.trim();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
pullRequestForBranch(branch: string): PullRequest | undefined {
|
|
66
|
+
const raw = this.gh([
|
|
67
|
+
"pr",
|
|
68
|
+
"list",
|
|
69
|
+
"--head",
|
|
70
|
+
branch,
|
|
71
|
+
"--state",
|
|
72
|
+
"all",
|
|
73
|
+
"--limit",
|
|
74
|
+
"20",
|
|
75
|
+
"--json",
|
|
76
|
+
"number,url,state,title,body,isDraft",
|
|
77
|
+
]).stdout;
|
|
78
|
+
const candidates = JSON.parse(raw) as PullRequestJson[];
|
|
79
|
+
const selected =
|
|
80
|
+
candidates.find((pr) => pr.state === "OPEN") ??
|
|
81
|
+
candidates.find((pr) => pr.state === "MERGED");
|
|
82
|
+
return selected ? normalizePullRequest(selected) : undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
pullRequest(number: number): PullRequest {
|
|
86
|
+
const raw = this.gh([
|
|
87
|
+
"pr",
|
|
88
|
+
"view",
|
|
89
|
+
String(number),
|
|
90
|
+
"--json",
|
|
91
|
+
"number,url,state,title,body,isDraft",
|
|
92
|
+
]).stdout;
|
|
93
|
+
return normalizePullRequest(JSON.parse(raw) as PullRequestJson);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
createPullRequest(change: Change, base: string, open: boolean): PullRequest {
|
|
97
|
+
const args = [
|
|
98
|
+
"pr",
|
|
99
|
+
"create",
|
|
100
|
+
"--base",
|
|
101
|
+
base,
|
|
102
|
+
"--head",
|
|
103
|
+
change.remoteBranch,
|
|
104
|
+
"--title",
|
|
105
|
+
change.subject,
|
|
106
|
+
"--body",
|
|
107
|
+
change.body,
|
|
108
|
+
];
|
|
109
|
+
if (!open) args.push("--draft");
|
|
110
|
+
this.gh(args);
|
|
111
|
+
const created = this.pullRequestForBranch(change.remoteBranch);
|
|
112
|
+
if (!created)
|
|
113
|
+
throw new Error(
|
|
114
|
+
`GitHub did not return the PR created for ${change.remoteBranch}`,
|
|
115
|
+
);
|
|
116
|
+
return created;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
linkStack(
|
|
120
|
+
branches: readonly string[],
|
|
121
|
+
base: string,
|
|
122
|
+
remote: string,
|
|
123
|
+
open: boolean,
|
|
124
|
+
): void {
|
|
125
|
+
const args = ["stack", "link", "--base", base, "--remote", remote];
|
|
126
|
+
if (open) args.push("--open");
|
|
127
|
+
args.push(...branches);
|
|
128
|
+
this.gh(args);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
appendToStack(
|
|
132
|
+
stackNumber: number,
|
|
133
|
+
branches: readonly string[],
|
|
134
|
+
remote: string,
|
|
135
|
+
open: boolean,
|
|
136
|
+
): void {
|
|
137
|
+
const args = ["stack", "link", "--remote", remote];
|
|
138
|
+
if (open) args.push("--open");
|
|
139
|
+
args.push(String(stackNumber), ...branches);
|
|
140
|
+
this.gh(args);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
editPullRequest(pr: PullRequest, change: Change): void {
|
|
144
|
+
if (pr.title === change.subject && pr.body === change.body) return;
|
|
145
|
+
this.gh([
|
|
146
|
+
"pr",
|
|
147
|
+
"edit",
|
|
148
|
+
String(pr.number),
|
|
149
|
+
"--title",
|
|
150
|
+
change.subject,
|
|
151
|
+
"--body",
|
|
152
|
+
change.body,
|
|
153
|
+
]);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
stackNumberForPullRequest(prNumber: number): number | undefined {
|
|
157
|
+
const raw = this.gh([
|
|
158
|
+
"api",
|
|
159
|
+
`repos/{owner}/{repo}/stacks?pull_request=${prNumber}`,
|
|
160
|
+
]).stdout;
|
|
161
|
+
const stacks = JSON.parse(raw) as StackJson[];
|
|
162
|
+
return stacks[0]?.number;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
pullRequestHead(reference: string): string {
|
|
166
|
+
return this.gh([
|
|
167
|
+
"pr",
|
|
168
|
+
"view",
|
|
169
|
+
reference,
|
|
170
|
+
"--json",
|
|
171
|
+
"headRefName",
|
|
172
|
+
"--jq",
|
|
173
|
+
".headRefName",
|
|
174
|
+
]).stdout.trim();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
checkoutPullRequest(reference: string): void {
|
|
178
|
+
this.gh(["pr", "checkout", reference]);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function normalizePullRequest(pr: PullRequestJson): PullRequest {
|
|
183
|
+
if (pr.state !== "OPEN" && pr.state !== "CLOSED" && pr.state !== "MERGED") {
|
|
184
|
+
throw new Error(`GitHub returned an unknown PR state: ${pr.state}`);
|
|
185
|
+
}
|
|
186
|
+
return { ...pr, state: pr.state };
|
|
187
|
+
}
|
package/src/identity.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { Commit } from "./model";
|
|
3
|
+
|
|
4
|
+
const trailerPattern = /^Bstack-Id:\s*(\S+)\s*$/gim;
|
|
5
|
+
|
|
6
|
+
export function readChangeId(message: string): string | undefined {
|
|
7
|
+
const matches = [...message.matchAll(trailerPattern)];
|
|
8
|
+
if (matches.length > 1) {
|
|
9
|
+
throw new Error("A commit contains more than one Bstack-Id trailer");
|
|
10
|
+
}
|
|
11
|
+
return matches[0]?.[1];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function addChangeId(message: string, changeId: string): string {
|
|
15
|
+
const trimmed = message.trimEnd();
|
|
16
|
+
return `${trimmed}\n\nBstack-Id: ${changeId}\n`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function newChangeId(): string {
|
|
20
|
+
return randomUUID().replaceAll("-", "");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function parseRawCommit(oid: string, raw: string): Commit {
|
|
24
|
+
const boundary = raw.indexOf("\n\n");
|
|
25
|
+
if (boundary === -1) {
|
|
26
|
+
throw new Error(`Commit ${oid} has an invalid object format`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const headers = raw.slice(0, boundary).split("\n");
|
|
30
|
+
const treeLine = headers.find((line) => line.startsWith("tree "));
|
|
31
|
+
const parents = headers.filter((line) => line.startsWith("parent "));
|
|
32
|
+
if (!treeLine || parents.length !== 1) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`Commit ${oid} must have exactly one parent; merge and root commits are not supported`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
if (headers.some((line) => line.startsWith("gpgsig "))) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`Commit ${oid} is signed. bstack cannot add an identity trailer without replacing its signature`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const message = raw.slice(boundary + 2);
|
|
44
|
+
return {
|
|
45
|
+
oid,
|
|
46
|
+
tree: treeLine.slice("tree ".length),
|
|
47
|
+
parent: parents[0]!.slice("parent ".length),
|
|
48
|
+
message,
|
|
49
|
+
headers,
|
|
50
|
+
changeId: readChangeId(message),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function rewriteCommit(
|
|
55
|
+
commit: Commit,
|
|
56
|
+
parent: string,
|
|
57
|
+
message: string,
|
|
58
|
+
): string {
|
|
59
|
+
const rewrittenHeaders: string[] = [];
|
|
60
|
+
let replacedParent = false;
|
|
61
|
+
|
|
62
|
+
for (const header of commit.headers) {
|
|
63
|
+
if (header.startsWith("parent ")) {
|
|
64
|
+
if (!replacedParent) {
|
|
65
|
+
rewrittenHeaders.push(`parent ${parent}`);
|
|
66
|
+
replacedParent = true;
|
|
67
|
+
}
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
rewrittenHeaders.push(header);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return `${rewrittenHeaders.join("\n")}\n\n${message}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function splitCommitMessage(message: string): {
|
|
77
|
+
subject: string;
|
|
78
|
+
body: string;
|
|
79
|
+
} {
|
|
80
|
+
const withoutIdentity = message
|
|
81
|
+
.split("\n")
|
|
82
|
+
.filter((line) => !/^Bstack-Id:\s*\S+\s*$/i.test(line))
|
|
83
|
+
.join("\n")
|
|
84
|
+
.trim();
|
|
85
|
+
const [subject = "Untitled change", ...bodyLines] =
|
|
86
|
+
withoutIdentity.split("\n");
|
|
87
|
+
return { subject, body: bodyLines.join("\n").trim() };
|
|
88
|
+
}
|
package/src/model.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export type Commit = {
|
|
2
|
+
oid: string;
|
|
3
|
+
tree: string;
|
|
4
|
+
parent: string;
|
|
5
|
+
message: string;
|
|
6
|
+
headers: readonly string[];
|
|
7
|
+
changeId: string | undefined;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type Change = {
|
|
11
|
+
id: string;
|
|
12
|
+
oid: string;
|
|
13
|
+
subject: string;
|
|
14
|
+
body: string;
|
|
15
|
+
remoteBranch: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type PullRequest = {
|
|
19
|
+
number: number;
|
|
20
|
+
url: string;
|
|
21
|
+
state: "OPEN" | "CLOSED" | "MERGED";
|
|
22
|
+
title: string;
|
|
23
|
+
body: string;
|
|
24
|
+
isDraft: boolean;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type StoredChange = {
|
|
28
|
+
id: string;
|
|
29
|
+
remoteBranch: string;
|
|
30
|
+
pullRequest: number;
|
|
31
|
+
url: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type StoredStack = {
|
|
35
|
+
remote: string;
|
|
36
|
+
base: string;
|
|
37
|
+
stackNumber?: number;
|
|
38
|
+
changes: StoredChange[];
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type BstackState = {
|
|
42
|
+
schemaVersion: 1;
|
|
43
|
+
stacks: StoredStack[];
|
|
44
|
+
};
|
package/src/reporter.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface Reporter {
|
|
2
|
+
progress(message: string): void;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export class ConsoleReporter implements Reporter {
|
|
6
|
+
constructor(private readonly enabled = true) {}
|
|
7
|
+
|
|
8
|
+
progress(message: string): void {
|
|
9
|
+
if (this.enabled) process.stderr.write(`[bstack] ${message}\n`);
|
|
10
|
+
}
|
|
11
|
+
}
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import type { BstackState, StoredStack } from "./model";
|
|
4
|
+
|
|
5
|
+
const emptyState = (): BstackState => ({ schemaVersion: 1, stacks: [] });
|
|
6
|
+
|
|
7
|
+
export class StateStore {
|
|
8
|
+
constructor(private readonly path: string) {}
|
|
9
|
+
|
|
10
|
+
read(): BstackState {
|
|
11
|
+
try {
|
|
12
|
+
const parsed = JSON.parse(readFileSync(this.path, "utf8")) as unknown;
|
|
13
|
+
if (!isState(parsed))
|
|
14
|
+
throw new Error(`Unsupported bstack state in ${this.path}`);
|
|
15
|
+
return parsed;
|
|
16
|
+
} catch (error) {
|
|
17
|
+
if (isMissingFile(error)) return emptyState();
|
|
18
|
+
throw error;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
write(state: BstackState): void {
|
|
23
|
+
mkdirSync(dirname(this.path), { recursive: true });
|
|
24
|
+
const temporary = `${this.path}.tmp`;
|
|
25
|
+
writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`);
|
|
26
|
+
renameSync(temporary, this.path);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
findByChangeIds(
|
|
30
|
+
state: BstackState,
|
|
31
|
+
ids: ReadonlySet<string>,
|
|
32
|
+
): StoredStack | undefined {
|
|
33
|
+
const matches = state.stacks.filter((stack) =>
|
|
34
|
+
stack.changes.some((change) => ids.has(change.id)),
|
|
35
|
+
);
|
|
36
|
+
if (matches.length > 1)
|
|
37
|
+
throw new Error(
|
|
38
|
+
"The current commits match more than one stored bstack stack",
|
|
39
|
+
);
|
|
40
|
+
return matches[0];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isMissingFile(error: unknown): boolean {
|
|
45
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isState(value: unknown): value is BstackState {
|
|
49
|
+
if (typeof value !== "object" || value === null) return false;
|
|
50
|
+
const candidate = value as { schemaVersion?: unknown; stacks?: unknown };
|
|
51
|
+
return candidate.schemaVersion === 1 && Array.isArray(candidate.stacks);
|
|
52
|
+
}
|
package/src/sync.ts
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import type { GitRepository } from "./git";
|
|
2
|
+
import type { GitHubPlatform } from "./github";
|
|
3
|
+
import type { BstackState, Change, PullRequest, StoredStack } from "./model";
|
|
4
|
+
import type { Reporter } from "./reporter";
|
|
5
|
+
import { StateStore } from "./state";
|
|
6
|
+
|
|
7
|
+
export type SyncOptions = {
|
|
8
|
+
base: string | undefined;
|
|
9
|
+
remote: string | undefined;
|
|
10
|
+
open: boolean;
|
|
11
|
+
dryRun: boolean;
|
|
12
|
+
reporter: Reporter;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type SyncResult = {
|
|
16
|
+
base: string;
|
|
17
|
+
remote: string;
|
|
18
|
+
rewritten: boolean;
|
|
19
|
+
changes: Array<Change & { pullRequest?: PullRequest }>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function syncStack(
|
|
23
|
+
repository: GitRepository,
|
|
24
|
+
github: GitHubPlatform,
|
|
25
|
+
options: SyncOptions,
|
|
26
|
+
): SyncResult {
|
|
27
|
+
const { reporter } = options;
|
|
28
|
+
reporter.progress("Checking the repository and GitHub prerequisites");
|
|
29
|
+
repository.assertReady();
|
|
30
|
+
github.assertReady();
|
|
31
|
+
const remote = repository.resolveRemote(options.remote);
|
|
32
|
+
const base = options.base ?? github.defaultBranch();
|
|
33
|
+
reporter.progress(
|
|
34
|
+
`Using ${remote} as the remote and ${base} as the stack base`,
|
|
35
|
+
);
|
|
36
|
+
reporter.progress(`Fetching ${remote}/${base}`);
|
|
37
|
+
const remoteBase = repository.fetchBase(remote, base);
|
|
38
|
+
const baseOid = repository.mergeBase("HEAD", remoteBase);
|
|
39
|
+
const commits = repository.commitsSince(baseOid);
|
|
40
|
+
if (commits.length === 0)
|
|
41
|
+
throw new Error(`No commits found between ${base} and HEAD`);
|
|
42
|
+
reporter.progress(
|
|
43
|
+
`Found ${commits.length} local change${commits.length === 1 ? "" : "s"}`,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const rewritten = commits.some((commit) => commit.changeId === undefined);
|
|
47
|
+
if (rewritten) {
|
|
48
|
+
reporter.progress(
|
|
49
|
+
options.dryRun
|
|
50
|
+
? "Stable change IDs would be added to the commits"
|
|
51
|
+
: "Adding stable change IDs to the commits",
|
|
52
|
+
);
|
|
53
|
+
} else {
|
|
54
|
+
reporter.progress("All commits already have stable change IDs");
|
|
55
|
+
}
|
|
56
|
+
const changes = repository.ensureChangeIds(commits, options.dryRun);
|
|
57
|
+
if (options.dryRun) {
|
|
58
|
+
reporter.progress(
|
|
59
|
+
"Dry run complete; no commits or remote branches were changed",
|
|
60
|
+
);
|
|
61
|
+
return { base, remote, rewritten, changes };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
reporter.progress("Reading the previous stack state");
|
|
65
|
+
const store = new StateStore(repository.statePath());
|
|
66
|
+
const state = store.read();
|
|
67
|
+
const previous = store.findByChangeIds(
|
|
68
|
+
state,
|
|
69
|
+
new Set(changes.map((change) => change.id)),
|
|
70
|
+
);
|
|
71
|
+
const evolution = analyzeEvolution(previous, changes, github);
|
|
72
|
+
|
|
73
|
+
reporter.progress(
|
|
74
|
+
`Publishing ${changes.length} protected remote ref${changes.length === 1 ? "" : "s"}`,
|
|
75
|
+
);
|
|
76
|
+
repository.pushChanges(remote, changes);
|
|
77
|
+
|
|
78
|
+
reporter.progress("Looking up existing pull requests");
|
|
79
|
+
const existing = changes.map((change) =>
|
|
80
|
+
github.pullRequestForBranch(change.remoteBranch),
|
|
81
|
+
);
|
|
82
|
+
let pullRequests: PullRequest[];
|
|
83
|
+
if (changes.length === 1) {
|
|
84
|
+
if (evolution.kind === "partial") {
|
|
85
|
+
reporter.progress(
|
|
86
|
+
"Updating this down-stack prefix while preserving higher pull requests",
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
reporter.progress(
|
|
90
|
+
existing[0]
|
|
91
|
+
? "Using the existing pull request"
|
|
92
|
+
: "Creating a pull request",
|
|
93
|
+
);
|
|
94
|
+
pullRequests = [
|
|
95
|
+
existing[0] ?? github.createPullRequest(changes[0]!, base, options.open),
|
|
96
|
+
];
|
|
97
|
+
} else {
|
|
98
|
+
if (evolution.kind === "full") {
|
|
99
|
+
reporter.progress(
|
|
100
|
+
`Linking ${changes.length} pull requests as a native GitHub stack`,
|
|
101
|
+
);
|
|
102
|
+
github.linkStack(
|
|
103
|
+
changes.map((change) => change.remoteBranch),
|
|
104
|
+
base,
|
|
105
|
+
remote,
|
|
106
|
+
options.open,
|
|
107
|
+
);
|
|
108
|
+
} else if (evolution.kind === "append") {
|
|
109
|
+
reporter.progress(
|
|
110
|
+
`Appending ${evolution.branches.length} pull request${evolution.branches.length === 1 ? "" : "s"} to stack #${evolution.stackNumber}`,
|
|
111
|
+
);
|
|
112
|
+
github.appendToStack(
|
|
113
|
+
evolution.stackNumber,
|
|
114
|
+
evolution.branches,
|
|
115
|
+
remote,
|
|
116
|
+
options.open,
|
|
117
|
+
);
|
|
118
|
+
} else if (evolution.kind === "partial") {
|
|
119
|
+
reporter.progress(
|
|
120
|
+
"Updating this down-stack prefix while preserving higher pull requests",
|
|
121
|
+
);
|
|
122
|
+
} else {
|
|
123
|
+
reporter.progress(
|
|
124
|
+
"The native GitHub stack already has the correct members",
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
pullRequests = changes.map((change, index) => {
|
|
128
|
+
const pr =
|
|
129
|
+
existing[index] ?? github.pullRequestForBranch(change.remoteBranch);
|
|
130
|
+
if (!pr)
|
|
131
|
+
throw new Error(
|
|
132
|
+
`GitHub did not return a PR for ${change.remoteBranch}`,
|
|
133
|
+
);
|
|
134
|
+
return pr;
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
reporter.progress("Synchronizing pull request titles and descriptions");
|
|
139
|
+
for (const [index, pr] of pullRequests.entries()) {
|
|
140
|
+
github.editPullRequest(pr, changes[index]!);
|
|
141
|
+
reporter.progress(`PR #${pr.number}: ${changes[index]!.subject}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const stackNumber =
|
|
145
|
+
previous?.stackNumber ??
|
|
146
|
+
(pullRequests.length > 1
|
|
147
|
+
? github.stackNumberForPullRequest(pullRequests[0]!.number)
|
|
148
|
+
: undefined);
|
|
149
|
+
const synchronizedChanges = changes.map((change, index) => ({
|
|
150
|
+
id: change.id,
|
|
151
|
+
remoteBranch: change.remoteBranch,
|
|
152
|
+
pullRequest: pullRequests[index]!.number,
|
|
153
|
+
url: pullRequests[index]!.url,
|
|
154
|
+
}));
|
|
155
|
+
const storedChanges =
|
|
156
|
+
evolution.kind === "partial" && previous
|
|
157
|
+
? [
|
|
158
|
+
...synchronizedChanges,
|
|
159
|
+
...previous.changes.slice(evolution.previousOffset + changes.length),
|
|
160
|
+
]
|
|
161
|
+
: synchronizedChanges;
|
|
162
|
+
const stored: StoredStack = {
|
|
163
|
+
remote,
|
|
164
|
+
base,
|
|
165
|
+
...(stackNumber === undefined ? {} : { stackNumber }),
|
|
166
|
+
changes: storedChanges,
|
|
167
|
+
};
|
|
168
|
+
writeUpdatedState(store, state, previous, stored);
|
|
169
|
+
reporter.progress("Saved the local stack state");
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
base,
|
|
173
|
+
remote,
|
|
174
|
+
rewritten,
|
|
175
|
+
changes: changes.map((change, index) => ({
|
|
176
|
+
...change,
|
|
177
|
+
pullRequest: pullRequests[index]!,
|
|
178
|
+
})),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
type Evolution =
|
|
183
|
+
| { kind: "full" }
|
|
184
|
+
| { kind: "skip" }
|
|
185
|
+
| { kind: "partial"; previousOffset: number }
|
|
186
|
+
| { kind: "append"; stackNumber: number; branches: string[] };
|
|
187
|
+
|
|
188
|
+
function analyzeEvolution(
|
|
189
|
+
previous: StoredStack | undefined,
|
|
190
|
+
changes: readonly Change[],
|
|
191
|
+
github: GitHubPlatform,
|
|
192
|
+
): Evolution {
|
|
193
|
+
if (!previous) return { kind: "full" };
|
|
194
|
+
const previousIds = previous.changes.map((change) => change.id);
|
|
195
|
+
const currentIds = changes.map((change) => change.id);
|
|
196
|
+
|
|
197
|
+
const firstCurrentIndex = previousIds.indexOf(currentIds[0]!);
|
|
198
|
+
if (firstCurrentIndex === -1) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
"The current commits do not continue the previously submitted stack",
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const removedPrefix = previous.changes.slice(0, firstCurrentIndex);
|
|
205
|
+
for (const removed of removedPrefix) {
|
|
206
|
+
if (github.pullRequest(removed.pullRequest).state !== "MERGED") {
|
|
207
|
+
throw new Error(
|
|
208
|
+
"Submitted commits may only disappear from the bottom after their pull requests are merged",
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const surviving = previousIds.slice(firstCurrentIndex);
|
|
214
|
+
const sharedLength = Math.min(surviving.length, currentIds.length);
|
|
215
|
+
for (let index = 0; index < sharedLength; index++) {
|
|
216
|
+
if (surviving[index] !== currentIds[index]) {
|
|
217
|
+
throw new Error(
|
|
218
|
+
"Reordering or removing submitted commits is not supported yet. Restore the original order before syncing",
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (currentIds.length < surviving.length) {
|
|
223
|
+
return { kind: "partial", previousOffset: firstCurrentIndex };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const appended = changes.slice(surviving.length);
|
|
227
|
+
if (removedPrefix.length === 0) return { kind: "full" };
|
|
228
|
+
if (appended.length === 0) return { kind: "skip" };
|
|
229
|
+
if (previous.stackNumber === undefined) {
|
|
230
|
+
throw new Error(
|
|
231
|
+
"Cannot append after a merge because the native GitHub stack number is missing from local state",
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
kind: "append",
|
|
236
|
+
stackNumber: previous.stackNumber,
|
|
237
|
+
branches: appended.map((change) => change.remoteBranch),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function writeUpdatedState(
|
|
242
|
+
store: StateStore,
|
|
243
|
+
state: BstackState,
|
|
244
|
+
previous: StoredStack | undefined,
|
|
245
|
+
updated: StoredStack,
|
|
246
|
+
): void {
|
|
247
|
+
const stacks = previous
|
|
248
|
+
? state.stacks.map((stack) => (stack === previous ? updated : stack))
|
|
249
|
+
: [...state.stacks, updated];
|
|
250
|
+
store.write({ schemaVersion: 1, stacks });
|
|
251
|
+
}
|