@fern-api/replay 0.15.1 → 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
@@ -951,7 +968,7 @@ var ReplayDetector = class {
951
968
  async detectPatchesInRange(rangeStart, lastGen, lock) {
952
969
  const log = await this.git.exec([
953
970
  "log",
954
- "--first-parent",
971
+ "--no-merges",
955
972
  "--format=%H%x00%an%x00%ae%x00%s",
956
973
  `${rangeStart}..HEAD`,
957
974
  "--",
@@ -971,18 +988,6 @@ var ReplayDetector = class {
971
988
  continue;
972
989
  }
973
990
  const parents = await this.git.getCommitParents(commit.sha);
974
- if (parents.length > 1) {
975
- const compositePatch = await this.createMergeCompositePatch({
976
- mergeCommit: commit,
977
- firstParent: parents[0],
978
- lastGen,
979
- lock
980
- });
981
- if (compositePatch) {
982
- newPatches.push(compositePatch);
983
- }
984
- continue;
985
- }
986
991
  let patchContent;
987
992
  try {
988
993
  patchContent = await this.git.formatPatch(commit.sha);
@@ -1120,9 +1125,7 @@ var ReplayDetector = class {
1120
1125
  * within the current linear detection window, and are absent from
1121
1126
  * HEAD at the end of the window.
1122
1127
  *
1123
- * Rationale: on a merge commit, createMergeCompositePatch already diffs
1124
- * firstParent..mergeCommit so net-zero files never enter the composite. On
1125
- * linear history each commit becomes its own patch, so a file that was
1128
+ * Rationale: each commit becomes its own patch, so a file that was
1126
1129
  * briefly added and later removed survives as a create+delete pair whose
1127
1130
  * cumulative diff is empty. We drop such patches before they reach the
1128
1131
  * lockfile.
@@ -1189,54 +1192,6 @@ var ReplayDetector = class {
1189
1192
  return /* @__PURE__ */ new Set();
1190
1193
  }
1191
1194
  }
1192
- /**
1193
- * Create a single composite patch for a merge commit by diffing the merge
1194
- * commit against its first parent. This captures the net change of the
1195
- * entire merged branch as one patch, eliminating accumulator chaining and
1196
- * zero-net-change ghost conflicts.
1197
- */
1198
- async createMergeCompositePatch(input) {
1199
- const { mergeCommit, firstParent, lastGen, lock } = input;
1200
- const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
1201
- const filesOutput = await this.git.exec([
1202
- "diff",
1203
- "--name-only",
1204
- firstParent,
1205
- mergeCommit.sha
1206
- ]);
1207
- const { files, sensitiveFiles } = filterInfrastructureAndSensitiveFiles(filesOutput);
1208
- if (sensitiveFiles.length > 0) {
1209
- this.warnings.push(
1210
- `Sensitive file(s) excluded from merge patch for commit ${mergeCommit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
1211
- );
1212
- }
1213
- if (files.length === 0) return null;
1214
- const diff = await this.git.exec([
1215
- "diff",
1216
- firstParent,
1217
- mergeCommit.sha,
1218
- "--",
1219
- ...files
1220
- ]);
1221
- if (!diff.trim()) return null;
1222
- const contentHash = this.computeContentHash(diff);
1223
- if (lock.patches.some((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
1224
- return null;
1225
- }
1226
- const theirsSnapshot = await capturePatchSnapshot(this.git, files, mergeCommit.sha);
1227
- return {
1228
- id: `patch-merge-${mergeCommit.sha.slice(0, 8)}`,
1229
- content_hash: contentHash,
1230
- original_commit: mergeCommit.sha,
1231
- original_message: mergeCommit.message,
1232
- original_author: `${mergeCommit.authorName} <${mergeCommit.authorEmail}>`,
1233
- base_generation: lastGen.commit_sha,
1234
- files,
1235
- patch_content: diff,
1236
- ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
1237
- ...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
1238
- };
1239
- }
1240
1195
  /**
1241
1196
  * Detect patches via tree diff for non-linear history. Returns a composite patch.
1242
1197
  * Revert reconciliation is skipped here because tree-diff produces a single composite
@@ -1436,6 +1391,26 @@ var ReplayDetector = class {
1436
1391
  getLastGeneration(lock) {
1437
1392
  return lock.generations.find((g) => g.commit_sha === lock.current_generation);
1438
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
+ }
1439
1414
  };
1440
1415
 
1441
1416
  // src/ThreeWayMerge.ts
@@ -2575,8 +2550,7 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
2575
2550
  tree_hash: treeHash,
2576
2551
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2577
2552
  cli_version: options?.cliVersion ?? "unknown",
2578
- generator_versions: options?.generatorVersions ?? {},
2579
- base_branch_head: options?.baseBranchHead
2553
+ generator_versions: options?.generatorVersions ?? {}
2580
2554
  };
2581
2555
  }
2582
2556
  async stageAll() {
@@ -2963,7 +2937,6 @@ var FirstGenerationFlow = class {
2963
2937
  flow: "first-generation",
2964
2938
  previousGenerationSha: null,
2965
2939
  currentGenerationSha: headSha,
2966
- baseBranchHead: options.baseBranchHead ?? null,
2967
2940
  _prepared: {
2968
2941
  kind: "terminal",
2969
2942
  flow: "first-generation",
@@ -2973,8 +2946,7 @@ var FirstGenerationFlow = class {
2973
2946
  }
2974
2947
  const commitOpts = options ? {
2975
2948
  cliVersion: options.cliVersion ?? "unknown",
2976
- generatorVersions: options.generatorVersions ?? {},
2977
- baseBranchHead: options.baseBranchHead
2949
+ generatorVersions: options.generatorVersions ?? {}
2978
2950
  } : void 0;
2979
2951
  await this.service.committer.commitGeneration("Initial SDK generation", commitOpts);
2980
2952
  const genRecord = await this.service.committer.createGenerationRecord(commitOpts);
@@ -2983,7 +2955,6 @@ var FirstGenerationFlow = class {
2983
2955
  flow: "first-generation",
2984
2956
  previousGenerationSha: null,
2985
2957
  currentGenerationSha: genRecord.commit_sha,
2986
- baseBranchHead: options?.baseBranchHead ?? null,
2987
2958
  _prepared: {
2988
2959
  kind: "active",
2989
2960
  flow: "first-generation",
@@ -3020,8 +2991,7 @@ var SkipApplicationFlow = class {
3020
2991
  async prepare(options) {
3021
2992
  const commitOpts = options ? {
3022
2993
  cliVersion: options.cliVersion ?? "unknown",
3023
- generatorVersions: options.generatorVersions ?? {},
3024
- baseBranchHead: options.baseBranchHead
2994
+ generatorVersions: options.generatorVersions ?? {}
3025
2995
  } : void 0;
3026
2996
  await this.service.committer.commitGeneration("Update SDK (replay skipped)", commitOpts);
3027
2997
  await this.service.cleanupStaleConflictMarkers();
@@ -3043,7 +3013,6 @@ var SkipApplicationFlow = class {
3043
3013
  flow: "skip-application",
3044
3014
  previousGenerationSha,
3045
3015
  currentGenerationSha: genRecord.commit_sha,
3046
- baseBranchHead: options?.baseBranchHead ?? null,
3047
3016
  _prepared: {
3048
3017
  kind: "active",
3049
3018
  flow: "skip-application",
@@ -3104,7 +3073,6 @@ var NoPatchesFlow = class {
3104
3073
  flow: "no-patches",
3105
3074
  previousGenerationSha,
3106
3075
  currentGenerationSha: headSha,
3107
- baseBranchHead: options.baseBranchHead ?? null,
3108
3076
  _prepared: {
3109
3077
  kind: "terminal",
3110
3078
  flow: "no-patches",
@@ -3136,8 +3104,7 @@ var NoPatchesFlow = class {
3136
3104
  }
3137
3105
  const commitOpts = options ? {
3138
3106
  cliVersion: options.cliVersion ?? "unknown",
3139
- generatorVersions: options.generatorVersions ?? {},
3140
- baseBranchHead: options.baseBranchHead
3107
+ generatorVersions: options.generatorVersions ?? {}
3141
3108
  } : void 0;
3142
3109
  await this.service.committer.commitGeneration("Update SDK", commitOpts);
3143
3110
  await this.service.cleanupStaleConflictMarkers();
@@ -3147,7 +3114,6 @@ var NoPatchesFlow = class {
3147
3114
  flow: "no-patches",
3148
3115
  previousGenerationSha,
3149
3116
  currentGenerationSha: genRecord.commit_sha,
3150
- baseBranchHead: options?.baseBranchHead ?? null,
3151
3117
  _prepared: {
3152
3118
  kind: "active",
3153
3119
  flow: "no-patches",
@@ -3237,7 +3203,6 @@ var NormalRegenerationFlow = class {
3237
3203
  flow: "normal-regeneration",
3238
3204
  previousGenerationSha,
3239
3205
  currentGenerationSha: headSha,
3240
- baseBranchHead: options.baseBranchHead ?? null,
3241
3206
  _prepared: {
3242
3207
  kind: "terminal",
3243
3208
  flow: "normal-regeneration",
@@ -3255,14 +3220,16 @@ var NormalRegenerationFlow = class {
3255
3220
  existingPatches = this.service.lockManager.getPatches();
3256
3221
  const seenHashes = /* @__PURE__ */ new Map();
3257
3222
  for (const p of existingPatches) {
3258
- const priorPatchId = seenHashes.get(p.content_hash);
3259
- if (priorPatchId !== void 0) {
3260
- this.service.lockManager.removePatch(p.id);
3261
- this.service.warnings.push(
3262
- `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.`
3263
- );
3264
- } else {
3265
- 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
+ }
3266
3233
  }
3267
3234
  }
3268
3235
  existingPatches = this.service.lockManager.getPatches();
@@ -3291,8 +3258,7 @@ var NormalRegenerationFlow = class {
3291
3258
  const allPatches = [...existingPatches, ...newPatches];
3292
3259
  const commitOpts = options ? {
3293
3260
  cliVersion: options.cliVersion ?? "unknown",
3294
- generatorVersions: options.generatorVersions ?? {},
3295
- baseBranchHead: options.baseBranchHead
3261
+ generatorVersions: options.generatorVersions ?? {}
3296
3262
  } : void 0;
3297
3263
  await this.service.committer.commitGeneration("Update SDK", commitOpts);
3298
3264
  await this.service.cleanupStaleConflictMarkers();
@@ -3302,7 +3268,6 @@ var NormalRegenerationFlow = class {
3302
3268
  flow: "normal-regeneration",
3303
3269
  previousGenerationSha,
3304
3270
  currentGenerationSha: genRecord.commit_sha,
3305
- baseBranchHead: options?.baseBranchHead ?? null,
3306
3271
  _prepared: {
3307
3272
  kind: "active",
3308
3273
  flow: "normal-regeneration",
@@ -3495,68 +3460,6 @@ var ReplayService = class {
3495
3460
  const prep = await this.prepareReplay(options);
3496
3461
  return this.applyPreparedReplay(prep, options);
3497
3462
  }
3498
- /**
3499
- * Sync the lockfile after a divergent PR was squash-merged.
3500
- * Call this BEFORE runReplay() when the CLI detects a merged divergent PR.
3501
- *
3502
- * After updating the generation record, re-detects customer patches via
3503
- * tree diff between the generation tag (pure generation tree) and HEAD
3504
- * (which includes customer customizations after squash merge). This ensures
3505
- * patches survive the squash merge → regenerate cycle even when the lockfile
3506
- * was restored from the base branch during conflict PR creation.
3507
- */
3508
- async syncFromDivergentMerge(generationCommitSha, options) {
3509
- const treeHash = await this.git.getTreeHash(generationCommitSha);
3510
- const timestamp = (await this.git.exec(["log", "-1", "--format=%aI", generationCommitSha])).trim();
3511
- const record = {
3512
- commit_sha: generationCommitSha,
3513
- tree_hash: treeHash,
3514
- timestamp,
3515
- cli_version: options?.cliVersion ?? "unknown",
3516
- generator_versions: options?.generatorVersions ?? {},
3517
- base_branch_head: options?.baseBranchHead
3518
- };
3519
- let resolvedPatches;
3520
- if (!this.lockManager.exists()) {
3521
- this.lockManager.initializeInMemory(record);
3522
- } else {
3523
- this.lockManager.read();
3524
- const unresolvedPatches = [
3525
- ...this.lockManager.getUnresolvedPatches(),
3526
- ...this.lockManager.getResolvingPatches()
3527
- ];
3528
- resolvedPatches = this.lockManager.getPatches().filter((p) => p.status == null);
3529
- this.lockManager.addGeneration(record);
3530
- this.lockManager.clearPatches();
3531
- for (const patch of unresolvedPatches) {
3532
- this.lockManager.addPatch(patch);
3533
- }
3534
- }
3535
- try {
3536
- const { patches: redetectedPatches } = await this.detector.detectNewPatches();
3537
- if (redetectedPatches.length > 0) {
3538
- const redetectedFiles = new Set(redetectedPatches.flatMap((p) => p.files));
3539
- const currentPatches = this.lockManager.getPatches();
3540
- for (const patch of currentPatches) {
3541
- if (patch.status != null && patch.files.some((f) => redetectedFiles.has(f))) {
3542
- this.lockManager.removePatch(patch.id);
3543
- }
3544
- }
3545
- for (const patch of redetectedPatches) {
3546
- this.lockManager.addPatch(patch);
3547
- }
3548
- }
3549
- } catch (error) {
3550
- for (const patch of resolvedPatches ?? []) {
3551
- this.lockManager.addPatch(patch);
3552
- }
3553
- this.detector.warnings.push(
3554
- `Patch re-detection failed after divergent merge sync. ${(resolvedPatches ?? []).length} previously resolved patch(es) preserved. Error: ${error instanceof Error ? error.message : String(error)}`
3555
- );
3556
- }
3557
- this.lockManager.save();
3558
- this.detector.warnings.push(...this.lockManager.warnings);
3559
- }
3560
3463
  determineFlow() {
3561
3464
  try {
3562
3465
  const lock = this.lockManager.read();