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