@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.cjs CHANGED
@@ -554,9 +554,22 @@ function isGenerationCommit(commit) {
554
554
  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.
555
555
  // The default PR title is "SDK Generation" (from GithubStep's commitMessage default).
556
556
  // GitHub appends "(#N)" for the PR number, e.g. "SDK Generation (#70)".
557
- commit.message.startsWith("SDK Generation");
557
+ commit.message.startsWith("SDK Generation") || // Merge commit (GitHub's "Create a merge commit" option) of a fern-bot
558
+ // regen PR. First-parent walking lands on this commit, not on the
559
+ // generation commit on the side branch, so the classifier must treat
560
+ // it as a generation boundary. GitHub's default subject is
561
+ // "Merge pull request #N from <owner>/<branch>"; fern-bot branches are
562
+ // named "<owner>/fern-bot/<...>".
563
+ /^Merge pull request #\d+ from [^/]+\/fern-bot\//.test(commit.message);
558
564
  return isBotAuthor || hasGenerationMarker;
559
565
  }
566
+ function isGenerationBoundary(commit) {
567
+ const isFernSupport = FERN_SUPPORT_NAMES.includes(commit.authorName);
568
+ const isBotAuthor = !isFernSupport && (commit.authorLogin === FERN_BOT_LOGIN || commit.authorEmail === FERN_BOT_EMAIL || commit.authorName === FERN_BOT_NAME);
569
+ const isAutoversion = commit.message.startsWith("[fern-autoversion]");
570
+ 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");
571
+ return isBotAuthor && !isAutoversion || hasBoundaryMarker;
572
+ }
560
573
  function isReplayCommit(commit) {
561
574
  return commit.message.startsWith("[fern-replay]");
562
575
  }
@@ -575,6 +588,7 @@ function parseRevertedMessage(subject) {
575
588
  // src/LockfileManager.ts
576
589
  var import_node_fs = require("fs");
577
590
  var import_node_path = require("path");
591
+ var import_minimatch = require("minimatch");
578
592
  var import_yaml = require("yaml");
579
593
  var LOCKFILE_HEADER = "# DO NOT EDIT MANUALLY - Managed by Fern Replay\n";
580
594
  var LockfileNotFoundError = class extends Error {
@@ -586,10 +600,23 @@ var LockfileNotFoundError = class extends Error {
586
600
  var LockfileManager = class {
587
601
  outputDir;
588
602
  lock = null;
603
+ fernignorePatterns = [];
589
604
  warnings = [];
590
605
  constructor(outputDir) {
591
606
  this.outputDir = outputDir;
592
607
  }
608
+ /**
609
+ * Inform the lockfile of the customer's `.fernignore` patterns so that
610
+ * `save()` can suppress its "Patch X removed from lockfile" warning when
611
+ * every file in the stripped patch is already `.fernignore`-protected.
612
+ * The strip itself still happens (credentials never persist); only the
613
+ * customer-visible warning is suppressed in that case, because the
614
+ * protected files survive the generator wipe regardless of whether the
615
+ * lockfile tracks them, so there's nothing for the customer to action.
616
+ */
617
+ setFernignorePatterns(patterns) {
618
+ this.fernignorePatterns = patterns;
619
+ }
593
620
  get lockfilePath() {
594
621
  return (0, import_node_path.join)(this.outputDir, ".fern", "replay.lock");
595
622
  }
@@ -658,9 +685,14 @@ var LockfileManager = class {
658
685
  if (sensitiveFiles.length > 0) {
659
686
  reasons.push(`sensitive files: ${sensitiveFiles.join(", ")}`);
660
687
  }
661
- this.warnings.push(
662
- `Patch ${patch.id} removed from lockfile: ${reasons.join("; ")}. This prevents credential exposure in committed lockfiles.`
688
+ const allProtected = patch.files.length > 0 && patch.files.every(
689
+ (f) => this.fernignorePatterns.some((p) => f === p || (0, import_minimatch.minimatch)(f, p))
663
690
  );
691
+ if (!allProtected) {
692
+ this.warnings.push(
693
+ `Patch ${patch.id} removed from lockfile: ${reasons.join("; ")}. This prevents credential exposure in committed lockfiles.`
694
+ );
695
+ }
664
696
  } else {
665
697
  cleanPatches.push(patch);
666
698
  }
@@ -884,8 +916,48 @@ var ReplayDetector = class {
884
916
  this.lockManager = lockManager;
885
917
  this.sdkOutputDir = sdkOutputDir;
886
918
  }
919
+ /**
920
+ * Derive the previous-generation SHA from git history instead of trusting
921
+ * `lock.current_generation`.
922
+ *
923
+ * Walks `git log HEAD --first-parent` and returns the SHA of the most recent
924
+ * commit that `isGenerationBoundary` classifies as a customization-baseline
925
+ * boundary, or null if no such commit is reachable from HEAD on the mainline.
926
+ *
927
+ * `isGenerationBoundary` is intentionally narrower than `isGenerationCommit`:
928
+ * it excludes `[fern-autoversion]` (lightweight bumps that don't reset the
929
+ * baseline) and the GitHub merge-commit subject for fern-bot regen PRs
930
+ * (which sits on first-parent but where the customer fix-up commits live
931
+ * on second-parent — stopping at the merge would hide them).
932
+ *
933
+ * See docs/adr/0001-derived-scan-boundary.md.
934
+ */
935
+ async findPreviousGenerationFromHistory() {
936
+ let log;
937
+ try {
938
+ log = await this.git.exec([
939
+ "log",
940
+ "--first-parent",
941
+ "--format=%H%x00%an%x00%ae%x00%s",
942
+ "HEAD"
943
+ ]);
944
+ } catch {
945
+ return null;
946
+ }
947
+ for (const commit of this.parseGitLog(log)) {
948
+ if (isGenerationBoundary(commit)) {
949
+ return commit.sha;
950
+ }
951
+ }
952
+ return null;
953
+ }
887
954
  async detectNewPatches() {
888
955
  const lock = this.lockManager.read();
956
+ const derivedSha = await this.findPreviousGenerationFromHistory();
957
+ if (derivedSha !== null) {
958
+ const derivedRecord = await this.synthesizeGenerationRecord(derivedSha);
959
+ return this.detectPatchesInRange(derivedSha, derivedRecord, lock);
960
+ }
889
961
  const lastGen = this.getLastGeneration(lock);
890
962
  if (!lastGen) {
891
963
  return { patches: [], revertedPatchIds: [] };
@@ -893,7 +965,7 @@ var ReplayDetector = class {
893
965
  const exists = await this.git.commitExists(lastGen.commit_sha);
894
966
  if (!exists) {
895
967
  this.warnings.push(
896
- `Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to alternate detection.`
968
+ `Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to tree-diff detection (likely a shallow clone).`
897
969
  );
898
970
  return this.detectPatchesViaTreeDiff(
899
971
  lastGen,
@@ -901,44 +973,8 @@ var ReplayDetector = class {
901
973
  true
902
974
  );
903
975
  }
904
- const isAncestor = await this.git.isAncestor(lastGen.commit_sha, "HEAD");
905
- if (!isAncestor) {
906
- return this.detectPatchesViaMergeBase(lastGen, lock);
907
- }
908
976
  return this.detectPatchesInRange(lastGen.commit_sha, lastGen, lock);
909
977
  }
910
- /**
911
- * Non-linear-history detection via `merge-base(prevGen, HEAD)`.
912
- *
913
- * After a squash-merge of a Fern-bot PR, `lastGen.commit_sha` (the previous
914
- * `[fern-generated]`) is orphaned but still in git's object database. The
915
- * merge-base is the commit on main from which the bot's branch was created —
916
- * a reachable ancestor of HEAD. Walking that range and classifying each
917
- * commit via `isGenerationCommit` mirrors the linear path and eliminates
918
- * the bug class where pipeline-driven changes (autoversion, replay,
919
- * generation) get bundled as customer customizations.
920
- *
921
- * Falls through to the legacy composite path when no common ancestor exists
922
- * (truly disjoint history — orphan branches with no shared root).
923
- */
924
- async detectPatchesViaMergeBase(lastGen, lock) {
925
- let mergeBase = "";
926
- try {
927
- mergeBase = (await this.git.exec(["merge-base", lastGen.commit_sha, "HEAD"])).trim();
928
- } catch {
929
- }
930
- if (!mergeBase) {
931
- this.warnings.push(
932
- `No common ancestor between previous generation ${lastGen.commit_sha.slice(0, 7)} and HEAD. Falling back to composite tree-diff detection.`
933
- );
934
- return this.detectPatchesViaTreeDiff(
935
- lastGen,
936
- /* commitKnownMissing */
937
- false
938
- );
939
- }
940
- return this.detectPatchesInRange(mergeBase, lastGen, lock);
941
- }
942
978
  /**
943
979
  * Walk `${rangeStart}..HEAD`, classify each commit, emit one
944
980
  * `StoredPatch` per customer commit. Body shared by the linear path
@@ -1374,6 +1410,26 @@ var ReplayDetector = class {
1374
1410
  getLastGeneration(lock) {
1375
1411
  return lock.generations.find((g) => g.commit_sha === lock.current_generation);
1376
1412
  }
1413
+ /**
1414
+ * Build a transient `GenerationRecord` from a SHA derived by walking git
1415
+ * history. Only `commit_sha` (and `tree_hash` when needed downstream) is
1416
+ * load-bearing here; the rest are filled with defensible placeholders.
1417
+ * This record is consumed by `detectPatchesInRange` and is never persisted.
1418
+ */
1419
+ async synthesizeGenerationRecord(sha) {
1420
+ let tree_hash = "";
1421
+ try {
1422
+ tree_hash = await this.git.getTreeHash(sha);
1423
+ } catch {
1424
+ }
1425
+ return {
1426
+ commit_sha: sha,
1427
+ tree_hash,
1428
+ timestamp: (/* @__PURE__ */ new Date(0)).toISOString(),
1429
+ cli_version: "",
1430
+ generator_versions: {}
1431
+ };
1432
+ }
1377
1433
  };
1378
1434
 
1379
1435
  // src/ThreeWayMerge.ts
@@ -1519,7 +1575,7 @@ function applyBothPatches(baseLines, oursPatches, theirsPatches) {
1519
1575
  var import_promises3 = require("fs/promises");
1520
1576
  var import_node_os = require("os");
1521
1577
  var import_node_path5 = require("path");
1522
- var import_minimatch = require("minimatch");
1578
+ var import_minimatch2 = require("minimatch");
1523
1579
 
1524
1580
  // src/accumulator/MergeAccumulator.ts
1525
1581
  var MergeAccumulator = class {
@@ -2235,13 +2291,13 @@ var ReplayApplicator = class {
2235
2291
  isExcluded(patch) {
2236
2292
  const config = this.lockManager.getCustomizationsConfig();
2237
2293
  if (!config.exclude) return false;
2238
- return patch.files.some((file) => config.exclude.some((pattern) => (0, import_minimatch.minimatch)(file, pattern)));
2294
+ return patch.files.some((file) => config.exclude.some((pattern) => (0, import_minimatch2.minimatch)(file, pattern)));
2239
2295
  }
2240
2296
  async resolveFilePath(filePath, baseTreeHash, currentTreeHash) {
2241
2297
  const config = this.lockManager.getCustomizationsConfig();
2242
2298
  if (config.moves) {
2243
2299
  for (const move of config.moves) {
2244
- if ((0, import_minimatch.minimatch)(filePath, move.from) || filePath === move.from) {
2300
+ if ((0, import_minimatch2.minimatch)(filePath, move.from) || filePath === move.from) {
2245
2301
  if (filePath === move.from) {
2246
2302
  return move.to;
2247
2303
  }
@@ -2513,8 +2569,7 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
2513
2569
  tree_hash: treeHash,
2514
2570
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2515
2571
  cli_version: options?.cliVersion ?? "unknown",
2516
- generator_versions: options?.generatorVersions ?? {},
2517
- base_branch_head: options?.baseBranchHead
2572
+ generator_versions: options?.generatorVersions ?? {}
2518
2573
  };
2519
2574
  }
2520
2575
  async stageAll() {
@@ -2533,7 +2588,7 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
2533
2588
  var import_node_fs2 = require("fs");
2534
2589
  var import_promises6 = require("fs/promises");
2535
2590
  var import_node_path8 = require("path");
2536
- var import_minimatch3 = require("minimatch");
2591
+ var import_minimatch4 = require("minimatch");
2537
2592
  init_GitClient();
2538
2593
 
2539
2594
  // src/PatchRegionDiff.ts
@@ -2644,12 +2699,23 @@ async function computePerPatchDiff(patch, getCurrentGenContent, getWorkingTreeCo
2644
2699
  unlocatableFiles.push(file);
2645
2700
  continue;
2646
2701
  }
2647
- const locInCurrentGen = locInCurrentGenRaw.map(
2648
- (l) => resolveSpan(l, currentGenLines, "old")
2649
- );
2650
- const locInWorkingTree = locInWorkingTreeRaw.map(
2651
- (l) => resolveSpan(l, workingTreeLines, "new")
2652
- );
2702
+ const locInCurrentGen = [];
2703
+ const locInWorkingTree = [];
2704
+ let spanResolutionFailed = false;
2705
+ for (let i = 0; i < locInCurrentGenRaw.length; i++) {
2706
+ const cg = resolveSpan(locInCurrentGenRaw[i], currentGenLines, "old");
2707
+ const wt = resolveSpan(locInWorkingTreeRaw[i], workingTreeLines, "new");
2708
+ if (cg === null || wt === null) {
2709
+ spanResolutionFailed = true;
2710
+ break;
2711
+ }
2712
+ locInCurrentGen.push(cg);
2713
+ locInWorkingTree.push(wt);
2714
+ }
2715
+ if (spanResolutionFailed) {
2716
+ unlocatableFiles.push(file);
2717
+ continue;
2718
+ }
2653
2719
  const overlay = overlayRegions(
2654
2720
  currentGenLines,
2655
2721
  locInCurrentGen,
@@ -2711,11 +2777,11 @@ function resolveSpan(loc, oursLines, prefer) {
2711
2777
  if (prefer === "old") {
2712
2778
  if (oldFits) return { ...loc, oursSpan: hunk.oldCount };
2713
2779
  if (newFits) return { ...loc, oursSpan: hunk.newCount };
2714
- } else {
2715
- if (newFits) return { ...loc, oursSpan: hunk.newCount };
2716
- if (oldFits) return { ...loc, oursSpan: hunk.oldCount };
2780
+ return loc;
2717
2781
  }
2718
- return loc;
2782
+ if (newFits) return { ...loc, oursSpan: hunk.newCount };
2783
+ if (oldFits) return { ...loc, oursSpan: hunk.oldCount };
2784
+ return null;
2719
2785
  }
2720
2786
  function sequenceMatches(needle, haystack, offset) {
2721
2787
  if (offset + needle.length > haystack.length) return false;
@@ -2829,7 +2895,7 @@ function synthesizeDeleteFileDiff(file, content) {
2829
2895
  }
2830
2896
 
2831
2897
  // src/classifier/FileOwnership.ts
2832
- var import_minimatch2 = require("minimatch");
2898
+ var import_minimatch3 = require("minimatch");
2833
2899
  var FileOwnership = class {
2834
2900
  constructor(git) {
2835
2901
  this.git = git;
@@ -2853,7 +2919,7 @@ var FileOwnership = class {
2853
2919
  const { file, originalFileName, patch, currentGenSha, fernignorePatterns, warnedGens, warnings } = ctx;
2854
2920
  if (patch.user_owned) return true;
2855
2921
  if (file === ".fernignore") return true;
2856
- if (fernignorePatterns.some((p) => file === p || (0, import_minimatch2.minimatch)(file, p))) return true;
2922
+ if (fernignorePatterns.some((p) => file === p || (0, import_minimatch3.minimatch)(file, p))) return true;
2857
2923
  if (fileIsCreationInPatchContent(patch.patch_content, originalFileName ?? file)) {
2858
2924
  return true;
2859
2925
  }
@@ -2901,7 +2967,6 @@ var FirstGenerationFlow = class {
2901
2967
  flow: "first-generation",
2902
2968
  previousGenerationSha: null,
2903
2969
  currentGenerationSha: headSha,
2904
- baseBranchHead: options.baseBranchHead ?? null,
2905
2970
  _prepared: {
2906
2971
  kind: "terminal",
2907
2972
  flow: "first-generation",
@@ -2911,8 +2976,7 @@ var FirstGenerationFlow = class {
2911
2976
  }
2912
2977
  const commitOpts = options ? {
2913
2978
  cliVersion: options.cliVersion ?? "unknown",
2914
- generatorVersions: options.generatorVersions ?? {},
2915
- baseBranchHead: options.baseBranchHead
2979
+ generatorVersions: options.generatorVersions ?? {}
2916
2980
  } : void 0;
2917
2981
  await this.service.committer.commitGeneration("Initial SDK generation", commitOpts);
2918
2982
  const genRecord = await this.service.committer.createGenerationRecord(commitOpts);
@@ -2921,7 +2985,6 @@ var FirstGenerationFlow = class {
2921
2985
  flow: "first-generation",
2922
2986
  previousGenerationSha: null,
2923
2987
  currentGenerationSha: genRecord.commit_sha,
2924
- baseBranchHead: options?.baseBranchHead ?? null,
2925
2988
  _prepared: {
2926
2989
  kind: "active",
2927
2990
  flow: "first-generation",
@@ -2935,6 +2998,7 @@ var FirstGenerationFlow = class {
2935
2998
  };
2936
2999
  }
2937
3000
  async apply(_prep) {
3001
+ this.service.lockManager.setFernignorePatterns(this.service.readFernignorePatterns());
2938
3002
  this.service.lockManager.save();
2939
3003
  return firstGenerationReport();
2940
3004
  }
@@ -2958,8 +3022,7 @@ var SkipApplicationFlow = class {
2958
3022
  async prepare(options) {
2959
3023
  const commitOpts = options ? {
2960
3024
  cliVersion: options.cliVersion ?? "unknown",
2961
- generatorVersions: options.generatorVersions ?? {},
2962
- baseBranchHead: options.baseBranchHead
3025
+ generatorVersions: options.generatorVersions ?? {}
2963
3026
  } : void 0;
2964
3027
  await this.service.committer.commitGeneration("Update SDK (replay skipped)", commitOpts);
2965
3028
  await this.service.cleanupStaleConflictMarkers();
@@ -2981,7 +3044,6 @@ var SkipApplicationFlow = class {
2981
3044
  flow: "skip-application",
2982
3045
  previousGenerationSha,
2983
3046
  currentGenerationSha: genRecord.commit_sha,
2984
- baseBranchHead: options?.baseBranchHead ?? null,
2985
3047
  _prepared: {
2986
3048
  kind: "active",
2987
3049
  flow: "skip-application",
@@ -2995,6 +3057,7 @@ var SkipApplicationFlow = class {
2995
3057
  };
2996
3058
  }
2997
3059
  async apply(_prep, options) {
3060
+ this.service.lockManager.setFernignorePatterns(this.service.readFernignorePatterns());
2998
3061
  this.service.lockManager.save();
2999
3062
  const credentialWarnings = [...this.service.lockManager.warnings];
3000
3063
  if (!options?.stageOnly) {
@@ -3042,7 +3105,6 @@ var NoPatchesFlow = class {
3042
3105
  flow: "no-patches",
3043
3106
  previousGenerationSha,
3044
3107
  currentGenerationSha: headSha,
3045
- baseBranchHead: options.baseBranchHead ?? null,
3046
3108
  _prepared: {
3047
3109
  kind: "terminal",
3048
3110
  flow: "no-patches",
@@ -3074,8 +3136,7 @@ var NoPatchesFlow = class {
3074
3136
  }
3075
3137
  const commitOpts = options ? {
3076
3138
  cliVersion: options.cliVersion ?? "unknown",
3077
- generatorVersions: options.generatorVersions ?? {},
3078
- baseBranchHead: options.baseBranchHead
3139
+ generatorVersions: options.generatorVersions ?? {}
3079
3140
  } : void 0;
3080
3141
  await this.service.committer.commitGeneration("Update SDK", commitOpts);
3081
3142
  await this.service.cleanupStaleConflictMarkers();
@@ -3085,7 +3146,6 @@ var NoPatchesFlow = class {
3085
3146
  flow: "no-patches",
3086
3147
  previousGenerationSha,
3087
3148
  currentGenerationSha: genRecord.commit_sha,
3088
- baseBranchHead: options?.baseBranchHead ?? null,
3089
3149
  _prepared: {
3090
3150
  kind: "active",
3091
3151
  flow: "no-patches",
@@ -3121,6 +3181,7 @@ var NoPatchesFlow = class {
3121
3181
  this.service.lockManager.addPatch(patch);
3122
3182
  }
3123
3183
  }
3184
+ this.service.lockManager.setFernignorePatterns(this.service.readFernignorePatterns());
3124
3185
  this.service.lockManager.save();
3125
3186
  this.service.warnings.push(...this.service.lockManager.warnings);
3126
3187
  if (newPatches.length > 0) {
@@ -3175,7 +3236,6 @@ var NormalRegenerationFlow = class {
3175
3236
  flow: "normal-regeneration",
3176
3237
  previousGenerationSha,
3177
3238
  currentGenerationSha: headSha,
3178
- baseBranchHead: options.baseBranchHead ?? null,
3179
3239
  _prepared: {
3180
3240
  kind: "terminal",
3181
3241
  flow: "normal-regeneration",
@@ -3193,14 +3253,16 @@ var NormalRegenerationFlow = class {
3193
3253
  existingPatches = this.service.lockManager.getPatches();
3194
3254
  const seenHashes = /* @__PURE__ */ new Map();
3195
3255
  for (const p of existingPatches) {
3196
- const priorPatchId = seenHashes.get(p.content_hash);
3197
- if (priorPatchId !== void 0) {
3198
- this.service.lockManager.removePatch(p.id);
3199
- this.service.warnings.push(
3200
- `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.`
3201
- );
3202
- } else {
3203
- seenHashes.set(p.content_hash, p.id);
3256
+ if (!p.original_commit || p.original_commit === "") {
3257
+ const priorPatchId = seenHashes.get(p.content_hash);
3258
+ if (priorPatchId !== void 0) {
3259
+ this.service.lockManager.removePatch(p.id);
3260
+ this.service.warnings.push(
3261
+ `Consolidated patch ${p.id} into prior patch ${priorPatchId}: byte-identical patch_content. Lockfile collapsed to avoid duplicate-apply corruption on next regen.`
3262
+ );
3263
+ } else {
3264
+ seenHashes.set(p.content_hash, p.id);
3265
+ }
3204
3266
  }
3205
3267
  }
3206
3268
  existingPatches = this.service.lockManager.getPatches();
@@ -3229,8 +3291,7 @@ var NormalRegenerationFlow = class {
3229
3291
  const allPatches = [...existingPatches, ...newPatches];
3230
3292
  const commitOpts = options ? {
3231
3293
  cliVersion: options.cliVersion ?? "unknown",
3232
- generatorVersions: options.generatorVersions ?? {},
3233
- baseBranchHead: options.baseBranchHead
3294
+ generatorVersions: options.generatorVersions ?? {}
3234
3295
  } : void 0;
3235
3296
  await this.service.committer.commitGeneration("Update SDK", commitOpts);
3236
3297
  await this.service.cleanupStaleConflictMarkers();
@@ -3240,7 +3301,6 @@ var NormalRegenerationFlow = class {
3240
3301
  flow: "normal-regeneration",
3241
3302
  previousGenerationSha,
3242
3303
  currentGenerationSha: genRecord.commit_sha,
3243
- baseBranchHead: options?.baseBranchHead ?? null,
3244
3304
  _prepared: {
3245
3305
  kind: "active",
3246
3306
  flow: "normal-regeneration",
@@ -3284,6 +3344,7 @@ var NormalRegenerationFlow = class {
3284
3344
  }
3285
3345
  }
3286
3346
  }
3347
+ this.service.lockManager.setFernignorePatterns(this.service.readFernignorePatterns());
3287
3348
  this.service.lockManager.save();
3288
3349
  this.service.warnings.push(...this.service.lockManager.warnings);
3289
3350
  if (options?.stageOnly) {
@@ -3433,68 +3494,6 @@ var ReplayService = class {
3433
3494
  const prep = await this.prepareReplay(options);
3434
3495
  return this.applyPreparedReplay(prep, options);
3435
3496
  }
3436
- /**
3437
- * Sync the lockfile after a divergent PR was squash-merged.
3438
- * Call this BEFORE runReplay() when the CLI detects a merged divergent PR.
3439
- *
3440
- * After updating the generation record, re-detects customer patches via
3441
- * tree diff between the generation tag (pure generation tree) and HEAD
3442
- * (which includes customer customizations after squash merge). This ensures
3443
- * patches survive the squash merge → regenerate cycle even when the lockfile
3444
- * was restored from the base branch during conflict PR creation.
3445
- */
3446
- async syncFromDivergentMerge(generationCommitSha, options) {
3447
- const treeHash = await this.git.getTreeHash(generationCommitSha);
3448
- const timestamp = (await this.git.exec(["log", "-1", "--format=%aI", generationCommitSha])).trim();
3449
- const record = {
3450
- commit_sha: generationCommitSha,
3451
- tree_hash: treeHash,
3452
- timestamp,
3453
- cli_version: options?.cliVersion ?? "unknown",
3454
- generator_versions: options?.generatorVersions ?? {},
3455
- base_branch_head: options?.baseBranchHead
3456
- };
3457
- let resolvedPatches;
3458
- if (!this.lockManager.exists()) {
3459
- this.lockManager.initializeInMemory(record);
3460
- } else {
3461
- this.lockManager.read();
3462
- const unresolvedPatches = [
3463
- ...this.lockManager.getUnresolvedPatches(),
3464
- ...this.lockManager.getResolvingPatches()
3465
- ];
3466
- resolvedPatches = this.lockManager.getPatches().filter((p) => p.status == null);
3467
- this.lockManager.addGeneration(record);
3468
- this.lockManager.clearPatches();
3469
- for (const patch of unresolvedPatches) {
3470
- this.lockManager.addPatch(patch);
3471
- }
3472
- }
3473
- try {
3474
- const { patches: redetectedPatches } = await this.detector.detectNewPatches();
3475
- if (redetectedPatches.length > 0) {
3476
- const redetectedFiles = new Set(redetectedPatches.flatMap((p) => p.files));
3477
- const currentPatches = this.lockManager.getPatches();
3478
- for (const patch of currentPatches) {
3479
- if (patch.status != null && patch.files.some((f) => redetectedFiles.has(f))) {
3480
- this.lockManager.removePatch(patch.id);
3481
- }
3482
- }
3483
- for (const patch of redetectedPatches) {
3484
- this.lockManager.addPatch(patch);
3485
- }
3486
- }
3487
- } catch (error) {
3488
- for (const patch of resolvedPatches ?? []) {
3489
- this.lockManager.addPatch(patch);
3490
- }
3491
- this.detector.warnings.push(
3492
- `Patch re-detection failed after divergent merge sync. ${(resolvedPatches ?? []).length} previously resolved patch(es) preserved. Error: ${error instanceof Error ? error.message : String(error)}`
3493
- );
3494
- }
3495
- this.lockManager.save();
3496
- this.detector.warnings.push(...this.lockManager.warnings);
3497
- }
3498
3497
  determineFlow() {
3499
3498
  try {
3500
3499
  const lock = this.lockManager.read();
@@ -3579,7 +3578,7 @@ var ReplayService = class {
3579
3578
  )
3580
3579
  );
3581
3580
  const hasProtectedFiles = patch.files.some(
3582
- (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || (0, import_minimatch3.minimatch)(file, p))
3581
+ (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || (0, import_minimatch4.minimatch)(file, p))
3583
3582
  );
3584
3583
  const allUserOwned = isUserOwnedPerFile.every(Boolean);
3585
3584
  if (allUserOwned && patch.files.length > 0) {
@@ -4019,7 +4018,7 @@ var ReplayService = class {
4019
4018
  );
4020
4019
  if (snapHasStaleMarkers) continue;
4021
4020
  const isProtectedSnap = patch.files.some(
4022
- (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || (0, import_minimatch3.minimatch)(file, p))
4021
+ (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || (0, import_minimatch4.minimatch)(file, p))
4023
4022
  );
4024
4023
  if (isProtectedSnap) continue;
4025
4024
  const snapHash = this.detector.computeContentHash(snapshotDiff);
@@ -4039,6 +4038,11 @@ var ReplayService = class {
4039
4038
  (files) => this.git.exec(["diff", currentGen, "HEAD", "--", ...files]).catch(() => null)
4040
4039
  );
4041
4040
  if (ppResult === null) continue;
4041
+ if (ppResult.hadFallback) {
4042
+ this.warnings.push(
4043
+ `Patch ${patch.id}: couldn't fully isolate ${ppResult.fallbackFiles.join(", ")} \u2014 customization preserved, attribution may have merged with another patch.`
4044
+ );
4045
+ }
4042
4046
  const diff = ppResult.diff;
4043
4047
  if (!diff.trim()) {
4044
4048
  if (await allPatchFilesUserOwned(patch)) continue;
@@ -4054,7 +4058,7 @@ var ReplayService = class {
4054
4058
  continue;
4055
4059
  }
4056
4060
  const isProtected = patch.files.some(
4057
- (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || (0, import_minimatch3.minimatch)(file, p))
4061
+ (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || (0, import_minimatch4.minimatch)(file, p))
4058
4062
  );
4059
4063
  if (isProtected) {
4060
4064
  continue;
@@ -4126,6 +4130,11 @@ var ReplayService = class {
4126
4130
  (files) => this.git.exec(["diff", currentGen, "HEAD", "--", ...files]).catch(() => null)
4127
4131
  );
4128
4132
  if (ppResult === null) continue;
4133
+ if (ppResult.hadFallback) {
4134
+ this.warnings.push(
4135
+ `Patch ${patch.id}: couldn't fully isolate ${ppResult.fallbackFiles.join(", ")} \u2014 customization preserved, attribution may have merged with another patch.`
4136
+ );
4137
+ }
4129
4138
  const diff = ppResult.diff;
4130
4139
  if (!diff.trim()) {
4131
4140
  if (await allPatchFilesUserOwned(patch)) {
@@ -4213,13 +4222,14 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
4213
4222
  if (files.length === 0) return;
4214
4223
  const fernignorePatterns = this.readFernignorePatterns();
4215
4224
  for (const file of files) {
4216
- if (fernignorePatterns.some((pattern) => (0, import_minimatch3.minimatch)(file, pattern))) continue;
4225
+ if (fernignorePatterns.some((pattern) => (0, import_minimatch4.minimatch)(file, pattern))) continue;
4217
4226
  try {
4218
4227
  await this.git.exec(["checkout", "HEAD", "--", file]);
4219
4228
  } catch {
4220
4229
  }
4221
4230
  }
4222
4231
  }
4232
+ /** @internal Used by Flow implementations in `src/flows/`. */
4223
4233
  readFernignorePatterns() {
4224
4234
  const fernignorePath = (0, import_node_path8.join)(this.outputDir, ".fernignore");
4225
4235
  if (!(0, import_node_fs2.existsSync)(fernignorePath)) return [];
@@ -4302,7 +4312,7 @@ function computeCommitBuckets(results, absorbedPatchIds, patchesPassedToCommit)
4302
4312
  var import_node_crypto2 = require("crypto");
4303
4313
  var import_node_fs3 = require("fs");
4304
4314
  var import_node_path9 = require("path");
4305
- var import_minimatch4 = require("minimatch");
4315
+ var import_minimatch5 = require("minimatch");
4306
4316
  var import_yaml2 = require("yaml");
4307
4317
  var FernignoreMigrator = class {
4308
4318
  git;
@@ -4334,7 +4344,7 @@ var FernignoreMigrator = class {
4334
4344
  const commitsOnly = [];
4335
4345
  const syntheticPatches = [];
4336
4346
  for (const pattern of patterns) {
4337
- const matchingPatch = patches.find((p) => p.files.some((f) => (0, import_minimatch4.minimatch)(f, pattern) || f === pattern));
4347
+ const matchingPatch = patches.find((p) => p.files.some((f) => (0, import_minimatch5.minimatch)(f, pattern) || f === pattern));
4338
4348
  if (matchingPatch) {
4339
4349
  trackedByBoth.push({
4340
4350
  file: pattern,
@@ -4407,7 +4417,7 @@ var FernignoreMigrator = class {
4407
4417
  fernignoreOnly.push(...remainingFernignoreOnly);
4408
4418
  }
4409
4419
  for (const patch of patches) {
4410
- const hasUnprotectedFiles = patch.files.some((f) => !patterns.some((p) => (0, import_minimatch4.minimatch)(f, p) || f === p));
4420
+ const hasUnprotectedFiles = patch.files.some((f) => !patterns.some((p) => (0, import_minimatch5.minimatch)(f, p) || f === p));
4411
4421
  if (hasUnprotectedFiles) {
4412
4422
  commitsOnly.push(patch);
4413
4423
  }
@@ -4419,7 +4429,7 @@ var FernignoreMigrator = class {
4419
4429
  const results = [];
4420
4430
  for (const pattern of patterns) {
4421
4431
  const matching = allFiles.filter(
4422
- (f) => (0, import_minimatch4.minimatch)(f, pattern) || f === pattern || f.startsWith(pattern + "/")
4432
+ (f) => (0, import_minimatch5.minimatch)(f, pattern) || f === pattern || f.startsWith(pattern + "/")
4423
4433
  );
4424
4434
  results.push({ pattern, files: matching.length > 0 ? matching : [pattern] });
4425
4435
  }
@@ -4732,7 +4742,7 @@ function computeContentHash(patchContent) {
4732
4742
  }
4733
4743
 
4734
4744
  // src/commands/forget.ts
4735
- var import_minimatch5 = require("minimatch");
4745
+ var import_minimatch6 = require("minimatch");
4736
4746
  function parseDiffStat(patchContent) {
4737
4747
  let additions = 0;
4738
4748
  let deletions = 0;
@@ -4762,7 +4772,7 @@ function toMatchedPatch(patch) {
4762
4772
  }
4763
4773
  function matchesPatch(patch, pattern) {
4764
4774
  const fileMatch = patch.files.some(
4765
- (file) => file === pattern || (0, import_minimatch5.minimatch)(file, pattern)
4775
+ (file) => file === pattern || (0, import_minimatch6.minimatch)(file, pattern)
4766
4776
  );
4767
4777
  if (fileMatch) return true;
4768
4778
  return patch.original_message.toLowerCase().includes(pattern.toLowerCase());