@fern-api/replay 0.18.0 → 0.19.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
@@ -430,15 +430,15 @@ __export(PatchApplyTheirs_exports, {
430
430
  async function applyPatchToContent(base, patchContent, filePath) {
431
431
  const fileDiff = extractFileDiff(patchContent, filePath);
432
432
  if (!fileDiff) return null;
433
- const tempDir = await (0, import_promises5.mkdtemp)((0, import_node_path7.join)((0, import_node_os3.tmpdir)(), "replay-migrate-"));
433
+ const tempDir = await (0, import_promises5.mkdtemp)((0, import_node_path8.join)((0, import_node_os3.tmpdir)(), "replay-migrate-"));
434
434
  const tempGit = new GitClient(tempDir);
435
435
  try {
436
436
  await tempGit.exec(["init"]);
437
437
  await tempGit.exec(["config", "user.email", "migrate@fern.com"]);
438
438
  await tempGit.exec(["config", "user.name", "Fern Replay Migration"]);
439
439
  await tempGit.exec(["config", "commit.gpgSign", "false"]);
440
- const tempFilePath = (0, import_node_path7.join)(tempDir, filePath);
441
- await (0, import_promises5.mkdir)((0, import_node_path7.dirname)(tempFilePath), { recursive: true });
440
+ const tempFilePath = (0, import_node_path8.join)(tempDir, filePath);
441
+ await (0, import_promises5.mkdir)((0, import_node_path8.dirname)(tempFilePath), { recursive: true });
442
442
  await (0, import_promises5.writeFile)(tempFilePath, base);
443
443
  await tempGit.exec(["add", filePath]);
444
444
  await tempGit.exec(["commit", "-m", `base for ${filePath}`, "--allow-empty"]);
@@ -469,13 +469,13 @@ function extractFileDiff(patchContent, filePath) {
469
469
  }
470
470
  return out.length > 0 ? out.join("\n") + "\n" : null;
471
471
  }
472
- var import_promises5, import_node_os3, import_node_path7;
472
+ var import_promises5, import_node_os3, import_node_path8;
473
473
  var init_PatchApplyTheirs = __esm({
474
474
  "src/PatchApplyTheirs.ts"() {
475
475
  "use strict";
476
476
  import_promises5 = require("fs/promises");
477
477
  import_node_os3 = require("os");
478
- import_node_path7 = require("path");
478
+ import_node_path8 = require("path");
479
479
  init_GitClient();
480
480
  }
481
481
  });
@@ -750,6 +750,22 @@ var LockfileManager = class {
750
750
  this.lock.forgotten_hashes.push(hash);
751
751
  }
752
752
  }
753
+ /**
754
+ * Record a dismissed customization: visibility metadata in `dismissals`
755
+ * plus a tombstone in `forgotten_hashes` for every content hash.
756
+ * `forgotten_hashes` stays the only list the detector consults; the
757
+ * metadata exists so `status` can show what was dismissed.
758
+ */
759
+ addDismissal(record) {
760
+ this.ensureLoaded();
761
+ if (!this.lock.dismissals) {
762
+ this.lock.dismissals = [];
763
+ }
764
+ this.lock.dismissals.push(record);
765
+ for (const hash of record.content_hashes) {
766
+ this.addForgottenHash(hash);
767
+ }
768
+ }
753
769
  getUnresolvedPatches() {
754
770
  this.ensureLoaded();
755
771
  return this.lock.patches.filter((p) => p.status === "unresolved");
@@ -917,12 +933,14 @@ async function capturePatchSnapshot(git, files, treeish) {
917
933
  return snapshot;
918
934
  }
919
935
  function filterInfrastructureAndSensitiveFiles(rawFilesOutput, fernignorePatterns) {
920
- const allFiles = rawFilesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
936
+ const rawFiles = rawFilesOutput.trim().split("\n").filter(Boolean);
937
+ const infrastructureFiles = rawFiles.filter((f) => INFRASTRUCTURE_FILES.has(f) || f.startsWith(".fern/"));
938
+ const allFiles = rawFiles.filter((f) => !INFRASTRUCTURE_FILES.has(f) && !f.startsWith(".fern/"));
921
939
  const sensitiveFiles = allFiles.filter((f) => isSensitiveFile(f));
922
940
  const nonSensitive = allFiles.filter((f) => !isSensitiveFile(f));
923
941
  const fernignoredFiles = nonSensitive.filter((f) => matchesFernignorePattern(f, fernignorePatterns));
924
942
  const files = nonSensitive.filter((f) => !matchesFernignorePattern(f, fernignorePatterns));
925
- return { files, sensitiveFiles, fernignoredFiles };
943
+ return { files, sensitiveFiles, fernignoredFiles, infrastructureFiles };
926
944
  }
927
945
  var ReplayDetector = class {
928
946
  git;
@@ -930,6 +948,15 @@ var ReplayDetector = class {
930
948
  sdkOutputDir;
931
949
  fernignorePatterns;
932
950
  warnings = [];
951
+ /**
952
+ * Structured degraded-state channel, mirroring `warnings`. Populated when
953
+ * detection cannot guarantee customizations were carried forward (baseline
954
+ * unreachable in this clone). Cleared per-run by `ReplayService.prepareReplay`
955
+ * alongside `warnings`. Merged into `ReplayReport.degradedReasons` by each
956
+ * flow handler. Degraded is observability only: detection results are
957
+ * unchanged.
958
+ */
959
+ degradedReasons = [];
933
960
  constructor(git, lockManager, sdkOutputDir, fernignorePatterns = []) {
934
961
  this.git = git;
935
962
  this.lockManager = lockManager;
@@ -1000,6 +1027,10 @@ var ReplayDetector = class {
1000
1027
  this.warnings.push(
1001
1028
  `No generation boundary found in git history and the lockfile's recorded generation (${lock.current_generation.slice(0, 7)}) matches no entry in its generation records. Customer customizations may differ from the last known generation and could be lost on the next regeneration. Run \`fern-replay bootstrap --force\` to re-initialize tracking.`
1002
1029
  );
1030
+ this.degradedReasons.push({
1031
+ code: "baseline-unresolvable",
1032
+ message: `The previous generation recorded in the lockfile (${lock.current_generation.slice(0, 7)}) could not be resolved to any reachable baseline during this run. Customizations may not have been carried forward \u2014 review the result before merging.`
1033
+ });
1003
1034
  }
1004
1035
  return { patches: [], revertedPatchIds: [] };
1005
1036
  }
@@ -1008,6 +1039,12 @@ var ReplayDetector = class {
1008
1039
  this.warnings.push(
1009
1040
  `Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to tree-diff detection (likely a shallow clone).`
1010
1041
  );
1042
+ if (lock.patches.length > 0) {
1043
+ this.degradedReasons.push({
1044
+ code: "generation-anchor-unreachable",
1045
+ message: `The previous generation (${lastGen.commit_sha.slice(0, 7)}) was not reachable during this run, and ${lock.patches.length} tracked customization(s) could not be verified against it. Customizations may not have been carried forward \u2014 review the result before merging.`
1046
+ });
1047
+ }
1011
1048
  return this.detectPatchesViaTreeDiff(
1012
1049
  lastGen,
1013
1050
  /* commitKnownMissing */
@@ -1047,54 +1084,27 @@ var ReplayDetector = class {
1047
1084
  if (lock.patches.find((p) => p.original_commit === commit.sha)) {
1048
1085
  continue;
1049
1086
  }
1050
- const parents = await this.git.getCommitParents(commit.sha);
1051
- let filesOutput;
1052
- try {
1053
- filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
1054
- } catch {
1087
+ const materialized = await this.materializeCommitPatch(commit.sha);
1088
+ if (materialized.sensitiveFiles.length > 0) {
1055
1089
  this.warnings.push(
1056
- `Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
1090
+ `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${materialized.sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
1057
1091
  );
1058
- continue;
1059
1092
  }
1060
- const { files, sensitiveFiles, fernignoredFiles } = filterInfrastructureAndSensitiveFiles(
1061
- filesOutput,
1062
- this.fernignorePatterns
1063
- );
1064
- if (sensitiveFiles.length > 0) {
1093
+ if (materialized.fernignoredFiles.length > 0) {
1065
1094
  this.warnings.push(
1066
- `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
1095
+ `.fernignore-protected file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${materialized.fernignoredFiles.join(", ")}. These files are managed by .fernignore, not Replay.`
1067
1096
  );
1068
1097
  }
1069
- if (fernignoredFiles.length > 0) {
1098
+ if (materialized.status === "unreachable") {
1070
1099
  this.warnings.push(
1071
- `.fernignore-protected file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${fernignoredFiles.join(", ")}. These files are managed by .fernignore, not Replay.`
1100
+ `Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
1072
1101
  );
1073
- }
1074
- if (files.length === 0) {
1075
1102
  continue;
1076
1103
  }
1077
- const wasFiltered = sensitiveFiles.length > 0 || fernignoredFiles.length > 0;
1078
- const parentSha = parents[0];
1079
- let patchContent;
1080
- if (wasFiltered && parentSha) {
1081
- try {
1082
- patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
1083
- } catch {
1084
- continue;
1085
- }
1086
- if (!patchContent.trim()) continue;
1087
- } else {
1088
- try {
1089
- patchContent = await this.git.formatPatch(commit.sha);
1090
- } catch {
1091
- this.warnings.push(
1092
- `Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
1093
- );
1094
- continue;
1095
- }
1104
+ if (materialized.status === "empty") {
1105
+ continue;
1096
1106
  }
1097
- const contentHash = this.computeContentHash(patchContent);
1107
+ const { files, patchContent, contentHash } = materialized;
1098
1108
  if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
1099
1109
  continue;
1100
1110
  }
@@ -1171,6 +1181,63 @@ var ReplayDetector = class {
1171
1181
  const filteredPatches = survivingPatches.filter((_, i) => !revertIndicesToRemove.has(i));
1172
1182
  return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
1173
1183
  }
1184
+ /**
1185
+ * Materialize a commit into patch content exactly as detection does.
1186
+ *
1187
+ * Pipeline: `diff-tree --name-only` → filter infrastructure / sensitive /
1188
+ * `.fernignore`d files → scoped `git diff` when files were stripped,
1189
+ * otherwise full `git format-patch` → content hash.
1190
+ *
1191
+ * This is THE definition of a commit's re-emission identity: the content
1192
+ * hash this method returns is what `detectPatchesInRange` will compute if
1193
+ * the commit re-enters the scan range (e.g. after a regen PR is merged
1194
+ * with a merge commit). Callers that tombstone a dismissed patch must use
1195
+ * this hash — the stored `content_hash` drifts every time the patch is
1196
+ * carried forward, so it does not identify the commit on re-detection.
1197
+ *
1198
+ * Never throws; failures are reported through the `status` discriminant.
1199
+ */
1200
+ async materializeCommitPatch(sha) {
1201
+ let parents;
1202
+ let filesOutput;
1203
+ try {
1204
+ parents = await this.git.getCommitParents(sha);
1205
+ filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", sha]);
1206
+ } catch {
1207
+ return { status: "unreachable", sensitiveFiles: [], fernignoredFiles: [] };
1208
+ }
1209
+ const { files, sensitiveFiles, fernignoredFiles, infrastructureFiles } = filterInfrastructureAndSensitiveFiles(filesOutput, this.fernignorePatterns);
1210
+ if (files.length === 0) {
1211
+ return { status: "empty", sensitiveFiles, fernignoredFiles };
1212
+ }
1213
+ const wasFiltered = sensitiveFiles.length > 0 || fernignoredFiles.length > 0 || infrastructureFiles.length > 0;
1214
+ const parentSha = parents[0];
1215
+ let patchContent;
1216
+ if (wasFiltered && parentSha) {
1217
+ try {
1218
+ patchContent = await this.git.exec(["diff", parentSha, sha, "--", ...files]);
1219
+ } catch {
1220
+ return { status: "empty", sensitiveFiles, fernignoredFiles };
1221
+ }
1222
+ if (!patchContent.trim()) {
1223
+ return { status: "empty", sensitiveFiles, fernignoredFiles };
1224
+ }
1225
+ } else {
1226
+ try {
1227
+ patchContent = await this.git.formatPatch(sha);
1228
+ } catch {
1229
+ return { status: "unreachable", sensitiveFiles, fernignoredFiles };
1230
+ }
1231
+ }
1232
+ return {
1233
+ status: "ok",
1234
+ files,
1235
+ sensitiveFiles,
1236
+ fernignoredFiles,
1237
+ patchContent,
1238
+ contentHash: this.computeContentHash(patchContent)
1239
+ };
1240
+ }
1174
1241
  /**
1175
1242
  * Compute content hash for deduplication.
1176
1243
  *
@@ -1275,6 +1342,13 @@ var ReplayDetector = class {
1275
1342
  async detectPatchesViaTreeDiff(lastGen, commitKnownMissing) {
1276
1343
  const diffBase = await this.resolveDiffBase(lastGen, commitKnownMissing);
1277
1344
  if (!diffBase) {
1345
+ this.warnings.push(
1346
+ `Neither the previous generation's commit (${lastGen.commit_sha.slice(0, 7)}) nor its recorded tree was reachable during this run. Falling back to a bounded commit scan, which cannot see customizations older than the clone window. Widen the clone window (e.g. \`git fetch --unshallow\`) and re-run replay to verify customizations were carried forward.`
1347
+ );
1348
+ this.degradedReasons.push({
1349
+ code: "generation-tree-unreachable",
1350
+ message: `The previous generation (${lastGen.commit_sha.slice(0, 7)}) was not reachable during this run \u2014 neither its commit nor its recorded tree. Customizations may not have been carried forward \u2014 review the result before merging, or widen the clone window and re-run replay.`
1351
+ });
1278
1352
  return this.detectPatchesViaCommitScan();
1279
1353
  }
1280
1354
  const lockForGuard = this.lockManager.read();
@@ -1387,10 +1461,7 @@ var ReplayDetector = class {
1387
1461
  continue;
1388
1462
  }
1389
1463
  const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
1390
- const { files, sensitiveFiles, fernignoredFiles } = filterInfrastructureAndSensitiveFiles(
1391
- filesOutput,
1392
- this.fernignorePatterns
1393
- );
1464
+ const { files, sensitiveFiles, fernignoredFiles, infrastructureFiles } = filterInfrastructureAndSensitiveFiles(filesOutput, this.fernignorePatterns);
1394
1465
  if (sensitiveFiles.length > 0) {
1395
1466
  this.warnings.push(
1396
1467
  `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
@@ -1401,7 +1472,8 @@ var ReplayDetector = class {
1401
1472
  `.fernignore-protected file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${fernignoredFiles.join(", ")}. These files are managed by .fernignore, not Replay.`
1402
1473
  );
1403
1474
  }
1404
- if ((sensitiveFiles.length > 0 || fernignoredFiles.length > 0) && files.length > 0) {
1475
+ const wasFiltered = sensitiveFiles.length > 0 || fernignoredFiles.length > 0 || infrastructureFiles.length > 0;
1476
+ if (wasFiltered && files.length > 0) {
1405
1477
  if (parents.length === 0) {
1406
1478
  continue;
1407
1479
  }
@@ -2010,6 +2082,51 @@ async function buildMergePlan(args) {
2010
2082
  // src/applicator/runMergeAndRecord.ts
2011
2083
  var import_promises2 = require("fs/promises");
2012
2084
  var import_node_path4 = require("path");
2085
+
2086
+ // src/autoversion.ts
2087
+ var AUTOVERSION_PLACEHOLDERS = [
2088
+ "v0.0.0-fern-placeholder",
2089
+ // Go -- a superstring of the default; check first
2090
+ "0.0.0-fern-placeholder",
2091
+ // TS/Java/C#/Ruby/PHP/Rust/default
2092
+ "0.0.0.dev0"
2093
+ // Python (PEP 440 -- Poetry rejects hyphens)
2094
+ ];
2095
+ var VERSION_TOKEN = /(?:v)?\d+\.\d+\.\d+[0-9A-Za-z.+-]*/g;
2096
+ var VERSION_MASK = "@@FERN_VERSION@@";
2097
+ function maskVersions(line) {
2098
+ return line.replace(VERSION_TOKEN, VERSION_MASK);
2099
+ }
2100
+ function containsPlaceholder(content) {
2101
+ if (!content) return false;
2102
+ return AUTOVERSION_PLACEHOLDERS.some((p) => content.includes(p));
2103
+ }
2104
+ function remapManagedLines(genBase, target, theirs) {
2105
+ if (!containsPlaceholder(genBase)) return theirs;
2106
+ const managedMasks = /* @__PURE__ */ new Set();
2107
+ for (const line of genBase.split("\n")) {
2108
+ if (AUTOVERSION_PLACEHOLDERS.some((p) => line.includes(p))) managedMasks.add(maskVersions(line));
2109
+ }
2110
+ if (managedMasks.size === 0) return theirs;
2111
+ const targetByMask = /* @__PURE__ */ new Map();
2112
+ for (const line of target.split("\n")) {
2113
+ const mask = maskVersions(line);
2114
+ if (managedMasks.has(mask) && !targetByMask.has(mask)) targetByMask.set(mask, line);
2115
+ }
2116
+ return theirs.split("\n").map((line) => {
2117
+ const mask = maskVersions(line);
2118
+ const replacement = managedMasks.has(mask) ? targetByMask.get(mask) : void 0;
2119
+ return replacement !== void 0 && replacement !== line ? replacement : line;
2120
+ }).join("\n");
2121
+ }
2122
+ function neutralizeAutoversionTheirs(genBase, theirs) {
2123
+ return remapManagedLines(genBase, genBase, theirs);
2124
+ }
2125
+ function alignAutoversionToOurs(genBase, ours, theirs) {
2126
+ return remapManagedLines(genBase, ours, theirs);
2127
+ }
2128
+
2129
+ // src/applicator/runMergeAndRecord.ts
2013
2130
  async function runMergeAndRecord(args) {
2014
2131
  const { plan, inputs, patch, accumulator } = args;
2015
2132
  const { resolvedPath, metadata, oursPath, ours } = inputs;
@@ -2040,11 +2157,13 @@ async function runMergeAndRecord(args) {
2040
2157
  accumulator.recordMerge(resolvedPath, merged2.content, patch.base_generation);
2041
2158
  return { file: resolvedPath, status: "merged", ...unreachableFlag };
2042
2159
  }
2043
- const merged = threeWayMerge(plan.mergeBase, ours, plan.theirs);
2160
+ const mergeBase = inputs.base != null ? alignAutoversionToOurs(inputs.base, ours, plan.mergeBase) : plan.mergeBase;
2161
+ const theirs = inputs.base != null ? alignAutoversionToOurs(inputs.base, ours, plan.theirs) : plan.theirs;
2162
+ const merged = threeWayMerge(mergeBase, ours, theirs);
2044
2163
  await (0, import_promises2.mkdir)((0, import_node_path4.dirname)(oursPath), { recursive: true });
2045
2164
  await (0, import_promises2.writeFile)(oursPath, merged.content);
2046
2165
  if (merged.hasConflicts) {
2047
- accumulator.recordConflict(resolvedPath, plan.theirs, patch.base_generation);
2166
+ accumulator.recordConflict(resolvedPath, theirs, patch.base_generation);
2048
2167
  return {
2049
2168
  file: resolvedPath,
2050
2169
  status: "conflict",
@@ -2053,7 +2172,7 @@ async function runMergeAndRecord(args) {
2053
2172
  conflictMetadata: metadata
2054
2173
  };
2055
2174
  }
2056
- accumulator.recordMerge(resolvedPath, plan.theirs, patch.base_generation);
2175
+ accumulator.recordMerge(resolvedPath, theirs, patch.base_generation);
2057
2176
  return {
2058
2177
  file: resolvedPath,
2059
2178
  status: "merged",
@@ -2671,16 +2790,25 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
2671
2790
  };
2672
2791
 
2673
2792
  // src/ReplayService.ts
2674
- var import_node_fs2 = require("fs");
2793
+ var import_node_fs3 = require("fs");
2675
2794
  var import_promises6 = require("fs/promises");
2676
- var import_node_path8 = require("path");
2795
+ var import_node_path9 = require("path");
2677
2796
  var import_minimatch5 = require("minimatch");
2678
2797
  init_GitClient();
2679
2798
 
2799
+ // src/shared/fernignore.ts
2800
+ var import_node_fs2 = require("fs");
2801
+ var import_node_path6 = require("path");
2802
+ function readFernignorePatterns(outputDir) {
2803
+ const fernignorePath = (0, import_node_path6.join)(outputDir, ".fernignore");
2804
+ if (!(0, import_node_fs2.existsSync)(fernignorePath)) return [];
2805
+ return (0, import_node_fs2.readFileSync)(fernignorePath, "utf-8").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
2806
+ }
2807
+
2680
2808
  // src/PatchRegionDiff.ts
2681
2809
  var import_promises4 = require("fs/promises");
2682
2810
  var import_node_os2 = require("os");
2683
- var import_node_path6 = require("path");
2811
+ var import_node_path7 = require("path");
2684
2812
  var import_node_child_process = require("child_process");
2685
2813
  init_HybridReconstruction();
2686
2814
  function normalizeHunkBody(hunk) {
@@ -2898,10 +3026,10 @@ function overlayRegions(currentGenLines, locInCurrentGen, workingTreeLines, locI
2898
3026
  }
2899
3027
  async function unifiedDiff(beforeContent, afterContent, filePath) {
2900
3028
  if (beforeContent === afterContent) return "";
2901
- const tmp = await (0, import_promises4.mkdtemp)((0, import_node_path6.join)((0, import_node_os2.tmpdir)(), "fern-replay-pp-"));
3029
+ const tmp = await (0, import_promises4.mkdtemp)((0, import_node_path7.join)((0, import_node_os2.tmpdir)(), "fern-replay-pp-"));
2902
3030
  try {
2903
- const beforePath = (0, import_node_path6.join)(tmp, "before");
2904
- const afterPath = (0, import_node_path6.join)(tmp, "after");
3031
+ const beforePath = (0, import_node_path7.join)(tmp, "before");
3032
+ const afterPath = (0, import_node_path7.join)(tmp, "after");
2905
3033
  await (0, import_promises4.writeFile)(beforePath, beforeContent, "utf-8");
2906
3034
  await (0, import_promises4.writeFile)(afterPath, afterContent, "utf-8");
2907
3035
  const raw = await runGitDiffNoIndex(beforePath, afterPath);
@@ -3077,6 +3205,7 @@ var FirstGenerationFlow = class {
3077
3205
  patchesToApply: [],
3078
3206
  newPatchIds: /* @__PURE__ */ new Set(),
3079
3207
  detectorWarnings: [],
3208
+ detectorDegraded: [],
3080
3209
  revertedCount: 0,
3081
3210
  genSha: genRecord.commit_sha,
3082
3211
  optionsSnapshot: options
@@ -3136,6 +3265,7 @@ var SkipApplicationFlow = class {
3136
3265
  patchesToApply: [],
3137
3266
  newPatchIds: /* @__PURE__ */ new Set(),
3138
3267
  detectorWarnings: [],
3268
+ detectorDegraded: [],
3139
3269
  revertedCount: 0,
3140
3270
  genSha: genRecord.commit_sha,
3141
3271
  optionsSnapshot: options
@@ -3173,8 +3303,10 @@ var NoPatchesFlow = class {
3173
3303
  const previousGenerationSha = preLock.current_generation ?? null;
3174
3304
  let { patches: newPatches, revertedPatchIds } = await this.service.detector.detectNewPatches();
3175
3305
  const detectorWarnings = [...this.service.detector.warnings];
3306
+ const detectorDegraded = [...this.service.detector.degradedReasons];
3176
3307
  if (options?.dryRun) {
3177
3308
  const dryRunWarnings = [...detectorWarnings, ...this.service.warnings];
3309
+ const dryRunDegraded = dedupeDegradedReasons([...detectorDegraded, ...this.service.degradedReasons]);
3178
3310
  const preparedReport = {
3179
3311
  flow: "no-patches",
3180
3312
  patchesDetected: newPatches.length,
@@ -3184,7 +3316,9 @@ var NoPatchesFlow = class {
3184
3316
  patchesReverted: revertedPatchIds.length,
3185
3317
  conflicts: [],
3186
3318
  wouldApply: newPatches,
3187
- warnings: dryRunWarnings.length > 0 ? dryRunWarnings : void 0
3319
+ warnings: dryRunWarnings.length > 0 ? dryRunWarnings : void 0,
3320
+ degraded: dryRunDegraded.length > 0 ? true : void 0,
3321
+ degradedReasons: dryRunDegraded.length > 0 ? dryRunDegraded : void 0
3188
3322
  };
3189
3323
  const headSha = await this.service.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
3190
3324
  return {
@@ -3238,6 +3372,7 @@ var NoPatchesFlow = class {
3238
3372
  patchesToApply: newPatches,
3239
3373
  newPatchIds: new Set(newPatches.map((p) => p.id)),
3240
3374
  detectorWarnings,
3375
+ detectorDegraded,
3241
3376
  revertedCount: revertedPatchIds.length,
3242
3377
  genSha: genRecord.commit_sha,
3243
3378
  optionsSnapshot: options
@@ -3281,6 +3416,7 @@ var NoPatchesFlow = class {
3281
3416
  }
3282
3417
  }
3283
3418
  const warnings = [...detectorWarnings, ...this.service.warnings];
3419
+ const degradedReasons = [...prep._prepared.detectorDegraded, ...this.service.degradedReasons];
3284
3420
  return this.service.buildReport(
3285
3421
  "no-patches",
3286
3422
  newPatches,
@@ -3289,7 +3425,8 @@ var NoPatchesFlow = class {
3289
3425
  warnings,
3290
3426
  rebaseCounts,
3291
3427
  void 0,
3292
- prep._prepared.revertedCount
3428
+ prep._prepared.revertedCount,
3429
+ degradedReasons
3293
3430
  );
3294
3431
  }
3295
3432
  };
@@ -3306,6 +3443,10 @@ var NormalRegenerationFlow = class {
3306
3443
  const existingPatches2 = this.service.lockManager.getPatches();
3307
3444
  const { patches: newPatches2, revertedPatchIds: dryRunReverted } = await this.service.detector.detectNewPatches();
3308
3445
  const warnings = [...this.service.detector.warnings, ...this.service.warnings];
3446
+ const dryRunDegraded = dedupeDegradedReasons([
3447
+ ...this.service.detector.degradedReasons,
3448
+ ...this.service.degradedReasons
3449
+ ]);
3309
3450
  const allPatches2 = [...existingPatches2, ...newPatches2];
3310
3451
  const preparedReport = {
3311
3452
  flow: "normal-regeneration",
@@ -3316,7 +3457,9 @@ var NormalRegenerationFlow = class {
3316
3457
  patchesReverted: dryRunReverted.length,
3317
3458
  conflicts: [],
3318
3459
  wouldApply: allPatches2,
3319
- warnings: warnings.length > 0 ? warnings : void 0
3460
+ warnings: warnings.length > 0 ? warnings : void 0,
3461
+ degraded: dryRunDegraded.length > 0 ? true : void 0,
3462
+ degradedReasons: dryRunDegraded.length > 0 ? dryRunDegraded : void 0
3320
3463
  };
3321
3464
  const headSha = await this.service.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
3322
3465
  return {
@@ -3355,6 +3498,7 @@ var NormalRegenerationFlow = class {
3355
3498
  existingPatches = this.service.lockManager.getPatches();
3356
3499
  let { patches: newPatches, revertedPatchIds } = await this.service.detector.detectNewPatches();
3357
3500
  const detectorWarnings = [...this.service.detector.warnings];
3501
+ const detectorDegraded = [...this.service.detector.degradedReasons];
3358
3502
  if (removedByPreRebase.length > 0) {
3359
3503
  const removedOriginalCommits = new Set(removedByPreRebase.map((p) => p.original_commit));
3360
3504
  const removedOriginalMessages = new Set(removedByPreRebase.map((p) => p.original_message));
@@ -3395,6 +3539,7 @@ var NormalRegenerationFlow = class {
3395
3539
  newPatchIds: new Set(newPatches.map((p) => p.id)),
3396
3540
  preRebaseCounts,
3397
3541
  detectorWarnings,
3542
+ detectorDegraded,
3398
3543
  revertedCount: revertedPatchIds.length,
3399
3544
  genSha: genRecord.commit_sha,
3400
3545
  optionsSnapshot: options
@@ -3443,6 +3588,7 @@ var NormalRegenerationFlow = class {
3443
3588
  await this.service.committer.commitReplay(appliedCount, allPatches, void 0, { buckets });
3444
3589
  }
3445
3590
  const warnings = [...detectorWarnings, ...this.service.warnings];
3591
+ const degradedReasons = [...prep._prepared.detectorDegraded, ...this.service.degradedReasons];
3446
3592
  return this.service.buildReport(
3447
3593
  "normal-regeneration",
3448
3594
  allPatches,
@@ -3451,7 +3597,8 @@ var NormalRegenerationFlow = class {
3451
3597
  warnings,
3452
3598
  rebaseCounts,
3453
3599
  prep._prepared.preRebaseCounts,
3454
- prep._prepared.revertedCount
3600
+ prep._prepared.revertedCount,
3601
+ degradedReasons
3455
3602
  );
3456
3603
  }
3457
3604
  };
@@ -3491,6 +3638,16 @@ var ReplayService = class {
3491
3638
  * @internal Used by Flow implementations in `src/flows/`.
3492
3639
  */
3493
3640
  warnings = [];
3641
+ /**
3642
+ * Service-level structured degraded reasons, mirroring `warnings`.
3643
+ * Populated at apply time (e.g. `recordUnreachableBaseWarnings` when a
3644
+ * patch's base-generation tree is unreachable). Merged with
3645
+ * `detector.degradedReasons` into `ReplayReport.degradedReasons` by each
3646
+ * flow handler. Cleared at the top of `prepareReplay()`.
3647
+ *
3648
+ * @internal Used by Flow implementations in `src/flows/`.
3649
+ */
3650
+ degradedReasons = [];
3494
3651
  /**
3495
3652
  * @internal Test-only accessor.
3496
3653
  * Returns the ReplayApplicator instance used for the last run. Tests use this
@@ -3534,7 +3691,10 @@ var ReplayService = class {
3534
3691
  async prepareReplay(options) {
3535
3692
  this.warnings = [];
3536
3693
  this.detector.warnings.length = 0;
3694
+ this.degradedReasons = [];
3695
+ this.detector.degradedReasons.length = 0;
3537
3696
  this.cleanseLegacyFernignoreEntries();
3697
+ this.cleanseInfrastructureFileEntries();
3538
3698
  return this.selectFlow(options).prepare(options);
3539
3699
  }
3540
3700
  /**
@@ -3588,6 +3748,50 @@ var ReplayService = class {
3588
3748
  this.lockManager.save();
3589
3749
  }
3590
3750
  }
3751
+ /**
3752
+ * Migration step: strips infrastructure file diffs (`.fernignore`, `.fern/*`)
3753
+ * from existing patches' `patch_content`. These diffs were bundled by older
3754
+ * detection logic that did not scope `git diff` when only infrastructure files
3755
+ * were stripped. Patches that end up with no surviving diff sections are
3756
+ * removed entirely.
3757
+ *
3758
+ * Idempotent: a second run is a no-op because the first run already removed
3759
+ * all infrastructure sections. Per-patch try/catch ensures isolation.
3760
+ */
3761
+ cleanseInfrastructureFileEntries() {
3762
+ if (!this.lockManager.exists()) return;
3763
+ this.lockManager.read();
3764
+ let lockfileMutated = false;
3765
+ const patches = this.lockManager.getPatches();
3766
+ for (const patch of patches) {
3767
+ try {
3768
+ const result = computeInfrastructureFileCleanse(
3769
+ patch,
3770
+ (content) => this.detector.computeContentHash(content)
3771
+ );
3772
+ if (result.unchanged) continue;
3773
+ if (result.remove) {
3774
+ this.lockManager.removePatch(patch.id);
3775
+ this.warnings.push(
3776
+ `Stripped infrastructure file diffs from patch ${patch.id}: patch contained only infrastructure file changes (${result.strippedPaths.join(", ")}) \u2014 removed.`
3777
+ );
3778
+ } else {
3779
+ this.lockManager.updatePatch(patch.id, {
3780
+ patch_content: result.patch_content,
3781
+ content_hash: result.content_hash
3782
+ });
3783
+ this.warnings.push(
3784
+ `Stripped infrastructure file diffs from patch ${patch.id}: removed bundled diffs for ${result.strippedPaths.join(", ")}.`
3785
+ );
3786
+ }
3787
+ lockfileMutated = true;
3788
+ } catch {
3789
+ }
3790
+ }
3791
+ if (lockfileMutated) {
3792
+ this.lockManager.save();
3793
+ }
3794
+ }
3591
3795
  /**
3592
3796
  * Phase 2 of the two-phase replay flow. Applies patches, rebases them against
3593
3797
  * the generation, saves the lockfile, and commits `[fern-replay]` (or stages
@@ -3754,7 +3958,9 @@ var ReplayService = class {
3754
3958
  if (baseChanged) repointed++;
3755
3959
  continue;
3756
3960
  }
3757
- const diff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
3961
+ const rawDiff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
3962
+ const neutralizedRebase = rawDiff != null && containsPlaceholder(rawDiff) ? await this.computeAutoversionNeutralizedRebase(patch.files, currentGenSha) : null;
3963
+ const diff = neutralizedRebase ? neutralizedRebase.diff : rawDiff;
3758
3964
  if (diff === null) continue;
3759
3965
  if (!diff.trim()) {
3760
3966
  if (hasProtectedFiles) {
@@ -3810,7 +4016,7 @@ var ReplayService = class {
3810
4016
  patch.base_generation = currentGenSha;
3811
4017
  patch.patch_content = diff;
3812
4018
  patch.content_hash = newContentHash;
3813
- const snapshot = await this.readSnapshotFromDisk(patch.files);
4019
+ const snapshot = neutralizedRebase ? neutralizedRebase.snapshot : await this.readSnapshotFromDisk(patch.files);
3814
4020
  const snapshotIsNonEmpty = Object.keys(snapshot).length > 0;
3815
4021
  if (snapshotIsNonEmpty) {
3816
4022
  patch.theirs_snapshot = snapshot;
@@ -3843,13 +4049,54 @@ var ReplayService = class {
3843
4049
  for (const file of files) {
3844
4050
  if (isBinaryFile(file)) continue;
3845
4051
  try {
3846
- const content = await (0, import_promises6.readFile)((0, import_node_path8.join)(this.outputDir, file), "utf-8");
4052
+ const content = await (0, import_promises6.readFile)((0, import_node_path9.join)(this.outputDir, file), "utf-8");
3847
4053
  snapshot[file] = content;
3848
4054
  } catch {
3849
4055
  }
3850
4056
  }
3851
4057
  return snapshot;
3852
4058
  }
4059
+ /**
4060
+ * Autoversion-aware replacement for the naive `git diff currentGen -- files`
4061
+ * + on-disk snapshot used when rebasing a cleanly-applied patch.
4062
+ *
4063
+ * The generation tree (BASE) still carries the placeholder on version lines
4064
+ * (autoversion runs after `[fern-generated]`), but the working tree holds
4065
+ * autoversion's resolved semver. A raw diff/snapshot would fold the
4066
+ * placeholder->semver swap into the customer's patch_content/theirs_snapshot,
4067
+ * pinning a stale version. This re-derives both against a version-neutralized
4068
+ * THEIRS so the pipeline-owned version line never enters the patch.
4069
+ *
4070
+ * Returns null when no file is under autoversion (fixed `--version`, i.e. no
4071
+ * placeholder in the generation tree) — callers keep their existing behavior.
4072
+ */
4073
+ async computeAutoversionNeutralizedRebase(files, currentGenSha) {
4074
+ const genByFile = /* @__PURE__ */ new Map();
4075
+ let anyPlaceholder = false;
4076
+ for (const file of files) {
4077
+ const gen = await this.git.showFile(currentGenSha, file).catch(() => null);
4078
+ genByFile.set(file, gen);
4079
+ if (containsPlaceholder(gen)) anyPlaceholder = true;
4080
+ }
4081
+ if (!anyPlaceholder) return null;
4082
+ const snapshot = {};
4083
+ const fragments = [];
4084
+ for (const file of files) {
4085
+ if (isBinaryFile(file)) continue;
4086
+ const disk = await (0, import_promises6.readFile)((0, import_node_path9.join)(this.outputDir, file), "utf-8").catch(() => null);
4087
+ if (disk == null) continue;
4088
+ const gen = genByFile.get(file) ?? null;
4089
+ const neutralized = gen != null ? neutralizeAutoversionTheirs(gen, disk) : disk;
4090
+ snapshot[file] = neutralized;
4091
+ if (gen == null) {
4092
+ fragments.push(synthesizeNewFileDiff(file, neutralized));
4093
+ } else if (gen !== neutralized) {
4094
+ const fileDiff = await unifiedDiff(gen, neutralized, file);
4095
+ if (fileDiff.trim()) fragments.push(fileDiff);
4096
+ }
4097
+ }
4098
+ return { diff: fragments.join(""), snapshot };
4099
+ }
3853
4100
  /**
3854
4101
  * Detects autoversion contamination — returns true when a
3855
4102
  * `[fern-autoversion]` commit between `fromSha` and `toSha` touched any
@@ -4360,6 +4607,12 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
4360
4607
  );
4361
4608
  }
4362
4609
  }
4610
+ if (warned.size > 0) {
4611
+ this.degradedReasons.push({
4612
+ code: "patch-base-unreachable",
4613
+ message: `${warned.size} customization file(s) could not be checked against the generation they were made on because that generation's tree was not reachable during this run. Customizations may not have been carried forward \u2014 review the result before merging, or widen the clone window and re-run replay.`
4614
+ });
4615
+ }
4363
4616
  }
4364
4617
  /**
4365
4618
  * After applyPatches(), strip conflict markers from conflicting files
@@ -4371,11 +4624,11 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
4371
4624
  if (result.status !== "conflict" || !result.fileResults) continue;
4372
4625
  for (const fileResult of result.fileResults) {
4373
4626
  if (fileResult.status !== "conflict") continue;
4374
- const filePath = (0, import_node_path8.join)(this.outputDir, fileResult.file);
4627
+ const filePath = (0, import_node_path9.join)(this.outputDir, fileResult.file);
4375
4628
  try {
4376
- const content = (0, import_node_fs2.readFileSync)(filePath, "utf-8");
4629
+ const content = (0, import_node_fs3.readFileSync)(filePath, "utf-8");
4377
4630
  const stripped = stripConflictMarkers(content);
4378
- (0, import_node_fs2.writeFileSync)(filePath, stripped);
4631
+ (0, import_node_fs3.writeFileSync)(filePath, stripped);
4379
4632
  } catch {
4380
4633
  }
4381
4634
  }
@@ -4403,12 +4656,11 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
4403
4656
  }
4404
4657
  /** @internal Used by Flow implementations in `src/flows/`. */
4405
4658
  readFernignorePatterns() {
4406
- const fernignorePath = (0, import_node_path8.join)(this.outputDir, ".fernignore");
4407
- if (!(0, import_node_fs2.existsSync)(fernignorePath)) return [];
4408
- return (0, import_node_fs2.readFileSync)(fernignorePath, "utf-8").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
4659
+ return readFernignorePatterns(this.outputDir);
4409
4660
  }
4410
4661
  /** @internal Used by Flow implementations in `src/flows/`. */
4411
- buildReport(flow, patches, results, options, warnings, rebaseCounts, preRebaseCounts, patchesReverted) {
4662
+ buildReport(flow, patches, results, options, warnings, rebaseCounts, preRebaseCounts, patchesReverted, degradedReasons) {
4663
+ const degraded = dedupeDegradedReasons(degradedReasons ?? []);
4412
4664
  const conflictResults = results.filter((r) => r.status === "conflict");
4413
4665
  const conflictDetails = conflictResults.map((r) => {
4414
4666
  const conflictFiles = r.fileResults?.filter((f) => f.status === "conflict") ?? [];
@@ -4448,10 +4700,23 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
4448
4700
  conflictDetails: r.fileResults?.filter((f) => f.status === "conflict") ?? []
4449
4701
  })) : void 0,
4450
4702
  wouldApply: options?.dryRun ? patches : void 0,
4451
- warnings: warnings && warnings.length > 0 ? warnings : void 0
4703
+ warnings: warnings && warnings.length > 0 ? warnings : void 0,
4704
+ degraded: degraded.length > 0 ? true : void 0,
4705
+ degradedReasons: degraded.length > 0 ? degraded : void 0
4452
4706
  };
4453
4707
  }
4454
4708
  };
4709
+ function dedupeDegradedReasons(reasons) {
4710
+ const seen = /* @__PURE__ */ new Set();
4711
+ const out = [];
4712
+ for (const reason of reasons) {
4713
+ const key = `${reason.code}\0${reason.message}`;
4714
+ if (seen.has(key)) continue;
4715
+ seen.add(key);
4716
+ out.push(reason);
4717
+ }
4718
+ return out;
4719
+ }
4455
4720
  function computeCommitBuckets(results, absorbedPatchIds, patchesPassedToCommit) {
4456
4721
  const resultByPatchId = /* @__PURE__ */ new Map();
4457
4722
  for (const r of results) resultByPatchId.set(r.patch.id, r);
@@ -4500,7 +4765,7 @@ function computeLegacyFernignoreCleanse(patch, fernignorePatterns, computeConten
4500
4765
  }
4501
4766
  }
4502
4767
  }
4503
- const { stripped: patchContentStripped, content: newPatchContent, strippedSectionPaths } = stripFernignoreDiffSections(patch.patch_content, matchesFernignore);
4768
+ const { stripped: patchContentStripped, content: newPatchContent, strippedSectionPaths } = stripDiffSectionsByPredicate(patch.patch_content, matchesFernignore);
4504
4769
  const unchanged = strippedFromFiles.length === 0 && !snapshotChanged && !patchContentStripped;
4505
4770
  if (unchanged) {
4506
4771
  return {
@@ -4535,7 +4800,38 @@ function computeLegacyFernignoreCleanse(patch, fernignorePatterns, computeConten
4535
4800
  strippedPaths
4536
4801
  };
4537
4802
  }
4538
- function stripFernignoreDiffSections(patchContent, matchesFernignore) {
4803
+ function isInfrastructureFile(filePath) {
4804
+ return filePath === ".fernignore" || filePath.startsWith(".fern/");
4805
+ }
4806
+ function computeInfrastructureFileCleanse(patch, computeContentHash2) {
4807
+ const { stripped, content: newPatchContent, strippedSectionPaths } = stripDiffSectionsByPredicate(patch.patch_content, isInfrastructureFile);
4808
+ if (!stripped) {
4809
+ return {
4810
+ unchanged: true,
4811
+ remove: false,
4812
+ patch_content: patch.patch_content,
4813
+ content_hash: patch.content_hash,
4814
+ strippedPaths: []
4815
+ };
4816
+ }
4817
+ if (newPatchContent.trim() === "" || patch.files.length === 0) {
4818
+ return {
4819
+ unchanged: false,
4820
+ remove: true,
4821
+ patch_content: "",
4822
+ content_hash: "",
4823
+ strippedPaths: strippedSectionPaths
4824
+ };
4825
+ }
4826
+ return {
4827
+ unchanged: false,
4828
+ remove: false,
4829
+ patch_content: newPatchContent,
4830
+ content_hash: computeContentHash2(newPatchContent),
4831
+ strippedPaths: strippedSectionPaths
4832
+ };
4833
+ }
4834
+ function stripDiffSectionsByPredicate(patchContent, shouldStrip) {
4539
4835
  const lines = patchContent.split("\n");
4540
4836
  const sectionStarts = [];
4541
4837
  for (let i = 0; i < lines.length; i++) {
@@ -4557,7 +4853,7 @@ function stripFernignoreDiffSections(patchContent, matchesFernignore) {
4557
4853
  const section = sectionStarts[i];
4558
4854
  const next = sectionStarts[i + 1];
4559
4855
  const endIdx = next ? next.idx : lines.length;
4560
- if (section.path !== null && matchesFernignore(section.path)) {
4856
+ if (section.path !== null && shouldStrip(section.path)) {
4561
4857
  stripped = true;
4562
4858
  strippedSectionPaths.push(section.path);
4563
4859
  continue;
@@ -4569,8 +4865,8 @@ function stripFernignoreDiffSections(patchContent, matchesFernignore) {
4569
4865
 
4570
4866
  // src/FernignoreMigrator.ts
4571
4867
  var import_node_crypto2 = require("crypto");
4572
- var import_node_fs3 = require("fs");
4573
- var import_node_path9 = require("path");
4868
+ var import_node_fs4 = require("fs");
4869
+ var import_node_path10 = require("path");
4574
4870
  var import_minimatch6 = require("minimatch");
4575
4871
  var import_yaml2 = require("yaml");
4576
4872
  var FernignoreMigrator = class {
@@ -4583,14 +4879,14 @@ var FernignoreMigrator = class {
4583
4879
  this.outputDir = outputDir;
4584
4880
  }
4585
4881
  fernignoreExists() {
4586
- return (0, import_node_fs3.existsSync)((0, import_node_path9.join)(this.outputDir, ".fernignore"));
4882
+ return (0, import_node_fs4.existsSync)((0, import_node_path10.join)(this.outputDir, ".fernignore"));
4587
4883
  }
4588
4884
  readFernignorePatterns() {
4589
- const fernignorePath = (0, import_node_path9.join)(this.outputDir, ".fernignore");
4590
- if (!(0, import_node_fs3.existsSync)(fernignorePath)) {
4885
+ const fernignorePath = (0, import_node_path10.join)(this.outputDir, ".fernignore");
4886
+ if (!(0, import_node_fs4.existsSync)(fernignorePath)) {
4591
4887
  return [];
4592
4888
  }
4593
- const content = (0, import_node_fs3.readFileSync)(fernignorePath, "utf-8");
4889
+ const content = (0, import_node_fs4.readFileSync)(fernignorePath, "utf-8");
4594
4890
  return content.split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
4595
4891
  }
4596
4892
  /** Analyze .fernignore patterns vs git history. Creates synthetic patches for files differing from pristine generation. */
@@ -4695,16 +4991,16 @@ var FernignoreMigrator = class {
4695
4991
  return results;
4696
4992
  }
4697
4993
  readFileContent(filePath) {
4698
- const fullPath = (0, import_node_path9.join)(this.outputDir, filePath);
4699
- if (!(0, import_node_fs3.existsSync)(fullPath)) {
4994
+ const fullPath = (0, import_node_path10.join)(this.outputDir, filePath);
4995
+ if (!(0, import_node_fs4.existsSync)(fullPath)) {
4700
4996
  return null;
4701
4997
  }
4702
4998
  try {
4703
- const stat = (0, import_node_fs3.statSync)(fullPath);
4999
+ const stat = (0, import_node_fs4.statSync)(fullPath);
4704
5000
  if (stat.isDirectory()) {
4705
5001
  return null;
4706
5002
  }
4707
- return (0, import_node_fs3.readFileSync)(fullPath, "utf-8");
5003
+ return (0, import_node_fs4.readFileSync)(fullPath, "utf-8");
4708
5004
  } catch {
4709
5005
  return null;
4710
5006
  }
@@ -4760,27 +5056,27 @@ var FernignoreMigrator = class {
4760
5056
  };
4761
5057
  }
4762
5058
  movePatternsToReplayYml(patterns) {
4763
- const replayYmlPath = (0, import_node_path9.join)(this.outputDir, ".fern", "replay.yml");
5059
+ const replayYmlPath = (0, import_node_path10.join)(this.outputDir, ".fern", "replay.yml");
4764
5060
  let config = {};
4765
- if ((0, import_node_fs3.existsSync)(replayYmlPath)) {
4766
- const content = (0, import_node_fs3.readFileSync)(replayYmlPath, "utf-8");
5061
+ if ((0, import_node_fs4.existsSync)(replayYmlPath)) {
5062
+ const content = (0, import_node_fs4.readFileSync)(replayYmlPath, "utf-8");
4767
5063
  config = (0, import_yaml2.parse)(content) ?? {};
4768
5064
  }
4769
5065
  const existing = config.exclude ?? [];
4770
5066
  const merged = [.../* @__PURE__ */ new Set([...existing, ...patterns])];
4771
5067
  config.exclude = merged;
4772
- const dir = (0, import_node_path9.dirname)(replayYmlPath);
4773
- if (!(0, import_node_fs3.existsSync)(dir)) {
4774
- (0, import_node_fs3.mkdirSync)(dir, { recursive: true });
5068
+ const dir = (0, import_node_path10.dirname)(replayYmlPath);
5069
+ if (!(0, import_node_fs4.existsSync)(dir)) {
5070
+ (0, import_node_fs4.mkdirSync)(dir, { recursive: true });
4775
5071
  }
4776
- (0, import_node_fs3.writeFileSync)(replayYmlPath, (0, import_yaml2.stringify)(config, { lineWidth: 0 }), "utf-8");
5072
+ (0, import_node_fs4.writeFileSync)(replayYmlPath, (0, import_yaml2.stringify)(config, { lineWidth: 0 }), "utf-8");
4777
5073
  }
4778
5074
  };
4779
5075
 
4780
5076
  // src/commands/bootstrap.ts
4781
5077
  var import_node_crypto3 = require("crypto");
4782
- var import_node_fs4 = require("fs");
4783
- var import_node_path10 = require("path");
5078
+ var import_node_fs5 = require("fs");
5079
+ var import_node_path11 = require("path");
4784
5080
  init_GitClient();
4785
5081
  async function bootstrap(outputDir, options) {
4786
5082
  const git = new GitClient(outputDir);
@@ -4947,10 +5243,10 @@ function parseGitLog(log) {
4947
5243
  }
4948
5244
  var REPLAY_FERNIGNORE_ENTRIES = [".fern/replay.lock", ".fern/replay.yml", ".gitattributes"];
4949
5245
  function ensureFernignoreEntries(outputDir) {
4950
- const fernignorePath = (0, import_node_path10.join)(outputDir, ".fernignore");
5246
+ const fernignorePath = (0, import_node_path11.join)(outputDir, ".fernignore");
4951
5247
  let content = "";
4952
- if ((0, import_node_fs4.existsSync)(fernignorePath)) {
4953
- content = (0, import_node_fs4.readFileSync)(fernignorePath, "utf-8");
5248
+ if ((0, import_node_fs5.existsSync)(fernignorePath)) {
5249
+ content = (0, import_node_fs5.readFileSync)(fernignorePath, "utf-8");
4954
5250
  }
4955
5251
  const lines = content.split("\n");
4956
5252
  const toAdd = [];
@@ -4966,15 +5262,15 @@ function ensureFernignoreEntries(outputDir) {
4966
5262
  content += "\n";
4967
5263
  }
4968
5264
  content += toAdd.join("\n") + "\n";
4969
- (0, import_node_fs4.writeFileSync)(fernignorePath, content, "utf-8");
5265
+ (0, import_node_fs5.writeFileSync)(fernignorePath, content, "utf-8");
4970
5266
  return true;
4971
5267
  }
4972
5268
  var GITATTRIBUTES_ENTRIES = [".fern/replay.lock linguist-generated=true"];
4973
5269
  function ensureGitattributesEntries(outputDir) {
4974
- const gitattributesPath = (0, import_node_path10.join)(outputDir, ".gitattributes");
5270
+ const gitattributesPath = (0, import_node_path11.join)(outputDir, ".gitattributes");
4975
5271
  let content = "";
4976
- if ((0, import_node_fs4.existsSync)(gitattributesPath)) {
4977
- content = (0, import_node_fs4.readFileSync)(gitattributesPath, "utf-8");
5272
+ if ((0, import_node_fs5.existsSync)(gitattributesPath)) {
5273
+ content = (0, import_node_fs5.readFileSync)(gitattributesPath, "utf-8");
4978
5274
  }
4979
5275
  const lines = content.split("\n");
4980
5276
  const toAdd = [];
@@ -4990,7 +5286,7 @@ function ensureGitattributesEntries(outputDir) {
4990
5286
  content += "\n";
4991
5287
  }
4992
5288
  content += toAdd.join("\n") + "\n";
4993
- (0, import_node_fs4.writeFileSync)(gitattributesPath, content, "utf-8");
5289
+ (0, import_node_fs5.writeFileSync)(gitattributesPath, content, "utf-8");
4994
5290
  }
4995
5291
  function computeContentHash(patchContent) {
4996
5292
  const lines = patchContent.split("\n");
@@ -5002,6 +5298,7 @@ function computeContentHash(patchContent) {
5002
5298
 
5003
5299
  // src/commands/forget.ts
5004
5300
  var import_minimatch7 = require("minimatch");
5301
+ init_GitClient();
5005
5302
  function parseDiffStat(patchContent) {
5006
5303
  let additions = 0;
5007
5304
  let deletions = 0;
@@ -5060,7 +5357,28 @@ var EMPTY_RESULT = {
5060
5357
  totalPatches: 0,
5061
5358
  warnings: []
5062
5359
  };
5063
- function forget(outputDir, options) {
5360
+ async function dismissPatches(outputDir, lockManager, patches) {
5361
+ const git = new GitClient(outputDir);
5362
+ const detector = new ReplayDetector(git, lockManager, outputDir, readFernignorePatterns(outputDir));
5363
+ for (const patch of patches) {
5364
+ const hashes = [patch.content_hash];
5365
+ if (patch.original_commit) {
5366
+ const materialized = await detector.materializeCommitPatch(patch.original_commit);
5367
+ if (materialized.status === "ok" && !hashes.includes(materialized.contentHash)) {
5368
+ hashes.push(materialized.contentHash);
5369
+ }
5370
+ }
5371
+ lockManager.addDismissal({
5372
+ patch_id: patch.id,
5373
+ original_commit: patch.original_commit,
5374
+ original_message: patch.original_message,
5375
+ content_hashes: hashes,
5376
+ dismissed_at: (/* @__PURE__ */ new Date()).toISOString(),
5377
+ via: "forget"
5378
+ });
5379
+ }
5380
+ }
5381
+ async function forget(outputDir, options) {
5064
5382
  const lockManager = new LockfileManager(outputDir);
5065
5383
  if (!lockManager.exists()) {
5066
5384
  return { ...EMPTY_RESULT };
@@ -5071,9 +5389,7 @@ function forget(outputDir, options) {
5071
5389
  const removed = lock.patches.map(toMatchedPatch);
5072
5390
  const warnings = buildWarnings(lock.patches);
5073
5391
  if (!options.dryRun) {
5074
- for (const patch of lock.patches) {
5075
- lockManager.addForgottenHash(patch.content_hash);
5076
- }
5392
+ await dismissPatches(outputDir, lockManager, lock.patches);
5077
5393
  lockManager.clearPatches();
5078
5394
  lockManager.save();
5079
5395
  }
@@ -5102,8 +5418,8 @@ function forget(outputDir, options) {
5102
5418
  }
5103
5419
  const warnings = buildWarnings(patchesToRemove);
5104
5420
  if (!options.dryRun) {
5421
+ await dismissPatches(outputDir, lockManager, patchesToRemove);
5105
5422
  for (const patch of patchesToRemove) {
5106
- lockManager.addForgottenHash(patch.content_hash);
5107
5423
  lockManager.removePatch(patch.id);
5108
5424
  }
5109
5425
  lockManager.save();
@@ -5144,7 +5460,7 @@ function forget(outputDir, options) {
5144
5460
  }
5145
5461
 
5146
5462
  // src/commands/reset.ts
5147
- var import_node_fs5 = require("fs");
5463
+ var import_node_fs6 = require("fs");
5148
5464
  function reset(outputDir, options) {
5149
5465
  const lockManager = new LockfileManager(outputDir);
5150
5466
  if (!lockManager.exists()) {
@@ -5158,7 +5474,7 @@ function reset(outputDir, options) {
5158
5474
  const lock = lockManager.read();
5159
5475
  const patchCount = lock.patches.length;
5160
5476
  if (!options?.dryRun) {
5161
- (0, import_node_fs5.unlinkSync)(lockManager.lockfilePath);
5477
+ (0, import_node_fs6.unlinkSync)(lockManager.lockfilePath);
5162
5478
  }
5163
5479
  return {
5164
5480
  success: true,
@@ -5170,7 +5486,7 @@ function reset(outputDir, options) {
5170
5486
 
5171
5487
  // src/commands/resolve.ts
5172
5488
  var import_promises7 = require("fs/promises");
5173
- var import_node_path11 = require("path");
5489
+ var import_node_path12 = require("path");
5174
5490
  init_GitClient();
5175
5491
  async function resolve(outputDir, options) {
5176
5492
  const lockManager = new LockfileManager(outputDir);
@@ -5211,15 +5527,16 @@ async function resolve(outputDir, options) {
5211
5527
  const patchesToCommit = [...resolvingPatches, ...unresolvedPatches];
5212
5528
  if (patchesToCommit.length > 0) {
5213
5529
  const currentGen = lock.current_generation;
5214
- const detector = new ReplayDetector(git, lockManager, outputDir);
5530
+ const detector = new ReplayDetector(git, lockManager, outputDir, readFernignorePatterns(outputDir));
5215
5531
  const warnings = [];
5532
+ const patchesDismissed = [];
5216
5533
  let patchesResolved = 0;
5217
5534
  const pass1 = [];
5218
5535
  for (const patch of patchesToCommit) {
5219
5536
  const result = await computePerPatchDiffWithFallback(
5220
5537
  patch,
5221
5538
  (f) => git.showFile(currentGen, f),
5222
- (f) => (0, import_promises7.readFile)((0, import_node_path11.join)(outputDir, f), "utf-8").catch(() => null),
5539
+ (f) => (0, import_promises7.readFile)((0, import_node_path12.join)(outputDir, f), "utf-8").catch(() => null),
5223
5540
  (files) => git.exec(["diff", currentGen, "--", ...files]).catch(() => null)
5224
5541
  );
5225
5542
  if (result === null) {
@@ -5253,7 +5570,27 @@ async function resolve(outputDir, options) {
5253
5570
  const r = pass1[i];
5254
5571
  if (r.kind === "skip") continue;
5255
5572
  if (r.kind === "revert") {
5573
+ const hashes = [patch.content_hash];
5574
+ if (patch.original_commit) {
5575
+ const materialized = await detector.materializeCommitPatch(patch.original_commit);
5576
+ if (materialized.status === "ok" && !hashes.includes(materialized.contentHash)) {
5577
+ hashes.push(materialized.contentHash);
5578
+ } else if (materialized.status === "unreachable") {
5579
+ warnings.push(
5580
+ `Patch ${patch.id}: original commit ${patch.original_commit.slice(0, 7)} is unreachable; dismissed using the stored content hash only. If this commit reappears in history, the customization may be re-detected once.`
5581
+ );
5582
+ }
5583
+ }
5584
+ lockManager.addDismissal({
5585
+ patch_id: patch.id,
5586
+ original_commit: patch.original_commit,
5587
+ original_message: patch.original_message,
5588
+ content_hashes: hashes,
5589
+ dismissed_at: (/* @__PURE__ */ new Date()).toISOString(),
5590
+ via: "resolve"
5591
+ });
5256
5592
  lockManager.removePatch(patch.id);
5593
+ patchesDismissed.push({ id: patch.id, message: patch.original_message });
5257
5594
  continue;
5258
5595
  }
5259
5596
  if (lastIndexByHash.get(r.hash) !== i) {
@@ -5275,7 +5612,7 @@ async function resolve(outputDir, options) {
5275
5612
  const theirsSnapshot = {};
5276
5613
  for (const file of filesForSnapshot) {
5277
5614
  if (isBinaryFile(file)) continue;
5278
- const content = await (0, import_promises7.readFile)((0, import_node_path11.join)(outputDir, file), "utf-8").catch(() => null);
5615
+ const content = await (0, import_promises7.readFile)((0, import_node_path12.join)(outputDir, file), "utf-8").catch(() => null);
5279
5616
  if (content != null) {
5280
5617
  theirsSnapshot[file] = content;
5281
5618
  }
@@ -5302,6 +5639,7 @@ async function resolve(outputDir, options) {
5302
5639
  commitSha: commitSha2,
5303
5640
  phase: "committed",
5304
5641
  patchesResolved,
5642
+ patchesDismissed: patchesDismissed.length > 0 ? patchesDismissed : void 0,
5305
5643
  warnings: warnings.length > 0 ? warnings : void 0
5306
5644
  };
5307
5645
  }
@@ -5349,7 +5687,9 @@ function status(outputDir) {
5349
5687
  lastGeneration: void 0,
5350
5688
  patches: [],
5351
5689
  unresolvedCount: 0,
5352
- excludePatterns: []
5690
+ excludePatterns: [],
5691
+ dismissed: [],
5692
+ legacyDismissedCount: 0
5353
5693
  };
5354
5694
  }
5355
5695
  const lock = lockManager.read();
@@ -5378,13 +5718,24 @@ function status(outputDir) {
5378
5718
  }
5379
5719
  const config = lockManager.getCustomizationsConfig();
5380
5720
  const excludePatterns = config.exclude ?? [];
5721
+ const dismissals = lock.dismissals ?? [];
5722
+ const dismissed = dismissals.map((d) => ({
5723
+ message: d.original_message,
5724
+ sha: d.original_commit.slice(0, 7),
5725
+ via: d.via,
5726
+ dismissedAt: d.dismissed_at
5727
+ }));
5728
+ const coveredHashes = new Set(dismissals.flatMap((d) => d.content_hashes));
5729
+ const legacyDismissedCount = (lock.forgotten_hashes ?? []).filter((h) => !coveredHashes.has(h)).length;
5381
5730
  return {
5382
5731
  initialized: true,
5383
5732
  generationCount: lock.generations.length,
5384
5733
  lastGeneration,
5385
5734
  patches,
5386
5735
  unresolvedCount,
5387
- excludePatterns
5736
+ excludePatterns,
5737
+ dismissed,
5738
+ legacyDismissedCount
5388
5739
  };
5389
5740
  }
5390
5741
  // Annotate the CommonJS export names for ESM import in node: