@fern-api/replay 0.16.0 → 0.16.2

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
@@ -588,6 +588,7 @@ function parseRevertedMessage(subject) {
588
588
  // src/LockfileManager.ts
589
589
  var import_node_fs = require("fs");
590
590
  var import_node_path = require("path");
591
+ var import_minimatch = require("minimatch");
591
592
  var import_yaml = require("yaml");
592
593
  var LOCKFILE_HEADER = "# DO NOT EDIT MANUALLY - Managed by Fern Replay\n";
593
594
  var LockfileNotFoundError = class extends Error {
@@ -599,10 +600,23 @@ var LockfileNotFoundError = class extends Error {
599
600
  var LockfileManager = class {
600
601
  outputDir;
601
602
  lock = null;
603
+ fernignorePatterns = [];
602
604
  warnings = [];
603
605
  constructor(outputDir) {
604
606
  this.outputDir = outputDir;
605
607
  }
608
+ /**
609
+ * Inform the lockfile of the customer's `.fernignore` patterns so that
610
+ * `save()` can suppress its "Patch X removed from lockfile" warning when
611
+ * every file in the stripped patch is already `.fernignore`-protected.
612
+ * The strip itself still happens (credentials never persist); only the
613
+ * customer-visible warning is suppressed in that case, because the
614
+ * protected files survive the generator wipe regardless of whether the
615
+ * lockfile tracks them, so there's nothing for the customer to action.
616
+ */
617
+ setFernignorePatterns(patterns) {
618
+ this.fernignorePatterns = patterns;
619
+ }
606
620
  get lockfilePath() {
607
621
  return (0, import_node_path.join)(this.outputDir, ".fern", "replay.lock");
608
622
  }
@@ -671,9 +685,14 @@ var LockfileManager = class {
671
685
  if (sensitiveFiles.length > 0) {
672
686
  reasons.push(`sensitive files: ${sensitiveFiles.join(", ")}`);
673
687
  }
674
- this.warnings.push(
675
- `Patch ${patch.id} removed from lockfile: ${reasons.join("; ")}. This prevents credential exposure in committed lockfiles.`
688
+ const allProtected = patch.files.length > 0 && patch.files.every(
689
+ (f) => this.fernignorePatterns.some((p) => f === p || (0, import_minimatch.minimatch)(f, p))
676
690
  );
691
+ if (!allProtected) {
692
+ this.warnings.push(
693
+ `Patch ${patch.id} removed from lockfile: ${reasons.join("; ")}. This prevents credential exposure in committed lockfiles.`
694
+ );
695
+ }
677
696
  } else {
678
697
  cleanPatches.push(patch);
679
698
  }
@@ -782,6 +801,7 @@ var LockfileManager = class {
782
801
 
783
802
  // src/ReplayDetector.ts
784
803
  var import_node_crypto = require("crypto");
804
+ var import_minimatch2 = require("minimatch");
785
805
 
786
806
  // src/shared/binary.ts
787
807
  var import_node_path2 = require("path");
@@ -841,6 +861,15 @@ function isBinaryFile(filePath) {
841
861
 
842
862
  // src/ReplayDetector.ts
843
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
+ }
844
873
  function parsePatchFileHeaders(patchContent) {
845
874
  const results = [];
846
875
  let currentPath = null;
@@ -881,21 +910,25 @@ async function capturePatchSnapshot(git, files, treeish) {
881
910
  }
882
911
  return snapshot;
883
912
  }
884
- function filterInfrastructureAndSensitiveFiles(rawFilesOutput) {
913
+ function filterInfrastructureAndSensitiveFiles(rawFilesOutput, fernignorePatterns) {
885
914
  const allFiles = rawFilesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
886
915
  const sensitiveFiles = allFiles.filter((f) => isSensitiveFile(f));
887
- const files = allFiles.filter((f) => !isSensitiveFile(f));
888
- 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 };
889
920
  }
890
921
  var ReplayDetector = class {
891
922
  git;
892
923
  lockManager;
893
924
  sdkOutputDir;
925
+ fernignorePatterns;
894
926
  warnings = [];
895
- constructor(git, lockManager, sdkOutputDir) {
927
+ constructor(git, lockManager, sdkOutputDir, fernignorePatterns = []) {
896
928
  this.git = git;
897
929
  this.lockManager = lockManager;
898
930
  this.sdkOutputDir = sdkOutputDir;
931
+ this.fernignorePatterns = fernignorePatterns;
899
932
  }
900
933
  /**
901
934
  * Derive the previous-generation SHA from git history instead of trusting
@@ -1002,22 +1035,30 @@ var ReplayDetector = class {
1002
1035
  continue;
1003
1036
  }
1004
1037
  const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
1005
- const { files, sensitiveFiles } = filterInfrastructureAndSensitiveFiles(filesOutput);
1038
+ const { files, sensitiveFiles, fernignoredFiles } = filterInfrastructureAndSensitiveFiles(
1039
+ filesOutput,
1040
+ this.fernignorePatterns
1041
+ );
1006
1042
  if (sensitiveFiles.length > 0) {
1007
1043
  this.warnings.push(
1008
1044
  `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
1009
1045
  );
1010
- if (files.length > 0) {
1011
- const parentSha = parents[0];
1012
- if (parentSha) {
1013
- try {
1014
- patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
1015
- } catch {
1016
- continue;
1017
- }
1018
- if (!patchContent.trim()) continue;
1019
- contentHash = this.computeContentHash(patchContent);
1046
+ }
1047
+ if (fernignoredFiles.length > 0) {
1048
+ this.warnings.push(
1049
+ `.fernignore-protected file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${fernignoredFiles.join(", ")}. These files are managed by .fernignore, not Replay.`
1050
+ );
1051
+ }
1052
+ if ((sensitiveFiles.length > 0 || fernignoredFiles.length > 0) && files.length > 0) {
1053
+ const parentSha = parents[0];
1054
+ if (parentSha) {
1055
+ try {
1056
+ patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
1057
+ } catch {
1058
+ continue;
1020
1059
  }
1060
+ if (!patchContent.trim()) continue;
1061
+ contentHash = this.computeContentHash(patchContent);
1021
1062
  }
1022
1063
  }
1023
1064
  if (files.length === 0) {
@@ -1216,12 +1257,20 @@ var ReplayDetector = class {
1216
1257
  resolvedBaseGeneration = fallback;
1217
1258
  }
1218
1259
  const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
1219
- const { files, sensitiveFiles } = filterInfrastructureAndSensitiveFiles(filesOutput);
1260
+ const { files, sensitiveFiles, fernignoredFiles } = filterInfrastructureAndSensitiveFiles(
1261
+ filesOutput,
1262
+ this.fernignorePatterns
1263
+ );
1220
1264
  if (sensitiveFiles.length > 0) {
1221
1265
  this.warnings.push(
1222
1266
  `Sensitive file(s) excluded from composite patch: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
1223
1267
  );
1224
1268
  }
1269
+ if (fernignoredFiles.length > 0) {
1270
+ this.warnings.push(
1271
+ `.fernignore-protected file(s) excluded from composite patch: ${fernignoredFiles.join(", ")}. These files are managed by .fernignore, not Replay.`
1272
+ );
1273
+ }
1225
1274
  if (files.length === 0) return { patches: [], revertedPatchIds: [] };
1226
1275
  const diff = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
1227
1276
  if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
@@ -1304,23 +1353,31 @@ var ReplayDetector = class {
1304
1353
  continue;
1305
1354
  }
1306
1355
  const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
1307
- const { files, sensitiveFiles } = filterInfrastructureAndSensitiveFiles(filesOutput);
1356
+ const { files, sensitiveFiles, fernignoredFiles } = filterInfrastructureAndSensitiveFiles(
1357
+ filesOutput,
1358
+ this.fernignorePatterns
1359
+ );
1308
1360
  if (sensitiveFiles.length > 0) {
1309
1361
  this.warnings.push(
1310
1362
  `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
1311
1363
  );
1312
- if (files.length > 0) {
1313
- if (parents.length === 0) {
1314
- continue;
1315
- }
1316
- try {
1317
- patchContent = await this.git.exec(["diff", parents[0], commit.sha, "--", ...files]);
1318
- } catch {
1319
- continue;
1320
- }
1321
- if (!patchContent.trim()) continue;
1322
- contentHash = this.computeContentHash(patchContent);
1364
+ }
1365
+ if (fernignoredFiles.length > 0) {
1366
+ this.warnings.push(
1367
+ `.fernignore-protected file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${fernignoredFiles.join(", ")}. These files are managed by .fernignore, not Replay.`
1368
+ );
1369
+ }
1370
+ if ((sensitiveFiles.length > 0 || fernignoredFiles.length > 0) && files.length > 0) {
1371
+ if (parents.length === 0) {
1372
+ continue;
1373
+ }
1374
+ try {
1375
+ patchContent = await this.git.exec(["diff", parents[0], commit.sha, "--", ...files]);
1376
+ } catch {
1377
+ continue;
1323
1378
  }
1379
+ if (!patchContent.trim()) continue;
1380
+ contentHash = this.computeContentHash(patchContent);
1324
1381
  }
1325
1382
  if (files.length === 0) {
1326
1383
  continue;
@@ -1556,7 +1613,7 @@ function applyBothPatches(baseLines, oursPatches, theirsPatches) {
1556
1613
  var import_promises3 = require("fs/promises");
1557
1614
  var import_node_os = require("os");
1558
1615
  var import_node_path5 = require("path");
1559
- var import_minimatch = require("minimatch");
1616
+ var import_minimatch3 = require("minimatch");
1560
1617
 
1561
1618
  // src/accumulator/MergeAccumulator.ts
1562
1619
  var MergeAccumulator = class {
@@ -2272,13 +2329,13 @@ var ReplayApplicator = class {
2272
2329
  isExcluded(patch) {
2273
2330
  const config = this.lockManager.getCustomizationsConfig();
2274
2331
  if (!config.exclude) return false;
2275
- return patch.files.some((file) => config.exclude.some((pattern) => (0, import_minimatch.minimatch)(file, pattern)));
2332
+ return patch.files.some((file) => config.exclude.some((pattern) => (0, import_minimatch3.minimatch)(file, pattern)));
2276
2333
  }
2277
2334
  async resolveFilePath(filePath, baseTreeHash, currentTreeHash) {
2278
2335
  const config = this.lockManager.getCustomizationsConfig();
2279
2336
  if (config.moves) {
2280
2337
  for (const move of config.moves) {
2281
- if ((0, import_minimatch.minimatch)(filePath, move.from) || filePath === move.from) {
2338
+ if ((0, import_minimatch3.minimatch)(filePath, move.from) || filePath === move.from) {
2282
2339
  if (filePath === move.from) {
2283
2340
  return move.to;
2284
2341
  }
@@ -2569,7 +2626,7 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
2569
2626
  var import_node_fs2 = require("fs");
2570
2627
  var import_promises6 = require("fs/promises");
2571
2628
  var import_node_path8 = require("path");
2572
- var import_minimatch3 = require("minimatch");
2629
+ var import_minimatch5 = require("minimatch");
2573
2630
  init_GitClient();
2574
2631
 
2575
2632
  // src/PatchRegionDiff.ts
@@ -2680,12 +2737,23 @@ async function computePerPatchDiff(patch, getCurrentGenContent, getWorkingTreeCo
2680
2737
  unlocatableFiles.push(file);
2681
2738
  continue;
2682
2739
  }
2683
- const locInCurrentGen = locInCurrentGenRaw.map(
2684
- (l) => resolveSpan(l, currentGenLines, "old")
2685
- );
2686
- const locInWorkingTree = locInWorkingTreeRaw.map(
2687
- (l) => resolveSpan(l, workingTreeLines, "new")
2688
- );
2740
+ const locInCurrentGen = [];
2741
+ const locInWorkingTree = [];
2742
+ let spanResolutionFailed = false;
2743
+ for (let i = 0; i < locInCurrentGenRaw.length; i++) {
2744
+ const cg = resolveSpan(locInCurrentGenRaw[i], currentGenLines, "old");
2745
+ const wt = resolveSpan(locInWorkingTreeRaw[i], workingTreeLines, "new");
2746
+ if (cg === null || wt === null) {
2747
+ spanResolutionFailed = true;
2748
+ break;
2749
+ }
2750
+ locInCurrentGen.push(cg);
2751
+ locInWorkingTree.push(wt);
2752
+ }
2753
+ if (spanResolutionFailed) {
2754
+ unlocatableFiles.push(file);
2755
+ continue;
2756
+ }
2689
2757
  const overlay = overlayRegions(
2690
2758
  currentGenLines,
2691
2759
  locInCurrentGen,
@@ -2747,11 +2815,11 @@ function resolveSpan(loc, oursLines, prefer) {
2747
2815
  if (prefer === "old") {
2748
2816
  if (oldFits) return { ...loc, oursSpan: hunk.oldCount };
2749
2817
  if (newFits) return { ...loc, oursSpan: hunk.newCount };
2750
- } else {
2751
- if (newFits) return { ...loc, oursSpan: hunk.newCount };
2752
- if (oldFits) return { ...loc, oursSpan: hunk.oldCount };
2818
+ return loc;
2753
2819
  }
2754
- return loc;
2820
+ if (newFits) return { ...loc, oursSpan: hunk.newCount };
2821
+ if (oldFits) return { ...loc, oursSpan: hunk.oldCount };
2822
+ return null;
2755
2823
  }
2756
2824
  function sequenceMatches(needle, haystack, offset) {
2757
2825
  if (offset + needle.length > haystack.length) return false;
@@ -2865,7 +2933,7 @@ function synthesizeDeleteFileDiff(file, content) {
2865
2933
  }
2866
2934
 
2867
2935
  // src/classifier/FileOwnership.ts
2868
- var import_minimatch2 = require("minimatch");
2936
+ var import_minimatch4 = require("minimatch");
2869
2937
  var FileOwnership = class {
2870
2938
  constructor(git) {
2871
2939
  this.git = git;
@@ -2889,7 +2957,7 @@ var FileOwnership = class {
2889
2957
  const { file, originalFileName, patch, currentGenSha, fernignorePatterns, warnedGens, warnings } = ctx;
2890
2958
  if (patch.user_owned) return true;
2891
2959
  if (file === ".fernignore") return true;
2892
- if (fernignorePatterns.some((p) => file === p || (0, import_minimatch2.minimatch)(file, p))) return true;
2960
+ if (fernignorePatterns.some((p) => file === p || (0, import_minimatch4.minimatch)(file, p))) return true;
2893
2961
  if (fileIsCreationInPatchContent(patch.patch_content, originalFileName ?? file)) {
2894
2962
  return true;
2895
2963
  }
@@ -2968,6 +3036,7 @@ var FirstGenerationFlow = class {
2968
3036
  };
2969
3037
  }
2970
3038
  async apply(_prep) {
3039
+ this.service.lockManager.setFernignorePatterns(this.service.readFernignorePatterns());
2971
3040
  this.service.lockManager.save();
2972
3041
  return firstGenerationReport();
2973
3042
  }
@@ -3026,6 +3095,7 @@ var SkipApplicationFlow = class {
3026
3095
  };
3027
3096
  }
3028
3097
  async apply(_prep, options) {
3098
+ this.service.lockManager.setFernignorePatterns(this.service.readFernignorePatterns());
3029
3099
  this.service.lockManager.save();
3030
3100
  const credentialWarnings = [...this.service.lockManager.warnings];
3031
3101
  if (!options?.stageOnly) {
@@ -3149,6 +3219,7 @@ var NoPatchesFlow = class {
3149
3219
  this.service.lockManager.addPatch(patch);
3150
3220
  }
3151
3221
  }
3222
+ this.service.lockManager.setFernignorePatterns(this.service.readFernignorePatterns());
3152
3223
  this.service.lockManager.save();
3153
3224
  this.service.warnings.push(...this.service.lockManager.warnings);
3154
3225
  if (newPatches.length > 0) {
@@ -3311,6 +3382,7 @@ var NormalRegenerationFlow = class {
3311
3382
  }
3312
3383
  }
3313
3384
  }
3385
+ this.service.lockManager.setFernignorePatterns(this.service.readFernignorePatterns());
3314
3386
  this.service.lockManager.save();
3315
3387
  this.service.warnings.push(...this.service.lockManager.warnings);
3316
3388
  if (options?.stageOnly) {
@@ -3391,7 +3463,8 @@ var ReplayService = class {
3391
3463
  this.git = git;
3392
3464
  this.outputDir = outputDir;
3393
3465
  this.lockManager = new LockfileManager(outputDir);
3394
- this.detector = new ReplayDetector(git, this.lockManager, outputDir);
3466
+ const fernignorePatterns = this.readFernignorePatterns();
3467
+ this.detector = new ReplayDetector(git, this.lockManager, outputDir, fernignorePatterns);
3395
3468
  this.applicator = new ReplayApplicator(git, this.lockManager, outputDir);
3396
3469
  this.committer = new ReplayCommitter(git, outputDir);
3397
3470
  this.fileOwnership = new FileOwnership(git);
@@ -3411,8 +3484,60 @@ var ReplayService = class {
3411
3484
  async prepareReplay(options) {
3412
3485
  this.warnings = [];
3413
3486
  this.detector.warnings.length = 0;
3487
+ this.cleanseLegacyFernignoreEntries();
3414
3488
  return this.selectFlow(options).prepare(options);
3415
3489
  }
3490
+ /**
3491
+ * ADR 0002 migration step. Strips `.fernignore`-matched entries from
3492
+ * `patch.files`, `patch.theirs_snapshot` keys, and `diff --git` sections
3493
+ * in `patch.patch_content` for every patch in the lockfile. Patches that
3494
+ * end up with no surviving files are removed entirely.
3495
+ *
3496
+ * Idempotent: a second run is a no-op because the first run already
3497
+ * matches the contract. Per-patch try/catch ensures one bad patch does
3498
+ * not block the rest. Each affected patch emits a warning naming the
3499
+ * stripped paths. `patch_content` is left untouched if no parseable
3500
+ * `diff --git` headers are found (fail-safe for exotic legacy formats).
3501
+ */
3502
+ cleanseLegacyFernignoreEntries() {
3503
+ const patterns = this.readFernignorePatterns();
3504
+ if (patterns.length === 0) return;
3505
+ if (!this.lockManager.exists()) return;
3506
+ this.lockManager.read();
3507
+ let lockfileMutated = false;
3508
+ const patches = this.lockManager.getPatches();
3509
+ for (const patch of patches) {
3510
+ try {
3511
+ const result = computeLegacyFernignoreCleanse(
3512
+ patch,
3513
+ patterns,
3514
+ (content) => this.detector.computeContentHash(content)
3515
+ );
3516
+ if (result.unchanged) continue;
3517
+ if (result.remove) {
3518
+ this.lockManager.removePatch(patch.id);
3519
+ this.warnings.push(
3520
+ `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.)`
3521
+ );
3522
+ } else {
3523
+ this.lockManager.updatePatch(patch.id, {
3524
+ files: result.files,
3525
+ patch_content: result.patch_content,
3526
+ content_hash: result.content_hash,
3527
+ ...result.theirs_snapshot !== void 0 ? { theirs_snapshot: result.theirs_snapshot } : {}
3528
+ });
3529
+ this.warnings.push(
3530
+ `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.)`
3531
+ );
3532
+ }
3533
+ lockfileMutated = true;
3534
+ } catch {
3535
+ }
3536
+ }
3537
+ if (lockfileMutated) {
3538
+ this.lockManager.save();
3539
+ }
3540
+ }
3416
3541
  /**
3417
3542
  * Phase 2 of the two-phase replay flow. Applies patches, rebases them against
3418
3543
  * the generation, saves the lockfile, and commits `[fern-replay]` (or stages
@@ -3544,7 +3669,7 @@ var ReplayService = class {
3544
3669
  )
3545
3670
  );
3546
3671
  const hasProtectedFiles = patch.files.some(
3547
- (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || (0, import_minimatch3.minimatch)(file, p))
3672
+ (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || (0, import_minimatch5.minimatch)(file, p))
3548
3673
  );
3549
3674
  const allUserOwned = isUserOwnedPerFile.every(Boolean);
3550
3675
  if (allUserOwned && patch.files.length > 0) {
@@ -3984,7 +4109,7 @@ var ReplayService = class {
3984
4109
  );
3985
4110
  if (snapHasStaleMarkers) continue;
3986
4111
  const isProtectedSnap = patch.files.some(
3987
- (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || (0, import_minimatch3.minimatch)(file, p))
4112
+ (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || (0, import_minimatch5.minimatch)(file, p))
3988
4113
  );
3989
4114
  if (isProtectedSnap) continue;
3990
4115
  const snapHash = this.detector.computeContentHash(snapshotDiff);
@@ -4004,6 +4129,11 @@ var ReplayService = class {
4004
4129
  (files) => this.git.exec(["diff", currentGen, "HEAD", "--", ...files]).catch(() => null)
4005
4130
  );
4006
4131
  if (ppResult === null) continue;
4132
+ if (ppResult.hadFallback) {
4133
+ this.warnings.push(
4134
+ `Patch ${patch.id}: couldn't fully isolate ${ppResult.fallbackFiles.join(", ")} \u2014 customization preserved, attribution may have merged with another patch.`
4135
+ );
4136
+ }
4007
4137
  const diff = ppResult.diff;
4008
4138
  if (!diff.trim()) {
4009
4139
  if (await allPatchFilesUserOwned(patch)) continue;
@@ -4019,7 +4149,7 @@ var ReplayService = class {
4019
4149
  continue;
4020
4150
  }
4021
4151
  const isProtected = patch.files.some(
4022
- (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || (0, import_minimatch3.minimatch)(file, p))
4152
+ (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || (0, import_minimatch5.minimatch)(file, p))
4023
4153
  );
4024
4154
  if (isProtected) {
4025
4155
  continue;
@@ -4091,6 +4221,11 @@ var ReplayService = class {
4091
4221
  (files) => this.git.exec(["diff", currentGen, "HEAD", "--", ...files]).catch(() => null)
4092
4222
  );
4093
4223
  if (ppResult === null) continue;
4224
+ if (ppResult.hadFallback) {
4225
+ this.warnings.push(
4226
+ `Patch ${patch.id}: couldn't fully isolate ${ppResult.fallbackFiles.join(", ")} \u2014 customization preserved, attribution may have merged with another patch.`
4227
+ );
4228
+ }
4094
4229
  const diff = ppResult.diff;
4095
4230
  if (!diff.trim()) {
4096
4231
  if (await allPatchFilesUserOwned(patch)) {
@@ -4178,13 +4313,14 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
4178
4313
  if (files.length === 0) return;
4179
4314
  const fernignorePatterns = this.readFernignorePatterns();
4180
4315
  for (const file of files) {
4181
- if (fernignorePatterns.some((pattern) => (0, import_minimatch3.minimatch)(file, pattern))) continue;
4316
+ if (fernignorePatterns.some((pattern) => (0, import_minimatch5.minimatch)(file, pattern))) continue;
4182
4317
  try {
4183
4318
  await this.git.exec(["checkout", "HEAD", "--", file]);
4184
4319
  } catch {
4185
4320
  }
4186
4321
  }
4187
4322
  }
4323
+ /** @internal Used by Flow implementations in `src/flows/`. */
4188
4324
  readFernignorePatterns() {
4189
4325
  const fernignorePath = (0, import_node_path8.join)(this.outputDir, ".fernignore");
4190
4326
  if (!(0, import_node_fs2.existsSync)(fernignorePath)) return [];
@@ -4262,12 +4398,99 @@ function computeCommitBuckets(results, absorbedPatchIds, patchesPassedToCommit)
4262
4398
  }
4263
4399
  return { applied, unresolved, absorbed };
4264
4400
  }
4401
+ function computeLegacyFernignoreCleanse(patch, fernignorePatterns, computeContentHash2) {
4402
+ const matchesFernignore = (filePath) => fernignorePatterns.some((p) => {
4403
+ if (filePath === p) return true;
4404
+ if ((0, import_minimatch5.minimatch)(filePath, p)) return true;
4405
+ if (!p.includes("*") && !p.includes("?") && filePath.startsWith(p + "/")) return true;
4406
+ return false;
4407
+ });
4408
+ const strippedFromFiles = patch.files.filter(matchesFernignore);
4409
+ const survivingFiles = patch.files.filter((f) => !matchesFernignore(f));
4410
+ let survivingSnapshot = void 0;
4411
+ let snapshotChanged = false;
4412
+ if (patch.theirs_snapshot) {
4413
+ survivingSnapshot = {};
4414
+ for (const [key, value] of Object.entries(patch.theirs_snapshot)) {
4415
+ if (matchesFernignore(key)) {
4416
+ snapshotChanged = true;
4417
+ } else {
4418
+ survivingSnapshot[key] = value;
4419
+ }
4420
+ }
4421
+ }
4422
+ const { stripped: patchContentStripped, content: newPatchContent, strippedSectionPaths } = stripFernignoreDiffSections(patch.patch_content, matchesFernignore);
4423
+ const unchanged = strippedFromFiles.length === 0 && !snapshotChanged && !patchContentStripped;
4424
+ if (unchanged) {
4425
+ return {
4426
+ unchanged: true,
4427
+ remove: false,
4428
+ files: patch.files,
4429
+ patch_content: patch.patch_content,
4430
+ content_hash: patch.content_hash,
4431
+ theirs_snapshot: patch.theirs_snapshot,
4432
+ strippedPaths: []
4433
+ };
4434
+ }
4435
+ const strippedPaths = Array.from(/* @__PURE__ */ new Set([...strippedFromFiles, ...strippedSectionPaths]));
4436
+ if (survivingFiles.length === 0 || newPatchContent.trim() === "") {
4437
+ return {
4438
+ unchanged: false,
4439
+ remove: true,
4440
+ files: [],
4441
+ patch_content: "",
4442
+ content_hash: "",
4443
+ strippedPaths
4444
+ };
4445
+ }
4446
+ const newContentHash = patchContentStripped ? computeContentHash2(newPatchContent) : patch.content_hash;
4447
+ return {
4448
+ unchanged: false,
4449
+ remove: false,
4450
+ files: survivingFiles,
4451
+ patch_content: newPatchContent,
4452
+ content_hash: newContentHash,
4453
+ ...survivingSnapshot !== void 0 ? { theirs_snapshot: survivingSnapshot } : {},
4454
+ strippedPaths
4455
+ };
4456
+ }
4457
+ function stripFernignoreDiffSections(patchContent, matchesFernignore) {
4458
+ const lines = patchContent.split("\n");
4459
+ const sectionStarts = [];
4460
+ for (let i = 0; i < lines.length; i++) {
4461
+ const line = lines[i];
4462
+ if (line.startsWith("diff --git ")) {
4463
+ const match = line.match(/^diff --git a\/(.+?) b\/(.+?)$/);
4464
+ const path = match ? match[2] : null;
4465
+ sectionStarts.push({ idx: i, path });
4466
+ }
4467
+ }
4468
+ if (sectionStarts.length === 0) {
4469
+ return { stripped: false, content: patchContent, strippedSectionPaths: [] };
4470
+ }
4471
+ const preamble = lines.slice(0, sectionStarts[0].idx);
4472
+ const newLines = [...preamble];
4473
+ const strippedSectionPaths = [];
4474
+ let stripped = false;
4475
+ for (let i = 0; i < sectionStarts.length; i++) {
4476
+ const section = sectionStarts[i];
4477
+ const next = sectionStarts[i + 1];
4478
+ const endIdx = next ? next.idx : lines.length;
4479
+ if (section.path !== null && matchesFernignore(section.path)) {
4480
+ stripped = true;
4481
+ strippedSectionPaths.push(section.path);
4482
+ continue;
4483
+ }
4484
+ newLines.push(...lines.slice(section.idx, endIdx));
4485
+ }
4486
+ return { stripped, content: newLines.join("\n"), strippedSectionPaths };
4487
+ }
4265
4488
 
4266
4489
  // src/FernignoreMigrator.ts
4267
4490
  var import_node_crypto2 = require("crypto");
4268
4491
  var import_node_fs3 = require("fs");
4269
4492
  var import_node_path9 = require("path");
4270
- var import_minimatch4 = require("minimatch");
4493
+ var import_minimatch6 = require("minimatch");
4271
4494
  var import_yaml2 = require("yaml");
4272
4495
  var FernignoreMigrator = class {
4273
4496
  git;
@@ -4299,7 +4522,7 @@ var FernignoreMigrator = class {
4299
4522
  const commitsOnly = [];
4300
4523
  const syntheticPatches = [];
4301
4524
  for (const pattern of patterns) {
4302
- const matchingPatch = patches.find((p) => p.files.some((f) => (0, import_minimatch4.minimatch)(f, pattern) || f === pattern));
4525
+ const matchingPatch = patches.find((p) => p.files.some((f) => (0, import_minimatch6.minimatch)(f, pattern) || f === pattern));
4303
4526
  if (matchingPatch) {
4304
4527
  trackedByBoth.push({
4305
4528
  file: pattern,
@@ -4372,7 +4595,7 @@ var FernignoreMigrator = class {
4372
4595
  fernignoreOnly.push(...remainingFernignoreOnly);
4373
4596
  }
4374
4597
  for (const patch of patches) {
4375
- const hasUnprotectedFiles = patch.files.some((f) => !patterns.some((p) => (0, import_minimatch4.minimatch)(f, p) || f === p));
4598
+ const hasUnprotectedFiles = patch.files.some((f) => !patterns.some((p) => (0, import_minimatch6.minimatch)(f, p) || f === p));
4376
4599
  if (hasUnprotectedFiles) {
4377
4600
  commitsOnly.push(patch);
4378
4601
  }
@@ -4384,7 +4607,7 @@ var FernignoreMigrator = class {
4384
4607
  const results = [];
4385
4608
  for (const pattern of patterns) {
4386
4609
  const matching = allFiles.filter(
4387
- (f) => (0, import_minimatch4.minimatch)(f, pattern) || f === pattern || f.startsWith(pattern + "/")
4610
+ (f) => (0, import_minimatch6.minimatch)(f, pattern) || f === pattern || f.startsWith(pattern + "/")
4388
4611
  );
4389
4612
  results.push({ pattern, files: matching.length > 0 ? matching : [pattern] });
4390
4613
  }
@@ -4697,7 +4920,7 @@ function computeContentHash(patchContent) {
4697
4920
  }
4698
4921
 
4699
4922
  // src/commands/forget.ts
4700
- var import_minimatch5 = require("minimatch");
4923
+ var import_minimatch7 = require("minimatch");
4701
4924
  function parseDiffStat(patchContent) {
4702
4925
  let additions = 0;
4703
4926
  let deletions = 0;
@@ -4727,7 +4950,7 @@ function toMatchedPatch(patch) {
4727
4950
  }
4728
4951
  function matchesPatch(patch, pattern) {
4729
4952
  const fileMatch = patch.files.some(
4730
- (file) => file === pattern || (0, import_minimatch5.minimatch)(file, pattern)
4953
+ (file) => file === pattern || (0, import_minimatch7.minimatch)(file, pattern)
4731
4954
  );
4732
4955
  if (fileMatch) return true;
4733
4956
  return patch.original_message.toLowerCase().includes(pattern.toLowerCase());