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