@fern-api/replay 0.16.1 → 0.17.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -746,6 +746,7 @@ var LockfileManager = class {
746
746
 
747
747
  // src/ReplayDetector.ts
748
748
  import { createHash } from "crypto";
749
+ import { minimatch as minimatch2 } from "minimatch";
749
750
 
750
751
  // src/shared/binary.ts
751
752
  import { extname } from "path";
@@ -805,6 +806,15 @@ function isBinaryFile(filePath) {
805
806
 
806
807
  // src/ReplayDetector.ts
807
808
  var INFRASTRUCTURE_FILES = /* @__PURE__ */ new Set([".fernignore"]);
809
+ function matchesFernignorePattern(filePath, patterns) {
810
+ if (patterns.length === 0) return false;
811
+ return patterns.some((p) => {
812
+ if (filePath === p) return true;
813
+ if (minimatch2(filePath, p)) return true;
814
+ if (!p.includes("*") && !p.includes("?") && filePath.startsWith(p + "/")) return true;
815
+ return false;
816
+ });
817
+ }
808
818
  function parsePatchFileHeaders(patchContent) {
809
819
  const results = [];
810
820
  let currentPath = null;
@@ -845,21 +855,25 @@ async function capturePatchSnapshot(git, files, treeish) {
845
855
  }
846
856
  return snapshot;
847
857
  }
848
- function filterInfrastructureAndSensitiveFiles(rawFilesOutput) {
858
+ function filterInfrastructureAndSensitiveFiles(rawFilesOutput, fernignorePatterns) {
849
859
  const allFiles = rawFilesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
850
860
  const sensitiveFiles = allFiles.filter((f) => isSensitiveFile(f));
851
- const files = allFiles.filter((f) => !isSensitiveFile(f));
852
- return { files, sensitiveFiles };
861
+ const nonSensitive = allFiles.filter((f) => !isSensitiveFile(f));
862
+ const fernignoredFiles = nonSensitive.filter((f) => matchesFernignorePattern(f, fernignorePatterns));
863
+ const files = nonSensitive.filter((f) => !matchesFernignorePattern(f, fernignorePatterns));
864
+ return { files, sensitiveFiles, fernignoredFiles };
853
865
  }
854
866
  var ReplayDetector = class {
855
867
  git;
856
868
  lockManager;
857
869
  sdkOutputDir;
870
+ fernignorePatterns;
858
871
  warnings = [];
859
- constructor(git, lockManager, sdkOutputDir) {
872
+ constructor(git, lockManager, sdkOutputDir, fernignorePatterns = []) {
860
873
  this.git = git;
861
874
  this.lockManager = lockManager;
862
875
  this.sdkOutputDir = sdkOutputDir;
876
+ this.fernignorePatterns = fernignorePatterns;
863
877
  }
864
878
  /**
865
879
  * Derive the previous-generation SHA from git history instead of trusting
@@ -894,6 +908,22 @@ var ReplayDetector = class {
894
908
  return commit.sha;
895
909
  }
896
910
  }
911
+ let fullLog;
912
+ try {
913
+ fullLog = await this.git.exec([
914
+ "log",
915
+ "--no-merges",
916
+ "--format=%H%x00%an%x00%ae%x00%s",
917
+ "HEAD"
918
+ ]);
919
+ } catch {
920
+ return null;
921
+ }
922
+ for (const commit of this.parseGitLog(fullLog)) {
923
+ if (isGenerationBoundary(commit)) {
924
+ return commit.sha;
925
+ }
926
+ }
897
927
  return null;
898
928
  }
899
929
  async detectNewPatches() {
@@ -905,6 +935,11 @@ var ReplayDetector = class {
905
935
  }
906
936
  const lastGen = this.getLastGeneration(lock);
907
937
  if (!lastGen) {
938
+ if (lock.current_generation) {
939
+ this.warnings.push(
940
+ `No generation boundary found in git history and the lockfile's recorded generation (${lock.current_generation.slice(0, 7)}) matches no entry in its generation records. Customer customizations may differ from the last known generation and could be lost on the next regeneration. Run \`fern-replay bootstrap --force\` to re-initialize tracking.`
941
+ );
942
+ }
908
943
  return { patches: [], revertedPatchIds: [] };
909
944
  }
910
945
  const exists = await this.git.commitExists(lastGen.commit_sha);
@@ -966,22 +1001,30 @@ var ReplayDetector = class {
966
1001
  continue;
967
1002
  }
968
1003
  const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
969
- const { files, sensitiveFiles } = filterInfrastructureAndSensitiveFiles(filesOutput);
1004
+ const { files, sensitiveFiles, fernignoredFiles } = filterInfrastructureAndSensitiveFiles(
1005
+ filesOutput,
1006
+ this.fernignorePatterns
1007
+ );
970
1008
  if (sensitiveFiles.length > 0) {
971
1009
  this.warnings.push(
972
1010
  `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
973
1011
  );
974
- if (files.length > 0) {
975
- const parentSha = parents[0];
976
- if (parentSha) {
977
- try {
978
- patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
979
- } catch {
980
- continue;
981
- }
982
- if (!patchContent.trim()) continue;
983
- contentHash = this.computeContentHash(patchContent);
1012
+ }
1013
+ if (fernignoredFiles.length > 0) {
1014
+ this.warnings.push(
1015
+ `.fernignore-protected file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${fernignoredFiles.join(", ")}. These files are managed by .fernignore, not Replay.`
1016
+ );
1017
+ }
1018
+ if ((sensitiveFiles.length > 0 || fernignoredFiles.length > 0) && files.length > 0) {
1019
+ const parentSha = parents[0];
1020
+ if (parentSha) {
1021
+ try {
1022
+ patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
1023
+ } catch {
1024
+ continue;
984
1025
  }
1026
+ if (!patchContent.trim()) continue;
1027
+ contentHash = this.computeContentHash(patchContent);
985
1028
  }
986
1029
  }
987
1030
  if (files.length === 0) {
@@ -1180,12 +1223,20 @@ var ReplayDetector = class {
1180
1223
  resolvedBaseGeneration = fallback;
1181
1224
  }
1182
1225
  const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
1183
- const { files, sensitiveFiles } = filterInfrastructureAndSensitiveFiles(filesOutput);
1226
+ const { files, sensitiveFiles, fernignoredFiles } = filterInfrastructureAndSensitiveFiles(
1227
+ filesOutput,
1228
+ this.fernignorePatterns
1229
+ );
1184
1230
  if (sensitiveFiles.length > 0) {
1185
1231
  this.warnings.push(
1186
1232
  `Sensitive file(s) excluded from composite patch: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
1187
1233
  );
1188
1234
  }
1235
+ if (fernignoredFiles.length > 0) {
1236
+ this.warnings.push(
1237
+ `.fernignore-protected file(s) excluded from composite patch: ${fernignoredFiles.join(", ")}. These files are managed by .fernignore, not Replay.`
1238
+ );
1239
+ }
1189
1240
  if (files.length === 0) return { patches: [], revertedPatchIds: [] };
1190
1241
  const diff = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
1191
1242
  if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
@@ -1268,23 +1319,31 @@ var ReplayDetector = class {
1268
1319
  continue;
1269
1320
  }
1270
1321
  const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
1271
- const { files, sensitiveFiles } = filterInfrastructureAndSensitiveFiles(filesOutput);
1322
+ const { files, sensitiveFiles, fernignoredFiles } = filterInfrastructureAndSensitiveFiles(
1323
+ filesOutput,
1324
+ this.fernignorePatterns
1325
+ );
1272
1326
  if (sensitiveFiles.length > 0) {
1273
1327
  this.warnings.push(
1274
1328
  `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
1275
1329
  );
1276
- if (files.length > 0) {
1277
- if (parents.length === 0) {
1278
- continue;
1279
- }
1280
- try {
1281
- patchContent = await this.git.exec(["diff", parents[0], commit.sha, "--", ...files]);
1282
- } catch {
1283
- continue;
1284
- }
1285
- if (!patchContent.trim()) continue;
1286
- contentHash = this.computeContentHash(patchContent);
1330
+ }
1331
+ if (fernignoredFiles.length > 0) {
1332
+ this.warnings.push(
1333
+ `.fernignore-protected file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${fernignoredFiles.join(", ")}. These files are managed by .fernignore, not Replay.`
1334
+ );
1335
+ }
1336
+ if ((sensitiveFiles.length > 0 || fernignoredFiles.length > 0) && files.length > 0) {
1337
+ if (parents.length === 0) {
1338
+ continue;
1339
+ }
1340
+ try {
1341
+ patchContent = await this.git.exec(["diff", parents[0], commit.sha, "--", ...files]);
1342
+ } catch {
1343
+ continue;
1287
1344
  }
1345
+ if (!patchContent.trim()) continue;
1346
+ contentHash = this.computeContentHash(patchContent);
1288
1347
  }
1289
1348
  if (files.length === 0) {
1290
1349
  continue;
@@ -1520,7 +1579,7 @@ function applyBothPatches(baseLines, oursPatches, theirsPatches) {
1520
1579
  import { mkdir as mkdir2, mkdtemp, readFile as readFile2, rm, unlink, writeFile as writeFile2 } from "fs/promises";
1521
1580
  import { tmpdir } from "os";
1522
1581
  import { dirname as dirname3, join as join3 } from "path";
1523
- import { minimatch as minimatch2 } from "minimatch";
1582
+ import { minimatch as minimatch3 } from "minimatch";
1524
1583
 
1525
1584
  // src/accumulator/MergeAccumulator.ts
1526
1585
  var MergeAccumulator = class {
@@ -1635,16 +1694,20 @@ async function resolveInputs(args) {
1635
1694
  const oursPath = join2(outputDir, resolvedPath);
1636
1695
  const ours = await readFile(oursPath, "utf-8").catch(() => null);
1637
1696
  let ghostTheirs = null;
1638
- if (!base && ours && !renameSourcePath) {
1697
+ let baseTreeUnreachable = false;
1698
+ if (!base && !renameSourcePath) {
1639
1699
  const treeReachable = await isTreeReachable(baseGen.tree_hash);
1640
1700
  if (!treeReachable) {
1641
- const fileDiff = extractFileDiff2(patch.patch_content, filePath);
1642
- if (fileDiff) {
1643
- const { reconstructFromGhostPatch: reconstructFromGhostPatch2 } = await Promise.resolve().then(() => (init_HybridReconstruction(), HybridReconstruction_exports));
1644
- const result = reconstructFromGhostPatch2(fileDiff, ours);
1645
- if (result) {
1646
- base = result.base;
1647
- ghostTheirs = result.theirs;
1701
+ baseTreeUnreachable = true;
1702
+ if (ours) {
1703
+ const fileDiff = extractFileDiff2(patch.patch_content, filePath);
1704
+ if (fileDiff) {
1705
+ const { reconstructFromGhostPatch: reconstructFromGhostPatch2 } = await Promise.resolve().then(() => (init_HybridReconstruction(), HybridReconstruction_exports));
1706
+ const result = reconstructFromGhostPatch2(fileDiff, ours);
1707
+ if (result) {
1708
+ base = result.base;
1709
+ ghostTheirs = result.theirs;
1710
+ }
1648
1711
  }
1649
1712
  }
1650
1713
  }
@@ -1656,7 +1719,11 @@ async function resolveInputs(args) {
1656
1719
  ours,
1657
1720
  oursPath,
1658
1721
  renameSourcePath,
1659
- ghostTheirs
1722
+ ghostTheirs,
1723
+ // Only meaningful when ghost reconstruction did NOT recover a base; if it
1724
+ // did, `base` is non-null and the merge proceeds normally. We still set
1725
+ // the flag truthfully (the tree IS unreachable) so Phase 3 can decide.
1726
+ baseTreeUnreachable
1660
1727
  };
1661
1728
  }
1662
1729
 
@@ -1839,7 +1906,11 @@ async function buildMergePlan(args) {
1839
1906
  return { kind: "new-file-only-user", theirs: effective_theirs };
1840
1907
  }
1841
1908
  if (base == null && ours != null && effective_theirs != null && !useAccumulatorAsMergeBase) {
1842
- return { kind: "new-file-both", theirs: effective_theirs };
1909
+ return {
1910
+ kind: "new-file-both",
1911
+ theirs: effective_theirs,
1912
+ ...inputs.baseTreeUnreachable ? { baseTreeUnreachable: true } : {}
1913
+ };
1843
1914
  }
1844
1915
  if (effective_theirs == null) {
1845
1916
  return { kind: "skipped", reason: "missing-content" };
@@ -1887,17 +1958,19 @@ async function runMergeAndRecord(args) {
1887
1958
  const merged2 = threeWayMerge("", ours, plan.theirs);
1888
1959
  await mkdir(dirname2(oursPath), { recursive: true });
1889
1960
  await writeFile(oursPath, merged2.content);
1961
+ const unreachableFlag = plan.baseTreeUnreachable ? { baseTreeUnreachable: true } : {};
1890
1962
  if (merged2.hasConflicts) {
1891
1963
  return {
1892
1964
  file: resolvedPath,
1893
1965
  status: "conflict",
1894
1966
  conflicts: merged2.conflicts,
1895
1967
  conflictReason: "new-file-both",
1896
- conflictMetadata: metadata
1968
+ conflictMetadata: metadata,
1969
+ ...unreachableFlag
1897
1970
  };
1898
1971
  }
1899
1972
  accumulator.recordMerge(resolvedPath, merged2.content, patch.base_generation);
1900
- return { file: resolvedPath, status: "merged" };
1973
+ return { file: resolvedPath, status: "merged", ...unreachableFlag };
1901
1974
  }
1902
1975
  const merged = threeWayMerge(plan.mergeBase, ours, plan.theirs);
1903
1976
  await mkdir(dirname2(oursPath), { recursive: true });
@@ -2236,13 +2309,13 @@ var ReplayApplicator = class {
2236
2309
  isExcluded(patch) {
2237
2310
  const config = this.lockManager.getCustomizationsConfig();
2238
2311
  if (!config.exclude) return false;
2239
- return patch.files.some((file) => config.exclude.some((pattern) => minimatch2(file, pattern)));
2312
+ return patch.files.some((file) => config.exclude.some((pattern) => minimatch3(file, pattern)));
2240
2313
  }
2241
2314
  async resolveFilePath(filePath, baseTreeHash, currentTreeHash) {
2242
2315
  const config = this.lockManager.getCustomizationsConfig();
2243
2316
  if (config.moves) {
2244
2317
  for (const move of config.moves) {
2245
- if (minimatch2(filePath, move.from) || filePath === move.from) {
2318
+ if (minimatch3(filePath, move.from) || filePath === move.from) {
2246
2319
  if (filePath === move.from) {
2247
2320
  return move.to;
2248
2321
  }
@@ -2533,7 +2606,7 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
2533
2606
  import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
2534
2607
  import { readFile as readFileAsync } from "fs/promises";
2535
2608
  import { join as join6 } from "path";
2536
- import { minimatch as minimatch4 } from "minimatch";
2609
+ import { minimatch as minimatch5 } from "minimatch";
2537
2610
  init_GitClient();
2538
2611
 
2539
2612
  // src/PatchRegionDiff.ts
@@ -2840,7 +2913,7 @@ function synthesizeDeleteFileDiff(file, content) {
2840
2913
  }
2841
2914
 
2842
2915
  // src/classifier/FileOwnership.ts
2843
- import { minimatch as minimatch3 } from "minimatch";
2916
+ import { minimatch as minimatch4 } from "minimatch";
2844
2917
  var FileOwnership = class {
2845
2918
  constructor(git) {
2846
2919
  this.git = git;
@@ -2864,7 +2937,7 @@ var FileOwnership = class {
2864
2937
  const { file, originalFileName, patch, currentGenSha, fernignorePatterns, warnedGens, warnings } = ctx;
2865
2938
  if (patch.user_owned) return true;
2866
2939
  if (file === ".fernignore") return true;
2867
- if (fernignorePatterns.some((p) => file === p || minimatch3(file, p))) return true;
2940
+ if (fernignorePatterns.some((p) => file === p || minimatch4(file, p))) return true;
2868
2941
  if (fileIsCreationInPatchContent(patch.patch_content, originalFileName ?? file)) {
2869
2942
  return true;
2870
2943
  }
@@ -3113,6 +3186,7 @@ var NoPatchesFlow = class {
3113
3186
  results = await this.service.applicator.applyPatches(newPatches);
3114
3187
  this.service._lastApplyResults = results;
3115
3188
  this.service.recordDroppedContextLineWarnings(results);
3189
+ this.service.recordUnreachableBaseWarnings(results);
3116
3190
  this.service.revertConflictingFiles(results);
3117
3191
  for (const result of results) {
3118
3192
  if (result.status === "conflict") {
@@ -3268,6 +3342,7 @@ var NormalRegenerationFlow = class {
3268
3342
  const results = await this.service.applicator.applyPatches(allPatches);
3269
3343
  this.service._lastApplyResults = results;
3270
3344
  this.service.recordDroppedContextLineWarnings(results);
3345
+ this.service.recordUnreachableBaseWarnings(results);
3271
3346
  this.service.revertConflictingFiles(results);
3272
3347
  for (const result of results) {
3273
3348
  if (result.status === "conflict") {
@@ -3370,7 +3445,8 @@ var ReplayService = class {
3370
3445
  this.git = git;
3371
3446
  this.outputDir = outputDir;
3372
3447
  this.lockManager = new LockfileManager(outputDir);
3373
- this.detector = new ReplayDetector(git, this.lockManager, outputDir);
3448
+ const fernignorePatterns = this.readFernignorePatterns();
3449
+ this.detector = new ReplayDetector(git, this.lockManager, outputDir, fernignorePatterns);
3374
3450
  this.applicator = new ReplayApplicator(git, this.lockManager, outputDir);
3375
3451
  this.committer = new ReplayCommitter(git, outputDir);
3376
3452
  this.fileOwnership = new FileOwnership(git);
@@ -3390,8 +3466,60 @@ var ReplayService = class {
3390
3466
  async prepareReplay(options) {
3391
3467
  this.warnings = [];
3392
3468
  this.detector.warnings.length = 0;
3469
+ this.cleanseLegacyFernignoreEntries();
3393
3470
  return this.selectFlow(options).prepare(options);
3394
3471
  }
3472
+ /**
3473
+ * ADR 0002 migration step. Strips `.fernignore`-matched entries from
3474
+ * `patch.files`, `patch.theirs_snapshot` keys, and `diff --git` sections
3475
+ * in `patch.patch_content` for every patch in the lockfile. Patches that
3476
+ * end up with no surviving files are removed entirely.
3477
+ *
3478
+ * Idempotent: a second run is a no-op because the first run already
3479
+ * matches the contract. Per-patch try/catch ensures one bad patch does
3480
+ * not block the rest. Each affected patch emits a warning naming the
3481
+ * stripped paths. `patch_content` is left untouched if no parseable
3482
+ * `diff --git` headers are found (fail-safe for exotic legacy formats).
3483
+ */
3484
+ cleanseLegacyFernignoreEntries() {
3485
+ const patterns = this.readFernignorePatterns();
3486
+ if (patterns.length === 0) return;
3487
+ if (!this.lockManager.exists()) return;
3488
+ this.lockManager.read();
3489
+ let lockfileMutated = false;
3490
+ const patches = this.lockManager.getPatches();
3491
+ for (const patch of patches) {
3492
+ try {
3493
+ const result = computeLegacyFernignoreCleanse(
3494
+ patch,
3495
+ patterns,
3496
+ (content) => this.detector.computeContentHash(content)
3497
+ );
3498
+ if (result.unchanged) continue;
3499
+ if (result.remove) {
3500
+ this.lockManager.removePatch(patch.id);
3501
+ this.warnings.push(
3502
+ `Cleansed legacy patch ${patch.id}: all referenced files match .fernignore \u2014 patch removed. Customer's on-disk content for those files is preserved by .fernignore. (ADR 0002: .fernignore-protected files are not tracked by Replay.)`
3503
+ );
3504
+ } else {
3505
+ this.lockManager.updatePatch(patch.id, {
3506
+ files: result.files,
3507
+ patch_content: result.patch_content,
3508
+ content_hash: result.content_hash,
3509
+ ...result.theirs_snapshot !== void 0 ? { theirs_snapshot: result.theirs_snapshot } : {}
3510
+ });
3511
+ this.warnings.push(
3512
+ `Cleansed legacy patch ${patch.id}: stripped .fernignore-matched entries (${result.strippedPaths.join(", ")}). Customer's on-disk content for those files is preserved by .fernignore. (ADR 0002.)`
3513
+ );
3514
+ }
3515
+ lockfileMutated = true;
3516
+ } catch {
3517
+ }
3518
+ }
3519
+ if (lockfileMutated) {
3520
+ this.lockManager.save();
3521
+ }
3522
+ }
3395
3523
  /**
3396
3524
  * Phase 2 of the two-phase replay flow. Applies patches, rebases them against
3397
3525
  * the generation, saves the lockfile, and commits `[fern-replay]` (or stages
@@ -3523,7 +3651,7 @@ var ReplayService = class {
3523
3651
  )
3524
3652
  );
3525
3653
  const hasProtectedFiles = patch.files.some(
3526
- (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch4(file, p))
3654
+ (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch5(file, p))
3527
3655
  );
3528
3656
  const allUserOwned = isUserOwnedPerFile.every(Boolean);
3529
3657
  if (allUserOwned && patch.files.length > 0) {
@@ -3963,7 +4091,7 @@ var ReplayService = class {
3963
4091
  );
3964
4092
  if (snapHasStaleMarkers) continue;
3965
4093
  const isProtectedSnap = patch.files.some(
3966
- (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch4(file, p))
4094
+ (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch5(file, p))
3967
4095
  );
3968
4096
  if (isProtectedSnap) continue;
3969
4097
  const snapHash = this.detector.computeContentHash(snapshotDiff);
@@ -4003,7 +4131,7 @@ var ReplayService = class {
4003
4131
  continue;
4004
4132
  }
4005
4133
  const isProtected = patch.files.some(
4006
- (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch4(file, p))
4134
+ (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch5(file, p))
4007
4135
  );
4008
4136
  if (isProtected) {
4009
4137
  continue;
@@ -4134,6 +4262,37 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
4134
4262
  }
4135
4263
  }
4136
4264
  }
4265
+ /**
4266
+ * After applyPatches(), surface a warning for each (patch × file) where a
4267
+ * customization's BASE could not be read because the patch's
4268
+ * base-generation tree is unreachable in this clone (shallow-clone window
4269
+ * too small / pruned history). In that state the merge pipeline falls
4270
+ * through to `new-file-both` and the customer's edit can be dropped with NO
4271
+ * conflict and NO error — the INC-866 silent-loss class. This converts that
4272
+ * silence into an explicit, actionable warning naming the file and the
4273
+ * likely cause (insufficient clone window).
4274
+ *
4275
+ * Mirrors `recordDroppedContextLineWarnings`'s "surface, never silently
4276
+ * drop" contract. De-duplicated per file path so a single insufficient
4277
+ * window doesn't spam one warning per touched file repeatedly.
4278
+ */
4279
+ /** @internal Used by Flow implementations in `src/flows/`. */
4280
+ recordUnreachableBaseWarnings(results) {
4281
+ const warned = /* @__PURE__ */ new Set();
4282
+ for (const result of results) {
4283
+ if (!result.fileResults) continue;
4284
+ for (const fr of result.fileResults) {
4285
+ if (!fr.baseTreeUnreachable) continue;
4286
+ if (warned.has(fr.file)) continue;
4287
+ warned.add(fr.file);
4288
+ const base = fr.conflictMetadata?.baseGeneration;
4289
+ const baseRef = base ? ` (base generation ${base.slice(0, 7)})` : "";
4290
+ this.warnings.push(
4291
+ `${fr.file}: could not read the prior generation's version of this file${baseRef} because that generation's tree is outside the clone window (insufficient shallow-clone depth or pruned history). The customization was treated as a new file, which can drop your edits silently. Re-run with the prior generation in the clone window \u2014 e.g. \`git fetch --shallow-since=<date just before the prior [fern-generated] commit>\` or \`git fetch --unshallow\` \u2014 then re-run replay.`
4292
+ );
4293
+ }
4294
+ }
4295
+ }
4137
4296
  /**
4138
4297
  * After applyPatches(), strip conflict markers from conflicting files
4139
4298
  * so only clean content is committed. Keeps the Generated (OURS) side.
@@ -4167,7 +4326,7 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
4167
4326
  if (files.length === 0) return;
4168
4327
  const fernignorePatterns = this.readFernignorePatterns();
4169
4328
  for (const file of files) {
4170
- if (fernignorePatterns.some((pattern) => minimatch4(file, pattern))) continue;
4329
+ if (fernignorePatterns.some((pattern) => minimatch5(file, pattern))) continue;
4171
4330
  try {
4172
4331
  await this.git.exec(["checkout", "HEAD", "--", file]);
4173
4332
  } catch {
@@ -4252,12 +4411,99 @@ function computeCommitBuckets(results, absorbedPatchIds, patchesPassedToCommit)
4252
4411
  }
4253
4412
  return { applied, unresolved, absorbed };
4254
4413
  }
4414
+ function computeLegacyFernignoreCleanse(patch, fernignorePatterns, computeContentHash2) {
4415
+ const matchesFernignore = (filePath) => fernignorePatterns.some((p) => {
4416
+ if (filePath === p) return true;
4417
+ if (minimatch5(filePath, p)) return true;
4418
+ if (!p.includes("*") && !p.includes("?") && filePath.startsWith(p + "/")) return true;
4419
+ return false;
4420
+ });
4421
+ const strippedFromFiles = patch.files.filter(matchesFernignore);
4422
+ const survivingFiles = patch.files.filter((f) => !matchesFernignore(f));
4423
+ let survivingSnapshot = void 0;
4424
+ let snapshotChanged = false;
4425
+ if (patch.theirs_snapshot) {
4426
+ survivingSnapshot = {};
4427
+ for (const [key, value] of Object.entries(patch.theirs_snapshot)) {
4428
+ if (matchesFernignore(key)) {
4429
+ snapshotChanged = true;
4430
+ } else {
4431
+ survivingSnapshot[key] = value;
4432
+ }
4433
+ }
4434
+ }
4435
+ const { stripped: patchContentStripped, content: newPatchContent, strippedSectionPaths } = stripFernignoreDiffSections(patch.patch_content, matchesFernignore);
4436
+ const unchanged = strippedFromFiles.length === 0 && !snapshotChanged && !patchContentStripped;
4437
+ if (unchanged) {
4438
+ return {
4439
+ unchanged: true,
4440
+ remove: false,
4441
+ files: patch.files,
4442
+ patch_content: patch.patch_content,
4443
+ content_hash: patch.content_hash,
4444
+ theirs_snapshot: patch.theirs_snapshot,
4445
+ strippedPaths: []
4446
+ };
4447
+ }
4448
+ const strippedPaths = Array.from(/* @__PURE__ */ new Set([...strippedFromFiles, ...strippedSectionPaths]));
4449
+ if (survivingFiles.length === 0 || newPatchContent.trim() === "") {
4450
+ return {
4451
+ unchanged: false,
4452
+ remove: true,
4453
+ files: [],
4454
+ patch_content: "",
4455
+ content_hash: "",
4456
+ strippedPaths
4457
+ };
4458
+ }
4459
+ const newContentHash = patchContentStripped ? computeContentHash2(newPatchContent) : patch.content_hash;
4460
+ return {
4461
+ unchanged: false,
4462
+ remove: false,
4463
+ files: survivingFiles,
4464
+ patch_content: newPatchContent,
4465
+ content_hash: newContentHash,
4466
+ ...survivingSnapshot !== void 0 ? { theirs_snapshot: survivingSnapshot } : {},
4467
+ strippedPaths
4468
+ };
4469
+ }
4470
+ function stripFernignoreDiffSections(patchContent, matchesFernignore) {
4471
+ const lines = patchContent.split("\n");
4472
+ const sectionStarts = [];
4473
+ for (let i = 0; i < lines.length; i++) {
4474
+ const line = lines[i];
4475
+ if (line.startsWith("diff --git ")) {
4476
+ const match = line.match(/^diff --git a\/(.+?) b\/(.+?)$/);
4477
+ const path = match ? match[2] : null;
4478
+ sectionStarts.push({ idx: i, path });
4479
+ }
4480
+ }
4481
+ if (sectionStarts.length === 0) {
4482
+ return { stripped: false, content: patchContent, strippedSectionPaths: [] };
4483
+ }
4484
+ const preamble = lines.slice(0, sectionStarts[0].idx);
4485
+ const newLines = [...preamble];
4486
+ const strippedSectionPaths = [];
4487
+ let stripped = false;
4488
+ for (let i = 0; i < sectionStarts.length; i++) {
4489
+ const section = sectionStarts[i];
4490
+ const next = sectionStarts[i + 1];
4491
+ const endIdx = next ? next.idx : lines.length;
4492
+ if (section.path !== null && matchesFernignore(section.path)) {
4493
+ stripped = true;
4494
+ strippedSectionPaths.push(section.path);
4495
+ continue;
4496
+ }
4497
+ newLines.push(...lines.slice(section.idx, endIdx));
4498
+ }
4499
+ return { stripped, content: newLines.join("\n"), strippedSectionPaths };
4500
+ }
4255
4501
 
4256
4502
  // src/FernignoreMigrator.ts
4257
4503
  import { createHash as createHash2 } from "crypto";
4258
4504
  import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, statSync, writeFileSync as writeFileSync3 } from "fs";
4259
4505
  import { dirname as dirname5, join as join7 } from "path";
4260
- import { minimatch as minimatch5 } from "minimatch";
4506
+ import { minimatch as minimatch6 } from "minimatch";
4261
4507
  import { parse as parse2, stringify as stringify2 } from "yaml";
4262
4508
  var FernignoreMigrator = class {
4263
4509
  git;
@@ -4289,7 +4535,7 @@ var FernignoreMigrator = class {
4289
4535
  const commitsOnly = [];
4290
4536
  const syntheticPatches = [];
4291
4537
  for (const pattern of patterns) {
4292
- const matchingPatch = patches.find((p) => p.files.some((f) => minimatch5(f, pattern) || f === pattern));
4538
+ const matchingPatch = patches.find((p) => p.files.some((f) => minimatch6(f, pattern) || f === pattern));
4293
4539
  if (matchingPatch) {
4294
4540
  trackedByBoth.push({
4295
4541
  file: pattern,
@@ -4362,7 +4608,7 @@ var FernignoreMigrator = class {
4362
4608
  fernignoreOnly.push(...remainingFernignoreOnly);
4363
4609
  }
4364
4610
  for (const patch of patches) {
4365
- const hasUnprotectedFiles = patch.files.some((f) => !patterns.some((p) => minimatch5(f, p) || f === p));
4611
+ const hasUnprotectedFiles = patch.files.some((f) => !patterns.some((p) => minimatch6(f, p) || f === p));
4366
4612
  if (hasUnprotectedFiles) {
4367
4613
  commitsOnly.push(patch);
4368
4614
  }
@@ -4374,7 +4620,7 @@ var FernignoreMigrator = class {
4374
4620
  const results = [];
4375
4621
  for (const pattern of patterns) {
4376
4622
  const matching = allFiles.filter(
4377
- (f) => minimatch5(f, pattern) || f === pattern || f.startsWith(pattern + "/")
4623
+ (f) => minimatch6(f, pattern) || f === pattern || f.startsWith(pattern + "/")
4378
4624
  );
4379
4625
  results.push({ pattern, files: matching.length > 0 ? matching : [pattern] });
4380
4626
  }
@@ -4687,7 +4933,7 @@ function computeContentHash(patchContent) {
4687
4933
  }
4688
4934
 
4689
4935
  // src/commands/forget.ts
4690
- import { minimatch as minimatch6 } from "minimatch";
4936
+ import { minimatch as minimatch7 } from "minimatch";
4691
4937
  function parseDiffStat(patchContent) {
4692
4938
  let additions = 0;
4693
4939
  let deletions = 0;
@@ -4717,7 +4963,7 @@ function toMatchedPatch(patch) {
4717
4963
  }
4718
4964
  function matchesPatch(patch, pattern) {
4719
4965
  const fileMatch = patch.files.some(
4720
- (file) => file === pattern || minimatch6(file, pattern)
4966
+ (file) => file === pattern || minimatch7(file, pattern)
4721
4967
  );
4722
4968
  if (fileMatch) return true;
4723
4969
  return patch.original_message.toLowerCase().includes(pattern.toLowerCase());