@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.js CHANGED
@@ -499,9 +499,22 @@ function isGenerationCommit(commit) {
499
499
  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.
500
500
  // The default PR title is "SDK Generation" (from GithubStep's commitMessage default).
501
501
  // GitHub appends "(#N)" for the PR number, e.g. "SDK Generation (#70)".
502
- commit.message.startsWith("SDK Generation");
502
+ commit.message.startsWith("SDK Generation") || // Merge commit (GitHub's "Create a merge commit" option) of a fern-bot
503
+ // regen PR. First-parent walking lands on this commit, not on the
504
+ // generation commit on the side branch, so the classifier must treat
505
+ // it as a generation boundary. GitHub's default subject is
506
+ // "Merge pull request #N from <owner>/<branch>"; fern-bot branches are
507
+ // named "<owner>/fern-bot/<...>".
508
+ /^Merge pull request #\d+ from [^/]+\/fern-bot\//.test(commit.message);
503
509
  return isBotAuthor || hasGenerationMarker;
504
510
  }
511
+ function isGenerationBoundary(commit) {
512
+ const isFernSupport = FERN_SUPPORT_NAMES.includes(commit.authorName);
513
+ const isBotAuthor = !isFernSupport && (commit.authorLogin === FERN_BOT_LOGIN || commit.authorEmail === FERN_BOT_EMAIL || commit.authorName === FERN_BOT_NAME);
514
+ const isAutoversion = commit.message.startsWith("[fern-autoversion]");
515
+ 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");
516
+ return isBotAuthor && !isAutoversion || hasBoundaryMarker;
517
+ }
505
518
  function isReplayCommit(commit) {
506
519
  return commit.message.startsWith("[fern-replay]");
507
520
  }
@@ -829,8 +842,48 @@ var ReplayDetector = class {
829
842
  this.lockManager = lockManager;
830
843
  this.sdkOutputDir = sdkOutputDir;
831
844
  }
845
+ /**
846
+ * Derive the previous-generation SHA from git history instead of trusting
847
+ * `lock.current_generation`.
848
+ *
849
+ * Walks `git log HEAD --first-parent` and returns the SHA of the most recent
850
+ * commit that `isGenerationBoundary` classifies as a customization-baseline
851
+ * boundary, or null if no such commit is reachable from HEAD on the mainline.
852
+ *
853
+ * `isGenerationBoundary` is intentionally narrower than `isGenerationCommit`:
854
+ * it excludes `[fern-autoversion]` (lightweight bumps that don't reset the
855
+ * baseline) and the GitHub merge-commit subject for fern-bot regen PRs
856
+ * (which sits on first-parent but where the customer fix-up commits live
857
+ * on second-parent — stopping at the merge would hide them).
858
+ *
859
+ * See docs/adr/0001-derived-scan-boundary.md.
860
+ */
861
+ async findPreviousGenerationFromHistory() {
862
+ let log;
863
+ try {
864
+ log = await this.git.exec([
865
+ "log",
866
+ "--first-parent",
867
+ "--format=%H%x00%an%x00%ae%x00%s",
868
+ "HEAD"
869
+ ]);
870
+ } catch {
871
+ return null;
872
+ }
873
+ for (const commit of this.parseGitLog(log)) {
874
+ if (isGenerationBoundary(commit)) {
875
+ return commit.sha;
876
+ }
877
+ }
878
+ return null;
879
+ }
832
880
  async detectNewPatches() {
833
881
  const lock = this.lockManager.read();
882
+ const derivedSha = await this.findPreviousGenerationFromHistory();
883
+ if (derivedSha !== null) {
884
+ const derivedRecord = await this.synthesizeGenerationRecord(derivedSha);
885
+ return this.detectPatchesInRange(derivedSha, derivedRecord, lock);
886
+ }
834
887
  const lastGen = this.getLastGeneration(lock);
835
888
  if (!lastGen) {
836
889
  return { patches: [], revertedPatchIds: [] };
@@ -838,7 +891,7 @@ var ReplayDetector = class {
838
891
  const exists = await this.git.commitExists(lastGen.commit_sha);
839
892
  if (!exists) {
840
893
  this.warnings.push(
841
- `Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to alternate detection.`
894
+ `Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to tree-diff detection (likely a shallow clone).`
842
895
  );
843
896
  return this.detectPatchesViaTreeDiff(
844
897
  lastGen,
@@ -846,44 +899,8 @@ var ReplayDetector = class {
846
899
  true
847
900
  );
848
901
  }
849
- const isAncestor = await this.git.isAncestor(lastGen.commit_sha, "HEAD");
850
- if (!isAncestor) {
851
- return this.detectPatchesViaMergeBase(lastGen, lock);
852
- }
853
902
  return this.detectPatchesInRange(lastGen.commit_sha, lastGen, lock);
854
903
  }
855
- /**
856
- * Non-linear-history detection via `merge-base(prevGen, HEAD)`.
857
- *
858
- * After a squash-merge of a Fern-bot PR, `lastGen.commit_sha` (the previous
859
- * `[fern-generated]`) is orphaned but still in git's object database. The
860
- * merge-base is the commit on main from which the bot's branch was created —
861
- * a reachable ancestor of HEAD. Walking that range and classifying each
862
- * commit via `isGenerationCommit` mirrors the linear path and eliminates
863
- * the bug class where pipeline-driven changes (autoversion, replay,
864
- * generation) get bundled as customer customizations.
865
- *
866
- * Falls through to the legacy composite path when no common ancestor exists
867
- * (truly disjoint history — orphan branches with no shared root).
868
- */
869
- async detectPatchesViaMergeBase(lastGen, lock) {
870
- let mergeBase = "";
871
- try {
872
- mergeBase = (await this.git.exec(["merge-base", lastGen.commit_sha, "HEAD"])).trim();
873
- } catch {
874
- }
875
- if (!mergeBase) {
876
- this.warnings.push(
877
- `No common ancestor between previous generation ${lastGen.commit_sha.slice(0, 7)} and HEAD. Falling back to composite tree-diff detection.`
878
- );
879
- return this.detectPatchesViaTreeDiff(
880
- lastGen,
881
- /* commitKnownMissing */
882
- false
883
- );
884
- }
885
- return this.detectPatchesInRange(mergeBase, lastGen, lock);
886
- }
887
904
  /**
888
905
  * Walk `${rangeStart}..HEAD`, classify each commit, emit one
889
906
  * `StoredPatch` per customer commit. Body shared by the linear path
@@ -896,7 +913,7 @@ var ReplayDetector = class {
896
913
  async detectPatchesInRange(rangeStart, lastGen, lock) {
897
914
  const log = await this.git.exec([
898
915
  "log",
899
- "--first-parent",
916
+ "--no-merges",
900
917
  "--format=%H%x00%an%x00%ae%x00%s",
901
918
  `${rangeStart}..HEAD`,
902
919
  "--",
@@ -916,18 +933,6 @@ var ReplayDetector = class {
916
933
  continue;
917
934
  }
918
935
  const parents = await this.git.getCommitParents(commit.sha);
919
- if (parents.length > 1) {
920
- const compositePatch = await this.createMergeCompositePatch({
921
- mergeCommit: commit,
922
- firstParent: parents[0],
923
- lastGen,
924
- lock
925
- });
926
- if (compositePatch) {
927
- newPatches.push(compositePatch);
928
- }
929
- continue;
930
- }
931
936
  let patchContent;
932
937
  try {
933
938
  patchContent = await this.git.formatPatch(commit.sha);
@@ -1065,9 +1070,7 @@ var ReplayDetector = class {
1065
1070
  * within the current linear detection window, and are absent from
1066
1071
  * HEAD at the end of the window.
1067
1072
  *
1068
- * Rationale: on a merge commit, createMergeCompositePatch already diffs
1069
- * firstParent..mergeCommit so net-zero files never enter the composite. On
1070
- * linear history each commit becomes its own patch, so a file that was
1073
+ * Rationale: each commit becomes its own patch, so a file that was
1071
1074
  * briefly added and later removed survives as a create+delete pair whose
1072
1075
  * cumulative diff is empty. We drop such patches before they reach the
1073
1076
  * lockfile.
@@ -1134,54 +1137,6 @@ var ReplayDetector = class {
1134
1137
  return /* @__PURE__ */ new Set();
1135
1138
  }
1136
1139
  }
1137
- /**
1138
- * Create a single composite patch for a merge commit by diffing the merge
1139
- * commit against its first parent. This captures the net change of the
1140
- * entire merged branch as one patch, eliminating accumulator chaining and
1141
- * zero-net-change ghost conflicts.
1142
- */
1143
- async createMergeCompositePatch(input) {
1144
- const { mergeCommit, firstParent, lastGen, lock } = input;
1145
- const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
1146
- const filesOutput = await this.git.exec([
1147
- "diff",
1148
- "--name-only",
1149
- firstParent,
1150
- mergeCommit.sha
1151
- ]);
1152
- const { files, sensitiveFiles } = filterInfrastructureAndSensitiveFiles(filesOutput);
1153
- if (sensitiveFiles.length > 0) {
1154
- this.warnings.push(
1155
- `Sensitive file(s) excluded from merge patch for commit ${mergeCommit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
1156
- );
1157
- }
1158
- if (files.length === 0) return null;
1159
- const diff = await this.git.exec([
1160
- "diff",
1161
- firstParent,
1162
- mergeCommit.sha,
1163
- "--",
1164
- ...files
1165
- ]);
1166
- if (!diff.trim()) return null;
1167
- const contentHash = this.computeContentHash(diff);
1168
- if (lock.patches.some((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
1169
- return null;
1170
- }
1171
- const theirsSnapshot = await capturePatchSnapshot(this.git, files, mergeCommit.sha);
1172
- return {
1173
- id: `patch-merge-${mergeCommit.sha.slice(0, 8)}`,
1174
- content_hash: contentHash,
1175
- original_commit: mergeCommit.sha,
1176
- original_message: mergeCommit.message,
1177
- original_author: `${mergeCommit.authorName} <${mergeCommit.authorEmail}>`,
1178
- base_generation: lastGen.commit_sha,
1179
- files,
1180
- patch_content: diff,
1181
- ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
1182
- ...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
1183
- };
1184
- }
1185
1140
  /**
1186
1141
  * Detect patches via tree diff for non-linear history. Returns a composite patch.
1187
1142
  * Revert reconciliation is skipped here because tree-diff produces a single composite
@@ -1381,6 +1336,26 @@ var ReplayDetector = class {
1381
1336
  getLastGeneration(lock) {
1382
1337
  return lock.generations.find((g) => g.commit_sha === lock.current_generation);
1383
1338
  }
1339
+ /**
1340
+ * Build a transient `GenerationRecord` from a SHA derived by walking git
1341
+ * history. Only `commit_sha` (and `tree_hash` when needed downstream) is
1342
+ * load-bearing here; the rest are filled with defensible placeholders.
1343
+ * This record is consumed by `detectPatchesInRange` and is never persisted.
1344
+ */
1345
+ async synthesizeGenerationRecord(sha) {
1346
+ let tree_hash = "";
1347
+ try {
1348
+ tree_hash = await this.git.getTreeHash(sha);
1349
+ } catch {
1350
+ }
1351
+ return {
1352
+ commit_sha: sha,
1353
+ tree_hash,
1354
+ timestamp: (/* @__PURE__ */ new Date(0)).toISOString(),
1355
+ cli_version: "",
1356
+ generator_versions: {}
1357
+ };
1358
+ }
1384
1359
  };
1385
1360
 
1386
1361
  // src/ThreeWayMerge.ts
@@ -2520,8 +2495,7 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
2520
2495
  tree_hash: treeHash,
2521
2496
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2522
2497
  cli_version: options?.cliVersion ?? "unknown",
2523
- generator_versions: options?.generatorVersions ?? {},
2524
- base_branch_head: options?.baseBranchHead
2498
+ generator_versions: options?.generatorVersions ?? {}
2525
2499
  };
2526
2500
  }
2527
2501
  async stageAll() {
@@ -2908,7 +2882,6 @@ var FirstGenerationFlow = class {
2908
2882
  flow: "first-generation",
2909
2883
  previousGenerationSha: null,
2910
2884
  currentGenerationSha: headSha,
2911
- baseBranchHead: options.baseBranchHead ?? null,
2912
2885
  _prepared: {
2913
2886
  kind: "terminal",
2914
2887
  flow: "first-generation",
@@ -2918,8 +2891,7 @@ var FirstGenerationFlow = class {
2918
2891
  }
2919
2892
  const commitOpts = options ? {
2920
2893
  cliVersion: options.cliVersion ?? "unknown",
2921
- generatorVersions: options.generatorVersions ?? {},
2922
- baseBranchHead: options.baseBranchHead
2894
+ generatorVersions: options.generatorVersions ?? {}
2923
2895
  } : void 0;
2924
2896
  await this.service.committer.commitGeneration("Initial SDK generation", commitOpts);
2925
2897
  const genRecord = await this.service.committer.createGenerationRecord(commitOpts);
@@ -2928,7 +2900,6 @@ var FirstGenerationFlow = class {
2928
2900
  flow: "first-generation",
2929
2901
  previousGenerationSha: null,
2930
2902
  currentGenerationSha: genRecord.commit_sha,
2931
- baseBranchHead: options?.baseBranchHead ?? null,
2932
2903
  _prepared: {
2933
2904
  kind: "active",
2934
2905
  flow: "first-generation",
@@ -2965,8 +2936,7 @@ var SkipApplicationFlow = class {
2965
2936
  async prepare(options) {
2966
2937
  const commitOpts = options ? {
2967
2938
  cliVersion: options.cliVersion ?? "unknown",
2968
- generatorVersions: options.generatorVersions ?? {},
2969
- baseBranchHead: options.baseBranchHead
2939
+ generatorVersions: options.generatorVersions ?? {}
2970
2940
  } : void 0;
2971
2941
  await this.service.committer.commitGeneration("Update SDK (replay skipped)", commitOpts);
2972
2942
  await this.service.cleanupStaleConflictMarkers();
@@ -2988,7 +2958,6 @@ var SkipApplicationFlow = class {
2988
2958
  flow: "skip-application",
2989
2959
  previousGenerationSha,
2990
2960
  currentGenerationSha: genRecord.commit_sha,
2991
- baseBranchHead: options?.baseBranchHead ?? null,
2992
2961
  _prepared: {
2993
2962
  kind: "active",
2994
2963
  flow: "skip-application",
@@ -3049,7 +3018,6 @@ var NoPatchesFlow = class {
3049
3018
  flow: "no-patches",
3050
3019
  previousGenerationSha,
3051
3020
  currentGenerationSha: headSha,
3052
- baseBranchHead: options.baseBranchHead ?? null,
3053
3021
  _prepared: {
3054
3022
  kind: "terminal",
3055
3023
  flow: "no-patches",
@@ -3081,8 +3049,7 @@ var NoPatchesFlow = class {
3081
3049
  }
3082
3050
  const commitOpts = options ? {
3083
3051
  cliVersion: options.cliVersion ?? "unknown",
3084
- generatorVersions: options.generatorVersions ?? {},
3085
- baseBranchHead: options.baseBranchHead
3052
+ generatorVersions: options.generatorVersions ?? {}
3086
3053
  } : void 0;
3087
3054
  await this.service.committer.commitGeneration("Update SDK", commitOpts);
3088
3055
  await this.service.cleanupStaleConflictMarkers();
@@ -3092,7 +3059,6 @@ var NoPatchesFlow = class {
3092
3059
  flow: "no-patches",
3093
3060
  previousGenerationSha,
3094
3061
  currentGenerationSha: genRecord.commit_sha,
3095
- baseBranchHead: options?.baseBranchHead ?? null,
3096
3062
  _prepared: {
3097
3063
  kind: "active",
3098
3064
  flow: "no-patches",
@@ -3182,7 +3148,6 @@ var NormalRegenerationFlow = class {
3182
3148
  flow: "normal-regeneration",
3183
3149
  previousGenerationSha,
3184
3150
  currentGenerationSha: headSha,
3185
- baseBranchHead: options.baseBranchHead ?? null,
3186
3151
  _prepared: {
3187
3152
  kind: "terminal",
3188
3153
  flow: "normal-regeneration",
@@ -3200,14 +3165,16 @@ var NormalRegenerationFlow = class {
3200
3165
  existingPatches = this.service.lockManager.getPatches();
3201
3166
  const seenHashes = /* @__PURE__ */ new Map();
3202
3167
  for (const p of existingPatches) {
3203
- const priorPatchId = seenHashes.get(p.content_hash);
3204
- if (priorPatchId !== void 0) {
3205
- this.service.lockManager.removePatch(p.id);
3206
- this.service.warnings.push(
3207
- `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.`
3208
- );
3209
- } else {
3210
- seenHashes.set(p.content_hash, p.id);
3168
+ if (!p.original_commit || p.original_commit === "") {
3169
+ const priorPatchId = seenHashes.get(p.content_hash);
3170
+ if (priorPatchId !== void 0) {
3171
+ this.service.lockManager.removePatch(p.id);
3172
+ this.service.warnings.push(
3173
+ `Consolidated patch ${p.id} into prior patch ${priorPatchId}: byte-identical patch_content. Lockfile collapsed to avoid duplicate-apply corruption on next regen.`
3174
+ );
3175
+ } else {
3176
+ seenHashes.set(p.content_hash, p.id);
3177
+ }
3211
3178
  }
3212
3179
  }
3213
3180
  existingPatches = this.service.lockManager.getPatches();
@@ -3236,8 +3203,7 @@ var NormalRegenerationFlow = class {
3236
3203
  const allPatches = [...existingPatches, ...newPatches];
3237
3204
  const commitOpts = options ? {
3238
3205
  cliVersion: options.cliVersion ?? "unknown",
3239
- generatorVersions: options.generatorVersions ?? {},
3240
- baseBranchHead: options.baseBranchHead
3206
+ generatorVersions: options.generatorVersions ?? {}
3241
3207
  } : void 0;
3242
3208
  await this.service.committer.commitGeneration("Update SDK", commitOpts);
3243
3209
  await this.service.cleanupStaleConflictMarkers();
@@ -3247,7 +3213,6 @@ var NormalRegenerationFlow = class {
3247
3213
  flow: "normal-regeneration",
3248
3214
  previousGenerationSha,
3249
3215
  currentGenerationSha: genRecord.commit_sha,
3250
- baseBranchHead: options?.baseBranchHead ?? null,
3251
3216
  _prepared: {
3252
3217
  kind: "active",
3253
3218
  flow: "normal-regeneration",
@@ -3440,68 +3405,6 @@ var ReplayService = class {
3440
3405
  const prep = await this.prepareReplay(options);
3441
3406
  return this.applyPreparedReplay(prep, options);
3442
3407
  }
3443
- /**
3444
- * Sync the lockfile after a divergent PR was squash-merged.
3445
- * Call this BEFORE runReplay() when the CLI detects a merged divergent PR.
3446
- *
3447
- * After updating the generation record, re-detects customer patches via
3448
- * tree diff between the generation tag (pure generation tree) and HEAD
3449
- * (which includes customer customizations after squash merge). This ensures
3450
- * patches survive the squash merge → regenerate cycle even when the lockfile
3451
- * was restored from the base branch during conflict PR creation.
3452
- */
3453
- async syncFromDivergentMerge(generationCommitSha, options) {
3454
- const treeHash = await this.git.getTreeHash(generationCommitSha);
3455
- const timestamp = (await this.git.exec(["log", "-1", "--format=%aI", generationCommitSha])).trim();
3456
- const record = {
3457
- commit_sha: generationCommitSha,
3458
- tree_hash: treeHash,
3459
- timestamp,
3460
- cli_version: options?.cliVersion ?? "unknown",
3461
- generator_versions: options?.generatorVersions ?? {},
3462
- base_branch_head: options?.baseBranchHead
3463
- };
3464
- let resolvedPatches;
3465
- if (!this.lockManager.exists()) {
3466
- this.lockManager.initializeInMemory(record);
3467
- } else {
3468
- this.lockManager.read();
3469
- const unresolvedPatches = [
3470
- ...this.lockManager.getUnresolvedPatches(),
3471
- ...this.lockManager.getResolvingPatches()
3472
- ];
3473
- resolvedPatches = this.lockManager.getPatches().filter((p) => p.status == null);
3474
- this.lockManager.addGeneration(record);
3475
- this.lockManager.clearPatches();
3476
- for (const patch of unresolvedPatches) {
3477
- this.lockManager.addPatch(patch);
3478
- }
3479
- }
3480
- try {
3481
- const { patches: redetectedPatches } = await this.detector.detectNewPatches();
3482
- if (redetectedPatches.length > 0) {
3483
- const redetectedFiles = new Set(redetectedPatches.flatMap((p) => p.files));
3484
- const currentPatches = this.lockManager.getPatches();
3485
- for (const patch of currentPatches) {
3486
- if (patch.status != null && patch.files.some((f) => redetectedFiles.has(f))) {
3487
- this.lockManager.removePatch(patch.id);
3488
- }
3489
- }
3490
- for (const patch of redetectedPatches) {
3491
- this.lockManager.addPatch(patch);
3492
- }
3493
- }
3494
- } catch (error) {
3495
- for (const patch of resolvedPatches ?? []) {
3496
- this.lockManager.addPatch(patch);
3497
- }
3498
- this.detector.warnings.push(
3499
- `Patch re-detection failed after divergent merge sync. ${(resolvedPatches ?? []).length} previously resolved patch(es) preserved. Error: ${error instanceof Error ? error.message : String(error)}`
3500
- );
3501
- }
3502
- this.lockManager.save();
3503
- this.detector.warnings.push(...this.lockManager.warnings);
3504
- }
3505
3408
  determineFlow() {
3506
3409
  try {
3507
3410
  const lock = this.lockManager.read();