@fern-api/replay 0.15.2 → 0.16.1

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
  }
@@ -520,6 +533,7 @@ function parseRevertedMessage(subject) {
520
533
  // src/LockfileManager.ts
521
534
  import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from "fs";
522
535
  import { join, dirname } from "path";
536
+ import { minimatch } from "minimatch";
523
537
  import { stringify, parse } from "yaml";
524
538
  var LOCKFILE_HEADER = "# DO NOT EDIT MANUALLY - Managed by Fern Replay\n";
525
539
  var LockfileNotFoundError = class extends Error {
@@ -531,10 +545,23 @@ var LockfileNotFoundError = class extends Error {
531
545
  var LockfileManager = class {
532
546
  outputDir;
533
547
  lock = null;
548
+ fernignorePatterns = [];
534
549
  warnings = [];
535
550
  constructor(outputDir) {
536
551
  this.outputDir = outputDir;
537
552
  }
553
+ /**
554
+ * Inform the lockfile of the customer's `.fernignore` patterns so that
555
+ * `save()` can suppress its "Patch X removed from lockfile" warning when
556
+ * every file in the stripped patch is already `.fernignore`-protected.
557
+ * The strip itself still happens (credentials never persist); only the
558
+ * customer-visible warning is suppressed in that case, because the
559
+ * protected files survive the generator wipe regardless of whether the
560
+ * lockfile tracks them, so there's nothing for the customer to action.
561
+ */
562
+ setFernignorePatterns(patterns) {
563
+ this.fernignorePatterns = patterns;
564
+ }
538
565
  get lockfilePath() {
539
566
  return join(this.outputDir, ".fern", "replay.lock");
540
567
  }
@@ -603,9 +630,14 @@ var LockfileManager = class {
603
630
  if (sensitiveFiles.length > 0) {
604
631
  reasons.push(`sensitive files: ${sensitiveFiles.join(", ")}`);
605
632
  }
606
- this.warnings.push(
607
- `Patch ${patch.id} removed from lockfile: ${reasons.join("; ")}. This prevents credential exposure in committed lockfiles.`
633
+ const allProtected = patch.files.length > 0 && patch.files.every(
634
+ (f) => this.fernignorePatterns.some((p) => f === p || minimatch(f, p))
608
635
  );
636
+ if (!allProtected) {
637
+ this.warnings.push(
638
+ `Patch ${patch.id} removed from lockfile: ${reasons.join("; ")}. This prevents credential exposure in committed lockfiles.`
639
+ );
640
+ }
609
641
  } else {
610
642
  cleanPatches.push(patch);
611
643
  }
@@ -829,8 +861,48 @@ var ReplayDetector = class {
829
861
  this.lockManager = lockManager;
830
862
  this.sdkOutputDir = sdkOutputDir;
831
863
  }
864
+ /**
865
+ * Derive the previous-generation SHA from git history instead of trusting
866
+ * `lock.current_generation`.
867
+ *
868
+ * Walks `git log HEAD --first-parent` and returns the SHA of the most recent
869
+ * commit that `isGenerationBoundary` classifies as a customization-baseline
870
+ * boundary, or null if no such commit is reachable from HEAD on the mainline.
871
+ *
872
+ * `isGenerationBoundary` is intentionally narrower than `isGenerationCommit`:
873
+ * it excludes `[fern-autoversion]` (lightweight bumps that don't reset the
874
+ * baseline) and the GitHub merge-commit subject for fern-bot regen PRs
875
+ * (which sits on first-parent but where the customer fix-up commits live
876
+ * on second-parent — stopping at the merge would hide them).
877
+ *
878
+ * See docs/adr/0001-derived-scan-boundary.md.
879
+ */
880
+ async findPreviousGenerationFromHistory() {
881
+ let log;
882
+ try {
883
+ log = await this.git.exec([
884
+ "log",
885
+ "--first-parent",
886
+ "--format=%H%x00%an%x00%ae%x00%s",
887
+ "HEAD"
888
+ ]);
889
+ } catch {
890
+ return null;
891
+ }
892
+ for (const commit of this.parseGitLog(log)) {
893
+ if (isGenerationBoundary(commit)) {
894
+ return commit.sha;
895
+ }
896
+ }
897
+ return null;
898
+ }
832
899
  async detectNewPatches() {
833
900
  const lock = this.lockManager.read();
901
+ const derivedSha = await this.findPreviousGenerationFromHistory();
902
+ if (derivedSha !== null) {
903
+ const derivedRecord = await this.synthesizeGenerationRecord(derivedSha);
904
+ return this.detectPatchesInRange(derivedSha, derivedRecord, lock);
905
+ }
834
906
  const lastGen = this.getLastGeneration(lock);
835
907
  if (!lastGen) {
836
908
  return { patches: [], revertedPatchIds: [] };
@@ -838,7 +910,7 @@ var ReplayDetector = class {
838
910
  const exists = await this.git.commitExists(lastGen.commit_sha);
839
911
  if (!exists) {
840
912
  this.warnings.push(
841
- `Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to alternate detection.`
913
+ `Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to tree-diff detection (likely a shallow clone).`
842
914
  );
843
915
  return this.detectPatchesViaTreeDiff(
844
916
  lastGen,
@@ -846,44 +918,8 @@ var ReplayDetector = class {
846
918
  true
847
919
  );
848
920
  }
849
- const isAncestor = await this.git.isAncestor(lastGen.commit_sha, "HEAD");
850
- if (!isAncestor) {
851
- return this.detectPatchesViaMergeBase(lastGen, lock);
852
- }
853
921
  return this.detectPatchesInRange(lastGen.commit_sha, lastGen, lock);
854
922
  }
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
923
  /**
888
924
  * Walk `${rangeStart}..HEAD`, classify each commit, emit one
889
925
  * `StoredPatch` per customer commit. Body shared by the linear path
@@ -1319,6 +1355,26 @@ var ReplayDetector = class {
1319
1355
  getLastGeneration(lock) {
1320
1356
  return lock.generations.find((g) => g.commit_sha === lock.current_generation);
1321
1357
  }
1358
+ /**
1359
+ * Build a transient `GenerationRecord` from a SHA derived by walking git
1360
+ * history. Only `commit_sha` (and `tree_hash` when needed downstream) is
1361
+ * load-bearing here; the rest are filled with defensible placeholders.
1362
+ * This record is consumed by `detectPatchesInRange` and is never persisted.
1363
+ */
1364
+ async synthesizeGenerationRecord(sha) {
1365
+ let tree_hash = "";
1366
+ try {
1367
+ tree_hash = await this.git.getTreeHash(sha);
1368
+ } catch {
1369
+ }
1370
+ return {
1371
+ commit_sha: sha,
1372
+ tree_hash,
1373
+ timestamp: (/* @__PURE__ */ new Date(0)).toISOString(),
1374
+ cli_version: "",
1375
+ generator_versions: {}
1376
+ };
1377
+ }
1322
1378
  };
1323
1379
 
1324
1380
  // src/ThreeWayMerge.ts
@@ -1464,7 +1520,7 @@ function applyBothPatches(baseLines, oursPatches, theirsPatches) {
1464
1520
  import { mkdir as mkdir2, mkdtemp, readFile as readFile2, rm, unlink, writeFile as writeFile2 } from "fs/promises";
1465
1521
  import { tmpdir } from "os";
1466
1522
  import { dirname as dirname3, join as join3 } from "path";
1467
- import { minimatch } from "minimatch";
1523
+ import { minimatch as minimatch2 } from "minimatch";
1468
1524
 
1469
1525
  // src/accumulator/MergeAccumulator.ts
1470
1526
  var MergeAccumulator = class {
@@ -2180,13 +2236,13 @@ var ReplayApplicator = class {
2180
2236
  isExcluded(patch) {
2181
2237
  const config = this.lockManager.getCustomizationsConfig();
2182
2238
  if (!config.exclude) return false;
2183
- return patch.files.some((file) => config.exclude.some((pattern) => minimatch(file, pattern)));
2239
+ return patch.files.some((file) => config.exclude.some((pattern) => minimatch2(file, pattern)));
2184
2240
  }
2185
2241
  async resolveFilePath(filePath, baseTreeHash, currentTreeHash) {
2186
2242
  const config = this.lockManager.getCustomizationsConfig();
2187
2243
  if (config.moves) {
2188
2244
  for (const move of config.moves) {
2189
- if (minimatch(filePath, move.from) || filePath === move.from) {
2245
+ if (minimatch2(filePath, move.from) || filePath === move.from) {
2190
2246
  if (filePath === move.from) {
2191
2247
  return move.to;
2192
2248
  }
@@ -2458,8 +2514,7 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
2458
2514
  tree_hash: treeHash,
2459
2515
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2460
2516
  cli_version: options?.cliVersion ?? "unknown",
2461
- generator_versions: options?.generatorVersions ?? {},
2462
- base_branch_head: options?.baseBranchHead
2517
+ generator_versions: options?.generatorVersions ?? {}
2463
2518
  };
2464
2519
  }
2465
2520
  async stageAll() {
@@ -2478,7 +2533,7 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
2478
2533
  import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
2479
2534
  import { readFile as readFileAsync } from "fs/promises";
2480
2535
  import { join as join6 } from "path";
2481
- import { minimatch as minimatch3 } from "minimatch";
2536
+ import { minimatch as minimatch4 } from "minimatch";
2482
2537
  init_GitClient();
2483
2538
 
2484
2539
  // src/PatchRegionDiff.ts
@@ -2589,12 +2644,23 @@ async function computePerPatchDiff(patch, getCurrentGenContent, getWorkingTreeCo
2589
2644
  unlocatableFiles.push(file);
2590
2645
  continue;
2591
2646
  }
2592
- const locInCurrentGen = locInCurrentGenRaw.map(
2593
- (l) => resolveSpan(l, currentGenLines, "old")
2594
- );
2595
- const locInWorkingTree = locInWorkingTreeRaw.map(
2596
- (l) => resolveSpan(l, workingTreeLines, "new")
2597
- );
2647
+ const locInCurrentGen = [];
2648
+ const locInWorkingTree = [];
2649
+ let spanResolutionFailed = false;
2650
+ for (let i = 0; i < locInCurrentGenRaw.length; i++) {
2651
+ const cg = resolveSpan(locInCurrentGenRaw[i], currentGenLines, "old");
2652
+ const wt = resolveSpan(locInWorkingTreeRaw[i], workingTreeLines, "new");
2653
+ if (cg === null || wt === null) {
2654
+ spanResolutionFailed = true;
2655
+ break;
2656
+ }
2657
+ locInCurrentGen.push(cg);
2658
+ locInWorkingTree.push(wt);
2659
+ }
2660
+ if (spanResolutionFailed) {
2661
+ unlocatableFiles.push(file);
2662
+ continue;
2663
+ }
2598
2664
  const overlay = overlayRegions(
2599
2665
  currentGenLines,
2600
2666
  locInCurrentGen,
@@ -2656,11 +2722,11 @@ function resolveSpan(loc, oursLines, prefer) {
2656
2722
  if (prefer === "old") {
2657
2723
  if (oldFits) return { ...loc, oursSpan: hunk.oldCount };
2658
2724
  if (newFits) return { ...loc, oursSpan: hunk.newCount };
2659
- } else {
2660
- if (newFits) return { ...loc, oursSpan: hunk.newCount };
2661
- if (oldFits) return { ...loc, oursSpan: hunk.oldCount };
2725
+ return loc;
2662
2726
  }
2663
- return loc;
2727
+ if (newFits) return { ...loc, oursSpan: hunk.newCount };
2728
+ if (oldFits) return { ...loc, oursSpan: hunk.oldCount };
2729
+ return null;
2664
2730
  }
2665
2731
  function sequenceMatches(needle, haystack, offset) {
2666
2732
  if (offset + needle.length > haystack.length) return false;
@@ -2774,7 +2840,7 @@ function synthesizeDeleteFileDiff(file, content) {
2774
2840
  }
2775
2841
 
2776
2842
  // src/classifier/FileOwnership.ts
2777
- import { minimatch as minimatch2 } from "minimatch";
2843
+ import { minimatch as minimatch3 } from "minimatch";
2778
2844
  var FileOwnership = class {
2779
2845
  constructor(git) {
2780
2846
  this.git = git;
@@ -2798,7 +2864,7 @@ var FileOwnership = class {
2798
2864
  const { file, originalFileName, patch, currentGenSha, fernignorePatterns, warnedGens, warnings } = ctx;
2799
2865
  if (patch.user_owned) return true;
2800
2866
  if (file === ".fernignore") return true;
2801
- if (fernignorePatterns.some((p) => file === p || minimatch2(file, p))) return true;
2867
+ if (fernignorePatterns.some((p) => file === p || minimatch3(file, p))) return true;
2802
2868
  if (fileIsCreationInPatchContent(patch.patch_content, originalFileName ?? file)) {
2803
2869
  return true;
2804
2870
  }
@@ -2846,7 +2912,6 @@ var FirstGenerationFlow = class {
2846
2912
  flow: "first-generation",
2847
2913
  previousGenerationSha: null,
2848
2914
  currentGenerationSha: headSha,
2849
- baseBranchHead: options.baseBranchHead ?? null,
2850
2915
  _prepared: {
2851
2916
  kind: "terminal",
2852
2917
  flow: "first-generation",
@@ -2856,8 +2921,7 @@ var FirstGenerationFlow = class {
2856
2921
  }
2857
2922
  const commitOpts = options ? {
2858
2923
  cliVersion: options.cliVersion ?? "unknown",
2859
- generatorVersions: options.generatorVersions ?? {},
2860
- baseBranchHead: options.baseBranchHead
2924
+ generatorVersions: options.generatorVersions ?? {}
2861
2925
  } : void 0;
2862
2926
  await this.service.committer.commitGeneration("Initial SDK generation", commitOpts);
2863
2927
  const genRecord = await this.service.committer.createGenerationRecord(commitOpts);
@@ -2866,7 +2930,6 @@ var FirstGenerationFlow = class {
2866
2930
  flow: "first-generation",
2867
2931
  previousGenerationSha: null,
2868
2932
  currentGenerationSha: genRecord.commit_sha,
2869
- baseBranchHead: options?.baseBranchHead ?? null,
2870
2933
  _prepared: {
2871
2934
  kind: "active",
2872
2935
  flow: "first-generation",
@@ -2880,6 +2943,7 @@ var FirstGenerationFlow = class {
2880
2943
  };
2881
2944
  }
2882
2945
  async apply(_prep) {
2946
+ this.service.lockManager.setFernignorePatterns(this.service.readFernignorePatterns());
2883
2947
  this.service.lockManager.save();
2884
2948
  return firstGenerationReport();
2885
2949
  }
@@ -2903,8 +2967,7 @@ var SkipApplicationFlow = class {
2903
2967
  async prepare(options) {
2904
2968
  const commitOpts = options ? {
2905
2969
  cliVersion: options.cliVersion ?? "unknown",
2906
- generatorVersions: options.generatorVersions ?? {},
2907
- baseBranchHead: options.baseBranchHead
2970
+ generatorVersions: options.generatorVersions ?? {}
2908
2971
  } : void 0;
2909
2972
  await this.service.committer.commitGeneration("Update SDK (replay skipped)", commitOpts);
2910
2973
  await this.service.cleanupStaleConflictMarkers();
@@ -2926,7 +2989,6 @@ var SkipApplicationFlow = class {
2926
2989
  flow: "skip-application",
2927
2990
  previousGenerationSha,
2928
2991
  currentGenerationSha: genRecord.commit_sha,
2929
- baseBranchHead: options?.baseBranchHead ?? null,
2930
2992
  _prepared: {
2931
2993
  kind: "active",
2932
2994
  flow: "skip-application",
@@ -2940,6 +3002,7 @@ var SkipApplicationFlow = class {
2940
3002
  };
2941
3003
  }
2942
3004
  async apply(_prep, options) {
3005
+ this.service.lockManager.setFernignorePatterns(this.service.readFernignorePatterns());
2943
3006
  this.service.lockManager.save();
2944
3007
  const credentialWarnings = [...this.service.lockManager.warnings];
2945
3008
  if (!options?.stageOnly) {
@@ -2987,7 +3050,6 @@ var NoPatchesFlow = class {
2987
3050
  flow: "no-patches",
2988
3051
  previousGenerationSha,
2989
3052
  currentGenerationSha: headSha,
2990
- baseBranchHead: options.baseBranchHead ?? null,
2991
3053
  _prepared: {
2992
3054
  kind: "terminal",
2993
3055
  flow: "no-patches",
@@ -3019,8 +3081,7 @@ var NoPatchesFlow = class {
3019
3081
  }
3020
3082
  const commitOpts = options ? {
3021
3083
  cliVersion: options.cliVersion ?? "unknown",
3022
- generatorVersions: options.generatorVersions ?? {},
3023
- baseBranchHead: options.baseBranchHead
3084
+ generatorVersions: options.generatorVersions ?? {}
3024
3085
  } : void 0;
3025
3086
  await this.service.committer.commitGeneration("Update SDK", commitOpts);
3026
3087
  await this.service.cleanupStaleConflictMarkers();
@@ -3030,7 +3091,6 @@ var NoPatchesFlow = class {
3030
3091
  flow: "no-patches",
3031
3092
  previousGenerationSha,
3032
3093
  currentGenerationSha: genRecord.commit_sha,
3033
- baseBranchHead: options?.baseBranchHead ?? null,
3034
3094
  _prepared: {
3035
3095
  kind: "active",
3036
3096
  flow: "no-patches",
@@ -3066,6 +3126,7 @@ var NoPatchesFlow = class {
3066
3126
  this.service.lockManager.addPatch(patch);
3067
3127
  }
3068
3128
  }
3129
+ this.service.lockManager.setFernignorePatterns(this.service.readFernignorePatterns());
3069
3130
  this.service.lockManager.save();
3070
3131
  this.service.warnings.push(...this.service.lockManager.warnings);
3071
3132
  if (newPatches.length > 0) {
@@ -3120,7 +3181,6 @@ var NormalRegenerationFlow = class {
3120
3181
  flow: "normal-regeneration",
3121
3182
  previousGenerationSha,
3122
3183
  currentGenerationSha: headSha,
3123
- baseBranchHead: options.baseBranchHead ?? null,
3124
3184
  _prepared: {
3125
3185
  kind: "terminal",
3126
3186
  flow: "normal-regeneration",
@@ -3138,14 +3198,16 @@ var NormalRegenerationFlow = class {
3138
3198
  existingPatches = this.service.lockManager.getPatches();
3139
3199
  const seenHashes = /* @__PURE__ */ new Map();
3140
3200
  for (const p of existingPatches) {
3141
- const priorPatchId = seenHashes.get(p.content_hash);
3142
- if (priorPatchId !== void 0) {
3143
- this.service.lockManager.removePatch(p.id);
3144
- this.service.warnings.push(
3145
- `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.`
3146
- );
3147
- } else {
3148
- seenHashes.set(p.content_hash, p.id);
3201
+ if (!p.original_commit || p.original_commit === "") {
3202
+ const priorPatchId = seenHashes.get(p.content_hash);
3203
+ if (priorPatchId !== void 0) {
3204
+ this.service.lockManager.removePatch(p.id);
3205
+ this.service.warnings.push(
3206
+ `Consolidated patch ${p.id} into prior patch ${priorPatchId}: byte-identical patch_content. Lockfile collapsed to avoid duplicate-apply corruption on next regen.`
3207
+ );
3208
+ } else {
3209
+ seenHashes.set(p.content_hash, p.id);
3210
+ }
3149
3211
  }
3150
3212
  }
3151
3213
  existingPatches = this.service.lockManager.getPatches();
@@ -3174,8 +3236,7 @@ var NormalRegenerationFlow = class {
3174
3236
  const allPatches = [...existingPatches, ...newPatches];
3175
3237
  const commitOpts = options ? {
3176
3238
  cliVersion: options.cliVersion ?? "unknown",
3177
- generatorVersions: options.generatorVersions ?? {},
3178
- baseBranchHead: options.baseBranchHead
3239
+ generatorVersions: options.generatorVersions ?? {}
3179
3240
  } : void 0;
3180
3241
  await this.service.committer.commitGeneration("Update SDK", commitOpts);
3181
3242
  await this.service.cleanupStaleConflictMarkers();
@@ -3185,7 +3246,6 @@ var NormalRegenerationFlow = class {
3185
3246
  flow: "normal-regeneration",
3186
3247
  previousGenerationSha,
3187
3248
  currentGenerationSha: genRecord.commit_sha,
3188
- baseBranchHead: options?.baseBranchHead ?? null,
3189
3249
  _prepared: {
3190
3250
  kind: "active",
3191
3251
  flow: "normal-regeneration",
@@ -3229,6 +3289,7 @@ var NormalRegenerationFlow = class {
3229
3289
  }
3230
3290
  }
3231
3291
  }
3292
+ this.service.lockManager.setFernignorePatterns(this.service.readFernignorePatterns());
3232
3293
  this.service.lockManager.save();
3233
3294
  this.service.warnings.push(...this.service.lockManager.warnings);
3234
3295
  if (options?.stageOnly) {
@@ -3378,68 +3439,6 @@ var ReplayService = class {
3378
3439
  const prep = await this.prepareReplay(options);
3379
3440
  return this.applyPreparedReplay(prep, options);
3380
3441
  }
3381
- /**
3382
- * Sync the lockfile after a divergent PR was squash-merged.
3383
- * Call this BEFORE runReplay() when the CLI detects a merged divergent PR.
3384
- *
3385
- * After updating the generation record, re-detects customer patches via
3386
- * tree diff between the generation tag (pure generation tree) and HEAD
3387
- * (which includes customer customizations after squash merge). This ensures
3388
- * patches survive the squash merge → regenerate cycle even when the lockfile
3389
- * was restored from the base branch during conflict PR creation.
3390
- */
3391
- async syncFromDivergentMerge(generationCommitSha, options) {
3392
- const treeHash = await this.git.getTreeHash(generationCommitSha);
3393
- const timestamp = (await this.git.exec(["log", "-1", "--format=%aI", generationCommitSha])).trim();
3394
- const record = {
3395
- commit_sha: generationCommitSha,
3396
- tree_hash: treeHash,
3397
- timestamp,
3398
- cli_version: options?.cliVersion ?? "unknown",
3399
- generator_versions: options?.generatorVersions ?? {},
3400
- base_branch_head: options?.baseBranchHead
3401
- };
3402
- let resolvedPatches;
3403
- if (!this.lockManager.exists()) {
3404
- this.lockManager.initializeInMemory(record);
3405
- } else {
3406
- this.lockManager.read();
3407
- const unresolvedPatches = [
3408
- ...this.lockManager.getUnresolvedPatches(),
3409
- ...this.lockManager.getResolvingPatches()
3410
- ];
3411
- resolvedPatches = this.lockManager.getPatches().filter((p) => p.status == null);
3412
- this.lockManager.addGeneration(record);
3413
- this.lockManager.clearPatches();
3414
- for (const patch of unresolvedPatches) {
3415
- this.lockManager.addPatch(patch);
3416
- }
3417
- }
3418
- try {
3419
- const { patches: redetectedPatches } = await this.detector.detectNewPatches();
3420
- if (redetectedPatches.length > 0) {
3421
- const redetectedFiles = new Set(redetectedPatches.flatMap((p) => p.files));
3422
- const currentPatches = this.lockManager.getPatches();
3423
- for (const patch of currentPatches) {
3424
- if (patch.status != null && patch.files.some((f) => redetectedFiles.has(f))) {
3425
- this.lockManager.removePatch(patch.id);
3426
- }
3427
- }
3428
- for (const patch of redetectedPatches) {
3429
- this.lockManager.addPatch(patch);
3430
- }
3431
- }
3432
- } catch (error) {
3433
- for (const patch of resolvedPatches ?? []) {
3434
- this.lockManager.addPatch(patch);
3435
- }
3436
- this.detector.warnings.push(
3437
- `Patch re-detection failed after divergent merge sync. ${(resolvedPatches ?? []).length} previously resolved patch(es) preserved. Error: ${error instanceof Error ? error.message : String(error)}`
3438
- );
3439
- }
3440
- this.lockManager.save();
3441
- this.detector.warnings.push(...this.lockManager.warnings);
3442
- }
3443
3442
  determineFlow() {
3444
3443
  try {
3445
3444
  const lock = this.lockManager.read();
@@ -3524,7 +3523,7 @@ var ReplayService = class {
3524
3523
  )
3525
3524
  );
3526
3525
  const hasProtectedFiles = patch.files.some(
3527
- (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch3(file, p))
3526
+ (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch4(file, p))
3528
3527
  );
3529
3528
  const allUserOwned = isUserOwnedPerFile.every(Boolean);
3530
3529
  if (allUserOwned && patch.files.length > 0) {
@@ -3964,7 +3963,7 @@ var ReplayService = class {
3964
3963
  );
3965
3964
  if (snapHasStaleMarkers) continue;
3966
3965
  const isProtectedSnap = patch.files.some(
3967
- (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch3(file, p))
3966
+ (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch4(file, p))
3968
3967
  );
3969
3968
  if (isProtectedSnap) continue;
3970
3969
  const snapHash = this.detector.computeContentHash(snapshotDiff);
@@ -3984,6 +3983,11 @@ var ReplayService = class {
3984
3983
  (files) => this.git.exec(["diff", currentGen, "HEAD", "--", ...files]).catch(() => null)
3985
3984
  );
3986
3985
  if (ppResult === null) continue;
3986
+ if (ppResult.hadFallback) {
3987
+ this.warnings.push(
3988
+ `Patch ${patch.id}: couldn't fully isolate ${ppResult.fallbackFiles.join(", ")} \u2014 customization preserved, attribution may have merged with another patch.`
3989
+ );
3990
+ }
3987
3991
  const diff = ppResult.diff;
3988
3992
  if (!diff.trim()) {
3989
3993
  if (await allPatchFilesUserOwned(patch)) continue;
@@ -3999,7 +4003,7 @@ var ReplayService = class {
3999
4003
  continue;
4000
4004
  }
4001
4005
  const isProtected = patch.files.some(
4002
- (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch3(file, p))
4006
+ (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch4(file, p))
4003
4007
  );
4004
4008
  if (isProtected) {
4005
4009
  continue;
@@ -4071,6 +4075,11 @@ var ReplayService = class {
4071
4075
  (files) => this.git.exec(["diff", currentGen, "HEAD", "--", ...files]).catch(() => null)
4072
4076
  );
4073
4077
  if (ppResult === null) continue;
4078
+ if (ppResult.hadFallback) {
4079
+ this.warnings.push(
4080
+ `Patch ${patch.id}: couldn't fully isolate ${ppResult.fallbackFiles.join(", ")} \u2014 customization preserved, attribution may have merged with another patch.`
4081
+ );
4082
+ }
4074
4083
  const diff = ppResult.diff;
4075
4084
  if (!diff.trim()) {
4076
4085
  if (await allPatchFilesUserOwned(patch)) {
@@ -4158,13 +4167,14 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
4158
4167
  if (files.length === 0) return;
4159
4168
  const fernignorePatterns = this.readFernignorePatterns();
4160
4169
  for (const file of files) {
4161
- if (fernignorePatterns.some((pattern) => minimatch3(file, pattern))) continue;
4170
+ if (fernignorePatterns.some((pattern) => minimatch4(file, pattern))) continue;
4162
4171
  try {
4163
4172
  await this.git.exec(["checkout", "HEAD", "--", file]);
4164
4173
  } catch {
4165
4174
  }
4166
4175
  }
4167
4176
  }
4177
+ /** @internal Used by Flow implementations in `src/flows/`. */
4168
4178
  readFernignorePatterns() {
4169
4179
  const fernignorePath = join6(this.outputDir, ".fernignore");
4170
4180
  if (!existsSync2(fernignorePath)) return [];
@@ -4247,7 +4257,7 @@ function computeCommitBuckets(results, absorbedPatchIds, patchesPassedToCommit)
4247
4257
  import { createHash as createHash2 } from "crypto";
4248
4258
  import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, statSync, writeFileSync as writeFileSync3 } from "fs";
4249
4259
  import { dirname as dirname5, join as join7 } from "path";
4250
- import { minimatch as minimatch4 } from "minimatch";
4260
+ import { minimatch as minimatch5 } from "minimatch";
4251
4261
  import { parse as parse2, stringify as stringify2 } from "yaml";
4252
4262
  var FernignoreMigrator = class {
4253
4263
  git;
@@ -4279,7 +4289,7 @@ var FernignoreMigrator = class {
4279
4289
  const commitsOnly = [];
4280
4290
  const syntheticPatches = [];
4281
4291
  for (const pattern of patterns) {
4282
- const matchingPatch = patches.find((p) => p.files.some((f) => minimatch4(f, pattern) || f === pattern));
4292
+ const matchingPatch = patches.find((p) => p.files.some((f) => minimatch5(f, pattern) || f === pattern));
4283
4293
  if (matchingPatch) {
4284
4294
  trackedByBoth.push({
4285
4295
  file: pattern,
@@ -4352,7 +4362,7 @@ var FernignoreMigrator = class {
4352
4362
  fernignoreOnly.push(...remainingFernignoreOnly);
4353
4363
  }
4354
4364
  for (const patch of patches) {
4355
- const hasUnprotectedFiles = patch.files.some((f) => !patterns.some((p) => minimatch4(f, p) || f === p));
4365
+ const hasUnprotectedFiles = patch.files.some((f) => !patterns.some((p) => minimatch5(f, p) || f === p));
4356
4366
  if (hasUnprotectedFiles) {
4357
4367
  commitsOnly.push(patch);
4358
4368
  }
@@ -4364,7 +4374,7 @@ var FernignoreMigrator = class {
4364
4374
  const results = [];
4365
4375
  for (const pattern of patterns) {
4366
4376
  const matching = allFiles.filter(
4367
- (f) => minimatch4(f, pattern) || f === pattern || f.startsWith(pattern + "/")
4377
+ (f) => minimatch5(f, pattern) || f === pattern || f.startsWith(pattern + "/")
4368
4378
  );
4369
4379
  results.push({ pattern, files: matching.length > 0 ? matching : [pattern] });
4370
4380
  }
@@ -4677,7 +4687,7 @@ function computeContentHash(patchContent) {
4677
4687
  }
4678
4688
 
4679
4689
  // src/commands/forget.ts
4680
- import { minimatch as minimatch5 } from "minimatch";
4690
+ import { minimatch as minimatch6 } from "minimatch";
4681
4691
  function parseDiffStat(patchContent) {
4682
4692
  let additions = 0;
4683
4693
  let deletions = 0;
@@ -4707,7 +4717,7 @@ function toMatchedPatch(patch) {
4707
4717
  }
4708
4718
  function matchesPatch(patch, pattern) {
4709
4719
  const fileMatch = patch.files.some(
4710
- (file) => file === pattern || minimatch5(file, pattern)
4720
+ (file) => file === pattern || minimatch6(file, pattern)
4711
4721
  );
4712
4722
  if (fileMatch) return true;
4713
4723
  return patch.original_message.toLowerCase().includes(pattern.toLowerCase());