@fern-api/replay 0.13.0 → 0.14.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.cjs CHANGED
@@ -51,9 +51,9 @@ var init_GitClient = __esm({
51
51
  return this.git.raw(args);
52
52
  }
53
53
  async execWithInput(args, input) {
54
- const { spawn } = await import("child_process");
54
+ const { spawn: spawn2 } = await import("child_process");
55
55
  return new Promise((resolve2, reject) => {
56
- const proc = spawn("git", args, { cwd: this.repoPath });
56
+ const proc = spawn2("git", args, { cwd: this.repoPath });
57
57
  let stdout = "";
58
58
  let stderr = "";
59
59
  proc.stdout.on("data", (data) => {
@@ -417,6 +417,64 @@ var init_HybridReconstruction = __esm({
417
417
  }
418
418
  });
419
419
 
420
+ // src/PatchApplyTheirs.ts
421
+ var PatchApplyTheirs_exports = {};
422
+ __export(PatchApplyTheirs_exports, {
423
+ applyPatchToContent: () => applyPatchToContent
424
+ });
425
+ async function applyPatchToContent(base, patchContent, filePath) {
426
+ const fileDiff = extractFileDiff(patchContent, filePath);
427
+ if (!fileDiff) return null;
428
+ const tempDir = await (0, import_promises3.mkdtemp)((0, import_node_path4.join)((0, import_node_os3.tmpdir)(), "replay-migrate-"));
429
+ const tempGit = new GitClient(tempDir);
430
+ try {
431
+ await tempGit.exec(["init"]);
432
+ await tempGit.exec(["config", "user.email", "migrate@fern.com"]);
433
+ await tempGit.exec(["config", "user.name", "Fern Replay Migration"]);
434
+ await tempGit.exec(["config", "commit.gpgSign", "false"]);
435
+ const tempFilePath = (0, import_node_path4.join)(tempDir, filePath);
436
+ await (0, import_promises3.mkdir)((0, import_node_path4.dirname)(tempFilePath), { recursive: true });
437
+ await (0, import_promises3.writeFile)(tempFilePath, base);
438
+ await tempGit.exec(["add", filePath]);
439
+ await tempGit.exec(["commit", "-m", `base for ${filePath}`, "--allow-empty"]);
440
+ await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
441
+ return await (0, import_promises3.readFile)(tempFilePath, "utf-8");
442
+ } catch {
443
+ return null;
444
+ } finally {
445
+ await (0, import_promises3.rm)(tempDir, { recursive: true, force: true }).catch(() => {
446
+ });
447
+ }
448
+ }
449
+ function extractFileDiff(patchContent, filePath) {
450
+ const lines = patchContent.split("\n");
451
+ const out = [];
452
+ let inTarget = false;
453
+ for (const line of lines) {
454
+ if (line.startsWith("diff --git")) {
455
+ if (inTarget) break;
456
+ const match = line.match(/^diff --git a\/.+ b\/(.+)$/);
457
+ if (match && match[1] === filePath) {
458
+ inTarget = true;
459
+ out.push(line);
460
+ }
461
+ continue;
462
+ }
463
+ if (inTarget) out.push(line);
464
+ }
465
+ return out.length > 0 ? out.join("\n") + "\n" : null;
466
+ }
467
+ var import_promises3, import_node_os3, import_node_path4;
468
+ var init_PatchApplyTheirs = __esm({
469
+ "src/PatchApplyTheirs.ts"() {
470
+ "use strict";
471
+ import_promises3 = require("fs/promises");
472
+ import_node_os3 = require("os");
473
+ import_node_path4 = require("path");
474
+ init_GitClient();
475
+ }
476
+ });
477
+
420
478
  // src/index.ts
421
479
  var index_exports = {};
422
480
  __export(index_exports, {
@@ -580,7 +638,17 @@ var LockfileManager = class {
580
638
  this.warnings.length = 0;
581
639
  const cleanPatches = [];
582
640
  for (const patch of this.lock.patches) {
583
- const credentialMatches = scanForCredentials(patch.patch_content);
641
+ const diffMatches = scanForCredentials(patch.patch_content);
642
+ const snapshotMatches = [];
643
+ if (patch.theirs_snapshot) {
644
+ for (const content2 of Object.values(patch.theirs_snapshot)) {
645
+ const m = scanForCredentials(content2);
646
+ for (const x of m) {
647
+ if (!snapshotMatches.includes(x)) snapshotMatches.push(x);
648
+ }
649
+ }
650
+ }
651
+ const credentialMatches = [...diffMatches, ...snapshotMatches.filter((m) => !diffMatches.includes(m))];
584
652
  const sensitiveFiles = patch.files.filter((f) => isSensitiveFile(f));
585
653
  if (credentialMatches.length > 0 || sensitiveFiles.length > 0) {
586
654
  const reasons = [];
@@ -701,1406 +769,1593 @@ var LockfileManager = class {
701
769
 
702
770
  // src/ReplayDetector.ts
703
771
  var import_node_crypto = require("crypto");
704
- var INFRASTRUCTURE_FILES = /* @__PURE__ */ new Set([".fernignore"]);
705
- function parsePatchFileHeaders(patchContent) {
706
- const results = [];
707
- let currentPath = null;
708
- let currentIsCreate = false;
709
- let currentIsDelete = false;
710
- const flush = () => {
711
- if (currentPath !== null) {
712
- results.push({ path: currentPath, isCreate: currentIsCreate, isDelete: currentIsDelete });
713
- }
714
- };
715
- for (const line of patchContent.split("\n")) {
716
- if (line.startsWith("diff --git ")) {
717
- flush();
718
- currentPath = null;
719
- currentIsCreate = false;
720
- currentIsDelete = false;
721
- const match = line.match(/^diff --git a\/(.+?) b\/(.+?)$/);
722
- if (match) {
723
- currentPath = match[2] ?? null;
772
+
773
+ // src/ReplayApplicator.ts
774
+ var import_promises = require("fs/promises");
775
+ var import_node_os = require("os");
776
+ var import_node_path2 = require("path");
777
+ var import_minimatch = require("minimatch");
778
+
779
+ // src/conflict-utils.ts
780
+ var CONFLICT_OPENER = "<<<<<<< Generated";
781
+ var CONFLICT_SEPARATOR = "=======";
782
+ var CONFLICT_CLOSER = ">>>>>>> Your customization";
783
+ function trimCR(line) {
784
+ return line.endsWith("\r") ? line.slice(0, -1) : line;
785
+ }
786
+ function findConflictRanges(lines) {
787
+ const ranges = [];
788
+ let i = 0;
789
+ while (i < lines.length) {
790
+ if (trimCR(lines[i]) === CONFLICT_OPENER) {
791
+ let separatorIdx = -1;
792
+ let j = i + 1;
793
+ let found = false;
794
+ while (j < lines.length) {
795
+ const trimmed = trimCR(lines[j]);
796
+ if (trimmed === CONFLICT_OPENER) {
797
+ break;
798
+ }
799
+ if (separatorIdx === -1 && trimmed === CONFLICT_SEPARATOR) {
800
+ separatorIdx = j;
801
+ } else if (separatorIdx !== -1 && trimmed === CONFLICT_CLOSER) {
802
+ ranges.push({ start: i, separator: separatorIdx, end: j });
803
+ i = j;
804
+ found = true;
805
+ break;
806
+ }
807
+ j++;
724
808
  }
725
- } else if (currentPath !== null) {
726
- if (line.startsWith("new file mode ")) {
727
- currentIsCreate = true;
728
- } else if (line.startsWith("deleted file mode ")) {
729
- currentIsDelete = true;
809
+ if (!found) {
810
+ i++;
811
+ continue;
730
812
  }
731
813
  }
814
+ i++;
732
815
  }
733
- flush();
734
- return results;
816
+ return ranges;
735
817
  }
736
- var ReplayDetector = class {
737
- git;
738
- lockManager;
739
- sdkOutputDir;
740
- warnings = [];
741
- constructor(git, lockManager, sdkOutputDir) {
742
- this.git = git;
743
- this.lockManager = lockManager;
744
- this.sdkOutputDir = sdkOutputDir;
745
- }
746
- async detectNewPatches() {
747
- const lock = this.lockManager.read();
748
- const lastGen = this.getLastGeneration(lock);
749
- if (!lastGen) {
750
- return { patches: [], revertedPatchIds: [] };
751
- }
752
- const exists = await this.git.commitExists(lastGen.commit_sha);
753
- if (!exists) {
754
- this.warnings.push(
755
- `Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to alternate detection.`
756
- );
757
- return this.detectPatchesViaTreeDiff(
758
- lastGen,
759
- /* commitKnownMissing */
760
- true
761
- );
762
- }
763
- const isAncestor = await this.git.isAncestor(lastGen.commit_sha, "HEAD");
764
- if (!isAncestor) {
765
- return this.detectPatchesViaMergeBase(lastGen, lock);
766
- }
767
- return this.detectPatchesInRange(lastGen.commit_sha, lastGen, lock);
768
- }
769
- /**
770
- * FER-10201 — Non-linear-history detection via `merge-base(prevGen, HEAD)`.
771
- *
772
- * After a squash-merge of a Fern-bot PR, `lastGen.commit_sha` (the previous
773
- * `[fern-generated]`) is orphaned but still in git's object database. The
774
- * merge-base is the commit on main from which the bot's branch was created —
775
- * a reachable ancestor of HEAD. Walking that range and classifying each
776
- * commit via `isGenerationCommit` mirrors the linear path and eliminates
777
- * the bug class where pipeline-driven changes (autoversion, replay,
778
- * generation) get bundled as customer customizations.
779
- *
780
- * Falls through to the legacy composite path when no common ancestor exists
781
- * (truly disjoint history — orphan branches with no shared root).
782
- */
783
- async detectPatchesViaMergeBase(lastGen, lock) {
784
- let mergeBase = "";
785
- try {
786
- mergeBase = (await this.git.exec(["merge-base", lastGen.commit_sha, "HEAD"])).trim();
787
- } catch {
788
- }
789
- if (!mergeBase) {
790
- this.warnings.push(
791
- `No common ancestor between previous generation ${lastGen.commit_sha.slice(0, 7)} and HEAD. Falling back to composite tree-diff detection.`
792
- );
793
- return this.detectPatchesViaTreeDiff(
794
- lastGen,
795
- /* commitKnownMissing */
796
- false
797
- );
798
- }
799
- return this.detectPatchesInRange(mergeBase, lastGen, lock);
818
+ function stripConflictMarkers(content) {
819
+ const lines = content.split(/\r?\n/);
820
+ const ranges = findConflictRanges(lines);
821
+ if (ranges.length === 0) {
822
+ return content;
800
823
  }
801
- /**
802
- * FER-10201 Walk `${rangeStart}..HEAD`, classify each commit, emit one
803
- * `StoredPatch` per customer commit. Body shared by the linear path
804
- * (rangeStart = lastGen.commit_sha) and the merge-base path.
805
- *
806
- * Patch `base_generation` is always `lastGen.commit_sha` — the
807
- * lockfile-tracked reference the applicator uses to fetch base file
808
- * content, regardless of where the walk actually started.
809
- */
810
- async detectPatchesInRange(rangeStart, lastGen, lock) {
811
- const log = await this.git.exec([
812
- "log",
813
- "--first-parent",
814
- "--format=%H%x00%an%x00%ae%x00%s",
815
- `${rangeStart}..HEAD`,
816
- "--",
817
- this.sdkOutputDir
818
- ]);
819
- if (!log.trim()) {
820
- return { patches: [], revertedPatchIds: [] };
821
- }
822
- const commits = this.parseGitLog(log);
823
- const newPatches = [];
824
- const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
825
- for (const commit of commits) {
826
- if (isGenerationCommit(commit)) {
827
- continue;
828
- }
829
- if (lock.patches.find((p) => p.original_commit === commit.sha)) {
824
+ const result = [];
825
+ let rangeIdx = 0;
826
+ for (let i = 0; i < lines.length; i++) {
827
+ if (rangeIdx < ranges.length) {
828
+ const range = ranges[rangeIdx];
829
+ if (i === range.start) {
830
830
  continue;
831
831
  }
832
- const parents = await this.git.getCommitParents(commit.sha);
833
- if (parents.length > 1) {
834
- const compositePatch = await this.createMergeCompositePatch({
835
- mergeCommit: commit,
836
- firstParent: parents[0],
837
- lastGen,
838
- lock
839
- });
840
- if (compositePatch) {
841
- newPatches.push(compositePatch);
842
- }
832
+ if (i > range.start && i < range.separator) {
833
+ result.push(lines[i]);
843
834
  continue;
844
835
  }
845
- let patchContent;
846
- try {
847
- patchContent = await this.git.formatPatch(commit.sha);
848
- } catch {
849
- this.warnings.push(
850
- `Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
851
- );
836
+ if (i === range.separator) {
852
837
  continue;
853
838
  }
854
- let contentHash = this.computeContentHash(patchContent);
855
- if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
839
+ if (i > range.separator && i < range.end) {
856
840
  continue;
857
841
  }
858
- const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
859
- const allFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
860
- const sensitiveFiles = allFiles.filter((f) => isSensitiveFile(f));
861
- const files = allFiles.filter((f) => !isSensitiveFile(f));
862
- if (sensitiveFiles.length > 0) {
863
- this.warnings.push(
864
- `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
865
- );
866
- if (files.length > 0) {
867
- const parentSha = parents[0];
868
- if (parentSha) {
869
- try {
870
- patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
871
- } catch {
872
- continue;
873
- }
874
- if (!patchContent.trim()) continue;
875
- contentHash = this.computeContentHash(patchContent);
876
- }
877
- }
878
- }
879
- if (files.length === 0) {
842
+ if (i === range.end) {
843
+ rangeIdx++;
880
844
  continue;
881
845
  }
882
- newPatches.push({
883
- id: `patch-${commit.sha.slice(0, 8)}`,
884
- content_hash: contentHash,
885
- original_commit: commit.sha,
886
- original_message: commit.message,
887
- original_author: `${commit.authorName} <${commit.authorEmail}>`,
888
- base_generation: lastGen.commit_sha,
889
- files,
890
- patch_content: patchContent,
891
- ...this.isCreationOnlyPatch(patchContent) ? { user_owned: true } : {}
892
- });
893
846
  }
894
- const survivingPatches = await this.collapseNetZeroFiles(newPatches);
895
- survivingPatches.reverse();
896
- const revertedPatchIdSet = /* @__PURE__ */ new Set();
897
- const revertIndicesToRemove = /* @__PURE__ */ new Set();
898
- for (let i = 0; i < survivingPatches.length; i++) {
899
- const patch = survivingPatches[i];
900
- if (!isRevertCommit(patch.original_message)) continue;
901
- let body = "";
902
- try {
903
- body = await this.git.getCommitBody(patch.original_commit);
904
- } catch {
905
- }
906
- const revertedSha = parseRevertedSha(body);
907
- const revertedMessage = parseRevertedMessage(patch.original_message);
908
- let matchedExisting = false;
909
- if (revertedSha) {
910
- const existing = lock.patches.find((p) => p.original_commit === revertedSha);
911
- if (existing) {
912
- revertedPatchIdSet.add(existing.id);
913
- revertIndicesToRemove.add(i);
914
- matchedExisting = true;
915
- }
847
+ result.push(lines[i]);
848
+ }
849
+ return result.join("\n");
850
+ }
851
+
852
+ // src/ThreeWayMerge.ts
853
+ var import_node_diff3 = require("node-diff3");
854
+ function threeWayMerge(base, ours, theirs) {
855
+ const baseLines = base.split("\n");
856
+ const oursLines = ours.split("\n");
857
+ const theirsLines = theirs.split("\n");
858
+ const regions = (0, import_node_diff3.diff3Merge)(oursLines, baseLines, theirsLines);
859
+ const outputLines = [];
860
+ const conflicts = [];
861
+ let currentLine = 1;
862
+ for (const region of regions) {
863
+ if (region.ok) {
864
+ outputLines.push(...region.ok);
865
+ currentLine += region.ok.length;
866
+ } else if (region.conflict) {
867
+ const resolved = tryResolveConflict(
868
+ region.conflict.a,
869
+ // ours (generator)
870
+ region.conflict.o,
871
+ // base
872
+ region.conflict.b
873
+ // theirs (user)
874
+ );
875
+ if (resolved !== null) {
876
+ outputLines.push(...resolved);
877
+ currentLine += resolved.length;
878
+ } else {
879
+ const startLine = currentLine;
880
+ outputLines.push("<<<<<<< Generated");
881
+ outputLines.push(...region.conflict.a);
882
+ outputLines.push("=======");
883
+ outputLines.push(...region.conflict.b);
884
+ outputLines.push(">>>>>>> Your customization");
885
+ const conflictLines = region.conflict.a.length + region.conflict.b.length + 3;
886
+ conflicts.push({
887
+ startLine,
888
+ endLine: startLine + conflictLines - 1,
889
+ ours: region.conflict.a,
890
+ theirs: region.conflict.b
891
+ });
892
+ currentLine += conflictLines;
916
893
  }
917
- if (!matchedExisting && revertedMessage) {
918
- const existing = lock.patches.find((p) => p.original_message === revertedMessage);
919
- if (existing) {
920
- revertedPatchIdSet.add(existing.id);
921
- revertIndicesToRemove.add(i);
922
- matchedExisting = true;
923
- }
894
+ }
895
+ }
896
+ const result = {
897
+ content: outputLines.join("\n"),
898
+ hasConflicts: conflicts.length > 0,
899
+ conflicts
900
+ };
901
+ if (conflicts.length === 0) {
902
+ const dropped = computeDroppedContextLines(baseLines, theirsLines, outputLines);
903
+ if (dropped.length > 0) {
904
+ result.droppedContextLines = dropped;
905
+ }
906
+ }
907
+ return result;
908
+ }
909
+ function computeDroppedContextLines(baseLines, theirsLines, mergedLines) {
910
+ const baseCounts = countLines(baseLines);
911
+ const theirsCounts = countLines(theirsLines);
912
+ const mergedCounts = countLines(mergedLines);
913
+ const dropped = [];
914
+ const seen = /* @__PURE__ */ new Set();
915
+ for (const line of baseLines) {
916
+ if (seen.has(line)) continue;
917
+ const inBase = baseCounts.get(line) ?? 0;
918
+ const inTheirs = theirsCounts.get(line) ?? 0;
919
+ const inMerged = mergedCounts.get(line) ?? 0;
920
+ if (inBase > 0 && inTheirs > 0 && inMerged < Math.min(inBase, inTheirs)) {
921
+ dropped.push(line);
922
+ seen.add(line);
923
+ }
924
+ }
925
+ return dropped;
926
+ }
927
+ function countLines(lines) {
928
+ const counts = /* @__PURE__ */ new Map();
929
+ for (const line of lines) {
930
+ counts.set(line, (counts.get(line) ?? 0) + 1);
931
+ }
932
+ return counts;
933
+ }
934
+ function tryResolveConflict(oursLines, baseLines, theirsLines) {
935
+ if (baseLines.length === 0) {
936
+ return null;
937
+ }
938
+ const oursPatches = (0, import_node_diff3.diffPatch)(baseLines, oursLines);
939
+ const theirsPatches = (0, import_node_diff3.diffPatch)(baseLines, theirsLines);
940
+ if (oursPatches.length === 0) return theirsLines;
941
+ if (theirsPatches.length === 0) return oursLines;
942
+ if (patchesOverlap(oursPatches, theirsPatches)) {
943
+ return null;
944
+ }
945
+ return applyBothPatches(baseLines, oursPatches, theirsPatches);
946
+ }
947
+ function patchesOverlap(oursPatches, theirsPatches) {
948
+ for (const op of oursPatches) {
949
+ const oStart = op.buffer1.offset;
950
+ const oEnd = oStart + op.buffer1.length;
951
+ for (const tp of theirsPatches) {
952
+ const tStart = tp.buffer1.offset;
953
+ const tEnd = tStart + tp.buffer1.length;
954
+ if (op.buffer1.length === 0 && tp.buffer1.length === 0 && oStart === tStart) {
955
+ return true;
924
956
  }
925
- if (matchedExisting) continue;
926
- let matchedNew = false;
927
- if (revertedSha) {
928
- const idx = survivingPatches.findIndex(
929
- (p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_commit === revertedSha
930
- );
931
- if (idx !== -1) {
932
- revertIndicesToRemove.add(i);
933
- revertIndicesToRemove.add(idx);
934
- matchedNew = true;
935
- }
957
+ if (tp.buffer1.length === 0 && tStart === oEnd) {
958
+ return true;
936
959
  }
937
- if (!matchedNew && revertedMessage) {
938
- const idx = survivingPatches.findIndex(
939
- (p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_message === revertedMessage
940
- );
941
- if (idx !== -1) {
942
- revertIndicesToRemove.add(i);
943
- revertIndicesToRemove.add(idx);
944
- }
960
+ if (op.buffer1.length === 0 && oStart === tEnd) {
961
+ return true;
945
962
  }
946
- if (!matchedExisting && !matchedNew) {
947
- revertIndicesToRemove.add(i);
963
+ if (oStart < tEnd && tStart < oEnd) {
964
+ return true;
948
965
  }
949
966
  }
950
- const filteredPatches = survivingPatches.filter((_, i) => !revertIndicesToRemove.has(i));
951
- return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
952
967
  }
968
+ return false;
969
+ }
970
+ function applyBothPatches(baseLines, oursPatches, theirsPatches) {
971
+ const allPatches = [
972
+ ...oursPatches.map((p) => ({
973
+ offset: p.buffer1.offset,
974
+ length: p.buffer1.length,
975
+ replacement: p.buffer2.chunk
976
+ })),
977
+ ...theirsPatches.map((p) => ({
978
+ offset: p.buffer1.offset,
979
+ length: p.buffer1.length,
980
+ replacement: p.buffer2.chunk
981
+ }))
982
+ ];
983
+ allPatches.sort((a, b) => b.offset - a.offset);
984
+ const result = [...baseLines];
985
+ for (const p of allPatches) {
986
+ result.splice(p.offset, p.length, ...p.replacement);
987
+ }
988
+ return result;
989
+ }
990
+
991
+ // src/ReplayApplicator.ts
992
+ var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
993
+ ".png",
994
+ ".jpg",
995
+ ".jpeg",
996
+ ".gif",
997
+ ".bmp",
998
+ ".ico",
999
+ ".webp",
1000
+ ".svg",
1001
+ ".pdf",
1002
+ ".doc",
1003
+ ".docx",
1004
+ ".xls",
1005
+ ".xlsx",
1006
+ ".ppt",
1007
+ ".pptx",
1008
+ ".zip",
1009
+ ".gz",
1010
+ ".tar",
1011
+ ".bz2",
1012
+ ".7z",
1013
+ ".rar",
1014
+ ".jar",
1015
+ ".war",
1016
+ ".ear",
1017
+ ".class",
1018
+ ".exe",
1019
+ ".dll",
1020
+ ".so",
1021
+ ".dylib",
1022
+ ".o",
1023
+ ".a",
1024
+ ".woff",
1025
+ ".woff2",
1026
+ ".ttf",
1027
+ ".eot",
1028
+ ".otf",
1029
+ ".mp3",
1030
+ ".mp4",
1031
+ ".avi",
1032
+ ".mov",
1033
+ ".wav",
1034
+ ".flac",
1035
+ ".sqlite",
1036
+ ".db",
1037
+ ".pyc",
1038
+ ".pyo",
1039
+ ".DS_Store"
1040
+ ]);
1041
+ var ReplayApplicator = class {
1042
+ git;
1043
+ lockManager;
1044
+ outputDir;
1045
+ renameCache = /* @__PURE__ */ new Map();
1046
+ treeExistsCache = /* @__PURE__ */ new Map();
1047
+ fileTheirsAccumulator = /* @__PURE__ */ new Map();
953
1048
  /**
954
- * Compute content hash for deduplication.
955
- *
956
- * Produces a format-agnostic hash so both `git format-patch` output
957
- * (email-wrapped) and plain `git diff` output hash to the same value
958
- * when the underlying diff hunks are identical.
1049
+ * Apply mode for the current `applyPatches` invocation. Set at the start
1050
+ * of `applyPatches`, read by `mergeFile` to decide:
1051
+ * - whether to skip the intra-loop marker strip (kept in `applyPatches`)
1052
+ * - whether to populate the accumulator after a conflicted merge
1053
+ * (resolve mode populates so subsequent patches on the same file
1054
+ * can use patch A's THEIRS as a structurally-correct merge base
1055
+ * when their diff was authored against post-A structure)
1056
+ * - whether to retry THEIRS reconstruction against the accumulator
1057
+ * when the BASE-relative reconstruction produced markers from a
1058
+ * cross-patch context mismatch
1059
+ */
1060
+ currentApplyMode = "replay";
1061
+ /**
1062
+ * Set of files that appear in 2+ patches in the current `applyPatches`
1063
+ * invocation. Computed at the top of `applyPatches`. Read by `mergeFile`
1064
+ * and `populateAccumulatorForPatch` to decide whether to use the patch's
1065
+ * `theirs_snapshot` directly as THEIRS (snapshot-as-primary) or fall
1066
+ * back to line-anchored reconstruction (FER-9525 cross-patch isolation).
959
1067
  *
960
- * Normalization:
961
- * 1. Strip email wrapper: everything before the first `diff --git` line
962
- * (From:, Subject:, Date:, diffstat, blank separators).
963
- * 2. Strip `index` lines (blob SHA pairs change across rebases).
964
- * 3. Strip trailing git version marker (`-- \n<version>`).
1068
+ * Snapshot-as-primary is correct when the patch owns its file (no other
1069
+ * patch in this run touches it): the snapshot is what the customer
1070
+ * intends for that file post-regen, regardless of generator structural
1071
+ * changes that would invalidate the diff's line anchors.
965
1072
  *
966
- * This ensures content hashes match across the no-patches and normal-
967
- * regeneration flows, which store formatPatch and git-diff formats
968
- * respectively (FER-9850, D5-D9).
1073
+ * When a file IS shared, snapshots are CUMULATIVE across the patches
1074
+ * touching it (each one's snapshot includes prior patches' contributions).
1075
+ * Using a later patch's cumulative snapshot as its individual THEIRS
1076
+ * would propagate prior patches' edits into the merge — surfacing nested
1077
+ * conflicts at regions the patch alone never touched. Reconstruction
1078
+ * via `applyPatchToContent` is required there.
969
1079
  */
970
- computeContentHash(patchContent) {
971
- const lines = patchContent.split("\n");
972
- const diffStart = lines.findIndex((l) => l.startsWith("diff --git "));
973
- const relevantLines = diffStart > 0 ? lines.slice(diffStart) : lines;
974
- const normalized = relevantLines.filter((line) => !line.startsWith("index ")).join("\n").replace(/\n-- \n[\d]+\.[\d]+[^\n]*\s*$/, "");
975
- return `sha256:${(0, import_node_crypto.createHash)("sha256").update(normalized).digest("hex")}`;
1080
+ sharedFiles = /* @__PURE__ */ new Set();
1081
+ constructor(git, lockManager, outputDir) {
1082
+ this.git = git;
1083
+ this.lockManager = lockManager;
1084
+ this.outputDir = outputDir;
976
1085
  }
977
1086
  /**
978
- * FER-9805 Drop patches whose files were transiently created and then
979
- * deleted within the current linear detection window, and are absent from
980
- * HEAD at the end of the window.
981
- *
982
- * Rationale: on a merge commit, createMergeCompositePatch already diffs
983
- * firstParent..mergeCommit so net-zero files never enter the composite. On
984
- * linear history each commit becomes its own patch, so a file that was
985
- * briefly added and later removed survives as a create+delete pair whose
986
- * cumulative diff is empty. We drop such patches before they reach the
987
- * lockfile.
988
- *
989
- * A file is "transient" when:
990
- * 1. some patch in the window sets `new file mode` for it (a creation), AND
991
- * 2. some patch in the window sets `deleted file mode` for it (a deletion), AND
992
- * 3. it is absent from HEAD's tree.
993
- *
994
- * The HEAD guard (#3) prevents false positives when a file is deleted
995
- * and then recreated with different content — the cumulative diff of
996
- * such a pair is non-empty and must be preserved.
997
- *
998
- * This only drops patches whose `files` are a subset of the transient
999
- * set. A partial-transient patch (one that touches a transient file
1000
- * alongside a persistent one) is left unmodified; surgical stripping of
1001
- * single files from a multi-file patch would require a follow-up that
1002
- * regenerates the patch content via `git format-patch -- <files>`. No
1003
- * current failing test exercises this, and leaving the patch intact
1004
- * preserves today's behaviour. TODO(FER-9805): revisit if a future
1005
- * adversarial test demands per-file stripping.
1006
- */
1007
- async collapseNetZeroFiles(patches) {
1008
- if (patches.length === 0) return patches;
1009
- const stateByFile = /* @__PURE__ */ new Map();
1010
- for (const patch of patches) {
1011
- for (const header of parsePatchFileHeaders(patch.patch_content)) {
1012
- if (!header.isCreate && !header.isDelete) continue;
1013
- const prior = stateByFile.get(header.path) ?? { created: false, deleted: false };
1014
- if (header.isCreate) prior.created = true;
1015
- if (header.isDelete) prior.deleted = true;
1016
- stateByFile.set(header.path, prior);
1017
- }
1018
- }
1019
- let anyCandidate = false;
1020
- for (const state of stateByFile.values()) {
1021
- if (state.created && state.deleted) {
1022
- anyCandidate = true;
1023
- break;
1024
- }
1025
- }
1026
- if (!anyCandidate) return patches;
1027
- const headFiles = await this.listHeadFiles();
1028
- const transient = /* @__PURE__ */ new Set();
1029
- for (const [file, state] of stateByFile) {
1030
- if (state.created && state.deleted && !headFiles.has(file)) {
1031
- transient.add(file);
1032
- }
1033
- }
1034
- if (transient.size === 0) return patches;
1035
- return patches.filter((patch) => !patch.files.every((f) => transient.has(f)));
1036
- }
1037
- /**
1038
- * List every tracked path at HEAD (one `git ls-tree -r` invocation).
1039
- * Returns an empty Set if HEAD is unborn or the invocation fails — the
1040
- * caller then treats every candidate as "not in HEAD" and may collapse
1041
- * more aggressively. On typical SDK repos this is a single sub-10ms call.
1087
+ * Resolve the GenerationRecord for a patch's base_generation.
1088
+ * Falls back to constructing an ad-hoc record from the commit's tree
1089
+ * when base_generation isn't a tracked generation (commit-scan patches).
1042
1090
  */
1043
- async listHeadFiles() {
1091
+ async resolveBaseGeneration(baseGeneration) {
1092
+ const gen = this.lockManager.getGeneration(baseGeneration);
1093
+ if (gen) return gen;
1044
1094
  try {
1045
- const out = await this.git.exec(["ls-tree", "-r", "--name-only", "HEAD"]);
1046
- return new Set(out.split("\n").map((l) => l.trim()).filter(Boolean));
1095
+ const treeHash = await this.git.getTreeHash(baseGeneration);
1096
+ return {
1097
+ commit_sha: baseGeneration,
1098
+ tree_hash: treeHash,
1099
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1100
+ cli_version: "unknown",
1101
+ generator_versions: {}
1102
+ };
1047
1103
  } catch {
1048
- return /* @__PURE__ */ new Set();
1104
+ return void 0;
1049
1105
  }
1050
1106
  }
1051
- /**
1052
- * Create a single composite patch for a merge commit by diffing the merge
1053
- * commit against its first parent. This captures the net change of the
1054
- * entire merged branch as one patch, eliminating accumulator chaining and
1055
- * zero-net-change ghost conflicts.
1056
- */
1057
- async createMergeCompositePatch(input) {
1058
- const { mergeCommit, firstParent, lastGen, lock } = input;
1059
- const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
1060
- const filesOutput = await this.git.exec([
1061
- "diff",
1062
- "--name-only",
1063
- firstParent,
1064
- mergeCommit.sha
1065
- ]);
1066
- const allMergeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
1067
- const sensitiveMergeFiles = allMergeFiles.filter((f) => isSensitiveFile(f));
1068
- const files = allMergeFiles.filter((f) => !isSensitiveFile(f));
1069
- if (sensitiveMergeFiles.length > 0) {
1070
- this.warnings.push(
1071
- `Sensitive file(s) excluded from merge patch for commit ${mergeCommit.sha.slice(0, 7)}: ${sensitiveMergeFiles.join(", ")}. These files will not be tracked by replay.`
1072
- );
1073
- }
1074
- if (files.length === 0) return null;
1075
- const diff = await this.git.exec([
1076
- "diff",
1077
- firstParent,
1078
- mergeCommit.sha,
1079
- "--",
1080
- ...files
1081
- ]);
1082
- if (!diff.trim()) return null;
1083
- const contentHash = this.computeContentHash(diff);
1084
- if (lock.patches.some((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
1085
- return null;
1086
- }
1087
- return {
1088
- id: `patch-merge-${mergeCommit.sha.slice(0, 8)}`,
1089
- content_hash: contentHash,
1090
- original_commit: mergeCommit.sha,
1091
- original_message: mergeCommit.message,
1092
- original_author: `${mergeCommit.authorName} <${mergeCommit.authorEmail}>`,
1093
- base_generation: lastGen.commit_sha,
1094
- files,
1095
- patch_content: diff,
1096
- ...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
1097
- };
1107
+ /** Reset inter-patch accumulator for a new cycle. */
1108
+ resetAccumulator() {
1109
+ this.fileTheirsAccumulator.clear();
1098
1110
  }
1099
1111
  /**
1100
- * Detect patches via tree diff for non-linear history. Returns a composite patch.
1101
- * Revert reconciliation is skipped here because tree-diff produces a single composite
1102
- * patch from the aggregate diff individual revert commits are not distinguishable.
1112
+ * @internal Test-only.
1113
+ * Returns a defensive copy of the inter-patch accumulator. Used by the
1114
+ * adversarial test suite (FER-9791) to assert invariant I2: after every
1115
+ * applied patch, the accumulator has an entry for each file the patch
1116
+ * touched, and the entry's content matches on-disk.
1103
1117
  */
1104
- async detectPatchesViaTreeDiff(lastGen, commitKnownMissing) {
1105
- const diffBase = await this.resolveDiffBase(lastGen, commitKnownMissing);
1106
- if (!diffBase) {
1107
- return this.detectPatchesViaCommitScan();
1108
- }
1109
- const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
1110
- const allTreeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
1111
- const sensitiveTreeFiles = allTreeFiles.filter((f) => isSensitiveFile(f));
1112
- const files = allTreeFiles.filter((f) => !isSensitiveFile(f));
1113
- if (sensitiveTreeFiles.length > 0) {
1114
- this.warnings.push(
1115
- `Sensitive file(s) excluded from composite patch: ${sensitiveTreeFiles.join(", ")}. These files will not be tracked by replay.`
1116
- );
1117
- }
1118
- if (files.length === 0) return { patches: [], revertedPatchIds: [] };
1119
- const diff = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
1120
- if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
1121
- const contentHash = this.computeContentHash(diff);
1122
- const lock = this.lockManager.read();
1123
- if (lock.patches.some((p) => p.content_hash === contentHash) || (lock.forgotten_hashes ?? []).includes(contentHash)) {
1124
- return { patches: [], revertedPatchIds: [] };
1118
+ getAccumulatorSnapshot() {
1119
+ const snapshot = /* @__PURE__ */ new Map();
1120
+ for (const [path, entry] of this.fileTheirsAccumulator) {
1121
+ snapshot.set(path, { content: entry.content, baseGeneration: entry.baseGeneration });
1125
1122
  }
1126
- const headSha = (await this.git.exec(["rev-parse", "HEAD"])).trim();
1127
- const compositePatch = {
1128
- id: `patch-composite-${headSha.slice(0, 8)}`,
1129
- content_hash: contentHash,
1130
- original_commit: headSha,
1131
- original_message: "Customer customizations (composite)",
1132
- original_author: "composite",
1133
- // Use diffBase when commit is unreachable — the applicator needs a reachable
1134
- // reference to find base file content. diffBase may be the tree_hash.
1135
- base_generation: commitKnownMissing ? diffBase : lastGen.commit_sha,
1136
- files,
1137
- patch_content: diff,
1138
- ...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
1139
- };
1140
- return { patches: [compositePatch], revertedPatchIds: [] };
1123
+ return snapshot;
1141
1124
  }
1142
1125
  /**
1143
- * Last-resort detection when both generation commit and tree are unreachable.
1144
- * Scans all commits from HEAD, filters against known lockfile patches, and
1145
- * skips creation-only commits (squashed history after force push).
1126
+ * Apply all patches, returning results for each.
1127
+ * Skips patches that match exclude patterns in replay.yml
1146
1128
  *
1147
- * Detected patches use the commit's parent as base_generation so the applicator
1148
- * can find base file content from the parent's tree (which IS reachable).
1129
+ * `applyMode` controls the post-conflict marker strategy:
1130
+ * - `"replay"` (default): strip conflict markers from disk between
1131
+ * iterations whenever a later patch touches the same file. Lets the
1132
+ * follow-up patch's `git apply --3way` see clean OURS content,
1133
+ * which is what the silent-loss fix (PR #73) relies on. The replay
1134
+ * pipeline calls `revertConflictingFiles` after the loop to clean
1135
+ * any markers that survive.
1136
+ * - `"resolve"`: keep markers on disk. The resolve command needs the
1137
+ * customer to see them. Slow-path 3-way merge naturally preserves
1138
+ * A's markers and B's clean writes when their regions don't overlap;
1139
+ * when they do overlap, the customer gets nested markers and
1140
+ * resolves manually. Either way they aren't silently dropped.
1149
1141
  */
1150
- async detectPatchesViaCommitScan() {
1151
- const lock = this.lockManager.read();
1152
- const log = await this.git.exec([
1153
- "log",
1154
- "--max-count=200",
1155
- "--format=%H%x00%an%x00%ae%x00%s",
1156
- "HEAD",
1157
- "--",
1158
- this.sdkOutputDir
1159
- ]);
1160
- if (!log.trim()) {
1161
- return { patches: [], revertedPatchIds: [] };
1162
- }
1163
- const commits = this.parseGitLog(log);
1164
- const newPatches = [];
1165
- const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
1166
- const existingHashes = new Set(lock.patches.map((p) => p.content_hash));
1167
- const existingCommits = new Set(lock.patches.map((p) => p.original_commit));
1168
- for (const commit of commits) {
1169
- if (isGenerationCommit(commit)) {
1170
- continue;
1171
- }
1172
- const parents = await this.git.getCommitParents(commit.sha);
1173
- if (parents.length > 1) {
1174
- continue;
1175
- }
1176
- if (existingCommits.has(commit.sha)) {
1177
- continue;
1178
- }
1179
- let patchContent;
1180
- try {
1181
- patchContent = await this.git.formatPatch(commit.sha);
1182
- } catch {
1183
- continue;
1184
- }
1185
- let contentHash = this.computeContentHash(patchContent);
1186
- if (existingHashes.has(contentHash) || forgottenHashes.has(contentHash)) {
1187
- continue;
1142
+ async applyPatches(patches, opts) {
1143
+ const applyMode = opts?.applyMode ?? "replay";
1144
+ this.currentApplyMode = applyMode;
1145
+ this.resetAccumulator();
1146
+ const fileFreq = /* @__PURE__ */ new Map();
1147
+ for (const p of patches) {
1148
+ for (const f of p.files) {
1149
+ fileFreq.set(f, (fileFreq.get(f) ?? 0) + 1);
1188
1150
  }
1189
- if (this.isCreationOnlyPatch(patchContent)) {
1151
+ }
1152
+ this.sharedFiles = new Set(
1153
+ Array.from(fileFreq.entries()).filter(([, n]) => n > 1).map(([f]) => f)
1154
+ );
1155
+ const results = [];
1156
+ for (let i = 0; i < patches.length; i++) {
1157
+ const patch = patches[i];
1158
+ if (this.isExcluded(patch)) {
1159
+ results.push({
1160
+ patch,
1161
+ status: "skipped",
1162
+ method: "git-am"
1163
+ });
1190
1164
  continue;
1191
1165
  }
1192
- const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
1193
- const allScanFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f));
1194
- const sensitiveScanFiles = allScanFiles.filter((f) => isSensitiveFile(f));
1195
- const files = allScanFiles.filter((f) => !isSensitiveFile(f));
1196
- if (sensitiveScanFiles.length > 0) {
1197
- this.warnings.push(
1198
- `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveScanFiles.join(", ")}. These files will not be tracked by replay.`
1199
- );
1200
- if (files.length > 0) {
1201
- if (parents.length === 0) {
1202
- continue;
1166
+ const result = await this.applyPatchWithFallback(patch);
1167
+ results.push(result);
1168
+ if (applyMode !== "resolve" && result.status === "conflict" && result.fileResults) {
1169
+ const laterFiles = /* @__PURE__ */ new Set();
1170
+ for (let j = i + 1; j < patches.length; j++) {
1171
+ for (const f of patches[j].files) {
1172
+ laterFiles.add(f);
1203
1173
  }
1204
- try {
1205
- patchContent = await this.git.exec(["diff", parents[0], commit.sha, "--", ...files]);
1206
- } catch {
1207
- continue;
1174
+ }
1175
+ const resolvedToOriginal = /* @__PURE__ */ new Map();
1176
+ if (result.resolvedFiles) {
1177
+ for (const [orig, resolved] of Object.entries(result.resolvedFiles)) {
1178
+ resolvedToOriginal.set(resolved, orig);
1179
+ }
1180
+ }
1181
+ for (const fileResult of result.fileResults) {
1182
+ if (fileResult.status !== "conflict") continue;
1183
+ const originalPath = resolvedToOriginal.get(fileResult.file) ?? fileResult.file;
1184
+ if (laterFiles.has(fileResult.file) || laterFiles.has(originalPath)) {
1185
+ const filePath = (0, import_node_path2.join)(this.outputDir, fileResult.file);
1186
+ try {
1187
+ const content = await (0, import_promises.readFile)(filePath, "utf-8");
1188
+ const stripped = stripConflictMarkers(content);
1189
+ await (0, import_promises.writeFile)(filePath, stripped);
1190
+ } catch {
1191
+ }
1208
1192
  }
1209
- if (!patchContent.trim()) continue;
1210
- contentHash = this.computeContentHash(patchContent);
1211
1193
  }
1212
1194
  }
1213
- if (files.length === 0) {
1214
- continue;
1215
- }
1216
- if (parents.length === 0) {
1217
- continue;
1218
- }
1219
- const parentSha = parents[0];
1220
- newPatches.push({
1221
- id: `patch-${commit.sha.slice(0, 8)}`,
1222
- content_hash: contentHash,
1223
- original_commit: commit.sha,
1224
- original_message: commit.message,
1225
- original_author: `${commit.authorName} <${commit.authorEmail}>`,
1226
- base_generation: parentSha,
1227
- files,
1228
- patch_content: patchContent
1229
- });
1230
1195
  }
1231
- newPatches.reverse();
1232
- return { patches: newPatches, revertedPatchIds: [] };
1196
+ return results;
1233
1197
  }
1234
1198
  /**
1235
- * Check if a format-patch consists entirely of new-file creations.
1236
- * Used to identify squashed commits after force push, which create all files
1237
- * from scratch (--- /dev/null) rather than modifying existing files.
1199
+ * Populate accumulator after git apply succeeds, AND collect per-file
1200
+ * results including any "dropped context lines" lines that were
1201
+ * present in BASE and THEIRS (unchanged context) but absent from the
1202
+ * final on-disk state because OURS deleted them. This is the fast-path
1203
+ * counterpart to ThreeWayMerge.computeDroppedContextLines used by the
1204
+ * 3-way slow path; both must surface the same warning.
1238
1205
  */
1239
- isCreationOnlyPatch(patchContent) {
1240
- const diffOldHeaders = patchContent.split("\n").filter((l) => l.startsWith("--- "));
1241
- if (diffOldHeaders.length === 0) {
1242
- return false;
1206
+ async populateAccumulatorForPatch(patch, baseGen, currentTreeHash) {
1207
+ const fileResults = [];
1208
+ if (!baseGen) return fileResults;
1209
+ const tempDir = await (0, import_promises.mkdtemp)((0, import_node_path2.join)((0, import_node_os.tmpdir)(), "replay-acc-"));
1210
+ const { GitClient: GitClient2 } = await Promise.resolve().then(() => (init_GitClient(), GitClient_exports));
1211
+ const tempGit = new GitClient2(tempDir);
1212
+ await tempGit.exec(["init"]);
1213
+ await tempGit.exec(["config", "user.email", "replay@fern.com"]);
1214
+ await tempGit.exec(["config", "user.name", "Fern Replay"]);
1215
+ try {
1216
+ for (const filePath of patch.files) {
1217
+ if (isBinaryFile(filePath)) continue;
1218
+ const resolvedPath = await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
1219
+ const base = await this.git.showFile(baseGen.tree_hash, filePath);
1220
+ const snapshotForFile = patch.theirs_snapshot?.[resolvedPath] ?? patch.theirs_snapshot?.[filePath];
1221
+ const fileIsShared = this.sharedFiles.has(resolvedPath) || this.sharedFiles.has(filePath);
1222
+ const theirs = !fileIsShared && snapshotForFile != null ? snapshotForFile : await this.applyPatchToContent(base, patch.patch_content, filePath, tempGit, tempDir);
1223
+ const finalOnDisk = await (0, import_promises.readFile)((0, import_node_path2.join)(this.outputDir, resolvedPath), "utf-8").catch(() => null);
1224
+ const effectiveTheirs = theirs ?? finalOnDisk;
1225
+ if (effectiveTheirs != null) {
1226
+ this.fileTheirsAccumulator.set(resolvedPath, {
1227
+ content: effectiveTheirs,
1228
+ baseGeneration: patch.base_generation
1229
+ });
1230
+ }
1231
+ if (base != null && effectiveTheirs != null && finalOnDisk != null) {
1232
+ const dropped = computeDroppedContextLinesForFile(base, effectiveTheirs, finalOnDisk);
1233
+ if (dropped.length > 0) {
1234
+ fileResults.push({
1235
+ file: resolvedPath,
1236
+ status: "merged",
1237
+ droppedContextLines: dropped
1238
+ });
1239
+ }
1240
+ }
1241
+ }
1242
+ } finally {
1243
+ await (0, import_promises.rm)(tempDir, { recursive: true }).catch(() => {
1244
+ });
1243
1245
  }
1244
- return diffOldHeaders.every((l) => l === "--- /dev/null");
1246
+ return fileResults;
1245
1247
  }
1246
- /**
1247
- * Resolve the best available diff base for a generation record.
1248
- * Prefers commit_sha, falls back to tree_hash for unreachable commits.
1249
- * When commitKnownMissing is true, skips the redundant commitExists check.
1250
- */
1251
- async resolveDiffBase(gen, commitKnownMissing) {
1252
- if (!commitKnownMissing && await this.git.commitExists(gen.commit_sha)) {
1253
- return gen.commit_sha;
1248
+ async applyPatchWithFallback(patch) {
1249
+ const baseGen = await this.resolveBaseGeneration(patch.base_generation);
1250
+ const lock = this.lockManager.read();
1251
+ const currentGen = lock.generations.find((g) => g.commit_sha === lock.current_generation);
1252
+ const currentTreeHash = currentGen?.tree_hash ?? baseGen?.tree_hash ?? "";
1253
+ const needsAccumulation = await Promise.all(
1254
+ patch.files.map(async (f) => {
1255
+ if (!baseGen) return false;
1256
+ const resolved = await this.resolveFilePath(f, baseGen.tree_hash, currentTreeHash);
1257
+ return this.fileTheirsAccumulator.has(resolved);
1258
+ })
1259
+ ).then((results) => results.some(Boolean));
1260
+ const resolvedFiles = {};
1261
+ for (const filePath of patch.files) {
1262
+ const resolvedPath = baseGen ? await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash) : filePath;
1263
+ if (resolvedPath !== filePath) {
1264
+ resolvedFiles[filePath] = resolvedPath;
1265
+ }
1254
1266
  }
1255
- if (await this.git.treeExists(gen.tree_hash)) {
1256
- return gen.tree_hash;
1267
+ const patchIsRenameAware = /^rename from /m.test(patch.patch_content);
1268
+ const hasGeneratorRename = Object.keys(resolvedFiles).length > 0 && !patchIsRenameAware;
1269
+ if (!needsAccumulation && !hasGeneratorRename) {
1270
+ const snapshots = /* @__PURE__ */ new Map();
1271
+ for (const filePath of patch.files) {
1272
+ const fullPath = (0, import_node_path2.join)(this.outputDir, filePath);
1273
+ snapshots.set(filePath, await (0, import_promises.readFile)(fullPath, "utf-8").catch(() => null));
1274
+ }
1275
+ try {
1276
+ await this.git.execWithInput(["apply", "--3way"], patch.patch_content);
1277
+ const fastPathFileResults = await this.populateAccumulatorForPatch(patch, baseGen, currentTreeHash);
1278
+ return {
1279
+ patch,
1280
+ status: "applied",
1281
+ method: "git-am",
1282
+ ...fastPathFileResults.length > 0 && { fileResults: fastPathFileResults },
1283
+ ...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
1284
+ };
1285
+ } catch {
1286
+ for (const [filePath, content] of snapshots) {
1287
+ const fullPath = (0, import_node_path2.join)(this.outputDir, filePath);
1288
+ if (content != null) {
1289
+ await (0, import_promises.writeFile)(fullPath, content);
1290
+ } else {
1291
+ await (0, import_promises.unlink)(fullPath).catch(() => {
1292
+ });
1293
+ }
1294
+ }
1295
+ }
1257
1296
  }
1258
- return null;
1259
- }
1260
- parseGitLog(log) {
1261
- return log.trim().split("\n").map((line) => {
1262
- const [sha, authorName, authorEmail, message] = line.split("\0");
1263
- return { sha, authorName, authorEmail, message };
1264
- });
1265
- }
1266
- getLastGeneration(lock) {
1267
- return lock.generations.find((g) => g.commit_sha === lock.current_generation);
1297
+ return this.applyWithThreeWayMerge(patch);
1268
1298
  }
1269
- };
1270
-
1271
- // src/ThreeWayMerge.ts
1272
- var import_node_diff3 = require("node-diff3");
1273
- function threeWayMerge(base, ours, theirs) {
1274
- const baseLines = base.split("\n");
1275
- const oursLines = ours.split("\n");
1276
- const theirsLines = theirs.split("\n");
1277
- const regions = (0, import_node_diff3.diff3Merge)(oursLines, baseLines, theirsLines);
1278
- const outputLines = [];
1279
- const conflicts = [];
1280
- let currentLine = 1;
1281
- for (const region of regions) {
1282
- if (region.ok) {
1283
- outputLines.push(...region.ok);
1284
- currentLine += region.ok.length;
1285
- } else if (region.conflict) {
1286
- const resolved = tryResolveConflict(
1287
- region.conflict.a,
1288
- // ours (generator)
1289
- region.conflict.o,
1290
- // base
1291
- region.conflict.b
1292
- // theirs (user)
1293
- );
1294
- if (resolved !== null) {
1295
- outputLines.push(...resolved);
1296
- currentLine += resolved.length;
1297
- } else {
1298
- const startLine = currentLine;
1299
- outputLines.push("<<<<<<< Generated");
1300
- outputLines.push(...region.conflict.a);
1301
- outputLines.push("=======");
1302
- outputLines.push(...region.conflict.b);
1303
- outputLines.push(">>>>>>> Your customization");
1304
- const conflictLines = region.conflict.a.length + region.conflict.b.length + 3;
1305
- conflicts.push({
1306
- startLine,
1307
- endLine: startLine + conflictLines - 1,
1308
- ours: region.conflict.a,
1309
- theirs: region.conflict.b
1310
- });
1311
- currentLine += conflictLines;
1299
+ async applyWithThreeWayMerge(patch) {
1300
+ const fileResults = [];
1301
+ const resolvedFiles = {};
1302
+ const tempDir = await (0, import_promises.mkdtemp)((0, import_node_path2.join)((0, import_node_os.tmpdir)(), "replay-"));
1303
+ const { GitClient: GitClient2 } = await Promise.resolve().then(() => (init_GitClient(), GitClient_exports));
1304
+ const tempGit = new GitClient2(tempDir);
1305
+ await tempGit.exec(["init"]);
1306
+ await tempGit.exec(["config", "user.email", "replay@fern.com"]);
1307
+ await tempGit.exec(["config", "user.name", "Fern Replay"]);
1308
+ try {
1309
+ for (const filePath of patch.files) {
1310
+ if (isBinaryFile(filePath)) {
1311
+ fileResults.push({
1312
+ file: filePath,
1313
+ status: "skipped",
1314
+ reason: "binary-file"
1315
+ });
1316
+ continue;
1317
+ }
1318
+ const result = await this.mergeFile(patch, filePath, tempGit, tempDir);
1319
+ if (result.file !== filePath) {
1320
+ resolvedFiles[filePath] = result.file;
1321
+ }
1322
+ fileResults.push(result);
1312
1323
  }
1324
+ } finally {
1325
+ await (0, import_promises.rm)(tempDir, { recursive: true }).catch(() => {
1326
+ });
1313
1327
  }
1328
+ const conflictFiles = fileResults.filter((r) => r.status === "conflict");
1329
+ const hasConflicts = conflictFiles.length > 0;
1330
+ const conflictReason = hasConflicts ? conflictFiles.some((f) => f.conflictReason === "base-generation-mismatch") ? "base-generation-mismatch" : conflictFiles[0]?.conflictReason : void 0;
1331
+ return {
1332
+ patch,
1333
+ status: hasConflicts ? "conflict" : "applied",
1334
+ method: "3way-merge",
1335
+ fileResults,
1336
+ conflictReason,
1337
+ ...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
1338
+ };
1314
1339
  }
1315
- return {
1316
- content: outputLines.join("\n"),
1317
- hasConflicts: conflicts.length > 0,
1318
- conflicts
1319
- };
1320
- }
1321
- function tryResolveConflict(oursLines, baseLines, theirsLines) {
1322
- if (baseLines.length === 0) {
1323
- return null;
1324
- }
1325
- const oursPatches = (0, import_node_diff3.diffPatch)(baseLines, oursLines);
1326
- const theirsPatches = (0, import_node_diff3.diffPatch)(baseLines, theirsLines);
1327
- if (oursPatches.length === 0) return theirsLines;
1328
- if (theirsPatches.length === 0) return oursLines;
1329
- if (patchesOverlap(oursPatches, theirsPatches)) {
1330
- return null;
1331
- }
1332
- return applyBothPatches(baseLines, oursPatches, theirsPatches);
1333
- }
1334
- function patchesOverlap(oursPatches, theirsPatches) {
1335
- for (const op of oursPatches) {
1336
- const oStart = op.buffer1.offset;
1337
- const oEnd = oStart + op.buffer1.length;
1338
- for (const tp of theirsPatches) {
1339
- const tStart = tp.buffer1.offset;
1340
- const tEnd = tStart + tp.buffer1.length;
1341
- if (op.buffer1.length === 0 && tp.buffer1.length === 0 && oStart === tStart) {
1342
- return true;
1340
+ async mergeFile(patch, filePath, tempGit, tempDir) {
1341
+ try {
1342
+ const baseGen = await this.resolveBaseGeneration(patch.base_generation);
1343
+ if (!baseGen) {
1344
+ return { file: filePath, status: "skipped", reason: "base-generation-not-found" };
1343
1345
  }
1344
- if (tp.buffer1.length === 0 && tStart === oEnd) {
1345
- return true;
1346
+ const lock = this.lockManager.read();
1347
+ const currentGen = lock.generations.find((g) => g.commit_sha === lock.current_generation);
1348
+ const currentTreeHash = currentGen?.tree_hash ?? baseGen.tree_hash;
1349
+ const resolvedPath = await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
1350
+ const metadata = {
1351
+ patchId: patch.id,
1352
+ patchMessage: patch.original_message,
1353
+ baseGeneration: patch.base_generation,
1354
+ currentGeneration: lock.current_generation
1355
+ };
1356
+ let base = await this.git.showFile(baseGen.tree_hash, filePath);
1357
+ let renameSourcePath;
1358
+ if (!base) {
1359
+ const renameSource = this.extractRenameSource(patch.patch_content, filePath);
1360
+ if (renameSource) {
1361
+ base = await this.git.showFile(baseGen.tree_hash, renameSource);
1362
+ renameSourcePath = renameSource;
1363
+ }
1346
1364
  }
1347
- if (op.buffer1.length === 0 && oStart === tEnd) {
1348
- return true;
1365
+ const oursPath = (0, import_node_path2.join)(this.outputDir, resolvedPath);
1366
+ const ours = await (0, import_promises.readFile)(oursPath, "utf-8").catch(() => null);
1367
+ let ghostReconstructed = false;
1368
+ let theirs = null;
1369
+ if (!base && ours && !renameSourcePath) {
1370
+ const treeReachable = await this.isTreeReachable(baseGen.tree_hash);
1371
+ if (!treeReachable) {
1372
+ const fileDiff = this.extractFileDiff(patch.patch_content, filePath);
1373
+ if (fileDiff) {
1374
+ const { reconstructFromGhostPatch: reconstructFromGhostPatch2 } = await Promise.resolve().then(() => (init_HybridReconstruction(), HybridReconstruction_exports));
1375
+ const result = reconstructFromGhostPatch2(fileDiff, ours);
1376
+ if (result) {
1377
+ base = result.base;
1378
+ theirs = result.theirs;
1379
+ ghostReconstructed = true;
1380
+ }
1381
+ }
1382
+ }
1349
1383
  }
1350
- if (oStart < tEnd && tStart < oEnd) {
1351
- return true;
1384
+ const snapshotForFile = patch.theirs_snapshot?.[resolvedPath] ?? patch.theirs_snapshot?.[filePath];
1385
+ const fileIsShared = this.sharedFiles.has(resolvedPath) || this.sharedFiles.has(filePath);
1386
+ const useSnapshotAsPrimary = !fileIsShared && snapshotForFile != null;
1387
+ if (!ghostReconstructed) {
1388
+ if (useSnapshotAsPrimary) {
1389
+ theirs = snapshotForFile;
1390
+ } else {
1391
+ theirs = await this.applyPatchToContent(
1392
+ base,
1393
+ patch.patch_content,
1394
+ filePath,
1395
+ tempGit,
1396
+ tempDir,
1397
+ renameSourcePath
1398
+ );
1399
+ const reconstructionHasMarkers = theirs != null && (theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization"));
1400
+ const baseHadMarkers = base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization"));
1401
+ if (reconstructionHasMarkers && !baseHadMarkers && snapshotForFile != null) {
1402
+ theirs = snapshotForFile;
1403
+ }
1404
+ }
1352
1405
  }
1353
- }
1354
- }
1355
- return false;
1356
- }
1357
- function applyBothPatches(baseLines, oursPatches, theirsPatches) {
1358
- const allPatches = [
1359
- ...oursPatches.map((p) => ({
1360
- offset: p.buffer1.offset,
1361
- length: p.buffer1.length,
1362
- replacement: p.buffer2.chunk
1363
- })),
1364
- ...theirsPatches.map((p) => ({
1365
- offset: p.buffer1.offset,
1366
- length: p.buffer1.length,
1367
- replacement: p.buffer2.chunk
1368
- }))
1369
- ];
1370
- allPatches.sort((a, b) => b.offset - a.offset);
1371
- const result = [...baseLines];
1372
- for (const p of allPatches) {
1373
- result.splice(p.offset, p.length, ...p.replacement);
1374
- }
1375
- return result;
1376
- }
1377
-
1378
- // src/ReplayApplicator.ts
1379
- var import_promises = require("fs/promises");
1380
- var import_node_os = require("os");
1381
- var import_node_path2 = require("path");
1382
- var import_minimatch = require("minimatch");
1383
-
1384
- // src/conflict-utils.ts
1385
- var CONFLICT_OPENER = "<<<<<<< Generated";
1386
- var CONFLICT_SEPARATOR = "=======";
1387
- var CONFLICT_CLOSER = ">>>>>>> Your customization";
1388
- function trimCR(line) {
1389
- return line.endsWith("\r") ? line.slice(0, -1) : line;
1390
- }
1391
- function findConflictRanges(lines) {
1392
- const ranges = [];
1393
- let i = 0;
1394
- while (i < lines.length) {
1395
- if (trimCR(lines[i]) === CONFLICT_OPENER) {
1396
- let separatorIdx = -1;
1397
- let j = i + 1;
1398
- let found = false;
1399
- while (j < lines.length) {
1400
- const trimmed = trimCR(lines[j]);
1401
- if (trimmed === CONFLICT_OPENER) {
1402
- break;
1406
+ const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
1407
+ if (theirs) {
1408
+ const theirsHasMarkers = theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization");
1409
+ const baseHasMarkers = base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization"));
1410
+ if (theirsHasMarkers && !baseHasMarkers) {
1411
+ if (accumulatorEntry) {
1412
+ theirs = null;
1413
+ } else {
1414
+ return {
1415
+ file: resolvedPath,
1416
+ status: "skipped",
1417
+ reason: "stale-conflict-markers"
1418
+ };
1419
+ }
1403
1420
  }
1404
- if (separatorIdx === -1 && trimmed === CONFLICT_SEPARATOR) {
1405
- separatorIdx = j;
1406
- } else if (separatorIdx !== -1 && trimmed === CONFLICT_CLOSER) {
1407
- ranges.push({ start: i, separator: separatorIdx, end: j });
1408
- i = j;
1409
- found = true;
1410
- break;
1421
+ }
1422
+ let useAccumulatorAsMergeBase = false;
1423
+ if (accumulatorEntry && (theirs == null || base == null)) {
1424
+ theirs = await this.applyPatchToContent(
1425
+ accumulatorEntry.content,
1426
+ patch.patch_content,
1427
+ filePath,
1428
+ tempGit,
1429
+ tempDir
1430
+ );
1431
+ if (theirs != null) {
1432
+ useAccumulatorAsMergeBase = true;
1433
+ } else if (await this.isPatchAlreadyApplied(
1434
+ patch.patch_content,
1435
+ filePath,
1436
+ accumulatorEntry.content,
1437
+ tempGit,
1438
+ tempDir
1439
+ )) {
1440
+ theirs = accumulatorEntry.content;
1441
+ useAccumulatorAsMergeBase = true;
1411
1442
  }
1412
- j++;
1413
1443
  }
1414
- if (!found) {
1415
- i++;
1416
- continue;
1444
+ if (theirs) {
1445
+ const theirsHasMarkers = theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization");
1446
+ const accBaseHasMarkers = accumulatorEntry != null && (accumulatorEntry.content.includes("<<<<<<< Generated") || accumulatorEntry.content.includes(">>>>>>> Your customization"));
1447
+ if (theirsHasMarkers && !accBaseHasMarkers && !(base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization")))) {
1448
+ return {
1449
+ file: resolvedPath,
1450
+ status: "skipped",
1451
+ reason: "stale-conflict-markers"
1452
+ };
1453
+ }
1454
+ }
1455
+ let effective_theirs = theirs;
1456
+ let baseMismatchSkipped = false;
1457
+ if (theirs != null && base != null && !useAccumulatorAsMergeBase) {
1458
+ if (accumulatorEntry && accumulatorEntry.baseGeneration === patch.base_generation) {
1459
+ try {
1460
+ const preMerged = threeWayMerge(base, accumulatorEntry.content, theirs);
1461
+ if (!preMerged.hasConflicts) {
1462
+ effective_theirs = preMerged.content;
1463
+ } else {
1464
+ effective_theirs = theirs;
1465
+ }
1466
+ } catch {
1467
+ effective_theirs = theirs;
1468
+ }
1469
+ } else if (accumulatorEntry) {
1470
+ baseMismatchSkipped = true;
1471
+ }
1472
+ }
1473
+ if (base == null && ours == null && effective_theirs != null) {
1474
+ this.fileTheirsAccumulator.set(resolvedPath, {
1475
+ content: effective_theirs,
1476
+ baseGeneration: patch.base_generation
1477
+ });
1478
+ const outDir2 = (0, import_node_path2.dirname)(oursPath);
1479
+ await (0, import_promises.mkdir)(outDir2, { recursive: true });
1480
+ await (0, import_promises.writeFile)(oursPath, effective_theirs);
1481
+ return { file: resolvedPath, status: "merged", reason: "new-file" };
1482
+ }
1483
+ if (base == null && ours != null && effective_theirs != null && !useAccumulatorAsMergeBase) {
1484
+ const merged2 = threeWayMerge("", ours, effective_theirs);
1485
+ const outDir2 = (0, import_node_path2.dirname)(oursPath);
1486
+ await (0, import_promises.mkdir)(outDir2, { recursive: true });
1487
+ await (0, import_promises.writeFile)(oursPath, merged2.content);
1488
+ if (merged2.hasConflicts) {
1489
+ return {
1490
+ file: resolvedPath,
1491
+ status: "conflict",
1492
+ conflicts: merged2.conflicts,
1493
+ conflictReason: "new-file-both",
1494
+ conflictMetadata: metadata
1495
+ };
1496
+ }
1497
+ this.fileTheirsAccumulator.set(resolvedPath, {
1498
+ content: merged2.content,
1499
+ baseGeneration: patch.base_generation
1500
+ });
1501
+ return { file: resolvedPath, status: "merged" };
1417
1502
  }
1418
- }
1419
- i++;
1420
- }
1421
- return ranges;
1422
- }
1423
- function stripConflictMarkers(content) {
1424
- const lines = content.split(/\r?\n/);
1425
- const ranges = findConflictRanges(lines);
1426
- if (ranges.length === 0) {
1427
- return content;
1428
- }
1429
- const result = [];
1430
- let rangeIdx = 0;
1431
- for (let i = 0; i < lines.length; i++) {
1432
- if (rangeIdx < ranges.length) {
1433
- const range = ranges[rangeIdx];
1434
- if (i === range.start) {
1435
- continue;
1503
+ if (effective_theirs == null) {
1504
+ return {
1505
+ file: resolvedPath,
1506
+ status: "skipped",
1507
+ reason: "missing-content"
1508
+ };
1436
1509
  }
1437
- if (i > range.start && i < range.separator) {
1438
- result.push(lines[i]);
1439
- continue;
1510
+ if (base == null && !useAccumulatorAsMergeBase || ours == null) {
1511
+ return {
1512
+ file: resolvedPath,
1513
+ status: "skipped",
1514
+ reason: "missing-content"
1515
+ };
1440
1516
  }
1441
- if (i === range.separator) {
1442
- continue;
1517
+ const mergeBase = useAccumulatorAsMergeBase && accumulatorEntry ? accumulatorEntry.content : base;
1518
+ if (mergeBase == null) {
1519
+ return {
1520
+ file: resolvedPath,
1521
+ status: "skipped",
1522
+ reason: "missing-content"
1523
+ };
1443
1524
  }
1444
- if (i > range.separator && i < range.end) {
1445
- continue;
1525
+ const merged = threeWayMerge(mergeBase, ours, effective_theirs);
1526
+ const outDir = (0, import_node_path2.dirname)(oursPath);
1527
+ await (0, import_promises.mkdir)(outDir, { recursive: true });
1528
+ await (0, import_promises.writeFile)(oursPath, merged.content);
1529
+ const populateAccumulator = effective_theirs != null && (!merged.hasConflicts || this.currentApplyMode === "resolve");
1530
+ if (populateAccumulator) {
1531
+ this.fileTheirsAccumulator.set(resolvedPath, {
1532
+ content: effective_theirs,
1533
+ baseGeneration: patch.base_generation
1534
+ });
1446
1535
  }
1447
- if (i === range.end) {
1448
- rangeIdx++;
1449
- continue;
1536
+ if (merged.hasConflicts) {
1537
+ return {
1538
+ file: resolvedPath,
1539
+ status: "conflict",
1540
+ conflicts: merged.conflicts,
1541
+ conflictReason: baseMismatchSkipped ? "base-generation-mismatch" : "same-line-edit",
1542
+ conflictMetadata: metadata
1543
+ };
1450
1544
  }
1451
- }
1452
- result.push(lines[i]);
1453
- }
1454
- return result.join("\n");
1455
- }
1456
-
1457
- // src/ReplayApplicator.ts
1458
- var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
1459
- ".png",
1460
- ".jpg",
1461
- ".jpeg",
1462
- ".gif",
1463
- ".bmp",
1464
- ".ico",
1465
- ".webp",
1466
- ".svg",
1467
- ".pdf",
1468
- ".doc",
1469
- ".docx",
1470
- ".xls",
1471
- ".xlsx",
1472
- ".ppt",
1473
- ".pptx",
1474
- ".zip",
1475
- ".gz",
1476
- ".tar",
1477
- ".bz2",
1478
- ".7z",
1479
- ".rar",
1480
- ".jar",
1481
- ".war",
1482
- ".ear",
1483
- ".class",
1484
- ".exe",
1485
- ".dll",
1486
- ".so",
1487
- ".dylib",
1488
- ".o",
1489
- ".a",
1490
- ".woff",
1491
- ".woff2",
1492
- ".ttf",
1493
- ".eot",
1494
- ".otf",
1495
- ".mp3",
1496
- ".mp4",
1497
- ".avi",
1498
- ".mov",
1499
- ".wav",
1500
- ".flac",
1501
- ".sqlite",
1502
- ".db",
1503
- ".pyc",
1504
- ".pyo",
1505
- ".DS_Store"
1506
- ]);
1507
- var ReplayApplicator = class {
1508
- git;
1509
- lockManager;
1510
- outputDir;
1511
- renameCache = /* @__PURE__ */ new Map();
1512
- treeExistsCache = /* @__PURE__ */ new Map();
1513
- fileTheirsAccumulator = /* @__PURE__ */ new Map();
1514
- constructor(git, lockManager, outputDir) {
1515
- this.git = git;
1516
- this.lockManager = lockManager;
1517
- this.outputDir = outputDir;
1518
- }
1519
- /**
1520
- * Resolve the GenerationRecord for a patch's base_generation.
1521
- * Falls back to constructing an ad-hoc record from the commit's tree
1522
- * when base_generation isn't a tracked generation (commit-scan patches).
1523
- */
1524
- async resolveBaseGeneration(baseGeneration) {
1525
- const gen = this.lockManager.getGeneration(baseGeneration);
1526
- if (gen) return gen;
1527
- try {
1528
- const treeHash = await this.git.getTreeHash(baseGeneration);
1529
1545
  return {
1530
- commit_sha: baseGeneration,
1531
- tree_hash: treeHash,
1532
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1533
- cli_version: "unknown",
1534
- generator_versions: {}
1546
+ file: resolvedPath,
1547
+ status: "merged",
1548
+ ...merged.droppedContextLines && merged.droppedContextLines.length > 0 ? { droppedContextLines: merged.droppedContextLines } : {}
1549
+ };
1550
+ } catch (error) {
1551
+ return {
1552
+ file: filePath,
1553
+ status: "skipped",
1554
+ reason: `error: ${error instanceof Error ? error.message : String(error)}`
1535
1555
  };
1536
- } catch {
1537
- return void 0;
1538
1556
  }
1539
1557
  }
1540
- /** Reset inter-patch accumulator for a new cycle. */
1541
- resetAccumulator() {
1542
- this.fileTheirsAccumulator.clear();
1543
- }
1544
- /**
1545
- * @internal Test-only.
1546
- * Returns a defensive copy of the inter-patch accumulator. Used by the
1547
- * adversarial test suite (FER-9791) to assert invariant I2: after every
1548
- * applied patch, the accumulator has an entry for each file the patch
1549
- * touched, and the entry's content matches on-disk.
1550
- */
1551
- getAccumulatorSnapshot() {
1552
- const snapshot = /* @__PURE__ */ new Map();
1553
- for (const [path, entry] of this.fileTheirsAccumulator) {
1554
- snapshot.set(path, { content: entry.content, baseGeneration: entry.baseGeneration });
1558
+ async isTreeReachable(treeHash) {
1559
+ let result = this.treeExistsCache.get(treeHash);
1560
+ if (result === void 0) {
1561
+ result = await this.git.treeExists(treeHash);
1562
+ this.treeExistsCache.set(treeHash, result);
1555
1563
  }
1556
- return snapshot;
1564
+ return result;
1557
1565
  }
1558
- /**
1559
- * Apply all patches, returning results for each.
1560
- * Skips patches that match exclude patterns in replay.yml
1561
- */
1562
- async applyPatches(patches) {
1563
- this.resetAccumulator();
1564
- const results = [];
1565
- for (let i = 0; i < patches.length; i++) {
1566
- const patch = patches[i];
1567
- if (this.isExcluded(patch)) {
1568
- results.push({
1569
- patch,
1570
- status: "skipped",
1571
- method: "git-am"
1572
- });
1573
- continue;
1574
- }
1575
- const result = await this.applyPatchWithFallback(patch);
1576
- results.push(result);
1577
- if (result.status === "conflict" && result.fileResults) {
1578
- const laterFiles = /* @__PURE__ */ new Set();
1579
- for (let j = i + 1; j < patches.length; j++) {
1580
- for (const f of patches[j].files) {
1581
- laterFiles.add(f);
1582
- }
1583
- }
1584
- const resolvedToOriginal = /* @__PURE__ */ new Map();
1585
- if (result.resolvedFiles) {
1586
- for (const [orig, resolved] of Object.entries(result.resolvedFiles)) {
1587
- resolvedToOriginal.set(resolved, orig);
1566
+ isExcluded(patch) {
1567
+ const config = this.lockManager.getCustomizationsConfig();
1568
+ if (!config.exclude) return false;
1569
+ return patch.files.some((file) => config.exclude.some((pattern) => (0, import_minimatch.minimatch)(file, pattern)));
1570
+ }
1571
+ async resolveFilePath(filePath, baseTreeHash, currentTreeHash) {
1572
+ const config = this.lockManager.getCustomizationsConfig();
1573
+ if (config.moves) {
1574
+ for (const move of config.moves) {
1575
+ if ((0, import_minimatch.minimatch)(filePath, move.from) || filePath === move.from) {
1576
+ if (filePath === move.from) {
1577
+ return move.to;
1588
1578
  }
1589
- }
1590
- for (const fileResult of result.fileResults) {
1591
- if (fileResult.status !== "conflict") continue;
1592
- const originalPath = resolvedToOriginal.get(fileResult.file) ?? fileResult.file;
1593
- if (laterFiles.has(fileResult.file) || laterFiles.has(originalPath)) {
1594
- const filePath = (0, import_node_path2.join)(this.outputDir, fileResult.file);
1595
- try {
1596
- const content = await (0, import_promises.readFile)(filePath, "utf-8");
1597
- const stripped = stripConflictMarkers(content);
1598
- await (0, import_promises.writeFile)(filePath, stripped);
1599
- } catch {
1600
- }
1579
+ const fromBase = move.from.replace(/\*\*.*$/, "");
1580
+ const toBase = move.to.replace(/\*\*.*$/, "");
1581
+ if (filePath.startsWith(fromBase)) {
1582
+ return toBase + filePath.slice(fromBase.length);
1601
1583
  }
1602
1584
  }
1603
1585
  }
1604
1586
  }
1605
- return results;
1587
+ const cacheKey = `${baseTreeHash}:${currentTreeHash}`;
1588
+ let renames = this.renameCache.get(cacheKey);
1589
+ if (!renames) {
1590
+ renames = await this.git.detectRenames(baseTreeHash, currentTreeHash);
1591
+ this.renameCache.set(cacheKey, renames);
1592
+ }
1593
+ const gitRename = renames.find((r) => r.from === filePath);
1594
+ if (gitRename) {
1595
+ return gitRename.to;
1596
+ }
1597
+ return filePath;
1598
+ }
1599
+ async applyPatchToContent(base, patchContent, filePath, tempGit, tempDir, sourceFilePath) {
1600
+ if (!base) {
1601
+ return this.extractNewFileFromPatch(patchContent, filePath);
1602
+ }
1603
+ const fileDiff = this.extractFileDiff(patchContent, filePath);
1604
+ if (!fileDiff) return null;
1605
+ try {
1606
+ if (sourceFilePath) {
1607
+ const tempSourcePath = (0, import_node_path2.join)(tempDir, sourceFilePath);
1608
+ await (0, import_promises.mkdir)((0, import_node_path2.dirname)(tempSourcePath), { recursive: true });
1609
+ await (0, import_promises.writeFile)(tempSourcePath, base);
1610
+ await tempGit.exec(["add", sourceFilePath]);
1611
+ await tempGit.exec([
1612
+ "commit",
1613
+ "-m",
1614
+ `base for rename ${sourceFilePath} -> ${filePath}`,
1615
+ "--allow-empty"
1616
+ ]);
1617
+ await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
1618
+ const tempTargetPath = (0, import_node_path2.join)(tempDir, filePath);
1619
+ return await (0, import_promises.readFile)(tempTargetPath, "utf-8");
1620
+ }
1621
+ const tempFilePath = (0, import_node_path2.join)(tempDir, filePath);
1622
+ await (0, import_promises.mkdir)((0, import_node_path2.dirname)(tempFilePath), { recursive: true });
1623
+ await (0, import_promises.writeFile)(tempFilePath, base);
1624
+ await tempGit.exec(["add", filePath]);
1625
+ await tempGit.exec(["commit", "-m", `base for ${filePath}`, "--allow-empty"]);
1626
+ await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
1627
+ return await (0, import_promises.readFile)(tempFilePath, "utf-8");
1628
+ } catch {
1629
+ return null;
1630
+ }
1606
1631
  }
1607
- /** Populate accumulator after git apply succeeds. */
1608
- async populateAccumulatorForPatch(patch, baseGen, currentTreeHash) {
1609
- if (!baseGen) return;
1610
- const tempDir = await (0, import_promises.mkdtemp)((0, import_node_path2.join)((0, import_node_os.tmpdir)(), "replay-acc-"));
1611
- const { GitClient: GitClient2 } = await Promise.resolve().then(() => (init_GitClient(), GitClient_exports));
1612
- const tempGit = new GitClient2(tempDir);
1613
- await tempGit.exec(["init"]);
1614
- await tempGit.exec(["config", "user.email", "replay@fern.com"]);
1615
- await tempGit.exec(["config", "user.name", "Fern Replay"]);
1632
+ /**
1633
+ * Detects whether a patch's additions are already present in `content`.
1634
+ * Uses `git apply --reverse --check` — if the reverse patch applies cleanly,
1635
+ * the forward patch is effectively a no-op (its +lines are already there and
1636
+ * its -lines are already gone). Used to distinguish "patch already applied"
1637
+ * from "patch base mismatch" after a forward-apply fails.
1638
+ */
1639
+ async isPatchAlreadyApplied(patchContent, filePath, content, tempGit, tempDir) {
1640
+ const fileDiff = this.extractFileDiff(patchContent, filePath);
1641
+ if (!fileDiff) return false;
1616
1642
  try {
1617
- for (const filePath of patch.files) {
1618
- if (isBinaryFile(filePath)) continue;
1619
- const resolvedPath = await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
1620
- const base = await this.git.showFile(baseGen.tree_hash, filePath);
1621
- const theirs = await this.applyPatchToContent(base, patch.patch_content, filePath, tempGit, tempDir);
1622
- const effectiveTheirs = theirs ?? await (0, import_promises.readFile)((0, import_node_path2.join)(this.outputDir, resolvedPath), "utf-8").catch(() => null);
1623
- if (effectiveTheirs != null) {
1624
- this.fileTheirsAccumulator.set(resolvedPath, {
1625
- content: effectiveTheirs,
1626
- baseGeneration: patch.base_generation
1627
- });
1643
+ const tempFilePath = (0, import_node_path2.join)(tempDir, filePath);
1644
+ await (0, import_promises.mkdir)((0, import_node_path2.dirname)(tempFilePath), { recursive: true });
1645
+ await (0, import_promises.writeFile)(tempFilePath, content);
1646
+ await tempGit.exec(["add", filePath]);
1647
+ await tempGit.exec(["commit", "-m", `check already-applied ${filePath}`, "--allow-empty"]);
1648
+ await tempGit.execWithInput(["apply", "--reverse", "--check", "--allow-empty"], fileDiff);
1649
+ return true;
1650
+ } catch {
1651
+ return false;
1652
+ }
1653
+ }
1654
+ extractFileDiff(patchContent, filePath) {
1655
+ const lines = patchContent.split("\n");
1656
+ const diffLines = [];
1657
+ let inTargetFile = false;
1658
+ for (const line of lines) {
1659
+ if (line.startsWith("diff --git")) {
1660
+ if (inTargetFile) {
1661
+ break;
1628
1662
  }
1663
+ if (isDiffLineForFile(line, filePath)) {
1664
+ inTargetFile = true;
1665
+ diffLines.push(line);
1666
+ }
1667
+ continue;
1668
+ }
1669
+ if (inTargetFile) {
1670
+ diffLines.push(line);
1629
1671
  }
1630
- } finally {
1631
- await (0, import_promises.rm)(tempDir, { recursive: true }).catch(() => {
1632
- });
1633
1672
  }
1673
+ return diffLines.length > 0 ? diffLines.join("\n") + "\n" : null;
1634
1674
  }
1635
- async applyPatchWithFallback(patch) {
1636
- const baseGen = await this.resolveBaseGeneration(patch.base_generation);
1637
- const lock = this.lockManager.read();
1638
- const currentGen = lock.generations.find((g) => g.commit_sha === lock.current_generation);
1639
- const currentTreeHash = currentGen?.tree_hash ?? baseGen?.tree_hash ?? "";
1640
- const needsAccumulation = await Promise.all(
1641
- patch.files.map(async (f) => {
1642
- if (!baseGen) return false;
1643
- const resolved = await this.resolveFilePath(f, baseGen.tree_hash, currentTreeHash);
1644
- return this.fileTheirsAccumulator.has(resolved);
1645
- })
1646
- ).then((results) => results.some(Boolean));
1647
- const resolvedFiles = {};
1648
- for (const filePath of patch.files) {
1649
- const resolvedPath = baseGen ? await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash) : filePath;
1650
- if (resolvedPath !== filePath) {
1651
- resolvedFiles[filePath] = resolvedPath;
1675
+ extractRenameSource(patchContent, targetFilePath) {
1676
+ const lines = patchContent.split("\n");
1677
+ let inTargetFile = false;
1678
+ for (const line of lines) {
1679
+ if (line.startsWith("diff --git")) {
1680
+ if (inTargetFile) break;
1681
+ inTargetFile = isDiffLineForFile(line, targetFilePath);
1682
+ continue;
1683
+ }
1684
+ if (!inTargetFile) continue;
1685
+ if (line.startsWith("@@")) break;
1686
+ if (line.startsWith("rename from ")) {
1687
+ return line.slice("rename from ".length);
1652
1688
  }
1653
1689
  }
1654
- const patchIsRenameAware = /^rename from /m.test(patch.patch_content);
1655
- const hasGeneratorRename = Object.keys(resolvedFiles).length > 0 && !patchIsRenameAware;
1656
- if (!needsAccumulation && !hasGeneratorRename) {
1657
- const snapshots = /* @__PURE__ */ new Map();
1658
- for (const filePath of patch.files) {
1659
- const fullPath = (0, import_node_path2.join)(this.outputDir, filePath);
1660
- snapshots.set(filePath, await (0, import_promises.readFile)(fullPath, "utf-8").catch(() => null));
1690
+ return null;
1691
+ }
1692
+ extractNewFileFromPatch(patchContent, filePath) {
1693
+ const lines = patchContent.split("\n");
1694
+ const addedLines = [];
1695
+ let inTargetFile = false;
1696
+ let inHunk = false;
1697
+ let isNewFile = false;
1698
+ let noTrailingNewline = false;
1699
+ for (const line of lines) {
1700
+ if (line.startsWith("diff --git")) {
1701
+ if (inTargetFile) break;
1702
+ inTargetFile = isDiffLineForFile(line, filePath);
1703
+ inHunk = false;
1704
+ isNewFile = false;
1705
+ continue;
1661
1706
  }
1662
- try {
1663
- await this.git.execWithInput(["apply", "--3way"], patch.patch_content);
1664
- await this.populateAccumulatorForPatch(patch, baseGen, currentTreeHash);
1665
- return {
1666
- patch,
1667
- status: "applied",
1668
- method: "git-am",
1669
- ...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
1670
- };
1671
- } catch {
1672
- for (const [filePath, content] of snapshots) {
1673
- const fullPath = (0, import_node_path2.join)(this.outputDir, filePath);
1674
- if (content != null) {
1675
- await (0, import_promises.writeFile)(fullPath, content);
1676
- } else {
1677
- await (0, import_promises.unlink)(fullPath).catch(() => {
1678
- });
1679
- }
1707
+ if (!inTargetFile) continue;
1708
+ if (!inHunk) {
1709
+ if (line === "--- /dev/null" || line.startsWith("new file mode")) {
1710
+ isNewFile = true;
1680
1711
  }
1681
1712
  }
1713
+ if (line.startsWith("@@")) {
1714
+ if (!isNewFile) return null;
1715
+ inHunk = true;
1716
+ continue;
1717
+ }
1718
+ if (!inHunk) continue;
1719
+ if (line === "\") {
1720
+ noTrailingNewline = true;
1721
+ continue;
1722
+ }
1723
+ if (line.startsWith("+") && !line.startsWith("+++")) {
1724
+ addedLines.push(line.slice(1));
1725
+ }
1682
1726
  }
1683
- return this.applyWithThreeWayMerge(patch);
1727
+ if (addedLines.length === 0) return null;
1728
+ return addedLines.join("\n") + (noTrailingNewline ? "" : "\n");
1684
1729
  }
1685
- async applyWithThreeWayMerge(patch) {
1686
- const fileResults = [];
1687
- const resolvedFiles = {};
1688
- const tempDir = await (0, import_promises.mkdtemp)((0, import_node_path2.join)((0, import_node_os.tmpdir)(), "replay-"));
1689
- const { GitClient: GitClient2 } = await Promise.resolve().then(() => (init_GitClient(), GitClient_exports));
1690
- const tempGit = new GitClient2(tempDir);
1691
- await tempGit.exec(["init"]);
1692
- await tempGit.exec(["config", "user.email", "replay@fern.com"]);
1693
- await tempGit.exec(["config", "user.name", "Fern Replay"]);
1694
- try {
1695
- for (const filePath of patch.files) {
1696
- if (isBinaryFile(filePath)) {
1697
- fileResults.push({
1698
- file: filePath,
1699
- status: "skipped",
1700
- reason: "binary-file"
1701
- });
1702
- continue;
1703
- }
1704
- const result = await this.mergeFile(patch, filePath, tempGit, tempDir);
1705
- if (result.file !== filePath) {
1706
- resolvedFiles[filePath] = result.file;
1707
- }
1708
- fileResults.push(result);
1730
+ };
1731
+ function isBinaryFile(filePath) {
1732
+ const ext = (0, import_node_path2.extname)(filePath).toLowerCase();
1733
+ return BINARY_EXTENSIONS.has(ext);
1734
+ }
1735
+ function computeDroppedContextLinesForFile(base, theirs, finalContent) {
1736
+ const baseLines = base.split("\n");
1737
+ const theirsLines = theirs.split("\n");
1738
+ const finalLines = finalContent.split("\n");
1739
+ const baseCounts = /* @__PURE__ */ new Map();
1740
+ const theirsCounts = /* @__PURE__ */ new Map();
1741
+ const finalCounts = /* @__PURE__ */ new Map();
1742
+ for (const l of baseLines) baseCounts.set(l, (baseCounts.get(l) ?? 0) + 1);
1743
+ for (const l of theirsLines) theirsCounts.set(l, (theirsCounts.get(l) ?? 0) + 1);
1744
+ for (const l of finalLines) finalCounts.set(l, (finalCounts.get(l) ?? 0) + 1);
1745
+ const dropped = [];
1746
+ const seen = /* @__PURE__ */ new Set();
1747
+ for (const line of baseLines) {
1748
+ if (seen.has(line)) continue;
1749
+ const inBase = baseCounts.get(line) ?? 0;
1750
+ const inTheirs = theirsCounts.get(line) ?? 0;
1751
+ const inFinal = finalCounts.get(line) ?? 0;
1752
+ if (inBase > 0 && inTheirs > 0 && inFinal < Math.min(inBase, inTheirs)) {
1753
+ dropped.push(line);
1754
+ seen.add(line);
1755
+ }
1756
+ }
1757
+ return dropped;
1758
+ }
1759
+ function isDiffLineForFile(diffLine, filePath) {
1760
+ const match = diffLine.match(/^diff --git a\/.+ b\/(.+)$/);
1761
+ return match !== null && match[1] === filePath;
1762
+ }
1763
+
1764
+ // src/ReplayDetector.ts
1765
+ var INFRASTRUCTURE_FILES = /* @__PURE__ */ new Set([".fernignore"]);
1766
+ function parsePatchFileHeaders(patchContent) {
1767
+ const results = [];
1768
+ let currentPath = null;
1769
+ let currentIsCreate = false;
1770
+ let currentIsDelete = false;
1771
+ const flush = () => {
1772
+ if (currentPath !== null) {
1773
+ results.push({ path: currentPath, isCreate: currentIsCreate, isDelete: currentIsDelete });
1774
+ }
1775
+ };
1776
+ for (const line of patchContent.split("\n")) {
1777
+ if (line.startsWith("diff --git ")) {
1778
+ flush();
1779
+ currentPath = null;
1780
+ currentIsCreate = false;
1781
+ currentIsDelete = false;
1782
+ const match = line.match(/^diff --git a\/(.+?) b\/(.+?)$/);
1783
+ if (match) {
1784
+ currentPath = match[2] ?? null;
1709
1785
  }
1710
- } finally {
1711
- await (0, import_promises.rm)(tempDir, { recursive: true }).catch(() => {
1712
- });
1786
+ } else if (currentPath !== null) {
1787
+ if (line.startsWith("new file mode ")) {
1788
+ currentIsCreate = true;
1789
+ } else if (line.startsWith("deleted file mode ")) {
1790
+ currentIsDelete = true;
1791
+ }
1792
+ }
1793
+ }
1794
+ flush();
1795
+ return results;
1796
+ }
1797
+ var ReplayDetector = class {
1798
+ git;
1799
+ lockManager;
1800
+ sdkOutputDir;
1801
+ warnings = [];
1802
+ constructor(git, lockManager, sdkOutputDir) {
1803
+ this.git = git;
1804
+ this.lockManager = lockManager;
1805
+ this.sdkOutputDir = sdkOutputDir;
1806
+ }
1807
+ async detectNewPatches() {
1808
+ const lock = this.lockManager.read();
1809
+ const lastGen = this.getLastGeneration(lock);
1810
+ if (!lastGen) {
1811
+ return { patches: [], revertedPatchIds: [] };
1713
1812
  }
1714
- const conflictFiles = fileResults.filter((r) => r.status === "conflict");
1715
- const hasConflicts = conflictFiles.length > 0;
1716
- const conflictReason = hasConflicts ? conflictFiles.some((f) => f.conflictReason === "base-generation-mismatch") ? "base-generation-mismatch" : conflictFiles[0]?.conflictReason : void 0;
1717
- return {
1718
- patch,
1719
- status: hasConflicts ? "conflict" : "applied",
1720
- method: "3way-merge",
1721
- fileResults,
1722
- conflictReason,
1723
- ...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
1724
- };
1813
+ const exists = await this.git.commitExists(lastGen.commit_sha);
1814
+ if (!exists) {
1815
+ this.warnings.push(
1816
+ `Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to alternate detection.`
1817
+ );
1818
+ return this.detectPatchesViaTreeDiff(
1819
+ lastGen,
1820
+ /* commitKnownMissing */
1821
+ true
1822
+ );
1823
+ }
1824
+ const isAncestor = await this.git.isAncestor(lastGen.commit_sha, "HEAD");
1825
+ if (!isAncestor) {
1826
+ return this.detectPatchesViaMergeBase(lastGen, lock);
1827
+ }
1828
+ return this.detectPatchesInRange(lastGen.commit_sha, lastGen, lock);
1725
1829
  }
1726
- async mergeFile(patch, filePath, tempGit, tempDir) {
1830
+ /**
1831
+ * FER-10201 — Non-linear-history detection via `merge-base(prevGen, HEAD)`.
1832
+ *
1833
+ * After a squash-merge of a Fern-bot PR, `lastGen.commit_sha` (the previous
1834
+ * `[fern-generated]`) is orphaned but still in git's object database. The
1835
+ * merge-base is the commit on main from which the bot's branch was created —
1836
+ * a reachable ancestor of HEAD. Walking that range and classifying each
1837
+ * commit via `isGenerationCommit` mirrors the linear path and eliminates
1838
+ * the bug class where pipeline-driven changes (autoversion, replay,
1839
+ * generation) get bundled as customer customizations.
1840
+ *
1841
+ * Falls through to the legacy composite path when no common ancestor exists
1842
+ * (truly disjoint history — orphan branches with no shared root).
1843
+ */
1844
+ async detectPatchesViaMergeBase(lastGen, lock) {
1845
+ let mergeBase = "";
1727
1846
  try {
1728
- const baseGen = await this.resolveBaseGeneration(patch.base_generation);
1729
- if (!baseGen) {
1730
- return { file: filePath, status: "skipped", reason: "base-generation-not-found" };
1847
+ mergeBase = (await this.git.exec(["merge-base", lastGen.commit_sha, "HEAD"])).trim();
1848
+ } catch {
1849
+ }
1850
+ if (!mergeBase) {
1851
+ this.warnings.push(
1852
+ `No common ancestor between previous generation ${lastGen.commit_sha.slice(0, 7)} and HEAD. Falling back to composite tree-diff detection.`
1853
+ );
1854
+ return this.detectPatchesViaTreeDiff(
1855
+ lastGen,
1856
+ /* commitKnownMissing */
1857
+ false
1858
+ );
1859
+ }
1860
+ return this.detectPatchesInRange(mergeBase, lastGen, lock);
1861
+ }
1862
+ /**
1863
+ * FER-10201 — Walk `${rangeStart}..HEAD`, classify each commit, emit one
1864
+ * `StoredPatch` per customer commit. Body shared by the linear path
1865
+ * (rangeStart = lastGen.commit_sha) and the merge-base path.
1866
+ *
1867
+ * Patch `base_generation` is always `lastGen.commit_sha` — the
1868
+ * lockfile-tracked reference the applicator uses to fetch base file
1869
+ * content, regardless of where the walk actually started.
1870
+ */
1871
+ async detectPatchesInRange(rangeStart, lastGen, lock) {
1872
+ const log = await this.git.exec([
1873
+ "log",
1874
+ "--first-parent",
1875
+ "--format=%H%x00%an%x00%ae%x00%s",
1876
+ `${rangeStart}..HEAD`,
1877
+ "--",
1878
+ this.sdkOutputDir
1879
+ ]);
1880
+ if (!log.trim()) {
1881
+ return { patches: [], revertedPatchIds: [] };
1882
+ }
1883
+ const commits = this.parseGitLog(log);
1884
+ const newPatches = [];
1885
+ const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
1886
+ for (const commit of commits) {
1887
+ if (isGenerationCommit(commit)) {
1888
+ continue;
1731
1889
  }
1732
- const lock = this.lockManager.read();
1733
- const currentGen = lock.generations.find((g) => g.commit_sha === lock.current_generation);
1734
- const currentTreeHash = currentGen?.tree_hash ?? baseGen.tree_hash;
1735
- const resolvedPath = await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
1736
- const metadata = {
1737
- patchId: patch.id,
1738
- patchMessage: patch.original_message,
1739
- baseGeneration: patch.base_generation,
1740
- currentGeneration: lock.current_generation
1741
- };
1742
- let base = await this.git.showFile(baseGen.tree_hash, filePath);
1743
- let renameSourcePath;
1744
- if (!base) {
1745
- const renameSource = this.extractRenameSource(patch.patch_content, filePath);
1746
- if (renameSource) {
1747
- base = await this.git.showFile(baseGen.tree_hash, renameSource);
1748
- renameSourcePath = renameSource;
1749
- }
1890
+ if (lock.patches.find((p) => p.original_commit === commit.sha)) {
1891
+ continue;
1750
1892
  }
1751
- const oursPath = (0, import_node_path2.join)(this.outputDir, resolvedPath);
1752
- const ours = await (0, import_promises.readFile)(oursPath, "utf-8").catch(() => null);
1753
- let ghostReconstructed = false;
1754
- let theirs = null;
1755
- if (!base && ours && !renameSourcePath) {
1756
- const treeReachable = await this.isTreeReachable(baseGen.tree_hash);
1757
- if (!treeReachable) {
1758
- const fileDiff = this.extractFileDiff(patch.patch_content, filePath);
1759
- if (fileDiff) {
1760
- const { reconstructFromGhostPatch: reconstructFromGhostPatch2 } = await Promise.resolve().then(() => (init_HybridReconstruction(), HybridReconstruction_exports));
1761
- const result = reconstructFromGhostPatch2(fileDiff, ours);
1762
- if (result) {
1763
- base = result.base;
1764
- theirs = result.theirs;
1765
- ghostReconstructed = true;
1766
- }
1767
- }
1893
+ const parents = await this.git.getCommitParents(commit.sha);
1894
+ if (parents.length > 1) {
1895
+ const compositePatch = await this.createMergeCompositePatch({
1896
+ mergeCommit: commit,
1897
+ firstParent: parents[0],
1898
+ lastGen,
1899
+ lock
1900
+ });
1901
+ if (compositePatch) {
1902
+ newPatches.push(compositePatch);
1768
1903
  }
1904
+ continue;
1769
1905
  }
1770
- if (!ghostReconstructed) {
1771
- theirs = await this.applyPatchToContent(
1772
- base,
1773
- patch.patch_content,
1774
- filePath,
1775
- tempGit,
1776
- tempDir,
1777
- renameSourcePath
1906
+ let patchContent;
1907
+ try {
1908
+ patchContent = await this.git.formatPatch(commit.sha);
1909
+ } catch {
1910
+ this.warnings.push(
1911
+ `Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
1778
1912
  );
1913
+ continue;
1779
1914
  }
1780
- if (theirs) {
1781
- const theirsHasMarkers = theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization");
1782
- const baseHasMarkers = base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization"));
1783
- if (theirsHasMarkers && !baseHasMarkers) {
1784
- return {
1785
- file: resolvedPath,
1786
- status: "skipped",
1787
- reason: "stale-conflict-markers"
1788
- };
1789
- }
1915
+ let contentHash = this.computeContentHash(patchContent);
1916
+ if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
1917
+ continue;
1790
1918
  }
1791
- let useAccumulatorAsMergeBase = false;
1792
- const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
1793
- if (accumulatorEntry && (theirs == null || base == null)) {
1794
- theirs = await this.applyPatchToContent(
1795
- accumulatorEntry.content,
1796
- patch.patch_content,
1797
- filePath,
1798
- tempGit,
1799
- tempDir
1919
+ const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
1920
+ const allFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
1921
+ const sensitiveFiles = allFiles.filter((f) => isSensitiveFile(f));
1922
+ const files = allFiles.filter((f) => !isSensitiveFile(f));
1923
+ if (sensitiveFiles.length > 0) {
1924
+ this.warnings.push(
1925
+ `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
1800
1926
  );
1801
- if (theirs != null) {
1802
- useAccumulatorAsMergeBase = true;
1803
- } else if (await this.isPatchAlreadyApplied(
1804
- patch.patch_content,
1805
- filePath,
1806
- accumulatorEntry.content,
1807
- tempGit,
1808
- tempDir
1809
- )) {
1810
- theirs = accumulatorEntry.content;
1811
- useAccumulatorAsMergeBase = true;
1927
+ if (files.length > 0) {
1928
+ const parentSha = parents[0];
1929
+ if (parentSha) {
1930
+ try {
1931
+ patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
1932
+ } catch {
1933
+ continue;
1934
+ }
1935
+ if (!patchContent.trim()) continue;
1936
+ contentHash = this.computeContentHash(patchContent);
1937
+ }
1812
1938
  }
1813
1939
  }
1814
- if (theirs) {
1815
- const theirsHasMarkers = theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization");
1816
- const accBaseHasMarkers = accumulatorEntry != null && (accumulatorEntry.content.includes("<<<<<<< Generated") || accumulatorEntry.content.includes(">>>>>>> Your customization"));
1817
- if (theirsHasMarkers && !accBaseHasMarkers && !(base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization")))) {
1818
- return {
1819
- file: resolvedPath,
1820
- status: "skipped",
1821
- reason: "stale-conflict-markers"
1822
- };
1823
- }
1940
+ if (files.length === 0) {
1941
+ continue;
1824
1942
  }
1825
- let effective_theirs = theirs;
1826
- let baseMismatchSkipped = false;
1827
- if (theirs != null && base != null && !useAccumulatorAsMergeBase) {
1828
- if (accumulatorEntry && accumulatorEntry.baseGeneration === patch.base_generation) {
1829
- try {
1830
- const preMerged = threeWayMerge(base, accumulatorEntry.content, theirs);
1831
- if (!preMerged.hasConflicts) {
1832
- effective_theirs = preMerged.content;
1833
- } else {
1834
- effective_theirs = theirs;
1835
- }
1836
- } catch {
1837
- effective_theirs = theirs;
1838
- }
1839
- } else if (accumulatorEntry) {
1840
- baseMismatchSkipped = true;
1943
+ const theirsSnapshot = {};
1944
+ for (const file of files) {
1945
+ if (isBinaryFile(file)) continue;
1946
+ const content = await this.git.showFile(commit.sha, file).catch(() => null);
1947
+ if (content != null) theirsSnapshot[file] = content;
1948
+ }
1949
+ newPatches.push({
1950
+ id: `patch-${commit.sha.slice(0, 8)}`,
1951
+ content_hash: contentHash,
1952
+ original_commit: commit.sha,
1953
+ original_message: commit.message,
1954
+ original_author: `${commit.authorName} <${commit.authorEmail}>`,
1955
+ base_generation: lastGen.commit_sha,
1956
+ files,
1957
+ patch_content: patchContent,
1958
+ ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
1959
+ ...this.isCreationOnlyPatch(patchContent) ? { user_owned: true } : {}
1960
+ });
1961
+ }
1962
+ const survivingPatches = await this.collapseNetZeroFiles(newPatches);
1963
+ survivingPatches.reverse();
1964
+ const revertedPatchIdSet = /* @__PURE__ */ new Set();
1965
+ const revertIndicesToRemove = /* @__PURE__ */ new Set();
1966
+ for (let i = 0; i < survivingPatches.length; i++) {
1967
+ const patch = survivingPatches[i];
1968
+ if (!isRevertCommit(patch.original_message)) continue;
1969
+ let body = "";
1970
+ try {
1971
+ body = await this.git.getCommitBody(patch.original_commit);
1972
+ } catch {
1973
+ }
1974
+ const revertedSha = parseRevertedSha(body);
1975
+ const revertedMessage = parseRevertedMessage(patch.original_message);
1976
+ let matchedExisting = false;
1977
+ if (revertedSha) {
1978
+ const existing = lock.patches.find((p) => p.original_commit === revertedSha);
1979
+ if (existing) {
1980
+ revertedPatchIdSet.add(existing.id);
1981
+ revertIndicesToRemove.add(i);
1982
+ matchedExisting = true;
1841
1983
  }
1842
1984
  }
1843
- if (base == null && ours == null && effective_theirs != null) {
1844
- this.fileTheirsAccumulator.set(resolvedPath, {
1845
- content: effective_theirs,
1846
- baseGeneration: patch.base_generation
1847
- });
1848
- const outDir2 = (0, import_node_path2.dirname)(oursPath);
1849
- await (0, import_promises.mkdir)(outDir2, { recursive: true });
1850
- await (0, import_promises.writeFile)(oursPath, effective_theirs);
1851
- return { file: resolvedPath, status: "merged", reason: "new-file" };
1985
+ if (!matchedExisting && revertedMessage) {
1986
+ const existing = lock.patches.find((p) => p.original_message === revertedMessage);
1987
+ if (existing) {
1988
+ revertedPatchIdSet.add(existing.id);
1989
+ revertIndicesToRemove.add(i);
1990
+ matchedExisting = true;
1991
+ }
1852
1992
  }
1853
- if (base == null && ours != null && effective_theirs != null && !useAccumulatorAsMergeBase) {
1854
- const merged2 = threeWayMerge("", ours, effective_theirs);
1855
- const outDir2 = (0, import_node_path2.dirname)(oursPath);
1856
- await (0, import_promises.mkdir)(outDir2, { recursive: true });
1857
- await (0, import_promises.writeFile)(oursPath, merged2.content);
1858
- if (merged2.hasConflicts) {
1859
- return {
1860
- file: resolvedPath,
1861
- status: "conflict",
1862
- conflicts: merged2.conflicts,
1863
- conflictReason: "new-file-both",
1864
- conflictMetadata: metadata
1865
- };
1993
+ if (matchedExisting) continue;
1994
+ let matchedNew = false;
1995
+ if (revertedSha) {
1996
+ const idx = survivingPatches.findIndex(
1997
+ (p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_commit === revertedSha
1998
+ );
1999
+ if (idx !== -1) {
2000
+ revertIndicesToRemove.add(i);
2001
+ revertIndicesToRemove.add(idx);
2002
+ matchedNew = true;
1866
2003
  }
1867
- this.fileTheirsAccumulator.set(resolvedPath, {
1868
- content: merged2.content,
1869
- baseGeneration: patch.base_generation
1870
- });
1871
- return { file: resolvedPath, status: "merged" };
1872
2004
  }
1873
- if (effective_theirs == null) {
1874
- return {
1875
- file: resolvedPath,
1876
- status: "skipped",
1877
- reason: "missing-content"
1878
- };
2005
+ if (!matchedNew && revertedMessage) {
2006
+ const idx = survivingPatches.findIndex(
2007
+ (p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_message === revertedMessage
2008
+ );
2009
+ if (idx !== -1) {
2010
+ revertIndicesToRemove.add(i);
2011
+ revertIndicesToRemove.add(idx);
2012
+ }
1879
2013
  }
1880
- if (base == null && !useAccumulatorAsMergeBase || ours == null) {
1881
- return {
1882
- file: resolvedPath,
1883
- status: "skipped",
1884
- reason: "missing-content"
1885
- };
2014
+ if (!matchedExisting && !matchedNew) {
2015
+ revertIndicesToRemove.add(i);
1886
2016
  }
1887
- const mergeBase = useAccumulatorAsMergeBase && accumulatorEntry ? accumulatorEntry.content : base;
1888
- if (mergeBase == null) {
1889
- return {
1890
- file: resolvedPath,
1891
- status: "skipped",
1892
- reason: "missing-content"
1893
- };
2017
+ }
2018
+ const filteredPatches = survivingPatches.filter((_, i) => !revertIndicesToRemove.has(i));
2019
+ return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
2020
+ }
2021
+ /**
2022
+ * Compute content hash for deduplication.
2023
+ *
2024
+ * Produces a format-agnostic hash so both `git format-patch` output
2025
+ * (email-wrapped) and plain `git diff` output hash to the same value
2026
+ * when the underlying diff hunks are identical.
2027
+ *
2028
+ * Normalization:
2029
+ * 1. Strip email wrapper: everything before the first `diff --git` line
2030
+ * (From:, Subject:, Date:, diffstat, blank separators).
2031
+ * 2. Strip `index` lines (blob SHA pairs change across rebases).
2032
+ * 3. Strip trailing git version marker (`-- \n<version>`).
2033
+ *
2034
+ * This ensures content hashes match across the no-patches and normal-
2035
+ * regeneration flows, which store formatPatch and git-diff formats
2036
+ * respectively (FER-9850, D5-D9).
2037
+ */
2038
+ computeContentHash(patchContent) {
2039
+ const lines = patchContent.split("\n");
2040
+ const diffStart = lines.findIndex((l) => l.startsWith("diff --git "));
2041
+ const relevantLines = diffStart > 0 ? lines.slice(diffStart) : lines;
2042
+ const normalized = relevantLines.filter((line) => !line.startsWith("index ")).join("\n").replace(/\n-- \n[\d]+\.[\d]+[^\n]*\s*$/, "");
2043
+ return `sha256:${(0, import_node_crypto.createHash)("sha256").update(normalized).digest("hex")}`;
2044
+ }
2045
+ /**
2046
+ * FER-9805 — Drop patches whose files were transiently created and then
2047
+ * deleted within the current linear detection window, and are absent from
2048
+ * HEAD at the end of the window.
2049
+ *
2050
+ * Rationale: on a merge commit, createMergeCompositePatch already diffs
2051
+ * firstParent..mergeCommit so net-zero files never enter the composite. On
2052
+ * linear history each commit becomes its own patch, so a file that was
2053
+ * briefly added and later removed survives as a create+delete pair whose
2054
+ * cumulative diff is empty. We drop such patches before they reach the
2055
+ * lockfile.
2056
+ *
2057
+ * A file is "transient" when:
2058
+ * 1. some patch in the window sets `new file mode` for it (a creation), AND
2059
+ * 2. some patch in the window sets `deleted file mode` for it (a deletion), AND
2060
+ * 3. it is absent from HEAD's tree.
2061
+ *
2062
+ * The HEAD guard (#3) prevents false positives when a file is deleted
2063
+ * and then recreated with different content — the cumulative diff of
2064
+ * such a pair is non-empty and must be preserved.
2065
+ *
2066
+ * This only drops patches whose `files` are a subset of the transient
2067
+ * set. A partial-transient patch (one that touches a transient file
2068
+ * alongside a persistent one) is left unmodified; surgical stripping of
2069
+ * single files from a multi-file patch would require a follow-up that
2070
+ * regenerates the patch content via `git format-patch -- <files>`. No
2071
+ * current failing test exercises this, and leaving the patch intact
2072
+ * preserves today's behaviour. TODO(FER-9805): revisit if a future
2073
+ * adversarial test demands per-file stripping.
2074
+ */
2075
+ async collapseNetZeroFiles(patches) {
2076
+ if (patches.length === 0) return patches;
2077
+ const stateByFile = /* @__PURE__ */ new Map();
2078
+ for (const patch of patches) {
2079
+ for (const header of parsePatchFileHeaders(patch.patch_content)) {
2080
+ if (!header.isCreate && !header.isDelete) continue;
2081
+ const prior = stateByFile.get(header.path) ?? { created: false, deleted: false };
2082
+ if (header.isCreate) prior.created = true;
2083
+ if (header.isDelete) prior.deleted = true;
2084
+ stateByFile.set(header.path, prior);
1894
2085
  }
1895
- const merged = threeWayMerge(mergeBase, ours, effective_theirs);
1896
- const outDir = (0, import_node_path2.dirname)(oursPath);
1897
- await (0, import_promises.mkdir)(outDir, { recursive: true });
1898
- await (0, import_promises.writeFile)(oursPath, merged.content);
1899
- if (effective_theirs != null && !merged.hasConflicts) {
1900
- this.fileTheirsAccumulator.set(resolvedPath, {
1901
- content: effective_theirs,
1902
- baseGeneration: patch.base_generation
1903
- });
2086
+ }
2087
+ let anyCandidate = false;
2088
+ for (const state of stateByFile.values()) {
2089
+ if (state.created && state.deleted) {
2090
+ anyCandidate = true;
2091
+ break;
1904
2092
  }
1905
- if (merged.hasConflicts) {
1906
- return {
1907
- file: resolvedPath,
1908
- status: "conflict",
1909
- conflicts: merged.conflicts,
1910
- conflictReason: baseMismatchSkipped ? "base-generation-mismatch" : "same-line-edit",
1911
- conflictMetadata: metadata
1912
- };
2093
+ }
2094
+ if (!anyCandidate) return patches;
2095
+ const headFiles = await this.listHeadFiles();
2096
+ const transient = /* @__PURE__ */ new Set();
2097
+ for (const [file, state] of stateByFile) {
2098
+ if (state.created && state.deleted && !headFiles.has(file)) {
2099
+ transient.add(file);
1913
2100
  }
1914
- return { file: resolvedPath, status: "merged" };
1915
- } catch (error) {
1916
- return {
1917
- file: filePath,
1918
- status: "skipped",
1919
- reason: `error: ${error instanceof Error ? error.message : String(error)}`
1920
- };
1921
2101
  }
2102
+ if (transient.size === 0) return patches;
2103
+ return patches.filter((patch) => !patch.files.every((f) => transient.has(f)));
1922
2104
  }
1923
- async isTreeReachable(treeHash) {
1924
- let result = this.treeExistsCache.get(treeHash);
1925
- if (result === void 0) {
1926
- result = await this.git.treeExists(treeHash);
1927
- this.treeExistsCache.set(treeHash, result);
2105
+ /**
2106
+ * List every tracked path at HEAD (one `git ls-tree -r` invocation).
2107
+ * Returns an empty Set if HEAD is unborn or the invocation fails — the
2108
+ * caller then treats every candidate as "not in HEAD" and may collapse
2109
+ * more aggressively. On typical SDK repos this is a single sub-10ms call.
2110
+ */
2111
+ async listHeadFiles() {
2112
+ try {
2113
+ const out = await this.git.exec(["ls-tree", "-r", "--name-only", "HEAD"]);
2114
+ return new Set(out.split("\n").map((l) => l.trim()).filter(Boolean));
2115
+ } catch {
2116
+ return /* @__PURE__ */ new Set();
1928
2117
  }
1929
- return result;
1930
- }
1931
- isExcluded(patch) {
1932
- const config = this.lockManager.getCustomizationsConfig();
1933
- if (!config.exclude) return false;
1934
- return patch.files.some((file) => config.exclude.some((pattern) => (0, import_minimatch.minimatch)(file, pattern)));
1935
2118
  }
1936
- async resolveFilePath(filePath, baseTreeHash, currentTreeHash) {
1937
- const config = this.lockManager.getCustomizationsConfig();
1938
- if (config.moves) {
1939
- for (const move of config.moves) {
1940
- if ((0, import_minimatch.minimatch)(filePath, move.from) || filePath === move.from) {
1941
- if (filePath === move.from) {
1942
- return move.to;
1943
- }
1944
- const fromBase = move.from.replace(/\*\*.*$/, "");
1945
- const toBase = move.to.replace(/\*\*.*$/, "");
1946
- if (filePath.startsWith(fromBase)) {
1947
- return toBase + filePath.slice(fromBase.length);
1948
- }
1949
- }
1950
- }
2119
+ /**
2120
+ * Create a single composite patch for a merge commit by diffing the merge
2121
+ * commit against its first parent. This captures the net change of the
2122
+ * entire merged branch as one patch, eliminating accumulator chaining and
2123
+ * zero-net-change ghost conflicts.
2124
+ */
2125
+ async createMergeCompositePatch(input) {
2126
+ const { mergeCommit, firstParent, lastGen, lock } = input;
2127
+ const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
2128
+ const filesOutput = await this.git.exec([
2129
+ "diff",
2130
+ "--name-only",
2131
+ firstParent,
2132
+ mergeCommit.sha
2133
+ ]);
2134
+ const allMergeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
2135
+ const sensitiveMergeFiles = allMergeFiles.filter((f) => isSensitiveFile(f));
2136
+ const files = allMergeFiles.filter((f) => !isSensitiveFile(f));
2137
+ if (sensitiveMergeFiles.length > 0) {
2138
+ this.warnings.push(
2139
+ `Sensitive file(s) excluded from merge patch for commit ${mergeCommit.sha.slice(0, 7)}: ${sensitiveMergeFiles.join(", ")}. These files will not be tracked by replay.`
2140
+ );
1951
2141
  }
1952
- const cacheKey = `${baseTreeHash}:${currentTreeHash}`;
1953
- let renames = this.renameCache.get(cacheKey);
1954
- if (!renames) {
1955
- renames = await this.git.detectRenames(baseTreeHash, currentTreeHash);
1956
- this.renameCache.set(cacheKey, renames);
2142
+ if (files.length === 0) return null;
2143
+ const diff = await this.git.exec([
2144
+ "diff",
2145
+ firstParent,
2146
+ mergeCommit.sha,
2147
+ "--",
2148
+ ...files
2149
+ ]);
2150
+ if (!diff.trim()) return null;
2151
+ const contentHash = this.computeContentHash(diff);
2152
+ if (lock.patches.some((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
2153
+ return null;
1957
2154
  }
1958
- const gitRename = renames.find((r) => r.from === filePath);
1959
- if (gitRename) {
1960
- return gitRename.to;
2155
+ const theirsSnapshot = {};
2156
+ for (const file of files) {
2157
+ if (isBinaryFile(file)) continue;
2158
+ const content = await this.git.showFile(mergeCommit.sha, file).catch(() => null);
2159
+ if (content != null) theirsSnapshot[file] = content;
1961
2160
  }
1962
- return filePath;
2161
+ return {
2162
+ id: `patch-merge-${mergeCommit.sha.slice(0, 8)}`,
2163
+ content_hash: contentHash,
2164
+ original_commit: mergeCommit.sha,
2165
+ original_message: mergeCommit.message,
2166
+ original_author: `${mergeCommit.authorName} <${mergeCommit.authorEmail}>`,
2167
+ base_generation: lastGen.commit_sha,
2168
+ files,
2169
+ patch_content: diff,
2170
+ ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
2171
+ ...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
2172
+ };
1963
2173
  }
1964
- async applyPatchToContent(base, patchContent, filePath, tempGit, tempDir, sourceFilePath) {
1965
- if (!base) {
1966
- return this.extractNewFileFromPatch(patchContent, filePath);
2174
+ /**
2175
+ * Detect patches via tree diff for non-linear history. Returns a composite patch.
2176
+ * Revert reconciliation is skipped here because tree-diff produces a single composite
2177
+ * patch from the aggregate diff — individual revert commits are not distinguishable.
2178
+ */
2179
+ async detectPatchesViaTreeDiff(lastGen, commitKnownMissing) {
2180
+ const diffBase = await this.resolveDiffBase(lastGen, commitKnownMissing);
2181
+ if (!diffBase) {
2182
+ return this.detectPatchesViaCommitScan();
1967
2183
  }
1968
- const fileDiff = this.extractFileDiff(patchContent, filePath);
1969
- if (!fileDiff) return null;
1970
- try {
1971
- if (sourceFilePath) {
1972
- const tempSourcePath = (0, import_node_path2.join)(tempDir, sourceFilePath);
1973
- await (0, import_promises.mkdir)((0, import_node_path2.dirname)(tempSourcePath), { recursive: true });
1974
- await (0, import_promises.writeFile)(tempSourcePath, base);
1975
- await tempGit.exec(["add", sourceFilePath]);
1976
- await tempGit.exec([
1977
- "commit",
1978
- "-m",
1979
- `base for rename ${sourceFilePath} -> ${filePath}`,
1980
- "--allow-empty"
1981
- ]);
1982
- await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
1983
- const tempTargetPath = (0, import_node_path2.join)(tempDir, filePath);
1984
- return await (0, import_promises.readFile)(tempTargetPath, "utf-8");
1985
- }
1986
- const tempFilePath = (0, import_node_path2.join)(tempDir, filePath);
1987
- await (0, import_promises.mkdir)((0, import_node_path2.dirname)(tempFilePath), { recursive: true });
1988
- await (0, import_promises.writeFile)(tempFilePath, base);
1989
- await tempGit.exec(["add", filePath]);
1990
- await tempGit.exec(["commit", "-m", `base for ${filePath}`, "--allow-empty"]);
1991
- await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
1992
- return await (0, import_promises.readFile)(tempFilePath, "utf-8");
1993
- } catch {
1994
- return null;
2184
+ const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
2185
+ const allTreeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
2186
+ const sensitiveTreeFiles = allTreeFiles.filter((f) => isSensitiveFile(f));
2187
+ const files = allTreeFiles.filter((f) => !isSensitiveFile(f));
2188
+ if (sensitiveTreeFiles.length > 0) {
2189
+ this.warnings.push(
2190
+ `Sensitive file(s) excluded from composite patch: ${sensitiveTreeFiles.join(", ")}. These files will not be tracked by replay.`
2191
+ );
2192
+ }
2193
+ if (files.length === 0) return { patches: [], revertedPatchIds: [] };
2194
+ const diff = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
2195
+ if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
2196
+ const contentHash = this.computeContentHash(diff);
2197
+ const lock = this.lockManager.read();
2198
+ if (lock.patches.some((p) => p.content_hash === contentHash) || (lock.forgotten_hashes ?? []).includes(contentHash)) {
2199
+ return { patches: [], revertedPatchIds: [] };
2200
+ }
2201
+ const headSha = (await this.git.exec(["rev-parse", "HEAD"])).trim();
2202
+ const theirsSnapshot = {};
2203
+ for (const file of files) {
2204
+ if (isBinaryFile(file)) continue;
2205
+ const content = await this.git.showFile("HEAD", file).catch(() => null);
2206
+ if (content != null) theirsSnapshot[file] = content;
1995
2207
  }
2208
+ const compositePatch = {
2209
+ id: `patch-composite-${headSha.slice(0, 8)}`,
2210
+ content_hash: contentHash,
2211
+ original_commit: headSha,
2212
+ original_message: "Customer customizations (composite)",
2213
+ original_author: "composite",
2214
+ // Use diffBase when commit is unreachable — the applicator needs a reachable
2215
+ // reference to find base file content. diffBase may be the tree_hash.
2216
+ base_generation: commitKnownMissing ? diffBase : lastGen.commit_sha,
2217
+ files,
2218
+ patch_content: diff,
2219
+ ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
2220
+ ...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
2221
+ };
2222
+ return { patches: [compositePatch], revertedPatchIds: [] };
1996
2223
  }
1997
2224
  /**
1998
- * Detects whether a patch's additions are already present in `content`.
1999
- * Uses `git apply --reverse --check` if the reverse patch applies cleanly,
2000
- * the forward patch is effectively a no-op (its +lines are already there and
2001
- * its -lines are already gone). Used to distinguish "patch already applied"
2002
- * from "patch base mismatch" after a forward-apply fails.
2225
+ * Last-resort detection when both generation commit and tree are unreachable.
2226
+ * Scans all commits from HEAD, filters against known lockfile patches, and
2227
+ * skips creation-only commits (squashed history after force push).
2228
+ *
2229
+ * Detected patches use the commit's parent as base_generation so the applicator
2230
+ * can find base file content from the parent's tree (which IS reachable).
2003
2231
  */
2004
- async isPatchAlreadyApplied(patchContent, filePath, content, tempGit, tempDir) {
2005
- const fileDiff = this.extractFileDiff(patchContent, filePath);
2006
- if (!fileDiff) return false;
2007
- try {
2008
- const tempFilePath = (0, import_node_path2.join)(tempDir, filePath);
2009
- await (0, import_promises.mkdir)((0, import_node_path2.dirname)(tempFilePath), { recursive: true });
2010
- await (0, import_promises.writeFile)(tempFilePath, content);
2011
- await tempGit.exec(["add", filePath]);
2012
- await tempGit.exec(["commit", "-m", `check already-applied ${filePath}`, "--allow-empty"]);
2013
- await tempGit.execWithInput(["apply", "--reverse", "--check", "--allow-empty"], fileDiff);
2014
- return true;
2015
- } catch {
2016
- return false;
2232
+ async detectPatchesViaCommitScan() {
2233
+ const lock = this.lockManager.read();
2234
+ const log = await this.git.exec([
2235
+ "log",
2236
+ "--max-count=200",
2237
+ "--format=%H%x00%an%x00%ae%x00%s",
2238
+ "HEAD",
2239
+ "--",
2240
+ this.sdkOutputDir
2241
+ ]);
2242
+ if (!log.trim()) {
2243
+ return { patches: [], revertedPatchIds: [] };
2017
2244
  }
2018
- }
2019
- extractFileDiff(patchContent, filePath) {
2020
- const lines = patchContent.split("\n");
2021
- const diffLines = [];
2022
- let inTargetFile = false;
2023
- for (const line of lines) {
2024
- if (line.startsWith("diff --git")) {
2025
- if (inTargetFile) {
2026
- break;
2027
- }
2028
- if (isDiffLineForFile(line, filePath)) {
2029
- inTargetFile = true;
2030
- diffLines.push(line);
2031
- }
2245
+ const commits = this.parseGitLog(log);
2246
+ const newPatches = [];
2247
+ const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
2248
+ const existingHashes = new Set(lock.patches.map((p) => p.content_hash));
2249
+ const existingCommits = new Set(lock.patches.map((p) => p.original_commit));
2250
+ for (const commit of commits) {
2251
+ if (isGenerationCommit(commit)) {
2032
2252
  continue;
2033
2253
  }
2034
- if (inTargetFile) {
2035
- diffLines.push(line);
2254
+ const parents = await this.git.getCommitParents(commit.sha);
2255
+ if (parents.length > 1) {
2256
+ continue;
2036
2257
  }
2037
- }
2038
- return diffLines.length > 0 ? diffLines.join("\n") + "\n" : null;
2039
- }
2040
- extractRenameSource(patchContent, targetFilePath) {
2041
- const lines = patchContent.split("\n");
2042
- let inTargetFile = false;
2043
- for (const line of lines) {
2044
- if (line.startsWith("diff --git")) {
2045
- if (inTargetFile) break;
2046
- inTargetFile = isDiffLineForFile(line, targetFilePath);
2258
+ if (existingCommits.has(commit.sha)) {
2047
2259
  continue;
2048
2260
  }
2049
- if (!inTargetFile) continue;
2050
- if (line.startsWith("@@")) break;
2051
- if (line.startsWith("rename from ")) {
2052
- return line.slice("rename from ".length);
2261
+ let patchContent;
2262
+ try {
2263
+ patchContent = await this.git.formatPatch(commit.sha);
2264
+ } catch {
2265
+ continue;
2053
2266
  }
2054
- }
2055
- return null;
2056
- }
2057
- extractNewFileFromPatch(patchContent, filePath) {
2058
- const lines = patchContent.split("\n");
2059
- const addedLines = [];
2060
- let inTargetFile = false;
2061
- let inHunk = false;
2062
- let isNewFile = false;
2063
- let noTrailingNewline = false;
2064
- for (const line of lines) {
2065
- if (line.startsWith("diff --git")) {
2066
- if (inTargetFile) break;
2067
- inTargetFile = isDiffLineForFile(line, filePath);
2068
- inHunk = false;
2069
- isNewFile = false;
2267
+ let contentHash = this.computeContentHash(patchContent);
2268
+ if (existingHashes.has(contentHash) || forgottenHashes.has(contentHash)) {
2070
2269
  continue;
2071
2270
  }
2072
- if (!inTargetFile) continue;
2073
- if (!inHunk) {
2074
- if (line === "--- /dev/null" || line.startsWith("new file mode")) {
2075
- isNewFile = true;
2271
+ if (this.isCreationOnlyPatch(patchContent)) {
2272
+ continue;
2273
+ }
2274
+ const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
2275
+ const allScanFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f));
2276
+ const sensitiveScanFiles = allScanFiles.filter((f) => isSensitiveFile(f));
2277
+ const files = allScanFiles.filter((f) => !isSensitiveFile(f));
2278
+ if (sensitiveScanFiles.length > 0) {
2279
+ this.warnings.push(
2280
+ `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveScanFiles.join(", ")}. These files will not be tracked by replay.`
2281
+ );
2282
+ if (files.length > 0) {
2283
+ if (parents.length === 0) {
2284
+ continue;
2285
+ }
2286
+ try {
2287
+ patchContent = await this.git.exec(["diff", parents[0], commit.sha, "--", ...files]);
2288
+ } catch {
2289
+ continue;
2290
+ }
2291
+ if (!patchContent.trim()) continue;
2292
+ contentHash = this.computeContentHash(patchContent);
2076
2293
  }
2077
2294
  }
2078
- if (line.startsWith("@@")) {
2079
- if (!isNewFile) return null;
2080
- inHunk = true;
2295
+ if (files.length === 0) {
2081
2296
  continue;
2082
2297
  }
2083
- if (!inHunk) continue;
2084
- if (line === "\") {
2085
- noTrailingNewline = true;
2298
+ if (parents.length === 0) {
2086
2299
  continue;
2087
2300
  }
2088
- if (line.startsWith("+") && !line.startsWith("+++")) {
2089
- addedLines.push(line.slice(1));
2301
+ const parentSha = parents[0];
2302
+ const theirsSnapshot = {};
2303
+ for (const file of files) {
2304
+ if (isBinaryFile(file)) continue;
2305
+ const content = await this.git.showFile(commit.sha, file).catch(() => null);
2306
+ if (content != null) theirsSnapshot[file] = content;
2090
2307
  }
2308
+ newPatches.push({
2309
+ id: `patch-${commit.sha.slice(0, 8)}`,
2310
+ content_hash: contentHash,
2311
+ original_commit: commit.sha,
2312
+ original_message: commit.message,
2313
+ original_author: `${commit.authorName} <${commit.authorEmail}>`,
2314
+ base_generation: parentSha,
2315
+ files,
2316
+ patch_content: patchContent,
2317
+ ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {}
2318
+ });
2319
+ }
2320
+ newPatches.reverse();
2321
+ return { patches: newPatches, revertedPatchIds: [] };
2322
+ }
2323
+ /**
2324
+ * Check if a format-patch consists entirely of new-file creations.
2325
+ * Used to identify squashed commits after force push, which create all files
2326
+ * from scratch (--- /dev/null) rather than modifying existing files.
2327
+ */
2328
+ isCreationOnlyPatch(patchContent) {
2329
+ const diffOldHeaders = patchContent.split("\n").filter((l) => l.startsWith("--- "));
2330
+ if (diffOldHeaders.length === 0) {
2331
+ return false;
2091
2332
  }
2092
- if (addedLines.length === 0) return null;
2093
- return addedLines.join("\n") + (noTrailingNewline ? "" : "\n");
2333
+ return diffOldHeaders.every((l) => l === "--- /dev/null");
2334
+ }
2335
+ /**
2336
+ * Resolve the best available diff base for a generation record.
2337
+ * Prefers commit_sha, falls back to tree_hash for unreachable commits.
2338
+ * When commitKnownMissing is true, skips the redundant commitExists check.
2339
+ */
2340
+ async resolveDiffBase(gen, commitKnownMissing) {
2341
+ if (!commitKnownMissing && await this.git.commitExists(gen.commit_sha)) {
2342
+ return gen.commit_sha;
2343
+ }
2344
+ if (await this.git.treeExists(gen.tree_hash)) {
2345
+ return gen.tree_hash;
2346
+ }
2347
+ return null;
2348
+ }
2349
+ parseGitLog(log) {
2350
+ return log.trim().split("\n").map((line) => {
2351
+ const [sha, authorName, authorEmail, message] = line.split("\0");
2352
+ return { sha, authorName, authorEmail, message };
2353
+ });
2354
+ }
2355
+ getLastGeneration(lock) {
2356
+ return lock.generations.find((g) => g.commit_sha === lock.current_generation);
2094
2357
  }
2095
2358
  };
2096
- function isBinaryFile(filePath) {
2097
- const ext = (0, import_node_path2.extname)(filePath).toLowerCase();
2098
- return BINARY_EXTENSIONS.has(ext);
2099
- }
2100
- function isDiffLineForFile(diffLine, filePath) {
2101
- const match = diffLine.match(/^diff --git a\/.+ b\/(.+)$/);
2102
- return match !== null && match[1] === filePath;
2103
- }
2104
2359
 
2105
2360
  // src/ReplayCommitter.ts
2106
2361
  var ReplayCommitter = class {
@@ -2132,13 +2387,46 @@ CLI Version: ${options.cliVersion}`;
2132
2387
  await this.git.exec(["commit", "-m", fullMessage]);
2133
2388
  return (await this.git.exec(["rev-parse", "HEAD"])).trim();
2134
2389
  }
2135
- async commitReplay(_patchCount, patches, message) {
2390
+ async commitReplay(_patchCount, patches, message, options) {
2136
2391
  await this.stageAll();
2137
2392
  if (!await this.hasStagedChanges()) {
2138
2393
  return (await this.git.exec(["rev-parse", "HEAD"])).trim();
2139
2394
  }
2140
2395
  let fullMessage = message ?? `[fern-replay] Applied customizations`;
2141
- if (patches && patches.length > 0) {
2396
+ const buckets = options?.buckets;
2397
+ if (buckets) {
2398
+ if (buckets.applied.length > 0) {
2399
+ fullMessage += `
2400
+
2401
+ Patches applied (${buckets.applied.length}):`;
2402
+ for (const p of buckets.applied) {
2403
+ fullMessage += `
2404
+ - ${p.id}: ${p.original_message}`;
2405
+ }
2406
+ }
2407
+ if (buckets.unresolved.length > 0) {
2408
+ fullMessage += `
2409
+
2410
+ Patches with unresolved conflicts (${buckets.unresolved.length}):`;
2411
+ for (const p of buckets.unresolved) {
2412
+ fullMessage += `
2413
+ - ${p.id}: ${p.original_message}`;
2414
+ }
2415
+ fullMessage += `
2416
+ Run \`fern-replay resolve\` to apply these customizations.`;
2417
+ }
2418
+ if (buckets.absorbed.length > 0) {
2419
+ fullMessage += `
2420
+
2421
+ Patches absorbed by generator (${buckets.absorbed.length}):`;
2422
+ for (const p of buckets.absorbed) {
2423
+ fullMessage += `
2424
+ - ${p.id}: ${p.original_message}`;
2425
+ }
2426
+ fullMessage += `
2427
+ The generator now produces these customizations natively.`;
2428
+ }
2429
+ } else if (patches && patches.length > 0) {
2142
2430
  fullMessage += "\n\nPatches replayed:";
2143
2431
  for (const patch of patches) {
2144
2432
  fullMessage += `
@@ -2174,9 +2462,304 @@ CLI Version: ${options.cliVersion}`;
2174
2462
 
2175
2463
  // src/ReplayService.ts
2176
2464
  var import_node_fs2 = require("fs");
2177
- var import_node_path3 = require("path");
2465
+ var import_promises4 = require("fs/promises");
2466
+ var import_node_path5 = require("path");
2178
2467
  var import_minimatch2 = require("minimatch");
2179
2468
  init_GitClient();
2469
+
2470
+ // src/PatchRegionDiff.ts
2471
+ var import_promises2 = require("fs/promises");
2472
+ var import_node_os2 = require("os");
2473
+ var import_node_path3 = require("path");
2474
+ var import_node_child_process = require("child_process");
2475
+ init_HybridReconstruction();
2476
+ function normalizeHunkBody(hunk) {
2477
+ let contextC = 0;
2478
+ let removeC = 0;
2479
+ let addC = 0;
2480
+ const trimmed = [];
2481
+ for (const line of hunk.lines) {
2482
+ const oldUsed = contextC + removeC;
2483
+ const newUsed = contextC + addC;
2484
+ if (oldUsed >= hunk.oldCount && newUsed >= hunk.newCount) break;
2485
+ trimmed.push(line);
2486
+ if (line.type === "context") contextC++;
2487
+ else if (line.type === "remove") removeC++;
2488
+ else if (line.type === "add") addC++;
2489
+ }
2490
+ return { ...hunk, lines: trimmed };
2491
+ }
2492
+ function extractFileDiffForFile(patchContent, filePath) {
2493
+ const lines = patchContent.split("\n");
2494
+ const out = [];
2495
+ let inTarget = false;
2496
+ for (const line of lines) {
2497
+ if (line.startsWith("diff --git")) {
2498
+ if (inTarget) break;
2499
+ if (isDiffLineForFile2(line, filePath)) {
2500
+ inTarget = true;
2501
+ out.push(line);
2502
+ }
2503
+ continue;
2504
+ }
2505
+ if (inTarget) out.push(line);
2506
+ }
2507
+ return out.length > 0 ? out.join("\n") + "\n" : null;
2508
+ }
2509
+ function isDiffLineForFile2(line, filePath) {
2510
+ const match = line.match(/^diff --git a\/(.+) b\/(.+)$/);
2511
+ return match !== null && (match[2] === filePath || match[1] === filePath);
2512
+ }
2513
+ async function computePerPatchDiff(patch, getCurrentGenContent, getWorkingTreeContent) {
2514
+ const fragments = [];
2515
+ const unlocatableFiles = [];
2516
+ for (const file of patch.files) {
2517
+ const fileDiff = extractFileDiffForFile(patch.patch_content, file);
2518
+ if (fileDiff == null) {
2519
+ unlocatableFiles.push(file);
2520
+ continue;
2521
+ }
2522
+ const hunksRaw = parseHunks(fileDiff).map(normalizeHunkBody);
2523
+ const hunks = hunksRaw.filter((h) => {
2524
+ if (h.lines.length === 0) return false;
2525
+ let ctx = 0, rm4 = 0, add = 0;
2526
+ for (const ln of h.lines) {
2527
+ if (ln.type === "context") ctx++;
2528
+ else if (ln.type === "remove") rm4++;
2529
+ else if (ln.type === "add") add++;
2530
+ }
2531
+ return ctx + rm4 === h.oldCount && ctx + add === h.newCount;
2532
+ });
2533
+ if (hunks.length === 0) {
2534
+ if (hunksRaw.length > 0) unlocatableFiles.push(file);
2535
+ continue;
2536
+ }
2537
+ const currentGen = await getCurrentGenContent(file);
2538
+ const workingTree = await getWorkingTreeContent(file);
2539
+ const isPureNewFile = hunks.every((h) => h.oldCount === 0);
2540
+ const isPureDelete = hunks.every((h) => h.newCount === 0);
2541
+ if (currentGen == null) {
2542
+ if (!isPureNewFile) {
2543
+ unlocatableFiles.push(file);
2544
+ continue;
2545
+ }
2546
+ if (workingTree == null) continue;
2547
+ fragments.push(synthesizeNewFileDiff(file, workingTree));
2548
+ continue;
2549
+ }
2550
+ if (isPureNewFile) {
2551
+ if (workingTree == null) {
2552
+ fragments.push(synthesizeDeleteFileDiff(file, currentGen));
2553
+ continue;
2554
+ }
2555
+ if (workingTree === currentGen) {
2556
+ continue;
2557
+ }
2558
+ const fullDiff = await unifiedDiff(currentGen, workingTree, file);
2559
+ if (fullDiff && fullDiff.trim()) fragments.push(fullDiff);
2560
+ continue;
2561
+ }
2562
+ if (workingTree == null) {
2563
+ if (!isPureDelete) {
2564
+ unlocatableFiles.push(file);
2565
+ continue;
2566
+ }
2567
+ fragments.push(synthesizeDeleteFileDiff(file, currentGen));
2568
+ continue;
2569
+ }
2570
+ const currentGenLines = splitLines(currentGen);
2571
+ const workingTreeLines = splitLines(workingTree);
2572
+ const locInCurrentGenRaw = locateHunksInOurs(hunks, currentGenLines);
2573
+ const locInWorkingTreeRaw = locateHunksInOurs(hunks, workingTreeLines);
2574
+ if (locInCurrentGenRaw == null || locInWorkingTreeRaw == null) {
2575
+ unlocatableFiles.push(file);
2576
+ continue;
2577
+ }
2578
+ const locInCurrentGen = locInCurrentGenRaw.map(
2579
+ (l) => resolveSpan(l, currentGenLines, "old")
2580
+ );
2581
+ const locInWorkingTree = locInWorkingTreeRaw.map(
2582
+ (l) => resolveSpan(l, workingTreeLines, "new")
2583
+ );
2584
+ const overlay = overlayRegions(
2585
+ currentGenLines,
2586
+ locInCurrentGen,
2587
+ workingTreeLines,
2588
+ locInWorkingTree
2589
+ );
2590
+ if (overlay === null) {
2591
+ unlocatableFiles.push(file);
2592
+ continue;
2593
+ }
2594
+ if (overlay === currentGen) {
2595
+ continue;
2596
+ }
2597
+ const fileUnifiedDiff = await unifiedDiff(currentGen, overlay, file);
2598
+ if (fileUnifiedDiff && fileUnifiedDiff.trim()) {
2599
+ fragments.push(fileUnifiedDiff);
2600
+ }
2601
+ }
2602
+ return {
2603
+ diff: fragments.join(""),
2604
+ unlocatableFiles
2605
+ };
2606
+ }
2607
+ async function computePerPatchDiffWithFallback(patch, getCurrentGenContent, getWorkingTreeContent, runCumulativeDiff) {
2608
+ const ppDiff = await computePerPatchDiff(
2609
+ patch,
2610
+ getCurrentGenContent,
2611
+ getWorkingTreeContent
2612
+ );
2613
+ if (ppDiff.unlocatableFiles.length === 0) {
2614
+ return { diff: ppDiff.diff, hadFallback: false, fallbackFiles: [] };
2615
+ }
2616
+ const cumulative = await runCumulativeDiff(ppDiff.unlocatableFiles);
2617
+ if (cumulative === null) {
2618
+ return null;
2619
+ }
2620
+ const merged = ppDiff.diff + (cumulative.trim() ? cumulative : "");
2621
+ return { diff: merged, hadFallback: true, fallbackFiles: ppDiff.unlocatableFiles };
2622
+ }
2623
+ function splitLines(content) {
2624
+ return content.split("\n");
2625
+ }
2626
+ function resolveSpan(loc, oursLines, prefer) {
2627
+ const { hunk, oursOffset } = loc;
2628
+ const oldSide = [];
2629
+ const newSide = [];
2630
+ for (const line of hunk.lines) {
2631
+ if (line.type === "context") {
2632
+ oldSide.push(line.content);
2633
+ newSide.push(line.content);
2634
+ } else if (line.type === "remove") {
2635
+ oldSide.push(line.content);
2636
+ } else if (line.type === "add") {
2637
+ newSide.push(line.content);
2638
+ }
2639
+ }
2640
+ const oldFits = sequenceMatches(oldSide, oursLines, oursOffset);
2641
+ const newFits = sequenceMatches(newSide, oursLines, oursOffset);
2642
+ if (prefer === "old") {
2643
+ if (oldFits) return { ...loc, oursSpan: hunk.oldCount };
2644
+ if (newFits) return { ...loc, oursSpan: hunk.newCount };
2645
+ } else {
2646
+ if (newFits) return { ...loc, oursSpan: hunk.newCount };
2647
+ if (oldFits) return { ...loc, oursSpan: hunk.oldCount };
2648
+ }
2649
+ return loc;
2650
+ }
2651
+ function sequenceMatches(needle, haystack, offset) {
2652
+ if (offset + needle.length > haystack.length) return false;
2653
+ for (let i = 0; i < needle.length; i++) {
2654
+ if (haystack[offset + i] !== needle[i]) return false;
2655
+ }
2656
+ return true;
2657
+ }
2658
+ function overlayRegions(currentGenLines, locInCurrentGen, workingTreeLines, locInWorkingTree) {
2659
+ if (locInCurrentGen.length !== locInWorkingTree.length) {
2660
+ return null;
2661
+ }
2662
+ const out = [];
2663
+ let cursor = 0;
2664
+ for (let i = 0; i < locInCurrentGen.length; i++) {
2665
+ const cg = locInCurrentGen[i];
2666
+ const wt = locInWorkingTree[i];
2667
+ if (cg.oursOffset > cursor) {
2668
+ out.push(...currentGenLines.slice(cursor, cg.oursOffset));
2669
+ }
2670
+ out.push(...workingTreeLines.slice(wt.oursOffset, wt.oursOffset + wt.oursSpan));
2671
+ cursor = cg.oursOffset + cg.oursSpan;
2672
+ }
2673
+ if (cursor < currentGenLines.length) {
2674
+ out.push(...currentGenLines.slice(cursor));
2675
+ }
2676
+ return out.join("\n");
2677
+ }
2678
+ async function unifiedDiff(beforeContent, afterContent, filePath) {
2679
+ if (beforeContent === afterContent) return "";
2680
+ const tmp = await (0, import_promises2.mkdtemp)((0, import_node_path3.join)((0, import_node_os2.tmpdir)(), "fern-replay-pp-"));
2681
+ try {
2682
+ const beforePath = (0, import_node_path3.join)(tmp, "before");
2683
+ const afterPath = (0, import_node_path3.join)(tmp, "after");
2684
+ await (0, import_promises2.writeFile)(beforePath, beforeContent, "utf-8");
2685
+ await (0, import_promises2.writeFile)(afterPath, afterContent, "utf-8");
2686
+ const raw = await runGitDiffNoIndex(beforePath, afterPath);
2687
+ if (!raw.trim()) return "";
2688
+ return rewriteDiffHeaders(raw, filePath);
2689
+ } finally {
2690
+ await (0, import_promises2.rm)(tmp, { recursive: true, force: true });
2691
+ }
2692
+ }
2693
+ function runGitDiffNoIndex(beforePath, afterPath) {
2694
+ return new Promise((resolve2, reject) => {
2695
+ const proc = (0, import_node_child_process.spawn)("git", ["diff", "--no-index", "--", beforePath, afterPath]);
2696
+ let stdout = "";
2697
+ let stderr = "";
2698
+ proc.stdout.on("data", (d) => stdout += d.toString());
2699
+ proc.stderr.on("data", (d) => stderr += d.toString());
2700
+ proc.on("close", (code) => {
2701
+ if (code === 0 || code === 1) resolve2(stdout);
2702
+ else reject(new Error(`git diff --no-index failed (code ${code}): ${stderr}`));
2703
+ });
2704
+ });
2705
+ }
2706
+ function rewriteDiffHeaders(raw, filePath) {
2707
+ const lines = raw.split("\n");
2708
+ const out = [];
2709
+ let seenFirstHeader = false;
2710
+ for (const line of lines) {
2711
+ if (line.startsWith("diff --git ")) {
2712
+ out.push(`diff --git a/${filePath} b/${filePath}`);
2713
+ seenFirstHeader = true;
2714
+ continue;
2715
+ }
2716
+ if (!seenFirstHeader) {
2717
+ continue;
2718
+ }
2719
+ if (line.startsWith("--- ")) {
2720
+ out.push(`--- a/${filePath}`);
2721
+ continue;
2722
+ }
2723
+ if (line.startsWith("+++ ")) {
2724
+ out.push(`+++ b/${filePath}`);
2725
+ continue;
2726
+ }
2727
+ out.push(line);
2728
+ }
2729
+ return out.join("\n");
2730
+ }
2731
+ function synthesizeNewFileDiff(file, content) {
2732
+ const lines = content.split("\n");
2733
+ const hasTrailingNewline = content.endsWith("\n");
2734
+ const bodyLines = hasTrailingNewline ? lines.slice(0, -1) : lines;
2735
+ const header = [
2736
+ `diff --git a/${file} b/${file}`,
2737
+ "new file mode 100644",
2738
+ "--- /dev/null",
2739
+ `+++ b/${file}`,
2740
+ `@@ -0,0 +1,${bodyLines.length} @@`
2741
+ ].join("\n");
2742
+ const body = bodyLines.map((l) => `+${l}`).join("\n");
2743
+ const trailer = hasTrailingNewline ? "" : "\n\";
2744
+ return header + "\n" + body + trailer + "\n";
2745
+ }
2746
+ function synthesizeDeleteFileDiff(file, content) {
2747
+ const lines = content.split("\n");
2748
+ const hasTrailingNewline = content.endsWith("\n");
2749
+ const bodyLines = hasTrailingNewline ? lines.slice(0, -1) : lines;
2750
+ const header = [
2751
+ `diff --git a/${file} b/${file}`,
2752
+ "deleted file mode 100644",
2753
+ `--- a/${file}`,
2754
+ "+++ /dev/null",
2755
+ `@@ -1,${bodyLines.length} +0,0 @@`
2756
+ ].join("\n");
2757
+ const body = bodyLines.map((l) => `-${l}`).join("\n");
2758
+ const trailer = hasTrailingNewline ? "" : "\n\";
2759
+ return header + "\n" + body + trailer + "\n";
2760
+ }
2761
+
2762
+ // src/ReplayService.ts
2180
2763
  var ReplayService = class {
2181
2764
  git;
2182
2765
  detector;
@@ -2570,6 +3153,7 @@ var ReplayService = class {
2570
3153
  if (newPatches.length > 0) {
2571
3154
  results = await this.applicator.applyPatches(newPatches);
2572
3155
  this._lastApplyResults = results;
3156
+ this.recordDroppedContextLineWarnings(results);
2573
3157
  this.revertConflictingFiles(results);
2574
3158
  for (const result of results) {
2575
3159
  if (result.status === "conflict") {
@@ -2588,7 +3172,8 @@ var ReplayService = class {
2588
3172
  if (newPatches.length > 0) {
2589
3173
  if (!options?.stageOnly) {
2590
3174
  const appliedCount = results.filter((r) => r.status === "applied").length;
2591
- await this.committer.commitReplay(appliedCount, newPatches);
3175
+ const buckets = computeCommitBuckets(results, rebaseCounts.absorbedPatchIds, newPatches);
3176
+ await this.committer.commitReplay(appliedCount, newPatches, void 0, { buckets });
2592
3177
  } else {
2593
3178
  await this.committer.stageAll();
2594
3179
  }
@@ -2651,12 +3236,16 @@ var ReplayService = class {
2651
3236
  (p) => preRebasePatchIds.has(p.id) && !postRebasePatchIds.has(p.id)
2652
3237
  );
2653
3238
  existingPatches = this.lockManager.getPatches();
2654
- const seenHashes = /* @__PURE__ */ new Set();
3239
+ const seenHashes = /* @__PURE__ */ new Map();
2655
3240
  for (const p of existingPatches) {
2656
- if (seenHashes.has(p.content_hash)) {
3241
+ const priorPatchId = seenHashes.get(p.content_hash);
3242
+ if (priorPatchId !== void 0) {
2657
3243
  this.lockManager.removePatch(p.id);
3244
+ this.warnings.push(
3245
+ `Consolidated patch ${p.id} (commit ${(p.original_commit || "<missing>").slice(0, 7)}) into prior patch ${priorPatchId}: byte-identical patch_content. Per-commit attribution preserved in git log; lockfile collapsed to avoid duplicate-apply corruption on next regen.`
3246
+ );
2658
3247
  } else {
2659
- seenHashes.add(p.content_hash);
3248
+ seenHashes.set(p.content_hash, p.id);
2660
3249
  }
2661
3250
  }
2662
3251
  existingPatches = this.lockManager.getPatches();
@@ -2717,6 +3306,7 @@ var ReplayService = class {
2717
3306
  const genSha = prep._prepared.genSha;
2718
3307
  const results = await this.applicator.applyPatches(allPatches);
2719
3308
  this._lastApplyResults = results;
3309
+ this.recordDroppedContextLineWarnings(results);
2720
3310
  this.revertConflictingFiles(results);
2721
3311
  for (const result of results) {
2722
3312
  if (result.status === "conflict") {
@@ -2744,7 +3334,8 @@ var ReplayService = class {
2744
3334
  await this.committer.stageAll();
2745
3335
  } else {
2746
3336
  const appliedCount = results.filter((r) => r.status === "applied").length;
2747
- await this.committer.commitReplay(appliedCount, allPatches);
3337
+ const buckets = computeCommitBuckets(results, rebaseCounts.absorbedPatchIds, allPatches);
3338
+ await this.committer.commitReplay(appliedCount, allPatches, void 0, { buckets });
2748
3339
  }
2749
3340
  const warnings = [...detectorWarnings, ...this.warnings];
2750
3341
  return this.buildReport(
@@ -2768,7 +3359,7 @@ var ReplayService = class {
2768
3359
  let repointed = 0;
2769
3360
  let contentRebased = 0;
2770
3361
  let keptAsUserOwned = 0;
2771
- const seenContentHashes = /* @__PURE__ */ new Set();
3362
+ const seenContentHashes = /* @__PURE__ */ new Map();
2772
3363
  const absorbedPatchIds = /* @__PURE__ */ new Set();
2773
3364
  const fernignorePatterns = this.readFernignorePatterns();
2774
3365
  for (const result of results) {
@@ -2782,6 +3373,23 @@ var ReplayService = class {
2782
3373
  }
2783
3374
  }
2784
3375
  }
3376
+ const conflictedFilePaths = /* @__PURE__ */ new Set();
3377
+ for (const r of results) {
3378
+ if (r.status !== "conflict") continue;
3379
+ if (r.fileResults) {
3380
+ for (const fr of r.fileResults) {
3381
+ if (fr.status !== "conflict") continue;
3382
+ conflictedFilePaths.add(fr.file);
3383
+ if (r.resolvedFiles) {
3384
+ for (const [orig, resolved] of Object.entries(r.resolvedFiles)) {
3385
+ if (resolved === fr.file) conflictedFilePaths.add(orig);
3386
+ }
3387
+ }
3388
+ }
3389
+ } else {
3390
+ for (const f of r.patch.files) conflictedFilePaths.add(f);
3391
+ }
3392
+ }
2785
3393
  for (const result of results) {
2786
3394
  if (result.status === "conflict" && result.fileResults) {
2787
3395
  await this.trimAbsorbedFiles(result, currentGenSha);
@@ -2818,17 +3426,27 @@ var ReplayService = class {
2818
3426
  if (allUserOwned && patch.files.length > 0) {
2819
3427
  const baseChanged = patch.base_generation !== currentGenSha;
2820
3428
  const userDiff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
3429
+ let userDiffSnapshot = null;
2821
3430
  if (userDiff != null && userDiff.trim()) {
2822
3431
  const newHash = this.detector.computeContentHash(userDiff);
2823
3432
  patch.patch_content = userDiff;
2824
3433
  patch.content_hash = newHash;
3434
+ const snap = await this.readSnapshotFromDisk(patch.files);
3435
+ if (Object.keys(snap).length > 0) {
3436
+ userDiffSnapshot = snap;
3437
+ patch.theirs_snapshot = snap;
3438
+ }
2825
3439
  }
2826
3440
  patch.base_generation = currentGenSha;
2827
3441
  try {
2828
3442
  this.lockManager.updatePatch(patch.id, {
2829
3443
  base_generation: currentGenSha,
2830
3444
  ...!patch.user_owned ? { user_owned: true } : {},
2831
- ...userDiff != null && userDiff.trim() ? { patch_content: patch.patch_content, content_hash: patch.content_hash } : {}
3445
+ ...userDiff != null && userDiff.trim() ? {
3446
+ patch_content: patch.patch_content,
3447
+ content_hash: patch.content_hash,
3448
+ ...userDiffSnapshot != null && Object.keys(userDiffSnapshot).length > 0 ? { theirs_snapshot: userDiffSnapshot } : {}
3449
+ } : {}
2832
3450
  });
2833
3451
  } catch {
2834
3452
  }
@@ -2849,6 +3467,18 @@ var ReplayService = class {
2849
3467
  keptAsUserOwned++;
2850
3468
  continue;
2851
3469
  }
3470
+ const overlapsConflicted = patch.files.some((f) => conflictedFilePaths.has(f));
3471
+ if (overlapsConflicted) {
3472
+ patch.status = "unresolved";
3473
+ try {
3474
+ this.lockManager.updatePatch(patch.id, { status: "unresolved" });
3475
+ } catch {
3476
+ }
3477
+ this.warnings.push(
3478
+ `Patch ${patch.id} (${patch.original_message}) was preserved as unresolved because an earlier patch conflicted on the same file(s) (${patch.files.join(", ")}). Run \`fern replay resolve\` to apply this customization manually.`
3479
+ );
3480
+ continue;
3481
+ }
2852
3482
  this.lockManager.removePatch(patch.id);
2853
3483
  absorbedPatchIds.add(patch.id);
2854
3484
  absorbed++;
@@ -2864,21 +3494,34 @@ var ReplayService = class {
2864
3494
  continue;
2865
3495
  }
2866
3496
  const newContentHash = this.detector.computeContentHash(diff);
2867
- if (seenContentHashes.has(newContentHash)) {
3497
+ const priorPatchId = seenContentHashes.get(newContentHash);
3498
+ if (priorPatchId !== void 0) {
2868
3499
  this.lockManager.removePatch(patch.id);
2869
3500
  absorbedPatchIds.add(patch.id);
2870
3501
  absorbed++;
3502
+ if (priorPatchId !== patch.id) {
3503
+ this.warnings.push(
3504
+ `Consolidated patch ${patch.id} (commit ${(patch.original_commit || "<missing>").slice(0, 7)}) into prior patch ${priorPatchId}: byte-identical patch_content after rebase. Per-commit attribution preserved in git log.`
3505
+ );
3506
+ }
2871
3507
  continue;
3508
+ } else {
3509
+ seenContentHashes.set(newContentHash, patch.id);
2872
3510
  }
2873
- seenContentHashes.add(newContentHash);
2874
3511
  patch.base_generation = currentGenSha;
2875
3512
  patch.patch_content = diff;
2876
3513
  patch.content_hash = newContentHash;
3514
+ const snapshot = await this.readSnapshotFromDisk(patch.files);
3515
+ const snapshotIsNonEmpty = Object.keys(snapshot).length > 0;
3516
+ if (snapshotIsNonEmpty) {
3517
+ patch.theirs_snapshot = snapshot;
3518
+ }
2877
3519
  try {
2878
3520
  this.lockManager.updatePatch(patch.id, {
2879
3521
  base_generation: currentGenSha,
2880
3522
  patch_content: diff,
2881
- content_hash: newContentHash
3523
+ content_hash: newContentHash,
3524
+ ...snapshotIsNonEmpty ? { theirs_snapshot: snapshot } : {}
2882
3525
  });
2883
3526
  } catch {
2884
3527
  }
@@ -2888,6 +3531,112 @@ var ReplayService = class {
2888
3531
  }
2889
3532
  return { absorbed, repointed, contentRebased, keptAsUserOwned, absorbedPatchIds };
2890
3533
  }
3534
+ /**
3535
+ * Read THEIRS snapshot (post-customer-edit content) for a list of files
3536
+ * from the working tree on disk. Used by `rebasePatches` after a clean
3537
+ * apply — disk = correctly-merged customer state at the new generation.
3538
+ * Skips binary files (matches `mergeFile`/`populateAccumulatorForPatch`
3539
+ * policy — binary content as `utf-8` is lossy and snapshots of it would
3540
+ * never be used during apply anyway).
3541
+ */
3542
+ async readSnapshotFromDisk(files) {
3543
+ const snapshot = {};
3544
+ for (const file of files) {
3545
+ if (isBinaryFile(file)) continue;
3546
+ try {
3547
+ const content = await (0, import_promises4.readFile)((0, import_node_path5.join)(this.outputDir, file), "utf-8");
3548
+ snapshot[file] = content;
3549
+ } catch {
3550
+ }
3551
+ }
3552
+ return snapshot;
3553
+ }
3554
+ /**
3555
+ * Read THEIRS snapshot from a git tree-ish (commit, branch, or tree).
3556
+ * Used when the diff that produced `patch_content` was relative to that
3557
+ * tree (e.g., `git diff currentGen HEAD --` → snapshot from HEAD).
3558
+ * Skips binary files.
3559
+ */
3560
+ async readSnapshotFromTree(treeRef, files) {
3561
+ const snapshot = {};
3562
+ for (const file of files) {
3563
+ if (isBinaryFile(file)) continue;
3564
+ const content = await this.git.showFile(treeRef, file).catch(() => null);
3565
+ if (content != null) snapshot[file] = content;
3566
+ }
3567
+ return snapshot;
3568
+ }
3569
+ /**
3570
+ * One-shot auto-migration: backfill `theirs_snapshot` on existing
3571
+ * patches that predate the snapshot encoding. Computes the snapshot by
3572
+ * applying the patch's `patch_content` to its `base_generation` tree —
3573
+ * yielding the patch's INDIVIDUAL contribution.
3574
+ *
3575
+ * **Skips patches sharing files with other patches.** HEAD's content
3576
+ * for a file shared by N patches is cumulative across all of them, so
3577
+ * a HEAD-fallback would falsely encode other patches' contributions
3578
+ * into one patch's snapshot — breaking FER-9525 cross-patch isolation
3579
+ * if the snapshot fallback later fires. Per-patch snapshots only
3580
+ * make sense when the patch owns its file. Multi-patch-same-file
3581
+ * legacy lockfiles keep using line-anchored reconstruction (the
3582
+ * existing path); they're no worse than pre-upgrade.
3583
+ *
3584
+ * **Does not fall back to HEAD when BASE-reconstruction fails.** Same
3585
+ * reason — HEAD content can be cumulative. Patches whose
3586
+ * reconstruction fails just don't get a snapshot; legacy paths
3587
+ * (PR #73's by-association gate, accumulator fallback) handle them.
3588
+ *
3589
+ * Skips patches that already have a snapshot.
3590
+ */
3591
+ async migratePatchesToSnapshot(patches) {
3592
+ const { applyPatchToContent: applyPatchToContent2 } = await Promise.resolve().then(() => (init_PatchApplyTheirs(), PatchApplyTheirs_exports));
3593
+ const fileFreq = /* @__PURE__ */ new Map();
3594
+ for (const p of patches) {
3595
+ for (const f of p.files) {
3596
+ fileFreq.set(f, (fileFreq.get(f) ?? 0) + 1);
3597
+ }
3598
+ }
3599
+ let migrated = 0;
3600
+ let skipped = 0;
3601
+ for (const patch of patches) {
3602
+ if (patch.theirs_snapshot != null) continue;
3603
+ const sharesAnyFile = patch.files.some((f) => (fileFreq.get(f) ?? 0) > 1);
3604
+ if (sharesAnyFile) {
3605
+ skipped++;
3606
+ continue;
3607
+ }
3608
+ const snapshot = {};
3609
+ for (const file of patch.files) {
3610
+ if (isBinaryFile(file)) continue;
3611
+ try {
3612
+ const base = await this.git.showFile(patch.base_generation, file).catch(() => null);
3613
+ if (base != null) {
3614
+ const content = await applyPatchToContent2(base, patch.patch_content, file);
3615
+ if (content != null) snapshot[file] = content;
3616
+ }
3617
+ } catch {
3618
+ }
3619
+ }
3620
+ if (Object.keys(snapshot).length > 0) {
3621
+ patch.theirs_snapshot = snapshot;
3622
+ try {
3623
+ this.lockManager.updatePatch(patch.id, { theirs_snapshot: snapshot });
3624
+ migrated++;
3625
+ } catch {
3626
+ }
3627
+ }
3628
+ }
3629
+ if (migrated > 0) {
3630
+ this.warnings.push(
3631
+ `Migrated ${migrated} patch(es) to snapshot encoding. Future regens use 3-way merge with stored THEIRS, robust to generator structural changes.`
3632
+ );
3633
+ }
3634
+ if (skipped > 0) {
3635
+ this.warnings.push(
3636
+ `Skipped snapshot migration for ${skipped} patch(es) sharing files with other patches. These continue to use line-anchored reconstruction (the existing path).`
3637
+ );
3638
+ }
3639
+ }
2891
3640
  /**
2892
3641
  * Determine whether `file` is user-owned (never produced by the generator).
2893
3642
  *
@@ -3008,6 +3757,20 @@ var ReplayService = class {
3008
3757
  this.lockManager.clearReplaySkippedAt();
3009
3758
  return { conflictResolved: 0, conflictAbsorbed: 0, contentRefreshed: 0 };
3010
3759
  }
3760
+ await this.migratePatchesToSnapshot(patches);
3761
+ const headMessage = await this.git.exec(["log", "-1", "--format=%s%n%aN%n%ae", "HEAD"]).catch(() => "");
3762
+ const [headMsgLine, headAuthorName, headAuthorEmail] = headMessage.split("\n");
3763
+ const headIsGeneration = headMsgLine ? isGenerationCommit({
3764
+ sha: "HEAD",
3765
+ authorName: headAuthorName ?? "",
3766
+ authorEmail: headAuthorEmail ?? "",
3767
+ message: headMsgLine
3768
+ }) : false;
3769
+ const headParentChangedFiles = async (patchFiles) => {
3770
+ if (patchFiles.length === 0) return /* @__PURE__ */ new Set();
3771
+ const diff = await this.git.exec(["diff", "--name-only", "HEAD~1", "HEAD", "--", ...patchFiles]).catch(() => "");
3772
+ return new Set(diff.trim().split("\n").filter(Boolean));
3773
+ };
3011
3774
  let conflictResolved = 0;
3012
3775
  let conflictAbsorbed = 0;
3013
3776
  let contentRefreshed = 0;
@@ -3044,10 +3807,23 @@ var ReplayService = class {
3044
3807
  delete patch.status;
3045
3808
  continue;
3046
3809
  }
3810
+ if (headIsGeneration) {
3811
+ const parentChangedFiles = await headParentChangedFiles(patch.files);
3812
+ const headTouchedSource = patch.files.some((f) => parentChangedFiles.has(f));
3813
+ if (headTouchedSource) {
3814
+ continue;
3815
+ }
3816
+ }
3047
3817
  if (patch.base_generation === currentGen) {
3048
3818
  try {
3049
- const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
3050
- if (diff === null) continue;
3819
+ const ppResult = await computePerPatchDiffWithFallback(
3820
+ patch,
3821
+ (f) => this.git.showFile(currentGen, f),
3822
+ (f) => this.git.showFile("HEAD", f),
3823
+ (files) => this.git.exec(["diff", currentGen, "HEAD", "--", ...files]).catch(() => null)
3824
+ );
3825
+ if (ppResult === null) continue;
3826
+ const diff = ppResult.diff;
3051
3827
  if (!diff.trim()) {
3052
3828
  if (await allPatchFilesUserOwned(patch)) continue;
3053
3829
  this.lockManager.removePatch(patch.id);
@@ -3074,10 +3850,12 @@ var ReplayService = class {
3074
3850
  if (newFiles.length === 0) {
3075
3851
  this.lockManager.removePatch(patch.id);
3076
3852
  } else {
3853
+ const snapshot = await this.readSnapshotFromTree("HEAD", newFiles);
3077
3854
  this.lockManager.updatePatch(patch.id, {
3078
3855
  patch_content: diff,
3079
3856
  content_hash: newContentHash,
3080
- files: newFiles
3857
+ files: newFiles,
3858
+ ...Object.keys(snapshot).length > 0 ? { theirs_snapshot: snapshot } : {}
3081
3859
  });
3082
3860
  }
3083
3861
  contentRefreshed++;
@@ -3089,8 +3867,14 @@ var ReplayService = class {
3089
3867
  try {
3090
3868
  const markerFiles = await this.git.exec(["grep", "-l", "<<<<<<< Generated", "HEAD", "--", ...patch.files]).catch(() => "");
3091
3869
  if (markerFiles.trim()) continue;
3092
- const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
3093
- if (diff === null) continue;
3870
+ const ppResult = await computePerPatchDiffWithFallback(
3871
+ patch,
3872
+ (f) => this.git.showFile(currentGen, f),
3873
+ (f) => this.git.showFile("HEAD", f),
3874
+ (files) => this.git.exec(["diff", currentGen, "HEAD", "--", ...files]).catch(() => null)
3875
+ );
3876
+ if (ppResult === null) continue;
3877
+ const diff = ppResult.diff;
3094
3878
  if (!diff.trim()) {
3095
3879
  if (await allPatchFilesUserOwned(patch)) {
3096
3880
  try {
@@ -3104,10 +3888,12 @@ var ReplayService = class {
3104
3888
  continue;
3105
3889
  }
3106
3890
  const newContentHash = this.detector.computeContentHash(diff);
3891
+ const snapshot = await this.readSnapshotFromTree("HEAD", patch.files);
3107
3892
  this.lockManager.updatePatch(patch.id, {
3108
3893
  base_generation: currentGen,
3109
3894
  patch_content: diff,
3110
- content_hash: newContentHash
3895
+ content_hash: newContentHash,
3896
+ ...Object.keys(snapshot).length > 0 ? { theirs_snapshot: snapshot } : {}
3111
3897
  });
3112
3898
  conflictResolved++;
3113
3899
  } catch {
@@ -3115,6 +3901,32 @@ var ReplayService = class {
3115
3901
  }
3116
3902
  return { conflictResolved, conflictAbsorbed, contentRefreshed };
3117
3903
  }
3904
+ /**
3905
+ * After applyPatches(), surface a warning for each (patch × file) where
3906
+ * diff3 silently dropped lines that were unchanged context in the
3907
+ * customer's THEIRS. The deletion stays on disk (correct diff3 outcome),
3908
+ * but the customer must learn so they can re-add the lines if they
3909
+ * relied on them. This is the "surface or preserve, never silently drop"
3910
+ * contract for legitimate-deletion cases.
3911
+ */
3912
+ recordDroppedContextLineWarnings(results) {
3913
+ const PREVIEW_COUNT = 5;
3914
+ for (const result of results) {
3915
+ if (!result.fileResults) continue;
3916
+ for (const fr of result.fileResults) {
3917
+ const dropped = fr.droppedContextLines;
3918
+ if (!dropped || dropped.length === 0) continue;
3919
+ const preview = dropped.slice(0, PREVIEW_COUNT).map((line) => ` ${line}`).join("\n");
3920
+ const more = dropped.length > PREVIEW_COUNT ? `
3921
+ ... and ${dropped.length - PREVIEW_COUNT} more` : "";
3922
+ this.warnings.push(
3923
+ `${fr.file}: ${dropped.length} line(s) that appeared as unchanged context in your customization were deleted by the new generator output. The merge followed the deletion. First lines deleted:
3924
+ ${preview}${more}
3925
+ If you want to keep these lines, restore them in a follow-up commit and re-run replay; otherwise this warning can be safely ignored.`
3926
+ );
3927
+ }
3928
+ }
3929
+ }
3118
3930
  /**
3119
3931
  * After applyPatches(), strip conflict markers from conflicting files
3120
3932
  * so only clean content is committed. Keeps the Generated (OURS) side.
@@ -3124,7 +3936,7 @@ var ReplayService = class {
3124
3936
  if (result.status !== "conflict" || !result.fileResults) continue;
3125
3937
  for (const fileResult of result.fileResults) {
3126
3938
  if (fileResult.status !== "conflict") continue;
3127
- const filePath = (0, import_node_path3.join)(this.outputDir, fileResult.file);
3939
+ const filePath = (0, import_node_path5.join)(this.outputDir, fileResult.file);
3128
3940
  try {
3129
3941
  const content = (0, import_node_fs2.readFileSync)(filePath, "utf-8");
3130
3942
  const stripped = stripConflictMarkers(content);
@@ -3154,7 +3966,7 @@ var ReplayService = class {
3154
3966
  }
3155
3967
  }
3156
3968
  readFernignorePatterns() {
3157
- const fernignorePath = (0, import_node_path3.join)(this.outputDir, ".fernignore");
3969
+ const fernignorePath = (0, import_node_path5.join)(this.outputDir, ".fernignore");
3158
3970
  if (!(0, import_node_fs2.existsSync)(fernignorePath)) return [];
3159
3971
  return (0, import_node_fs2.readFileSync)(fernignorePath, "utf-8").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
3160
3972
  }
@@ -3172,6 +3984,9 @@ var ReplayService = class {
3172
3984
  };
3173
3985
  }).filter((d) => d.files.length > 0);
3174
3986
  const partialCount = conflictDetails.filter((d) => d.cleanFiles && d.cleanFiles.length > 0).length;
3987
+ const unresolvedResults = results.filter(
3988
+ (r) => r.status === "conflict" || r.patch.status === "unresolved"
3989
+ );
3175
3990
  return {
3176
3991
  flow,
3177
3992
  patchesDetected: patches.length,
@@ -3188,7 +4003,7 @@ var ReplayService = class {
3188
4003
  patchesRefreshed: preRebaseCounts && preRebaseCounts.contentRefreshed > 0 ? preRebaseCounts.contentRefreshed : void 0,
3189
4004
  conflicts: conflictResults.flatMap((r) => r.fileResults?.filter((f) => f.status === "conflict") ?? []),
3190
4005
  conflictDetails: conflictDetails.length > 0 ? conflictDetails : void 0,
3191
- unresolvedPatches: conflictResults.length > 0 ? conflictResults.map((r) => ({
4006
+ unresolvedPatches: unresolvedResults.length > 0 ? unresolvedResults.map((r) => ({
3192
4007
  patchId: r.patch.id,
3193
4008
  patchMessage: r.patch.original_message,
3194
4009
  files: r.patch.files,
@@ -3199,11 +4014,38 @@ var ReplayService = class {
3199
4014
  };
3200
4015
  }
3201
4016
  };
4017
+ function computeCommitBuckets(results, absorbedPatchIds, patchesPassedToCommit) {
4018
+ const resultByPatchId = /* @__PURE__ */ new Map();
4019
+ for (const r of results) resultByPatchId.set(r.patch.id, r);
4020
+ const applied = [];
4021
+ const unresolved = [];
4022
+ const absorbed = [];
4023
+ for (const patch of patchesPassedToCommit) {
4024
+ if (absorbedPatchIds.has(patch.id)) {
4025
+ absorbed.push(patch);
4026
+ continue;
4027
+ }
4028
+ const r = resultByPatchId.get(patch.id);
4029
+ if (!r) {
4030
+ applied.push(patch);
4031
+ continue;
4032
+ }
4033
+ if (r.status === "conflict" || patch.status === "unresolved") {
4034
+ unresolved.push(patch);
4035
+ continue;
4036
+ }
4037
+ if (r.status === "applied") {
4038
+ applied.push(patch);
4039
+ continue;
4040
+ }
4041
+ }
4042
+ return { applied, unresolved, absorbed };
4043
+ }
3202
4044
 
3203
4045
  // src/FernignoreMigrator.ts
3204
4046
  var import_node_crypto2 = require("crypto");
3205
4047
  var import_node_fs3 = require("fs");
3206
- var import_node_path4 = require("path");
4048
+ var import_node_path6 = require("path");
3207
4049
  var import_minimatch3 = require("minimatch");
3208
4050
  var import_yaml2 = require("yaml");
3209
4051
  var FernignoreMigrator = class {
@@ -3216,10 +4058,10 @@ var FernignoreMigrator = class {
3216
4058
  this.outputDir = outputDir;
3217
4059
  }
3218
4060
  fernignoreExists() {
3219
- return (0, import_node_fs3.existsSync)((0, import_node_path4.join)(this.outputDir, ".fernignore"));
4061
+ return (0, import_node_fs3.existsSync)((0, import_node_path6.join)(this.outputDir, ".fernignore"));
3220
4062
  }
3221
4063
  readFernignorePatterns() {
3222
- const fernignorePath = (0, import_node_path4.join)(this.outputDir, ".fernignore");
4064
+ const fernignorePath = (0, import_node_path6.join)(this.outputDir, ".fernignore");
3223
4065
  if (!(0, import_node_fs3.existsSync)(fernignorePath)) {
3224
4066
  return [];
3225
4067
  }
@@ -3271,6 +4113,12 @@ var FernignoreMigrator = class {
3271
4113
  if (patchFiles.length > 0) {
3272
4114
  const patchContent = diffParts.join("\n");
3273
4115
  const contentHash = `sha256:${(0, import_node_crypto2.createHash)("sha256").update(patchContent).digest("hex")}`;
4116
+ const theirsSnapshot = {};
4117
+ for (const filePath of patchFiles) {
4118
+ if (isBinaryFile(filePath)) continue;
4119
+ const c = this.readFileContent(filePath);
4120
+ if (c != null) theirsSnapshot[filePath] = c;
4121
+ }
3274
4122
  syntheticPatches.push({
3275
4123
  id: `patch-fernignore-${(0, import_node_crypto2.createHash)("sha256").update(pattern).digest("hex").slice(0, 8)}`,
3276
4124
  content_hash: contentHash,
@@ -3279,7 +4127,17 @@ var FernignoreMigrator = class {
3279
4127
  original_author: "Fern Replay <replay@buildwithfern.com>",
3280
4128
  base_generation: currentGen.commit_sha,
3281
4129
  files: patchFiles,
3282
- patch_content: patchContent
4130
+ patch_content: patchContent,
4131
+ ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
4132
+ // Synthetic patches captured from .fernignore-protected files
4133
+ // are user-owned by definition — the customer's content
4134
+ // diverges from the generator's pristine output, and the
4135
+ // generator never produced this divergent content. Without
4136
+ // this flag, removing the file from .fernignore later (so
4137
+ // it leaves the protection check in `isFileUserOwned`) would
4138
+ // expose the patch to absorption on the next regen,
4139
+ // silently losing the customization.
4140
+ user_owned: true
3283
4141
  });
3284
4142
  trackedByBoth.push({
3285
4143
  file: pattern,
@@ -3312,7 +4170,7 @@ var FernignoreMigrator = class {
3312
4170
  return results;
3313
4171
  }
3314
4172
  readFileContent(filePath) {
3315
- const fullPath = (0, import_node_path4.join)(this.outputDir, filePath);
4173
+ const fullPath = (0, import_node_path6.join)(this.outputDir, filePath);
3316
4174
  if (!(0, import_node_fs3.existsSync)(fullPath)) {
3317
4175
  return null;
3318
4176
  }
@@ -3377,7 +4235,7 @@ var FernignoreMigrator = class {
3377
4235
  };
3378
4236
  }
3379
4237
  movePatternsToReplayYml(patterns) {
3380
- const replayYmlPath = (0, import_node_path4.join)(this.outputDir, ".fern", "replay.yml");
4238
+ const replayYmlPath = (0, import_node_path6.join)(this.outputDir, ".fern", "replay.yml");
3381
4239
  let config = {};
3382
4240
  if ((0, import_node_fs3.existsSync)(replayYmlPath)) {
3383
4241
  const content = (0, import_node_fs3.readFileSync)(replayYmlPath, "utf-8");
@@ -3386,7 +4244,7 @@ var FernignoreMigrator = class {
3386
4244
  const existing = config.exclude ?? [];
3387
4245
  const merged = [.../* @__PURE__ */ new Set([...existing, ...patterns])];
3388
4246
  config.exclude = merged;
3389
- const dir = (0, import_node_path4.dirname)(replayYmlPath);
4247
+ const dir = (0, import_node_path6.dirname)(replayYmlPath);
3390
4248
  if (!(0, import_node_fs3.existsSync)(dir)) {
3391
4249
  (0, import_node_fs3.mkdirSync)(dir, { recursive: true });
3392
4250
  }
@@ -3397,7 +4255,7 @@ var FernignoreMigrator = class {
3397
4255
  // src/commands/bootstrap.ts
3398
4256
  var import_node_crypto3 = require("crypto");
3399
4257
  var import_node_fs4 = require("fs");
3400
- var import_node_path5 = require("path");
4258
+ var import_node_path7 = require("path");
3401
4259
  init_GitClient();
3402
4260
  async function bootstrap(outputDir, options) {
3403
4261
  const git = new GitClient(outputDir);
@@ -3564,7 +4422,7 @@ function parseGitLog(log) {
3564
4422
  }
3565
4423
  var REPLAY_FERNIGNORE_ENTRIES = [".fern/replay.lock", ".fern/replay.yml", ".gitattributes"];
3566
4424
  function ensureFernignoreEntries(outputDir) {
3567
- const fernignorePath = (0, import_node_path5.join)(outputDir, ".fernignore");
4425
+ const fernignorePath = (0, import_node_path7.join)(outputDir, ".fernignore");
3568
4426
  let content = "";
3569
4427
  if ((0, import_node_fs4.existsSync)(fernignorePath)) {
3570
4428
  content = (0, import_node_fs4.readFileSync)(fernignorePath, "utf-8");
@@ -3588,7 +4446,7 @@ function ensureFernignoreEntries(outputDir) {
3588
4446
  }
3589
4447
  var GITATTRIBUTES_ENTRIES = [".fern/replay.lock linguist-generated=true"];
3590
4448
  function ensureGitattributesEntries(outputDir) {
3591
- const gitattributesPath = (0, import_node_path5.join)(outputDir, ".gitattributes");
4449
+ const gitattributesPath = (0, import_node_path7.join)(outputDir, ".gitattributes");
3592
4450
  let content = "";
3593
4451
  if ((0, import_node_fs4.existsSync)(gitattributesPath)) {
3594
4452
  content = (0, import_node_fs4.readFileSync)(gitattributesPath, "utf-8");
@@ -3786,6 +4644,8 @@ function reset(outputDir, options) {
3786
4644
  }
3787
4645
 
3788
4646
  // src/commands/resolve.ts
4647
+ var import_promises5 = require("fs/promises");
4648
+ var import_node_path8 = require("path");
3789
4649
  init_GitClient();
3790
4650
  async function resolve(outputDir, options) {
3791
4651
  const lockManager = new LockfileManager(outputDir);
@@ -3801,7 +4661,7 @@ async function resolve(outputDir, options) {
3801
4661
  const resolvingPatches = lockManager.getResolvingPatches();
3802
4662
  if (unresolvedPatches.length > 0) {
3803
4663
  const applicator = new ReplayApplicator(git, lockManager, outputDir);
3804
- await applicator.applyPatches(unresolvedPatches);
4664
+ await applicator.applyPatches(unresolvedPatches, { applyMode: "resolve" });
3805
4665
  const markerFiles = await findConflictMarkerFiles(git);
3806
4666
  if (markerFiles.length > 0) {
3807
4667
  for (const patch of unresolvedPatches) {
@@ -3827,20 +4687,80 @@ async function resolve(outputDir, options) {
3827
4687
  if (patchesToCommit.length > 0) {
3828
4688
  const currentGen = lock.current_generation;
3829
4689
  const detector = new ReplayDetector(git, lockManager, outputDir);
4690
+ const warnings = [];
3830
4691
  let patchesResolved = 0;
4692
+ const pass1 = [];
3831
4693
  for (const patch of patchesToCommit) {
3832
- const diff = await git.exec(["diff", currentGen, "--", ...patch.files]).catch(() => null);
3833
- if (!diff || !diff.trim()) {
4694
+ const result = await computePerPatchDiffWithFallback(
4695
+ patch,
4696
+ (f) => git.showFile(currentGen, f),
4697
+ (f) => (0, import_promises5.readFile)((0, import_node_path8.join)(outputDir, f), "utf-8").catch(() => null),
4698
+ (files) => git.exec(["diff", currentGen, "--", ...files]).catch(() => null)
4699
+ );
4700
+ if (result === null) {
4701
+ warnings.push(
4702
+ `Patch ${patch.id}: cumulative-diff fallback failed for unlocatable files; preserving patch unchanged`
4703
+ );
4704
+ pass1.push({ kind: "skip" });
4705
+ continue;
4706
+ }
4707
+ const diff = result.diff;
4708
+ if (!diff.trim()) {
4709
+ pass1.push({ kind: "revert" });
4710
+ continue;
4711
+ }
4712
+ const hash = detector.computeContentHash(diff);
4713
+ pass1.push({
4714
+ kind: "apply",
4715
+ diff,
4716
+ hash,
4717
+ hadFallback: result.hadFallback,
4718
+ fallbackFiles: result.fallbackFiles
4719
+ });
4720
+ }
4721
+ const lastIndexByHash = /* @__PURE__ */ new Map();
4722
+ for (let i = 0; i < pass1.length; i++) {
4723
+ const r = pass1[i];
4724
+ if (r.kind === "apply") lastIndexByHash.set(r.hash, i);
4725
+ }
4726
+ for (let i = 0; i < patchesToCommit.length; i++) {
4727
+ const patch = patchesToCommit[i];
4728
+ const r = pass1[i];
4729
+ if (r.kind === "skip") continue;
4730
+ if (r.kind === "revert") {
3834
4731
  lockManager.removePatch(patch.id);
3835
4732
  continue;
3836
4733
  }
3837
- const newContentHash = detector.computeContentHash(diff);
3838
- const changedFiles = await getChangedFiles(git, currentGen, patch.files);
4734
+ if (lastIndexByHash.get(r.hash) !== i) {
4735
+ const canonicalIdx = lastIndexByHash.get(r.hash);
4736
+ const canonical = patchesToCommit[canonicalIdx];
4737
+ lockManager.removePatch(patch.id);
4738
+ warnings.push(
4739
+ `Consolidated patch ${patch.id} (commit ${(patch.original_commit || "<missing>").slice(0, 7)}) into patch ${canonical.id} (commit ${(canonical.original_commit || "<missing>").slice(0, 7)}): byte-identical patch_content after resolution. Per-commit attribution preserved in git log; lockfile collapsed to avoid duplicate-apply corruption on next regen.`
4740
+ );
4741
+ continue;
4742
+ }
4743
+ if (r.hadFallback) {
4744
+ warnings.push(
4745
+ `Patch ${patch.id}: ${r.fallbackFiles.join(", ")} could not be anchored; cumulative-diff fallback applied for those files`
4746
+ );
4747
+ }
4748
+ const changedFiles = r.hadFallback ? await getChangedFiles(git, currentGen, patch.files) : extractFilesFromDiff(r.diff);
4749
+ const filesForSnapshot = changedFiles.length > 0 ? changedFiles : patch.files;
4750
+ const theirsSnapshot = {};
4751
+ for (const file of filesForSnapshot) {
4752
+ if (isBinaryFile(file)) continue;
4753
+ const content = await (0, import_promises5.readFile)((0, import_node_path8.join)(outputDir, file), "utf-8").catch(() => null);
4754
+ if (content != null) {
4755
+ theirsSnapshot[file] = content;
4756
+ }
4757
+ }
3839
4758
  lockManager.markPatchResolved(patch.id, {
3840
- patch_content: diff,
3841
- content_hash: newContentHash,
4759
+ patch_content: r.diff,
4760
+ content_hash: r.hash,
3842
4761
  base_generation: currentGen,
3843
- files: changedFiles
4762
+ files: filesForSnapshot,
4763
+ ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {}
3844
4764
  });
3845
4765
  patchesResolved++;
3846
4766
  }
@@ -3856,7 +4776,8 @@ async function resolve(outputDir, options) {
3856
4776
  success: true,
3857
4777
  commitSha: commitSha2,
3858
4778
  phase: "committed",
3859
- patchesResolved
4779
+ patchesResolved,
4780
+ warnings: warnings.length > 0 ? warnings : void 0
3860
4781
  };
3861
4782
  }
3862
4783
  const committer = new ReplayCommitter(git, outputDir);
@@ -3874,6 +4795,24 @@ async function getChangedFiles(git, currentGen, files) {
3874
4795
  const changed = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !f.startsWith(".fern/"));
3875
4796
  return changed.length > 0 ? changed : files;
3876
4797
  }
4798
+ function extractFilesFromDiff(unifiedDiff2) {
4799
+ const out = /* @__PURE__ */ new Set();
4800
+ const renameSources = /* @__PURE__ */ new Set();
4801
+ let currentTarget = null;
4802
+ for (const line of unifiedDiff2.split("\n")) {
4803
+ const headerMatch = line.match(/^diff --git a\/(.+?) b\/(.+)$/);
4804
+ if (headerMatch) {
4805
+ currentTarget = headerMatch[2];
4806
+ if (!currentTarget.startsWith(".fern/")) out.add(currentTarget);
4807
+ continue;
4808
+ }
4809
+ if (currentTarget && line.startsWith("rename from ")) {
4810
+ renameSources.add(line.slice("rename from ".length));
4811
+ }
4812
+ }
4813
+ for (const src of renameSources) out.delete(src);
4814
+ return Array.from(out);
4815
+ }
3877
4816
 
3878
4817
  // src/commands/status.ts
3879
4818
  function status(outputDir) {