@fern-api/replay 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.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) => {
@@ -708,17 +708,57 @@ var ReplayDetector = class {
708
708
  }
709
709
  const isAncestor = await this.git.isAncestor(lastGen.commit_sha, "HEAD");
710
710
  if (!isAncestor) {
711
+ return this.detectPatchesViaMergeBase(lastGen, lock);
712
+ }
713
+ return this.detectPatchesInRange(lastGen.commit_sha, lastGen, lock);
714
+ }
715
+ /**
716
+ * FER-10201 — Non-linear-history detection via `merge-base(prevGen, HEAD)`.
717
+ *
718
+ * After a squash-merge of a Fern-bot PR, `lastGen.commit_sha` (the previous
719
+ * `[fern-generated]`) is orphaned but still in git's object database. The
720
+ * merge-base is the commit on main from which the bot's branch was created —
721
+ * a reachable ancestor of HEAD. Walking that range and classifying each
722
+ * commit via `isGenerationCommit` mirrors the linear path and eliminates
723
+ * the bug class where pipeline-driven changes (autoversion, replay,
724
+ * generation) get bundled as customer customizations.
725
+ *
726
+ * Falls through to the legacy composite path when no common ancestor exists
727
+ * (truly disjoint history — orphan branches with no shared root).
728
+ */
729
+ async detectPatchesViaMergeBase(lastGen, lock) {
730
+ let mergeBase = "";
731
+ try {
732
+ mergeBase = (await this.git.exec(["merge-base", lastGen.commit_sha, "HEAD"])).trim();
733
+ } catch {
734
+ }
735
+ if (!mergeBase) {
736
+ this.warnings.push(
737
+ `No common ancestor between previous generation ${lastGen.commit_sha.slice(0, 7)} and HEAD. Falling back to composite tree-diff detection.`
738
+ );
711
739
  return this.detectPatchesViaTreeDiff(
712
740
  lastGen,
713
741
  /* commitKnownMissing */
714
742
  false
715
743
  );
716
744
  }
745
+ return this.detectPatchesInRange(mergeBase, lastGen, lock);
746
+ }
747
+ /**
748
+ * FER-10201 — Walk `${rangeStart}..HEAD`, classify each commit, emit one
749
+ * `StoredPatch` per customer commit. Body shared by the linear path
750
+ * (rangeStart = lastGen.commit_sha) and the merge-base path.
751
+ *
752
+ * Patch `base_generation` is always `lastGen.commit_sha` — the
753
+ * lockfile-tracked reference the applicator uses to fetch base file
754
+ * content, regardless of where the walk actually started.
755
+ */
756
+ async detectPatchesInRange(rangeStart, lastGen, lock) {
717
757
  const log = await this.git.exec([
718
758
  "log",
719
759
  "--first-parent",
720
760
  "--format=%H%x00%an%x00%ae%x00%s",
721
- `${lastGen.commit_sha}..HEAD`,
761
+ `${rangeStart}..HEAD`,
722
762
  "--",
723
763
  this.sdkOutputDir
724
764
  ]);
@@ -1218,11 +1258,43 @@ function threeWayMerge(base, ours, theirs) {
1218
1258
  }
1219
1259
  }
1220
1260
  }
1221
- return {
1261
+ const result = {
1222
1262
  content: outputLines.join("\n"),
1223
1263
  hasConflicts: conflicts.length > 0,
1224
1264
  conflicts
1225
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;
1226
1298
  }
1227
1299
  function tryResolveConflict(oursLines, baseLines, theirsLines) {
1228
1300
  if (baseLines.length === 0) {
@@ -1417,6 +1489,19 @@ var ReplayApplicator = class {
1417
1489
  renameCache = /* @__PURE__ */ new Map();
1418
1490
  treeExistsCache = /* @__PURE__ */ new Map();
1419
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";
1420
1505
  constructor(git, lockManager, outputDir) {
1421
1506
  this.git = git;
1422
1507
  this.lockManager = lockManager;
@@ -1464,8 +1549,23 @@ var ReplayApplicator = class {
1464
1549
  /**
1465
1550
  * Apply all patches, returning results for each.
1466
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.
1467
1565
  */
1468
- async applyPatches(patches) {
1566
+ async applyPatches(patches, opts) {
1567
+ const applyMode = opts?.applyMode ?? "replay";
1568
+ this.currentApplyMode = applyMode;
1469
1569
  this.resetAccumulator();
1470
1570
  const results = [];
1471
1571
  for (let i = 0; i < patches.length; i++) {
@@ -1480,7 +1580,7 @@ var ReplayApplicator = class {
1480
1580
  }
1481
1581
  const result = await this.applyPatchWithFallback(patch);
1482
1582
  results.push(result);
1483
- if (result.status === "conflict" && result.fileResults) {
1583
+ if (applyMode !== "resolve" && result.status === "conflict" && result.fileResults) {
1484
1584
  const laterFiles = /* @__PURE__ */ new Set();
1485
1585
  for (let j = i + 1; j < patches.length; j++) {
1486
1586
  for (const f of patches[j].files) {
@@ -1510,9 +1610,17 @@ var ReplayApplicator = class {
1510
1610
  }
1511
1611
  return results;
1512
1612
  }
1513
- /** 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
+ */
1514
1621
  async populateAccumulatorForPatch(patch, baseGen, currentTreeHash) {
1515
- if (!baseGen) return;
1622
+ const fileResults = [];
1623
+ if (!baseGen) return fileResults;
1516
1624
  const tempDir = await mkdtemp(join2(tmpdir(), "replay-acc-"));
1517
1625
  const { GitClient: GitClient2 } = await Promise.resolve().then(() => (init_GitClient(), GitClient_exports));
1518
1626
  const tempGit = new GitClient2(tempDir);
@@ -1525,18 +1633,30 @@ var ReplayApplicator = class {
1525
1633
  const resolvedPath = await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
1526
1634
  const base = await this.git.showFile(baseGen.tree_hash, filePath);
1527
1635
  const theirs = await this.applyPatchToContent(base, patch.patch_content, filePath, tempGit, tempDir);
1528
- 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;
1529
1638
  if (effectiveTheirs != null) {
1530
1639
  this.fileTheirsAccumulator.set(resolvedPath, {
1531
1640
  content: effectiveTheirs,
1532
1641
  baseGeneration: patch.base_generation
1533
1642
  });
1534
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
+ }
1535
1654
  }
1536
1655
  } finally {
1537
1656
  await rm(tempDir, { recursive: true }).catch(() => {
1538
1657
  });
1539
1658
  }
1659
+ return fileResults;
1540
1660
  }
1541
1661
  async applyPatchWithFallback(patch) {
1542
1662
  const baseGen = await this.resolveBaseGeneration(patch.base_generation);
@@ -1567,11 +1687,12 @@ var ReplayApplicator = class {
1567
1687
  }
1568
1688
  try {
1569
1689
  await this.git.execWithInput(["apply", "--3way"], patch.patch_content);
1570
- await this.populateAccumulatorForPatch(patch, baseGen, currentTreeHash);
1690
+ const fastPathFileResults = await this.populateAccumulatorForPatch(patch, baseGen, currentTreeHash);
1571
1691
  return {
1572
1692
  patch,
1573
1693
  status: "applied",
1574
1694
  method: "git-am",
1695
+ ...fastPathFileResults.length > 0 && { fileResults: fastPathFileResults },
1575
1696
  ...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
1576
1697
  };
1577
1698
  } catch {
@@ -1683,19 +1804,23 @@ var ReplayApplicator = class {
1683
1804
  renameSourcePath
1684
1805
  );
1685
1806
  }
1807
+ const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
1686
1808
  if (theirs) {
1687
1809
  const theirsHasMarkers = theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization");
1688
1810
  const baseHasMarkers = base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization"));
1689
1811
  if (theirsHasMarkers && !baseHasMarkers) {
1690
- return {
1691
- file: resolvedPath,
1692
- status: "skipped",
1693
- reason: "stale-conflict-markers"
1694
- };
1812
+ if (accumulatorEntry) {
1813
+ theirs = null;
1814
+ } else {
1815
+ return {
1816
+ file: resolvedPath,
1817
+ status: "skipped",
1818
+ reason: "stale-conflict-markers"
1819
+ };
1820
+ }
1695
1821
  }
1696
1822
  }
1697
1823
  let useAccumulatorAsMergeBase = false;
1698
- const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
1699
1824
  if (accumulatorEntry && (theirs == null || base == null)) {
1700
1825
  theirs = await this.applyPatchToContent(
1701
1826
  accumulatorEntry.content,
@@ -1802,7 +1927,8 @@ var ReplayApplicator = class {
1802
1927
  const outDir = dirname2(oursPath);
1803
1928
  await mkdir(outDir, { recursive: true });
1804
1929
  await writeFile(oursPath, merged.content);
1805
- if (effective_theirs != null && !merged.hasConflicts) {
1930
+ const populateAccumulator = effective_theirs != null && (!merged.hasConflicts || this.currentApplyMode === "resolve");
1931
+ if (populateAccumulator) {
1806
1932
  this.fileTheirsAccumulator.set(resolvedPath, {
1807
1933
  content: effective_theirs,
1808
1934
  baseGeneration: patch.base_generation
@@ -1817,7 +1943,11 @@ var ReplayApplicator = class {
1817
1943
  conflictMetadata: metadata
1818
1944
  };
1819
1945
  }
1820
- return { file: resolvedPath, status: "merged" };
1946
+ return {
1947
+ file: resolvedPath,
1948
+ status: "merged",
1949
+ ...merged.droppedContextLines && merged.droppedContextLines.length > 0 ? { droppedContextLines: merged.droppedContextLines } : {}
1950
+ };
1821
1951
  } catch (error) {
1822
1952
  return {
1823
1953
  file: filePath,
@@ -2003,6 +2133,30 @@ function isBinaryFile(filePath) {
2003
2133
  const ext = extname(filePath).toLowerCase();
2004
2134
  return BINARY_EXTENSIONS.has(ext);
2005
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
+ }
2006
2160
  function isDiffLineForFile(diffLine, filePath) {
2007
2161
  const match = diffLine.match(/^diff --git a\/.+ b\/(.+)$/);
2008
2162
  return match !== null && match[1] === filePath;
@@ -2038,13 +2192,46 @@ CLI Version: ${options.cliVersion}`;
2038
2192
  await this.git.exec(["commit", "-m", fullMessage]);
2039
2193
  return (await this.git.exec(["rev-parse", "HEAD"])).trim();
2040
2194
  }
2041
- async commitReplay(_patchCount, patches, message) {
2195
+ async commitReplay(_patchCount, patches, message, options) {
2042
2196
  await this.stageAll();
2043
2197
  if (!await this.hasStagedChanges()) {
2044
2198
  return (await this.git.exec(["rev-parse", "HEAD"])).trim();
2045
2199
  }
2046
2200
  let fullMessage = message ?? `[fern-replay] Applied customizations`;
2047
- 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) {
2048
2235
  fullMessage += "\n\nPatches replayed:";
2049
2236
  for (const patch of patches) {
2050
2237
  fullMessage += `
@@ -2080,9 +2267,303 @@ CLI Version: ${options.cliVersion}`;
2080
2267
 
2081
2268
  // src/ReplayService.ts
2082
2269
  import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
2083
- import { join as join3 } from "path";
2270
+ import { join as join4 } from "path";
2084
2271
  import { minimatch as minimatch2 } from "minimatch";
2085
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
2086
2567
  var ReplayService = class {
2087
2568
  git;
2088
2569
  detector;
@@ -2476,6 +2957,7 @@ var ReplayService = class {
2476
2957
  if (newPatches.length > 0) {
2477
2958
  results = await this.applicator.applyPatches(newPatches);
2478
2959
  this._lastApplyResults = results;
2960
+ this.recordDroppedContextLineWarnings(results);
2479
2961
  this.revertConflictingFiles(results);
2480
2962
  for (const result of results) {
2481
2963
  if (result.status === "conflict") {
@@ -2494,7 +2976,8 @@ var ReplayService = class {
2494
2976
  if (newPatches.length > 0) {
2495
2977
  if (!options?.stageOnly) {
2496
2978
  const appliedCount = results.filter((r) => r.status === "applied").length;
2497
- await this.committer.commitReplay(appliedCount, newPatches);
2979
+ const buckets = computeCommitBuckets(results, rebaseCounts.absorbedPatchIds, newPatches);
2980
+ await this.committer.commitReplay(appliedCount, newPatches, void 0, { buckets });
2498
2981
  } else {
2499
2982
  await this.committer.stageAll();
2500
2983
  }
@@ -2557,12 +3040,21 @@ var ReplayService = class {
2557
3040
  (p) => preRebasePatchIds.has(p.id) && !postRebasePatchIds.has(p.id)
2558
3041
  );
2559
3042
  existingPatches = this.lockManager.getPatches();
2560
- const seenHashes = /* @__PURE__ */ new Set();
3043
+ const seenHashes = /* @__PURE__ */ new Map();
2561
3044
  for (const p of existingPatches) {
2562
- if (seenHashes.has(p.content_hash)) {
2563
- 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
+ }
2564
3056
  } else {
2565
- seenHashes.add(p.content_hash);
3057
+ seenHashes.set(p.content_hash, p.original_commit);
2566
3058
  }
2567
3059
  }
2568
3060
  existingPatches = this.lockManager.getPatches();
@@ -2623,6 +3115,7 @@ var ReplayService = class {
2623
3115
  const genSha = prep._prepared.genSha;
2624
3116
  const results = await this.applicator.applyPatches(allPatches);
2625
3117
  this._lastApplyResults = results;
3118
+ this.recordDroppedContextLineWarnings(results);
2626
3119
  this.revertConflictingFiles(results);
2627
3120
  for (const result of results) {
2628
3121
  if (result.status === "conflict") {
@@ -2650,7 +3143,8 @@ var ReplayService = class {
2650
3143
  await this.committer.stageAll();
2651
3144
  } else {
2652
3145
  const appliedCount = results.filter((r) => r.status === "applied").length;
2653
- await this.committer.commitReplay(appliedCount, allPatches);
3146
+ const buckets = computeCommitBuckets(results, rebaseCounts.absorbedPatchIds, allPatches);
3147
+ await this.committer.commitReplay(appliedCount, allPatches, void 0, { buckets });
2654
3148
  }
2655
3149
  const warnings = [...detectorWarnings, ...this.warnings];
2656
3150
  return this.buildReport(
@@ -2674,7 +3168,7 @@ var ReplayService = class {
2674
3168
  let repointed = 0;
2675
3169
  let contentRebased = 0;
2676
3170
  let keptAsUserOwned = 0;
2677
- const seenContentHashes = /* @__PURE__ */ new Set();
3171
+ const seenContentHashes = /* @__PURE__ */ new Map();
2678
3172
  const absorbedPatchIds = /* @__PURE__ */ new Set();
2679
3173
  const fernignorePatterns = this.readFernignorePatterns();
2680
3174
  for (const result of results) {
@@ -2688,6 +3182,23 @@ var ReplayService = class {
2688
3182
  }
2689
3183
  }
2690
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
+ }
2691
3202
  for (const result of results) {
2692
3203
  if (result.status === "conflict" && result.fileResults) {
2693
3204
  await this.trimAbsorbedFiles(result, currentGenSha);
@@ -2755,6 +3266,18 @@ var ReplayService = class {
2755
3266
  keptAsUserOwned++;
2756
3267
  continue;
2757
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
+ }
2758
3281
  this.lockManager.removePatch(patch.id);
2759
3282
  absorbedPatchIds.add(patch.id);
2760
3283
  absorbed++;
@@ -2770,13 +3293,22 @@ var ReplayService = class {
2770
3293
  continue;
2771
3294
  }
2772
3295
  const newContentHash = this.detector.computeContentHash(diff);
2773
- if (seenContentHashes.has(newContentHash)) {
2774
- this.lockManager.removePatch(patch.id);
2775
- absorbedPatchIds.add(patch.id);
2776
- absorbed++;
2777
- 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);
2778
3311
  }
2779
- seenContentHashes.add(newContentHash);
2780
3312
  patch.base_generation = currentGenSha;
2781
3313
  patch.patch_content = diff;
2782
3314
  patch.content_hash = newContentHash;
@@ -2952,8 +3484,14 @@ var ReplayService = class {
2952
3484
  }
2953
3485
  if (patch.base_generation === currentGen) {
2954
3486
  try {
2955
- const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
2956
- 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;
2957
3495
  if (!diff.trim()) {
2958
3496
  if (await allPatchFilesUserOwned(patch)) continue;
2959
3497
  this.lockManager.removePatch(patch.id);
@@ -2995,8 +3533,14 @@ var ReplayService = class {
2995
3533
  try {
2996
3534
  const markerFiles = await this.git.exec(["grep", "-l", "<<<<<<< Generated", "HEAD", "--", ...patch.files]).catch(() => "");
2997
3535
  if (markerFiles.trim()) continue;
2998
- const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
2999
- 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;
3000
3544
  if (!diff.trim()) {
3001
3545
  if (await allPatchFilesUserOwned(patch)) {
3002
3546
  try {
@@ -3021,6 +3565,32 @@ var ReplayService = class {
3021
3565
  }
3022
3566
  return { conflictResolved, conflictAbsorbed, contentRefreshed };
3023
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
+ }
3024
3594
  /**
3025
3595
  * After applyPatches(), strip conflict markers from conflicting files
3026
3596
  * so only clean content is committed. Keeps the Generated (OURS) side.
@@ -3030,7 +3600,7 @@ var ReplayService = class {
3030
3600
  if (result.status !== "conflict" || !result.fileResults) continue;
3031
3601
  for (const fileResult of result.fileResults) {
3032
3602
  if (fileResult.status !== "conflict") continue;
3033
- const filePath = join3(this.outputDir, fileResult.file);
3603
+ const filePath = join4(this.outputDir, fileResult.file);
3034
3604
  try {
3035
3605
  const content = readFileSync2(filePath, "utf-8");
3036
3606
  const stripped = stripConflictMarkers(content);
@@ -3060,7 +3630,7 @@ var ReplayService = class {
3060
3630
  }
3061
3631
  }
3062
3632
  readFernignorePatterns() {
3063
- const fernignorePath = join3(this.outputDir, ".fernignore");
3633
+ const fernignorePath = join4(this.outputDir, ".fernignore");
3064
3634
  if (!existsSync2(fernignorePath)) return [];
3065
3635
  return readFileSync2(fernignorePath, "utf-8").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
3066
3636
  }
@@ -3078,6 +3648,9 @@ var ReplayService = class {
3078
3648
  };
3079
3649
  }).filter((d) => d.files.length > 0);
3080
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
+ );
3081
3654
  return {
3082
3655
  flow,
3083
3656
  patchesDetected: patches.length,
@@ -3094,7 +3667,7 @@ var ReplayService = class {
3094
3667
  patchesRefreshed: preRebaseCounts && preRebaseCounts.contentRefreshed > 0 ? preRebaseCounts.contentRefreshed : void 0,
3095
3668
  conflicts: conflictResults.flatMap((r) => r.fileResults?.filter((f) => f.status === "conflict") ?? []),
3096
3669
  conflictDetails: conflictDetails.length > 0 ? conflictDetails : void 0,
3097
- unresolvedPatches: conflictResults.length > 0 ? conflictResults.map((r) => ({
3670
+ unresolvedPatches: unresolvedResults.length > 0 ? unresolvedResults.map((r) => ({
3098
3671
  patchId: r.patch.id,
3099
3672
  patchMessage: r.patch.original_message,
3100
3673
  files: r.patch.files,
@@ -3105,11 +3678,38 @@ var ReplayService = class {
3105
3678
  };
3106
3679
  }
3107
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
+ }
3108
3708
 
3109
3709
  // src/FernignoreMigrator.ts
3110
3710
  import { createHash as createHash2 } from "crypto";
3111
3711
  import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, statSync, writeFileSync as writeFileSync3 } from "fs";
3112
- import { dirname as dirname3, join as join4 } from "path";
3712
+ import { dirname as dirname3, join as join5 } from "path";
3113
3713
  import { minimatch as minimatch3 } from "minimatch";
3114
3714
  import { parse as parse2, stringify as stringify2 } from "yaml";
3115
3715
  var FernignoreMigrator = class {
@@ -3122,10 +3722,10 @@ var FernignoreMigrator = class {
3122
3722
  this.outputDir = outputDir;
3123
3723
  }
3124
3724
  fernignoreExists() {
3125
- return existsSync3(join4(this.outputDir, ".fernignore"));
3725
+ return existsSync3(join5(this.outputDir, ".fernignore"));
3126
3726
  }
3127
3727
  readFernignorePatterns() {
3128
- const fernignorePath = join4(this.outputDir, ".fernignore");
3728
+ const fernignorePath = join5(this.outputDir, ".fernignore");
3129
3729
  if (!existsSync3(fernignorePath)) {
3130
3730
  return [];
3131
3731
  }
@@ -3185,7 +3785,16 @@ var FernignoreMigrator = class {
3185
3785
  original_author: "Fern Replay <replay@buildwithfern.com>",
3186
3786
  base_generation: currentGen.commit_sha,
3187
3787
  files: patchFiles,
3188
- 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
3189
3798
  });
3190
3799
  trackedByBoth.push({
3191
3800
  file: pattern,
@@ -3218,7 +3827,7 @@ var FernignoreMigrator = class {
3218
3827
  return results;
3219
3828
  }
3220
3829
  readFileContent(filePath) {
3221
- const fullPath = join4(this.outputDir, filePath);
3830
+ const fullPath = join5(this.outputDir, filePath);
3222
3831
  if (!existsSync3(fullPath)) {
3223
3832
  return null;
3224
3833
  }
@@ -3283,7 +3892,7 @@ var FernignoreMigrator = class {
3283
3892
  };
3284
3893
  }
3285
3894
  movePatternsToReplayYml(patterns) {
3286
- const replayYmlPath = join4(this.outputDir, ".fern", "replay.yml");
3895
+ const replayYmlPath = join5(this.outputDir, ".fern", "replay.yml");
3287
3896
  let config = {};
3288
3897
  if (existsSync3(replayYmlPath)) {
3289
3898
  const content = readFileSync3(replayYmlPath, "utf-8");
@@ -3303,7 +3912,7 @@ var FernignoreMigrator = class {
3303
3912
  // src/commands/bootstrap.ts
3304
3913
  import { createHash as createHash3 } from "crypto";
3305
3914
  import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
3306
- import { join as join5 } from "path";
3915
+ import { join as join6 } from "path";
3307
3916
  init_GitClient();
3308
3917
  async function bootstrap(outputDir, options) {
3309
3918
  const git = new GitClient(outputDir);
@@ -3470,7 +4079,7 @@ function parseGitLog(log) {
3470
4079
  }
3471
4080
  var REPLAY_FERNIGNORE_ENTRIES = [".fern/replay.lock", ".fern/replay.yml", ".gitattributes"];
3472
4081
  function ensureFernignoreEntries(outputDir) {
3473
- const fernignorePath = join5(outputDir, ".fernignore");
4082
+ const fernignorePath = join6(outputDir, ".fernignore");
3474
4083
  let content = "";
3475
4084
  if (existsSync4(fernignorePath)) {
3476
4085
  content = readFileSync4(fernignorePath, "utf-8");
@@ -3494,7 +4103,7 @@ function ensureFernignoreEntries(outputDir) {
3494
4103
  }
3495
4104
  var GITATTRIBUTES_ENTRIES = [".fern/replay.lock linguist-generated=true"];
3496
4105
  function ensureGitattributesEntries(outputDir) {
3497
- const gitattributesPath = join5(outputDir, ".gitattributes");
4106
+ const gitattributesPath = join6(outputDir, ".gitattributes");
3498
4107
  let content = "";
3499
4108
  if (existsSync4(gitattributesPath)) {
3500
4109
  content = readFileSync4(gitattributesPath, "utf-8");
@@ -3693,6 +4302,8 @@ function reset(outputDir, options) {
3693
4302
 
3694
4303
  // src/commands/resolve.ts
3695
4304
  init_GitClient();
4305
+ import { readFile as readFile2 } from "fs/promises";
4306
+ import { join as join7 } from "path";
3696
4307
  async function resolve(outputDir, options) {
3697
4308
  const lockManager = new LockfileManager(outputDir);
3698
4309
  if (!lockManager.exists()) {
@@ -3707,7 +4318,7 @@ async function resolve(outputDir, options) {
3707
4318
  const resolvingPatches = lockManager.getResolvingPatches();
3708
4319
  if (unresolvedPatches.length > 0) {
3709
4320
  const applicator = new ReplayApplicator(git, lockManager, outputDir);
3710
- await applicator.applyPatches(unresolvedPatches);
4321
+ await applicator.applyPatches(unresolvedPatches, { applyMode: "resolve" });
3711
4322
  const markerFiles = await findConflictMarkerFiles(git);
3712
4323
  if (markerFiles.length > 0) {
3713
4324
  for (const patch of unresolvedPatches) {
@@ -3733,20 +4344,38 @@ async function resolve(outputDir, options) {
3733
4344
  if (patchesToCommit.length > 0) {
3734
4345
  const currentGen = lock.current_generation;
3735
4346
  const detector = new ReplayDetector(git, lockManager, outputDir);
4347
+ const warnings = [];
3736
4348
  let patchesResolved = 0;
3737
4349
  for (const patch of patchesToCommit) {
3738
- const diff = await git.exec(["diff", currentGen, "--", ...patch.files]).catch(() => null);
3739
- 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()) {
3740
4364
  lockManager.removePatch(patch.id);
3741
4365
  continue;
3742
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
+ }
3743
4372
  const newContentHash = detector.computeContentHash(diff);
3744
- const changedFiles = await getChangedFiles(git, currentGen, patch.files);
4373
+ const changedFiles = result.hadFallback ? await getChangedFiles(git, currentGen, patch.files) : extractFilesFromDiff(diff);
3745
4374
  lockManager.markPatchResolved(patch.id, {
3746
4375
  patch_content: diff,
3747
4376
  content_hash: newContentHash,
3748
4377
  base_generation: currentGen,
3749
- files: changedFiles
4378
+ files: changedFiles.length > 0 ? changedFiles : patch.files
3750
4379
  });
3751
4380
  patchesResolved++;
3752
4381
  }
@@ -3762,7 +4391,8 @@ async function resolve(outputDir, options) {
3762
4391
  success: true,
3763
4392
  commitSha: commitSha2,
3764
4393
  phase: "committed",
3765
- patchesResolved
4394
+ patchesResolved,
4395
+ warnings: warnings.length > 0 ? warnings : void 0
3766
4396
  };
3767
4397
  }
3768
4398
  const committer = new ReplayCommitter(git, outputDir);
@@ -3780,6 +4410,24 @@ async function getChangedFiles(git, currentGen, files) {
3780
4410
  const changed = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !f.startsWith(".fern/"));
3781
4411
  return changed.length > 0 ? changed : files;
3782
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
+ }
3783
4431
 
3784
4432
  // src/commands/status.ts
3785
4433
  function status(outputDir) {