@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.js CHANGED
@@ -415,6 +415,12 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from "
415
415
  import { join, dirname } from "path";
416
416
  import { stringify, parse } from "yaml";
417
417
  var LOCKFILE_HEADER = "# DO NOT EDIT MANUALLY - Managed by Fern Replay\n";
418
+ var LockfileNotFoundError = class extends Error {
419
+ constructor(path) {
420
+ super(`Lockfile not found: ${path}`);
421
+ this.name = "LockfileNotFoundError";
422
+ }
423
+ };
418
424
  var LockfileManager = class {
419
425
  outputDir;
420
426
  lock = null;
@@ -434,12 +440,16 @@ var LockfileManager = class {
434
440
  if (this.lock) {
435
441
  return this.lock;
436
442
  }
437
- if (!this.exists()) {
438
- throw new Error(`Lockfile not found: ${this.lockfilePath}`);
443
+ try {
444
+ const content = readFileSync(this.lockfilePath, "utf-8");
445
+ this.lock = parse(content);
446
+ return this.lock;
447
+ } catch (error) {
448
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
449
+ throw new LockfileNotFoundError(this.lockfilePath);
450
+ }
451
+ throw error;
439
452
  }
440
- const content = readFileSync(this.lockfilePath, "utf-8");
441
- this.lock = parse(content);
442
- return this.lock;
443
453
  }
444
454
  initialize(firstGeneration) {
445
455
  this.initializeInMemory(firstGeneration);
@@ -585,13 +595,21 @@ var ReplayDetector = class {
585
595
  const exists = await this.git.commitExists(lastGen.commit_sha);
586
596
  if (!exists) {
587
597
  this.warnings.push(
588
- `Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Skipping new patch detection. Existing lockfile patches will still be applied.`
598
+ `Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to alternate detection.`
599
+ );
600
+ return this.detectPatchesViaTreeDiff(
601
+ lastGen,
602
+ /* commitKnownMissing */
603
+ true
589
604
  );
590
- return { patches: [], revertedPatchIds: [] };
591
605
  }
592
606
  const isAncestor = await this.git.isAncestor(lastGen.commit_sha, "HEAD");
593
607
  if (!isAncestor) {
594
- return this.detectPatchesViaTreeDiff(lastGen);
608
+ return this.detectPatchesViaTreeDiff(
609
+ lastGen,
610
+ /* commitKnownMissing */
611
+ false
612
+ );
595
613
  }
596
614
  const log = await this.git.exec([
597
615
  "log",
@@ -617,7 +635,15 @@ var ReplayDetector = class {
617
635
  if (lock.patches.find((p) => p.original_commit === commit.sha)) {
618
636
  continue;
619
637
  }
620
- const patchContent = await this.git.formatPatch(commit.sha);
638
+ let patchContent;
639
+ try {
640
+ patchContent = await this.git.formatPatch(commit.sha);
641
+ } catch {
642
+ this.warnings.push(
643
+ `Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
644
+ );
645
+ continue;
646
+ }
621
647
  const contentHash = this.computeContentHash(patchContent);
622
648
  if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
623
649
  continue;
@@ -710,11 +736,15 @@ var ReplayDetector = class {
710
736
  * Revert reconciliation is skipped here because tree-diff produces a single composite
711
737
  * patch from the aggregate diff — individual revert commits are not distinguishable.
712
738
  */
713
- async detectPatchesViaTreeDiff(lastGen) {
714
- const filesOutput = await this.git.exec(["diff", "--name-only", lastGen.commit_sha, "HEAD"]);
739
+ async detectPatchesViaTreeDiff(lastGen, commitKnownMissing) {
740
+ const diffBase = await this.resolveDiffBase(lastGen, commitKnownMissing);
741
+ if (!diffBase) {
742
+ return this.detectPatchesViaCommitScan();
743
+ }
744
+ const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
715
745
  const files = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
716
746
  if (files.length === 0) return { patches: [], revertedPatchIds: [] };
717
- const diff = await this.git.exec(["diff", lastGen.commit_sha, "HEAD", "--", ...files]);
747
+ const diff = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
718
748
  if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
719
749
  const contentHash = this.computeContentHash(diff);
720
750
  const lock = this.lockManager.read();
@@ -728,12 +758,113 @@ var ReplayDetector = class {
728
758
  original_commit: headSha,
729
759
  original_message: "Customer customizations (composite)",
730
760
  original_author: "composite",
731
- base_generation: lastGen.commit_sha,
761
+ // Use diffBase when commit is unreachable — the applicator needs a reachable
762
+ // reference to find base file content. diffBase may be the tree_hash.
763
+ base_generation: commitKnownMissing ? diffBase : lastGen.commit_sha,
732
764
  files,
733
765
  patch_content: diff
734
766
  };
735
767
  return { patches: [compositePatch], revertedPatchIds: [] };
736
768
  }
769
+ /**
770
+ * Last-resort detection when both generation commit and tree are unreachable.
771
+ * Scans all commits from HEAD, filters against known lockfile patches, and
772
+ * skips creation-only commits (squashed history after force push).
773
+ *
774
+ * Detected patches use the commit's parent as base_generation so the applicator
775
+ * can find base file content from the parent's tree (which IS reachable).
776
+ */
777
+ async detectPatchesViaCommitScan() {
778
+ const lock = this.lockManager.read();
779
+ const log = await this.git.exec([
780
+ "log",
781
+ "--max-count=200",
782
+ "--format=%H%x00%an%x00%ae%x00%s",
783
+ "HEAD",
784
+ "--",
785
+ this.sdkOutputDir
786
+ ]);
787
+ if (!log.trim()) {
788
+ return { patches: [], revertedPatchIds: [] };
789
+ }
790
+ const commits = this.parseGitLog(log);
791
+ const newPatches = [];
792
+ const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
793
+ const existingHashes = new Set(lock.patches.map((p) => p.content_hash));
794
+ const existingCommits = new Set(lock.patches.map((p) => p.original_commit));
795
+ for (const commit of commits) {
796
+ if (isGenerationCommit(commit)) {
797
+ continue;
798
+ }
799
+ const parents = await this.git.getCommitParents(commit.sha);
800
+ if (parents.length > 1) {
801
+ continue;
802
+ }
803
+ if (existingCommits.has(commit.sha)) {
804
+ continue;
805
+ }
806
+ let patchContent;
807
+ try {
808
+ patchContent = await this.git.formatPatch(commit.sha);
809
+ } catch {
810
+ continue;
811
+ }
812
+ const contentHash = this.computeContentHash(patchContent);
813
+ if (existingHashes.has(contentHash) || forgottenHashes.has(contentHash)) {
814
+ continue;
815
+ }
816
+ if (this.isCreationOnlyPatch(patchContent)) {
817
+ continue;
818
+ }
819
+ const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
820
+ const files = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f));
821
+ if (files.length === 0) {
822
+ continue;
823
+ }
824
+ if (parents.length === 0) {
825
+ continue;
826
+ }
827
+ const parentSha = parents[0];
828
+ newPatches.push({
829
+ id: `patch-${commit.sha.slice(0, 8)}`,
830
+ content_hash: contentHash,
831
+ original_commit: commit.sha,
832
+ original_message: commit.message,
833
+ original_author: `${commit.authorName} <${commit.authorEmail}>`,
834
+ base_generation: parentSha,
835
+ files,
836
+ patch_content: patchContent
837
+ });
838
+ }
839
+ newPatches.reverse();
840
+ return { patches: newPatches, revertedPatchIds: [] };
841
+ }
842
+ /**
843
+ * Check if a format-patch consists entirely of new-file creations.
844
+ * Used to identify squashed commits after force push, which create all files
845
+ * from scratch (--- /dev/null) rather than modifying existing files.
846
+ */
847
+ isCreationOnlyPatch(patchContent) {
848
+ const diffOldHeaders = patchContent.split("\n").filter((l) => l.startsWith("--- "));
849
+ if (diffOldHeaders.length === 0) {
850
+ return false;
851
+ }
852
+ return diffOldHeaders.every((l) => l === "--- /dev/null");
853
+ }
854
+ /**
855
+ * Resolve the best available diff base for a generation record.
856
+ * Prefers commit_sha, falls back to tree_hash for unreachable commits.
857
+ * When commitKnownMissing is true, skips the redundant commitExists check.
858
+ */
859
+ async resolveDiffBase(gen, commitKnownMissing) {
860
+ if (!commitKnownMissing && await this.git.commitExists(gen.commit_sha)) {
861
+ return gen.commit_sha;
862
+ }
863
+ if (await this.git.treeExists(gen.tree_hash)) {
864
+ return gen.tree_hash;
865
+ }
866
+ return null;
867
+ }
737
868
  parseGitLog(log) {
738
869
  return log.trim().split("\n").map((line) => {
739
870
  const [sha, authorName, authorEmail, message] = line.split("\0");
@@ -746,7 +877,7 @@ var ReplayDetector = class {
746
877
  };
747
878
 
748
879
  // src/ThreeWayMerge.ts
749
- import { diff3Merge } from "node-diff3";
880
+ import { diff3Merge, diffPatch } from "node-diff3";
750
881
  function threeWayMerge(base, ours, theirs) {
751
882
  const baseLines = base.split("\n");
752
883
  const oursLines = ours.split("\n");
@@ -760,20 +891,33 @@ function threeWayMerge(base, ours, theirs) {
760
891
  outputLines.push(...region.ok);
761
892
  currentLine += region.ok.length;
762
893
  } else if (region.conflict) {
763
- const startLine = currentLine;
764
- outputLines.push("<<<<<<< Generated");
765
- outputLines.push(...region.conflict.a);
766
- outputLines.push("=======");
767
- outputLines.push(...region.conflict.b);
768
- outputLines.push(">>>>>>> Your customization");
769
- const conflictLines = region.conflict.a.length + region.conflict.b.length + 3;
770
- conflicts.push({
771
- startLine,
772
- endLine: startLine + conflictLines - 1,
773
- ours: region.conflict.a,
774
- theirs: region.conflict.b
775
- });
776
- currentLine += conflictLines;
894
+ const resolved = tryResolveConflict(
895
+ region.conflict.a,
896
+ // ours (generator)
897
+ region.conflict.o,
898
+ // base
899
+ region.conflict.b
900
+ // theirs (user)
901
+ );
902
+ if (resolved !== null) {
903
+ outputLines.push(...resolved);
904
+ currentLine += resolved.length;
905
+ } else {
906
+ const startLine = currentLine;
907
+ outputLines.push("<<<<<<< Generated");
908
+ outputLines.push(...region.conflict.a);
909
+ outputLines.push("=======");
910
+ outputLines.push(...region.conflict.b);
911
+ outputLines.push(">>>>>>> Your customization");
912
+ const conflictLines = region.conflict.a.length + region.conflict.b.length + 3;
913
+ conflicts.push({
914
+ startLine,
915
+ endLine: startLine + conflictLines - 1,
916
+ ours: region.conflict.a,
917
+ theirs: region.conflict.b
918
+ });
919
+ currentLine += conflictLines;
920
+ }
777
921
  }
778
922
  }
779
923
  return {
@@ -782,6 +926,62 @@ function threeWayMerge(base, ours, theirs) {
782
926
  conflicts
783
927
  };
784
928
  }
929
+ function tryResolveConflict(oursLines, baseLines, theirsLines) {
930
+ if (baseLines.length === 0) {
931
+ return null;
932
+ }
933
+ const oursPatches = diffPatch(baseLines, oursLines);
934
+ const theirsPatches = diffPatch(baseLines, theirsLines);
935
+ if (oursPatches.length === 0) return theirsLines;
936
+ if (theirsPatches.length === 0) return oursLines;
937
+ if (patchesOverlap(oursPatches, theirsPatches)) {
938
+ return null;
939
+ }
940
+ return applyBothPatches(baseLines, oursPatches, theirsPatches);
941
+ }
942
+ function patchesOverlap(oursPatches, theirsPatches) {
943
+ for (const op of oursPatches) {
944
+ const oStart = op.buffer1.offset;
945
+ const oEnd = oStart + op.buffer1.length;
946
+ for (const tp of theirsPatches) {
947
+ const tStart = tp.buffer1.offset;
948
+ const tEnd = tStart + tp.buffer1.length;
949
+ if (op.buffer1.length === 0 && tp.buffer1.length === 0 && oStart === tStart) {
950
+ return true;
951
+ }
952
+ if (tp.buffer1.length === 0 && tStart === oEnd) {
953
+ return true;
954
+ }
955
+ if (op.buffer1.length === 0 && oStart === tEnd) {
956
+ return true;
957
+ }
958
+ if (oStart < tEnd && tStart < oEnd) {
959
+ return true;
960
+ }
961
+ }
962
+ }
963
+ return false;
964
+ }
965
+ function applyBothPatches(baseLines, oursPatches, theirsPatches) {
966
+ const allPatches = [
967
+ ...oursPatches.map((p) => ({
968
+ offset: p.buffer1.offset,
969
+ length: p.buffer1.length,
970
+ replacement: p.buffer2.chunk
971
+ })),
972
+ ...theirsPatches.map((p) => ({
973
+ offset: p.buffer1.offset,
974
+ length: p.buffer1.length,
975
+ replacement: p.buffer2.chunk
976
+ }))
977
+ ];
978
+ allPatches.sort((a, b) => b.offset - a.offset);
979
+ const result = [...baseLines];
980
+ for (const p of allPatches) {
981
+ result.splice(p.offset, p.length, ...p.replacement);
982
+ }
983
+ return result;
984
+ }
785
985
 
786
986
  // src/ReplayApplicator.ts
787
987
  import { mkdir, mkdtemp, readFile, rm, writeFile } from "fs/promises";
@@ -790,29 +990,74 @@ import { dirname as dirname2, extname, join as join2 } from "path";
790
990
  import { minimatch } from "minimatch";
791
991
 
792
992
  // src/conflict-utils.ts
993
+ var CONFLICT_OPENER = "<<<<<<< Generated";
994
+ var CONFLICT_SEPARATOR = "=======";
995
+ var CONFLICT_CLOSER = ">>>>>>> Your customization";
996
+ function trimCR(line) {
997
+ return line.endsWith("\r") ? line.slice(0, -1) : line;
998
+ }
999
+ function findConflictRanges(lines) {
1000
+ const ranges = [];
1001
+ let i = 0;
1002
+ while (i < lines.length) {
1003
+ if (trimCR(lines[i]) === CONFLICT_OPENER) {
1004
+ let separatorIdx = -1;
1005
+ let j = i + 1;
1006
+ let found = false;
1007
+ while (j < lines.length) {
1008
+ const trimmed = trimCR(lines[j]);
1009
+ if (trimmed === CONFLICT_OPENER) {
1010
+ break;
1011
+ }
1012
+ if (separatorIdx === -1 && trimmed === CONFLICT_SEPARATOR) {
1013
+ separatorIdx = j;
1014
+ } else if (separatorIdx !== -1 && trimmed === CONFLICT_CLOSER) {
1015
+ ranges.push({ start: i, separator: separatorIdx, end: j });
1016
+ i = j;
1017
+ found = true;
1018
+ break;
1019
+ }
1020
+ j++;
1021
+ }
1022
+ if (!found) {
1023
+ i++;
1024
+ continue;
1025
+ }
1026
+ }
1027
+ i++;
1028
+ }
1029
+ return ranges;
1030
+ }
793
1031
  function stripConflictMarkers(content) {
794
- const lines = content.split("\n");
1032
+ const lines = content.split(/\r?\n/);
1033
+ const ranges = findConflictRanges(lines);
1034
+ if (ranges.length === 0) {
1035
+ return content;
1036
+ }
795
1037
  const result = [];
796
- let inConflict = false;
797
- let inOurs = false;
798
- for (const line of lines) {
799
- if (line.startsWith("<<<<<<< ")) {
800
- inConflict = true;
801
- inOurs = true;
802
- continue;
803
- }
804
- if (inConflict && line === "=======") {
805
- inOurs = false;
806
- continue;
807
- }
808
- if (inConflict && line.startsWith(">>>>>>> ")) {
809
- inConflict = false;
810
- inOurs = false;
811
- continue;
812
- }
813
- if (!inConflict || inOurs) {
814
- result.push(line);
1038
+ let rangeIdx = 0;
1039
+ for (let i = 0; i < lines.length; i++) {
1040
+ if (rangeIdx < ranges.length) {
1041
+ const range = ranges[rangeIdx];
1042
+ if (i === range.start) {
1043
+ continue;
1044
+ }
1045
+ if (i > range.start && i < range.separator) {
1046
+ result.push(lines[i]);
1047
+ continue;
1048
+ }
1049
+ if (i === range.separator) {
1050
+ continue;
1051
+ }
1052
+ if (i > range.separator && i < range.end) {
1053
+ continue;
1054
+ }
1055
+ if (i === range.end) {
1056
+ rangeIdx++;
1057
+ continue;
1058
+ }
815
1059
  }
1060
+ result.push(lines[i]);
816
1061
  }
817
1062
  return result.join("\n");
818
1063
  }
@@ -879,6 +1124,27 @@ var ReplayApplicator = class {
879
1124
  this.lockManager = lockManager;
880
1125
  this.outputDir = outputDir;
881
1126
  }
1127
+ /**
1128
+ * Resolve the GenerationRecord for a patch's base_generation.
1129
+ * Falls back to constructing an ad-hoc record from the commit's tree
1130
+ * when base_generation isn't a tracked generation (commit-scan patches).
1131
+ */
1132
+ async resolveBaseGeneration(baseGeneration) {
1133
+ const gen = this.lockManager.getGeneration(baseGeneration);
1134
+ if (gen) return gen;
1135
+ try {
1136
+ const treeHash = await this.git.getTreeHash(baseGeneration);
1137
+ return {
1138
+ commit_sha: baseGeneration,
1139
+ tree_hash: treeHash,
1140
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1141
+ cli_version: "unknown",
1142
+ generator_versions: {}
1143
+ };
1144
+ } catch {
1145
+ return void 0;
1146
+ }
1147
+ }
882
1148
  /** Reset inter-patch accumulator for a new cycle. */
883
1149
  resetAccumulator() {
884
1150
  this.fileTheirsAccumulator.clear();
@@ -960,7 +1226,7 @@ var ReplayApplicator = class {
960
1226
  }
961
1227
  }
962
1228
  async applyPatchWithFallback(patch) {
963
- const baseGen = this.lockManager.getGeneration(patch.base_generation);
1229
+ const baseGen = await this.resolveBaseGeneration(patch.base_generation);
964
1230
  const lock = this.lockManager.read();
965
1231
  const currentGen = lock.generations.find((g) => g.commit_sha === lock.current_generation);
966
1232
  const currentTreeHash = currentGen?.tree_hash ?? baseGen?.tree_hash ?? "";
@@ -1044,7 +1310,7 @@ var ReplayApplicator = class {
1044
1310
  }
1045
1311
  async mergeFile(patch, filePath, tempGit, tempDir) {
1046
1312
  try {
1047
- const baseGen = this.lockManager.getGeneration(patch.base_generation);
1313
+ const baseGen = await this.resolveBaseGeneration(patch.base_generation);
1048
1314
  if (!baseGen) {
1049
1315
  return { file: filePath, status: "skipped", reason: "base-generation-not-found" };
1050
1316
  }
@@ -1096,6 +1362,17 @@ var ReplayApplicator = class {
1096
1362
  renameSourcePath
1097
1363
  );
1098
1364
  }
1365
+ if (theirs) {
1366
+ const theirsHasMarkers = theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization");
1367
+ const baseHasMarkers = base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization"));
1368
+ if (theirsHasMarkers && !baseHasMarkers) {
1369
+ return {
1370
+ file: resolvedPath,
1371
+ status: "skipped",
1372
+ reason: "stale-conflict-markers"
1373
+ };
1374
+ }
1375
+ }
1099
1376
  let useAccumulatorAsMergeBase = false;
1100
1377
  const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
1101
1378
  if (!theirs && accumulatorEntry) {
@@ -1110,6 +1387,17 @@ var ReplayApplicator = class {
1110
1387
  useAccumulatorAsMergeBase = true;
1111
1388
  }
1112
1389
  }
1390
+ if (theirs) {
1391
+ const theirsHasMarkers = theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization");
1392
+ const accBaseHasMarkers = accumulatorEntry != null && (accumulatorEntry.content.includes("<<<<<<< Generated") || accumulatorEntry.content.includes(">>>>>>> Your customization"));
1393
+ if (theirsHasMarkers && !accBaseHasMarkers && !(base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization")))) {
1394
+ return {
1395
+ file: resolvedPath,
1396
+ status: "skipped",
1397
+ reason: "stale-conflict-markers"
1398
+ };
1399
+ }
1400
+ }
1113
1401
  let effective_theirs = theirs;
1114
1402
  let baseMismatchSkipped = false;
1115
1403
  if (theirs && base && !useAccumulatorAsMergeBase) {
@@ -1176,7 +1464,7 @@ var ReplayApplicator = class {
1176
1464
  const outDir = dirname2(oursPath);
1177
1465
  await mkdir(outDir, { recursive: true });
1178
1466
  await writeFile(oursPath, merged.content);
1179
- if (effective_theirs) {
1467
+ if (effective_theirs && !merged.hasConflicts) {
1180
1468
  this.fileTheirsAccumulator.set(resolvedPath, {
1181
1469
  content: effective_theirs,
1182
1470
  baseGeneration: patch.base_generation
@@ -1478,6 +1766,7 @@ var ReplayService = class {
1478
1766
  generator_versions: options?.generatorVersions ?? {},
1479
1767
  base_branch_head: options?.baseBranchHead
1480
1768
  };
1769
+ let resolvedPatches;
1481
1770
  if (!this.lockManager.exists()) {
1482
1771
  this.lockManager.initializeInMemory(record);
1483
1772
  } else {
@@ -1486,13 +1775,13 @@ var ReplayService = class {
1486
1775
  ...this.lockManager.getUnresolvedPatches(),
1487
1776
  ...this.lockManager.getResolvingPatches()
1488
1777
  ];
1778
+ resolvedPatches = this.lockManager.getPatches().filter((p) => p.status == null);
1489
1779
  this.lockManager.addGeneration(record);
1490
1780
  this.lockManager.clearPatches();
1491
1781
  for (const patch of unresolvedPatches) {
1492
1782
  this.lockManager.addPatch(patch);
1493
1783
  }
1494
1784
  }
1495
- this.lockManager.save();
1496
1785
  try {
1497
1786
  const { patches: redetectedPatches } = await this.detector.detectNewPatches();
1498
1787
  if (redetectedPatches.length > 0) {
@@ -1506,20 +1795,27 @@ var ReplayService = class {
1506
1795
  for (const patch of redetectedPatches) {
1507
1796
  this.lockManager.addPatch(patch);
1508
1797
  }
1509
- this.lockManager.save();
1510
1798
  }
1511
- } catch {
1799
+ } catch (error) {
1800
+ for (const patch of resolvedPatches ?? []) {
1801
+ this.lockManager.addPatch(patch);
1802
+ }
1803
+ this.detector.warnings.push(
1804
+ `Patch re-detection failed after divergent merge sync. ${(resolvedPatches ?? []).length} previously resolved patch(es) preserved. Error: ${error instanceof Error ? error.message : String(error)}`
1805
+ );
1512
1806
  }
1807
+ this.lockManager.save();
1513
1808
  }
1514
1809
  determineFlow() {
1515
- if (!this.lockManager.exists()) {
1516
- return "first-generation";
1517
- }
1518
- const lock = this.lockManager.read();
1519
- if (lock.patches.length === 0) {
1520
- return "no-patches";
1810
+ try {
1811
+ const lock = this.lockManager.read();
1812
+ return lock.patches.length === 0 ? "no-patches" : "normal-regeneration";
1813
+ } catch (error) {
1814
+ if (error instanceof LockfileNotFoundError) {
1815
+ return "first-generation";
1816
+ }
1817
+ throw error;
1521
1818
  }
1522
- return "normal-regeneration";
1523
1819
  }
1524
1820
  async handleFirstGeneration(options) {
1525
1821
  if (options?.dryRun) {
@@ -1561,12 +1857,17 @@ var ReplayService = class {
1561
1857
  baseBranchHead: options.baseBranchHead
1562
1858
  } : void 0;
1563
1859
  await this.committer.commitGeneration("Update SDK (replay skipped)", commitOpts);
1860
+ await this.cleanupStaleConflictMarkers();
1564
1861
  const genRecord = await this.committer.createGenerationRecord(commitOpts);
1565
- if (this.lockManager.exists()) {
1862
+ try {
1566
1863
  this.lockManager.read();
1567
1864
  this.lockManager.addGeneration(genRecord);
1568
- } else {
1569
- this.lockManager.initializeInMemory(genRecord);
1865
+ } catch (error) {
1866
+ if (error instanceof LockfileNotFoundError) {
1867
+ this.lockManager.initializeInMemory(genRecord);
1868
+ } else {
1869
+ throw error;
1870
+ }
1570
1871
  }
1571
1872
  this.lockManager.setReplaySkippedAt((/* @__PURE__ */ new Date()).toISOString());
1572
1873
  this.lockManager.save();
@@ -1612,6 +1913,7 @@ var ReplayService = class {
1612
1913
  baseBranchHead: options.baseBranchHead
1613
1914
  } : void 0;
1614
1915
  await this.committer.commitGeneration("Update SDK", commitOpts);
1916
+ await this.cleanupStaleConflictMarkers();
1615
1917
  const genRecord = await this.committer.createGenerationRecord(commitOpts);
1616
1918
  this.lockManager.addGeneration(genRecord);
1617
1919
  let results = [];
@@ -1705,6 +2007,7 @@ var ReplayService = class {
1705
2007
  baseBranchHead: options.baseBranchHead
1706
2008
  } : void 0;
1707
2009
  await this.committer.commitGeneration("Update SDK", commitOpts);
2010
+ await this.cleanupStaleConflictMarkers();
1708
2011
  const genRecord = await this.committer.createGenerationRecord(commitOpts);
1709
2012
  this.lockManager.addGeneration(genRecord);
1710
2013
  const results = await this.applicator.applyPatches(allPatches);
@@ -1895,6 +2198,13 @@ var ReplayService = class {
1895
2198
  contentRefreshed++;
1896
2199
  continue;
1897
2200
  }
2201
+ const diffLines = diff.split("\n");
2202
+ const hasStaleMarkers = diffLines.some(
2203
+ (l) => l.startsWith("+<<<<<<< Generated") || l.startsWith("+>>>>>>> Your customization")
2204
+ );
2205
+ if (hasStaleMarkers) {
2206
+ continue;
2207
+ }
1898
2208
  const newContentHash = this.detector.computeContentHash(diff);
1899
2209
  if (newContentHash !== patch.content_hash) {
1900
2210
  const filesOutput = await this.git.exec(["diff", "--name-only", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
@@ -1915,7 +2225,7 @@ var ReplayService = class {
1915
2225
  continue;
1916
2226
  }
1917
2227
  try {
1918
- const markerFiles = await this.git.exec(["grep", "-l", "<<<<<<<", "HEAD", "--", ...patch.files]).catch(() => "");
2228
+ const markerFiles = await this.git.exec(["grep", "-l", "<<<<<<< Generated", "HEAD", "--", ...patch.files]).catch(() => "");
1919
2229
  if (markerFiles.trim()) continue;
1920
2230
  const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
1921
2231
  if (diff === null) continue;
@@ -1955,6 +2265,25 @@ var ReplayService = class {
1955
2265
  }
1956
2266
  }
1957
2267
  }
2268
+ /**
2269
+ * Clean up stale conflict markers left by a previous crashed run.
2270
+ * Called after commitGeneration() when HEAD is the [fern-generated] commit.
2271
+ * Restores files to their clean generated state from HEAD.
2272
+ * Skips .fernignore-protected files to prevent overwriting user content.
2273
+ */
2274
+ async cleanupStaleConflictMarkers() {
2275
+ const markerFiles = await this.git.exec(["grep", "-l", "<<<<<<< Generated", "--", "."]).catch(() => "");
2276
+ const files = markerFiles.trim().split("\n").filter(Boolean);
2277
+ if (files.length === 0) return;
2278
+ const fernignorePatterns = this.readFernignorePatterns();
2279
+ for (const file of files) {
2280
+ if (fernignorePatterns.some((pattern) => minimatch2(file, pattern))) continue;
2281
+ try {
2282
+ await this.git.exec(["checkout", "HEAD", "--", file]);
2283
+ } catch {
2284
+ }
2285
+ }
2286
+ }
1958
2287
  readFernignorePatterns() {
1959
2288
  const fernignorePath = join3(this.outputDir, ".fernignore");
1960
2289
  if (!existsSync2(fernignorePath)) return [];
@@ -2729,6 +3058,7 @@ export {
2729
3058
  FernignoreMigrator,
2730
3059
  GitClient,
2731
3060
  LockfileManager,
3061
+ LockfileNotFoundError,
2732
3062
  ReplayApplicator,
2733
3063
  ReplayCommitter,
2734
3064
  ReplayDetector,