@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/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();
@@ -13552,17 +13552,57 @@ var ReplayDetector = class {
13552
13552
  }
13553
13553
  const isAncestor = await this.git.isAncestor(lastGen.commit_sha, "HEAD");
13554
13554
  if (!isAncestor) {
13555
+ return this.detectPatchesViaMergeBase(lastGen, lock);
13556
+ }
13557
+ return this.detectPatchesInRange(lastGen.commit_sha, lastGen, lock);
13558
+ }
13559
+ /**
13560
+ * FER-10201 — Non-linear-history detection via `merge-base(prevGen, HEAD)`.
13561
+ *
13562
+ * After a squash-merge of a Fern-bot PR, `lastGen.commit_sha` (the previous
13563
+ * `[fern-generated]`) is orphaned but still in git's object database. The
13564
+ * merge-base is the commit on main from which the bot's branch was created —
13565
+ * a reachable ancestor of HEAD. Walking that range and classifying each
13566
+ * commit via `isGenerationCommit` mirrors the linear path and eliminates
13567
+ * the bug class where pipeline-driven changes (autoversion, replay,
13568
+ * generation) get bundled as customer customizations.
13569
+ *
13570
+ * Falls through to the legacy composite path when no common ancestor exists
13571
+ * (truly disjoint history — orphan branches with no shared root).
13572
+ */
13573
+ async detectPatchesViaMergeBase(lastGen, lock) {
13574
+ let mergeBase = "";
13575
+ try {
13576
+ mergeBase = (await this.git.exec(["merge-base", lastGen.commit_sha, "HEAD"])).trim();
13577
+ } catch {
13578
+ }
13579
+ if (!mergeBase) {
13580
+ this.warnings.push(
13581
+ `No common ancestor between previous generation ${lastGen.commit_sha.slice(0, 7)} and HEAD. Falling back to composite tree-diff detection.`
13582
+ );
13555
13583
  return this.detectPatchesViaTreeDiff(
13556
13584
  lastGen,
13557
13585
  /* commitKnownMissing */
13558
13586
  false
13559
13587
  );
13560
13588
  }
13589
+ return this.detectPatchesInRange(mergeBase, lastGen, lock);
13590
+ }
13591
+ /**
13592
+ * FER-10201 — Walk `${rangeStart}..HEAD`, classify each commit, emit one
13593
+ * `StoredPatch` per customer commit. Body shared by the linear path
13594
+ * (rangeStart = lastGen.commit_sha) and the merge-base path.
13595
+ *
13596
+ * Patch `base_generation` is always `lastGen.commit_sha` — the
13597
+ * lockfile-tracked reference the applicator uses to fetch base file
13598
+ * content, regardless of where the walk actually started.
13599
+ */
13600
+ async detectPatchesInRange(rangeStart, lastGen, lock) {
13561
13601
  const log = await this.git.exec([
13562
13602
  "log",
13563
13603
  "--first-parent",
13564
13604
  "--format=%H%x00%an%x00%ae%x00%s",
13565
- `${lastGen.commit_sha}..HEAD`,
13605
+ `${rangeStart}..HEAD`,
13566
13606
  "--",
13567
13607
  this.sdkOutputDir
13568
13608
  ]);
@@ -14020,7 +14060,7 @@ var ReplayDetector = class {
14020
14060
 
14021
14061
  // src/ReplayService.ts
14022
14062
  var import_node_fs2 = require("fs");
14023
- var import_node_path4 = require("path");
14063
+ var import_node_path5 = require("path");
14024
14064
 
14025
14065
  // node_modules/minimatch/dist/esm/index.js
14026
14066
  var import_brace_expansion = __toESM(require_brace_expansion(), 1);
@@ -15721,11 +15761,43 @@ function threeWayMerge(base, ours, theirs) {
15721
15761
  }
15722
15762
  }
15723
15763
  }
15724
- return {
15764
+ const result = {
15725
15765
  content: outputLines.join("\n"),
15726
15766
  hasConflicts: conflicts2.length > 0,
15727
15767
  conflicts: conflicts2
15728
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;
15729
15801
  }
15730
15802
  function tryResolveConflict(oursLines, baseLines, theirsLines) {
15731
15803
  if (baseLines.length === 0) {
@@ -15841,6 +15913,19 @@ var ReplayApplicator = class {
15841
15913
  renameCache = /* @__PURE__ */ new Map();
15842
15914
  treeExistsCache = /* @__PURE__ */ new Map();
15843
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";
15844
15929
  constructor(git, lockManager, outputDir) {
15845
15930
  this.git = git;
15846
15931
  this.lockManager = lockManager;
@@ -15888,8 +15973,23 @@ var ReplayApplicator = class {
15888
15973
  /**
15889
15974
  * Apply all patches, returning results for each.
15890
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.
15891
15989
  */
15892
- async applyPatches(patches) {
15990
+ async applyPatches(patches, opts) {
15991
+ const applyMode = opts?.applyMode ?? "replay";
15992
+ this.currentApplyMode = applyMode;
15893
15993
  this.resetAccumulator();
15894
15994
  const results = [];
15895
15995
  for (let i = 0; i < patches.length; i++) {
@@ -15904,7 +16004,7 @@ var ReplayApplicator = class {
15904
16004
  }
15905
16005
  const result = await this.applyPatchWithFallback(patch);
15906
16006
  results.push(result);
15907
- if (result.status === "conflict" && result.fileResults) {
16007
+ if (applyMode !== "resolve" && result.status === "conflict" && result.fileResults) {
15908
16008
  const laterFiles = /* @__PURE__ */ new Set();
15909
16009
  for (let j = i + 1; j < patches.length; j++) {
15910
16010
  for (const f of patches[j].files) {
@@ -15934,9 +16034,17 @@ var ReplayApplicator = class {
15934
16034
  }
15935
16035
  return results;
15936
16036
  }
15937
- /** 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
+ */
15938
16045
  async populateAccumulatorForPatch(patch, baseGen, currentTreeHash) {
15939
- if (!baseGen) return;
16046
+ const fileResults = [];
16047
+ if (!baseGen) return fileResults;
15940
16048
  const tempDir = await (0, import_promises.mkdtemp)((0, import_node_path3.join)((0, import_node_os.tmpdir)(), "replay-acc-"));
15941
16049
  const { GitClient: GitClient2 } = await Promise.resolve().then(() => (init_GitClient(), GitClient_exports));
15942
16050
  const tempGit = new GitClient2(tempDir);
@@ -15949,18 +16057,30 @@ var ReplayApplicator = class {
15949
16057
  const resolvedPath = await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
15950
16058
  const base = await this.git.showFile(baseGen.tree_hash, filePath);
15951
16059
  const theirs = await this.applyPatchToContent(base, patch.patch_content, filePath, tempGit, tempDir);
15952
- 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;
15953
16062
  if (effectiveTheirs != null) {
15954
16063
  this.fileTheirsAccumulator.set(resolvedPath, {
15955
16064
  content: effectiveTheirs,
15956
16065
  baseGeneration: patch.base_generation
15957
16066
  });
15958
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
+ }
15959
16078
  }
15960
16079
  } finally {
15961
16080
  await (0, import_promises.rm)(tempDir, { recursive: true }).catch(() => {
15962
16081
  });
15963
16082
  }
16083
+ return fileResults;
15964
16084
  }
15965
16085
  async applyPatchWithFallback(patch) {
15966
16086
  const baseGen = await this.resolveBaseGeneration(patch.base_generation);
@@ -15991,11 +16111,12 @@ var ReplayApplicator = class {
15991
16111
  }
15992
16112
  try {
15993
16113
  await this.git.execWithInput(["apply", "--3way"], patch.patch_content);
15994
- await this.populateAccumulatorForPatch(patch, baseGen, currentTreeHash);
16114
+ const fastPathFileResults = await this.populateAccumulatorForPatch(patch, baseGen, currentTreeHash);
15995
16115
  return {
15996
16116
  patch,
15997
16117
  status: "applied",
15998
16118
  method: "git-am",
16119
+ ...fastPathFileResults.length > 0 && { fileResults: fastPathFileResults },
15999
16120
  ...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
16000
16121
  };
16001
16122
  } catch {
@@ -16107,19 +16228,23 @@ var ReplayApplicator = class {
16107
16228
  renameSourcePath
16108
16229
  );
16109
16230
  }
16231
+ const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
16110
16232
  if (theirs) {
16111
16233
  const theirsHasMarkers = theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization");
16112
16234
  const baseHasMarkers = base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization"));
16113
16235
  if (theirsHasMarkers && !baseHasMarkers) {
16114
- return {
16115
- file: resolvedPath,
16116
- status: "skipped",
16117
- reason: "stale-conflict-markers"
16118
- };
16236
+ if (accumulatorEntry) {
16237
+ theirs = null;
16238
+ } else {
16239
+ return {
16240
+ file: resolvedPath,
16241
+ status: "skipped",
16242
+ reason: "stale-conflict-markers"
16243
+ };
16244
+ }
16119
16245
  }
16120
16246
  }
16121
16247
  let useAccumulatorAsMergeBase = false;
16122
- const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
16123
16248
  if (accumulatorEntry && (theirs == null || base == null)) {
16124
16249
  theirs = await this.applyPatchToContent(
16125
16250
  accumulatorEntry.content,
@@ -16226,7 +16351,8 @@ var ReplayApplicator = class {
16226
16351
  const outDir = (0, import_node_path3.dirname)(oursPath);
16227
16352
  await (0, import_promises.mkdir)(outDir, { recursive: true });
16228
16353
  await (0, import_promises.writeFile)(oursPath, merged.content);
16229
- if (effective_theirs != null && !merged.hasConflicts) {
16354
+ const populateAccumulator = effective_theirs != null && (!merged.hasConflicts || this.currentApplyMode === "resolve");
16355
+ if (populateAccumulator) {
16230
16356
  this.fileTheirsAccumulator.set(resolvedPath, {
16231
16357
  content: effective_theirs,
16232
16358
  baseGeneration: patch.base_generation
@@ -16241,7 +16367,11 @@ var ReplayApplicator = class {
16241
16367
  conflictMetadata: metadata
16242
16368
  };
16243
16369
  }
16244
- return { file: resolvedPath, status: "merged" };
16370
+ return {
16371
+ file: resolvedPath,
16372
+ status: "merged",
16373
+ ...merged.droppedContextLines && merged.droppedContextLines.length > 0 ? { droppedContextLines: merged.droppedContextLines } : {}
16374
+ };
16245
16375
  } catch (error) {
16246
16376
  return {
16247
16377
  file: filePath,
@@ -16427,6 +16557,30 @@ function isBinaryFile(filePath) {
16427
16557
  const ext2 = (0, import_node_path3.extname)(filePath).toLowerCase();
16428
16558
  return BINARY_EXTENSIONS.has(ext2);
16429
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
+ }
16430
16584
  function isDiffLineForFile(diffLine, filePath) {
16431
16585
  const match2 = diffLine.match(/^diff --git a\/.+ b\/(.+)$/);
16432
16586
  return match2 !== null && match2[1] === filePath;
@@ -16462,13 +16616,46 @@ CLI Version: ${options.cliVersion}`;
16462
16616
  await this.git.exec(["commit", "-m", fullMessage]);
16463
16617
  return (await this.git.exec(["rev-parse", "HEAD"])).trim();
16464
16618
  }
16465
- async commitReplay(_patchCount, patches, message) {
16619
+ async commitReplay(_patchCount, patches, message, options) {
16466
16620
  await this.stageAll();
16467
16621
  if (!await this.hasStagedChanges()) {
16468
16622
  return (await this.git.exec(["rev-parse", "HEAD"])).trim();
16469
16623
  }
16470
16624
  let fullMessage = message ?? `[fern-replay] Applied customizations`;
16471
- 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) {
16472
16659
  fullMessage += "\n\nPatches replayed:";
16473
16660
  for (const patch of patches) {
16474
16661
  fullMessage += `
@@ -16502,6 +16689,298 @@ CLI Version: ${options.cliVersion}`;
16502
16689
  }
16503
16690
  };
16504
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
+
16505
16984
  // src/ReplayService.ts
16506
16985
  var ReplayService = class {
16507
16986
  git;
@@ -16896,6 +17375,7 @@ var ReplayService = class {
16896
17375
  if (newPatches.length > 0) {
16897
17376
  results = await this.applicator.applyPatches(newPatches);
16898
17377
  this._lastApplyResults = results;
17378
+ this.recordDroppedContextLineWarnings(results);
16899
17379
  this.revertConflictingFiles(results);
16900
17380
  for (const result of results) {
16901
17381
  if (result.status === "conflict") {
@@ -16914,7 +17394,8 @@ var ReplayService = class {
16914
17394
  if (newPatches.length > 0) {
16915
17395
  if (!options?.stageOnly) {
16916
17396
  const appliedCount = results.filter((r) => r.status === "applied").length;
16917
- await this.committer.commitReplay(appliedCount, newPatches);
17397
+ const buckets = computeCommitBuckets(results, rebaseCounts.absorbedPatchIds, newPatches);
17398
+ await this.committer.commitReplay(appliedCount, newPatches, void 0, { buckets });
16918
17399
  } else {
16919
17400
  await this.committer.stageAll();
16920
17401
  }
@@ -16977,12 +17458,21 @@ var ReplayService = class {
16977
17458
  (p) => preRebasePatchIds.has(p.id) && !postRebasePatchIds.has(p.id)
16978
17459
  );
16979
17460
  existingPatches = this.lockManager.getPatches();
16980
- const seenHashes = /* @__PURE__ */ new Set();
17461
+ const seenHashes = /* @__PURE__ */ new Map();
16981
17462
  for (const p of existingPatches) {
16982
- if (seenHashes.has(p.content_hash)) {
16983
- 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
+ }
16984
17474
  } else {
16985
- seenHashes.add(p.content_hash);
17475
+ seenHashes.set(p.content_hash, p.original_commit);
16986
17476
  }
16987
17477
  }
16988
17478
  existingPatches = this.lockManager.getPatches();
@@ -17043,6 +17533,7 @@ var ReplayService = class {
17043
17533
  const genSha = prep._prepared.genSha;
17044
17534
  const results = await this.applicator.applyPatches(allPatches);
17045
17535
  this._lastApplyResults = results;
17536
+ this.recordDroppedContextLineWarnings(results);
17046
17537
  this.revertConflictingFiles(results);
17047
17538
  for (const result of results) {
17048
17539
  if (result.status === "conflict") {
@@ -17070,7 +17561,8 @@ var ReplayService = class {
17070
17561
  await this.committer.stageAll();
17071
17562
  } else {
17072
17563
  const appliedCount = results.filter((r) => r.status === "applied").length;
17073
- await this.committer.commitReplay(appliedCount, allPatches);
17564
+ const buckets = computeCommitBuckets(results, rebaseCounts.absorbedPatchIds, allPatches);
17565
+ await this.committer.commitReplay(appliedCount, allPatches, void 0, { buckets });
17074
17566
  }
17075
17567
  const warnings = [...detectorWarnings, ...this.warnings];
17076
17568
  return this.buildReport(
@@ -17094,7 +17586,7 @@ var ReplayService = class {
17094
17586
  let repointed = 0;
17095
17587
  let contentRebased = 0;
17096
17588
  let keptAsUserOwned = 0;
17097
- const seenContentHashes = /* @__PURE__ */ new Set();
17589
+ const seenContentHashes = /* @__PURE__ */ new Map();
17098
17590
  const absorbedPatchIds = /* @__PURE__ */ new Set();
17099
17591
  const fernignorePatterns = this.readFernignorePatterns();
17100
17592
  for (const result of results) {
@@ -17108,6 +17600,23 @@ var ReplayService = class {
17108
17600
  }
17109
17601
  }
17110
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
+ }
17111
17620
  for (const result of results) {
17112
17621
  if (result.status === "conflict" && result.fileResults) {
17113
17622
  await this.trimAbsorbedFiles(result, currentGenSha);
@@ -17175,6 +17684,18 @@ var ReplayService = class {
17175
17684
  keptAsUserOwned++;
17176
17685
  continue;
17177
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
+ }
17178
17699
  this.lockManager.removePatch(patch.id);
17179
17700
  absorbedPatchIds.add(patch.id);
17180
17701
  absorbed++;
@@ -17190,13 +17711,22 @@ var ReplayService = class {
17190
17711
  continue;
17191
17712
  }
17192
17713
  const newContentHash = this.detector.computeContentHash(diff);
17193
- if (seenContentHashes.has(newContentHash)) {
17194
- this.lockManager.removePatch(patch.id);
17195
- absorbedPatchIds.add(patch.id);
17196
- absorbed++;
17197
- 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);
17198
17729
  }
17199
- seenContentHashes.add(newContentHash);
17200
17730
  patch.base_generation = currentGenSha;
17201
17731
  patch.patch_content = diff;
17202
17732
  patch.content_hash = newContentHash;
@@ -17372,8 +17902,14 @@ var ReplayService = class {
17372
17902
  }
17373
17903
  if (patch.base_generation === currentGen) {
17374
17904
  try {
17375
- const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
17376
- 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;
17377
17913
  if (!diff.trim()) {
17378
17914
  if (await allPatchFilesUserOwned(patch)) continue;
17379
17915
  this.lockManager.removePatch(patch.id);
@@ -17415,8 +17951,14 @@ var ReplayService = class {
17415
17951
  try {
17416
17952
  const markerFiles = await this.git.exec(["grep", "-l", "<<<<<<< Generated", "HEAD", "--", ...patch.files]).catch(() => "");
17417
17953
  if (markerFiles.trim()) continue;
17418
- const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
17419
- 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;
17420
17962
  if (!diff.trim()) {
17421
17963
  if (await allPatchFilesUserOwned(patch)) {
17422
17964
  try {
@@ -17441,6 +17983,32 @@ var ReplayService = class {
17441
17983
  }
17442
17984
  return { conflictResolved, conflictAbsorbed, contentRefreshed };
17443
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
+ }
17444
18012
  /**
17445
18013
  * After applyPatches(), strip conflict markers from conflicting files
17446
18014
  * so only clean content is committed. Keeps the Generated (OURS) side.
@@ -17450,7 +18018,7 @@ var ReplayService = class {
17450
18018
  if (result.status !== "conflict" || !result.fileResults) continue;
17451
18019
  for (const fileResult of result.fileResults) {
17452
18020
  if (fileResult.status !== "conflict") continue;
17453
- const filePath = (0, import_node_path4.join)(this.outputDir, fileResult.file);
18021
+ const filePath = (0, import_node_path5.join)(this.outputDir, fileResult.file);
17454
18022
  try {
17455
18023
  const content = (0, import_node_fs2.readFileSync)(filePath, "utf-8");
17456
18024
  const stripped = stripConflictMarkers(content);
@@ -17480,7 +18048,7 @@ var ReplayService = class {
17480
18048
  }
17481
18049
  }
17482
18050
  readFernignorePatterns() {
17483
- const fernignorePath = (0, import_node_path4.join)(this.outputDir, ".fernignore");
18051
+ const fernignorePath = (0, import_node_path5.join)(this.outputDir, ".fernignore");
17484
18052
  if (!(0, import_node_fs2.existsSync)(fernignorePath)) return [];
17485
18053
  return (0, import_node_fs2.readFileSync)(fernignorePath, "utf-8").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
17486
18054
  }
@@ -17498,6 +18066,9 @@ var ReplayService = class {
17498
18066
  };
17499
18067
  }).filter((d) => d.files.length > 0);
17500
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
+ );
17501
18072
  return {
17502
18073
  flow,
17503
18074
  patchesDetected: patches.length,
@@ -17514,7 +18085,7 @@ var ReplayService = class {
17514
18085
  patchesRefreshed: preRebaseCounts && preRebaseCounts.contentRefreshed > 0 ? preRebaseCounts.contentRefreshed : void 0,
17515
18086
  conflicts: conflictResults.flatMap((r) => r.fileResults?.filter((f) => f.status === "conflict") ?? []),
17516
18087
  conflictDetails: conflictDetails.length > 0 ? conflictDetails : void 0,
17517
- unresolvedPatches: conflictResults.length > 0 ? conflictResults.map((r) => ({
18088
+ unresolvedPatches: unresolvedResults.length > 0 ? unresolvedResults.map((r) => ({
17518
18089
  patchId: r.patch.id,
17519
18090
  patchMessage: r.patch.original_message,
17520
18091
  files: r.patch.files,
@@ -17525,11 +18096,38 @@ var ReplayService = class {
17525
18096
  };
17526
18097
  }
17527
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
+ }
17528
18126
 
17529
18127
  // src/FernignoreMigrator.ts
17530
18128
  var import_node_crypto2 = require("crypto");
17531
18129
  var import_node_fs3 = require("fs");
17532
- var import_node_path5 = require("path");
18130
+ var import_node_path6 = require("path");
17533
18131
  var import_yaml2 = __toESM(require_dist3(), 1);
17534
18132
  var FernignoreMigrator = class {
17535
18133
  git;
@@ -17541,10 +18139,10 @@ var FernignoreMigrator = class {
17541
18139
  this.outputDir = outputDir;
17542
18140
  }
17543
18141
  fernignoreExists() {
17544
- 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"));
17545
18143
  }
17546
18144
  readFernignorePatterns() {
17547
- const fernignorePath = (0, import_node_path5.join)(this.outputDir, ".fernignore");
18145
+ const fernignorePath = (0, import_node_path6.join)(this.outputDir, ".fernignore");
17548
18146
  if (!(0, import_node_fs3.existsSync)(fernignorePath)) {
17549
18147
  return [];
17550
18148
  }
@@ -17604,7 +18202,16 @@ var FernignoreMigrator = class {
17604
18202
  original_author: "Fern Replay <replay@buildwithfern.com>",
17605
18203
  base_generation: currentGen.commit_sha,
17606
18204
  files: patchFiles,
17607
- 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
17608
18215
  });
17609
18216
  trackedByBoth.push({
17610
18217
  file: pattern,
@@ -17637,7 +18244,7 @@ var FernignoreMigrator = class {
17637
18244
  return results;
17638
18245
  }
17639
18246
  readFileContent(filePath) {
17640
- const fullPath = (0, import_node_path5.join)(this.outputDir, filePath);
18247
+ const fullPath = (0, import_node_path6.join)(this.outputDir, filePath);
17641
18248
  if (!(0, import_node_fs3.existsSync)(fullPath)) {
17642
18249
  return null;
17643
18250
  }
@@ -17702,7 +18309,7 @@ var FernignoreMigrator = class {
17702
18309
  };
17703
18310
  }
17704
18311
  movePatternsToReplayYml(patterns) {
17705
- 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");
17706
18313
  let config = {};
17707
18314
  if ((0, import_node_fs3.existsSync)(replayYmlPath)) {
17708
18315
  const content = (0, import_node_fs3.readFileSync)(replayYmlPath, "utf-8");
@@ -17711,7 +18318,7 @@ var FernignoreMigrator = class {
17711
18318
  const existing = config.exclude ?? [];
17712
18319
  const merged = [.../* @__PURE__ */ new Set([...existing, ...patterns])];
17713
18320
  config.exclude = merged;
17714
- const dir = (0, import_node_path5.dirname)(replayYmlPath);
18321
+ const dir = (0, import_node_path6.dirname)(replayYmlPath);
17715
18322
  if (!(0, import_node_fs3.existsSync)(dir)) {
17716
18323
  (0, import_node_fs3.mkdirSync)(dir, { recursive: true });
17717
18324
  }
@@ -17722,7 +18329,7 @@ var FernignoreMigrator = class {
17722
18329
  // src/commands/bootstrap.ts
17723
18330
  var import_node_crypto3 = require("crypto");
17724
18331
  var import_node_fs4 = require("fs");
17725
- var import_node_path6 = require("path");
18332
+ var import_node_path7 = require("path");
17726
18333
  init_GitClient();
17727
18334
  async function bootstrap(outputDir, options) {
17728
18335
  const git = new GitClient(outputDir);
@@ -17889,7 +18496,7 @@ function parseGitLog(log) {
17889
18496
  }
17890
18497
  var REPLAY_FERNIGNORE_ENTRIES = [".fern/replay.lock", ".fern/replay.yml", ".gitattributes"];
17891
18498
  function ensureFernignoreEntries(outputDir) {
17892
- const fernignorePath = (0, import_node_path6.join)(outputDir, ".fernignore");
18499
+ const fernignorePath = (0, import_node_path7.join)(outputDir, ".fernignore");
17893
18500
  let content = "";
17894
18501
  if ((0, import_node_fs4.existsSync)(fernignorePath)) {
17895
18502
  content = (0, import_node_fs4.readFileSync)(fernignorePath, "utf-8");
@@ -17913,7 +18520,7 @@ function ensureFernignoreEntries(outputDir) {
17913
18520
  }
17914
18521
  var GITATTRIBUTES_ENTRIES = [".fern/replay.lock linguist-generated=true"];
17915
18522
  function ensureGitattributesEntries(outputDir) {
17916
- const gitattributesPath = (0, import_node_path6.join)(outputDir, ".gitattributes");
18523
+ const gitattributesPath = (0, import_node_path7.join)(outputDir, ".gitattributes");
17917
18524
  let content = "";
17918
18525
  if ((0, import_node_fs4.existsSync)(gitattributesPath)) {
17919
18526
  content = (0, import_node_fs4.readFileSync)(gitattributesPath, "utf-8");
@@ -18110,6 +18717,8 @@ function reset(outputDir, options) {
18110
18717
  }
18111
18718
 
18112
18719
  // src/commands/resolve.ts
18720
+ var import_promises3 = require("fs/promises");
18721
+ var import_node_path8 = require("path");
18113
18722
  init_GitClient();
18114
18723
  async function resolve(outputDir, options) {
18115
18724
  const lockManager = new LockfileManager(outputDir);
@@ -18125,7 +18734,7 @@ async function resolve(outputDir, options) {
18125
18734
  const resolvingPatches = lockManager.getResolvingPatches();
18126
18735
  if (unresolvedPatches.length > 0) {
18127
18736
  const applicator = new ReplayApplicator(git, lockManager, outputDir);
18128
- await applicator.applyPatches(unresolvedPatches);
18737
+ await applicator.applyPatches(unresolvedPatches, { applyMode: "resolve" });
18129
18738
  const markerFiles = await findConflictMarkerFiles(git);
18130
18739
  if (markerFiles.length > 0) {
18131
18740
  for (const patch of unresolvedPatches) {
@@ -18151,20 +18760,38 @@ async function resolve(outputDir, options) {
18151
18760
  if (patchesToCommit.length > 0) {
18152
18761
  const currentGen = lock.current_generation;
18153
18762
  const detector = new ReplayDetector(git, lockManager, outputDir);
18763
+ const warnings = [];
18154
18764
  let patchesResolved = 0;
18155
18765
  for (const patch of patchesToCommit) {
18156
- const diff = await git.exec(["diff", currentGen, "--", ...patch.files]).catch(() => null);
18157
- 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()) {
18158
18780
  lockManager.removePatch(patch.id);
18159
18781
  continue;
18160
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
+ }
18161
18788
  const newContentHash = detector.computeContentHash(diff);
18162
- const changedFiles = await getChangedFiles(git, currentGen, patch.files);
18789
+ const changedFiles = result.hadFallback ? await getChangedFiles(git, currentGen, patch.files) : extractFilesFromDiff(diff);
18163
18790
  lockManager.markPatchResolved(patch.id, {
18164
18791
  patch_content: diff,
18165
18792
  content_hash: newContentHash,
18166
18793
  base_generation: currentGen,
18167
- files: changedFiles
18794
+ files: changedFiles.length > 0 ? changedFiles : patch.files
18168
18795
  });
18169
18796
  patchesResolved++;
18170
18797
  }
@@ -18180,7 +18807,8 @@ async function resolve(outputDir, options) {
18180
18807
  success: true,
18181
18808
  commitSha: commitSha2,
18182
18809
  phase: "committed",
18183
- patchesResolved
18810
+ patchesResolved,
18811
+ warnings: warnings.length > 0 ? warnings : void 0
18184
18812
  };
18185
18813
  }
18186
18814
  const committer = new ReplayCommitter(git, outputDir);
@@ -18198,6 +18826,24 @@ async function getChangedFiles(git, currentGen, files) {
18198
18826
  const changed = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !f.startsWith(".fern/"));
18199
18827
  return changed.length > 0 ? changed : files;
18200
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
+ }
18201
18847
 
18202
18848
  // src/commands/status.ts
18203
18849
  function status(outputDir) {
@@ -18332,7 +18978,7 @@ function parseArgs(argv) {
18332
18978
  positionals.push(arg);
18333
18979
  }
18334
18980
  }
18335
- return { command, dir: (0, import_node_path7.resolve)(dir), flags, positionals };
18981
+ return { command, dir: (0, import_node_path9.resolve)(dir), flags, positionals };
18336
18982
  }
18337
18983
  async function runBootstrap(dir, flags) {
18338
18984
  const dryRun = !!flags.dryRun;