@fern-api/replay 0.9.0 → 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
@@ -111,6 +111,14 @@ var init_GitClient = __esm({
111
111
  return false;
112
112
  }
113
113
  }
114
+ async treeExists(treeHash) {
115
+ try {
116
+ const type = await this.exec(["cat-file", "-t", treeHash]);
117
+ return type.trim() === "tree";
118
+ } catch {
119
+ return false;
120
+ }
121
+ }
114
122
  async getCommitBody(commitSha) {
115
123
  return this.exec(["log", "-1", "--format=%B", commitSha]);
116
124
  }
@@ -121,6 +129,255 @@ var init_GitClient = __esm({
121
129
  }
122
130
  });
123
131
 
132
+ // src/HybridReconstruction.ts
133
+ var HybridReconstruction_exports = {};
134
+ __export(HybridReconstruction_exports, {
135
+ assembleHybrid: () => assembleHybrid,
136
+ locateHunksInOurs: () => locateHunksInOurs,
137
+ parseHunks: () => parseHunks,
138
+ reconstructFromGhostPatch: () => reconstructFromGhostPatch
139
+ });
140
+ function parseHunks(fileDiff) {
141
+ const lines = fileDiff.split("\n");
142
+ const hunks = [];
143
+ let currentHunk = null;
144
+ for (const line of lines) {
145
+ const headerMatch = line.match(
146
+ /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/
147
+ );
148
+ if (headerMatch) {
149
+ if (currentHunk) {
150
+ hunks.push(currentHunk);
151
+ }
152
+ currentHunk = {
153
+ oldStart: parseInt(headerMatch[1], 10),
154
+ oldCount: headerMatch[2] != null ? parseInt(headerMatch[2], 10) : 1,
155
+ newStart: parseInt(headerMatch[3], 10),
156
+ newCount: headerMatch[4] != null ? parseInt(headerMatch[4], 10) : 1,
157
+ lines: []
158
+ };
159
+ continue;
160
+ }
161
+ if (!currentHunk) continue;
162
+ if (line.startsWith("diff --git") || line.startsWith("index ") || line.startsWith("---") || line.startsWith("+++") || line.startsWith("old mode") || line.startsWith("new mode") || line.startsWith("similarity index") || line.startsWith("rename from") || line.startsWith("rename to") || line.startsWith("new file mode") || line.startsWith("deleted file mode")) {
163
+ continue;
164
+ }
165
+ if (line === "\") {
166
+ continue;
167
+ }
168
+ if (line.startsWith("-")) {
169
+ currentHunk.lines.push({ type: "remove", content: line.slice(1) });
170
+ } else if (line.startsWith("+")) {
171
+ currentHunk.lines.push({ type: "add", content: line.slice(1) });
172
+ } else if (line.startsWith(" ") || line === "") {
173
+ currentHunk.lines.push({
174
+ type: "context",
175
+ content: line.startsWith(" ") ? line.slice(1) : line
176
+ });
177
+ }
178
+ }
179
+ if (currentHunk) {
180
+ hunks.push(currentHunk);
181
+ }
182
+ return hunks;
183
+ }
184
+ function extractLeadingContext(hunk) {
185
+ const result = [];
186
+ for (const line of hunk.lines) {
187
+ if (line.type !== "context") break;
188
+ result.push(line.content);
189
+ }
190
+ return result;
191
+ }
192
+ function extractTrailingContext(hunk) {
193
+ const result = [];
194
+ for (let i = hunk.lines.length - 1; i >= 0; i--) {
195
+ if (hunk.lines[i].type !== "context") break;
196
+ result.unshift(hunk.lines[i].content);
197
+ }
198
+ return result;
199
+ }
200
+ function countOursLinesBeforeTrailing(hunk) {
201
+ let count = 0;
202
+ const trailingStart = findTrailingContextStart(hunk);
203
+ for (let i = 0; i < trailingStart; i++) {
204
+ if (hunk.lines[i].type === "context") count++;
205
+ }
206
+ return count;
207
+ }
208
+ function findTrailingContextStart(hunk) {
209
+ let i = hunk.lines.length - 1;
210
+ while (i >= 0 && hunk.lines[i].type === "context") {
211
+ i--;
212
+ }
213
+ return i + 1;
214
+ }
215
+ function matchesAt(needle, haystack, offset) {
216
+ for (let i = 0; i < needle.length; i++) {
217
+ if (haystack[offset + i] !== needle[i]) return false;
218
+ }
219
+ return true;
220
+ }
221
+ function findContextInOurs(contextLines, oursLines, minIndex, hint) {
222
+ const SEARCH_WINDOW = 200;
223
+ const maxStart = oursLines.length - contextLines.length;
224
+ const clampedHint = Math.max(minIndex, Math.min(hint, maxStart));
225
+ if (clampedHint >= minIndex && clampedHint <= maxStart) {
226
+ if (matchesAt(contextLines, oursLines, clampedHint)) {
227
+ return clampedHint;
228
+ }
229
+ }
230
+ for (let delta = 1; delta <= SEARCH_WINDOW; delta++) {
231
+ for (const sign of [1, -1]) {
232
+ const idx = clampedHint + delta * sign;
233
+ if (idx < minIndex || idx > maxStart) continue;
234
+ if (matchesAt(contextLines, oursLines, idx)) {
235
+ return idx;
236
+ }
237
+ }
238
+ }
239
+ return -1;
240
+ }
241
+ function computeOursSpan(hunk, oursLines, oursOffset) {
242
+ const leading = extractLeadingContext(hunk);
243
+ const trailing = extractTrailingContext(hunk);
244
+ if (trailing.length === 0) {
245
+ const contextCount2 = hunk.lines.filter(
246
+ (l) => l.type === "context"
247
+ ).length;
248
+ return Math.min(contextCount2, oursLines.length - oursOffset);
249
+ }
250
+ const searchStart = oursOffset + leading.length;
251
+ for (let i = searchStart; i <= oursLines.length - trailing.length; i++) {
252
+ if (matchesAt(trailing, oursLines, i)) {
253
+ return i + trailing.length - oursOffset;
254
+ }
255
+ }
256
+ const contextCount = hunk.lines.filter(
257
+ (l) => l.type === "context"
258
+ ).length;
259
+ return Math.min(contextCount, oursLines.length - oursOffset);
260
+ }
261
+ function locateHunksInOurs(hunks, oursLines) {
262
+ const located = [];
263
+ let minOursIndex = 0;
264
+ for (const hunk of hunks) {
265
+ const contextLines = extractLeadingContext(hunk);
266
+ let oursOffset;
267
+ if (contextLines.length > 0) {
268
+ const found = findContextInOurs(
269
+ contextLines,
270
+ oursLines,
271
+ minOursIndex,
272
+ hunk.newStart - 1
273
+ );
274
+ if (found === -1) {
275
+ const trailingContext = extractTrailingContext(hunk);
276
+ if (trailingContext.length > 0) {
277
+ const trailingFound = findContextInOurs(
278
+ trailingContext,
279
+ oursLines,
280
+ minOursIndex,
281
+ hunk.newStart - 1
282
+ );
283
+ if (trailingFound === -1) return null;
284
+ const nonTrailingCount = countOursLinesBeforeTrailing(hunk);
285
+ oursOffset = trailingFound - nonTrailingCount;
286
+ if (oursOffset < minOursIndex) return null;
287
+ } else {
288
+ return null;
289
+ }
290
+ } else {
291
+ oursOffset = found;
292
+ }
293
+ } else if (hunk.oldStart === 1 && hunk.oldCount === 0) {
294
+ oursOffset = 0;
295
+ } else {
296
+ oursOffset = Math.max(hunk.newStart - 1, minOursIndex);
297
+ }
298
+ const oursSpan = computeOursSpan(hunk, oursLines, oursOffset);
299
+ located.push({ hunk, oursOffset, oursSpan });
300
+ minOursIndex = oursOffset + oursSpan;
301
+ }
302
+ return located;
303
+ }
304
+ function assembleHybrid(locatedHunks, oursLines) {
305
+ const baseLines = [];
306
+ const theirsLines = [];
307
+ let oursCursor = 0;
308
+ for (const { hunk, oursOffset, oursSpan } of locatedHunks) {
309
+ if (oursOffset > oursCursor) {
310
+ const gapLines = oursLines.slice(oursCursor, oursOffset);
311
+ baseLines.push(...gapLines);
312
+ theirsLines.push(...gapLines);
313
+ }
314
+ for (const line of hunk.lines) {
315
+ switch (line.type) {
316
+ case "context":
317
+ baseLines.push(line.content);
318
+ theirsLines.push(line.content);
319
+ break;
320
+ case "remove":
321
+ baseLines.push(line.content);
322
+ break;
323
+ case "add":
324
+ theirsLines.push(line.content);
325
+ break;
326
+ }
327
+ }
328
+ oursCursor = oursOffset + oursSpan;
329
+ }
330
+ if (oursCursor < oursLines.length) {
331
+ const gapLines = oursLines.slice(oursCursor);
332
+ baseLines.push(...gapLines);
333
+ theirsLines.push(...gapLines);
334
+ }
335
+ return {
336
+ base: baseLines.join("\n"),
337
+ theirs: theirsLines.join("\n")
338
+ };
339
+ }
340
+ function reconstructFromGhostPatch(fileDiff, ours) {
341
+ const hunks = parseHunks(fileDiff);
342
+ if (hunks.length === 0) {
343
+ return null;
344
+ }
345
+ const isPureAddition = hunks.every(
346
+ (h) => h.oldCount === 0 && h.lines.every((l) => l.type !== "remove")
347
+ );
348
+ if (isPureAddition) {
349
+ return null;
350
+ }
351
+ const isPureDeletion = hunks.every(
352
+ (h) => h.newCount === 0 && h.lines.every((l) => l.type !== "add")
353
+ );
354
+ if (isPureDeletion) {
355
+ const baseLines = [];
356
+ for (const hunk of hunks) {
357
+ for (const line of hunk.lines) {
358
+ if (line.type === "context" || line.type === "remove") {
359
+ baseLines.push(line.content);
360
+ }
361
+ }
362
+ }
363
+ return {
364
+ base: baseLines.join("\n"),
365
+ theirs: ""
366
+ };
367
+ }
368
+ const oursLines = ours.split("\n");
369
+ const located = locateHunksInOurs(hunks, oursLines);
370
+ if (!located) {
371
+ return null;
372
+ }
373
+ return assembleHybrid(located, oursLines);
374
+ }
375
+ var init_HybridReconstruction = __esm({
376
+ "src/HybridReconstruction.ts"() {
377
+ "use strict";
378
+ }
379
+ });
380
+
124
381
  // src/index.ts
125
382
  init_GitClient();
126
383
 
@@ -158,6 +415,12 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from "
158
415
  import { join, dirname } from "path";
159
416
  import { stringify, parse } from "yaml";
160
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
+ };
161
424
  var LockfileManager = class {
162
425
  outputDir;
163
426
  lock = null;
@@ -177,12 +440,16 @@ var LockfileManager = class {
177
440
  if (this.lock) {
178
441
  return this.lock;
179
442
  }
180
- if (!this.exists()) {
181
- 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;
182
452
  }
183
- const content = readFileSync(this.lockfilePath, "utf-8");
184
- this.lock = parse(content);
185
- return this.lock;
186
453
  }
187
454
  initialize(firstGeneration) {
188
455
  this.initializeInMemory(firstGeneration);
@@ -328,13 +595,21 @@ var ReplayDetector = class {
328
595
  const exists = await this.git.commitExists(lastGen.commit_sha);
329
596
  if (!exists) {
330
597
  this.warnings.push(
331
- `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
332
604
  );
333
- return { patches: [], revertedPatchIds: [] };
334
605
  }
335
606
  const isAncestor = await this.git.isAncestor(lastGen.commit_sha, "HEAD");
336
607
  if (!isAncestor) {
337
- return this.detectPatchesViaTreeDiff(lastGen);
608
+ return this.detectPatchesViaTreeDiff(
609
+ lastGen,
610
+ /* commitKnownMissing */
611
+ false
612
+ );
338
613
  }
339
614
  const log = await this.git.exec([
340
615
  "log",
@@ -360,7 +635,15 @@ var ReplayDetector = class {
360
635
  if (lock.patches.find((p) => p.original_commit === commit.sha)) {
361
636
  continue;
362
637
  }
363
- 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
+ }
364
647
  const contentHash = this.computeContentHash(patchContent);
365
648
  if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
366
649
  continue;
@@ -453,11 +736,15 @@ var ReplayDetector = class {
453
736
  * Revert reconciliation is skipped here because tree-diff produces a single composite
454
737
  * patch from the aggregate diff — individual revert commits are not distinguishable.
455
738
  */
456
- async detectPatchesViaTreeDiff(lastGen) {
457
- 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"]);
458
745
  const files = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
459
746
  if (files.length === 0) return { patches: [], revertedPatchIds: [] };
460
- const diff = await this.git.exec(["diff", lastGen.commit_sha, "HEAD", "--", ...files]);
747
+ const diff = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
461
748
  if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
462
749
  const contentHash = this.computeContentHash(diff);
463
750
  const lock = this.lockManager.read();
@@ -471,12 +758,113 @@ var ReplayDetector = class {
471
758
  original_commit: headSha,
472
759
  original_message: "Customer customizations (composite)",
473
760
  original_author: "composite",
474
- 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,
475
764
  files,
476
765
  patch_content: diff
477
766
  };
478
767
  return { patches: [compositePatch], revertedPatchIds: [] };
479
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
+ }
480
868
  parseGitLog(log) {
481
869
  return log.trim().split("\n").map((line) => {
482
870
  const [sha, authorName, authorEmail, message] = line.split("\0");
@@ -489,7 +877,7 @@ var ReplayDetector = class {
489
877
  };
490
878
 
491
879
  // src/ThreeWayMerge.ts
492
- import { diff3Merge } from "node-diff3";
880
+ import { diff3Merge, diffPatch } from "node-diff3";
493
881
  function threeWayMerge(base, ours, theirs) {
494
882
  const baseLines = base.split("\n");
495
883
  const oursLines = ours.split("\n");
@@ -503,20 +891,33 @@ function threeWayMerge(base, ours, theirs) {
503
891
  outputLines.push(...region.ok);
504
892
  currentLine += region.ok.length;
505
893
  } else if (region.conflict) {
506
- const startLine = currentLine;
507
- outputLines.push("<<<<<<< Generated");
508
- outputLines.push(...region.conflict.a);
509
- outputLines.push("=======");
510
- outputLines.push(...region.conflict.b);
511
- outputLines.push(">>>>>>> Your customization");
512
- const conflictLines = region.conflict.a.length + region.conflict.b.length + 3;
513
- conflicts.push({
514
- startLine,
515
- endLine: startLine + conflictLines - 1,
516
- ours: region.conflict.a,
517
- theirs: region.conflict.b
518
- });
519
- 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
+ }
520
921
  }
521
922
  }
522
923
  return {
@@ -525,6 +926,62 @@ function threeWayMerge(base, ours, theirs) {
525
926
  conflicts
526
927
  };
527
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
+ }
528
985
 
529
986
  // src/ReplayApplicator.ts
530
987
  import { mkdir, mkdtemp, readFile, rm, writeFile } from "fs/promises";
@@ -533,29 +990,74 @@ import { dirname as dirname2, extname, join as join2 } from "path";
533
990
  import { minimatch } from "minimatch";
534
991
 
535
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
+ }
536
1031
  function stripConflictMarkers(content) {
537
- 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
+ }
538
1037
  const result = [];
539
- let inConflict = false;
540
- let inOurs = false;
541
- for (const line of lines) {
542
- if (line.startsWith("<<<<<<< ")) {
543
- inConflict = true;
544
- inOurs = true;
545
- continue;
546
- }
547
- if (inConflict && line === "=======") {
548
- inOurs = false;
549
- continue;
550
- }
551
- if (inConflict && line.startsWith(">>>>>>> ")) {
552
- inConflict = false;
553
- inOurs = false;
554
- continue;
555
- }
556
- if (!inConflict || inOurs) {
557
- 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
+ }
558
1059
  }
1060
+ result.push(lines[i]);
559
1061
  }
560
1062
  return result.join("\n");
561
1063
  }
@@ -615,12 +1117,34 @@ var ReplayApplicator = class {
615
1117
  lockManager;
616
1118
  outputDir;
617
1119
  renameCache = /* @__PURE__ */ new Map();
1120
+ treeExistsCache = /* @__PURE__ */ new Map();
618
1121
  fileTheirsAccumulator = /* @__PURE__ */ new Map();
619
1122
  constructor(git, lockManager, outputDir) {
620
1123
  this.git = git;
621
1124
  this.lockManager = lockManager;
622
1125
  this.outputDir = outputDir;
623
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
+ }
624
1148
  /** Reset inter-patch accumulator for a new cycle. */
625
1149
  resetAccumulator() {
626
1150
  this.fileTheirsAccumulator.clear();
@@ -702,7 +1226,7 @@ var ReplayApplicator = class {
702
1226
  }
703
1227
  }
704
1228
  async applyPatchWithFallback(patch) {
705
- const baseGen = this.lockManager.getGeneration(patch.base_generation);
1229
+ const baseGen = await this.resolveBaseGeneration(patch.base_generation);
706
1230
  const lock = this.lockManager.read();
707
1231
  const currentGen = lock.generations.find((g) => g.commit_sha === lock.current_generation);
708
1232
  const currentTreeHash = currentGen?.tree_hash ?? baseGen?.tree_hash ?? "";
@@ -786,7 +1310,7 @@ var ReplayApplicator = class {
786
1310
  }
787
1311
  async mergeFile(patch, filePath, tempGit, tempDir) {
788
1312
  try {
789
- const baseGen = this.lockManager.getGeneration(patch.base_generation);
1313
+ const baseGen = await this.resolveBaseGeneration(patch.base_generation);
790
1314
  if (!baseGen) {
791
1315
  return { file: filePath, status: "skipped", reason: "base-generation-not-found" };
792
1316
  }
@@ -811,14 +1335,44 @@ var ReplayApplicator = class {
811
1335
  }
812
1336
  const oursPath = join2(this.outputDir, resolvedPath);
813
1337
  const ours = await readFile(oursPath, "utf-8").catch(() => null);
814
- let theirs = await this.applyPatchToContent(
815
- base,
816
- patch.patch_content,
817
- filePath,
818
- tempGit,
819
- tempDir,
820
- renameSourcePath
821
- );
1338
+ let ghostReconstructed = false;
1339
+ let theirs = null;
1340
+ if (!base && ours && !renameSourcePath) {
1341
+ const treeReachable = await this.isTreeReachable(baseGen.tree_hash);
1342
+ if (!treeReachable) {
1343
+ const fileDiff = this.extractFileDiff(patch.patch_content, filePath);
1344
+ if (fileDiff) {
1345
+ const { reconstructFromGhostPatch: reconstructFromGhostPatch2 } = await Promise.resolve().then(() => (init_HybridReconstruction(), HybridReconstruction_exports));
1346
+ const result = reconstructFromGhostPatch2(fileDiff, ours);
1347
+ if (result) {
1348
+ base = result.base;
1349
+ theirs = result.theirs;
1350
+ ghostReconstructed = true;
1351
+ }
1352
+ }
1353
+ }
1354
+ }
1355
+ if (!ghostReconstructed) {
1356
+ theirs = await this.applyPatchToContent(
1357
+ base,
1358
+ patch.patch_content,
1359
+ filePath,
1360
+ tempGit,
1361
+ tempDir,
1362
+ renameSourcePath
1363
+ );
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
+ }
822
1376
  let useAccumulatorAsMergeBase = false;
823
1377
  const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
824
1378
  if (!theirs && accumulatorEntry) {
@@ -833,6 +1387,17 @@ var ReplayApplicator = class {
833
1387
  useAccumulatorAsMergeBase = true;
834
1388
  }
835
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
+ }
836
1401
  let effective_theirs = theirs;
837
1402
  let baseMismatchSkipped = false;
838
1403
  if (theirs && base && !useAccumulatorAsMergeBase) {
@@ -851,13 +1416,13 @@ var ReplayApplicator = class {
851
1416
  baseMismatchSkipped = true;
852
1417
  }
853
1418
  }
854
- if (!base && !ours && effective_theirs) {
1419
+ if (base == null && !ours && effective_theirs) {
855
1420
  const outDir2 = dirname2(oursPath);
856
1421
  await mkdir(outDir2, { recursive: true });
857
1422
  await writeFile(oursPath, effective_theirs);
858
1423
  return { file: resolvedPath, status: "merged", reason: "new-file" };
859
1424
  }
860
- if (!base && ours && effective_theirs) {
1425
+ if (base == null && ours && effective_theirs) {
861
1426
  const merged2 = threeWayMerge("", ours, effective_theirs);
862
1427
  const outDir2 = dirname2(oursPath);
863
1428
  await mkdir(outDir2, { recursive: true });
@@ -880,7 +1445,7 @@ var ReplayApplicator = class {
880
1445
  reason: "missing-content"
881
1446
  };
882
1447
  }
883
- if (!base && !useAccumulatorAsMergeBase || !ours) {
1448
+ if (base == null && !useAccumulatorAsMergeBase || !ours) {
884
1449
  return {
885
1450
  file: resolvedPath,
886
1451
  status: "skipped",
@@ -899,7 +1464,7 @@ var ReplayApplicator = class {
899
1464
  const outDir = dirname2(oursPath);
900
1465
  await mkdir(outDir, { recursive: true });
901
1466
  await writeFile(oursPath, merged.content);
902
- if (effective_theirs) {
1467
+ if (effective_theirs && !merged.hasConflicts) {
903
1468
  this.fileTheirsAccumulator.set(resolvedPath, {
904
1469
  content: effective_theirs,
905
1470
  baseGeneration: patch.base_generation
@@ -923,6 +1488,14 @@ var ReplayApplicator = class {
923
1488
  };
924
1489
  }
925
1490
  }
1491
+ async isTreeReachable(treeHash) {
1492
+ let result = this.treeExistsCache.get(treeHash);
1493
+ if (result === void 0) {
1494
+ result = await this.git.treeExists(treeHash);
1495
+ this.treeExistsCache.set(treeHash, result);
1496
+ }
1497
+ return result;
1498
+ }
926
1499
  isExcluded(patch) {
927
1500
  const config = this.lockManager.getCustomizationsConfig();
928
1501
  if (!config.exclude) return false;
@@ -1193,6 +1766,7 @@ var ReplayService = class {
1193
1766
  generator_versions: options?.generatorVersions ?? {},
1194
1767
  base_branch_head: options?.baseBranchHead
1195
1768
  };
1769
+ let resolvedPatches;
1196
1770
  if (!this.lockManager.exists()) {
1197
1771
  this.lockManager.initializeInMemory(record);
1198
1772
  } else {
@@ -1201,13 +1775,13 @@ var ReplayService = class {
1201
1775
  ...this.lockManager.getUnresolvedPatches(),
1202
1776
  ...this.lockManager.getResolvingPatches()
1203
1777
  ];
1778
+ resolvedPatches = this.lockManager.getPatches().filter((p) => p.status == null);
1204
1779
  this.lockManager.addGeneration(record);
1205
1780
  this.lockManager.clearPatches();
1206
1781
  for (const patch of unresolvedPatches) {
1207
1782
  this.lockManager.addPatch(patch);
1208
1783
  }
1209
1784
  }
1210
- this.lockManager.save();
1211
1785
  try {
1212
1786
  const { patches: redetectedPatches } = await this.detector.detectNewPatches();
1213
1787
  if (redetectedPatches.length > 0) {
@@ -1221,20 +1795,27 @@ var ReplayService = class {
1221
1795
  for (const patch of redetectedPatches) {
1222
1796
  this.lockManager.addPatch(patch);
1223
1797
  }
1224
- this.lockManager.save();
1225
1798
  }
1226
- } 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
+ );
1227
1806
  }
1807
+ this.lockManager.save();
1228
1808
  }
1229
1809
  determineFlow() {
1230
- if (!this.lockManager.exists()) {
1231
- return "first-generation";
1232
- }
1233
- const lock = this.lockManager.read();
1234
- if (lock.patches.length === 0) {
1235
- 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;
1236
1818
  }
1237
- return "normal-regeneration";
1238
1819
  }
1239
1820
  async handleFirstGeneration(options) {
1240
1821
  if (options?.dryRun) {
@@ -1276,12 +1857,17 @@ var ReplayService = class {
1276
1857
  baseBranchHead: options.baseBranchHead
1277
1858
  } : void 0;
1278
1859
  await this.committer.commitGeneration("Update SDK (replay skipped)", commitOpts);
1860
+ await this.cleanupStaleConflictMarkers();
1279
1861
  const genRecord = await this.committer.createGenerationRecord(commitOpts);
1280
- if (this.lockManager.exists()) {
1862
+ try {
1281
1863
  this.lockManager.read();
1282
1864
  this.lockManager.addGeneration(genRecord);
1283
- } else {
1284
- this.lockManager.initializeInMemory(genRecord);
1865
+ } catch (error) {
1866
+ if (error instanceof LockfileNotFoundError) {
1867
+ this.lockManager.initializeInMemory(genRecord);
1868
+ } else {
1869
+ throw error;
1870
+ }
1285
1871
  }
1286
1872
  this.lockManager.setReplaySkippedAt((/* @__PURE__ */ new Date()).toISOString());
1287
1873
  this.lockManager.save();
@@ -1327,6 +1913,7 @@ var ReplayService = class {
1327
1913
  baseBranchHead: options.baseBranchHead
1328
1914
  } : void 0;
1329
1915
  await this.committer.commitGeneration("Update SDK", commitOpts);
1916
+ await this.cleanupStaleConflictMarkers();
1330
1917
  const genRecord = await this.committer.createGenerationRecord(commitOpts);
1331
1918
  this.lockManager.addGeneration(genRecord);
1332
1919
  let results = [];
@@ -1420,6 +2007,7 @@ var ReplayService = class {
1420
2007
  baseBranchHead: options.baseBranchHead
1421
2008
  } : void 0;
1422
2009
  await this.committer.commitGeneration("Update SDK", commitOpts);
2010
+ await this.cleanupStaleConflictMarkers();
1423
2011
  const genRecord = await this.committer.createGenerationRecord(commitOpts);
1424
2012
  this.lockManager.addGeneration(genRecord);
1425
2013
  const results = await this.applicator.applyPatches(allPatches);
@@ -1610,6 +2198,13 @@ var ReplayService = class {
1610
2198
  contentRefreshed++;
1611
2199
  continue;
1612
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
+ }
1613
2208
  const newContentHash = this.detector.computeContentHash(diff);
1614
2209
  if (newContentHash !== patch.content_hash) {
1615
2210
  const filesOutput = await this.git.exec(["diff", "--name-only", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
@@ -1630,7 +2225,7 @@ var ReplayService = class {
1630
2225
  continue;
1631
2226
  }
1632
2227
  try {
1633
- 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(() => "");
1634
2229
  if (markerFiles.trim()) continue;
1635
2230
  const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
1636
2231
  if (diff === null) continue;
@@ -1670,6 +2265,25 @@ var ReplayService = class {
1670
2265
  }
1671
2266
  }
1672
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
+ }
1673
2287
  readFernignorePatterns() {
1674
2288
  const fernignorePath = join3(this.outputDir, ".fernignore");
1675
2289
  if (!existsSync2(fernignorePath)) return [];
@@ -1997,6 +2611,7 @@ async function bootstrap(outputDir, options) {
1997
2611
  }
1998
2612
  lockManager.save();
1999
2613
  const fernignoreUpdated = ensureFernignoreEntries(outputDir);
2614
+ ensureGitattributesEntries(outputDir);
2000
2615
  if (migrator.fernignoreExists() && fernignorePatterns.length > 0) {
2001
2616
  const action = options?.fernignoreAction ?? "skip";
2002
2617
  if (action === "migrate") {
@@ -2078,7 +2693,7 @@ function parseGitLog(log) {
2078
2693
  return { sha, authorName, authorEmail, message };
2079
2694
  });
2080
2695
  }
2081
- var REPLAY_FERNIGNORE_ENTRIES = [".fern/replay.lock", ".fern/replay.yml"];
2696
+ var REPLAY_FERNIGNORE_ENTRIES = [".fern/replay.lock", ".fern/replay.yml", ".gitattributes"];
2082
2697
  function ensureFernignoreEntries(outputDir) {
2083
2698
  const fernignorePath = join5(outputDir, ".fernignore");
2084
2699
  let content = "";
@@ -2102,6 +2717,29 @@ function ensureFernignoreEntries(outputDir) {
2102
2717
  writeFileSync4(fernignorePath, content, "utf-8");
2103
2718
  return true;
2104
2719
  }
2720
+ var GITATTRIBUTES_ENTRIES = [".fern/replay.lock linguist-generated=true"];
2721
+ function ensureGitattributesEntries(outputDir) {
2722
+ const gitattributesPath = join5(outputDir, ".gitattributes");
2723
+ let content = "";
2724
+ if (existsSync4(gitattributesPath)) {
2725
+ content = readFileSync4(gitattributesPath, "utf-8");
2726
+ }
2727
+ const lines = content.split("\n");
2728
+ const toAdd = [];
2729
+ for (const entry of GITATTRIBUTES_ENTRIES) {
2730
+ if (!lines.some((line) => line.trim() === entry)) {
2731
+ toAdd.push(entry);
2732
+ }
2733
+ }
2734
+ if (toAdd.length === 0) {
2735
+ return;
2736
+ }
2737
+ if (content && !content.endsWith("\n")) {
2738
+ content += "\n";
2739
+ }
2740
+ content += toAdd.join("\n") + "\n";
2741
+ writeFileSync4(gitattributesPath, content, "utf-8");
2742
+ }
2105
2743
  function computeContentHash(patchContent) {
2106
2744
  const normalized = patchContent.split("\n").filter((line) => !line.startsWith("From ") && !line.startsWith("index ") && !line.startsWith("Date: ")).join("\n");
2107
2745
  return `sha256:${createHash3("sha256").update(normalized).digest("hex")}`;
@@ -2420,6 +3058,7 @@ export {
2420
3058
  FernignoreMigrator,
2421
3059
  GitClient,
2422
3060
  LockfileManager,
3061
+ LockfileNotFoundError,
2423
3062
  ReplayApplicator,
2424
3063
  ReplayCommitter,
2425
3064
  ReplayDetector,