@fern-api/replay 0.14.0 → 0.14.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 +3182 -2855
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +1748 -1417
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +86 -2
- package/dist/index.d.ts +86 -2
- package/dist/index.js +1747 -1417
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -417,6 +417,64 @@ var init_HybridReconstruction = __esm({
|
|
|
417
417
|
}
|
|
418
418
|
});
|
|
419
419
|
|
|
420
|
+
// src/PatchApplyTheirs.ts
|
|
421
|
+
var PatchApplyTheirs_exports = {};
|
|
422
|
+
__export(PatchApplyTheirs_exports, {
|
|
423
|
+
applyPatchToContent: () => applyPatchToContent
|
|
424
|
+
});
|
|
425
|
+
async function applyPatchToContent(base, patchContent, filePath) {
|
|
426
|
+
const fileDiff = extractFileDiff(patchContent, filePath);
|
|
427
|
+
if (!fileDiff) return null;
|
|
428
|
+
const tempDir = await (0, import_promises3.mkdtemp)((0, import_node_path4.join)((0, import_node_os3.tmpdir)(), "replay-migrate-"));
|
|
429
|
+
const tempGit = new GitClient(tempDir);
|
|
430
|
+
try {
|
|
431
|
+
await tempGit.exec(["init"]);
|
|
432
|
+
await tempGit.exec(["config", "user.email", "migrate@fern.com"]);
|
|
433
|
+
await tempGit.exec(["config", "user.name", "Fern Replay Migration"]);
|
|
434
|
+
await tempGit.exec(["config", "commit.gpgSign", "false"]);
|
|
435
|
+
const tempFilePath = (0, import_node_path4.join)(tempDir, filePath);
|
|
436
|
+
await (0, import_promises3.mkdir)((0, import_node_path4.dirname)(tempFilePath), { recursive: true });
|
|
437
|
+
await (0, import_promises3.writeFile)(tempFilePath, base);
|
|
438
|
+
await tempGit.exec(["add", filePath]);
|
|
439
|
+
await tempGit.exec(["commit", "-m", `base for ${filePath}`, "--allow-empty"]);
|
|
440
|
+
await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
|
|
441
|
+
return await (0, import_promises3.readFile)(tempFilePath, "utf-8");
|
|
442
|
+
} catch {
|
|
443
|
+
return null;
|
|
444
|
+
} finally {
|
|
445
|
+
await (0, import_promises3.rm)(tempDir, { recursive: true, force: true }).catch(() => {
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
function extractFileDiff(patchContent, filePath) {
|
|
450
|
+
const lines = patchContent.split("\n");
|
|
451
|
+
const out = [];
|
|
452
|
+
let inTarget = false;
|
|
453
|
+
for (const line of lines) {
|
|
454
|
+
if (line.startsWith("diff --git")) {
|
|
455
|
+
if (inTarget) break;
|
|
456
|
+
const match = line.match(/^diff --git a\/.+ b\/(.+)$/);
|
|
457
|
+
if (match && match[1] === filePath) {
|
|
458
|
+
inTarget = true;
|
|
459
|
+
out.push(line);
|
|
460
|
+
}
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
if (inTarget) out.push(line);
|
|
464
|
+
}
|
|
465
|
+
return out.length > 0 ? out.join("\n") + "\n" : null;
|
|
466
|
+
}
|
|
467
|
+
var import_promises3, import_node_os3, import_node_path4;
|
|
468
|
+
var init_PatchApplyTheirs = __esm({
|
|
469
|
+
"src/PatchApplyTheirs.ts"() {
|
|
470
|
+
"use strict";
|
|
471
|
+
import_promises3 = require("fs/promises");
|
|
472
|
+
import_node_os3 = require("os");
|
|
473
|
+
import_node_path4 = require("path");
|
|
474
|
+
init_GitClient();
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
|
|
420
478
|
// src/index.ts
|
|
421
479
|
var index_exports = {};
|
|
422
480
|
__export(index_exports, {
|
|
@@ -580,7 +638,17 @@ var LockfileManager = class {
|
|
|
580
638
|
this.warnings.length = 0;
|
|
581
639
|
const cleanPatches = [];
|
|
582
640
|
for (const patch of this.lock.patches) {
|
|
583
|
-
const
|
|
641
|
+
const diffMatches = scanForCredentials(patch.patch_content);
|
|
642
|
+
const snapshotMatches = [];
|
|
643
|
+
if (patch.theirs_snapshot) {
|
|
644
|
+
for (const content2 of Object.values(patch.theirs_snapshot)) {
|
|
645
|
+
const m = scanForCredentials(content2);
|
|
646
|
+
for (const x of m) {
|
|
647
|
+
if (!snapshotMatches.includes(x)) snapshotMatches.push(x);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
const credentialMatches = [...diffMatches, ...snapshotMatches.filter((m) => !diffMatches.includes(m))];
|
|
584
652
|
const sensitiveFiles = patch.files.filter((f) => isSensitiveFile(f));
|
|
585
653
|
if (credentialMatches.length > 0 || sensitiveFiles.length > 0) {
|
|
586
654
|
const reasons = [];
|
|
@@ -701,1520 +769,1593 @@ var LockfileManager = class {
|
|
|
701
769
|
|
|
702
770
|
// src/ReplayDetector.ts
|
|
703
771
|
var import_node_crypto = require("crypto");
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
772
|
+
|
|
773
|
+
// src/ReplayApplicator.ts
|
|
774
|
+
var import_promises = require("fs/promises");
|
|
775
|
+
var import_node_os = require("os");
|
|
776
|
+
var import_node_path2 = require("path");
|
|
777
|
+
var import_minimatch = require("minimatch");
|
|
778
|
+
|
|
779
|
+
// src/conflict-utils.ts
|
|
780
|
+
var CONFLICT_OPENER = "<<<<<<< Generated";
|
|
781
|
+
var CONFLICT_SEPARATOR = "=======";
|
|
782
|
+
var CONFLICT_CLOSER = ">>>>>>> Your customization";
|
|
783
|
+
function trimCR(line) {
|
|
784
|
+
return line.endsWith("\r") ? line.slice(0, -1) : line;
|
|
785
|
+
}
|
|
786
|
+
function findConflictRanges(lines) {
|
|
787
|
+
const ranges = [];
|
|
788
|
+
let i = 0;
|
|
789
|
+
while (i < lines.length) {
|
|
790
|
+
if (trimCR(lines[i]) === CONFLICT_OPENER) {
|
|
791
|
+
let separatorIdx = -1;
|
|
792
|
+
let j = i + 1;
|
|
793
|
+
let found = false;
|
|
794
|
+
while (j < lines.length) {
|
|
795
|
+
const trimmed = trimCR(lines[j]);
|
|
796
|
+
if (trimmed === CONFLICT_OPENER) {
|
|
797
|
+
break;
|
|
798
|
+
}
|
|
799
|
+
if (separatorIdx === -1 && trimmed === CONFLICT_SEPARATOR) {
|
|
800
|
+
separatorIdx = j;
|
|
801
|
+
} else if (separatorIdx !== -1 && trimmed === CONFLICT_CLOSER) {
|
|
802
|
+
ranges.push({ start: i, separator: separatorIdx, end: j });
|
|
803
|
+
i = j;
|
|
804
|
+
found = true;
|
|
805
|
+
break;
|
|
806
|
+
}
|
|
807
|
+
j++;
|
|
724
808
|
}
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
} else if (line.startsWith("deleted file mode ")) {
|
|
729
|
-
currentIsDelete = true;
|
|
809
|
+
if (!found) {
|
|
810
|
+
i++;
|
|
811
|
+
continue;
|
|
730
812
|
}
|
|
731
813
|
}
|
|
814
|
+
i++;
|
|
732
815
|
}
|
|
733
|
-
|
|
734
|
-
return results;
|
|
816
|
+
return ranges;
|
|
735
817
|
}
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
constructor(git, lockManager, sdkOutputDir) {
|
|
742
|
-
this.git = git;
|
|
743
|
-
this.lockManager = lockManager;
|
|
744
|
-
this.sdkOutputDir = sdkOutputDir;
|
|
745
|
-
}
|
|
746
|
-
async detectNewPatches() {
|
|
747
|
-
const lock = this.lockManager.read();
|
|
748
|
-
const lastGen = this.getLastGeneration(lock);
|
|
749
|
-
if (!lastGen) {
|
|
750
|
-
return { patches: [], revertedPatchIds: [] };
|
|
751
|
-
}
|
|
752
|
-
const exists = await this.git.commitExists(lastGen.commit_sha);
|
|
753
|
-
if (!exists) {
|
|
754
|
-
this.warnings.push(
|
|
755
|
-
`Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to alternate detection.`
|
|
756
|
-
);
|
|
757
|
-
return this.detectPatchesViaTreeDiff(
|
|
758
|
-
lastGen,
|
|
759
|
-
/* commitKnownMissing */
|
|
760
|
-
true
|
|
761
|
-
);
|
|
762
|
-
}
|
|
763
|
-
const isAncestor = await this.git.isAncestor(lastGen.commit_sha, "HEAD");
|
|
764
|
-
if (!isAncestor) {
|
|
765
|
-
return this.detectPatchesViaMergeBase(lastGen, lock);
|
|
766
|
-
}
|
|
767
|
-
return this.detectPatchesInRange(lastGen.commit_sha, lastGen, lock);
|
|
768
|
-
}
|
|
769
|
-
/**
|
|
770
|
-
* FER-10201 — Non-linear-history detection via `merge-base(prevGen, HEAD)`.
|
|
771
|
-
*
|
|
772
|
-
* After a squash-merge of a Fern-bot PR, `lastGen.commit_sha` (the previous
|
|
773
|
-
* `[fern-generated]`) is orphaned but still in git's object database. The
|
|
774
|
-
* merge-base is the commit on main from which the bot's branch was created —
|
|
775
|
-
* a reachable ancestor of HEAD. Walking that range and classifying each
|
|
776
|
-
* commit via `isGenerationCommit` mirrors the linear path and eliminates
|
|
777
|
-
* the bug class where pipeline-driven changes (autoversion, replay,
|
|
778
|
-
* generation) get bundled as customer customizations.
|
|
779
|
-
*
|
|
780
|
-
* Falls through to the legacy composite path when no common ancestor exists
|
|
781
|
-
* (truly disjoint history — orphan branches with no shared root).
|
|
782
|
-
*/
|
|
783
|
-
async detectPatchesViaMergeBase(lastGen, lock) {
|
|
784
|
-
let mergeBase = "";
|
|
785
|
-
try {
|
|
786
|
-
mergeBase = (await this.git.exec(["merge-base", lastGen.commit_sha, "HEAD"])).trim();
|
|
787
|
-
} catch {
|
|
788
|
-
}
|
|
789
|
-
if (!mergeBase) {
|
|
790
|
-
this.warnings.push(
|
|
791
|
-
`No common ancestor between previous generation ${lastGen.commit_sha.slice(0, 7)} and HEAD. Falling back to composite tree-diff detection.`
|
|
792
|
-
);
|
|
793
|
-
return this.detectPatchesViaTreeDiff(
|
|
794
|
-
lastGen,
|
|
795
|
-
/* commitKnownMissing */
|
|
796
|
-
false
|
|
797
|
-
);
|
|
798
|
-
}
|
|
799
|
-
return this.detectPatchesInRange(mergeBase, lastGen, lock);
|
|
818
|
+
function stripConflictMarkers(content) {
|
|
819
|
+
const lines = content.split(/\r?\n/);
|
|
820
|
+
const ranges = findConflictRanges(lines);
|
|
821
|
+
if (ranges.length === 0) {
|
|
822
|
+
return content;
|
|
800
823
|
}
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
* lockfile-tracked reference the applicator uses to fetch base file
|
|
808
|
-
* content, regardless of where the walk actually started.
|
|
809
|
-
*/
|
|
810
|
-
async detectPatchesInRange(rangeStart, lastGen, lock) {
|
|
811
|
-
const log = await this.git.exec([
|
|
812
|
-
"log",
|
|
813
|
-
"--first-parent",
|
|
814
|
-
"--format=%H%x00%an%x00%ae%x00%s",
|
|
815
|
-
`${rangeStart}..HEAD`,
|
|
816
|
-
"--",
|
|
817
|
-
this.sdkOutputDir
|
|
818
|
-
]);
|
|
819
|
-
if (!log.trim()) {
|
|
820
|
-
return { patches: [], revertedPatchIds: [] };
|
|
821
|
-
}
|
|
822
|
-
const commits = this.parseGitLog(log);
|
|
823
|
-
const newPatches = [];
|
|
824
|
-
const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
|
|
825
|
-
for (const commit of commits) {
|
|
826
|
-
if (isGenerationCommit(commit)) {
|
|
827
|
-
continue;
|
|
828
|
-
}
|
|
829
|
-
if (lock.patches.find((p) => p.original_commit === commit.sha)) {
|
|
824
|
+
const result = [];
|
|
825
|
+
let rangeIdx = 0;
|
|
826
|
+
for (let i = 0; i < lines.length; i++) {
|
|
827
|
+
if (rangeIdx < ranges.length) {
|
|
828
|
+
const range = ranges[rangeIdx];
|
|
829
|
+
if (i === range.start) {
|
|
830
830
|
continue;
|
|
831
831
|
}
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
const compositePatch = await this.createMergeCompositePatch({
|
|
835
|
-
mergeCommit: commit,
|
|
836
|
-
firstParent: parents[0],
|
|
837
|
-
lastGen,
|
|
838
|
-
lock
|
|
839
|
-
});
|
|
840
|
-
if (compositePatch) {
|
|
841
|
-
newPatches.push(compositePatch);
|
|
842
|
-
}
|
|
832
|
+
if (i > range.start && i < range.separator) {
|
|
833
|
+
result.push(lines[i]);
|
|
843
834
|
continue;
|
|
844
835
|
}
|
|
845
|
-
|
|
846
|
-
try {
|
|
847
|
-
patchContent = await this.git.formatPatch(commit.sha);
|
|
848
|
-
} catch {
|
|
849
|
-
this.warnings.push(
|
|
850
|
-
`Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
|
|
851
|
-
);
|
|
836
|
+
if (i === range.separator) {
|
|
852
837
|
continue;
|
|
853
838
|
}
|
|
854
|
-
|
|
855
|
-
if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
|
|
839
|
+
if (i > range.separator && i < range.end) {
|
|
856
840
|
continue;
|
|
857
841
|
}
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
const sensitiveFiles = allFiles.filter((f) => isSensitiveFile(f));
|
|
861
|
-
const files = allFiles.filter((f) => !isSensitiveFile(f));
|
|
862
|
-
if (sensitiveFiles.length > 0) {
|
|
863
|
-
this.warnings.push(
|
|
864
|
-
`Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
|
|
865
|
-
);
|
|
866
|
-
if (files.length > 0) {
|
|
867
|
-
const parentSha = parents[0];
|
|
868
|
-
if (parentSha) {
|
|
869
|
-
try {
|
|
870
|
-
patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
|
|
871
|
-
} catch {
|
|
872
|
-
continue;
|
|
873
|
-
}
|
|
874
|
-
if (!patchContent.trim()) continue;
|
|
875
|
-
contentHash = this.computeContentHash(patchContent);
|
|
876
|
-
}
|
|
877
|
-
}
|
|
878
|
-
}
|
|
879
|
-
if (files.length === 0) {
|
|
842
|
+
if (i === range.end) {
|
|
843
|
+
rangeIdx++;
|
|
880
844
|
continue;
|
|
881
845
|
}
|
|
882
|
-
newPatches.push({
|
|
883
|
-
id: `patch-${commit.sha.slice(0, 8)}`,
|
|
884
|
-
content_hash: contentHash,
|
|
885
|
-
original_commit: commit.sha,
|
|
886
|
-
original_message: commit.message,
|
|
887
|
-
original_author: `${commit.authorName} <${commit.authorEmail}>`,
|
|
888
|
-
base_generation: lastGen.commit_sha,
|
|
889
|
-
files,
|
|
890
|
-
patch_content: patchContent,
|
|
891
|
-
...this.isCreationOnlyPatch(patchContent) ? { user_owned: true } : {}
|
|
892
|
-
});
|
|
893
846
|
}
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
);
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
);
|
|
941
|
-
if (idx !== -1) {
|
|
942
|
-
revertIndicesToRemove.add(i);
|
|
943
|
-
revertIndicesToRemove.add(idx);
|
|
944
|
-
}
|
|
945
|
-
}
|
|
946
|
-
if (!matchedExisting && !matchedNew) {
|
|
947
|
-
revertIndicesToRemove.add(i);
|
|
847
|
+
result.push(lines[i]);
|
|
848
|
+
}
|
|
849
|
+
return result.join("\n");
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// src/ThreeWayMerge.ts
|
|
853
|
+
var import_node_diff3 = require("node-diff3");
|
|
854
|
+
function threeWayMerge(base, ours, theirs) {
|
|
855
|
+
const baseLines = base.split("\n");
|
|
856
|
+
const oursLines = ours.split("\n");
|
|
857
|
+
const theirsLines = theirs.split("\n");
|
|
858
|
+
const regions = (0, import_node_diff3.diff3Merge)(oursLines, baseLines, theirsLines);
|
|
859
|
+
const outputLines = [];
|
|
860
|
+
const conflicts = [];
|
|
861
|
+
let currentLine = 1;
|
|
862
|
+
for (const region of regions) {
|
|
863
|
+
if (region.ok) {
|
|
864
|
+
outputLines.push(...region.ok);
|
|
865
|
+
currentLine += region.ok.length;
|
|
866
|
+
} else if (region.conflict) {
|
|
867
|
+
const resolved = tryResolveConflict(
|
|
868
|
+
region.conflict.a,
|
|
869
|
+
// ours (generator)
|
|
870
|
+
region.conflict.o,
|
|
871
|
+
// base
|
|
872
|
+
region.conflict.b
|
|
873
|
+
// theirs (user)
|
|
874
|
+
);
|
|
875
|
+
if (resolved !== null) {
|
|
876
|
+
outputLines.push(...resolved);
|
|
877
|
+
currentLine += resolved.length;
|
|
878
|
+
} else {
|
|
879
|
+
const startLine = currentLine;
|
|
880
|
+
outputLines.push("<<<<<<< Generated");
|
|
881
|
+
outputLines.push(...region.conflict.a);
|
|
882
|
+
outputLines.push("=======");
|
|
883
|
+
outputLines.push(...region.conflict.b);
|
|
884
|
+
outputLines.push(">>>>>>> Your customization");
|
|
885
|
+
const conflictLines = region.conflict.a.length + region.conflict.b.length + 3;
|
|
886
|
+
conflicts.push({
|
|
887
|
+
startLine,
|
|
888
|
+
endLine: startLine + conflictLines - 1,
|
|
889
|
+
ours: region.conflict.a,
|
|
890
|
+
theirs: region.conflict.b
|
|
891
|
+
});
|
|
892
|
+
currentLine += conflictLines;
|
|
948
893
|
}
|
|
949
894
|
}
|
|
950
|
-
const filteredPatches = survivingPatches.filter((_, i) => !revertIndicesToRemove.has(i));
|
|
951
|
-
return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
|
|
952
895
|
}
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
* 2. Strip `index` lines (blob SHA pairs change across rebases).
|
|
964
|
-
* 3. Strip trailing git version marker (`-- \n<version>`).
|
|
965
|
-
*
|
|
966
|
-
* This ensures content hashes match across the no-patches and normal-
|
|
967
|
-
* regeneration flows, which store formatPatch and git-diff formats
|
|
968
|
-
* respectively (FER-9850, D5-D9).
|
|
969
|
-
*/
|
|
970
|
-
computeContentHash(patchContent) {
|
|
971
|
-
const lines = patchContent.split("\n");
|
|
972
|
-
const diffStart = lines.findIndex((l) => l.startsWith("diff --git "));
|
|
973
|
-
const relevantLines = diffStart > 0 ? lines.slice(diffStart) : lines;
|
|
974
|
-
const normalized = relevantLines.filter((line) => !line.startsWith("index ")).join("\n").replace(/\n-- \n[\d]+\.[\d]+[^\n]*\s*$/, "");
|
|
975
|
-
return `sha256:${(0, import_node_crypto.createHash)("sha256").update(normalized).digest("hex")}`;
|
|
896
|
+
const result = {
|
|
897
|
+
content: outputLines.join("\n"),
|
|
898
|
+
hasConflicts: conflicts.length > 0,
|
|
899
|
+
conflicts
|
|
900
|
+
};
|
|
901
|
+
if (conflicts.length === 0) {
|
|
902
|
+
const dropped = computeDroppedContextLines(baseLines, theirsLines, outputLines);
|
|
903
|
+
if (dropped.length > 0) {
|
|
904
|
+
result.droppedContextLines = dropped;
|
|
905
|
+
}
|
|
976
906
|
}
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
*
|
|
994
|
-
* The HEAD guard (#3) prevents false positives when a file is deleted
|
|
995
|
-
* and then recreated with different content — the cumulative diff of
|
|
996
|
-
* such a pair is non-empty and must be preserved.
|
|
997
|
-
*
|
|
998
|
-
* This only drops patches whose `files` are a subset of the transient
|
|
999
|
-
* set. A partial-transient patch (one that touches a transient file
|
|
1000
|
-
* alongside a persistent one) is left unmodified; surgical stripping of
|
|
1001
|
-
* single files from a multi-file patch would require a follow-up that
|
|
1002
|
-
* regenerates the patch content via `git format-patch -- <files>`. No
|
|
1003
|
-
* current failing test exercises this, and leaving the patch intact
|
|
1004
|
-
* preserves today's behaviour. TODO(FER-9805): revisit if a future
|
|
1005
|
-
* adversarial test demands per-file stripping.
|
|
1006
|
-
*/
|
|
1007
|
-
async collapseNetZeroFiles(patches) {
|
|
1008
|
-
if (patches.length === 0) return patches;
|
|
1009
|
-
const stateByFile = /* @__PURE__ */ new Map();
|
|
1010
|
-
for (const patch of patches) {
|
|
1011
|
-
for (const header of parsePatchFileHeaders(patch.patch_content)) {
|
|
1012
|
-
if (!header.isCreate && !header.isDelete) continue;
|
|
1013
|
-
const prior = stateByFile.get(header.path) ?? { created: false, deleted: false };
|
|
1014
|
-
if (header.isCreate) prior.created = true;
|
|
1015
|
-
if (header.isDelete) prior.deleted = true;
|
|
1016
|
-
stateByFile.set(header.path, prior);
|
|
1017
|
-
}
|
|
907
|
+
return result;
|
|
908
|
+
}
|
|
909
|
+
function computeDroppedContextLines(baseLines, theirsLines, mergedLines) {
|
|
910
|
+
const baseCounts = countLines(baseLines);
|
|
911
|
+
const theirsCounts = countLines(theirsLines);
|
|
912
|
+
const mergedCounts = countLines(mergedLines);
|
|
913
|
+
const dropped = [];
|
|
914
|
+
const seen = /* @__PURE__ */ new Set();
|
|
915
|
+
for (const line of baseLines) {
|
|
916
|
+
if (seen.has(line)) continue;
|
|
917
|
+
const inBase = baseCounts.get(line) ?? 0;
|
|
918
|
+
const inTheirs = theirsCounts.get(line) ?? 0;
|
|
919
|
+
const inMerged = mergedCounts.get(line) ?? 0;
|
|
920
|
+
if (inBase > 0 && inTheirs > 0 && inMerged < Math.min(inBase, inTheirs)) {
|
|
921
|
+
dropped.push(line);
|
|
922
|
+
seen.add(line);
|
|
1018
923
|
}
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
924
|
+
}
|
|
925
|
+
return dropped;
|
|
926
|
+
}
|
|
927
|
+
function countLines(lines) {
|
|
928
|
+
const counts = /* @__PURE__ */ new Map();
|
|
929
|
+
for (const line of lines) {
|
|
930
|
+
counts.set(line, (counts.get(line) ?? 0) + 1);
|
|
931
|
+
}
|
|
932
|
+
return counts;
|
|
933
|
+
}
|
|
934
|
+
function tryResolveConflict(oursLines, baseLines, theirsLines) {
|
|
935
|
+
if (baseLines.length === 0) {
|
|
936
|
+
return null;
|
|
937
|
+
}
|
|
938
|
+
const oursPatches = (0, import_node_diff3.diffPatch)(baseLines, oursLines);
|
|
939
|
+
const theirsPatches = (0, import_node_diff3.diffPatch)(baseLines, theirsLines);
|
|
940
|
+
if (oursPatches.length === 0) return theirsLines;
|
|
941
|
+
if (theirsPatches.length === 0) return oursLines;
|
|
942
|
+
if (patchesOverlap(oursPatches, theirsPatches)) {
|
|
943
|
+
return null;
|
|
944
|
+
}
|
|
945
|
+
return applyBothPatches(baseLines, oursPatches, theirsPatches);
|
|
946
|
+
}
|
|
947
|
+
function patchesOverlap(oursPatches, theirsPatches) {
|
|
948
|
+
for (const op of oursPatches) {
|
|
949
|
+
const oStart = op.buffer1.offset;
|
|
950
|
+
const oEnd = oStart + op.buffer1.length;
|
|
951
|
+
for (const tp of theirsPatches) {
|
|
952
|
+
const tStart = tp.buffer1.offset;
|
|
953
|
+
const tEnd = tStart + tp.buffer1.length;
|
|
954
|
+
if (op.buffer1.length === 0 && tp.buffer1.length === 0 && oStart === tStart) {
|
|
955
|
+
return true;
|
|
1024
956
|
}
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
957
|
+
if (tp.buffer1.length === 0 && tStart === oEnd) {
|
|
958
|
+
return true;
|
|
959
|
+
}
|
|
960
|
+
if (op.buffer1.length === 0 && oStart === tEnd) {
|
|
961
|
+
return true;
|
|
962
|
+
}
|
|
963
|
+
if (oStart < tEnd && tStart < oEnd) {
|
|
964
|
+
return true;
|
|
1032
965
|
}
|
|
1033
966
|
}
|
|
1034
|
-
if (transient.size === 0) return patches;
|
|
1035
|
-
return patches.filter((patch) => !patch.files.every((f) => transient.has(f)));
|
|
1036
967
|
}
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
968
|
+
return false;
|
|
969
|
+
}
|
|
970
|
+
function applyBothPatches(baseLines, oursPatches, theirsPatches) {
|
|
971
|
+
const allPatches = [
|
|
972
|
+
...oursPatches.map((p) => ({
|
|
973
|
+
offset: p.buffer1.offset,
|
|
974
|
+
length: p.buffer1.length,
|
|
975
|
+
replacement: p.buffer2.chunk
|
|
976
|
+
})),
|
|
977
|
+
...theirsPatches.map((p) => ({
|
|
978
|
+
offset: p.buffer1.offset,
|
|
979
|
+
length: p.buffer1.length,
|
|
980
|
+
replacement: p.buffer2.chunk
|
|
981
|
+
}))
|
|
982
|
+
];
|
|
983
|
+
allPatches.sort((a, b) => b.offset - a.offset);
|
|
984
|
+
const result = [...baseLines];
|
|
985
|
+
for (const p of allPatches) {
|
|
986
|
+
result.splice(p.offset, p.length, ...p.replacement);
|
|
987
|
+
}
|
|
988
|
+
return result;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// src/ReplayApplicator.ts
|
|
992
|
+
var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
993
|
+
".png",
|
|
994
|
+
".jpg",
|
|
995
|
+
".jpeg",
|
|
996
|
+
".gif",
|
|
997
|
+
".bmp",
|
|
998
|
+
".ico",
|
|
999
|
+
".webp",
|
|
1000
|
+
".svg",
|
|
1001
|
+
".pdf",
|
|
1002
|
+
".doc",
|
|
1003
|
+
".docx",
|
|
1004
|
+
".xls",
|
|
1005
|
+
".xlsx",
|
|
1006
|
+
".ppt",
|
|
1007
|
+
".pptx",
|
|
1008
|
+
".zip",
|
|
1009
|
+
".gz",
|
|
1010
|
+
".tar",
|
|
1011
|
+
".bz2",
|
|
1012
|
+
".7z",
|
|
1013
|
+
".rar",
|
|
1014
|
+
".jar",
|
|
1015
|
+
".war",
|
|
1016
|
+
".ear",
|
|
1017
|
+
".class",
|
|
1018
|
+
".exe",
|
|
1019
|
+
".dll",
|
|
1020
|
+
".so",
|
|
1021
|
+
".dylib",
|
|
1022
|
+
".o",
|
|
1023
|
+
".a",
|
|
1024
|
+
".woff",
|
|
1025
|
+
".woff2",
|
|
1026
|
+
".ttf",
|
|
1027
|
+
".eot",
|
|
1028
|
+
".otf",
|
|
1029
|
+
".mp3",
|
|
1030
|
+
".mp4",
|
|
1031
|
+
".avi",
|
|
1032
|
+
".mov",
|
|
1033
|
+
".wav",
|
|
1034
|
+
".flac",
|
|
1035
|
+
".sqlite",
|
|
1036
|
+
".db",
|
|
1037
|
+
".pyc",
|
|
1038
|
+
".pyo",
|
|
1039
|
+
".DS_Store"
|
|
1040
|
+
]);
|
|
1041
|
+
var ReplayApplicator = class {
|
|
1042
|
+
git;
|
|
1043
|
+
lockManager;
|
|
1044
|
+
outputDir;
|
|
1045
|
+
renameCache = /* @__PURE__ */ new Map();
|
|
1046
|
+
treeExistsCache = /* @__PURE__ */ new Map();
|
|
1047
|
+
fileTheirsAccumulator = /* @__PURE__ */ new Map();
|
|
1048
|
+
/**
|
|
1049
|
+
* Apply mode for the current `applyPatches` invocation. Set at the start
|
|
1050
|
+
* of `applyPatches`, read by `mergeFile` to decide:
|
|
1051
|
+
* - whether to skip the intra-loop marker strip (kept in `applyPatches`)
|
|
1052
|
+
* - whether to populate the accumulator after a conflicted merge
|
|
1053
|
+
* (resolve mode populates so subsequent patches on the same file
|
|
1054
|
+
* can use patch A's THEIRS as a structurally-correct merge base
|
|
1055
|
+
* when their diff was authored against post-A structure)
|
|
1056
|
+
* - whether to retry THEIRS reconstruction against the accumulator
|
|
1057
|
+
* when the BASE-relative reconstruction produced markers from a
|
|
1058
|
+
* cross-patch context mismatch
|
|
1042
1059
|
*/
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1060
|
+
currentApplyMode = "replay";
|
|
1061
|
+
/**
|
|
1062
|
+
* Set of files that appear in 2+ patches in the current `applyPatches`
|
|
1063
|
+
* invocation. Computed at the top of `applyPatches`. Read by `mergeFile`
|
|
1064
|
+
* and `populateAccumulatorForPatch` to decide whether to use the patch's
|
|
1065
|
+
* `theirs_snapshot` directly as THEIRS (snapshot-as-primary) or fall
|
|
1066
|
+
* back to line-anchored reconstruction (FER-9525 cross-patch isolation).
|
|
1067
|
+
*
|
|
1068
|
+
* Snapshot-as-primary is correct when the patch owns its file (no other
|
|
1069
|
+
* patch in this run touches it): the snapshot is what the customer
|
|
1070
|
+
* intends for that file post-regen, regardless of generator structural
|
|
1071
|
+
* changes that would invalidate the diff's line anchors.
|
|
1072
|
+
*
|
|
1073
|
+
* When a file IS shared, snapshots are CUMULATIVE across the patches
|
|
1074
|
+
* touching it (each one's snapshot includes prior patches' contributions).
|
|
1075
|
+
* Using a later patch's cumulative snapshot as its individual THEIRS
|
|
1076
|
+
* would propagate prior patches' edits into the merge — surfacing nested
|
|
1077
|
+
* conflicts at regions the patch alone never touched. Reconstruction
|
|
1078
|
+
* via `applyPatchToContent` is required there.
|
|
1079
|
+
*/
|
|
1080
|
+
sharedFiles = /* @__PURE__ */ new Set();
|
|
1081
|
+
constructor(git, lockManager, outputDir) {
|
|
1082
|
+
this.git = git;
|
|
1083
|
+
this.lockManager = lockManager;
|
|
1084
|
+
this.outputDir = outputDir;
|
|
1050
1085
|
}
|
|
1051
1086
|
/**
|
|
1052
|
-
*
|
|
1053
|
-
*
|
|
1054
|
-
*
|
|
1055
|
-
* zero-net-change ghost conflicts.
|
|
1087
|
+
* Resolve the GenerationRecord for a patch's base_generation.
|
|
1088
|
+
* Falls back to constructing an ad-hoc record from the commit's tree
|
|
1089
|
+
* when base_generation isn't a tracked generation (commit-scan patches).
|
|
1056
1090
|
*/
|
|
1057
|
-
async
|
|
1058
|
-
const
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
`Sensitive file(s) excluded from merge patch for commit ${mergeCommit.sha.slice(0, 7)}: ${sensitiveMergeFiles.join(", ")}. These files will not be tracked by replay.`
|
|
1072
|
-
);
|
|
1073
|
-
}
|
|
1074
|
-
if (files.length === 0) return null;
|
|
1075
|
-
const diff = await this.git.exec([
|
|
1076
|
-
"diff",
|
|
1077
|
-
firstParent,
|
|
1078
|
-
mergeCommit.sha,
|
|
1079
|
-
"--",
|
|
1080
|
-
...files
|
|
1081
|
-
]);
|
|
1082
|
-
if (!diff.trim()) return null;
|
|
1083
|
-
const contentHash = this.computeContentHash(diff);
|
|
1084
|
-
if (lock.patches.some((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
|
|
1085
|
-
return null;
|
|
1091
|
+
async resolveBaseGeneration(baseGeneration) {
|
|
1092
|
+
const gen = this.lockManager.getGeneration(baseGeneration);
|
|
1093
|
+
if (gen) return gen;
|
|
1094
|
+
try {
|
|
1095
|
+
const treeHash = await this.git.getTreeHash(baseGeneration);
|
|
1096
|
+
return {
|
|
1097
|
+
commit_sha: baseGeneration,
|
|
1098
|
+
tree_hash: treeHash,
|
|
1099
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1100
|
+
cli_version: "unknown",
|
|
1101
|
+
generator_versions: {}
|
|
1102
|
+
};
|
|
1103
|
+
} catch {
|
|
1104
|
+
return void 0;
|
|
1086
1105
|
}
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
original_message: mergeCommit.message,
|
|
1092
|
-
original_author: `${mergeCommit.authorName} <${mergeCommit.authorEmail}>`,
|
|
1093
|
-
base_generation: lastGen.commit_sha,
|
|
1094
|
-
files,
|
|
1095
|
-
patch_content: diff,
|
|
1096
|
-
...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
|
|
1097
|
-
};
|
|
1106
|
+
}
|
|
1107
|
+
/** Reset inter-patch accumulator for a new cycle. */
|
|
1108
|
+
resetAccumulator() {
|
|
1109
|
+
this.fileTheirsAccumulator.clear();
|
|
1098
1110
|
}
|
|
1099
1111
|
/**
|
|
1100
|
-
*
|
|
1101
|
-
*
|
|
1102
|
-
*
|
|
1112
|
+
* @internal Test-only.
|
|
1113
|
+
* Returns a defensive copy of the inter-patch accumulator. Used by the
|
|
1114
|
+
* adversarial test suite (FER-9791) to assert invariant I2: after every
|
|
1115
|
+
* applied patch, the accumulator has an entry for each file the patch
|
|
1116
|
+
* touched, and the entry's content matches on-disk.
|
|
1103
1117
|
*/
|
|
1104
|
-
|
|
1105
|
-
const
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
}
|
|
1109
|
-
const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
|
|
1110
|
-
const allTreeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
|
|
1111
|
-
const sensitiveTreeFiles = allTreeFiles.filter((f) => isSensitiveFile(f));
|
|
1112
|
-
const files = allTreeFiles.filter((f) => !isSensitiveFile(f));
|
|
1113
|
-
if (sensitiveTreeFiles.length > 0) {
|
|
1114
|
-
this.warnings.push(
|
|
1115
|
-
`Sensitive file(s) excluded from composite patch: ${sensitiveTreeFiles.join(", ")}. These files will not be tracked by replay.`
|
|
1116
|
-
);
|
|
1117
|
-
}
|
|
1118
|
-
if (files.length === 0) return { patches: [], revertedPatchIds: [] };
|
|
1119
|
-
const diff = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
|
|
1120
|
-
if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
|
|
1121
|
-
const contentHash = this.computeContentHash(diff);
|
|
1122
|
-
const lock = this.lockManager.read();
|
|
1123
|
-
if (lock.patches.some((p) => p.content_hash === contentHash) || (lock.forgotten_hashes ?? []).includes(contentHash)) {
|
|
1124
|
-
return { patches: [], revertedPatchIds: [] };
|
|
1118
|
+
getAccumulatorSnapshot() {
|
|
1119
|
+
const snapshot = /* @__PURE__ */ new Map();
|
|
1120
|
+
for (const [path, entry] of this.fileTheirsAccumulator) {
|
|
1121
|
+
snapshot.set(path, { content: entry.content, baseGeneration: entry.baseGeneration });
|
|
1125
1122
|
}
|
|
1126
|
-
|
|
1127
|
-
const compositePatch = {
|
|
1128
|
-
id: `patch-composite-${headSha.slice(0, 8)}`,
|
|
1129
|
-
content_hash: contentHash,
|
|
1130
|
-
original_commit: headSha,
|
|
1131
|
-
original_message: "Customer customizations (composite)",
|
|
1132
|
-
original_author: "composite",
|
|
1133
|
-
// Use diffBase when commit is unreachable — the applicator needs a reachable
|
|
1134
|
-
// reference to find base file content. diffBase may be the tree_hash.
|
|
1135
|
-
base_generation: commitKnownMissing ? diffBase : lastGen.commit_sha,
|
|
1136
|
-
files,
|
|
1137
|
-
patch_content: diff,
|
|
1138
|
-
...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
|
|
1139
|
-
};
|
|
1140
|
-
return { patches: [compositePatch], revertedPatchIds: [] };
|
|
1123
|
+
return snapshot;
|
|
1141
1124
|
}
|
|
1142
1125
|
/**
|
|
1143
|
-
*
|
|
1144
|
-
*
|
|
1145
|
-
* skips creation-only commits (squashed history after force push).
|
|
1126
|
+
* Apply all patches, returning results for each.
|
|
1127
|
+
* Skips patches that match exclude patterns in replay.yml
|
|
1146
1128
|
*
|
|
1147
|
-
*
|
|
1148
|
-
*
|
|
1129
|
+
* `applyMode` controls the post-conflict marker strategy:
|
|
1130
|
+
* - `"replay"` (default): strip conflict markers from disk between
|
|
1131
|
+
* iterations whenever a later patch touches the same file. Lets the
|
|
1132
|
+
* follow-up patch's `git apply --3way` see clean OURS content,
|
|
1133
|
+
* which is what the silent-loss fix (PR #73) relies on. The replay
|
|
1134
|
+
* pipeline calls `revertConflictingFiles` after the loop to clean
|
|
1135
|
+
* any markers that survive.
|
|
1136
|
+
* - `"resolve"`: keep markers on disk. The resolve command needs the
|
|
1137
|
+
* customer to see them. Slow-path 3-way merge naturally preserves
|
|
1138
|
+
* A's markers and B's clean writes when their regions don't overlap;
|
|
1139
|
+
* when they do overlap, the customer gets nested markers and
|
|
1140
|
+
* resolves manually. Either way they aren't silently dropped.
|
|
1149
1141
|
*/
|
|
1150
|
-
async
|
|
1151
|
-
const
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
this.sdkOutputDir
|
|
1159
|
-
]);
|
|
1160
|
-
if (!log.trim()) {
|
|
1161
|
-
return { patches: [], revertedPatchIds: [] };
|
|
1162
|
-
}
|
|
1163
|
-
const commits = this.parseGitLog(log);
|
|
1164
|
-
const newPatches = [];
|
|
1165
|
-
const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
|
|
1166
|
-
const existingHashes = new Set(lock.patches.map((p) => p.content_hash));
|
|
1167
|
-
const existingCommits = new Set(lock.patches.map((p) => p.original_commit));
|
|
1168
|
-
for (const commit of commits) {
|
|
1169
|
-
if (isGenerationCommit(commit)) {
|
|
1170
|
-
continue;
|
|
1171
|
-
}
|
|
1172
|
-
const parents = await this.git.getCommitParents(commit.sha);
|
|
1173
|
-
if (parents.length > 1) {
|
|
1174
|
-
continue;
|
|
1175
|
-
}
|
|
1176
|
-
if (existingCommits.has(commit.sha)) {
|
|
1177
|
-
continue;
|
|
1178
|
-
}
|
|
1179
|
-
let patchContent;
|
|
1180
|
-
try {
|
|
1181
|
-
patchContent = await this.git.formatPatch(commit.sha);
|
|
1182
|
-
} catch {
|
|
1183
|
-
continue;
|
|
1184
|
-
}
|
|
1185
|
-
let contentHash = this.computeContentHash(patchContent);
|
|
1186
|
-
if (existingHashes.has(contentHash) || forgottenHashes.has(contentHash)) {
|
|
1187
|
-
continue;
|
|
1142
|
+
async applyPatches(patches, opts) {
|
|
1143
|
+
const applyMode = opts?.applyMode ?? "replay";
|
|
1144
|
+
this.currentApplyMode = applyMode;
|
|
1145
|
+
this.resetAccumulator();
|
|
1146
|
+
const fileFreq = /* @__PURE__ */ new Map();
|
|
1147
|
+
for (const p of patches) {
|
|
1148
|
+
for (const f of p.files) {
|
|
1149
|
+
fileFreq.set(f, (fileFreq.get(f) ?? 0) + 1);
|
|
1188
1150
|
}
|
|
1189
|
-
|
|
1151
|
+
}
|
|
1152
|
+
this.sharedFiles = new Set(
|
|
1153
|
+
Array.from(fileFreq.entries()).filter(([, n]) => n > 1).map(([f]) => f)
|
|
1154
|
+
);
|
|
1155
|
+
const results = [];
|
|
1156
|
+
for (let i = 0; i < patches.length; i++) {
|
|
1157
|
+
const patch = patches[i];
|
|
1158
|
+
if (this.isExcluded(patch)) {
|
|
1159
|
+
results.push({
|
|
1160
|
+
patch,
|
|
1161
|
+
status: "skipped",
|
|
1162
|
+
method: "git-am"
|
|
1163
|
+
});
|
|
1190
1164
|
continue;
|
|
1191
1165
|
}
|
|
1192
|
-
const
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
);
|
|
1200
|
-
if (files.length > 0) {
|
|
1201
|
-
if (parents.length === 0) {
|
|
1202
|
-
continue;
|
|
1166
|
+
const result = await this.applyPatchWithFallback(patch);
|
|
1167
|
+
results.push(result);
|
|
1168
|
+
if (applyMode !== "resolve" && result.status === "conflict" && result.fileResults) {
|
|
1169
|
+
const laterFiles = /* @__PURE__ */ new Set();
|
|
1170
|
+
for (let j = i + 1; j < patches.length; j++) {
|
|
1171
|
+
for (const f of patches[j].files) {
|
|
1172
|
+
laterFiles.add(f);
|
|
1203
1173
|
}
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1174
|
+
}
|
|
1175
|
+
const resolvedToOriginal = /* @__PURE__ */ new Map();
|
|
1176
|
+
if (result.resolvedFiles) {
|
|
1177
|
+
for (const [orig, resolved] of Object.entries(result.resolvedFiles)) {
|
|
1178
|
+
resolvedToOriginal.set(resolved, orig);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
for (const fileResult of result.fileResults) {
|
|
1182
|
+
if (fileResult.status !== "conflict") continue;
|
|
1183
|
+
const originalPath = resolvedToOriginal.get(fileResult.file) ?? fileResult.file;
|
|
1184
|
+
if (laterFiles.has(fileResult.file) || laterFiles.has(originalPath)) {
|
|
1185
|
+
const filePath = (0, import_node_path2.join)(this.outputDir, fileResult.file);
|
|
1186
|
+
try {
|
|
1187
|
+
const content = await (0, import_promises.readFile)(filePath, "utf-8");
|
|
1188
|
+
const stripped = stripConflictMarkers(content);
|
|
1189
|
+
await (0, import_promises.writeFile)(filePath, stripped);
|
|
1190
|
+
} catch {
|
|
1191
|
+
}
|
|
1208
1192
|
}
|
|
1209
|
-
if (!patchContent.trim()) continue;
|
|
1210
|
-
contentHash = this.computeContentHash(patchContent);
|
|
1211
1193
|
}
|
|
1212
1194
|
}
|
|
1213
|
-
if (files.length === 0) {
|
|
1214
|
-
continue;
|
|
1215
|
-
}
|
|
1216
|
-
if (parents.length === 0) {
|
|
1217
|
-
continue;
|
|
1218
|
-
}
|
|
1219
|
-
const parentSha = parents[0];
|
|
1220
|
-
newPatches.push({
|
|
1221
|
-
id: `patch-${commit.sha.slice(0, 8)}`,
|
|
1222
|
-
content_hash: contentHash,
|
|
1223
|
-
original_commit: commit.sha,
|
|
1224
|
-
original_message: commit.message,
|
|
1225
|
-
original_author: `${commit.authorName} <${commit.authorEmail}>`,
|
|
1226
|
-
base_generation: parentSha,
|
|
1227
|
-
files,
|
|
1228
|
-
patch_content: patchContent
|
|
1229
|
-
});
|
|
1230
1195
|
}
|
|
1231
|
-
|
|
1232
|
-
return { patches: newPatches, revertedPatchIds: [] };
|
|
1196
|
+
return results;
|
|
1233
1197
|
}
|
|
1234
1198
|
/**
|
|
1235
|
-
*
|
|
1236
|
-
*
|
|
1237
|
-
*
|
|
1199
|
+
* Populate accumulator after git apply succeeds, AND collect per-file
|
|
1200
|
+
* results including any "dropped context lines" — lines that were
|
|
1201
|
+
* present in BASE and THEIRS (unchanged context) but absent from the
|
|
1202
|
+
* final on-disk state because OURS deleted them. This is the fast-path
|
|
1203
|
+
* counterpart to ThreeWayMerge.computeDroppedContextLines used by the
|
|
1204
|
+
* 3-way slow path; both must surface the same warning.
|
|
1238
1205
|
*/
|
|
1239
|
-
|
|
1240
|
-
const
|
|
1241
|
-
if (
|
|
1242
|
-
|
|
1206
|
+
async populateAccumulatorForPatch(patch, baseGen, currentTreeHash) {
|
|
1207
|
+
const fileResults = [];
|
|
1208
|
+
if (!baseGen) return fileResults;
|
|
1209
|
+
const tempDir = await (0, import_promises.mkdtemp)((0, import_node_path2.join)((0, import_node_os.tmpdir)(), "replay-acc-"));
|
|
1210
|
+
const { GitClient: GitClient2 } = await Promise.resolve().then(() => (init_GitClient(), GitClient_exports));
|
|
1211
|
+
const tempGit = new GitClient2(tempDir);
|
|
1212
|
+
await tempGit.exec(["init"]);
|
|
1213
|
+
await tempGit.exec(["config", "user.email", "replay@fern.com"]);
|
|
1214
|
+
await tempGit.exec(["config", "user.name", "Fern Replay"]);
|
|
1215
|
+
try {
|
|
1216
|
+
for (const filePath of patch.files) {
|
|
1217
|
+
if (isBinaryFile(filePath)) continue;
|
|
1218
|
+
const resolvedPath = await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
|
|
1219
|
+
const base = await this.git.showFile(baseGen.tree_hash, filePath);
|
|
1220
|
+
const snapshotForFile = patch.theirs_snapshot?.[resolvedPath] ?? patch.theirs_snapshot?.[filePath];
|
|
1221
|
+
const fileIsShared = this.sharedFiles.has(resolvedPath) || this.sharedFiles.has(filePath);
|
|
1222
|
+
const theirs = !fileIsShared && snapshotForFile != null ? snapshotForFile : await this.applyPatchToContent(base, patch.patch_content, filePath, tempGit, tempDir);
|
|
1223
|
+
const finalOnDisk = await (0, import_promises.readFile)((0, import_node_path2.join)(this.outputDir, resolvedPath), "utf-8").catch(() => null);
|
|
1224
|
+
const effectiveTheirs = theirs ?? finalOnDisk;
|
|
1225
|
+
if (effectiveTheirs != null) {
|
|
1226
|
+
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
1227
|
+
content: effectiveTheirs,
|
|
1228
|
+
baseGeneration: patch.base_generation
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
if (base != null && effectiveTheirs != null && finalOnDisk != null) {
|
|
1232
|
+
const dropped = computeDroppedContextLinesForFile(base, effectiveTheirs, finalOnDisk);
|
|
1233
|
+
if (dropped.length > 0) {
|
|
1234
|
+
fileResults.push({
|
|
1235
|
+
file: resolvedPath,
|
|
1236
|
+
status: "merged",
|
|
1237
|
+
droppedContextLines: dropped
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
} finally {
|
|
1243
|
+
await (0, import_promises.rm)(tempDir, { recursive: true }).catch(() => {
|
|
1244
|
+
});
|
|
1243
1245
|
}
|
|
1244
|
-
return
|
|
1246
|
+
return fileResults;
|
|
1245
1247
|
}
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1248
|
+
async applyPatchWithFallback(patch) {
|
|
1249
|
+
const baseGen = await this.resolveBaseGeneration(patch.base_generation);
|
|
1250
|
+
const lock = this.lockManager.read();
|
|
1251
|
+
const currentGen = lock.generations.find((g) => g.commit_sha === lock.current_generation);
|
|
1252
|
+
const currentTreeHash = currentGen?.tree_hash ?? baseGen?.tree_hash ?? "";
|
|
1253
|
+
const needsAccumulation = await Promise.all(
|
|
1254
|
+
patch.files.map(async (f) => {
|
|
1255
|
+
if (!baseGen) return false;
|
|
1256
|
+
const resolved = await this.resolveFilePath(f, baseGen.tree_hash, currentTreeHash);
|
|
1257
|
+
return this.fileTheirsAccumulator.has(resolved);
|
|
1258
|
+
})
|
|
1259
|
+
).then((results) => results.some(Boolean));
|
|
1260
|
+
const resolvedFiles = {};
|
|
1261
|
+
for (const filePath of patch.files) {
|
|
1262
|
+
const resolvedPath = baseGen ? await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash) : filePath;
|
|
1263
|
+
if (resolvedPath !== filePath) {
|
|
1264
|
+
resolvedFiles[filePath] = resolvedPath;
|
|
1265
|
+
}
|
|
1254
1266
|
}
|
|
1255
|
-
|
|
1256
|
-
|
|
1267
|
+
const patchIsRenameAware = /^rename from /m.test(patch.patch_content);
|
|
1268
|
+
const hasGeneratorRename = Object.keys(resolvedFiles).length > 0 && !patchIsRenameAware;
|
|
1269
|
+
if (!needsAccumulation && !hasGeneratorRename) {
|
|
1270
|
+
const snapshots = /* @__PURE__ */ new Map();
|
|
1271
|
+
for (const filePath of patch.files) {
|
|
1272
|
+
const fullPath = (0, import_node_path2.join)(this.outputDir, filePath);
|
|
1273
|
+
snapshots.set(filePath, await (0, import_promises.readFile)(fullPath, "utf-8").catch(() => null));
|
|
1274
|
+
}
|
|
1275
|
+
try {
|
|
1276
|
+
await this.git.execWithInput(["apply", "--3way"], patch.patch_content);
|
|
1277
|
+
const fastPathFileResults = await this.populateAccumulatorForPatch(patch, baseGen, currentTreeHash);
|
|
1278
|
+
return {
|
|
1279
|
+
patch,
|
|
1280
|
+
status: "applied",
|
|
1281
|
+
method: "git-am",
|
|
1282
|
+
...fastPathFileResults.length > 0 && { fileResults: fastPathFileResults },
|
|
1283
|
+
...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
|
|
1284
|
+
};
|
|
1285
|
+
} catch {
|
|
1286
|
+
for (const [filePath, content] of snapshots) {
|
|
1287
|
+
const fullPath = (0, import_node_path2.join)(this.outputDir, filePath);
|
|
1288
|
+
if (content != null) {
|
|
1289
|
+
await (0, import_promises.writeFile)(fullPath, content);
|
|
1290
|
+
} else {
|
|
1291
|
+
await (0, import_promises.unlink)(fullPath).catch(() => {
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1257
1296
|
}
|
|
1258
|
-
return
|
|
1297
|
+
return this.applyWithThreeWayMerge(patch);
|
|
1259
1298
|
}
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
});
|
|
1299
|
+
async applyWithThreeWayMerge(patch) {
|
|
1300
|
+
const fileResults = [];
|
|
1301
|
+
const resolvedFiles = {};
|
|
1302
|
+
const tempDir = await (0, import_promises.mkdtemp)((0, import_node_path2.join)((0, import_node_os.tmpdir)(), "replay-"));
|
|
1303
|
+
const { GitClient: GitClient2 } = await Promise.resolve().then(() => (init_GitClient(), GitClient_exports));
|
|
1304
|
+
const tempGit = new GitClient2(tempDir);
|
|
1305
|
+
await tempGit.exec(["init"]);
|
|
1306
|
+
await tempGit.exec(["config", "user.email", "replay@fern.com"]);
|
|
1307
|
+
await tempGit.exec(["config", "user.name", "Fern Replay"]);
|
|
1308
|
+
try {
|
|
1309
|
+
for (const filePath of patch.files) {
|
|
1310
|
+
if (isBinaryFile(filePath)) {
|
|
1311
|
+
fileResults.push({
|
|
1312
|
+
file: filePath,
|
|
1313
|
+
status: "skipped",
|
|
1314
|
+
reason: "binary-file"
|
|
1315
|
+
});
|
|
1316
|
+
continue;
|
|
1317
|
+
}
|
|
1318
|
+
const result = await this.mergeFile(patch, filePath, tempGit, tempDir);
|
|
1319
|
+
if (result.file !== filePath) {
|
|
1320
|
+
resolvedFiles[filePath] = result.file;
|
|
1321
|
+
}
|
|
1322
|
+
fileResults.push(result);
|
|
1323
|
+
}
|
|
1324
|
+
} finally {
|
|
1325
|
+
await (0, import_promises.rm)(tempDir, { recursive: true }).catch(() => {
|
|
1326
|
+
});
|
|
1327
|
+
}
|
|
1328
|
+
const conflictFiles = fileResults.filter((r) => r.status === "conflict");
|
|
1329
|
+
const hasConflicts = conflictFiles.length > 0;
|
|
1330
|
+
const conflictReason = hasConflicts ? conflictFiles.some((f) => f.conflictReason === "base-generation-mismatch") ? "base-generation-mismatch" : conflictFiles[0]?.conflictReason : void 0;
|
|
1331
|
+
return {
|
|
1332
|
+
patch,
|
|
1333
|
+
status: hasConflicts ? "conflict" : "applied",
|
|
1334
|
+
method: "3way-merge",
|
|
1335
|
+
fileResults,
|
|
1336
|
+
conflictReason,
|
|
1337
|
+
...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
|
|
1338
|
+
};
|
|
1265
1339
|
}
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
// src/ThreeWayMerge.ts
|
|
1272
|
-
var import_node_diff3 = require("node-diff3");
|
|
1273
|
-
function threeWayMerge(base, ours, theirs) {
|
|
1274
|
-
const baseLines = base.split("\n");
|
|
1275
|
-
const oursLines = ours.split("\n");
|
|
1276
|
-
const theirsLines = theirs.split("\n");
|
|
1277
|
-
const regions = (0, import_node_diff3.diff3Merge)(oursLines, baseLines, theirsLines);
|
|
1278
|
-
const outputLines = [];
|
|
1279
|
-
const conflicts = [];
|
|
1280
|
-
let currentLine = 1;
|
|
1281
|
-
for (const region of regions) {
|
|
1282
|
-
if (region.ok) {
|
|
1283
|
-
outputLines.push(...region.ok);
|
|
1284
|
-
currentLine += region.ok.length;
|
|
1285
|
-
} else if (region.conflict) {
|
|
1286
|
-
const resolved = tryResolveConflict(
|
|
1287
|
-
region.conflict.a,
|
|
1288
|
-
// ours (generator)
|
|
1289
|
-
region.conflict.o,
|
|
1290
|
-
// base
|
|
1291
|
-
region.conflict.b
|
|
1292
|
-
// theirs (user)
|
|
1293
|
-
);
|
|
1294
|
-
if (resolved !== null) {
|
|
1295
|
-
outputLines.push(...resolved);
|
|
1296
|
-
currentLine += resolved.length;
|
|
1297
|
-
} else {
|
|
1298
|
-
const startLine = currentLine;
|
|
1299
|
-
outputLines.push("<<<<<<< Generated");
|
|
1300
|
-
outputLines.push(...region.conflict.a);
|
|
1301
|
-
outputLines.push("=======");
|
|
1302
|
-
outputLines.push(...region.conflict.b);
|
|
1303
|
-
outputLines.push(">>>>>>> Your customization");
|
|
1304
|
-
const conflictLines = region.conflict.a.length + region.conflict.b.length + 3;
|
|
1305
|
-
conflicts.push({
|
|
1306
|
-
startLine,
|
|
1307
|
-
endLine: startLine + conflictLines - 1,
|
|
1308
|
-
ours: region.conflict.a,
|
|
1309
|
-
theirs: region.conflict.b
|
|
1310
|
-
});
|
|
1311
|
-
currentLine += conflictLines;
|
|
1340
|
+
async mergeFile(patch, filePath, tempGit, tempDir) {
|
|
1341
|
+
try {
|
|
1342
|
+
const baseGen = await this.resolveBaseGeneration(patch.base_generation);
|
|
1343
|
+
if (!baseGen) {
|
|
1344
|
+
return { file: filePath, status: "skipped", reason: "base-generation-not-found" };
|
|
1312
1345
|
}
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
const mergedCounts = countLines(mergedLines);
|
|
1332
|
-
const dropped = [];
|
|
1333
|
-
const seen = /* @__PURE__ */ new Set();
|
|
1334
|
-
for (const line of baseLines) {
|
|
1335
|
-
if (seen.has(line)) continue;
|
|
1336
|
-
const inBase = baseCounts.get(line) ?? 0;
|
|
1337
|
-
const inTheirs = theirsCounts.get(line) ?? 0;
|
|
1338
|
-
const inMerged = mergedCounts.get(line) ?? 0;
|
|
1339
|
-
if (inBase > 0 && inTheirs > 0 && inMerged < Math.min(inBase, inTheirs)) {
|
|
1340
|
-
dropped.push(line);
|
|
1341
|
-
seen.add(line);
|
|
1342
|
-
}
|
|
1343
|
-
}
|
|
1344
|
-
return dropped;
|
|
1345
|
-
}
|
|
1346
|
-
function countLines(lines) {
|
|
1347
|
-
const counts = /* @__PURE__ */ new Map();
|
|
1348
|
-
for (const line of lines) {
|
|
1349
|
-
counts.set(line, (counts.get(line) ?? 0) + 1);
|
|
1350
|
-
}
|
|
1351
|
-
return counts;
|
|
1352
|
-
}
|
|
1353
|
-
function tryResolveConflict(oursLines, baseLines, theirsLines) {
|
|
1354
|
-
if (baseLines.length === 0) {
|
|
1355
|
-
return null;
|
|
1356
|
-
}
|
|
1357
|
-
const oursPatches = (0, import_node_diff3.diffPatch)(baseLines, oursLines);
|
|
1358
|
-
const theirsPatches = (0, import_node_diff3.diffPatch)(baseLines, theirsLines);
|
|
1359
|
-
if (oursPatches.length === 0) return theirsLines;
|
|
1360
|
-
if (theirsPatches.length === 0) return oursLines;
|
|
1361
|
-
if (patchesOverlap(oursPatches, theirsPatches)) {
|
|
1362
|
-
return null;
|
|
1363
|
-
}
|
|
1364
|
-
return applyBothPatches(baseLines, oursPatches, theirsPatches);
|
|
1365
|
-
}
|
|
1366
|
-
function patchesOverlap(oursPatches, theirsPatches) {
|
|
1367
|
-
for (const op of oursPatches) {
|
|
1368
|
-
const oStart = op.buffer1.offset;
|
|
1369
|
-
const oEnd = oStart + op.buffer1.length;
|
|
1370
|
-
for (const tp of theirsPatches) {
|
|
1371
|
-
const tStart = tp.buffer1.offset;
|
|
1372
|
-
const tEnd = tStart + tp.buffer1.length;
|
|
1373
|
-
if (op.buffer1.length === 0 && tp.buffer1.length === 0 && oStart === tStart) {
|
|
1374
|
-
return true;
|
|
1346
|
+
const lock = this.lockManager.read();
|
|
1347
|
+
const currentGen = lock.generations.find((g) => g.commit_sha === lock.current_generation);
|
|
1348
|
+
const currentTreeHash = currentGen?.tree_hash ?? baseGen.tree_hash;
|
|
1349
|
+
const resolvedPath = await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
|
|
1350
|
+
const metadata = {
|
|
1351
|
+
patchId: patch.id,
|
|
1352
|
+
patchMessage: patch.original_message,
|
|
1353
|
+
baseGeneration: patch.base_generation,
|
|
1354
|
+
currentGeneration: lock.current_generation
|
|
1355
|
+
};
|
|
1356
|
+
let base = await this.git.showFile(baseGen.tree_hash, filePath);
|
|
1357
|
+
let renameSourcePath;
|
|
1358
|
+
if (!base) {
|
|
1359
|
+
const renameSource = this.extractRenameSource(patch.patch_content, filePath);
|
|
1360
|
+
if (renameSource) {
|
|
1361
|
+
base = await this.git.showFile(baseGen.tree_hash, renameSource);
|
|
1362
|
+
renameSourcePath = renameSource;
|
|
1363
|
+
}
|
|
1375
1364
|
}
|
|
1376
|
-
|
|
1377
|
-
|
|
1365
|
+
const oursPath = (0, import_node_path2.join)(this.outputDir, resolvedPath);
|
|
1366
|
+
const ours = await (0, import_promises.readFile)(oursPath, "utf-8").catch(() => null);
|
|
1367
|
+
let ghostReconstructed = false;
|
|
1368
|
+
let theirs = null;
|
|
1369
|
+
if (!base && ours && !renameSourcePath) {
|
|
1370
|
+
const treeReachable = await this.isTreeReachable(baseGen.tree_hash);
|
|
1371
|
+
if (!treeReachable) {
|
|
1372
|
+
const fileDiff = this.extractFileDiff(patch.patch_content, filePath);
|
|
1373
|
+
if (fileDiff) {
|
|
1374
|
+
const { reconstructFromGhostPatch: reconstructFromGhostPatch2 } = await Promise.resolve().then(() => (init_HybridReconstruction(), HybridReconstruction_exports));
|
|
1375
|
+
const result = reconstructFromGhostPatch2(fileDiff, ours);
|
|
1376
|
+
if (result) {
|
|
1377
|
+
base = result.base;
|
|
1378
|
+
theirs = result.theirs;
|
|
1379
|
+
ghostReconstructed = true;
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1378
1383
|
}
|
|
1379
|
-
|
|
1380
|
-
|
|
1384
|
+
const snapshotForFile = patch.theirs_snapshot?.[resolvedPath] ?? patch.theirs_snapshot?.[filePath];
|
|
1385
|
+
const fileIsShared = this.sharedFiles.has(resolvedPath) || this.sharedFiles.has(filePath);
|
|
1386
|
+
const useSnapshotAsPrimary = !fileIsShared && snapshotForFile != null;
|
|
1387
|
+
if (!ghostReconstructed) {
|
|
1388
|
+
if (useSnapshotAsPrimary) {
|
|
1389
|
+
theirs = snapshotForFile;
|
|
1390
|
+
} else {
|
|
1391
|
+
theirs = await this.applyPatchToContent(
|
|
1392
|
+
base,
|
|
1393
|
+
patch.patch_content,
|
|
1394
|
+
filePath,
|
|
1395
|
+
tempGit,
|
|
1396
|
+
tempDir,
|
|
1397
|
+
renameSourcePath
|
|
1398
|
+
);
|
|
1399
|
+
const reconstructionHasMarkers = theirs != null && (theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization"));
|
|
1400
|
+
const baseHadMarkers = base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization"));
|
|
1401
|
+
if (reconstructionHasMarkers && !baseHadMarkers && snapshotForFile != null) {
|
|
1402
|
+
theirs = snapshotForFile;
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1381
1405
|
}
|
|
1382
|
-
|
|
1383
|
-
|
|
1406
|
+
const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
|
|
1407
|
+
if (theirs) {
|
|
1408
|
+
const theirsHasMarkers = theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization");
|
|
1409
|
+
const baseHasMarkers = base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization"));
|
|
1410
|
+
if (theirsHasMarkers && !baseHasMarkers) {
|
|
1411
|
+
if (accumulatorEntry) {
|
|
1412
|
+
theirs = null;
|
|
1413
|
+
} else {
|
|
1414
|
+
return {
|
|
1415
|
+
file: resolvedPath,
|
|
1416
|
+
status: "skipped",
|
|
1417
|
+
reason: "stale-conflict-markers"
|
|
1418
|
+
};
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1384
1421
|
}
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
result.splice(p.offset, p.length, ...p.replacement);
|
|
1406
|
-
}
|
|
1407
|
-
return result;
|
|
1408
|
-
}
|
|
1409
|
-
|
|
1410
|
-
// src/ReplayApplicator.ts
|
|
1411
|
-
var import_promises = require("fs/promises");
|
|
1412
|
-
var import_node_os = require("os");
|
|
1413
|
-
var import_node_path2 = require("path");
|
|
1414
|
-
var import_minimatch = require("minimatch");
|
|
1415
|
-
|
|
1416
|
-
// src/conflict-utils.ts
|
|
1417
|
-
var CONFLICT_OPENER = "<<<<<<< Generated";
|
|
1418
|
-
var CONFLICT_SEPARATOR = "=======";
|
|
1419
|
-
var CONFLICT_CLOSER = ">>>>>>> Your customization";
|
|
1420
|
-
function trimCR(line) {
|
|
1421
|
-
return line.endsWith("\r") ? line.slice(0, -1) : line;
|
|
1422
|
-
}
|
|
1423
|
-
function findConflictRanges(lines) {
|
|
1424
|
-
const ranges = [];
|
|
1425
|
-
let i = 0;
|
|
1426
|
-
while (i < lines.length) {
|
|
1427
|
-
if (trimCR(lines[i]) === CONFLICT_OPENER) {
|
|
1428
|
-
let separatorIdx = -1;
|
|
1429
|
-
let j = i + 1;
|
|
1430
|
-
let found = false;
|
|
1431
|
-
while (j < lines.length) {
|
|
1432
|
-
const trimmed = trimCR(lines[j]);
|
|
1433
|
-
if (trimmed === CONFLICT_OPENER) {
|
|
1434
|
-
break;
|
|
1422
|
+
let useAccumulatorAsMergeBase = false;
|
|
1423
|
+
if (accumulatorEntry && (theirs == null || base == null)) {
|
|
1424
|
+
theirs = await this.applyPatchToContent(
|
|
1425
|
+
accumulatorEntry.content,
|
|
1426
|
+
patch.patch_content,
|
|
1427
|
+
filePath,
|
|
1428
|
+
tempGit,
|
|
1429
|
+
tempDir
|
|
1430
|
+
);
|
|
1431
|
+
if (theirs != null) {
|
|
1432
|
+
useAccumulatorAsMergeBase = true;
|
|
1433
|
+
} else if (await this.isPatchAlreadyApplied(
|
|
1434
|
+
patch.patch_content,
|
|
1435
|
+
filePath,
|
|
1436
|
+
accumulatorEntry.content,
|
|
1437
|
+
tempGit,
|
|
1438
|
+
tempDir
|
|
1439
|
+
)) {
|
|
1440
|
+
theirs = accumulatorEntry.content;
|
|
1441
|
+
useAccumulatorAsMergeBase = true;
|
|
1435
1442
|
}
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
+
}
|
|
1444
|
+
if (theirs) {
|
|
1445
|
+
const theirsHasMarkers = theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization");
|
|
1446
|
+
const accBaseHasMarkers = accumulatorEntry != null && (accumulatorEntry.content.includes("<<<<<<< Generated") || accumulatorEntry.content.includes(">>>>>>> Your customization"));
|
|
1447
|
+
if (theirsHasMarkers && !accBaseHasMarkers && !(base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization")))) {
|
|
1448
|
+
return {
|
|
1449
|
+
file: resolvedPath,
|
|
1450
|
+
status: "skipped",
|
|
1451
|
+
reason: "stale-conflict-markers"
|
|
1452
|
+
};
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
let effective_theirs = theirs;
|
|
1456
|
+
let baseMismatchSkipped = false;
|
|
1457
|
+
if (theirs != null && base != null && !useAccumulatorAsMergeBase) {
|
|
1458
|
+
if (accumulatorEntry && accumulatorEntry.baseGeneration === patch.base_generation) {
|
|
1459
|
+
try {
|
|
1460
|
+
const preMerged = threeWayMerge(base, accumulatorEntry.content, theirs);
|
|
1461
|
+
if (!preMerged.hasConflicts) {
|
|
1462
|
+
effective_theirs = preMerged.content;
|
|
1463
|
+
} else {
|
|
1464
|
+
effective_theirs = theirs;
|
|
1465
|
+
}
|
|
1466
|
+
} catch {
|
|
1467
|
+
effective_theirs = theirs;
|
|
1468
|
+
}
|
|
1469
|
+
} else if (accumulatorEntry) {
|
|
1470
|
+
baseMismatchSkipped = true;
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
if (base == null && ours == null && effective_theirs != null) {
|
|
1474
|
+
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
1475
|
+
content: effective_theirs,
|
|
1476
|
+
baseGeneration: patch.base_generation
|
|
1477
|
+
});
|
|
1478
|
+
const outDir2 = (0, import_node_path2.dirname)(oursPath);
|
|
1479
|
+
await (0, import_promises.mkdir)(outDir2, { recursive: true });
|
|
1480
|
+
await (0, import_promises.writeFile)(oursPath, effective_theirs);
|
|
1481
|
+
return { file: resolvedPath, status: "merged", reason: "new-file" };
|
|
1482
|
+
}
|
|
1483
|
+
if (base == null && ours != null && effective_theirs != null && !useAccumulatorAsMergeBase) {
|
|
1484
|
+
const merged2 = threeWayMerge("", ours, effective_theirs);
|
|
1485
|
+
const outDir2 = (0, import_node_path2.dirname)(oursPath);
|
|
1486
|
+
await (0, import_promises.mkdir)(outDir2, { recursive: true });
|
|
1487
|
+
await (0, import_promises.writeFile)(oursPath, merged2.content);
|
|
1488
|
+
if (merged2.hasConflicts) {
|
|
1489
|
+
return {
|
|
1490
|
+
file: resolvedPath,
|
|
1491
|
+
status: "conflict",
|
|
1492
|
+
conflicts: merged2.conflicts,
|
|
1493
|
+
conflictReason: "new-file-both",
|
|
1494
|
+
conflictMetadata: metadata
|
|
1495
|
+
};
|
|
1443
1496
|
}
|
|
1444
|
-
|
|
1497
|
+
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
1498
|
+
content: merged2.content,
|
|
1499
|
+
baseGeneration: patch.base_generation
|
|
1500
|
+
});
|
|
1501
|
+
return { file: resolvedPath, status: "merged" };
|
|
1445
1502
|
}
|
|
1446
|
-
if (
|
|
1447
|
-
|
|
1448
|
-
|
|
1503
|
+
if (effective_theirs == null) {
|
|
1504
|
+
return {
|
|
1505
|
+
file: resolvedPath,
|
|
1506
|
+
status: "skipped",
|
|
1507
|
+
reason: "missing-content"
|
|
1508
|
+
};
|
|
1449
1509
|
}
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
const lines = content.split(/\r?\n/);
|
|
1457
|
-
const ranges = findConflictRanges(lines);
|
|
1458
|
-
if (ranges.length === 0) {
|
|
1459
|
-
return content;
|
|
1460
|
-
}
|
|
1461
|
-
const result = [];
|
|
1462
|
-
let rangeIdx = 0;
|
|
1463
|
-
for (let i = 0; i < lines.length; i++) {
|
|
1464
|
-
if (rangeIdx < ranges.length) {
|
|
1465
|
-
const range = ranges[rangeIdx];
|
|
1466
|
-
if (i === range.start) {
|
|
1467
|
-
continue;
|
|
1510
|
+
if (base == null && !useAccumulatorAsMergeBase || ours == null) {
|
|
1511
|
+
return {
|
|
1512
|
+
file: resolvedPath,
|
|
1513
|
+
status: "skipped",
|
|
1514
|
+
reason: "missing-content"
|
|
1515
|
+
};
|
|
1468
1516
|
}
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1517
|
+
const mergeBase = useAccumulatorAsMergeBase && accumulatorEntry ? accumulatorEntry.content : base;
|
|
1518
|
+
if (mergeBase == null) {
|
|
1519
|
+
return {
|
|
1520
|
+
file: resolvedPath,
|
|
1521
|
+
status: "skipped",
|
|
1522
|
+
reason: "missing-content"
|
|
1523
|
+
};
|
|
1472
1524
|
}
|
|
1473
|
-
|
|
1474
|
-
|
|
1525
|
+
const merged = threeWayMerge(mergeBase, ours, effective_theirs);
|
|
1526
|
+
const outDir = (0, import_node_path2.dirname)(oursPath);
|
|
1527
|
+
await (0, import_promises.mkdir)(outDir, { recursive: true });
|
|
1528
|
+
await (0, import_promises.writeFile)(oursPath, merged.content);
|
|
1529
|
+
const populateAccumulator = effective_theirs != null && (!merged.hasConflicts || this.currentApplyMode === "resolve");
|
|
1530
|
+
if (populateAccumulator) {
|
|
1531
|
+
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
1532
|
+
content: effective_theirs,
|
|
1533
|
+
baseGeneration: patch.base_generation
|
|
1534
|
+
});
|
|
1475
1535
|
}
|
|
1476
|
-
if (
|
|
1477
|
-
|
|
1536
|
+
if (merged.hasConflicts) {
|
|
1537
|
+
return {
|
|
1538
|
+
file: resolvedPath,
|
|
1539
|
+
status: "conflict",
|
|
1540
|
+
conflicts: merged.conflicts,
|
|
1541
|
+
conflictReason: baseMismatchSkipped ? "base-generation-mismatch" : "same-line-edit",
|
|
1542
|
+
conflictMetadata: metadata
|
|
1543
|
+
};
|
|
1478
1544
|
}
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1545
|
+
return {
|
|
1546
|
+
file: resolvedPath,
|
|
1547
|
+
status: "merged",
|
|
1548
|
+
...merged.droppedContextLines && merged.droppedContextLines.length > 0 ? { droppedContextLines: merged.droppedContextLines } : {}
|
|
1549
|
+
};
|
|
1550
|
+
} catch (error) {
|
|
1551
|
+
return {
|
|
1552
|
+
file: filePath,
|
|
1553
|
+
status: "skipped",
|
|
1554
|
+
reason: `error: ${error instanceof Error ? error.message : String(error)}`
|
|
1555
|
+
};
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
async isTreeReachable(treeHash) {
|
|
1559
|
+
let result = this.treeExistsCache.get(treeHash);
|
|
1560
|
+
if (result === void 0) {
|
|
1561
|
+
result = await this.git.treeExists(treeHash);
|
|
1562
|
+
this.treeExistsCache.set(treeHash, result);
|
|
1563
|
+
}
|
|
1564
|
+
return result;
|
|
1565
|
+
}
|
|
1566
|
+
isExcluded(patch) {
|
|
1567
|
+
const config = this.lockManager.getCustomizationsConfig();
|
|
1568
|
+
if (!config.exclude) return false;
|
|
1569
|
+
return patch.files.some((file) => config.exclude.some((pattern) => (0, import_minimatch.minimatch)(file, pattern)));
|
|
1570
|
+
}
|
|
1571
|
+
async resolveFilePath(filePath, baseTreeHash, currentTreeHash) {
|
|
1572
|
+
const config = this.lockManager.getCustomizationsConfig();
|
|
1573
|
+
if (config.moves) {
|
|
1574
|
+
for (const move of config.moves) {
|
|
1575
|
+
if ((0, import_minimatch.minimatch)(filePath, move.from) || filePath === move.from) {
|
|
1576
|
+
if (filePath === move.from) {
|
|
1577
|
+
return move.to;
|
|
1578
|
+
}
|
|
1579
|
+
const fromBase = move.from.replace(/\*\*.*$/, "");
|
|
1580
|
+
const toBase = move.to.replace(/\*\*.*$/, "");
|
|
1581
|
+
if (filePath.startsWith(fromBase)) {
|
|
1582
|
+
return toBase + filePath.slice(fromBase.length);
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1482
1585
|
}
|
|
1483
1586
|
}
|
|
1484
|
-
|
|
1587
|
+
const cacheKey = `${baseTreeHash}:${currentTreeHash}`;
|
|
1588
|
+
let renames = this.renameCache.get(cacheKey);
|
|
1589
|
+
if (!renames) {
|
|
1590
|
+
renames = await this.git.detectRenames(baseTreeHash, currentTreeHash);
|
|
1591
|
+
this.renameCache.set(cacheKey, renames);
|
|
1592
|
+
}
|
|
1593
|
+
const gitRename = renames.find((r) => r.from === filePath);
|
|
1594
|
+
if (gitRename) {
|
|
1595
|
+
return gitRename.to;
|
|
1596
|
+
}
|
|
1597
|
+
return filePath;
|
|
1485
1598
|
}
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
".so",
|
|
1519
|
-
".dylib",
|
|
1520
|
-
".o",
|
|
1521
|
-
".a",
|
|
1522
|
-
".woff",
|
|
1523
|
-
".woff2",
|
|
1524
|
-
".ttf",
|
|
1525
|
-
".eot",
|
|
1526
|
-
".otf",
|
|
1527
|
-
".mp3",
|
|
1528
|
-
".mp4",
|
|
1529
|
-
".avi",
|
|
1530
|
-
".mov",
|
|
1531
|
-
".wav",
|
|
1532
|
-
".flac",
|
|
1533
|
-
".sqlite",
|
|
1534
|
-
".db",
|
|
1535
|
-
".pyc",
|
|
1536
|
-
".pyo",
|
|
1537
|
-
".DS_Store"
|
|
1538
|
-
]);
|
|
1539
|
-
var ReplayApplicator = class {
|
|
1540
|
-
git;
|
|
1541
|
-
lockManager;
|
|
1542
|
-
outputDir;
|
|
1543
|
-
renameCache = /* @__PURE__ */ new Map();
|
|
1544
|
-
treeExistsCache = /* @__PURE__ */ new Map();
|
|
1545
|
-
fileTheirsAccumulator = /* @__PURE__ */ new Map();
|
|
1546
|
-
/**
|
|
1547
|
-
* Apply mode for the current `applyPatches` invocation. Set at the start
|
|
1548
|
-
* of `applyPatches`, read by `mergeFile` to decide:
|
|
1549
|
-
* - whether to skip the intra-loop marker strip (kept in `applyPatches`)
|
|
1550
|
-
* - whether to populate the accumulator after a conflicted merge
|
|
1551
|
-
* (resolve mode populates so subsequent patches on the same file
|
|
1552
|
-
* can use patch A's THEIRS as a structurally-correct merge base
|
|
1553
|
-
* when their diff was authored against post-A structure)
|
|
1554
|
-
* - whether to retry THEIRS reconstruction against the accumulator
|
|
1555
|
-
* when the BASE-relative reconstruction produced markers from a
|
|
1556
|
-
* cross-patch context mismatch
|
|
1557
|
-
*/
|
|
1558
|
-
currentApplyMode = "replay";
|
|
1559
|
-
constructor(git, lockManager, outputDir) {
|
|
1560
|
-
this.git = git;
|
|
1561
|
-
this.lockManager = lockManager;
|
|
1562
|
-
this.outputDir = outputDir;
|
|
1599
|
+
async applyPatchToContent(base, patchContent, filePath, tempGit, tempDir, sourceFilePath) {
|
|
1600
|
+
if (!base) {
|
|
1601
|
+
return this.extractNewFileFromPatch(patchContent, filePath);
|
|
1602
|
+
}
|
|
1603
|
+
const fileDiff = this.extractFileDiff(patchContent, filePath);
|
|
1604
|
+
if (!fileDiff) return null;
|
|
1605
|
+
try {
|
|
1606
|
+
if (sourceFilePath) {
|
|
1607
|
+
const tempSourcePath = (0, import_node_path2.join)(tempDir, sourceFilePath);
|
|
1608
|
+
await (0, import_promises.mkdir)((0, import_node_path2.dirname)(tempSourcePath), { recursive: true });
|
|
1609
|
+
await (0, import_promises.writeFile)(tempSourcePath, base);
|
|
1610
|
+
await tempGit.exec(["add", sourceFilePath]);
|
|
1611
|
+
await tempGit.exec([
|
|
1612
|
+
"commit",
|
|
1613
|
+
"-m",
|
|
1614
|
+
`base for rename ${sourceFilePath} -> ${filePath}`,
|
|
1615
|
+
"--allow-empty"
|
|
1616
|
+
]);
|
|
1617
|
+
await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
|
|
1618
|
+
const tempTargetPath = (0, import_node_path2.join)(tempDir, filePath);
|
|
1619
|
+
return await (0, import_promises.readFile)(tempTargetPath, "utf-8");
|
|
1620
|
+
}
|
|
1621
|
+
const tempFilePath = (0, import_node_path2.join)(tempDir, filePath);
|
|
1622
|
+
await (0, import_promises.mkdir)((0, import_node_path2.dirname)(tempFilePath), { recursive: true });
|
|
1623
|
+
await (0, import_promises.writeFile)(tempFilePath, base);
|
|
1624
|
+
await tempGit.exec(["add", filePath]);
|
|
1625
|
+
await tempGit.exec(["commit", "-m", `base for ${filePath}`, "--allow-empty"]);
|
|
1626
|
+
await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
|
|
1627
|
+
return await (0, import_promises.readFile)(tempFilePath, "utf-8");
|
|
1628
|
+
} catch {
|
|
1629
|
+
return null;
|
|
1630
|
+
}
|
|
1563
1631
|
}
|
|
1564
1632
|
/**
|
|
1565
|
-
*
|
|
1566
|
-
*
|
|
1567
|
-
*
|
|
1633
|
+
* Detects whether a patch's additions are already present in `content`.
|
|
1634
|
+
* Uses `git apply --reverse --check` — if the reverse patch applies cleanly,
|
|
1635
|
+
* the forward patch is effectively a no-op (its +lines are already there and
|
|
1636
|
+
* its -lines are already gone). Used to distinguish "patch already applied"
|
|
1637
|
+
* from "patch base mismatch" after a forward-apply fails.
|
|
1568
1638
|
*/
|
|
1569
|
-
async
|
|
1570
|
-
const
|
|
1571
|
-
if (
|
|
1639
|
+
async isPatchAlreadyApplied(patchContent, filePath, content, tempGit, tempDir) {
|
|
1640
|
+
const fileDiff = this.extractFileDiff(patchContent, filePath);
|
|
1641
|
+
if (!fileDiff) return false;
|
|
1572
1642
|
try {
|
|
1573
|
-
const
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
};
|
|
1643
|
+
const tempFilePath = (0, import_node_path2.join)(tempDir, filePath);
|
|
1644
|
+
await (0, import_promises.mkdir)((0, import_node_path2.dirname)(tempFilePath), { recursive: true });
|
|
1645
|
+
await (0, import_promises.writeFile)(tempFilePath, content);
|
|
1646
|
+
await tempGit.exec(["add", filePath]);
|
|
1647
|
+
await tempGit.exec(["commit", "-m", `check already-applied ${filePath}`, "--allow-empty"]);
|
|
1648
|
+
await tempGit.execWithInput(["apply", "--reverse", "--check", "--allow-empty"], fileDiff);
|
|
1649
|
+
return true;
|
|
1581
1650
|
} catch {
|
|
1582
|
-
return
|
|
1651
|
+
return false;
|
|
1583
1652
|
}
|
|
1584
1653
|
}
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1654
|
+
extractFileDiff(patchContent, filePath) {
|
|
1655
|
+
const lines = patchContent.split("\n");
|
|
1656
|
+
const diffLines = [];
|
|
1657
|
+
let inTargetFile = false;
|
|
1658
|
+
for (const line of lines) {
|
|
1659
|
+
if (line.startsWith("diff --git")) {
|
|
1660
|
+
if (inTargetFile) {
|
|
1661
|
+
break;
|
|
1662
|
+
}
|
|
1663
|
+
if (isDiffLineForFile(line, filePath)) {
|
|
1664
|
+
inTargetFile = true;
|
|
1665
|
+
diffLines.push(line);
|
|
1666
|
+
}
|
|
1667
|
+
continue;
|
|
1668
|
+
}
|
|
1669
|
+
if (inTargetFile) {
|
|
1670
|
+
diffLines.push(line);
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
return diffLines.length > 0 ? diffLines.join("\n") + "\n" : null;
|
|
1588
1674
|
}
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1675
|
+
extractRenameSource(patchContent, targetFilePath) {
|
|
1676
|
+
const lines = patchContent.split("\n");
|
|
1677
|
+
let inTargetFile = false;
|
|
1678
|
+
for (const line of lines) {
|
|
1679
|
+
if (line.startsWith("diff --git")) {
|
|
1680
|
+
if (inTargetFile) break;
|
|
1681
|
+
inTargetFile = isDiffLineForFile(line, targetFilePath);
|
|
1682
|
+
continue;
|
|
1683
|
+
}
|
|
1684
|
+
if (!inTargetFile) continue;
|
|
1685
|
+
if (line.startsWith("@@")) break;
|
|
1686
|
+
if (line.startsWith("rename from ")) {
|
|
1687
|
+
return line.slice("rename from ".length);
|
|
1688
|
+
}
|
|
1600
1689
|
}
|
|
1601
|
-
return
|
|
1690
|
+
return null;
|
|
1602
1691
|
}
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
* A's markers and B's clean writes when their regions don't overlap;
|
|
1617
|
-
* when they do overlap, the customer gets nested markers and
|
|
1618
|
-
* resolves manually. Either way they aren't silently dropped.
|
|
1619
|
-
*/
|
|
1620
|
-
async applyPatches(patches, opts) {
|
|
1621
|
-
const applyMode = opts?.applyMode ?? "replay";
|
|
1622
|
-
this.currentApplyMode = applyMode;
|
|
1623
|
-
this.resetAccumulator();
|
|
1624
|
-
const results = [];
|
|
1625
|
-
for (let i = 0; i < patches.length; i++) {
|
|
1626
|
-
const patch = patches[i];
|
|
1627
|
-
if (this.isExcluded(patch)) {
|
|
1628
|
-
results.push({
|
|
1629
|
-
patch,
|
|
1630
|
-
status: "skipped",
|
|
1631
|
-
method: "git-am"
|
|
1632
|
-
});
|
|
1692
|
+
extractNewFileFromPatch(patchContent, filePath) {
|
|
1693
|
+
const lines = patchContent.split("\n");
|
|
1694
|
+
const addedLines = [];
|
|
1695
|
+
let inTargetFile = false;
|
|
1696
|
+
let inHunk = false;
|
|
1697
|
+
let isNewFile = false;
|
|
1698
|
+
let noTrailingNewline = false;
|
|
1699
|
+
for (const line of lines) {
|
|
1700
|
+
if (line.startsWith("diff --git")) {
|
|
1701
|
+
if (inTargetFile) break;
|
|
1702
|
+
inTargetFile = isDiffLineForFile(line, filePath);
|
|
1703
|
+
inHunk = false;
|
|
1704
|
+
isNewFile = false;
|
|
1633
1705
|
continue;
|
|
1634
1706
|
}
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
for (let j = i + 1; j < patches.length; j++) {
|
|
1640
|
-
for (const f of patches[j].files) {
|
|
1641
|
-
laterFiles.add(f);
|
|
1642
|
-
}
|
|
1643
|
-
}
|
|
1644
|
-
const resolvedToOriginal = /* @__PURE__ */ new Map();
|
|
1645
|
-
if (result.resolvedFiles) {
|
|
1646
|
-
for (const [orig, resolved] of Object.entries(result.resolvedFiles)) {
|
|
1647
|
-
resolvedToOriginal.set(resolved, orig);
|
|
1648
|
-
}
|
|
1649
|
-
}
|
|
1650
|
-
for (const fileResult of result.fileResults) {
|
|
1651
|
-
if (fileResult.status !== "conflict") continue;
|
|
1652
|
-
const originalPath = resolvedToOriginal.get(fileResult.file) ?? fileResult.file;
|
|
1653
|
-
if (laterFiles.has(fileResult.file) || laterFiles.has(originalPath)) {
|
|
1654
|
-
const filePath = (0, import_node_path2.join)(this.outputDir, fileResult.file);
|
|
1655
|
-
try {
|
|
1656
|
-
const content = await (0, import_promises.readFile)(filePath, "utf-8");
|
|
1657
|
-
const stripped = stripConflictMarkers(content);
|
|
1658
|
-
await (0, import_promises.writeFile)(filePath, stripped);
|
|
1659
|
-
} catch {
|
|
1660
|
-
}
|
|
1661
|
-
}
|
|
1707
|
+
if (!inTargetFile) continue;
|
|
1708
|
+
if (!inHunk) {
|
|
1709
|
+
if (line === "--- /dev/null" || line.startsWith("new file mode")) {
|
|
1710
|
+
isNewFile = true;
|
|
1662
1711
|
}
|
|
1663
1712
|
}
|
|
1713
|
+
if (line.startsWith("@@")) {
|
|
1714
|
+
if (!isNewFile) return null;
|
|
1715
|
+
inHunk = true;
|
|
1716
|
+
continue;
|
|
1717
|
+
}
|
|
1718
|
+
if (!inHunk) continue;
|
|
1719
|
+
if (line === "\") {
|
|
1720
|
+
noTrailingNewline = true;
|
|
1721
|
+
continue;
|
|
1722
|
+
}
|
|
1723
|
+
if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
1724
|
+
addedLines.push(line.slice(1));
|
|
1725
|
+
}
|
|
1664
1726
|
}
|
|
1665
|
-
return
|
|
1727
|
+
if (addedLines.length === 0) return null;
|
|
1728
|
+
return addedLines.join("\n") + (noTrailingNewline ? "" : "\n");
|
|
1666
1729
|
}
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1730
|
+
};
|
|
1731
|
+
function isBinaryFile(filePath) {
|
|
1732
|
+
const ext = (0, import_node_path2.extname)(filePath).toLowerCase();
|
|
1733
|
+
return BINARY_EXTENSIONS.has(ext);
|
|
1734
|
+
}
|
|
1735
|
+
function computeDroppedContextLinesForFile(base, theirs, finalContent) {
|
|
1736
|
+
const baseLines = base.split("\n");
|
|
1737
|
+
const theirsLines = theirs.split("\n");
|
|
1738
|
+
const finalLines = finalContent.split("\n");
|
|
1739
|
+
const baseCounts = /* @__PURE__ */ new Map();
|
|
1740
|
+
const theirsCounts = /* @__PURE__ */ new Map();
|
|
1741
|
+
const finalCounts = /* @__PURE__ */ new Map();
|
|
1742
|
+
for (const l of baseLines) baseCounts.set(l, (baseCounts.get(l) ?? 0) + 1);
|
|
1743
|
+
for (const l of theirsLines) theirsCounts.set(l, (theirsCounts.get(l) ?? 0) + 1);
|
|
1744
|
+
for (const l of finalLines) finalCounts.set(l, (finalCounts.get(l) ?? 0) + 1);
|
|
1745
|
+
const dropped = [];
|
|
1746
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1747
|
+
for (const line of baseLines) {
|
|
1748
|
+
if (seen.has(line)) continue;
|
|
1749
|
+
const inBase = baseCounts.get(line) ?? 0;
|
|
1750
|
+
const inTheirs = theirsCounts.get(line) ?? 0;
|
|
1751
|
+
const inFinal = finalCounts.get(line) ?? 0;
|
|
1752
|
+
if (inBase > 0 && inTheirs > 0 && inFinal < Math.min(inBase, inTheirs)) {
|
|
1753
|
+
dropped.push(line);
|
|
1754
|
+
seen.add(line);
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
return dropped;
|
|
1758
|
+
}
|
|
1759
|
+
function isDiffLineForFile(diffLine, filePath) {
|
|
1760
|
+
const match = diffLine.match(/^diff --git a\/.+ b\/(.+)$/);
|
|
1761
|
+
return match !== null && match[1] === filePath;
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
// src/ReplayDetector.ts
|
|
1765
|
+
var INFRASTRUCTURE_FILES = /* @__PURE__ */ new Set([".fernignore"]);
|
|
1766
|
+
function parsePatchFileHeaders(patchContent) {
|
|
1767
|
+
const results = [];
|
|
1768
|
+
let currentPath = null;
|
|
1769
|
+
let currentIsCreate = false;
|
|
1770
|
+
let currentIsDelete = false;
|
|
1771
|
+
const flush = () => {
|
|
1772
|
+
if (currentPath !== null) {
|
|
1773
|
+
results.push({ path: currentPath, isCreate: currentIsCreate, isDelete: currentIsDelete });
|
|
1774
|
+
}
|
|
1775
|
+
};
|
|
1776
|
+
for (const line of patchContent.split("\n")) {
|
|
1777
|
+
if (line.startsWith("diff --git ")) {
|
|
1778
|
+
flush();
|
|
1779
|
+
currentPath = null;
|
|
1780
|
+
currentIsCreate = false;
|
|
1781
|
+
currentIsDelete = false;
|
|
1782
|
+
const match = line.match(/^diff --git a\/(.+?) b\/(.+?)$/);
|
|
1783
|
+
if (match) {
|
|
1784
|
+
currentPath = match[2] ?? null;
|
|
1785
|
+
}
|
|
1786
|
+
} else if (currentPath !== null) {
|
|
1787
|
+
if (line.startsWith("new file mode ")) {
|
|
1788
|
+
currentIsCreate = true;
|
|
1789
|
+
} else if (line.startsWith("deleted file mode ")) {
|
|
1790
|
+
currentIsDelete = true;
|
|
1708
1791
|
}
|
|
1709
|
-
} finally {
|
|
1710
|
-
await (0, import_promises.rm)(tempDir, { recursive: true }).catch(() => {
|
|
1711
|
-
});
|
|
1712
1792
|
}
|
|
1713
|
-
return fileResults;
|
|
1714
1793
|
}
|
|
1715
|
-
|
|
1716
|
-
|
|
1794
|
+
flush();
|
|
1795
|
+
return results;
|
|
1796
|
+
}
|
|
1797
|
+
var ReplayDetector = class {
|
|
1798
|
+
git;
|
|
1799
|
+
lockManager;
|
|
1800
|
+
sdkOutputDir;
|
|
1801
|
+
warnings = [];
|
|
1802
|
+
constructor(git, lockManager, sdkOutputDir) {
|
|
1803
|
+
this.git = git;
|
|
1804
|
+
this.lockManager = lockManager;
|
|
1805
|
+
this.sdkOutputDir = sdkOutputDir;
|
|
1806
|
+
}
|
|
1807
|
+
async detectNewPatches() {
|
|
1717
1808
|
const lock = this.lockManager.read();
|
|
1718
|
-
const
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
patch.files.map(async (f) => {
|
|
1722
|
-
if (!baseGen) return false;
|
|
1723
|
-
const resolved = await this.resolveFilePath(f, baseGen.tree_hash, currentTreeHash);
|
|
1724
|
-
return this.fileTheirsAccumulator.has(resolved);
|
|
1725
|
-
})
|
|
1726
|
-
).then((results) => results.some(Boolean));
|
|
1727
|
-
const resolvedFiles = {};
|
|
1728
|
-
for (const filePath of patch.files) {
|
|
1729
|
-
const resolvedPath = baseGen ? await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash) : filePath;
|
|
1730
|
-
if (resolvedPath !== filePath) {
|
|
1731
|
-
resolvedFiles[filePath] = resolvedPath;
|
|
1732
|
-
}
|
|
1809
|
+
const lastGen = this.getLastGeneration(lock);
|
|
1810
|
+
if (!lastGen) {
|
|
1811
|
+
return { patches: [], revertedPatchIds: [] };
|
|
1733
1812
|
}
|
|
1734
|
-
const
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
const fastPathFileResults = await this.populateAccumulatorForPatch(patch, baseGen, currentTreeHash);
|
|
1745
|
-
return {
|
|
1746
|
-
patch,
|
|
1747
|
-
status: "applied",
|
|
1748
|
-
method: "git-am",
|
|
1749
|
-
...fastPathFileResults.length > 0 && { fileResults: fastPathFileResults },
|
|
1750
|
-
...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
|
|
1751
|
-
};
|
|
1752
|
-
} catch {
|
|
1753
|
-
for (const [filePath, content] of snapshots) {
|
|
1754
|
-
const fullPath = (0, import_node_path2.join)(this.outputDir, filePath);
|
|
1755
|
-
if (content != null) {
|
|
1756
|
-
await (0, import_promises.writeFile)(fullPath, content);
|
|
1757
|
-
} else {
|
|
1758
|
-
await (0, import_promises.unlink)(fullPath).catch(() => {
|
|
1759
|
-
});
|
|
1760
|
-
}
|
|
1761
|
-
}
|
|
1762
|
-
}
|
|
1813
|
+
const exists = await this.git.commitExists(lastGen.commit_sha);
|
|
1814
|
+
if (!exists) {
|
|
1815
|
+
this.warnings.push(
|
|
1816
|
+
`Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to alternate detection.`
|
|
1817
|
+
);
|
|
1818
|
+
return this.detectPatchesViaTreeDiff(
|
|
1819
|
+
lastGen,
|
|
1820
|
+
/* commitKnownMissing */
|
|
1821
|
+
true
|
|
1822
|
+
);
|
|
1763
1823
|
}
|
|
1764
|
-
|
|
1824
|
+
const isAncestor = await this.git.isAncestor(lastGen.commit_sha, "HEAD");
|
|
1825
|
+
if (!isAncestor) {
|
|
1826
|
+
return this.detectPatchesViaMergeBase(lastGen, lock);
|
|
1827
|
+
}
|
|
1828
|
+
return this.detectPatchesInRange(lastGen.commit_sha, lastGen, lock);
|
|
1765
1829
|
}
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1830
|
+
/**
|
|
1831
|
+
* FER-10201 — Non-linear-history detection via `merge-base(prevGen, HEAD)`.
|
|
1832
|
+
*
|
|
1833
|
+
* After a squash-merge of a Fern-bot PR, `lastGen.commit_sha` (the previous
|
|
1834
|
+
* `[fern-generated]`) is orphaned but still in git's object database. The
|
|
1835
|
+
* merge-base is the commit on main from which the bot's branch was created —
|
|
1836
|
+
* a reachable ancestor of HEAD. Walking that range and classifying each
|
|
1837
|
+
* commit via `isGenerationCommit` mirrors the linear path and eliminates
|
|
1838
|
+
* the bug class where pipeline-driven changes (autoversion, replay,
|
|
1839
|
+
* generation) get bundled as customer customizations.
|
|
1840
|
+
*
|
|
1841
|
+
* Falls through to the legacy composite path when no common ancestor exists
|
|
1842
|
+
* (truly disjoint history — orphan branches with no shared root).
|
|
1843
|
+
*/
|
|
1844
|
+
async detectPatchesViaMergeBase(lastGen, lock) {
|
|
1845
|
+
let mergeBase = "";
|
|
1775
1846
|
try {
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
fileResults.push({
|
|
1779
|
-
file: filePath,
|
|
1780
|
-
status: "skipped",
|
|
1781
|
-
reason: "binary-file"
|
|
1782
|
-
});
|
|
1783
|
-
continue;
|
|
1784
|
-
}
|
|
1785
|
-
const result = await this.mergeFile(patch, filePath, tempGit, tempDir);
|
|
1786
|
-
if (result.file !== filePath) {
|
|
1787
|
-
resolvedFiles[filePath] = result.file;
|
|
1788
|
-
}
|
|
1789
|
-
fileResults.push(result);
|
|
1790
|
-
}
|
|
1791
|
-
} finally {
|
|
1792
|
-
await (0, import_promises.rm)(tempDir, { recursive: true }).catch(() => {
|
|
1793
|
-
});
|
|
1847
|
+
mergeBase = (await this.git.exec(["merge-base", lastGen.commit_sha, "HEAD"])).trim();
|
|
1848
|
+
} catch {
|
|
1794
1849
|
}
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1850
|
+
if (!mergeBase) {
|
|
1851
|
+
this.warnings.push(
|
|
1852
|
+
`No common ancestor between previous generation ${lastGen.commit_sha.slice(0, 7)} and HEAD. Falling back to composite tree-diff detection.`
|
|
1853
|
+
);
|
|
1854
|
+
return this.detectPatchesViaTreeDiff(
|
|
1855
|
+
lastGen,
|
|
1856
|
+
/* commitKnownMissing */
|
|
1857
|
+
false
|
|
1858
|
+
);
|
|
1859
|
+
}
|
|
1860
|
+
return this.detectPatchesInRange(mergeBase, lastGen, lock);
|
|
1806
1861
|
}
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
let ghostReconstructed = false;
|
|
1835
|
-
let theirs = null;
|
|
1836
|
-
if (!base && ours && !renameSourcePath) {
|
|
1837
|
-
const treeReachable = await this.isTreeReachable(baseGen.tree_hash);
|
|
1838
|
-
if (!treeReachable) {
|
|
1839
|
-
const fileDiff = this.extractFileDiff(patch.patch_content, filePath);
|
|
1840
|
-
if (fileDiff) {
|
|
1841
|
-
const { reconstructFromGhostPatch: reconstructFromGhostPatch2 } = await Promise.resolve().then(() => (init_HybridReconstruction(), HybridReconstruction_exports));
|
|
1842
|
-
const result = reconstructFromGhostPatch2(fileDiff, ours);
|
|
1843
|
-
if (result) {
|
|
1844
|
-
base = result.base;
|
|
1845
|
-
theirs = result.theirs;
|
|
1846
|
-
ghostReconstructed = true;
|
|
1847
|
-
}
|
|
1848
|
-
}
|
|
1849
|
-
}
|
|
1862
|
+
/**
|
|
1863
|
+
* FER-10201 — Walk `${rangeStart}..HEAD`, classify each commit, emit one
|
|
1864
|
+
* `StoredPatch` per customer commit. Body shared by the linear path
|
|
1865
|
+
* (rangeStart = lastGen.commit_sha) and the merge-base path.
|
|
1866
|
+
*
|
|
1867
|
+
* Patch `base_generation` is always `lastGen.commit_sha` — the
|
|
1868
|
+
* lockfile-tracked reference the applicator uses to fetch base file
|
|
1869
|
+
* content, regardless of where the walk actually started.
|
|
1870
|
+
*/
|
|
1871
|
+
async detectPatchesInRange(rangeStart, lastGen, lock) {
|
|
1872
|
+
const log = await this.git.exec([
|
|
1873
|
+
"log",
|
|
1874
|
+
"--first-parent",
|
|
1875
|
+
"--format=%H%x00%an%x00%ae%x00%s",
|
|
1876
|
+
`${rangeStart}..HEAD`,
|
|
1877
|
+
"--",
|
|
1878
|
+
this.sdkOutputDir
|
|
1879
|
+
]);
|
|
1880
|
+
if (!log.trim()) {
|
|
1881
|
+
return { patches: [], revertedPatchIds: [] };
|
|
1882
|
+
}
|
|
1883
|
+
const commits = this.parseGitLog(log);
|
|
1884
|
+
const newPatches = [];
|
|
1885
|
+
const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
|
|
1886
|
+
for (const commit of commits) {
|
|
1887
|
+
if (isGenerationCommit(commit)) {
|
|
1888
|
+
continue;
|
|
1850
1889
|
}
|
|
1851
|
-
if (
|
|
1852
|
-
|
|
1853
|
-
base,
|
|
1854
|
-
patch.patch_content,
|
|
1855
|
-
filePath,
|
|
1856
|
-
tempGit,
|
|
1857
|
-
tempDir,
|
|
1858
|
-
renameSourcePath
|
|
1859
|
-
);
|
|
1890
|
+
if (lock.patches.find((p) => p.original_commit === commit.sha)) {
|
|
1891
|
+
continue;
|
|
1860
1892
|
}
|
|
1861
|
-
const
|
|
1862
|
-
if (
|
|
1863
|
-
const
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
status: "skipped",
|
|
1872
|
-
reason: "stale-conflict-markers"
|
|
1873
|
-
};
|
|
1874
|
-
}
|
|
1893
|
+
const parents = await this.git.getCommitParents(commit.sha);
|
|
1894
|
+
if (parents.length > 1) {
|
|
1895
|
+
const compositePatch = await this.createMergeCompositePatch({
|
|
1896
|
+
mergeCommit: commit,
|
|
1897
|
+
firstParent: parents[0],
|
|
1898
|
+
lastGen,
|
|
1899
|
+
lock
|
|
1900
|
+
});
|
|
1901
|
+
if (compositePatch) {
|
|
1902
|
+
newPatches.push(compositePatch);
|
|
1875
1903
|
}
|
|
1904
|
+
continue;
|
|
1876
1905
|
}
|
|
1877
|
-
let
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
tempGit,
|
|
1884
|
-
tempDir
|
|
1906
|
+
let patchContent;
|
|
1907
|
+
try {
|
|
1908
|
+
patchContent = await this.git.formatPatch(commit.sha);
|
|
1909
|
+
} catch {
|
|
1910
|
+
this.warnings.push(
|
|
1911
|
+
`Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
|
|
1885
1912
|
);
|
|
1886
|
-
|
|
1887
|
-
useAccumulatorAsMergeBase = true;
|
|
1888
|
-
} else if (await this.isPatchAlreadyApplied(
|
|
1889
|
-
patch.patch_content,
|
|
1890
|
-
filePath,
|
|
1891
|
-
accumulatorEntry.content,
|
|
1892
|
-
tempGit,
|
|
1893
|
-
tempDir
|
|
1894
|
-
)) {
|
|
1895
|
-
theirs = accumulatorEntry.content;
|
|
1896
|
-
useAccumulatorAsMergeBase = true;
|
|
1897
|
-
}
|
|
1913
|
+
continue;
|
|
1898
1914
|
}
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
if (theirsHasMarkers && !accBaseHasMarkers && !(base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization")))) {
|
|
1903
|
-
return {
|
|
1904
|
-
file: resolvedPath,
|
|
1905
|
-
status: "skipped",
|
|
1906
|
-
reason: "stale-conflict-markers"
|
|
1907
|
-
};
|
|
1908
|
-
}
|
|
1915
|
+
let contentHash = this.computeContentHash(patchContent);
|
|
1916
|
+
if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
|
|
1917
|
+
continue;
|
|
1909
1918
|
}
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1919
|
+
const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
|
|
1920
|
+
const allFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
|
|
1921
|
+
const sensitiveFiles = allFiles.filter((f) => isSensitiveFile(f));
|
|
1922
|
+
const files = allFiles.filter((f) => !isSensitiveFile(f));
|
|
1923
|
+
if (sensitiveFiles.length > 0) {
|
|
1924
|
+
this.warnings.push(
|
|
1925
|
+
`Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
|
|
1926
|
+
);
|
|
1927
|
+
if (files.length > 0) {
|
|
1928
|
+
const parentSha = parents[0];
|
|
1929
|
+
if (parentSha) {
|
|
1930
|
+
try {
|
|
1931
|
+
patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
|
|
1932
|
+
} catch {
|
|
1933
|
+
continue;
|
|
1920
1934
|
}
|
|
1921
|
-
|
|
1922
|
-
|
|
1935
|
+
if (!patchContent.trim()) continue;
|
|
1936
|
+
contentHash = this.computeContentHash(patchContent);
|
|
1923
1937
|
}
|
|
1924
|
-
} else if (accumulatorEntry) {
|
|
1925
|
-
baseMismatchSkipped = true;
|
|
1926
1938
|
}
|
|
1927
1939
|
}
|
|
1928
|
-
if (
|
|
1929
|
-
|
|
1930
|
-
content: effective_theirs,
|
|
1931
|
-
baseGeneration: patch.base_generation
|
|
1932
|
-
});
|
|
1933
|
-
const outDir2 = (0, import_node_path2.dirname)(oursPath);
|
|
1934
|
-
await (0, import_promises.mkdir)(outDir2, { recursive: true });
|
|
1935
|
-
await (0, import_promises.writeFile)(oursPath, effective_theirs);
|
|
1936
|
-
return { file: resolvedPath, status: "merged", reason: "new-file" };
|
|
1940
|
+
if (files.length === 0) {
|
|
1941
|
+
continue;
|
|
1937
1942
|
}
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
await (
|
|
1942
|
-
|
|
1943
|
-
if (merged2.hasConflicts) {
|
|
1944
|
-
return {
|
|
1945
|
-
file: resolvedPath,
|
|
1946
|
-
status: "conflict",
|
|
1947
|
-
conflicts: merged2.conflicts,
|
|
1948
|
-
conflictReason: "new-file-both",
|
|
1949
|
-
conflictMetadata: metadata
|
|
1950
|
-
};
|
|
1951
|
-
}
|
|
1952
|
-
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
1953
|
-
content: merged2.content,
|
|
1954
|
-
baseGeneration: patch.base_generation
|
|
1955
|
-
});
|
|
1956
|
-
return { file: resolvedPath, status: "merged" };
|
|
1943
|
+
const theirsSnapshot = {};
|
|
1944
|
+
for (const file of files) {
|
|
1945
|
+
if (isBinaryFile(file)) continue;
|
|
1946
|
+
const content = await this.git.showFile(commit.sha, file).catch(() => null);
|
|
1947
|
+
if (content != null) theirsSnapshot[file] = content;
|
|
1957
1948
|
}
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
}
|
|
1949
|
+
newPatches.push({
|
|
1950
|
+
id: `patch-${commit.sha.slice(0, 8)}`,
|
|
1951
|
+
content_hash: contentHash,
|
|
1952
|
+
original_commit: commit.sha,
|
|
1953
|
+
original_message: commit.message,
|
|
1954
|
+
original_author: `${commit.authorName} <${commit.authorEmail}>`,
|
|
1955
|
+
base_generation: lastGen.commit_sha,
|
|
1956
|
+
files,
|
|
1957
|
+
patch_content: patchContent,
|
|
1958
|
+
...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
|
|
1959
|
+
...this.isCreationOnlyPatch(patchContent) ? { user_owned: true } : {}
|
|
1960
|
+
});
|
|
1961
|
+
}
|
|
1962
|
+
const survivingPatches = await this.collapseNetZeroFiles(newPatches);
|
|
1963
|
+
survivingPatches.reverse();
|
|
1964
|
+
const revertedPatchIdSet = /* @__PURE__ */ new Set();
|
|
1965
|
+
const revertIndicesToRemove = /* @__PURE__ */ new Set();
|
|
1966
|
+
for (let i = 0; i < survivingPatches.length; i++) {
|
|
1967
|
+
const patch = survivingPatches[i];
|
|
1968
|
+
if (!isRevertCommit(patch.original_message)) continue;
|
|
1969
|
+
let body = "";
|
|
1970
|
+
try {
|
|
1971
|
+
body = await this.git.getCommitBody(patch.original_commit);
|
|
1972
|
+
} catch {
|
|
1964
1973
|
}
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1974
|
+
const revertedSha = parseRevertedSha(body);
|
|
1975
|
+
const revertedMessage = parseRevertedMessage(patch.original_message);
|
|
1976
|
+
let matchedExisting = false;
|
|
1977
|
+
if (revertedSha) {
|
|
1978
|
+
const existing = lock.patches.find((p) => p.original_commit === revertedSha);
|
|
1979
|
+
if (existing) {
|
|
1980
|
+
revertedPatchIdSet.add(existing.id);
|
|
1981
|
+
revertIndicesToRemove.add(i);
|
|
1982
|
+
matchedExisting = true;
|
|
1983
|
+
}
|
|
1971
1984
|
}
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
}
|
|
1985
|
+
if (!matchedExisting && revertedMessage) {
|
|
1986
|
+
const existing = lock.patches.find((p) => p.original_message === revertedMessage);
|
|
1987
|
+
if (existing) {
|
|
1988
|
+
revertedPatchIdSet.add(existing.id);
|
|
1989
|
+
revertIndicesToRemove.add(i);
|
|
1990
|
+
matchedExisting = true;
|
|
1991
|
+
}
|
|
1979
1992
|
}
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1993
|
+
if (matchedExisting) continue;
|
|
1994
|
+
let matchedNew = false;
|
|
1995
|
+
if (revertedSha) {
|
|
1996
|
+
const idx = survivingPatches.findIndex(
|
|
1997
|
+
(p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_commit === revertedSha
|
|
1998
|
+
);
|
|
1999
|
+
if (idx !== -1) {
|
|
2000
|
+
revertIndicesToRemove.add(i);
|
|
2001
|
+
revertIndicesToRemove.add(idx);
|
|
2002
|
+
matchedNew = true;
|
|
2003
|
+
}
|
|
1990
2004
|
}
|
|
1991
|
-
if (
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
}
|
|
2005
|
+
if (!matchedNew && revertedMessage) {
|
|
2006
|
+
const idx = survivingPatches.findIndex(
|
|
2007
|
+
(p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_message === revertedMessage
|
|
2008
|
+
);
|
|
2009
|
+
if (idx !== -1) {
|
|
2010
|
+
revertIndicesToRemove.add(i);
|
|
2011
|
+
revertIndicesToRemove.add(idx);
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
if (!matchedExisting && !matchedNew) {
|
|
2015
|
+
revertIndicesToRemove.add(i);
|
|
1999
2016
|
}
|
|
2000
|
-
return {
|
|
2001
|
-
file: resolvedPath,
|
|
2002
|
-
status: "merged",
|
|
2003
|
-
...merged.droppedContextLines && merged.droppedContextLines.length > 0 ? { droppedContextLines: merged.droppedContextLines } : {}
|
|
2004
|
-
};
|
|
2005
|
-
} catch (error) {
|
|
2006
|
-
return {
|
|
2007
|
-
file: filePath,
|
|
2008
|
-
status: "skipped",
|
|
2009
|
-
reason: `error: ${error instanceof Error ? error.message : String(error)}`
|
|
2010
|
-
};
|
|
2011
|
-
}
|
|
2012
|
-
}
|
|
2013
|
-
async isTreeReachable(treeHash) {
|
|
2014
|
-
let result = this.treeExistsCache.get(treeHash);
|
|
2015
|
-
if (result === void 0) {
|
|
2016
|
-
result = await this.git.treeExists(treeHash);
|
|
2017
|
-
this.treeExistsCache.set(treeHash, result);
|
|
2018
2017
|
}
|
|
2019
|
-
|
|
2018
|
+
const filteredPatches = survivingPatches.filter((_, i) => !revertIndicesToRemove.has(i));
|
|
2019
|
+
return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
|
|
2020
2020
|
}
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2021
|
+
/**
|
|
2022
|
+
* Compute content hash for deduplication.
|
|
2023
|
+
*
|
|
2024
|
+
* Produces a format-agnostic hash so both `git format-patch` output
|
|
2025
|
+
* (email-wrapped) and plain `git diff` output hash to the same value
|
|
2026
|
+
* when the underlying diff hunks are identical.
|
|
2027
|
+
*
|
|
2028
|
+
* Normalization:
|
|
2029
|
+
* 1. Strip email wrapper: everything before the first `diff --git` line
|
|
2030
|
+
* (From:, Subject:, Date:, diffstat, blank separators).
|
|
2031
|
+
* 2. Strip `index` lines (blob SHA pairs change across rebases).
|
|
2032
|
+
* 3. Strip trailing git version marker (`-- \n<version>`).
|
|
2033
|
+
*
|
|
2034
|
+
* This ensures content hashes match across the no-patches and normal-
|
|
2035
|
+
* regeneration flows, which store formatPatch and git-diff formats
|
|
2036
|
+
* respectively (FER-9850, D5-D9).
|
|
2037
|
+
*/
|
|
2038
|
+
computeContentHash(patchContent) {
|
|
2039
|
+
const lines = patchContent.split("\n");
|
|
2040
|
+
const diffStart = lines.findIndex((l) => l.startsWith("diff --git "));
|
|
2041
|
+
const relevantLines = diffStart > 0 ? lines.slice(diffStart) : lines;
|
|
2042
|
+
const normalized = relevantLines.filter((line) => !line.startsWith("index ")).join("\n").replace(/\n-- \n[\d]+\.[\d]+[^\n]*\s*$/, "");
|
|
2043
|
+
return `sha256:${(0, import_node_crypto.createHash)("sha256").update(normalized).digest("hex")}`;
|
|
2025
2044
|
}
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2045
|
+
/**
|
|
2046
|
+
* FER-9805 — Drop patches whose files were transiently created and then
|
|
2047
|
+
* deleted within the current linear detection window, and are absent from
|
|
2048
|
+
* HEAD at the end of the window.
|
|
2049
|
+
*
|
|
2050
|
+
* Rationale: on a merge commit, createMergeCompositePatch already diffs
|
|
2051
|
+
* firstParent..mergeCommit so net-zero files never enter the composite. On
|
|
2052
|
+
* linear history each commit becomes its own patch, so a file that was
|
|
2053
|
+
* briefly added and later removed survives as a create+delete pair whose
|
|
2054
|
+
* cumulative diff is empty. We drop such patches before they reach the
|
|
2055
|
+
* lockfile.
|
|
2056
|
+
*
|
|
2057
|
+
* A file is "transient" when:
|
|
2058
|
+
* 1. some patch in the window sets `new file mode` for it (a creation), AND
|
|
2059
|
+
* 2. some patch in the window sets `deleted file mode` for it (a deletion), AND
|
|
2060
|
+
* 3. it is absent from HEAD's tree.
|
|
2061
|
+
*
|
|
2062
|
+
* The HEAD guard (#3) prevents false positives when a file is deleted
|
|
2063
|
+
* and then recreated with different content — the cumulative diff of
|
|
2064
|
+
* such a pair is non-empty and must be preserved.
|
|
2065
|
+
*
|
|
2066
|
+
* This only drops patches whose `files` are a subset of the transient
|
|
2067
|
+
* set. A partial-transient patch (one that touches a transient file
|
|
2068
|
+
* alongside a persistent one) is left unmodified; surgical stripping of
|
|
2069
|
+
* single files from a multi-file patch would require a follow-up that
|
|
2070
|
+
* regenerates the patch content via `git format-patch -- <files>`. No
|
|
2071
|
+
* current failing test exercises this, and leaving the patch intact
|
|
2072
|
+
* preserves today's behaviour. TODO(FER-9805): revisit if a future
|
|
2073
|
+
* adversarial test demands per-file stripping.
|
|
2074
|
+
*/
|
|
2075
|
+
async collapseNetZeroFiles(patches) {
|
|
2076
|
+
if (patches.length === 0) return patches;
|
|
2077
|
+
const stateByFile = /* @__PURE__ */ new Map();
|
|
2078
|
+
for (const patch of patches) {
|
|
2079
|
+
for (const header of parsePatchFileHeaders(patch.patch_content)) {
|
|
2080
|
+
if (!header.isCreate && !header.isDelete) continue;
|
|
2081
|
+
const prior = stateByFile.get(header.path) ?? { created: false, deleted: false };
|
|
2082
|
+
if (header.isCreate) prior.created = true;
|
|
2083
|
+
if (header.isDelete) prior.deleted = true;
|
|
2084
|
+
stateByFile.set(header.path, prior);
|
|
2040
2085
|
}
|
|
2041
2086
|
}
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2087
|
+
let anyCandidate = false;
|
|
2088
|
+
for (const state of stateByFile.values()) {
|
|
2089
|
+
if (state.created && state.deleted) {
|
|
2090
|
+
anyCandidate = true;
|
|
2091
|
+
break;
|
|
2092
|
+
}
|
|
2047
2093
|
}
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2094
|
+
if (!anyCandidate) return patches;
|
|
2095
|
+
const headFiles = await this.listHeadFiles();
|
|
2096
|
+
const transient = /* @__PURE__ */ new Set();
|
|
2097
|
+
for (const [file, state] of stateByFile) {
|
|
2098
|
+
if (state.created && state.deleted && !headFiles.has(file)) {
|
|
2099
|
+
transient.add(file);
|
|
2100
|
+
}
|
|
2051
2101
|
}
|
|
2052
|
-
return
|
|
2102
|
+
if (transient.size === 0) return patches;
|
|
2103
|
+
return patches.filter((patch) => !patch.files.every((f) => transient.has(f)));
|
|
2053
2104
|
}
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2105
|
+
/**
|
|
2106
|
+
* List every tracked path at HEAD (one `git ls-tree -r` invocation).
|
|
2107
|
+
* Returns an empty Set if HEAD is unborn or the invocation fails — the
|
|
2108
|
+
* caller then treats every candidate as "not in HEAD" and may collapse
|
|
2109
|
+
* more aggressively. On typical SDK repos this is a single sub-10ms call.
|
|
2110
|
+
*/
|
|
2111
|
+
async listHeadFiles() {
|
|
2060
2112
|
try {
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
await (0, import_promises.mkdir)((0, import_node_path2.dirname)(tempSourcePath), { recursive: true });
|
|
2064
|
-
await (0, import_promises.writeFile)(tempSourcePath, base);
|
|
2065
|
-
await tempGit.exec(["add", sourceFilePath]);
|
|
2066
|
-
await tempGit.exec([
|
|
2067
|
-
"commit",
|
|
2068
|
-
"-m",
|
|
2069
|
-
`base for rename ${sourceFilePath} -> ${filePath}`,
|
|
2070
|
-
"--allow-empty"
|
|
2071
|
-
]);
|
|
2072
|
-
await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
|
|
2073
|
-
const tempTargetPath = (0, import_node_path2.join)(tempDir, filePath);
|
|
2074
|
-
return await (0, import_promises.readFile)(tempTargetPath, "utf-8");
|
|
2075
|
-
}
|
|
2076
|
-
const tempFilePath = (0, import_node_path2.join)(tempDir, filePath);
|
|
2077
|
-
await (0, import_promises.mkdir)((0, import_node_path2.dirname)(tempFilePath), { recursive: true });
|
|
2078
|
-
await (0, import_promises.writeFile)(tempFilePath, base);
|
|
2079
|
-
await tempGit.exec(["add", filePath]);
|
|
2080
|
-
await tempGit.exec(["commit", "-m", `base for ${filePath}`, "--allow-empty"]);
|
|
2081
|
-
await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
|
|
2082
|
-
return await (0, import_promises.readFile)(tempFilePath, "utf-8");
|
|
2113
|
+
const out = await this.git.exec(["ls-tree", "-r", "--name-only", "HEAD"]);
|
|
2114
|
+
return new Set(out.split("\n").map((l) => l.trim()).filter(Boolean));
|
|
2083
2115
|
} catch {
|
|
2116
|
+
return /* @__PURE__ */ new Set();
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
/**
|
|
2120
|
+
* Create a single composite patch for a merge commit by diffing the merge
|
|
2121
|
+
* commit against its first parent. This captures the net change of the
|
|
2122
|
+
* entire merged branch as one patch, eliminating accumulator chaining and
|
|
2123
|
+
* zero-net-change ghost conflicts.
|
|
2124
|
+
*/
|
|
2125
|
+
async createMergeCompositePatch(input) {
|
|
2126
|
+
const { mergeCommit, firstParent, lastGen, lock } = input;
|
|
2127
|
+
const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
|
|
2128
|
+
const filesOutput = await this.git.exec([
|
|
2129
|
+
"diff",
|
|
2130
|
+
"--name-only",
|
|
2131
|
+
firstParent,
|
|
2132
|
+
mergeCommit.sha
|
|
2133
|
+
]);
|
|
2134
|
+
const allMergeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
|
|
2135
|
+
const sensitiveMergeFiles = allMergeFiles.filter((f) => isSensitiveFile(f));
|
|
2136
|
+
const files = allMergeFiles.filter((f) => !isSensitiveFile(f));
|
|
2137
|
+
if (sensitiveMergeFiles.length > 0) {
|
|
2138
|
+
this.warnings.push(
|
|
2139
|
+
`Sensitive file(s) excluded from merge patch for commit ${mergeCommit.sha.slice(0, 7)}: ${sensitiveMergeFiles.join(", ")}. These files will not be tracked by replay.`
|
|
2140
|
+
);
|
|
2141
|
+
}
|
|
2142
|
+
if (files.length === 0) return null;
|
|
2143
|
+
const diff = await this.git.exec([
|
|
2144
|
+
"diff",
|
|
2145
|
+
firstParent,
|
|
2146
|
+
mergeCommit.sha,
|
|
2147
|
+
"--",
|
|
2148
|
+
...files
|
|
2149
|
+
]);
|
|
2150
|
+
if (!diff.trim()) return null;
|
|
2151
|
+
const contentHash = this.computeContentHash(diff);
|
|
2152
|
+
if (lock.patches.some((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
|
|
2084
2153
|
return null;
|
|
2085
2154
|
}
|
|
2155
|
+
const theirsSnapshot = {};
|
|
2156
|
+
for (const file of files) {
|
|
2157
|
+
if (isBinaryFile(file)) continue;
|
|
2158
|
+
const content = await this.git.showFile(mergeCommit.sha, file).catch(() => null);
|
|
2159
|
+
if (content != null) theirsSnapshot[file] = content;
|
|
2160
|
+
}
|
|
2161
|
+
return {
|
|
2162
|
+
id: `patch-merge-${mergeCommit.sha.slice(0, 8)}`,
|
|
2163
|
+
content_hash: contentHash,
|
|
2164
|
+
original_commit: mergeCommit.sha,
|
|
2165
|
+
original_message: mergeCommit.message,
|
|
2166
|
+
original_author: `${mergeCommit.authorName} <${mergeCommit.authorEmail}>`,
|
|
2167
|
+
base_generation: lastGen.commit_sha,
|
|
2168
|
+
files,
|
|
2169
|
+
patch_content: diff,
|
|
2170
|
+
...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
|
|
2171
|
+
...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
|
|
2172
|
+
};
|
|
2086
2173
|
}
|
|
2087
2174
|
/**
|
|
2088
|
-
*
|
|
2089
|
-
*
|
|
2090
|
-
*
|
|
2091
|
-
* its -lines are already gone). Used to distinguish "patch already applied"
|
|
2092
|
-
* from "patch base mismatch" after a forward-apply fails.
|
|
2175
|
+
* Detect patches via tree diff for non-linear history. Returns a composite patch.
|
|
2176
|
+
* Revert reconciliation is skipped here because tree-diff produces a single composite
|
|
2177
|
+
* patch from the aggregate diff — individual revert commits are not distinguishable.
|
|
2093
2178
|
*/
|
|
2094
|
-
async
|
|
2095
|
-
const
|
|
2096
|
-
if (!
|
|
2097
|
-
|
|
2098
|
-
const tempFilePath = (0, import_node_path2.join)(tempDir, filePath);
|
|
2099
|
-
await (0, import_promises.mkdir)((0, import_node_path2.dirname)(tempFilePath), { recursive: true });
|
|
2100
|
-
await (0, import_promises.writeFile)(tempFilePath, content);
|
|
2101
|
-
await tempGit.exec(["add", filePath]);
|
|
2102
|
-
await tempGit.exec(["commit", "-m", `check already-applied ${filePath}`, "--allow-empty"]);
|
|
2103
|
-
await tempGit.execWithInput(["apply", "--reverse", "--check", "--allow-empty"], fileDiff);
|
|
2104
|
-
return true;
|
|
2105
|
-
} catch {
|
|
2106
|
-
return false;
|
|
2179
|
+
async detectPatchesViaTreeDiff(lastGen, commitKnownMissing) {
|
|
2180
|
+
const diffBase = await this.resolveDiffBase(lastGen, commitKnownMissing);
|
|
2181
|
+
if (!diffBase) {
|
|
2182
|
+
return this.detectPatchesViaCommitScan();
|
|
2107
2183
|
}
|
|
2184
|
+
const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
|
|
2185
|
+
const allTreeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
|
|
2186
|
+
const sensitiveTreeFiles = allTreeFiles.filter((f) => isSensitiveFile(f));
|
|
2187
|
+
const files = allTreeFiles.filter((f) => !isSensitiveFile(f));
|
|
2188
|
+
if (sensitiveTreeFiles.length > 0) {
|
|
2189
|
+
this.warnings.push(
|
|
2190
|
+
`Sensitive file(s) excluded from composite patch: ${sensitiveTreeFiles.join(", ")}. These files will not be tracked by replay.`
|
|
2191
|
+
);
|
|
2192
|
+
}
|
|
2193
|
+
if (files.length === 0) return { patches: [], revertedPatchIds: [] };
|
|
2194
|
+
const diff = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
|
|
2195
|
+
if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
|
|
2196
|
+
const contentHash = this.computeContentHash(diff);
|
|
2197
|
+
const lock = this.lockManager.read();
|
|
2198
|
+
if (lock.patches.some((p) => p.content_hash === contentHash) || (lock.forgotten_hashes ?? []).includes(contentHash)) {
|
|
2199
|
+
return { patches: [], revertedPatchIds: [] };
|
|
2200
|
+
}
|
|
2201
|
+
const headSha = (await this.git.exec(["rev-parse", "HEAD"])).trim();
|
|
2202
|
+
const theirsSnapshot = {};
|
|
2203
|
+
for (const file of files) {
|
|
2204
|
+
if (isBinaryFile(file)) continue;
|
|
2205
|
+
const content = await this.git.showFile("HEAD", file).catch(() => null);
|
|
2206
|
+
if (content != null) theirsSnapshot[file] = content;
|
|
2207
|
+
}
|
|
2208
|
+
const compositePatch = {
|
|
2209
|
+
id: `patch-composite-${headSha.slice(0, 8)}`,
|
|
2210
|
+
content_hash: contentHash,
|
|
2211
|
+
original_commit: headSha,
|
|
2212
|
+
original_message: "Customer customizations (composite)",
|
|
2213
|
+
original_author: "composite",
|
|
2214
|
+
// Use diffBase when commit is unreachable — the applicator needs a reachable
|
|
2215
|
+
// reference to find base file content. diffBase may be the tree_hash.
|
|
2216
|
+
base_generation: commitKnownMissing ? diffBase : lastGen.commit_sha,
|
|
2217
|
+
files,
|
|
2218
|
+
patch_content: diff,
|
|
2219
|
+
...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
|
|
2220
|
+
...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
|
|
2221
|
+
};
|
|
2222
|
+
return { patches: [compositePatch], revertedPatchIds: [] };
|
|
2108
2223
|
}
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2224
|
+
/**
|
|
2225
|
+
* Last-resort detection when both generation commit and tree are unreachable.
|
|
2226
|
+
* Scans all commits from HEAD, filters against known lockfile patches, and
|
|
2227
|
+
* skips creation-only commits (squashed history after force push).
|
|
2228
|
+
*
|
|
2229
|
+
* Detected patches use the commit's parent as base_generation so the applicator
|
|
2230
|
+
* can find base file content from the parent's tree (which IS reachable).
|
|
2231
|
+
*/
|
|
2232
|
+
async detectPatchesViaCommitScan() {
|
|
2233
|
+
const lock = this.lockManager.read();
|
|
2234
|
+
const log = await this.git.exec([
|
|
2235
|
+
"log",
|
|
2236
|
+
"--max-count=200",
|
|
2237
|
+
"--format=%H%x00%an%x00%ae%x00%s",
|
|
2238
|
+
"HEAD",
|
|
2239
|
+
"--",
|
|
2240
|
+
this.sdkOutputDir
|
|
2241
|
+
]);
|
|
2242
|
+
if (!log.trim()) {
|
|
2243
|
+
return { patches: [], revertedPatchIds: [] };
|
|
2244
|
+
}
|
|
2245
|
+
const commits = this.parseGitLog(log);
|
|
2246
|
+
const newPatches = [];
|
|
2247
|
+
const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
|
|
2248
|
+
const existingHashes = new Set(lock.patches.map((p) => p.content_hash));
|
|
2249
|
+
const existingCommits = new Set(lock.patches.map((p) => p.original_commit));
|
|
2250
|
+
for (const commit of commits) {
|
|
2251
|
+
if (isGenerationCommit(commit)) {
|
|
2122
2252
|
continue;
|
|
2123
2253
|
}
|
|
2124
|
-
|
|
2125
|
-
|
|
2254
|
+
const parents = await this.git.getCommitParents(commit.sha);
|
|
2255
|
+
if (parents.length > 1) {
|
|
2256
|
+
continue;
|
|
2126
2257
|
}
|
|
2127
|
-
|
|
2128
|
-
return diffLines.length > 0 ? diffLines.join("\n") + "\n" : null;
|
|
2129
|
-
}
|
|
2130
|
-
extractRenameSource(patchContent, targetFilePath) {
|
|
2131
|
-
const lines = patchContent.split("\n");
|
|
2132
|
-
let inTargetFile = false;
|
|
2133
|
-
for (const line of lines) {
|
|
2134
|
-
if (line.startsWith("diff --git")) {
|
|
2135
|
-
if (inTargetFile) break;
|
|
2136
|
-
inTargetFile = isDiffLineForFile(line, targetFilePath);
|
|
2258
|
+
if (existingCommits.has(commit.sha)) {
|
|
2137
2259
|
continue;
|
|
2138
2260
|
}
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2261
|
+
let patchContent;
|
|
2262
|
+
try {
|
|
2263
|
+
patchContent = await this.git.formatPatch(commit.sha);
|
|
2264
|
+
} catch {
|
|
2265
|
+
continue;
|
|
2143
2266
|
}
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
}
|
|
2147
|
-
extractNewFileFromPatch(patchContent, filePath) {
|
|
2148
|
-
const lines = patchContent.split("\n");
|
|
2149
|
-
const addedLines = [];
|
|
2150
|
-
let inTargetFile = false;
|
|
2151
|
-
let inHunk = false;
|
|
2152
|
-
let isNewFile = false;
|
|
2153
|
-
let noTrailingNewline = false;
|
|
2154
|
-
for (const line of lines) {
|
|
2155
|
-
if (line.startsWith("diff --git")) {
|
|
2156
|
-
if (inTargetFile) break;
|
|
2157
|
-
inTargetFile = isDiffLineForFile(line, filePath);
|
|
2158
|
-
inHunk = false;
|
|
2159
|
-
isNewFile = false;
|
|
2267
|
+
let contentHash = this.computeContentHash(patchContent);
|
|
2268
|
+
if (existingHashes.has(contentHash) || forgottenHashes.has(contentHash)) {
|
|
2160
2269
|
continue;
|
|
2161
2270
|
}
|
|
2162
|
-
if (
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2271
|
+
if (this.isCreationOnlyPatch(patchContent)) {
|
|
2272
|
+
continue;
|
|
2273
|
+
}
|
|
2274
|
+
const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
|
|
2275
|
+
const allScanFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f));
|
|
2276
|
+
const sensitiveScanFiles = allScanFiles.filter((f) => isSensitiveFile(f));
|
|
2277
|
+
const files = allScanFiles.filter((f) => !isSensitiveFile(f));
|
|
2278
|
+
if (sensitiveScanFiles.length > 0) {
|
|
2279
|
+
this.warnings.push(
|
|
2280
|
+
`Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveScanFiles.join(", ")}. These files will not be tracked by replay.`
|
|
2281
|
+
);
|
|
2282
|
+
if (files.length > 0) {
|
|
2283
|
+
if (parents.length === 0) {
|
|
2284
|
+
continue;
|
|
2285
|
+
}
|
|
2286
|
+
try {
|
|
2287
|
+
patchContent = await this.git.exec(["diff", parents[0], commit.sha, "--", ...files]);
|
|
2288
|
+
} catch {
|
|
2289
|
+
continue;
|
|
2290
|
+
}
|
|
2291
|
+
if (!patchContent.trim()) continue;
|
|
2292
|
+
contentHash = this.computeContentHash(patchContent);
|
|
2166
2293
|
}
|
|
2167
2294
|
}
|
|
2168
|
-
if (
|
|
2169
|
-
if (!isNewFile) return null;
|
|
2170
|
-
inHunk = true;
|
|
2295
|
+
if (files.length === 0) {
|
|
2171
2296
|
continue;
|
|
2172
2297
|
}
|
|
2173
|
-
if (
|
|
2174
|
-
if (line === "\") {
|
|
2175
|
-
noTrailingNewline = true;
|
|
2298
|
+
if (parents.length === 0) {
|
|
2176
2299
|
continue;
|
|
2177
2300
|
}
|
|
2178
|
-
|
|
2179
|
-
|
|
2301
|
+
const parentSha = parents[0];
|
|
2302
|
+
const theirsSnapshot = {};
|
|
2303
|
+
for (const file of files) {
|
|
2304
|
+
if (isBinaryFile(file)) continue;
|
|
2305
|
+
const content = await this.git.showFile(commit.sha, file).catch(() => null);
|
|
2306
|
+
if (content != null) theirsSnapshot[file] = content;
|
|
2180
2307
|
}
|
|
2308
|
+
newPatches.push({
|
|
2309
|
+
id: `patch-${commit.sha.slice(0, 8)}`,
|
|
2310
|
+
content_hash: contentHash,
|
|
2311
|
+
original_commit: commit.sha,
|
|
2312
|
+
original_message: commit.message,
|
|
2313
|
+
original_author: `${commit.authorName} <${commit.authorEmail}>`,
|
|
2314
|
+
base_generation: parentSha,
|
|
2315
|
+
files,
|
|
2316
|
+
patch_content: patchContent,
|
|
2317
|
+
...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {}
|
|
2318
|
+
});
|
|
2181
2319
|
}
|
|
2182
|
-
|
|
2183
|
-
return
|
|
2320
|
+
newPatches.reverse();
|
|
2321
|
+
return { patches: newPatches, revertedPatchIds: [] };
|
|
2184
2322
|
}
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
const baseCounts = /* @__PURE__ */ new Map();
|
|
2195
|
-
const theirsCounts = /* @__PURE__ */ new Map();
|
|
2196
|
-
const finalCounts = /* @__PURE__ */ new Map();
|
|
2197
|
-
for (const l of baseLines) baseCounts.set(l, (baseCounts.get(l) ?? 0) + 1);
|
|
2198
|
-
for (const l of theirsLines) theirsCounts.set(l, (theirsCounts.get(l) ?? 0) + 1);
|
|
2199
|
-
for (const l of finalLines) finalCounts.set(l, (finalCounts.get(l) ?? 0) + 1);
|
|
2200
|
-
const dropped = [];
|
|
2201
|
-
const seen = /* @__PURE__ */ new Set();
|
|
2202
|
-
for (const line of baseLines) {
|
|
2203
|
-
if (seen.has(line)) continue;
|
|
2204
|
-
const inBase = baseCounts.get(line) ?? 0;
|
|
2205
|
-
const inTheirs = theirsCounts.get(line) ?? 0;
|
|
2206
|
-
const inFinal = finalCounts.get(line) ?? 0;
|
|
2207
|
-
if (inBase > 0 && inTheirs > 0 && inFinal < Math.min(inBase, inTheirs)) {
|
|
2208
|
-
dropped.push(line);
|
|
2209
|
-
seen.add(line);
|
|
2323
|
+
/**
|
|
2324
|
+
* Check if a format-patch consists entirely of new-file creations.
|
|
2325
|
+
* Used to identify squashed commits after force push, which create all files
|
|
2326
|
+
* from scratch (--- /dev/null) rather than modifying existing files.
|
|
2327
|
+
*/
|
|
2328
|
+
isCreationOnlyPatch(patchContent) {
|
|
2329
|
+
const diffOldHeaders = patchContent.split("\n").filter((l) => l.startsWith("--- "));
|
|
2330
|
+
if (diffOldHeaders.length === 0) {
|
|
2331
|
+
return false;
|
|
2210
2332
|
}
|
|
2333
|
+
return diffOldHeaders.every((l) => l === "--- /dev/null");
|
|
2211
2334
|
}
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2335
|
+
/**
|
|
2336
|
+
* Resolve the best available diff base for a generation record.
|
|
2337
|
+
* Prefers commit_sha, falls back to tree_hash for unreachable commits.
|
|
2338
|
+
* When commitKnownMissing is true, skips the redundant commitExists check.
|
|
2339
|
+
*/
|
|
2340
|
+
async resolveDiffBase(gen, commitKnownMissing) {
|
|
2341
|
+
if (!commitKnownMissing && await this.git.commitExists(gen.commit_sha)) {
|
|
2342
|
+
return gen.commit_sha;
|
|
2343
|
+
}
|
|
2344
|
+
if (await this.git.treeExists(gen.tree_hash)) {
|
|
2345
|
+
return gen.tree_hash;
|
|
2346
|
+
}
|
|
2347
|
+
return null;
|
|
2348
|
+
}
|
|
2349
|
+
parseGitLog(log) {
|
|
2350
|
+
return log.trim().split("\n").map((line) => {
|
|
2351
|
+
const [sha, authorName, authorEmail, message] = line.split("\0");
|
|
2352
|
+
return { sha, authorName, authorEmail, message };
|
|
2353
|
+
});
|
|
2354
|
+
}
|
|
2355
|
+
getLastGeneration(lock) {
|
|
2356
|
+
return lock.generations.find((g) => g.commit_sha === lock.current_generation);
|
|
2357
|
+
}
|
|
2358
|
+
};
|
|
2218
2359
|
|
|
2219
2360
|
// src/ReplayCommitter.ts
|
|
2220
2361
|
var ReplayCommitter = class {
|
|
@@ -2321,7 +2462,8 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
|
|
|
2321
2462
|
|
|
2322
2463
|
// src/ReplayService.ts
|
|
2323
2464
|
var import_node_fs2 = require("fs");
|
|
2324
|
-
var
|
|
2465
|
+
var import_promises4 = require("fs/promises");
|
|
2466
|
+
var import_node_path5 = require("path");
|
|
2325
2467
|
var import_minimatch2 = require("minimatch");
|
|
2326
2468
|
init_GitClient();
|
|
2327
2469
|
|
|
@@ -2380,13 +2522,13 @@ async function computePerPatchDiff(patch, getCurrentGenContent, getWorkingTreeCo
|
|
|
2380
2522
|
const hunksRaw = parseHunks(fileDiff).map(normalizeHunkBody);
|
|
2381
2523
|
const hunks = hunksRaw.filter((h) => {
|
|
2382
2524
|
if (h.lines.length === 0) return false;
|
|
2383
|
-
let ctx = 0,
|
|
2525
|
+
let ctx = 0, rm4 = 0, add = 0;
|
|
2384
2526
|
for (const ln of h.lines) {
|
|
2385
2527
|
if (ln.type === "context") ctx++;
|
|
2386
|
-
else if (ln.type === "remove")
|
|
2528
|
+
else if (ln.type === "remove") rm4++;
|
|
2387
2529
|
else if (ln.type === "add") add++;
|
|
2388
2530
|
}
|
|
2389
|
-
return ctx +
|
|
2531
|
+
return ctx + rm4 === h.oldCount && ctx + add === h.newCount;
|
|
2390
2532
|
});
|
|
2391
2533
|
if (hunks.length === 0) {
|
|
2392
2534
|
if (hunksRaw.length > 0) unlocatableFiles.push(file);
|
|
@@ -3096,19 +3238,14 @@ var ReplayService = class {
|
|
|
3096
3238
|
existingPatches = this.lockManager.getPatches();
|
|
3097
3239
|
const seenHashes = /* @__PURE__ */ new Map();
|
|
3098
3240
|
for (const p of existingPatches) {
|
|
3099
|
-
const
|
|
3100
|
-
if (
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
} else {
|
|
3106
|
-
this.warnings.push(
|
|
3107
|
-
`Preserving patch ${p.id} (commit ${(p.original_commit || "<missing>").slice(0, 7)}): shares content_hash with a prior patch ${provenanceMissing ? "(provenance missing on at least one side)" : "from a different source commit"}. Both retained to avoid silent customization loss (FER-9983).`
|
|
3108
|
-
);
|
|
3109
|
-
}
|
|
3241
|
+
const priorPatchId = seenHashes.get(p.content_hash);
|
|
3242
|
+
if (priorPatchId !== void 0) {
|
|
3243
|
+
this.lockManager.removePatch(p.id);
|
|
3244
|
+
this.warnings.push(
|
|
3245
|
+
`Consolidated patch ${p.id} (commit ${(p.original_commit || "<missing>").slice(0, 7)}) into prior patch ${priorPatchId}: byte-identical patch_content. Per-commit attribution preserved in git log; lockfile collapsed to avoid duplicate-apply corruption on next regen.`
|
|
3246
|
+
);
|
|
3110
3247
|
} else {
|
|
3111
|
-
seenHashes.set(p.content_hash, p.
|
|
3248
|
+
seenHashes.set(p.content_hash, p.id);
|
|
3112
3249
|
}
|
|
3113
3250
|
}
|
|
3114
3251
|
existingPatches = this.lockManager.getPatches();
|
|
@@ -3289,17 +3426,27 @@ var ReplayService = class {
|
|
|
3289
3426
|
if (allUserOwned && patch.files.length > 0) {
|
|
3290
3427
|
const baseChanged = patch.base_generation !== currentGenSha;
|
|
3291
3428
|
const userDiff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
|
|
3429
|
+
let userDiffSnapshot = null;
|
|
3292
3430
|
if (userDiff != null && userDiff.trim()) {
|
|
3293
3431
|
const newHash = this.detector.computeContentHash(userDiff);
|
|
3294
3432
|
patch.patch_content = userDiff;
|
|
3295
3433
|
patch.content_hash = newHash;
|
|
3434
|
+
const snap = await this.readSnapshotFromDisk(patch.files);
|
|
3435
|
+
if (Object.keys(snap).length > 0) {
|
|
3436
|
+
userDiffSnapshot = snap;
|
|
3437
|
+
patch.theirs_snapshot = snap;
|
|
3438
|
+
}
|
|
3296
3439
|
}
|
|
3297
3440
|
patch.base_generation = currentGenSha;
|
|
3298
3441
|
try {
|
|
3299
3442
|
this.lockManager.updatePatch(patch.id, {
|
|
3300
3443
|
base_generation: currentGenSha,
|
|
3301
3444
|
...!patch.user_owned ? { user_owned: true } : {},
|
|
3302
|
-
...userDiff != null && userDiff.trim() ? {
|
|
3445
|
+
...userDiff != null && userDiff.trim() ? {
|
|
3446
|
+
patch_content: patch.patch_content,
|
|
3447
|
+
content_hash: patch.content_hash,
|
|
3448
|
+
...userDiffSnapshot != null && Object.keys(userDiffSnapshot).length > 0 ? { theirs_snapshot: userDiffSnapshot } : {}
|
|
3449
|
+
} : {}
|
|
3303
3450
|
});
|
|
3304
3451
|
} catch {
|
|
3305
3452
|
}
|
|
@@ -3347,30 +3494,34 @@ var ReplayService = class {
|
|
|
3347
3494
|
continue;
|
|
3348
3495
|
}
|
|
3349
3496
|
const newContentHash = this.detector.computeContentHash(diff);
|
|
3350
|
-
const
|
|
3351
|
-
if (
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3497
|
+
const priorPatchId = seenContentHashes.get(newContentHash);
|
|
3498
|
+
if (priorPatchId !== void 0) {
|
|
3499
|
+
this.lockManager.removePatch(patch.id);
|
|
3500
|
+
absorbedPatchIds.add(patch.id);
|
|
3501
|
+
absorbed++;
|
|
3502
|
+
if (priorPatchId !== patch.id) {
|
|
3503
|
+
this.warnings.push(
|
|
3504
|
+
`Consolidated patch ${patch.id} (commit ${(patch.original_commit || "<missing>").slice(0, 7)}) into prior patch ${priorPatchId}: byte-identical patch_content after rebase. Per-commit attribution preserved in git log.`
|
|
3505
|
+
);
|
|
3359
3506
|
}
|
|
3360
|
-
|
|
3361
|
-
`Preserving patch ${patch.id} (commit ${(patch.original_commit || "<missing>").slice(0, 7)}): shares content_hash with a prior patch ${provenanceMissing ? "(provenance missing on at least one side)" : "from a different source commit"}. Both retained to avoid silent customization loss (FER-9983).`
|
|
3362
|
-
);
|
|
3507
|
+
continue;
|
|
3363
3508
|
} else {
|
|
3364
|
-
seenContentHashes.set(newContentHash, patch.
|
|
3509
|
+
seenContentHashes.set(newContentHash, patch.id);
|
|
3365
3510
|
}
|
|
3366
3511
|
patch.base_generation = currentGenSha;
|
|
3367
3512
|
patch.patch_content = diff;
|
|
3368
3513
|
patch.content_hash = newContentHash;
|
|
3514
|
+
const snapshot = await this.readSnapshotFromDisk(patch.files);
|
|
3515
|
+
const snapshotIsNonEmpty = Object.keys(snapshot).length > 0;
|
|
3516
|
+
if (snapshotIsNonEmpty) {
|
|
3517
|
+
patch.theirs_snapshot = snapshot;
|
|
3518
|
+
}
|
|
3369
3519
|
try {
|
|
3370
3520
|
this.lockManager.updatePatch(patch.id, {
|
|
3371
3521
|
base_generation: currentGenSha,
|
|
3372
3522
|
patch_content: diff,
|
|
3373
|
-
content_hash: newContentHash
|
|
3523
|
+
content_hash: newContentHash,
|
|
3524
|
+
...snapshotIsNonEmpty ? { theirs_snapshot: snapshot } : {}
|
|
3374
3525
|
});
|
|
3375
3526
|
} catch {
|
|
3376
3527
|
}
|
|
@@ -3380,6 +3531,112 @@ var ReplayService = class {
|
|
|
3380
3531
|
}
|
|
3381
3532
|
return { absorbed, repointed, contentRebased, keptAsUserOwned, absorbedPatchIds };
|
|
3382
3533
|
}
|
|
3534
|
+
/**
|
|
3535
|
+
* Read THEIRS snapshot (post-customer-edit content) for a list of files
|
|
3536
|
+
* from the working tree on disk. Used by `rebasePatches` after a clean
|
|
3537
|
+
* apply — disk = correctly-merged customer state at the new generation.
|
|
3538
|
+
* Skips binary files (matches `mergeFile`/`populateAccumulatorForPatch`
|
|
3539
|
+
* policy — binary content as `utf-8` is lossy and snapshots of it would
|
|
3540
|
+
* never be used during apply anyway).
|
|
3541
|
+
*/
|
|
3542
|
+
async readSnapshotFromDisk(files) {
|
|
3543
|
+
const snapshot = {};
|
|
3544
|
+
for (const file of files) {
|
|
3545
|
+
if (isBinaryFile(file)) continue;
|
|
3546
|
+
try {
|
|
3547
|
+
const content = await (0, import_promises4.readFile)((0, import_node_path5.join)(this.outputDir, file), "utf-8");
|
|
3548
|
+
snapshot[file] = content;
|
|
3549
|
+
} catch {
|
|
3550
|
+
}
|
|
3551
|
+
}
|
|
3552
|
+
return snapshot;
|
|
3553
|
+
}
|
|
3554
|
+
/**
|
|
3555
|
+
* Read THEIRS snapshot from a git tree-ish (commit, branch, or tree).
|
|
3556
|
+
* Used when the diff that produced `patch_content` was relative to that
|
|
3557
|
+
* tree (e.g., `git diff currentGen HEAD --` → snapshot from HEAD).
|
|
3558
|
+
* Skips binary files.
|
|
3559
|
+
*/
|
|
3560
|
+
async readSnapshotFromTree(treeRef, files) {
|
|
3561
|
+
const snapshot = {};
|
|
3562
|
+
for (const file of files) {
|
|
3563
|
+
if (isBinaryFile(file)) continue;
|
|
3564
|
+
const content = await this.git.showFile(treeRef, file).catch(() => null);
|
|
3565
|
+
if (content != null) snapshot[file] = content;
|
|
3566
|
+
}
|
|
3567
|
+
return snapshot;
|
|
3568
|
+
}
|
|
3569
|
+
/**
|
|
3570
|
+
* One-shot auto-migration: backfill `theirs_snapshot` on existing
|
|
3571
|
+
* patches that predate the snapshot encoding. Computes the snapshot by
|
|
3572
|
+
* applying the patch's `patch_content` to its `base_generation` tree —
|
|
3573
|
+
* yielding the patch's INDIVIDUAL contribution.
|
|
3574
|
+
*
|
|
3575
|
+
* **Skips patches sharing files with other patches.** HEAD's content
|
|
3576
|
+
* for a file shared by N patches is cumulative across all of them, so
|
|
3577
|
+
* a HEAD-fallback would falsely encode other patches' contributions
|
|
3578
|
+
* into one patch's snapshot — breaking FER-9525 cross-patch isolation
|
|
3579
|
+
* if the snapshot fallback later fires. Per-patch snapshots only
|
|
3580
|
+
* make sense when the patch owns its file. Multi-patch-same-file
|
|
3581
|
+
* legacy lockfiles keep using line-anchored reconstruction (the
|
|
3582
|
+
* existing path); they're no worse than pre-upgrade.
|
|
3583
|
+
*
|
|
3584
|
+
* **Does not fall back to HEAD when BASE-reconstruction fails.** Same
|
|
3585
|
+
* reason — HEAD content can be cumulative. Patches whose
|
|
3586
|
+
* reconstruction fails just don't get a snapshot; legacy paths
|
|
3587
|
+
* (PR #73's by-association gate, accumulator fallback) handle them.
|
|
3588
|
+
*
|
|
3589
|
+
* Skips patches that already have a snapshot.
|
|
3590
|
+
*/
|
|
3591
|
+
async migratePatchesToSnapshot(patches) {
|
|
3592
|
+
const { applyPatchToContent: applyPatchToContent2 } = await Promise.resolve().then(() => (init_PatchApplyTheirs(), PatchApplyTheirs_exports));
|
|
3593
|
+
const fileFreq = /* @__PURE__ */ new Map();
|
|
3594
|
+
for (const p of patches) {
|
|
3595
|
+
for (const f of p.files) {
|
|
3596
|
+
fileFreq.set(f, (fileFreq.get(f) ?? 0) + 1);
|
|
3597
|
+
}
|
|
3598
|
+
}
|
|
3599
|
+
let migrated = 0;
|
|
3600
|
+
let skipped = 0;
|
|
3601
|
+
for (const patch of patches) {
|
|
3602
|
+
if (patch.theirs_snapshot != null) continue;
|
|
3603
|
+
const sharesAnyFile = patch.files.some((f) => (fileFreq.get(f) ?? 0) > 1);
|
|
3604
|
+
if (sharesAnyFile) {
|
|
3605
|
+
skipped++;
|
|
3606
|
+
continue;
|
|
3607
|
+
}
|
|
3608
|
+
const snapshot = {};
|
|
3609
|
+
for (const file of patch.files) {
|
|
3610
|
+
if (isBinaryFile(file)) continue;
|
|
3611
|
+
try {
|
|
3612
|
+
const base = await this.git.showFile(patch.base_generation, file).catch(() => null);
|
|
3613
|
+
if (base != null) {
|
|
3614
|
+
const content = await applyPatchToContent2(base, patch.patch_content, file);
|
|
3615
|
+
if (content != null) snapshot[file] = content;
|
|
3616
|
+
}
|
|
3617
|
+
} catch {
|
|
3618
|
+
}
|
|
3619
|
+
}
|
|
3620
|
+
if (Object.keys(snapshot).length > 0) {
|
|
3621
|
+
patch.theirs_snapshot = snapshot;
|
|
3622
|
+
try {
|
|
3623
|
+
this.lockManager.updatePatch(patch.id, { theirs_snapshot: snapshot });
|
|
3624
|
+
migrated++;
|
|
3625
|
+
} catch {
|
|
3626
|
+
}
|
|
3627
|
+
}
|
|
3628
|
+
}
|
|
3629
|
+
if (migrated > 0) {
|
|
3630
|
+
this.warnings.push(
|
|
3631
|
+
`Migrated ${migrated} patch(es) to snapshot encoding. Future regens use 3-way merge with stored THEIRS, robust to generator structural changes.`
|
|
3632
|
+
);
|
|
3633
|
+
}
|
|
3634
|
+
if (skipped > 0) {
|
|
3635
|
+
this.warnings.push(
|
|
3636
|
+
`Skipped snapshot migration for ${skipped} patch(es) sharing files with other patches. These continue to use line-anchored reconstruction (the existing path).`
|
|
3637
|
+
);
|
|
3638
|
+
}
|
|
3639
|
+
}
|
|
3383
3640
|
/**
|
|
3384
3641
|
* Determine whether `file` is user-owned (never produced by the generator).
|
|
3385
3642
|
*
|
|
@@ -3500,6 +3757,20 @@ var ReplayService = class {
|
|
|
3500
3757
|
this.lockManager.clearReplaySkippedAt();
|
|
3501
3758
|
return { conflictResolved: 0, conflictAbsorbed: 0, contentRefreshed: 0 };
|
|
3502
3759
|
}
|
|
3760
|
+
await this.migratePatchesToSnapshot(patches);
|
|
3761
|
+
const headMessage = await this.git.exec(["log", "-1", "--format=%s%n%aN%n%ae", "HEAD"]).catch(() => "");
|
|
3762
|
+
const [headMsgLine, headAuthorName, headAuthorEmail] = headMessage.split("\n");
|
|
3763
|
+
const headIsGeneration = headMsgLine ? isGenerationCommit({
|
|
3764
|
+
sha: "HEAD",
|
|
3765
|
+
authorName: headAuthorName ?? "",
|
|
3766
|
+
authorEmail: headAuthorEmail ?? "",
|
|
3767
|
+
message: headMsgLine
|
|
3768
|
+
}) : false;
|
|
3769
|
+
const headParentChangedFiles = async (patchFiles) => {
|
|
3770
|
+
if (patchFiles.length === 0) return /* @__PURE__ */ new Set();
|
|
3771
|
+
const diff = await this.git.exec(["diff", "--name-only", "HEAD~1", "HEAD", "--", ...patchFiles]).catch(() => "");
|
|
3772
|
+
return new Set(diff.trim().split("\n").filter(Boolean));
|
|
3773
|
+
};
|
|
3503
3774
|
let conflictResolved = 0;
|
|
3504
3775
|
let conflictAbsorbed = 0;
|
|
3505
3776
|
let contentRefreshed = 0;
|
|
@@ -3536,6 +3807,13 @@ var ReplayService = class {
|
|
|
3536
3807
|
delete patch.status;
|
|
3537
3808
|
continue;
|
|
3538
3809
|
}
|
|
3810
|
+
if (headIsGeneration) {
|
|
3811
|
+
const parentChangedFiles = await headParentChangedFiles(patch.files);
|
|
3812
|
+
const headTouchedSource = patch.files.some((f) => parentChangedFiles.has(f));
|
|
3813
|
+
if (headTouchedSource) {
|
|
3814
|
+
continue;
|
|
3815
|
+
}
|
|
3816
|
+
}
|
|
3539
3817
|
if (patch.base_generation === currentGen) {
|
|
3540
3818
|
try {
|
|
3541
3819
|
const ppResult = await computePerPatchDiffWithFallback(
|
|
@@ -3572,10 +3850,12 @@ var ReplayService = class {
|
|
|
3572
3850
|
if (newFiles.length === 0) {
|
|
3573
3851
|
this.lockManager.removePatch(patch.id);
|
|
3574
3852
|
} else {
|
|
3853
|
+
const snapshot = await this.readSnapshotFromTree("HEAD", newFiles);
|
|
3575
3854
|
this.lockManager.updatePatch(patch.id, {
|
|
3576
3855
|
patch_content: diff,
|
|
3577
3856
|
content_hash: newContentHash,
|
|
3578
|
-
files: newFiles
|
|
3857
|
+
files: newFiles,
|
|
3858
|
+
...Object.keys(snapshot).length > 0 ? { theirs_snapshot: snapshot } : {}
|
|
3579
3859
|
});
|
|
3580
3860
|
}
|
|
3581
3861
|
contentRefreshed++;
|
|
@@ -3608,10 +3888,12 @@ var ReplayService = class {
|
|
|
3608
3888
|
continue;
|
|
3609
3889
|
}
|
|
3610
3890
|
const newContentHash = this.detector.computeContentHash(diff);
|
|
3891
|
+
const snapshot = await this.readSnapshotFromTree("HEAD", patch.files);
|
|
3611
3892
|
this.lockManager.updatePatch(patch.id, {
|
|
3612
3893
|
base_generation: currentGen,
|
|
3613
3894
|
patch_content: diff,
|
|
3614
|
-
content_hash: newContentHash
|
|
3895
|
+
content_hash: newContentHash,
|
|
3896
|
+
...Object.keys(snapshot).length > 0 ? { theirs_snapshot: snapshot } : {}
|
|
3615
3897
|
});
|
|
3616
3898
|
conflictResolved++;
|
|
3617
3899
|
} catch {
|
|
@@ -3654,7 +3936,7 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
|
|
|
3654
3936
|
if (result.status !== "conflict" || !result.fileResults) continue;
|
|
3655
3937
|
for (const fileResult of result.fileResults) {
|
|
3656
3938
|
if (fileResult.status !== "conflict") continue;
|
|
3657
|
-
const filePath = (0,
|
|
3939
|
+
const filePath = (0, import_node_path5.join)(this.outputDir, fileResult.file);
|
|
3658
3940
|
try {
|
|
3659
3941
|
const content = (0, import_node_fs2.readFileSync)(filePath, "utf-8");
|
|
3660
3942
|
const stripped = stripConflictMarkers(content);
|
|
@@ -3684,7 +3966,7 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
|
|
|
3684
3966
|
}
|
|
3685
3967
|
}
|
|
3686
3968
|
readFernignorePatterns() {
|
|
3687
|
-
const fernignorePath = (0,
|
|
3969
|
+
const fernignorePath = (0, import_node_path5.join)(this.outputDir, ".fernignore");
|
|
3688
3970
|
if (!(0, import_node_fs2.existsSync)(fernignorePath)) return [];
|
|
3689
3971
|
return (0, import_node_fs2.readFileSync)(fernignorePath, "utf-8").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
|
|
3690
3972
|
}
|
|
@@ -3763,7 +4045,7 @@ function computeCommitBuckets(results, absorbedPatchIds, patchesPassedToCommit)
|
|
|
3763
4045
|
// src/FernignoreMigrator.ts
|
|
3764
4046
|
var import_node_crypto2 = require("crypto");
|
|
3765
4047
|
var import_node_fs3 = require("fs");
|
|
3766
|
-
var
|
|
4048
|
+
var import_node_path6 = require("path");
|
|
3767
4049
|
var import_minimatch3 = require("minimatch");
|
|
3768
4050
|
var import_yaml2 = require("yaml");
|
|
3769
4051
|
var FernignoreMigrator = class {
|
|
@@ -3776,10 +4058,10 @@ var FernignoreMigrator = class {
|
|
|
3776
4058
|
this.outputDir = outputDir;
|
|
3777
4059
|
}
|
|
3778
4060
|
fernignoreExists() {
|
|
3779
|
-
return (0, import_node_fs3.existsSync)((0,
|
|
4061
|
+
return (0, import_node_fs3.existsSync)((0, import_node_path6.join)(this.outputDir, ".fernignore"));
|
|
3780
4062
|
}
|
|
3781
4063
|
readFernignorePatterns() {
|
|
3782
|
-
const fernignorePath = (0,
|
|
4064
|
+
const fernignorePath = (0, import_node_path6.join)(this.outputDir, ".fernignore");
|
|
3783
4065
|
if (!(0, import_node_fs3.existsSync)(fernignorePath)) {
|
|
3784
4066
|
return [];
|
|
3785
4067
|
}
|
|
@@ -3831,6 +4113,12 @@ var FernignoreMigrator = class {
|
|
|
3831
4113
|
if (patchFiles.length > 0) {
|
|
3832
4114
|
const patchContent = diffParts.join("\n");
|
|
3833
4115
|
const contentHash = `sha256:${(0, import_node_crypto2.createHash)("sha256").update(patchContent).digest("hex")}`;
|
|
4116
|
+
const theirsSnapshot = {};
|
|
4117
|
+
for (const filePath of patchFiles) {
|
|
4118
|
+
if (isBinaryFile(filePath)) continue;
|
|
4119
|
+
const c = this.readFileContent(filePath);
|
|
4120
|
+
if (c != null) theirsSnapshot[filePath] = c;
|
|
4121
|
+
}
|
|
3834
4122
|
syntheticPatches.push({
|
|
3835
4123
|
id: `patch-fernignore-${(0, import_node_crypto2.createHash)("sha256").update(pattern).digest("hex").slice(0, 8)}`,
|
|
3836
4124
|
content_hash: contentHash,
|
|
@@ -3840,6 +4128,7 @@ var FernignoreMigrator = class {
|
|
|
3840
4128
|
base_generation: currentGen.commit_sha,
|
|
3841
4129
|
files: patchFiles,
|
|
3842
4130
|
patch_content: patchContent,
|
|
4131
|
+
...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
|
|
3843
4132
|
// Synthetic patches captured from .fernignore-protected files
|
|
3844
4133
|
// are user-owned by definition — the customer's content
|
|
3845
4134
|
// diverges from the generator's pristine output, and the
|
|
@@ -3881,7 +4170,7 @@ var FernignoreMigrator = class {
|
|
|
3881
4170
|
return results;
|
|
3882
4171
|
}
|
|
3883
4172
|
readFileContent(filePath) {
|
|
3884
|
-
const fullPath = (0,
|
|
4173
|
+
const fullPath = (0, import_node_path6.join)(this.outputDir, filePath);
|
|
3885
4174
|
if (!(0, import_node_fs3.existsSync)(fullPath)) {
|
|
3886
4175
|
return null;
|
|
3887
4176
|
}
|
|
@@ -3946,7 +4235,7 @@ var FernignoreMigrator = class {
|
|
|
3946
4235
|
};
|
|
3947
4236
|
}
|
|
3948
4237
|
movePatternsToReplayYml(patterns) {
|
|
3949
|
-
const replayYmlPath = (0,
|
|
4238
|
+
const replayYmlPath = (0, import_node_path6.join)(this.outputDir, ".fern", "replay.yml");
|
|
3950
4239
|
let config = {};
|
|
3951
4240
|
if ((0, import_node_fs3.existsSync)(replayYmlPath)) {
|
|
3952
4241
|
const content = (0, import_node_fs3.readFileSync)(replayYmlPath, "utf-8");
|
|
@@ -3955,7 +4244,7 @@ var FernignoreMigrator = class {
|
|
|
3955
4244
|
const existing = config.exclude ?? [];
|
|
3956
4245
|
const merged = [.../* @__PURE__ */ new Set([...existing, ...patterns])];
|
|
3957
4246
|
config.exclude = merged;
|
|
3958
|
-
const dir = (0,
|
|
4247
|
+
const dir = (0, import_node_path6.dirname)(replayYmlPath);
|
|
3959
4248
|
if (!(0, import_node_fs3.existsSync)(dir)) {
|
|
3960
4249
|
(0, import_node_fs3.mkdirSync)(dir, { recursive: true });
|
|
3961
4250
|
}
|
|
@@ -3966,7 +4255,7 @@ var FernignoreMigrator = class {
|
|
|
3966
4255
|
// src/commands/bootstrap.ts
|
|
3967
4256
|
var import_node_crypto3 = require("crypto");
|
|
3968
4257
|
var import_node_fs4 = require("fs");
|
|
3969
|
-
var
|
|
4258
|
+
var import_node_path7 = require("path");
|
|
3970
4259
|
init_GitClient();
|
|
3971
4260
|
async function bootstrap(outputDir, options) {
|
|
3972
4261
|
const git = new GitClient(outputDir);
|
|
@@ -4133,7 +4422,7 @@ function parseGitLog(log) {
|
|
|
4133
4422
|
}
|
|
4134
4423
|
var REPLAY_FERNIGNORE_ENTRIES = [".fern/replay.lock", ".fern/replay.yml", ".gitattributes"];
|
|
4135
4424
|
function ensureFernignoreEntries(outputDir) {
|
|
4136
|
-
const fernignorePath = (0,
|
|
4425
|
+
const fernignorePath = (0, import_node_path7.join)(outputDir, ".fernignore");
|
|
4137
4426
|
let content = "";
|
|
4138
4427
|
if ((0, import_node_fs4.existsSync)(fernignorePath)) {
|
|
4139
4428
|
content = (0, import_node_fs4.readFileSync)(fernignorePath, "utf-8");
|
|
@@ -4157,7 +4446,7 @@ function ensureFernignoreEntries(outputDir) {
|
|
|
4157
4446
|
}
|
|
4158
4447
|
var GITATTRIBUTES_ENTRIES = [".fern/replay.lock linguist-generated=true"];
|
|
4159
4448
|
function ensureGitattributesEntries(outputDir) {
|
|
4160
|
-
const gitattributesPath = (0,
|
|
4449
|
+
const gitattributesPath = (0, import_node_path7.join)(outputDir, ".gitattributes");
|
|
4161
4450
|
let content = "";
|
|
4162
4451
|
if ((0, import_node_fs4.existsSync)(gitattributesPath)) {
|
|
4163
4452
|
content = (0, import_node_fs4.readFileSync)(gitattributesPath, "utf-8");
|
|
@@ -4355,8 +4644,8 @@ function reset(outputDir, options) {
|
|
|
4355
4644
|
}
|
|
4356
4645
|
|
|
4357
4646
|
// src/commands/resolve.ts
|
|
4358
|
-
var
|
|
4359
|
-
var
|
|
4647
|
+
var import_promises5 = require("fs/promises");
|
|
4648
|
+
var import_node_path8 = require("path");
|
|
4360
4649
|
init_GitClient();
|
|
4361
4650
|
async function resolve(outputDir, options) {
|
|
4362
4651
|
const lockManager = new LockfileManager(outputDir);
|
|
@@ -4400,36 +4689,78 @@ async function resolve(outputDir, options) {
|
|
|
4400
4689
|
const detector = new ReplayDetector(git, lockManager, outputDir);
|
|
4401
4690
|
const warnings = [];
|
|
4402
4691
|
let patchesResolved = 0;
|
|
4692
|
+
const pass1 = [];
|
|
4403
4693
|
for (const patch of patchesToCommit) {
|
|
4404
4694
|
const result = await computePerPatchDiffWithFallback(
|
|
4405
4695
|
patch,
|
|
4406
4696
|
(f) => git.showFile(currentGen, f),
|
|
4407
|
-
(f) => (0,
|
|
4697
|
+
(f) => (0, import_promises5.readFile)((0, import_node_path8.join)(outputDir, f), "utf-8").catch(() => null),
|
|
4408
4698
|
(files) => git.exec(["diff", currentGen, "--", ...files]).catch(() => null)
|
|
4409
4699
|
);
|
|
4410
4700
|
if (result === null) {
|
|
4411
4701
|
warnings.push(
|
|
4412
4702
|
`Patch ${patch.id}: cumulative-diff fallback failed for unlocatable files; preserving patch unchanged`
|
|
4413
4703
|
);
|
|
4704
|
+
pass1.push({ kind: "skip" });
|
|
4414
4705
|
continue;
|
|
4415
4706
|
}
|
|
4416
4707
|
const diff = result.diff;
|
|
4417
4708
|
if (!diff.trim()) {
|
|
4709
|
+
pass1.push({ kind: "revert" });
|
|
4710
|
+
continue;
|
|
4711
|
+
}
|
|
4712
|
+
const hash = detector.computeContentHash(diff);
|
|
4713
|
+
pass1.push({
|
|
4714
|
+
kind: "apply",
|
|
4715
|
+
diff,
|
|
4716
|
+
hash,
|
|
4717
|
+
hadFallback: result.hadFallback,
|
|
4718
|
+
fallbackFiles: result.fallbackFiles
|
|
4719
|
+
});
|
|
4720
|
+
}
|
|
4721
|
+
const lastIndexByHash = /* @__PURE__ */ new Map();
|
|
4722
|
+
for (let i = 0; i < pass1.length; i++) {
|
|
4723
|
+
const r = pass1[i];
|
|
4724
|
+
if (r.kind === "apply") lastIndexByHash.set(r.hash, i);
|
|
4725
|
+
}
|
|
4726
|
+
for (let i = 0; i < patchesToCommit.length; i++) {
|
|
4727
|
+
const patch = patchesToCommit[i];
|
|
4728
|
+
const r = pass1[i];
|
|
4729
|
+
if (r.kind === "skip") continue;
|
|
4730
|
+
if (r.kind === "revert") {
|
|
4731
|
+
lockManager.removePatch(patch.id);
|
|
4732
|
+
continue;
|
|
4733
|
+
}
|
|
4734
|
+
if (lastIndexByHash.get(r.hash) !== i) {
|
|
4735
|
+
const canonicalIdx = lastIndexByHash.get(r.hash);
|
|
4736
|
+
const canonical = patchesToCommit[canonicalIdx];
|
|
4418
4737
|
lockManager.removePatch(patch.id);
|
|
4738
|
+
warnings.push(
|
|
4739
|
+
`Consolidated patch ${patch.id} (commit ${(patch.original_commit || "<missing>").slice(0, 7)}) into patch ${canonical.id} (commit ${(canonical.original_commit || "<missing>").slice(0, 7)}): byte-identical patch_content after resolution. Per-commit attribution preserved in git log; lockfile collapsed to avoid duplicate-apply corruption on next regen.`
|
|
4740
|
+
);
|
|
4419
4741
|
continue;
|
|
4420
4742
|
}
|
|
4421
|
-
if (
|
|
4743
|
+
if (r.hadFallback) {
|
|
4422
4744
|
warnings.push(
|
|
4423
|
-
`Patch ${patch.id}: ${
|
|
4745
|
+
`Patch ${patch.id}: ${r.fallbackFiles.join(", ")} could not be anchored; cumulative-diff fallback applied for those files`
|
|
4424
4746
|
);
|
|
4425
4747
|
}
|
|
4426
|
-
const
|
|
4427
|
-
const
|
|
4748
|
+
const changedFiles = r.hadFallback ? await getChangedFiles(git, currentGen, patch.files) : extractFilesFromDiff(r.diff);
|
|
4749
|
+
const filesForSnapshot = changedFiles.length > 0 ? changedFiles : patch.files;
|
|
4750
|
+
const theirsSnapshot = {};
|
|
4751
|
+
for (const file of filesForSnapshot) {
|
|
4752
|
+
if (isBinaryFile(file)) continue;
|
|
4753
|
+
const content = await (0, import_promises5.readFile)((0, import_node_path8.join)(outputDir, file), "utf-8").catch(() => null);
|
|
4754
|
+
if (content != null) {
|
|
4755
|
+
theirsSnapshot[file] = content;
|
|
4756
|
+
}
|
|
4757
|
+
}
|
|
4428
4758
|
lockManager.markPatchResolved(patch.id, {
|
|
4429
|
-
patch_content: diff,
|
|
4430
|
-
content_hash:
|
|
4759
|
+
patch_content: r.diff,
|
|
4760
|
+
content_hash: r.hash,
|
|
4431
4761
|
base_generation: currentGen,
|
|
4432
|
-
files:
|
|
4762
|
+
files: filesForSnapshot,
|
|
4763
|
+
...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {}
|
|
4433
4764
|
});
|
|
4434
4765
|
patchesResolved++;
|
|
4435
4766
|
}
|