bstack 0.3.0 → 1.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.
Files changed (2) hide show
  1. package/dist/bstack.js +143 -73
  2. package/package.json +4 -1
package/dist/bstack.js CHANGED
@@ -2,10 +2,11 @@
2
2
  import { parseArgs } from "node:util";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { randomUUID } from "node:crypto";
5
+ import { z } from "zod";
5
6
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
6
7
  import { dirname } from "node:path";
7
8
  //#region package.json
8
- var version = "0.3.0";
9
+ var version = "1.0.0";
9
10
  //#endregion
10
11
  //#region src/command.ts
11
12
  var CommandError = class extends Error {
@@ -82,14 +83,14 @@ function checkoutStack(repository, github, options) {
82
83
  }
83
84
  //#endregion
84
85
  //#region src/identity.ts
85
- const trailerPattern = /^Bstack-Id:\s*(\S+)\s*$/gim;
86
+ const trailerPattern = /^bstack-id:\s*(\S+)\s*$/gm;
86
87
  function readChangeId(message) {
87
88
  const matches = [...message.matchAll(trailerPattern)];
88
- if (matches.length > 1) throw new Error("A commit contains more than one Bstack-Id trailer");
89
+ if (matches.length > 1) throw new Error("A commit contains more than one bstack-id trailer");
89
90
  return matches[0]?.[1];
90
91
  }
91
92
  function addChangeId(message, changeId) {
92
- return `${message.trimEnd()}\n\nBstack-Id: ${changeId}\n`;
93
+ return `${message.trimEnd()}\n\nbstack-id: ${changeId}\n`;
93
94
  }
94
95
  function newChangeId() {
95
96
  return randomUUID().replaceAll("-", "");
@@ -128,7 +129,7 @@ function rewriteCommit(commit, parent, message) {
128
129
  return `${rewrittenHeaders.join("\n")}\n\n${message}`;
129
130
  }
130
131
  function splitCommitMessage(message) {
131
- const [subject = "Untitled change", ...bodyLines] = message.split("\n").filter((line) => !/^Bstack-Id:\s*\S+\s*$/i.test(line)).join("\n").trim().split("\n");
132
+ const [subject = "Untitled change", ...bodyLines] = message.split("\n").filter((line) => !/^bstack-id:\s*\S+\s*$/.test(line)).join("\n").trim().split("\n");
132
133
  return {
133
134
  subject,
134
135
  body: bodyLines.join("\n").trim()
@@ -230,7 +231,7 @@ var GitRepository = class {
230
231
  oid
231
232
  ]).stdout));
232
233
  }
233
- ensureChangeIds(commits, dryRun) {
234
+ ensureChangeIds(commits, dryRun, userLogin) {
234
235
  const assigned = commits.map((commit) => commit.changeId ?? newChangeId());
235
236
  const needsRewrite = commits.some((commit) => commit.changeId === void 0);
236
237
  let parent = commits[0]?.parent;
@@ -268,7 +269,7 @@ var GitRepository = class {
268
269
  oid,
269
270
  subject,
270
271
  body,
271
- remoteBranch: `bstack/${id}`
272
+ remoteBranch: `bstack/${userLogin}/${id}`
272
273
  };
273
274
  });
274
275
  }
@@ -313,6 +314,19 @@ var GitRepository = class {
313
314
  };
314
315
  //#endregion
315
316
  //#region src/github.ts
317
+ const pullRequestSchema = z.object({
318
+ number: z.number(),
319
+ url: z.string(),
320
+ state: z.enum([
321
+ "OPEN",
322
+ "CLOSED",
323
+ "MERGED"
324
+ ]),
325
+ title: z.string(),
326
+ body: z.string(),
327
+ isDraft: z.boolean()
328
+ });
329
+ const stackSchema = z.object({ number: z.number() });
316
330
  var GhPlatform = class {
317
331
  cwd;
318
332
  runner;
@@ -331,6 +345,14 @@ var GhPlatform = class {
331
345
  ]);
332
346
  this.gh(["stack", "--version"]);
333
347
  }
348
+ currentUserLogin() {
349
+ return z.string().min(1).parse(this.gh([
350
+ "api",
351
+ "user",
352
+ "--jq",
353
+ ".login"
354
+ ]).stdout.trim());
355
+ }
334
356
  defaultBranch() {
335
357
  return this.gh([
336
358
  "repo",
@@ -354,9 +376,8 @@ var GhPlatform = class {
354
376
  "--json",
355
377
  "number,url,state,title,body,isDraft"
356
378
  ]).stdout;
357
- const candidates = JSON.parse(raw);
358
- const selected = candidates.find((pr) => pr.state === "OPEN") ?? candidates.find((pr) => pr.state === "MERGED");
359
- return selected ? normalizePullRequest(selected) : void 0;
379
+ const candidates = pullRequestSchema.array().parse(JSON.parse(raw));
380
+ return candidates.find((pr) => pr.state === "OPEN") ?? candidates.find((pr) => pr.state === "MERGED");
360
381
  }
361
382
  pullRequest(number) {
362
383
  const raw = this.gh([
@@ -366,7 +387,7 @@ var GhPlatform = class {
366
387
  "--json",
367
388
  "number,url,state,title,body,isDraft"
368
389
  ]).stdout;
369
- return normalizePullRequest(JSON.parse(raw));
390
+ return pullRequestSchema.parse(JSON.parse(raw));
370
391
  }
371
392
  createPullRequest(change, base, draft) {
372
393
  const args = [
@@ -418,6 +439,15 @@ var GhPlatform = class {
418
439
  String(stackNumber)
419
440
  ]);
420
441
  }
442
+ editPullRequestBase(pr, base) {
443
+ this.gh([
444
+ "pr",
445
+ "edit",
446
+ String(pr.number),
447
+ "--base",
448
+ base
449
+ ]);
450
+ }
421
451
  editPullRequest(pr, change) {
422
452
  if (pr.title === change.subject && pr.body === change.body) return;
423
453
  this.gh([
@@ -432,7 +462,7 @@ var GhPlatform = class {
432
462
  }
433
463
  stackNumberForPullRequest(prNumber) {
434
464
  const raw = this.gh(["api", `repos/{owner}/{repo}/stacks?pull_request=${prNumber}`]).stdout;
435
- return JSON.parse(raw)[0]?.number;
465
+ return stackSchema.array().parse(JSON.parse(raw))[0]?.number;
436
466
  }
437
467
  pullRequestHead(reference) {
438
468
  return this.gh([
@@ -453,13 +483,6 @@ var GhPlatform = class {
453
483
  ]);
454
484
  }
455
485
  };
456
- function normalizePullRequest(pr) {
457
- if (pr.state !== "OPEN" && pr.state !== "CLOSED" && pr.state !== "MERGED") throw new Error(`GitHub returned an unknown PR state: ${pr.state}`);
458
- return {
459
- ...pr,
460
- state: pr.state
461
- };
462
- }
463
486
  //#endregion
464
487
  //#region src/reporter.ts
465
488
  var ConsoleReporter = class {
@@ -477,6 +500,22 @@ const emptyState = () => ({
477
500
  schemaVersion: 1,
478
501
  stacks: []
479
502
  });
503
+ const storedChangeSchema = z.object({
504
+ id: z.string(),
505
+ remoteBranch: z.string(),
506
+ pullRequest: z.number(),
507
+ url: z.string()
508
+ });
509
+ const storedStackSchema = z.object({
510
+ remote: z.string(),
511
+ base: z.string(),
512
+ stackNumber: z.number().optional(),
513
+ changes: storedChangeSchema.array()
514
+ });
515
+ const stateSchema = z.object({
516
+ schemaVersion: z.literal(1),
517
+ stacks: storedStackSchema.array()
518
+ });
480
519
  var StateStore = class {
481
520
  path;
482
521
  constructor(path) {
@@ -484,11 +523,21 @@ var StateStore = class {
484
523
  }
485
524
  read() {
486
525
  try {
487
- const parsed = JSON.parse(readFileSync(this.path, "utf8"));
488
- if (!isState(parsed)) throw new Error(`Unsupported bstack state in ${this.path}`);
489
- return parsed;
526
+ return {
527
+ schemaVersion: 1,
528
+ stacks: stateSchema.parse(JSON.parse(readFileSync(this.path, "utf8"))).stacks.map((stack) => {
529
+ const stored = {
530
+ remote: stack.remote,
531
+ base: stack.base,
532
+ changes: stack.changes
533
+ };
534
+ if (stack.stackNumber !== void 0) stored.stackNumber = stack.stackNumber;
535
+ return stored;
536
+ })
537
+ };
490
538
  } catch (error) {
491
- if (isMissingFile(error)) return emptyState();
539
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return emptyState();
540
+ if (error instanceof z.ZodError) throw new Error(`Unsupported bstack state in ${this.path}`, { cause: error });
492
541
  throw error;
493
542
  }
494
543
  }
@@ -504,14 +553,6 @@ var StateStore = class {
504
553
  return matches[0];
505
554
  }
506
555
  };
507
- function isMissingFile(error) {
508
- return error instanceof Error && "code" in error && error.code === "ENOENT";
509
- }
510
- function isState(value) {
511
- if (typeof value !== "object" || value === null) return false;
512
- const candidate = value;
513
- return candidate.schemaVersion === 1 && Array.isArray(candidate.stacks);
514
- }
515
556
  //#endregion
516
557
  //#region src/sync.ts
517
558
  function syncStack(repository, github, options) {
@@ -521,7 +562,9 @@ function syncStack(repository, github, options) {
521
562
  github.assertReady();
522
563
  const remote = repository.resolveRemote(options.remote);
523
564
  const base = options.base ?? github.defaultBranch();
565
+ const userLogin = github.currentUserLogin();
524
566
  reporter.progress(`Using ${remote} as the remote and ${base} as the stack base`);
567
+ reporter.progress(`Using ${userLogin} as the remote branch namespace`);
525
568
  reporter.progress(`Fetching ${remote}/${base}`);
526
569
  const remoteBase = repository.fetchBase(remote, base);
527
570
  const baseOid = repository.mergeBase("HEAD", remoteBase);
@@ -531,7 +574,7 @@ function syncStack(repository, github, options) {
531
574
  const rewritten = commits.some((commit) => commit.changeId === void 0);
532
575
  if (rewritten) reporter.progress(options.dryRun ? "Stable change IDs would be added to the commits" : "Adding stable change IDs to the commits");
533
576
  else reporter.progress("All commits already have stable change IDs");
534
- const changes = repository.ensureChangeIds(commits, options.dryRun);
577
+ const changes = repository.ensureChangeIds(commits, options.dryRun, userLogin);
535
578
  if (options.dryRun) {
536
579
  reporter.progress("Dry run complete; no commits or remote branches were changed");
537
580
  return {
@@ -545,7 +588,7 @@ function syncStack(repository, github, options) {
545
588
  const store = new StateStore(repository.statePath());
546
589
  const state = store.read();
547
590
  const previous = store.findByChangeIds(state, new Set(changes.map((change) => change.id)));
548
- const evolution = analyzeEvolution(previous, changes, github);
591
+ const evolution = analyzeEvolution(previous, changes, github, repository.currentBranch() === "");
549
592
  reporter.progress(`Publishing ${changes.length} protected remote ref${changes.length === 1 ? "" : "s"}`);
550
593
  repository.pushChanges(remote, changes);
551
594
  reporter.progress("Looking up existing pull requests");
@@ -554,24 +597,28 @@ function syncStack(repository, github, options) {
554
597
  if (changes.length === 1) {
555
598
  if (evolution.kind === "partial") reporter.progress("Updating this down-stack prefix while preserving higher pull requests");
556
599
  reporter.progress(existing[0] ? "Using the existing pull request" : "Creating a pull request");
557
- pullRequests = [existing[0] ?? github.createPullRequest(changes[0], base, options.draft)];
600
+ const pullRequest = existing[0] ?? github.createPullRequest(changes[0], base, options.draft);
601
+ if (evolution.kind === "collapse") {
602
+ reporter.progress(`Removing omitted pull requests from stack #${evolution.stackNumber}`);
603
+ github.unstack(evolution.stackNumber);
604
+ try {
605
+ github.editPullRequestBase(pullRequest, base);
606
+ } catch (error) {
607
+ restorePreviousStack(github, previous, base, remote, reporter, error);
608
+ }
609
+ }
610
+ pullRequests = [pullRequest];
558
611
  } else {
559
612
  if (evolution.kind === "full") {
560
613
  reporter.progress(`Linking ${changes.length} pull requests as a native GitHub stack`);
561
614
  github.linkStack(changes.map((change) => change.remoteBranch), base, remote, options.draft);
562
615
  } else if (evolution.kind === "rebuild") {
563
- reporter.progress(`Rebuilding stack #${evolution.stackNumber} to insert pull requests`);
616
+ reporter.progress(`Rebuilding stack #${evolution.stackNumber} to ${evolution.action} pull requests`);
564
617
  github.unstack(evolution.stackNumber);
565
618
  try {
566
619
  github.linkStack(changes.map((change) => change.remoteBranch), base, remote, options.draft);
567
620
  } catch (error) {
568
- reporter.progress("Rebuild failed; restoring the previous native GitHub stack");
569
- try {
570
- github.linkStack(previous.changes.map((change) => change.remoteBranch), base, remote, true);
571
- } catch (rollbackError) {
572
- throw new Error(`Stack rebuild failed: ${errorMessage(error)}\nRestoring the previous stack also failed: ${errorMessage(rollbackError)}`);
573
- }
574
- throw error;
621
+ restorePreviousStack(github, previous, base, remote, reporter, error);
575
622
  }
576
623
  } else if (evolution.kind === "append") {
577
624
  reporter.progress(`Appending ${evolution.branches.length} pull request${evolution.branches.length === 1 ? "" : "s"} to stack #${evolution.stackNumber}`);
@@ -589,20 +636,20 @@ function syncStack(repository, github, options) {
589
636
  github.editPullRequest(pr, changes[index]);
590
637
  reporter.progress(`PR #${pr.number}: ${changes[index].subject}`);
591
638
  }
592
- const stackNumber = evolution.kind === "rebuild" ? github.stackNumberForPullRequest(pullRequests[0].number) : previous?.stackNumber ?? (pullRequests.length > 1 ? github.stackNumberForPullRequest(pullRequests[0].number) : void 0);
639
+ const stackNumber = evolution.kind === "rebuild" ? github.stackNumberForPullRequest(pullRequests[0].number) : evolution.kind === "collapse" ? void 0 : previous?.stackNumber ?? (pullRequests.length > 1 ? github.stackNumberForPullRequest(pullRequests[0].number) : void 0);
593
640
  const synchronizedChanges = changes.map((change, index) => ({
594
641
  id: change.id,
595
642
  remoteBranch: change.remoteBranch,
596
643
  pullRequest: pullRequests[index].number,
597
644
  url: pullRequests[index].url
598
645
  }));
599
- const storedChanges = evolution.kind === "partial" && previous ? [...synchronizedChanges, ...previous.changes.slice(evolution.previousOffset + changes.length)] : synchronizedChanges;
600
- writeUpdatedState(store, state, previous, {
646
+ const stored = {
601
647
  remote,
602
648
  base,
603
- ...stackNumber === void 0 ? {} : { stackNumber },
604
- changes: storedChanges
605
- });
649
+ changes: evolution.kind === "partial" && previous ? [...synchronizedChanges, ...previous.changes.slice(evolution.previousOffset + changes.length)] : synchronizedChanges
650
+ };
651
+ if (stackNumber !== void 0) stored.stackNumber = stackNumber;
652
+ writeUpdatedState(store, state, previous, stored);
606
653
  reporter.progress("Saved the local stack state");
607
654
  return {
608
655
  base,
@@ -614,46 +661,69 @@ function syncStack(repository, github, options) {
614
661
  }))
615
662
  };
616
663
  }
617
- function analyzeEvolution(previous, changes, github) {
664
+ function analyzeEvolution(previous, changes, github, preserveHigherChanges) {
618
665
  if (!previous) return { kind: "full" };
619
666
  const previousIds = previous.changes.map((change) => change.id);
620
667
  const currentIds = changes.map((change) => change.id);
621
- if (isSubsequence(previousIds, currentIds)) {
668
+ const previousIdSet = new Set(previousIds);
669
+ const currentIdSet = new Set(currentIds);
670
+ if (!sameValues(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");
671
+ const removed = previous.changes.filter((change) => !currentIdSet.has(change.id));
672
+ const added = changes.filter((change) => !previousIdSet.has(change.id));
673
+ if (removed.length === 0) {
622
674
  if (previousIds.every((id, index) => currentIds[index] === id)) return { kind: "full" };
623
675
  const stackNumber = previous.stackNumber ?? github.stackNumberForPullRequest(previous.changes[0].pullRequest);
624
676
  return stackNumber === void 0 ? { kind: "full" } : {
625
677
  kind: "rebuild",
626
- stackNumber
678
+ stackNumber,
679
+ action: "insert"
627
680
  };
628
681
  }
629
682
  const firstCurrentIndex = previousIds.indexOf(currentIds[0]);
630
- if (firstCurrentIndex === -1) throw new Error("The current commits do not continue the previously submitted stack");
631
- const removedPrefix = previous.changes.slice(0, firstCurrentIndex);
632
- for (const removed of removedPrefix) if (github.pullRequest(removed.pullRequest).state !== "MERGED") throw new Error("Submitted commits may only disappear from the bottom after their pull requests are merged");
633
- const surviving = previousIds.slice(firstCurrentIndex);
634
- const sharedLength = Math.min(surviving.length, currentIds.length);
635
- for (let index = 0; index < sharedLength; index++) if (surviving[index] !== currentIds[index]) throw new Error("Reordering or removing submitted commits is not supported yet. Restore the original order before syncing");
636
- if (currentIds.length < surviving.length) return {
637
- kind: "partial",
638
- previousOffset: firstCurrentIndex
683
+ const isPreviousSlice = firstCurrentIndex >= 0 && currentIds.every((id, index) => previousIds[firstCurrentIndex + index] === id);
684
+ if (preserveHigherChanges && added.length === 0 && isPreviousSlice && firstCurrentIndex + currentIds.length < previousIds.length) {
685
+ if (previous.changes.slice(0, firstCurrentIndex).every((change) => github.pullRequest(change.pullRequest).state === "MERGED")) return {
686
+ kind: "partial",
687
+ previousOffset: firstCurrentIndex
688
+ };
689
+ }
690
+ const removedPrefixWasMerged = removed.every((change, index) => previous.changes[index] === change) && removed.every((change) => github.pullRequest(change.pullRequest).state === "MERGED");
691
+ const survivingIds = previousIds.slice(removed.length);
692
+ const onlyAppendedAfterMergedPrefix = removedPrefixWasMerged && survivingIds.every((id, index) => currentIds[index] === id) && currentIds.slice(survivingIds.length).every((id) => !previousIdSet.has(id));
693
+ if (removedPrefixWasMerged && added.length === 0) return { kind: "skip" };
694
+ if (onlyAppendedAfterMergedPrefix) {
695
+ if (previous.stackNumber === void 0) throw new Error("Cannot append after a merge because the native GitHub stack number is missing from local state");
696
+ return {
697
+ kind: "append",
698
+ stackNumber: previous.stackNumber,
699
+ branches: added.map((change) => change.remoteBranch)
700
+ };
701
+ }
702
+ const stackNumber = previous.stackNumber ?? github.stackNumberForPullRequest(previous.changes[0].pullRequest);
703
+ if (stackNumber === void 0) throw new Error("Cannot remove submitted commits because the native GitHub stack number is missing from local state");
704
+ if (changes.length === 1) return {
705
+ kind: "collapse",
706
+ stackNumber
639
707
  };
640
- const appended = changes.slice(surviving.length);
641
- if (removedPrefix.length === 0) return { kind: "full" };
642
- if (appended.length === 0) return { kind: "skip" };
643
- if (previous.stackNumber === void 0) throw new Error("Cannot append after a merge because the native GitHub stack number is missing from local state");
644
708
  return {
645
- kind: "append",
646
- stackNumber: previous.stackNumber,
647
- branches: appended.map((change) => change.remoteBranch)
709
+ kind: "rebuild",
710
+ stackNumber,
711
+ action: added.length === 0 ? "remove" : "update"
648
712
  };
649
713
  }
650
- function isSubsequence(expected, actual) {
651
- let expectedIndex = 0;
652
- for (const value of actual) if (value === expected[expectedIndex]) expectedIndex++;
653
- return expectedIndex === expected.length;
714
+ function sameValues(left, right) {
715
+ return left.length === right.length && left.every((value, index) => value === right[index]);
654
716
  }
655
- function errorMessage(error) {
656
- return error instanceof Error ? error.message : String(error);
717
+ function restorePreviousStack(github, previous, base, remote, reporter, rebuildError) {
718
+ const rebuildMessage = rebuildError instanceof Error ? rebuildError.message : String(rebuildError);
719
+ reporter.progress("Rebuild failed; restoring the previous native GitHub stack");
720
+ try {
721
+ github.linkStack(previous.changes.map((change) => change.remoteBranch), base, remote, true);
722
+ } catch (rollbackError) {
723
+ const rollbackMessage = rollbackError instanceof Error ? rollbackError.message : String(rollbackError);
724
+ throw new Error(`Stack rebuild failed: ${rebuildMessage}\nRestoring the previous stack also failed: ${rollbackMessage}`);
725
+ }
726
+ throw rebuildError;
657
727
  }
658
728
  function writeUpdatedState(store, state, previous, updated) {
659
729
  const stacks = previous ? state.stacks.map((stack) => stack === previous ? updated : stack) : [...state.stacks, updated];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bstack",
3
- "version": "0.3.0",
3
+ "version": "1.0.0",
4
4
  "packageManager": "pnpm@11.5.1",
5
5
  "description": "Create native GitHub stacked pull requests from a linear series of commits",
6
6
  "repository": {
@@ -38,5 +38,8 @@
38
38
  "tsdown": "^0.22.14",
39
39
  "typescript": "^7.0.2",
40
40
  "vitest": "^4.0.0"
41
+ },
42
+ "dependencies": {
43
+ "zod": "^4.4.3"
41
44
  }
42
45
  }