@fern-api/replay 0.18.0 → 0.19.1

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
@@ -16377,6 +16449,51 @@ async function buildMergePlan(args) {
16377
16449
  // src/applicator/runMergeAndRecord.ts
16378
16450
  var import_promises2 = require("fs/promises");
16379
16451
  var import_node_path5 = require("path");
16452
+
16453
+ // src/autoversion.ts
16454
+ var AUTOVERSION_PLACEHOLDERS = [
16455
+ "v0.0.0-fern-placeholder",
16456
+ // Go -- a superstring of the default; check first
16457
+ "0.0.0-fern-placeholder",
16458
+ // TS/Java/C#/Ruby/PHP/Rust/default
16459
+ "0.0.0.dev0"
16460
+ // Python (PEP 440 -- Poetry rejects hyphens)
16461
+ ];
16462
+ var VERSION_TOKEN = /(?:v)?\d+\.\d+\.\d+[0-9A-Za-z.+-]*/g;
16463
+ var VERSION_MASK = "@@FERN_VERSION@@";
16464
+ function maskVersions(line) {
16465
+ return line.replace(VERSION_TOKEN, VERSION_MASK);
16466
+ }
16467
+ function containsPlaceholder(content) {
16468
+ if (!content) return false;
16469
+ return AUTOVERSION_PLACEHOLDERS.some((p) => content.includes(p));
16470
+ }
16471
+ function remapManagedLines(genBase, target, theirs) {
16472
+ if (!containsPlaceholder(genBase)) return theirs;
16473
+ const managedMasks = /* @__PURE__ */ new Set();
16474
+ for (const line of genBase.split("\n")) {
16475
+ if (AUTOVERSION_PLACEHOLDERS.some((p) => line.includes(p))) managedMasks.add(maskVersions(line));
16476
+ }
16477
+ if (managedMasks.size === 0) return theirs;
16478
+ const targetByMask = /* @__PURE__ */ new Map();
16479
+ for (const line of target.split("\n")) {
16480
+ const mask = maskVersions(line);
16481
+ if (managedMasks.has(mask) && !targetByMask.has(mask)) targetByMask.set(mask, line);
16482
+ }
16483
+ return theirs.split("\n").map((line) => {
16484
+ const mask = maskVersions(line);
16485
+ const replacement = managedMasks.has(mask) ? targetByMask.get(mask) : void 0;
16486
+ return replacement !== void 0 && replacement !== line ? replacement : line;
16487
+ }).join("\n");
16488
+ }
16489
+ function neutralizeAutoversionTheirs(genBase, theirs) {
16490
+ return remapManagedLines(genBase, genBase, theirs);
16491
+ }
16492
+ function alignAutoversionToOurs(genBase, ours, theirs) {
16493
+ return remapManagedLines(genBase, ours, theirs);
16494
+ }
16495
+
16496
+ // src/applicator/runMergeAndRecord.ts
16380
16497
  async function runMergeAndRecord(args) {
16381
16498
  const { plan, inputs, patch, accumulator } = args;
16382
16499
  const { resolvedPath, metadata, oursPath, ours } = inputs;
@@ -16407,11 +16524,13 @@ async function runMergeAndRecord(args) {
16407
16524
  accumulator.recordMerge(resolvedPath, merged2.content, patch.base_generation);
16408
16525
  return { file: resolvedPath, status: "merged", ...unreachableFlag };
16409
16526
  }
16410
- const merged = threeWayMerge(plan.mergeBase, ours, plan.theirs);
16527
+ const mergeBase = inputs.base != null ? alignAutoversionToOurs(inputs.base, ours, plan.mergeBase) : plan.mergeBase;
16528
+ const theirs = inputs.base != null ? alignAutoversionToOurs(inputs.base, ours, plan.theirs) : plan.theirs;
16529
+ const merged = threeWayMerge(mergeBase, ours, theirs);
16411
16530
  await (0, import_promises2.mkdir)((0, import_node_path5.dirname)(oursPath), { recursive: true });
16412
16531
  await (0, import_promises2.writeFile)(oursPath, merged.content);
16413
16532
  if (merged.hasConflicts) {
16414
- accumulator.recordConflict(resolvedPath, plan.theirs, patch.base_generation);
16533
+ accumulator.recordConflict(resolvedPath, theirs, patch.base_generation);
16415
16534
  return {
16416
16535
  file: resolvedPath,
16417
16536
  status: "conflict",
@@ -16420,7 +16539,7 @@ async function runMergeAndRecord(args) {
16420
16539
  conflictMetadata: metadata
16421
16540
  };
16422
16541
  }
16423
- accumulator.recordMerge(resolvedPath, plan.theirs, patch.base_generation);
16542
+ accumulator.recordMerge(resolvedPath, theirs, patch.base_generation);
16424
16543
  return {
16425
16544
  file: resolvedPath,
16426
16545
  status: "merged",
@@ -16935,6 +17054,15 @@ function isDiffLineForFile(diffLine, filePath) {
16935
17054
  return match2 !== null && match2[1] === filePath;
16936
17055
  }
16937
17056
 
17057
+ // src/shared/fernignore.ts
17058
+ var import_node_fs2 = require("fs");
17059
+ var import_node_path7 = require("path");
17060
+ function readFernignorePatterns(outputDir) {
17061
+ const fernignorePath = (0, import_node_path7.join)(outputDir, ".fernignore");
17062
+ if (!(0, import_node_fs2.existsSync)(fernignorePath)) return [];
17063
+ return (0, import_node_fs2.readFileSync)(fernignorePath, "utf-8").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
17064
+ }
17065
+
16938
17066
  // src/ReplayCommitter.ts
16939
17067
  var ReplayCommitter = class {
16940
17068
  git;
@@ -17040,7 +17168,7 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
17040
17168
  // src/PatchRegionDiff.ts
17041
17169
  var import_promises4 = require("fs/promises");
17042
17170
  var import_node_os2 = require("os");
17043
- var import_node_path7 = require("path");
17171
+ var import_node_path8 = require("path");
17044
17172
  var import_node_child_process = require("child_process");
17045
17173
  init_HybridReconstruction();
17046
17174
  function normalizeHunkBody(hunk) {
@@ -17258,10 +17386,10 @@ function overlayRegions(currentGenLines, locInCurrentGen, workingTreeLines, locI
17258
17386
  }
17259
17387
  async function unifiedDiff(beforeContent, afterContent, filePath) {
17260
17388
  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-"));
17389
+ const tmp = await (0, import_promises4.mkdtemp)((0, import_node_path8.join)((0, import_node_os2.tmpdir)(), "fern-replay-pp-"));
17262
17390
  try {
17263
- const beforePath = (0, import_node_path7.join)(tmp, "before");
17264
- const afterPath = (0, import_node_path7.join)(tmp, "after");
17391
+ const beforePath = (0, import_node_path8.join)(tmp, "before");
17392
+ const afterPath = (0, import_node_path8.join)(tmp, "after");
17265
17393
  await (0, import_promises4.writeFile)(beforePath, beforeContent, "utf-8");
17266
17394
  await (0, import_promises4.writeFile)(afterPath, afterContent, "utf-8");
17267
17395
  const raw = await runGitDiffNoIndex(beforePath, afterPath);
@@ -17436,6 +17564,7 @@ var FirstGenerationFlow = class {
17436
17564
  patchesToApply: [],
17437
17565
  newPatchIds: /* @__PURE__ */ new Set(),
17438
17566
  detectorWarnings: [],
17567
+ detectorDegraded: [],
17439
17568
  revertedCount: 0,
17440
17569
  genSha: genRecord.commit_sha,
17441
17570
  optionsSnapshot: options
@@ -17495,6 +17624,7 @@ var SkipApplicationFlow = class {
17495
17624
  patchesToApply: [],
17496
17625
  newPatchIds: /* @__PURE__ */ new Set(),
17497
17626
  detectorWarnings: [],
17627
+ detectorDegraded: [],
17498
17628
  revertedCount: 0,
17499
17629
  genSha: genRecord.commit_sha,
17500
17630
  optionsSnapshot: options
@@ -17532,8 +17662,10 @@ var NoPatchesFlow = class {
17532
17662
  const previousGenerationSha = preLock.current_generation ?? null;
17533
17663
  let { patches: newPatches, revertedPatchIds } = await this.service.detector.detectNewPatches();
17534
17664
  const detectorWarnings = [...this.service.detector.warnings];
17665
+ const detectorDegraded = [...this.service.detector.degradedReasons];
17535
17666
  if (options?.dryRun) {
17536
17667
  const dryRunWarnings = [...detectorWarnings, ...this.service.warnings];
17668
+ const dryRunDegraded = dedupeDegradedReasons([...detectorDegraded, ...this.service.degradedReasons]);
17537
17669
  const preparedReport = {
17538
17670
  flow: "no-patches",
17539
17671
  patchesDetected: newPatches.length,
@@ -17543,7 +17675,9 @@ var NoPatchesFlow = class {
17543
17675
  patchesReverted: revertedPatchIds.length,
17544
17676
  conflicts: [],
17545
17677
  wouldApply: newPatches,
17546
- warnings: dryRunWarnings.length > 0 ? dryRunWarnings : void 0
17678
+ warnings: dryRunWarnings.length > 0 ? dryRunWarnings : void 0,
17679
+ degraded: dryRunDegraded.length > 0 ? true : void 0,
17680
+ degradedReasons: dryRunDegraded.length > 0 ? dryRunDegraded : void 0
17547
17681
  };
17548
17682
  const headSha = await this.service.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
17549
17683
  return {
@@ -17597,6 +17731,7 @@ var NoPatchesFlow = class {
17597
17731
  patchesToApply: newPatches,
17598
17732
  newPatchIds: new Set(newPatches.map((p) => p.id)),
17599
17733
  detectorWarnings,
17734
+ detectorDegraded,
17600
17735
  revertedCount: revertedPatchIds.length,
17601
17736
  genSha: genRecord.commit_sha,
17602
17737
  optionsSnapshot: options
@@ -17640,6 +17775,7 @@ var NoPatchesFlow = class {
17640
17775
  }
17641
17776
  }
17642
17777
  const warnings = [...detectorWarnings, ...this.service.warnings];
17778
+ const degradedReasons = [...prep._prepared.detectorDegraded, ...this.service.degradedReasons];
17643
17779
  return this.service.buildReport(
17644
17780
  "no-patches",
17645
17781
  newPatches,
@@ -17648,7 +17784,8 @@ var NoPatchesFlow = class {
17648
17784
  warnings,
17649
17785
  rebaseCounts,
17650
17786
  void 0,
17651
- prep._prepared.revertedCount
17787
+ prep._prepared.revertedCount,
17788
+ degradedReasons
17652
17789
  );
17653
17790
  }
17654
17791
  };
@@ -17665,6 +17802,10 @@ var NormalRegenerationFlow = class {
17665
17802
  const existingPatches2 = this.service.lockManager.getPatches();
17666
17803
  const { patches: newPatches2, revertedPatchIds: dryRunReverted } = await this.service.detector.detectNewPatches();
17667
17804
  const warnings = [...this.service.detector.warnings, ...this.service.warnings];
17805
+ const dryRunDegraded = dedupeDegradedReasons([
17806
+ ...this.service.detector.degradedReasons,
17807
+ ...this.service.degradedReasons
17808
+ ]);
17668
17809
  const allPatches2 = [...existingPatches2, ...newPatches2];
17669
17810
  const preparedReport = {
17670
17811
  flow: "normal-regeneration",
@@ -17675,7 +17816,9 @@ var NormalRegenerationFlow = class {
17675
17816
  patchesReverted: dryRunReverted.length,
17676
17817
  conflicts: [],
17677
17818
  wouldApply: allPatches2,
17678
- warnings: warnings.length > 0 ? warnings : void 0
17819
+ warnings: warnings.length > 0 ? warnings : void 0,
17820
+ degraded: dryRunDegraded.length > 0 ? true : void 0,
17821
+ degradedReasons: dryRunDegraded.length > 0 ? dryRunDegraded : void 0
17679
17822
  };
17680
17823
  const headSha = await this.service.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
17681
17824
  return {
@@ -17714,6 +17857,7 @@ var NormalRegenerationFlow = class {
17714
17857
  existingPatches = this.service.lockManager.getPatches();
17715
17858
  let { patches: newPatches, revertedPatchIds } = await this.service.detector.detectNewPatches();
17716
17859
  const detectorWarnings = [...this.service.detector.warnings];
17860
+ const detectorDegraded = [...this.service.detector.degradedReasons];
17717
17861
  if (removedByPreRebase.length > 0) {
17718
17862
  const removedOriginalCommits = new Set(removedByPreRebase.map((p) => p.original_commit));
17719
17863
  const removedOriginalMessages = new Set(removedByPreRebase.map((p) => p.original_message));
@@ -17754,6 +17898,7 @@ var NormalRegenerationFlow = class {
17754
17898
  newPatchIds: new Set(newPatches.map((p) => p.id)),
17755
17899
  preRebaseCounts,
17756
17900
  detectorWarnings,
17901
+ detectorDegraded,
17757
17902
  revertedCount: revertedPatchIds.length,
17758
17903
  genSha: genRecord.commit_sha,
17759
17904
  optionsSnapshot: options
@@ -17802,6 +17947,7 @@ var NormalRegenerationFlow = class {
17802
17947
  await this.service.committer.commitReplay(appliedCount, allPatches, void 0, { buckets });
17803
17948
  }
17804
17949
  const warnings = [...detectorWarnings, ...this.service.warnings];
17950
+ const degradedReasons = [...prep._prepared.detectorDegraded, ...this.service.degradedReasons];
17805
17951
  return this.service.buildReport(
17806
17952
  "normal-regeneration",
17807
17953
  allPatches,
@@ -17810,7 +17956,8 @@ var NormalRegenerationFlow = class {
17810
17956
  warnings,
17811
17957
  rebaseCounts,
17812
17958
  prep._prepared.preRebaseCounts,
17813
- prep._prepared.revertedCount
17959
+ prep._prepared.revertedCount,
17960
+ degradedReasons
17814
17961
  );
17815
17962
  }
17816
17963
  };
@@ -17850,6 +17997,16 @@ var ReplayService = class {
17850
17997
  * @internal Used by Flow implementations in `src/flows/`.
17851
17998
  */
17852
17999
  warnings = [];
18000
+ /**
18001
+ * Service-level structured degraded reasons, mirroring `warnings`.
18002
+ * Populated at apply time (e.g. `recordUnreachableBaseWarnings` when a
18003
+ * patch's base-generation tree is unreachable). Merged with
18004
+ * `detector.degradedReasons` into `ReplayReport.degradedReasons` by each
18005
+ * flow handler. Cleared at the top of `prepareReplay()`.
18006
+ *
18007
+ * @internal Used by Flow implementations in `src/flows/`.
18008
+ */
18009
+ degradedReasons = [];
17853
18010
  /**
17854
18011
  * @internal Test-only accessor.
17855
18012
  * Returns the ReplayApplicator instance used for the last run. Tests use this
@@ -17893,7 +18050,10 @@ var ReplayService = class {
17893
18050
  async prepareReplay(options) {
17894
18051
  this.warnings = [];
17895
18052
  this.detector.warnings.length = 0;
18053
+ this.degradedReasons = [];
18054
+ this.detector.degradedReasons.length = 0;
17896
18055
  this.cleanseLegacyFernignoreEntries();
18056
+ this.cleanseInfrastructureFileEntries();
17897
18057
  return this.selectFlow(options).prepare(options);
17898
18058
  }
17899
18059
  /**
@@ -17947,6 +18107,50 @@ var ReplayService = class {
17947
18107
  this.lockManager.save();
17948
18108
  }
17949
18109
  }
18110
+ /**
18111
+ * Migration step: strips infrastructure file diffs (`.fernignore`, `.fern/*`)
18112
+ * from existing patches' `patch_content`. These diffs were bundled by older
18113
+ * detection logic that did not scope `git diff` when only infrastructure files
18114
+ * were stripped. Patches that end up with no surviving diff sections are
18115
+ * removed entirely.
18116
+ *
18117
+ * Idempotent: a second run is a no-op because the first run already removed
18118
+ * all infrastructure sections. Per-patch try/catch ensures isolation.
18119
+ */
18120
+ cleanseInfrastructureFileEntries() {
18121
+ if (!this.lockManager.exists()) return;
18122
+ this.lockManager.read();
18123
+ let lockfileMutated = false;
18124
+ const patches = this.lockManager.getPatches();
18125
+ for (const patch of patches) {
18126
+ try {
18127
+ const result = computeInfrastructureFileCleanse(
18128
+ patch,
18129
+ (content) => this.detector.computeContentHash(content)
18130
+ );
18131
+ if (result.unchanged) continue;
18132
+ if (result.remove) {
18133
+ this.lockManager.removePatch(patch.id);
18134
+ this.warnings.push(
18135
+ `Stripped infrastructure file diffs from patch ${patch.id}: patch contained only infrastructure file changes (${result.strippedPaths.join(", ")}) \u2014 removed.`
18136
+ );
18137
+ } else {
18138
+ this.lockManager.updatePatch(patch.id, {
18139
+ patch_content: result.patch_content,
18140
+ content_hash: result.content_hash
18141
+ });
18142
+ this.warnings.push(
18143
+ `Stripped infrastructure file diffs from patch ${patch.id}: removed bundled diffs for ${result.strippedPaths.join(", ")}.`
18144
+ );
18145
+ }
18146
+ lockfileMutated = true;
18147
+ } catch {
18148
+ }
18149
+ }
18150
+ if (lockfileMutated) {
18151
+ this.lockManager.save();
18152
+ }
18153
+ }
17950
18154
  /**
17951
18155
  * Phase 2 of the two-phase replay flow. Applies patches, rebases them against
17952
18156
  * the generation, saves the lockfile, and commits `[fern-replay]` (or stages
@@ -18113,7 +18317,9 @@ var ReplayService = class {
18113
18317
  if (baseChanged) repointed++;
18114
18318
  continue;
18115
18319
  }
18116
- const diff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
18320
+ const rawDiff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
18321
+ const neutralizedRebase = rawDiff != null && containsPlaceholder(rawDiff) ? await this.computeAutoversionNeutralizedRebase(patch.files, currentGenSha) : null;
18322
+ const diff = neutralizedRebase ? neutralizedRebase.diff : rawDiff;
18117
18323
  if (diff === null) continue;
18118
18324
  if (!diff.trim()) {
18119
18325
  if (hasProtectedFiles) {
@@ -18169,7 +18375,7 @@ var ReplayService = class {
18169
18375
  patch.base_generation = currentGenSha;
18170
18376
  patch.patch_content = diff;
18171
18377
  patch.content_hash = newContentHash;
18172
- const snapshot = await this.readSnapshotFromDisk(patch.files);
18378
+ const snapshot = neutralizedRebase ? neutralizedRebase.snapshot : await this.readSnapshotFromDisk(patch.files);
18173
18379
  const snapshotIsNonEmpty = Object.keys(snapshot).length > 0;
18174
18380
  if (snapshotIsNonEmpty) {
18175
18381
  patch.theirs_snapshot = snapshot;
@@ -18202,13 +18408,54 @@ var ReplayService = class {
18202
18408
  for (const file of files) {
18203
18409
  if (isBinaryFile(file)) continue;
18204
18410
  try {
18205
- const content = await (0, import_promises6.readFile)((0, import_node_path9.join)(this.outputDir, file), "utf-8");
18411
+ const content = await (0, import_promises6.readFile)((0, import_node_path10.join)(this.outputDir, file), "utf-8");
18206
18412
  snapshot[file] = content;
18207
18413
  } catch {
18208
18414
  }
18209
18415
  }
18210
18416
  return snapshot;
18211
18417
  }
18418
+ /**
18419
+ * Autoversion-aware replacement for the naive `git diff currentGen -- files`
18420
+ * + on-disk snapshot used when rebasing a cleanly-applied patch.
18421
+ *
18422
+ * The generation tree (BASE) still carries the placeholder on version lines
18423
+ * (autoversion runs after `[fern-generated]`), but the working tree holds
18424
+ * autoversion's resolved semver. A raw diff/snapshot would fold the
18425
+ * placeholder->semver swap into the customer's patch_content/theirs_snapshot,
18426
+ * pinning a stale version. This re-derives both against a version-neutralized
18427
+ * THEIRS so the pipeline-owned version line never enters the patch.
18428
+ *
18429
+ * Returns null when no file is under autoversion (fixed `--version`, i.e. no
18430
+ * placeholder in the generation tree) — callers keep their existing behavior.
18431
+ */
18432
+ async computeAutoversionNeutralizedRebase(files, currentGenSha) {
18433
+ const genByFile = /* @__PURE__ */ new Map();
18434
+ let anyPlaceholder = false;
18435
+ for (const file of files) {
18436
+ const gen = await this.git.showFile(currentGenSha, file).catch(() => null);
18437
+ genByFile.set(file, gen);
18438
+ if (containsPlaceholder(gen)) anyPlaceholder = true;
18439
+ }
18440
+ if (!anyPlaceholder) return null;
18441
+ const snapshot = {};
18442
+ const fragments = [];
18443
+ for (const file of files) {
18444
+ if (isBinaryFile(file)) continue;
18445
+ const disk = await (0, import_promises6.readFile)((0, import_node_path10.join)(this.outputDir, file), "utf-8").catch(() => null);
18446
+ if (disk == null) continue;
18447
+ const gen = genByFile.get(file) ?? null;
18448
+ const neutralized = gen != null ? neutralizeAutoversionTheirs(gen, disk) : disk;
18449
+ snapshot[file] = neutralized;
18450
+ if (gen == null) {
18451
+ fragments.push(synthesizeNewFileDiff(file, neutralized));
18452
+ } else if (gen !== neutralized) {
18453
+ const fileDiff = await unifiedDiff(gen, neutralized, file);
18454
+ if (fileDiff.trim()) fragments.push(fileDiff);
18455
+ }
18456
+ }
18457
+ return { diff: fragments.join(""), snapshot };
18458
+ }
18212
18459
  /**
18213
18460
  * Detects autoversion contamination — returns true when a
18214
18461
  * `[fern-autoversion]` commit between `fromSha` and `toSha` touched any
@@ -18719,6 +18966,12 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
18719
18966
  );
18720
18967
  }
18721
18968
  }
18969
+ if (warned.size > 0) {
18970
+ this.degradedReasons.push({
18971
+ code: "patch-base-unreachable",
18972
+ 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.`
18973
+ });
18974
+ }
18722
18975
  }
18723
18976
  /**
18724
18977
  * After applyPatches(), strip conflict markers from conflicting files
@@ -18730,11 +18983,11 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
18730
18983
  if (result.status !== "conflict" || !result.fileResults) continue;
18731
18984
  for (const fileResult of result.fileResults) {
18732
18985
  if (fileResult.status !== "conflict") continue;
18733
- const filePath = (0, import_node_path9.join)(this.outputDir, fileResult.file);
18986
+ const filePath = (0, import_node_path10.join)(this.outputDir, fileResult.file);
18734
18987
  try {
18735
- const content = (0, import_node_fs2.readFileSync)(filePath, "utf-8");
18988
+ const content = (0, import_node_fs3.readFileSync)(filePath, "utf-8");
18736
18989
  const stripped = stripConflictMarkers(content);
18737
- (0, import_node_fs2.writeFileSync)(filePath, stripped);
18990
+ (0, import_node_fs3.writeFileSync)(filePath, stripped);
18738
18991
  } catch {
18739
18992
  }
18740
18993
  }
@@ -18762,12 +19015,11 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
18762
19015
  }
18763
19016
  /** @internal Used by Flow implementations in `src/flows/`. */
18764
19017
  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("#"));
19018
+ return readFernignorePatterns(this.outputDir);
18768
19019
  }
18769
19020
  /** @internal Used by Flow implementations in `src/flows/`. */
18770
- buildReport(flow, patches, results, options, warnings, rebaseCounts, preRebaseCounts, patchesReverted) {
19021
+ buildReport(flow, patches, results, options, warnings, rebaseCounts, preRebaseCounts, patchesReverted, degradedReasons) {
19022
+ const degraded = dedupeDegradedReasons(degradedReasons ?? []);
18771
19023
  const conflictResults = results.filter((r) => r.status === "conflict");
18772
19024
  const conflictDetails = conflictResults.map((r) => {
18773
19025
  const conflictFiles = r.fileResults?.filter((f) => f.status === "conflict") ?? [];
@@ -18807,10 +19059,23 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
18807
19059
  conflictDetails: r.fileResults?.filter((f) => f.status === "conflict") ?? []
18808
19060
  })) : void 0,
18809
19061
  wouldApply: options?.dryRun ? patches : void 0,
18810
- warnings: warnings && warnings.length > 0 ? warnings : void 0
19062
+ warnings: warnings && warnings.length > 0 ? warnings : void 0,
19063
+ degraded: degraded.length > 0 ? true : void 0,
19064
+ degradedReasons: degraded.length > 0 ? degraded : void 0
18811
19065
  };
18812
19066
  }
18813
19067
  };
19068
+ function dedupeDegradedReasons(reasons) {
19069
+ const seen = /* @__PURE__ */ new Set();
19070
+ const out = [];
19071
+ for (const reason of reasons) {
19072
+ const key = `${reason.code}\0${reason.message}`;
19073
+ if (seen.has(key)) continue;
19074
+ seen.add(key);
19075
+ out.push(reason);
19076
+ }
19077
+ return out;
19078
+ }
18814
19079
  function computeCommitBuckets(results, absorbedPatchIds, patchesPassedToCommit) {
18815
19080
  const resultByPatchId = /* @__PURE__ */ new Map();
18816
19081
  for (const r of results) resultByPatchId.set(r.patch.id, r);
@@ -18859,7 +19124,7 @@ function computeLegacyFernignoreCleanse(patch, fernignorePatterns, computeConten
18859
19124
  }
18860
19125
  }
18861
19126
  }
18862
- const { stripped: patchContentStripped, content: newPatchContent, strippedSectionPaths } = stripFernignoreDiffSections(patch.patch_content, matchesFernignore);
19127
+ const { stripped: patchContentStripped, content: newPatchContent, strippedSectionPaths } = stripDiffSectionsByPredicate(patch.patch_content, matchesFernignore);
18863
19128
  const unchanged = strippedFromFiles.length === 0 && !snapshotChanged && !patchContentStripped;
18864
19129
  if (unchanged) {
18865
19130
  return {
@@ -18894,7 +19159,38 @@ function computeLegacyFernignoreCleanse(patch, fernignorePatterns, computeConten
18894
19159
  strippedPaths
18895
19160
  };
18896
19161
  }
18897
- function stripFernignoreDiffSections(patchContent, matchesFernignore) {
19162
+ function isInfrastructureFile(filePath) {
19163
+ return filePath === ".fernignore" || filePath.startsWith(".fern/");
19164
+ }
19165
+ function computeInfrastructureFileCleanse(patch, computeContentHash2) {
19166
+ const { stripped, content: newPatchContent, strippedSectionPaths } = stripDiffSectionsByPredicate(patch.patch_content, isInfrastructureFile);
19167
+ if (!stripped) {
19168
+ return {
19169
+ unchanged: true,
19170
+ remove: false,
19171
+ patch_content: patch.patch_content,
19172
+ content_hash: patch.content_hash,
19173
+ strippedPaths: []
19174
+ };
19175
+ }
19176
+ if (newPatchContent.trim() === "" || patch.files.length === 0) {
19177
+ return {
19178
+ unchanged: false,
19179
+ remove: true,
19180
+ patch_content: "",
19181
+ content_hash: "",
19182
+ strippedPaths: strippedSectionPaths
19183
+ };
19184
+ }
19185
+ return {
19186
+ unchanged: false,
19187
+ remove: false,
19188
+ patch_content: newPatchContent,
19189
+ content_hash: computeContentHash2(newPatchContent),
19190
+ strippedPaths: strippedSectionPaths
19191
+ };
19192
+ }
19193
+ function stripDiffSectionsByPredicate(patchContent, shouldStrip) {
18898
19194
  const lines = patchContent.split("\n");
18899
19195
  const sectionStarts = [];
18900
19196
  for (let i = 0; i < lines.length; i++) {
@@ -18916,7 +19212,7 @@ function stripFernignoreDiffSections(patchContent, matchesFernignore) {
18916
19212
  const section = sectionStarts[i];
18917
19213
  const next = sectionStarts[i + 1];
18918
19214
  const endIdx = next ? next.idx : lines.length;
18919
- if (section.path !== null && matchesFernignore(section.path)) {
19215
+ if (section.path !== null && shouldStrip(section.path)) {
18920
19216
  stripped = true;
18921
19217
  strippedSectionPaths.push(section.path);
18922
19218
  continue;
@@ -18928,8 +19224,8 @@ function stripFernignoreDiffSections(patchContent, matchesFernignore) {
18928
19224
 
18929
19225
  // src/FernignoreMigrator.ts
18930
19226
  var import_node_crypto2 = require("crypto");
18931
- var import_node_fs3 = require("fs");
18932
- var import_node_path10 = require("path");
19227
+ var import_node_fs4 = require("fs");
19228
+ var import_node_path11 = require("path");
18933
19229
  var import_yaml2 = __toESM(require_dist3(), 1);
18934
19230
  var FernignoreMigrator = class {
18935
19231
  git;
@@ -18941,14 +19237,14 @@ var FernignoreMigrator = class {
18941
19237
  this.outputDir = outputDir;
18942
19238
  }
18943
19239
  fernignoreExists() {
18944
- return (0, import_node_fs3.existsSync)((0, import_node_path10.join)(this.outputDir, ".fernignore"));
19240
+ return (0, import_node_fs4.existsSync)((0, import_node_path11.join)(this.outputDir, ".fernignore"));
18945
19241
  }
18946
19242
  readFernignorePatterns() {
18947
- const fernignorePath = (0, import_node_path10.join)(this.outputDir, ".fernignore");
18948
- if (!(0, import_node_fs3.existsSync)(fernignorePath)) {
19243
+ const fernignorePath = (0, import_node_path11.join)(this.outputDir, ".fernignore");
19244
+ if (!(0, import_node_fs4.existsSync)(fernignorePath)) {
18949
19245
  return [];
18950
19246
  }
18951
- const content = (0, import_node_fs3.readFileSync)(fernignorePath, "utf-8");
19247
+ const content = (0, import_node_fs4.readFileSync)(fernignorePath, "utf-8");
18952
19248
  return content.split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
18953
19249
  }
18954
19250
  /** Analyze .fernignore patterns vs git history. Creates synthetic patches for files differing from pristine generation. */
@@ -19053,16 +19349,16 @@ var FernignoreMigrator = class {
19053
19349
  return results;
19054
19350
  }
19055
19351
  readFileContent(filePath) {
19056
- const fullPath = (0, import_node_path10.join)(this.outputDir, filePath);
19057
- if (!(0, import_node_fs3.existsSync)(fullPath)) {
19352
+ const fullPath = (0, import_node_path11.join)(this.outputDir, filePath);
19353
+ if (!(0, import_node_fs4.existsSync)(fullPath)) {
19058
19354
  return null;
19059
19355
  }
19060
19356
  try {
19061
- const stat = (0, import_node_fs3.statSync)(fullPath);
19357
+ const stat = (0, import_node_fs4.statSync)(fullPath);
19062
19358
  if (stat.isDirectory()) {
19063
19359
  return null;
19064
19360
  }
19065
- return (0, import_node_fs3.readFileSync)(fullPath, "utf-8");
19361
+ return (0, import_node_fs4.readFileSync)(fullPath, "utf-8");
19066
19362
  } catch {
19067
19363
  return null;
19068
19364
  }
@@ -19118,27 +19414,27 @@ var FernignoreMigrator = class {
19118
19414
  };
19119
19415
  }
19120
19416
  movePatternsToReplayYml(patterns) {
19121
- const replayYmlPath = (0, import_node_path10.join)(this.outputDir, ".fern", "replay.yml");
19417
+ const replayYmlPath = (0, import_node_path11.join)(this.outputDir, ".fern", "replay.yml");
19122
19418
  let config = {};
19123
- if ((0, import_node_fs3.existsSync)(replayYmlPath)) {
19124
- const content = (0, import_node_fs3.readFileSync)(replayYmlPath, "utf-8");
19419
+ if ((0, import_node_fs4.existsSync)(replayYmlPath)) {
19420
+ const content = (0, import_node_fs4.readFileSync)(replayYmlPath, "utf-8");
19125
19421
  config = (0, import_yaml2.parse)(content) ?? {};
19126
19422
  }
19127
19423
  const existing = config.exclude ?? [];
19128
19424
  const merged = [.../* @__PURE__ */ new Set([...existing, ...patterns])];
19129
19425
  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 });
19426
+ const dir = (0, import_node_path11.dirname)(replayYmlPath);
19427
+ if (!(0, import_node_fs4.existsSync)(dir)) {
19428
+ (0, import_node_fs4.mkdirSync)(dir, { recursive: true });
19133
19429
  }
19134
- (0, import_node_fs3.writeFileSync)(replayYmlPath, (0, import_yaml2.stringify)(config, { lineWidth: 0 }), "utf-8");
19430
+ (0, import_node_fs4.writeFileSync)(replayYmlPath, (0, import_yaml2.stringify)(config, { lineWidth: 0 }), "utf-8");
19135
19431
  }
19136
19432
  };
19137
19433
 
19138
19434
  // src/commands/bootstrap.ts
19139
19435
  var import_node_crypto3 = require("crypto");
19140
- var import_node_fs4 = require("fs");
19141
- var import_node_path11 = require("path");
19436
+ var import_node_fs5 = require("fs");
19437
+ var import_node_path12 = require("path");
19142
19438
  init_GitClient();
19143
19439
  async function bootstrap(outputDir, options) {
19144
19440
  const git = new GitClient(outputDir);
@@ -19305,10 +19601,10 @@ function parseGitLog(log) {
19305
19601
  }
19306
19602
  var REPLAY_FERNIGNORE_ENTRIES = [".fern/replay.lock", ".fern/replay.yml", ".gitattributes"];
19307
19603
  function ensureFernignoreEntries(outputDir) {
19308
- const fernignorePath = (0, import_node_path11.join)(outputDir, ".fernignore");
19604
+ const fernignorePath = (0, import_node_path12.join)(outputDir, ".fernignore");
19309
19605
  let content = "";
19310
- if ((0, import_node_fs4.existsSync)(fernignorePath)) {
19311
- content = (0, import_node_fs4.readFileSync)(fernignorePath, "utf-8");
19606
+ if ((0, import_node_fs5.existsSync)(fernignorePath)) {
19607
+ content = (0, import_node_fs5.readFileSync)(fernignorePath, "utf-8");
19312
19608
  }
19313
19609
  const lines = content.split("\n");
19314
19610
  const toAdd = [];
@@ -19324,15 +19620,15 @@ function ensureFernignoreEntries(outputDir) {
19324
19620
  content += "\n";
19325
19621
  }
19326
19622
  content += toAdd.join("\n") + "\n";
19327
- (0, import_node_fs4.writeFileSync)(fernignorePath, content, "utf-8");
19623
+ (0, import_node_fs5.writeFileSync)(fernignorePath, content, "utf-8");
19328
19624
  return true;
19329
19625
  }
19330
19626
  var GITATTRIBUTES_ENTRIES = [".fern/replay.lock linguist-generated=true"];
19331
19627
  function ensureGitattributesEntries(outputDir) {
19332
- const gitattributesPath = (0, import_node_path11.join)(outputDir, ".gitattributes");
19628
+ const gitattributesPath = (0, import_node_path12.join)(outputDir, ".gitattributes");
19333
19629
  let content = "";
19334
- if ((0, import_node_fs4.existsSync)(gitattributesPath)) {
19335
- content = (0, import_node_fs4.readFileSync)(gitattributesPath, "utf-8");
19630
+ if ((0, import_node_fs5.existsSync)(gitattributesPath)) {
19631
+ content = (0, import_node_fs5.readFileSync)(gitattributesPath, "utf-8");
19336
19632
  }
19337
19633
  const lines = content.split("\n");
19338
19634
  const toAdd = [];
@@ -19348,7 +19644,7 @@ function ensureGitattributesEntries(outputDir) {
19348
19644
  content += "\n";
19349
19645
  }
19350
19646
  content += toAdd.join("\n") + "\n";
19351
- (0, import_node_fs4.writeFileSync)(gitattributesPath, content, "utf-8");
19647
+ (0, import_node_fs5.writeFileSync)(gitattributesPath, content, "utf-8");
19352
19648
  }
19353
19649
  function computeContentHash(patchContent) {
19354
19650
  const lines = patchContent.split("\n");
@@ -19359,6 +19655,7 @@ function computeContentHash(patchContent) {
19359
19655
  }
19360
19656
 
19361
19657
  // src/commands/forget.ts
19658
+ init_GitClient();
19362
19659
  function parseDiffStat(patchContent) {
19363
19660
  let additions = 0;
19364
19661
  let deletions = 0;
@@ -19417,7 +19714,28 @@ var EMPTY_RESULT = {
19417
19714
  totalPatches: 0,
19418
19715
  warnings: []
19419
19716
  };
19420
- function forget(outputDir, options) {
19717
+ async function dismissPatches(outputDir, lockManager, patches) {
19718
+ const git = new GitClient(outputDir);
19719
+ const detector = new ReplayDetector(git, lockManager, outputDir, readFernignorePatterns(outputDir));
19720
+ for (const patch of patches) {
19721
+ const hashes = [patch.content_hash];
19722
+ if (patch.original_commit) {
19723
+ const materialized = await detector.materializeCommitPatch(patch.original_commit);
19724
+ if (materialized.status === "ok" && !hashes.includes(materialized.contentHash)) {
19725
+ hashes.push(materialized.contentHash);
19726
+ }
19727
+ }
19728
+ lockManager.addDismissal({
19729
+ patch_id: patch.id,
19730
+ original_commit: patch.original_commit,
19731
+ original_message: patch.original_message,
19732
+ content_hashes: hashes,
19733
+ dismissed_at: (/* @__PURE__ */ new Date()).toISOString(),
19734
+ via: "forget"
19735
+ });
19736
+ }
19737
+ }
19738
+ async function forget(outputDir, options) {
19421
19739
  const lockManager = new LockfileManager(outputDir);
19422
19740
  if (!lockManager.exists()) {
19423
19741
  return { ...EMPTY_RESULT };
@@ -19428,9 +19746,7 @@ function forget(outputDir, options) {
19428
19746
  const removed = lock.patches.map(toMatchedPatch);
19429
19747
  const warnings = buildWarnings(lock.patches);
19430
19748
  if (!options.dryRun) {
19431
- for (const patch of lock.patches) {
19432
- lockManager.addForgottenHash(patch.content_hash);
19433
- }
19749
+ await dismissPatches(outputDir, lockManager, lock.patches);
19434
19750
  lockManager.clearPatches();
19435
19751
  lockManager.save();
19436
19752
  }
@@ -19459,8 +19775,8 @@ function forget(outputDir, options) {
19459
19775
  }
19460
19776
  const warnings = buildWarnings(patchesToRemove);
19461
19777
  if (!options.dryRun) {
19778
+ await dismissPatches(outputDir, lockManager, patchesToRemove);
19462
19779
  for (const patch of patchesToRemove) {
19463
- lockManager.addForgottenHash(patch.content_hash);
19464
19780
  lockManager.removePatch(patch.id);
19465
19781
  }
19466
19782
  lockManager.save();
@@ -19501,7 +19817,7 @@ function forget(outputDir, options) {
19501
19817
  }
19502
19818
 
19503
19819
  // src/commands/reset.ts
19504
- var import_node_fs5 = require("fs");
19820
+ var import_node_fs6 = require("fs");
19505
19821
  function reset(outputDir, options) {
19506
19822
  const lockManager = new LockfileManager(outputDir);
19507
19823
  if (!lockManager.exists()) {
@@ -19515,7 +19831,7 @@ function reset(outputDir, options) {
19515
19831
  const lock = lockManager.read();
19516
19832
  const patchCount = lock.patches.length;
19517
19833
  if (!options?.dryRun) {
19518
- (0, import_node_fs5.unlinkSync)(lockManager.lockfilePath);
19834
+ (0, import_node_fs6.unlinkSync)(lockManager.lockfilePath);
19519
19835
  }
19520
19836
  return {
19521
19837
  success: true,
@@ -19527,7 +19843,7 @@ function reset(outputDir, options) {
19527
19843
 
19528
19844
  // src/commands/resolve.ts
19529
19845
  var import_promises7 = require("fs/promises");
19530
- var import_node_path12 = require("path");
19846
+ var import_node_path13 = require("path");
19531
19847
  init_GitClient();
19532
19848
  async function resolve(outputDir, options) {
19533
19849
  const lockManager = new LockfileManager(outputDir);
@@ -19568,15 +19884,16 @@ async function resolve(outputDir, options) {
19568
19884
  const patchesToCommit = [...resolvingPatches, ...unresolvedPatches];
19569
19885
  if (patchesToCommit.length > 0) {
19570
19886
  const currentGen = lock.current_generation;
19571
- const detector = new ReplayDetector(git, lockManager, outputDir);
19887
+ const detector = new ReplayDetector(git, lockManager, outputDir, readFernignorePatterns(outputDir));
19572
19888
  const warnings = [];
19889
+ const patchesDismissed = [];
19573
19890
  let patchesResolved = 0;
19574
19891
  const pass1 = [];
19575
19892
  for (const patch of patchesToCommit) {
19576
19893
  const result = await computePerPatchDiffWithFallback(
19577
19894
  patch,
19578
19895
  (f) => git.showFile(currentGen, f),
19579
- (f) => (0, import_promises7.readFile)((0, import_node_path12.join)(outputDir, f), "utf-8").catch(() => null),
19896
+ (f) => (0, import_promises7.readFile)((0, import_node_path13.join)(outputDir, f), "utf-8").catch(() => null),
19580
19897
  (files) => git.exec(["diff", currentGen, "--", ...files]).catch(() => null)
19581
19898
  );
19582
19899
  if (result === null) {
@@ -19610,7 +19927,27 @@ async function resolve(outputDir, options) {
19610
19927
  const r = pass1[i];
19611
19928
  if (r.kind === "skip") continue;
19612
19929
  if (r.kind === "revert") {
19930
+ const hashes = [patch.content_hash];
19931
+ if (patch.original_commit) {
19932
+ const materialized = await detector.materializeCommitPatch(patch.original_commit);
19933
+ if (materialized.status === "ok" && !hashes.includes(materialized.contentHash)) {
19934
+ hashes.push(materialized.contentHash);
19935
+ } else if (materialized.status === "unreachable") {
19936
+ warnings.push(
19937
+ `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.`
19938
+ );
19939
+ }
19940
+ }
19941
+ lockManager.addDismissal({
19942
+ patch_id: patch.id,
19943
+ original_commit: patch.original_commit,
19944
+ original_message: patch.original_message,
19945
+ content_hashes: hashes,
19946
+ dismissed_at: (/* @__PURE__ */ new Date()).toISOString(),
19947
+ via: "resolve"
19948
+ });
19613
19949
  lockManager.removePatch(patch.id);
19950
+ patchesDismissed.push({ id: patch.id, message: patch.original_message });
19614
19951
  continue;
19615
19952
  }
19616
19953
  if (lastIndexByHash.get(r.hash) !== i) {
@@ -19632,7 +19969,7 @@ async function resolve(outputDir, options) {
19632
19969
  const theirsSnapshot = {};
19633
19970
  for (const file of filesForSnapshot) {
19634
19971
  if (isBinaryFile(file)) continue;
19635
- const content = await (0, import_promises7.readFile)((0, import_node_path12.join)(outputDir, file), "utf-8").catch(() => null);
19972
+ const content = await (0, import_promises7.readFile)((0, import_node_path13.join)(outputDir, file), "utf-8").catch(() => null);
19636
19973
  if (content != null) {
19637
19974
  theirsSnapshot[file] = content;
19638
19975
  }
@@ -19659,6 +19996,7 @@ async function resolve(outputDir, options) {
19659
19996
  commitSha: commitSha2,
19660
19997
  phase: "committed",
19661
19998
  patchesResolved,
19999
+ patchesDismissed: patchesDismissed.length > 0 ? patchesDismissed : void 0,
19662
20000
  warnings: warnings.length > 0 ? warnings : void 0
19663
20001
  };
19664
20002
  }
@@ -19706,7 +20044,9 @@ function status(outputDir) {
19706
20044
  lastGeneration: void 0,
19707
20045
  patches: [],
19708
20046
  unresolvedCount: 0,
19709
- excludePatterns: []
20047
+ excludePatterns: [],
20048
+ dismissed: [],
20049
+ legacyDismissedCount: 0
19710
20050
  };
19711
20051
  }
19712
20052
  const lock = lockManager.read();
@@ -19735,13 +20075,24 @@ function status(outputDir) {
19735
20075
  }
19736
20076
  const config = lockManager.getCustomizationsConfig();
19737
20077
  const excludePatterns = config.exclude ?? [];
20078
+ const dismissals = lock.dismissals ?? [];
20079
+ const dismissed = dismissals.map((d) => ({
20080
+ message: d.original_message,
20081
+ sha: d.original_commit.slice(0, 7),
20082
+ via: d.via,
20083
+ dismissedAt: d.dismissed_at
20084
+ }));
20085
+ const coveredHashes = new Set(dismissals.flatMap((d) => d.content_hashes));
20086
+ const legacyDismissedCount = (lock.forgotten_hashes ?? []).filter((h) => !coveredHashes.has(h)).length;
19738
20087
  return {
19739
20088
  initialized: true,
19740
20089
  generationCount: lock.generations.length,
19741
20090
  lastGeneration,
19742
20091
  patches,
19743
20092
  unresolvedCount,
19744
- excludePatterns
20093
+ excludePatterns,
20094
+ dismissed,
20095
+ legacyDismissedCount
19745
20096
  };
19746
20097
  }
19747
20098
 
@@ -19829,7 +20180,7 @@ function parseArgs(argv) {
19829
20180
  positionals.push(arg);
19830
20181
  }
19831
20182
  }
19832
- return { command, dir: (0, import_node_path13.resolve)(dir), flags, positionals };
20183
+ return { command, dir: (0, import_node_path14.resolve)(dir), flags, positionals };
19833
20184
  }
19834
20185
  async function runBootstrap(dir, flags) {
19835
20186
  const dryRun = !!flags.dryRun;
@@ -19907,6 +20258,7 @@ async function runStatus(dir, flags) {
19907
20258
  }
19908
20259
  if (result.patches.length === 0) {
19909
20260
  console.log("\nNo customizations tracked. Edit SDK files and commit \u2014 Replay will detect them on next generation.");
20261
+ printDismissedSection(result);
19910
20262
  return;
19911
20263
  }
19912
20264
  const unresolvedSuffix = result.unresolvedCount > 0 ? `, ${result.unresolvedCount} unresolved` : "";
@@ -19944,6 +20296,26 @@ ${prefix}${patch.id} [${patch.type}] "${patch.message}"`);
19944
20296
  console.log(` ... and ${result.patches.length - maxDisplay} more`);
19945
20297
  }
19946
20298
  }
20299
+ printDismissedSection(result);
20300
+ }
20301
+ function printDismissedSection(result) {
20302
+ if (result.dismissed.length === 0 && result.legacyDismissedCount === 0) {
20303
+ return;
20304
+ }
20305
+ const total = result.dismissed.length + result.legacyDismissedCount;
20306
+ console.log(`
20307
+ Dismissed customizations: ${total}`);
20308
+ for (const d of result.dismissed) {
20309
+ const shaSuffix = d.sha ? ` (${d.sha})` : "";
20310
+ const date = d.dismissedAt ? d.dismissedAt.slice(0, 10) : "";
20311
+ const dateSuffix = date ? ` on ${date}` : "";
20312
+ console.log(` "${d.message}"${shaSuffix} \u2014 dismissed via ${d.via}${dateSuffix}`);
20313
+ }
20314
+ if (result.legacyDismissedCount > 0) {
20315
+ console.log(
20316
+ ` ... and ${result.legacyDismissedCount} dismissed before this version \u2014 details unavailable`
20317
+ );
20318
+ }
19947
20319
  }
19948
20320
  async function runDetect(dir) {
19949
20321
  const lockManager = new LockfileManager(dir);
@@ -19955,6 +20327,7 @@ async function runDetect(dir) {
19955
20327
  const git = new GitClient(dir);
19956
20328
  const detector = new ReplayDetector(git, lockManager, dir);
19957
20329
  const { patches, revertedPatchIds } = await detector.detectNewPatches();
20330
+ printDegradedBanner(detector.degradedReasons);
19958
20331
  if (detector.warnings.length > 0) {
19959
20332
  for (const w of detector.warnings) console.log(`Warning: ${w}`);
19960
20333
  console.log();
@@ -19985,7 +20358,20 @@ async function runReplay(dir, flags) {
19985
20358
  const report = await service.runReplay({ dryRun });
19986
20359
  printReport(report);
19987
20360
  }
20361
+ function printDegradedBanner(reasons) {
20362
+ if (reasons.length === 0) return;
20363
+ console.log("============================================================");
20364
+ console.log(" REPLAY DEGRADED \u2014 customizations may not have been");
20365
+ console.log(" carried forward in this run. Review the result before");
20366
+ console.log(" merging.");
20367
+ console.log("============================================================");
20368
+ for (const reason of reasons) {
20369
+ console.log(` [${reason.code}] ${reason.message}`);
20370
+ }
20371
+ console.log();
20372
+ }
19988
20373
  function printReport(report) {
20374
+ printDegradedBanner(report.degradedReasons ?? []);
19989
20375
  console.log(`Flow: ${report.flow}`);
19990
20376
  console.log(`Patches detected: ${report.patchesDetected}`);
19991
20377
  console.log(`Patches applied: ${report.patchesApplied}`);
@@ -20153,7 +20539,7 @@ async function runForget(dir, flags, positionals) {
20153
20539
  const pattern = !isPatchIdMode && positionals.length > 0 ? positionals[0] : void 0;
20154
20540
  const patchIds = isPatchIdMode ? positionals : void 0;
20155
20541
  if (all) {
20156
- const preview = forget(dir, { all: true, dryRun: true });
20542
+ const preview = await forget(dir, { all: true, dryRun: true });
20157
20543
  if (!preview.initialized) {
20158
20544
  console.log("Replay is not initialized. Run 'fern replay init' to get started.");
20159
20545
  process.exit(1);
@@ -20172,14 +20558,14 @@ async function runForget(dir, flags, positionals) {
20172
20558
  return;
20173
20559
  }
20174
20560
  }
20175
- const finalResult = dryRun ? preview : forget(dir, { all: true });
20561
+ const finalResult = dryRun ? preview : await forget(dir, { all: true });
20176
20562
  printForgetWarnings(finalResult.warnings);
20177
20563
  const verb2 = dryRun ? "Would remove" : "Removed";
20178
20564
  console.log(`${verb2} ${finalResult.removed.length} patches. ${finalResult.remaining} patches remaining.`);
20179
20565
  return;
20180
20566
  }
20181
20567
  if (isPatchIdMode && patchIds) {
20182
- const result2 = forget(dir, { patchIds, dryRun });
20568
+ const result2 = await forget(dir, { patchIds, dryRun });
20183
20569
  if (!result2.initialized) {
20184
20570
  console.log("Replay is not initialized. Run 'fern replay init' to get started.");
20185
20571
  process.exit(1);
@@ -20206,7 +20592,7 @@ async function runForget(dir, flags, positionals) {
20206
20592
  console.log(`${result2.remaining} patches remaining.`);
20207
20593
  return;
20208
20594
  }
20209
- const searchResult = pattern ? forget(dir, { pattern }) : forget(dir);
20595
+ const searchResult = pattern ? await forget(dir, { pattern }) : await forget(dir);
20210
20596
  if (!searchResult.initialized) {
20211
20597
  console.log("Replay is not initialized. Run 'fern replay init' to get started.");
20212
20598
  process.exit(1);
@@ -20254,7 +20640,7 @@ This will remove ${selectedIds.length} patch(es). Affected files will be overwri
20254
20640
  return;
20255
20641
  }
20256
20642
  }
20257
- const result = forget(dir, { patchIds: selectedIds, dryRun });
20643
+ const result = await forget(dir, { patchIds: selectedIds, dryRun });
20258
20644
  printForgetWarnings(result.warnings);
20259
20645
  const verb = dryRun ? "Would remove" : "Removed";
20260
20646
  console.log(`
@@ -20321,12 +20707,23 @@ Resolve the conflicts in your editor, then run 'fern-replay resolve ${dir}' agai
20321
20707
  if (result.patchesResolved) {
20322
20708
  console.log(`Resolution complete. Updated ${result.patchesResolved} patch(es) in lockfile.`);
20323
20709
  }
20710
+ if (result.patchesDismissed && result.patchesDismissed.length > 0) {
20711
+ console.log(`Permanently dismissed ${result.patchesDismissed.length} customization(s) (you kept the generated code):`);
20712
+ for (const d of result.patchesDismissed) {
20713
+ console.log(` "${d.message}" (${d.id}) \u2014 will not return on future generations`);
20714
+ }
20715
+ }
20716
+ if (result.warnings) {
20717
+ for (const w of result.warnings) {
20718
+ console.log(`Warning: ${w}`);
20719
+ }
20720
+ }
20324
20721
  console.log(`Created [fern-replay] commit: ${result.commitSha?.slice(0, 7)}`);
20325
20722
  console.log("Push to update the PR.");
20326
20723
  }
20327
20724
  async function main() {
20328
20725
  const { command, dir, flags, positionals } = parseArgs(process.argv);
20329
- if (!(0, import_node_fs6.existsSync)(dir)) {
20726
+ if (!(0, import_node_fs7.existsSync)(dir)) {
20330
20727
  console.error(`Directory not found: ${dir}`);
20331
20728
  process.exit(1);
20332
20729
  }