@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/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,9 +1362,20 @@ 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
- if (!theirs && accumulatorEntry) {
1378
+ if (accumulatorEntry && (!theirs || base == null)) {
1102
1379
  theirs = await this.applyPatchToContent(
1103
1380
  accumulatorEntry.content,
1104
1381
  patch.patch_content,
@@ -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) {
@@ -1129,12 +1417,16 @@ var ReplayApplicator = class {
1129
1417
  }
1130
1418
  }
1131
1419
  if (base == null && !ours && effective_theirs) {
1420
+ this.fileTheirsAccumulator.set(resolvedPath, {
1421
+ content: effective_theirs,
1422
+ baseGeneration: patch.base_generation
1423
+ });
1132
1424
  const outDir2 = dirname2(oursPath);
1133
1425
  await mkdir(outDir2, { recursive: true });
1134
1426
  await writeFile(oursPath, effective_theirs);
1135
1427
  return { file: resolvedPath, status: "merged", reason: "new-file" };
1136
1428
  }
1137
- if (base == null && ours && effective_theirs) {
1429
+ if (base == null && ours && effective_theirs && !useAccumulatorAsMergeBase) {
1138
1430
  const merged2 = threeWayMerge("", ours, effective_theirs);
1139
1431
  const outDir2 = dirname2(oursPath);
1140
1432
  await mkdir(outDir2, { recursive: true });
@@ -1176,7 +1468,7 @@ var ReplayApplicator = class {
1176
1468
  const outDir = dirname2(oursPath);
1177
1469
  await mkdir(outDir, { recursive: true });
1178
1470
  await writeFile(oursPath, merged.content);
1179
- if (effective_theirs) {
1471
+ if (effective_theirs && !merged.hasConflicts) {
1180
1472
  this.fileTheirsAccumulator.set(resolvedPath, {
1181
1473
  content: effective_theirs,
1182
1474
  baseGeneration: patch.base_generation
@@ -1317,16 +1609,24 @@ var ReplayApplicator = class {
1317
1609
  const addedLines = [];
1318
1610
  let inTargetFile = false;
1319
1611
  let inHunk = false;
1612
+ let isNewFile = false;
1320
1613
  let noTrailingNewline = false;
1321
1614
  for (const line of lines) {
1322
1615
  if (line.startsWith("diff --git")) {
1323
1616
  if (inTargetFile) break;
1324
1617
  inTargetFile = isDiffLineForFile(line, filePath);
1325
1618
  inHunk = false;
1619
+ isNewFile = false;
1326
1620
  continue;
1327
1621
  }
1328
1622
  if (!inTargetFile) continue;
1623
+ if (!inHunk) {
1624
+ if (line === "--- /dev/null" || line.startsWith("new file mode")) {
1625
+ isNewFile = true;
1626
+ }
1627
+ }
1329
1628
  if (line.startsWith("@@")) {
1629
+ if (!isNewFile) return null;
1330
1630
  inHunk = true;
1331
1631
  continue;
1332
1632
  }
@@ -1478,6 +1778,7 @@ var ReplayService = class {
1478
1778
  generator_versions: options?.generatorVersions ?? {},
1479
1779
  base_branch_head: options?.baseBranchHead
1480
1780
  };
1781
+ let resolvedPatches;
1481
1782
  if (!this.lockManager.exists()) {
1482
1783
  this.lockManager.initializeInMemory(record);
1483
1784
  } else {
@@ -1486,13 +1787,13 @@ var ReplayService = class {
1486
1787
  ...this.lockManager.getUnresolvedPatches(),
1487
1788
  ...this.lockManager.getResolvingPatches()
1488
1789
  ];
1790
+ resolvedPatches = this.lockManager.getPatches().filter((p) => p.status == null);
1489
1791
  this.lockManager.addGeneration(record);
1490
1792
  this.lockManager.clearPatches();
1491
1793
  for (const patch of unresolvedPatches) {
1492
1794
  this.lockManager.addPatch(patch);
1493
1795
  }
1494
1796
  }
1495
- this.lockManager.save();
1496
1797
  try {
1497
1798
  const { patches: redetectedPatches } = await this.detector.detectNewPatches();
1498
1799
  if (redetectedPatches.length > 0) {
@@ -1506,20 +1807,27 @@ var ReplayService = class {
1506
1807
  for (const patch of redetectedPatches) {
1507
1808
  this.lockManager.addPatch(patch);
1508
1809
  }
1509
- this.lockManager.save();
1510
1810
  }
1511
- } catch {
1811
+ } catch (error) {
1812
+ for (const patch of resolvedPatches ?? []) {
1813
+ this.lockManager.addPatch(patch);
1814
+ }
1815
+ this.detector.warnings.push(
1816
+ `Patch re-detection failed after divergent merge sync. ${(resolvedPatches ?? []).length} previously resolved patch(es) preserved. Error: ${error instanceof Error ? error.message : String(error)}`
1817
+ );
1512
1818
  }
1819
+ this.lockManager.save();
1513
1820
  }
1514
1821
  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";
1822
+ try {
1823
+ const lock = this.lockManager.read();
1824
+ return lock.patches.length === 0 ? "no-patches" : "normal-regeneration";
1825
+ } catch (error) {
1826
+ if (error instanceof LockfileNotFoundError) {
1827
+ return "first-generation";
1828
+ }
1829
+ throw error;
1521
1830
  }
1522
- return "normal-regeneration";
1523
1831
  }
1524
1832
  async handleFirstGeneration(options) {
1525
1833
  if (options?.dryRun) {
@@ -1561,12 +1869,17 @@ var ReplayService = class {
1561
1869
  baseBranchHead: options.baseBranchHead
1562
1870
  } : void 0;
1563
1871
  await this.committer.commitGeneration("Update SDK (replay skipped)", commitOpts);
1872
+ await this.cleanupStaleConflictMarkers();
1564
1873
  const genRecord = await this.committer.createGenerationRecord(commitOpts);
1565
- if (this.lockManager.exists()) {
1874
+ try {
1566
1875
  this.lockManager.read();
1567
1876
  this.lockManager.addGeneration(genRecord);
1568
- } else {
1569
- this.lockManager.initializeInMemory(genRecord);
1877
+ } catch (error) {
1878
+ if (error instanceof LockfileNotFoundError) {
1879
+ this.lockManager.initializeInMemory(genRecord);
1880
+ } else {
1881
+ throw error;
1882
+ }
1570
1883
  }
1571
1884
  this.lockManager.setReplaySkippedAt((/* @__PURE__ */ new Date()).toISOString());
1572
1885
  this.lockManager.save();
@@ -1612,6 +1925,7 @@ var ReplayService = class {
1612
1925
  baseBranchHead: options.baseBranchHead
1613
1926
  } : void 0;
1614
1927
  await this.committer.commitGeneration("Update SDK", commitOpts);
1928
+ await this.cleanupStaleConflictMarkers();
1615
1929
  const genRecord = await this.committer.createGenerationRecord(commitOpts);
1616
1930
  this.lockManager.addGeneration(genRecord);
1617
1931
  let results = [];
@@ -1705,6 +2019,7 @@ var ReplayService = class {
1705
2019
  baseBranchHead: options.baseBranchHead
1706
2020
  } : void 0;
1707
2021
  await this.committer.commitGeneration("Update SDK", commitOpts);
2022
+ await this.cleanupStaleConflictMarkers();
1708
2023
  const genRecord = await this.committer.createGenerationRecord(commitOpts);
1709
2024
  this.lockManager.addGeneration(genRecord);
1710
2025
  const results = await this.applicator.applyPatches(allPatches);
@@ -1895,6 +2210,13 @@ var ReplayService = class {
1895
2210
  contentRefreshed++;
1896
2211
  continue;
1897
2212
  }
2213
+ const diffLines = diff.split("\n");
2214
+ const hasStaleMarkers = diffLines.some(
2215
+ (l) => l.startsWith("+<<<<<<< Generated") || l.startsWith("+>>>>>>> Your customization")
2216
+ );
2217
+ if (hasStaleMarkers) {
2218
+ continue;
2219
+ }
1898
2220
  const newContentHash = this.detector.computeContentHash(diff);
1899
2221
  if (newContentHash !== patch.content_hash) {
1900
2222
  const filesOutput = await this.git.exec(["diff", "--name-only", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
@@ -1915,7 +2237,7 @@ var ReplayService = class {
1915
2237
  continue;
1916
2238
  }
1917
2239
  try {
1918
- const markerFiles = await this.git.exec(["grep", "-l", "<<<<<<<", "HEAD", "--", ...patch.files]).catch(() => "");
2240
+ const markerFiles = await this.git.exec(["grep", "-l", "<<<<<<< Generated", "HEAD", "--", ...patch.files]).catch(() => "");
1919
2241
  if (markerFiles.trim()) continue;
1920
2242
  const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
1921
2243
  if (diff === null) continue;
@@ -1955,6 +2277,25 @@ var ReplayService = class {
1955
2277
  }
1956
2278
  }
1957
2279
  }
2280
+ /**
2281
+ * Clean up stale conflict markers left by a previous crashed run.
2282
+ * Called after commitGeneration() when HEAD is the [fern-generated] commit.
2283
+ * Restores files to their clean generated state from HEAD.
2284
+ * Skips .fernignore-protected files to prevent overwriting user content.
2285
+ */
2286
+ async cleanupStaleConflictMarkers() {
2287
+ const markerFiles = await this.git.exec(["grep", "-l", "<<<<<<< Generated", "--", "."]).catch(() => "");
2288
+ const files = markerFiles.trim().split("\n").filter(Boolean);
2289
+ if (files.length === 0) return;
2290
+ const fernignorePatterns = this.readFernignorePatterns();
2291
+ for (const file of files) {
2292
+ if (fernignorePatterns.some((pattern) => minimatch2(file, pattern))) continue;
2293
+ try {
2294
+ await this.git.exec(["checkout", "HEAD", "--", file]);
2295
+ } catch {
2296
+ }
2297
+ }
2298
+ }
1958
2299
  readFernignorePatterns() {
1959
2300
  const fernignorePath = join3(this.outputDir, ".fernignore");
1960
2301
  if (!existsSync2(fernignorePath)) return [];
@@ -2729,6 +3070,7 @@ export {
2729
3070
  FernignoreMigrator,
2730
3071
  GitClient,
2731
3072
  LockfileManager,
3073
+ LockfileNotFoundError,
2732
3074
  ReplayApplicator,
2733
3075
  ReplayCommitter,
2734
3076
  ReplayDetector,