@fern-api/replay 0.15.0 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -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,1672 @@ 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
+ "--first-parent",
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
- }
915
+ if (lock.patches.find((p) => p.original_commit === commit.sha)) {
916
+ continue;
917
+ }
918
+ const parents = await this.git.getCommitParents(commit.sha);
919
+ if (parents.length > 1) {
920
+ const compositePatch = await this.createMergeCompositePatch({
921
+ mergeCommit: commit,
922
+ firstParent: parents[0],
923
+ lastGen,
924
+ lock
925
+ });
926
+ if (compositePatch) {
927
+ newPatches.push(compositePatch);
1125
928
  }
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);
929
+ continue;
930
+ }
931
+ let patchContent;
932
+ try {
933
+ patchContent = await this.git.formatPatch(commit.sha);
934
+ } catch {
935
+ this.warnings.push(
936
+ `Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
937
+ );
938
+ continue;
939
+ }
940
+ let contentHash = this.computeContentHash(patchContent);
941
+ if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
942
+ continue;
943
+ }
944
+ const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
945
+ const { files, sensitiveFiles } = filterInfrastructureAndSensitiveFiles(filesOutput);
946
+ if (sensitiveFiles.length > 0) {
947
+ this.warnings.push(
948
+ `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
949
+ );
950
+ if (files.length > 0) {
951
+ const parentSha = parents[0];
952
+ if (parentSha) {
1131
953
  try {
1132
- const content = await readFile(filePath, "utf-8");
1133
- const stripped = stripConflictMarkers(content);
1134
- await writeFile(filePath, stripped);
954
+ patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
1135
955
  } catch {
956
+ continue;
1136
957
  }
958
+ if (!patchContent.trim()) continue;
959
+ contentHash = this.computeContentHash(patchContent);
1137
960
  }
1138
961
  }
1139
962
  }
963
+ if (files.length === 0) {
964
+ continue;
965
+ }
966
+ const theirsSnapshot = await capturePatchSnapshot(this.git, files, commit.sha);
967
+ newPatches.push({
968
+ id: `patch-${commit.sha.slice(0, 8)}`,
969
+ content_hash: contentHash,
970
+ original_commit: commit.sha,
971
+ original_message: commit.message,
972
+ original_author: `${commit.authorName} <${commit.authorEmail}>`,
973
+ base_generation: lastGen.commit_sha,
974
+ files,
975
+ patch_content: patchContent,
976
+ ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
977
+ ...this.isCreationOnlyPatch(patchContent) ? { user_owned: true } : {}
978
+ });
1140
979
  }
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
- });
980
+ const survivingPatches = await this.collapseNetZeroFiles(newPatches);
981
+ survivingPatches.reverse();
982
+ const revertedPatchIdSet = /* @__PURE__ */ new Set();
983
+ const revertIndicesToRemove = /* @__PURE__ */ new Set();
984
+ for (let i = 0; i < survivingPatches.length; i++) {
985
+ const patch = survivingPatches[i];
986
+ if (!isRevertCommit(patch.original_message)) continue;
987
+ let body = "";
988
+ try {
989
+ body = await this.git.getCommitBody(patch.original_commit);
990
+ } catch {
991
+ }
992
+ const revertedSha = parseRevertedSha(body);
993
+ const revertedMessage = parseRevertedMessage(patch.original_message);
994
+ let matchedExisting = false;
995
+ if (revertedSha) {
996
+ const existing = lock.patches.find((p) => p.original_commit === revertedSha);
997
+ if (existing) {
998
+ revertedPatchIdSet.add(existing.id);
999
+ revertIndicesToRemove.add(i);
1000
+ matchedExisting = true;
1175
1001
  }
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
- }
1002
+ }
1003
+ if (!matchedExisting && revertedMessage) {
1004
+ const existing = lock.patches.find((p) => p.original_message === revertedMessage);
1005
+ if (existing) {
1006
+ revertedPatchIdSet.add(existing.id);
1007
+ revertIndicesToRemove.add(i);
1008
+ matchedExisting = true;
1185
1009
  }
1186
1010
  }
1187
- } finally {
1188
- await rm(tempDir, { recursive: true }).catch(() => {
1189
- });
1011
+ if (matchedExisting) continue;
1012
+ let matchedNew = false;
1013
+ if (revertedSha) {
1014
+ const idx = survivingPatches.findIndex(
1015
+ (p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_commit === revertedSha
1016
+ );
1017
+ if (idx !== -1) {
1018
+ revertIndicesToRemove.add(i);
1019
+ revertIndicesToRemove.add(idx);
1020
+ matchedNew = true;
1021
+ }
1022
+ }
1023
+ if (!matchedNew && revertedMessage) {
1024
+ const idx = survivingPatches.findIndex(
1025
+ (p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_message === revertedMessage
1026
+ );
1027
+ if (idx !== -1) {
1028
+ revertIndicesToRemove.add(i);
1029
+ revertIndicesToRemove.add(idx);
1030
+ }
1031
+ }
1032
+ if (!matchedExisting && !matchedNew) {
1033
+ revertIndicesToRemove.add(i);
1034
+ }
1190
1035
  }
1191
- return fileResults;
1036
+ const filteredPatches = survivingPatches.filter((_, i) => !revertIndicesToRemove.has(i));
1037
+ return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
1192
1038
  }
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;
1039
+ /**
1040
+ * Compute content hash for deduplication.
1041
+ *
1042
+ * Produces a format-agnostic hash so both `git format-patch` output
1043
+ * (email-wrapped) and plain `git diff` output hash to the same value
1044
+ * when the underlying diff hunks are identical.
1045
+ *
1046
+ * Normalization:
1047
+ * 1. Strip email wrapper: everything before the first `diff --git` line
1048
+ * (From:, Subject:, Date:, diffstat, blank separators).
1049
+ * 2. Strip `index` lines (blob SHA pairs change across rebases).
1050
+ * 3. Strip trailing git version marker (`-- \n<version>`).
1051
+ *
1052
+ * This ensures content hashes match across the no-patches and normal-
1053
+ * regeneration flows, which store formatPatch and git-diff formats
1054
+ * respectively.
1055
+ */
1056
+ computeContentHash(patchContent) {
1057
+ const lines = patchContent.split("\n");
1058
+ const diffStart = lines.findIndex((l) => l.startsWith("diff --git "));
1059
+ const relevantLines = diffStart > 0 ? lines.slice(diffStart) : lines;
1060
+ const normalized = relevantLines.filter((line) => !line.startsWith("index ")).join("\n").replace(/\n-- \n[\d]+\.[\d]+[^\n]*\s*$/, "");
1061
+ return `sha256:${createHash("sha256").update(normalized).digest("hex")}`;
1062
+ }
1063
+ /**
1064
+ * Drop patches whose files were transiently created and then deleted
1065
+ * within the current linear detection window, and are absent from
1066
+ * HEAD at the end of the window.
1067
+ *
1068
+ * Rationale: on a merge commit, createMergeCompositePatch already diffs
1069
+ * firstParent..mergeCommit so net-zero files never enter the composite. On
1070
+ * linear history each commit becomes its own patch, so a file that was
1071
+ * briefly added and later removed survives as a create+delete pair whose
1072
+ * cumulative diff is empty. We drop such patches before they reach the
1073
+ * lockfile.
1074
+ *
1075
+ * A file is "transient" when:
1076
+ * 1. some patch in the window sets `new file mode` for it (a creation), AND
1077
+ * 2. some patch in the window sets `deleted file mode` for it (a deletion), AND
1078
+ * 3. it is absent from HEAD's tree.
1079
+ *
1080
+ * The HEAD guard (#3) prevents false positives when a file is deleted
1081
+ * and then recreated with different content — the cumulative diff of
1082
+ * such a pair is non-empty and must be preserved.
1083
+ *
1084
+ * This only drops patches whose `files` are a subset of the transient
1085
+ * set. A partial-transient patch (one that touches a transient file
1086
+ * alongside a persistent one) is left unmodified; surgical stripping of
1087
+ * single files from a multi-file patch would require a follow-up that
1088
+ * regenerates the patch content via `git format-patch -- <files>`. No
1089
+ * current failing test exercises this, and leaving the patch intact
1090
+ * preserves today's behaviour. Revisit if a future adversarial test
1091
+ * demands per-file stripping.
1092
+ */
1093
+ async collapseNetZeroFiles(patches) {
1094
+ if (patches.length === 0) return patches;
1095
+ const stateByFile = /* @__PURE__ */ new Map();
1096
+ for (const patch of patches) {
1097
+ for (const header of parsePatchFileHeaders(patch.patch_content)) {
1098
+ if (!header.isCreate && !header.isDelete) continue;
1099
+ const prior = stateByFile.get(header.path) ?? { created: false, deleted: false };
1100
+ if (header.isCreate) prior.created = true;
1101
+ if (header.isDelete) prior.deleted = true;
1102
+ stateByFile.set(header.path, prior);
1210
1103
  }
1211
1104
  }
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));
1105
+ let anyCandidate = false;
1106
+ for (const state of stateByFile.values()) {
1107
+ if (state.created && state.deleted) {
1108
+ anyCandidate = true;
1109
+ break;
1219
1110
  }
1220
- try {
1221
- await this.git.execWithInput(["apply", "--3way"], patch.patch_content);
1222
- const fastPathFileResults = await this.populateAccumulatorForPatch(patch, baseGen, currentTreeHash);
1223
- return {
1224
- patch,
1225
- status: "applied",
1226
- method: "git-am",
1227
- ...fastPathFileResults.length > 0 && { fileResults: fastPathFileResults },
1228
- ...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
1229
- };
1230
- } catch {
1231
- for (const [filePath, content] of snapshots) {
1232
- const fullPath = join2(this.outputDir, filePath);
1233
- if (content != null) {
1234
- await writeFile(fullPath, content);
1235
- } else {
1236
- await unlink(fullPath).catch(() => {
1237
- });
1238
- }
1239
- }
1111
+ }
1112
+ if (!anyCandidate) return patches;
1113
+ const headFiles = await this.listHeadFiles();
1114
+ const transient = /* @__PURE__ */ new Set();
1115
+ for (const [file, state] of stateByFile) {
1116
+ if (state.created && state.deleted && !headFiles.has(file)) {
1117
+ transient.add(file);
1240
1118
  }
1241
1119
  }
1242
- return this.applyWithThreeWayMerge(patch);
1120
+ if (transient.size === 0) return patches;
1121
+ return patches.filter((patch) => !patch.files.every((f) => transient.has(f)));
1243
1122
  }
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"]);
1123
+ /**
1124
+ * List every tracked path at HEAD (one `git ls-tree -r` invocation).
1125
+ * Returns an empty Set if HEAD is unborn or the invocation fails — the
1126
+ * caller then treats every candidate as "not in HEAD" and may collapse
1127
+ * more aggressively. On typical SDK repos this is a single sub-10ms call.
1128
+ */
1129
+ async listHeadFiles() {
1253
1130
  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
- });
1131
+ const out = await this.git.exec(["ls-tree", "-r", "--name-only", "HEAD"]);
1132
+ return new Set(out.split("\n").map((l) => l.trim()).filter(Boolean));
1133
+ } catch {
1134
+ return /* @__PURE__ */ new Set();
1272
1135
  }
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;
1136
+ }
1137
+ /**
1138
+ * Create a single composite patch for a merge commit by diffing the merge
1139
+ * commit against its first parent. This captures the net change of the
1140
+ * entire merged branch as one patch, eliminating accumulator chaining and
1141
+ * zero-net-change ghost conflicts.
1142
+ */
1143
+ async createMergeCompositePatch(input) {
1144
+ const { mergeCommit, firstParent, lastGen, lock } = input;
1145
+ const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
1146
+ const filesOutput = await this.git.exec([
1147
+ "diff",
1148
+ "--name-only",
1149
+ firstParent,
1150
+ mergeCommit.sha
1151
+ ]);
1152
+ const { files, sensitiveFiles } = filterInfrastructureAndSensitiveFiles(filesOutput);
1153
+ if (sensitiveFiles.length > 0) {
1154
+ this.warnings.push(
1155
+ `Sensitive file(s) excluded from merge patch for commit ${mergeCommit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
1156
+ );
1157
+ }
1158
+ if (files.length === 0) return null;
1159
+ const diff = await this.git.exec([
1160
+ "diff",
1161
+ firstParent,
1162
+ mergeCommit.sha,
1163
+ "--",
1164
+ ...files
1165
+ ]);
1166
+ if (!diff.trim()) return null;
1167
+ const contentHash = this.computeContentHash(diff);
1168
+ if (lock.patches.some((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
1169
+ return null;
1170
+ }
1171
+ const theirsSnapshot = await capturePatchSnapshot(this.git, files, mergeCommit.sha);
1276
1172
  return {
1277
- patch,
1278
- status: hasConflicts ? "conflict" : "applied",
1279
- method: "3way-merge",
1280
- fileResults,
1281
- conflictReason,
1282
- ...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
1173
+ id: `patch-merge-${mergeCommit.sha.slice(0, 8)}`,
1174
+ content_hash: contentHash,
1175
+ original_commit: mergeCommit.sha,
1176
+ original_message: mergeCommit.message,
1177
+ original_author: `${mergeCommit.authorName} <${mergeCommit.authorEmail}>`,
1178
+ base_generation: lastGen.commit_sha,
1179
+ files,
1180
+ patch_content: diff,
1181
+ ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
1182
+ ...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
1283
1183
  };
1284
1184
  }
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" };
1185
+ /**
1186
+ * Detect patches via tree diff for non-linear history. Returns a composite patch.
1187
+ * Revert reconciliation is skipped here because tree-diff produces a single composite
1188
+ * patch from the aggregate diff — individual revert commits are not distinguishable.
1189
+ */
1190
+ async detectPatchesViaTreeDiff(lastGen, commitKnownMissing) {
1191
+ const diffBase = await this.resolveDiffBase(lastGen, commitKnownMissing);
1192
+ if (!diffBase) {
1193
+ return this.detectPatchesViaCommitScan();
1194
+ }
1195
+ const lockForGuard = this.lockManager.read();
1196
+ const knownGenerations = new Set(lockForGuard.generations.map((g) => g.commit_sha));
1197
+ let resolvedBaseGeneration = commitKnownMissing ? diffBase : lastGen.commit_sha;
1198
+ if (commitKnownMissing && !knownGenerations.has(diffBase)) {
1199
+ const fallback = lockForGuard.current_generation ?? lockForGuard.generations[lockForGuard.generations.length - 1]?.commit_sha;
1200
+ if (!fallback) {
1201
+ this.warnings.push(
1202
+ `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.`
1203
+ );
1204
+ return { patches: [], revertedPatchIds: [] };
1290
1205
  }
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
- }
1206
+ resolvedBaseGeneration = fallback;
1207
+ }
1208
+ const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
1209
+ const { files, sensitiveFiles } = filterInfrastructureAndSensitiveFiles(filesOutput);
1210
+ if (sensitiveFiles.length > 0) {
1211
+ this.warnings.push(
1212
+ `Sensitive file(s) excluded from composite patch: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
1213
+ );
1214
+ }
1215
+ if (files.length === 0) return { patches: [], revertedPatchIds: [] };
1216
+ const diff = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
1217
+ if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
1218
+ const contentHash = this.computeContentHash(diff);
1219
+ const lock = this.lockManager.read();
1220
+ if (lock.patches.some((p) => p.content_hash === contentHash) || (lock.forgotten_hashes ?? []).includes(contentHash)) {
1221
+ return { patches: [], revertedPatchIds: [] };
1222
+ }
1223
+ const headSha = (await this.git.exec(["rev-parse", "HEAD"])).trim();
1224
+ const theirsSnapshot = await capturePatchSnapshot(this.git, files, "HEAD");
1225
+ const compositePatch = {
1226
+ id: `patch-composite-${headSha.slice(0, 8)}`,
1227
+ content_hash: contentHash,
1228
+ original_commit: headSha,
1229
+ original_message: "Customer customizations (composite)",
1230
+ original_author: "composite",
1231
+ // Anchor `base_generation` on a SHA the applicator's
1232
+ // `resolveBaseGeneration` can find — always a member of
1233
+ // `lock.generations`. When `diffBase` is a recorded
1234
+ // generation, use it directly; otherwise the guard above
1235
+ // has already mapped it onto `current_generation`.
1236
+ base_generation: resolvedBaseGeneration,
1237
+ files,
1238
+ patch_content: diff,
1239
+ ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
1240
+ ...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
1241
+ };
1242
+ return { patches: [compositePatch], revertedPatchIds: [] };
1243
+ }
1244
+ /**
1245
+ * Last-resort detection when both generation commit and tree are unreachable.
1246
+ * Scans all commits from HEAD, filters against known lockfile patches, and
1247
+ * skips creation-only commits (squashed history after force push).
1248
+ *
1249
+ * Detected patches use the commit's parent as base_generation so the applicator
1250
+ * can find base file content from the parent's tree (which IS reachable).
1251
+ */
1252
+ async detectPatchesViaCommitScan() {
1253
+ const lock = this.lockManager.read();
1254
+ const knownGenerations = new Set(lock.generations.map((g) => g.commit_sha));
1255
+ const fallbackAnchor = lock.current_generation ?? lock.generations[lock.generations.length - 1]?.commit_sha;
1256
+ const log = await this.git.exec([
1257
+ "log",
1258
+ "--max-count=200",
1259
+ "--format=%H%x00%an%x00%ae%x00%s",
1260
+ "HEAD",
1261
+ "--",
1262
+ this.sdkOutputDir
1263
+ ]);
1264
+ if (!log.trim()) {
1265
+ return { patches: [], revertedPatchIds: [] };
1266
+ }
1267
+ const commits = this.parseGitLog(log);
1268
+ const newPatches = [];
1269
+ const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
1270
+ const existingHashes = new Set(lock.patches.map((p) => p.content_hash));
1271
+ const existingCommits = new Set(lock.patches.map((p) => p.original_commit));
1272
+ for (const commit of commits) {
1273
+ if (isGenerationCommit(commit)) {
1274
+ continue;
1309
1275
  }
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
- }
1276
+ const parents = await this.git.getCommitParents(commit.sha);
1277
+ if (parents.length > 1) {
1278
+ continue;
1328
1279
  }
1329
- const snapshotForFile = patch.theirs_snapshot?.[resolvedPath] ?? patch.theirs_snapshot?.[filePath];
1330
- const fileIsShared = this.sharedFiles.has(resolvedPath) || this.sharedFiles.has(filePath);
1331
- const useSnapshotAsPrimary = !fileIsShared && snapshotForFile != null;
1332
- if (!ghostReconstructed) {
1333
- if (useSnapshotAsPrimary) {
1334
- theirs = snapshotForFile;
1335
- } else {
1336
- theirs = await this.applyPatchToContent(
1337
- base,
1338
- patch.patch_content,
1339
- filePath,
1340
- tempGit,
1341
- tempDir,
1342
- renameSourcePath
1343
- );
1344
- const reconstructionHasMarkers = theirs != null && (theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization"));
1345
- const baseHadMarkers = base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization"));
1346
- if (reconstructionHasMarkers && !baseHadMarkers && snapshotForFile != null) {
1347
- theirs = snapshotForFile;
1348
- }
1349
- }
1280
+ if (existingCommits.has(commit.sha)) {
1281
+ continue;
1350
1282
  }
1351
- const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
1352
- if (theirs) {
1353
- const theirsHasMarkers = theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization");
1354
- const baseHasMarkers = base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization"));
1355
- if (theirsHasMarkers && !baseHasMarkers) {
1356
- if (accumulatorEntry) {
1357
- theirs = null;
1358
- } else {
1359
- return {
1360
- file: resolvedPath,
1361
- status: "skipped",
1362
- reason: "stale-conflict-markers"
1363
- };
1364
- }
1365
- }
1283
+ let patchContent;
1284
+ try {
1285
+ patchContent = await this.git.formatPatch(commit.sha);
1286
+ } catch {
1287
+ continue;
1366
1288
  }
1367
- let useAccumulatorAsMergeBase = false;
1368
- if (accumulatorEntry && (theirs == null || base == null)) {
1369
- theirs = await this.applyPatchToContent(
1370
- accumulatorEntry.content,
1371
- patch.patch_content,
1372
- filePath,
1373
- tempGit,
1374
- tempDir
1375
- );
1376
- if (theirs != null) {
1377
- useAccumulatorAsMergeBase = true;
1378
- } else if (await this.isPatchAlreadyApplied(
1379
- patch.patch_content,
1380
- filePath,
1381
- accumulatorEntry.content,
1382
- tempGit,
1383
- tempDir
1384
- )) {
1385
- theirs = accumulatorEntry.content;
1386
- useAccumulatorAsMergeBase = true;
1387
- }
1289
+ let contentHash = this.computeContentHash(patchContent);
1290
+ if (existingHashes.has(contentHash) || forgottenHashes.has(contentHash)) {
1291
+ continue;
1388
1292
  }
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
- }
1293
+ if (this.isCreationOnlyPatch(patchContent)) {
1294
+ continue;
1399
1295
  }
1400
- let effective_theirs = theirs;
1401
- let baseMismatchSkipped = false;
1402
- if (theirs != null && base != null && !useAccumulatorAsMergeBase) {
1403
- if (accumulatorEntry && accumulatorEntry.baseGeneration === patch.base_generation) {
1296
+ const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
1297
+ const { files, sensitiveFiles } = filterInfrastructureAndSensitiveFiles(filesOutput);
1298
+ if (sensitiveFiles.length > 0) {
1299
+ this.warnings.push(
1300
+ `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
1301
+ );
1302
+ if (files.length > 0) {
1303
+ if (parents.length === 0) {
1304
+ continue;
1305
+ }
1404
1306
  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
- }
1307
+ patchContent = await this.git.exec(["diff", parents[0], commit.sha, "--", ...files]);
1411
1308
  } catch {
1412
- effective_theirs = theirs;
1309
+ continue;
1413
1310
  }
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
- };
1311
+ if (!patchContent.trim()) continue;
1312
+ contentHash = this.computeContentHash(patchContent);
1441
1313
  }
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
1314
  }
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
- };
1315
+ if (files.length === 0) {
1316
+ continue;
1469
1317
  }
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
- });
1318
+ if (parents.length === 0) {
1319
+ continue;
1480
1320
  }
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
- };
1321
+ const parentSha = parents[0];
1322
+ let baseGeneration;
1323
+ if (knownGenerations.has(parentSha)) {
1324
+ baseGeneration = parentSha;
1325
+ } else if (fallbackAnchor) {
1326
+ baseGeneration = fallbackAnchor;
1327
+ } else {
1328
+ this.warnings.push(
1329
+ `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.`
1330
+ );
1331
+ continue;
1489
1332
  }
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
- };
1333
+ const theirsSnapshot = await capturePatchSnapshot(this.git, files, commit.sha);
1334
+ newPatches.push({
1335
+ id: `patch-${commit.sha.slice(0, 8)}`,
1336
+ content_hash: contentHash,
1337
+ original_commit: commit.sha,
1338
+ original_message: commit.message,
1339
+ original_author: `${commit.authorName} <${commit.authorEmail}>`,
1340
+ base_generation: baseGeneration,
1341
+ files,
1342
+ patch_content: patchContent,
1343
+ ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {}
1344
+ });
1501
1345
  }
1346
+ newPatches.reverse();
1347
+ return { patches: newPatches, revertedPatchIds: [] };
1502
1348
  }
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);
1349
+ /**
1350
+ * Check if a format-patch consists entirely of new-file creations.
1351
+ * Used to identify squashed commits after force push, which create all files
1352
+ * from scratch (--- /dev/null) rather than modifying existing files.
1353
+ */
1354
+ isCreationOnlyPatch(patchContent) {
1355
+ const diffOldHeaders = patchContent.split("\n").filter((l) => l.startsWith("--- "));
1356
+ if (diffOldHeaders.length === 0) {
1357
+ return false;
1508
1358
  }
1509
- return result;
1359
+ return diffOldHeaders.every((l) => l === "--- /dev/null");
1510
1360
  }
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)));
1361
+ /**
1362
+ * Resolve the best available diff base for a generation record.
1363
+ * Prefers commit_sha, falls back to tree_hash for unreachable commits.
1364
+ * When commitKnownMissing is true, skips the redundant commitExists check.
1365
+ */
1366
+ async resolveDiffBase(gen, commitKnownMissing) {
1367
+ if (!commitKnownMissing && await this.git.commitExists(gen.commit_sha)) {
1368
+ return gen.commit_sha;
1369
+ }
1370
+ if (await this.git.treeExists(gen.tree_hash)) {
1371
+ return gen.tree_hash;
1372
+ }
1373
+ return null;
1515
1374
  }
1516
- async resolveFilePath(filePath, baseTreeHash, currentTreeHash) {
1517
- const config = this.lockManager.getCustomizationsConfig();
1518
- if (config.moves) {
1519
- for (const move of config.moves) {
1520
- if (minimatch(filePath, move.from) || filePath === move.from) {
1521
- if (filePath === move.from) {
1522
- return move.to;
1523
- }
1524
- const fromBase = move.from.replace(/\*\*.*$/, "");
1525
- const toBase = move.to.replace(/\*\*.*$/, "");
1526
- if (filePath.startsWith(fromBase)) {
1527
- return toBase + filePath.slice(fromBase.length);
1528
- }
1529
- }
1375
+ parseGitLog(log) {
1376
+ return log.trim().split("\n").map((line) => {
1377
+ const [sha, authorName, authorEmail, message] = line.split("\0");
1378
+ return { sha, authorName, authorEmail, message };
1379
+ });
1380
+ }
1381
+ getLastGeneration(lock) {
1382
+ return lock.generations.find((g) => g.commit_sha === lock.current_generation);
1383
+ }
1384
+ };
1385
+
1386
+ // src/ThreeWayMerge.ts
1387
+ import { diff3Merge, diffPatch } from "node-diff3";
1388
+ function threeWayMerge(base, ours, theirs) {
1389
+ const baseLines = base.split("\n");
1390
+ const oursLines = ours.split("\n");
1391
+ const theirsLines = theirs.split("\n");
1392
+ const regions = diff3Merge(oursLines, baseLines, theirsLines);
1393
+ const outputLines = [];
1394
+ const conflicts = [];
1395
+ let currentLine = 1;
1396
+ for (const region of regions) {
1397
+ if (region.ok) {
1398
+ outputLines.push(...region.ok);
1399
+ currentLine += region.ok.length;
1400
+ } else if (region.conflict) {
1401
+ const resolved = tryResolveConflict(
1402
+ region.conflict.a,
1403
+ // ours (generator)
1404
+ region.conflict.o,
1405
+ // base
1406
+ region.conflict.b
1407
+ // theirs (user)
1408
+ );
1409
+ if (resolved !== null) {
1410
+ outputLines.push(...resolved);
1411
+ currentLine += resolved.length;
1412
+ } else {
1413
+ const startLine = currentLine;
1414
+ outputLines.push("<<<<<<< Generated");
1415
+ outputLines.push(...region.conflict.a);
1416
+ outputLines.push("=======");
1417
+ outputLines.push(...region.conflict.b);
1418
+ outputLines.push(">>>>>>> Your customization");
1419
+ const conflictLines = region.conflict.a.length + region.conflict.b.length + 3;
1420
+ conflicts.push({
1421
+ startLine,
1422
+ endLine: startLine + conflictLines - 1,
1423
+ ours: region.conflict.a,
1424
+ theirs: region.conflict.b
1425
+ });
1426
+ currentLine += conflictLines;
1530
1427
  }
1531
1428
  }
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;
1429
+ }
1430
+ const result = {
1431
+ content: outputLines.join("\n"),
1432
+ hasConflicts: conflicts.length > 0,
1433
+ conflicts
1434
+ };
1435
+ if (conflicts.length === 0) {
1436
+ const dropped = computeDroppedContextLines(baseLines, theirsLines, outputLines);
1437
+ if (dropped.length > 0) {
1438
+ result.droppedContextLines = dropped;
1541
1439
  }
1542
- return filePath;
1543
1440
  }
1544
- async applyPatchToContent(base, patchContent, filePath, tempGit, tempDir, sourceFilePath) {
1545
- if (!base) {
1546
- return this.extractNewFileFromPatch(patchContent, filePath);
1441
+ return result;
1442
+ }
1443
+ function computeDroppedContextLines(baseLines, theirsLines, mergedLines) {
1444
+ const baseCounts = countLines(baseLines);
1445
+ const theirsCounts = countLines(theirsLines);
1446
+ const mergedCounts = countLines(mergedLines);
1447
+ const dropped = [];
1448
+ const seen = /* @__PURE__ */ new Set();
1449
+ for (const line of baseLines) {
1450
+ if (seen.has(line)) continue;
1451
+ const inBase = baseCounts.get(line) ?? 0;
1452
+ const inTheirs = theirsCounts.get(line) ?? 0;
1453
+ const inMerged = mergedCounts.get(line) ?? 0;
1454
+ if (inBase > 0 && inTheirs > 0 && inMerged < Math.min(inBase, inTheirs)) {
1455
+ dropped.push(line);
1456
+ seen.add(line);
1547
1457
  }
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");
1458
+ }
1459
+ return dropped;
1460
+ }
1461
+ function countLines(lines) {
1462
+ const counts = /* @__PURE__ */ new Map();
1463
+ for (const line of lines) {
1464
+ counts.set(line, (counts.get(line) ?? 0) + 1);
1465
+ }
1466
+ return counts;
1467
+ }
1468
+ function tryResolveConflict(oursLines, baseLines, theirsLines) {
1469
+ if (baseLines.length === 0) {
1470
+ return null;
1471
+ }
1472
+ const oursPatches = diffPatch(baseLines, oursLines);
1473
+ const theirsPatches = diffPatch(baseLines, theirsLines);
1474
+ if (oursPatches.length === 0) return theirsLines;
1475
+ if (theirsPatches.length === 0) return oursLines;
1476
+ if (patchesOverlap(oursPatches, theirsPatches)) {
1477
+ return null;
1478
+ }
1479
+ return applyBothPatches(baseLines, oursPatches, theirsPatches);
1480
+ }
1481
+ function patchesOverlap(oursPatches, theirsPatches) {
1482
+ for (const op of oursPatches) {
1483
+ const oStart = op.buffer1.offset;
1484
+ const oEnd = oStart + op.buffer1.length;
1485
+ for (const tp of theirsPatches) {
1486
+ const tStart = tp.buffer1.offset;
1487
+ const tEnd = tStart + tp.buffer1.length;
1488
+ if (op.buffer1.length === 0 && tp.buffer1.length === 0 && oStart === tStart) {
1489
+ return true;
1490
+ }
1491
+ if (tp.buffer1.length === 0 && tStart === oEnd) {
1492
+ return true;
1493
+ }
1494
+ if (op.buffer1.length === 0 && oStart === tEnd) {
1495
+ return true;
1496
+ }
1497
+ if (oStart < tEnd && tStart < oEnd) {
1498
+ return true;
1565
1499
  }
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
1500
  }
1576
1501
  }
1502
+ return false;
1503
+ }
1504
+ function applyBothPatches(baseLines, oursPatches, theirsPatches) {
1505
+ const allPatches = [
1506
+ ...oursPatches.map((p) => ({
1507
+ offset: p.buffer1.offset,
1508
+ length: p.buffer1.length,
1509
+ replacement: p.buffer2.chunk
1510
+ })),
1511
+ ...theirsPatches.map((p) => ({
1512
+ offset: p.buffer1.offset,
1513
+ length: p.buffer1.length,
1514
+ replacement: p.buffer2.chunk
1515
+ }))
1516
+ ];
1517
+ allPatches.sort((a, b) => b.offset - a.offset);
1518
+ const result = [...baseLines];
1519
+ for (const p of allPatches) {
1520
+ result.splice(p.offset, p.length, ...p.replacement);
1521
+ }
1522
+ return result;
1523
+ }
1524
+
1525
+ // src/ReplayApplicator.ts
1526
+ import { mkdir as mkdir2, mkdtemp, readFile as readFile2, rm, unlink, writeFile as writeFile2 } from "fs/promises";
1527
+ import { tmpdir } from "os";
1528
+ import { dirname as dirname3, join as join3 } from "path";
1529
+ import { minimatch } from "minimatch";
1530
+
1531
+ // src/accumulator/MergeAccumulator.ts
1532
+ var MergeAccumulator = class {
1533
+ constructor(mode, sharedFiles) {
1534
+ this.mode = mode;
1535
+ this.sharedFiles = sharedFiles;
1536
+ }
1537
+ entries = /* @__PURE__ */ new Map();
1538
+ /** Was this file touched by 2+ patches in the current `applyPatches` invocation? */
1539
+ isShared(path) {
1540
+ return this.sharedFiles.has(path);
1541
+ }
1542
+ /** Has any prior patch already recorded a result for this path? */
1543
+ has(path) {
1544
+ return this.entries.has(path);
1545
+ }
1546
+ /** Look up the accumulated content + base generation for this path. */
1547
+ lookup(path) {
1548
+ return this.entries.get(path);
1549
+ }
1577
1550
  /**
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.
1551
+ * True if any of the patch's files have a prior accumulator entry — in
1552
+ * which case the fast path (`git apply --3way`) cannot honor prior
1553
+ * patches' contributions and we must route through the slow path.
1583
1554
  */
1584
- async isPatchAlreadyApplied(patchContent, filePath, content, tempGit, tempDir) {
1585
- const fileDiff = this.extractFileDiff(patchContent, filePath);
1586
- if (!fileDiff) return false;
1587
- try {
1588
- const tempFilePath = join2(tempDir, filePath);
1589
- await mkdir(dirname2(tempFilePath), { recursive: true });
1590
- await writeFile(tempFilePath, content);
1591
- await tempGit.exec(["add", filePath]);
1592
- await tempGit.exec(["commit", "-m", `check already-applied ${filePath}`, "--allow-empty"]);
1593
- await tempGit.execWithInput(["apply", "--reverse", "--check", "--allow-empty"], fileDiff);
1594
- return true;
1595
- } catch {
1596
- return false;
1555
+ shouldSkipFastPath(patch) {
1556
+ return patch.files.some((f) => this.entries.has(f));
1557
+ }
1558
+ /** Record a successful (clean) merge result. */
1559
+ recordMerge(path, content, baseGeneration) {
1560
+ this.entries.set(path, { content, baseGeneration });
1561
+ }
1562
+ /**
1563
+ * Record a conflicted merge result. Populates only in `"resolve"` mode
1564
+ * replay mode keeps the accumulator clean of marker-laden content
1565
+ * since markers will be stripped from disk after the apply loop.
1566
+ */
1567
+ recordConflict(path, content, baseGeneration) {
1568
+ if (this.mode === "resolve") {
1569
+ this.entries.set(path, { content, baseGeneration });
1597
1570
  }
1598
1571
  }
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);
1572
+ /**
1573
+ * Defensive copy for test introspection. Used by invariant I2 to assert
1574
+ * that after every applied patch, the accumulator has an entry for each
1575
+ * file the patch touched (see `__tests__/invariants/i2-accumulator-correctness.ts`).
1576
+ *
1577
+ * @internal
1578
+ */
1579
+ snapshot() {
1580
+ const copy = /* @__PURE__ */ new Map();
1581
+ for (const [path, entry] of this.entries) {
1582
+ copy.set(path, { content: entry.content, baseGeneration: entry.baseGeneration });
1583
+ }
1584
+ return copy;
1585
+ }
1586
+ };
1587
+
1588
+ // src/applicator/resolveInputs.ts
1589
+ import { readFile } from "fs/promises";
1590
+ import { join as join2 } from "path";
1591
+ async function resolveInputs(args) {
1592
+ const {
1593
+ patch,
1594
+ filePath,
1595
+ git,
1596
+ lockManager,
1597
+ outputDir,
1598
+ isTreeReachable,
1599
+ resolveBaseGeneration,
1600
+ resolveFilePath,
1601
+ extractRenameSource,
1602
+ extractFileDiff: extractFileDiff2
1603
+ } = args;
1604
+ const baseGen = await resolveBaseGeneration(patch.base_generation);
1605
+ if (!baseGen) {
1606
+ return {
1607
+ resolvedPath: filePath,
1608
+ metadata: {
1609
+ patchId: patch.id,
1610
+ patchMessage: patch.original_message,
1611
+ baseGeneration: patch.base_generation,
1612
+ currentGeneration: lockManager.read().current_generation
1613
+ },
1614
+ base: null,
1615
+ ours: null,
1616
+ oursPath: join2(outputDir, filePath),
1617
+ renameSourcePath: void 0,
1618
+ ghostTheirs: null,
1619
+ earlyExit: { status: "skipped", reason: "base-generation-not-found" }
1620
+ };
1621
+ }
1622
+ const lock = lockManager.read();
1623
+ const currentGen = lock.generations.find((g) => g.commit_sha === lock.current_generation);
1624
+ const currentTreeHash = currentGen?.tree_hash ?? baseGen.tree_hash;
1625
+ const resolvedPath = await resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
1626
+ const metadata = {
1627
+ patchId: patch.id,
1628
+ patchMessage: patch.original_message,
1629
+ baseGeneration: patch.base_generation,
1630
+ currentGeneration: lock.current_generation
1631
+ };
1632
+ let base = await git.showFile(baseGen.tree_hash, filePath);
1633
+ let renameSourcePath;
1634
+ if (!base) {
1635
+ const renameSource = extractRenameSource(patch.patch_content, filePath);
1636
+ if (renameSource) {
1637
+ base = await git.showFile(baseGen.tree_hash, renameSource);
1638
+ renameSourcePath = renameSource;
1639
+ }
1640
+ }
1641
+ const oursPath = join2(outputDir, resolvedPath);
1642
+ const ours = await readFile(oursPath, "utf-8").catch(() => null);
1643
+ let ghostTheirs = null;
1644
+ if (!base && ours && !renameSourcePath) {
1645
+ const treeReachable = await isTreeReachable(baseGen.tree_hash);
1646
+ if (!treeReachable) {
1647
+ const fileDiff = extractFileDiff2(patch.patch_content, filePath);
1648
+ if (fileDiff) {
1649
+ const { reconstructFromGhostPatch: reconstructFromGhostPatch2 } = await Promise.resolve().then(() => (init_HybridReconstruction(), HybridReconstruction_exports));
1650
+ const result = reconstructFromGhostPatch2(fileDiff, ours);
1651
+ if (result) {
1652
+ base = result.base;
1653
+ ghostTheirs = result.theirs;
1611
1654
  }
1612
- continue;
1613
- }
1614
- if (inTargetFile) {
1615
- diffLines.push(line);
1616
1655
  }
1617
1656
  }
1618
- return diffLines.length > 0 ? diffLines.join("\n") + "\n" : null;
1619
1657
  }
1620
- extractRenameSource(patchContent, targetFilePath) {
1621
- const lines = patchContent.split("\n");
1622
- let inTargetFile = false;
1623
- for (const line of lines) {
1624
- if (line.startsWith("diff --git")) {
1625
- if (inTargetFile) break;
1626
- inTargetFile = isDiffLineForFile(line, targetFilePath);
1627
- continue;
1658
+ return {
1659
+ resolvedPath,
1660
+ metadata,
1661
+ base,
1662
+ ours,
1663
+ oursPath,
1664
+ renameSourcePath,
1665
+ ghostTheirs
1666
+ };
1667
+ }
1668
+
1669
+ // src/conflict-utils.ts
1670
+ var CONFLICT_OPENER = "<<<<<<< Generated";
1671
+ var CONFLICT_SEPARATOR = "=======";
1672
+ var CONFLICT_CLOSER = ">>>>>>> Your customization";
1673
+ function trimCR(line) {
1674
+ return line.endsWith("\r") ? line.slice(0, -1) : line;
1675
+ }
1676
+ function findConflictRanges(lines) {
1677
+ const ranges = [];
1678
+ let i = 0;
1679
+ while (i < lines.length) {
1680
+ if (trimCR(lines[i]) === CONFLICT_OPENER) {
1681
+ let separatorIdx = -1;
1682
+ let j = i + 1;
1683
+ let found = false;
1684
+ while (j < lines.length) {
1685
+ const trimmed = trimCR(lines[j]);
1686
+ if (trimmed === CONFLICT_OPENER) {
1687
+ break;
1688
+ }
1689
+ if (separatorIdx === -1 && trimmed === CONFLICT_SEPARATOR) {
1690
+ separatorIdx = j;
1691
+ } else if (separatorIdx !== -1 && trimmed === CONFLICT_CLOSER) {
1692
+ ranges.push({ start: i, separator: separatorIdx, end: j });
1693
+ i = j;
1694
+ found = true;
1695
+ break;
1696
+ }
1697
+ j++;
1628
1698
  }
1629
- if (!inTargetFile) continue;
1630
- if (line.startsWith("@@")) break;
1631
- if (line.startsWith("rename from ")) {
1632
- return line.slice("rename from ".length);
1699
+ if (!found) {
1700
+ i++;
1701
+ continue;
1633
1702
  }
1634
1703
  }
1635
- return null;
1704
+ i++;
1636
1705
  }
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;
1706
+ return ranges;
1707
+ }
1708
+ function stripConflictMarkers(content) {
1709
+ const lines = content.split(/\r?\n/);
1710
+ const ranges = findConflictRanges(lines);
1711
+ if (ranges.length === 0) {
1712
+ return content;
1713
+ }
1714
+ const result = [];
1715
+ let rangeIdx = 0;
1716
+ for (let i = 0; i < lines.length; i++) {
1717
+ if (rangeIdx < ranges.length) {
1718
+ const range = ranges[rangeIdx];
1719
+ if (i === range.start) {
1650
1720
  continue;
1651
1721
  }
1652
- if (!inTargetFile) continue;
1653
- if (!inHunk) {
1654
- if (line === "--- /dev/null" || line.startsWith("new file mode")) {
1655
- isNewFile = true;
1656
- }
1722
+ if (i > range.start && i < range.separator) {
1723
+ result.push(lines[i]);
1724
+ continue;
1657
1725
  }
1658
- if (line.startsWith("@@")) {
1659
- if (!isNewFile) return null;
1660
- inHunk = true;
1726
+ if (i === range.separator) {
1661
1727
  continue;
1662
1728
  }
1663
- if (!inHunk) continue;
1664
- if (line === "\") {
1665
- noTrailingNewline = true;
1729
+ if (i > range.separator && i < range.end) {
1666
1730
  continue;
1667
1731
  }
1668
- if (line.startsWith("+") && !line.startsWith("+++")) {
1669
- addedLines.push(line.slice(1));
1732
+ if (i === range.end) {
1733
+ rangeIdx++;
1734
+ continue;
1670
1735
  }
1671
1736
  }
1672
- if (addedLines.length === 0) return null;
1673
- return addedLines.join("\n") + (noTrailingNewline ? "" : "\n");
1674
- }
1675
- };
1676
- function isBinaryFile(filePath) {
1677
- const ext = extname(filePath).toLowerCase();
1678
- return BINARY_EXTENSIONS.has(ext);
1679
- }
1680
- function computeDroppedContextLinesForFile(base, theirs, finalContent) {
1681
- const baseLines = base.split("\n");
1682
- const theirsLines = theirs.split("\n");
1683
- const finalLines = finalContent.split("\n");
1684
- const baseCounts = /* @__PURE__ */ new Map();
1685
- const theirsCounts = /* @__PURE__ */ new Map();
1686
- const finalCounts = /* @__PURE__ */ new Map();
1687
- for (const l of baseLines) baseCounts.set(l, (baseCounts.get(l) ?? 0) + 1);
1688
- for (const l of theirsLines) theirsCounts.set(l, (theirsCounts.get(l) ?? 0) + 1);
1689
- for (const l of finalLines) finalCounts.set(l, (finalCounts.get(l) ?? 0) + 1);
1690
- const dropped = [];
1691
- const seen = /* @__PURE__ */ new Set();
1692
- for (const line of baseLines) {
1693
- if (seen.has(line)) continue;
1694
- const inBase = baseCounts.get(line) ?? 0;
1695
- const inTheirs = theirsCounts.get(line) ?? 0;
1696
- const inFinal = finalCounts.get(line) ?? 0;
1697
- if (inBase > 0 && inTheirs > 0 && inFinal < Math.min(inBase, inTheirs)) {
1698
- dropped.push(line);
1699
- seen.add(line);
1700
- }
1737
+ result.push(lines[i]);
1701
1738
  }
1702
- return dropped;
1739
+ return result.join("\n");
1703
1740
  }
1704
- function isDiffLineForFile(diffLine, filePath) {
1705
- const match = diffLine.match(/^diff --git a\/.+ b\/(.+)$/);
1706
- return match !== null && match[1] === filePath;
1741
+ function hasConflictMarkerStrings(content) {
1742
+ if (content == null) return false;
1743
+ return content.includes(CONFLICT_OPENER) || content.includes(CONFLICT_CLOSER);
1707
1744
  }
1708
1745
 
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 });
1746
+ // src/applicator/buildMergePlan.ts
1747
+ async function buildMergePlan(args) {
1748
+ const {
1749
+ inputs,
1750
+ patch,
1751
+ filePath,
1752
+ accumulator,
1753
+ tempGit,
1754
+ tempDir,
1755
+ applyPatchToContent: applyPatchToContent2,
1756
+ isPatchAlreadyApplied
1757
+ } = args;
1758
+ const { base, ours, resolvedPath, renameSourcePath, ghostTheirs } = inputs;
1759
+ let theirs = ghostTheirs;
1760
+ const ghostReconstructed = ghostTheirs != null;
1761
+ const snapshotForFile = patch.theirs_snapshot?.[resolvedPath] ?? patch.theirs_snapshot?.[filePath];
1762
+ const fileIsShared = accumulator.isShared(resolvedPath) || accumulator.isShared(filePath);
1763
+ const useSnapshotAsPrimary = !fileIsShared && snapshotForFile != null;
1764
+ let primarySource = ghostReconstructed ? "ghost" : "reconstructed";
1765
+ if (!ghostReconstructed) {
1766
+ if (useSnapshotAsPrimary) {
1767
+ theirs = snapshotForFile;
1768
+ primarySource = "snapshot";
1769
+ } else {
1770
+ theirs = await applyPatchToContent2(
1771
+ base,
1772
+ patch.patch_content,
1773
+ filePath,
1774
+ tempGit,
1775
+ tempDir,
1776
+ renameSourcePath
1777
+ );
1778
+ const reconstructionHasMarkers = hasConflictMarkerStrings(theirs);
1779
+ const baseHadMarkers = hasConflictMarkerStrings(base);
1780
+ if (reconstructionHasMarkers && !baseHadMarkers && snapshotForFile != null) {
1781
+ theirs = snapshotForFile;
1782
+ }
1783
+ }
1784
+ }
1785
+ const accumulatorEntry = accumulator.lookup(resolvedPath);
1786
+ if (theirs) {
1787
+ const theirsHasMarkers = hasConflictMarkerStrings(theirs);
1788
+ const baseHasMarkers = hasConflictMarkerStrings(base);
1789
+ if (theirsHasMarkers && !baseHasMarkers) {
1790
+ if (accumulatorEntry) {
1791
+ theirs = null;
1792
+ } else {
1793
+ return { kind: "skipped", reason: "stale-conflict-markers" };
1794
+ }
1795
+ }
1796
+ }
1797
+ let useAccumulatorAsMergeBase = false;
1798
+ if (accumulatorEntry && (theirs == null || base == null)) {
1799
+ theirs = await applyPatchToContent2(
1800
+ accumulatorEntry.content,
1801
+ patch.patch_content,
1802
+ filePath,
1803
+ tempGit,
1804
+ tempDir
1805
+ );
1806
+ if (theirs != null) {
1807
+ useAccumulatorAsMergeBase = true;
1808
+ } else if (await isPatchAlreadyApplied(
1809
+ patch.patch_content,
1810
+ filePath,
1811
+ accumulatorEntry.content,
1812
+ tempGit,
1813
+ tempDir
1814
+ )) {
1815
+ theirs = accumulatorEntry.content;
1816
+ useAccumulatorAsMergeBase = true;
1817
+ }
1818
+ }
1819
+ if (theirs) {
1820
+ const theirsHasMarkers = hasConflictMarkerStrings(theirs);
1821
+ const accBaseHasMarkers = accumulatorEntry != null && hasConflictMarkerStrings(accumulatorEntry.content);
1822
+ if (theirsHasMarkers && !accBaseHasMarkers && !hasConflictMarkerStrings(base)) {
1823
+ return { kind: "skipped", reason: "stale-conflict-markers" };
1824
+ }
1825
+ }
1826
+ let effective_theirs = theirs;
1827
+ let conflictReasonHint;
1828
+ if (theirs != null && base != null && !useAccumulatorAsMergeBase) {
1829
+ if (accumulatorEntry && accumulatorEntry.baseGeneration === patch.base_generation) {
1830
+ try {
1831
+ const preMerged = threeWayMerge(base, accumulatorEntry.content, theirs);
1832
+ if (!preMerged.hasConflicts) {
1833
+ effective_theirs = preMerged.content;
1834
+ } else {
1835
+ effective_theirs = theirs;
1836
+ }
1837
+ } catch {
1838
+ effective_theirs = theirs;
1839
+ }
1840
+ } else if (accumulatorEntry) {
1841
+ conflictReasonHint = "base-generation-mismatch";
1842
+ }
1843
+ }
1844
+ if (base == null && ours == null && effective_theirs != null) {
1845
+ return { kind: "new-file-only-user", theirs: effective_theirs };
1846
+ }
1847
+ if (base == null && ours != null && effective_theirs != null && !useAccumulatorAsMergeBase) {
1848
+ return { kind: "new-file-both", theirs: effective_theirs };
1849
+ }
1850
+ if (effective_theirs == null) {
1851
+ return { kind: "skipped", reason: "missing-content" };
1852
+ }
1853
+ if (base == null && !useAccumulatorAsMergeBase || ours == null) {
1854
+ return { kind: "skipped", reason: "missing-content" };
1855
+ }
1856
+ if (useAccumulatorAsMergeBase) {
1857
+ const mergeBase = accumulatorEntry?.content ?? base;
1858
+ if (mergeBase == null) {
1859
+ return { kind: "skipped", reason: "missing-content" };
1719
1860
  }
1861
+ return {
1862
+ kind: "accumulator-base",
1863
+ theirs: effective_theirs,
1864
+ mergeBase,
1865
+ conflictReasonHint
1866
+ };
1867
+ }
1868
+ return {
1869
+ kind: primarySource,
1870
+ base,
1871
+ theirs: effective_theirs,
1872
+ mergeBase: base,
1873
+ conflictReasonHint
1720
1874
  };
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;
1730
- }
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;
1736
- }
1875
+ }
1876
+
1877
+ // src/applicator/runMergeAndRecord.ts
1878
+ import { mkdir, writeFile } from "fs/promises";
1879
+ import { dirname as dirname2 } from "path";
1880
+ async function runMergeAndRecord(args) {
1881
+ const { plan, inputs, patch, accumulator } = args;
1882
+ const { resolvedPath, metadata, oursPath, ours } = inputs;
1883
+ if (plan.kind === "skipped") {
1884
+ return { file: resolvedPath, status: "skipped", reason: plan.reason };
1885
+ }
1886
+ if (plan.kind === "new-file-only-user") {
1887
+ accumulator.recordMerge(resolvedPath, plan.theirs, patch.base_generation);
1888
+ await mkdir(dirname2(oursPath), { recursive: true });
1889
+ await writeFile(oursPath, plan.theirs);
1890
+ return { file: resolvedPath, status: "merged", reason: "new-file" };
1891
+ }
1892
+ if (plan.kind === "new-file-both") {
1893
+ const merged2 = threeWayMerge("", ours, plan.theirs);
1894
+ await mkdir(dirname2(oursPath), { recursive: true });
1895
+ await writeFile(oursPath, merged2.content);
1896
+ if (merged2.hasConflicts) {
1897
+ return {
1898
+ file: resolvedPath,
1899
+ status: "conflict",
1900
+ conflicts: merged2.conflicts,
1901
+ conflictReason: "new-file-both",
1902
+ conflictMetadata: metadata
1903
+ };
1737
1904
  }
1905
+ accumulator.recordMerge(resolvedPath, merged2.content, patch.base_generation);
1906
+ return { file: resolvedPath, status: "merged" };
1738
1907
  }
1739
- flush();
1740
- return results;
1908
+ const merged = threeWayMerge(plan.mergeBase, ours, plan.theirs);
1909
+ await mkdir(dirname2(oursPath), { recursive: true });
1910
+ await writeFile(oursPath, merged.content);
1911
+ if (merged.hasConflicts) {
1912
+ accumulator.recordConflict(resolvedPath, plan.theirs, patch.base_generation);
1913
+ return {
1914
+ file: resolvedPath,
1915
+ status: "conflict",
1916
+ conflicts: merged.conflicts,
1917
+ conflictReason: plan.conflictReasonHint ?? "same-line-edit",
1918
+ conflictMetadata: metadata
1919
+ };
1920
+ }
1921
+ accumulator.recordMerge(resolvedPath, plan.theirs, patch.base_generation);
1922
+ return {
1923
+ file: resolvedPath,
1924
+ status: "merged",
1925
+ ...merged.droppedContextLines && merged.droppedContextLines.length > 0 ? { droppedContextLines: merged.droppedContextLines } : {}
1926
+ };
1741
1927
  }
1742
- var ReplayDetector = class {
1928
+
1929
+ // src/ReplayApplicator.ts
1930
+ var ReplayApplicator = class {
1743
1931
  git;
1744
1932
  lockManager;
1745
- sdkOutputDir;
1746
- warnings = [];
1747
- constructor(git, lockManager, sdkOutputDir) {
1933
+ outputDir;
1934
+ renameCache = /* @__PURE__ */ new Map();
1935
+ treeExistsCache = /* @__PURE__ */ new Map();
1936
+ /**
1937
+ * Inter-patch THEIRS coordination. Created fresh in each `applyPatches`
1938
+ * invocation with the run's apply mode + precomputed shared-file set.
1939
+ * See `src/accumulator/MergeAccumulator.ts` for the full contract.
1940
+ *
1941
+ * Initialized to an empty default in the constructor so type narrowing
1942
+ * works for callers that read it before any `applyPatches` invocation
1943
+ * (e.g., the `getAccumulatorSnapshot` test accessor).
1944
+ */
1945
+ accumulator = new MergeAccumulator("replay", /* @__PURE__ */ new Set());
1946
+ constructor(git, lockManager, outputDir) {
1748
1947
  this.git = git;
1749
1948
  this.lockManager = lockManager;
1750
- this.sdkOutputDir = sdkOutputDir;
1751
- }
1752
- async detectNewPatches() {
1753
- const lock = this.lockManager.read();
1754
- const lastGen = this.getLastGeneration(lock);
1755
- if (!lastGen) {
1756
- return { patches: [], revertedPatchIds: [] };
1757
- }
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);
1949
+ this.outputDir = outputDir;
1774
1950
  }
1775
1951
  /**
1776
- * FER-10201 Non-linear-history detection via `merge-base(prevGen, HEAD)`.
1777
- *
1778
- * After a squash-merge of a Fern-bot PR, `lastGen.commit_sha` (the previous
1779
- * `[fern-generated]`) is orphaned but still in git's object database. The
1780
- * merge-base is the commit on main from which the bot's branch was created —
1781
- * a reachable ancestor of HEAD. Walking that range and classifying each
1782
- * commit via `isGenerationCommit` mirrors the linear path and eliminates
1783
- * the bug class where pipeline-driven changes (autoversion, replay,
1784
- * generation) get bundled as customer customizations.
1785
- *
1786
- * Falls through to the legacy composite path when no common ancestor exists
1787
- * (truly disjoint history — orphan branches with no shared root).
1952
+ * Resolve the GenerationRecord for a patch's base_generation.
1953
+ * Falls back to constructing an ad-hoc record from the commit's tree
1954
+ * when base_generation isn't a tracked generation (commit-scan patches).
1788
1955
  */
1789
- async detectPatchesViaMergeBase(lastGen, lock) {
1790
- let mergeBase = "";
1956
+ async resolveBaseGeneration(baseGeneration) {
1957
+ const gen = this.lockManager.getGeneration(baseGeneration);
1958
+ if (gen) return gen;
1791
1959
  try {
1792
- mergeBase = (await this.git.exec(["merge-base", lastGen.commit_sha, "HEAD"])).trim();
1960
+ const treeHash = await this.git.getTreeHash(baseGeneration);
1961
+ return {
1962
+ commit_sha: baseGeneration,
1963
+ tree_hash: treeHash,
1964
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1965
+ cli_version: "unknown",
1966
+ generator_versions: {}
1967
+ };
1793
1968
  } catch {
1969
+ return void 0;
1794
1970
  }
1795
- if (!mergeBase) {
1796
- this.warnings.push(
1797
- `No common ancestor between previous generation ${lastGen.commit_sha.slice(0, 7)} and HEAD. Falling back to composite tree-diff detection.`
1798
- );
1799
- return this.detectPatchesViaTreeDiff(
1800
- lastGen,
1801
- /* commitKnownMissing */
1802
- false
1803
- );
1804
- }
1805
- return this.detectPatchesInRange(mergeBase, lastGen, lock);
1806
1971
  }
1807
1972
  /**
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.
1973
+ * @internal Test-only.
1974
+ * Returns a defensive copy of the inter-patch accumulator. Used by
1975
+ * invariant I2 to assert that after every applied patch, the
1976
+ * accumulator has an entry for each file the patch touched, and the
1977
+ * entry's content matches on-disk.
1978
+ */
1979
+ getAccumulatorSnapshot() {
1980
+ return this.accumulator.snapshot();
1981
+ }
1982
+ /**
1983
+ * Apply all patches, returning results for each.
1984
+ * Skips patches that match exclude patterns in replay.yml
1811
1985
  *
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.
1986
+ * `applyMode` controls the post-conflict marker strategy:
1987
+ * - `"replay"` (default): strip conflict markers from disk between
1988
+ * iterations whenever a later patch touches the same file. Lets the
1989
+ * follow-up patch's `git apply --3way` see clean OURS content,
1990
+ * which is what the silent-loss fix (PR #73) relies on. The replay
1991
+ * pipeline calls `revertConflictingFiles` after the loop to clean
1992
+ * any markers that survive.
1993
+ * - `"resolve"`: keep markers on disk. The resolve command needs the
1994
+ * customer to see them. Slow-path 3-way merge naturally preserves
1995
+ * A's markers and B's clean writes when their regions don't overlap;
1996
+ * when they do overlap, the customer gets nested markers and
1997
+ * resolves manually. Either way they aren't silently dropped.
1815
1998
  */
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: [] };
1827
- }
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;
1834
- }
1835
- if (lock.patches.find((p) => p.original_commit === commit.sha)) {
1836
- continue;
1999
+ async applyPatches(patches, opts) {
2000
+ const applyMode = opts?.applyMode ?? "replay";
2001
+ const fileFreq = /* @__PURE__ */ new Map();
2002
+ for (const p of patches) {
2003
+ for (const f of p.files) {
2004
+ fileFreq.set(f, (fileFreq.get(f) ?? 0) + 1);
1837
2005
  }
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
2006
+ }
2007
+ const sharedFiles = new Set(
2008
+ Array.from(fileFreq.entries()).filter(([, n]) => n > 1).map(([f]) => f)
2009
+ );
2010
+ this.accumulator = new MergeAccumulator(applyMode, sharedFiles);
2011
+ const results = [];
2012
+ for (let i = 0; i < patches.length; i++) {
2013
+ const patch = patches[i];
2014
+ if (this.isExcluded(patch)) {
2015
+ results.push({
2016
+ patch,
2017
+ status: "skipped",
2018
+ method: "git-am"
1845
2019
  });
1846
- if (compositePatch) {
1847
- newPatches.push(compositePatch);
1848
- }
1849
- continue;
1850
- }
1851
- let patchContent;
1852
- try {
1853
- patchContent = await this.git.formatPatch(commit.sha);
1854
- } catch {
1855
- this.warnings.push(
1856
- `Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
1857
- );
1858
- continue;
1859
- }
1860
- let contentHash = this.computeContentHash(patchContent);
1861
- if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
1862
2020
  continue;
1863
2021
  }
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) {
2022
+ const result = await this.applyPatchWithFallback(patch);
2023
+ results.push(result);
2024
+ if (applyMode !== "resolve" && result.status === "conflict" && result.fileResults) {
2025
+ const laterFiles = /* @__PURE__ */ new Set();
2026
+ for (let j = i + 1; j < patches.length; j++) {
2027
+ for (const f of patches[j].files) {
2028
+ laterFiles.add(f);
2029
+ }
2030
+ }
2031
+ const resolvedToOriginal = /* @__PURE__ */ new Map();
2032
+ if (result.resolvedFiles) {
2033
+ for (const [orig, resolved] of Object.entries(result.resolvedFiles)) {
2034
+ resolvedToOriginal.set(resolved, orig);
2035
+ }
2036
+ }
2037
+ for (const fileResult of result.fileResults) {
2038
+ if (fileResult.status !== "conflict") continue;
2039
+ const originalPath = resolvedToOriginal.get(fileResult.file) ?? fileResult.file;
2040
+ if (laterFiles.has(fileResult.file) || laterFiles.has(originalPath)) {
2041
+ const filePath = join3(this.outputDir, fileResult.file);
1875
2042
  try {
1876
- patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
2043
+ const content = await readFile2(filePath, "utf-8");
2044
+ const stripped = stripConflictMarkers(content);
2045
+ await writeFile2(filePath, stripped);
1877
2046
  } catch {
1878
- continue;
1879
2047
  }
1880
- if (!patchContent.trim()) continue;
1881
- contentHash = this.computeContentHash(patchContent);
1882
2048
  }
1883
2049
  }
1884
2050
  }
1885
- if (files.length === 0) {
1886
- continue;
1887
- }
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;
1893
- }
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 } : {}
2051
+ }
2052
+ return results;
2053
+ }
2054
+ /**
2055
+ * Populate accumulator after git apply succeeds, AND collect per-file
2056
+ * results including any "dropped context lines" — lines that were
2057
+ * present in BASE and THEIRS (unchanged context) but absent from the
2058
+ * final on-disk state because OURS deleted them. This is the fast-path
2059
+ * counterpart to ThreeWayMerge.computeDroppedContextLines used by the
2060
+ * 3-way slow path; both must surface the same warning.
2061
+ */
2062
+ async populateAccumulatorForPatch(patch, baseGen, currentTreeHash) {
2063
+ const fileResults = [];
2064
+ if (!baseGen) return fileResults;
2065
+ const tempDir = await mkdtemp(join3(tmpdir(), "replay-acc-"));
2066
+ const { GitClient: GitClient2 } = await Promise.resolve().then(() => (init_GitClient(), GitClient_exports));
2067
+ const tempGit = new GitClient2(tempDir);
2068
+ await tempGit.exec(["init"]);
2069
+ await tempGit.exec(["config", "user.email", "replay@fern.com"]);
2070
+ await tempGit.exec(["config", "user.name", "Fern Replay"]);
2071
+ try {
2072
+ for (const filePath of patch.files) {
2073
+ if (isBinaryFile(filePath)) continue;
2074
+ const resolvedPath = await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
2075
+ const base = await this.git.showFile(baseGen.tree_hash, filePath);
2076
+ const snapshotForFile = patch.theirs_snapshot?.[resolvedPath] ?? patch.theirs_snapshot?.[filePath];
2077
+ const fileIsShared = this.accumulator.isShared(resolvedPath) || this.accumulator.isShared(filePath);
2078
+ const theirs = !fileIsShared && snapshotForFile != null ? snapshotForFile : await this.applyPatchToContent(base, patch.patch_content, filePath, tempGit, tempDir);
2079
+ const finalOnDisk = await readFile2(join3(this.outputDir, resolvedPath), "utf-8").catch(() => null);
2080
+ const effectiveTheirs = theirs ?? finalOnDisk;
2081
+ if (effectiveTheirs != null) {
2082
+ this.accumulator.recordMerge(resolvedPath, effectiveTheirs, patch.base_generation);
2083
+ }
2084
+ if (base != null && effectiveTheirs != null && finalOnDisk != null) {
2085
+ const dropped = computeDroppedContextLinesForFile(base, effectiveTheirs, finalOnDisk);
2086
+ if (dropped.length > 0) {
2087
+ fileResults.push({
2088
+ file: resolvedPath,
2089
+ status: "merged",
2090
+ droppedContextLines: dropped
2091
+ });
2092
+ }
2093
+ }
2094
+ }
2095
+ } finally {
2096
+ await rm(tempDir, { recursive: true }).catch(() => {
1905
2097
  });
1906
2098
  }
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 {
2099
+ return fileResults;
2100
+ }
2101
+ async applyPatchWithFallback(patch) {
2102
+ const baseGen = await this.resolveBaseGeneration(patch.base_generation);
2103
+ const lock = this.lockManager.read();
2104
+ const currentGen = lock.generations.find((g) => g.commit_sha === lock.current_generation);
2105
+ const currentTreeHash = currentGen?.tree_hash ?? baseGen?.tree_hash ?? "";
2106
+ const needsAccumulation = await Promise.all(
2107
+ patch.files.map(async (f) => {
2108
+ if (!baseGen) return false;
2109
+ const resolved = await this.resolveFilePath(f, baseGen.tree_hash, currentTreeHash);
2110
+ return this.accumulator.has(resolved);
2111
+ })
2112
+ ).then((results) => results.some(Boolean));
2113
+ const resolvedFiles = {};
2114
+ for (const filePath of patch.files) {
2115
+ const resolvedPath = baseGen ? await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash) : filePath;
2116
+ if (resolvedPath !== filePath) {
2117
+ resolvedFiles[filePath] = resolvedPath;
1918
2118
  }
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
- }
2119
+ }
2120
+ const patchIsRenameAware = /^rename from /m.test(patch.patch_content);
2121
+ const hasGeneratorRename = Object.keys(resolvedFiles).length > 0 && !patchIsRenameAware;
2122
+ if (!needsAccumulation && !hasGeneratorRename) {
2123
+ const snapshots = /* @__PURE__ */ new Map();
2124
+ for (const filePath of patch.files) {
2125
+ const fullPath = join3(this.outputDir, filePath);
2126
+ snapshots.set(filePath, await readFile2(fullPath, "utf-8").catch(() => null));
1929
2127
  }
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;
2128
+ try {
2129
+ await this.git.execWithInput(["apply", "--3way"], patch.patch_content);
2130
+ const fastPathFileResults = await this.populateAccumulatorForPatch(patch, baseGen, currentTreeHash);
2131
+ return {
2132
+ patch,
2133
+ status: "applied",
2134
+ method: "git-am",
2135
+ ...fastPathFileResults.length > 0 && { fileResults: fastPathFileResults },
2136
+ ...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
2137
+ };
2138
+ } catch {
2139
+ for (const [filePath, content] of snapshots) {
2140
+ const fullPath = join3(this.outputDir, filePath);
2141
+ if (content != null) {
2142
+ await writeFile2(fullPath, content);
2143
+ } else {
2144
+ await unlink(fullPath).catch(() => {
2145
+ });
2146
+ }
1936
2147
  }
1937
2148
  }
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;
2149
+ }
2150
+ return this.applyWithThreeWayMerge(patch);
2151
+ }
2152
+ async applyWithThreeWayMerge(patch) {
2153
+ const fileResults = [];
2154
+ const resolvedFiles = {};
2155
+ const tempDir = await mkdtemp(join3(tmpdir(), "replay-"));
2156
+ const { GitClient: GitClient2 } = await Promise.resolve().then(() => (init_GitClient(), GitClient_exports));
2157
+ const tempGit = new GitClient2(tempDir);
2158
+ await tempGit.exec(["init"]);
2159
+ await tempGit.exec(["config", "user.email", "replay@fern.com"]);
2160
+ await tempGit.exec(["config", "user.name", "Fern Replay"]);
2161
+ try {
2162
+ for (const filePath of patch.files) {
2163
+ if (isBinaryFile(filePath)) {
2164
+ fileResults.push({
2165
+ file: filePath,
2166
+ status: "skipped",
2167
+ reason: "binary-file"
2168
+ });
2169
+ continue;
1948
2170
  }
1949
- }
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);
2171
+ const result = await this.mergeFile(patch, filePath, tempGit, tempDir);
2172
+ if (result.file !== filePath) {
2173
+ resolvedFiles[filePath] = result.file;
1957
2174
  }
2175
+ fileResults.push(result);
1958
2176
  }
1959
- if (!matchedExisting && !matchedNew) {
1960
- revertIndicesToRemove.add(i);
2177
+ } finally {
2178
+ await rm(tempDir, { recursive: true }).catch(() => {
2179
+ });
2180
+ }
2181
+ const conflictFiles = fileResults.filter((r) => r.status === "conflict");
2182
+ const hasConflicts = conflictFiles.length > 0;
2183
+ const conflictReason = hasConflicts ? conflictFiles.some((f) => f.conflictReason === "base-generation-mismatch") ? "base-generation-mismatch" : conflictFiles[0]?.conflictReason : void 0;
2184
+ return {
2185
+ patch,
2186
+ status: hasConflicts ? "conflict" : "applied",
2187
+ method: "3way-merge",
2188
+ fileResults,
2189
+ conflictReason,
2190
+ ...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
2191
+ };
2192
+ }
2193
+ async mergeFile(patch, filePath, tempGit, tempDir) {
2194
+ try {
2195
+ const inputs = await resolveInputs({
2196
+ patch,
2197
+ filePath,
2198
+ git: this.git,
2199
+ lockManager: this.lockManager,
2200
+ outputDir: this.outputDir,
2201
+ isTreeReachable: (h) => this.isTreeReachable(h),
2202
+ resolveBaseGeneration: (s) => this.resolveBaseGeneration(s),
2203
+ resolveFilePath: (f, b, c) => this.resolveFilePath(f, b, c),
2204
+ extractRenameSource: (p, f) => this.extractRenameSource(p, f),
2205
+ extractFileDiff: (p, f) => this.extractFileDiff(p, f)
2206
+ });
2207
+ if (inputs.earlyExit) {
2208
+ return { file: filePath, ...inputs.earlyExit };
1961
2209
  }
2210
+ const plan = await buildMergePlan({
2211
+ inputs,
2212
+ patch,
2213
+ filePath,
2214
+ accumulator: this.accumulator,
2215
+ tempGit,
2216
+ tempDir,
2217
+ applyPatchToContent: (b, p, f, g, d, s) => this.applyPatchToContent(b, p, f, g, d, s),
2218
+ isPatchAlreadyApplied: (p, f, c, g, d) => this.isPatchAlreadyApplied(p, f, c, g, d)
2219
+ });
2220
+ return await runMergeAndRecord({
2221
+ plan,
2222
+ inputs,
2223
+ patch,
2224
+ accumulator: this.accumulator
2225
+ });
2226
+ } catch (error) {
2227
+ return {
2228
+ file: filePath,
2229
+ status: "skipped",
2230
+ reason: `error: ${error instanceof Error ? error.message : String(error)}`
2231
+ };
1962
2232
  }
1963
- const filteredPatches = survivingPatches.filter((_, i) => !revertIndicesToRemove.has(i));
1964
- return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
1965
2233
  }
1966
- /**
1967
- * Compute content hash for deduplication.
1968
- *
1969
- * Produces a format-agnostic hash so both `git format-patch` output
1970
- * (email-wrapped) and plain `git diff` output hash to the same value
1971
- * when the underlying diff hunks are identical.
1972
- *
1973
- * Normalization:
1974
- * 1. Strip email wrapper: everything before the first `diff --git` line
1975
- * (From:, Subject:, Date:, diffstat, blank separators).
1976
- * 2. Strip `index` lines (blob SHA pairs change across rebases).
1977
- * 3. Strip trailing git version marker (`-- \n<version>`).
1978
- *
1979
- * This ensures content hashes match across the no-patches and normal-
1980
- * regeneration flows, which store formatPatch and git-diff formats
1981
- * respectively (FER-9850, D5-D9).
1982
- */
1983
- computeContentHash(patchContent) {
1984
- const lines = patchContent.split("\n");
1985
- const diffStart = lines.findIndex((l) => l.startsWith("diff --git "));
1986
- const relevantLines = diffStart > 0 ? lines.slice(diffStart) : lines;
1987
- const normalized = relevantLines.filter((line) => !line.startsWith("index ")).join("\n").replace(/\n-- \n[\d]+\.[\d]+[^\n]*\s*$/, "");
1988
- return `sha256:${createHash("sha256").update(normalized).digest("hex")}`;
2234
+ async isTreeReachable(treeHash) {
2235
+ let result = this.treeExistsCache.get(treeHash);
2236
+ if (result === void 0) {
2237
+ result = await this.git.treeExists(treeHash);
2238
+ this.treeExistsCache.set(treeHash, result);
2239
+ }
2240
+ return result;
1989
2241
  }
1990
- /**
1991
- * FER-9805 Drop patches whose files were transiently created and then
1992
- * deleted within the current linear detection window, and are absent from
1993
- * HEAD at the end of the window.
1994
- *
1995
- * Rationale: on a merge commit, createMergeCompositePatch already diffs
1996
- * firstParent..mergeCommit so net-zero files never enter the composite. On
1997
- * linear history each commit becomes its own patch, so a file that was
1998
- * briefly added and later removed survives as a create+delete pair whose
1999
- * cumulative diff is empty. We drop such patches before they reach the
2000
- * lockfile.
2001
- *
2002
- * A file is "transient" when:
2003
- * 1. some patch in the window sets `new file mode` for it (a creation), AND
2004
- * 2. some patch in the window sets `deleted file mode` for it (a deletion), AND
2005
- * 3. it is absent from HEAD's tree.
2006
- *
2007
- * The HEAD guard (#3) prevents false positives when a file is deleted
2008
- * and then recreated with different content — the cumulative diff of
2009
- * such a pair is non-empty and must be preserved.
2010
- *
2011
- * This only drops patches whose `files` are a subset of the transient
2012
- * set. A partial-transient patch (one that touches a transient file
2013
- * alongside a persistent one) is left unmodified; surgical stripping of
2014
- * single files from a multi-file patch would require a follow-up that
2015
- * regenerates the patch content via `git format-patch -- <files>`. No
2016
- * current failing test exercises this, and leaving the patch intact
2017
- * preserves today's behaviour. TODO(FER-9805): revisit if a future
2018
- * adversarial test demands per-file stripping.
2019
- */
2020
- async collapseNetZeroFiles(patches) {
2021
- if (patches.length === 0) return patches;
2022
- const stateByFile = /* @__PURE__ */ new Map();
2023
- for (const patch of patches) {
2024
- for (const header of parsePatchFileHeaders(patch.patch_content)) {
2025
- if (!header.isCreate && !header.isDelete) continue;
2026
- const prior = stateByFile.get(header.path) ?? { created: false, deleted: false };
2027
- if (header.isCreate) prior.created = true;
2028
- if (header.isDelete) prior.deleted = true;
2029
- stateByFile.set(header.path, prior);
2242
+ isExcluded(patch) {
2243
+ const config = this.lockManager.getCustomizationsConfig();
2244
+ if (!config.exclude) return false;
2245
+ return patch.files.some((file) => config.exclude.some((pattern) => minimatch(file, pattern)));
2246
+ }
2247
+ async resolveFilePath(filePath, baseTreeHash, currentTreeHash) {
2248
+ const config = this.lockManager.getCustomizationsConfig();
2249
+ if (config.moves) {
2250
+ for (const move of config.moves) {
2251
+ if (minimatch(filePath, move.from) || filePath === move.from) {
2252
+ if (filePath === move.from) {
2253
+ return move.to;
2254
+ }
2255
+ const fromBase = move.from.replace(/\*\*.*$/, "");
2256
+ const toBase = move.to.replace(/\*\*.*$/, "");
2257
+ if (filePath.startsWith(fromBase)) {
2258
+ return toBase + filePath.slice(fromBase.length);
2259
+ }
2260
+ }
2030
2261
  }
2031
2262
  }
2032
- let anyCandidate = false;
2033
- for (const state of stateByFile.values()) {
2034
- if (state.created && state.deleted) {
2035
- anyCandidate = true;
2036
- break;
2037
- }
2263
+ const cacheKey = `${baseTreeHash}:${currentTreeHash}`;
2264
+ let renames = this.renameCache.get(cacheKey);
2265
+ if (!renames) {
2266
+ renames = await this.git.detectRenames(baseTreeHash, currentTreeHash);
2267
+ this.renameCache.set(cacheKey, renames);
2038
2268
  }
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);
2045
- }
2269
+ const gitRename = renames.find((r) => r.from === filePath);
2270
+ if (gitRename) {
2271
+ return gitRename.to;
2046
2272
  }
2047
- if (transient.size === 0) return patches;
2048
- return patches.filter((patch) => !patch.files.every((f) => transient.has(f)));
2273
+ return filePath;
2049
2274
  }
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() {
2275
+ async applyPatchToContent(base, patchContent, filePath, tempGit, tempDir, sourceFilePath) {
2276
+ if (!base) {
2277
+ return this.extractNewFileFromPatch(patchContent, filePath);
2278
+ }
2279
+ const fileDiff = this.extractFileDiff(patchContent, filePath);
2280
+ if (!fileDiff) return null;
2057
2281
  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));
2282
+ if (sourceFilePath) {
2283
+ const tempSourcePath = join3(tempDir, sourceFilePath);
2284
+ await mkdir2(dirname3(tempSourcePath), { recursive: true });
2285
+ await writeFile2(tempSourcePath, base);
2286
+ await tempGit.exec(["add", sourceFilePath]);
2287
+ await tempGit.exec([
2288
+ "commit",
2289
+ "-m",
2290
+ `base for rename ${sourceFilePath} -> ${filePath}`,
2291
+ "--allow-empty"
2292
+ ]);
2293
+ await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
2294
+ const tempTargetPath = join3(tempDir, filePath);
2295
+ return await readFile2(tempTargetPath, "utf-8");
2296
+ }
2297
+ const tempFilePath = join3(tempDir, filePath);
2298
+ await mkdir2(dirname3(tempFilePath), { recursive: true });
2299
+ await writeFile2(tempFilePath, base);
2300
+ await tempGit.exec(["add", filePath]);
2301
+ await tempGit.exec(["commit", "-m", `base for ${filePath}`, "--allow-empty"]);
2302
+ await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
2303
+ return await readFile2(tempFilePath, "utf-8");
2060
2304
  } catch {
2061
- return /* @__PURE__ */ new Set();
2062
- }
2063
- }
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
- );
2086
- }
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
2305
  return null;
2099
2306
  }
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;
2105
- }
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
- };
2118
2307
  }
2119
2308
  /**
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.
2309
+ * Detects whether a patch's additions are already present in `content`.
2310
+ * Uses `git apply --reverse --check` if the reverse patch applies cleanly,
2311
+ * the forward patch is effectively a no-op (its +lines are already there and
2312
+ * its -lines are already gone). Used to distinguish "patch already applied"
2313
+ * from "patch base mismatch" after a forward-apply fails.
2123
2314
  */
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: [] };
2145
- }
2146
- const headSha = (await this.git.exec(["rev-parse", "HEAD"])).trim();
2147
- const theirsSnapshot = {};
2148
- for (const file of files) {
2149
- if (isBinaryFile(file)) continue;
2150
- const content = await this.git.showFile("HEAD", file).catch(() => null);
2151
- if (content != null) theirsSnapshot[file] = content;
2315
+ async isPatchAlreadyApplied(patchContent, filePath, content, tempGit, tempDir) {
2316
+ const fileDiff = this.extractFileDiff(patchContent, filePath);
2317
+ if (!fileDiff) return false;
2318
+ try {
2319
+ const tempFilePath = join3(tempDir, filePath);
2320
+ await mkdir2(dirname3(tempFilePath), { recursive: true });
2321
+ await writeFile2(tempFilePath, content);
2322
+ await tempGit.exec(["add", filePath]);
2323
+ await tempGit.exec(["commit", "-m", `check already-applied ${filePath}`, "--allow-empty"]);
2324
+ await tempGit.execWithInput(["apply", "--reverse", "--check", "--allow-empty"], fileDiff);
2325
+ return true;
2326
+ } catch {
2327
+ return false;
2152
2328
  }
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
2329
  }
2169
- /**
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).
2176
- */
2177
- async detectPatchesViaCommitScan() {
2178
- const lock = this.lockManager.read();
2179
- const log = await this.git.exec([
2180
- "log",
2181
- "--max-count=200",
2182
- "--format=%H%x00%an%x00%ae%x00%s",
2183
- "HEAD",
2184
- "--",
2185
- this.sdkOutputDir
2186
- ]);
2187
- if (!log.trim()) {
2188
- return { patches: [], revertedPatchIds: [] };
2189
- }
2190
- const commits = this.parseGitLog(log);
2191
- const newPatches = [];
2192
- const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
2193
- const existingHashes = new Set(lock.patches.map((p) => p.content_hash));
2194
- const existingCommits = new Set(lock.patches.map((p) => p.original_commit));
2195
- for (const commit of commits) {
2196
- if (isGenerationCommit(commit)) {
2197
- continue;
2198
- }
2199
- const parents = await this.git.getCommitParents(commit.sha);
2200
- if (parents.length > 1) {
2330
+ extractFileDiff(patchContent, filePath) {
2331
+ const lines = patchContent.split("\n");
2332
+ const diffLines = [];
2333
+ let inTargetFile = false;
2334
+ for (const line of lines) {
2335
+ if (line.startsWith("diff --git")) {
2336
+ if (inTargetFile) {
2337
+ break;
2338
+ }
2339
+ if (isDiffLineForFile(line, filePath)) {
2340
+ inTargetFile = true;
2341
+ diffLines.push(line);
2342
+ }
2201
2343
  continue;
2202
2344
  }
2203
- if (existingCommits.has(commit.sha)) {
2204
- continue;
2345
+ if (inTargetFile) {
2346
+ diffLines.push(line);
2205
2347
  }
2206
- let patchContent;
2207
- try {
2208
- patchContent = await this.git.formatPatch(commit.sha);
2209
- } catch {
2348
+ }
2349
+ return diffLines.length > 0 ? diffLines.join("\n") + "\n" : null;
2350
+ }
2351
+ extractRenameSource(patchContent, targetFilePath) {
2352
+ const lines = patchContent.split("\n");
2353
+ let inTargetFile = false;
2354
+ for (const line of lines) {
2355
+ if (line.startsWith("diff --git")) {
2356
+ if (inTargetFile) break;
2357
+ inTargetFile = isDiffLineForFile(line, targetFilePath);
2210
2358
  continue;
2211
2359
  }
2212
- let contentHash = this.computeContentHash(patchContent);
2213
- if (existingHashes.has(contentHash) || forgottenHashes.has(contentHash)) {
2214
- continue;
2360
+ if (!inTargetFile) continue;
2361
+ if (line.startsWith("@@")) break;
2362
+ if (line.startsWith("rename from ")) {
2363
+ return line.slice("rename from ".length);
2215
2364
  }
2216
- if (this.isCreationOnlyPatch(patchContent)) {
2365
+ }
2366
+ return null;
2367
+ }
2368
+ extractNewFileFromPatch(patchContent, filePath) {
2369
+ const lines = patchContent.split("\n");
2370
+ const addedLines = [];
2371
+ let inTargetFile = false;
2372
+ let inHunk = false;
2373
+ let isNewFile = false;
2374
+ let noTrailingNewline = false;
2375
+ for (const line of lines) {
2376
+ if (line.startsWith("diff --git")) {
2377
+ if (inTargetFile) break;
2378
+ inTargetFile = isDiffLineForFile(line, filePath);
2379
+ inHunk = false;
2380
+ isNewFile = false;
2217
2381
  continue;
2218
2382
  }
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);
2383
+ if (!inTargetFile) continue;
2384
+ if (!inHunk) {
2385
+ if (line === "--- /dev/null" || line.startsWith("new file mode")) {
2386
+ isNewFile = true;
2238
2387
  }
2239
2388
  }
2240
- if (files.length === 0) {
2389
+ if (line.startsWith("@@")) {
2390
+ if (!isNewFile) return null;
2391
+ inHunk = true;
2241
2392
  continue;
2242
2393
  }
2243
- if (parents.length === 0) {
2394
+ if (!inHunk) continue;
2395
+ if (line === "\") {
2396
+ noTrailingNewline = true;
2244
2397
  continue;
2245
2398
  }
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;
2399
+ if (line.startsWith("+") && !line.startsWith("+++")) {
2400
+ addedLines.push(line.slice(1));
2252
2401
  }
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
2402
  }
2278
- return diffOldHeaders.every((l) => l === "--- /dev/null");
2403
+ if (addedLines.length === 0) return null;
2404
+ return addedLines.join("\n") + (noTrailingNewline ? "" : "\n");
2279
2405
  }
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;
2406
+ };
2407
+ function computeDroppedContextLinesForFile(base, theirs, finalContent) {
2408
+ const baseLines = base.split("\n");
2409
+ const theirsLines = theirs.split("\n");
2410
+ const finalLines = finalContent.split("\n");
2411
+ const baseCounts = /* @__PURE__ */ new Map();
2412
+ const theirsCounts = /* @__PURE__ */ new Map();
2413
+ const finalCounts = /* @__PURE__ */ new Map();
2414
+ for (const l of baseLines) baseCounts.set(l, (baseCounts.get(l) ?? 0) + 1);
2415
+ for (const l of theirsLines) theirsCounts.set(l, (theirsCounts.get(l) ?? 0) + 1);
2416
+ for (const l of finalLines) finalCounts.set(l, (finalCounts.get(l) ?? 0) + 1);
2417
+ const dropped = [];
2418
+ const seen = /* @__PURE__ */ new Set();
2419
+ for (const line of baseLines) {
2420
+ if (seen.has(line)) continue;
2421
+ const inBase = baseCounts.get(line) ?? 0;
2422
+ const inTheirs = theirsCounts.get(line) ?? 0;
2423
+ const inFinal = finalCounts.get(line) ?? 0;
2424
+ if (inBase > 0 && inTheirs > 0 && inFinal < Math.min(inBase, inTheirs)) {
2425
+ dropped.push(line);
2426
+ seen.add(line);
2291
2427
  }
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
2428
  }
2303
- };
2429
+ return dropped;
2430
+ }
2431
+ function isDiffLineForFile(diffLine, filePath) {
2432
+ const match = diffLine.match(/^diff --git a\/.+ b\/(.+)$/);
2433
+ return match !== null && match[1] === filePath;
2434
+ }
2304
2435
 
2305
2436
  // src/ReplayCommitter.ts
2306
2437
  var ReplayCommitter = class {
@@ -2408,15 +2539,15 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
2408
2539
  // src/ReplayService.ts
2409
2540
  import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
2410
2541
  import { readFile as readFileAsync } from "fs/promises";
2411
- import { join as join5 } from "path";
2412
- import { minimatch as minimatch2 } from "minimatch";
2542
+ import { join as join6 } from "path";
2543
+ import { minimatch as minimatch3 } from "minimatch";
2413
2544
  init_GitClient();
2414
2545
 
2415
2546
  // src/PatchRegionDiff.ts
2416
2547
  init_HybridReconstruction();
2417
- import { mkdtemp as mkdtemp2, writeFile as writeFile2, rm as rm2 } from "fs/promises";
2548
+ import { mkdtemp as mkdtemp2, writeFile as writeFile3, rm as rm2 } from "fs/promises";
2418
2549
  import { tmpdir as tmpdir2 } from "os";
2419
- import { join as join3 } from "path";
2550
+ import { join as join4 } from "path";
2420
2551
  import { spawn } from "child_process";
2421
2552
  function normalizeHunkBody(hunk) {
2422
2553
  let contextC = 0;
@@ -2622,12 +2753,12 @@ function overlayRegions(currentGenLines, locInCurrentGen, workingTreeLines, locI
2622
2753
  }
2623
2754
  async function unifiedDiff(beforeContent, afterContent, filePath) {
2624
2755
  if (beforeContent === afterContent) return "";
2625
- const tmp = await mkdtemp2(join3(tmpdir2(), "fern-replay-pp-"));
2756
+ const tmp = await mkdtemp2(join4(tmpdir2(), "fern-replay-pp-"));
2626
2757
  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");
2758
+ const beforePath = join4(tmp, "before");
2759
+ const afterPath = join4(tmp, "after");
2760
+ await writeFile3(beforePath, beforeContent, "utf-8");
2761
+ await writeFile3(afterPath, afterContent, "utf-8");
2631
2762
  const raw = await runGitDiffNoIndex(beforePath, afterPath);
2632
2763
  if (!raw.trim()) return "";
2633
2764
  return rewriteDiffHeaders(raw, filePath);
@@ -2704,211 +2835,84 @@ function synthesizeDeleteFileDiff(file, content) {
2704
2835
  return header + "\n" + body + trailer + "\n";
2705
2836
  }
2706
2837
 
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);
2838
+ // src/classifier/FileOwnership.ts
2839
+ import { minimatch as minimatch2 } from "minimatch";
2840
+ var FileOwnership = class {
2841
+ constructor(git) {
2744
2842
  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
2843
  }
2811
2844
  /**
2812
- * Sync the lockfile after a divergent PR was squash-merged.
2813
- * Call this BEFORE runReplay() when the CLI detects a merged divergent PR.
2845
+ * Canonical resolution. Returns true if `file` is user-owned per:
2814
2846
  *
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.
2847
+ * 1. `patch.user_owned === true` authoritative, persisted flag.
2848
+ * 2. `file === ".fernignore"` always user-owned.
2849
+ * 3. File matches a `.fernignore` pattern (via minimatch).
2850
+ * 4. Patch content shows `--- /dev/null` for this file (legacy-safe
2851
+ * signal for patches predating `user_owned` or whose flag was lost).
2852
+ * 5. Tree-based check against `patch.base_generation` when reachable
2853
+ * AND distinct from `currentGenSha`. Unreachable base → conservative
2854
+ * user-owned (silent-absorption safety net), with a one-time warning
2855
+ * per base SHA.
2856
+ * 6. Final fallback: check `currentGenSha` when no usable base exists
2857
+ * (legacy one-gen lockfile).
2820
2858
  */
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
- }
2859
+ async resolveCanonical(ctx) {
2860
+ const { file, originalFileName, patch, currentGenSha, fernignorePatterns, warnedGens, warnings } = ctx;
2861
+ if (patch.user_owned) return true;
2862
+ if (file === ".fernignore") return true;
2863
+ if (fernignorePatterns.some((p) => file === p || minimatch2(file, p))) return true;
2864
+ if (fileIsCreationInPatchContent(patch.patch_content, originalFileName ?? file)) {
2865
+ return true;
2847
2866
  }
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);
2867
+ const base = patch.base_generation;
2868
+ if (base && base !== currentGenSha) {
2869
+ const reachable = await this.git.commitExists(base) || await this.git.treeExists(base);
2870
+ if (!reachable) {
2871
+ if (!warnedGens.has(base)) {
2872
+ warnedGens.add(base);
2873
+ warnings.push(
2874
+ `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.`
2875
+ );
2860
2876
  }
2877
+ return true;
2861
2878
  }
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
- );
2879
+ return await this.git.showFile(base, originalFileName ?? file) === null;
2869
2880
  }
2870
- this.lockManager.save();
2871
- this.detector.warnings.push(...this.lockManager.warnings);
2881
+ return await this.git.showFile(currentGenSha, originalFileName ?? file) === null;
2872
2882
  }
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;
2883
+ };
2884
+ function fileIsCreationInPatchContent(patchContent, file) {
2885
+ const lines = patchContent.split("\n");
2886
+ let inFileSection = false;
2887
+ for (const line of lines) {
2888
+ if (line.startsWith("diff --git ")) {
2889
+ inFileSection = line.includes(` b/${file}`) || line.endsWith(` b/${file}`);
2890
+ continue;
2891
+ }
2892
+ if (inFileSection && line.startsWith("--- ")) {
2893
+ return line === "--- /dev/null";
2882
2894
  }
2883
2895
  }
2884
- firstGenerationReport() {
2885
- return {
2886
- flow: "first-generation",
2887
- patchesDetected: 0,
2888
- patchesApplied: 0,
2889
- patchesWithConflicts: 0,
2890
- patchesSkipped: 0,
2891
- conflicts: []
2892
- };
2896
+ return false;
2897
+ }
2898
+
2899
+ // src/flows/FirstGenerationFlow.ts
2900
+ var FirstGenerationFlow = class {
2901
+ constructor(service) {
2902
+ this.service = service;
2893
2903
  }
2894
- async _prepareFirstGeneration(options) {
2904
+ async prepare(options) {
2895
2905
  if (options?.dryRun) {
2896
- const headSha = await this.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
2906
+ const headSha = await this.service.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
2897
2907
  return {
2898
2908
  flow: "first-generation",
2899
2909
  previousGenerationSha: null,
2900
2910
  currentGenerationSha: headSha,
2901
2911
  baseBranchHead: options.baseBranchHead ?? null,
2902
2912
  _prepared: {
2913
+ kind: "terminal",
2903
2914
  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
2915
+ preparedReport: firstGenerationReport()
2912
2916
  }
2913
2917
  };
2914
2918
  }
@@ -2917,17 +2921,17 @@ var ReplayService = class {
2917
2921
  generatorVersions: options.generatorVersions ?? {},
2918
2922
  baseBranchHead: options.baseBranchHead
2919
2923
  } : void 0;
2920
- await this.committer.commitGeneration("Initial SDK generation", commitOpts);
2921
- const genRecord = await this.committer.createGenerationRecord(commitOpts);
2922
- this.lockManager.initializeInMemory(genRecord);
2924
+ await this.service.committer.commitGeneration("Initial SDK generation", commitOpts);
2925
+ const genRecord = await this.service.committer.createGenerationRecord(commitOpts);
2926
+ this.service.lockManager.initializeInMemory(genRecord);
2923
2927
  return {
2924
2928
  flow: "first-generation",
2925
2929
  previousGenerationSha: null,
2926
2930
  currentGenerationSha: genRecord.commit_sha,
2927
2931
  baseBranchHead: options?.baseBranchHead ?? null,
2928
2932
  _prepared: {
2933
+ kind: "active",
2929
2934
  flow: "first-generation",
2930
- terminal: false,
2931
2935
  patchesToApply: [],
2932
2936
  newPatchIds: /* @__PURE__ */ new Set(),
2933
2937
  detectorWarnings: [],
@@ -2937,47 +2941,57 @@ var ReplayService = class {
2937
2941
  }
2938
2942
  };
2939
2943
  }
2940
- async _applyFirstGeneration(_prep) {
2941
- this.lockManager.save();
2942
- return this.firstGenerationReport();
2944
+ async apply(_prep) {
2945
+ this.service.lockManager.save();
2946
+ return firstGenerationReport();
2943
2947
  }
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) {
2948
+ };
2949
+ function firstGenerationReport() {
2950
+ return {
2951
+ flow: "first-generation",
2952
+ patchesDetected: 0,
2953
+ patchesApplied: 0,
2954
+ patchesWithConflicts: 0,
2955
+ patchesSkipped: 0,
2956
+ conflicts: []
2957
+ };
2958
+ }
2959
+
2960
+ // src/flows/SkipApplicationFlow.ts
2961
+ var SkipApplicationFlow = class {
2962
+ constructor(service) {
2963
+ this.service = service;
2964
+ }
2965
+ async prepare(options) {
2952
2966
  const commitOpts = options ? {
2953
2967
  cliVersion: options.cliVersion ?? "unknown",
2954
2968
  generatorVersions: options.generatorVersions ?? {},
2955
2969
  baseBranchHead: options.baseBranchHead
2956
2970
  } : void 0;
2957
- await this.committer.commitGeneration("Update SDK (replay skipped)", commitOpts);
2958
- await this.cleanupStaleConflictMarkers();
2959
- const genRecord = await this.committer.createGenerationRecord(commitOpts);
2971
+ await this.service.committer.commitGeneration("Update SDK (replay skipped)", commitOpts);
2972
+ await this.service.cleanupStaleConflictMarkers();
2973
+ const genRecord = await this.service.committer.createGenerationRecord(commitOpts);
2960
2974
  let previousGenerationSha = null;
2961
2975
  try {
2962
- const lock = this.lockManager.read();
2976
+ const lock = this.service.lockManager.read();
2963
2977
  previousGenerationSha = lock.current_generation ?? null;
2964
- this.lockManager.addGeneration(genRecord);
2978
+ this.service.lockManager.addGeneration(genRecord);
2965
2979
  } catch (error) {
2966
2980
  if (error instanceof LockfileNotFoundError) {
2967
- this.lockManager.initializeInMemory(genRecord);
2981
+ this.service.lockManager.initializeInMemory(genRecord);
2968
2982
  } else {
2969
2983
  throw error;
2970
2984
  }
2971
2985
  }
2972
- this.lockManager.setReplaySkippedAt((/* @__PURE__ */ new Date()).toISOString());
2986
+ this.service.lockManager.setReplaySkippedAt((/* @__PURE__ */ new Date()).toISOString());
2973
2987
  return {
2974
2988
  flow: "skip-application",
2975
2989
  previousGenerationSha,
2976
2990
  currentGenerationSha: genRecord.commit_sha,
2977
2991
  baseBranchHead: options?.baseBranchHead ?? null,
2978
2992
  _prepared: {
2993
+ kind: "active",
2979
2994
  flow: "skip-application",
2980
- terminal: false,
2981
2995
  patchesToApply: [],
2982
2996
  newPatchIds: /* @__PURE__ */ new Set(),
2983
2997
  detectorWarnings: [],
@@ -2987,13 +3001,13 @@ var ReplayService = class {
2987
3001
  }
2988
3002
  };
2989
3003
  }
2990
- async _applySkipApplication(_prep, options) {
2991
- this.lockManager.save();
2992
- const credentialWarnings = [...this.lockManager.warnings];
3004
+ async apply(_prep, options) {
3005
+ this.service.lockManager.save();
3006
+ const credentialWarnings = [...this.service.lockManager.warnings];
2993
3007
  if (!options?.stageOnly) {
2994
- await this.committer.commitReplay(0);
3008
+ await this.service.committer.commitReplay(0);
2995
3009
  } else {
2996
- await this.committer.stageAll();
3010
+ await this.service.committer.stageAll();
2997
3011
  }
2998
3012
  return {
2999
3013
  flow: "skip-application",
@@ -3005,13 +3019,20 @@ var ReplayService = class {
3005
3019
  warnings: credentialWarnings.length > 0 ? credentialWarnings : void 0
3006
3020
  };
3007
3021
  }
3008
- async _prepareNoPatchesRegeneration(options) {
3009
- const preLock = this.lockManager.read();
3022
+ };
3023
+
3024
+ // src/flows/NoPatchesFlow.ts
3025
+ var NoPatchesFlow = class {
3026
+ constructor(service) {
3027
+ this.service = service;
3028
+ }
3029
+ async prepare(options) {
3030
+ const preLock = this.service.lockManager.read();
3010
3031
  const previousGenerationSha = preLock.current_generation ?? null;
3011
- let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
3012
- const detectorWarnings = [...this.detector.warnings];
3032
+ let { patches: newPatches, revertedPatchIds } = await this.service.detector.detectNewPatches();
3033
+ const detectorWarnings = [...this.service.detector.warnings];
3013
3034
  if (options?.dryRun) {
3014
- const dryRunWarnings = [...detectorWarnings, ...this.warnings];
3035
+ const dryRunWarnings = [...detectorWarnings, ...this.service.warnings];
3015
3036
  const preparedReport = {
3016
3037
  flow: "no-patches",
3017
3038
  patchesDetected: newPatches.length,
@@ -3023,22 +3044,16 @@ var ReplayService = class {
3023
3044
  wouldApply: newPatches,
3024
3045
  warnings: dryRunWarnings.length > 0 ? dryRunWarnings : void 0
3025
3046
  };
3026
- const headSha = await this.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
3047
+ const headSha = await this.service.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
3027
3048
  return {
3028
3049
  flow: "no-patches",
3029
3050
  previousGenerationSha,
3030
3051
  currentGenerationSha: headSha,
3031
3052
  baseBranchHead: options.baseBranchHead ?? null,
3032
3053
  _prepared: {
3054
+ kind: "terminal",
3033
3055
  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
3056
+ preparedReport
3042
3057
  }
3043
3058
  };
3044
3059
  }
@@ -3047,7 +3062,7 @@ var ReplayService = class {
3047
3062
  const uniqueFiles = [...new Set(newPatches.flatMap((p) => p.files))];
3048
3063
  const absorbedFiles = /* @__PURE__ */ new Set();
3049
3064
  for (const file of uniqueFiles) {
3050
- const diff = await this.git.exec(["diff", preCurrentGen, "HEAD", "--", file]).catch(() => null);
3065
+ const diff = await this.service.git.exec(["diff", preCurrentGen, "HEAD", "--", file]).catch(() => null);
3051
3066
  if (diff !== null && !diff.trim()) {
3052
3067
  absorbedFiles.add(file);
3053
3068
  }
@@ -3060,7 +3075,7 @@ var ReplayService = class {
3060
3075
  }
3061
3076
  for (const id of revertedPatchIds) {
3062
3077
  try {
3063
- this.lockManager.removePatch(id);
3078
+ this.service.lockManager.removePatch(id);
3064
3079
  } catch {
3065
3080
  }
3066
3081
  }
@@ -3069,18 +3084,18 @@ var ReplayService = class {
3069
3084
  generatorVersions: options.generatorVersions ?? {},
3070
3085
  baseBranchHead: options.baseBranchHead
3071
3086
  } : 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);
3087
+ await this.service.committer.commitGeneration("Update SDK", commitOpts);
3088
+ await this.service.cleanupStaleConflictMarkers();
3089
+ const genRecord = await this.service.committer.createGenerationRecord(commitOpts);
3090
+ this.service.lockManager.addGeneration(genRecord);
3076
3091
  return {
3077
3092
  flow: "no-patches",
3078
3093
  previousGenerationSha,
3079
3094
  currentGenerationSha: genRecord.commit_sha,
3080
3095
  baseBranchHead: options?.baseBranchHead ?? null,
3081
3096
  _prepared: {
3097
+ kind: "active",
3082
3098
  flow: "no-patches",
3083
- terminal: false,
3084
3099
  patchesToApply: newPatches,
3085
3100
  newPatchIds: new Set(newPatches.map((p) => p.id)),
3086
3101
  detectorWarnings,
@@ -3090,41 +3105,42 @@ var ReplayService = class {
3090
3105
  }
3091
3106
  };
3092
3107
  }
3093
- async _applyNoPatchesRegeneration(prep, options) {
3108
+ async apply(prep, options) {
3109
+ assertActive(prep);
3094
3110
  const newPatches = prep._prepared.patchesToApply;
3095
3111
  const detectorWarnings = prep._prepared.detectorWarnings;
3096
3112
  const genSha = prep._prepared.genSha;
3097
3113
  let results = [];
3098
3114
  if (newPatches.length > 0) {
3099
- results = await this.applicator.applyPatches(newPatches);
3100
- this._lastApplyResults = results;
3101
- this.recordDroppedContextLineWarnings(results);
3102
- this.revertConflictingFiles(results);
3115
+ results = await this.service.applicator.applyPatches(newPatches);
3116
+ this.service._lastApplyResults = results;
3117
+ this.service.recordDroppedContextLineWarnings(results);
3118
+ this.service.revertConflictingFiles(results);
3103
3119
  for (const result of results) {
3104
3120
  if (result.status === "conflict") {
3105
3121
  result.patch.status = "unresolved";
3106
3122
  }
3107
3123
  }
3108
3124
  }
3109
- const rebaseCounts = await this.rebasePatches(results, genSha);
3125
+ const rebaseCounts = await this.service.rebasePatches(results, genSha);
3110
3126
  for (const patch of newPatches) {
3111
3127
  if (!rebaseCounts.absorbedPatchIds.has(patch.id)) {
3112
- this.lockManager.addPatch(patch);
3128
+ this.service.lockManager.addPatch(patch);
3113
3129
  }
3114
3130
  }
3115
- this.lockManager.save();
3116
- this.warnings.push(...this.lockManager.warnings);
3131
+ this.service.lockManager.save();
3132
+ this.service.warnings.push(...this.service.lockManager.warnings);
3117
3133
  if (newPatches.length > 0) {
3118
3134
  if (!options?.stageOnly) {
3119
3135
  const appliedCount = results.filter((r) => r.status === "applied").length;
3120
3136
  const buckets = computeCommitBuckets(results, rebaseCounts.absorbedPatchIds, newPatches);
3121
- await this.committer.commitReplay(appliedCount, newPatches, void 0, { buckets });
3137
+ await this.service.committer.commitReplay(appliedCount, newPatches, void 0, { buckets });
3122
3138
  } else {
3123
- await this.committer.stageAll();
3139
+ await this.service.committer.stageAll();
3124
3140
  }
3125
3141
  }
3126
- const warnings = [...detectorWarnings, ...this.warnings];
3127
- return this.buildReport(
3142
+ const warnings = [...detectorWarnings, ...this.service.warnings];
3143
+ return this.service.buildReport(
3128
3144
  "no-patches",
3129
3145
  newPatches,
3130
3146
  results,
@@ -3135,13 +3151,20 @@ var ReplayService = class {
3135
3151
  prep._prepared.revertedCount
3136
3152
  );
3137
3153
  }
3138
- async _prepareNormalRegeneration(options) {
3139
- const preLock = this.lockManager.read();
3154
+ };
3155
+
3156
+ // src/flows/NormalRegenerationFlow.ts
3157
+ var NormalRegenerationFlow = class {
3158
+ constructor(service) {
3159
+ this.service = service;
3160
+ }
3161
+ async prepare(options) {
3162
+ const preLock = this.service.lockManager.read();
3140
3163
  const previousGenerationSha = preLock.current_generation ?? null;
3141
3164
  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];
3165
+ const existingPatches2 = this.service.lockManager.getPatches();
3166
+ const { patches: newPatches2, revertedPatchIds: dryRunReverted } = await this.service.detector.detectNewPatches();
3167
+ const warnings = [...this.service.detector.warnings, ...this.service.warnings];
3145
3168
  const allPatches2 = [...existingPatches2, ...newPatches2];
3146
3169
  const preparedReport = {
3147
3170
  flow: "normal-regeneration",
@@ -3154,48 +3177,42 @@ var ReplayService = class {
3154
3177
  wouldApply: allPatches2,
3155
3178
  warnings: warnings.length > 0 ? warnings : void 0
3156
3179
  };
3157
- const headSha = await this.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
3180
+ const headSha = await this.service.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
3158
3181
  return {
3159
3182
  flow: "normal-regeneration",
3160
3183
  previousGenerationSha,
3161
3184
  currentGenerationSha: headSha,
3162
3185
  baseBranchHead: options.baseBranchHead ?? null,
3163
3186
  _prepared: {
3187
+ kind: "terminal",
3164
3188
  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
3189
+ preparedReport
3173
3190
  }
3174
3191
  };
3175
3192
  }
3176
- let existingPatches = this.lockManager.getPatches();
3193
+ let existingPatches = this.service.lockManager.getPatches();
3177
3194
  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));
3195
+ const preRebaseCounts = await this.service.preGenerationRebase(existingPatches);
3196
+ const postRebasePatchIds = new Set(this.service.lockManager.getPatches().map((p) => p.id));
3180
3197
  const removedByPreRebase = existingPatches.filter(
3181
3198
  (p) => preRebasePatchIds.has(p.id) && !postRebasePatchIds.has(p.id)
3182
3199
  );
3183
- existingPatches = this.lockManager.getPatches();
3200
+ existingPatches = this.service.lockManager.getPatches();
3184
3201
  const seenHashes = /* @__PURE__ */ new Map();
3185
3202
  for (const p of existingPatches) {
3186
3203
  const priorPatchId = seenHashes.get(p.content_hash);
3187
3204
  if (priorPatchId !== void 0) {
3188
- this.lockManager.removePatch(p.id);
3189
- this.warnings.push(
3205
+ this.service.lockManager.removePatch(p.id);
3206
+ this.service.warnings.push(
3190
3207
  `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
3208
  );
3192
3209
  } else {
3193
3210
  seenHashes.set(p.content_hash, p.id);
3194
3211
  }
3195
3212
  }
3196
- existingPatches = this.lockManager.getPatches();
3197
- let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
3198
- const detectorWarnings = [...this.detector.warnings];
3213
+ existingPatches = this.service.lockManager.getPatches();
3214
+ let { patches: newPatches, revertedPatchIds } = await this.service.detector.detectNewPatches();
3215
+ const detectorWarnings = [...this.service.detector.warnings];
3199
3216
  if (removedByPreRebase.length > 0) {
3200
3217
  const removedOriginalCommits = new Set(removedByPreRebase.map((p) => p.original_commit));
3201
3218
  const removedOriginalMessages = new Set(removedByPreRebase.map((p) => p.original_message));
@@ -3210,7 +3227,7 @@ var ReplayService = class {
3210
3227
  }
3211
3228
  for (const id of revertedPatchIds) {
3212
3229
  try {
3213
- this.lockManager.removePatch(id);
3230
+ this.service.lockManager.removePatch(id);
3214
3231
  } catch {
3215
3232
  }
3216
3233
  }
@@ -3222,18 +3239,18 @@ var ReplayService = class {
3222
3239
  generatorVersions: options.generatorVersions ?? {},
3223
3240
  baseBranchHead: options.baseBranchHead
3224
3241
  } : 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);
3242
+ await this.service.committer.commitGeneration("Update SDK", commitOpts);
3243
+ await this.service.cleanupStaleConflictMarkers();
3244
+ const genRecord = await this.service.committer.createGenerationRecord(commitOpts);
3245
+ this.service.lockManager.addGeneration(genRecord);
3229
3246
  return {
3230
3247
  flow: "normal-regeneration",
3231
3248
  previousGenerationSha,
3232
3249
  currentGenerationSha: genRecord.commit_sha,
3233
3250
  baseBranchHead: options?.baseBranchHead ?? null,
3234
3251
  _prepared: {
3252
+ kind: "active",
3235
3253
  flow: "normal-regeneration",
3236
- terminal: false,
3237
3254
  patchesToApply: allPatches,
3238
3255
  newPatchIds: new Set(newPatches.map((p) => p.id)),
3239
3256
  preRebaseCounts,
@@ -3244,46 +3261,47 @@ var ReplayService = class {
3244
3261
  }
3245
3262
  };
3246
3263
  }
3247
- async _applyNormalRegeneration(prep, options) {
3264
+ async apply(prep, options) {
3265
+ assertActive(prep);
3248
3266
  const allPatches = prep._prepared.patchesToApply;
3249
3267
  const newPatchIds = prep._prepared.newPatchIds;
3250
3268
  const detectorWarnings = prep._prepared.detectorWarnings;
3251
3269
  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);
3270
+ const results = await this.service.applicator.applyPatches(allPatches);
3271
+ this.service._lastApplyResults = results;
3272
+ this.service.recordDroppedContextLineWarnings(results);
3273
+ this.service.revertConflictingFiles(results);
3256
3274
  for (const result of results) {
3257
3275
  if (result.status === "conflict") {
3258
3276
  result.patch.status = "unresolved";
3259
3277
  }
3260
3278
  }
3261
- const rebaseCounts = await this.rebasePatches(results, genSha);
3279
+ const rebaseCounts = await this.service.rebasePatches(results, genSha);
3262
3280
  const newPatches = allPatches.filter((p) => newPatchIds.has(p.id));
3263
3281
  for (const patch of newPatches) {
3264
3282
  if (!rebaseCounts.absorbedPatchIds.has(patch.id)) {
3265
- this.lockManager.addPatch(patch);
3283
+ this.service.lockManager.addPatch(patch);
3266
3284
  }
3267
3285
  }
3268
3286
  for (const result of results) {
3269
3287
  if (result.status === "conflict") {
3270
3288
  try {
3271
- this.lockManager.markPatchUnresolved(result.patch.id);
3289
+ this.service.lockManager.markPatchUnresolved(result.patch.id);
3272
3290
  } catch {
3273
3291
  }
3274
3292
  }
3275
3293
  }
3276
- this.lockManager.save();
3277
- this.warnings.push(...this.lockManager.warnings);
3294
+ this.service.lockManager.save();
3295
+ this.service.warnings.push(...this.service.lockManager.warnings);
3278
3296
  if (options?.stageOnly) {
3279
- await this.committer.stageAll();
3297
+ await this.service.committer.stageAll();
3280
3298
  } else {
3281
3299
  const appliedCount = results.filter((r) => r.status === "applied").length;
3282
3300
  const buckets = computeCommitBuckets(results, rebaseCounts.absorbedPatchIds, allPatches);
3283
- await this.committer.commitReplay(appliedCount, allPatches, void 0, { buckets });
3301
+ await this.service.committer.commitReplay(appliedCount, allPatches, void 0, { buckets });
3284
3302
  }
3285
- const warnings = [...detectorWarnings, ...this.warnings];
3286
- return this.buildReport(
3303
+ const warnings = [...detectorWarnings, ...this.service.warnings];
3304
+ return this.service.buildReport(
3287
3305
  "normal-regeneration",
3288
3306
  allPatches,
3289
3307
  results,
@@ -3294,10 +3312,213 @@ var ReplayService = class {
3294
3312
  prep._prepared.revertedCount
3295
3313
  );
3296
3314
  }
3315
+ };
3316
+
3317
+ // src/ReplayService.ts
3318
+ function assertActive(prep) {
3319
+ if (prep._prepared.kind !== "active") {
3320
+ throw new Error(
3321
+ `Flow.apply() called with terminal preparation (kind=${prep._prepared.kind}); terminal cases must be handled by applyPreparedReplay's dispatcher`
3322
+ );
3323
+ }
3324
+ }
3325
+ var ReplayService = class {
3326
+ /** @internal Used by Flow implementations in `src/flows/`. */
3327
+ git;
3328
+ /** @internal Used by Flow implementations in `src/flows/`. */
3329
+ detector;
3330
+ /** @internal Used by Flow implementations in `src/flows/`. */
3331
+ applicator;
3332
+ /** @internal Used by Flow implementations in `src/flows/`. */
3333
+ committer;
3334
+ /** @internal Used by Flow implementations in `src/flows/`. */
3335
+ lockManager;
3336
+ /** @internal Used by Flow implementations in `src/flows/`. */
3337
+ fileOwnership;
3338
+ /** @internal Used by Flow implementations in `src/flows/`. */
3339
+ outputDir;
3340
+ /** @internal Test-only. Captures the last ReplayResult[] returned from applicator.applyPatches(). */
3341
+ _lastApplyResults = [];
3342
+ /**
3343
+ * Service-level warnings accumulated during a single runReplay() call.
3344
+ * Merged with `detector.warnings` into `ReplayReport.warnings` by each flow handler.
3345
+ * Populated by `FileOwnership.resolveCanonical` when a patch's base generation tree
3346
+ * is unreachable (shallow clone / aggressive `git gc --prune`) and we fall back to a
3347
+ * conservative heuristic. Cleared at the top of `runReplay()`.
3348
+ *
3349
+ * @internal Used by Flow implementations in `src/flows/`.
3350
+ */
3351
+ warnings = [];
3352
+ /**
3353
+ * @internal Test-only accessor.
3354
+ * Returns the ReplayApplicator instance used for the last run. Tests use this
3355
+ * to inspect the inter-patch accumulator after runReplay() completes (invariant I2).
3356
+ */
3357
+ get applicatorRef() {
3358
+ return this.applicator;
3359
+ }
3360
+ /**
3361
+ * @internal Test-only accessor.
3362
+ * Returns the ReplayResult[] from the most recent applyPatches() call. Tests use
3363
+ * this to filter applied-vs-conflicted-vs-absorbed patches for invariant
3364
+ * assertions. Returns an empty array if no patches have been applied yet.
3365
+ */
3366
+ getLastResults() {
3367
+ return this._lastApplyResults;
3368
+ }
3369
+ constructor(outputDir, _config) {
3370
+ const git = new GitClient(outputDir);
3371
+ this.git = git;
3372
+ this.outputDir = outputDir;
3373
+ this.lockManager = new LockfileManager(outputDir);
3374
+ this.detector = new ReplayDetector(git, this.lockManager, outputDir);
3375
+ this.applicator = new ReplayApplicator(git, this.lockManager, outputDir);
3376
+ this.committer = new ReplayCommitter(git, outputDir);
3377
+ this.fileOwnership = new FileOwnership(git);
3378
+ }
3379
+ /**
3380
+ * Phase 1 of the two-phase replay flow. Runs preGenerationRebase (when applicable),
3381
+ * detects new patches, and creates the `[fern-generated]` commit. Leaves HEAD at
3382
+ * the generation commit (or unchanged for dry-run).
3383
+ *
3384
+ * Does NOT apply patches — the returned handle must be passed to
3385
+ * `applyPreparedReplay()` to complete the run. No disk writes to the lockfile
3386
+ * happen here; lockfile persistence is deferred to phase 2.
3387
+ *
3388
+ * Callers may land additional commits on HEAD between `prepareReplay` and
3389
+ * `applyPreparedReplay` (e.g., `[fern-autoversion]`).
3390
+ */
3391
+ async prepareReplay(options) {
3392
+ this.warnings = [];
3393
+ this.detector.warnings.length = 0;
3394
+ return this.selectFlow(options).prepare(options);
3395
+ }
3396
+ /**
3397
+ * Phase 2 of the two-phase replay flow. Applies patches, rebases them against
3398
+ * the generation, saves the lockfile, and commits `[fern-replay]` (or stages
3399
+ * under `stageOnly`). Operates on HEAD at call time — mid-phase commits are
3400
+ * respected.
3401
+ *
3402
+ * For terminal handles (dry-run, first-gen has no patch work, skip-app does
3403
+ * its own post-commit logic), this returns the precomputed report.
3404
+ */
3405
+ async applyPreparedReplay(prep, options) {
3406
+ if (prep._prepared.kind === "terminal") {
3407
+ return prep._prepared.preparedReport;
3408
+ }
3409
+ return this.flowForKind(prep._prepared.flow).apply(prep, options);
3410
+ }
3411
+ /**
3412
+ * Construct the flow for the current run. `selectFlow` is called from
3413
+ * `prepareReplay` and decides based on options + lockfile state.
3414
+ */
3415
+ selectFlow(options) {
3416
+ if (options?.skipApplication) return new SkipApplicationFlow(this);
3417
+ return this.flowForKind(this.determineFlow());
3418
+ }
3419
+ /**
3420
+ * Construct a flow given its kind. Used by `applyPreparedReplay` to
3421
+ * route to the same kind that was selected in phase 1.
3422
+ */
3423
+ flowForKind(kind) {
3424
+ switch (kind) {
3425
+ case "first-generation":
3426
+ return new FirstGenerationFlow(this);
3427
+ case "no-patches":
3428
+ return new NoPatchesFlow(this);
3429
+ case "normal-regeneration":
3430
+ return new NormalRegenerationFlow(this);
3431
+ case "skip-application":
3432
+ return new SkipApplicationFlow(this);
3433
+ }
3434
+ }
3435
+ /**
3436
+ * Backwards-compatible atomic entry point. Composes `prepareReplay` + `applyPreparedReplay`.
3437
+ * Behavior is byte-identical to the pre-split implementation for all existing callers.
3438
+ */
3439
+ async runReplay(options) {
3440
+ const prep = await this.prepareReplay(options);
3441
+ return this.applyPreparedReplay(prep, options);
3442
+ }
3443
+ /**
3444
+ * Sync the lockfile after a divergent PR was squash-merged.
3445
+ * Call this BEFORE runReplay() when the CLI detects a merged divergent PR.
3446
+ *
3447
+ * After updating the generation record, re-detects customer patches via
3448
+ * tree diff between the generation tag (pure generation tree) and HEAD
3449
+ * (which includes customer customizations after squash merge). This ensures
3450
+ * patches survive the squash merge → regenerate cycle even when the lockfile
3451
+ * was restored from the base branch during conflict PR creation.
3452
+ */
3453
+ async syncFromDivergentMerge(generationCommitSha, options) {
3454
+ const treeHash = await this.git.getTreeHash(generationCommitSha);
3455
+ const timestamp = (await this.git.exec(["log", "-1", "--format=%aI", generationCommitSha])).trim();
3456
+ const record = {
3457
+ commit_sha: generationCommitSha,
3458
+ tree_hash: treeHash,
3459
+ timestamp,
3460
+ cli_version: options?.cliVersion ?? "unknown",
3461
+ generator_versions: options?.generatorVersions ?? {},
3462
+ base_branch_head: options?.baseBranchHead
3463
+ };
3464
+ let resolvedPatches;
3465
+ if (!this.lockManager.exists()) {
3466
+ this.lockManager.initializeInMemory(record);
3467
+ } else {
3468
+ this.lockManager.read();
3469
+ const unresolvedPatches = [
3470
+ ...this.lockManager.getUnresolvedPatches(),
3471
+ ...this.lockManager.getResolvingPatches()
3472
+ ];
3473
+ resolvedPatches = this.lockManager.getPatches().filter((p) => p.status == null);
3474
+ this.lockManager.addGeneration(record);
3475
+ this.lockManager.clearPatches();
3476
+ for (const patch of unresolvedPatches) {
3477
+ this.lockManager.addPatch(patch);
3478
+ }
3479
+ }
3480
+ try {
3481
+ const { patches: redetectedPatches } = await this.detector.detectNewPatches();
3482
+ if (redetectedPatches.length > 0) {
3483
+ const redetectedFiles = new Set(redetectedPatches.flatMap((p) => p.files));
3484
+ const currentPatches = this.lockManager.getPatches();
3485
+ for (const patch of currentPatches) {
3486
+ if (patch.status != null && patch.files.some((f) => redetectedFiles.has(f))) {
3487
+ this.lockManager.removePatch(patch.id);
3488
+ }
3489
+ }
3490
+ for (const patch of redetectedPatches) {
3491
+ this.lockManager.addPatch(patch);
3492
+ }
3493
+ }
3494
+ } catch (error) {
3495
+ for (const patch of resolvedPatches ?? []) {
3496
+ this.lockManager.addPatch(patch);
3497
+ }
3498
+ this.detector.warnings.push(
3499
+ `Patch re-detection failed after divergent merge sync. ${(resolvedPatches ?? []).length} previously resolved patch(es) preserved. Error: ${error instanceof Error ? error.message : String(error)}`
3500
+ );
3501
+ }
3502
+ this.lockManager.save();
3503
+ this.detector.warnings.push(...this.lockManager.warnings);
3504
+ }
3505
+ determineFlow() {
3506
+ try {
3507
+ const lock = this.lockManager.read();
3508
+ return lock.patches.length === 0 ? "no-patches" : "normal-regeneration";
3509
+ } catch (error) {
3510
+ if (error instanceof LockfileNotFoundError) {
3511
+ return "first-generation";
3512
+ }
3513
+ throw error;
3514
+ }
3515
+ }
3297
3516
  /**
3298
3517
  * Rebase cleanly applied patches so they are relative to the current generation.
3299
3518
  * This prevents recurring conflicts on subsequent regenerations.
3300
3519
  * Returns the number of patches rebased.
3520
+ *
3521
+ * @internal Used by Flow implementations in `src/flows/`.
3301
3522
  */
3302
3523
  async rebasePatches(results, currentGenSha) {
3303
3524
  let absorbed = 0;
@@ -3307,6 +3528,7 @@ var ReplayService = class {
3307
3528
  const seenContentHashes = /* @__PURE__ */ new Map();
3308
3529
  const absorbedPatchIds = /* @__PURE__ */ new Set();
3309
3530
  const fernignorePatterns = this.readFernignorePatterns();
3531
+ const warnedGens = /* @__PURE__ */ new Set();
3310
3532
  for (const result of results) {
3311
3533
  if (result.resolvedFiles && Object.keys(result.resolvedFiles).length > 0) {
3312
3534
  const patch = result.patch;
@@ -3351,21 +3573,20 @@ var ReplayService = class {
3351
3573
  }
3352
3574
  }
3353
3575
  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
- })
3576
+ patch.files.map(
3577
+ (file) => this.fileOwnership.resolveCanonical({
3578
+ file,
3579
+ originalFileName: originalByResolved[file],
3580
+ patch,
3581
+ currentGenSha,
3582
+ fernignorePatterns,
3583
+ warnedGens,
3584
+ warnings: this.warnings
3585
+ })
3586
+ )
3366
3587
  );
3367
3588
  const hasProtectedFiles = patch.files.some(
3368
- (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch2(file, p))
3589
+ (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch3(file, p))
3369
3590
  );
3370
3591
  const allUserOwned = isUserOwnedPerFile.every(Boolean);
3371
3592
  if (allUserOwned && patch.files.length > 0) {
@@ -3489,7 +3710,7 @@ var ReplayService = class {
3489
3710
  for (const file of files) {
3490
3711
  if (isBinaryFile(file)) continue;
3491
3712
  try {
3492
- const content = await readFileAsync(join5(this.outputDir, file), "utf-8");
3713
+ const content = await readFileAsync(join6(this.outputDir, file), "utf-8");
3493
3714
  snapshot[file] = content;
3494
3715
  } catch {
3495
3716
  }
@@ -3497,10 +3718,16 @@ var ReplayService = class {
3497
3718
  return snapshot;
3498
3719
  }
3499
3720
  /**
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.
3721
+ * Detects autoversion contamination — returns true when a
3722
+ * `[fern-autoversion]` commit between `fromSha` and `toSha` touched any
3723
+ * of the patch's `files`. Re-classifies each grep match via
3724
+ * `isGenerationCommit` so a customer commit that merely quotes the
3725
+ * marker in its body doesn't false-positive.
3726
+ *
3727
+ * Used by preGenerationRebase to skip the customer-content rebuild for
3728
+ * patches whose files were last touched by an autoversion step — a
3729
+ * naive `git diff currentGen HEAD` there would capture the
3730
+ * placeholder→semver swap as customer customization.
3504
3731
  */
3505
3732
  async hasAutoversionContamination(fromSha, toSha, files) {
3506
3733
  if (files.length === 0) return false;
@@ -3529,10 +3756,15 @@ var ReplayService = class {
3529
3756
  return false;
3530
3757
  }
3531
3758
  /**
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.
3759
+ * Rebuild patch_content from `theirs_snapshot` (captured at detection
3760
+ * time, predates pipeline content) rather than a HEAD-relative diff.
3761
+ * Returns `null` when snapshot is missing or doesn't cover every file
3762
+ * in `patch.files` — caller leaves the patch unchanged.
3763
+ *
3764
+ * Used as the autoversion-contamination escape hatch: when the
3765
+ * HEAD-relative diff would capture pipeline content (autoversion's
3766
+ * placeholder swap) as customer customization, the snapshot path
3767
+ * preserves the customer's actual intent.
3536
3768
  */
3537
3769
  async computeSnapshotBasedPatchDiff(patch, currentGen) {
3538
3770
  if (!patch.theirs_snapshot) return null;
@@ -3580,7 +3812,7 @@ var ReplayService = class {
3580
3812
  * **Skips patches sharing files with other patches.** HEAD's content
3581
3813
  * for a file shared by N patches is cumulative across all of them, so
3582
3814
  * a HEAD-fallback would falsely encode other patches' contributions
3583
- * into one patch's snapshot — breaking FER-9525 cross-patch isolation
3815
+ * into one patch's snapshot — breaking cross-patch isolation
3584
3816
  * if the snapshot fallback later fires. Per-patch snapshots only
3585
3817
  * make sense when the patch owns its file. Multi-patch-same-file
3586
3818
  * legacy lockfiles keep using line-anchored reconstruction (the
@@ -3642,72 +3874,6 @@ var ReplayService = class {
3642
3874
  );
3643
3875
  }
3644
3876
  }
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
3877
  /**
3712
3878
  * For conflict patches with mixed results (some files merged, some conflicted),
3713
3879
  * check if the cleanly merged files were absorbed by the generator (empty diff).
@@ -3726,7 +3892,15 @@ var ReplayService = class {
3726
3892
  const warnedGens = /* @__PURE__ */ new Set();
3727
3893
  const generatorCleanFiles = [];
3728
3894
  for (const file of cleanFiles) {
3729
- if (await this.isFileUserOwned(file, patch, currentGenSha, fernignorePatterns, warnedGens)) {
3895
+ const userOwned = await this.fileOwnership.resolveCanonical({
3896
+ file,
3897
+ patch,
3898
+ currentGenSha,
3899
+ fernignorePatterns,
3900
+ warnedGens,
3901
+ warnings: this.warnings
3902
+ });
3903
+ if (userOwned) {
3730
3904
  continue;
3731
3905
  }
3732
3906
  generatorCleanFiles.push(file);
@@ -3755,6 +3929,7 @@ var ReplayService = class {
3755
3929
  * Pre-generation rebase: update patches using the customer's current state.
3756
3930
  * Called BEFORE commitGeneration() while HEAD has customer code.
3757
3931
  */
3932
+ /** @internal Used by Flow implementations in `src/flows/`. */
3758
3933
  async preGenerationRebase(patches) {
3759
3934
  const lock = this.lockManager.read();
3760
3935
  const currentGen = lock.current_generation;
@@ -3793,7 +3968,15 @@ var ReplayService = class {
3793
3968
  if (patch.user_owned) return true;
3794
3969
  if (patch.files.length === 0) return false;
3795
3970
  for (const file of patch.files) {
3796
- if (!await this.isFileUserOwned(file, patch, currentGen, fernignorePatterns, warnedGens)) {
3971
+ const userOwned = await this.fileOwnership.resolveCanonical({
3972
+ file,
3973
+ patch,
3974
+ currentGenSha: currentGen,
3975
+ fernignorePatterns,
3976
+ warnedGens,
3977
+ warnings: this.warnings
3978
+ });
3979
+ if (!userOwned) {
3797
3980
  return false;
3798
3981
  }
3799
3982
  }
@@ -3843,7 +4026,7 @@ var ReplayService = class {
3843
4026
  );
3844
4027
  if (snapHasStaleMarkers) continue;
3845
4028
  const isProtectedSnap = patch.files.some(
3846
- (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch2(file, p))
4029
+ (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch3(file, p))
3847
4030
  );
3848
4031
  if (isProtectedSnap) continue;
3849
4032
  const snapHash = this.detector.computeContentHash(snapshotDiff);
@@ -3878,7 +4061,7 @@ var ReplayService = class {
3878
4061
  continue;
3879
4062
  }
3880
4063
  const isProtected = patch.files.some(
3881
- (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch2(file, p))
4064
+ (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch3(file, p))
3882
4065
  );
3883
4066
  if (isProtected) {
3884
4067
  continue;
@@ -3985,6 +4168,7 @@ var ReplayService = class {
3985
4168
  * relied on them. This is the "surface or preserve, never silently drop"
3986
4169
  * contract for legitimate-deletion cases.
3987
4170
  */
4171
+ /** @internal Used by Flow implementations in `src/flows/`. */
3988
4172
  recordDroppedContextLineWarnings(results) {
3989
4173
  const PREVIEW_COUNT = 5;
3990
4174
  for (const result of results) {
@@ -4007,12 +4191,13 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
4007
4191
  * After applyPatches(), strip conflict markers from conflicting files
4008
4192
  * so only clean content is committed. Keeps the Generated (OURS) side.
4009
4193
  */
4194
+ /** @internal Used by Flow implementations in `src/flows/`. */
4010
4195
  revertConflictingFiles(results) {
4011
4196
  for (const result of results) {
4012
4197
  if (result.status !== "conflict" || !result.fileResults) continue;
4013
4198
  for (const fileResult of result.fileResults) {
4014
4199
  if (fileResult.status !== "conflict") continue;
4015
- const filePath = join5(this.outputDir, fileResult.file);
4200
+ const filePath = join6(this.outputDir, fileResult.file);
4016
4201
  try {
4017
4202
  const content = readFileSync2(filePath, "utf-8");
4018
4203
  const stripped = stripConflictMarkers(content);
@@ -4028,13 +4213,14 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
4028
4213
  * Restores files to their clean generated state from HEAD.
4029
4214
  * Skips .fernignore-protected files to prevent overwriting user content.
4030
4215
  */
4216
+ /** @internal Used by Flow implementations in `src/flows/`. */
4031
4217
  async cleanupStaleConflictMarkers() {
4032
4218
  const markerFiles = await this.git.exec(["grep", "-l", "<<<<<<< Generated", "--", "."]).catch(() => "");
4033
4219
  const files = markerFiles.trim().split("\n").filter(Boolean);
4034
4220
  if (files.length === 0) return;
4035
4221
  const fernignorePatterns = this.readFernignorePatterns();
4036
4222
  for (const file of files) {
4037
- if (fernignorePatterns.some((pattern) => minimatch2(file, pattern))) continue;
4223
+ if (fernignorePatterns.some((pattern) => minimatch3(file, pattern))) continue;
4038
4224
  try {
4039
4225
  await this.git.exec(["checkout", "HEAD", "--", file]);
4040
4226
  } catch {
@@ -4042,10 +4228,11 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
4042
4228
  }
4043
4229
  }
4044
4230
  readFernignorePatterns() {
4045
- const fernignorePath = join5(this.outputDir, ".fernignore");
4231
+ const fernignorePath = join6(this.outputDir, ".fernignore");
4046
4232
  if (!existsSync2(fernignorePath)) return [];
4047
4233
  return readFileSync2(fernignorePath, "utf-8").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
4048
4234
  }
4235
+ /** @internal Used by Flow implementations in `src/flows/`. */
4049
4236
  buildReport(flow, patches, results, options, warnings, rebaseCounts, preRebaseCounts, patchesReverted) {
4050
4237
  const conflictResults = results.filter((r) => r.status === "conflict");
4051
4238
  const conflictDetails = conflictResults.map((r) => {
@@ -4121,8 +4308,8 @@ function computeCommitBuckets(results, absorbedPatchIds, patchesPassedToCommit)
4121
4308
  // src/FernignoreMigrator.ts
4122
4309
  import { createHash as createHash2 } from "crypto";
4123
4310
  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";
4311
+ import { dirname as dirname5, join as join7 } from "path";
4312
+ import { minimatch as minimatch4 } from "minimatch";
4126
4313
  import { parse as parse2, stringify as stringify2 } from "yaml";
4127
4314
  var FernignoreMigrator = class {
4128
4315
  git;
@@ -4134,10 +4321,10 @@ var FernignoreMigrator = class {
4134
4321
  this.outputDir = outputDir;
4135
4322
  }
4136
4323
  fernignoreExists() {
4137
- return existsSync3(join6(this.outputDir, ".fernignore"));
4324
+ return existsSync3(join7(this.outputDir, ".fernignore"));
4138
4325
  }
4139
4326
  readFernignorePatterns() {
4140
- const fernignorePath = join6(this.outputDir, ".fernignore");
4327
+ const fernignorePath = join7(this.outputDir, ".fernignore");
4141
4328
  if (!existsSync3(fernignorePath)) {
4142
4329
  return [];
4143
4330
  }
@@ -4154,7 +4341,7 @@ var FernignoreMigrator = class {
4154
4341
  const commitsOnly = [];
4155
4342
  const syntheticPatches = [];
4156
4343
  for (const pattern of patterns) {
4157
- const matchingPatch = patches.find((p) => p.files.some((f) => minimatch3(f, pattern) || f === pattern));
4344
+ const matchingPatch = patches.find((p) => p.files.some((f) => minimatch4(f, pattern) || f === pattern));
4158
4345
  if (matchingPatch) {
4159
4346
  trackedByBoth.push({
4160
4347
  file: pattern,
@@ -4227,7 +4414,7 @@ var FernignoreMigrator = class {
4227
4414
  fernignoreOnly.push(...remainingFernignoreOnly);
4228
4415
  }
4229
4416
  for (const patch of patches) {
4230
- const hasUnprotectedFiles = patch.files.some((f) => !patterns.some((p) => minimatch3(f, p) || f === p));
4417
+ const hasUnprotectedFiles = patch.files.some((f) => !patterns.some((p) => minimatch4(f, p) || f === p));
4231
4418
  if (hasUnprotectedFiles) {
4232
4419
  commitsOnly.push(patch);
4233
4420
  }
@@ -4239,14 +4426,14 @@ var FernignoreMigrator = class {
4239
4426
  const results = [];
4240
4427
  for (const pattern of patterns) {
4241
4428
  const matching = allFiles.filter(
4242
- (f) => minimatch3(f, pattern) || f === pattern || f.startsWith(pattern + "/")
4429
+ (f) => minimatch4(f, pattern) || f === pattern || f.startsWith(pattern + "/")
4243
4430
  );
4244
4431
  results.push({ pattern, files: matching.length > 0 ? matching : [pattern] });
4245
4432
  }
4246
4433
  return results;
4247
4434
  }
4248
4435
  readFileContent(filePath) {
4249
- const fullPath = join6(this.outputDir, filePath);
4436
+ const fullPath = join7(this.outputDir, filePath);
4250
4437
  if (!existsSync3(fullPath)) {
4251
4438
  return null;
4252
4439
  }
@@ -4311,7 +4498,7 @@ var FernignoreMigrator = class {
4311
4498
  };
4312
4499
  }
4313
4500
  movePatternsToReplayYml(patterns) {
4314
- const replayYmlPath = join6(this.outputDir, ".fern", "replay.yml");
4501
+ const replayYmlPath = join7(this.outputDir, ".fern", "replay.yml");
4315
4502
  let config = {};
4316
4503
  if (existsSync3(replayYmlPath)) {
4317
4504
  const content = readFileSync3(replayYmlPath, "utf-8");
@@ -4320,7 +4507,7 @@ var FernignoreMigrator = class {
4320
4507
  const existing = config.exclude ?? [];
4321
4508
  const merged = [.../* @__PURE__ */ new Set([...existing, ...patterns])];
4322
4509
  config.exclude = merged;
4323
- const dir = dirname4(replayYmlPath);
4510
+ const dir = dirname5(replayYmlPath);
4324
4511
  if (!existsSync3(dir)) {
4325
4512
  mkdirSync2(dir, { recursive: true });
4326
4513
  }
@@ -4331,7 +4518,7 @@ var FernignoreMigrator = class {
4331
4518
  // src/commands/bootstrap.ts
4332
4519
  import { createHash as createHash3 } from "crypto";
4333
4520
  import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
4334
- import { join as join7 } from "path";
4521
+ import { join as join8 } from "path";
4335
4522
  init_GitClient();
4336
4523
  async function bootstrap(outputDir, options) {
4337
4524
  const git = new GitClient(outputDir);
@@ -4498,7 +4685,7 @@ function parseGitLog(log) {
4498
4685
  }
4499
4686
  var REPLAY_FERNIGNORE_ENTRIES = [".fern/replay.lock", ".fern/replay.yml", ".gitattributes"];
4500
4687
  function ensureFernignoreEntries(outputDir) {
4501
- const fernignorePath = join7(outputDir, ".fernignore");
4688
+ const fernignorePath = join8(outputDir, ".fernignore");
4502
4689
  let content = "";
4503
4690
  if (existsSync4(fernignorePath)) {
4504
4691
  content = readFileSync4(fernignorePath, "utf-8");
@@ -4522,7 +4709,7 @@ function ensureFernignoreEntries(outputDir) {
4522
4709
  }
4523
4710
  var GITATTRIBUTES_ENTRIES = [".fern/replay.lock linguist-generated=true"];
4524
4711
  function ensureGitattributesEntries(outputDir) {
4525
- const gitattributesPath = join7(outputDir, ".gitattributes");
4712
+ const gitattributesPath = join8(outputDir, ".gitattributes");
4526
4713
  let content = "";
4527
4714
  if (existsSync4(gitattributesPath)) {
4528
4715
  content = readFileSync4(gitattributesPath, "utf-8");
@@ -4552,7 +4739,7 @@ function computeContentHash(patchContent) {
4552
4739
  }
4553
4740
 
4554
4741
  // src/commands/forget.ts
4555
- import { minimatch as minimatch4 } from "minimatch";
4742
+ import { minimatch as minimatch5 } from "minimatch";
4556
4743
  function parseDiffStat(patchContent) {
4557
4744
  let additions = 0;
4558
4745
  let deletions = 0;
@@ -4582,7 +4769,7 @@ function toMatchedPatch(patch) {
4582
4769
  }
4583
4770
  function matchesPatch(patch, pattern) {
4584
4771
  const fileMatch = patch.files.some(
4585
- (file) => file === pattern || minimatch4(file, pattern)
4772
+ (file) => file === pattern || minimatch5(file, pattern)
4586
4773
  );
4587
4774
  if (fileMatch) return true;
4588
4775
  return patch.original_message.toLowerCase().includes(pattern.toLowerCase());
@@ -4721,8 +4908,8 @@ function reset(outputDir, options) {
4721
4908
 
4722
4909
  // src/commands/resolve.ts
4723
4910
  init_GitClient();
4724
- import { readFile as readFile3 } from "fs/promises";
4725
- import { join as join8 } from "path";
4911
+ import { readFile as readFile4 } from "fs/promises";
4912
+ import { join as join9 } from "path";
4726
4913
  async function resolve(outputDir, options) {
4727
4914
  const lockManager = new LockfileManager(outputDir);
4728
4915
  if (!lockManager.exists()) {
@@ -4770,7 +4957,7 @@ async function resolve(outputDir, options) {
4770
4957
  const result = await computePerPatchDiffWithFallback(
4771
4958
  patch,
4772
4959
  (f) => git.showFile(currentGen, f),
4773
- (f) => readFile3(join8(outputDir, f), "utf-8").catch(() => null),
4960
+ (f) => readFile4(join9(outputDir, f), "utf-8").catch(() => null),
4774
4961
  (files) => git.exec(["diff", currentGen, "--", ...files]).catch(() => null)
4775
4962
  );
4776
4963
  if (result === null) {
@@ -4826,7 +5013,7 @@ async function resolve(outputDir, options) {
4826
5013
  const theirsSnapshot = {};
4827
5014
  for (const file of filesForSnapshot) {
4828
5015
  if (isBinaryFile(file)) continue;
4829
- const content = await readFile3(join8(outputDir, file), "utf-8").catch(() => null);
5016
+ const content = await readFile4(join9(outputDir, file), "utf-8").catch(() => null);
4830
5017
  if (content != null) {
4831
5018
  theirsSnapshot[file] = content;
4832
5019
  }