bstack 1.0.3 → 1.1.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.
Files changed (3) hide show
  1. package/README.md +58 -7
  2. package/dist/bstack.js +243 -184
  3. package/package.json +13 -13
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 `github/gh-stack` extension:
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
- ## Use
18
+ ## How to use
19
19
 
20
- Create one commit per change, then sync the stack:
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
- Run `bstack` again after amending or rebasing commits.
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
- New PRs are ready for review by default. Pass `--draft` to create draft PRs.
43
+ ### Add another pull request
32
44
 
33
- Checkout a stack through one of its PRs:
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
- Use `--dry-run` to inspect without syncing and `--quiet` to hide progress logs.
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 { z } from "zod";
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.0.3";
9
+ var version = "1.1.0";
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(repository, github, options) {
47
- const { reporter } = options;
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 GitRepository = class {
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
- ensureChangeIds(commits, dryRun, userLogin) {
237
- const assigned = commits.map((commit) => commit.changeId ?? generateChangeId());
238
- const needsRewrite = commits.some((commit) => commit.changeId === void 0);
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
- if (needsRewrite && !dryRun) {
242
- for (const [index, commit] of commits.entries()) {
243
- const changeId = assigned[index];
244
- const message = commit.changeId ? commit.message : addChangeId(commit.message, changeId);
245
- const raw = rewriteCommit(commit, parent, message);
246
- const oid = this.git([
247
- "hash-object",
248
- "-t",
249
- "commit",
250
- "-w",
251
- "--stdin"
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
- return commits.map((commit, index) => {
266
- const id = assigned[index];
267
- const oid = needsRewrite && !dryRun ? rewrittenOids[index] : commit.oid;
268
- const { subject, body } = splitCommitMessage(commit.message);
269
- return {
270
- id,
271
- oid,
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 new Map(result.stdout.split("\n").filter(Boolean).map((line) => {
287
- const [oid, ref] = line.split(/\s+/, 2);
288
- return [ref.replace("refs/heads/", ""), oid];
289
- }));
261
+ return rewrittenOids;
290
262
  }
291
- pushChanges(remote, changes) {
292
- const existing = this.remoteBranchOids(remote, changes.map((change) => change.remoteBranch));
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 change of changes) {
296
- const expected = existing.get(change.remoteBranch) ?? "";
297
- leases.push(`--force-with-lease=refs/heads/${change.remoteBranch}:${expected}`);
298
- refspecs.push(`${change.oid}:refs/heads/${change.remoteBranch}`);
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 = z.object({
320
- number: z.number(),
321
- url: z.string(),
322
- state: z.enum([
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: z.string(),
328
- body: z.string(),
329
- isDraft: z.boolean()
323
+ title: v.string(),
324
+ body: v.string(),
325
+ isDraft: v.boolean()
330
326
  });
331
- const stackSchema = z.object({ number: z.number() });
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
- return z.string().min(1).parse(this.gh([
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 = pullRequestSchema.array().parse(JSON.parse(raw));
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 pullRequestSchema.parse(JSON.parse(raw));
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 stackSchema.array().parse(JSON.parse(raw))[0]?.number;
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
- if (this.enabled) process.stderr.write(`[bstack] ${message}\n`);
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 = z.object({
506
- id: z.string(),
507
- remoteBranch: z.string(),
508
- pullRequest: z.number(),
509
- url: z.string()
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 = z.object({
512
- remote: z.string(),
513
- base: z.string(),
514
- stackNumber: z.number().optional(),
515
- changes: storedChangeSchema.array()
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 = z.object({
518
- schemaVersion: z.literal(1),
519
- stacks: storedStackSchema.array()
513
+ const stateSchema = v.object({
514
+ schemaVersion: v.literal(1),
515
+ stacks: v.array(storedStackSchema)
520
516
  });
521
- var StateStore = class {
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: stateSchema.parse(JSON.parse(readFileSync(this.path, "utf8"))).stacks.map((stack) => {
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 instanceof z.ZodError) throw new Error(`Unsupported bstack state in ${this.path}`, { cause: 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
- findByChangeIds(state, ids) {
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(repository, github, options) {
561
- const { reporter } = options;
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 rewritten = commits.some((commit) => commit.changeId === void 0);
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 changes = repository.ensureChangeIds(commits, options.dryRun, userLogin);
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 store = new StateStore(repository.statePath());
591
- const state = store.read();
592
- const previous = store.findByChangeIds(state, new Set(changes.map((change) => change.id)));
593
- const transition = analyzeStackTransition(previous, changes, github, repository.currentBranch() === "");
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.pushChanges(remote, changes);
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(store, state, previous, updatedStack);
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
- --quiet Hide progress logs; the final summary is still printed
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
- quiet: {
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 runner = new NodeCommandRunner();
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 GitRepository(cwd, runner);
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(repository, github, {
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(repository, github, {
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,29 +1,25 @@
1
1
  {
2
2
  "name": "bstack",
3
- "version": "1.0.3",
4
- "packageManager": "pnpm@11.5.1",
3
+ "version": "1.1.0",
5
4
  "description": "Create native GitHub stacked pull requests from a linear series of commits",
6
5
  "repository": {
7
6
  "type": "git",
8
7
  "url": "git+https://github.com/wsehl/bstack.git"
9
8
  },
10
- "files": [
11
- "dist/bstack.js"
12
- ],
13
9
  "bin": {
14
10
  "bstack": "./dist/bstack.js"
15
11
  },
16
- "engines": {
17
- "node": ">=20"
18
- },
12
+ "files": [
13
+ "dist/bstack.js"
14
+ ],
15
+ "type": "module",
19
16
  "publishConfig": {
20
17
  "access": "public"
21
18
  },
22
- "type": "module",
23
19
  "scripts": {
24
20
  "build": "tsdown",
25
21
  "format": "oxfmt --write .",
26
- "format:check": "oxfmt --check .",
22
+ "format-check": "oxfmt --check .",
27
23
  "lint": "oxlint .",
28
24
  "prepack": "pnpm run build",
29
25
  "prepublishOnly": "pnpm run type-check && pnpm test",
@@ -31,6 +27,9 @@
31
27
  "test": "vitest run",
32
28
  "type-check": "tsc --noEmit"
33
29
  },
30
+ "dependencies": {
31
+ "valibot": "^1.4.2"
32
+ },
34
33
  "devDependencies": {
35
34
  "@types/node": "^26.0.0",
36
35
  "oxfmt": "^0.64.0",
@@ -39,7 +38,8 @@
39
38
  "typescript": "^7.0.2",
40
39
  "vitest": "^4.0.0"
41
40
  },
42
- "dependencies": {
43
- "zod": "^4.4.3"
44
- }
41
+ "engines": {
42
+ "node": ">=20"
43
+ },
44
+ "packageManager": "pnpm@11.5.1"
45
45
  }