@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.cjs CHANGED
@@ -425,24 +425,24 @@ __export(PatchApplyTheirs_exports, {
425
425
  async function applyPatchToContent(base, patchContent, filePath) {
426
426
  const fileDiff = extractFileDiff(patchContent, filePath);
427
427
  if (!fileDiff) return null;
428
- const tempDir = await (0, import_promises3.mkdtemp)((0, import_node_path4.join)((0, import_node_os3.tmpdir)(), "replay-migrate-"));
428
+ const tempDir = await (0, import_promises5.mkdtemp)((0, import_node_path7.join)((0, import_node_os3.tmpdir)(), "replay-migrate-"));
429
429
  const tempGit = new GitClient(tempDir);
430
430
  try {
431
431
  await tempGit.exec(["init"]);
432
432
  await tempGit.exec(["config", "user.email", "migrate@fern.com"]);
433
433
  await tempGit.exec(["config", "user.name", "Fern Replay Migration"]);
434
434
  await tempGit.exec(["config", "commit.gpgSign", "false"]);
435
- const tempFilePath = (0, import_node_path4.join)(tempDir, filePath);
436
- await (0, import_promises3.mkdir)((0, import_node_path4.dirname)(tempFilePath), { recursive: true });
437
- await (0, import_promises3.writeFile)(tempFilePath, base);
435
+ const tempFilePath = (0, import_node_path7.join)(tempDir, filePath);
436
+ await (0, import_promises5.mkdir)((0, import_node_path7.dirname)(tempFilePath), { recursive: true });
437
+ await (0, import_promises5.writeFile)(tempFilePath, base);
438
438
  await tempGit.exec(["add", filePath]);
439
439
  await tempGit.exec(["commit", "-m", `base for ${filePath}`, "--allow-empty"]);
440
440
  await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
441
- return await (0, import_promises3.readFile)(tempFilePath, "utf-8");
441
+ return await (0, import_promises5.readFile)(tempFilePath, "utf-8");
442
442
  } catch {
443
443
  return null;
444
444
  } finally {
445
- await (0, import_promises3.rm)(tempDir, { recursive: true, force: true }).catch(() => {
445
+ await (0, import_promises5.rm)(tempDir, { recursive: true, force: true }).catch(() => {
446
446
  });
447
447
  }
448
448
  }
@@ -464,13 +464,13 @@ function extractFileDiff(patchContent, filePath) {
464
464
  }
465
465
  return out.length > 0 ? out.join("\n") + "\n" : null;
466
466
  }
467
- var import_promises3, import_node_os3, import_node_path4;
467
+ var import_promises5, import_node_os3, import_node_path7;
468
468
  var init_PatchApplyTheirs = __esm({
469
469
  "src/PatchApplyTheirs.ts"() {
470
470
  "use strict";
471
- import_promises3 = require("fs/promises");
471
+ import_promises5 = require("fs/promises");
472
472
  import_node_os3 = require("os");
473
- import_node_path4 = require("path");
473
+ import_node_path7 = require("path");
474
474
  init_GitClient();
475
475
  }
476
476
  });
@@ -770,225 +770,8 @@ var LockfileManager = class {
770
770
  // src/ReplayDetector.ts
771
771
  var import_node_crypto = require("crypto");
772
772
 
773
- // src/ReplayApplicator.ts
774
- var import_promises = require("fs/promises");
775
- var import_node_os = require("os");
773
+ // src/shared/binary.ts
776
774
  var import_node_path2 = require("path");
777
- var import_minimatch = require("minimatch");
778
-
779
- // src/conflict-utils.ts
780
- var CONFLICT_OPENER = "<<<<<<< Generated";
781
- var CONFLICT_SEPARATOR = "=======";
782
- var CONFLICT_CLOSER = ">>>>>>> Your customization";
783
- function trimCR(line) {
784
- return line.endsWith("\r") ? line.slice(0, -1) : line;
785
- }
786
- function findConflictRanges(lines) {
787
- const ranges = [];
788
- let i = 0;
789
- while (i < lines.length) {
790
- if (trimCR(lines[i]) === CONFLICT_OPENER) {
791
- let separatorIdx = -1;
792
- let j = i + 1;
793
- let found = false;
794
- while (j < lines.length) {
795
- const trimmed = trimCR(lines[j]);
796
- if (trimmed === CONFLICT_OPENER) {
797
- break;
798
- }
799
- if (separatorIdx === -1 && trimmed === CONFLICT_SEPARATOR) {
800
- separatorIdx = j;
801
- } else if (separatorIdx !== -1 && trimmed === CONFLICT_CLOSER) {
802
- ranges.push({ start: i, separator: separatorIdx, end: j });
803
- i = j;
804
- found = true;
805
- break;
806
- }
807
- j++;
808
- }
809
- if (!found) {
810
- i++;
811
- continue;
812
- }
813
- }
814
- i++;
815
- }
816
- return ranges;
817
- }
818
- function stripConflictMarkers(content) {
819
- const lines = content.split(/\r?\n/);
820
- const ranges = findConflictRanges(lines);
821
- if (ranges.length === 0) {
822
- return content;
823
- }
824
- const result = [];
825
- let rangeIdx = 0;
826
- for (let i = 0; i < lines.length; i++) {
827
- if (rangeIdx < ranges.length) {
828
- const range = ranges[rangeIdx];
829
- if (i === range.start) {
830
- continue;
831
- }
832
- if (i > range.start && i < range.separator) {
833
- result.push(lines[i]);
834
- continue;
835
- }
836
- if (i === range.separator) {
837
- continue;
838
- }
839
- if (i > range.separator && i < range.end) {
840
- continue;
841
- }
842
- if (i === range.end) {
843
- rangeIdx++;
844
- continue;
845
- }
846
- }
847
- result.push(lines[i]);
848
- }
849
- return result.join("\n");
850
- }
851
-
852
- // src/ThreeWayMerge.ts
853
- var import_node_diff3 = require("node-diff3");
854
- function threeWayMerge(base, ours, theirs) {
855
- const baseLines = base.split("\n");
856
- const oursLines = ours.split("\n");
857
- const theirsLines = theirs.split("\n");
858
- const regions = (0, import_node_diff3.diff3Merge)(oursLines, baseLines, theirsLines);
859
- const outputLines = [];
860
- const conflicts = [];
861
- let currentLine = 1;
862
- for (const region of regions) {
863
- if (region.ok) {
864
- outputLines.push(...region.ok);
865
- currentLine += region.ok.length;
866
- } else if (region.conflict) {
867
- const resolved = tryResolveConflict(
868
- region.conflict.a,
869
- // ours (generator)
870
- region.conflict.o,
871
- // base
872
- region.conflict.b
873
- // theirs (user)
874
- );
875
- if (resolved !== null) {
876
- outputLines.push(...resolved);
877
- currentLine += resolved.length;
878
- } else {
879
- const startLine = currentLine;
880
- outputLines.push("<<<<<<< Generated");
881
- outputLines.push(...region.conflict.a);
882
- outputLines.push("=======");
883
- outputLines.push(...region.conflict.b);
884
- outputLines.push(">>>>>>> Your customization");
885
- const conflictLines = region.conflict.a.length + region.conflict.b.length + 3;
886
- conflicts.push({
887
- startLine,
888
- endLine: startLine + conflictLines - 1,
889
- ours: region.conflict.a,
890
- theirs: region.conflict.b
891
- });
892
- currentLine += conflictLines;
893
- }
894
- }
895
- }
896
- const result = {
897
- content: outputLines.join("\n"),
898
- hasConflicts: conflicts.length > 0,
899
- conflicts
900
- };
901
- if (conflicts.length === 0) {
902
- const dropped = computeDroppedContextLines(baseLines, theirsLines, outputLines);
903
- if (dropped.length > 0) {
904
- result.droppedContextLines = dropped;
905
- }
906
- }
907
- return result;
908
- }
909
- function computeDroppedContextLines(baseLines, theirsLines, mergedLines) {
910
- const baseCounts = countLines(baseLines);
911
- const theirsCounts = countLines(theirsLines);
912
- const mergedCounts = countLines(mergedLines);
913
- const dropped = [];
914
- const seen = /* @__PURE__ */ new Set();
915
- for (const line of baseLines) {
916
- if (seen.has(line)) continue;
917
- const inBase = baseCounts.get(line) ?? 0;
918
- const inTheirs = theirsCounts.get(line) ?? 0;
919
- const inMerged = mergedCounts.get(line) ?? 0;
920
- if (inBase > 0 && inTheirs > 0 && inMerged < Math.min(inBase, inTheirs)) {
921
- dropped.push(line);
922
- seen.add(line);
923
- }
924
- }
925
- return dropped;
926
- }
927
- function countLines(lines) {
928
- const counts = /* @__PURE__ */ new Map();
929
- for (const line of lines) {
930
- counts.set(line, (counts.get(line) ?? 0) + 1);
931
- }
932
- return counts;
933
- }
934
- function tryResolveConflict(oursLines, baseLines, theirsLines) {
935
- if (baseLines.length === 0) {
936
- return null;
937
- }
938
- const oursPatches = (0, import_node_diff3.diffPatch)(baseLines, oursLines);
939
- const theirsPatches = (0, import_node_diff3.diffPatch)(baseLines, theirsLines);
940
- if (oursPatches.length === 0) return theirsLines;
941
- if (theirsPatches.length === 0) return oursLines;
942
- if (patchesOverlap(oursPatches, theirsPatches)) {
943
- return null;
944
- }
945
- return applyBothPatches(baseLines, oursPatches, theirsPatches);
946
- }
947
- function patchesOverlap(oursPatches, theirsPatches) {
948
- for (const op of oursPatches) {
949
- const oStart = op.buffer1.offset;
950
- const oEnd = oStart + op.buffer1.length;
951
- for (const tp of theirsPatches) {
952
- const tStart = tp.buffer1.offset;
953
- const tEnd = tStart + tp.buffer1.length;
954
- if (op.buffer1.length === 0 && tp.buffer1.length === 0 && oStart === tStart) {
955
- return true;
956
- }
957
- if (tp.buffer1.length === 0 && tStart === oEnd) {
958
- return true;
959
- }
960
- if (op.buffer1.length === 0 && oStart === tEnd) {
961
- return true;
962
- }
963
- if (oStart < tEnd && tStart < oEnd) {
964
- return true;
965
- }
966
- }
967
- }
968
- return false;
969
- }
970
- function applyBothPatches(baseLines, oursPatches, theirsPatches) {
971
- const allPatches = [
972
- ...oursPatches.map((p) => ({
973
- offset: p.buffer1.offset,
974
- length: p.buffer1.length,
975
- replacement: p.buffer2.chunk
976
- })),
977
- ...theirsPatches.map((p) => ({
978
- offset: p.buffer1.offset,
979
- length: p.buffer1.length,
980
- replacement: p.buffer2.chunk
981
- }))
982
- ];
983
- allPatches.sort((a, b) => b.offset - a.offset);
984
- const result = [...baseLines];
985
- for (const p of allPatches) {
986
- result.splice(p.offset, p.length, ...p.replacement);
987
- }
988
- return result;
989
- }
990
-
991
- // src/ReplayApplicator.ts
992
775
  var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
993
776
  ".png",
994
777
  ".jpg",
@@ -1038,1324 +821,1672 @@ var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
1038
821
  ".pyo",
1039
822
  ".DS_Store"
1040
823
  ]);
1041
- var ReplayApplicator = class {
1042
- git;
1043
- lockManager;
1044
- outputDir;
1045
- renameCache = /* @__PURE__ */ new Map();
1046
- treeExistsCache = /* @__PURE__ */ new Map();
1047
- fileTheirsAccumulator = /* @__PURE__ */ new Map();
1048
- /**
1049
- * Apply mode for the current `applyPatches` invocation. Set at the start
1050
- * of `applyPatches`, read by `mergeFile` to decide:
1051
- * - whether to skip the intra-loop marker strip (kept in `applyPatches`)
1052
- * - whether to populate the accumulator after a conflicted merge
1053
- * (resolve mode populates so subsequent patches on the same file
1054
- * can use patch A's THEIRS as a structurally-correct merge base
1055
- * when their diff was authored against post-A structure)
1056
- * - whether to retry THEIRS reconstruction against the accumulator
1057
- * when the BASE-relative reconstruction produced markers from a
1058
- * cross-patch context mismatch
1059
- */
1060
- currentApplyMode = "replay";
1061
- /**
1062
- * Set of files that appear in 2+ patches in the current `applyPatches`
1063
- * invocation. Computed at the top of `applyPatches`. Read by `mergeFile`
1064
- * and `populateAccumulatorForPatch` to decide whether to use the patch's
1065
- * `theirs_snapshot` directly as THEIRS (snapshot-as-primary) or fall
1066
- * back to line-anchored reconstruction (FER-9525 cross-patch isolation).
1067
- *
1068
- * Snapshot-as-primary is correct when the patch owns its file (no other
1069
- * patch in this run touches it): the snapshot is what the customer
1070
- * intends for that file post-regen, regardless of generator structural
1071
- * changes that would invalidate the diff's line anchors.
1072
- *
1073
- * When a file IS shared, snapshots are CUMULATIVE across the patches
1074
- * touching it (each one's snapshot includes prior patches' contributions).
1075
- * Using a later patch's cumulative snapshot as its individual THEIRS
1076
- * would propagate prior patches' edits into the merge — surfacing nested
1077
- * conflicts at regions the patch alone never touched. Reconstruction
1078
- * via `applyPatchToContent` is required there.
1079
- */
1080
- sharedFiles = /* @__PURE__ */ new Set();
1081
- constructor(git, lockManager, outputDir) {
1082
- this.git = git;
1083
- this.lockManager = lockManager;
1084
- this.outputDir = outputDir;
1085
- }
1086
- /**
1087
- * Resolve the GenerationRecord for a patch's base_generation.
1088
- * Falls back to constructing an ad-hoc record from the commit's tree
1089
- * when base_generation isn't a tracked generation (commit-scan patches).
1090
- */
1091
- async resolveBaseGeneration(baseGeneration) {
1092
- const gen = this.lockManager.getGeneration(baseGeneration);
1093
- if (gen) return gen;
1094
- try {
1095
- const treeHash = await this.git.getTreeHash(baseGeneration);
1096
- return {
1097
- commit_sha: baseGeneration,
1098
- tree_hash: treeHash,
1099
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1100
- cli_version: "unknown",
1101
- generator_versions: {}
1102
- };
1103
- } catch {
1104
- return void 0;
1105
- }
1106
- }
1107
- /** Reset inter-patch accumulator for a new cycle. */
1108
- resetAccumulator() {
1109
- this.fileTheirsAccumulator.clear();
824
+ function isBinaryFile(filePath) {
825
+ const ext = (0, import_node_path2.extname)(filePath).toLowerCase();
826
+ return BINARY_EXTENSIONS.has(ext);
827
+ }
828
+
829
+ // src/ReplayDetector.ts
830
+ var INFRASTRUCTURE_FILES = /* @__PURE__ */ new Set([".fernignore"]);
831
+ function parsePatchFileHeaders(patchContent) {
832
+ const results = [];
833
+ let currentPath = null;
834
+ let currentIsCreate = false;
835
+ let currentIsDelete = false;
836
+ const flush = () => {
837
+ if (currentPath !== null) {
838
+ results.push({ path: currentPath, isCreate: currentIsCreate, isDelete: currentIsDelete });
839
+ }
840
+ };
841
+ for (const line of patchContent.split("\n")) {
842
+ if (line.startsWith("diff --git ")) {
843
+ flush();
844
+ currentPath = null;
845
+ currentIsCreate = false;
846
+ currentIsDelete = false;
847
+ const match = line.match(/^diff --git a\/(.+?) b\/(.+?)$/);
848
+ if (match) {
849
+ currentPath = match[2] ?? null;
850
+ }
851
+ } else if (currentPath !== null) {
852
+ if (line.startsWith("new file mode ")) {
853
+ currentIsCreate = true;
854
+ } else if (line.startsWith("deleted file mode ")) {
855
+ currentIsDelete = true;
856
+ }
857
+ }
858
+ }
859
+ flush();
860
+ return results;
861
+ }
862
+ async function capturePatchSnapshot(git, files, treeish) {
863
+ const snapshot = {};
864
+ for (const file of files) {
865
+ if (isBinaryFile(file)) continue;
866
+ const content = await git.showFile(treeish, file).catch(() => null);
867
+ if (content != null) snapshot[file] = content;
868
+ }
869
+ return snapshot;
870
+ }
871
+ function filterInfrastructureAndSensitiveFiles(rawFilesOutput) {
872
+ const allFiles = rawFilesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
873
+ const sensitiveFiles = allFiles.filter((f) => isSensitiveFile(f));
874
+ const files = allFiles.filter((f) => !isSensitiveFile(f));
875
+ return { files, sensitiveFiles };
876
+ }
877
+ var ReplayDetector = class {
878
+ git;
879
+ lockManager;
880
+ sdkOutputDir;
881
+ warnings = [];
882
+ constructor(git, lockManager, sdkOutputDir) {
883
+ this.git = git;
884
+ this.lockManager = lockManager;
885
+ this.sdkOutputDir = sdkOutputDir;
886
+ }
887
+ async detectNewPatches() {
888
+ const lock = this.lockManager.read();
889
+ const lastGen = this.getLastGeneration(lock);
890
+ if (!lastGen) {
891
+ return { patches: [], revertedPatchIds: [] };
892
+ }
893
+ const exists = await this.git.commitExists(lastGen.commit_sha);
894
+ if (!exists) {
895
+ this.warnings.push(
896
+ `Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to alternate detection.`
897
+ );
898
+ return this.detectPatchesViaTreeDiff(
899
+ lastGen,
900
+ /* commitKnownMissing */
901
+ true
902
+ );
903
+ }
904
+ const isAncestor = await this.git.isAncestor(lastGen.commit_sha, "HEAD");
905
+ if (!isAncestor) {
906
+ return this.detectPatchesViaMergeBase(lastGen, lock);
907
+ }
908
+ return this.detectPatchesInRange(lastGen.commit_sha, lastGen, lock);
1110
909
  }
1111
910
  /**
1112
- * @internal Test-only.
1113
- * Returns a defensive copy of the inter-patch accumulator. Used by the
1114
- * adversarial test suite (FER-9791) to assert invariant I2: after every
1115
- * applied patch, the accumulator has an entry for each file the patch
1116
- * touched, and the entry's content matches on-disk.
911
+ * Non-linear-history detection via `merge-base(prevGen, HEAD)`.
912
+ *
913
+ * After a squash-merge of a Fern-bot PR, `lastGen.commit_sha` (the previous
914
+ * `[fern-generated]`) is orphaned but still in git's object database. The
915
+ * merge-base is the commit on main from which the bot's branch was created —
916
+ * a reachable ancestor of HEAD. Walking that range and classifying each
917
+ * commit via `isGenerationCommit` mirrors the linear path and eliminates
918
+ * the bug class where pipeline-driven changes (autoversion, replay,
919
+ * generation) get bundled as customer customizations.
920
+ *
921
+ * Falls through to the legacy composite path when no common ancestor exists
922
+ * (truly disjoint history — orphan branches with no shared root).
1117
923
  */
1118
- getAccumulatorSnapshot() {
1119
- const snapshot = /* @__PURE__ */ new Map();
1120
- for (const [path, entry] of this.fileTheirsAccumulator) {
1121
- snapshot.set(path, { content: entry.content, baseGeneration: entry.baseGeneration });
924
+ async detectPatchesViaMergeBase(lastGen, lock) {
925
+ let mergeBase = "";
926
+ try {
927
+ mergeBase = (await this.git.exec(["merge-base", lastGen.commit_sha, "HEAD"])).trim();
928
+ } catch {
1122
929
  }
1123
- return snapshot;
930
+ if (!mergeBase) {
931
+ this.warnings.push(
932
+ `No common ancestor between previous generation ${lastGen.commit_sha.slice(0, 7)} and HEAD. Falling back to composite tree-diff detection.`
933
+ );
934
+ return this.detectPatchesViaTreeDiff(
935
+ lastGen,
936
+ /* commitKnownMissing */
937
+ false
938
+ );
939
+ }
940
+ return this.detectPatchesInRange(mergeBase, lastGen, lock);
1124
941
  }
1125
942
  /**
1126
- * Apply all patches, returning results for each.
1127
- * Skips patches that match exclude patterns in replay.yml
943
+ * Walk `${rangeStart}..HEAD`, classify each commit, emit one
944
+ * `StoredPatch` per customer commit. Body shared by the linear path
945
+ * (rangeStart = lastGen.commit_sha) and the merge-base path.
1128
946
  *
1129
- * `applyMode` controls the post-conflict marker strategy:
1130
- * - `"replay"` (default): strip conflict markers from disk between
1131
- * iterations whenever a later patch touches the same file. Lets the
1132
- * follow-up patch's `git apply --3way` see clean OURS content,
1133
- * which is what the silent-loss fix (PR #73) relies on. The replay
1134
- * pipeline calls `revertConflictingFiles` after the loop to clean
1135
- * any markers that survive.
1136
- * - `"resolve"`: keep markers on disk. The resolve command needs the
1137
- * customer to see them. Slow-path 3-way merge naturally preserves
1138
- * A's markers and B's clean writes when their regions don't overlap;
1139
- * when they do overlap, the customer gets nested markers and
1140
- * resolves manually. Either way they aren't silently dropped.
947
+ * Patch `base_generation` is always `lastGen.commit_sha` the
948
+ * lockfile-tracked reference the applicator uses to fetch base file
949
+ * content, regardless of where the walk actually started.
1141
950
  */
1142
- async applyPatches(patches, opts) {
1143
- const applyMode = opts?.applyMode ?? "replay";
1144
- this.currentApplyMode = applyMode;
1145
- this.resetAccumulator();
1146
- const fileFreq = /* @__PURE__ */ new Map();
1147
- for (const p of patches) {
1148
- for (const f of p.files) {
1149
- fileFreq.set(f, (fileFreq.get(f) ?? 0) + 1);
1150
- }
951
+ async detectPatchesInRange(rangeStart, lastGen, lock) {
952
+ const log = await this.git.exec([
953
+ "log",
954
+ "--first-parent",
955
+ "--format=%H%x00%an%x00%ae%x00%s",
956
+ `${rangeStart}..HEAD`,
957
+ "--",
958
+ this.sdkOutputDir
959
+ ]);
960
+ if (!log.trim()) {
961
+ return { patches: [], revertedPatchIds: [] };
1151
962
  }
1152
- this.sharedFiles = new Set(
1153
- Array.from(fileFreq.entries()).filter(([, n]) => n > 1).map(([f]) => f)
1154
- );
1155
- const results = [];
1156
- for (let i = 0; i < patches.length; i++) {
1157
- const patch = patches[i];
1158
- if (this.isExcluded(patch)) {
1159
- results.push({
1160
- patch,
1161
- status: "skipped",
1162
- method: "git-am"
1163
- });
963
+ const commits = this.parseGitLog(log);
964
+ const newPatches = [];
965
+ const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
966
+ for (const commit of commits) {
967
+ if (isGenerationCommit(commit)) {
1164
968
  continue;
1165
969
  }
1166
- const result = await this.applyPatchWithFallback(patch);
1167
- results.push(result);
1168
- if (applyMode !== "resolve" && result.status === "conflict" && result.fileResults) {
1169
- const laterFiles = /* @__PURE__ */ new Set();
1170
- for (let j = i + 1; j < patches.length; j++) {
1171
- for (const f of patches[j].files) {
1172
- laterFiles.add(f);
1173
- }
1174
- }
1175
- const resolvedToOriginal = /* @__PURE__ */ new Map();
1176
- if (result.resolvedFiles) {
1177
- for (const [orig, resolved] of Object.entries(result.resolvedFiles)) {
1178
- resolvedToOriginal.set(resolved, orig);
1179
- }
970
+ if (lock.patches.find((p) => p.original_commit === commit.sha)) {
971
+ continue;
972
+ }
973
+ const parents = await this.git.getCommitParents(commit.sha);
974
+ if (parents.length > 1) {
975
+ const compositePatch = await this.createMergeCompositePatch({
976
+ mergeCommit: commit,
977
+ firstParent: parents[0],
978
+ lastGen,
979
+ lock
980
+ });
981
+ if (compositePatch) {
982
+ newPatches.push(compositePatch);
1180
983
  }
1181
- for (const fileResult of result.fileResults) {
1182
- if (fileResult.status !== "conflict") continue;
1183
- const originalPath = resolvedToOriginal.get(fileResult.file) ?? fileResult.file;
1184
- if (laterFiles.has(fileResult.file) || laterFiles.has(originalPath)) {
1185
- const filePath = (0, import_node_path2.join)(this.outputDir, fileResult.file);
984
+ continue;
985
+ }
986
+ let patchContent;
987
+ try {
988
+ patchContent = await this.git.formatPatch(commit.sha);
989
+ } catch {
990
+ this.warnings.push(
991
+ `Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
992
+ );
993
+ continue;
994
+ }
995
+ let contentHash = this.computeContentHash(patchContent);
996
+ if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
997
+ continue;
998
+ }
999
+ const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
1000
+ const { files, sensitiveFiles } = filterInfrastructureAndSensitiveFiles(filesOutput);
1001
+ if (sensitiveFiles.length > 0) {
1002
+ this.warnings.push(
1003
+ `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
1004
+ );
1005
+ if (files.length > 0) {
1006
+ const parentSha = parents[0];
1007
+ if (parentSha) {
1186
1008
  try {
1187
- const content = await (0, import_promises.readFile)(filePath, "utf-8");
1188
- const stripped = stripConflictMarkers(content);
1189
- await (0, import_promises.writeFile)(filePath, stripped);
1009
+ patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
1190
1010
  } catch {
1011
+ continue;
1191
1012
  }
1013
+ if (!patchContent.trim()) continue;
1014
+ contentHash = this.computeContentHash(patchContent);
1192
1015
  }
1193
1016
  }
1194
1017
  }
1195
- }
1196
- return results;
1197
- }
1198
- /**
1199
- * Populate accumulator after git apply succeeds, AND collect per-file
1200
- * results including any "dropped context lines" — lines that were
1201
- * present in BASE and THEIRS (unchanged context) but absent from the
1202
- * final on-disk state because OURS deleted them. This is the fast-path
1203
- * counterpart to ThreeWayMerge.computeDroppedContextLines used by the
1204
- * 3-way slow path; both must surface the same warning.
1205
- */
1206
- async populateAccumulatorForPatch(patch, baseGen, currentTreeHash) {
1207
- const fileResults = [];
1208
- if (!baseGen) return fileResults;
1209
- const tempDir = await (0, import_promises.mkdtemp)((0, import_node_path2.join)((0, import_node_os.tmpdir)(), "replay-acc-"));
1210
- const { GitClient: GitClient2 } = await Promise.resolve().then(() => (init_GitClient(), GitClient_exports));
1211
- const tempGit = new GitClient2(tempDir);
1212
- await tempGit.exec(["init"]);
1213
- await tempGit.exec(["config", "user.email", "replay@fern.com"]);
1214
- await tempGit.exec(["config", "user.name", "Fern Replay"]);
1215
- try {
1216
- for (const filePath of patch.files) {
1217
- if (isBinaryFile(filePath)) continue;
1218
- const resolvedPath = await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
1219
- const base = await this.git.showFile(baseGen.tree_hash, filePath);
1220
- const snapshotForFile = patch.theirs_snapshot?.[resolvedPath] ?? patch.theirs_snapshot?.[filePath];
1221
- const fileIsShared = this.sharedFiles.has(resolvedPath) || this.sharedFiles.has(filePath);
1222
- const theirs = !fileIsShared && snapshotForFile != null ? snapshotForFile : await this.applyPatchToContent(base, patch.patch_content, filePath, tempGit, tempDir);
1223
- const finalOnDisk = await (0, import_promises.readFile)((0, import_node_path2.join)(this.outputDir, resolvedPath), "utf-8").catch(() => null);
1224
- const effectiveTheirs = theirs ?? finalOnDisk;
1225
- if (effectiveTheirs != null) {
1226
- this.fileTheirsAccumulator.set(resolvedPath, {
1227
- content: effectiveTheirs,
1228
- baseGeneration: patch.base_generation
1229
- });
1230
- }
1231
- if (base != null && effectiveTheirs != null && finalOnDisk != null) {
1232
- const dropped = computeDroppedContextLinesForFile(base, effectiveTheirs, finalOnDisk);
1233
- if (dropped.length > 0) {
1234
- fileResults.push({
1235
- file: resolvedPath,
1236
- status: "merged",
1237
- droppedContextLines: dropped
1238
- });
1239
- }
1240
- }
1018
+ if (files.length === 0) {
1019
+ continue;
1241
1020
  }
1242
- } finally {
1243
- await (0, import_promises.rm)(tempDir, { recursive: true }).catch(() => {
1021
+ const theirsSnapshot = await capturePatchSnapshot(this.git, files, commit.sha);
1022
+ newPatches.push({
1023
+ id: `patch-${commit.sha.slice(0, 8)}`,
1024
+ content_hash: contentHash,
1025
+ original_commit: commit.sha,
1026
+ original_message: commit.message,
1027
+ original_author: `${commit.authorName} <${commit.authorEmail}>`,
1028
+ base_generation: lastGen.commit_sha,
1029
+ files,
1030
+ patch_content: patchContent,
1031
+ ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
1032
+ ...this.isCreationOnlyPatch(patchContent) ? { user_owned: true } : {}
1244
1033
  });
1245
1034
  }
1246
- return fileResults;
1247
- }
1248
- async applyPatchWithFallback(patch) {
1249
- const baseGen = await this.resolveBaseGeneration(patch.base_generation);
1250
- const lock = this.lockManager.read();
1251
- const currentGen = lock.generations.find((g) => g.commit_sha === lock.current_generation);
1252
- const currentTreeHash = currentGen?.tree_hash ?? baseGen?.tree_hash ?? "";
1253
- const needsAccumulation = await Promise.all(
1254
- patch.files.map(async (f) => {
1255
- if (!baseGen) return false;
1256
- const resolved = await this.resolveFilePath(f, baseGen.tree_hash, currentTreeHash);
1257
- return this.fileTheirsAccumulator.has(resolved);
1258
- })
1259
- ).then((results) => results.some(Boolean));
1260
- const resolvedFiles = {};
1261
- for (const filePath of patch.files) {
1262
- const resolvedPath = baseGen ? await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash) : filePath;
1263
- if (resolvedPath !== filePath) {
1264
- resolvedFiles[filePath] = resolvedPath;
1265
- }
1266
- }
1267
- const patchIsRenameAware = /^rename from /m.test(patch.patch_content);
1268
- const hasGeneratorRename = Object.keys(resolvedFiles).length > 0 && !patchIsRenameAware;
1269
- if (!needsAccumulation && !hasGeneratorRename) {
1270
- const snapshots = /* @__PURE__ */ new Map();
1271
- for (const filePath of patch.files) {
1272
- const fullPath = (0, import_node_path2.join)(this.outputDir, filePath);
1273
- snapshots.set(filePath, await (0, import_promises.readFile)(fullPath, "utf-8").catch(() => null));
1274
- }
1035
+ const survivingPatches = await this.collapseNetZeroFiles(newPatches);
1036
+ survivingPatches.reverse();
1037
+ const revertedPatchIdSet = /* @__PURE__ */ new Set();
1038
+ const revertIndicesToRemove = /* @__PURE__ */ new Set();
1039
+ for (let i = 0; i < survivingPatches.length; i++) {
1040
+ const patch = survivingPatches[i];
1041
+ if (!isRevertCommit(patch.original_message)) continue;
1042
+ let body = "";
1275
1043
  try {
1276
- await this.git.execWithInput(["apply", "--3way"], patch.patch_content);
1277
- const fastPathFileResults = await this.populateAccumulatorForPatch(patch, baseGen, currentTreeHash);
1278
- return {
1279
- patch,
1280
- status: "applied",
1281
- method: "git-am",
1282
- ...fastPathFileResults.length > 0 && { fileResults: fastPathFileResults },
1283
- ...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
1284
- };
1044
+ body = await this.git.getCommitBody(patch.original_commit);
1285
1045
  } catch {
1286
- for (const [filePath, content] of snapshots) {
1287
- const fullPath = (0, import_node_path2.join)(this.outputDir, filePath);
1288
- if (content != null) {
1289
- await (0, import_promises.writeFile)(fullPath, content);
1290
- } else {
1291
- await (0, import_promises.unlink)(fullPath).catch(() => {
1292
- });
1293
- }
1294
- }
1295
- }
1296
- }
1297
- return this.applyWithThreeWayMerge(patch);
1298
- }
1299
- async applyWithThreeWayMerge(patch) {
1300
- const fileResults = [];
1301
- const resolvedFiles = {};
1302
- const tempDir = await (0, import_promises.mkdtemp)((0, import_node_path2.join)((0, import_node_os.tmpdir)(), "replay-"));
1303
- const { GitClient: GitClient2 } = await Promise.resolve().then(() => (init_GitClient(), GitClient_exports));
1304
- const tempGit = new GitClient2(tempDir);
1305
- await tempGit.exec(["init"]);
1306
- await tempGit.exec(["config", "user.email", "replay@fern.com"]);
1307
- await tempGit.exec(["config", "user.name", "Fern Replay"]);
1308
- try {
1309
- for (const filePath of patch.files) {
1310
- if (isBinaryFile(filePath)) {
1311
- fileResults.push({
1312
- file: filePath,
1313
- status: "skipped",
1314
- reason: "binary-file"
1315
- });
1316
- continue;
1317
- }
1318
- const result = await this.mergeFile(patch, filePath, tempGit, tempDir);
1319
- if (result.file !== filePath) {
1320
- resolvedFiles[filePath] = result.file;
1321
- }
1322
- fileResults.push(result);
1323
- }
1324
- } finally {
1325
- await (0, import_promises.rm)(tempDir, { recursive: true }).catch(() => {
1326
- });
1327
- }
1328
- const conflictFiles = fileResults.filter((r) => r.status === "conflict");
1329
- const hasConflicts = conflictFiles.length > 0;
1330
- const conflictReason = hasConflicts ? conflictFiles.some((f) => f.conflictReason === "base-generation-mismatch") ? "base-generation-mismatch" : conflictFiles[0]?.conflictReason : void 0;
1331
- return {
1332
- patch,
1333
- status: hasConflicts ? "conflict" : "applied",
1334
- method: "3way-merge",
1335
- fileResults,
1336
- conflictReason,
1337
- ...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
1338
- };
1339
- }
1340
- async mergeFile(patch, filePath, tempGit, tempDir) {
1341
- try {
1342
- const baseGen = await this.resolveBaseGeneration(patch.base_generation);
1343
- if (!baseGen) {
1344
- return { file: filePath, status: "skipped", reason: "base-generation-not-found" };
1345
- }
1346
- const lock = this.lockManager.read();
1347
- const currentGen = lock.generations.find((g) => g.commit_sha === lock.current_generation);
1348
- const currentTreeHash = currentGen?.tree_hash ?? baseGen.tree_hash;
1349
- const resolvedPath = await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
1350
- const metadata = {
1351
- patchId: patch.id,
1352
- patchMessage: patch.original_message,
1353
- baseGeneration: patch.base_generation,
1354
- currentGeneration: lock.current_generation
1355
- };
1356
- let base = await this.git.showFile(baseGen.tree_hash, filePath);
1357
- let renameSourcePath;
1358
- if (!base) {
1359
- const renameSource = this.extractRenameSource(patch.patch_content, filePath);
1360
- if (renameSource) {
1361
- base = await this.git.showFile(baseGen.tree_hash, renameSource);
1362
- renameSourcePath = renameSource;
1363
- }
1364
- }
1365
- const oursPath = (0, import_node_path2.join)(this.outputDir, resolvedPath);
1366
- const ours = await (0, import_promises.readFile)(oursPath, "utf-8").catch(() => null);
1367
- let ghostReconstructed = false;
1368
- let theirs = null;
1369
- if (!base && ours && !renameSourcePath) {
1370
- const treeReachable = await this.isTreeReachable(baseGen.tree_hash);
1371
- if (!treeReachable) {
1372
- const fileDiff = this.extractFileDiff(patch.patch_content, filePath);
1373
- if (fileDiff) {
1374
- const { reconstructFromGhostPatch: reconstructFromGhostPatch2 } = await Promise.resolve().then(() => (init_HybridReconstruction(), HybridReconstruction_exports));
1375
- const result = reconstructFromGhostPatch2(fileDiff, ours);
1376
- if (result) {
1377
- base = result.base;
1378
- theirs = result.theirs;
1379
- ghostReconstructed = true;
1380
- }
1381
- }
1382
- }
1383
1046
  }
1384
- const snapshotForFile = patch.theirs_snapshot?.[resolvedPath] ?? patch.theirs_snapshot?.[filePath];
1385
- const fileIsShared = this.sharedFiles.has(resolvedPath) || this.sharedFiles.has(filePath);
1386
- const useSnapshotAsPrimary = !fileIsShared && snapshotForFile != null;
1387
- if (!ghostReconstructed) {
1388
- if (useSnapshotAsPrimary) {
1389
- theirs = snapshotForFile;
1390
- } else {
1391
- theirs = await this.applyPatchToContent(
1392
- base,
1393
- patch.patch_content,
1394
- filePath,
1395
- tempGit,
1396
- tempDir,
1397
- renameSourcePath
1398
- );
1399
- const reconstructionHasMarkers = theirs != null && (theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization"));
1400
- const baseHadMarkers = base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization"));
1401
- if (reconstructionHasMarkers && !baseHadMarkers && snapshotForFile != null) {
1402
- theirs = snapshotForFile;
1403
- }
1047
+ const revertedSha = parseRevertedSha(body);
1048
+ const revertedMessage = parseRevertedMessage(patch.original_message);
1049
+ let matchedExisting = false;
1050
+ if (revertedSha) {
1051
+ const existing = lock.patches.find((p) => p.original_commit === revertedSha);
1052
+ if (existing) {
1053
+ revertedPatchIdSet.add(existing.id);
1054
+ revertIndicesToRemove.add(i);
1055
+ matchedExisting = true;
1404
1056
  }
1405
1057
  }
1406
- const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
1407
- if (theirs) {
1408
- const theirsHasMarkers = theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization");
1409
- const baseHasMarkers = base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization"));
1410
- if (theirsHasMarkers && !baseHasMarkers) {
1411
- if (accumulatorEntry) {
1412
- theirs = null;
1413
- } else {
1414
- return {
1415
- file: resolvedPath,
1416
- status: "skipped",
1417
- reason: "stale-conflict-markers"
1418
- };
1419
- }
1058
+ if (!matchedExisting && revertedMessage) {
1059
+ const existing = lock.patches.find((p) => p.original_message === revertedMessage);
1060
+ if (existing) {
1061
+ revertedPatchIdSet.add(existing.id);
1062
+ revertIndicesToRemove.add(i);
1063
+ matchedExisting = true;
1420
1064
  }
1421
1065
  }
1422
- let useAccumulatorAsMergeBase = false;
1423
- if (accumulatorEntry && (theirs == null || base == null)) {
1424
- theirs = await this.applyPatchToContent(
1425
- accumulatorEntry.content,
1426
- patch.patch_content,
1427
- filePath,
1428
- tempGit,
1429
- tempDir
1066
+ if (matchedExisting) continue;
1067
+ let matchedNew = false;
1068
+ if (revertedSha) {
1069
+ const idx = survivingPatches.findIndex(
1070
+ (p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_commit === revertedSha
1430
1071
  );
1431
- if (theirs != null) {
1432
- useAccumulatorAsMergeBase = true;
1433
- } else if (await this.isPatchAlreadyApplied(
1434
- patch.patch_content,
1435
- filePath,
1436
- accumulatorEntry.content,
1437
- tempGit,
1438
- tempDir
1439
- )) {
1440
- theirs = accumulatorEntry.content;
1441
- useAccumulatorAsMergeBase = true;
1442
- }
1443
- }
1444
- if (theirs) {
1445
- const theirsHasMarkers = theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization");
1446
- const accBaseHasMarkers = accumulatorEntry != null && (accumulatorEntry.content.includes("<<<<<<< Generated") || accumulatorEntry.content.includes(">>>>>>> Your customization"));
1447
- if (theirsHasMarkers && !accBaseHasMarkers && !(base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization")))) {
1448
- return {
1449
- file: resolvedPath,
1450
- status: "skipped",
1451
- reason: "stale-conflict-markers"
1452
- };
1453
- }
1454
- }
1455
- let effective_theirs = theirs;
1456
- let baseMismatchSkipped = false;
1457
- if (theirs != null && base != null && !useAccumulatorAsMergeBase) {
1458
- if (accumulatorEntry && accumulatorEntry.baseGeneration === patch.base_generation) {
1459
- try {
1460
- const preMerged = threeWayMerge(base, accumulatorEntry.content, theirs);
1461
- if (!preMerged.hasConflicts) {
1462
- effective_theirs = preMerged.content;
1463
- } else {
1464
- effective_theirs = theirs;
1465
- }
1466
- } catch {
1467
- effective_theirs = theirs;
1468
- }
1469
- } else if (accumulatorEntry) {
1470
- baseMismatchSkipped = true;
1072
+ if (idx !== -1) {
1073
+ revertIndicesToRemove.add(i);
1074
+ revertIndicesToRemove.add(idx);
1075
+ matchedNew = true;
1471
1076
  }
1472
1077
  }
1473
- if (base == null && ours == null && effective_theirs != null) {
1474
- this.fileTheirsAccumulator.set(resolvedPath, {
1475
- content: effective_theirs,
1476
- baseGeneration: patch.base_generation
1477
- });
1478
- const outDir2 = (0, import_node_path2.dirname)(oursPath);
1479
- await (0, import_promises.mkdir)(outDir2, { recursive: true });
1480
- await (0, import_promises.writeFile)(oursPath, effective_theirs);
1481
- return { file: resolvedPath, status: "merged", reason: "new-file" };
1482
- }
1483
- if (base == null && ours != null && effective_theirs != null && !useAccumulatorAsMergeBase) {
1484
- const merged2 = threeWayMerge("", ours, effective_theirs);
1485
- const outDir2 = (0, import_node_path2.dirname)(oursPath);
1486
- await (0, import_promises.mkdir)(outDir2, { recursive: true });
1487
- await (0, import_promises.writeFile)(oursPath, merged2.content);
1488
- if (merged2.hasConflicts) {
1489
- return {
1490
- file: resolvedPath,
1491
- status: "conflict",
1492
- conflicts: merged2.conflicts,
1493
- conflictReason: "new-file-both",
1494
- conflictMetadata: metadata
1495
- };
1078
+ if (!matchedNew && revertedMessage) {
1079
+ const idx = survivingPatches.findIndex(
1080
+ (p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_message === revertedMessage
1081
+ );
1082
+ if (idx !== -1) {
1083
+ revertIndicesToRemove.add(i);
1084
+ revertIndicesToRemove.add(idx);
1496
1085
  }
1497
- this.fileTheirsAccumulator.set(resolvedPath, {
1498
- content: merged2.content,
1499
- baseGeneration: patch.base_generation
1500
- });
1501
- return { file: resolvedPath, status: "merged" };
1502
- }
1503
- if (effective_theirs == null) {
1504
- return {
1505
- file: resolvedPath,
1506
- status: "skipped",
1507
- reason: "missing-content"
1508
- };
1509
- }
1510
- if (base == null && !useAccumulatorAsMergeBase || ours == null) {
1511
- return {
1512
- file: resolvedPath,
1513
- status: "skipped",
1514
- reason: "missing-content"
1515
- };
1516
- }
1517
- const mergeBase = useAccumulatorAsMergeBase && accumulatorEntry ? accumulatorEntry.content : base;
1518
- if (mergeBase == null) {
1519
- return {
1520
- file: resolvedPath,
1521
- status: "skipped",
1522
- reason: "missing-content"
1523
- };
1524
- }
1525
- const merged = threeWayMerge(mergeBase, ours, effective_theirs);
1526
- const outDir = (0, import_node_path2.dirname)(oursPath);
1527
- await (0, import_promises.mkdir)(outDir, { recursive: true });
1528
- await (0, import_promises.writeFile)(oursPath, merged.content);
1529
- const populateAccumulator = effective_theirs != null && (!merged.hasConflicts || this.currentApplyMode === "resolve");
1530
- if (populateAccumulator) {
1531
- this.fileTheirsAccumulator.set(resolvedPath, {
1532
- content: effective_theirs,
1533
- baseGeneration: patch.base_generation
1534
- });
1535
1086
  }
1536
- if (merged.hasConflicts) {
1537
- return {
1538
- file: resolvedPath,
1539
- status: "conflict",
1540
- conflicts: merged.conflicts,
1541
- conflictReason: baseMismatchSkipped ? "base-generation-mismatch" : "same-line-edit",
1542
- conflictMetadata: metadata
1543
- };
1087
+ if (!matchedExisting && !matchedNew) {
1088
+ revertIndicesToRemove.add(i);
1544
1089
  }
1545
- return {
1546
- file: resolvedPath,
1547
- status: "merged",
1548
- ...merged.droppedContextLines && merged.droppedContextLines.length > 0 ? { droppedContextLines: merged.droppedContextLines } : {}
1549
- };
1550
- } catch (error) {
1551
- return {
1552
- file: filePath,
1553
- status: "skipped",
1554
- reason: `error: ${error instanceof Error ? error.message : String(error)}`
1555
- };
1556
- }
1557
- }
1558
- async isTreeReachable(treeHash) {
1559
- let result = this.treeExistsCache.get(treeHash);
1560
- if (result === void 0) {
1561
- result = await this.git.treeExists(treeHash);
1562
- this.treeExistsCache.set(treeHash, result);
1563
1090
  }
1564
- return result;
1091
+ const filteredPatches = survivingPatches.filter((_, i) => !revertIndicesToRemove.has(i));
1092
+ return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
1565
1093
  }
1566
- isExcluded(patch) {
1567
- const config = this.lockManager.getCustomizationsConfig();
1568
- if (!config.exclude) return false;
1569
- return patch.files.some((file) => config.exclude.some((pattern) => (0, import_minimatch.minimatch)(file, pattern)));
1094
+ /**
1095
+ * Compute content hash for deduplication.
1096
+ *
1097
+ * Produces a format-agnostic hash so both `git format-patch` output
1098
+ * (email-wrapped) and plain `git diff` output hash to the same value
1099
+ * when the underlying diff hunks are identical.
1100
+ *
1101
+ * Normalization:
1102
+ * 1. Strip email wrapper: everything before the first `diff --git` line
1103
+ * (From:, Subject:, Date:, diffstat, blank separators).
1104
+ * 2. Strip `index` lines (blob SHA pairs change across rebases).
1105
+ * 3. Strip trailing git version marker (`-- \n<version>`).
1106
+ *
1107
+ * This ensures content hashes match across the no-patches and normal-
1108
+ * regeneration flows, which store formatPatch and git-diff formats
1109
+ * respectively.
1110
+ */
1111
+ computeContentHash(patchContent) {
1112
+ const lines = patchContent.split("\n");
1113
+ const diffStart = lines.findIndex((l) => l.startsWith("diff --git "));
1114
+ const relevantLines = diffStart > 0 ? lines.slice(diffStart) : lines;
1115
+ const normalized = relevantLines.filter((line) => !line.startsWith("index ")).join("\n").replace(/\n-- \n[\d]+\.[\d]+[^\n]*\s*$/, "");
1116
+ return `sha256:${(0, import_node_crypto.createHash)("sha256").update(normalized).digest("hex")}`;
1570
1117
  }
1571
- async resolveFilePath(filePath, baseTreeHash, currentTreeHash) {
1572
- const config = this.lockManager.getCustomizationsConfig();
1573
- if (config.moves) {
1574
- for (const move of config.moves) {
1575
- if ((0, import_minimatch.minimatch)(filePath, move.from) || filePath === move.from) {
1576
- if (filePath === move.from) {
1577
- return move.to;
1578
- }
1579
- const fromBase = move.from.replace(/\*\*.*$/, "");
1580
- const toBase = move.to.replace(/\*\*.*$/, "");
1581
- if (filePath.startsWith(fromBase)) {
1582
- return toBase + filePath.slice(fromBase.length);
1583
- }
1584
- }
1585
- }
1586
- }
1587
- const cacheKey = `${baseTreeHash}:${currentTreeHash}`;
1588
- let renames = this.renameCache.get(cacheKey);
1589
- if (!renames) {
1590
- renames = await this.git.detectRenames(baseTreeHash, currentTreeHash);
1591
- this.renameCache.set(cacheKey, renames);
1592
- }
1593
- const gitRename = renames.find((r) => r.from === filePath);
1594
- if (gitRename) {
1595
- return gitRename.to;
1596
- }
1597
- return filePath;
1598
- }
1599
- async applyPatchToContent(base, patchContent, filePath, tempGit, tempDir, sourceFilePath) {
1600
- if (!base) {
1601
- return this.extractNewFileFromPatch(patchContent, filePath);
1118
+ /**
1119
+ * Drop patches whose files were transiently created and then deleted
1120
+ * within the current linear detection window, and are absent from
1121
+ * HEAD at the end of the window.
1122
+ *
1123
+ * Rationale: on a merge commit, createMergeCompositePatch already diffs
1124
+ * firstParent..mergeCommit so net-zero files never enter the composite. On
1125
+ * linear history each commit becomes its own patch, so a file that was
1126
+ * briefly added and later removed survives as a create+delete pair whose
1127
+ * cumulative diff is empty. We drop such patches before they reach the
1128
+ * lockfile.
1129
+ *
1130
+ * A file is "transient" when:
1131
+ * 1. some patch in the window sets `new file mode` for it (a creation), AND
1132
+ * 2. some patch in the window sets `deleted file mode` for it (a deletion), AND
1133
+ * 3. it is absent from HEAD's tree.
1134
+ *
1135
+ * The HEAD guard (#3) prevents false positives when a file is deleted
1136
+ * and then recreated with different content — the cumulative diff of
1137
+ * such a pair is non-empty and must be preserved.
1138
+ *
1139
+ * This only drops patches whose `files` are a subset of the transient
1140
+ * set. A partial-transient patch (one that touches a transient file
1141
+ * alongside a persistent one) is left unmodified; surgical stripping of
1142
+ * single files from a multi-file patch would require a follow-up that
1143
+ * regenerates the patch content via `git format-patch -- <files>`. No
1144
+ * current failing test exercises this, and leaving the patch intact
1145
+ * preserves today's behaviour. Revisit if a future adversarial test
1146
+ * demands per-file stripping.
1147
+ */
1148
+ async collapseNetZeroFiles(patches) {
1149
+ if (patches.length === 0) return patches;
1150
+ const stateByFile = /* @__PURE__ */ new Map();
1151
+ for (const patch of patches) {
1152
+ for (const header of parsePatchFileHeaders(patch.patch_content)) {
1153
+ if (!header.isCreate && !header.isDelete) continue;
1154
+ const prior = stateByFile.get(header.path) ?? { created: false, deleted: false };
1155
+ if (header.isCreate) prior.created = true;
1156
+ if (header.isDelete) prior.deleted = true;
1157
+ stateByFile.set(header.path, prior);
1158
+ }
1602
1159
  }
1603
- const fileDiff = this.extractFileDiff(patchContent, filePath);
1604
- if (!fileDiff) return null;
1605
- try {
1606
- if (sourceFilePath) {
1607
- const tempSourcePath = (0, import_node_path2.join)(tempDir, sourceFilePath);
1608
- await (0, import_promises.mkdir)((0, import_node_path2.dirname)(tempSourcePath), { recursive: true });
1609
- await (0, import_promises.writeFile)(tempSourcePath, base);
1610
- await tempGit.exec(["add", sourceFilePath]);
1611
- await tempGit.exec([
1612
- "commit",
1613
- "-m",
1614
- `base for rename ${sourceFilePath} -> ${filePath}`,
1615
- "--allow-empty"
1616
- ]);
1617
- await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
1618
- const tempTargetPath = (0, import_node_path2.join)(tempDir, filePath);
1619
- return await (0, import_promises.readFile)(tempTargetPath, "utf-8");
1160
+ let anyCandidate = false;
1161
+ for (const state of stateByFile.values()) {
1162
+ if (state.created && state.deleted) {
1163
+ anyCandidate = true;
1164
+ break;
1620
1165
  }
1621
- const tempFilePath = (0, import_node_path2.join)(tempDir, filePath);
1622
- await (0, import_promises.mkdir)((0, import_node_path2.dirname)(tempFilePath), { recursive: true });
1623
- await (0, import_promises.writeFile)(tempFilePath, base);
1624
- await tempGit.exec(["add", filePath]);
1625
- await tempGit.exec(["commit", "-m", `base for ${filePath}`, "--allow-empty"]);
1626
- await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
1627
- return await (0, import_promises.readFile)(tempFilePath, "utf-8");
1628
- } catch {
1629
- return null;
1630
1166
  }
1167
+ if (!anyCandidate) return patches;
1168
+ const headFiles = await this.listHeadFiles();
1169
+ const transient = /* @__PURE__ */ new Set();
1170
+ for (const [file, state] of stateByFile) {
1171
+ if (state.created && state.deleted && !headFiles.has(file)) {
1172
+ transient.add(file);
1173
+ }
1174
+ }
1175
+ if (transient.size === 0) return patches;
1176
+ return patches.filter((patch) => !patch.files.every((f) => transient.has(f)));
1631
1177
  }
1632
1178
  /**
1633
- * Detects whether a patch's additions are already present in `content`.
1634
- * Uses `git apply --reverse --check` if the reverse patch applies cleanly,
1635
- * the forward patch is effectively a no-op (its +lines are already there and
1636
- * its -lines are already gone). Used to distinguish "patch already applied"
1637
- * from "patch base mismatch" after a forward-apply fails.
1179
+ * List every tracked path at HEAD (one `git ls-tree -r` invocation).
1180
+ * Returns an empty Set if HEAD is unborn or the invocation fails the
1181
+ * caller then treats every candidate as "not in HEAD" and may collapse
1182
+ * more aggressively. On typical SDK repos this is a single sub-10ms call.
1638
1183
  */
1639
- async isPatchAlreadyApplied(patchContent, filePath, content, tempGit, tempDir) {
1640
- const fileDiff = this.extractFileDiff(patchContent, filePath);
1641
- if (!fileDiff) return false;
1184
+ async listHeadFiles() {
1642
1185
  try {
1643
- const tempFilePath = (0, import_node_path2.join)(tempDir, filePath);
1644
- await (0, import_promises.mkdir)((0, import_node_path2.dirname)(tempFilePath), { recursive: true });
1645
- await (0, import_promises.writeFile)(tempFilePath, content);
1646
- await tempGit.exec(["add", filePath]);
1647
- await tempGit.exec(["commit", "-m", `check already-applied ${filePath}`, "--allow-empty"]);
1648
- await tempGit.execWithInput(["apply", "--reverse", "--check", "--allow-empty"], fileDiff);
1649
- return true;
1186
+ const out = await this.git.exec(["ls-tree", "-r", "--name-only", "HEAD"]);
1187
+ return new Set(out.split("\n").map((l) => l.trim()).filter(Boolean));
1650
1188
  } catch {
1651
- return false;
1189
+ return /* @__PURE__ */ new Set();
1652
1190
  }
1653
1191
  }
1654
- extractFileDiff(patchContent, filePath) {
1655
- const lines = patchContent.split("\n");
1656
- const diffLines = [];
1657
- let inTargetFile = false;
1658
- for (const line of lines) {
1659
- if (line.startsWith("diff --git")) {
1660
- if (inTargetFile) {
1661
- break;
1662
- }
1663
- if (isDiffLineForFile(line, filePath)) {
1664
- inTargetFile = true;
1665
- diffLines.push(line);
1666
- }
1667
- continue;
1668
- }
1669
- if (inTargetFile) {
1670
- diffLines.push(line);
1671
- }
1192
+ /**
1193
+ * Create a single composite patch for a merge commit by diffing the merge
1194
+ * commit against its first parent. This captures the net change of the
1195
+ * entire merged branch as one patch, eliminating accumulator chaining and
1196
+ * zero-net-change ghost conflicts.
1197
+ */
1198
+ async createMergeCompositePatch(input) {
1199
+ const { mergeCommit, firstParent, lastGen, lock } = input;
1200
+ const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
1201
+ const filesOutput = await this.git.exec([
1202
+ "diff",
1203
+ "--name-only",
1204
+ firstParent,
1205
+ mergeCommit.sha
1206
+ ]);
1207
+ const { files, sensitiveFiles } = filterInfrastructureAndSensitiveFiles(filesOutput);
1208
+ if (sensitiveFiles.length > 0) {
1209
+ this.warnings.push(
1210
+ `Sensitive file(s) excluded from merge patch for commit ${mergeCommit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
1211
+ );
1672
1212
  }
1673
- return diffLines.length > 0 ? diffLines.join("\n") + "\n" : null;
1213
+ if (files.length === 0) return null;
1214
+ const diff = await this.git.exec([
1215
+ "diff",
1216
+ firstParent,
1217
+ mergeCommit.sha,
1218
+ "--",
1219
+ ...files
1220
+ ]);
1221
+ if (!diff.trim()) return null;
1222
+ const contentHash = this.computeContentHash(diff);
1223
+ if (lock.patches.some((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
1224
+ return null;
1225
+ }
1226
+ const theirsSnapshot = await capturePatchSnapshot(this.git, files, mergeCommit.sha);
1227
+ return {
1228
+ id: `patch-merge-${mergeCommit.sha.slice(0, 8)}`,
1229
+ content_hash: contentHash,
1230
+ original_commit: mergeCommit.sha,
1231
+ original_message: mergeCommit.message,
1232
+ original_author: `${mergeCommit.authorName} <${mergeCommit.authorEmail}>`,
1233
+ base_generation: lastGen.commit_sha,
1234
+ files,
1235
+ patch_content: diff,
1236
+ ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
1237
+ ...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
1238
+ };
1674
1239
  }
1675
- extractRenameSource(patchContent, targetFilePath) {
1676
- const lines = patchContent.split("\n");
1677
- let inTargetFile = false;
1678
- for (const line of lines) {
1679
- if (line.startsWith("diff --git")) {
1680
- if (inTargetFile) break;
1681
- inTargetFile = isDiffLineForFile(line, targetFilePath);
1682
- continue;
1683
- }
1684
- if (!inTargetFile) continue;
1685
- if (line.startsWith("@@")) break;
1686
- if (line.startsWith("rename from ")) {
1687
- return line.slice("rename from ".length);
1240
+ /**
1241
+ * Detect patches via tree diff for non-linear history. Returns a composite patch.
1242
+ * Revert reconciliation is skipped here because tree-diff produces a single composite
1243
+ * patch from the aggregate diff — individual revert commits are not distinguishable.
1244
+ */
1245
+ async detectPatchesViaTreeDiff(lastGen, commitKnownMissing) {
1246
+ const diffBase = await this.resolveDiffBase(lastGen, commitKnownMissing);
1247
+ if (!diffBase) {
1248
+ return this.detectPatchesViaCommitScan();
1249
+ }
1250
+ const lockForGuard = this.lockManager.read();
1251
+ const knownGenerations = new Set(lockForGuard.generations.map((g) => g.commit_sha));
1252
+ let resolvedBaseGeneration = commitKnownMissing ? diffBase : lastGen.commit_sha;
1253
+ if (commitKnownMissing && !knownGenerations.has(diffBase)) {
1254
+ const fallback = lockForGuard.current_generation ?? lockForGuard.generations[lockForGuard.generations.length - 1]?.commit_sha;
1255
+ if (!fallback) {
1256
+ this.warnings.push(
1257
+ `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.`
1258
+ );
1259
+ return { patches: [], revertedPatchIds: [] };
1688
1260
  }
1261
+ resolvedBaseGeneration = fallback;
1689
1262
  }
1690
- return null;
1263
+ const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
1264
+ const { files, sensitiveFiles } = filterInfrastructureAndSensitiveFiles(filesOutput);
1265
+ if (sensitiveFiles.length > 0) {
1266
+ this.warnings.push(
1267
+ `Sensitive file(s) excluded from composite patch: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
1268
+ );
1269
+ }
1270
+ if (files.length === 0) return { patches: [], revertedPatchIds: [] };
1271
+ const diff = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
1272
+ if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
1273
+ const contentHash = this.computeContentHash(diff);
1274
+ const lock = this.lockManager.read();
1275
+ if (lock.patches.some((p) => p.content_hash === contentHash) || (lock.forgotten_hashes ?? []).includes(contentHash)) {
1276
+ return { patches: [], revertedPatchIds: [] };
1277
+ }
1278
+ const headSha = (await this.git.exec(["rev-parse", "HEAD"])).trim();
1279
+ const theirsSnapshot = await capturePatchSnapshot(this.git, files, "HEAD");
1280
+ const compositePatch = {
1281
+ id: `patch-composite-${headSha.slice(0, 8)}`,
1282
+ content_hash: contentHash,
1283
+ original_commit: headSha,
1284
+ original_message: "Customer customizations (composite)",
1285
+ original_author: "composite",
1286
+ // Anchor `base_generation` on a SHA the applicator's
1287
+ // `resolveBaseGeneration` can find — always a member of
1288
+ // `lock.generations`. When `diffBase` is a recorded
1289
+ // generation, use it directly; otherwise the guard above
1290
+ // has already mapped it onto `current_generation`.
1291
+ base_generation: resolvedBaseGeneration,
1292
+ files,
1293
+ patch_content: diff,
1294
+ ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
1295
+ ...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
1296
+ };
1297
+ return { patches: [compositePatch], revertedPatchIds: [] };
1691
1298
  }
1692
- extractNewFileFromPatch(patchContent, filePath) {
1693
- const lines = patchContent.split("\n");
1694
- const addedLines = [];
1695
- let inTargetFile = false;
1696
- let inHunk = false;
1697
- let isNewFile = false;
1698
- let noTrailingNewline = false;
1699
- for (const line of lines) {
1700
- if (line.startsWith("diff --git")) {
1701
- if (inTargetFile) break;
1702
- inTargetFile = isDiffLineForFile(line, filePath);
1703
- inHunk = false;
1704
- isNewFile = false;
1299
+ /**
1300
+ * Last-resort detection when both generation commit and tree are unreachable.
1301
+ * Scans all commits from HEAD, filters against known lockfile patches, and
1302
+ * skips creation-only commits (squashed history after force push).
1303
+ *
1304
+ * Detected patches use the commit's parent as base_generation so the applicator
1305
+ * can find base file content from the parent's tree (which IS reachable).
1306
+ */
1307
+ async detectPatchesViaCommitScan() {
1308
+ const lock = this.lockManager.read();
1309
+ const knownGenerations = new Set(lock.generations.map((g) => g.commit_sha));
1310
+ const fallbackAnchor = lock.current_generation ?? lock.generations[lock.generations.length - 1]?.commit_sha;
1311
+ const log = await this.git.exec([
1312
+ "log",
1313
+ "--max-count=200",
1314
+ "--format=%H%x00%an%x00%ae%x00%s",
1315
+ "HEAD",
1316
+ "--",
1317
+ this.sdkOutputDir
1318
+ ]);
1319
+ if (!log.trim()) {
1320
+ return { patches: [], revertedPatchIds: [] };
1321
+ }
1322
+ const commits = this.parseGitLog(log);
1323
+ const newPatches = [];
1324
+ const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
1325
+ const existingHashes = new Set(lock.patches.map((p) => p.content_hash));
1326
+ const existingCommits = new Set(lock.patches.map((p) => p.original_commit));
1327
+ for (const commit of commits) {
1328
+ if (isGenerationCommit(commit)) {
1705
1329
  continue;
1706
1330
  }
1707
- if (!inTargetFile) continue;
1708
- if (!inHunk) {
1709
- if (line === "--- /dev/null" || line.startsWith("new file mode")) {
1710
- isNewFile = true;
1711
- }
1331
+ const parents = await this.git.getCommitParents(commit.sha);
1332
+ if (parents.length > 1) {
1333
+ continue;
1712
1334
  }
1713
- if (line.startsWith("@@")) {
1714
- if (!isNewFile) return null;
1715
- inHunk = true;
1716
- continue;
1717
- }
1718
- if (!inHunk) continue;
1719
- if (line === "\") {
1720
- noTrailingNewline = true;
1721
- continue;
1722
- }
1723
- if (line.startsWith("+") && !line.startsWith("+++")) {
1724
- addedLines.push(line.slice(1));
1725
- }
1726
- }
1727
- if (addedLines.length === 0) return null;
1728
- return addedLines.join("\n") + (noTrailingNewline ? "" : "\n");
1729
- }
1730
- };
1731
- function isBinaryFile(filePath) {
1732
- const ext = (0, import_node_path2.extname)(filePath).toLowerCase();
1733
- return BINARY_EXTENSIONS.has(ext);
1734
- }
1735
- function computeDroppedContextLinesForFile(base, theirs, finalContent) {
1736
- const baseLines = base.split("\n");
1737
- const theirsLines = theirs.split("\n");
1738
- const finalLines = finalContent.split("\n");
1739
- const baseCounts = /* @__PURE__ */ new Map();
1740
- const theirsCounts = /* @__PURE__ */ new Map();
1741
- const finalCounts = /* @__PURE__ */ new Map();
1742
- for (const l of baseLines) baseCounts.set(l, (baseCounts.get(l) ?? 0) + 1);
1743
- for (const l of theirsLines) theirsCounts.set(l, (theirsCounts.get(l) ?? 0) + 1);
1744
- for (const l of finalLines) finalCounts.set(l, (finalCounts.get(l) ?? 0) + 1);
1745
- const dropped = [];
1746
- const seen = /* @__PURE__ */ new Set();
1747
- for (const line of baseLines) {
1748
- if (seen.has(line)) continue;
1749
- const inBase = baseCounts.get(line) ?? 0;
1750
- const inTheirs = theirsCounts.get(line) ?? 0;
1751
- const inFinal = finalCounts.get(line) ?? 0;
1752
- if (inBase > 0 && inTheirs > 0 && inFinal < Math.min(inBase, inTheirs)) {
1753
- dropped.push(line);
1754
- seen.add(line);
1755
- }
1756
- }
1757
- return dropped;
1758
- }
1759
- function isDiffLineForFile(diffLine, filePath) {
1760
- const match = diffLine.match(/^diff --git a\/.+ b\/(.+)$/);
1761
- return match !== null && match[1] === filePath;
1762
- }
1763
-
1764
- // src/ReplayDetector.ts
1765
- var INFRASTRUCTURE_FILES = /* @__PURE__ */ new Set([".fernignore"]);
1766
- function parsePatchFileHeaders(patchContent) {
1767
- const results = [];
1768
- let currentPath = null;
1769
- let currentIsCreate = false;
1770
- let currentIsDelete = false;
1771
- const flush = () => {
1772
- if (currentPath !== null) {
1773
- results.push({ path: currentPath, isCreate: currentIsCreate, isDelete: currentIsDelete });
1774
- }
1775
- };
1776
- for (const line of patchContent.split("\n")) {
1777
- if (line.startsWith("diff --git ")) {
1778
- flush();
1779
- currentPath = null;
1780
- currentIsCreate = false;
1781
- currentIsDelete = false;
1782
- const match = line.match(/^diff --git a\/(.+?) b\/(.+?)$/);
1783
- if (match) {
1784
- currentPath = match[2] ?? null;
1785
- }
1786
- } else if (currentPath !== null) {
1787
- if (line.startsWith("new file mode ")) {
1788
- currentIsCreate = true;
1789
- } else if (line.startsWith("deleted file mode ")) {
1790
- currentIsDelete = true;
1791
- }
1792
- }
1793
- }
1794
- flush();
1795
- return results;
1796
- }
1797
- var ReplayDetector = class {
1798
- git;
1799
- lockManager;
1800
- sdkOutputDir;
1801
- warnings = [];
1802
- constructor(git, lockManager, sdkOutputDir) {
1803
- this.git = git;
1804
- this.lockManager = lockManager;
1805
- this.sdkOutputDir = sdkOutputDir;
1806
- }
1807
- async detectNewPatches() {
1808
- const lock = this.lockManager.read();
1809
- const lastGen = this.getLastGeneration(lock);
1810
- if (!lastGen) {
1811
- return { patches: [], revertedPatchIds: [] };
1812
- }
1813
- const exists = await this.git.commitExists(lastGen.commit_sha);
1814
- if (!exists) {
1815
- this.warnings.push(
1816
- `Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to alternate detection.`
1817
- );
1818
- return this.detectPatchesViaTreeDiff(
1819
- lastGen,
1820
- /* commitKnownMissing */
1821
- true
1822
- );
1823
- }
1824
- const isAncestor = await this.git.isAncestor(lastGen.commit_sha, "HEAD");
1825
- if (!isAncestor) {
1826
- return this.detectPatchesViaMergeBase(lastGen, lock);
1827
- }
1828
- return this.detectPatchesInRange(lastGen.commit_sha, lastGen, lock);
1829
- }
1830
- /**
1831
- * FER-10201 — Non-linear-history detection via `merge-base(prevGen, HEAD)`.
1832
- *
1833
- * After a squash-merge of a Fern-bot PR, `lastGen.commit_sha` (the previous
1834
- * `[fern-generated]`) is orphaned but still in git's object database. The
1835
- * merge-base is the commit on main from which the bot's branch was created —
1836
- * a reachable ancestor of HEAD. Walking that range and classifying each
1837
- * commit via `isGenerationCommit` mirrors the linear path and eliminates
1838
- * the bug class where pipeline-driven changes (autoversion, replay,
1839
- * generation) get bundled as customer customizations.
1840
- *
1841
- * Falls through to the legacy composite path when no common ancestor exists
1842
- * (truly disjoint history — orphan branches with no shared root).
1843
- */
1844
- async detectPatchesViaMergeBase(lastGen, lock) {
1845
- let mergeBase = "";
1846
- try {
1847
- mergeBase = (await this.git.exec(["merge-base", lastGen.commit_sha, "HEAD"])).trim();
1848
- } catch {
1849
- }
1850
- if (!mergeBase) {
1851
- this.warnings.push(
1852
- `No common ancestor between previous generation ${lastGen.commit_sha.slice(0, 7)} and HEAD. Falling back to composite tree-diff detection.`
1853
- );
1854
- return this.detectPatchesViaTreeDiff(
1855
- lastGen,
1856
- /* commitKnownMissing */
1857
- false
1858
- );
1859
- }
1860
- return this.detectPatchesInRange(mergeBase, lastGen, lock);
1861
- }
1862
- /**
1863
- * FER-10201 — Walk `${rangeStart}..HEAD`, classify each commit, emit one
1864
- * `StoredPatch` per customer commit. Body shared by the linear path
1865
- * (rangeStart = lastGen.commit_sha) and the merge-base path.
1866
- *
1867
- * Patch `base_generation` is always `lastGen.commit_sha` — the
1868
- * lockfile-tracked reference the applicator uses to fetch base file
1869
- * content, regardless of where the walk actually started.
1870
- */
1871
- async detectPatchesInRange(rangeStart, lastGen, lock) {
1872
- const log = await this.git.exec([
1873
- "log",
1874
- "--first-parent",
1875
- "--format=%H%x00%an%x00%ae%x00%s",
1876
- `${rangeStart}..HEAD`,
1877
- "--",
1878
- this.sdkOutputDir
1879
- ]);
1880
- if (!log.trim()) {
1881
- return { patches: [], revertedPatchIds: [] };
1882
- }
1883
- const commits = this.parseGitLog(log);
1884
- const newPatches = [];
1885
- const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
1886
- for (const commit of commits) {
1887
- if (isGenerationCommit(commit)) {
1888
- continue;
1889
- }
1890
- if (lock.patches.find((p) => p.original_commit === commit.sha)) {
1891
- continue;
1892
- }
1893
- const parents = await this.git.getCommitParents(commit.sha);
1894
- if (parents.length > 1) {
1895
- const compositePatch = await this.createMergeCompositePatch({
1896
- mergeCommit: commit,
1897
- firstParent: parents[0],
1898
- lastGen,
1899
- lock
1900
- });
1901
- if (compositePatch) {
1902
- newPatches.push(compositePatch);
1903
- }
1335
+ if (existingCommits.has(commit.sha)) {
1904
1336
  continue;
1905
1337
  }
1906
1338
  let patchContent;
1907
1339
  try {
1908
1340
  patchContent = await this.git.formatPatch(commit.sha);
1909
1341
  } catch {
1910
- this.warnings.push(
1911
- `Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
1912
- );
1913
1342
  continue;
1914
1343
  }
1915
1344
  let contentHash = this.computeContentHash(patchContent);
1916
- if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
1345
+ if (existingHashes.has(contentHash) || forgottenHashes.has(contentHash)) {
1346
+ continue;
1347
+ }
1348
+ if (this.isCreationOnlyPatch(patchContent)) {
1917
1349
  continue;
1918
1350
  }
1919
1351
  const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
1920
- const allFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
1921
- const sensitiveFiles = allFiles.filter((f) => isSensitiveFile(f));
1922
- const files = allFiles.filter((f) => !isSensitiveFile(f));
1352
+ const { files, sensitiveFiles } = filterInfrastructureAndSensitiveFiles(filesOutput);
1923
1353
  if (sensitiveFiles.length > 0) {
1924
1354
  this.warnings.push(
1925
1355
  `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
1926
1356
  );
1927
1357
  if (files.length > 0) {
1928
- const parentSha = parents[0];
1929
- if (parentSha) {
1930
- try {
1931
- patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
1932
- } catch {
1933
- continue;
1934
- }
1935
- if (!patchContent.trim()) continue;
1936
- contentHash = this.computeContentHash(patchContent);
1358
+ if (parents.length === 0) {
1359
+ continue;
1360
+ }
1361
+ try {
1362
+ patchContent = await this.git.exec(["diff", parents[0], commit.sha, "--", ...files]);
1363
+ } catch {
1364
+ continue;
1937
1365
  }
1366
+ if (!patchContent.trim()) continue;
1367
+ contentHash = this.computeContentHash(patchContent);
1938
1368
  }
1939
1369
  }
1940
1370
  if (files.length === 0) {
1941
1371
  continue;
1942
1372
  }
1943
- const theirsSnapshot = {};
1944
- for (const file of files) {
1945
- if (isBinaryFile(file)) continue;
1946
- const content = await this.git.showFile(commit.sha, file).catch(() => null);
1947
- if (content != null) theirsSnapshot[file] = content;
1373
+ if (parents.length === 0) {
1374
+ continue;
1375
+ }
1376
+ const parentSha = parents[0];
1377
+ let baseGeneration;
1378
+ if (knownGenerations.has(parentSha)) {
1379
+ baseGeneration = parentSha;
1380
+ } else if (fallbackAnchor) {
1381
+ baseGeneration = fallbackAnchor;
1382
+ } else {
1383
+ this.warnings.push(
1384
+ `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.`
1385
+ );
1386
+ continue;
1948
1387
  }
1388
+ const theirsSnapshot = await capturePatchSnapshot(this.git, files, commit.sha);
1949
1389
  newPatches.push({
1950
1390
  id: `patch-${commit.sha.slice(0, 8)}`,
1951
1391
  content_hash: contentHash,
1952
1392
  original_commit: commit.sha,
1953
1393
  original_message: commit.message,
1954
1394
  original_author: `${commit.authorName} <${commit.authorEmail}>`,
1955
- base_generation: lastGen.commit_sha,
1395
+ base_generation: baseGeneration,
1956
1396
  files,
1957
1397
  patch_content: patchContent,
1958
- ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
1959
- ...this.isCreationOnlyPatch(patchContent) ? { user_owned: true } : {}
1398
+ ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {}
1960
1399
  });
1961
1400
  }
1962
- const survivingPatches = await this.collapseNetZeroFiles(newPatches);
1963
- survivingPatches.reverse();
1964
- const revertedPatchIdSet = /* @__PURE__ */ new Set();
1965
- const revertIndicesToRemove = /* @__PURE__ */ new Set();
1966
- for (let i = 0; i < survivingPatches.length; i++) {
1967
- const patch = survivingPatches[i];
1968
- if (!isRevertCommit(patch.original_message)) continue;
1969
- let body = "";
1970
- try {
1971
- body = await this.git.getCommitBody(patch.original_commit);
1972
- } catch {
1973
- }
1974
- const revertedSha = parseRevertedSha(body);
1975
- const revertedMessage = parseRevertedMessage(patch.original_message);
1976
- let matchedExisting = false;
1977
- if (revertedSha) {
1978
- const existing = lock.patches.find((p) => p.original_commit === revertedSha);
1979
- if (existing) {
1980
- revertedPatchIdSet.add(existing.id);
1981
- revertIndicesToRemove.add(i);
1982
- matchedExisting = true;
1983
- }
1984
- }
1985
- if (!matchedExisting && revertedMessage) {
1986
- const existing = lock.patches.find((p) => p.original_message === revertedMessage);
1987
- if (existing) {
1988
- revertedPatchIdSet.add(existing.id);
1989
- revertIndicesToRemove.add(i);
1990
- matchedExisting = true;
1401
+ newPatches.reverse();
1402
+ return { patches: newPatches, revertedPatchIds: [] };
1403
+ }
1404
+ /**
1405
+ * Check if a format-patch consists entirely of new-file creations.
1406
+ * Used to identify squashed commits after force push, which create all files
1407
+ * from scratch (--- /dev/null) rather than modifying existing files.
1408
+ */
1409
+ isCreationOnlyPatch(patchContent) {
1410
+ const diffOldHeaders = patchContent.split("\n").filter((l) => l.startsWith("--- "));
1411
+ if (diffOldHeaders.length === 0) {
1412
+ return false;
1413
+ }
1414
+ return diffOldHeaders.every((l) => l === "--- /dev/null");
1415
+ }
1416
+ /**
1417
+ * Resolve the best available diff base for a generation record.
1418
+ * Prefers commit_sha, falls back to tree_hash for unreachable commits.
1419
+ * When commitKnownMissing is true, skips the redundant commitExists check.
1420
+ */
1421
+ async resolveDiffBase(gen, commitKnownMissing) {
1422
+ if (!commitKnownMissing && await this.git.commitExists(gen.commit_sha)) {
1423
+ return gen.commit_sha;
1424
+ }
1425
+ if (await this.git.treeExists(gen.tree_hash)) {
1426
+ return gen.tree_hash;
1427
+ }
1428
+ return null;
1429
+ }
1430
+ parseGitLog(log) {
1431
+ return log.trim().split("\n").map((line) => {
1432
+ const [sha, authorName, authorEmail, message] = line.split("\0");
1433
+ return { sha, authorName, authorEmail, message };
1434
+ });
1435
+ }
1436
+ getLastGeneration(lock) {
1437
+ return lock.generations.find((g) => g.commit_sha === lock.current_generation);
1438
+ }
1439
+ };
1440
+
1441
+ // src/ThreeWayMerge.ts
1442
+ var import_node_diff3 = require("node-diff3");
1443
+ function threeWayMerge(base, ours, theirs) {
1444
+ const baseLines = base.split("\n");
1445
+ const oursLines = ours.split("\n");
1446
+ const theirsLines = theirs.split("\n");
1447
+ const regions = (0, import_node_diff3.diff3Merge)(oursLines, baseLines, theirsLines);
1448
+ const outputLines = [];
1449
+ const conflicts = [];
1450
+ let currentLine = 1;
1451
+ for (const region of regions) {
1452
+ if (region.ok) {
1453
+ outputLines.push(...region.ok);
1454
+ currentLine += region.ok.length;
1455
+ } else if (region.conflict) {
1456
+ const resolved = tryResolveConflict(
1457
+ region.conflict.a,
1458
+ // ours (generator)
1459
+ region.conflict.o,
1460
+ // base
1461
+ region.conflict.b
1462
+ // theirs (user)
1463
+ );
1464
+ if (resolved !== null) {
1465
+ outputLines.push(...resolved);
1466
+ currentLine += resolved.length;
1467
+ } else {
1468
+ const startLine = currentLine;
1469
+ outputLines.push("<<<<<<< Generated");
1470
+ outputLines.push(...region.conflict.a);
1471
+ outputLines.push("=======");
1472
+ outputLines.push(...region.conflict.b);
1473
+ outputLines.push(">>>>>>> Your customization");
1474
+ const conflictLines = region.conflict.a.length + region.conflict.b.length + 3;
1475
+ conflicts.push({
1476
+ startLine,
1477
+ endLine: startLine + conflictLines - 1,
1478
+ ours: region.conflict.a,
1479
+ theirs: region.conflict.b
1480
+ });
1481
+ currentLine += conflictLines;
1482
+ }
1483
+ }
1484
+ }
1485
+ const result = {
1486
+ content: outputLines.join("\n"),
1487
+ hasConflicts: conflicts.length > 0,
1488
+ conflicts
1489
+ };
1490
+ if (conflicts.length === 0) {
1491
+ const dropped = computeDroppedContextLines(baseLines, theirsLines, outputLines);
1492
+ if (dropped.length > 0) {
1493
+ result.droppedContextLines = dropped;
1494
+ }
1495
+ }
1496
+ return result;
1497
+ }
1498
+ function computeDroppedContextLines(baseLines, theirsLines, mergedLines) {
1499
+ const baseCounts = countLines(baseLines);
1500
+ const theirsCounts = countLines(theirsLines);
1501
+ const mergedCounts = countLines(mergedLines);
1502
+ const dropped = [];
1503
+ const seen = /* @__PURE__ */ new Set();
1504
+ for (const line of baseLines) {
1505
+ if (seen.has(line)) continue;
1506
+ const inBase = baseCounts.get(line) ?? 0;
1507
+ const inTheirs = theirsCounts.get(line) ?? 0;
1508
+ const inMerged = mergedCounts.get(line) ?? 0;
1509
+ if (inBase > 0 && inTheirs > 0 && inMerged < Math.min(inBase, inTheirs)) {
1510
+ dropped.push(line);
1511
+ seen.add(line);
1512
+ }
1513
+ }
1514
+ return dropped;
1515
+ }
1516
+ function countLines(lines) {
1517
+ const counts = /* @__PURE__ */ new Map();
1518
+ for (const line of lines) {
1519
+ counts.set(line, (counts.get(line) ?? 0) + 1);
1520
+ }
1521
+ return counts;
1522
+ }
1523
+ function tryResolveConflict(oursLines, baseLines, theirsLines) {
1524
+ if (baseLines.length === 0) {
1525
+ return null;
1526
+ }
1527
+ const oursPatches = (0, import_node_diff3.diffPatch)(baseLines, oursLines);
1528
+ const theirsPatches = (0, import_node_diff3.diffPatch)(baseLines, theirsLines);
1529
+ if (oursPatches.length === 0) return theirsLines;
1530
+ if (theirsPatches.length === 0) return oursLines;
1531
+ if (patchesOverlap(oursPatches, theirsPatches)) {
1532
+ return null;
1533
+ }
1534
+ return applyBothPatches(baseLines, oursPatches, theirsPatches);
1535
+ }
1536
+ function patchesOverlap(oursPatches, theirsPatches) {
1537
+ for (const op of oursPatches) {
1538
+ const oStart = op.buffer1.offset;
1539
+ const oEnd = oStart + op.buffer1.length;
1540
+ for (const tp of theirsPatches) {
1541
+ const tStart = tp.buffer1.offset;
1542
+ const tEnd = tStart + tp.buffer1.length;
1543
+ if (op.buffer1.length === 0 && tp.buffer1.length === 0 && oStart === tStart) {
1544
+ return true;
1545
+ }
1546
+ if (tp.buffer1.length === 0 && tStart === oEnd) {
1547
+ return true;
1548
+ }
1549
+ if (op.buffer1.length === 0 && oStart === tEnd) {
1550
+ return true;
1551
+ }
1552
+ if (oStart < tEnd && tStart < oEnd) {
1553
+ return true;
1554
+ }
1555
+ }
1556
+ }
1557
+ return false;
1558
+ }
1559
+ function applyBothPatches(baseLines, oursPatches, theirsPatches) {
1560
+ const allPatches = [
1561
+ ...oursPatches.map((p) => ({
1562
+ offset: p.buffer1.offset,
1563
+ length: p.buffer1.length,
1564
+ replacement: p.buffer2.chunk
1565
+ })),
1566
+ ...theirsPatches.map((p) => ({
1567
+ offset: p.buffer1.offset,
1568
+ length: p.buffer1.length,
1569
+ replacement: p.buffer2.chunk
1570
+ }))
1571
+ ];
1572
+ allPatches.sort((a, b) => b.offset - a.offset);
1573
+ const result = [...baseLines];
1574
+ for (const p of allPatches) {
1575
+ result.splice(p.offset, p.length, ...p.replacement);
1576
+ }
1577
+ return result;
1578
+ }
1579
+
1580
+ // src/ReplayApplicator.ts
1581
+ var import_promises3 = require("fs/promises");
1582
+ var import_node_os = require("os");
1583
+ var import_node_path5 = require("path");
1584
+ var import_minimatch = require("minimatch");
1585
+
1586
+ // src/accumulator/MergeAccumulator.ts
1587
+ var MergeAccumulator = class {
1588
+ constructor(mode, sharedFiles) {
1589
+ this.mode = mode;
1590
+ this.sharedFiles = sharedFiles;
1591
+ }
1592
+ entries = /* @__PURE__ */ new Map();
1593
+ /** Was this file touched by 2+ patches in the current `applyPatches` invocation? */
1594
+ isShared(path) {
1595
+ return this.sharedFiles.has(path);
1596
+ }
1597
+ /** Has any prior patch already recorded a result for this path? */
1598
+ has(path) {
1599
+ return this.entries.has(path);
1600
+ }
1601
+ /** Look up the accumulated content + base generation for this path. */
1602
+ lookup(path) {
1603
+ return this.entries.get(path);
1604
+ }
1605
+ /**
1606
+ * True if any of the patch's files have a prior accumulator entry — in
1607
+ * which case the fast path (`git apply --3way`) cannot honor prior
1608
+ * patches' contributions and we must route through the slow path.
1609
+ */
1610
+ shouldSkipFastPath(patch) {
1611
+ return patch.files.some((f) => this.entries.has(f));
1612
+ }
1613
+ /** Record a successful (clean) merge result. */
1614
+ recordMerge(path, content, baseGeneration) {
1615
+ this.entries.set(path, { content, baseGeneration });
1616
+ }
1617
+ /**
1618
+ * Record a conflicted merge result. Populates only in `"resolve"` mode
1619
+ * — replay mode keeps the accumulator clean of marker-laden content
1620
+ * since markers will be stripped from disk after the apply loop.
1621
+ */
1622
+ recordConflict(path, content, baseGeneration) {
1623
+ if (this.mode === "resolve") {
1624
+ this.entries.set(path, { content, baseGeneration });
1625
+ }
1626
+ }
1627
+ /**
1628
+ * Defensive copy for test introspection. Used by invariant I2 to assert
1629
+ * that after every applied patch, the accumulator has an entry for each
1630
+ * file the patch touched (see `__tests__/invariants/i2-accumulator-correctness.ts`).
1631
+ *
1632
+ * @internal
1633
+ */
1634
+ snapshot() {
1635
+ const copy = /* @__PURE__ */ new Map();
1636
+ for (const [path, entry] of this.entries) {
1637
+ copy.set(path, { content: entry.content, baseGeneration: entry.baseGeneration });
1638
+ }
1639
+ return copy;
1640
+ }
1641
+ };
1642
+
1643
+ // src/applicator/resolveInputs.ts
1644
+ var import_promises = require("fs/promises");
1645
+ var import_node_path3 = require("path");
1646
+ async function resolveInputs(args) {
1647
+ const {
1648
+ patch,
1649
+ filePath,
1650
+ git,
1651
+ lockManager,
1652
+ outputDir,
1653
+ isTreeReachable,
1654
+ resolveBaseGeneration,
1655
+ resolveFilePath,
1656
+ extractRenameSource,
1657
+ extractFileDiff: extractFileDiff2
1658
+ } = args;
1659
+ const baseGen = await resolveBaseGeneration(patch.base_generation);
1660
+ if (!baseGen) {
1661
+ return {
1662
+ resolvedPath: filePath,
1663
+ metadata: {
1664
+ patchId: patch.id,
1665
+ patchMessage: patch.original_message,
1666
+ baseGeneration: patch.base_generation,
1667
+ currentGeneration: lockManager.read().current_generation
1668
+ },
1669
+ base: null,
1670
+ ours: null,
1671
+ oursPath: (0, import_node_path3.join)(outputDir, filePath),
1672
+ renameSourcePath: void 0,
1673
+ ghostTheirs: null,
1674
+ earlyExit: { status: "skipped", reason: "base-generation-not-found" }
1675
+ };
1676
+ }
1677
+ const lock = lockManager.read();
1678
+ const currentGen = lock.generations.find((g) => g.commit_sha === lock.current_generation);
1679
+ const currentTreeHash = currentGen?.tree_hash ?? baseGen.tree_hash;
1680
+ const resolvedPath = await resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
1681
+ const metadata = {
1682
+ patchId: patch.id,
1683
+ patchMessage: patch.original_message,
1684
+ baseGeneration: patch.base_generation,
1685
+ currentGeneration: lock.current_generation
1686
+ };
1687
+ let base = await git.showFile(baseGen.tree_hash, filePath);
1688
+ let renameSourcePath;
1689
+ if (!base) {
1690
+ const renameSource = extractRenameSource(patch.patch_content, filePath);
1691
+ if (renameSource) {
1692
+ base = await git.showFile(baseGen.tree_hash, renameSource);
1693
+ renameSourcePath = renameSource;
1694
+ }
1695
+ }
1696
+ const oursPath = (0, import_node_path3.join)(outputDir, resolvedPath);
1697
+ const ours = await (0, import_promises.readFile)(oursPath, "utf-8").catch(() => null);
1698
+ let ghostTheirs = null;
1699
+ if (!base && ours && !renameSourcePath) {
1700
+ const treeReachable = await isTreeReachable(baseGen.tree_hash);
1701
+ if (!treeReachable) {
1702
+ const fileDiff = extractFileDiff2(patch.patch_content, filePath);
1703
+ if (fileDiff) {
1704
+ const { reconstructFromGhostPatch: reconstructFromGhostPatch2 } = await Promise.resolve().then(() => (init_HybridReconstruction(), HybridReconstruction_exports));
1705
+ const result = reconstructFromGhostPatch2(fileDiff, ours);
1706
+ if (result) {
1707
+ base = result.base;
1708
+ ghostTheirs = result.theirs;
1709
+ }
1710
+ }
1711
+ }
1712
+ }
1713
+ return {
1714
+ resolvedPath,
1715
+ metadata,
1716
+ base,
1717
+ ours,
1718
+ oursPath,
1719
+ renameSourcePath,
1720
+ ghostTheirs
1721
+ };
1722
+ }
1723
+
1724
+ // src/conflict-utils.ts
1725
+ var CONFLICT_OPENER = "<<<<<<< Generated";
1726
+ var CONFLICT_SEPARATOR = "=======";
1727
+ var CONFLICT_CLOSER = ">>>>>>> Your customization";
1728
+ function trimCR(line) {
1729
+ return line.endsWith("\r") ? line.slice(0, -1) : line;
1730
+ }
1731
+ function findConflictRanges(lines) {
1732
+ const ranges = [];
1733
+ let i = 0;
1734
+ while (i < lines.length) {
1735
+ if (trimCR(lines[i]) === CONFLICT_OPENER) {
1736
+ let separatorIdx = -1;
1737
+ let j = i + 1;
1738
+ let found = false;
1739
+ while (j < lines.length) {
1740
+ const trimmed = trimCR(lines[j]);
1741
+ if (trimmed === CONFLICT_OPENER) {
1742
+ break;
1743
+ }
1744
+ if (separatorIdx === -1 && trimmed === CONFLICT_SEPARATOR) {
1745
+ separatorIdx = j;
1746
+ } else if (separatorIdx !== -1 && trimmed === CONFLICT_CLOSER) {
1747
+ ranges.push({ start: i, separator: separatorIdx, end: j });
1748
+ i = j;
1749
+ found = true;
1750
+ break;
1751
+ }
1752
+ j++;
1753
+ }
1754
+ if (!found) {
1755
+ i++;
1756
+ continue;
1757
+ }
1758
+ }
1759
+ i++;
1760
+ }
1761
+ return ranges;
1762
+ }
1763
+ function stripConflictMarkers(content) {
1764
+ const lines = content.split(/\r?\n/);
1765
+ const ranges = findConflictRanges(lines);
1766
+ if (ranges.length === 0) {
1767
+ return content;
1768
+ }
1769
+ const result = [];
1770
+ let rangeIdx = 0;
1771
+ for (let i = 0; i < lines.length; i++) {
1772
+ if (rangeIdx < ranges.length) {
1773
+ const range = ranges[rangeIdx];
1774
+ if (i === range.start) {
1775
+ continue;
1776
+ }
1777
+ if (i > range.start && i < range.separator) {
1778
+ result.push(lines[i]);
1779
+ continue;
1780
+ }
1781
+ if (i === range.separator) {
1782
+ continue;
1783
+ }
1784
+ if (i > range.separator && i < range.end) {
1785
+ continue;
1786
+ }
1787
+ if (i === range.end) {
1788
+ rangeIdx++;
1789
+ continue;
1790
+ }
1791
+ }
1792
+ result.push(lines[i]);
1793
+ }
1794
+ return result.join("\n");
1795
+ }
1796
+ function hasConflictMarkerStrings(content) {
1797
+ if (content == null) return false;
1798
+ return content.includes(CONFLICT_OPENER) || content.includes(CONFLICT_CLOSER);
1799
+ }
1800
+
1801
+ // src/applicator/buildMergePlan.ts
1802
+ async function buildMergePlan(args) {
1803
+ const {
1804
+ inputs,
1805
+ patch,
1806
+ filePath,
1807
+ accumulator,
1808
+ tempGit,
1809
+ tempDir,
1810
+ applyPatchToContent: applyPatchToContent2,
1811
+ isPatchAlreadyApplied
1812
+ } = args;
1813
+ const { base, ours, resolvedPath, renameSourcePath, ghostTheirs } = inputs;
1814
+ let theirs = ghostTheirs;
1815
+ const ghostReconstructed = ghostTheirs != null;
1816
+ const snapshotForFile = patch.theirs_snapshot?.[resolvedPath] ?? patch.theirs_snapshot?.[filePath];
1817
+ const fileIsShared = accumulator.isShared(resolvedPath) || accumulator.isShared(filePath);
1818
+ const useSnapshotAsPrimary = !fileIsShared && snapshotForFile != null;
1819
+ let primarySource = ghostReconstructed ? "ghost" : "reconstructed";
1820
+ if (!ghostReconstructed) {
1821
+ if (useSnapshotAsPrimary) {
1822
+ theirs = snapshotForFile;
1823
+ primarySource = "snapshot";
1824
+ } else {
1825
+ theirs = await applyPatchToContent2(
1826
+ base,
1827
+ patch.patch_content,
1828
+ filePath,
1829
+ tempGit,
1830
+ tempDir,
1831
+ renameSourcePath
1832
+ );
1833
+ const reconstructionHasMarkers = hasConflictMarkerStrings(theirs);
1834
+ const baseHadMarkers = hasConflictMarkerStrings(base);
1835
+ if (reconstructionHasMarkers && !baseHadMarkers && snapshotForFile != null) {
1836
+ theirs = snapshotForFile;
1837
+ }
1838
+ }
1839
+ }
1840
+ const accumulatorEntry = accumulator.lookup(resolvedPath);
1841
+ if (theirs) {
1842
+ const theirsHasMarkers = hasConflictMarkerStrings(theirs);
1843
+ const baseHasMarkers = hasConflictMarkerStrings(base);
1844
+ if (theirsHasMarkers && !baseHasMarkers) {
1845
+ if (accumulatorEntry) {
1846
+ theirs = null;
1847
+ } else {
1848
+ return { kind: "skipped", reason: "stale-conflict-markers" };
1849
+ }
1850
+ }
1851
+ }
1852
+ let useAccumulatorAsMergeBase = false;
1853
+ if (accumulatorEntry && (theirs == null || base == null)) {
1854
+ theirs = await applyPatchToContent2(
1855
+ accumulatorEntry.content,
1856
+ patch.patch_content,
1857
+ filePath,
1858
+ tempGit,
1859
+ tempDir
1860
+ );
1861
+ if (theirs != null) {
1862
+ useAccumulatorAsMergeBase = true;
1863
+ } else if (await isPatchAlreadyApplied(
1864
+ patch.patch_content,
1865
+ filePath,
1866
+ accumulatorEntry.content,
1867
+ tempGit,
1868
+ tempDir
1869
+ )) {
1870
+ theirs = accumulatorEntry.content;
1871
+ useAccumulatorAsMergeBase = true;
1872
+ }
1873
+ }
1874
+ if (theirs) {
1875
+ const theirsHasMarkers = hasConflictMarkerStrings(theirs);
1876
+ const accBaseHasMarkers = accumulatorEntry != null && hasConflictMarkerStrings(accumulatorEntry.content);
1877
+ if (theirsHasMarkers && !accBaseHasMarkers && !hasConflictMarkerStrings(base)) {
1878
+ return { kind: "skipped", reason: "stale-conflict-markers" };
1879
+ }
1880
+ }
1881
+ let effective_theirs = theirs;
1882
+ let conflictReasonHint;
1883
+ if (theirs != null && base != null && !useAccumulatorAsMergeBase) {
1884
+ if (accumulatorEntry && accumulatorEntry.baseGeneration === patch.base_generation) {
1885
+ try {
1886
+ const preMerged = threeWayMerge(base, accumulatorEntry.content, theirs);
1887
+ if (!preMerged.hasConflicts) {
1888
+ effective_theirs = preMerged.content;
1889
+ } else {
1890
+ effective_theirs = theirs;
1891
+ }
1892
+ } catch {
1893
+ effective_theirs = theirs;
1894
+ }
1895
+ } else if (accumulatorEntry) {
1896
+ conflictReasonHint = "base-generation-mismatch";
1897
+ }
1898
+ }
1899
+ if (base == null && ours == null && effective_theirs != null) {
1900
+ return { kind: "new-file-only-user", theirs: effective_theirs };
1901
+ }
1902
+ if (base == null && ours != null && effective_theirs != null && !useAccumulatorAsMergeBase) {
1903
+ return { kind: "new-file-both", theirs: effective_theirs };
1904
+ }
1905
+ if (effective_theirs == null) {
1906
+ return { kind: "skipped", reason: "missing-content" };
1907
+ }
1908
+ if (base == null && !useAccumulatorAsMergeBase || ours == null) {
1909
+ return { kind: "skipped", reason: "missing-content" };
1910
+ }
1911
+ if (useAccumulatorAsMergeBase) {
1912
+ const mergeBase = accumulatorEntry?.content ?? base;
1913
+ if (mergeBase == null) {
1914
+ return { kind: "skipped", reason: "missing-content" };
1915
+ }
1916
+ return {
1917
+ kind: "accumulator-base",
1918
+ theirs: effective_theirs,
1919
+ mergeBase,
1920
+ conflictReasonHint
1921
+ };
1922
+ }
1923
+ return {
1924
+ kind: primarySource,
1925
+ base,
1926
+ theirs: effective_theirs,
1927
+ mergeBase: base,
1928
+ conflictReasonHint
1929
+ };
1930
+ }
1931
+
1932
+ // src/applicator/runMergeAndRecord.ts
1933
+ var import_promises2 = require("fs/promises");
1934
+ var import_node_path4 = require("path");
1935
+ async function runMergeAndRecord(args) {
1936
+ const { plan, inputs, patch, accumulator } = args;
1937
+ const { resolvedPath, metadata, oursPath, ours } = inputs;
1938
+ if (plan.kind === "skipped") {
1939
+ return { file: resolvedPath, status: "skipped", reason: plan.reason };
1940
+ }
1941
+ if (plan.kind === "new-file-only-user") {
1942
+ accumulator.recordMerge(resolvedPath, plan.theirs, patch.base_generation);
1943
+ await (0, import_promises2.mkdir)((0, import_node_path4.dirname)(oursPath), { recursive: true });
1944
+ await (0, import_promises2.writeFile)(oursPath, plan.theirs);
1945
+ return { file: resolvedPath, status: "merged", reason: "new-file" };
1946
+ }
1947
+ if (plan.kind === "new-file-both") {
1948
+ const merged2 = threeWayMerge("", ours, plan.theirs);
1949
+ await (0, import_promises2.mkdir)((0, import_node_path4.dirname)(oursPath), { recursive: true });
1950
+ await (0, import_promises2.writeFile)(oursPath, merged2.content);
1951
+ if (merged2.hasConflicts) {
1952
+ return {
1953
+ file: resolvedPath,
1954
+ status: "conflict",
1955
+ conflicts: merged2.conflicts,
1956
+ conflictReason: "new-file-both",
1957
+ conflictMetadata: metadata
1958
+ };
1959
+ }
1960
+ accumulator.recordMerge(resolvedPath, merged2.content, patch.base_generation);
1961
+ return { file: resolvedPath, status: "merged" };
1962
+ }
1963
+ const merged = threeWayMerge(plan.mergeBase, ours, plan.theirs);
1964
+ await (0, import_promises2.mkdir)((0, import_node_path4.dirname)(oursPath), { recursive: true });
1965
+ await (0, import_promises2.writeFile)(oursPath, merged.content);
1966
+ if (merged.hasConflicts) {
1967
+ accumulator.recordConflict(resolvedPath, plan.theirs, patch.base_generation);
1968
+ return {
1969
+ file: resolvedPath,
1970
+ status: "conflict",
1971
+ conflicts: merged.conflicts,
1972
+ conflictReason: plan.conflictReasonHint ?? "same-line-edit",
1973
+ conflictMetadata: metadata
1974
+ };
1975
+ }
1976
+ accumulator.recordMerge(resolvedPath, plan.theirs, patch.base_generation);
1977
+ return {
1978
+ file: resolvedPath,
1979
+ status: "merged",
1980
+ ...merged.droppedContextLines && merged.droppedContextLines.length > 0 ? { droppedContextLines: merged.droppedContextLines } : {}
1981
+ };
1982
+ }
1983
+
1984
+ // src/ReplayApplicator.ts
1985
+ var ReplayApplicator = class {
1986
+ git;
1987
+ lockManager;
1988
+ outputDir;
1989
+ renameCache = /* @__PURE__ */ new Map();
1990
+ treeExistsCache = /* @__PURE__ */ new Map();
1991
+ /**
1992
+ * Inter-patch THEIRS coordination. Created fresh in each `applyPatches`
1993
+ * invocation with the run's apply mode + precomputed shared-file set.
1994
+ * See `src/accumulator/MergeAccumulator.ts` for the full contract.
1995
+ *
1996
+ * Initialized to an empty default in the constructor so type narrowing
1997
+ * works for callers that read it before any `applyPatches` invocation
1998
+ * (e.g., the `getAccumulatorSnapshot` test accessor).
1999
+ */
2000
+ accumulator = new MergeAccumulator("replay", /* @__PURE__ */ new Set());
2001
+ constructor(git, lockManager, outputDir) {
2002
+ this.git = git;
2003
+ this.lockManager = lockManager;
2004
+ this.outputDir = outputDir;
2005
+ }
2006
+ /**
2007
+ * Resolve the GenerationRecord for a patch's base_generation.
2008
+ * Falls back to constructing an ad-hoc record from the commit's tree
2009
+ * when base_generation isn't a tracked generation (commit-scan patches).
2010
+ */
2011
+ async resolveBaseGeneration(baseGeneration) {
2012
+ const gen = this.lockManager.getGeneration(baseGeneration);
2013
+ if (gen) return gen;
2014
+ try {
2015
+ const treeHash = await this.git.getTreeHash(baseGeneration);
2016
+ return {
2017
+ commit_sha: baseGeneration,
2018
+ tree_hash: treeHash,
2019
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2020
+ cli_version: "unknown",
2021
+ generator_versions: {}
2022
+ };
2023
+ } catch {
2024
+ return void 0;
2025
+ }
2026
+ }
2027
+ /**
2028
+ * @internal Test-only.
2029
+ * Returns a defensive copy of the inter-patch accumulator. Used by
2030
+ * invariant I2 to assert that after every applied patch, the
2031
+ * accumulator has an entry for each file the patch touched, and the
2032
+ * entry's content matches on-disk.
2033
+ */
2034
+ getAccumulatorSnapshot() {
2035
+ return this.accumulator.snapshot();
2036
+ }
2037
+ /**
2038
+ * Apply all patches, returning results for each.
2039
+ * Skips patches that match exclude patterns in replay.yml
2040
+ *
2041
+ * `applyMode` controls the post-conflict marker strategy:
2042
+ * - `"replay"` (default): strip conflict markers from disk between
2043
+ * iterations whenever a later patch touches the same file. Lets the
2044
+ * follow-up patch's `git apply --3way` see clean OURS content,
2045
+ * which is what the silent-loss fix (PR #73) relies on. The replay
2046
+ * pipeline calls `revertConflictingFiles` after the loop to clean
2047
+ * any markers that survive.
2048
+ * - `"resolve"`: keep markers on disk. The resolve command needs the
2049
+ * customer to see them. Slow-path 3-way merge naturally preserves
2050
+ * A's markers and B's clean writes when their regions don't overlap;
2051
+ * when they do overlap, the customer gets nested markers and
2052
+ * resolves manually. Either way they aren't silently dropped.
2053
+ */
2054
+ async applyPatches(patches, opts) {
2055
+ const applyMode = opts?.applyMode ?? "replay";
2056
+ const fileFreq = /* @__PURE__ */ new Map();
2057
+ for (const p of patches) {
2058
+ for (const f of p.files) {
2059
+ fileFreq.set(f, (fileFreq.get(f) ?? 0) + 1);
2060
+ }
2061
+ }
2062
+ const sharedFiles = new Set(
2063
+ Array.from(fileFreq.entries()).filter(([, n]) => n > 1).map(([f]) => f)
2064
+ );
2065
+ this.accumulator = new MergeAccumulator(applyMode, sharedFiles);
2066
+ const results = [];
2067
+ for (let i = 0; i < patches.length; i++) {
2068
+ const patch = patches[i];
2069
+ if (this.isExcluded(patch)) {
2070
+ results.push({
2071
+ patch,
2072
+ status: "skipped",
2073
+ method: "git-am"
2074
+ });
2075
+ continue;
2076
+ }
2077
+ const result = await this.applyPatchWithFallback(patch);
2078
+ results.push(result);
2079
+ if (applyMode !== "resolve" && result.status === "conflict" && result.fileResults) {
2080
+ const laterFiles = /* @__PURE__ */ new Set();
2081
+ for (let j = i + 1; j < patches.length; j++) {
2082
+ for (const f of patches[j].files) {
2083
+ laterFiles.add(f);
2084
+ }
2085
+ }
2086
+ const resolvedToOriginal = /* @__PURE__ */ new Map();
2087
+ if (result.resolvedFiles) {
2088
+ for (const [orig, resolved] of Object.entries(result.resolvedFiles)) {
2089
+ resolvedToOriginal.set(resolved, orig);
2090
+ }
1991
2091
  }
1992
- }
1993
- if (matchedExisting) continue;
1994
- let matchedNew = false;
1995
- if (revertedSha) {
1996
- const idx = survivingPatches.findIndex(
1997
- (p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_commit === revertedSha
1998
- );
1999
- if (idx !== -1) {
2000
- revertIndicesToRemove.add(i);
2001
- revertIndicesToRemove.add(idx);
2002
- matchedNew = true;
2092
+ for (const fileResult of result.fileResults) {
2093
+ if (fileResult.status !== "conflict") continue;
2094
+ const originalPath = resolvedToOriginal.get(fileResult.file) ?? fileResult.file;
2095
+ if (laterFiles.has(fileResult.file) || laterFiles.has(originalPath)) {
2096
+ const filePath = (0, import_node_path5.join)(this.outputDir, fileResult.file);
2097
+ try {
2098
+ const content = await (0, import_promises3.readFile)(filePath, "utf-8");
2099
+ const stripped = stripConflictMarkers(content);
2100
+ await (0, import_promises3.writeFile)(filePath, stripped);
2101
+ } catch {
2102
+ }
2103
+ }
2003
2104
  }
2004
2105
  }
2005
- if (!matchedNew && revertedMessage) {
2006
- const idx = survivingPatches.findIndex(
2007
- (p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_message === revertedMessage
2008
- );
2009
- if (idx !== -1) {
2010
- revertIndicesToRemove.add(i);
2011
- revertIndicesToRemove.add(idx);
2106
+ }
2107
+ return results;
2108
+ }
2109
+ /**
2110
+ * Populate accumulator after git apply succeeds, AND collect per-file
2111
+ * results including any "dropped context lines" — lines that were
2112
+ * present in BASE and THEIRS (unchanged context) but absent from the
2113
+ * final on-disk state because OURS deleted them. This is the fast-path
2114
+ * counterpart to ThreeWayMerge.computeDroppedContextLines used by the
2115
+ * 3-way slow path; both must surface the same warning.
2116
+ */
2117
+ async populateAccumulatorForPatch(patch, baseGen, currentTreeHash) {
2118
+ const fileResults = [];
2119
+ if (!baseGen) return fileResults;
2120
+ const tempDir = await (0, import_promises3.mkdtemp)((0, import_node_path5.join)((0, import_node_os.tmpdir)(), "replay-acc-"));
2121
+ const { GitClient: GitClient2 } = await Promise.resolve().then(() => (init_GitClient(), GitClient_exports));
2122
+ const tempGit = new GitClient2(tempDir);
2123
+ await tempGit.exec(["init"]);
2124
+ await tempGit.exec(["config", "user.email", "replay@fern.com"]);
2125
+ await tempGit.exec(["config", "user.name", "Fern Replay"]);
2126
+ try {
2127
+ for (const filePath of patch.files) {
2128
+ if (isBinaryFile(filePath)) continue;
2129
+ const resolvedPath = await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
2130
+ const base = await this.git.showFile(baseGen.tree_hash, filePath);
2131
+ const snapshotForFile = patch.theirs_snapshot?.[resolvedPath] ?? patch.theirs_snapshot?.[filePath];
2132
+ const fileIsShared = this.accumulator.isShared(resolvedPath) || this.accumulator.isShared(filePath);
2133
+ const theirs = !fileIsShared && snapshotForFile != null ? snapshotForFile : await this.applyPatchToContent(base, patch.patch_content, filePath, tempGit, tempDir);
2134
+ const finalOnDisk = await (0, import_promises3.readFile)((0, import_node_path5.join)(this.outputDir, resolvedPath), "utf-8").catch(() => null);
2135
+ const effectiveTheirs = theirs ?? finalOnDisk;
2136
+ if (effectiveTheirs != null) {
2137
+ this.accumulator.recordMerge(resolvedPath, effectiveTheirs, patch.base_generation);
2138
+ }
2139
+ if (base != null && effectiveTheirs != null && finalOnDisk != null) {
2140
+ const dropped = computeDroppedContextLinesForFile(base, effectiveTheirs, finalOnDisk);
2141
+ if (dropped.length > 0) {
2142
+ fileResults.push({
2143
+ file: resolvedPath,
2144
+ status: "merged",
2145
+ droppedContextLines: dropped
2146
+ });
2147
+ }
2012
2148
  }
2013
2149
  }
2014
- if (!matchedExisting && !matchedNew) {
2015
- revertIndicesToRemove.add(i);
2150
+ } finally {
2151
+ await (0, import_promises3.rm)(tempDir, { recursive: true }).catch(() => {
2152
+ });
2153
+ }
2154
+ return fileResults;
2155
+ }
2156
+ async applyPatchWithFallback(patch) {
2157
+ const baseGen = await this.resolveBaseGeneration(patch.base_generation);
2158
+ const lock = this.lockManager.read();
2159
+ const currentGen = lock.generations.find((g) => g.commit_sha === lock.current_generation);
2160
+ const currentTreeHash = currentGen?.tree_hash ?? baseGen?.tree_hash ?? "";
2161
+ const needsAccumulation = await Promise.all(
2162
+ patch.files.map(async (f) => {
2163
+ if (!baseGen) return false;
2164
+ const resolved = await this.resolveFilePath(f, baseGen.tree_hash, currentTreeHash);
2165
+ return this.accumulator.has(resolved);
2166
+ })
2167
+ ).then((results) => results.some(Boolean));
2168
+ const resolvedFiles = {};
2169
+ for (const filePath of patch.files) {
2170
+ const resolvedPath = baseGen ? await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash) : filePath;
2171
+ if (resolvedPath !== filePath) {
2172
+ resolvedFiles[filePath] = resolvedPath;
2016
2173
  }
2017
2174
  }
2018
- const filteredPatches = survivingPatches.filter((_, i) => !revertIndicesToRemove.has(i));
2019
- return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
2175
+ const patchIsRenameAware = /^rename from /m.test(patch.patch_content);
2176
+ const hasGeneratorRename = Object.keys(resolvedFiles).length > 0 && !patchIsRenameAware;
2177
+ if (!needsAccumulation && !hasGeneratorRename) {
2178
+ const snapshots = /* @__PURE__ */ new Map();
2179
+ for (const filePath of patch.files) {
2180
+ const fullPath = (0, import_node_path5.join)(this.outputDir, filePath);
2181
+ snapshots.set(filePath, await (0, import_promises3.readFile)(fullPath, "utf-8").catch(() => null));
2182
+ }
2183
+ try {
2184
+ await this.git.execWithInput(["apply", "--3way"], patch.patch_content);
2185
+ const fastPathFileResults = await this.populateAccumulatorForPatch(patch, baseGen, currentTreeHash);
2186
+ return {
2187
+ patch,
2188
+ status: "applied",
2189
+ method: "git-am",
2190
+ ...fastPathFileResults.length > 0 && { fileResults: fastPathFileResults },
2191
+ ...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
2192
+ };
2193
+ } catch {
2194
+ for (const [filePath, content] of snapshots) {
2195
+ const fullPath = (0, import_node_path5.join)(this.outputDir, filePath);
2196
+ if (content != null) {
2197
+ await (0, import_promises3.writeFile)(fullPath, content);
2198
+ } else {
2199
+ await (0, import_promises3.unlink)(fullPath).catch(() => {
2200
+ });
2201
+ }
2202
+ }
2203
+ }
2204
+ }
2205
+ return this.applyWithThreeWayMerge(patch);
2020
2206
  }
2021
- /**
2022
- * Compute content hash for deduplication.
2023
- *
2024
- * Produces a format-agnostic hash so both `git format-patch` output
2025
- * (email-wrapped) and plain `git diff` output hash to the same value
2026
- * when the underlying diff hunks are identical.
2027
- *
2028
- * Normalization:
2029
- * 1. Strip email wrapper: everything before the first `diff --git` line
2030
- * (From:, Subject:, Date:, diffstat, blank separators).
2031
- * 2. Strip `index` lines (blob SHA pairs change across rebases).
2032
- * 3. Strip trailing git version marker (`-- \n<version>`).
2033
- *
2034
- * This ensures content hashes match across the no-patches and normal-
2035
- * regeneration flows, which store formatPatch and git-diff formats
2036
- * respectively (FER-9850, D5-D9).
2037
- */
2038
- computeContentHash(patchContent) {
2039
- const lines = patchContent.split("\n");
2040
- const diffStart = lines.findIndex((l) => l.startsWith("diff --git "));
2041
- const relevantLines = diffStart > 0 ? lines.slice(diffStart) : lines;
2042
- const normalized = relevantLines.filter((line) => !line.startsWith("index ")).join("\n").replace(/\n-- \n[\d]+\.[\d]+[^\n]*\s*$/, "");
2043
- return `sha256:${(0, import_node_crypto.createHash)("sha256").update(normalized).digest("hex")}`;
2207
+ async applyWithThreeWayMerge(patch) {
2208
+ const fileResults = [];
2209
+ const resolvedFiles = {};
2210
+ const tempDir = await (0, import_promises3.mkdtemp)((0, import_node_path5.join)((0, import_node_os.tmpdir)(), "replay-"));
2211
+ const { GitClient: GitClient2 } = await Promise.resolve().then(() => (init_GitClient(), GitClient_exports));
2212
+ const tempGit = new GitClient2(tempDir);
2213
+ await tempGit.exec(["init"]);
2214
+ await tempGit.exec(["config", "user.email", "replay@fern.com"]);
2215
+ await tempGit.exec(["config", "user.name", "Fern Replay"]);
2216
+ try {
2217
+ for (const filePath of patch.files) {
2218
+ if (isBinaryFile(filePath)) {
2219
+ fileResults.push({
2220
+ file: filePath,
2221
+ status: "skipped",
2222
+ reason: "binary-file"
2223
+ });
2224
+ continue;
2225
+ }
2226
+ const result = await this.mergeFile(patch, filePath, tempGit, tempDir);
2227
+ if (result.file !== filePath) {
2228
+ resolvedFiles[filePath] = result.file;
2229
+ }
2230
+ fileResults.push(result);
2231
+ }
2232
+ } finally {
2233
+ await (0, import_promises3.rm)(tempDir, { recursive: true }).catch(() => {
2234
+ });
2235
+ }
2236
+ const conflictFiles = fileResults.filter((r) => r.status === "conflict");
2237
+ const hasConflicts = conflictFiles.length > 0;
2238
+ const conflictReason = hasConflicts ? conflictFiles.some((f) => f.conflictReason === "base-generation-mismatch") ? "base-generation-mismatch" : conflictFiles[0]?.conflictReason : void 0;
2239
+ return {
2240
+ patch,
2241
+ status: hasConflicts ? "conflict" : "applied",
2242
+ method: "3way-merge",
2243
+ fileResults,
2244
+ conflictReason,
2245
+ ...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
2246
+ };
2044
2247
  }
2045
- /**
2046
- * FER-9805 — Drop patches whose files were transiently created and then
2047
- * deleted within the current linear detection window, and are absent from
2048
- * HEAD at the end of the window.
2049
- *
2050
- * Rationale: on a merge commit, createMergeCompositePatch already diffs
2051
- * firstParent..mergeCommit so net-zero files never enter the composite. On
2052
- * linear history each commit becomes its own patch, so a file that was
2053
- * briefly added and later removed survives as a create+delete pair whose
2054
- * cumulative diff is empty. We drop such patches before they reach the
2055
- * lockfile.
2056
- *
2057
- * A file is "transient" when:
2058
- * 1. some patch in the window sets `new file mode` for it (a creation), AND
2059
- * 2. some patch in the window sets `deleted file mode` for it (a deletion), AND
2060
- * 3. it is absent from HEAD's tree.
2061
- *
2062
- * The HEAD guard (#3) prevents false positives when a file is deleted
2063
- * and then recreated with different content — the cumulative diff of
2064
- * such a pair is non-empty and must be preserved.
2065
- *
2066
- * This only drops patches whose `files` are a subset of the transient
2067
- * set. A partial-transient patch (one that touches a transient file
2068
- * alongside a persistent one) is left unmodified; surgical stripping of
2069
- * single files from a multi-file patch would require a follow-up that
2070
- * regenerates the patch content via `git format-patch -- <files>`. No
2071
- * current failing test exercises this, and leaving the patch intact
2072
- * preserves today's behaviour. TODO(FER-9805): revisit if a future
2073
- * adversarial test demands per-file stripping.
2074
- */
2075
- async collapseNetZeroFiles(patches) {
2076
- if (patches.length === 0) return patches;
2077
- const stateByFile = /* @__PURE__ */ new Map();
2078
- for (const patch of patches) {
2079
- for (const header of parsePatchFileHeaders(patch.patch_content)) {
2080
- if (!header.isCreate && !header.isDelete) continue;
2081
- const prior = stateByFile.get(header.path) ?? { created: false, deleted: false };
2082
- if (header.isCreate) prior.created = true;
2083
- if (header.isDelete) prior.deleted = true;
2084
- stateByFile.set(header.path, prior);
2248
+ async mergeFile(patch, filePath, tempGit, tempDir) {
2249
+ try {
2250
+ const inputs = await resolveInputs({
2251
+ patch,
2252
+ filePath,
2253
+ git: this.git,
2254
+ lockManager: this.lockManager,
2255
+ outputDir: this.outputDir,
2256
+ isTreeReachable: (h) => this.isTreeReachable(h),
2257
+ resolveBaseGeneration: (s) => this.resolveBaseGeneration(s),
2258
+ resolveFilePath: (f, b, c) => this.resolveFilePath(f, b, c),
2259
+ extractRenameSource: (p, f) => this.extractRenameSource(p, f),
2260
+ extractFileDiff: (p, f) => this.extractFileDiff(p, f)
2261
+ });
2262
+ if (inputs.earlyExit) {
2263
+ return { file: filePath, ...inputs.earlyExit };
2085
2264
  }
2265
+ const plan = await buildMergePlan({
2266
+ inputs,
2267
+ patch,
2268
+ filePath,
2269
+ accumulator: this.accumulator,
2270
+ tempGit,
2271
+ tempDir,
2272
+ applyPatchToContent: (b, p, f, g, d, s) => this.applyPatchToContent(b, p, f, g, d, s),
2273
+ isPatchAlreadyApplied: (p, f, c, g, d) => this.isPatchAlreadyApplied(p, f, c, g, d)
2274
+ });
2275
+ return await runMergeAndRecord({
2276
+ plan,
2277
+ inputs,
2278
+ patch,
2279
+ accumulator: this.accumulator
2280
+ });
2281
+ } catch (error) {
2282
+ return {
2283
+ file: filePath,
2284
+ status: "skipped",
2285
+ reason: `error: ${error instanceof Error ? error.message : String(error)}`
2286
+ };
2086
2287
  }
2087
- let anyCandidate = false;
2088
- for (const state of stateByFile.values()) {
2089
- if (state.created && state.deleted) {
2090
- anyCandidate = true;
2091
- break;
2288
+ }
2289
+ async isTreeReachable(treeHash) {
2290
+ let result = this.treeExistsCache.get(treeHash);
2291
+ if (result === void 0) {
2292
+ result = await this.git.treeExists(treeHash);
2293
+ this.treeExistsCache.set(treeHash, result);
2294
+ }
2295
+ return result;
2296
+ }
2297
+ isExcluded(patch) {
2298
+ const config = this.lockManager.getCustomizationsConfig();
2299
+ if (!config.exclude) return false;
2300
+ return patch.files.some((file) => config.exclude.some((pattern) => (0, import_minimatch.minimatch)(file, pattern)));
2301
+ }
2302
+ async resolveFilePath(filePath, baseTreeHash, currentTreeHash) {
2303
+ const config = this.lockManager.getCustomizationsConfig();
2304
+ if (config.moves) {
2305
+ for (const move of config.moves) {
2306
+ if ((0, import_minimatch.minimatch)(filePath, move.from) || filePath === move.from) {
2307
+ if (filePath === move.from) {
2308
+ return move.to;
2309
+ }
2310
+ const fromBase = move.from.replace(/\*\*.*$/, "");
2311
+ const toBase = move.to.replace(/\*\*.*$/, "");
2312
+ if (filePath.startsWith(fromBase)) {
2313
+ return toBase + filePath.slice(fromBase.length);
2314
+ }
2315
+ }
2092
2316
  }
2093
2317
  }
2094
- if (!anyCandidate) return patches;
2095
- const headFiles = await this.listHeadFiles();
2096
- const transient = /* @__PURE__ */ new Set();
2097
- for (const [file, state] of stateByFile) {
2098
- if (state.created && state.deleted && !headFiles.has(file)) {
2099
- transient.add(file);
2100
- }
2318
+ const cacheKey = `${baseTreeHash}:${currentTreeHash}`;
2319
+ let renames = this.renameCache.get(cacheKey);
2320
+ if (!renames) {
2321
+ renames = await this.git.detectRenames(baseTreeHash, currentTreeHash);
2322
+ this.renameCache.set(cacheKey, renames);
2101
2323
  }
2102
- if (transient.size === 0) return patches;
2103
- return patches.filter((patch) => !patch.files.every((f) => transient.has(f)));
2104
- }
2105
- /**
2106
- * List every tracked path at HEAD (one `git ls-tree -r` invocation).
2107
- * Returns an empty Set if HEAD is unborn or the invocation fails — the
2108
- * caller then treats every candidate as "not in HEAD" and may collapse
2109
- * more aggressively. On typical SDK repos this is a single sub-10ms call.
2110
- */
2111
- async listHeadFiles() {
2112
- try {
2113
- const out = await this.git.exec(["ls-tree", "-r", "--name-only", "HEAD"]);
2114
- return new Set(out.split("\n").map((l) => l.trim()).filter(Boolean));
2115
- } catch {
2116
- return /* @__PURE__ */ new Set();
2324
+ const gitRename = renames.find((r) => r.from === filePath);
2325
+ if (gitRename) {
2326
+ return gitRename.to;
2117
2327
  }
2328
+ return filePath;
2118
2329
  }
2119
- /**
2120
- * Create a single composite patch for a merge commit by diffing the merge
2121
- * commit against its first parent. This captures the net change of the
2122
- * entire merged branch as one patch, eliminating accumulator chaining and
2123
- * zero-net-change ghost conflicts.
2124
- */
2125
- async createMergeCompositePatch(input) {
2126
- const { mergeCommit, firstParent, lastGen, lock } = input;
2127
- const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
2128
- const filesOutput = await this.git.exec([
2129
- "diff",
2130
- "--name-only",
2131
- firstParent,
2132
- mergeCommit.sha
2133
- ]);
2134
- const allMergeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
2135
- const sensitiveMergeFiles = allMergeFiles.filter((f) => isSensitiveFile(f));
2136
- const files = allMergeFiles.filter((f) => !isSensitiveFile(f));
2137
- if (sensitiveMergeFiles.length > 0) {
2138
- this.warnings.push(
2139
- `Sensitive file(s) excluded from merge patch for commit ${mergeCommit.sha.slice(0, 7)}: ${sensitiveMergeFiles.join(", ")}. These files will not be tracked by replay.`
2140
- );
2330
+ async applyPatchToContent(base, patchContent, filePath, tempGit, tempDir, sourceFilePath) {
2331
+ if (!base) {
2332
+ return this.extractNewFileFromPatch(patchContent, filePath);
2141
2333
  }
2142
- if (files.length === 0) return null;
2143
- const diff = await this.git.exec([
2144
- "diff",
2145
- firstParent,
2146
- mergeCommit.sha,
2147
- "--",
2148
- ...files
2149
- ]);
2150
- if (!diff.trim()) return null;
2151
- const contentHash = this.computeContentHash(diff);
2152
- if (lock.patches.some((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
2334
+ const fileDiff = this.extractFileDiff(patchContent, filePath);
2335
+ if (!fileDiff) return null;
2336
+ try {
2337
+ if (sourceFilePath) {
2338
+ const tempSourcePath = (0, import_node_path5.join)(tempDir, sourceFilePath);
2339
+ await (0, import_promises3.mkdir)((0, import_node_path5.dirname)(tempSourcePath), { recursive: true });
2340
+ await (0, import_promises3.writeFile)(tempSourcePath, base);
2341
+ await tempGit.exec(["add", sourceFilePath]);
2342
+ await tempGit.exec([
2343
+ "commit",
2344
+ "-m",
2345
+ `base for rename ${sourceFilePath} -> ${filePath}`,
2346
+ "--allow-empty"
2347
+ ]);
2348
+ await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
2349
+ const tempTargetPath = (0, import_node_path5.join)(tempDir, filePath);
2350
+ return await (0, import_promises3.readFile)(tempTargetPath, "utf-8");
2351
+ }
2352
+ const tempFilePath = (0, import_node_path5.join)(tempDir, filePath);
2353
+ await (0, import_promises3.mkdir)((0, import_node_path5.dirname)(tempFilePath), { recursive: true });
2354
+ await (0, import_promises3.writeFile)(tempFilePath, base);
2355
+ await tempGit.exec(["add", filePath]);
2356
+ await tempGit.exec(["commit", "-m", `base for ${filePath}`, "--allow-empty"]);
2357
+ await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
2358
+ return await (0, import_promises3.readFile)(tempFilePath, "utf-8");
2359
+ } catch {
2153
2360
  return null;
2154
2361
  }
2155
- const theirsSnapshot = {};
2156
- for (const file of files) {
2157
- if (isBinaryFile(file)) continue;
2158
- const content = await this.git.showFile(mergeCommit.sha, file).catch(() => null);
2159
- if (content != null) theirsSnapshot[file] = content;
2160
- }
2161
- return {
2162
- id: `patch-merge-${mergeCommit.sha.slice(0, 8)}`,
2163
- content_hash: contentHash,
2164
- original_commit: mergeCommit.sha,
2165
- original_message: mergeCommit.message,
2166
- original_author: `${mergeCommit.authorName} <${mergeCommit.authorEmail}>`,
2167
- base_generation: lastGen.commit_sha,
2168
- files,
2169
- patch_content: diff,
2170
- ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
2171
- ...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
2172
- };
2173
2362
  }
2174
2363
  /**
2175
- * Detect patches via tree diff for non-linear history. Returns a composite patch.
2176
- * Revert reconciliation is skipped here because tree-diff produces a single composite
2177
- * patch from the aggregate diff individual revert commits are not distinguishable.
2364
+ * Detects whether a patch's additions are already present in `content`.
2365
+ * Uses `git apply --reverse --check` if the reverse patch applies cleanly,
2366
+ * the forward patch is effectively a no-op (its +lines are already there and
2367
+ * its -lines are already gone). Used to distinguish "patch already applied"
2368
+ * from "patch base mismatch" after a forward-apply fails.
2178
2369
  */
2179
- async detectPatchesViaTreeDiff(lastGen, commitKnownMissing) {
2180
- const diffBase = await this.resolveDiffBase(lastGen, commitKnownMissing);
2181
- if (!diffBase) {
2182
- return this.detectPatchesViaCommitScan();
2183
- }
2184
- const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
2185
- const allTreeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
2186
- const sensitiveTreeFiles = allTreeFiles.filter((f) => isSensitiveFile(f));
2187
- const files = allTreeFiles.filter((f) => !isSensitiveFile(f));
2188
- if (sensitiveTreeFiles.length > 0) {
2189
- this.warnings.push(
2190
- `Sensitive file(s) excluded from composite patch: ${sensitiveTreeFiles.join(", ")}. These files will not be tracked by replay.`
2191
- );
2192
- }
2193
- if (files.length === 0) return { patches: [], revertedPatchIds: [] };
2194
- const diff = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
2195
- if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
2196
- const contentHash = this.computeContentHash(diff);
2197
- const lock = this.lockManager.read();
2198
- if (lock.patches.some((p) => p.content_hash === contentHash) || (lock.forgotten_hashes ?? []).includes(contentHash)) {
2199
- return { patches: [], revertedPatchIds: [] };
2200
- }
2201
- const headSha = (await this.git.exec(["rev-parse", "HEAD"])).trim();
2202
- const theirsSnapshot = {};
2203
- for (const file of files) {
2204
- if (isBinaryFile(file)) continue;
2205
- const content = await this.git.showFile("HEAD", file).catch(() => null);
2206
- if (content != null) theirsSnapshot[file] = content;
2370
+ async isPatchAlreadyApplied(patchContent, filePath, content, tempGit, tempDir) {
2371
+ const fileDiff = this.extractFileDiff(patchContent, filePath);
2372
+ if (!fileDiff) return false;
2373
+ try {
2374
+ const tempFilePath = (0, import_node_path5.join)(tempDir, filePath);
2375
+ await (0, import_promises3.mkdir)((0, import_node_path5.dirname)(tempFilePath), { recursive: true });
2376
+ await (0, import_promises3.writeFile)(tempFilePath, content);
2377
+ await tempGit.exec(["add", filePath]);
2378
+ await tempGit.exec(["commit", "-m", `check already-applied ${filePath}`, "--allow-empty"]);
2379
+ await tempGit.execWithInput(["apply", "--reverse", "--check", "--allow-empty"], fileDiff);
2380
+ return true;
2381
+ } catch {
2382
+ return false;
2207
2383
  }
2208
- const compositePatch = {
2209
- id: `patch-composite-${headSha.slice(0, 8)}`,
2210
- content_hash: contentHash,
2211
- original_commit: headSha,
2212
- original_message: "Customer customizations (composite)",
2213
- original_author: "composite",
2214
- // Use diffBase when commit is unreachable — the applicator needs a reachable
2215
- // reference to find base file content. diffBase may be the tree_hash.
2216
- base_generation: commitKnownMissing ? diffBase : lastGen.commit_sha,
2217
- files,
2218
- patch_content: diff,
2219
- ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
2220
- ...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
2221
- };
2222
- return { patches: [compositePatch], revertedPatchIds: [] };
2223
2384
  }
2224
- /**
2225
- * Last-resort detection when both generation commit and tree are unreachable.
2226
- * Scans all commits from HEAD, filters against known lockfile patches, and
2227
- * skips creation-only commits (squashed history after force push).
2228
- *
2229
- * Detected patches use the commit's parent as base_generation so the applicator
2230
- * can find base file content from the parent's tree (which IS reachable).
2231
- */
2232
- async detectPatchesViaCommitScan() {
2233
- const lock = this.lockManager.read();
2234
- const log = await this.git.exec([
2235
- "log",
2236
- "--max-count=200",
2237
- "--format=%H%x00%an%x00%ae%x00%s",
2238
- "HEAD",
2239
- "--",
2240
- this.sdkOutputDir
2241
- ]);
2242
- if (!log.trim()) {
2243
- return { patches: [], revertedPatchIds: [] };
2244
- }
2245
- const commits = this.parseGitLog(log);
2246
- const newPatches = [];
2247
- const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
2248
- const existingHashes = new Set(lock.patches.map((p) => p.content_hash));
2249
- const existingCommits = new Set(lock.patches.map((p) => p.original_commit));
2250
- for (const commit of commits) {
2251
- if (isGenerationCommit(commit)) {
2252
- continue;
2253
- }
2254
- const parents = await this.git.getCommitParents(commit.sha);
2255
- if (parents.length > 1) {
2385
+ extractFileDiff(patchContent, filePath) {
2386
+ const lines = patchContent.split("\n");
2387
+ const diffLines = [];
2388
+ let inTargetFile = false;
2389
+ for (const line of lines) {
2390
+ if (line.startsWith("diff --git")) {
2391
+ if (inTargetFile) {
2392
+ break;
2393
+ }
2394
+ if (isDiffLineForFile(line, filePath)) {
2395
+ inTargetFile = true;
2396
+ diffLines.push(line);
2397
+ }
2256
2398
  continue;
2257
2399
  }
2258
- if (existingCommits.has(commit.sha)) {
2259
- continue;
2400
+ if (inTargetFile) {
2401
+ diffLines.push(line);
2260
2402
  }
2261
- let patchContent;
2262
- try {
2263
- patchContent = await this.git.formatPatch(commit.sha);
2264
- } catch {
2403
+ }
2404
+ return diffLines.length > 0 ? diffLines.join("\n") + "\n" : null;
2405
+ }
2406
+ extractRenameSource(patchContent, targetFilePath) {
2407
+ const lines = patchContent.split("\n");
2408
+ let inTargetFile = false;
2409
+ for (const line of lines) {
2410
+ if (line.startsWith("diff --git")) {
2411
+ if (inTargetFile) break;
2412
+ inTargetFile = isDiffLineForFile(line, targetFilePath);
2265
2413
  continue;
2266
2414
  }
2267
- let contentHash = this.computeContentHash(patchContent);
2268
- if (existingHashes.has(contentHash) || forgottenHashes.has(contentHash)) {
2269
- continue;
2415
+ if (!inTargetFile) continue;
2416
+ if (line.startsWith("@@")) break;
2417
+ if (line.startsWith("rename from ")) {
2418
+ return line.slice("rename from ".length);
2270
2419
  }
2271
- if (this.isCreationOnlyPatch(patchContent)) {
2420
+ }
2421
+ return null;
2422
+ }
2423
+ extractNewFileFromPatch(patchContent, filePath) {
2424
+ const lines = patchContent.split("\n");
2425
+ const addedLines = [];
2426
+ let inTargetFile = false;
2427
+ let inHunk = false;
2428
+ let isNewFile = false;
2429
+ let noTrailingNewline = false;
2430
+ for (const line of lines) {
2431
+ if (line.startsWith("diff --git")) {
2432
+ if (inTargetFile) break;
2433
+ inTargetFile = isDiffLineForFile(line, filePath);
2434
+ inHunk = false;
2435
+ isNewFile = false;
2272
2436
  continue;
2273
2437
  }
2274
- const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
2275
- const allScanFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f));
2276
- const sensitiveScanFiles = allScanFiles.filter((f) => isSensitiveFile(f));
2277
- const files = allScanFiles.filter((f) => !isSensitiveFile(f));
2278
- if (sensitiveScanFiles.length > 0) {
2279
- this.warnings.push(
2280
- `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveScanFiles.join(", ")}. These files will not be tracked by replay.`
2281
- );
2282
- if (files.length > 0) {
2283
- if (parents.length === 0) {
2284
- continue;
2285
- }
2286
- try {
2287
- patchContent = await this.git.exec(["diff", parents[0], commit.sha, "--", ...files]);
2288
- } catch {
2289
- continue;
2290
- }
2291
- if (!patchContent.trim()) continue;
2292
- contentHash = this.computeContentHash(patchContent);
2438
+ if (!inTargetFile) continue;
2439
+ if (!inHunk) {
2440
+ if (line === "--- /dev/null" || line.startsWith("new file mode")) {
2441
+ isNewFile = true;
2293
2442
  }
2294
2443
  }
2295
- if (files.length === 0) {
2444
+ if (line.startsWith("@@")) {
2445
+ if (!isNewFile) return null;
2446
+ inHunk = true;
2296
2447
  continue;
2297
2448
  }
2298
- if (parents.length === 0) {
2449
+ if (!inHunk) continue;
2450
+ if (line === "\") {
2451
+ noTrailingNewline = true;
2299
2452
  continue;
2300
2453
  }
2301
- const parentSha = parents[0];
2302
- const theirsSnapshot = {};
2303
- for (const file of files) {
2304
- if (isBinaryFile(file)) continue;
2305
- const content = await this.git.showFile(commit.sha, file).catch(() => null);
2306
- if (content != null) theirsSnapshot[file] = content;
2454
+ if (line.startsWith("+") && !line.startsWith("+++")) {
2455
+ addedLines.push(line.slice(1));
2307
2456
  }
2308
- newPatches.push({
2309
- id: `patch-${commit.sha.slice(0, 8)}`,
2310
- content_hash: contentHash,
2311
- original_commit: commit.sha,
2312
- original_message: commit.message,
2313
- original_author: `${commit.authorName} <${commit.authorEmail}>`,
2314
- base_generation: parentSha,
2315
- files,
2316
- patch_content: patchContent,
2317
- ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {}
2318
- });
2319
- }
2320
- newPatches.reverse();
2321
- return { patches: newPatches, revertedPatchIds: [] };
2322
- }
2323
- /**
2324
- * Check if a format-patch consists entirely of new-file creations.
2325
- * Used to identify squashed commits after force push, which create all files
2326
- * from scratch (--- /dev/null) rather than modifying existing files.
2327
- */
2328
- isCreationOnlyPatch(patchContent) {
2329
- const diffOldHeaders = patchContent.split("\n").filter((l) => l.startsWith("--- "));
2330
- if (diffOldHeaders.length === 0) {
2331
- return false;
2332
2457
  }
2333
- return diffOldHeaders.every((l) => l === "--- /dev/null");
2458
+ if (addedLines.length === 0) return null;
2459
+ return addedLines.join("\n") + (noTrailingNewline ? "" : "\n");
2334
2460
  }
2335
- /**
2336
- * Resolve the best available diff base for a generation record.
2337
- * Prefers commit_sha, falls back to tree_hash for unreachable commits.
2338
- * When commitKnownMissing is true, skips the redundant commitExists check.
2339
- */
2340
- async resolveDiffBase(gen, commitKnownMissing) {
2341
- if (!commitKnownMissing && await this.git.commitExists(gen.commit_sha)) {
2342
- return gen.commit_sha;
2343
- }
2344
- if (await this.git.treeExists(gen.tree_hash)) {
2345
- return gen.tree_hash;
2461
+ };
2462
+ function computeDroppedContextLinesForFile(base, theirs, finalContent) {
2463
+ const baseLines = base.split("\n");
2464
+ const theirsLines = theirs.split("\n");
2465
+ const finalLines = finalContent.split("\n");
2466
+ const baseCounts = /* @__PURE__ */ new Map();
2467
+ const theirsCounts = /* @__PURE__ */ new Map();
2468
+ const finalCounts = /* @__PURE__ */ new Map();
2469
+ for (const l of baseLines) baseCounts.set(l, (baseCounts.get(l) ?? 0) + 1);
2470
+ for (const l of theirsLines) theirsCounts.set(l, (theirsCounts.get(l) ?? 0) + 1);
2471
+ for (const l of finalLines) finalCounts.set(l, (finalCounts.get(l) ?? 0) + 1);
2472
+ const dropped = [];
2473
+ const seen = /* @__PURE__ */ new Set();
2474
+ for (const line of baseLines) {
2475
+ if (seen.has(line)) continue;
2476
+ const inBase = baseCounts.get(line) ?? 0;
2477
+ const inTheirs = theirsCounts.get(line) ?? 0;
2478
+ const inFinal = finalCounts.get(line) ?? 0;
2479
+ if (inBase > 0 && inTheirs > 0 && inFinal < Math.min(inBase, inTheirs)) {
2480
+ dropped.push(line);
2481
+ seen.add(line);
2346
2482
  }
2347
- return null;
2348
- }
2349
- parseGitLog(log) {
2350
- return log.trim().split("\n").map((line) => {
2351
- const [sha, authorName, authorEmail, message] = line.split("\0");
2352
- return { sha, authorName, authorEmail, message };
2353
- });
2354
- }
2355
- getLastGeneration(lock) {
2356
- return lock.generations.find((g) => g.commit_sha === lock.current_generation);
2357
2483
  }
2358
- };
2484
+ return dropped;
2485
+ }
2486
+ function isDiffLineForFile(diffLine, filePath) {
2487
+ const match = diffLine.match(/^diff --git a\/.+ b\/(.+)$/);
2488
+ return match !== null && match[1] === filePath;
2489
+ }
2359
2490
 
2360
2491
  // src/ReplayCommitter.ts
2361
2492
  var ReplayCommitter = class {
@@ -2462,15 +2593,15 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
2462
2593
 
2463
2594
  // src/ReplayService.ts
2464
2595
  var import_node_fs2 = require("fs");
2465
- var import_promises4 = require("fs/promises");
2466
- var import_node_path5 = require("path");
2467
- var import_minimatch2 = require("minimatch");
2596
+ var import_promises6 = require("fs/promises");
2597
+ var import_node_path8 = require("path");
2598
+ var import_minimatch3 = require("minimatch");
2468
2599
  init_GitClient();
2469
2600
 
2470
2601
  // src/PatchRegionDiff.ts
2471
- var import_promises2 = require("fs/promises");
2602
+ var import_promises4 = require("fs/promises");
2472
2603
  var import_node_os2 = require("os");
2473
- var import_node_path3 = require("path");
2604
+ var import_node_path6 = require("path");
2474
2605
  var import_node_child_process = require("child_process");
2475
2606
  init_HybridReconstruction();
2476
2607
  function normalizeHunkBody(hunk) {
@@ -2677,17 +2808,17 @@ function overlayRegions(currentGenLines, locInCurrentGen, workingTreeLines, locI
2677
2808
  }
2678
2809
  async function unifiedDiff(beforeContent, afterContent, filePath) {
2679
2810
  if (beforeContent === afterContent) return "";
2680
- const tmp = await (0, import_promises2.mkdtemp)((0, import_node_path3.join)((0, import_node_os2.tmpdir)(), "fern-replay-pp-"));
2811
+ const tmp = await (0, import_promises4.mkdtemp)((0, import_node_path6.join)((0, import_node_os2.tmpdir)(), "fern-replay-pp-"));
2681
2812
  try {
2682
- const beforePath = (0, import_node_path3.join)(tmp, "before");
2683
- const afterPath = (0, import_node_path3.join)(tmp, "after");
2684
- await (0, import_promises2.writeFile)(beforePath, beforeContent, "utf-8");
2685
- await (0, import_promises2.writeFile)(afterPath, afterContent, "utf-8");
2813
+ const beforePath = (0, import_node_path6.join)(tmp, "before");
2814
+ const afterPath = (0, import_node_path6.join)(tmp, "after");
2815
+ await (0, import_promises4.writeFile)(beforePath, beforeContent, "utf-8");
2816
+ await (0, import_promises4.writeFile)(afterPath, afterContent, "utf-8");
2686
2817
  const raw = await runGitDiffNoIndex(beforePath, afterPath);
2687
2818
  if (!raw.trim()) return "";
2688
2819
  return rewriteDiffHeaders(raw, filePath);
2689
2820
  } finally {
2690
- await (0, import_promises2.rm)(tmp, { recursive: true, force: true });
2821
+ await (0, import_promises4.rm)(tmp, { recursive: true, force: true });
2691
2822
  }
2692
2823
  }
2693
2824
  function runGitDiffNoIndex(beforePath, afterPath) {
@@ -2759,211 +2890,84 @@ function synthesizeDeleteFileDiff(file, content) {
2759
2890
  return header + "\n" + body + trailer + "\n";
2760
2891
  }
2761
2892
 
2762
- // src/ReplayService.ts
2763
- var ReplayService = class {
2764
- git;
2765
- detector;
2766
- applicator;
2767
- committer;
2768
- lockManager;
2769
- outputDir;
2770
- /** @internal Test-only. Captures the last ReplayResult[] returned from applicator.applyPatches(). */
2771
- _lastApplyResults = [];
2772
- /**
2773
- * Service-level warnings accumulated during a single runReplay() call.
2774
- * Merged with `detector.warnings` into `ReplayReport.warnings` by each flow handler.
2775
- * Populated by `isFileUserOwned` when a patch's base generation tree is unreachable
2776
- * (shallow clone / aggressive `git gc --prune`) and we fall back to a conservative
2777
- * heuristic. Cleared at the top of `runReplay()`.
2778
- */
2779
- warnings = [];
2780
- /**
2781
- * @internal Test-only accessor.
2782
- * Returns the ReplayApplicator instance used for the last run. Tests use this
2783
- * to inspect the inter-patch accumulator after runReplay() completes (FER-9791, I2).
2784
- */
2785
- get applicatorRef() {
2786
- return this.applicator;
2787
- }
2788
- /**
2789
- * @internal Test-only accessor.
2790
- * Returns the ReplayResult[] from the most recent applyPatches() call. Tests use
2791
- * this to filter applied-vs-conflicted-vs-absorbed patches for invariant assertions
2792
- * (FER-9791). Returns an empty array if no patches have been applied yet.
2793
- */
2794
- getLastResults() {
2795
- return this._lastApplyResults;
2796
- }
2797
- constructor(outputDir, _config) {
2798
- const git = new GitClient(outputDir);
2893
+ // src/classifier/FileOwnership.ts
2894
+ var import_minimatch2 = require("minimatch");
2895
+ var FileOwnership = class {
2896
+ constructor(git) {
2799
2897
  this.git = git;
2800
- this.outputDir = outputDir;
2801
- this.lockManager = new LockfileManager(outputDir);
2802
- this.detector = new ReplayDetector(git, this.lockManager, outputDir);
2803
- this.applicator = new ReplayApplicator(git, this.lockManager, outputDir);
2804
- this.committer = new ReplayCommitter(git, outputDir);
2805
- }
2806
- /**
2807
- * Phase 1 of the two-phase replay flow. Runs preGenerationRebase (when applicable),
2808
- * detects new patches, and creates the `[fern-generated]` commit. Leaves HEAD at
2809
- * the generation commit (or unchanged for dry-run).
2810
- *
2811
- * Does NOT apply patches — the returned handle must be passed to
2812
- * `applyPreparedReplay()` to complete the run. No disk writes to the lockfile
2813
- * happen here; lockfile persistence is deferred to phase 2.
2814
- *
2815
- * Callers may land additional commits on HEAD between `prepareReplay` and
2816
- * `applyPreparedReplay` (e.g., `[fern-autoversion]`).
2817
- */
2818
- async prepareReplay(options) {
2819
- this.warnings = [];
2820
- this.detector.warnings.length = 0;
2821
- if (options?.skipApplication) {
2822
- return this._prepareSkipApplication(options);
2823
- }
2824
- const flow = this.determineFlow();
2825
- switch (flow) {
2826
- case "first-generation":
2827
- return this._prepareFirstGeneration(options);
2828
- case "no-patches":
2829
- return this._prepareNoPatchesRegeneration(options);
2830
- case "normal-regeneration":
2831
- return this._prepareNormalRegeneration(options);
2832
- }
2833
- }
2834
- /**
2835
- * Phase 2 of the two-phase replay flow. Applies patches, rebases them against
2836
- * the generation, saves the lockfile, and commits `[fern-replay]` (or stages
2837
- * under `stageOnly`). Operates on HEAD at call time — mid-phase commits are
2838
- * respected.
2839
- *
2840
- * For terminal handles (dry-run, first-gen has no patch work, skip-app does
2841
- * its own post-commit logic), this returns the precomputed report.
2842
- */
2843
- async applyPreparedReplay(prep, options) {
2844
- if (prep._prepared.terminal) {
2845
- return prep._prepared.preparedReport;
2846
- }
2847
- switch (prep._prepared.flow) {
2848
- case "first-generation":
2849
- return this._applyFirstGeneration(prep);
2850
- case "skip-application":
2851
- return this._applySkipApplication(prep, options);
2852
- case "no-patches":
2853
- return this._applyNoPatchesRegeneration(prep, options);
2854
- case "normal-regeneration":
2855
- return this._applyNormalRegeneration(prep, options);
2856
- }
2857
- }
2858
- /**
2859
- * Backwards-compatible atomic entry point. Composes `prepareReplay` + `applyPreparedReplay`.
2860
- * Behavior is byte-identical to the pre-split implementation for all existing callers.
2861
- */
2862
- async runReplay(options) {
2863
- const prep = await this.prepareReplay(options);
2864
- return this.applyPreparedReplay(prep, options);
2865
2898
  }
2866
2899
  /**
2867
- * Sync the lockfile after a divergent PR was squash-merged.
2868
- * Call this BEFORE runReplay() when the CLI detects a merged divergent PR.
2900
+ * Canonical resolution. Returns true if `file` is user-owned per:
2869
2901
  *
2870
- * After updating the generation record, re-detects customer patches via
2871
- * tree diff between the generation tag (pure generation tree) and HEAD
2872
- * (which includes customer customizations after squash merge). This ensures
2873
- * patches survive the squash merge regenerate cycle even when the lockfile
2874
- * was restored from the base branch during conflict PR creation.
2902
+ * 1. `patch.user_owned === true` authoritative, persisted flag.
2903
+ * 2. `file === ".fernignore"` always user-owned.
2904
+ * 3. File matches a `.fernignore` pattern (via minimatch).
2905
+ * 4. Patch content shows `--- /dev/null` for this file (legacy-safe
2906
+ * signal for patches predating `user_owned` or whose flag was lost).
2907
+ * 5. Tree-based check against `patch.base_generation` when reachable
2908
+ * AND distinct from `currentGenSha`. Unreachable base → conservative
2909
+ * user-owned (silent-absorption safety net), with a one-time warning
2910
+ * per base SHA.
2911
+ * 6. Final fallback: check `currentGenSha` when no usable base exists
2912
+ * (legacy one-gen lockfile).
2875
2913
  */
2876
- async syncFromDivergentMerge(generationCommitSha, options) {
2877
- const treeHash = await this.git.getTreeHash(generationCommitSha);
2878
- const timestamp = (await this.git.exec(["log", "-1", "--format=%aI", generationCommitSha])).trim();
2879
- const record = {
2880
- commit_sha: generationCommitSha,
2881
- tree_hash: treeHash,
2882
- timestamp,
2883
- cli_version: options?.cliVersion ?? "unknown",
2884
- generator_versions: options?.generatorVersions ?? {},
2885
- base_branch_head: options?.baseBranchHead
2886
- };
2887
- let resolvedPatches;
2888
- if (!this.lockManager.exists()) {
2889
- this.lockManager.initializeInMemory(record);
2890
- } else {
2891
- this.lockManager.read();
2892
- const unresolvedPatches = [
2893
- ...this.lockManager.getUnresolvedPatches(),
2894
- ...this.lockManager.getResolvingPatches()
2895
- ];
2896
- resolvedPatches = this.lockManager.getPatches().filter((p) => p.status == null);
2897
- this.lockManager.addGeneration(record);
2898
- this.lockManager.clearPatches();
2899
- for (const patch of unresolvedPatches) {
2900
- this.lockManager.addPatch(patch);
2901
- }
2914
+ async resolveCanonical(ctx) {
2915
+ const { file, originalFileName, patch, currentGenSha, fernignorePatterns, warnedGens, warnings } = ctx;
2916
+ if (patch.user_owned) return true;
2917
+ if (file === ".fernignore") return true;
2918
+ if (fernignorePatterns.some((p) => file === p || (0, import_minimatch2.minimatch)(file, p))) return true;
2919
+ if (fileIsCreationInPatchContent(patch.patch_content, originalFileName ?? file)) {
2920
+ return true;
2902
2921
  }
2903
- try {
2904
- const { patches: redetectedPatches } = await this.detector.detectNewPatches();
2905
- if (redetectedPatches.length > 0) {
2906
- const redetectedFiles = new Set(redetectedPatches.flatMap((p) => p.files));
2907
- const currentPatches = this.lockManager.getPatches();
2908
- for (const patch of currentPatches) {
2909
- if (patch.status != null && patch.files.some((f) => redetectedFiles.has(f))) {
2910
- this.lockManager.removePatch(patch.id);
2911
- }
2912
- }
2913
- for (const patch of redetectedPatches) {
2914
- this.lockManager.addPatch(patch);
2922
+ const base = patch.base_generation;
2923
+ if (base && base !== currentGenSha) {
2924
+ const reachable = await this.git.commitExists(base) || await this.git.treeExists(base);
2925
+ if (!reachable) {
2926
+ if (!warnedGens.has(base)) {
2927
+ warnedGens.add(base);
2928
+ warnings.push(
2929
+ `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.`
2930
+ );
2915
2931
  }
2932
+ return true;
2916
2933
  }
2917
- } catch (error) {
2918
- for (const patch of resolvedPatches ?? []) {
2919
- this.lockManager.addPatch(patch);
2920
- }
2921
- this.detector.warnings.push(
2922
- `Patch re-detection failed after divergent merge sync. ${(resolvedPatches ?? []).length} previously resolved patch(es) preserved. Error: ${error instanceof Error ? error.message : String(error)}`
2923
- );
2934
+ return await this.git.showFile(base, originalFileName ?? file) === null;
2924
2935
  }
2925
- this.lockManager.save();
2926
- this.detector.warnings.push(...this.lockManager.warnings);
2927
- }
2928
- determineFlow() {
2929
- try {
2930
- const lock = this.lockManager.read();
2931
- return lock.patches.length === 0 ? "no-patches" : "normal-regeneration";
2932
- } catch (error) {
2933
- if (error instanceof LockfileNotFoundError) {
2934
- return "first-generation";
2935
- }
2936
- throw error;
2936
+ return await this.git.showFile(currentGenSha, originalFileName ?? file) === null;
2937
+ }
2938
+ };
2939
+ function fileIsCreationInPatchContent(patchContent, file) {
2940
+ const lines = patchContent.split("\n");
2941
+ let inFileSection = false;
2942
+ for (const line of lines) {
2943
+ if (line.startsWith("diff --git ")) {
2944
+ inFileSection = line.includes(` b/${file}`) || line.endsWith(` b/${file}`);
2945
+ continue;
2946
+ }
2947
+ if (inFileSection && line.startsWith("--- ")) {
2948
+ return line === "--- /dev/null";
2937
2949
  }
2938
2950
  }
2939
- firstGenerationReport() {
2940
- return {
2941
- flow: "first-generation",
2942
- patchesDetected: 0,
2943
- patchesApplied: 0,
2944
- patchesWithConflicts: 0,
2945
- patchesSkipped: 0,
2946
- conflicts: []
2947
- };
2951
+ return false;
2952
+ }
2953
+
2954
+ // src/flows/FirstGenerationFlow.ts
2955
+ var FirstGenerationFlow = class {
2956
+ constructor(service) {
2957
+ this.service = service;
2948
2958
  }
2949
- async _prepareFirstGeneration(options) {
2959
+ async prepare(options) {
2950
2960
  if (options?.dryRun) {
2951
- const headSha = await this.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
2961
+ const headSha = await this.service.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
2952
2962
  return {
2953
2963
  flow: "first-generation",
2954
2964
  previousGenerationSha: null,
2955
2965
  currentGenerationSha: headSha,
2956
2966
  baseBranchHead: options.baseBranchHead ?? null,
2957
2967
  _prepared: {
2968
+ kind: "terminal",
2958
2969
  flow: "first-generation",
2959
- terminal: true,
2960
- preparedReport: this.firstGenerationReport(),
2961
- patchesToApply: [],
2962
- newPatchIds: /* @__PURE__ */ new Set(),
2963
- detectorWarnings: [],
2964
- revertedCount: 0,
2965
- genSha: headSha,
2966
- optionsSnapshot: options
2970
+ preparedReport: firstGenerationReport()
2967
2971
  }
2968
2972
  };
2969
2973
  }
@@ -2972,17 +2976,17 @@ var ReplayService = class {
2972
2976
  generatorVersions: options.generatorVersions ?? {},
2973
2977
  baseBranchHead: options.baseBranchHead
2974
2978
  } : void 0;
2975
- await this.committer.commitGeneration("Initial SDK generation", commitOpts);
2976
- const genRecord = await this.committer.createGenerationRecord(commitOpts);
2977
- this.lockManager.initializeInMemory(genRecord);
2979
+ await this.service.committer.commitGeneration("Initial SDK generation", commitOpts);
2980
+ const genRecord = await this.service.committer.createGenerationRecord(commitOpts);
2981
+ this.service.lockManager.initializeInMemory(genRecord);
2978
2982
  return {
2979
2983
  flow: "first-generation",
2980
2984
  previousGenerationSha: null,
2981
2985
  currentGenerationSha: genRecord.commit_sha,
2982
2986
  baseBranchHead: options?.baseBranchHead ?? null,
2983
2987
  _prepared: {
2988
+ kind: "active",
2984
2989
  flow: "first-generation",
2985
- terminal: false,
2986
2990
  patchesToApply: [],
2987
2991
  newPatchIds: /* @__PURE__ */ new Set(),
2988
2992
  detectorWarnings: [],
@@ -2992,47 +2996,57 @@ var ReplayService = class {
2992
2996
  }
2993
2997
  };
2994
2998
  }
2995
- async _applyFirstGeneration(_prep) {
2996
- this.lockManager.save();
2997
- return this.firstGenerationReport();
2999
+ async apply(_prep) {
3000
+ this.service.lockManager.save();
3001
+ return firstGenerationReport();
2998
3002
  }
2999
- /**
3000
- * Skip-application mode phase 1: commit the generation and update the
3001
- * in-memory lockfile, but don't detect or apply patches. Sets a marker
3002
- * so the next normal run skips revert detection in preGenerationRebase().
3003
- *
3004
- * Disk save is deferred to `_applySkipApplication`.
3005
- */
3006
- async _prepareSkipApplication(options) {
3003
+ };
3004
+ function firstGenerationReport() {
3005
+ return {
3006
+ flow: "first-generation",
3007
+ patchesDetected: 0,
3008
+ patchesApplied: 0,
3009
+ patchesWithConflicts: 0,
3010
+ patchesSkipped: 0,
3011
+ conflicts: []
3012
+ };
3013
+ }
3014
+
3015
+ // src/flows/SkipApplicationFlow.ts
3016
+ var SkipApplicationFlow = class {
3017
+ constructor(service) {
3018
+ this.service = service;
3019
+ }
3020
+ async prepare(options) {
3007
3021
  const commitOpts = options ? {
3008
3022
  cliVersion: options.cliVersion ?? "unknown",
3009
3023
  generatorVersions: options.generatorVersions ?? {},
3010
3024
  baseBranchHead: options.baseBranchHead
3011
3025
  } : void 0;
3012
- await this.committer.commitGeneration("Update SDK (replay skipped)", commitOpts);
3013
- await this.cleanupStaleConflictMarkers();
3014
- const genRecord = await this.committer.createGenerationRecord(commitOpts);
3026
+ await this.service.committer.commitGeneration("Update SDK (replay skipped)", commitOpts);
3027
+ await this.service.cleanupStaleConflictMarkers();
3028
+ const genRecord = await this.service.committer.createGenerationRecord(commitOpts);
3015
3029
  let previousGenerationSha = null;
3016
3030
  try {
3017
- const lock = this.lockManager.read();
3031
+ const lock = this.service.lockManager.read();
3018
3032
  previousGenerationSha = lock.current_generation ?? null;
3019
- this.lockManager.addGeneration(genRecord);
3033
+ this.service.lockManager.addGeneration(genRecord);
3020
3034
  } catch (error) {
3021
3035
  if (error instanceof LockfileNotFoundError) {
3022
- this.lockManager.initializeInMemory(genRecord);
3036
+ this.service.lockManager.initializeInMemory(genRecord);
3023
3037
  } else {
3024
3038
  throw error;
3025
3039
  }
3026
3040
  }
3027
- this.lockManager.setReplaySkippedAt((/* @__PURE__ */ new Date()).toISOString());
3041
+ this.service.lockManager.setReplaySkippedAt((/* @__PURE__ */ new Date()).toISOString());
3028
3042
  return {
3029
3043
  flow: "skip-application",
3030
3044
  previousGenerationSha,
3031
3045
  currentGenerationSha: genRecord.commit_sha,
3032
3046
  baseBranchHead: options?.baseBranchHead ?? null,
3033
3047
  _prepared: {
3048
+ kind: "active",
3034
3049
  flow: "skip-application",
3035
- terminal: false,
3036
3050
  patchesToApply: [],
3037
3051
  newPatchIds: /* @__PURE__ */ new Set(),
3038
3052
  detectorWarnings: [],
@@ -3042,13 +3056,13 @@ var ReplayService = class {
3042
3056
  }
3043
3057
  };
3044
3058
  }
3045
- async _applySkipApplication(_prep, options) {
3046
- this.lockManager.save();
3047
- const credentialWarnings = [...this.lockManager.warnings];
3059
+ async apply(_prep, options) {
3060
+ this.service.lockManager.save();
3061
+ const credentialWarnings = [...this.service.lockManager.warnings];
3048
3062
  if (!options?.stageOnly) {
3049
- await this.committer.commitReplay(0);
3063
+ await this.service.committer.commitReplay(0);
3050
3064
  } else {
3051
- await this.committer.stageAll();
3065
+ await this.service.committer.stageAll();
3052
3066
  }
3053
3067
  return {
3054
3068
  flow: "skip-application",
@@ -3060,13 +3074,20 @@ var ReplayService = class {
3060
3074
  warnings: credentialWarnings.length > 0 ? credentialWarnings : void 0
3061
3075
  };
3062
3076
  }
3063
- async _prepareNoPatchesRegeneration(options) {
3064
- const preLock = this.lockManager.read();
3077
+ };
3078
+
3079
+ // src/flows/NoPatchesFlow.ts
3080
+ var NoPatchesFlow = class {
3081
+ constructor(service) {
3082
+ this.service = service;
3083
+ }
3084
+ async prepare(options) {
3085
+ const preLock = this.service.lockManager.read();
3065
3086
  const previousGenerationSha = preLock.current_generation ?? null;
3066
- let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
3067
- const detectorWarnings = [...this.detector.warnings];
3087
+ let { patches: newPatches, revertedPatchIds } = await this.service.detector.detectNewPatches();
3088
+ const detectorWarnings = [...this.service.detector.warnings];
3068
3089
  if (options?.dryRun) {
3069
- const dryRunWarnings = [...detectorWarnings, ...this.warnings];
3090
+ const dryRunWarnings = [...detectorWarnings, ...this.service.warnings];
3070
3091
  const preparedReport = {
3071
3092
  flow: "no-patches",
3072
3093
  patchesDetected: newPatches.length,
@@ -3078,22 +3099,16 @@ var ReplayService = class {
3078
3099
  wouldApply: newPatches,
3079
3100
  warnings: dryRunWarnings.length > 0 ? dryRunWarnings : void 0
3080
3101
  };
3081
- const headSha = await this.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
3102
+ const headSha = await this.service.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
3082
3103
  return {
3083
3104
  flow: "no-patches",
3084
3105
  previousGenerationSha,
3085
3106
  currentGenerationSha: headSha,
3086
3107
  baseBranchHead: options.baseBranchHead ?? null,
3087
3108
  _prepared: {
3109
+ kind: "terminal",
3088
3110
  flow: "no-patches",
3089
- terminal: true,
3090
- preparedReport,
3091
- patchesToApply: [],
3092
- newPatchIds: /* @__PURE__ */ new Set(),
3093
- detectorWarnings,
3094
- revertedCount: revertedPatchIds.length,
3095
- genSha: headSha,
3096
- optionsSnapshot: options
3111
+ preparedReport
3097
3112
  }
3098
3113
  };
3099
3114
  }
@@ -3102,7 +3117,7 @@ var ReplayService = class {
3102
3117
  const uniqueFiles = [...new Set(newPatches.flatMap((p) => p.files))];
3103
3118
  const absorbedFiles = /* @__PURE__ */ new Set();
3104
3119
  for (const file of uniqueFiles) {
3105
- const diff = await this.git.exec(["diff", preCurrentGen, "HEAD", "--", file]).catch(() => null);
3120
+ const diff = await this.service.git.exec(["diff", preCurrentGen, "HEAD", "--", file]).catch(() => null);
3106
3121
  if (diff !== null && !diff.trim()) {
3107
3122
  absorbedFiles.add(file);
3108
3123
  }
@@ -3115,7 +3130,7 @@ var ReplayService = class {
3115
3130
  }
3116
3131
  for (const id of revertedPatchIds) {
3117
3132
  try {
3118
- this.lockManager.removePatch(id);
3133
+ this.service.lockManager.removePatch(id);
3119
3134
  } catch {
3120
3135
  }
3121
3136
  }
@@ -3124,18 +3139,18 @@ var ReplayService = class {
3124
3139
  generatorVersions: options.generatorVersions ?? {},
3125
3140
  baseBranchHead: options.baseBranchHead
3126
3141
  } : void 0;
3127
- await this.committer.commitGeneration("Update SDK", commitOpts);
3128
- await this.cleanupStaleConflictMarkers();
3129
- const genRecord = await this.committer.createGenerationRecord(commitOpts);
3130
- this.lockManager.addGeneration(genRecord);
3142
+ await this.service.committer.commitGeneration("Update SDK", commitOpts);
3143
+ await this.service.cleanupStaleConflictMarkers();
3144
+ const genRecord = await this.service.committer.createGenerationRecord(commitOpts);
3145
+ this.service.lockManager.addGeneration(genRecord);
3131
3146
  return {
3132
3147
  flow: "no-patches",
3133
3148
  previousGenerationSha,
3134
3149
  currentGenerationSha: genRecord.commit_sha,
3135
3150
  baseBranchHead: options?.baseBranchHead ?? null,
3136
3151
  _prepared: {
3152
+ kind: "active",
3137
3153
  flow: "no-patches",
3138
- terminal: false,
3139
3154
  patchesToApply: newPatches,
3140
3155
  newPatchIds: new Set(newPatches.map((p) => p.id)),
3141
3156
  detectorWarnings,
@@ -3145,41 +3160,42 @@ var ReplayService = class {
3145
3160
  }
3146
3161
  };
3147
3162
  }
3148
- async _applyNoPatchesRegeneration(prep, options) {
3163
+ async apply(prep, options) {
3164
+ assertActive(prep);
3149
3165
  const newPatches = prep._prepared.patchesToApply;
3150
3166
  const detectorWarnings = prep._prepared.detectorWarnings;
3151
3167
  const genSha = prep._prepared.genSha;
3152
3168
  let results = [];
3153
3169
  if (newPatches.length > 0) {
3154
- results = await this.applicator.applyPatches(newPatches);
3155
- this._lastApplyResults = results;
3156
- this.recordDroppedContextLineWarnings(results);
3157
- this.revertConflictingFiles(results);
3170
+ results = await this.service.applicator.applyPatches(newPatches);
3171
+ this.service._lastApplyResults = results;
3172
+ this.service.recordDroppedContextLineWarnings(results);
3173
+ this.service.revertConflictingFiles(results);
3158
3174
  for (const result of results) {
3159
3175
  if (result.status === "conflict") {
3160
3176
  result.patch.status = "unresolved";
3161
3177
  }
3162
3178
  }
3163
3179
  }
3164
- const rebaseCounts = await this.rebasePatches(results, genSha);
3180
+ const rebaseCounts = await this.service.rebasePatches(results, genSha);
3165
3181
  for (const patch of newPatches) {
3166
3182
  if (!rebaseCounts.absorbedPatchIds.has(patch.id)) {
3167
- this.lockManager.addPatch(patch);
3183
+ this.service.lockManager.addPatch(patch);
3168
3184
  }
3169
3185
  }
3170
- this.lockManager.save();
3171
- this.warnings.push(...this.lockManager.warnings);
3186
+ this.service.lockManager.save();
3187
+ this.service.warnings.push(...this.service.lockManager.warnings);
3172
3188
  if (newPatches.length > 0) {
3173
3189
  if (!options?.stageOnly) {
3174
3190
  const appliedCount = results.filter((r) => r.status === "applied").length;
3175
3191
  const buckets = computeCommitBuckets(results, rebaseCounts.absorbedPatchIds, newPatches);
3176
- await this.committer.commitReplay(appliedCount, newPatches, void 0, { buckets });
3192
+ await this.service.committer.commitReplay(appliedCount, newPatches, void 0, { buckets });
3177
3193
  } else {
3178
- await this.committer.stageAll();
3194
+ await this.service.committer.stageAll();
3179
3195
  }
3180
3196
  }
3181
- const warnings = [...detectorWarnings, ...this.warnings];
3182
- return this.buildReport(
3197
+ const warnings = [...detectorWarnings, ...this.service.warnings];
3198
+ return this.service.buildReport(
3183
3199
  "no-patches",
3184
3200
  newPatches,
3185
3201
  results,
@@ -3190,13 +3206,20 @@ var ReplayService = class {
3190
3206
  prep._prepared.revertedCount
3191
3207
  );
3192
3208
  }
3193
- async _prepareNormalRegeneration(options) {
3194
- const preLock = this.lockManager.read();
3209
+ };
3210
+
3211
+ // src/flows/NormalRegenerationFlow.ts
3212
+ var NormalRegenerationFlow = class {
3213
+ constructor(service) {
3214
+ this.service = service;
3215
+ }
3216
+ async prepare(options) {
3217
+ const preLock = this.service.lockManager.read();
3195
3218
  const previousGenerationSha = preLock.current_generation ?? null;
3196
3219
  if (options?.dryRun) {
3197
- const existingPatches2 = this.lockManager.getPatches();
3198
- const { patches: newPatches2, revertedPatchIds: dryRunReverted } = await this.detector.detectNewPatches();
3199
- const warnings = [...this.detector.warnings, ...this.warnings];
3220
+ const existingPatches2 = this.service.lockManager.getPatches();
3221
+ const { patches: newPatches2, revertedPatchIds: dryRunReverted } = await this.service.detector.detectNewPatches();
3222
+ const warnings = [...this.service.detector.warnings, ...this.service.warnings];
3200
3223
  const allPatches2 = [...existingPatches2, ...newPatches2];
3201
3224
  const preparedReport = {
3202
3225
  flow: "normal-regeneration",
@@ -3209,48 +3232,42 @@ var ReplayService = class {
3209
3232
  wouldApply: allPatches2,
3210
3233
  warnings: warnings.length > 0 ? warnings : void 0
3211
3234
  };
3212
- const headSha = await this.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
3235
+ const headSha = await this.service.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
3213
3236
  return {
3214
3237
  flow: "normal-regeneration",
3215
3238
  previousGenerationSha,
3216
3239
  currentGenerationSha: headSha,
3217
3240
  baseBranchHead: options.baseBranchHead ?? null,
3218
3241
  _prepared: {
3242
+ kind: "terminal",
3219
3243
  flow: "normal-regeneration",
3220
- terminal: true,
3221
- preparedReport,
3222
- patchesToApply: [],
3223
- newPatchIds: /* @__PURE__ */ new Set(),
3224
- detectorWarnings: [...this.detector.warnings],
3225
- revertedCount: dryRunReverted.length,
3226
- genSha: headSha,
3227
- optionsSnapshot: options
3244
+ preparedReport
3228
3245
  }
3229
3246
  };
3230
3247
  }
3231
- let existingPatches = this.lockManager.getPatches();
3248
+ let existingPatches = this.service.lockManager.getPatches();
3232
3249
  const preRebasePatchIds = new Set(existingPatches.map((p) => p.id));
3233
- const preRebaseCounts = await this.preGenerationRebase(existingPatches);
3234
- const postRebasePatchIds = new Set(this.lockManager.getPatches().map((p) => p.id));
3250
+ const preRebaseCounts = await this.service.preGenerationRebase(existingPatches);
3251
+ const postRebasePatchIds = new Set(this.service.lockManager.getPatches().map((p) => p.id));
3235
3252
  const removedByPreRebase = existingPatches.filter(
3236
3253
  (p) => preRebasePatchIds.has(p.id) && !postRebasePatchIds.has(p.id)
3237
3254
  );
3238
- existingPatches = this.lockManager.getPatches();
3255
+ existingPatches = this.service.lockManager.getPatches();
3239
3256
  const seenHashes = /* @__PURE__ */ new Map();
3240
3257
  for (const p of existingPatches) {
3241
3258
  const priorPatchId = seenHashes.get(p.content_hash);
3242
3259
  if (priorPatchId !== void 0) {
3243
- this.lockManager.removePatch(p.id);
3244
- this.warnings.push(
3260
+ this.service.lockManager.removePatch(p.id);
3261
+ this.service.warnings.push(
3245
3262
  `Consolidated patch ${p.id} (commit ${(p.original_commit || "<missing>").slice(0, 7)}) into prior patch ${priorPatchId}: byte-identical patch_content. Per-commit attribution preserved in git log; lockfile collapsed to avoid duplicate-apply corruption on next regen.`
3246
3263
  );
3247
3264
  } else {
3248
3265
  seenHashes.set(p.content_hash, p.id);
3249
3266
  }
3250
3267
  }
3251
- existingPatches = this.lockManager.getPatches();
3252
- let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
3253
- const detectorWarnings = [...this.detector.warnings];
3268
+ existingPatches = this.service.lockManager.getPatches();
3269
+ let { patches: newPatches, revertedPatchIds } = await this.service.detector.detectNewPatches();
3270
+ const detectorWarnings = [...this.service.detector.warnings];
3254
3271
  if (removedByPreRebase.length > 0) {
3255
3272
  const removedOriginalCommits = new Set(removedByPreRebase.map((p) => p.original_commit));
3256
3273
  const removedOriginalMessages = new Set(removedByPreRebase.map((p) => p.original_message));
@@ -3265,7 +3282,7 @@ var ReplayService = class {
3265
3282
  }
3266
3283
  for (const id of revertedPatchIds) {
3267
3284
  try {
3268
- this.lockManager.removePatch(id);
3285
+ this.service.lockManager.removePatch(id);
3269
3286
  } catch {
3270
3287
  }
3271
3288
  }
@@ -3277,18 +3294,18 @@ var ReplayService = class {
3277
3294
  generatorVersions: options.generatorVersions ?? {},
3278
3295
  baseBranchHead: options.baseBranchHead
3279
3296
  } : void 0;
3280
- await this.committer.commitGeneration("Update SDK", commitOpts);
3281
- await this.cleanupStaleConflictMarkers();
3282
- const genRecord = await this.committer.createGenerationRecord(commitOpts);
3283
- this.lockManager.addGeneration(genRecord);
3297
+ await this.service.committer.commitGeneration("Update SDK", commitOpts);
3298
+ await this.service.cleanupStaleConflictMarkers();
3299
+ const genRecord = await this.service.committer.createGenerationRecord(commitOpts);
3300
+ this.service.lockManager.addGeneration(genRecord);
3284
3301
  return {
3285
3302
  flow: "normal-regeneration",
3286
3303
  previousGenerationSha,
3287
3304
  currentGenerationSha: genRecord.commit_sha,
3288
3305
  baseBranchHead: options?.baseBranchHead ?? null,
3289
3306
  _prepared: {
3307
+ kind: "active",
3290
3308
  flow: "normal-regeneration",
3291
- terminal: false,
3292
3309
  patchesToApply: allPatches,
3293
3310
  newPatchIds: new Set(newPatches.map((p) => p.id)),
3294
3311
  preRebaseCounts,
@@ -3299,46 +3316,47 @@ var ReplayService = class {
3299
3316
  }
3300
3317
  };
3301
3318
  }
3302
- async _applyNormalRegeneration(prep, options) {
3319
+ async apply(prep, options) {
3320
+ assertActive(prep);
3303
3321
  const allPatches = prep._prepared.patchesToApply;
3304
3322
  const newPatchIds = prep._prepared.newPatchIds;
3305
3323
  const detectorWarnings = prep._prepared.detectorWarnings;
3306
3324
  const genSha = prep._prepared.genSha;
3307
- const results = await this.applicator.applyPatches(allPatches);
3308
- this._lastApplyResults = results;
3309
- this.recordDroppedContextLineWarnings(results);
3310
- this.revertConflictingFiles(results);
3325
+ const results = await this.service.applicator.applyPatches(allPatches);
3326
+ this.service._lastApplyResults = results;
3327
+ this.service.recordDroppedContextLineWarnings(results);
3328
+ this.service.revertConflictingFiles(results);
3311
3329
  for (const result of results) {
3312
3330
  if (result.status === "conflict") {
3313
3331
  result.patch.status = "unresolved";
3314
3332
  }
3315
3333
  }
3316
- const rebaseCounts = await this.rebasePatches(results, genSha);
3334
+ const rebaseCounts = await this.service.rebasePatches(results, genSha);
3317
3335
  const newPatches = allPatches.filter((p) => newPatchIds.has(p.id));
3318
3336
  for (const patch of newPatches) {
3319
3337
  if (!rebaseCounts.absorbedPatchIds.has(patch.id)) {
3320
- this.lockManager.addPatch(patch);
3338
+ this.service.lockManager.addPatch(patch);
3321
3339
  }
3322
3340
  }
3323
3341
  for (const result of results) {
3324
3342
  if (result.status === "conflict") {
3325
3343
  try {
3326
- this.lockManager.markPatchUnresolved(result.patch.id);
3344
+ this.service.lockManager.markPatchUnresolved(result.patch.id);
3327
3345
  } catch {
3328
3346
  }
3329
3347
  }
3330
3348
  }
3331
- this.lockManager.save();
3332
- this.warnings.push(...this.lockManager.warnings);
3349
+ this.service.lockManager.save();
3350
+ this.service.warnings.push(...this.service.lockManager.warnings);
3333
3351
  if (options?.stageOnly) {
3334
- await this.committer.stageAll();
3352
+ await this.service.committer.stageAll();
3335
3353
  } else {
3336
3354
  const appliedCount = results.filter((r) => r.status === "applied").length;
3337
3355
  const buckets = computeCommitBuckets(results, rebaseCounts.absorbedPatchIds, allPatches);
3338
- await this.committer.commitReplay(appliedCount, allPatches, void 0, { buckets });
3356
+ await this.service.committer.commitReplay(appliedCount, allPatches, void 0, { buckets });
3339
3357
  }
3340
- const warnings = [...detectorWarnings, ...this.warnings];
3341
- return this.buildReport(
3358
+ const warnings = [...detectorWarnings, ...this.service.warnings];
3359
+ return this.service.buildReport(
3342
3360
  "normal-regeneration",
3343
3361
  allPatches,
3344
3362
  results,
@@ -3349,10 +3367,213 @@ var ReplayService = class {
3349
3367
  prep._prepared.revertedCount
3350
3368
  );
3351
3369
  }
3370
+ };
3371
+
3372
+ // src/ReplayService.ts
3373
+ function assertActive(prep) {
3374
+ if (prep._prepared.kind !== "active") {
3375
+ throw new Error(
3376
+ `Flow.apply() called with terminal preparation (kind=${prep._prepared.kind}); terminal cases must be handled by applyPreparedReplay's dispatcher`
3377
+ );
3378
+ }
3379
+ }
3380
+ var ReplayService = class {
3381
+ /** @internal Used by Flow implementations in `src/flows/`. */
3382
+ git;
3383
+ /** @internal Used by Flow implementations in `src/flows/`. */
3384
+ detector;
3385
+ /** @internal Used by Flow implementations in `src/flows/`. */
3386
+ applicator;
3387
+ /** @internal Used by Flow implementations in `src/flows/`. */
3388
+ committer;
3389
+ /** @internal Used by Flow implementations in `src/flows/`. */
3390
+ lockManager;
3391
+ /** @internal Used by Flow implementations in `src/flows/`. */
3392
+ fileOwnership;
3393
+ /** @internal Used by Flow implementations in `src/flows/`. */
3394
+ outputDir;
3395
+ /** @internal Test-only. Captures the last ReplayResult[] returned from applicator.applyPatches(). */
3396
+ _lastApplyResults = [];
3397
+ /**
3398
+ * Service-level warnings accumulated during a single runReplay() call.
3399
+ * Merged with `detector.warnings` into `ReplayReport.warnings` by each flow handler.
3400
+ * Populated by `FileOwnership.resolveCanonical` when a patch's base generation tree
3401
+ * is unreachable (shallow clone / aggressive `git gc --prune`) and we fall back to a
3402
+ * conservative heuristic. Cleared at the top of `runReplay()`.
3403
+ *
3404
+ * @internal Used by Flow implementations in `src/flows/`.
3405
+ */
3406
+ warnings = [];
3407
+ /**
3408
+ * @internal Test-only accessor.
3409
+ * Returns the ReplayApplicator instance used for the last run. Tests use this
3410
+ * to inspect the inter-patch accumulator after runReplay() completes (invariant I2).
3411
+ */
3412
+ get applicatorRef() {
3413
+ return this.applicator;
3414
+ }
3415
+ /**
3416
+ * @internal Test-only accessor.
3417
+ * Returns the ReplayResult[] from the most recent applyPatches() call. Tests use
3418
+ * this to filter applied-vs-conflicted-vs-absorbed patches for invariant
3419
+ * assertions. Returns an empty array if no patches have been applied yet.
3420
+ */
3421
+ getLastResults() {
3422
+ return this._lastApplyResults;
3423
+ }
3424
+ constructor(outputDir, _config) {
3425
+ const git = new GitClient(outputDir);
3426
+ this.git = git;
3427
+ this.outputDir = outputDir;
3428
+ this.lockManager = new LockfileManager(outputDir);
3429
+ this.detector = new ReplayDetector(git, this.lockManager, outputDir);
3430
+ this.applicator = new ReplayApplicator(git, this.lockManager, outputDir);
3431
+ this.committer = new ReplayCommitter(git, outputDir);
3432
+ this.fileOwnership = new FileOwnership(git);
3433
+ }
3434
+ /**
3435
+ * Phase 1 of the two-phase replay flow. Runs preGenerationRebase (when applicable),
3436
+ * detects new patches, and creates the `[fern-generated]` commit. Leaves HEAD at
3437
+ * the generation commit (or unchanged for dry-run).
3438
+ *
3439
+ * Does NOT apply patches — the returned handle must be passed to
3440
+ * `applyPreparedReplay()` to complete the run. No disk writes to the lockfile
3441
+ * happen here; lockfile persistence is deferred to phase 2.
3442
+ *
3443
+ * Callers may land additional commits on HEAD between `prepareReplay` and
3444
+ * `applyPreparedReplay` (e.g., `[fern-autoversion]`).
3445
+ */
3446
+ async prepareReplay(options) {
3447
+ this.warnings = [];
3448
+ this.detector.warnings.length = 0;
3449
+ return this.selectFlow(options).prepare(options);
3450
+ }
3451
+ /**
3452
+ * Phase 2 of the two-phase replay flow. Applies patches, rebases them against
3453
+ * the generation, saves the lockfile, and commits `[fern-replay]` (or stages
3454
+ * under `stageOnly`). Operates on HEAD at call time — mid-phase commits are
3455
+ * respected.
3456
+ *
3457
+ * For terminal handles (dry-run, first-gen has no patch work, skip-app does
3458
+ * its own post-commit logic), this returns the precomputed report.
3459
+ */
3460
+ async applyPreparedReplay(prep, options) {
3461
+ if (prep._prepared.kind === "terminal") {
3462
+ return prep._prepared.preparedReport;
3463
+ }
3464
+ return this.flowForKind(prep._prepared.flow).apply(prep, options);
3465
+ }
3466
+ /**
3467
+ * Construct the flow for the current run. `selectFlow` is called from
3468
+ * `prepareReplay` and decides based on options + lockfile state.
3469
+ */
3470
+ selectFlow(options) {
3471
+ if (options?.skipApplication) return new SkipApplicationFlow(this);
3472
+ return this.flowForKind(this.determineFlow());
3473
+ }
3474
+ /**
3475
+ * Construct a flow given its kind. Used by `applyPreparedReplay` to
3476
+ * route to the same kind that was selected in phase 1.
3477
+ */
3478
+ flowForKind(kind) {
3479
+ switch (kind) {
3480
+ case "first-generation":
3481
+ return new FirstGenerationFlow(this);
3482
+ case "no-patches":
3483
+ return new NoPatchesFlow(this);
3484
+ case "normal-regeneration":
3485
+ return new NormalRegenerationFlow(this);
3486
+ case "skip-application":
3487
+ return new SkipApplicationFlow(this);
3488
+ }
3489
+ }
3490
+ /**
3491
+ * Backwards-compatible atomic entry point. Composes `prepareReplay` + `applyPreparedReplay`.
3492
+ * Behavior is byte-identical to the pre-split implementation for all existing callers.
3493
+ */
3494
+ async runReplay(options) {
3495
+ const prep = await this.prepareReplay(options);
3496
+ return this.applyPreparedReplay(prep, options);
3497
+ }
3498
+ /**
3499
+ * Sync the lockfile after a divergent PR was squash-merged.
3500
+ * Call this BEFORE runReplay() when the CLI detects a merged divergent PR.
3501
+ *
3502
+ * After updating the generation record, re-detects customer patches via
3503
+ * tree diff between the generation tag (pure generation tree) and HEAD
3504
+ * (which includes customer customizations after squash merge). This ensures
3505
+ * patches survive the squash merge → regenerate cycle even when the lockfile
3506
+ * was restored from the base branch during conflict PR creation.
3507
+ */
3508
+ async syncFromDivergentMerge(generationCommitSha, options) {
3509
+ const treeHash = await this.git.getTreeHash(generationCommitSha);
3510
+ const timestamp = (await this.git.exec(["log", "-1", "--format=%aI", generationCommitSha])).trim();
3511
+ const record = {
3512
+ commit_sha: generationCommitSha,
3513
+ tree_hash: treeHash,
3514
+ timestamp,
3515
+ cli_version: options?.cliVersion ?? "unknown",
3516
+ generator_versions: options?.generatorVersions ?? {},
3517
+ base_branch_head: options?.baseBranchHead
3518
+ };
3519
+ let resolvedPatches;
3520
+ if (!this.lockManager.exists()) {
3521
+ this.lockManager.initializeInMemory(record);
3522
+ } else {
3523
+ this.lockManager.read();
3524
+ const unresolvedPatches = [
3525
+ ...this.lockManager.getUnresolvedPatches(),
3526
+ ...this.lockManager.getResolvingPatches()
3527
+ ];
3528
+ resolvedPatches = this.lockManager.getPatches().filter((p) => p.status == null);
3529
+ this.lockManager.addGeneration(record);
3530
+ this.lockManager.clearPatches();
3531
+ for (const patch of unresolvedPatches) {
3532
+ this.lockManager.addPatch(patch);
3533
+ }
3534
+ }
3535
+ try {
3536
+ const { patches: redetectedPatches } = await this.detector.detectNewPatches();
3537
+ if (redetectedPatches.length > 0) {
3538
+ const redetectedFiles = new Set(redetectedPatches.flatMap((p) => p.files));
3539
+ const currentPatches = this.lockManager.getPatches();
3540
+ for (const patch of currentPatches) {
3541
+ if (patch.status != null && patch.files.some((f) => redetectedFiles.has(f))) {
3542
+ this.lockManager.removePatch(patch.id);
3543
+ }
3544
+ }
3545
+ for (const patch of redetectedPatches) {
3546
+ this.lockManager.addPatch(patch);
3547
+ }
3548
+ }
3549
+ } catch (error) {
3550
+ for (const patch of resolvedPatches ?? []) {
3551
+ this.lockManager.addPatch(patch);
3552
+ }
3553
+ this.detector.warnings.push(
3554
+ `Patch re-detection failed after divergent merge sync. ${(resolvedPatches ?? []).length} previously resolved patch(es) preserved. Error: ${error instanceof Error ? error.message : String(error)}`
3555
+ );
3556
+ }
3557
+ this.lockManager.save();
3558
+ this.detector.warnings.push(...this.lockManager.warnings);
3559
+ }
3560
+ determineFlow() {
3561
+ try {
3562
+ const lock = this.lockManager.read();
3563
+ return lock.patches.length === 0 ? "no-patches" : "normal-regeneration";
3564
+ } catch (error) {
3565
+ if (error instanceof LockfileNotFoundError) {
3566
+ return "first-generation";
3567
+ }
3568
+ throw error;
3569
+ }
3570
+ }
3352
3571
  /**
3353
3572
  * Rebase cleanly applied patches so they are relative to the current generation.
3354
3573
  * This prevents recurring conflicts on subsequent regenerations.
3355
3574
  * Returns the number of patches rebased.
3575
+ *
3576
+ * @internal Used by Flow implementations in `src/flows/`.
3356
3577
  */
3357
3578
  async rebasePatches(results, currentGenSha) {
3358
3579
  let absorbed = 0;
@@ -3362,6 +3583,7 @@ var ReplayService = class {
3362
3583
  const seenContentHashes = /* @__PURE__ */ new Map();
3363
3584
  const absorbedPatchIds = /* @__PURE__ */ new Set();
3364
3585
  const fernignorePatterns = this.readFernignorePatterns();
3586
+ const warnedGens = /* @__PURE__ */ new Set();
3365
3587
  for (const result of results) {
3366
3588
  if (result.resolvedFiles && Object.keys(result.resolvedFiles).length > 0) {
3367
3589
  const patch = result.patch;
@@ -3406,21 +3628,20 @@ var ReplayService = class {
3406
3628
  }
3407
3629
  }
3408
3630
  const isUserOwnedPerFile = await Promise.all(
3409
- patch.files.map(async (file) => {
3410
- if (file === ".fernignore") return true;
3411
- if (fernignorePatterns.some((p) => file === p || (0, import_minimatch2.minimatch)(file, p))) {
3412
- return true;
3413
- }
3414
- if (patch.user_owned) return true;
3415
- if (this.fileIsCreationInPatchContent(patch.patch_content, originalByResolved[file] ?? file)) {
3416
- return true;
3417
- }
3418
- const content = await this.git.showFile(currentGenSha, file);
3419
- return content === null;
3420
- })
3631
+ patch.files.map(
3632
+ (file) => this.fileOwnership.resolveCanonical({
3633
+ file,
3634
+ originalFileName: originalByResolved[file],
3635
+ patch,
3636
+ currentGenSha,
3637
+ fernignorePatterns,
3638
+ warnedGens,
3639
+ warnings: this.warnings
3640
+ })
3641
+ )
3421
3642
  );
3422
3643
  const hasProtectedFiles = patch.files.some(
3423
- (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || (0, import_minimatch2.minimatch)(file, p))
3644
+ (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || (0, import_minimatch3.minimatch)(file, p))
3424
3645
  );
3425
3646
  const allUserOwned = isUserOwnedPerFile.every(Boolean);
3426
3647
  if (allUserOwned && patch.files.length > 0) {
@@ -3544,7 +3765,7 @@ var ReplayService = class {
3544
3765
  for (const file of files) {
3545
3766
  if (isBinaryFile(file)) continue;
3546
3767
  try {
3547
- const content = await (0, import_promises4.readFile)((0, import_node_path5.join)(this.outputDir, file), "utf-8");
3768
+ const content = await (0, import_promises6.readFile)((0, import_node_path8.join)(this.outputDir, file), "utf-8");
3548
3769
  snapshot[file] = content;
3549
3770
  } catch {
3550
3771
  }
@@ -3552,10 +3773,16 @@ var ReplayService = class {
3552
3773
  return snapshot;
3553
3774
  }
3554
3775
  /**
3555
- * FER-10203: returns true when a `[fern-autoversion]` commit between
3556
- * `fromSha` and `toSha` touched any of the patch's `files`. Re-classifies
3557
- * each grep match via `isGenerationCommit` so a customer commit that
3558
- * merely quotes the marker in its body doesn't false-positive.
3776
+ * Detects autoversion contamination — returns true when a
3777
+ * `[fern-autoversion]` commit between `fromSha` and `toSha` touched any
3778
+ * of the patch's `files`. Re-classifies each grep match via
3779
+ * `isGenerationCommit` so a customer commit that merely quotes the
3780
+ * marker in its body doesn't false-positive.
3781
+ *
3782
+ * Used by preGenerationRebase to skip the customer-content rebuild for
3783
+ * patches whose files were last touched by an autoversion step — a
3784
+ * naive `git diff currentGen HEAD` there would capture the
3785
+ * placeholder→semver swap as customer customization.
3559
3786
  */
3560
3787
  async hasAutoversionContamination(fromSha, toSha, files) {
3561
3788
  if (files.length === 0) return false;
@@ -3584,10 +3811,15 @@ var ReplayService = class {
3584
3811
  return false;
3585
3812
  }
3586
3813
  /**
3587
- * FER-10203: rebuild patch_content from `theirs_snapshot` (captured at
3588
- * detection time, predates pipeline content) rather than a HEAD-relative
3589
- * diff. Returns `null` when snapshot is missing or doesn't cover every
3590
- * file in `patch.files` — caller leaves the patch unchanged.
3814
+ * Rebuild patch_content from `theirs_snapshot` (captured at detection
3815
+ * time, predates pipeline content) rather than a HEAD-relative diff.
3816
+ * Returns `null` when snapshot is missing or doesn't cover every file
3817
+ * in `patch.files` — caller leaves the patch unchanged.
3818
+ *
3819
+ * Used as the autoversion-contamination escape hatch: when the
3820
+ * HEAD-relative diff would capture pipeline content (autoversion's
3821
+ * placeholder swap) as customer customization, the snapshot path
3822
+ * preserves the customer's actual intent.
3591
3823
  */
3592
3824
  async computeSnapshotBasedPatchDiff(patch, currentGen) {
3593
3825
  if (!patch.theirs_snapshot) return null;
@@ -3635,7 +3867,7 @@ var ReplayService = class {
3635
3867
  * **Skips patches sharing files with other patches.** HEAD's content
3636
3868
  * for a file shared by N patches is cumulative across all of them, so
3637
3869
  * a HEAD-fallback would falsely encode other patches' contributions
3638
- * into one patch's snapshot — breaking FER-9525 cross-patch isolation
3870
+ * into one patch's snapshot — breaking cross-patch isolation
3639
3871
  * if the snapshot fallback later fires. Per-patch snapshots only
3640
3872
  * make sense when the patch owns its file. Multi-patch-same-file
3641
3873
  * legacy lockfiles keep using line-anchored reconstruction (the
@@ -3697,72 +3929,6 @@ var ReplayService = class {
3697
3929
  );
3698
3930
  }
3699
3931
  }
3700
- /**
3701
- * Determine whether `file` is user-owned (never produced by the generator).
3702
- *
3703
- * Resolution order (first match wins):
3704
- * 1. `patch.user_owned === true` — authoritative, persisted across cycles.
3705
- * 2. `.fernignore` itself, or file matches a .fernignore pattern.
3706
- * 3. Legacy-safe signal: `patch_content` shows this file as a `--- /dev/null`
3707
- * creation. Works for patches that predate the `user_owned` field or
3708
- * whose flag was lost. Immune to rename tricks — relies on raw patch text.
3709
- * 4. Tree-based check against `patch.base_generation` when it is reachable
3710
- * AND distinct from `currentGenSha`. If the base tree is unreachable
3711
- * (shallow clone / aggressive `git gc --prune`), emit a one-time
3712
- * customer-actionable warning (deduplicated via warnedGens) and
3713
- * conservatively treat as user-owned — strictly safer than silent
3714
- * absorption (FER-9809).
3715
- * 5. Final fallback — check `currentGenSha` when there is no usable base
3716
- * (legacy one-gen lockfile). Preserves pre-fix behavior for that edge.
3717
- *
3718
- * `originalFileName` is the pre-rename path when the generator moved the
3719
- * file between the patch's base and the current generation. Tree lookups
3720
- * at ancestor generations use the original path — that's where the
3721
- * generator produced the content.
3722
- */
3723
- async isFileUserOwned(file, patch, currentGenSha, fernignorePatterns, warnedGens, originalFileName) {
3724
- if (patch.user_owned) return true;
3725
- if (file === ".fernignore") return true;
3726
- if (fernignorePatterns.some((p) => file === p || (0, import_minimatch2.minimatch)(file, p))) return true;
3727
- if (this.fileIsCreationInPatchContent(patch.patch_content, originalFileName ?? file)) {
3728
- return true;
3729
- }
3730
- const base = patch.base_generation;
3731
- if (base && base !== currentGenSha) {
3732
- const reachable = await this.git.commitExists(base) || await this.git.treeExists(base);
3733
- if (!reachable) {
3734
- if (!warnedGens.has(base)) {
3735
- warnedGens.add(base);
3736
- this.warnings.push(
3737
- `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.`
3738
- );
3739
- }
3740
- return true;
3741
- }
3742
- return await this.git.showFile(base, originalFileName ?? file) === null;
3743
- }
3744
- return await this.git.showFile(currentGenSha, originalFileName ?? file) === null;
3745
- }
3746
- /**
3747
- * Returns true if `file`'s diff section in `patchContent` starts with
3748
- * `--- /dev/null`, meaning this file was introduced by the patch (user
3749
- * creation). Used as a legacy-safe fallback when `patch.user_owned` is
3750
- * absent or cleared.
3751
- */
3752
- fileIsCreationInPatchContent(patchContent, file) {
3753
- const lines = patchContent.split("\n");
3754
- let inFileSection = false;
3755
- for (const line of lines) {
3756
- if (line.startsWith("diff --git ")) {
3757
- inFileSection = line.includes(` b/${file}`) || line.endsWith(` b/${file}`);
3758
- continue;
3759
- }
3760
- if (inFileSection && line.startsWith("--- ")) {
3761
- return line === "--- /dev/null";
3762
- }
3763
- }
3764
- return false;
3765
- }
3766
3932
  /**
3767
3933
  * For conflict patches with mixed results (some files merged, some conflicted),
3768
3934
  * check if the cleanly merged files were absorbed by the generator (empty diff).
@@ -3781,7 +3947,15 @@ var ReplayService = class {
3781
3947
  const warnedGens = /* @__PURE__ */ new Set();
3782
3948
  const generatorCleanFiles = [];
3783
3949
  for (const file of cleanFiles) {
3784
- if (await this.isFileUserOwned(file, patch, currentGenSha, fernignorePatterns, warnedGens)) {
3950
+ const userOwned = await this.fileOwnership.resolveCanonical({
3951
+ file,
3952
+ patch,
3953
+ currentGenSha,
3954
+ fernignorePatterns,
3955
+ warnedGens,
3956
+ warnings: this.warnings
3957
+ });
3958
+ if (userOwned) {
3785
3959
  continue;
3786
3960
  }
3787
3961
  generatorCleanFiles.push(file);
@@ -3810,6 +3984,7 @@ var ReplayService = class {
3810
3984
  * Pre-generation rebase: update patches using the customer's current state.
3811
3985
  * Called BEFORE commitGeneration() while HEAD has customer code.
3812
3986
  */
3987
+ /** @internal Used by Flow implementations in `src/flows/`. */
3813
3988
  async preGenerationRebase(patches) {
3814
3989
  const lock = this.lockManager.read();
3815
3990
  const currentGen = lock.current_generation;
@@ -3848,7 +4023,15 @@ var ReplayService = class {
3848
4023
  if (patch.user_owned) return true;
3849
4024
  if (patch.files.length === 0) return false;
3850
4025
  for (const file of patch.files) {
3851
- if (!await this.isFileUserOwned(file, patch, currentGen, fernignorePatterns, warnedGens)) {
4026
+ const userOwned = await this.fileOwnership.resolveCanonical({
4027
+ file,
4028
+ patch,
4029
+ currentGenSha: currentGen,
4030
+ fernignorePatterns,
4031
+ warnedGens,
4032
+ warnings: this.warnings
4033
+ });
4034
+ if (!userOwned) {
3852
4035
  return false;
3853
4036
  }
3854
4037
  }
@@ -3898,7 +4081,7 @@ var ReplayService = class {
3898
4081
  );
3899
4082
  if (snapHasStaleMarkers) continue;
3900
4083
  const isProtectedSnap = patch.files.some(
3901
- (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || (0, import_minimatch2.minimatch)(file, p))
4084
+ (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || (0, import_minimatch3.minimatch)(file, p))
3902
4085
  );
3903
4086
  if (isProtectedSnap) continue;
3904
4087
  const snapHash = this.detector.computeContentHash(snapshotDiff);
@@ -3933,7 +4116,7 @@ var ReplayService = class {
3933
4116
  continue;
3934
4117
  }
3935
4118
  const isProtected = patch.files.some(
3936
- (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || (0, import_minimatch2.minimatch)(file, p))
4119
+ (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || (0, import_minimatch3.minimatch)(file, p))
3937
4120
  );
3938
4121
  if (isProtected) {
3939
4122
  continue;
@@ -4040,6 +4223,7 @@ var ReplayService = class {
4040
4223
  * relied on them. This is the "surface or preserve, never silently drop"
4041
4224
  * contract for legitimate-deletion cases.
4042
4225
  */
4226
+ /** @internal Used by Flow implementations in `src/flows/`. */
4043
4227
  recordDroppedContextLineWarnings(results) {
4044
4228
  const PREVIEW_COUNT = 5;
4045
4229
  for (const result of results) {
@@ -4062,12 +4246,13 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
4062
4246
  * After applyPatches(), strip conflict markers from conflicting files
4063
4247
  * so only clean content is committed. Keeps the Generated (OURS) side.
4064
4248
  */
4249
+ /** @internal Used by Flow implementations in `src/flows/`. */
4065
4250
  revertConflictingFiles(results) {
4066
4251
  for (const result of results) {
4067
4252
  if (result.status !== "conflict" || !result.fileResults) continue;
4068
4253
  for (const fileResult of result.fileResults) {
4069
4254
  if (fileResult.status !== "conflict") continue;
4070
- const filePath = (0, import_node_path5.join)(this.outputDir, fileResult.file);
4255
+ const filePath = (0, import_node_path8.join)(this.outputDir, fileResult.file);
4071
4256
  try {
4072
4257
  const content = (0, import_node_fs2.readFileSync)(filePath, "utf-8");
4073
4258
  const stripped = stripConflictMarkers(content);
@@ -4083,13 +4268,14 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
4083
4268
  * Restores files to their clean generated state from HEAD.
4084
4269
  * Skips .fernignore-protected files to prevent overwriting user content.
4085
4270
  */
4271
+ /** @internal Used by Flow implementations in `src/flows/`. */
4086
4272
  async cleanupStaleConflictMarkers() {
4087
4273
  const markerFiles = await this.git.exec(["grep", "-l", "<<<<<<< Generated", "--", "."]).catch(() => "");
4088
4274
  const files = markerFiles.trim().split("\n").filter(Boolean);
4089
4275
  if (files.length === 0) return;
4090
4276
  const fernignorePatterns = this.readFernignorePatterns();
4091
4277
  for (const file of files) {
4092
- if (fernignorePatterns.some((pattern) => (0, import_minimatch2.minimatch)(file, pattern))) continue;
4278
+ if (fernignorePatterns.some((pattern) => (0, import_minimatch3.minimatch)(file, pattern))) continue;
4093
4279
  try {
4094
4280
  await this.git.exec(["checkout", "HEAD", "--", file]);
4095
4281
  } catch {
@@ -4097,10 +4283,11 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
4097
4283
  }
4098
4284
  }
4099
4285
  readFernignorePatterns() {
4100
- const fernignorePath = (0, import_node_path5.join)(this.outputDir, ".fernignore");
4286
+ const fernignorePath = (0, import_node_path8.join)(this.outputDir, ".fernignore");
4101
4287
  if (!(0, import_node_fs2.existsSync)(fernignorePath)) return [];
4102
4288
  return (0, import_node_fs2.readFileSync)(fernignorePath, "utf-8").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
4103
4289
  }
4290
+ /** @internal Used by Flow implementations in `src/flows/`. */
4104
4291
  buildReport(flow, patches, results, options, warnings, rebaseCounts, preRebaseCounts, patchesReverted) {
4105
4292
  const conflictResults = results.filter((r) => r.status === "conflict");
4106
4293
  const conflictDetails = conflictResults.map((r) => {
@@ -4176,8 +4363,8 @@ function computeCommitBuckets(results, absorbedPatchIds, patchesPassedToCommit)
4176
4363
  // src/FernignoreMigrator.ts
4177
4364
  var import_node_crypto2 = require("crypto");
4178
4365
  var import_node_fs3 = require("fs");
4179
- var import_node_path6 = require("path");
4180
- var import_minimatch3 = require("minimatch");
4366
+ var import_node_path9 = require("path");
4367
+ var import_minimatch4 = require("minimatch");
4181
4368
  var import_yaml2 = require("yaml");
4182
4369
  var FernignoreMigrator = class {
4183
4370
  git;
@@ -4189,10 +4376,10 @@ var FernignoreMigrator = class {
4189
4376
  this.outputDir = outputDir;
4190
4377
  }
4191
4378
  fernignoreExists() {
4192
- return (0, import_node_fs3.existsSync)((0, import_node_path6.join)(this.outputDir, ".fernignore"));
4379
+ return (0, import_node_fs3.existsSync)((0, import_node_path9.join)(this.outputDir, ".fernignore"));
4193
4380
  }
4194
4381
  readFernignorePatterns() {
4195
- const fernignorePath = (0, import_node_path6.join)(this.outputDir, ".fernignore");
4382
+ const fernignorePath = (0, import_node_path9.join)(this.outputDir, ".fernignore");
4196
4383
  if (!(0, import_node_fs3.existsSync)(fernignorePath)) {
4197
4384
  return [];
4198
4385
  }
@@ -4209,7 +4396,7 @@ var FernignoreMigrator = class {
4209
4396
  const commitsOnly = [];
4210
4397
  const syntheticPatches = [];
4211
4398
  for (const pattern of patterns) {
4212
- const matchingPatch = patches.find((p) => p.files.some((f) => (0, import_minimatch3.minimatch)(f, pattern) || f === pattern));
4399
+ const matchingPatch = patches.find((p) => p.files.some((f) => (0, import_minimatch4.minimatch)(f, pattern) || f === pattern));
4213
4400
  if (matchingPatch) {
4214
4401
  trackedByBoth.push({
4215
4402
  file: pattern,
@@ -4282,7 +4469,7 @@ var FernignoreMigrator = class {
4282
4469
  fernignoreOnly.push(...remainingFernignoreOnly);
4283
4470
  }
4284
4471
  for (const patch of patches) {
4285
- const hasUnprotectedFiles = patch.files.some((f) => !patterns.some((p) => (0, import_minimatch3.minimatch)(f, p) || f === p));
4472
+ const hasUnprotectedFiles = patch.files.some((f) => !patterns.some((p) => (0, import_minimatch4.minimatch)(f, p) || f === p));
4286
4473
  if (hasUnprotectedFiles) {
4287
4474
  commitsOnly.push(patch);
4288
4475
  }
@@ -4294,14 +4481,14 @@ var FernignoreMigrator = class {
4294
4481
  const results = [];
4295
4482
  for (const pattern of patterns) {
4296
4483
  const matching = allFiles.filter(
4297
- (f) => (0, import_minimatch3.minimatch)(f, pattern) || f === pattern || f.startsWith(pattern + "/")
4484
+ (f) => (0, import_minimatch4.minimatch)(f, pattern) || f === pattern || f.startsWith(pattern + "/")
4298
4485
  );
4299
4486
  results.push({ pattern, files: matching.length > 0 ? matching : [pattern] });
4300
4487
  }
4301
4488
  return results;
4302
4489
  }
4303
4490
  readFileContent(filePath) {
4304
- const fullPath = (0, import_node_path6.join)(this.outputDir, filePath);
4491
+ const fullPath = (0, import_node_path9.join)(this.outputDir, filePath);
4305
4492
  if (!(0, import_node_fs3.existsSync)(fullPath)) {
4306
4493
  return null;
4307
4494
  }
@@ -4366,7 +4553,7 @@ var FernignoreMigrator = class {
4366
4553
  };
4367
4554
  }
4368
4555
  movePatternsToReplayYml(patterns) {
4369
- const replayYmlPath = (0, import_node_path6.join)(this.outputDir, ".fern", "replay.yml");
4556
+ const replayYmlPath = (0, import_node_path9.join)(this.outputDir, ".fern", "replay.yml");
4370
4557
  let config = {};
4371
4558
  if ((0, import_node_fs3.existsSync)(replayYmlPath)) {
4372
4559
  const content = (0, import_node_fs3.readFileSync)(replayYmlPath, "utf-8");
@@ -4375,7 +4562,7 @@ var FernignoreMigrator = class {
4375
4562
  const existing = config.exclude ?? [];
4376
4563
  const merged = [.../* @__PURE__ */ new Set([...existing, ...patterns])];
4377
4564
  config.exclude = merged;
4378
- const dir = (0, import_node_path6.dirname)(replayYmlPath);
4565
+ const dir = (0, import_node_path9.dirname)(replayYmlPath);
4379
4566
  if (!(0, import_node_fs3.existsSync)(dir)) {
4380
4567
  (0, import_node_fs3.mkdirSync)(dir, { recursive: true });
4381
4568
  }
@@ -4386,7 +4573,7 @@ var FernignoreMigrator = class {
4386
4573
  // src/commands/bootstrap.ts
4387
4574
  var import_node_crypto3 = require("crypto");
4388
4575
  var import_node_fs4 = require("fs");
4389
- var import_node_path7 = require("path");
4576
+ var import_node_path10 = require("path");
4390
4577
  init_GitClient();
4391
4578
  async function bootstrap(outputDir, options) {
4392
4579
  const git = new GitClient(outputDir);
@@ -4553,7 +4740,7 @@ function parseGitLog(log) {
4553
4740
  }
4554
4741
  var REPLAY_FERNIGNORE_ENTRIES = [".fern/replay.lock", ".fern/replay.yml", ".gitattributes"];
4555
4742
  function ensureFernignoreEntries(outputDir) {
4556
- const fernignorePath = (0, import_node_path7.join)(outputDir, ".fernignore");
4743
+ const fernignorePath = (0, import_node_path10.join)(outputDir, ".fernignore");
4557
4744
  let content = "";
4558
4745
  if ((0, import_node_fs4.existsSync)(fernignorePath)) {
4559
4746
  content = (0, import_node_fs4.readFileSync)(fernignorePath, "utf-8");
@@ -4577,7 +4764,7 @@ function ensureFernignoreEntries(outputDir) {
4577
4764
  }
4578
4765
  var GITATTRIBUTES_ENTRIES = [".fern/replay.lock linguist-generated=true"];
4579
4766
  function ensureGitattributesEntries(outputDir) {
4580
- const gitattributesPath = (0, import_node_path7.join)(outputDir, ".gitattributes");
4767
+ const gitattributesPath = (0, import_node_path10.join)(outputDir, ".gitattributes");
4581
4768
  let content = "";
4582
4769
  if ((0, import_node_fs4.existsSync)(gitattributesPath)) {
4583
4770
  content = (0, import_node_fs4.readFileSync)(gitattributesPath, "utf-8");
@@ -4607,7 +4794,7 @@ function computeContentHash(patchContent) {
4607
4794
  }
4608
4795
 
4609
4796
  // src/commands/forget.ts
4610
- var import_minimatch4 = require("minimatch");
4797
+ var import_minimatch5 = require("minimatch");
4611
4798
  function parseDiffStat(patchContent) {
4612
4799
  let additions = 0;
4613
4800
  let deletions = 0;
@@ -4637,7 +4824,7 @@ function toMatchedPatch(patch) {
4637
4824
  }
4638
4825
  function matchesPatch(patch, pattern) {
4639
4826
  const fileMatch = patch.files.some(
4640
- (file) => file === pattern || (0, import_minimatch4.minimatch)(file, pattern)
4827
+ (file) => file === pattern || (0, import_minimatch5.minimatch)(file, pattern)
4641
4828
  );
4642
4829
  if (fileMatch) return true;
4643
4830
  return patch.original_message.toLowerCase().includes(pattern.toLowerCase());
@@ -4775,8 +4962,8 @@ function reset(outputDir, options) {
4775
4962
  }
4776
4963
 
4777
4964
  // src/commands/resolve.ts
4778
- var import_promises5 = require("fs/promises");
4779
- var import_node_path8 = require("path");
4965
+ var import_promises7 = require("fs/promises");
4966
+ var import_node_path11 = require("path");
4780
4967
  init_GitClient();
4781
4968
  async function resolve(outputDir, options) {
4782
4969
  const lockManager = new LockfileManager(outputDir);
@@ -4825,7 +5012,7 @@ async function resolve(outputDir, options) {
4825
5012
  const result = await computePerPatchDiffWithFallback(
4826
5013
  patch,
4827
5014
  (f) => git.showFile(currentGen, f),
4828
- (f) => (0, import_promises5.readFile)((0, import_node_path8.join)(outputDir, f), "utf-8").catch(() => null),
5015
+ (f) => (0, import_promises7.readFile)((0, import_node_path11.join)(outputDir, f), "utf-8").catch(() => null),
4829
5016
  (files) => git.exec(["diff", currentGen, "--", ...files]).catch(() => null)
4830
5017
  );
4831
5018
  if (result === null) {
@@ -4881,7 +5068,7 @@ async function resolve(outputDir, options) {
4881
5068
  const theirsSnapshot = {};
4882
5069
  for (const file of filesForSnapshot) {
4883
5070
  if (isBinaryFile(file)) continue;
4884
- const content = await (0, import_promises5.readFile)((0, import_node_path8.join)(outputDir, file), "utf-8").catch(() => null);
5071
+ const content = await (0, import_promises7.readFile)((0, import_node_path11.join)(outputDir, file), "utf-8").catch(() => null);
4885
5072
  if (content != null) {
4886
5073
  theirsSnapshot[file] = content;
4887
5074
  }