canopycms 0.8.0 → 0.8.2

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 (86) hide show
  1. package/CHANGELOG.md +112 -47
  2. package/dist/api/admin-branch-health.d.ts +11 -0
  3. package/dist/api/admin-branch-health.js +36 -0
  4. package/dist/api/admin.d.ts +8 -1
  5. package/dist/api/branch-merge.js +1 -1
  6. package/dist/api/branch-review.js +20 -3
  7. package/dist/api/branch-status.js +30 -19
  8. package/dist/api/branch.d.ts +8 -3
  9. package/dist/api/branch.js +32 -3
  10. package/dist/api/client.d.ts +3 -1
  11. package/dist/api/client.js +4 -0
  12. package/dist/api/github-sync.d.ts +2 -0
  13. package/dist/api/github-sync.js +6 -1
  14. package/dist/api/pr-comment-sync.js +3 -1
  15. package/dist/branch-metadata-file.d.ts +66 -0
  16. package/dist/branch-metadata-file.js +14 -0
  17. package/dist/branch-metadata.d.ts +4 -2
  18. package/dist/branch-metadata.js +10 -2
  19. package/dist/branch-schema-cache.d.ts +22 -2
  20. package/dist/branch-schema-cache.js +88 -12
  21. package/dist/cli/cli.js +91 -18
  22. package/dist/cli/generate-ai-content.js +72 -11
  23. package/dist/cli/init.js +2 -2
  24. package/dist/content-reader.d.ts +24 -10
  25. package/dist/content-reader.js +43 -6
  26. package/dist/context.js +43 -41
  27. package/dist/editor/BranchManager.d.ts +8 -4
  28. package/dist/editor/BranchManager.js +43 -18
  29. package/dist/editor/Editor.js +3 -2
  30. package/dist/editor/admin/SystemHealthPanel.js +65 -31
  31. package/dist/editor/admin/UnpublishedAtMergeAlert.d.ts +16 -0
  32. package/dist/editor/admin/UnpublishedAtMergeAlert.js +50 -0
  33. package/dist/editor/admin/useSystemHealth.d.ts +2 -0
  34. package/dist/editor/admin/useSystemHealth.js +21 -0
  35. package/dist/editor/branch-lock.d.ts +2 -0
  36. package/dist/editor/branch-lock.js +5 -3
  37. package/dist/editor/branch-status.d.ts +25 -1
  38. package/dist/editor/branch-status.js +75 -0
  39. package/dist/editor/components/EditorHeader.d.ts +6 -2
  40. package/dist/editor/components/EditorHeader.js +51 -30
  41. package/dist/editor/components/EntryHeader.d.ts +3 -1
  42. package/dist/editor/components/EntryHeader.js +2 -1
  43. package/dist/editor/components/TooltipDetail.d.ts +14 -0
  44. package/dist/editor/components/TooltipDetail.js +18 -0
  45. package/dist/editor/copy.d.ts +7 -0
  46. package/dist/editor/copy.js +9 -0
  47. package/dist/editor/hooks/useBranchManager.d.ts +8 -3
  48. package/dist/editor/hooks/useBranchManager.js +48 -5
  49. package/dist/pr-comments.d.ts +2 -0
  50. package/dist/pr-comments.js +20 -14
  51. package/dist/resource-generation.js +2 -3
  52. package/dist/server.d.ts +6 -1
  53. package/dist/server.js +6 -1
  54. package/dist/submission-attribution.d.ts +1 -1
  55. package/dist/submission-attribution.js +20 -2
  56. package/dist/task-queue/task-queue.d.ts +5 -1
  57. package/dist/task-queue/task-queue.js +4 -2
  58. package/dist/types.d.ts +61 -0
  59. package/dist/version.d.ts +1 -1
  60. package/dist/version.js +1 -1
  61. package/dist/worker/branch-merge.d.ts +68 -0
  62. package/dist/worker/branch-merge.js +228 -0
  63. package/dist/worker/canopy-state.d.ts +2 -0
  64. package/dist/worker/canopy-state.js +1 -1
  65. package/dist/worker/cms-worker.d.ts +1 -0
  66. package/dist/worker/cms-worker.js +5 -3
  67. package/dist/worker/git-sync.js +15 -8
  68. package/dist/worker/github-commits.d.ts +106 -0
  69. package/dist/worker/github-commits.js +485 -0
  70. package/dist/worker/github-gateway-http.js +3 -2
  71. package/dist/worker/github-gateway-wire.d.ts +24 -15
  72. package/dist/worker/github-gateway-wire.js +4 -1
  73. package/dist/worker/github-gateway.d.ts +16 -5
  74. package/dist/worker/github-gateway.js +22 -5
  75. package/dist/worker/github-mirror.d.ts +8 -0
  76. package/dist/worker/github-mirror.js +12 -1
  77. package/dist/worker/github-transfer.d.ts +3 -1
  78. package/dist/worker/github-transfer.js +3 -2
  79. package/dist/worker/history-rewrite.d.ts +13 -1
  80. package/dist/worker/history-rewrite.js +27 -3
  81. package/dist/worker/rebase.d.ts +13 -7
  82. package/dist/worker/rebase.js +351 -95
  83. package/dist/worker/task-runner.d.ts +1 -1
  84. package/dist/worker/task-runner.js +172 -34
  85. package/dist/worker/worker-context.d.ts +2 -0
  86. package/package.json +1 -1
@@ -18,6 +18,7 @@ export declare const pushRequest: z.ZodObject<{
18
18
  branch: z.ZodString;
19
19
  sha: z.ZodString;
20
20
  lease: z.ZodOptional<z.ZodString>;
21
+ seen: z.ZodOptional<z.ZodString>;
21
22
  prerequisites: z.ZodArray<z.ZodString, "many">;
22
23
  /** Whether the request body is a bundle; without one it is empty. */
23
24
  bundle: z.ZodBoolean;
@@ -26,12 +27,14 @@ export declare const pushRequest: z.ZodObject<{
26
27
  sha: string;
27
28
  prerequisites: string[];
28
29
  bundle: boolean;
30
+ seen?: string | undefined;
29
31
  lease?: string | undefined;
30
32
  }, {
31
33
  branch: string;
32
34
  sha: string;
33
35
  prerequisites: string[];
34
36
  bundle: boolean;
37
+ seen?: string | undefined;
35
38
  lease?: string | undefined;
36
39
  }>;
37
40
  export declare const requests: {
@@ -52,13 +55,13 @@ export declare const requests: {
52
55
  base: z.ZodString;
53
56
  }, "strict", z.ZodTypeAny, {
54
57
  title: string;
55
- head: string;
56
58
  base: string;
59
+ head: string;
57
60
  body: string;
58
61
  }, {
59
62
  title: string;
60
- head: string;
61
63
  base: string;
64
+ head: string;
62
65
  body: string;
63
66
  }>;
64
67
  readonly updatePullRequest: z.ZodObject<{
@@ -93,15 +96,15 @@ export declare const requests: {
93
96
  base: z.ZodString;
94
97
  }, "strict", z.ZodTypeAny, {
95
98
  title: string;
96
- head: string;
97
99
  base: string;
100
+ head: string;
98
101
  body: string;
99
102
  markReadyIfDraft?: boolean | undefined;
100
103
  mergeSectionIntoBody?: boolean | undefined;
101
104
  }, {
102
105
  title: string;
103
- head: string;
104
106
  base: string;
107
+ head: string;
105
108
  body: string;
106
109
  markReadyIfDraft?: boolean | undefined;
107
110
  mergeSectionIntoBody?: boolean | undefined;
@@ -206,18 +209,24 @@ export declare const answers: {
206
209
  state: z.ZodEnum<["open", "closed"]>;
207
210
  node_id: z.ZodString;
208
211
  body: z.ZodNullable<z.ZodString>;
212
+ mergeable_state: z.ZodOptional<z.ZodString>;
213
+ baseRef: z.ZodOptional<z.ZodString>;
209
214
  }, "strict", z.ZodTypeAny, {
210
215
  merged: boolean;
211
216
  state: "open" | "closed";
212
217
  body: string | null;
213
218
  merged_at: string | null;
214
219
  node_id: string;
220
+ mergeable_state?: string | undefined;
221
+ baseRef?: string | undefined;
215
222
  }, {
216
223
  merged: boolean;
217
224
  state: "open" | "closed";
218
225
  body: string | null;
219
226
  merged_at: string | null;
220
227
  node_id: string;
228
+ mergeable_state?: string | undefined;
229
+ baseRef?: string | undefined;
221
230
  }>;
222
231
  readonly createOrUpdatePullRequest: z.ZodObject<{
223
232
  number: z.ZodNumber;
@@ -303,21 +312,21 @@ export declare const answers: {
303
312
  kind: z.ZodEnum<["auto-scaling", "spot"]>;
304
313
  reason: z.ZodString;
305
314
  }, "strict", z.ZodTypeAny, {
306
- reason: string;
307
315
  kind: "auto-scaling" | "spot";
308
- }, {
309
316
  reason: string;
317
+ }, {
310
318
  kind: "auto-scaling" | "spot";
319
+ reason: string;
311
320
  }>>;
312
321
  }, "strict", z.ZodTypeAny, {
313
322
  notice: {
314
- reason: string;
315
323
  kind: "auto-scaling" | "spot";
324
+ reason: string;
316
325
  } | null;
317
326
  }, {
318
327
  notice: {
319
- reason: string;
320
328
  kind: "auto-scaling" | "spot";
329
+ reason: string;
321
330
  } | null;
322
331
  }>;
323
332
  readonly completeLifecycle: z.ZodObject<{}, "strict", z.ZodTypeAny, {}, {}>;
@@ -422,28 +431,28 @@ declare const errorEnvelope: z.ZodDiscriminatedUnion<"kind", [z.ZodObject<{
422
431
  branch: string;
423
432
  push: boolean;
424
433
  message: string;
425
- reason: "settings" | "protected" | "invalid" | "other-settings" | "outside-namespace" | "not-a-commit" | "too-long";
426
434
  kind: "refusal";
427
- operation: "push" | "pull-request" | "delete";
435
+ reason: "settings" | "protected" | "invalid" | "other-settings" | "outside-namespace" | "not-a-commit" | "too-long";
436
+ operation: "push" | "delete" | "pull-request";
428
437
  }, {
429
438
  branch: string;
430
439
  push: boolean;
431
440
  message: string;
432
- reason: "settings" | "protected" | "invalid" | "other-settings" | "outside-namespace" | "not-a-commit" | "too-long";
433
441
  kind: "refusal";
434
- operation: "push" | "pull-request" | "delete";
442
+ reason: "settings" | "protected" | "invalid" | "other-settings" | "outside-namespace" | "not-a-commit" | "too-long";
443
+ operation: "push" | "delete" | "pull-request";
435
444
  }>, z.ZodObject<{
436
445
  kind: z.ZodLiteral<"push">;
437
- pushKind: z.ZodEnum<["rejected", "rejected-after-stale-lease"]>;
446
+ pushKind: z.ZodEnum<["rejected", "rejected-after-stale-lease", "seen-commit-gone"]>;
438
447
  message: z.ZodString;
439
448
  }, "strict", z.ZodTypeAny, {
440
449
  message: string;
441
450
  kind: "push";
442
- pushKind: "rejected" | "rejected-after-stale-lease";
451
+ pushKind: "rejected" | "rejected-after-stale-lease" | "seen-commit-gone";
443
452
  }, {
444
453
  message: string;
445
454
  kind: "push";
446
- pushKind: "rejected" | "rejected-after-stale-lease";
455
+ pushKind: "rejected" | "rejected-after-stale-lease" | "seen-commit-gone";
447
456
  }>, z.ZodObject<{
448
457
  kind: z.ZodLiteral<"transient">;
449
458
  code: z.ZodEnum<["github-fetch-failed", "bundle-rejected", "prerequisite-gone", "bundle-gone", "mirror-corrupt", "objects-still-needed", "ref-kept-moving", "default-branch-unread"]>;
@@ -43,6 +43,7 @@ export const pushRequest = z
43
43
  branch: name,
44
44
  sha: objectId,
45
45
  lease: objectId.optional(),
46
+ seen: objectId.optional(),
46
47
  prerequisites: z.array(objectId).max(3),
47
48
  /** Whether the request body is a bundle; without one it is empty. */
48
49
  bundle: z.boolean(),
@@ -111,6 +112,8 @@ export const answers = {
111
112
  state: z.enum(['open', 'closed']),
112
113
  node_id: z.string().max(256),
113
114
  body: pullRequestText.body.nullable(),
115
+ mergeable_state: z.string().max(64).optional(),
116
+ baseRef: z.string().max(256).optional(),
114
117
  })
115
118
  .strict(),
116
119
  createOrUpdatePullRequest: pullRequestRef.extend({ created: z.boolean() }).strict(),
@@ -250,7 +253,7 @@ const errorEnvelope = z.discriminatedUnion('kind', [
250
253
  z
251
254
  .object({
252
255
  kind: z.literal('push'),
253
- pushKind: z.enum(['rejected', 'rejected-after-stale-lease']),
256
+ pushKind: z.enum(['rejected', 'rejected-after-stale-lease', 'seen-commit-gone']),
254
257
  message,
255
258
  })
256
259
  .strict(),
@@ -24,6 +24,12 @@ export interface GitHubPushRequest {
24
24
  sha: string;
25
25
  /** The commit GitHub must still be at for the push to replace it (`--force-with-lease`). */
26
26
  lease?: string;
27
+ /**
28
+ * The last commit the caller saw on GitHub's branch. A reset on GitHub leaves a tip the push
29
+ * would fast-forward, restoring what it removed, so a push is refused while GitHub's branch
30
+ * doesn't contain this commit.
31
+ */
32
+ seen?: string;
27
33
  /**
28
34
  * At most {@link MAX_PUSH_PREREQUISITES} commits the caller would build a bundle of the branch
29
35
  * on, so an answer of `need-objects` can say which of them the gateway can use.
@@ -50,13 +56,14 @@ export type GitHubPushAnswer = ({
50
56
  };
51
57
  /**
52
58
  * A push to GitHub failed: `rejected` on the first attempt, `rejected-after-stale-lease` on the
53
- * plain retry after GitHub refused a stale lease. The message is the failure's own: git's output,
54
- * for a refusal, which the task runner classifies. A {@link RefusedPushError} from the first
55
- * attempt is thrown as itself.
59
+ * plain retry after GitHub refused a stale lease, `seen-commit-gone` before any attempt, when
60
+ * GitHub's branch no longer holds {@link GitHubPushRequest.seen}. The message is the failure's
61
+ * own: git's output, for a refusal, which the task runner classifies. A {@link RefusedPushError}
62
+ * from the first attempt is thrown as itself.
56
63
  */
57
64
  export declare class GitHubPushError extends Error {
58
- readonly kind: 'rejected' | 'rejected-after-stale-lease';
59
- constructor(kind: 'rejected' | 'rejected-after-stale-lease', message: string);
65
+ readonly kind: 'rejected' | 'rejected-after-stale-lease' | 'seen-commit-gone';
66
+ constructor(kind: 'rejected' | 'rejected-after-stale-lease' | 'seen-commit-gone', message: string);
60
67
  }
61
68
  /** A moved ref for a log line: `<old7>..<new7>`, or `new branch at <new7>`. */
62
69
  export declare function describeMove(update: Extract<GitHubRefUpdate, {
@@ -70,6 +77,10 @@ export type GitHubPullRequest = Pick<RestEndpointMethodTypes['pulls']['get']['re
70
77
  * request a person opened from a branch in the namespace has its body answered.
71
78
  */
72
79
  body: string | null;
80
+ /** GitHub's own word on what stands between the pull request and a merge; `behind` needs the base. */
81
+ mergeable_state?: string;
82
+ /** The branch the pull request merges into on GitHub, which a person can change. */
83
+ baseRef?: string;
73
84
  };
74
85
  export type GitHubCreateOrUpdatePullRequest = Omit<CreateOrUpdatePullRequestParams, 'octokit' | 'owner' | 'repo' | 'signal'>;
75
86
  /**
@@ -30,9 +30,10 @@ const MAX_PUSH_PREREQUISITES = 3;
30
30
  const DEFAULT_MAX_BUNDLE_BYTES = 1024 * 1024 * 1024;
31
31
  /**
32
32
  * A push to GitHub failed: `rejected` on the first attempt, `rejected-after-stale-lease` on the
33
- * plain retry after GitHub refused a stale lease. The message is the failure's own: git's output,
34
- * for a refusal, which the task runner classifies. A {@link RefusedPushError} from the first
35
- * attempt is thrown as itself.
33
+ * plain retry after GitHub refused a stale lease, `seen-commit-gone` before any attempt, when
34
+ * GitHub's branch no longer holds {@link GitHubPushRequest.seen}. The message is the failure's
35
+ * own: git's output, for a refusal, which the task runner classifies. A {@link RefusedPushError}
36
+ * from the first attempt is thrown as itself.
36
37
  */
37
38
  export class GitHubPushError extends Error {
38
39
  constructor(kind, message) {
@@ -498,6 +499,8 @@ class LocalGitHubGateway {
498
499
  assertObjectId(request.sha, 'a commit ID');
499
500
  if (request.lease !== undefined)
500
501
  assertObjectId(request.lease, 'a commit ID');
502
+ if (request.seen !== undefined)
503
+ assertObjectId(request.seen, 'a commit ID');
501
504
  if (request.prerequisites.length > MAX_PUSH_PREREQUISITES) {
502
505
  throw new Error(`A push names at most ${MAX_PUSH_PREREQUISITES} prerequisites`);
503
506
  }
@@ -513,6 +516,10 @@ class LocalGitHubGateway {
513
516
  // and sessions are not re-entrant. So does resolving the credential, so the session never
514
517
  // waits on GitHub's API. A body is read first, so its writer is not left waiting.
515
518
  const session = (received) => this.afterGitHubFetch(async () => {
519
+ // A reused fetch can predate a reset, whose heads would still show the commit `seen`
520
+ // names. Nothing moved at GitHub, so this ends the reuse and leaves other fetches good.
521
+ if (request.seen !== undefined)
522
+ this.githubFetch = null;
516
523
  const snapshot = await this.joinGitHubFetchForPush(credential, request.branch, signal);
517
524
  await credential();
518
525
  return snapshot;
@@ -573,8 +580,15 @@ class LocalGitHubGateway {
573
580
  else {
574
581
  repo = await mirror.quarantine(received, request, heads, signal);
575
582
  }
576
- const attempt = (lease) => mirror.pushToGitHub(credential, branch, sha, {
583
+ const tip = heads.get(branch);
584
+ if (request.seen !== undefined &&
585
+ tip !== undefined &&
586
+ !(await mirror.historyHolds(tip, request.seen))) {
587
+ throw new GitHubPushError('seen-commit-gone', `GitHub's ${branch} no longer contains ${request.seen}, the last commit seen there`);
588
+ }
589
+ const attempt = (lease, pin) => mirror.pushToGitHub(credential, branch, sha, {
577
590
  lease,
591
+ pin,
578
592
  signal,
579
593
  repo,
580
594
  policy: this.policy,
@@ -582,7 +596,8 @@ class LocalGitHubGateway {
582
596
  onPushing: () => this.invalidateGitHubFetch(),
583
597
  });
584
598
  try {
585
- return { kind: 'pushed', ...(await attempt(request.lease)), pastStaleLease: false };
599
+ const pin = request.seen === undefined || request.lease !== undefined ? undefined : tip;
600
+ return { kind: 'pushed', ...(await attempt(request.lease, pin)), pastStaleLease: false };
586
601
  }
587
602
  catch (err) {
588
603
  if (err instanceof RefusedPushError || err instanceof GitHubTransientError)
@@ -671,6 +686,8 @@ class LocalGitHubGateway {
671
686
  state: data.state,
672
687
  node_id: data.node_id,
673
688
  body: ours ? (data.body ?? '') : null,
689
+ mergeable_state: data.mergeable_state,
690
+ baseRef: data.base.ref,
674
691
  };
675
692
  });
676
693
  }
@@ -294,6 +294,8 @@ export declare class MirrorSession {
294
294
  reachableFromHeads(sha: string): Promise<boolean>;
295
295
  /** Of `prerequisites`, the commits a GitHub branch contained at the last fetch. */
296
296
  usablePrerequisites(prerequisites: readonly string[]): Promise<string[]>;
297
+ /** Whether `tip`'s history holds `commit`. One the mirror doesn't hold, GitHub's heads don't. */
298
+ historyHolds(tip: string, commit: string): Promise<boolean>;
297
299
  /**
298
300
  * Whether every one of `commits` is reachable from one of `tips`, in ONE walk: rev-list fed the
299
301
  * commits and a `^<tip>` per tip lists what the tips do not reach, so empty output means all
@@ -337,6 +339,12 @@ export declare class MirrorSession {
337
339
  */
338
340
  pushToGitHub(credential: GitHubCredential, branch: string, sha: string, options: {
339
341
  lease?: string;
342
+ /**
343
+ * The tip a caller's check saw. A reset to an ancestor of `sha` after that check would
344
+ * otherwise take a plain push, so a push that fast-forwards this tip carries it as a lease,
345
+ * which forces nothing there and is refused if the ref moved.
346
+ */
347
+ pin?: string;
340
348
  signal?: AbortSignal;
341
349
  repo?: string;
342
350
  policy: BranchPolicy;
@@ -775,6 +775,12 @@ export class MirrorSession {
775
775
  }
776
776
  return usable;
777
777
  }
778
+ /** Whether `tip`'s history holds `commit`. One the mirror doesn't hold, GitHub's heads don't. */
779
+ async historyHolds(tip, commit) {
780
+ if ((await this.objectTypes([commit])).get(commit) !== 'commit')
781
+ return false;
782
+ return this.allReachable([commit], [tip]);
783
+ }
778
784
  /**
779
785
  * Whether every one of `commits` is reachable from one of `tips`, in ONE walk: rev-list fed the
780
786
  * commits and a `^<tip>` per tip lists what the tips do not reach, so empty output means all
@@ -902,12 +908,17 @@ export class MirrorSession {
902
908
  throw new RefusedPushError(branch, 'invalid', undefined, branchInsideMessage(branch, inside));
903
909
  }
904
910
  }
911
+ const pinned = options.pin !== undefined &&
912
+ (await this.run(['rev-list', '--max-count=1', '--end-of-options', options.pin, `^${sha}`], {
913
+ repo: options.repo,
914
+ })) === '';
915
+ const lease = options.lease ?? (pinned ? options.pin : undefined);
905
916
  options.onPushing?.();
906
917
  const status = await this.githubBound(credential, [
907
918
  'push',
908
919
  '--porcelain',
909
920
  '--progress',
910
- ...(options.lease ? [`--force-with-lease=refs/heads/${branch}:${options.lease}`] : []),
921
+ ...(lease ? [`--force-with-lease=refs/heads/${branch}:${lease}`] : []),
911
922
  '--end-of-options',
912
923
  credential.url,
913
924
  `${sha}:refs/heads/${branch}`,
@@ -32,7 +32,9 @@ export declare function seedFromGitHub(ctx: TransferContext, gitDir: string, bas
32
32
  */
33
33
  export declare function unreachableFrom(ctx: TransferContext, gitDir: string, ids: readonly string[], tips: readonly string[]): Promise<Set<string>>;
34
34
  /** A push to GitHub of the commit `readSha` finds, as {@link pushToGitHub} takes it. */
35
- export interface TransferPushRequest extends Omit<GitHubPushRequest, 'sha' | 'prerequisites'> {
35
+ export interface TransferPushRequest extends Omit<GitHubPushRequest, 'sha' | 'prerequisites' | 'seen'> {
36
+ /** {@link GitHubPushRequest.seen}, read after each attempt's commit, as the two go together. */
37
+ readSeen?: () => Promise<string | undefined>;
36
38
  /**
37
39
  * Branches whose GitHub tips, as `remote.git`'s tracking namespace holds them, a bundle of
38
40
  * `branch` is built on: the branch itself, and the base for a content branch. Missing ones are
@@ -109,11 +109,12 @@ export async function unreachableFrom(ctx, gitDir, ids, tips) {
109
109
  * {@link MAX_PUSH_RESTARTS} times, so a ref moved on every pass cannot hold a task for ever.
110
110
  */
111
111
  export async function pushToGitHub(ctx, request, readSha, signal) {
112
- const { prerequisiteBranches, ...rest } = request;
112
+ const { prerequisiteBranches, readSeen, ...rest } = request;
113
113
  for (let restarts = 0;; restarts++) {
114
114
  const sha = await readSha();
115
+ const seen = await readSeen?.();
115
116
  const prerequisites = await trackingTips(ctx.remoteGitPath, prerequisiteBranches);
116
- const ask = { ...rest, sha, prerequisites };
117
+ const ask = { ...rest, sha, ...(seen ? { seen } : {}), prerequisites };
117
118
  const first = await ctx.github().push(ask, undefined, signal);
118
119
  if (first.kind === 'pushed')
119
120
  return { sha, outcome: outcomeOf(first) };
@@ -44,11 +44,15 @@ export declare function readPublishedSha(ctx: Pick<HistoryRewriteContext, 'remot
44
44
  * caller's snapshot: the task loop runs concurrently with syncGit() and may
45
45
  * have cleared the marker while this branch was rebasing.
46
46
  *
47
+ * `rewrittenTip` goes in the same write as `lastGitHubTip`, so a push that lands
48
+ * and dies before its stamp is never read as someone rewriting the branch on
49
+ * GitHub (github-commits.ts).
50
+ *
47
51
  * Resolves whether `branchRef`'s branch.json now holds a marker, set here or kept by set-once.
48
52
  * When it does not, the caller publishes nothing: a rewrite pushed without its marker leaves
49
53
  * remote.git rewritten and the queued GitHub push failing with a false divergence diagnosis.
50
54
  */
51
- export declare function markHistoryRewritten(ctx: Pick<HistoryRewriteContext, 'contentBranchesPath'>, branchPath: string, branchRef: string, expectedSha: string): Promise<boolean>;
55
+ export declare function markHistoryRewritten(ctx: Pick<HistoryRewriteContext, 'contentBranchesPath'>, branchPath: string, branchRef: string, expectedSha: string, rewrittenTip: string): Promise<boolean>;
52
56
  /**
53
57
  * Clear the marker once GitHub is confirmed to hold the rewritten history.
54
58
  *
@@ -126,3 +130,11 @@ export declare function reconcilePendingRewrite(ctx: HistoryRewriteContext, opti
126
130
  * louder `diverged` bucket.
127
131
  */
128
132
  export declare function hasPendingHistoryRewrite(ctx: Pick<HistoryRewriteContext, 'contentBranchesPath'>, branchName: string): Promise<boolean>;
133
+ /**
134
+ * Records that GitHub's `branchName` holds `sha`, in `lastGitHubTip`. It runs before `remote.git`
135
+ * takes the commit, so `remote.git` never holds a GitHub commit the record doesn't name: a clone
136
+ * can lag `remote.git` for as long as it stays busy, and once GitHub drops that commit it could not
137
+ * be told from the branch's own work (github-commits.ts). A branch name with no workspace has no
138
+ * record, and a directory whose branch.json names another ref is that ref's.
139
+ */
140
+ export declare function recordSeenOnGitHub(ctx: Pick<HistoryRewriteContext, 'contentBranchesPath'>, branchName: string, sha: string): Promise<void>;
@@ -47,18 +47,28 @@ export async function readPublishedSha(ctx, branchRef) {
47
47
  * caller's snapshot: the task loop runs concurrently with syncGit() and may
48
48
  * have cleared the marker while this branch was rebasing.
49
49
  *
50
+ * `rewrittenTip` goes in the same write as `lastGitHubTip`, so a push that lands
51
+ * and dies before its stamp is never read as someone rewriting the branch on
52
+ * GitHub (github-commits.ts).
53
+ *
50
54
  * Resolves whether `branchRef`'s branch.json now holds a marker, set here or kept by set-once.
51
55
  * When it does not, the caller publishes nothing: a rewrite pushed without its marker leaves
52
56
  * remote.git rewritten and the queued GitHub push failing with a false divergence diagnosis.
53
57
  */
54
- export async function markHistoryRewritten(ctx, branchPath, branchRef, expectedSha) {
58
+ export async function markHistoryRewritten(ctx, branchPath, branchRef, expectedSha, rewrittenTip) {
55
59
  const meta = getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath);
56
60
  let marked = false;
57
61
  await meta.update((current) => {
58
62
  marked = current?.branch.name === branchRef;
59
- if (!marked || typeof current?.branch.historyRewrittenFrom === 'string')
63
+ if (!marked)
60
64
  return null;
61
- return { branch: { historyRewrittenFrom: expectedSha } };
65
+ const marker = current?.branch.historyRewrittenFrom;
66
+ return {
67
+ branch: {
68
+ historyRewrittenFrom: typeof marker === 'string' ? marker : expectedSha,
69
+ lastGitHubTip: rewrittenTip,
70
+ },
71
+ };
62
72
  });
63
73
  return marked;
64
74
  }
@@ -226,3 +236,17 @@ export async function hasPendingHistoryRewrite(ctx, branchName) {
226
236
  return false;
227
237
  }
228
238
  }
239
+ /**
240
+ * Records that GitHub's `branchName` holds `sha`, in `lastGitHubTip`. It runs before `remote.git`
241
+ * takes the commit, so `remote.git` never holds a GitHub commit the record doesn't name: a clone
242
+ * can lag `remote.git` for as long as it stays busy, and once GitHub drops that commit it could not
243
+ * be told from the branch's own work (github-commits.ts). A branch name with no workspace has no
244
+ * record, and a directory whose branch.json names another ref is that ref's.
245
+ */
246
+ export async function recordSeenOnGitHub(ctx, branchName, sha) {
247
+ const branchPath = path.join(ctx.contentBranchesPath, sanitizeBranchName(branchName));
248
+ const metaFile = await BranchMetadataFileManager.loadOnly(branchPath);
249
+ if (metaFile?.branch.name !== branchName)
250
+ return;
251
+ await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).update((current) => (current?.branch.name === branchName ? { branch: { lastGitHubTip: sha } } : null), { keepUpdatedAt: true });
252
+ }
@@ -1,5 +1,7 @@
1
1
  import { type BranchMetadataFile } from '../branch-metadata.js';
2
- import type { BranchPolicyDescription } from './branch-policy.js';
2
+ import { type BranchPolicyDescription } from './branch-policy.js';
3
+ import type { GitHubPullRequest } from './github-gateway.js';
4
+ import { type GitHubSnapshot } from './github-commits.js';
3
5
  import type { WorkerContext } from './worker-context.js';
4
6
  /**
5
7
  * The rebase loop: the deepest leaf of the git-sync cluster, and the most
@@ -16,12 +18,16 @@ import type { WorkerContext } from './worker-context.js';
16
18
  * - it carries a rewritten history forward into remote.git and GitHub under a
17
19
  * lease keyed to the exact commit it replaced ([SYNC-H1], history-rewrite.ts).
18
20
  *
21
+ * Ahead of the rebase it brings commits made on GitHub into the clone, for every
22
+ * status (github-commits.ts): the rebase would otherwise rewrite the clone away
23
+ * from a tip it never had.
24
+ *
19
25
  * `pollMergeState` lives here rather than in git-sync.ts because the rebase
20
26
  * loop is its only caller: submitted/approved branches are skipped for rebasing
21
27
  * but still need their PR's resolution polled, and this loop is the only thing
22
28
  * that walks every branch workspace.
23
29
  */
24
- export type RebaseContext = Pick<WorkerContext, 'baseBranch' | 'sanitizedBaseBranch' | 'contentBranchesPath' | 'contentRoot' | 'remoteGitPath' | 'taskDir' | 'taskTimeoutMs' | 'github' | 'afterConflictDetectedForTesting' | 'afterRebaseCompletedForTesting' | 'ensureSettingsBranch' | 'isDraining'>;
30
+ export type RebaseContext = Pick<WorkerContext, 'baseBranch' | 'sanitizedBaseBranch' | 'contentBranchesPath' | 'contentRoot' | 'remoteGitPath' | 'taskDir' | 'taskTimeoutMs' | 'github' | 'ensureStatusReport' | 'afterConflictDetectedForTesting' | 'afterRebaseCompletedForTesting' | 'ensureSettingsBranch' | 'isDraining'>;
25
31
  /**
26
32
  * Per-cycle outcome of `rebaseActiveBranches()`, folded by `syncGit()` into the
27
33
  * worker's self-reported status (`WorkerStatusReport.lastGitSync`, see
@@ -29,8 +35,8 @@ export type RebaseContext = Pick<WorkerContext, 'baseBranch' | 'sanitizedBaseBra
29
35
  */
30
36
  export interface RebaseSummary {
31
37
  /**
32
- * Branches that were behind and completed a rebase onto the base branch,
33
- * whether or not conflicts were resolved via --theirs. Branches already up to
38
+ * Branches that were behind and caught up with the base branch, by a rebase or
39
+ * by merging it, whether or not conflicts were resolved. Branches already up to
34
40
  * date are NOT listed here.
35
41
  */
36
42
  rebased: string[];
@@ -64,9 +70,9 @@ export interface RebaseSummary {
64
70
  * markAsMerged API so both paths produce identical archived-branch metadata.
65
71
  * closed-without-merge -> record pullRequestState only; an admin decides the
66
72
  * workflow transition. Best-effort: a failure is logged, swallowed and retried
67
- * next sync cycle.
73
+ * next sync cycle. Resolves GitHub's answer for a pull request not merged.
68
74
  */
69
- export declare function pollMergeState(ctx: RebaseContext, branchDir: string, branchPath: string, metaFile: BranchMetadataFile | null): Promise<void>;
75
+ export declare function pollMergeState(ctx: RebaseContext, branchDir: string, branchPath: string, metaFile: BranchMetadataFile | null): Promise<GitHubPullRequest | null>;
70
76
  /**
71
77
  * Rebase every branch workspace that is behind the base branch, one cycle.
72
78
  *
@@ -79,4 +85,4 @@ export declare function pollMergeState(ctx: RebaseContext, branchDir: string, br
79
85
  * that branch's cross-host content-write lock and runs a series of git
80
86
  * subprocesses against a shared filesystem.
81
87
  */
82
- export declare function runRebaseCycle(ctx: RebaseContext, gatewayPolicy?: BranchPolicyDescription): Promise<RebaseSummary>;
88
+ export declare function runRebaseCycle(ctx: RebaseContext, gatewayPolicy?: BranchPolicyDescription, gitHub?: GitHubSnapshot): Promise<RebaseSummary>;