@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.js CHANGED
@@ -533,6 +533,7 @@ function parseRevertedMessage(subject) {
533
533
  // src/LockfileManager.ts
534
534
  import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from "fs";
535
535
  import { join, dirname } from "path";
536
+ import { minimatch } from "minimatch";
536
537
  import { stringify, parse } from "yaml";
537
538
  var LOCKFILE_HEADER = "# DO NOT EDIT MANUALLY - Managed by Fern Replay\n";
538
539
  var LockfileNotFoundError = class extends Error {
@@ -544,10 +545,23 @@ var LockfileNotFoundError = class extends Error {
544
545
  var LockfileManager = class {
545
546
  outputDir;
546
547
  lock = null;
548
+ fernignorePatterns = [];
547
549
  warnings = [];
548
550
  constructor(outputDir) {
549
551
  this.outputDir = outputDir;
550
552
  }
553
+ /**
554
+ * Inform the lockfile of the customer's `.fernignore` patterns so that
555
+ * `save()` can suppress its "Patch X removed from lockfile" warning when
556
+ * every file in the stripped patch is already `.fernignore`-protected.
557
+ * The strip itself still happens (credentials never persist); only the
558
+ * customer-visible warning is suppressed in that case, because the
559
+ * protected files survive the generator wipe regardless of whether the
560
+ * lockfile tracks them, so there's nothing for the customer to action.
561
+ */
562
+ setFernignorePatterns(patterns) {
563
+ this.fernignorePatterns = patterns;
564
+ }
551
565
  get lockfilePath() {
552
566
  return join(this.outputDir, ".fern", "replay.lock");
553
567
  }
@@ -616,9 +630,14 @@ var LockfileManager = class {
616
630
  if (sensitiveFiles.length > 0) {
617
631
  reasons.push(`sensitive files: ${sensitiveFiles.join(", ")}`);
618
632
  }
619
- this.warnings.push(
620
- `Patch ${patch.id} removed from lockfile: ${reasons.join("; ")}. This prevents credential exposure in committed lockfiles.`
633
+ const allProtected = patch.files.length > 0 && patch.files.every(
634
+ (f) => this.fernignorePatterns.some((p) => f === p || minimatch(f, p))
621
635
  );
636
+ if (!allProtected) {
637
+ this.warnings.push(
638
+ `Patch ${patch.id} removed from lockfile: ${reasons.join("; ")}. This prevents credential exposure in committed lockfiles.`
639
+ );
640
+ }
622
641
  } else {
623
642
  cleanPatches.push(patch);
624
643
  }
@@ -727,6 +746,7 @@ var LockfileManager = class {
727
746
 
728
747
  // src/ReplayDetector.ts
729
748
  import { createHash } from "crypto";
749
+ import { minimatch as minimatch2 } from "minimatch";
730
750
 
731
751
  // src/shared/binary.ts
732
752
  import { extname } from "path";
@@ -786,6 +806,15 @@ function isBinaryFile(filePath) {
786
806
 
787
807
  // src/ReplayDetector.ts
788
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
+ }
789
818
  function parsePatchFileHeaders(patchContent) {
790
819
  const results = [];
791
820
  let currentPath = null;
@@ -826,21 +855,25 @@ async function capturePatchSnapshot(git, files, treeish) {
826
855
  }
827
856
  return snapshot;
828
857
  }
829
- function filterInfrastructureAndSensitiveFiles(rawFilesOutput) {
858
+ function filterInfrastructureAndSensitiveFiles(rawFilesOutput, fernignorePatterns) {
830
859
  const allFiles = rawFilesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
831
860
  const sensitiveFiles = allFiles.filter((f) => isSensitiveFile(f));
832
- const files = allFiles.filter((f) => !isSensitiveFile(f));
833
- 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 };
834
865
  }
835
866
  var ReplayDetector = class {
836
867
  git;
837
868
  lockManager;
838
869
  sdkOutputDir;
870
+ fernignorePatterns;
839
871
  warnings = [];
840
- constructor(git, lockManager, sdkOutputDir) {
872
+ constructor(git, lockManager, sdkOutputDir, fernignorePatterns = []) {
841
873
  this.git = git;
842
874
  this.lockManager = lockManager;
843
875
  this.sdkOutputDir = sdkOutputDir;
876
+ this.fernignorePatterns = fernignorePatterns;
844
877
  }
845
878
  /**
846
879
  * Derive the previous-generation SHA from git history instead of trusting
@@ -947,22 +980,30 @@ var ReplayDetector = class {
947
980
  continue;
948
981
  }
949
982
  const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
950
- const { files, sensitiveFiles } = filterInfrastructureAndSensitiveFiles(filesOutput);
983
+ const { files, sensitiveFiles, fernignoredFiles } = filterInfrastructureAndSensitiveFiles(
984
+ filesOutput,
985
+ this.fernignorePatterns
986
+ );
951
987
  if (sensitiveFiles.length > 0) {
952
988
  this.warnings.push(
953
989
  `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
954
990
  );
955
- if (files.length > 0) {
956
- const parentSha = parents[0];
957
- if (parentSha) {
958
- try {
959
- patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
960
- } catch {
961
- continue;
962
- }
963
- if (!patchContent.trim()) continue;
964
- contentHash = this.computeContentHash(patchContent);
991
+ }
992
+ if (fernignoredFiles.length > 0) {
993
+ this.warnings.push(
994
+ `.fernignore-protected file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${fernignoredFiles.join(", ")}. These files are managed by .fernignore, not Replay.`
995
+ );
996
+ }
997
+ if ((sensitiveFiles.length > 0 || fernignoredFiles.length > 0) && files.length > 0) {
998
+ const parentSha = parents[0];
999
+ if (parentSha) {
1000
+ try {
1001
+ patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
1002
+ } catch {
1003
+ continue;
965
1004
  }
1005
+ if (!patchContent.trim()) continue;
1006
+ contentHash = this.computeContentHash(patchContent);
966
1007
  }
967
1008
  }
968
1009
  if (files.length === 0) {
@@ -1161,12 +1202,20 @@ var ReplayDetector = class {
1161
1202
  resolvedBaseGeneration = fallback;
1162
1203
  }
1163
1204
  const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
1164
- const { files, sensitiveFiles } = filterInfrastructureAndSensitiveFiles(filesOutput);
1205
+ const { files, sensitiveFiles, fernignoredFiles } = filterInfrastructureAndSensitiveFiles(
1206
+ filesOutput,
1207
+ this.fernignorePatterns
1208
+ );
1165
1209
  if (sensitiveFiles.length > 0) {
1166
1210
  this.warnings.push(
1167
1211
  `Sensitive file(s) excluded from composite patch: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
1168
1212
  );
1169
1213
  }
1214
+ if (fernignoredFiles.length > 0) {
1215
+ this.warnings.push(
1216
+ `.fernignore-protected file(s) excluded from composite patch: ${fernignoredFiles.join(", ")}. These files are managed by .fernignore, not Replay.`
1217
+ );
1218
+ }
1170
1219
  if (files.length === 0) return { patches: [], revertedPatchIds: [] };
1171
1220
  const diff = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
1172
1221
  if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
@@ -1249,23 +1298,31 @@ var ReplayDetector = class {
1249
1298
  continue;
1250
1299
  }
1251
1300
  const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
1252
- const { files, sensitiveFiles } = filterInfrastructureAndSensitiveFiles(filesOutput);
1301
+ const { files, sensitiveFiles, fernignoredFiles } = filterInfrastructureAndSensitiveFiles(
1302
+ filesOutput,
1303
+ this.fernignorePatterns
1304
+ );
1253
1305
  if (sensitiveFiles.length > 0) {
1254
1306
  this.warnings.push(
1255
1307
  `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
1256
1308
  );
1257
- if (files.length > 0) {
1258
- if (parents.length === 0) {
1259
- continue;
1260
- }
1261
- try {
1262
- patchContent = await this.git.exec(["diff", parents[0], commit.sha, "--", ...files]);
1263
- } catch {
1264
- continue;
1265
- }
1266
- if (!patchContent.trim()) continue;
1267
- contentHash = this.computeContentHash(patchContent);
1309
+ }
1310
+ if (fernignoredFiles.length > 0) {
1311
+ this.warnings.push(
1312
+ `.fernignore-protected file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${fernignoredFiles.join(", ")}. These files are managed by .fernignore, not Replay.`
1313
+ );
1314
+ }
1315
+ if ((sensitiveFiles.length > 0 || fernignoredFiles.length > 0) && files.length > 0) {
1316
+ if (parents.length === 0) {
1317
+ continue;
1318
+ }
1319
+ try {
1320
+ patchContent = await this.git.exec(["diff", parents[0], commit.sha, "--", ...files]);
1321
+ } catch {
1322
+ continue;
1268
1323
  }
1324
+ if (!patchContent.trim()) continue;
1325
+ contentHash = this.computeContentHash(patchContent);
1269
1326
  }
1270
1327
  if (files.length === 0) {
1271
1328
  continue;
@@ -1501,7 +1558,7 @@ function applyBothPatches(baseLines, oursPatches, theirsPatches) {
1501
1558
  import { mkdir as mkdir2, mkdtemp, readFile as readFile2, rm, unlink, writeFile as writeFile2 } from "fs/promises";
1502
1559
  import { tmpdir } from "os";
1503
1560
  import { dirname as dirname3, join as join3 } from "path";
1504
- import { minimatch } from "minimatch";
1561
+ import { minimatch as minimatch3 } from "minimatch";
1505
1562
 
1506
1563
  // src/accumulator/MergeAccumulator.ts
1507
1564
  var MergeAccumulator = class {
@@ -2217,13 +2274,13 @@ var ReplayApplicator = class {
2217
2274
  isExcluded(patch) {
2218
2275
  const config = this.lockManager.getCustomizationsConfig();
2219
2276
  if (!config.exclude) return false;
2220
- return patch.files.some((file) => config.exclude.some((pattern) => minimatch(file, pattern)));
2277
+ return patch.files.some((file) => config.exclude.some((pattern) => minimatch3(file, pattern)));
2221
2278
  }
2222
2279
  async resolveFilePath(filePath, baseTreeHash, currentTreeHash) {
2223
2280
  const config = this.lockManager.getCustomizationsConfig();
2224
2281
  if (config.moves) {
2225
2282
  for (const move of config.moves) {
2226
- if (minimatch(filePath, move.from) || filePath === move.from) {
2283
+ if (minimatch3(filePath, move.from) || filePath === move.from) {
2227
2284
  if (filePath === move.from) {
2228
2285
  return move.to;
2229
2286
  }
@@ -2514,7 +2571,7 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
2514
2571
  import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
2515
2572
  import { readFile as readFileAsync } from "fs/promises";
2516
2573
  import { join as join6 } from "path";
2517
- import { minimatch as minimatch3 } from "minimatch";
2574
+ import { minimatch as minimatch5 } from "minimatch";
2518
2575
  init_GitClient();
2519
2576
 
2520
2577
  // src/PatchRegionDiff.ts
@@ -2625,12 +2682,23 @@ async function computePerPatchDiff(patch, getCurrentGenContent, getWorkingTreeCo
2625
2682
  unlocatableFiles.push(file);
2626
2683
  continue;
2627
2684
  }
2628
- const locInCurrentGen = locInCurrentGenRaw.map(
2629
- (l) => resolveSpan(l, currentGenLines, "old")
2630
- );
2631
- const locInWorkingTree = locInWorkingTreeRaw.map(
2632
- (l) => resolveSpan(l, workingTreeLines, "new")
2633
- );
2685
+ const locInCurrentGen = [];
2686
+ const locInWorkingTree = [];
2687
+ let spanResolutionFailed = false;
2688
+ for (let i = 0; i < locInCurrentGenRaw.length; i++) {
2689
+ const cg = resolveSpan(locInCurrentGenRaw[i], currentGenLines, "old");
2690
+ const wt = resolveSpan(locInWorkingTreeRaw[i], workingTreeLines, "new");
2691
+ if (cg === null || wt === null) {
2692
+ spanResolutionFailed = true;
2693
+ break;
2694
+ }
2695
+ locInCurrentGen.push(cg);
2696
+ locInWorkingTree.push(wt);
2697
+ }
2698
+ if (spanResolutionFailed) {
2699
+ unlocatableFiles.push(file);
2700
+ continue;
2701
+ }
2634
2702
  const overlay = overlayRegions(
2635
2703
  currentGenLines,
2636
2704
  locInCurrentGen,
@@ -2692,11 +2760,11 @@ function resolveSpan(loc, oursLines, prefer) {
2692
2760
  if (prefer === "old") {
2693
2761
  if (oldFits) return { ...loc, oursSpan: hunk.oldCount };
2694
2762
  if (newFits) return { ...loc, oursSpan: hunk.newCount };
2695
- } else {
2696
- if (newFits) return { ...loc, oursSpan: hunk.newCount };
2697
- if (oldFits) return { ...loc, oursSpan: hunk.oldCount };
2763
+ return loc;
2698
2764
  }
2699
- return loc;
2765
+ if (newFits) return { ...loc, oursSpan: hunk.newCount };
2766
+ if (oldFits) return { ...loc, oursSpan: hunk.oldCount };
2767
+ return null;
2700
2768
  }
2701
2769
  function sequenceMatches(needle, haystack, offset) {
2702
2770
  if (offset + needle.length > haystack.length) return false;
@@ -2810,7 +2878,7 @@ function synthesizeDeleteFileDiff(file, content) {
2810
2878
  }
2811
2879
 
2812
2880
  // src/classifier/FileOwnership.ts
2813
- import { minimatch as minimatch2 } from "minimatch";
2881
+ import { minimatch as minimatch4 } from "minimatch";
2814
2882
  var FileOwnership = class {
2815
2883
  constructor(git) {
2816
2884
  this.git = git;
@@ -2834,7 +2902,7 @@ var FileOwnership = class {
2834
2902
  const { file, originalFileName, patch, currentGenSha, fernignorePatterns, warnedGens, warnings } = ctx;
2835
2903
  if (patch.user_owned) return true;
2836
2904
  if (file === ".fernignore") return true;
2837
- if (fernignorePatterns.some((p) => file === p || minimatch2(file, p))) return true;
2905
+ if (fernignorePatterns.some((p) => file === p || minimatch4(file, p))) return true;
2838
2906
  if (fileIsCreationInPatchContent(patch.patch_content, originalFileName ?? file)) {
2839
2907
  return true;
2840
2908
  }
@@ -2913,6 +2981,7 @@ var FirstGenerationFlow = class {
2913
2981
  };
2914
2982
  }
2915
2983
  async apply(_prep) {
2984
+ this.service.lockManager.setFernignorePatterns(this.service.readFernignorePatterns());
2916
2985
  this.service.lockManager.save();
2917
2986
  return firstGenerationReport();
2918
2987
  }
@@ -2971,6 +3040,7 @@ var SkipApplicationFlow = class {
2971
3040
  };
2972
3041
  }
2973
3042
  async apply(_prep, options) {
3043
+ this.service.lockManager.setFernignorePatterns(this.service.readFernignorePatterns());
2974
3044
  this.service.lockManager.save();
2975
3045
  const credentialWarnings = [...this.service.lockManager.warnings];
2976
3046
  if (!options?.stageOnly) {
@@ -3094,6 +3164,7 @@ var NoPatchesFlow = class {
3094
3164
  this.service.lockManager.addPatch(patch);
3095
3165
  }
3096
3166
  }
3167
+ this.service.lockManager.setFernignorePatterns(this.service.readFernignorePatterns());
3097
3168
  this.service.lockManager.save();
3098
3169
  this.service.warnings.push(...this.service.lockManager.warnings);
3099
3170
  if (newPatches.length > 0) {
@@ -3256,6 +3327,7 @@ var NormalRegenerationFlow = class {
3256
3327
  }
3257
3328
  }
3258
3329
  }
3330
+ this.service.lockManager.setFernignorePatterns(this.service.readFernignorePatterns());
3259
3331
  this.service.lockManager.save();
3260
3332
  this.service.warnings.push(...this.service.lockManager.warnings);
3261
3333
  if (options?.stageOnly) {
@@ -3336,7 +3408,8 @@ var ReplayService = class {
3336
3408
  this.git = git;
3337
3409
  this.outputDir = outputDir;
3338
3410
  this.lockManager = new LockfileManager(outputDir);
3339
- this.detector = new ReplayDetector(git, this.lockManager, outputDir);
3411
+ const fernignorePatterns = this.readFernignorePatterns();
3412
+ this.detector = new ReplayDetector(git, this.lockManager, outputDir, fernignorePatterns);
3340
3413
  this.applicator = new ReplayApplicator(git, this.lockManager, outputDir);
3341
3414
  this.committer = new ReplayCommitter(git, outputDir);
3342
3415
  this.fileOwnership = new FileOwnership(git);
@@ -3356,8 +3429,60 @@ var ReplayService = class {
3356
3429
  async prepareReplay(options) {
3357
3430
  this.warnings = [];
3358
3431
  this.detector.warnings.length = 0;
3432
+ this.cleanseLegacyFernignoreEntries();
3359
3433
  return this.selectFlow(options).prepare(options);
3360
3434
  }
3435
+ /**
3436
+ * ADR 0002 migration step. Strips `.fernignore`-matched entries from
3437
+ * `patch.files`, `patch.theirs_snapshot` keys, and `diff --git` sections
3438
+ * in `patch.patch_content` for every patch in the lockfile. Patches that
3439
+ * end up with no surviving files are removed entirely.
3440
+ *
3441
+ * Idempotent: a second run is a no-op because the first run already
3442
+ * matches the contract. Per-patch try/catch ensures one bad patch does
3443
+ * not block the rest. Each affected patch emits a warning naming the
3444
+ * stripped paths. `patch_content` is left untouched if no parseable
3445
+ * `diff --git` headers are found (fail-safe for exotic legacy formats).
3446
+ */
3447
+ cleanseLegacyFernignoreEntries() {
3448
+ const patterns = this.readFernignorePatterns();
3449
+ if (patterns.length === 0) return;
3450
+ if (!this.lockManager.exists()) return;
3451
+ this.lockManager.read();
3452
+ let lockfileMutated = false;
3453
+ const patches = this.lockManager.getPatches();
3454
+ for (const patch of patches) {
3455
+ try {
3456
+ const result = computeLegacyFernignoreCleanse(
3457
+ patch,
3458
+ patterns,
3459
+ (content) => this.detector.computeContentHash(content)
3460
+ );
3461
+ if (result.unchanged) continue;
3462
+ if (result.remove) {
3463
+ this.lockManager.removePatch(patch.id);
3464
+ this.warnings.push(
3465
+ `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.)`
3466
+ );
3467
+ } else {
3468
+ this.lockManager.updatePatch(patch.id, {
3469
+ files: result.files,
3470
+ patch_content: result.patch_content,
3471
+ content_hash: result.content_hash,
3472
+ ...result.theirs_snapshot !== void 0 ? { theirs_snapshot: result.theirs_snapshot } : {}
3473
+ });
3474
+ this.warnings.push(
3475
+ `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.)`
3476
+ );
3477
+ }
3478
+ lockfileMutated = true;
3479
+ } catch {
3480
+ }
3481
+ }
3482
+ if (lockfileMutated) {
3483
+ this.lockManager.save();
3484
+ }
3485
+ }
3361
3486
  /**
3362
3487
  * Phase 2 of the two-phase replay flow. Applies patches, rebases them against
3363
3488
  * the generation, saves the lockfile, and commits `[fern-replay]` (or stages
@@ -3489,7 +3614,7 @@ var ReplayService = class {
3489
3614
  )
3490
3615
  );
3491
3616
  const hasProtectedFiles = patch.files.some(
3492
- (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch3(file, p))
3617
+ (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch5(file, p))
3493
3618
  );
3494
3619
  const allUserOwned = isUserOwnedPerFile.every(Boolean);
3495
3620
  if (allUserOwned && patch.files.length > 0) {
@@ -3929,7 +4054,7 @@ var ReplayService = class {
3929
4054
  );
3930
4055
  if (snapHasStaleMarkers) continue;
3931
4056
  const isProtectedSnap = patch.files.some(
3932
- (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch3(file, p))
4057
+ (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch5(file, p))
3933
4058
  );
3934
4059
  if (isProtectedSnap) continue;
3935
4060
  const snapHash = this.detector.computeContentHash(snapshotDiff);
@@ -3949,6 +4074,11 @@ var ReplayService = class {
3949
4074
  (files) => this.git.exec(["diff", currentGen, "HEAD", "--", ...files]).catch(() => null)
3950
4075
  );
3951
4076
  if (ppResult === null) continue;
4077
+ if (ppResult.hadFallback) {
4078
+ this.warnings.push(
4079
+ `Patch ${patch.id}: couldn't fully isolate ${ppResult.fallbackFiles.join(", ")} \u2014 customization preserved, attribution may have merged with another patch.`
4080
+ );
4081
+ }
3952
4082
  const diff = ppResult.diff;
3953
4083
  if (!diff.trim()) {
3954
4084
  if (await allPatchFilesUserOwned(patch)) continue;
@@ -3964,7 +4094,7 @@ var ReplayService = class {
3964
4094
  continue;
3965
4095
  }
3966
4096
  const isProtected = patch.files.some(
3967
- (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch3(file, p))
4097
+ (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch5(file, p))
3968
4098
  );
3969
4099
  if (isProtected) {
3970
4100
  continue;
@@ -4036,6 +4166,11 @@ var ReplayService = class {
4036
4166
  (files) => this.git.exec(["diff", currentGen, "HEAD", "--", ...files]).catch(() => null)
4037
4167
  );
4038
4168
  if (ppResult === null) continue;
4169
+ if (ppResult.hadFallback) {
4170
+ this.warnings.push(
4171
+ `Patch ${patch.id}: couldn't fully isolate ${ppResult.fallbackFiles.join(", ")} \u2014 customization preserved, attribution may have merged with another patch.`
4172
+ );
4173
+ }
4039
4174
  const diff = ppResult.diff;
4040
4175
  if (!diff.trim()) {
4041
4176
  if (await allPatchFilesUserOwned(patch)) {
@@ -4123,13 +4258,14 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
4123
4258
  if (files.length === 0) return;
4124
4259
  const fernignorePatterns = this.readFernignorePatterns();
4125
4260
  for (const file of files) {
4126
- if (fernignorePatterns.some((pattern) => minimatch3(file, pattern))) continue;
4261
+ if (fernignorePatterns.some((pattern) => minimatch5(file, pattern))) continue;
4127
4262
  try {
4128
4263
  await this.git.exec(["checkout", "HEAD", "--", file]);
4129
4264
  } catch {
4130
4265
  }
4131
4266
  }
4132
4267
  }
4268
+ /** @internal Used by Flow implementations in `src/flows/`. */
4133
4269
  readFernignorePatterns() {
4134
4270
  const fernignorePath = join6(this.outputDir, ".fernignore");
4135
4271
  if (!existsSync2(fernignorePath)) return [];
@@ -4207,12 +4343,99 @@ function computeCommitBuckets(results, absorbedPatchIds, patchesPassedToCommit)
4207
4343
  }
4208
4344
  return { applied, unresolved, absorbed };
4209
4345
  }
4346
+ function computeLegacyFernignoreCleanse(patch, fernignorePatterns, computeContentHash2) {
4347
+ const matchesFernignore = (filePath) => fernignorePatterns.some((p) => {
4348
+ if (filePath === p) return true;
4349
+ if (minimatch5(filePath, p)) return true;
4350
+ if (!p.includes("*") && !p.includes("?") && filePath.startsWith(p + "/")) return true;
4351
+ return false;
4352
+ });
4353
+ const strippedFromFiles = patch.files.filter(matchesFernignore);
4354
+ const survivingFiles = patch.files.filter((f) => !matchesFernignore(f));
4355
+ let survivingSnapshot = void 0;
4356
+ let snapshotChanged = false;
4357
+ if (patch.theirs_snapshot) {
4358
+ survivingSnapshot = {};
4359
+ for (const [key, value] of Object.entries(patch.theirs_snapshot)) {
4360
+ if (matchesFernignore(key)) {
4361
+ snapshotChanged = true;
4362
+ } else {
4363
+ survivingSnapshot[key] = value;
4364
+ }
4365
+ }
4366
+ }
4367
+ const { stripped: patchContentStripped, content: newPatchContent, strippedSectionPaths } = stripFernignoreDiffSections(patch.patch_content, matchesFernignore);
4368
+ const unchanged = strippedFromFiles.length === 0 && !snapshotChanged && !patchContentStripped;
4369
+ if (unchanged) {
4370
+ return {
4371
+ unchanged: true,
4372
+ remove: false,
4373
+ files: patch.files,
4374
+ patch_content: patch.patch_content,
4375
+ content_hash: patch.content_hash,
4376
+ theirs_snapshot: patch.theirs_snapshot,
4377
+ strippedPaths: []
4378
+ };
4379
+ }
4380
+ const strippedPaths = Array.from(/* @__PURE__ */ new Set([...strippedFromFiles, ...strippedSectionPaths]));
4381
+ if (survivingFiles.length === 0 || newPatchContent.trim() === "") {
4382
+ return {
4383
+ unchanged: false,
4384
+ remove: true,
4385
+ files: [],
4386
+ patch_content: "",
4387
+ content_hash: "",
4388
+ strippedPaths
4389
+ };
4390
+ }
4391
+ const newContentHash = patchContentStripped ? computeContentHash2(newPatchContent) : patch.content_hash;
4392
+ return {
4393
+ unchanged: false,
4394
+ remove: false,
4395
+ files: survivingFiles,
4396
+ patch_content: newPatchContent,
4397
+ content_hash: newContentHash,
4398
+ ...survivingSnapshot !== void 0 ? { theirs_snapshot: survivingSnapshot } : {},
4399
+ strippedPaths
4400
+ };
4401
+ }
4402
+ function stripFernignoreDiffSections(patchContent, matchesFernignore) {
4403
+ const lines = patchContent.split("\n");
4404
+ const sectionStarts = [];
4405
+ for (let i = 0; i < lines.length; i++) {
4406
+ const line = lines[i];
4407
+ if (line.startsWith("diff --git ")) {
4408
+ const match = line.match(/^diff --git a\/(.+?) b\/(.+?)$/);
4409
+ const path = match ? match[2] : null;
4410
+ sectionStarts.push({ idx: i, path });
4411
+ }
4412
+ }
4413
+ if (sectionStarts.length === 0) {
4414
+ return { stripped: false, content: patchContent, strippedSectionPaths: [] };
4415
+ }
4416
+ const preamble = lines.slice(0, sectionStarts[0].idx);
4417
+ const newLines = [...preamble];
4418
+ const strippedSectionPaths = [];
4419
+ let stripped = false;
4420
+ for (let i = 0; i < sectionStarts.length; i++) {
4421
+ const section = sectionStarts[i];
4422
+ const next = sectionStarts[i + 1];
4423
+ const endIdx = next ? next.idx : lines.length;
4424
+ if (section.path !== null && matchesFernignore(section.path)) {
4425
+ stripped = true;
4426
+ strippedSectionPaths.push(section.path);
4427
+ continue;
4428
+ }
4429
+ newLines.push(...lines.slice(section.idx, endIdx));
4430
+ }
4431
+ return { stripped, content: newLines.join("\n"), strippedSectionPaths };
4432
+ }
4210
4433
 
4211
4434
  // src/FernignoreMigrator.ts
4212
4435
  import { createHash as createHash2 } from "crypto";
4213
4436
  import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, statSync, writeFileSync as writeFileSync3 } from "fs";
4214
4437
  import { dirname as dirname5, join as join7 } from "path";
4215
- import { minimatch as minimatch4 } from "minimatch";
4438
+ import { minimatch as minimatch6 } from "minimatch";
4216
4439
  import { parse as parse2, stringify as stringify2 } from "yaml";
4217
4440
  var FernignoreMigrator = class {
4218
4441
  git;
@@ -4244,7 +4467,7 @@ var FernignoreMigrator = class {
4244
4467
  const commitsOnly = [];
4245
4468
  const syntheticPatches = [];
4246
4469
  for (const pattern of patterns) {
4247
- const matchingPatch = patches.find((p) => p.files.some((f) => minimatch4(f, pattern) || f === pattern));
4470
+ const matchingPatch = patches.find((p) => p.files.some((f) => minimatch6(f, pattern) || f === pattern));
4248
4471
  if (matchingPatch) {
4249
4472
  trackedByBoth.push({
4250
4473
  file: pattern,
@@ -4317,7 +4540,7 @@ var FernignoreMigrator = class {
4317
4540
  fernignoreOnly.push(...remainingFernignoreOnly);
4318
4541
  }
4319
4542
  for (const patch of patches) {
4320
- const hasUnprotectedFiles = patch.files.some((f) => !patterns.some((p) => minimatch4(f, p) || f === p));
4543
+ const hasUnprotectedFiles = patch.files.some((f) => !patterns.some((p) => minimatch6(f, p) || f === p));
4321
4544
  if (hasUnprotectedFiles) {
4322
4545
  commitsOnly.push(patch);
4323
4546
  }
@@ -4329,7 +4552,7 @@ var FernignoreMigrator = class {
4329
4552
  const results = [];
4330
4553
  for (const pattern of patterns) {
4331
4554
  const matching = allFiles.filter(
4332
- (f) => minimatch4(f, pattern) || f === pattern || f.startsWith(pattern + "/")
4555
+ (f) => minimatch6(f, pattern) || f === pattern || f.startsWith(pattern + "/")
4333
4556
  );
4334
4557
  results.push({ pattern, files: matching.length > 0 ? matching : [pattern] });
4335
4558
  }
@@ -4642,7 +4865,7 @@ function computeContentHash(patchContent) {
4642
4865
  }
4643
4866
 
4644
4867
  // src/commands/forget.ts
4645
- import { minimatch as minimatch5 } from "minimatch";
4868
+ import { minimatch as minimatch7 } from "minimatch";
4646
4869
  function parseDiffStat(patchContent) {
4647
4870
  let additions = 0;
4648
4871
  let deletions = 0;
@@ -4672,7 +4895,7 @@ function toMatchedPatch(patch) {
4672
4895
  }
4673
4896
  function matchesPatch(patch, pattern) {
4674
4897
  const fileMatch = patch.files.some(
4675
- (file) => file === pattern || minimatch5(file, pattern)
4898
+ (file) => file === pattern || minimatch7(file, pattern)
4676
4899
  );
4677
4900
  if (fileMatch) return true;
4678
4901
  return patch.original_message.toLowerCase().includes(pattern.toLowerCase());