@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/cli.cjs CHANGED
@@ -5368,9 +5368,9 @@ var init_GitClient = __esm({
5368
5368
  return this.git.raw(args);
5369
5369
  }
5370
5370
  async execWithInput(args, input) {
5371
- const { spawn: spawn2 } = await import("child_process");
5371
+ const { spawn: spawn3 } = await import("child_process");
5372
5372
  return new Promise((resolve2, reject) => {
5373
- const proc = spawn2("git", args, { cwd: this.repoPath });
5373
+ const proc = spawn3("git", args, { cwd: this.repoPath });
5374
5374
  let stdout = "";
5375
5375
  let stderr = "";
5376
5376
  proc.stdout.on("data", (data) => {
@@ -9886,7 +9886,7 @@ var require_resolve_block_scalar = __commonJS({
9886
9886
  if (!header)
9887
9887
  return { value: "", type: null, comment: "", range: [start, start, start] };
9888
9888
  const type = header.mode === ">" ? Scalar.Scalar.BLOCK_FOLDED : Scalar.Scalar.BLOCK_LITERAL;
9889
- const lines = scalar.source ? splitLines(scalar.source) : [];
9889
+ const lines = scalar.source ? splitLines2(scalar.source) : [];
9890
9890
  let chompStart = lines.length;
9891
9891
  for (let i = lines.length - 1; i >= 0; --i) {
9892
9892
  const content = lines[i][1];
@@ -10044,7 +10044,7 @@ var require_resolve_block_scalar = __commonJS({
10044
10044
  }
10045
10045
  return { mode, indent, chomp, comment, length };
10046
10046
  }
10047
- function splitLines(source) {
10047
+ function splitLines2(source) {
10048
10048
  const split = source.split(/\n( *)/);
10049
10049
  const first2 = split[0];
10050
10050
  const m = first2.match(/^( *)/);
@@ -13233,7 +13233,7 @@ var init_HybridReconstruction = __esm({
13233
13233
  });
13234
13234
 
13235
13235
  // src/cli.ts
13236
- var import_node_path7 = require("path");
13236
+ var import_node_path9 = require("path");
13237
13237
  var import_node_fs6 = require("fs");
13238
13238
  var import_node_readline = require("readline");
13239
13239
  init_GitClient();
@@ -14060,7 +14060,7 @@ var ReplayDetector = class {
14060
14060
 
14061
14061
  // src/ReplayService.ts
14062
14062
  var import_node_fs2 = require("fs");
14063
- var import_node_path4 = require("path");
14063
+ var import_node_path5 = require("path");
14064
14064
 
14065
14065
  // node_modules/minimatch/dist/esm/index.js
14066
14066
  var import_brace_expansion = __toESM(require_brace_expansion(), 1);
@@ -15761,11 +15761,43 @@ function threeWayMerge(base, ours, theirs) {
15761
15761
  }
15762
15762
  }
15763
15763
  }
15764
- return {
15764
+ const result = {
15765
15765
  content: outputLines.join("\n"),
15766
15766
  hasConflicts: conflicts2.length > 0,
15767
15767
  conflicts: conflicts2
15768
15768
  };
15769
+ if (conflicts2.length === 0) {
15770
+ const dropped = computeDroppedContextLines(baseLines, theirsLines, outputLines);
15771
+ if (dropped.length > 0) {
15772
+ result.droppedContextLines = dropped;
15773
+ }
15774
+ }
15775
+ return result;
15776
+ }
15777
+ function computeDroppedContextLines(baseLines, theirsLines, mergedLines) {
15778
+ const baseCounts = countLines(baseLines);
15779
+ const theirsCounts = countLines(theirsLines);
15780
+ const mergedCounts = countLines(mergedLines);
15781
+ const dropped = [];
15782
+ const seen = /* @__PURE__ */ new Set();
15783
+ for (const line of baseLines) {
15784
+ if (seen.has(line)) continue;
15785
+ const inBase = baseCounts.get(line) ?? 0;
15786
+ const inTheirs = theirsCounts.get(line) ?? 0;
15787
+ const inMerged = mergedCounts.get(line) ?? 0;
15788
+ if (inBase > 0 && inTheirs > 0 && inMerged < Math.min(inBase, inTheirs)) {
15789
+ dropped.push(line);
15790
+ seen.add(line);
15791
+ }
15792
+ }
15793
+ return dropped;
15794
+ }
15795
+ function countLines(lines) {
15796
+ const counts = /* @__PURE__ */ new Map();
15797
+ for (const line of lines) {
15798
+ counts.set(line, (counts.get(line) ?? 0) + 1);
15799
+ }
15800
+ return counts;
15769
15801
  }
15770
15802
  function tryResolveConflict(oursLines, baseLines, theirsLines) {
15771
15803
  if (baseLines.length === 0) {
@@ -15881,6 +15913,19 @@ var ReplayApplicator = class {
15881
15913
  renameCache = /* @__PURE__ */ new Map();
15882
15914
  treeExistsCache = /* @__PURE__ */ new Map();
15883
15915
  fileTheirsAccumulator = /* @__PURE__ */ new Map();
15916
+ /**
15917
+ * Apply mode for the current `applyPatches` invocation. Set at the start
15918
+ * of `applyPatches`, read by `mergeFile` to decide:
15919
+ * - whether to skip the intra-loop marker strip (kept in `applyPatches`)
15920
+ * - whether to populate the accumulator after a conflicted merge
15921
+ * (resolve mode populates so subsequent patches on the same file
15922
+ * can use patch A's THEIRS as a structurally-correct merge base
15923
+ * when their diff was authored against post-A structure)
15924
+ * - whether to retry THEIRS reconstruction against the accumulator
15925
+ * when the BASE-relative reconstruction produced markers from a
15926
+ * cross-patch context mismatch
15927
+ */
15928
+ currentApplyMode = "replay";
15884
15929
  constructor(git, lockManager, outputDir) {
15885
15930
  this.git = git;
15886
15931
  this.lockManager = lockManager;
@@ -15928,8 +15973,23 @@ var ReplayApplicator = class {
15928
15973
  /**
15929
15974
  * Apply all patches, returning results for each.
15930
15975
  * Skips patches that match exclude patterns in replay.yml
15976
+ *
15977
+ * `applyMode` controls the post-conflict marker strategy:
15978
+ * - `"replay"` (default): strip conflict markers from disk between
15979
+ * iterations whenever a later patch touches the same file. Lets the
15980
+ * follow-up patch's `git apply --3way` see clean OURS content,
15981
+ * which is what the silent-loss fix (PR #73) relies on. The replay
15982
+ * pipeline calls `revertConflictingFiles` after the loop to clean
15983
+ * any markers that survive.
15984
+ * - `"resolve"`: keep markers on disk. The resolve command needs the
15985
+ * customer to see them. Slow-path 3-way merge naturally preserves
15986
+ * A's markers and B's clean writes when their regions don't overlap;
15987
+ * when they do overlap, the customer gets nested markers and
15988
+ * resolves manually. Either way they aren't silently dropped.
15931
15989
  */
15932
- async applyPatches(patches) {
15990
+ async applyPatches(patches, opts) {
15991
+ const applyMode = opts?.applyMode ?? "replay";
15992
+ this.currentApplyMode = applyMode;
15933
15993
  this.resetAccumulator();
15934
15994
  const results = [];
15935
15995
  for (let i = 0; i < patches.length; i++) {
@@ -15944,7 +16004,7 @@ var ReplayApplicator = class {
15944
16004
  }
15945
16005
  const result = await this.applyPatchWithFallback(patch);
15946
16006
  results.push(result);
15947
- if (result.status === "conflict" && result.fileResults) {
16007
+ if (applyMode !== "resolve" && result.status === "conflict" && result.fileResults) {
15948
16008
  const laterFiles = /* @__PURE__ */ new Set();
15949
16009
  for (let j = i + 1; j < patches.length; j++) {
15950
16010
  for (const f of patches[j].files) {
@@ -15974,9 +16034,17 @@ var ReplayApplicator = class {
15974
16034
  }
15975
16035
  return results;
15976
16036
  }
15977
- /** Populate accumulator after git apply succeeds. */
16037
+ /**
16038
+ * Populate accumulator after git apply succeeds, AND collect per-file
16039
+ * results including any "dropped context lines" — lines that were
16040
+ * present in BASE and THEIRS (unchanged context) but absent from the
16041
+ * final on-disk state because OURS deleted them. This is the fast-path
16042
+ * counterpart to ThreeWayMerge.computeDroppedContextLines used by the
16043
+ * 3-way slow path; both must surface the same warning.
16044
+ */
15978
16045
  async populateAccumulatorForPatch(patch, baseGen, currentTreeHash) {
15979
- if (!baseGen) return;
16046
+ const fileResults = [];
16047
+ if (!baseGen) return fileResults;
15980
16048
  const tempDir = await (0, import_promises.mkdtemp)((0, import_node_path3.join)((0, import_node_os.tmpdir)(), "replay-acc-"));
15981
16049
  const { GitClient: GitClient2 } = await Promise.resolve().then(() => (init_GitClient(), GitClient_exports));
15982
16050
  const tempGit = new GitClient2(tempDir);
@@ -15989,18 +16057,30 @@ var ReplayApplicator = class {
15989
16057
  const resolvedPath = await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
15990
16058
  const base = await this.git.showFile(baseGen.tree_hash, filePath);
15991
16059
  const theirs = await this.applyPatchToContent(base, patch.patch_content, filePath, tempGit, tempDir);
15992
- const effectiveTheirs = theirs ?? await (0, import_promises.readFile)((0, import_node_path3.join)(this.outputDir, resolvedPath), "utf-8").catch(() => null);
16060
+ const finalOnDisk = await (0, import_promises.readFile)((0, import_node_path3.join)(this.outputDir, resolvedPath), "utf-8").catch(() => null);
16061
+ const effectiveTheirs = theirs ?? finalOnDisk;
15993
16062
  if (effectiveTheirs != null) {
15994
16063
  this.fileTheirsAccumulator.set(resolvedPath, {
15995
16064
  content: effectiveTheirs,
15996
16065
  baseGeneration: patch.base_generation
15997
16066
  });
15998
16067
  }
16068
+ if (base != null && effectiveTheirs != null && finalOnDisk != null) {
16069
+ const dropped = computeDroppedContextLinesForFile(base, effectiveTheirs, finalOnDisk);
16070
+ if (dropped.length > 0) {
16071
+ fileResults.push({
16072
+ file: resolvedPath,
16073
+ status: "merged",
16074
+ droppedContextLines: dropped
16075
+ });
16076
+ }
16077
+ }
15999
16078
  }
16000
16079
  } finally {
16001
16080
  await (0, import_promises.rm)(tempDir, { recursive: true }).catch(() => {
16002
16081
  });
16003
16082
  }
16083
+ return fileResults;
16004
16084
  }
16005
16085
  async applyPatchWithFallback(patch) {
16006
16086
  const baseGen = await this.resolveBaseGeneration(patch.base_generation);
@@ -16031,11 +16111,12 @@ var ReplayApplicator = class {
16031
16111
  }
16032
16112
  try {
16033
16113
  await this.git.execWithInput(["apply", "--3way"], patch.patch_content);
16034
- await this.populateAccumulatorForPatch(patch, baseGen, currentTreeHash);
16114
+ const fastPathFileResults = await this.populateAccumulatorForPatch(patch, baseGen, currentTreeHash);
16035
16115
  return {
16036
16116
  patch,
16037
16117
  status: "applied",
16038
16118
  method: "git-am",
16119
+ ...fastPathFileResults.length > 0 && { fileResults: fastPathFileResults },
16039
16120
  ...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
16040
16121
  };
16041
16122
  } catch {
@@ -16147,19 +16228,23 @@ var ReplayApplicator = class {
16147
16228
  renameSourcePath
16148
16229
  );
16149
16230
  }
16231
+ const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
16150
16232
  if (theirs) {
16151
16233
  const theirsHasMarkers = theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization");
16152
16234
  const baseHasMarkers = base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization"));
16153
16235
  if (theirsHasMarkers && !baseHasMarkers) {
16154
- return {
16155
- file: resolvedPath,
16156
- status: "skipped",
16157
- reason: "stale-conflict-markers"
16158
- };
16236
+ if (accumulatorEntry) {
16237
+ theirs = null;
16238
+ } else {
16239
+ return {
16240
+ file: resolvedPath,
16241
+ status: "skipped",
16242
+ reason: "stale-conflict-markers"
16243
+ };
16244
+ }
16159
16245
  }
16160
16246
  }
16161
16247
  let useAccumulatorAsMergeBase = false;
16162
- const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
16163
16248
  if (accumulatorEntry && (theirs == null || base == null)) {
16164
16249
  theirs = await this.applyPatchToContent(
16165
16250
  accumulatorEntry.content,
@@ -16266,7 +16351,8 @@ var ReplayApplicator = class {
16266
16351
  const outDir = (0, import_node_path3.dirname)(oursPath);
16267
16352
  await (0, import_promises.mkdir)(outDir, { recursive: true });
16268
16353
  await (0, import_promises.writeFile)(oursPath, merged.content);
16269
- if (effective_theirs != null && !merged.hasConflicts) {
16354
+ const populateAccumulator = effective_theirs != null && (!merged.hasConflicts || this.currentApplyMode === "resolve");
16355
+ if (populateAccumulator) {
16270
16356
  this.fileTheirsAccumulator.set(resolvedPath, {
16271
16357
  content: effective_theirs,
16272
16358
  baseGeneration: patch.base_generation
@@ -16281,7 +16367,11 @@ var ReplayApplicator = class {
16281
16367
  conflictMetadata: metadata
16282
16368
  };
16283
16369
  }
16284
- return { file: resolvedPath, status: "merged" };
16370
+ return {
16371
+ file: resolvedPath,
16372
+ status: "merged",
16373
+ ...merged.droppedContextLines && merged.droppedContextLines.length > 0 ? { droppedContextLines: merged.droppedContextLines } : {}
16374
+ };
16285
16375
  } catch (error) {
16286
16376
  return {
16287
16377
  file: filePath,
@@ -16467,6 +16557,30 @@ function isBinaryFile(filePath) {
16467
16557
  const ext2 = (0, import_node_path3.extname)(filePath).toLowerCase();
16468
16558
  return BINARY_EXTENSIONS.has(ext2);
16469
16559
  }
16560
+ function computeDroppedContextLinesForFile(base, theirs, finalContent) {
16561
+ const baseLines = base.split("\n");
16562
+ const theirsLines = theirs.split("\n");
16563
+ const finalLines = finalContent.split("\n");
16564
+ const baseCounts = /* @__PURE__ */ new Map();
16565
+ const theirsCounts = /* @__PURE__ */ new Map();
16566
+ const finalCounts = /* @__PURE__ */ new Map();
16567
+ for (const l of baseLines) baseCounts.set(l, (baseCounts.get(l) ?? 0) + 1);
16568
+ for (const l of theirsLines) theirsCounts.set(l, (theirsCounts.get(l) ?? 0) + 1);
16569
+ for (const l of finalLines) finalCounts.set(l, (finalCounts.get(l) ?? 0) + 1);
16570
+ const dropped = [];
16571
+ const seen = /* @__PURE__ */ new Set();
16572
+ for (const line of baseLines) {
16573
+ if (seen.has(line)) continue;
16574
+ const inBase = baseCounts.get(line) ?? 0;
16575
+ const inTheirs = theirsCounts.get(line) ?? 0;
16576
+ const inFinal = finalCounts.get(line) ?? 0;
16577
+ if (inBase > 0 && inTheirs > 0 && inFinal < Math.min(inBase, inTheirs)) {
16578
+ dropped.push(line);
16579
+ seen.add(line);
16580
+ }
16581
+ }
16582
+ return dropped;
16583
+ }
16470
16584
  function isDiffLineForFile(diffLine, filePath) {
16471
16585
  const match2 = diffLine.match(/^diff --git a\/.+ b\/(.+)$/);
16472
16586
  return match2 !== null && match2[1] === filePath;
@@ -16502,13 +16616,46 @@ CLI Version: ${options.cliVersion}`;
16502
16616
  await this.git.exec(["commit", "-m", fullMessage]);
16503
16617
  return (await this.git.exec(["rev-parse", "HEAD"])).trim();
16504
16618
  }
16505
- async commitReplay(_patchCount, patches, message) {
16619
+ async commitReplay(_patchCount, patches, message, options) {
16506
16620
  await this.stageAll();
16507
16621
  if (!await this.hasStagedChanges()) {
16508
16622
  return (await this.git.exec(["rev-parse", "HEAD"])).trim();
16509
16623
  }
16510
16624
  let fullMessage = message ?? `[fern-replay] Applied customizations`;
16511
- if (patches && patches.length > 0) {
16625
+ const buckets = options?.buckets;
16626
+ if (buckets) {
16627
+ if (buckets.applied.length > 0) {
16628
+ fullMessage += `
16629
+
16630
+ Patches applied (${buckets.applied.length}):`;
16631
+ for (const p of buckets.applied) {
16632
+ fullMessage += `
16633
+ - ${p.id}: ${p.original_message}`;
16634
+ }
16635
+ }
16636
+ if (buckets.unresolved.length > 0) {
16637
+ fullMessage += `
16638
+
16639
+ Patches with unresolved conflicts (${buckets.unresolved.length}):`;
16640
+ for (const p of buckets.unresolved) {
16641
+ fullMessage += `
16642
+ - ${p.id}: ${p.original_message}`;
16643
+ }
16644
+ fullMessage += `
16645
+ Run \`fern-replay resolve\` to apply these customizations.`;
16646
+ }
16647
+ if (buckets.absorbed.length > 0) {
16648
+ fullMessage += `
16649
+
16650
+ Patches absorbed by generator (${buckets.absorbed.length}):`;
16651
+ for (const p of buckets.absorbed) {
16652
+ fullMessage += `
16653
+ - ${p.id}: ${p.original_message}`;
16654
+ }
16655
+ fullMessage += `
16656
+ The generator now produces these customizations natively.`;
16657
+ }
16658
+ } else if (patches && patches.length > 0) {
16512
16659
  fullMessage += "\n\nPatches replayed:";
16513
16660
  for (const patch of patches) {
16514
16661
  fullMessage += `
@@ -16542,6 +16689,298 @@ CLI Version: ${options.cliVersion}`;
16542
16689
  }
16543
16690
  };
16544
16691
 
16692
+ // src/PatchRegionDiff.ts
16693
+ var import_promises2 = require("fs/promises");
16694
+ var import_node_os2 = require("os");
16695
+ var import_node_path4 = require("path");
16696
+ var import_node_child_process = require("child_process");
16697
+ init_HybridReconstruction();
16698
+ function normalizeHunkBody(hunk) {
16699
+ let contextC = 0;
16700
+ let removeC = 0;
16701
+ let addC = 0;
16702
+ const trimmed2 = [];
16703
+ for (const line of hunk.lines) {
16704
+ const oldUsed = contextC + removeC;
16705
+ const newUsed = contextC + addC;
16706
+ if (oldUsed >= hunk.oldCount && newUsed >= hunk.newCount) break;
16707
+ trimmed2.push(line);
16708
+ if (line.type === "context") contextC++;
16709
+ else if (line.type === "remove") removeC++;
16710
+ else if (line.type === "add") addC++;
16711
+ }
16712
+ return { ...hunk, lines: trimmed2 };
16713
+ }
16714
+ function extractFileDiffForFile(patchContent, filePath) {
16715
+ const lines = patchContent.split("\n");
16716
+ const out = [];
16717
+ let inTarget = false;
16718
+ for (const line of lines) {
16719
+ if (line.startsWith("diff --git")) {
16720
+ if (inTarget) break;
16721
+ if (isDiffLineForFile2(line, filePath)) {
16722
+ inTarget = true;
16723
+ out.push(line);
16724
+ }
16725
+ continue;
16726
+ }
16727
+ if (inTarget) out.push(line);
16728
+ }
16729
+ return out.length > 0 ? out.join("\n") + "\n" : null;
16730
+ }
16731
+ function isDiffLineForFile2(line, filePath) {
16732
+ const match2 = line.match(/^diff --git a\/(.+) b\/(.+)$/);
16733
+ return match2 !== null && (match2[2] === filePath || match2[1] === filePath);
16734
+ }
16735
+ async function computePerPatchDiff(patch, getCurrentGenContent, getWorkingTreeContent) {
16736
+ const fragments = [];
16737
+ const unlocatableFiles = [];
16738
+ for (const file of patch.files) {
16739
+ const fileDiff = extractFileDiffForFile(patch.patch_content, file);
16740
+ if (fileDiff == null) {
16741
+ unlocatableFiles.push(file);
16742
+ continue;
16743
+ }
16744
+ const hunksRaw = parseHunks(fileDiff).map(normalizeHunkBody);
16745
+ const hunks = hunksRaw.filter((h) => {
16746
+ if (h.lines.length === 0) return false;
16747
+ let ctx = 0, rm3 = 0, add = 0;
16748
+ for (const ln of h.lines) {
16749
+ if (ln.type === "context") ctx++;
16750
+ else if (ln.type === "remove") rm3++;
16751
+ else if (ln.type === "add") add++;
16752
+ }
16753
+ return ctx + rm3 === h.oldCount && ctx + add === h.newCount;
16754
+ });
16755
+ if (hunks.length === 0) {
16756
+ if (hunksRaw.length > 0) unlocatableFiles.push(file);
16757
+ continue;
16758
+ }
16759
+ const currentGen = await getCurrentGenContent(file);
16760
+ const workingTree = await getWorkingTreeContent(file);
16761
+ const isPureNewFile = hunks.every((h) => h.oldCount === 0);
16762
+ const isPureDelete = hunks.every((h) => h.newCount === 0);
16763
+ if (currentGen == null) {
16764
+ if (!isPureNewFile) {
16765
+ unlocatableFiles.push(file);
16766
+ continue;
16767
+ }
16768
+ if (workingTree == null) continue;
16769
+ fragments.push(synthesizeNewFileDiff(file, workingTree));
16770
+ continue;
16771
+ }
16772
+ if (isPureNewFile) {
16773
+ if (workingTree == null) {
16774
+ fragments.push(synthesizeDeleteFileDiff(file, currentGen));
16775
+ continue;
16776
+ }
16777
+ if (workingTree === currentGen) {
16778
+ continue;
16779
+ }
16780
+ const fullDiff = await unifiedDiff(currentGen, workingTree, file);
16781
+ if (fullDiff && fullDiff.trim()) fragments.push(fullDiff);
16782
+ continue;
16783
+ }
16784
+ if (workingTree == null) {
16785
+ if (!isPureDelete) {
16786
+ unlocatableFiles.push(file);
16787
+ continue;
16788
+ }
16789
+ fragments.push(synthesizeDeleteFileDiff(file, currentGen));
16790
+ continue;
16791
+ }
16792
+ const currentGenLines = splitLines(currentGen);
16793
+ const workingTreeLines = splitLines(workingTree);
16794
+ const locInCurrentGenRaw = locateHunksInOurs(hunks, currentGenLines);
16795
+ const locInWorkingTreeRaw = locateHunksInOurs(hunks, workingTreeLines);
16796
+ if (locInCurrentGenRaw == null || locInWorkingTreeRaw == null) {
16797
+ unlocatableFiles.push(file);
16798
+ continue;
16799
+ }
16800
+ const locInCurrentGen = locInCurrentGenRaw.map(
16801
+ (l) => resolveSpan(l, currentGenLines, "old")
16802
+ );
16803
+ const locInWorkingTree = locInWorkingTreeRaw.map(
16804
+ (l) => resolveSpan(l, workingTreeLines, "new")
16805
+ );
16806
+ const overlay = overlayRegions(
16807
+ currentGenLines,
16808
+ locInCurrentGen,
16809
+ workingTreeLines,
16810
+ locInWorkingTree
16811
+ );
16812
+ if (overlay === null) {
16813
+ unlocatableFiles.push(file);
16814
+ continue;
16815
+ }
16816
+ if (overlay === currentGen) {
16817
+ continue;
16818
+ }
16819
+ const fileUnifiedDiff = await unifiedDiff(currentGen, overlay, file);
16820
+ if (fileUnifiedDiff && fileUnifiedDiff.trim()) {
16821
+ fragments.push(fileUnifiedDiff);
16822
+ }
16823
+ }
16824
+ return {
16825
+ diff: fragments.join(""),
16826
+ unlocatableFiles
16827
+ };
16828
+ }
16829
+ async function computePerPatchDiffWithFallback(patch, getCurrentGenContent, getWorkingTreeContent, runCumulativeDiff) {
16830
+ const ppDiff = await computePerPatchDiff(
16831
+ patch,
16832
+ getCurrentGenContent,
16833
+ getWorkingTreeContent
16834
+ );
16835
+ if (ppDiff.unlocatableFiles.length === 0) {
16836
+ return { diff: ppDiff.diff, hadFallback: false, fallbackFiles: [] };
16837
+ }
16838
+ const cumulative = await runCumulativeDiff(ppDiff.unlocatableFiles);
16839
+ if (cumulative === null) {
16840
+ return null;
16841
+ }
16842
+ const merged = ppDiff.diff + (cumulative.trim() ? cumulative : "");
16843
+ return { diff: merged, hadFallback: true, fallbackFiles: ppDiff.unlocatableFiles };
16844
+ }
16845
+ function splitLines(content) {
16846
+ return content.split("\n");
16847
+ }
16848
+ function resolveSpan(loc, oursLines, prefer) {
16849
+ const { hunk, oursOffset } = loc;
16850
+ const oldSide = [];
16851
+ const newSide = [];
16852
+ for (const line of hunk.lines) {
16853
+ if (line.type === "context") {
16854
+ oldSide.push(line.content);
16855
+ newSide.push(line.content);
16856
+ } else if (line.type === "remove") {
16857
+ oldSide.push(line.content);
16858
+ } else if (line.type === "add") {
16859
+ newSide.push(line.content);
16860
+ }
16861
+ }
16862
+ const oldFits = sequenceMatches(oldSide, oursLines, oursOffset);
16863
+ const newFits = sequenceMatches(newSide, oursLines, oursOffset);
16864
+ if (prefer === "old") {
16865
+ if (oldFits) return { ...loc, oursSpan: hunk.oldCount };
16866
+ if (newFits) return { ...loc, oursSpan: hunk.newCount };
16867
+ } else {
16868
+ if (newFits) return { ...loc, oursSpan: hunk.newCount };
16869
+ if (oldFits) return { ...loc, oursSpan: hunk.oldCount };
16870
+ }
16871
+ return loc;
16872
+ }
16873
+ function sequenceMatches(needle, haystack, offset) {
16874
+ if (offset + needle.length > haystack.length) return false;
16875
+ for (let i = 0; i < needle.length; i++) {
16876
+ if (haystack[offset + i] !== needle[i]) return false;
16877
+ }
16878
+ return true;
16879
+ }
16880
+ function overlayRegions(currentGenLines, locInCurrentGen, workingTreeLines, locInWorkingTree) {
16881
+ if (locInCurrentGen.length !== locInWorkingTree.length) {
16882
+ return null;
16883
+ }
16884
+ const out = [];
16885
+ let cursor = 0;
16886
+ for (let i = 0; i < locInCurrentGen.length; i++) {
16887
+ const cg = locInCurrentGen[i];
16888
+ const wt = locInWorkingTree[i];
16889
+ if (cg.oursOffset > cursor) {
16890
+ out.push(...currentGenLines.slice(cursor, cg.oursOffset));
16891
+ }
16892
+ out.push(...workingTreeLines.slice(wt.oursOffset, wt.oursOffset + wt.oursSpan));
16893
+ cursor = cg.oursOffset + cg.oursSpan;
16894
+ }
16895
+ if (cursor < currentGenLines.length) {
16896
+ out.push(...currentGenLines.slice(cursor));
16897
+ }
16898
+ return out.join("\n");
16899
+ }
16900
+ async function unifiedDiff(beforeContent, afterContent, filePath) {
16901
+ if (beforeContent === afterContent) return "";
16902
+ const tmp = await (0, import_promises2.mkdtemp)((0, import_node_path4.join)((0, import_node_os2.tmpdir)(), "fern-replay-pp-"));
16903
+ try {
16904
+ const beforePath = (0, import_node_path4.join)(tmp, "before");
16905
+ const afterPath = (0, import_node_path4.join)(tmp, "after");
16906
+ await (0, import_promises2.writeFile)(beforePath, beforeContent, "utf-8");
16907
+ await (0, import_promises2.writeFile)(afterPath, afterContent, "utf-8");
16908
+ const raw = await runGitDiffNoIndex(beforePath, afterPath);
16909
+ if (!raw.trim()) return "";
16910
+ return rewriteDiffHeaders(raw, filePath);
16911
+ } finally {
16912
+ await (0, import_promises2.rm)(tmp, { recursive: true, force: true });
16913
+ }
16914
+ }
16915
+ function runGitDiffNoIndex(beforePath, afterPath) {
16916
+ return new Promise((resolve2, reject) => {
16917
+ const proc = (0, import_node_child_process.spawn)("git", ["diff", "--no-index", "--", beforePath, afterPath]);
16918
+ let stdout = "";
16919
+ let stderr = "";
16920
+ proc.stdout.on("data", (d) => stdout += d.toString());
16921
+ proc.stderr.on("data", (d) => stderr += d.toString());
16922
+ proc.on("close", (code) => {
16923
+ if (code === 0 || code === 1) resolve2(stdout);
16924
+ else reject(new Error(`git diff --no-index failed (code ${code}): ${stderr}`));
16925
+ });
16926
+ });
16927
+ }
16928
+ function rewriteDiffHeaders(raw, filePath) {
16929
+ const lines = raw.split("\n");
16930
+ const out = [];
16931
+ let seenFirstHeader = false;
16932
+ for (const line of lines) {
16933
+ if (line.startsWith("diff --git ")) {
16934
+ out.push(`diff --git a/${filePath} b/${filePath}`);
16935
+ seenFirstHeader = true;
16936
+ continue;
16937
+ }
16938
+ if (!seenFirstHeader) {
16939
+ continue;
16940
+ }
16941
+ if (line.startsWith("--- ")) {
16942
+ out.push(`--- a/${filePath}`);
16943
+ continue;
16944
+ }
16945
+ if (line.startsWith("+++ ")) {
16946
+ out.push(`+++ b/${filePath}`);
16947
+ continue;
16948
+ }
16949
+ out.push(line);
16950
+ }
16951
+ return out.join("\n");
16952
+ }
16953
+ function synthesizeNewFileDiff(file, content) {
16954
+ const lines = content.split("\n");
16955
+ const hasTrailingNewline = content.endsWith("\n");
16956
+ const bodyLines = hasTrailingNewline ? lines.slice(0, -1) : lines;
16957
+ const header = [
16958
+ `diff --git a/${file} b/${file}`,
16959
+ "new file mode 100644",
16960
+ "--- /dev/null",
16961
+ `+++ b/${file}`,
16962
+ `@@ -0,0 +1,${bodyLines.length} @@`
16963
+ ].join("\n");
16964
+ const body = bodyLines.map((l) => `+${l}`).join("\n");
16965
+ const trailer = hasTrailingNewline ? "" : "\n\";
16966
+ return header + "\n" + body + trailer + "\n";
16967
+ }
16968
+ function synthesizeDeleteFileDiff(file, content) {
16969
+ const lines = content.split("\n");
16970
+ const hasTrailingNewline = content.endsWith("\n");
16971
+ const bodyLines = hasTrailingNewline ? lines.slice(0, -1) : lines;
16972
+ const header = [
16973
+ `diff --git a/${file} b/${file}`,
16974
+ "deleted file mode 100644",
16975
+ `--- a/${file}`,
16976
+ "+++ /dev/null",
16977
+ `@@ -1,${bodyLines.length} +0,0 @@`
16978
+ ].join("\n");
16979
+ const body = bodyLines.map((l) => `-${l}`).join("\n");
16980
+ const trailer = hasTrailingNewline ? "" : "\n\";
16981
+ return header + "\n" + body + trailer + "\n";
16982
+ }
16983
+
16545
16984
  // src/ReplayService.ts
16546
16985
  var ReplayService = class {
16547
16986
  git;
@@ -16936,6 +17375,7 @@ var ReplayService = class {
16936
17375
  if (newPatches.length > 0) {
16937
17376
  results = await this.applicator.applyPatches(newPatches);
16938
17377
  this._lastApplyResults = results;
17378
+ this.recordDroppedContextLineWarnings(results);
16939
17379
  this.revertConflictingFiles(results);
16940
17380
  for (const result of results) {
16941
17381
  if (result.status === "conflict") {
@@ -16954,7 +17394,8 @@ var ReplayService = class {
16954
17394
  if (newPatches.length > 0) {
16955
17395
  if (!options?.stageOnly) {
16956
17396
  const appliedCount = results.filter((r) => r.status === "applied").length;
16957
- await this.committer.commitReplay(appliedCount, newPatches);
17397
+ const buckets = computeCommitBuckets(results, rebaseCounts.absorbedPatchIds, newPatches);
17398
+ await this.committer.commitReplay(appliedCount, newPatches, void 0, { buckets });
16958
17399
  } else {
16959
17400
  await this.committer.stageAll();
16960
17401
  }
@@ -17017,12 +17458,21 @@ var ReplayService = class {
17017
17458
  (p) => preRebasePatchIds.has(p.id) && !postRebasePatchIds.has(p.id)
17018
17459
  );
17019
17460
  existingPatches = this.lockManager.getPatches();
17020
- const seenHashes = /* @__PURE__ */ new Set();
17461
+ const seenHashes = /* @__PURE__ */ new Map();
17021
17462
  for (const p of existingPatches) {
17022
- if (seenHashes.has(p.content_hash)) {
17023
- this.lockManager.removePatch(p.id);
17463
+ const priorOrigin = seenHashes.get(p.content_hash);
17464
+ if (priorOrigin !== void 0) {
17465
+ const provenanceMissing = !priorOrigin || !p.original_commit;
17466
+ const sameOrigin = priorOrigin === p.original_commit;
17467
+ if (sameOrigin && !provenanceMissing) {
17468
+ this.lockManager.removePatch(p.id);
17469
+ } else {
17470
+ this.warnings.push(
17471
+ `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).`
17472
+ );
17473
+ }
17024
17474
  } else {
17025
- seenHashes.add(p.content_hash);
17475
+ seenHashes.set(p.content_hash, p.original_commit);
17026
17476
  }
17027
17477
  }
17028
17478
  existingPatches = this.lockManager.getPatches();
@@ -17083,6 +17533,7 @@ var ReplayService = class {
17083
17533
  const genSha = prep._prepared.genSha;
17084
17534
  const results = await this.applicator.applyPatches(allPatches);
17085
17535
  this._lastApplyResults = results;
17536
+ this.recordDroppedContextLineWarnings(results);
17086
17537
  this.revertConflictingFiles(results);
17087
17538
  for (const result of results) {
17088
17539
  if (result.status === "conflict") {
@@ -17110,7 +17561,8 @@ var ReplayService = class {
17110
17561
  await this.committer.stageAll();
17111
17562
  } else {
17112
17563
  const appliedCount = results.filter((r) => r.status === "applied").length;
17113
- await this.committer.commitReplay(appliedCount, allPatches);
17564
+ const buckets = computeCommitBuckets(results, rebaseCounts.absorbedPatchIds, allPatches);
17565
+ await this.committer.commitReplay(appliedCount, allPatches, void 0, { buckets });
17114
17566
  }
17115
17567
  const warnings = [...detectorWarnings, ...this.warnings];
17116
17568
  return this.buildReport(
@@ -17134,7 +17586,7 @@ var ReplayService = class {
17134
17586
  let repointed = 0;
17135
17587
  let contentRebased = 0;
17136
17588
  let keptAsUserOwned = 0;
17137
- const seenContentHashes = /* @__PURE__ */ new Set();
17589
+ const seenContentHashes = /* @__PURE__ */ new Map();
17138
17590
  const absorbedPatchIds = /* @__PURE__ */ new Set();
17139
17591
  const fernignorePatterns = this.readFernignorePatterns();
17140
17592
  for (const result of results) {
@@ -17148,6 +17600,23 @@ var ReplayService = class {
17148
17600
  }
17149
17601
  }
17150
17602
  }
17603
+ const conflictedFilePaths = /* @__PURE__ */ new Set();
17604
+ for (const r of results) {
17605
+ if (r.status !== "conflict") continue;
17606
+ if (r.fileResults) {
17607
+ for (const fr of r.fileResults) {
17608
+ if (fr.status !== "conflict") continue;
17609
+ conflictedFilePaths.add(fr.file);
17610
+ if (r.resolvedFiles) {
17611
+ for (const [orig, resolved] of Object.entries(r.resolvedFiles)) {
17612
+ if (resolved === fr.file) conflictedFilePaths.add(orig);
17613
+ }
17614
+ }
17615
+ }
17616
+ } else {
17617
+ for (const f of r.patch.files) conflictedFilePaths.add(f);
17618
+ }
17619
+ }
17151
17620
  for (const result of results) {
17152
17621
  if (result.status === "conflict" && result.fileResults) {
17153
17622
  await this.trimAbsorbedFiles(result, currentGenSha);
@@ -17215,6 +17684,18 @@ var ReplayService = class {
17215
17684
  keptAsUserOwned++;
17216
17685
  continue;
17217
17686
  }
17687
+ const overlapsConflicted = patch.files.some((f) => conflictedFilePaths.has(f));
17688
+ if (overlapsConflicted) {
17689
+ patch.status = "unresolved";
17690
+ try {
17691
+ this.lockManager.updatePatch(patch.id, { status: "unresolved" });
17692
+ } catch {
17693
+ }
17694
+ this.warnings.push(
17695
+ `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.`
17696
+ );
17697
+ continue;
17698
+ }
17218
17699
  this.lockManager.removePatch(patch.id);
17219
17700
  absorbedPatchIds.add(patch.id);
17220
17701
  absorbed++;
@@ -17230,13 +17711,22 @@ var ReplayService = class {
17230
17711
  continue;
17231
17712
  }
17232
17713
  const newContentHash = this.detector.computeContentHash(diff);
17233
- if (seenContentHashes.has(newContentHash)) {
17234
- this.lockManager.removePatch(patch.id);
17235
- absorbedPatchIds.add(patch.id);
17236
- absorbed++;
17237
- continue;
17714
+ const priorOrigin = seenContentHashes.get(newContentHash);
17715
+ if (priorOrigin !== void 0) {
17716
+ const provenanceMissing = !priorOrigin || !patch.original_commit;
17717
+ const sameOrigin = priorOrigin === patch.original_commit;
17718
+ if (sameOrigin && !provenanceMissing) {
17719
+ this.lockManager.removePatch(patch.id);
17720
+ absorbedPatchIds.add(patch.id);
17721
+ absorbed++;
17722
+ continue;
17723
+ }
17724
+ this.warnings.push(
17725
+ `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).`
17726
+ );
17727
+ } else {
17728
+ seenContentHashes.set(newContentHash, patch.original_commit);
17238
17729
  }
17239
- seenContentHashes.add(newContentHash);
17240
17730
  patch.base_generation = currentGenSha;
17241
17731
  patch.patch_content = diff;
17242
17732
  patch.content_hash = newContentHash;
@@ -17412,8 +17902,14 @@ var ReplayService = class {
17412
17902
  }
17413
17903
  if (patch.base_generation === currentGen) {
17414
17904
  try {
17415
- const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
17416
- if (diff === null) continue;
17905
+ const ppResult = await computePerPatchDiffWithFallback(
17906
+ patch,
17907
+ (f) => this.git.showFile(currentGen, f),
17908
+ (f) => this.git.showFile("HEAD", f),
17909
+ (files) => this.git.exec(["diff", currentGen, "HEAD", "--", ...files]).catch(() => null)
17910
+ );
17911
+ if (ppResult === null) continue;
17912
+ const diff = ppResult.diff;
17417
17913
  if (!diff.trim()) {
17418
17914
  if (await allPatchFilesUserOwned(patch)) continue;
17419
17915
  this.lockManager.removePatch(patch.id);
@@ -17455,8 +17951,14 @@ var ReplayService = class {
17455
17951
  try {
17456
17952
  const markerFiles = await this.git.exec(["grep", "-l", "<<<<<<< Generated", "HEAD", "--", ...patch.files]).catch(() => "");
17457
17953
  if (markerFiles.trim()) continue;
17458
- const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
17459
- if (diff === null) continue;
17954
+ const ppResult = await computePerPatchDiffWithFallback(
17955
+ patch,
17956
+ (f) => this.git.showFile(currentGen, f),
17957
+ (f) => this.git.showFile("HEAD", f),
17958
+ (files) => this.git.exec(["diff", currentGen, "HEAD", "--", ...files]).catch(() => null)
17959
+ );
17960
+ if (ppResult === null) continue;
17961
+ const diff = ppResult.diff;
17460
17962
  if (!diff.trim()) {
17461
17963
  if (await allPatchFilesUserOwned(patch)) {
17462
17964
  try {
@@ -17481,6 +17983,32 @@ var ReplayService = class {
17481
17983
  }
17482
17984
  return { conflictResolved, conflictAbsorbed, contentRefreshed };
17483
17985
  }
17986
+ /**
17987
+ * After applyPatches(), surface a warning for each (patch × file) where
17988
+ * diff3 silently dropped lines that were unchanged context in the
17989
+ * customer's THEIRS. The deletion stays on disk (correct diff3 outcome),
17990
+ * but the customer must learn so they can re-add the lines if they
17991
+ * relied on them. This is the "surface or preserve, never silently drop"
17992
+ * contract for legitimate-deletion cases.
17993
+ */
17994
+ recordDroppedContextLineWarnings(results) {
17995
+ const PREVIEW_COUNT = 5;
17996
+ for (const result of results) {
17997
+ if (!result.fileResults) continue;
17998
+ for (const fr of result.fileResults) {
17999
+ const dropped = fr.droppedContextLines;
18000
+ if (!dropped || dropped.length === 0) continue;
18001
+ const preview = dropped.slice(0, PREVIEW_COUNT).map((line) => ` ${line}`).join("\n");
18002
+ const more = dropped.length > PREVIEW_COUNT ? `
18003
+ ... and ${dropped.length - PREVIEW_COUNT} more` : "";
18004
+ this.warnings.push(
18005
+ `${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:
18006
+ ${preview}${more}
18007
+ 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.`
18008
+ );
18009
+ }
18010
+ }
18011
+ }
17484
18012
  /**
17485
18013
  * After applyPatches(), strip conflict markers from conflicting files
17486
18014
  * so only clean content is committed. Keeps the Generated (OURS) side.
@@ -17490,7 +18018,7 @@ var ReplayService = class {
17490
18018
  if (result.status !== "conflict" || !result.fileResults) continue;
17491
18019
  for (const fileResult of result.fileResults) {
17492
18020
  if (fileResult.status !== "conflict") continue;
17493
- const filePath = (0, import_node_path4.join)(this.outputDir, fileResult.file);
18021
+ const filePath = (0, import_node_path5.join)(this.outputDir, fileResult.file);
17494
18022
  try {
17495
18023
  const content = (0, import_node_fs2.readFileSync)(filePath, "utf-8");
17496
18024
  const stripped = stripConflictMarkers(content);
@@ -17520,7 +18048,7 @@ var ReplayService = class {
17520
18048
  }
17521
18049
  }
17522
18050
  readFernignorePatterns() {
17523
- const fernignorePath = (0, import_node_path4.join)(this.outputDir, ".fernignore");
18051
+ const fernignorePath = (0, import_node_path5.join)(this.outputDir, ".fernignore");
17524
18052
  if (!(0, import_node_fs2.existsSync)(fernignorePath)) return [];
17525
18053
  return (0, import_node_fs2.readFileSync)(fernignorePath, "utf-8").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
17526
18054
  }
@@ -17538,6 +18066,9 @@ var ReplayService = class {
17538
18066
  };
17539
18067
  }).filter((d) => d.files.length > 0);
17540
18068
  const partialCount = conflictDetails.filter((d) => d.cleanFiles && d.cleanFiles.length > 0).length;
18069
+ const unresolvedResults = results.filter(
18070
+ (r) => r.status === "conflict" || r.patch.status === "unresolved"
18071
+ );
17541
18072
  return {
17542
18073
  flow,
17543
18074
  patchesDetected: patches.length,
@@ -17554,7 +18085,7 @@ var ReplayService = class {
17554
18085
  patchesRefreshed: preRebaseCounts && preRebaseCounts.contentRefreshed > 0 ? preRebaseCounts.contentRefreshed : void 0,
17555
18086
  conflicts: conflictResults.flatMap((r) => r.fileResults?.filter((f) => f.status === "conflict") ?? []),
17556
18087
  conflictDetails: conflictDetails.length > 0 ? conflictDetails : void 0,
17557
- unresolvedPatches: conflictResults.length > 0 ? conflictResults.map((r) => ({
18088
+ unresolvedPatches: unresolvedResults.length > 0 ? unresolvedResults.map((r) => ({
17558
18089
  patchId: r.patch.id,
17559
18090
  patchMessage: r.patch.original_message,
17560
18091
  files: r.patch.files,
@@ -17565,11 +18096,38 @@ var ReplayService = class {
17565
18096
  };
17566
18097
  }
17567
18098
  };
18099
+ function computeCommitBuckets(results, absorbedPatchIds, patchesPassedToCommit) {
18100
+ const resultByPatchId = /* @__PURE__ */ new Map();
18101
+ for (const r of results) resultByPatchId.set(r.patch.id, r);
18102
+ const applied = [];
18103
+ const unresolved = [];
18104
+ const absorbed = [];
18105
+ for (const patch of patchesPassedToCommit) {
18106
+ if (absorbedPatchIds.has(patch.id)) {
18107
+ absorbed.push(patch);
18108
+ continue;
18109
+ }
18110
+ const r = resultByPatchId.get(patch.id);
18111
+ if (!r) {
18112
+ applied.push(patch);
18113
+ continue;
18114
+ }
18115
+ if (r.status === "conflict" || patch.status === "unresolved") {
18116
+ unresolved.push(patch);
18117
+ continue;
18118
+ }
18119
+ if (r.status === "applied") {
18120
+ applied.push(patch);
18121
+ continue;
18122
+ }
18123
+ }
18124
+ return { applied, unresolved, absorbed };
18125
+ }
17568
18126
 
17569
18127
  // src/FernignoreMigrator.ts
17570
18128
  var import_node_crypto2 = require("crypto");
17571
18129
  var import_node_fs3 = require("fs");
17572
- var import_node_path5 = require("path");
18130
+ var import_node_path6 = require("path");
17573
18131
  var import_yaml2 = __toESM(require_dist3(), 1);
17574
18132
  var FernignoreMigrator = class {
17575
18133
  git;
@@ -17581,10 +18139,10 @@ var FernignoreMigrator = class {
17581
18139
  this.outputDir = outputDir;
17582
18140
  }
17583
18141
  fernignoreExists() {
17584
- return (0, import_node_fs3.existsSync)((0, import_node_path5.join)(this.outputDir, ".fernignore"));
18142
+ return (0, import_node_fs3.existsSync)((0, import_node_path6.join)(this.outputDir, ".fernignore"));
17585
18143
  }
17586
18144
  readFernignorePatterns() {
17587
- const fernignorePath = (0, import_node_path5.join)(this.outputDir, ".fernignore");
18145
+ const fernignorePath = (0, import_node_path6.join)(this.outputDir, ".fernignore");
17588
18146
  if (!(0, import_node_fs3.existsSync)(fernignorePath)) {
17589
18147
  return [];
17590
18148
  }
@@ -17644,7 +18202,16 @@ var FernignoreMigrator = class {
17644
18202
  original_author: "Fern Replay <replay@buildwithfern.com>",
17645
18203
  base_generation: currentGen.commit_sha,
17646
18204
  files: patchFiles,
17647
- patch_content: patchContent
18205
+ patch_content: patchContent,
18206
+ // Synthetic patches captured from .fernignore-protected files
18207
+ // are user-owned by definition — the customer's content
18208
+ // diverges from the generator's pristine output, and the
18209
+ // generator never produced this divergent content. Without
18210
+ // this flag, removing the file from .fernignore later (so
18211
+ // it leaves the protection check in `isFileUserOwned`) would
18212
+ // expose the patch to absorption on the next regen,
18213
+ // silently losing the customization.
18214
+ user_owned: true
17648
18215
  });
17649
18216
  trackedByBoth.push({
17650
18217
  file: pattern,
@@ -17677,7 +18244,7 @@ var FernignoreMigrator = class {
17677
18244
  return results;
17678
18245
  }
17679
18246
  readFileContent(filePath) {
17680
- const fullPath = (0, import_node_path5.join)(this.outputDir, filePath);
18247
+ const fullPath = (0, import_node_path6.join)(this.outputDir, filePath);
17681
18248
  if (!(0, import_node_fs3.existsSync)(fullPath)) {
17682
18249
  return null;
17683
18250
  }
@@ -17742,7 +18309,7 @@ var FernignoreMigrator = class {
17742
18309
  };
17743
18310
  }
17744
18311
  movePatternsToReplayYml(patterns) {
17745
- const replayYmlPath = (0, import_node_path5.join)(this.outputDir, ".fern", "replay.yml");
18312
+ const replayYmlPath = (0, import_node_path6.join)(this.outputDir, ".fern", "replay.yml");
17746
18313
  let config = {};
17747
18314
  if ((0, import_node_fs3.existsSync)(replayYmlPath)) {
17748
18315
  const content = (0, import_node_fs3.readFileSync)(replayYmlPath, "utf-8");
@@ -17751,7 +18318,7 @@ var FernignoreMigrator = class {
17751
18318
  const existing = config.exclude ?? [];
17752
18319
  const merged = [.../* @__PURE__ */ new Set([...existing, ...patterns])];
17753
18320
  config.exclude = merged;
17754
- const dir = (0, import_node_path5.dirname)(replayYmlPath);
18321
+ const dir = (0, import_node_path6.dirname)(replayYmlPath);
17755
18322
  if (!(0, import_node_fs3.existsSync)(dir)) {
17756
18323
  (0, import_node_fs3.mkdirSync)(dir, { recursive: true });
17757
18324
  }
@@ -17762,7 +18329,7 @@ var FernignoreMigrator = class {
17762
18329
  // src/commands/bootstrap.ts
17763
18330
  var import_node_crypto3 = require("crypto");
17764
18331
  var import_node_fs4 = require("fs");
17765
- var import_node_path6 = require("path");
18332
+ var import_node_path7 = require("path");
17766
18333
  init_GitClient();
17767
18334
  async function bootstrap(outputDir, options) {
17768
18335
  const git = new GitClient(outputDir);
@@ -17929,7 +18496,7 @@ function parseGitLog(log) {
17929
18496
  }
17930
18497
  var REPLAY_FERNIGNORE_ENTRIES = [".fern/replay.lock", ".fern/replay.yml", ".gitattributes"];
17931
18498
  function ensureFernignoreEntries(outputDir) {
17932
- const fernignorePath = (0, import_node_path6.join)(outputDir, ".fernignore");
18499
+ const fernignorePath = (0, import_node_path7.join)(outputDir, ".fernignore");
17933
18500
  let content = "";
17934
18501
  if ((0, import_node_fs4.existsSync)(fernignorePath)) {
17935
18502
  content = (0, import_node_fs4.readFileSync)(fernignorePath, "utf-8");
@@ -17953,7 +18520,7 @@ function ensureFernignoreEntries(outputDir) {
17953
18520
  }
17954
18521
  var GITATTRIBUTES_ENTRIES = [".fern/replay.lock linguist-generated=true"];
17955
18522
  function ensureGitattributesEntries(outputDir) {
17956
- const gitattributesPath = (0, import_node_path6.join)(outputDir, ".gitattributes");
18523
+ const gitattributesPath = (0, import_node_path7.join)(outputDir, ".gitattributes");
17957
18524
  let content = "";
17958
18525
  if ((0, import_node_fs4.existsSync)(gitattributesPath)) {
17959
18526
  content = (0, import_node_fs4.readFileSync)(gitattributesPath, "utf-8");
@@ -18150,6 +18717,8 @@ function reset(outputDir, options) {
18150
18717
  }
18151
18718
 
18152
18719
  // src/commands/resolve.ts
18720
+ var import_promises3 = require("fs/promises");
18721
+ var import_node_path8 = require("path");
18153
18722
  init_GitClient();
18154
18723
  async function resolve(outputDir, options) {
18155
18724
  const lockManager = new LockfileManager(outputDir);
@@ -18165,7 +18734,7 @@ async function resolve(outputDir, options) {
18165
18734
  const resolvingPatches = lockManager.getResolvingPatches();
18166
18735
  if (unresolvedPatches.length > 0) {
18167
18736
  const applicator = new ReplayApplicator(git, lockManager, outputDir);
18168
- await applicator.applyPatches(unresolvedPatches);
18737
+ await applicator.applyPatches(unresolvedPatches, { applyMode: "resolve" });
18169
18738
  const markerFiles = await findConflictMarkerFiles(git);
18170
18739
  if (markerFiles.length > 0) {
18171
18740
  for (const patch of unresolvedPatches) {
@@ -18191,20 +18760,38 @@ async function resolve(outputDir, options) {
18191
18760
  if (patchesToCommit.length > 0) {
18192
18761
  const currentGen = lock.current_generation;
18193
18762
  const detector = new ReplayDetector(git, lockManager, outputDir);
18763
+ const warnings = [];
18194
18764
  let patchesResolved = 0;
18195
18765
  for (const patch of patchesToCommit) {
18196
- const diff = await git.exec(["diff", currentGen, "--", ...patch.files]).catch(() => null);
18197
- if (!diff || !diff.trim()) {
18766
+ const result = await computePerPatchDiffWithFallback(
18767
+ patch,
18768
+ (f) => git.showFile(currentGen, f),
18769
+ (f) => (0, import_promises3.readFile)((0, import_node_path8.join)(outputDir, f), "utf-8").catch(() => null),
18770
+ (files) => git.exec(["diff", currentGen, "--", ...files]).catch(() => null)
18771
+ );
18772
+ if (result === null) {
18773
+ warnings.push(
18774
+ `Patch ${patch.id}: cumulative-diff fallback failed for unlocatable files; preserving patch unchanged`
18775
+ );
18776
+ continue;
18777
+ }
18778
+ const diff = result.diff;
18779
+ if (!diff.trim()) {
18198
18780
  lockManager.removePatch(patch.id);
18199
18781
  continue;
18200
18782
  }
18783
+ if (result.hadFallback) {
18784
+ warnings.push(
18785
+ `Patch ${patch.id}: ${result.fallbackFiles.join(", ")} could not be anchored; cumulative-diff fallback applied for those files`
18786
+ );
18787
+ }
18201
18788
  const newContentHash = detector.computeContentHash(diff);
18202
- const changedFiles = await getChangedFiles(git, currentGen, patch.files);
18789
+ const changedFiles = result.hadFallback ? await getChangedFiles(git, currentGen, patch.files) : extractFilesFromDiff(diff);
18203
18790
  lockManager.markPatchResolved(patch.id, {
18204
18791
  patch_content: diff,
18205
18792
  content_hash: newContentHash,
18206
18793
  base_generation: currentGen,
18207
- files: changedFiles
18794
+ files: changedFiles.length > 0 ? changedFiles : patch.files
18208
18795
  });
18209
18796
  patchesResolved++;
18210
18797
  }
@@ -18220,7 +18807,8 @@ async function resolve(outputDir, options) {
18220
18807
  success: true,
18221
18808
  commitSha: commitSha2,
18222
18809
  phase: "committed",
18223
- patchesResolved
18810
+ patchesResolved,
18811
+ warnings: warnings.length > 0 ? warnings : void 0
18224
18812
  };
18225
18813
  }
18226
18814
  const committer = new ReplayCommitter(git, outputDir);
@@ -18238,6 +18826,24 @@ async function getChangedFiles(git, currentGen, files) {
18238
18826
  const changed = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !f.startsWith(".fern/"));
18239
18827
  return changed.length > 0 ? changed : files;
18240
18828
  }
18829
+ function extractFilesFromDiff(unifiedDiff2) {
18830
+ const out = /* @__PURE__ */ new Set();
18831
+ const renameSources = /* @__PURE__ */ new Set();
18832
+ let currentTarget = null;
18833
+ for (const line of unifiedDiff2.split("\n")) {
18834
+ const headerMatch = line.match(/^diff --git a\/(.+?) b\/(.+)$/);
18835
+ if (headerMatch) {
18836
+ currentTarget = headerMatch[2];
18837
+ if (!currentTarget.startsWith(".fern/")) out.add(currentTarget);
18838
+ continue;
18839
+ }
18840
+ if (currentTarget && line.startsWith("rename from ")) {
18841
+ renameSources.add(line.slice("rename from ".length));
18842
+ }
18843
+ }
18844
+ for (const src of renameSources) out.delete(src);
18845
+ return Array.from(out);
18846
+ }
18241
18847
 
18242
18848
  // src/commands/status.ts
18243
18849
  function status(outputDir) {
@@ -18372,7 +18978,7 @@ function parseArgs(argv) {
18372
18978
  positionals.push(arg);
18373
18979
  }
18374
18980
  }
18375
- return { command, dir: (0, import_node_path7.resolve)(dir), flags, positionals };
18981
+ return { command, dir: (0, import_node_path9.resolve)(dir), flags, positionals };
18376
18982
  }
18377
18983
  async function runBootstrap(dir, flags) {
18378
18984
  const dryRun = !!flags.dryRun;