@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.js CHANGED
@@ -40,6 +40,11 @@ var init_GitClient = __esm({
40
40
  proc.stderr.on("data", (data) => {
41
41
  stderr += data.toString();
42
42
  });
43
+ proc.on("error", (err) => {
44
+ reject(new Error(`git ${args.join(" ")} failed to spawn: ${err.message}`));
45
+ });
46
+ proc.stdin.on("error", () => {
47
+ });
43
48
  proc.on("close", (code) => {
44
49
  if (code === 0) {
45
50
  resolve2(stdout);
@@ -402,18 +407,18 @@ __export(PatchApplyTheirs_exports, {
402
407
  });
403
408
  import { mkdtemp as mkdtemp3, mkdir as mkdir3, writeFile as writeFile4, readFile as readFile3, rm as rm3 } from "fs/promises";
404
409
  import { tmpdir as tmpdir3 } from "os";
405
- import { dirname as dirname4, join as join5 } from "path";
410
+ import { dirname as dirname4, join as join6 } from "path";
406
411
  async function applyPatchToContent(base, patchContent, filePath) {
407
412
  const fileDiff = extractFileDiff(patchContent, filePath);
408
413
  if (!fileDiff) return null;
409
- const tempDir = await mkdtemp3(join5(tmpdir3(), "replay-migrate-"));
414
+ const tempDir = await mkdtemp3(join6(tmpdir3(), "replay-migrate-"));
410
415
  const tempGit = new GitClient(tempDir);
411
416
  try {
412
417
  await tempGit.exec(["init"]);
413
418
  await tempGit.exec(["config", "user.email", "migrate@fern.com"]);
414
419
  await tempGit.exec(["config", "user.name", "Fern Replay Migration"]);
415
420
  await tempGit.exec(["config", "commit.gpgSign", "false"]);
416
- const tempFilePath = join5(tempDir, filePath);
421
+ const tempFilePath = join6(tempDir, filePath);
417
422
  await mkdir3(dirname4(tempFilePath), { recursive: true });
418
423
  await writeFile4(tempFilePath, base);
419
424
  await tempGit.exec(["add", filePath]);
@@ -690,6 +695,22 @@ var LockfileManager = class {
690
695
  this.lock.forgotten_hashes.push(hash);
691
696
  }
692
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
+ }
693
714
  getUnresolvedPatches() {
694
715
  this.ensureLoaded();
695
716
  return this.lock.patches.filter((p) => p.status === "unresolved");
@@ -808,7 +829,8 @@ function isBinaryFile(filePath) {
808
829
  var INFRASTRUCTURE_FILES = /* @__PURE__ */ new Set([".fernignore"]);
809
830
  function matchesFernignorePattern(filePath, patterns) {
810
831
  if (patterns.length === 0) return false;
811
- return patterns.some((p) => {
832
+ return patterns.some((rawPattern) => {
833
+ const p = rawPattern.endsWith("/") ? rawPattern.slice(0, -1) : rawPattern;
812
834
  if (filePath === p) return true;
813
835
  if (minimatch2(filePath, p)) return true;
814
836
  if (!p.includes("*") && !p.includes("?") && filePath.startsWith(p + "/")) return true;
@@ -856,12 +878,14 @@ async function capturePatchSnapshot(git, files, treeish) {
856
878
  return snapshot;
857
879
  }
858
880
  function filterInfrastructureAndSensitiveFiles(rawFilesOutput, fernignorePatterns) {
859
- 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/"));
860
884
  const sensitiveFiles = allFiles.filter((f) => isSensitiveFile(f));
861
885
  const nonSensitive = allFiles.filter((f) => !isSensitiveFile(f));
862
886
  const fernignoredFiles = nonSensitive.filter((f) => matchesFernignorePattern(f, fernignorePatterns));
863
887
  const files = nonSensitive.filter((f) => !matchesFernignorePattern(f, fernignorePatterns));
864
- return { files, sensitiveFiles, fernignoredFiles };
888
+ return { files, sensitiveFiles, fernignoredFiles, infrastructureFiles };
865
889
  }
866
890
  var ReplayDetector = class {
867
891
  git;
@@ -869,6 +893,15 @@ var ReplayDetector = class {
869
893
  sdkOutputDir;
870
894
  fernignorePatterns;
871
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 = [];
872
905
  constructor(git, lockManager, sdkOutputDir, fernignorePatterns = []) {
873
906
  this.git = git;
874
907
  this.lockManager = lockManager;
@@ -939,6 +972,10 @@ var ReplayDetector = class {
939
972
  this.warnings.push(
940
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.`
941
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
+ });
942
979
  }
943
980
  return { patches: [], revertedPatchIds: [] };
944
981
  }
@@ -947,6 +984,12 @@ var ReplayDetector = class {
947
984
  this.warnings.push(
948
985
  `Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to tree-diff detection (likely a shallow clone).`
949
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
+ }
950
993
  return this.detectPatchesViaTreeDiff(
951
994
  lastGen,
952
995
  /* commitKnownMissing */
@@ -986,48 +1029,28 @@ var ReplayDetector = class {
986
1029
  if (lock.patches.find((p) => p.original_commit === commit.sha)) {
987
1030
  continue;
988
1031
  }
989
- const parents = await this.git.getCommitParents(commit.sha);
990
- let patchContent;
991
- try {
992
- patchContent = await this.git.formatPatch(commit.sha);
993
- } catch {
1032
+ const materialized = await this.materializeCommitPatch(commit.sha);
1033
+ if (materialized.sensitiveFiles.length > 0) {
994
1034
  this.warnings.push(
995
- `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.`
996
1036
  );
997
- continue;
998
- }
999
- let contentHash = this.computeContentHash(patchContent);
1000
- if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
1001
- continue;
1002
1037
  }
1003
- const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
1004
- const { files, sensitiveFiles, fernignoredFiles } = filterInfrastructureAndSensitiveFiles(
1005
- filesOutput,
1006
- this.fernignorePatterns
1007
- );
1008
- if (sensitiveFiles.length > 0) {
1038
+ if (materialized.fernignoredFiles.length > 0) {
1009
1039
  this.warnings.push(
1010
- `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.`
1011
1041
  );
1012
1042
  }
1013
- if (fernignoredFiles.length > 0) {
1043
+ if (materialized.status === "unreachable") {
1014
1044
  this.warnings.push(
1015
- `.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.`
1016
1046
  );
1047
+ continue;
1017
1048
  }
1018
- if ((sensitiveFiles.length > 0 || fernignoredFiles.length > 0) && files.length > 0) {
1019
- const parentSha = parents[0];
1020
- if (parentSha) {
1021
- try {
1022
- patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
1023
- } catch {
1024
- continue;
1025
- }
1026
- if (!patchContent.trim()) continue;
1027
- contentHash = this.computeContentHash(patchContent);
1028
- }
1049
+ if (materialized.status === "empty") {
1050
+ continue;
1029
1051
  }
1030
- if (files.length === 0) {
1052
+ const { files, patchContent, contentHash } = materialized;
1053
+ if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
1031
1054
  continue;
1032
1055
  }
1033
1056
  const theirsSnapshot = await capturePatchSnapshot(this.git, files, commit.sha);
@@ -1103,6 +1126,63 @@ var ReplayDetector = class {
1103
1126
  const filteredPatches = survivingPatches.filter((_, i) => !revertIndicesToRemove.has(i));
1104
1127
  return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
1105
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
+ }
1106
1186
  /**
1107
1187
  * Compute content hash for deduplication.
1108
1188
  *
@@ -1207,6 +1287,13 @@ var ReplayDetector = class {
1207
1287
  async detectPatchesViaTreeDiff(lastGen, commitKnownMissing) {
1208
1288
  const diffBase = await this.resolveDiffBase(lastGen, commitKnownMissing);
1209
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
+ });
1210
1297
  return this.detectPatchesViaCommitScan();
1211
1298
  }
1212
1299
  const lockForGuard = this.lockManager.read();
@@ -1319,10 +1406,7 @@ var ReplayDetector = class {
1319
1406
  continue;
1320
1407
  }
1321
1408
  const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
1322
- const { files, sensitiveFiles, fernignoredFiles } = filterInfrastructureAndSensitiveFiles(
1323
- filesOutput,
1324
- this.fernignorePatterns
1325
- );
1409
+ const { files, sensitiveFiles, fernignoredFiles, infrastructureFiles } = filterInfrastructureAndSensitiveFiles(filesOutput, this.fernignorePatterns);
1326
1410
  if (sensitiveFiles.length > 0) {
1327
1411
  this.warnings.push(
1328
1412
  `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
@@ -1333,7 +1417,8 @@ var ReplayDetector = class {
1333
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.`
1334
1418
  );
1335
1419
  }
1336
- 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) {
1337
1422
  if (parents.length === 0) {
1338
1423
  continue;
1339
1424
  }
@@ -2603,17 +2688,26 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
2603
2688
  };
2604
2689
 
2605
2690
  // src/ReplayService.ts
2606
- import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
2691
+ import { readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
2607
2692
  import { readFile as readFileAsync } from "fs/promises";
2608
- import { join as join6 } from "path";
2693
+ import { join as join7 } from "path";
2609
2694
  import { minimatch as minimatch5 } from "minimatch";
2610
2695
  init_GitClient();
2611
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
+
2612
2706
  // src/PatchRegionDiff.ts
2613
2707
  init_HybridReconstruction();
2614
2708
  import { mkdtemp as mkdtemp2, writeFile as writeFile3, rm as rm2 } from "fs/promises";
2615
2709
  import { tmpdir as tmpdir2 } from "os";
2616
- import { join as join4 } from "path";
2710
+ import { join as join5 } from "path";
2617
2711
  import { spawn } from "child_process";
2618
2712
  function normalizeHunkBody(hunk) {
2619
2713
  let contextC = 0;
@@ -2830,10 +2924,10 @@ function overlayRegions(currentGenLines, locInCurrentGen, workingTreeLines, locI
2830
2924
  }
2831
2925
  async function unifiedDiff(beforeContent, afterContent, filePath) {
2832
2926
  if (beforeContent === afterContent) return "";
2833
- const tmp = await mkdtemp2(join4(tmpdir2(), "fern-replay-pp-"));
2927
+ const tmp = await mkdtemp2(join5(tmpdir2(), "fern-replay-pp-"));
2834
2928
  try {
2835
- const beforePath = join4(tmp, "before");
2836
- const afterPath = join4(tmp, "after");
2929
+ const beforePath = join5(tmp, "before");
2930
+ const afterPath = join5(tmp, "after");
2837
2931
  await writeFile3(beforePath, beforeContent, "utf-8");
2838
2932
  await writeFile3(afterPath, afterContent, "utf-8");
2839
2933
  const raw = await runGitDiffNoIndex(beforePath, afterPath);
@@ -3009,6 +3103,7 @@ var FirstGenerationFlow = class {
3009
3103
  patchesToApply: [],
3010
3104
  newPatchIds: /* @__PURE__ */ new Set(),
3011
3105
  detectorWarnings: [],
3106
+ detectorDegraded: [],
3012
3107
  revertedCount: 0,
3013
3108
  genSha: genRecord.commit_sha,
3014
3109
  optionsSnapshot: options
@@ -3068,6 +3163,7 @@ var SkipApplicationFlow = class {
3068
3163
  patchesToApply: [],
3069
3164
  newPatchIds: /* @__PURE__ */ new Set(),
3070
3165
  detectorWarnings: [],
3166
+ detectorDegraded: [],
3071
3167
  revertedCount: 0,
3072
3168
  genSha: genRecord.commit_sha,
3073
3169
  optionsSnapshot: options
@@ -3105,8 +3201,10 @@ var NoPatchesFlow = class {
3105
3201
  const previousGenerationSha = preLock.current_generation ?? null;
3106
3202
  let { patches: newPatches, revertedPatchIds } = await this.service.detector.detectNewPatches();
3107
3203
  const detectorWarnings = [...this.service.detector.warnings];
3204
+ const detectorDegraded = [...this.service.detector.degradedReasons];
3108
3205
  if (options?.dryRun) {
3109
3206
  const dryRunWarnings = [...detectorWarnings, ...this.service.warnings];
3207
+ const dryRunDegraded = dedupeDegradedReasons([...detectorDegraded, ...this.service.degradedReasons]);
3110
3208
  const preparedReport = {
3111
3209
  flow: "no-patches",
3112
3210
  patchesDetected: newPatches.length,
@@ -3116,7 +3214,9 @@ var NoPatchesFlow = class {
3116
3214
  patchesReverted: revertedPatchIds.length,
3117
3215
  conflicts: [],
3118
3216
  wouldApply: newPatches,
3119
- 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
3120
3220
  };
3121
3221
  const headSha = await this.service.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
3122
3222
  return {
@@ -3170,6 +3270,7 @@ var NoPatchesFlow = class {
3170
3270
  patchesToApply: newPatches,
3171
3271
  newPatchIds: new Set(newPatches.map((p) => p.id)),
3172
3272
  detectorWarnings,
3273
+ detectorDegraded,
3173
3274
  revertedCount: revertedPatchIds.length,
3174
3275
  genSha: genRecord.commit_sha,
3175
3276
  optionsSnapshot: options
@@ -3213,6 +3314,7 @@ var NoPatchesFlow = class {
3213
3314
  }
3214
3315
  }
3215
3316
  const warnings = [...detectorWarnings, ...this.service.warnings];
3317
+ const degradedReasons = [...prep._prepared.detectorDegraded, ...this.service.degradedReasons];
3216
3318
  return this.service.buildReport(
3217
3319
  "no-patches",
3218
3320
  newPatches,
@@ -3221,7 +3323,8 @@ var NoPatchesFlow = class {
3221
3323
  warnings,
3222
3324
  rebaseCounts,
3223
3325
  void 0,
3224
- prep._prepared.revertedCount
3326
+ prep._prepared.revertedCount,
3327
+ degradedReasons
3225
3328
  );
3226
3329
  }
3227
3330
  };
@@ -3238,6 +3341,10 @@ var NormalRegenerationFlow = class {
3238
3341
  const existingPatches2 = this.service.lockManager.getPatches();
3239
3342
  const { patches: newPatches2, revertedPatchIds: dryRunReverted } = await this.service.detector.detectNewPatches();
3240
3343
  const warnings = [...this.service.detector.warnings, ...this.service.warnings];
3344
+ const dryRunDegraded = dedupeDegradedReasons([
3345
+ ...this.service.detector.degradedReasons,
3346
+ ...this.service.degradedReasons
3347
+ ]);
3241
3348
  const allPatches2 = [...existingPatches2, ...newPatches2];
3242
3349
  const preparedReport = {
3243
3350
  flow: "normal-regeneration",
@@ -3248,7 +3355,9 @@ var NormalRegenerationFlow = class {
3248
3355
  patchesReverted: dryRunReverted.length,
3249
3356
  conflicts: [],
3250
3357
  wouldApply: allPatches2,
3251
- 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
3252
3361
  };
3253
3362
  const headSha = await this.service.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
3254
3363
  return {
@@ -3287,6 +3396,7 @@ var NormalRegenerationFlow = class {
3287
3396
  existingPatches = this.service.lockManager.getPatches();
3288
3397
  let { patches: newPatches, revertedPatchIds } = await this.service.detector.detectNewPatches();
3289
3398
  const detectorWarnings = [...this.service.detector.warnings];
3399
+ const detectorDegraded = [...this.service.detector.degradedReasons];
3290
3400
  if (removedByPreRebase.length > 0) {
3291
3401
  const removedOriginalCommits = new Set(removedByPreRebase.map((p) => p.original_commit));
3292
3402
  const removedOriginalMessages = new Set(removedByPreRebase.map((p) => p.original_message));
@@ -3327,6 +3437,7 @@ var NormalRegenerationFlow = class {
3327
3437
  newPatchIds: new Set(newPatches.map((p) => p.id)),
3328
3438
  preRebaseCounts,
3329
3439
  detectorWarnings,
3440
+ detectorDegraded,
3330
3441
  revertedCount: revertedPatchIds.length,
3331
3442
  genSha: genRecord.commit_sha,
3332
3443
  optionsSnapshot: options
@@ -3375,6 +3486,7 @@ var NormalRegenerationFlow = class {
3375
3486
  await this.service.committer.commitReplay(appliedCount, allPatches, void 0, { buckets });
3376
3487
  }
3377
3488
  const warnings = [...detectorWarnings, ...this.service.warnings];
3489
+ const degradedReasons = [...prep._prepared.detectorDegraded, ...this.service.degradedReasons];
3378
3490
  return this.service.buildReport(
3379
3491
  "normal-regeneration",
3380
3492
  allPatches,
@@ -3383,7 +3495,8 @@ var NormalRegenerationFlow = class {
3383
3495
  warnings,
3384
3496
  rebaseCounts,
3385
3497
  prep._prepared.preRebaseCounts,
3386
- prep._prepared.revertedCount
3498
+ prep._prepared.revertedCount,
3499
+ degradedReasons
3387
3500
  );
3388
3501
  }
3389
3502
  };
@@ -3423,6 +3536,16 @@ var ReplayService = class {
3423
3536
  * @internal Used by Flow implementations in `src/flows/`.
3424
3537
  */
3425
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 = [];
3426
3549
  /**
3427
3550
  * @internal Test-only accessor.
3428
3551
  * Returns the ReplayApplicator instance used for the last run. Tests use this
@@ -3466,7 +3589,10 @@ var ReplayService = class {
3466
3589
  async prepareReplay(options) {
3467
3590
  this.warnings = [];
3468
3591
  this.detector.warnings.length = 0;
3592
+ this.degradedReasons = [];
3593
+ this.detector.degradedReasons.length = 0;
3469
3594
  this.cleanseLegacyFernignoreEntries();
3595
+ this.cleanseInfrastructureFileEntries();
3470
3596
  return this.selectFlow(options).prepare(options);
3471
3597
  }
3472
3598
  /**
@@ -3520,6 +3646,50 @@ var ReplayService = class {
3520
3646
  this.lockManager.save();
3521
3647
  }
3522
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
+ }
3523
3693
  /**
3524
3694
  * Phase 2 of the two-phase replay flow. Applies patches, rebases them against
3525
3695
  * the generation, saves the lockfile, and commits `[fern-replay]` (or stages
@@ -3775,7 +3945,7 @@ var ReplayService = class {
3775
3945
  for (const file of files) {
3776
3946
  if (isBinaryFile(file)) continue;
3777
3947
  try {
3778
- const content = await readFileAsync(join6(this.outputDir, file), "utf-8");
3948
+ const content = await readFileAsync(join7(this.outputDir, file), "utf-8");
3779
3949
  snapshot[file] = content;
3780
3950
  } catch {
3781
3951
  }
@@ -4292,6 +4462,12 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
4292
4462
  );
4293
4463
  }
4294
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
+ }
4295
4471
  }
4296
4472
  /**
4297
4473
  * After applyPatches(), strip conflict markers from conflicting files
@@ -4303,9 +4479,9 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
4303
4479
  if (result.status !== "conflict" || !result.fileResults) continue;
4304
4480
  for (const fileResult of result.fileResults) {
4305
4481
  if (fileResult.status !== "conflict") continue;
4306
- const filePath = join6(this.outputDir, fileResult.file);
4482
+ const filePath = join7(this.outputDir, fileResult.file);
4307
4483
  try {
4308
- const content = readFileSync2(filePath, "utf-8");
4484
+ const content = readFileSync3(filePath, "utf-8");
4309
4485
  const stripped = stripConflictMarkers(content);
4310
4486
  writeFileSync2(filePath, stripped);
4311
4487
  } catch {
@@ -4335,12 +4511,11 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
4335
4511
  }
4336
4512
  /** @internal Used by Flow implementations in `src/flows/`. */
4337
4513
  readFernignorePatterns() {
4338
- const fernignorePath = join6(this.outputDir, ".fernignore");
4339
- if (!existsSync2(fernignorePath)) return [];
4340
- return readFileSync2(fernignorePath, "utf-8").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
4514
+ return readFernignorePatterns(this.outputDir);
4341
4515
  }
4342
4516
  /** @internal Used by Flow implementations in `src/flows/`. */
4343
- 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 ?? []);
4344
4519
  const conflictResults = results.filter((r) => r.status === "conflict");
4345
4520
  const conflictDetails = conflictResults.map((r) => {
4346
4521
  const conflictFiles = r.fileResults?.filter((f) => f.status === "conflict") ?? [];
@@ -4380,10 +4555,23 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
4380
4555
  conflictDetails: r.fileResults?.filter((f) => f.status === "conflict") ?? []
4381
4556
  })) : void 0,
4382
4557
  wouldApply: options?.dryRun ? patches : void 0,
4383
- 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
4384
4561
  };
4385
4562
  }
4386
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
+ }
4387
4575
  function computeCommitBuckets(results, absorbedPatchIds, patchesPassedToCommit) {
4388
4576
  const resultByPatchId = /* @__PURE__ */ new Map();
4389
4577
  for (const r of results) resultByPatchId.set(r.patch.id, r);
@@ -4432,7 +4620,7 @@ function computeLegacyFernignoreCleanse(patch, fernignorePatterns, computeConten
4432
4620
  }
4433
4621
  }
4434
4622
  }
4435
- const { stripped: patchContentStripped, content: newPatchContent, strippedSectionPaths } = stripFernignoreDiffSections(patch.patch_content, matchesFernignore);
4623
+ const { stripped: patchContentStripped, content: newPatchContent, strippedSectionPaths } = stripDiffSectionsByPredicate(patch.patch_content, matchesFernignore);
4436
4624
  const unchanged = strippedFromFiles.length === 0 && !snapshotChanged && !patchContentStripped;
4437
4625
  if (unchanged) {
4438
4626
  return {
@@ -4467,7 +4655,38 @@ function computeLegacyFernignoreCleanse(patch, fernignorePatterns, computeConten
4467
4655
  strippedPaths
4468
4656
  };
4469
4657
  }
4470
- 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) {
4471
4690
  const lines = patchContent.split("\n");
4472
4691
  const sectionStarts = [];
4473
4692
  for (let i = 0; i < lines.length; i++) {
@@ -4489,7 +4708,7 @@ function stripFernignoreDiffSections(patchContent, matchesFernignore) {
4489
4708
  const section = sectionStarts[i];
4490
4709
  const next = sectionStarts[i + 1];
4491
4710
  const endIdx = next ? next.idx : lines.length;
4492
- if (section.path !== null && matchesFernignore(section.path)) {
4711
+ if (section.path !== null && shouldStrip(section.path)) {
4493
4712
  stripped = true;
4494
4713
  strippedSectionPaths.push(section.path);
4495
4714
  continue;
@@ -4501,8 +4720,8 @@ function stripFernignoreDiffSections(patchContent, matchesFernignore) {
4501
4720
 
4502
4721
  // src/FernignoreMigrator.ts
4503
4722
  import { createHash as createHash2 } from "crypto";
4504
- import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, statSync, writeFileSync as writeFileSync3 } from "fs";
4505
- 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";
4506
4725
  import { minimatch as minimatch6 } from "minimatch";
4507
4726
  import { parse as parse2, stringify as stringify2 } from "yaml";
4508
4727
  var FernignoreMigrator = class {
@@ -4515,14 +4734,14 @@ var FernignoreMigrator = class {
4515
4734
  this.outputDir = outputDir;
4516
4735
  }
4517
4736
  fernignoreExists() {
4518
- return existsSync3(join7(this.outputDir, ".fernignore"));
4737
+ return existsSync3(join8(this.outputDir, ".fernignore"));
4519
4738
  }
4520
4739
  readFernignorePatterns() {
4521
- const fernignorePath = join7(this.outputDir, ".fernignore");
4740
+ const fernignorePath = join8(this.outputDir, ".fernignore");
4522
4741
  if (!existsSync3(fernignorePath)) {
4523
4742
  return [];
4524
4743
  }
4525
- const content = readFileSync3(fernignorePath, "utf-8");
4744
+ const content = readFileSync4(fernignorePath, "utf-8");
4526
4745
  return content.split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
4527
4746
  }
4528
4747
  /** Analyze .fernignore patterns vs git history. Creates synthetic patches for files differing from pristine generation. */
@@ -4627,7 +4846,7 @@ var FernignoreMigrator = class {
4627
4846
  return results;
4628
4847
  }
4629
4848
  readFileContent(filePath) {
4630
- const fullPath = join7(this.outputDir, filePath);
4849
+ const fullPath = join8(this.outputDir, filePath);
4631
4850
  if (!existsSync3(fullPath)) {
4632
4851
  return null;
4633
4852
  }
@@ -4636,7 +4855,7 @@ var FernignoreMigrator = class {
4636
4855
  if (stat.isDirectory()) {
4637
4856
  return null;
4638
4857
  }
4639
- return readFileSync3(fullPath, "utf-8");
4858
+ return readFileSync4(fullPath, "utf-8");
4640
4859
  } catch {
4641
4860
  return null;
4642
4861
  }
@@ -4692,10 +4911,10 @@ var FernignoreMigrator = class {
4692
4911
  };
4693
4912
  }
4694
4913
  movePatternsToReplayYml(patterns) {
4695
- const replayYmlPath = join7(this.outputDir, ".fern", "replay.yml");
4914
+ const replayYmlPath = join8(this.outputDir, ".fern", "replay.yml");
4696
4915
  let config = {};
4697
4916
  if (existsSync3(replayYmlPath)) {
4698
- const content = readFileSync3(replayYmlPath, "utf-8");
4917
+ const content = readFileSync4(replayYmlPath, "utf-8");
4699
4918
  config = parse2(content) ?? {};
4700
4919
  }
4701
4920
  const existing = config.exclude ?? [];
@@ -4711,8 +4930,8 @@ var FernignoreMigrator = class {
4711
4930
 
4712
4931
  // src/commands/bootstrap.ts
4713
4932
  import { createHash as createHash3 } from "crypto";
4714
- import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
4715
- 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";
4716
4935
  init_GitClient();
4717
4936
  async function bootstrap(outputDir, options) {
4718
4937
  const git = new GitClient(outputDir);
@@ -4879,10 +5098,10 @@ function parseGitLog(log) {
4879
5098
  }
4880
5099
  var REPLAY_FERNIGNORE_ENTRIES = [".fern/replay.lock", ".fern/replay.yml", ".gitattributes"];
4881
5100
  function ensureFernignoreEntries(outputDir) {
4882
- const fernignorePath = join8(outputDir, ".fernignore");
5101
+ const fernignorePath = join9(outputDir, ".fernignore");
4883
5102
  let content = "";
4884
5103
  if (existsSync4(fernignorePath)) {
4885
- content = readFileSync4(fernignorePath, "utf-8");
5104
+ content = readFileSync5(fernignorePath, "utf-8");
4886
5105
  }
4887
5106
  const lines = content.split("\n");
4888
5107
  const toAdd = [];
@@ -4903,10 +5122,10 @@ function ensureFernignoreEntries(outputDir) {
4903
5122
  }
4904
5123
  var GITATTRIBUTES_ENTRIES = [".fern/replay.lock linguist-generated=true"];
4905
5124
  function ensureGitattributesEntries(outputDir) {
4906
- const gitattributesPath = join8(outputDir, ".gitattributes");
5125
+ const gitattributesPath = join9(outputDir, ".gitattributes");
4907
5126
  let content = "";
4908
5127
  if (existsSync4(gitattributesPath)) {
4909
- content = readFileSync4(gitattributesPath, "utf-8");
5128
+ content = readFileSync5(gitattributesPath, "utf-8");
4910
5129
  }
4911
5130
  const lines = content.split("\n");
4912
5131
  const toAdd = [];
@@ -4933,6 +5152,7 @@ function computeContentHash(patchContent) {
4933
5152
  }
4934
5153
 
4935
5154
  // src/commands/forget.ts
5155
+ init_GitClient();
4936
5156
  import { minimatch as minimatch7 } from "minimatch";
4937
5157
  function parseDiffStat(patchContent) {
4938
5158
  let additions = 0;
@@ -4992,7 +5212,28 @@ var EMPTY_RESULT = {
4992
5212
  totalPatches: 0,
4993
5213
  warnings: []
4994
5214
  };
4995
- 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) {
4996
5237
  const lockManager = new LockfileManager(outputDir);
4997
5238
  if (!lockManager.exists()) {
4998
5239
  return { ...EMPTY_RESULT };
@@ -5003,9 +5244,7 @@ function forget(outputDir, options) {
5003
5244
  const removed = lock.patches.map(toMatchedPatch);
5004
5245
  const warnings = buildWarnings(lock.patches);
5005
5246
  if (!options.dryRun) {
5006
- for (const patch of lock.patches) {
5007
- lockManager.addForgottenHash(patch.content_hash);
5008
- }
5247
+ await dismissPatches(outputDir, lockManager, lock.patches);
5009
5248
  lockManager.clearPatches();
5010
5249
  lockManager.save();
5011
5250
  }
@@ -5034,8 +5273,8 @@ function forget(outputDir, options) {
5034
5273
  }
5035
5274
  const warnings = buildWarnings(patchesToRemove);
5036
5275
  if (!options.dryRun) {
5276
+ await dismissPatches(outputDir, lockManager, patchesToRemove);
5037
5277
  for (const patch of patchesToRemove) {
5038
- lockManager.addForgottenHash(patch.content_hash);
5039
5278
  lockManager.removePatch(patch.id);
5040
5279
  }
5041
5280
  lockManager.save();
@@ -5103,7 +5342,7 @@ function reset(outputDir, options) {
5103
5342
  // src/commands/resolve.ts
5104
5343
  init_GitClient();
5105
5344
  import { readFile as readFile4 } from "fs/promises";
5106
- import { join as join9 } from "path";
5345
+ import { join as join10 } from "path";
5107
5346
  async function resolve(outputDir, options) {
5108
5347
  const lockManager = new LockfileManager(outputDir);
5109
5348
  if (!lockManager.exists()) {
@@ -5143,15 +5382,16 @@ async function resolve(outputDir, options) {
5143
5382
  const patchesToCommit = [...resolvingPatches, ...unresolvedPatches];
5144
5383
  if (patchesToCommit.length > 0) {
5145
5384
  const currentGen = lock.current_generation;
5146
- const detector = new ReplayDetector(git, lockManager, outputDir);
5385
+ const detector = new ReplayDetector(git, lockManager, outputDir, readFernignorePatterns(outputDir));
5147
5386
  const warnings = [];
5387
+ const patchesDismissed = [];
5148
5388
  let patchesResolved = 0;
5149
5389
  const pass1 = [];
5150
5390
  for (const patch of patchesToCommit) {
5151
5391
  const result = await computePerPatchDiffWithFallback(
5152
5392
  patch,
5153
5393
  (f) => git.showFile(currentGen, f),
5154
- (f) => readFile4(join9(outputDir, f), "utf-8").catch(() => null),
5394
+ (f) => readFile4(join10(outputDir, f), "utf-8").catch(() => null),
5155
5395
  (files) => git.exec(["diff", currentGen, "--", ...files]).catch(() => null)
5156
5396
  );
5157
5397
  if (result === null) {
@@ -5185,7 +5425,27 @@ async function resolve(outputDir, options) {
5185
5425
  const r = pass1[i];
5186
5426
  if (r.kind === "skip") continue;
5187
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
+ });
5188
5447
  lockManager.removePatch(patch.id);
5448
+ patchesDismissed.push({ id: patch.id, message: patch.original_message });
5189
5449
  continue;
5190
5450
  }
5191
5451
  if (lastIndexByHash.get(r.hash) !== i) {
@@ -5207,7 +5467,7 @@ async function resolve(outputDir, options) {
5207
5467
  const theirsSnapshot = {};
5208
5468
  for (const file of filesForSnapshot) {
5209
5469
  if (isBinaryFile(file)) continue;
5210
- const content = await readFile4(join9(outputDir, file), "utf-8").catch(() => null);
5470
+ const content = await readFile4(join10(outputDir, file), "utf-8").catch(() => null);
5211
5471
  if (content != null) {
5212
5472
  theirsSnapshot[file] = content;
5213
5473
  }
@@ -5234,6 +5494,7 @@ async function resolve(outputDir, options) {
5234
5494
  commitSha: commitSha2,
5235
5495
  phase: "committed",
5236
5496
  patchesResolved,
5497
+ patchesDismissed: patchesDismissed.length > 0 ? patchesDismissed : void 0,
5237
5498
  warnings: warnings.length > 0 ? warnings : void 0
5238
5499
  };
5239
5500
  }
@@ -5281,7 +5542,9 @@ function status(outputDir) {
5281
5542
  lastGeneration: void 0,
5282
5543
  patches: [],
5283
5544
  unresolvedCount: 0,
5284
- excludePatterns: []
5545
+ excludePatterns: [],
5546
+ dismissed: [],
5547
+ legacyDismissedCount: 0
5285
5548
  };
5286
5549
  }
5287
5550
  const lock = lockManager.read();
@@ -5310,13 +5573,24 @@ function status(outputDir) {
5310
5573
  }
5311
5574
  const config = lockManager.getCustomizationsConfig();
5312
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;
5313
5585
  return {
5314
5586
  initialized: true,
5315
5587
  generationCount: lock.generations.length,
5316
5588
  lastGeneration,
5317
5589
  patches,
5318
5590
  unresolvedCount,
5319
- excludePatterns
5591
+ excludePatterns,
5592
+ dismissed,
5593
+ legacyDismissedCount
5320
5594
  };
5321
5595
  }
5322
5596
  export {