@fern-api/replay 0.9.1 → 0.10.1

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
@@ -13111,6 +13111,12 @@ var import_node_fs = require("fs");
13111
13111
  var import_node_path2 = require("path");
13112
13112
  var import_yaml = __toESM(require_dist3(), 1);
13113
13113
  var LOCKFILE_HEADER = "# DO NOT EDIT MANUALLY - Managed by Fern Replay\n";
13114
+ var LockfileNotFoundError = class extends Error {
13115
+ constructor(path2) {
13116
+ super(`Lockfile not found: ${path2}`);
13117
+ this.name = "LockfileNotFoundError";
13118
+ }
13119
+ };
13114
13120
  var LockfileManager = class {
13115
13121
  outputDir;
13116
13122
  lock = null;
@@ -13130,12 +13136,16 @@ var LockfileManager = class {
13130
13136
  if (this.lock) {
13131
13137
  return this.lock;
13132
13138
  }
13133
- if (!this.exists()) {
13134
- throw new Error(`Lockfile not found: ${this.lockfilePath}`);
13139
+ try {
13140
+ const content = (0, import_node_fs.readFileSync)(this.lockfilePath, "utf-8");
13141
+ this.lock = (0, import_yaml.parse)(content);
13142
+ return this.lock;
13143
+ } catch (error) {
13144
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
13145
+ throw new LockfileNotFoundError(this.lockfilePath);
13146
+ }
13147
+ throw error;
13135
13148
  }
13136
- const content = (0, import_node_fs.readFileSync)(this.lockfilePath, "utf-8");
13137
- this.lock = (0, import_yaml.parse)(content);
13138
- return this.lock;
13139
13149
  }
13140
13150
  initialize(firstGeneration) {
13141
13151
  this.initializeInMemory(firstGeneration);
@@ -13312,13 +13322,21 @@ var ReplayDetector = class {
13312
13322
  const exists2 = await this.git.commitExists(lastGen.commit_sha);
13313
13323
  if (!exists2) {
13314
13324
  this.warnings.push(
13315
- `Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Skipping new patch detection. Existing lockfile patches will still be applied.`
13325
+ `Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to alternate detection.`
13326
+ );
13327
+ return this.detectPatchesViaTreeDiff(
13328
+ lastGen,
13329
+ /* commitKnownMissing */
13330
+ true
13316
13331
  );
13317
- return { patches: [], revertedPatchIds: [] };
13318
13332
  }
13319
13333
  const isAncestor = await this.git.isAncestor(lastGen.commit_sha, "HEAD");
13320
13334
  if (!isAncestor) {
13321
- return this.detectPatchesViaTreeDiff(lastGen);
13335
+ return this.detectPatchesViaTreeDiff(
13336
+ lastGen,
13337
+ /* commitKnownMissing */
13338
+ false
13339
+ );
13322
13340
  }
13323
13341
  const log = await this.git.exec([
13324
13342
  "log",
@@ -13344,7 +13362,15 @@ var ReplayDetector = class {
13344
13362
  if (lock.patches.find((p) => p.original_commit === commit.sha)) {
13345
13363
  continue;
13346
13364
  }
13347
- const patchContent = await this.git.formatPatch(commit.sha);
13365
+ let patchContent;
13366
+ try {
13367
+ patchContent = await this.git.formatPatch(commit.sha);
13368
+ } catch {
13369
+ this.warnings.push(
13370
+ `Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
13371
+ );
13372
+ continue;
13373
+ }
13348
13374
  const contentHash = this.computeContentHash(patchContent);
13349
13375
  if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
13350
13376
  continue;
@@ -13437,11 +13463,15 @@ var ReplayDetector = class {
13437
13463
  * Revert reconciliation is skipped here because tree-diff produces a single composite
13438
13464
  * patch from the aggregate diff — individual revert commits are not distinguishable.
13439
13465
  */
13440
- async detectPatchesViaTreeDiff(lastGen) {
13441
- const filesOutput = await this.git.exec(["diff", "--name-only", lastGen.commit_sha, "HEAD"]);
13466
+ async detectPatchesViaTreeDiff(lastGen, commitKnownMissing) {
13467
+ const diffBase = await this.resolveDiffBase(lastGen, commitKnownMissing);
13468
+ if (!diffBase) {
13469
+ return this.detectPatchesViaCommitScan();
13470
+ }
13471
+ const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
13442
13472
  const files = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
13443
13473
  if (files.length === 0) return { patches: [], revertedPatchIds: [] };
13444
- const diff = await this.git.exec(["diff", lastGen.commit_sha, "HEAD", "--", ...files]);
13474
+ const diff = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
13445
13475
  if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
13446
13476
  const contentHash = this.computeContentHash(diff);
13447
13477
  const lock = this.lockManager.read();
@@ -13455,12 +13485,113 @@ var ReplayDetector = class {
13455
13485
  original_commit: headSha,
13456
13486
  original_message: "Customer customizations (composite)",
13457
13487
  original_author: "composite",
13458
- base_generation: lastGen.commit_sha,
13488
+ // Use diffBase when commit is unreachable — the applicator needs a reachable
13489
+ // reference to find base file content. diffBase may be the tree_hash.
13490
+ base_generation: commitKnownMissing ? diffBase : lastGen.commit_sha,
13459
13491
  files,
13460
13492
  patch_content: diff
13461
13493
  };
13462
13494
  return { patches: [compositePatch], revertedPatchIds: [] };
13463
13495
  }
13496
+ /**
13497
+ * Last-resort detection when both generation commit and tree are unreachable.
13498
+ * Scans all commits from HEAD, filters against known lockfile patches, and
13499
+ * skips creation-only commits (squashed history after force push).
13500
+ *
13501
+ * Detected patches use the commit's parent as base_generation so the applicator
13502
+ * can find base file content from the parent's tree (which IS reachable).
13503
+ */
13504
+ async detectPatchesViaCommitScan() {
13505
+ const lock = this.lockManager.read();
13506
+ const log = await this.git.exec([
13507
+ "log",
13508
+ "--max-count=200",
13509
+ "--format=%H%x00%an%x00%ae%x00%s",
13510
+ "HEAD",
13511
+ "--",
13512
+ this.sdkOutputDir
13513
+ ]);
13514
+ if (!log.trim()) {
13515
+ return { patches: [], revertedPatchIds: [] };
13516
+ }
13517
+ const commits = this.parseGitLog(log);
13518
+ const newPatches = [];
13519
+ const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
13520
+ const existingHashes = new Set(lock.patches.map((p) => p.content_hash));
13521
+ const existingCommits = new Set(lock.patches.map((p) => p.original_commit));
13522
+ for (const commit of commits) {
13523
+ if (isGenerationCommit(commit)) {
13524
+ continue;
13525
+ }
13526
+ const parents = await this.git.getCommitParents(commit.sha);
13527
+ if (parents.length > 1) {
13528
+ continue;
13529
+ }
13530
+ if (existingCommits.has(commit.sha)) {
13531
+ continue;
13532
+ }
13533
+ let patchContent;
13534
+ try {
13535
+ patchContent = await this.git.formatPatch(commit.sha);
13536
+ } catch {
13537
+ continue;
13538
+ }
13539
+ const contentHash = this.computeContentHash(patchContent);
13540
+ if (existingHashes.has(contentHash) || forgottenHashes.has(contentHash)) {
13541
+ continue;
13542
+ }
13543
+ if (this.isCreationOnlyPatch(patchContent)) {
13544
+ continue;
13545
+ }
13546
+ const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
13547
+ const files = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f));
13548
+ if (files.length === 0) {
13549
+ continue;
13550
+ }
13551
+ if (parents.length === 0) {
13552
+ continue;
13553
+ }
13554
+ const parentSha = parents[0];
13555
+ newPatches.push({
13556
+ id: `patch-${commit.sha.slice(0, 8)}`,
13557
+ content_hash: contentHash,
13558
+ original_commit: commit.sha,
13559
+ original_message: commit.message,
13560
+ original_author: `${commit.authorName} <${commit.authorEmail}>`,
13561
+ base_generation: parentSha,
13562
+ files,
13563
+ patch_content: patchContent
13564
+ });
13565
+ }
13566
+ newPatches.reverse();
13567
+ return { patches: newPatches, revertedPatchIds: [] };
13568
+ }
13569
+ /**
13570
+ * Check if a format-patch consists entirely of new-file creations.
13571
+ * Used to identify squashed commits after force push, which create all files
13572
+ * from scratch (--- /dev/null) rather than modifying existing files.
13573
+ */
13574
+ isCreationOnlyPatch(patchContent) {
13575
+ const diffOldHeaders = patchContent.split("\n").filter((l) => l.startsWith("--- "));
13576
+ if (diffOldHeaders.length === 0) {
13577
+ return false;
13578
+ }
13579
+ return diffOldHeaders.every((l) => l === "--- /dev/null");
13580
+ }
13581
+ /**
13582
+ * Resolve the best available diff base for a generation record.
13583
+ * Prefers commit_sha, falls back to tree_hash for unreachable commits.
13584
+ * When commitKnownMissing is true, skips the redundant commitExists check.
13585
+ */
13586
+ async resolveDiffBase(gen, commitKnownMissing) {
13587
+ if (!commitKnownMissing && await this.git.commitExists(gen.commit_sha)) {
13588
+ return gen.commit_sha;
13589
+ }
13590
+ if (await this.git.treeExists(gen.tree_hash)) {
13591
+ return gen.tree_hash;
13592
+ }
13593
+ return null;
13594
+ }
13464
13595
  parseGitLog(log) {
13465
13596
  return log.trim().split("\n").map((line) => {
13466
13597
  const [sha, authorName, authorEmail, message] = line.split("\0");
@@ -14815,29 +14946,74 @@ var import_node_os = require("os");
14815
14946
  var import_node_path3 = require("path");
14816
14947
 
14817
14948
  // src/conflict-utils.ts
14949
+ var CONFLICT_OPENER = "<<<<<<< Generated";
14950
+ var CONFLICT_SEPARATOR = "=======";
14951
+ var CONFLICT_CLOSER = ">>>>>>> Your customization";
14952
+ function trimCR(line) {
14953
+ return line.endsWith("\r") ? line.slice(0, -1) : line;
14954
+ }
14955
+ function findConflictRanges(lines) {
14956
+ const ranges = [];
14957
+ let i = 0;
14958
+ while (i < lines.length) {
14959
+ if (trimCR(lines[i]) === CONFLICT_OPENER) {
14960
+ let separatorIdx = -1;
14961
+ let j = i + 1;
14962
+ let found = false;
14963
+ while (j < lines.length) {
14964
+ const trimmed2 = trimCR(lines[j]);
14965
+ if (trimmed2 === CONFLICT_OPENER) {
14966
+ break;
14967
+ }
14968
+ if (separatorIdx === -1 && trimmed2 === CONFLICT_SEPARATOR) {
14969
+ separatorIdx = j;
14970
+ } else if (separatorIdx !== -1 && trimmed2 === CONFLICT_CLOSER) {
14971
+ ranges.push({ start: i, separator: separatorIdx, end: j });
14972
+ i = j;
14973
+ found = true;
14974
+ break;
14975
+ }
14976
+ j++;
14977
+ }
14978
+ if (!found) {
14979
+ i++;
14980
+ continue;
14981
+ }
14982
+ }
14983
+ i++;
14984
+ }
14985
+ return ranges;
14986
+ }
14818
14987
  function stripConflictMarkers(content) {
14819
- const lines = content.split("\n");
14988
+ const lines = content.split(/\r?\n/);
14989
+ const ranges = findConflictRanges(lines);
14990
+ if (ranges.length === 0) {
14991
+ return content;
14992
+ }
14820
14993
  const result = [];
14821
- let inConflict = false;
14822
- let inOurs = false;
14823
- for (const line of lines) {
14824
- if (line.startsWith("<<<<<<< ")) {
14825
- inConflict = true;
14826
- inOurs = true;
14827
- continue;
14828
- }
14829
- if (inConflict && line === "=======") {
14830
- inOurs = false;
14831
- continue;
14832
- }
14833
- if (inConflict && line.startsWith(">>>>>>> ")) {
14834
- inConflict = false;
14835
- inOurs = false;
14836
- continue;
14837
- }
14838
- if (!inConflict || inOurs) {
14839
- result.push(line);
14994
+ let rangeIdx = 0;
14995
+ for (let i = 0; i < lines.length; i++) {
14996
+ if (rangeIdx < ranges.length) {
14997
+ const range = ranges[rangeIdx];
14998
+ if (i === range.start) {
14999
+ continue;
15000
+ }
15001
+ if (i > range.start && i < range.separator) {
15002
+ result.push(lines[i]);
15003
+ continue;
15004
+ }
15005
+ if (i === range.separator) {
15006
+ continue;
15007
+ }
15008
+ if (i > range.separator && i < range.end) {
15009
+ continue;
15010
+ }
15011
+ if (i === range.end) {
15012
+ rangeIdx++;
15013
+ continue;
15014
+ }
14840
15015
  }
15016
+ result.push(lines[i]);
14841
15017
  }
14842
15018
  return result.join("\n");
14843
15019
  }
@@ -14908,6 +15084,37 @@ function diffIndices(buffer1, buffer2) {
14908
15084
  result.reverse();
14909
15085
  return result;
14910
15086
  }
15087
+ function diffPatch(buffer1, buffer2) {
15088
+ const lcs = LCS(buffer1, buffer2);
15089
+ let result = [];
15090
+ let tail1 = buffer1.length;
15091
+ let tail2 = buffer2.length;
15092
+ function chunkDescription(buffer, offset, length) {
15093
+ let chunk = [];
15094
+ for (let i = 0; i < length; i++) {
15095
+ chunk.push(buffer[offset + i]);
15096
+ }
15097
+ return {
15098
+ offset,
15099
+ length,
15100
+ chunk
15101
+ };
15102
+ }
15103
+ for (let candidate = lcs; candidate !== null; candidate = candidate.chain) {
15104
+ const mismatchLength1 = tail1 - candidate.buffer1index - 1;
15105
+ const mismatchLength2 = tail2 - candidate.buffer2index - 1;
15106
+ tail1 = candidate.buffer1index;
15107
+ tail2 = candidate.buffer2index;
15108
+ if (mismatchLength1 || mismatchLength2) {
15109
+ result.push({
15110
+ buffer1: chunkDescription(buffer1, candidate.buffer1index + 1, mismatchLength1),
15111
+ buffer2: chunkDescription(buffer2, candidate.buffer2index + 1, mismatchLength2)
15112
+ });
15113
+ }
15114
+ }
15115
+ result.reverse();
15116
+ return result;
15117
+ }
14911
15118
  function diff3MergeRegions(a, o, b) {
14912
15119
  let hunks = [];
14913
15120
  function addHunk(h, ab) {
@@ -15070,20 +15277,33 @@ function threeWayMerge(base, ours, theirs) {
15070
15277
  outputLines.push(...region.ok);
15071
15278
  currentLine += region.ok.length;
15072
15279
  } else if (region.conflict) {
15073
- const startLine = currentLine;
15074
- outputLines.push("<<<<<<< Generated");
15075
- outputLines.push(...region.conflict.a);
15076
- outputLines.push("=======");
15077
- outputLines.push(...region.conflict.b);
15078
- outputLines.push(">>>>>>> Your customization");
15079
- const conflictLines = region.conflict.a.length + region.conflict.b.length + 3;
15080
- conflicts2.push({
15081
- startLine,
15082
- endLine: startLine + conflictLines - 1,
15083
- ours: region.conflict.a,
15084
- theirs: region.conflict.b
15085
- });
15086
- currentLine += conflictLines;
15280
+ const resolved = tryResolveConflict(
15281
+ region.conflict.a,
15282
+ // ours (generator)
15283
+ region.conflict.o,
15284
+ // base
15285
+ region.conflict.b
15286
+ // theirs (user)
15287
+ );
15288
+ if (resolved !== null) {
15289
+ outputLines.push(...resolved);
15290
+ currentLine += resolved.length;
15291
+ } else {
15292
+ const startLine = currentLine;
15293
+ outputLines.push("<<<<<<< Generated");
15294
+ outputLines.push(...region.conflict.a);
15295
+ outputLines.push("=======");
15296
+ outputLines.push(...region.conflict.b);
15297
+ outputLines.push(">>>>>>> Your customization");
15298
+ const conflictLines = region.conflict.a.length + region.conflict.b.length + 3;
15299
+ conflicts2.push({
15300
+ startLine,
15301
+ endLine: startLine + conflictLines - 1,
15302
+ ours: region.conflict.a,
15303
+ theirs: region.conflict.b
15304
+ });
15305
+ currentLine += conflictLines;
15306
+ }
15087
15307
  }
15088
15308
  }
15089
15309
  return {
@@ -15092,6 +15312,62 @@ function threeWayMerge(base, ours, theirs) {
15092
15312
  conflicts: conflicts2
15093
15313
  };
15094
15314
  }
15315
+ function tryResolveConflict(oursLines, baseLines, theirsLines) {
15316
+ if (baseLines.length === 0) {
15317
+ return null;
15318
+ }
15319
+ const oursPatches = diffPatch(baseLines, oursLines);
15320
+ const theirsPatches = diffPatch(baseLines, theirsLines);
15321
+ if (oursPatches.length === 0) return theirsLines;
15322
+ if (theirsPatches.length === 0) return oursLines;
15323
+ if (patchesOverlap(oursPatches, theirsPatches)) {
15324
+ return null;
15325
+ }
15326
+ return applyBothPatches(baseLines, oursPatches, theirsPatches);
15327
+ }
15328
+ function patchesOverlap(oursPatches, theirsPatches) {
15329
+ for (const op of oursPatches) {
15330
+ const oStart = op.buffer1.offset;
15331
+ const oEnd = oStart + op.buffer1.length;
15332
+ for (const tp of theirsPatches) {
15333
+ const tStart = tp.buffer1.offset;
15334
+ const tEnd = tStart + tp.buffer1.length;
15335
+ if (op.buffer1.length === 0 && tp.buffer1.length === 0 && oStart === tStart) {
15336
+ return true;
15337
+ }
15338
+ if (tp.buffer1.length === 0 && tStart === oEnd) {
15339
+ return true;
15340
+ }
15341
+ if (op.buffer1.length === 0 && oStart === tEnd) {
15342
+ return true;
15343
+ }
15344
+ if (oStart < tEnd && tStart < oEnd) {
15345
+ return true;
15346
+ }
15347
+ }
15348
+ }
15349
+ return false;
15350
+ }
15351
+ function applyBothPatches(baseLines, oursPatches, theirsPatches) {
15352
+ const allPatches = [
15353
+ ...oursPatches.map((p) => ({
15354
+ offset: p.buffer1.offset,
15355
+ length: p.buffer1.length,
15356
+ replacement: p.buffer2.chunk
15357
+ })),
15358
+ ...theirsPatches.map((p) => ({
15359
+ offset: p.buffer1.offset,
15360
+ length: p.buffer1.length,
15361
+ replacement: p.buffer2.chunk
15362
+ }))
15363
+ ];
15364
+ allPatches.sort((a, b) => b.offset - a.offset);
15365
+ const result = [...baseLines];
15366
+ for (const p of allPatches) {
15367
+ result.splice(p.offset, p.length, ...p.replacement);
15368
+ }
15369
+ return result;
15370
+ }
15095
15371
 
15096
15372
  // src/ReplayApplicator.ts
15097
15373
  var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
@@ -15155,6 +15431,27 @@ var ReplayApplicator = class {
15155
15431
  this.lockManager = lockManager;
15156
15432
  this.outputDir = outputDir;
15157
15433
  }
15434
+ /**
15435
+ * Resolve the GenerationRecord for a patch's base_generation.
15436
+ * Falls back to constructing an ad-hoc record from the commit's tree
15437
+ * when base_generation isn't a tracked generation (commit-scan patches).
15438
+ */
15439
+ async resolveBaseGeneration(baseGeneration) {
15440
+ const gen = this.lockManager.getGeneration(baseGeneration);
15441
+ if (gen) return gen;
15442
+ try {
15443
+ const treeHash = await this.git.getTreeHash(baseGeneration);
15444
+ return {
15445
+ commit_sha: baseGeneration,
15446
+ tree_hash: treeHash,
15447
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
15448
+ cli_version: "unknown",
15449
+ generator_versions: {}
15450
+ };
15451
+ } catch {
15452
+ return void 0;
15453
+ }
15454
+ }
15158
15455
  /** Reset inter-patch accumulator for a new cycle. */
15159
15456
  resetAccumulator() {
15160
15457
  this.fileTheirsAccumulator.clear();
@@ -15236,7 +15533,7 @@ var ReplayApplicator = class {
15236
15533
  }
15237
15534
  }
15238
15535
  async applyPatchWithFallback(patch) {
15239
- const baseGen = this.lockManager.getGeneration(patch.base_generation);
15536
+ const baseGen = await this.resolveBaseGeneration(patch.base_generation);
15240
15537
  const lock = this.lockManager.read();
15241
15538
  const currentGen = lock.generations.find((g) => g.commit_sha === lock.current_generation);
15242
15539
  const currentTreeHash = currentGen?.tree_hash ?? baseGen?.tree_hash ?? "";
@@ -15320,7 +15617,7 @@ var ReplayApplicator = class {
15320
15617
  }
15321
15618
  async mergeFile(patch, filePath, tempGit, tempDir) {
15322
15619
  try {
15323
- const baseGen = this.lockManager.getGeneration(patch.base_generation);
15620
+ const baseGen = await this.resolveBaseGeneration(patch.base_generation);
15324
15621
  if (!baseGen) {
15325
15622
  return { file: filePath, status: "skipped", reason: "base-generation-not-found" };
15326
15623
  }
@@ -15372,9 +15669,20 @@ var ReplayApplicator = class {
15372
15669
  renameSourcePath
15373
15670
  );
15374
15671
  }
15672
+ if (theirs) {
15673
+ const theirsHasMarkers = theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization");
15674
+ const baseHasMarkers = base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization"));
15675
+ if (theirsHasMarkers && !baseHasMarkers) {
15676
+ return {
15677
+ file: resolvedPath,
15678
+ status: "skipped",
15679
+ reason: "stale-conflict-markers"
15680
+ };
15681
+ }
15682
+ }
15375
15683
  let useAccumulatorAsMergeBase = false;
15376
15684
  const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
15377
- if (!theirs && accumulatorEntry) {
15685
+ if (accumulatorEntry && (!theirs || base == null)) {
15378
15686
  theirs = await this.applyPatchToContent(
15379
15687
  accumulatorEntry.content,
15380
15688
  patch.patch_content,
@@ -15386,6 +15694,17 @@ var ReplayApplicator = class {
15386
15694
  useAccumulatorAsMergeBase = true;
15387
15695
  }
15388
15696
  }
15697
+ if (theirs) {
15698
+ const theirsHasMarkers = theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization");
15699
+ const accBaseHasMarkers = accumulatorEntry != null && (accumulatorEntry.content.includes("<<<<<<< Generated") || accumulatorEntry.content.includes(">>>>>>> Your customization"));
15700
+ if (theirsHasMarkers && !accBaseHasMarkers && !(base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization")))) {
15701
+ return {
15702
+ file: resolvedPath,
15703
+ status: "skipped",
15704
+ reason: "stale-conflict-markers"
15705
+ };
15706
+ }
15707
+ }
15389
15708
  let effective_theirs = theirs;
15390
15709
  let baseMismatchSkipped = false;
15391
15710
  if (theirs && base && !useAccumulatorAsMergeBase) {
@@ -15405,12 +15724,16 @@ var ReplayApplicator = class {
15405
15724
  }
15406
15725
  }
15407
15726
  if (base == null && !ours && effective_theirs) {
15727
+ this.fileTheirsAccumulator.set(resolvedPath, {
15728
+ content: effective_theirs,
15729
+ baseGeneration: patch.base_generation
15730
+ });
15408
15731
  const outDir2 = (0, import_node_path3.dirname)(oursPath);
15409
15732
  await (0, import_promises.mkdir)(outDir2, { recursive: true });
15410
15733
  await (0, import_promises.writeFile)(oursPath, effective_theirs);
15411
15734
  return { file: resolvedPath, status: "merged", reason: "new-file" };
15412
15735
  }
15413
- if (base == null && ours && effective_theirs) {
15736
+ if (base == null && ours && effective_theirs && !useAccumulatorAsMergeBase) {
15414
15737
  const merged2 = threeWayMerge("", ours, effective_theirs);
15415
15738
  const outDir2 = (0, import_node_path3.dirname)(oursPath);
15416
15739
  await (0, import_promises.mkdir)(outDir2, { recursive: true });
@@ -15452,7 +15775,7 @@ var ReplayApplicator = class {
15452
15775
  const outDir = (0, import_node_path3.dirname)(oursPath);
15453
15776
  await (0, import_promises.mkdir)(outDir, { recursive: true });
15454
15777
  await (0, import_promises.writeFile)(oursPath, merged.content);
15455
- if (effective_theirs) {
15778
+ if (effective_theirs && !merged.hasConflicts) {
15456
15779
  this.fileTheirsAccumulator.set(resolvedPath, {
15457
15780
  content: effective_theirs,
15458
15781
  baseGeneration: patch.base_generation
@@ -15593,16 +15916,24 @@ var ReplayApplicator = class {
15593
15916
  const addedLines = [];
15594
15917
  let inTargetFile = false;
15595
15918
  let inHunk = false;
15919
+ let isNewFile = false;
15596
15920
  let noTrailingNewline = false;
15597
15921
  for (const line of lines) {
15598
15922
  if (line.startsWith("diff --git")) {
15599
15923
  if (inTargetFile) break;
15600
15924
  inTargetFile = isDiffLineForFile(line, filePath);
15601
15925
  inHunk = false;
15926
+ isNewFile = false;
15602
15927
  continue;
15603
15928
  }
15604
15929
  if (!inTargetFile) continue;
15930
+ if (!inHunk) {
15931
+ if (line === "--- /dev/null" || line.startsWith("new file mode")) {
15932
+ isNewFile = true;
15933
+ }
15934
+ }
15605
15935
  if (line.startsWith("@@")) {
15936
+ if (!isNewFile) return null;
15606
15937
  inHunk = true;
15607
15938
  continue;
15608
15939
  }
@@ -15750,6 +16081,7 @@ var ReplayService = class {
15750
16081
  generator_versions: options?.generatorVersions ?? {},
15751
16082
  base_branch_head: options?.baseBranchHead
15752
16083
  };
16084
+ let resolvedPatches;
15753
16085
  if (!this.lockManager.exists()) {
15754
16086
  this.lockManager.initializeInMemory(record);
15755
16087
  } else {
@@ -15758,13 +16090,13 @@ var ReplayService = class {
15758
16090
  ...this.lockManager.getUnresolvedPatches(),
15759
16091
  ...this.lockManager.getResolvingPatches()
15760
16092
  ];
16093
+ resolvedPatches = this.lockManager.getPatches().filter((p) => p.status == null);
15761
16094
  this.lockManager.addGeneration(record);
15762
16095
  this.lockManager.clearPatches();
15763
16096
  for (const patch of unresolvedPatches) {
15764
16097
  this.lockManager.addPatch(patch);
15765
16098
  }
15766
16099
  }
15767
- this.lockManager.save();
15768
16100
  try {
15769
16101
  const { patches: redetectedPatches } = await this.detector.detectNewPatches();
15770
16102
  if (redetectedPatches.length > 0) {
@@ -15778,20 +16110,27 @@ var ReplayService = class {
15778
16110
  for (const patch of redetectedPatches) {
15779
16111
  this.lockManager.addPatch(patch);
15780
16112
  }
15781
- this.lockManager.save();
15782
16113
  }
15783
- } catch {
16114
+ } catch (error) {
16115
+ for (const patch of resolvedPatches ?? []) {
16116
+ this.lockManager.addPatch(patch);
16117
+ }
16118
+ this.detector.warnings.push(
16119
+ `Patch re-detection failed after divergent merge sync. ${(resolvedPatches ?? []).length} previously resolved patch(es) preserved. Error: ${error instanceof Error ? error.message : String(error)}`
16120
+ );
15784
16121
  }
16122
+ this.lockManager.save();
15785
16123
  }
15786
16124
  determineFlow() {
15787
- if (!this.lockManager.exists()) {
15788
- return "first-generation";
15789
- }
15790
- const lock = this.lockManager.read();
15791
- if (lock.patches.length === 0) {
15792
- return "no-patches";
16125
+ try {
16126
+ const lock = this.lockManager.read();
16127
+ return lock.patches.length === 0 ? "no-patches" : "normal-regeneration";
16128
+ } catch (error) {
16129
+ if (error instanceof LockfileNotFoundError) {
16130
+ return "first-generation";
16131
+ }
16132
+ throw error;
15793
16133
  }
15794
- return "normal-regeneration";
15795
16134
  }
15796
16135
  async handleFirstGeneration(options) {
15797
16136
  if (options?.dryRun) {
@@ -15833,12 +16172,17 @@ var ReplayService = class {
15833
16172
  baseBranchHead: options.baseBranchHead
15834
16173
  } : void 0;
15835
16174
  await this.committer.commitGeneration("Update SDK (replay skipped)", commitOpts);
16175
+ await this.cleanupStaleConflictMarkers();
15836
16176
  const genRecord = await this.committer.createGenerationRecord(commitOpts);
15837
- if (this.lockManager.exists()) {
16177
+ try {
15838
16178
  this.lockManager.read();
15839
16179
  this.lockManager.addGeneration(genRecord);
15840
- } else {
15841
- this.lockManager.initializeInMemory(genRecord);
16180
+ } catch (error) {
16181
+ if (error instanceof LockfileNotFoundError) {
16182
+ this.lockManager.initializeInMemory(genRecord);
16183
+ } else {
16184
+ throw error;
16185
+ }
15842
16186
  }
15843
16187
  this.lockManager.setReplaySkippedAt((/* @__PURE__ */ new Date()).toISOString());
15844
16188
  this.lockManager.save();
@@ -15884,6 +16228,7 @@ var ReplayService = class {
15884
16228
  baseBranchHead: options.baseBranchHead
15885
16229
  } : void 0;
15886
16230
  await this.committer.commitGeneration("Update SDK", commitOpts);
16231
+ await this.cleanupStaleConflictMarkers();
15887
16232
  const genRecord = await this.committer.createGenerationRecord(commitOpts);
15888
16233
  this.lockManager.addGeneration(genRecord);
15889
16234
  let results = [];
@@ -15977,6 +16322,7 @@ var ReplayService = class {
15977
16322
  baseBranchHead: options.baseBranchHead
15978
16323
  } : void 0;
15979
16324
  await this.committer.commitGeneration("Update SDK", commitOpts);
16325
+ await this.cleanupStaleConflictMarkers();
15980
16326
  const genRecord = await this.committer.createGenerationRecord(commitOpts);
15981
16327
  this.lockManager.addGeneration(genRecord);
15982
16328
  const results = await this.applicator.applyPatches(allPatches);
@@ -16167,6 +16513,13 @@ var ReplayService = class {
16167
16513
  contentRefreshed++;
16168
16514
  continue;
16169
16515
  }
16516
+ const diffLines = diff.split("\n");
16517
+ const hasStaleMarkers = diffLines.some(
16518
+ (l) => l.startsWith("+<<<<<<< Generated") || l.startsWith("+>>>>>>> Your customization")
16519
+ );
16520
+ if (hasStaleMarkers) {
16521
+ continue;
16522
+ }
16170
16523
  const newContentHash = this.detector.computeContentHash(diff);
16171
16524
  if (newContentHash !== patch.content_hash) {
16172
16525
  const filesOutput = await this.git.exec(["diff", "--name-only", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
@@ -16187,7 +16540,7 @@ var ReplayService = class {
16187
16540
  continue;
16188
16541
  }
16189
16542
  try {
16190
- const markerFiles = await this.git.exec(["grep", "-l", "<<<<<<<", "HEAD", "--", ...patch.files]).catch(() => "");
16543
+ const markerFiles = await this.git.exec(["grep", "-l", "<<<<<<< Generated", "HEAD", "--", ...patch.files]).catch(() => "");
16191
16544
  if (markerFiles.trim()) continue;
16192
16545
  const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
16193
16546
  if (diff === null) continue;
@@ -16227,6 +16580,25 @@ var ReplayService = class {
16227
16580
  }
16228
16581
  }
16229
16582
  }
16583
+ /**
16584
+ * Clean up stale conflict markers left by a previous crashed run.
16585
+ * Called after commitGeneration() when HEAD is the [fern-generated] commit.
16586
+ * Restores files to their clean generated state from HEAD.
16587
+ * Skips .fernignore-protected files to prevent overwriting user content.
16588
+ */
16589
+ async cleanupStaleConflictMarkers() {
16590
+ const markerFiles = await this.git.exec(["grep", "-l", "<<<<<<< Generated", "--", "."]).catch(() => "");
16591
+ const files = markerFiles.trim().split("\n").filter(Boolean);
16592
+ if (files.length === 0) return;
16593
+ const fernignorePatterns = this.readFernignorePatterns();
16594
+ for (const file of files) {
16595
+ if (fernignorePatterns.some((pattern) => minimatch(file, pattern))) continue;
16596
+ try {
16597
+ await this.git.exec(["checkout", "HEAD", "--", file]);
16598
+ } catch {
16599
+ }
16600
+ }
16601
+ }
16230
16602
  readFernignorePatterns() {
16231
16603
  const fernignorePath = (0, import_node_path4.join)(this.outputDir, ".fernignore");
16232
16604
  if (!(0, import_node_fs2.existsSync)(fernignorePath)) return [];