@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.cjs CHANGED
@@ -801,6 +801,7 @@ var LockfileManager = class {
801
801
 
802
802
  // src/ReplayDetector.ts
803
803
  var import_node_crypto = require("crypto");
804
+ var import_minimatch2 = require("minimatch");
804
805
 
805
806
  // src/shared/binary.ts
806
807
  var import_node_path2 = require("path");
@@ -860,6 +861,15 @@ function isBinaryFile(filePath) {
860
861
 
861
862
  // src/ReplayDetector.ts
862
863
  var INFRASTRUCTURE_FILES = /* @__PURE__ */ new Set([".fernignore"]);
864
+ function matchesFernignorePattern(filePath, patterns) {
865
+ if (patterns.length === 0) return false;
866
+ return patterns.some((p) => {
867
+ if (filePath === p) return true;
868
+ if ((0, import_minimatch2.minimatch)(filePath, p)) return true;
869
+ if (!p.includes("*") && !p.includes("?") && filePath.startsWith(p + "/")) return true;
870
+ return false;
871
+ });
872
+ }
863
873
  function parsePatchFileHeaders(patchContent) {
864
874
  const results = [];
865
875
  let currentPath = null;
@@ -900,21 +910,25 @@ async function capturePatchSnapshot(git, files, treeish) {
900
910
  }
901
911
  return snapshot;
902
912
  }
903
- function filterInfrastructureAndSensitiveFiles(rawFilesOutput) {
913
+ function filterInfrastructureAndSensitiveFiles(rawFilesOutput, fernignorePatterns) {
904
914
  const allFiles = rawFilesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
905
915
  const sensitiveFiles = allFiles.filter((f) => isSensitiveFile(f));
906
- const files = allFiles.filter((f) => !isSensitiveFile(f));
907
- return { files, sensitiveFiles };
916
+ const nonSensitive = allFiles.filter((f) => !isSensitiveFile(f));
917
+ const fernignoredFiles = nonSensitive.filter((f) => matchesFernignorePattern(f, fernignorePatterns));
918
+ const files = nonSensitive.filter((f) => !matchesFernignorePattern(f, fernignorePatterns));
919
+ return { files, sensitiveFiles, fernignoredFiles };
908
920
  }
909
921
  var ReplayDetector = class {
910
922
  git;
911
923
  lockManager;
912
924
  sdkOutputDir;
925
+ fernignorePatterns;
913
926
  warnings = [];
914
- constructor(git, lockManager, sdkOutputDir) {
927
+ constructor(git, lockManager, sdkOutputDir, fernignorePatterns = []) {
915
928
  this.git = git;
916
929
  this.lockManager = lockManager;
917
930
  this.sdkOutputDir = sdkOutputDir;
931
+ this.fernignorePatterns = fernignorePatterns;
918
932
  }
919
933
  /**
920
934
  * Derive the previous-generation SHA from git history instead of trusting
@@ -949,6 +963,22 @@ var ReplayDetector = class {
949
963
  return commit.sha;
950
964
  }
951
965
  }
966
+ let fullLog;
967
+ try {
968
+ fullLog = await this.git.exec([
969
+ "log",
970
+ "--no-merges",
971
+ "--format=%H%x00%an%x00%ae%x00%s",
972
+ "HEAD"
973
+ ]);
974
+ } catch {
975
+ return null;
976
+ }
977
+ for (const commit of this.parseGitLog(fullLog)) {
978
+ if (isGenerationBoundary(commit)) {
979
+ return commit.sha;
980
+ }
981
+ }
952
982
  return null;
953
983
  }
954
984
  async detectNewPatches() {
@@ -960,6 +990,11 @@ var ReplayDetector = class {
960
990
  }
961
991
  const lastGen = this.getLastGeneration(lock);
962
992
  if (!lastGen) {
993
+ if (lock.current_generation) {
994
+ this.warnings.push(
995
+ `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.`
996
+ );
997
+ }
963
998
  return { patches: [], revertedPatchIds: [] };
964
999
  }
965
1000
  const exists = await this.git.commitExists(lastGen.commit_sha);
@@ -1021,22 +1056,30 @@ var ReplayDetector = class {
1021
1056
  continue;
1022
1057
  }
1023
1058
  const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
1024
- const { files, sensitiveFiles } = filterInfrastructureAndSensitiveFiles(filesOutput);
1059
+ const { files, sensitiveFiles, fernignoredFiles } = filterInfrastructureAndSensitiveFiles(
1060
+ filesOutput,
1061
+ this.fernignorePatterns
1062
+ );
1025
1063
  if (sensitiveFiles.length > 0) {
1026
1064
  this.warnings.push(
1027
1065
  `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
1028
1066
  );
1029
- if (files.length > 0) {
1030
- const parentSha = parents[0];
1031
- if (parentSha) {
1032
- try {
1033
- patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
1034
- } catch {
1035
- continue;
1036
- }
1037
- if (!patchContent.trim()) continue;
1038
- contentHash = this.computeContentHash(patchContent);
1067
+ }
1068
+ if (fernignoredFiles.length > 0) {
1069
+ this.warnings.push(
1070
+ `.fernignore-protected file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${fernignoredFiles.join(", ")}. These files are managed by .fernignore, not Replay.`
1071
+ );
1072
+ }
1073
+ if ((sensitiveFiles.length > 0 || fernignoredFiles.length > 0) && files.length > 0) {
1074
+ const parentSha = parents[0];
1075
+ if (parentSha) {
1076
+ try {
1077
+ patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
1078
+ } catch {
1079
+ continue;
1039
1080
  }
1081
+ if (!patchContent.trim()) continue;
1082
+ contentHash = this.computeContentHash(patchContent);
1040
1083
  }
1041
1084
  }
1042
1085
  if (files.length === 0) {
@@ -1235,12 +1278,20 @@ var ReplayDetector = class {
1235
1278
  resolvedBaseGeneration = fallback;
1236
1279
  }
1237
1280
  const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
1238
- const { files, sensitiveFiles } = filterInfrastructureAndSensitiveFiles(filesOutput);
1281
+ const { files, sensitiveFiles, fernignoredFiles } = filterInfrastructureAndSensitiveFiles(
1282
+ filesOutput,
1283
+ this.fernignorePatterns
1284
+ );
1239
1285
  if (sensitiveFiles.length > 0) {
1240
1286
  this.warnings.push(
1241
1287
  `Sensitive file(s) excluded from composite patch: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
1242
1288
  );
1243
1289
  }
1290
+ if (fernignoredFiles.length > 0) {
1291
+ this.warnings.push(
1292
+ `.fernignore-protected file(s) excluded from composite patch: ${fernignoredFiles.join(", ")}. These files are managed by .fernignore, not Replay.`
1293
+ );
1294
+ }
1244
1295
  if (files.length === 0) return { patches: [], revertedPatchIds: [] };
1245
1296
  const diff = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
1246
1297
  if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
@@ -1323,23 +1374,31 @@ var ReplayDetector = class {
1323
1374
  continue;
1324
1375
  }
1325
1376
  const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
1326
- const { files, sensitiveFiles } = filterInfrastructureAndSensitiveFiles(filesOutput);
1377
+ const { files, sensitiveFiles, fernignoredFiles } = filterInfrastructureAndSensitiveFiles(
1378
+ filesOutput,
1379
+ this.fernignorePatterns
1380
+ );
1327
1381
  if (sensitiveFiles.length > 0) {
1328
1382
  this.warnings.push(
1329
1383
  `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
1330
1384
  );
1331
- if (files.length > 0) {
1332
- if (parents.length === 0) {
1333
- continue;
1334
- }
1335
- try {
1336
- patchContent = await this.git.exec(["diff", parents[0], commit.sha, "--", ...files]);
1337
- } catch {
1338
- continue;
1339
- }
1340
- if (!patchContent.trim()) continue;
1341
- contentHash = this.computeContentHash(patchContent);
1385
+ }
1386
+ if (fernignoredFiles.length > 0) {
1387
+ this.warnings.push(
1388
+ `.fernignore-protected file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${fernignoredFiles.join(", ")}. These files are managed by .fernignore, not Replay.`
1389
+ );
1390
+ }
1391
+ if ((sensitiveFiles.length > 0 || fernignoredFiles.length > 0) && files.length > 0) {
1392
+ if (parents.length === 0) {
1393
+ continue;
1394
+ }
1395
+ try {
1396
+ patchContent = await this.git.exec(["diff", parents[0], commit.sha, "--", ...files]);
1397
+ } catch {
1398
+ continue;
1342
1399
  }
1400
+ if (!patchContent.trim()) continue;
1401
+ contentHash = this.computeContentHash(patchContent);
1343
1402
  }
1344
1403
  if (files.length === 0) {
1345
1404
  continue;
@@ -1575,7 +1634,7 @@ function applyBothPatches(baseLines, oursPatches, theirsPatches) {
1575
1634
  var import_promises3 = require("fs/promises");
1576
1635
  var import_node_os = require("os");
1577
1636
  var import_node_path5 = require("path");
1578
- var import_minimatch2 = require("minimatch");
1637
+ var import_minimatch3 = require("minimatch");
1579
1638
 
1580
1639
  // src/accumulator/MergeAccumulator.ts
1581
1640
  var MergeAccumulator = class {
@@ -1690,16 +1749,20 @@ async function resolveInputs(args) {
1690
1749
  const oursPath = (0, import_node_path3.join)(outputDir, resolvedPath);
1691
1750
  const ours = await (0, import_promises.readFile)(oursPath, "utf-8").catch(() => null);
1692
1751
  let ghostTheirs = null;
1693
- if (!base && ours && !renameSourcePath) {
1752
+ let baseTreeUnreachable = false;
1753
+ if (!base && !renameSourcePath) {
1694
1754
  const treeReachable = await isTreeReachable(baseGen.tree_hash);
1695
1755
  if (!treeReachable) {
1696
- const fileDiff = extractFileDiff2(patch.patch_content, filePath);
1697
- if (fileDiff) {
1698
- const { reconstructFromGhostPatch: reconstructFromGhostPatch2 } = await Promise.resolve().then(() => (init_HybridReconstruction(), HybridReconstruction_exports));
1699
- const result = reconstructFromGhostPatch2(fileDiff, ours);
1700
- if (result) {
1701
- base = result.base;
1702
- ghostTheirs = result.theirs;
1756
+ baseTreeUnreachable = true;
1757
+ if (ours) {
1758
+ const fileDiff = extractFileDiff2(patch.patch_content, filePath);
1759
+ if (fileDiff) {
1760
+ const { reconstructFromGhostPatch: reconstructFromGhostPatch2 } = await Promise.resolve().then(() => (init_HybridReconstruction(), HybridReconstruction_exports));
1761
+ const result = reconstructFromGhostPatch2(fileDiff, ours);
1762
+ if (result) {
1763
+ base = result.base;
1764
+ ghostTheirs = result.theirs;
1765
+ }
1703
1766
  }
1704
1767
  }
1705
1768
  }
@@ -1711,7 +1774,11 @@ async function resolveInputs(args) {
1711
1774
  ours,
1712
1775
  oursPath,
1713
1776
  renameSourcePath,
1714
- ghostTheirs
1777
+ ghostTheirs,
1778
+ // Only meaningful when ghost reconstruction did NOT recover a base; if it
1779
+ // did, `base` is non-null and the merge proceeds normally. We still set
1780
+ // the flag truthfully (the tree IS unreachable) so Phase 3 can decide.
1781
+ baseTreeUnreachable
1715
1782
  };
1716
1783
  }
1717
1784
 
@@ -1894,7 +1961,11 @@ async function buildMergePlan(args) {
1894
1961
  return { kind: "new-file-only-user", theirs: effective_theirs };
1895
1962
  }
1896
1963
  if (base == null && ours != null && effective_theirs != null && !useAccumulatorAsMergeBase) {
1897
- return { kind: "new-file-both", theirs: effective_theirs };
1964
+ return {
1965
+ kind: "new-file-both",
1966
+ theirs: effective_theirs,
1967
+ ...inputs.baseTreeUnreachable ? { baseTreeUnreachable: true } : {}
1968
+ };
1898
1969
  }
1899
1970
  if (effective_theirs == null) {
1900
1971
  return { kind: "skipped", reason: "missing-content" };
@@ -1942,17 +2013,19 @@ async function runMergeAndRecord(args) {
1942
2013
  const merged2 = threeWayMerge("", ours, plan.theirs);
1943
2014
  await (0, import_promises2.mkdir)((0, import_node_path4.dirname)(oursPath), { recursive: true });
1944
2015
  await (0, import_promises2.writeFile)(oursPath, merged2.content);
2016
+ const unreachableFlag = plan.baseTreeUnreachable ? { baseTreeUnreachable: true } : {};
1945
2017
  if (merged2.hasConflicts) {
1946
2018
  return {
1947
2019
  file: resolvedPath,
1948
2020
  status: "conflict",
1949
2021
  conflicts: merged2.conflicts,
1950
2022
  conflictReason: "new-file-both",
1951
- conflictMetadata: metadata
2023
+ conflictMetadata: metadata,
2024
+ ...unreachableFlag
1952
2025
  };
1953
2026
  }
1954
2027
  accumulator.recordMerge(resolvedPath, merged2.content, patch.base_generation);
1955
- return { file: resolvedPath, status: "merged" };
2028
+ return { file: resolvedPath, status: "merged", ...unreachableFlag };
1956
2029
  }
1957
2030
  const merged = threeWayMerge(plan.mergeBase, ours, plan.theirs);
1958
2031
  await (0, import_promises2.mkdir)((0, import_node_path4.dirname)(oursPath), { recursive: true });
@@ -2291,13 +2364,13 @@ var ReplayApplicator = class {
2291
2364
  isExcluded(patch) {
2292
2365
  const config = this.lockManager.getCustomizationsConfig();
2293
2366
  if (!config.exclude) return false;
2294
- return patch.files.some((file) => config.exclude.some((pattern) => (0, import_minimatch2.minimatch)(file, pattern)));
2367
+ return patch.files.some((file) => config.exclude.some((pattern) => (0, import_minimatch3.minimatch)(file, pattern)));
2295
2368
  }
2296
2369
  async resolveFilePath(filePath, baseTreeHash, currentTreeHash) {
2297
2370
  const config = this.lockManager.getCustomizationsConfig();
2298
2371
  if (config.moves) {
2299
2372
  for (const move of config.moves) {
2300
- if ((0, import_minimatch2.minimatch)(filePath, move.from) || filePath === move.from) {
2373
+ if ((0, import_minimatch3.minimatch)(filePath, move.from) || filePath === move.from) {
2301
2374
  if (filePath === move.from) {
2302
2375
  return move.to;
2303
2376
  }
@@ -2588,7 +2661,7 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
2588
2661
  var import_node_fs2 = require("fs");
2589
2662
  var import_promises6 = require("fs/promises");
2590
2663
  var import_node_path8 = require("path");
2591
- var import_minimatch4 = require("minimatch");
2664
+ var import_minimatch5 = require("minimatch");
2592
2665
  init_GitClient();
2593
2666
 
2594
2667
  // src/PatchRegionDiff.ts
@@ -2895,7 +2968,7 @@ function synthesizeDeleteFileDiff(file, content) {
2895
2968
  }
2896
2969
 
2897
2970
  // src/classifier/FileOwnership.ts
2898
- var import_minimatch3 = require("minimatch");
2971
+ var import_minimatch4 = require("minimatch");
2899
2972
  var FileOwnership = class {
2900
2973
  constructor(git) {
2901
2974
  this.git = git;
@@ -2919,7 +2992,7 @@ var FileOwnership = class {
2919
2992
  const { file, originalFileName, patch, currentGenSha, fernignorePatterns, warnedGens, warnings } = ctx;
2920
2993
  if (patch.user_owned) return true;
2921
2994
  if (file === ".fernignore") return true;
2922
- if (fernignorePatterns.some((p) => file === p || (0, import_minimatch3.minimatch)(file, p))) return true;
2995
+ if (fernignorePatterns.some((p) => file === p || (0, import_minimatch4.minimatch)(file, p))) return true;
2923
2996
  if (fileIsCreationInPatchContent(patch.patch_content, originalFileName ?? file)) {
2924
2997
  return true;
2925
2998
  }
@@ -3168,6 +3241,7 @@ var NoPatchesFlow = class {
3168
3241
  results = await this.service.applicator.applyPatches(newPatches);
3169
3242
  this.service._lastApplyResults = results;
3170
3243
  this.service.recordDroppedContextLineWarnings(results);
3244
+ this.service.recordUnreachableBaseWarnings(results);
3171
3245
  this.service.revertConflictingFiles(results);
3172
3246
  for (const result of results) {
3173
3247
  if (result.status === "conflict") {
@@ -3323,6 +3397,7 @@ var NormalRegenerationFlow = class {
3323
3397
  const results = await this.service.applicator.applyPatches(allPatches);
3324
3398
  this.service._lastApplyResults = results;
3325
3399
  this.service.recordDroppedContextLineWarnings(results);
3400
+ this.service.recordUnreachableBaseWarnings(results);
3326
3401
  this.service.revertConflictingFiles(results);
3327
3402
  for (const result of results) {
3328
3403
  if (result.status === "conflict") {
@@ -3425,7 +3500,8 @@ var ReplayService = class {
3425
3500
  this.git = git;
3426
3501
  this.outputDir = outputDir;
3427
3502
  this.lockManager = new LockfileManager(outputDir);
3428
- this.detector = new ReplayDetector(git, this.lockManager, outputDir);
3503
+ const fernignorePatterns = this.readFernignorePatterns();
3504
+ this.detector = new ReplayDetector(git, this.lockManager, outputDir, fernignorePatterns);
3429
3505
  this.applicator = new ReplayApplicator(git, this.lockManager, outputDir);
3430
3506
  this.committer = new ReplayCommitter(git, outputDir);
3431
3507
  this.fileOwnership = new FileOwnership(git);
@@ -3445,8 +3521,60 @@ var ReplayService = class {
3445
3521
  async prepareReplay(options) {
3446
3522
  this.warnings = [];
3447
3523
  this.detector.warnings.length = 0;
3524
+ this.cleanseLegacyFernignoreEntries();
3448
3525
  return this.selectFlow(options).prepare(options);
3449
3526
  }
3527
+ /**
3528
+ * ADR 0002 migration step. Strips `.fernignore`-matched entries from
3529
+ * `patch.files`, `patch.theirs_snapshot` keys, and `diff --git` sections
3530
+ * in `patch.patch_content` for every patch in the lockfile. Patches that
3531
+ * end up with no surviving files are removed entirely.
3532
+ *
3533
+ * Idempotent: a second run is a no-op because the first run already
3534
+ * matches the contract. Per-patch try/catch ensures one bad patch does
3535
+ * not block the rest. Each affected patch emits a warning naming the
3536
+ * stripped paths. `patch_content` is left untouched if no parseable
3537
+ * `diff --git` headers are found (fail-safe for exotic legacy formats).
3538
+ */
3539
+ cleanseLegacyFernignoreEntries() {
3540
+ const patterns = this.readFernignorePatterns();
3541
+ if (patterns.length === 0) return;
3542
+ if (!this.lockManager.exists()) return;
3543
+ this.lockManager.read();
3544
+ let lockfileMutated = false;
3545
+ const patches = this.lockManager.getPatches();
3546
+ for (const patch of patches) {
3547
+ try {
3548
+ const result = computeLegacyFernignoreCleanse(
3549
+ patch,
3550
+ patterns,
3551
+ (content) => this.detector.computeContentHash(content)
3552
+ );
3553
+ if (result.unchanged) continue;
3554
+ if (result.remove) {
3555
+ this.lockManager.removePatch(patch.id);
3556
+ this.warnings.push(
3557
+ `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.)`
3558
+ );
3559
+ } else {
3560
+ this.lockManager.updatePatch(patch.id, {
3561
+ files: result.files,
3562
+ patch_content: result.patch_content,
3563
+ content_hash: result.content_hash,
3564
+ ...result.theirs_snapshot !== void 0 ? { theirs_snapshot: result.theirs_snapshot } : {}
3565
+ });
3566
+ this.warnings.push(
3567
+ `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.)`
3568
+ );
3569
+ }
3570
+ lockfileMutated = true;
3571
+ } catch {
3572
+ }
3573
+ }
3574
+ if (lockfileMutated) {
3575
+ this.lockManager.save();
3576
+ }
3577
+ }
3450
3578
  /**
3451
3579
  * Phase 2 of the two-phase replay flow. Applies patches, rebases them against
3452
3580
  * the generation, saves the lockfile, and commits `[fern-replay]` (or stages
@@ -3578,7 +3706,7 @@ var ReplayService = class {
3578
3706
  )
3579
3707
  );
3580
3708
  const hasProtectedFiles = patch.files.some(
3581
- (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || (0, import_minimatch4.minimatch)(file, p))
3709
+ (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || (0, import_minimatch5.minimatch)(file, p))
3582
3710
  );
3583
3711
  const allUserOwned = isUserOwnedPerFile.every(Boolean);
3584
3712
  if (allUserOwned && patch.files.length > 0) {
@@ -4018,7 +4146,7 @@ var ReplayService = class {
4018
4146
  );
4019
4147
  if (snapHasStaleMarkers) continue;
4020
4148
  const isProtectedSnap = patch.files.some(
4021
- (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || (0, import_minimatch4.minimatch)(file, p))
4149
+ (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || (0, import_minimatch5.minimatch)(file, p))
4022
4150
  );
4023
4151
  if (isProtectedSnap) continue;
4024
4152
  const snapHash = this.detector.computeContentHash(snapshotDiff);
@@ -4058,7 +4186,7 @@ var ReplayService = class {
4058
4186
  continue;
4059
4187
  }
4060
4188
  const isProtected = patch.files.some(
4061
- (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || (0, import_minimatch4.minimatch)(file, p))
4189
+ (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || (0, import_minimatch5.minimatch)(file, p))
4062
4190
  );
4063
4191
  if (isProtected) {
4064
4192
  continue;
@@ -4189,6 +4317,37 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
4189
4317
  }
4190
4318
  }
4191
4319
  }
4320
+ /**
4321
+ * After applyPatches(), surface a warning for each (patch × file) where a
4322
+ * customization's BASE could not be read because the patch's
4323
+ * base-generation tree is unreachable in this clone (shallow-clone window
4324
+ * too small / pruned history). In that state the merge pipeline falls
4325
+ * through to `new-file-both` and the customer's edit can be dropped with NO
4326
+ * conflict and NO error — the INC-866 silent-loss class. This converts that
4327
+ * silence into an explicit, actionable warning naming the file and the
4328
+ * likely cause (insufficient clone window).
4329
+ *
4330
+ * Mirrors `recordDroppedContextLineWarnings`'s "surface, never silently
4331
+ * drop" contract. De-duplicated per file path so a single insufficient
4332
+ * window doesn't spam one warning per touched file repeatedly.
4333
+ */
4334
+ /** @internal Used by Flow implementations in `src/flows/`. */
4335
+ recordUnreachableBaseWarnings(results) {
4336
+ const warned = /* @__PURE__ */ new Set();
4337
+ for (const result of results) {
4338
+ if (!result.fileResults) continue;
4339
+ for (const fr of result.fileResults) {
4340
+ if (!fr.baseTreeUnreachable) continue;
4341
+ if (warned.has(fr.file)) continue;
4342
+ warned.add(fr.file);
4343
+ const base = fr.conflictMetadata?.baseGeneration;
4344
+ const baseRef = base ? ` (base generation ${base.slice(0, 7)})` : "";
4345
+ this.warnings.push(
4346
+ `${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.`
4347
+ );
4348
+ }
4349
+ }
4350
+ }
4192
4351
  /**
4193
4352
  * After applyPatches(), strip conflict markers from conflicting files
4194
4353
  * so only clean content is committed. Keeps the Generated (OURS) side.
@@ -4222,7 +4381,7 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
4222
4381
  if (files.length === 0) return;
4223
4382
  const fernignorePatterns = this.readFernignorePatterns();
4224
4383
  for (const file of files) {
4225
- if (fernignorePatterns.some((pattern) => (0, import_minimatch4.minimatch)(file, pattern))) continue;
4384
+ if (fernignorePatterns.some((pattern) => (0, import_minimatch5.minimatch)(file, pattern))) continue;
4226
4385
  try {
4227
4386
  await this.git.exec(["checkout", "HEAD", "--", file]);
4228
4387
  } catch {
@@ -4307,12 +4466,99 @@ function computeCommitBuckets(results, absorbedPatchIds, patchesPassedToCommit)
4307
4466
  }
4308
4467
  return { applied, unresolved, absorbed };
4309
4468
  }
4469
+ function computeLegacyFernignoreCleanse(patch, fernignorePatterns, computeContentHash2) {
4470
+ const matchesFernignore = (filePath) => fernignorePatterns.some((p) => {
4471
+ if (filePath === p) return true;
4472
+ if ((0, import_minimatch5.minimatch)(filePath, p)) return true;
4473
+ if (!p.includes("*") && !p.includes("?") && filePath.startsWith(p + "/")) return true;
4474
+ return false;
4475
+ });
4476
+ const strippedFromFiles = patch.files.filter(matchesFernignore);
4477
+ const survivingFiles = patch.files.filter((f) => !matchesFernignore(f));
4478
+ let survivingSnapshot = void 0;
4479
+ let snapshotChanged = false;
4480
+ if (patch.theirs_snapshot) {
4481
+ survivingSnapshot = {};
4482
+ for (const [key, value] of Object.entries(patch.theirs_snapshot)) {
4483
+ if (matchesFernignore(key)) {
4484
+ snapshotChanged = true;
4485
+ } else {
4486
+ survivingSnapshot[key] = value;
4487
+ }
4488
+ }
4489
+ }
4490
+ const { stripped: patchContentStripped, content: newPatchContent, strippedSectionPaths } = stripFernignoreDiffSections(patch.patch_content, matchesFernignore);
4491
+ const unchanged = strippedFromFiles.length === 0 && !snapshotChanged && !patchContentStripped;
4492
+ if (unchanged) {
4493
+ return {
4494
+ unchanged: true,
4495
+ remove: false,
4496
+ files: patch.files,
4497
+ patch_content: patch.patch_content,
4498
+ content_hash: patch.content_hash,
4499
+ theirs_snapshot: patch.theirs_snapshot,
4500
+ strippedPaths: []
4501
+ };
4502
+ }
4503
+ const strippedPaths = Array.from(/* @__PURE__ */ new Set([...strippedFromFiles, ...strippedSectionPaths]));
4504
+ if (survivingFiles.length === 0 || newPatchContent.trim() === "") {
4505
+ return {
4506
+ unchanged: false,
4507
+ remove: true,
4508
+ files: [],
4509
+ patch_content: "",
4510
+ content_hash: "",
4511
+ strippedPaths
4512
+ };
4513
+ }
4514
+ const newContentHash = patchContentStripped ? computeContentHash2(newPatchContent) : patch.content_hash;
4515
+ return {
4516
+ unchanged: false,
4517
+ remove: false,
4518
+ files: survivingFiles,
4519
+ patch_content: newPatchContent,
4520
+ content_hash: newContentHash,
4521
+ ...survivingSnapshot !== void 0 ? { theirs_snapshot: survivingSnapshot } : {},
4522
+ strippedPaths
4523
+ };
4524
+ }
4525
+ function stripFernignoreDiffSections(patchContent, matchesFernignore) {
4526
+ const lines = patchContent.split("\n");
4527
+ const sectionStarts = [];
4528
+ for (let i = 0; i < lines.length; i++) {
4529
+ const line = lines[i];
4530
+ if (line.startsWith("diff --git ")) {
4531
+ const match = line.match(/^diff --git a\/(.+?) b\/(.+?)$/);
4532
+ const path = match ? match[2] : null;
4533
+ sectionStarts.push({ idx: i, path });
4534
+ }
4535
+ }
4536
+ if (sectionStarts.length === 0) {
4537
+ return { stripped: false, content: patchContent, strippedSectionPaths: [] };
4538
+ }
4539
+ const preamble = lines.slice(0, sectionStarts[0].idx);
4540
+ const newLines = [...preamble];
4541
+ const strippedSectionPaths = [];
4542
+ let stripped = false;
4543
+ for (let i = 0; i < sectionStarts.length; i++) {
4544
+ const section = sectionStarts[i];
4545
+ const next = sectionStarts[i + 1];
4546
+ const endIdx = next ? next.idx : lines.length;
4547
+ if (section.path !== null && matchesFernignore(section.path)) {
4548
+ stripped = true;
4549
+ strippedSectionPaths.push(section.path);
4550
+ continue;
4551
+ }
4552
+ newLines.push(...lines.slice(section.idx, endIdx));
4553
+ }
4554
+ return { stripped, content: newLines.join("\n"), strippedSectionPaths };
4555
+ }
4310
4556
 
4311
4557
  // src/FernignoreMigrator.ts
4312
4558
  var import_node_crypto2 = require("crypto");
4313
4559
  var import_node_fs3 = require("fs");
4314
4560
  var import_node_path9 = require("path");
4315
- var import_minimatch5 = require("minimatch");
4561
+ var import_minimatch6 = require("minimatch");
4316
4562
  var import_yaml2 = require("yaml");
4317
4563
  var FernignoreMigrator = class {
4318
4564
  git;
@@ -4344,7 +4590,7 @@ var FernignoreMigrator = class {
4344
4590
  const commitsOnly = [];
4345
4591
  const syntheticPatches = [];
4346
4592
  for (const pattern of patterns) {
4347
- const matchingPatch = patches.find((p) => p.files.some((f) => (0, import_minimatch5.minimatch)(f, pattern) || f === pattern));
4593
+ const matchingPatch = patches.find((p) => p.files.some((f) => (0, import_minimatch6.minimatch)(f, pattern) || f === pattern));
4348
4594
  if (matchingPatch) {
4349
4595
  trackedByBoth.push({
4350
4596
  file: pattern,
@@ -4417,7 +4663,7 @@ var FernignoreMigrator = class {
4417
4663
  fernignoreOnly.push(...remainingFernignoreOnly);
4418
4664
  }
4419
4665
  for (const patch of patches) {
4420
- const hasUnprotectedFiles = patch.files.some((f) => !patterns.some((p) => (0, import_minimatch5.minimatch)(f, p) || f === p));
4666
+ const hasUnprotectedFiles = patch.files.some((f) => !patterns.some((p) => (0, import_minimatch6.minimatch)(f, p) || f === p));
4421
4667
  if (hasUnprotectedFiles) {
4422
4668
  commitsOnly.push(patch);
4423
4669
  }
@@ -4429,7 +4675,7 @@ var FernignoreMigrator = class {
4429
4675
  const results = [];
4430
4676
  for (const pattern of patterns) {
4431
4677
  const matching = allFiles.filter(
4432
- (f) => (0, import_minimatch5.minimatch)(f, pattern) || f === pattern || f.startsWith(pattern + "/")
4678
+ (f) => (0, import_minimatch6.minimatch)(f, pattern) || f === pattern || f.startsWith(pattern + "/")
4433
4679
  );
4434
4680
  results.push({ pattern, files: matching.length > 0 ? matching : [pattern] });
4435
4681
  }
@@ -4742,7 +4988,7 @@ function computeContentHash(patchContent) {
4742
4988
  }
4743
4989
 
4744
4990
  // src/commands/forget.ts
4745
- var import_minimatch6 = require("minimatch");
4991
+ var import_minimatch7 = require("minimatch");
4746
4992
  function parseDiffStat(patchContent) {
4747
4993
  let additions = 0;
4748
4994
  let deletions = 0;
@@ -4772,7 +5018,7 @@ function toMatchedPatch(patch) {
4772
5018
  }
4773
5019
  function matchesPatch(patch, pattern) {
4774
5020
  const fileMatch = patch.files.some(
4775
- (file) => file === pattern || (0, import_minimatch6.minimatch)(file, pattern)
5021
+ (file) => file === pattern || (0, import_minimatch7.minimatch)(file, pattern)
4776
5022
  );
4777
5023
  if (fileMatch) return true;
4778
5024
  return patch.original_message.toLowerCase().includes(pattern.toLowerCase());