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