@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/index.js CHANGED
@@ -439,7 +439,7 @@ var FERN_SUPPORT_NAMES = ["fern-support", "Fern Support"];
439
439
  function isGenerationCommit(commit) {
440
440
  const isFernSupport = FERN_SUPPORT_NAMES.includes(commit.authorName);
441
441
  const isBotAuthor = !isFernSupport && (commit.authorLogin === FERN_BOT_LOGIN || commit.authorEmail === FERN_BOT_EMAIL || commit.authorName === FERN_BOT_NAME);
442
- 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.
442
+ 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.
443
443
  // The default PR title is "SDK Generation" (from GithubStep's commitMessage default).
444
444
  // GitHub appends "(#N)" for the PR number, e.g. "SDK Generation (#70)".
445
445
  commit.message.startsWith("SDK Generation");
@@ -708,17 +708,57 @@ var ReplayDetector = class {
708
708
  }
709
709
  const isAncestor = await this.git.isAncestor(lastGen.commit_sha, "HEAD");
710
710
  if (!isAncestor) {
711
+ return this.detectPatchesViaMergeBase(lastGen, lock);
712
+ }
713
+ return this.detectPatchesInRange(lastGen.commit_sha, lastGen, lock);
714
+ }
715
+ /**
716
+ * FER-10201 — Non-linear-history detection via `merge-base(prevGen, HEAD)`.
717
+ *
718
+ * After a squash-merge of a Fern-bot PR, `lastGen.commit_sha` (the previous
719
+ * `[fern-generated]`) is orphaned but still in git's object database. The
720
+ * merge-base is the commit on main from which the bot's branch was created —
721
+ * a reachable ancestor of HEAD. Walking that range and classifying each
722
+ * commit via `isGenerationCommit` mirrors the linear path and eliminates
723
+ * the bug class where pipeline-driven changes (autoversion, replay,
724
+ * generation) get bundled as customer customizations.
725
+ *
726
+ * Falls through to the legacy composite path when no common ancestor exists
727
+ * (truly disjoint history — orphan branches with no shared root).
728
+ */
729
+ async detectPatchesViaMergeBase(lastGen, lock) {
730
+ let mergeBase = "";
731
+ try {
732
+ mergeBase = (await this.git.exec(["merge-base", lastGen.commit_sha, "HEAD"])).trim();
733
+ } catch {
734
+ }
735
+ if (!mergeBase) {
736
+ this.warnings.push(
737
+ `No common ancestor between previous generation ${lastGen.commit_sha.slice(0, 7)} and HEAD. Falling back to composite tree-diff detection.`
738
+ );
711
739
  return this.detectPatchesViaTreeDiff(
712
740
  lastGen,
713
741
  /* commitKnownMissing */
714
742
  false
715
743
  );
716
744
  }
745
+ return this.detectPatchesInRange(mergeBase, lastGen, lock);
746
+ }
747
+ /**
748
+ * FER-10201 — Walk `${rangeStart}..HEAD`, classify each commit, emit one
749
+ * `StoredPatch` per customer commit. Body shared by the linear path
750
+ * (rangeStart = lastGen.commit_sha) and the merge-base path.
751
+ *
752
+ * Patch `base_generation` is always `lastGen.commit_sha` — the
753
+ * lockfile-tracked reference the applicator uses to fetch base file
754
+ * content, regardless of where the walk actually started.
755
+ */
756
+ async detectPatchesInRange(rangeStart, lastGen, lock) {
717
757
  const log = await this.git.exec([
718
758
  "log",
719
759
  "--first-parent",
720
760
  "--format=%H%x00%an%x00%ae%x00%s",
721
- `${lastGen.commit_sha}..HEAD`,
761
+ `${rangeStart}..HEAD`,
722
762
  "--",
723
763
  this.sdkOutputDir
724
764
  ]);
@@ -2126,21 +2166,66 @@ var ReplayService = class {
2126
2166
  this.applicator = new ReplayApplicator(git, this.lockManager, outputDir);
2127
2167
  this.committer = new ReplayCommitter(git, outputDir);
2128
2168
  }
2129
- async runReplay(options) {
2169
+ /**
2170
+ * Phase 1 of the two-phase replay flow. Runs preGenerationRebase (when applicable),
2171
+ * detects new patches, and creates the `[fern-generated]` commit. Leaves HEAD at
2172
+ * the generation commit (or unchanged for dry-run).
2173
+ *
2174
+ * Does NOT apply patches — the returned handle must be passed to
2175
+ * `applyPreparedReplay()` to complete the run. No disk writes to the lockfile
2176
+ * happen here; lockfile persistence is deferred to phase 2.
2177
+ *
2178
+ * Callers may land additional commits on HEAD between `prepareReplay` and
2179
+ * `applyPreparedReplay` (e.g., `[fern-autoversion]`).
2180
+ */
2181
+ async prepareReplay(options) {
2130
2182
  this.warnings = [];
2183
+ this.detector.warnings.length = 0;
2131
2184
  if (options?.skipApplication) {
2132
- return this.handleSkipApplication(options);
2185
+ return this._prepareSkipApplication(options);
2133
2186
  }
2134
2187
  const flow = this.determineFlow();
2135
2188
  switch (flow) {
2136
2189
  case "first-generation":
2137
- return this.handleFirstGeneration(options);
2190
+ return this._prepareFirstGeneration(options);
2191
+ case "no-patches":
2192
+ return this._prepareNoPatchesRegeneration(options);
2193
+ case "normal-regeneration":
2194
+ return this._prepareNormalRegeneration(options);
2195
+ }
2196
+ }
2197
+ /**
2198
+ * Phase 2 of the two-phase replay flow. Applies patches, rebases them against
2199
+ * the generation, saves the lockfile, and commits `[fern-replay]` (or stages
2200
+ * under `stageOnly`). Operates on HEAD at call time — mid-phase commits are
2201
+ * respected.
2202
+ *
2203
+ * For terminal handles (dry-run, first-gen has no patch work, skip-app does
2204
+ * its own post-commit logic), this returns the precomputed report.
2205
+ */
2206
+ async applyPreparedReplay(prep, options) {
2207
+ if (prep._prepared.terminal) {
2208
+ return prep._prepared.preparedReport;
2209
+ }
2210
+ switch (prep._prepared.flow) {
2211
+ case "first-generation":
2212
+ return this._applyFirstGeneration(prep);
2213
+ case "skip-application":
2214
+ return this._applySkipApplication(prep, options);
2138
2215
  case "no-patches":
2139
- return this.handleNoPatchesRegeneration(options);
2216
+ return this._applyNoPatchesRegeneration(prep, options);
2140
2217
  case "normal-regeneration":
2141
- return this.handleNormalRegeneration(options);
2218
+ return this._applyNormalRegeneration(prep, options);
2142
2219
  }
2143
2220
  }
2221
+ /**
2222
+ * Backwards-compatible atomic entry point. Composes `prepareReplay` + `applyPreparedReplay`.
2223
+ * Behavior is byte-identical to the pre-split implementation for all existing callers.
2224
+ */
2225
+ async runReplay(options) {
2226
+ const prep = await this.prepareReplay(options);
2227
+ return this.applyPreparedReplay(prep, options);
2228
+ }
2144
2229
  /**
2145
2230
  * Sync the lockfile after a divergent PR was squash-merged.
2146
2231
  * Call this BEFORE runReplay() when the CLI detects a merged divergent PR.
@@ -2214,15 +2299,35 @@ var ReplayService = class {
2214
2299
  throw error;
2215
2300
  }
2216
2301
  }
2217
- async handleFirstGeneration(options) {
2302
+ firstGenerationReport() {
2303
+ return {
2304
+ flow: "first-generation",
2305
+ patchesDetected: 0,
2306
+ patchesApplied: 0,
2307
+ patchesWithConflicts: 0,
2308
+ patchesSkipped: 0,
2309
+ conflicts: []
2310
+ };
2311
+ }
2312
+ async _prepareFirstGeneration(options) {
2218
2313
  if (options?.dryRun) {
2314
+ const headSha = await this.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
2219
2315
  return {
2220
2316
  flow: "first-generation",
2221
- patchesDetected: 0,
2222
- patchesApplied: 0,
2223
- patchesWithConflicts: 0,
2224
- patchesSkipped: 0,
2225
- conflicts: []
2317
+ previousGenerationSha: null,
2318
+ currentGenerationSha: headSha,
2319
+ baseBranchHead: options.baseBranchHead ?? null,
2320
+ _prepared: {
2321
+ flow: "first-generation",
2322
+ terminal: true,
2323
+ preparedReport: this.firstGenerationReport(),
2324
+ patchesToApply: [],
2325
+ newPatchIds: /* @__PURE__ */ new Set(),
2326
+ detectorWarnings: [],
2327
+ revertedCount: 0,
2328
+ genSha: headSha,
2329
+ optionsSnapshot: options
2330
+ }
2226
2331
  };
2227
2332
  }
2228
2333
  const commitOpts = options ? {
@@ -2232,22 +2337,36 @@ var ReplayService = class {
2232
2337
  } : void 0;
2233
2338
  await this.committer.commitGeneration("Initial SDK generation", commitOpts);
2234
2339
  const genRecord = await this.committer.createGenerationRecord(commitOpts);
2235
- this.lockManager.initialize(genRecord);
2340
+ this.lockManager.initializeInMemory(genRecord);
2236
2341
  return {
2237
2342
  flow: "first-generation",
2238
- patchesDetected: 0,
2239
- patchesApplied: 0,
2240
- patchesWithConflicts: 0,
2241
- patchesSkipped: 0,
2242
- conflicts: []
2343
+ previousGenerationSha: null,
2344
+ currentGenerationSha: genRecord.commit_sha,
2345
+ baseBranchHead: options?.baseBranchHead ?? null,
2346
+ _prepared: {
2347
+ flow: "first-generation",
2348
+ terminal: false,
2349
+ patchesToApply: [],
2350
+ newPatchIds: /* @__PURE__ */ new Set(),
2351
+ detectorWarnings: [],
2352
+ revertedCount: 0,
2353
+ genSha: genRecord.commit_sha,
2354
+ optionsSnapshot: options
2355
+ }
2243
2356
  };
2244
2357
  }
2358
+ async _applyFirstGeneration(_prep) {
2359
+ this.lockManager.save();
2360
+ return this.firstGenerationReport();
2361
+ }
2245
2362
  /**
2246
- * Skip-application mode: commit the generation and update the lockfile
2247
- * but don't detect or apply patches. Sets a marker so the next normal
2248
- * run skips revert detection in preGenerationRebase().
2363
+ * Skip-application mode phase 1: commit the generation and update the
2364
+ * in-memory lockfile, but don't detect or apply patches. Sets a marker
2365
+ * so the next normal run skips revert detection in preGenerationRebase().
2366
+ *
2367
+ * Disk save is deferred to `_applySkipApplication`.
2249
2368
  */
2250
- async handleSkipApplication(options) {
2369
+ async _prepareSkipApplication(options) {
2251
2370
  const commitOpts = options ? {
2252
2371
  cliVersion: options.cliVersion ?? "unknown",
2253
2372
  generatorVersions: options.generatorVersions ?? {},
@@ -2256,8 +2375,10 @@ var ReplayService = class {
2256
2375
  await this.committer.commitGeneration("Update SDK (replay skipped)", commitOpts);
2257
2376
  await this.cleanupStaleConflictMarkers();
2258
2377
  const genRecord = await this.committer.createGenerationRecord(commitOpts);
2378
+ let previousGenerationSha = null;
2259
2379
  try {
2260
- this.lockManager.read();
2380
+ const lock = this.lockManager.read();
2381
+ previousGenerationSha = lock.current_generation ?? null;
2261
2382
  this.lockManager.addGeneration(genRecord);
2262
2383
  } catch (error) {
2263
2384
  if (error instanceof LockfileNotFoundError) {
@@ -2267,6 +2388,24 @@ var ReplayService = class {
2267
2388
  }
2268
2389
  }
2269
2390
  this.lockManager.setReplaySkippedAt((/* @__PURE__ */ new Date()).toISOString());
2391
+ return {
2392
+ flow: "skip-application",
2393
+ previousGenerationSha,
2394
+ currentGenerationSha: genRecord.commit_sha,
2395
+ baseBranchHead: options?.baseBranchHead ?? null,
2396
+ _prepared: {
2397
+ flow: "skip-application",
2398
+ terminal: false,
2399
+ patchesToApply: [],
2400
+ newPatchIds: /* @__PURE__ */ new Set(),
2401
+ detectorWarnings: [],
2402
+ revertedCount: 0,
2403
+ genSha: genRecord.commit_sha,
2404
+ optionsSnapshot: options
2405
+ }
2406
+ };
2407
+ }
2408
+ async _applySkipApplication(_prep, options) {
2270
2409
  this.lockManager.save();
2271
2410
  const credentialWarnings = [...this.lockManager.warnings];
2272
2411
  if (!options?.stageOnly) {
@@ -2284,12 +2423,14 @@ var ReplayService = class {
2284
2423
  warnings: credentialWarnings.length > 0 ? credentialWarnings : void 0
2285
2424
  };
2286
2425
  }
2287
- async handleNoPatchesRegeneration(options) {
2426
+ async _prepareNoPatchesRegeneration(options) {
2427
+ const preLock = this.lockManager.read();
2428
+ const previousGenerationSha = preLock.current_generation ?? null;
2288
2429
  let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
2289
2430
  const detectorWarnings = [...this.detector.warnings];
2290
2431
  if (options?.dryRun) {
2291
2432
  const dryRunWarnings = [...detectorWarnings, ...this.warnings];
2292
- return {
2433
+ const preparedReport = {
2293
2434
  flow: "no-patches",
2294
2435
  patchesDetected: newPatches.length,
2295
2436
  patchesApplied: 0,
@@ -2300,9 +2441,26 @@ var ReplayService = class {
2300
2441
  wouldApply: newPatches,
2301
2442
  warnings: dryRunWarnings.length > 0 ? dryRunWarnings : void 0
2302
2443
  };
2444
+ const headSha = await this.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
2445
+ return {
2446
+ flow: "no-patches",
2447
+ previousGenerationSha,
2448
+ currentGenerationSha: headSha,
2449
+ baseBranchHead: options.baseBranchHead ?? null,
2450
+ _prepared: {
2451
+ flow: "no-patches",
2452
+ terminal: true,
2453
+ preparedReport,
2454
+ patchesToApply: [],
2455
+ newPatchIds: /* @__PURE__ */ new Set(),
2456
+ detectorWarnings,
2457
+ revertedCount: revertedPatchIds.length,
2458
+ genSha: headSha,
2459
+ optionsSnapshot: options
2460
+ }
2461
+ };
2303
2462
  }
2304
2463
  {
2305
- const preLock = this.lockManager.read();
2306
2464
  const preCurrentGen = preLock.current_generation;
2307
2465
  const uniqueFiles = [...new Set(newPatches.flatMap((p) => p.files))];
2308
2466
  const absorbedFiles = /* @__PURE__ */ new Set();
@@ -2333,6 +2491,27 @@ var ReplayService = class {
2333
2491
  await this.cleanupStaleConflictMarkers();
2334
2492
  const genRecord = await this.committer.createGenerationRecord(commitOpts);
2335
2493
  this.lockManager.addGeneration(genRecord);
2494
+ return {
2495
+ flow: "no-patches",
2496
+ previousGenerationSha,
2497
+ currentGenerationSha: genRecord.commit_sha,
2498
+ baseBranchHead: options?.baseBranchHead ?? null,
2499
+ _prepared: {
2500
+ flow: "no-patches",
2501
+ terminal: false,
2502
+ patchesToApply: newPatches,
2503
+ newPatchIds: new Set(newPatches.map((p) => p.id)),
2504
+ detectorWarnings,
2505
+ revertedCount: revertedPatchIds.length,
2506
+ genSha: genRecord.commit_sha,
2507
+ optionsSnapshot: options
2508
+ }
2509
+ };
2510
+ }
2511
+ async _applyNoPatchesRegeneration(prep, options) {
2512
+ const newPatches = prep._prepared.patchesToApply;
2513
+ const detectorWarnings = prep._prepared.detectorWarnings;
2514
+ const genSha = prep._prepared.genSha;
2336
2515
  let results = [];
2337
2516
  if (newPatches.length > 0) {
2338
2517
  results = await this.applicator.applyPatches(newPatches);
@@ -2344,7 +2523,7 @@ var ReplayService = class {
2344
2523
  }
2345
2524
  }
2346
2525
  }
2347
- const rebaseCounts = await this.rebasePatches(results, genRecord.commit_sha);
2526
+ const rebaseCounts = await this.rebasePatches(results, genSha);
2348
2527
  for (const patch of newPatches) {
2349
2528
  if (!rebaseCounts.absorbedPatchIds.has(patch.id)) {
2350
2529
  this.lockManager.addPatch(patch);
@@ -2361,15 +2540,26 @@ var ReplayService = class {
2361
2540
  }
2362
2541
  }
2363
2542
  const warnings = [...detectorWarnings, ...this.warnings];
2364
- return this.buildReport("no-patches", newPatches, results, options, warnings, rebaseCounts, void 0, revertedPatchIds.length);
2543
+ return this.buildReport(
2544
+ "no-patches",
2545
+ newPatches,
2546
+ results,
2547
+ prep._prepared.optionsSnapshot,
2548
+ warnings,
2549
+ rebaseCounts,
2550
+ void 0,
2551
+ prep._prepared.revertedCount
2552
+ );
2365
2553
  }
2366
- async handleNormalRegeneration(options) {
2554
+ async _prepareNormalRegeneration(options) {
2555
+ const preLock = this.lockManager.read();
2556
+ const previousGenerationSha = preLock.current_generation ?? null;
2367
2557
  if (options?.dryRun) {
2368
2558
  const existingPatches2 = this.lockManager.getPatches();
2369
2559
  const { patches: newPatches2, revertedPatchIds: dryRunReverted } = await this.detector.detectNewPatches();
2370
- const warnings2 = [...this.detector.warnings, ...this.warnings];
2560
+ const warnings = [...this.detector.warnings, ...this.warnings];
2371
2561
  const allPatches2 = [...existingPatches2, ...newPatches2];
2372
- return {
2562
+ const preparedReport = {
2373
2563
  flow: "normal-regeneration",
2374
2564
  patchesDetected: allPatches2.length,
2375
2565
  patchesApplied: 0,
@@ -2378,7 +2568,25 @@ var ReplayService = class {
2378
2568
  patchesReverted: dryRunReverted.length,
2379
2569
  conflicts: [],
2380
2570
  wouldApply: allPatches2,
2381
- warnings: warnings2.length > 0 ? warnings2 : void 0
2571
+ warnings: warnings.length > 0 ? warnings : void 0
2572
+ };
2573
+ const headSha = await this.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
2574
+ return {
2575
+ flow: "normal-regeneration",
2576
+ previousGenerationSha,
2577
+ currentGenerationSha: headSha,
2578
+ baseBranchHead: options.baseBranchHead ?? null,
2579
+ _prepared: {
2580
+ flow: "normal-regeneration",
2581
+ terminal: true,
2582
+ preparedReport,
2583
+ patchesToApply: [],
2584
+ newPatchIds: /* @__PURE__ */ new Set(),
2585
+ detectorWarnings: [...this.detector.warnings],
2586
+ revertedCount: dryRunReverted.length,
2587
+ genSha: headSha,
2588
+ optionsSnapshot: options
2589
+ }
2382
2590
  };
2383
2591
  }
2384
2592
  let existingPatches = this.lockManager.getPatches();
@@ -2430,6 +2638,29 @@ var ReplayService = class {
2430
2638
  await this.cleanupStaleConflictMarkers();
2431
2639
  const genRecord = await this.committer.createGenerationRecord(commitOpts);
2432
2640
  this.lockManager.addGeneration(genRecord);
2641
+ return {
2642
+ flow: "normal-regeneration",
2643
+ previousGenerationSha,
2644
+ currentGenerationSha: genRecord.commit_sha,
2645
+ baseBranchHead: options?.baseBranchHead ?? null,
2646
+ _prepared: {
2647
+ flow: "normal-regeneration",
2648
+ terminal: false,
2649
+ patchesToApply: allPatches,
2650
+ newPatchIds: new Set(newPatches.map((p) => p.id)),
2651
+ preRebaseCounts,
2652
+ detectorWarnings,
2653
+ revertedCount: revertedPatchIds.length,
2654
+ genSha: genRecord.commit_sha,
2655
+ optionsSnapshot: options
2656
+ }
2657
+ };
2658
+ }
2659
+ async _applyNormalRegeneration(prep, options) {
2660
+ const allPatches = prep._prepared.patchesToApply;
2661
+ const newPatchIds = prep._prepared.newPatchIds;
2662
+ const detectorWarnings = prep._prepared.detectorWarnings;
2663
+ const genSha = prep._prepared.genSha;
2433
2664
  const results = await this.applicator.applyPatches(allPatches);
2434
2665
  this._lastApplyResults = results;
2435
2666
  this.revertConflictingFiles(results);
@@ -2438,7 +2669,8 @@ var ReplayService = class {
2438
2669
  result.patch.status = "unresolved";
2439
2670
  }
2440
2671
  }
2441
- const rebaseCounts = await this.rebasePatches(results, genRecord.commit_sha);
2672
+ const rebaseCounts = await this.rebasePatches(results, genSha);
2673
+ const newPatches = allPatches.filter((p) => newPatchIds.has(p.id));
2442
2674
  for (const patch of newPatches) {
2443
2675
  if (!rebaseCounts.absorbedPatchIds.has(patch.id)) {
2444
2676
  this.lockManager.addPatch(patch);
@@ -2465,11 +2697,11 @@ var ReplayService = class {
2465
2697
  "normal-regeneration",
2466
2698
  allPatches,
2467
2699
  results,
2468
- options,
2700
+ prep._prepared.optionsSnapshot,
2469
2701
  warnings,
2470
2702
  rebaseCounts,
2471
- preRebaseCounts,
2472
- revertedPatchIds.length
2703
+ prep._prepared.preRebaseCounts,
2704
+ prep._prepared.revertedCount
2473
2705
  );
2474
2706
  }
2475
2707
  /**