@fern-api/replay 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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
  }
@@ -2671,16 +2743,25 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
2671
2743
  };
2672
2744
 
2673
2745
  // src/ReplayService.ts
2674
- var import_node_fs2 = require("fs");
2746
+ var import_node_fs3 = require("fs");
2675
2747
  var import_promises6 = require("fs/promises");
2676
- var import_node_path8 = require("path");
2748
+ var import_node_path9 = require("path");
2677
2749
  var import_minimatch5 = require("minimatch");
2678
2750
  init_GitClient();
2679
2751
 
2752
+ // src/shared/fernignore.ts
2753
+ var import_node_fs2 = require("fs");
2754
+ var import_node_path6 = require("path");
2755
+ function readFernignorePatterns(outputDir) {
2756
+ const fernignorePath = (0, import_node_path6.join)(outputDir, ".fernignore");
2757
+ if (!(0, import_node_fs2.existsSync)(fernignorePath)) return [];
2758
+ return (0, import_node_fs2.readFileSync)(fernignorePath, "utf-8").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
2759
+ }
2760
+
2680
2761
  // src/PatchRegionDiff.ts
2681
2762
  var import_promises4 = require("fs/promises");
2682
2763
  var import_node_os2 = require("os");
2683
- var import_node_path6 = require("path");
2764
+ var import_node_path7 = require("path");
2684
2765
  var import_node_child_process = require("child_process");
2685
2766
  init_HybridReconstruction();
2686
2767
  function normalizeHunkBody(hunk) {
@@ -2898,10 +2979,10 @@ function overlayRegions(currentGenLines, locInCurrentGen, workingTreeLines, locI
2898
2979
  }
2899
2980
  async function unifiedDiff(beforeContent, afterContent, filePath) {
2900
2981
  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-"));
2982
+ const tmp = await (0, import_promises4.mkdtemp)((0, import_node_path7.join)((0, import_node_os2.tmpdir)(), "fern-replay-pp-"));
2902
2983
  try {
2903
- const beforePath = (0, import_node_path6.join)(tmp, "before");
2904
- const afterPath = (0, import_node_path6.join)(tmp, "after");
2984
+ const beforePath = (0, import_node_path7.join)(tmp, "before");
2985
+ const afterPath = (0, import_node_path7.join)(tmp, "after");
2905
2986
  await (0, import_promises4.writeFile)(beforePath, beforeContent, "utf-8");
2906
2987
  await (0, import_promises4.writeFile)(afterPath, afterContent, "utf-8");
2907
2988
  const raw = await runGitDiffNoIndex(beforePath, afterPath);
@@ -3077,6 +3158,7 @@ var FirstGenerationFlow = class {
3077
3158
  patchesToApply: [],
3078
3159
  newPatchIds: /* @__PURE__ */ new Set(),
3079
3160
  detectorWarnings: [],
3161
+ detectorDegraded: [],
3080
3162
  revertedCount: 0,
3081
3163
  genSha: genRecord.commit_sha,
3082
3164
  optionsSnapshot: options
@@ -3136,6 +3218,7 @@ var SkipApplicationFlow = class {
3136
3218
  patchesToApply: [],
3137
3219
  newPatchIds: /* @__PURE__ */ new Set(),
3138
3220
  detectorWarnings: [],
3221
+ detectorDegraded: [],
3139
3222
  revertedCount: 0,
3140
3223
  genSha: genRecord.commit_sha,
3141
3224
  optionsSnapshot: options
@@ -3173,8 +3256,10 @@ var NoPatchesFlow = class {
3173
3256
  const previousGenerationSha = preLock.current_generation ?? null;
3174
3257
  let { patches: newPatches, revertedPatchIds } = await this.service.detector.detectNewPatches();
3175
3258
  const detectorWarnings = [...this.service.detector.warnings];
3259
+ const detectorDegraded = [...this.service.detector.degradedReasons];
3176
3260
  if (options?.dryRun) {
3177
3261
  const dryRunWarnings = [...detectorWarnings, ...this.service.warnings];
3262
+ const dryRunDegraded = dedupeDegradedReasons([...detectorDegraded, ...this.service.degradedReasons]);
3178
3263
  const preparedReport = {
3179
3264
  flow: "no-patches",
3180
3265
  patchesDetected: newPatches.length,
@@ -3184,7 +3269,9 @@ var NoPatchesFlow = class {
3184
3269
  patchesReverted: revertedPatchIds.length,
3185
3270
  conflicts: [],
3186
3271
  wouldApply: newPatches,
3187
- warnings: dryRunWarnings.length > 0 ? dryRunWarnings : void 0
3272
+ warnings: dryRunWarnings.length > 0 ? dryRunWarnings : void 0,
3273
+ degraded: dryRunDegraded.length > 0 ? true : void 0,
3274
+ degradedReasons: dryRunDegraded.length > 0 ? dryRunDegraded : void 0
3188
3275
  };
3189
3276
  const headSha = await this.service.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
3190
3277
  return {
@@ -3238,6 +3325,7 @@ var NoPatchesFlow = class {
3238
3325
  patchesToApply: newPatches,
3239
3326
  newPatchIds: new Set(newPatches.map((p) => p.id)),
3240
3327
  detectorWarnings,
3328
+ detectorDegraded,
3241
3329
  revertedCount: revertedPatchIds.length,
3242
3330
  genSha: genRecord.commit_sha,
3243
3331
  optionsSnapshot: options
@@ -3281,6 +3369,7 @@ var NoPatchesFlow = class {
3281
3369
  }
3282
3370
  }
3283
3371
  const warnings = [...detectorWarnings, ...this.service.warnings];
3372
+ const degradedReasons = [...prep._prepared.detectorDegraded, ...this.service.degradedReasons];
3284
3373
  return this.service.buildReport(
3285
3374
  "no-patches",
3286
3375
  newPatches,
@@ -3289,7 +3378,8 @@ var NoPatchesFlow = class {
3289
3378
  warnings,
3290
3379
  rebaseCounts,
3291
3380
  void 0,
3292
- prep._prepared.revertedCount
3381
+ prep._prepared.revertedCount,
3382
+ degradedReasons
3293
3383
  );
3294
3384
  }
3295
3385
  };
@@ -3306,6 +3396,10 @@ var NormalRegenerationFlow = class {
3306
3396
  const existingPatches2 = this.service.lockManager.getPatches();
3307
3397
  const { patches: newPatches2, revertedPatchIds: dryRunReverted } = await this.service.detector.detectNewPatches();
3308
3398
  const warnings = [...this.service.detector.warnings, ...this.service.warnings];
3399
+ const dryRunDegraded = dedupeDegradedReasons([
3400
+ ...this.service.detector.degradedReasons,
3401
+ ...this.service.degradedReasons
3402
+ ]);
3309
3403
  const allPatches2 = [...existingPatches2, ...newPatches2];
3310
3404
  const preparedReport = {
3311
3405
  flow: "normal-regeneration",
@@ -3316,7 +3410,9 @@ var NormalRegenerationFlow = class {
3316
3410
  patchesReverted: dryRunReverted.length,
3317
3411
  conflicts: [],
3318
3412
  wouldApply: allPatches2,
3319
- warnings: warnings.length > 0 ? warnings : void 0
3413
+ warnings: warnings.length > 0 ? warnings : void 0,
3414
+ degraded: dryRunDegraded.length > 0 ? true : void 0,
3415
+ degradedReasons: dryRunDegraded.length > 0 ? dryRunDegraded : void 0
3320
3416
  };
3321
3417
  const headSha = await this.service.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
3322
3418
  return {
@@ -3355,6 +3451,7 @@ var NormalRegenerationFlow = class {
3355
3451
  existingPatches = this.service.lockManager.getPatches();
3356
3452
  let { patches: newPatches, revertedPatchIds } = await this.service.detector.detectNewPatches();
3357
3453
  const detectorWarnings = [...this.service.detector.warnings];
3454
+ const detectorDegraded = [...this.service.detector.degradedReasons];
3358
3455
  if (removedByPreRebase.length > 0) {
3359
3456
  const removedOriginalCommits = new Set(removedByPreRebase.map((p) => p.original_commit));
3360
3457
  const removedOriginalMessages = new Set(removedByPreRebase.map((p) => p.original_message));
@@ -3395,6 +3492,7 @@ var NormalRegenerationFlow = class {
3395
3492
  newPatchIds: new Set(newPatches.map((p) => p.id)),
3396
3493
  preRebaseCounts,
3397
3494
  detectorWarnings,
3495
+ detectorDegraded,
3398
3496
  revertedCount: revertedPatchIds.length,
3399
3497
  genSha: genRecord.commit_sha,
3400
3498
  optionsSnapshot: options
@@ -3443,6 +3541,7 @@ var NormalRegenerationFlow = class {
3443
3541
  await this.service.committer.commitReplay(appliedCount, allPatches, void 0, { buckets });
3444
3542
  }
3445
3543
  const warnings = [...detectorWarnings, ...this.service.warnings];
3544
+ const degradedReasons = [...prep._prepared.detectorDegraded, ...this.service.degradedReasons];
3446
3545
  return this.service.buildReport(
3447
3546
  "normal-regeneration",
3448
3547
  allPatches,
@@ -3451,7 +3550,8 @@ var NormalRegenerationFlow = class {
3451
3550
  warnings,
3452
3551
  rebaseCounts,
3453
3552
  prep._prepared.preRebaseCounts,
3454
- prep._prepared.revertedCount
3553
+ prep._prepared.revertedCount,
3554
+ degradedReasons
3455
3555
  );
3456
3556
  }
3457
3557
  };
@@ -3491,6 +3591,16 @@ var ReplayService = class {
3491
3591
  * @internal Used by Flow implementations in `src/flows/`.
3492
3592
  */
3493
3593
  warnings = [];
3594
+ /**
3595
+ * Service-level structured degraded reasons, mirroring `warnings`.
3596
+ * Populated at apply time (e.g. `recordUnreachableBaseWarnings` when a
3597
+ * patch's base-generation tree is unreachable). Merged with
3598
+ * `detector.degradedReasons` into `ReplayReport.degradedReasons` by each
3599
+ * flow handler. Cleared at the top of `prepareReplay()`.
3600
+ *
3601
+ * @internal Used by Flow implementations in `src/flows/`.
3602
+ */
3603
+ degradedReasons = [];
3494
3604
  /**
3495
3605
  * @internal Test-only accessor.
3496
3606
  * Returns the ReplayApplicator instance used for the last run. Tests use this
@@ -3534,7 +3644,10 @@ var ReplayService = class {
3534
3644
  async prepareReplay(options) {
3535
3645
  this.warnings = [];
3536
3646
  this.detector.warnings.length = 0;
3647
+ this.degradedReasons = [];
3648
+ this.detector.degradedReasons.length = 0;
3537
3649
  this.cleanseLegacyFernignoreEntries();
3650
+ this.cleanseInfrastructureFileEntries();
3538
3651
  return this.selectFlow(options).prepare(options);
3539
3652
  }
3540
3653
  /**
@@ -3588,6 +3701,50 @@ var ReplayService = class {
3588
3701
  this.lockManager.save();
3589
3702
  }
3590
3703
  }
3704
+ /**
3705
+ * Migration step: strips infrastructure file diffs (`.fernignore`, `.fern/*`)
3706
+ * from existing patches' `patch_content`. These diffs were bundled by older
3707
+ * detection logic that did not scope `git diff` when only infrastructure files
3708
+ * were stripped. Patches that end up with no surviving diff sections are
3709
+ * removed entirely.
3710
+ *
3711
+ * Idempotent: a second run is a no-op because the first run already removed
3712
+ * all infrastructure sections. Per-patch try/catch ensures isolation.
3713
+ */
3714
+ cleanseInfrastructureFileEntries() {
3715
+ if (!this.lockManager.exists()) return;
3716
+ this.lockManager.read();
3717
+ let lockfileMutated = false;
3718
+ const patches = this.lockManager.getPatches();
3719
+ for (const patch of patches) {
3720
+ try {
3721
+ const result = computeInfrastructureFileCleanse(
3722
+ patch,
3723
+ (content) => this.detector.computeContentHash(content)
3724
+ );
3725
+ if (result.unchanged) continue;
3726
+ if (result.remove) {
3727
+ this.lockManager.removePatch(patch.id);
3728
+ this.warnings.push(
3729
+ `Stripped infrastructure file diffs from patch ${patch.id}: patch contained only infrastructure file changes (${result.strippedPaths.join(", ")}) \u2014 removed.`
3730
+ );
3731
+ } else {
3732
+ this.lockManager.updatePatch(patch.id, {
3733
+ patch_content: result.patch_content,
3734
+ content_hash: result.content_hash
3735
+ });
3736
+ this.warnings.push(
3737
+ `Stripped infrastructure file diffs from patch ${patch.id}: removed bundled diffs for ${result.strippedPaths.join(", ")}.`
3738
+ );
3739
+ }
3740
+ lockfileMutated = true;
3741
+ } catch {
3742
+ }
3743
+ }
3744
+ if (lockfileMutated) {
3745
+ this.lockManager.save();
3746
+ }
3747
+ }
3591
3748
  /**
3592
3749
  * Phase 2 of the two-phase replay flow. Applies patches, rebases them against
3593
3750
  * the generation, saves the lockfile, and commits `[fern-replay]` (or stages
@@ -3843,7 +4000,7 @@ var ReplayService = class {
3843
4000
  for (const file of files) {
3844
4001
  if (isBinaryFile(file)) continue;
3845
4002
  try {
3846
- const content = await (0, import_promises6.readFile)((0, import_node_path8.join)(this.outputDir, file), "utf-8");
4003
+ const content = await (0, import_promises6.readFile)((0, import_node_path9.join)(this.outputDir, file), "utf-8");
3847
4004
  snapshot[file] = content;
3848
4005
  } catch {
3849
4006
  }
@@ -4360,6 +4517,12 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
4360
4517
  );
4361
4518
  }
4362
4519
  }
4520
+ if (warned.size > 0) {
4521
+ this.degradedReasons.push({
4522
+ code: "patch-base-unreachable",
4523
+ 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.`
4524
+ });
4525
+ }
4363
4526
  }
4364
4527
  /**
4365
4528
  * After applyPatches(), strip conflict markers from conflicting files
@@ -4371,11 +4534,11 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
4371
4534
  if (result.status !== "conflict" || !result.fileResults) continue;
4372
4535
  for (const fileResult of result.fileResults) {
4373
4536
  if (fileResult.status !== "conflict") continue;
4374
- const filePath = (0, import_node_path8.join)(this.outputDir, fileResult.file);
4537
+ const filePath = (0, import_node_path9.join)(this.outputDir, fileResult.file);
4375
4538
  try {
4376
- const content = (0, import_node_fs2.readFileSync)(filePath, "utf-8");
4539
+ const content = (0, import_node_fs3.readFileSync)(filePath, "utf-8");
4377
4540
  const stripped = stripConflictMarkers(content);
4378
- (0, import_node_fs2.writeFileSync)(filePath, stripped);
4541
+ (0, import_node_fs3.writeFileSync)(filePath, stripped);
4379
4542
  } catch {
4380
4543
  }
4381
4544
  }
@@ -4403,12 +4566,11 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
4403
4566
  }
4404
4567
  /** @internal Used by Flow implementations in `src/flows/`. */
4405
4568
  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("#"));
4569
+ return readFernignorePatterns(this.outputDir);
4409
4570
  }
4410
4571
  /** @internal Used by Flow implementations in `src/flows/`. */
4411
- buildReport(flow, patches, results, options, warnings, rebaseCounts, preRebaseCounts, patchesReverted) {
4572
+ buildReport(flow, patches, results, options, warnings, rebaseCounts, preRebaseCounts, patchesReverted, degradedReasons) {
4573
+ const degraded = dedupeDegradedReasons(degradedReasons ?? []);
4412
4574
  const conflictResults = results.filter((r) => r.status === "conflict");
4413
4575
  const conflictDetails = conflictResults.map((r) => {
4414
4576
  const conflictFiles = r.fileResults?.filter((f) => f.status === "conflict") ?? [];
@@ -4448,10 +4610,23 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
4448
4610
  conflictDetails: r.fileResults?.filter((f) => f.status === "conflict") ?? []
4449
4611
  })) : void 0,
4450
4612
  wouldApply: options?.dryRun ? patches : void 0,
4451
- warnings: warnings && warnings.length > 0 ? warnings : void 0
4613
+ warnings: warnings && warnings.length > 0 ? warnings : void 0,
4614
+ degraded: degraded.length > 0 ? true : void 0,
4615
+ degradedReasons: degraded.length > 0 ? degraded : void 0
4452
4616
  };
4453
4617
  }
4454
4618
  };
4619
+ function dedupeDegradedReasons(reasons) {
4620
+ const seen = /* @__PURE__ */ new Set();
4621
+ const out = [];
4622
+ for (const reason of reasons) {
4623
+ const key = `${reason.code}\0${reason.message}`;
4624
+ if (seen.has(key)) continue;
4625
+ seen.add(key);
4626
+ out.push(reason);
4627
+ }
4628
+ return out;
4629
+ }
4455
4630
  function computeCommitBuckets(results, absorbedPatchIds, patchesPassedToCommit) {
4456
4631
  const resultByPatchId = /* @__PURE__ */ new Map();
4457
4632
  for (const r of results) resultByPatchId.set(r.patch.id, r);
@@ -4500,7 +4675,7 @@ function computeLegacyFernignoreCleanse(patch, fernignorePatterns, computeConten
4500
4675
  }
4501
4676
  }
4502
4677
  }
4503
- const { stripped: patchContentStripped, content: newPatchContent, strippedSectionPaths } = stripFernignoreDiffSections(patch.patch_content, matchesFernignore);
4678
+ const { stripped: patchContentStripped, content: newPatchContent, strippedSectionPaths } = stripDiffSectionsByPredicate(patch.patch_content, matchesFernignore);
4504
4679
  const unchanged = strippedFromFiles.length === 0 && !snapshotChanged && !patchContentStripped;
4505
4680
  if (unchanged) {
4506
4681
  return {
@@ -4535,7 +4710,38 @@ function computeLegacyFernignoreCleanse(patch, fernignorePatterns, computeConten
4535
4710
  strippedPaths
4536
4711
  };
4537
4712
  }
4538
- function stripFernignoreDiffSections(patchContent, matchesFernignore) {
4713
+ function isInfrastructureFile(filePath) {
4714
+ return filePath === ".fernignore" || filePath.startsWith(".fern/");
4715
+ }
4716
+ function computeInfrastructureFileCleanse(patch, computeContentHash2) {
4717
+ const { stripped, content: newPatchContent, strippedSectionPaths } = stripDiffSectionsByPredicate(patch.patch_content, isInfrastructureFile);
4718
+ if (!stripped) {
4719
+ return {
4720
+ unchanged: true,
4721
+ remove: false,
4722
+ patch_content: patch.patch_content,
4723
+ content_hash: patch.content_hash,
4724
+ strippedPaths: []
4725
+ };
4726
+ }
4727
+ if (newPatchContent.trim() === "" || patch.files.length === 0) {
4728
+ return {
4729
+ unchanged: false,
4730
+ remove: true,
4731
+ patch_content: "",
4732
+ content_hash: "",
4733
+ strippedPaths: strippedSectionPaths
4734
+ };
4735
+ }
4736
+ return {
4737
+ unchanged: false,
4738
+ remove: false,
4739
+ patch_content: newPatchContent,
4740
+ content_hash: computeContentHash2(newPatchContent),
4741
+ strippedPaths: strippedSectionPaths
4742
+ };
4743
+ }
4744
+ function stripDiffSectionsByPredicate(patchContent, shouldStrip) {
4539
4745
  const lines = patchContent.split("\n");
4540
4746
  const sectionStarts = [];
4541
4747
  for (let i = 0; i < lines.length; i++) {
@@ -4557,7 +4763,7 @@ function stripFernignoreDiffSections(patchContent, matchesFernignore) {
4557
4763
  const section = sectionStarts[i];
4558
4764
  const next = sectionStarts[i + 1];
4559
4765
  const endIdx = next ? next.idx : lines.length;
4560
- if (section.path !== null && matchesFernignore(section.path)) {
4766
+ if (section.path !== null && shouldStrip(section.path)) {
4561
4767
  stripped = true;
4562
4768
  strippedSectionPaths.push(section.path);
4563
4769
  continue;
@@ -4569,8 +4775,8 @@ function stripFernignoreDiffSections(patchContent, matchesFernignore) {
4569
4775
 
4570
4776
  // src/FernignoreMigrator.ts
4571
4777
  var import_node_crypto2 = require("crypto");
4572
- var import_node_fs3 = require("fs");
4573
- var import_node_path9 = require("path");
4778
+ var import_node_fs4 = require("fs");
4779
+ var import_node_path10 = require("path");
4574
4780
  var import_minimatch6 = require("minimatch");
4575
4781
  var import_yaml2 = require("yaml");
4576
4782
  var FernignoreMigrator = class {
@@ -4583,14 +4789,14 @@ var FernignoreMigrator = class {
4583
4789
  this.outputDir = outputDir;
4584
4790
  }
4585
4791
  fernignoreExists() {
4586
- return (0, import_node_fs3.existsSync)((0, import_node_path9.join)(this.outputDir, ".fernignore"));
4792
+ return (0, import_node_fs4.existsSync)((0, import_node_path10.join)(this.outputDir, ".fernignore"));
4587
4793
  }
4588
4794
  readFernignorePatterns() {
4589
- const fernignorePath = (0, import_node_path9.join)(this.outputDir, ".fernignore");
4590
- if (!(0, import_node_fs3.existsSync)(fernignorePath)) {
4795
+ const fernignorePath = (0, import_node_path10.join)(this.outputDir, ".fernignore");
4796
+ if (!(0, import_node_fs4.existsSync)(fernignorePath)) {
4591
4797
  return [];
4592
4798
  }
4593
- const content = (0, import_node_fs3.readFileSync)(fernignorePath, "utf-8");
4799
+ const content = (0, import_node_fs4.readFileSync)(fernignorePath, "utf-8");
4594
4800
  return content.split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
4595
4801
  }
4596
4802
  /** Analyze .fernignore patterns vs git history. Creates synthetic patches for files differing from pristine generation. */
@@ -4695,16 +4901,16 @@ var FernignoreMigrator = class {
4695
4901
  return results;
4696
4902
  }
4697
4903
  readFileContent(filePath) {
4698
- const fullPath = (0, import_node_path9.join)(this.outputDir, filePath);
4699
- if (!(0, import_node_fs3.existsSync)(fullPath)) {
4904
+ const fullPath = (0, import_node_path10.join)(this.outputDir, filePath);
4905
+ if (!(0, import_node_fs4.existsSync)(fullPath)) {
4700
4906
  return null;
4701
4907
  }
4702
4908
  try {
4703
- const stat = (0, import_node_fs3.statSync)(fullPath);
4909
+ const stat = (0, import_node_fs4.statSync)(fullPath);
4704
4910
  if (stat.isDirectory()) {
4705
4911
  return null;
4706
4912
  }
4707
- return (0, import_node_fs3.readFileSync)(fullPath, "utf-8");
4913
+ return (0, import_node_fs4.readFileSync)(fullPath, "utf-8");
4708
4914
  } catch {
4709
4915
  return null;
4710
4916
  }
@@ -4760,27 +4966,27 @@ var FernignoreMigrator = class {
4760
4966
  };
4761
4967
  }
4762
4968
  movePatternsToReplayYml(patterns) {
4763
- const replayYmlPath = (0, import_node_path9.join)(this.outputDir, ".fern", "replay.yml");
4969
+ const replayYmlPath = (0, import_node_path10.join)(this.outputDir, ".fern", "replay.yml");
4764
4970
  let config = {};
4765
- if ((0, import_node_fs3.existsSync)(replayYmlPath)) {
4766
- const content = (0, import_node_fs3.readFileSync)(replayYmlPath, "utf-8");
4971
+ if ((0, import_node_fs4.existsSync)(replayYmlPath)) {
4972
+ const content = (0, import_node_fs4.readFileSync)(replayYmlPath, "utf-8");
4767
4973
  config = (0, import_yaml2.parse)(content) ?? {};
4768
4974
  }
4769
4975
  const existing = config.exclude ?? [];
4770
4976
  const merged = [.../* @__PURE__ */ new Set([...existing, ...patterns])];
4771
4977
  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 });
4978
+ const dir = (0, import_node_path10.dirname)(replayYmlPath);
4979
+ if (!(0, import_node_fs4.existsSync)(dir)) {
4980
+ (0, import_node_fs4.mkdirSync)(dir, { recursive: true });
4775
4981
  }
4776
- (0, import_node_fs3.writeFileSync)(replayYmlPath, (0, import_yaml2.stringify)(config, { lineWidth: 0 }), "utf-8");
4982
+ (0, import_node_fs4.writeFileSync)(replayYmlPath, (0, import_yaml2.stringify)(config, { lineWidth: 0 }), "utf-8");
4777
4983
  }
4778
4984
  };
4779
4985
 
4780
4986
  // src/commands/bootstrap.ts
4781
4987
  var import_node_crypto3 = require("crypto");
4782
- var import_node_fs4 = require("fs");
4783
- var import_node_path10 = require("path");
4988
+ var import_node_fs5 = require("fs");
4989
+ var import_node_path11 = require("path");
4784
4990
  init_GitClient();
4785
4991
  async function bootstrap(outputDir, options) {
4786
4992
  const git = new GitClient(outputDir);
@@ -4947,10 +5153,10 @@ function parseGitLog(log) {
4947
5153
  }
4948
5154
  var REPLAY_FERNIGNORE_ENTRIES = [".fern/replay.lock", ".fern/replay.yml", ".gitattributes"];
4949
5155
  function ensureFernignoreEntries(outputDir) {
4950
- const fernignorePath = (0, import_node_path10.join)(outputDir, ".fernignore");
5156
+ const fernignorePath = (0, import_node_path11.join)(outputDir, ".fernignore");
4951
5157
  let content = "";
4952
- if ((0, import_node_fs4.existsSync)(fernignorePath)) {
4953
- content = (0, import_node_fs4.readFileSync)(fernignorePath, "utf-8");
5158
+ if ((0, import_node_fs5.existsSync)(fernignorePath)) {
5159
+ content = (0, import_node_fs5.readFileSync)(fernignorePath, "utf-8");
4954
5160
  }
4955
5161
  const lines = content.split("\n");
4956
5162
  const toAdd = [];
@@ -4966,15 +5172,15 @@ function ensureFernignoreEntries(outputDir) {
4966
5172
  content += "\n";
4967
5173
  }
4968
5174
  content += toAdd.join("\n") + "\n";
4969
- (0, import_node_fs4.writeFileSync)(fernignorePath, content, "utf-8");
5175
+ (0, import_node_fs5.writeFileSync)(fernignorePath, content, "utf-8");
4970
5176
  return true;
4971
5177
  }
4972
5178
  var GITATTRIBUTES_ENTRIES = [".fern/replay.lock linguist-generated=true"];
4973
5179
  function ensureGitattributesEntries(outputDir) {
4974
- const gitattributesPath = (0, import_node_path10.join)(outputDir, ".gitattributes");
5180
+ const gitattributesPath = (0, import_node_path11.join)(outputDir, ".gitattributes");
4975
5181
  let content = "";
4976
- if ((0, import_node_fs4.existsSync)(gitattributesPath)) {
4977
- content = (0, import_node_fs4.readFileSync)(gitattributesPath, "utf-8");
5182
+ if ((0, import_node_fs5.existsSync)(gitattributesPath)) {
5183
+ content = (0, import_node_fs5.readFileSync)(gitattributesPath, "utf-8");
4978
5184
  }
4979
5185
  const lines = content.split("\n");
4980
5186
  const toAdd = [];
@@ -4990,7 +5196,7 @@ function ensureGitattributesEntries(outputDir) {
4990
5196
  content += "\n";
4991
5197
  }
4992
5198
  content += toAdd.join("\n") + "\n";
4993
- (0, import_node_fs4.writeFileSync)(gitattributesPath, content, "utf-8");
5199
+ (0, import_node_fs5.writeFileSync)(gitattributesPath, content, "utf-8");
4994
5200
  }
4995
5201
  function computeContentHash(patchContent) {
4996
5202
  const lines = patchContent.split("\n");
@@ -5002,6 +5208,7 @@ function computeContentHash(patchContent) {
5002
5208
 
5003
5209
  // src/commands/forget.ts
5004
5210
  var import_minimatch7 = require("minimatch");
5211
+ init_GitClient();
5005
5212
  function parseDiffStat(patchContent) {
5006
5213
  let additions = 0;
5007
5214
  let deletions = 0;
@@ -5060,7 +5267,28 @@ var EMPTY_RESULT = {
5060
5267
  totalPatches: 0,
5061
5268
  warnings: []
5062
5269
  };
5063
- function forget(outputDir, options) {
5270
+ async function dismissPatches(outputDir, lockManager, patches) {
5271
+ const git = new GitClient(outputDir);
5272
+ const detector = new ReplayDetector(git, lockManager, outputDir, readFernignorePatterns(outputDir));
5273
+ for (const patch of patches) {
5274
+ const hashes = [patch.content_hash];
5275
+ if (patch.original_commit) {
5276
+ const materialized = await detector.materializeCommitPatch(patch.original_commit);
5277
+ if (materialized.status === "ok" && !hashes.includes(materialized.contentHash)) {
5278
+ hashes.push(materialized.contentHash);
5279
+ }
5280
+ }
5281
+ lockManager.addDismissal({
5282
+ patch_id: patch.id,
5283
+ original_commit: patch.original_commit,
5284
+ original_message: patch.original_message,
5285
+ content_hashes: hashes,
5286
+ dismissed_at: (/* @__PURE__ */ new Date()).toISOString(),
5287
+ via: "forget"
5288
+ });
5289
+ }
5290
+ }
5291
+ async function forget(outputDir, options) {
5064
5292
  const lockManager = new LockfileManager(outputDir);
5065
5293
  if (!lockManager.exists()) {
5066
5294
  return { ...EMPTY_RESULT };
@@ -5071,9 +5299,7 @@ function forget(outputDir, options) {
5071
5299
  const removed = lock.patches.map(toMatchedPatch);
5072
5300
  const warnings = buildWarnings(lock.patches);
5073
5301
  if (!options.dryRun) {
5074
- for (const patch of lock.patches) {
5075
- lockManager.addForgottenHash(patch.content_hash);
5076
- }
5302
+ await dismissPatches(outputDir, lockManager, lock.patches);
5077
5303
  lockManager.clearPatches();
5078
5304
  lockManager.save();
5079
5305
  }
@@ -5102,8 +5328,8 @@ function forget(outputDir, options) {
5102
5328
  }
5103
5329
  const warnings = buildWarnings(patchesToRemove);
5104
5330
  if (!options.dryRun) {
5331
+ await dismissPatches(outputDir, lockManager, patchesToRemove);
5105
5332
  for (const patch of patchesToRemove) {
5106
- lockManager.addForgottenHash(patch.content_hash);
5107
5333
  lockManager.removePatch(patch.id);
5108
5334
  }
5109
5335
  lockManager.save();
@@ -5144,7 +5370,7 @@ function forget(outputDir, options) {
5144
5370
  }
5145
5371
 
5146
5372
  // src/commands/reset.ts
5147
- var import_node_fs5 = require("fs");
5373
+ var import_node_fs6 = require("fs");
5148
5374
  function reset(outputDir, options) {
5149
5375
  const lockManager = new LockfileManager(outputDir);
5150
5376
  if (!lockManager.exists()) {
@@ -5158,7 +5384,7 @@ function reset(outputDir, options) {
5158
5384
  const lock = lockManager.read();
5159
5385
  const patchCount = lock.patches.length;
5160
5386
  if (!options?.dryRun) {
5161
- (0, import_node_fs5.unlinkSync)(lockManager.lockfilePath);
5387
+ (0, import_node_fs6.unlinkSync)(lockManager.lockfilePath);
5162
5388
  }
5163
5389
  return {
5164
5390
  success: true,
@@ -5170,7 +5396,7 @@ function reset(outputDir, options) {
5170
5396
 
5171
5397
  // src/commands/resolve.ts
5172
5398
  var import_promises7 = require("fs/promises");
5173
- var import_node_path11 = require("path");
5399
+ var import_node_path12 = require("path");
5174
5400
  init_GitClient();
5175
5401
  async function resolve(outputDir, options) {
5176
5402
  const lockManager = new LockfileManager(outputDir);
@@ -5211,15 +5437,16 @@ async function resolve(outputDir, options) {
5211
5437
  const patchesToCommit = [...resolvingPatches, ...unresolvedPatches];
5212
5438
  if (patchesToCommit.length > 0) {
5213
5439
  const currentGen = lock.current_generation;
5214
- const detector = new ReplayDetector(git, lockManager, outputDir);
5440
+ const detector = new ReplayDetector(git, lockManager, outputDir, readFernignorePatterns(outputDir));
5215
5441
  const warnings = [];
5442
+ const patchesDismissed = [];
5216
5443
  let patchesResolved = 0;
5217
5444
  const pass1 = [];
5218
5445
  for (const patch of patchesToCommit) {
5219
5446
  const result = await computePerPatchDiffWithFallback(
5220
5447
  patch,
5221
5448
  (f) => git.showFile(currentGen, f),
5222
- (f) => (0, import_promises7.readFile)((0, import_node_path11.join)(outputDir, f), "utf-8").catch(() => null),
5449
+ (f) => (0, import_promises7.readFile)((0, import_node_path12.join)(outputDir, f), "utf-8").catch(() => null),
5223
5450
  (files) => git.exec(["diff", currentGen, "--", ...files]).catch(() => null)
5224
5451
  );
5225
5452
  if (result === null) {
@@ -5253,7 +5480,27 @@ async function resolve(outputDir, options) {
5253
5480
  const r = pass1[i];
5254
5481
  if (r.kind === "skip") continue;
5255
5482
  if (r.kind === "revert") {
5483
+ const hashes = [patch.content_hash];
5484
+ if (patch.original_commit) {
5485
+ const materialized = await detector.materializeCommitPatch(patch.original_commit);
5486
+ if (materialized.status === "ok" && !hashes.includes(materialized.contentHash)) {
5487
+ hashes.push(materialized.contentHash);
5488
+ } else if (materialized.status === "unreachable") {
5489
+ warnings.push(
5490
+ `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.`
5491
+ );
5492
+ }
5493
+ }
5494
+ lockManager.addDismissal({
5495
+ patch_id: patch.id,
5496
+ original_commit: patch.original_commit,
5497
+ original_message: patch.original_message,
5498
+ content_hashes: hashes,
5499
+ dismissed_at: (/* @__PURE__ */ new Date()).toISOString(),
5500
+ via: "resolve"
5501
+ });
5256
5502
  lockManager.removePatch(patch.id);
5503
+ patchesDismissed.push({ id: patch.id, message: patch.original_message });
5257
5504
  continue;
5258
5505
  }
5259
5506
  if (lastIndexByHash.get(r.hash) !== i) {
@@ -5275,7 +5522,7 @@ async function resolve(outputDir, options) {
5275
5522
  const theirsSnapshot = {};
5276
5523
  for (const file of filesForSnapshot) {
5277
5524
  if (isBinaryFile(file)) continue;
5278
- const content = await (0, import_promises7.readFile)((0, import_node_path11.join)(outputDir, file), "utf-8").catch(() => null);
5525
+ const content = await (0, import_promises7.readFile)((0, import_node_path12.join)(outputDir, file), "utf-8").catch(() => null);
5279
5526
  if (content != null) {
5280
5527
  theirsSnapshot[file] = content;
5281
5528
  }
@@ -5302,6 +5549,7 @@ async function resolve(outputDir, options) {
5302
5549
  commitSha: commitSha2,
5303
5550
  phase: "committed",
5304
5551
  patchesResolved,
5552
+ patchesDismissed: patchesDismissed.length > 0 ? patchesDismissed : void 0,
5305
5553
  warnings: warnings.length > 0 ? warnings : void 0
5306
5554
  };
5307
5555
  }
@@ -5349,7 +5597,9 @@ function status(outputDir) {
5349
5597
  lastGeneration: void 0,
5350
5598
  patches: [],
5351
5599
  unresolvedCount: 0,
5352
- excludePatterns: []
5600
+ excludePatterns: [],
5601
+ dismissed: [],
5602
+ legacyDismissedCount: 0
5353
5603
  };
5354
5604
  }
5355
5605
  const lock = lockManager.read();
@@ -5378,13 +5628,24 @@ function status(outputDir) {
5378
5628
  }
5379
5629
  const config = lockManager.getCustomizationsConfig();
5380
5630
  const excludePatterns = config.exclude ?? [];
5631
+ const dismissals = lock.dismissals ?? [];
5632
+ const dismissed = dismissals.map((d) => ({
5633
+ message: d.original_message,
5634
+ sha: d.original_commit.slice(0, 7),
5635
+ via: d.via,
5636
+ dismissedAt: d.dismissed_at
5637
+ }));
5638
+ const coveredHashes = new Set(dismissals.flatMap((d) => d.content_hashes));
5639
+ const legacyDismissedCount = (lock.forgotten_hashes ?? []).filter((h) => !coveredHashes.has(h)).length;
5381
5640
  return {
5382
5641
  initialized: true,
5383
5642
  generationCount: lock.generations.length,
5384
5643
  lastGeneration,
5385
5644
  patches,
5386
5645
  unresolvedCount,
5387
- excludePatterns
5646
+ excludePatterns,
5647
+ dismissed,
5648
+ legacyDismissedCount
5388
5649
  };
5389
5650
  }
5390
5651
  // Annotate the CommonJS export names for ESM import in node: