@dev.fast/whiteboard 0.3.4 → 0.3.5-preview.20261020.130

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 (115) hide show
  1. package/dist/{account-alias-CprZojXd.js → account-alias-CMyXCuuq.js} +13 -8
  2. package/dist/{agent-cli-CThN7Q59.js → agent-cli-CahKfDT0.js} +15 -10
  3. package/dist/agent-cli-CvYV2R6c.js +2 -0
  4. package/dist/{agent-client-BOOi7EUR.js → agent-client-D2RmE9NL.js} +13 -3
  5. package/dist/{authoring-tools-BNwuMnz6.js → authoring-tools-BLhYKrNN.js} +34 -27
  6. package/dist/{background-server-DLSDogkI.js → background-server-CFUU4VtN.js} +1 -1
  7. package/dist/{background-server-CkvxA-6O.js → background-server-k4K_Hvh1.js} +5 -5
  8. package/dist/build-info.json +1 -1
  9. package/dist/{cli-R4yfa5DT.js → cli-BUVSMGud.js} +1 -1
  10. package/dist/{cli-runner-Cuc9FSzu.js → cli-runner-C7W6VA94.js} +26 -26
  11. package/dist/cli.js +4 -3
  12. package/dist/{client-BN_FDxFr.js → client-B0f4EJ7v.js} +253 -47
  13. package/dist/{fuzzy-match-BO9bK37N.js → fuzzy-match-BbaX-dGh.js} +6 -4
  14. package/dist/{headless-host-Cog6SpS9.js → headless-host-C7xYMe74.js} +513 -140
  15. package/dist/{headless-host-CVBOQyRS.js → headless-host-cYdlRYPB.js} +1 -1
  16. package/dist/lens-selection-CAtsGaym.js +214 -0
  17. package/dist/{local-data-D_9tvdsp.js → local-data-DrNSaiS5.js} +2416 -320
  18. package/dist/{mcp-D1hziANa.js → mcp-yXnjUkOV.js} +6 -10
  19. package/dist/{package-paths-C--__jEv.js → package-paths-BWxO-UEL.js} +1 -1
  20. package/dist/{profile-DokFw782.js → profile-BlqZlCPZ.js} +20 -8
  21. package/dist/profile-CGxNfeYQ.js +2 -0
  22. package/dist/{remote-attach-CEJ-kvDY.js → remote-attach-CZTrtWIk.js} +4 -4
  23. package/dist/{remote-extensions-Blcjr9Ze.js → remote-extensions-CNXwqK0D.js} +3 -3
  24. package/dist/{remote-extensions-GN8Gf_qG.js → remote-extensions-VP-kfq0C.js} +1 -1
  25. package/dist/{remote-language-server-BTKBQ7x4.js → remote-language-server-BiPzol99.js} +5 -5
  26. package/dist/{remote-uninstall-DW7Gdryh.js → remote-uninstall-CFTkoioA.js} +6 -6
  27. package/dist/{request-origin-Zcn_CjX4.js → request-origin--8lLCclv.js} +55 -14
  28. package/dist/{review-app-launcher-CkwLclzR.js → review-app-launcher-KA2_mOeo.js} +2 -2
  29. package/dist/{review-home-paths-j2BRAaNv.js → review-home-paths-D8AoCdd2.js} +19 -17
  30. package/dist/{review-telemetry-BLNwYnLV.js → review-telemetry-B2JniTIs.js} +5 -5
  31. package/dist/runtime-Bj2tD33R.js +27 -0
  32. package/dist/runtime.d.ts +1 -1
  33. package/dist/runtime.js +1 -1
  34. package/dist/{s3-DtJFWC0G.js → s3-CsG2EWED.js} +1 -1
  35. package/dist/{s3-config-BTO12tCC.js → s3-config-CjcLyFV5.js} +4 -2
  36. package/dist/{s3-config-CSy3fx8H.js → s3-config-zz4GWh2T.js} +1 -1
  37. package/dist/server/desktop-host.js +29 -76
  38. package/dist/{server-discovery-Bey8IqXH.js → server-discovery-CRXG48Uz.js} +3 -3
  39. package/dist/sharing/index.d.ts +484 -70
  40. package/dist/sharing/index.js +1 -1
  41. package/dist/{src-DsM3jO8G.js → src-CyYCvVHx.js} +90 -17
  42. package/dist/{src-BMPuxWv4.js → src-WPaQn75m.js} +208 -73
  43. package/dist/{stored-document-migration-CChxtFAy.js → stored-document-migration-B_BijGHF.js} +270 -269
  44. package/dist/{thread-state-5pYr6_zM.js → thread-state-D6MlnGv7.js} +7 -2
  45. package/dist/{tool-failure-C9MpbXJJ.js → tool-failure-_makh_Sr.js} +10 -3
  46. package/instructions/authoring.md +18 -5
  47. package/package.json +6 -6
  48. package/src/ask/agents.ts +13 -1
  49. package/src/ask/checkout-files.ts +2 -5
  50. package/src/ask/thread-state.ts +4 -0
  51. package/src/ask/thread.ts +16 -0
  52. package/src/ask/threads.ts +24 -3
  53. package/src/cli-install.ts +13 -2
  54. package/src/cli-runner.ts +4 -1
  55. package/src/cli.ts +4 -1
  56. package/src/diff-selection-migration.ts +48 -34
  57. package/src/heading-anchors.ts +42 -0
  58. package/src/lens-selection.ts +38 -2
  59. package/src/review-api/README.md +52 -13
  60. package/src/review-api/activity.ts +1 -4
  61. package/src/review-api/agent-cli.ts +9 -1
  62. package/src/review-api/anchor-quotes.ts +14 -7
  63. package/src/review-api/ask-history.ts +137 -51
  64. package/src/review-api/authoring-tools.ts +45 -30
  65. package/src/review-api/blocks/definition.ts +1 -1
  66. package/src/review-api/blocks/markdown.ts +2 -2
  67. package/src/review-api/board-order.ts +144 -0
  68. package/src/review-api/checkout-groups.ts +560 -0
  69. package/src/review-api/document-headings.ts +58 -27
  70. package/src/review-api/document-text.ts +31 -2
  71. package/src/review-api/document.ts +281 -34
  72. package/src/review-api/foreign-references.ts +328 -0
  73. package/src/review-api/github-provider.ts +51 -13
  74. package/src/review-api/http.ts +702 -123
  75. package/src/review-api/instructions.ts +0 -19
  76. package/src/review-api/link-preview.ts +113 -0
  77. package/src/review-api/local-data.ts +310 -46
  78. package/src/review-api/mcp.ts +4 -12
  79. package/src/review-api/onboarding.ts +1 -1
  80. package/src/review-api/profile.ts +37 -6
  81. package/src/review-api/public-tools.ts +24 -2
  82. package/src/review-api/pull-request.ts +82 -49
  83. package/src/review-api/request-origin.ts +10 -0
  84. package/src/review-api/review-progress.ts +61 -27
  85. package/src/review-api/scratchpad-plan-upgrade.ts +250 -0
  86. package/src/review-api/store-migrations.ts +357 -0
  87. package/src/review-api/store-schema.ts +38 -29
  88. package/src/review-api/store.ts +2144 -480
  89. package/src/review-api/worktree-events.ts +3 -5
  90. package/src/review-api/worktree-source.ts +3 -7
  91. package/src/review-api/worktree-structural.integration.ts +72 -0
  92. package/src/review-stack.ts +15 -23
  93. package/src/runtime.ts +4 -30
  94. package/src/server/desktop-host.ts +6 -1
  95. package/src/server/desktop-server.ts +1 -31
  96. package/src/server/headless-host.ts +0 -2
  97. package/src/server/hono-http.ts +2 -1
  98. package/src/server/legacy-tutorial-cleanup.ts +2 -3
  99. package/src/server/review-gateway-list.ts +3 -5
  100. package/src/server/review-gateway-memory.ts +1 -1
  101. package/src/server/review-lifecycle-telemetry.ts +2 -2
  102. package/src/server/review-server-core.ts +1 -5
  103. package/src/server/structural-comparisons.ts +47 -0
  104. package/src/sharing/export.ts +36 -7
  105. package/src/sharing/host.ts +34 -14
  106. package/src/sharing/import.ts +156 -24
  107. package/src/sharing/repository.ts +128 -20
  108. package/src/startup-trace.ts +3 -3
  109. package/src/stored-document-migration.ts +110 -19
  110. package/src/ui-telemetry-events.ts +44 -14
  111. package/dist/agent-cli-C6SPSII0.js +0 -2
  112. package/dist/profile-CYPmvBoA.js +0 -2
  113. package/dist/runtime-CKiWhGrf.js +0 -52
  114. package/instructions/scratchpad.md +0 -23
  115. package/src/review-preferences.ts +0 -88
@@ -5,7 +5,7 @@ import { parseShareLink, shareIdSchema } from "@dev.fast/review-share-protocol";
5
5
  import {
6
6
  DEFAULT_STORE_ORIGIN,
7
7
  clearStoreAuth,
8
- openUrlInBrowser,
8
+ type openUrlInBrowser,
9
9
  readStoreAuth,
10
10
  runStoreLogin,
11
11
  } from "@dev.fast/trace-core";
@@ -18,13 +18,18 @@ import { z } from "zod";
18
18
 
19
19
  import { readSharingAuth } from "./auth.js";
20
20
  import { ShareAuthError, ShareClient, SharePreflightError } from "./client.js";
21
- import { exportShare, validateShareSources } from "./export.js";
21
+ import {
22
+ exportShare,
23
+ referenceCommits,
24
+ validateShareSources,
25
+ } from "./export.js";
22
26
  import { SharedReviewStore, sharedReviewId } from "./import.js";
23
27
  import { readShareRepository, verifyShareRepository } from "./repository.js";
24
28
 
25
29
  interface LoginState {
26
30
  pending: boolean;
27
31
  url?: string;
32
+ userCode?: string;
28
33
  error?: string;
29
34
  }
30
35
 
@@ -61,7 +66,6 @@ export function mountSharingHost(
61
66
  options: SharingHostOptions = {},
62
67
  ) {
63
68
  const startLogin = options.login ?? runStoreLogin;
64
- const openUrl = options.openUrl ?? openUrlInBrowser;
65
69
 
66
70
  let login: LoginState = {
67
71
  pending: false,
@@ -92,9 +96,9 @@ export function mountSharingHost(
92
96
  traces: false,
93
97
  stdout: discard,
94
98
  stderr: discard,
95
- openUrl: async (url) => {
96
- login.url = url;
97
- await openUrl(url);
99
+ openUrl: options.openUrl,
100
+ onDeviceCode: (device) => {
101
+ Object.assign(login, device);
98
102
  },
99
103
  })
100
104
  .then((code) => {
@@ -223,13 +227,26 @@ export function mountSharingPublisher(
223
227
  409,
224
228
  );
225
229
 
226
- const root = snapshot.pins
227
- ? store.repositoryPath(snapshot.pins.repositoryId)
228
- : undefined;
230
+ const repositoryId =
231
+ snapshot.pins?.repositoryId ?? snapshot.target?.repositoryId;
232
+
233
+ const root = repositoryId ? store.repositoryPath(repositoryId) : undefined;
229
234
 
230
- const repository = root
231
- ? await (options.readRepository ?? readShareRepository)(root)
232
- : undefined;
235
+ const readRepository = options.readRepository ?? readShareRepository;
236
+
237
+ // Unpinned boards name their repository when they can, so receivers can
238
+ // group them.
239
+ const repository = !root
240
+ ? undefined
241
+ : snapshot.pins
242
+ ? await readRepository(root)
243
+ : await (async () => {
244
+ try {
245
+ return await readRepository(root);
246
+ } catch {
247
+ return undefined;
248
+ }
249
+ })();
233
250
 
234
251
  const bundle = await exportShare({
235
252
  store,
@@ -239,9 +256,12 @@ export function mountSharingPublisher(
239
256
  repository,
240
257
  });
241
258
 
259
+ const commits = referenceCommits(snapshot);
260
+
261
+ // The commits must be on GitHub for a recipient to fetch them.
242
262
  const verification =
243
- root && snapshot.pins && repository
244
- ? verifyRepository(root, snapshot.pins, repository).then(
263
+ root && (snapshot.pins || commits.length) && repository
264
+ ? verifyRepository(root, snapshot.pins, repository, commits).then(
245
265
  () => ({ ok: true as const }),
246
266
  (error: Error) => ({ ok: false as const, error }),
247
267
  )
@@ -28,6 +28,9 @@ import { textIncludesQuote } from "@review/evidence.js";
28
28
  import { lensSchema } from "@review/review-api/diff-lenses.js";
29
29
  import { ReviewInputError } from "@review/review-api/document.js";
30
30
  import {
31
+ type FileLineRange,
32
+ type Pins,
33
+ anchorPins,
31
34
  checkReferences,
32
35
  documentSchema,
33
36
  pinsSchema,
@@ -52,10 +55,16 @@ import { z } from "zod";
52
55
  import {
53
56
  type ShareBundle,
54
57
  digestBytes,
58
+ referenceCommits,
55
59
  validateShareSources,
56
60
  } from "./export.js";
57
61
  import {
62
+ COMMIT_ID,
63
+ assertCommitIds,
64
+ cloneSharedRepository,
58
65
  fetchPinnedRepository,
66
+ fetchSharedCommits,
67
+ forgetSharedCommits,
59
68
  repositoryReady,
60
69
  sharedGit,
61
70
  } from "./repository.js";
@@ -63,11 +72,12 @@ import {
63
72
  export const sharedSnapshotSchema = z.strictObject({
64
73
  reviewId: z.string().min(1),
65
74
  version: z.number().int().nonnegative(),
66
- title: z.string().min(1),
75
+ /** Empty for a board never named; it reads "Untitled". */
76
+ title: z.string(),
67
77
  pins: pinsSchema
68
78
  .extend({
69
- base: z.string().regex(/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/),
70
- head: z.string().regex(/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/),
79
+ base: z.string().regex(COMMIT_ID),
80
+ head: z.string().regex(COMMIT_ID),
71
81
  })
72
82
  .optional(),
73
83
  document: documentSchema,
@@ -90,7 +100,7 @@ const count = z.number().int().nonnegative();
90
100
  const counts = z.strictObject({ additions: count, deletions: count });
91
101
 
92
102
  const mapSchema = z.strictObject({
93
- commit: z.string(),
103
+ commit: z.string().regex(COMMIT_ID),
94
104
  side: z.enum(["base", "head"]),
95
105
  elements: z.array(normalizedSoftwareElementSchema),
96
106
  relationships: z.array(normalizedSoftwareRelationshipSchema),
@@ -166,8 +176,10 @@ export function validateShareBundle(bundle: ShareBundle) {
166
176
  throw new Error("Shared snapshot does not match its manifest.");
167
177
  checkReferences(snapshot.document);
168
178
  validateShareSources(snapshot);
179
+ // Every commit it quotes reaches git as an argument.
180
+ assertCommitIds(referenceCommits(snapshot));
169
181
 
170
- if (Boolean(snapshot.pins) !== Boolean(manifest.repository))
182
+ if (snapshot.pins && !manifest.repository)
171
183
  throw new Error("Shared source pins require a repository.");
172
184
 
173
185
  const presentation = sharePresentationSchema.parse(
@@ -285,6 +297,7 @@ export class SharedReviewStore {
285
297
  constructor(
286
298
  readonly root: string,
287
299
  private readonly fetchRepository = fetchPinnedRepository,
300
+ private readonly cloneRepository = cloneSharedRepository,
288
301
  ) {
289
302
  this.purgeTimer = setInterval(() => {
290
303
  void this.purgeExpired().catch((error) =>
@@ -372,7 +385,7 @@ export class SharedReviewStore {
372
385
  throw new ReviewInputError("Shared review is not available.", 404);
373
386
 
374
387
  if (!saved.snapshot.pins) {
375
- this.setStatus(id, "ready");
388
+ await this.prepareUnpinned(id, saved.manifest.repository?.cloneUrl);
376
389
 
377
390
  return;
378
391
  }
@@ -381,10 +394,12 @@ export class SharedReviewStore {
381
394
  throw new ReviewInputError("Repository service is unavailable.", 409);
382
395
  const root = this.repositoryRoot(id);
383
396
 
397
+ const commits = referenceCommits(saved.snapshot);
398
+
384
399
  try {
385
400
  this.setStatus(id, "fetching");
386
401
 
387
- if (!(await repositoryReady(root, saved.snapshot.pins))) {
402
+ if (!(await repositoryReady(root, saved.snapshot.pins, commits))) {
388
403
  const prior = this.repositories.get(id);
389
404
  await local.data.workspaces.remove(id);
390
405
 
@@ -394,11 +409,13 @@ export class SharedReviewStore {
394
409
  root,
395
410
  saved.manifest.repository!.cloneUrl,
396
411
  saved.snapshot.pins,
412
+ false,
413
+ commits,
397
414
  );
398
415
  await sharedGit(root, [
399
416
  "checkout",
400
417
  "--detach",
401
- saved.snapshot.pins.head,
418
+ { commit: saved.snapshot.pins.head },
402
419
  ]);
403
420
  }
404
421
 
@@ -413,16 +430,7 @@ export class SharedReviewStore {
413
430
  this.setStatus(id, "validating");
414
431
  await local.data.validatePins(pins);
415
432
 
416
- const references = sourceReferences(saved.snapshot.document).map(
417
- (item) => item.source,
418
- );
419
-
420
- for (const map of Object.values(saved.presentation.maps))
421
- for (const element of map.elements)
422
- for (const range of element.sourceRanges ?? [])
423
- references.push({ ...range, side: map.side });
424
-
425
- await local.data.validateSources(pins, references);
433
+ await this.validateReferences(saved, repository.id, pins);
426
434
  await local.data.workspaces.open(id, pins);
427
435
 
428
436
  if (
@@ -449,13 +457,114 @@ export class SharedReviewStore {
449
457
  }
450
458
  }
451
459
 
460
+ /** Reads each of a shared board's references at its own commits, in the
461
+ * checkout of its repository. */
462
+ private async validateReferences(
463
+ saved: ReturnType<typeof validateShareBundle>,
464
+ repositoryId: string,
465
+ pins?: Pins,
466
+ ) {
467
+ const references = new Map<
468
+ string,
469
+ { pins: Pins; ranges: FileLineRange[] }
470
+ >();
471
+
472
+ const add = (at: Pins, range: FileLineRange) => {
473
+ const key = JSON.stringify([at.base, at.head]);
474
+ const group = references.get(key) ?? { pins: at, ranges: [] };
475
+
476
+ group.ranges.push(range);
477
+ references.set(key, group);
478
+ };
479
+
480
+ for (const { source } of sourceReferences(saved.snapshot.document, {
481
+ repositoryId,
482
+ }))
483
+ add({ ...anchorPins(source, pins), repositoryId }, source);
484
+
485
+ if (pins)
486
+ for (const map of Object.values(saved.presentation.maps))
487
+ for (const element of map.elements)
488
+ for (const range of element.sourceRanges ?? [])
489
+ add(pins, { ...range, side: map.side });
490
+
491
+ for (const group of references.values())
492
+ await this.local!.data.validateSources(group.pins, group.ranges);
493
+ }
494
+
495
+ /** Files an unpinned board under a local checkout of its repository,
496
+ * else a managed clone. */
497
+ private async prepareUnpinned(id: string, cloneUrl: string | undefined) {
498
+ const local = this.local;
499
+ const saved = this.validated.get(id)!;
500
+
501
+ if (!cloneUrl || !local) {
502
+ this.setStatus(id, "ready");
503
+
504
+ return;
505
+ }
506
+
507
+ const root = this.repositoryRoot(id);
508
+
509
+ try {
510
+ this.setStatus(id, "fetching");
511
+ const managed = path.join(await realpath(this.root), ".repositories");
512
+
513
+ let repositoryId = local.store
514
+ .checkoutsOf(cloneUrl)
515
+ .find(
516
+ (checkout) =>
517
+ !local.store.repositoryPath(checkout).startsWith(managed),
518
+ );
519
+
520
+ if (!repositoryId) {
521
+ if (!existsSync(path.join(root, ".git"))) {
522
+ await rm(root, { recursive: true, force: true });
523
+ await this.cloneRepository(root, cloneUrl);
524
+ }
525
+
526
+ repositoryId = (await local.data.register(root)).id;
527
+ }
528
+
529
+ // A plan's commit links read at commits the checkout may not have.
530
+ const checkout = local.store.repositoryPath(repositoryId);
531
+
532
+ await fetchSharedCommits(
533
+ checkout,
534
+ referenceCommits(saved.snapshot, repositoryId),
535
+ id,
536
+ );
537
+ this.setStatus(id, "validating");
538
+ await this.validateReferences(saved, repositoryId);
539
+ this.repositories.set(id, repositoryId);
540
+ await writeFile(
541
+ path.join(this.root, id, "repository.json"),
542
+ JSON.stringify({ repositoryId, ready: true }),
543
+ { mode: 0o600 },
544
+ );
545
+ this.setStatus(id, "ready");
546
+ } catch (error) {
547
+ const message =
548
+ error instanceof ReviewInputError
549
+ ? error.message
550
+ : "Could not prepare the shared repository. Check Git credentials and retry.";
551
+
552
+ this.setStatus(id, "error", message);
553
+ throw new ReviewInputError(message, 409);
554
+ }
555
+ }
556
+
452
557
  async assertReady(id: string) {
453
558
  const saved = this.validated.get(id);
454
559
 
455
560
  if (
456
561
  !saved ||
457
562
  (saved.snapshot.pins &&
458
- !(await repositoryReady(this.repositoryRoot(id), saved.snapshot.pins)))
563
+ !(await repositoryReady(
564
+ this.repositoryRoot(id),
565
+ saved.snapshot.pins,
566
+ referenceCommits(saved.snapshot),
567
+ )))
459
568
  ) {
460
569
  this.setStatus(
461
570
  id,
@@ -517,9 +626,14 @@ export class SharedReviewStore {
517
626
  ),
518
627
  );
519
628
 
629
+ // A board with no pins may use the receiver's own checkout.
520
630
  if (
521
631
  this.local?.store.repositoryPath(metadata.repositoryId) ===
522
- path.join(await realpath(this.root), ".repositories", id)
632
+ path.join(await realpath(this.root), ".repositories", id) ||
633
+ (!validated.snapshot.pins &&
634
+ this.local?.store
635
+ .repositories()
636
+ .some((repository) => repository.id === metadata.repositoryId))
523
637
  ) {
524
638
  this.repositories.set(id, metadata.repositoryId);
525
639
  prepared = metadata.ready;
@@ -528,13 +642,17 @@ export class SharedReviewStore {
528
642
  /* A first or interrupted import has no local registration yet. */
529
643
  }
530
644
 
531
- if (!validated.snapshot.pins) this.setStatus(id, "ready");
532
- else if (
645
+ if (!validated.snapshot.pins) {
646
+ if (this.repositories.has(id) || !validated.manifest.repository)
647
+ this.setStatus(id, "ready");
648
+ else void this.prepare(id).catch(() => {});
649
+ } else if (
533
650
  prepared &&
534
651
  this.local &&
535
652
  (await repositoryReady(
536
653
  this.repositoryRoot(id),
537
654
  validated.snapshot.pins,
655
+ referenceCommits(validated.snapshot),
538
656
  ))
539
657
  ) {
540
658
  const repository = await this.local.data.register(
@@ -637,7 +755,9 @@ export class SharedReviewStore {
637
755
  ...validated.snapshot.pins,
638
756
  repositoryId: repositoryId!,
639
757
  }
640
- : undefined,
758
+ : repositoryId
759
+ ? { kind: "project" as const, repositoryId }
760
+ : undefined,
641
761
  shared: {
642
762
  ...bundle.attribution,
643
763
  cloneUrl: bundle.manifest.repository?.cloneUrl,
@@ -658,11 +778,16 @@ export class SharedReviewStore {
658
778
  .map((id) => {
659
779
  const { document: _document, ...snapshot } = this.get(id).snapshot;
660
780
 
781
+ const project = snapshot.target
782
+ ? this.local?.store.checkoutProject(snapshot.target.repositoryId)
783
+ : undefined;
784
+
661
785
  return {
662
786
  ...snapshot,
663
787
  firstCreatedAt:
664
788
  snapshot.version === 0 ? snapshot.createdAt : undefined,
665
789
  repositoryName: "Shared review",
790
+ ...(project && { project }),
666
791
  viewedAt: this.attention.get(id)?.viewedAt ?? null,
667
792
  trashedAt: this.attention.get(id)?.trashedAt ?? null,
668
793
  };
@@ -700,8 +825,15 @@ export class SharedReviewStore {
700
825
  const repositoryId = this.repositories.get(id);
701
826
 
702
827
  if (repositoryId) {
828
+ const checkout = this.local?.store.repositoryPath(repositoryId);
829
+
830
+ // The commits kept for its links go with it.
831
+ if (checkout)
832
+ await forgetSharedCommits(checkout, id).catch((error) =>
833
+ console.error(`Could not drop the refs kept for share ${id}:`, error),
834
+ );
703
835
  await this.local?.data.forgetRepository(repositoryId);
704
- this.local?.store.unregisterRepository(repositoryId);
836
+ await this.local?.store.unregisterRepository(repositoryId);
705
837
  }
706
838
 
707
839
  await rm(this.repositoryRoot(id), { recursive: true, force: true });
@@ -2,12 +2,25 @@ import { mkdir, mkdtemp, rm } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import path from "node:path";
4
4
 
5
- import { gitAt } from "@dev.fast/local-vcs";
5
+ import { type VcsArg, gitAt } from "@dev.fast/local-vcs";
6
6
  import { normalizeGitHubRemote } from "@dev.fast/review-share-protocol";
7
7
  import { type Pins, ReviewInputError } from "@review/review-api/document.js";
8
8
 
9
+ /** A full commit id, SHA-1 or SHA-256. */
10
+ export const COMMIT_ID = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
11
+
12
+ /** Refuses anything but full commit ids, before any of them reaches git:
13
+ * a shared board's commits come from someone else, and one spelled as an
14
+ * option or a refspec would make git do something else. */
15
+ export function assertCommitIds(commits: readonly string[]) {
16
+ if (!commits.every((commit) => COMMIT_ID.test(commit)))
17
+ throw new ReviewInputError(
18
+ "The shared whiteboard names a commit that isn't a full commit id.",
19
+ );
20
+ }
21
+
9
22
  /** Keep credential helpers but prevent the calling shell from redirecting Git's object store. */
10
- export function sharedGit(cwd: string, args: string[]) {
23
+ export function sharedGit(cwd: string, args: VcsArg[]) {
11
24
  const env = Object.fromEntries(
12
25
  Object.entries(process.env).filter(
13
26
  ([key]) =>
@@ -25,7 +38,7 @@ export function sharedGit(cwd: string, args: string[]) {
25
38
  cwd,
26
39
  ["-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", ...args],
27
40
  {
28
- env: { ...env, GIT_TERMINAL_PROMPT: "0" },
41
+ env,
29
42
  timeout: 300_000,
30
43
  },
31
44
  );
@@ -46,18 +59,28 @@ function fetchError(stderr: string, publishing: boolean): ReviewInputError {
46
59
  );
47
60
  }
48
61
 
49
- /** Recipients need complete history; publication only checks that the commits exist. */
62
+ /** Recipients need complete history; publication only checks that the commits exist.
63
+ * `commits` are other commits the board's references read at; a plan has
64
+ * only those. */
50
65
  export async function fetchPinnedRepository(
51
66
  root: string,
52
67
  url: string,
53
- pins: Pick<Pins, "base" | "head">,
68
+ pins: Pick<Pins, "base" | "head"> | undefined,
54
69
  publishing = false,
70
+ commits: readonly string[] = [],
55
71
  ) {
56
- if (
57
- ![pins.base, pins.head].every((commit) =>
58
- /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(commit),
59
- )
60
- )
72
+ // Each commit and the ref it is fetched to.
73
+ const named = [
74
+ ...(pins
75
+ ? [
76
+ { commit: pins.base, ref: "base" },
77
+ { commit: pins.head, ref: "head" },
78
+ ]
79
+ : []),
80
+ ...commits.map((commit) => ({ commit, ref: `commits/${commit}` })),
81
+ ];
82
+
83
+ if (!named.every(({ commit }) => COMMIT_ID.test(commit)))
61
84
  throw new ReviewInputError(
62
85
  "Sharing requires immutable base and head commits.",
63
86
  );
@@ -67,7 +90,7 @@ export async function fetchPinnedRepository(
67
90
  if (!publishing) {
68
91
  await sharedGit(root, ["config", "core.hooksPath", "/dev/null"]);
69
92
  await sharedGit(root, ["config", "core.fsmonitor", "false"]);
70
- await sharedGit(root, ["remote", "add", "origin", url]);
93
+ await sharedGit(root, ["remote", "add", "origin", { remote: url }]);
71
94
  }
72
95
 
73
96
  // Branch refs make Git reject non-commit objects during the fetch itself.
@@ -79,9 +102,10 @@ export async function fetchPinnedRepository(
79
102
  "--no-tags",
80
103
  "--force",
81
104
  ...(publishing ? ["--depth=1", "--filter=tree:0"] : []),
82
- publishing ? url : "origin",
83
- `${pins.base}:${refs}/base`,
84
- `${pins.head}:${refs}/head`,
105
+ { remote: publishing ? url : "origin" },
106
+ ...named.map(({ commit, ref }) => ({
107
+ refspec: { from: commit, to: `${refs}/${ref}` },
108
+ })),
85
109
  ]);
86
110
  } catch (error) {
87
111
  throw fetchError(
@@ -91,21 +115,104 @@ export async function fetchPinnedRepository(
91
115
  }
92
116
 
93
117
  if (!publishing)
94
- for (const commit of [pins.base, pins.head])
95
- await sharedGit(root, ["cat-file", "-e", `${commit}^{commit}`]);
118
+ for (const { commit } of named)
119
+ await sharedGit(root, ["cat-file", "-e", { rev: `${commit}^{commit}` }]);
120
+ }
121
+
122
+ /** Fetches the commits a shared board's links read at into a checkout of
123
+ * its repository, from its origin unless it has them, and keeps each under
124
+ * `refs/review/shared/<share>/<commit>`, so `git gc` can't prune one the
125
+ * board still quotes. */
126
+ export async function fetchSharedCommits(
127
+ root: string,
128
+ commits: readonly string[],
129
+ share: string,
130
+ ) {
131
+ assertCommitIds(commits);
132
+
133
+ const ref = (commit: string) => `refs/review/shared/${share}/${commit}`;
134
+ const missing: string[] = [];
135
+
136
+ for (const commit of commits)
137
+ try {
138
+ await sharedGit(root, ["cat-file", "-e", { rev: `${commit}^{commit}` }]);
139
+ await sharedGit(root, ["update-ref", { ref: ref(commit) }, { commit }]);
140
+ } catch {
141
+ missing.push(commit);
142
+ }
143
+
144
+ if (!missing.length) return;
145
+
146
+ try {
147
+ await sharedGit(root, [
148
+ "fetch",
149
+ "--no-tags",
150
+ "origin",
151
+ ...missing.map((commit) => ({
152
+ refspec: { from: commit, to: ref(commit), force: true },
153
+ })),
154
+ ]);
155
+ } catch (error) {
156
+ throw fetchError(
157
+ error instanceof Error && "stderr" in error ? String(error.stderr) : "",
158
+ false,
159
+ );
160
+ }
161
+ }
162
+
163
+ /** Drops the refs `fetchSharedCommits` kept for a share, once the share is
164
+ * gone, so `git gc` can prune what only it quoted. */
165
+ export async function forgetSharedCommits(root: string, share: string) {
166
+ const { stdout } = await sharedGit(root, [
167
+ "for-each-ref",
168
+ "--format=%(refname)",
169
+ { ref: `refs/review/shared/${share}/` },
170
+ ]);
171
+
172
+ for (const ref of stdout.split("\n").filter(Boolean))
173
+ await sharedGit(root, ["update-ref", "-d", { ref }]);
174
+ }
175
+
176
+ /** A managed clone of a repository's default branch, for a shared board
177
+ * with no pins whose repository the receiver has no checkout of. */
178
+ export async function cloneSharedRepository(root: string, url: string) {
179
+ await mkdir(root, { recursive: true, mode: 0o700 });
180
+ await sharedGit(root, ["init", "--quiet"]);
181
+ await sharedGit(root, ["config", "core.hooksPath", "/dev/null"]);
182
+ await sharedGit(root, ["config", "core.fsmonitor", "false"]);
183
+ await sharedGit(root, ["remote", "add", "origin", { remote: url }]);
184
+
185
+ try {
186
+ await sharedGit(root, ["fetch", "--no-tags", "origin", "HEAD"]);
187
+ } catch (error) {
188
+ throw fetchError(
189
+ error instanceof Error && "stderr" in error ? String(error.stderr) : "",
190
+ false,
191
+ );
192
+ }
193
+
194
+ await sharedGit(root, ["checkout", "--detach", "FETCH_HEAD"]);
96
195
  }
97
196
 
98
197
  export async function repositoryReady(
99
198
  root: string,
100
199
  pins: Pick<Pins, "base" | "head">,
200
+ commits: readonly string[] = [],
101
201
  ): Promise<boolean> {
202
+ if (
203
+ ![pins.base, pins.head, ...commits].every((commit) =>
204
+ COMMIT_ID.test(commit),
205
+ )
206
+ )
207
+ return false;
208
+
102
209
  try {
103
210
  const head = await sharedGit(root, ["rev-parse", "HEAD"]);
104
211
 
105
212
  if (head.stdout.trim() !== pins.head) return false;
106
213
 
107
- for (const commit of [pins.base, pins.head])
108
- await sharedGit(root, ["cat-file", "-e", `${commit}^{commit}`]);
214
+ for (const commit of [pins.base, pins.head, ...commits])
215
+ await sharedGit(root, ["cat-file", "-e", { rev: `${commit}^{commit}` }]);
109
216
 
110
217
  return true;
111
218
  } catch {
@@ -129,14 +236,15 @@ export async function readShareRepository(root: string) {
129
236
 
130
237
  export async function verifyShareRepository(
131
238
  root: string,
132
- pins: Pins,
239
+ pins: Pins | undefined,
133
240
  repository?: { cloneUrl: string },
241
+ commits: readonly string[] = [],
134
242
  ) {
135
243
  const { cloneUrl } = repository ?? (await readShareRepository(root));
136
244
  const temporary = await mkdtemp(path.join(tmpdir(), "review-share-check-"));
137
245
 
138
246
  try {
139
- await fetchPinnedRepository(temporary, cloneUrl, pins, true);
247
+ await fetchPinnedRepository(temporary, cloneUrl, pins, true, commits);
140
248
  } finally {
141
249
  await rm(temporary, { recursive: true, force: true });
142
250
  }
@@ -221,9 +221,9 @@ if (traceEnabled) {
221
221
  // Every git/jj spawn inside @dev.fast/local-vcs becomes a `$ …` span under
222
222
  // whatever span is active in the caller's async context.
223
223
  setLocalVcsCommandObserver({
224
- start({ file, args, cwd }) {
225
- const handle = startSpan(commandSpanName(file, args), {
226
- detail: commandDetail(file, args, cwd),
224
+ start({ tool, args, cwd }) {
225
+ const handle = startSpan(commandSpanName(tool, args), {
226
+ detail: commandDetail(tool, args, cwd),
227
227
  });
228
228
 
229
229
  return ({ ok }) => (ok ? handle.end() : handle.fail());