@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.js CHANGED
@@ -407,18 +407,18 @@ __export(PatchApplyTheirs_exports, {
407
407
  });
408
408
  import { mkdtemp as mkdtemp3, mkdir as mkdir3, writeFile as writeFile4, readFile as readFile3, rm as rm3 } from "fs/promises";
409
409
  import { tmpdir as tmpdir3 } from "os";
410
- import { dirname as dirname4, join as join5 } from "path";
410
+ import { dirname as dirname4, join as join6 } from "path";
411
411
  async function applyPatchToContent(base, patchContent, filePath) {
412
412
  const fileDiff = extractFileDiff(patchContent, filePath);
413
413
  if (!fileDiff) return null;
414
- const tempDir = await mkdtemp3(join5(tmpdir3(), "replay-migrate-"));
414
+ const tempDir = await mkdtemp3(join6(tmpdir3(), "replay-migrate-"));
415
415
  const tempGit = new GitClient(tempDir);
416
416
  try {
417
417
  await tempGit.exec(["init"]);
418
418
  await tempGit.exec(["config", "user.email", "migrate@fern.com"]);
419
419
  await tempGit.exec(["config", "user.name", "Fern Replay Migration"]);
420
420
  await tempGit.exec(["config", "commit.gpgSign", "false"]);
421
- const tempFilePath = join5(tempDir, filePath);
421
+ const tempFilePath = join6(tempDir, filePath);
422
422
  await mkdir3(dirname4(tempFilePath), { recursive: true });
423
423
  await writeFile4(tempFilePath, base);
424
424
  await tempGit.exec(["add", filePath]);
@@ -695,6 +695,22 @@ var LockfileManager = class {
695
695
  this.lock.forgotten_hashes.push(hash);
696
696
  }
697
697
  }
698
+ /**
699
+ * Record a dismissed customization: visibility metadata in `dismissals`
700
+ * plus a tombstone in `forgotten_hashes` for every content hash.
701
+ * `forgotten_hashes` stays the only list the detector consults; the
702
+ * metadata exists so `status` can show what was dismissed.
703
+ */
704
+ addDismissal(record) {
705
+ this.ensureLoaded();
706
+ if (!this.lock.dismissals) {
707
+ this.lock.dismissals = [];
708
+ }
709
+ this.lock.dismissals.push(record);
710
+ for (const hash of record.content_hashes) {
711
+ this.addForgottenHash(hash);
712
+ }
713
+ }
698
714
  getUnresolvedPatches() {
699
715
  this.ensureLoaded();
700
716
  return this.lock.patches.filter((p) => p.status === "unresolved");
@@ -862,12 +878,14 @@ async function capturePatchSnapshot(git, files, treeish) {
862
878
  return snapshot;
863
879
  }
864
880
  function filterInfrastructureAndSensitiveFiles(rawFilesOutput, fernignorePatterns) {
865
- const allFiles = rawFilesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
881
+ const rawFiles = rawFilesOutput.trim().split("\n").filter(Boolean);
882
+ const infrastructureFiles = rawFiles.filter((f) => INFRASTRUCTURE_FILES.has(f) || f.startsWith(".fern/"));
883
+ const allFiles = rawFiles.filter((f) => !INFRASTRUCTURE_FILES.has(f) && !f.startsWith(".fern/"));
866
884
  const sensitiveFiles = allFiles.filter((f) => isSensitiveFile(f));
867
885
  const nonSensitive = allFiles.filter((f) => !isSensitiveFile(f));
868
886
  const fernignoredFiles = nonSensitive.filter((f) => matchesFernignorePattern(f, fernignorePatterns));
869
887
  const files = nonSensitive.filter((f) => !matchesFernignorePattern(f, fernignorePatterns));
870
- return { files, sensitiveFiles, fernignoredFiles };
888
+ return { files, sensitiveFiles, fernignoredFiles, infrastructureFiles };
871
889
  }
872
890
  var ReplayDetector = class {
873
891
  git;
@@ -875,6 +893,15 @@ var ReplayDetector = class {
875
893
  sdkOutputDir;
876
894
  fernignorePatterns;
877
895
  warnings = [];
896
+ /**
897
+ * Structured degraded-state channel, mirroring `warnings`. Populated when
898
+ * detection cannot guarantee customizations were carried forward (baseline
899
+ * unreachable in this clone). Cleared per-run by `ReplayService.prepareReplay`
900
+ * alongside `warnings`. Merged into `ReplayReport.degradedReasons` by each
901
+ * flow handler. Degraded is observability only: detection results are
902
+ * unchanged.
903
+ */
904
+ degradedReasons = [];
878
905
  constructor(git, lockManager, sdkOutputDir, fernignorePatterns = []) {
879
906
  this.git = git;
880
907
  this.lockManager = lockManager;
@@ -945,6 +972,10 @@ var ReplayDetector = class {
945
972
  this.warnings.push(
946
973
  `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.`
947
974
  );
975
+ this.degradedReasons.push({
976
+ code: "baseline-unresolvable",
977
+ 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.`
978
+ });
948
979
  }
949
980
  return { patches: [], revertedPatchIds: [] };
950
981
  }
@@ -953,6 +984,12 @@ var ReplayDetector = class {
953
984
  this.warnings.push(
954
985
  `Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to tree-diff detection (likely a shallow clone).`
955
986
  );
987
+ if (lock.patches.length > 0) {
988
+ this.degradedReasons.push({
989
+ code: "generation-anchor-unreachable",
990
+ 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.`
991
+ });
992
+ }
956
993
  return this.detectPatchesViaTreeDiff(
957
994
  lastGen,
958
995
  /* commitKnownMissing */
@@ -992,54 +1029,27 @@ var ReplayDetector = class {
992
1029
  if (lock.patches.find((p) => p.original_commit === commit.sha)) {
993
1030
  continue;
994
1031
  }
995
- const parents = await this.git.getCommitParents(commit.sha);
996
- let filesOutput;
997
- try {
998
- filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
999
- } catch {
1032
+ const materialized = await this.materializeCommitPatch(commit.sha);
1033
+ if (materialized.sensitiveFiles.length > 0) {
1000
1034
  this.warnings.push(
1001
- `Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
1035
+ `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${materialized.sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
1002
1036
  );
1003
- continue;
1004
1037
  }
1005
- const { files, sensitiveFiles, fernignoredFiles } = filterInfrastructureAndSensitiveFiles(
1006
- filesOutput,
1007
- this.fernignorePatterns
1008
- );
1009
- if (sensitiveFiles.length > 0) {
1038
+ if (materialized.fernignoredFiles.length > 0) {
1010
1039
  this.warnings.push(
1011
- `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
1040
+ `.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.`
1012
1041
  );
1013
1042
  }
1014
- if (fernignoredFiles.length > 0) {
1043
+ if (materialized.status === "unreachable") {
1015
1044
  this.warnings.push(
1016
- `.fernignore-protected file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${fernignoredFiles.join(", ")}. These files are managed by .fernignore, not Replay.`
1045
+ `Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
1017
1046
  );
1018
- }
1019
- if (files.length === 0) {
1020
1047
  continue;
1021
1048
  }
1022
- const wasFiltered = sensitiveFiles.length > 0 || fernignoredFiles.length > 0;
1023
- const parentSha = parents[0];
1024
- let patchContent;
1025
- if (wasFiltered && parentSha) {
1026
- try {
1027
- patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
1028
- } catch {
1029
- continue;
1030
- }
1031
- if (!patchContent.trim()) continue;
1032
- } else {
1033
- try {
1034
- patchContent = await this.git.formatPatch(commit.sha);
1035
- } catch {
1036
- this.warnings.push(
1037
- `Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
1038
- );
1039
- continue;
1040
- }
1049
+ if (materialized.status === "empty") {
1050
+ continue;
1041
1051
  }
1042
- const contentHash = this.computeContentHash(patchContent);
1052
+ const { files, patchContent, contentHash } = materialized;
1043
1053
  if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
1044
1054
  continue;
1045
1055
  }
@@ -1116,6 +1126,63 @@ var ReplayDetector = class {
1116
1126
  const filteredPatches = survivingPatches.filter((_, i) => !revertIndicesToRemove.has(i));
1117
1127
  return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
1118
1128
  }
1129
+ /**
1130
+ * Materialize a commit into patch content exactly as detection does.
1131
+ *
1132
+ * Pipeline: `diff-tree --name-only` → filter infrastructure / sensitive /
1133
+ * `.fernignore`d files → scoped `git diff` when files were stripped,
1134
+ * otherwise full `git format-patch` → content hash.
1135
+ *
1136
+ * This is THE definition of a commit's re-emission identity: the content
1137
+ * hash this method returns is what `detectPatchesInRange` will compute if
1138
+ * the commit re-enters the scan range (e.g. after a regen PR is merged
1139
+ * with a merge commit). Callers that tombstone a dismissed patch must use
1140
+ * this hash — the stored `content_hash` drifts every time the patch is
1141
+ * carried forward, so it does not identify the commit on re-detection.
1142
+ *
1143
+ * Never throws; failures are reported through the `status` discriminant.
1144
+ */
1145
+ async materializeCommitPatch(sha) {
1146
+ let parents;
1147
+ let filesOutput;
1148
+ try {
1149
+ parents = await this.git.getCommitParents(sha);
1150
+ filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", sha]);
1151
+ } catch {
1152
+ return { status: "unreachable", sensitiveFiles: [], fernignoredFiles: [] };
1153
+ }
1154
+ const { files, sensitiveFiles, fernignoredFiles, infrastructureFiles } = filterInfrastructureAndSensitiveFiles(filesOutput, this.fernignorePatterns);
1155
+ if (files.length === 0) {
1156
+ return { status: "empty", sensitiveFiles, fernignoredFiles };
1157
+ }
1158
+ const wasFiltered = sensitiveFiles.length > 0 || fernignoredFiles.length > 0 || infrastructureFiles.length > 0;
1159
+ const parentSha = parents[0];
1160
+ let patchContent;
1161
+ if (wasFiltered && parentSha) {
1162
+ try {
1163
+ patchContent = await this.git.exec(["diff", parentSha, sha, "--", ...files]);
1164
+ } catch {
1165
+ return { status: "empty", sensitiveFiles, fernignoredFiles };
1166
+ }
1167
+ if (!patchContent.trim()) {
1168
+ return { status: "empty", sensitiveFiles, fernignoredFiles };
1169
+ }
1170
+ } else {
1171
+ try {
1172
+ patchContent = await this.git.formatPatch(sha);
1173
+ } catch {
1174
+ return { status: "unreachable", sensitiveFiles, fernignoredFiles };
1175
+ }
1176
+ }
1177
+ return {
1178
+ status: "ok",
1179
+ files,
1180
+ sensitiveFiles,
1181
+ fernignoredFiles,
1182
+ patchContent,
1183
+ contentHash: this.computeContentHash(patchContent)
1184
+ };
1185
+ }
1119
1186
  /**
1120
1187
  * Compute content hash for deduplication.
1121
1188
  *
@@ -1220,6 +1287,13 @@ var ReplayDetector = class {
1220
1287
  async detectPatchesViaTreeDiff(lastGen, commitKnownMissing) {
1221
1288
  const diffBase = await this.resolveDiffBase(lastGen, commitKnownMissing);
1222
1289
  if (!diffBase) {
1290
+ this.warnings.push(
1291
+ `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.`
1292
+ );
1293
+ this.degradedReasons.push({
1294
+ code: "generation-tree-unreachable",
1295
+ 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.`
1296
+ });
1223
1297
  return this.detectPatchesViaCommitScan();
1224
1298
  }
1225
1299
  const lockForGuard = this.lockManager.read();
@@ -1332,10 +1406,7 @@ var ReplayDetector = class {
1332
1406
  continue;
1333
1407
  }
1334
1408
  const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
1335
- const { files, sensitiveFiles, fernignoredFiles } = filterInfrastructureAndSensitiveFiles(
1336
- filesOutput,
1337
- this.fernignorePatterns
1338
- );
1409
+ const { files, sensitiveFiles, fernignoredFiles, infrastructureFiles } = filterInfrastructureAndSensitiveFiles(filesOutput, this.fernignorePatterns);
1339
1410
  if (sensitiveFiles.length > 0) {
1340
1411
  this.warnings.push(
1341
1412
  `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
@@ -1346,7 +1417,8 @@ var ReplayDetector = class {
1346
1417
  `.fernignore-protected file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${fernignoredFiles.join(", ")}. These files are managed by .fernignore, not Replay.`
1347
1418
  );
1348
1419
  }
1349
- if ((sensitiveFiles.length > 0 || fernignoredFiles.length > 0) && files.length > 0) {
1420
+ const wasFiltered = sensitiveFiles.length > 0 || fernignoredFiles.length > 0 || infrastructureFiles.length > 0;
1421
+ if (wasFiltered && files.length > 0) {
1350
1422
  if (parents.length === 0) {
1351
1423
  continue;
1352
1424
  }
@@ -1955,6 +2027,51 @@ async function buildMergePlan(args) {
1955
2027
  // src/applicator/runMergeAndRecord.ts
1956
2028
  import { mkdir, writeFile } from "fs/promises";
1957
2029
  import { dirname as dirname2 } from "path";
2030
+
2031
+ // src/autoversion.ts
2032
+ var AUTOVERSION_PLACEHOLDERS = [
2033
+ "v0.0.0-fern-placeholder",
2034
+ // Go -- a superstring of the default; check first
2035
+ "0.0.0-fern-placeholder",
2036
+ // TS/Java/C#/Ruby/PHP/Rust/default
2037
+ "0.0.0.dev0"
2038
+ // Python (PEP 440 -- Poetry rejects hyphens)
2039
+ ];
2040
+ var VERSION_TOKEN = /(?:v)?\d+\.\d+\.\d+[0-9A-Za-z.+-]*/g;
2041
+ var VERSION_MASK = "@@FERN_VERSION@@";
2042
+ function maskVersions(line) {
2043
+ return line.replace(VERSION_TOKEN, VERSION_MASK);
2044
+ }
2045
+ function containsPlaceholder(content) {
2046
+ if (!content) return false;
2047
+ return AUTOVERSION_PLACEHOLDERS.some((p) => content.includes(p));
2048
+ }
2049
+ function remapManagedLines(genBase, target, theirs) {
2050
+ if (!containsPlaceholder(genBase)) return theirs;
2051
+ const managedMasks = /* @__PURE__ */ new Set();
2052
+ for (const line of genBase.split("\n")) {
2053
+ if (AUTOVERSION_PLACEHOLDERS.some((p) => line.includes(p))) managedMasks.add(maskVersions(line));
2054
+ }
2055
+ if (managedMasks.size === 0) return theirs;
2056
+ const targetByMask = /* @__PURE__ */ new Map();
2057
+ for (const line of target.split("\n")) {
2058
+ const mask = maskVersions(line);
2059
+ if (managedMasks.has(mask) && !targetByMask.has(mask)) targetByMask.set(mask, line);
2060
+ }
2061
+ return theirs.split("\n").map((line) => {
2062
+ const mask = maskVersions(line);
2063
+ const replacement = managedMasks.has(mask) ? targetByMask.get(mask) : void 0;
2064
+ return replacement !== void 0 && replacement !== line ? replacement : line;
2065
+ }).join("\n");
2066
+ }
2067
+ function neutralizeAutoversionTheirs(genBase, theirs) {
2068
+ return remapManagedLines(genBase, genBase, theirs);
2069
+ }
2070
+ function alignAutoversionToOurs(genBase, ours, theirs) {
2071
+ return remapManagedLines(genBase, ours, theirs);
2072
+ }
2073
+
2074
+ // src/applicator/runMergeAndRecord.ts
1958
2075
  async function runMergeAndRecord(args) {
1959
2076
  const { plan, inputs, patch, accumulator } = args;
1960
2077
  const { resolvedPath, metadata, oursPath, ours } = inputs;
@@ -1985,11 +2102,13 @@ async function runMergeAndRecord(args) {
1985
2102
  accumulator.recordMerge(resolvedPath, merged2.content, patch.base_generation);
1986
2103
  return { file: resolvedPath, status: "merged", ...unreachableFlag };
1987
2104
  }
1988
- const merged = threeWayMerge(plan.mergeBase, ours, plan.theirs);
2105
+ const mergeBase = inputs.base != null ? alignAutoversionToOurs(inputs.base, ours, plan.mergeBase) : plan.mergeBase;
2106
+ const theirs = inputs.base != null ? alignAutoversionToOurs(inputs.base, ours, plan.theirs) : plan.theirs;
2107
+ const merged = threeWayMerge(mergeBase, ours, theirs);
1989
2108
  await mkdir(dirname2(oursPath), { recursive: true });
1990
2109
  await writeFile(oursPath, merged.content);
1991
2110
  if (merged.hasConflicts) {
1992
- accumulator.recordConflict(resolvedPath, plan.theirs, patch.base_generation);
2111
+ accumulator.recordConflict(resolvedPath, theirs, patch.base_generation);
1993
2112
  return {
1994
2113
  file: resolvedPath,
1995
2114
  status: "conflict",
@@ -1998,7 +2117,7 @@ async function runMergeAndRecord(args) {
1998
2117
  conflictMetadata: metadata
1999
2118
  };
2000
2119
  }
2001
- accumulator.recordMerge(resolvedPath, plan.theirs, patch.base_generation);
2120
+ accumulator.recordMerge(resolvedPath, theirs, patch.base_generation);
2002
2121
  return {
2003
2122
  file: resolvedPath,
2004
2123
  status: "merged",
@@ -2616,17 +2735,26 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
2616
2735
  };
2617
2736
 
2618
2737
  // src/ReplayService.ts
2619
- import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
2738
+ import { readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
2620
2739
  import { readFile as readFileAsync } from "fs/promises";
2621
- import { join as join6 } from "path";
2740
+ import { join as join7 } from "path";
2622
2741
  import { minimatch as minimatch5 } from "minimatch";
2623
2742
  init_GitClient();
2624
2743
 
2744
+ // src/shared/fernignore.ts
2745
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
2746
+ import { join as join4 } from "path";
2747
+ function readFernignorePatterns(outputDir) {
2748
+ const fernignorePath = join4(outputDir, ".fernignore");
2749
+ if (!existsSync2(fernignorePath)) return [];
2750
+ return readFileSync2(fernignorePath, "utf-8").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
2751
+ }
2752
+
2625
2753
  // src/PatchRegionDiff.ts
2626
2754
  init_HybridReconstruction();
2627
2755
  import { mkdtemp as mkdtemp2, writeFile as writeFile3, rm as rm2 } from "fs/promises";
2628
2756
  import { tmpdir as tmpdir2 } from "os";
2629
- import { join as join4 } from "path";
2757
+ import { join as join5 } from "path";
2630
2758
  import { spawn } from "child_process";
2631
2759
  function normalizeHunkBody(hunk) {
2632
2760
  let contextC = 0;
@@ -2843,10 +2971,10 @@ function overlayRegions(currentGenLines, locInCurrentGen, workingTreeLines, locI
2843
2971
  }
2844
2972
  async function unifiedDiff(beforeContent, afterContent, filePath) {
2845
2973
  if (beforeContent === afterContent) return "";
2846
- const tmp = await mkdtemp2(join4(tmpdir2(), "fern-replay-pp-"));
2974
+ const tmp = await mkdtemp2(join5(tmpdir2(), "fern-replay-pp-"));
2847
2975
  try {
2848
- const beforePath = join4(tmp, "before");
2849
- const afterPath = join4(tmp, "after");
2976
+ const beforePath = join5(tmp, "before");
2977
+ const afterPath = join5(tmp, "after");
2850
2978
  await writeFile3(beforePath, beforeContent, "utf-8");
2851
2979
  await writeFile3(afterPath, afterContent, "utf-8");
2852
2980
  const raw = await runGitDiffNoIndex(beforePath, afterPath);
@@ -3022,6 +3150,7 @@ var FirstGenerationFlow = class {
3022
3150
  patchesToApply: [],
3023
3151
  newPatchIds: /* @__PURE__ */ new Set(),
3024
3152
  detectorWarnings: [],
3153
+ detectorDegraded: [],
3025
3154
  revertedCount: 0,
3026
3155
  genSha: genRecord.commit_sha,
3027
3156
  optionsSnapshot: options
@@ -3081,6 +3210,7 @@ var SkipApplicationFlow = class {
3081
3210
  patchesToApply: [],
3082
3211
  newPatchIds: /* @__PURE__ */ new Set(),
3083
3212
  detectorWarnings: [],
3213
+ detectorDegraded: [],
3084
3214
  revertedCount: 0,
3085
3215
  genSha: genRecord.commit_sha,
3086
3216
  optionsSnapshot: options
@@ -3118,8 +3248,10 @@ var NoPatchesFlow = class {
3118
3248
  const previousGenerationSha = preLock.current_generation ?? null;
3119
3249
  let { patches: newPatches, revertedPatchIds } = await this.service.detector.detectNewPatches();
3120
3250
  const detectorWarnings = [...this.service.detector.warnings];
3251
+ const detectorDegraded = [...this.service.detector.degradedReasons];
3121
3252
  if (options?.dryRun) {
3122
3253
  const dryRunWarnings = [...detectorWarnings, ...this.service.warnings];
3254
+ const dryRunDegraded = dedupeDegradedReasons([...detectorDegraded, ...this.service.degradedReasons]);
3123
3255
  const preparedReport = {
3124
3256
  flow: "no-patches",
3125
3257
  patchesDetected: newPatches.length,
@@ -3129,7 +3261,9 @@ var NoPatchesFlow = class {
3129
3261
  patchesReverted: revertedPatchIds.length,
3130
3262
  conflicts: [],
3131
3263
  wouldApply: newPatches,
3132
- warnings: dryRunWarnings.length > 0 ? dryRunWarnings : void 0
3264
+ warnings: dryRunWarnings.length > 0 ? dryRunWarnings : void 0,
3265
+ degraded: dryRunDegraded.length > 0 ? true : void 0,
3266
+ degradedReasons: dryRunDegraded.length > 0 ? dryRunDegraded : void 0
3133
3267
  };
3134
3268
  const headSha = await this.service.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
3135
3269
  return {
@@ -3183,6 +3317,7 @@ var NoPatchesFlow = class {
3183
3317
  patchesToApply: newPatches,
3184
3318
  newPatchIds: new Set(newPatches.map((p) => p.id)),
3185
3319
  detectorWarnings,
3320
+ detectorDegraded,
3186
3321
  revertedCount: revertedPatchIds.length,
3187
3322
  genSha: genRecord.commit_sha,
3188
3323
  optionsSnapshot: options
@@ -3226,6 +3361,7 @@ var NoPatchesFlow = class {
3226
3361
  }
3227
3362
  }
3228
3363
  const warnings = [...detectorWarnings, ...this.service.warnings];
3364
+ const degradedReasons = [...prep._prepared.detectorDegraded, ...this.service.degradedReasons];
3229
3365
  return this.service.buildReport(
3230
3366
  "no-patches",
3231
3367
  newPatches,
@@ -3234,7 +3370,8 @@ var NoPatchesFlow = class {
3234
3370
  warnings,
3235
3371
  rebaseCounts,
3236
3372
  void 0,
3237
- prep._prepared.revertedCount
3373
+ prep._prepared.revertedCount,
3374
+ degradedReasons
3238
3375
  );
3239
3376
  }
3240
3377
  };
@@ -3251,6 +3388,10 @@ var NormalRegenerationFlow = class {
3251
3388
  const existingPatches2 = this.service.lockManager.getPatches();
3252
3389
  const { patches: newPatches2, revertedPatchIds: dryRunReverted } = await this.service.detector.detectNewPatches();
3253
3390
  const warnings = [...this.service.detector.warnings, ...this.service.warnings];
3391
+ const dryRunDegraded = dedupeDegradedReasons([
3392
+ ...this.service.detector.degradedReasons,
3393
+ ...this.service.degradedReasons
3394
+ ]);
3254
3395
  const allPatches2 = [...existingPatches2, ...newPatches2];
3255
3396
  const preparedReport = {
3256
3397
  flow: "normal-regeneration",
@@ -3261,7 +3402,9 @@ var NormalRegenerationFlow = class {
3261
3402
  patchesReverted: dryRunReverted.length,
3262
3403
  conflicts: [],
3263
3404
  wouldApply: allPatches2,
3264
- warnings: warnings.length > 0 ? warnings : void 0
3405
+ warnings: warnings.length > 0 ? warnings : void 0,
3406
+ degraded: dryRunDegraded.length > 0 ? true : void 0,
3407
+ degradedReasons: dryRunDegraded.length > 0 ? dryRunDegraded : void 0
3265
3408
  };
3266
3409
  const headSha = await this.service.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
3267
3410
  return {
@@ -3300,6 +3443,7 @@ var NormalRegenerationFlow = class {
3300
3443
  existingPatches = this.service.lockManager.getPatches();
3301
3444
  let { patches: newPatches, revertedPatchIds } = await this.service.detector.detectNewPatches();
3302
3445
  const detectorWarnings = [...this.service.detector.warnings];
3446
+ const detectorDegraded = [...this.service.detector.degradedReasons];
3303
3447
  if (removedByPreRebase.length > 0) {
3304
3448
  const removedOriginalCommits = new Set(removedByPreRebase.map((p) => p.original_commit));
3305
3449
  const removedOriginalMessages = new Set(removedByPreRebase.map((p) => p.original_message));
@@ -3340,6 +3484,7 @@ var NormalRegenerationFlow = class {
3340
3484
  newPatchIds: new Set(newPatches.map((p) => p.id)),
3341
3485
  preRebaseCounts,
3342
3486
  detectorWarnings,
3487
+ detectorDegraded,
3343
3488
  revertedCount: revertedPatchIds.length,
3344
3489
  genSha: genRecord.commit_sha,
3345
3490
  optionsSnapshot: options
@@ -3388,6 +3533,7 @@ var NormalRegenerationFlow = class {
3388
3533
  await this.service.committer.commitReplay(appliedCount, allPatches, void 0, { buckets });
3389
3534
  }
3390
3535
  const warnings = [...detectorWarnings, ...this.service.warnings];
3536
+ const degradedReasons = [...prep._prepared.detectorDegraded, ...this.service.degradedReasons];
3391
3537
  return this.service.buildReport(
3392
3538
  "normal-regeneration",
3393
3539
  allPatches,
@@ -3396,7 +3542,8 @@ var NormalRegenerationFlow = class {
3396
3542
  warnings,
3397
3543
  rebaseCounts,
3398
3544
  prep._prepared.preRebaseCounts,
3399
- prep._prepared.revertedCount
3545
+ prep._prepared.revertedCount,
3546
+ degradedReasons
3400
3547
  );
3401
3548
  }
3402
3549
  };
@@ -3436,6 +3583,16 @@ var ReplayService = class {
3436
3583
  * @internal Used by Flow implementations in `src/flows/`.
3437
3584
  */
3438
3585
  warnings = [];
3586
+ /**
3587
+ * Service-level structured degraded reasons, mirroring `warnings`.
3588
+ * Populated at apply time (e.g. `recordUnreachableBaseWarnings` when a
3589
+ * patch's base-generation tree is unreachable). Merged with
3590
+ * `detector.degradedReasons` into `ReplayReport.degradedReasons` by each
3591
+ * flow handler. Cleared at the top of `prepareReplay()`.
3592
+ *
3593
+ * @internal Used by Flow implementations in `src/flows/`.
3594
+ */
3595
+ degradedReasons = [];
3439
3596
  /**
3440
3597
  * @internal Test-only accessor.
3441
3598
  * Returns the ReplayApplicator instance used for the last run. Tests use this
@@ -3479,7 +3636,10 @@ var ReplayService = class {
3479
3636
  async prepareReplay(options) {
3480
3637
  this.warnings = [];
3481
3638
  this.detector.warnings.length = 0;
3639
+ this.degradedReasons = [];
3640
+ this.detector.degradedReasons.length = 0;
3482
3641
  this.cleanseLegacyFernignoreEntries();
3642
+ this.cleanseInfrastructureFileEntries();
3483
3643
  return this.selectFlow(options).prepare(options);
3484
3644
  }
3485
3645
  /**
@@ -3533,6 +3693,50 @@ var ReplayService = class {
3533
3693
  this.lockManager.save();
3534
3694
  }
3535
3695
  }
3696
+ /**
3697
+ * Migration step: strips infrastructure file diffs (`.fernignore`, `.fern/*`)
3698
+ * from existing patches' `patch_content`. These diffs were bundled by older
3699
+ * detection logic that did not scope `git diff` when only infrastructure files
3700
+ * were stripped. Patches that end up with no surviving diff sections are
3701
+ * removed entirely.
3702
+ *
3703
+ * Idempotent: a second run is a no-op because the first run already removed
3704
+ * all infrastructure sections. Per-patch try/catch ensures isolation.
3705
+ */
3706
+ cleanseInfrastructureFileEntries() {
3707
+ if (!this.lockManager.exists()) return;
3708
+ this.lockManager.read();
3709
+ let lockfileMutated = false;
3710
+ const patches = this.lockManager.getPatches();
3711
+ for (const patch of patches) {
3712
+ try {
3713
+ const result = computeInfrastructureFileCleanse(
3714
+ patch,
3715
+ (content) => this.detector.computeContentHash(content)
3716
+ );
3717
+ if (result.unchanged) continue;
3718
+ if (result.remove) {
3719
+ this.lockManager.removePatch(patch.id);
3720
+ this.warnings.push(
3721
+ `Stripped infrastructure file diffs from patch ${patch.id}: patch contained only infrastructure file changes (${result.strippedPaths.join(", ")}) \u2014 removed.`
3722
+ );
3723
+ } else {
3724
+ this.lockManager.updatePatch(patch.id, {
3725
+ patch_content: result.patch_content,
3726
+ content_hash: result.content_hash
3727
+ });
3728
+ this.warnings.push(
3729
+ `Stripped infrastructure file diffs from patch ${patch.id}: removed bundled diffs for ${result.strippedPaths.join(", ")}.`
3730
+ );
3731
+ }
3732
+ lockfileMutated = true;
3733
+ } catch {
3734
+ }
3735
+ }
3736
+ if (lockfileMutated) {
3737
+ this.lockManager.save();
3738
+ }
3739
+ }
3536
3740
  /**
3537
3741
  * Phase 2 of the two-phase replay flow. Applies patches, rebases them against
3538
3742
  * the generation, saves the lockfile, and commits `[fern-replay]` (or stages
@@ -3699,7 +3903,9 @@ var ReplayService = class {
3699
3903
  if (baseChanged) repointed++;
3700
3904
  continue;
3701
3905
  }
3702
- const diff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
3906
+ const rawDiff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
3907
+ const neutralizedRebase = rawDiff != null && containsPlaceholder(rawDiff) ? await this.computeAutoversionNeutralizedRebase(patch.files, currentGenSha) : null;
3908
+ const diff = neutralizedRebase ? neutralizedRebase.diff : rawDiff;
3703
3909
  if (diff === null) continue;
3704
3910
  if (!diff.trim()) {
3705
3911
  if (hasProtectedFiles) {
@@ -3755,7 +3961,7 @@ var ReplayService = class {
3755
3961
  patch.base_generation = currentGenSha;
3756
3962
  patch.patch_content = diff;
3757
3963
  patch.content_hash = newContentHash;
3758
- const snapshot = await this.readSnapshotFromDisk(patch.files);
3964
+ const snapshot = neutralizedRebase ? neutralizedRebase.snapshot : await this.readSnapshotFromDisk(patch.files);
3759
3965
  const snapshotIsNonEmpty = Object.keys(snapshot).length > 0;
3760
3966
  if (snapshotIsNonEmpty) {
3761
3967
  patch.theirs_snapshot = snapshot;
@@ -3788,13 +3994,54 @@ var ReplayService = class {
3788
3994
  for (const file of files) {
3789
3995
  if (isBinaryFile(file)) continue;
3790
3996
  try {
3791
- const content = await readFileAsync(join6(this.outputDir, file), "utf-8");
3997
+ const content = await readFileAsync(join7(this.outputDir, file), "utf-8");
3792
3998
  snapshot[file] = content;
3793
3999
  } catch {
3794
4000
  }
3795
4001
  }
3796
4002
  return snapshot;
3797
4003
  }
4004
+ /**
4005
+ * Autoversion-aware replacement for the naive `git diff currentGen -- files`
4006
+ * + on-disk snapshot used when rebasing a cleanly-applied patch.
4007
+ *
4008
+ * The generation tree (BASE) still carries the placeholder on version lines
4009
+ * (autoversion runs after `[fern-generated]`), but the working tree holds
4010
+ * autoversion's resolved semver. A raw diff/snapshot would fold the
4011
+ * placeholder->semver swap into the customer's patch_content/theirs_snapshot,
4012
+ * pinning a stale version. This re-derives both against a version-neutralized
4013
+ * THEIRS so the pipeline-owned version line never enters the patch.
4014
+ *
4015
+ * Returns null when no file is under autoversion (fixed `--version`, i.e. no
4016
+ * placeholder in the generation tree) — callers keep their existing behavior.
4017
+ */
4018
+ async computeAutoversionNeutralizedRebase(files, currentGenSha) {
4019
+ const genByFile = /* @__PURE__ */ new Map();
4020
+ let anyPlaceholder = false;
4021
+ for (const file of files) {
4022
+ const gen = await this.git.showFile(currentGenSha, file).catch(() => null);
4023
+ genByFile.set(file, gen);
4024
+ if (containsPlaceholder(gen)) anyPlaceholder = true;
4025
+ }
4026
+ if (!anyPlaceholder) return null;
4027
+ const snapshot = {};
4028
+ const fragments = [];
4029
+ for (const file of files) {
4030
+ if (isBinaryFile(file)) continue;
4031
+ const disk = await readFileAsync(join7(this.outputDir, file), "utf-8").catch(() => null);
4032
+ if (disk == null) continue;
4033
+ const gen = genByFile.get(file) ?? null;
4034
+ const neutralized = gen != null ? neutralizeAutoversionTheirs(gen, disk) : disk;
4035
+ snapshot[file] = neutralized;
4036
+ if (gen == null) {
4037
+ fragments.push(synthesizeNewFileDiff(file, neutralized));
4038
+ } else if (gen !== neutralized) {
4039
+ const fileDiff = await unifiedDiff(gen, neutralized, file);
4040
+ if (fileDiff.trim()) fragments.push(fileDiff);
4041
+ }
4042
+ }
4043
+ return { diff: fragments.join(""), snapshot };
4044
+ }
3798
4045
  /**
3799
4046
  * Detects autoversion contamination — returns true when a
3800
4047
  * `[fern-autoversion]` commit between `fromSha` and `toSha` touched any
@@ -4305,6 +4552,12 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
4305
4552
  );
4306
4553
  }
4307
4554
  }
4555
+ if (warned.size > 0) {
4556
+ this.degradedReasons.push({
4557
+ code: "patch-base-unreachable",
4558
+ 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.`
4559
+ });
4560
+ }
4308
4561
  }
4309
4562
  /**
4310
4563
  * After applyPatches(), strip conflict markers from conflicting files
@@ -4316,9 +4569,9 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
4316
4569
  if (result.status !== "conflict" || !result.fileResults) continue;
4317
4570
  for (const fileResult of result.fileResults) {
4318
4571
  if (fileResult.status !== "conflict") continue;
4319
- const filePath = join6(this.outputDir, fileResult.file);
4572
+ const filePath = join7(this.outputDir, fileResult.file);
4320
4573
  try {
4321
- const content = readFileSync2(filePath, "utf-8");
4574
+ const content = readFileSync3(filePath, "utf-8");
4322
4575
  const stripped = stripConflictMarkers(content);
4323
4576
  writeFileSync2(filePath, stripped);
4324
4577
  } catch {
@@ -4348,12 +4601,11 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
4348
4601
  }
4349
4602
  /** @internal Used by Flow implementations in `src/flows/`. */
4350
4603
  readFernignorePatterns() {
4351
- const fernignorePath = join6(this.outputDir, ".fernignore");
4352
- if (!existsSync2(fernignorePath)) return [];
4353
- return readFileSync2(fernignorePath, "utf-8").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
4604
+ return readFernignorePatterns(this.outputDir);
4354
4605
  }
4355
4606
  /** @internal Used by Flow implementations in `src/flows/`. */
4356
- buildReport(flow, patches, results, options, warnings, rebaseCounts, preRebaseCounts, patchesReverted) {
4607
+ buildReport(flow, patches, results, options, warnings, rebaseCounts, preRebaseCounts, patchesReverted, degradedReasons) {
4608
+ const degraded = dedupeDegradedReasons(degradedReasons ?? []);
4357
4609
  const conflictResults = results.filter((r) => r.status === "conflict");
4358
4610
  const conflictDetails = conflictResults.map((r) => {
4359
4611
  const conflictFiles = r.fileResults?.filter((f) => f.status === "conflict") ?? [];
@@ -4393,10 +4645,23 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
4393
4645
  conflictDetails: r.fileResults?.filter((f) => f.status === "conflict") ?? []
4394
4646
  })) : void 0,
4395
4647
  wouldApply: options?.dryRun ? patches : void 0,
4396
- warnings: warnings && warnings.length > 0 ? warnings : void 0
4648
+ warnings: warnings && warnings.length > 0 ? warnings : void 0,
4649
+ degraded: degraded.length > 0 ? true : void 0,
4650
+ degradedReasons: degraded.length > 0 ? degraded : void 0
4397
4651
  };
4398
4652
  }
4399
4653
  };
4654
+ function dedupeDegradedReasons(reasons) {
4655
+ const seen = /* @__PURE__ */ new Set();
4656
+ const out = [];
4657
+ for (const reason of reasons) {
4658
+ const key = `${reason.code}\0${reason.message}`;
4659
+ if (seen.has(key)) continue;
4660
+ seen.add(key);
4661
+ out.push(reason);
4662
+ }
4663
+ return out;
4664
+ }
4400
4665
  function computeCommitBuckets(results, absorbedPatchIds, patchesPassedToCommit) {
4401
4666
  const resultByPatchId = /* @__PURE__ */ new Map();
4402
4667
  for (const r of results) resultByPatchId.set(r.patch.id, r);
@@ -4445,7 +4710,7 @@ function computeLegacyFernignoreCleanse(patch, fernignorePatterns, computeConten
4445
4710
  }
4446
4711
  }
4447
4712
  }
4448
- const { stripped: patchContentStripped, content: newPatchContent, strippedSectionPaths } = stripFernignoreDiffSections(patch.patch_content, matchesFernignore);
4713
+ const { stripped: patchContentStripped, content: newPatchContent, strippedSectionPaths } = stripDiffSectionsByPredicate(patch.patch_content, matchesFernignore);
4449
4714
  const unchanged = strippedFromFiles.length === 0 && !snapshotChanged && !patchContentStripped;
4450
4715
  if (unchanged) {
4451
4716
  return {
@@ -4480,7 +4745,38 @@ function computeLegacyFernignoreCleanse(patch, fernignorePatterns, computeConten
4480
4745
  strippedPaths
4481
4746
  };
4482
4747
  }
4483
- function stripFernignoreDiffSections(patchContent, matchesFernignore) {
4748
+ function isInfrastructureFile(filePath) {
4749
+ return filePath === ".fernignore" || filePath.startsWith(".fern/");
4750
+ }
4751
+ function computeInfrastructureFileCleanse(patch, computeContentHash2) {
4752
+ const { stripped, content: newPatchContent, strippedSectionPaths } = stripDiffSectionsByPredicate(patch.patch_content, isInfrastructureFile);
4753
+ if (!stripped) {
4754
+ return {
4755
+ unchanged: true,
4756
+ remove: false,
4757
+ patch_content: patch.patch_content,
4758
+ content_hash: patch.content_hash,
4759
+ strippedPaths: []
4760
+ };
4761
+ }
4762
+ if (newPatchContent.trim() === "" || patch.files.length === 0) {
4763
+ return {
4764
+ unchanged: false,
4765
+ remove: true,
4766
+ patch_content: "",
4767
+ content_hash: "",
4768
+ strippedPaths: strippedSectionPaths
4769
+ };
4770
+ }
4771
+ return {
4772
+ unchanged: false,
4773
+ remove: false,
4774
+ patch_content: newPatchContent,
4775
+ content_hash: computeContentHash2(newPatchContent),
4776
+ strippedPaths: strippedSectionPaths
4777
+ };
4778
+ }
4779
+ function stripDiffSectionsByPredicate(patchContent, shouldStrip) {
4484
4780
  const lines = patchContent.split("\n");
4485
4781
  const sectionStarts = [];
4486
4782
  for (let i = 0; i < lines.length; i++) {
@@ -4502,7 +4798,7 @@ function stripFernignoreDiffSections(patchContent, matchesFernignore) {
4502
4798
  const section = sectionStarts[i];
4503
4799
  const next = sectionStarts[i + 1];
4504
4800
  const endIdx = next ? next.idx : lines.length;
4505
- if (section.path !== null && matchesFernignore(section.path)) {
4801
+ if (section.path !== null && shouldStrip(section.path)) {
4506
4802
  stripped = true;
4507
4803
  strippedSectionPaths.push(section.path);
4508
4804
  continue;
@@ -4514,8 +4810,8 @@ function stripFernignoreDiffSections(patchContent, matchesFernignore) {
4514
4810
 
4515
4811
  // src/FernignoreMigrator.ts
4516
4812
  import { createHash as createHash2 } from "crypto";
4517
- import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, statSync, writeFileSync as writeFileSync3 } from "fs";
4518
- import { dirname as dirname5, join as join7 } from "path";
4813
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync4, statSync, writeFileSync as writeFileSync3 } from "fs";
4814
+ import { dirname as dirname5, join as join8 } from "path";
4519
4815
  import { minimatch as minimatch6 } from "minimatch";
4520
4816
  import { parse as parse2, stringify as stringify2 } from "yaml";
4521
4817
  var FernignoreMigrator = class {
@@ -4528,14 +4824,14 @@ var FernignoreMigrator = class {
4528
4824
  this.outputDir = outputDir;
4529
4825
  }
4530
4826
  fernignoreExists() {
4531
- return existsSync3(join7(this.outputDir, ".fernignore"));
4827
+ return existsSync3(join8(this.outputDir, ".fernignore"));
4532
4828
  }
4533
4829
  readFernignorePatterns() {
4534
- const fernignorePath = join7(this.outputDir, ".fernignore");
4830
+ const fernignorePath = join8(this.outputDir, ".fernignore");
4535
4831
  if (!existsSync3(fernignorePath)) {
4536
4832
  return [];
4537
4833
  }
4538
- const content = readFileSync3(fernignorePath, "utf-8");
4834
+ const content = readFileSync4(fernignorePath, "utf-8");
4539
4835
  return content.split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
4540
4836
  }
4541
4837
  /** Analyze .fernignore patterns vs git history. Creates synthetic patches for files differing from pristine generation. */
@@ -4640,7 +4936,7 @@ var FernignoreMigrator = class {
4640
4936
  return results;
4641
4937
  }
4642
4938
  readFileContent(filePath) {
4643
- const fullPath = join7(this.outputDir, filePath);
4939
+ const fullPath = join8(this.outputDir, filePath);
4644
4940
  if (!existsSync3(fullPath)) {
4645
4941
  return null;
4646
4942
  }
@@ -4649,7 +4945,7 @@ var FernignoreMigrator = class {
4649
4945
  if (stat.isDirectory()) {
4650
4946
  return null;
4651
4947
  }
4652
- return readFileSync3(fullPath, "utf-8");
4948
+ return readFileSync4(fullPath, "utf-8");
4653
4949
  } catch {
4654
4950
  return null;
4655
4951
  }
@@ -4705,10 +5001,10 @@ var FernignoreMigrator = class {
4705
5001
  };
4706
5002
  }
4707
5003
  movePatternsToReplayYml(patterns) {
4708
- const replayYmlPath = join7(this.outputDir, ".fern", "replay.yml");
5004
+ const replayYmlPath = join8(this.outputDir, ".fern", "replay.yml");
4709
5005
  let config = {};
4710
5006
  if (existsSync3(replayYmlPath)) {
4711
- const content = readFileSync3(replayYmlPath, "utf-8");
5007
+ const content = readFileSync4(replayYmlPath, "utf-8");
4712
5008
  config = parse2(content) ?? {};
4713
5009
  }
4714
5010
  const existing = config.exclude ?? [];
@@ -4724,8 +5020,8 @@ var FernignoreMigrator = class {
4724
5020
 
4725
5021
  // src/commands/bootstrap.ts
4726
5022
  import { createHash as createHash3 } from "crypto";
4727
- import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
4728
- import { join as join8 } from "path";
5023
+ import { existsSync as existsSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
5024
+ import { join as join9 } from "path";
4729
5025
  init_GitClient();
4730
5026
  async function bootstrap(outputDir, options) {
4731
5027
  const git = new GitClient(outputDir);
@@ -4892,10 +5188,10 @@ function parseGitLog(log) {
4892
5188
  }
4893
5189
  var REPLAY_FERNIGNORE_ENTRIES = [".fern/replay.lock", ".fern/replay.yml", ".gitattributes"];
4894
5190
  function ensureFernignoreEntries(outputDir) {
4895
- const fernignorePath = join8(outputDir, ".fernignore");
5191
+ const fernignorePath = join9(outputDir, ".fernignore");
4896
5192
  let content = "";
4897
5193
  if (existsSync4(fernignorePath)) {
4898
- content = readFileSync4(fernignorePath, "utf-8");
5194
+ content = readFileSync5(fernignorePath, "utf-8");
4899
5195
  }
4900
5196
  const lines = content.split("\n");
4901
5197
  const toAdd = [];
@@ -4916,10 +5212,10 @@ function ensureFernignoreEntries(outputDir) {
4916
5212
  }
4917
5213
  var GITATTRIBUTES_ENTRIES = [".fern/replay.lock linguist-generated=true"];
4918
5214
  function ensureGitattributesEntries(outputDir) {
4919
- const gitattributesPath = join8(outputDir, ".gitattributes");
5215
+ const gitattributesPath = join9(outputDir, ".gitattributes");
4920
5216
  let content = "";
4921
5217
  if (existsSync4(gitattributesPath)) {
4922
- content = readFileSync4(gitattributesPath, "utf-8");
5218
+ content = readFileSync5(gitattributesPath, "utf-8");
4923
5219
  }
4924
5220
  const lines = content.split("\n");
4925
5221
  const toAdd = [];
@@ -4946,6 +5242,7 @@ function computeContentHash(patchContent) {
4946
5242
  }
4947
5243
 
4948
5244
  // src/commands/forget.ts
5245
+ init_GitClient();
4949
5246
  import { minimatch as minimatch7 } from "minimatch";
4950
5247
  function parseDiffStat(patchContent) {
4951
5248
  let additions = 0;
@@ -5005,7 +5302,28 @@ var EMPTY_RESULT = {
5005
5302
  totalPatches: 0,
5006
5303
  warnings: []
5007
5304
  };
5008
- function forget(outputDir, options) {
5305
+ async function dismissPatches(outputDir, lockManager, patches) {
5306
+ const git = new GitClient(outputDir);
5307
+ const detector = new ReplayDetector(git, lockManager, outputDir, readFernignorePatterns(outputDir));
5308
+ for (const patch of patches) {
5309
+ const hashes = [patch.content_hash];
5310
+ if (patch.original_commit) {
5311
+ const materialized = await detector.materializeCommitPatch(patch.original_commit);
5312
+ if (materialized.status === "ok" && !hashes.includes(materialized.contentHash)) {
5313
+ hashes.push(materialized.contentHash);
5314
+ }
5315
+ }
5316
+ lockManager.addDismissal({
5317
+ patch_id: patch.id,
5318
+ original_commit: patch.original_commit,
5319
+ original_message: patch.original_message,
5320
+ content_hashes: hashes,
5321
+ dismissed_at: (/* @__PURE__ */ new Date()).toISOString(),
5322
+ via: "forget"
5323
+ });
5324
+ }
5325
+ }
5326
+ async function forget(outputDir, options) {
5009
5327
  const lockManager = new LockfileManager(outputDir);
5010
5328
  if (!lockManager.exists()) {
5011
5329
  return { ...EMPTY_RESULT };
@@ -5016,9 +5334,7 @@ function forget(outputDir, options) {
5016
5334
  const removed = lock.patches.map(toMatchedPatch);
5017
5335
  const warnings = buildWarnings(lock.patches);
5018
5336
  if (!options.dryRun) {
5019
- for (const patch of lock.patches) {
5020
- lockManager.addForgottenHash(patch.content_hash);
5021
- }
5337
+ await dismissPatches(outputDir, lockManager, lock.patches);
5022
5338
  lockManager.clearPatches();
5023
5339
  lockManager.save();
5024
5340
  }
@@ -5047,8 +5363,8 @@ function forget(outputDir, options) {
5047
5363
  }
5048
5364
  const warnings = buildWarnings(patchesToRemove);
5049
5365
  if (!options.dryRun) {
5366
+ await dismissPatches(outputDir, lockManager, patchesToRemove);
5050
5367
  for (const patch of patchesToRemove) {
5051
- lockManager.addForgottenHash(patch.content_hash);
5052
5368
  lockManager.removePatch(patch.id);
5053
5369
  }
5054
5370
  lockManager.save();
@@ -5116,7 +5432,7 @@ function reset(outputDir, options) {
5116
5432
  // src/commands/resolve.ts
5117
5433
  init_GitClient();
5118
5434
  import { readFile as readFile4 } from "fs/promises";
5119
- import { join as join9 } from "path";
5435
+ import { join as join10 } from "path";
5120
5436
  async function resolve(outputDir, options) {
5121
5437
  const lockManager = new LockfileManager(outputDir);
5122
5438
  if (!lockManager.exists()) {
@@ -5156,15 +5472,16 @@ async function resolve(outputDir, options) {
5156
5472
  const patchesToCommit = [...resolvingPatches, ...unresolvedPatches];
5157
5473
  if (patchesToCommit.length > 0) {
5158
5474
  const currentGen = lock.current_generation;
5159
- const detector = new ReplayDetector(git, lockManager, outputDir);
5475
+ const detector = new ReplayDetector(git, lockManager, outputDir, readFernignorePatterns(outputDir));
5160
5476
  const warnings = [];
5477
+ const patchesDismissed = [];
5161
5478
  let patchesResolved = 0;
5162
5479
  const pass1 = [];
5163
5480
  for (const patch of patchesToCommit) {
5164
5481
  const result = await computePerPatchDiffWithFallback(
5165
5482
  patch,
5166
5483
  (f) => git.showFile(currentGen, f),
5167
- (f) => readFile4(join9(outputDir, f), "utf-8").catch(() => null),
5484
+ (f) => readFile4(join10(outputDir, f), "utf-8").catch(() => null),
5168
5485
  (files) => git.exec(["diff", currentGen, "--", ...files]).catch(() => null)
5169
5486
  );
5170
5487
  if (result === null) {
@@ -5198,7 +5515,27 @@ async function resolve(outputDir, options) {
5198
5515
  const r = pass1[i];
5199
5516
  if (r.kind === "skip") continue;
5200
5517
  if (r.kind === "revert") {
5518
+ const hashes = [patch.content_hash];
5519
+ if (patch.original_commit) {
5520
+ const materialized = await detector.materializeCommitPatch(patch.original_commit);
5521
+ if (materialized.status === "ok" && !hashes.includes(materialized.contentHash)) {
5522
+ hashes.push(materialized.contentHash);
5523
+ } else if (materialized.status === "unreachable") {
5524
+ warnings.push(
5525
+ `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.`
5526
+ );
5527
+ }
5528
+ }
5529
+ lockManager.addDismissal({
5530
+ patch_id: patch.id,
5531
+ original_commit: patch.original_commit,
5532
+ original_message: patch.original_message,
5533
+ content_hashes: hashes,
5534
+ dismissed_at: (/* @__PURE__ */ new Date()).toISOString(),
5535
+ via: "resolve"
5536
+ });
5201
5537
  lockManager.removePatch(patch.id);
5538
+ patchesDismissed.push({ id: patch.id, message: patch.original_message });
5202
5539
  continue;
5203
5540
  }
5204
5541
  if (lastIndexByHash.get(r.hash) !== i) {
@@ -5220,7 +5557,7 @@ async function resolve(outputDir, options) {
5220
5557
  const theirsSnapshot = {};
5221
5558
  for (const file of filesForSnapshot) {
5222
5559
  if (isBinaryFile(file)) continue;
5223
- const content = await readFile4(join9(outputDir, file), "utf-8").catch(() => null);
5560
+ const content = await readFile4(join10(outputDir, file), "utf-8").catch(() => null);
5224
5561
  if (content != null) {
5225
5562
  theirsSnapshot[file] = content;
5226
5563
  }
@@ -5247,6 +5584,7 @@ async function resolve(outputDir, options) {
5247
5584
  commitSha: commitSha2,
5248
5585
  phase: "committed",
5249
5586
  patchesResolved,
5587
+ patchesDismissed: patchesDismissed.length > 0 ? patchesDismissed : void 0,
5250
5588
  warnings: warnings.length > 0 ? warnings : void 0
5251
5589
  };
5252
5590
  }
@@ -5294,7 +5632,9 @@ function status(outputDir) {
5294
5632
  lastGeneration: void 0,
5295
5633
  patches: [],
5296
5634
  unresolvedCount: 0,
5297
- excludePatterns: []
5635
+ excludePatterns: [],
5636
+ dismissed: [],
5637
+ legacyDismissedCount: 0
5298
5638
  };
5299
5639
  }
5300
5640
  const lock = lockManager.read();
@@ -5323,13 +5663,24 @@ function status(outputDir) {
5323
5663
  }
5324
5664
  const config = lockManager.getCustomizationsConfig();
5325
5665
  const excludePatterns = config.exclude ?? [];
5666
+ const dismissals = lock.dismissals ?? [];
5667
+ const dismissed = dismissals.map((d) => ({
5668
+ message: d.original_message,
5669
+ sha: d.original_commit.slice(0, 7),
5670
+ via: d.via,
5671
+ dismissedAt: d.dismissed_at
5672
+ }));
5673
+ const coveredHashes = new Set(dismissals.flatMap((d) => d.content_hashes));
5674
+ const legacyDismissedCount = (lock.forgotten_hashes ?? []).filter((h) => !coveredHashes.has(h)).length;
5326
5675
  return {
5327
5676
  initialized: true,
5328
5677
  generationCount: lock.generations.length,
5329
5678
  lastGeneration,
5330
5679
  patches,
5331
5680
  unresolvedCount,
5332
- excludePatterns
5681
+ excludePatterns,
5682
+ dismissed,
5683
+ legacyDismissedCount
5333
5684
  };
5334
5685
  }
5335
5686
  export {