@fern-api/replay 0.9.1 → 0.10.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
@@ -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,6 +15669,17 @@ 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
15685
  if (!theirs && accumulatorEntry) {
@@ -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) {
@@ -15452,7 +15771,7 @@ var ReplayApplicator = class {
15452
15771
  const outDir = (0, import_node_path3.dirname)(oursPath);
15453
15772
  await (0, import_promises.mkdir)(outDir, { recursive: true });
15454
15773
  await (0, import_promises.writeFile)(oursPath, merged.content);
15455
- if (effective_theirs) {
15774
+ if (effective_theirs && !merged.hasConflicts) {
15456
15775
  this.fileTheirsAccumulator.set(resolvedPath, {
15457
15776
  content: effective_theirs,
15458
15777
  baseGeneration: patch.base_generation
@@ -15750,6 +16069,7 @@ var ReplayService = class {
15750
16069
  generator_versions: options?.generatorVersions ?? {},
15751
16070
  base_branch_head: options?.baseBranchHead
15752
16071
  };
16072
+ let resolvedPatches;
15753
16073
  if (!this.lockManager.exists()) {
15754
16074
  this.lockManager.initializeInMemory(record);
15755
16075
  } else {
@@ -15758,13 +16078,13 @@ var ReplayService = class {
15758
16078
  ...this.lockManager.getUnresolvedPatches(),
15759
16079
  ...this.lockManager.getResolvingPatches()
15760
16080
  ];
16081
+ resolvedPatches = this.lockManager.getPatches().filter((p) => p.status == null);
15761
16082
  this.lockManager.addGeneration(record);
15762
16083
  this.lockManager.clearPatches();
15763
16084
  for (const patch of unresolvedPatches) {
15764
16085
  this.lockManager.addPatch(patch);
15765
16086
  }
15766
16087
  }
15767
- this.lockManager.save();
15768
16088
  try {
15769
16089
  const { patches: redetectedPatches } = await this.detector.detectNewPatches();
15770
16090
  if (redetectedPatches.length > 0) {
@@ -15778,20 +16098,27 @@ var ReplayService = class {
15778
16098
  for (const patch of redetectedPatches) {
15779
16099
  this.lockManager.addPatch(patch);
15780
16100
  }
15781
- this.lockManager.save();
15782
16101
  }
15783
- } catch {
16102
+ } catch (error) {
16103
+ for (const patch of resolvedPatches ?? []) {
16104
+ this.lockManager.addPatch(patch);
16105
+ }
16106
+ this.detector.warnings.push(
16107
+ `Patch re-detection failed after divergent merge sync. ${(resolvedPatches ?? []).length} previously resolved patch(es) preserved. Error: ${error instanceof Error ? error.message : String(error)}`
16108
+ );
15784
16109
  }
16110
+ this.lockManager.save();
15785
16111
  }
15786
16112
  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";
16113
+ try {
16114
+ const lock = this.lockManager.read();
16115
+ return lock.patches.length === 0 ? "no-patches" : "normal-regeneration";
16116
+ } catch (error) {
16117
+ if (error instanceof LockfileNotFoundError) {
16118
+ return "first-generation";
16119
+ }
16120
+ throw error;
15793
16121
  }
15794
- return "normal-regeneration";
15795
16122
  }
15796
16123
  async handleFirstGeneration(options) {
15797
16124
  if (options?.dryRun) {
@@ -15833,12 +16160,17 @@ var ReplayService = class {
15833
16160
  baseBranchHead: options.baseBranchHead
15834
16161
  } : void 0;
15835
16162
  await this.committer.commitGeneration("Update SDK (replay skipped)", commitOpts);
16163
+ await this.cleanupStaleConflictMarkers();
15836
16164
  const genRecord = await this.committer.createGenerationRecord(commitOpts);
15837
- if (this.lockManager.exists()) {
16165
+ try {
15838
16166
  this.lockManager.read();
15839
16167
  this.lockManager.addGeneration(genRecord);
15840
- } else {
15841
- this.lockManager.initializeInMemory(genRecord);
16168
+ } catch (error) {
16169
+ if (error instanceof LockfileNotFoundError) {
16170
+ this.lockManager.initializeInMemory(genRecord);
16171
+ } else {
16172
+ throw error;
16173
+ }
15842
16174
  }
15843
16175
  this.lockManager.setReplaySkippedAt((/* @__PURE__ */ new Date()).toISOString());
15844
16176
  this.lockManager.save();
@@ -15884,6 +16216,7 @@ var ReplayService = class {
15884
16216
  baseBranchHead: options.baseBranchHead
15885
16217
  } : void 0;
15886
16218
  await this.committer.commitGeneration("Update SDK", commitOpts);
16219
+ await this.cleanupStaleConflictMarkers();
15887
16220
  const genRecord = await this.committer.createGenerationRecord(commitOpts);
15888
16221
  this.lockManager.addGeneration(genRecord);
15889
16222
  let results = [];
@@ -15977,6 +16310,7 @@ var ReplayService = class {
15977
16310
  baseBranchHead: options.baseBranchHead
15978
16311
  } : void 0;
15979
16312
  await this.committer.commitGeneration("Update SDK", commitOpts);
16313
+ await this.cleanupStaleConflictMarkers();
15980
16314
  const genRecord = await this.committer.createGenerationRecord(commitOpts);
15981
16315
  this.lockManager.addGeneration(genRecord);
15982
16316
  const results = await this.applicator.applyPatches(allPatches);
@@ -16167,6 +16501,13 @@ var ReplayService = class {
16167
16501
  contentRefreshed++;
16168
16502
  continue;
16169
16503
  }
16504
+ const diffLines = diff.split("\n");
16505
+ const hasStaleMarkers = diffLines.some(
16506
+ (l) => l.startsWith("+<<<<<<< Generated") || l.startsWith("+>>>>>>> Your customization")
16507
+ );
16508
+ if (hasStaleMarkers) {
16509
+ continue;
16510
+ }
16170
16511
  const newContentHash = this.detector.computeContentHash(diff);
16171
16512
  if (newContentHash !== patch.content_hash) {
16172
16513
  const filesOutput = await this.git.exec(["diff", "--name-only", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
@@ -16187,7 +16528,7 @@ var ReplayService = class {
16187
16528
  continue;
16188
16529
  }
16189
16530
  try {
16190
- const markerFiles = await this.git.exec(["grep", "-l", "<<<<<<<", "HEAD", "--", ...patch.files]).catch(() => "");
16531
+ const markerFiles = await this.git.exec(["grep", "-l", "<<<<<<< Generated", "HEAD", "--", ...patch.files]).catch(() => "");
16191
16532
  if (markerFiles.trim()) continue;
16192
16533
  const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
16193
16534
  if (diff === null) continue;
@@ -16227,6 +16568,25 @@ var ReplayService = class {
16227
16568
  }
16228
16569
  }
16229
16570
  }
16571
+ /**
16572
+ * Clean up stale conflict markers left by a previous crashed run.
16573
+ * Called after commitGeneration() when HEAD is the [fern-generated] commit.
16574
+ * Restores files to their clean generated state from HEAD.
16575
+ * Skips .fernignore-protected files to prevent overwriting user content.
16576
+ */
16577
+ async cleanupStaleConflictMarkers() {
16578
+ const markerFiles = await this.git.exec(["grep", "-l", "<<<<<<< Generated", "--", "."]).catch(() => "");
16579
+ const files = markerFiles.trim().split("\n").filter(Boolean);
16580
+ if (files.length === 0) return;
16581
+ const fernignorePatterns = this.readFernignorePatterns();
16582
+ for (const file of files) {
16583
+ if (fernignorePatterns.some((pattern) => minimatch(file, pattern))) continue;
16584
+ try {
16585
+ await this.git.exec(["checkout", "HEAD", "--", file]);
16586
+ } catch {
16587
+ }
16588
+ }
16589
+ }
16230
16590
  readFernignorePatterns() {
16231
16591
  const fernignorePath = (0, import_node_path4.join)(this.outputDir, ".fernignore");
16232
16592
  if (!(0, import_node_fs2.existsSync)(fernignorePath)) return [];