@fern-api/replay 0.13.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) => {
@@ -1312,11 +1312,43 @@ function threeWayMerge(base, ours, theirs) {
1312
1312
  }
1313
1313
  }
1314
1314
  }
1315
- return {
1315
+ const result = {
1316
1316
  content: outputLines.join("\n"),
1317
1317
  hasConflicts: conflicts.length > 0,
1318
1318
  conflicts
1319
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;
1320
1352
  }
1321
1353
  function tryResolveConflict(oursLines, baseLines, theirsLines) {
1322
1354
  if (baseLines.length === 0) {
@@ -1511,6 +1543,19 @@ var ReplayApplicator = class {
1511
1543
  renameCache = /* @__PURE__ */ new Map();
1512
1544
  treeExistsCache = /* @__PURE__ */ new Map();
1513
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";
1514
1559
  constructor(git, lockManager, outputDir) {
1515
1560
  this.git = git;
1516
1561
  this.lockManager = lockManager;
@@ -1558,8 +1603,23 @@ var ReplayApplicator = class {
1558
1603
  /**
1559
1604
  * Apply all patches, returning results for each.
1560
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.
1561
1619
  */
1562
- async applyPatches(patches) {
1620
+ async applyPatches(patches, opts) {
1621
+ const applyMode = opts?.applyMode ?? "replay";
1622
+ this.currentApplyMode = applyMode;
1563
1623
  this.resetAccumulator();
1564
1624
  const results = [];
1565
1625
  for (let i = 0; i < patches.length; i++) {
@@ -1574,7 +1634,7 @@ var ReplayApplicator = class {
1574
1634
  }
1575
1635
  const result = await this.applyPatchWithFallback(patch);
1576
1636
  results.push(result);
1577
- if (result.status === "conflict" && result.fileResults) {
1637
+ if (applyMode !== "resolve" && result.status === "conflict" && result.fileResults) {
1578
1638
  const laterFiles = /* @__PURE__ */ new Set();
1579
1639
  for (let j = i + 1; j < patches.length; j++) {
1580
1640
  for (const f of patches[j].files) {
@@ -1604,9 +1664,17 @@ var ReplayApplicator = class {
1604
1664
  }
1605
1665
  return results;
1606
1666
  }
1607
- /** 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
+ */
1608
1675
  async populateAccumulatorForPatch(patch, baseGen, currentTreeHash) {
1609
- if (!baseGen) return;
1676
+ const fileResults = [];
1677
+ if (!baseGen) return fileResults;
1610
1678
  const tempDir = await (0, import_promises.mkdtemp)((0, import_node_path2.join)((0, import_node_os.tmpdir)(), "replay-acc-"));
1611
1679
  const { GitClient: GitClient2 } = await Promise.resolve().then(() => (init_GitClient(), GitClient_exports));
1612
1680
  const tempGit = new GitClient2(tempDir);
@@ -1619,18 +1687,30 @@ var ReplayApplicator = class {
1619
1687
  const resolvedPath = await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
1620
1688
  const base = await this.git.showFile(baseGen.tree_hash, filePath);
1621
1689
  const theirs = await this.applyPatchToContent(base, patch.patch_content, filePath, tempGit, tempDir);
1622
- 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;
1623
1692
  if (effectiveTheirs != null) {
1624
1693
  this.fileTheirsAccumulator.set(resolvedPath, {
1625
1694
  content: effectiveTheirs,
1626
1695
  baseGeneration: patch.base_generation
1627
1696
  });
1628
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
+ }
1629
1708
  }
1630
1709
  } finally {
1631
1710
  await (0, import_promises.rm)(tempDir, { recursive: true }).catch(() => {
1632
1711
  });
1633
1712
  }
1713
+ return fileResults;
1634
1714
  }
1635
1715
  async applyPatchWithFallback(patch) {
1636
1716
  const baseGen = await this.resolveBaseGeneration(patch.base_generation);
@@ -1661,11 +1741,12 @@ var ReplayApplicator = class {
1661
1741
  }
1662
1742
  try {
1663
1743
  await this.git.execWithInput(["apply", "--3way"], patch.patch_content);
1664
- await this.populateAccumulatorForPatch(patch, baseGen, currentTreeHash);
1744
+ const fastPathFileResults = await this.populateAccumulatorForPatch(patch, baseGen, currentTreeHash);
1665
1745
  return {
1666
1746
  patch,
1667
1747
  status: "applied",
1668
1748
  method: "git-am",
1749
+ ...fastPathFileResults.length > 0 && { fileResults: fastPathFileResults },
1669
1750
  ...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
1670
1751
  };
1671
1752
  } catch {
@@ -1777,19 +1858,23 @@ var ReplayApplicator = class {
1777
1858
  renameSourcePath
1778
1859
  );
1779
1860
  }
1861
+ const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
1780
1862
  if (theirs) {
1781
1863
  const theirsHasMarkers = theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization");
1782
1864
  const baseHasMarkers = base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization"));
1783
1865
  if (theirsHasMarkers && !baseHasMarkers) {
1784
- return {
1785
- file: resolvedPath,
1786
- status: "skipped",
1787
- reason: "stale-conflict-markers"
1788
- };
1866
+ if (accumulatorEntry) {
1867
+ theirs = null;
1868
+ } else {
1869
+ return {
1870
+ file: resolvedPath,
1871
+ status: "skipped",
1872
+ reason: "stale-conflict-markers"
1873
+ };
1874
+ }
1789
1875
  }
1790
1876
  }
1791
1877
  let useAccumulatorAsMergeBase = false;
1792
- const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
1793
1878
  if (accumulatorEntry && (theirs == null || base == null)) {
1794
1879
  theirs = await this.applyPatchToContent(
1795
1880
  accumulatorEntry.content,
@@ -1896,7 +1981,8 @@ var ReplayApplicator = class {
1896
1981
  const outDir = (0, import_node_path2.dirname)(oursPath);
1897
1982
  await (0, import_promises.mkdir)(outDir, { recursive: true });
1898
1983
  await (0, import_promises.writeFile)(oursPath, merged.content);
1899
- if (effective_theirs != null && !merged.hasConflicts) {
1984
+ const populateAccumulator = effective_theirs != null && (!merged.hasConflicts || this.currentApplyMode === "resolve");
1985
+ if (populateAccumulator) {
1900
1986
  this.fileTheirsAccumulator.set(resolvedPath, {
1901
1987
  content: effective_theirs,
1902
1988
  baseGeneration: patch.base_generation
@@ -1911,7 +1997,11 @@ var ReplayApplicator = class {
1911
1997
  conflictMetadata: metadata
1912
1998
  };
1913
1999
  }
1914
- return { file: resolvedPath, status: "merged" };
2000
+ return {
2001
+ file: resolvedPath,
2002
+ status: "merged",
2003
+ ...merged.droppedContextLines && merged.droppedContextLines.length > 0 ? { droppedContextLines: merged.droppedContextLines } : {}
2004
+ };
1915
2005
  } catch (error) {
1916
2006
  return {
1917
2007
  file: filePath,
@@ -2097,6 +2187,30 @@ function isBinaryFile(filePath) {
2097
2187
  const ext = (0, import_node_path2.extname)(filePath).toLowerCase();
2098
2188
  return BINARY_EXTENSIONS.has(ext);
2099
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
+ }
2100
2214
  function isDiffLineForFile(diffLine, filePath) {
2101
2215
  const match = diffLine.match(/^diff --git a\/.+ b\/(.+)$/);
2102
2216
  return match !== null && match[1] === filePath;
@@ -2132,13 +2246,46 @@ CLI Version: ${options.cliVersion}`;
2132
2246
  await this.git.exec(["commit", "-m", fullMessage]);
2133
2247
  return (await this.git.exec(["rev-parse", "HEAD"])).trim();
2134
2248
  }
2135
- async commitReplay(_patchCount, patches, message) {
2249
+ async commitReplay(_patchCount, patches, message, options) {
2136
2250
  await this.stageAll();
2137
2251
  if (!await this.hasStagedChanges()) {
2138
2252
  return (await this.git.exec(["rev-parse", "HEAD"])).trim();
2139
2253
  }
2140
2254
  let fullMessage = message ?? `[fern-replay] Applied customizations`;
2141
- 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) {
2142
2289
  fullMessage += "\n\nPatches replayed:";
2143
2290
  for (const patch of patches) {
2144
2291
  fullMessage += `
@@ -2174,9 +2321,303 @@ CLI Version: ${options.cliVersion}`;
2174
2321
 
2175
2322
  // src/ReplayService.ts
2176
2323
  var import_node_fs2 = require("fs");
2177
- var import_node_path3 = require("path");
2324
+ var import_node_path4 = require("path");
2178
2325
  var import_minimatch2 = require("minimatch");
2179
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
2180
2621
  var ReplayService = class {
2181
2622
  git;
2182
2623
  detector;
@@ -2570,6 +3011,7 @@ var ReplayService = class {
2570
3011
  if (newPatches.length > 0) {
2571
3012
  results = await this.applicator.applyPatches(newPatches);
2572
3013
  this._lastApplyResults = results;
3014
+ this.recordDroppedContextLineWarnings(results);
2573
3015
  this.revertConflictingFiles(results);
2574
3016
  for (const result of results) {
2575
3017
  if (result.status === "conflict") {
@@ -2588,7 +3030,8 @@ var ReplayService = class {
2588
3030
  if (newPatches.length > 0) {
2589
3031
  if (!options?.stageOnly) {
2590
3032
  const appliedCount = results.filter((r) => r.status === "applied").length;
2591
- await this.committer.commitReplay(appliedCount, newPatches);
3033
+ const buckets = computeCommitBuckets(results, rebaseCounts.absorbedPatchIds, newPatches);
3034
+ await this.committer.commitReplay(appliedCount, newPatches, void 0, { buckets });
2592
3035
  } else {
2593
3036
  await this.committer.stageAll();
2594
3037
  }
@@ -2651,12 +3094,21 @@ var ReplayService = class {
2651
3094
  (p) => preRebasePatchIds.has(p.id) && !postRebasePatchIds.has(p.id)
2652
3095
  );
2653
3096
  existingPatches = this.lockManager.getPatches();
2654
- const seenHashes = /* @__PURE__ */ new Set();
3097
+ const seenHashes = /* @__PURE__ */ new Map();
2655
3098
  for (const p of existingPatches) {
2656
- if (seenHashes.has(p.content_hash)) {
2657
- 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
+ }
2658
3110
  } else {
2659
- seenHashes.add(p.content_hash);
3111
+ seenHashes.set(p.content_hash, p.original_commit);
2660
3112
  }
2661
3113
  }
2662
3114
  existingPatches = this.lockManager.getPatches();
@@ -2717,6 +3169,7 @@ var ReplayService = class {
2717
3169
  const genSha = prep._prepared.genSha;
2718
3170
  const results = await this.applicator.applyPatches(allPatches);
2719
3171
  this._lastApplyResults = results;
3172
+ this.recordDroppedContextLineWarnings(results);
2720
3173
  this.revertConflictingFiles(results);
2721
3174
  for (const result of results) {
2722
3175
  if (result.status === "conflict") {
@@ -2744,7 +3197,8 @@ var ReplayService = class {
2744
3197
  await this.committer.stageAll();
2745
3198
  } else {
2746
3199
  const appliedCount = results.filter((r) => r.status === "applied").length;
2747
- await this.committer.commitReplay(appliedCount, allPatches);
3200
+ const buckets = computeCommitBuckets(results, rebaseCounts.absorbedPatchIds, allPatches);
3201
+ await this.committer.commitReplay(appliedCount, allPatches, void 0, { buckets });
2748
3202
  }
2749
3203
  const warnings = [...detectorWarnings, ...this.warnings];
2750
3204
  return this.buildReport(
@@ -2768,7 +3222,7 @@ var ReplayService = class {
2768
3222
  let repointed = 0;
2769
3223
  let contentRebased = 0;
2770
3224
  let keptAsUserOwned = 0;
2771
- const seenContentHashes = /* @__PURE__ */ new Set();
3225
+ const seenContentHashes = /* @__PURE__ */ new Map();
2772
3226
  const absorbedPatchIds = /* @__PURE__ */ new Set();
2773
3227
  const fernignorePatterns = this.readFernignorePatterns();
2774
3228
  for (const result of results) {
@@ -2782,6 +3236,23 @@ var ReplayService = class {
2782
3236
  }
2783
3237
  }
2784
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
+ }
2785
3256
  for (const result of results) {
2786
3257
  if (result.status === "conflict" && result.fileResults) {
2787
3258
  await this.trimAbsorbedFiles(result, currentGenSha);
@@ -2849,6 +3320,18 @@ var ReplayService = class {
2849
3320
  keptAsUserOwned++;
2850
3321
  continue;
2851
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
+ }
2852
3335
  this.lockManager.removePatch(patch.id);
2853
3336
  absorbedPatchIds.add(patch.id);
2854
3337
  absorbed++;
@@ -2864,13 +3347,22 @@ var ReplayService = class {
2864
3347
  continue;
2865
3348
  }
2866
3349
  const newContentHash = this.detector.computeContentHash(diff);
2867
- if (seenContentHashes.has(newContentHash)) {
2868
- this.lockManager.removePatch(patch.id);
2869
- absorbedPatchIds.add(patch.id);
2870
- absorbed++;
2871
- 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);
2872
3365
  }
2873
- seenContentHashes.add(newContentHash);
2874
3366
  patch.base_generation = currentGenSha;
2875
3367
  patch.patch_content = diff;
2876
3368
  patch.content_hash = newContentHash;
@@ -3046,8 +3538,14 @@ var ReplayService = class {
3046
3538
  }
3047
3539
  if (patch.base_generation === currentGen) {
3048
3540
  try {
3049
- const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
3050
- 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;
3051
3549
  if (!diff.trim()) {
3052
3550
  if (await allPatchFilesUserOwned(patch)) continue;
3053
3551
  this.lockManager.removePatch(patch.id);
@@ -3089,8 +3587,14 @@ var ReplayService = class {
3089
3587
  try {
3090
3588
  const markerFiles = await this.git.exec(["grep", "-l", "<<<<<<< Generated", "HEAD", "--", ...patch.files]).catch(() => "");
3091
3589
  if (markerFiles.trim()) continue;
3092
- const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
3093
- 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;
3094
3598
  if (!diff.trim()) {
3095
3599
  if (await allPatchFilesUserOwned(patch)) {
3096
3600
  try {
@@ -3115,6 +3619,32 @@ var ReplayService = class {
3115
3619
  }
3116
3620
  return { conflictResolved, conflictAbsorbed, contentRefreshed };
3117
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
+ }
3118
3648
  /**
3119
3649
  * After applyPatches(), strip conflict markers from conflicting files
3120
3650
  * so only clean content is committed. Keeps the Generated (OURS) side.
@@ -3124,7 +3654,7 @@ var ReplayService = class {
3124
3654
  if (result.status !== "conflict" || !result.fileResults) continue;
3125
3655
  for (const fileResult of result.fileResults) {
3126
3656
  if (fileResult.status !== "conflict") continue;
3127
- const filePath = (0, import_node_path3.join)(this.outputDir, fileResult.file);
3657
+ const filePath = (0, import_node_path4.join)(this.outputDir, fileResult.file);
3128
3658
  try {
3129
3659
  const content = (0, import_node_fs2.readFileSync)(filePath, "utf-8");
3130
3660
  const stripped = stripConflictMarkers(content);
@@ -3154,7 +3684,7 @@ var ReplayService = class {
3154
3684
  }
3155
3685
  }
3156
3686
  readFernignorePatterns() {
3157
- const fernignorePath = (0, import_node_path3.join)(this.outputDir, ".fernignore");
3687
+ const fernignorePath = (0, import_node_path4.join)(this.outputDir, ".fernignore");
3158
3688
  if (!(0, import_node_fs2.existsSync)(fernignorePath)) return [];
3159
3689
  return (0, import_node_fs2.readFileSync)(fernignorePath, "utf-8").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
3160
3690
  }
@@ -3172,6 +3702,9 @@ var ReplayService = class {
3172
3702
  };
3173
3703
  }).filter((d) => d.files.length > 0);
3174
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
+ );
3175
3708
  return {
3176
3709
  flow,
3177
3710
  patchesDetected: patches.length,
@@ -3188,7 +3721,7 @@ var ReplayService = class {
3188
3721
  patchesRefreshed: preRebaseCounts && preRebaseCounts.contentRefreshed > 0 ? preRebaseCounts.contentRefreshed : void 0,
3189
3722
  conflicts: conflictResults.flatMap((r) => r.fileResults?.filter((f) => f.status === "conflict") ?? []),
3190
3723
  conflictDetails: conflictDetails.length > 0 ? conflictDetails : void 0,
3191
- unresolvedPatches: conflictResults.length > 0 ? conflictResults.map((r) => ({
3724
+ unresolvedPatches: unresolvedResults.length > 0 ? unresolvedResults.map((r) => ({
3192
3725
  patchId: r.patch.id,
3193
3726
  patchMessage: r.patch.original_message,
3194
3727
  files: r.patch.files,
@@ -3199,11 +3732,38 @@ var ReplayService = class {
3199
3732
  };
3200
3733
  }
3201
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
+ }
3202
3762
 
3203
3763
  // src/FernignoreMigrator.ts
3204
3764
  var import_node_crypto2 = require("crypto");
3205
3765
  var import_node_fs3 = require("fs");
3206
- var import_node_path4 = require("path");
3766
+ var import_node_path5 = require("path");
3207
3767
  var import_minimatch3 = require("minimatch");
3208
3768
  var import_yaml2 = require("yaml");
3209
3769
  var FernignoreMigrator = class {
@@ -3216,10 +3776,10 @@ var FernignoreMigrator = class {
3216
3776
  this.outputDir = outputDir;
3217
3777
  }
3218
3778
  fernignoreExists() {
3219
- 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"));
3220
3780
  }
3221
3781
  readFernignorePatterns() {
3222
- const fernignorePath = (0, import_node_path4.join)(this.outputDir, ".fernignore");
3782
+ const fernignorePath = (0, import_node_path5.join)(this.outputDir, ".fernignore");
3223
3783
  if (!(0, import_node_fs3.existsSync)(fernignorePath)) {
3224
3784
  return [];
3225
3785
  }
@@ -3279,7 +3839,16 @@ var FernignoreMigrator = class {
3279
3839
  original_author: "Fern Replay <replay@buildwithfern.com>",
3280
3840
  base_generation: currentGen.commit_sha,
3281
3841
  files: patchFiles,
3282
- 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
3283
3852
  });
3284
3853
  trackedByBoth.push({
3285
3854
  file: pattern,
@@ -3312,7 +3881,7 @@ var FernignoreMigrator = class {
3312
3881
  return results;
3313
3882
  }
3314
3883
  readFileContent(filePath) {
3315
- const fullPath = (0, import_node_path4.join)(this.outputDir, filePath);
3884
+ const fullPath = (0, import_node_path5.join)(this.outputDir, filePath);
3316
3885
  if (!(0, import_node_fs3.existsSync)(fullPath)) {
3317
3886
  return null;
3318
3887
  }
@@ -3377,7 +3946,7 @@ var FernignoreMigrator = class {
3377
3946
  };
3378
3947
  }
3379
3948
  movePatternsToReplayYml(patterns) {
3380
- 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");
3381
3950
  let config = {};
3382
3951
  if ((0, import_node_fs3.existsSync)(replayYmlPath)) {
3383
3952
  const content = (0, import_node_fs3.readFileSync)(replayYmlPath, "utf-8");
@@ -3386,7 +3955,7 @@ var FernignoreMigrator = class {
3386
3955
  const existing = config.exclude ?? [];
3387
3956
  const merged = [.../* @__PURE__ */ new Set([...existing, ...patterns])];
3388
3957
  config.exclude = merged;
3389
- const dir = (0, import_node_path4.dirname)(replayYmlPath);
3958
+ const dir = (0, import_node_path5.dirname)(replayYmlPath);
3390
3959
  if (!(0, import_node_fs3.existsSync)(dir)) {
3391
3960
  (0, import_node_fs3.mkdirSync)(dir, { recursive: true });
3392
3961
  }
@@ -3397,7 +3966,7 @@ var FernignoreMigrator = class {
3397
3966
  // src/commands/bootstrap.ts
3398
3967
  var import_node_crypto3 = require("crypto");
3399
3968
  var import_node_fs4 = require("fs");
3400
- var import_node_path5 = require("path");
3969
+ var import_node_path6 = require("path");
3401
3970
  init_GitClient();
3402
3971
  async function bootstrap(outputDir, options) {
3403
3972
  const git = new GitClient(outputDir);
@@ -3564,7 +4133,7 @@ function parseGitLog(log) {
3564
4133
  }
3565
4134
  var REPLAY_FERNIGNORE_ENTRIES = [".fern/replay.lock", ".fern/replay.yml", ".gitattributes"];
3566
4135
  function ensureFernignoreEntries(outputDir) {
3567
- const fernignorePath = (0, import_node_path5.join)(outputDir, ".fernignore");
4136
+ const fernignorePath = (0, import_node_path6.join)(outputDir, ".fernignore");
3568
4137
  let content = "";
3569
4138
  if ((0, import_node_fs4.existsSync)(fernignorePath)) {
3570
4139
  content = (0, import_node_fs4.readFileSync)(fernignorePath, "utf-8");
@@ -3588,7 +4157,7 @@ function ensureFernignoreEntries(outputDir) {
3588
4157
  }
3589
4158
  var GITATTRIBUTES_ENTRIES = [".fern/replay.lock linguist-generated=true"];
3590
4159
  function ensureGitattributesEntries(outputDir) {
3591
- const gitattributesPath = (0, import_node_path5.join)(outputDir, ".gitattributes");
4160
+ const gitattributesPath = (0, import_node_path6.join)(outputDir, ".gitattributes");
3592
4161
  let content = "";
3593
4162
  if ((0, import_node_fs4.existsSync)(gitattributesPath)) {
3594
4163
  content = (0, import_node_fs4.readFileSync)(gitattributesPath, "utf-8");
@@ -3786,6 +4355,8 @@ function reset(outputDir, options) {
3786
4355
  }
3787
4356
 
3788
4357
  // src/commands/resolve.ts
4358
+ var import_promises3 = require("fs/promises");
4359
+ var import_node_path7 = require("path");
3789
4360
  init_GitClient();
3790
4361
  async function resolve(outputDir, options) {
3791
4362
  const lockManager = new LockfileManager(outputDir);
@@ -3801,7 +4372,7 @@ async function resolve(outputDir, options) {
3801
4372
  const resolvingPatches = lockManager.getResolvingPatches();
3802
4373
  if (unresolvedPatches.length > 0) {
3803
4374
  const applicator = new ReplayApplicator(git, lockManager, outputDir);
3804
- await applicator.applyPatches(unresolvedPatches);
4375
+ await applicator.applyPatches(unresolvedPatches, { applyMode: "resolve" });
3805
4376
  const markerFiles = await findConflictMarkerFiles(git);
3806
4377
  if (markerFiles.length > 0) {
3807
4378
  for (const patch of unresolvedPatches) {
@@ -3827,20 +4398,38 @@ async function resolve(outputDir, options) {
3827
4398
  if (patchesToCommit.length > 0) {
3828
4399
  const currentGen = lock.current_generation;
3829
4400
  const detector = new ReplayDetector(git, lockManager, outputDir);
4401
+ const warnings = [];
3830
4402
  let patchesResolved = 0;
3831
4403
  for (const patch of patchesToCommit) {
3832
- const diff = await git.exec(["diff", currentGen, "--", ...patch.files]).catch(() => null);
3833
- 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()) {
3834
4418
  lockManager.removePatch(patch.id);
3835
4419
  continue;
3836
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
+ }
3837
4426
  const newContentHash = detector.computeContentHash(diff);
3838
- const changedFiles = await getChangedFiles(git, currentGen, patch.files);
4427
+ const changedFiles = result.hadFallback ? await getChangedFiles(git, currentGen, patch.files) : extractFilesFromDiff(diff);
3839
4428
  lockManager.markPatchResolved(patch.id, {
3840
4429
  patch_content: diff,
3841
4430
  content_hash: newContentHash,
3842
4431
  base_generation: currentGen,
3843
- files: changedFiles
4432
+ files: changedFiles.length > 0 ? changedFiles : patch.files
3844
4433
  });
3845
4434
  patchesResolved++;
3846
4435
  }
@@ -3856,7 +4445,8 @@ async function resolve(outputDir, options) {
3856
4445
  success: true,
3857
4446
  commitSha: commitSha2,
3858
4447
  phase: "committed",
3859
- patchesResolved
4448
+ patchesResolved,
4449
+ warnings: warnings.length > 0 ? warnings : void 0
3860
4450
  };
3861
4451
  }
3862
4452
  const committer = new ReplayCommitter(git, outputDir);
@@ -3874,6 +4464,24 @@ async function getChangedFiles(git, currentGen, files) {
3874
4464
  const changed = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !f.startsWith(".fern/"));
3875
4465
  return changed.length > 0 ? changed : files;
3876
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
+ }
3877
4485
 
3878
4486
  // src/commands/status.ts
3879
4487
  function status(outputDir) {