@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/index.cjs CHANGED
@@ -409,6 +409,7 @@ __export(index_exports, {
409
409
  FernignoreMigrator: () => FernignoreMigrator,
410
410
  GitClient: () => GitClient,
411
411
  LockfileManager: () => LockfileManager,
412
+ LockfileNotFoundError: () => LockfileNotFoundError,
412
413
  ReplayApplicator: () => ReplayApplicator,
413
414
  ReplayCommitter: () => ReplayCommitter,
414
415
  ReplayDetector: () => ReplayDetector,
@@ -462,6 +463,12 @@ var import_node_fs = require("fs");
462
463
  var import_node_path = require("path");
463
464
  var import_yaml = require("yaml");
464
465
  var LOCKFILE_HEADER = "# DO NOT EDIT MANUALLY - Managed by Fern Replay\n";
466
+ var LockfileNotFoundError = class extends Error {
467
+ constructor(path) {
468
+ super(`Lockfile not found: ${path}`);
469
+ this.name = "LockfileNotFoundError";
470
+ }
471
+ };
465
472
  var LockfileManager = class {
466
473
  outputDir;
467
474
  lock = null;
@@ -481,12 +488,16 @@ var LockfileManager = class {
481
488
  if (this.lock) {
482
489
  return this.lock;
483
490
  }
484
- if (!this.exists()) {
485
- throw new Error(`Lockfile not found: ${this.lockfilePath}`);
491
+ try {
492
+ const content = (0, import_node_fs.readFileSync)(this.lockfilePath, "utf-8");
493
+ this.lock = (0, import_yaml.parse)(content);
494
+ return this.lock;
495
+ } catch (error) {
496
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
497
+ throw new LockfileNotFoundError(this.lockfilePath);
498
+ }
499
+ throw error;
486
500
  }
487
- const content = (0, import_node_fs.readFileSync)(this.lockfilePath, "utf-8");
488
- this.lock = (0, import_yaml.parse)(content);
489
- return this.lock;
490
501
  }
491
502
  initialize(firstGeneration) {
492
503
  this.initializeInMemory(firstGeneration);
@@ -632,13 +643,21 @@ var ReplayDetector = class {
632
643
  const exists = await this.git.commitExists(lastGen.commit_sha);
633
644
  if (!exists) {
634
645
  this.warnings.push(
635
- `Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Skipping new patch detection. Existing lockfile patches will still be applied.`
646
+ `Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to alternate detection.`
647
+ );
648
+ return this.detectPatchesViaTreeDiff(
649
+ lastGen,
650
+ /* commitKnownMissing */
651
+ true
636
652
  );
637
- return { patches: [], revertedPatchIds: [] };
638
653
  }
639
654
  const isAncestor = await this.git.isAncestor(lastGen.commit_sha, "HEAD");
640
655
  if (!isAncestor) {
641
- return this.detectPatchesViaTreeDiff(lastGen);
656
+ return this.detectPatchesViaTreeDiff(
657
+ lastGen,
658
+ /* commitKnownMissing */
659
+ false
660
+ );
642
661
  }
643
662
  const log = await this.git.exec([
644
663
  "log",
@@ -664,7 +683,15 @@ var ReplayDetector = class {
664
683
  if (lock.patches.find((p) => p.original_commit === commit.sha)) {
665
684
  continue;
666
685
  }
667
- const patchContent = await this.git.formatPatch(commit.sha);
686
+ let patchContent;
687
+ try {
688
+ patchContent = await this.git.formatPatch(commit.sha);
689
+ } catch {
690
+ this.warnings.push(
691
+ `Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
692
+ );
693
+ continue;
694
+ }
668
695
  const contentHash = this.computeContentHash(patchContent);
669
696
  if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
670
697
  continue;
@@ -757,11 +784,15 @@ var ReplayDetector = class {
757
784
  * Revert reconciliation is skipped here because tree-diff produces a single composite
758
785
  * patch from the aggregate diff — individual revert commits are not distinguishable.
759
786
  */
760
- async detectPatchesViaTreeDiff(lastGen) {
761
- const filesOutput = await this.git.exec(["diff", "--name-only", lastGen.commit_sha, "HEAD"]);
787
+ async detectPatchesViaTreeDiff(lastGen, commitKnownMissing) {
788
+ const diffBase = await this.resolveDiffBase(lastGen, commitKnownMissing);
789
+ if (!diffBase) {
790
+ return this.detectPatchesViaCommitScan();
791
+ }
792
+ const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
762
793
  const files = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
763
794
  if (files.length === 0) return { patches: [], revertedPatchIds: [] };
764
- const diff = await this.git.exec(["diff", lastGen.commit_sha, "HEAD", "--", ...files]);
795
+ const diff = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
765
796
  if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
766
797
  const contentHash = this.computeContentHash(diff);
767
798
  const lock = this.lockManager.read();
@@ -775,12 +806,113 @@ var ReplayDetector = class {
775
806
  original_commit: headSha,
776
807
  original_message: "Customer customizations (composite)",
777
808
  original_author: "composite",
778
- base_generation: lastGen.commit_sha,
809
+ // Use diffBase when commit is unreachable — the applicator needs a reachable
810
+ // reference to find base file content. diffBase may be the tree_hash.
811
+ base_generation: commitKnownMissing ? diffBase : lastGen.commit_sha,
779
812
  files,
780
813
  patch_content: diff
781
814
  };
782
815
  return { patches: [compositePatch], revertedPatchIds: [] };
783
816
  }
817
+ /**
818
+ * Last-resort detection when both generation commit and tree are unreachable.
819
+ * Scans all commits from HEAD, filters against known lockfile patches, and
820
+ * skips creation-only commits (squashed history after force push).
821
+ *
822
+ * Detected patches use the commit's parent as base_generation so the applicator
823
+ * can find base file content from the parent's tree (which IS reachable).
824
+ */
825
+ async detectPatchesViaCommitScan() {
826
+ const lock = this.lockManager.read();
827
+ const log = await this.git.exec([
828
+ "log",
829
+ "--max-count=200",
830
+ "--format=%H%x00%an%x00%ae%x00%s",
831
+ "HEAD",
832
+ "--",
833
+ this.sdkOutputDir
834
+ ]);
835
+ if (!log.trim()) {
836
+ return { patches: [], revertedPatchIds: [] };
837
+ }
838
+ const commits = this.parseGitLog(log);
839
+ const newPatches = [];
840
+ const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
841
+ const existingHashes = new Set(lock.patches.map((p) => p.content_hash));
842
+ const existingCommits = new Set(lock.patches.map((p) => p.original_commit));
843
+ for (const commit of commits) {
844
+ if (isGenerationCommit(commit)) {
845
+ continue;
846
+ }
847
+ const parents = await this.git.getCommitParents(commit.sha);
848
+ if (parents.length > 1) {
849
+ continue;
850
+ }
851
+ if (existingCommits.has(commit.sha)) {
852
+ continue;
853
+ }
854
+ let patchContent;
855
+ try {
856
+ patchContent = await this.git.formatPatch(commit.sha);
857
+ } catch {
858
+ continue;
859
+ }
860
+ const contentHash = this.computeContentHash(patchContent);
861
+ if (existingHashes.has(contentHash) || forgottenHashes.has(contentHash)) {
862
+ continue;
863
+ }
864
+ if (this.isCreationOnlyPatch(patchContent)) {
865
+ continue;
866
+ }
867
+ const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
868
+ const files = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f));
869
+ if (files.length === 0) {
870
+ continue;
871
+ }
872
+ if (parents.length === 0) {
873
+ continue;
874
+ }
875
+ const parentSha = parents[0];
876
+ newPatches.push({
877
+ id: `patch-${commit.sha.slice(0, 8)}`,
878
+ content_hash: contentHash,
879
+ original_commit: commit.sha,
880
+ original_message: commit.message,
881
+ original_author: `${commit.authorName} <${commit.authorEmail}>`,
882
+ base_generation: parentSha,
883
+ files,
884
+ patch_content: patchContent
885
+ });
886
+ }
887
+ newPatches.reverse();
888
+ return { patches: newPatches, revertedPatchIds: [] };
889
+ }
890
+ /**
891
+ * Check if a format-patch consists entirely of new-file creations.
892
+ * Used to identify squashed commits after force push, which create all files
893
+ * from scratch (--- /dev/null) rather than modifying existing files.
894
+ */
895
+ isCreationOnlyPatch(patchContent) {
896
+ const diffOldHeaders = patchContent.split("\n").filter((l) => l.startsWith("--- "));
897
+ if (diffOldHeaders.length === 0) {
898
+ return false;
899
+ }
900
+ return diffOldHeaders.every((l) => l === "--- /dev/null");
901
+ }
902
+ /**
903
+ * Resolve the best available diff base for a generation record.
904
+ * Prefers commit_sha, falls back to tree_hash for unreachable commits.
905
+ * When commitKnownMissing is true, skips the redundant commitExists check.
906
+ */
907
+ async resolveDiffBase(gen, commitKnownMissing) {
908
+ if (!commitKnownMissing && await this.git.commitExists(gen.commit_sha)) {
909
+ return gen.commit_sha;
910
+ }
911
+ if (await this.git.treeExists(gen.tree_hash)) {
912
+ return gen.tree_hash;
913
+ }
914
+ return null;
915
+ }
784
916
  parseGitLog(log) {
785
917
  return log.trim().split("\n").map((line) => {
786
918
  const [sha, authorName, authorEmail, message] = line.split("\0");
@@ -807,20 +939,33 @@ function threeWayMerge(base, ours, theirs) {
807
939
  outputLines.push(...region.ok);
808
940
  currentLine += region.ok.length;
809
941
  } else if (region.conflict) {
810
- const startLine = currentLine;
811
- outputLines.push("<<<<<<< Generated");
812
- outputLines.push(...region.conflict.a);
813
- outputLines.push("=======");
814
- outputLines.push(...region.conflict.b);
815
- outputLines.push(">>>>>>> Your customization");
816
- const conflictLines = region.conflict.a.length + region.conflict.b.length + 3;
817
- conflicts.push({
818
- startLine,
819
- endLine: startLine + conflictLines - 1,
820
- ours: region.conflict.a,
821
- theirs: region.conflict.b
822
- });
823
- currentLine += conflictLines;
942
+ const resolved = tryResolveConflict(
943
+ region.conflict.a,
944
+ // ours (generator)
945
+ region.conflict.o,
946
+ // base
947
+ region.conflict.b
948
+ // theirs (user)
949
+ );
950
+ if (resolved !== null) {
951
+ outputLines.push(...resolved);
952
+ currentLine += resolved.length;
953
+ } else {
954
+ const startLine = currentLine;
955
+ outputLines.push("<<<<<<< Generated");
956
+ outputLines.push(...region.conflict.a);
957
+ outputLines.push("=======");
958
+ outputLines.push(...region.conflict.b);
959
+ outputLines.push(">>>>>>> Your customization");
960
+ const conflictLines = region.conflict.a.length + region.conflict.b.length + 3;
961
+ conflicts.push({
962
+ startLine,
963
+ endLine: startLine + conflictLines - 1,
964
+ ours: region.conflict.a,
965
+ theirs: region.conflict.b
966
+ });
967
+ currentLine += conflictLines;
968
+ }
824
969
  }
825
970
  }
826
971
  return {
@@ -829,6 +974,62 @@ function threeWayMerge(base, ours, theirs) {
829
974
  conflicts
830
975
  };
831
976
  }
977
+ function tryResolveConflict(oursLines, baseLines, theirsLines) {
978
+ if (baseLines.length === 0) {
979
+ return null;
980
+ }
981
+ const oursPatches = (0, import_node_diff3.diffPatch)(baseLines, oursLines);
982
+ const theirsPatches = (0, import_node_diff3.diffPatch)(baseLines, theirsLines);
983
+ if (oursPatches.length === 0) return theirsLines;
984
+ if (theirsPatches.length === 0) return oursLines;
985
+ if (patchesOverlap(oursPatches, theirsPatches)) {
986
+ return null;
987
+ }
988
+ return applyBothPatches(baseLines, oursPatches, theirsPatches);
989
+ }
990
+ function patchesOverlap(oursPatches, theirsPatches) {
991
+ for (const op of oursPatches) {
992
+ const oStart = op.buffer1.offset;
993
+ const oEnd = oStart + op.buffer1.length;
994
+ for (const tp of theirsPatches) {
995
+ const tStart = tp.buffer1.offset;
996
+ const tEnd = tStart + tp.buffer1.length;
997
+ if (op.buffer1.length === 0 && tp.buffer1.length === 0 && oStart === tStart) {
998
+ return true;
999
+ }
1000
+ if (tp.buffer1.length === 0 && tStart === oEnd) {
1001
+ return true;
1002
+ }
1003
+ if (op.buffer1.length === 0 && oStart === tEnd) {
1004
+ return true;
1005
+ }
1006
+ if (oStart < tEnd && tStart < oEnd) {
1007
+ return true;
1008
+ }
1009
+ }
1010
+ }
1011
+ return false;
1012
+ }
1013
+ function applyBothPatches(baseLines, oursPatches, theirsPatches) {
1014
+ const allPatches = [
1015
+ ...oursPatches.map((p) => ({
1016
+ offset: p.buffer1.offset,
1017
+ length: p.buffer1.length,
1018
+ replacement: p.buffer2.chunk
1019
+ })),
1020
+ ...theirsPatches.map((p) => ({
1021
+ offset: p.buffer1.offset,
1022
+ length: p.buffer1.length,
1023
+ replacement: p.buffer2.chunk
1024
+ }))
1025
+ ];
1026
+ allPatches.sort((a, b) => b.offset - a.offset);
1027
+ const result = [...baseLines];
1028
+ for (const p of allPatches) {
1029
+ result.splice(p.offset, p.length, ...p.replacement);
1030
+ }
1031
+ return result;
1032
+ }
832
1033
 
833
1034
  // src/ReplayApplicator.ts
834
1035
  var import_promises = require("fs/promises");
@@ -837,29 +1038,74 @@ var import_node_path2 = require("path");
837
1038
  var import_minimatch = require("minimatch");
838
1039
 
839
1040
  // src/conflict-utils.ts
1041
+ var CONFLICT_OPENER = "<<<<<<< Generated";
1042
+ var CONFLICT_SEPARATOR = "=======";
1043
+ var CONFLICT_CLOSER = ">>>>>>> Your customization";
1044
+ function trimCR(line) {
1045
+ return line.endsWith("\r") ? line.slice(0, -1) : line;
1046
+ }
1047
+ function findConflictRanges(lines) {
1048
+ const ranges = [];
1049
+ let i = 0;
1050
+ while (i < lines.length) {
1051
+ if (trimCR(lines[i]) === CONFLICT_OPENER) {
1052
+ let separatorIdx = -1;
1053
+ let j = i + 1;
1054
+ let found = false;
1055
+ while (j < lines.length) {
1056
+ const trimmed = trimCR(lines[j]);
1057
+ if (trimmed === CONFLICT_OPENER) {
1058
+ break;
1059
+ }
1060
+ if (separatorIdx === -1 && trimmed === CONFLICT_SEPARATOR) {
1061
+ separatorIdx = j;
1062
+ } else if (separatorIdx !== -1 && trimmed === CONFLICT_CLOSER) {
1063
+ ranges.push({ start: i, separator: separatorIdx, end: j });
1064
+ i = j;
1065
+ found = true;
1066
+ break;
1067
+ }
1068
+ j++;
1069
+ }
1070
+ if (!found) {
1071
+ i++;
1072
+ continue;
1073
+ }
1074
+ }
1075
+ i++;
1076
+ }
1077
+ return ranges;
1078
+ }
840
1079
  function stripConflictMarkers(content) {
841
- const lines = content.split("\n");
1080
+ const lines = content.split(/\r?\n/);
1081
+ const ranges = findConflictRanges(lines);
1082
+ if (ranges.length === 0) {
1083
+ return content;
1084
+ }
842
1085
  const result = [];
843
- let inConflict = false;
844
- let inOurs = false;
845
- for (const line of lines) {
846
- if (line.startsWith("<<<<<<< ")) {
847
- inConflict = true;
848
- inOurs = true;
849
- continue;
850
- }
851
- if (inConflict && line === "=======") {
852
- inOurs = false;
853
- continue;
854
- }
855
- if (inConflict && line.startsWith(">>>>>>> ")) {
856
- inConflict = false;
857
- inOurs = false;
858
- continue;
859
- }
860
- if (!inConflict || inOurs) {
861
- result.push(line);
1086
+ let rangeIdx = 0;
1087
+ for (let i = 0; i < lines.length; i++) {
1088
+ if (rangeIdx < ranges.length) {
1089
+ const range = ranges[rangeIdx];
1090
+ if (i === range.start) {
1091
+ continue;
1092
+ }
1093
+ if (i > range.start && i < range.separator) {
1094
+ result.push(lines[i]);
1095
+ continue;
1096
+ }
1097
+ if (i === range.separator) {
1098
+ continue;
1099
+ }
1100
+ if (i > range.separator && i < range.end) {
1101
+ continue;
1102
+ }
1103
+ if (i === range.end) {
1104
+ rangeIdx++;
1105
+ continue;
1106
+ }
862
1107
  }
1108
+ result.push(lines[i]);
863
1109
  }
864
1110
  return result.join("\n");
865
1111
  }
@@ -926,6 +1172,27 @@ var ReplayApplicator = class {
926
1172
  this.lockManager = lockManager;
927
1173
  this.outputDir = outputDir;
928
1174
  }
1175
+ /**
1176
+ * Resolve the GenerationRecord for a patch's base_generation.
1177
+ * Falls back to constructing an ad-hoc record from the commit's tree
1178
+ * when base_generation isn't a tracked generation (commit-scan patches).
1179
+ */
1180
+ async resolveBaseGeneration(baseGeneration) {
1181
+ const gen = this.lockManager.getGeneration(baseGeneration);
1182
+ if (gen) return gen;
1183
+ try {
1184
+ const treeHash = await this.git.getTreeHash(baseGeneration);
1185
+ return {
1186
+ commit_sha: baseGeneration,
1187
+ tree_hash: treeHash,
1188
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1189
+ cli_version: "unknown",
1190
+ generator_versions: {}
1191
+ };
1192
+ } catch {
1193
+ return void 0;
1194
+ }
1195
+ }
929
1196
  /** Reset inter-patch accumulator for a new cycle. */
930
1197
  resetAccumulator() {
931
1198
  this.fileTheirsAccumulator.clear();
@@ -1007,7 +1274,7 @@ var ReplayApplicator = class {
1007
1274
  }
1008
1275
  }
1009
1276
  async applyPatchWithFallback(patch) {
1010
- const baseGen = this.lockManager.getGeneration(patch.base_generation);
1277
+ const baseGen = await this.resolveBaseGeneration(patch.base_generation);
1011
1278
  const lock = this.lockManager.read();
1012
1279
  const currentGen = lock.generations.find((g) => g.commit_sha === lock.current_generation);
1013
1280
  const currentTreeHash = currentGen?.tree_hash ?? baseGen?.tree_hash ?? "";
@@ -1091,7 +1358,7 @@ var ReplayApplicator = class {
1091
1358
  }
1092
1359
  async mergeFile(patch, filePath, tempGit, tempDir) {
1093
1360
  try {
1094
- const baseGen = this.lockManager.getGeneration(patch.base_generation);
1361
+ const baseGen = await this.resolveBaseGeneration(patch.base_generation);
1095
1362
  if (!baseGen) {
1096
1363
  return { file: filePath, status: "skipped", reason: "base-generation-not-found" };
1097
1364
  }
@@ -1143,6 +1410,17 @@ var ReplayApplicator = class {
1143
1410
  renameSourcePath
1144
1411
  );
1145
1412
  }
1413
+ if (theirs) {
1414
+ const theirsHasMarkers = theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization");
1415
+ const baseHasMarkers = base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization"));
1416
+ if (theirsHasMarkers && !baseHasMarkers) {
1417
+ return {
1418
+ file: resolvedPath,
1419
+ status: "skipped",
1420
+ reason: "stale-conflict-markers"
1421
+ };
1422
+ }
1423
+ }
1146
1424
  let useAccumulatorAsMergeBase = false;
1147
1425
  const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
1148
1426
  if (!theirs && accumulatorEntry) {
@@ -1157,6 +1435,17 @@ var ReplayApplicator = class {
1157
1435
  useAccumulatorAsMergeBase = true;
1158
1436
  }
1159
1437
  }
1438
+ if (theirs) {
1439
+ const theirsHasMarkers = theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization");
1440
+ const accBaseHasMarkers = accumulatorEntry != null && (accumulatorEntry.content.includes("<<<<<<< Generated") || accumulatorEntry.content.includes(">>>>>>> Your customization"));
1441
+ if (theirsHasMarkers && !accBaseHasMarkers && !(base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization")))) {
1442
+ return {
1443
+ file: resolvedPath,
1444
+ status: "skipped",
1445
+ reason: "stale-conflict-markers"
1446
+ };
1447
+ }
1448
+ }
1160
1449
  let effective_theirs = theirs;
1161
1450
  let baseMismatchSkipped = false;
1162
1451
  if (theirs && base && !useAccumulatorAsMergeBase) {
@@ -1223,7 +1512,7 @@ var ReplayApplicator = class {
1223
1512
  const outDir = (0, import_node_path2.dirname)(oursPath);
1224
1513
  await (0, import_promises.mkdir)(outDir, { recursive: true });
1225
1514
  await (0, import_promises.writeFile)(oursPath, merged.content);
1226
- if (effective_theirs) {
1515
+ if (effective_theirs && !merged.hasConflicts) {
1227
1516
  this.fileTheirsAccumulator.set(resolvedPath, {
1228
1517
  content: effective_theirs,
1229
1518
  baseGeneration: patch.base_generation
@@ -1525,6 +1814,7 @@ var ReplayService = class {
1525
1814
  generator_versions: options?.generatorVersions ?? {},
1526
1815
  base_branch_head: options?.baseBranchHead
1527
1816
  };
1817
+ let resolvedPatches;
1528
1818
  if (!this.lockManager.exists()) {
1529
1819
  this.lockManager.initializeInMemory(record);
1530
1820
  } else {
@@ -1533,13 +1823,13 @@ var ReplayService = class {
1533
1823
  ...this.lockManager.getUnresolvedPatches(),
1534
1824
  ...this.lockManager.getResolvingPatches()
1535
1825
  ];
1826
+ resolvedPatches = this.lockManager.getPatches().filter((p) => p.status == null);
1536
1827
  this.lockManager.addGeneration(record);
1537
1828
  this.lockManager.clearPatches();
1538
1829
  for (const patch of unresolvedPatches) {
1539
1830
  this.lockManager.addPatch(patch);
1540
1831
  }
1541
1832
  }
1542
- this.lockManager.save();
1543
1833
  try {
1544
1834
  const { patches: redetectedPatches } = await this.detector.detectNewPatches();
1545
1835
  if (redetectedPatches.length > 0) {
@@ -1553,20 +1843,27 @@ var ReplayService = class {
1553
1843
  for (const patch of redetectedPatches) {
1554
1844
  this.lockManager.addPatch(patch);
1555
1845
  }
1556
- this.lockManager.save();
1557
1846
  }
1558
- } catch {
1847
+ } catch (error) {
1848
+ for (const patch of resolvedPatches ?? []) {
1849
+ this.lockManager.addPatch(patch);
1850
+ }
1851
+ this.detector.warnings.push(
1852
+ `Patch re-detection failed after divergent merge sync. ${(resolvedPatches ?? []).length} previously resolved patch(es) preserved. Error: ${error instanceof Error ? error.message : String(error)}`
1853
+ );
1559
1854
  }
1855
+ this.lockManager.save();
1560
1856
  }
1561
1857
  determineFlow() {
1562
- if (!this.lockManager.exists()) {
1563
- return "first-generation";
1564
- }
1565
- const lock = this.lockManager.read();
1566
- if (lock.patches.length === 0) {
1567
- return "no-patches";
1858
+ try {
1859
+ const lock = this.lockManager.read();
1860
+ return lock.patches.length === 0 ? "no-patches" : "normal-regeneration";
1861
+ } catch (error) {
1862
+ if (error instanceof LockfileNotFoundError) {
1863
+ return "first-generation";
1864
+ }
1865
+ throw error;
1568
1866
  }
1569
- return "normal-regeneration";
1570
1867
  }
1571
1868
  async handleFirstGeneration(options) {
1572
1869
  if (options?.dryRun) {
@@ -1608,12 +1905,17 @@ var ReplayService = class {
1608
1905
  baseBranchHead: options.baseBranchHead
1609
1906
  } : void 0;
1610
1907
  await this.committer.commitGeneration("Update SDK (replay skipped)", commitOpts);
1908
+ await this.cleanupStaleConflictMarkers();
1611
1909
  const genRecord = await this.committer.createGenerationRecord(commitOpts);
1612
- if (this.lockManager.exists()) {
1910
+ try {
1613
1911
  this.lockManager.read();
1614
1912
  this.lockManager.addGeneration(genRecord);
1615
- } else {
1616
- this.lockManager.initializeInMemory(genRecord);
1913
+ } catch (error) {
1914
+ if (error instanceof LockfileNotFoundError) {
1915
+ this.lockManager.initializeInMemory(genRecord);
1916
+ } else {
1917
+ throw error;
1918
+ }
1617
1919
  }
1618
1920
  this.lockManager.setReplaySkippedAt((/* @__PURE__ */ new Date()).toISOString());
1619
1921
  this.lockManager.save();
@@ -1659,6 +1961,7 @@ var ReplayService = class {
1659
1961
  baseBranchHead: options.baseBranchHead
1660
1962
  } : void 0;
1661
1963
  await this.committer.commitGeneration("Update SDK", commitOpts);
1964
+ await this.cleanupStaleConflictMarkers();
1662
1965
  const genRecord = await this.committer.createGenerationRecord(commitOpts);
1663
1966
  this.lockManager.addGeneration(genRecord);
1664
1967
  let results = [];
@@ -1752,6 +2055,7 @@ var ReplayService = class {
1752
2055
  baseBranchHead: options.baseBranchHead
1753
2056
  } : void 0;
1754
2057
  await this.committer.commitGeneration("Update SDK", commitOpts);
2058
+ await this.cleanupStaleConflictMarkers();
1755
2059
  const genRecord = await this.committer.createGenerationRecord(commitOpts);
1756
2060
  this.lockManager.addGeneration(genRecord);
1757
2061
  const results = await this.applicator.applyPatches(allPatches);
@@ -1942,6 +2246,13 @@ var ReplayService = class {
1942
2246
  contentRefreshed++;
1943
2247
  continue;
1944
2248
  }
2249
+ const diffLines = diff.split("\n");
2250
+ const hasStaleMarkers = diffLines.some(
2251
+ (l) => l.startsWith("+<<<<<<< Generated") || l.startsWith("+>>>>>>> Your customization")
2252
+ );
2253
+ if (hasStaleMarkers) {
2254
+ continue;
2255
+ }
1945
2256
  const newContentHash = this.detector.computeContentHash(diff);
1946
2257
  if (newContentHash !== patch.content_hash) {
1947
2258
  const filesOutput = await this.git.exec(["diff", "--name-only", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
@@ -1962,7 +2273,7 @@ var ReplayService = class {
1962
2273
  continue;
1963
2274
  }
1964
2275
  try {
1965
- const markerFiles = await this.git.exec(["grep", "-l", "<<<<<<<", "HEAD", "--", ...patch.files]).catch(() => "");
2276
+ const markerFiles = await this.git.exec(["grep", "-l", "<<<<<<< Generated", "HEAD", "--", ...patch.files]).catch(() => "");
1966
2277
  if (markerFiles.trim()) continue;
1967
2278
  const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
1968
2279
  if (diff === null) continue;
@@ -2002,6 +2313,25 @@ var ReplayService = class {
2002
2313
  }
2003
2314
  }
2004
2315
  }
2316
+ /**
2317
+ * Clean up stale conflict markers left by a previous crashed run.
2318
+ * Called after commitGeneration() when HEAD is the [fern-generated] commit.
2319
+ * Restores files to their clean generated state from HEAD.
2320
+ * Skips .fernignore-protected files to prevent overwriting user content.
2321
+ */
2322
+ async cleanupStaleConflictMarkers() {
2323
+ const markerFiles = await this.git.exec(["grep", "-l", "<<<<<<< Generated", "--", "."]).catch(() => "");
2324
+ const files = markerFiles.trim().split("\n").filter(Boolean);
2325
+ if (files.length === 0) return;
2326
+ const fernignorePatterns = this.readFernignorePatterns();
2327
+ for (const file of files) {
2328
+ if (fernignorePatterns.some((pattern) => (0, import_minimatch2.minimatch)(file, pattern))) continue;
2329
+ try {
2330
+ await this.git.exec(["checkout", "HEAD", "--", file]);
2331
+ } catch {
2332
+ }
2333
+ }
2334
+ }
2005
2335
  readFernignorePatterns() {
2006
2336
  const fernignorePath = (0, import_node_path3.join)(this.outputDir, ".fernignore");
2007
2337
  if (!(0, import_node_fs2.existsSync)(fernignorePath)) return [];
@@ -2777,6 +3107,7 @@ function status(outputDir) {
2777
3107
  FernignoreMigrator,
2778
3108
  GitClient,
2779
3109
  LockfileManager,
3110
+ LockfileNotFoundError,
2780
3111
  ReplayApplicator,
2781
3112
  ReplayCommitter,
2782
3113
  ReplayDetector,