@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/cli.cjs CHANGED
@@ -5379,6 +5379,11 @@ var init_GitClient = __esm({
5379
5379
  proc.stderr.on("data", (data) => {
5380
5380
  stderr += data.toString();
5381
5381
  });
5382
+ proc.on("error", (err) => {
5383
+ reject(new Error(`git ${args.join(" ")} failed to spawn: ${err.message}`));
5384
+ });
5385
+ proc.stdin.on("error", () => {
5386
+ });
5382
5387
  proc.on("close", (code) => {
5383
5388
  if (code === 0) {
5384
5389
  resolve2(stdout);
@@ -13240,15 +13245,15 @@ __export(PatchApplyTheirs_exports, {
13240
13245
  async function applyPatchToContent(base, patchContent, filePath) {
13241
13246
  const fileDiff = extractFileDiff(patchContent, filePath);
13242
13247
  if (!fileDiff) return null;
13243
- 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-"));
13244
13249
  const tempGit = new GitClient(tempDir);
13245
13250
  try {
13246
13251
  await tempGit.exec(["init"]);
13247
13252
  await tempGit.exec(["config", "user.email", "migrate@fern.com"]);
13248
13253
  await tempGit.exec(["config", "user.name", "Fern Replay Migration"]);
13249
13254
  await tempGit.exec(["config", "commit.gpgSign", "false"]);
13250
- const tempFilePath = (0, import_node_path8.join)(tempDir, filePath);
13251
- 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 });
13252
13257
  await (0, import_promises5.writeFile)(tempFilePath, base);
13253
13258
  await tempGit.exec(["add", filePath]);
13254
13259
  await tempGit.exec(["commit", "-m", `base for ${filePath}`, "--allow-empty"]);
@@ -13279,20 +13284,20 @@ function extractFileDiff(patchContent, filePath) {
13279
13284
  }
13280
13285
  return out.length > 0 ? out.join("\n") + "\n" : null;
13281
13286
  }
13282
- var import_promises5, import_node_os3, import_node_path8;
13287
+ var import_promises5, import_node_os3, import_node_path9;
13283
13288
  var init_PatchApplyTheirs = __esm({
13284
13289
  "src/PatchApplyTheirs.ts"() {
13285
13290
  "use strict";
13286
13291
  import_promises5 = require("fs/promises");
13287
13292
  import_node_os3 = require("os");
13288
- import_node_path8 = require("path");
13293
+ import_node_path9 = require("path");
13289
13294
  init_GitClient();
13290
13295
  }
13291
13296
  });
13292
13297
 
13293
13298
  // src/cli.ts
13294
- var import_node_path13 = require("path");
13295
- var import_node_fs6 = require("fs");
13299
+ var import_node_path14 = require("path");
13300
+ var import_node_fs7 = require("fs");
13296
13301
  var import_node_readline = require("readline");
13297
13302
  init_GitClient();
13298
13303
 
@@ -14822,6 +14827,22 @@ var LockfileManager = class {
14822
14827
  this.lock.forgotten_hashes.push(hash);
14823
14828
  }
14824
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
+ }
14825
14846
  getUnresolvedPatches() {
14826
14847
  this.ensureLoaded();
14827
14848
  return this.lock.patches.filter((p) => p.status === "unresolved");
@@ -14981,7 +15002,8 @@ function parseRevertedMessage(subject) {
14981
15002
  var INFRASTRUCTURE_FILES = /* @__PURE__ */ new Set([".fernignore"]);
14982
15003
  function matchesFernignorePattern(filePath, patterns) {
14983
15004
  if (patterns.length === 0) return false;
14984
- return patterns.some((p) => {
15005
+ return patterns.some((rawPattern) => {
15006
+ const p = rawPattern.endsWith("/") ? rawPattern.slice(0, -1) : rawPattern;
14985
15007
  if (filePath === p) return true;
14986
15008
  if (minimatch(filePath, p)) return true;
14987
15009
  if (!p.includes("*") && !p.includes("?") && filePath.startsWith(p + "/")) return true;
@@ -15029,12 +15051,14 @@ async function capturePatchSnapshot(git, files, treeish) {
15029
15051
  return snapshot;
15030
15052
  }
15031
15053
  function filterInfrastructureAndSensitiveFiles(rawFilesOutput, fernignorePatterns) {
15032
- 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/"));
15033
15057
  const sensitiveFiles = allFiles.filter((f) => isSensitiveFile(f));
15034
15058
  const nonSensitive = allFiles.filter((f) => !isSensitiveFile(f));
15035
15059
  const fernignoredFiles = nonSensitive.filter((f) => matchesFernignorePattern(f, fernignorePatterns));
15036
15060
  const files = nonSensitive.filter((f) => !matchesFernignorePattern(f, fernignorePatterns));
15037
- return { files, sensitiveFiles, fernignoredFiles };
15061
+ return { files, sensitiveFiles, fernignoredFiles, infrastructureFiles };
15038
15062
  }
15039
15063
  var ReplayDetector = class {
15040
15064
  git;
@@ -15042,6 +15066,15 @@ var ReplayDetector = class {
15042
15066
  sdkOutputDir;
15043
15067
  fernignorePatterns;
15044
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 = [];
15045
15078
  constructor(git, lockManager, sdkOutputDir, fernignorePatterns = []) {
15046
15079
  this.git = git;
15047
15080
  this.lockManager = lockManager;
@@ -15112,6 +15145,10 @@ var ReplayDetector = class {
15112
15145
  this.warnings.push(
15113
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.`
15114
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
+ });
15115
15152
  }
15116
15153
  return { patches: [], revertedPatchIds: [] };
15117
15154
  }
@@ -15120,6 +15157,12 @@ var ReplayDetector = class {
15120
15157
  this.warnings.push(
15121
15158
  `Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to tree-diff detection (likely a shallow clone).`
15122
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
+ }
15123
15166
  return this.detectPatchesViaTreeDiff(
15124
15167
  lastGen,
15125
15168
  /* commitKnownMissing */
@@ -15159,48 +15202,28 @@ var ReplayDetector = class {
15159
15202
  if (lock.patches.find((p) => p.original_commit === commit.sha)) {
15160
15203
  continue;
15161
15204
  }
15162
- const parents = await this.git.getCommitParents(commit.sha);
15163
- let patchContent;
15164
- try {
15165
- patchContent = await this.git.formatPatch(commit.sha);
15166
- } catch {
15205
+ const materialized = await this.materializeCommitPatch(commit.sha);
15206
+ if (materialized.sensitiveFiles.length > 0) {
15167
15207
  this.warnings.push(
15168
- `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.`
15169
15209
  );
15170
- continue;
15171
15210
  }
15172
- let contentHash = this.computeContentHash(patchContent);
15173
- if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
15174
- continue;
15175
- }
15176
- const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
15177
- const { files, sensitiveFiles, fernignoredFiles } = filterInfrastructureAndSensitiveFiles(
15178
- filesOutput,
15179
- this.fernignorePatterns
15180
- );
15181
- if (sensitiveFiles.length > 0) {
15211
+ if (materialized.fernignoredFiles.length > 0) {
15182
15212
  this.warnings.push(
15183
- `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.`
15184
15214
  );
15185
15215
  }
15186
- if (fernignoredFiles.length > 0) {
15216
+ if (materialized.status === "unreachable") {
15187
15217
  this.warnings.push(
15188
- `.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.`
15189
15219
  );
15220
+ continue;
15190
15221
  }
15191
- if ((sensitiveFiles.length > 0 || fernignoredFiles.length > 0) && files.length > 0) {
15192
- const parentSha = parents[0];
15193
- if (parentSha) {
15194
- try {
15195
- patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
15196
- } catch {
15197
- continue;
15198
- }
15199
- if (!patchContent.trim()) continue;
15200
- contentHash = this.computeContentHash(patchContent);
15201
- }
15222
+ if (materialized.status === "empty") {
15223
+ continue;
15202
15224
  }
15203
- if (files.length === 0) {
15225
+ const { files, patchContent, contentHash } = materialized;
15226
+ if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
15204
15227
  continue;
15205
15228
  }
15206
15229
  const theirsSnapshot = await capturePatchSnapshot(this.git, files, commit.sha);
@@ -15276,6 +15299,63 @@ var ReplayDetector = class {
15276
15299
  const filteredPatches = survivingPatches.filter((_, i) => !revertIndicesToRemove.has(i));
15277
15300
  return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
15278
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
+ }
15279
15359
  /**
15280
15360
  * Compute content hash for deduplication.
15281
15361
  *
@@ -15380,6 +15460,13 @@ var ReplayDetector = class {
15380
15460
  async detectPatchesViaTreeDiff(lastGen, commitKnownMissing) {
15381
15461
  const diffBase = await this.resolveDiffBase(lastGen, commitKnownMissing);
15382
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
+ });
15383
15470
  return this.detectPatchesViaCommitScan();
15384
15471
  }
15385
15472
  const lockForGuard = this.lockManager.read();
@@ -15492,10 +15579,7 @@ var ReplayDetector = class {
15492
15579
  continue;
15493
15580
  }
15494
15581
  const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
15495
- const { files, sensitiveFiles, fernignoredFiles } = filterInfrastructureAndSensitiveFiles(
15496
- filesOutput,
15497
- this.fernignorePatterns
15498
- );
15582
+ const { files, sensitiveFiles, fernignoredFiles, infrastructureFiles } = filterInfrastructureAndSensitiveFiles(filesOutput, this.fernignorePatterns);
15499
15583
  if (sensitiveFiles.length > 0) {
15500
15584
  this.warnings.push(
15501
15585
  `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
@@ -15506,7 +15590,8 @@ var ReplayDetector = class {
15506
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.`
15507
15591
  );
15508
15592
  }
15509
- 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) {
15510
15595
  if (parents.length === 0) {
15511
15596
  continue;
15512
15597
  }
@@ -15610,9 +15695,9 @@ var ReplayDetector = class {
15610
15695
  };
15611
15696
 
15612
15697
  // src/ReplayService.ts
15613
- var import_node_fs2 = require("fs");
15698
+ var import_node_fs3 = require("fs");
15614
15699
  var import_promises6 = require("fs/promises");
15615
- var import_node_path9 = require("path");
15700
+ var import_node_path10 = require("path");
15616
15701
  init_GitClient();
15617
15702
 
15618
15703
  // src/ReplayApplicator.ts
@@ -16922,6 +17007,15 @@ function isDiffLineForFile(diffLine, filePath) {
16922
17007
  return match2 !== null && match2[1] === filePath;
16923
17008
  }
16924
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
+
16925
17019
  // src/ReplayCommitter.ts
16926
17020
  var ReplayCommitter = class {
16927
17021
  git;
@@ -17027,7 +17121,7 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
17027
17121
  // src/PatchRegionDiff.ts
17028
17122
  var import_promises4 = require("fs/promises");
17029
17123
  var import_node_os2 = require("os");
17030
- var import_node_path7 = require("path");
17124
+ var import_node_path8 = require("path");
17031
17125
  var import_node_child_process = require("child_process");
17032
17126
  init_HybridReconstruction();
17033
17127
  function normalizeHunkBody(hunk) {
@@ -17245,10 +17339,10 @@ function overlayRegions(currentGenLines, locInCurrentGen, workingTreeLines, locI
17245
17339
  }
17246
17340
  async function unifiedDiff(beforeContent, afterContent, filePath) {
17247
17341
  if (beforeContent === afterContent) return "";
17248
- 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-"));
17249
17343
  try {
17250
- const beforePath = (0, import_node_path7.join)(tmp, "before");
17251
- 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");
17252
17346
  await (0, import_promises4.writeFile)(beforePath, beforeContent, "utf-8");
17253
17347
  await (0, import_promises4.writeFile)(afterPath, afterContent, "utf-8");
17254
17348
  const raw = await runGitDiffNoIndex(beforePath, afterPath);
@@ -17423,6 +17517,7 @@ var FirstGenerationFlow = class {
17423
17517
  patchesToApply: [],
17424
17518
  newPatchIds: /* @__PURE__ */ new Set(),
17425
17519
  detectorWarnings: [],
17520
+ detectorDegraded: [],
17426
17521
  revertedCount: 0,
17427
17522
  genSha: genRecord.commit_sha,
17428
17523
  optionsSnapshot: options
@@ -17482,6 +17577,7 @@ var SkipApplicationFlow = class {
17482
17577
  patchesToApply: [],
17483
17578
  newPatchIds: /* @__PURE__ */ new Set(),
17484
17579
  detectorWarnings: [],
17580
+ detectorDegraded: [],
17485
17581
  revertedCount: 0,
17486
17582
  genSha: genRecord.commit_sha,
17487
17583
  optionsSnapshot: options
@@ -17519,8 +17615,10 @@ var NoPatchesFlow = class {
17519
17615
  const previousGenerationSha = preLock.current_generation ?? null;
17520
17616
  let { patches: newPatches, revertedPatchIds } = await this.service.detector.detectNewPatches();
17521
17617
  const detectorWarnings = [...this.service.detector.warnings];
17618
+ const detectorDegraded = [...this.service.detector.degradedReasons];
17522
17619
  if (options?.dryRun) {
17523
17620
  const dryRunWarnings = [...detectorWarnings, ...this.service.warnings];
17621
+ const dryRunDegraded = dedupeDegradedReasons([...detectorDegraded, ...this.service.degradedReasons]);
17524
17622
  const preparedReport = {
17525
17623
  flow: "no-patches",
17526
17624
  patchesDetected: newPatches.length,
@@ -17530,7 +17628,9 @@ var NoPatchesFlow = class {
17530
17628
  patchesReverted: revertedPatchIds.length,
17531
17629
  conflicts: [],
17532
17630
  wouldApply: newPatches,
17533
- 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
17534
17634
  };
17535
17635
  const headSha = await this.service.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
17536
17636
  return {
@@ -17584,6 +17684,7 @@ var NoPatchesFlow = class {
17584
17684
  patchesToApply: newPatches,
17585
17685
  newPatchIds: new Set(newPatches.map((p) => p.id)),
17586
17686
  detectorWarnings,
17687
+ detectorDegraded,
17587
17688
  revertedCount: revertedPatchIds.length,
17588
17689
  genSha: genRecord.commit_sha,
17589
17690
  optionsSnapshot: options
@@ -17627,6 +17728,7 @@ var NoPatchesFlow = class {
17627
17728
  }
17628
17729
  }
17629
17730
  const warnings = [...detectorWarnings, ...this.service.warnings];
17731
+ const degradedReasons = [...prep._prepared.detectorDegraded, ...this.service.degradedReasons];
17630
17732
  return this.service.buildReport(
17631
17733
  "no-patches",
17632
17734
  newPatches,
@@ -17635,7 +17737,8 @@ var NoPatchesFlow = class {
17635
17737
  warnings,
17636
17738
  rebaseCounts,
17637
17739
  void 0,
17638
- prep._prepared.revertedCount
17740
+ prep._prepared.revertedCount,
17741
+ degradedReasons
17639
17742
  );
17640
17743
  }
17641
17744
  };
@@ -17652,6 +17755,10 @@ var NormalRegenerationFlow = class {
17652
17755
  const existingPatches2 = this.service.lockManager.getPatches();
17653
17756
  const { patches: newPatches2, revertedPatchIds: dryRunReverted } = await this.service.detector.detectNewPatches();
17654
17757
  const warnings = [...this.service.detector.warnings, ...this.service.warnings];
17758
+ const dryRunDegraded = dedupeDegradedReasons([
17759
+ ...this.service.detector.degradedReasons,
17760
+ ...this.service.degradedReasons
17761
+ ]);
17655
17762
  const allPatches2 = [...existingPatches2, ...newPatches2];
17656
17763
  const preparedReport = {
17657
17764
  flow: "normal-regeneration",
@@ -17662,7 +17769,9 @@ var NormalRegenerationFlow = class {
17662
17769
  patchesReverted: dryRunReverted.length,
17663
17770
  conflicts: [],
17664
17771
  wouldApply: allPatches2,
17665
- 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
17666
17775
  };
17667
17776
  const headSha = await this.service.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
17668
17777
  return {
@@ -17701,6 +17810,7 @@ var NormalRegenerationFlow = class {
17701
17810
  existingPatches = this.service.lockManager.getPatches();
17702
17811
  let { patches: newPatches, revertedPatchIds } = await this.service.detector.detectNewPatches();
17703
17812
  const detectorWarnings = [...this.service.detector.warnings];
17813
+ const detectorDegraded = [...this.service.detector.degradedReasons];
17704
17814
  if (removedByPreRebase.length > 0) {
17705
17815
  const removedOriginalCommits = new Set(removedByPreRebase.map((p) => p.original_commit));
17706
17816
  const removedOriginalMessages = new Set(removedByPreRebase.map((p) => p.original_message));
@@ -17741,6 +17851,7 @@ var NormalRegenerationFlow = class {
17741
17851
  newPatchIds: new Set(newPatches.map((p) => p.id)),
17742
17852
  preRebaseCounts,
17743
17853
  detectorWarnings,
17854
+ detectorDegraded,
17744
17855
  revertedCount: revertedPatchIds.length,
17745
17856
  genSha: genRecord.commit_sha,
17746
17857
  optionsSnapshot: options
@@ -17789,6 +17900,7 @@ var NormalRegenerationFlow = class {
17789
17900
  await this.service.committer.commitReplay(appliedCount, allPatches, void 0, { buckets });
17790
17901
  }
17791
17902
  const warnings = [...detectorWarnings, ...this.service.warnings];
17903
+ const degradedReasons = [...prep._prepared.detectorDegraded, ...this.service.degradedReasons];
17792
17904
  return this.service.buildReport(
17793
17905
  "normal-regeneration",
17794
17906
  allPatches,
@@ -17797,7 +17909,8 @@ var NormalRegenerationFlow = class {
17797
17909
  warnings,
17798
17910
  rebaseCounts,
17799
17911
  prep._prepared.preRebaseCounts,
17800
- prep._prepared.revertedCount
17912
+ prep._prepared.revertedCount,
17913
+ degradedReasons
17801
17914
  );
17802
17915
  }
17803
17916
  };
@@ -17837,6 +17950,16 @@ var ReplayService = class {
17837
17950
  * @internal Used by Flow implementations in `src/flows/`.
17838
17951
  */
17839
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 = [];
17840
17963
  /**
17841
17964
  * @internal Test-only accessor.
17842
17965
  * Returns the ReplayApplicator instance used for the last run. Tests use this
@@ -17880,7 +18003,10 @@ var ReplayService = class {
17880
18003
  async prepareReplay(options) {
17881
18004
  this.warnings = [];
17882
18005
  this.detector.warnings.length = 0;
18006
+ this.degradedReasons = [];
18007
+ this.detector.degradedReasons.length = 0;
17883
18008
  this.cleanseLegacyFernignoreEntries();
18009
+ this.cleanseInfrastructureFileEntries();
17884
18010
  return this.selectFlow(options).prepare(options);
17885
18011
  }
17886
18012
  /**
@@ -17934,6 +18060,50 @@ var ReplayService = class {
17934
18060
  this.lockManager.save();
17935
18061
  }
17936
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
+ }
17937
18107
  /**
17938
18108
  * Phase 2 of the two-phase replay flow. Applies patches, rebases them against
17939
18109
  * the generation, saves the lockfile, and commits `[fern-replay]` (or stages
@@ -18189,7 +18359,7 @@ var ReplayService = class {
18189
18359
  for (const file of files) {
18190
18360
  if (isBinaryFile(file)) continue;
18191
18361
  try {
18192
- 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");
18193
18363
  snapshot[file] = content;
18194
18364
  } catch {
18195
18365
  }
@@ -18706,6 +18876,12 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
18706
18876
  );
18707
18877
  }
18708
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
+ }
18709
18885
  }
18710
18886
  /**
18711
18887
  * After applyPatches(), strip conflict markers from conflicting files
@@ -18717,11 +18893,11 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
18717
18893
  if (result.status !== "conflict" || !result.fileResults) continue;
18718
18894
  for (const fileResult of result.fileResults) {
18719
18895
  if (fileResult.status !== "conflict") continue;
18720
- const filePath = (0, import_node_path9.join)(this.outputDir, fileResult.file);
18896
+ const filePath = (0, import_node_path10.join)(this.outputDir, fileResult.file);
18721
18897
  try {
18722
- const content = (0, import_node_fs2.readFileSync)(filePath, "utf-8");
18898
+ const content = (0, import_node_fs3.readFileSync)(filePath, "utf-8");
18723
18899
  const stripped = stripConflictMarkers(content);
18724
- (0, import_node_fs2.writeFileSync)(filePath, stripped);
18900
+ (0, import_node_fs3.writeFileSync)(filePath, stripped);
18725
18901
  } catch {
18726
18902
  }
18727
18903
  }
@@ -18749,12 +18925,11 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
18749
18925
  }
18750
18926
  /** @internal Used by Flow implementations in `src/flows/`. */
18751
18927
  readFernignorePatterns() {
18752
- const fernignorePath = (0, import_node_path9.join)(this.outputDir, ".fernignore");
18753
- if (!(0, import_node_fs2.existsSync)(fernignorePath)) return [];
18754
- 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);
18755
18929
  }
18756
18930
  /** @internal Used by Flow implementations in `src/flows/`. */
18757
- 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 ?? []);
18758
18933
  const conflictResults = results.filter((r) => r.status === "conflict");
18759
18934
  const conflictDetails = conflictResults.map((r) => {
18760
18935
  const conflictFiles = r.fileResults?.filter((f) => f.status === "conflict") ?? [];
@@ -18794,10 +18969,23 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
18794
18969
  conflictDetails: r.fileResults?.filter((f) => f.status === "conflict") ?? []
18795
18970
  })) : void 0,
18796
18971
  wouldApply: options?.dryRun ? patches : void 0,
18797
- 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
18798
18975
  };
18799
18976
  }
18800
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
+ }
18801
18989
  function computeCommitBuckets(results, absorbedPatchIds, patchesPassedToCommit) {
18802
18990
  const resultByPatchId = /* @__PURE__ */ new Map();
18803
18991
  for (const r of results) resultByPatchId.set(r.patch.id, r);
@@ -18846,7 +19034,7 @@ function computeLegacyFernignoreCleanse(patch, fernignorePatterns, computeConten
18846
19034
  }
18847
19035
  }
18848
19036
  }
18849
- const { stripped: patchContentStripped, content: newPatchContent, strippedSectionPaths } = stripFernignoreDiffSections(patch.patch_content, matchesFernignore);
19037
+ const { stripped: patchContentStripped, content: newPatchContent, strippedSectionPaths } = stripDiffSectionsByPredicate(patch.patch_content, matchesFernignore);
18850
19038
  const unchanged = strippedFromFiles.length === 0 && !snapshotChanged && !patchContentStripped;
18851
19039
  if (unchanged) {
18852
19040
  return {
@@ -18881,7 +19069,38 @@ function computeLegacyFernignoreCleanse(patch, fernignorePatterns, computeConten
18881
19069
  strippedPaths
18882
19070
  };
18883
19071
  }
18884
- 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) {
18885
19104
  const lines = patchContent.split("\n");
18886
19105
  const sectionStarts = [];
18887
19106
  for (let i = 0; i < lines.length; i++) {
@@ -18903,7 +19122,7 @@ function stripFernignoreDiffSections(patchContent, matchesFernignore) {
18903
19122
  const section = sectionStarts[i];
18904
19123
  const next = sectionStarts[i + 1];
18905
19124
  const endIdx = next ? next.idx : lines.length;
18906
- if (section.path !== null && matchesFernignore(section.path)) {
19125
+ if (section.path !== null && shouldStrip(section.path)) {
18907
19126
  stripped = true;
18908
19127
  strippedSectionPaths.push(section.path);
18909
19128
  continue;
@@ -18915,8 +19134,8 @@ function stripFernignoreDiffSections(patchContent, matchesFernignore) {
18915
19134
 
18916
19135
  // src/FernignoreMigrator.ts
18917
19136
  var import_node_crypto2 = require("crypto");
18918
- var import_node_fs3 = require("fs");
18919
- var import_node_path10 = require("path");
19137
+ var import_node_fs4 = require("fs");
19138
+ var import_node_path11 = require("path");
18920
19139
  var import_yaml2 = __toESM(require_dist3(), 1);
18921
19140
  var FernignoreMigrator = class {
18922
19141
  git;
@@ -18928,14 +19147,14 @@ var FernignoreMigrator = class {
18928
19147
  this.outputDir = outputDir;
18929
19148
  }
18930
19149
  fernignoreExists() {
18931
- 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"));
18932
19151
  }
18933
19152
  readFernignorePatterns() {
18934
- const fernignorePath = (0, import_node_path10.join)(this.outputDir, ".fernignore");
18935
- 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)) {
18936
19155
  return [];
18937
19156
  }
18938
- const content = (0, import_node_fs3.readFileSync)(fernignorePath, "utf-8");
19157
+ const content = (0, import_node_fs4.readFileSync)(fernignorePath, "utf-8");
18939
19158
  return content.split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
18940
19159
  }
18941
19160
  /** Analyze .fernignore patterns vs git history. Creates synthetic patches for files differing from pristine generation. */
@@ -19040,16 +19259,16 @@ var FernignoreMigrator = class {
19040
19259
  return results;
19041
19260
  }
19042
19261
  readFileContent(filePath) {
19043
- const fullPath = (0, import_node_path10.join)(this.outputDir, filePath);
19044
- 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)) {
19045
19264
  return null;
19046
19265
  }
19047
19266
  try {
19048
- const stat = (0, import_node_fs3.statSync)(fullPath);
19267
+ const stat = (0, import_node_fs4.statSync)(fullPath);
19049
19268
  if (stat.isDirectory()) {
19050
19269
  return null;
19051
19270
  }
19052
- return (0, import_node_fs3.readFileSync)(fullPath, "utf-8");
19271
+ return (0, import_node_fs4.readFileSync)(fullPath, "utf-8");
19053
19272
  } catch {
19054
19273
  return null;
19055
19274
  }
@@ -19105,27 +19324,27 @@ var FernignoreMigrator = class {
19105
19324
  };
19106
19325
  }
19107
19326
  movePatternsToReplayYml(patterns) {
19108
- 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");
19109
19328
  let config = {};
19110
- if ((0, import_node_fs3.existsSync)(replayYmlPath)) {
19111
- 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");
19112
19331
  config = (0, import_yaml2.parse)(content) ?? {};
19113
19332
  }
19114
19333
  const existing = config.exclude ?? [];
19115
19334
  const merged = [.../* @__PURE__ */ new Set([...existing, ...patterns])];
19116
19335
  config.exclude = merged;
19117
- const dir = (0, import_node_path10.dirname)(replayYmlPath);
19118
- if (!(0, import_node_fs3.existsSync)(dir)) {
19119
- (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 });
19120
19339
  }
19121
- (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");
19122
19341
  }
19123
19342
  };
19124
19343
 
19125
19344
  // src/commands/bootstrap.ts
19126
19345
  var import_node_crypto3 = require("crypto");
19127
- var import_node_fs4 = require("fs");
19128
- var import_node_path11 = require("path");
19346
+ var import_node_fs5 = require("fs");
19347
+ var import_node_path12 = require("path");
19129
19348
  init_GitClient();
19130
19349
  async function bootstrap(outputDir, options) {
19131
19350
  const git = new GitClient(outputDir);
@@ -19292,10 +19511,10 @@ function parseGitLog(log) {
19292
19511
  }
19293
19512
  var REPLAY_FERNIGNORE_ENTRIES = [".fern/replay.lock", ".fern/replay.yml", ".gitattributes"];
19294
19513
  function ensureFernignoreEntries(outputDir) {
19295
- const fernignorePath = (0, import_node_path11.join)(outputDir, ".fernignore");
19514
+ const fernignorePath = (0, import_node_path12.join)(outputDir, ".fernignore");
19296
19515
  let content = "";
19297
- if ((0, import_node_fs4.existsSync)(fernignorePath)) {
19298
- 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");
19299
19518
  }
19300
19519
  const lines = content.split("\n");
19301
19520
  const toAdd = [];
@@ -19311,15 +19530,15 @@ function ensureFernignoreEntries(outputDir) {
19311
19530
  content += "\n";
19312
19531
  }
19313
19532
  content += toAdd.join("\n") + "\n";
19314
- (0, import_node_fs4.writeFileSync)(fernignorePath, content, "utf-8");
19533
+ (0, import_node_fs5.writeFileSync)(fernignorePath, content, "utf-8");
19315
19534
  return true;
19316
19535
  }
19317
19536
  var GITATTRIBUTES_ENTRIES = [".fern/replay.lock linguist-generated=true"];
19318
19537
  function ensureGitattributesEntries(outputDir) {
19319
- const gitattributesPath = (0, import_node_path11.join)(outputDir, ".gitattributes");
19538
+ const gitattributesPath = (0, import_node_path12.join)(outputDir, ".gitattributes");
19320
19539
  let content = "";
19321
- if ((0, import_node_fs4.existsSync)(gitattributesPath)) {
19322
- 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");
19323
19542
  }
19324
19543
  const lines = content.split("\n");
19325
19544
  const toAdd = [];
@@ -19335,7 +19554,7 @@ function ensureGitattributesEntries(outputDir) {
19335
19554
  content += "\n";
19336
19555
  }
19337
19556
  content += toAdd.join("\n") + "\n";
19338
- (0, import_node_fs4.writeFileSync)(gitattributesPath, content, "utf-8");
19557
+ (0, import_node_fs5.writeFileSync)(gitattributesPath, content, "utf-8");
19339
19558
  }
19340
19559
  function computeContentHash(patchContent) {
19341
19560
  const lines = patchContent.split("\n");
@@ -19346,6 +19565,7 @@ function computeContentHash(patchContent) {
19346
19565
  }
19347
19566
 
19348
19567
  // src/commands/forget.ts
19568
+ init_GitClient();
19349
19569
  function parseDiffStat(patchContent) {
19350
19570
  let additions = 0;
19351
19571
  let deletions = 0;
@@ -19404,7 +19624,28 @@ var EMPTY_RESULT = {
19404
19624
  totalPatches: 0,
19405
19625
  warnings: []
19406
19626
  };
19407
- 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) {
19408
19649
  const lockManager = new LockfileManager(outputDir);
19409
19650
  if (!lockManager.exists()) {
19410
19651
  return { ...EMPTY_RESULT };
@@ -19415,9 +19656,7 @@ function forget(outputDir, options) {
19415
19656
  const removed = lock.patches.map(toMatchedPatch);
19416
19657
  const warnings = buildWarnings(lock.patches);
19417
19658
  if (!options.dryRun) {
19418
- for (const patch of lock.patches) {
19419
- lockManager.addForgottenHash(patch.content_hash);
19420
- }
19659
+ await dismissPatches(outputDir, lockManager, lock.patches);
19421
19660
  lockManager.clearPatches();
19422
19661
  lockManager.save();
19423
19662
  }
@@ -19446,8 +19685,8 @@ function forget(outputDir, options) {
19446
19685
  }
19447
19686
  const warnings = buildWarnings(patchesToRemove);
19448
19687
  if (!options.dryRun) {
19688
+ await dismissPatches(outputDir, lockManager, patchesToRemove);
19449
19689
  for (const patch of patchesToRemove) {
19450
- lockManager.addForgottenHash(patch.content_hash);
19451
19690
  lockManager.removePatch(patch.id);
19452
19691
  }
19453
19692
  lockManager.save();
@@ -19488,7 +19727,7 @@ function forget(outputDir, options) {
19488
19727
  }
19489
19728
 
19490
19729
  // src/commands/reset.ts
19491
- var import_node_fs5 = require("fs");
19730
+ var import_node_fs6 = require("fs");
19492
19731
  function reset(outputDir, options) {
19493
19732
  const lockManager = new LockfileManager(outputDir);
19494
19733
  if (!lockManager.exists()) {
@@ -19502,7 +19741,7 @@ function reset(outputDir, options) {
19502
19741
  const lock = lockManager.read();
19503
19742
  const patchCount = lock.patches.length;
19504
19743
  if (!options?.dryRun) {
19505
- (0, import_node_fs5.unlinkSync)(lockManager.lockfilePath);
19744
+ (0, import_node_fs6.unlinkSync)(lockManager.lockfilePath);
19506
19745
  }
19507
19746
  return {
19508
19747
  success: true,
@@ -19514,7 +19753,7 @@ function reset(outputDir, options) {
19514
19753
 
19515
19754
  // src/commands/resolve.ts
19516
19755
  var import_promises7 = require("fs/promises");
19517
- var import_node_path12 = require("path");
19756
+ var import_node_path13 = require("path");
19518
19757
  init_GitClient();
19519
19758
  async function resolve(outputDir, options) {
19520
19759
  const lockManager = new LockfileManager(outputDir);
@@ -19555,15 +19794,16 @@ async function resolve(outputDir, options) {
19555
19794
  const patchesToCommit = [...resolvingPatches, ...unresolvedPatches];
19556
19795
  if (patchesToCommit.length > 0) {
19557
19796
  const currentGen = lock.current_generation;
19558
- const detector = new ReplayDetector(git, lockManager, outputDir);
19797
+ const detector = new ReplayDetector(git, lockManager, outputDir, readFernignorePatterns(outputDir));
19559
19798
  const warnings = [];
19799
+ const patchesDismissed = [];
19560
19800
  let patchesResolved = 0;
19561
19801
  const pass1 = [];
19562
19802
  for (const patch of patchesToCommit) {
19563
19803
  const result = await computePerPatchDiffWithFallback(
19564
19804
  patch,
19565
19805
  (f) => git.showFile(currentGen, f),
19566
- (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),
19567
19807
  (files) => git.exec(["diff", currentGen, "--", ...files]).catch(() => null)
19568
19808
  );
19569
19809
  if (result === null) {
@@ -19597,7 +19837,27 @@ async function resolve(outputDir, options) {
19597
19837
  const r = pass1[i];
19598
19838
  if (r.kind === "skip") continue;
19599
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
+ });
19600
19859
  lockManager.removePatch(patch.id);
19860
+ patchesDismissed.push({ id: patch.id, message: patch.original_message });
19601
19861
  continue;
19602
19862
  }
19603
19863
  if (lastIndexByHash.get(r.hash) !== i) {
@@ -19619,7 +19879,7 @@ async function resolve(outputDir, options) {
19619
19879
  const theirsSnapshot = {};
19620
19880
  for (const file of filesForSnapshot) {
19621
19881
  if (isBinaryFile(file)) continue;
19622
- 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);
19623
19883
  if (content != null) {
19624
19884
  theirsSnapshot[file] = content;
19625
19885
  }
@@ -19646,6 +19906,7 @@ async function resolve(outputDir, options) {
19646
19906
  commitSha: commitSha2,
19647
19907
  phase: "committed",
19648
19908
  patchesResolved,
19909
+ patchesDismissed: patchesDismissed.length > 0 ? patchesDismissed : void 0,
19649
19910
  warnings: warnings.length > 0 ? warnings : void 0
19650
19911
  };
19651
19912
  }
@@ -19693,7 +19954,9 @@ function status(outputDir) {
19693
19954
  lastGeneration: void 0,
19694
19955
  patches: [],
19695
19956
  unresolvedCount: 0,
19696
- excludePatterns: []
19957
+ excludePatterns: [],
19958
+ dismissed: [],
19959
+ legacyDismissedCount: 0
19697
19960
  };
19698
19961
  }
19699
19962
  const lock = lockManager.read();
@@ -19722,13 +19985,24 @@ function status(outputDir) {
19722
19985
  }
19723
19986
  const config = lockManager.getCustomizationsConfig();
19724
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;
19725
19997
  return {
19726
19998
  initialized: true,
19727
19999
  generationCount: lock.generations.length,
19728
20000
  lastGeneration,
19729
20001
  patches,
19730
20002
  unresolvedCount,
19731
- excludePatterns
20003
+ excludePatterns,
20004
+ dismissed,
20005
+ legacyDismissedCount
19732
20006
  };
19733
20007
  }
19734
20008
 
@@ -19816,7 +20090,7 @@ function parseArgs(argv) {
19816
20090
  positionals.push(arg);
19817
20091
  }
19818
20092
  }
19819
- return { command, dir: (0, import_node_path13.resolve)(dir), flags, positionals };
20093
+ return { command, dir: (0, import_node_path14.resolve)(dir), flags, positionals };
19820
20094
  }
19821
20095
  async function runBootstrap(dir, flags) {
19822
20096
  const dryRun = !!flags.dryRun;
@@ -19894,6 +20168,7 @@ async function runStatus(dir, flags) {
19894
20168
  }
19895
20169
  if (result.patches.length === 0) {
19896
20170
  console.log("\nNo customizations tracked. Edit SDK files and commit \u2014 Replay will detect them on next generation.");
20171
+ printDismissedSection(result);
19897
20172
  return;
19898
20173
  }
19899
20174
  const unresolvedSuffix = result.unresolvedCount > 0 ? `, ${result.unresolvedCount} unresolved` : "";
@@ -19931,6 +20206,26 @@ ${prefix}${patch.id} [${patch.type}] "${patch.message}"`);
19931
20206
  console.log(` ... and ${result.patches.length - maxDisplay} more`);
19932
20207
  }
19933
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
+ }
19934
20229
  }
19935
20230
  async function runDetect(dir) {
19936
20231
  const lockManager = new LockfileManager(dir);
@@ -19942,6 +20237,7 @@ async function runDetect(dir) {
19942
20237
  const git = new GitClient(dir);
19943
20238
  const detector = new ReplayDetector(git, lockManager, dir);
19944
20239
  const { patches, revertedPatchIds } = await detector.detectNewPatches();
20240
+ printDegradedBanner(detector.degradedReasons);
19945
20241
  if (detector.warnings.length > 0) {
19946
20242
  for (const w of detector.warnings) console.log(`Warning: ${w}`);
19947
20243
  console.log();
@@ -19972,7 +20268,20 @@ async function runReplay(dir, flags) {
19972
20268
  const report = await service.runReplay({ dryRun });
19973
20269
  printReport(report);
19974
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
+ }
19975
20283
  function printReport(report) {
20284
+ printDegradedBanner(report.degradedReasons ?? []);
19976
20285
  console.log(`Flow: ${report.flow}`);
19977
20286
  console.log(`Patches detected: ${report.patchesDetected}`);
19978
20287
  console.log(`Patches applied: ${report.patchesApplied}`);
@@ -20140,7 +20449,7 @@ async function runForget(dir, flags, positionals) {
20140
20449
  const pattern = !isPatchIdMode && positionals.length > 0 ? positionals[0] : void 0;
20141
20450
  const patchIds = isPatchIdMode ? positionals : void 0;
20142
20451
  if (all) {
20143
- const preview = forget(dir, { all: true, dryRun: true });
20452
+ const preview = await forget(dir, { all: true, dryRun: true });
20144
20453
  if (!preview.initialized) {
20145
20454
  console.log("Replay is not initialized. Run 'fern replay init' to get started.");
20146
20455
  process.exit(1);
@@ -20159,14 +20468,14 @@ async function runForget(dir, flags, positionals) {
20159
20468
  return;
20160
20469
  }
20161
20470
  }
20162
- const finalResult = dryRun ? preview : forget(dir, { all: true });
20471
+ const finalResult = dryRun ? preview : await forget(dir, { all: true });
20163
20472
  printForgetWarnings(finalResult.warnings);
20164
20473
  const verb2 = dryRun ? "Would remove" : "Removed";
20165
20474
  console.log(`${verb2} ${finalResult.removed.length} patches. ${finalResult.remaining} patches remaining.`);
20166
20475
  return;
20167
20476
  }
20168
20477
  if (isPatchIdMode && patchIds) {
20169
- const result2 = forget(dir, { patchIds, dryRun });
20478
+ const result2 = await forget(dir, { patchIds, dryRun });
20170
20479
  if (!result2.initialized) {
20171
20480
  console.log("Replay is not initialized. Run 'fern replay init' to get started.");
20172
20481
  process.exit(1);
@@ -20193,7 +20502,7 @@ async function runForget(dir, flags, positionals) {
20193
20502
  console.log(`${result2.remaining} patches remaining.`);
20194
20503
  return;
20195
20504
  }
20196
- const searchResult = pattern ? forget(dir, { pattern }) : forget(dir);
20505
+ const searchResult = pattern ? await forget(dir, { pattern }) : await forget(dir);
20197
20506
  if (!searchResult.initialized) {
20198
20507
  console.log("Replay is not initialized. Run 'fern replay init' to get started.");
20199
20508
  process.exit(1);
@@ -20241,7 +20550,7 @@ This will remove ${selectedIds.length} patch(es). Affected files will be overwri
20241
20550
  return;
20242
20551
  }
20243
20552
  }
20244
- const result = forget(dir, { patchIds: selectedIds, dryRun });
20553
+ const result = await forget(dir, { patchIds: selectedIds, dryRun });
20245
20554
  printForgetWarnings(result.warnings);
20246
20555
  const verb = dryRun ? "Would remove" : "Removed";
20247
20556
  console.log(`
@@ -20308,12 +20617,23 @@ Resolve the conflicts in your editor, then run 'fern-replay resolve ${dir}' agai
20308
20617
  if (result.patchesResolved) {
20309
20618
  console.log(`Resolution complete. Updated ${result.patchesResolved} patch(es) in lockfile.`);
20310
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
+ }
20311
20631
  console.log(`Created [fern-replay] commit: ${result.commitSha?.slice(0, 7)}`);
20312
20632
  console.log("Push to update the PR.");
20313
20633
  }
20314
20634
  async function main() {
20315
20635
  const { command, dir, flags, positionals } = parseArgs(process.argv);
20316
- if (!(0, import_node_fs6.existsSync)(dir)) {
20636
+ if (!(0, import_node_fs7.existsSync)(dir)) {
20317
20637
  console.error(`Directory not found: ${dir}`);
20318
20638
  process.exit(1);
20319
20639
  }