bstack 0.0.0 → 0.2.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/package.json CHANGED
@@ -1,37 +1,42 @@
1
1
  {
2
2
  "name": "bstack",
3
- "version": "0.0.0",
3
+ "version": "0.2.0",
4
+ "packageManager": "pnpm@11.5.1",
4
5
  "description": "Create native GitHub stacked pull requests from a linear series of commits",
5
6
  "repository": {
6
7
  "type": "git",
7
- "url": "https://github.com/wsehl/bstack.git"
8
+ "url": "git+https://github.com/wsehl/bstack.git"
8
9
  },
9
10
  "files": [
10
- "src"
11
+ "dist/bstack.js"
11
12
  ],
12
13
  "bin": {
13
- "bstack": "./src/cli.ts"
14
+ "bstack": "./dist/bstack.js"
14
15
  },
15
16
  "engines": {
16
- "bun": ">=1.3.0"
17
+ "node": ">=20"
17
18
  },
18
19
  "publishConfig": {
19
20
  "access": "public"
20
21
  },
21
22
  "type": "module",
22
23
  "scripts": {
23
- "build": "bun build ./src/cli.ts --compile --outfile ./dist/bstack",
24
+ "build": "tsdown",
24
25
  "format": "oxfmt --write .",
25
26
  "format:check": "oxfmt --check .",
26
27
  "lint": "oxlint .",
27
- "start": "bun run src/cli.ts",
28
- "test": "bun test",
28
+ "prepack": "pnpm run build",
29
+ "prepublishOnly": "pnpm run type-check && pnpm test",
30
+ "start": "pnpm run build && node dist/bstack.js",
31
+ "test": "vitest run",
29
32
  "type-check": "tsc --noEmit"
30
33
  },
31
34
  "devDependencies": {
32
- "@types/bun": "^1.4.0",
35
+ "@types/node": "^26.0.0",
33
36
  "oxfmt": "^0.64.0",
34
37
  "oxlint": "^1.79.0",
35
- "typescript": "^7.0.2"
38
+ "tsdown": "^0.22.14",
39
+ "typescript": "^7.0.2",
40
+ "vitest": "^4.0.0"
36
41
  }
37
42
  }
package/src/checkout.ts DELETED
@@ -1,67 +0,0 @@
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 DELETED
@@ -1,100 +0,0 @@
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 DELETED
@@ -1,61 +0,0 @@
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 DELETED
@@ -1,197 +0,0 @@
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 DELETED
@@ -1,187 +0,0 @@
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
- }