@fern-api/replay 0.15.0 → 0.15.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -7
- package/dist/cli.cjs +3437 -3311
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +1932 -1807
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +198 -145
- package/dist/index.d.ts +198 -145
- package/dist/index.js +1937 -1812
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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,
|
|
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,
|
|
436
|
-
await (0,
|
|
437
|
-
await (0,
|
|
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,
|
|
441
|
+
return await (0, import_promises5.readFile)(tempFilePath, "utf-8");
|
|
442
442
|
} catch {
|
|
443
443
|
return null;
|
|
444
444
|
} finally {
|
|
445
|
-
await (0,
|
|
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
|
|
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
|
-
|
|
471
|
+
import_promises5 = require("fs/promises");
|
|
472
472
|
import_node_os3 = require("os");
|
|
473
|
-
|
|
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/
|
|
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,1610 @@ var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
|
1038
821
|
".pyo",
|
|
1039
822
|
".DS_Store"
|
|
1040
823
|
]);
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
}
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
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
|
-
*
|
|
1113
|
-
*
|
|
1114
|
-
*
|
|
1115
|
-
*
|
|
1116
|
-
*
|
|
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
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
1127
|
-
*
|
|
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
|
-
* `
|
|
1130
|
-
*
|
|
1131
|
-
*
|
|
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
|
|
1143
|
-
const
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
951
|
+
async detectPatchesInRange(rangeStart, lastGen, lock) {
|
|
952
|
+
const log = await this.git.exec([
|
|
953
|
+
"log",
|
|
954
|
+
"--no-merges",
|
|
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
|
-
|
|
1153
|
-
|
|
1154
|
-
);
|
|
1155
|
-
const
|
|
1156
|
-
|
|
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
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
970
|
+
if (lock.patches.find((p) => p.original_commit === commit.sha)) {
|
|
971
|
+
continue;
|
|
972
|
+
}
|
|
973
|
+
const parents = await this.git.getCommitParents(commit.sha);
|
|
974
|
+
let patchContent;
|
|
975
|
+
try {
|
|
976
|
+
patchContent = await this.git.formatPatch(commit.sha);
|
|
977
|
+
} catch {
|
|
978
|
+
this.warnings.push(
|
|
979
|
+
`Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
|
|
980
|
+
);
|
|
981
|
+
continue;
|
|
982
|
+
}
|
|
983
|
+
let contentHash = this.computeContentHash(patchContent);
|
|
984
|
+
if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
|
|
985
|
+
continue;
|
|
986
|
+
}
|
|
987
|
+
const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
|
|
988
|
+
const { files, sensitiveFiles } = filterInfrastructureAndSensitiveFiles(filesOutput);
|
|
989
|
+
if (sensitiveFiles.length > 0) {
|
|
990
|
+
this.warnings.push(
|
|
991
|
+
`Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
|
|
992
|
+
);
|
|
993
|
+
if (files.length > 0) {
|
|
994
|
+
const parentSha = parents[0];
|
|
995
|
+
if (parentSha) {
|
|
1186
996
|
try {
|
|
1187
|
-
|
|
1188
|
-
const stripped = stripConflictMarkers(content);
|
|
1189
|
-
await (0, import_promises.writeFile)(filePath, stripped);
|
|
997
|
+
patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
|
|
1190
998
|
} catch {
|
|
999
|
+
continue;
|
|
1191
1000
|
}
|
|
1001
|
+
if (!patchContent.trim()) continue;
|
|
1002
|
+
contentHash = this.computeContentHash(patchContent);
|
|
1192
1003
|
}
|
|
1193
1004
|
}
|
|
1194
1005
|
}
|
|
1195
|
-
|
|
1196
|
-
|
|
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
|
-
}
|
|
1241
|
-
}
|
|
1242
|
-
} finally {
|
|
1243
|
-
await (0, import_promises.rm)(tempDir, { recursive: true }).catch(() => {
|
|
1244
|
-
});
|
|
1245
|
-
}
|
|
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;
|
|
1006
|
+
if (files.length === 0) {
|
|
1007
|
+
continue;
|
|
1265
1008
|
}
|
|
1009
|
+
const theirsSnapshot = await capturePatchSnapshot(this.git, files, commit.sha);
|
|
1010
|
+
newPatches.push({
|
|
1011
|
+
id: `patch-${commit.sha.slice(0, 8)}`,
|
|
1012
|
+
content_hash: contentHash,
|
|
1013
|
+
original_commit: commit.sha,
|
|
1014
|
+
original_message: commit.message,
|
|
1015
|
+
original_author: `${commit.authorName} <${commit.authorEmail}>`,
|
|
1016
|
+
base_generation: lastGen.commit_sha,
|
|
1017
|
+
files,
|
|
1018
|
+
patch_content: patchContent,
|
|
1019
|
+
...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
|
|
1020
|
+
...this.isCreationOnlyPatch(patchContent) ? { user_owned: true } : {}
|
|
1021
|
+
});
|
|
1266
1022
|
}
|
|
1267
|
-
const
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1023
|
+
const survivingPatches = await this.collapseNetZeroFiles(newPatches);
|
|
1024
|
+
survivingPatches.reverse();
|
|
1025
|
+
const revertedPatchIdSet = /* @__PURE__ */ new Set();
|
|
1026
|
+
const revertIndicesToRemove = /* @__PURE__ */ new Set();
|
|
1027
|
+
for (let i = 0; i < survivingPatches.length; i++) {
|
|
1028
|
+
const patch = survivingPatches[i];
|
|
1029
|
+
if (!isRevertCommit(patch.original_message)) continue;
|
|
1030
|
+
let body = "";
|
|
1275
1031
|
try {
|
|
1276
|
-
await this.git.
|
|
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
|
-
};
|
|
1032
|
+
body = await this.git.getCommitBody(patch.original_commit);
|
|
1285
1033
|
} 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
1034
|
}
|
|
1384
|
-
const
|
|
1385
|
-
const
|
|
1386
|
-
|
|
1387
|
-
if (
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
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
|
-
}
|
|
1035
|
+
const revertedSha = parseRevertedSha(body);
|
|
1036
|
+
const revertedMessage = parseRevertedMessage(patch.original_message);
|
|
1037
|
+
let matchedExisting = false;
|
|
1038
|
+
if (revertedSha) {
|
|
1039
|
+
const existing = lock.patches.find((p) => p.original_commit === revertedSha);
|
|
1040
|
+
if (existing) {
|
|
1041
|
+
revertedPatchIdSet.add(existing.id);
|
|
1042
|
+
revertIndicesToRemove.add(i);
|
|
1043
|
+
matchedExisting = true;
|
|
1404
1044
|
}
|
|
1405
1045
|
}
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
theirs = null;
|
|
1413
|
-
} else {
|
|
1414
|
-
return {
|
|
1415
|
-
file: resolvedPath,
|
|
1416
|
-
status: "skipped",
|
|
1417
|
-
reason: "stale-conflict-markers"
|
|
1418
|
-
};
|
|
1419
|
-
}
|
|
1046
|
+
if (!matchedExisting && revertedMessage) {
|
|
1047
|
+
const existing = lock.patches.find((p) => p.original_message === revertedMessage);
|
|
1048
|
+
if (existing) {
|
|
1049
|
+
revertedPatchIdSet.add(existing.id);
|
|
1050
|
+
revertIndicesToRemove.add(i);
|
|
1051
|
+
matchedExisting = true;
|
|
1420
1052
|
}
|
|
1421
1053
|
}
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
filePath,
|
|
1428
|
-
tempGit,
|
|
1429
|
-
tempDir
|
|
1054
|
+
if (matchedExisting) continue;
|
|
1055
|
+
let matchedNew = false;
|
|
1056
|
+
if (revertedSha) {
|
|
1057
|
+
const idx = survivingPatches.findIndex(
|
|
1058
|
+
(p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_commit === revertedSha
|
|
1430
1059
|
);
|
|
1431
|
-
if (
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
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;
|
|
1060
|
+
if (idx !== -1) {
|
|
1061
|
+
revertIndicesToRemove.add(i);
|
|
1062
|
+
revertIndicesToRemove.add(idx);
|
|
1063
|
+
matchedNew = true;
|
|
1471
1064
|
}
|
|
1472
1065
|
}
|
|
1473
|
-
if (
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
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
|
-
};
|
|
1066
|
+
if (!matchedNew && revertedMessage) {
|
|
1067
|
+
const idx = survivingPatches.findIndex(
|
|
1068
|
+
(p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_message === revertedMessage
|
|
1069
|
+
);
|
|
1070
|
+
if (idx !== -1) {
|
|
1071
|
+
revertIndicesToRemove.add(i);
|
|
1072
|
+
revertIndicesToRemove.add(idx);
|
|
1496
1073
|
}
|
|
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
1074
|
}
|
|
1510
|
-
if (
|
|
1511
|
-
|
|
1512
|
-
file: resolvedPath,
|
|
1513
|
-
status: "skipped",
|
|
1514
|
-
reason: "missing-content"
|
|
1515
|
-
};
|
|
1075
|
+
if (!matchedExisting && !matchedNew) {
|
|
1076
|
+
revertIndicesToRemove.add(i);
|
|
1516
1077
|
}
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1078
|
+
}
|
|
1079
|
+
const filteredPatches = survivingPatches.filter((_, i) => !revertIndicesToRemove.has(i));
|
|
1080
|
+
return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
|
|
1081
|
+
}
|
|
1082
|
+
/**
|
|
1083
|
+
* Compute content hash for deduplication.
|
|
1084
|
+
*
|
|
1085
|
+
* Produces a format-agnostic hash so both `git format-patch` output
|
|
1086
|
+
* (email-wrapped) and plain `git diff` output hash to the same value
|
|
1087
|
+
* when the underlying diff hunks are identical.
|
|
1088
|
+
*
|
|
1089
|
+
* Normalization:
|
|
1090
|
+
* 1. Strip email wrapper: everything before the first `diff --git` line
|
|
1091
|
+
* (From:, Subject:, Date:, diffstat, blank separators).
|
|
1092
|
+
* 2. Strip `index` lines (blob SHA pairs change across rebases).
|
|
1093
|
+
* 3. Strip trailing git version marker (`-- \n<version>`).
|
|
1094
|
+
*
|
|
1095
|
+
* This ensures content hashes match across the no-patches and normal-
|
|
1096
|
+
* regeneration flows, which store formatPatch and git-diff formats
|
|
1097
|
+
* respectively.
|
|
1098
|
+
*/
|
|
1099
|
+
computeContentHash(patchContent) {
|
|
1100
|
+
const lines = patchContent.split("\n");
|
|
1101
|
+
const diffStart = lines.findIndex((l) => l.startsWith("diff --git "));
|
|
1102
|
+
const relevantLines = diffStart > 0 ? lines.slice(diffStart) : lines;
|
|
1103
|
+
const normalized = relevantLines.filter((line) => !line.startsWith("index ")).join("\n").replace(/\n-- \n[\d]+\.[\d]+[^\n]*\s*$/, "");
|
|
1104
|
+
return `sha256:${(0, import_node_crypto.createHash)("sha256").update(normalized).digest("hex")}`;
|
|
1105
|
+
}
|
|
1106
|
+
/**
|
|
1107
|
+
* Drop patches whose files were transiently created and then deleted
|
|
1108
|
+
* within the current linear detection window, and are absent from
|
|
1109
|
+
* HEAD at the end of the window.
|
|
1110
|
+
*
|
|
1111
|
+
* Rationale: each commit becomes its own patch, so a file that was
|
|
1112
|
+
* briefly added and later removed survives as a create+delete pair whose
|
|
1113
|
+
* cumulative diff is empty. We drop such patches before they reach the
|
|
1114
|
+
* lockfile.
|
|
1115
|
+
*
|
|
1116
|
+
* A file is "transient" when:
|
|
1117
|
+
* 1. some patch in the window sets `new file mode` for it (a creation), AND
|
|
1118
|
+
* 2. some patch in the window sets `deleted file mode` for it (a deletion), AND
|
|
1119
|
+
* 3. it is absent from HEAD's tree.
|
|
1120
|
+
*
|
|
1121
|
+
* The HEAD guard (#3) prevents false positives when a file is deleted
|
|
1122
|
+
* and then recreated with different content — the cumulative diff of
|
|
1123
|
+
* such a pair is non-empty and must be preserved.
|
|
1124
|
+
*
|
|
1125
|
+
* This only drops patches whose `files` are a subset of the transient
|
|
1126
|
+
* set. A partial-transient patch (one that touches a transient file
|
|
1127
|
+
* alongside a persistent one) is left unmodified; surgical stripping of
|
|
1128
|
+
* single files from a multi-file patch would require a follow-up that
|
|
1129
|
+
* regenerates the patch content via `git format-patch -- <files>`. No
|
|
1130
|
+
* current failing test exercises this, and leaving the patch intact
|
|
1131
|
+
* preserves today's behaviour. Revisit if a future adversarial test
|
|
1132
|
+
* demands per-file stripping.
|
|
1133
|
+
*/
|
|
1134
|
+
async collapseNetZeroFiles(patches) {
|
|
1135
|
+
if (patches.length === 0) return patches;
|
|
1136
|
+
const stateByFile = /* @__PURE__ */ new Map();
|
|
1137
|
+
for (const patch of patches) {
|
|
1138
|
+
for (const header of parsePatchFileHeaders(patch.patch_content)) {
|
|
1139
|
+
if (!header.isCreate && !header.isDelete) continue;
|
|
1140
|
+
const prior = stateByFile.get(header.path) ?? { created: false, deleted: false };
|
|
1141
|
+
if (header.isCreate) prior.created = true;
|
|
1142
|
+
if (header.isDelete) prior.deleted = true;
|
|
1143
|
+
stateByFile.set(header.path, prior);
|
|
1524
1144
|
}
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
1532
|
-
content: effective_theirs,
|
|
1533
|
-
baseGeneration: patch.base_generation
|
|
1534
|
-
});
|
|
1145
|
+
}
|
|
1146
|
+
let anyCandidate = false;
|
|
1147
|
+
for (const state of stateByFile.values()) {
|
|
1148
|
+
if (state.created && state.deleted) {
|
|
1149
|
+
anyCandidate = true;
|
|
1150
|
+
break;
|
|
1535
1151
|
}
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
};
|
|
1152
|
+
}
|
|
1153
|
+
if (!anyCandidate) return patches;
|
|
1154
|
+
const headFiles = await this.listHeadFiles();
|
|
1155
|
+
const transient = /* @__PURE__ */ new Set();
|
|
1156
|
+
for (const [file, state] of stateByFile) {
|
|
1157
|
+
if (state.created && state.deleted && !headFiles.has(file)) {
|
|
1158
|
+
transient.add(file);
|
|
1544
1159
|
}
|
|
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
1160
|
}
|
|
1161
|
+
if (transient.size === 0) return patches;
|
|
1162
|
+
return patches.filter((patch) => !patch.files.every((f) => transient.has(f)));
|
|
1557
1163
|
}
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
}
|
|
1571
|
-
async resolveFilePath(filePath, baseTreeHash, currentTreeHash) {
|
|
1572
|
-
const config = this.lockManager.getCustomizationsConfig();
|
|
1573
|
-
if (config.moves) {
|
|
1574
|
-
for (const move of config.moves) {
|
|
1575
|
-
if ((0, import_minimatch.minimatch)(filePath, move.from) || filePath === move.from) {
|
|
1576
|
-
if (filePath === move.from) {
|
|
1577
|
-
return move.to;
|
|
1578
|
-
}
|
|
1579
|
-
const fromBase = move.from.replace(/\*\*.*$/, "");
|
|
1580
|
-
const toBase = move.to.replace(/\*\*.*$/, "");
|
|
1581
|
-
if (filePath.startsWith(fromBase)) {
|
|
1582
|
-
return toBase + filePath.slice(fromBase.length);
|
|
1583
|
-
}
|
|
1584
|
-
}
|
|
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;
|
|
1164
|
+
/**
|
|
1165
|
+
* List every tracked path at HEAD (one `git ls-tree -r` invocation).
|
|
1166
|
+
* Returns an empty Set if HEAD is unborn or the invocation fails — the
|
|
1167
|
+
* caller then treats every candidate as "not in HEAD" and may collapse
|
|
1168
|
+
* more aggressively. On typical SDK repos this is a single sub-10ms call.
|
|
1169
|
+
*/
|
|
1170
|
+
async listHeadFiles() {
|
|
1171
|
+
try {
|
|
1172
|
+
const out = await this.git.exec(["ls-tree", "-r", "--name-only", "HEAD"]);
|
|
1173
|
+
return new Set(out.split("\n").map((l) => l.trim()).filter(Boolean));
|
|
1174
|
+
} catch {
|
|
1175
|
+
return /* @__PURE__ */ new Set();
|
|
1596
1176
|
}
|
|
1597
|
-
return filePath;
|
|
1598
1177
|
}
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1178
|
+
/**
|
|
1179
|
+
* Detect patches via tree diff for non-linear history. Returns a composite patch.
|
|
1180
|
+
* Revert reconciliation is skipped here because tree-diff produces a single composite
|
|
1181
|
+
* patch from the aggregate diff — individual revert commits are not distinguishable.
|
|
1182
|
+
*/
|
|
1183
|
+
async detectPatchesViaTreeDiff(lastGen, commitKnownMissing) {
|
|
1184
|
+
const diffBase = await this.resolveDiffBase(lastGen, commitKnownMissing);
|
|
1185
|
+
if (!diffBase) {
|
|
1186
|
+
return this.detectPatchesViaCommitScan();
|
|
1602
1187
|
}
|
|
1603
|
-
const
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
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");
|
|
1188
|
+
const lockForGuard = this.lockManager.read();
|
|
1189
|
+
const knownGenerations = new Set(lockForGuard.generations.map((g) => g.commit_sha));
|
|
1190
|
+
let resolvedBaseGeneration = commitKnownMissing ? diffBase : lastGen.commit_sha;
|
|
1191
|
+
if (commitKnownMissing && !knownGenerations.has(diffBase)) {
|
|
1192
|
+
const fallback = lockForGuard.current_generation ?? lockForGuard.generations[lockForGuard.generations.length - 1]?.commit_sha;
|
|
1193
|
+
if (!fallback) {
|
|
1194
|
+
this.warnings.push(
|
|
1195
|
+
`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.`
|
|
1196
|
+
);
|
|
1197
|
+
return { patches: [], revertedPatchIds: [] };
|
|
1620
1198
|
}
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1199
|
+
resolvedBaseGeneration = fallback;
|
|
1200
|
+
}
|
|
1201
|
+
const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
|
|
1202
|
+
const { files, sensitiveFiles } = filterInfrastructureAndSensitiveFiles(filesOutput);
|
|
1203
|
+
if (sensitiveFiles.length > 0) {
|
|
1204
|
+
this.warnings.push(
|
|
1205
|
+
`Sensitive file(s) excluded from composite patch: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
|
|
1206
|
+
);
|
|
1207
|
+
}
|
|
1208
|
+
if (files.length === 0) return { patches: [], revertedPatchIds: [] };
|
|
1209
|
+
const diff = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
|
|
1210
|
+
if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
|
|
1211
|
+
const contentHash = this.computeContentHash(diff);
|
|
1212
|
+
const lock = this.lockManager.read();
|
|
1213
|
+
if (lock.patches.some((p) => p.content_hash === contentHash) || (lock.forgotten_hashes ?? []).includes(contentHash)) {
|
|
1214
|
+
return { patches: [], revertedPatchIds: [] };
|
|
1630
1215
|
}
|
|
1216
|
+
const headSha = (await this.git.exec(["rev-parse", "HEAD"])).trim();
|
|
1217
|
+
const theirsSnapshot = await capturePatchSnapshot(this.git, files, "HEAD");
|
|
1218
|
+
const compositePatch = {
|
|
1219
|
+
id: `patch-composite-${headSha.slice(0, 8)}`,
|
|
1220
|
+
content_hash: contentHash,
|
|
1221
|
+
original_commit: headSha,
|
|
1222
|
+
original_message: "Customer customizations (composite)",
|
|
1223
|
+
original_author: "composite",
|
|
1224
|
+
// Anchor `base_generation` on a SHA the applicator's
|
|
1225
|
+
// `resolveBaseGeneration` can find — always a member of
|
|
1226
|
+
// `lock.generations`. When `diffBase` is a recorded
|
|
1227
|
+
// generation, use it directly; otherwise the guard above
|
|
1228
|
+
// has already mapped it onto `current_generation`.
|
|
1229
|
+
base_generation: resolvedBaseGeneration,
|
|
1230
|
+
files,
|
|
1231
|
+
patch_content: diff,
|
|
1232
|
+
...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
|
|
1233
|
+
...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
|
|
1234
|
+
};
|
|
1235
|
+
return { patches: [compositePatch], revertedPatchIds: [] };
|
|
1631
1236
|
}
|
|
1632
1237
|
/**
|
|
1633
|
-
*
|
|
1634
|
-
*
|
|
1635
|
-
*
|
|
1636
|
-
*
|
|
1637
|
-
*
|
|
1238
|
+
* Last-resort detection when both generation commit and tree are unreachable.
|
|
1239
|
+
* Scans all commits from HEAD, filters against known lockfile patches, and
|
|
1240
|
+
* skips creation-only commits (squashed history after force push).
|
|
1241
|
+
*
|
|
1242
|
+
* Detected patches use the commit's parent as base_generation so the applicator
|
|
1243
|
+
* can find base file content from the parent's tree (which IS reachable).
|
|
1638
1244
|
*/
|
|
1639
|
-
async
|
|
1640
|
-
const
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1245
|
+
async detectPatchesViaCommitScan() {
|
|
1246
|
+
const lock = this.lockManager.read();
|
|
1247
|
+
const knownGenerations = new Set(lock.generations.map((g) => g.commit_sha));
|
|
1248
|
+
const fallbackAnchor = lock.current_generation ?? lock.generations[lock.generations.length - 1]?.commit_sha;
|
|
1249
|
+
const log = await this.git.exec([
|
|
1250
|
+
"log",
|
|
1251
|
+
"--max-count=200",
|
|
1252
|
+
"--format=%H%x00%an%x00%ae%x00%s",
|
|
1253
|
+
"HEAD",
|
|
1254
|
+
"--",
|
|
1255
|
+
this.sdkOutputDir
|
|
1256
|
+
]);
|
|
1257
|
+
if (!log.trim()) {
|
|
1258
|
+
return { patches: [], revertedPatchIds: [] };
|
|
1652
1259
|
}
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
const
|
|
1656
|
-
const
|
|
1657
|
-
|
|
1658
|
-
for (const
|
|
1659
|
-
if (
|
|
1660
|
-
if (inTargetFile) {
|
|
1661
|
-
break;
|
|
1662
|
-
}
|
|
1663
|
-
if (isDiffLineForFile(line, filePath)) {
|
|
1664
|
-
inTargetFile = true;
|
|
1665
|
-
diffLines.push(line);
|
|
1666
|
-
}
|
|
1260
|
+
const commits = this.parseGitLog(log);
|
|
1261
|
+
const newPatches = [];
|
|
1262
|
+
const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
|
|
1263
|
+
const existingHashes = new Set(lock.patches.map((p) => p.content_hash));
|
|
1264
|
+
const existingCommits = new Set(lock.patches.map((p) => p.original_commit));
|
|
1265
|
+
for (const commit of commits) {
|
|
1266
|
+
if (isGenerationCommit(commit)) {
|
|
1667
1267
|
continue;
|
|
1668
1268
|
}
|
|
1669
|
-
|
|
1670
|
-
|
|
1269
|
+
const parents = await this.git.getCommitParents(commit.sha);
|
|
1270
|
+
if (parents.length > 1) {
|
|
1271
|
+
continue;
|
|
1671
1272
|
}
|
|
1672
|
-
|
|
1673
|
-
return diffLines.length > 0 ? diffLines.join("\n") + "\n" : null;
|
|
1674
|
-
}
|
|
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);
|
|
1273
|
+
if (existingCommits.has(commit.sha)) {
|
|
1682
1274
|
continue;
|
|
1683
1275
|
}
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1276
|
+
let patchContent;
|
|
1277
|
+
try {
|
|
1278
|
+
patchContent = await this.git.formatPatch(commit.sha);
|
|
1279
|
+
} catch {
|
|
1280
|
+
continue;
|
|
1688
1281
|
}
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
}
|
|
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;
|
|
1282
|
+
let contentHash = this.computeContentHash(patchContent);
|
|
1283
|
+
if (existingHashes.has(contentHash) || forgottenHashes.has(contentHash)) {
|
|
1705
1284
|
continue;
|
|
1706
1285
|
}
|
|
1707
|
-
if (
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1286
|
+
if (this.isCreationOnlyPatch(patchContent)) {
|
|
1287
|
+
continue;
|
|
1288
|
+
}
|
|
1289
|
+
const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
|
|
1290
|
+
const { files, sensitiveFiles } = filterInfrastructureAndSensitiveFiles(filesOutput);
|
|
1291
|
+
if (sensitiveFiles.length > 0) {
|
|
1292
|
+
this.warnings.push(
|
|
1293
|
+
`Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
|
|
1294
|
+
);
|
|
1295
|
+
if (files.length > 0) {
|
|
1296
|
+
if (parents.length === 0) {
|
|
1297
|
+
continue;
|
|
1298
|
+
}
|
|
1299
|
+
try {
|
|
1300
|
+
patchContent = await this.git.exec(["diff", parents[0], commit.sha, "--", ...files]);
|
|
1301
|
+
} catch {
|
|
1302
|
+
continue;
|
|
1303
|
+
}
|
|
1304
|
+
if (!patchContent.trim()) continue;
|
|
1305
|
+
contentHash = this.computeContentHash(patchContent);
|
|
1711
1306
|
}
|
|
1712
1307
|
}
|
|
1713
|
-
if (
|
|
1714
|
-
if (!isNewFile) return null;
|
|
1715
|
-
inHunk = true;
|
|
1308
|
+
if (files.length === 0) {
|
|
1716
1309
|
continue;
|
|
1717
1310
|
}
|
|
1718
|
-
if (
|
|
1719
|
-
if (line === "\") {
|
|
1720
|
-
noTrailingNewline = true;
|
|
1311
|
+
if (parents.length === 0) {
|
|
1721
1312
|
continue;
|
|
1722
1313
|
}
|
|
1723
|
-
|
|
1724
|
-
|
|
1314
|
+
const parentSha = parents[0];
|
|
1315
|
+
let baseGeneration;
|
|
1316
|
+
if (knownGenerations.has(parentSha)) {
|
|
1317
|
+
baseGeneration = parentSha;
|
|
1318
|
+
} else if (fallbackAnchor) {
|
|
1319
|
+
baseGeneration = fallbackAnchor;
|
|
1320
|
+
} else {
|
|
1321
|
+
this.warnings.push(
|
|
1322
|
+
`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.`
|
|
1323
|
+
);
|
|
1324
|
+
continue;
|
|
1725
1325
|
}
|
|
1326
|
+
const theirsSnapshot = await capturePatchSnapshot(this.git, files, commit.sha);
|
|
1327
|
+
newPatches.push({
|
|
1328
|
+
id: `patch-${commit.sha.slice(0, 8)}`,
|
|
1329
|
+
content_hash: contentHash,
|
|
1330
|
+
original_commit: commit.sha,
|
|
1331
|
+
original_message: commit.message,
|
|
1332
|
+
original_author: `${commit.authorName} <${commit.authorEmail}>`,
|
|
1333
|
+
base_generation: baseGeneration,
|
|
1334
|
+
files,
|
|
1335
|
+
patch_content: patchContent,
|
|
1336
|
+
...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {}
|
|
1337
|
+
});
|
|
1726
1338
|
}
|
|
1727
|
-
|
|
1728
|
-
return
|
|
1729
|
-
}
|
|
1339
|
+
newPatches.reverse();
|
|
1340
|
+
return { patches: newPatches, revertedPatchIds: [] };
|
|
1341
|
+
}
|
|
1342
|
+
/**
|
|
1343
|
+
* Check if a format-patch consists entirely of new-file creations.
|
|
1344
|
+
* Used to identify squashed commits after force push, which create all files
|
|
1345
|
+
* from scratch (--- /dev/null) rather than modifying existing files.
|
|
1346
|
+
*/
|
|
1347
|
+
isCreationOnlyPatch(patchContent) {
|
|
1348
|
+
const diffOldHeaders = patchContent.split("\n").filter((l) => l.startsWith("--- "));
|
|
1349
|
+
if (diffOldHeaders.length === 0) {
|
|
1350
|
+
return false;
|
|
1351
|
+
}
|
|
1352
|
+
return diffOldHeaders.every((l) => l === "--- /dev/null");
|
|
1353
|
+
}
|
|
1354
|
+
/**
|
|
1355
|
+
* Resolve the best available diff base for a generation record.
|
|
1356
|
+
* Prefers commit_sha, falls back to tree_hash for unreachable commits.
|
|
1357
|
+
* When commitKnownMissing is true, skips the redundant commitExists check.
|
|
1358
|
+
*/
|
|
1359
|
+
async resolveDiffBase(gen, commitKnownMissing) {
|
|
1360
|
+
if (!commitKnownMissing && await this.git.commitExists(gen.commit_sha)) {
|
|
1361
|
+
return gen.commit_sha;
|
|
1362
|
+
}
|
|
1363
|
+
if (await this.git.treeExists(gen.tree_hash)) {
|
|
1364
|
+
return gen.tree_hash;
|
|
1365
|
+
}
|
|
1366
|
+
return null;
|
|
1367
|
+
}
|
|
1368
|
+
parseGitLog(log) {
|
|
1369
|
+
return log.trim().split("\n").map((line) => {
|
|
1370
|
+
const [sha, authorName, authorEmail, message] = line.split("\0");
|
|
1371
|
+
return { sha, authorName, authorEmail, message };
|
|
1372
|
+
});
|
|
1373
|
+
}
|
|
1374
|
+
getLastGeneration(lock) {
|
|
1375
|
+
return lock.generations.find((g) => g.commit_sha === lock.current_generation);
|
|
1376
|
+
}
|
|
1730
1377
|
};
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
function computeDroppedContextLinesForFile(base, theirs, finalContent) {
|
|
1378
|
+
|
|
1379
|
+
// src/ThreeWayMerge.ts
|
|
1380
|
+
var import_node_diff3 = require("node-diff3");
|
|
1381
|
+
function threeWayMerge(base, ours, theirs) {
|
|
1736
1382
|
const baseLines = base.split("\n");
|
|
1383
|
+
const oursLines = ours.split("\n");
|
|
1737
1384
|
const theirsLines = theirs.split("\n");
|
|
1738
|
-
const
|
|
1739
|
-
const
|
|
1740
|
-
const
|
|
1741
|
-
|
|
1742
|
-
for (const
|
|
1743
|
-
|
|
1744
|
-
|
|
1385
|
+
const regions = (0, import_node_diff3.diff3Merge)(oursLines, baseLines, theirsLines);
|
|
1386
|
+
const outputLines = [];
|
|
1387
|
+
const conflicts = [];
|
|
1388
|
+
let currentLine = 1;
|
|
1389
|
+
for (const region of regions) {
|
|
1390
|
+
if (region.ok) {
|
|
1391
|
+
outputLines.push(...region.ok);
|
|
1392
|
+
currentLine += region.ok.length;
|
|
1393
|
+
} else if (region.conflict) {
|
|
1394
|
+
const resolved = tryResolveConflict(
|
|
1395
|
+
region.conflict.a,
|
|
1396
|
+
// ours (generator)
|
|
1397
|
+
region.conflict.o,
|
|
1398
|
+
// base
|
|
1399
|
+
region.conflict.b
|
|
1400
|
+
// theirs (user)
|
|
1401
|
+
);
|
|
1402
|
+
if (resolved !== null) {
|
|
1403
|
+
outputLines.push(...resolved);
|
|
1404
|
+
currentLine += resolved.length;
|
|
1405
|
+
} else {
|
|
1406
|
+
const startLine = currentLine;
|
|
1407
|
+
outputLines.push("<<<<<<< Generated");
|
|
1408
|
+
outputLines.push(...region.conflict.a);
|
|
1409
|
+
outputLines.push("=======");
|
|
1410
|
+
outputLines.push(...region.conflict.b);
|
|
1411
|
+
outputLines.push(">>>>>>> Your customization");
|
|
1412
|
+
const conflictLines = region.conflict.a.length + region.conflict.b.length + 3;
|
|
1413
|
+
conflicts.push({
|
|
1414
|
+
startLine,
|
|
1415
|
+
endLine: startLine + conflictLines - 1,
|
|
1416
|
+
ours: region.conflict.a,
|
|
1417
|
+
theirs: region.conflict.b
|
|
1418
|
+
});
|
|
1419
|
+
currentLine += conflictLines;
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
const result = {
|
|
1424
|
+
content: outputLines.join("\n"),
|
|
1425
|
+
hasConflicts: conflicts.length > 0,
|
|
1426
|
+
conflicts
|
|
1427
|
+
};
|
|
1428
|
+
if (conflicts.length === 0) {
|
|
1429
|
+
const dropped = computeDroppedContextLines(baseLines, theirsLines, outputLines);
|
|
1430
|
+
if (dropped.length > 0) {
|
|
1431
|
+
result.droppedContextLines = dropped;
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
return result;
|
|
1435
|
+
}
|
|
1436
|
+
function computeDroppedContextLines(baseLines, theirsLines, mergedLines) {
|
|
1437
|
+
const baseCounts = countLines(baseLines);
|
|
1438
|
+
const theirsCounts = countLines(theirsLines);
|
|
1439
|
+
const mergedCounts = countLines(mergedLines);
|
|
1745
1440
|
const dropped = [];
|
|
1746
1441
|
const seen = /* @__PURE__ */ new Set();
|
|
1747
1442
|
for (const line of baseLines) {
|
|
1748
1443
|
if (seen.has(line)) continue;
|
|
1749
1444
|
const inBase = baseCounts.get(line) ?? 0;
|
|
1750
1445
|
const inTheirs = theirsCounts.get(line) ?? 0;
|
|
1751
|
-
const
|
|
1752
|
-
if (inBase > 0 && inTheirs > 0 &&
|
|
1446
|
+
const inMerged = mergedCounts.get(line) ?? 0;
|
|
1447
|
+
if (inBase > 0 && inTheirs > 0 && inMerged < Math.min(inBase, inTheirs)) {
|
|
1753
1448
|
dropped.push(line);
|
|
1754
1449
|
seen.add(line);
|
|
1755
1450
|
}
|
|
1756
1451
|
}
|
|
1757
1452
|
return dropped;
|
|
1758
1453
|
}
|
|
1759
|
-
function
|
|
1760
|
-
const
|
|
1761
|
-
|
|
1454
|
+
function countLines(lines) {
|
|
1455
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1456
|
+
for (const line of lines) {
|
|
1457
|
+
counts.set(line, (counts.get(line) ?? 0) + 1);
|
|
1458
|
+
}
|
|
1459
|
+
return counts;
|
|
1762
1460
|
}
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
const
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
const
|
|
1783
|
-
if (
|
|
1784
|
-
|
|
1461
|
+
function tryResolveConflict(oursLines, baseLines, theirsLines) {
|
|
1462
|
+
if (baseLines.length === 0) {
|
|
1463
|
+
return null;
|
|
1464
|
+
}
|
|
1465
|
+
const oursPatches = (0, import_node_diff3.diffPatch)(baseLines, oursLines);
|
|
1466
|
+
const theirsPatches = (0, import_node_diff3.diffPatch)(baseLines, theirsLines);
|
|
1467
|
+
if (oursPatches.length === 0) return theirsLines;
|
|
1468
|
+
if (theirsPatches.length === 0) return oursLines;
|
|
1469
|
+
if (patchesOverlap(oursPatches, theirsPatches)) {
|
|
1470
|
+
return null;
|
|
1471
|
+
}
|
|
1472
|
+
return applyBothPatches(baseLines, oursPatches, theirsPatches);
|
|
1473
|
+
}
|
|
1474
|
+
function patchesOverlap(oursPatches, theirsPatches) {
|
|
1475
|
+
for (const op of oursPatches) {
|
|
1476
|
+
const oStart = op.buffer1.offset;
|
|
1477
|
+
const oEnd = oStart + op.buffer1.length;
|
|
1478
|
+
for (const tp of theirsPatches) {
|
|
1479
|
+
const tStart = tp.buffer1.offset;
|
|
1480
|
+
const tEnd = tStart + tp.buffer1.length;
|
|
1481
|
+
if (op.buffer1.length === 0 && tp.buffer1.length === 0 && oStart === tStart) {
|
|
1482
|
+
return true;
|
|
1785
1483
|
}
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1484
|
+
if (tp.buffer1.length === 0 && tStart === oEnd) {
|
|
1485
|
+
return true;
|
|
1486
|
+
}
|
|
1487
|
+
if (op.buffer1.length === 0 && oStart === tEnd) {
|
|
1488
|
+
return true;
|
|
1489
|
+
}
|
|
1490
|
+
if (oStart < tEnd && tStart < oEnd) {
|
|
1491
|
+
return true;
|
|
1791
1492
|
}
|
|
1792
1493
|
}
|
|
1793
1494
|
}
|
|
1794
|
-
|
|
1795
|
-
return results;
|
|
1495
|
+
return false;
|
|
1796
1496
|
}
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1497
|
+
function applyBothPatches(baseLines, oursPatches, theirsPatches) {
|
|
1498
|
+
const allPatches = [
|
|
1499
|
+
...oursPatches.map((p) => ({
|
|
1500
|
+
offset: p.buffer1.offset,
|
|
1501
|
+
length: p.buffer1.length,
|
|
1502
|
+
replacement: p.buffer2.chunk
|
|
1503
|
+
})),
|
|
1504
|
+
...theirsPatches.map((p) => ({
|
|
1505
|
+
offset: p.buffer1.offset,
|
|
1506
|
+
length: p.buffer1.length,
|
|
1507
|
+
replacement: p.buffer2.chunk
|
|
1508
|
+
}))
|
|
1509
|
+
];
|
|
1510
|
+
allPatches.sort((a, b) => b.offset - a.offset);
|
|
1511
|
+
const result = [...baseLines];
|
|
1512
|
+
for (const p of allPatches) {
|
|
1513
|
+
result.splice(p.offset, p.length, ...p.replacement);
|
|
1806
1514
|
}
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1515
|
+
return result;
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
// src/ReplayApplicator.ts
|
|
1519
|
+
var import_promises3 = require("fs/promises");
|
|
1520
|
+
var import_node_os = require("os");
|
|
1521
|
+
var import_node_path5 = require("path");
|
|
1522
|
+
var import_minimatch = require("minimatch");
|
|
1523
|
+
|
|
1524
|
+
// src/accumulator/MergeAccumulator.ts
|
|
1525
|
+
var MergeAccumulator = class {
|
|
1526
|
+
constructor(mode, sharedFiles) {
|
|
1527
|
+
this.mode = mode;
|
|
1528
|
+
this.sharedFiles = sharedFiles;
|
|
1529
|
+
}
|
|
1530
|
+
entries = /* @__PURE__ */ new Map();
|
|
1531
|
+
/** Was this file touched by 2+ patches in the current `applyPatches` invocation? */
|
|
1532
|
+
isShared(path) {
|
|
1533
|
+
return this.sharedFiles.has(path);
|
|
1534
|
+
}
|
|
1535
|
+
/** Has any prior patch already recorded a result for this path? */
|
|
1536
|
+
has(path) {
|
|
1537
|
+
return this.entries.has(path);
|
|
1538
|
+
}
|
|
1539
|
+
/** Look up the accumulated content + base generation for this path. */
|
|
1540
|
+
lookup(path) {
|
|
1541
|
+
return this.entries.get(path);
|
|
1542
|
+
}
|
|
1543
|
+
/**
|
|
1544
|
+
* True if any of the patch's files have a prior accumulator entry — in
|
|
1545
|
+
* which case the fast path (`git apply --3way`) cannot honor prior
|
|
1546
|
+
* patches' contributions and we must route through the slow path.
|
|
1547
|
+
*/
|
|
1548
|
+
shouldSkipFastPath(patch) {
|
|
1549
|
+
return patch.files.some((f) => this.entries.has(f));
|
|
1550
|
+
}
|
|
1551
|
+
/** Record a successful (clean) merge result. */
|
|
1552
|
+
recordMerge(path, content, baseGeneration) {
|
|
1553
|
+
this.entries.set(path, { content, baseGeneration });
|
|
1554
|
+
}
|
|
1555
|
+
/**
|
|
1556
|
+
* Record a conflicted merge result. Populates only in `"resolve"` mode
|
|
1557
|
+
* — replay mode keeps the accumulator clean of marker-laden content
|
|
1558
|
+
* since markers will be stripped from disk after the apply loop.
|
|
1559
|
+
*/
|
|
1560
|
+
recordConflict(path, content, baseGeneration) {
|
|
1561
|
+
if (this.mode === "resolve") {
|
|
1562
|
+
this.entries.set(path, { content, baseGeneration });
|
|
1827
1563
|
}
|
|
1828
|
-
return this.detectPatchesInRange(lastGen.commit_sha, lastGen, lock);
|
|
1829
1564
|
}
|
|
1830
1565
|
/**
|
|
1831
|
-
*
|
|
1832
|
-
*
|
|
1833
|
-
*
|
|
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.
|
|
1566
|
+
* Defensive copy for test introspection. Used by invariant I2 to assert
|
|
1567
|
+
* that after every applied patch, the accumulator has an entry for each
|
|
1568
|
+
* file the patch touched (see `__tests__/invariants/i2-accumulator-correctness.ts`).
|
|
1840
1569
|
*
|
|
1841
|
-
*
|
|
1842
|
-
* (truly disjoint history — orphan branches with no shared root).
|
|
1570
|
+
* @internal
|
|
1843
1571
|
*/
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
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
|
-
);
|
|
1572
|
+
snapshot() {
|
|
1573
|
+
const copy = /* @__PURE__ */ new Map();
|
|
1574
|
+
for (const [path, entry] of this.entries) {
|
|
1575
|
+
copy.set(path, { content: entry.content, baseGeneration: entry.baseGeneration });
|
|
1859
1576
|
}
|
|
1860
|
-
return
|
|
1577
|
+
return copy;
|
|
1861
1578
|
}
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1579
|
+
};
|
|
1580
|
+
|
|
1581
|
+
// src/applicator/resolveInputs.ts
|
|
1582
|
+
var import_promises = require("fs/promises");
|
|
1583
|
+
var import_node_path3 = require("path");
|
|
1584
|
+
async function resolveInputs(args) {
|
|
1585
|
+
const {
|
|
1586
|
+
patch,
|
|
1587
|
+
filePath,
|
|
1588
|
+
git,
|
|
1589
|
+
lockManager,
|
|
1590
|
+
outputDir,
|
|
1591
|
+
isTreeReachable,
|
|
1592
|
+
resolveBaseGeneration,
|
|
1593
|
+
resolveFilePath,
|
|
1594
|
+
extractRenameSource,
|
|
1595
|
+
extractFileDiff: extractFileDiff2
|
|
1596
|
+
} = args;
|
|
1597
|
+
const baseGen = await resolveBaseGeneration(patch.base_generation);
|
|
1598
|
+
if (!baseGen) {
|
|
1599
|
+
return {
|
|
1600
|
+
resolvedPath: filePath,
|
|
1601
|
+
metadata: {
|
|
1602
|
+
patchId: patch.id,
|
|
1603
|
+
patchMessage: patch.original_message,
|
|
1604
|
+
baseGeneration: patch.base_generation,
|
|
1605
|
+
currentGeneration: lockManager.read().current_generation
|
|
1606
|
+
},
|
|
1607
|
+
base: null,
|
|
1608
|
+
ours: null,
|
|
1609
|
+
oursPath: (0, import_node_path3.join)(outputDir, filePath),
|
|
1610
|
+
renameSourcePath: void 0,
|
|
1611
|
+
ghostTheirs: null,
|
|
1612
|
+
earlyExit: { status: "skipped", reason: "base-generation-not-found" }
|
|
1613
|
+
};
|
|
1614
|
+
}
|
|
1615
|
+
const lock = lockManager.read();
|
|
1616
|
+
const currentGen = lock.generations.find((g) => g.commit_sha === lock.current_generation);
|
|
1617
|
+
const currentTreeHash = currentGen?.tree_hash ?? baseGen.tree_hash;
|
|
1618
|
+
const resolvedPath = await resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
|
|
1619
|
+
const metadata = {
|
|
1620
|
+
patchId: patch.id,
|
|
1621
|
+
patchMessage: patch.original_message,
|
|
1622
|
+
baseGeneration: patch.base_generation,
|
|
1623
|
+
currentGeneration: lock.current_generation
|
|
1624
|
+
};
|
|
1625
|
+
let base = await git.showFile(baseGen.tree_hash, filePath);
|
|
1626
|
+
let renameSourcePath;
|
|
1627
|
+
if (!base) {
|
|
1628
|
+
const renameSource = extractRenameSource(patch.patch_content, filePath);
|
|
1629
|
+
if (renameSource) {
|
|
1630
|
+
base = await git.showFile(baseGen.tree_hash, renameSource);
|
|
1631
|
+
renameSourcePath = renameSource;
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
const oursPath = (0, import_node_path3.join)(outputDir, resolvedPath);
|
|
1635
|
+
const ours = await (0, import_promises.readFile)(oursPath, "utf-8").catch(() => null);
|
|
1636
|
+
let ghostTheirs = null;
|
|
1637
|
+
if (!base && ours && !renameSourcePath) {
|
|
1638
|
+
const treeReachable = await isTreeReachable(baseGen.tree_hash);
|
|
1639
|
+
if (!treeReachable) {
|
|
1640
|
+
const fileDiff = extractFileDiff2(patch.patch_content, filePath);
|
|
1641
|
+
if (fileDiff) {
|
|
1642
|
+
const { reconstructFromGhostPatch: reconstructFromGhostPatch2 } = await Promise.resolve().then(() => (init_HybridReconstruction(), HybridReconstruction_exports));
|
|
1643
|
+
const result = reconstructFromGhostPatch2(fileDiff, ours);
|
|
1644
|
+
if (result) {
|
|
1645
|
+
base = result.base;
|
|
1646
|
+
ghostTheirs = result.theirs;
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1882
1649
|
}
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1650
|
+
}
|
|
1651
|
+
return {
|
|
1652
|
+
resolvedPath,
|
|
1653
|
+
metadata,
|
|
1654
|
+
base,
|
|
1655
|
+
ours,
|
|
1656
|
+
oursPath,
|
|
1657
|
+
renameSourcePath,
|
|
1658
|
+
ghostTheirs
|
|
1659
|
+
};
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
// src/conflict-utils.ts
|
|
1663
|
+
var CONFLICT_OPENER = "<<<<<<< Generated";
|
|
1664
|
+
var CONFLICT_SEPARATOR = "=======";
|
|
1665
|
+
var CONFLICT_CLOSER = ">>>>>>> Your customization";
|
|
1666
|
+
function trimCR(line) {
|
|
1667
|
+
return line.endsWith("\r") ? line.slice(0, -1) : line;
|
|
1668
|
+
}
|
|
1669
|
+
function findConflictRanges(lines) {
|
|
1670
|
+
const ranges = [];
|
|
1671
|
+
let i = 0;
|
|
1672
|
+
while (i < lines.length) {
|
|
1673
|
+
if (trimCR(lines[i]) === CONFLICT_OPENER) {
|
|
1674
|
+
let separatorIdx = -1;
|
|
1675
|
+
let j = i + 1;
|
|
1676
|
+
let found = false;
|
|
1677
|
+
while (j < lines.length) {
|
|
1678
|
+
const trimmed = trimCR(lines[j]);
|
|
1679
|
+
if (trimmed === CONFLICT_OPENER) {
|
|
1680
|
+
break;
|
|
1681
|
+
}
|
|
1682
|
+
if (separatorIdx === -1 && trimmed === CONFLICT_SEPARATOR) {
|
|
1683
|
+
separatorIdx = j;
|
|
1684
|
+
} else if (separatorIdx !== -1 && trimmed === CONFLICT_CLOSER) {
|
|
1685
|
+
ranges.push({ start: i, separator: separatorIdx, end: j });
|
|
1686
|
+
i = j;
|
|
1687
|
+
found = true;
|
|
1688
|
+
break;
|
|
1689
|
+
}
|
|
1690
|
+
j++;
|
|
1889
1691
|
}
|
|
1890
|
-
if (
|
|
1692
|
+
if (!found) {
|
|
1693
|
+
i++;
|
|
1891
1694
|
continue;
|
|
1892
1695
|
}
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1696
|
+
}
|
|
1697
|
+
i++;
|
|
1698
|
+
}
|
|
1699
|
+
return ranges;
|
|
1700
|
+
}
|
|
1701
|
+
function stripConflictMarkers(content) {
|
|
1702
|
+
const lines = content.split(/\r?\n/);
|
|
1703
|
+
const ranges = findConflictRanges(lines);
|
|
1704
|
+
if (ranges.length === 0) {
|
|
1705
|
+
return content;
|
|
1706
|
+
}
|
|
1707
|
+
const result = [];
|
|
1708
|
+
let rangeIdx = 0;
|
|
1709
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1710
|
+
if (rangeIdx < ranges.length) {
|
|
1711
|
+
const range = ranges[rangeIdx];
|
|
1712
|
+
if (i === range.start) {
|
|
1904
1713
|
continue;
|
|
1905
1714
|
}
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
patchContent = await this.git.formatPatch(commit.sha);
|
|
1909
|
-
} catch {
|
|
1910
|
-
this.warnings.push(
|
|
1911
|
-
`Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
|
|
1912
|
-
);
|
|
1715
|
+
if (i > range.start && i < range.separator) {
|
|
1716
|
+
result.push(lines[i]);
|
|
1913
1717
|
continue;
|
|
1914
1718
|
}
|
|
1915
|
-
|
|
1916
|
-
if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
|
|
1719
|
+
if (i === range.separator) {
|
|
1917
1720
|
continue;
|
|
1918
1721
|
}
|
|
1919
|
-
|
|
1920
|
-
const allFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
|
|
1921
|
-
const sensitiveFiles = allFiles.filter((f) => isSensitiveFile(f));
|
|
1922
|
-
const files = allFiles.filter((f) => !isSensitiveFile(f));
|
|
1923
|
-
if (sensitiveFiles.length > 0) {
|
|
1924
|
-
this.warnings.push(
|
|
1925
|
-
`Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
|
|
1926
|
-
);
|
|
1927
|
-
if (files.length > 0) {
|
|
1928
|
-
const parentSha = parents[0];
|
|
1929
|
-
if (parentSha) {
|
|
1930
|
-
try {
|
|
1931
|
-
patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
|
|
1932
|
-
} catch {
|
|
1933
|
-
continue;
|
|
1934
|
-
}
|
|
1935
|
-
if (!patchContent.trim()) continue;
|
|
1936
|
-
contentHash = this.computeContentHash(patchContent);
|
|
1937
|
-
}
|
|
1938
|
-
}
|
|
1939
|
-
}
|
|
1940
|
-
if (files.length === 0) {
|
|
1722
|
+
if (i > range.separator && i < range.end) {
|
|
1941
1723
|
continue;
|
|
1942
1724
|
}
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
const content = await this.git.showFile(commit.sha, file).catch(() => null);
|
|
1947
|
-
if (content != null) theirsSnapshot[file] = content;
|
|
1725
|
+
if (i === range.end) {
|
|
1726
|
+
rangeIdx++;
|
|
1727
|
+
continue;
|
|
1948
1728
|
}
|
|
1949
|
-
newPatches.push({
|
|
1950
|
-
id: `patch-${commit.sha.slice(0, 8)}`,
|
|
1951
|
-
content_hash: contentHash,
|
|
1952
|
-
original_commit: commit.sha,
|
|
1953
|
-
original_message: commit.message,
|
|
1954
|
-
original_author: `${commit.authorName} <${commit.authorEmail}>`,
|
|
1955
|
-
base_generation: lastGen.commit_sha,
|
|
1956
|
-
files,
|
|
1957
|
-
patch_content: patchContent,
|
|
1958
|
-
...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
|
|
1959
|
-
...this.isCreationOnlyPatch(patchContent) ? { user_owned: true } : {}
|
|
1960
|
-
});
|
|
1961
1729
|
}
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1730
|
+
result.push(lines[i]);
|
|
1731
|
+
}
|
|
1732
|
+
return result.join("\n");
|
|
1733
|
+
}
|
|
1734
|
+
function hasConflictMarkerStrings(content) {
|
|
1735
|
+
if (content == null) return false;
|
|
1736
|
+
return content.includes(CONFLICT_OPENER) || content.includes(CONFLICT_CLOSER);
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
// src/applicator/buildMergePlan.ts
|
|
1740
|
+
async function buildMergePlan(args) {
|
|
1741
|
+
const {
|
|
1742
|
+
inputs,
|
|
1743
|
+
patch,
|
|
1744
|
+
filePath,
|
|
1745
|
+
accumulator,
|
|
1746
|
+
tempGit,
|
|
1747
|
+
tempDir,
|
|
1748
|
+
applyPatchToContent: applyPatchToContent2,
|
|
1749
|
+
isPatchAlreadyApplied
|
|
1750
|
+
} = args;
|
|
1751
|
+
const { base, ours, resolvedPath, renameSourcePath, ghostTheirs } = inputs;
|
|
1752
|
+
let theirs = ghostTheirs;
|
|
1753
|
+
const ghostReconstructed = ghostTheirs != null;
|
|
1754
|
+
const snapshotForFile = patch.theirs_snapshot?.[resolvedPath] ?? patch.theirs_snapshot?.[filePath];
|
|
1755
|
+
const fileIsShared = accumulator.isShared(resolvedPath) || accumulator.isShared(filePath);
|
|
1756
|
+
const useSnapshotAsPrimary = !fileIsShared && snapshotForFile != null;
|
|
1757
|
+
let primarySource = ghostReconstructed ? "ghost" : "reconstructed";
|
|
1758
|
+
if (!ghostReconstructed) {
|
|
1759
|
+
if (useSnapshotAsPrimary) {
|
|
1760
|
+
theirs = snapshotForFile;
|
|
1761
|
+
primarySource = "snapshot";
|
|
1762
|
+
} else {
|
|
1763
|
+
theirs = await applyPatchToContent2(
|
|
1764
|
+
base,
|
|
1765
|
+
patch.patch_content,
|
|
1766
|
+
filePath,
|
|
1767
|
+
tempGit,
|
|
1768
|
+
tempDir,
|
|
1769
|
+
renameSourcePath
|
|
1770
|
+
);
|
|
1771
|
+
const reconstructionHasMarkers = hasConflictMarkerStrings(theirs);
|
|
1772
|
+
const baseHadMarkers = hasConflictMarkerStrings(base);
|
|
1773
|
+
if (reconstructionHasMarkers && !baseHadMarkers && snapshotForFile != null) {
|
|
1774
|
+
theirs = snapshotForFile;
|
|
1992
1775
|
}
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
}
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
const accumulatorEntry = accumulator.lookup(resolvedPath);
|
|
1779
|
+
if (theirs) {
|
|
1780
|
+
const theirsHasMarkers = hasConflictMarkerStrings(theirs);
|
|
1781
|
+
const baseHasMarkers = hasConflictMarkerStrings(base);
|
|
1782
|
+
if (theirsHasMarkers && !baseHasMarkers) {
|
|
1783
|
+
if (accumulatorEntry) {
|
|
1784
|
+
theirs = null;
|
|
1785
|
+
} else {
|
|
1786
|
+
return { kind: "skipped", reason: "stale-conflict-markers" };
|
|
2004
1787
|
}
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
let useAccumulatorAsMergeBase = false;
|
|
1791
|
+
if (accumulatorEntry && (theirs == null || base == null)) {
|
|
1792
|
+
theirs = await applyPatchToContent2(
|
|
1793
|
+
accumulatorEntry.content,
|
|
1794
|
+
patch.patch_content,
|
|
1795
|
+
filePath,
|
|
1796
|
+
tempGit,
|
|
1797
|
+
tempDir
|
|
1798
|
+
);
|
|
1799
|
+
if (theirs != null) {
|
|
1800
|
+
useAccumulatorAsMergeBase = true;
|
|
1801
|
+
} else if (await isPatchAlreadyApplied(
|
|
1802
|
+
patch.patch_content,
|
|
1803
|
+
filePath,
|
|
1804
|
+
accumulatorEntry.content,
|
|
1805
|
+
tempGit,
|
|
1806
|
+
tempDir
|
|
1807
|
+
)) {
|
|
1808
|
+
theirs = accumulatorEntry.content;
|
|
1809
|
+
useAccumulatorAsMergeBase = true;
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
if (theirs) {
|
|
1813
|
+
const theirsHasMarkers = hasConflictMarkerStrings(theirs);
|
|
1814
|
+
const accBaseHasMarkers = accumulatorEntry != null && hasConflictMarkerStrings(accumulatorEntry.content);
|
|
1815
|
+
if (theirsHasMarkers && !accBaseHasMarkers && !hasConflictMarkerStrings(base)) {
|
|
1816
|
+
return { kind: "skipped", reason: "stale-conflict-markers" };
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
let effective_theirs = theirs;
|
|
1820
|
+
let conflictReasonHint;
|
|
1821
|
+
if (theirs != null && base != null && !useAccumulatorAsMergeBase) {
|
|
1822
|
+
if (accumulatorEntry && accumulatorEntry.baseGeneration === patch.base_generation) {
|
|
1823
|
+
try {
|
|
1824
|
+
const preMerged = threeWayMerge(base, accumulatorEntry.content, theirs);
|
|
1825
|
+
if (!preMerged.hasConflicts) {
|
|
1826
|
+
effective_theirs = preMerged.content;
|
|
1827
|
+
} else {
|
|
1828
|
+
effective_theirs = theirs;
|
|
2012
1829
|
}
|
|
1830
|
+
} catch {
|
|
1831
|
+
effective_theirs = theirs;
|
|
2013
1832
|
}
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
}
|
|
1833
|
+
} else if (accumulatorEntry) {
|
|
1834
|
+
conflictReasonHint = "base-generation-mismatch";
|
|
2017
1835
|
}
|
|
2018
|
-
const filteredPatches = survivingPatches.filter((_, i) => !revertIndicesToRemove.has(i));
|
|
2019
|
-
return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
|
|
2020
1836
|
}
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
1837
|
+
if (base == null && ours == null && effective_theirs != null) {
|
|
1838
|
+
return { kind: "new-file-only-user", theirs: effective_theirs };
|
|
1839
|
+
}
|
|
1840
|
+
if (base == null && ours != null && effective_theirs != null && !useAccumulatorAsMergeBase) {
|
|
1841
|
+
return { kind: "new-file-both", theirs: effective_theirs };
|
|
1842
|
+
}
|
|
1843
|
+
if (effective_theirs == null) {
|
|
1844
|
+
return { kind: "skipped", reason: "missing-content" };
|
|
1845
|
+
}
|
|
1846
|
+
if (base == null && !useAccumulatorAsMergeBase || ours == null) {
|
|
1847
|
+
return { kind: "skipped", reason: "missing-content" };
|
|
1848
|
+
}
|
|
1849
|
+
if (useAccumulatorAsMergeBase) {
|
|
1850
|
+
const mergeBase = accumulatorEntry?.content ?? base;
|
|
1851
|
+
if (mergeBase == null) {
|
|
1852
|
+
return { kind: "skipped", reason: "missing-content" };
|
|
1853
|
+
}
|
|
1854
|
+
return {
|
|
1855
|
+
kind: "accumulator-base",
|
|
1856
|
+
theirs: effective_theirs,
|
|
1857
|
+
mergeBase,
|
|
1858
|
+
conflictReasonHint
|
|
1859
|
+
};
|
|
1860
|
+
}
|
|
1861
|
+
return {
|
|
1862
|
+
kind: primarySource,
|
|
1863
|
+
base,
|
|
1864
|
+
theirs: effective_theirs,
|
|
1865
|
+
mergeBase: base,
|
|
1866
|
+
conflictReasonHint
|
|
1867
|
+
};
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
// src/applicator/runMergeAndRecord.ts
|
|
1871
|
+
var import_promises2 = require("fs/promises");
|
|
1872
|
+
var import_node_path4 = require("path");
|
|
1873
|
+
async function runMergeAndRecord(args) {
|
|
1874
|
+
const { plan, inputs, patch, accumulator } = args;
|
|
1875
|
+
const { resolvedPath, metadata, oursPath, ours } = inputs;
|
|
1876
|
+
if (plan.kind === "skipped") {
|
|
1877
|
+
return { file: resolvedPath, status: "skipped", reason: plan.reason };
|
|
1878
|
+
}
|
|
1879
|
+
if (plan.kind === "new-file-only-user") {
|
|
1880
|
+
accumulator.recordMerge(resolvedPath, plan.theirs, patch.base_generation);
|
|
1881
|
+
await (0, import_promises2.mkdir)((0, import_node_path4.dirname)(oursPath), { recursive: true });
|
|
1882
|
+
await (0, import_promises2.writeFile)(oursPath, plan.theirs);
|
|
1883
|
+
return { file: resolvedPath, status: "merged", reason: "new-file" };
|
|
1884
|
+
}
|
|
1885
|
+
if (plan.kind === "new-file-both") {
|
|
1886
|
+
const merged2 = threeWayMerge("", ours, plan.theirs);
|
|
1887
|
+
await (0, import_promises2.mkdir)((0, import_node_path4.dirname)(oursPath), { recursive: true });
|
|
1888
|
+
await (0, import_promises2.writeFile)(oursPath, merged2.content);
|
|
1889
|
+
if (merged2.hasConflicts) {
|
|
1890
|
+
return {
|
|
1891
|
+
file: resolvedPath,
|
|
1892
|
+
status: "conflict",
|
|
1893
|
+
conflicts: merged2.conflicts,
|
|
1894
|
+
conflictReason: "new-file-both",
|
|
1895
|
+
conflictMetadata: metadata
|
|
1896
|
+
};
|
|
1897
|
+
}
|
|
1898
|
+
accumulator.recordMerge(resolvedPath, merged2.content, patch.base_generation);
|
|
1899
|
+
return { file: resolvedPath, status: "merged" };
|
|
1900
|
+
}
|
|
1901
|
+
const merged = threeWayMerge(plan.mergeBase, ours, plan.theirs);
|
|
1902
|
+
await (0, import_promises2.mkdir)((0, import_node_path4.dirname)(oursPath), { recursive: true });
|
|
1903
|
+
await (0, import_promises2.writeFile)(oursPath, merged.content);
|
|
1904
|
+
if (merged.hasConflicts) {
|
|
1905
|
+
accumulator.recordConflict(resolvedPath, plan.theirs, patch.base_generation);
|
|
1906
|
+
return {
|
|
1907
|
+
file: resolvedPath,
|
|
1908
|
+
status: "conflict",
|
|
1909
|
+
conflicts: merged.conflicts,
|
|
1910
|
+
conflictReason: plan.conflictReasonHint ?? "same-line-edit",
|
|
1911
|
+
conflictMetadata: metadata
|
|
1912
|
+
};
|
|
2044
1913
|
}
|
|
1914
|
+
accumulator.recordMerge(resolvedPath, plan.theirs, patch.base_generation);
|
|
1915
|
+
return {
|
|
1916
|
+
file: resolvedPath,
|
|
1917
|
+
status: "merged",
|
|
1918
|
+
...merged.droppedContextLines && merged.droppedContextLines.length > 0 ? { droppedContextLines: merged.droppedContextLines } : {}
|
|
1919
|
+
};
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
// src/ReplayApplicator.ts
|
|
1923
|
+
var ReplayApplicator = class {
|
|
1924
|
+
git;
|
|
1925
|
+
lockManager;
|
|
1926
|
+
outputDir;
|
|
1927
|
+
renameCache = /* @__PURE__ */ new Map();
|
|
1928
|
+
treeExistsCache = /* @__PURE__ */ new Map();
|
|
2045
1929
|
/**
|
|
2046
|
-
*
|
|
2047
|
-
*
|
|
2048
|
-
*
|
|
1930
|
+
* Inter-patch THEIRS coordination. Created fresh in each `applyPatches`
|
|
1931
|
+
* invocation with the run's apply mode + precomputed shared-file set.
|
|
1932
|
+
* See `src/accumulator/MergeAccumulator.ts` for the full contract.
|
|
2049
1933
|
*
|
|
2050
|
-
*
|
|
2051
|
-
*
|
|
2052
|
-
*
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
*
|
|
2062
|
-
*
|
|
2063
|
-
*
|
|
2064
|
-
|
|
1934
|
+
* Initialized to an empty default in the constructor so type narrowing
|
|
1935
|
+
* works for callers that read it before any `applyPatches` invocation
|
|
1936
|
+
* (e.g., the `getAccumulatorSnapshot` test accessor).
|
|
1937
|
+
*/
|
|
1938
|
+
accumulator = new MergeAccumulator("replay", /* @__PURE__ */ new Set());
|
|
1939
|
+
constructor(git, lockManager, outputDir) {
|
|
1940
|
+
this.git = git;
|
|
1941
|
+
this.lockManager = lockManager;
|
|
1942
|
+
this.outputDir = outputDir;
|
|
1943
|
+
}
|
|
1944
|
+
/**
|
|
1945
|
+
* Resolve the GenerationRecord for a patch's base_generation.
|
|
1946
|
+
* Falls back to constructing an ad-hoc record from the commit's tree
|
|
1947
|
+
* when base_generation isn't a tracked generation (commit-scan patches).
|
|
1948
|
+
*/
|
|
1949
|
+
async resolveBaseGeneration(baseGeneration) {
|
|
1950
|
+
const gen = this.lockManager.getGeneration(baseGeneration);
|
|
1951
|
+
if (gen) return gen;
|
|
1952
|
+
try {
|
|
1953
|
+
const treeHash = await this.git.getTreeHash(baseGeneration);
|
|
1954
|
+
return {
|
|
1955
|
+
commit_sha: baseGeneration,
|
|
1956
|
+
tree_hash: treeHash,
|
|
1957
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1958
|
+
cli_version: "unknown",
|
|
1959
|
+
generator_versions: {}
|
|
1960
|
+
};
|
|
1961
|
+
} catch {
|
|
1962
|
+
return void 0;
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
/**
|
|
1966
|
+
* @internal Test-only.
|
|
1967
|
+
* Returns a defensive copy of the inter-patch accumulator. Used by
|
|
1968
|
+
* invariant I2 to assert that after every applied patch, the
|
|
1969
|
+
* accumulator has an entry for each file the patch touched, and the
|
|
1970
|
+
* entry's content matches on-disk.
|
|
1971
|
+
*/
|
|
1972
|
+
getAccumulatorSnapshot() {
|
|
1973
|
+
return this.accumulator.snapshot();
|
|
1974
|
+
}
|
|
1975
|
+
/**
|
|
1976
|
+
* Apply all patches, returning results for each.
|
|
1977
|
+
* Skips patches that match exclude patterns in replay.yml
|
|
2065
1978
|
*
|
|
2066
|
-
*
|
|
2067
|
-
*
|
|
2068
|
-
*
|
|
2069
|
-
*
|
|
2070
|
-
*
|
|
2071
|
-
*
|
|
2072
|
-
*
|
|
2073
|
-
*
|
|
1979
|
+
* `applyMode` controls the post-conflict marker strategy:
|
|
1980
|
+
* - `"replay"` (default): strip conflict markers from disk between
|
|
1981
|
+
* iterations whenever a later patch touches the same file. Lets the
|
|
1982
|
+
* follow-up patch's `git apply --3way` see clean OURS content,
|
|
1983
|
+
* which is what the silent-loss fix (PR #73) relies on. The replay
|
|
1984
|
+
* pipeline calls `revertConflictingFiles` after the loop to clean
|
|
1985
|
+
* any markers that survive.
|
|
1986
|
+
* - `"resolve"`: keep markers on disk. The resolve command needs the
|
|
1987
|
+
* customer to see them. Slow-path 3-way merge naturally preserves
|
|
1988
|
+
* A's markers and B's clean writes when their regions don't overlap;
|
|
1989
|
+
* when they do overlap, the customer gets nested markers and
|
|
1990
|
+
* resolves manually. Either way they aren't silently dropped.
|
|
2074
1991
|
*/
|
|
2075
|
-
async
|
|
2076
|
-
|
|
2077
|
-
const
|
|
2078
|
-
for (const
|
|
2079
|
-
for (const
|
|
2080
|
-
|
|
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);
|
|
1992
|
+
async applyPatches(patches, opts) {
|
|
1993
|
+
const applyMode = opts?.applyMode ?? "replay";
|
|
1994
|
+
const fileFreq = /* @__PURE__ */ new Map();
|
|
1995
|
+
for (const p of patches) {
|
|
1996
|
+
for (const f of p.files) {
|
|
1997
|
+
fileFreq.set(f, (fileFreq.get(f) ?? 0) + 1);
|
|
2085
1998
|
}
|
|
2086
1999
|
}
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2000
|
+
const sharedFiles = new Set(
|
|
2001
|
+
Array.from(fileFreq.entries()).filter(([, n]) => n > 1).map(([f]) => f)
|
|
2002
|
+
);
|
|
2003
|
+
this.accumulator = new MergeAccumulator(applyMode, sharedFiles);
|
|
2004
|
+
const results = [];
|
|
2005
|
+
for (let i = 0; i < patches.length; i++) {
|
|
2006
|
+
const patch = patches[i];
|
|
2007
|
+
if (this.isExcluded(patch)) {
|
|
2008
|
+
results.push({
|
|
2009
|
+
patch,
|
|
2010
|
+
status: "skipped",
|
|
2011
|
+
method: "git-am"
|
|
2012
|
+
});
|
|
2013
|
+
continue;
|
|
2014
|
+
}
|
|
2015
|
+
const result = await this.applyPatchWithFallback(patch);
|
|
2016
|
+
results.push(result);
|
|
2017
|
+
if (applyMode !== "resolve" && result.status === "conflict" && result.fileResults) {
|
|
2018
|
+
const laterFiles = /* @__PURE__ */ new Set();
|
|
2019
|
+
for (let j = i + 1; j < patches.length; j++) {
|
|
2020
|
+
for (const f of patches[j].files) {
|
|
2021
|
+
laterFiles.add(f);
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
const resolvedToOriginal = /* @__PURE__ */ new Map();
|
|
2025
|
+
if (result.resolvedFiles) {
|
|
2026
|
+
for (const [orig, resolved] of Object.entries(result.resolvedFiles)) {
|
|
2027
|
+
resolvedToOriginal.set(resolved, orig);
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
for (const fileResult of result.fileResults) {
|
|
2031
|
+
if (fileResult.status !== "conflict") continue;
|
|
2032
|
+
const originalPath = resolvedToOriginal.get(fileResult.file) ?? fileResult.file;
|
|
2033
|
+
if (laterFiles.has(fileResult.file) || laterFiles.has(originalPath)) {
|
|
2034
|
+
const filePath = (0, import_node_path5.join)(this.outputDir, fileResult.file);
|
|
2035
|
+
try {
|
|
2036
|
+
const content = await (0, import_promises3.readFile)(filePath, "utf-8");
|
|
2037
|
+
const stripped = stripConflictMarkers(content);
|
|
2038
|
+
await (0, import_promises3.writeFile)(filePath, stripped);
|
|
2039
|
+
} catch {
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2092
2043
|
}
|
|
2093
2044
|
}
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2045
|
+
return results;
|
|
2046
|
+
}
|
|
2047
|
+
/**
|
|
2048
|
+
* Populate accumulator after git apply succeeds, AND collect per-file
|
|
2049
|
+
* results including any "dropped context lines" — lines that were
|
|
2050
|
+
* present in BASE and THEIRS (unchanged context) but absent from the
|
|
2051
|
+
* final on-disk state because OURS deleted them. This is the fast-path
|
|
2052
|
+
* counterpart to ThreeWayMerge.computeDroppedContextLines used by the
|
|
2053
|
+
* 3-way slow path; both must surface the same warning.
|
|
2054
|
+
*/
|
|
2055
|
+
async populateAccumulatorForPatch(patch, baseGen, currentTreeHash) {
|
|
2056
|
+
const fileResults = [];
|
|
2057
|
+
if (!baseGen) return fileResults;
|
|
2058
|
+
const tempDir = await (0, import_promises3.mkdtemp)((0, import_node_path5.join)((0, import_node_os.tmpdir)(), "replay-acc-"));
|
|
2059
|
+
const { GitClient: GitClient2 } = await Promise.resolve().then(() => (init_GitClient(), GitClient_exports));
|
|
2060
|
+
const tempGit = new GitClient2(tempDir);
|
|
2061
|
+
await tempGit.exec(["init"]);
|
|
2062
|
+
await tempGit.exec(["config", "user.email", "replay@fern.com"]);
|
|
2063
|
+
await tempGit.exec(["config", "user.name", "Fern Replay"]);
|
|
2064
|
+
try {
|
|
2065
|
+
for (const filePath of patch.files) {
|
|
2066
|
+
if (isBinaryFile(filePath)) continue;
|
|
2067
|
+
const resolvedPath = await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
|
|
2068
|
+
const base = await this.git.showFile(baseGen.tree_hash, filePath);
|
|
2069
|
+
const snapshotForFile = patch.theirs_snapshot?.[resolvedPath] ?? patch.theirs_snapshot?.[filePath];
|
|
2070
|
+
const fileIsShared = this.accumulator.isShared(resolvedPath) || this.accumulator.isShared(filePath);
|
|
2071
|
+
const theirs = !fileIsShared && snapshotForFile != null ? snapshotForFile : await this.applyPatchToContent(base, patch.patch_content, filePath, tempGit, tempDir);
|
|
2072
|
+
const finalOnDisk = await (0, import_promises3.readFile)((0, import_node_path5.join)(this.outputDir, resolvedPath), "utf-8").catch(() => null);
|
|
2073
|
+
const effectiveTheirs = theirs ?? finalOnDisk;
|
|
2074
|
+
if (effectiveTheirs != null) {
|
|
2075
|
+
this.accumulator.recordMerge(resolvedPath, effectiveTheirs, patch.base_generation);
|
|
2076
|
+
}
|
|
2077
|
+
if (base != null && effectiveTheirs != null && finalOnDisk != null) {
|
|
2078
|
+
const dropped = computeDroppedContextLinesForFile(base, effectiveTheirs, finalOnDisk);
|
|
2079
|
+
if (dropped.length > 0) {
|
|
2080
|
+
fileResults.push({
|
|
2081
|
+
file: resolvedPath,
|
|
2082
|
+
status: "merged",
|
|
2083
|
+
droppedContextLines: dropped
|
|
2084
|
+
});
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
} finally {
|
|
2089
|
+
await (0, import_promises3.rm)(tempDir, { recursive: true }).catch(() => {
|
|
2090
|
+
});
|
|
2091
|
+
}
|
|
2092
|
+
return fileResults;
|
|
2093
|
+
}
|
|
2094
|
+
async applyPatchWithFallback(patch) {
|
|
2095
|
+
const baseGen = await this.resolveBaseGeneration(patch.base_generation);
|
|
2096
|
+
const lock = this.lockManager.read();
|
|
2097
|
+
const currentGen = lock.generations.find((g) => g.commit_sha === lock.current_generation);
|
|
2098
|
+
const currentTreeHash = currentGen?.tree_hash ?? baseGen?.tree_hash ?? "";
|
|
2099
|
+
const needsAccumulation = await Promise.all(
|
|
2100
|
+
patch.files.map(async (f) => {
|
|
2101
|
+
if (!baseGen) return false;
|
|
2102
|
+
const resolved = await this.resolveFilePath(f, baseGen.tree_hash, currentTreeHash);
|
|
2103
|
+
return this.accumulator.has(resolved);
|
|
2104
|
+
})
|
|
2105
|
+
).then((results) => results.some(Boolean));
|
|
2106
|
+
const resolvedFiles = {};
|
|
2107
|
+
for (const filePath of patch.files) {
|
|
2108
|
+
const resolvedPath = baseGen ? await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash) : filePath;
|
|
2109
|
+
if (resolvedPath !== filePath) {
|
|
2110
|
+
resolvedFiles[filePath] = resolvedPath;
|
|
2100
2111
|
}
|
|
2101
2112
|
}
|
|
2102
|
-
|
|
2103
|
-
|
|
2113
|
+
const patchIsRenameAware = /^rename from /m.test(patch.patch_content);
|
|
2114
|
+
const hasGeneratorRename = Object.keys(resolvedFiles).length > 0 && !patchIsRenameAware;
|
|
2115
|
+
if (!needsAccumulation && !hasGeneratorRename) {
|
|
2116
|
+
const snapshots = /* @__PURE__ */ new Map();
|
|
2117
|
+
for (const filePath of patch.files) {
|
|
2118
|
+
const fullPath = (0, import_node_path5.join)(this.outputDir, filePath);
|
|
2119
|
+
snapshots.set(filePath, await (0, import_promises3.readFile)(fullPath, "utf-8").catch(() => null));
|
|
2120
|
+
}
|
|
2121
|
+
try {
|
|
2122
|
+
await this.git.execWithInput(["apply", "--3way"], patch.patch_content);
|
|
2123
|
+
const fastPathFileResults = await this.populateAccumulatorForPatch(patch, baseGen, currentTreeHash);
|
|
2124
|
+
return {
|
|
2125
|
+
patch,
|
|
2126
|
+
status: "applied",
|
|
2127
|
+
method: "git-am",
|
|
2128
|
+
...fastPathFileResults.length > 0 && { fileResults: fastPathFileResults },
|
|
2129
|
+
...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
|
|
2130
|
+
};
|
|
2131
|
+
} catch {
|
|
2132
|
+
for (const [filePath, content] of snapshots) {
|
|
2133
|
+
const fullPath = (0, import_node_path5.join)(this.outputDir, filePath);
|
|
2134
|
+
if (content != null) {
|
|
2135
|
+
await (0, import_promises3.writeFile)(fullPath, content);
|
|
2136
|
+
} else {
|
|
2137
|
+
await (0, import_promises3.unlink)(fullPath).catch(() => {
|
|
2138
|
+
});
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
return this.applyWithThreeWayMerge(patch);
|
|
2144
|
+
}
|
|
2145
|
+
async applyWithThreeWayMerge(patch) {
|
|
2146
|
+
const fileResults = [];
|
|
2147
|
+
const resolvedFiles = {};
|
|
2148
|
+
const tempDir = await (0, import_promises3.mkdtemp)((0, import_node_path5.join)((0, import_node_os.tmpdir)(), "replay-"));
|
|
2149
|
+
const { GitClient: GitClient2 } = await Promise.resolve().then(() => (init_GitClient(), GitClient_exports));
|
|
2150
|
+
const tempGit = new GitClient2(tempDir);
|
|
2151
|
+
await tempGit.exec(["init"]);
|
|
2152
|
+
await tempGit.exec(["config", "user.email", "replay@fern.com"]);
|
|
2153
|
+
await tempGit.exec(["config", "user.name", "Fern Replay"]);
|
|
2154
|
+
try {
|
|
2155
|
+
for (const filePath of patch.files) {
|
|
2156
|
+
if (isBinaryFile(filePath)) {
|
|
2157
|
+
fileResults.push({
|
|
2158
|
+
file: filePath,
|
|
2159
|
+
status: "skipped",
|
|
2160
|
+
reason: "binary-file"
|
|
2161
|
+
});
|
|
2162
|
+
continue;
|
|
2163
|
+
}
|
|
2164
|
+
const result = await this.mergeFile(patch, filePath, tempGit, tempDir);
|
|
2165
|
+
if (result.file !== filePath) {
|
|
2166
|
+
resolvedFiles[filePath] = result.file;
|
|
2167
|
+
}
|
|
2168
|
+
fileResults.push(result);
|
|
2169
|
+
}
|
|
2170
|
+
} finally {
|
|
2171
|
+
await (0, import_promises3.rm)(tempDir, { recursive: true }).catch(() => {
|
|
2172
|
+
});
|
|
2173
|
+
}
|
|
2174
|
+
const conflictFiles = fileResults.filter((r) => r.status === "conflict");
|
|
2175
|
+
const hasConflicts = conflictFiles.length > 0;
|
|
2176
|
+
const conflictReason = hasConflicts ? conflictFiles.some((f) => f.conflictReason === "base-generation-mismatch") ? "base-generation-mismatch" : conflictFiles[0]?.conflictReason : void 0;
|
|
2177
|
+
return {
|
|
2178
|
+
patch,
|
|
2179
|
+
status: hasConflicts ? "conflict" : "applied",
|
|
2180
|
+
method: "3way-merge",
|
|
2181
|
+
fileResults,
|
|
2182
|
+
conflictReason,
|
|
2183
|
+
...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
|
|
2184
|
+
};
|
|
2185
|
+
}
|
|
2186
|
+
async mergeFile(patch, filePath, tempGit, tempDir) {
|
|
2187
|
+
try {
|
|
2188
|
+
const inputs = await resolveInputs({
|
|
2189
|
+
patch,
|
|
2190
|
+
filePath,
|
|
2191
|
+
git: this.git,
|
|
2192
|
+
lockManager: this.lockManager,
|
|
2193
|
+
outputDir: this.outputDir,
|
|
2194
|
+
isTreeReachable: (h) => this.isTreeReachable(h),
|
|
2195
|
+
resolveBaseGeneration: (s) => this.resolveBaseGeneration(s),
|
|
2196
|
+
resolveFilePath: (f, b, c) => this.resolveFilePath(f, b, c),
|
|
2197
|
+
extractRenameSource: (p, f) => this.extractRenameSource(p, f),
|
|
2198
|
+
extractFileDiff: (p, f) => this.extractFileDiff(p, f)
|
|
2199
|
+
});
|
|
2200
|
+
if (inputs.earlyExit) {
|
|
2201
|
+
return { file: filePath, ...inputs.earlyExit };
|
|
2202
|
+
}
|
|
2203
|
+
const plan = await buildMergePlan({
|
|
2204
|
+
inputs,
|
|
2205
|
+
patch,
|
|
2206
|
+
filePath,
|
|
2207
|
+
accumulator: this.accumulator,
|
|
2208
|
+
tempGit,
|
|
2209
|
+
tempDir,
|
|
2210
|
+
applyPatchToContent: (b, p, f, g, d, s) => this.applyPatchToContent(b, p, f, g, d, s),
|
|
2211
|
+
isPatchAlreadyApplied: (p, f, c, g, d) => this.isPatchAlreadyApplied(p, f, c, g, d)
|
|
2212
|
+
});
|
|
2213
|
+
return await runMergeAndRecord({
|
|
2214
|
+
plan,
|
|
2215
|
+
inputs,
|
|
2216
|
+
patch,
|
|
2217
|
+
accumulator: this.accumulator
|
|
2218
|
+
});
|
|
2219
|
+
} catch (error) {
|
|
2220
|
+
return {
|
|
2221
|
+
file: filePath,
|
|
2222
|
+
status: "skipped",
|
|
2223
|
+
reason: `error: ${error instanceof Error ? error.message : String(error)}`
|
|
2224
|
+
};
|
|
2225
|
+
}
|
|
2104
2226
|
}
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
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();
|
|
2227
|
+
async isTreeReachable(treeHash) {
|
|
2228
|
+
let result = this.treeExistsCache.get(treeHash);
|
|
2229
|
+
if (result === void 0) {
|
|
2230
|
+
result = await this.git.treeExists(treeHash);
|
|
2231
|
+
this.treeExistsCache.set(treeHash, result);
|
|
2117
2232
|
}
|
|
2233
|
+
return result;
|
|
2118
2234
|
}
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
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
|
-
);
|
|
2235
|
+
isExcluded(patch) {
|
|
2236
|
+
const config = this.lockManager.getCustomizationsConfig();
|
|
2237
|
+
if (!config.exclude) return false;
|
|
2238
|
+
return patch.files.some((file) => config.exclude.some((pattern) => (0, import_minimatch.minimatch)(file, pattern)));
|
|
2239
|
+
}
|
|
2240
|
+
async resolveFilePath(filePath, baseTreeHash, currentTreeHash) {
|
|
2241
|
+
const config = this.lockManager.getCustomizationsConfig();
|
|
2242
|
+
if (config.moves) {
|
|
2243
|
+
for (const move of config.moves) {
|
|
2244
|
+
if ((0, import_minimatch.minimatch)(filePath, move.from) || filePath === move.from) {
|
|
2245
|
+
if (filePath === move.from) {
|
|
2246
|
+
return move.to;
|
|
2247
|
+
}
|
|
2248
|
+
const fromBase = move.from.replace(/\*\*.*$/, "");
|
|
2249
|
+
const toBase = move.to.replace(/\*\*.*$/, "");
|
|
2250
|
+
if (filePath.startsWith(fromBase)) {
|
|
2251
|
+
return toBase + filePath.slice(fromBase.length);
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2141
2255
|
}
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
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)) {
|
|
2153
|
-
return null;
|
|
2256
|
+
const cacheKey = `${baseTreeHash}:${currentTreeHash}`;
|
|
2257
|
+
let renames = this.renameCache.get(cacheKey);
|
|
2258
|
+
if (!renames) {
|
|
2259
|
+
renames = await this.git.detectRenames(baseTreeHash, currentTreeHash);
|
|
2260
|
+
this.renameCache.set(cacheKey, renames);
|
|
2154
2261
|
}
|
|
2155
|
-
const
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
const content = await this.git.showFile(mergeCommit.sha, file).catch(() => null);
|
|
2159
|
-
if (content != null) theirsSnapshot[file] = content;
|
|
2262
|
+
const gitRename = renames.find((r) => r.from === filePath);
|
|
2263
|
+
if (gitRename) {
|
|
2264
|
+
return gitRename.to;
|
|
2160
2265
|
}
|
|
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
|
-
};
|
|
2266
|
+
return filePath;
|
|
2173
2267
|
}
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
* patch from the aggregate diff — individual revert commits are not distinguishable.
|
|
2178
|
-
*/
|
|
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: [] };
|
|
2268
|
+
async applyPatchToContent(base, patchContent, filePath, tempGit, tempDir, sourceFilePath) {
|
|
2269
|
+
if (!base) {
|
|
2270
|
+
return this.extractNewFileFromPatch(patchContent, filePath);
|
|
2200
2271
|
}
|
|
2201
|
-
const
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
if (
|
|
2205
|
-
|
|
2206
|
-
|
|
2272
|
+
const fileDiff = this.extractFileDiff(patchContent, filePath);
|
|
2273
|
+
if (!fileDiff) return null;
|
|
2274
|
+
try {
|
|
2275
|
+
if (sourceFilePath) {
|
|
2276
|
+
const tempSourcePath = (0, import_node_path5.join)(tempDir, sourceFilePath);
|
|
2277
|
+
await (0, import_promises3.mkdir)((0, import_node_path5.dirname)(tempSourcePath), { recursive: true });
|
|
2278
|
+
await (0, import_promises3.writeFile)(tempSourcePath, base);
|
|
2279
|
+
await tempGit.exec(["add", sourceFilePath]);
|
|
2280
|
+
await tempGit.exec([
|
|
2281
|
+
"commit",
|
|
2282
|
+
"-m",
|
|
2283
|
+
`base for rename ${sourceFilePath} -> ${filePath}`,
|
|
2284
|
+
"--allow-empty"
|
|
2285
|
+
]);
|
|
2286
|
+
await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
|
|
2287
|
+
const tempTargetPath = (0, import_node_path5.join)(tempDir, filePath);
|
|
2288
|
+
return await (0, import_promises3.readFile)(tempTargetPath, "utf-8");
|
|
2289
|
+
}
|
|
2290
|
+
const tempFilePath = (0, import_node_path5.join)(tempDir, filePath);
|
|
2291
|
+
await (0, import_promises3.mkdir)((0, import_node_path5.dirname)(tempFilePath), { recursive: true });
|
|
2292
|
+
await (0, import_promises3.writeFile)(tempFilePath, base);
|
|
2293
|
+
await tempGit.exec(["add", filePath]);
|
|
2294
|
+
await tempGit.exec(["commit", "-m", `base for ${filePath}`, "--allow-empty"]);
|
|
2295
|
+
await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
|
|
2296
|
+
return await (0, import_promises3.readFile)(tempFilePath, "utf-8");
|
|
2297
|
+
} catch {
|
|
2298
|
+
return null;
|
|
2207
2299
|
}
|
|
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
2300
|
}
|
|
2224
2301
|
/**
|
|
2225
|
-
*
|
|
2226
|
-
*
|
|
2227
|
-
*
|
|
2228
|
-
*
|
|
2229
|
-
*
|
|
2230
|
-
* can find base file content from the parent's tree (which IS reachable).
|
|
2302
|
+
* Detects whether a patch's additions are already present in `content`.
|
|
2303
|
+
* Uses `git apply --reverse --check` — if the reverse patch applies cleanly,
|
|
2304
|
+
* the forward patch is effectively a no-op (its +lines are already there and
|
|
2305
|
+
* its -lines are already gone). Used to distinguish "patch already applied"
|
|
2306
|
+
* from "patch base mismatch" after a forward-apply fails.
|
|
2231
2307
|
*/
|
|
2232
|
-
async
|
|
2233
|
-
const
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
"
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2308
|
+
async isPatchAlreadyApplied(patchContent, filePath, content, tempGit, tempDir) {
|
|
2309
|
+
const fileDiff = this.extractFileDiff(patchContent, filePath);
|
|
2310
|
+
if (!fileDiff) return false;
|
|
2311
|
+
try {
|
|
2312
|
+
const tempFilePath = (0, import_node_path5.join)(tempDir, filePath);
|
|
2313
|
+
await (0, import_promises3.mkdir)((0, import_node_path5.dirname)(tempFilePath), { recursive: true });
|
|
2314
|
+
await (0, import_promises3.writeFile)(tempFilePath, content);
|
|
2315
|
+
await tempGit.exec(["add", filePath]);
|
|
2316
|
+
await tempGit.exec(["commit", "-m", `check already-applied ${filePath}`, "--allow-empty"]);
|
|
2317
|
+
await tempGit.execWithInput(["apply", "--reverse", "--check", "--allow-empty"], fileDiff);
|
|
2318
|
+
return true;
|
|
2319
|
+
} catch {
|
|
2320
|
+
return false;
|
|
2244
2321
|
}
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
const
|
|
2248
|
-
const
|
|
2249
|
-
|
|
2250
|
-
for (const
|
|
2251
|
-
if (
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2322
|
+
}
|
|
2323
|
+
extractFileDiff(patchContent, filePath) {
|
|
2324
|
+
const lines = patchContent.split("\n");
|
|
2325
|
+
const diffLines = [];
|
|
2326
|
+
let inTargetFile = false;
|
|
2327
|
+
for (const line of lines) {
|
|
2328
|
+
if (line.startsWith("diff --git")) {
|
|
2329
|
+
if (inTargetFile) {
|
|
2330
|
+
break;
|
|
2331
|
+
}
|
|
2332
|
+
if (isDiffLineForFile(line, filePath)) {
|
|
2333
|
+
inTargetFile = true;
|
|
2334
|
+
diffLines.push(line);
|
|
2335
|
+
}
|
|
2256
2336
|
continue;
|
|
2257
2337
|
}
|
|
2258
|
-
if (
|
|
2259
|
-
|
|
2338
|
+
if (inTargetFile) {
|
|
2339
|
+
diffLines.push(line);
|
|
2260
2340
|
}
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2341
|
+
}
|
|
2342
|
+
return diffLines.length > 0 ? diffLines.join("\n") + "\n" : null;
|
|
2343
|
+
}
|
|
2344
|
+
extractRenameSource(patchContent, targetFilePath) {
|
|
2345
|
+
const lines = patchContent.split("\n");
|
|
2346
|
+
let inTargetFile = false;
|
|
2347
|
+
for (const line of lines) {
|
|
2348
|
+
if (line.startsWith("diff --git")) {
|
|
2349
|
+
if (inTargetFile) break;
|
|
2350
|
+
inTargetFile = isDiffLineForFile(line, targetFilePath);
|
|
2265
2351
|
continue;
|
|
2266
2352
|
}
|
|
2267
|
-
|
|
2268
|
-
if (
|
|
2269
|
-
|
|
2353
|
+
if (!inTargetFile) continue;
|
|
2354
|
+
if (line.startsWith("@@")) break;
|
|
2355
|
+
if (line.startsWith("rename from ")) {
|
|
2356
|
+
return line.slice("rename from ".length);
|
|
2270
2357
|
}
|
|
2271
|
-
|
|
2358
|
+
}
|
|
2359
|
+
return null;
|
|
2360
|
+
}
|
|
2361
|
+
extractNewFileFromPatch(patchContent, filePath) {
|
|
2362
|
+
const lines = patchContent.split("\n");
|
|
2363
|
+
const addedLines = [];
|
|
2364
|
+
let inTargetFile = false;
|
|
2365
|
+
let inHunk = false;
|
|
2366
|
+
let isNewFile = false;
|
|
2367
|
+
let noTrailingNewline = false;
|
|
2368
|
+
for (const line of lines) {
|
|
2369
|
+
if (line.startsWith("diff --git")) {
|
|
2370
|
+
if (inTargetFile) break;
|
|
2371
|
+
inTargetFile = isDiffLineForFile(line, filePath);
|
|
2372
|
+
inHunk = false;
|
|
2373
|
+
isNewFile = false;
|
|
2272
2374
|
continue;
|
|
2273
2375
|
}
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
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);
|
|
2376
|
+
if (!inTargetFile) continue;
|
|
2377
|
+
if (!inHunk) {
|
|
2378
|
+
if (line === "--- /dev/null" || line.startsWith("new file mode")) {
|
|
2379
|
+
isNewFile = true;
|
|
2293
2380
|
}
|
|
2294
2381
|
}
|
|
2295
|
-
if (
|
|
2382
|
+
if (line.startsWith("@@")) {
|
|
2383
|
+
if (!isNewFile) return null;
|
|
2384
|
+
inHunk = true;
|
|
2296
2385
|
continue;
|
|
2297
2386
|
}
|
|
2298
|
-
if (
|
|
2387
|
+
if (!inHunk) continue;
|
|
2388
|
+
if (line === "\") {
|
|
2389
|
+
noTrailingNewline = true;
|
|
2299
2390
|
continue;
|
|
2300
2391
|
}
|
|
2301
|
-
|
|
2302
|
-
|
|
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;
|
|
2392
|
+
if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
2393
|
+
addedLines.push(line.slice(1));
|
|
2307
2394
|
}
|
|
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
2395
|
}
|
|
2333
|
-
|
|
2396
|
+
if (addedLines.length === 0) return null;
|
|
2397
|
+
return addedLines.join("\n") + (noTrailingNewline ? "" : "\n");
|
|
2334
2398
|
}
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2399
|
+
};
|
|
2400
|
+
function computeDroppedContextLinesForFile(base, theirs, finalContent) {
|
|
2401
|
+
const baseLines = base.split("\n");
|
|
2402
|
+
const theirsLines = theirs.split("\n");
|
|
2403
|
+
const finalLines = finalContent.split("\n");
|
|
2404
|
+
const baseCounts = /* @__PURE__ */ new Map();
|
|
2405
|
+
const theirsCounts = /* @__PURE__ */ new Map();
|
|
2406
|
+
const finalCounts = /* @__PURE__ */ new Map();
|
|
2407
|
+
for (const l of baseLines) baseCounts.set(l, (baseCounts.get(l) ?? 0) + 1);
|
|
2408
|
+
for (const l of theirsLines) theirsCounts.set(l, (theirsCounts.get(l) ?? 0) + 1);
|
|
2409
|
+
for (const l of finalLines) finalCounts.set(l, (finalCounts.get(l) ?? 0) + 1);
|
|
2410
|
+
const dropped = [];
|
|
2411
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2412
|
+
for (const line of baseLines) {
|
|
2413
|
+
if (seen.has(line)) continue;
|
|
2414
|
+
const inBase = baseCounts.get(line) ?? 0;
|
|
2415
|
+
const inTheirs = theirsCounts.get(line) ?? 0;
|
|
2416
|
+
const inFinal = finalCounts.get(line) ?? 0;
|
|
2417
|
+
if (inBase > 0 && inTheirs > 0 && inFinal < Math.min(inBase, inTheirs)) {
|
|
2418
|
+
dropped.push(line);
|
|
2419
|
+
seen.add(line);
|
|
2346
2420
|
}
|
|
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
2421
|
}
|
|
2358
|
-
|
|
2422
|
+
return dropped;
|
|
2423
|
+
}
|
|
2424
|
+
function isDiffLineForFile(diffLine, filePath) {
|
|
2425
|
+
const match = diffLine.match(/^diff --git a\/.+ b\/(.+)$/);
|
|
2426
|
+
return match !== null && match[1] === filePath;
|
|
2427
|
+
}
|
|
2359
2428
|
|
|
2360
2429
|
// src/ReplayCommitter.ts
|
|
2361
2430
|
var ReplayCommitter = class {
|
|
@@ -2462,15 +2531,15 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
|
|
|
2462
2531
|
|
|
2463
2532
|
// src/ReplayService.ts
|
|
2464
2533
|
var import_node_fs2 = require("fs");
|
|
2465
|
-
var
|
|
2466
|
-
var
|
|
2467
|
-
var
|
|
2534
|
+
var import_promises6 = require("fs/promises");
|
|
2535
|
+
var import_node_path8 = require("path");
|
|
2536
|
+
var import_minimatch3 = require("minimatch");
|
|
2468
2537
|
init_GitClient();
|
|
2469
2538
|
|
|
2470
2539
|
// src/PatchRegionDiff.ts
|
|
2471
|
-
var
|
|
2540
|
+
var import_promises4 = require("fs/promises");
|
|
2472
2541
|
var import_node_os2 = require("os");
|
|
2473
|
-
var
|
|
2542
|
+
var import_node_path6 = require("path");
|
|
2474
2543
|
var import_node_child_process = require("child_process");
|
|
2475
2544
|
init_HybridReconstruction();
|
|
2476
2545
|
function normalizeHunkBody(hunk) {
|
|
@@ -2677,17 +2746,17 @@ function overlayRegions(currentGenLines, locInCurrentGen, workingTreeLines, locI
|
|
|
2677
2746
|
}
|
|
2678
2747
|
async function unifiedDiff(beforeContent, afterContent, filePath) {
|
|
2679
2748
|
if (beforeContent === afterContent) return "";
|
|
2680
|
-
const tmp = await (0,
|
|
2749
|
+
const tmp = await (0, import_promises4.mkdtemp)((0, import_node_path6.join)((0, import_node_os2.tmpdir)(), "fern-replay-pp-"));
|
|
2681
2750
|
try {
|
|
2682
|
-
const beforePath = (0,
|
|
2683
|
-
const afterPath = (0,
|
|
2684
|
-
await (0,
|
|
2685
|
-
await (0,
|
|
2751
|
+
const beforePath = (0, import_node_path6.join)(tmp, "before");
|
|
2752
|
+
const afterPath = (0, import_node_path6.join)(tmp, "after");
|
|
2753
|
+
await (0, import_promises4.writeFile)(beforePath, beforeContent, "utf-8");
|
|
2754
|
+
await (0, import_promises4.writeFile)(afterPath, afterContent, "utf-8");
|
|
2686
2755
|
const raw = await runGitDiffNoIndex(beforePath, afterPath);
|
|
2687
2756
|
if (!raw.trim()) return "";
|
|
2688
2757
|
return rewriteDiffHeaders(raw, filePath);
|
|
2689
2758
|
} finally {
|
|
2690
|
-
await (0,
|
|
2759
|
+
await (0, import_promises4.rm)(tmp, { recursive: true, force: true });
|
|
2691
2760
|
}
|
|
2692
2761
|
}
|
|
2693
2762
|
function runGitDiffNoIndex(beforePath, afterPath) {
|
|
@@ -2759,211 +2828,84 @@ function synthesizeDeleteFileDiff(file, content) {
|
|
|
2759
2828
|
return header + "\n" + body + trailer + "\n";
|
|
2760
2829
|
}
|
|
2761
2830
|
|
|
2762
|
-
// src/
|
|
2763
|
-
var
|
|
2764
|
-
|
|
2765
|
-
|
|
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);
|
|
2831
|
+
// src/classifier/FileOwnership.ts
|
|
2832
|
+
var import_minimatch2 = require("minimatch");
|
|
2833
|
+
var FileOwnership = class {
|
|
2834
|
+
constructor(git) {
|
|
2799
2835
|
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
2836
|
}
|
|
2866
2837
|
/**
|
|
2867
|
-
*
|
|
2868
|
-
* Call this BEFORE runReplay() when the CLI detects a merged divergent PR.
|
|
2838
|
+
* Canonical resolution. Returns true if `file` is user-owned per:
|
|
2869
2839
|
*
|
|
2870
|
-
*
|
|
2871
|
-
*
|
|
2872
|
-
*
|
|
2873
|
-
*
|
|
2874
|
-
*
|
|
2840
|
+
* 1. `patch.user_owned === true` — authoritative, persisted flag.
|
|
2841
|
+
* 2. `file === ".fernignore"` — always user-owned.
|
|
2842
|
+
* 3. File matches a `.fernignore` pattern (via minimatch).
|
|
2843
|
+
* 4. Patch content shows `--- /dev/null` for this file (legacy-safe
|
|
2844
|
+
* signal for patches predating `user_owned` or whose flag was lost).
|
|
2845
|
+
* 5. Tree-based check against `patch.base_generation` when reachable
|
|
2846
|
+
* AND distinct from `currentGenSha`. Unreachable base → conservative
|
|
2847
|
+
* user-owned (silent-absorption safety net), with a one-time warning
|
|
2848
|
+
* per base SHA.
|
|
2849
|
+
* 6. Final fallback: check `currentGenSha` when no usable base exists
|
|
2850
|
+
* (legacy one-gen lockfile).
|
|
2875
2851
|
*/
|
|
2876
|
-
async
|
|
2877
|
-
const
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
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
|
-
}
|
|
2852
|
+
async resolveCanonical(ctx) {
|
|
2853
|
+
const { file, originalFileName, patch, currentGenSha, fernignorePatterns, warnedGens, warnings } = ctx;
|
|
2854
|
+
if (patch.user_owned) return true;
|
|
2855
|
+
if (file === ".fernignore") return true;
|
|
2856
|
+
if (fernignorePatterns.some((p) => file === p || (0, import_minimatch2.minimatch)(file, p))) return true;
|
|
2857
|
+
if (fileIsCreationInPatchContent(patch.patch_content, originalFileName ?? file)) {
|
|
2858
|
+
return true;
|
|
2902
2859
|
}
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
}
|
|
2913
|
-
for (const patch of redetectedPatches) {
|
|
2914
|
-
this.lockManager.addPatch(patch);
|
|
2860
|
+
const base = patch.base_generation;
|
|
2861
|
+
if (base && base !== currentGenSha) {
|
|
2862
|
+
const reachable = await this.git.commitExists(base) || await this.git.treeExists(base);
|
|
2863
|
+
if (!reachable) {
|
|
2864
|
+
if (!warnedGens.has(base)) {
|
|
2865
|
+
warnedGens.add(base);
|
|
2866
|
+
warnings.push(
|
|
2867
|
+
`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.`
|
|
2868
|
+
);
|
|
2915
2869
|
}
|
|
2870
|
+
return true;
|
|
2916
2871
|
}
|
|
2917
|
-
|
|
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
|
-
);
|
|
2872
|
+
return await this.git.showFile(base, originalFileName ?? file) === null;
|
|
2924
2873
|
}
|
|
2925
|
-
this.
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
2874
|
+
return await this.git.showFile(currentGenSha, originalFileName ?? file) === null;
|
|
2875
|
+
}
|
|
2876
|
+
};
|
|
2877
|
+
function fileIsCreationInPatchContent(patchContent, file) {
|
|
2878
|
+
const lines = patchContent.split("\n");
|
|
2879
|
+
let inFileSection = false;
|
|
2880
|
+
for (const line of lines) {
|
|
2881
|
+
if (line.startsWith("diff --git ")) {
|
|
2882
|
+
inFileSection = line.includes(` b/${file}`) || line.endsWith(` b/${file}`);
|
|
2883
|
+
continue;
|
|
2884
|
+
}
|
|
2885
|
+
if (inFileSection && line.startsWith("--- ")) {
|
|
2886
|
+
return line === "--- /dev/null";
|
|
2937
2887
|
}
|
|
2938
2888
|
}
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
conflicts: []
|
|
2947
|
-
};
|
|
2889
|
+
return false;
|
|
2890
|
+
}
|
|
2891
|
+
|
|
2892
|
+
// src/flows/FirstGenerationFlow.ts
|
|
2893
|
+
var FirstGenerationFlow = class {
|
|
2894
|
+
constructor(service) {
|
|
2895
|
+
this.service = service;
|
|
2948
2896
|
}
|
|
2949
|
-
async
|
|
2897
|
+
async prepare(options) {
|
|
2950
2898
|
if (options?.dryRun) {
|
|
2951
|
-
const headSha = await this.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
|
|
2899
|
+
const headSha = await this.service.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
|
|
2952
2900
|
return {
|
|
2953
2901
|
flow: "first-generation",
|
|
2954
2902
|
previousGenerationSha: null,
|
|
2955
2903
|
currentGenerationSha: headSha,
|
|
2956
2904
|
baseBranchHead: options.baseBranchHead ?? null,
|
|
2957
2905
|
_prepared: {
|
|
2906
|
+
kind: "terminal",
|
|
2958
2907
|
flow: "first-generation",
|
|
2959
|
-
|
|
2960
|
-
preparedReport: this.firstGenerationReport(),
|
|
2961
|
-
patchesToApply: [],
|
|
2962
|
-
newPatchIds: /* @__PURE__ */ new Set(),
|
|
2963
|
-
detectorWarnings: [],
|
|
2964
|
-
revertedCount: 0,
|
|
2965
|
-
genSha: headSha,
|
|
2966
|
-
optionsSnapshot: options
|
|
2908
|
+
preparedReport: firstGenerationReport()
|
|
2967
2909
|
}
|
|
2968
2910
|
};
|
|
2969
2911
|
}
|
|
@@ -2972,17 +2914,17 @@ var ReplayService = class {
|
|
|
2972
2914
|
generatorVersions: options.generatorVersions ?? {},
|
|
2973
2915
|
baseBranchHead: options.baseBranchHead
|
|
2974
2916
|
} : void 0;
|
|
2975
|
-
await this.committer.commitGeneration("Initial SDK generation", commitOpts);
|
|
2976
|
-
const genRecord = await this.committer.createGenerationRecord(commitOpts);
|
|
2977
|
-
this.lockManager.initializeInMemory(genRecord);
|
|
2917
|
+
await this.service.committer.commitGeneration("Initial SDK generation", commitOpts);
|
|
2918
|
+
const genRecord = await this.service.committer.createGenerationRecord(commitOpts);
|
|
2919
|
+
this.service.lockManager.initializeInMemory(genRecord);
|
|
2978
2920
|
return {
|
|
2979
2921
|
flow: "first-generation",
|
|
2980
2922
|
previousGenerationSha: null,
|
|
2981
2923
|
currentGenerationSha: genRecord.commit_sha,
|
|
2982
2924
|
baseBranchHead: options?.baseBranchHead ?? null,
|
|
2983
2925
|
_prepared: {
|
|
2926
|
+
kind: "active",
|
|
2984
2927
|
flow: "first-generation",
|
|
2985
|
-
terminal: false,
|
|
2986
2928
|
patchesToApply: [],
|
|
2987
2929
|
newPatchIds: /* @__PURE__ */ new Set(),
|
|
2988
2930
|
detectorWarnings: [],
|
|
@@ -2992,47 +2934,57 @@ var ReplayService = class {
|
|
|
2992
2934
|
}
|
|
2993
2935
|
};
|
|
2994
2936
|
}
|
|
2995
|
-
async
|
|
2996
|
-
this.lockManager.save();
|
|
2997
|
-
return
|
|
2937
|
+
async apply(_prep) {
|
|
2938
|
+
this.service.lockManager.save();
|
|
2939
|
+
return firstGenerationReport();
|
|
2998
2940
|
}
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
2941
|
+
};
|
|
2942
|
+
function firstGenerationReport() {
|
|
2943
|
+
return {
|
|
2944
|
+
flow: "first-generation",
|
|
2945
|
+
patchesDetected: 0,
|
|
2946
|
+
patchesApplied: 0,
|
|
2947
|
+
patchesWithConflicts: 0,
|
|
2948
|
+
patchesSkipped: 0,
|
|
2949
|
+
conflicts: []
|
|
2950
|
+
};
|
|
2951
|
+
}
|
|
2952
|
+
|
|
2953
|
+
// src/flows/SkipApplicationFlow.ts
|
|
2954
|
+
var SkipApplicationFlow = class {
|
|
2955
|
+
constructor(service) {
|
|
2956
|
+
this.service = service;
|
|
2957
|
+
}
|
|
2958
|
+
async prepare(options) {
|
|
3007
2959
|
const commitOpts = options ? {
|
|
3008
2960
|
cliVersion: options.cliVersion ?? "unknown",
|
|
3009
2961
|
generatorVersions: options.generatorVersions ?? {},
|
|
3010
2962
|
baseBranchHead: options.baseBranchHead
|
|
3011
2963
|
} : void 0;
|
|
3012
|
-
await this.committer.commitGeneration("Update SDK (replay skipped)", commitOpts);
|
|
3013
|
-
await this.cleanupStaleConflictMarkers();
|
|
3014
|
-
const genRecord = await this.committer.createGenerationRecord(commitOpts);
|
|
2964
|
+
await this.service.committer.commitGeneration("Update SDK (replay skipped)", commitOpts);
|
|
2965
|
+
await this.service.cleanupStaleConflictMarkers();
|
|
2966
|
+
const genRecord = await this.service.committer.createGenerationRecord(commitOpts);
|
|
3015
2967
|
let previousGenerationSha = null;
|
|
3016
2968
|
try {
|
|
3017
|
-
const lock = this.lockManager.read();
|
|
2969
|
+
const lock = this.service.lockManager.read();
|
|
3018
2970
|
previousGenerationSha = lock.current_generation ?? null;
|
|
3019
|
-
this.lockManager.addGeneration(genRecord);
|
|
2971
|
+
this.service.lockManager.addGeneration(genRecord);
|
|
3020
2972
|
} catch (error) {
|
|
3021
2973
|
if (error instanceof LockfileNotFoundError) {
|
|
3022
|
-
this.lockManager.initializeInMemory(genRecord);
|
|
2974
|
+
this.service.lockManager.initializeInMemory(genRecord);
|
|
3023
2975
|
} else {
|
|
3024
2976
|
throw error;
|
|
3025
2977
|
}
|
|
3026
2978
|
}
|
|
3027
|
-
this.lockManager.setReplaySkippedAt((/* @__PURE__ */ new Date()).toISOString());
|
|
2979
|
+
this.service.lockManager.setReplaySkippedAt((/* @__PURE__ */ new Date()).toISOString());
|
|
3028
2980
|
return {
|
|
3029
2981
|
flow: "skip-application",
|
|
3030
2982
|
previousGenerationSha,
|
|
3031
2983
|
currentGenerationSha: genRecord.commit_sha,
|
|
3032
2984
|
baseBranchHead: options?.baseBranchHead ?? null,
|
|
3033
2985
|
_prepared: {
|
|
2986
|
+
kind: "active",
|
|
3034
2987
|
flow: "skip-application",
|
|
3035
|
-
terminal: false,
|
|
3036
2988
|
patchesToApply: [],
|
|
3037
2989
|
newPatchIds: /* @__PURE__ */ new Set(),
|
|
3038
2990
|
detectorWarnings: [],
|
|
@@ -3042,13 +2994,13 @@ var ReplayService = class {
|
|
|
3042
2994
|
}
|
|
3043
2995
|
};
|
|
3044
2996
|
}
|
|
3045
|
-
async
|
|
3046
|
-
this.lockManager.save();
|
|
3047
|
-
const credentialWarnings = [...this.lockManager.warnings];
|
|
2997
|
+
async apply(_prep, options) {
|
|
2998
|
+
this.service.lockManager.save();
|
|
2999
|
+
const credentialWarnings = [...this.service.lockManager.warnings];
|
|
3048
3000
|
if (!options?.stageOnly) {
|
|
3049
|
-
await this.committer.commitReplay(0);
|
|
3001
|
+
await this.service.committer.commitReplay(0);
|
|
3050
3002
|
} else {
|
|
3051
|
-
await this.committer.stageAll();
|
|
3003
|
+
await this.service.committer.stageAll();
|
|
3052
3004
|
}
|
|
3053
3005
|
return {
|
|
3054
3006
|
flow: "skip-application",
|
|
@@ -3060,13 +3012,20 @@ var ReplayService = class {
|
|
|
3060
3012
|
warnings: credentialWarnings.length > 0 ? credentialWarnings : void 0
|
|
3061
3013
|
};
|
|
3062
3014
|
}
|
|
3063
|
-
|
|
3064
|
-
|
|
3015
|
+
};
|
|
3016
|
+
|
|
3017
|
+
// src/flows/NoPatchesFlow.ts
|
|
3018
|
+
var NoPatchesFlow = class {
|
|
3019
|
+
constructor(service) {
|
|
3020
|
+
this.service = service;
|
|
3021
|
+
}
|
|
3022
|
+
async prepare(options) {
|
|
3023
|
+
const preLock = this.service.lockManager.read();
|
|
3065
3024
|
const previousGenerationSha = preLock.current_generation ?? null;
|
|
3066
|
-
let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
|
|
3067
|
-
const detectorWarnings = [...this.detector.warnings];
|
|
3025
|
+
let { patches: newPatches, revertedPatchIds } = await this.service.detector.detectNewPatches();
|
|
3026
|
+
const detectorWarnings = [...this.service.detector.warnings];
|
|
3068
3027
|
if (options?.dryRun) {
|
|
3069
|
-
const dryRunWarnings = [...detectorWarnings, ...this.warnings];
|
|
3028
|
+
const dryRunWarnings = [...detectorWarnings, ...this.service.warnings];
|
|
3070
3029
|
const preparedReport = {
|
|
3071
3030
|
flow: "no-patches",
|
|
3072
3031
|
patchesDetected: newPatches.length,
|
|
@@ -3078,22 +3037,16 @@ var ReplayService = class {
|
|
|
3078
3037
|
wouldApply: newPatches,
|
|
3079
3038
|
warnings: dryRunWarnings.length > 0 ? dryRunWarnings : void 0
|
|
3080
3039
|
};
|
|
3081
|
-
const headSha = await this.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
|
|
3040
|
+
const headSha = await this.service.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
|
|
3082
3041
|
return {
|
|
3083
3042
|
flow: "no-patches",
|
|
3084
3043
|
previousGenerationSha,
|
|
3085
3044
|
currentGenerationSha: headSha,
|
|
3086
3045
|
baseBranchHead: options.baseBranchHead ?? null,
|
|
3087
3046
|
_prepared: {
|
|
3047
|
+
kind: "terminal",
|
|
3088
3048
|
flow: "no-patches",
|
|
3089
|
-
|
|
3090
|
-
preparedReport,
|
|
3091
|
-
patchesToApply: [],
|
|
3092
|
-
newPatchIds: /* @__PURE__ */ new Set(),
|
|
3093
|
-
detectorWarnings,
|
|
3094
|
-
revertedCount: revertedPatchIds.length,
|
|
3095
|
-
genSha: headSha,
|
|
3096
|
-
optionsSnapshot: options
|
|
3049
|
+
preparedReport
|
|
3097
3050
|
}
|
|
3098
3051
|
};
|
|
3099
3052
|
}
|
|
@@ -3102,7 +3055,7 @@ var ReplayService = class {
|
|
|
3102
3055
|
const uniqueFiles = [...new Set(newPatches.flatMap((p) => p.files))];
|
|
3103
3056
|
const absorbedFiles = /* @__PURE__ */ new Set();
|
|
3104
3057
|
for (const file of uniqueFiles) {
|
|
3105
|
-
const diff = await this.git.exec(["diff", preCurrentGen, "HEAD", "--", file]).catch(() => null);
|
|
3058
|
+
const diff = await this.service.git.exec(["diff", preCurrentGen, "HEAD", "--", file]).catch(() => null);
|
|
3106
3059
|
if (diff !== null && !diff.trim()) {
|
|
3107
3060
|
absorbedFiles.add(file);
|
|
3108
3061
|
}
|
|
@@ -3115,7 +3068,7 @@ var ReplayService = class {
|
|
|
3115
3068
|
}
|
|
3116
3069
|
for (const id of revertedPatchIds) {
|
|
3117
3070
|
try {
|
|
3118
|
-
this.lockManager.removePatch(id);
|
|
3071
|
+
this.service.lockManager.removePatch(id);
|
|
3119
3072
|
} catch {
|
|
3120
3073
|
}
|
|
3121
3074
|
}
|
|
@@ -3124,18 +3077,18 @@ var ReplayService = class {
|
|
|
3124
3077
|
generatorVersions: options.generatorVersions ?? {},
|
|
3125
3078
|
baseBranchHead: options.baseBranchHead
|
|
3126
3079
|
} : 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);
|
|
3080
|
+
await this.service.committer.commitGeneration("Update SDK", commitOpts);
|
|
3081
|
+
await this.service.cleanupStaleConflictMarkers();
|
|
3082
|
+
const genRecord = await this.service.committer.createGenerationRecord(commitOpts);
|
|
3083
|
+
this.service.lockManager.addGeneration(genRecord);
|
|
3131
3084
|
return {
|
|
3132
3085
|
flow: "no-patches",
|
|
3133
3086
|
previousGenerationSha,
|
|
3134
3087
|
currentGenerationSha: genRecord.commit_sha,
|
|
3135
3088
|
baseBranchHead: options?.baseBranchHead ?? null,
|
|
3136
3089
|
_prepared: {
|
|
3090
|
+
kind: "active",
|
|
3137
3091
|
flow: "no-patches",
|
|
3138
|
-
terminal: false,
|
|
3139
3092
|
patchesToApply: newPatches,
|
|
3140
3093
|
newPatchIds: new Set(newPatches.map((p) => p.id)),
|
|
3141
3094
|
detectorWarnings,
|
|
@@ -3145,41 +3098,42 @@ var ReplayService = class {
|
|
|
3145
3098
|
}
|
|
3146
3099
|
};
|
|
3147
3100
|
}
|
|
3148
|
-
async
|
|
3101
|
+
async apply(prep, options) {
|
|
3102
|
+
assertActive(prep);
|
|
3149
3103
|
const newPatches = prep._prepared.patchesToApply;
|
|
3150
3104
|
const detectorWarnings = prep._prepared.detectorWarnings;
|
|
3151
3105
|
const genSha = prep._prepared.genSha;
|
|
3152
3106
|
let results = [];
|
|
3153
3107
|
if (newPatches.length > 0) {
|
|
3154
|
-
results = await this.applicator.applyPatches(newPatches);
|
|
3155
|
-
this._lastApplyResults = results;
|
|
3156
|
-
this.recordDroppedContextLineWarnings(results);
|
|
3157
|
-
this.revertConflictingFiles(results);
|
|
3108
|
+
results = await this.service.applicator.applyPatches(newPatches);
|
|
3109
|
+
this.service._lastApplyResults = results;
|
|
3110
|
+
this.service.recordDroppedContextLineWarnings(results);
|
|
3111
|
+
this.service.revertConflictingFiles(results);
|
|
3158
3112
|
for (const result of results) {
|
|
3159
3113
|
if (result.status === "conflict") {
|
|
3160
3114
|
result.patch.status = "unresolved";
|
|
3161
3115
|
}
|
|
3162
3116
|
}
|
|
3163
3117
|
}
|
|
3164
|
-
const rebaseCounts = await this.rebasePatches(results, genSha);
|
|
3118
|
+
const rebaseCounts = await this.service.rebasePatches(results, genSha);
|
|
3165
3119
|
for (const patch of newPatches) {
|
|
3166
3120
|
if (!rebaseCounts.absorbedPatchIds.has(patch.id)) {
|
|
3167
|
-
this.lockManager.addPatch(patch);
|
|
3121
|
+
this.service.lockManager.addPatch(patch);
|
|
3168
3122
|
}
|
|
3169
3123
|
}
|
|
3170
|
-
this.lockManager.save();
|
|
3171
|
-
this.warnings.push(...this.lockManager.warnings);
|
|
3124
|
+
this.service.lockManager.save();
|
|
3125
|
+
this.service.warnings.push(...this.service.lockManager.warnings);
|
|
3172
3126
|
if (newPatches.length > 0) {
|
|
3173
3127
|
if (!options?.stageOnly) {
|
|
3174
3128
|
const appliedCount = results.filter((r) => r.status === "applied").length;
|
|
3175
3129
|
const buckets = computeCommitBuckets(results, rebaseCounts.absorbedPatchIds, newPatches);
|
|
3176
|
-
await this.committer.commitReplay(appliedCount, newPatches, void 0, { buckets });
|
|
3130
|
+
await this.service.committer.commitReplay(appliedCount, newPatches, void 0, { buckets });
|
|
3177
3131
|
} else {
|
|
3178
|
-
await this.committer.stageAll();
|
|
3132
|
+
await this.service.committer.stageAll();
|
|
3179
3133
|
}
|
|
3180
3134
|
}
|
|
3181
|
-
const warnings = [...detectorWarnings, ...this.warnings];
|
|
3182
|
-
return this.buildReport(
|
|
3135
|
+
const warnings = [...detectorWarnings, ...this.service.warnings];
|
|
3136
|
+
return this.service.buildReport(
|
|
3183
3137
|
"no-patches",
|
|
3184
3138
|
newPatches,
|
|
3185
3139
|
results,
|
|
@@ -3190,13 +3144,20 @@ var ReplayService = class {
|
|
|
3190
3144
|
prep._prepared.revertedCount
|
|
3191
3145
|
);
|
|
3192
3146
|
}
|
|
3193
|
-
|
|
3194
|
-
|
|
3147
|
+
};
|
|
3148
|
+
|
|
3149
|
+
// src/flows/NormalRegenerationFlow.ts
|
|
3150
|
+
var NormalRegenerationFlow = class {
|
|
3151
|
+
constructor(service) {
|
|
3152
|
+
this.service = service;
|
|
3153
|
+
}
|
|
3154
|
+
async prepare(options) {
|
|
3155
|
+
const preLock = this.service.lockManager.read();
|
|
3195
3156
|
const previousGenerationSha = preLock.current_generation ?? null;
|
|
3196
3157
|
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];
|
|
3158
|
+
const existingPatches2 = this.service.lockManager.getPatches();
|
|
3159
|
+
const { patches: newPatches2, revertedPatchIds: dryRunReverted } = await this.service.detector.detectNewPatches();
|
|
3160
|
+
const warnings = [...this.service.detector.warnings, ...this.service.warnings];
|
|
3200
3161
|
const allPatches2 = [...existingPatches2, ...newPatches2];
|
|
3201
3162
|
const preparedReport = {
|
|
3202
3163
|
flow: "normal-regeneration",
|
|
@@ -3209,48 +3170,42 @@ var ReplayService = class {
|
|
|
3209
3170
|
wouldApply: allPatches2,
|
|
3210
3171
|
warnings: warnings.length > 0 ? warnings : void 0
|
|
3211
3172
|
};
|
|
3212
|
-
const headSha = await this.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
|
|
3173
|
+
const headSha = await this.service.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
|
|
3213
3174
|
return {
|
|
3214
3175
|
flow: "normal-regeneration",
|
|
3215
3176
|
previousGenerationSha,
|
|
3216
3177
|
currentGenerationSha: headSha,
|
|
3217
3178
|
baseBranchHead: options.baseBranchHead ?? null,
|
|
3218
3179
|
_prepared: {
|
|
3180
|
+
kind: "terminal",
|
|
3219
3181
|
flow: "normal-regeneration",
|
|
3220
|
-
|
|
3221
|
-
preparedReport,
|
|
3222
|
-
patchesToApply: [],
|
|
3223
|
-
newPatchIds: /* @__PURE__ */ new Set(),
|
|
3224
|
-
detectorWarnings: [...this.detector.warnings],
|
|
3225
|
-
revertedCount: dryRunReverted.length,
|
|
3226
|
-
genSha: headSha,
|
|
3227
|
-
optionsSnapshot: options
|
|
3182
|
+
preparedReport
|
|
3228
3183
|
}
|
|
3229
3184
|
};
|
|
3230
3185
|
}
|
|
3231
|
-
let existingPatches = this.lockManager.getPatches();
|
|
3186
|
+
let existingPatches = this.service.lockManager.getPatches();
|
|
3232
3187
|
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));
|
|
3188
|
+
const preRebaseCounts = await this.service.preGenerationRebase(existingPatches);
|
|
3189
|
+
const postRebasePatchIds = new Set(this.service.lockManager.getPatches().map((p) => p.id));
|
|
3235
3190
|
const removedByPreRebase = existingPatches.filter(
|
|
3236
3191
|
(p) => preRebasePatchIds.has(p.id) && !postRebasePatchIds.has(p.id)
|
|
3237
3192
|
);
|
|
3238
|
-
existingPatches = this.lockManager.getPatches();
|
|
3193
|
+
existingPatches = this.service.lockManager.getPatches();
|
|
3239
3194
|
const seenHashes = /* @__PURE__ */ new Map();
|
|
3240
3195
|
for (const p of existingPatches) {
|
|
3241
3196
|
const priorPatchId = seenHashes.get(p.content_hash);
|
|
3242
3197
|
if (priorPatchId !== void 0) {
|
|
3243
|
-
this.lockManager.removePatch(p.id);
|
|
3244
|
-
this.warnings.push(
|
|
3198
|
+
this.service.lockManager.removePatch(p.id);
|
|
3199
|
+
this.service.warnings.push(
|
|
3245
3200
|
`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
3201
|
);
|
|
3247
3202
|
} else {
|
|
3248
3203
|
seenHashes.set(p.content_hash, p.id);
|
|
3249
3204
|
}
|
|
3250
3205
|
}
|
|
3251
|
-
existingPatches = this.lockManager.getPatches();
|
|
3252
|
-
let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
|
|
3253
|
-
const detectorWarnings = [...this.detector.warnings];
|
|
3206
|
+
existingPatches = this.service.lockManager.getPatches();
|
|
3207
|
+
let { patches: newPatches, revertedPatchIds } = await this.service.detector.detectNewPatches();
|
|
3208
|
+
const detectorWarnings = [...this.service.detector.warnings];
|
|
3254
3209
|
if (removedByPreRebase.length > 0) {
|
|
3255
3210
|
const removedOriginalCommits = new Set(removedByPreRebase.map((p) => p.original_commit));
|
|
3256
3211
|
const removedOriginalMessages = new Set(removedByPreRebase.map((p) => p.original_message));
|
|
@@ -3265,7 +3220,7 @@ var ReplayService = class {
|
|
|
3265
3220
|
}
|
|
3266
3221
|
for (const id of revertedPatchIds) {
|
|
3267
3222
|
try {
|
|
3268
|
-
this.lockManager.removePatch(id);
|
|
3223
|
+
this.service.lockManager.removePatch(id);
|
|
3269
3224
|
} catch {
|
|
3270
3225
|
}
|
|
3271
3226
|
}
|
|
@@ -3277,18 +3232,18 @@ var ReplayService = class {
|
|
|
3277
3232
|
generatorVersions: options.generatorVersions ?? {},
|
|
3278
3233
|
baseBranchHead: options.baseBranchHead
|
|
3279
3234
|
} : 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);
|
|
3235
|
+
await this.service.committer.commitGeneration("Update SDK", commitOpts);
|
|
3236
|
+
await this.service.cleanupStaleConflictMarkers();
|
|
3237
|
+
const genRecord = await this.service.committer.createGenerationRecord(commitOpts);
|
|
3238
|
+
this.service.lockManager.addGeneration(genRecord);
|
|
3284
3239
|
return {
|
|
3285
3240
|
flow: "normal-regeneration",
|
|
3286
3241
|
previousGenerationSha,
|
|
3287
3242
|
currentGenerationSha: genRecord.commit_sha,
|
|
3288
3243
|
baseBranchHead: options?.baseBranchHead ?? null,
|
|
3289
3244
|
_prepared: {
|
|
3245
|
+
kind: "active",
|
|
3290
3246
|
flow: "normal-regeneration",
|
|
3291
|
-
terminal: false,
|
|
3292
3247
|
patchesToApply: allPatches,
|
|
3293
3248
|
newPatchIds: new Set(newPatches.map((p) => p.id)),
|
|
3294
3249
|
preRebaseCounts,
|
|
@@ -3299,46 +3254,47 @@ var ReplayService = class {
|
|
|
3299
3254
|
}
|
|
3300
3255
|
};
|
|
3301
3256
|
}
|
|
3302
|
-
async
|
|
3257
|
+
async apply(prep, options) {
|
|
3258
|
+
assertActive(prep);
|
|
3303
3259
|
const allPatches = prep._prepared.patchesToApply;
|
|
3304
3260
|
const newPatchIds = prep._prepared.newPatchIds;
|
|
3305
3261
|
const detectorWarnings = prep._prepared.detectorWarnings;
|
|
3306
3262
|
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);
|
|
3263
|
+
const results = await this.service.applicator.applyPatches(allPatches);
|
|
3264
|
+
this.service._lastApplyResults = results;
|
|
3265
|
+
this.service.recordDroppedContextLineWarnings(results);
|
|
3266
|
+
this.service.revertConflictingFiles(results);
|
|
3311
3267
|
for (const result of results) {
|
|
3312
3268
|
if (result.status === "conflict") {
|
|
3313
3269
|
result.patch.status = "unresolved";
|
|
3314
3270
|
}
|
|
3315
3271
|
}
|
|
3316
|
-
const rebaseCounts = await this.rebasePatches(results, genSha);
|
|
3272
|
+
const rebaseCounts = await this.service.rebasePatches(results, genSha);
|
|
3317
3273
|
const newPatches = allPatches.filter((p) => newPatchIds.has(p.id));
|
|
3318
3274
|
for (const patch of newPatches) {
|
|
3319
3275
|
if (!rebaseCounts.absorbedPatchIds.has(patch.id)) {
|
|
3320
|
-
this.lockManager.addPatch(patch);
|
|
3276
|
+
this.service.lockManager.addPatch(patch);
|
|
3321
3277
|
}
|
|
3322
3278
|
}
|
|
3323
3279
|
for (const result of results) {
|
|
3324
3280
|
if (result.status === "conflict") {
|
|
3325
3281
|
try {
|
|
3326
|
-
this.lockManager.markPatchUnresolved(result.patch.id);
|
|
3282
|
+
this.service.lockManager.markPatchUnresolved(result.patch.id);
|
|
3327
3283
|
} catch {
|
|
3328
3284
|
}
|
|
3329
3285
|
}
|
|
3330
3286
|
}
|
|
3331
|
-
this.lockManager.save();
|
|
3332
|
-
this.warnings.push(...this.lockManager.warnings);
|
|
3287
|
+
this.service.lockManager.save();
|
|
3288
|
+
this.service.warnings.push(...this.service.lockManager.warnings);
|
|
3333
3289
|
if (options?.stageOnly) {
|
|
3334
|
-
await this.committer.stageAll();
|
|
3290
|
+
await this.service.committer.stageAll();
|
|
3335
3291
|
} else {
|
|
3336
3292
|
const appliedCount = results.filter((r) => r.status === "applied").length;
|
|
3337
3293
|
const buckets = computeCommitBuckets(results, rebaseCounts.absorbedPatchIds, allPatches);
|
|
3338
|
-
await this.committer.commitReplay(appliedCount, allPatches, void 0, { buckets });
|
|
3294
|
+
await this.service.committer.commitReplay(appliedCount, allPatches, void 0, { buckets });
|
|
3339
3295
|
}
|
|
3340
|
-
const warnings = [...detectorWarnings, ...this.warnings];
|
|
3341
|
-
return this.buildReport(
|
|
3296
|
+
const warnings = [...detectorWarnings, ...this.service.warnings];
|
|
3297
|
+
return this.service.buildReport(
|
|
3342
3298
|
"normal-regeneration",
|
|
3343
3299
|
allPatches,
|
|
3344
3300
|
results,
|
|
@@ -3349,10 +3305,213 @@ var ReplayService = class {
|
|
|
3349
3305
|
prep._prepared.revertedCount
|
|
3350
3306
|
);
|
|
3351
3307
|
}
|
|
3308
|
+
};
|
|
3309
|
+
|
|
3310
|
+
// src/ReplayService.ts
|
|
3311
|
+
function assertActive(prep) {
|
|
3312
|
+
if (prep._prepared.kind !== "active") {
|
|
3313
|
+
throw new Error(
|
|
3314
|
+
`Flow.apply() called with terminal preparation (kind=${prep._prepared.kind}); terminal cases must be handled by applyPreparedReplay's dispatcher`
|
|
3315
|
+
);
|
|
3316
|
+
}
|
|
3317
|
+
}
|
|
3318
|
+
var ReplayService = class {
|
|
3319
|
+
/** @internal Used by Flow implementations in `src/flows/`. */
|
|
3320
|
+
git;
|
|
3321
|
+
/** @internal Used by Flow implementations in `src/flows/`. */
|
|
3322
|
+
detector;
|
|
3323
|
+
/** @internal Used by Flow implementations in `src/flows/`. */
|
|
3324
|
+
applicator;
|
|
3325
|
+
/** @internal Used by Flow implementations in `src/flows/`. */
|
|
3326
|
+
committer;
|
|
3327
|
+
/** @internal Used by Flow implementations in `src/flows/`. */
|
|
3328
|
+
lockManager;
|
|
3329
|
+
/** @internal Used by Flow implementations in `src/flows/`. */
|
|
3330
|
+
fileOwnership;
|
|
3331
|
+
/** @internal Used by Flow implementations in `src/flows/`. */
|
|
3332
|
+
outputDir;
|
|
3333
|
+
/** @internal Test-only. Captures the last ReplayResult[] returned from applicator.applyPatches(). */
|
|
3334
|
+
_lastApplyResults = [];
|
|
3335
|
+
/**
|
|
3336
|
+
* Service-level warnings accumulated during a single runReplay() call.
|
|
3337
|
+
* Merged with `detector.warnings` into `ReplayReport.warnings` by each flow handler.
|
|
3338
|
+
* Populated by `FileOwnership.resolveCanonical` when a patch's base generation tree
|
|
3339
|
+
* is unreachable (shallow clone / aggressive `git gc --prune`) and we fall back to a
|
|
3340
|
+
* conservative heuristic. Cleared at the top of `runReplay()`.
|
|
3341
|
+
*
|
|
3342
|
+
* @internal Used by Flow implementations in `src/flows/`.
|
|
3343
|
+
*/
|
|
3344
|
+
warnings = [];
|
|
3345
|
+
/**
|
|
3346
|
+
* @internal Test-only accessor.
|
|
3347
|
+
* Returns the ReplayApplicator instance used for the last run. Tests use this
|
|
3348
|
+
* to inspect the inter-patch accumulator after runReplay() completes (invariant I2).
|
|
3349
|
+
*/
|
|
3350
|
+
get applicatorRef() {
|
|
3351
|
+
return this.applicator;
|
|
3352
|
+
}
|
|
3353
|
+
/**
|
|
3354
|
+
* @internal Test-only accessor.
|
|
3355
|
+
* Returns the ReplayResult[] from the most recent applyPatches() call. Tests use
|
|
3356
|
+
* this to filter applied-vs-conflicted-vs-absorbed patches for invariant
|
|
3357
|
+
* assertions. Returns an empty array if no patches have been applied yet.
|
|
3358
|
+
*/
|
|
3359
|
+
getLastResults() {
|
|
3360
|
+
return this._lastApplyResults;
|
|
3361
|
+
}
|
|
3362
|
+
constructor(outputDir, _config) {
|
|
3363
|
+
const git = new GitClient(outputDir);
|
|
3364
|
+
this.git = git;
|
|
3365
|
+
this.outputDir = outputDir;
|
|
3366
|
+
this.lockManager = new LockfileManager(outputDir);
|
|
3367
|
+
this.detector = new ReplayDetector(git, this.lockManager, outputDir);
|
|
3368
|
+
this.applicator = new ReplayApplicator(git, this.lockManager, outputDir);
|
|
3369
|
+
this.committer = new ReplayCommitter(git, outputDir);
|
|
3370
|
+
this.fileOwnership = new FileOwnership(git);
|
|
3371
|
+
}
|
|
3372
|
+
/**
|
|
3373
|
+
* Phase 1 of the two-phase replay flow. Runs preGenerationRebase (when applicable),
|
|
3374
|
+
* detects new patches, and creates the `[fern-generated]` commit. Leaves HEAD at
|
|
3375
|
+
* the generation commit (or unchanged for dry-run).
|
|
3376
|
+
*
|
|
3377
|
+
* Does NOT apply patches — the returned handle must be passed to
|
|
3378
|
+
* `applyPreparedReplay()` to complete the run. No disk writes to the lockfile
|
|
3379
|
+
* happen here; lockfile persistence is deferred to phase 2.
|
|
3380
|
+
*
|
|
3381
|
+
* Callers may land additional commits on HEAD between `prepareReplay` and
|
|
3382
|
+
* `applyPreparedReplay` (e.g., `[fern-autoversion]`).
|
|
3383
|
+
*/
|
|
3384
|
+
async prepareReplay(options) {
|
|
3385
|
+
this.warnings = [];
|
|
3386
|
+
this.detector.warnings.length = 0;
|
|
3387
|
+
return this.selectFlow(options).prepare(options);
|
|
3388
|
+
}
|
|
3389
|
+
/**
|
|
3390
|
+
* Phase 2 of the two-phase replay flow. Applies patches, rebases them against
|
|
3391
|
+
* the generation, saves the lockfile, and commits `[fern-replay]` (or stages
|
|
3392
|
+
* under `stageOnly`). Operates on HEAD at call time — mid-phase commits are
|
|
3393
|
+
* respected.
|
|
3394
|
+
*
|
|
3395
|
+
* For terminal handles (dry-run, first-gen has no patch work, skip-app does
|
|
3396
|
+
* its own post-commit logic), this returns the precomputed report.
|
|
3397
|
+
*/
|
|
3398
|
+
async applyPreparedReplay(prep, options) {
|
|
3399
|
+
if (prep._prepared.kind === "terminal") {
|
|
3400
|
+
return prep._prepared.preparedReport;
|
|
3401
|
+
}
|
|
3402
|
+
return this.flowForKind(prep._prepared.flow).apply(prep, options);
|
|
3403
|
+
}
|
|
3404
|
+
/**
|
|
3405
|
+
* Construct the flow for the current run. `selectFlow` is called from
|
|
3406
|
+
* `prepareReplay` and decides based on options + lockfile state.
|
|
3407
|
+
*/
|
|
3408
|
+
selectFlow(options) {
|
|
3409
|
+
if (options?.skipApplication) return new SkipApplicationFlow(this);
|
|
3410
|
+
return this.flowForKind(this.determineFlow());
|
|
3411
|
+
}
|
|
3412
|
+
/**
|
|
3413
|
+
* Construct a flow given its kind. Used by `applyPreparedReplay` to
|
|
3414
|
+
* route to the same kind that was selected in phase 1.
|
|
3415
|
+
*/
|
|
3416
|
+
flowForKind(kind) {
|
|
3417
|
+
switch (kind) {
|
|
3418
|
+
case "first-generation":
|
|
3419
|
+
return new FirstGenerationFlow(this);
|
|
3420
|
+
case "no-patches":
|
|
3421
|
+
return new NoPatchesFlow(this);
|
|
3422
|
+
case "normal-regeneration":
|
|
3423
|
+
return new NormalRegenerationFlow(this);
|
|
3424
|
+
case "skip-application":
|
|
3425
|
+
return new SkipApplicationFlow(this);
|
|
3426
|
+
}
|
|
3427
|
+
}
|
|
3428
|
+
/**
|
|
3429
|
+
* Backwards-compatible atomic entry point. Composes `prepareReplay` + `applyPreparedReplay`.
|
|
3430
|
+
* Behavior is byte-identical to the pre-split implementation for all existing callers.
|
|
3431
|
+
*/
|
|
3432
|
+
async runReplay(options) {
|
|
3433
|
+
const prep = await this.prepareReplay(options);
|
|
3434
|
+
return this.applyPreparedReplay(prep, options);
|
|
3435
|
+
}
|
|
3436
|
+
/**
|
|
3437
|
+
* Sync the lockfile after a divergent PR was squash-merged.
|
|
3438
|
+
* Call this BEFORE runReplay() when the CLI detects a merged divergent PR.
|
|
3439
|
+
*
|
|
3440
|
+
* After updating the generation record, re-detects customer patches via
|
|
3441
|
+
* tree diff between the generation tag (pure generation tree) and HEAD
|
|
3442
|
+
* (which includes customer customizations after squash merge). This ensures
|
|
3443
|
+
* patches survive the squash merge → regenerate cycle even when the lockfile
|
|
3444
|
+
* was restored from the base branch during conflict PR creation.
|
|
3445
|
+
*/
|
|
3446
|
+
async syncFromDivergentMerge(generationCommitSha, options) {
|
|
3447
|
+
const treeHash = await this.git.getTreeHash(generationCommitSha);
|
|
3448
|
+
const timestamp = (await this.git.exec(["log", "-1", "--format=%aI", generationCommitSha])).trim();
|
|
3449
|
+
const record = {
|
|
3450
|
+
commit_sha: generationCommitSha,
|
|
3451
|
+
tree_hash: treeHash,
|
|
3452
|
+
timestamp,
|
|
3453
|
+
cli_version: options?.cliVersion ?? "unknown",
|
|
3454
|
+
generator_versions: options?.generatorVersions ?? {},
|
|
3455
|
+
base_branch_head: options?.baseBranchHead
|
|
3456
|
+
};
|
|
3457
|
+
let resolvedPatches;
|
|
3458
|
+
if (!this.lockManager.exists()) {
|
|
3459
|
+
this.lockManager.initializeInMemory(record);
|
|
3460
|
+
} else {
|
|
3461
|
+
this.lockManager.read();
|
|
3462
|
+
const unresolvedPatches = [
|
|
3463
|
+
...this.lockManager.getUnresolvedPatches(),
|
|
3464
|
+
...this.lockManager.getResolvingPatches()
|
|
3465
|
+
];
|
|
3466
|
+
resolvedPatches = this.lockManager.getPatches().filter((p) => p.status == null);
|
|
3467
|
+
this.lockManager.addGeneration(record);
|
|
3468
|
+
this.lockManager.clearPatches();
|
|
3469
|
+
for (const patch of unresolvedPatches) {
|
|
3470
|
+
this.lockManager.addPatch(patch);
|
|
3471
|
+
}
|
|
3472
|
+
}
|
|
3473
|
+
try {
|
|
3474
|
+
const { patches: redetectedPatches } = await this.detector.detectNewPatches();
|
|
3475
|
+
if (redetectedPatches.length > 0) {
|
|
3476
|
+
const redetectedFiles = new Set(redetectedPatches.flatMap((p) => p.files));
|
|
3477
|
+
const currentPatches = this.lockManager.getPatches();
|
|
3478
|
+
for (const patch of currentPatches) {
|
|
3479
|
+
if (patch.status != null && patch.files.some((f) => redetectedFiles.has(f))) {
|
|
3480
|
+
this.lockManager.removePatch(patch.id);
|
|
3481
|
+
}
|
|
3482
|
+
}
|
|
3483
|
+
for (const patch of redetectedPatches) {
|
|
3484
|
+
this.lockManager.addPatch(patch);
|
|
3485
|
+
}
|
|
3486
|
+
}
|
|
3487
|
+
} catch (error) {
|
|
3488
|
+
for (const patch of resolvedPatches ?? []) {
|
|
3489
|
+
this.lockManager.addPatch(patch);
|
|
3490
|
+
}
|
|
3491
|
+
this.detector.warnings.push(
|
|
3492
|
+
`Patch re-detection failed after divergent merge sync. ${(resolvedPatches ?? []).length} previously resolved patch(es) preserved. Error: ${error instanceof Error ? error.message : String(error)}`
|
|
3493
|
+
);
|
|
3494
|
+
}
|
|
3495
|
+
this.lockManager.save();
|
|
3496
|
+
this.detector.warnings.push(...this.lockManager.warnings);
|
|
3497
|
+
}
|
|
3498
|
+
determineFlow() {
|
|
3499
|
+
try {
|
|
3500
|
+
const lock = this.lockManager.read();
|
|
3501
|
+
return lock.patches.length === 0 ? "no-patches" : "normal-regeneration";
|
|
3502
|
+
} catch (error) {
|
|
3503
|
+
if (error instanceof LockfileNotFoundError) {
|
|
3504
|
+
return "first-generation";
|
|
3505
|
+
}
|
|
3506
|
+
throw error;
|
|
3507
|
+
}
|
|
3508
|
+
}
|
|
3352
3509
|
/**
|
|
3353
3510
|
* Rebase cleanly applied patches so they are relative to the current generation.
|
|
3354
3511
|
* This prevents recurring conflicts on subsequent regenerations.
|
|
3355
3512
|
* Returns the number of patches rebased.
|
|
3513
|
+
*
|
|
3514
|
+
* @internal Used by Flow implementations in `src/flows/`.
|
|
3356
3515
|
*/
|
|
3357
3516
|
async rebasePatches(results, currentGenSha) {
|
|
3358
3517
|
let absorbed = 0;
|
|
@@ -3362,6 +3521,7 @@ var ReplayService = class {
|
|
|
3362
3521
|
const seenContentHashes = /* @__PURE__ */ new Map();
|
|
3363
3522
|
const absorbedPatchIds = /* @__PURE__ */ new Set();
|
|
3364
3523
|
const fernignorePatterns = this.readFernignorePatterns();
|
|
3524
|
+
const warnedGens = /* @__PURE__ */ new Set();
|
|
3365
3525
|
for (const result of results) {
|
|
3366
3526
|
if (result.resolvedFiles && Object.keys(result.resolvedFiles).length > 0) {
|
|
3367
3527
|
const patch = result.patch;
|
|
@@ -3406,21 +3566,20 @@ var ReplayService = class {
|
|
|
3406
3566
|
}
|
|
3407
3567
|
}
|
|
3408
3568
|
const isUserOwnedPerFile = await Promise.all(
|
|
3409
|
-
patch.files.map(
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
})
|
|
3569
|
+
patch.files.map(
|
|
3570
|
+
(file) => this.fileOwnership.resolveCanonical({
|
|
3571
|
+
file,
|
|
3572
|
+
originalFileName: originalByResolved[file],
|
|
3573
|
+
patch,
|
|
3574
|
+
currentGenSha,
|
|
3575
|
+
fernignorePatterns,
|
|
3576
|
+
warnedGens,
|
|
3577
|
+
warnings: this.warnings
|
|
3578
|
+
})
|
|
3579
|
+
)
|
|
3421
3580
|
);
|
|
3422
3581
|
const hasProtectedFiles = patch.files.some(
|
|
3423
|
-
(file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || (0,
|
|
3582
|
+
(file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || (0, import_minimatch3.minimatch)(file, p))
|
|
3424
3583
|
);
|
|
3425
3584
|
const allUserOwned = isUserOwnedPerFile.every(Boolean);
|
|
3426
3585
|
if (allUserOwned && patch.files.length > 0) {
|
|
@@ -3544,7 +3703,7 @@ var ReplayService = class {
|
|
|
3544
3703
|
for (const file of files) {
|
|
3545
3704
|
if (isBinaryFile(file)) continue;
|
|
3546
3705
|
try {
|
|
3547
|
-
const content = await (0,
|
|
3706
|
+
const content = await (0, import_promises6.readFile)((0, import_node_path8.join)(this.outputDir, file), "utf-8");
|
|
3548
3707
|
snapshot[file] = content;
|
|
3549
3708
|
} catch {
|
|
3550
3709
|
}
|
|
@@ -3552,10 +3711,16 @@ var ReplayService = class {
|
|
|
3552
3711
|
return snapshot;
|
|
3553
3712
|
}
|
|
3554
3713
|
/**
|
|
3555
|
-
*
|
|
3556
|
-
* `fromSha` and `toSha` touched any
|
|
3557
|
-
*
|
|
3558
|
-
*
|
|
3714
|
+
* Detects autoversion contamination — returns true when a
|
|
3715
|
+
* `[fern-autoversion]` commit between `fromSha` and `toSha` touched any
|
|
3716
|
+
* of the patch's `files`. Re-classifies each grep match via
|
|
3717
|
+
* `isGenerationCommit` so a customer commit that merely quotes the
|
|
3718
|
+
* marker in its body doesn't false-positive.
|
|
3719
|
+
*
|
|
3720
|
+
* Used by preGenerationRebase to skip the customer-content rebuild for
|
|
3721
|
+
* patches whose files were last touched by an autoversion step — a
|
|
3722
|
+
* naive `git diff currentGen HEAD` there would capture the
|
|
3723
|
+
* placeholder→semver swap as customer customization.
|
|
3559
3724
|
*/
|
|
3560
3725
|
async hasAutoversionContamination(fromSha, toSha, files) {
|
|
3561
3726
|
if (files.length === 0) return false;
|
|
@@ -3584,10 +3749,15 @@ var ReplayService = class {
|
|
|
3584
3749
|
return false;
|
|
3585
3750
|
}
|
|
3586
3751
|
/**
|
|
3587
|
-
*
|
|
3588
|
-
*
|
|
3589
|
-
*
|
|
3590
|
-
*
|
|
3752
|
+
* Rebuild patch_content from `theirs_snapshot` (captured at detection
|
|
3753
|
+
* time, predates pipeline content) rather than a HEAD-relative diff.
|
|
3754
|
+
* Returns `null` when snapshot is missing or doesn't cover every file
|
|
3755
|
+
* in `patch.files` — caller leaves the patch unchanged.
|
|
3756
|
+
*
|
|
3757
|
+
* Used as the autoversion-contamination escape hatch: when the
|
|
3758
|
+
* HEAD-relative diff would capture pipeline content (autoversion's
|
|
3759
|
+
* placeholder swap) as customer customization, the snapshot path
|
|
3760
|
+
* preserves the customer's actual intent.
|
|
3591
3761
|
*/
|
|
3592
3762
|
async computeSnapshotBasedPatchDiff(patch, currentGen) {
|
|
3593
3763
|
if (!patch.theirs_snapshot) return null;
|
|
@@ -3635,7 +3805,7 @@ var ReplayService = class {
|
|
|
3635
3805
|
* **Skips patches sharing files with other patches.** HEAD's content
|
|
3636
3806
|
* for a file shared by N patches is cumulative across all of them, so
|
|
3637
3807
|
* a HEAD-fallback would falsely encode other patches' contributions
|
|
3638
|
-
* into one patch's snapshot — breaking
|
|
3808
|
+
* into one patch's snapshot — breaking cross-patch isolation
|
|
3639
3809
|
* if the snapshot fallback later fires. Per-patch snapshots only
|
|
3640
3810
|
* make sense when the patch owns its file. Multi-patch-same-file
|
|
3641
3811
|
* legacy lockfiles keep using line-anchored reconstruction (the
|
|
@@ -3697,72 +3867,6 @@ var ReplayService = class {
|
|
|
3697
3867
|
);
|
|
3698
3868
|
}
|
|
3699
3869
|
}
|
|
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
3870
|
/**
|
|
3767
3871
|
* For conflict patches with mixed results (some files merged, some conflicted),
|
|
3768
3872
|
* check if the cleanly merged files were absorbed by the generator (empty diff).
|
|
@@ -3781,7 +3885,15 @@ var ReplayService = class {
|
|
|
3781
3885
|
const warnedGens = /* @__PURE__ */ new Set();
|
|
3782
3886
|
const generatorCleanFiles = [];
|
|
3783
3887
|
for (const file of cleanFiles) {
|
|
3784
|
-
|
|
3888
|
+
const userOwned = await this.fileOwnership.resolveCanonical({
|
|
3889
|
+
file,
|
|
3890
|
+
patch,
|
|
3891
|
+
currentGenSha,
|
|
3892
|
+
fernignorePatterns,
|
|
3893
|
+
warnedGens,
|
|
3894
|
+
warnings: this.warnings
|
|
3895
|
+
});
|
|
3896
|
+
if (userOwned) {
|
|
3785
3897
|
continue;
|
|
3786
3898
|
}
|
|
3787
3899
|
generatorCleanFiles.push(file);
|
|
@@ -3810,6 +3922,7 @@ var ReplayService = class {
|
|
|
3810
3922
|
* Pre-generation rebase: update patches using the customer's current state.
|
|
3811
3923
|
* Called BEFORE commitGeneration() while HEAD has customer code.
|
|
3812
3924
|
*/
|
|
3925
|
+
/** @internal Used by Flow implementations in `src/flows/`. */
|
|
3813
3926
|
async preGenerationRebase(patches) {
|
|
3814
3927
|
const lock = this.lockManager.read();
|
|
3815
3928
|
const currentGen = lock.current_generation;
|
|
@@ -3848,7 +3961,15 @@ var ReplayService = class {
|
|
|
3848
3961
|
if (patch.user_owned) return true;
|
|
3849
3962
|
if (patch.files.length === 0) return false;
|
|
3850
3963
|
for (const file of patch.files) {
|
|
3851
|
-
|
|
3964
|
+
const userOwned = await this.fileOwnership.resolveCanonical({
|
|
3965
|
+
file,
|
|
3966
|
+
patch,
|
|
3967
|
+
currentGenSha: currentGen,
|
|
3968
|
+
fernignorePatterns,
|
|
3969
|
+
warnedGens,
|
|
3970
|
+
warnings: this.warnings
|
|
3971
|
+
});
|
|
3972
|
+
if (!userOwned) {
|
|
3852
3973
|
return false;
|
|
3853
3974
|
}
|
|
3854
3975
|
}
|
|
@@ -3898,7 +4019,7 @@ var ReplayService = class {
|
|
|
3898
4019
|
);
|
|
3899
4020
|
if (snapHasStaleMarkers) continue;
|
|
3900
4021
|
const isProtectedSnap = patch.files.some(
|
|
3901
|
-
(file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || (0,
|
|
4022
|
+
(file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || (0, import_minimatch3.minimatch)(file, p))
|
|
3902
4023
|
);
|
|
3903
4024
|
if (isProtectedSnap) continue;
|
|
3904
4025
|
const snapHash = this.detector.computeContentHash(snapshotDiff);
|
|
@@ -3933,7 +4054,7 @@ var ReplayService = class {
|
|
|
3933
4054
|
continue;
|
|
3934
4055
|
}
|
|
3935
4056
|
const isProtected = patch.files.some(
|
|
3936
|
-
(file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || (0,
|
|
4057
|
+
(file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || (0, import_minimatch3.minimatch)(file, p))
|
|
3937
4058
|
);
|
|
3938
4059
|
if (isProtected) {
|
|
3939
4060
|
continue;
|
|
@@ -4040,6 +4161,7 @@ var ReplayService = class {
|
|
|
4040
4161
|
* relied on them. This is the "surface or preserve, never silently drop"
|
|
4041
4162
|
* contract for legitimate-deletion cases.
|
|
4042
4163
|
*/
|
|
4164
|
+
/** @internal Used by Flow implementations in `src/flows/`. */
|
|
4043
4165
|
recordDroppedContextLineWarnings(results) {
|
|
4044
4166
|
const PREVIEW_COUNT = 5;
|
|
4045
4167
|
for (const result of results) {
|
|
@@ -4062,12 +4184,13 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
|
|
|
4062
4184
|
* After applyPatches(), strip conflict markers from conflicting files
|
|
4063
4185
|
* so only clean content is committed. Keeps the Generated (OURS) side.
|
|
4064
4186
|
*/
|
|
4187
|
+
/** @internal Used by Flow implementations in `src/flows/`. */
|
|
4065
4188
|
revertConflictingFiles(results) {
|
|
4066
4189
|
for (const result of results) {
|
|
4067
4190
|
if (result.status !== "conflict" || !result.fileResults) continue;
|
|
4068
4191
|
for (const fileResult of result.fileResults) {
|
|
4069
4192
|
if (fileResult.status !== "conflict") continue;
|
|
4070
|
-
const filePath = (0,
|
|
4193
|
+
const filePath = (0, import_node_path8.join)(this.outputDir, fileResult.file);
|
|
4071
4194
|
try {
|
|
4072
4195
|
const content = (0, import_node_fs2.readFileSync)(filePath, "utf-8");
|
|
4073
4196
|
const stripped = stripConflictMarkers(content);
|
|
@@ -4083,13 +4206,14 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
|
|
|
4083
4206
|
* Restores files to their clean generated state from HEAD.
|
|
4084
4207
|
* Skips .fernignore-protected files to prevent overwriting user content.
|
|
4085
4208
|
*/
|
|
4209
|
+
/** @internal Used by Flow implementations in `src/flows/`. */
|
|
4086
4210
|
async cleanupStaleConflictMarkers() {
|
|
4087
4211
|
const markerFiles = await this.git.exec(["grep", "-l", "<<<<<<< Generated", "--", "."]).catch(() => "");
|
|
4088
4212
|
const files = markerFiles.trim().split("\n").filter(Boolean);
|
|
4089
4213
|
if (files.length === 0) return;
|
|
4090
4214
|
const fernignorePatterns = this.readFernignorePatterns();
|
|
4091
4215
|
for (const file of files) {
|
|
4092
|
-
if (fernignorePatterns.some((pattern) => (0,
|
|
4216
|
+
if (fernignorePatterns.some((pattern) => (0, import_minimatch3.minimatch)(file, pattern))) continue;
|
|
4093
4217
|
try {
|
|
4094
4218
|
await this.git.exec(["checkout", "HEAD", "--", file]);
|
|
4095
4219
|
} catch {
|
|
@@ -4097,10 +4221,11 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
|
|
|
4097
4221
|
}
|
|
4098
4222
|
}
|
|
4099
4223
|
readFernignorePatterns() {
|
|
4100
|
-
const fernignorePath = (0,
|
|
4224
|
+
const fernignorePath = (0, import_node_path8.join)(this.outputDir, ".fernignore");
|
|
4101
4225
|
if (!(0, import_node_fs2.existsSync)(fernignorePath)) return [];
|
|
4102
4226
|
return (0, import_node_fs2.readFileSync)(fernignorePath, "utf-8").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
|
|
4103
4227
|
}
|
|
4228
|
+
/** @internal Used by Flow implementations in `src/flows/`. */
|
|
4104
4229
|
buildReport(flow, patches, results, options, warnings, rebaseCounts, preRebaseCounts, patchesReverted) {
|
|
4105
4230
|
const conflictResults = results.filter((r) => r.status === "conflict");
|
|
4106
4231
|
const conflictDetails = conflictResults.map((r) => {
|
|
@@ -4176,8 +4301,8 @@ function computeCommitBuckets(results, absorbedPatchIds, patchesPassedToCommit)
|
|
|
4176
4301
|
// src/FernignoreMigrator.ts
|
|
4177
4302
|
var import_node_crypto2 = require("crypto");
|
|
4178
4303
|
var import_node_fs3 = require("fs");
|
|
4179
|
-
var
|
|
4180
|
-
var
|
|
4304
|
+
var import_node_path9 = require("path");
|
|
4305
|
+
var import_minimatch4 = require("minimatch");
|
|
4181
4306
|
var import_yaml2 = require("yaml");
|
|
4182
4307
|
var FernignoreMigrator = class {
|
|
4183
4308
|
git;
|
|
@@ -4189,10 +4314,10 @@ var FernignoreMigrator = class {
|
|
|
4189
4314
|
this.outputDir = outputDir;
|
|
4190
4315
|
}
|
|
4191
4316
|
fernignoreExists() {
|
|
4192
|
-
return (0, import_node_fs3.existsSync)((0,
|
|
4317
|
+
return (0, import_node_fs3.existsSync)((0, import_node_path9.join)(this.outputDir, ".fernignore"));
|
|
4193
4318
|
}
|
|
4194
4319
|
readFernignorePatterns() {
|
|
4195
|
-
const fernignorePath = (0,
|
|
4320
|
+
const fernignorePath = (0, import_node_path9.join)(this.outputDir, ".fernignore");
|
|
4196
4321
|
if (!(0, import_node_fs3.existsSync)(fernignorePath)) {
|
|
4197
4322
|
return [];
|
|
4198
4323
|
}
|
|
@@ -4209,7 +4334,7 @@ var FernignoreMigrator = class {
|
|
|
4209
4334
|
const commitsOnly = [];
|
|
4210
4335
|
const syntheticPatches = [];
|
|
4211
4336
|
for (const pattern of patterns) {
|
|
4212
|
-
const matchingPatch = patches.find((p) => p.files.some((f) => (0,
|
|
4337
|
+
const matchingPatch = patches.find((p) => p.files.some((f) => (0, import_minimatch4.minimatch)(f, pattern) || f === pattern));
|
|
4213
4338
|
if (matchingPatch) {
|
|
4214
4339
|
trackedByBoth.push({
|
|
4215
4340
|
file: pattern,
|
|
@@ -4282,7 +4407,7 @@ var FernignoreMigrator = class {
|
|
|
4282
4407
|
fernignoreOnly.push(...remainingFernignoreOnly);
|
|
4283
4408
|
}
|
|
4284
4409
|
for (const patch of patches) {
|
|
4285
|
-
const hasUnprotectedFiles = patch.files.some((f) => !patterns.some((p) => (0,
|
|
4410
|
+
const hasUnprotectedFiles = patch.files.some((f) => !patterns.some((p) => (0, import_minimatch4.minimatch)(f, p) || f === p));
|
|
4286
4411
|
if (hasUnprotectedFiles) {
|
|
4287
4412
|
commitsOnly.push(patch);
|
|
4288
4413
|
}
|
|
@@ -4294,14 +4419,14 @@ var FernignoreMigrator = class {
|
|
|
4294
4419
|
const results = [];
|
|
4295
4420
|
for (const pattern of patterns) {
|
|
4296
4421
|
const matching = allFiles.filter(
|
|
4297
|
-
(f) => (0,
|
|
4422
|
+
(f) => (0, import_minimatch4.minimatch)(f, pattern) || f === pattern || f.startsWith(pattern + "/")
|
|
4298
4423
|
);
|
|
4299
4424
|
results.push({ pattern, files: matching.length > 0 ? matching : [pattern] });
|
|
4300
4425
|
}
|
|
4301
4426
|
return results;
|
|
4302
4427
|
}
|
|
4303
4428
|
readFileContent(filePath) {
|
|
4304
|
-
const fullPath = (0,
|
|
4429
|
+
const fullPath = (0, import_node_path9.join)(this.outputDir, filePath);
|
|
4305
4430
|
if (!(0, import_node_fs3.existsSync)(fullPath)) {
|
|
4306
4431
|
return null;
|
|
4307
4432
|
}
|
|
@@ -4366,7 +4491,7 @@ var FernignoreMigrator = class {
|
|
|
4366
4491
|
};
|
|
4367
4492
|
}
|
|
4368
4493
|
movePatternsToReplayYml(patterns) {
|
|
4369
|
-
const replayYmlPath = (0,
|
|
4494
|
+
const replayYmlPath = (0, import_node_path9.join)(this.outputDir, ".fern", "replay.yml");
|
|
4370
4495
|
let config = {};
|
|
4371
4496
|
if ((0, import_node_fs3.existsSync)(replayYmlPath)) {
|
|
4372
4497
|
const content = (0, import_node_fs3.readFileSync)(replayYmlPath, "utf-8");
|
|
@@ -4375,7 +4500,7 @@ var FernignoreMigrator = class {
|
|
|
4375
4500
|
const existing = config.exclude ?? [];
|
|
4376
4501
|
const merged = [.../* @__PURE__ */ new Set([...existing, ...patterns])];
|
|
4377
4502
|
config.exclude = merged;
|
|
4378
|
-
const dir = (0,
|
|
4503
|
+
const dir = (0, import_node_path9.dirname)(replayYmlPath);
|
|
4379
4504
|
if (!(0, import_node_fs3.existsSync)(dir)) {
|
|
4380
4505
|
(0, import_node_fs3.mkdirSync)(dir, { recursive: true });
|
|
4381
4506
|
}
|
|
@@ -4386,7 +4511,7 @@ var FernignoreMigrator = class {
|
|
|
4386
4511
|
// src/commands/bootstrap.ts
|
|
4387
4512
|
var import_node_crypto3 = require("crypto");
|
|
4388
4513
|
var import_node_fs4 = require("fs");
|
|
4389
|
-
var
|
|
4514
|
+
var import_node_path10 = require("path");
|
|
4390
4515
|
init_GitClient();
|
|
4391
4516
|
async function bootstrap(outputDir, options) {
|
|
4392
4517
|
const git = new GitClient(outputDir);
|
|
@@ -4553,7 +4678,7 @@ function parseGitLog(log) {
|
|
|
4553
4678
|
}
|
|
4554
4679
|
var REPLAY_FERNIGNORE_ENTRIES = [".fern/replay.lock", ".fern/replay.yml", ".gitattributes"];
|
|
4555
4680
|
function ensureFernignoreEntries(outputDir) {
|
|
4556
|
-
const fernignorePath = (0,
|
|
4681
|
+
const fernignorePath = (0, import_node_path10.join)(outputDir, ".fernignore");
|
|
4557
4682
|
let content = "";
|
|
4558
4683
|
if ((0, import_node_fs4.existsSync)(fernignorePath)) {
|
|
4559
4684
|
content = (0, import_node_fs4.readFileSync)(fernignorePath, "utf-8");
|
|
@@ -4577,7 +4702,7 @@ function ensureFernignoreEntries(outputDir) {
|
|
|
4577
4702
|
}
|
|
4578
4703
|
var GITATTRIBUTES_ENTRIES = [".fern/replay.lock linguist-generated=true"];
|
|
4579
4704
|
function ensureGitattributesEntries(outputDir) {
|
|
4580
|
-
const gitattributesPath = (0,
|
|
4705
|
+
const gitattributesPath = (0, import_node_path10.join)(outputDir, ".gitattributes");
|
|
4581
4706
|
let content = "";
|
|
4582
4707
|
if ((0, import_node_fs4.existsSync)(gitattributesPath)) {
|
|
4583
4708
|
content = (0, import_node_fs4.readFileSync)(gitattributesPath, "utf-8");
|
|
@@ -4607,7 +4732,7 @@ function computeContentHash(patchContent) {
|
|
|
4607
4732
|
}
|
|
4608
4733
|
|
|
4609
4734
|
// src/commands/forget.ts
|
|
4610
|
-
var
|
|
4735
|
+
var import_minimatch5 = require("minimatch");
|
|
4611
4736
|
function parseDiffStat(patchContent) {
|
|
4612
4737
|
let additions = 0;
|
|
4613
4738
|
let deletions = 0;
|
|
@@ -4637,7 +4762,7 @@ function toMatchedPatch(patch) {
|
|
|
4637
4762
|
}
|
|
4638
4763
|
function matchesPatch(patch, pattern) {
|
|
4639
4764
|
const fileMatch = patch.files.some(
|
|
4640
|
-
(file) => file === pattern || (0,
|
|
4765
|
+
(file) => file === pattern || (0, import_minimatch5.minimatch)(file, pattern)
|
|
4641
4766
|
);
|
|
4642
4767
|
if (fileMatch) return true;
|
|
4643
4768
|
return patch.original_message.toLowerCase().includes(pattern.toLowerCase());
|
|
@@ -4775,8 +4900,8 @@ function reset(outputDir, options) {
|
|
|
4775
4900
|
}
|
|
4776
4901
|
|
|
4777
4902
|
// src/commands/resolve.ts
|
|
4778
|
-
var
|
|
4779
|
-
var
|
|
4903
|
+
var import_promises7 = require("fs/promises");
|
|
4904
|
+
var import_node_path11 = require("path");
|
|
4780
4905
|
init_GitClient();
|
|
4781
4906
|
async function resolve(outputDir, options) {
|
|
4782
4907
|
const lockManager = new LockfileManager(outputDir);
|
|
@@ -4825,7 +4950,7 @@ async function resolve(outputDir, options) {
|
|
|
4825
4950
|
const result = await computePerPatchDiffWithFallback(
|
|
4826
4951
|
patch,
|
|
4827
4952
|
(f) => git.showFile(currentGen, f),
|
|
4828
|
-
(f) => (0,
|
|
4953
|
+
(f) => (0, import_promises7.readFile)((0, import_node_path11.join)(outputDir, f), "utf-8").catch(() => null),
|
|
4829
4954
|
(files) => git.exec(["diff", currentGen, "--", ...files]).catch(() => null)
|
|
4830
4955
|
);
|
|
4831
4956
|
if (result === null) {
|
|
@@ -4881,7 +5006,7 @@ async function resolve(outputDir, options) {
|
|
|
4881
5006
|
const theirsSnapshot = {};
|
|
4882
5007
|
for (const file of filesForSnapshot) {
|
|
4883
5008
|
if (isBinaryFile(file)) continue;
|
|
4884
|
-
const content = await (0,
|
|
5009
|
+
const content = await (0, import_promises7.readFile)((0, import_node_path11.join)(outputDir, file), "utf-8").catch(() => null);
|
|
4885
5010
|
if (content != null) {
|
|
4886
5011
|
theirsSnapshot[file] = content;
|
|
4887
5012
|
}
|