@fern-api/replay 0.11.0 → 0.13.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/cli.cjs CHANGED
@@ -13469,7 +13469,7 @@ var FERN_SUPPORT_NAMES = ["fern-support", "Fern Support"];
13469
13469
  function isGenerationCommit(commit) {
13470
13470
  const isFernSupport = FERN_SUPPORT_NAMES.includes(commit.authorName);
13471
13471
  const isBotAuthor = !isFernSupport && (commit.authorLogin === FERN_BOT_LOGIN || commit.authorEmail === FERN_BOT_EMAIL || commit.authorName === FERN_BOT_NAME);
13472
- const hasGenerationMarker = commit.message.startsWith("[fern-generated]") || commit.message.startsWith("[fern-replay]") || 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.
13472
+ 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.
13473
13473
  // The default PR title is "SDK Generation" (from GithubStep's commitMessage default).
13474
13474
  // GitHub appends "(#N)" for the PR number, e.g. "SDK Generation (#70)".
13475
13475
  commit.message.startsWith("SDK Generation");
@@ -13552,17 +13552,57 @@ var ReplayDetector = class {
13552
13552
  }
13553
13553
  const isAncestor = await this.git.isAncestor(lastGen.commit_sha, "HEAD");
13554
13554
  if (!isAncestor) {
13555
+ return this.detectPatchesViaMergeBase(lastGen, lock);
13556
+ }
13557
+ return this.detectPatchesInRange(lastGen.commit_sha, lastGen, lock);
13558
+ }
13559
+ /**
13560
+ * FER-10201 — Non-linear-history detection via `merge-base(prevGen, HEAD)`.
13561
+ *
13562
+ * After a squash-merge of a Fern-bot PR, `lastGen.commit_sha` (the previous
13563
+ * `[fern-generated]`) is orphaned but still in git's object database. The
13564
+ * merge-base is the commit on main from which the bot's branch was created —
13565
+ * a reachable ancestor of HEAD. Walking that range and classifying each
13566
+ * commit via `isGenerationCommit` mirrors the linear path and eliminates
13567
+ * the bug class where pipeline-driven changes (autoversion, replay,
13568
+ * generation) get bundled as customer customizations.
13569
+ *
13570
+ * Falls through to the legacy composite path when no common ancestor exists
13571
+ * (truly disjoint history — orphan branches with no shared root).
13572
+ */
13573
+ async detectPatchesViaMergeBase(lastGen, lock) {
13574
+ let mergeBase = "";
13575
+ try {
13576
+ mergeBase = (await this.git.exec(["merge-base", lastGen.commit_sha, "HEAD"])).trim();
13577
+ } catch {
13578
+ }
13579
+ if (!mergeBase) {
13580
+ this.warnings.push(
13581
+ `No common ancestor between previous generation ${lastGen.commit_sha.slice(0, 7)} and HEAD. Falling back to composite tree-diff detection.`
13582
+ );
13555
13583
  return this.detectPatchesViaTreeDiff(
13556
13584
  lastGen,
13557
13585
  /* commitKnownMissing */
13558
13586
  false
13559
13587
  );
13560
13588
  }
13589
+ return this.detectPatchesInRange(mergeBase, lastGen, lock);
13590
+ }
13591
+ /**
13592
+ * FER-10201 — Walk `${rangeStart}..HEAD`, classify each commit, emit one
13593
+ * `StoredPatch` per customer commit. Body shared by the linear path
13594
+ * (rangeStart = lastGen.commit_sha) and the merge-base path.
13595
+ *
13596
+ * Patch `base_generation` is always `lastGen.commit_sha` — the
13597
+ * lockfile-tracked reference the applicator uses to fetch base file
13598
+ * content, regardless of where the walk actually started.
13599
+ */
13600
+ async detectPatchesInRange(rangeStart, lastGen, lock) {
13561
13601
  const log = await this.git.exec([
13562
13602
  "log",
13563
13603
  "--first-parent",
13564
13604
  "--format=%H%x00%an%x00%ae%x00%s",
13565
- `${lastGen.commit_sha}..HEAD`,
13605
+ `${rangeStart}..HEAD`,
13566
13606
  "--",
13567
13607
  this.sdkOutputDir
13568
13608
  ]);
@@ -16546,21 +16586,66 @@ var ReplayService = class {
16546
16586
  this.applicator = new ReplayApplicator(git, this.lockManager, outputDir);
16547
16587
  this.committer = new ReplayCommitter(git, outputDir);
16548
16588
  }
16549
- async runReplay(options) {
16589
+ /**
16590
+ * Phase 1 of the two-phase replay flow. Runs preGenerationRebase (when applicable),
16591
+ * detects new patches, and creates the `[fern-generated]` commit. Leaves HEAD at
16592
+ * the generation commit (or unchanged for dry-run).
16593
+ *
16594
+ * Does NOT apply patches — the returned handle must be passed to
16595
+ * `applyPreparedReplay()` to complete the run. No disk writes to the lockfile
16596
+ * happen here; lockfile persistence is deferred to phase 2.
16597
+ *
16598
+ * Callers may land additional commits on HEAD between `prepareReplay` and
16599
+ * `applyPreparedReplay` (e.g., `[fern-autoversion]`).
16600
+ */
16601
+ async prepareReplay(options) {
16550
16602
  this.warnings = [];
16603
+ this.detector.warnings.length = 0;
16551
16604
  if (options?.skipApplication) {
16552
- return this.handleSkipApplication(options);
16605
+ return this._prepareSkipApplication(options);
16553
16606
  }
16554
16607
  const flow = this.determineFlow();
16555
16608
  switch (flow) {
16556
16609
  case "first-generation":
16557
- return this.handleFirstGeneration(options);
16610
+ return this._prepareFirstGeneration(options);
16558
16611
  case "no-patches":
16559
- return this.handleNoPatchesRegeneration(options);
16612
+ return this._prepareNoPatchesRegeneration(options);
16560
16613
  case "normal-regeneration":
16561
- return this.handleNormalRegeneration(options);
16614
+ return this._prepareNormalRegeneration(options);
16562
16615
  }
16563
16616
  }
16617
+ /**
16618
+ * Phase 2 of the two-phase replay flow. Applies patches, rebases them against
16619
+ * the generation, saves the lockfile, and commits `[fern-replay]` (or stages
16620
+ * under `stageOnly`). Operates on HEAD at call time — mid-phase commits are
16621
+ * respected.
16622
+ *
16623
+ * For terminal handles (dry-run, first-gen has no patch work, skip-app does
16624
+ * its own post-commit logic), this returns the precomputed report.
16625
+ */
16626
+ async applyPreparedReplay(prep, options) {
16627
+ if (prep._prepared.terminal) {
16628
+ return prep._prepared.preparedReport;
16629
+ }
16630
+ switch (prep._prepared.flow) {
16631
+ case "first-generation":
16632
+ return this._applyFirstGeneration(prep);
16633
+ case "skip-application":
16634
+ return this._applySkipApplication(prep, options);
16635
+ case "no-patches":
16636
+ return this._applyNoPatchesRegeneration(prep, options);
16637
+ case "normal-regeneration":
16638
+ return this._applyNormalRegeneration(prep, options);
16639
+ }
16640
+ }
16641
+ /**
16642
+ * Backwards-compatible atomic entry point. Composes `prepareReplay` + `applyPreparedReplay`.
16643
+ * Behavior is byte-identical to the pre-split implementation for all existing callers.
16644
+ */
16645
+ async runReplay(options) {
16646
+ const prep = await this.prepareReplay(options);
16647
+ return this.applyPreparedReplay(prep, options);
16648
+ }
16564
16649
  /**
16565
16650
  * Sync the lockfile after a divergent PR was squash-merged.
16566
16651
  * Call this BEFORE runReplay() when the CLI detects a merged divergent PR.
@@ -16634,15 +16719,35 @@ var ReplayService = class {
16634
16719
  throw error;
16635
16720
  }
16636
16721
  }
16637
- async handleFirstGeneration(options) {
16722
+ firstGenerationReport() {
16723
+ return {
16724
+ flow: "first-generation",
16725
+ patchesDetected: 0,
16726
+ patchesApplied: 0,
16727
+ patchesWithConflicts: 0,
16728
+ patchesSkipped: 0,
16729
+ conflicts: []
16730
+ };
16731
+ }
16732
+ async _prepareFirstGeneration(options) {
16638
16733
  if (options?.dryRun) {
16734
+ const headSha = await this.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
16639
16735
  return {
16640
16736
  flow: "first-generation",
16641
- patchesDetected: 0,
16642
- patchesApplied: 0,
16643
- patchesWithConflicts: 0,
16644
- patchesSkipped: 0,
16645
- conflicts: []
16737
+ previousGenerationSha: null,
16738
+ currentGenerationSha: headSha,
16739
+ baseBranchHead: options.baseBranchHead ?? null,
16740
+ _prepared: {
16741
+ flow: "first-generation",
16742
+ terminal: true,
16743
+ preparedReport: this.firstGenerationReport(),
16744
+ patchesToApply: [],
16745
+ newPatchIds: /* @__PURE__ */ new Set(),
16746
+ detectorWarnings: [],
16747
+ revertedCount: 0,
16748
+ genSha: headSha,
16749
+ optionsSnapshot: options
16750
+ }
16646
16751
  };
16647
16752
  }
16648
16753
  const commitOpts = options ? {
@@ -16652,22 +16757,36 @@ var ReplayService = class {
16652
16757
  } : void 0;
16653
16758
  await this.committer.commitGeneration("Initial SDK generation", commitOpts);
16654
16759
  const genRecord = await this.committer.createGenerationRecord(commitOpts);
16655
- this.lockManager.initialize(genRecord);
16760
+ this.lockManager.initializeInMemory(genRecord);
16656
16761
  return {
16657
16762
  flow: "first-generation",
16658
- patchesDetected: 0,
16659
- patchesApplied: 0,
16660
- patchesWithConflicts: 0,
16661
- patchesSkipped: 0,
16662
- conflicts: []
16763
+ previousGenerationSha: null,
16764
+ currentGenerationSha: genRecord.commit_sha,
16765
+ baseBranchHead: options?.baseBranchHead ?? null,
16766
+ _prepared: {
16767
+ flow: "first-generation",
16768
+ terminal: false,
16769
+ patchesToApply: [],
16770
+ newPatchIds: /* @__PURE__ */ new Set(),
16771
+ detectorWarnings: [],
16772
+ revertedCount: 0,
16773
+ genSha: genRecord.commit_sha,
16774
+ optionsSnapshot: options
16775
+ }
16663
16776
  };
16664
16777
  }
16778
+ async _applyFirstGeneration(_prep) {
16779
+ this.lockManager.save();
16780
+ return this.firstGenerationReport();
16781
+ }
16665
16782
  /**
16666
- * Skip-application mode: commit the generation and update the lockfile
16667
- * but don't detect or apply patches. Sets a marker so the next normal
16668
- * run skips revert detection in preGenerationRebase().
16783
+ * Skip-application mode phase 1: commit the generation and update the
16784
+ * in-memory lockfile, but don't detect or apply patches. Sets a marker
16785
+ * so the next normal run skips revert detection in preGenerationRebase().
16786
+ *
16787
+ * Disk save is deferred to `_applySkipApplication`.
16669
16788
  */
16670
- async handleSkipApplication(options) {
16789
+ async _prepareSkipApplication(options) {
16671
16790
  const commitOpts = options ? {
16672
16791
  cliVersion: options.cliVersion ?? "unknown",
16673
16792
  generatorVersions: options.generatorVersions ?? {},
@@ -16676,8 +16795,10 @@ var ReplayService = class {
16676
16795
  await this.committer.commitGeneration("Update SDK (replay skipped)", commitOpts);
16677
16796
  await this.cleanupStaleConflictMarkers();
16678
16797
  const genRecord = await this.committer.createGenerationRecord(commitOpts);
16798
+ let previousGenerationSha = null;
16679
16799
  try {
16680
- this.lockManager.read();
16800
+ const lock = this.lockManager.read();
16801
+ previousGenerationSha = lock.current_generation ?? null;
16681
16802
  this.lockManager.addGeneration(genRecord);
16682
16803
  } catch (error) {
16683
16804
  if (error instanceof LockfileNotFoundError) {
@@ -16687,6 +16808,24 @@ var ReplayService = class {
16687
16808
  }
16688
16809
  }
16689
16810
  this.lockManager.setReplaySkippedAt((/* @__PURE__ */ new Date()).toISOString());
16811
+ return {
16812
+ flow: "skip-application",
16813
+ previousGenerationSha,
16814
+ currentGenerationSha: genRecord.commit_sha,
16815
+ baseBranchHead: options?.baseBranchHead ?? null,
16816
+ _prepared: {
16817
+ flow: "skip-application",
16818
+ terminal: false,
16819
+ patchesToApply: [],
16820
+ newPatchIds: /* @__PURE__ */ new Set(),
16821
+ detectorWarnings: [],
16822
+ revertedCount: 0,
16823
+ genSha: genRecord.commit_sha,
16824
+ optionsSnapshot: options
16825
+ }
16826
+ };
16827
+ }
16828
+ async _applySkipApplication(_prep, options) {
16690
16829
  this.lockManager.save();
16691
16830
  const credentialWarnings = [...this.lockManager.warnings];
16692
16831
  if (!options?.stageOnly) {
@@ -16704,12 +16843,14 @@ var ReplayService = class {
16704
16843
  warnings: credentialWarnings.length > 0 ? credentialWarnings : void 0
16705
16844
  };
16706
16845
  }
16707
- async handleNoPatchesRegeneration(options) {
16846
+ async _prepareNoPatchesRegeneration(options) {
16847
+ const preLock = this.lockManager.read();
16848
+ const previousGenerationSha = preLock.current_generation ?? null;
16708
16849
  let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
16709
16850
  const detectorWarnings = [...this.detector.warnings];
16710
16851
  if (options?.dryRun) {
16711
16852
  const dryRunWarnings = [...detectorWarnings, ...this.warnings];
16712
- return {
16853
+ const preparedReport = {
16713
16854
  flow: "no-patches",
16714
16855
  patchesDetected: newPatches.length,
16715
16856
  patchesApplied: 0,
@@ -16720,9 +16861,26 @@ var ReplayService = class {
16720
16861
  wouldApply: newPatches,
16721
16862
  warnings: dryRunWarnings.length > 0 ? dryRunWarnings : void 0
16722
16863
  };
16864
+ const headSha = await this.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
16865
+ return {
16866
+ flow: "no-patches",
16867
+ previousGenerationSha,
16868
+ currentGenerationSha: headSha,
16869
+ baseBranchHead: options.baseBranchHead ?? null,
16870
+ _prepared: {
16871
+ flow: "no-patches",
16872
+ terminal: true,
16873
+ preparedReport,
16874
+ patchesToApply: [],
16875
+ newPatchIds: /* @__PURE__ */ new Set(),
16876
+ detectorWarnings,
16877
+ revertedCount: revertedPatchIds.length,
16878
+ genSha: headSha,
16879
+ optionsSnapshot: options
16880
+ }
16881
+ };
16723
16882
  }
16724
16883
  {
16725
- const preLock = this.lockManager.read();
16726
16884
  const preCurrentGen = preLock.current_generation;
16727
16885
  const uniqueFiles = [...new Set(newPatches.flatMap((p) => p.files))];
16728
16886
  const absorbedFiles = /* @__PURE__ */ new Set();
@@ -16753,6 +16911,27 @@ var ReplayService = class {
16753
16911
  await this.cleanupStaleConflictMarkers();
16754
16912
  const genRecord = await this.committer.createGenerationRecord(commitOpts);
16755
16913
  this.lockManager.addGeneration(genRecord);
16914
+ return {
16915
+ flow: "no-patches",
16916
+ previousGenerationSha,
16917
+ currentGenerationSha: genRecord.commit_sha,
16918
+ baseBranchHead: options?.baseBranchHead ?? null,
16919
+ _prepared: {
16920
+ flow: "no-patches",
16921
+ terminal: false,
16922
+ patchesToApply: newPatches,
16923
+ newPatchIds: new Set(newPatches.map((p) => p.id)),
16924
+ detectorWarnings,
16925
+ revertedCount: revertedPatchIds.length,
16926
+ genSha: genRecord.commit_sha,
16927
+ optionsSnapshot: options
16928
+ }
16929
+ };
16930
+ }
16931
+ async _applyNoPatchesRegeneration(prep, options) {
16932
+ const newPatches = prep._prepared.patchesToApply;
16933
+ const detectorWarnings = prep._prepared.detectorWarnings;
16934
+ const genSha = prep._prepared.genSha;
16756
16935
  let results = [];
16757
16936
  if (newPatches.length > 0) {
16758
16937
  results = await this.applicator.applyPatches(newPatches);
@@ -16764,7 +16943,7 @@ var ReplayService = class {
16764
16943
  }
16765
16944
  }
16766
16945
  }
16767
- const rebaseCounts = await this.rebasePatches(results, genRecord.commit_sha);
16946
+ const rebaseCounts = await this.rebasePatches(results, genSha);
16768
16947
  for (const patch of newPatches) {
16769
16948
  if (!rebaseCounts.absorbedPatchIds.has(patch.id)) {
16770
16949
  this.lockManager.addPatch(patch);
@@ -16781,15 +16960,26 @@ var ReplayService = class {
16781
16960
  }
16782
16961
  }
16783
16962
  const warnings = [...detectorWarnings, ...this.warnings];
16784
- return this.buildReport("no-patches", newPatches, results, options, warnings, rebaseCounts, void 0, revertedPatchIds.length);
16963
+ return this.buildReport(
16964
+ "no-patches",
16965
+ newPatches,
16966
+ results,
16967
+ prep._prepared.optionsSnapshot,
16968
+ warnings,
16969
+ rebaseCounts,
16970
+ void 0,
16971
+ prep._prepared.revertedCount
16972
+ );
16785
16973
  }
16786
- async handleNormalRegeneration(options) {
16974
+ async _prepareNormalRegeneration(options) {
16975
+ const preLock = this.lockManager.read();
16976
+ const previousGenerationSha = preLock.current_generation ?? null;
16787
16977
  if (options?.dryRun) {
16788
16978
  const existingPatches2 = this.lockManager.getPatches();
16789
16979
  const { patches: newPatches2, revertedPatchIds: dryRunReverted } = await this.detector.detectNewPatches();
16790
- const warnings2 = [...this.detector.warnings, ...this.warnings];
16980
+ const warnings = [...this.detector.warnings, ...this.warnings];
16791
16981
  const allPatches2 = [...existingPatches2, ...newPatches2];
16792
- return {
16982
+ const preparedReport = {
16793
16983
  flow: "normal-regeneration",
16794
16984
  patchesDetected: allPatches2.length,
16795
16985
  patchesApplied: 0,
@@ -16798,7 +16988,25 @@ var ReplayService = class {
16798
16988
  patchesReverted: dryRunReverted.length,
16799
16989
  conflicts: [],
16800
16990
  wouldApply: allPatches2,
16801
- warnings: warnings2.length > 0 ? warnings2 : void 0
16991
+ warnings: warnings.length > 0 ? warnings : void 0
16992
+ };
16993
+ const headSha = await this.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
16994
+ return {
16995
+ flow: "normal-regeneration",
16996
+ previousGenerationSha,
16997
+ currentGenerationSha: headSha,
16998
+ baseBranchHead: options.baseBranchHead ?? null,
16999
+ _prepared: {
17000
+ flow: "normal-regeneration",
17001
+ terminal: true,
17002
+ preparedReport,
17003
+ patchesToApply: [],
17004
+ newPatchIds: /* @__PURE__ */ new Set(),
17005
+ detectorWarnings: [...this.detector.warnings],
17006
+ revertedCount: dryRunReverted.length,
17007
+ genSha: headSha,
17008
+ optionsSnapshot: options
17009
+ }
16802
17010
  };
16803
17011
  }
16804
17012
  let existingPatches = this.lockManager.getPatches();
@@ -16850,6 +17058,29 @@ var ReplayService = class {
16850
17058
  await this.cleanupStaleConflictMarkers();
16851
17059
  const genRecord = await this.committer.createGenerationRecord(commitOpts);
16852
17060
  this.lockManager.addGeneration(genRecord);
17061
+ return {
17062
+ flow: "normal-regeneration",
17063
+ previousGenerationSha,
17064
+ currentGenerationSha: genRecord.commit_sha,
17065
+ baseBranchHead: options?.baseBranchHead ?? null,
17066
+ _prepared: {
17067
+ flow: "normal-regeneration",
17068
+ terminal: false,
17069
+ patchesToApply: allPatches,
17070
+ newPatchIds: new Set(newPatches.map((p) => p.id)),
17071
+ preRebaseCounts,
17072
+ detectorWarnings,
17073
+ revertedCount: revertedPatchIds.length,
17074
+ genSha: genRecord.commit_sha,
17075
+ optionsSnapshot: options
17076
+ }
17077
+ };
17078
+ }
17079
+ async _applyNormalRegeneration(prep, options) {
17080
+ const allPatches = prep._prepared.patchesToApply;
17081
+ const newPatchIds = prep._prepared.newPatchIds;
17082
+ const detectorWarnings = prep._prepared.detectorWarnings;
17083
+ const genSha = prep._prepared.genSha;
16853
17084
  const results = await this.applicator.applyPatches(allPatches);
16854
17085
  this._lastApplyResults = results;
16855
17086
  this.revertConflictingFiles(results);
@@ -16858,7 +17089,8 @@ var ReplayService = class {
16858
17089
  result.patch.status = "unresolved";
16859
17090
  }
16860
17091
  }
16861
- const rebaseCounts = await this.rebasePatches(results, genRecord.commit_sha);
17092
+ const rebaseCounts = await this.rebasePatches(results, genSha);
17093
+ const newPatches = allPatches.filter((p) => newPatchIds.has(p.id));
16862
17094
  for (const patch of newPatches) {
16863
17095
  if (!rebaseCounts.absorbedPatchIds.has(patch.id)) {
16864
17096
  this.lockManager.addPatch(patch);
@@ -16885,11 +17117,11 @@ var ReplayService = class {
16885
17117
  "normal-regeneration",
16886
17118
  allPatches,
16887
17119
  results,
16888
- options,
17120
+ prep._prepared.optionsSnapshot,
16889
17121
  warnings,
16890
17122
  rebaseCounts,
16891
- preRebaseCounts,
16892
- revertedPatchIds.length
17123
+ prep._prepared.preRebaseCounts,
17124
+ prep._prepared.revertedCount
16893
17125
  );
16894
17126
  }
16895
17127
  /**