@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.js CHANGED
@@ -407,18 +407,18 @@ __export(PatchApplyTheirs_exports, {
407
407
  });
408
408
  import { mkdtemp as mkdtemp3, mkdir as mkdir3, writeFile as writeFile4, readFile as readFile3, rm as rm3 } from "fs/promises";
409
409
  import { tmpdir as tmpdir3 } from "os";
410
- import { dirname as dirname4, join as join5 } from "path";
410
+ import { dirname as dirname4, join as join6 } from "path";
411
411
  async function applyPatchToContent(base, patchContent, filePath) {
412
412
  const fileDiff = extractFileDiff(patchContent, filePath);
413
413
  if (!fileDiff) return null;
414
- const tempDir = await mkdtemp3(join5(tmpdir3(), "replay-migrate-"));
414
+ const tempDir = await mkdtemp3(join6(tmpdir3(), "replay-migrate-"));
415
415
  const tempGit = new GitClient(tempDir);
416
416
  try {
417
417
  await tempGit.exec(["init"]);
418
418
  await tempGit.exec(["config", "user.email", "migrate@fern.com"]);
419
419
  await tempGit.exec(["config", "user.name", "Fern Replay Migration"]);
420
420
  await tempGit.exec(["config", "commit.gpgSign", "false"]);
421
- const tempFilePath = join5(tempDir, filePath);
421
+ const tempFilePath = join6(tempDir, filePath);
422
422
  await mkdir3(dirname4(tempFilePath), { recursive: true });
423
423
  await writeFile4(tempFilePath, base);
424
424
  await tempGit.exec(["add", filePath]);
@@ -695,6 +695,22 @@ var LockfileManager = class {
695
695
  this.lock.forgotten_hashes.push(hash);
696
696
  }
697
697
  }
698
+ /**
699
+ * Record a dismissed customization: visibility metadata in `dismissals`
700
+ * plus a tombstone in `forgotten_hashes` for every content hash.
701
+ * `forgotten_hashes` stays the only list the detector consults; the
702
+ * metadata exists so `status` can show what was dismissed.
703
+ */
704
+ addDismissal(record) {
705
+ this.ensureLoaded();
706
+ if (!this.lock.dismissals) {
707
+ this.lock.dismissals = [];
708
+ }
709
+ this.lock.dismissals.push(record);
710
+ for (const hash of record.content_hashes) {
711
+ this.addForgottenHash(hash);
712
+ }
713
+ }
698
714
  getUnresolvedPatches() {
699
715
  this.ensureLoaded();
700
716
  return this.lock.patches.filter((p) => p.status === "unresolved");
@@ -862,12 +878,14 @@ async function capturePatchSnapshot(git, files, treeish) {
862
878
  return snapshot;
863
879
  }
864
880
  function filterInfrastructureAndSensitiveFiles(rawFilesOutput, fernignorePatterns) {
865
- const allFiles = rawFilesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
881
+ const rawFiles = rawFilesOutput.trim().split("\n").filter(Boolean);
882
+ const infrastructureFiles = rawFiles.filter((f) => INFRASTRUCTURE_FILES.has(f) || f.startsWith(".fern/"));
883
+ const allFiles = rawFiles.filter((f) => !INFRASTRUCTURE_FILES.has(f) && !f.startsWith(".fern/"));
866
884
  const sensitiveFiles = allFiles.filter((f) => isSensitiveFile(f));
867
885
  const nonSensitive = allFiles.filter((f) => !isSensitiveFile(f));
868
886
  const fernignoredFiles = nonSensitive.filter((f) => matchesFernignorePattern(f, fernignorePatterns));
869
887
  const files = nonSensitive.filter((f) => !matchesFernignorePattern(f, fernignorePatterns));
870
- return { files, sensitiveFiles, fernignoredFiles };
888
+ return { files, sensitiveFiles, fernignoredFiles, infrastructureFiles };
871
889
  }
872
890
  var ReplayDetector = class {
873
891
  git;
@@ -875,6 +893,15 @@ var ReplayDetector = class {
875
893
  sdkOutputDir;
876
894
  fernignorePatterns;
877
895
  warnings = [];
896
+ /**
897
+ * Structured degraded-state channel, mirroring `warnings`. Populated when
898
+ * detection cannot guarantee customizations were carried forward (baseline
899
+ * unreachable in this clone). Cleared per-run by `ReplayService.prepareReplay`
900
+ * alongside `warnings`. Merged into `ReplayReport.degradedReasons` by each
901
+ * flow handler. Degraded is observability only: detection results are
902
+ * unchanged.
903
+ */
904
+ degradedReasons = [];
878
905
  constructor(git, lockManager, sdkOutputDir, fernignorePatterns = []) {
879
906
  this.git = git;
880
907
  this.lockManager = lockManager;
@@ -945,6 +972,10 @@ var ReplayDetector = class {
945
972
  this.warnings.push(
946
973
  `No generation boundary found in git history and the lockfile's recorded generation (${lock.current_generation.slice(0, 7)}) matches no entry in its generation records. Customer customizations may differ from the last known generation and could be lost on the next regeneration. Run \`fern-replay bootstrap --force\` to re-initialize tracking.`
947
974
  );
975
+ this.degradedReasons.push({
976
+ code: "baseline-unresolvable",
977
+ message: `The previous generation recorded in the lockfile (${lock.current_generation.slice(0, 7)}) could not be resolved to any reachable baseline during this run. Customizations may not have been carried forward \u2014 review the result before merging.`
978
+ });
948
979
  }
949
980
  return { patches: [], revertedPatchIds: [] };
950
981
  }
@@ -953,6 +984,12 @@ var ReplayDetector = class {
953
984
  this.warnings.push(
954
985
  `Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to tree-diff detection (likely a shallow clone).`
955
986
  );
987
+ if (lock.patches.length > 0) {
988
+ this.degradedReasons.push({
989
+ code: "generation-anchor-unreachable",
990
+ message: `The previous generation (${lastGen.commit_sha.slice(0, 7)}) was not reachable during this run, and ${lock.patches.length} tracked customization(s) could not be verified against it. Customizations may not have been carried forward \u2014 review the result before merging.`
991
+ });
992
+ }
956
993
  return this.detectPatchesViaTreeDiff(
957
994
  lastGen,
958
995
  /* commitKnownMissing */
@@ -992,54 +1029,27 @@ var ReplayDetector = class {
992
1029
  if (lock.patches.find((p) => p.original_commit === commit.sha)) {
993
1030
  continue;
994
1031
  }
995
- const parents = await this.git.getCommitParents(commit.sha);
996
- let filesOutput;
997
- try {
998
- filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
999
- } catch {
1032
+ const materialized = await this.materializeCommitPatch(commit.sha);
1033
+ if (materialized.sensitiveFiles.length > 0) {
1000
1034
  this.warnings.push(
1001
- `Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
1035
+ `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${materialized.sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
1002
1036
  );
1003
- continue;
1004
1037
  }
1005
- const { files, sensitiveFiles, fernignoredFiles } = filterInfrastructureAndSensitiveFiles(
1006
- filesOutput,
1007
- this.fernignorePatterns
1008
- );
1009
- if (sensitiveFiles.length > 0) {
1038
+ if (materialized.fernignoredFiles.length > 0) {
1010
1039
  this.warnings.push(
1011
- `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
1040
+ `.fernignore-protected file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${materialized.fernignoredFiles.join(", ")}. These files are managed by .fernignore, not Replay.`
1012
1041
  );
1013
1042
  }
1014
- if (fernignoredFiles.length > 0) {
1043
+ if (materialized.status === "unreachable") {
1015
1044
  this.warnings.push(
1016
- `.fernignore-protected file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${fernignoredFiles.join(", ")}. These files are managed by .fernignore, not Replay.`
1045
+ `Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
1017
1046
  );
1018
- }
1019
- if (files.length === 0) {
1020
1047
  continue;
1021
1048
  }
1022
- const wasFiltered = sensitiveFiles.length > 0 || fernignoredFiles.length > 0;
1023
- const parentSha = parents[0];
1024
- let patchContent;
1025
- if (wasFiltered && parentSha) {
1026
- try {
1027
- patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
1028
- } catch {
1029
- continue;
1030
- }
1031
- if (!patchContent.trim()) continue;
1032
- } else {
1033
- try {
1034
- patchContent = await this.git.formatPatch(commit.sha);
1035
- } catch {
1036
- this.warnings.push(
1037
- `Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
1038
- );
1039
- continue;
1040
- }
1049
+ if (materialized.status === "empty") {
1050
+ continue;
1041
1051
  }
1042
- const contentHash = this.computeContentHash(patchContent);
1052
+ const { files, patchContent, contentHash } = materialized;
1043
1053
  if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
1044
1054
  continue;
1045
1055
  }
@@ -1116,6 +1126,63 @@ var ReplayDetector = class {
1116
1126
  const filteredPatches = survivingPatches.filter((_, i) => !revertIndicesToRemove.has(i));
1117
1127
  return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
1118
1128
  }
1129
+ /**
1130
+ * Materialize a commit into patch content exactly as detection does.
1131
+ *
1132
+ * Pipeline: `diff-tree --name-only` → filter infrastructure / sensitive /
1133
+ * `.fernignore`d files → scoped `git diff` when files were stripped,
1134
+ * otherwise full `git format-patch` → content hash.
1135
+ *
1136
+ * This is THE definition of a commit's re-emission identity: the content
1137
+ * hash this method returns is what `detectPatchesInRange` will compute if
1138
+ * the commit re-enters the scan range (e.g. after a regen PR is merged
1139
+ * with a merge commit). Callers that tombstone a dismissed patch must use
1140
+ * this hash — the stored `content_hash` drifts every time the patch is
1141
+ * carried forward, so it does not identify the commit on re-detection.
1142
+ *
1143
+ * Never throws; failures are reported through the `status` discriminant.
1144
+ */
1145
+ async materializeCommitPatch(sha) {
1146
+ let parents;
1147
+ let filesOutput;
1148
+ try {
1149
+ parents = await this.git.getCommitParents(sha);
1150
+ filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", sha]);
1151
+ } catch {
1152
+ return { status: "unreachable", sensitiveFiles: [], fernignoredFiles: [] };
1153
+ }
1154
+ const { files, sensitiveFiles, fernignoredFiles, infrastructureFiles } = filterInfrastructureAndSensitiveFiles(filesOutput, this.fernignorePatterns);
1155
+ if (files.length === 0) {
1156
+ return { status: "empty", sensitiveFiles, fernignoredFiles };
1157
+ }
1158
+ const wasFiltered = sensitiveFiles.length > 0 || fernignoredFiles.length > 0 || infrastructureFiles.length > 0;
1159
+ const parentSha = parents[0];
1160
+ let patchContent;
1161
+ if (wasFiltered && parentSha) {
1162
+ try {
1163
+ patchContent = await this.git.exec(["diff", parentSha, sha, "--", ...files]);
1164
+ } catch {
1165
+ return { status: "empty", sensitiveFiles, fernignoredFiles };
1166
+ }
1167
+ if (!patchContent.trim()) {
1168
+ return { status: "empty", sensitiveFiles, fernignoredFiles };
1169
+ }
1170
+ } else {
1171
+ try {
1172
+ patchContent = await this.git.formatPatch(sha);
1173
+ } catch {
1174
+ return { status: "unreachable", sensitiveFiles, fernignoredFiles };
1175
+ }
1176
+ }
1177
+ return {
1178
+ status: "ok",
1179
+ files,
1180
+ sensitiveFiles,
1181
+ fernignoredFiles,
1182
+ patchContent,
1183
+ contentHash: this.computeContentHash(patchContent)
1184
+ };
1185
+ }
1119
1186
  /**
1120
1187
  * Compute content hash for deduplication.
1121
1188
  *
@@ -1220,6 +1287,13 @@ var ReplayDetector = class {
1220
1287
  async detectPatchesViaTreeDiff(lastGen, commitKnownMissing) {
1221
1288
  const diffBase = await this.resolveDiffBase(lastGen, commitKnownMissing);
1222
1289
  if (!diffBase) {
1290
+ this.warnings.push(
1291
+ `Neither the previous generation's commit (${lastGen.commit_sha.slice(0, 7)}) nor its recorded tree was reachable during this run. Falling back to a bounded commit scan, which cannot see customizations older than the clone window. Widen the clone window (e.g. \`git fetch --unshallow\`) and re-run replay to verify customizations were carried forward.`
1292
+ );
1293
+ this.degradedReasons.push({
1294
+ code: "generation-tree-unreachable",
1295
+ message: `The previous generation (${lastGen.commit_sha.slice(0, 7)}) was not reachable during this run \u2014 neither its commit nor its recorded tree. Customizations may not have been carried forward \u2014 review the result before merging, or widen the clone window and re-run replay.`
1296
+ });
1223
1297
  return this.detectPatchesViaCommitScan();
1224
1298
  }
1225
1299
  const lockForGuard = this.lockManager.read();
@@ -1332,10 +1406,7 @@ var ReplayDetector = class {
1332
1406
  continue;
1333
1407
  }
1334
1408
  const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
1335
- const { files, sensitiveFiles, fernignoredFiles } = filterInfrastructureAndSensitiveFiles(
1336
- filesOutput,
1337
- this.fernignorePatterns
1338
- );
1409
+ const { files, sensitiveFiles, fernignoredFiles, infrastructureFiles } = filterInfrastructureAndSensitiveFiles(filesOutput, this.fernignorePatterns);
1339
1410
  if (sensitiveFiles.length > 0) {
1340
1411
  this.warnings.push(
1341
1412
  `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
@@ -1346,7 +1417,8 @@ var ReplayDetector = class {
1346
1417
  `.fernignore-protected file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${fernignoredFiles.join(", ")}. These files are managed by .fernignore, not Replay.`
1347
1418
  );
1348
1419
  }
1349
- if ((sensitiveFiles.length > 0 || fernignoredFiles.length > 0) && files.length > 0) {
1420
+ const wasFiltered = sensitiveFiles.length > 0 || fernignoredFiles.length > 0 || infrastructureFiles.length > 0;
1421
+ if (wasFiltered && files.length > 0) {
1350
1422
  if (parents.length === 0) {
1351
1423
  continue;
1352
1424
  }
@@ -2616,17 +2688,26 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
2616
2688
  };
2617
2689
 
2618
2690
  // src/ReplayService.ts
2619
- import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
2691
+ import { readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
2620
2692
  import { readFile as readFileAsync } from "fs/promises";
2621
- import { join as join6 } from "path";
2693
+ import { join as join7 } from "path";
2622
2694
  import { minimatch as minimatch5 } from "minimatch";
2623
2695
  init_GitClient();
2624
2696
 
2697
+ // src/shared/fernignore.ts
2698
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
2699
+ import { join as join4 } from "path";
2700
+ function readFernignorePatterns(outputDir) {
2701
+ const fernignorePath = join4(outputDir, ".fernignore");
2702
+ if (!existsSync2(fernignorePath)) return [];
2703
+ return readFileSync2(fernignorePath, "utf-8").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
2704
+ }
2705
+
2625
2706
  // src/PatchRegionDiff.ts
2626
2707
  init_HybridReconstruction();
2627
2708
  import { mkdtemp as mkdtemp2, writeFile as writeFile3, rm as rm2 } from "fs/promises";
2628
2709
  import { tmpdir as tmpdir2 } from "os";
2629
- import { join as join4 } from "path";
2710
+ import { join as join5 } from "path";
2630
2711
  import { spawn } from "child_process";
2631
2712
  function normalizeHunkBody(hunk) {
2632
2713
  let contextC = 0;
@@ -2843,10 +2924,10 @@ function overlayRegions(currentGenLines, locInCurrentGen, workingTreeLines, locI
2843
2924
  }
2844
2925
  async function unifiedDiff(beforeContent, afterContent, filePath) {
2845
2926
  if (beforeContent === afterContent) return "";
2846
- const tmp = await mkdtemp2(join4(tmpdir2(), "fern-replay-pp-"));
2927
+ const tmp = await mkdtemp2(join5(tmpdir2(), "fern-replay-pp-"));
2847
2928
  try {
2848
- const beforePath = join4(tmp, "before");
2849
- const afterPath = join4(tmp, "after");
2929
+ const beforePath = join5(tmp, "before");
2930
+ const afterPath = join5(tmp, "after");
2850
2931
  await writeFile3(beforePath, beforeContent, "utf-8");
2851
2932
  await writeFile3(afterPath, afterContent, "utf-8");
2852
2933
  const raw = await runGitDiffNoIndex(beforePath, afterPath);
@@ -3022,6 +3103,7 @@ var FirstGenerationFlow = class {
3022
3103
  patchesToApply: [],
3023
3104
  newPatchIds: /* @__PURE__ */ new Set(),
3024
3105
  detectorWarnings: [],
3106
+ detectorDegraded: [],
3025
3107
  revertedCount: 0,
3026
3108
  genSha: genRecord.commit_sha,
3027
3109
  optionsSnapshot: options
@@ -3081,6 +3163,7 @@ var SkipApplicationFlow = class {
3081
3163
  patchesToApply: [],
3082
3164
  newPatchIds: /* @__PURE__ */ new Set(),
3083
3165
  detectorWarnings: [],
3166
+ detectorDegraded: [],
3084
3167
  revertedCount: 0,
3085
3168
  genSha: genRecord.commit_sha,
3086
3169
  optionsSnapshot: options
@@ -3118,8 +3201,10 @@ var NoPatchesFlow = class {
3118
3201
  const previousGenerationSha = preLock.current_generation ?? null;
3119
3202
  let { patches: newPatches, revertedPatchIds } = await this.service.detector.detectNewPatches();
3120
3203
  const detectorWarnings = [...this.service.detector.warnings];
3204
+ const detectorDegraded = [...this.service.detector.degradedReasons];
3121
3205
  if (options?.dryRun) {
3122
3206
  const dryRunWarnings = [...detectorWarnings, ...this.service.warnings];
3207
+ const dryRunDegraded = dedupeDegradedReasons([...detectorDegraded, ...this.service.degradedReasons]);
3123
3208
  const preparedReport = {
3124
3209
  flow: "no-patches",
3125
3210
  patchesDetected: newPatches.length,
@@ -3129,7 +3214,9 @@ var NoPatchesFlow = class {
3129
3214
  patchesReverted: revertedPatchIds.length,
3130
3215
  conflicts: [],
3131
3216
  wouldApply: newPatches,
3132
- warnings: dryRunWarnings.length > 0 ? dryRunWarnings : void 0
3217
+ warnings: dryRunWarnings.length > 0 ? dryRunWarnings : void 0,
3218
+ degraded: dryRunDegraded.length > 0 ? true : void 0,
3219
+ degradedReasons: dryRunDegraded.length > 0 ? dryRunDegraded : void 0
3133
3220
  };
3134
3221
  const headSha = await this.service.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
3135
3222
  return {
@@ -3183,6 +3270,7 @@ var NoPatchesFlow = class {
3183
3270
  patchesToApply: newPatches,
3184
3271
  newPatchIds: new Set(newPatches.map((p) => p.id)),
3185
3272
  detectorWarnings,
3273
+ detectorDegraded,
3186
3274
  revertedCount: revertedPatchIds.length,
3187
3275
  genSha: genRecord.commit_sha,
3188
3276
  optionsSnapshot: options
@@ -3226,6 +3314,7 @@ var NoPatchesFlow = class {
3226
3314
  }
3227
3315
  }
3228
3316
  const warnings = [...detectorWarnings, ...this.service.warnings];
3317
+ const degradedReasons = [...prep._prepared.detectorDegraded, ...this.service.degradedReasons];
3229
3318
  return this.service.buildReport(
3230
3319
  "no-patches",
3231
3320
  newPatches,
@@ -3234,7 +3323,8 @@ var NoPatchesFlow = class {
3234
3323
  warnings,
3235
3324
  rebaseCounts,
3236
3325
  void 0,
3237
- prep._prepared.revertedCount
3326
+ prep._prepared.revertedCount,
3327
+ degradedReasons
3238
3328
  );
3239
3329
  }
3240
3330
  };
@@ -3251,6 +3341,10 @@ var NormalRegenerationFlow = class {
3251
3341
  const existingPatches2 = this.service.lockManager.getPatches();
3252
3342
  const { patches: newPatches2, revertedPatchIds: dryRunReverted } = await this.service.detector.detectNewPatches();
3253
3343
  const warnings = [...this.service.detector.warnings, ...this.service.warnings];
3344
+ const dryRunDegraded = dedupeDegradedReasons([
3345
+ ...this.service.detector.degradedReasons,
3346
+ ...this.service.degradedReasons
3347
+ ]);
3254
3348
  const allPatches2 = [...existingPatches2, ...newPatches2];
3255
3349
  const preparedReport = {
3256
3350
  flow: "normal-regeneration",
@@ -3261,7 +3355,9 @@ var NormalRegenerationFlow = class {
3261
3355
  patchesReverted: dryRunReverted.length,
3262
3356
  conflicts: [],
3263
3357
  wouldApply: allPatches2,
3264
- warnings: warnings.length > 0 ? warnings : void 0
3358
+ warnings: warnings.length > 0 ? warnings : void 0,
3359
+ degraded: dryRunDegraded.length > 0 ? true : void 0,
3360
+ degradedReasons: dryRunDegraded.length > 0 ? dryRunDegraded : void 0
3265
3361
  };
3266
3362
  const headSha = await this.service.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
3267
3363
  return {
@@ -3300,6 +3396,7 @@ var NormalRegenerationFlow = class {
3300
3396
  existingPatches = this.service.lockManager.getPatches();
3301
3397
  let { patches: newPatches, revertedPatchIds } = await this.service.detector.detectNewPatches();
3302
3398
  const detectorWarnings = [...this.service.detector.warnings];
3399
+ const detectorDegraded = [...this.service.detector.degradedReasons];
3303
3400
  if (removedByPreRebase.length > 0) {
3304
3401
  const removedOriginalCommits = new Set(removedByPreRebase.map((p) => p.original_commit));
3305
3402
  const removedOriginalMessages = new Set(removedByPreRebase.map((p) => p.original_message));
@@ -3340,6 +3437,7 @@ var NormalRegenerationFlow = class {
3340
3437
  newPatchIds: new Set(newPatches.map((p) => p.id)),
3341
3438
  preRebaseCounts,
3342
3439
  detectorWarnings,
3440
+ detectorDegraded,
3343
3441
  revertedCount: revertedPatchIds.length,
3344
3442
  genSha: genRecord.commit_sha,
3345
3443
  optionsSnapshot: options
@@ -3388,6 +3486,7 @@ var NormalRegenerationFlow = class {
3388
3486
  await this.service.committer.commitReplay(appliedCount, allPatches, void 0, { buckets });
3389
3487
  }
3390
3488
  const warnings = [...detectorWarnings, ...this.service.warnings];
3489
+ const degradedReasons = [...prep._prepared.detectorDegraded, ...this.service.degradedReasons];
3391
3490
  return this.service.buildReport(
3392
3491
  "normal-regeneration",
3393
3492
  allPatches,
@@ -3396,7 +3495,8 @@ var NormalRegenerationFlow = class {
3396
3495
  warnings,
3397
3496
  rebaseCounts,
3398
3497
  prep._prepared.preRebaseCounts,
3399
- prep._prepared.revertedCount
3498
+ prep._prepared.revertedCount,
3499
+ degradedReasons
3400
3500
  );
3401
3501
  }
3402
3502
  };
@@ -3436,6 +3536,16 @@ var ReplayService = class {
3436
3536
  * @internal Used by Flow implementations in `src/flows/`.
3437
3537
  */
3438
3538
  warnings = [];
3539
+ /**
3540
+ * Service-level structured degraded reasons, mirroring `warnings`.
3541
+ * Populated at apply time (e.g. `recordUnreachableBaseWarnings` when a
3542
+ * patch's base-generation tree is unreachable). Merged with
3543
+ * `detector.degradedReasons` into `ReplayReport.degradedReasons` by each
3544
+ * flow handler. Cleared at the top of `prepareReplay()`.
3545
+ *
3546
+ * @internal Used by Flow implementations in `src/flows/`.
3547
+ */
3548
+ degradedReasons = [];
3439
3549
  /**
3440
3550
  * @internal Test-only accessor.
3441
3551
  * Returns the ReplayApplicator instance used for the last run. Tests use this
@@ -3479,7 +3589,10 @@ var ReplayService = class {
3479
3589
  async prepareReplay(options) {
3480
3590
  this.warnings = [];
3481
3591
  this.detector.warnings.length = 0;
3592
+ this.degradedReasons = [];
3593
+ this.detector.degradedReasons.length = 0;
3482
3594
  this.cleanseLegacyFernignoreEntries();
3595
+ this.cleanseInfrastructureFileEntries();
3483
3596
  return this.selectFlow(options).prepare(options);
3484
3597
  }
3485
3598
  /**
@@ -3533,6 +3646,50 @@ var ReplayService = class {
3533
3646
  this.lockManager.save();
3534
3647
  }
3535
3648
  }
3649
+ /**
3650
+ * Migration step: strips infrastructure file diffs (`.fernignore`, `.fern/*`)
3651
+ * from existing patches' `patch_content`. These diffs were bundled by older
3652
+ * detection logic that did not scope `git diff` when only infrastructure files
3653
+ * were stripped. Patches that end up with no surviving diff sections are
3654
+ * removed entirely.
3655
+ *
3656
+ * Idempotent: a second run is a no-op because the first run already removed
3657
+ * all infrastructure sections. Per-patch try/catch ensures isolation.
3658
+ */
3659
+ cleanseInfrastructureFileEntries() {
3660
+ if (!this.lockManager.exists()) return;
3661
+ this.lockManager.read();
3662
+ let lockfileMutated = false;
3663
+ const patches = this.lockManager.getPatches();
3664
+ for (const patch of patches) {
3665
+ try {
3666
+ const result = computeInfrastructureFileCleanse(
3667
+ patch,
3668
+ (content) => this.detector.computeContentHash(content)
3669
+ );
3670
+ if (result.unchanged) continue;
3671
+ if (result.remove) {
3672
+ this.lockManager.removePatch(patch.id);
3673
+ this.warnings.push(
3674
+ `Stripped infrastructure file diffs from patch ${patch.id}: patch contained only infrastructure file changes (${result.strippedPaths.join(", ")}) \u2014 removed.`
3675
+ );
3676
+ } else {
3677
+ this.lockManager.updatePatch(patch.id, {
3678
+ patch_content: result.patch_content,
3679
+ content_hash: result.content_hash
3680
+ });
3681
+ this.warnings.push(
3682
+ `Stripped infrastructure file diffs from patch ${patch.id}: removed bundled diffs for ${result.strippedPaths.join(", ")}.`
3683
+ );
3684
+ }
3685
+ lockfileMutated = true;
3686
+ } catch {
3687
+ }
3688
+ }
3689
+ if (lockfileMutated) {
3690
+ this.lockManager.save();
3691
+ }
3692
+ }
3536
3693
  /**
3537
3694
  * Phase 2 of the two-phase replay flow. Applies patches, rebases them against
3538
3695
  * the generation, saves the lockfile, and commits `[fern-replay]` (or stages
@@ -3788,7 +3945,7 @@ var ReplayService = class {
3788
3945
  for (const file of files) {
3789
3946
  if (isBinaryFile(file)) continue;
3790
3947
  try {
3791
- const content = await readFileAsync(join6(this.outputDir, file), "utf-8");
3948
+ const content = await readFileAsync(join7(this.outputDir, file), "utf-8");
3792
3949
  snapshot[file] = content;
3793
3950
  } catch {
3794
3951
  }
@@ -4305,6 +4462,12 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
4305
4462
  );
4306
4463
  }
4307
4464
  }
4465
+ if (warned.size > 0) {
4466
+ this.degradedReasons.push({
4467
+ code: "patch-base-unreachable",
4468
+ 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.`
4469
+ });
4470
+ }
4308
4471
  }
4309
4472
  /**
4310
4473
  * After applyPatches(), strip conflict markers from conflicting files
@@ -4316,9 +4479,9 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
4316
4479
  if (result.status !== "conflict" || !result.fileResults) continue;
4317
4480
  for (const fileResult of result.fileResults) {
4318
4481
  if (fileResult.status !== "conflict") continue;
4319
- const filePath = join6(this.outputDir, fileResult.file);
4482
+ const filePath = join7(this.outputDir, fileResult.file);
4320
4483
  try {
4321
- const content = readFileSync2(filePath, "utf-8");
4484
+ const content = readFileSync3(filePath, "utf-8");
4322
4485
  const stripped = stripConflictMarkers(content);
4323
4486
  writeFileSync2(filePath, stripped);
4324
4487
  } catch {
@@ -4348,12 +4511,11 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
4348
4511
  }
4349
4512
  /** @internal Used by Flow implementations in `src/flows/`. */
4350
4513
  readFernignorePatterns() {
4351
- const fernignorePath = join6(this.outputDir, ".fernignore");
4352
- if (!existsSync2(fernignorePath)) return [];
4353
- return readFileSync2(fernignorePath, "utf-8").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
4514
+ return readFernignorePatterns(this.outputDir);
4354
4515
  }
4355
4516
  /** @internal Used by Flow implementations in `src/flows/`. */
4356
- buildReport(flow, patches, results, options, warnings, rebaseCounts, preRebaseCounts, patchesReverted) {
4517
+ buildReport(flow, patches, results, options, warnings, rebaseCounts, preRebaseCounts, patchesReverted, degradedReasons) {
4518
+ const degraded = dedupeDegradedReasons(degradedReasons ?? []);
4357
4519
  const conflictResults = results.filter((r) => r.status === "conflict");
4358
4520
  const conflictDetails = conflictResults.map((r) => {
4359
4521
  const conflictFiles = r.fileResults?.filter((f) => f.status === "conflict") ?? [];
@@ -4393,10 +4555,23 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
4393
4555
  conflictDetails: r.fileResults?.filter((f) => f.status === "conflict") ?? []
4394
4556
  })) : void 0,
4395
4557
  wouldApply: options?.dryRun ? patches : void 0,
4396
- warnings: warnings && warnings.length > 0 ? warnings : void 0
4558
+ warnings: warnings && warnings.length > 0 ? warnings : void 0,
4559
+ degraded: degraded.length > 0 ? true : void 0,
4560
+ degradedReasons: degraded.length > 0 ? degraded : void 0
4397
4561
  };
4398
4562
  }
4399
4563
  };
4564
+ function dedupeDegradedReasons(reasons) {
4565
+ const seen = /* @__PURE__ */ new Set();
4566
+ const out = [];
4567
+ for (const reason of reasons) {
4568
+ const key = `${reason.code}\0${reason.message}`;
4569
+ if (seen.has(key)) continue;
4570
+ seen.add(key);
4571
+ out.push(reason);
4572
+ }
4573
+ return out;
4574
+ }
4400
4575
  function computeCommitBuckets(results, absorbedPatchIds, patchesPassedToCommit) {
4401
4576
  const resultByPatchId = /* @__PURE__ */ new Map();
4402
4577
  for (const r of results) resultByPatchId.set(r.patch.id, r);
@@ -4445,7 +4620,7 @@ function computeLegacyFernignoreCleanse(patch, fernignorePatterns, computeConten
4445
4620
  }
4446
4621
  }
4447
4622
  }
4448
- const { stripped: patchContentStripped, content: newPatchContent, strippedSectionPaths } = stripFernignoreDiffSections(patch.patch_content, matchesFernignore);
4623
+ const { stripped: patchContentStripped, content: newPatchContent, strippedSectionPaths } = stripDiffSectionsByPredicate(patch.patch_content, matchesFernignore);
4449
4624
  const unchanged = strippedFromFiles.length === 0 && !snapshotChanged && !patchContentStripped;
4450
4625
  if (unchanged) {
4451
4626
  return {
@@ -4480,7 +4655,38 @@ function computeLegacyFernignoreCleanse(patch, fernignorePatterns, computeConten
4480
4655
  strippedPaths
4481
4656
  };
4482
4657
  }
4483
- function stripFernignoreDiffSections(patchContent, matchesFernignore) {
4658
+ function isInfrastructureFile(filePath) {
4659
+ return filePath === ".fernignore" || filePath.startsWith(".fern/");
4660
+ }
4661
+ function computeInfrastructureFileCleanse(patch, computeContentHash2) {
4662
+ const { stripped, content: newPatchContent, strippedSectionPaths } = stripDiffSectionsByPredicate(patch.patch_content, isInfrastructureFile);
4663
+ if (!stripped) {
4664
+ return {
4665
+ unchanged: true,
4666
+ remove: false,
4667
+ patch_content: patch.patch_content,
4668
+ content_hash: patch.content_hash,
4669
+ strippedPaths: []
4670
+ };
4671
+ }
4672
+ if (newPatchContent.trim() === "" || patch.files.length === 0) {
4673
+ return {
4674
+ unchanged: false,
4675
+ remove: true,
4676
+ patch_content: "",
4677
+ content_hash: "",
4678
+ strippedPaths: strippedSectionPaths
4679
+ };
4680
+ }
4681
+ return {
4682
+ unchanged: false,
4683
+ remove: false,
4684
+ patch_content: newPatchContent,
4685
+ content_hash: computeContentHash2(newPatchContent),
4686
+ strippedPaths: strippedSectionPaths
4687
+ };
4688
+ }
4689
+ function stripDiffSectionsByPredicate(patchContent, shouldStrip) {
4484
4690
  const lines = patchContent.split("\n");
4485
4691
  const sectionStarts = [];
4486
4692
  for (let i = 0; i < lines.length; i++) {
@@ -4502,7 +4708,7 @@ function stripFernignoreDiffSections(patchContent, matchesFernignore) {
4502
4708
  const section = sectionStarts[i];
4503
4709
  const next = sectionStarts[i + 1];
4504
4710
  const endIdx = next ? next.idx : lines.length;
4505
- if (section.path !== null && matchesFernignore(section.path)) {
4711
+ if (section.path !== null && shouldStrip(section.path)) {
4506
4712
  stripped = true;
4507
4713
  strippedSectionPaths.push(section.path);
4508
4714
  continue;
@@ -4514,8 +4720,8 @@ function stripFernignoreDiffSections(patchContent, matchesFernignore) {
4514
4720
 
4515
4721
  // src/FernignoreMigrator.ts
4516
4722
  import { createHash as createHash2 } from "crypto";
4517
- import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, statSync, writeFileSync as writeFileSync3 } from "fs";
4518
- import { dirname as dirname5, join as join7 } from "path";
4723
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync4, statSync, writeFileSync as writeFileSync3 } from "fs";
4724
+ import { dirname as dirname5, join as join8 } from "path";
4519
4725
  import { minimatch as minimatch6 } from "minimatch";
4520
4726
  import { parse as parse2, stringify as stringify2 } from "yaml";
4521
4727
  var FernignoreMigrator = class {
@@ -4528,14 +4734,14 @@ var FernignoreMigrator = class {
4528
4734
  this.outputDir = outputDir;
4529
4735
  }
4530
4736
  fernignoreExists() {
4531
- return existsSync3(join7(this.outputDir, ".fernignore"));
4737
+ return existsSync3(join8(this.outputDir, ".fernignore"));
4532
4738
  }
4533
4739
  readFernignorePatterns() {
4534
- const fernignorePath = join7(this.outputDir, ".fernignore");
4740
+ const fernignorePath = join8(this.outputDir, ".fernignore");
4535
4741
  if (!existsSync3(fernignorePath)) {
4536
4742
  return [];
4537
4743
  }
4538
- const content = readFileSync3(fernignorePath, "utf-8");
4744
+ const content = readFileSync4(fernignorePath, "utf-8");
4539
4745
  return content.split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
4540
4746
  }
4541
4747
  /** Analyze .fernignore patterns vs git history. Creates synthetic patches for files differing from pristine generation. */
@@ -4640,7 +4846,7 @@ var FernignoreMigrator = class {
4640
4846
  return results;
4641
4847
  }
4642
4848
  readFileContent(filePath) {
4643
- const fullPath = join7(this.outputDir, filePath);
4849
+ const fullPath = join8(this.outputDir, filePath);
4644
4850
  if (!existsSync3(fullPath)) {
4645
4851
  return null;
4646
4852
  }
@@ -4649,7 +4855,7 @@ var FernignoreMigrator = class {
4649
4855
  if (stat.isDirectory()) {
4650
4856
  return null;
4651
4857
  }
4652
- return readFileSync3(fullPath, "utf-8");
4858
+ return readFileSync4(fullPath, "utf-8");
4653
4859
  } catch {
4654
4860
  return null;
4655
4861
  }
@@ -4705,10 +4911,10 @@ var FernignoreMigrator = class {
4705
4911
  };
4706
4912
  }
4707
4913
  movePatternsToReplayYml(patterns) {
4708
- const replayYmlPath = join7(this.outputDir, ".fern", "replay.yml");
4914
+ const replayYmlPath = join8(this.outputDir, ".fern", "replay.yml");
4709
4915
  let config = {};
4710
4916
  if (existsSync3(replayYmlPath)) {
4711
- const content = readFileSync3(replayYmlPath, "utf-8");
4917
+ const content = readFileSync4(replayYmlPath, "utf-8");
4712
4918
  config = parse2(content) ?? {};
4713
4919
  }
4714
4920
  const existing = config.exclude ?? [];
@@ -4724,8 +4930,8 @@ var FernignoreMigrator = class {
4724
4930
 
4725
4931
  // src/commands/bootstrap.ts
4726
4932
  import { createHash as createHash3 } from "crypto";
4727
- import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
4728
- import { join as join8 } from "path";
4933
+ import { existsSync as existsSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
4934
+ import { join as join9 } from "path";
4729
4935
  init_GitClient();
4730
4936
  async function bootstrap(outputDir, options) {
4731
4937
  const git = new GitClient(outputDir);
@@ -4892,10 +5098,10 @@ function parseGitLog(log) {
4892
5098
  }
4893
5099
  var REPLAY_FERNIGNORE_ENTRIES = [".fern/replay.lock", ".fern/replay.yml", ".gitattributes"];
4894
5100
  function ensureFernignoreEntries(outputDir) {
4895
- const fernignorePath = join8(outputDir, ".fernignore");
5101
+ const fernignorePath = join9(outputDir, ".fernignore");
4896
5102
  let content = "";
4897
5103
  if (existsSync4(fernignorePath)) {
4898
- content = readFileSync4(fernignorePath, "utf-8");
5104
+ content = readFileSync5(fernignorePath, "utf-8");
4899
5105
  }
4900
5106
  const lines = content.split("\n");
4901
5107
  const toAdd = [];
@@ -4916,10 +5122,10 @@ function ensureFernignoreEntries(outputDir) {
4916
5122
  }
4917
5123
  var GITATTRIBUTES_ENTRIES = [".fern/replay.lock linguist-generated=true"];
4918
5124
  function ensureGitattributesEntries(outputDir) {
4919
- const gitattributesPath = join8(outputDir, ".gitattributes");
5125
+ const gitattributesPath = join9(outputDir, ".gitattributes");
4920
5126
  let content = "";
4921
5127
  if (existsSync4(gitattributesPath)) {
4922
- content = readFileSync4(gitattributesPath, "utf-8");
5128
+ content = readFileSync5(gitattributesPath, "utf-8");
4923
5129
  }
4924
5130
  const lines = content.split("\n");
4925
5131
  const toAdd = [];
@@ -4946,6 +5152,7 @@ function computeContentHash(patchContent) {
4946
5152
  }
4947
5153
 
4948
5154
  // src/commands/forget.ts
5155
+ init_GitClient();
4949
5156
  import { minimatch as minimatch7 } from "minimatch";
4950
5157
  function parseDiffStat(patchContent) {
4951
5158
  let additions = 0;
@@ -5005,7 +5212,28 @@ var EMPTY_RESULT = {
5005
5212
  totalPatches: 0,
5006
5213
  warnings: []
5007
5214
  };
5008
- function forget(outputDir, options) {
5215
+ async function dismissPatches(outputDir, lockManager, patches) {
5216
+ const git = new GitClient(outputDir);
5217
+ const detector = new ReplayDetector(git, lockManager, outputDir, readFernignorePatterns(outputDir));
5218
+ for (const patch of patches) {
5219
+ const hashes = [patch.content_hash];
5220
+ if (patch.original_commit) {
5221
+ const materialized = await detector.materializeCommitPatch(patch.original_commit);
5222
+ if (materialized.status === "ok" && !hashes.includes(materialized.contentHash)) {
5223
+ hashes.push(materialized.contentHash);
5224
+ }
5225
+ }
5226
+ lockManager.addDismissal({
5227
+ patch_id: patch.id,
5228
+ original_commit: patch.original_commit,
5229
+ original_message: patch.original_message,
5230
+ content_hashes: hashes,
5231
+ dismissed_at: (/* @__PURE__ */ new Date()).toISOString(),
5232
+ via: "forget"
5233
+ });
5234
+ }
5235
+ }
5236
+ async function forget(outputDir, options) {
5009
5237
  const lockManager = new LockfileManager(outputDir);
5010
5238
  if (!lockManager.exists()) {
5011
5239
  return { ...EMPTY_RESULT };
@@ -5016,9 +5244,7 @@ function forget(outputDir, options) {
5016
5244
  const removed = lock.patches.map(toMatchedPatch);
5017
5245
  const warnings = buildWarnings(lock.patches);
5018
5246
  if (!options.dryRun) {
5019
- for (const patch of lock.patches) {
5020
- lockManager.addForgottenHash(patch.content_hash);
5021
- }
5247
+ await dismissPatches(outputDir, lockManager, lock.patches);
5022
5248
  lockManager.clearPatches();
5023
5249
  lockManager.save();
5024
5250
  }
@@ -5047,8 +5273,8 @@ function forget(outputDir, options) {
5047
5273
  }
5048
5274
  const warnings = buildWarnings(patchesToRemove);
5049
5275
  if (!options.dryRun) {
5276
+ await dismissPatches(outputDir, lockManager, patchesToRemove);
5050
5277
  for (const patch of patchesToRemove) {
5051
- lockManager.addForgottenHash(patch.content_hash);
5052
5278
  lockManager.removePatch(patch.id);
5053
5279
  }
5054
5280
  lockManager.save();
@@ -5116,7 +5342,7 @@ function reset(outputDir, options) {
5116
5342
  // src/commands/resolve.ts
5117
5343
  init_GitClient();
5118
5344
  import { readFile as readFile4 } from "fs/promises";
5119
- import { join as join9 } from "path";
5345
+ import { join as join10 } from "path";
5120
5346
  async function resolve(outputDir, options) {
5121
5347
  const lockManager = new LockfileManager(outputDir);
5122
5348
  if (!lockManager.exists()) {
@@ -5156,15 +5382,16 @@ async function resolve(outputDir, options) {
5156
5382
  const patchesToCommit = [...resolvingPatches, ...unresolvedPatches];
5157
5383
  if (patchesToCommit.length > 0) {
5158
5384
  const currentGen = lock.current_generation;
5159
- const detector = new ReplayDetector(git, lockManager, outputDir);
5385
+ const detector = new ReplayDetector(git, lockManager, outputDir, readFernignorePatterns(outputDir));
5160
5386
  const warnings = [];
5387
+ const patchesDismissed = [];
5161
5388
  let patchesResolved = 0;
5162
5389
  const pass1 = [];
5163
5390
  for (const patch of patchesToCommit) {
5164
5391
  const result = await computePerPatchDiffWithFallback(
5165
5392
  patch,
5166
5393
  (f) => git.showFile(currentGen, f),
5167
- (f) => readFile4(join9(outputDir, f), "utf-8").catch(() => null),
5394
+ (f) => readFile4(join10(outputDir, f), "utf-8").catch(() => null),
5168
5395
  (files) => git.exec(["diff", currentGen, "--", ...files]).catch(() => null)
5169
5396
  );
5170
5397
  if (result === null) {
@@ -5198,7 +5425,27 @@ async function resolve(outputDir, options) {
5198
5425
  const r = pass1[i];
5199
5426
  if (r.kind === "skip") continue;
5200
5427
  if (r.kind === "revert") {
5428
+ const hashes = [patch.content_hash];
5429
+ if (patch.original_commit) {
5430
+ const materialized = await detector.materializeCommitPatch(patch.original_commit);
5431
+ if (materialized.status === "ok" && !hashes.includes(materialized.contentHash)) {
5432
+ hashes.push(materialized.contentHash);
5433
+ } else if (materialized.status === "unreachable") {
5434
+ warnings.push(
5435
+ `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.`
5436
+ );
5437
+ }
5438
+ }
5439
+ lockManager.addDismissal({
5440
+ patch_id: patch.id,
5441
+ original_commit: patch.original_commit,
5442
+ original_message: patch.original_message,
5443
+ content_hashes: hashes,
5444
+ dismissed_at: (/* @__PURE__ */ new Date()).toISOString(),
5445
+ via: "resolve"
5446
+ });
5201
5447
  lockManager.removePatch(patch.id);
5448
+ patchesDismissed.push({ id: patch.id, message: patch.original_message });
5202
5449
  continue;
5203
5450
  }
5204
5451
  if (lastIndexByHash.get(r.hash) !== i) {
@@ -5220,7 +5467,7 @@ async function resolve(outputDir, options) {
5220
5467
  const theirsSnapshot = {};
5221
5468
  for (const file of filesForSnapshot) {
5222
5469
  if (isBinaryFile(file)) continue;
5223
- const content = await readFile4(join9(outputDir, file), "utf-8").catch(() => null);
5470
+ const content = await readFile4(join10(outputDir, file), "utf-8").catch(() => null);
5224
5471
  if (content != null) {
5225
5472
  theirsSnapshot[file] = content;
5226
5473
  }
@@ -5247,6 +5494,7 @@ async function resolve(outputDir, options) {
5247
5494
  commitSha: commitSha2,
5248
5495
  phase: "committed",
5249
5496
  patchesResolved,
5497
+ patchesDismissed: patchesDismissed.length > 0 ? patchesDismissed : void 0,
5250
5498
  warnings: warnings.length > 0 ? warnings : void 0
5251
5499
  };
5252
5500
  }
@@ -5294,7 +5542,9 @@ function status(outputDir) {
5294
5542
  lastGeneration: void 0,
5295
5543
  patches: [],
5296
5544
  unresolvedCount: 0,
5297
- excludePatterns: []
5545
+ excludePatterns: [],
5546
+ dismissed: [],
5547
+ legacyDismissedCount: 0
5298
5548
  };
5299
5549
  }
5300
5550
  const lock = lockManager.read();
@@ -5323,13 +5573,24 @@ function status(outputDir) {
5323
5573
  }
5324
5574
  const config = lockManager.getCustomizationsConfig();
5325
5575
  const excludePatterns = config.exclude ?? [];
5576
+ const dismissals = lock.dismissals ?? [];
5577
+ const dismissed = dismissals.map((d) => ({
5578
+ message: d.original_message,
5579
+ sha: d.original_commit.slice(0, 7),
5580
+ via: d.via,
5581
+ dismissedAt: d.dismissed_at
5582
+ }));
5583
+ const coveredHashes = new Set(dismissals.flatMap((d) => d.content_hashes));
5584
+ const legacyDismissedCount = (lock.forgotten_hashes ?? []).filter((h) => !coveredHashes.has(h)).length;
5326
5585
  return {
5327
5586
  initialized: true,
5328
5587
  generationCount: lock.generations.length,
5329
5588
  lastGeneration,
5330
5589
  patches,
5331
5590
  unresolvedCount,
5332
- excludePatterns
5591
+ excludePatterns,
5592
+ dismissed,
5593
+ legacyDismissedCount
5333
5594
  };
5334
5595
  }
5335
5596
  export {