@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.js CHANGED
@@ -29,9 +29,9 @@ var init_GitClient = __esm({
29
29
  return this.git.raw(args);
30
30
  }
31
31
  async execWithInput(args, input) {
32
- const { spawn } = await import("child_process");
32
+ const { spawn: spawn2 } = await import("child_process");
33
33
  return new Promise((resolve2, reject) => {
34
- const proc = spawn("git", args, { cwd: this.repoPath });
34
+ const proc = spawn2("git", args, { cwd: this.repoPath });
35
35
  let stdout = "";
36
36
  let stderr = "";
37
37
  proc.stdout.on("data", (data) => {
@@ -1258,11 +1258,43 @@ function threeWayMerge(base, ours, theirs) {
1258
1258
  }
1259
1259
  }
1260
1260
  }
1261
- return {
1261
+ const result = {
1262
1262
  content: outputLines.join("\n"),
1263
1263
  hasConflicts: conflicts.length > 0,
1264
1264
  conflicts
1265
1265
  };
1266
+ if (conflicts.length === 0) {
1267
+ const dropped = computeDroppedContextLines(baseLines, theirsLines, outputLines);
1268
+ if (dropped.length > 0) {
1269
+ result.droppedContextLines = dropped;
1270
+ }
1271
+ }
1272
+ return result;
1273
+ }
1274
+ function computeDroppedContextLines(baseLines, theirsLines, mergedLines) {
1275
+ const baseCounts = countLines(baseLines);
1276
+ const theirsCounts = countLines(theirsLines);
1277
+ const mergedCounts = countLines(mergedLines);
1278
+ const dropped = [];
1279
+ const seen = /* @__PURE__ */ new Set();
1280
+ for (const line of baseLines) {
1281
+ if (seen.has(line)) continue;
1282
+ const inBase = baseCounts.get(line) ?? 0;
1283
+ const inTheirs = theirsCounts.get(line) ?? 0;
1284
+ const inMerged = mergedCounts.get(line) ?? 0;
1285
+ if (inBase > 0 && inTheirs > 0 && inMerged < Math.min(inBase, inTheirs)) {
1286
+ dropped.push(line);
1287
+ seen.add(line);
1288
+ }
1289
+ }
1290
+ return dropped;
1291
+ }
1292
+ function countLines(lines) {
1293
+ const counts = /* @__PURE__ */ new Map();
1294
+ for (const line of lines) {
1295
+ counts.set(line, (counts.get(line) ?? 0) + 1);
1296
+ }
1297
+ return counts;
1266
1298
  }
1267
1299
  function tryResolveConflict(oursLines, baseLines, theirsLines) {
1268
1300
  if (baseLines.length === 0) {
@@ -1457,6 +1489,19 @@ var ReplayApplicator = class {
1457
1489
  renameCache = /* @__PURE__ */ new Map();
1458
1490
  treeExistsCache = /* @__PURE__ */ new Map();
1459
1491
  fileTheirsAccumulator = /* @__PURE__ */ new Map();
1492
+ /**
1493
+ * Apply mode for the current `applyPatches` invocation. Set at the start
1494
+ * of `applyPatches`, read by `mergeFile` to decide:
1495
+ * - whether to skip the intra-loop marker strip (kept in `applyPatches`)
1496
+ * - whether to populate the accumulator after a conflicted merge
1497
+ * (resolve mode populates so subsequent patches on the same file
1498
+ * can use patch A's THEIRS as a structurally-correct merge base
1499
+ * when their diff was authored against post-A structure)
1500
+ * - whether to retry THEIRS reconstruction against the accumulator
1501
+ * when the BASE-relative reconstruction produced markers from a
1502
+ * cross-patch context mismatch
1503
+ */
1504
+ currentApplyMode = "replay";
1460
1505
  constructor(git, lockManager, outputDir) {
1461
1506
  this.git = git;
1462
1507
  this.lockManager = lockManager;
@@ -1504,8 +1549,23 @@ var ReplayApplicator = class {
1504
1549
  /**
1505
1550
  * Apply all patches, returning results for each.
1506
1551
  * Skips patches that match exclude patterns in replay.yml
1552
+ *
1553
+ * `applyMode` controls the post-conflict marker strategy:
1554
+ * - `"replay"` (default): strip conflict markers from disk between
1555
+ * iterations whenever a later patch touches the same file. Lets the
1556
+ * follow-up patch's `git apply --3way` see clean OURS content,
1557
+ * which is what the silent-loss fix (PR #73) relies on. The replay
1558
+ * pipeline calls `revertConflictingFiles` after the loop to clean
1559
+ * any markers that survive.
1560
+ * - `"resolve"`: keep markers on disk. The resolve command needs the
1561
+ * customer to see them. Slow-path 3-way merge naturally preserves
1562
+ * A's markers and B's clean writes when their regions don't overlap;
1563
+ * when they do overlap, the customer gets nested markers and
1564
+ * resolves manually. Either way they aren't silently dropped.
1507
1565
  */
1508
- async applyPatches(patches) {
1566
+ async applyPatches(patches, opts) {
1567
+ const applyMode = opts?.applyMode ?? "replay";
1568
+ this.currentApplyMode = applyMode;
1509
1569
  this.resetAccumulator();
1510
1570
  const results = [];
1511
1571
  for (let i = 0; i < patches.length; i++) {
@@ -1520,7 +1580,7 @@ var ReplayApplicator = class {
1520
1580
  }
1521
1581
  const result = await this.applyPatchWithFallback(patch);
1522
1582
  results.push(result);
1523
- if (result.status === "conflict" && result.fileResults) {
1583
+ if (applyMode !== "resolve" && result.status === "conflict" && result.fileResults) {
1524
1584
  const laterFiles = /* @__PURE__ */ new Set();
1525
1585
  for (let j = i + 1; j < patches.length; j++) {
1526
1586
  for (const f of patches[j].files) {
@@ -1550,9 +1610,17 @@ var ReplayApplicator = class {
1550
1610
  }
1551
1611
  return results;
1552
1612
  }
1553
- /** Populate accumulator after git apply succeeds. */
1613
+ /**
1614
+ * Populate accumulator after git apply succeeds, AND collect per-file
1615
+ * results including any "dropped context lines" — lines that were
1616
+ * present in BASE and THEIRS (unchanged context) but absent from the
1617
+ * final on-disk state because OURS deleted them. This is the fast-path
1618
+ * counterpart to ThreeWayMerge.computeDroppedContextLines used by the
1619
+ * 3-way slow path; both must surface the same warning.
1620
+ */
1554
1621
  async populateAccumulatorForPatch(patch, baseGen, currentTreeHash) {
1555
- if (!baseGen) return;
1622
+ const fileResults = [];
1623
+ if (!baseGen) return fileResults;
1556
1624
  const tempDir = await mkdtemp(join2(tmpdir(), "replay-acc-"));
1557
1625
  const { GitClient: GitClient2 } = await Promise.resolve().then(() => (init_GitClient(), GitClient_exports));
1558
1626
  const tempGit = new GitClient2(tempDir);
@@ -1565,18 +1633,30 @@ var ReplayApplicator = class {
1565
1633
  const resolvedPath = await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
1566
1634
  const base = await this.git.showFile(baseGen.tree_hash, filePath);
1567
1635
  const theirs = await this.applyPatchToContent(base, patch.patch_content, filePath, tempGit, tempDir);
1568
- const effectiveTheirs = theirs ?? await readFile(join2(this.outputDir, resolvedPath), "utf-8").catch(() => null);
1636
+ const finalOnDisk = await readFile(join2(this.outputDir, resolvedPath), "utf-8").catch(() => null);
1637
+ const effectiveTheirs = theirs ?? finalOnDisk;
1569
1638
  if (effectiveTheirs != null) {
1570
1639
  this.fileTheirsAccumulator.set(resolvedPath, {
1571
1640
  content: effectiveTheirs,
1572
1641
  baseGeneration: patch.base_generation
1573
1642
  });
1574
1643
  }
1644
+ if (base != null && effectiveTheirs != null && finalOnDisk != null) {
1645
+ const dropped = computeDroppedContextLinesForFile(base, effectiveTheirs, finalOnDisk);
1646
+ if (dropped.length > 0) {
1647
+ fileResults.push({
1648
+ file: resolvedPath,
1649
+ status: "merged",
1650
+ droppedContextLines: dropped
1651
+ });
1652
+ }
1653
+ }
1575
1654
  }
1576
1655
  } finally {
1577
1656
  await rm(tempDir, { recursive: true }).catch(() => {
1578
1657
  });
1579
1658
  }
1659
+ return fileResults;
1580
1660
  }
1581
1661
  async applyPatchWithFallback(patch) {
1582
1662
  const baseGen = await this.resolveBaseGeneration(patch.base_generation);
@@ -1607,11 +1687,12 @@ var ReplayApplicator = class {
1607
1687
  }
1608
1688
  try {
1609
1689
  await this.git.execWithInput(["apply", "--3way"], patch.patch_content);
1610
- await this.populateAccumulatorForPatch(patch, baseGen, currentTreeHash);
1690
+ const fastPathFileResults = await this.populateAccumulatorForPatch(patch, baseGen, currentTreeHash);
1611
1691
  return {
1612
1692
  patch,
1613
1693
  status: "applied",
1614
1694
  method: "git-am",
1695
+ ...fastPathFileResults.length > 0 && { fileResults: fastPathFileResults },
1615
1696
  ...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
1616
1697
  };
1617
1698
  } catch {
@@ -1723,19 +1804,23 @@ var ReplayApplicator = class {
1723
1804
  renameSourcePath
1724
1805
  );
1725
1806
  }
1807
+ const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
1726
1808
  if (theirs) {
1727
1809
  const theirsHasMarkers = theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization");
1728
1810
  const baseHasMarkers = base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization"));
1729
1811
  if (theirsHasMarkers && !baseHasMarkers) {
1730
- return {
1731
- file: resolvedPath,
1732
- status: "skipped",
1733
- reason: "stale-conflict-markers"
1734
- };
1812
+ if (accumulatorEntry) {
1813
+ theirs = null;
1814
+ } else {
1815
+ return {
1816
+ file: resolvedPath,
1817
+ status: "skipped",
1818
+ reason: "stale-conflict-markers"
1819
+ };
1820
+ }
1735
1821
  }
1736
1822
  }
1737
1823
  let useAccumulatorAsMergeBase = false;
1738
- const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
1739
1824
  if (accumulatorEntry && (theirs == null || base == null)) {
1740
1825
  theirs = await this.applyPatchToContent(
1741
1826
  accumulatorEntry.content,
@@ -1842,7 +1927,8 @@ var ReplayApplicator = class {
1842
1927
  const outDir = dirname2(oursPath);
1843
1928
  await mkdir(outDir, { recursive: true });
1844
1929
  await writeFile(oursPath, merged.content);
1845
- if (effective_theirs != null && !merged.hasConflicts) {
1930
+ const populateAccumulator = effective_theirs != null && (!merged.hasConflicts || this.currentApplyMode === "resolve");
1931
+ if (populateAccumulator) {
1846
1932
  this.fileTheirsAccumulator.set(resolvedPath, {
1847
1933
  content: effective_theirs,
1848
1934
  baseGeneration: patch.base_generation
@@ -1857,7 +1943,11 @@ var ReplayApplicator = class {
1857
1943
  conflictMetadata: metadata
1858
1944
  };
1859
1945
  }
1860
- return { file: resolvedPath, status: "merged" };
1946
+ return {
1947
+ file: resolvedPath,
1948
+ status: "merged",
1949
+ ...merged.droppedContextLines && merged.droppedContextLines.length > 0 ? { droppedContextLines: merged.droppedContextLines } : {}
1950
+ };
1861
1951
  } catch (error) {
1862
1952
  return {
1863
1953
  file: filePath,
@@ -2043,6 +2133,30 @@ function isBinaryFile(filePath) {
2043
2133
  const ext = extname(filePath).toLowerCase();
2044
2134
  return BINARY_EXTENSIONS.has(ext);
2045
2135
  }
2136
+ function computeDroppedContextLinesForFile(base, theirs, finalContent) {
2137
+ const baseLines = base.split("\n");
2138
+ const theirsLines = theirs.split("\n");
2139
+ const finalLines = finalContent.split("\n");
2140
+ const baseCounts = /* @__PURE__ */ new Map();
2141
+ const theirsCounts = /* @__PURE__ */ new Map();
2142
+ const finalCounts = /* @__PURE__ */ new Map();
2143
+ for (const l of baseLines) baseCounts.set(l, (baseCounts.get(l) ?? 0) + 1);
2144
+ for (const l of theirsLines) theirsCounts.set(l, (theirsCounts.get(l) ?? 0) + 1);
2145
+ for (const l of finalLines) finalCounts.set(l, (finalCounts.get(l) ?? 0) + 1);
2146
+ const dropped = [];
2147
+ const seen = /* @__PURE__ */ new Set();
2148
+ for (const line of baseLines) {
2149
+ if (seen.has(line)) continue;
2150
+ const inBase = baseCounts.get(line) ?? 0;
2151
+ const inTheirs = theirsCounts.get(line) ?? 0;
2152
+ const inFinal = finalCounts.get(line) ?? 0;
2153
+ if (inBase > 0 && inTheirs > 0 && inFinal < Math.min(inBase, inTheirs)) {
2154
+ dropped.push(line);
2155
+ seen.add(line);
2156
+ }
2157
+ }
2158
+ return dropped;
2159
+ }
2046
2160
  function isDiffLineForFile(diffLine, filePath) {
2047
2161
  const match = diffLine.match(/^diff --git a\/.+ b\/(.+)$/);
2048
2162
  return match !== null && match[1] === filePath;
@@ -2078,13 +2192,46 @@ CLI Version: ${options.cliVersion}`;
2078
2192
  await this.git.exec(["commit", "-m", fullMessage]);
2079
2193
  return (await this.git.exec(["rev-parse", "HEAD"])).trim();
2080
2194
  }
2081
- async commitReplay(_patchCount, patches, message) {
2195
+ async commitReplay(_patchCount, patches, message, options) {
2082
2196
  await this.stageAll();
2083
2197
  if (!await this.hasStagedChanges()) {
2084
2198
  return (await this.git.exec(["rev-parse", "HEAD"])).trim();
2085
2199
  }
2086
2200
  let fullMessage = message ?? `[fern-replay] Applied customizations`;
2087
- if (patches && patches.length > 0) {
2201
+ const buckets = options?.buckets;
2202
+ if (buckets) {
2203
+ if (buckets.applied.length > 0) {
2204
+ fullMessage += `
2205
+
2206
+ Patches applied (${buckets.applied.length}):`;
2207
+ for (const p of buckets.applied) {
2208
+ fullMessage += `
2209
+ - ${p.id}: ${p.original_message}`;
2210
+ }
2211
+ }
2212
+ if (buckets.unresolved.length > 0) {
2213
+ fullMessage += `
2214
+
2215
+ Patches with unresolved conflicts (${buckets.unresolved.length}):`;
2216
+ for (const p of buckets.unresolved) {
2217
+ fullMessage += `
2218
+ - ${p.id}: ${p.original_message}`;
2219
+ }
2220
+ fullMessage += `
2221
+ Run \`fern-replay resolve\` to apply these customizations.`;
2222
+ }
2223
+ if (buckets.absorbed.length > 0) {
2224
+ fullMessage += `
2225
+
2226
+ Patches absorbed by generator (${buckets.absorbed.length}):`;
2227
+ for (const p of buckets.absorbed) {
2228
+ fullMessage += `
2229
+ - ${p.id}: ${p.original_message}`;
2230
+ }
2231
+ fullMessage += `
2232
+ The generator now produces these customizations natively.`;
2233
+ }
2234
+ } else if (patches && patches.length > 0) {
2088
2235
  fullMessage += "\n\nPatches replayed:";
2089
2236
  for (const patch of patches) {
2090
2237
  fullMessage += `
@@ -2120,9 +2267,303 @@ CLI Version: ${options.cliVersion}`;
2120
2267
 
2121
2268
  // src/ReplayService.ts
2122
2269
  import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
2123
- import { join as join3 } from "path";
2270
+ import { join as join4 } from "path";
2124
2271
  import { minimatch as minimatch2 } from "minimatch";
2125
2272
  init_GitClient();
2273
+
2274
+ // src/PatchRegionDiff.ts
2275
+ init_HybridReconstruction();
2276
+ import { mkdtemp as mkdtemp2, writeFile as writeFile2, rm as rm2 } from "fs/promises";
2277
+ import { tmpdir as tmpdir2 } from "os";
2278
+ import { join as join3 } from "path";
2279
+ import { spawn } from "child_process";
2280
+ function normalizeHunkBody(hunk) {
2281
+ let contextC = 0;
2282
+ let removeC = 0;
2283
+ let addC = 0;
2284
+ const trimmed = [];
2285
+ for (const line of hunk.lines) {
2286
+ const oldUsed = contextC + removeC;
2287
+ const newUsed = contextC + addC;
2288
+ if (oldUsed >= hunk.oldCount && newUsed >= hunk.newCount) break;
2289
+ trimmed.push(line);
2290
+ if (line.type === "context") contextC++;
2291
+ else if (line.type === "remove") removeC++;
2292
+ else if (line.type === "add") addC++;
2293
+ }
2294
+ return { ...hunk, lines: trimmed };
2295
+ }
2296
+ function extractFileDiffForFile(patchContent, filePath) {
2297
+ const lines = patchContent.split("\n");
2298
+ const out = [];
2299
+ let inTarget = false;
2300
+ for (const line of lines) {
2301
+ if (line.startsWith("diff --git")) {
2302
+ if (inTarget) break;
2303
+ if (isDiffLineForFile2(line, filePath)) {
2304
+ inTarget = true;
2305
+ out.push(line);
2306
+ }
2307
+ continue;
2308
+ }
2309
+ if (inTarget) out.push(line);
2310
+ }
2311
+ return out.length > 0 ? out.join("\n") + "\n" : null;
2312
+ }
2313
+ function isDiffLineForFile2(line, filePath) {
2314
+ const match = line.match(/^diff --git a\/(.+) b\/(.+)$/);
2315
+ return match !== null && (match[2] === filePath || match[1] === filePath);
2316
+ }
2317
+ async function computePerPatchDiff(patch, getCurrentGenContent, getWorkingTreeContent) {
2318
+ const fragments = [];
2319
+ const unlocatableFiles = [];
2320
+ for (const file of patch.files) {
2321
+ const fileDiff = extractFileDiffForFile(patch.patch_content, file);
2322
+ if (fileDiff == null) {
2323
+ unlocatableFiles.push(file);
2324
+ continue;
2325
+ }
2326
+ const hunksRaw = parseHunks(fileDiff).map(normalizeHunkBody);
2327
+ const hunks = hunksRaw.filter((h) => {
2328
+ if (h.lines.length === 0) return false;
2329
+ let ctx = 0, rm3 = 0, add = 0;
2330
+ for (const ln of h.lines) {
2331
+ if (ln.type === "context") ctx++;
2332
+ else if (ln.type === "remove") rm3++;
2333
+ else if (ln.type === "add") add++;
2334
+ }
2335
+ return ctx + rm3 === h.oldCount && ctx + add === h.newCount;
2336
+ });
2337
+ if (hunks.length === 0) {
2338
+ if (hunksRaw.length > 0) unlocatableFiles.push(file);
2339
+ continue;
2340
+ }
2341
+ const currentGen = await getCurrentGenContent(file);
2342
+ const workingTree = await getWorkingTreeContent(file);
2343
+ const isPureNewFile = hunks.every((h) => h.oldCount === 0);
2344
+ const isPureDelete = hunks.every((h) => h.newCount === 0);
2345
+ if (currentGen == null) {
2346
+ if (!isPureNewFile) {
2347
+ unlocatableFiles.push(file);
2348
+ continue;
2349
+ }
2350
+ if (workingTree == null) continue;
2351
+ fragments.push(synthesizeNewFileDiff(file, workingTree));
2352
+ continue;
2353
+ }
2354
+ if (isPureNewFile) {
2355
+ if (workingTree == null) {
2356
+ fragments.push(synthesizeDeleteFileDiff(file, currentGen));
2357
+ continue;
2358
+ }
2359
+ if (workingTree === currentGen) {
2360
+ continue;
2361
+ }
2362
+ const fullDiff = await unifiedDiff(currentGen, workingTree, file);
2363
+ if (fullDiff && fullDiff.trim()) fragments.push(fullDiff);
2364
+ continue;
2365
+ }
2366
+ if (workingTree == null) {
2367
+ if (!isPureDelete) {
2368
+ unlocatableFiles.push(file);
2369
+ continue;
2370
+ }
2371
+ fragments.push(synthesizeDeleteFileDiff(file, currentGen));
2372
+ continue;
2373
+ }
2374
+ const currentGenLines = splitLines(currentGen);
2375
+ const workingTreeLines = splitLines(workingTree);
2376
+ const locInCurrentGenRaw = locateHunksInOurs(hunks, currentGenLines);
2377
+ const locInWorkingTreeRaw = locateHunksInOurs(hunks, workingTreeLines);
2378
+ if (locInCurrentGenRaw == null || locInWorkingTreeRaw == null) {
2379
+ unlocatableFiles.push(file);
2380
+ continue;
2381
+ }
2382
+ const locInCurrentGen = locInCurrentGenRaw.map(
2383
+ (l) => resolveSpan(l, currentGenLines, "old")
2384
+ );
2385
+ const locInWorkingTree = locInWorkingTreeRaw.map(
2386
+ (l) => resolveSpan(l, workingTreeLines, "new")
2387
+ );
2388
+ const overlay = overlayRegions(
2389
+ currentGenLines,
2390
+ locInCurrentGen,
2391
+ workingTreeLines,
2392
+ locInWorkingTree
2393
+ );
2394
+ if (overlay === null) {
2395
+ unlocatableFiles.push(file);
2396
+ continue;
2397
+ }
2398
+ if (overlay === currentGen) {
2399
+ continue;
2400
+ }
2401
+ const fileUnifiedDiff = await unifiedDiff(currentGen, overlay, file);
2402
+ if (fileUnifiedDiff && fileUnifiedDiff.trim()) {
2403
+ fragments.push(fileUnifiedDiff);
2404
+ }
2405
+ }
2406
+ return {
2407
+ diff: fragments.join(""),
2408
+ unlocatableFiles
2409
+ };
2410
+ }
2411
+ async function computePerPatchDiffWithFallback(patch, getCurrentGenContent, getWorkingTreeContent, runCumulativeDiff) {
2412
+ const ppDiff = await computePerPatchDiff(
2413
+ patch,
2414
+ getCurrentGenContent,
2415
+ getWorkingTreeContent
2416
+ );
2417
+ if (ppDiff.unlocatableFiles.length === 0) {
2418
+ return { diff: ppDiff.diff, hadFallback: false, fallbackFiles: [] };
2419
+ }
2420
+ const cumulative = await runCumulativeDiff(ppDiff.unlocatableFiles);
2421
+ if (cumulative === null) {
2422
+ return null;
2423
+ }
2424
+ const merged = ppDiff.diff + (cumulative.trim() ? cumulative : "");
2425
+ return { diff: merged, hadFallback: true, fallbackFiles: ppDiff.unlocatableFiles };
2426
+ }
2427
+ function splitLines(content) {
2428
+ return content.split("\n");
2429
+ }
2430
+ function resolveSpan(loc, oursLines, prefer) {
2431
+ const { hunk, oursOffset } = loc;
2432
+ const oldSide = [];
2433
+ const newSide = [];
2434
+ for (const line of hunk.lines) {
2435
+ if (line.type === "context") {
2436
+ oldSide.push(line.content);
2437
+ newSide.push(line.content);
2438
+ } else if (line.type === "remove") {
2439
+ oldSide.push(line.content);
2440
+ } else if (line.type === "add") {
2441
+ newSide.push(line.content);
2442
+ }
2443
+ }
2444
+ const oldFits = sequenceMatches(oldSide, oursLines, oursOffset);
2445
+ const newFits = sequenceMatches(newSide, oursLines, oursOffset);
2446
+ if (prefer === "old") {
2447
+ if (oldFits) return { ...loc, oursSpan: hunk.oldCount };
2448
+ if (newFits) return { ...loc, oursSpan: hunk.newCount };
2449
+ } else {
2450
+ if (newFits) return { ...loc, oursSpan: hunk.newCount };
2451
+ if (oldFits) return { ...loc, oursSpan: hunk.oldCount };
2452
+ }
2453
+ return loc;
2454
+ }
2455
+ function sequenceMatches(needle, haystack, offset) {
2456
+ if (offset + needle.length > haystack.length) return false;
2457
+ for (let i = 0; i < needle.length; i++) {
2458
+ if (haystack[offset + i] !== needle[i]) return false;
2459
+ }
2460
+ return true;
2461
+ }
2462
+ function overlayRegions(currentGenLines, locInCurrentGen, workingTreeLines, locInWorkingTree) {
2463
+ if (locInCurrentGen.length !== locInWorkingTree.length) {
2464
+ return null;
2465
+ }
2466
+ const out = [];
2467
+ let cursor = 0;
2468
+ for (let i = 0; i < locInCurrentGen.length; i++) {
2469
+ const cg = locInCurrentGen[i];
2470
+ const wt = locInWorkingTree[i];
2471
+ if (cg.oursOffset > cursor) {
2472
+ out.push(...currentGenLines.slice(cursor, cg.oursOffset));
2473
+ }
2474
+ out.push(...workingTreeLines.slice(wt.oursOffset, wt.oursOffset + wt.oursSpan));
2475
+ cursor = cg.oursOffset + cg.oursSpan;
2476
+ }
2477
+ if (cursor < currentGenLines.length) {
2478
+ out.push(...currentGenLines.slice(cursor));
2479
+ }
2480
+ return out.join("\n");
2481
+ }
2482
+ async function unifiedDiff(beforeContent, afterContent, filePath) {
2483
+ if (beforeContent === afterContent) return "";
2484
+ const tmp = await mkdtemp2(join3(tmpdir2(), "fern-replay-pp-"));
2485
+ try {
2486
+ const beforePath = join3(tmp, "before");
2487
+ const afterPath = join3(tmp, "after");
2488
+ await writeFile2(beforePath, beforeContent, "utf-8");
2489
+ await writeFile2(afterPath, afterContent, "utf-8");
2490
+ const raw = await runGitDiffNoIndex(beforePath, afterPath);
2491
+ if (!raw.trim()) return "";
2492
+ return rewriteDiffHeaders(raw, filePath);
2493
+ } finally {
2494
+ await rm2(tmp, { recursive: true, force: true });
2495
+ }
2496
+ }
2497
+ function runGitDiffNoIndex(beforePath, afterPath) {
2498
+ return new Promise((resolve2, reject) => {
2499
+ const proc = spawn("git", ["diff", "--no-index", "--", beforePath, afterPath]);
2500
+ let stdout = "";
2501
+ let stderr = "";
2502
+ proc.stdout.on("data", (d) => stdout += d.toString());
2503
+ proc.stderr.on("data", (d) => stderr += d.toString());
2504
+ proc.on("close", (code) => {
2505
+ if (code === 0 || code === 1) resolve2(stdout);
2506
+ else reject(new Error(`git diff --no-index failed (code ${code}): ${stderr}`));
2507
+ });
2508
+ });
2509
+ }
2510
+ function rewriteDiffHeaders(raw, filePath) {
2511
+ const lines = raw.split("\n");
2512
+ const out = [];
2513
+ let seenFirstHeader = false;
2514
+ for (const line of lines) {
2515
+ if (line.startsWith("diff --git ")) {
2516
+ out.push(`diff --git a/${filePath} b/${filePath}`);
2517
+ seenFirstHeader = true;
2518
+ continue;
2519
+ }
2520
+ if (!seenFirstHeader) {
2521
+ continue;
2522
+ }
2523
+ if (line.startsWith("--- ")) {
2524
+ out.push(`--- a/${filePath}`);
2525
+ continue;
2526
+ }
2527
+ if (line.startsWith("+++ ")) {
2528
+ out.push(`+++ b/${filePath}`);
2529
+ continue;
2530
+ }
2531
+ out.push(line);
2532
+ }
2533
+ return out.join("\n");
2534
+ }
2535
+ function synthesizeNewFileDiff(file, content) {
2536
+ const lines = content.split("\n");
2537
+ const hasTrailingNewline = content.endsWith("\n");
2538
+ const bodyLines = hasTrailingNewline ? lines.slice(0, -1) : lines;
2539
+ const header = [
2540
+ `diff --git a/${file} b/${file}`,
2541
+ "new file mode 100644",
2542
+ "--- /dev/null",
2543
+ `+++ b/${file}`,
2544
+ `@@ -0,0 +1,${bodyLines.length} @@`
2545
+ ].join("\n");
2546
+ const body = bodyLines.map((l) => `+${l}`).join("\n");
2547
+ const trailer = hasTrailingNewline ? "" : "\n\";
2548
+ return header + "\n" + body + trailer + "\n";
2549
+ }
2550
+ function synthesizeDeleteFileDiff(file, content) {
2551
+ const lines = content.split("\n");
2552
+ const hasTrailingNewline = content.endsWith("\n");
2553
+ const bodyLines = hasTrailingNewline ? lines.slice(0, -1) : lines;
2554
+ const header = [
2555
+ `diff --git a/${file} b/${file}`,
2556
+ "deleted file mode 100644",
2557
+ `--- a/${file}`,
2558
+ "+++ /dev/null",
2559
+ `@@ -1,${bodyLines.length} +0,0 @@`
2560
+ ].join("\n");
2561
+ const body = bodyLines.map((l) => `-${l}`).join("\n");
2562
+ const trailer = hasTrailingNewline ? "" : "\n\";
2563
+ return header + "\n" + body + trailer + "\n";
2564
+ }
2565
+
2566
+ // src/ReplayService.ts
2126
2567
  var ReplayService = class {
2127
2568
  git;
2128
2569
  detector;
@@ -2516,6 +2957,7 @@ var ReplayService = class {
2516
2957
  if (newPatches.length > 0) {
2517
2958
  results = await this.applicator.applyPatches(newPatches);
2518
2959
  this._lastApplyResults = results;
2960
+ this.recordDroppedContextLineWarnings(results);
2519
2961
  this.revertConflictingFiles(results);
2520
2962
  for (const result of results) {
2521
2963
  if (result.status === "conflict") {
@@ -2534,7 +2976,8 @@ var ReplayService = class {
2534
2976
  if (newPatches.length > 0) {
2535
2977
  if (!options?.stageOnly) {
2536
2978
  const appliedCount = results.filter((r) => r.status === "applied").length;
2537
- await this.committer.commitReplay(appliedCount, newPatches);
2979
+ const buckets = computeCommitBuckets(results, rebaseCounts.absorbedPatchIds, newPatches);
2980
+ await this.committer.commitReplay(appliedCount, newPatches, void 0, { buckets });
2538
2981
  } else {
2539
2982
  await this.committer.stageAll();
2540
2983
  }
@@ -2597,12 +3040,21 @@ var ReplayService = class {
2597
3040
  (p) => preRebasePatchIds.has(p.id) && !postRebasePatchIds.has(p.id)
2598
3041
  );
2599
3042
  existingPatches = this.lockManager.getPatches();
2600
- const seenHashes = /* @__PURE__ */ new Set();
3043
+ const seenHashes = /* @__PURE__ */ new Map();
2601
3044
  for (const p of existingPatches) {
2602
- if (seenHashes.has(p.content_hash)) {
2603
- this.lockManager.removePatch(p.id);
3045
+ const priorOrigin = seenHashes.get(p.content_hash);
3046
+ if (priorOrigin !== void 0) {
3047
+ const provenanceMissing = !priorOrigin || !p.original_commit;
3048
+ const sameOrigin = priorOrigin === p.original_commit;
3049
+ if (sameOrigin && !provenanceMissing) {
3050
+ this.lockManager.removePatch(p.id);
3051
+ } else {
3052
+ this.warnings.push(
3053
+ `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).`
3054
+ );
3055
+ }
2604
3056
  } else {
2605
- seenHashes.add(p.content_hash);
3057
+ seenHashes.set(p.content_hash, p.original_commit);
2606
3058
  }
2607
3059
  }
2608
3060
  existingPatches = this.lockManager.getPatches();
@@ -2663,6 +3115,7 @@ var ReplayService = class {
2663
3115
  const genSha = prep._prepared.genSha;
2664
3116
  const results = await this.applicator.applyPatches(allPatches);
2665
3117
  this._lastApplyResults = results;
3118
+ this.recordDroppedContextLineWarnings(results);
2666
3119
  this.revertConflictingFiles(results);
2667
3120
  for (const result of results) {
2668
3121
  if (result.status === "conflict") {
@@ -2690,7 +3143,8 @@ var ReplayService = class {
2690
3143
  await this.committer.stageAll();
2691
3144
  } else {
2692
3145
  const appliedCount = results.filter((r) => r.status === "applied").length;
2693
- await this.committer.commitReplay(appliedCount, allPatches);
3146
+ const buckets = computeCommitBuckets(results, rebaseCounts.absorbedPatchIds, allPatches);
3147
+ await this.committer.commitReplay(appliedCount, allPatches, void 0, { buckets });
2694
3148
  }
2695
3149
  const warnings = [...detectorWarnings, ...this.warnings];
2696
3150
  return this.buildReport(
@@ -2714,7 +3168,7 @@ var ReplayService = class {
2714
3168
  let repointed = 0;
2715
3169
  let contentRebased = 0;
2716
3170
  let keptAsUserOwned = 0;
2717
- const seenContentHashes = /* @__PURE__ */ new Set();
3171
+ const seenContentHashes = /* @__PURE__ */ new Map();
2718
3172
  const absorbedPatchIds = /* @__PURE__ */ new Set();
2719
3173
  const fernignorePatterns = this.readFernignorePatterns();
2720
3174
  for (const result of results) {
@@ -2728,6 +3182,23 @@ var ReplayService = class {
2728
3182
  }
2729
3183
  }
2730
3184
  }
3185
+ const conflictedFilePaths = /* @__PURE__ */ new Set();
3186
+ for (const r of results) {
3187
+ if (r.status !== "conflict") continue;
3188
+ if (r.fileResults) {
3189
+ for (const fr of r.fileResults) {
3190
+ if (fr.status !== "conflict") continue;
3191
+ conflictedFilePaths.add(fr.file);
3192
+ if (r.resolvedFiles) {
3193
+ for (const [orig, resolved] of Object.entries(r.resolvedFiles)) {
3194
+ if (resolved === fr.file) conflictedFilePaths.add(orig);
3195
+ }
3196
+ }
3197
+ }
3198
+ } else {
3199
+ for (const f of r.patch.files) conflictedFilePaths.add(f);
3200
+ }
3201
+ }
2731
3202
  for (const result of results) {
2732
3203
  if (result.status === "conflict" && result.fileResults) {
2733
3204
  await this.trimAbsorbedFiles(result, currentGenSha);
@@ -2795,6 +3266,18 @@ var ReplayService = class {
2795
3266
  keptAsUserOwned++;
2796
3267
  continue;
2797
3268
  }
3269
+ const overlapsConflicted = patch.files.some((f) => conflictedFilePaths.has(f));
3270
+ if (overlapsConflicted) {
3271
+ patch.status = "unresolved";
3272
+ try {
3273
+ this.lockManager.updatePatch(patch.id, { status: "unresolved" });
3274
+ } catch {
3275
+ }
3276
+ this.warnings.push(
3277
+ `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.`
3278
+ );
3279
+ continue;
3280
+ }
2798
3281
  this.lockManager.removePatch(patch.id);
2799
3282
  absorbedPatchIds.add(patch.id);
2800
3283
  absorbed++;
@@ -2810,13 +3293,22 @@ var ReplayService = class {
2810
3293
  continue;
2811
3294
  }
2812
3295
  const newContentHash = this.detector.computeContentHash(diff);
2813
- if (seenContentHashes.has(newContentHash)) {
2814
- this.lockManager.removePatch(patch.id);
2815
- absorbedPatchIds.add(patch.id);
2816
- absorbed++;
2817
- continue;
3296
+ const priorOrigin = seenContentHashes.get(newContentHash);
3297
+ if (priorOrigin !== void 0) {
3298
+ const provenanceMissing = !priorOrigin || !patch.original_commit;
3299
+ const sameOrigin = priorOrigin === patch.original_commit;
3300
+ if (sameOrigin && !provenanceMissing) {
3301
+ this.lockManager.removePatch(patch.id);
3302
+ absorbedPatchIds.add(patch.id);
3303
+ absorbed++;
3304
+ continue;
3305
+ }
3306
+ this.warnings.push(
3307
+ `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).`
3308
+ );
3309
+ } else {
3310
+ seenContentHashes.set(newContentHash, patch.original_commit);
2818
3311
  }
2819
- seenContentHashes.add(newContentHash);
2820
3312
  patch.base_generation = currentGenSha;
2821
3313
  patch.patch_content = diff;
2822
3314
  patch.content_hash = newContentHash;
@@ -2992,8 +3484,14 @@ var ReplayService = class {
2992
3484
  }
2993
3485
  if (patch.base_generation === currentGen) {
2994
3486
  try {
2995
- const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
2996
- if (diff === null) continue;
3487
+ const ppResult = await computePerPatchDiffWithFallback(
3488
+ patch,
3489
+ (f) => this.git.showFile(currentGen, f),
3490
+ (f) => this.git.showFile("HEAD", f),
3491
+ (files) => this.git.exec(["diff", currentGen, "HEAD", "--", ...files]).catch(() => null)
3492
+ );
3493
+ if (ppResult === null) continue;
3494
+ const diff = ppResult.diff;
2997
3495
  if (!diff.trim()) {
2998
3496
  if (await allPatchFilesUserOwned(patch)) continue;
2999
3497
  this.lockManager.removePatch(patch.id);
@@ -3035,8 +3533,14 @@ var ReplayService = class {
3035
3533
  try {
3036
3534
  const markerFiles = await this.git.exec(["grep", "-l", "<<<<<<< Generated", "HEAD", "--", ...patch.files]).catch(() => "");
3037
3535
  if (markerFiles.trim()) continue;
3038
- const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
3039
- if (diff === null) continue;
3536
+ const ppResult = await computePerPatchDiffWithFallback(
3537
+ patch,
3538
+ (f) => this.git.showFile(currentGen, f),
3539
+ (f) => this.git.showFile("HEAD", f),
3540
+ (files) => this.git.exec(["diff", currentGen, "HEAD", "--", ...files]).catch(() => null)
3541
+ );
3542
+ if (ppResult === null) continue;
3543
+ const diff = ppResult.diff;
3040
3544
  if (!diff.trim()) {
3041
3545
  if (await allPatchFilesUserOwned(patch)) {
3042
3546
  try {
@@ -3061,6 +3565,32 @@ var ReplayService = class {
3061
3565
  }
3062
3566
  return { conflictResolved, conflictAbsorbed, contentRefreshed };
3063
3567
  }
3568
+ /**
3569
+ * After applyPatches(), surface a warning for each (patch × file) where
3570
+ * diff3 silently dropped lines that were unchanged context in the
3571
+ * customer's THEIRS. The deletion stays on disk (correct diff3 outcome),
3572
+ * but the customer must learn so they can re-add the lines if they
3573
+ * relied on them. This is the "surface or preserve, never silently drop"
3574
+ * contract for legitimate-deletion cases.
3575
+ */
3576
+ recordDroppedContextLineWarnings(results) {
3577
+ const PREVIEW_COUNT = 5;
3578
+ for (const result of results) {
3579
+ if (!result.fileResults) continue;
3580
+ for (const fr of result.fileResults) {
3581
+ const dropped = fr.droppedContextLines;
3582
+ if (!dropped || dropped.length === 0) continue;
3583
+ const preview = dropped.slice(0, PREVIEW_COUNT).map((line) => ` ${line}`).join("\n");
3584
+ const more = dropped.length > PREVIEW_COUNT ? `
3585
+ ... and ${dropped.length - PREVIEW_COUNT} more` : "";
3586
+ this.warnings.push(
3587
+ `${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:
3588
+ ${preview}${more}
3589
+ 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.`
3590
+ );
3591
+ }
3592
+ }
3593
+ }
3064
3594
  /**
3065
3595
  * After applyPatches(), strip conflict markers from conflicting files
3066
3596
  * so only clean content is committed. Keeps the Generated (OURS) side.
@@ -3070,7 +3600,7 @@ var ReplayService = class {
3070
3600
  if (result.status !== "conflict" || !result.fileResults) continue;
3071
3601
  for (const fileResult of result.fileResults) {
3072
3602
  if (fileResult.status !== "conflict") continue;
3073
- const filePath = join3(this.outputDir, fileResult.file);
3603
+ const filePath = join4(this.outputDir, fileResult.file);
3074
3604
  try {
3075
3605
  const content = readFileSync2(filePath, "utf-8");
3076
3606
  const stripped = stripConflictMarkers(content);
@@ -3100,7 +3630,7 @@ var ReplayService = class {
3100
3630
  }
3101
3631
  }
3102
3632
  readFernignorePatterns() {
3103
- const fernignorePath = join3(this.outputDir, ".fernignore");
3633
+ const fernignorePath = join4(this.outputDir, ".fernignore");
3104
3634
  if (!existsSync2(fernignorePath)) return [];
3105
3635
  return readFileSync2(fernignorePath, "utf-8").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
3106
3636
  }
@@ -3118,6 +3648,9 @@ var ReplayService = class {
3118
3648
  };
3119
3649
  }).filter((d) => d.files.length > 0);
3120
3650
  const partialCount = conflictDetails.filter((d) => d.cleanFiles && d.cleanFiles.length > 0).length;
3651
+ const unresolvedResults = results.filter(
3652
+ (r) => r.status === "conflict" || r.patch.status === "unresolved"
3653
+ );
3121
3654
  return {
3122
3655
  flow,
3123
3656
  patchesDetected: patches.length,
@@ -3134,7 +3667,7 @@ var ReplayService = class {
3134
3667
  patchesRefreshed: preRebaseCounts && preRebaseCounts.contentRefreshed > 0 ? preRebaseCounts.contentRefreshed : void 0,
3135
3668
  conflicts: conflictResults.flatMap((r) => r.fileResults?.filter((f) => f.status === "conflict") ?? []),
3136
3669
  conflictDetails: conflictDetails.length > 0 ? conflictDetails : void 0,
3137
- unresolvedPatches: conflictResults.length > 0 ? conflictResults.map((r) => ({
3670
+ unresolvedPatches: unresolvedResults.length > 0 ? unresolvedResults.map((r) => ({
3138
3671
  patchId: r.patch.id,
3139
3672
  patchMessage: r.patch.original_message,
3140
3673
  files: r.patch.files,
@@ -3145,11 +3678,38 @@ var ReplayService = class {
3145
3678
  };
3146
3679
  }
3147
3680
  };
3681
+ function computeCommitBuckets(results, absorbedPatchIds, patchesPassedToCommit) {
3682
+ const resultByPatchId = /* @__PURE__ */ new Map();
3683
+ for (const r of results) resultByPatchId.set(r.patch.id, r);
3684
+ const applied = [];
3685
+ const unresolved = [];
3686
+ const absorbed = [];
3687
+ for (const patch of patchesPassedToCommit) {
3688
+ if (absorbedPatchIds.has(patch.id)) {
3689
+ absorbed.push(patch);
3690
+ continue;
3691
+ }
3692
+ const r = resultByPatchId.get(patch.id);
3693
+ if (!r) {
3694
+ applied.push(patch);
3695
+ continue;
3696
+ }
3697
+ if (r.status === "conflict" || patch.status === "unresolved") {
3698
+ unresolved.push(patch);
3699
+ continue;
3700
+ }
3701
+ if (r.status === "applied") {
3702
+ applied.push(patch);
3703
+ continue;
3704
+ }
3705
+ }
3706
+ return { applied, unresolved, absorbed };
3707
+ }
3148
3708
 
3149
3709
  // src/FernignoreMigrator.ts
3150
3710
  import { createHash as createHash2 } from "crypto";
3151
3711
  import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, statSync, writeFileSync as writeFileSync3 } from "fs";
3152
- import { dirname as dirname3, join as join4 } from "path";
3712
+ import { dirname as dirname3, join as join5 } from "path";
3153
3713
  import { minimatch as minimatch3 } from "minimatch";
3154
3714
  import { parse as parse2, stringify as stringify2 } from "yaml";
3155
3715
  var FernignoreMigrator = class {
@@ -3162,10 +3722,10 @@ var FernignoreMigrator = class {
3162
3722
  this.outputDir = outputDir;
3163
3723
  }
3164
3724
  fernignoreExists() {
3165
- return existsSync3(join4(this.outputDir, ".fernignore"));
3725
+ return existsSync3(join5(this.outputDir, ".fernignore"));
3166
3726
  }
3167
3727
  readFernignorePatterns() {
3168
- const fernignorePath = join4(this.outputDir, ".fernignore");
3728
+ const fernignorePath = join5(this.outputDir, ".fernignore");
3169
3729
  if (!existsSync3(fernignorePath)) {
3170
3730
  return [];
3171
3731
  }
@@ -3225,7 +3785,16 @@ var FernignoreMigrator = class {
3225
3785
  original_author: "Fern Replay <replay@buildwithfern.com>",
3226
3786
  base_generation: currentGen.commit_sha,
3227
3787
  files: patchFiles,
3228
- patch_content: patchContent
3788
+ patch_content: patchContent,
3789
+ // Synthetic patches captured from .fernignore-protected files
3790
+ // are user-owned by definition — the customer's content
3791
+ // diverges from the generator's pristine output, and the
3792
+ // generator never produced this divergent content. Without
3793
+ // this flag, removing the file from .fernignore later (so
3794
+ // it leaves the protection check in `isFileUserOwned`) would
3795
+ // expose the patch to absorption on the next regen,
3796
+ // silently losing the customization.
3797
+ user_owned: true
3229
3798
  });
3230
3799
  trackedByBoth.push({
3231
3800
  file: pattern,
@@ -3258,7 +3827,7 @@ var FernignoreMigrator = class {
3258
3827
  return results;
3259
3828
  }
3260
3829
  readFileContent(filePath) {
3261
- const fullPath = join4(this.outputDir, filePath);
3830
+ const fullPath = join5(this.outputDir, filePath);
3262
3831
  if (!existsSync3(fullPath)) {
3263
3832
  return null;
3264
3833
  }
@@ -3323,7 +3892,7 @@ var FernignoreMigrator = class {
3323
3892
  };
3324
3893
  }
3325
3894
  movePatternsToReplayYml(patterns) {
3326
- const replayYmlPath = join4(this.outputDir, ".fern", "replay.yml");
3895
+ const replayYmlPath = join5(this.outputDir, ".fern", "replay.yml");
3327
3896
  let config = {};
3328
3897
  if (existsSync3(replayYmlPath)) {
3329
3898
  const content = readFileSync3(replayYmlPath, "utf-8");
@@ -3343,7 +3912,7 @@ var FernignoreMigrator = class {
3343
3912
  // src/commands/bootstrap.ts
3344
3913
  import { createHash as createHash3 } from "crypto";
3345
3914
  import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
3346
- import { join as join5 } from "path";
3915
+ import { join as join6 } from "path";
3347
3916
  init_GitClient();
3348
3917
  async function bootstrap(outputDir, options) {
3349
3918
  const git = new GitClient(outputDir);
@@ -3510,7 +4079,7 @@ function parseGitLog(log) {
3510
4079
  }
3511
4080
  var REPLAY_FERNIGNORE_ENTRIES = [".fern/replay.lock", ".fern/replay.yml", ".gitattributes"];
3512
4081
  function ensureFernignoreEntries(outputDir) {
3513
- const fernignorePath = join5(outputDir, ".fernignore");
4082
+ const fernignorePath = join6(outputDir, ".fernignore");
3514
4083
  let content = "";
3515
4084
  if (existsSync4(fernignorePath)) {
3516
4085
  content = readFileSync4(fernignorePath, "utf-8");
@@ -3534,7 +4103,7 @@ function ensureFernignoreEntries(outputDir) {
3534
4103
  }
3535
4104
  var GITATTRIBUTES_ENTRIES = [".fern/replay.lock linguist-generated=true"];
3536
4105
  function ensureGitattributesEntries(outputDir) {
3537
- const gitattributesPath = join5(outputDir, ".gitattributes");
4106
+ const gitattributesPath = join6(outputDir, ".gitattributes");
3538
4107
  let content = "";
3539
4108
  if (existsSync4(gitattributesPath)) {
3540
4109
  content = readFileSync4(gitattributesPath, "utf-8");
@@ -3733,6 +4302,8 @@ function reset(outputDir, options) {
3733
4302
 
3734
4303
  // src/commands/resolve.ts
3735
4304
  init_GitClient();
4305
+ import { readFile as readFile2 } from "fs/promises";
4306
+ import { join as join7 } from "path";
3736
4307
  async function resolve(outputDir, options) {
3737
4308
  const lockManager = new LockfileManager(outputDir);
3738
4309
  if (!lockManager.exists()) {
@@ -3747,7 +4318,7 @@ async function resolve(outputDir, options) {
3747
4318
  const resolvingPatches = lockManager.getResolvingPatches();
3748
4319
  if (unresolvedPatches.length > 0) {
3749
4320
  const applicator = new ReplayApplicator(git, lockManager, outputDir);
3750
- await applicator.applyPatches(unresolvedPatches);
4321
+ await applicator.applyPatches(unresolvedPatches, { applyMode: "resolve" });
3751
4322
  const markerFiles = await findConflictMarkerFiles(git);
3752
4323
  if (markerFiles.length > 0) {
3753
4324
  for (const patch of unresolvedPatches) {
@@ -3773,20 +4344,38 @@ async function resolve(outputDir, options) {
3773
4344
  if (patchesToCommit.length > 0) {
3774
4345
  const currentGen = lock.current_generation;
3775
4346
  const detector = new ReplayDetector(git, lockManager, outputDir);
4347
+ const warnings = [];
3776
4348
  let patchesResolved = 0;
3777
4349
  for (const patch of patchesToCommit) {
3778
- const diff = await git.exec(["diff", currentGen, "--", ...patch.files]).catch(() => null);
3779
- if (!diff || !diff.trim()) {
4350
+ const result = await computePerPatchDiffWithFallback(
4351
+ patch,
4352
+ (f) => git.showFile(currentGen, f),
4353
+ (f) => readFile2(join7(outputDir, f), "utf-8").catch(() => null),
4354
+ (files) => git.exec(["diff", currentGen, "--", ...files]).catch(() => null)
4355
+ );
4356
+ if (result === null) {
4357
+ warnings.push(
4358
+ `Patch ${patch.id}: cumulative-diff fallback failed for unlocatable files; preserving patch unchanged`
4359
+ );
4360
+ continue;
4361
+ }
4362
+ const diff = result.diff;
4363
+ if (!diff.trim()) {
3780
4364
  lockManager.removePatch(patch.id);
3781
4365
  continue;
3782
4366
  }
4367
+ if (result.hadFallback) {
4368
+ warnings.push(
4369
+ `Patch ${patch.id}: ${result.fallbackFiles.join(", ")} could not be anchored; cumulative-diff fallback applied for those files`
4370
+ );
4371
+ }
3783
4372
  const newContentHash = detector.computeContentHash(diff);
3784
- const changedFiles = await getChangedFiles(git, currentGen, patch.files);
4373
+ const changedFiles = result.hadFallback ? await getChangedFiles(git, currentGen, patch.files) : extractFilesFromDiff(diff);
3785
4374
  lockManager.markPatchResolved(patch.id, {
3786
4375
  patch_content: diff,
3787
4376
  content_hash: newContentHash,
3788
4377
  base_generation: currentGen,
3789
- files: changedFiles
4378
+ files: changedFiles.length > 0 ? changedFiles : patch.files
3790
4379
  });
3791
4380
  patchesResolved++;
3792
4381
  }
@@ -3802,7 +4391,8 @@ async function resolve(outputDir, options) {
3802
4391
  success: true,
3803
4392
  commitSha: commitSha2,
3804
4393
  phase: "committed",
3805
- patchesResolved
4394
+ patchesResolved,
4395
+ warnings: warnings.length > 0 ? warnings : void 0
3806
4396
  };
3807
4397
  }
3808
4398
  const committer = new ReplayCommitter(git, outputDir);
@@ -3820,6 +4410,24 @@ async function getChangedFiles(git, currentGen, files) {
3820
4410
  const changed = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !f.startsWith(".fern/"));
3821
4411
  return changed.length > 0 ? changed : files;
3822
4412
  }
4413
+ function extractFilesFromDiff(unifiedDiff2) {
4414
+ const out = /* @__PURE__ */ new Set();
4415
+ const renameSources = /* @__PURE__ */ new Set();
4416
+ let currentTarget = null;
4417
+ for (const line of unifiedDiff2.split("\n")) {
4418
+ const headerMatch = line.match(/^diff --git a\/(.+?) b\/(.+)$/);
4419
+ if (headerMatch) {
4420
+ currentTarget = headerMatch[2];
4421
+ if (!currentTarget.startsWith(".fern/")) out.add(currentTarget);
4422
+ continue;
4423
+ }
4424
+ if (currentTarget && line.startsWith("rename from ")) {
4425
+ renameSources.add(line.slice("rename from ".length));
4426
+ }
4427
+ }
4428
+ for (const src of renameSources) out.delete(src);
4429
+ return Array.from(out);
4430
+ }
3823
4431
 
3824
4432
  // src/commands/status.ts
3825
4433
  function status(outputDir) {