@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.cjs CHANGED
@@ -417,6 +417,64 @@ var init_HybridReconstruction = __esm({
417
417
  }
418
418
  });
419
419
 
420
+ // src/PatchApplyTheirs.ts
421
+ var PatchApplyTheirs_exports = {};
422
+ __export(PatchApplyTheirs_exports, {
423
+ applyPatchToContent: () => applyPatchToContent
424
+ });
425
+ async function applyPatchToContent(base, patchContent, filePath) {
426
+ const fileDiff = extractFileDiff(patchContent, filePath);
427
+ if (!fileDiff) return null;
428
+ const tempDir = await (0, import_promises3.mkdtemp)((0, import_node_path4.join)((0, import_node_os3.tmpdir)(), "replay-migrate-"));
429
+ const tempGit = new GitClient(tempDir);
430
+ try {
431
+ await tempGit.exec(["init"]);
432
+ await tempGit.exec(["config", "user.email", "migrate@fern.com"]);
433
+ await tempGit.exec(["config", "user.name", "Fern Replay Migration"]);
434
+ await tempGit.exec(["config", "commit.gpgSign", "false"]);
435
+ const tempFilePath = (0, import_node_path4.join)(tempDir, filePath);
436
+ await (0, import_promises3.mkdir)((0, import_node_path4.dirname)(tempFilePath), { recursive: true });
437
+ await (0, import_promises3.writeFile)(tempFilePath, base);
438
+ await tempGit.exec(["add", filePath]);
439
+ await tempGit.exec(["commit", "-m", `base for ${filePath}`, "--allow-empty"]);
440
+ await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
441
+ return await (0, import_promises3.readFile)(tempFilePath, "utf-8");
442
+ } catch {
443
+ return null;
444
+ } finally {
445
+ await (0, import_promises3.rm)(tempDir, { recursive: true, force: true }).catch(() => {
446
+ });
447
+ }
448
+ }
449
+ function extractFileDiff(patchContent, filePath) {
450
+ const lines = patchContent.split("\n");
451
+ const out = [];
452
+ let inTarget = false;
453
+ for (const line of lines) {
454
+ if (line.startsWith("diff --git")) {
455
+ if (inTarget) break;
456
+ const match = line.match(/^diff --git a\/.+ b\/(.+)$/);
457
+ if (match && match[1] === filePath) {
458
+ inTarget = true;
459
+ out.push(line);
460
+ }
461
+ continue;
462
+ }
463
+ if (inTarget) out.push(line);
464
+ }
465
+ return out.length > 0 ? out.join("\n") + "\n" : null;
466
+ }
467
+ var import_promises3, import_node_os3, import_node_path4;
468
+ var init_PatchApplyTheirs = __esm({
469
+ "src/PatchApplyTheirs.ts"() {
470
+ "use strict";
471
+ import_promises3 = require("fs/promises");
472
+ import_node_os3 = require("os");
473
+ import_node_path4 = require("path");
474
+ init_GitClient();
475
+ }
476
+ });
477
+
420
478
  // src/index.ts
421
479
  var index_exports = {};
422
480
  __export(index_exports, {
@@ -580,7 +638,17 @@ var LockfileManager = class {
580
638
  this.warnings.length = 0;
581
639
  const cleanPatches = [];
582
640
  for (const patch of this.lock.patches) {
583
- const credentialMatches = scanForCredentials(patch.patch_content);
641
+ const diffMatches = scanForCredentials(patch.patch_content);
642
+ const snapshotMatches = [];
643
+ if (patch.theirs_snapshot) {
644
+ for (const content2 of Object.values(patch.theirs_snapshot)) {
645
+ const m = scanForCredentials(content2);
646
+ for (const x of m) {
647
+ if (!snapshotMatches.includes(x)) snapshotMatches.push(x);
648
+ }
649
+ }
650
+ }
651
+ const credentialMatches = [...diffMatches, ...snapshotMatches.filter((m) => !diffMatches.includes(m))];
584
652
  const sensitiveFiles = patch.files.filter((f) => isSensitiveFile(f));
585
653
  if (credentialMatches.length > 0 || sensitiveFiles.length > 0) {
586
654
  const reasons = [];
@@ -701,1520 +769,1593 @@ var LockfileManager = class {
701
769
 
702
770
  // src/ReplayDetector.ts
703
771
  var import_node_crypto = require("crypto");
704
- var INFRASTRUCTURE_FILES = /* @__PURE__ */ new Set([".fernignore"]);
705
- function parsePatchFileHeaders(patchContent) {
706
- const results = [];
707
- let currentPath = null;
708
- let currentIsCreate = false;
709
- let currentIsDelete = false;
710
- const flush = () => {
711
- if (currentPath !== null) {
712
- results.push({ path: currentPath, isCreate: currentIsCreate, isDelete: currentIsDelete });
713
- }
714
- };
715
- for (const line of patchContent.split("\n")) {
716
- if (line.startsWith("diff --git ")) {
717
- flush();
718
- currentPath = null;
719
- currentIsCreate = false;
720
- currentIsDelete = false;
721
- const match = line.match(/^diff --git a\/(.+?) b\/(.+?)$/);
722
- if (match) {
723
- currentPath = match[2] ?? null;
772
+
773
+ // src/ReplayApplicator.ts
774
+ var import_promises = require("fs/promises");
775
+ var import_node_os = require("os");
776
+ var import_node_path2 = require("path");
777
+ var import_minimatch = require("minimatch");
778
+
779
+ // src/conflict-utils.ts
780
+ var CONFLICT_OPENER = "<<<<<<< Generated";
781
+ var CONFLICT_SEPARATOR = "=======";
782
+ var CONFLICT_CLOSER = ">>>>>>> Your customization";
783
+ function trimCR(line) {
784
+ return line.endsWith("\r") ? line.slice(0, -1) : line;
785
+ }
786
+ function findConflictRanges(lines) {
787
+ const ranges = [];
788
+ let i = 0;
789
+ while (i < lines.length) {
790
+ if (trimCR(lines[i]) === CONFLICT_OPENER) {
791
+ let separatorIdx = -1;
792
+ let j = i + 1;
793
+ let found = false;
794
+ while (j < lines.length) {
795
+ const trimmed = trimCR(lines[j]);
796
+ if (trimmed === CONFLICT_OPENER) {
797
+ break;
798
+ }
799
+ if (separatorIdx === -1 && trimmed === CONFLICT_SEPARATOR) {
800
+ separatorIdx = j;
801
+ } else if (separatorIdx !== -1 && trimmed === CONFLICT_CLOSER) {
802
+ ranges.push({ start: i, separator: separatorIdx, end: j });
803
+ i = j;
804
+ found = true;
805
+ break;
806
+ }
807
+ j++;
724
808
  }
725
- } else if (currentPath !== null) {
726
- if (line.startsWith("new file mode ")) {
727
- currentIsCreate = true;
728
- } else if (line.startsWith("deleted file mode ")) {
729
- currentIsDelete = true;
809
+ if (!found) {
810
+ i++;
811
+ continue;
730
812
  }
731
813
  }
814
+ i++;
732
815
  }
733
- flush();
734
- return results;
816
+ return ranges;
735
817
  }
736
- var ReplayDetector = class {
737
- git;
738
- lockManager;
739
- sdkOutputDir;
740
- warnings = [];
741
- constructor(git, lockManager, sdkOutputDir) {
742
- this.git = git;
743
- this.lockManager = lockManager;
744
- this.sdkOutputDir = sdkOutputDir;
745
- }
746
- async detectNewPatches() {
747
- const lock = this.lockManager.read();
748
- const lastGen = this.getLastGeneration(lock);
749
- if (!lastGen) {
750
- return { patches: [], revertedPatchIds: [] };
751
- }
752
- const exists = await this.git.commitExists(lastGen.commit_sha);
753
- if (!exists) {
754
- this.warnings.push(
755
- `Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to alternate detection.`
756
- );
757
- return this.detectPatchesViaTreeDiff(
758
- lastGen,
759
- /* commitKnownMissing */
760
- true
761
- );
762
- }
763
- const isAncestor = await this.git.isAncestor(lastGen.commit_sha, "HEAD");
764
- if (!isAncestor) {
765
- return this.detectPatchesViaMergeBase(lastGen, lock);
766
- }
767
- return this.detectPatchesInRange(lastGen.commit_sha, lastGen, lock);
768
- }
769
- /**
770
- * FER-10201 — Non-linear-history detection via `merge-base(prevGen, HEAD)`.
771
- *
772
- * After a squash-merge of a Fern-bot PR, `lastGen.commit_sha` (the previous
773
- * `[fern-generated]`) is orphaned but still in git's object database. The
774
- * merge-base is the commit on main from which the bot's branch was created —
775
- * a reachable ancestor of HEAD. Walking that range and classifying each
776
- * commit via `isGenerationCommit` mirrors the linear path and eliminates
777
- * the bug class where pipeline-driven changes (autoversion, replay,
778
- * generation) get bundled as customer customizations.
779
- *
780
- * Falls through to the legacy composite path when no common ancestor exists
781
- * (truly disjoint history — orphan branches with no shared root).
782
- */
783
- async detectPatchesViaMergeBase(lastGen, lock) {
784
- let mergeBase = "";
785
- try {
786
- mergeBase = (await this.git.exec(["merge-base", lastGen.commit_sha, "HEAD"])).trim();
787
- } catch {
788
- }
789
- if (!mergeBase) {
790
- this.warnings.push(
791
- `No common ancestor between previous generation ${lastGen.commit_sha.slice(0, 7)} and HEAD. Falling back to composite tree-diff detection.`
792
- );
793
- return this.detectPatchesViaTreeDiff(
794
- lastGen,
795
- /* commitKnownMissing */
796
- false
797
- );
798
- }
799
- return this.detectPatchesInRange(mergeBase, lastGen, lock);
818
+ function stripConflictMarkers(content) {
819
+ const lines = content.split(/\r?\n/);
820
+ const ranges = findConflictRanges(lines);
821
+ if (ranges.length === 0) {
822
+ return content;
800
823
  }
801
- /**
802
- * FER-10201 Walk `${rangeStart}..HEAD`, classify each commit, emit one
803
- * `StoredPatch` per customer commit. Body shared by the linear path
804
- * (rangeStart = lastGen.commit_sha) and the merge-base path.
805
- *
806
- * Patch `base_generation` is always `lastGen.commit_sha` — the
807
- * lockfile-tracked reference the applicator uses to fetch base file
808
- * content, regardless of where the walk actually started.
809
- */
810
- async detectPatchesInRange(rangeStart, lastGen, lock) {
811
- const log = await this.git.exec([
812
- "log",
813
- "--first-parent",
814
- "--format=%H%x00%an%x00%ae%x00%s",
815
- `${rangeStart}..HEAD`,
816
- "--",
817
- this.sdkOutputDir
818
- ]);
819
- if (!log.trim()) {
820
- return { patches: [], revertedPatchIds: [] };
821
- }
822
- const commits = this.parseGitLog(log);
823
- const newPatches = [];
824
- const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
825
- for (const commit of commits) {
826
- if (isGenerationCommit(commit)) {
827
- continue;
828
- }
829
- if (lock.patches.find((p) => p.original_commit === commit.sha)) {
824
+ const result = [];
825
+ let rangeIdx = 0;
826
+ for (let i = 0; i < lines.length; i++) {
827
+ if (rangeIdx < ranges.length) {
828
+ const range = ranges[rangeIdx];
829
+ if (i === range.start) {
830
830
  continue;
831
831
  }
832
- const parents = await this.git.getCommitParents(commit.sha);
833
- if (parents.length > 1) {
834
- const compositePatch = await this.createMergeCompositePatch({
835
- mergeCommit: commit,
836
- firstParent: parents[0],
837
- lastGen,
838
- lock
839
- });
840
- if (compositePatch) {
841
- newPatches.push(compositePatch);
842
- }
832
+ if (i > range.start && i < range.separator) {
833
+ result.push(lines[i]);
843
834
  continue;
844
835
  }
845
- let patchContent;
846
- try {
847
- patchContent = await this.git.formatPatch(commit.sha);
848
- } catch {
849
- this.warnings.push(
850
- `Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
851
- );
836
+ if (i === range.separator) {
852
837
  continue;
853
838
  }
854
- let contentHash = this.computeContentHash(patchContent);
855
- if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
839
+ if (i > range.separator && i < range.end) {
856
840
  continue;
857
841
  }
858
- const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
859
- const allFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
860
- const sensitiveFiles = allFiles.filter((f) => isSensitiveFile(f));
861
- const files = allFiles.filter((f) => !isSensitiveFile(f));
862
- if (sensitiveFiles.length > 0) {
863
- this.warnings.push(
864
- `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
865
- );
866
- if (files.length > 0) {
867
- const parentSha = parents[0];
868
- if (parentSha) {
869
- try {
870
- patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
871
- } catch {
872
- continue;
873
- }
874
- if (!patchContent.trim()) continue;
875
- contentHash = this.computeContentHash(patchContent);
876
- }
877
- }
878
- }
879
- if (files.length === 0) {
842
+ if (i === range.end) {
843
+ rangeIdx++;
880
844
  continue;
881
845
  }
882
- newPatches.push({
883
- id: `patch-${commit.sha.slice(0, 8)}`,
884
- content_hash: contentHash,
885
- original_commit: commit.sha,
886
- original_message: commit.message,
887
- original_author: `${commit.authorName} <${commit.authorEmail}>`,
888
- base_generation: lastGen.commit_sha,
889
- files,
890
- patch_content: patchContent,
891
- ...this.isCreationOnlyPatch(patchContent) ? { user_owned: true } : {}
892
- });
893
846
  }
894
- const survivingPatches = await this.collapseNetZeroFiles(newPatches);
895
- survivingPatches.reverse();
896
- const revertedPatchIdSet = /* @__PURE__ */ new Set();
897
- const revertIndicesToRemove = /* @__PURE__ */ new Set();
898
- for (let i = 0; i < survivingPatches.length; i++) {
899
- const patch = survivingPatches[i];
900
- if (!isRevertCommit(patch.original_message)) continue;
901
- let body = "";
902
- try {
903
- body = await this.git.getCommitBody(patch.original_commit);
904
- } catch {
905
- }
906
- const revertedSha = parseRevertedSha(body);
907
- const revertedMessage = parseRevertedMessage(patch.original_message);
908
- let matchedExisting = false;
909
- if (revertedSha) {
910
- const existing = lock.patches.find((p) => p.original_commit === revertedSha);
911
- if (existing) {
912
- revertedPatchIdSet.add(existing.id);
913
- revertIndicesToRemove.add(i);
914
- matchedExisting = true;
915
- }
916
- }
917
- if (!matchedExisting && revertedMessage) {
918
- const existing = lock.patches.find((p) => p.original_message === revertedMessage);
919
- if (existing) {
920
- revertedPatchIdSet.add(existing.id);
921
- revertIndicesToRemove.add(i);
922
- matchedExisting = true;
923
- }
924
- }
925
- if (matchedExisting) continue;
926
- let matchedNew = false;
927
- if (revertedSha) {
928
- const idx = survivingPatches.findIndex(
929
- (p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_commit === revertedSha
930
- );
931
- if (idx !== -1) {
932
- revertIndicesToRemove.add(i);
933
- revertIndicesToRemove.add(idx);
934
- matchedNew = true;
935
- }
936
- }
937
- if (!matchedNew && revertedMessage) {
938
- const idx = survivingPatches.findIndex(
939
- (p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_message === revertedMessage
940
- );
941
- if (idx !== -1) {
942
- revertIndicesToRemove.add(i);
943
- revertIndicesToRemove.add(idx);
944
- }
945
- }
946
- if (!matchedExisting && !matchedNew) {
947
- revertIndicesToRemove.add(i);
847
+ result.push(lines[i]);
848
+ }
849
+ return result.join("\n");
850
+ }
851
+
852
+ // src/ThreeWayMerge.ts
853
+ var import_node_diff3 = require("node-diff3");
854
+ function threeWayMerge(base, ours, theirs) {
855
+ const baseLines = base.split("\n");
856
+ const oursLines = ours.split("\n");
857
+ const theirsLines = theirs.split("\n");
858
+ const regions = (0, import_node_diff3.diff3Merge)(oursLines, baseLines, theirsLines);
859
+ const outputLines = [];
860
+ const conflicts = [];
861
+ let currentLine = 1;
862
+ for (const region of regions) {
863
+ if (region.ok) {
864
+ outputLines.push(...region.ok);
865
+ currentLine += region.ok.length;
866
+ } else if (region.conflict) {
867
+ const resolved = tryResolveConflict(
868
+ region.conflict.a,
869
+ // ours (generator)
870
+ region.conflict.o,
871
+ // base
872
+ region.conflict.b
873
+ // theirs (user)
874
+ );
875
+ if (resolved !== null) {
876
+ outputLines.push(...resolved);
877
+ currentLine += resolved.length;
878
+ } else {
879
+ const startLine = currentLine;
880
+ outputLines.push("<<<<<<< Generated");
881
+ outputLines.push(...region.conflict.a);
882
+ outputLines.push("=======");
883
+ outputLines.push(...region.conflict.b);
884
+ outputLines.push(">>>>>>> Your customization");
885
+ const conflictLines = region.conflict.a.length + region.conflict.b.length + 3;
886
+ conflicts.push({
887
+ startLine,
888
+ endLine: startLine + conflictLines - 1,
889
+ ours: region.conflict.a,
890
+ theirs: region.conflict.b
891
+ });
892
+ currentLine += conflictLines;
948
893
  }
949
894
  }
950
- const filteredPatches = survivingPatches.filter((_, i) => !revertIndicesToRemove.has(i));
951
- return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
952
895
  }
953
- /**
954
- * Compute content hash for deduplication.
955
- *
956
- * Produces a format-agnostic hash so both `git format-patch` output
957
- * (email-wrapped) and plain `git diff` output hash to the same value
958
- * when the underlying diff hunks are identical.
959
- *
960
- * Normalization:
961
- * 1. Strip email wrapper: everything before the first `diff --git` line
962
- * (From:, Subject:, Date:, diffstat, blank separators).
963
- * 2. Strip `index` lines (blob SHA pairs change across rebases).
964
- * 3. Strip trailing git version marker (`-- \n<version>`).
965
- *
966
- * This ensures content hashes match across the no-patches and normal-
967
- * regeneration flows, which store formatPatch and git-diff formats
968
- * respectively (FER-9850, D5-D9).
969
- */
970
- computeContentHash(patchContent) {
971
- const lines = patchContent.split("\n");
972
- const diffStart = lines.findIndex((l) => l.startsWith("diff --git "));
973
- const relevantLines = diffStart > 0 ? lines.slice(diffStart) : lines;
974
- const normalized = relevantLines.filter((line) => !line.startsWith("index ")).join("\n").replace(/\n-- \n[\d]+\.[\d]+[^\n]*\s*$/, "");
975
- return `sha256:${(0, import_node_crypto.createHash)("sha256").update(normalized).digest("hex")}`;
896
+ const result = {
897
+ content: outputLines.join("\n"),
898
+ hasConflicts: conflicts.length > 0,
899
+ conflicts
900
+ };
901
+ if (conflicts.length === 0) {
902
+ const dropped = computeDroppedContextLines(baseLines, theirsLines, outputLines);
903
+ if (dropped.length > 0) {
904
+ result.droppedContextLines = dropped;
905
+ }
976
906
  }
977
- /**
978
- * FER-9805 — Drop patches whose files were transiently created and then
979
- * deleted within the current linear detection window, and are absent from
980
- * HEAD at the end of the window.
981
- *
982
- * Rationale: on a merge commit, createMergeCompositePatch already diffs
983
- * firstParent..mergeCommit so net-zero files never enter the composite. On
984
- * linear history each commit becomes its own patch, so a file that was
985
- * briefly added and later removed survives as a create+delete pair whose
986
- * cumulative diff is empty. We drop such patches before they reach the
987
- * lockfile.
988
- *
989
- * A file is "transient" when:
990
- * 1. some patch in the window sets `new file mode` for it (a creation), AND
991
- * 2. some patch in the window sets `deleted file mode` for it (a deletion), AND
992
- * 3. it is absent from HEAD's tree.
993
- *
994
- * The HEAD guard (#3) prevents false positives when a file is deleted
995
- * and then recreated with different content — the cumulative diff of
996
- * such a pair is non-empty and must be preserved.
997
- *
998
- * This only drops patches whose `files` are a subset of the transient
999
- * set. A partial-transient patch (one that touches a transient file
1000
- * alongside a persistent one) is left unmodified; surgical stripping of
1001
- * single files from a multi-file patch would require a follow-up that
1002
- * regenerates the patch content via `git format-patch -- <files>`. No
1003
- * current failing test exercises this, and leaving the patch intact
1004
- * preserves today's behaviour. TODO(FER-9805): revisit if a future
1005
- * adversarial test demands per-file stripping.
1006
- */
1007
- async collapseNetZeroFiles(patches) {
1008
- if (patches.length === 0) return patches;
1009
- const stateByFile = /* @__PURE__ */ new Map();
1010
- for (const patch of patches) {
1011
- for (const header of parsePatchFileHeaders(patch.patch_content)) {
1012
- if (!header.isCreate && !header.isDelete) continue;
1013
- const prior = stateByFile.get(header.path) ?? { created: false, deleted: false };
1014
- if (header.isCreate) prior.created = true;
1015
- if (header.isDelete) prior.deleted = true;
1016
- stateByFile.set(header.path, prior);
1017
- }
907
+ return result;
908
+ }
909
+ function computeDroppedContextLines(baseLines, theirsLines, mergedLines) {
910
+ const baseCounts = countLines(baseLines);
911
+ const theirsCounts = countLines(theirsLines);
912
+ const mergedCounts = countLines(mergedLines);
913
+ const dropped = [];
914
+ const seen = /* @__PURE__ */ new Set();
915
+ for (const line of baseLines) {
916
+ if (seen.has(line)) continue;
917
+ const inBase = baseCounts.get(line) ?? 0;
918
+ const inTheirs = theirsCounts.get(line) ?? 0;
919
+ const inMerged = mergedCounts.get(line) ?? 0;
920
+ if (inBase > 0 && inTheirs > 0 && inMerged < Math.min(inBase, inTheirs)) {
921
+ dropped.push(line);
922
+ seen.add(line);
1018
923
  }
1019
- let anyCandidate = false;
1020
- for (const state of stateByFile.values()) {
1021
- if (state.created && state.deleted) {
1022
- anyCandidate = true;
1023
- break;
924
+ }
925
+ return dropped;
926
+ }
927
+ function countLines(lines) {
928
+ const counts = /* @__PURE__ */ new Map();
929
+ for (const line of lines) {
930
+ counts.set(line, (counts.get(line) ?? 0) + 1);
931
+ }
932
+ return counts;
933
+ }
934
+ function tryResolveConflict(oursLines, baseLines, theirsLines) {
935
+ if (baseLines.length === 0) {
936
+ return null;
937
+ }
938
+ const oursPatches = (0, import_node_diff3.diffPatch)(baseLines, oursLines);
939
+ const theirsPatches = (0, import_node_diff3.diffPatch)(baseLines, theirsLines);
940
+ if (oursPatches.length === 0) return theirsLines;
941
+ if (theirsPatches.length === 0) return oursLines;
942
+ if (patchesOverlap(oursPatches, theirsPatches)) {
943
+ return null;
944
+ }
945
+ return applyBothPatches(baseLines, oursPatches, theirsPatches);
946
+ }
947
+ function patchesOverlap(oursPatches, theirsPatches) {
948
+ for (const op of oursPatches) {
949
+ const oStart = op.buffer1.offset;
950
+ const oEnd = oStart + op.buffer1.length;
951
+ for (const tp of theirsPatches) {
952
+ const tStart = tp.buffer1.offset;
953
+ const tEnd = tStart + tp.buffer1.length;
954
+ if (op.buffer1.length === 0 && tp.buffer1.length === 0 && oStart === tStart) {
955
+ return true;
1024
956
  }
1025
- }
1026
- if (!anyCandidate) return patches;
1027
- const headFiles = await this.listHeadFiles();
1028
- const transient = /* @__PURE__ */ new Set();
1029
- for (const [file, state] of stateByFile) {
1030
- if (state.created && state.deleted && !headFiles.has(file)) {
1031
- transient.add(file);
957
+ if (tp.buffer1.length === 0 && tStart === oEnd) {
958
+ return true;
959
+ }
960
+ if (op.buffer1.length === 0 && oStart === tEnd) {
961
+ return true;
962
+ }
963
+ if (oStart < tEnd && tStart < oEnd) {
964
+ return true;
1032
965
  }
1033
966
  }
1034
- if (transient.size === 0) return patches;
1035
- return patches.filter((patch) => !patch.files.every((f) => transient.has(f)));
1036
967
  }
1037
- /**
1038
- * List every tracked path at HEAD (one `git ls-tree -r` invocation).
1039
- * Returns an empty Set if HEAD is unborn or the invocation fails — the
1040
- * caller then treats every candidate as "not in HEAD" and may collapse
1041
- * more aggressively. On typical SDK repos this is a single sub-10ms call.
968
+ return false;
969
+ }
970
+ function applyBothPatches(baseLines, oursPatches, theirsPatches) {
971
+ const allPatches = [
972
+ ...oursPatches.map((p) => ({
973
+ offset: p.buffer1.offset,
974
+ length: p.buffer1.length,
975
+ replacement: p.buffer2.chunk
976
+ })),
977
+ ...theirsPatches.map((p) => ({
978
+ offset: p.buffer1.offset,
979
+ length: p.buffer1.length,
980
+ replacement: p.buffer2.chunk
981
+ }))
982
+ ];
983
+ allPatches.sort((a, b) => b.offset - a.offset);
984
+ const result = [...baseLines];
985
+ for (const p of allPatches) {
986
+ result.splice(p.offset, p.length, ...p.replacement);
987
+ }
988
+ return result;
989
+ }
990
+
991
+ // src/ReplayApplicator.ts
992
+ var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
993
+ ".png",
994
+ ".jpg",
995
+ ".jpeg",
996
+ ".gif",
997
+ ".bmp",
998
+ ".ico",
999
+ ".webp",
1000
+ ".svg",
1001
+ ".pdf",
1002
+ ".doc",
1003
+ ".docx",
1004
+ ".xls",
1005
+ ".xlsx",
1006
+ ".ppt",
1007
+ ".pptx",
1008
+ ".zip",
1009
+ ".gz",
1010
+ ".tar",
1011
+ ".bz2",
1012
+ ".7z",
1013
+ ".rar",
1014
+ ".jar",
1015
+ ".war",
1016
+ ".ear",
1017
+ ".class",
1018
+ ".exe",
1019
+ ".dll",
1020
+ ".so",
1021
+ ".dylib",
1022
+ ".o",
1023
+ ".a",
1024
+ ".woff",
1025
+ ".woff2",
1026
+ ".ttf",
1027
+ ".eot",
1028
+ ".otf",
1029
+ ".mp3",
1030
+ ".mp4",
1031
+ ".avi",
1032
+ ".mov",
1033
+ ".wav",
1034
+ ".flac",
1035
+ ".sqlite",
1036
+ ".db",
1037
+ ".pyc",
1038
+ ".pyo",
1039
+ ".DS_Store"
1040
+ ]);
1041
+ var ReplayApplicator = class {
1042
+ git;
1043
+ lockManager;
1044
+ outputDir;
1045
+ renameCache = /* @__PURE__ */ new Map();
1046
+ treeExistsCache = /* @__PURE__ */ new Map();
1047
+ fileTheirsAccumulator = /* @__PURE__ */ new Map();
1048
+ /**
1049
+ * Apply mode for the current `applyPatches` invocation. Set at the start
1050
+ * of `applyPatches`, read by `mergeFile` to decide:
1051
+ * - whether to skip the intra-loop marker strip (kept in `applyPatches`)
1052
+ * - whether to populate the accumulator after a conflicted merge
1053
+ * (resolve mode populates so subsequent patches on the same file
1054
+ * can use patch A's THEIRS as a structurally-correct merge base
1055
+ * when their diff was authored against post-A structure)
1056
+ * - whether to retry THEIRS reconstruction against the accumulator
1057
+ * when the BASE-relative reconstruction produced markers from a
1058
+ * cross-patch context mismatch
1042
1059
  */
1043
- async listHeadFiles() {
1044
- try {
1045
- const out = await this.git.exec(["ls-tree", "-r", "--name-only", "HEAD"]);
1046
- return new Set(out.split("\n").map((l) => l.trim()).filter(Boolean));
1047
- } catch {
1048
- return /* @__PURE__ */ new Set();
1049
- }
1060
+ currentApplyMode = "replay";
1061
+ /**
1062
+ * Set of files that appear in 2+ patches in the current `applyPatches`
1063
+ * invocation. Computed at the top of `applyPatches`. Read by `mergeFile`
1064
+ * and `populateAccumulatorForPatch` to decide whether to use the patch's
1065
+ * `theirs_snapshot` directly as THEIRS (snapshot-as-primary) or fall
1066
+ * back to line-anchored reconstruction (FER-9525 cross-patch isolation).
1067
+ *
1068
+ * Snapshot-as-primary is correct when the patch owns its file (no other
1069
+ * patch in this run touches it): the snapshot is what the customer
1070
+ * intends for that file post-regen, regardless of generator structural
1071
+ * changes that would invalidate the diff's line anchors.
1072
+ *
1073
+ * When a file IS shared, snapshots are CUMULATIVE across the patches
1074
+ * touching it (each one's snapshot includes prior patches' contributions).
1075
+ * Using a later patch's cumulative snapshot as its individual THEIRS
1076
+ * would propagate prior patches' edits into the merge — surfacing nested
1077
+ * conflicts at regions the patch alone never touched. Reconstruction
1078
+ * via `applyPatchToContent` is required there.
1079
+ */
1080
+ sharedFiles = /* @__PURE__ */ new Set();
1081
+ constructor(git, lockManager, outputDir) {
1082
+ this.git = git;
1083
+ this.lockManager = lockManager;
1084
+ this.outputDir = outputDir;
1050
1085
  }
1051
1086
  /**
1052
- * Create a single composite patch for a merge commit by diffing the merge
1053
- * commit against its first parent. This captures the net change of the
1054
- * entire merged branch as one patch, eliminating accumulator chaining and
1055
- * zero-net-change ghost conflicts.
1087
+ * Resolve the GenerationRecord for a patch's base_generation.
1088
+ * Falls back to constructing an ad-hoc record from the commit's tree
1089
+ * when base_generation isn't a tracked generation (commit-scan patches).
1056
1090
  */
1057
- async createMergeCompositePatch(input) {
1058
- const { mergeCommit, firstParent, lastGen, lock } = input;
1059
- const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
1060
- const filesOutput = await this.git.exec([
1061
- "diff",
1062
- "--name-only",
1063
- firstParent,
1064
- mergeCommit.sha
1065
- ]);
1066
- const allMergeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
1067
- const sensitiveMergeFiles = allMergeFiles.filter((f) => isSensitiveFile(f));
1068
- const files = allMergeFiles.filter((f) => !isSensitiveFile(f));
1069
- if (sensitiveMergeFiles.length > 0) {
1070
- this.warnings.push(
1071
- `Sensitive file(s) excluded from merge patch for commit ${mergeCommit.sha.slice(0, 7)}: ${sensitiveMergeFiles.join(", ")}. These files will not be tracked by replay.`
1072
- );
1073
- }
1074
- if (files.length === 0) return null;
1075
- const diff = await this.git.exec([
1076
- "diff",
1077
- firstParent,
1078
- mergeCommit.sha,
1079
- "--",
1080
- ...files
1081
- ]);
1082
- if (!diff.trim()) return null;
1083
- const contentHash = this.computeContentHash(diff);
1084
- if (lock.patches.some((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
1085
- return null;
1091
+ async resolveBaseGeneration(baseGeneration) {
1092
+ const gen = this.lockManager.getGeneration(baseGeneration);
1093
+ if (gen) return gen;
1094
+ try {
1095
+ const treeHash = await this.git.getTreeHash(baseGeneration);
1096
+ return {
1097
+ commit_sha: baseGeneration,
1098
+ tree_hash: treeHash,
1099
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1100
+ cli_version: "unknown",
1101
+ generator_versions: {}
1102
+ };
1103
+ } catch {
1104
+ return void 0;
1086
1105
  }
1087
- return {
1088
- id: `patch-merge-${mergeCommit.sha.slice(0, 8)}`,
1089
- content_hash: contentHash,
1090
- original_commit: mergeCommit.sha,
1091
- original_message: mergeCommit.message,
1092
- original_author: `${mergeCommit.authorName} <${mergeCommit.authorEmail}>`,
1093
- base_generation: lastGen.commit_sha,
1094
- files,
1095
- patch_content: diff,
1096
- ...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
1097
- };
1106
+ }
1107
+ /** Reset inter-patch accumulator for a new cycle. */
1108
+ resetAccumulator() {
1109
+ this.fileTheirsAccumulator.clear();
1098
1110
  }
1099
1111
  /**
1100
- * Detect patches via tree diff for non-linear history. Returns a composite patch.
1101
- * Revert reconciliation is skipped here because tree-diff produces a single composite
1102
- * patch from the aggregate diff individual revert commits are not distinguishable.
1112
+ * @internal Test-only.
1113
+ * Returns a defensive copy of the inter-patch accumulator. Used by the
1114
+ * adversarial test suite (FER-9791) to assert invariant I2: after every
1115
+ * applied patch, the accumulator has an entry for each file the patch
1116
+ * touched, and the entry's content matches on-disk.
1103
1117
  */
1104
- async detectPatchesViaTreeDiff(lastGen, commitKnownMissing) {
1105
- const diffBase = await this.resolveDiffBase(lastGen, commitKnownMissing);
1106
- if (!diffBase) {
1107
- return this.detectPatchesViaCommitScan();
1108
- }
1109
- const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
1110
- const allTreeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
1111
- const sensitiveTreeFiles = allTreeFiles.filter((f) => isSensitiveFile(f));
1112
- const files = allTreeFiles.filter((f) => !isSensitiveFile(f));
1113
- if (sensitiveTreeFiles.length > 0) {
1114
- this.warnings.push(
1115
- `Sensitive file(s) excluded from composite patch: ${sensitiveTreeFiles.join(", ")}. These files will not be tracked by replay.`
1116
- );
1117
- }
1118
- if (files.length === 0) return { patches: [], revertedPatchIds: [] };
1119
- const diff = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
1120
- if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
1121
- const contentHash = this.computeContentHash(diff);
1122
- const lock = this.lockManager.read();
1123
- if (lock.patches.some((p) => p.content_hash === contentHash) || (lock.forgotten_hashes ?? []).includes(contentHash)) {
1124
- return { patches: [], revertedPatchIds: [] };
1118
+ getAccumulatorSnapshot() {
1119
+ const snapshot = /* @__PURE__ */ new Map();
1120
+ for (const [path, entry] of this.fileTheirsAccumulator) {
1121
+ snapshot.set(path, { content: entry.content, baseGeneration: entry.baseGeneration });
1125
1122
  }
1126
- const headSha = (await this.git.exec(["rev-parse", "HEAD"])).trim();
1127
- const compositePatch = {
1128
- id: `patch-composite-${headSha.slice(0, 8)}`,
1129
- content_hash: contentHash,
1130
- original_commit: headSha,
1131
- original_message: "Customer customizations (composite)",
1132
- original_author: "composite",
1133
- // Use diffBase when commit is unreachable — the applicator needs a reachable
1134
- // reference to find base file content. diffBase may be the tree_hash.
1135
- base_generation: commitKnownMissing ? diffBase : lastGen.commit_sha,
1136
- files,
1137
- patch_content: diff,
1138
- ...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
1139
- };
1140
- return { patches: [compositePatch], revertedPatchIds: [] };
1123
+ return snapshot;
1141
1124
  }
1142
1125
  /**
1143
- * Last-resort detection when both generation commit and tree are unreachable.
1144
- * Scans all commits from HEAD, filters against known lockfile patches, and
1145
- * skips creation-only commits (squashed history after force push).
1126
+ * Apply all patches, returning results for each.
1127
+ * Skips patches that match exclude patterns in replay.yml
1146
1128
  *
1147
- * Detected patches use the commit's parent as base_generation so the applicator
1148
- * can find base file content from the parent's tree (which IS reachable).
1129
+ * `applyMode` controls the post-conflict marker strategy:
1130
+ * - `"replay"` (default): strip conflict markers from disk between
1131
+ * iterations whenever a later patch touches the same file. Lets the
1132
+ * follow-up patch's `git apply --3way` see clean OURS content,
1133
+ * which is what the silent-loss fix (PR #73) relies on. The replay
1134
+ * pipeline calls `revertConflictingFiles` after the loop to clean
1135
+ * any markers that survive.
1136
+ * - `"resolve"`: keep markers on disk. The resolve command needs the
1137
+ * customer to see them. Slow-path 3-way merge naturally preserves
1138
+ * A's markers and B's clean writes when their regions don't overlap;
1139
+ * when they do overlap, the customer gets nested markers and
1140
+ * resolves manually. Either way they aren't silently dropped.
1149
1141
  */
1150
- async detectPatchesViaCommitScan() {
1151
- const lock = this.lockManager.read();
1152
- const log = await this.git.exec([
1153
- "log",
1154
- "--max-count=200",
1155
- "--format=%H%x00%an%x00%ae%x00%s",
1156
- "HEAD",
1157
- "--",
1158
- this.sdkOutputDir
1159
- ]);
1160
- if (!log.trim()) {
1161
- return { patches: [], revertedPatchIds: [] };
1162
- }
1163
- const commits = this.parseGitLog(log);
1164
- const newPatches = [];
1165
- const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
1166
- const existingHashes = new Set(lock.patches.map((p) => p.content_hash));
1167
- const existingCommits = new Set(lock.patches.map((p) => p.original_commit));
1168
- for (const commit of commits) {
1169
- if (isGenerationCommit(commit)) {
1170
- continue;
1171
- }
1172
- const parents = await this.git.getCommitParents(commit.sha);
1173
- if (parents.length > 1) {
1174
- continue;
1175
- }
1176
- if (existingCommits.has(commit.sha)) {
1177
- continue;
1178
- }
1179
- let patchContent;
1180
- try {
1181
- patchContent = await this.git.formatPatch(commit.sha);
1182
- } catch {
1183
- continue;
1184
- }
1185
- let contentHash = this.computeContentHash(patchContent);
1186
- if (existingHashes.has(contentHash) || forgottenHashes.has(contentHash)) {
1187
- continue;
1142
+ async applyPatches(patches, opts) {
1143
+ const applyMode = opts?.applyMode ?? "replay";
1144
+ this.currentApplyMode = applyMode;
1145
+ this.resetAccumulator();
1146
+ const fileFreq = /* @__PURE__ */ new Map();
1147
+ for (const p of patches) {
1148
+ for (const f of p.files) {
1149
+ fileFreq.set(f, (fileFreq.get(f) ?? 0) + 1);
1188
1150
  }
1189
- if (this.isCreationOnlyPatch(patchContent)) {
1151
+ }
1152
+ this.sharedFiles = new Set(
1153
+ Array.from(fileFreq.entries()).filter(([, n]) => n > 1).map(([f]) => f)
1154
+ );
1155
+ const results = [];
1156
+ for (let i = 0; i < patches.length; i++) {
1157
+ const patch = patches[i];
1158
+ if (this.isExcluded(patch)) {
1159
+ results.push({
1160
+ patch,
1161
+ status: "skipped",
1162
+ method: "git-am"
1163
+ });
1190
1164
  continue;
1191
1165
  }
1192
- const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
1193
- const allScanFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f));
1194
- const sensitiveScanFiles = allScanFiles.filter((f) => isSensitiveFile(f));
1195
- const files = allScanFiles.filter((f) => !isSensitiveFile(f));
1196
- if (sensitiveScanFiles.length > 0) {
1197
- this.warnings.push(
1198
- `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveScanFiles.join(", ")}. These files will not be tracked by replay.`
1199
- );
1200
- if (files.length > 0) {
1201
- if (parents.length === 0) {
1202
- continue;
1166
+ const result = await this.applyPatchWithFallback(patch);
1167
+ results.push(result);
1168
+ if (applyMode !== "resolve" && result.status === "conflict" && result.fileResults) {
1169
+ const laterFiles = /* @__PURE__ */ new Set();
1170
+ for (let j = i + 1; j < patches.length; j++) {
1171
+ for (const f of patches[j].files) {
1172
+ laterFiles.add(f);
1203
1173
  }
1204
- try {
1205
- patchContent = await this.git.exec(["diff", parents[0], commit.sha, "--", ...files]);
1206
- } catch {
1207
- continue;
1174
+ }
1175
+ const resolvedToOriginal = /* @__PURE__ */ new Map();
1176
+ if (result.resolvedFiles) {
1177
+ for (const [orig, resolved] of Object.entries(result.resolvedFiles)) {
1178
+ resolvedToOriginal.set(resolved, orig);
1179
+ }
1180
+ }
1181
+ for (const fileResult of result.fileResults) {
1182
+ if (fileResult.status !== "conflict") continue;
1183
+ const originalPath = resolvedToOriginal.get(fileResult.file) ?? fileResult.file;
1184
+ if (laterFiles.has(fileResult.file) || laterFiles.has(originalPath)) {
1185
+ const filePath = (0, import_node_path2.join)(this.outputDir, fileResult.file);
1186
+ try {
1187
+ const content = await (0, import_promises.readFile)(filePath, "utf-8");
1188
+ const stripped = stripConflictMarkers(content);
1189
+ await (0, import_promises.writeFile)(filePath, stripped);
1190
+ } catch {
1191
+ }
1208
1192
  }
1209
- if (!patchContent.trim()) continue;
1210
- contentHash = this.computeContentHash(patchContent);
1211
1193
  }
1212
1194
  }
1213
- if (files.length === 0) {
1214
- continue;
1215
- }
1216
- if (parents.length === 0) {
1217
- continue;
1218
- }
1219
- const parentSha = parents[0];
1220
- newPatches.push({
1221
- id: `patch-${commit.sha.slice(0, 8)}`,
1222
- content_hash: contentHash,
1223
- original_commit: commit.sha,
1224
- original_message: commit.message,
1225
- original_author: `${commit.authorName} <${commit.authorEmail}>`,
1226
- base_generation: parentSha,
1227
- files,
1228
- patch_content: patchContent
1229
- });
1230
1195
  }
1231
- newPatches.reverse();
1232
- return { patches: newPatches, revertedPatchIds: [] };
1196
+ return results;
1233
1197
  }
1234
1198
  /**
1235
- * Check if a format-patch consists entirely of new-file creations.
1236
- * Used to identify squashed commits after force push, which create all files
1237
- * from scratch (--- /dev/null) rather than modifying existing files.
1199
+ * Populate accumulator after git apply succeeds, AND collect per-file
1200
+ * results including any "dropped context lines" lines that were
1201
+ * present in BASE and THEIRS (unchanged context) but absent from the
1202
+ * final on-disk state because OURS deleted them. This is the fast-path
1203
+ * counterpart to ThreeWayMerge.computeDroppedContextLines used by the
1204
+ * 3-way slow path; both must surface the same warning.
1238
1205
  */
1239
- isCreationOnlyPatch(patchContent) {
1240
- const diffOldHeaders = patchContent.split("\n").filter((l) => l.startsWith("--- "));
1241
- if (diffOldHeaders.length === 0) {
1242
- return false;
1206
+ async populateAccumulatorForPatch(patch, baseGen, currentTreeHash) {
1207
+ const fileResults = [];
1208
+ if (!baseGen) return fileResults;
1209
+ const tempDir = await (0, import_promises.mkdtemp)((0, import_node_path2.join)((0, import_node_os.tmpdir)(), "replay-acc-"));
1210
+ const { GitClient: GitClient2 } = await Promise.resolve().then(() => (init_GitClient(), GitClient_exports));
1211
+ const tempGit = new GitClient2(tempDir);
1212
+ await tempGit.exec(["init"]);
1213
+ await tempGit.exec(["config", "user.email", "replay@fern.com"]);
1214
+ await tempGit.exec(["config", "user.name", "Fern Replay"]);
1215
+ try {
1216
+ for (const filePath of patch.files) {
1217
+ if (isBinaryFile(filePath)) continue;
1218
+ const resolvedPath = await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
1219
+ const base = await this.git.showFile(baseGen.tree_hash, filePath);
1220
+ const snapshotForFile = patch.theirs_snapshot?.[resolvedPath] ?? patch.theirs_snapshot?.[filePath];
1221
+ const fileIsShared = this.sharedFiles.has(resolvedPath) || this.sharedFiles.has(filePath);
1222
+ const theirs = !fileIsShared && snapshotForFile != null ? snapshotForFile : await this.applyPatchToContent(base, patch.patch_content, filePath, tempGit, tempDir);
1223
+ const finalOnDisk = await (0, import_promises.readFile)((0, import_node_path2.join)(this.outputDir, resolvedPath), "utf-8").catch(() => null);
1224
+ const effectiveTheirs = theirs ?? finalOnDisk;
1225
+ if (effectiveTheirs != null) {
1226
+ this.fileTheirsAccumulator.set(resolvedPath, {
1227
+ content: effectiveTheirs,
1228
+ baseGeneration: patch.base_generation
1229
+ });
1230
+ }
1231
+ if (base != null && effectiveTheirs != null && finalOnDisk != null) {
1232
+ const dropped = computeDroppedContextLinesForFile(base, effectiveTheirs, finalOnDisk);
1233
+ if (dropped.length > 0) {
1234
+ fileResults.push({
1235
+ file: resolvedPath,
1236
+ status: "merged",
1237
+ droppedContextLines: dropped
1238
+ });
1239
+ }
1240
+ }
1241
+ }
1242
+ } finally {
1243
+ await (0, import_promises.rm)(tempDir, { recursive: true }).catch(() => {
1244
+ });
1243
1245
  }
1244
- return diffOldHeaders.every((l) => l === "--- /dev/null");
1246
+ return fileResults;
1245
1247
  }
1246
- /**
1247
- * Resolve the best available diff base for a generation record.
1248
- * Prefers commit_sha, falls back to tree_hash for unreachable commits.
1249
- * When commitKnownMissing is true, skips the redundant commitExists check.
1250
- */
1251
- async resolveDiffBase(gen, commitKnownMissing) {
1252
- if (!commitKnownMissing && await this.git.commitExists(gen.commit_sha)) {
1253
- return gen.commit_sha;
1248
+ async applyPatchWithFallback(patch) {
1249
+ const baseGen = await this.resolveBaseGeneration(patch.base_generation);
1250
+ const lock = this.lockManager.read();
1251
+ const currentGen = lock.generations.find((g) => g.commit_sha === lock.current_generation);
1252
+ const currentTreeHash = currentGen?.tree_hash ?? baseGen?.tree_hash ?? "";
1253
+ const needsAccumulation = await Promise.all(
1254
+ patch.files.map(async (f) => {
1255
+ if (!baseGen) return false;
1256
+ const resolved = await this.resolveFilePath(f, baseGen.tree_hash, currentTreeHash);
1257
+ return this.fileTheirsAccumulator.has(resolved);
1258
+ })
1259
+ ).then((results) => results.some(Boolean));
1260
+ const resolvedFiles = {};
1261
+ for (const filePath of patch.files) {
1262
+ const resolvedPath = baseGen ? await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash) : filePath;
1263
+ if (resolvedPath !== filePath) {
1264
+ resolvedFiles[filePath] = resolvedPath;
1265
+ }
1254
1266
  }
1255
- if (await this.git.treeExists(gen.tree_hash)) {
1256
- return gen.tree_hash;
1267
+ const patchIsRenameAware = /^rename from /m.test(patch.patch_content);
1268
+ const hasGeneratorRename = Object.keys(resolvedFiles).length > 0 && !patchIsRenameAware;
1269
+ if (!needsAccumulation && !hasGeneratorRename) {
1270
+ const snapshots = /* @__PURE__ */ new Map();
1271
+ for (const filePath of patch.files) {
1272
+ const fullPath = (0, import_node_path2.join)(this.outputDir, filePath);
1273
+ snapshots.set(filePath, await (0, import_promises.readFile)(fullPath, "utf-8").catch(() => null));
1274
+ }
1275
+ try {
1276
+ await this.git.execWithInput(["apply", "--3way"], patch.patch_content);
1277
+ const fastPathFileResults = await this.populateAccumulatorForPatch(patch, baseGen, currentTreeHash);
1278
+ return {
1279
+ patch,
1280
+ status: "applied",
1281
+ method: "git-am",
1282
+ ...fastPathFileResults.length > 0 && { fileResults: fastPathFileResults },
1283
+ ...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
1284
+ };
1285
+ } catch {
1286
+ for (const [filePath, content] of snapshots) {
1287
+ const fullPath = (0, import_node_path2.join)(this.outputDir, filePath);
1288
+ if (content != null) {
1289
+ await (0, import_promises.writeFile)(fullPath, content);
1290
+ } else {
1291
+ await (0, import_promises.unlink)(fullPath).catch(() => {
1292
+ });
1293
+ }
1294
+ }
1295
+ }
1257
1296
  }
1258
- return null;
1297
+ return this.applyWithThreeWayMerge(patch);
1259
1298
  }
1260
- parseGitLog(log) {
1261
- return log.trim().split("\n").map((line) => {
1262
- const [sha, authorName, authorEmail, message] = line.split("\0");
1263
- return { sha, authorName, authorEmail, message };
1264
- });
1299
+ async applyWithThreeWayMerge(patch) {
1300
+ const fileResults = [];
1301
+ const resolvedFiles = {};
1302
+ const tempDir = await (0, import_promises.mkdtemp)((0, import_node_path2.join)((0, import_node_os.tmpdir)(), "replay-"));
1303
+ const { GitClient: GitClient2 } = await Promise.resolve().then(() => (init_GitClient(), GitClient_exports));
1304
+ const tempGit = new GitClient2(tempDir);
1305
+ await tempGit.exec(["init"]);
1306
+ await tempGit.exec(["config", "user.email", "replay@fern.com"]);
1307
+ await tempGit.exec(["config", "user.name", "Fern Replay"]);
1308
+ try {
1309
+ for (const filePath of patch.files) {
1310
+ if (isBinaryFile(filePath)) {
1311
+ fileResults.push({
1312
+ file: filePath,
1313
+ status: "skipped",
1314
+ reason: "binary-file"
1315
+ });
1316
+ continue;
1317
+ }
1318
+ const result = await this.mergeFile(patch, filePath, tempGit, tempDir);
1319
+ if (result.file !== filePath) {
1320
+ resolvedFiles[filePath] = result.file;
1321
+ }
1322
+ fileResults.push(result);
1323
+ }
1324
+ } finally {
1325
+ await (0, import_promises.rm)(tempDir, { recursive: true }).catch(() => {
1326
+ });
1327
+ }
1328
+ const conflictFiles = fileResults.filter((r) => r.status === "conflict");
1329
+ const hasConflicts = conflictFiles.length > 0;
1330
+ const conflictReason = hasConflicts ? conflictFiles.some((f) => f.conflictReason === "base-generation-mismatch") ? "base-generation-mismatch" : conflictFiles[0]?.conflictReason : void 0;
1331
+ return {
1332
+ patch,
1333
+ status: hasConflicts ? "conflict" : "applied",
1334
+ method: "3way-merge",
1335
+ fileResults,
1336
+ conflictReason,
1337
+ ...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
1338
+ };
1265
1339
  }
1266
- getLastGeneration(lock) {
1267
- return lock.generations.find((g) => g.commit_sha === lock.current_generation);
1268
- }
1269
- };
1270
-
1271
- // src/ThreeWayMerge.ts
1272
- var import_node_diff3 = require("node-diff3");
1273
- function threeWayMerge(base, ours, theirs) {
1274
- const baseLines = base.split("\n");
1275
- const oursLines = ours.split("\n");
1276
- const theirsLines = theirs.split("\n");
1277
- const regions = (0, import_node_diff3.diff3Merge)(oursLines, baseLines, theirsLines);
1278
- const outputLines = [];
1279
- const conflicts = [];
1280
- let currentLine = 1;
1281
- for (const region of regions) {
1282
- if (region.ok) {
1283
- outputLines.push(...region.ok);
1284
- currentLine += region.ok.length;
1285
- } else if (region.conflict) {
1286
- const resolved = tryResolveConflict(
1287
- region.conflict.a,
1288
- // ours (generator)
1289
- region.conflict.o,
1290
- // base
1291
- region.conflict.b
1292
- // theirs (user)
1293
- );
1294
- if (resolved !== null) {
1295
- outputLines.push(...resolved);
1296
- currentLine += resolved.length;
1297
- } else {
1298
- const startLine = currentLine;
1299
- outputLines.push("<<<<<<< Generated");
1300
- outputLines.push(...region.conflict.a);
1301
- outputLines.push("=======");
1302
- outputLines.push(...region.conflict.b);
1303
- outputLines.push(">>>>>>> Your customization");
1304
- const conflictLines = region.conflict.a.length + region.conflict.b.length + 3;
1305
- conflicts.push({
1306
- startLine,
1307
- endLine: startLine + conflictLines - 1,
1308
- ours: region.conflict.a,
1309
- theirs: region.conflict.b
1310
- });
1311
- currentLine += conflictLines;
1340
+ async mergeFile(patch, filePath, tempGit, tempDir) {
1341
+ try {
1342
+ const baseGen = await this.resolveBaseGeneration(patch.base_generation);
1343
+ if (!baseGen) {
1344
+ return { file: filePath, status: "skipped", reason: "base-generation-not-found" };
1312
1345
  }
1313
- }
1314
- }
1315
- const result = {
1316
- content: outputLines.join("\n"),
1317
- hasConflicts: conflicts.length > 0,
1318
- conflicts
1319
- };
1320
- if (conflicts.length === 0) {
1321
- const dropped = computeDroppedContextLines(baseLines, theirsLines, outputLines);
1322
- if (dropped.length > 0) {
1323
- result.droppedContextLines = dropped;
1324
- }
1325
- }
1326
- return result;
1327
- }
1328
- function computeDroppedContextLines(baseLines, theirsLines, mergedLines) {
1329
- const baseCounts = countLines(baseLines);
1330
- const theirsCounts = countLines(theirsLines);
1331
- const mergedCounts = countLines(mergedLines);
1332
- const dropped = [];
1333
- const seen = /* @__PURE__ */ new Set();
1334
- for (const line of baseLines) {
1335
- if (seen.has(line)) continue;
1336
- const inBase = baseCounts.get(line) ?? 0;
1337
- const inTheirs = theirsCounts.get(line) ?? 0;
1338
- const inMerged = mergedCounts.get(line) ?? 0;
1339
- if (inBase > 0 && inTheirs > 0 && inMerged < Math.min(inBase, inTheirs)) {
1340
- dropped.push(line);
1341
- seen.add(line);
1342
- }
1343
- }
1344
- return dropped;
1345
- }
1346
- function countLines(lines) {
1347
- const counts = /* @__PURE__ */ new Map();
1348
- for (const line of lines) {
1349
- counts.set(line, (counts.get(line) ?? 0) + 1);
1350
- }
1351
- return counts;
1352
- }
1353
- function tryResolveConflict(oursLines, baseLines, theirsLines) {
1354
- if (baseLines.length === 0) {
1355
- return null;
1356
- }
1357
- const oursPatches = (0, import_node_diff3.diffPatch)(baseLines, oursLines);
1358
- const theirsPatches = (0, import_node_diff3.diffPatch)(baseLines, theirsLines);
1359
- if (oursPatches.length === 0) return theirsLines;
1360
- if (theirsPatches.length === 0) return oursLines;
1361
- if (patchesOverlap(oursPatches, theirsPatches)) {
1362
- return null;
1363
- }
1364
- return applyBothPatches(baseLines, oursPatches, theirsPatches);
1365
- }
1366
- function patchesOverlap(oursPatches, theirsPatches) {
1367
- for (const op of oursPatches) {
1368
- const oStart = op.buffer1.offset;
1369
- const oEnd = oStart + op.buffer1.length;
1370
- for (const tp of theirsPatches) {
1371
- const tStart = tp.buffer1.offset;
1372
- const tEnd = tStart + tp.buffer1.length;
1373
- if (op.buffer1.length === 0 && tp.buffer1.length === 0 && oStart === tStart) {
1374
- return true;
1346
+ const lock = this.lockManager.read();
1347
+ const currentGen = lock.generations.find((g) => g.commit_sha === lock.current_generation);
1348
+ const currentTreeHash = currentGen?.tree_hash ?? baseGen.tree_hash;
1349
+ const resolvedPath = await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
1350
+ const metadata = {
1351
+ patchId: patch.id,
1352
+ patchMessage: patch.original_message,
1353
+ baseGeneration: patch.base_generation,
1354
+ currentGeneration: lock.current_generation
1355
+ };
1356
+ let base = await this.git.showFile(baseGen.tree_hash, filePath);
1357
+ let renameSourcePath;
1358
+ if (!base) {
1359
+ const renameSource = this.extractRenameSource(patch.patch_content, filePath);
1360
+ if (renameSource) {
1361
+ base = await this.git.showFile(baseGen.tree_hash, renameSource);
1362
+ renameSourcePath = renameSource;
1363
+ }
1375
1364
  }
1376
- if (tp.buffer1.length === 0 && tStart === oEnd) {
1377
- return true;
1365
+ const oursPath = (0, import_node_path2.join)(this.outputDir, resolvedPath);
1366
+ const ours = await (0, import_promises.readFile)(oursPath, "utf-8").catch(() => null);
1367
+ let ghostReconstructed = false;
1368
+ let theirs = null;
1369
+ if (!base && ours && !renameSourcePath) {
1370
+ const treeReachable = await this.isTreeReachable(baseGen.tree_hash);
1371
+ if (!treeReachable) {
1372
+ const fileDiff = this.extractFileDiff(patch.patch_content, filePath);
1373
+ if (fileDiff) {
1374
+ const { reconstructFromGhostPatch: reconstructFromGhostPatch2 } = await Promise.resolve().then(() => (init_HybridReconstruction(), HybridReconstruction_exports));
1375
+ const result = reconstructFromGhostPatch2(fileDiff, ours);
1376
+ if (result) {
1377
+ base = result.base;
1378
+ theirs = result.theirs;
1379
+ ghostReconstructed = true;
1380
+ }
1381
+ }
1382
+ }
1378
1383
  }
1379
- if (op.buffer1.length === 0 && oStart === tEnd) {
1380
- return true;
1384
+ const snapshotForFile = patch.theirs_snapshot?.[resolvedPath] ?? patch.theirs_snapshot?.[filePath];
1385
+ const fileIsShared = this.sharedFiles.has(resolvedPath) || this.sharedFiles.has(filePath);
1386
+ const useSnapshotAsPrimary = !fileIsShared && snapshotForFile != null;
1387
+ if (!ghostReconstructed) {
1388
+ if (useSnapshotAsPrimary) {
1389
+ theirs = snapshotForFile;
1390
+ } else {
1391
+ theirs = await this.applyPatchToContent(
1392
+ base,
1393
+ patch.patch_content,
1394
+ filePath,
1395
+ tempGit,
1396
+ tempDir,
1397
+ renameSourcePath
1398
+ );
1399
+ const reconstructionHasMarkers = theirs != null && (theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization"));
1400
+ const baseHadMarkers = base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization"));
1401
+ if (reconstructionHasMarkers && !baseHadMarkers && snapshotForFile != null) {
1402
+ theirs = snapshotForFile;
1403
+ }
1404
+ }
1381
1405
  }
1382
- if (oStart < tEnd && tStart < oEnd) {
1383
- return true;
1406
+ const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
1407
+ if (theirs) {
1408
+ const theirsHasMarkers = theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization");
1409
+ const baseHasMarkers = base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization"));
1410
+ if (theirsHasMarkers && !baseHasMarkers) {
1411
+ if (accumulatorEntry) {
1412
+ theirs = null;
1413
+ } else {
1414
+ return {
1415
+ file: resolvedPath,
1416
+ status: "skipped",
1417
+ reason: "stale-conflict-markers"
1418
+ };
1419
+ }
1420
+ }
1384
1421
  }
1385
- }
1386
- }
1387
- return false;
1388
- }
1389
- function applyBothPatches(baseLines, oursPatches, theirsPatches) {
1390
- const allPatches = [
1391
- ...oursPatches.map((p) => ({
1392
- offset: p.buffer1.offset,
1393
- length: p.buffer1.length,
1394
- replacement: p.buffer2.chunk
1395
- })),
1396
- ...theirsPatches.map((p) => ({
1397
- offset: p.buffer1.offset,
1398
- length: p.buffer1.length,
1399
- replacement: p.buffer2.chunk
1400
- }))
1401
- ];
1402
- allPatches.sort((a, b) => b.offset - a.offset);
1403
- const result = [...baseLines];
1404
- for (const p of allPatches) {
1405
- result.splice(p.offset, p.length, ...p.replacement);
1406
- }
1407
- return result;
1408
- }
1409
-
1410
- // src/ReplayApplicator.ts
1411
- var import_promises = require("fs/promises");
1412
- var import_node_os = require("os");
1413
- var import_node_path2 = require("path");
1414
- var import_minimatch = require("minimatch");
1415
-
1416
- // src/conflict-utils.ts
1417
- var CONFLICT_OPENER = "<<<<<<< Generated";
1418
- var CONFLICT_SEPARATOR = "=======";
1419
- var CONFLICT_CLOSER = ">>>>>>> Your customization";
1420
- function trimCR(line) {
1421
- return line.endsWith("\r") ? line.slice(0, -1) : line;
1422
- }
1423
- function findConflictRanges(lines) {
1424
- const ranges = [];
1425
- let i = 0;
1426
- while (i < lines.length) {
1427
- if (trimCR(lines[i]) === CONFLICT_OPENER) {
1428
- let separatorIdx = -1;
1429
- let j = i + 1;
1430
- let found = false;
1431
- while (j < lines.length) {
1432
- const trimmed = trimCR(lines[j]);
1433
- if (trimmed === CONFLICT_OPENER) {
1434
- break;
1422
+ let useAccumulatorAsMergeBase = false;
1423
+ if (accumulatorEntry && (theirs == null || base == null)) {
1424
+ theirs = await this.applyPatchToContent(
1425
+ accumulatorEntry.content,
1426
+ patch.patch_content,
1427
+ filePath,
1428
+ tempGit,
1429
+ tempDir
1430
+ );
1431
+ if (theirs != null) {
1432
+ useAccumulatorAsMergeBase = true;
1433
+ } else if (await this.isPatchAlreadyApplied(
1434
+ patch.patch_content,
1435
+ filePath,
1436
+ accumulatorEntry.content,
1437
+ tempGit,
1438
+ tempDir
1439
+ )) {
1440
+ theirs = accumulatorEntry.content;
1441
+ useAccumulatorAsMergeBase = true;
1435
1442
  }
1436
- if (separatorIdx === -1 && trimmed === CONFLICT_SEPARATOR) {
1437
- separatorIdx = j;
1438
- } else if (separatorIdx !== -1 && trimmed === CONFLICT_CLOSER) {
1439
- ranges.push({ start: i, separator: separatorIdx, end: j });
1440
- i = j;
1441
- found = true;
1442
- break;
1443
+ }
1444
+ if (theirs) {
1445
+ const theirsHasMarkers = theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization");
1446
+ const accBaseHasMarkers = accumulatorEntry != null && (accumulatorEntry.content.includes("<<<<<<< Generated") || accumulatorEntry.content.includes(">>>>>>> Your customization"));
1447
+ if (theirsHasMarkers && !accBaseHasMarkers && !(base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization")))) {
1448
+ return {
1449
+ file: resolvedPath,
1450
+ status: "skipped",
1451
+ reason: "stale-conflict-markers"
1452
+ };
1453
+ }
1454
+ }
1455
+ let effective_theirs = theirs;
1456
+ let baseMismatchSkipped = false;
1457
+ if (theirs != null && base != null && !useAccumulatorAsMergeBase) {
1458
+ if (accumulatorEntry && accumulatorEntry.baseGeneration === patch.base_generation) {
1459
+ try {
1460
+ const preMerged = threeWayMerge(base, accumulatorEntry.content, theirs);
1461
+ if (!preMerged.hasConflicts) {
1462
+ effective_theirs = preMerged.content;
1463
+ } else {
1464
+ effective_theirs = theirs;
1465
+ }
1466
+ } catch {
1467
+ effective_theirs = theirs;
1468
+ }
1469
+ } else if (accumulatorEntry) {
1470
+ baseMismatchSkipped = true;
1471
+ }
1472
+ }
1473
+ if (base == null && ours == null && effective_theirs != null) {
1474
+ this.fileTheirsAccumulator.set(resolvedPath, {
1475
+ content: effective_theirs,
1476
+ baseGeneration: patch.base_generation
1477
+ });
1478
+ const outDir2 = (0, import_node_path2.dirname)(oursPath);
1479
+ await (0, import_promises.mkdir)(outDir2, { recursive: true });
1480
+ await (0, import_promises.writeFile)(oursPath, effective_theirs);
1481
+ return { file: resolvedPath, status: "merged", reason: "new-file" };
1482
+ }
1483
+ if (base == null && ours != null && effective_theirs != null && !useAccumulatorAsMergeBase) {
1484
+ const merged2 = threeWayMerge("", ours, effective_theirs);
1485
+ const outDir2 = (0, import_node_path2.dirname)(oursPath);
1486
+ await (0, import_promises.mkdir)(outDir2, { recursive: true });
1487
+ await (0, import_promises.writeFile)(oursPath, merged2.content);
1488
+ if (merged2.hasConflicts) {
1489
+ return {
1490
+ file: resolvedPath,
1491
+ status: "conflict",
1492
+ conflicts: merged2.conflicts,
1493
+ conflictReason: "new-file-both",
1494
+ conflictMetadata: metadata
1495
+ };
1443
1496
  }
1444
- j++;
1497
+ this.fileTheirsAccumulator.set(resolvedPath, {
1498
+ content: merged2.content,
1499
+ baseGeneration: patch.base_generation
1500
+ });
1501
+ return { file: resolvedPath, status: "merged" };
1445
1502
  }
1446
- if (!found) {
1447
- i++;
1448
- continue;
1503
+ if (effective_theirs == null) {
1504
+ return {
1505
+ file: resolvedPath,
1506
+ status: "skipped",
1507
+ reason: "missing-content"
1508
+ };
1449
1509
  }
1450
- }
1451
- i++;
1452
- }
1453
- return ranges;
1454
- }
1455
- function stripConflictMarkers(content) {
1456
- const lines = content.split(/\r?\n/);
1457
- const ranges = findConflictRanges(lines);
1458
- if (ranges.length === 0) {
1459
- return content;
1460
- }
1461
- const result = [];
1462
- let rangeIdx = 0;
1463
- for (let i = 0; i < lines.length; i++) {
1464
- if (rangeIdx < ranges.length) {
1465
- const range = ranges[rangeIdx];
1466
- if (i === range.start) {
1467
- continue;
1510
+ if (base == null && !useAccumulatorAsMergeBase || ours == null) {
1511
+ return {
1512
+ file: resolvedPath,
1513
+ status: "skipped",
1514
+ reason: "missing-content"
1515
+ };
1468
1516
  }
1469
- if (i > range.start && i < range.separator) {
1470
- result.push(lines[i]);
1471
- continue;
1517
+ const mergeBase = useAccumulatorAsMergeBase && accumulatorEntry ? accumulatorEntry.content : base;
1518
+ if (mergeBase == null) {
1519
+ return {
1520
+ file: resolvedPath,
1521
+ status: "skipped",
1522
+ reason: "missing-content"
1523
+ };
1472
1524
  }
1473
- if (i === range.separator) {
1474
- continue;
1525
+ const merged = threeWayMerge(mergeBase, ours, effective_theirs);
1526
+ const outDir = (0, import_node_path2.dirname)(oursPath);
1527
+ await (0, import_promises.mkdir)(outDir, { recursive: true });
1528
+ await (0, import_promises.writeFile)(oursPath, merged.content);
1529
+ const populateAccumulator = effective_theirs != null && (!merged.hasConflicts || this.currentApplyMode === "resolve");
1530
+ if (populateAccumulator) {
1531
+ this.fileTheirsAccumulator.set(resolvedPath, {
1532
+ content: effective_theirs,
1533
+ baseGeneration: patch.base_generation
1534
+ });
1475
1535
  }
1476
- if (i > range.separator && i < range.end) {
1477
- continue;
1536
+ if (merged.hasConflicts) {
1537
+ return {
1538
+ file: resolvedPath,
1539
+ status: "conflict",
1540
+ conflicts: merged.conflicts,
1541
+ conflictReason: baseMismatchSkipped ? "base-generation-mismatch" : "same-line-edit",
1542
+ conflictMetadata: metadata
1543
+ };
1478
1544
  }
1479
- if (i === range.end) {
1480
- rangeIdx++;
1481
- continue;
1545
+ return {
1546
+ file: resolvedPath,
1547
+ status: "merged",
1548
+ ...merged.droppedContextLines && merged.droppedContextLines.length > 0 ? { droppedContextLines: merged.droppedContextLines } : {}
1549
+ };
1550
+ } catch (error) {
1551
+ return {
1552
+ file: filePath,
1553
+ status: "skipped",
1554
+ reason: `error: ${error instanceof Error ? error.message : String(error)}`
1555
+ };
1556
+ }
1557
+ }
1558
+ async isTreeReachable(treeHash) {
1559
+ let result = this.treeExistsCache.get(treeHash);
1560
+ if (result === void 0) {
1561
+ result = await this.git.treeExists(treeHash);
1562
+ this.treeExistsCache.set(treeHash, result);
1563
+ }
1564
+ return result;
1565
+ }
1566
+ isExcluded(patch) {
1567
+ const config = this.lockManager.getCustomizationsConfig();
1568
+ if (!config.exclude) return false;
1569
+ return patch.files.some((file) => config.exclude.some((pattern) => (0, import_minimatch.minimatch)(file, pattern)));
1570
+ }
1571
+ async resolveFilePath(filePath, baseTreeHash, currentTreeHash) {
1572
+ const config = this.lockManager.getCustomizationsConfig();
1573
+ if (config.moves) {
1574
+ for (const move of config.moves) {
1575
+ if ((0, import_minimatch.minimatch)(filePath, move.from) || filePath === move.from) {
1576
+ if (filePath === move.from) {
1577
+ return move.to;
1578
+ }
1579
+ const fromBase = move.from.replace(/\*\*.*$/, "");
1580
+ const toBase = move.to.replace(/\*\*.*$/, "");
1581
+ if (filePath.startsWith(fromBase)) {
1582
+ return toBase + filePath.slice(fromBase.length);
1583
+ }
1584
+ }
1482
1585
  }
1483
1586
  }
1484
- result.push(lines[i]);
1587
+ const cacheKey = `${baseTreeHash}:${currentTreeHash}`;
1588
+ let renames = this.renameCache.get(cacheKey);
1589
+ if (!renames) {
1590
+ renames = await this.git.detectRenames(baseTreeHash, currentTreeHash);
1591
+ this.renameCache.set(cacheKey, renames);
1592
+ }
1593
+ const gitRename = renames.find((r) => r.from === filePath);
1594
+ if (gitRename) {
1595
+ return gitRename.to;
1596
+ }
1597
+ return filePath;
1485
1598
  }
1486
- return result.join("\n");
1487
- }
1488
-
1489
- // src/ReplayApplicator.ts
1490
- var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
1491
- ".png",
1492
- ".jpg",
1493
- ".jpeg",
1494
- ".gif",
1495
- ".bmp",
1496
- ".ico",
1497
- ".webp",
1498
- ".svg",
1499
- ".pdf",
1500
- ".doc",
1501
- ".docx",
1502
- ".xls",
1503
- ".xlsx",
1504
- ".ppt",
1505
- ".pptx",
1506
- ".zip",
1507
- ".gz",
1508
- ".tar",
1509
- ".bz2",
1510
- ".7z",
1511
- ".rar",
1512
- ".jar",
1513
- ".war",
1514
- ".ear",
1515
- ".class",
1516
- ".exe",
1517
- ".dll",
1518
- ".so",
1519
- ".dylib",
1520
- ".o",
1521
- ".a",
1522
- ".woff",
1523
- ".woff2",
1524
- ".ttf",
1525
- ".eot",
1526
- ".otf",
1527
- ".mp3",
1528
- ".mp4",
1529
- ".avi",
1530
- ".mov",
1531
- ".wav",
1532
- ".flac",
1533
- ".sqlite",
1534
- ".db",
1535
- ".pyc",
1536
- ".pyo",
1537
- ".DS_Store"
1538
- ]);
1539
- var ReplayApplicator = class {
1540
- git;
1541
- lockManager;
1542
- outputDir;
1543
- renameCache = /* @__PURE__ */ new Map();
1544
- treeExistsCache = /* @__PURE__ */ new Map();
1545
- fileTheirsAccumulator = /* @__PURE__ */ new Map();
1546
- /**
1547
- * Apply mode for the current `applyPatches` invocation. Set at the start
1548
- * of `applyPatches`, read by `mergeFile` to decide:
1549
- * - whether to skip the intra-loop marker strip (kept in `applyPatches`)
1550
- * - whether to populate the accumulator after a conflicted merge
1551
- * (resolve mode populates so subsequent patches on the same file
1552
- * can use patch A's THEIRS as a structurally-correct merge base
1553
- * when their diff was authored against post-A structure)
1554
- * - whether to retry THEIRS reconstruction against the accumulator
1555
- * when the BASE-relative reconstruction produced markers from a
1556
- * cross-patch context mismatch
1557
- */
1558
- currentApplyMode = "replay";
1559
- constructor(git, lockManager, outputDir) {
1560
- this.git = git;
1561
- this.lockManager = lockManager;
1562
- this.outputDir = outputDir;
1599
+ async applyPatchToContent(base, patchContent, filePath, tempGit, tempDir, sourceFilePath) {
1600
+ if (!base) {
1601
+ return this.extractNewFileFromPatch(patchContent, filePath);
1602
+ }
1603
+ const fileDiff = this.extractFileDiff(patchContent, filePath);
1604
+ if (!fileDiff) return null;
1605
+ try {
1606
+ if (sourceFilePath) {
1607
+ const tempSourcePath = (0, import_node_path2.join)(tempDir, sourceFilePath);
1608
+ await (0, import_promises.mkdir)((0, import_node_path2.dirname)(tempSourcePath), { recursive: true });
1609
+ await (0, import_promises.writeFile)(tempSourcePath, base);
1610
+ await tempGit.exec(["add", sourceFilePath]);
1611
+ await tempGit.exec([
1612
+ "commit",
1613
+ "-m",
1614
+ `base for rename ${sourceFilePath} -> ${filePath}`,
1615
+ "--allow-empty"
1616
+ ]);
1617
+ await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
1618
+ const tempTargetPath = (0, import_node_path2.join)(tempDir, filePath);
1619
+ return await (0, import_promises.readFile)(tempTargetPath, "utf-8");
1620
+ }
1621
+ const tempFilePath = (0, import_node_path2.join)(tempDir, filePath);
1622
+ await (0, import_promises.mkdir)((0, import_node_path2.dirname)(tempFilePath), { recursive: true });
1623
+ await (0, import_promises.writeFile)(tempFilePath, base);
1624
+ await tempGit.exec(["add", filePath]);
1625
+ await tempGit.exec(["commit", "-m", `base for ${filePath}`, "--allow-empty"]);
1626
+ await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
1627
+ return await (0, import_promises.readFile)(tempFilePath, "utf-8");
1628
+ } catch {
1629
+ return null;
1630
+ }
1563
1631
  }
1564
1632
  /**
1565
- * Resolve the GenerationRecord for a patch's base_generation.
1566
- * Falls back to constructing an ad-hoc record from the commit's tree
1567
- * when base_generation isn't a tracked generation (commit-scan patches).
1633
+ * Detects whether a patch's additions are already present in `content`.
1634
+ * Uses `git apply --reverse --check` if the reverse patch applies cleanly,
1635
+ * the forward patch is effectively a no-op (its +lines are already there and
1636
+ * its -lines are already gone). Used to distinguish "patch already applied"
1637
+ * from "patch base mismatch" after a forward-apply fails.
1568
1638
  */
1569
- async resolveBaseGeneration(baseGeneration) {
1570
- const gen = this.lockManager.getGeneration(baseGeneration);
1571
- if (gen) return gen;
1639
+ async isPatchAlreadyApplied(patchContent, filePath, content, tempGit, tempDir) {
1640
+ const fileDiff = this.extractFileDiff(patchContent, filePath);
1641
+ if (!fileDiff) return false;
1572
1642
  try {
1573
- const treeHash = await this.git.getTreeHash(baseGeneration);
1574
- return {
1575
- commit_sha: baseGeneration,
1576
- tree_hash: treeHash,
1577
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1578
- cli_version: "unknown",
1579
- generator_versions: {}
1580
- };
1643
+ const tempFilePath = (0, import_node_path2.join)(tempDir, filePath);
1644
+ await (0, import_promises.mkdir)((0, import_node_path2.dirname)(tempFilePath), { recursive: true });
1645
+ await (0, import_promises.writeFile)(tempFilePath, content);
1646
+ await tempGit.exec(["add", filePath]);
1647
+ await tempGit.exec(["commit", "-m", `check already-applied ${filePath}`, "--allow-empty"]);
1648
+ await tempGit.execWithInput(["apply", "--reverse", "--check", "--allow-empty"], fileDiff);
1649
+ return true;
1581
1650
  } catch {
1582
- return void 0;
1651
+ return false;
1583
1652
  }
1584
1653
  }
1585
- /** Reset inter-patch accumulator for a new cycle. */
1586
- resetAccumulator() {
1587
- this.fileTheirsAccumulator.clear();
1654
+ extractFileDiff(patchContent, filePath) {
1655
+ const lines = patchContent.split("\n");
1656
+ const diffLines = [];
1657
+ let inTargetFile = false;
1658
+ for (const line of lines) {
1659
+ if (line.startsWith("diff --git")) {
1660
+ if (inTargetFile) {
1661
+ break;
1662
+ }
1663
+ if (isDiffLineForFile(line, filePath)) {
1664
+ inTargetFile = true;
1665
+ diffLines.push(line);
1666
+ }
1667
+ continue;
1668
+ }
1669
+ if (inTargetFile) {
1670
+ diffLines.push(line);
1671
+ }
1672
+ }
1673
+ return diffLines.length > 0 ? diffLines.join("\n") + "\n" : null;
1588
1674
  }
1589
- /**
1590
- * @internal Test-only.
1591
- * Returns a defensive copy of the inter-patch accumulator. Used by the
1592
- * adversarial test suite (FER-9791) to assert invariant I2: after every
1593
- * applied patch, the accumulator has an entry for each file the patch
1594
- * touched, and the entry's content matches on-disk.
1595
- */
1596
- getAccumulatorSnapshot() {
1597
- const snapshot = /* @__PURE__ */ new Map();
1598
- for (const [path, entry] of this.fileTheirsAccumulator) {
1599
- snapshot.set(path, { content: entry.content, baseGeneration: entry.baseGeneration });
1675
+ extractRenameSource(patchContent, targetFilePath) {
1676
+ const lines = patchContent.split("\n");
1677
+ let inTargetFile = false;
1678
+ for (const line of lines) {
1679
+ if (line.startsWith("diff --git")) {
1680
+ if (inTargetFile) break;
1681
+ inTargetFile = isDiffLineForFile(line, targetFilePath);
1682
+ continue;
1683
+ }
1684
+ if (!inTargetFile) continue;
1685
+ if (line.startsWith("@@")) break;
1686
+ if (line.startsWith("rename from ")) {
1687
+ return line.slice("rename from ".length);
1688
+ }
1600
1689
  }
1601
- return snapshot;
1690
+ return null;
1602
1691
  }
1603
- /**
1604
- * Apply all patches, returning results for each.
1605
- * Skips patches that match exclude patterns in replay.yml
1606
- *
1607
- * `applyMode` controls the post-conflict marker strategy:
1608
- * - `"replay"` (default): strip conflict markers from disk between
1609
- * iterations whenever a later patch touches the same file. Lets the
1610
- * follow-up patch's `git apply --3way` see clean OURS content,
1611
- * which is what the silent-loss fix (PR #73) relies on. The replay
1612
- * pipeline calls `revertConflictingFiles` after the loop to clean
1613
- * any markers that survive.
1614
- * - `"resolve"`: keep markers on disk. The resolve command needs the
1615
- * customer to see them. Slow-path 3-way merge naturally preserves
1616
- * A's markers and B's clean writes when their regions don't overlap;
1617
- * when they do overlap, the customer gets nested markers and
1618
- * resolves manually. Either way they aren't silently dropped.
1619
- */
1620
- async applyPatches(patches, opts) {
1621
- const applyMode = opts?.applyMode ?? "replay";
1622
- this.currentApplyMode = applyMode;
1623
- this.resetAccumulator();
1624
- const results = [];
1625
- for (let i = 0; i < patches.length; i++) {
1626
- const patch = patches[i];
1627
- if (this.isExcluded(patch)) {
1628
- results.push({
1629
- patch,
1630
- status: "skipped",
1631
- method: "git-am"
1632
- });
1692
+ extractNewFileFromPatch(patchContent, filePath) {
1693
+ const lines = patchContent.split("\n");
1694
+ const addedLines = [];
1695
+ let inTargetFile = false;
1696
+ let inHunk = false;
1697
+ let isNewFile = false;
1698
+ let noTrailingNewline = false;
1699
+ for (const line of lines) {
1700
+ if (line.startsWith("diff --git")) {
1701
+ if (inTargetFile) break;
1702
+ inTargetFile = isDiffLineForFile(line, filePath);
1703
+ inHunk = false;
1704
+ isNewFile = false;
1633
1705
  continue;
1634
1706
  }
1635
- const result = await this.applyPatchWithFallback(patch);
1636
- results.push(result);
1637
- if (applyMode !== "resolve" && result.status === "conflict" && result.fileResults) {
1638
- const laterFiles = /* @__PURE__ */ new Set();
1639
- for (let j = i + 1; j < patches.length; j++) {
1640
- for (const f of patches[j].files) {
1641
- laterFiles.add(f);
1642
- }
1643
- }
1644
- const resolvedToOriginal = /* @__PURE__ */ new Map();
1645
- if (result.resolvedFiles) {
1646
- for (const [orig, resolved] of Object.entries(result.resolvedFiles)) {
1647
- resolvedToOriginal.set(resolved, orig);
1648
- }
1649
- }
1650
- for (const fileResult of result.fileResults) {
1651
- if (fileResult.status !== "conflict") continue;
1652
- const originalPath = resolvedToOriginal.get(fileResult.file) ?? fileResult.file;
1653
- if (laterFiles.has(fileResult.file) || laterFiles.has(originalPath)) {
1654
- const filePath = (0, import_node_path2.join)(this.outputDir, fileResult.file);
1655
- try {
1656
- const content = await (0, import_promises.readFile)(filePath, "utf-8");
1657
- const stripped = stripConflictMarkers(content);
1658
- await (0, import_promises.writeFile)(filePath, stripped);
1659
- } catch {
1660
- }
1661
- }
1707
+ if (!inTargetFile) continue;
1708
+ if (!inHunk) {
1709
+ if (line === "--- /dev/null" || line.startsWith("new file mode")) {
1710
+ isNewFile = true;
1662
1711
  }
1663
1712
  }
1713
+ if (line.startsWith("@@")) {
1714
+ if (!isNewFile) return null;
1715
+ inHunk = true;
1716
+ continue;
1717
+ }
1718
+ if (!inHunk) continue;
1719
+ if (line === "\") {
1720
+ noTrailingNewline = true;
1721
+ continue;
1722
+ }
1723
+ if (line.startsWith("+") && !line.startsWith("+++")) {
1724
+ addedLines.push(line.slice(1));
1725
+ }
1664
1726
  }
1665
- return results;
1727
+ if (addedLines.length === 0) return null;
1728
+ return addedLines.join("\n") + (noTrailingNewline ? "" : "\n");
1666
1729
  }
1667
- /**
1668
- * Populate accumulator after git apply succeeds, AND collect per-file
1669
- * results including any "dropped context lines" — lines that were
1670
- * present in BASE and THEIRS (unchanged context) but absent from the
1671
- * final on-disk state because OURS deleted them. This is the fast-path
1672
- * counterpart to ThreeWayMerge.computeDroppedContextLines used by the
1673
- * 3-way slow path; both must surface the same warning.
1674
- */
1675
- async populateAccumulatorForPatch(patch, baseGen, currentTreeHash) {
1676
- const fileResults = [];
1677
- if (!baseGen) return fileResults;
1678
- const tempDir = await (0, import_promises.mkdtemp)((0, import_node_path2.join)((0, import_node_os.tmpdir)(), "replay-acc-"));
1679
- const { GitClient: GitClient2 } = await Promise.resolve().then(() => (init_GitClient(), GitClient_exports));
1680
- const tempGit = new GitClient2(tempDir);
1681
- await tempGit.exec(["init"]);
1682
- await tempGit.exec(["config", "user.email", "replay@fern.com"]);
1683
- await tempGit.exec(["config", "user.name", "Fern Replay"]);
1684
- try {
1685
- for (const filePath of patch.files) {
1686
- if (isBinaryFile(filePath)) continue;
1687
- const resolvedPath = await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
1688
- const base = await this.git.showFile(baseGen.tree_hash, filePath);
1689
- const theirs = await this.applyPatchToContent(base, patch.patch_content, filePath, tempGit, tempDir);
1690
- const finalOnDisk = await (0, import_promises.readFile)((0, import_node_path2.join)(this.outputDir, resolvedPath), "utf-8").catch(() => null);
1691
- const effectiveTheirs = theirs ?? finalOnDisk;
1692
- if (effectiveTheirs != null) {
1693
- this.fileTheirsAccumulator.set(resolvedPath, {
1694
- content: effectiveTheirs,
1695
- baseGeneration: patch.base_generation
1696
- });
1697
- }
1698
- if (base != null && effectiveTheirs != null && finalOnDisk != null) {
1699
- const dropped = computeDroppedContextLinesForFile(base, effectiveTheirs, finalOnDisk);
1700
- if (dropped.length > 0) {
1701
- fileResults.push({
1702
- file: resolvedPath,
1703
- status: "merged",
1704
- droppedContextLines: dropped
1705
- });
1706
- }
1707
- }
1730
+ };
1731
+ function isBinaryFile(filePath) {
1732
+ const ext = (0, import_node_path2.extname)(filePath).toLowerCase();
1733
+ return BINARY_EXTENSIONS.has(ext);
1734
+ }
1735
+ function computeDroppedContextLinesForFile(base, theirs, finalContent) {
1736
+ const baseLines = base.split("\n");
1737
+ const theirsLines = theirs.split("\n");
1738
+ const finalLines = finalContent.split("\n");
1739
+ const baseCounts = /* @__PURE__ */ new Map();
1740
+ const theirsCounts = /* @__PURE__ */ new Map();
1741
+ const finalCounts = /* @__PURE__ */ new Map();
1742
+ for (const l of baseLines) baseCounts.set(l, (baseCounts.get(l) ?? 0) + 1);
1743
+ for (const l of theirsLines) theirsCounts.set(l, (theirsCounts.get(l) ?? 0) + 1);
1744
+ for (const l of finalLines) finalCounts.set(l, (finalCounts.get(l) ?? 0) + 1);
1745
+ const dropped = [];
1746
+ const seen = /* @__PURE__ */ new Set();
1747
+ for (const line of baseLines) {
1748
+ if (seen.has(line)) continue;
1749
+ const inBase = baseCounts.get(line) ?? 0;
1750
+ const inTheirs = theirsCounts.get(line) ?? 0;
1751
+ const inFinal = finalCounts.get(line) ?? 0;
1752
+ if (inBase > 0 && inTheirs > 0 && inFinal < Math.min(inBase, inTheirs)) {
1753
+ dropped.push(line);
1754
+ seen.add(line);
1755
+ }
1756
+ }
1757
+ return dropped;
1758
+ }
1759
+ function isDiffLineForFile(diffLine, filePath) {
1760
+ const match = diffLine.match(/^diff --git a\/.+ b\/(.+)$/);
1761
+ return match !== null && match[1] === filePath;
1762
+ }
1763
+
1764
+ // src/ReplayDetector.ts
1765
+ var INFRASTRUCTURE_FILES = /* @__PURE__ */ new Set([".fernignore"]);
1766
+ function parsePatchFileHeaders(patchContent) {
1767
+ const results = [];
1768
+ let currentPath = null;
1769
+ let currentIsCreate = false;
1770
+ let currentIsDelete = false;
1771
+ const flush = () => {
1772
+ if (currentPath !== null) {
1773
+ results.push({ path: currentPath, isCreate: currentIsCreate, isDelete: currentIsDelete });
1774
+ }
1775
+ };
1776
+ for (const line of patchContent.split("\n")) {
1777
+ if (line.startsWith("diff --git ")) {
1778
+ flush();
1779
+ currentPath = null;
1780
+ currentIsCreate = false;
1781
+ currentIsDelete = false;
1782
+ const match = line.match(/^diff --git a\/(.+?) b\/(.+?)$/);
1783
+ if (match) {
1784
+ currentPath = match[2] ?? null;
1785
+ }
1786
+ } else if (currentPath !== null) {
1787
+ if (line.startsWith("new file mode ")) {
1788
+ currentIsCreate = true;
1789
+ } else if (line.startsWith("deleted file mode ")) {
1790
+ currentIsDelete = true;
1708
1791
  }
1709
- } finally {
1710
- await (0, import_promises.rm)(tempDir, { recursive: true }).catch(() => {
1711
- });
1712
1792
  }
1713
- return fileResults;
1714
1793
  }
1715
- async applyPatchWithFallback(patch) {
1716
- const baseGen = await this.resolveBaseGeneration(patch.base_generation);
1794
+ flush();
1795
+ return results;
1796
+ }
1797
+ var ReplayDetector = class {
1798
+ git;
1799
+ lockManager;
1800
+ sdkOutputDir;
1801
+ warnings = [];
1802
+ constructor(git, lockManager, sdkOutputDir) {
1803
+ this.git = git;
1804
+ this.lockManager = lockManager;
1805
+ this.sdkOutputDir = sdkOutputDir;
1806
+ }
1807
+ async detectNewPatches() {
1717
1808
  const lock = this.lockManager.read();
1718
- const currentGen = lock.generations.find((g) => g.commit_sha === lock.current_generation);
1719
- const currentTreeHash = currentGen?.tree_hash ?? baseGen?.tree_hash ?? "";
1720
- const needsAccumulation = await Promise.all(
1721
- patch.files.map(async (f) => {
1722
- if (!baseGen) return false;
1723
- const resolved = await this.resolveFilePath(f, baseGen.tree_hash, currentTreeHash);
1724
- return this.fileTheirsAccumulator.has(resolved);
1725
- })
1726
- ).then((results) => results.some(Boolean));
1727
- const resolvedFiles = {};
1728
- for (const filePath of patch.files) {
1729
- const resolvedPath = baseGen ? await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash) : filePath;
1730
- if (resolvedPath !== filePath) {
1731
- resolvedFiles[filePath] = resolvedPath;
1732
- }
1809
+ const lastGen = this.getLastGeneration(lock);
1810
+ if (!lastGen) {
1811
+ return { patches: [], revertedPatchIds: [] };
1733
1812
  }
1734
- const patchIsRenameAware = /^rename from /m.test(patch.patch_content);
1735
- const hasGeneratorRename = Object.keys(resolvedFiles).length > 0 && !patchIsRenameAware;
1736
- if (!needsAccumulation && !hasGeneratorRename) {
1737
- const snapshots = /* @__PURE__ */ new Map();
1738
- for (const filePath of patch.files) {
1739
- const fullPath = (0, import_node_path2.join)(this.outputDir, filePath);
1740
- snapshots.set(filePath, await (0, import_promises.readFile)(fullPath, "utf-8").catch(() => null));
1741
- }
1742
- try {
1743
- await this.git.execWithInput(["apply", "--3way"], patch.patch_content);
1744
- const fastPathFileResults = await this.populateAccumulatorForPatch(patch, baseGen, currentTreeHash);
1745
- return {
1746
- patch,
1747
- status: "applied",
1748
- method: "git-am",
1749
- ...fastPathFileResults.length > 0 && { fileResults: fastPathFileResults },
1750
- ...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
1751
- };
1752
- } catch {
1753
- for (const [filePath, content] of snapshots) {
1754
- const fullPath = (0, import_node_path2.join)(this.outputDir, filePath);
1755
- if (content != null) {
1756
- await (0, import_promises.writeFile)(fullPath, content);
1757
- } else {
1758
- await (0, import_promises.unlink)(fullPath).catch(() => {
1759
- });
1760
- }
1761
- }
1762
- }
1813
+ const exists = await this.git.commitExists(lastGen.commit_sha);
1814
+ if (!exists) {
1815
+ this.warnings.push(
1816
+ `Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to alternate detection.`
1817
+ );
1818
+ return this.detectPatchesViaTreeDiff(
1819
+ lastGen,
1820
+ /* commitKnownMissing */
1821
+ true
1822
+ );
1763
1823
  }
1764
- return this.applyWithThreeWayMerge(patch);
1824
+ const isAncestor = await this.git.isAncestor(lastGen.commit_sha, "HEAD");
1825
+ if (!isAncestor) {
1826
+ return this.detectPatchesViaMergeBase(lastGen, lock);
1827
+ }
1828
+ return this.detectPatchesInRange(lastGen.commit_sha, lastGen, lock);
1765
1829
  }
1766
- async applyWithThreeWayMerge(patch) {
1767
- const fileResults = [];
1768
- const resolvedFiles = {};
1769
- const tempDir = await (0, import_promises.mkdtemp)((0, import_node_path2.join)((0, import_node_os.tmpdir)(), "replay-"));
1770
- const { GitClient: GitClient2 } = await Promise.resolve().then(() => (init_GitClient(), GitClient_exports));
1771
- const tempGit = new GitClient2(tempDir);
1772
- await tempGit.exec(["init"]);
1773
- await tempGit.exec(["config", "user.email", "replay@fern.com"]);
1774
- await tempGit.exec(["config", "user.name", "Fern Replay"]);
1830
+ /**
1831
+ * FER-10201 Non-linear-history detection via `merge-base(prevGen, HEAD)`.
1832
+ *
1833
+ * After a squash-merge of a Fern-bot PR, `lastGen.commit_sha` (the previous
1834
+ * `[fern-generated]`) is orphaned but still in git's object database. The
1835
+ * merge-base is the commit on main from which the bot's branch was created —
1836
+ * a reachable ancestor of HEAD. Walking that range and classifying each
1837
+ * commit via `isGenerationCommit` mirrors the linear path and eliminates
1838
+ * the bug class where pipeline-driven changes (autoversion, replay,
1839
+ * generation) get bundled as customer customizations.
1840
+ *
1841
+ * Falls through to the legacy composite path when no common ancestor exists
1842
+ * (truly disjoint history — orphan branches with no shared root).
1843
+ */
1844
+ async detectPatchesViaMergeBase(lastGen, lock) {
1845
+ let mergeBase = "";
1775
1846
  try {
1776
- for (const filePath of patch.files) {
1777
- if (isBinaryFile(filePath)) {
1778
- fileResults.push({
1779
- file: filePath,
1780
- status: "skipped",
1781
- reason: "binary-file"
1782
- });
1783
- continue;
1784
- }
1785
- const result = await this.mergeFile(patch, filePath, tempGit, tempDir);
1786
- if (result.file !== filePath) {
1787
- resolvedFiles[filePath] = result.file;
1788
- }
1789
- fileResults.push(result);
1790
- }
1791
- } finally {
1792
- await (0, import_promises.rm)(tempDir, { recursive: true }).catch(() => {
1793
- });
1847
+ mergeBase = (await this.git.exec(["merge-base", lastGen.commit_sha, "HEAD"])).trim();
1848
+ } catch {
1794
1849
  }
1795
- const conflictFiles = fileResults.filter((r) => r.status === "conflict");
1796
- const hasConflicts = conflictFiles.length > 0;
1797
- const conflictReason = hasConflicts ? conflictFiles.some((f) => f.conflictReason === "base-generation-mismatch") ? "base-generation-mismatch" : conflictFiles[0]?.conflictReason : void 0;
1798
- return {
1799
- patch,
1800
- status: hasConflicts ? "conflict" : "applied",
1801
- method: "3way-merge",
1802
- fileResults,
1803
- conflictReason,
1804
- ...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
1805
- };
1850
+ if (!mergeBase) {
1851
+ this.warnings.push(
1852
+ `No common ancestor between previous generation ${lastGen.commit_sha.slice(0, 7)} and HEAD. Falling back to composite tree-diff detection.`
1853
+ );
1854
+ return this.detectPatchesViaTreeDiff(
1855
+ lastGen,
1856
+ /* commitKnownMissing */
1857
+ false
1858
+ );
1859
+ }
1860
+ return this.detectPatchesInRange(mergeBase, lastGen, lock);
1806
1861
  }
1807
- async mergeFile(patch, filePath, tempGit, tempDir) {
1808
- try {
1809
- const baseGen = await this.resolveBaseGeneration(patch.base_generation);
1810
- if (!baseGen) {
1811
- return { file: filePath, status: "skipped", reason: "base-generation-not-found" };
1812
- }
1813
- const lock = this.lockManager.read();
1814
- const currentGen = lock.generations.find((g) => g.commit_sha === lock.current_generation);
1815
- const currentTreeHash = currentGen?.tree_hash ?? baseGen.tree_hash;
1816
- const resolvedPath = await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
1817
- const metadata = {
1818
- patchId: patch.id,
1819
- patchMessage: patch.original_message,
1820
- baseGeneration: patch.base_generation,
1821
- currentGeneration: lock.current_generation
1822
- };
1823
- let base = await this.git.showFile(baseGen.tree_hash, filePath);
1824
- let renameSourcePath;
1825
- if (!base) {
1826
- const renameSource = this.extractRenameSource(patch.patch_content, filePath);
1827
- if (renameSource) {
1828
- base = await this.git.showFile(baseGen.tree_hash, renameSource);
1829
- renameSourcePath = renameSource;
1830
- }
1831
- }
1832
- const oursPath = (0, import_node_path2.join)(this.outputDir, resolvedPath);
1833
- const ours = await (0, import_promises.readFile)(oursPath, "utf-8").catch(() => null);
1834
- let ghostReconstructed = false;
1835
- let theirs = null;
1836
- if (!base && ours && !renameSourcePath) {
1837
- const treeReachable = await this.isTreeReachable(baseGen.tree_hash);
1838
- if (!treeReachable) {
1839
- const fileDiff = this.extractFileDiff(patch.patch_content, filePath);
1840
- if (fileDiff) {
1841
- const { reconstructFromGhostPatch: reconstructFromGhostPatch2 } = await Promise.resolve().then(() => (init_HybridReconstruction(), HybridReconstruction_exports));
1842
- const result = reconstructFromGhostPatch2(fileDiff, ours);
1843
- if (result) {
1844
- base = result.base;
1845
- theirs = result.theirs;
1846
- ghostReconstructed = true;
1847
- }
1848
- }
1849
- }
1862
+ /**
1863
+ * FER-10201 — Walk `${rangeStart}..HEAD`, classify each commit, emit one
1864
+ * `StoredPatch` per customer commit. Body shared by the linear path
1865
+ * (rangeStart = lastGen.commit_sha) and the merge-base path.
1866
+ *
1867
+ * Patch `base_generation` is always `lastGen.commit_sha` — the
1868
+ * lockfile-tracked reference the applicator uses to fetch base file
1869
+ * content, regardless of where the walk actually started.
1870
+ */
1871
+ async detectPatchesInRange(rangeStart, lastGen, lock) {
1872
+ const log = await this.git.exec([
1873
+ "log",
1874
+ "--first-parent",
1875
+ "--format=%H%x00%an%x00%ae%x00%s",
1876
+ `${rangeStart}..HEAD`,
1877
+ "--",
1878
+ this.sdkOutputDir
1879
+ ]);
1880
+ if (!log.trim()) {
1881
+ return { patches: [], revertedPatchIds: [] };
1882
+ }
1883
+ const commits = this.parseGitLog(log);
1884
+ const newPatches = [];
1885
+ const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
1886
+ for (const commit of commits) {
1887
+ if (isGenerationCommit(commit)) {
1888
+ continue;
1850
1889
  }
1851
- if (!ghostReconstructed) {
1852
- theirs = await this.applyPatchToContent(
1853
- base,
1854
- patch.patch_content,
1855
- filePath,
1856
- tempGit,
1857
- tempDir,
1858
- renameSourcePath
1859
- );
1890
+ if (lock.patches.find((p) => p.original_commit === commit.sha)) {
1891
+ continue;
1860
1892
  }
1861
- const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
1862
- if (theirs) {
1863
- const theirsHasMarkers = theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization");
1864
- const baseHasMarkers = base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization"));
1865
- if (theirsHasMarkers && !baseHasMarkers) {
1866
- if (accumulatorEntry) {
1867
- theirs = null;
1868
- } else {
1869
- return {
1870
- file: resolvedPath,
1871
- status: "skipped",
1872
- reason: "stale-conflict-markers"
1873
- };
1874
- }
1893
+ const parents = await this.git.getCommitParents(commit.sha);
1894
+ if (parents.length > 1) {
1895
+ const compositePatch = await this.createMergeCompositePatch({
1896
+ mergeCommit: commit,
1897
+ firstParent: parents[0],
1898
+ lastGen,
1899
+ lock
1900
+ });
1901
+ if (compositePatch) {
1902
+ newPatches.push(compositePatch);
1875
1903
  }
1904
+ continue;
1876
1905
  }
1877
- let useAccumulatorAsMergeBase = false;
1878
- if (accumulatorEntry && (theirs == null || base == null)) {
1879
- theirs = await this.applyPatchToContent(
1880
- accumulatorEntry.content,
1881
- patch.patch_content,
1882
- filePath,
1883
- tempGit,
1884
- tempDir
1906
+ let patchContent;
1907
+ try {
1908
+ patchContent = await this.git.formatPatch(commit.sha);
1909
+ } catch {
1910
+ this.warnings.push(
1911
+ `Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
1885
1912
  );
1886
- if (theirs != null) {
1887
- useAccumulatorAsMergeBase = true;
1888
- } else if (await this.isPatchAlreadyApplied(
1889
- patch.patch_content,
1890
- filePath,
1891
- accumulatorEntry.content,
1892
- tempGit,
1893
- tempDir
1894
- )) {
1895
- theirs = accumulatorEntry.content;
1896
- useAccumulatorAsMergeBase = true;
1897
- }
1913
+ continue;
1898
1914
  }
1899
- if (theirs) {
1900
- const theirsHasMarkers = theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization");
1901
- const accBaseHasMarkers = accumulatorEntry != null && (accumulatorEntry.content.includes("<<<<<<< Generated") || accumulatorEntry.content.includes(">>>>>>> Your customization"));
1902
- if (theirsHasMarkers && !accBaseHasMarkers && !(base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization")))) {
1903
- return {
1904
- file: resolvedPath,
1905
- status: "skipped",
1906
- reason: "stale-conflict-markers"
1907
- };
1908
- }
1915
+ let contentHash = this.computeContentHash(patchContent);
1916
+ if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
1917
+ continue;
1909
1918
  }
1910
- let effective_theirs = theirs;
1911
- let baseMismatchSkipped = false;
1912
- if (theirs != null && base != null && !useAccumulatorAsMergeBase) {
1913
- if (accumulatorEntry && accumulatorEntry.baseGeneration === patch.base_generation) {
1914
- try {
1915
- const preMerged = threeWayMerge(base, accumulatorEntry.content, theirs);
1916
- if (!preMerged.hasConflicts) {
1917
- effective_theirs = preMerged.content;
1918
- } else {
1919
- effective_theirs = theirs;
1919
+ const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
1920
+ const allFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
1921
+ const sensitiveFiles = allFiles.filter((f) => isSensitiveFile(f));
1922
+ const files = allFiles.filter((f) => !isSensitiveFile(f));
1923
+ if (sensitiveFiles.length > 0) {
1924
+ this.warnings.push(
1925
+ `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
1926
+ );
1927
+ if (files.length > 0) {
1928
+ const parentSha = parents[0];
1929
+ if (parentSha) {
1930
+ try {
1931
+ patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
1932
+ } catch {
1933
+ continue;
1920
1934
  }
1921
- } catch {
1922
- effective_theirs = theirs;
1935
+ if (!patchContent.trim()) continue;
1936
+ contentHash = this.computeContentHash(patchContent);
1923
1937
  }
1924
- } else if (accumulatorEntry) {
1925
- baseMismatchSkipped = true;
1926
1938
  }
1927
1939
  }
1928
- if (base == null && ours == null && effective_theirs != null) {
1929
- this.fileTheirsAccumulator.set(resolvedPath, {
1930
- content: effective_theirs,
1931
- baseGeneration: patch.base_generation
1932
- });
1933
- const outDir2 = (0, import_node_path2.dirname)(oursPath);
1934
- await (0, import_promises.mkdir)(outDir2, { recursive: true });
1935
- await (0, import_promises.writeFile)(oursPath, effective_theirs);
1936
- return { file: resolvedPath, status: "merged", reason: "new-file" };
1940
+ if (files.length === 0) {
1941
+ continue;
1937
1942
  }
1938
- if (base == null && ours != null && effective_theirs != null && !useAccumulatorAsMergeBase) {
1939
- const merged2 = threeWayMerge("", ours, effective_theirs);
1940
- const outDir2 = (0, import_node_path2.dirname)(oursPath);
1941
- await (0, import_promises.mkdir)(outDir2, { recursive: true });
1942
- await (0, import_promises.writeFile)(oursPath, merged2.content);
1943
- if (merged2.hasConflicts) {
1944
- return {
1945
- file: resolvedPath,
1946
- status: "conflict",
1947
- conflicts: merged2.conflicts,
1948
- conflictReason: "new-file-both",
1949
- conflictMetadata: metadata
1950
- };
1951
- }
1952
- this.fileTheirsAccumulator.set(resolvedPath, {
1953
- content: merged2.content,
1954
- baseGeneration: patch.base_generation
1955
- });
1956
- return { file: resolvedPath, status: "merged" };
1943
+ const theirsSnapshot = {};
1944
+ for (const file of files) {
1945
+ if (isBinaryFile(file)) continue;
1946
+ const content = await this.git.showFile(commit.sha, file).catch(() => null);
1947
+ if (content != null) theirsSnapshot[file] = content;
1957
1948
  }
1958
- if (effective_theirs == null) {
1959
- return {
1960
- file: resolvedPath,
1961
- status: "skipped",
1962
- reason: "missing-content"
1963
- };
1949
+ newPatches.push({
1950
+ id: `patch-${commit.sha.slice(0, 8)}`,
1951
+ content_hash: contentHash,
1952
+ original_commit: commit.sha,
1953
+ original_message: commit.message,
1954
+ original_author: `${commit.authorName} <${commit.authorEmail}>`,
1955
+ base_generation: lastGen.commit_sha,
1956
+ files,
1957
+ patch_content: patchContent,
1958
+ ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
1959
+ ...this.isCreationOnlyPatch(patchContent) ? { user_owned: true } : {}
1960
+ });
1961
+ }
1962
+ const survivingPatches = await this.collapseNetZeroFiles(newPatches);
1963
+ survivingPatches.reverse();
1964
+ const revertedPatchIdSet = /* @__PURE__ */ new Set();
1965
+ const revertIndicesToRemove = /* @__PURE__ */ new Set();
1966
+ for (let i = 0; i < survivingPatches.length; i++) {
1967
+ const patch = survivingPatches[i];
1968
+ if (!isRevertCommit(patch.original_message)) continue;
1969
+ let body = "";
1970
+ try {
1971
+ body = await this.git.getCommitBody(patch.original_commit);
1972
+ } catch {
1964
1973
  }
1965
- if (base == null && !useAccumulatorAsMergeBase || ours == null) {
1966
- return {
1967
- file: resolvedPath,
1968
- status: "skipped",
1969
- reason: "missing-content"
1970
- };
1974
+ const revertedSha = parseRevertedSha(body);
1975
+ const revertedMessage = parseRevertedMessage(patch.original_message);
1976
+ let matchedExisting = false;
1977
+ if (revertedSha) {
1978
+ const existing = lock.patches.find((p) => p.original_commit === revertedSha);
1979
+ if (existing) {
1980
+ revertedPatchIdSet.add(existing.id);
1981
+ revertIndicesToRemove.add(i);
1982
+ matchedExisting = true;
1983
+ }
1971
1984
  }
1972
- const mergeBase = useAccumulatorAsMergeBase && accumulatorEntry ? accumulatorEntry.content : base;
1973
- if (mergeBase == null) {
1974
- return {
1975
- file: resolvedPath,
1976
- status: "skipped",
1977
- reason: "missing-content"
1978
- };
1985
+ if (!matchedExisting && revertedMessage) {
1986
+ const existing = lock.patches.find((p) => p.original_message === revertedMessage);
1987
+ if (existing) {
1988
+ revertedPatchIdSet.add(existing.id);
1989
+ revertIndicesToRemove.add(i);
1990
+ matchedExisting = true;
1991
+ }
1979
1992
  }
1980
- const merged = threeWayMerge(mergeBase, ours, effective_theirs);
1981
- const outDir = (0, import_node_path2.dirname)(oursPath);
1982
- await (0, import_promises.mkdir)(outDir, { recursive: true });
1983
- await (0, import_promises.writeFile)(oursPath, merged.content);
1984
- const populateAccumulator = effective_theirs != null && (!merged.hasConflicts || this.currentApplyMode === "resolve");
1985
- if (populateAccumulator) {
1986
- this.fileTheirsAccumulator.set(resolvedPath, {
1987
- content: effective_theirs,
1988
- baseGeneration: patch.base_generation
1989
- });
1993
+ if (matchedExisting) continue;
1994
+ let matchedNew = false;
1995
+ if (revertedSha) {
1996
+ const idx = survivingPatches.findIndex(
1997
+ (p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_commit === revertedSha
1998
+ );
1999
+ if (idx !== -1) {
2000
+ revertIndicesToRemove.add(i);
2001
+ revertIndicesToRemove.add(idx);
2002
+ matchedNew = true;
2003
+ }
1990
2004
  }
1991
- if (merged.hasConflicts) {
1992
- return {
1993
- file: resolvedPath,
1994
- status: "conflict",
1995
- conflicts: merged.conflicts,
1996
- conflictReason: baseMismatchSkipped ? "base-generation-mismatch" : "same-line-edit",
1997
- conflictMetadata: metadata
1998
- };
2005
+ if (!matchedNew && revertedMessage) {
2006
+ const idx = survivingPatches.findIndex(
2007
+ (p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_message === revertedMessage
2008
+ );
2009
+ if (idx !== -1) {
2010
+ revertIndicesToRemove.add(i);
2011
+ revertIndicesToRemove.add(idx);
2012
+ }
2013
+ }
2014
+ if (!matchedExisting && !matchedNew) {
2015
+ revertIndicesToRemove.add(i);
1999
2016
  }
2000
- return {
2001
- file: resolvedPath,
2002
- status: "merged",
2003
- ...merged.droppedContextLines && merged.droppedContextLines.length > 0 ? { droppedContextLines: merged.droppedContextLines } : {}
2004
- };
2005
- } catch (error) {
2006
- return {
2007
- file: filePath,
2008
- status: "skipped",
2009
- reason: `error: ${error instanceof Error ? error.message : String(error)}`
2010
- };
2011
- }
2012
- }
2013
- async isTreeReachable(treeHash) {
2014
- let result = this.treeExistsCache.get(treeHash);
2015
- if (result === void 0) {
2016
- result = await this.git.treeExists(treeHash);
2017
- this.treeExistsCache.set(treeHash, result);
2018
2017
  }
2019
- return result;
2018
+ const filteredPatches = survivingPatches.filter((_, i) => !revertIndicesToRemove.has(i));
2019
+ return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
2020
2020
  }
2021
- isExcluded(patch) {
2022
- const config = this.lockManager.getCustomizationsConfig();
2023
- if (!config.exclude) return false;
2024
- return patch.files.some((file) => config.exclude.some((pattern) => (0, import_minimatch.minimatch)(file, pattern)));
2021
+ /**
2022
+ * Compute content hash for deduplication.
2023
+ *
2024
+ * Produces a format-agnostic hash so both `git format-patch` output
2025
+ * (email-wrapped) and plain `git diff` output hash to the same value
2026
+ * when the underlying diff hunks are identical.
2027
+ *
2028
+ * Normalization:
2029
+ * 1. Strip email wrapper: everything before the first `diff --git` line
2030
+ * (From:, Subject:, Date:, diffstat, blank separators).
2031
+ * 2. Strip `index` lines (blob SHA pairs change across rebases).
2032
+ * 3. Strip trailing git version marker (`-- \n<version>`).
2033
+ *
2034
+ * This ensures content hashes match across the no-patches and normal-
2035
+ * regeneration flows, which store formatPatch and git-diff formats
2036
+ * respectively (FER-9850, D5-D9).
2037
+ */
2038
+ computeContentHash(patchContent) {
2039
+ const lines = patchContent.split("\n");
2040
+ const diffStart = lines.findIndex((l) => l.startsWith("diff --git "));
2041
+ const relevantLines = diffStart > 0 ? lines.slice(diffStart) : lines;
2042
+ const normalized = relevantLines.filter((line) => !line.startsWith("index ")).join("\n").replace(/\n-- \n[\d]+\.[\d]+[^\n]*\s*$/, "");
2043
+ return `sha256:${(0, import_node_crypto.createHash)("sha256").update(normalized).digest("hex")}`;
2025
2044
  }
2026
- async resolveFilePath(filePath, baseTreeHash, currentTreeHash) {
2027
- const config = this.lockManager.getCustomizationsConfig();
2028
- if (config.moves) {
2029
- for (const move of config.moves) {
2030
- if ((0, import_minimatch.minimatch)(filePath, move.from) || filePath === move.from) {
2031
- if (filePath === move.from) {
2032
- return move.to;
2033
- }
2034
- const fromBase = move.from.replace(/\*\*.*$/, "");
2035
- const toBase = move.to.replace(/\*\*.*$/, "");
2036
- if (filePath.startsWith(fromBase)) {
2037
- return toBase + filePath.slice(fromBase.length);
2038
- }
2039
- }
2045
+ /**
2046
+ * FER-9805 Drop patches whose files were transiently created and then
2047
+ * deleted within the current linear detection window, and are absent from
2048
+ * HEAD at the end of the window.
2049
+ *
2050
+ * Rationale: on a merge commit, createMergeCompositePatch already diffs
2051
+ * firstParent..mergeCommit so net-zero files never enter the composite. On
2052
+ * linear history each commit becomes its own patch, so a file that was
2053
+ * briefly added and later removed survives as a create+delete pair whose
2054
+ * cumulative diff is empty. We drop such patches before they reach the
2055
+ * lockfile.
2056
+ *
2057
+ * A file is "transient" when:
2058
+ * 1. some patch in the window sets `new file mode` for it (a creation), AND
2059
+ * 2. some patch in the window sets `deleted file mode` for it (a deletion), AND
2060
+ * 3. it is absent from HEAD's tree.
2061
+ *
2062
+ * The HEAD guard (#3) prevents false positives when a file is deleted
2063
+ * and then recreated with different content — the cumulative diff of
2064
+ * such a pair is non-empty and must be preserved.
2065
+ *
2066
+ * This only drops patches whose `files` are a subset of the transient
2067
+ * set. A partial-transient patch (one that touches a transient file
2068
+ * alongside a persistent one) is left unmodified; surgical stripping of
2069
+ * single files from a multi-file patch would require a follow-up that
2070
+ * regenerates the patch content via `git format-patch -- <files>`. No
2071
+ * current failing test exercises this, and leaving the patch intact
2072
+ * preserves today's behaviour. TODO(FER-9805): revisit if a future
2073
+ * adversarial test demands per-file stripping.
2074
+ */
2075
+ async collapseNetZeroFiles(patches) {
2076
+ if (patches.length === 0) return patches;
2077
+ const stateByFile = /* @__PURE__ */ new Map();
2078
+ for (const patch of patches) {
2079
+ for (const header of parsePatchFileHeaders(patch.patch_content)) {
2080
+ if (!header.isCreate && !header.isDelete) continue;
2081
+ const prior = stateByFile.get(header.path) ?? { created: false, deleted: false };
2082
+ if (header.isCreate) prior.created = true;
2083
+ if (header.isDelete) prior.deleted = true;
2084
+ stateByFile.set(header.path, prior);
2040
2085
  }
2041
2086
  }
2042
- const cacheKey = `${baseTreeHash}:${currentTreeHash}`;
2043
- let renames = this.renameCache.get(cacheKey);
2044
- if (!renames) {
2045
- renames = await this.git.detectRenames(baseTreeHash, currentTreeHash);
2046
- this.renameCache.set(cacheKey, renames);
2087
+ let anyCandidate = false;
2088
+ for (const state of stateByFile.values()) {
2089
+ if (state.created && state.deleted) {
2090
+ anyCandidate = true;
2091
+ break;
2092
+ }
2047
2093
  }
2048
- const gitRename = renames.find((r) => r.from === filePath);
2049
- if (gitRename) {
2050
- return gitRename.to;
2094
+ if (!anyCandidate) return patches;
2095
+ const headFiles = await this.listHeadFiles();
2096
+ const transient = /* @__PURE__ */ new Set();
2097
+ for (const [file, state] of stateByFile) {
2098
+ if (state.created && state.deleted && !headFiles.has(file)) {
2099
+ transient.add(file);
2100
+ }
2051
2101
  }
2052
- return filePath;
2102
+ if (transient.size === 0) return patches;
2103
+ return patches.filter((patch) => !patch.files.every((f) => transient.has(f)));
2053
2104
  }
2054
- async applyPatchToContent(base, patchContent, filePath, tempGit, tempDir, sourceFilePath) {
2055
- if (!base) {
2056
- return this.extractNewFileFromPatch(patchContent, filePath);
2057
- }
2058
- const fileDiff = this.extractFileDiff(patchContent, filePath);
2059
- if (!fileDiff) return null;
2105
+ /**
2106
+ * List every tracked path at HEAD (one `git ls-tree -r` invocation).
2107
+ * Returns an empty Set if HEAD is unborn or the invocation fails — the
2108
+ * caller then treats every candidate as "not in HEAD" and may collapse
2109
+ * more aggressively. On typical SDK repos this is a single sub-10ms call.
2110
+ */
2111
+ async listHeadFiles() {
2060
2112
  try {
2061
- if (sourceFilePath) {
2062
- const tempSourcePath = (0, import_node_path2.join)(tempDir, sourceFilePath);
2063
- await (0, import_promises.mkdir)((0, import_node_path2.dirname)(tempSourcePath), { recursive: true });
2064
- await (0, import_promises.writeFile)(tempSourcePath, base);
2065
- await tempGit.exec(["add", sourceFilePath]);
2066
- await tempGit.exec([
2067
- "commit",
2068
- "-m",
2069
- `base for rename ${sourceFilePath} -> ${filePath}`,
2070
- "--allow-empty"
2071
- ]);
2072
- await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
2073
- const tempTargetPath = (0, import_node_path2.join)(tempDir, filePath);
2074
- return await (0, import_promises.readFile)(tempTargetPath, "utf-8");
2075
- }
2076
- const tempFilePath = (0, import_node_path2.join)(tempDir, filePath);
2077
- await (0, import_promises.mkdir)((0, import_node_path2.dirname)(tempFilePath), { recursive: true });
2078
- await (0, import_promises.writeFile)(tempFilePath, base);
2079
- await tempGit.exec(["add", filePath]);
2080
- await tempGit.exec(["commit", "-m", `base for ${filePath}`, "--allow-empty"]);
2081
- await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
2082
- return await (0, import_promises.readFile)(tempFilePath, "utf-8");
2113
+ const out = await this.git.exec(["ls-tree", "-r", "--name-only", "HEAD"]);
2114
+ return new Set(out.split("\n").map((l) => l.trim()).filter(Boolean));
2083
2115
  } catch {
2116
+ return /* @__PURE__ */ new Set();
2117
+ }
2118
+ }
2119
+ /**
2120
+ * Create a single composite patch for a merge commit by diffing the merge
2121
+ * commit against its first parent. This captures the net change of the
2122
+ * entire merged branch as one patch, eliminating accumulator chaining and
2123
+ * zero-net-change ghost conflicts.
2124
+ */
2125
+ async createMergeCompositePatch(input) {
2126
+ const { mergeCommit, firstParent, lastGen, lock } = input;
2127
+ const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
2128
+ const filesOutput = await this.git.exec([
2129
+ "diff",
2130
+ "--name-only",
2131
+ firstParent,
2132
+ mergeCommit.sha
2133
+ ]);
2134
+ const allMergeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
2135
+ const sensitiveMergeFiles = allMergeFiles.filter((f) => isSensitiveFile(f));
2136
+ const files = allMergeFiles.filter((f) => !isSensitiveFile(f));
2137
+ if (sensitiveMergeFiles.length > 0) {
2138
+ this.warnings.push(
2139
+ `Sensitive file(s) excluded from merge patch for commit ${mergeCommit.sha.slice(0, 7)}: ${sensitiveMergeFiles.join(", ")}. These files will not be tracked by replay.`
2140
+ );
2141
+ }
2142
+ if (files.length === 0) return null;
2143
+ const diff = await this.git.exec([
2144
+ "diff",
2145
+ firstParent,
2146
+ mergeCommit.sha,
2147
+ "--",
2148
+ ...files
2149
+ ]);
2150
+ if (!diff.trim()) return null;
2151
+ const contentHash = this.computeContentHash(diff);
2152
+ if (lock.patches.some((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
2084
2153
  return null;
2085
2154
  }
2155
+ const theirsSnapshot = {};
2156
+ for (const file of files) {
2157
+ if (isBinaryFile(file)) continue;
2158
+ const content = await this.git.showFile(mergeCommit.sha, file).catch(() => null);
2159
+ if (content != null) theirsSnapshot[file] = content;
2160
+ }
2161
+ return {
2162
+ id: `patch-merge-${mergeCommit.sha.slice(0, 8)}`,
2163
+ content_hash: contentHash,
2164
+ original_commit: mergeCommit.sha,
2165
+ original_message: mergeCommit.message,
2166
+ original_author: `${mergeCommit.authorName} <${mergeCommit.authorEmail}>`,
2167
+ base_generation: lastGen.commit_sha,
2168
+ files,
2169
+ patch_content: diff,
2170
+ ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
2171
+ ...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
2172
+ };
2086
2173
  }
2087
2174
  /**
2088
- * Detects whether a patch's additions are already present in `content`.
2089
- * Uses `git apply --reverse --check` if the reverse patch applies cleanly,
2090
- * the forward patch is effectively a no-op (its +lines are already there and
2091
- * its -lines are already gone). Used to distinguish "patch already applied"
2092
- * from "patch base mismatch" after a forward-apply fails.
2175
+ * Detect patches via tree diff for non-linear history. Returns a composite patch.
2176
+ * Revert reconciliation is skipped here because tree-diff produces a single composite
2177
+ * patch from the aggregate diff individual revert commits are not distinguishable.
2093
2178
  */
2094
- async isPatchAlreadyApplied(patchContent, filePath, content, tempGit, tempDir) {
2095
- const fileDiff = this.extractFileDiff(patchContent, filePath);
2096
- if (!fileDiff) return false;
2097
- try {
2098
- const tempFilePath = (0, import_node_path2.join)(tempDir, filePath);
2099
- await (0, import_promises.mkdir)((0, import_node_path2.dirname)(tempFilePath), { recursive: true });
2100
- await (0, import_promises.writeFile)(tempFilePath, content);
2101
- await tempGit.exec(["add", filePath]);
2102
- await tempGit.exec(["commit", "-m", `check already-applied ${filePath}`, "--allow-empty"]);
2103
- await tempGit.execWithInput(["apply", "--reverse", "--check", "--allow-empty"], fileDiff);
2104
- return true;
2105
- } catch {
2106
- return false;
2179
+ async detectPatchesViaTreeDiff(lastGen, commitKnownMissing) {
2180
+ const diffBase = await this.resolveDiffBase(lastGen, commitKnownMissing);
2181
+ if (!diffBase) {
2182
+ return this.detectPatchesViaCommitScan();
2107
2183
  }
2184
+ const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
2185
+ const allTreeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
2186
+ const sensitiveTreeFiles = allTreeFiles.filter((f) => isSensitiveFile(f));
2187
+ const files = allTreeFiles.filter((f) => !isSensitiveFile(f));
2188
+ if (sensitiveTreeFiles.length > 0) {
2189
+ this.warnings.push(
2190
+ `Sensitive file(s) excluded from composite patch: ${sensitiveTreeFiles.join(", ")}. These files will not be tracked by replay.`
2191
+ );
2192
+ }
2193
+ if (files.length === 0) return { patches: [], revertedPatchIds: [] };
2194
+ const diff = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
2195
+ if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
2196
+ const contentHash = this.computeContentHash(diff);
2197
+ const lock = this.lockManager.read();
2198
+ if (lock.patches.some((p) => p.content_hash === contentHash) || (lock.forgotten_hashes ?? []).includes(contentHash)) {
2199
+ return { patches: [], revertedPatchIds: [] };
2200
+ }
2201
+ const headSha = (await this.git.exec(["rev-parse", "HEAD"])).trim();
2202
+ const theirsSnapshot = {};
2203
+ for (const file of files) {
2204
+ if (isBinaryFile(file)) continue;
2205
+ const content = await this.git.showFile("HEAD", file).catch(() => null);
2206
+ if (content != null) theirsSnapshot[file] = content;
2207
+ }
2208
+ const compositePatch = {
2209
+ id: `patch-composite-${headSha.slice(0, 8)}`,
2210
+ content_hash: contentHash,
2211
+ original_commit: headSha,
2212
+ original_message: "Customer customizations (composite)",
2213
+ original_author: "composite",
2214
+ // Use diffBase when commit is unreachable — the applicator needs a reachable
2215
+ // reference to find base file content. diffBase may be the tree_hash.
2216
+ base_generation: commitKnownMissing ? diffBase : lastGen.commit_sha,
2217
+ files,
2218
+ patch_content: diff,
2219
+ ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
2220
+ ...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
2221
+ };
2222
+ return { patches: [compositePatch], revertedPatchIds: [] };
2108
2223
  }
2109
- extractFileDiff(patchContent, filePath) {
2110
- const lines = patchContent.split("\n");
2111
- const diffLines = [];
2112
- let inTargetFile = false;
2113
- for (const line of lines) {
2114
- if (line.startsWith("diff --git")) {
2115
- if (inTargetFile) {
2116
- break;
2117
- }
2118
- if (isDiffLineForFile(line, filePath)) {
2119
- inTargetFile = true;
2120
- diffLines.push(line);
2121
- }
2224
+ /**
2225
+ * Last-resort detection when both generation commit and tree are unreachable.
2226
+ * Scans all commits from HEAD, filters against known lockfile patches, and
2227
+ * skips creation-only commits (squashed history after force push).
2228
+ *
2229
+ * Detected patches use the commit's parent as base_generation so the applicator
2230
+ * can find base file content from the parent's tree (which IS reachable).
2231
+ */
2232
+ async detectPatchesViaCommitScan() {
2233
+ const lock = this.lockManager.read();
2234
+ const log = await this.git.exec([
2235
+ "log",
2236
+ "--max-count=200",
2237
+ "--format=%H%x00%an%x00%ae%x00%s",
2238
+ "HEAD",
2239
+ "--",
2240
+ this.sdkOutputDir
2241
+ ]);
2242
+ if (!log.trim()) {
2243
+ return { patches: [], revertedPatchIds: [] };
2244
+ }
2245
+ const commits = this.parseGitLog(log);
2246
+ const newPatches = [];
2247
+ const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
2248
+ const existingHashes = new Set(lock.patches.map((p) => p.content_hash));
2249
+ const existingCommits = new Set(lock.patches.map((p) => p.original_commit));
2250
+ for (const commit of commits) {
2251
+ if (isGenerationCommit(commit)) {
2122
2252
  continue;
2123
2253
  }
2124
- if (inTargetFile) {
2125
- diffLines.push(line);
2254
+ const parents = await this.git.getCommitParents(commit.sha);
2255
+ if (parents.length > 1) {
2256
+ continue;
2126
2257
  }
2127
- }
2128
- return diffLines.length > 0 ? diffLines.join("\n") + "\n" : null;
2129
- }
2130
- extractRenameSource(patchContent, targetFilePath) {
2131
- const lines = patchContent.split("\n");
2132
- let inTargetFile = false;
2133
- for (const line of lines) {
2134
- if (line.startsWith("diff --git")) {
2135
- if (inTargetFile) break;
2136
- inTargetFile = isDiffLineForFile(line, targetFilePath);
2258
+ if (existingCommits.has(commit.sha)) {
2137
2259
  continue;
2138
2260
  }
2139
- if (!inTargetFile) continue;
2140
- if (line.startsWith("@@")) break;
2141
- if (line.startsWith("rename from ")) {
2142
- return line.slice("rename from ".length);
2261
+ let patchContent;
2262
+ try {
2263
+ patchContent = await this.git.formatPatch(commit.sha);
2264
+ } catch {
2265
+ continue;
2143
2266
  }
2144
- }
2145
- return null;
2146
- }
2147
- extractNewFileFromPatch(patchContent, filePath) {
2148
- const lines = patchContent.split("\n");
2149
- const addedLines = [];
2150
- let inTargetFile = false;
2151
- let inHunk = false;
2152
- let isNewFile = false;
2153
- let noTrailingNewline = false;
2154
- for (const line of lines) {
2155
- if (line.startsWith("diff --git")) {
2156
- if (inTargetFile) break;
2157
- inTargetFile = isDiffLineForFile(line, filePath);
2158
- inHunk = false;
2159
- isNewFile = false;
2267
+ let contentHash = this.computeContentHash(patchContent);
2268
+ if (existingHashes.has(contentHash) || forgottenHashes.has(contentHash)) {
2160
2269
  continue;
2161
2270
  }
2162
- if (!inTargetFile) continue;
2163
- if (!inHunk) {
2164
- if (line === "--- /dev/null" || line.startsWith("new file mode")) {
2165
- isNewFile = true;
2271
+ if (this.isCreationOnlyPatch(patchContent)) {
2272
+ continue;
2273
+ }
2274
+ const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
2275
+ const allScanFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f));
2276
+ const sensitiveScanFiles = allScanFiles.filter((f) => isSensitiveFile(f));
2277
+ const files = allScanFiles.filter((f) => !isSensitiveFile(f));
2278
+ if (sensitiveScanFiles.length > 0) {
2279
+ this.warnings.push(
2280
+ `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveScanFiles.join(", ")}. These files will not be tracked by replay.`
2281
+ );
2282
+ if (files.length > 0) {
2283
+ if (parents.length === 0) {
2284
+ continue;
2285
+ }
2286
+ try {
2287
+ patchContent = await this.git.exec(["diff", parents[0], commit.sha, "--", ...files]);
2288
+ } catch {
2289
+ continue;
2290
+ }
2291
+ if (!patchContent.trim()) continue;
2292
+ contentHash = this.computeContentHash(patchContent);
2166
2293
  }
2167
2294
  }
2168
- if (line.startsWith("@@")) {
2169
- if (!isNewFile) return null;
2170
- inHunk = true;
2295
+ if (files.length === 0) {
2171
2296
  continue;
2172
2297
  }
2173
- if (!inHunk) continue;
2174
- if (line === "\") {
2175
- noTrailingNewline = true;
2298
+ if (parents.length === 0) {
2176
2299
  continue;
2177
2300
  }
2178
- if (line.startsWith("+") && !line.startsWith("+++")) {
2179
- addedLines.push(line.slice(1));
2301
+ const parentSha = parents[0];
2302
+ const theirsSnapshot = {};
2303
+ for (const file of files) {
2304
+ if (isBinaryFile(file)) continue;
2305
+ const content = await this.git.showFile(commit.sha, file).catch(() => null);
2306
+ if (content != null) theirsSnapshot[file] = content;
2180
2307
  }
2308
+ newPatches.push({
2309
+ id: `patch-${commit.sha.slice(0, 8)}`,
2310
+ content_hash: contentHash,
2311
+ original_commit: commit.sha,
2312
+ original_message: commit.message,
2313
+ original_author: `${commit.authorName} <${commit.authorEmail}>`,
2314
+ base_generation: parentSha,
2315
+ files,
2316
+ patch_content: patchContent,
2317
+ ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {}
2318
+ });
2181
2319
  }
2182
- if (addedLines.length === 0) return null;
2183
- return addedLines.join("\n") + (noTrailingNewline ? "" : "\n");
2320
+ newPatches.reverse();
2321
+ return { patches: newPatches, revertedPatchIds: [] };
2184
2322
  }
2185
- };
2186
- function isBinaryFile(filePath) {
2187
- const ext = (0, import_node_path2.extname)(filePath).toLowerCase();
2188
- return BINARY_EXTENSIONS.has(ext);
2189
- }
2190
- function computeDroppedContextLinesForFile(base, theirs, finalContent) {
2191
- const baseLines = base.split("\n");
2192
- const theirsLines = theirs.split("\n");
2193
- const finalLines = finalContent.split("\n");
2194
- const baseCounts = /* @__PURE__ */ new Map();
2195
- const theirsCounts = /* @__PURE__ */ new Map();
2196
- const finalCounts = /* @__PURE__ */ new Map();
2197
- for (const l of baseLines) baseCounts.set(l, (baseCounts.get(l) ?? 0) + 1);
2198
- for (const l of theirsLines) theirsCounts.set(l, (theirsCounts.get(l) ?? 0) + 1);
2199
- for (const l of finalLines) finalCounts.set(l, (finalCounts.get(l) ?? 0) + 1);
2200
- const dropped = [];
2201
- const seen = /* @__PURE__ */ new Set();
2202
- for (const line of baseLines) {
2203
- if (seen.has(line)) continue;
2204
- const inBase = baseCounts.get(line) ?? 0;
2205
- const inTheirs = theirsCounts.get(line) ?? 0;
2206
- const inFinal = finalCounts.get(line) ?? 0;
2207
- if (inBase > 0 && inTheirs > 0 && inFinal < Math.min(inBase, inTheirs)) {
2208
- dropped.push(line);
2209
- seen.add(line);
2323
+ /**
2324
+ * Check if a format-patch consists entirely of new-file creations.
2325
+ * Used to identify squashed commits after force push, which create all files
2326
+ * from scratch (--- /dev/null) rather than modifying existing files.
2327
+ */
2328
+ isCreationOnlyPatch(patchContent) {
2329
+ const diffOldHeaders = patchContent.split("\n").filter((l) => l.startsWith("--- "));
2330
+ if (diffOldHeaders.length === 0) {
2331
+ return false;
2210
2332
  }
2333
+ return diffOldHeaders.every((l) => l === "--- /dev/null");
2211
2334
  }
2212
- return dropped;
2213
- }
2214
- function isDiffLineForFile(diffLine, filePath) {
2215
- const match = diffLine.match(/^diff --git a\/.+ b\/(.+)$/);
2216
- return match !== null && match[1] === filePath;
2217
- }
2335
+ /**
2336
+ * Resolve the best available diff base for a generation record.
2337
+ * Prefers commit_sha, falls back to tree_hash for unreachable commits.
2338
+ * When commitKnownMissing is true, skips the redundant commitExists check.
2339
+ */
2340
+ async resolveDiffBase(gen, commitKnownMissing) {
2341
+ if (!commitKnownMissing && await this.git.commitExists(gen.commit_sha)) {
2342
+ return gen.commit_sha;
2343
+ }
2344
+ if (await this.git.treeExists(gen.tree_hash)) {
2345
+ return gen.tree_hash;
2346
+ }
2347
+ return null;
2348
+ }
2349
+ parseGitLog(log) {
2350
+ return log.trim().split("\n").map((line) => {
2351
+ const [sha, authorName, authorEmail, message] = line.split("\0");
2352
+ return { sha, authorName, authorEmail, message };
2353
+ });
2354
+ }
2355
+ getLastGeneration(lock) {
2356
+ return lock.generations.find((g) => g.commit_sha === lock.current_generation);
2357
+ }
2358
+ };
2218
2359
 
2219
2360
  // src/ReplayCommitter.ts
2220
2361
  var ReplayCommitter = class {
@@ -2321,7 +2462,8 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
2321
2462
 
2322
2463
  // src/ReplayService.ts
2323
2464
  var import_node_fs2 = require("fs");
2324
- var import_node_path4 = require("path");
2465
+ var import_promises4 = require("fs/promises");
2466
+ var import_node_path5 = require("path");
2325
2467
  var import_minimatch2 = require("minimatch");
2326
2468
  init_GitClient();
2327
2469
 
@@ -2380,13 +2522,13 @@ async function computePerPatchDiff(patch, getCurrentGenContent, getWorkingTreeCo
2380
2522
  const hunksRaw = parseHunks(fileDiff).map(normalizeHunkBody);
2381
2523
  const hunks = hunksRaw.filter((h) => {
2382
2524
  if (h.lines.length === 0) return false;
2383
- let ctx = 0, rm3 = 0, add = 0;
2525
+ let ctx = 0, rm4 = 0, add = 0;
2384
2526
  for (const ln of h.lines) {
2385
2527
  if (ln.type === "context") ctx++;
2386
- else if (ln.type === "remove") rm3++;
2528
+ else if (ln.type === "remove") rm4++;
2387
2529
  else if (ln.type === "add") add++;
2388
2530
  }
2389
- return ctx + rm3 === h.oldCount && ctx + add === h.newCount;
2531
+ return ctx + rm4 === h.oldCount && ctx + add === h.newCount;
2390
2532
  });
2391
2533
  if (hunks.length === 0) {
2392
2534
  if (hunksRaw.length > 0) unlocatableFiles.push(file);
@@ -3096,19 +3238,14 @@ var ReplayService = class {
3096
3238
  existingPatches = this.lockManager.getPatches();
3097
3239
  const seenHashes = /* @__PURE__ */ new Map();
3098
3240
  for (const p of existingPatches) {
3099
- const priorOrigin = seenHashes.get(p.content_hash);
3100
- if (priorOrigin !== void 0) {
3101
- const provenanceMissing = !priorOrigin || !p.original_commit;
3102
- const sameOrigin = priorOrigin === p.original_commit;
3103
- if (sameOrigin && !provenanceMissing) {
3104
- this.lockManager.removePatch(p.id);
3105
- } else {
3106
- this.warnings.push(
3107
- `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).`
3108
- );
3109
- }
3241
+ const priorPatchId = seenHashes.get(p.content_hash);
3242
+ if (priorPatchId !== void 0) {
3243
+ this.lockManager.removePatch(p.id);
3244
+ this.warnings.push(
3245
+ `Consolidated patch ${p.id} (commit ${(p.original_commit || "<missing>").slice(0, 7)}) into prior patch ${priorPatchId}: byte-identical patch_content. Per-commit attribution preserved in git log; lockfile collapsed to avoid duplicate-apply corruption on next regen.`
3246
+ );
3110
3247
  } else {
3111
- seenHashes.set(p.content_hash, p.original_commit);
3248
+ seenHashes.set(p.content_hash, p.id);
3112
3249
  }
3113
3250
  }
3114
3251
  existingPatches = this.lockManager.getPatches();
@@ -3289,17 +3426,27 @@ var ReplayService = class {
3289
3426
  if (allUserOwned && patch.files.length > 0) {
3290
3427
  const baseChanged = patch.base_generation !== currentGenSha;
3291
3428
  const userDiff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
3429
+ let userDiffSnapshot = null;
3292
3430
  if (userDiff != null && userDiff.trim()) {
3293
3431
  const newHash = this.detector.computeContentHash(userDiff);
3294
3432
  patch.patch_content = userDiff;
3295
3433
  patch.content_hash = newHash;
3434
+ const snap = await this.readSnapshotFromDisk(patch.files);
3435
+ if (Object.keys(snap).length > 0) {
3436
+ userDiffSnapshot = snap;
3437
+ patch.theirs_snapshot = snap;
3438
+ }
3296
3439
  }
3297
3440
  patch.base_generation = currentGenSha;
3298
3441
  try {
3299
3442
  this.lockManager.updatePatch(patch.id, {
3300
3443
  base_generation: currentGenSha,
3301
3444
  ...!patch.user_owned ? { user_owned: true } : {},
3302
- ...userDiff != null && userDiff.trim() ? { patch_content: patch.patch_content, content_hash: patch.content_hash } : {}
3445
+ ...userDiff != null && userDiff.trim() ? {
3446
+ patch_content: patch.patch_content,
3447
+ content_hash: patch.content_hash,
3448
+ ...userDiffSnapshot != null && Object.keys(userDiffSnapshot).length > 0 ? { theirs_snapshot: userDiffSnapshot } : {}
3449
+ } : {}
3303
3450
  });
3304
3451
  } catch {
3305
3452
  }
@@ -3347,30 +3494,34 @@ var ReplayService = class {
3347
3494
  continue;
3348
3495
  }
3349
3496
  const newContentHash = this.detector.computeContentHash(diff);
3350
- const priorOrigin = seenContentHashes.get(newContentHash);
3351
- if (priorOrigin !== void 0) {
3352
- const provenanceMissing = !priorOrigin || !patch.original_commit;
3353
- const sameOrigin = priorOrigin === patch.original_commit;
3354
- if (sameOrigin && !provenanceMissing) {
3355
- this.lockManager.removePatch(patch.id);
3356
- absorbedPatchIds.add(patch.id);
3357
- absorbed++;
3358
- continue;
3497
+ const priorPatchId = seenContentHashes.get(newContentHash);
3498
+ if (priorPatchId !== void 0) {
3499
+ this.lockManager.removePatch(patch.id);
3500
+ absorbedPatchIds.add(patch.id);
3501
+ absorbed++;
3502
+ if (priorPatchId !== patch.id) {
3503
+ this.warnings.push(
3504
+ `Consolidated patch ${patch.id} (commit ${(patch.original_commit || "<missing>").slice(0, 7)}) into prior patch ${priorPatchId}: byte-identical patch_content after rebase. Per-commit attribution preserved in git log.`
3505
+ );
3359
3506
  }
3360
- this.warnings.push(
3361
- `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).`
3362
- );
3507
+ continue;
3363
3508
  } else {
3364
- seenContentHashes.set(newContentHash, patch.original_commit);
3509
+ seenContentHashes.set(newContentHash, patch.id);
3365
3510
  }
3366
3511
  patch.base_generation = currentGenSha;
3367
3512
  patch.patch_content = diff;
3368
3513
  patch.content_hash = newContentHash;
3514
+ const snapshot = await this.readSnapshotFromDisk(patch.files);
3515
+ const snapshotIsNonEmpty = Object.keys(snapshot).length > 0;
3516
+ if (snapshotIsNonEmpty) {
3517
+ patch.theirs_snapshot = snapshot;
3518
+ }
3369
3519
  try {
3370
3520
  this.lockManager.updatePatch(patch.id, {
3371
3521
  base_generation: currentGenSha,
3372
3522
  patch_content: diff,
3373
- content_hash: newContentHash
3523
+ content_hash: newContentHash,
3524
+ ...snapshotIsNonEmpty ? { theirs_snapshot: snapshot } : {}
3374
3525
  });
3375
3526
  } catch {
3376
3527
  }
@@ -3380,6 +3531,112 @@ var ReplayService = class {
3380
3531
  }
3381
3532
  return { absorbed, repointed, contentRebased, keptAsUserOwned, absorbedPatchIds };
3382
3533
  }
3534
+ /**
3535
+ * Read THEIRS snapshot (post-customer-edit content) for a list of files
3536
+ * from the working tree on disk. Used by `rebasePatches` after a clean
3537
+ * apply — disk = correctly-merged customer state at the new generation.
3538
+ * Skips binary files (matches `mergeFile`/`populateAccumulatorForPatch`
3539
+ * policy — binary content as `utf-8` is lossy and snapshots of it would
3540
+ * never be used during apply anyway).
3541
+ */
3542
+ async readSnapshotFromDisk(files) {
3543
+ const snapshot = {};
3544
+ for (const file of files) {
3545
+ if (isBinaryFile(file)) continue;
3546
+ try {
3547
+ const content = await (0, import_promises4.readFile)((0, import_node_path5.join)(this.outputDir, file), "utf-8");
3548
+ snapshot[file] = content;
3549
+ } catch {
3550
+ }
3551
+ }
3552
+ return snapshot;
3553
+ }
3554
+ /**
3555
+ * Read THEIRS snapshot from a git tree-ish (commit, branch, or tree).
3556
+ * Used when the diff that produced `patch_content` was relative to that
3557
+ * tree (e.g., `git diff currentGen HEAD --` → snapshot from HEAD).
3558
+ * Skips binary files.
3559
+ */
3560
+ async readSnapshotFromTree(treeRef, files) {
3561
+ const snapshot = {};
3562
+ for (const file of files) {
3563
+ if (isBinaryFile(file)) continue;
3564
+ const content = await this.git.showFile(treeRef, file).catch(() => null);
3565
+ if (content != null) snapshot[file] = content;
3566
+ }
3567
+ return snapshot;
3568
+ }
3569
+ /**
3570
+ * One-shot auto-migration: backfill `theirs_snapshot` on existing
3571
+ * patches that predate the snapshot encoding. Computes the snapshot by
3572
+ * applying the patch's `patch_content` to its `base_generation` tree —
3573
+ * yielding the patch's INDIVIDUAL contribution.
3574
+ *
3575
+ * **Skips patches sharing files with other patches.** HEAD's content
3576
+ * for a file shared by N patches is cumulative across all of them, so
3577
+ * a HEAD-fallback would falsely encode other patches' contributions
3578
+ * into one patch's snapshot — breaking FER-9525 cross-patch isolation
3579
+ * if the snapshot fallback later fires. Per-patch snapshots only
3580
+ * make sense when the patch owns its file. Multi-patch-same-file
3581
+ * legacy lockfiles keep using line-anchored reconstruction (the
3582
+ * existing path); they're no worse than pre-upgrade.
3583
+ *
3584
+ * **Does not fall back to HEAD when BASE-reconstruction fails.** Same
3585
+ * reason — HEAD content can be cumulative. Patches whose
3586
+ * reconstruction fails just don't get a snapshot; legacy paths
3587
+ * (PR #73's by-association gate, accumulator fallback) handle them.
3588
+ *
3589
+ * Skips patches that already have a snapshot.
3590
+ */
3591
+ async migratePatchesToSnapshot(patches) {
3592
+ const { applyPatchToContent: applyPatchToContent2 } = await Promise.resolve().then(() => (init_PatchApplyTheirs(), PatchApplyTheirs_exports));
3593
+ const fileFreq = /* @__PURE__ */ new Map();
3594
+ for (const p of patches) {
3595
+ for (const f of p.files) {
3596
+ fileFreq.set(f, (fileFreq.get(f) ?? 0) + 1);
3597
+ }
3598
+ }
3599
+ let migrated = 0;
3600
+ let skipped = 0;
3601
+ for (const patch of patches) {
3602
+ if (patch.theirs_snapshot != null) continue;
3603
+ const sharesAnyFile = patch.files.some((f) => (fileFreq.get(f) ?? 0) > 1);
3604
+ if (sharesAnyFile) {
3605
+ skipped++;
3606
+ continue;
3607
+ }
3608
+ const snapshot = {};
3609
+ for (const file of patch.files) {
3610
+ if (isBinaryFile(file)) continue;
3611
+ try {
3612
+ const base = await this.git.showFile(patch.base_generation, file).catch(() => null);
3613
+ if (base != null) {
3614
+ const content = await applyPatchToContent2(base, patch.patch_content, file);
3615
+ if (content != null) snapshot[file] = content;
3616
+ }
3617
+ } catch {
3618
+ }
3619
+ }
3620
+ if (Object.keys(snapshot).length > 0) {
3621
+ patch.theirs_snapshot = snapshot;
3622
+ try {
3623
+ this.lockManager.updatePatch(patch.id, { theirs_snapshot: snapshot });
3624
+ migrated++;
3625
+ } catch {
3626
+ }
3627
+ }
3628
+ }
3629
+ if (migrated > 0) {
3630
+ this.warnings.push(
3631
+ `Migrated ${migrated} patch(es) to snapshot encoding. Future regens use 3-way merge with stored THEIRS, robust to generator structural changes.`
3632
+ );
3633
+ }
3634
+ if (skipped > 0) {
3635
+ this.warnings.push(
3636
+ `Skipped snapshot migration for ${skipped} patch(es) sharing files with other patches. These continue to use line-anchored reconstruction (the existing path).`
3637
+ );
3638
+ }
3639
+ }
3383
3640
  /**
3384
3641
  * Determine whether `file` is user-owned (never produced by the generator).
3385
3642
  *
@@ -3500,6 +3757,20 @@ var ReplayService = class {
3500
3757
  this.lockManager.clearReplaySkippedAt();
3501
3758
  return { conflictResolved: 0, conflictAbsorbed: 0, contentRefreshed: 0 };
3502
3759
  }
3760
+ await this.migratePatchesToSnapshot(patches);
3761
+ const headMessage = await this.git.exec(["log", "-1", "--format=%s%n%aN%n%ae", "HEAD"]).catch(() => "");
3762
+ const [headMsgLine, headAuthorName, headAuthorEmail] = headMessage.split("\n");
3763
+ const headIsGeneration = headMsgLine ? isGenerationCommit({
3764
+ sha: "HEAD",
3765
+ authorName: headAuthorName ?? "",
3766
+ authorEmail: headAuthorEmail ?? "",
3767
+ message: headMsgLine
3768
+ }) : false;
3769
+ const headParentChangedFiles = async (patchFiles) => {
3770
+ if (patchFiles.length === 0) return /* @__PURE__ */ new Set();
3771
+ const diff = await this.git.exec(["diff", "--name-only", "HEAD~1", "HEAD", "--", ...patchFiles]).catch(() => "");
3772
+ return new Set(diff.trim().split("\n").filter(Boolean));
3773
+ };
3503
3774
  let conflictResolved = 0;
3504
3775
  let conflictAbsorbed = 0;
3505
3776
  let contentRefreshed = 0;
@@ -3536,6 +3807,13 @@ var ReplayService = class {
3536
3807
  delete patch.status;
3537
3808
  continue;
3538
3809
  }
3810
+ if (headIsGeneration) {
3811
+ const parentChangedFiles = await headParentChangedFiles(patch.files);
3812
+ const headTouchedSource = patch.files.some((f) => parentChangedFiles.has(f));
3813
+ if (headTouchedSource) {
3814
+ continue;
3815
+ }
3816
+ }
3539
3817
  if (patch.base_generation === currentGen) {
3540
3818
  try {
3541
3819
  const ppResult = await computePerPatchDiffWithFallback(
@@ -3572,10 +3850,12 @@ var ReplayService = class {
3572
3850
  if (newFiles.length === 0) {
3573
3851
  this.lockManager.removePatch(patch.id);
3574
3852
  } else {
3853
+ const snapshot = await this.readSnapshotFromTree("HEAD", newFiles);
3575
3854
  this.lockManager.updatePatch(patch.id, {
3576
3855
  patch_content: diff,
3577
3856
  content_hash: newContentHash,
3578
- files: newFiles
3857
+ files: newFiles,
3858
+ ...Object.keys(snapshot).length > 0 ? { theirs_snapshot: snapshot } : {}
3579
3859
  });
3580
3860
  }
3581
3861
  contentRefreshed++;
@@ -3608,10 +3888,12 @@ var ReplayService = class {
3608
3888
  continue;
3609
3889
  }
3610
3890
  const newContentHash = this.detector.computeContentHash(diff);
3891
+ const snapshot = await this.readSnapshotFromTree("HEAD", patch.files);
3611
3892
  this.lockManager.updatePatch(patch.id, {
3612
3893
  base_generation: currentGen,
3613
3894
  patch_content: diff,
3614
- content_hash: newContentHash
3895
+ content_hash: newContentHash,
3896
+ ...Object.keys(snapshot).length > 0 ? { theirs_snapshot: snapshot } : {}
3615
3897
  });
3616
3898
  conflictResolved++;
3617
3899
  } catch {
@@ -3654,7 +3936,7 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
3654
3936
  if (result.status !== "conflict" || !result.fileResults) continue;
3655
3937
  for (const fileResult of result.fileResults) {
3656
3938
  if (fileResult.status !== "conflict") continue;
3657
- const filePath = (0, import_node_path4.join)(this.outputDir, fileResult.file);
3939
+ const filePath = (0, import_node_path5.join)(this.outputDir, fileResult.file);
3658
3940
  try {
3659
3941
  const content = (0, import_node_fs2.readFileSync)(filePath, "utf-8");
3660
3942
  const stripped = stripConflictMarkers(content);
@@ -3684,7 +3966,7 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
3684
3966
  }
3685
3967
  }
3686
3968
  readFernignorePatterns() {
3687
- const fernignorePath = (0, import_node_path4.join)(this.outputDir, ".fernignore");
3969
+ const fernignorePath = (0, import_node_path5.join)(this.outputDir, ".fernignore");
3688
3970
  if (!(0, import_node_fs2.existsSync)(fernignorePath)) return [];
3689
3971
  return (0, import_node_fs2.readFileSync)(fernignorePath, "utf-8").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
3690
3972
  }
@@ -3763,7 +4045,7 @@ function computeCommitBuckets(results, absorbedPatchIds, patchesPassedToCommit)
3763
4045
  // src/FernignoreMigrator.ts
3764
4046
  var import_node_crypto2 = require("crypto");
3765
4047
  var import_node_fs3 = require("fs");
3766
- var import_node_path5 = require("path");
4048
+ var import_node_path6 = require("path");
3767
4049
  var import_minimatch3 = require("minimatch");
3768
4050
  var import_yaml2 = require("yaml");
3769
4051
  var FernignoreMigrator = class {
@@ -3776,10 +4058,10 @@ var FernignoreMigrator = class {
3776
4058
  this.outputDir = outputDir;
3777
4059
  }
3778
4060
  fernignoreExists() {
3779
- return (0, import_node_fs3.existsSync)((0, import_node_path5.join)(this.outputDir, ".fernignore"));
4061
+ return (0, import_node_fs3.existsSync)((0, import_node_path6.join)(this.outputDir, ".fernignore"));
3780
4062
  }
3781
4063
  readFernignorePatterns() {
3782
- const fernignorePath = (0, import_node_path5.join)(this.outputDir, ".fernignore");
4064
+ const fernignorePath = (0, import_node_path6.join)(this.outputDir, ".fernignore");
3783
4065
  if (!(0, import_node_fs3.existsSync)(fernignorePath)) {
3784
4066
  return [];
3785
4067
  }
@@ -3831,6 +4113,12 @@ var FernignoreMigrator = class {
3831
4113
  if (patchFiles.length > 0) {
3832
4114
  const patchContent = diffParts.join("\n");
3833
4115
  const contentHash = `sha256:${(0, import_node_crypto2.createHash)("sha256").update(patchContent).digest("hex")}`;
4116
+ const theirsSnapshot = {};
4117
+ for (const filePath of patchFiles) {
4118
+ if (isBinaryFile(filePath)) continue;
4119
+ const c = this.readFileContent(filePath);
4120
+ if (c != null) theirsSnapshot[filePath] = c;
4121
+ }
3834
4122
  syntheticPatches.push({
3835
4123
  id: `patch-fernignore-${(0, import_node_crypto2.createHash)("sha256").update(pattern).digest("hex").slice(0, 8)}`,
3836
4124
  content_hash: contentHash,
@@ -3840,6 +4128,7 @@ var FernignoreMigrator = class {
3840
4128
  base_generation: currentGen.commit_sha,
3841
4129
  files: patchFiles,
3842
4130
  patch_content: patchContent,
4131
+ ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
3843
4132
  // Synthetic patches captured from .fernignore-protected files
3844
4133
  // are user-owned by definition — the customer's content
3845
4134
  // diverges from the generator's pristine output, and the
@@ -3881,7 +4170,7 @@ var FernignoreMigrator = class {
3881
4170
  return results;
3882
4171
  }
3883
4172
  readFileContent(filePath) {
3884
- const fullPath = (0, import_node_path5.join)(this.outputDir, filePath);
4173
+ const fullPath = (0, import_node_path6.join)(this.outputDir, filePath);
3885
4174
  if (!(0, import_node_fs3.existsSync)(fullPath)) {
3886
4175
  return null;
3887
4176
  }
@@ -3946,7 +4235,7 @@ var FernignoreMigrator = class {
3946
4235
  };
3947
4236
  }
3948
4237
  movePatternsToReplayYml(patterns) {
3949
- const replayYmlPath = (0, import_node_path5.join)(this.outputDir, ".fern", "replay.yml");
4238
+ const replayYmlPath = (0, import_node_path6.join)(this.outputDir, ".fern", "replay.yml");
3950
4239
  let config = {};
3951
4240
  if ((0, import_node_fs3.existsSync)(replayYmlPath)) {
3952
4241
  const content = (0, import_node_fs3.readFileSync)(replayYmlPath, "utf-8");
@@ -3955,7 +4244,7 @@ var FernignoreMigrator = class {
3955
4244
  const existing = config.exclude ?? [];
3956
4245
  const merged = [.../* @__PURE__ */ new Set([...existing, ...patterns])];
3957
4246
  config.exclude = merged;
3958
- const dir = (0, import_node_path5.dirname)(replayYmlPath);
4247
+ const dir = (0, import_node_path6.dirname)(replayYmlPath);
3959
4248
  if (!(0, import_node_fs3.existsSync)(dir)) {
3960
4249
  (0, import_node_fs3.mkdirSync)(dir, { recursive: true });
3961
4250
  }
@@ -3966,7 +4255,7 @@ var FernignoreMigrator = class {
3966
4255
  // src/commands/bootstrap.ts
3967
4256
  var import_node_crypto3 = require("crypto");
3968
4257
  var import_node_fs4 = require("fs");
3969
- var import_node_path6 = require("path");
4258
+ var import_node_path7 = require("path");
3970
4259
  init_GitClient();
3971
4260
  async function bootstrap(outputDir, options) {
3972
4261
  const git = new GitClient(outputDir);
@@ -4133,7 +4422,7 @@ function parseGitLog(log) {
4133
4422
  }
4134
4423
  var REPLAY_FERNIGNORE_ENTRIES = [".fern/replay.lock", ".fern/replay.yml", ".gitattributes"];
4135
4424
  function ensureFernignoreEntries(outputDir) {
4136
- const fernignorePath = (0, import_node_path6.join)(outputDir, ".fernignore");
4425
+ const fernignorePath = (0, import_node_path7.join)(outputDir, ".fernignore");
4137
4426
  let content = "";
4138
4427
  if ((0, import_node_fs4.existsSync)(fernignorePath)) {
4139
4428
  content = (0, import_node_fs4.readFileSync)(fernignorePath, "utf-8");
@@ -4157,7 +4446,7 @@ function ensureFernignoreEntries(outputDir) {
4157
4446
  }
4158
4447
  var GITATTRIBUTES_ENTRIES = [".fern/replay.lock linguist-generated=true"];
4159
4448
  function ensureGitattributesEntries(outputDir) {
4160
- const gitattributesPath = (0, import_node_path6.join)(outputDir, ".gitattributes");
4449
+ const gitattributesPath = (0, import_node_path7.join)(outputDir, ".gitattributes");
4161
4450
  let content = "";
4162
4451
  if ((0, import_node_fs4.existsSync)(gitattributesPath)) {
4163
4452
  content = (0, import_node_fs4.readFileSync)(gitattributesPath, "utf-8");
@@ -4355,8 +4644,8 @@ function reset(outputDir, options) {
4355
4644
  }
4356
4645
 
4357
4646
  // src/commands/resolve.ts
4358
- var import_promises3 = require("fs/promises");
4359
- var import_node_path7 = require("path");
4647
+ var import_promises5 = require("fs/promises");
4648
+ var import_node_path8 = require("path");
4360
4649
  init_GitClient();
4361
4650
  async function resolve(outputDir, options) {
4362
4651
  const lockManager = new LockfileManager(outputDir);
@@ -4400,36 +4689,78 @@ async function resolve(outputDir, options) {
4400
4689
  const detector = new ReplayDetector(git, lockManager, outputDir);
4401
4690
  const warnings = [];
4402
4691
  let patchesResolved = 0;
4692
+ const pass1 = [];
4403
4693
  for (const patch of patchesToCommit) {
4404
4694
  const result = await computePerPatchDiffWithFallback(
4405
4695
  patch,
4406
4696
  (f) => git.showFile(currentGen, f),
4407
- (f) => (0, import_promises3.readFile)((0, import_node_path7.join)(outputDir, f), "utf-8").catch(() => null),
4697
+ (f) => (0, import_promises5.readFile)((0, import_node_path8.join)(outputDir, f), "utf-8").catch(() => null),
4408
4698
  (files) => git.exec(["diff", currentGen, "--", ...files]).catch(() => null)
4409
4699
  );
4410
4700
  if (result === null) {
4411
4701
  warnings.push(
4412
4702
  `Patch ${patch.id}: cumulative-diff fallback failed for unlocatable files; preserving patch unchanged`
4413
4703
  );
4704
+ pass1.push({ kind: "skip" });
4414
4705
  continue;
4415
4706
  }
4416
4707
  const diff = result.diff;
4417
4708
  if (!diff.trim()) {
4709
+ pass1.push({ kind: "revert" });
4710
+ continue;
4711
+ }
4712
+ const hash = detector.computeContentHash(diff);
4713
+ pass1.push({
4714
+ kind: "apply",
4715
+ diff,
4716
+ hash,
4717
+ hadFallback: result.hadFallback,
4718
+ fallbackFiles: result.fallbackFiles
4719
+ });
4720
+ }
4721
+ const lastIndexByHash = /* @__PURE__ */ new Map();
4722
+ for (let i = 0; i < pass1.length; i++) {
4723
+ const r = pass1[i];
4724
+ if (r.kind === "apply") lastIndexByHash.set(r.hash, i);
4725
+ }
4726
+ for (let i = 0; i < patchesToCommit.length; i++) {
4727
+ const patch = patchesToCommit[i];
4728
+ const r = pass1[i];
4729
+ if (r.kind === "skip") continue;
4730
+ if (r.kind === "revert") {
4731
+ lockManager.removePatch(patch.id);
4732
+ continue;
4733
+ }
4734
+ if (lastIndexByHash.get(r.hash) !== i) {
4735
+ const canonicalIdx = lastIndexByHash.get(r.hash);
4736
+ const canonical = patchesToCommit[canonicalIdx];
4418
4737
  lockManager.removePatch(patch.id);
4738
+ warnings.push(
4739
+ `Consolidated patch ${patch.id} (commit ${(patch.original_commit || "<missing>").slice(0, 7)}) into patch ${canonical.id} (commit ${(canonical.original_commit || "<missing>").slice(0, 7)}): byte-identical patch_content after resolution. Per-commit attribution preserved in git log; lockfile collapsed to avoid duplicate-apply corruption on next regen.`
4740
+ );
4419
4741
  continue;
4420
4742
  }
4421
- if (result.hadFallback) {
4743
+ if (r.hadFallback) {
4422
4744
  warnings.push(
4423
- `Patch ${patch.id}: ${result.fallbackFiles.join(", ")} could not be anchored; cumulative-diff fallback applied for those files`
4745
+ `Patch ${patch.id}: ${r.fallbackFiles.join(", ")} could not be anchored; cumulative-diff fallback applied for those files`
4424
4746
  );
4425
4747
  }
4426
- const newContentHash = detector.computeContentHash(diff);
4427
- const changedFiles = result.hadFallback ? await getChangedFiles(git, currentGen, patch.files) : extractFilesFromDiff(diff);
4748
+ const changedFiles = r.hadFallback ? await getChangedFiles(git, currentGen, patch.files) : extractFilesFromDiff(r.diff);
4749
+ const filesForSnapshot = changedFiles.length > 0 ? changedFiles : patch.files;
4750
+ const theirsSnapshot = {};
4751
+ for (const file of filesForSnapshot) {
4752
+ if (isBinaryFile(file)) continue;
4753
+ const content = await (0, import_promises5.readFile)((0, import_node_path8.join)(outputDir, file), "utf-8").catch(() => null);
4754
+ if (content != null) {
4755
+ theirsSnapshot[file] = content;
4756
+ }
4757
+ }
4428
4758
  lockManager.markPatchResolved(patch.id, {
4429
- patch_content: diff,
4430
- content_hash: newContentHash,
4759
+ patch_content: r.diff,
4760
+ content_hash: r.hash,
4431
4761
  base_generation: currentGen,
4432
- files: changedFiles.length > 0 ? changedFiles : patch.files
4762
+ files: filesForSnapshot,
4763
+ ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {}
4433
4764
  });
4434
4765
  patchesResolved++;
4435
4766
  }