@fern-api/replay 0.9.1 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.cjs +438 -66
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +409 -66
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +38 -1
- package/dist/index.d.ts +38 -1
- package/dist/index.js +409 -67
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -415,6 +415,12 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from "
|
|
|
415
415
|
import { join, dirname } from "path";
|
|
416
416
|
import { stringify, parse } from "yaml";
|
|
417
417
|
var LOCKFILE_HEADER = "# DO NOT EDIT MANUALLY - Managed by Fern Replay\n";
|
|
418
|
+
var LockfileNotFoundError = class extends Error {
|
|
419
|
+
constructor(path) {
|
|
420
|
+
super(`Lockfile not found: ${path}`);
|
|
421
|
+
this.name = "LockfileNotFoundError";
|
|
422
|
+
}
|
|
423
|
+
};
|
|
418
424
|
var LockfileManager = class {
|
|
419
425
|
outputDir;
|
|
420
426
|
lock = null;
|
|
@@ -434,12 +440,16 @@ var LockfileManager = class {
|
|
|
434
440
|
if (this.lock) {
|
|
435
441
|
return this.lock;
|
|
436
442
|
}
|
|
437
|
-
|
|
438
|
-
|
|
443
|
+
try {
|
|
444
|
+
const content = readFileSync(this.lockfilePath, "utf-8");
|
|
445
|
+
this.lock = parse(content);
|
|
446
|
+
return this.lock;
|
|
447
|
+
} catch (error) {
|
|
448
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
449
|
+
throw new LockfileNotFoundError(this.lockfilePath);
|
|
450
|
+
}
|
|
451
|
+
throw error;
|
|
439
452
|
}
|
|
440
|
-
const content = readFileSync(this.lockfilePath, "utf-8");
|
|
441
|
-
this.lock = parse(content);
|
|
442
|
-
return this.lock;
|
|
443
453
|
}
|
|
444
454
|
initialize(firstGeneration) {
|
|
445
455
|
this.initializeInMemory(firstGeneration);
|
|
@@ -585,13 +595,21 @@ var ReplayDetector = class {
|
|
|
585
595
|
const exists = await this.git.commitExists(lastGen.commit_sha);
|
|
586
596
|
if (!exists) {
|
|
587
597
|
this.warnings.push(
|
|
588
|
-
`Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history.
|
|
598
|
+
`Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to alternate detection.`
|
|
599
|
+
);
|
|
600
|
+
return this.detectPatchesViaTreeDiff(
|
|
601
|
+
lastGen,
|
|
602
|
+
/* commitKnownMissing */
|
|
603
|
+
true
|
|
589
604
|
);
|
|
590
|
-
return { patches: [], revertedPatchIds: [] };
|
|
591
605
|
}
|
|
592
606
|
const isAncestor = await this.git.isAncestor(lastGen.commit_sha, "HEAD");
|
|
593
607
|
if (!isAncestor) {
|
|
594
|
-
return this.detectPatchesViaTreeDiff(
|
|
608
|
+
return this.detectPatchesViaTreeDiff(
|
|
609
|
+
lastGen,
|
|
610
|
+
/* commitKnownMissing */
|
|
611
|
+
false
|
|
612
|
+
);
|
|
595
613
|
}
|
|
596
614
|
const log = await this.git.exec([
|
|
597
615
|
"log",
|
|
@@ -617,7 +635,15 @@ var ReplayDetector = class {
|
|
|
617
635
|
if (lock.patches.find((p) => p.original_commit === commit.sha)) {
|
|
618
636
|
continue;
|
|
619
637
|
}
|
|
620
|
-
|
|
638
|
+
let patchContent;
|
|
639
|
+
try {
|
|
640
|
+
patchContent = await this.git.formatPatch(commit.sha);
|
|
641
|
+
} catch {
|
|
642
|
+
this.warnings.push(
|
|
643
|
+
`Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
|
|
644
|
+
);
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
621
647
|
const contentHash = this.computeContentHash(patchContent);
|
|
622
648
|
if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
|
|
623
649
|
continue;
|
|
@@ -710,11 +736,15 @@ var ReplayDetector = class {
|
|
|
710
736
|
* Revert reconciliation is skipped here because tree-diff produces a single composite
|
|
711
737
|
* patch from the aggregate diff — individual revert commits are not distinguishable.
|
|
712
738
|
*/
|
|
713
|
-
async detectPatchesViaTreeDiff(lastGen) {
|
|
714
|
-
const
|
|
739
|
+
async detectPatchesViaTreeDiff(lastGen, commitKnownMissing) {
|
|
740
|
+
const diffBase = await this.resolveDiffBase(lastGen, commitKnownMissing);
|
|
741
|
+
if (!diffBase) {
|
|
742
|
+
return this.detectPatchesViaCommitScan();
|
|
743
|
+
}
|
|
744
|
+
const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
|
|
715
745
|
const files = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
|
|
716
746
|
if (files.length === 0) return { patches: [], revertedPatchIds: [] };
|
|
717
|
-
const diff = await this.git.exec(["diff",
|
|
747
|
+
const diff = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
|
|
718
748
|
if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
|
|
719
749
|
const contentHash = this.computeContentHash(diff);
|
|
720
750
|
const lock = this.lockManager.read();
|
|
@@ -728,12 +758,113 @@ var ReplayDetector = class {
|
|
|
728
758
|
original_commit: headSha,
|
|
729
759
|
original_message: "Customer customizations (composite)",
|
|
730
760
|
original_author: "composite",
|
|
731
|
-
|
|
761
|
+
// Use diffBase when commit is unreachable — the applicator needs a reachable
|
|
762
|
+
// reference to find base file content. diffBase may be the tree_hash.
|
|
763
|
+
base_generation: commitKnownMissing ? diffBase : lastGen.commit_sha,
|
|
732
764
|
files,
|
|
733
765
|
patch_content: diff
|
|
734
766
|
};
|
|
735
767
|
return { patches: [compositePatch], revertedPatchIds: [] };
|
|
736
768
|
}
|
|
769
|
+
/**
|
|
770
|
+
* Last-resort detection when both generation commit and tree are unreachable.
|
|
771
|
+
* Scans all commits from HEAD, filters against known lockfile patches, and
|
|
772
|
+
* skips creation-only commits (squashed history after force push).
|
|
773
|
+
*
|
|
774
|
+
* Detected patches use the commit's parent as base_generation so the applicator
|
|
775
|
+
* can find base file content from the parent's tree (which IS reachable).
|
|
776
|
+
*/
|
|
777
|
+
async detectPatchesViaCommitScan() {
|
|
778
|
+
const lock = this.lockManager.read();
|
|
779
|
+
const log = await this.git.exec([
|
|
780
|
+
"log",
|
|
781
|
+
"--max-count=200",
|
|
782
|
+
"--format=%H%x00%an%x00%ae%x00%s",
|
|
783
|
+
"HEAD",
|
|
784
|
+
"--",
|
|
785
|
+
this.sdkOutputDir
|
|
786
|
+
]);
|
|
787
|
+
if (!log.trim()) {
|
|
788
|
+
return { patches: [], revertedPatchIds: [] };
|
|
789
|
+
}
|
|
790
|
+
const commits = this.parseGitLog(log);
|
|
791
|
+
const newPatches = [];
|
|
792
|
+
const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
|
|
793
|
+
const existingHashes = new Set(lock.patches.map((p) => p.content_hash));
|
|
794
|
+
const existingCommits = new Set(lock.patches.map((p) => p.original_commit));
|
|
795
|
+
for (const commit of commits) {
|
|
796
|
+
if (isGenerationCommit(commit)) {
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
const parents = await this.git.getCommitParents(commit.sha);
|
|
800
|
+
if (parents.length > 1) {
|
|
801
|
+
continue;
|
|
802
|
+
}
|
|
803
|
+
if (existingCommits.has(commit.sha)) {
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
let patchContent;
|
|
807
|
+
try {
|
|
808
|
+
patchContent = await this.git.formatPatch(commit.sha);
|
|
809
|
+
} catch {
|
|
810
|
+
continue;
|
|
811
|
+
}
|
|
812
|
+
const contentHash = this.computeContentHash(patchContent);
|
|
813
|
+
if (existingHashes.has(contentHash) || forgottenHashes.has(contentHash)) {
|
|
814
|
+
continue;
|
|
815
|
+
}
|
|
816
|
+
if (this.isCreationOnlyPatch(patchContent)) {
|
|
817
|
+
continue;
|
|
818
|
+
}
|
|
819
|
+
const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
|
|
820
|
+
const files = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f));
|
|
821
|
+
if (files.length === 0) {
|
|
822
|
+
continue;
|
|
823
|
+
}
|
|
824
|
+
if (parents.length === 0) {
|
|
825
|
+
continue;
|
|
826
|
+
}
|
|
827
|
+
const parentSha = parents[0];
|
|
828
|
+
newPatches.push({
|
|
829
|
+
id: `patch-${commit.sha.slice(0, 8)}`,
|
|
830
|
+
content_hash: contentHash,
|
|
831
|
+
original_commit: commit.sha,
|
|
832
|
+
original_message: commit.message,
|
|
833
|
+
original_author: `${commit.authorName} <${commit.authorEmail}>`,
|
|
834
|
+
base_generation: parentSha,
|
|
835
|
+
files,
|
|
836
|
+
patch_content: patchContent
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
newPatches.reverse();
|
|
840
|
+
return { patches: newPatches, revertedPatchIds: [] };
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* Check if a format-patch consists entirely of new-file creations.
|
|
844
|
+
* Used to identify squashed commits after force push, which create all files
|
|
845
|
+
* from scratch (--- /dev/null) rather than modifying existing files.
|
|
846
|
+
*/
|
|
847
|
+
isCreationOnlyPatch(patchContent) {
|
|
848
|
+
const diffOldHeaders = patchContent.split("\n").filter((l) => l.startsWith("--- "));
|
|
849
|
+
if (diffOldHeaders.length === 0) {
|
|
850
|
+
return false;
|
|
851
|
+
}
|
|
852
|
+
return diffOldHeaders.every((l) => l === "--- /dev/null");
|
|
853
|
+
}
|
|
854
|
+
/**
|
|
855
|
+
* Resolve the best available diff base for a generation record.
|
|
856
|
+
* Prefers commit_sha, falls back to tree_hash for unreachable commits.
|
|
857
|
+
* When commitKnownMissing is true, skips the redundant commitExists check.
|
|
858
|
+
*/
|
|
859
|
+
async resolveDiffBase(gen, commitKnownMissing) {
|
|
860
|
+
if (!commitKnownMissing && await this.git.commitExists(gen.commit_sha)) {
|
|
861
|
+
return gen.commit_sha;
|
|
862
|
+
}
|
|
863
|
+
if (await this.git.treeExists(gen.tree_hash)) {
|
|
864
|
+
return gen.tree_hash;
|
|
865
|
+
}
|
|
866
|
+
return null;
|
|
867
|
+
}
|
|
737
868
|
parseGitLog(log) {
|
|
738
869
|
return log.trim().split("\n").map((line) => {
|
|
739
870
|
const [sha, authorName, authorEmail, message] = line.split("\0");
|
|
@@ -746,7 +877,7 @@ var ReplayDetector = class {
|
|
|
746
877
|
};
|
|
747
878
|
|
|
748
879
|
// src/ThreeWayMerge.ts
|
|
749
|
-
import { diff3Merge } from "node-diff3";
|
|
880
|
+
import { diff3Merge, diffPatch } from "node-diff3";
|
|
750
881
|
function threeWayMerge(base, ours, theirs) {
|
|
751
882
|
const baseLines = base.split("\n");
|
|
752
883
|
const oursLines = ours.split("\n");
|
|
@@ -760,20 +891,33 @@ function threeWayMerge(base, ours, theirs) {
|
|
|
760
891
|
outputLines.push(...region.ok);
|
|
761
892
|
currentLine += region.ok.length;
|
|
762
893
|
} else if (region.conflict) {
|
|
763
|
-
const
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
894
|
+
const resolved = tryResolveConflict(
|
|
895
|
+
region.conflict.a,
|
|
896
|
+
// ours (generator)
|
|
897
|
+
region.conflict.o,
|
|
898
|
+
// base
|
|
899
|
+
region.conflict.b
|
|
900
|
+
// theirs (user)
|
|
901
|
+
);
|
|
902
|
+
if (resolved !== null) {
|
|
903
|
+
outputLines.push(...resolved);
|
|
904
|
+
currentLine += resolved.length;
|
|
905
|
+
} else {
|
|
906
|
+
const startLine = currentLine;
|
|
907
|
+
outputLines.push("<<<<<<< Generated");
|
|
908
|
+
outputLines.push(...region.conflict.a);
|
|
909
|
+
outputLines.push("=======");
|
|
910
|
+
outputLines.push(...region.conflict.b);
|
|
911
|
+
outputLines.push(">>>>>>> Your customization");
|
|
912
|
+
const conflictLines = region.conflict.a.length + region.conflict.b.length + 3;
|
|
913
|
+
conflicts.push({
|
|
914
|
+
startLine,
|
|
915
|
+
endLine: startLine + conflictLines - 1,
|
|
916
|
+
ours: region.conflict.a,
|
|
917
|
+
theirs: region.conflict.b
|
|
918
|
+
});
|
|
919
|
+
currentLine += conflictLines;
|
|
920
|
+
}
|
|
777
921
|
}
|
|
778
922
|
}
|
|
779
923
|
return {
|
|
@@ -782,6 +926,62 @@ function threeWayMerge(base, ours, theirs) {
|
|
|
782
926
|
conflicts
|
|
783
927
|
};
|
|
784
928
|
}
|
|
929
|
+
function tryResolveConflict(oursLines, baseLines, theirsLines) {
|
|
930
|
+
if (baseLines.length === 0) {
|
|
931
|
+
return null;
|
|
932
|
+
}
|
|
933
|
+
const oursPatches = diffPatch(baseLines, oursLines);
|
|
934
|
+
const theirsPatches = diffPatch(baseLines, theirsLines);
|
|
935
|
+
if (oursPatches.length === 0) return theirsLines;
|
|
936
|
+
if (theirsPatches.length === 0) return oursLines;
|
|
937
|
+
if (patchesOverlap(oursPatches, theirsPatches)) {
|
|
938
|
+
return null;
|
|
939
|
+
}
|
|
940
|
+
return applyBothPatches(baseLines, oursPatches, theirsPatches);
|
|
941
|
+
}
|
|
942
|
+
function patchesOverlap(oursPatches, theirsPatches) {
|
|
943
|
+
for (const op of oursPatches) {
|
|
944
|
+
const oStart = op.buffer1.offset;
|
|
945
|
+
const oEnd = oStart + op.buffer1.length;
|
|
946
|
+
for (const tp of theirsPatches) {
|
|
947
|
+
const tStart = tp.buffer1.offset;
|
|
948
|
+
const tEnd = tStart + tp.buffer1.length;
|
|
949
|
+
if (op.buffer1.length === 0 && tp.buffer1.length === 0 && oStart === tStart) {
|
|
950
|
+
return true;
|
|
951
|
+
}
|
|
952
|
+
if (tp.buffer1.length === 0 && tStart === oEnd) {
|
|
953
|
+
return true;
|
|
954
|
+
}
|
|
955
|
+
if (op.buffer1.length === 0 && oStart === tEnd) {
|
|
956
|
+
return true;
|
|
957
|
+
}
|
|
958
|
+
if (oStart < tEnd && tStart < oEnd) {
|
|
959
|
+
return true;
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
return false;
|
|
964
|
+
}
|
|
965
|
+
function applyBothPatches(baseLines, oursPatches, theirsPatches) {
|
|
966
|
+
const allPatches = [
|
|
967
|
+
...oursPatches.map((p) => ({
|
|
968
|
+
offset: p.buffer1.offset,
|
|
969
|
+
length: p.buffer1.length,
|
|
970
|
+
replacement: p.buffer2.chunk
|
|
971
|
+
})),
|
|
972
|
+
...theirsPatches.map((p) => ({
|
|
973
|
+
offset: p.buffer1.offset,
|
|
974
|
+
length: p.buffer1.length,
|
|
975
|
+
replacement: p.buffer2.chunk
|
|
976
|
+
}))
|
|
977
|
+
];
|
|
978
|
+
allPatches.sort((a, b) => b.offset - a.offset);
|
|
979
|
+
const result = [...baseLines];
|
|
980
|
+
for (const p of allPatches) {
|
|
981
|
+
result.splice(p.offset, p.length, ...p.replacement);
|
|
982
|
+
}
|
|
983
|
+
return result;
|
|
984
|
+
}
|
|
785
985
|
|
|
786
986
|
// src/ReplayApplicator.ts
|
|
787
987
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from "fs/promises";
|
|
@@ -790,29 +990,74 @@ import { dirname as dirname2, extname, join as join2 } from "path";
|
|
|
790
990
|
import { minimatch } from "minimatch";
|
|
791
991
|
|
|
792
992
|
// src/conflict-utils.ts
|
|
993
|
+
var CONFLICT_OPENER = "<<<<<<< Generated";
|
|
994
|
+
var CONFLICT_SEPARATOR = "=======";
|
|
995
|
+
var CONFLICT_CLOSER = ">>>>>>> Your customization";
|
|
996
|
+
function trimCR(line) {
|
|
997
|
+
return line.endsWith("\r") ? line.slice(0, -1) : line;
|
|
998
|
+
}
|
|
999
|
+
function findConflictRanges(lines) {
|
|
1000
|
+
const ranges = [];
|
|
1001
|
+
let i = 0;
|
|
1002
|
+
while (i < lines.length) {
|
|
1003
|
+
if (trimCR(lines[i]) === CONFLICT_OPENER) {
|
|
1004
|
+
let separatorIdx = -1;
|
|
1005
|
+
let j = i + 1;
|
|
1006
|
+
let found = false;
|
|
1007
|
+
while (j < lines.length) {
|
|
1008
|
+
const trimmed = trimCR(lines[j]);
|
|
1009
|
+
if (trimmed === CONFLICT_OPENER) {
|
|
1010
|
+
break;
|
|
1011
|
+
}
|
|
1012
|
+
if (separatorIdx === -1 && trimmed === CONFLICT_SEPARATOR) {
|
|
1013
|
+
separatorIdx = j;
|
|
1014
|
+
} else if (separatorIdx !== -1 && trimmed === CONFLICT_CLOSER) {
|
|
1015
|
+
ranges.push({ start: i, separator: separatorIdx, end: j });
|
|
1016
|
+
i = j;
|
|
1017
|
+
found = true;
|
|
1018
|
+
break;
|
|
1019
|
+
}
|
|
1020
|
+
j++;
|
|
1021
|
+
}
|
|
1022
|
+
if (!found) {
|
|
1023
|
+
i++;
|
|
1024
|
+
continue;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
i++;
|
|
1028
|
+
}
|
|
1029
|
+
return ranges;
|
|
1030
|
+
}
|
|
793
1031
|
function stripConflictMarkers(content) {
|
|
794
|
-
const lines = content.split(
|
|
1032
|
+
const lines = content.split(/\r?\n/);
|
|
1033
|
+
const ranges = findConflictRanges(lines);
|
|
1034
|
+
if (ranges.length === 0) {
|
|
1035
|
+
return content;
|
|
1036
|
+
}
|
|
795
1037
|
const result = [];
|
|
796
|
-
let
|
|
797
|
-
let
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
1038
|
+
let rangeIdx = 0;
|
|
1039
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1040
|
+
if (rangeIdx < ranges.length) {
|
|
1041
|
+
const range = ranges[rangeIdx];
|
|
1042
|
+
if (i === range.start) {
|
|
1043
|
+
continue;
|
|
1044
|
+
}
|
|
1045
|
+
if (i > range.start && i < range.separator) {
|
|
1046
|
+
result.push(lines[i]);
|
|
1047
|
+
continue;
|
|
1048
|
+
}
|
|
1049
|
+
if (i === range.separator) {
|
|
1050
|
+
continue;
|
|
1051
|
+
}
|
|
1052
|
+
if (i > range.separator && i < range.end) {
|
|
1053
|
+
continue;
|
|
1054
|
+
}
|
|
1055
|
+
if (i === range.end) {
|
|
1056
|
+
rangeIdx++;
|
|
1057
|
+
continue;
|
|
1058
|
+
}
|
|
815
1059
|
}
|
|
1060
|
+
result.push(lines[i]);
|
|
816
1061
|
}
|
|
817
1062
|
return result.join("\n");
|
|
818
1063
|
}
|
|
@@ -879,6 +1124,27 @@ var ReplayApplicator = class {
|
|
|
879
1124
|
this.lockManager = lockManager;
|
|
880
1125
|
this.outputDir = outputDir;
|
|
881
1126
|
}
|
|
1127
|
+
/**
|
|
1128
|
+
* Resolve the GenerationRecord for a patch's base_generation.
|
|
1129
|
+
* Falls back to constructing an ad-hoc record from the commit's tree
|
|
1130
|
+
* when base_generation isn't a tracked generation (commit-scan patches).
|
|
1131
|
+
*/
|
|
1132
|
+
async resolveBaseGeneration(baseGeneration) {
|
|
1133
|
+
const gen = this.lockManager.getGeneration(baseGeneration);
|
|
1134
|
+
if (gen) return gen;
|
|
1135
|
+
try {
|
|
1136
|
+
const treeHash = await this.git.getTreeHash(baseGeneration);
|
|
1137
|
+
return {
|
|
1138
|
+
commit_sha: baseGeneration,
|
|
1139
|
+
tree_hash: treeHash,
|
|
1140
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1141
|
+
cli_version: "unknown",
|
|
1142
|
+
generator_versions: {}
|
|
1143
|
+
};
|
|
1144
|
+
} catch {
|
|
1145
|
+
return void 0;
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
882
1148
|
/** Reset inter-patch accumulator for a new cycle. */
|
|
883
1149
|
resetAccumulator() {
|
|
884
1150
|
this.fileTheirsAccumulator.clear();
|
|
@@ -960,7 +1226,7 @@ var ReplayApplicator = class {
|
|
|
960
1226
|
}
|
|
961
1227
|
}
|
|
962
1228
|
async applyPatchWithFallback(patch) {
|
|
963
|
-
const baseGen = this.
|
|
1229
|
+
const baseGen = await this.resolveBaseGeneration(patch.base_generation);
|
|
964
1230
|
const lock = this.lockManager.read();
|
|
965
1231
|
const currentGen = lock.generations.find((g) => g.commit_sha === lock.current_generation);
|
|
966
1232
|
const currentTreeHash = currentGen?.tree_hash ?? baseGen?.tree_hash ?? "";
|
|
@@ -1044,7 +1310,7 @@ var ReplayApplicator = class {
|
|
|
1044
1310
|
}
|
|
1045
1311
|
async mergeFile(patch, filePath, tempGit, tempDir) {
|
|
1046
1312
|
try {
|
|
1047
|
-
const baseGen = this.
|
|
1313
|
+
const baseGen = await this.resolveBaseGeneration(patch.base_generation);
|
|
1048
1314
|
if (!baseGen) {
|
|
1049
1315
|
return { file: filePath, status: "skipped", reason: "base-generation-not-found" };
|
|
1050
1316
|
}
|
|
@@ -1096,9 +1362,20 @@ var ReplayApplicator = class {
|
|
|
1096
1362
|
renameSourcePath
|
|
1097
1363
|
);
|
|
1098
1364
|
}
|
|
1365
|
+
if (theirs) {
|
|
1366
|
+
const theirsHasMarkers = theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization");
|
|
1367
|
+
const baseHasMarkers = base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization"));
|
|
1368
|
+
if (theirsHasMarkers && !baseHasMarkers) {
|
|
1369
|
+
return {
|
|
1370
|
+
file: resolvedPath,
|
|
1371
|
+
status: "skipped",
|
|
1372
|
+
reason: "stale-conflict-markers"
|
|
1373
|
+
};
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1099
1376
|
let useAccumulatorAsMergeBase = false;
|
|
1100
1377
|
const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
|
|
1101
|
-
if (!theirs
|
|
1378
|
+
if (accumulatorEntry && (!theirs || base == null)) {
|
|
1102
1379
|
theirs = await this.applyPatchToContent(
|
|
1103
1380
|
accumulatorEntry.content,
|
|
1104
1381
|
patch.patch_content,
|
|
@@ -1110,6 +1387,17 @@ var ReplayApplicator = class {
|
|
|
1110
1387
|
useAccumulatorAsMergeBase = true;
|
|
1111
1388
|
}
|
|
1112
1389
|
}
|
|
1390
|
+
if (theirs) {
|
|
1391
|
+
const theirsHasMarkers = theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization");
|
|
1392
|
+
const accBaseHasMarkers = accumulatorEntry != null && (accumulatorEntry.content.includes("<<<<<<< Generated") || accumulatorEntry.content.includes(">>>>>>> Your customization"));
|
|
1393
|
+
if (theirsHasMarkers && !accBaseHasMarkers && !(base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization")))) {
|
|
1394
|
+
return {
|
|
1395
|
+
file: resolvedPath,
|
|
1396
|
+
status: "skipped",
|
|
1397
|
+
reason: "stale-conflict-markers"
|
|
1398
|
+
};
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1113
1401
|
let effective_theirs = theirs;
|
|
1114
1402
|
let baseMismatchSkipped = false;
|
|
1115
1403
|
if (theirs && base && !useAccumulatorAsMergeBase) {
|
|
@@ -1129,12 +1417,16 @@ var ReplayApplicator = class {
|
|
|
1129
1417
|
}
|
|
1130
1418
|
}
|
|
1131
1419
|
if (base == null && !ours && effective_theirs) {
|
|
1420
|
+
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
1421
|
+
content: effective_theirs,
|
|
1422
|
+
baseGeneration: patch.base_generation
|
|
1423
|
+
});
|
|
1132
1424
|
const outDir2 = dirname2(oursPath);
|
|
1133
1425
|
await mkdir(outDir2, { recursive: true });
|
|
1134
1426
|
await writeFile(oursPath, effective_theirs);
|
|
1135
1427
|
return { file: resolvedPath, status: "merged", reason: "new-file" };
|
|
1136
1428
|
}
|
|
1137
|
-
if (base == null && ours && effective_theirs) {
|
|
1429
|
+
if (base == null && ours && effective_theirs && !useAccumulatorAsMergeBase) {
|
|
1138
1430
|
const merged2 = threeWayMerge("", ours, effective_theirs);
|
|
1139
1431
|
const outDir2 = dirname2(oursPath);
|
|
1140
1432
|
await mkdir(outDir2, { recursive: true });
|
|
@@ -1176,7 +1468,7 @@ var ReplayApplicator = class {
|
|
|
1176
1468
|
const outDir = dirname2(oursPath);
|
|
1177
1469
|
await mkdir(outDir, { recursive: true });
|
|
1178
1470
|
await writeFile(oursPath, merged.content);
|
|
1179
|
-
if (effective_theirs) {
|
|
1471
|
+
if (effective_theirs && !merged.hasConflicts) {
|
|
1180
1472
|
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
1181
1473
|
content: effective_theirs,
|
|
1182
1474
|
baseGeneration: patch.base_generation
|
|
@@ -1317,16 +1609,24 @@ var ReplayApplicator = class {
|
|
|
1317
1609
|
const addedLines = [];
|
|
1318
1610
|
let inTargetFile = false;
|
|
1319
1611
|
let inHunk = false;
|
|
1612
|
+
let isNewFile = false;
|
|
1320
1613
|
let noTrailingNewline = false;
|
|
1321
1614
|
for (const line of lines) {
|
|
1322
1615
|
if (line.startsWith("diff --git")) {
|
|
1323
1616
|
if (inTargetFile) break;
|
|
1324
1617
|
inTargetFile = isDiffLineForFile(line, filePath);
|
|
1325
1618
|
inHunk = false;
|
|
1619
|
+
isNewFile = false;
|
|
1326
1620
|
continue;
|
|
1327
1621
|
}
|
|
1328
1622
|
if (!inTargetFile) continue;
|
|
1623
|
+
if (!inHunk) {
|
|
1624
|
+
if (line === "--- /dev/null" || line.startsWith("new file mode")) {
|
|
1625
|
+
isNewFile = true;
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1329
1628
|
if (line.startsWith("@@")) {
|
|
1629
|
+
if (!isNewFile) return null;
|
|
1330
1630
|
inHunk = true;
|
|
1331
1631
|
continue;
|
|
1332
1632
|
}
|
|
@@ -1478,6 +1778,7 @@ var ReplayService = class {
|
|
|
1478
1778
|
generator_versions: options?.generatorVersions ?? {},
|
|
1479
1779
|
base_branch_head: options?.baseBranchHead
|
|
1480
1780
|
};
|
|
1781
|
+
let resolvedPatches;
|
|
1481
1782
|
if (!this.lockManager.exists()) {
|
|
1482
1783
|
this.lockManager.initializeInMemory(record);
|
|
1483
1784
|
} else {
|
|
@@ -1486,13 +1787,13 @@ var ReplayService = class {
|
|
|
1486
1787
|
...this.lockManager.getUnresolvedPatches(),
|
|
1487
1788
|
...this.lockManager.getResolvingPatches()
|
|
1488
1789
|
];
|
|
1790
|
+
resolvedPatches = this.lockManager.getPatches().filter((p) => p.status == null);
|
|
1489
1791
|
this.lockManager.addGeneration(record);
|
|
1490
1792
|
this.lockManager.clearPatches();
|
|
1491
1793
|
for (const patch of unresolvedPatches) {
|
|
1492
1794
|
this.lockManager.addPatch(patch);
|
|
1493
1795
|
}
|
|
1494
1796
|
}
|
|
1495
|
-
this.lockManager.save();
|
|
1496
1797
|
try {
|
|
1497
1798
|
const { patches: redetectedPatches } = await this.detector.detectNewPatches();
|
|
1498
1799
|
if (redetectedPatches.length > 0) {
|
|
@@ -1506,20 +1807,27 @@ var ReplayService = class {
|
|
|
1506
1807
|
for (const patch of redetectedPatches) {
|
|
1507
1808
|
this.lockManager.addPatch(patch);
|
|
1508
1809
|
}
|
|
1509
|
-
this.lockManager.save();
|
|
1510
1810
|
}
|
|
1511
|
-
} catch {
|
|
1811
|
+
} catch (error) {
|
|
1812
|
+
for (const patch of resolvedPatches ?? []) {
|
|
1813
|
+
this.lockManager.addPatch(patch);
|
|
1814
|
+
}
|
|
1815
|
+
this.detector.warnings.push(
|
|
1816
|
+
`Patch re-detection failed after divergent merge sync. ${(resolvedPatches ?? []).length} previously resolved patch(es) preserved. Error: ${error instanceof Error ? error.message : String(error)}`
|
|
1817
|
+
);
|
|
1512
1818
|
}
|
|
1819
|
+
this.lockManager.save();
|
|
1513
1820
|
}
|
|
1514
1821
|
determineFlow() {
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1822
|
+
try {
|
|
1823
|
+
const lock = this.lockManager.read();
|
|
1824
|
+
return lock.patches.length === 0 ? "no-patches" : "normal-regeneration";
|
|
1825
|
+
} catch (error) {
|
|
1826
|
+
if (error instanceof LockfileNotFoundError) {
|
|
1827
|
+
return "first-generation";
|
|
1828
|
+
}
|
|
1829
|
+
throw error;
|
|
1521
1830
|
}
|
|
1522
|
-
return "normal-regeneration";
|
|
1523
1831
|
}
|
|
1524
1832
|
async handleFirstGeneration(options) {
|
|
1525
1833
|
if (options?.dryRun) {
|
|
@@ -1561,12 +1869,17 @@ var ReplayService = class {
|
|
|
1561
1869
|
baseBranchHead: options.baseBranchHead
|
|
1562
1870
|
} : void 0;
|
|
1563
1871
|
await this.committer.commitGeneration("Update SDK (replay skipped)", commitOpts);
|
|
1872
|
+
await this.cleanupStaleConflictMarkers();
|
|
1564
1873
|
const genRecord = await this.committer.createGenerationRecord(commitOpts);
|
|
1565
|
-
|
|
1874
|
+
try {
|
|
1566
1875
|
this.lockManager.read();
|
|
1567
1876
|
this.lockManager.addGeneration(genRecord);
|
|
1568
|
-
}
|
|
1569
|
-
|
|
1877
|
+
} catch (error) {
|
|
1878
|
+
if (error instanceof LockfileNotFoundError) {
|
|
1879
|
+
this.lockManager.initializeInMemory(genRecord);
|
|
1880
|
+
} else {
|
|
1881
|
+
throw error;
|
|
1882
|
+
}
|
|
1570
1883
|
}
|
|
1571
1884
|
this.lockManager.setReplaySkippedAt((/* @__PURE__ */ new Date()).toISOString());
|
|
1572
1885
|
this.lockManager.save();
|
|
@@ -1612,6 +1925,7 @@ var ReplayService = class {
|
|
|
1612
1925
|
baseBranchHead: options.baseBranchHead
|
|
1613
1926
|
} : void 0;
|
|
1614
1927
|
await this.committer.commitGeneration("Update SDK", commitOpts);
|
|
1928
|
+
await this.cleanupStaleConflictMarkers();
|
|
1615
1929
|
const genRecord = await this.committer.createGenerationRecord(commitOpts);
|
|
1616
1930
|
this.lockManager.addGeneration(genRecord);
|
|
1617
1931
|
let results = [];
|
|
@@ -1705,6 +2019,7 @@ var ReplayService = class {
|
|
|
1705
2019
|
baseBranchHead: options.baseBranchHead
|
|
1706
2020
|
} : void 0;
|
|
1707
2021
|
await this.committer.commitGeneration("Update SDK", commitOpts);
|
|
2022
|
+
await this.cleanupStaleConflictMarkers();
|
|
1708
2023
|
const genRecord = await this.committer.createGenerationRecord(commitOpts);
|
|
1709
2024
|
this.lockManager.addGeneration(genRecord);
|
|
1710
2025
|
const results = await this.applicator.applyPatches(allPatches);
|
|
@@ -1895,6 +2210,13 @@ var ReplayService = class {
|
|
|
1895
2210
|
contentRefreshed++;
|
|
1896
2211
|
continue;
|
|
1897
2212
|
}
|
|
2213
|
+
const diffLines = diff.split("\n");
|
|
2214
|
+
const hasStaleMarkers = diffLines.some(
|
|
2215
|
+
(l) => l.startsWith("+<<<<<<< Generated") || l.startsWith("+>>>>>>> Your customization")
|
|
2216
|
+
);
|
|
2217
|
+
if (hasStaleMarkers) {
|
|
2218
|
+
continue;
|
|
2219
|
+
}
|
|
1898
2220
|
const newContentHash = this.detector.computeContentHash(diff);
|
|
1899
2221
|
if (newContentHash !== patch.content_hash) {
|
|
1900
2222
|
const filesOutput = await this.git.exec(["diff", "--name-only", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
|
|
@@ -1915,7 +2237,7 @@ var ReplayService = class {
|
|
|
1915
2237
|
continue;
|
|
1916
2238
|
}
|
|
1917
2239
|
try {
|
|
1918
|
-
const markerFiles = await this.git.exec(["grep", "-l", "<<<<<<<", "HEAD", "--", ...patch.files]).catch(() => "");
|
|
2240
|
+
const markerFiles = await this.git.exec(["grep", "-l", "<<<<<<< Generated", "HEAD", "--", ...patch.files]).catch(() => "");
|
|
1919
2241
|
if (markerFiles.trim()) continue;
|
|
1920
2242
|
const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
|
|
1921
2243
|
if (diff === null) continue;
|
|
@@ -1955,6 +2277,25 @@ var ReplayService = class {
|
|
|
1955
2277
|
}
|
|
1956
2278
|
}
|
|
1957
2279
|
}
|
|
2280
|
+
/**
|
|
2281
|
+
* Clean up stale conflict markers left by a previous crashed run.
|
|
2282
|
+
* Called after commitGeneration() when HEAD is the [fern-generated] commit.
|
|
2283
|
+
* Restores files to their clean generated state from HEAD.
|
|
2284
|
+
* Skips .fernignore-protected files to prevent overwriting user content.
|
|
2285
|
+
*/
|
|
2286
|
+
async cleanupStaleConflictMarkers() {
|
|
2287
|
+
const markerFiles = await this.git.exec(["grep", "-l", "<<<<<<< Generated", "--", "."]).catch(() => "");
|
|
2288
|
+
const files = markerFiles.trim().split("\n").filter(Boolean);
|
|
2289
|
+
if (files.length === 0) return;
|
|
2290
|
+
const fernignorePatterns = this.readFernignorePatterns();
|
|
2291
|
+
for (const file of files) {
|
|
2292
|
+
if (fernignorePatterns.some((pattern) => minimatch2(file, pattern))) continue;
|
|
2293
|
+
try {
|
|
2294
|
+
await this.git.exec(["checkout", "HEAD", "--", file]);
|
|
2295
|
+
} catch {
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
1958
2299
|
readFernignorePatterns() {
|
|
1959
2300
|
const fernignorePath = join3(this.outputDir, ".fernignore");
|
|
1960
2301
|
if (!existsSync2(fernignorePath)) return [];
|
|
@@ -2729,6 +3070,7 @@ export {
|
|
|
2729
3070
|
FernignoreMigrator,
|
|
2730
3071
|
GitClient,
|
|
2731
3072
|
LockfileManager,
|
|
3073
|
+
LockfileNotFoundError,
|
|
2732
3074
|
ReplayApplicator,
|
|
2733
3075
|
ReplayCommitter,
|
|
2734
3076
|
ReplayDetector,
|