@fern-api/replay 0.17.4 → 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
@@ -62,6 +62,11 @@ var init_GitClient = __esm({
62
62
  proc.stderr.on("data", (data) => {
63
63
  stderr += data.toString();
64
64
  });
65
+ proc.on("error", (err) => {
66
+ reject(new Error(`git ${args.join(" ")} failed to spawn: ${err.message}`));
67
+ });
68
+ proc.stdin.on("error", () => {
69
+ });
65
70
  proc.on("close", (code) => {
66
71
  if (code === 0) {
67
72
  resolve2(stdout);
@@ -425,15 +430,15 @@ __export(PatchApplyTheirs_exports, {
425
430
  async function applyPatchToContent(base, patchContent, filePath) {
426
431
  const fileDiff = extractFileDiff(patchContent, filePath);
427
432
  if (!fileDiff) return null;
428
- 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-"));
429
434
  const tempGit = new GitClient(tempDir);
430
435
  try {
431
436
  await tempGit.exec(["init"]);
432
437
  await tempGit.exec(["config", "user.email", "migrate@fern.com"]);
433
438
  await tempGit.exec(["config", "user.name", "Fern Replay Migration"]);
434
439
  await tempGit.exec(["config", "commit.gpgSign", "false"]);
435
- const tempFilePath = (0, import_node_path7.join)(tempDir, filePath);
436
- 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 });
437
442
  await (0, import_promises5.writeFile)(tempFilePath, base);
438
443
  await tempGit.exec(["add", filePath]);
439
444
  await tempGit.exec(["commit", "-m", `base for ${filePath}`, "--allow-empty"]);
@@ -464,13 +469,13 @@ function extractFileDiff(patchContent, filePath) {
464
469
  }
465
470
  return out.length > 0 ? out.join("\n") + "\n" : null;
466
471
  }
467
- var import_promises5, import_node_os3, import_node_path7;
472
+ var import_promises5, import_node_os3, import_node_path8;
468
473
  var init_PatchApplyTheirs = __esm({
469
474
  "src/PatchApplyTheirs.ts"() {
470
475
  "use strict";
471
476
  import_promises5 = require("fs/promises");
472
477
  import_node_os3 = require("os");
473
- import_node_path7 = require("path");
478
+ import_node_path8 = require("path");
474
479
  init_GitClient();
475
480
  }
476
481
  });
@@ -745,6 +750,22 @@ var LockfileManager = class {
745
750
  this.lock.forgotten_hashes.push(hash);
746
751
  }
747
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
+ }
748
769
  getUnresolvedPatches() {
749
770
  this.ensureLoaded();
750
771
  return this.lock.patches.filter((p) => p.status === "unresolved");
@@ -863,7 +884,8 @@ function isBinaryFile(filePath) {
863
884
  var INFRASTRUCTURE_FILES = /* @__PURE__ */ new Set([".fernignore"]);
864
885
  function matchesFernignorePattern(filePath, patterns) {
865
886
  if (patterns.length === 0) return false;
866
- return patterns.some((p) => {
887
+ return patterns.some((rawPattern) => {
888
+ const p = rawPattern.endsWith("/") ? rawPattern.slice(0, -1) : rawPattern;
867
889
  if (filePath === p) return true;
868
890
  if ((0, import_minimatch2.minimatch)(filePath, p)) return true;
869
891
  if (!p.includes("*") && !p.includes("?") && filePath.startsWith(p + "/")) return true;
@@ -911,12 +933,14 @@ async function capturePatchSnapshot(git, files, treeish) {
911
933
  return snapshot;
912
934
  }
913
935
  function filterInfrastructureAndSensitiveFiles(rawFilesOutput, fernignorePatterns) {
914
- 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/"));
915
939
  const sensitiveFiles = allFiles.filter((f) => isSensitiveFile(f));
916
940
  const nonSensitive = allFiles.filter((f) => !isSensitiveFile(f));
917
941
  const fernignoredFiles = nonSensitive.filter((f) => matchesFernignorePattern(f, fernignorePatterns));
918
942
  const files = nonSensitive.filter((f) => !matchesFernignorePattern(f, fernignorePatterns));
919
- return { files, sensitiveFiles, fernignoredFiles };
943
+ return { files, sensitiveFiles, fernignoredFiles, infrastructureFiles };
920
944
  }
921
945
  var ReplayDetector = class {
922
946
  git;
@@ -924,6 +948,15 @@ var ReplayDetector = class {
924
948
  sdkOutputDir;
925
949
  fernignorePatterns;
926
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 = [];
927
960
  constructor(git, lockManager, sdkOutputDir, fernignorePatterns = []) {
928
961
  this.git = git;
929
962
  this.lockManager = lockManager;
@@ -994,6 +1027,10 @@ var ReplayDetector = class {
994
1027
  this.warnings.push(
995
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.`
996
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
+ });
997
1034
  }
998
1035
  return { patches: [], revertedPatchIds: [] };
999
1036
  }
@@ -1002,6 +1039,12 @@ var ReplayDetector = class {
1002
1039
  this.warnings.push(
1003
1040
  `Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to tree-diff detection (likely a shallow clone).`
1004
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
+ }
1005
1048
  return this.detectPatchesViaTreeDiff(
1006
1049
  lastGen,
1007
1050
  /* commitKnownMissing */
@@ -1041,48 +1084,28 @@ var ReplayDetector = class {
1041
1084
  if (lock.patches.find((p) => p.original_commit === commit.sha)) {
1042
1085
  continue;
1043
1086
  }
1044
- const parents = await this.git.getCommitParents(commit.sha);
1045
- let patchContent;
1046
- try {
1047
- patchContent = await this.git.formatPatch(commit.sha);
1048
- } catch {
1087
+ const materialized = await this.materializeCommitPatch(commit.sha);
1088
+ if (materialized.sensitiveFiles.length > 0) {
1049
1089
  this.warnings.push(
1050
- `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.`
1051
1091
  );
1052
- continue;
1053
1092
  }
1054
- let contentHash = this.computeContentHash(patchContent);
1055
- if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
1056
- continue;
1057
- }
1058
- const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
1059
- const { files, sensitiveFiles, fernignoredFiles } = filterInfrastructureAndSensitiveFiles(
1060
- filesOutput,
1061
- this.fernignorePatterns
1062
- );
1063
- if (sensitiveFiles.length > 0) {
1093
+ if (materialized.fernignoredFiles.length > 0) {
1064
1094
  this.warnings.push(
1065
- `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.`
1066
1096
  );
1067
1097
  }
1068
- if (fernignoredFiles.length > 0) {
1098
+ if (materialized.status === "unreachable") {
1069
1099
  this.warnings.push(
1070
- `.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.`
1071
1101
  );
1102
+ continue;
1072
1103
  }
1073
- if ((sensitiveFiles.length > 0 || fernignoredFiles.length > 0) && files.length > 0) {
1074
- const parentSha = parents[0];
1075
- if (parentSha) {
1076
- try {
1077
- patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
1078
- } catch {
1079
- continue;
1080
- }
1081
- if (!patchContent.trim()) continue;
1082
- contentHash = this.computeContentHash(patchContent);
1083
- }
1104
+ if (materialized.status === "empty") {
1105
+ continue;
1084
1106
  }
1085
- if (files.length === 0) {
1107
+ const { files, patchContent, contentHash } = materialized;
1108
+ if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
1086
1109
  continue;
1087
1110
  }
1088
1111
  const theirsSnapshot = await capturePatchSnapshot(this.git, files, commit.sha);
@@ -1158,6 +1181,63 @@ var ReplayDetector = class {
1158
1181
  const filteredPatches = survivingPatches.filter((_, i) => !revertIndicesToRemove.has(i));
1159
1182
  return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
1160
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
+ }
1161
1241
  /**
1162
1242
  * Compute content hash for deduplication.
1163
1243
  *
@@ -1262,6 +1342,13 @@ var ReplayDetector = class {
1262
1342
  async detectPatchesViaTreeDiff(lastGen, commitKnownMissing) {
1263
1343
  const diffBase = await this.resolveDiffBase(lastGen, commitKnownMissing);
1264
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
+ });
1265
1352
  return this.detectPatchesViaCommitScan();
1266
1353
  }
1267
1354
  const lockForGuard = this.lockManager.read();
@@ -1374,10 +1461,7 @@ var ReplayDetector = class {
1374
1461
  continue;
1375
1462
  }
1376
1463
  const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
1377
- const { files, sensitiveFiles, fernignoredFiles } = filterInfrastructureAndSensitiveFiles(
1378
- filesOutput,
1379
- this.fernignorePatterns
1380
- );
1464
+ const { files, sensitiveFiles, fernignoredFiles, infrastructureFiles } = filterInfrastructureAndSensitiveFiles(filesOutput, this.fernignorePatterns);
1381
1465
  if (sensitiveFiles.length > 0) {
1382
1466
  this.warnings.push(
1383
1467
  `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
@@ -1388,7 +1472,8 @@ var ReplayDetector = class {
1388
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.`
1389
1473
  );
1390
1474
  }
1391
- 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) {
1392
1477
  if (parents.length === 0) {
1393
1478
  continue;
1394
1479
  }
@@ -2658,16 +2743,25 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
2658
2743
  };
2659
2744
 
2660
2745
  // src/ReplayService.ts
2661
- var import_node_fs2 = require("fs");
2746
+ var import_node_fs3 = require("fs");
2662
2747
  var import_promises6 = require("fs/promises");
2663
- var import_node_path8 = require("path");
2748
+ var import_node_path9 = require("path");
2664
2749
  var import_minimatch5 = require("minimatch");
2665
2750
  init_GitClient();
2666
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
+
2667
2761
  // src/PatchRegionDiff.ts
2668
2762
  var import_promises4 = require("fs/promises");
2669
2763
  var import_node_os2 = require("os");
2670
- var import_node_path6 = require("path");
2764
+ var import_node_path7 = require("path");
2671
2765
  var import_node_child_process = require("child_process");
2672
2766
  init_HybridReconstruction();
2673
2767
  function normalizeHunkBody(hunk) {
@@ -2885,10 +2979,10 @@ function overlayRegions(currentGenLines, locInCurrentGen, workingTreeLines, locI
2885
2979
  }
2886
2980
  async function unifiedDiff(beforeContent, afterContent, filePath) {
2887
2981
  if (beforeContent === afterContent) return "";
2888
- 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-"));
2889
2983
  try {
2890
- const beforePath = (0, import_node_path6.join)(tmp, "before");
2891
- 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");
2892
2986
  await (0, import_promises4.writeFile)(beforePath, beforeContent, "utf-8");
2893
2987
  await (0, import_promises4.writeFile)(afterPath, afterContent, "utf-8");
2894
2988
  const raw = await runGitDiffNoIndex(beforePath, afterPath);
@@ -3064,6 +3158,7 @@ var FirstGenerationFlow = class {
3064
3158
  patchesToApply: [],
3065
3159
  newPatchIds: /* @__PURE__ */ new Set(),
3066
3160
  detectorWarnings: [],
3161
+ detectorDegraded: [],
3067
3162
  revertedCount: 0,
3068
3163
  genSha: genRecord.commit_sha,
3069
3164
  optionsSnapshot: options
@@ -3123,6 +3218,7 @@ var SkipApplicationFlow = class {
3123
3218
  patchesToApply: [],
3124
3219
  newPatchIds: /* @__PURE__ */ new Set(),
3125
3220
  detectorWarnings: [],
3221
+ detectorDegraded: [],
3126
3222
  revertedCount: 0,
3127
3223
  genSha: genRecord.commit_sha,
3128
3224
  optionsSnapshot: options
@@ -3160,8 +3256,10 @@ var NoPatchesFlow = class {
3160
3256
  const previousGenerationSha = preLock.current_generation ?? null;
3161
3257
  let { patches: newPatches, revertedPatchIds } = await this.service.detector.detectNewPatches();
3162
3258
  const detectorWarnings = [...this.service.detector.warnings];
3259
+ const detectorDegraded = [...this.service.detector.degradedReasons];
3163
3260
  if (options?.dryRun) {
3164
3261
  const dryRunWarnings = [...detectorWarnings, ...this.service.warnings];
3262
+ const dryRunDegraded = dedupeDegradedReasons([...detectorDegraded, ...this.service.degradedReasons]);
3165
3263
  const preparedReport = {
3166
3264
  flow: "no-patches",
3167
3265
  patchesDetected: newPatches.length,
@@ -3171,7 +3269,9 @@ var NoPatchesFlow = class {
3171
3269
  patchesReverted: revertedPatchIds.length,
3172
3270
  conflicts: [],
3173
3271
  wouldApply: newPatches,
3174
- 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
3175
3275
  };
3176
3276
  const headSha = await this.service.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
3177
3277
  return {
@@ -3225,6 +3325,7 @@ var NoPatchesFlow = class {
3225
3325
  patchesToApply: newPatches,
3226
3326
  newPatchIds: new Set(newPatches.map((p) => p.id)),
3227
3327
  detectorWarnings,
3328
+ detectorDegraded,
3228
3329
  revertedCount: revertedPatchIds.length,
3229
3330
  genSha: genRecord.commit_sha,
3230
3331
  optionsSnapshot: options
@@ -3268,6 +3369,7 @@ var NoPatchesFlow = class {
3268
3369
  }
3269
3370
  }
3270
3371
  const warnings = [...detectorWarnings, ...this.service.warnings];
3372
+ const degradedReasons = [...prep._prepared.detectorDegraded, ...this.service.degradedReasons];
3271
3373
  return this.service.buildReport(
3272
3374
  "no-patches",
3273
3375
  newPatches,
@@ -3276,7 +3378,8 @@ var NoPatchesFlow = class {
3276
3378
  warnings,
3277
3379
  rebaseCounts,
3278
3380
  void 0,
3279
- prep._prepared.revertedCount
3381
+ prep._prepared.revertedCount,
3382
+ degradedReasons
3280
3383
  );
3281
3384
  }
3282
3385
  };
@@ -3293,6 +3396,10 @@ var NormalRegenerationFlow = class {
3293
3396
  const existingPatches2 = this.service.lockManager.getPatches();
3294
3397
  const { patches: newPatches2, revertedPatchIds: dryRunReverted } = await this.service.detector.detectNewPatches();
3295
3398
  const warnings = [...this.service.detector.warnings, ...this.service.warnings];
3399
+ const dryRunDegraded = dedupeDegradedReasons([
3400
+ ...this.service.detector.degradedReasons,
3401
+ ...this.service.degradedReasons
3402
+ ]);
3296
3403
  const allPatches2 = [...existingPatches2, ...newPatches2];
3297
3404
  const preparedReport = {
3298
3405
  flow: "normal-regeneration",
@@ -3303,7 +3410,9 @@ var NormalRegenerationFlow = class {
3303
3410
  patchesReverted: dryRunReverted.length,
3304
3411
  conflicts: [],
3305
3412
  wouldApply: allPatches2,
3306
- 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
3307
3416
  };
3308
3417
  const headSha = await this.service.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
3309
3418
  return {
@@ -3342,6 +3451,7 @@ var NormalRegenerationFlow = class {
3342
3451
  existingPatches = this.service.lockManager.getPatches();
3343
3452
  let { patches: newPatches, revertedPatchIds } = await this.service.detector.detectNewPatches();
3344
3453
  const detectorWarnings = [...this.service.detector.warnings];
3454
+ const detectorDegraded = [...this.service.detector.degradedReasons];
3345
3455
  if (removedByPreRebase.length > 0) {
3346
3456
  const removedOriginalCommits = new Set(removedByPreRebase.map((p) => p.original_commit));
3347
3457
  const removedOriginalMessages = new Set(removedByPreRebase.map((p) => p.original_message));
@@ -3382,6 +3492,7 @@ var NormalRegenerationFlow = class {
3382
3492
  newPatchIds: new Set(newPatches.map((p) => p.id)),
3383
3493
  preRebaseCounts,
3384
3494
  detectorWarnings,
3495
+ detectorDegraded,
3385
3496
  revertedCount: revertedPatchIds.length,
3386
3497
  genSha: genRecord.commit_sha,
3387
3498
  optionsSnapshot: options
@@ -3430,6 +3541,7 @@ var NormalRegenerationFlow = class {
3430
3541
  await this.service.committer.commitReplay(appliedCount, allPatches, void 0, { buckets });
3431
3542
  }
3432
3543
  const warnings = [...detectorWarnings, ...this.service.warnings];
3544
+ const degradedReasons = [...prep._prepared.detectorDegraded, ...this.service.degradedReasons];
3433
3545
  return this.service.buildReport(
3434
3546
  "normal-regeneration",
3435
3547
  allPatches,
@@ -3438,7 +3550,8 @@ var NormalRegenerationFlow = class {
3438
3550
  warnings,
3439
3551
  rebaseCounts,
3440
3552
  prep._prepared.preRebaseCounts,
3441
- prep._prepared.revertedCount
3553
+ prep._prepared.revertedCount,
3554
+ degradedReasons
3442
3555
  );
3443
3556
  }
3444
3557
  };
@@ -3478,6 +3591,16 @@ var ReplayService = class {
3478
3591
  * @internal Used by Flow implementations in `src/flows/`.
3479
3592
  */
3480
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 = [];
3481
3604
  /**
3482
3605
  * @internal Test-only accessor.
3483
3606
  * Returns the ReplayApplicator instance used for the last run. Tests use this
@@ -3521,7 +3644,10 @@ var ReplayService = class {
3521
3644
  async prepareReplay(options) {
3522
3645
  this.warnings = [];
3523
3646
  this.detector.warnings.length = 0;
3647
+ this.degradedReasons = [];
3648
+ this.detector.degradedReasons.length = 0;
3524
3649
  this.cleanseLegacyFernignoreEntries();
3650
+ this.cleanseInfrastructureFileEntries();
3525
3651
  return this.selectFlow(options).prepare(options);
3526
3652
  }
3527
3653
  /**
@@ -3575,6 +3701,50 @@ var ReplayService = class {
3575
3701
  this.lockManager.save();
3576
3702
  }
3577
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
+ }
3578
3748
  /**
3579
3749
  * Phase 2 of the two-phase replay flow. Applies patches, rebases them against
3580
3750
  * the generation, saves the lockfile, and commits `[fern-replay]` (or stages
@@ -3830,7 +4000,7 @@ var ReplayService = class {
3830
4000
  for (const file of files) {
3831
4001
  if (isBinaryFile(file)) continue;
3832
4002
  try {
3833
- 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");
3834
4004
  snapshot[file] = content;
3835
4005
  } catch {
3836
4006
  }
@@ -4347,6 +4517,12 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
4347
4517
  );
4348
4518
  }
4349
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
+ }
4350
4526
  }
4351
4527
  /**
4352
4528
  * After applyPatches(), strip conflict markers from conflicting files
@@ -4358,11 +4534,11 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
4358
4534
  if (result.status !== "conflict" || !result.fileResults) continue;
4359
4535
  for (const fileResult of result.fileResults) {
4360
4536
  if (fileResult.status !== "conflict") continue;
4361
- const filePath = (0, import_node_path8.join)(this.outputDir, fileResult.file);
4537
+ const filePath = (0, import_node_path9.join)(this.outputDir, fileResult.file);
4362
4538
  try {
4363
- const content = (0, import_node_fs2.readFileSync)(filePath, "utf-8");
4539
+ const content = (0, import_node_fs3.readFileSync)(filePath, "utf-8");
4364
4540
  const stripped = stripConflictMarkers(content);
4365
- (0, import_node_fs2.writeFileSync)(filePath, stripped);
4541
+ (0, import_node_fs3.writeFileSync)(filePath, stripped);
4366
4542
  } catch {
4367
4543
  }
4368
4544
  }
@@ -4390,12 +4566,11 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
4390
4566
  }
4391
4567
  /** @internal Used by Flow implementations in `src/flows/`. */
4392
4568
  readFernignorePatterns() {
4393
- const fernignorePath = (0, import_node_path8.join)(this.outputDir, ".fernignore");
4394
- if (!(0, import_node_fs2.existsSync)(fernignorePath)) return [];
4395
- 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);
4396
4570
  }
4397
4571
  /** @internal Used by Flow implementations in `src/flows/`. */
4398
- 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 ?? []);
4399
4574
  const conflictResults = results.filter((r) => r.status === "conflict");
4400
4575
  const conflictDetails = conflictResults.map((r) => {
4401
4576
  const conflictFiles = r.fileResults?.filter((f) => f.status === "conflict") ?? [];
@@ -4435,10 +4610,23 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
4435
4610
  conflictDetails: r.fileResults?.filter((f) => f.status === "conflict") ?? []
4436
4611
  })) : void 0,
4437
4612
  wouldApply: options?.dryRun ? patches : void 0,
4438
- 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
4439
4616
  };
4440
4617
  }
4441
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
+ }
4442
4630
  function computeCommitBuckets(results, absorbedPatchIds, patchesPassedToCommit) {
4443
4631
  const resultByPatchId = /* @__PURE__ */ new Map();
4444
4632
  for (const r of results) resultByPatchId.set(r.patch.id, r);
@@ -4487,7 +4675,7 @@ function computeLegacyFernignoreCleanse(patch, fernignorePatterns, computeConten
4487
4675
  }
4488
4676
  }
4489
4677
  }
4490
- const { stripped: patchContentStripped, content: newPatchContent, strippedSectionPaths } = stripFernignoreDiffSections(patch.patch_content, matchesFernignore);
4678
+ const { stripped: patchContentStripped, content: newPatchContent, strippedSectionPaths } = stripDiffSectionsByPredicate(patch.patch_content, matchesFernignore);
4491
4679
  const unchanged = strippedFromFiles.length === 0 && !snapshotChanged && !patchContentStripped;
4492
4680
  if (unchanged) {
4493
4681
  return {
@@ -4522,7 +4710,38 @@ function computeLegacyFernignoreCleanse(patch, fernignorePatterns, computeConten
4522
4710
  strippedPaths
4523
4711
  };
4524
4712
  }
4525
- 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) {
4526
4745
  const lines = patchContent.split("\n");
4527
4746
  const sectionStarts = [];
4528
4747
  for (let i = 0; i < lines.length; i++) {
@@ -4544,7 +4763,7 @@ function stripFernignoreDiffSections(patchContent, matchesFernignore) {
4544
4763
  const section = sectionStarts[i];
4545
4764
  const next = sectionStarts[i + 1];
4546
4765
  const endIdx = next ? next.idx : lines.length;
4547
- if (section.path !== null && matchesFernignore(section.path)) {
4766
+ if (section.path !== null && shouldStrip(section.path)) {
4548
4767
  stripped = true;
4549
4768
  strippedSectionPaths.push(section.path);
4550
4769
  continue;
@@ -4556,8 +4775,8 @@ function stripFernignoreDiffSections(patchContent, matchesFernignore) {
4556
4775
 
4557
4776
  // src/FernignoreMigrator.ts
4558
4777
  var import_node_crypto2 = require("crypto");
4559
- var import_node_fs3 = require("fs");
4560
- var import_node_path9 = require("path");
4778
+ var import_node_fs4 = require("fs");
4779
+ var import_node_path10 = require("path");
4561
4780
  var import_minimatch6 = require("minimatch");
4562
4781
  var import_yaml2 = require("yaml");
4563
4782
  var FernignoreMigrator = class {
@@ -4570,14 +4789,14 @@ var FernignoreMigrator = class {
4570
4789
  this.outputDir = outputDir;
4571
4790
  }
4572
4791
  fernignoreExists() {
4573
- 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"));
4574
4793
  }
4575
4794
  readFernignorePatterns() {
4576
- const fernignorePath = (0, import_node_path9.join)(this.outputDir, ".fernignore");
4577
- 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)) {
4578
4797
  return [];
4579
4798
  }
4580
- const content = (0, import_node_fs3.readFileSync)(fernignorePath, "utf-8");
4799
+ const content = (0, import_node_fs4.readFileSync)(fernignorePath, "utf-8");
4581
4800
  return content.split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
4582
4801
  }
4583
4802
  /** Analyze .fernignore patterns vs git history. Creates synthetic patches for files differing from pristine generation. */
@@ -4682,16 +4901,16 @@ var FernignoreMigrator = class {
4682
4901
  return results;
4683
4902
  }
4684
4903
  readFileContent(filePath) {
4685
- const fullPath = (0, import_node_path9.join)(this.outputDir, filePath);
4686
- 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)) {
4687
4906
  return null;
4688
4907
  }
4689
4908
  try {
4690
- const stat = (0, import_node_fs3.statSync)(fullPath);
4909
+ const stat = (0, import_node_fs4.statSync)(fullPath);
4691
4910
  if (stat.isDirectory()) {
4692
4911
  return null;
4693
4912
  }
4694
- return (0, import_node_fs3.readFileSync)(fullPath, "utf-8");
4913
+ return (0, import_node_fs4.readFileSync)(fullPath, "utf-8");
4695
4914
  } catch {
4696
4915
  return null;
4697
4916
  }
@@ -4747,27 +4966,27 @@ var FernignoreMigrator = class {
4747
4966
  };
4748
4967
  }
4749
4968
  movePatternsToReplayYml(patterns) {
4750
- 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");
4751
4970
  let config = {};
4752
- if ((0, import_node_fs3.existsSync)(replayYmlPath)) {
4753
- 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");
4754
4973
  config = (0, import_yaml2.parse)(content) ?? {};
4755
4974
  }
4756
4975
  const existing = config.exclude ?? [];
4757
4976
  const merged = [.../* @__PURE__ */ new Set([...existing, ...patterns])];
4758
4977
  config.exclude = merged;
4759
- const dir = (0, import_node_path9.dirname)(replayYmlPath);
4760
- if (!(0, import_node_fs3.existsSync)(dir)) {
4761
- (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 });
4762
4981
  }
4763
- (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");
4764
4983
  }
4765
4984
  };
4766
4985
 
4767
4986
  // src/commands/bootstrap.ts
4768
4987
  var import_node_crypto3 = require("crypto");
4769
- var import_node_fs4 = require("fs");
4770
- var import_node_path10 = require("path");
4988
+ var import_node_fs5 = require("fs");
4989
+ var import_node_path11 = require("path");
4771
4990
  init_GitClient();
4772
4991
  async function bootstrap(outputDir, options) {
4773
4992
  const git = new GitClient(outputDir);
@@ -4934,10 +5153,10 @@ function parseGitLog(log) {
4934
5153
  }
4935
5154
  var REPLAY_FERNIGNORE_ENTRIES = [".fern/replay.lock", ".fern/replay.yml", ".gitattributes"];
4936
5155
  function ensureFernignoreEntries(outputDir) {
4937
- const fernignorePath = (0, import_node_path10.join)(outputDir, ".fernignore");
5156
+ const fernignorePath = (0, import_node_path11.join)(outputDir, ".fernignore");
4938
5157
  let content = "";
4939
- if ((0, import_node_fs4.existsSync)(fernignorePath)) {
4940
- 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");
4941
5160
  }
4942
5161
  const lines = content.split("\n");
4943
5162
  const toAdd = [];
@@ -4953,15 +5172,15 @@ function ensureFernignoreEntries(outputDir) {
4953
5172
  content += "\n";
4954
5173
  }
4955
5174
  content += toAdd.join("\n") + "\n";
4956
- (0, import_node_fs4.writeFileSync)(fernignorePath, content, "utf-8");
5175
+ (0, import_node_fs5.writeFileSync)(fernignorePath, content, "utf-8");
4957
5176
  return true;
4958
5177
  }
4959
5178
  var GITATTRIBUTES_ENTRIES = [".fern/replay.lock linguist-generated=true"];
4960
5179
  function ensureGitattributesEntries(outputDir) {
4961
- const gitattributesPath = (0, import_node_path10.join)(outputDir, ".gitattributes");
5180
+ const gitattributesPath = (0, import_node_path11.join)(outputDir, ".gitattributes");
4962
5181
  let content = "";
4963
- if ((0, import_node_fs4.existsSync)(gitattributesPath)) {
4964
- 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");
4965
5184
  }
4966
5185
  const lines = content.split("\n");
4967
5186
  const toAdd = [];
@@ -4977,7 +5196,7 @@ function ensureGitattributesEntries(outputDir) {
4977
5196
  content += "\n";
4978
5197
  }
4979
5198
  content += toAdd.join("\n") + "\n";
4980
- (0, import_node_fs4.writeFileSync)(gitattributesPath, content, "utf-8");
5199
+ (0, import_node_fs5.writeFileSync)(gitattributesPath, content, "utf-8");
4981
5200
  }
4982
5201
  function computeContentHash(patchContent) {
4983
5202
  const lines = patchContent.split("\n");
@@ -4989,6 +5208,7 @@ function computeContentHash(patchContent) {
4989
5208
 
4990
5209
  // src/commands/forget.ts
4991
5210
  var import_minimatch7 = require("minimatch");
5211
+ init_GitClient();
4992
5212
  function parseDiffStat(patchContent) {
4993
5213
  let additions = 0;
4994
5214
  let deletions = 0;
@@ -5047,7 +5267,28 @@ var EMPTY_RESULT = {
5047
5267
  totalPatches: 0,
5048
5268
  warnings: []
5049
5269
  };
5050
- 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) {
5051
5292
  const lockManager = new LockfileManager(outputDir);
5052
5293
  if (!lockManager.exists()) {
5053
5294
  return { ...EMPTY_RESULT };
@@ -5058,9 +5299,7 @@ function forget(outputDir, options) {
5058
5299
  const removed = lock.patches.map(toMatchedPatch);
5059
5300
  const warnings = buildWarnings(lock.patches);
5060
5301
  if (!options.dryRun) {
5061
- for (const patch of lock.patches) {
5062
- lockManager.addForgottenHash(patch.content_hash);
5063
- }
5302
+ await dismissPatches(outputDir, lockManager, lock.patches);
5064
5303
  lockManager.clearPatches();
5065
5304
  lockManager.save();
5066
5305
  }
@@ -5089,8 +5328,8 @@ function forget(outputDir, options) {
5089
5328
  }
5090
5329
  const warnings = buildWarnings(patchesToRemove);
5091
5330
  if (!options.dryRun) {
5331
+ await dismissPatches(outputDir, lockManager, patchesToRemove);
5092
5332
  for (const patch of patchesToRemove) {
5093
- lockManager.addForgottenHash(patch.content_hash);
5094
5333
  lockManager.removePatch(patch.id);
5095
5334
  }
5096
5335
  lockManager.save();
@@ -5131,7 +5370,7 @@ function forget(outputDir, options) {
5131
5370
  }
5132
5371
 
5133
5372
  // src/commands/reset.ts
5134
- var import_node_fs5 = require("fs");
5373
+ var import_node_fs6 = require("fs");
5135
5374
  function reset(outputDir, options) {
5136
5375
  const lockManager = new LockfileManager(outputDir);
5137
5376
  if (!lockManager.exists()) {
@@ -5145,7 +5384,7 @@ function reset(outputDir, options) {
5145
5384
  const lock = lockManager.read();
5146
5385
  const patchCount = lock.patches.length;
5147
5386
  if (!options?.dryRun) {
5148
- (0, import_node_fs5.unlinkSync)(lockManager.lockfilePath);
5387
+ (0, import_node_fs6.unlinkSync)(lockManager.lockfilePath);
5149
5388
  }
5150
5389
  return {
5151
5390
  success: true,
@@ -5157,7 +5396,7 @@ function reset(outputDir, options) {
5157
5396
 
5158
5397
  // src/commands/resolve.ts
5159
5398
  var import_promises7 = require("fs/promises");
5160
- var import_node_path11 = require("path");
5399
+ var import_node_path12 = require("path");
5161
5400
  init_GitClient();
5162
5401
  async function resolve(outputDir, options) {
5163
5402
  const lockManager = new LockfileManager(outputDir);
@@ -5198,15 +5437,16 @@ async function resolve(outputDir, options) {
5198
5437
  const patchesToCommit = [...resolvingPatches, ...unresolvedPatches];
5199
5438
  if (patchesToCommit.length > 0) {
5200
5439
  const currentGen = lock.current_generation;
5201
- const detector = new ReplayDetector(git, lockManager, outputDir);
5440
+ const detector = new ReplayDetector(git, lockManager, outputDir, readFernignorePatterns(outputDir));
5202
5441
  const warnings = [];
5442
+ const patchesDismissed = [];
5203
5443
  let patchesResolved = 0;
5204
5444
  const pass1 = [];
5205
5445
  for (const patch of patchesToCommit) {
5206
5446
  const result = await computePerPatchDiffWithFallback(
5207
5447
  patch,
5208
5448
  (f) => git.showFile(currentGen, f),
5209
- (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),
5210
5450
  (files) => git.exec(["diff", currentGen, "--", ...files]).catch(() => null)
5211
5451
  );
5212
5452
  if (result === null) {
@@ -5240,7 +5480,27 @@ async function resolve(outputDir, options) {
5240
5480
  const r = pass1[i];
5241
5481
  if (r.kind === "skip") continue;
5242
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
+ });
5243
5502
  lockManager.removePatch(patch.id);
5503
+ patchesDismissed.push({ id: patch.id, message: patch.original_message });
5244
5504
  continue;
5245
5505
  }
5246
5506
  if (lastIndexByHash.get(r.hash) !== i) {
@@ -5262,7 +5522,7 @@ async function resolve(outputDir, options) {
5262
5522
  const theirsSnapshot = {};
5263
5523
  for (const file of filesForSnapshot) {
5264
5524
  if (isBinaryFile(file)) continue;
5265
- 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);
5266
5526
  if (content != null) {
5267
5527
  theirsSnapshot[file] = content;
5268
5528
  }
@@ -5289,6 +5549,7 @@ async function resolve(outputDir, options) {
5289
5549
  commitSha: commitSha2,
5290
5550
  phase: "committed",
5291
5551
  patchesResolved,
5552
+ patchesDismissed: patchesDismissed.length > 0 ? patchesDismissed : void 0,
5292
5553
  warnings: warnings.length > 0 ? warnings : void 0
5293
5554
  };
5294
5555
  }
@@ -5336,7 +5597,9 @@ function status(outputDir) {
5336
5597
  lastGeneration: void 0,
5337
5598
  patches: [],
5338
5599
  unresolvedCount: 0,
5339
- excludePatterns: []
5600
+ excludePatterns: [],
5601
+ dismissed: [],
5602
+ legacyDismissedCount: 0
5340
5603
  };
5341
5604
  }
5342
5605
  const lock = lockManager.read();
@@ -5365,13 +5628,24 @@ function status(outputDir) {
5365
5628
  }
5366
5629
  const config = lockManager.getCustomizationsConfig();
5367
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;
5368
5640
  return {
5369
5641
  initialized: true,
5370
5642
  generationCount: lock.generations.length,
5371
5643
  lastGeneration,
5372
5644
  patches,
5373
5645
  unresolvedCount,
5374
- excludePatterns
5646
+ excludePatterns,
5647
+ dismissed,
5648
+ legacyDismissedCount
5375
5649
  };
5376
5650
  }
5377
5651
  // Annotate the CommonJS export names for ESM import in node: