@fern-api/replay 0.14.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.js CHANGED
@@ -395,6 +395,63 @@ var init_HybridReconstruction = __esm({
395
395
  }
396
396
  });
397
397
 
398
+ // src/PatchApplyTheirs.ts
399
+ var PatchApplyTheirs_exports = {};
400
+ __export(PatchApplyTheirs_exports, {
401
+ applyPatchToContent: () => applyPatchToContent
402
+ });
403
+ import { mkdtemp as mkdtemp3, mkdir as mkdir2, writeFile as writeFile3, readFile as readFile2, rm as rm3 } from "fs/promises";
404
+ import { tmpdir as tmpdir3 } from "os";
405
+ import { dirname as dirname3, join as join4 } from "path";
406
+ async function applyPatchToContent(base, patchContent, filePath) {
407
+ const fileDiff = extractFileDiff(patchContent, filePath);
408
+ if (!fileDiff) return null;
409
+ const tempDir = await mkdtemp3(join4(tmpdir3(), "replay-migrate-"));
410
+ const tempGit = new GitClient(tempDir);
411
+ try {
412
+ await tempGit.exec(["init"]);
413
+ await tempGit.exec(["config", "user.email", "migrate@fern.com"]);
414
+ await tempGit.exec(["config", "user.name", "Fern Replay Migration"]);
415
+ await tempGit.exec(["config", "commit.gpgSign", "false"]);
416
+ const tempFilePath = join4(tempDir, filePath);
417
+ await mkdir2(dirname3(tempFilePath), { recursive: true });
418
+ await writeFile3(tempFilePath, base);
419
+ await tempGit.exec(["add", filePath]);
420
+ await tempGit.exec(["commit", "-m", `base for ${filePath}`, "--allow-empty"]);
421
+ await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
422
+ return await readFile2(tempFilePath, "utf-8");
423
+ } catch {
424
+ return null;
425
+ } finally {
426
+ await rm3(tempDir, { recursive: true, force: true }).catch(() => {
427
+ });
428
+ }
429
+ }
430
+ function extractFileDiff(patchContent, filePath) {
431
+ const lines = patchContent.split("\n");
432
+ const out = [];
433
+ let inTarget = false;
434
+ for (const line of lines) {
435
+ if (line.startsWith("diff --git")) {
436
+ if (inTarget) break;
437
+ const match = line.match(/^diff --git a\/.+ b\/(.+)$/);
438
+ if (match && match[1] === filePath) {
439
+ inTarget = true;
440
+ out.push(line);
441
+ }
442
+ continue;
443
+ }
444
+ if (inTarget) out.push(line);
445
+ }
446
+ return out.length > 0 ? out.join("\n") + "\n" : null;
447
+ }
448
+ var init_PatchApplyTheirs = __esm({
449
+ "src/PatchApplyTheirs.ts"() {
450
+ "use strict";
451
+ init_GitClient();
452
+ }
453
+ });
454
+
398
455
  // src/credentials.ts
399
456
  var CREDENTIAL_PATTERNS = [
400
457
  { name: "PRIVATE KEY block", re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
@@ -526,7 +583,17 @@ var LockfileManager = class {
526
583
  this.warnings.length = 0;
527
584
  const cleanPatches = [];
528
585
  for (const patch of this.lock.patches) {
529
- const credentialMatches = scanForCredentials(patch.patch_content);
586
+ const diffMatches = scanForCredentials(patch.patch_content);
587
+ const snapshotMatches = [];
588
+ if (patch.theirs_snapshot) {
589
+ for (const content2 of Object.values(patch.theirs_snapshot)) {
590
+ const m = scanForCredentials(content2);
591
+ for (const x of m) {
592
+ if (!snapshotMatches.includes(x)) snapshotMatches.push(x);
593
+ }
594
+ }
595
+ }
596
+ const credentialMatches = [...diffMatches, ...snapshotMatches.filter((m) => !diffMatches.includes(m))];
530
597
  const sensitiveFiles = patch.files.filter((f) => isSensitiveFile(f));
531
598
  if (credentialMatches.length > 0 || sensitiveFiles.length > 0) {
532
599
  const reasons = [];
@@ -647,1520 +714,1593 @@ var LockfileManager = class {
647
714
 
648
715
  // src/ReplayDetector.ts
649
716
  import { createHash } from "crypto";
650
- var INFRASTRUCTURE_FILES = /* @__PURE__ */ new Set([".fernignore"]);
651
- function parsePatchFileHeaders(patchContent) {
652
- const results = [];
653
- let currentPath = null;
654
- let currentIsCreate = false;
655
- let currentIsDelete = false;
656
- const flush = () => {
657
- if (currentPath !== null) {
658
- results.push({ path: currentPath, isCreate: currentIsCreate, isDelete: currentIsDelete });
659
- }
660
- };
661
- for (const line of patchContent.split("\n")) {
662
- if (line.startsWith("diff --git ")) {
663
- flush();
664
- currentPath = null;
665
- currentIsCreate = false;
666
- currentIsDelete = false;
667
- const match = line.match(/^diff --git a\/(.+?) b\/(.+?)$/);
668
- if (match) {
669
- currentPath = match[2] ?? null;
717
+
718
+ // src/ReplayApplicator.ts
719
+ import { mkdir, mkdtemp, readFile, rm, unlink, writeFile } from "fs/promises";
720
+ import { tmpdir } from "os";
721
+ import { dirname as dirname2, extname, join as join2 } from "path";
722
+ import { minimatch } from "minimatch";
723
+
724
+ // src/conflict-utils.ts
725
+ var CONFLICT_OPENER = "<<<<<<< Generated";
726
+ var CONFLICT_SEPARATOR = "=======";
727
+ var CONFLICT_CLOSER = ">>>>>>> Your customization";
728
+ function trimCR(line) {
729
+ return line.endsWith("\r") ? line.slice(0, -1) : line;
730
+ }
731
+ function findConflictRanges(lines) {
732
+ const ranges = [];
733
+ let i = 0;
734
+ while (i < lines.length) {
735
+ if (trimCR(lines[i]) === CONFLICT_OPENER) {
736
+ let separatorIdx = -1;
737
+ let j = i + 1;
738
+ let found = false;
739
+ while (j < lines.length) {
740
+ const trimmed = trimCR(lines[j]);
741
+ if (trimmed === CONFLICT_OPENER) {
742
+ break;
743
+ }
744
+ if (separatorIdx === -1 && trimmed === CONFLICT_SEPARATOR) {
745
+ separatorIdx = j;
746
+ } else if (separatorIdx !== -1 && trimmed === CONFLICT_CLOSER) {
747
+ ranges.push({ start: i, separator: separatorIdx, end: j });
748
+ i = j;
749
+ found = true;
750
+ break;
751
+ }
752
+ j++;
670
753
  }
671
- } else if (currentPath !== null) {
672
- if (line.startsWith("new file mode ")) {
673
- currentIsCreate = true;
674
- } else if (line.startsWith("deleted file mode ")) {
675
- currentIsDelete = true;
754
+ if (!found) {
755
+ i++;
756
+ continue;
676
757
  }
677
758
  }
759
+ i++;
678
760
  }
679
- flush();
680
- return results;
761
+ return ranges;
681
762
  }
682
- var ReplayDetector = class {
683
- git;
684
- lockManager;
685
- sdkOutputDir;
686
- warnings = [];
687
- constructor(git, lockManager, sdkOutputDir) {
688
- this.git = git;
689
- this.lockManager = lockManager;
690
- this.sdkOutputDir = sdkOutputDir;
691
- }
692
- async detectNewPatches() {
693
- const lock = this.lockManager.read();
694
- const lastGen = this.getLastGeneration(lock);
695
- if (!lastGen) {
696
- return { patches: [], revertedPatchIds: [] };
697
- }
698
- const exists = await this.git.commitExists(lastGen.commit_sha);
699
- if (!exists) {
700
- this.warnings.push(
701
- `Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to alternate detection.`
702
- );
703
- return this.detectPatchesViaTreeDiff(
704
- lastGen,
705
- /* commitKnownMissing */
706
- true
707
- );
708
- }
709
- const isAncestor = await this.git.isAncestor(lastGen.commit_sha, "HEAD");
710
- if (!isAncestor) {
711
- return this.detectPatchesViaMergeBase(lastGen, lock);
712
- }
713
- return this.detectPatchesInRange(lastGen.commit_sha, lastGen, lock);
714
- }
715
- /**
716
- * FER-10201 — Non-linear-history detection via `merge-base(prevGen, HEAD)`.
717
- *
718
- * After a squash-merge of a Fern-bot PR, `lastGen.commit_sha` (the previous
719
- * `[fern-generated]`) is orphaned but still in git's object database. The
720
- * merge-base is the commit on main from which the bot's branch was created —
721
- * a reachable ancestor of HEAD. Walking that range and classifying each
722
- * commit via `isGenerationCommit` mirrors the linear path and eliminates
723
- * the bug class where pipeline-driven changes (autoversion, replay,
724
- * generation) get bundled as customer customizations.
725
- *
726
- * Falls through to the legacy composite path when no common ancestor exists
727
- * (truly disjoint history — orphan branches with no shared root).
728
- */
729
- async detectPatchesViaMergeBase(lastGen, lock) {
730
- let mergeBase = "";
731
- try {
732
- mergeBase = (await this.git.exec(["merge-base", lastGen.commit_sha, "HEAD"])).trim();
733
- } catch {
734
- }
735
- if (!mergeBase) {
736
- this.warnings.push(
737
- `No common ancestor between previous generation ${lastGen.commit_sha.slice(0, 7)} and HEAD. Falling back to composite tree-diff detection.`
738
- );
739
- return this.detectPatchesViaTreeDiff(
740
- lastGen,
741
- /* commitKnownMissing */
742
- false
743
- );
744
- }
745
- return this.detectPatchesInRange(mergeBase, lastGen, lock);
763
+ function stripConflictMarkers(content) {
764
+ const lines = content.split(/\r?\n/);
765
+ const ranges = findConflictRanges(lines);
766
+ if (ranges.length === 0) {
767
+ return content;
746
768
  }
747
- /**
748
- * FER-10201 Walk `${rangeStart}..HEAD`, classify each commit, emit one
749
- * `StoredPatch` per customer commit. Body shared by the linear path
750
- * (rangeStart = lastGen.commit_sha) and the merge-base path.
751
- *
752
- * Patch `base_generation` is always `lastGen.commit_sha` — the
753
- * lockfile-tracked reference the applicator uses to fetch base file
754
- * content, regardless of where the walk actually started.
755
- */
756
- async detectPatchesInRange(rangeStart, lastGen, lock) {
757
- const log = await this.git.exec([
758
- "log",
759
- "--first-parent",
760
- "--format=%H%x00%an%x00%ae%x00%s",
761
- `${rangeStart}..HEAD`,
762
- "--",
763
- this.sdkOutputDir
764
- ]);
765
- if (!log.trim()) {
766
- return { patches: [], revertedPatchIds: [] };
767
- }
768
- const commits = this.parseGitLog(log);
769
- const newPatches = [];
770
- const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
771
- for (const commit of commits) {
772
- if (isGenerationCommit(commit)) {
773
- continue;
774
- }
775
- if (lock.patches.find((p) => p.original_commit === commit.sha)) {
769
+ const result = [];
770
+ let rangeIdx = 0;
771
+ for (let i = 0; i < lines.length; i++) {
772
+ if (rangeIdx < ranges.length) {
773
+ const range = ranges[rangeIdx];
774
+ if (i === range.start) {
776
775
  continue;
777
776
  }
778
- const parents = await this.git.getCommitParents(commit.sha);
779
- if (parents.length > 1) {
780
- const compositePatch = await this.createMergeCompositePatch({
781
- mergeCommit: commit,
782
- firstParent: parents[0],
783
- lastGen,
784
- lock
785
- });
786
- if (compositePatch) {
787
- newPatches.push(compositePatch);
788
- }
777
+ if (i > range.start && i < range.separator) {
778
+ result.push(lines[i]);
789
779
  continue;
790
780
  }
791
- let patchContent;
792
- try {
793
- patchContent = await this.git.formatPatch(commit.sha);
794
- } catch {
795
- this.warnings.push(
796
- `Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
797
- );
781
+ if (i === range.separator) {
798
782
  continue;
799
783
  }
800
- let contentHash = this.computeContentHash(patchContent);
801
- if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
784
+ if (i > range.separator && i < range.end) {
802
785
  continue;
803
786
  }
804
- const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
805
- const allFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
806
- const sensitiveFiles = allFiles.filter((f) => isSensitiveFile(f));
807
- const files = allFiles.filter((f) => !isSensitiveFile(f));
808
- if (sensitiveFiles.length > 0) {
809
- this.warnings.push(
810
- `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
811
- );
812
- if (files.length > 0) {
813
- const parentSha = parents[0];
814
- if (parentSha) {
815
- try {
816
- patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
817
- } catch {
818
- continue;
819
- }
820
- if (!patchContent.trim()) continue;
821
- contentHash = this.computeContentHash(patchContent);
822
- }
823
- }
824
- }
825
- if (files.length === 0) {
787
+ if (i === range.end) {
788
+ rangeIdx++;
826
789
  continue;
827
790
  }
828
- newPatches.push({
829
- id: `patch-${commit.sha.slice(0, 8)}`,
830
- content_hash: contentHash,
831
- original_commit: commit.sha,
832
- original_message: commit.message,
833
- original_author: `${commit.authorName} <${commit.authorEmail}>`,
834
- base_generation: lastGen.commit_sha,
835
- files,
836
- patch_content: patchContent,
837
- ...this.isCreationOnlyPatch(patchContent) ? { user_owned: true } : {}
838
- });
839
791
  }
840
- const survivingPatches = await this.collapseNetZeroFiles(newPatches);
841
- survivingPatches.reverse();
842
- const revertedPatchIdSet = /* @__PURE__ */ new Set();
843
- const revertIndicesToRemove = /* @__PURE__ */ new Set();
844
- for (let i = 0; i < survivingPatches.length; i++) {
845
- const patch = survivingPatches[i];
846
- if (!isRevertCommit(patch.original_message)) continue;
847
- let body = "";
848
- try {
849
- body = await this.git.getCommitBody(patch.original_commit);
850
- } catch {
792
+ result.push(lines[i]);
793
+ }
794
+ return result.join("\n");
795
+ }
796
+
797
+ // src/ThreeWayMerge.ts
798
+ import { diff3Merge, diffPatch } from "node-diff3";
799
+ function threeWayMerge(base, ours, theirs) {
800
+ const baseLines = base.split("\n");
801
+ const oursLines = ours.split("\n");
802
+ const theirsLines = theirs.split("\n");
803
+ const regions = diff3Merge(oursLines, baseLines, theirsLines);
804
+ const outputLines = [];
805
+ const conflicts = [];
806
+ let currentLine = 1;
807
+ for (const region of regions) {
808
+ if (region.ok) {
809
+ outputLines.push(...region.ok);
810
+ currentLine += region.ok.length;
811
+ } else if (region.conflict) {
812
+ const resolved = tryResolveConflict(
813
+ region.conflict.a,
814
+ // ours (generator)
815
+ region.conflict.o,
816
+ // base
817
+ region.conflict.b
818
+ // theirs (user)
819
+ );
820
+ if (resolved !== null) {
821
+ outputLines.push(...resolved);
822
+ currentLine += resolved.length;
823
+ } else {
824
+ const startLine = currentLine;
825
+ outputLines.push("<<<<<<< Generated");
826
+ outputLines.push(...region.conflict.a);
827
+ outputLines.push("=======");
828
+ outputLines.push(...region.conflict.b);
829
+ outputLines.push(">>>>>>> Your customization");
830
+ const conflictLines = region.conflict.a.length + region.conflict.b.length + 3;
831
+ conflicts.push({
832
+ startLine,
833
+ endLine: startLine + conflictLines - 1,
834
+ ours: region.conflict.a,
835
+ theirs: region.conflict.b
836
+ });
837
+ currentLine += conflictLines;
838
+ }
839
+ }
840
+ }
841
+ const result = {
842
+ content: outputLines.join("\n"),
843
+ hasConflicts: conflicts.length > 0,
844
+ conflicts
845
+ };
846
+ if (conflicts.length === 0) {
847
+ const dropped = computeDroppedContextLines(baseLines, theirsLines, outputLines);
848
+ if (dropped.length > 0) {
849
+ result.droppedContextLines = dropped;
850
+ }
851
+ }
852
+ return result;
853
+ }
854
+ function computeDroppedContextLines(baseLines, theirsLines, mergedLines) {
855
+ const baseCounts = countLines(baseLines);
856
+ const theirsCounts = countLines(theirsLines);
857
+ const mergedCounts = countLines(mergedLines);
858
+ const dropped = [];
859
+ const seen = /* @__PURE__ */ new Set();
860
+ for (const line of baseLines) {
861
+ if (seen.has(line)) continue;
862
+ const inBase = baseCounts.get(line) ?? 0;
863
+ const inTheirs = theirsCounts.get(line) ?? 0;
864
+ const inMerged = mergedCounts.get(line) ?? 0;
865
+ if (inBase > 0 && inTheirs > 0 && inMerged < Math.min(inBase, inTheirs)) {
866
+ dropped.push(line);
867
+ seen.add(line);
868
+ }
869
+ }
870
+ return dropped;
871
+ }
872
+ function countLines(lines) {
873
+ const counts = /* @__PURE__ */ new Map();
874
+ for (const line of lines) {
875
+ counts.set(line, (counts.get(line) ?? 0) + 1);
876
+ }
877
+ return counts;
878
+ }
879
+ function tryResolveConflict(oursLines, baseLines, theirsLines) {
880
+ if (baseLines.length === 0) {
881
+ return null;
882
+ }
883
+ const oursPatches = diffPatch(baseLines, oursLines);
884
+ const theirsPatches = diffPatch(baseLines, theirsLines);
885
+ if (oursPatches.length === 0) return theirsLines;
886
+ if (theirsPatches.length === 0) return oursLines;
887
+ if (patchesOverlap(oursPatches, theirsPatches)) {
888
+ return null;
889
+ }
890
+ return applyBothPatches(baseLines, oursPatches, theirsPatches);
891
+ }
892
+ function patchesOverlap(oursPatches, theirsPatches) {
893
+ for (const op of oursPatches) {
894
+ const oStart = op.buffer1.offset;
895
+ const oEnd = oStart + op.buffer1.length;
896
+ for (const tp of theirsPatches) {
897
+ const tStart = tp.buffer1.offset;
898
+ const tEnd = tStart + tp.buffer1.length;
899
+ if (op.buffer1.length === 0 && tp.buffer1.length === 0 && oStart === tStart) {
900
+ return true;
901
+ }
902
+ if (tp.buffer1.length === 0 && tStart === oEnd) {
903
+ return true;
904
+ }
905
+ if (op.buffer1.length === 0 && oStart === tEnd) {
906
+ return true;
907
+ }
908
+ if (oStart < tEnd && tStart < oEnd) {
909
+ return true;
910
+ }
911
+ }
912
+ }
913
+ return false;
914
+ }
915
+ function applyBothPatches(baseLines, oursPatches, theirsPatches) {
916
+ const allPatches = [
917
+ ...oursPatches.map((p) => ({
918
+ offset: p.buffer1.offset,
919
+ length: p.buffer1.length,
920
+ replacement: p.buffer2.chunk
921
+ })),
922
+ ...theirsPatches.map((p) => ({
923
+ offset: p.buffer1.offset,
924
+ length: p.buffer1.length,
925
+ replacement: p.buffer2.chunk
926
+ }))
927
+ ];
928
+ allPatches.sort((a, b) => b.offset - a.offset);
929
+ const result = [...baseLines];
930
+ for (const p of allPatches) {
931
+ result.splice(p.offset, p.length, ...p.replacement);
932
+ }
933
+ return result;
934
+ }
935
+
936
+ // src/ReplayApplicator.ts
937
+ var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
938
+ ".png",
939
+ ".jpg",
940
+ ".jpeg",
941
+ ".gif",
942
+ ".bmp",
943
+ ".ico",
944
+ ".webp",
945
+ ".svg",
946
+ ".pdf",
947
+ ".doc",
948
+ ".docx",
949
+ ".xls",
950
+ ".xlsx",
951
+ ".ppt",
952
+ ".pptx",
953
+ ".zip",
954
+ ".gz",
955
+ ".tar",
956
+ ".bz2",
957
+ ".7z",
958
+ ".rar",
959
+ ".jar",
960
+ ".war",
961
+ ".ear",
962
+ ".class",
963
+ ".exe",
964
+ ".dll",
965
+ ".so",
966
+ ".dylib",
967
+ ".o",
968
+ ".a",
969
+ ".woff",
970
+ ".woff2",
971
+ ".ttf",
972
+ ".eot",
973
+ ".otf",
974
+ ".mp3",
975
+ ".mp4",
976
+ ".avi",
977
+ ".mov",
978
+ ".wav",
979
+ ".flac",
980
+ ".sqlite",
981
+ ".db",
982
+ ".pyc",
983
+ ".pyo",
984
+ ".DS_Store"
985
+ ]);
986
+ var ReplayApplicator = class {
987
+ git;
988
+ lockManager;
989
+ outputDir;
990
+ renameCache = /* @__PURE__ */ new Map();
991
+ treeExistsCache = /* @__PURE__ */ new Map();
992
+ fileTheirsAccumulator = /* @__PURE__ */ new Map();
993
+ /**
994
+ * Apply mode for the current `applyPatches` invocation. Set at the start
995
+ * of `applyPatches`, read by `mergeFile` to decide:
996
+ * - whether to skip the intra-loop marker strip (kept in `applyPatches`)
997
+ * - whether to populate the accumulator after a conflicted merge
998
+ * (resolve mode populates so subsequent patches on the same file
999
+ * can use patch A's THEIRS as a structurally-correct merge base
1000
+ * when their diff was authored against post-A structure)
1001
+ * - whether to retry THEIRS reconstruction against the accumulator
1002
+ * when the BASE-relative reconstruction produced markers from a
1003
+ * cross-patch context mismatch
1004
+ */
1005
+ currentApplyMode = "replay";
1006
+ /**
1007
+ * Set of files that appear in 2+ patches in the current `applyPatches`
1008
+ * invocation. Computed at the top of `applyPatches`. Read by `mergeFile`
1009
+ * and `populateAccumulatorForPatch` to decide whether to use the patch's
1010
+ * `theirs_snapshot` directly as THEIRS (snapshot-as-primary) or fall
1011
+ * back to line-anchored reconstruction (FER-9525 cross-patch isolation).
1012
+ *
1013
+ * Snapshot-as-primary is correct when the patch owns its file (no other
1014
+ * patch in this run touches it): the snapshot is what the customer
1015
+ * intends for that file post-regen, regardless of generator structural
1016
+ * changes that would invalidate the diff's line anchors.
1017
+ *
1018
+ * When a file IS shared, snapshots are CUMULATIVE across the patches
1019
+ * touching it (each one's snapshot includes prior patches' contributions).
1020
+ * Using a later patch's cumulative snapshot as its individual THEIRS
1021
+ * would propagate prior patches' edits into the merge — surfacing nested
1022
+ * conflicts at regions the patch alone never touched. Reconstruction
1023
+ * via `applyPatchToContent` is required there.
1024
+ */
1025
+ sharedFiles = /* @__PURE__ */ new Set();
1026
+ constructor(git, lockManager, outputDir) {
1027
+ this.git = git;
1028
+ this.lockManager = lockManager;
1029
+ this.outputDir = outputDir;
1030
+ }
1031
+ /**
1032
+ * Resolve the GenerationRecord for a patch's base_generation.
1033
+ * Falls back to constructing an ad-hoc record from the commit's tree
1034
+ * when base_generation isn't a tracked generation (commit-scan patches).
1035
+ */
1036
+ async resolveBaseGeneration(baseGeneration) {
1037
+ const gen = this.lockManager.getGeneration(baseGeneration);
1038
+ if (gen) return gen;
1039
+ try {
1040
+ const treeHash = await this.git.getTreeHash(baseGeneration);
1041
+ return {
1042
+ commit_sha: baseGeneration,
1043
+ tree_hash: treeHash,
1044
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1045
+ cli_version: "unknown",
1046
+ generator_versions: {}
1047
+ };
1048
+ } catch {
1049
+ return void 0;
1050
+ }
1051
+ }
1052
+ /** Reset inter-patch accumulator for a new cycle. */
1053
+ resetAccumulator() {
1054
+ this.fileTheirsAccumulator.clear();
1055
+ }
1056
+ /**
1057
+ * @internal Test-only.
1058
+ * Returns a defensive copy of the inter-patch accumulator. Used by the
1059
+ * adversarial test suite (FER-9791) to assert invariant I2: after every
1060
+ * applied patch, the accumulator has an entry for each file the patch
1061
+ * touched, and the entry's content matches on-disk.
1062
+ */
1063
+ getAccumulatorSnapshot() {
1064
+ const snapshot = /* @__PURE__ */ new Map();
1065
+ for (const [path, entry] of this.fileTheirsAccumulator) {
1066
+ snapshot.set(path, { content: entry.content, baseGeneration: entry.baseGeneration });
1067
+ }
1068
+ return snapshot;
1069
+ }
1070
+ /**
1071
+ * Apply all patches, returning results for each.
1072
+ * Skips patches that match exclude patterns in replay.yml
1073
+ *
1074
+ * `applyMode` controls the post-conflict marker strategy:
1075
+ * - `"replay"` (default): strip conflict markers from disk between
1076
+ * iterations whenever a later patch touches the same file. Lets the
1077
+ * follow-up patch's `git apply --3way` see clean OURS content,
1078
+ * which is what the silent-loss fix (PR #73) relies on. The replay
1079
+ * pipeline calls `revertConflictingFiles` after the loop to clean
1080
+ * any markers that survive.
1081
+ * - `"resolve"`: keep markers on disk. The resolve command needs the
1082
+ * customer to see them. Slow-path 3-way merge naturally preserves
1083
+ * A's markers and B's clean writes when their regions don't overlap;
1084
+ * when they do overlap, the customer gets nested markers and
1085
+ * resolves manually. Either way they aren't silently dropped.
1086
+ */
1087
+ async applyPatches(patches, opts) {
1088
+ const applyMode = opts?.applyMode ?? "replay";
1089
+ this.currentApplyMode = applyMode;
1090
+ this.resetAccumulator();
1091
+ const fileFreq = /* @__PURE__ */ new Map();
1092
+ for (const p of patches) {
1093
+ for (const f of p.files) {
1094
+ fileFreq.set(f, (fileFreq.get(f) ?? 0) + 1);
851
1095
  }
852
- const revertedSha = parseRevertedSha(body);
853
- const revertedMessage = parseRevertedMessage(patch.original_message);
854
- let matchedExisting = false;
855
- if (revertedSha) {
856
- const existing = lock.patches.find((p) => p.original_commit === revertedSha);
857
- if (existing) {
858
- revertedPatchIdSet.add(existing.id);
859
- revertIndicesToRemove.add(i);
860
- matchedExisting = true;
861
- }
1096
+ }
1097
+ this.sharedFiles = new Set(
1098
+ Array.from(fileFreq.entries()).filter(([, n]) => n > 1).map(([f]) => f)
1099
+ );
1100
+ const results = [];
1101
+ for (let i = 0; i < patches.length; i++) {
1102
+ const patch = patches[i];
1103
+ if (this.isExcluded(patch)) {
1104
+ results.push({
1105
+ patch,
1106
+ status: "skipped",
1107
+ method: "git-am"
1108
+ });
1109
+ continue;
862
1110
  }
863
- if (!matchedExisting && revertedMessage) {
864
- const existing = lock.patches.find((p) => p.original_message === revertedMessage);
865
- if (existing) {
866
- revertedPatchIdSet.add(existing.id);
867
- revertIndicesToRemove.add(i);
868
- matchedExisting = true;
1111
+ const result = await this.applyPatchWithFallback(patch);
1112
+ results.push(result);
1113
+ if (applyMode !== "resolve" && result.status === "conflict" && result.fileResults) {
1114
+ const laterFiles = /* @__PURE__ */ new Set();
1115
+ for (let j = i + 1; j < patches.length; j++) {
1116
+ for (const f of patches[j].files) {
1117
+ laterFiles.add(f);
1118
+ }
869
1119
  }
870
- }
871
- if (matchedExisting) continue;
872
- let matchedNew = false;
873
- if (revertedSha) {
874
- const idx = survivingPatches.findIndex(
875
- (p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_commit === revertedSha
876
- );
877
- if (idx !== -1) {
878
- revertIndicesToRemove.add(i);
879
- revertIndicesToRemove.add(idx);
880
- matchedNew = true;
1120
+ const resolvedToOriginal = /* @__PURE__ */ new Map();
1121
+ if (result.resolvedFiles) {
1122
+ for (const [orig, resolved] of Object.entries(result.resolvedFiles)) {
1123
+ resolvedToOriginal.set(resolved, orig);
1124
+ }
881
1125
  }
882
- }
883
- if (!matchedNew && revertedMessage) {
884
- const idx = survivingPatches.findIndex(
885
- (p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_message === revertedMessage
886
- );
887
- if (idx !== -1) {
888
- revertIndicesToRemove.add(i);
889
- revertIndicesToRemove.add(idx);
1126
+ for (const fileResult of result.fileResults) {
1127
+ if (fileResult.status !== "conflict") continue;
1128
+ const originalPath = resolvedToOriginal.get(fileResult.file) ?? fileResult.file;
1129
+ if (laterFiles.has(fileResult.file) || laterFiles.has(originalPath)) {
1130
+ const filePath = join2(this.outputDir, fileResult.file);
1131
+ try {
1132
+ const content = await readFile(filePath, "utf-8");
1133
+ const stripped = stripConflictMarkers(content);
1134
+ await writeFile(filePath, stripped);
1135
+ } catch {
1136
+ }
1137
+ }
890
1138
  }
891
1139
  }
892
- if (!matchedExisting && !matchedNew) {
893
- revertIndicesToRemove.add(i);
894
- }
895
1140
  }
896
- const filteredPatches = survivingPatches.filter((_, i) => !revertIndicesToRemove.has(i));
897
- return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
898
- }
899
- /**
900
- * Compute content hash for deduplication.
901
- *
902
- * Produces a format-agnostic hash so both `git format-patch` output
903
- * (email-wrapped) and plain `git diff` output hash to the same value
904
- * when the underlying diff hunks are identical.
905
- *
906
- * Normalization:
907
- * 1. Strip email wrapper: everything before the first `diff --git` line
908
- * (From:, Subject:, Date:, diffstat, blank separators).
909
- * 2. Strip `index` lines (blob SHA pairs change across rebases).
910
- * 3. Strip trailing git version marker (`-- \n<version>`).
911
- *
912
- * This ensures content hashes match across the no-patches and normal-
913
- * regeneration flows, which store formatPatch and git-diff formats
914
- * respectively (FER-9850, D5-D9).
915
- */
916
- computeContentHash(patchContent) {
917
- const lines = patchContent.split("\n");
918
- const diffStart = lines.findIndex((l) => l.startsWith("diff --git "));
919
- const relevantLines = diffStart > 0 ? lines.slice(diffStart) : lines;
920
- const normalized = relevantLines.filter((line) => !line.startsWith("index ")).join("\n").replace(/\n-- \n[\d]+\.[\d]+[^\n]*\s*$/, "");
921
- return `sha256:${createHash("sha256").update(normalized).digest("hex")}`;
1141
+ return results;
922
1142
  }
923
1143
  /**
924
- * FER-9805 Drop patches whose files were transiently created and then
925
- * deleted within the current linear detection window, and are absent from
926
- * HEAD at the end of the window.
927
- *
928
- * Rationale: on a merge commit, createMergeCompositePatch already diffs
929
- * firstParent..mergeCommit so net-zero files never enter the composite. On
930
- * linear history each commit becomes its own patch, so a file that was
931
- * briefly added and later removed survives as a create+delete pair whose
932
- * cumulative diff is empty. We drop such patches before they reach the
933
- * lockfile.
934
- *
935
- * A file is "transient" when:
936
- * 1. some patch in the window sets `new file mode` for it (a creation), AND
937
- * 2. some patch in the window sets `deleted file mode` for it (a deletion), AND
938
- * 3. it is absent from HEAD's tree.
939
- *
940
- * The HEAD guard (#3) prevents false positives when a file is deleted
941
- * and then recreated with different content — the cumulative diff of
942
- * such a pair is non-empty and must be preserved.
943
- *
944
- * This only drops patches whose `files` are a subset of the transient
945
- * set. A partial-transient patch (one that touches a transient file
946
- * alongside a persistent one) is left unmodified; surgical stripping of
947
- * single files from a multi-file patch would require a follow-up that
948
- * regenerates the patch content via `git format-patch -- <files>`. No
949
- * current failing test exercises this, and leaving the patch intact
950
- * preserves today's behaviour. TODO(FER-9805): revisit if a future
951
- * adversarial test demands per-file stripping.
1144
+ * Populate accumulator after git apply succeeds, AND collect per-file
1145
+ * results including any "dropped context lines" lines that were
1146
+ * present in BASE and THEIRS (unchanged context) but absent from the
1147
+ * final on-disk state because OURS deleted them. This is the fast-path
1148
+ * counterpart to ThreeWayMerge.computeDroppedContextLines used by the
1149
+ * 3-way slow path; both must surface the same warning.
952
1150
  */
953
- async collapseNetZeroFiles(patches) {
954
- if (patches.length === 0) return patches;
955
- const stateByFile = /* @__PURE__ */ new Map();
956
- for (const patch of patches) {
957
- for (const header of parsePatchFileHeaders(patch.patch_content)) {
958
- if (!header.isCreate && !header.isDelete) continue;
959
- const prior = stateByFile.get(header.path) ?? { created: false, deleted: false };
960
- if (header.isCreate) prior.created = true;
961
- if (header.isDelete) prior.deleted = true;
962
- stateByFile.set(header.path, prior);
1151
+ async populateAccumulatorForPatch(patch, baseGen, currentTreeHash) {
1152
+ const fileResults = [];
1153
+ if (!baseGen) return fileResults;
1154
+ const tempDir = await mkdtemp(join2(tmpdir(), "replay-acc-"));
1155
+ const { GitClient: GitClient2 } = await Promise.resolve().then(() => (init_GitClient(), GitClient_exports));
1156
+ const tempGit = new GitClient2(tempDir);
1157
+ await tempGit.exec(["init"]);
1158
+ await tempGit.exec(["config", "user.email", "replay@fern.com"]);
1159
+ await tempGit.exec(["config", "user.name", "Fern Replay"]);
1160
+ try {
1161
+ for (const filePath of patch.files) {
1162
+ if (isBinaryFile(filePath)) continue;
1163
+ const resolvedPath = await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
1164
+ const base = await this.git.showFile(baseGen.tree_hash, filePath);
1165
+ const snapshotForFile = patch.theirs_snapshot?.[resolvedPath] ?? patch.theirs_snapshot?.[filePath];
1166
+ const fileIsShared = this.sharedFiles.has(resolvedPath) || this.sharedFiles.has(filePath);
1167
+ const theirs = !fileIsShared && snapshotForFile != null ? snapshotForFile : await this.applyPatchToContent(base, patch.patch_content, filePath, tempGit, tempDir);
1168
+ const finalOnDisk = await readFile(join2(this.outputDir, resolvedPath), "utf-8").catch(() => null);
1169
+ const effectiveTheirs = theirs ?? finalOnDisk;
1170
+ if (effectiveTheirs != null) {
1171
+ this.fileTheirsAccumulator.set(resolvedPath, {
1172
+ content: effectiveTheirs,
1173
+ baseGeneration: patch.base_generation
1174
+ });
1175
+ }
1176
+ if (base != null && effectiveTheirs != null && finalOnDisk != null) {
1177
+ const dropped = computeDroppedContextLinesForFile(base, effectiveTheirs, finalOnDisk);
1178
+ if (dropped.length > 0) {
1179
+ fileResults.push({
1180
+ file: resolvedPath,
1181
+ status: "merged",
1182
+ droppedContextLines: dropped
1183
+ });
1184
+ }
1185
+ }
963
1186
  }
1187
+ } finally {
1188
+ await rm(tempDir, { recursive: true }).catch(() => {
1189
+ });
964
1190
  }
965
- let anyCandidate = false;
966
- for (const state of stateByFile.values()) {
967
- if (state.created && state.deleted) {
968
- anyCandidate = true;
969
- break;
1191
+ return fileResults;
1192
+ }
1193
+ async applyPatchWithFallback(patch) {
1194
+ const baseGen = await this.resolveBaseGeneration(patch.base_generation);
1195
+ const lock = this.lockManager.read();
1196
+ const currentGen = lock.generations.find((g) => g.commit_sha === lock.current_generation);
1197
+ const currentTreeHash = currentGen?.tree_hash ?? baseGen?.tree_hash ?? "";
1198
+ const needsAccumulation = await Promise.all(
1199
+ patch.files.map(async (f) => {
1200
+ if (!baseGen) return false;
1201
+ const resolved = await this.resolveFilePath(f, baseGen.tree_hash, currentTreeHash);
1202
+ return this.fileTheirsAccumulator.has(resolved);
1203
+ })
1204
+ ).then((results) => results.some(Boolean));
1205
+ const resolvedFiles = {};
1206
+ for (const filePath of patch.files) {
1207
+ const resolvedPath = baseGen ? await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash) : filePath;
1208
+ if (resolvedPath !== filePath) {
1209
+ resolvedFiles[filePath] = resolvedPath;
970
1210
  }
971
1211
  }
972
- if (!anyCandidate) return patches;
973
- const headFiles = await this.listHeadFiles();
974
- const transient = /* @__PURE__ */ new Set();
975
- for (const [file, state] of stateByFile) {
976
- if (state.created && state.deleted && !headFiles.has(file)) {
977
- transient.add(file);
1212
+ const patchIsRenameAware = /^rename from /m.test(patch.patch_content);
1213
+ const hasGeneratorRename = Object.keys(resolvedFiles).length > 0 && !patchIsRenameAware;
1214
+ if (!needsAccumulation && !hasGeneratorRename) {
1215
+ const snapshots = /* @__PURE__ */ new Map();
1216
+ for (const filePath of patch.files) {
1217
+ const fullPath = join2(this.outputDir, filePath);
1218
+ snapshots.set(filePath, await readFile(fullPath, "utf-8").catch(() => null));
1219
+ }
1220
+ try {
1221
+ await this.git.execWithInput(["apply", "--3way"], patch.patch_content);
1222
+ const fastPathFileResults = await this.populateAccumulatorForPatch(patch, baseGen, currentTreeHash);
1223
+ return {
1224
+ patch,
1225
+ status: "applied",
1226
+ method: "git-am",
1227
+ ...fastPathFileResults.length > 0 && { fileResults: fastPathFileResults },
1228
+ ...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
1229
+ };
1230
+ } catch {
1231
+ for (const [filePath, content] of snapshots) {
1232
+ const fullPath = join2(this.outputDir, filePath);
1233
+ if (content != null) {
1234
+ await writeFile(fullPath, content);
1235
+ } else {
1236
+ await unlink(fullPath).catch(() => {
1237
+ });
1238
+ }
1239
+ }
978
1240
  }
979
1241
  }
980
- if (transient.size === 0) return patches;
981
- return patches.filter((patch) => !patch.files.every((f) => transient.has(f)));
1242
+ return this.applyWithThreeWayMerge(patch);
982
1243
  }
983
- /**
984
- * List every tracked path at HEAD (one `git ls-tree -r` invocation).
985
- * Returns an empty Set if HEAD is unborn or the invocation fails — the
986
- * caller then treats every candidate as "not in HEAD" and may collapse
987
- * more aggressively. On typical SDK repos this is a single sub-10ms call.
988
- */
989
- async listHeadFiles() {
1244
+ async applyWithThreeWayMerge(patch) {
1245
+ const fileResults = [];
1246
+ const resolvedFiles = {};
1247
+ const tempDir = await mkdtemp(join2(tmpdir(), "replay-"));
1248
+ const { GitClient: GitClient2 } = await Promise.resolve().then(() => (init_GitClient(), GitClient_exports));
1249
+ const tempGit = new GitClient2(tempDir);
1250
+ await tempGit.exec(["init"]);
1251
+ await tempGit.exec(["config", "user.email", "replay@fern.com"]);
1252
+ await tempGit.exec(["config", "user.name", "Fern Replay"]);
990
1253
  try {
991
- const out = await this.git.exec(["ls-tree", "-r", "--name-only", "HEAD"]);
992
- return new Set(out.split("\n").map((l) => l.trim()).filter(Boolean));
993
- } catch {
994
- return /* @__PURE__ */ new Set();
995
- }
996
- }
997
- /**
998
- * Create a single composite patch for a merge commit by diffing the merge
999
- * commit against its first parent. This captures the net change of the
1000
- * entire merged branch as one patch, eliminating accumulator chaining and
1001
- * zero-net-change ghost conflicts.
1002
- */
1003
- async createMergeCompositePatch(input) {
1004
- const { mergeCommit, firstParent, lastGen, lock } = input;
1005
- const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
1006
- const filesOutput = await this.git.exec([
1007
- "diff",
1008
- "--name-only",
1009
- firstParent,
1010
- mergeCommit.sha
1011
- ]);
1012
- const allMergeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
1013
- const sensitiveMergeFiles = allMergeFiles.filter((f) => isSensitiveFile(f));
1014
- const files = allMergeFiles.filter((f) => !isSensitiveFile(f));
1015
- if (sensitiveMergeFiles.length > 0) {
1016
- this.warnings.push(
1017
- `Sensitive file(s) excluded from merge patch for commit ${mergeCommit.sha.slice(0, 7)}: ${sensitiveMergeFiles.join(", ")}. These files will not be tracked by replay.`
1018
- );
1019
- }
1020
- if (files.length === 0) return null;
1021
- const diff = await this.git.exec([
1022
- "diff",
1023
- firstParent,
1024
- mergeCommit.sha,
1025
- "--",
1026
- ...files
1027
- ]);
1028
- if (!diff.trim()) return null;
1029
- const contentHash = this.computeContentHash(diff);
1030
- if (lock.patches.some((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
1031
- return null;
1254
+ for (const filePath of patch.files) {
1255
+ if (isBinaryFile(filePath)) {
1256
+ fileResults.push({
1257
+ file: filePath,
1258
+ status: "skipped",
1259
+ reason: "binary-file"
1260
+ });
1261
+ continue;
1262
+ }
1263
+ const result = await this.mergeFile(patch, filePath, tempGit, tempDir);
1264
+ if (result.file !== filePath) {
1265
+ resolvedFiles[filePath] = result.file;
1266
+ }
1267
+ fileResults.push(result);
1268
+ }
1269
+ } finally {
1270
+ await rm(tempDir, { recursive: true }).catch(() => {
1271
+ });
1032
1272
  }
1273
+ const conflictFiles = fileResults.filter((r) => r.status === "conflict");
1274
+ const hasConflicts = conflictFiles.length > 0;
1275
+ const conflictReason = hasConflicts ? conflictFiles.some((f) => f.conflictReason === "base-generation-mismatch") ? "base-generation-mismatch" : conflictFiles[0]?.conflictReason : void 0;
1033
1276
  return {
1034
- id: `patch-merge-${mergeCommit.sha.slice(0, 8)}`,
1035
- content_hash: contentHash,
1036
- original_commit: mergeCommit.sha,
1037
- original_message: mergeCommit.message,
1038
- original_author: `${mergeCommit.authorName} <${mergeCommit.authorEmail}>`,
1039
- base_generation: lastGen.commit_sha,
1040
- files,
1041
- patch_content: diff,
1042
- ...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
1043
- };
1044
- }
1045
- /**
1046
- * Detect patches via tree diff for non-linear history. Returns a composite patch.
1047
- * Revert reconciliation is skipped here because tree-diff produces a single composite
1048
- * patch from the aggregate diff — individual revert commits are not distinguishable.
1049
- */
1050
- async detectPatchesViaTreeDiff(lastGen, commitKnownMissing) {
1051
- const diffBase = await this.resolveDiffBase(lastGen, commitKnownMissing);
1052
- if (!diffBase) {
1053
- return this.detectPatchesViaCommitScan();
1054
- }
1055
- const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
1056
- const allTreeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
1057
- const sensitiveTreeFiles = allTreeFiles.filter((f) => isSensitiveFile(f));
1058
- const files = allTreeFiles.filter((f) => !isSensitiveFile(f));
1059
- if (sensitiveTreeFiles.length > 0) {
1060
- this.warnings.push(
1061
- `Sensitive file(s) excluded from composite patch: ${sensitiveTreeFiles.join(", ")}. These files will not be tracked by replay.`
1062
- );
1063
- }
1064
- if (files.length === 0) return { patches: [], revertedPatchIds: [] };
1065
- const diff = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
1066
- if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
1067
- const contentHash = this.computeContentHash(diff);
1068
- const lock = this.lockManager.read();
1069
- if (lock.patches.some((p) => p.content_hash === contentHash) || (lock.forgotten_hashes ?? []).includes(contentHash)) {
1070
- return { patches: [], revertedPatchIds: [] };
1071
- }
1072
- const headSha = (await this.git.exec(["rev-parse", "HEAD"])).trim();
1073
- const compositePatch = {
1074
- id: `patch-composite-${headSha.slice(0, 8)}`,
1075
- content_hash: contentHash,
1076
- original_commit: headSha,
1077
- original_message: "Customer customizations (composite)",
1078
- original_author: "composite",
1079
- // Use diffBase when commit is unreachable — the applicator needs a reachable
1080
- // reference to find base file content. diffBase may be the tree_hash.
1081
- base_generation: commitKnownMissing ? diffBase : lastGen.commit_sha,
1082
- files,
1083
- patch_content: diff,
1084
- ...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
1277
+ patch,
1278
+ status: hasConflicts ? "conflict" : "applied",
1279
+ method: "3way-merge",
1280
+ fileResults,
1281
+ conflictReason,
1282
+ ...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
1085
1283
  };
1086
- return { patches: [compositePatch], revertedPatchIds: [] };
1087
1284
  }
1088
- /**
1089
- * Last-resort detection when both generation commit and tree are unreachable.
1090
- * Scans all commits from HEAD, filters against known lockfile patches, and
1091
- * skips creation-only commits (squashed history after force push).
1092
- *
1093
- * Detected patches use the commit's parent as base_generation so the applicator
1094
- * can find base file content from the parent's tree (which IS reachable).
1095
- */
1096
- async detectPatchesViaCommitScan() {
1097
- const lock = this.lockManager.read();
1098
- const log = await this.git.exec([
1099
- "log",
1100
- "--max-count=200",
1101
- "--format=%H%x00%an%x00%ae%x00%s",
1102
- "HEAD",
1103
- "--",
1104
- this.sdkOutputDir
1105
- ]);
1106
- if (!log.trim()) {
1107
- return { patches: [], revertedPatchIds: [] };
1108
- }
1109
- const commits = this.parseGitLog(log);
1110
- const newPatches = [];
1111
- const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
1112
- const existingHashes = new Set(lock.patches.map((p) => p.content_hash));
1113
- const existingCommits = new Set(lock.patches.map((p) => p.original_commit));
1114
- for (const commit of commits) {
1115
- if (isGenerationCommit(commit)) {
1116
- continue;
1285
+ async mergeFile(patch, filePath, tempGit, tempDir) {
1286
+ try {
1287
+ const baseGen = await this.resolveBaseGeneration(patch.base_generation);
1288
+ if (!baseGen) {
1289
+ return { file: filePath, status: "skipped", reason: "base-generation-not-found" };
1117
1290
  }
1118
- const parents = await this.git.getCommitParents(commit.sha);
1119
- if (parents.length > 1) {
1120
- continue;
1291
+ const lock = this.lockManager.read();
1292
+ const currentGen = lock.generations.find((g) => g.commit_sha === lock.current_generation);
1293
+ const currentTreeHash = currentGen?.tree_hash ?? baseGen.tree_hash;
1294
+ const resolvedPath = await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
1295
+ const metadata = {
1296
+ patchId: patch.id,
1297
+ patchMessage: patch.original_message,
1298
+ baseGeneration: patch.base_generation,
1299
+ currentGeneration: lock.current_generation
1300
+ };
1301
+ let base = await this.git.showFile(baseGen.tree_hash, filePath);
1302
+ let renameSourcePath;
1303
+ if (!base) {
1304
+ const renameSource = this.extractRenameSource(patch.patch_content, filePath);
1305
+ if (renameSource) {
1306
+ base = await this.git.showFile(baseGen.tree_hash, renameSource);
1307
+ renameSourcePath = renameSource;
1308
+ }
1121
1309
  }
1122
- if (existingCommits.has(commit.sha)) {
1123
- continue;
1310
+ const oursPath = join2(this.outputDir, resolvedPath);
1311
+ const ours = await readFile(oursPath, "utf-8").catch(() => null);
1312
+ let ghostReconstructed = false;
1313
+ let theirs = null;
1314
+ if (!base && ours && !renameSourcePath) {
1315
+ const treeReachable = await this.isTreeReachable(baseGen.tree_hash);
1316
+ if (!treeReachable) {
1317
+ const fileDiff = this.extractFileDiff(patch.patch_content, filePath);
1318
+ if (fileDiff) {
1319
+ const { reconstructFromGhostPatch: reconstructFromGhostPatch2 } = await Promise.resolve().then(() => (init_HybridReconstruction(), HybridReconstruction_exports));
1320
+ const result = reconstructFromGhostPatch2(fileDiff, ours);
1321
+ if (result) {
1322
+ base = result.base;
1323
+ theirs = result.theirs;
1324
+ ghostReconstructed = true;
1325
+ }
1326
+ }
1327
+ }
1124
1328
  }
1125
- let patchContent;
1126
- try {
1127
- patchContent = await this.git.formatPatch(commit.sha);
1128
- } catch {
1129
- continue;
1329
+ const snapshotForFile = patch.theirs_snapshot?.[resolvedPath] ?? patch.theirs_snapshot?.[filePath];
1330
+ const fileIsShared = this.sharedFiles.has(resolvedPath) || this.sharedFiles.has(filePath);
1331
+ const useSnapshotAsPrimary = !fileIsShared && snapshotForFile != null;
1332
+ if (!ghostReconstructed) {
1333
+ if (useSnapshotAsPrimary) {
1334
+ theirs = snapshotForFile;
1335
+ } else {
1336
+ theirs = await this.applyPatchToContent(
1337
+ base,
1338
+ patch.patch_content,
1339
+ filePath,
1340
+ tempGit,
1341
+ tempDir,
1342
+ renameSourcePath
1343
+ );
1344
+ const reconstructionHasMarkers = theirs != null && (theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization"));
1345
+ const baseHadMarkers = base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization"));
1346
+ if (reconstructionHasMarkers && !baseHadMarkers && snapshotForFile != null) {
1347
+ theirs = snapshotForFile;
1348
+ }
1349
+ }
1130
1350
  }
1131
- let contentHash = this.computeContentHash(patchContent);
1132
- if (existingHashes.has(contentHash) || forgottenHashes.has(contentHash)) {
1133
- continue;
1351
+ const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
1352
+ if (theirs) {
1353
+ const theirsHasMarkers = theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization");
1354
+ const baseHasMarkers = base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization"));
1355
+ if (theirsHasMarkers && !baseHasMarkers) {
1356
+ if (accumulatorEntry) {
1357
+ theirs = null;
1358
+ } else {
1359
+ return {
1360
+ file: resolvedPath,
1361
+ status: "skipped",
1362
+ reason: "stale-conflict-markers"
1363
+ };
1364
+ }
1365
+ }
1366
+ }
1367
+ let useAccumulatorAsMergeBase = false;
1368
+ if (accumulatorEntry && (theirs == null || base == null)) {
1369
+ theirs = await this.applyPatchToContent(
1370
+ accumulatorEntry.content,
1371
+ patch.patch_content,
1372
+ filePath,
1373
+ tempGit,
1374
+ tempDir
1375
+ );
1376
+ if (theirs != null) {
1377
+ useAccumulatorAsMergeBase = true;
1378
+ } else if (await this.isPatchAlreadyApplied(
1379
+ patch.patch_content,
1380
+ filePath,
1381
+ accumulatorEntry.content,
1382
+ tempGit,
1383
+ tempDir
1384
+ )) {
1385
+ theirs = accumulatorEntry.content;
1386
+ useAccumulatorAsMergeBase = true;
1387
+ }
1134
1388
  }
1135
- if (this.isCreationOnlyPatch(patchContent)) {
1136
- continue;
1389
+ if (theirs) {
1390
+ const theirsHasMarkers = theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization");
1391
+ const accBaseHasMarkers = accumulatorEntry != null && (accumulatorEntry.content.includes("<<<<<<< Generated") || accumulatorEntry.content.includes(">>>>>>> Your customization"));
1392
+ if (theirsHasMarkers && !accBaseHasMarkers && !(base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization")))) {
1393
+ return {
1394
+ file: resolvedPath,
1395
+ status: "skipped",
1396
+ reason: "stale-conflict-markers"
1397
+ };
1398
+ }
1137
1399
  }
1138
- const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
1139
- const allScanFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f));
1140
- const sensitiveScanFiles = allScanFiles.filter((f) => isSensitiveFile(f));
1141
- const files = allScanFiles.filter((f) => !isSensitiveFile(f));
1142
- if (sensitiveScanFiles.length > 0) {
1143
- this.warnings.push(
1144
- `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveScanFiles.join(", ")}. These files will not be tracked by replay.`
1145
- );
1146
- if (files.length > 0) {
1147
- if (parents.length === 0) {
1148
- continue;
1149
- }
1400
+ let effective_theirs = theirs;
1401
+ let baseMismatchSkipped = false;
1402
+ if (theirs != null && base != null && !useAccumulatorAsMergeBase) {
1403
+ if (accumulatorEntry && accumulatorEntry.baseGeneration === patch.base_generation) {
1150
1404
  try {
1151
- patchContent = await this.git.exec(["diff", parents[0], commit.sha, "--", ...files]);
1405
+ const preMerged = threeWayMerge(base, accumulatorEntry.content, theirs);
1406
+ if (!preMerged.hasConflicts) {
1407
+ effective_theirs = preMerged.content;
1408
+ } else {
1409
+ effective_theirs = theirs;
1410
+ }
1152
1411
  } catch {
1153
- continue;
1412
+ effective_theirs = theirs;
1154
1413
  }
1155
- if (!patchContent.trim()) continue;
1156
- contentHash = this.computeContentHash(patchContent);
1414
+ } else if (accumulatorEntry) {
1415
+ baseMismatchSkipped = true;
1157
1416
  }
1158
1417
  }
1159
- if (files.length === 0) {
1160
- continue;
1418
+ if (base == null && ours == null && effective_theirs != null) {
1419
+ this.fileTheirsAccumulator.set(resolvedPath, {
1420
+ content: effective_theirs,
1421
+ baseGeneration: patch.base_generation
1422
+ });
1423
+ const outDir2 = dirname2(oursPath);
1424
+ await mkdir(outDir2, { recursive: true });
1425
+ await writeFile(oursPath, effective_theirs);
1426
+ return { file: resolvedPath, status: "merged", reason: "new-file" };
1161
1427
  }
1162
- if (parents.length === 0) {
1163
- continue;
1428
+ if (base == null && ours != null && effective_theirs != null && !useAccumulatorAsMergeBase) {
1429
+ const merged2 = threeWayMerge("", ours, effective_theirs);
1430
+ const outDir2 = dirname2(oursPath);
1431
+ await mkdir(outDir2, { recursive: true });
1432
+ await writeFile(oursPath, merged2.content);
1433
+ if (merged2.hasConflicts) {
1434
+ return {
1435
+ file: resolvedPath,
1436
+ status: "conflict",
1437
+ conflicts: merged2.conflicts,
1438
+ conflictReason: "new-file-both",
1439
+ conflictMetadata: metadata
1440
+ };
1441
+ }
1442
+ this.fileTheirsAccumulator.set(resolvedPath, {
1443
+ content: merged2.content,
1444
+ baseGeneration: patch.base_generation
1445
+ });
1446
+ return { file: resolvedPath, status: "merged" };
1164
1447
  }
1165
- const parentSha = parents[0];
1166
- newPatches.push({
1167
- id: `patch-${commit.sha.slice(0, 8)}`,
1168
- content_hash: contentHash,
1169
- original_commit: commit.sha,
1170
- original_message: commit.message,
1171
- original_author: `${commit.authorName} <${commit.authorEmail}>`,
1172
- base_generation: parentSha,
1173
- files,
1174
- patch_content: patchContent
1175
- });
1176
- }
1177
- newPatches.reverse();
1178
- return { patches: newPatches, revertedPatchIds: [] };
1179
- }
1180
- /**
1181
- * Check if a format-patch consists entirely of new-file creations.
1182
- * Used to identify squashed commits after force push, which create all files
1183
- * from scratch (--- /dev/null) rather than modifying existing files.
1184
- */
1185
- isCreationOnlyPatch(patchContent) {
1186
- const diffOldHeaders = patchContent.split("\n").filter((l) => l.startsWith("--- "));
1187
- if (diffOldHeaders.length === 0) {
1188
- return false;
1448
+ if (effective_theirs == null) {
1449
+ return {
1450
+ file: resolvedPath,
1451
+ status: "skipped",
1452
+ reason: "missing-content"
1453
+ };
1454
+ }
1455
+ if (base == null && !useAccumulatorAsMergeBase || ours == null) {
1456
+ return {
1457
+ file: resolvedPath,
1458
+ status: "skipped",
1459
+ reason: "missing-content"
1460
+ };
1461
+ }
1462
+ const mergeBase = useAccumulatorAsMergeBase && accumulatorEntry ? accumulatorEntry.content : base;
1463
+ if (mergeBase == null) {
1464
+ return {
1465
+ file: resolvedPath,
1466
+ status: "skipped",
1467
+ reason: "missing-content"
1468
+ };
1469
+ }
1470
+ const merged = threeWayMerge(mergeBase, ours, effective_theirs);
1471
+ const outDir = dirname2(oursPath);
1472
+ await mkdir(outDir, { recursive: true });
1473
+ await writeFile(oursPath, merged.content);
1474
+ const populateAccumulator = effective_theirs != null && (!merged.hasConflicts || this.currentApplyMode === "resolve");
1475
+ if (populateAccumulator) {
1476
+ this.fileTheirsAccumulator.set(resolvedPath, {
1477
+ content: effective_theirs,
1478
+ baseGeneration: patch.base_generation
1479
+ });
1480
+ }
1481
+ if (merged.hasConflicts) {
1482
+ return {
1483
+ file: resolvedPath,
1484
+ status: "conflict",
1485
+ conflicts: merged.conflicts,
1486
+ conflictReason: baseMismatchSkipped ? "base-generation-mismatch" : "same-line-edit",
1487
+ conflictMetadata: metadata
1488
+ };
1489
+ }
1490
+ return {
1491
+ file: resolvedPath,
1492
+ status: "merged",
1493
+ ...merged.droppedContextLines && merged.droppedContextLines.length > 0 ? { droppedContextLines: merged.droppedContextLines } : {}
1494
+ };
1495
+ } catch (error) {
1496
+ return {
1497
+ file: filePath,
1498
+ status: "skipped",
1499
+ reason: `error: ${error instanceof Error ? error.message : String(error)}`
1500
+ };
1189
1501
  }
1190
- return diffOldHeaders.every((l) => l === "--- /dev/null");
1191
1502
  }
1192
- /**
1193
- * Resolve the best available diff base for a generation record.
1194
- * Prefers commit_sha, falls back to tree_hash for unreachable commits.
1195
- * When commitKnownMissing is true, skips the redundant commitExists check.
1196
- */
1197
- async resolveDiffBase(gen, commitKnownMissing) {
1198
- if (!commitKnownMissing && await this.git.commitExists(gen.commit_sha)) {
1199
- return gen.commit_sha;
1200
- }
1201
- if (await this.git.treeExists(gen.tree_hash)) {
1202
- return gen.tree_hash;
1503
+ async isTreeReachable(treeHash) {
1504
+ let result = this.treeExistsCache.get(treeHash);
1505
+ if (result === void 0) {
1506
+ result = await this.git.treeExists(treeHash);
1507
+ this.treeExistsCache.set(treeHash, result);
1203
1508
  }
1204
- return null;
1205
- }
1206
- parseGitLog(log) {
1207
- return log.trim().split("\n").map((line) => {
1208
- const [sha, authorName, authorEmail, message] = line.split("\0");
1209
- return { sha, authorName, authorEmail, message };
1210
- });
1509
+ return result;
1211
1510
  }
1212
- getLastGeneration(lock) {
1213
- return lock.generations.find((g) => g.commit_sha === lock.current_generation);
1511
+ isExcluded(patch) {
1512
+ const config = this.lockManager.getCustomizationsConfig();
1513
+ if (!config.exclude) return false;
1514
+ return patch.files.some((file) => config.exclude.some((pattern) => minimatch(file, pattern)));
1214
1515
  }
1215
- };
1216
-
1217
- // src/ThreeWayMerge.ts
1218
- import { diff3Merge, diffPatch } from "node-diff3";
1219
- function threeWayMerge(base, ours, theirs) {
1220
- const baseLines = base.split("\n");
1221
- const oursLines = ours.split("\n");
1222
- const theirsLines = theirs.split("\n");
1223
- const regions = diff3Merge(oursLines, baseLines, theirsLines);
1224
- const outputLines = [];
1225
- const conflicts = [];
1226
- let currentLine = 1;
1227
- for (const region of regions) {
1228
- if (region.ok) {
1229
- outputLines.push(...region.ok);
1230
- currentLine += region.ok.length;
1231
- } else if (region.conflict) {
1232
- const resolved = tryResolveConflict(
1233
- region.conflict.a,
1234
- // ours (generator)
1235
- region.conflict.o,
1236
- // base
1237
- region.conflict.b
1238
- // theirs (user)
1239
- );
1240
- if (resolved !== null) {
1241
- outputLines.push(...resolved);
1242
- currentLine += resolved.length;
1243
- } else {
1244
- const startLine = currentLine;
1245
- outputLines.push("<<<<<<< Generated");
1246
- outputLines.push(...region.conflict.a);
1247
- outputLines.push("=======");
1248
- outputLines.push(...region.conflict.b);
1249
- outputLines.push(">>>>>>> Your customization");
1250
- const conflictLines = region.conflict.a.length + region.conflict.b.length + 3;
1251
- conflicts.push({
1252
- startLine,
1253
- endLine: startLine + conflictLines - 1,
1254
- ours: region.conflict.a,
1255
- theirs: region.conflict.b
1256
- });
1257
- currentLine += conflictLines;
1516
+ async resolveFilePath(filePath, baseTreeHash, currentTreeHash) {
1517
+ const config = this.lockManager.getCustomizationsConfig();
1518
+ if (config.moves) {
1519
+ for (const move of config.moves) {
1520
+ if (minimatch(filePath, move.from) || filePath === move.from) {
1521
+ if (filePath === move.from) {
1522
+ return move.to;
1523
+ }
1524
+ const fromBase = move.from.replace(/\*\*.*$/, "");
1525
+ const toBase = move.to.replace(/\*\*.*$/, "");
1526
+ if (filePath.startsWith(fromBase)) {
1527
+ return toBase + filePath.slice(fromBase.length);
1528
+ }
1529
+ }
1258
1530
  }
1259
1531
  }
1532
+ const cacheKey = `${baseTreeHash}:${currentTreeHash}`;
1533
+ let renames = this.renameCache.get(cacheKey);
1534
+ if (!renames) {
1535
+ renames = await this.git.detectRenames(baseTreeHash, currentTreeHash);
1536
+ this.renameCache.set(cacheKey, renames);
1537
+ }
1538
+ const gitRename = renames.find((r) => r.from === filePath);
1539
+ if (gitRename) {
1540
+ return gitRename.to;
1541
+ }
1542
+ return filePath;
1260
1543
  }
1261
- const result = {
1262
- content: outputLines.join("\n"),
1263
- hasConflicts: conflicts.length > 0,
1264
- conflicts
1265
- };
1266
- if (conflicts.length === 0) {
1267
- const dropped = computeDroppedContextLines(baseLines, theirsLines, outputLines);
1268
- if (dropped.length > 0) {
1269
- result.droppedContextLines = dropped;
1544
+ async applyPatchToContent(base, patchContent, filePath, tempGit, tempDir, sourceFilePath) {
1545
+ if (!base) {
1546
+ return this.extractNewFileFromPatch(patchContent, filePath);
1270
1547
  }
1271
- }
1272
- return result;
1273
- }
1274
- function computeDroppedContextLines(baseLines, theirsLines, mergedLines) {
1275
- const baseCounts = countLines(baseLines);
1276
- const theirsCounts = countLines(theirsLines);
1277
- const mergedCounts = countLines(mergedLines);
1278
- const dropped = [];
1279
- const seen = /* @__PURE__ */ new Set();
1280
- for (const line of baseLines) {
1281
- if (seen.has(line)) continue;
1282
- const inBase = baseCounts.get(line) ?? 0;
1283
- const inTheirs = theirsCounts.get(line) ?? 0;
1284
- const inMerged = mergedCounts.get(line) ?? 0;
1285
- if (inBase > 0 && inTheirs > 0 && inMerged < Math.min(inBase, inTheirs)) {
1286
- dropped.push(line);
1287
- seen.add(line);
1548
+ const fileDiff = this.extractFileDiff(patchContent, filePath);
1549
+ if (!fileDiff) return null;
1550
+ try {
1551
+ if (sourceFilePath) {
1552
+ const tempSourcePath = join2(tempDir, sourceFilePath);
1553
+ await mkdir(dirname2(tempSourcePath), { recursive: true });
1554
+ await writeFile(tempSourcePath, base);
1555
+ await tempGit.exec(["add", sourceFilePath]);
1556
+ await tempGit.exec([
1557
+ "commit",
1558
+ "-m",
1559
+ `base for rename ${sourceFilePath} -> ${filePath}`,
1560
+ "--allow-empty"
1561
+ ]);
1562
+ await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
1563
+ const tempTargetPath = join2(tempDir, filePath);
1564
+ return await readFile(tempTargetPath, "utf-8");
1565
+ }
1566
+ const tempFilePath = join2(tempDir, filePath);
1567
+ await mkdir(dirname2(tempFilePath), { recursive: true });
1568
+ await writeFile(tempFilePath, base);
1569
+ await tempGit.exec(["add", filePath]);
1570
+ await tempGit.exec(["commit", "-m", `base for ${filePath}`, "--allow-empty"]);
1571
+ await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
1572
+ return await readFile(tempFilePath, "utf-8");
1573
+ } catch {
1574
+ return null;
1288
1575
  }
1289
1576
  }
1290
- return dropped;
1291
- }
1292
- function countLines(lines) {
1293
- const counts = /* @__PURE__ */ new Map();
1294
- for (const line of lines) {
1295
- counts.set(line, (counts.get(line) ?? 0) + 1);
1296
- }
1297
- return counts;
1298
- }
1299
- function tryResolveConflict(oursLines, baseLines, theirsLines) {
1300
- if (baseLines.length === 0) {
1301
- return null;
1302
- }
1303
- const oursPatches = diffPatch(baseLines, oursLines);
1304
- const theirsPatches = diffPatch(baseLines, theirsLines);
1305
- if (oursPatches.length === 0) return theirsLines;
1306
- if (theirsPatches.length === 0) return oursLines;
1307
- if (patchesOverlap(oursPatches, theirsPatches)) {
1308
- return null;
1577
+ /**
1578
+ * Detects whether a patch's additions are already present in `content`.
1579
+ * Uses `git apply --reverse --check` — if the reverse patch applies cleanly,
1580
+ * the forward patch is effectively a no-op (its +lines are already there and
1581
+ * its -lines are already gone). Used to distinguish "patch already applied"
1582
+ * from "patch base mismatch" after a forward-apply fails.
1583
+ */
1584
+ async isPatchAlreadyApplied(patchContent, filePath, content, tempGit, tempDir) {
1585
+ const fileDiff = this.extractFileDiff(patchContent, filePath);
1586
+ if (!fileDiff) return false;
1587
+ try {
1588
+ const tempFilePath = join2(tempDir, filePath);
1589
+ await mkdir(dirname2(tempFilePath), { recursive: true });
1590
+ await writeFile(tempFilePath, content);
1591
+ await tempGit.exec(["add", filePath]);
1592
+ await tempGit.exec(["commit", "-m", `check already-applied ${filePath}`, "--allow-empty"]);
1593
+ await tempGit.execWithInput(["apply", "--reverse", "--check", "--allow-empty"], fileDiff);
1594
+ return true;
1595
+ } catch {
1596
+ return false;
1597
+ }
1309
1598
  }
1310
- return applyBothPatches(baseLines, oursPatches, theirsPatches);
1311
- }
1312
- function patchesOverlap(oursPatches, theirsPatches) {
1313
- for (const op of oursPatches) {
1314
- const oStart = op.buffer1.offset;
1315
- const oEnd = oStart + op.buffer1.length;
1316
- for (const tp of theirsPatches) {
1317
- const tStart = tp.buffer1.offset;
1318
- const tEnd = tStart + tp.buffer1.length;
1319
- if (op.buffer1.length === 0 && tp.buffer1.length === 0 && oStart === tStart) {
1320
- return true;
1599
+ extractFileDiff(patchContent, filePath) {
1600
+ const lines = patchContent.split("\n");
1601
+ const diffLines = [];
1602
+ let inTargetFile = false;
1603
+ for (const line of lines) {
1604
+ if (line.startsWith("diff --git")) {
1605
+ if (inTargetFile) {
1606
+ break;
1607
+ }
1608
+ if (isDiffLineForFile(line, filePath)) {
1609
+ inTargetFile = true;
1610
+ diffLines.push(line);
1611
+ }
1612
+ continue;
1321
1613
  }
1322
- if (tp.buffer1.length === 0 && tStart === oEnd) {
1323
- return true;
1614
+ if (inTargetFile) {
1615
+ diffLines.push(line);
1324
1616
  }
1325
- if (op.buffer1.length === 0 && oStart === tEnd) {
1326
- return true;
1617
+ }
1618
+ return diffLines.length > 0 ? diffLines.join("\n") + "\n" : null;
1619
+ }
1620
+ extractRenameSource(patchContent, targetFilePath) {
1621
+ const lines = patchContent.split("\n");
1622
+ let inTargetFile = false;
1623
+ for (const line of lines) {
1624
+ if (line.startsWith("diff --git")) {
1625
+ if (inTargetFile) break;
1626
+ inTargetFile = isDiffLineForFile(line, targetFilePath);
1627
+ continue;
1327
1628
  }
1328
- if (oStart < tEnd && tStart < oEnd) {
1329
- return true;
1629
+ if (!inTargetFile) continue;
1630
+ if (line.startsWith("@@")) break;
1631
+ if (line.startsWith("rename from ")) {
1632
+ return line.slice("rename from ".length);
1330
1633
  }
1331
1634
  }
1635
+ return null;
1332
1636
  }
1333
- return false;
1334
- }
1335
- function applyBothPatches(baseLines, oursPatches, theirsPatches) {
1336
- const allPatches = [
1337
- ...oursPatches.map((p) => ({
1338
- offset: p.buffer1.offset,
1339
- length: p.buffer1.length,
1340
- replacement: p.buffer2.chunk
1341
- })),
1342
- ...theirsPatches.map((p) => ({
1343
- offset: p.buffer1.offset,
1344
- length: p.buffer1.length,
1345
- replacement: p.buffer2.chunk
1346
- }))
1347
- ];
1348
- allPatches.sort((a, b) => b.offset - a.offset);
1349
- const result = [...baseLines];
1350
- for (const p of allPatches) {
1351
- result.splice(p.offset, p.length, ...p.replacement);
1352
- }
1353
- return result;
1354
- }
1355
-
1356
- // src/ReplayApplicator.ts
1357
- import { mkdir, mkdtemp, readFile, rm, unlink, writeFile } from "fs/promises";
1358
- import { tmpdir } from "os";
1359
- import { dirname as dirname2, extname, join as join2 } from "path";
1360
- import { minimatch } from "minimatch";
1361
-
1362
- // src/conflict-utils.ts
1363
- var CONFLICT_OPENER = "<<<<<<< Generated";
1364
- var CONFLICT_SEPARATOR = "=======";
1365
- var CONFLICT_CLOSER = ">>>>>>> Your customization";
1366
- function trimCR(line) {
1367
- return line.endsWith("\r") ? line.slice(0, -1) : line;
1368
- }
1369
- function findConflictRanges(lines) {
1370
- const ranges = [];
1371
- let i = 0;
1372
- while (i < lines.length) {
1373
- if (trimCR(lines[i]) === CONFLICT_OPENER) {
1374
- let separatorIdx = -1;
1375
- let j = i + 1;
1376
- let found = false;
1377
- while (j < lines.length) {
1378
- const trimmed = trimCR(lines[j]);
1379
- if (trimmed === CONFLICT_OPENER) {
1380
- break;
1381
- }
1382
- if (separatorIdx === -1 && trimmed === CONFLICT_SEPARATOR) {
1383
- separatorIdx = j;
1384
- } else if (separatorIdx !== -1 && trimmed === CONFLICT_CLOSER) {
1385
- ranges.push({ start: i, separator: separatorIdx, end: j });
1386
- i = j;
1387
- found = true;
1388
- break;
1637
+ extractNewFileFromPatch(patchContent, filePath) {
1638
+ const lines = patchContent.split("\n");
1639
+ const addedLines = [];
1640
+ let inTargetFile = false;
1641
+ let inHunk = false;
1642
+ let isNewFile = false;
1643
+ let noTrailingNewline = false;
1644
+ for (const line of lines) {
1645
+ if (line.startsWith("diff --git")) {
1646
+ if (inTargetFile) break;
1647
+ inTargetFile = isDiffLineForFile(line, filePath);
1648
+ inHunk = false;
1649
+ isNewFile = false;
1650
+ continue;
1651
+ }
1652
+ if (!inTargetFile) continue;
1653
+ if (!inHunk) {
1654
+ if (line === "--- /dev/null" || line.startsWith("new file mode")) {
1655
+ isNewFile = true;
1389
1656
  }
1390
- j++;
1391
1657
  }
1392
- if (!found) {
1393
- i++;
1658
+ if (line.startsWith("@@")) {
1659
+ if (!isNewFile) return null;
1660
+ inHunk = true;
1661
+ continue;
1662
+ }
1663
+ if (!inHunk) continue;
1664
+ if (line === "\") {
1665
+ noTrailingNewline = true;
1394
1666
  continue;
1395
1667
  }
1668
+ if (line.startsWith("+") && !line.startsWith("+++")) {
1669
+ addedLines.push(line.slice(1));
1670
+ }
1671
+ }
1672
+ if (addedLines.length === 0) return null;
1673
+ return addedLines.join("\n") + (noTrailingNewline ? "" : "\n");
1674
+ }
1675
+ };
1676
+ function isBinaryFile(filePath) {
1677
+ const ext = extname(filePath).toLowerCase();
1678
+ return BINARY_EXTENSIONS.has(ext);
1679
+ }
1680
+ function computeDroppedContextLinesForFile(base, theirs, finalContent) {
1681
+ const baseLines = base.split("\n");
1682
+ const theirsLines = theirs.split("\n");
1683
+ const finalLines = finalContent.split("\n");
1684
+ const baseCounts = /* @__PURE__ */ new Map();
1685
+ const theirsCounts = /* @__PURE__ */ new Map();
1686
+ const finalCounts = /* @__PURE__ */ new Map();
1687
+ for (const l of baseLines) baseCounts.set(l, (baseCounts.get(l) ?? 0) + 1);
1688
+ for (const l of theirsLines) theirsCounts.set(l, (theirsCounts.get(l) ?? 0) + 1);
1689
+ for (const l of finalLines) finalCounts.set(l, (finalCounts.get(l) ?? 0) + 1);
1690
+ const dropped = [];
1691
+ const seen = /* @__PURE__ */ new Set();
1692
+ for (const line of baseLines) {
1693
+ if (seen.has(line)) continue;
1694
+ const inBase = baseCounts.get(line) ?? 0;
1695
+ const inTheirs = theirsCounts.get(line) ?? 0;
1696
+ const inFinal = finalCounts.get(line) ?? 0;
1697
+ if (inBase > 0 && inTheirs > 0 && inFinal < Math.min(inBase, inTheirs)) {
1698
+ dropped.push(line);
1699
+ seen.add(line);
1396
1700
  }
1397
- i++;
1398
1701
  }
1399
- return ranges;
1702
+ return dropped;
1400
1703
  }
1401
- function stripConflictMarkers(content) {
1402
- const lines = content.split(/\r?\n/);
1403
- const ranges = findConflictRanges(lines);
1404
- if (ranges.length === 0) {
1405
- return content;
1406
- }
1407
- const result = [];
1408
- let rangeIdx = 0;
1409
- for (let i = 0; i < lines.length; i++) {
1410
- if (rangeIdx < ranges.length) {
1411
- const range = ranges[rangeIdx];
1412
- if (i === range.start) {
1413
- continue;
1414
- }
1415
- if (i > range.start && i < range.separator) {
1416
- result.push(lines[i]);
1417
- continue;
1418
- }
1419
- if (i === range.separator) {
1420
- continue;
1421
- }
1422
- if (i > range.separator && i < range.end) {
1423
- continue;
1704
+ function isDiffLineForFile(diffLine, filePath) {
1705
+ const match = diffLine.match(/^diff --git a\/.+ b\/(.+)$/);
1706
+ return match !== null && match[1] === filePath;
1707
+ }
1708
+
1709
+ // src/ReplayDetector.ts
1710
+ var INFRASTRUCTURE_FILES = /* @__PURE__ */ new Set([".fernignore"]);
1711
+ function parsePatchFileHeaders(patchContent) {
1712
+ const results = [];
1713
+ let currentPath = null;
1714
+ let currentIsCreate = false;
1715
+ let currentIsDelete = false;
1716
+ const flush = () => {
1717
+ if (currentPath !== null) {
1718
+ results.push({ path: currentPath, isCreate: currentIsCreate, isDelete: currentIsDelete });
1719
+ }
1720
+ };
1721
+ for (const line of patchContent.split("\n")) {
1722
+ if (line.startsWith("diff --git ")) {
1723
+ flush();
1724
+ currentPath = null;
1725
+ currentIsCreate = false;
1726
+ currentIsDelete = false;
1727
+ const match = line.match(/^diff --git a\/(.+?) b\/(.+?)$/);
1728
+ if (match) {
1729
+ currentPath = match[2] ?? null;
1424
1730
  }
1425
- if (i === range.end) {
1426
- rangeIdx++;
1427
- continue;
1731
+ } else if (currentPath !== null) {
1732
+ if (line.startsWith("new file mode ")) {
1733
+ currentIsCreate = true;
1734
+ } else if (line.startsWith("deleted file mode ")) {
1735
+ currentIsDelete = true;
1428
1736
  }
1429
1737
  }
1430
- result.push(lines[i]);
1431
1738
  }
1432
- return result.join("\n");
1739
+ flush();
1740
+ return results;
1433
1741
  }
1434
-
1435
- // src/ReplayApplicator.ts
1436
- var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
1437
- ".png",
1438
- ".jpg",
1439
- ".jpeg",
1440
- ".gif",
1441
- ".bmp",
1442
- ".ico",
1443
- ".webp",
1444
- ".svg",
1445
- ".pdf",
1446
- ".doc",
1447
- ".docx",
1448
- ".xls",
1449
- ".xlsx",
1450
- ".ppt",
1451
- ".pptx",
1452
- ".zip",
1453
- ".gz",
1454
- ".tar",
1455
- ".bz2",
1456
- ".7z",
1457
- ".rar",
1458
- ".jar",
1459
- ".war",
1460
- ".ear",
1461
- ".class",
1462
- ".exe",
1463
- ".dll",
1464
- ".so",
1465
- ".dylib",
1466
- ".o",
1467
- ".a",
1468
- ".woff",
1469
- ".woff2",
1470
- ".ttf",
1471
- ".eot",
1472
- ".otf",
1473
- ".mp3",
1474
- ".mp4",
1475
- ".avi",
1476
- ".mov",
1477
- ".wav",
1478
- ".flac",
1479
- ".sqlite",
1480
- ".db",
1481
- ".pyc",
1482
- ".pyo",
1483
- ".DS_Store"
1484
- ]);
1485
- var ReplayApplicator = class {
1742
+ var ReplayDetector = class {
1486
1743
  git;
1487
1744
  lockManager;
1488
- outputDir;
1489
- renameCache = /* @__PURE__ */ new Map();
1490
- treeExistsCache = /* @__PURE__ */ new Map();
1491
- fileTheirsAccumulator = /* @__PURE__ */ new Map();
1492
- /**
1493
- * Apply mode for the current `applyPatches` invocation. Set at the start
1494
- * of `applyPatches`, read by `mergeFile` to decide:
1495
- * - whether to skip the intra-loop marker strip (kept in `applyPatches`)
1496
- * - whether to populate the accumulator after a conflicted merge
1497
- * (resolve mode populates so subsequent patches on the same file
1498
- * can use patch A's THEIRS as a structurally-correct merge base
1499
- * when their diff was authored against post-A structure)
1500
- * - whether to retry THEIRS reconstruction against the accumulator
1501
- * when the BASE-relative reconstruction produced markers from a
1502
- * cross-patch context mismatch
1503
- */
1504
- currentApplyMode = "replay";
1505
- constructor(git, lockManager, outputDir) {
1745
+ sdkOutputDir;
1746
+ warnings = [];
1747
+ constructor(git, lockManager, sdkOutputDir) {
1506
1748
  this.git = git;
1507
1749
  this.lockManager = lockManager;
1508
- this.outputDir = outputDir;
1509
- }
1510
- /**
1511
- * Resolve the GenerationRecord for a patch's base_generation.
1512
- * Falls back to constructing an ad-hoc record from the commit's tree
1513
- * when base_generation isn't a tracked generation (commit-scan patches).
1514
- */
1515
- async resolveBaseGeneration(baseGeneration) {
1516
- const gen = this.lockManager.getGeneration(baseGeneration);
1517
- if (gen) return gen;
1518
- try {
1519
- const treeHash = await this.git.getTreeHash(baseGeneration);
1520
- return {
1521
- commit_sha: baseGeneration,
1522
- tree_hash: treeHash,
1523
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1524
- cli_version: "unknown",
1525
- generator_versions: {}
1526
- };
1527
- } catch {
1528
- return void 0;
1529
- }
1530
- }
1531
- /** Reset inter-patch accumulator for a new cycle. */
1532
- resetAccumulator() {
1533
- this.fileTheirsAccumulator.clear();
1750
+ this.sdkOutputDir = sdkOutputDir;
1534
1751
  }
1535
- /**
1536
- * @internal Test-only.
1537
- * Returns a defensive copy of the inter-patch accumulator. Used by the
1538
- * adversarial test suite (FER-9791) to assert invariant I2: after every
1539
- * applied patch, the accumulator has an entry for each file the patch
1540
- * touched, and the entry's content matches on-disk.
1541
- */
1542
- getAccumulatorSnapshot() {
1543
- const snapshot = /* @__PURE__ */ new Map();
1544
- for (const [path, entry] of this.fileTheirsAccumulator) {
1545
- snapshot.set(path, { content: entry.content, baseGeneration: entry.baseGeneration });
1752
+ async detectNewPatches() {
1753
+ const lock = this.lockManager.read();
1754
+ const lastGen = this.getLastGeneration(lock);
1755
+ if (!lastGen) {
1756
+ return { patches: [], revertedPatchIds: [] };
1546
1757
  }
1547
- return snapshot;
1548
- }
1549
- /**
1550
- * Apply all patches, returning results for each.
1551
- * Skips patches that match exclude patterns in replay.yml
1552
- *
1553
- * `applyMode` controls the post-conflict marker strategy:
1554
- * - `"replay"` (default): strip conflict markers from disk between
1555
- * iterations whenever a later patch touches the same file. Lets the
1556
- * follow-up patch's `git apply --3way` see clean OURS content,
1557
- * which is what the silent-loss fix (PR #73) relies on. The replay
1558
- * pipeline calls `revertConflictingFiles` after the loop to clean
1559
- * any markers that survive.
1560
- * - `"resolve"`: keep markers on disk. The resolve command needs the
1561
- * customer to see them. Slow-path 3-way merge naturally preserves
1562
- * A's markers and B's clean writes when their regions don't overlap;
1563
- * when they do overlap, the customer gets nested markers and
1564
- * resolves manually. Either way they aren't silently dropped.
1565
- */
1566
- async applyPatches(patches, opts) {
1567
- const applyMode = opts?.applyMode ?? "replay";
1568
- this.currentApplyMode = applyMode;
1569
- this.resetAccumulator();
1570
- const results = [];
1571
- for (let i = 0; i < patches.length; i++) {
1572
- const patch = patches[i];
1573
- if (this.isExcluded(patch)) {
1574
- results.push({
1575
- patch,
1576
- status: "skipped",
1577
- method: "git-am"
1578
- });
1579
- continue;
1580
- }
1581
- const result = await this.applyPatchWithFallback(patch);
1582
- results.push(result);
1583
- if (applyMode !== "resolve" && result.status === "conflict" && result.fileResults) {
1584
- const laterFiles = /* @__PURE__ */ new Set();
1585
- for (let j = i + 1; j < patches.length; j++) {
1586
- for (const f of patches[j].files) {
1587
- laterFiles.add(f);
1588
- }
1589
- }
1590
- const resolvedToOriginal = /* @__PURE__ */ new Map();
1591
- if (result.resolvedFiles) {
1592
- for (const [orig, resolved] of Object.entries(result.resolvedFiles)) {
1593
- resolvedToOriginal.set(resolved, orig);
1594
- }
1595
- }
1596
- for (const fileResult of result.fileResults) {
1597
- if (fileResult.status !== "conflict") continue;
1598
- const originalPath = resolvedToOriginal.get(fileResult.file) ?? fileResult.file;
1599
- if (laterFiles.has(fileResult.file) || laterFiles.has(originalPath)) {
1600
- const filePath = join2(this.outputDir, fileResult.file);
1601
- try {
1602
- const content = await readFile(filePath, "utf-8");
1603
- const stripped = stripConflictMarkers(content);
1604
- await writeFile(filePath, stripped);
1605
- } catch {
1606
- }
1607
- }
1608
- }
1609
- }
1758
+ const exists = await this.git.commitExists(lastGen.commit_sha);
1759
+ if (!exists) {
1760
+ this.warnings.push(
1761
+ `Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to alternate detection.`
1762
+ );
1763
+ return this.detectPatchesViaTreeDiff(
1764
+ lastGen,
1765
+ /* commitKnownMissing */
1766
+ true
1767
+ );
1610
1768
  }
1611
- return results;
1769
+ const isAncestor = await this.git.isAncestor(lastGen.commit_sha, "HEAD");
1770
+ if (!isAncestor) {
1771
+ return this.detectPatchesViaMergeBase(lastGen, lock);
1772
+ }
1773
+ return this.detectPatchesInRange(lastGen.commit_sha, lastGen, lock);
1612
1774
  }
1613
1775
  /**
1614
- * Populate accumulator after git apply succeeds, AND collect per-file
1615
- * results including any "dropped context lines" — lines that were
1616
- * present in BASE and THEIRS (unchanged context) but absent from the
1617
- * final on-disk state because OURS deleted them. This is the fast-path
1618
- * counterpart to ThreeWayMerge.computeDroppedContextLines used by the
1619
- * 3-way slow path; both must surface the same warning.
1776
+ * FER-10201 Non-linear-history detection via `merge-base(prevGen, HEAD)`.
1777
+ *
1778
+ * After a squash-merge of a Fern-bot PR, `lastGen.commit_sha` (the previous
1779
+ * `[fern-generated]`) is orphaned but still in git's object database. The
1780
+ * merge-base is the commit on main from which the bot's branch was created —
1781
+ * a reachable ancestor of HEAD. Walking that range and classifying each
1782
+ * commit via `isGenerationCommit` mirrors the linear path and eliminates
1783
+ * the bug class where pipeline-driven changes (autoversion, replay,
1784
+ * generation) get bundled as customer customizations.
1785
+ *
1786
+ * Falls through to the legacy composite path when no common ancestor exists
1787
+ * (truly disjoint history — orphan branches with no shared root).
1620
1788
  */
1621
- async populateAccumulatorForPatch(patch, baseGen, currentTreeHash) {
1622
- const fileResults = [];
1623
- if (!baseGen) return fileResults;
1624
- const tempDir = await mkdtemp(join2(tmpdir(), "replay-acc-"));
1625
- const { GitClient: GitClient2 } = await Promise.resolve().then(() => (init_GitClient(), GitClient_exports));
1626
- const tempGit = new GitClient2(tempDir);
1627
- await tempGit.exec(["init"]);
1628
- await tempGit.exec(["config", "user.email", "replay@fern.com"]);
1629
- await tempGit.exec(["config", "user.name", "Fern Replay"]);
1789
+ async detectPatchesViaMergeBase(lastGen, lock) {
1790
+ let mergeBase = "";
1630
1791
  try {
1631
- for (const filePath of patch.files) {
1632
- if (isBinaryFile(filePath)) continue;
1633
- const resolvedPath = await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
1634
- const base = await this.git.showFile(baseGen.tree_hash, filePath);
1635
- const theirs = await this.applyPatchToContent(base, patch.patch_content, filePath, tempGit, tempDir);
1636
- const finalOnDisk = await readFile(join2(this.outputDir, resolvedPath), "utf-8").catch(() => null);
1637
- const effectiveTheirs = theirs ?? finalOnDisk;
1638
- if (effectiveTheirs != null) {
1639
- this.fileTheirsAccumulator.set(resolvedPath, {
1640
- content: effectiveTheirs,
1641
- baseGeneration: patch.base_generation
1642
- });
1643
- }
1644
- if (base != null && effectiveTheirs != null && finalOnDisk != null) {
1645
- const dropped = computeDroppedContextLinesForFile(base, effectiveTheirs, finalOnDisk);
1646
- if (dropped.length > 0) {
1647
- fileResults.push({
1648
- file: resolvedPath,
1649
- status: "merged",
1650
- droppedContextLines: dropped
1651
- });
1652
- }
1653
- }
1654
- }
1655
- } finally {
1656
- await rm(tempDir, { recursive: true }).catch(() => {
1657
- });
1792
+ mergeBase = (await this.git.exec(["merge-base", lastGen.commit_sha, "HEAD"])).trim();
1793
+ } catch {
1658
1794
  }
1659
- return fileResults;
1795
+ if (!mergeBase) {
1796
+ this.warnings.push(
1797
+ `No common ancestor between previous generation ${lastGen.commit_sha.slice(0, 7)} and HEAD. Falling back to composite tree-diff detection.`
1798
+ );
1799
+ return this.detectPatchesViaTreeDiff(
1800
+ lastGen,
1801
+ /* commitKnownMissing */
1802
+ false
1803
+ );
1804
+ }
1805
+ return this.detectPatchesInRange(mergeBase, lastGen, lock);
1660
1806
  }
1661
- async applyPatchWithFallback(patch) {
1662
- const baseGen = await this.resolveBaseGeneration(patch.base_generation);
1663
- const lock = this.lockManager.read();
1664
- const currentGen = lock.generations.find((g) => g.commit_sha === lock.current_generation);
1665
- const currentTreeHash = currentGen?.tree_hash ?? baseGen?.tree_hash ?? "";
1666
- const needsAccumulation = await Promise.all(
1667
- patch.files.map(async (f) => {
1668
- if (!baseGen) return false;
1669
- const resolved = await this.resolveFilePath(f, baseGen.tree_hash, currentTreeHash);
1670
- return this.fileTheirsAccumulator.has(resolved);
1671
- })
1672
- ).then((results) => results.some(Boolean));
1673
- const resolvedFiles = {};
1674
- for (const filePath of patch.files) {
1675
- const resolvedPath = baseGen ? await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash) : filePath;
1676
- if (resolvedPath !== filePath) {
1677
- resolvedFiles[filePath] = resolvedPath;
1678
- }
1807
+ /**
1808
+ * FER-10201 Walk `${rangeStart}..HEAD`, classify each commit, emit one
1809
+ * `StoredPatch` per customer commit. Body shared by the linear path
1810
+ * (rangeStart = lastGen.commit_sha) and the merge-base path.
1811
+ *
1812
+ * Patch `base_generation` is always `lastGen.commit_sha` — the
1813
+ * lockfile-tracked reference the applicator uses to fetch base file
1814
+ * content, regardless of where the walk actually started.
1815
+ */
1816
+ async detectPatchesInRange(rangeStart, lastGen, lock) {
1817
+ const log = await this.git.exec([
1818
+ "log",
1819
+ "--first-parent",
1820
+ "--format=%H%x00%an%x00%ae%x00%s",
1821
+ `${rangeStart}..HEAD`,
1822
+ "--",
1823
+ this.sdkOutputDir
1824
+ ]);
1825
+ if (!log.trim()) {
1826
+ return { patches: [], revertedPatchIds: [] };
1679
1827
  }
1680
- const patchIsRenameAware = /^rename from /m.test(patch.patch_content);
1681
- const hasGeneratorRename = Object.keys(resolvedFiles).length > 0 && !patchIsRenameAware;
1682
- if (!needsAccumulation && !hasGeneratorRename) {
1683
- const snapshots = /* @__PURE__ */ new Map();
1684
- for (const filePath of patch.files) {
1685
- const fullPath = join2(this.outputDir, filePath);
1686
- snapshots.set(filePath, await readFile(fullPath, "utf-8").catch(() => null));
1828
+ const commits = this.parseGitLog(log);
1829
+ const newPatches = [];
1830
+ const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
1831
+ for (const commit of commits) {
1832
+ if (isGenerationCommit(commit)) {
1833
+ continue;
1687
1834
  }
1688
- try {
1689
- await this.git.execWithInput(["apply", "--3way"], patch.patch_content);
1690
- const fastPathFileResults = await this.populateAccumulatorForPatch(patch, baseGen, currentTreeHash);
1691
- return {
1692
- patch,
1693
- status: "applied",
1694
- method: "git-am",
1695
- ...fastPathFileResults.length > 0 && { fileResults: fastPathFileResults },
1696
- ...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
1697
- };
1698
- } catch {
1699
- for (const [filePath, content] of snapshots) {
1700
- const fullPath = join2(this.outputDir, filePath);
1701
- if (content != null) {
1702
- await writeFile(fullPath, content);
1703
- } else {
1704
- await unlink(fullPath).catch(() => {
1705
- });
1706
- }
1707
- }
1835
+ if (lock.patches.find((p) => p.original_commit === commit.sha)) {
1836
+ continue;
1708
1837
  }
1709
- }
1710
- return this.applyWithThreeWayMerge(patch);
1711
- }
1712
- async applyWithThreeWayMerge(patch) {
1713
- const fileResults = [];
1714
- const resolvedFiles = {};
1715
- const tempDir = await mkdtemp(join2(tmpdir(), "replay-"));
1716
- const { GitClient: GitClient2 } = await Promise.resolve().then(() => (init_GitClient(), GitClient_exports));
1717
- const tempGit = new GitClient2(tempDir);
1718
- await tempGit.exec(["init"]);
1719
- await tempGit.exec(["config", "user.email", "replay@fern.com"]);
1720
- await tempGit.exec(["config", "user.name", "Fern Replay"]);
1721
- try {
1722
- for (const filePath of patch.files) {
1723
- if (isBinaryFile(filePath)) {
1724
- fileResults.push({
1725
- file: filePath,
1726
- status: "skipped",
1727
- reason: "binary-file"
1728
- });
1729
- continue;
1730
- }
1731
- const result = await this.mergeFile(patch, filePath, tempGit, tempDir);
1732
- if (result.file !== filePath) {
1733
- resolvedFiles[filePath] = result.file;
1838
+ const parents = await this.git.getCommitParents(commit.sha);
1839
+ if (parents.length > 1) {
1840
+ const compositePatch = await this.createMergeCompositePatch({
1841
+ mergeCommit: commit,
1842
+ firstParent: parents[0],
1843
+ lastGen,
1844
+ lock
1845
+ });
1846
+ if (compositePatch) {
1847
+ newPatches.push(compositePatch);
1734
1848
  }
1735
- fileResults.push(result);
1849
+ continue;
1736
1850
  }
1737
- } finally {
1738
- await rm(tempDir, { recursive: true }).catch(() => {
1739
- });
1740
- }
1741
- const conflictFiles = fileResults.filter((r) => r.status === "conflict");
1742
- const hasConflicts = conflictFiles.length > 0;
1743
- const conflictReason = hasConflicts ? conflictFiles.some((f) => f.conflictReason === "base-generation-mismatch") ? "base-generation-mismatch" : conflictFiles[0]?.conflictReason : void 0;
1744
- return {
1745
- patch,
1746
- status: hasConflicts ? "conflict" : "applied",
1747
- method: "3way-merge",
1748
- fileResults,
1749
- conflictReason,
1750
- ...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
1751
- };
1752
- }
1753
- async mergeFile(patch, filePath, tempGit, tempDir) {
1754
- try {
1755
- const baseGen = await this.resolveBaseGeneration(patch.base_generation);
1756
- if (!baseGen) {
1757
- return { file: filePath, status: "skipped", reason: "base-generation-not-found" };
1851
+ let patchContent;
1852
+ try {
1853
+ patchContent = await this.git.formatPatch(commit.sha);
1854
+ } catch {
1855
+ this.warnings.push(
1856
+ `Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
1857
+ );
1858
+ continue;
1758
1859
  }
1759
- const lock = this.lockManager.read();
1760
- const currentGen = lock.generations.find((g) => g.commit_sha === lock.current_generation);
1761
- const currentTreeHash = currentGen?.tree_hash ?? baseGen.tree_hash;
1762
- const resolvedPath = await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
1763
- const metadata = {
1764
- patchId: patch.id,
1765
- patchMessage: patch.original_message,
1766
- baseGeneration: patch.base_generation,
1767
- currentGeneration: lock.current_generation
1768
- };
1769
- let base = await this.git.showFile(baseGen.tree_hash, filePath);
1770
- let renameSourcePath;
1771
- if (!base) {
1772
- const renameSource = this.extractRenameSource(patch.patch_content, filePath);
1773
- if (renameSource) {
1774
- base = await this.git.showFile(baseGen.tree_hash, renameSource);
1775
- renameSourcePath = renameSource;
1776
- }
1860
+ let contentHash = this.computeContentHash(patchContent);
1861
+ if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
1862
+ continue;
1777
1863
  }
1778
- const oursPath = join2(this.outputDir, resolvedPath);
1779
- const ours = await readFile(oursPath, "utf-8").catch(() => null);
1780
- let ghostReconstructed = false;
1781
- let theirs = null;
1782
- if (!base && ours && !renameSourcePath) {
1783
- const treeReachable = await this.isTreeReachable(baseGen.tree_hash);
1784
- if (!treeReachable) {
1785
- const fileDiff = this.extractFileDiff(patch.patch_content, filePath);
1786
- if (fileDiff) {
1787
- const { reconstructFromGhostPatch: reconstructFromGhostPatch2 } = await Promise.resolve().then(() => (init_HybridReconstruction(), HybridReconstruction_exports));
1788
- const result = reconstructFromGhostPatch2(fileDiff, ours);
1789
- if (result) {
1790
- base = result.base;
1791
- theirs = result.theirs;
1792
- ghostReconstructed = true;
1864
+ const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
1865
+ const allFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
1866
+ const sensitiveFiles = allFiles.filter((f) => isSensitiveFile(f));
1867
+ const files = allFiles.filter((f) => !isSensitiveFile(f));
1868
+ if (sensitiveFiles.length > 0) {
1869
+ this.warnings.push(
1870
+ `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
1871
+ );
1872
+ if (files.length > 0) {
1873
+ const parentSha = parents[0];
1874
+ if (parentSha) {
1875
+ try {
1876
+ patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
1877
+ } catch {
1878
+ continue;
1793
1879
  }
1880
+ if (!patchContent.trim()) continue;
1881
+ contentHash = this.computeContentHash(patchContent);
1794
1882
  }
1795
1883
  }
1796
1884
  }
1797
- if (!ghostReconstructed) {
1798
- theirs = await this.applyPatchToContent(
1799
- base,
1800
- patch.patch_content,
1801
- filePath,
1802
- tempGit,
1803
- tempDir,
1804
- renameSourcePath
1805
- );
1885
+ if (files.length === 0) {
1886
+ continue;
1806
1887
  }
1807
- const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
1808
- if (theirs) {
1809
- const theirsHasMarkers = theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization");
1810
- const baseHasMarkers = base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization"));
1811
- if (theirsHasMarkers && !baseHasMarkers) {
1812
- if (accumulatorEntry) {
1813
- theirs = null;
1814
- } else {
1815
- return {
1816
- file: resolvedPath,
1817
- status: "skipped",
1818
- reason: "stale-conflict-markers"
1819
- };
1820
- }
1821
- }
1888
+ const theirsSnapshot = {};
1889
+ for (const file of files) {
1890
+ if (isBinaryFile(file)) continue;
1891
+ const content = await this.git.showFile(commit.sha, file).catch(() => null);
1892
+ if (content != null) theirsSnapshot[file] = content;
1822
1893
  }
1823
- let useAccumulatorAsMergeBase = false;
1824
- if (accumulatorEntry && (theirs == null || base == null)) {
1825
- theirs = await this.applyPatchToContent(
1826
- accumulatorEntry.content,
1827
- patch.patch_content,
1828
- filePath,
1829
- tempGit,
1830
- tempDir
1831
- );
1832
- if (theirs != null) {
1833
- useAccumulatorAsMergeBase = true;
1834
- } else if (await this.isPatchAlreadyApplied(
1835
- patch.patch_content,
1836
- filePath,
1837
- accumulatorEntry.content,
1838
- tempGit,
1839
- tempDir
1840
- )) {
1841
- theirs = accumulatorEntry.content;
1842
- useAccumulatorAsMergeBase = true;
1843
- }
1894
+ newPatches.push({
1895
+ id: `patch-${commit.sha.slice(0, 8)}`,
1896
+ content_hash: contentHash,
1897
+ original_commit: commit.sha,
1898
+ original_message: commit.message,
1899
+ original_author: `${commit.authorName} <${commit.authorEmail}>`,
1900
+ base_generation: lastGen.commit_sha,
1901
+ files,
1902
+ patch_content: patchContent,
1903
+ ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
1904
+ ...this.isCreationOnlyPatch(patchContent) ? { user_owned: true } : {}
1905
+ });
1906
+ }
1907
+ const survivingPatches = await this.collapseNetZeroFiles(newPatches);
1908
+ survivingPatches.reverse();
1909
+ const revertedPatchIdSet = /* @__PURE__ */ new Set();
1910
+ const revertIndicesToRemove = /* @__PURE__ */ new Set();
1911
+ for (let i = 0; i < survivingPatches.length; i++) {
1912
+ const patch = survivingPatches[i];
1913
+ if (!isRevertCommit(patch.original_message)) continue;
1914
+ let body = "";
1915
+ try {
1916
+ body = await this.git.getCommitBody(patch.original_commit);
1917
+ } catch {
1844
1918
  }
1845
- if (theirs) {
1846
- const theirsHasMarkers = theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization");
1847
- const accBaseHasMarkers = accumulatorEntry != null && (accumulatorEntry.content.includes("<<<<<<< Generated") || accumulatorEntry.content.includes(">>>>>>> Your customization"));
1848
- if (theirsHasMarkers && !accBaseHasMarkers && !(base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization")))) {
1849
- return {
1850
- file: resolvedPath,
1851
- status: "skipped",
1852
- reason: "stale-conflict-markers"
1853
- };
1919
+ const revertedSha = parseRevertedSha(body);
1920
+ const revertedMessage = parseRevertedMessage(patch.original_message);
1921
+ let matchedExisting = false;
1922
+ if (revertedSha) {
1923
+ const existing = lock.patches.find((p) => p.original_commit === revertedSha);
1924
+ if (existing) {
1925
+ revertedPatchIdSet.add(existing.id);
1926
+ revertIndicesToRemove.add(i);
1927
+ matchedExisting = true;
1854
1928
  }
1855
1929
  }
1856
- let effective_theirs = theirs;
1857
- let baseMismatchSkipped = false;
1858
- if (theirs != null && base != null && !useAccumulatorAsMergeBase) {
1859
- if (accumulatorEntry && accumulatorEntry.baseGeneration === patch.base_generation) {
1860
- try {
1861
- const preMerged = threeWayMerge(base, accumulatorEntry.content, theirs);
1862
- if (!preMerged.hasConflicts) {
1863
- effective_theirs = preMerged.content;
1864
- } else {
1865
- effective_theirs = theirs;
1866
- }
1867
- } catch {
1868
- effective_theirs = theirs;
1869
- }
1870
- } else if (accumulatorEntry) {
1871
- baseMismatchSkipped = true;
1930
+ if (!matchedExisting && revertedMessage) {
1931
+ const existing = lock.patches.find((p) => p.original_message === revertedMessage);
1932
+ if (existing) {
1933
+ revertedPatchIdSet.add(existing.id);
1934
+ revertIndicesToRemove.add(i);
1935
+ matchedExisting = true;
1872
1936
  }
1873
1937
  }
1874
- if (base == null && ours == null && effective_theirs != null) {
1875
- this.fileTheirsAccumulator.set(resolvedPath, {
1876
- content: effective_theirs,
1877
- baseGeneration: patch.base_generation
1878
- });
1879
- const outDir2 = dirname2(oursPath);
1880
- await mkdir(outDir2, { recursive: true });
1881
- await writeFile(oursPath, effective_theirs);
1882
- return { file: resolvedPath, status: "merged", reason: "new-file" };
1883
- }
1884
- if (base == null && ours != null && effective_theirs != null && !useAccumulatorAsMergeBase) {
1885
- const merged2 = threeWayMerge("", ours, effective_theirs);
1886
- const outDir2 = dirname2(oursPath);
1887
- await mkdir(outDir2, { recursive: true });
1888
- await writeFile(oursPath, merged2.content);
1889
- if (merged2.hasConflicts) {
1890
- return {
1891
- file: resolvedPath,
1892
- status: "conflict",
1893
- conflicts: merged2.conflicts,
1894
- conflictReason: "new-file-both",
1895
- conflictMetadata: metadata
1896
- };
1938
+ if (matchedExisting) continue;
1939
+ let matchedNew = false;
1940
+ if (revertedSha) {
1941
+ const idx = survivingPatches.findIndex(
1942
+ (p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_commit === revertedSha
1943
+ );
1944
+ if (idx !== -1) {
1945
+ revertIndicesToRemove.add(i);
1946
+ revertIndicesToRemove.add(idx);
1947
+ matchedNew = true;
1897
1948
  }
1898
- this.fileTheirsAccumulator.set(resolvedPath, {
1899
- content: merged2.content,
1900
- baseGeneration: patch.base_generation
1901
- });
1902
- return { file: resolvedPath, status: "merged" };
1903
1949
  }
1904
- if (effective_theirs == null) {
1905
- return {
1906
- file: resolvedPath,
1907
- status: "skipped",
1908
- reason: "missing-content"
1909
- };
1950
+ if (!matchedNew && revertedMessage) {
1951
+ const idx = survivingPatches.findIndex(
1952
+ (p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_message === revertedMessage
1953
+ );
1954
+ if (idx !== -1) {
1955
+ revertIndicesToRemove.add(i);
1956
+ revertIndicesToRemove.add(idx);
1957
+ }
1910
1958
  }
1911
- if (base == null && !useAccumulatorAsMergeBase || ours == null) {
1912
- return {
1913
- file: resolvedPath,
1914
- status: "skipped",
1915
- reason: "missing-content"
1916
- };
1959
+ if (!matchedExisting && !matchedNew) {
1960
+ revertIndicesToRemove.add(i);
1917
1961
  }
1918
- const mergeBase = useAccumulatorAsMergeBase && accumulatorEntry ? accumulatorEntry.content : base;
1919
- if (mergeBase == null) {
1920
- return {
1921
- file: resolvedPath,
1922
- status: "skipped",
1923
- reason: "missing-content"
1924
- };
1962
+ }
1963
+ const filteredPatches = survivingPatches.filter((_, i) => !revertIndicesToRemove.has(i));
1964
+ return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
1965
+ }
1966
+ /**
1967
+ * Compute content hash for deduplication.
1968
+ *
1969
+ * Produces a format-agnostic hash so both `git format-patch` output
1970
+ * (email-wrapped) and plain `git diff` output hash to the same value
1971
+ * when the underlying diff hunks are identical.
1972
+ *
1973
+ * Normalization:
1974
+ * 1. Strip email wrapper: everything before the first `diff --git` line
1975
+ * (From:, Subject:, Date:, diffstat, blank separators).
1976
+ * 2. Strip `index` lines (blob SHA pairs change across rebases).
1977
+ * 3. Strip trailing git version marker (`-- \n<version>`).
1978
+ *
1979
+ * This ensures content hashes match across the no-patches and normal-
1980
+ * regeneration flows, which store formatPatch and git-diff formats
1981
+ * respectively (FER-9850, D5-D9).
1982
+ */
1983
+ computeContentHash(patchContent) {
1984
+ const lines = patchContent.split("\n");
1985
+ const diffStart = lines.findIndex((l) => l.startsWith("diff --git "));
1986
+ const relevantLines = diffStart > 0 ? lines.slice(diffStart) : lines;
1987
+ const normalized = relevantLines.filter((line) => !line.startsWith("index ")).join("\n").replace(/\n-- \n[\d]+\.[\d]+[^\n]*\s*$/, "");
1988
+ return `sha256:${createHash("sha256").update(normalized).digest("hex")}`;
1989
+ }
1990
+ /**
1991
+ * FER-9805 — Drop patches whose files were transiently created and then
1992
+ * deleted within the current linear detection window, and are absent from
1993
+ * HEAD at the end of the window.
1994
+ *
1995
+ * Rationale: on a merge commit, createMergeCompositePatch already diffs
1996
+ * firstParent..mergeCommit so net-zero files never enter the composite. On
1997
+ * linear history each commit becomes its own patch, so a file that was
1998
+ * briefly added and later removed survives as a create+delete pair whose
1999
+ * cumulative diff is empty. We drop such patches before they reach the
2000
+ * lockfile.
2001
+ *
2002
+ * A file is "transient" when:
2003
+ * 1. some patch in the window sets `new file mode` for it (a creation), AND
2004
+ * 2. some patch in the window sets `deleted file mode` for it (a deletion), AND
2005
+ * 3. it is absent from HEAD's tree.
2006
+ *
2007
+ * The HEAD guard (#3) prevents false positives when a file is deleted
2008
+ * and then recreated with different content — the cumulative diff of
2009
+ * such a pair is non-empty and must be preserved.
2010
+ *
2011
+ * This only drops patches whose `files` are a subset of the transient
2012
+ * set. A partial-transient patch (one that touches a transient file
2013
+ * alongside a persistent one) is left unmodified; surgical stripping of
2014
+ * single files from a multi-file patch would require a follow-up that
2015
+ * regenerates the patch content via `git format-patch -- <files>`. No
2016
+ * current failing test exercises this, and leaving the patch intact
2017
+ * preserves today's behaviour. TODO(FER-9805): revisit if a future
2018
+ * adversarial test demands per-file stripping.
2019
+ */
2020
+ async collapseNetZeroFiles(patches) {
2021
+ if (patches.length === 0) return patches;
2022
+ const stateByFile = /* @__PURE__ */ new Map();
2023
+ for (const patch of patches) {
2024
+ for (const header of parsePatchFileHeaders(patch.patch_content)) {
2025
+ if (!header.isCreate && !header.isDelete) continue;
2026
+ const prior = stateByFile.get(header.path) ?? { created: false, deleted: false };
2027
+ if (header.isCreate) prior.created = true;
2028
+ if (header.isDelete) prior.deleted = true;
2029
+ stateByFile.set(header.path, prior);
1925
2030
  }
1926
- const merged = threeWayMerge(mergeBase, ours, effective_theirs);
1927
- const outDir = dirname2(oursPath);
1928
- await mkdir(outDir, { recursive: true });
1929
- await writeFile(oursPath, merged.content);
1930
- const populateAccumulator = effective_theirs != null && (!merged.hasConflicts || this.currentApplyMode === "resolve");
1931
- if (populateAccumulator) {
1932
- this.fileTheirsAccumulator.set(resolvedPath, {
1933
- content: effective_theirs,
1934
- baseGeneration: patch.base_generation
1935
- });
2031
+ }
2032
+ let anyCandidate = false;
2033
+ for (const state of stateByFile.values()) {
2034
+ if (state.created && state.deleted) {
2035
+ anyCandidate = true;
2036
+ break;
1936
2037
  }
1937
- if (merged.hasConflicts) {
1938
- return {
1939
- file: resolvedPath,
1940
- status: "conflict",
1941
- conflicts: merged.conflicts,
1942
- conflictReason: baseMismatchSkipped ? "base-generation-mismatch" : "same-line-edit",
1943
- conflictMetadata: metadata
1944
- };
2038
+ }
2039
+ if (!anyCandidate) return patches;
2040
+ const headFiles = await this.listHeadFiles();
2041
+ const transient = /* @__PURE__ */ new Set();
2042
+ for (const [file, state] of stateByFile) {
2043
+ if (state.created && state.deleted && !headFiles.has(file)) {
2044
+ transient.add(file);
1945
2045
  }
1946
- return {
1947
- file: resolvedPath,
1948
- status: "merged",
1949
- ...merged.droppedContextLines && merged.droppedContextLines.length > 0 ? { droppedContextLines: merged.droppedContextLines } : {}
1950
- };
1951
- } catch (error) {
1952
- return {
1953
- file: filePath,
1954
- status: "skipped",
1955
- reason: `error: ${error instanceof Error ? error.message : String(error)}`
1956
- };
1957
2046
  }
2047
+ if (transient.size === 0) return patches;
2048
+ return patches.filter((patch) => !patch.files.every((f) => transient.has(f)));
1958
2049
  }
1959
- async isTreeReachable(treeHash) {
1960
- let result = this.treeExistsCache.get(treeHash);
1961
- if (result === void 0) {
1962
- result = await this.git.treeExists(treeHash);
1963
- this.treeExistsCache.set(treeHash, result);
2050
+ /**
2051
+ * List every tracked path at HEAD (one `git ls-tree -r` invocation).
2052
+ * Returns an empty Set if HEAD is unborn or the invocation fails — the
2053
+ * caller then treats every candidate as "not in HEAD" and may collapse
2054
+ * more aggressively. On typical SDK repos this is a single sub-10ms call.
2055
+ */
2056
+ async listHeadFiles() {
2057
+ try {
2058
+ const out = await this.git.exec(["ls-tree", "-r", "--name-only", "HEAD"]);
2059
+ return new Set(out.split("\n").map((l) => l.trim()).filter(Boolean));
2060
+ } catch {
2061
+ return /* @__PURE__ */ new Set();
1964
2062
  }
1965
- return result;
1966
- }
1967
- isExcluded(patch) {
1968
- const config = this.lockManager.getCustomizationsConfig();
1969
- if (!config.exclude) return false;
1970
- return patch.files.some((file) => config.exclude.some((pattern) => minimatch(file, pattern)));
1971
2063
  }
1972
- async resolveFilePath(filePath, baseTreeHash, currentTreeHash) {
1973
- const config = this.lockManager.getCustomizationsConfig();
1974
- if (config.moves) {
1975
- for (const move of config.moves) {
1976
- if (minimatch(filePath, move.from) || filePath === move.from) {
1977
- if (filePath === move.from) {
1978
- return move.to;
1979
- }
1980
- const fromBase = move.from.replace(/\*\*.*$/, "");
1981
- const toBase = move.to.replace(/\*\*.*$/, "");
1982
- if (filePath.startsWith(fromBase)) {
1983
- return toBase + filePath.slice(fromBase.length);
1984
- }
1985
- }
1986
- }
2064
+ /**
2065
+ * Create a single composite patch for a merge commit by diffing the merge
2066
+ * commit against its first parent. This captures the net change of the
2067
+ * entire merged branch as one patch, eliminating accumulator chaining and
2068
+ * zero-net-change ghost conflicts.
2069
+ */
2070
+ async createMergeCompositePatch(input) {
2071
+ const { mergeCommit, firstParent, lastGen, lock } = input;
2072
+ const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
2073
+ const filesOutput = await this.git.exec([
2074
+ "diff",
2075
+ "--name-only",
2076
+ firstParent,
2077
+ mergeCommit.sha
2078
+ ]);
2079
+ const allMergeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
2080
+ const sensitiveMergeFiles = allMergeFiles.filter((f) => isSensitiveFile(f));
2081
+ const files = allMergeFiles.filter((f) => !isSensitiveFile(f));
2082
+ if (sensitiveMergeFiles.length > 0) {
2083
+ this.warnings.push(
2084
+ `Sensitive file(s) excluded from merge patch for commit ${mergeCommit.sha.slice(0, 7)}: ${sensitiveMergeFiles.join(", ")}. These files will not be tracked by replay.`
2085
+ );
1987
2086
  }
1988
- const cacheKey = `${baseTreeHash}:${currentTreeHash}`;
1989
- let renames = this.renameCache.get(cacheKey);
1990
- if (!renames) {
1991
- renames = await this.git.detectRenames(baseTreeHash, currentTreeHash);
1992
- this.renameCache.set(cacheKey, renames);
2087
+ if (files.length === 0) return null;
2088
+ const diff = await this.git.exec([
2089
+ "diff",
2090
+ firstParent,
2091
+ mergeCommit.sha,
2092
+ "--",
2093
+ ...files
2094
+ ]);
2095
+ if (!diff.trim()) return null;
2096
+ const contentHash = this.computeContentHash(diff);
2097
+ if (lock.patches.some((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
2098
+ return null;
1993
2099
  }
1994
- const gitRename = renames.find((r) => r.from === filePath);
1995
- if (gitRename) {
1996
- return gitRename.to;
2100
+ const theirsSnapshot = {};
2101
+ for (const file of files) {
2102
+ if (isBinaryFile(file)) continue;
2103
+ const content = await this.git.showFile(mergeCommit.sha, file).catch(() => null);
2104
+ if (content != null) theirsSnapshot[file] = content;
1997
2105
  }
1998
- return filePath;
2106
+ return {
2107
+ id: `patch-merge-${mergeCommit.sha.slice(0, 8)}`,
2108
+ content_hash: contentHash,
2109
+ original_commit: mergeCommit.sha,
2110
+ original_message: mergeCommit.message,
2111
+ original_author: `${mergeCommit.authorName} <${mergeCommit.authorEmail}>`,
2112
+ base_generation: lastGen.commit_sha,
2113
+ files,
2114
+ patch_content: diff,
2115
+ ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
2116
+ ...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
2117
+ };
1999
2118
  }
2000
- async applyPatchToContent(base, patchContent, filePath, tempGit, tempDir, sourceFilePath) {
2001
- if (!base) {
2002
- return this.extractNewFileFromPatch(patchContent, filePath);
2119
+ /**
2120
+ * Detect patches via tree diff for non-linear history. Returns a composite patch.
2121
+ * Revert reconciliation is skipped here because tree-diff produces a single composite
2122
+ * patch from the aggregate diff — individual revert commits are not distinguishable.
2123
+ */
2124
+ async detectPatchesViaTreeDiff(lastGen, commitKnownMissing) {
2125
+ const diffBase = await this.resolveDiffBase(lastGen, commitKnownMissing);
2126
+ if (!diffBase) {
2127
+ return this.detectPatchesViaCommitScan();
2003
2128
  }
2004
- const fileDiff = this.extractFileDiff(patchContent, filePath);
2005
- if (!fileDiff) return null;
2006
- try {
2007
- if (sourceFilePath) {
2008
- const tempSourcePath = join2(tempDir, sourceFilePath);
2009
- await mkdir(dirname2(tempSourcePath), { recursive: true });
2010
- await writeFile(tempSourcePath, base);
2011
- await tempGit.exec(["add", sourceFilePath]);
2012
- await tempGit.exec([
2013
- "commit",
2014
- "-m",
2015
- `base for rename ${sourceFilePath} -> ${filePath}`,
2016
- "--allow-empty"
2017
- ]);
2018
- await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
2019
- const tempTargetPath = join2(tempDir, filePath);
2020
- return await readFile(tempTargetPath, "utf-8");
2021
- }
2022
- const tempFilePath = join2(tempDir, filePath);
2023
- await mkdir(dirname2(tempFilePath), { recursive: true });
2024
- await writeFile(tempFilePath, base);
2025
- await tempGit.exec(["add", filePath]);
2026
- await tempGit.exec(["commit", "-m", `base for ${filePath}`, "--allow-empty"]);
2027
- await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
2028
- return await readFile(tempFilePath, "utf-8");
2029
- } catch {
2030
- return null;
2129
+ const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
2130
+ const allTreeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
2131
+ const sensitiveTreeFiles = allTreeFiles.filter((f) => isSensitiveFile(f));
2132
+ const files = allTreeFiles.filter((f) => !isSensitiveFile(f));
2133
+ if (sensitiveTreeFiles.length > 0) {
2134
+ this.warnings.push(
2135
+ `Sensitive file(s) excluded from composite patch: ${sensitiveTreeFiles.join(", ")}. These files will not be tracked by replay.`
2136
+ );
2137
+ }
2138
+ if (files.length === 0) return { patches: [], revertedPatchIds: [] };
2139
+ const diff = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
2140
+ if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
2141
+ const contentHash = this.computeContentHash(diff);
2142
+ const lock = this.lockManager.read();
2143
+ if (lock.patches.some((p) => p.content_hash === contentHash) || (lock.forgotten_hashes ?? []).includes(contentHash)) {
2144
+ return { patches: [], revertedPatchIds: [] };
2145
+ }
2146
+ const headSha = (await this.git.exec(["rev-parse", "HEAD"])).trim();
2147
+ const theirsSnapshot = {};
2148
+ for (const file of files) {
2149
+ if (isBinaryFile(file)) continue;
2150
+ const content = await this.git.showFile("HEAD", file).catch(() => null);
2151
+ if (content != null) theirsSnapshot[file] = content;
2031
2152
  }
2153
+ const compositePatch = {
2154
+ id: `patch-composite-${headSha.slice(0, 8)}`,
2155
+ content_hash: contentHash,
2156
+ original_commit: headSha,
2157
+ original_message: "Customer customizations (composite)",
2158
+ original_author: "composite",
2159
+ // Use diffBase when commit is unreachable — the applicator needs a reachable
2160
+ // reference to find base file content. diffBase may be the tree_hash.
2161
+ base_generation: commitKnownMissing ? diffBase : lastGen.commit_sha,
2162
+ files,
2163
+ patch_content: diff,
2164
+ ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
2165
+ ...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
2166
+ };
2167
+ return { patches: [compositePatch], revertedPatchIds: [] };
2032
2168
  }
2033
2169
  /**
2034
- * Detects whether a patch's additions are already present in `content`.
2035
- * Uses `git apply --reverse --check` if the reverse patch applies cleanly,
2036
- * the forward patch is effectively a no-op (its +lines are already there and
2037
- * its -lines are already gone). Used to distinguish "patch already applied"
2038
- * from "patch base mismatch" after a forward-apply fails.
2170
+ * Last-resort detection when both generation commit and tree are unreachable.
2171
+ * Scans all commits from HEAD, filters against known lockfile patches, and
2172
+ * skips creation-only commits (squashed history after force push).
2173
+ *
2174
+ * Detected patches use the commit's parent as base_generation so the applicator
2175
+ * can find base file content from the parent's tree (which IS reachable).
2039
2176
  */
2040
- async isPatchAlreadyApplied(patchContent, filePath, content, tempGit, tempDir) {
2041
- const fileDiff = this.extractFileDiff(patchContent, filePath);
2042
- if (!fileDiff) return false;
2043
- try {
2044
- const tempFilePath = join2(tempDir, filePath);
2045
- await mkdir(dirname2(tempFilePath), { recursive: true });
2046
- await writeFile(tempFilePath, content);
2047
- await tempGit.exec(["add", filePath]);
2048
- await tempGit.exec(["commit", "-m", `check already-applied ${filePath}`, "--allow-empty"]);
2049
- await tempGit.execWithInput(["apply", "--reverse", "--check", "--allow-empty"], fileDiff);
2050
- return true;
2051
- } catch {
2052
- return false;
2053
- }
2054
- }
2055
- extractFileDiff(patchContent, filePath) {
2056
- const lines = patchContent.split("\n");
2057
- const diffLines = [];
2058
- let inTargetFile = false;
2059
- for (const line of lines) {
2060
- if (line.startsWith("diff --git")) {
2061
- if (inTargetFile) {
2062
- break;
2063
- }
2064
- if (isDiffLineForFile(line, filePath)) {
2065
- inTargetFile = true;
2066
- diffLines.push(line);
2067
- }
2177
+ async detectPatchesViaCommitScan() {
2178
+ const lock = this.lockManager.read();
2179
+ const log = await this.git.exec([
2180
+ "log",
2181
+ "--max-count=200",
2182
+ "--format=%H%x00%an%x00%ae%x00%s",
2183
+ "HEAD",
2184
+ "--",
2185
+ this.sdkOutputDir
2186
+ ]);
2187
+ if (!log.trim()) {
2188
+ return { patches: [], revertedPatchIds: [] };
2189
+ }
2190
+ const commits = this.parseGitLog(log);
2191
+ const newPatches = [];
2192
+ const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
2193
+ const existingHashes = new Set(lock.patches.map((p) => p.content_hash));
2194
+ const existingCommits = new Set(lock.patches.map((p) => p.original_commit));
2195
+ for (const commit of commits) {
2196
+ if (isGenerationCommit(commit)) {
2068
2197
  continue;
2069
2198
  }
2070
- if (inTargetFile) {
2071
- diffLines.push(line);
2199
+ const parents = await this.git.getCommitParents(commit.sha);
2200
+ if (parents.length > 1) {
2201
+ continue;
2072
2202
  }
2073
- }
2074
- return diffLines.length > 0 ? diffLines.join("\n") + "\n" : null;
2075
- }
2076
- extractRenameSource(patchContent, targetFilePath) {
2077
- const lines = patchContent.split("\n");
2078
- let inTargetFile = false;
2079
- for (const line of lines) {
2080
- if (line.startsWith("diff --git")) {
2081
- if (inTargetFile) break;
2082
- inTargetFile = isDiffLineForFile(line, targetFilePath);
2203
+ if (existingCommits.has(commit.sha)) {
2083
2204
  continue;
2084
2205
  }
2085
- if (!inTargetFile) continue;
2086
- if (line.startsWith("@@")) break;
2087
- if (line.startsWith("rename from ")) {
2088
- return line.slice("rename from ".length);
2206
+ let patchContent;
2207
+ try {
2208
+ patchContent = await this.git.formatPatch(commit.sha);
2209
+ } catch {
2210
+ continue;
2089
2211
  }
2090
- }
2091
- return null;
2092
- }
2093
- extractNewFileFromPatch(patchContent, filePath) {
2094
- const lines = patchContent.split("\n");
2095
- const addedLines = [];
2096
- let inTargetFile = false;
2097
- let inHunk = false;
2098
- let isNewFile = false;
2099
- let noTrailingNewline = false;
2100
- for (const line of lines) {
2101
- if (line.startsWith("diff --git")) {
2102
- if (inTargetFile) break;
2103
- inTargetFile = isDiffLineForFile(line, filePath);
2104
- inHunk = false;
2105
- isNewFile = false;
2212
+ let contentHash = this.computeContentHash(patchContent);
2213
+ if (existingHashes.has(contentHash) || forgottenHashes.has(contentHash)) {
2106
2214
  continue;
2107
2215
  }
2108
- if (!inTargetFile) continue;
2109
- if (!inHunk) {
2110
- if (line === "--- /dev/null" || line.startsWith("new file mode")) {
2111
- isNewFile = true;
2216
+ if (this.isCreationOnlyPatch(patchContent)) {
2217
+ continue;
2218
+ }
2219
+ const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
2220
+ const allScanFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f));
2221
+ const sensitiveScanFiles = allScanFiles.filter((f) => isSensitiveFile(f));
2222
+ const files = allScanFiles.filter((f) => !isSensitiveFile(f));
2223
+ if (sensitiveScanFiles.length > 0) {
2224
+ this.warnings.push(
2225
+ `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveScanFiles.join(", ")}. These files will not be tracked by replay.`
2226
+ );
2227
+ if (files.length > 0) {
2228
+ if (parents.length === 0) {
2229
+ continue;
2230
+ }
2231
+ try {
2232
+ patchContent = await this.git.exec(["diff", parents[0], commit.sha, "--", ...files]);
2233
+ } catch {
2234
+ continue;
2235
+ }
2236
+ if (!patchContent.trim()) continue;
2237
+ contentHash = this.computeContentHash(patchContent);
2112
2238
  }
2113
2239
  }
2114
- if (line.startsWith("@@")) {
2115
- if (!isNewFile) return null;
2116
- inHunk = true;
2240
+ if (files.length === 0) {
2117
2241
  continue;
2118
2242
  }
2119
- if (!inHunk) continue;
2120
- if (line === "\") {
2121
- noTrailingNewline = true;
2243
+ if (parents.length === 0) {
2122
2244
  continue;
2123
2245
  }
2124
- if (line.startsWith("+") && !line.startsWith("+++")) {
2125
- addedLines.push(line.slice(1));
2246
+ const parentSha = parents[0];
2247
+ const theirsSnapshot = {};
2248
+ for (const file of files) {
2249
+ if (isBinaryFile(file)) continue;
2250
+ const content = await this.git.showFile(commit.sha, file).catch(() => null);
2251
+ if (content != null) theirsSnapshot[file] = content;
2126
2252
  }
2253
+ newPatches.push({
2254
+ id: `patch-${commit.sha.slice(0, 8)}`,
2255
+ content_hash: contentHash,
2256
+ original_commit: commit.sha,
2257
+ original_message: commit.message,
2258
+ original_author: `${commit.authorName} <${commit.authorEmail}>`,
2259
+ base_generation: parentSha,
2260
+ files,
2261
+ patch_content: patchContent,
2262
+ ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {}
2263
+ });
2127
2264
  }
2128
- if (addedLines.length === 0) return null;
2129
- return addedLines.join("\n") + (noTrailingNewline ? "" : "\n");
2265
+ newPatches.reverse();
2266
+ return { patches: newPatches, revertedPatchIds: [] };
2130
2267
  }
2131
- };
2132
- function isBinaryFile(filePath) {
2133
- const ext = extname(filePath).toLowerCase();
2134
- return BINARY_EXTENSIONS.has(ext);
2135
- }
2136
- function computeDroppedContextLinesForFile(base, theirs, finalContent) {
2137
- const baseLines = base.split("\n");
2138
- const theirsLines = theirs.split("\n");
2139
- const finalLines = finalContent.split("\n");
2140
- const baseCounts = /* @__PURE__ */ new Map();
2141
- const theirsCounts = /* @__PURE__ */ new Map();
2142
- const finalCounts = /* @__PURE__ */ new Map();
2143
- for (const l of baseLines) baseCounts.set(l, (baseCounts.get(l) ?? 0) + 1);
2144
- for (const l of theirsLines) theirsCounts.set(l, (theirsCounts.get(l) ?? 0) + 1);
2145
- for (const l of finalLines) finalCounts.set(l, (finalCounts.get(l) ?? 0) + 1);
2146
- const dropped = [];
2147
- const seen = /* @__PURE__ */ new Set();
2148
- for (const line of baseLines) {
2149
- if (seen.has(line)) continue;
2150
- const inBase = baseCounts.get(line) ?? 0;
2151
- const inTheirs = theirsCounts.get(line) ?? 0;
2152
- const inFinal = finalCounts.get(line) ?? 0;
2153
- if (inBase > 0 && inTheirs > 0 && inFinal < Math.min(inBase, inTheirs)) {
2154
- dropped.push(line);
2155
- seen.add(line);
2268
+ /**
2269
+ * Check if a format-patch consists entirely of new-file creations.
2270
+ * Used to identify squashed commits after force push, which create all files
2271
+ * from scratch (--- /dev/null) rather than modifying existing files.
2272
+ */
2273
+ isCreationOnlyPatch(patchContent) {
2274
+ const diffOldHeaders = patchContent.split("\n").filter((l) => l.startsWith("--- "));
2275
+ if (diffOldHeaders.length === 0) {
2276
+ return false;
2156
2277
  }
2278
+ return diffOldHeaders.every((l) => l === "--- /dev/null");
2157
2279
  }
2158
- return dropped;
2159
- }
2160
- function isDiffLineForFile(diffLine, filePath) {
2161
- const match = diffLine.match(/^diff --git a\/.+ b\/(.+)$/);
2162
- return match !== null && match[1] === filePath;
2163
- }
2280
+ /**
2281
+ * Resolve the best available diff base for a generation record.
2282
+ * Prefers commit_sha, falls back to tree_hash for unreachable commits.
2283
+ * When commitKnownMissing is true, skips the redundant commitExists check.
2284
+ */
2285
+ async resolveDiffBase(gen, commitKnownMissing) {
2286
+ if (!commitKnownMissing && await this.git.commitExists(gen.commit_sha)) {
2287
+ return gen.commit_sha;
2288
+ }
2289
+ if (await this.git.treeExists(gen.tree_hash)) {
2290
+ return gen.tree_hash;
2291
+ }
2292
+ return null;
2293
+ }
2294
+ parseGitLog(log) {
2295
+ return log.trim().split("\n").map((line) => {
2296
+ const [sha, authorName, authorEmail, message] = line.split("\0");
2297
+ return { sha, authorName, authorEmail, message };
2298
+ });
2299
+ }
2300
+ getLastGeneration(lock) {
2301
+ return lock.generations.find((g) => g.commit_sha === lock.current_generation);
2302
+ }
2303
+ };
2164
2304
 
2165
2305
  // src/ReplayCommitter.ts
2166
2306
  var ReplayCommitter = class {
@@ -2267,7 +2407,8 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
2267
2407
 
2268
2408
  // src/ReplayService.ts
2269
2409
  import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
2270
- import { join as join4 } from "path";
2410
+ import { readFile as readFileAsync } from "fs/promises";
2411
+ import { join as join5 } from "path";
2271
2412
  import { minimatch as minimatch2 } from "minimatch";
2272
2413
  init_GitClient();
2273
2414
 
@@ -2326,13 +2467,13 @@ async function computePerPatchDiff(patch, getCurrentGenContent, getWorkingTreeCo
2326
2467
  const hunksRaw = parseHunks(fileDiff).map(normalizeHunkBody);
2327
2468
  const hunks = hunksRaw.filter((h) => {
2328
2469
  if (h.lines.length === 0) return false;
2329
- let ctx = 0, rm3 = 0, add = 0;
2470
+ let ctx = 0, rm4 = 0, add = 0;
2330
2471
  for (const ln of h.lines) {
2331
2472
  if (ln.type === "context") ctx++;
2332
- else if (ln.type === "remove") rm3++;
2473
+ else if (ln.type === "remove") rm4++;
2333
2474
  else if (ln.type === "add") add++;
2334
2475
  }
2335
- return ctx + rm3 === h.oldCount && ctx + add === h.newCount;
2476
+ return ctx + rm4 === h.oldCount && ctx + add === h.newCount;
2336
2477
  });
2337
2478
  if (hunks.length === 0) {
2338
2479
  if (hunksRaw.length > 0) unlocatableFiles.push(file);
@@ -3042,19 +3183,14 @@ var ReplayService = class {
3042
3183
  existingPatches = this.lockManager.getPatches();
3043
3184
  const seenHashes = /* @__PURE__ */ new Map();
3044
3185
  for (const p of existingPatches) {
3045
- const priorOrigin = seenHashes.get(p.content_hash);
3046
- if (priorOrigin !== void 0) {
3047
- const provenanceMissing = !priorOrigin || !p.original_commit;
3048
- const sameOrigin = priorOrigin === p.original_commit;
3049
- if (sameOrigin && !provenanceMissing) {
3050
- this.lockManager.removePatch(p.id);
3051
- } else {
3052
- this.warnings.push(
3053
- `Preserving patch ${p.id} (commit ${(p.original_commit || "<missing>").slice(0, 7)}): shares content_hash with a prior patch ${provenanceMissing ? "(provenance missing on at least one side)" : "from a different source commit"}. Both retained to avoid silent customization loss (FER-9983).`
3054
- );
3055
- }
3186
+ const priorPatchId = seenHashes.get(p.content_hash);
3187
+ if (priorPatchId !== void 0) {
3188
+ this.lockManager.removePatch(p.id);
3189
+ this.warnings.push(
3190
+ `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.`
3191
+ );
3056
3192
  } else {
3057
- seenHashes.set(p.content_hash, p.original_commit);
3193
+ seenHashes.set(p.content_hash, p.id);
3058
3194
  }
3059
3195
  }
3060
3196
  existingPatches = this.lockManager.getPatches();
@@ -3235,17 +3371,27 @@ var ReplayService = class {
3235
3371
  if (allUserOwned && patch.files.length > 0) {
3236
3372
  const baseChanged = patch.base_generation !== currentGenSha;
3237
3373
  const userDiff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
3374
+ let userDiffSnapshot = null;
3238
3375
  if (userDiff != null && userDiff.trim()) {
3239
3376
  const newHash = this.detector.computeContentHash(userDiff);
3240
3377
  patch.patch_content = userDiff;
3241
3378
  patch.content_hash = newHash;
3379
+ const snap = await this.readSnapshotFromDisk(patch.files);
3380
+ if (Object.keys(snap).length > 0) {
3381
+ userDiffSnapshot = snap;
3382
+ patch.theirs_snapshot = snap;
3383
+ }
3242
3384
  }
3243
3385
  patch.base_generation = currentGenSha;
3244
3386
  try {
3245
3387
  this.lockManager.updatePatch(patch.id, {
3246
3388
  base_generation: currentGenSha,
3247
3389
  ...!patch.user_owned ? { user_owned: true } : {},
3248
- ...userDiff != null && userDiff.trim() ? { patch_content: patch.patch_content, content_hash: patch.content_hash } : {}
3390
+ ...userDiff != null && userDiff.trim() ? {
3391
+ patch_content: patch.patch_content,
3392
+ content_hash: patch.content_hash,
3393
+ ...userDiffSnapshot != null && Object.keys(userDiffSnapshot).length > 0 ? { theirs_snapshot: userDiffSnapshot } : {}
3394
+ } : {}
3249
3395
  });
3250
3396
  } catch {
3251
3397
  }
@@ -3293,30 +3439,34 @@ var ReplayService = class {
3293
3439
  continue;
3294
3440
  }
3295
3441
  const newContentHash = this.detector.computeContentHash(diff);
3296
- const priorOrigin = seenContentHashes.get(newContentHash);
3297
- if (priorOrigin !== void 0) {
3298
- const provenanceMissing = !priorOrigin || !patch.original_commit;
3299
- const sameOrigin = priorOrigin === patch.original_commit;
3300
- if (sameOrigin && !provenanceMissing) {
3301
- this.lockManager.removePatch(patch.id);
3302
- absorbedPatchIds.add(patch.id);
3303
- absorbed++;
3304
- continue;
3442
+ const priorPatchId = seenContentHashes.get(newContentHash);
3443
+ if (priorPatchId !== void 0) {
3444
+ this.lockManager.removePatch(patch.id);
3445
+ absorbedPatchIds.add(patch.id);
3446
+ absorbed++;
3447
+ if (priorPatchId !== patch.id) {
3448
+ this.warnings.push(
3449
+ `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.`
3450
+ );
3305
3451
  }
3306
- this.warnings.push(
3307
- `Preserving patch ${patch.id} (commit ${(patch.original_commit || "<missing>").slice(0, 7)}): shares content_hash with a prior patch ${provenanceMissing ? "(provenance missing on at least one side)" : "from a different source commit"}. Both retained to avoid silent customization loss (FER-9983).`
3308
- );
3452
+ continue;
3309
3453
  } else {
3310
- seenContentHashes.set(newContentHash, patch.original_commit);
3454
+ seenContentHashes.set(newContentHash, patch.id);
3311
3455
  }
3312
3456
  patch.base_generation = currentGenSha;
3313
3457
  patch.patch_content = diff;
3314
3458
  patch.content_hash = newContentHash;
3459
+ const snapshot = await this.readSnapshotFromDisk(patch.files);
3460
+ const snapshotIsNonEmpty = Object.keys(snapshot).length > 0;
3461
+ if (snapshotIsNonEmpty) {
3462
+ patch.theirs_snapshot = snapshot;
3463
+ }
3315
3464
  try {
3316
3465
  this.lockManager.updatePatch(patch.id, {
3317
3466
  base_generation: currentGenSha,
3318
3467
  patch_content: diff,
3319
- content_hash: newContentHash
3468
+ content_hash: newContentHash,
3469
+ ...snapshotIsNonEmpty ? { theirs_snapshot: snapshot } : {}
3320
3470
  });
3321
3471
  } catch {
3322
3472
  }
@@ -3326,6 +3476,112 @@ var ReplayService = class {
3326
3476
  }
3327
3477
  return { absorbed, repointed, contentRebased, keptAsUserOwned, absorbedPatchIds };
3328
3478
  }
3479
+ /**
3480
+ * Read THEIRS snapshot (post-customer-edit content) for a list of files
3481
+ * from the working tree on disk. Used by `rebasePatches` after a clean
3482
+ * apply — disk = correctly-merged customer state at the new generation.
3483
+ * Skips binary files (matches `mergeFile`/`populateAccumulatorForPatch`
3484
+ * policy — binary content as `utf-8` is lossy and snapshots of it would
3485
+ * never be used during apply anyway).
3486
+ */
3487
+ async readSnapshotFromDisk(files) {
3488
+ const snapshot = {};
3489
+ for (const file of files) {
3490
+ if (isBinaryFile(file)) continue;
3491
+ try {
3492
+ const content = await readFileAsync(join5(this.outputDir, file), "utf-8");
3493
+ snapshot[file] = content;
3494
+ } catch {
3495
+ }
3496
+ }
3497
+ return snapshot;
3498
+ }
3499
+ /**
3500
+ * Read THEIRS snapshot from a git tree-ish (commit, branch, or tree).
3501
+ * Used when the diff that produced `patch_content` was relative to that
3502
+ * tree (e.g., `git diff currentGen HEAD --` → snapshot from HEAD).
3503
+ * Skips binary files.
3504
+ */
3505
+ async readSnapshotFromTree(treeRef, files) {
3506
+ const snapshot = {};
3507
+ for (const file of files) {
3508
+ if (isBinaryFile(file)) continue;
3509
+ const content = await this.git.showFile(treeRef, file).catch(() => null);
3510
+ if (content != null) snapshot[file] = content;
3511
+ }
3512
+ return snapshot;
3513
+ }
3514
+ /**
3515
+ * One-shot auto-migration: backfill `theirs_snapshot` on existing
3516
+ * patches that predate the snapshot encoding. Computes the snapshot by
3517
+ * applying the patch's `patch_content` to its `base_generation` tree —
3518
+ * yielding the patch's INDIVIDUAL contribution.
3519
+ *
3520
+ * **Skips patches sharing files with other patches.** HEAD's content
3521
+ * for a file shared by N patches is cumulative across all of them, so
3522
+ * a HEAD-fallback would falsely encode other patches' contributions
3523
+ * into one patch's snapshot — breaking FER-9525 cross-patch isolation
3524
+ * if the snapshot fallback later fires. Per-patch snapshots only
3525
+ * make sense when the patch owns its file. Multi-patch-same-file
3526
+ * legacy lockfiles keep using line-anchored reconstruction (the
3527
+ * existing path); they're no worse than pre-upgrade.
3528
+ *
3529
+ * **Does not fall back to HEAD when BASE-reconstruction fails.** Same
3530
+ * reason — HEAD content can be cumulative. Patches whose
3531
+ * reconstruction fails just don't get a snapshot; legacy paths
3532
+ * (PR #73's by-association gate, accumulator fallback) handle them.
3533
+ *
3534
+ * Skips patches that already have a snapshot.
3535
+ */
3536
+ async migratePatchesToSnapshot(patches) {
3537
+ const { applyPatchToContent: applyPatchToContent2 } = await Promise.resolve().then(() => (init_PatchApplyTheirs(), PatchApplyTheirs_exports));
3538
+ const fileFreq = /* @__PURE__ */ new Map();
3539
+ for (const p of patches) {
3540
+ for (const f of p.files) {
3541
+ fileFreq.set(f, (fileFreq.get(f) ?? 0) + 1);
3542
+ }
3543
+ }
3544
+ let migrated = 0;
3545
+ let skipped = 0;
3546
+ for (const patch of patches) {
3547
+ if (patch.theirs_snapshot != null) continue;
3548
+ const sharesAnyFile = patch.files.some((f) => (fileFreq.get(f) ?? 0) > 1);
3549
+ if (sharesAnyFile) {
3550
+ skipped++;
3551
+ continue;
3552
+ }
3553
+ const snapshot = {};
3554
+ for (const file of patch.files) {
3555
+ if (isBinaryFile(file)) continue;
3556
+ try {
3557
+ const base = await this.git.showFile(patch.base_generation, file).catch(() => null);
3558
+ if (base != null) {
3559
+ const content = await applyPatchToContent2(base, patch.patch_content, file);
3560
+ if (content != null) snapshot[file] = content;
3561
+ }
3562
+ } catch {
3563
+ }
3564
+ }
3565
+ if (Object.keys(snapshot).length > 0) {
3566
+ patch.theirs_snapshot = snapshot;
3567
+ try {
3568
+ this.lockManager.updatePatch(patch.id, { theirs_snapshot: snapshot });
3569
+ migrated++;
3570
+ } catch {
3571
+ }
3572
+ }
3573
+ }
3574
+ if (migrated > 0) {
3575
+ this.warnings.push(
3576
+ `Migrated ${migrated} patch(es) to snapshot encoding. Future regens use 3-way merge with stored THEIRS, robust to generator structural changes.`
3577
+ );
3578
+ }
3579
+ if (skipped > 0) {
3580
+ this.warnings.push(
3581
+ `Skipped snapshot migration for ${skipped} patch(es) sharing files with other patches. These continue to use line-anchored reconstruction (the existing path).`
3582
+ );
3583
+ }
3584
+ }
3329
3585
  /**
3330
3586
  * Determine whether `file` is user-owned (never produced by the generator).
3331
3587
  *
@@ -3446,6 +3702,20 @@ var ReplayService = class {
3446
3702
  this.lockManager.clearReplaySkippedAt();
3447
3703
  return { conflictResolved: 0, conflictAbsorbed: 0, contentRefreshed: 0 };
3448
3704
  }
3705
+ await this.migratePatchesToSnapshot(patches);
3706
+ const headMessage = await this.git.exec(["log", "-1", "--format=%s%n%aN%n%ae", "HEAD"]).catch(() => "");
3707
+ const [headMsgLine, headAuthorName, headAuthorEmail] = headMessage.split("\n");
3708
+ const headIsGeneration = headMsgLine ? isGenerationCommit({
3709
+ sha: "HEAD",
3710
+ authorName: headAuthorName ?? "",
3711
+ authorEmail: headAuthorEmail ?? "",
3712
+ message: headMsgLine
3713
+ }) : false;
3714
+ const headParentChangedFiles = async (patchFiles) => {
3715
+ if (patchFiles.length === 0) return /* @__PURE__ */ new Set();
3716
+ const diff = await this.git.exec(["diff", "--name-only", "HEAD~1", "HEAD", "--", ...patchFiles]).catch(() => "");
3717
+ return new Set(diff.trim().split("\n").filter(Boolean));
3718
+ };
3449
3719
  let conflictResolved = 0;
3450
3720
  let conflictAbsorbed = 0;
3451
3721
  let contentRefreshed = 0;
@@ -3482,6 +3752,13 @@ var ReplayService = class {
3482
3752
  delete patch.status;
3483
3753
  continue;
3484
3754
  }
3755
+ if (headIsGeneration) {
3756
+ const parentChangedFiles = await headParentChangedFiles(patch.files);
3757
+ const headTouchedSource = patch.files.some((f) => parentChangedFiles.has(f));
3758
+ if (headTouchedSource) {
3759
+ continue;
3760
+ }
3761
+ }
3485
3762
  if (patch.base_generation === currentGen) {
3486
3763
  try {
3487
3764
  const ppResult = await computePerPatchDiffWithFallback(
@@ -3518,10 +3795,12 @@ var ReplayService = class {
3518
3795
  if (newFiles.length === 0) {
3519
3796
  this.lockManager.removePatch(patch.id);
3520
3797
  } else {
3798
+ const snapshot = await this.readSnapshotFromTree("HEAD", newFiles);
3521
3799
  this.lockManager.updatePatch(patch.id, {
3522
3800
  patch_content: diff,
3523
3801
  content_hash: newContentHash,
3524
- files: newFiles
3802
+ files: newFiles,
3803
+ ...Object.keys(snapshot).length > 0 ? { theirs_snapshot: snapshot } : {}
3525
3804
  });
3526
3805
  }
3527
3806
  contentRefreshed++;
@@ -3554,10 +3833,12 @@ var ReplayService = class {
3554
3833
  continue;
3555
3834
  }
3556
3835
  const newContentHash = this.detector.computeContentHash(diff);
3836
+ const snapshot = await this.readSnapshotFromTree("HEAD", patch.files);
3557
3837
  this.lockManager.updatePatch(patch.id, {
3558
3838
  base_generation: currentGen,
3559
3839
  patch_content: diff,
3560
- content_hash: newContentHash
3840
+ content_hash: newContentHash,
3841
+ ...Object.keys(snapshot).length > 0 ? { theirs_snapshot: snapshot } : {}
3561
3842
  });
3562
3843
  conflictResolved++;
3563
3844
  } catch {
@@ -3600,7 +3881,7 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
3600
3881
  if (result.status !== "conflict" || !result.fileResults) continue;
3601
3882
  for (const fileResult of result.fileResults) {
3602
3883
  if (fileResult.status !== "conflict") continue;
3603
- const filePath = join4(this.outputDir, fileResult.file);
3884
+ const filePath = join5(this.outputDir, fileResult.file);
3604
3885
  try {
3605
3886
  const content = readFileSync2(filePath, "utf-8");
3606
3887
  const stripped = stripConflictMarkers(content);
@@ -3630,7 +3911,7 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
3630
3911
  }
3631
3912
  }
3632
3913
  readFernignorePatterns() {
3633
- const fernignorePath = join4(this.outputDir, ".fernignore");
3914
+ const fernignorePath = join5(this.outputDir, ".fernignore");
3634
3915
  if (!existsSync2(fernignorePath)) return [];
3635
3916
  return readFileSync2(fernignorePath, "utf-8").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
3636
3917
  }
@@ -3709,7 +3990,7 @@ function computeCommitBuckets(results, absorbedPatchIds, patchesPassedToCommit)
3709
3990
  // src/FernignoreMigrator.ts
3710
3991
  import { createHash as createHash2 } from "crypto";
3711
3992
  import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, statSync, writeFileSync as writeFileSync3 } from "fs";
3712
- import { dirname as dirname3, join as join5 } from "path";
3993
+ import { dirname as dirname4, join as join6 } from "path";
3713
3994
  import { minimatch as minimatch3 } from "minimatch";
3714
3995
  import { parse as parse2, stringify as stringify2 } from "yaml";
3715
3996
  var FernignoreMigrator = class {
@@ -3722,10 +4003,10 @@ var FernignoreMigrator = class {
3722
4003
  this.outputDir = outputDir;
3723
4004
  }
3724
4005
  fernignoreExists() {
3725
- return existsSync3(join5(this.outputDir, ".fernignore"));
4006
+ return existsSync3(join6(this.outputDir, ".fernignore"));
3726
4007
  }
3727
4008
  readFernignorePatterns() {
3728
- const fernignorePath = join5(this.outputDir, ".fernignore");
4009
+ const fernignorePath = join6(this.outputDir, ".fernignore");
3729
4010
  if (!existsSync3(fernignorePath)) {
3730
4011
  return [];
3731
4012
  }
@@ -3777,6 +4058,12 @@ var FernignoreMigrator = class {
3777
4058
  if (patchFiles.length > 0) {
3778
4059
  const patchContent = diffParts.join("\n");
3779
4060
  const contentHash = `sha256:${createHash2("sha256").update(patchContent).digest("hex")}`;
4061
+ const theirsSnapshot = {};
4062
+ for (const filePath of patchFiles) {
4063
+ if (isBinaryFile(filePath)) continue;
4064
+ const c = this.readFileContent(filePath);
4065
+ if (c != null) theirsSnapshot[filePath] = c;
4066
+ }
3780
4067
  syntheticPatches.push({
3781
4068
  id: `patch-fernignore-${createHash2("sha256").update(pattern).digest("hex").slice(0, 8)}`,
3782
4069
  content_hash: contentHash,
@@ -3786,6 +4073,7 @@ var FernignoreMigrator = class {
3786
4073
  base_generation: currentGen.commit_sha,
3787
4074
  files: patchFiles,
3788
4075
  patch_content: patchContent,
4076
+ ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
3789
4077
  // Synthetic patches captured from .fernignore-protected files
3790
4078
  // are user-owned by definition — the customer's content
3791
4079
  // diverges from the generator's pristine output, and the
@@ -3827,7 +4115,7 @@ var FernignoreMigrator = class {
3827
4115
  return results;
3828
4116
  }
3829
4117
  readFileContent(filePath) {
3830
- const fullPath = join5(this.outputDir, filePath);
4118
+ const fullPath = join6(this.outputDir, filePath);
3831
4119
  if (!existsSync3(fullPath)) {
3832
4120
  return null;
3833
4121
  }
@@ -3892,7 +4180,7 @@ var FernignoreMigrator = class {
3892
4180
  };
3893
4181
  }
3894
4182
  movePatternsToReplayYml(patterns) {
3895
- const replayYmlPath = join5(this.outputDir, ".fern", "replay.yml");
4183
+ const replayYmlPath = join6(this.outputDir, ".fern", "replay.yml");
3896
4184
  let config = {};
3897
4185
  if (existsSync3(replayYmlPath)) {
3898
4186
  const content = readFileSync3(replayYmlPath, "utf-8");
@@ -3901,7 +4189,7 @@ var FernignoreMigrator = class {
3901
4189
  const existing = config.exclude ?? [];
3902
4190
  const merged = [.../* @__PURE__ */ new Set([...existing, ...patterns])];
3903
4191
  config.exclude = merged;
3904
- const dir = dirname3(replayYmlPath);
4192
+ const dir = dirname4(replayYmlPath);
3905
4193
  if (!existsSync3(dir)) {
3906
4194
  mkdirSync2(dir, { recursive: true });
3907
4195
  }
@@ -3912,7 +4200,7 @@ var FernignoreMigrator = class {
3912
4200
  // src/commands/bootstrap.ts
3913
4201
  import { createHash as createHash3 } from "crypto";
3914
4202
  import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
3915
- import { join as join6 } from "path";
4203
+ import { join as join7 } from "path";
3916
4204
  init_GitClient();
3917
4205
  async function bootstrap(outputDir, options) {
3918
4206
  const git = new GitClient(outputDir);
@@ -4079,7 +4367,7 @@ function parseGitLog(log) {
4079
4367
  }
4080
4368
  var REPLAY_FERNIGNORE_ENTRIES = [".fern/replay.lock", ".fern/replay.yml", ".gitattributes"];
4081
4369
  function ensureFernignoreEntries(outputDir) {
4082
- const fernignorePath = join6(outputDir, ".fernignore");
4370
+ const fernignorePath = join7(outputDir, ".fernignore");
4083
4371
  let content = "";
4084
4372
  if (existsSync4(fernignorePath)) {
4085
4373
  content = readFileSync4(fernignorePath, "utf-8");
@@ -4103,7 +4391,7 @@ function ensureFernignoreEntries(outputDir) {
4103
4391
  }
4104
4392
  var GITATTRIBUTES_ENTRIES = [".fern/replay.lock linguist-generated=true"];
4105
4393
  function ensureGitattributesEntries(outputDir) {
4106
- const gitattributesPath = join6(outputDir, ".gitattributes");
4394
+ const gitattributesPath = join7(outputDir, ".gitattributes");
4107
4395
  let content = "";
4108
4396
  if (existsSync4(gitattributesPath)) {
4109
4397
  content = readFileSync4(gitattributesPath, "utf-8");
@@ -4302,8 +4590,8 @@ function reset(outputDir, options) {
4302
4590
 
4303
4591
  // src/commands/resolve.ts
4304
4592
  init_GitClient();
4305
- import { readFile as readFile2 } from "fs/promises";
4306
- import { join as join7 } from "path";
4593
+ import { readFile as readFile3 } from "fs/promises";
4594
+ import { join as join8 } from "path";
4307
4595
  async function resolve(outputDir, options) {
4308
4596
  const lockManager = new LockfileManager(outputDir);
4309
4597
  if (!lockManager.exists()) {
@@ -4346,36 +4634,78 @@ async function resolve(outputDir, options) {
4346
4634
  const detector = new ReplayDetector(git, lockManager, outputDir);
4347
4635
  const warnings = [];
4348
4636
  let patchesResolved = 0;
4637
+ const pass1 = [];
4349
4638
  for (const patch of patchesToCommit) {
4350
4639
  const result = await computePerPatchDiffWithFallback(
4351
4640
  patch,
4352
4641
  (f) => git.showFile(currentGen, f),
4353
- (f) => readFile2(join7(outputDir, f), "utf-8").catch(() => null),
4642
+ (f) => readFile3(join8(outputDir, f), "utf-8").catch(() => null),
4354
4643
  (files) => git.exec(["diff", currentGen, "--", ...files]).catch(() => null)
4355
4644
  );
4356
4645
  if (result === null) {
4357
4646
  warnings.push(
4358
4647
  `Patch ${patch.id}: cumulative-diff fallback failed for unlocatable files; preserving patch unchanged`
4359
4648
  );
4649
+ pass1.push({ kind: "skip" });
4360
4650
  continue;
4361
4651
  }
4362
4652
  const diff = result.diff;
4363
4653
  if (!diff.trim()) {
4654
+ pass1.push({ kind: "revert" });
4655
+ continue;
4656
+ }
4657
+ const hash = detector.computeContentHash(diff);
4658
+ pass1.push({
4659
+ kind: "apply",
4660
+ diff,
4661
+ hash,
4662
+ hadFallback: result.hadFallback,
4663
+ fallbackFiles: result.fallbackFiles
4664
+ });
4665
+ }
4666
+ const lastIndexByHash = /* @__PURE__ */ new Map();
4667
+ for (let i = 0; i < pass1.length; i++) {
4668
+ const r = pass1[i];
4669
+ if (r.kind === "apply") lastIndexByHash.set(r.hash, i);
4670
+ }
4671
+ for (let i = 0; i < patchesToCommit.length; i++) {
4672
+ const patch = patchesToCommit[i];
4673
+ const r = pass1[i];
4674
+ if (r.kind === "skip") continue;
4675
+ if (r.kind === "revert") {
4676
+ lockManager.removePatch(patch.id);
4677
+ continue;
4678
+ }
4679
+ if (lastIndexByHash.get(r.hash) !== i) {
4680
+ const canonicalIdx = lastIndexByHash.get(r.hash);
4681
+ const canonical = patchesToCommit[canonicalIdx];
4364
4682
  lockManager.removePatch(patch.id);
4683
+ warnings.push(
4684
+ `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.`
4685
+ );
4365
4686
  continue;
4366
4687
  }
4367
- if (result.hadFallback) {
4688
+ if (r.hadFallback) {
4368
4689
  warnings.push(
4369
- `Patch ${patch.id}: ${result.fallbackFiles.join(", ")} could not be anchored; cumulative-diff fallback applied for those files`
4690
+ `Patch ${patch.id}: ${r.fallbackFiles.join(", ")} could not be anchored; cumulative-diff fallback applied for those files`
4370
4691
  );
4371
4692
  }
4372
- const newContentHash = detector.computeContentHash(diff);
4373
- const changedFiles = result.hadFallback ? await getChangedFiles(git, currentGen, patch.files) : extractFilesFromDiff(diff);
4693
+ const changedFiles = r.hadFallback ? await getChangedFiles(git, currentGen, patch.files) : extractFilesFromDiff(r.diff);
4694
+ const filesForSnapshot = changedFiles.length > 0 ? changedFiles : patch.files;
4695
+ const theirsSnapshot = {};
4696
+ for (const file of filesForSnapshot) {
4697
+ if (isBinaryFile(file)) continue;
4698
+ const content = await readFile3(join8(outputDir, file), "utf-8").catch(() => null);
4699
+ if (content != null) {
4700
+ theirsSnapshot[file] = content;
4701
+ }
4702
+ }
4374
4703
  lockManager.markPatchResolved(patch.id, {
4375
- patch_content: diff,
4376
- content_hash: newContentHash,
4704
+ patch_content: r.diff,
4705
+ content_hash: r.hash,
4377
4706
  base_generation: currentGen,
4378
- files: changedFiles.length > 0 ? changedFiles : patch.files
4707
+ files: filesForSnapshot,
4708
+ ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {}
4379
4709
  });
4380
4710
  patchesResolved++;
4381
4711
  }