@fern-api/replay 0.15.2 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -554,9 +554,22 @@ function isGenerationCommit(commit) {
554
554
  const hasGenerationMarker = commit.message.startsWith("[fern-generated]") || commit.message.startsWith("[fern-replay]") || commit.message.startsWith("[fern-autoversion]") || commit.message.includes("Generated by Fern") || commit.message.includes("\u{1F916} Generated with Fern") || // Squash merge of a Fern-generated PR uses the PR title as commit message.
555
555
  // The default PR title is "SDK Generation" (from GithubStep's commitMessage default).
556
556
  // GitHub appends "(#N)" for the PR number, e.g. "SDK Generation (#70)".
557
- commit.message.startsWith("SDK Generation");
557
+ commit.message.startsWith("SDK Generation") || // Merge commit (GitHub's "Create a merge commit" option) of a fern-bot
558
+ // regen PR. First-parent walking lands on this commit, not on the
559
+ // generation commit on the side branch, so the classifier must treat
560
+ // it as a generation boundary. GitHub's default subject is
561
+ // "Merge pull request #N from <owner>/<branch>"; fern-bot branches are
562
+ // named "<owner>/fern-bot/<...>".
563
+ /^Merge pull request #\d+ from [^/]+\/fern-bot\//.test(commit.message);
558
564
  return isBotAuthor || hasGenerationMarker;
559
565
  }
566
+ function isGenerationBoundary(commit) {
567
+ const isFernSupport = FERN_SUPPORT_NAMES.includes(commit.authorName);
568
+ const isBotAuthor = !isFernSupport && (commit.authorLogin === FERN_BOT_LOGIN || commit.authorEmail === FERN_BOT_EMAIL || commit.authorName === FERN_BOT_NAME);
569
+ const isAutoversion = commit.message.startsWith("[fern-autoversion]");
570
+ const hasBoundaryMarker = commit.message.startsWith("[fern-generated]") || commit.message.startsWith("[fern-replay]") || commit.message.includes("Generated by Fern") || commit.message.includes("\u{1F916} Generated with Fern") || commit.message.startsWith("SDK Generation");
571
+ return isBotAuthor && !isAutoversion || hasBoundaryMarker;
572
+ }
560
573
  function isReplayCommit(commit) {
561
574
  return commit.message.startsWith("[fern-replay]");
562
575
  }
@@ -884,8 +897,48 @@ var ReplayDetector = class {
884
897
  this.lockManager = lockManager;
885
898
  this.sdkOutputDir = sdkOutputDir;
886
899
  }
900
+ /**
901
+ * Derive the previous-generation SHA from git history instead of trusting
902
+ * `lock.current_generation`.
903
+ *
904
+ * Walks `git log HEAD --first-parent` and returns the SHA of the most recent
905
+ * commit that `isGenerationBoundary` classifies as a customization-baseline
906
+ * boundary, or null if no such commit is reachable from HEAD on the mainline.
907
+ *
908
+ * `isGenerationBoundary` is intentionally narrower than `isGenerationCommit`:
909
+ * it excludes `[fern-autoversion]` (lightweight bumps that don't reset the
910
+ * baseline) and the GitHub merge-commit subject for fern-bot regen PRs
911
+ * (which sits on first-parent but where the customer fix-up commits live
912
+ * on second-parent — stopping at the merge would hide them).
913
+ *
914
+ * See docs/adr/0001-derived-scan-boundary.md.
915
+ */
916
+ async findPreviousGenerationFromHistory() {
917
+ let log;
918
+ try {
919
+ log = await this.git.exec([
920
+ "log",
921
+ "--first-parent",
922
+ "--format=%H%x00%an%x00%ae%x00%s",
923
+ "HEAD"
924
+ ]);
925
+ } catch {
926
+ return null;
927
+ }
928
+ for (const commit of this.parseGitLog(log)) {
929
+ if (isGenerationBoundary(commit)) {
930
+ return commit.sha;
931
+ }
932
+ }
933
+ return null;
934
+ }
887
935
  async detectNewPatches() {
888
936
  const lock = this.lockManager.read();
937
+ const derivedSha = await this.findPreviousGenerationFromHistory();
938
+ if (derivedSha !== null) {
939
+ const derivedRecord = await this.synthesizeGenerationRecord(derivedSha);
940
+ return this.detectPatchesInRange(derivedSha, derivedRecord, lock);
941
+ }
889
942
  const lastGen = this.getLastGeneration(lock);
890
943
  if (!lastGen) {
891
944
  return { patches: [], revertedPatchIds: [] };
@@ -893,7 +946,7 @@ var ReplayDetector = class {
893
946
  const exists = await this.git.commitExists(lastGen.commit_sha);
894
947
  if (!exists) {
895
948
  this.warnings.push(
896
- `Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to alternate detection.`
949
+ `Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to tree-diff detection (likely a shallow clone).`
897
950
  );
898
951
  return this.detectPatchesViaTreeDiff(
899
952
  lastGen,
@@ -901,44 +954,8 @@ var ReplayDetector = class {
901
954
  true
902
955
  );
903
956
  }
904
- const isAncestor = await this.git.isAncestor(lastGen.commit_sha, "HEAD");
905
- if (!isAncestor) {
906
- return this.detectPatchesViaMergeBase(lastGen, lock);
907
- }
908
957
  return this.detectPatchesInRange(lastGen.commit_sha, lastGen, lock);
909
958
  }
910
- /**
911
- * Non-linear-history detection via `merge-base(prevGen, HEAD)`.
912
- *
913
- * After a squash-merge of a Fern-bot PR, `lastGen.commit_sha` (the previous
914
- * `[fern-generated]`) is orphaned but still in git's object database. The
915
- * merge-base is the commit on main from which the bot's branch was created —
916
- * a reachable ancestor of HEAD. Walking that range and classifying each
917
- * commit via `isGenerationCommit` mirrors the linear path and eliminates
918
- * the bug class where pipeline-driven changes (autoversion, replay,
919
- * generation) get bundled as customer customizations.
920
- *
921
- * Falls through to the legacy composite path when no common ancestor exists
922
- * (truly disjoint history — orphan branches with no shared root).
923
- */
924
- async detectPatchesViaMergeBase(lastGen, lock) {
925
- let mergeBase = "";
926
- try {
927
- mergeBase = (await this.git.exec(["merge-base", lastGen.commit_sha, "HEAD"])).trim();
928
- } catch {
929
- }
930
- if (!mergeBase) {
931
- this.warnings.push(
932
- `No common ancestor between previous generation ${lastGen.commit_sha.slice(0, 7)} and HEAD. Falling back to composite tree-diff detection.`
933
- );
934
- return this.detectPatchesViaTreeDiff(
935
- lastGen,
936
- /* commitKnownMissing */
937
- false
938
- );
939
- }
940
- return this.detectPatchesInRange(mergeBase, lastGen, lock);
941
- }
942
959
  /**
943
960
  * Walk `${rangeStart}..HEAD`, classify each commit, emit one
944
961
  * `StoredPatch` per customer commit. Body shared by the linear path
@@ -1374,6 +1391,26 @@ var ReplayDetector = class {
1374
1391
  getLastGeneration(lock) {
1375
1392
  return lock.generations.find((g) => g.commit_sha === lock.current_generation);
1376
1393
  }
1394
+ /**
1395
+ * Build a transient `GenerationRecord` from a SHA derived by walking git
1396
+ * history. Only `commit_sha` (and `tree_hash` when needed downstream) is
1397
+ * load-bearing here; the rest are filled with defensible placeholders.
1398
+ * This record is consumed by `detectPatchesInRange` and is never persisted.
1399
+ */
1400
+ async synthesizeGenerationRecord(sha) {
1401
+ let tree_hash = "";
1402
+ try {
1403
+ tree_hash = await this.git.getTreeHash(sha);
1404
+ } catch {
1405
+ }
1406
+ return {
1407
+ commit_sha: sha,
1408
+ tree_hash,
1409
+ timestamp: (/* @__PURE__ */ new Date(0)).toISOString(),
1410
+ cli_version: "",
1411
+ generator_versions: {}
1412
+ };
1413
+ }
1377
1414
  };
1378
1415
 
1379
1416
  // src/ThreeWayMerge.ts
@@ -2513,8 +2550,7 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
2513
2550
  tree_hash: treeHash,
2514
2551
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2515
2552
  cli_version: options?.cliVersion ?? "unknown",
2516
- generator_versions: options?.generatorVersions ?? {},
2517
- base_branch_head: options?.baseBranchHead
2553
+ generator_versions: options?.generatorVersions ?? {}
2518
2554
  };
2519
2555
  }
2520
2556
  async stageAll() {
@@ -2901,7 +2937,6 @@ var FirstGenerationFlow = class {
2901
2937
  flow: "first-generation",
2902
2938
  previousGenerationSha: null,
2903
2939
  currentGenerationSha: headSha,
2904
- baseBranchHead: options.baseBranchHead ?? null,
2905
2940
  _prepared: {
2906
2941
  kind: "terminal",
2907
2942
  flow: "first-generation",
@@ -2911,8 +2946,7 @@ var FirstGenerationFlow = class {
2911
2946
  }
2912
2947
  const commitOpts = options ? {
2913
2948
  cliVersion: options.cliVersion ?? "unknown",
2914
- generatorVersions: options.generatorVersions ?? {},
2915
- baseBranchHead: options.baseBranchHead
2949
+ generatorVersions: options.generatorVersions ?? {}
2916
2950
  } : void 0;
2917
2951
  await this.service.committer.commitGeneration("Initial SDK generation", commitOpts);
2918
2952
  const genRecord = await this.service.committer.createGenerationRecord(commitOpts);
@@ -2921,7 +2955,6 @@ var FirstGenerationFlow = class {
2921
2955
  flow: "first-generation",
2922
2956
  previousGenerationSha: null,
2923
2957
  currentGenerationSha: genRecord.commit_sha,
2924
- baseBranchHead: options?.baseBranchHead ?? null,
2925
2958
  _prepared: {
2926
2959
  kind: "active",
2927
2960
  flow: "first-generation",
@@ -2958,8 +2991,7 @@ var SkipApplicationFlow = class {
2958
2991
  async prepare(options) {
2959
2992
  const commitOpts = options ? {
2960
2993
  cliVersion: options.cliVersion ?? "unknown",
2961
- generatorVersions: options.generatorVersions ?? {},
2962
- baseBranchHead: options.baseBranchHead
2994
+ generatorVersions: options.generatorVersions ?? {}
2963
2995
  } : void 0;
2964
2996
  await this.service.committer.commitGeneration("Update SDK (replay skipped)", commitOpts);
2965
2997
  await this.service.cleanupStaleConflictMarkers();
@@ -2981,7 +3013,6 @@ var SkipApplicationFlow = class {
2981
3013
  flow: "skip-application",
2982
3014
  previousGenerationSha,
2983
3015
  currentGenerationSha: genRecord.commit_sha,
2984
- baseBranchHead: options?.baseBranchHead ?? null,
2985
3016
  _prepared: {
2986
3017
  kind: "active",
2987
3018
  flow: "skip-application",
@@ -3042,7 +3073,6 @@ var NoPatchesFlow = class {
3042
3073
  flow: "no-patches",
3043
3074
  previousGenerationSha,
3044
3075
  currentGenerationSha: headSha,
3045
- baseBranchHead: options.baseBranchHead ?? null,
3046
3076
  _prepared: {
3047
3077
  kind: "terminal",
3048
3078
  flow: "no-patches",
@@ -3074,8 +3104,7 @@ var NoPatchesFlow = class {
3074
3104
  }
3075
3105
  const commitOpts = options ? {
3076
3106
  cliVersion: options.cliVersion ?? "unknown",
3077
- generatorVersions: options.generatorVersions ?? {},
3078
- baseBranchHead: options.baseBranchHead
3107
+ generatorVersions: options.generatorVersions ?? {}
3079
3108
  } : void 0;
3080
3109
  await this.service.committer.commitGeneration("Update SDK", commitOpts);
3081
3110
  await this.service.cleanupStaleConflictMarkers();
@@ -3085,7 +3114,6 @@ var NoPatchesFlow = class {
3085
3114
  flow: "no-patches",
3086
3115
  previousGenerationSha,
3087
3116
  currentGenerationSha: genRecord.commit_sha,
3088
- baseBranchHead: options?.baseBranchHead ?? null,
3089
3117
  _prepared: {
3090
3118
  kind: "active",
3091
3119
  flow: "no-patches",
@@ -3175,7 +3203,6 @@ var NormalRegenerationFlow = class {
3175
3203
  flow: "normal-regeneration",
3176
3204
  previousGenerationSha,
3177
3205
  currentGenerationSha: headSha,
3178
- baseBranchHead: options.baseBranchHead ?? null,
3179
3206
  _prepared: {
3180
3207
  kind: "terminal",
3181
3208
  flow: "normal-regeneration",
@@ -3193,14 +3220,16 @@ var NormalRegenerationFlow = class {
3193
3220
  existingPatches = this.service.lockManager.getPatches();
3194
3221
  const seenHashes = /* @__PURE__ */ new Map();
3195
3222
  for (const p of existingPatches) {
3196
- const priorPatchId = seenHashes.get(p.content_hash);
3197
- if (priorPatchId !== void 0) {
3198
- this.service.lockManager.removePatch(p.id);
3199
- this.service.warnings.push(
3200
- `Consolidated patch ${p.id} (commit ${(p.original_commit || "<missing>").slice(0, 7)}) into prior patch ${priorPatchId}: byte-identical patch_content. Per-commit attribution preserved in git log; lockfile collapsed to avoid duplicate-apply corruption on next regen.`
3201
- );
3202
- } else {
3203
- seenHashes.set(p.content_hash, p.id);
3223
+ if (!p.original_commit || p.original_commit === "") {
3224
+ const priorPatchId = seenHashes.get(p.content_hash);
3225
+ if (priorPatchId !== void 0) {
3226
+ this.service.lockManager.removePatch(p.id);
3227
+ this.service.warnings.push(
3228
+ `Consolidated patch ${p.id} into prior patch ${priorPatchId}: byte-identical patch_content. Lockfile collapsed to avoid duplicate-apply corruption on next regen.`
3229
+ );
3230
+ } else {
3231
+ seenHashes.set(p.content_hash, p.id);
3232
+ }
3204
3233
  }
3205
3234
  }
3206
3235
  existingPatches = this.service.lockManager.getPatches();
@@ -3229,8 +3258,7 @@ var NormalRegenerationFlow = class {
3229
3258
  const allPatches = [...existingPatches, ...newPatches];
3230
3259
  const commitOpts = options ? {
3231
3260
  cliVersion: options.cliVersion ?? "unknown",
3232
- generatorVersions: options.generatorVersions ?? {},
3233
- baseBranchHead: options.baseBranchHead
3261
+ generatorVersions: options.generatorVersions ?? {}
3234
3262
  } : void 0;
3235
3263
  await this.service.committer.commitGeneration("Update SDK", commitOpts);
3236
3264
  await this.service.cleanupStaleConflictMarkers();
@@ -3240,7 +3268,6 @@ var NormalRegenerationFlow = class {
3240
3268
  flow: "normal-regeneration",
3241
3269
  previousGenerationSha,
3242
3270
  currentGenerationSha: genRecord.commit_sha,
3243
- baseBranchHead: options?.baseBranchHead ?? null,
3244
3271
  _prepared: {
3245
3272
  kind: "active",
3246
3273
  flow: "normal-regeneration",
@@ -3433,68 +3460,6 @@ var ReplayService = class {
3433
3460
  const prep = await this.prepareReplay(options);
3434
3461
  return this.applyPreparedReplay(prep, options);
3435
3462
  }
3436
- /**
3437
- * Sync the lockfile after a divergent PR was squash-merged.
3438
- * Call this BEFORE runReplay() when the CLI detects a merged divergent PR.
3439
- *
3440
- * After updating the generation record, re-detects customer patches via
3441
- * tree diff between the generation tag (pure generation tree) and HEAD
3442
- * (which includes customer customizations after squash merge). This ensures
3443
- * patches survive the squash merge → regenerate cycle even when the lockfile
3444
- * was restored from the base branch during conflict PR creation.
3445
- */
3446
- async syncFromDivergentMerge(generationCommitSha, options) {
3447
- const treeHash = await this.git.getTreeHash(generationCommitSha);
3448
- const timestamp = (await this.git.exec(["log", "-1", "--format=%aI", generationCommitSha])).trim();
3449
- const record = {
3450
- commit_sha: generationCommitSha,
3451
- tree_hash: treeHash,
3452
- timestamp,
3453
- cli_version: options?.cliVersion ?? "unknown",
3454
- generator_versions: options?.generatorVersions ?? {},
3455
- base_branch_head: options?.baseBranchHead
3456
- };
3457
- let resolvedPatches;
3458
- if (!this.lockManager.exists()) {
3459
- this.lockManager.initializeInMemory(record);
3460
- } else {
3461
- this.lockManager.read();
3462
- const unresolvedPatches = [
3463
- ...this.lockManager.getUnresolvedPatches(),
3464
- ...this.lockManager.getResolvingPatches()
3465
- ];
3466
- resolvedPatches = this.lockManager.getPatches().filter((p) => p.status == null);
3467
- this.lockManager.addGeneration(record);
3468
- this.lockManager.clearPatches();
3469
- for (const patch of unresolvedPatches) {
3470
- this.lockManager.addPatch(patch);
3471
- }
3472
- }
3473
- try {
3474
- const { patches: redetectedPatches } = await this.detector.detectNewPatches();
3475
- if (redetectedPatches.length > 0) {
3476
- const redetectedFiles = new Set(redetectedPatches.flatMap((p) => p.files));
3477
- const currentPatches = this.lockManager.getPatches();
3478
- for (const patch of currentPatches) {
3479
- if (patch.status != null && patch.files.some((f) => redetectedFiles.has(f))) {
3480
- this.lockManager.removePatch(patch.id);
3481
- }
3482
- }
3483
- for (const patch of redetectedPatches) {
3484
- this.lockManager.addPatch(patch);
3485
- }
3486
- }
3487
- } catch (error) {
3488
- for (const patch of resolvedPatches ?? []) {
3489
- this.lockManager.addPatch(patch);
3490
- }
3491
- this.detector.warnings.push(
3492
- `Patch re-detection failed after divergent merge sync. ${(resolvedPatches ?? []).length} previously resolved patch(es) preserved. Error: ${error instanceof Error ? error.message : String(error)}`
3493
- );
3494
- }
3495
- this.lockManager.save();
3496
- this.detector.warnings.push(...this.lockManager.warnings);
3497
- }
3498
3463
  determineFlow() {
3499
3464
  try {
3500
3465
  const lock = this.lockManager.read();