@fern-api/replay 0.12.0 → 0.14.0

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
@@ -51,9 +51,9 @@ var init_GitClient = __esm({
51
51
  return this.git.raw(args);
52
52
  }
53
53
  async execWithInput(args, input) {
54
- const { spawn } = await import("child_process");
54
+ const { spawn: spawn2 } = await import("child_process");
55
55
  return new Promise((resolve2, reject) => {
56
- const proc = spawn("git", args, { cwd: this.repoPath });
56
+ const proc = spawn2("git", args, { cwd: this.repoPath });
57
57
  let stdout = "";
58
58
  let stderr = "";
59
59
  proc.stdout.on("data", (data) => {
@@ -762,17 +762,57 @@ var ReplayDetector = class {
762
762
  }
763
763
  const isAncestor = await this.git.isAncestor(lastGen.commit_sha, "HEAD");
764
764
  if (!isAncestor) {
765
+ return this.detectPatchesViaMergeBase(lastGen, lock);
766
+ }
767
+ return this.detectPatchesInRange(lastGen.commit_sha, lastGen, lock);
768
+ }
769
+ /**
770
+ * FER-10201 — Non-linear-history detection via `merge-base(prevGen, HEAD)`.
771
+ *
772
+ * After a squash-merge of a Fern-bot PR, `lastGen.commit_sha` (the previous
773
+ * `[fern-generated]`) is orphaned but still in git's object database. The
774
+ * merge-base is the commit on main from which the bot's branch was created —
775
+ * a reachable ancestor of HEAD. Walking that range and classifying each
776
+ * commit via `isGenerationCommit` mirrors the linear path and eliminates
777
+ * the bug class where pipeline-driven changes (autoversion, replay,
778
+ * generation) get bundled as customer customizations.
779
+ *
780
+ * Falls through to the legacy composite path when no common ancestor exists
781
+ * (truly disjoint history — orphan branches with no shared root).
782
+ */
783
+ async detectPatchesViaMergeBase(lastGen, lock) {
784
+ let mergeBase = "";
785
+ try {
786
+ mergeBase = (await this.git.exec(["merge-base", lastGen.commit_sha, "HEAD"])).trim();
787
+ } catch {
788
+ }
789
+ if (!mergeBase) {
790
+ this.warnings.push(
791
+ `No common ancestor between previous generation ${lastGen.commit_sha.slice(0, 7)} and HEAD. Falling back to composite tree-diff detection.`
792
+ );
765
793
  return this.detectPatchesViaTreeDiff(
766
794
  lastGen,
767
795
  /* commitKnownMissing */
768
796
  false
769
797
  );
770
798
  }
799
+ return this.detectPatchesInRange(mergeBase, lastGen, lock);
800
+ }
801
+ /**
802
+ * FER-10201 — Walk `${rangeStart}..HEAD`, classify each commit, emit one
803
+ * `StoredPatch` per customer commit. Body shared by the linear path
804
+ * (rangeStart = lastGen.commit_sha) and the merge-base path.
805
+ *
806
+ * Patch `base_generation` is always `lastGen.commit_sha` — the
807
+ * lockfile-tracked reference the applicator uses to fetch base file
808
+ * content, regardless of where the walk actually started.
809
+ */
810
+ async detectPatchesInRange(rangeStart, lastGen, lock) {
771
811
  const log = await this.git.exec([
772
812
  "log",
773
813
  "--first-parent",
774
814
  "--format=%H%x00%an%x00%ae%x00%s",
775
- `${lastGen.commit_sha}..HEAD`,
815
+ `${rangeStart}..HEAD`,
776
816
  "--",
777
817
  this.sdkOutputDir
778
818
  ]);
@@ -1272,11 +1312,43 @@ function threeWayMerge(base, ours, theirs) {
1272
1312
  }
1273
1313
  }
1274
1314
  }
1275
- return {
1315
+ const result = {
1276
1316
  content: outputLines.join("\n"),
1277
1317
  hasConflicts: conflicts.length > 0,
1278
1318
  conflicts
1279
1319
  };
1320
+ if (conflicts.length === 0) {
1321
+ const dropped = computeDroppedContextLines(baseLines, theirsLines, outputLines);
1322
+ if (dropped.length > 0) {
1323
+ result.droppedContextLines = dropped;
1324
+ }
1325
+ }
1326
+ return result;
1327
+ }
1328
+ function computeDroppedContextLines(baseLines, theirsLines, mergedLines) {
1329
+ const baseCounts = countLines(baseLines);
1330
+ const theirsCounts = countLines(theirsLines);
1331
+ const mergedCounts = countLines(mergedLines);
1332
+ const dropped = [];
1333
+ const seen = /* @__PURE__ */ new Set();
1334
+ for (const line of baseLines) {
1335
+ if (seen.has(line)) continue;
1336
+ const inBase = baseCounts.get(line) ?? 0;
1337
+ const inTheirs = theirsCounts.get(line) ?? 0;
1338
+ const inMerged = mergedCounts.get(line) ?? 0;
1339
+ if (inBase > 0 && inTheirs > 0 && inMerged < Math.min(inBase, inTheirs)) {
1340
+ dropped.push(line);
1341
+ seen.add(line);
1342
+ }
1343
+ }
1344
+ return dropped;
1345
+ }
1346
+ function countLines(lines) {
1347
+ const counts = /* @__PURE__ */ new Map();
1348
+ for (const line of lines) {
1349
+ counts.set(line, (counts.get(line) ?? 0) + 1);
1350
+ }
1351
+ return counts;
1280
1352
  }
1281
1353
  function tryResolveConflict(oursLines, baseLines, theirsLines) {
1282
1354
  if (baseLines.length === 0) {
@@ -1471,6 +1543,19 @@ var ReplayApplicator = class {
1471
1543
  renameCache = /* @__PURE__ */ new Map();
1472
1544
  treeExistsCache = /* @__PURE__ */ new Map();
1473
1545
  fileTheirsAccumulator = /* @__PURE__ */ new Map();
1546
+ /**
1547
+ * Apply mode for the current `applyPatches` invocation. Set at the start
1548
+ * of `applyPatches`, read by `mergeFile` to decide:
1549
+ * - whether to skip the intra-loop marker strip (kept in `applyPatches`)
1550
+ * - whether to populate the accumulator after a conflicted merge
1551
+ * (resolve mode populates so subsequent patches on the same file
1552
+ * can use patch A's THEIRS as a structurally-correct merge base
1553
+ * when their diff was authored against post-A structure)
1554
+ * - whether to retry THEIRS reconstruction against the accumulator
1555
+ * when the BASE-relative reconstruction produced markers from a
1556
+ * cross-patch context mismatch
1557
+ */
1558
+ currentApplyMode = "replay";
1474
1559
  constructor(git, lockManager, outputDir) {
1475
1560
  this.git = git;
1476
1561
  this.lockManager = lockManager;
@@ -1518,8 +1603,23 @@ var ReplayApplicator = class {
1518
1603
  /**
1519
1604
  * Apply all patches, returning results for each.
1520
1605
  * Skips patches that match exclude patterns in replay.yml
1606
+ *
1607
+ * `applyMode` controls the post-conflict marker strategy:
1608
+ * - `"replay"` (default): strip conflict markers from disk between
1609
+ * iterations whenever a later patch touches the same file. Lets the
1610
+ * follow-up patch's `git apply --3way` see clean OURS content,
1611
+ * which is what the silent-loss fix (PR #73) relies on. The replay
1612
+ * pipeline calls `revertConflictingFiles` after the loop to clean
1613
+ * any markers that survive.
1614
+ * - `"resolve"`: keep markers on disk. The resolve command needs the
1615
+ * customer to see them. Slow-path 3-way merge naturally preserves
1616
+ * A's markers and B's clean writes when their regions don't overlap;
1617
+ * when they do overlap, the customer gets nested markers and
1618
+ * resolves manually. Either way they aren't silently dropped.
1521
1619
  */
1522
- async applyPatches(patches) {
1620
+ async applyPatches(patches, opts) {
1621
+ const applyMode = opts?.applyMode ?? "replay";
1622
+ this.currentApplyMode = applyMode;
1523
1623
  this.resetAccumulator();
1524
1624
  const results = [];
1525
1625
  for (let i = 0; i < patches.length; i++) {
@@ -1534,7 +1634,7 @@ var ReplayApplicator = class {
1534
1634
  }
1535
1635
  const result = await this.applyPatchWithFallback(patch);
1536
1636
  results.push(result);
1537
- if (result.status === "conflict" && result.fileResults) {
1637
+ if (applyMode !== "resolve" && result.status === "conflict" && result.fileResults) {
1538
1638
  const laterFiles = /* @__PURE__ */ new Set();
1539
1639
  for (let j = i + 1; j < patches.length; j++) {
1540
1640
  for (const f of patches[j].files) {
@@ -1564,9 +1664,17 @@ var ReplayApplicator = class {
1564
1664
  }
1565
1665
  return results;
1566
1666
  }
1567
- /** Populate accumulator after git apply succeeds. */
1667
+ /**
1668
+ * Populate accumulator after git apply succeeds, AND collect per-file
1669
+ * results including any "dropped context lines" — lines that were
1670
+ * present in BASE and THEIRS (unchanged context) but absent from the
1671
+ * final on-disk state because OURS deleted them. This is the fast-path
1672
+ * counterpart to ThreeWayMerge.computeDroppedContextLines used by the
1673
+ * 3-way slow path; both must surface the same warning.
1674
+ */
1568
1675
  async populateAccumulatorForPatch(patch, baseGen, currentTreeHash) {
1569
- if (!baseGen) return;
1676
+ const fileResults = [];
1677
+ if (!baseGen) return fileResults;
1570
1678
  const tempDir = await (0, import_promises.mkdtemp)((0, import_node_path2.join)((0, import_node_os.tmpdir)(), "replay-acc-"));
1571
1679
  const { GitClient: GitClient2 } = await Promise.resolve().then(() => (init_GitClient(), GitClient_exports));
1572
1680
  const tempGit = new GitClient2(tempDir);
@@ -1579,18 +1687,30 @@ var ReplayApplicator = class {
1579
1687
  const resolvedPath = await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
1580
1688
  const base = await this.git.showFile(baseGen.tree_hash, filePath);
1581
1689
  const theirs = await this.applyPatchToContent(base, patch.patch_content, filePath, tempGit, tempDir);
1582
- const effectiveTheirs = theirs ?? await (0, import_promises.readFile)((0, import_node_path2.join)(this.outputDir, resolvedPath), "utf-8").catch(() => null);
1690
+ const finalOnDisk = await (0, import_promises.readFile)((0, import_node_path2.join)(this.outputDir, resolvedPath), "utf-8").catch(() => null);
1691
+ const effectiveTheirs = theirs ?? finalOnDisk;
1583
1692
  if (effectiveTheirs != null) {
1584
1693
  this.fileTheirsAccumulator.set(resolvedPath, {
1585
1694
  content: effectiveTheirs,
1586
1695
  baseGeneration: patch.base_generation
1587
1696
  });
1588
1697
  }
1698
+ if (base != null && effectiveTheirs != null && finalOnDisk != null) {
1699
+ const dropped = computeDroppedContextLinesForFile(base, effectiveTheirs, finalOnDisk);
1700
+ if (dropped.length > 0) {
1701
+ fileResults.push({
1702
+ file: resolvedPath,
1703
+ status: "merged",
1704
+ droppedContextLines: dropped
1705
+ });
1706
+ }
1707
+ }
1589
1708
  }
1590
1709
  } finally {
1591
1710
  await (0, import_promises.rm)(tempDir, { recursive: true }).catch(() => {
1592
1711
  });
1593
1712
  }
1713
+ return fileResults;
1594
1714
  }
1595
1715
  async applyPatchWithFallback(patch) {
1596
1716
  const baseGen = await this.resolveBaseGeneration(patch.base_generation);
@@ -1621,11 +1741,12 @@ var ReplayApplicator = class {
1621
1741
  }
1622
1742
  try {
1623
1743
  await this.git.execWithInput(["apply", "--3way"], patch.patch_content);
1624
- await this.populateAccumulatorForPatch(patch, baseGen, currentTreeHash);
1744
+ const fastPathFileResults = await this.populateAccumulatorForPatch(patch, baseGen, currentTreeHash);
1625
1745
  return {
1626
1746
  patch,
1627
1747
  status: "applied",
1628
1748
  method: "git-am",
1749
+ ...fastPathFileResults.length > 0 && { fileResults: fastPathFileResults },
1629
1750
  ...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
1630
1751
  };
1631
1752
  } catch {
@@ -1737,19 +1858,23 @@ var ReplayApplicator = class {
1737
1858
  renameSourcePath
1738
1859
  );
1739
1860
  }
1861
+ const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
1740
1862
  if (theirs) {
1741
1863
  const theirsHasMarkers = theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization");
1742
1864
  const baseHasMarkers = base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization"));
1743
1865
  if (theirsHasMarkers && !baseHasMarkers) {
1744
- return {
1745
- file: resolvedPath,
1746
- status: "skipped",
1747
- reason: "stale-conflict-markers"
1748
- };
1866
+ if (accumulatorEntry) {
1867
+ theirs = null;
1868
+ } else {
1869
+ return {
1870
+ file: resolvedPath,
1871
+ status: "skipped",
1872
+ reason: "stale-conflict-markers"
1873
+ };
1874
+ }
1749
1875
  }
1750
1876
  }
1751
1877
  let useAccumulatorAsMergeBase = false;
1752
- const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
1753
1878
  if (accumulatorEntry && (theirs == null || base == null)) {
1754
1879
  theirs = await this.applyPatchToContent(
1755
1880
  accumulatorEntry.content,
@@ -1856,7 +1981,8 @@ var ReplayApplicator = class {
1856
1981
  const outDir = (0, import_node_path2.dirname)(oursPath);
1857
1982
  await (0, import_promises.mkdir)(outDir, { recursive: true });
1858
1983
  await (0, import_promises.writeFile)(oursPath, merged.content);
1859
- if (effective_theirs != null && !merged.hasConflicts) {
1984
+ const populateAccumulator = effective_theirs != null && (!merged.hasConflicts || this.currentApplyMode === "resolve");
1985
+ if (populateAccumulator) {
1860
1986
  this.fileTheirsAccumulator.set(resolvedPath, {
1861
1987
  content: effective_theirs,
1862
1988
  baseGeneration: patch.base_generation
@@ -1871,7 +1997,11 @@ var ReplayApplicator = class {
1871
1997
  conflictMetadata: metadata
1872
1998
  };
1873
1999
  }
1874
- return { file: resolvedPath, status: "merged" };
2000
+ return {
2001
+ file: resolvedPath,
2002
+ status: "merged",
2003
+ ...merged.droppedContextLines && merged.droppedContextLines.length > 0 ? { droppedContextLines: merged.droppedContextLines } : {}
2004
+ };
1875
2005
  } catch (error) {
1876
2006
  return {
1877
2007
  file: filePath,
@@ -2057,6 +2187,30 @@ function isBinaryFile(filePath) {
2057
2187
  const ext = (0, import_node_path2.extname)(filePath).toLowerCase();
2058
2188
  return BINARY_EXTENSIONS.has(ext);
2059
2189
  }
2190
+ function computeDroppedContextLinesForFile(base, theirs, finalContent) {
2191
+ const baseLines = base.split("\n");
2192
+ const theirsLines = theirs.split("\n");
2193
+ const finalLines = finalContent.split("\n");
2194
+ const baseCounts = /* @__PURE__ */ new Map();
2195
+ const theirsCounts = /* @__PURE__ */ new Map();
2196
+ const finalCounts = /* @__PURE__ */ new Map();
2197
+ for (const l of baseLines) baseCounts.set(l, (baseCounts.get(l) ?? 0) + 1);
2198
+ for (const l of theirsLines) theirsCounts.set(l, (theirsCounts.get(l) ?? 0) + 1);
2199
+ for (const l of finalLines) finalCounts.set(l, (finalCounts.get(l) ?? 0) + 1);
2200
+ const dropped = [];
2201
+ const seen = /* @__PURE__ */ new Set();
2202
+ for (const line of baseLines) {
2203
+ if (seen.has(line)) continue;
2204
+ const inBase = baseCounts.get(line) ?? 0;
2205
+ const inTheirs = theirsCounts.get(line) ?? 0;
2206
+ const inFinal = finalCounts.get(line) ?? 0;
2207
+ if (inBase > 0 && inTheirs > 0 && inFinal < Math.min(inBase, inTheirs)) {
2208
+ dropped.push(line);
2209
+ seen.add(line);
2210
+ }
2211
+ }
2212
+ return dropped;
2213
+ }
2060
2214
  function isDiffLineForFile(diffLine, filePath) {
2061
2215
  const match = diffLine.match(/^diff --git a\/.+ b\/(.+)$/);
2062
2216
  return match !== null && match[1] === filePath;
@@ -2092,13 +2246,46 @@ CLI Version: ${options.cliVersion}`;
2092
2246
  await this.git.exec(["commit", "-m", fullMessage]);
2093
2247
  return (await this.git.exec(["rev-parse", "HEAD"])).trim();
2094
2248
  }
2095
- async commitReplay(_patchCount, patches, message) {
2249
+ async commitReplay(_patchCount, patches, message, options) {
2096
2250
  await this.stageAll();
2097
2251
  if (!await this.hasStagedChanges()) {
2098
2252
  return (await this.git.exec(["rev-parse", "HEAD"])).trim();
2099
2253
  }
2100
2254
  let fullMessage = message ?? `[fern-replay] Applied customizations`;
2101
- if (patches && patches.length > 0) {
2255
+ const buckets = options?.buckets;
2256
+ if (buckets) {
2257
+ if (buckets.applied.length > 0) {
2258
+ fullMessage += `
2259
+
2260
+ Patches applied (${buckets.applied.length}):`;
2261
+ for (const p of buckets.applied) {
2262
+ fullMessage += `
2263
+ - ${p.id}: ${p.original_message}`;
2264
+ }
2265
+ }
2266
+ if (buckets.unresolved.length > 0) {
2267
+ fullMessage += `
2268
+
2269
+ Patches with unresolved conflicts (${buckets.unresolved.length}):`;
2270
+ for (const p of buckets.unresolved) {
2271
+ fullMessage += `
2272
+ - ${p.id}: ${p.original_message}`;
2273
+ }
2274
+ fullMessage += `
2275
+ Run \`fern-replay resolve\` to apply these customizations.`;
2276
+ }
2277
+ if (buckets.absorbed.length > 0) {
2278
+ fullMessage += `
2279
+
2280
+ Patches absorbed by generator (${buckets.absorbed.length}):`;
2281
+ for (const p of buckets.absorbed) {
2282
+ fullMessage += `
2283
+ - ${p.id}: ${p.original_message}`;
2284
+ }
2285
+ fullMessage += `
2286
+ The generator now produces these customizations natively.`;
2287
+ }
2288
+ } else if (patches && patches.length > 0) {
2102
2289
  fullMessage += "\n\nPatches replayed:";
2103
2290
  for (const patch of patches) {
2104
2291
  fullMessage += `
@@ -2134,9 +2321,303 @@ CLI Version: ${options.cliVersion}`;
2134
2321
 
2135
2322
  // src/ReplayService.ts
2136
2323
  var import_node_fs2 = require("fs");
2137
- var import_node_path3 = require("path");
2324
+ var import_node_path4 = require("path");
2138
2325
  var import_minimatch2 = require("minimatch");
2139
2326
  init_GitClient();
2327
+
2328
+ // src/PatchRegionDiff.ts
2329
+ var import_promises2 = require("fs/promises");
2330
+ var import_node_os2 = require("os");
2331
+ var import_node_path3 = require("path");
2332
+ var import_node_child_process = require("child_process");
2333
+ init_HybridReconstruction();
2334
+ function normalizeHunkBody(hunk) {
2335
+ let contextC = 0;
2336
+ let removeC = 0;
2337
+ let addC = 0;
2338
+ const trimmed = [];
2339
+ for (const line of hunk.lines) {
2340
+ const oldUsed = contextC + removeC;
2341
+ const newUsed = contextC + addC;
2342
+ if (oldUsed >= hunk.oldCount && newUsed >= hunk.newCount) break;
2343
+ trimmed.push(line);
2344
+ if (line.type === "context") contextC++;
2345
+ else if (line.type === "remove") removeC++;
2346
+ else if (line.type === "add") addC++;
2347
+ }
2348
+ return { ...hunk, lines: trimmed };
2349
+ }
2350
+ function extractFileDiffForFile(patchContent, filePath) {
2351
+ const lines = patchContent.split("\n");
2352
+ const out = [];
2353
+ let inTarget = false;
2354
+ for (const line of lines) {
2355
+ if (line.startsWith("diff --git")) {
2356
+ if (inTarget) break;
2357
+ if (isDiffLineForFile2(line, filePath)) {
2358
+ inTarget = true;
2359
+ out.push(line);
2360
+ }
2361
+ continue;
2362
+ }
2363
+ if (inTarget) out.push(line);
2364
+ }
2365
+ return out.length > 0 ? out.join("\n") + "\n" : null;
2366
+ }
2367
+ function isDiffLineForFile2(line, filePath) {
2368
+ const match = line.match(/^diff --git a\/(.+) b\/(.+)$/);
2369
+ return match !== null && (match[2] === filePath || match[1] === filePath);
2370
+ }
2371
+ async function computePerPatchDiff(patch, getCurrentGenContent, getWorkingTreeContent) {
2372
+ const fragments = [];
2373
+ const unlocatableFiles = [];
2374
+ for (const file of patch.files) {
2375
+ const fileDiff = extractFileDiffForFile(patch.patch_content, file);
2376
+ if (fileDiff == null) {
2377
+ unlocatableFiles.push(file);
2378
+ continue;
2379
+ }
2380
+ const hunksRaw = parseHunks(fileDiff).map(normalizeHunkBody);
2381
+ const hunks = hunksRaw.filter((h) => {
2382
+ if (h.lines.length === 0) return false;
2383
+ let ctx = 0, rm3 = 0, add = 0;
2384
+ for (const ln of h.lines) {
2385
+ if (ln.type === "context") ctx++;
2386
+ else if (ln.type === "remove") rm3++;
2387
+ else if (ln.type === "add") add++;
2388
+ }
2389
+ return ctx + rm3 === h.oldCount && ctx + add === h.newCount;
2390
+ });
2391
+ if (hunks.length === 0) {
2392
+ if (hunksRaw.length > 0) unlocatableFiles.push(file);
2393
+ continue;
2394
+ }
2395
+ const currentGen = await getCurrentGenContent(file);
2396
+ const workingTree = await getWorkingTreeContent(file);
2397
+ const isPureNewFile = hunks.every((h) => h.oldCount === 0);
2398
+ const isPureDelete = hunks.every((h) => h.newCount === 0);
2399
+ if (currentGen == null) {
2400
+ if (!isPureNewFile) {
2401
+ unlocatableFiles.push(file);
2402
+ continue;
2403
+ }
2404
+ if (workingTree == null) continue;
2405
+ fragments.push(synthesizeNewFileDiff(file, workingTree));
2406
+ continue;
2407
+ }
2408
+ if (isPureNewFile) {
2409
+ if (workingTree == null) {
2410
+ fragments.push(synthesizeDeleteFileDiff(file, currentGen));
2411
+ continue;
2412
+ }
2413
+ if (workingTree === currentGen) {
2414
+ continue;
2415
+ }
2416
+ const fullDiff = await unifiedDiff(currentGen, workingTree, file);
2417
+ if (fullDiff && fullDiff.trim()) fragments.push(fullDiff);
2418
+ continue;
2419
+ }
2420
+ if (workingTree == null) {
2421
+ if (!isPureDelete) {
2422
+ unlocatableFiles.push(file);
2423
+ continue;
2424
+ }
2425
+ fragments.push(synthesizeDeleteFileDiff(file, currentGen));
2426
+ continue;
2427
+ }
2428
+ const currentGenLines = splitLines(currentGen);
2429
+ const workingTreeLines = splitLines(workingTree);
2430
+ const locInCurrentGenRaw = locateHunksInOurs(hunks, currentGenLines);
2431
+ const locInWorkingTreeRaw = locateHunksInOurs(hunks, workingTreeLines);
2432
+ if (locInCurrentGenRaw == null || locInWorkingTreeRaw == null) {
2433
+ unlocatableFiles.push(file);
2434
+ continue;
2435
+ }
2436
+ const locInCurrentGen = locInCurrentGenRaw.map(
2437
+ (l) => resolveSpan(l, currentGenLines, "old")
2438
+ );
2439
+ const locInWorkingTree = locInWorkingTreeRaw.map(
2440
+ (l) => resolveSpan(l, workingTreeLines, "new")
2441
+ );
2442
+ const overlay = overlayRegions(
2443
+ currentGenLines,
2444
+ locInCurrentGen,
2445
+ workingTreeLines,
2446
+ locInWorkingTree
2447
+ );
2448
+ if (overlay === null) {
2449
+ unlocatableFiles.push(file);
2450
+ continue;
2451
+ }
2452
+ if (overlay === currentGen) {
2453
+ continue;
2454
+ }
2455
+ const fileUnifiedDiff = await unifiedDiff(currentGen, overlay, file);
2456
+ if (fileUnifiedDiff && fileUnifiedDiff.trim()) {
2457
+ fragments.push(fileUnifiedDiff);
2458
+ }
2459
+ }
2460
+ return {
2461
+ diff: fragments.join(""),
2462
+ unlocatableFiles
2463
+ };
2464
+ }
2465
+ async function computePerPatchDiffWithFallback(patch, getCurrentGenContent, getWorkingTreeContent, runCumulativeDiff) {
2466
+ const ppDiff = await computePerPatchDiff(
2467
+ patch,
2468
+ getCurrentGenContent,
2469
+ getWorkingTreeContent
2470
+ );
2471
+ if (ppDiff.unlocatableFiles.length === 0) {
2472
+ return { diff: ppDiff.diff, hadFallback: false, fallbackFiles: [] };
2473
+ }
2474
+ const cumulative = await runCumulativeDiff(ppDiff.unlocatableFiles);
2475
+ if (cumulative === null) {
2476
+ return null;
2477
+ }
2478
+ const merged = ppDiff.diff + (cumulative.trim() ? cumulative : "");
2479
+ return { diff: merged, hadFallback: true, fallbackFiles: ppDiff.unlocatableFiles };
2480
+ }
2481
+ function splitLines(content) {
2482
+ return content.split("\n");
2483
+ }
2484
+ function resolveSpan(loc, oursLines, prefer) {
2485
+ const { hunk, oursOffset } = loc;
2486
+ const oldSide = [];
2487
+ const newSide = [];
2488
+ for (const line of hunk.lines) {
2489
+ if (line.type === "context") {
2490
+ oldSide.push(line.content);
2491
+ newSide.push(line.content);
2492
+ } else if (line.type === "remove") {
2493
+ oldSide.push(line.content);
2494
+ } else if (line.type === "add") {
2495
+ newSide.push(line.content);
2496
+ }
2497
+ }
2498
+ const oldFits = sequenceMatches(oldSide, oursLines, oursOffset);
2499
+ const newFits = sequenceMatches(newSide, oursLines, oursOffset);
2500
+ if (prefer === "old") {
2501
+ if (oldFits) return { ...loc, oursSpan: hunk.oldCount };
2502
+ if (newFits) return { ...loc, oursSpan: hunk.newCount };
2503
+ } else {
2504
+ if (newFits) return { ...loc, oursSpan: hunk.newCount };
2505
+ if (oldFits) return { ...loc, oursSpan: hunk.oldCount };
2506
+ }
2507
+ return loc;
2508
+ }
2509
+ function sequenceMatches(needle, haystack, offset) {
2510
+ if (offset + needle.length > haystack.length) return false;
2511
+ for (let i = 0; i < needle.length; i++) {
2512
+ if (haystack[offset + i] !== needle[i]) return false;
2513
+ }
2514
+ return true;
2515
+ }
2516
+ function overlayRegions(currentGenLines, locInCurrentGen, workingTreeLines, locInWorkingTree) {
2517
+ if (locInCurrentGen.length !== locInWorkingTree.length) {
2518
+ return null;
2519
+ }
2520
+ const out = [];
2521
+ let cursor = 0;
2522
+ for (let i = 0; i < locInCurrentGen.length; i++) {
2523
+ const cg = locInCurrentGen[i];
2524
+ const wt = locInWorkingTree[i];
2525
+ if (cg.oursOffset > cursor) {
2526
+ out.push(...currentGenLines.slice(cursor, cg.oursOffset));
2527
+ }
2528
+ out.push(...workingTreeLines.slice(wt.oursOffset, wt.oursOffset + wt.oursSpan));
2529
+ cursor = cg.oursOffset + cg.oursSpan;
2530
+ }
2531
+ if (cursor < currentGenLines.length) {
2532
+ out.push(...currentGenLines.slice(cursor));
2533
+ }
2534
+ return out.join("\n");
2535
+ }
2536
+ async function unifiedDiff(beforeContent, afterContent, filePath) {
2537
+ if (beforeContent === afterContent) return "";
2538
+ const tmp = await (0, import_promises2.mkdtemp)((0, import_node_path3.join)((0, import_node_os2.tmpdir)(), "fern-replay-pp-"));
2539
+ try {
2540
+ const beforePath = (0, import_node_path3.join)(tmp, "before");
2541
+ const afterPath = (0, import_node_path3.join)(tmp, "after");
2542
+ await (0, import_promises2.writeFile)(beforePath, beforeContent, "utf-8");
2543
+ await (0, import_promises2.writeFile)(afterPath, afterContent, "utf-8");
2544
+ const raw = await runGitDiffNoIndex(beforePath, afterPath);
2545
+ if (!raw.trim()) return "";
2546
+ return rewriteDiffHeaders(raw, filePath);
2547
+ } finally {
2548
+ await (0, import_promises2.rm)(tmp, { recursive: true, force: true });
2549
+ }
2550
+ }
2551
+ function runGitDiffNoIndex(beforePath, afterPath) {
2552
+ return new Promise((resolve2, reject) => {
2553
+ const proc = (0, import_node_child_process.spawn)("git", ["diff", "--no-index", "--", beforePath, afterPath]);
2554
+ let stdout = "";
2555
+ let stderr = "";
2556
+ proc.stdout.on("data", (d) => stdout += d.toString());
2557
+ proc.stderr.on("data", (d) => stderr += d.toString());
2558
+ proc.on("close", (code) => {
2559
+ if (code === 0 || code === 1) resolve2(stdout);
2560
+ else reject(new Error(`git diff --no-index failed (code ${code}): ${stderr}`));
2561
+ });
2562
+ });
2563
+ }
2564
+ function rewriteDiffHeaders(raw, filePath) {
2565
+ const lines = raw.split("\n");
2566
+ const out = [];
2567
+ let seenFirstHeader = false;
2568
+ for (const line of lines) {
2569
+ if (line.startsWith("diff --git ")) {
2570
+ out.push(`diff --git a/${filePath} b/${filePath}`);
2571
+ seenFirstHeader = true;
2572
+ continue;
2573
+ }
2574
+ if (!seenFirstHeader) {
2575
+ continue;
2576
+ }
2577
+ if (line.startsWith("--- ")) {
2578
+ out.push(`--- a/${filePath}`);
2579
+ continue;
2580
+ }
2581
+ if (line.startsWith("+++ ")) {
2582
+ out.push(`+++ b/${filePath}`);
2583
+ continue;
2584
+ }
2585
+ out.push(line);
2586
+ }
2587
+ return out.join("\n");
2588
+ }
2589
+ function synthesizeNewFileDiff(file, content) {
2590
+ const lines = content.split("\n");
2591
+ const hasTrailingNewline = content.endsWith("\n");
2592
+ const bodyLines = hasTrailingNewline ? lines.slice(0, -1) : lines;
2593
+ const header = [
2594
+ `diff --git a/${file} b/${file}`,
2595
+ "new file mode 100644",
2596
+ "--- /dev/null",
2597
+ `+++ b/${file}`,
2598
+ `@@ -0,0 +1,${bodyLines.length} @@`
2599
+ ].join("\n");
2600
+ const body = bodyLines.map((l) => `+${l}`).join("\n");
2601
+ const trailer = hasTrailingNewline ? "" : "\n\";
2602
+ return header + "\n" + body + trailer + "\n";
2603
+ }
2604
+ function synthesizeDeleteFileDiff(file, content) {
2605
+ const lines = content.split("\n");
2606
+ const hasTrailingNewline = content.endsWith("\n");
2607
+ const bodyLines = hasTrailingNewline ? lines.slice(0, -1) : lines;
2608
+ const header = [
2609
+ `diff --git a/${file} b/${file}`,
2610
+ "deleted file mode 100644",
2611
+ `--- a/${file}`,
2612
+ "+++ /dev/null",
2613
+ `@@ -1,${bodyLines.length} +0,0 @@`
2614
+ ].join("\n");
2615
+ const body = bodyLines.map((l) => `-${l}`).join("\n");
2616
+ const trailer = hasTrailingNewline ? "" : "\n\";
2617
+ return header + "\n" + body + trailer + "\n";
2618
+ }
2619
+
2620
+ // src/ReplayService.ts
2140
2621
  var ReplayService = class {
2141
2622
  git;
2142
2623
  detector;
@@ -2530,6 +3011,7 @@ var ReplayService = class {
2530
3011
  if (newPatches.length > 0) {
2531
3012
  results = await this.applicator.applyPatches(newPatches);
2532
3013
  this._lastApplyResults = results;
3014
+ this.recordDroppedContextLineWarnings(results);
2533
3015
  this.revertConflictingFiles(results);
2534
3016
  for (const result of results) {
2535
3017
  if (result.status === "conflict") {
@@ -2548,7 +3030,8 @@ var ReplayService = class {
2548
3030
  if (newPatches.length > 0) {
2549
3031
  if (!options?.stageOnly) {
2550
3032
  const appliedCount = results.filter((r) => r.status === "applied").length;
2551
- await this.committer.commitReplay(appliedCount, newPatches);
3033
+ const buckets = computeCommitBuckets(results, rebaseCounts.absorbedPatchIds, newPatches);
3034
+ await this.committer.commitReplay(appliedCount, newPatches, void 0, { buckets });
2552
3035
  } else {
2553
3036
  await this.committer.stageAll();
2554
3037
  }
@@ -2611,12 +3094,21 @@ var ReplayService = class {
2611
3094
  (p) => preRebasePatchIds.has(p.id) && !postRebasePatchIds.has(p.id)
2612
3095
  );
2613
3096
  existingPatches = this.lockManager.getPatches();
2614
- const seenHashes = /* @__PURE__ */ new Set();
3097
+ const seenHashes = /* @__PURE__ */ new Map();
2615
3098
  for (const p of existingPatches) {
2616
- if (seenHashes.has(p.content_hash)) {
2617
- this.lockManager.removePatch(p.id);
3099
+ const priorOrigin = seenHashes.get(p.content_hash);
3100
+ if (priorOrigin !== void 0) {
3101
+ const provenanceMissing = !priorOrigin || !p.original_commit;
3102
+ const sameOrigin = priorOrigin === p.original_commit;
3103
+ if (sameOrigin && !provenanceMissing) {
3104
+ this.lockManager.removePatch(p.id);
3105
+ } else {
3106
+ this.warnings.push(
3107
+ `Preserving patch ${p.id} (commit ${(p.original_commit || "<missing>").slice(0, 7)}): shares content_hash with a prior patch ${provenanceMissing ? "(provenance missing on at least one side)" : "from a different source commit"}. Both retained to avoid silent customization loss (FER-9983).`
3108
+ );
3109
+ }
2618
3110
  } else {
2619
- seenHashes.add(p.content_hash);
3111
+ seenHashes.set(p.content_hash, p.original_commit);
2620
3112
  }
2621
3113
  }
2622
3114
  existingPatches = this.lockManager.getPatches();
@@ -2677,6 +3169,7 @@ var ReplayService = class {
2677
3169
  const genSha = prep._prepared.genSha;
2678
3170
  const results = await this.applicator.applyPatches(allPatches);
2679
3171
  this._lastApplyResults = results;
3172
+ this.recordDroppedContextLineWarnings(results);
2680
3173
  this.revertConflictingFiles(results);
2681
3174
  for (const result of results) {
2682
3175
  if (result.status === "conflict") {
@@ -2704,7 +3197,8 @@ var ReplayService = class {
2704
3197
  await this.committer.stageAll();
2705
3198
  } else {
2706
3199
  const appliedCount = results.filter((r) => r.status === "applied").length;
2707
- await this.committer.commitReplay(appliedCount, allPatches);
3200
+ const buckets = computeCommitBuckets(results, rebaseCounts.absorbedPatchIds, allPatches);
3201
+ await this.committer.commitReplay(appliedCount, allPatches, void 0, { buckets });
2708
3202
  }
2709
3203
  const warnings = [...detectorWarnings, ...this.warnings];
2710
3204
  return this.buildReport(
@@ -2728,7 +3222,7 @@ var ReplayService = class {
2728
3222
  let repointed = 0;
2729
3223
  let contentRebased = 0;
2730
3224
  let keptAsUserOwned = 0;
2731
- const seenContentHashes = /* @__PURE__ */ new Set();
3225
+ const seenContentHashes = /* @__PURE__ */ new Map();
2732
3226
  const absorbedPatchIds = /* @__PURE__ */ new Set();
2733
3227
  const fernignorePatterns = this.readFernignorePatterns();
2734
3228
  for (const result of results) {
@@ -2742,6 +3236,23 @@ var ReplayService = class {
2742
3236
  }
2743
3237
  }
2744
3238
  }
3239
+ const conflictedFilePaths = /* @__PURE__ */ new Set();
3240
+ for (const r of results) {
3241
+ if (r.status !== "conflict") continue;
3242
+ if (r.fileResults) {
3243
+ for (const fr of r.fileResults) {
3244
+ if (fr.status !== "conflict") continue;
3245
+ conflictedFilePaths.add(fr.file);
3246
+ if (r.resolvedFiles) {
3247
+ for (const [orig, resolved] of Object.entries(r.resolvedFiles)) {
3248
+ if (resolved === fr.file) conflictedFilePaths.add(orig);
3249
+ }
3250
+ }
3251
+ }
3252
+ } else {
3253
+ for (const f of r.patch.files) conflictedFilePaths.add(f);
3254
+ }
3255
+ }
2745
3256
  for (const result of results) {
2746
3257
  if (result.status === "conflict" && result.fileResults) {
2747
3258
  await this.trimAbsorbedFiles(result, currentGenSha);
@@ -2809,6 +3320,18 @@ var ReplayService = class {
2809
3320
  keptAsUserOwned++;
2810
3321
  continue;
2811
3322
  }
3323
+ const overlapsConflicted = patch.files.some((f) => conflictedFilePaths.has(f));
3324
+ if (overlapsConflicted) {
3325
+ patch.status = "unresolved";
3326
+ try {
3327
+ this.lockManager.updatePatch(patch.id, { status: "unresolved" });
3328
+ } catch {
3329
+ }
3330
+ this.warnings.push(
3331
+ `Patch ${patch.id} (${patch.original_message}) was preserved as unresolved because an earlier patch conflicted on the same file(s) (${patch.files.join(", ")}). Run \`fern replay resolve\` to apply this customization manually.`
3332
+ );
3333
+ continue;
3334
+ }
2812
3335
  this.lockManager.removePatch(patch.id);
2813
3336
  absorbedPatchIds.add(patch.id);
2814
3337
  absorbed++;
@@ -2824,13 +3347,22 @@ var ReplayService = class {
2824
3347
  continue;
2825
3348
  }
2826
3349
  const newContentHash = this.detector.computeContentHash(diff);
2827
- if (seenContentHashes.has(newContentHash)) {
2828
- this.lockManager.removePatch(patch.id);
2829
- absorbedPatchIds.add(patch.id);
2830
- absorbed++;
2831
- continue;
3350
+ const priorOrigin = seenContentHashes.get(newContentHash);
3351
+ if (priorOrigin !== void 0) {
3352
+ const provenanceMissing = !priorOrigin || !patch.original_commit;
3353
+ const sameOrigin = priorOrigin === patch.original_commit;
3354
+ if (sameOrigin && !provenanceMissing) {
3355
+ this.lockManager.removePatch(patch.id);
3356
+ absorbedPatchIds.add(patch.id);
3357
+ absorbed++;
3358
+ continue;
3359
+ }
3360
+ this.warnings.push(
3361
+ `Preserving patch ${patch.id} (commit ${(patch.original_commit || "<missing>").slice(0, 7)}): shares content_hash with a prior patch ${provenanceMissing ? "(provenance missing on at least one side)" : "from a different source commit"}. Both retained to avoid silent customization loss (FER-9983).`
3362
+ );
3363
+ } else {
3364
+ seenContentHashes.set(newContentHash, patch.original_commit);
2832
3365
  }
2833
- seenContentHashes.add(newContentHash);
2834
3366
  patch.base_generation = currentGenSha;
2835
3367
  patch.patch_content = diff;
2836
3368
  patch.content_hash = newContentHash;
@@ -3006,8 +3538,14 @@ var ReplayService = class {
3006
3538
  }
3007
3539
  if (patch.base_generation === currentGen) {
3008
3540
  try {
3009
- const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
3010
- if (diff === null) continue;
3541
+ const ppResult = await computePerPatchDiffWithFallback(
3542
+ patch,
3543
+ (f) => this.git.showFile(currentGen, f),
3544
+ (f) => this.git.showFile("HEAD", f),
3545
+ (files) => this.git.exec(["diff", currentGen, "HEAD", "--", ...files]).catch(() => null)
3546
+ );
3547
+ if (ppResult === null) continue;
3548
+ const diff = ppResult.diff;
3011
3549
  if (!diff.trim()) {
3012
3550
  if (await allPatchFilesUserOwned(patch)) continue;
3013
3551
  this.lockManager.removePatch(patch.id);
@@ -3049,8 +3587,14 @@ var ReplayService = class {
3049
3587
  try {
3050
3588
  const markerFiles = await this.git.exec(["grep", "-l", "<<<<<<< Generated", "HEAD", "--", ...patch.files]).catch(() => "");
3051
3589
  if (markerFiles.trim()) continue;
3052
- const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
3053
- if (diff === null) continue;
3590
+ const ppResult = await computePerPatchDiffWithFallback(
3591
+ patch,
3592
+ (f) => this.git.showFile(currentGen, f),
3593
+ (f) => this.git.showFile("HEAD", f),
3594
+ (files) => this.git.exec(["diff", currentGen, "HEAD", "--", ...files]).catch(() => null)
3595
+ );
3596
+ if (ppResult === null) continue;
3597
+ const diff = ppResult.diff;
3054
3598
  if (!diff.trim()) {
3055
3599
  if (await allPatchFilesUserOwned(patch)) {
3056
3600
  try {
@@ -3075,6 +3619,32 @@ var ReplayService = class {
3075
3619
  }
3076
3620
  return { conflictResolved, conflictAbsorbed, contentRefreshed };
3077
3621
  }
3622
+ /**
3623
+ * After applyPatches(), surface a warning for each (patch × file) where
3624
+ * diff3 silently dropped lines that were unchanged context in the
3625
+ * customer's THEIRS. The deletion stays on disk (correct diff3 outcome),
3626
+ * but the customer must learn so they can re-add the lines if they
3627
+ * relied on them. This is the "surface or preserve, never silently drop"
3628
+ * contract for legitimate-deletion cases.
3629
+ */
3630
+ recordDroppedContextLineWarnings(results) {
3631
+ const PREVIEW_COUNT = 5;
3632
+ for (const result of results) {
3633
+ if (!result.fileResults) continue;
3634
+ for (const fr of result.fileResults) {
3635
+ const dropped = fr.droppedContextLines;
3636
+ if (!dropped || dropped.length === 0) continue;
3637
+ const preview = dropped.slice(0, PREVIEW_COUNT).map((line) => ` ${line}`).join("\n");
3638
+ const more = dropped.length > PREVIEW_COUNT ? `
3639
+ ... and ${dropped.length - PREVIEW_COUNT} more` : "";
3640
+ this.warnings.push(
3641
+ `${fr.file}: ${dropped.length} line(s) that appeared as unchanged context in your customization were deleted by the new generator output. The merge followed the deletion. First lines deleted:
3642
+ ${preview}${more}
3643
+ If you want to keep these lines, restore them in a follow-up commit and re-run replay; otherwise this warning can be safely ignored.`
3644
+ );
3645
+ }
3646
+ }
3647
+ }
3078
3648
  /**
3079
3649
  * After applyPatches(), strip conflict markers from conflicting files
3080
3650
  * so only clean content is committed. Keeps the Generated (OURS) side.
@@ -3084,7 +3654,7 @@ var ReplayService = class {
3084
3654
  if (result.status !== "conflict" || !result.fileResults) continue;
3085
3655
  for (const fileResult of result.fileResults) {
3086
3656
  if (fileResult.status !== "conflict") continue;
3087
- const filePath = (0, import_node_path3.join)(this.outputDir, fileResult.file);
3657
+ const filePath = (0, import_node_path4.join)(this.outputDir, fileResult.file);
3088
3658
  try {
3089
3659
  const content = (0, import_node_fs2.readFileSync)(filePath, "utf-8");
3090
3660
  const stripped = stripConflictMarkers(content);
@@ -3114,7 +3684,7 @@ var ReplayService = class {
3114
3684
  }
3115
3685
  }
3116
3686
  readFernignorePatterns() {
3117
- const fernignorePath = (0, import_node_path3.join)(this.outputDir, ".fernignore");
3687
+ const fernignorePath = (0, import_node_path4.join)(this.outputDir, ".fernignore");
3118
3688
  if (!(0, import_node_fs2.existsSync)(fernignorePath)) return [];
3119
3689
  return (0, import_node_fs2.readFileSync)(fernignorePath, "utf-8").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
3120
3690
  }
@@ -3132,6 +3702,9 @@ var ReplayService = class {
3132
3702
  };
3133
3703
  }).filter((d) => d.files.length > 0);
3134
3704
  const partialCount = conflictDetails.filter((d) => d.cleanFiles && d.cleanFiles.length > 0).length;
3705
+ const unresolvedResults = results.filter(
3706
+ (r) => r.status === "conflict" || r.patch.status === "unresolved"
3707
+ );
3135
3708
  return {
3136
3709
  flow,
3137
3710
  patchesDetected: patches.length,
@@ -3148,7 +3721,7 @@ var ReplayService = class {
3148
3721
  patchesRefreshed: preRebaseCounts && preRebaseCounts.contentRefreshed > 0 ? preRebaseCounts.contentRefreshed : void 0,
3149
3722
  conflicts: conflictResults.flatMap((r) => r.fileResults?.filter((f) => f.status === "conflict") ?? []),
3150
3723
  conflictDetails: conflictDetails.length > 0 ? conflictDetails : void 0,
3151
- unresolvedPatches: conflictResults.length > 0 ? conflictResults.map((r) => ({
3724
+ unresolvedPatches: unresolvedResults.length > 0 ? unresolvedResults.map((r) => ({
3152
3725
  patchId: r.patch.id,
3153
3726
  patchMessage: r.patch.original_message,
3154
3727
  files: r.patch.files,
@@ -3159,11 +3732,38 @@ var ReplayService = class {
3159
3732
  };
3160
3733
  }
3161
3734
  };
3735
+ function computeCommitBuckets(results, absorbedPatchIds, patchesPassedToCommit) {
3736
+ const resultByPatchId = /* @__PURE__ */ new Map();
3737
+ for (const r of results) resultByPatchId.set(r.patch.id, r);
3738
+ const applied = [];
3739
+ const unresolved = [];
3740
+ const absorbed = [];
3741
+ for (const patch of patchesPassedToCommit) {
3742
+ if (absorbedPatchIds.has(patch.id)) {
3743
+ absorbed.push(patch);
3744
+ continue;
3745
+ }
3746
+ const r = resultByPatchId.get(patch.id);
3747
+ if (!r) {
3748
+ applied.push(patch);
3749
+ continue;
3750
+ }
3751
+ if (r.status === "conflict" || patch.status === "unresolved") {
3752
+ unresolved.push(patch);
3753
+ continue;
3754
+ }
3755
+ if (r.status === "applied") {
3756
+ applied.push(patch);
3757
+ continue;
3758
+ }
3759
+ }
3760
+ return { applied, unresolved, absorbed };
3761
+ }
3162
3762
 
3163
3763
  // src/FernignoreMigrator.ts
3164
3764
  var import_node_crypto2 = require("crypto");
3165
3765
  var import_node_fs3 = require("fs");
3166
- var import_node_path4 = require("path");
3766
+ var import_node_path5 = require("path");
3167
3767
  var import_minimatch3 = require("minimatch");
3168
3768
  var import_yaml2 = require("yaml");
3169
3769
  var FernignoreMigrator = class {
@@ -3176,10 +3776,10 @@ var FernignoreMigrator = class {
3176
3776
  this.outputDir = outputDir;
3177
3777
  }
3178
3778
  fernignoreExists() {
3179
- return (0, import_node_fs3.existsSync)((0, import_node_path4.join)(this.outputDir, ".fernignore"));
3779
+ return (0, import_node_fs3.existsSync)((0, import_node_path5.join)(this.outputDir, ".fernignore"));
3180
3780
  }
3181
3781
  readFernignorePatterns() {
3182
- const fernignorePath = (0, import_node_path4.join)(this.outputDir, ".fernignore");
3782
+ const fernignorePath = (0, import_node_path5.join)(this.outputDir, ".fernignore");
3183
3783
  if (!(0, import_node_fs3.existsSync)(fernignorePath)) {
3184
3784
  return [];
3185
3785
  }
@@ -3239,7 +3839,16 @@ var FernignoreMigrator = class {
3239
3839
  original_author: "Fern Replay <replay@buildwithfern.com>",
3240
3840
  base_generation: currentGen.commit_sha,
3241
3841
  files: patchFiles,
3242
- patch_content: patchContent
3842
+ patch_content: patchContent,
3843
+ // Synthetic patches captured from .fernignore-protected files
3844
+ // are user-owned by definition — the customer's content
3845
+ // diverges from the generator's pristine output, and the
3846
+ // generator never produced this divergent content. Without
3847
+ // this flag, removing the file from .fernignore later (so
3848
+ // it leaves the protection check in `isFileUserOwned`) would
3849
+ // expose the patch to absorption on the next regen,
3850
+ // silently losing the customization.
3851
+ user_owned: true
3243
3852
  });
3244
3853
  trackedByBoth.push({
3245
3854
  file: pattern,
@@ -3272,7 +3881,7 @@ var FernignoreMigrator = class {
3272
3881
  return results;
3273
3882
  }
3274
3883
  readFileContent(filePath) {
3275
- const fullPath = (0, import_node_path4.join)(this.outputDir, filePath);
3884
+ const fullPath = (0, import_node_path5.join)(this.outputDir, filePath);
3276
3885
  if (!(0, import_node_fs3.existsSync)(fullPath)) {
3277
3886
  return null;
3278
3887
  }
@@ -3337,7 +3946,7 @@ var FernignoreMigrator = class {
3337
3946
  };
3338
3947
  }
3339
3948
  movePatternsToReplayYml(patterns) {
3340
- const replayYmlPath = (0, import_node_path4.join)(this.outputDir, ".fern", "replay.yml");
3949
+ const replayYmlPath = (0, import_node_path5.join)(this.outputDir, ".fern", "replay.yml");
3341
3950
  let config = {};
3342
3951
  if ((0, import_node_fs3.existsSync)(replayYmlPath)) {
3343
3952
  const content = (0, import_node_fs3.readFileSync)(replayYmlPath, "utf-8");
@@ -3346,7 +3955,7 @@ var FernignoreMigrator = class {
3346
3955
  const existing = config.exclude ?? [];
3347
3956
  const merged = [.../* @__PURE__ */ new Set([...existing, ...patterns])];
3348
3957
  config.exclude = merged;
3349
- const dir = (0, import_node_path4.dirname)(replayYmlPath);
3958
+ const dir = (0, import_node_path5.dirname)(replayYmlPath);
3350
3959
  if (!(0, import_node_fs3.existsSync)(dir)) {
3351
3960
  (0, import_node_fs3.mkdirSync)(dir, { recursive: true });
3352
3961
  }
@@ -3357,7 +3966,7 @@ var FernignoreMigrator = class {
3357
3966
  // src/commands/bootstrap.ts
3358
3967
  var import_node_crypto3 = require("crypto");
3359
3968
  var import_node_fs4 = require("fs");
3360
- var import_node_path5 = require("path");
3969
+ var import_node_path6 = require("path");
3361
3970
  init_GitClient();
3362
3971
  async function bootstrap(outputDir, options) {
3363
3972
  const git = new GitClient(outputDir);
@@ -3524,7 +4133,7 @@ function parseGitLog(log) {
3524
4133
  }
3525
4134
  var REPLAY_FERNIGNORE_ENTRIES = [".fern/replay.lock", ".fern/replay.yml", ".gitattributes"];
3526
4135
  function ensureFernignoreEntries(outputDir) {
3527
- const fernignorePath = (0, import_node_path5.join)(outputDir, ".fernignore");
4136
+ const fernignorePath = (0, import_node_path6.join)(outputDir, ".fernignore");
3528
4137
  let content = "";
3529
4138
  if ((0, import_node_fs4.existsSync)(fernignorePath)) {
3530
4139
  content = (0, import_node_fs4.readFileSync)(fernignorePath, "utf-8");
@@ -3548,7 +4157,7 @@ function ensureFernignoreEntries(outputDir) {
3548
4157
  }
3549
4158
  var GITATTRIBUTES_ENTRIES = [".fern/replay.lock linguist-generated=true"];
3550
4159
  function ensureGitattributesEntries(outputDir) {
3551
- const gitattributesPath = (0, import_node_path5.join)(outputDir, ".gitattributes");
4160
+ const gitattributesPath = (0, import_node_path6.join)(outputDir, ".gitattributes");
3552
4161
  let content = "";
3553
4162
  if ((0, import_node_fs4.existsSync)(gitattributesPath)) {
3554
4163
  content = (0, import_node_fs4.readFileSync)(gitattributesPath, "utf-8");
@@ -3746,6 +4355,8 @@ function reset(outputDir, options) {
3746
4355
  }
3747
4356
 
3748
4357
  // src/commands/resolve.ts
4358
+ var import_promises3 = require("fs/promises");
4359
+ var import_node_path7 = require("path");
3749
4360
  init_GitClient();
3750
4361
  async function resolve(outputDir, options) {
3751
4362
  const lockManager = new LockfileManager(outputDir);
@@ -3761,7 +4372,7 @@ async function resolve(outputDir, options) {
3761
4372
  const resolvingPatches = lockManager.getResolvingPatches();
3762
4373
  if (unresolvedPatches.length > 0) {
3763
4374
  const applicator = new ReplayApplicator(git, lockManager, outputDir);
3764
- await applicator.applyPatches(unresolvedPatches);
4375
+ await applicator.applyPatches(unresolvedPatches, { applyMode: "resolve" });
3765
4376
  const markerFiles = await findConflictMarkerFiles(git);
3766
4377
  if (markerFiles.length > 0) {
3767
4378
  for (const patch of unresolvedPatches) {
@@ -3787,20 +4398,38 @@ async function resolve(outputDir, options) {
3787
4398
  if (patchesToCommit.length > 0) {
3788
4399
  const currentGen = lock.current_generation;
3789
4400
  const detector = new ReplayDetector(git, lockManager, outputDir);
4401
+ const warnings = [];
3790
4402
  let patchesResolved = 0;
3791
4403
  for (const patch of patchesToCommit) {
3792
- const diff = await git.exec(["diff", currentGen, "--", ...patch.files]).catch(() => null);
3793
- if (!diff || !diff.trim()) {
4404
+ const result = await computePerPatchDiffWithFallback(
4405
+ patch,
4406
+ (f) => git.showFile(currentGen, f),
4407
+ (f) => (0, import_promises3.readFile)((0, import_node_path7.join)(outputDir, f), "utf-8").catch(() => null),
4408
+ (files) => git.exec(["diff", currentGen, "--", ...files]).catch(() => null)
4409
+ );
4410
+ if (result === null) {
4411
+ warnings.push(
4412
+ `Patch ${patch.id}: cumulative-diff fallback failed for unlocatable files; preserving patch unchanged`
4413
+ );
4414
+ continue;
4415
+ }
4416
+ const diff = result.diff;
4417
+ if (!diff.trim()) {
3794
4418
  lockManager.removePatch(patch.id);
3795
4419
  continue;
3796
4420
  }
4421
+ if (result.hadFallback) {
4422
+ warnings.push(
4423
+ `Patch ${patch.id}: ${result.fallbackFiles.join(", ")} could not be anchored; cumulative-diff fallback applied for those files`
4424
+ );
4425
+ }
3797
4426
  const newContentHash = detector.computeContentHash(diff);
3798
- const changedFiles = await getChangedFiles(git, currentGen, patch.files);
4427
+ const changedFiles = result.hadFallback ? await getChangedFiles(git, currentGen, patch.files) : extractFilesFromDiff(diff);
3799
4428
  lockManager.markPatchResolved(patch.id, {
3800
4429
  patch_content: diff,
3801
4430
  content_hash: newContentHash,
3802
4431
  base_generation: currentGen,
3803
- files: changedFiles
4432
+ files: changedFiles.length > 0 ? changedFiles : patch.files
3804
4433
  });
3805
4434
  patchesResolved++;
3806
4435
  }
@@ -3816,7 +4445,8 @@ async function resolve(outputDir, options) {
3816
4445
  success: true,
3817
4446
  commitSha: commitSha2,
3818
4447
  phase: "committed",
3819
- patchesResolved
4448
+ patchesResolved,
4449
+ warnings: warnings.length > 0 ? warnings : void 0
3820
4450
  };
3821
4451
  }
3822
4452
  const committer = new ReplayCommitter(git, outputDir);
@@ -3834,6 +4464,24 @@ async function getChangedFiles(git, currentGen, files) {
3834
4464
  const changed = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !f.startsWith(".fern/"));
3835
4465
  return changed.length > 0 ? changed : files;
3836
4466
  }
4467
+ function extractFilesFromDiff(unifiedDiff2) {
4468
+ const out = /* @__PURE__ */ new Set();
4469
+ const renameSources = /* @__PURE__ */ new Set();
4470
+ let currentTarget = null;
4471
+ for (const line of unifiedDiff2.split("\n")) {
4472
+ const headerMatch = line.match(/^diff --git a\/(.+?) b\/(.+)$/);
4473
+ if (headerMatch) {
4474
+ currentTarget = headerMatch[2];
4475
+ if (!currentTarget.startsWith(".fern/")) out.add(currentTarget);
4476
+ continue;
4477
+ }
4478
+ if (currentTarget && line.startsWith("rename from ")) {
4479
+ renameSources.add(line.slice("rename from ".length));
4480
+ }
4481
+ }
4482
+ for (const src of renameSources) out.delete(src);
4483
+ return Array.from(out);
4484
+ }
3837
4485
 
3838
4486
  // src/commands/status.ts
3839
4487
  function status(outputDir) {