@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/cli.cjs CHANGED
@@ -13596,9 +13596,22 @@ function isGenerationCommit(commit) {
13596
13596
  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.
13597
13597
  // The default PR title is "SDK Generation" (from GithubStep's commitMessage default).
13598
13598
  // GitHub appends "(#N)" for the PR number, e.g. "SDK Generation (#70)".
13599
- commit.message.startsWith("SDK Generation");
13599
+ commit.message.startsWith("SDK Generation") || // Merge commit (GitHub's "Create a merge commit" option) of a fern-bot
13600
+ // regen PR. First-parent walking lands on this commit, not on the
13601
+ // generation commit on the side branch, so the classifier must treat
13602
+ // it as a generation boundary. GitHub's default subject is
13603
+ // "Merge pull request #N from <owner>/<branch>"; fern-bot branches are
13604
+ // named "<owner>/fern-bot/<...>".
13605
+ /^Merge pull request #\d+ from [^/]+\/fern-bot\//.test(commit.message);
13600
13606
  return isBotAuthor || hasGenerationMarker;
13601
13607
  }
13608
+ function isGenerationBoundary(commit) {
13609
+ const isFernSupport = FERN_SUPPORT_NAMES.includes(commit.authorName);
13610
+ const isBotAuthor = !isFernSupport && (commit.authorLogin === FERN_BOT_LOGIN || commit.authorEmail === FERN_BOT_EMAIL || commit.authorName === FERN_BOT_NAME);
13611
+ const isAutoversion = commit.message.startsWith("[fern-autoversion]");
13612
+ 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");
13613
+ return isBotAuthor && !isAutoversion || hasBoundaryMarker;
13614
+ }
13602
13615
  function isReplayCommit(commit) {
13603
13616
  return commit.message.startsWith("[fern-replay]");
13604
13617
  }
@@ -13672,8 +13685,48 @@ var ReplayDetector = class {
13672
13685
  this.lockManager = lockManager;
13673
13686
  this.sdkOutputDir = sdkOutputDir;
13674
13687
  }
13688
+ /**
13689
+ * Derive the previous-generation SHA from git history instead of trusting
13690
+ * `lock.current_generation`.
13691
+ *
13692
+ * Walks `git log HEAD --first-parent` and returns the SHA of the most recent
13693
+ * commit that `isGenerationBoundary` classifies as a customization-baseline
13694
+ * boundary, or null if no such commit is reachable from HEAD on the mainline.
13695
+ *
13696
+ * `isGenerationBoundary` is intentionally narrower than `isGenerationCommit`:
13697
+ * it excludes `[fern-autoversion]` (lightweight bumps that don't reset the
13698
+ * baseline) and the GitHub merge-commit subject for fern-bot regen PRs
13699
+ * (which sits on first-parent but where the customer fix-up commits live
13700
+ * on second-parent — stopping at the merge would hide them).
13701
+ *
13702
+ * See docs/adr/0001-derived-scan-boundary.md.
13703
+ */
13704
+ async findPreviousGenerationFromHistory() {
13705
+ let log;
13706
+ try {
13707
+ log = await this.git.exec([
13708
+ "log",
13709
+ "--first-parent",
13710
+ "--format=%H%x00%an%x00%ae%x00%s",
13711
+ "HEAD"
13712
+ ]);
13713
+ } catch {
13714
+ return null;
13715
+ }
13716
+ for (const commit of this.parseGitLog(log)) {
13717
+ if (isGenerationBoundary(commit)) {
13718
+ return commit.sha;
13719
+ }
13720
+ }
13721
+ return null;
13722
+ }
13675
13723
  async detectNewPatches() {
13676
13724
  const lock = this.lockManager.read();
13725
+ const derivedSha = await this.findPreviousGenerationFromHistory();
13726
+ if (derivedSha !== null) {
13727
+ const derivedRecord = await this.synthesizeGenerationRecord(derivedSha);
13728
+ return this.detectPatchesInRange(derivedSha, derivedRecord, lock);
13729
+ }
13677
13730
  const lastGen = this.getLastGeneration(lock);
13678
13731
  if (!lastGen) {
13679
13732
  return { patches: [], revertedPatchIds: [] };
@@ -13681,7 +13734,7 @@ var ReplayDetector = class {
13681
13734
  const exists2 = await this.git.commitExists(lastGen.commit_sha);
13682
13735
  if (!exists2) {
13683
13736
  this.warnings.push(
13684
- `Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to alternate detection.`
13737
+ `Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to tree-diff detection (likely a shallow clone).`
13685
13738
  );
13686
13739
  return this.detectPatchesViaTreeDiff(
13687
13740
  lastGen,
@@ -13689,44 +13742,8 @@ var ReplayDetector = class {
13689
13742
  true
13690
13743
  );
13691
13744
  }
13692
- const isAncestor = await this.git.isAncestor(lastGen.commit_sha, "HEAD");
13693
- if (!isAncestor) {
13694
- return this.detectPatchesViaMergeBase(lastGen, lock);
13695
- }
13696
13745
  return this.detectPatchesInRange(lastGen.commit_sha, lastGen, lock);
13697
13746
  }
13698
- /**
13699
- * Non-linear-history detection via `merge-base(prevGen, HEAD)`.
13700
- *
13701
- * After a squash-merge of a Fern-bot PR, `lastGen.commit_sha` (the previous
13702
- * `[fern-generated]`) is orphaned but still in git's object database. The
13703
- * merge-base is the commit on main from which the bot's branch was created —
13704
- * a reachable ancestor of HEAD. Walking that range and classifying each
13705
- * commit via `isGenerationCommit` mirrors the linear path and eliminates
13706
- * the bug class where pipeline-driven changes (autoversion, replay,
13707
- * generation) get bundled as customer customizations.
13708
- *
13709
- * Falls through to the legacy composite path when no common ancestor exists
13710
- * (truly disjoint history — orphan branches with no shared root).
13711
- */
13712
- async detectPatchesViaMergeBase(lastGen, lock) {
13713
- let mergeBase = "";
13714
- try {
13715
- mergeBase = (await this.git.exec(["merge-base", lastGen.commit_sha, "HEAD"])).trim();
13716
- } catch {
13717
- }
13718
- if (!mergeBase) {
13719
- this.warnings.push(
13720
- `No common ancestor between previous generation ${lastGen.commit_sha.slice(0, 7)} and HEAD. Falling back to composite tree-diff detection.`
13721
- );
13722
- return this.detectPatchesViaTreeDiff(
13723
- lastGen,
13724
- /* commitKnownMissing */
13725
- false
13726
- );
13727
- }
13728
- return this.detectPatchesInRange(mergeBase, lastGen, lock);
13729
- }
13730
13747
  /**
13731
13748
  * Walk `${rangeStart}..HEAD`, classify each commit, emit one
13732
13749
  * `StoredPatch` per customer commit. Body shared by the linear path
@@ -13739,7 +13756,7 @@ var ReplayDetector = class {
13739
13756
  async detectPatchesInRange(rangeStart, lastGen, lock) {
13740
13757
  const log = await this.git.exec([
13741
13758
  "log",
13742
- "--first-parent",
13759
+ "--no-merges",
13743
13760
  "--format=%H%x00%an%x00%ae%x00%s",
13744
13761
  `${rangeStart}..HEAD`,
13745
13762
  "--",
@@ -13759,18 +13776,6 @@ var ReplayDetector = class {
13759
13776
  continue;
13760
13777
  }
13761
13778
  const parents = await this.git.getCommitParents(commit.sha);
13762
- if (parents.length > 1) {
13763
- const compositePatch = await this.createMergeCompositePatch({
13764
- mergeCommit: commit,
13765
- firstParent: parents[0],
13766
- lastGen,
13767
- lock
13768
- });
13769
- if (compositePatch) {
13770
- newPatches.push(compositePatch);
13771
- }
13772
- continue;
13773
- }
13774
13779
  let patchContent;
13775
13780
  try {
13776
13781
  patchContent = await this.git.formatPatch(commit.sha);
@@ -13908,9 +13913,7 @@ var ReplayDetector = class {
13908
13913
  * within the current linear detection window, and are absent from
13909
13914
  * HEAD at the end of the window.
13910
13915
  *
13911
- * Rationale: on a merge commit, createMergeCompositePatch already diffs
13912
- * firstParent..mergeCommit so net-zero files never enter the composite. On
13913
- * linear history each commit becomes its own patch, so a file that was
13916
+ * Rationale: each commit becomes its own patch, so a file that was
13914
13917
  * briefly added and later removed survives as a create+delete pair whose
13915
13918
  * cumulative diff is empty. We drop such patches before they reach the
13916
13919
  * lockfile.
@@ -13977,54 +13980,6 @@ var ReplayDetector = class {
13977
13980
  return /* @__PURE__ */ new Set();
13978
13981
  }
13979
13982
  }
13980
- /**
13981
- * Create a single composite patch for a merge commit by diffing the merge
13982
- * commit against its first parent. This captures the net change of the
13983
- * entire merged branch as one patch, eliminating accumulator chaining and
13984
- * zero-net-change ghost conflicts.
13985
- */
13986
- async createMergeCompositePatch(input) {
13987
- const { mergeCommit, firstParent, lastGen, lock } = input;
13988
- const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
13989
- const filesOutput = await this.git.exec([
13990
- "diff",
13991
- "--name-only",
13992
- firstParent,
13993
- mergeCommit.sha
13994
- ]);
13995
- const { files, sensitiveFiles } = filterInfrastructureAndSensitiveFiles(filesOutput);
13996
- if (sensitiveFiles.length > 0) {
13997
- this.warnings.push(
13998
- `Sensitive file(s) excluded from merge patch for commit ${mergeCommit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
13999
- );
14000
- }
14001
- if (files.length === 0) return null;
14002
- const diff = await this.git.exec([
14003
- "diff",
14004
- firstParent,
14005
- mergeCommit.sha,
14006
- "--",
14007
- ...files
14008
- ]);
14009
- if (!diff.trim()) return null;
14010
- const contentHash = this.computeContentHash(diff);
14011
- if (lock.patches.some((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
14012
- return null;
14013
- }
14014
- const theirsSnapshot = await capturePatchSnapshot(this.git, files, mergeCommit.sha);
14015
- return {
14016
- id: `patch-merge-${mergeCommit.sha.slice(0, 8)}`,
14017
- content_hash: contentHash,
14018
- original_commit: mergeCommit.sha,
14019
- original_message: mergeCommit.message,
14020
- original_author: `${mergeCommit.authorName} <${mergeCommit.authorEmail}>`,
14021
- base_generation: lastGen.commit_sha,
14022
- files,
14023
- patch_content: diff,
14024
- ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
14025
- ...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
14026
- };
14027
- }
14028
13983
  /**
14029
13984
  * Detect patches via tree diff for non-linear history. Returns a composite patch.
14030
13985
  * Revert reconciliation is skipped here because tree-diff produces a single composite
@@ -14224,6 +14179,26 @@ var ReplayDetector = class {
14224
14179
  getLastGeneration(lock) {
14225
14180
  return lock.generations.find((g) => g.commit_sha === lock.current_generation);
14226
14181
  }
14182
+ /**
14183
+ * Build a transient `GenerationRecord` from a SHA derived by walking git
14184
+ * history. Only `commit_sha` (and `tree_hash` when needed downstream) is
14185
+ * load-bearing here; the rest are filled with defensible placeholders.
14186
+ * This record is consumed by `detectPatchesInRange` and is never persisted.
14187
+ */
14188
+ async synthesizeGenerationRecord(sha) {
14189
+ let tree_hash = "";
14190
+ try {
14191
+ tree_hash = await this.git.getTreeHash(sha);
14192
+ } catch {
14193
+ }
14194
+ return {
14195
+ commit_sha: sha,
14196
+ tree_hash,
14197
+ timestamp: (/* @__PURE__ */ new Date(0)).toISOString(),
14198
+ cli_version: "",
14199
+ generator_versions: {}
14200
+ };
14201
+ }
14227
14202
  };
14228
14203
 
14229
14204
  // src/ReplayService.ts
@@ -16944,8 +16919,7 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
16944
16919
  tree_hash: treeHash,
16945
16920
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
16946
16921
  cli_version: options?.cliVersion ?? "unknown",
16947
- generator_versions: options?.generatorVersions ?? {},
16948
- base_branch_head: options?.baseBranchHead
16922
+ generator_versions: options?.generatorVersions ?? {}
16949
16923
  };
16950
16924
  }
16951
16925
  async stageAll() {
@@ -17324,7 +17298,6 @@ var FirstGenerationFlow = class {
17324
17298
  flow: "first-generation",
17325
17299
  previousGenerationSha: null,
17326
17300
  currentGenerationSha: headSha,
17327
- baseBranchHead: options.baseBranchHead ?? null,
17328
17301
  _prepared: {
17329
17302
  kind: "terminal",
17330
17303
  flow: "first-generation",
@@ -17334,8 +17307,7 @@ var FirstGenerationFlow = class {
17334
17307
  }
17335
17308
  const commitOpts = options ? {
17336
17309
  cliVersion: options.cliVersion ?? "unknown",
17337
- generatorVersions: options.generatorVersions ?? {},
17338
- baseBranchHead: options.baseBranchHead
17310
+ generatorVersions: options.generatorVersions ?? {}
17339
17311
  } : void 0;
17340
17312
  await this.service.committer.commitGeneration("Initial SDK generation", commitOpts);
17341
17313
  const genRecord = await this.service.committer.createGenerationRecord(commitOpts);
@@ -17344,7 +17316,6 @@ var FirstGenerationFlow = class {
17344
17316
  flow: "first-generation",
17345
17317
  previousGenerationSha: null,
17346
17318
  currentGenerationSha: genRecord.commit_sha,
17347
- baseBranchHead: options?.baseBranchHead ?? null,
17348
17319
  _prepared: {
17349
17320
  kind: "active",
17350
17321
  flow: "first-generation",
@@ -17381,8 +17352,7 @@ var SkipApplicationFlow = class {
17381
17352
  async prepare(options) {
17382
17353
  const commitOpts = options ? {
17383
17354
  cliVersion: options.cliVersion ?? "unknown",
17384
- generatorVersions: options.generatorVersions ?? {},
17385
- baseBranchHead: options.baseBranchHead
17355
+ generatorVersions: options.generatorVersions ?? {}
17386
17356
  } : void 0;
17387
17357
  await this.service.committer.commitGeneration("Update SDK (replay skipped)", commitOpts);
17388
17358
  await this.service.cleanupStaleConflictMarkers();
@@ -17404,7 +17374,6 @@ var SkipApplicationFlow = class {
17404
17374
  flow: "skip-application",
17405
17375
  previousGenerationSha,
17406
17376
  currentGenerationSha: genRecord.commit_sha,
17407
- baseBranchHead: options?.baseBranchHead ?? null,
17408
17377
  _prepared: {
17409
17378
  kind: "active",
17410
17379
  flow: "skip-application",
@@ -17465,7 +17434,6 @@ var NoPatchesFlow = class {
17465
17434
  flow: "no-patches",
17466
17435
  previousGenerationSha,
17467
17436
  currentGenerationSha: headSha,
17468
- baseBranchHead: options.baseBranchHead ?? null,
17469
17437
  _prepared: {
17470
17438
  kind: "terminal",
17471
17439
  flow: "no-patches",
@@ -17497,8 +17465,7 @@ var NoPatchesFlow = class {
17497
17465
  }
17498
17466
  const commitOpts = options ? {
17499
17467
  cliVersion: options.cliVersion ?? "unknown",
17500
- generatorVersions: options.generatorVersions ?? {},
17501
- baseBranchHead: options.baseBranchHead
17468
+ generatorVersions: options.generatorVersions ?? {}
17502
17469
  } : void 0;
17503
17470
  await this.service.committer.commitGeneration("Update SDK", commitOpts);
17504
17471
  await this.service.cleanupStaleConflictMarkers();
@@ -17508,7 +17475,6 @@ var NoPatchesFlow = class {
17508
17475
  flow: "no-patches",
17509
17476
  previousGenerationSha,
17510
17477
  currentGenerationSha: genRecord.commit_sha,
17511
- baseBranchHead: options?.baseBranchHead ?? null,
17512
17478
  _prepared: {
17513
17479
  kind: "active",
17514
17480
  flow: "no-patches",
@@ -17598,7 +17564,6 @@ var NormalRegenerationFlow = class {
17598
17564
  flow: "normal-regeneration",
17599
17565
  previousGenerationSha,
17600
17566
  currentGenerationSha: headSha,
17601
- baseBranchHead: options.baseBranchHead ?? null,
17602
17567
  _prepared: {
17603
17568
  kind: "terminal",
17604
17569
  flow: "normal-regeneration",
@@ -17616,14 +17581,16 @@ var NormalRegenerationFlow = class {
17616
17581
  existingPatches = this.service.lockManager.getPatches();
17617
17582
  const seenHashes = /* @__PURE__ */ new Map();
17618
17583
  for (const p of existingPatches) {
17619
- const priorPatchId = seenHashes.get(p.content_hash);
17620
- if (priorPatchId !== void 0) {
17621
- this.service.lockManager.removePatch(p.id);
17622
- this.service.warnings.push(
17623
- `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.`
17624
- );
17625
- } else {
17626
- seenHashes.set(p.content_hash, p.id);
17584
+ if (!p.original_commit || p.original_commit === "") {
17585
+ const priorPatchId = seenHashes.get(p.content_hash);
17586
+ if (priorPatchId !== void 0) {
17587
+ this.service.lockManager.removePatch(p.id);
17588
+ this.service.warnings.push(
17589
+ `Consolidated patch ${p.id} into prior patch ${priorPatchId}: byte-identical patch_content. Lockfile collapsed to avoid duplicate-apply corruption on next regen.`
17590
+ );
17591
+ } else {
17592
+ seenHashes.set(p.content_hash, p.id);
17593
+ }
17627
17594
  }
17628
17595
  }
17629
17596
  existingPatches = this.service.lockManager.getPatches();
@@ -17652,8 +17619,7 @@ var NormalRegenerationFlow = class {
17652
17619
  const allPatches = [...existingPatches, ...newPatches];
17653
17620
  const commitOpts = options ? {
17654
17621
  cliVersion: options.cliVersion ?? "unknown",
17655
- generatorVersions: options.generatorVersions ?? {},
17656
- baseBranchHead: options.baseBranchHead
17622
+ generatorVersions: options.generatorVersions ?? {}
17657
17623
  } : void 0;
17658
17624
  await this.service.committer.commitGeneration("Update SDK", commitOpts);
17659
17625
  await this.service.cleanupStaleConflictMarkers();
@@ -17663,7 +17629,6 @@ var NormalRegenerationFlow = class {
17663
17629
  flow: "normal-regeneration",
17664
17630
  previousGenerationSha,
17665
17631
  currentGenerationSha: genRecord.commit_sha,
17666
- baseBranchHead: options?.baseBranchHead ?? null,
17667
17632
  _prepared: {
17668
17633
  kind: "active",
17669
17634
  flow: "normal-regeneration",
@@ -17856,68 +17821,6 @@ var ReplayService = class {
17856
17821
  const prep = await this.prepareReplay(options);
17857
17822
  return this.applyPreparedReplay(prep, options);
17858
17823
  }
17859
- /**
17860
- * Sync the lockfile after a divergent PR was squash-merged.
17861
- * Call this BEFORE runReplay() when the CLI detects a merged divergent PR.
17862
- *
17863
- * After updating the generation record, re-detects customer patches via
17864
- * tree diff between the generation tag (pure generation tree) and HEAD
17865
- * (which includes customer customizations after squash merge). This ensures
17866
- * patches survive the squash merge → regenerate cycle even when the lockfile
17867
- * was restored from the base branch during conflict PR creation.
17868
- */
17869
- async syncFromDivergentMerge(generationCommitSha, options) {
17870
- const treeHash = await this.git.getTreeHash(generationCommitSha);
17871
- const timestamp = (await this.git.exec(["log", "-1", "--format=%aI", generationCommitSha])).trim();
17872
- const record = {
17873
- commit_sha: generationCommitSha,
17874
- tree_hash: treeHash,
17875
- timestamp,
17876
- cli_version: options?.cliVersion ?? "unknown",
17877
- generator_versions: options?.generatorVersions ?? {},
17878
- base_branch_head: options?.baseBranchHead
17879
- };
17880
- let resolvedPatches;
17881
- if (!this.lockManager.exists()) {
17882
- this.lockManager.initializeInMemory(record);
17883
- } else {
17884
- this.lockManager.read();
17885
- const unresolvedPatches = [
17886
- ...this.lockManager.getUnresolvedPatches(),
17887
- ...this.lockManager.getResolvingPatches()
17888
- ];
17889
- resolvedPatches = this.lockManager.getPatches().filter((p) => p.status == null);
17890
- this.lockManager.addGeneration(record);
17891
- this.lockManager.clearPatches();
17892
- for (const patch of unresolvedPatches) {
17893
- this.lockManager.addPatch(patch);
17894
- }
17895
- }
17896
- try {
17897
- const { patches: redetectedPatches } = await this.detector.detectNewPatches();
17898
- if (redetectedPatches.length > 0) {
17899
- const redetectedFiles = new Set(redetectedPatches.flatMap((p) => p.files));
17900
- const currentPatches = this.lockManager.getPatches();
17901
- for (const patch of currentPatches) {
17902
- if (patch.status != null && patch.files.some((f) => redetectedFiles.has(f))) {
17903
- this.lockManager.removePatch(patch.id);
17904
- }
17905
- }
17906
- for (const patch of redetectedPatches) {
17907
- this.lockManager.addPatch(patch);
17908
- }
17909
- }
17910
- } catch (error) {
17911
- for (const patch of resolvedPatches ?? []) {
17912
- this.lockManager.addPatch(patch);
17913
- }
17914
- this.detector.warnings.push(
17915
- `Patch re-detection failed after divergent merge sync. ${(resolvedPatches ?? []).length} previously resolved patch(es) preserved. Error: ${error instanceof Error ? error.message : String(error)}`
17916
- );
17917
- }
17918
- this.lockManager.save();
17919
- this.detector.warnings.push(...this.lockManager.warnings);
17920
- }
17921
17824
  determineFlow() {
17922
17825
  try {
17923
17826
  const lock = this.lockManager.read();