@fern-api/replay 0.15.0 → 0.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -400,26 +400,26 @@ var PatchApplyTheirs_exports = {};
400
400
  __export(PatchApplyTheirs_exports, {
401
401
  applyPatchToContent: () => applyPatchToContent
402
402
  });
403
- import { mkdtemp as mkdtemp3, mkdir as mkdir2, writeFile as writeFile3, readFile as readFile2, rm as rm3 } from "fs/promises";
403
+ import { mkdtemp as mkdtemp3, mkdir as mkdir3, writeFile as writeFile4, readFile as readFile3, rm as rm3 } from "fs/promises";
404
404
  import { tmpdir as tmpdir3 } from "os";
405
- import { dirname as dirname3, join as join4 } from "path";
405
+ import { dirname as dirname4, join as join5 } from "path";
406
406
  async function applyPatchToContent(base, patchContent, filePath) {
407
407
  const fileDiff = extractFileDiff(patchContent, filePath);
408
408
  if (!fileDiff) return null;
409
- const tempDir = await mkdtemp3(join4(tmpdir3(), "replay-migrate-"));
409
+ const tempDir = await mkdtemp3(join5(tmpdir3(), "replay-migrate-"));
410
410
  const tempGit = new GitClient(tempDir);
411
411
  try {
412
412
  await tempGit.exec(["init"]);
413
413
  await tempGit.exec(["config", "user.email", "migrate@fern.com"]);
414
414
  await tempGit.exec(["config", "user.name", "Fern Replay Migration"]);
415
415
  await tempGit.exec(["config", "commit.gpgSign", "false"]);
416
- const tempFilePath = join4(tempDir, filePath);
417
- await mkdir2(dirname3(tempFilePath), { recursive: true });
418
- await writeFile3(tempFilePath, base);
416
+ const tempFilePath = join5(tempDir, filePath);
417
+ await mkdir3(dirname4(tempFilePath), { recursive: true });
418
+ await writeFile4(tempFilePath, base);
419
419
  await tempGit.exec(["add", filePath]);
420
420
  await tempGit.exec(["commit", "-m", `base for ${filePath}`, "--allow-empty"]);
421
421
  await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
422
- return await readFile2(tempFilePath, "utf-8");
422
+ return await readFile3(tempFilePath, "utf-8");
423
423
  } catch {
424
424
  return null;
425
425
  } finally {
@@ -715,225 +715,8 @@ var LockfileManager = class {
715
715
  // src/ReplayDetector.ts
716
716
  import { createHash } from "crypto";
717
717
 
718
- // src/ReplayApplicator.ts
719
- import { mkdir, mkdtemp, readFile, rm, unlink, writeFile } from "fs/promises";
720
- import { tmpdir } from "os";
721
- import { dirname as dirname2, extname, join as join2 } from "path";
722
- import { minimatch } from "minimatch";
723
-
724
- // src/conflict-utils.ts
725
- var CONFLICT_OPENER = "<<<<<<< Generated";
726
- var CONFLICT_SEPARATOR = "=======";
727
- var CONFLICT_CLOSER = ">>>>>>> Your customization";
728
- function trimCR(line) {
729
- return line.endsWith("\r") ? line.slice(0, -1) : line;
730
- }
731
- function findConflictRanges(lines) {
732
- const ranges = [];
733
- let i = 0;
734
- while (i < lines.length) {
735
- if (trimCR(lines[i]) === CONFLICT_OPENER) {
736
- let separatorIdx = -1;
737
- let j = i + 1;
738
- let found = false;
739
- while (j < lines.length) {
740
- const trimmed = trimCR(lines[j]);
741
- if (trimmed === CONFLICT_OPENER) {
742
- break;
743
- }
744
- if (separatorIdx === -1 && trimmed === CONFLICT_SEPARATOR) {
745
- separatorIdx = j;
746
- } else if (separatorIdx !== -1 && trimmed === CONFLICT_CLOSER) {
747
- ranges.push({ start: i, separator: separatorIdx, end: j });
748
- i = j;
749
- found = true;
750
- break;
751
- }
752
- j++;
753
- }
754
- if (!found) {
755
- i++;
756
- continue;
757
- }
758
- }
759
- i++;
760
- }
761
- return ranges;
762
- }
763
- function stripConflictMarkers(content) {
764
- const lines = content.split(/\r?\n/);
765
- const ranges = findConflictRanges(lines);
766
- if (ranges.length === 0) {
767
- return content;
768
- }
769
- const result = [];
770
- let rangeIdx = 0;
771
- for (let i = 0; i < lines.length; i++) {
772
- if (rangeIdx < ranges.length) {
773
- const range = ranges[rangeIdx];
774
- if (i === range.start) {
775
- continue;
776
- }
777
- if (i > range.start && i < range.separator) {
778
- result.push(lines[i]);
779
- continue;
780
- }
781
- if (i === range.separator) {
782
- continue;
783
- }
784
- if (i > range.separator && i < range.end) {
785
- continue;
786
- }
787
- if (i === range.end) {
788
- rangeIdx++;
789
- continue;
790
- }
791
- }
792
- result.push(lines[i]);
793
- }
794
- return result.join("\n");
795
- }
796
-
797
- // src/ThreeWayMerge.ts
798
- import { diff3Merge, diffPatch } from "node-diff3";
799
- function threeWayMerge(base, ours, theirs) {
800
- const baseLines = base.split("\n");
801
- const oursLines = ours.split("\n");
802
- const theirsLines = theirs.split("\n");
803
- const regions = diff3Merge(oursLines, baseLines, theirsLines);
804
- const outputLines = [];
805
- const conflicts = [];
806
- let currentLine = 1;
807
- for (const region of regions) {
808
- if (region.ok) {
809
- outputLines.push(...region.ok);
810
- currentLine += region.ok.length;
811
- } else if (region.conflict) {
812
- const resolved = tryResolveConflict(
813
- region.conflict.a,
814
- // ours (generator)
815
- region.conflict.o,
816
- // base
817
- region.conflict.b
818
- // theirs (user)
819
- );
820
- if (resolved !== null) {
821
- outputLines.push(...resolved);
822
- currentLine += resolved.length;
823
- } else {
824
- const startLine = currentLine;
825
- outputLines.push("<<<<<<< Generated");
826
- outputLines.push(...region.conflict.a);
827
- outputLines.push("=======");
828
- outputLines.push(...region.conflict.b);
829
- outputLines.push(">>>>>>> Your customization");
830
- const conflictLines = region.conflict.a.length + region.conflict.b.length + 3;
831
- conflicts.push({
832
- startLine,
833
- endLine: startLine + conflictLines - 1,
834
- ours: region.conflict.a,
835
- theirs: region.conflict.b
836
- });
837
- currentLine += conflictLines;
838
- }
839
- }
840
- }
841
- const result = {
842
- content: outputLines.join("\n"),
843
- hasConflicts: conflicts.length > 0,
844
- conflicts
845
- };
846
- if (conflicts.length === 0) {
847
- const dropped = computeDroppedContextLines(baseLines, theirsLines, outputLines);
848
- if (dropped.length > 0) {
849
- result.droppedContextLines = dropped;
850
- }
851
- }
852
- return result;
853
- }
854
- function computeDroppedContextLines(baseLines, theirsLines, mergedLines) {
855
- const baseCounts = countLines(baseLines);
856
- const theirsCounts = countLines(theirsLines);
857
- const mergedCounts = countLines(mergedLines);
858
- const dropped = [];
859
- const seen = /* @__PURE__ */ new Set();
860
- for (const line of baseLines) {
861
- if (seen.has(line)) continue;
862
- const inBase = baseCounts.get(line) ?? 0;
863
- const inTheirs = theirsCounts.get(line) ?? 0;
864
- const inMerged = mergedCounts.get(line) ?? 0;
865
- if (inBase > 0 && inTheirs > 0 && inMerged < Math.min(inBase, inTheirs)) {
866
- dropped.push(line);
867
- seen.add(line);
868
- }
869
- }
870
- return dropped;
871
- }
872
- function countLines(lines) {
873
- const counts = /* @__PURE__ */ new Map();
874
- for (const line of lines) {
875
- counts.set(line, (counts.get(line) ?? 0) + 1);
876
- }
877
- return counts;
878
- }
879
- function tryResolveConflict(oursLines, baseLines, theirsLines) {
880
- if (baseLines.length === 0) {
881
- return null;
882
- }
883
- const oursPatches = diffPatch(baseLines, oursLines);
884
- const theirsPatches = diffPatch(baseLines, theirsLines);
885
- if (oursPatches.length === 0) return theirsLines;
886
- if (theirsPatches.length === 0) return oursLines;
887
- if (patchesOverlap(oursPatches, theirsPatches)) {
888
- return null;
889
- }
890
- return applyBothPatches(baseLines, oursPatches, theirsPatches);
891
- }
892
- function patchesOverlap(oursPatches, theirsPatches) {
893
- for (const op of oursPatches) {
894
- const oStart = op.buffer1.offset;
895
- const oEnd = oStart + op.buffer1.length;
896
- for (const tp of theirsPatches) {
897
- const tStart = tp.buffer1.offset;
898
- const tEnd = tStart + tp.buffer1.length;
899
- if (op.buffer1.length === 0 && tp.buffer1.length === 0 && oStart === tStart) {
900
- return true;
901
- }
902
- if (tp.buffer1.length === 0 && tStart === oEnd) {
903
- return true;
904
- }
905
- if (op.buffer1.length === 0 && oStart === tEnd) {
906
- return true;
907
- }
908
- if (oStart < tEnd && tStart < oEnd) {
909
- return true;
910
- }
911
- }
912
- }
913
- return false;
914
- }
915
- function applyBothPatches(baseLines, oursPatches, theirsPatches) {
916
- const allPatches = [
917
- ...oursPatches.map((p) => ({
918
- offset: p.buffer1.offset,
919
- length: p.buffer1.length,
920
- replacement: p.buffer2.chunk
921
- })),
922
- ...theirsPatches.map((p) => ({
923
- offset: p.buffer1.offset,
924
- length: p.buffer1.length,
925
- replacement: p.buffer2.chunk
926
- }))
927
- ];
928
- allPatches.sort((a, b) => b.offset - a.offset);
929
- const result = [...baseLines];
930
- for (const p of allPatches) {
931
- result.splice(p.offset, p.length, ...p.replacement);
932
- }
933
- return result;
934
- }
935
-
936
- // src/ReplayApplicator.ts
718
+ // src/shared/binary.ts
719
+ import { extname } from "path";
937
720
  var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
938
721
  ".png",
939
722
  ".jpg",
@@ -983,1324 +766,1610 @@ var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
983
766
  ".pyo",
984
767
  ".DS_Store"
985
768
  ]);
986
- var ReplayApplicator = class {
987
- git;
988
- lockManager;
989
- outputDir;
990
- renameCache = /* @__PURE__ */ new Map();
991
- treeExistsCache = /* @__PURE__ */ new Map();
992
- fileTheirsAccumulator = /* @__PURE__ */ new Map();
993
- /**
994
- * Apply mode for the current `applyPatches` invocation. Set at the start
995
- * of `applyPatches`, read by `mergeFile` to decide:
996
- * - whether to skip the intra-loop marker strip (kept in `applyPatches`)
997
- * - whether to populate the accumulator after a conflicted merge
998
- * (resolve mode populates so subsequent patches on the same file
999
- * can use patch A's THEIRS as a structurally-correct merge base
1000
- * when their diff was authored against post-A structure)
1001
- * - whether to retry THEIRS reconstruction against the accumulator
1002
- * when the BASE-relative reconstruction produced markers from a
1003
- * cross-patch context mismatch
1004
- */
1005
- currentApplyMode = "replay";
1006
- /**
1007
- * Set of files that appear in 2+ patches in the current `applyPatches`
1008
- * invocation. Computed at the top of `applyPatches`. Read by `mergeFile`
1009
- * and `populateAccumulatorForPatch` to decide whether to use the patch's
1010
- * `theirs_snapshot` directly as THEIRS (snapshot-as-primary) or fall
1011
- * back to line-anchored reconstruction (FER-9525 cross-patch isolation).
1012
- *
1013
- * Snapshot-as-primary is correct when the patch owns its file (no other
1014
- * patch in this run touches it): the snapshot is what the customer
1015
- * intends for that file post-regen, regardless of generator structural
1016
- * changes that would invalidate the diff's line anchors.
1017
- *
1018
- * When a file IS shared, snapshots are CUMULATIVE across the patches
1019
- * touching it (each one's snapshot includes prior patches' contributions).
1020
- * Using a later patch's cumulative snapshot as its individual THEIRS
1021
- * would propagate prior patches' edits into the merge — surfacing nested
1022
- * conflicts at regions the patch alone never touched. Reconstruction
1023
- * via `applyPatchToContent` is required there.
1024
- */
1025
- sharedFiles = /* @__PURE__ */ new Set();
1026
- constructor(git, lockManager, outputDir) {
1027
- this.git = git;
1028
- this.lockManager = lockManager;
1029
- this.outputDir = outputDir;
1030
- }
1031
- /**
1032
- * Resolve the GenerationRecord for a patch's base_generation.
1033
- * Falls back to constructing an ad-hoc record from the commit's tree
1034
- * when base_generation isn't a tracked generation (commit-scan patches).
1035
- */
1036
- async resolveBaseGeneration(baseGeneration) {
1037
- const gen = this.lockManager.getGeneration(baseGeneration);
1038
- if (gen) return gen;
1039
- try {
1040
- const treeHash = await this.git.getTreeHash(baseGeneration);
1041
- return {
1042
- commit_sha: baseGeneration,
1043
- tree_hash: treeHash,
1044
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1045
- cli_version: "unknown",
1046
- generator_versions: {}
1047
- };
1048
- } catch {
1049
- return void 0;
1050
- }
1051
- }
1052
- /** Reset inter-patch accumulator for a new cycle. */
1053
- resetAccumulator() {
1054
- this.fileTheirsAccumulator.clear();
1055
- }
769
+ function isBinaryFile(filePath) {
770
+ const ext = extname(filePath).toLowerCase();
771
+ return BINARY_EXTENSIONS.has(ext);
772
+ }
773
+
774
+ // src/ReplayDetector.ts
775
+ var INFRASTRUCTURE_FILES = /* @__PURE__ */ new Set([".fernignore"]);
776
+ function parsePatchFileHeaders(patchContent) {
777
+ const results = [];
778
+ let currentPath = null;
779
+ let currentIsCreate = false;
780
+ let currentIsDelete = false;
781
+ const flush = () => {
782
+ if (currentPath !== null) {
783
+ results.push({ path: currentPath, isCreate: currentIsCreate, isDelete: currentIsDelete });
784
+ }
785
+ };
786
+ for (const line of patchContent.split("\n")) {
787
+ if (line.startsWith("diff --git ")) {
788
+ flush();
789
+ currentPath = null;
790
+ currentIsCreate = false;
791
+ currentIsDelete = false;
792
+ const match = line.match(/^diff --git a\/(.+?) b\/(.+?)$/);
793
+ if (match) {
794
+ currentPath = match[2] ?? null;
795
+ }
796
+ } else if (currentPath !== null) {
797
+ if (line.startsWith("new file mode ")) {
798
+ currentIsCreate = true;
799
+ } else if (line.startsWith("deleted file mode ")) {
800
+ currentIsDelete = true;
801
+ }
802
+ }
803
+ }
804
+ flush();
805
+ return results;
806
+ }
807
+ async function capturePatchSnapshot(git, files, treeish) {
808
+ const snapshot = {};
809
+ for (const file of files) {
810
+ if (isBinaryFile(file)) continue;
811
+ const content = await git.showFile(treeish, file).catch(() => null);
812
+ if (content != null) snapshot[file] = content;
813
+ }
814
+ return snapshot;
815
+ }
816
+ function filterInfrastructureAndSensitiveFiles(rawFilesOutput) {
817
+ const allFiles = rawFilesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
818
+ const sensitiveFiles = allFiles.filter((f) => isSensitiveFile(f));
819
+ const files = allFiles.filter((f) => !isSensitiveFile(f));
820
+ return { files, sensitiveFiles };
821
+ }
822
+ var ReplayDetector = class {
823
+ git;
824
+ lockManager;
825
+ sdkOutputDir;
826
+ warnings = [];
827
+ constructor(git, lockManager, sdkOutputDir) {
828
+ this.git = git;
829
+ this.lockManager = lockManager;
830
+ this.sdkOutputDir = sdkOutputDir;
831
+ }
832
+ async detectNewPatches() {
833
+ const lock = this.lockManager.read();
834
+ const lastGen = this.getLastGeneration(lock);
835
+ if (!lastGen) {
836
+ return { patches: [], revertedPatchIds: [] };
837
+ }
838
+ const exists = await this.git.commitExists(lastGen.commit_sha);
839
+ if (!exists) {
840
+ this.warnings.push(
841
+ `Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to alternate detection.`
842
+ );
843
+ return this.detectPatchesViaTreeDiff(
844
+ lastGen,
845
+ /* commitKnownMissing */
846
+ true
847
+ );
848
+ }
849
+ const isAncestor = await this.git.isAncestor(lastGen.commit_sha, "HEAD");
850
+ if (!isAncestor) {
851
+ return this.detectPatchesViaMergeBase(lastGen, lock);
852
+ }
853
+ return this.detectPatchesInRange(lastGen.commit_sha, lastGen, lock);
854
+ }
1056
855
  /**
1057
- * @internal Test-only.
1058
- * Returns a defensive copy of the inter-patch accumulator. Used by the
1059
- * adversarial test suite (FER-9791) to assert invariant I2: after every
1060
- * applied patch, the accumulator has an entry for each file the patch
1061
- * touched, and the entry's content matches on-disk.
856
+ * Non-linear-history detection via `merge-base(prevGen, HEAD)`.
857
+ *
858
+ * After a squash-merge of a Fern-bot PR, `lastGen.commit_sha` (the previous
859
+ * `[fern-generated]`) is orphaned but still in git's object database. The
860
+ * merge-base is the commit on main from which the bot's branch was created —
861
+ * a reachable ancestor of HEAD. Walking that range and classifying each
862
+ * commit via `isGenerationCommit` mirrors the linear path and eliminates
863
+ * the bug class where pipeline-driven changes (autoversion, replay,
864
+ * generation) get bundled as customer customizations.
865
+ *
866
+ * Falls through to the legacy composite path when no common ancestor exists
867
+ * (truly disjoint history — orphan branches with no shared root).
1062
868
  */
1063
- getAccumulatorSnapshot() {
1064
- const snapshot = /* @__PURE__ */ new Map();
1065
- for (const [path, entry] of this.fileTheirsAccumulator) {
1066
- snapshot.set(path, { content: entry.content, baseGeneration: entry.baseGeneration });
869
+ async detectPatchesViaMergeBase(lastGen, lock) {
870
+ let mergeBase = "";
871
+ try {
872
+ mergeBase = (await this.git.exec(["merge-base", lastGen.commit_sha, "HEAD"])).trim();
873
+ } catch {
1067
874
  }
1068
- return snapshot;
875
+ if (!mergeBase) {
876
+ this.warnings.push(
877
+ `No common ancestor between previous generation ${lastGen.commit_sha.slice(0, 7)} and HEAD. Falling back to composite tree-diff detection.`
878
+ );
879
+ return this.detectPatchesViaTreeDiff(
880
+ lastGen,
881
+ /* commitKnownMissing */
882
+ false
883
+ );
884
+ }
885
+ return this.detectPatchesInRange(mergeBase, lastGen, lock);
1069
886
  }
1070
887
  /**
1071
- * Apply all patches, returning results for each.
1072
- * Skips patches that match exclude patterns in replay.yml
888
+ * Walk `${rangeStart}..HEAD`, classify each commit, emit one
889
+ * `StoredPatch` per customer commit. Body shared by the linear path
890
+ * (rangeStart = lastGen.commit_sha) and the merge-base path.
1073
891
  *
1074
- * `applyMode` controls the post-conflict marker strategy:
1075
- * - `"replay"` (default): strip conflict markers from disk between
1076
- * iterations whenever a later patch touches the same file. Lets the
1077
- * follow-up patch's `git apply --3way` see clean OURS content,
1078
- * which is what the silent-loss fix (PR #73) relies on. The replay
1079
- * pipeline calls `revertConflictingFiles` after the loop to clean
1080
- * any markers that survive.
1081
- * - `"resolve"`: keep markers on disk. The resolve command needs the
1082
- * customer to see them. Slow-path 3-way merge naturally preserves
1083
- * A's markers and B's clean writes when their regions don't overlap;
1084
- * when they do overlap, the customer gets nested markers and
1085
- * resolves manually. Either way they aren't silently dropped.
892
+ * Patch `base_generation` is always `lastGen.commit_sha` the
893
+ * lockfile-tracked reference the applicator uses to fetch base file
894
+ * content, regardless of where the walk actually started.
1086
895
  */
1087
- async applyPatches(patches, opts) {
1088
- const applyMode = opts?.applyMode ?? "replay";
1089
- this.currentApplyMode = applyMode;
1090
- this.resetAccumulator();
1091
- const fileFreq = /* @__PURE__ */ new Map();
1092
- for (const p of patches) {
1093
- for (const f of p.files) {
1094
- fileFreq.set(f, (fileFreq.get(f) ?? 0) + 1);
1095
- }
896
+ async detectPatchesInRange(rangeStart, lastGen, lock) {
897
+ const log = await this.git.exec([
898
+ "log",
899
+ "--no-merges",
900
+ "--format=%H%x00%an%x00%ae%x00%s",
901
+ `${rangeStart}..HEAD`,
902
+ "--",
903
+ this.sdkOutputDir
904
+ ]);
905
+ if (!log.trim()) {
906
+ return { patches: [], revertedPatchIds: [] };
1096
907
  }
1097
- this.sharedFiles = new Set(
1098
- Array.from(fileFreq.entries()).filter(([, n]) => n > 1).map(([f]) => f)
1099
- );
1100
- const results = [];
1101
- for (let i = 0; i < patches.length; i++) {
1102
- const patch = patches[i];
1103
- if (this.isExcluded(patch)) {
1104
- results.push({
1105
- patch,
1106
- status: "skipped",
1107
- method: "git-am"
1108
- });
908
+ const commits = this.parseGitLog(log);
909
+ const newPatches = [];
910
+ const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
911
+ for (const commit of commits) {
912
+ if (isGenerationCommit(commit)) {
1109
913
  continue;
1110
914
  }
1111
- const result = await this.applyPatchWithFallback(patch);
1112
- results.push(result);
1113
- if (applyMode !== "resolve" && result.status === "conflict" && result.fileResults) {
1114
- const laterFiles = /* @__PURE__ */ new Set();
1115
- for (let j = i + 1; j < patches.length; j++) {
1116
- for (const f of patches[j].files) {
1117
- laterFiles.add(f);
1118
- }
1119
- }
1120
- const resolvedToOriginal = /* @__PURE__ */ new Map();
1121
- if (result.resolvedFiles) {
1122
- for (const [orig, resolved] of Object.entries(result.resolvedFiles)) {
1123
- resolvedToOriginal.set(resolved, orig);
1124
- }
1125
- }
1126
- for (const fileResult of result.fileResults) {
1127
- if (fileResult.status !== "conflict") continue;
1128
- const originalPath = resolvedToOriginal.get(fileResult.file) ?? fileResult.file;
1129
- if (laterFiles.has(fileResult.file) || laterFiles.has(originalPath)) {
1130
- const filePath = join2(this.outputDir, fileResult.file);
915
+ if (lock.patches.find((p) => p.original_commit === commit.sha)) {
916
+ continue;
917
+ }
918
+ const parents = await this.git.getCommitParents(commit.sha);
919
+ let patchContent;
920
+ try {
921
+ patchContent = await this.git.formatPatch(commit.sha);
922
+ } catch {
923
+ this.warnings.push(
924
+ `Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
925
+ );
926
+ continue;
927
+ }
928
+ let contentHash = this.computeContentHash(patchContent);
929
+ if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
930
+ continue;
931
+ }
932
+ const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
933
+ const { files, sensitiveFiles } = filterInfrastructureAndSensitiveFiles(filesOutput);
934
+ if (sensitiveFiles.length > 0) {
935
+ this.warnings.push(
936
+ `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
937
+ );
938
+ if (files.length > 0) {
939
+ const parentSha = parents[0];
940
+ if (parentSha) {
1131
941
  try {
1132
- const content = await readFile(filePath, "utf-8");
1133
- const stripped = stripConflictMarkers(content);
1134
- await writeFile(filePath, stripped);
942
+ patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
1135
943
  } catch {
944
+ continue;
1136
945
  }
946
+ if (!patchContent.trim()) continue;
947
+ contentHash = this.computeContentHash(patchContent);
1137
948
  }
1138
949
  }
1139
950
  }
1140
- }
1141
- return results;
1142
- }
1143
- /**
1144
- * Populate accumulator after git apply succeeds, AND collect per-file
1145
- * results including any "dropped context lines" — lines that were
1146
- * present in BASE and THEIRS (unchanged context) but absent from the
1147
- * final on-disk state because OURS deleted them. This is the fast-path
1148
- * counterpart to ThreeWayMerge.computeDroppedContextLines used by the
1149
- * 3-way slow path; both must surface the same warning.
1150
- */
1151
- async populateAccumulatorForPatch(patch, baseGen, currentTreeHash) {
1152
- const fileResults = [];
1153
- if (!baseGen) return fileResults;
1154
- const tempDir = await mkdtemp(join2(tmpdir(), "replay-acc-"));
1155
- const { GitClient: GitClient2 } = await Promise.resolve().then(() => (init_GitClient(), GitClient_exports));
1156
- const tempGit = new GitClient2(tempDir);
1157
- await tempGit.exec(["init"]);
1158
- await tempGit.exec(["config", "user.email", "replay@fern.com"]);
1159
- await tempGit.exec(["config", "user.name", "Fern Replay"]);
1160
- try {
1161
- for (const filePath of patch.files) {
1162
- if (isBinaryFile(filePath)) continue;
1163
- const resolvedPath = await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
1164
- const base = await this.git.showFile(baseGen.tree_hash, filePath);
1165
- const snapshotForFile = patch.theirs_snapshot?.[resolvedPath] ?? patch.theirs_snapshot?.[filePath];
1166
- const fileIsShared = this.sharedFiles.has(resolvedPath) || this.sharedFiles.has(filePath);
1167
- const theirs = !fileIsShared && snapshotForFile != null ? snapshotForFile : await this.applyPatchToContent(base, patch.patch_content, filePath, tempGit, tempDir);
1168
- const finalOnDisk = await readFile(join2(this.outputDir, resolvedPath), "utf-8").catch(() => null);
1169
- const effectiveTheirs = theirs ?? finalOnDisk;
1170
- if (effectiveTheirs != null) {
1171
- this.fileTheirsAccumulator.set(resolvedPath, {
1172
- content: effectiveTheirs,
1173
- baseGeneration: patch.base_generation
1174
- });
1175
- }
1176
- if (base != null && effectiveTheirs != null && finalOnDisk != null) {
1177
- const dropped = computeDroppedContextLinesForFile(base, effectiveTheirs, finalOnDisk);
1178
- if (dropped.length > 0) {
1179
- fileResults.push({
1180
- file: resolvedPath,
1181
- status: "merged",
1182
- droppedContextLines: dropped
1183
- });
1184
- }
1185
- }
1186
- }
1187
- } finally {
1188
- await rm(tempDir, { recursive: true }).catch(() => {
1189
- });
1190
- }
1191
- return fileResults;
1192
- }
1193
- async applyPatchWithFallback(patch) {
1194
- const baseGen = await this.resolveBaseGeneration(patch.base_generation);
1195
- const lock = this.lockManager.read();
1196
- const currentGen = lock.generations.find((g) => g.commit_sha === lock.current_generation);
1197
- const currentTreeHash = currentGen?.tree_hash ?? baseGen?.tree_hash ?? "";
1198
- const needsAccumulation = await Promise.all(
1199
- patch.files.map(async (f) => {
1200
- if (!baseGen) return false;
1201
- const resolved = await this.resolveFilePath(f, baseGen.tree_hash, currentTreeHash);
1202
- return this.fileTheirsAccumulator.has(resolved);
1203
- })
1204
- ).then((results) => results.some(Boolean));
1205
- const resolvedFiles = {};
1206
- for (const filePath of patch.files) {
1207
- const resolvedPath = baseGen ? await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash) : filePath;
1208
- if (resolvedPath !== filePath) {
1209
- resolvedFiles[filePath] = resolvedPath;
951
+ if (files.length === 0) {
952
+ continue;
1210
953
  }
954
+ const theirsSnapshot = await capturePatchSnapshot(this.git, files, commit.sha);
955
+ newPatches.push({
956
+ id: `patch-${commit.sha.slice(0, 8)}`,
957
+ content_hash: contentHash,
958
+ original_commit: commit.sha,
959
+ original_message: commit.message,
960
+ original_author: `${commit.authorName} <${commit.authorEmail}>`,
961
+ base_generation: lastGen.commit_sha,
962
+ files,
963
+ patch_content: patchContent,
964
+ ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
965
+ ...this.isCreationOnlyPatch(patchContent) ? { user_owned: true } : {}
966
+ });
1211
967
  }
1212
- const patchIsRenameAware = /^rename from /m.test(patch.patch_content);
1213
- const hasGeneratorRename = Object.keys(resolvedFiles).length > 0 && !patchIsRenameAware;
1214
- if (!needsAccumulation && !hasGeneratorRename) {
1215
- const snapshots = /* @__PURE__ */ new Map();
1216
- for (const filePath of patch.files) {
1217
- const fullPath = join2(this.outputDir, filePath);
1218
- snapshots.set(filePath, await readFile(fullPath, "utf-8").catch(() => null));
1219
- }
968
+ const survivingPatches = await this.collapseNetZeroFiles(newPatches);
969
+ survivingPatches.reverse();
970
+ const revertedPatchIdSet = /* @__PURE__ */ new Set();
971
+ const revertIndicesToRemove = /* @__PURE__ */ new Set();
972
+ for (let i = 0; i < survivingPatches.length; i++) {
973
+ const patch = survivingPatches[i];
974
+ if (!isRevertCommit(patch.original_message)) continue;
975
+ let body = "";
1220
976
  try {
1221
- await this.git.execWithInput(["apply", "--3way"], patch.patch_content);
1222
- const fastPathFileResults = await this.populateAccumulatorForPatch(patch, baseGen, currentTreeHash);
1223
- return {
1224
- patch,
1225
- status: "applied",
1226
- method: "git-am",
1227
- ...fastPathFileResults.length > 0 && { fileResults: fastPathFileResults },
1228
- ...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
1229
- };
977
+ body = await this.git.getCommitBody(patch.original_commit);
1230
978
  } catch {
1231
- for (const [filePath, content] of snapshots) {
1232
- const fullPath = join2(this.outputDir, filePath);
1233
- if (content != null) {
1234
- await writeFile(fullPath, content);
1235
- } else {
1236
- await unlink(fullPath).catch(() => {
1237
- });
1238
- }
1239
- }
1240
- }
1241
- }
1242
- return this.applyWithThreeWayMerge(patch);
1243
- }
1244
- async applyWithThreeWayMerge(patch) {
1245
- const fileResults = [];
1246
- const resolvedFiles = {};
1247
- const tempDir = await mkdtemp(join2(tmpdir(), "replay-"));
1248
- const { GitClient: GitClient2 } = await Promise.resolve().then(() => (init_GitClient(), GitClient_exports));
1249
- const tempGit = new GitClient2(tempDir);
1250
- await tempGit.exec(["init"]);
1251
- await tempGit.exec(["config", "user.email", "replay@fern.com"]);
1252
- await tempGit.exec(["config", "user.name", "Fern Replay"]);
1253
- try {
1254
- for (const filePath of patch.files) {
1255
- if (isBinaryFile(filePath)) {
1256
- fileResults.push({
1257
- file: filePath,
1258
- status: "skipped",
1259
- reason: "binary-file"
1260
- });
1261
- continue;
1262
- }
1263
- const result = await this.mergeFile(patch, filePath, tempGit, tempDir);
1264
- if (result.file !== filePath) {
1265
- resolvedFiles[filePath] = result.file;
1266
- }
1267
- fileResults.push(result);
1268
- }
1269
- } finally {
1270
- await rm(tempDir, { recursive: true }).catch(() => {
1271
- });
1272
- }
1273
- const conflictFiles = fileResults.filter((r) => r.status === "conflict");
1274
- const hasConflicts = conflictFiles.length > 0;
1275
- const conflictReason = hasConflicts ? conflictFiles.some((f) => f.conflictReason === "base-generation-mismatch") ? "base-generation-mismatch" : conflictFiles[0]?.conflictReason : void 0;
1276
- return {
1277
- patch,
1278
- status: hasConflicts ? "conflict" : "applied",
1279
- method: "3way-merge",
1280
- fileResults,
1281
- conflictReason,
1282
- ...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
1283
- };
1284
- }
1285
- async mergeFile(patch, filePath, tempGit, tempDir) {
1286
- try {
1287
- const baseGen = await this.resolveBaseGeneration(patch.base_generation);
1288
- if (!baseGen) {
1289
- return { file: filePath, status: "skipped", reason: "base-generation-not-found" };
1290
- }
1291
- const lock = this.lockManager.read();
1292
- const currentGen = lock.generations.find((g) => g.commit_sha === lock.current_generation);
1293
- const currentTreeHash = currentGen?.tree_hash ?? baseGen.tree_hash;
1294
- const resolvedPath = await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
1295
- const metadata = {
1296
- patchId: patch.id,
1297
- patchMessage: patch.original_message,
1298
- baseGeneration: patch.base_generation,
1299
- currentGeneration: lock.current_generation
1300
- };
1301
- let base = await this.git.showFile(baseGen.tree_hash, filePath);
1302
- let renameSourcePath;
1303
- if (!base) {
1304
- const renameSource = this.extractRenameSource(patch.patch_content, filePath);
1305
- if (renameSource) {
1306
- base = await this.git.showFile(baseGen.tree_hash, renameSource);
1307
- renameSourcePath = renameSource;
1308
- }
1309
- }
1310
- const oursPath = join2(this.outputDir, resolvedPath);
1311
- const ours = await readFile(oursPath, "utf-8").catch(() => null);
1312
- let ghostReconstructed = false;
1313
- let theirs = null;
1314
- if (!base && ours && !renameSourcePath) {
1315
- const treeReachable = await this.isTreeReachable(baseGen.tree_hash);
1316
- if (!treeReachable) {
1317
- const fileDiff = this.extractFileDiff(patch.patch_content, filePath);
1318
- if (fileDiff) {
1319
- const { reconstructFromGhostPatch: reconstructFromGhostPatch2 } = await Promise.resolve().then(() => (init_HybridReconstruction(), HybridReconstruction_exports));
1320
- const result = reconstructFromGhostPatch2(fileDiff, ours);
1321
- if (result) {
1322
- base = result.base;
1323
- theirs = result.theirs;
1324
- ghostReconstructed = true;
1325
- }
1326
- }
1327
- }
1328
979
  }
1329
- const snapshotForFile = patch.theirs_snapshot?.[resolvedPath] ?? patch.theirs_snapshot?.[filePath];
1330
- const fileIsShared = this.sharedFiles.has(resolvedPath) || this.sharedFiles.has(filePath);
1331
- const useSnapshotAsPrimary = !fileIsShared && snapshotForFile != null;
1332
- if (!ghostReconstructed) {
1333
- if (useSnapshotAsPrimary) {
1334
- theirs = snapshotForFile;
1335
- } else {
1336
- theirs = await this.applyPatchToContent(
1337
- base,
1338
- patch.patch_content,
1339
- filePath,
1340
- tempGit,
1341
- tempDir,
1342
- renameSourcePath
1343
- );
1344
- const reconstructionHasMarkers = theirs != null && (theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization"));
1345
- const baseHadMarkers = base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization"));
1346
- if (reconstructionHasMarkers && !baseHadMarkers && snapshotForFile != null) {
1347
- theirs = snapshotForFile;
1348
- }
980
+ const revertedSha = parseRevertedSha(body);
981
+ const revertedMessage = parseRevertedMessage(patch.original_message);
982
+ let matchedExisting = false;
983
+ if (revertedSha) {
984
+ const existing = lock.patches.find((p) => p.original_commit === revertedSha);
985
+ if (existing) {
986
+ revertedPatchIdSet.add(existing.id);
987
+ revertIndicesToRemove.add(i);
988
+ matchedExisting = true;
1349
989
  }
1350
990
  }
1351
- const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
1352
- if (theirs) {
1353
- const theirsHasMarkers = theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization");
1354
- const baseHasMarkers = base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization"));
1355
- if (theirsHasMarkers && !baseHasMarkers) {
1356
- if (accumulatorEntry) {
1357
- theirs = null;
1358
- } else {
1359
- return {
1360
- file: resolvedPath,
1361
- status: "skipped",
1362
- reason: "stale-conflict-markers"
1363
- };
1364
- }
991
+ if (!matchedExisting && revertedMessage) {
992
+ const existing = lock.patches.find((p) => p.original_message === revertedMessage);
993
+ if (existing) {
994
+ revertedPatchIdSet.add(existing.id);
995
+ revertIndicesToRemove.add(i);
996
+ matchedExisting = true;
1365
997
  }
1366
998
  }
1367
- let useAccumulatorAsMergeBase = false;
1368
- if (accumulatorEntry && (theirs == null || base == null)) {
1369
- theirs = await this.applyPatchToContent(
1370
- accumulatorEntry.content,
1371
- patch.patch_content,
1372
- filePath,
1373
- tempGit,
1374
- tempDir
999
+ if (matchedExisting) continue;
1000
+ let matchedNew = false;
1001
+ if (revertedSha) {
1002
+ const idx = survivingPatches.findIndex(
1003
+ (p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_commit === revertedSha
1375
1004
  );
1376
- if (theirs != null) {
1377
- useAccumulatorAsMergeBase = true;
1378
- } else if (await this.isPatchAlreadyApplied(
1379
- patch.patch_content,
1380
- filePath,
1381
- accumulatorEntry.content,
1382
- tempGit,
1383
- tempDir
1384
- )) {
1385
- theirs = accumulatorEntry.content;
1386
- useAccumulatorAsMergeBase = true;
1387
- }
1388
- }
1389
- if (theirs) {
1390
- const theirsHasMarkers = theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization");
1391
- const accBaseHasMarkers = accumulatorEntry != null && (accumulatorEntry.content.includes("<<<<<<< Generated") || accumulatorEntry.content.includes(">>>>>>> Your customization"));
1392
- if (theirsHasMarkers && !accBaseHasMarkers && !(base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization")))) {
1393
- return {
1394
- file: resolvedPath,
1395
- status: "skipped",
1396
- reason: "stale-conflict-markers"
1397
- };
1398
- }
1399
- }
1400
- let effective_theirs = theirs;
1401
- let baseMismatchSkipped = false;
1402
- if (theirs != null && base != null && !useAccumulatorAsMergeBase) {
1403
- if (accumulatorEntry && accumulatorEntry.baseGeneration === patch.base_generation) {
1404
- try {
1405
- const preMerged = threeWayMerge(base, accumulatorEntry.content, theirs);
1406
- if (!preMerged.hasConflicts) {
1407
- effective_theirs = preMerged.content;
1408
- } else {
1409
- effective_theirs = theirs;
1410
- }
1411
- } catch {
1412
- effective_theirs = theirs;
1413
- }
1414
- } else if (accumulatorEntry) {
1415
- baseMismatchSkipped = true;
1416
- }
1417
- }
1418
- if (base == null && ours == null && effective_theirs != null) {
1419
- this.fileTheirsAccumulator.set(resolvedPath, {
1420
- content: effective_theirs,
1421
- baseGeneration: patch.base_generation
1422
- });
1423
- const outDir2 = dirname2(oursPath);
1424
- await mkdir(outDir2, { recursive: true });
1425
- await writeFile(oursPath, effective_theirs);
1426
- return { file: resolvedPath, status: "merged", reason: "new-file" };
1427
- }
1428
- if (base == null && ours != null && effective_theirs != null && !useAccumulatorAsMergeBase) {
1429
- const merged2 = threeWayMerge("", ours, effective_theirs);
1430
- const outDir2 = dirname2(oursPath);
1431
- await mkdir(outDir2, { recursive: true });
1432
- await writeFile(oursPath, merged2.content);
1433
- if (merged2.hasConflicts) {
1434
- return {
1435
- file: resolvedPath,
1436
- status: "conflict",
1437
- conflicts: merged2.conflicts,
1438
- conflictReason: "new-file-both",
1439
- conflictMetadata: metadata
1440
- };
1005
+ if (idx !== -1) {
1006
+ revertIndicesToRemove.add(i);
1007
+ revertIndicesToRemove.add(idx);
1008
+ matchedNew = true;
1441
1009
  }
1442
- this.fileTheirsAccumulator.set(resolvedPath, {
1443
- content: merged2.content,
1444
- baseGeneration: patch.base_generation
1445
- });
1446
- return { file: resolvedPath, status: "merged" };
1447
- }
1448
- if (effective_theirs == null) {
1449
- return {
1450
- file: resolvedPath,
1451
- status: "skipped",
1452
- reason: "missing-content"
1453
- };
1454
- }
1455
- if (base == null && !useAccumulatorAsMergeBase || ours == null) {
1456
- return {
1457
- file: resolvedPath,
1458
- status: "skipped",
1459
- reason: "missing-content"
1460
- };
1461
- }
1462
- const mergeBase = useAccumulatorAsMergeBase && accumulatorEntry ? accumulatorEntry.content : base;
1463
- if (mergeBase == null) {
1464
- return {
1465
- file: resolvedPath,
1466
- status: "skipped",
1467
- reason: "missing-content"
1468
- };
1469
- }
1470
- const merged = threeWayMerge(mergeBase, ours, effective_theirs);
1471
- const outDir = dirname2(oursPath);
1472
- await mkdir(outDir, { recursive: true });
1473
- await writeFile(oursPath, merged.content);
1474
- const populateAccumulator = effective_theirs != null && (!merged.hasConflicts || this.currentApplyMode === "resolve");
1475
- if (populateAccumulator) {
1476
- this.fileTheirsAccumulator.set(resolvedPath, {
1477
- content: effective_theirs,
1478
- baseGeneration: patch.base_generation
1479
- });
1480
- }
1481
- if (merged.hasConflicts) {
1482
- return {
1483
- file: resolvedPath,
1484
- status: "conflict",
1485
- conflicts: merged.conflicts,
1486
- conflictReason: baseMismatchSkipped ? "base-generation-mismatch" : "same-line-edit",
1487
- conflictMetadata: metadata
1488
- };
1489
1010
  }
1490
- return {
1491
- file: resolvedPath,
1492
- status: "merged",
1493
- ...merged.droppedContextLines && merged.droppedContextLines.length > 0 ? { droppedContextLines: merged.droppedContextLines } : {}
1494
- };
1495
- } catch (error) {
1496
- return {
1497
- file: filePath,
1498
- status: "skipped",
1499
- reason: `error: ${error instanceof Error ? error.message : String(error)}`
1500
- };
1501
- }
1502
- }
1503
- async isTreeReachable(treeHash) {
1504
- let result = this.treeExistsCache.get(treeHash);
1505
- if (result === void 0) {
1506
- result = await this.git.treeExists(treeHash);
1507
- this.treeExistsCache.set(treeHash, result);
1508
- }
1509
- return result;
1510
- }
1511
- isExcluded(patch) {
1512
- const config = this.lockManager.getCustomizationsConfig();
1513
- if (!config.exclude) return false;
1514
- return patch.files.some((file) => config.exclude.some((pattern) => minimatch(file, pattern)));
1515
- }
1516
- async resolveFilePath(filePath, baseTreeHash, currentTreeHash) {
1517
- const config = this.lockManager.getCustomizationsConfig();
1518
- if (config.moves) {
1519
- for (const move of config.moves) {
1520
- if (minimatch(filePath, move.from) || filePath === move.from) {
1521
- if (filePath === move.from) {
1522
- return move.to;
1523
- }
1524
- const fromBase = move.from.replace(/\*\*.*$/, "");
1525
- const toBase = move.to.replace(/\*\*.*$/, "");
1526
- if (filePath.startsWith(fromBase)) {
1527
- return toBase + filePath.slice(fromBase.length);
1528
- }
1011
+ if (!matchedNew && revertedMessage) {
1012
+ const idx = survivingPatches.findIndex(
1013
+ (p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_message === revertedMessage
1014
+ );
1015
+ if (idx !== -1) {
1016
+ revertIndicesToRemove.add(i);
1017
+ revertIndicesToRemove.add(idx);
1529
1018
  }
1530
1019
  }
1531
- }
1532
- const cacheKey = `${baseTreeHash}:${currentTreeHash}`;
1533
- let renames = this.renameCache.get(cacheKey);
1534
- if (!renames) {
1535
- renames = await this.git.detectRenames(baseTreeHash, currentTreeHash);
1536
- this.renameCache.set(cacheKey, renames);
1537
- }
1538
- const gitRename = renames.find((r) => r.from === filePath);
1539
- if (gitRename) {
1540
- return gitRename.to;
1541
- }
1542
- return filePath;
1543
- }
1544
- async applyPatchToContent(base, patchContent, filePath, tempGit, tempDir, sourceFilePath) {
1545
- if (!base) {
1546
- return this.extractNewFileFromPatch(patchContent, filePath);
1547
- }
1548
- const fileDiff = this.extractFileDiff(patchContent, filePath);
1549
- if (!fileDiff) return null;
1550
- try {
1551
- if (sourceFilePath) {
1552
- const tempSourcePath = join2(tempDir, sourceFilePath);
1553
- await mkdir(dirname2(tempSourcePath), { recursive: true });
1554
- await writeFile(tempSourcePath, base);
1555
- await tempGit.exec(["add", sourceFilePath]);
1556
- await tempGit.exec([
1557
- "commit",
1558
- "-m",
1559
- `base for rename ${sourceFilePath} -> ${filePath}`,
1560
- "--allow-empty"
1561
- ]);
1562
- await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
1563
- const tempTargetPath = join2(tempDir, filePath);
1564
- return await readFile(tempTargetPath, "utf-8");
1020
+ if (!matchedExisting && !matchedNew) {
1021
+ revertIndicesToRemove.add(i);
1565
1022
  }
1566
- const tempFilePath = join2(tempDir, filePath);
1567
- await mkdir(dirname2(tempFilePath), { recursive: true });
1568
- await writeFile(tempFilePath, base);
1569
- await tempGit.exec(["add", filePath]);
1570
- await tempGit.exec(["commit", "-m", `base for ${filePath}`, "--allow-empty"]);
1571
- await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
1572
- return await readFile(tempFilePath, "utf-8");
1573
- } catch {
1574
- return null;
1575
1023
  }
1024
+ const filteredPatches = survivingPatches.filter((_, i) => !revertIndicesToRemove.has(i));
1025
+ return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
1576
1026
  }
1577
1027
  /**
1578
- * Detects whether a patch's additions are already present in `content`.
1579
- * Uses `git apply --reverse --check` — if the reverse patch applies cleanly,
1580
- * the forward patch is effectively a no-op (its +lines are already there and
1581
- * its -lines are already gone). Used to distinguish "patch already applied"
1582
- * from "patch base mismatch" after a forward-apply fails.
1028
+ * Compute content hash for deduplication.
1029
+ *
1030
+ * Produces a format-agnostic hash so both `git format-patch` output
1031
+ * (email-wrapped) and plain `git diff` output hash to the same value
1032
+ * when the underlying diff hunks are identical.
1033
+ *
1034
+ * Normalization:
1035
+ * 1. Strip email wrapper: everything before the first `diff --git` line
1036
+ * (From:, Subject:, Date:, diffstat, blank separators).
1037
+ * 2. Strip `index` lines (blob SHA pairs change across rebases).
1038
+ * 3. Strip trailing git version marker (`-- \n<version>`).
1039
+ *
1040
+ * This ensures content hashes match across the no-patches and normal-
1041
+ * regeneration flows, which store formatPatch and git-diff formats
1042
+ * respectively.
1583
1043
  */
1584
- async isPatchAlreadyApplied(patchContent, filePath, content, tempGit, tempDir) {
1585
- const fileDiff = this.extractFileDiff(patchContent, filePath);
1586
- if (!fileDiff) return false;
1044
+ computeContentHash(patchContent) {
1045
+ const lines = patchContent.split("\n");
1046
+ const diffStart = lines.findIndex((l) => l.startsWith("diff --git "));
1047
+ const relevantLines = diffStart > 0 ? lines.slice(diffStart) : lines;
1048
+ const normalized = relevantLines.filter((line) => !line.startsWith("index ")).join("\n").replace(/\n-- \n[\d]+\.[\d]+[^\n]*\s*$/, "");
1049
+ return `sha256:${createHash("sha256").update(normalized).digest("hex")}`;
1050
+ }
1051
+ /**
1052
+ * Drop patches whose files were transiently created and then deleted
1053
+ * within the current linear detection window, and are absent from
1054
+ * HEAD at the end of the window.
1055
+ *
1056
+ * Rationale: each commit becomes its own patch, so a file that was
1057
+ * briefly added and later removed survives as a create+delete pair whose
1058
+ * cumulative diff is empty. We drop such patches before they reach the
1059
+ * lockfile.
1060
+ *
1061
+ * A file is "transient" when:
1062
+ * 1. some patch in the window sets `new file mode` for it (a creation), AND
1063
+ * 2. some patch in the window sets `deleted file mode` for it (a deletion), AND
1064
+ * 3. it is absent from HEAD's tree.
1065
+ *
1066
+ * The HEAD guard (#3) prevents false positives when a file is deleted
1067
+ * and then recreated with different content — the cumulative diff of
1068
+ * such a pair is non-empty and must be preserved.
1069
+ *
1070
+ * This only drops patches whose `files` are a subset of the transient
1071
+ * set. A partial-transient patch (one that touches a transient file
1072
+ * alongside a persistent one) is left unmodified; surgical stripping of
1073
+ * single files from a multi-file patch would require a follow-up that
1074
+ * regenerates the patch content via `git format-patch -- <files>`. No
1075
+ * current failing test exercises this, and leaving the patch intact
1076
+ * preserves today's behaviour. Revisit if a future adversarial test
1077
+ * demands per-file stripping.
1078
+ */
1079
+ async collapseNetZeroFiles(patches) {
1080
+ if (patches.length === 0) return patches;
1081
+ const stateByFile = /* @__PURE__ */ new Map();
1082
+ for (const patch of patches) {
1083
+ for (const header of parsePatchFileHeaders(patch.patch_content)) {
1084
+ if (!header.isCreate && !header.isDelete) continue;
1085
+ const prior = stateByFile.get(header.path) ?? { created: false, deleted: false };
1086
+ if (header.isCreate) prior.created = true;
1087
+ if (header.isDelete) prior.deleted = true;
1088
+ stateByFile.set(header.path, prior);
1089
+ }
1090
+ }
1091
+ let anyCandidate = false;
1092
+ for (const state of stateByFile.values()) {
1093
+ if (state.created && state.deleted) {
1094
+ anyCandidate = true;
1095
+ break;
1096
+ }
1097
+ }
1098
+ if (!anyCandidate) return patches;
1099
+ const headFiles = await this.listHeadFiles();
1100
+ const transient = /* @__PURE__ */ new Set();
1101
+ for (const [file, state] of stateByFile) {
1102
+ if (state.created && state.deleted && !headFiles.has(file)) {
1103
+ transient.add(file);
1104
+ }
1105
+ }
1106
+ if (transient.size === 0) return patches;
1107
+ return patches.filter((patch) => !patch.files.every((f) => transient.has(f)));
1108
+ }
1109
+ /**
1110
+ * List every tracked path at HEAD (one `git ls-tree -r` invocation).
1111
+ * Returns an empty Set if HEAD is unborn or the invocation fails — the
1112
+ * caller then treats every candidate as "not in HEAD" and may collapse
1113
+ * more aggressively. On typical SDK repos this is a single sub-10ms call.
1114
+ */
1115
+ async listHeadFiles() {
1587
1116
  try {
1588
- const tempFilePath = join2(tempDir, filePath);
1589
- await mkdir(dirname2(tempFilePath), { recursive: true });
1590
- await writeFile(tempFilePath, content);
1591
- await tempGit.exec(["add", filePath]);
1592
- await tempGit.exec(["commit", "-m", `check already-applied ${filePath}`, "--allow-empty"]);
1593
- await tempGit.execWithInput(["apply", "--reverse", "--check", "--allow-empty"], fileDiff);
1594
- return true;
1117
+ const out = await this.git.exec(["ls-tree", "-r", "--name-only", "HEAD"]);
1118
+ return new Set(out.split("\n").map((l) => l.trim()).filter(Boolean));
1595
1119
  } catch {
1596
- return false;
1120
+ return /* @__PURE__ */ new Set();
1597
1121
  }
1598
1122
  }
1599
- extractFileDiff(patchContent, filePath) {
1600
- const lines = patchContent.split("\n");
1601
- const diffLines = [];
1602
- let inTargetFile = false;
1603
- for (const line of lines) {
1604
- if (line.startsWith("diff --git")) {
1605
- if (inTargetFile) {
1606
- break;
1607
- }
1608
- if (isDiffLineForFile(line, filePath)) {
1609
- inTargetFile = true;
1610
- diffLines.push(line);
1611
- }
1612
- continue;
1613
- }
1614
- if (inTargetFile) {
1615
- diffLines.push(line);
1123
+ /**
1124
+ * Detect patches via tree diff for non-linear history. Returns a composite patch.
1125
+ * Revert reconciliation is skipped here because tree-diff produces a single composite
1126
+ * patch from the aggregate diff — individual revert commits are not distinguishable.
1127
+ */
1128
+ async detectPatchesViaTreeDiff(lastGen, commitKnownMissing) {
1129
+ const diffBase = await this.resolveDiffBase(lastGen, commitKnownMissing);
1130
+ if (!diffBase) {
1131
+ return this.detectPatchesViaCommitScan();
1132
+ }
1133
+ const lockForGuard = this.lockManager.read();
1134
+ const knownGenerations = new Set(lockForGuard.generations.map((g) => g.commit_sha));
1135
+ let resolvedBaseGeneration = commitKnownMissing ? diffBase : lastGen.commit_sha;
1136
+ if (commitKnownMissing && !knownGenerations.has(diffBase)) {
1137
+ const fallback = lockForGuard.current_generation ?? lockForGuard.generations[lockForGuard.generations.length - 1]?.commit_sha;
1138
+ if (!fallback) {
1139
+ this.warnings.push(
1140
+ `Composite-patch fallback skipped: diff base ${diffBase.slice(0, 7)} is not a recorded generation and the lockfile has no recorded generations to anchor on. Customer customizations on disk are preserved; tracked patches will resume on the next regeneration.`
1141
+ );
1142
+ return { patches: [], revertedPatchIds: [] };
1616
1143
  }
1144
+ resolvedBaseGeneration = fallback;
1617
1145
  }
1618
- return diffLines.length > 0 ? diffLines.join("\n") + "\n" : null;
1146
+ const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
1147
+ const { files, sensitiveFiles } = filterInfrastructureAndSensitiveFiles(filesOutput);
1148
+ if (sensitiveFiles.length > 0) {
1149
+ this.warnings.push(
1150
+ `Sensitive file(s) excluded from composite patch: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
1151
+ );
1152
+ }
1153
+ if (files.length === 0) return { patches: [], revertedPatchIds: [] };
1154
+ const diff = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
1155
+ if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
1156
+ const contentHash = this.computeContentHash(diff);
1157
+ const lock = this.lockManager.read();
1158
+ if (lock.patches.some((p) => p.content_hash === contentHash) || (lock.forgotten_hashes ?? []).includes(contentHash)) {
1159
+ return { patches: [], revertedPatchIds: [] };
1160
+ }
1161
+ const headSha = (await this.git.exec(["rev-parse", "HEAD"])).trim();
1162
+ const theirsSnapshot = await capturePatchSnapshot(this.git, files, "HEAD");
1163
+ const compositePatch = {
1164
+ id: `patch-composite-${headSha.slice(0, 8)}`,
1165
+ content_hash: contentHash,
1166
+ original_commit: headSha,
1167
+ original_message: "Customer customizations (composite)",
1168
+ original_author: "composite",
1169
+ // Anchor `base_generation` on a SHA the applicator's
1170
+ // `resolveBaseGeneration` can find — always a member of
1171
+ // `lock.generations`. When `diffBase` is a recorded
1172
+ // generation, use it directly; otherwise the guard above
1173
+ // has already mapped it onto `current_generation`.
1174
+ base_generation: resolvedBaseGeneration,
1175
+ files,
1176
+ patch_content: diff,
1177
+ ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
1178
+ ...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
1179
+ };
1180
+ return { patches: [compositePatch], revertedPatchIds: [] };
1619
1181
  }
1620
- extractRenameSource(patchContent, targetFilePath) {
1621
- const lines = patchContent.split("\n");
1622
- let inTargetFile = false;
1623
- for (const line of lines) {
1624
- if (line.startsWith("diff --git")) {
1625
- if (inTargetFile) break;
1626
- inTargetFile = isDiffLineForFile(line, targetFilePath);
1182
+ /**
1183
+ * Last-resort detection when both generation commit and tree are unreachable.
1184
+ * Scans all commits from HEAD, filters against known lockfile patches, and
1185
+ * skips creation-only commits (squashed history after force push).
1186
+ *
1187
+ * Detected patches use the commit's parent as base_generation so the applicator
1188
+ * can find base file content from the parent's tree (which IS reachable).
1189
+ */
1190
+ async detectPatchesViaCommitScan() {
1191
+ const lock = this.lockManager.read();
1192
+ const knownGenerations = new Set(lock.generations.map((g) => g.commit_sha));
1193
+ const fallbackAnchor = lock.current_generation ?? lock.generations[lock.generations.length - 1]?.commit_sha;
1194
+ const log = await this.git.exec([
1195
+ "log",
1196
+ "--max-count=200",
1197
+ "--format=%H%x00%an%x00%ae%x00%s",
1198
+ "HEAD",
1199
+ "--",
1200
+ this.sdkOutputDir
1201
+ ]);
1202
+ if (!log.trim()) {
1203
+ return { patches: [], revertedPatchIds: [] };
1204
+ }
1205
+ const commits = this.parseGitLog(log);
1206
+ const newPatches = [];
1207
+ const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
1208
+ const existingHashes = new Set(lock.patches.map((p) => p.content_hash));
1209
+ const existingCommits = new Set(lock.patches.map((p) => p.original_commit));
1210
+ for (const commit of commits) {
1211
+ if (isGenerationCommit(commit)) {
1627
1212
  continue;
1628
1213
  }
1629
- if (!inTargetFile) continue;
1630
- if (line.startsWith("@@")) break;
1631
- if (line.startsWith("rename from ")) {
1632
- return line.slice("rename from ".length);
1214
+ const parents = await this.git.getCommitParents(commit.sha);
1215
+ if (parents.length > 1) {
1216
+ continue;
1633
1217
  }
1634
- }
1635
- return null;
1636
- }
1637
- extractNewFileFromPatch(patchContent, filePath) {
1638
- const lines = patchContent.split("\n");
1639
- const addedLines = [];
1640
- let inTargetFile = false;
1641
- let inHunk = false;
1642
- let isNewFile = false;
1643
- let noTrailingNewline = false;
1644
- for (const line of lines) {
1645
- if (line.startsWith("diff --git")) {
1646
- if (inTargetFile) break;
1647
- inTargetFile = isDiffLineForFile(line, filePath);
1648
- inHunk = false;
1649
- isNewFile = false;
1218
+ if (existingCommits.has(commit.sha)) {
1650
1219
  continue;
1651
1220
  }
1652
- if (!inTargetFile) continue;
1653
- if (!inHunk) {
1654
- if (line === "--- /dev/null" || line.startsWith("new file mode")) {
1655
- isNewFile = true;
1656
- }
1221
+ let patchContent;
1222
+ try {
1223
+ patchContent = await this.git.formatPatch(commit.sha);
1224
+ } catch {
1225
+ continue;
1657
1226
  }
1658
- if (line.startsWith("@@")) {
1659
- if (!isNewFile) return null;
1660
- inHunk = true;
1227
+ let contentHash = this.computeContentHash(patchContent);
1228
+ if (existingHashes.has(contentHash) || forgottenHashes.has(contentHash)) {
1661
1229
  continue;
1662
1230
  }
1663
- if (!inHunk) continue;
1664
- if (line === "\") {
1665
- noTrailingNewline = true;
1231
+ if (this.isCreationOnlyPatch(patchContent)) {
1666
1232
  continue;
1667
1233
  }
1668
- if (line.startsWith("+") && !line.startsWith("+++")) {
1669
- addedLines.push(line.slice(1));
1234
+ const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
1235
+ const { files, sensitiveFiles } = filterInfrastructureAndSensitiveFiles(filesOutput);
1236
+ if (sensitiveFiles.length > 0) {
1237
+ this.warnings.push(
1238
+ `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
1239
+ );
1240
+ if (files.length > 0) {
1241
+ if (parents.length === 0) {
1242
+ continue;
1243
+ }
1244
+ try {
1245
+ patchContent = await this.git.exec(["diff", parents[0], commit.sha, "--", ...files]);
1246
+ } catch {
1247
+ continue;
1248
+ }
1249
+ if (!patchContent.trim()) continue;
1250
+ contentHash = this.computeContentHash(patchContent);
1251
+ }
1670
1252
  }
1671
- }
1672
- if (addedLines.length === 0) return null;
1673
- return addedLines.join("\n") + (noTrailingNewline ? "" : "\n");
1674
- }
1675
- };
1676
- function isBinaryFile(filePath) {
1677
- const ext = extname(filePath).toLowerCase();
1678
- return BINARY_EXTENSIONS.has(ext);
1679
- }
1680
- function computeDroppedContextLinesForFile(base, theirs, finalContent) {
1253
+ if (files.length === 0) {
1254
+ continue;
1255
+ }
1256
+ if (parents.length === 0) {
1257
+ continue;
1258
+ }
1259
+ const parentSha = parents[0];
1260
+ let baseGeneration;
1261
+ if (knownGenerations.has(parentSha)) {
1262
+ baseGeneration = parentSha;
1263
+ } else if (fallbackAnchor) {
1264
+ baseGeneration = fallbackAnchor;
1265
+ } else {
1266
+ this.warnings.push(
1267
+ `Skipping commit ${commit.sha.slice(0, 7)} (${commit.message.split("\n")[0]}): no recorded generation in the lockfile to anchor on. The customer's state on disk is preserved; tracked patches will resume on the next regeneration.`
1268
+ );
1269
+ continue;
1270
+ }
1271
+ const theirsSnapshot = await capturePatchSnapshot(this.git, files, commit.sha);
1272
+ newPatches.push({
1273
+ id: `patch-${commit.sha.slice(0, 8)}`,
1274
+ content_hash: contentHash,
1275
+ original_commit: commit.sha,
1276
+ original_message: commit.message,
1277
+ original_author: `${commit.authorName} <${commit.authorEmail}>`,
1278
+ base_generation: baseGeneration,
1279
+ files,
1280
+ patch_content: patchContent,
1281
+ ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {}
1282
+ });
1283
+ }
1284
+ newPatches.reverse();
1285
+ return { patches: newPatches, revertedPatchIds: [] };
1286
+ }
1287
+ /**
1288
+ * Check if a format-patch consists entirely of new-file creations.
1289
+ * Used to identify squashed commits after force push, which create all files
1290
+ * from scratch (--- /dev/null) rather than modifying existing files.
1291
+ */
1292
+ isCreationOnlyPatch(patchContent) {
1293
+ const diffOldHeaders = patchContent.split("\n").filter((l) => l.startsWith("--- "));
1294
+ if (diffOldHeaders.length === 0) {
1295
+ return false;
1296
+ }
1297
+ return diffOldHeaders.every((l) => l === "--- /dev/null");
1298
+ }
1299
+ /**
1300
+ * Resolve the best available diff base for a generation record.
1301
+ * Prefers commit_sha, falls back to tree_hash for unreachable commits.
1302
+ * When commitKnownMissing is true, skips the redundant commitExists check.
1303
+ */
1304
+ async resolveDiffBase(gen, commitKnownMissing) {
1305
+ if (!commitKnownMissing && await this.git.commitExists(gen.commit_sha)) {
1306
+ return gen.commit_sha;
1307
+ }
1308
+ if (await this.git.treeExists(gen.tree_hash)) {
1309
+ return gen.tree_hash;
1310
+ }
1311
+ return null;
1312
+ }
1313
+ parseGitLog(log) {
1314
+ return log.trim().split("\n").map((line) => {
1315
+ const [sha, authorName, authorEmail, message] = line.split("\0");
1316
+ return { sha, authorName, authorEmail, message };
1317
+ });
1318
+ }
1319
+ getLastGeneration(lock) {
1320
+ return lock.generations.find((g) => g.commit_sha === lock.current_generation);
1321
+ }
1322
+ };
1323
+
1324
+ // src/ThreeWayMerge.ts
1325
+ import { diff3Merge, diffPatch } from "node-diff3";
1326
+ function threeWayMerge(base, ours, theirs) {
1681
1327
  const baseLines = base.split("\n");
1328
+ const oursLines = ours.split("\n");
1682
1329
  const theirsLines = theirs.split("\n");
1683
- const finalLines = finalContent.split("\n");
1684
- const baseCounts = /* @__PURE__ */ new Map();
1685
- const theirsCounts = /* @__PURE__ */ new Map();
1686
- const finalCounts = /* @__PURE__ */ new Map();
1687
- for (const l of baseLines) baseCounts.set(l, (baseCounts.get(l) ?? 0) + 1);
1688
- for (const l of theirsLines) theirsCounts.set(l, (theirsCounts.get(l) ?? 0) + 1);
1689
- for (const l of finalLines) finalCounts.set(l, (finalCounts.get(l) ?? 0) + 1);
1330
+ const regions = diff3Merge(oursLines, baseLines, theirsLines);
1331
+ const outputLines = [];
1332
+ const conflicts = [];
1333
+ let currentLine = 1;
1334
+ for (const region of regions) {
1335
+ if (region.ok) {
1336
+ outputLines.push(...region.ok);
1337
+ currentLine += region.ok.length;
1338
+ } else if (region.conflict) {
1339
+ const resolved = tryResolveConflict(
1340
+ region.conflict.a,
1341
+ // ours (generator)
1342
+ region.conflict.o,
1343
+ // base
1344
+ region.conflict.b
1345
+ // theirs (user)
1346
+ );
1347
+ if (resolved !== null) {
1348
+ outputLines.push(...resolved);
1349
+ currentLine += resolved.length;
1350
+ } else {
1351
+ const startLine = currentLine;
1352
+ outputLines.push("<<<<<<< Generated");
1353
+ outputLines.push(...region.conflict.a);
1354
+ outputLines.push("=======");
1355
+ outputLines.push(...region.conflict.b);
1356
+ outputLines.push(">>>>>>> Your customization");
1357
+ const conflictLines = region.conflict.a.length + region.conflict.b.length + 3;
1358
+ conflicts.push({
1359
+ startLine,
1360
+ endLine: startLine + conflictLines - 1,
1361
+ ours: region.conflict.a,
1362
+ theirs: region.conflict.b
1363
+ });
1364
+ currentLine += conflictLines;
1365
+ }
1366
+ }
1367
+ }
1368
+ const result = {
1369
+ content: outputLines.join("\n"),
1370
+ hasConflicts: conflicts.length > 0,
1371
+ conflicts
1372
+ };
1373
+ if (conflicts.length === 0) {
1374
+ const dropped = computeDroppedContextLines(baseLines, theirsLines, outputLines);
1375
+ if (dropped.length > 0) {
1376
+ result.droppedContextLines = dropped;
1377
+ }
1378
+ }
1379
+ return result;
1380
+ }
1381
+ function computeDroppedContextLines(baseLines, theirsLines, mergedLines) {
1382
+ const baseCounts = countLines(baseLines);
1383
+ const theirsCounts = countLines(theirsLines);
1384
+ const mergedCounts = countLines(mergedLines);
1690
1385
  const dropped = [];
1691
1386
  const seen = /* @__PURE__ */ new Set();
1692
1387
  for (const line of baseLines) {
1693
1388
  if (seen.has(line)) continue;
1694
1389
  const inBase = baseCounts.get(line) ?? 0;
1695
1390
  const inTheirs = theirsCounts.get(line) ?? 0;
1696
- const inFinal = finalCounts.get(line) ?? 0;
1697
- if (inBase > 0 && inTheirs > 0 && inFinal < Math.min(inBase, inTheirs)) {
1391
+ const inMerged = mergedCounts.get(line) ?? 0;
1392
+ if (inBase > 0 && inTheirs > 0 && inMerged < Math.min(inBase, inTheirs)) {
1698
1393
  dropped.push(line);
1699
1394
  seen.add(line);
1700
1395
  }
1701
1396
  }
1702
1397
  return dropped;
1703
1398
  }
1704
- function isDiffLineForFile(diffLine, filePath) {
1705
- const match = diffLine.match(/^diff --git a\/.+ b\/(.+)$/);
1706
- return match !== null && match[1] === filePath;
1399
+ function countLines(lines) {
1400
+ const counts = /* @__PURE__ */ new Map();
1401
+ for (const line of lines) {
1402
+ counts.set(line, (counts.get(line) ?? 0) + 1);
1403
+ }
1404
+ return counts;
1707
1405
  }
1708
-
1709
- // src/ReplayDetector.ts
1710
- var INFRASTRUCTURE_FILES = /* @__PURE__ */ new Set([".fernignore"]);
1711
- function parsePatchFileHeaders(patchContent) {
1712
- const results = [];
1713
- let currentPath = null;
1714
- let currentIsCreate = false;
1715
- let currentIsDelete = false;
1716
- const flush = () => {
1717
- if (currentPath !== null) {
1718
- results.push({ path: currentPath, isCreate: currentIsCreate, isDelete: currentIsDelete });
1719
- }
1720
- };
1721
- for (const line of patchContent.split("\n")) {
1722
- if (line.startsWith("diff --git ")) {
1723
- flush();
1724
- currentPath = null;
1725
- currentIsCreate = false;
1726
- currentIsDelete = false;
1727
- const match = line.match(/^diff --git a\/(.+?) b\/(.+?)$/);
1728
- if (match) {
1729
- currentPath = match[2] ?? null;
1406
+ function tryResolveConflict(oursLines, baseLines, theirsLines) {
1407
+ if (baseLines.length === 0) {
1408
+ return null;
1409
+ }
1410
+ const oursPatches = diffPatch(baseLines, oursLines);
1411
+ const theirsPatches = diffPatch(baseLines, theirsLines);
1412
+ if (oursPatches.length === 0) return theirsLines;
1413
+ if (theirsPatches.length === 0) return oursLines;
1414
+ if (patchesOverlap(oursPatches, theirsPatches)) {
1415
+ return null;
1416
+ }
1417
+ return applyBothPatches(baseLines, oursPatches, theirsPatches);
1418
+ }
1419
+ function patchesOverlap(oursPatches, theirsPatches) {
1420
+ for (const op of oursPatches) {
1421
+ const oStart = op.buffer1.offset;
1422
+ const oEnd = oStart + op.buffer1.length;
1423
+ for (const tp of theirsPatches) {
1424
+ const tStart = tp.buffer1.offset;
1425
+ const tEnd = tStart + tp.buffer1.length;
1426
+ if (op.buffer1.length === 0 && tp.buffer1.length === 0 && oStart === tStart) {
1427
+ return true;
1730
1428
  }
1731
- } else if (currentPath !== null) {
1732
- if (line.startsWith("new file mode ")) {
1733
- currentIsCreate = true;
1734
- } else if (line.startsWith("deleted file mode ")) {
1735
- currentIsDelete = true;
1429
+ if (tp.buffer1.length === 0 && tStart === oEnd) {
1430
+ return true;
1431
+ }
1432
+ if (op.buffer1.length === 0 && oStart === tEnd) {
1433
+ return true;
1434
+ }
1435
+ if (oStart < tEnd && tStart < oEnd) {
1436
+ return true;
1736
1437
  }
1737
1438
  }
1738
1439
  }
1739
- flush();
1740
- return results;
1440
+ return false;
1741
1441
  }
1742
- var ReplayDetector = class {
1743
- git;
1744
- lockManager;
1745
- sdkOutputDir;
1746
- warnings = [];
1747
- constructor(git, lockManager, sdkOutputDir) {
1748
- this.git = git;
1749
- this.lockManager = lockManager;
1750
- this.sdkOutputDir = sdkOutputDir;
1442
+ function applyBothPatches(baseLines, oursPatches, theirsPatches) {
1443
+ const allPatches = [
1444
+ ...oursPatches.map((p) => ({
1445
+ offset: p.buffer1.offset,
1446
+ length: p.buffer1.length,
1447
+ replacement: p.buffer2.chunk
1448
+ })),
1449
+ ...theirsPatches.map((p) => ({
1450
+ offset: p.buffer1.offset,
1451
+ length: p.buffer1.length,
1452
+ replacement: p.buffer2.chunk
1453
+ }))
1454
+ ];
1455
+ allPatches.sort((a, b) => b.offset - a.offset);
1456
+ const result = [...baseLines];
1457
+ for (const p of allPatches) {
1458
+ result.splice(p.offset, p.length, ...p.replacement);
1751
1459
  }
1752
- async detectNewPatches() {
1753
- const lock = this.lockManager.read();
1754
- const lastGen = this.getLastGeneration(lock);
1755
- if (!lastGen) {
1756
- return { patches: [], revertedPatchIds: [] };
1460
+ return result;
1461
+ }
1462
+
1463
+ // src/ReplayApplicator.ts
1464
+ import { mkdir as mkdir2, mkdtemp, readFile as readFile2, rm, unlink, writeFile as writeFile2 } from "fs/promises";
1465
+ import { tmpdir } from "os";
1466
+ import { dirname as dirname3, join as join3 } from "path";
1467
+ import { minimatch } from "minimatch";
1468
+
1469
+ // src/accumulator/MergeAccumulator.ts
1470
+ var MergeAccumulator = class {
1471
+ constructor(mode, sharedFiles) {
1472
+ this.mode = mode;
1473
+ this.sharedFiles = sharedFiles;
1474
+ }
1475
+ entries = /* @__PURE__ */ new Map();
1476
+ /** Was this file touched by 2+ patches in the current `applyPatches` invocation? */
1477
+ isShared(path) {
1478
+ return this.sharedFiles.has(path);
1479
+ }
1480
+ /** Has any prior patch already recorded a result for this path? */
1481
+ has(path) {
1482
+ return this.entries.has(path);
1483
+ }
1484
+ /** Look up the accumulated content + base generation for this path. */
1485
+ lookup(path) {
1486
+ return this.entries.get(path);
1487
+ }
1488
+ /**
1489
+ * True if any of the patch's files have a prior accumulator entry — in
1490
+ * which case the fast path (`git apply --3way`) cannot honor prior
1491
+ * patches' contributions and we must route through the slow path.
1492
+ */
1493
+ shouldSkipFastPath(patch) {
1494
+ return patch.files.some((f) => this.entries.has(f));
1495
+ }
1496
+ /** Record a successful (clean) merge result. */
1497
+ recordMerge(path, content, baseGeneration) {
1498
+ this.entries.set(path, { content, baseGeneration });
1499
+ }
1500
+ /**
1501
+ * Record a conflicted merge result. Populates only in `"resolve"` mode
1502
+ * — replay mode keeps the accumulator clean of marker-laden content
1503
+ * since markers will be stripped from disk after the apply loop.
1504
+ */
1505
+ recordConflict(path, content, baseGeneration) {
1506
+ if (this.mode === "resolve") {
1507
+ this.entries.set(path, { content, baseGeneration });
1757
1508
  }
1758
- const exists = await this.git.commitExists(lastGen.commit_sha);
1759
- if (!exists) {
1760
- this.warnings.push(
1761
- `Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to alternate detection.`
1762
- );
1763
- return this.detectPatchesViaTreeDiff(
1764
- lastGen,
1765
- /* commitKnownMissing */
1766
- true
1767
- );
1768
- }
1769
- const isAncestor = await this.git.isAncestor(lastGen.commit_sha, "HEAD");
1770
- if (!isAncestor) {
1771
- return this.detectPatchesViaMergeBase(lastGen, lock);
1772
- }
1773
- return this.detectPatchesInRange(lastGen.commit_sha, lastGen, lock);
1774
1509
  }
1775
1510
  /**
1776
- * FER-10201 Non-linear-history detection via `merge-base(prevGen, HEAD)`.
1777
- *
1778
- * After a squash-merge of a Fern-bot PR, `lastGen.commit_sha` (the previous
1779
- * `[fern-generated]`) is orphaned but still in git's object database. The
1780
- * merge-base is the commit on main from which the bot's branch was created —
1781
- * a reachable ancestor of HEAD. Walking that range and classifying each
1782
- * commit via `isGenerationCommit` mirrors the linear path and eliminates
1783
- * the bug class where pipeline-driven changes (autoversion, replay,
1784
- * generation) get bundled as customer customizations.
1511
+ * Defensive copy for test introspection. Used by invariant I2 to assert
1512
+ * that after every applied patch, the accumulator has an entry for each
1513
+ * file the patch touched (see `__tests__/invariants/i2-accumulator-correctness.ts`).
1785
1514
  *
1786
- * Falls through to the legacy composite path when no common ancestor exists
1787
- * (truly disjoint history — orphan branches with no shared root).
1515
+ * @internal
1788
1516
  */
1789
- async detectPatchesViaMergeBase(lastGen, lock) {
1790
- let mergeBase = "";
1791
- try {
1792
- mergeBase = (await this.git.exec(["merge-base", lastGen.commit_sha, "HEAD"])).trim();
1793
- } catch {
1794
- }
1795
- if (!mergeBase) {
1796
- this.warnings.push(
1797
- `No common ancestor between previous generation ${lastGen.commit_sha.slice(0, 7)} and HEAD. Falling back to composite tree-diff detection.`
1798
- );
1799
- return this.detectPatchesViaTreeDiff(
1800
- lastGen,
1801
- /* commitKnownMissing */
1802
- false
1803
- );
1517
+ snapshot() {
1518
+ const copy = /* @__PURE__ */ new Map();
1519
+ for (const [path, entry] of this.entries) {
1520
+ copy.set(path, { content: entry.content, baseGeneration: entry.baseGeneration });
1804
1521
  }
1805
- return this.detectPatchesInRange(mergeBase, lastGen, lock);
1522
+ return copy;
1806
1523
  }
1807
- /**
1808
- * FER-10201 — Walk `${rangeStart}..HEAD`, classify each commit, emit one
1809
- * `StoredPatch` per customer commit. Body shared by the linear path
1810
- * (rangeStart = lastGen.commit_sha) and the merge-base path.
1811
- *
1812
- * Patch `base_generation` is always `lastGen.commit_sha` — the
1813
- * lockfile-tracked reference the applicator uses to fetch base file
1814
- * content, regardless of where the walk actually started.
1815
- */
1816
- async detectPatchesInRange(rangeStart, lastGen, lock) {
1817
- const log = await this.git.exec([
1818
- "log",
1819
- "--first-parent",
1820
- "--format=%H%x00%an%x00%ae%x00%s",
1821
- `${rangeStart}..HEAD`,
1822
- "--",
1823
- this.sdkOutputDir
1824
- ]);
1825
- if (!log.trim()) {
1826
- return { patches: [], revertedPatchIds: [] };
1524
+ };
1525
+
1526
+ // src/applicator/resolveInputs.ts
1527
+ import { readFile } from "fs/promises";
1528
+ import { join as join2 } from "path";
1529
+ async function resolveInputs(args) {
1530
+ const {
1531
+ patch,
1532
+ filePath,
1533
+ git,
1534
+ lockManager,
1535
+ outputDir,
1536
+ isTreeReachable,
1537
+ resolveBaseGeneration,
1538
+ resolveFilePath,
1539
+ extractRenameSource,
1540
+ extractFileDiff: extractFileDiff2
1541
+ } = args;
1542
+ const baseGen = await resolveBaseGeneration(patch.base_generation);
1543
+ if (!baseGen) {
1544
+ return {
1545
+ resolvedPath: filePath,
1546
+ metadata: {
1547
+ patchId: patch.id,
1548
+ patchMessage: patch.original_message,
1549
+ baseGeneration: patch.base_generation,
1550
+ currentGeneration: lockManager.read().current_generation
1551
+ },
1552
+ base: null,
1553
+ ours: null,
1554
+ oursPath: join2(outputDir, filePath),
1555
+ renameSourcePath: void 0,
1556
+ ghostTheirs: null,
1557
+ earlyExit: { status: "skipped", reason: "base-generation-not-found" }
1558
+ };
1559
+ }
1560
+ const lock = lockManager.read();
1561
+ const currentGen = lock.generations.find((g) => g.commit_sha === lock.current_generation);
1562
+ const currentTreeHash = currentGen?.tree_hash ?? baseGen.tree_hash;
1563
+ const resolvedPath = await resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
1564
+ const metadata = {
1565
+ patchId: patch.id,
1566
+ patchMessage: patch.original_message,
1567
+ baseGeneration: patch.base_generation,
1568
+ currentGeneration: lock.current_generation
1569
+ };
1570
+ let base = await git.showFile(baseGen.tree_hash, filePath);
1571
+ let renameSourcePath;
1572
+ if (!base) {
1573
+ const renameSource = extractRenameSource(patch.patch_content, filePath);
1574
+ if (renameSource) {
1575
+ base = await git.showFile(baseGen.tree_hash, renameSource);
1576
+ renameSourcePath = renameSource;
1577
+ }
1578
+ }
1579
+ const oursPath = join2(outputDir, resolvedPath);
1580
+ const ours = await readFile(oursPath, "utf-8").catch(() => null);
1581
+ let ghostTheirs = null;
1582
+ if (!base && ours && !renameSourcePath) {
1583
+ const treeReachable = await isTreeReachable(baseGen.tree_hash);
1584
+ if (!treeReachable) {
1585
+ const fileDiff = extractFileDiff2(patch.patch_content, filePath);
1586
+ if (fileDiff) {
1587
+ const { reconstructFromGhostPatch: reconstructFromGhostPatch2 } = await Promise.resolve().then(() => (init_HybridReconstruction(), HybridReconstruction_exports));
1588
+ const result = reconstructFromGhostPatch2(fileDiff, ours);
1589
+ if (result) {
1590
+ base = result.base;
1591
+ ghostTheirs = result.theirs;
1592
+ }
1593
+ }
1827
1594
  }
1828
- const commits = this.parseGitLog(log);
1829
- const newPatches = [];
1830
- const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
1831
- for (const commit of commits) {
1832
- if (isGenerationCommit(commit)) {
1833
- continue;
1595
+ }
1596
+ return {
1597
+ resolvedPath,
1598
+ metadata,
1599
+ base,
1600
+ ours,
1601
+ oursPath,
1602
+ renameSourcePath,
1603
+ ghostTheirs
1604
+ };
1605
+ }
1606
+
1607
+ // src/conflict-utils.ts
1608
+ var CONFLICT_OPENER = "<<<<<<< Generated";
1609
+ var CONFLICT_SEPARATOR = "=======";
1610
+ var CONFLICT_CLOSER = ">>>>>>> Your customization";
1611
+ function trimCR(line) {
1612
+ return line.endsWith("\r") ? line.slice(0, -1) : line;
1613
+ }
1614
+ function findConflictRanges(lines) {
1615
+ const ranges = [];
1616
+ let i = 0;
1617
+ while (i < lines.length) {
1618
+ if (trimCR(lines[i]) === CONFLICT_OPENER) {
1619
+ let separatorIdx = -1;
1620
+ let j = i + 1;
1621
+ let found = false;
1622
+ while (j < lines.length) {
1623
+ const trimmed = trimCR(lines[j]);
1624
+ if (trimmed === CONFLICT_OPENER) {
1625
+ break;
1626
+ }
1627
+ if (separatorIdx === -1 && trimmed === CONFLICT_SEPARATOR) {
1628
+ separatorIdx = j;
1629
+ } else if (separatorIdx !== -1 && trimmed === CONFLICT_CLOSER) {
1630
+ ranges.push({ start: i, separator: separatorIdx, end: j });
1631
+ i = j;
1632
+ found = true;
1633
+ break;
1634
+ }
1635
+ j++;
1834
1636
  }
1835
- if (lock.patches.find((p) => p.original_commit === commit.sha)) {
1637
+ if (!found) {
1638
+ i++;
1836
1639
  continue;
1837
1640
  }
1838
- const parents = await this.git.getCommitParents(commit.sha);
1839
- if (parents.length > 1) {
1840
- const compositePatch = await this.createMergeCompositePatch({
1841
- mergeCommit: commit,
1842
- firstParent: parents[0],
1843
- lastGen,
1844
- lock
1845
- });
1846
- if (compositePatch) {
1847
- newPatches.push(compositePatch);
1848
- }
1641
+ }
1642
+ i++;
1643
+ }
1644
+ return ranges;
1645
+ }
1646
+ function stripConflictMarkers(content) {
1647
+ const lines = content.split(/\r?\n/);
1648
+ const ranges = findConflictRanges(lines);
1649
+ if (ranges.length === 0) {
1650
+ return content;
1651
+ }
1652
+ const result = [];
1653
+ let rangeIdx = 0;
1654
+ for (let i = 0; i < lines.length; i++) {
1655
+ if (rangeIdx < ranges.length) {
1656
+ const range = ranges[rangeIdx];
1657
+ if (i === range.start) {
1849
1658
  continue;
1850
1659
  }
1851
- let patchContent;
1852
- try {
1853
- patchContent = await this.git.formatPatch(commit.sha);
1854
- } catch {
1855
- this.warnings.push(
1856
- `Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
1857
- );
1660
+ if (i > range.start && i < range.separator) {
1661
+ result.push(lines[i]);
1858
1662
  continue;
1859
1663
  }
1860
- let contentHash = this.computeContentHash(patchContent);
1861
- if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
1664
+ if (i === range.separator) {
1862
1665
  continue;
1863
1666
  }
1864
- const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
1865
- const allFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
1866
- const sensitiveFiles = allFiles.filter((f) => isSensitiveFile(f));
1867
- const files = allFiles.filter((f) => !isSensitiveFile(f));
1868
- if (sensitiveFiles.length > 0) {
1869
- this.warnings.push(
1870
- `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
1871
- );
1872
- if (files.length > 0) {
1873
- const parentSha = parents[0];
1874
- if (parentSha) {
1875
- try {
1876
- patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
1877
- } catch {
1878
- continue;
1879
- }
1880
- if (!patchContent.trim()) continue;
1881
- contentHash = this.computeContentHash(patchContent);
1882
- }
1883
- }
1884
- }
1885
- if (files.length === 0) {
1667
+ if (i > range.separator && i < range.end) {
1886
1668
  continue;
1887
1669
  }
1888
- const theirsSnapshot = {};
1889
- for (const file of files) {
1890
- if (isBinaryFile(file)) continue;
1891
- const content = await this.git.showFile(commit.sha, file).catch(() => null);
1892
- if (content != null) theirsSnapshot[file] = content;
1670
+ if (i === range.end) {
1671
+ rangeIdx++;
1672
+ continue;
1893
1673
  }
1894
- newPatches.push({
1895
- id: `patch-${commit.sha.slice(0, 8)}`,
1896
- content_hash: contentHash,
1897
- original_commit: commit.sha,
1898
- original_message: commit.message,
1899
- original_author: `${commit.authorName} <${commit.authorEmail}>`,
1900
- base_generation: lastGen.commit_sha,
1901
- files,
1902
- patch_content: patchContent,
1903
- ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
1904
- ...this.isCreationOnlyPatch(patchContent) ? { user_owned: true } : {}
1905
- });
1906
1674
  }
1907
- const survivingPatches = await this.collapseNetZeroFiles(newPatches);
1908
- survivingPatches.reverse();
1909
- const revertedPatchIdSet = /* @__PURE__ */ new Set();
1910
- const revertIndicesToRemove = /* @__PURE__ */ new Set();
1911
- for (let i = 0; i < survivingPatches.length; i++) {
1912
- const patch = survivingPatches[i];
1913
- if (!isRevertCommit(patch.original_message)) continue;
1914
- let body = "";
1915
- try {
1916
- body = await this.git.getCommitBody(patch.original_commit);
1917
- } catch {
1918
- }
1919
- const revertedSha = parseRevertedSha(body);
1920
- const revertedMessage = parseRevertedMessage(patch.original_message);
1921
- let matchedExisting = false;
1922
- if (revertedSha) {
1923
- const existing = lock.patches.find((p) => p.original_commit === revertedSha);
1924
- if (existing) {
1925
- revertedPatchIdSet.add(existing.id);
1926
- revertIndicesToRemove.add(i);
1927
- matchedExisting = true;
1928
- }
1929
- }
1930
- if (!matchedExisting && revertedMessage) {
1931
- const existing = lock.patches.find((p) => p.original_message === revertedMessage);
1932
- if (existing) {
1933
- revertedPatchIdSet.add(existing.id);
1934
- revertIndicesToRemove.add(i);
1935
- matchedExisting = true;
1936
- }
1675
+ result.push(lines[i]);
1676
+ }
1677
+ return result.join("\n");
1678
+ }
1679
+ function hasConflictMarkerStrings(content) {
1680
+ if (content == null) return false;
1681
+ return content.includes(CONFLICT_OPENER) || content.includes(CONFLICT_CLOSER);
1682
+ }
1683
+
1684
+ // src/applicator/buildMergePlan.ts
1685
+ async function buildMergePlan(args) {
1686
+ const {
1687
+ inputs,
1688
+ patch,
1689
+ filePath,
1690
+ accumulator,
1691
+ tempGit,
1692
+ tempDir,
1693
+ applyPatchToContent: applyPatchToContent2,
1694
+ isPatchAlreadyApplied
1695
+ } = args;
1696
+ const { base, ours, resolvedPath, renameSourcePath, ghostTheirs } = inputs;
1697
+ let theirs = ghostTheirs;
1698
+ const ghostReconstructed = ghostTheirs != null;
1699
+ const snapshotForFile = patch.theirs_snapshot?.[resolvedPath] ?? patch.theirs_snapshot?.[filePath];
1700
+ const fileIsShared = accumulator.isShared(resolvedPath) || accumulator.isShared(filePath);
1701
+ const useSnapshotAsPrimary = !fileIsShared && snapshotForFile != null;
1702
+ let primarySource = ghostReconstructed ? "ghost" : "reconstructed";
1703
+ if (!ghostReconstructed) {
1704
+ if (useSnapshotAsPrimary) {
1705
+ theirs = snapshotForFile;
1706
+ primarySource = "snapshot";
1707
+ } else {
1708
+ theirs = await applyPatchToContent2(
1709
+ base,
1710
+ patch.patch_content,
1711
+ filePath,
1712
+ tempGit,
1713
+ tempDir,
1714
+ renameSourcePath
1715
+ );
1716
+ const reconstructionHasMarkers = hasConflictMarkerStrings(theirs);
1717
+ const baseHadMarkers = hasConflictMarkerStrings(base);
1718
+ if (reconstructionHasMarkers && !baseHadMarkers && snapshotForFile != null) {
1719
+ theirs = snapshotForFile;
1937
1720
  }
1938
- if (matchedExisting) continue;
1939
- let matchedNew = false;
1940
- if (revertedSha) {
1941
- const idx = survivingPatches.findIndex(
1942
- (p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_commit === revertedSha
1943
- );
1944
- if (idx !== -1) {
1945
- revertIndicesToRemove.add(i);
1946
- revertIndicesToRemove.add(idx);
1947
- matchedNew = true;
1948
- }
1721
+ }
1722
+ }
1723
+ const accumulatorEntry = accumulator.lookup(resolvedPath);
1724
+ if (theirs) {
1725
+ const theirsHasMarkers = hasConflictMarkerStrings(theirs);
1726
+ const baseHasMarkers = hasConflictMarkerStrings(base);
1727
+ if (theirsHasMarkers && !baseHasMarkers) {
1728
+ if (accumulatorEntry) {
1729
+ theirs = null;
1730
+ } else {
1731
+ return { kind: "skipped", reason: "stale-conflict-markers" };
1949
1732
  }
1950
- if (!matchedNew && revertedMessage) {
1951
- const idx = survivingPatches.findIndex(
1952
- (p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_message === revertedMessage
1953
- );
1954
- if (idx !== -1) {
1955
- revertIndicesToRemove.add(i);
1956
- revertIndicesToRemove.add(idx);
1733
+ }
1734
+ }
1735
+ let useAccumulatorAsMergeBase = false;
1736
+ if (accumulatorEntry && (theirs == null || base == null)) {
1737
+ theirs = await applyPatchToContent2(
1738
+ accumulatorEntry.content,
1739
+ patch.patch_content,
1740
+ filePath,
1741
+ tempGit,
1742
+ tempDir
1743
+ );
1744
+ if (theirs != null) {
1745
+ useAccumulatorAsMergeBase = true;
1746
+ } else if (await isPatchAlreadyApplied(
1747
+ patch.patch_content,
1748
+ filePath,
1749
+ accumulatorEntry.content,
1750
+ tempGit,
1751
+ tempDir
1752
+ )) {
1753
+ theirs = accumulatorEntry.content;
1754
+ useAccumulatorAsMergeBase = true;
1755
+ }
1756
+ }
1757
+ if (theirs) {
1758
+ const theirsHasMarkers = hasConflictMarkerStrings(theirs);
1759
+ const accBaseHasMarkers = accumulatorEntry != null && hasConflictMarkerStrings(accumulatorEntry.content);
1760
+ if (theirsHasMarkers && !accBaseHasMarkers && !hasConflictMarkerStrings(base)) {
1761
+ return { kind: "skipped", reason: "stale-conflict-markers" };
1762
+ }
1763
+ }
1764
+ let effective_theirs = theirs;
1765
+ let conflictReasonHint;
1766
+ if (theirs != null && base != null && !useAccumulatorAsMergeBase) {
1767
+ if (accumulatorEntry && accumulatorEntry.baseGeneration === patch.base_generation) {
1768
+ try {
1769
+ const preMerged = threeWayMerge(base, accumulatorEntry.content, theirs);
1770
+ if (!preMerged.hasConflicts) {
1771
+ effective_theirs = preMerged.content;
1772
+ } else {
1773
+ effective_theirs = theirs;
1957
1774
  }
1775
+ } catch {
1776
+ effective_theirs = theirs;
1958
1777
  }
1959
- if (!matchedExisting && !matchedNew) {
1960
- revertIndicesToRemove.add(i);
1961
- }
1778
+ } else if (accumulatorEntry) {
1779
+ conflictReasonHint = "base-generation-mismatch";
1962
1780
  }
1963
- const filteredPatches = survivingPatches.filter((_, i) => !revertIndicesToRemove.has(i));
1964
- return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
1965
1781
  }
1966
- /**
1967
- * Compute content hash for deduplication.
1968
- *
1969
- * Produces a format-agnostic hash so both `git format-patch` output
1970
- * (email-wrapped) and plain `git diff` output hash to the same value
1971
- * when the underlying diff hunks are identical.
1972
- *
1973
- * Normalization:
1974
- * 1. Strip email wrapper: everything before the first `diff --git` line
1975
- * (From:, Subject:, Date:, diffstat, blank separators).
1976
- * 2. Strip `index` lines (blob SHA pairs change across rebases).
1977
- * 3. Strip trailing git version marker (`-- \n<version>`).
1782
+ if (base == null && ours == null && effective_theirs != null) {
1783
+ return { kind: "new-file-only-user", theirs: effective_theirs };
1784
+ }
1785
+ if (base == null && ours != null && effective_theirs != null && !useAccumulatorAsMergeBase) {
1786
+ return { kind: "new-file-both", theirs: effective_theirs };
1787
+ }
1788
+ if (effective_theirs == null) {
1789
+ return { kind: "skipped", reason: "missing-content" };
1790
+ }
1791
+ if (base == null && !useAccumulatorAsMergeBase || ours == null) {
1792
+ return { kind: "skipped", reason: "missing-content" };
1793
+ }
1794
+ if (useAccumulatorAsMergeBase) {
1795
+ const mergeBase = accumulatorEntry?.content ?? base;
1796
+ if (mergeBase == null) {
1797
+ return { kind: "skipped", reason: "missing-content" };
1798
+ }
1799
+ return {
1800
+ kind: "accumulator-base",
1801
+ theirs: effective_theirs,
1802
+ mergeBase,
1803
+ conflictReasonHint
1804
+ };
1805
+ }
1806
+ return {
1807
+ kind: primarySource,
1808
+ base,
1809
+ theirs: effective_theirs,
1810
+ mergeBase: base,
1811
+ conflictReasonHint
1812
+ };
1813
+ }
1814
+
1815
+ // src/applicator/runMergeAndRecord.ts
1816
+ import { mkdir, writeFile } from "fs/promises";
1817
+ import { dirname as dirname2 } from "path";
1818
+ async function runMergeAndRecord(args) {
1819
+ const { plan, inputs, patch, accumulator } = args;
1820
+ const { resolvedPath, metadata, oursPath, ours } = inputs;
1821
+ if (plan.kind === "skipped") {
1822
+ return { file: resolvedPath, status: "skipped", reason: plan.reason };
1823
+ }
1824
+ if (plan.kind === "new-file-only-user") {
1825
+ accumulator.recordMerge(resolvedPath, plan.theirs, patch.base_generation);
1826
+ await mkdir(dirname2(oursPath), { recursive: true });
1827
+ await writeFile(oursPath, plan.theirs);
1828
+ return { file: resolvedPath, status: "merged", reason: "new-file" };
1829
+ }
1830
+ if (plan.kind === "new-file-both") {
1831
+ const merged2 = threeWayMerge("", ours, plan.theirs);
1832
+ await mkdir(dirname2(oursPath), { recursive: true });
1833
+ await writeFile(oursPath, merged2.content);
1834
+ if (merged2.hasConflicts) {
1835
+ return {
1836
+ file: resolvedPath,
1837
+ status: "conflict",
1838
+ conflicts: merged2.conflicts,
1839
+ conflictReason: "new-file-both",
1840
+ conflictMetadata: metadata
1841
+ };
1842
+ }
1843
+ accumulator.recordMerge(resolvedPath, merged2.content, patch.base_generation);
1844
+ return { file: resolvedPath, status: "merged" };
1845
+ }
1846
+ const merged = threeWayMerge(plan.mergeBase, ours, plan.theirs);
1847
+ await mkdir(dirname2(oursPath), { recursive: true });
1848
+ await writeFile(oursPath, merged.content);
1849
+ if (merged.hasConflicts) {
1850
+ accumulator.recordConflict(resolvedPath, plan.theirs, patch.base_generation);
1851
+ return {
1852
+ file: resolvedPath,
1853
+ status: "conflict",
1854
+ conflicts: merged.conflicts,
1855
+ conflictReason: plan.conflictReasonHint ?? "same-line-edit",
1856
+ conflictMetadata: metadata
1857
+ };
1858
+ }
1859
+ accumulator.recordMerge(resolvedPath, plan.theirs, patch.base_generation);
1860
+ return {
1861
+ file: resolvedPath,
1862
+ status: "merged",
1863
+ ...merged.droppedContextLines && merged.droppedContextLines.length > 0 ? { droppedContextLines: merged.droppedContextLines } : {}
1864
+ };
1865
+ }
1866
+
1867
+ // src/ReplayApplicator.ts
1868
+ var ReplayApplicator = class {
1869
+ git;
1870
+ lockManager;
1871
+ outputDir;
1872
+ renameCache = /* @__PURE__ */ new Map();
1873
+ treeExistsCache = /* @__PURE__ */ new Map();
1874
+ /**
1875
+ * Inter-patch THEIRS coordination. Created fresh in each `applyPatches`
1876
+ * invocation with the run's apply mode + precomputed shared-file set.
1877
+ * See `src/accumulator/MergeAccumulator.ts` for the full contract.
1978
1878
  *
1979
- * This ensures content hashes match across the no-patches and normal-
1980
- * regeneration flows, which store formatPatch and git-diff formats
1981
- * respectively (FER-9850, D5-D9).
1879
+ * Initialized to an empty default in the constructor so type narrowing
1880
+ * works for callers that read it before any `applyPatches` invocation
1881
+ * (e.g., the `getAccumulatorSnapshot` test accessor).
1982
1882
  */
1983
- computeContentHash(patchContent) {
1984
- const lines = patchContent.split("\n");
1985
- const diffStart = lines.findIndex((l) => l.startsWith("diff --git "));
1986
- const relevantLines = diffStart > 0 ? lines.slice(diffStart) : lines;
1987
- const normalized = relevantLines.filter((line) => !line.startsWith("index ")).join("\n").replace(/\n-- \n[\d]+\.[\d]+[^\n]*\s*$/, "");
1988
- return `sha256:${createHash("sha256").update(normalized).digest("hex")}`;
1883
+ accumulator = new MergeAccumulator("replay", /* @__PURE__ */ new Set());
1884
+ constructor(git, lockManager, outputDir) {
1885
+ this.git = git;
1886
+ this.lockManager = lockManager;
1887
+ this.outputDir = outputDir;
1989
1888
  }
1990
1889
  /**
1991
- * FER-9805 Drop patches whose files were transiently created and then
1992
- * deleted within the current linear detection window, and are absent from
1993
- * HEAD at the end of the window.
1994
- *
1995
- * Rationale: on a merge commit, createMergeCompositePatch already diffs
1996
- * firstParent..mergeCommit so net-zero files never enter the composite. On
1997
- * linear history each commit becomes its own patch, so a file that was
1998
- * briefly added and later removed survives as a create+delete pair whose
1999
- * cumulative diff is empty. We drop such patches before they reach the
2000
- * lockfile.
2001
- *
2002
- * A file is "transient" when:
2003
- * 1. some patch in the window sets `new file mode` for it (a creation), AND
2004
- * 2. some patch in the window sets `deleted file mode` for it (a deletion), AND
2005
- * 3. it is absent from HEAD's tree.
2006
- *
2007
- * The HEAD guard (#3) prevents false positives when a file is deleted
2008
- * and then recreated with different content — the cumulative diff of
2009
- * such a pair is non-empty and must be preserved.
1890
+ * Resolve the GenerationRecord for a patch's base_generation.
1891
+ * Falls back to constructing an ad-hoc record from the commit's tree
1892
+ * when base_generation isn't a tracked generation (commit-scan patches).
1893
+ */
1894
+ async resolveBaseGeneration(baseGeneration) {
1895
+ const gen = this.lockManager.getGeneration(baseGeneration);
1896
+ if (gen) return gen;
1897
+ try {
1898
+ const treeHash = await this.git.getTreeHash(baseGeneration);
1899
+ return {
1900
+ commit_sha: baseGeneration,
1901
+ tree_hash: treeHash,
1902
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1903
+ cli_version: "unknown",
1904
+ generator_versions: {}
1905
+ };
1906
+ } catch {
1907
+ return void 0;
1908
+ }
1909
+ }
1910
+ /**
1911
+ * @internal Test-only.
1912
+ * Returns a defensive copy of the inter-patch accumulator. Used by
1913
+ * invariant I2 to assert that after every applied patch, the
1914
+ * accumulator has an entry for each file the patch touched, and the
1915
+ * entry's content matches on-disk.
1916
+ */
1917
+ getAccumulatorSnapshot() {
1918
+ return this.accumulator.snapshot();
1919
+ }
1920
+ /**
1921
+ * Apply all patches, returning results for each.
1922
+ * Skips patches that match exclude patterns in replay.yml
2010
1923
  *
2011
- * This only drops patches whose `files` are a subset of the transient
2012
- * set. A partial-transient patch (one that touches a transient file
2013
- * alongside a persistent one) is left unmodified; surgical stripping of
2014
- * single files from a multi-file patch would require a follow-up that
2015
- * regenerates the patch content via `git format-patch -- <files>`. No
2016
- * current failing test exercises this, and leaving the patch intact
2017
- * preserves today's behaviour. TODO(FER-9805): revisit if a future
2018
- * adversarial test demands per-file stripping.
1924
+ * `applyMode` controls the post-conflict marker strategy:
1925
+ * - `"replay"` (default): strip conflict markers from disk between
1926
+ * iterations whenever a later patch touches the same file. Lets the
1927
+ * follow-up patch's `git apply --3way` see clean OURS content,
1928
+ * which is what the silent-loss fix (PR #73) relies on. The replay
1929
+ * pipeline calls `revertConflictingFiles` after the loop to clean
1930
+ * any markers that survive.
1931
+ * - `"resolve"`: keep markers on disk. The resolve command needs the
1932
+ * customer to see them. Slow-path 3-way merge naturally preserves
1933
+ * A's markers and B's clean writes when their regions don't overlap;
1934
+ * when they do overlap, the customer gets nested markers and
1935
+ * resolves manually. Either way they aren't silently dropped.
2019
1936
  */
2020
- async collapseNetZeroFiles(patches) {
2021
- if (patches.length === 0) return patches;
2022
- const stateByFile = /* @__PURE__ */ new Map();
2023
- for (const patch of patches) {
2024
- for (const header of parsePatchFileHeaders(patch.patch_content)) {
2025
- if (!header.isCreate && !header.isDelete) continue;
2026
- const prior = stateByFile.get(header.path) ?? { created: false, deleted: false };
2027
- if (header.isCreate) prior.created = true;
2028
- if (header.isDelete) prior.deleted = true;
2029
- stateByFile.set(header.path, prior);
1937
+ async applyPatches(patches, opts) {
1938
+ const applyMode = opts?.applyMode ?? "replay";
1939
+ const fileFreq = /* @__PURE__ */ new Map();
1940
+ for (const p of patches) {
1941
+ for (const f of p.files) {
1942
+ fileFreq.set(f, (fileFreq.get(f) ?? 0) + 1);
2030
1943
  }
2031
1944
  }
2032
- let anyCandidate = false;
2033
- for (const state of stateByFile.values()) {
2034
- if (state.created && state.deleted) {
2035
- anyCandidate = true;
2036
- break;
1945
+ const sharedFiles = new Set(
1946
+ Array.from(fileFreq.entries()).filter(([, n]) => n > 1).map(([f]) => f)
1947
+ );
1948
+ this.accumulator = new MergeAccumulator(applyMode, sharedFiles);
1949
+ const results = [];
1950
+ for (let i = 0; i < patches.length; i++) {
1951
+ const patch = patches[i];
1952
+ if (this.isExcluded(patch)) {
1953
+ results.push({
1954
+ patch,
1955
+ status: "skipped",
1956
+ method: "git-am"
1957
+ });
1958
+ continue;
1959
+ }
1960
+ const result = await this.applyPatchWithFallback(patch);
1961
+ results.push(result);
1962
+ if (applyMode !== "resolve" && result.status === "conflict" && result.fileResults) {
1963
+ const laterFiles = /* @__PURE__ */ new Set();
1964
+ for (let j = i + 1; j < patches.length; j++) {
1965
+ for (const f of patches[j].files) {
1966
+ laterFiles.add(f);
1967
+ }
1968
+ }
1969
+ const resolvedToOriginal = /* @__PURE__ */ new Map();
1970
+ if (result.resolvedFiles) {
1971
+ for (const [orig, resolved] of Object.entries(result.resolvedFiles)) {
1972
+ resolvedToOriginal.set(resolved, orig);
1973
+ }
1974
+ }
1975
+ for (const fileResult of result.fileResults) {
1976
+ if (fileResult.status !== "conflict") continue;
1977
+ const originalPath = resolvedToOriginal.get(fileResult.file) ?? fileResult.file;
1978
+ if (laterFiles.has(fileResult.file) || laterFiles.has(originalPath)) {
1979
+ const filePath = join3(this.outputDir, fileResult.file);
1980
+ try {
1981
+ const content = await readFile2(filePath, "utf-8");
1982
+ const stripped = stripConflictMarkers(content);
1983
+ await writeFile2(filePath, stripped);
1984
+ } catch {
1985
+ }
1986
+ }
1987
+ }
2037
1988
  }
2038
1989
  }
2039
- if (!anyCandidate) return patches;
2040
- const headFiles = await this.listHeadFiles();
2041
- const transient = /* @__PURE__ */ new Set();
2042
- for (const [file, state] of stateByFile) {
2043
- if (state.created && state.deleted && !headFiles.has(file)) {
2044
- transient.add(file);
1990
+ return results;
1991
+ }
1992
+ /**
1993
+ * Populate accumulator after git apply succeeds, AND collect per-file
1994
+ * results including any "dropped context lines" — lines that were
1995
+ * present in BASE and THEIRS (unchanged context) but absent from the
1996
+ * final on-disk state because OURS deleted them. This is the fast-path
1997
+ * counterpart to ThreeWayMerge.computeDroppedContextLines used by the
1998
+ * 3-way slow path; both must surface the same warning.
1999
+ */
2000
+ async populateAccumulatorForPatch(patch, baseGen, currentTreeHash) {
2001
+ const fileResults = [];
2002
+ if (!baseGen) return fileResults;
2003
+ const tempDir = await mkdtemp(join3(tmpdir(), "replay-acc-"));
2004
+ const { GitClient: GitClient2 } = await Promise.resolve().then(() => (init_GitClient(), GitClient_exports));
2005
+ const tempGit = new GitClient2(tempDir);
2006
+ await tempGit.exec(["init"]);
2007
+ await tempGit.exec(["config", "user.email", "replay@fern.com"]);
2008
+ await tempGit.exec(["config", "user.name", "Fern Replay"]);
2009
+ try {
2010
+ for (const filePath of patch.files) {
2011
+ if (isBinaryFile(filePath)) continue;
2012
+ const resolvedPath = await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
2013
+ const base = await this.git.showFile(baseGen.tree_hash, filePath);
2014
+ const snapshotForFile = patch.theirs_snapshot?.[resolvedPath] ?? patch.theirs_snapshot?.[filePath];
2015
+ const fileIsShared = this.accumulator.isShared(resolvedPath) || this.accumulator.isShared(filePath);
2016
+ const theirs = !fileIsShared && snapshotForFile != null ? snapshotForFile : await this.applyPatchToContent(base, patch.patch_content, filePath, tempGit, tempDir);
2017
+ const finalOnDisk = await readFile2(join3(this.outputDir, resolvedPath), "utf-8").catch(() => null);
2018
+ const effectiveTheirs = theirs ?? finalOnDisk;
2019
+ if (effectiveTheirs != null) {
2020
+ this.accumulator.recordMerge(resolvedPath, effectiveTheirs, patch.base_generation);
2021
+ }
2022
+ if (base != null && effectiveTheirs != null && finalOnDisk != null) {
2023
+ const dropped = computeDroppedContextLinesForFile(base, effectiveTheirs, finalOnDisk);
2024
+ if (dropped.length > 0) {
2025
+ fileResults.push({
2026
+ file: resolvedPath,
2027
+ status: "merged",
2028
+ droppedContextLines: dropped
2029
+ });
2030
+ }
2031
+ }
2032
+ }
2033
+ } finally {
2034
+ await rm(tempDir, { recursive: true }).catch(() => {
2035
+ });
2036
+ }
2037
+ return fileResults;
2038
+ }
2039
+ async applyPatchWithFallback(patch) {
2040
+ const baseGen = await this.resolveBaseGeneration(patch.base_generation);
2041
+ const lock = this.lockManager.read();
2042
+ const currentGen = lock.generations.find((g) => g.commit_sha === lock.current_generation);
2043
+ const currentTreeHash = currentGen?.tree_hash ?? baseGen?.tree_hash ?? "";
2044
+ const needsAccumulation = await Promise.all(
2045
+ patch.files.map(async (f) => {
2046
+ if (!baseGen) return false;
2047
+ const resolved = await this.resolveFilePath(f, baseGen.tree_hash, currentTreeHash);
2048
+ return this.accumulator.has(resolved);
2049
+ })
2050
+ ).then((results) => results.some(Boolean));
2051
+ const resolvedFiles = {};
2052
+ for (const filePath of patch.files) {
2053
+ const resolvedPath = baseGen ? await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash) : filePath;
2054
+ if (resolvedPath !== filePath) {
2055
+ resolvedFiles[filePath] = resolvedPath;
2056
+ }
2057
+ }
2058
+ const patchIsRenameAware = /^rename from /m.test(patch.patch_content);
2059
+ const hasGeneratorRename = Object.keys(resolvedFiles).length > 0 && !patchIsRenameAware;
2060
+ if (!needsAccumulation && !hasGeneratorRename) {
2061
+ const snapshots = /* @__PURE__ */ new Map();
2062
+ for (const filePath of patch.files) {
2063
+ const fullPath = join3(this.outputDir, filePath);
2064
+ snapshots.set(filePath, await readFile2(fullPath, "utf-8").catch(() => null));
2065
+ }
2066
+ try {
2067
+ await this.git.execWithInput(["apply", "--3way"], patch.patch_content);
2068
+ const fastPathFileResults = await this.populateAccumulatorForPatch(patch, baseGen, currentTreeHash);
2069
+ return {
2070
+ patch,
2071
+ status: "applied",
2072
+ method: "git-am",
2073
+ ...fastPathFileResults.length > 0 && { fileResults: fastPathFileResults },
2074
+ ...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
2075
+ };
2076
+ } catch {
2077
+ for (const [filePath, content] of snapshots) {
2078
+ const fullPath = join3(this.outputDir, filePath);
2079
+ if (content != null) {
2080
+ await writeFile2(fullPath, content);
2081
+ } else {
2082
+ await unlink(fullPath).catch(() => {
2083
+ });
2084
+ }
2085
+ }
2086
+ }
2087
+ }
2088
+ return this.applyWithThreeWayMerge(patch);
2089
+ }
2090
+ async applyWithThreeWayMerge(patch) {
2091
+ const fileResults = [];
2092
+ const resolvedFiles = {};
2093
+ const tempDir = await mkdtemp(join3(tmpdir(), "replay-"));
2094
+ const { GitClient: GitClient2 } = await Promise.resolve().then(() => (init_GitClient(), GitClient_exports));
2095
+ const tempGit = new GitClient2(tempDir);
2096
+ await tempGit.exec(["init"]);
2097
+ await tempGit.exec(["config", "user.email", "replay@fern.com"]);
2098
+ await tempGit.exec(["config", "user.name", "Fern Replay"]);
2099
+ try {
2100
+ for (const filePath of patch.files) {
2101
+ if (isBinaryFile(filePath)) {
2102
+ fileResults.push({
2103
+ file: filePath,
2104
+ status: "skipped",
2105
+ reason: "binary-file"
2106
+ });
2107
+ continue;
2108
+ }
2109
+ const result = await this.mergeFile(patch, filePath, tempGit, tempDir);
2110
+ if (result.file !== filePath) {
2111
+ resolvedFiles[filePath] = result.file;
2112
+ }
2113
+ fileResults.push(result);
2114
+ }
2115
+ } finally {
2116
+ await rm(tempDir, { recursive: true }).catch(() => {
2117
+ });
2118
+ }
2119
+ const conflictFiles = fileResults.filter((r) => r.status === "conflict");
2120
+ const hasConflicts = conflictFiles.length > 0;
2121
+ const conflictReason = hasConflicts ? conflictFiles.some((f) => f.conflictReason === "base-generation-mismatch") ? "base-generation-mismatch" : conflictFiles[0]?.conflictReason : void 0;
2122
+ return {
2123
+ patch,
2124
+ status: hasConflicts ? "conflict" : "applied",
2125
+ method: "3way-merge",
2126
+ fileResults,
2127
+ conflictReason,
2128
+ ...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
2129
+ };
2130
+ }
2131
+ async mergeFile(patch, filePath, tempGit, tempDir) {
2132
+ try {
2133
+ const inputs = await resolveInputs({
2134
+ patch,
2135
+ filePath,
2136
+ git: this.git,
2137
+ lockManager: this.lockManager,
2138
+ outputDir: this.outputDir,
2139
+ isTreeReachable: (h) => this.isTreeReachable(h),
2140
+ resolveBaseGeneration: (s) => this.resolveBaseGeneration(s),
2141
+ resolveFilePath: (f, b, c) => this.resolveFilePath(f, b, c),
2142
+ extractRenameSource: (p, f) => this.extractRenameSource(p, f),
2143
+ extractFileDiff: (p, f) => this.extractFileDiff(p, f)
2144
+ });
2145
+ if (inputs.earlyExit) {
2146
+ return { file: filePath, ...inputs.earlyExit };
2045
2147
  }
2148
+ const plan = await buildMergePlan({
2149
+ inputs,
2150
+ patch,
2151
+ filePath,
2152
+ accumulator: this.accumulator,
2153
+ tempGit,
2154
+ tempDir,
2155
+ applyPatchToContent: (b, p, f, g, d, s) => this.applyPatchToContent(b, p, f, g, d, s),
2156
+ isPatchAlreadyApplied: (p, f, c, g, d) => this.isPatchAlreadyApplied(p, f, c, g, d)
2157
+ });
2158
+ return await runMergeAndRecord({
2159
+ plan,
2160
+ inputs,
2161
+ patch,
2162
+ accumulator: this.accumulator
2163
+ });
2164
+ } catch (error) {
2165
+ return {
2166
+ file: filePath,
2167
+ status: "skipped",
2168
+ reason: `error: ${error instanceof Error ? error.message : String(error)}`
2169
+ };
2046
2170
  }
2047
- if (transient.size === 0) return patches;
2048
- return patches.filter((patch) => !patch.files.every((f) => transient.has(f)));
2049
2171
  }
2050
- /**
2051
- * List every tracked path at HEAD (one `git ls-tree -r` invocation).
2052
- * Returns an empty Set if HEAD is unborn or the invocation fails — the
2053
- * caller then treats every candidate as "not in HEAD" and may collapse
2054
- * more aggressively. On typical SDK repos this is a single sub-10ms call.
2055
- */
2056
- async listHeadFiles() {
2057
- try {
2058
- const out = await this.git.exec(["ls-tree", "-r", "--name-only", "HEAD"]);
2059
- return new Set(out.split("\n").map((l) => l.trim()).filter(Boolean));
2060
- } catch {
2061
- return /* @__PURE__ */ new Set();
2172
+ async isTreeReachable(treeHash) {
2173
+ let result = this.treeExistsCache.get(treeHash);
2174
+ if (result === void 0) {
2175
+ result = await this.git.treeExists(treeHash);
2176
+ this.treeExistsCache.set(treeHash, result);
2062
2177
  }
2178
+ return result;
2063
2179
  }
2064
- /**
2065
- * Create a single composite patch for a merge commit by diffing the merge
2066
- * commit against its first parent. This captures the net change of the
2067
- * entire merged branch as one patch, eliminating accumulator chaining and
2068
- * zero-net-change ghost conflicts.
2069
- */
2070
- async createMergeCompositePatch(input) {
2071
- const { mergeCommit, firstParent, lastGen, lock } = input;
2072
- const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
2073
- const filesOutput = await this.git.exec([
2074
- "diff",
2075
- "--name-only",
2076
- firstParent,
2077
- mergeCommit.sha
2078
- ]);
2079
- const allMergeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
2080
- const sensitiveMergeFiles = allMergeFiles.filter((f) => isSensitiveFile(f));
2081
- const files = allMergeFiles.filter((f) => !isSensitiveFile(f));
2082
- if (sensitiveMergeFiles.length > 0) {
2083
- this.warnings.push(
2084
- `Sensitive file(s) excluded from merge patch for commit ${mergeCommit.sha.slice(0, 7)}: ${sensitiveMergeFiles.join(", ")}. These files will not be tracked by replay.`
2085
- );
2180
+ isExcluded(patch) {
2181
+ const config = this.lockManager.getCustomizationsConfig();
2182
+ if (!config.exclude) return false;
2183
+ return patch.files.some((file) => config.exclude.some((pattern) => minimatch(file, pattern)));
2184
+ }
2185
+ async resolveFilePath(filePath, baseTreeHash, currentTreeHash) {
2186
+ const config = this.lockManager.getCustomizationsConfig();
2187
+ if (config.moves) {
2188
+ for (const move of config.moves) {
2189
+ if (minimatch(filePath, move.from) || filePath === move.from) {
2190
+ if (filePath === move.from) {
2191
+ return move.to;
2192
+ }
2193
+ const fromBase = move.from.replace(/\*\*.*$/, "");
2194
+ const toBase = move.to.replace(/\*\*.*$/, "");
2195
+ if (filePath.startsWith(fromBase)) {
2196
+ return toBase + filePath.slice(fromBase.length);
2197
+ }
2198
+ }
2199
+ }
2086
2200
  }
2087
- if (files.length === 0) return null;
2088
- const diff = await this.git.exec([
2089
- "diff",
2090
- firstParent,
2091
- mergeCommit.sha,
2092
- "--",
2093
- ...files
2094
- ]);
2095
- if (!diff.trim()) return null;
2096
- const contentHash = this.computeContentHash(diff);
2097
- if (lock.patches.some((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
2098
- return null;
2201
+ const cacheKey = `${baseTreeHash}:${currentTreeHash}`;
2202
+ let renames = this.renameCache.get(cacheKey);
2203
+ if (!renames) {
2204
+ renames = await this.git.detectRenames(baseTreeHash, currentTreeHash);
2205
+ this.renameCache.set(cacheKey, renames);
2099
2206
  }
2100
- const theirsSnapshot = {};
2101
- for (const file of files) {
2102
- if (isBinaryFile(file)) continue;
2103
- const content = await this.git.showFile(mergeCommit.sha, file).catch(() => null);
2104
- if (content != null) theirsSnapshot[file] = content;
2207
+ const gitRename = renames.find((r) => r.from === filePath);
2208
+ if (gitRename) {
2209
+ return gitRename.to;
2105
2210
  }
2106
- return {
2107
- id: `patch-merge-${mergeCommit.sha.slice(0, 8)}`,
2108
- content_hash: contentHash,
2109
- original_commit: mergeCommit.sha,
2110
- original_message: mergeCommit.message,
2111
- original_author: `${mergeCommit.authorName} <${mergeCommit.authorEmail}>`,
2112
- base_generation: lastGen.commit_sha,
2113
- files,
2114
- patch_content: diff,
2115
- ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
2116
- ...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
2117
- };
2211
+ return filePath;
2118
2212
  }
2119
- /**
2120
- * Detect patches via tree diff for non-linear history. Returns a composite patch.
2121
- * Revert reconciliation is skipped here because tree-diff produces a single composite
2122
- * patch from the aggregate diff — individual revert commits are not distinguishable.
2123
- */
2124
- async detectPatchesViaTreeDiff(lastGen, commitKnownMissing) {
2125
- const diffBase = await this.resolveDiffBase(lastGen, commitKnownMissing);
2126
- if (!diffBase) {
2127
- return this.detectPatchesViaCommitScan();
2128
- }
2129
- const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
2130
- const allTreeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
2131
- const sensitiveTreeFiles = allTreeFiles.filter((f) => isSensitiveFile(f));
2132
- const files = allTreeFiles.filter((f) => !isSensitiveFile(f));
2133
- if (sensitiveTreeFiles.length > 0) {
2134
- this.warnings.push(
2135
- `Sensitive file(s) excluded from composite patch: ${sensitiveTreeFiles.join(", ")}. These files will not be tracked by replay.`
2136
- );
2137
- }
2138
- if (files.length === 0) return { patches: [], revertedPatchIds: [] };
2139
- const diff = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
2140
- if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
2141
- const contentHash = this.computeContentHash(diff);
2142
- const lock = this.lockManager.read();
2143
- if (lock.patches.some((p) => p.content_hash === contentHash) || (lock.forgotten_hashes ?? []).includes(contentHash)) {
2144
- return { patches: [], revertedPatchIds: [] };
2213
+ async applyPatchToContent(base, patchContent, filePath, tempGit, tempDir, sourceFilePath) {
2214
+ if (!base) {
2215
+ return this.extractNewFileFromPatch(patchContent, filePath);
2145
2216
  }
2146
- const headSha = (await this.git.exec(["rev-parse", "HEAD"])).trim();
2147
- const theirsSnapshot = {};
2148
- for (const file of files) {
2149
- if (isBinaryFile(file)) continue;
2150
- const content = await this.git.showFile("HEAD", file).catch(() => null);
2151
- if (content != null) theirsSnapshot[file] = content;
2217
+ const fileDiff = this.extractFileDiff(patchContent, filePath);
2218
+ if (!fileDiff) return null;
2219
+ try {
2220
+ if (sourceFilePath) {
2221
+ const tempSourcePath = join3(tempDir, sourceFilePath);
2222
+ await mkdir2(dirname3(tempSourcePath), { recursive: true });
2223
+ await writeFile2(tempSourcePath, base);
2224
+ await tempGit.exec(["add", sourceFilePath]);
2225
+ await tempGit.exec([
2226
+ "commit",
2227
+ "-m",
2228
+ `base for rename ${sourceFilePath} -> ${filePath}`,
2229
+ "--allow-empty"
2230
+ ]);
2231
+ await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
2232
+ const tempTargetPath = join3(tempDir, filePath);
2233
+ return await readFile2(tempTargetPath, "utf-8");
2234
+ }
2235
+ const tempFilePath = join3(tempDir, filePath);
2236
+ await mkdir2(dirname3(tempFilePath), { recursive: true });
2237
+ await writeFile2(tempFilePath, base);
2238
+ await tempGit.exec(["add", filePath]);
2239
+ await tempGit.exec(["commit", "-m", `base for ${filePath}`, "--allow-empty"]);
2240
+ await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
2241
+ return await readFile2(tempFilePath, "utf-8");
2242
+ } catch {
2243
+ return null;
2152
2244
  }
2153
- const compositePatch = {
2154
- id: `patch-composite-${headSha.slice(0, 8)}`,
2155
- content_hash: contentHash,
2156
- original_commit: headSha,
2157
- original_message: "Customer customizations (composite)",
2158
- original_author: "composite",
2159
- // Use diffBase when commit is unreachable — the applicator needs a reachable
2160
- // reference to find base file content. diffBase may be the tree_hash.
2161
- base_generation: commitKnownMissing ? diffBase : lastGen.commit_sha,
2162
- files,
2163
- patch_content: diff,
2164
- ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
2165
- ...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
2166
- };
2167
- return { patches: [compositePatch], revertedPatchIds: [] };
2168
2245
  }
2169
2246
  /**
2170
- * Last-resort detection when both generation commit and tree are unreachable.
2171
- * Scans all commits from HEAD, filters against known lockfile patches, and
2172
- * skips creation-only commits (squashed history after force push).
2173
- *
2174
- * Detected patches use the commit's parent as base_generation so the applicator
2175
- * can find base file content from the parent's tree (which IS reachable).
2247
+ * Detects whether a patch's additions are already present in `content`.
2248
+ * Uses `git apply --reverse --check` if the reverse patch applies cleanly,
2249
+ * the forward patch is effectively a no-op (its +lines are already there and
2250
+ * its -lines are already gone). Used to distinguish "patch already applied"
2251
+ * from "patch base mismatch" after a forward-apply fails.
2176
2252
  */
2177
- async detectPatchesViaCommitScan() {
2178
- const lock = this.lockManager.read();
2179
- const log = await this.git.exec([
2180
- "log",
2181
- "--max-count=200",
2182
- "--format=%H%x00%an%x00%ae%x00%s",
2183
- "HEAD",
2184
- "--",
2185
- this.sdkOutputDir
2186
- ]);
2187
- if (!log.trim()) {
2188
- return { patches: [], revertedPatchIds: [] };
2253
+ async isPatchAlreadyApplied(patchContent, filePath, content, tempGit, tempDir) {
2254
+ const fileDiff = this.extractFileDiff(patchContent, filePath);
2255
+ if (!fileDiff) return false;
2256
+ try {
2257
+ const tempFilePath = join3(tempDir, filePath);
2258
+ await mkdir2(dirname3(tempFilePath), { recursive: true });
2259
+ await writeFile2(tempFilePath, content);
2260
+ await tempGit.exec(["add", filePath]);
2261
+ await tempGit.exec(["commit", "-m", `check already-applied ${filePath}`, "--allow-empty"]);
2262
+ await tempGit.execWithInput(["apply", "--reverse", "--check", "--allow-empty"], fileDiff);
2263
+ return true;
2264
+ } catch {
2265
+ return false;
2189
2266
  }
2190
- const commits = this.parseGitLog(log);
2191
- const newPatches = [];
2192
- const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
2193
- const existingHashes = new Set(lock.patches.map((p) => p.content_hash));
2194
- const existingCommits = new Set(lock.patches.map((p) => p.original_commit));
2195
- for (const commit of commits) {
2196
- if (isGenerationCommit(commit)) {
2197
- continue;
2198
- }
2199
- const parents = await this.git.getCommitParents(commit.sha);
2200
- if (parents.length > 1) {
2267
+ }
2268
+ extractFileDiff(patchContent, filePath) {
2269
+ const lines = patchContent.split("\n");
2270
+ const diffLines = [];
2271
+ let inTargetFile = false;
2272
+ for (const line of lines) {
2273
+ if (line.startsWith("diff --git")) {
2274
+ if (inTargetFile) {
2275
+ break;
2276
+ }
2277
+ if (isDiffLineForFile(line, filePath)) {
2278
+ inTargetFile = true;
2279
+ diffLines.push(line);
2280
+ }
2201
2281
  continue;
2202
2282
  }
2203
- if (existingCommits.has(commit.sha)) {
2204
- continue;
2283
+ if (inTargetFile) {
2284
+ diffLines.push(line);
2205
2285
  }
2206
- let patchContent;
2207
- try {
2208
- patchContent = await this.git.formatPatch(commit.sha);
2209
- } catch {
2286
+ }
2287
+ return diffLines.length > 0 ? diffLines.join("\n") + "\n" : null;
2288
+ }
2289
+ extractRenameSource(patchContent, targetFilePath) {
2290
+ const lines = patchContent.split("\n");
2291
+ let inTargetFile = false;
2292
+ for (const line of lines) {
2293
+ if (line.startsWith("diff --git")) {
2294
+ if (inTargetFile) break;
2295
+ inTargetFile = isDiffLineForFile(line, targetFilePath);
2210
2296
  continue;
2211
2297
  }
2212
- let contentHash = this.computeContentHash(patchContent);
2213
- if (existingHashes.has(contentHash) || forgottenHashes.has(contentHash)) {
2214
- continue;
2298
+ if (!inTargetFile) continue;
2299
+ if (line.startsWith("@@")) break;
2300
+ if (line.startsWith("rename from ")) {
2301
+ return line.slice("rename from ".length);
2215
2302
  }
2216
- if (this.isCreationOnlyPatch(patchContent)) {
2303
+ }
2304
+ return null;
2305
+ }
2306
+ extractNewFileFromPatch(patchContent, filePath) {
2307
+ const lines = patchContent.split("\n");
2308
+ const addedLines = [];
2309
+ let inTargetFile = false;
2310
+ let inHunk = false;
2311
+ let isNewFile = false;
2312
+ let noTrailingNewline = false;
2313
+ for (const line of lines) {
2314
+ if (line.startsWith("diff --git")) {
2315
+ if (inTargetFile) break;
2316
+ inTargetFile = isDiffLineForFile(line, filePath);
2317
+ inHunk = false;
2318
+ isNewFile = false;
2217
2319
  continue;
2218
2320
  }
2219
- const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
2220
- const allScanFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f));
2221
- const sensitiveScanFiles = allScanFiles.filter((f) => isSensitiveFile(f));
2222
- const files = allScanFiles.filter((f) => !isSensitiveFile(f));
2223
- if (sensitiveScanFiles.length > 0) {
2224
- this.warnings.push(
2225
- `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveScanFiles.join(", ")}. These files will not be tracked by replay.`
2226
- );
2227
- if (files.length > 0) {
2228
- if (parents.length === 0) {
2229
- continue;
2230
- }
2231
- try {
2232
- patchContent = await this.git.exec(["diff", parents[0], commit.sha, "--", ...files]);
2233
- } catch {
2234
- continue;
2235
- }
2236
- if (!patchContent.trim()) continue;
2237
- contentHash = this.computeContentHash(patchContent);
2321
+ if (!inTargetFile) continue;
2322
+ if (!inHunk) {
2323
+ if (line === "--- /dev/null" || line.startsWith("new file mode")) {
2324
+ isNewFile = true;
2238
2325
  }
2239
2326
  }
2240
- if (files.length === 0) {
2327
+ if (line.startsWith("@@")) {
2328
+ if (!isNewFile) return null;
2329
+ inHunk = true;
2241
2330
  continue;
2242
2331
  }
2243
- if (parents.length === 0) {
2332
+ if (!inHunk) continue;
2333
+ if (line === "\") {
2334
+ noTrailingNewline = true;
2244
2335
  continue;
2245
2336
  }
2246
- const parentSha = parents[0];
2247
- const theirsSnapshot = {};
2248
- for (const file of files) {
2249
- if (isBinaryFile(file)) continue;
2250
- const content = await this.git.showFile(commit.sha, file).catch(() => null);
2251
- if (content != null) theirsSnapshot[file] = content;
2337
+ if (line.startsWith("+") && !line.startsWith("+++")) {
2338
+ addedLines.push(line.slice(1));
2252
2339
  }
2253
- newPatches.push({
2254
- id: `patch-${commit.sha.slice(0, 8)}`,
2255
- content_hash: contentHash,
2256
- original_commit: commit.sha,
2257
- original_message: commit.message,
2258
- original_author: `${commit.authorName} <${commit.authorEmail}>`,
2259
- base_generation: parentSha,
2260
- files,
2261
- patch_content: patchContent,
2262
- ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {}
2263
- });
2264
- }
2265
- newPatches.reverse();
2266
- return { patches: newPatches, revertedPatchIds: [] };
2267
- }
2268
- /**
2269
- * Check if a format-patch consists entirely of new-file creations.
2270
- * Used to identify squashed commits after force push, which create all files
2271
- * from scratch (--- /dev/null) rather than modifying existing files.
2272
- */
2273
- isCreationOnlyPatch(patchContent) {
2274
- const diffOldHeaders = patchContent.split("\n").filter((l) => l.startsWith("--- "));
2275
- if (diffOldHeaders.length === 0) {
2276
- return false;
2277
2340
  }
2278
- return diffOldHeaders.every((l) => l === "--- /dev/null");
2341
+ if (addedLines.length === 0) return null;
2342
+ return addedLines.join("\n") + (noTrailingNewline ? "" : "\n");
2279
2343
  }
2280
- /**
2281
- * Resolve the best available diff base for a generation record.
2282
- * Prefers commit_sha, falls back to tree_hash for unreachable commits.
2283
- * When commitKnownMissing is true, skips the redundant commitExists check.
2284
- */
2285
- async resolveDiffBase(gen, commitKnownMissing) {
2286
- if (!commitKnownMissing && await this.git.commitExists(gen.commit_sha)) {
2287
- return gen.commit_sha;
2288
- }
2289
- if (await this.git.treeExists(gen.tree_hash)) {
2290
- return gen.tree_hash;
2344
+ };
2345
+ function computeDroppedContextLinesForFile(base, theirs, finalContent) {
2346
+ const baseLines = base.split("\n");
2347
+ const theirsLines = theirs.split("\n");
2348
+ const finalLines = finalContent.split("\n");
2349
+ const baseCounts = /* @__PURE__ */ new Map();
2350
+ const theirsCounts = /* @__PURE__ */ new Map();
2351
+ const finalCounts = /* @__PURE__ */ new Map();
2352
+ for (const l of baseLines) baseCounts.set(l, (baseCounts.get(l) ?? 0) + 1);
2353
+ for (const l of theirsLines) theirsCounts.set(l, (theirsCounts.get(l) ?? 0) + 1);
2354
+ for (const l of finalLines) finalCounts.set(l, (finalCounts.get(l) ?? 0) + 1);
2355
+ const dropped = [];
2356
+ const seen = /* @__PURE__ */ new Set();
2357
+ for (const line of baseLines) {
2358
+ if (seen.has(line)) continue;
2359
+ const inBase = baseCounts.get(line) ?? 0;
2360
+ const inTheirs = theirsCounts.get(line) ?? 0;
2361
+ const inFinal = finalCounts.get(line) ?? 0;
2362
+ if (inBase > 0 && inTheirs > 0 && inFinal < Math.min(inBase, inTheirs)) {
2363
+ dropped.push(line);
2364
+ seen.add(line);
2291
2365
  }
2292
- return null;
2293
- }
2294
- parseGitLog(log) {
2295
- return log.trim().split("\n").map((line) => {
2296
- const [sha, authorName, authorEmail, message] = line.split("\0");
2297
- return { sha, authorName, authorEmail, message };
2298
- });
2299
- }
2300
- getLastGeneration(lock) {
2301
- return lock.generations.find((g) => g.commit_sha === lock.current_generation);
2302
2366
  }
2303
- };
2367
+ return dropped;
2368
+ }
2369
+ function isDiffLineForFile(diffLine, filePath) {
2370
+ const match = diffLine.match(/^diff --git a\/.+ b\/(.+)$/);
2371
+ return match !== null && match[1] === filePath;
2372
+ }
2304
2373
 
2305
2374
  // src/ReplayCommitter.ts
2306
2375
  var ReplayCommitter = class {
@@ -2408,15 +2477,15 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
2408
2477
  // src/ReplayService.ts
2409
2478
  import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
2410
2479
  import { readFile as readFileAsync } from "fs/promises";
2411
- import { join as join5 } from "path";
2412
- import { minimatch as minimatch2 } from "minimatch";
2480
+ import { join as join6 } from "path";
2481
+ import { minimatch as minimatch3 } from "minimatch";
2413
2482
  init_GitClient();
2414
2483
 
2415
2484
  // src/PatchRegionDiff.ts
2416
2485
  init_HybridReconstruction();
2417
- import { mkdtemp as mkdtemp2, writeFile as writeFile2, rm as rm2 } from "fs/promises";
2486
+ import { mkdtemp as mkdtemp2, writeFile as writeFile3, rm as rm2 } from "fs/promises";
2418
2487
  import { tmpdir as tmpdir2 } from "os";
2419
- import { join as join3 } from "path";
2488
+ import { join as join4 } from "path";
2420
2489
  import { spawn } from "child_process";
2421
2490
  function normalizeHunkBody(hunk) {
2422
2491
  let contextC = 0;
@@ -2622,12 +2691,12 @@ function overlayRegions(currentGenLines, locInCurrentGen, workingTreeLines, locI
2622
2691
  }
2623
2692
  async function unifiedDiff(beforeContent, afterContent, filePath) {
2624
2693
  if (beforeContent === afterContent) return "";
2625
- const tmp = await mkdtemp2(join3(tmpdir2(), "fern-replay-pp-"));
2694
+ const tmp = await mkdtemp2(join4(tmpdir2(), "fern-replay-pp-"));
2626
2695
  try {
2627
- const beforePath = join3(tmp, "before");
2628
- const afterPath = join3(tmp, "after");
2629
- await writeFile2(beforePath, beforeContent, "utf-8");
2630
- await writeFile2(afterPath, afterContent, "utf-8");
2696
+ const beforePath = join4(tmp, "before");
2697
+ const afterPath = join4(tmp, "after");
2698
+ await writeFile3(beforePath, beforeContent, "utf-8");
2699
+ await writeFile3(afterPath, afterContent, "utf-8");
2631
2700
  const raw = await runGitDiffNoIndex(beforePath, afterPath);
2632
2701
  if (!raw.trim()) return "";
2633
2702
  return rewriteDiffHeaders(raw, filePath);
@@ -2704,211 +2773,84 @@ function synthesizeDeleteFileDiff(file, content) {
2704
2773
  return header + "\n" + body + trailer + "\n";
2705
2774
  }
2706
2775
 
2707
- // src/ReplayService.ts
2708
- var ReplayService = class {
2709
- git;
2710
- detector;
2711
- applicator;
2712
- committer;
2713
- lockManager;
2714
- outputDir;
2715
- /** @internal Test-only. Captures the last ReplayResult[] returned from applicator.applyPatches(). */
2716
- _lastApplyResults = [];
2717
- /**
2718
- * Service-level warnings accumulated during a single runReplay() call.
2719
- * Merged with `detector.warnings` into `ReplayReport.warnings` by each flow handler.
2720
- * Populated by `isFileUserOwned` when a patch's base generation tree is unreachable
2721
- * (shallow clone / aggressive `git gc --prune`) and we fall back to a conservative
2722
- * heuristic. Cleared at the top of `runReplay()`.
2723
- */
2724
- warnings = [];
2725
- /**
2726
- * @internal Test-only accessor.
2727
- * Returns the ReplayApplicator instance used for the last run. Tests use this
2728
- * to inspect the inter-patch accumulator after runReplay() completes (FER-9791, I2).
2729
- */
2730
- get applicatorRef() {
2731
- return this.applicator;
2732
- }
2733
- /**
2734
- * @internal Test-only accessor.
2735
- * Returns the ReplayResult[] from the most recent applyPatches() call. Tests use
2736
- * this to filter applied-vs-conflicted-vs-absorbed patches for invariant assertions
2737
- * (FER-9791). Returns an empty array if no patches have been applied yet.
2738
- */
2739
- getLastResults() {
2740
- return this._lastApplyResults;
2741
- }
2742
- constructor(outputDir, _config) {
2743
- const git = new GitClient(outputDir);
2776
+ // src/classifier/FileOwnership.ts
2777
+ import { minimatch as minimatch2 } from "minimatch";
2778
+ var FileOwnership = class {
2779
+ constructor(git) {
2744
2780
  this.git = git;
2745
- this.outputDir = outputDir;
2746
- this.lockManager = new LockfileManager(outputDir);
2747
- this.detector = new ReplayDetector(git, this.lockManager, outputDir);
2748
- this.applicator = new ReplayApplicator(git, this.lockManager, outputDir);
2749
- this.committer = new ReplayCommitter(git, outputDir);
2750
- }
2751
- /**
2752
- * Phase 1 of the two-phase replay flow. Runs preGenerationRebase (when applicable),
2753
- * detects new patches, and creates the `[fern-generated]` commit. Leaves HEAD at
2754
- * the generation commit (or unchanged for dry-run).
2755
- *
2756
- * Does NOT apply patches — the returned handle must be passed to
2757
- * `applyPreparedReplay()` to complete the run. No disk writes to the lockfile
2758
- * happen here; lockfile persistence is deferred to phase 2.
2759
- *
2760
- * Callers may land additional commits on HEAD between `prepareReplay` and
2761
- * `applyPreparedReplay` (e.g., `[fern-autoversion]`).
2762
- */
2763
- async prepareReplay(options) {
2764
- this.warnings = [];
2765
- this.detector.warnings.length = 0;
2766
- if (options?.skipApplication) {
2767
- return this._prepareSkipApplication(options);
2768
- }
2769
- const flow = this.determineFlow();
2770
- switch (flow) {
2771
- case "first-generation":
2772
- return this._prepareFirstGeneration(options);
2773
- case "no-patches":
2774
- return this._prepareNoPatchesRegeneration(options);
2775
- case "normal-regeneration":
2776
- return this._prepareNormalRegeneration(options);
2777
- }
2778
- }
2779
- /**
2780
- * Phase 2 of the two-phase replay flow. Applies patches, rebases them against
2781
- * the generation, saves the lockfile, and commits `[fern-replay]` (or stages
2782
- * under `stageOnly`). Operates on HEAD at call time — mid-phase commits are
2783
- * respected.
2784
- *
2785
- * For terminal handles (dry-run, first-gen has no patch work, skip-app does
2786
- * its own post-commit logic), this returns the precomputed report.
2787
- */
2788
- async applyPreparedReplay(prep, options) {
2789
- if (prep._prepared.terminal) {
2790
- return prep._prepared.preparedReport;
2791
- }
2792
- switch (prep._prepared.flow) {
2793
- case "first-generation":
2794
- return this._applyFirstGeneration(prep);
2795
- case "skip-application":
2796
- return this._applySkipApplication(prep, options);
2797
- case "no-patches":
2798
- return this._applyNoPatchesRegeneration(prep, options);
2799
- case "normal-regeneration":
2800
- return this._applyNormalRegeneration(prep, options);
2801
- }
2802
- }
2803
- /**
2804
- * Backwards-compatible atomic entry point. Composes `prepareReplay` + `applyPreparedReplay`.
2805
- * Behavior is byte-identical to the pre-split implementation for all existing callers.
2806
- */
2807
- async runReplay(options) {
2808
- const prep = await this.prepareReplay(options);
2809
- return this.applyPreparedReplay(prep, options);
2810
2781
  }
2811
2782
  /**
2812
- * Sync the lockfile after a divergent PR was squash-merged.
2813
- * Call this BEFORE runReplay() when the CLI detects a merged divergent PR.
2783
+ * Canonical resolution. Returns true if `file` is user-owned per:
2814
2784
  *
2815
- * After updating the generation record, re-detects customer patches via
2816
- * tree diff between the generation tag (pure generation tree) and HEAD
2817
- * (which includes customer customizations after squash merge). This ensures
2818
- * patches survive the squash merge regenerate cycle even when the lockfile
2819
- * was restored from the base branch during conflict PR creation.
2785
+ * 1. `patch.user_owned === true` authoritative, persisted flag.
2786
+ * 2. `file === ".fernignore"` always user-owned.
2787
+ * 3. File matches a `.fernignore` pattern (via minimatch).
2788
+ * 4. Patch content shows `--- /dev/null` for this file (legacy-safe
2789
+ * signal for patches predating `user_owned` or whose flag was lost).
2790
+ * 5. Tree-based check against `patch.base_generation` when reachable
2791
+ * AND distinct from `currentGenSha`. Unreachable base → conservative
2792
+ * user-owned (silent-absorption safety net), with a one-time warning
2793
+ * per base SHA.
2794
+ * 6. Final fallback: check `currentGenSha` when no usable base exists
2795
+ * (legacy one-gen lockfile).
2820
2796
  */
2821
- async syncFromDivergentMerge(generationCommitSha, options) {
2822
- const treeHash = await this.git.getTreeHash(generationCommitSha);
2823
- const timestamp = (await this.git.exec(["log", "-1", "--format=%aI", generationCommitSha])).trim();
2824
- const record = {
2825
- commit_sha: generationCommitSha,
2826
- tree_hash: treeHash,
2827
- timestamp,
2828
- cli_version: options?.cliVersion ?? "unknown",
2829
- generator_versions: options?.generatorVersions ?? {},
2830
- base_branch_head: options?.baseBranchHead
2831
- };
2832
- let resolvedPatches;
2833
- if (!this.lockManager.exists()) {
2834
- this.lockManager.initializeInMemory(record);
2835
- } else {
2836
- this.lockManager.read();
2837
- const unresolvedPatches = [
2838
- ...this.lockManager.getUnresolvedPatches(),
2839
- ...this.lockManager.getResolvingPatches()
2840
- ];
2841
- resolvedPatches = this.lockManager.getPatches().filter((p) => p.status == null);
2842
- this.lockManager.addGeneration(record);
2843
- this.lockManager.clearPatches();
2844
- for (const patch of unresolvedPatches) {
2845
- this.lockManager.addPatch(patch);
2846
- }
2797
+ async resolveCanonical(ctx) {
2798
+ const { file, originalFileName, patch, currentGenSha, fernignorePatterns, warnedGens, warnings } = ctx;
2799
+ if (patch.user_owned) return true;
2800
+ if (file === ".fernignore") return true;
2801
+ if (fernignorePatterns.some((p) => file === p || minimatch2(file, p))) return true;
2802
+ if (fileIsCreationInPatchContent(patch.patch_content, originalFileName ?? file)) {
2803
+ return true;
2847
2804
  }
2848
- try {
2849
- const { patches: redetectedPatches } = await this.detector.detectNewPatches();
2850
- if (redetectedPatches.length > 0) {
2851
- const redetectedFiles = new Set(redetectedPatches.flatMap((p) => p.files));
2852
- const currentPatches = this.lockManager.getPatches();
2853
- for (const patch of currentPatches) {
2854
- if (patch.status != null && patch.files.some((f) => redetectedFiles.has(f))) {
2855
- this.lockManager.removePatch(patch.id);
2856
- }
2857
- }
2858
- for (const patch of redetectedPatches) {
2859
- this.lockManager.addPatch(patch);
2805
+ const base = patch.base_generation;
2806
+ if (base && base !== currentGenSha) {
2807
+ const reachable = await this.git.commitExists(base) || await this.git.treeExists(base);
2808
+ if (!reachable) {
2809
+ if (!warnedGens.has(base)) {
2810
+ warnedGens.add(base);
2811
+ warnings.push(
2812
+ `Base generation ${base.slice(0, 7)} is unreachable (shallow clone or pruned history). Treating affected files as user-owned to avoid silent data loss. Run \`git fetch --unshallow\` or avoid \`git gc --prune\` to restore full history.`
2813
+ );
2860
2814
  }
2815
+ return true;
2861
2816
  }
2862
- } catch (error) {
2863
- for (const patch of resolvedPatches ?? []) {
2864
- this.lockManager.addPatch(patch);
2865
- }
2866
- this.detector.warnings.push(
2867
- `Patch re-detection failed after divergent merge sync. ${(resolvedPatches ?? []).length} previously resolved patch(es) preserved. Error: ${error instanceof Error ? error.message : String(error)}`
2868
- );
2817
+ return await this.git.showFile(base, originalFileName ?? file) === null;
2869
2818
  }
2870
- this.lockManager.save();
2871
- this.detector.warnings.push(...this.lockManager.warnings);
2819
+ return await this.git.showFile(currentGenSha, originalFileName ?? file) === null;
2872
2820
  }
2873
- determineFlow() {
2874
- try {
2875
- const lock = this.lockManager.read();
2876
- return lock.patches.length === 0 ? "no-patches" : "normal-regeneration";
2877
- } catch (error) {
2878
- if (error instanceof LockfileNotFoundError) {
2879
- return "first-generation";
2880
- }
2881
- throw error;
2821
+ };
2822
+ function fileIsCreationInPatchContent(patchContent, file) {
2823
+ const lines = patchContent.split("\n");
2824
+ let inFileSection = false;
2825
+ for (const line of lines) {
2826
+ if (line.startsWith("diff --git ")) {
2827
+ inFileSection = line.includes(` b/${file}`) || line.endsWith(` b/${file}`);
2828
+ continue;
2829
+ }
2830
+ if (inFileSection && line.startsWith("--- ")) {
2831
+ return line === "--- /dev/null";
2882
2832
  }
2883
2833
  }
2884
- firstGenerationReport() {
2885
- return {
2886
- flow: "first-generation",
2887
- patchesDetected: 0,
2888
- patchesApplied: 0,
2889
- patchesWithConflicts: 0,
2890
- patchesSkipped: 0,
2891
- conflicts: []
2892
- };
2834
+ return false;
2835
+ }
2836
+
2837
+ // src/flows/FirstGenerationFlow.ts
2838
+ var FirstGenerationFlow = class {
2839
+ constructor(service) {
2840
+ this.service = service;
2893
2841
  }
2894
- async _prepareFirstGeneration(options) {
2842
+ async prepare(options) {
2895
2843
  if (options?.dryRun) {
2896
- const headSha = await this.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
2844
+ const headSha = await this.service.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
2897
2845
  return {
2898
2846
  flow: "first-generation",
2899
2847
  previousGenerationSha: null,
2900
2848
  currentGenerationSha: headSha,
2901
2849
  baseBranchHead: options.baseBranchHead ?? null,
2902
2850
  _prepared: {
2851
+ kind: "terminal",
2903
2852
  flow: "first-generation",
2904
- terminal: true,
2905
- preparedReport: this.firstGenerationReport(),
2906
- patchesToApply: [],
2907
- newPatchIds: /* @__PURE__ */ new Set(),
2908
- detectorWarnings: [],
2909
- revertedCount: 0,
2910
- genSha: headSha,
2911
- optionsSnapshot: options
2853
+ preparedReport: firstGenerationReport()
2912
2854
  }
2913
2855
  };
2914
2856
  }
@@ -2917,17 +2859,17 @@ var ReplayService = class {
2917
2859
  generatorVersions: options.generatorVersions ?? {},
2918
2860
  baseBranchHead: options.baseBranchHead
2919
2861
  } : void 0;
2920
- await this.committer.commitGeneration("Initial SDK generation", commitOpts);
2921
- const genRecord = await this.committer.createGenerationRecord(commitOpts);
2922
- this.lockManager.initializeInMemory(genRecord);
2862
+ await this.service.committer.commitGeneration("Initial SDK generation", commitOpts);
2863
+ const genRecord = await this.service.committer.createGenerationRecord(commitOpts);
2864
+ this.service.lockManager.initializeInMemory(genRecord);
2923
2865
  return {
2924
2866
  flow: "first-generation",
2925
2867
  previousGenerationSha: null,
2926
2868
  currentGenerationSha: genRecord.commit_sha,
2927
2869
  baseBranchHead: options?.baseBranchHead ?? null,
2928
2870
  _prepared: {
2871
+ kind: "active",
2929
2872
  flow: "first-generation",
2930
- terminal: false,
2931
2873
  patchesToApply: [],
2932
2874
  newPatchIds: /* @__PURE__ */ new Set(),
2933
2875
  detectorWarnings: [],
@@ -2937,47 +2879,57 @@ var ReplayService = class {
2937
2879
  }
2938
2880
  };
2939
2881
  }
2940
- async _applyFirstGeneration(_prep) {
2941
- this.lockManager.save();
2942
- return this.firstGenerationReport();
2882
+ async apply(_prep) {
2883
+ this.service.lockManager.save();
2884
+ return firstGenerationReport();
2943
2885
  }
2944
- /**
2945
- * Skip-application mode phase 1: commit the generation and update the
2946
- * in-memory lockfile, but don't detect or apply patches. Sets a marker
2947
- * so the next normal run skips revert detection in preGenerationRebase().
2948
- *
2949
- * Disk save is deferred to `_applySkipApplication`.
2950
- */
2951
- async _prepareSkipApplication(options) {
2886
+ };
2887
+ function firstGenerationReport() {
2888
+ return {
2889
+ flow: "first-generation",
2890
+ patchesDetected: 0,
2891
+ patchesApplied: 0,
2892
+ patchesWithConflicts: 0,
2893
+ patchesSkipped: 0,
2894
+ conflicts: []
2895
+ };
2896
+ }
2897
+
2898
+ // src/flows/SkipApplicationFlow.ts
2899
+ var SkipApplicationFlow = class {
2900
+ constructor(service) {
2901
+ this.service = service;
2902
+ }
2903
+ async prepare(options) {
2952
2904
  const commitOpts = options ? {
2953
2905
  cliVersion: options.cliVersion ?? "unknown",
2954
2906
  generatorVersions: options.generatorVersions ?? {},
2955
2907
  baseBranchHead: options.baseBranchHead
2956
2908
  } : void 0;
2957
- await this.committer.commitGeneration("Update SDK (replay skipped)", commitOpts);
2958
- await this.cleanupStaleConflictMarkers();
2959
- const genRecord = await this.committer.createGenerationRecord(commitOpts);
2909
+ await this.service.committer.commitGeneration("Update SDK (replay skipped)", commitOpts);
2910
+ await this.service.cleanupStaleConflictMarkers();
2911
+ const genRecord = await this.service.committer.createGenerationRecord(commitOpts);
2960
2912
  let previousGenerationSha = null;
2961
2913
  try {
2962
- const lock = this.lockManager.read();
2914
+ const lock = this.service.lockManager.read();
2963
2915
  previousGenerationSha = lock.current_generation ?? null;
2964
- this.lockManager.addGeneration(genRecord);
2916
+ this.service.lockManager.addGeneration(genRecord);
2965
2917
  } catch (error) {
2966
2918
  if (error instanceof LockfileNotFoundError) {
2967
- this.lockManager.initializeInMemory(genRecord);
2919
+ this.service.lockManager.initializeInMemory(genRecord);
2968
2920
  } else {
2969
2921
  throw error;
2970
2922
  }
2971
2923
  }
2972
- this.lockManager.setReplaySkippedAt((/* @__PURE__ */ new Date()).toISOString());
2924
+ this.service.lockManager.setReplaySkippedAt((/* @__PURE__ */ new Date()).toISOString());
2973
2925
  return {
2974
2926
  flow: "skip-application",
2975
2927
  previousGenerationSha,
2976
2928
  currentGenerationSha: genRecord.commit_sha,
2977
2929
  baseBranchHead: options?.baseBranchHead ?? null,
2978
2930
  _prepared: {
2931
+ kind: "active",
2979
2932
  flow: "skip-application",
2980
- terminal: false,
2981
2933
  patchesToApply: [],
2982
2934
  newPatchIds: /* @__PURE__ */ new Set(),
2983
2935
  detectorWarnings: [],
@@ -2987,13 +2939,13 @@ var ReplayService = class {
2987
2939
  }
2988
2940
  };
2989
2941
  }
2990
- async _applySkipApplication(_prep, options) {
2991
- this.lockManager.save();
2992
- const credentialWarnings = [...this.lockManager.warnings];
2942
+ async apply(_prep, options) {
2943
+ this.service.lockManager.save();
2944
+ const credentialWarnings = [...this.service.lockManager.warnings];
2993
2945
  if (!options?.stageOnly) {
2994
- await this.committer.commitReplay(0);
2946
+ await this.service.committer.commitReplay(0);
2995
2947
  } else {
2996
- await this.committer.stageAll();
2948
+ await this.service.committer.stageAll();
2997
2949
  }
2998
2950
  return {
2999
2951
  flow: "skip-application",
@@ -3005,13 +2957,20 @@ var ReplayService = class {
3005
2957
  warnings: credentialWarnings.length > 0 ? credentialWarnings : void 0
3006
2958
  };
3007
2959
  }
3008
- async _prepareNoPatchesRegeneration(options) {
3009
- const preLock = this.lockManager.read();
2960
+ };
2961
+
2962
+ // src/flows/NoPatchesFlow.ts
2963
+ var NoPatchesFlow = class {
2964
+ constructor(service) {
2965
+ this.service = service;
2966
+ }
2967
+ async prepare(options) {
2968
+ const preLock = this.service.lockManager.read();
3010
2969
  const previousGenerationSha = preLock.current_generation ?? null;
3011
- let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
3012
- const detectorWarnings = [...this.detector.warnings];
2970
+ let { patches: newPatches, revertedPatchIds } = await this.service.detector.detectNewPatches();
2971
+ const detectorWarnings = [...this.service.detector.warnings];
3013
2972
  if (options?.dryRun) {
3014
- const dryRunWarnings = [...detectorWarnings, ...this.warnings];
2973
+ const dryRunWarnings = [...detectorWarnings, ...this.service.warnings];
3015
2974
  const preparedReport = {
3016
2975
  flow: "no-patches",
3017
2976
  patchesDetected: newPatches.length,
@@ -3023,22 +2982,16 @@ var ReplayService = class {
3023
2982
  wouldApply: newPatches,
3024
2983
  warnings: dryRunWarnings.length > 0 ? dryRunWarnings : void 0
3025
2984
  };
3026
- const headSha = await this.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
2985
+ const headSha = await this.service.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
3027
2986
  return {
3028
2987
  flow: "no-patches",
3029
2988
  previousGenerationSha,
3030
2989
  currentGenerationSha: headSha,
3031
2990
  baseBranchHead: options.baseBranchHead ?? null,
3032
2991
  _prepared: {
2992
+ kind: "terminal",
3033
2993
  flow: "no-patches",
3034
- terminal: true,
3035
- preparedReport,
3036
- patchesToApply: [],
3037
- newPatchIds: /* @__PURE__ */ new Set(),
3038
- detectorWarnings,
3039
- revertedCount: revertedPatchIds.length,
3040
- genSha: headSha,
3041
- optionsSnapshot: options
2994
+ preparedReport
3042
2995
  }
3043
2996
  };
3044
2997
  }
@@ -3047,7 +3000,7 @@ var ReplayService = class {
3047
3000
  const uniqueFiles = [...new Set(newPatches.flatMap((p) => p.files))];
3048
3001
  const absorbedFiles = /* @__PURE__ */ new Set();
3049
3002
  for (const file of uniqueFiles) {
3050
- const diff = await this.git.exec(["diff", preCurrentGen, "HEAD", "--", file]).catch(() => null);
3003
+ const diff = await this.service.git.exec(["diff", preCurrentGen, "HEAD", "--", file]).catch(() => null);
3051
3004
  if (diff !== null && !diff.trim()) {
3052
3005
  absorbedFiles.add(file);
3053
3006
  }
@@ -3060,7 +3013,7 @@ var ReplayService = class {
3060
3013
  }
3061
3014
  for (const id of revertedPatchIds) {
3062
3015
  try {
3063
- this.lockManager.removePatch(id);
3016
+ this.service.lockManager.removePatch(id);
3064
3017
  } catch {
3065
3018
  }
3066
3019
  }
@@ -3069,18 +3022,18 @@ var ReplayService = class {
3069
3022
  generatorVersions: options.generatorVersions ?? {},
3070
3023
  baseBranchHead: options.baseBranchHead
3071
3024
  } : void 0;
3072
- await this.committer.commitGeneration("Update SDK", commitOpts);
3073
- await this.cleanupStaleConflictMarkers();
3074
- const genRecord = await this.committer.createGenerationRecord(commitOpts);
3075
- this.lockManager.addGeneration(genRecord);
3025
+ await this.service.committer.commitGeneration("Update SDK", commitOpts);
3026
+ await this.service.cleanupStaleConflictMarkers();
3027
+ const genRecord = await this.service.committer.createGenerationRecord(commitOpts);
3028
+ this.service.lockManager.addGeneration(genRecord);
3076
3029
  return {
3077
3030
  flow: "no-patches",
3078
3031
  previousGenerationSha,
3079
3032
  currentGenerationSha: genRecord.commit_sha,
3080
3033
  baseBranchHead: options?.baseBranchHead ?? null,
3081
3034
  _prepared: {
3035
+ kind: "active",
3082
3036
  flow: "no-patches",
3083
- terminal: false,
3084
3037
  patchesToApply: newPatches,
3085
3038
  newPatchIds: new Set(newPatches.map((p) => p.id)),
3086
3039
  detectorWarnings,
@@ -3090,41 +3043,42 @@ var ReplayService = class {
3090
3043
  }
3091
3044
  };
3092
3045
  }
3093
- async _applyNoPatchesRegeneration(prep, options) {
3046
+ async apply(prep, options) {
3047
+ assertActive(prep);
3094
3048
  const newPatches = prep._prepared.patchesToApply;
3095
3049
  const detectorWarnings = prep._prepared.detectorWarnings;
3096
3050
  const genSha = prep._prepared.genSha;
3097
3051
  let results = [];
3098
3052
  if (newPatches.length > 0) {
3099
- results = await this.applicator.applyPatches(newPatches);
3100
- this._lastApplyResults = results;
3101
- this.recordDroppedContextLineWarnings(results);
3102
- this.revertConflictingFiles(results);
3053
+ results = await this.service.applicator.applyPatches(newPatches);
3054
+ this.service._lastApplyResults = results;
3055
+ this.service.recordDroppedContextLineWarnings(results);
3056
+ this.service.revertConflictingFiles(results);
3103
3057
  for (const result of results) {
3104
3058
  if (result.status === "conflict") {
3105
3059
  result.patch.status = "unresolved";
3106
3060
  }
3107
3061
  }
3108
3062
  }
3109
- const rebaseCounts = await this.rebasePatches(results, genSha);
3063
+ const rebaseCounts = await this.service.rebasePatches(results, genSha);
3110
3064
  for (const patch of newPatches) {
3111
3065
  if (!rebaseCounts.absorbedPatchIds.has(patch.id)) {
3112
- this.lockManager.addPatch(patch);
3066
+ this.service.lockManager.addPatch(patch);
3113
3067
  }
3114
3068
  }
3115
- this.lockManager.save();
3116
- this.warnings.push(...this.lockManager.warnings);
3069
+ this.service.lockManager.save();
3070
+ this.service.warnings.push(...this.service.lockManager.warnings);
3117
3071
  if (newPatches.length > 0) {
3118
3072
  if (!options?.stageOnly) {
3119
3073
  const appliedCount = results.filter((r) => r.status === "applied").length;
3120
3074
  const buckets = computeCommitBuckets(results, rebaseCounts.absorbedPatchIds, newPatches);
3121
- await this.committer.commitReplay(appliedCount, newPatches, void 0, { buckets });
3075
+ await this.service.committer.commitReplay(appliedCount, newPatches, void 0, { buckets });
3122
3076
  } else {
3123
- await this.committer.stageAll();
3077
+ await this.service.committer.stageAll();
3124
3078
  }
3125
3079
  }
3126
- const warnings = [...detectorWarnings, ...this.warnings];
3127
- return this.buildReport(
3080
+ const warnings = [...detectorWarnings, ...this.service.warnings];
3081
+ return this.service.buildReport(
3128
3082
  "no-patches",
3129
3083
  newPatches,
3130
3084
  results,
@@ -3135,13 +3089,20 @@ var ReplayService = class {
3135
3089
  prep._prepared.revertedCount
3136
3090
  );
3137
3091
  }
3138
- async _prepareNormalRegeneration(options) {
3139
- const preLock = this.lockManager.read();
3092
+ };
3093
+
3094
+ // src/flows/NormalRegenerationFlow.ts
3095
+ var NormalRegenerationFlow = class {
3096
+ constructor(service) {
3097
+ this.service = service;
3098
+ }
3099
+ async prepare(options) {
3100
+ const preLock = this.service.lockManager.read();
3140
3101
  const previousGenerationSha = preLock.current_generation ?? null;
3141
3102
  if (options?.dryRun) {
3142
- const existingPatches2 = this.lockManager.getPatches();
3143
- const { patches: newPatches2, revertedPatchIds: dryRunReverted } = await this.detector.detectNewPatches();
3144
- const warnings = [...this.detector.warnings, ...this.warnings];
3103
+ const existingPatches2 = this.service.lockManager.getPatches();
3104
+ const { patches: newPatches2, revertedPatchIds: dryRunReverted } = await this.service.detector.detectNewPatches();
3105
+ const warnings = [...this.service.detector.warnings, ...this.service.warnings];
3145
3106
  const allPatches2 = [...existingPatches2, ...newPatches2];
3146
3107
  const preparedReport = {
3147
3108
  flow: "normal-regeneration",
@@ -3154,48 +3115,42 @@ var ReplayService = class {
3154
3115
  wouldApply: allPatches2,
3155
3116
  warnings: warnings.length > 0 ? warnings : void 0
3156
3117
  };
3157
- const headSha = await this.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
3118
+ const headSha = await this.service.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
3158
3119
  return {
3159
3120
  flow: "normal-regeneration",
3160
3121
  previousGenerationSha,
3161
3122
  currentGenerationSha: headSha,
3162
3123
  baseBranchHead: options.baseBranchHead ?? null,
3163
3124
  _prepared: {
3125
+ kind: "terminal",
3164
3126
  flow: "normal-regeneration",
3165
- terminal: true,
3166
- preparedReport,
3167
- patchesToApply: [],
3168
- newPatchIds: /* @__PURE__ */ new Set(),
3169
- detectorWarnings: [...this.detector.warnings],
3170
- revertedCount: dryRunReverted.length,
3171
- genSha: headSha,
3172
- optionsSnapshot: options
3127
+ preparedReport
3173
3128
  }
3174
3129
  };
3175
3130
  }
3176
- let existingPatches = this.lockManager.getPatches();
3131
+ let existingPatches = this.service.lockManager.getPatches();
3177
3132
  const preRebasePatchIds = new Set(existingPatches.map((p) => p.id));
3178
- const preRebaseCounts = await this.preGenerationRebase(existingPatches);
3179
- const postRebasePatchIds = new Set(this.lockManager.getPatches().map((p) => p.id));
3133
+ const preRebaseCounts = await this.service.preGenerationRebase(existingPatches);
3134
+ const postRebasePatchIds = new Set(this.service.lockManager.getPatches().map((p) => p.id));
3180
3135
  const removedByPreRebase = existingPatches.filter(
3181
3136
  (p) => preRebasePatchIds.has(p.id) && !postRebasePatchIds.has(p.id)
3182
3137
  );
3183
- existingPatches = this.lockManager.getPatches();
3138
+ existingPatches = this.service.lockManager.getPatches();
3184
3139
  const seenHashes = /* @__PURE__ */ new Map();
3185
3140
  for (const p of existingPatches) {
3186
3141
  const priorPatchId = seenHashes.get(p.content_hash);
3187
3142
  if (priorPatchId !== void 0) {
3188
- this.lockManager.removePatch(p.id);
3189
- this.warnings.push(
3143
+ this.service.lockManager.removePatch(p.id);
3144
+ this.service.warnings.push(
3190
3145
  `Consolidated patch ${p.id} (commit ${(p.original_commit || "<missing>").slice(0, 7)}) into prior patch ${priorPatchId}: byte-identical patch_content. Per-commit attribution preserved in git log; lockfile collapsed to avoid duplicate-apply corruption on next regen.`
3191
3146
  );
3192
3147
  } else {
3193
3148
  seenHashes.set(p.content_hash, p.id);
3194
3149
  }
3195
3150
  }
3196
- existingPatches = this.lockManager.getPatches();
3197
- let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
3198
- const detectorWarnings = [...this.detector.warnings];
3151
+ existingPatches = this.service.lockManager.getPatches();
3152
+ let { patches: newPatches, revertedPatchIds } = await this.service.detector.detectNewPatches();
3153
+ const detectorWarnings = [...this.service.detector.warnings];
3199
3154
  if (removedByPreRebase.length > 0) {
3200
3155
  const removedOriginalCommits = new Set(removedByPreRebase.map((p) => p.original_commit));
3201
3156
  const removedOriginalMessages = new Set(removedByPreRebase.map((p) => p.original_message));
@@ -3210,7 +3165,7 @@ var ReplayService = class {
3210
3165
  }
3211
3166
  for (const id of revertedPatchIds) {
3212
3167
  try {
3213
- this.lockManager.removePatch(id);
3168
+ this.service.lockManager.removePatch(id);
3214
3169
  } catch {
3215
3170
  }
3216
3171
  }
@@ -3222,18 +3177,18 @@ var ReplayService = class {
3222
3177
  generatorVersions: options.generatorVersions ?? {},
3223
3178
  baseBranchHead: options.baseBranchHead
3224
3179
  } : void 0;
3225
- await this.committer.commitGeneration("Update SDK", commitOpts);
3226
- await this.cleanupStaleConflictMarkers();
3227
- const genRecord = await this.committer.createGenerationRecord(commitOpts);
3228
- this.lockManager.addGeneration(genRecord);
3180
+ await this.service.committer.commitGeneration("Update SDK", commitOpts);
3181
+ await this.service.cleanupStaleConflictMarkers();
3182
+ const genRecord = await this.service.committer.createGenerationRecord(commitOpts);
3183
+ this.service.lockManager.addGeneration(genRecord);
3229
3184
  return {
3230
3185
  flow: "normal-regeneration",
3231
3186
  previousGenerationSha,
3232
3187
  currentGenerationSha: genRecord.commit_sha,
3233
3188
  baseBranchHead: options?.baseBranchHead ?? null,
3234
3189
  _prepared: {
3190
+ kind: "active",
3235
3191
  flow: "normal-regeneration",
3236
- terminal: false,
3237
3192
  patchesToApply: allPatches,
3238
3193
  newPatchIds: new Set(newPatches.map((p) => p.id)),
3239
3194
  preRebaseCounts,
@@ -3244,46 +3199,47 @@ var ReplayService = class {
3244
3199
  }
3245
3200
  };
3246
3201
  }
3247
- async _applyNormalRegeneration(prep, options) {
3202
+ async apply(prep, options) {
3203
+ assertActive(prep);
3248
3204
  const allPatches = prep._prepared.patchesToApply;
3249
3205
  const newPatchIds = prep._prepared.newPatchIds;
3250
3206
  const detectorWarnings = prep._prepared.detectorWarnings;
3251
3207
  const genSha = prep._prepared.genSha;
3252
- const results = await this.applicator.applyPatches(allPatches);
3253
- this._lastApplyResults = results;
3254
- this.recordDroppedContextLineWarnings(results);
3255
- this.revertConflictingFiles(results);
3208
+ const results = await this.service.applicator.applyPatches(allPatches);
3209
+ this.service._lastApplyResults = results;
3210
+ this.service.recordDroppedContextLineWarnings(results);
3211
+ this.service.revertConflictingFiles(results);
3256
3212
  for (const result of results) {
3257
3213
  if (result.status === "conflict") {
3258
3214
  result.patch.status = "unresolved";
3259
3215
  }
3260
3216
  }
3261
- const rebaseCounts = await this.rebasePatches(results, genSha);
3217
+ const rebaseCounts = await this.service.rebasePatches(results, genSha);
3262
3218
  const newPatches = allPatches.filter((p) => newPatchIds.has(p.id));
3263
3219
  for (const patch of newPatches) {
3264
3220
  if (!rebaseCounts.absorbedPatchIds.has(patch.id)) {
3265
- this.lockManager.addPatch(patch);
3221
+ this.service.lockManager.addPatch(patch);
3266
3222
  }
3267
3223
  }
3268
3224
  for (const result of results) {
3269
3225
  if (result.status === "conflict") {
3270
3226
  try {
3271
- this.lockManager.markPatchUnresolved(result.patch.id);
3227
+ this.service.lockManager.markPatchUnresolved(result.patch.id);
3272
3228
  } catch {
3273
3229
  }
3274
3230
  }
3275
3231
  }
3276
- this.lockManager.save();
3277
- this.warnings.push(...this.lockManager.warnings);
3232
+ this.service.lockManager.save();
3233
+ this.service.warnings.push(...this.service.lockManager.warnings);
3278
3234
  if (options?.stageOnly) {
3279
- await this.committer.stageAll();
3235
+ await this.service.committer.stageAll();
3280
3236
  } else {
3281
3237
  const appliedCount = results.filter((r) => r.status === "applied").length;
3282
3238
  const buckets = computeCommitBuckets(results, rebaseCounts.absorbedPatchIds, allPatches);
3283
- await this.committer.commitReplay(appliedCount, allPatches, void 0, { buckets });
3239
+ await this.service.committer.commitReplay(appliedCount, allPatches, void 0, { buckets });
3284
3240
  }
3285
- const warnings = [...detectorWarnings, ...this.warnings];
3286
- return this.buildReport(
3241
+ const warnings = [...detectorWarnings, ...this.service.warnings];
3242
+ return this.service.buildReport(
3287
3243
  "normal-regeneration",
3288
3244
  allPatches,
3289
3245
  results,
@@ -3294,10 +3250,213 @@ var ReplayService = class {
3294
3250
  prep._prepared.revertedCount
3295
3251
  );
3296
3252
  }
3253
+ };
3254
+
3255
+ // src/ReplayService.ts
3256
+ function assertActive(prep) {
3257
+ if (prep._prepared.kind !== "active") {
3258
+ throw new Error(
3259
+ `Flow.apply() called with terminal preparation (kind=${prep._prepared.kind}); terminal cases must be handled by applyPreparedReplay's dispatcher`
3260
+ );
3261
+ }
3262
+ }
3263
+ var ReplayService = class {
3264
+ /** @internal Used by Flow implementations in `src/flows/`. */
3265
+ git;
3266
+ /** @internal Used by Flow implementations in `src/flows/`. */
3267
+ detector;
3268
+ /** @internal Used by Flow implementations in `src/flows/`. */
3269
+ applicator;
3270
+ /** @internal Used by Flow implementations in `src/flows/`. */
3271
+ committer;
3272
+ /** @internal Used by Flow implementations in `src/flows/`. */
3273
+ lockManager;
3274
+ /** @internal Used by Flow implementations in `src/flows/`. */
3275
+ fileOwnership;
3276
+ /** @internal Used by Flow implementations in `src/flows/`. */
3277
+ outputDir;
3278
+ /** @internal Test-only. Captures the last ReplayResult[] returned from applicator.applyPatches(). */
3279
+ _lastApplyResults = [];
3280
+ /**
3281
+ * Service-level warnings accumulated during a single runReplay() call.
3282
+ * Merged with `detector.warnings` into `ReplayReport.warnings` by each flow handler.
3283
+ * Populated by `FileOwnership.resolveCanonical` when a patch's base generation tree
3284
+ * is unreachable (shallow clone / aggressive `git gc --prune`) and we fall back to a
3285
+ * conservative heuristic. Cleared at the top of `runReplay()`.
3286
+ *
3287
+ * @internal Used by Flow implementations in `src/flows/`.
3288
+ */
3289
+ warnings = [];
3290
+ /**
3291
+ * @internal Test-only accessor.
3292
+ * Returns the ReplayApplicator instance used for the last run. Tests use this
3293
+ * to inspect the inter-patch accumulator after runReplay() completes (invariant I2).
3294
+ */
3295
+ get applicatorRef() {
3296
+ return this.applicator;
3297
+ }
3298
+ /**
3299
+ * @internal Test-only accessor.
3300
+ * Returns the ReplayResult[] from the most recent applyPatches() call. Tests use
3301
+ * this to filter applied-vs-conflicted-vs-absorbed patches for invariant
3302
+ * assertions. Returns an empty array if no patches have been applied yet.
3303
+ */
3304
+ getLastResults() {
3305
+ return this._lastApplyResults;
3306
+ }
3307
+ constructor(outputDir, _config) {
3308
+ const git = new GitClient(outputDir);
3309
+ this.git = git;
3310
+ this.outputDir = outputDir;
3311
+ this.lockManager = new LockfileManager(outputDir);
3312
+ this.detector = new ReplayDetector(git, this.lockManager, outputDir);
3313
+ this.applicator = new ReplayApplicator(git, this.lockManager, outputDir);
3314
+ this.committer = new ReplayCommitter(git, outputDir);
3315
+ this.fileOwnership = new FileOwnership(git);
3316
+ }
3317
+ /**
3318
+ * Phase 1 of the two-phase replay flow. Runs preGenerationRebase (when applicable),
3319
+ * detects new patches, and creates the `[fern-generated]` commit. Leaves HEAD at
3320
+ * the generation commit (or unchanged for dry-run).
3321
+ *
3322
+ * Does NOT apply patches — the returned handle must be passed to
3323
+ * `applyPreparedReplay()` to complete the run. No disk writes to the lockfile
3324
+ * happen here; lockfile persistence is deferred to phase 2.
3325
+ *
3326
+ * Callers may land additional commits on HEAD between `prepareReplay` and
3327
+ * `applyPreparedReplay` (e.g., `[fern-autoversion]`).
3328
+ */
3329
+ async prepareReplay(options) {
3330
+ this.warnings = [];
3331
+ this.detector.warnings.length = 0;
3332
+ return this.selectFlow(options).prepare(options);
3333
+ }
3334
+ /**
3335
+ * Phase 2 of the two-phase replay flow. Applies patches, rebases them against
3336
+ * the generation, saves the lockfile, and commits `[fern-replay]` (or stages
3337
+ * under `stageOnly`). Operates on HEAD at call time — mid-phase commits are
3338
+ * respected.
3339
+ *
3340
+ * For terminal handles (dry-run, first-gen has no patch work, skip-app does
3341
+ * its own post-commit logic), this returns the precomputed report.
3342
+ */
3343
+ async applyPreparedReplay(prep, options) {
3344
+ if (prep._prepared.kind === "terminal") {
3345
+ return prep._prepared.preparedReport;
3346
+ }
3347
+ return this.flowForKind(prep._prepared.flow).apply(prep, options);
3348
+ }
3349
+ /**
3350
+ * Construct the flow for the current run. `selectFlow` is called from
3351
+ * `prepareReplay` and decides based on options + lockfile state.
3352
+ */
3353
+ selectFlow(options) {
3354
+ if (options?.skipApplication) return new SkipApplicationFlow(this);
3355
+ return this.flowForKind(this.determineFlow());
3356
+ }
3357
+ /**
3358
+ * Construct a flow given its kind. Used by `applyPreparedReplay` to
3359
+ * route to the same kind that was selected in phase 1.
3360
+ */
3361
+ flowForKind(kind) {
3362
+ switch (kind) {
3363
+ case "first-generation":
3364
+ return new FirstGenerationFlow(this);
3365
+ case "no-patches":
3366
+ return new NoPatchesFlow(this);
3367
+ case "normal-regeneration":
3368
+ return new NormalRegenerationFlow(this);
3369
+ case "skip-application":
3370
+ return new SkipApplicationFlow(this);
3371
+ }
3372
+ }
3373
+ /**
3374
+ * Backwards-compatible atomic entry point. Composes `prepareReplay` + `applyPreparedReplay`.
3375
+ * Behavior is byte-identical to the pre-split implementation for all existing callers.
3376
+ */
3377
+ async runReplay(options) {
3378
+ const prep = await this.prepareReplay(options);
3379
+ return this.applyPreparedReplay(prep, options);
3380
+ }
3381
+ /**
3382
+ * Sync the lockfile after a divergent PR was squash-merged.
3383
+ * Call this BEFORE runReplay() when the CLI detects a merged divergent PR.
3384
+ *
3385
+ * After updating the generation record, re-detects customer patches via
3386
+ * tree diff between the generation tag (pure generation tree) and HEAD
3387
+ * (which includes customer customizations after squash merge). This ensures
3388
+ * patches survive the squash merge → regenerate cycle even when the lockfile
3389
+ * was restored from the base branch during conflict PR creation.
3390
+ */
3391
+ async syncFromDivergentMerge(generationCommitSha, options) {
3392
+ const treeHash = await this.git.getTreeHash(generationCommitSha);
3393
+ const timestamp = (await this.git.exec(["log", "-1", "--format=%aI", generationCommitSha])).trim();
3394
+ const record = {
3395
+ commit_sha: generationCommitSha,
3396
+ tree_hash: treeHash,
3397
+ timestamp,
3398
+ cli_version: options?.cliVersion ?? "unknown",
3399
+ generator_versions: options?.generatorVersions ?? {},
3400
+ base_branch_head: options?.baseBranchHead
3401
+ };
3402
+ let resolvedPatches;
3403
+ if (!this.lockManager.exists()) {
3404
+ this.lockManager.initializeInMemory(record);
3405
+ } else {
3406
+ this.lockManager.read();
3407
+ const unresolvedPatches = [
3408
+ ...this.lockManager.getUnresolvedPatches(),
3409
+ ...this.lockManager.getResolvingPatches()
3410
+ ];
3411
+ resolvedPatches = this.lockManager.getPatches().filter((p) => p.status == null);
3412
+ this.lockManager.addGeneration(record);
3413
+ this.lockManager.clearPatches();
3414
+ for (const patch of unresolvedPatches) {
3415
+ this.lockManager.addPatch(patch);
3416
+ }
3417
+ }
3418
+ try {
3419
+ const { patches: redetectedPatches } = await this.detector.detectNewPatches();
3420
+ if (redetectedPatches.length > 0) {
3421
+ const redetectedFiles = new Set(redetectedPatches.flatMap((p) => p.files));
3422
+ const currentPatches = this.lockManager.getPatches();
3423
+ for (const patch of currentPatches) {
3424
+ if (patch.status != null && patch.files.some((f) => redetectedFiles.has(f))) {
3425
+ this.lockManager.removePatch(patch.id);
3426
+ }
3427
+ }
3428
+ for (const patch of redetectedPatches) {
3429
+ this.lockManager.addPatch(patch);
3430
+ }
3431
+ }
3432
+ } catch (error) {
3433
+ for (const patch of resolvedPatches ?? []) {
3434
+ this.lockManager.addPatch(patch);
3435
+ }
3436
+ this.detector.warnings.push(
3437
+ `Patch re-detection failed after divergent merge sync. ${(resolvedPatches ?? []).length} previously resolved patch(es) preserved. Error: ${error instanceof Error ? error.message : String(error)}`
3438
+ );
3439
+ }
3440
+ this.lockManager.save();
3441
+ this.detector.warnings.push(...this.lockManager.warnings);
3442
+ }
3443
+ determineFlow() {
3444
+ try {
3445
+ const lock = this.lockManager.read();
3446
+ return lock.patches.length === 0 ? "no-patches" : "normal-regeneration";
3447
+ } catch (error) {
3448
+ if (error instanceof LockfileNotFoundError) {
3449
+ return "first-generation";
3450
+ }
3451
+ throw error;
3452
+ }
3453
+ }
3297
3454
  /**
3298
3455
  * Rebase cleanly applied patches so they are relative to the current generation.
3299
3456
  * This prevents recurring conflicts on subsequent regenerations.
3300
3457
  * Returns the number of patches rebased.
3458
+ *
3459
+ * @internal Used by Flow implementations in `src/flows/`.
3301
3460
  */
3302
3461
  async rebasePatches(results, currentGenSha) {
3303
3462
  let absorbed = 0;
@@ -3307,6 +3466,7 @@ var ReplayService = class {
3307
3466
  const seenContentHashes = /* @__PURE__ */ new Map();
3308
3467
  const absorbedPatchIds = /* @__PURE__ */ new Set();
3309
3468
  const fernignorePatterns = this.readFernignorePatterns();
3469
+ const warnedGens = /* @__PURE__ */ new Set();
3310
3470
  for (const result of results) {
3311
3471
  if (result.resolvedFiles && Object.keys(result.resolvedFiles).length > 0) {
3312
3472
  const patch = result.patch;
@@ -3351,21 +3511,20 @@ var ReplayService = class {
3351
3511
  }
3352
3512
  }
3353
3513
  const isUserOwnedPerFile = await Promise.all(
3354
- patch.files.map(async (file) => {
3355
- if (file === ".fernignore") return true;
3356
- if (fernignorePatterns.some((p) => file === p || minimatch2(file, p))) {
3357
- return true;
3358
- }
3359
- if (patch.user_owned) return true;
3360
- if (this.fileIsCreationInPatchContent(patch.patch_content, originalByResolved[file] ?? file)) {
3361
- return true;
3362
- }
3363
- const content = await this.git.showFile(currentGenSha, file);
3364
- return content === null;
3365
- })
3514
+ patch.files.map(
3515
+ (file) => this.fileOwnership.resolveCanonical({
3516
+ file,
3517
+ originalFileName: originalByResolved[file],
3518
+ patch,
3519
+ currentGenSha,
3520
+ fernignorePatterns,
3521
+ warnedGens,
3522
+ warnings: this.warnings
3523
+ })
3524
+ )
3366
3525
  );
3367
3526
  const hasProtectedFiles = patch.files.some(
3368
- (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch2(file, p))
3527
+ (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch3(file, p))
3369
3528
  );
3370
3529
  const allUserOwned = isUserOwnedPerFile.every(Boolean);
3371
3530
  if (allUserOwned && patch.files.length > 0) {
@@ -3489,7 +3648,7 @@ var ReplayService = class {
3489
3648
  for (const file of files) {
3490
3649
  if (isBinaryFile(file)) continue;
3491
3650
  try {
3492
- const content = await readFileAsync(join5(this.outputDir, file), "utf-8");
3651
+ const content = await readFileAsync(join6(this.outputDir, file), "utf-8");
3493
3652
  snapshot[file] = content;
3494
3653
  } catch {
3495
3654
  }
@@ -3497,10 +3656,16 @@ var ReplayService = class {
3497
3656
  return snapshot;
3498
3657
  }
3499
3658
  /**
3500
- * FER-10203: returns true when a `[fern-autoversion]` commit between
3501
- * `fromSha` and `toSha` touched any of the patch's `files`. Re-classifies
3502
- * each grep match via `isGenerationCommit` so a customer commit that
3503
- * merely quotes the marker in its body doesn't false-positive.
3659
+ * Detects autoversion contamination — returns true when a
3660
+ * `[fern-autoversion]` commit between `fromSha` and `toSha` touched any
3661
+ * of the patch's `files`. Re-classifies each grep match via
3662
+ * `isGenerationCommit` so a customer commit that merely quotes the
3663
+ * marker in its body doesn't false-positive.
3664
+ *
3665
+ * Used by preGenerationRebase to skip the customer-content rebuild for
3666
+ * patches whose files were last touched by an autoversion step — a
3667
+ * naive `git diff currentGen HEAD` there would capture the
3668
+ * placeholder→semver swap as customer customization.
3504
3669
  */
3505
3670
  async hasAutoversionContamination(fromSha, toSha, files) {
3506
3671
  if (files.length === 0) return false;
@@ -3529,10 +3694,15 @@ var ReplayService = class {
3529
3694
  return false;
3530
3695
  }
3531
3696
  /**
3532
- * FER-10203: rebuild patch_content from `theirs_snapshot` (captured at
3533
- * detection time, predates pipeline content) rather than a HEAD-relative
3534
- * diff. Returns `null` when snapshot is missing or doesn't cover every
3535
- * file in `patch.files` — caller leaves the patch unchanged.
3697
+ * Rebuild patch_content from `theirs_snapshot` (captured at detection
3698
+ * time, predates pipeline content) rather than a HEAD-relative diff.
3699
+ * Returns `null` when snapshot is missing or doesn't cover every file
3700
+ * in `patch.files` — caller leaves the patch unchanged.
3701
+ *
3702
+ * Used as the autoversion-contamination escape hatch: when the
3703
+ * HEAD-relative diff would capture pipeline content (autoversion's
3704
+ * placeholder swap) as customer customization, the snapshot path
3705
+ * preserves the customer's actual intent.
3536
3706
  */
3537
3707
  async computeSnapshotBasedPatchDiff(patch, currentGen) {
3538
3708
  if (!patch.theirs_snapshot) return null;
@@ -3580,7 +3750,7 @@ var ReplayService = class {
3580
3750
  * **Skips patches sharing files with other patches.** HEAD's content
3581
3751
  * for a file shared by N patches is cumulative across all of them, so
3582
3752
  * a HEAD-fallback would falsely encode other patches' contributions
3583
- * into one patch's snapshot — breaking FER-9525 cross-patch isolation
3753
+ * into one patch's snapshot — breaking cross-patch isolation
3584
3754
  * if the snapshot fallback later fires. Per-patch snapshots only
3585
3755
  * make sense when the patch owns its file. Multi-patch-same-file
3586
3756
  * legacy lockfiles keep using line-anchored reconstruction (the
@@ -3642,72 +3812,6 @@ var ReplayService = class {
3642
3812
  );
3643
3813
  }
3644
3814
  }
3645
- /**
3646
- * Determine whether `file` is user-owned (never produced by the generator).
3647
- *
3648
- * Resolution order (first match wins):
3649
- * 1. `patch.user_owned === true` — authoritative, persisted across cycles.
3650
- * 2. `.fernignore` itself, or file matches a .fernignore pattern.
3651
- * 3. Legacy-safe signal: `patch_content` shows this file as a `--- /dev/null`
3652
- * creation. Works for patches that predate the `user_owned` field or
3653
- * whose flag was lost. Immune to rename tricks — relies on raw patch text.
3654
- * 4. Tree-based check against `patch.base_generation` when it is reachable
3655
- * AND distinct from `currentGenSha`. If the base tree is unreachable
3656
- * (shallow clone / aggressive `git gc --prune`), emit a one-time
3657
- * customer-actionable warning (deduplicated via warnedGens) and
3658
- * conservatively treat as user-owned — strictly safer than silent
3659
- * absorption (FER-9809).
3660
- * 5. Final fallback — check `currentGenSha` when there is no usable base
3661
- * (legacy one-gen lockfile). Preserves pre-fix behavior for that edge.
3662
- *
3663
- * `originalFileName` is the pre-rename path when the generator moved the
3664
- * file between the patch's base and the current generation. Tree lookups
3665
- * at ancestor generations use the original path — that's where the
3666
- * generator produced the content.
3667
- */
3668
- async isFileUserOwned(file, patch, currentGenSha, fernignorePatterns, warnedGens, originalFileName) {
3669
- if (patch.user_owned) return true;
3670
- if (file === ".fernignore") return true;
3671
- if (fernignorePatterns.some((p) => file === p || minimatch2(file, p))) return true;
3672
- if (this.fileIsCreationInPatchContent(patch.patch_content, originalFileName ?? file)) {
3673
- return true;
3674
- }
3675
- const base = patch.base_generation;
3676
- if (base && base !== currentGenSha) {
3677
- const reachable = await this.git.commitExists(base) || await this.git.treeExists(base);
3678
- if (!reachable) {
3679
- if (!warnedGens.has(base)) {
3680
- warnedGens.add(base);
3681
- this.warnings.push(
3682
- `Base generation ${base.slice(0, 7)} is unreachable (shallow clone or pruned history). Treating affected files as user-owned to avoid silent data loss. Run \`git fetch --unshallow\` or avoid \`git gc --prune\` to restore full history.`
3683
- );
3684
- }
3685
- return true;
3686
- }
3687
- return await this.git.showFile(base, originalFileName ?? file) === null;
3688
- }
3689
- return await this.git.showFile(currentGenSha, originalFileName ?? file) === null;
3690
- }
3691
- /**
3692
- * Returns true if `file`'s diff section in `patchContent` starts with
3693
- * `--- /dev/null`, meaning this file was introduced by the patch (user
3694
- * creation). Used as a legacy-safe fallback when `patch.user_owned` is
3695
- * absent or cleared.
3696
- */
3697
- fileIsCreationInPatchContent(patchContent, file) {
3698
- const lines = patchContent.split("\n");
3699
- let inFileSection = false;
3700
- for (const line of lines) {
3701
- if (line.startsWith("diff --git ")) {
3702
- inFileSection = line.includes(` b/${file}`) || line.endsWith(` b/${file}`);
3703
- continue;
3704
- }
3705
- if (inFileSection && line.startsWith("--- ")) {
3706
- return line === "--- /dev/null";
3707
- }
3708
- }
3709
- return false;
3710
- }
3711
3815
  /**
3712
3816
  * For conflict patches with mixed results (some files merged, some conflicted),
3713
3817
  * check if the cleanly merged files were absorbed by the generator (empty diff).
@@ -3726,7 +3830,15 @@ var ReplayService = class {
3726
3830
  const warnedGens = /* @__PURE__ */ new Set();
3727
3831
  const generatorCleanFiles = [];
3728
3832
  for (const file of cleanFiles) {
3729
- if (await this.isFileUserOwned(file, patch, currentGenSha, fernignorePatterns, warnedGens)) {
3833
+ const userOwned = await this.fileOwnership.resolveCanonical({
3834
+ file,
3835
+ patch,
3836
+ currentGenSha,
3837
+ fernignorePatterns,
3838
+ warnedGens,
3839
+ warnings: this.warnings
3840
+ });
3841
+ if (userOwned) {
3730
3842
  continue;
3731
3843
  }
3732
3844
  generatorCleanFiles.push(file);
@@ -3755,6 +3867,7 @@ var ReplayService = class {
3755
3867
  * Pre-generation rebase: update patches using the customer's current state.
3756
3868
  * Called BEFORE commitGeneration() while HEAD has customer code.
3757
3869
  */
3870
+ /** @internal Used by Flow implementations in `src/flows/`. */
3758
3871
  async preGenerationRebase(patches) {
3759
3872
  const lock = this.lockManager.read();
3760
3873
  const currentGen = lock.current_generation;
@@ -3793,7 +3906,15 @@ var ReplayService = class {
3793
3906
  if (patch.user_owned) return true;
3794
3907
  if (patch.files.length === 0) return false;
3795
3908
  for (const file of patch.files) {
3796
- if (!await this.isFileUserOwned(file, patch, currentGen, fernignorePatterns, warnedGens)) {
3909
+ const userOwned = await this.fileOwnership.resolveCanonical({
3910
+ file,
3911
+ patch,
3912
+ currentGenSha: currentGen,
3913
+ fernignorePatterns,
3914
+ warnedGens,
3915
+ warnings: this.warnings
3916
+ });
3917
+ if (!userOwned) {
3797
3918
  return false;
3798
3919
  }
3799
3920
  }
@@ -3843,7 +3964,7 @@ var ReplayService = class {
3843
3964
  );
3844
3965
  if (snapHasStaleMarkers) continue;
3845
3966
  const isProtectedSnap = patch.files.some(
3846
- (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch2(file, p))
3967
+ (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch3(file, p))
3847
3968
  );
3848
3969
  if (isProtectedSnap) continue;
3849
3970
  const snapHash = this.detector.computeContentHash(snapshotDiff);
@@ -3878,7 +3999,7 @@ var ReplayService = class {
3878
3999
  continue;
3879
4000
  }
3880
4001
  const isProtected = patch.files.some(
3881
- (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch2(file, p))
4002
+ (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch3(file, p))
3882
4003
  );
3883
4004
  if (isProtected) {
3884
4005
  continue;
@@ -3985,6 +4106,7 @@ var ReplayService = class {
3985
4106
  * relied on them. This is the "surface or preserve, never silently drop"
3986
4107
  * contract for legitimate-deletion cases.
3987
4108
  */
4109
+ /** @internal Used by Flow implementations in `src/flows/`. */
3988
4110
  recordDroppedContextLineWarnings(results) {
3989
4111
  const PREVIEW_COUNT = 5;
3990
4112
  for (const result of results) {
@@ -4007,12 +4129,13 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
4007
4129
  * After applyPatches(), strip conflict markers from conflicting files
4008
4130
  * so only clean content is committed. Keeps the Generated (OURS) side.
4009
4131
  */
4132
+ /** @internal Used by Flow implementations in `src/flows/`. */
4010
4133
  revertConflictingFiles(results) {
4011
4134
  for (const result of results) {
4012
4135
  if (result.status !== "conflict" || !result.fileResults) continue;
4013
4136
  for (const fileResult of result.fileResults) {
4014
4137
  if (fileResult.status !== "conflict") continue;
4015
- const filePath = join5(this.outputDir, fileResult.file);
4138
+ const filePath = join6(this.outputDir, fileResult.file);
4016
4139
  try {
4017
4140
  const content = readFileSync2(filePath, "utf-8");
4018
4141
  const stripped = stripConflictMarkers(content);
@@ -4028,13 +4151,14 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
4028
4151
  * Restores files to their clean generated state from HEAD.
4029
4152
  * Skips .fernignore-protected files to prevent overwriting user content.
4030
4153
  */
4154
+ /** @internal Used by Flow implementations in `src/flows/`. */
4031
4155
  async cleanupStaleConflictMarkers() {
4032
4156
  const markerFiles = await this.git.exec(["grep", "-l", "<<<<<<< Generated", "--", "."]).catch(() => "");
4033
4157
  const files = markerFiles.trim().split("\n").filter(Boolean);
4034
4158
  if (files.length === 0) return;
4035
4159
  const fernignorePatterns = this.readFernignorePatterns();
4036
4160
  for (const file of files) {
4037
- if (fernignorePatterns.some((pattern) => minimatch2(file, pattern))) continue;
4161
+ if (fernignorePatterns.some((pattern) => minimatch3(file, pattern))) continue;
4038
4162
  try {
4039
4163
  await this.git.exec(["checkout", "HEAD", "--", file]);
4040
4164
  } catch {
@@ -4042,10 +4166,11 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
4042
4166
  }
4043
4167
  }
4044
4168
  readFernignorePatterns() {
4045
- const fernignorePath = join5(this.outputDir, ".fernignore");
4169
+ const fernignorePath = join6(this.outputDir, ".fernignore");
4046
4170
  if (!existsSync2(fernignorePath)) return [];
4047
4171
  return readFileSync2(fernignorePath, "utf-8").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
4048
4172
  }
4173
+ /** @internal Used by Flow implementations in `src/flows/`. */
4049
4174
  buildReport(flow, patches, results, options, warnings, rebaseCounts, preRebaseCounts, patchesReverted) {
4050
4175
  const conflictResults = results.filter((r) => r.status === "conflict");
4051
4176
  const conflictDetails = conflictResults.map((r) => {
@@ -4121,8 +4246,8 @@ function computeCommitBuckets(results, absorbedPatchIds, patchesPassedToCommit)
4121
4246
  // src/FernignoreMigrator.ts
4122
4247
  import { createHash as createHash2 } from "crypto";
4123
4248
  import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, statSync, writeFileSync as writeFileSync3 } from "fs";
4124
- import { dirname as dirname4, join as join6 } from "path";
4125
- import { minimatch as minimatch3 } from "minimatch";
4249
+ import { dirname as dirname5, join as join7 } from "path";
4250
+ import { minimatch as minimatch4 } from "minimatch";
4126
4251
  import { parse as parse2, stringify as stringify2 } from "yaml";
4127
4252
  var FernignoreMigrator = class {
4128
4253
  git;
@@ -4134,10 +4259,10 @@ var FernignoreMigrator = class {
4134
4259
  this.outputDir = outputDir;
4135
4260
  }
4136
4261
  fernignoreExists() {
4137
- return existsSync3(join6(this.outputDir, ".fernignore"));
4262
+ return existsSync3(join7(this.outputDir, ".fernignore"));
4138
4263
  }
4139
4264
  readFernignorePatterns() {
4140
- const fernignorePath = join6(this.outputDir, ".fernignore");
4265
+ const fernignorePath = join7(this.outputDir, ".fernignore");
4141
4266
  if (!existsSync3(fernignorePath)) {
4142
4267
  return [];
4143
4268
  }
@@ -4154,7 +4279,7 @@ var FernignoreMigrator = class {
4154
4279
  const commitsOnly = [];
4155
4280
  const syntheticPatches = [];
4156
4281
  for (const pattern of patterns) {
4157
- const matchingPatch = patches.find((p) => p.files.some((f) => minimatch3(f, pattern) || f === pattern));
4282
+ const matchingPatch = patches.find((p) => p.files.some((f) => minimatch4(f, pattern) || f === pattern));
4158
4283
  if (matchingPatch) {
4159
4284
  trackedByBoth.push({
4160
4285
  file: pattern,
@@ -4227,7 +4352,7 @@ var FernignoreMigrator = class {
4227
4352
  fernignoreOnly.push(...remainingFernignoreOnly);
4228
4353
  }
4229
4354
  for (const patch of patches) {
4230
- const hasUnprotectedFiles = patch.files.some((f) => !patterns.some((p) => minimatch3(f, p) || f === p));
4355
+ const hasUnprotectedFiles = patch.files.some((f) => !patterns.some((p) => minimatch4(f, p) || f === p));
4231
4356
  if (hasUnprotectedFiles) {
4232
4357
  commitsOnly.push(patch);
4233
4358
  }
@@ -4239,14 +4364,14 @@ var FernignoreMigrator = class {
4239
4364
  const results = [];
4240
4365
  for (const pattern of patterns) {
4241
4366
  const matching = allFiles.filter(
4242
- (f) => minimatch3(f, pattern) || f === pattern || f.startsWith(pattern + "/")
4367
+ (f) => minimatch4(f, pattern) || f === pattern || f.startsWith(pattern + "/")
4243
4368
  );
4244
4369
  results.push({ pattern, files: matching.length > 0 ? matching : [pattern] });
4245
4370
  }
4246
4371
  return results;
4247
4372
  }
4248
4373
  readFileContent(filePath) {
4249
- const fullPath = join6(this.outputDir, filePath);
4374
+ const fullPath = join7(this.outputDir, filePath);
4250
4375
  if (!existsSync3(fullPath)) {
4251
4376
  return null;
4252
4377
  }
@@ -4311,7 +4436,7 @@ var FernignoreMigrator = class {
4311
4436
  };
4312
4437
  }
4313
4438
  movePatternsToReplayYml(patterns) {
4314
- const replayYmlPath = join6(this.outputDir, ".fern", "replay.yml");
4439
+ const replayYmlPath = join7(this.outputDir, ".fern", "replay.yml");
4315
4440
  let config = {};
4316
4441
  if (existsSync3(replayYmlPath)) {
4317
4442
  const content = readFileSync3(replayYmlPath, "utf-8");
@@ -4320,7 +4445,7 @@ var FernignoreMigrator = class {
4320
4445
  const existing = config.exclude ?? [];
4321
4446
  const merged = [.../* @__PURE__ */ new Set([...existing, ...patterns])];
4322
4447
  config.exclude = merged;
4323
- const dir = dirname4(replayYmlPath);
4448
+ const dir = dirname5(replayYmlPath);
4324
4449
  if (!existsSync3(dir)) {
4325
4450
  mkdirSync2(dir, { recursive: true });
4326
4451
  }
@@ -4331,7 +4456,7 @@ var FernignoreMigrator = class {
4331
4456
  // src/commands/bootstrap.ts
4332
4457
  import { createHash as createHash3 } from "crypto";
4333
4458
  import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
4334
- import { join as join7 } from "path";
4459
+ import { join as join8 } from "path";
4335
4460
  init_GitClient();
4336
4461
  async function bootstrap(outputDir, options) {
4337
4462
  const git = new GitClient(outputDir);
@@ -4498,7 +4623,7 @@ function parseGitLog(log) {
4498
4623
  }
4499
4624
  var REPLAY_FERNIGNORE_ENTRIES = [".fern/replay.lock", ".fern/replay.yml", ".gitattributes"];
4500
4625
  function ensureFernignoreEntries(outputDir) {
4501
- const fernignorePath = join7(outputDir, ".fernignore");
4626
+ const fernignorePath = join8(outputDir, ".fernignore");
4502
4627
  let content = "";
4503
4628
  if (existsSync4(fernignorePath)) {
4504
4629
  content = readFileSync4(fernignorePath, "utf-8");
@@ -4522,7 +4647,7 @@ function ensureFernignoreEntries(outputDir) {
4522
4647
  }
4523
4648
  var GITATTRIBUTES_ENTRIES = [".fern/replay.lock linguist-generated=true"];
4524
4649
  function ensureGitattributesEntries(outputDir) {
4525
- const gitattributesPath = join7(outputDir, ".gitattributes");
4650
+ const gitattributesPath = join8(outputDir, ".gitattributes");
4526
4651
  let content = "";
4527
4652
  if (existsSync4(gitattributesPath)) {
4528
4653
  content = readFileSync4(gitattributesPath, "utf-8");
@@ -4552,7 +4677,7 @@ function computeContentHash(patchContent) {
4552
4677
  }
4553
4678
 
4554
4679
  // src/commands/forget.ts
4555
- import { minimatch as minimatch4 } from "minimatch";
4680
+ import { minimatch as minimatch5 } from "minimatch";
4556
4681
  function parseDiffStat(patchContent) {
4557
4682
  let additions = 0;
4558
4683
  let deletions = 0;
@@ -4582,7 +4707,7 @@ function toMatchedPatch(patch) {
4582
4707
  }
4583
4708
  function matchesPatch(patch, pattern) {
4584
4709
  const fileMatch = patch.files.some(
4585
- (file) => file === pattern || minimatch4(file, pattern)
4710
+ (file) => file === pattern || minimatch5(file, pattern)
4586
4711
  );
4587
4712
  if (fileMatch) return true;
4588
4713
  return patch.original_message.toLowerCase().includes(pattern.toLowerCase());
@@ -4721,8 +4846,8 @@ function reset(outputDir, options) {
4721
4846
 
4722
4847
  // src/commands/resolve.ts
4723
4848
  init_GitClient();
4724
- import { readFile as readFile3 } from "fs/promises";
4725
- import { join as join8 } from "path";
4849
+ import { readFile as readFile4 } from "fs/promises";
4850
+ import { join as join9 } from "path";
4726
4851
  async function resolve(outputDir, options) {
4727
4852
  const lockManager = new LockfileManager(outputDir);
4728
4853
  if (!lockManager.exists()) {
@@ -4770,7 +4895,7 @@ async function resolve(outputDir, options) {
4770
4895
  const result = await computePerPatchDiffWithFallback(
4771
4896
  patch,
4772
4897
  (f) => git.showFile(currentGen, f),
4773
- (f) => readFile3(join8(outputDir, f), "utf-8").catch(() => null),
4898
+ (f) => readFile4(join9(outputDir, f), "utf-8").catch(() => null),
4774
4899
  (files) => git.exec(["diff", currentGen, "--", ...files]).catch(() => null)
4775
4900
  );
4776
4901
  if (result === null) {
@@ -4826,7 +4951,7 @@ async function resolve(outputDir, options) {
4826
4951
  const theirsSnapshot = {};
4827
4952
  for (const file of filesForSnapshot) {
4828
4953
  if (isBinaryFile(file)) continue;
4829
- const content = await readFile3(join8(outputDir, file), "utf-8").catch(() => null);
4954
+ const content = await readFile4(join9(outputDir, file), "utf-8").catch(() => null);
4830
4955
  if (content != null) {
4831
4956
  theirsSnapshot[file] = content;
4832
4957
  }