@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/cli.cjs CHANGED
@@ -13245,15 +13245,15 @@ __export(PatchApplyTheirs_exports, {
13245
13245
  async function applyPatchToContent(base, patchContent, filePath) {
13246
13246
  const fileDiff = extractFileDiff(patchContent, filePath);
13247
13247
  if (!fileDiff) return null;
13248
- const tempDir = await (0, import_promises5.mkdtemp)((0, import_node_path8.join)((0, import_node_os3.tmpdir)(), "replay-migrate-"));
13248
+ const tempDir = await (0, import_promises5.mkdtemp)((0, import_node_path9.join)((0, import_node_os3.tmpdir)(), "replay-migrate-"));
13249
13249
  const tempGit = new GitClient(tempDir);
13250
13250
  try {
13251
13251
  await tempGit.exec(["init"]);
13252
13252
  await tempGit.exec(["config", "user.email", "migrate@fern.com"]);
13253
13253
  await tempGit.exec(["config", "user.name", "Fern Replay Migration"]);
13254
13254
  await tempGit.exec(["config", "commit.gpgSign", "false"]);
13255
- const tempFilePath = (0, import_node_path8.join)(tempDir, filePath);
13256
- await (0, import_promises5.mkdir)((0, import_node_path8.dirname)(tempFilePath), { recursive: true });
13255
+ const tempFilePath = (0, import_node_path9.join)(tempDir, filePath);
13256
+ await (0, import_promises5.mkdir)((0, import_node_path9.dirname)(tempFilePath), { recursive: true });
13257
13257
  await (0, import_promises5.writeFile)(tempFilePath, base);
13258
13258
  await tempGit.exec(["add", filePath]);
13259
13259
  await tempGit.exec(["commit", "-m", `base for ${filePath}`, "--allow-empty"]);
@@ -13284,20 +13284,20 @@ function extractFileDiff(patchContent, filePath) {
13284
13284
  }
13285
13285
  return out.length > 0 ? out.join("\n") + "\n" : null;
13286
13286
  }
13287
- var import_promises5, import_node_os3, import_node_path8;
13287
+ var import_promises5, import_node_os3, import_node_path9;
13288
13288
  var init_PatchApplyTheirs = __esm({
13289
13289
  "src/PatchApplyTheirs.ts"() {
13290
13290
  "use strict";
13291
13291
  import_promises5 = require("fs/promises");
13292
13292
  import_node_os3 = require("os");
13293
- import_node_path8 = require("path");
13293
+ import_node_path9 = require("path");
13294
13294
  init_GitClient();
13295
13295
  }
13296
13296
  });
13297
13297
 
13298
13298
  // src/cli.ts
13299
- var import_node_path13 = require("path");
13300
- var import_node_fs6 = require("fs");
13299
+ var import_node_path14 = require("path");
13300
+ var import_node_fs7 = require("fs");
13301
13301
  var import_node_readline = require("readline");
13302
13302
  init_GitClient();
13303
13303
 
@@ -14827,6 +14827,22 @@ var LockfileManager = class {
14827
14827
  this.lock.forgotten_hashes.push(hash);
14828
14828
  }
14829
14829
  }
14830
+ /**
14831
+ * Record a dismissed customization: visibility metadata in `dismissals`
14832
+ * plus a tombstone in `forgotten_hashes` for every content hash.
14833
+ * `forgotten_hashes` stays the only list the detector consults; the
14834
+ * metadata exists so `status` can show what was dismissed.
14835
+ */
14836
+ addDismissal(record) {
14837
+ this.ensureLoaded();
14838
+ if (!this.lock.dismissals) {
14839
+ this.lock.dismissals = [];
14840
+ }
14841
+ this.lock.dismissals.push(record);
14842
+ for (const hash of record.content_hashes) {
14843
+ this.addForgottenHash(hash);
14844
+ }
14845
+ }
14830
14846
  getUnresolvedPatches() {
14831
14847
  this.ensureLoaded();
14832
14848
  return this.lock.patches.filter((p) => p.status === "unresolved");
@@ -15035,12 +15051,14 @@ async function capturePatchSnapshot(git, files, treeish) {
15035
15051
  return snapshot;
15036
15052
  }
15037
15053
  function filterInfrastructureAndSensitiveFiles(rawFilesOutput, fernignorePatterns) {
15038
- const allFiles = rawFilesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
15054
+ const rawFiles = rawFilesOutput.trim().split("\n").filter(Boolean);
15055
+ const infrastructureFiles = rawFiles.filter((f) => INFRASTRUCTURE_FILES.has(f) || f.startsWith(".fern/"));
15056
+ const allFiles = rawFiles.filter((f) => !INFRASTRUCTURE_FILES.has(f) && !f.startsWith(".fern/"));
15039
15057
  const sensitiveFiles = allFiles.filter((f) => isSensitiveFile(f));
15040
15058
  const nonSensitive = allFiles.filter((f) => !isSensitiveFile(f));
15041
15059
  const fernignoredFiles = nonSensitive.filter((f) => matchesFernignorePattern(f, fernignorePatterns));
15042
15060
  const files = nonSensitive.filter((f) => !matchesFernignorePattern(f, fernignorePatterns));
15043
- return { files, sensitiveFiles, fernignoredFiles };
15061
+ return { files, sensitiveFiles, fernignoredFiles, infrastructureFiles };
15044
15062
  }
15045
15063
  var ReplayDetector = class {
15046
15064
  git;
@@ -15048,6 +15066,15 @@ var ReplayDetector = class {
15048
15066
  sdkOutputDir;
15049
15067
  fernignorePatterns;
15050
15068
  warnings = [];
15069
+ /**
15070
+ * Structured degraded-state channel, mirroring `warnings`. Populated when
15071
+ * detection cannot guarantee customizations were carried forward (baseline
15072
+ * unreachable in this clone). Cleared per-run by `ReplayService.prepareReplay`
15073
+ * alongside `warnings`. Merged into `ReplayReport.degradedReasons` by each
15074
+ * flow handler. Degraded is observability only: detection results are
15075
+ * unchanged.
15076
+ */
15077
+ degradedReasons = [];
15051
15078
  constructor(git, lockManager, sdkOutputDir, fernignorePatterns = []) {
15052
15079
  this.git = git;
15053
15080
  this.lockManager = lockManager;
@@ -15118,6 +15145,10 @@ var ReplayDetector = class {
15118
15145
  this.warnings.push(
15119
15146
  `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.`
15120
15147
  );
15148
+ this.degradedReasons.push({
15149
+ code: "baseline-unresolvable",
15150
+ 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.`
15151
+ });
15121
15152
  }
15122
15153
  return { patches: [], revertedPatchIds: [] };
15123
15154
  }
@@ -15126,6 +15157,12 @@ var ReplayDetector = class {
15126
15157
  this.warnings.push(
15127
15158
  `Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to tree-diff detection (likely a shallow clone).`
15128
15159
  );
15160
+ if (lock.patches.length > 0) {
15161
+ this.degradedReasons.push({
15162
+ code: "generation-anchor-unreachable",
15163
+ 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.`
15164
+ });
15165
+ }
15129
15166
  return this.detectPatchesViaTreeDiff(
15130
15167
  lastGen,
15131
15168
  /* commitKnownMissing */
@@ -15165,54 +15202,27 @@ var ReplayDetector = class {
15165
15202
  if (lock.patches.find((p) => p.original_commit === commit.sha)) {
15166
15203
  continue;
15167
15204
  }
15168
- const parents = await this.git.getCommitParents(commit.sha);
15169
- let filesOutput;
15170
- try {
15171
- filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
15172
- } catch {
15205
+ const materialized = await this.materializeCommitPatch(commit.sha);
15206
+ if (materialized.sensitiveFiles.length > 0) {
15173
15207
  this.warnings.push(
15174
- `Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
15208
+ `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${materialized.sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
15175
15209
  );
15176
- continue;
15177
15210
  }
15178
- const { files, sensitiveFiles, fernignoredFiles } = filterInfrastructureAndSensitiveFiles(
15179
- filesOutput,
15180
- this.fernignorePatterns
15181
- );
15182
- if (sensitiveFiles.length > 0) {
15211
+ if (materialized.fernignoredFiles.length > 0) {
15183
15212
  this.warnings.push(
15184
- `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
15213
+ `.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.`
15185
15214
  );
15186
15215
  }
15187
- if (fernignoredFiles.length > 0) {
15216
+ if (materialized.status === "unreachable") {
15188
15217
  this.warnings.push(
15189
- `.fernignore-protected file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${fernignoredFiles.join(", ")}. These files are managed by .fernignore, not Replay.`
15218
+ `Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
15190
15219
  );
15191
- }
15192
- if (files.length === 0) {
15193
15220
  continue;
15194
15221
  }
15195
- const wasFiltered = sensitiveFiles.length > 0 || fernignoredFiles.length > 0;
15196
- const parentSha = parents[0];
15197
- let patchContent;
15198
- if (wasFiltered && parentSha) {
15199
- try {
15200
- patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
15201
- } catch {
15202
- continue;
15203
- }
15204
- if (!patchContent.trim()) continue;
15205
- } else {
15206
- try {
15207
- patchContent = await this.git.formatPatch(commit.sha);
15208
- } catch {
15209
- this.warnings.push(
15210
- `Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
15211
- );
15212
- continue;
15213
- }
15222
+ if (materialized.status === "empty") {
15223
+ continue;
15214
15224
  }
15215
- const contentHash = this.computeContentHash(patchContent);
15225
+ const { files, patchContent, contentHash } = materialized;
15216
15226
  if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
15217
15227
  continue;
15218
15228
  }
@@ -15289,6 +15299,63 @@ var ReplayDetector = class {
15289
15299
  const filteredPatches = survivingPatches.filter((_, i) => !revertIndicesToRemove.has(i));
15290
15300
  return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
15291
15301
  }
15302
+ /**
15303
+ * Materialize a commit into patch content exactly as detection does.
15304
+ *
15305
+ * Pipeline: `diff-tree --name-only` → filter infrastructure / sensitive /
15306
+ * `.fernignore`d files → scoped `git diff` when files were stripped,
15307
+ * otherwise full `git format-patch` → content hash.
15308
+ *
15309
+ * This is THE definition of a commit's re-emission identity: the content
15310
+ * hash this method returns is what `detectPatchesInRange` will compute if
15311
+ * the commit re-enters the scan range (e.g. after a regen PR is merged
15312
+ * with a merge commit). Callers that tombstone a dismissed patch must use
15313
+ * this hash — the stored `content_hash` drifts every time the patch is
15314
+ * carried forward, so it does not identify the commit on re-detection.
15315
+ *
15316
+ * Never throws; failures are reported through the `status` discriminant.
15317
+ */
15318
+ async materializeCommitPatch(sha) {
15319
+ let parents;
15320
+ let filesOutput;
15321
+ try {
15322
+ parents = await this.git.getCommitParents(sha);
15323
+ filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", sha]);
15324
+ } catch {
15325
+ return { status: "unreachable", sensitiveFiles: [], fernignoredFiles: [] };
15326
+ }
15327
+ const { files, sensitiveFiles, fernignoredFiles, infrastructureFiles } = filterInfrastructureAndSensitiveFiles(filesOutput, this.fernignorePatterns);
15328
+ if (files.length === 0) {
15329
+ return { status: "empty", sensitiveFiles, fernignoredFiles };
15330
+ }
15331
+ const wasFiltered = sensitiveFiles.length > 0 || fernignoredFiles.length > 0 || infrastructureFiles.length > 0;
15332
+ const parentSha = parents[0];
15333
+ let patchContent;
15334
+ if (wasFiltered && parentSha) {
15335
+ try {
15336
+ patchContent = await this.git.exec(["diff", parentSha, sha, "--", ...files]);
15337
+ } catch {
15338
+ return { status: "empty", sensitiveFiles, fernignoredFiles };
15339
+ }
15340
+ if (!patchContent.trim()) {
15341
+ return { status: "empty", sensitiveFiles, fernignoredFiles };
15342
+ }
15343
+ } else {
15344
+ try {
15345
+ patchContent = await this.git.formatPatch(sha);
15346
+ } catch {
15347
+ return { status: "unreachable", sensitiveFiles, fernignoredFiles };
15348
+ }
15349
+ }
15350
+ return {
15351
+ status: "ok",
15352
+ files,
15353
+ sensitiveFiles,
15354
+ fernignoredFiles,
15355
+ patchContent,
15356
+ contentHash: this.computeContentHash(patchContent)
15357
+ };
15358
+ }
15292
15359
  /**
15293
15360
  * Compute content hash for deduplication.
15294
15361
  *
@@ -15393,6 +15460,13 @@ var ReplayDetector = class {
15393
15460
  async detectPatchesViaTreeDiff(lastGen, commitKnownMissing) {
15394
15461
  const diffBase = await this.resolveDiffBase(lastGen, commitKnownMissing);
15395
15462
  if (!diffBase) {
15463
+ this.warnings.push(
15464
+ `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.`
15465
+ );
15466
+ this.degradedReasons.push({
15467
+ code: "generation-tree-unreachable",
15468
+ 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.`
15469
+ });
15396
15470
  return this.detectPatchesViaCommitScan();
15397
15471
  }
15398
15472
  const lockForGuard = this.lockManager.read();
@@ -15505,10 +15579,7 @@ var ReplayDetector = class {
15505
15579
  continue;
15506
15580
  }
15507
15581
  const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
15508
- const { files, sensitiveFiles, fernignoredFiles } = filterInfrastructureAndSensitiveFiles(
15509
- filesOutput,
15510
- this.fernignorePatterns
15511
- );
15582
+ const { files, sensitiveFiles, fernignoredFiles, infrastructureFiles } = filterInfrastructureAndSensitiveFiles(filesOutput, this.fernignorePatterns);
15512
15583
  if (sensitiveFiles.length > 0) {
15513
15584
  this.warnings.push(
15514
15585
  `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
@@ -15519,7 +15590,8 @@ var ReplayDetector = class {
15519
15590
  `.fernignore-protected file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${fernignoredFiles.join(", ")}. These files are managed by .fernignore, not Replay.`
15520
15591
  );
15521
15592
  }
15522
- if ((sensitiveFiles.length > 0 || fernignoredFiles.length > 0) && files.length > 0) {
15593
+ const wasFiltered = sensitiveFiles.length > 0 || fernignoredFiles.length > 0 || infrastructureFiles.length > 0;
15594
+ if (wasFiltered && files.length > 0) {
15523
15595
  if (parents.length === 0) {
15524
15596
  continue;
15525
15597
  }
@@ -15623,9 +15695,9 @@ var ReplayDetector = class {
15623
15695
  };
15624
15696
 
15625
15697
  // src/ReplayService.ts
15626
- var import_node_fs2 = require("fs");
15698
+ var import_node_fs3 = require("fs");
15627
15699
  var import_promises6 = require("fs/promises");
15628
- var import_node_path9 = require("path");
15700
+ var import_node_path10 = require("path");
15629
15701
  init_GitClient();
15630
15702
 
15631
15703
  // src/ReplayApplicator.ts
@@ -16935,6 +17007,15 @@ function isDiffLineForFile(diffLine, filePath) {
16935
17007
  return match2 !== null && match2[1] === filePath;
16936
17008
  }
16937
17009
 
17010
+ // src/shared/fernignore.ts
17011
+ var import_node_fs2 = require("fs");
17012
+ var import_node_path7 = require("path");
17013
+ function readFernignorePatterns(outputDir) {
17014
+ const fernignorePath = (0, import_node_path7.join)(outputDir, ".fernignore");
17015
+ if (!(0, import_node_fs2.existsSync)(fernignorePath)) return [];
17016
+ return (0, import_node_fs2.readFileSync)(fernignorePath, "utf-8").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
17017
+ }
17018
+
16938
17019
  // src/ReplayCommitter.ts
16939
17020
  var ReplayCommitter = class {
16940
17021
  git;
@@ -17040,7 +17121,7 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
17040
17121
  // src/PatchRegionDiff.ts
17041
17122
  var import_promises4 = require("fs/promises");
17042
17123
  var import_node_os2 = require("os");
17043
- var import_node_path7 = require("path");
17124
+ var import_node_path8 = require("path");
17044
17125
  var import_node_child_process = require("child_process");
17045
17126
  init_HybridReconstruction();
17046
17127
  function normalizeHunkBody(hunk) {
@@ -17258,10 +17339,10 @@ function overlayRegions(currentGenLines, locInCurrentGen, workingTreeLines, locI
17258
17339
  }
17259
17340
  async function unifiedDiff(beforeContent, afterContent, filePath) {
17260
17341
  if (beforeContent === afterContent) return "";
17261
- const tmp = await (0, import_promises4.mkdtemp)((0, import_node_path7.join)((0, import_node_os2.tmpdir)(), "fern-replay-pp-"));
17342
+ const tmp = await (0, import_promises4.mkdtemp)((0, import_node_path8.join)((0, import_node_os2.tmpdir)(), "fern-replay-pp-"));
17262
17343
  try {
17263
- const beforePath = (0, import_node_path7.join)(tmp, "before");
17264
- const afterPath = (0, import_node_path7.join)(tmp, "after");
17344
+ const beforePath = (0, import_node_path8.join)(tmp, "before");
17345
+ const afterPath = (0, import_node_path8.join)(tmp, "after");
17265
17346
  await (0, import_promises4.writeFile)(beforePath, beforeContent, "utf-8");
17266
17347
  await (0, import_promises4.writeFile)(afterPath, afterContent, "utf-8");
17267
17348
  const raw = await runGitDiffNoIndex(beforePath, afterPath);
@@ -17436,6 +17517,7 @@ var FirstGenerationFlow = class {
17436
17517
  patchesToApply: [],
17437
17518
  newPatchIds: /* @__PURE__ */ new Set(),
17438
17519
  detectorWarnings: [],
17520
+ detectorDegraded: [],
17439
17521
  revertedCount: 0,
17440
17522
  genSha: genRecord.commit_sha,
17441
17523
  optionsSnapshot: options
@@ -17495,6 +17577,7 @@ var SkipApplicationFlow = class {
17495
17577
  patchesToApply: [],
17496
17578
  newPatchIds: /* @__PURE__ */ new Set(),
17497
17579
  detectorWarnings: [],
17580
+ detectorDegraded: [],
17498
17581
  revertedCount: 0,
17499
17582
  genSha: genRecord.commit_sha,
17500
17583
  optionsSnapshot: options
@@ -17532,8 +17615,10 @@ var NoPatchesFlow = class {
17532
17615
  const previousGenerationSha = preLock.current_generation ?? null;
17533
17616
  let { patches: newPatches, revertedPatchIds } = await this.service.detector.detectNewPatches();
17534
17617
  const detectorWarnings = [...this.service.detector.warnings];
17618
+ const detectorDegraded = [...this.service.detector.degradedReasons];
17535
17619
  if (options?.dryRun) {
17536
17620
  const dryRunWarnings = [...detectorWarnings, ...this.service.warnings];
17621
+ const dryRunDegraded = dedupeDegradedReasons([...detectorDegraded, ...this.service.degradedReasons]);
17537
17622
  const preparedReport = {
17538
17623
  flow: "no-patches",
17539
17624
  patchesDetected: newPatches.length,
@@ -17543,7 +17628,9 @@ var NoPatchesFlow = class {
17543
17628
  patchesReverted: revertedPatchIds.length,
17544
17629
  conflicts: [],
17545
17630
  wouldApply: newPatches,
17546
- warnings: dryRunWarnings.length > 0 ? dryRunWarnings : void 0
17631
+ warnings: dryRunWarnings.length > 0 ? dryRunWarnings : void 0,
17632
+ degraded: dryRunDegraded.length > 0 ? true : void 0,
17633
+ degradedReasons: dryRunDegraded.length > 0 ? dryRunDegraded : void 0
17547
17634
  };
17548
17635
  const headSha = await this.service.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
17549
17636
  return {
@@ -17597,6 +17684,7 @@ var NoPatchesFlow = class {
17597
17684
  patchesToApply: newPatches,
17598
17685
  newPatchIds: new Set(newPatches.map((p) => p.id)),
17599
17686
  detectorWarnings,
17687
+ detectorDegraded,
17600
17688
  revertedCount: revertedPatchIds.length,
17601
17689
  genSha: genRecord.commit_sha,
17602
17690
  optionsSnapshot: options
@@ -17640,6 +17728,7 @@ var NoPatchesFlow = class {
17640
17728
  }
17641
17729
  }
17642
17730
  const warnings = [...detectorWarnings, ...this.service.warnings];
17731
+ const degradedReasons = [...prep._prepared.detectorDegraded, ...this.service.degradedReasons];
17643
17732
  return this.service.buildReport(
17644
17733
  "no-patches",
17645
17734
  newPatches,
@@ -17648,7 +17737,8 @@ var NoPatchesFlow = class {
17648
17737
  warnings,
17649
17738
  rebaseCounts,
17650
17739
  void 0,
17651
- prep._prepared.revertedCount
17740
+ prep._prepared.revertedCount,
17741
+ degradedReasons
17652
17742
  );
17653
17743
  }
17654
17744
  };
@@ -17665,6 +17755,10 @@ var NormalRegenerationFlow = class {
17665
17755
  const existingPatches2 = this.service.lockManager.getPatches();
17666
17756
  const { patches: newPatches2, revertedPatchIds: dryRunReverted } = await this.service.detector.detectNewPatches();
17667
17757
  const warnings = [...this.service.detector.warnings, ...this.service.warnings];
17758
+ const dryRunDegraded = dedupeDegradedReasons([
17759
+ ...this.service.detector.degradedReasons,
17760
+ ...this.service.degradedReasons
17761
+ ]);
17668
17762
  const allPatches2 = [...existingPatches2, ...newPatches2];
17669
17763
  const preparedReport = {
17670
17764
  flow: "normal-regeneration",
@@ -17675,7 +17769,9 @@ var NormalRegenerationFlow = class {
17675
17769
  patchesReverted: dryRunReverted.length,
17676
17770
  conflicts: [],
17677
17771
  wouldApply: allPatches2,
17678
- warnings: warnings.length > 0 ? warnings : void 0
17772
+ warnings: warnings.length > 0 ? warnings : void 0,
17773
+ degraded: dryRunDegraded.length > 0 ? true : void 0,
17774
+ degradedReasons: dryRunDegraded.length > 0 ? dryRunDegraded : void 0
17679
17775
  };
17680
17776
  const headSha = await this.service.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
17681
17777
  return {
@@ -17714,6 +17810,7 @@ var NormalRegenerationFlow = class {
17714
17810
  existingPatches = this.service.lockManager.getPatches();
17715
17811
  let { patches: newPatches, revertedPatchIds } = await this.service.detector.detectNewPatches();
17716
17812
  const detectorWarnings = [...this.service.detector.warnings];
17813
+ const detectorDegraded = [...this.service.detector.degradedReasons];
17717
17814
  if (removedByPreRebase.length > 0) {
17718
17815
  const removedOriginalCommits = new Set(removedByPreRebase.map((p) => p.original_commit));
17719
17816
  const removedOriginalMessages = new Set(removedByPreRebase.map((p) => p.original_message));
@@ -17754,6 +17851,7 @@ var NormalRegenerationFlow = class {
17754
17851
  newPatchIds: new Set(newPatches.map((p) => p.id)),
17755
17852
  preRebaseCounts,
17756
17853
  detectorWarnings,
17854
+ detectorDegraded,
17757
17855
  revertedCount: revertedPatchIds.length,
17758
17856
  genSha: genRecord.commit_sha,
17759
17857
  optionsSnapshot: options
@@ -17802,6 +17900,7 @@ var NormalRegenerationFlow = class {
17802
17900
  await this.service.committer.commitReplay(appliedCount, allPatches, void 0, { buckets });
17803
17901
  }
17804
17902
  const warnings = [...detectorWarnings, ...this.service.warnings];
17903
+ const degradedReasons = [...prep._prepared.detectorDegraded, ...this.service.degradedReasons];
17805
17904
  return this.service.buildReport(
17806
17905
  "normal-regeneration",
17807
17906
  allPatches,
@@ -17810,7 +17909,8 @@ var NormalRegenerationFlow = class {
17810
17909
  warnings,
17811
17910
  rebaseCounts,
17812
17911
  prep._prepared.preRebaseCounts,
17813
- prep._prepared.revertedCount
17912
+ prep._prepared.revertedCount,
17913
+ degradedReasons
17814
17914
  );
17815
17915
  }
17816
17916
  };
@@ -17850,6 +17950,16 @@ var ReplayService = class {
17850
17950
  * @internal Used by Flow implementations in `src/flows/`.
17851
17951
  */
17852
17952
  warnings = [];
17953
+ /**
17954
+ * Service-level structured degraded reasons, mirroring `warnings`.
17955
+ * Populated at apply time (e.g. `recordUnreachableBaseWarnings` when a
17956
+ * patch's base-generation tree is unreachable). Merged with
17957
+ * `detector.degradedReasons` into `ReplayReport.degradedReasons` by each
17958
+ * flow handler. Cleared at the top of `prepareReplay()`.
17959
+ *
17960
+ * @internal Used by Flow implementations in `src/flows/`.
17961
+ */
17962
+ degradedReasons = [];
17853
17963
  /**
17854
17964
  * @internal Test-only accessor.
17855
17965
  * Returns the ReplayApplicator instance used for the last run. Tests use this
@@ -17893,7 +18003,10 @@ var ReplayService = class {
17893
18003
  async prepareReplay(options) {
17894
18004
  this.warnings = [];
17895
18005
  this.detector.warnings.length = 0;
18006
+ this.degradedReasons = [];
18007
+ this.detector.degradedReasons.length = 0;
17896
18008
  this.cleanseLegacyFernignoreEntries();
18009
+ this.cleanseInfrastructureFileEntries();
17897
18010
  return this.selectFlow(options).prepare(options);
17898
18011
  }
17899
18012
  /**
@@ -17947,6 +18060,50 @@ var ReplayService = class {
17947
18060
  this.lockManager.save();
17948
18061
  }
17949
18062
  }
18063
+ /**
18064
+ * Migration step: strips infrastructure file diffs (`.fernignore`, `.fern/*`)
18065
+ * from existing patches' `patch_content`. These diffs were bundled by older
18066
+ * detection logic that did not scope `git diff` when only infrastructure files
18067
+ * were stripped. Patches that end up with no surviving diff sections are
18068
+ * removed entirely.
18069
+ *
18070
+ * Idempotent: a second run is a no-op because the first run already removed
18071
+ * all infrastructure sections. Per-patch try/catch ensures isolation.
18072
+ */
18073
+ cleanseInfrastructureFileEntries() {
18074
+ if (!this.lockManager.exists()) return;
18075
+ this.lockManager.read();
18076
+ let lockfileMutated = false;
18077
+ const patches = this.lockManager.getPatches();
18078
+ for (const patch of patches) {
18079
+ try {
18080
+ const result = computeInfrastructureFileCleanse(
18081
+ patch,
18082
+ (content) => this.detector.computeContentHash(content)
18083
+ );
18084
+ if (result.unchanged) continue;
18085
+ if (result.remove) {
18086
+ this.lockManager.removePatch(patch.id);
18087
+ this.warnings.push(
18088
+ `Stripped infrastructure file diffs from patch ${patch.id}: patch contained only infrastructure file changes (${result.strippedPaths.join(", ")}) \u2014 removed.`
18089
+ );
18090
+ } else {
18091
+ this.lockManager.updatePatch(patch.id, {
18092
+ patch_content: result.patch_content,
18093
+ content_hash: result.content_hash
18094
+ });
18095
+ this.warnings.push(
18096
+ `Stripped infrastructure file diffs from patch ${patch.id}: removed bundled diffs for ${result.strippedPaths.join(", ")}.`
18097
+ );
18098
+ }
18099
+ lockfileMutated = true;
18100
+ } catch {
18101
+ }
18102
+ }
18103
+ if (lockfileMutated) {
18104
+ this.lockManager.save();
18105
+ }
18106
+ }
17950
18107
  /**
17951
18108
  * Phase 2 of the two-phase replay flow. Applies patches, rebases them against
17952
18109
  * the generation, saves the lockfile, and commits `[fern-replay]` (or stages
@@ -18202,7 +18359,7 @@ var ReplayService = class {
18202
18359
  for (const file of files) {
18203
18360
  if (isBinaryFile(file)) continue;
18204
18361
  try {
18205
- const content = await (0, import_promises6.readFile)((0, import_node_path9.join)(this.outputDir, file), "utf-8");
18362
+ const content = await (0, import_promises6.readFile)((0, import_node_path10.join)(this.outputDir, file), "utf-8");
18206
18363
  snapshot[file] = content;
18207
18364
  } catch {
18208
18365
  }
@@ -18719,6 +18876,12 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
18719
18876
  );
18720
18877
  }
18721
18878
  }
18879
+ if (warned.size > 0) {
18880
+ this.degradedReasons.push({
18881
+ code: "patch-base-unreachable",
18882
+ 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.`
18883
+ });
18884
+ }
18722
18885
  }
18723
18886
  /**
18724
18887
  * After applyPatches(), strip conflict markers from conflicting files
@@ -18730,11 +18893,11 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
18730
18893
  if (result.status !== "conflict" || !result.fileResults) continue;
18731
18894
  for (const fileResult of result.fileResults) {
18732
18895
  if (fileResult.status !== "conflict") continue;
18733
- const filePath = (0, import_node_path9.join)(this.outputDir, fileResult.file);
18896
+ const filePath = (0, import_node_path10.join)(this.outputDir, fileResult.file);
18734
18897
  try {
18735
- const content = (0, import_node_fs2.readFileSync)(filePath, "utf-8");
18898
+ const content = (0, import_node_fs3.readFileSync)(filePath, "utf-8");
18736
18899
  const stripped = stripConflictMarkers(content);
18737
- (0, import_node_fs2.writeFileSync)(filePath, stripped);
18900
+ (0, import_node_fs3.writeFileSync)(filePath, stripped);
18738
18901
  } catch {
18739
18902
  }
18740
18903
  }
@@ -18762,12 +18925,11 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
18762
18925
  }
18763
18926
  /** @internal Used by Flow implementations in `src/flows/`. */
18764
18927
  readFernignorePatterns() {
18765
- const fernignorePath = (0, import_node_path9.join)(this.outputDir, ".fernignore");
18766
- if (!(0, import_node_fs2.existsSync)(fernignorePath)) return [];
18767
- return (0, import_node_fs2.readFileSync)(fernignorePath, "utf-8").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
18928
+ return readFernignorePatterns(this.outputDir);
18768
18929
  }
18769
18930
  /** @internal Used by Flow implementations in `src/flows/`. */
18770
- buildReport(flow, patches, results, options, warnings, rebaseCounts, preRebaseCounts, patchesReverted) {
18931
+ buildReport(flow, patches, results, options, warnings, rebaseCounts, preRebaseCounts, patchesReverted, degradedReasons) {
18932
+ const degraded = dedupeDegradedReasons(degradedReasons ?? []);
18771
18933
  const conflictResults = results.filter((r) => r.status === "conflict");
18772
18934
  const conflictDetails = conflictResults.map((r) => {
18773
18935
  const conflictFiles = r.fileResults?.filter((f) => f.status === "conflict") ?? [];
@@ -18807,10 +18969,23 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
18807
18969
  conflictDetails: r.fileResults?.filter((f) => f.status === "conflict") ?? []
18808
18970
  })) : void 0,
18809
18971
  wouldApply: options?.dryRun ? patches : void 0,
18810
- warnings: warnings && warnings.length > 0 ? warnings : void 0
18972
+ warnings: warnings && warnings.length > 0 ? warnings : void 0,
18973
+ degraded: degraded.length > 0 ? true : void 0,
18974
+ degradedReasons: degraded.length > 0 ? degraded : void 0
18811
18975
  };
18812
18976
  }
18813
18977
  };
18978
+ function dedupeDegradedReasons(reasons) {
18979
+ const seen = /* @__PURE__ */ new Set();
18980
+ const out = [];
18981
+ for (const reason of reasons) {
18982
+ const key = `${reason.code}\0${reason.message}`;
18983
+ if (seen.has(key)) continue;
18984
+ seen.add(key);
18985
+ out.push(reason);
18986
+ }
18987
+ return out;
18988
+ }
18814
18989
  function computeCommitBuckets(results, absorbedPatchIds, patchesPassedToCommit) {
18815
18990
  const resultByPatchId = /* @__PURE__ */ new Map();
18816
18991
  for (const r of results) resultByPatchId.set(r.patch.id, r);
@@ -18859,7 +19034,7 @@ function computeLegacyFernignoreCleanse(patch, fernignorePatterns, computeConten
18859
19034
  }
18860
19035
  }
18861
19036
  }
18862
- const { stripped: patchContentStripped, content: newPatchContent, strippedSectionPaths } = stripFernignoreDiffSections(patch.patch_content, matchesFernignore);
19037
+ const { stripped: patchContentStripped, content: newPatchContent, strippedSectionPaths } = stripDiffSectionsByPredicate(patch.patch_content, matchesFernignore);
18863
19038
  const unchanged = strippedFromFiles.length === 0 && !snapshotChanged && !patchContentStripped;
18864
19039
  if (unchanged) {
18865
19040
  return {
@@ -18894,7 +19069,38 @@ function computeLegacyFernignoreCleanse(patch, fernignorePatterns, computeConten
18894
19069
  strippedPaths
18895
19070
  };
18896
19071
  }
18897
- function stripFernignoreDiffSections(patchContent, matchesFernignore) {
19072
+ function isInfrastructureFile(filePath) {
19073
+ return filePath === ".fernignore" || filePath.startsWith(".fern/");
19074
+ }
19075
+ function computeInfrastructureFileCleanse(patch, computeContentHash2) {
19076
+ const { stripped, content: newPatchContent, strippedSectionPaths } = stripDiffSectionsByPredicate(patch.patch_content, isInfrastructureFile);
19077
+ if (!stripped) {
19078
+ return {
19079
+ unchanged: true,
19080
+ remove: false,
19081
+ patch_content: patch.patch_content,
19082
+ content_hash: patch.content_hash,
19083
+ strippedPaths: []
19084
+ };
19085
+ }
19086
+ if (newPatchContent.trim() === "" || patch.files.length === 0) {
19087
+ return {
19088
+ unchanged: false,
19089
+ remove: true,
19090
+ patch_content: "",
19091
+ content_hash: "",
19092
+ strippedPaths: strippedSectionPaths
19093
+ };
19094
+ }
19095
+ return {
19096
+ unchanged: false,
19097
+ remove: false,
19098
+ patch_content: newPatchContent,
19099
+ content_hash: computeContentHash2(newPatchContent),
19100
+ strippedPaths: strippedSectionPaths
19101
+ };
19102
+ }
19103
+ function stripDiffSectionsByPredicate(patchContent, shouldStrip) {
18898
19104
  const lines = patchContent.split("\n");
18899
19105
  const sectionStarts = [];
18900
19106
  for (let i = 0; i < lines.length; i++) {
@@ -18916,7 +19122,7 @@ function stripFernignoreDiffSections(patchContent, matchesFernignore) {
18916
19122
  const section = sectionStarts[i];
18917
19123
  const next = sectionStarts[i + 1];
18918
19124
  const endIdx = next ? next.idx : lines.length;
18919
- if (section.path !== null && matchesFernignore(section.path)) {
19125
+ if (section.path !== null && shouldStrip(section.path)) {
18920
19126
  stripped = true;
18921
19127
  strippedSectionPaths.push(section.path);
18922
19128
  continue;
@@ -18928,8 +19134,8 @@ function stripFernignoreDiffSections(patchContent, matchesFernignore) {
18928
19134
 
18929
19135
  // src/FernignoreMigrator.ts
18930
19136
  var import_node_crypto2 = require("crypto");
18931
- var import_node_fs3 = require("fs");
18932
- var import_node_path10 = require("path");
19137
+ var import_node_fs4 = require("fs");
19138
+ var import_node_path11 = require("path");
18933
19139
  var import_yaml2 = __toESM(require_dist3(), 1);
18934
19140
  var FernignoreMigrator = class {
18935
19141
  git;
@@ -18941,14 +19147,14 @@ var FernignoreMigrator = class {
18941
19147
  this.outputDir = outputDir;
18942
19148
  }
18943
19149
  fernignoreExists() {
18944
- return (0, import_node_fs3.existsSync)((0, import_node_path10.join)(this.outputDir, ".fernignore"));
19150
+ return (0, import_node_fs4.existsSync)((0, import_node_path11.join)(this.outputDir, ".fernignore"));
18945
19151
  }
18946
19152
  readFernignorePatterns() {
18947
- const fernignorePath = (0, import_node_path10.join)(this.outputDir, ".fernignore");
18948
- if (!(0, import_node_fs3.existsSync)(fernignorePath)) {
19153
+ const fernignorePath = (0, import_node_path11.join)(this.outputDir, ".fernignore");
19154
+ if (!(0, import_node_fs4.existsSync)(fernignorePath)) {
18949
19155
  return [];
18950
19156
  }
18951
- const content = (0, import_node_fs3.readFileSync)(fernignorePath, "utf-8");
19157
+ const content = (0, import_node_fs4.readFileSync)(fernignorePath, "utf-8");
18952
19158
  return content.split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
18953
19159
  }
18954
19160
  /** Analyze .fernignore patterns vs git history. Creates synthetic patches for files differing from pristine generation. */
@@ -19053,16 +19259,16 @@ var FernignoreMigrator = class {
19053
19259
  return results;
19054
19260
  }
19055
19261
  readFileContent(filePath) {
19056
- const fullPath = (0, import_node_path10.join)(this.outputDir, filePath);
19057
- if (!(0, import_node_fs3.existsSync)(fullPath)) {
19262
+ const fullPath = (0, import_node_path11.join)(this.outputDir, filePath);
19263
+ if (!(0, import_node_fs4.existsSync)(fullPath)) {
19058
19264
  return null;
19059
19265
  }
19060
19266
  try {
19061
- const stat = (0, import_node_fs3.statSync)(fullPath);
19267
+ const stat = (0, import_node_fs4.statSync)(fullPath);
19062
19268
  if (stat.isDirectory()) {
19063
19269
  return null;
19064
19270
  }
19065
- return (0, import_node_fs3.readFileSync)(fullPath, "utf-8");
19271
+ return (0, import_node_fs4.readFileSync)(fullPath, "utf-8");
19066
19272
  } catch {
19067
19273
  return null;
19068
19274
  }
@@ -19118,27 +19324,27 @@ var FernignoreMigrator = class {
19118
19324
  };
19119
19325
  }
19120
19326
  movePatternsToReplayYml(patterns) {
19121
- const replayYmlPath = (0, import_node_path10.join)(this.outputDir, ".fern", "replay.yml");
19327
+ const replayYmlPath = (0, import_node_path11.join)(this.outputDir, ".fern", "replay.yml");
19122
19328
  let config = {};
19123
- if ((0, import_node_fs3.existsSync)(replayYmlPath)) {
19124
- const content = (0, import_node_fs3.readFileSync)(replayYmlPath, "utf-8");
19329
+ if ((0, import_node_fs4.existsSync)(replayYmlPath)) {
19330
+ const content = (0, import_node_fs4.readFileSync)(replayYmlPath, "utf-8");
19125
19331
  config = (0, import_yaml2.parse)(content) ?? {};
19126
19332
  }
19127
19333
  const existing = config.exclude ?? [];
19128
19334
  const merged = [.../* @__PURE__ */ new Set([...existing, ...patterns])];
19129
19335
  config.exclude = merged;
19130
- const dir = (0, import_node_path10.dirname)(replayYmlPath);
19131
- if (!(0, import_node_fs3.existsSync)(dir)) {
19132
- (0, import_node_fs3.mkdirSync)(dir, { recursive: true });
19336
+ const dir = (0, import_node_path11.dirname)(replayYmlPath);
19337
+ if (!(0, import_node_fs4.existsSync)(dir)) {
19338
+ (0, import_node_fs4.mkdirSync)(dir, { recursive: true });
19133
19339
  }
19134
- (0, import_node_fs3.writeFileSync)(replayYmlPath, (0, import_yaml2.stringify)(config, { lineWidth: 0 }), "utf-8");
19340
+ (0, import_node_fs4.writeFileSync)(replayYmlPath, (0, import_yaml2.stringify)(config, { lineWidth: 0 }), "utf-8");
19135
19341
  }
19136
19342
  };
19137
19343
 
19138
19344
  // src/commands/bootstrap.ts
19139
19345
  var import_node_crypto3 = require("crypto");
19140
- var import_node_fs4 = require("fs");
19141
- var import_node_path11 = require("path");
19346
+ var import_node_fs5 = require("fs");
19347
+ var import_node_path12 = require("path");
19142
19348
  init_GitClient();
19143
19349
  async function bootstrap(outputDir, options) {
19144
19350
  const git = new GitClient(outputDir);
@@ -19305,10 +19511,10 @@ function parseGitLog(log) {
19305
19511
  }
19306
19512
  var REPLAY_FERNIGNORE_ENTRIES = [".fern/replay.lock", ".fern/replay.yml", ".gitattributes"];
19307
19513
  function ensureFernignoreEntries(outputDir) {
19308
- const fernignorePath = (0, import_node_path11.join)(outputDir, ".fernignore");
19514
+ const fernignorePath = (0, import_node_path12.join)(outputDir, ".fernignore");
19309
19515
  let content = "";
19310
- if ((0, import_node_fs4.existsSync)(fernignorePath)) {
19311
- content = (0, import_node_fs4.readFileSync)(fernignorePath, "utf-8");
19516
+ if ((0, import_node_fs5.existsSync)(fernignorePath)) {
19517
+ content = (0, import_node_fs5.readFileSync)(fernignorePath, "utf-8");
19312
19518
  }
19313
19519
  const lines = content.split("\n");
19314
19520
  const toAdd = [];
@@ -19324,15 +19530,15 @@ function ensureFernignoreEntries(outputDir) {
19324
19530
  content += "\n";
19325
19531
  }
19326
19532
  content += toAdd.join("\n") + "\n";
19327
- (0, import_node_fs4.writeFileSync)(fernignorePath, content, "utf-8");
19533
+ (0, import_node_fs5.writeFileSync)(fernignorePath, content, "utf-8");
19328
19534
  return true;
19329
19535
  }
19330
19536
  var GITATTRIBUTES_ENTRIES = [".fern/replay.lock linguist-generated=true"];
19331
19537
  function ensureGitattributesEntries(outputDir) {
19332
- const gitattributesPath = (0, import_node_path11.join)(outputDir, ".gitattributes");
19538
+ const gitattributesPath = (0, import_node_path12.join)(outputDir, ".gitattributes");
19333
19539
  let content = "";
19334
- if ((0, import_node_fs4.existsSync)(gitattributesPath)) {
19335
- content = (0, import_node_fs4.readFileSync)(gitattributesPath, "utf-8");
19540
+ if ((0, import_node_fs5.existsSync)(gitattributesPath)) {
19541
+ content = (0, import_node_fs5.readFileSync)(gitattributesPath, "utf-8");
19336
19542
  }
19337
19543
  const lines = content.split("\n");
19338
19544
  const toAdd = [];
@@ -19348,7 +19554,7 @@ function ensureGitattributesEntries(outputDir) {
19348
19554
  content += "\n";
19349
19555
  }
19350
19556
  content += toAdd.join("\n") + "\n";
19351
- (0, import_node_fs4.writeFileSync)(gitattributesPath, content, "utf-8");
19557
+ (0, import_node_fs5.writeFileSync)(gitattributesPath, content, "utf-8");
19352
19558
  }
19353
19559
  function computeContentHash(patchContent) {
19354
19560
  const lines = patchContent.split("\n");
@@ -19359,6 +19565,7 @@ function computeContentHash(patchContent) {
19359
19565
  }
19360
19566
 
19361
19567
  // src/commands/forget.ts
19568
+ init_GitClient();
19362
19569
  function parseDiffStat(patchContent) {
19363
19570
  let additions = 0;
19364
19571
  let deletions = 0;
@@ -19417,7 +19624,28 @@ var EMPTY_RESULT = {
19417
19624
  totalPatches: 0,
19418
19625
  warnings: []
19419
19626
  };
19420
- function forget(outputDir, options) {
19627
+ async function dismissPatches(outputDir, lockManager, patches) {
19628
+ const git = new GitClient(outputDir);
19629
+ const detector = new ReplayDetector(git, lockManager, outputDir, readFernignorePatterns(outputDir));
19630
+ for (const patch of patches) {
19631
+ const hashes = [patch.content_hash];
19632
+ if (patch.original_commit) {
19633
+ const materialized = await detector.materializeCommitPatch(patch.original_commit);
19634
+ if (materialized.status === "ok" && !hashes.includes(materialized.contentHash)) {
19635
+ hashes.push(materialized.contentHash);
19636
+ }
19637
+ }
19638
+ lockManager.addDismissal({
19639
+ patch_id: patch.id,
19640
+ original_commit: patch.original_commit,
19641
+ original_message: patch.original_message,
19642
+ content_hashes: hashes,
19643
+ dismissed_at: (/* @__PURE__ */ new Date()).toISOString(),
19644
+ via: "forget"
19645
+ });
19646
+ }
19647
+ }
19648
+ async function forget(outputDir, options) {
19421
19649
  const lockManager = new LockfileManager(outputDir);
19422
19650
  if (!lockManager.exists()) {
19423
19651
  return { ...EMPTY_RESULT };
@@ -19428,9 +19656,7 @@ function forget(outputDir, options) {
19428
19656
  const removed = lock.patches.map(toMatchedPatch);
19429
19657
  const warnings = buildWarnings(lock.patches);
19430
19658
  if (!options.dryRun) {
19431
- for (const patch of lock.patches) {
19432
- lockManager.addForgottenHash(patch.content_hash);
19433
- }
19659
+ await dismissPatches(outputDir, lockManager, lock.patches);
19434
19660
  lockManager.clearPatches();
19435
19661
  lockManager.save();
19436
19662
  }
@@ -19459,8 +19685,8 @@ function forget(outputDir, options) {
19459
19685
  }
19460
19686
  const warnings = buildWarnings(patchesToRemove);
19461
19687
  if (!options.dryRun) {
19688
+ await dismissPatches(outputDir, lockManager, patchesToRemove);
19462
19689
  for (const patch of patchesToRemove) {
19463
- lockManager.addForgottenHash(patch.content_hash);
19464
19690
  lockManager.removePatch(patch.id);
19465
19691
  }
19466
19692
  lockManager.save();
@@ -19501,7 +19727,7 @@ function forget(outputDir, options) {
19501
19727
  }
19502
19728
 
19503
19729
  // src/commands/reset.ts
19504
- var import_node_fs5 = require("fs");
19730
+ var import_node_fs6 = require("fs");
19505
19731
  function reset(outputDir, options) {
19506
19732
  const lockManager = new LockfileManager(outputDir);
19507
19733
  if (!lockManager.exists()) {
@@ -19515,7 +19741,7 @@ function reset(outputDir, options) {
19515
19741
  const lock = lockManager.read();
19516
19742
  const patchCount = lock.patches.length;
19517
19743
  if (!options?.dryRun) {
19518
- (0, import_node_fs5.unlinkSync)(lockManager.lockfilePath);
19744
+ (0, import_node_fs6.unlinkSync)(lockManager.lockfilePath);
19519
19745
  }
19520
19746
  return {
19521
19747
  success: true,
@@ -19527,7 +19753,7 @@ function reset(outputDir, options) {
19527
19753
 
19528
19754
  // src/commands/resolve.ts
19529
19755
  var import_promises7 = require("fs/promises");
19530
- var import_node_path12 = require("path");
19756
+ var import_node_path13 = require("path");
19531
19757
  init_GitClient();
19532
19758
  async function resolve(outputDir, options) {
19533
19759
  const lockManager = new LockfileManager(outputDir);
@@ -19568,15 +19794,16 @@ async function resolve(outputDir, options) {
19568
19794
  const patchesToCommit = [...resolvingPatches, ...unresolvedPatches];
19569
19795
  if (patchesToCommit.length > 0) {
19570
19796
  const currentGen = lock.current_generation;
19571
- const detector = new ReplayDetector(git, lockManager, outputDir);
19797
+ const detector = new ReplayDetector(git, lockManager, outputDir, readFernignorePatterns(outputDir));
19572
19798
  const warnings = [];
19799
+ const patchesDismissed = [];
19573
19800
  let patchesResolved = 0;
19574
19801
  const pass1 = [];
19575
19802
  for (const patch of patchesToCommit) {
19576
19803
  const result = await computePerPatchDiffWithFallback(
19577
19804
  patch,
19578
19805
  (f) => git.showFile(currentGen, f),
19579
- (f) => (0, import_promises7.readFile)((0, import_node_path12.join)(outputDir, f), "utf-8").catch(() => null),
19806
+ (f) => (0, import_promises7.readFile)((0, import_node_path13.join)(outputDir, f), "utf-8").catch(() => null),
19580
19807
  (files) => git.exec(["diff", currentGen, "--", ...files]).catch(() => null)
19581
19808
  );
19582
19809
  if (result === null) {
@@ -19610,7 +19837,27 @@ async function resolve(outputDir, options) {
19610
19837
  const r = pass1[i];
19611
19838
  if (r.kind === "skip") continue;
19612
19839
  if (r.kind === "revert") {
19840
+ const hashes = [patch.content_hash];
19841
+ if (patch.original_commit) {
19842
+ const materialized = await detector.materializeCommitPatch(patch.original_commit);
19843
+ if (materialized.status === "ok" && !hashes.includes(materialized.contentHash)) {
19844
+ hashes.push(materialized.contentHash);
19845
+ } else if (materialized.status === "unreachable") {
19846
+ warnings.push(
19847
+ `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.`
19848
+ );
19849
+ }
19850
+ }
19851
+ lockManager.addDismissal({
19852
+ patch_id: patch.id,
19853
+ original_commit: patch.original_commit,
19854
+ original_message: patch.original_message,
19855
+ content_hashes: hashes,
19856
+ dismissed_at: (/* @__PURE__ */ new Date()).toISOString(),
19857
+ via: "resolve"
19858
+ });
19613
19859
  lockManager.removePatch(patch.id);
19860
+ patchesDismissed.push({ id: patch.id, message: patch.original_message });
19614
19861
  continue;
19615
19862
  }
19616
19863
  if (lastIndexByHash.get(r.hash) !== i) {
@@ -19632,7 +19879,7 @@ async function resolve(outputDir, options) {
19632
19879
  const theirsSnapshot = {};
19633
19880
  for (const file of filesForSnapshot) {
19634
19881
  if (isBinaryFile(file)) continue;
19635
- const content = await (0, import_promises7.readFile)((0, import_node_path12.join)(outputDir, file), "utf-8").catch(() => null);
19882
+ const content = await (0, import_promises7.readFile)((0, import_node_path13.join)(outputDir, file), "utf-8").catch(() => null);
19636
19883
  if (content != null) {
19637
19884
  theirsSnapshot[file] = content;
19638
19885
  }
@@ -19659,6 +19906,7 @@ async function resolve(outputDir, options) {
19659
19906
  commitSha: commitSha2,
19660
19907
  phase: "committed",
19661
19908
  patchesResolved,
19909
+ patchesDismissed: patchesDismissed.length > 0 ? patchesDismissed : void 0,
19662
19910
  warnings: warnings.length > 0 ? warnings : void 0
19663
19911
  };
19664
19912
  }
@@ -19706,7 +19954,9 @@ function status(outputDir) {
19706
19954
  lastGeneration: void 0,
19707
19955
  patches: [],
19708
19956
  unresolvedCount: 0,
19709
- excludePatterns: []
19957
+ excludePatterns: [],
19958
+ dismissed: [],
19959
+ legacyDismissedCount: 0
19710
19960
  };
19711
19961
  }
19712
19962
  const lock = lockManager.read();
@@ -19735,13 +19985,24 @@ function status(outputDir) {
19735
19985
  }
19736
19986
  const config = lockManager.getCustomizationsConfig();
19737
19987
  const excludePatterns = config.exclude ?? [];
19988
+ const dismissals = lock.dismissals ?? [];
19989
+ const dismissed = dismissals.map((d) => ({
19990
+ message: d.original_message,
19991
+ sha: d.original_commit.slice(0, 7),
19992
+ via: d.via,
19993
+ dismissedAt: d.dismissed_at
19994
+ }));
19995
+ const coveredHashes = new Set(dismissals.flatMap((d) => d.content_hashes));
19996
+ const legacyDismissedCount = (lock.forgotten_hashes ?? []).filter((h) => !coveredHashes.has(h)).length;
19738
19997
  return {
19739
19998
  initialized: true,
19740
19999
  generationCount: lock.generations.length,
19741
20000
  lastGeneration,
19742
20001
  patches,
19743
20002
  unresolvedCount,
19744
- excludePatterns
20003
+ excludePatterns,
20004
+ dismissed,
20005
+ legacyDismissedCount
19745
20006
  };
19746
20007
  }
19747
20008
 
@@ -19829,7 +20090,7 @@ function parseArgs(argv) {
19829
20090
  positionals.push(arg);
19830
20091
  }
19831
20092
  }
19832
- return { command, dir: (0, import_node_path13.resolve)(dir), flags, positionals };
20093
+ return { command, dir: (0, import_node_path14.resolve)(dir), flags, positionals };
19833
20094
  }
19834
20095
  async function runBootstrap(dir, flags) {
19835
20096
  const dryRun = !!flags.dryRun;
@@ -19907,6 +20168,7 @@ async function runStatus(dir, flags) {
19907
20168
  }
19908
20169
  if (result.patches.length === 0) {
19909
20170
  console.log("\nNo customizations tracked. Edit SDK files and commit \u2014 Replay will detect them on next generation.");
20171
+ printDismissedSection(result);
19910
20172
  return;
19911
20173
  }
19912
20174
  const unresolvedSuffix = result.unresolvedCount > 0 ? `, ${result.unresolvedCount} unresolved` : "";
@@ -19944,6 +20206,26 @@ ${prefix}${patch.id} [${patch.type}] "${patch.message}"`);
19944
20206
  console.log(` ... and ${result.patches.length - maxDisplay} more`);
19945
20207
  }
19946
20208
  }
20209
+ printDismissedSection(result);
20210
+ }
20211
+ function printDismissedSection(result) {
20212
+ if (result.dismissed.length === 0 && result.legacyDismissedCount === 0) {
20213
+ return;
20214
+ }
20215
+ const total = result.dismissed.length + result.legacyDismissedCount;
20216
+ console.log(`
20217
+ Dismissed customizations: ${total}`);
20218
+ for (const d of result.dismissed) {
20219
+ const shaSuffix = d.sha ? ` (${d.sha})` : "";
20220
+ const date = d.dismissedAt ? d.dismissedAt.slice(0, 10) : "";
20221
+ const dateSuffix = date ? ` on ${date}` : "";
20222
+ console.log(` "${d.message}"${shaSuffix} \u2014 dismissed via ${d.via}${dateSuffix}`);
20223
+ }
20224
+ if (result.legacyDismissedCount > 0) {
20225
+ console.log(
20226
+ ` ... and ${result.legacyDismissedCount} dismissed before this version \u2014 details unavailable`
20227
+ );
20228
+ }
19947
20229
  }
19948
20230
  async function runDetect(dir) {
19949
20231
  const lockManager = new LockfileManager(dir);
@@ -19955,6 +20237,7 @@ async function runDetect(dir) {
19955
20237
  const git = new GitClient(dir);
19956
20238
  const detector = new ReplayDetector(git, lockManager, dir);
19957
20239
  const { patches, revertedPatchIds } = await detector.detectNewPatches();
20240
+ printDegradedBanner(detector.degradedReasons);
19958
20241
  if (detector.warnings.length > 0) {
19959
20242
  for (const w of detector.warnings) console.log(`Warning: ${w}`);
19960
20243
  console.log();
@@ -19985,7 +20268,20 @@ async function runReplay(dir, flags) {
19985
20268
  const report = await service.runReplay({ dryRun });
19986
20269
  printReport(report);
19987
20270
  }
20271
+ function printDegradedBanner(reasons) {
20272
+ if (reasons.length === 0) return;
20273
+ console.log("============================================================");
20274
+ console.log(" REPLAY DEGRADED \u2014 customizations may not have been");
20275
+ console.log(" carried forward in this run. Review the result before");
20276
+ console.log(" merging.");
20277
+ console.log("============================================================");
20278
+ for (const reason of reasons) {
20279
+ console.log(` [${reason.code}] ${reason.message}`);
20280
+ }
20281
+ console.log();
20282
+ }
19988
20283
  function printReport(report) {
20284
+ printDegradedBanner(report.degradedReasons ?? []);
19989
20285
  console.log(`Flow: ${report.flow}`);
19990
20286
  console.log(`Patches detected: ${report.patchesDetected}`);
19991
20287
  console.log(`Patches applied: ${report.patchesApplied}`);
@@ -20153,7 +20449,7 @@ async function runForget(dir, flags, positionals) {
20153
20449
  const pattern = !isPatchIdMode && positionals.length > 0 ? positionals[0] : void 0;
20154
20450
  const patchIds = isPatchIdMode ? positionals : void 0;
20155
20451
  if (all) {
20156
- const preview = forget(dir, { all: true, dryRun: true });
20452
+ const preview = await forget(dir, { all: true, dryRun: true });
20157
20453
  if (!preview.initialized) {
20158
20454
  console.log("Replay is not initialized. Run 'fern replay init' to get started.");
20159
20455
  process.exit(1);
@@ -20172,14 +20468,14 @@ async function runForget(dir, flags, positionals) {
20172
20468
  return;
20173
20469
  }
20174
20470
  }
20175
- const finalResult = dryRun ? preview : forget(dir, { all: true });
20471
+ const finalResult = dryRun ? preview : await forget(dir, { all: true });
20176
20472
  printForgetWarnings(finalResult.warnings);
20177
20473
  const verb2 = dryRun ? "Would remove" : "Removed";
20178
20474
  console.log(`${verb2} ${finalResult.removed.length} patches. ${finalResult.remaining} patches remaining.`);
20179
20475
  return;
20180
20476
  }
20181
20477
  if (isPatchIdMode && patchIds) {
20182
- const result2 = forget(dir, { patchIds, dryRun });
20478
+ const result2 = await forget(dir, { patchIds, dryRun });
20183
20479
  if (!result2.initialized) {
20184
20480
  console.log("Replay is not initialized. Run 'fern replay init' to get started.");
20185
20481
  process.exit(1);
@@ -20206,7 +20502,7 @@ async function runForget(dir, flags, positionals) {
20206
20502
  console.log(`${result2.remaining} patches remaining.`);
20207
20503
  return;
20208
20504
  }
20209
- const searchResult = pattern ? forget(dir, { pattern }) : forget(dir);
20505
+ const searchResult = pattern ? await forget(dir, { pattern }) : await forget(dir);
20210
20506
  if (!searchResult.initialized) {
20211
20507
  console.log("Replay is not initialized. Run 'fern replay init' to get started.");
20212
20508
  process.exit(1);
@@ -20254,7 +20550,7 @@ This will remove ${selectedIds.length} patch(es). Affected files will be overwri
20254
20550
  return;
20255
20551
  }
20256
20552
  }
20257
- const result = forget(dir, { patchIds: selectedIds, dryRun });
20553
+ const result = await forget(dir, { patchIds: selectedIds, dryRun });
20258
20554
  printForgetWarnings(result.warnings);
20259
20555
  const verb = dryRun ? "Would remove" : "Removed";
20260
20556
  console.log(`
@@ -20321,12 +20617,23 @@ Resolve the conflicts in your editor, then run 'fern-replay resolve ${dir}' agai
20321
20617
  if (result.patchesResolved) {
20322
20618
  console.log(`Resolution complete. Updated ${result.patchesResolved} patch(es) in lockfile.`);
20323
20619
  }
20620
+ if (result.patchesDismissed && result.patchesDismissed.length > 0) {
20621
+ console.log(`Permanently dismissed ${result.patchesDismissed.length} customization(s) (you kept the generated code):`);
20622
+ for (const d of result.patchesDismissed) {
20623
+ console.log(` "${d.message}" (${d.id}) \u2014 will not return on future generations`);
20624
+ }
20625
+ }
20626
+ if (result.warnings) {
20627
+ for (const w of result.warnings) {
20628
+ console.log(`Warning: ${w}`);
20629
+ }
20630
+ }
20324
20631
  console.log(`Created [fern-replay] commit: ${result.commitSha?.slice(0, 7)}`);
20325
20632
  console.log("Push to update the PR.");
20326
20633
  }
20327
20634
  async function main() {
20328
20635
  const { command, dir, flags, positionals } = parseArgs(process.argv);
20329
- if (!(0, import_node_fs6.existsSync)(dir)) {
20636
+ if (!(0, import_node_fs7.existsSync)(dir)) {
20330
20637
  console.error(`Directory not found: ${dir}`);
20331
20638
  process.exit(1);
20332
20639
  }