@fern-api/replay 0.13.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 +3722 -2789
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +2227 -1288
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +158 -5
- package/dist/index.d.ts +158 -5
- package/dist/index.js +2226 -1288
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -51,9 +51,9 @@ var init_GitClient = __esm({
|
|
|
51
51
|
return this.git.raw(args);
|
|
52
52
|
}
|
|
53
53
|
async execWithInput(args, input) {
|
|
54
|
-
const { spawn } = await import("child_process");
|
|
54
|
+
const { spawn: spawn2 } = await import("child_process");
|
|
55
55
|
return new Promise((resolve2, reject) => {
|
|
56
|
-
const proc =
|
|
56
|
+
const proc = spawn2("git", args, { cwd: this.repoPath });
|
|
57
57
|
let stdout = "";
|
|
58
58
|
let stderr = "";
|
|
59
59
|
proc.stdout.on("data", (data) => {
|
|
@@ -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,1406 +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
|
-
|
|
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;
|
|
916
893
|
}
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
const result = {
|
|
897
|
+
content: outputLines.join("\n"),
|
|
898
|
+
hasConflicts: conflicts.length > 0,
|
|
899
|
+
conflicts
|
|
900
|
+
};
|
|
901
|
+
if (conflicts.length === 0) {
|
|
902
|
+
const dropped = computeDroppedContextLines(baseLines, theirsLines, outputLines);
|
|
903
|
+
if (dropped.length > 0) {
|
|
904
|
+
result.droppedContextLines = dropped;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
return result;
|
|
908
|
+
}
|
|
909
|
+
function computeDroppedContextLines(baseLines, theirsLines, mergedLines) {
|
|
910
|
+
const baseCounts = countLines(baseLines);
|
|
911
|
+
const theirsCounts = countLines(theirsLines);
|
|
912
|
+
const mergedCounts = countLines(mergedLines);
|
|
913
|
+
const dropped = [];
|
|
914
|
+
const seen = /* @__PURE__ */ new Set();
|
|
915
|
+
for (const line of baseLines) {
|
|
916
|
+
if (seen.has(line)) continue;
|
|
917
|
+
const inBase = baseCounts.get(line) ?? 0;
|
|
918
|
+
const inTheirs = theirsCounts.get(line) ?? 0;
|
|
919
|
+
const inMerged = mergedCounts.get(line) ?? 0;
|
|
920
|
+
if (inBase > 0 && inTheirs > 0 && inMerged < Math.min(inBase, inTheirs)) {
|
|
921
|
+
dropped.push(line);
|
|
922
|
+
seen.add(line);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
return dropped;
|
|
926
|
+
}
|
|
927
|
+
function countLines(lines) {
|
|
928
|
+
const counts = /* @__PURE__ */ new Map();
|
|
929
|
+
for (const line of lines) {
|
|
930
|
+
counts.set(line, (counts.get(line) ?? 0) + 1);
|
|
931
|
+
}
|
|
932
|
+
return counts;
|
|
933
|
+
}
|
|
934
|
+
function tryResolveConflict(oursLines, baseLines, theirsLines) {
|
|
935
|
+
if (baseLines.length === 0) {
|
|
936
|
+
return null;
|
|
937
|
+
}
|
|
938
|
+
const oursPatches = (0, import_node_diff3.diffPatch)(baseLines, oursLines);
|
|
939
|
+
const theirsPatches = (0, import_node_diff3.diffPatch)(baseLines, theirsLines);
|
|
940
|
+
if (oursPatches.length === 0) return theirsLines;
|
|
941
|
+
if (theirsPatches.length === 0) return oursLines;
|
|
942
|
+
if (patchesOverlap(oursPatches, theirsPatches)) {
|
|
943
|
+
return null;
|
|
944
|
+
}
|
|
945
|
+
return applyBothPatches(baseLines, oursPatches, theirsPatches);
|
|
946
|
+
}
|
|
947
|
+
function patchesOverlap(oursPatches, theirsPatches) {
|
|
948
|
+
for (const op of oursPatches) {
|
|
949
|
+
const oStart = op.buffer1.offset;
|
|
950
|
+
const oEnd = oStart + op.buffer1.length;
|
|
951
|
+
for (const tp of theirsPatches) {
|
|
952
|
+
const tStart = tp.buffer1.offset;
|
|
953
|
+
const tEnd = tStart + tp.buffer1.length;
|
|
954
|
+
if (op.buffer1.length === 0 && tp.buffer1.length === 0 && oStart === tStart) {
|
|
955
|
+
return true;
|
|
924
956
|
}
|
|
925
|
-
if (
|
|
926
|
-
|
|
927
|
-
if (revertedSha) {
|
|
928
|
-
const idx = survivingPatches.findIndex(
|
|
929
|
-
(p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_commit === revertedSha
|
|
930
|
-
);
|
|
931
|
-
if (idx !== -1) {
|
|
932
|
-
revertIndicesToRemove.add(i);
|
|
933
|
-
revertIndicesToRemove.add(idx);
|
|
934
|
-
matchedNew = true;
|
|
935
|
-
}
|
|
957
|
+
if (tp.buffer1.length === 0 && tStart === oEnd) {
|
|
958
|
+
return true;
|
|
936
959
|
}
|
|
937
|
-
if (
|
|
938
|
-
|
|
939
|
-
(p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_message === revertedMessage
|
|
940
|
-
);
|
|
941
|
-
if (idx !== -1) {
|
|
942
|
-
revertIndicesToRemove.add(i);
|
|
943
|
-
revertIndicesToRemove.add(idx);
|
|
944
|
-
}
|
|
960
|
+
if (op.buffer1.length === 0 && oStart === tEnd) {
|
|
961
|
+
return true;
|
|
945
962
|
}
|
|
946
|
-
if (
|
|
947
|
-
|
|
963
|
+
if (oStart < tEnd && tStart < oEnd) {
|
|
964
|
+
return true;
|
|
948
965
|
}
|
|
949
966
|
}
|
|
950
|
-
const filteredPatches = survivingPatches.filter((_, i) => !revertIndicesToRemove.has(i));
|
|
951
|
-
return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
|
|
952
967
|
}
|
|
968
|
+
return false;
|
|
969
|
+
}
|
|
970
|
+
function applyBothPatches(baseLines, oursPatches, theirsPatches) {
|
|
971
|
+
const allPatches = [
|
|
972
|
+
...oursPatches.map((p) => ({
|
|
973
|
+
offset: p.buffer1.offset,
|
|
974
|
+
length: p.buffer1.length,
|
|
975
|
+
replacement: p.buffer2.chunk
|
|
976
|
+
})),
|
|
977
|
+
...theirsPatches.map((p) => ({
|
|
978
|
+
offset: p.buffer1.offset,
|
|
979
|
+
length: p.buffer1.length,
|
|
980
|
+
replacement: p.buffer2.chunk
|
|
981
|
+
}))
|
|
982
|
+
];
|
|
983
|
+
allPatches.sort((a, b) => b.offset - a.offset);
|
|
984
|
+
const result = [...baseLines];
|
|
985
|
+
for (const p of allPatches) {
|
|
986
|
+
result.splice(p.offset, p.length, ...p.replacement);
|
|
987
|
+
}
|
|
988
|
+
return result;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// src/ReplayApplicator.ts
|
|
992
|
+
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();
|
|
953
1048
|
/**
|
|
954
|
-
*
|
|
955
|
-
*
|
|
956
|
-
*
|
|
957
|
-
*
|
|
958
|
-
*
|
|
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
|
|
1059
|
+
*/
|
|
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).
|
|
959
1067
|
*
|
|
960
|
-
*
|
|
961
|
-
*
|
|
962
|
-
*
|
|
963
|
-
*
|
|
964
|
-
* 3. Strip trailing git version marker (`-- \n<version>`).
|
|
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.
|
|
965
1072
|
*
|
|
966
|
-
*
|
|
967
|
-
*
|
|
968
|
-
*
|
|
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.
|
|
969
1079
|
*/
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
return `sha256:${(0, import_node_crypto.createHash)("sha256").update(normalized).digest("hex")}`;
|
|
1080
|
+
sharedFiles = /* @__PURE__ */ new Set();
|
|
1081
|
+
constructor(git, lockManager, outputDir) {
|
|
1082
|
+
this.git = git;
|
|
1083
|
+
this.lockManager = lockManager;
|
|
1084
|
+
this.outputDir = outputDir;
|
|
976
1085
|
}
|
|
977
1086
|
/**
|
|
978
|
-
*
|
|
979
|
-
*
|
|
980
|
-
*
|
|
981
|
-
*
|
|
982
|
-
* Rationale: on a merge commit, createMergeCompositePatch already diffs
|
|
983
|
-
* firstParent..mergeCommit so net-zero files never enter the composite. On
|
|
984
|
-
* linear history each commit becomes its own patch, so a file that was
|
|
985
|
-
* briefly added and later removed survives as a create+delete pair whose
|
|
986
|
-
* cumulative diff is empty. We drop such patches before they reach the
|
|
987
|
-
* lockfile.
|
|
988
|
-
*
|
|
989
|
-
* A file is "transient" when:
|
|
990
|
-
* 1. some patch in the window sets `new file mode` for it (a creation), AND
|
|
991
|
-
* 2. some patch in the window sets `deleted file mode` for it (a deletion), AND
|
|
992
|
-
* 3. it is absent from HEAD's tree.
|
|
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
|
-
}
|
|
1018
|
-
}
|
|
1019
|
-
let anyCandidate = false;
|
|
1020
|
-
for (const state of stateByFile.values()) {
|
|
1021
|
-
if (state.created && state.deleted) {
|
|
1022
|
-
anyCandidate = true;
|
|
1023
|
-
break;
|
|
1024
|
-
}
|
|
1025
|
-
}
|
|
1026
|
-
if (!anyCandidate) return patches;
|
|
1027
|
-
const headFiles = await this.listHeadFiles();
|
|
1028
|
-
const transient = /* @__PURE__ */ new Set();
|
|
1029
|
-
for (const [file, state] of stateByFile) {
|
|
1030
|
-
if (state.created && state.deleted && !headFiles.has(file)) {
|
|
1031
|
-
transient.add(file);
|
|
1032
|
-
}
|
|
1033
|
-
}
|
|
1034
|
-
if (transient.size === 0) return patches;
|
|
1035
|
-
return patches.filter((patch) => !patch.files.every((f) => transient.has(f)));
|
|
1036
|
-
}
|
|
1037
|
-
/**
|
|
1038
|
-
* List every tracked path at HEAD (one `git ls-tree -r` invocation).
|
|
1039
|
-
* Returns an empty Set if HEAD is unborn or the invocation fails — the
|
|
1040
|
-
* caller then treats every candidate as "not in HEAD" and may collapse
|
|
1041
|
-
* more aggressively. On typical SDK repos this is a single sub-10ms call.
|
|
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).
|
|
1042
1090
|
*/
|
|
1043
|
-
async
|
|
1091
|
+
async resolveBaseGeneration(baseGeneration) {
|
|
1092
|
+
const gen = this.lockManager.getGeneration(baseGeneration);
|
|
1093
|
+
if (gen) return gen;
|
|
1044
1094
|
try {
|
|
1045
|
-
const
|
|
1046
|
-
return
|
|
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
|
+
};
|
|
1047
1103
|
} catch {
|
|
1048
|
-
return
|
|
1104
|
+
return void 0;
|
|
1049
1105
|
}
|
|
1050
1106
|
}
|
|
1051
|
-
/**
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
* entire merged branch as one patch, eliminating accumulator chaining and
|
|
1055
|
-
* zero-net-change ghost conflicts.
|
|
1056
|
-
*/
|
|
1057
|
-
async createMergeCompositePatch(input) {
|
|
1058
|
-
const { mergeCommit, firstParent, lastGen, lock } = input;
|
|
1059
|
-
const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
|
|
1060
|
-
const filesOutput = await this.git.exec([
|
|
1061
|
-
"diff",
|
|
1062
|
-
"--name-only",
|
|
1063
|
-
firstParent,
|
|
1064
|
-
mergeCommit.sha
|
|
1065
|
-
]);
|
|
1066
|
-
const allMergeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
|
|
1067
|
-
const sensitiveMergeFiles = allMergeFiles.filter((f) => isSensitiveFile(f));
|
|
1068
|
-
const files = allMergeFiles.filter((f) => !isSensitiveFile(f));
|
|
1069
|
-
if (sensitiveMergeFiles.length > 0) {
|
|
1070
|
-
this.warnings.push(
|
|
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;
|
|
1086
|
-
}
|
|
1087
|
-
return {
|
|
1088
|
-
id: `patch-merge-${mergeCommit.sha.slice(0, 8)}`,
|
|
1089
|
-
content_hash: contentHash,
|
|
1090
|
-
original_commit: mergeCommit.sha,
|
|
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
|
-
};
|
|
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
|
|
1259
|
-
}
|
|
1260
|
-
parseGitLog(log) {
|
|
1261
|
-
return log.trim().split("\n").map((line) => {
|
|
1262
|
-
const [sha, authorName, authorEmail, message] = line.split("\0");
|
|
1263
|
-
return { sha, authorName, authorEmail, message };
|
|
1264
|
-
});
|
|
1265
|
-
}
|
|
1266
|
-
getLastGeneration(lock) {
|
|
1267
|
-
return lock.generations.find((g) => g.commit_sha === lock.current_generation);
|
|
1297
|
+
return this.applyWithThreeWayMerge(patch);
|
|
1268
1298
|
}
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
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;
|
|
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);
|
|
1312
1323
|
}
|
|
1324
|
+
} finally {
|
|
1325
|
+
await (0, import_promises.rm)(tempDir, { recursive: true }).catch(() => {
|
|
1326
|
+
});
|
|
1313
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
|
+
};
|
|
1314
1339
|
}
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
}
|
|
1321
|
-
function tryResolveConflict(oursLines, baseLines, theirsLines) {
|
|
1322
|
-
if (baseLines.length === 0) {
|
|
1323
|
-
return null;
|
|
1324
|
-
}
|
|
1325
|
-
const oursPatches = (0, import_node_diff3.diffPatch)(baseLines, oursLines);
|
|
1326
|
-
const theirsPatches = (0, import_node_diff3.diffPatch)(baseLines, theirsLines);
|
|
1327
|
-
if (oursPatches.length === 0) return theirsLines;
|
|
1328
|
-
if (theirsPatches.length === 0) return oursLines;
|
|
1329
|
-
if (patchesOverlap(oursPatches, theirsPatches)) {
|
|
1330
|
-
return null;
|
|
1331
|
-
}
|
|
1332
|
-
return applyBothPatches(baseLines, oursPatches, theirsPatches);
|
|
1333
|
-
}
|
|
1334
|
-
function patchesOverlap(oursPatches, theirsPatches) {
|
|
1335
|
-
for (const op of oursPatches) {
|
|
1336
|
-
const oStart = op.buffer1.offset;
|
|
1337
|
-
const oEnd = oStart + op.buffer1.length;
|
|
1338
|
-
for (const tp of theirsPatches) {
|
|
1339
|
-
const tStart = tp.buffer1.offset;
|
|
1340
|
-
const tEnd = tStart + tp.buffer1.length;
|
|
1341
|
-
if (op.buffer1.length === 0 && tp.buffer1.length === 0 && oStart === tStart) {
|
|
1342
|
-
return true;
|
|
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" };
|
|
1343
1345
|
}
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
+
const lock = this.lockManager.read();
|
|
1347
|
+
const currentGen = lock.generations.find((g) => g.commit_sha === lock.current_generation);
|
|
1348
|
+
const currentTreeHash = currentGen?.tree_hash ?? baseGen.tree_hash;
|
|
1349
|
+
const resolvedPath = await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
|
|
1350
|
+
const metadata = {
|
|
1351
|
+
patchId: patch.id,
|
|
1352
|
+
patchMessage: patch.original_message,
|
|
1353
|
+
baseGeneration: patch.base_generation,
|
|
1354
|
+
currentGeneration: lock.current_generation
|
|
1355
|
+
};
|
|
1356
|
+
let base = await this.git.showFile(baseGen.tree_hash, filePath);
|
|
1357
|
+
let renameSourcePath;
|
|
1358
|
+
if (!base) {
|
|
1359
|
+
const renameSource = this.extractRenameSource(patch.patch_content, filePath);
|
|
1360
|
+
if (renameSource) {
|
|
1361
|
+
base = await this.git.showFile(baseGen.tree_hash, renameSource);
|
|
1362
|
+
renameSourcePath = renameSource;
|
|
1363
|
+
}
|
|
1346
1364
|
}
|
|
1347
|
-
|
|
1348
|
-
|
|
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
|
+
}
|
|
1349
1383
|
}
|
|
1350
|
-
|
|
1351
|
-
|
|
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
|
+
}
|
|
1352
1405
|
}
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
replacement: p.buffer2.chunk
|
|
1368
|
-
}))
|
|
1369
|
-
];
|
|
1370
|
-
allPatches.sort((a, b) => b.offset - a.offset);
|
|
1371
|
-
const result = [...baseLines];
|
|
1372
|
-
for (const p of allPatches) {
|
|
1373
|
-
result.splice(p.offset, p.length, ...p.replacement);
|
|
1374
|
-
}
|
|
1375
|
-
return result;
|
|
1376
|
-
}
|
|
1377
|
-
|
|
1378
|
-
// src/ReplayApplicator.ts
|
|
1379
|
-
var import_promises = require("fs/promises");
|
|
1380
|
-
var import_node_os = require("os");
|
|
1381
|
-
var import_node_path2 = require("path");
|
|
1382
|
-
var import_minimatch = require("minimatch");
|
|
1383
|
-
|
|
1384
|
-
// src/conflict-utils.ts
|
|
1385
|
-
var CONFLICT_OPENER = "<<<<<<< Generated";
|
|
1386
|
-
var CONFLICT_SEPARATOR = "=======";
|
|
1387
|
-
var CONFLICT_CLOSER = ">>>>>>> Your customization";
|
|
1388
|
-
function trimCR(line) {
|
|
1389
|
-
return line.endsWith("\r") ? line.slice(0, -1) : line;
|
|
1390
|
-
}
|
|
1391
|
-
function findConflictRanges(lines) {
|
|
1392
|
-
const ranges = [];
|
|
1393
|
-
let i = 0;
|
|
1394
|
-
while (i < lines.length) {
|
|
1395
|
-
if (trimCR(lines[i]) === CONFLICT_OPENER) {
|
|
1396
|
-
let separatorIdx = -1;
|
|
1397
|
-
let j = i + 1;
|
|
1398
|
-
let found = false;
|
|
1399
|
-
while (j < lines.length) {
|
|
1400
|
-
const trimmed = trimCR(lines[j]);
|
|
1401
|
-
if (trimmed === CONFLICT_OPENER) {
|
|
1402
|
-
break;
|
|
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
|
+
}
|
|
1403
1420
|
}
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1421
|
+
}
|
|
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;
|
|
1411
1442
|
}
|
|
1412
|
-
j++;
|
|
1413
1443
|
}
|
|
1414
|
-
if (
|
|
1415
|
-
|
|
1416
|
-
|
|
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
|
+
};
|
|
1496
|
+
}
|
|
1497
|
+
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
1498
|
+
content: merged2.content,
|
|
1499
|
+
baseGeneration: patch.base_generation
|
|
1500
|
+
});
|
|
1501
|
+
return { file: resolvedPath, status: "merged" };
|
|
1417
1502
|
}
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
const lines = content.split(/\r?\n/);
|
|
1425
|
-
const ranges = findConflictRanges(lines);
|
|
1426
|
-
if (ranges.length === 0) {
|
|
1427
|
-
return content;
|
|
1428
|
-
}
|
|
1429
|
-
const result = [];
|
|
1430
|
-
let rangeIdx = 0;
|
|
1431
|
-
for (let i = 0; i < lines.length; i++) {
|
|
1432
|
-
if (rangeIdx < ranges.length) {
|
|
1433
|
-
const range = ranges[rangeIdx];
|
|
1434
|
-
if (i === range.start) {
|
|
1435
|
-
continue;
|
|
1503
|
+
if (effective_theirs == null) {
|
|
1504
|
+
return {
|
|
1505
|
+
file: resolvedPath,
|
|
1506
|
+
status: "skipped",
|
|
1507
|
+
reason: "missing-content"
|
|
1508
|
+
};
|
|
1436
1509
|
}
|
|
1437
|
-
if (
|
|
1438
|
-
|
|
1439
|
-
|
|
1510
|
+
if (base == null && !useAccumulatorAsMergeBase || ours == null) {
|
|
1511
|
+
return {
|
|
1512
|
+
file: resolvedPath,
|
|
1513
|
+
status: "skipped",
|
|
1514
|
+
reason: "missing-content"
|
|
1515
|
+
};
|
|
1440
1516
|
}
|
|
1441
|
-
|
|
1442
|
-
|
|
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
|
+
};
|
|
1443
1524
|
}
|
|
1444
|
-
|
|
1445
|
-
|
|
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
|
+
});
|
|
1446
1535
|
}
|
|
1447
|
-
if (
|
|
1448
|
-
|
|
1449
|
-
|
|
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
|
+
};
|
|
1450
1544
|
}
|
|
1451
|
-
}
|
|
1452
|
-
result.push(lines[i]);
|
|
1453
|
-
}
|
|
1454
|
-
return result.join("\n");
|
|
1455
|
-
}
|
|
1456
|
-
|
|
1457
|
-
// src/ReplayApplicator.ts
|
|
1458
|
-
var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
1459
|
-
".png",
|
|
1460
|
-
".jpg",
|
|
1461
|
-
".jpeg",
|
|
1462
|
-
".gif",
|
|
1463
|
-
".bmp",
|
|
1464
|
-
".ico",
|
|
1465
|
-
".webp",
|
|
1466
|
-
".svg",
|
|
1467
|
-
".pdf",
|
|
1468
|
-
".doc",
|
|
1469
|
-
".docx",
|
|
1470
|
-
".xls",
|
|
1471
|
-
".xlsx",
|
|
1472
|
-
".ppt",
|
|
1473
|
-
".pptx",
|
|
1474
|
-
".zip",
|
|
1475
|
-
".gz",
|
|
1476
|
-
".tar",
|
|
1477
|
-
".bz2",
|
|
1478
|
-
".7z",
|
|
1479
|
-
".rar",
|
|
1480
|
-
".jar",
|
|
1481
|
-
".war",
|
|
1482
|
-
".ear",
|
|
1483
|
-
".class",
|
|
1484
|
-
".exe",
|
|
1485
|
-
".dll",
|
|
1486
|
-
".so",
|
|
1487
|
-
".dylib",
|
|
1488
|
-
".o",
|
|
1489
|
-
".a",
|
|
1490
|
-
".woff",
|
|
1491
|
-
".woff2",
|
|
1492
|
-
".ttf",
|
|
1493
|
-
".eot",
|
|
1494
|
-
".otf",
|
|
1495
|
-
".mp3",
|
|
1496
|
-
".mp4",
|
|
1497
|
-
".avi",
|
|
1498
|
-
".mov",
|
|
1499
|
-
".wav",
|
|
1500
|
-
".flac",
|
|
1501
|
-
".sqlite",
|
|
1502
|
-
".db",
|
|
1503
|
-
".pyc",
|
|
1504
|
-
".pyo",
|
|
1505
|
-
".DS_Store"
|
|
1506
|
-
]);
|
|
1507
|
-
var ReplayApplicator = class {
|
|
1508
|
-
git;
|
|
1509
|
-
lockManager;
|
|
1510
|
-
outputDir;
|
|
1511
|
-
renameCache = /* @__PURE__ */ new Map();
|
|
1512
|
-
treeExistsCache = /* @__PURE__ */ new Map();
|
|
1513
|
-
fileTheirsAccumulator = /* @__PURE__ */ new Map();
|
|
1514
|
-
constructor(git, lockManager, outputDir) {
|
|
1515
|
-
this.git = git;
|
|
1516
|
-
this.lockManager = lockManager;
|
|
1517
|
-
this.outputDir = outputDir;
|
|
1518
|
-
}
|
|
1519
|
-
/**
|
|
1520
|
-
* Resolve the GenerationRecord for a patch's base_generation.
|
|
1521
|
-
* Falls back to constructing an ad-hoc record from the commit's tree
|
|
1522
|
-
* when base_generation isn't a tracked generation (commit-scan patches).
|
|
1523
|
-
*/
|
|
1524
|
-
async resolveBaseGeneration(baseGeneration) {
|
|
1525
|
-
const gen = this.lockManager.getGeneration(baseGeneration);
|
|
1526
|
-
if (gen) return gen;
|
|
1527
|
-
try {
|
|
1528
|
-
const treeHash = await this.git.getTreeHash(baseGeneration);
|
|
1529
1545
|
return {
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
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)}`
|
|
1535
1555
|
};
|
|
1536
|
-
} catch {
|
|
1537
|
-
return void 0;
|
|
1538
1556
|
}
|
|
1539
1557
|
}
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
* @internal Test-only.
|
|
1546
|
-
* Returns a defensive copy of the inter-patch accumulator. Used by the
|
|
1547
|
-
* adversarial test suite (FER-9791) to assert invariant I2: after every
|
|
1548
|
-
* applied patch, the accumulator has an entry for each file the patch
|
|
1549
|
-
* touched, and the entry's content matches on-disk.
|
|
1550
|
-
*/
|
|
1551
|
-
getAccumulatorSnapshot() {
|
|
1552
|
-
const snapshot = /* @__PURE__ */ new Map();
|
|
1553
|
-
for (const [path, entry] of this.fileTheirsAccumulator) {
|
|
1554
|
-
snapshot.set(path, { content: entry.content, baseGeneration: entry.baseGeneration });
|
|
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);
|
|
1555
1563
|
}
|
|
1556
|
-
return
|
|
1564
|
+
return result;
|
|
1557
1565
|
}
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
const
|
|
1565
|
-
|
|
1566
|
-
const
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
status: "skipped",
|
|
1571
|
-
method: "git-am"
|
|
1572
|
-
});
|
|
1573
|
-
continue;
|
|
1574
|
-
}
|
|
1575
|
-
const result = await this.applyPatchWithFallback(patch);
|
|
1576
|
-
results.push(result);
|
|
1577
|
-
if (result.status === "conflict" && result.fileResults) {
|
|
1578
|
-
const laterFiles = /* @__PURE__ */ new Set();
|
|
1579
|
-
for (let j = i + 1; j < patches.length; j++) {
|
|
1580
|
-
for (const f of patches[j].files) {
|
|
1581
|
-
laterFiles.add(f);
|
|
1582
|
-
}
|
|
1583
|
-
}
|
|
1584
|
-
const resolvedToOriginal = /* @__PURE__ */ new Map();
|
|
1585
|
-
if (result.resolvedFiles) {
|
|
1586
|
-
for (const [orig, resolved] of Object.entries(result.resolvedFiles)) {
|
|
1587
|
-
resolvedToOriginal.set(resolved, orig);
|
|
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;
|
|
1588
1578
|
}
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
if (
|
|
1592
|
-
|
|
1593
|
-
if (laterFiles.has(fileResult.file) || laterFiles.has(originalPath)) {
|
|
1594
|
-
const filePath = (0, import_node_path2.join)(this.outputDir, fileResult.file);
|
|
1595
|
-
try {
|
|
1596
|
-
const content = await (0, import_promises.readFile)(filePath, "utf-8");
|
|
1597
|
-
const stripped = stripConflictMarkers(content);
|
|
1598
|
-
await (0, import_promises.writeFile)(filePath, stripped);
|
|
1599
|
-
} catch {
|
|
1600
|
-
}
|
|
1579
|
+
const fromBase = move.from.replace(/\*\*.*$/, "");
|
|
1580
|
+
const toBase = move.to.replace(/\*\*.*$/, "");
|
|
1581
|
+
if (filePath.startsWith(fromBase)) {
|
|
1582
|
+
return toBase + filePath.slice(fromBase.length);
|
|
1601
1583
|
}
|
|
1602
1584
|
}
|
|
1603
1585
|
}
|
|
1604
1586
|
}
|
|
1605
|
-
|
|
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;
|
|
1598
|
+
}
|
|
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
|
+
}
|
|
1606
1631
|
}
|
|
1607
|
-
/**
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1632
|
+
/**
|
|
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.
|
|
1638
|
+
*/
|
|
1639
|
+
async isPatchAlreadyApplied(patchContent, filePath, content, tempGit, tempDir) {
|
|
1640
|
+
const fileDiff = this.extractFileDiff(patchContent, filePath);
|
|
1641
|
+
if (!fileDiff) return false;
|
|
1616
1642
|
try {
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
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;
|
|
1650
|
+
} catch {
|
|
1651
|
+
return false;
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
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;
|
|
1628
1662
|
}
|
|
1663
|
+
if (isDiffLineForFile(line, filePath)) {
|
|
1664
|
+
inTargetFile = true;
|
|
1665
|
+
diffLines.push(line);
|
|
1666
|
+
}
|
|
1667
|
+
continue;
|
|
1668
|
+
}
|
|
1669
|
+
if (inTargetFile) {
|
|
1670
|
+
diffLines.push(line);
|
|
1629
1671
|
}
|
|
1630
|
-
} finally {
|
|
1631
|
-
await (0, import_promises.rm)(tempDir, { recursive: true }).catch(() => {
|
|
1632
|
-
});
|
|
1633
1672
|
}
|
|
1673
|
+
return diffLines.length > 0 ? diffLines.join("\n") + "\n" : null;
|
|
1634
1674
|
}
|
|
1635
|
-
|
|
1636
|
-
const
|
|
1637
|
-
|
|
1638
|
-
const
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
for (const filePath of patch.files) {
|
|
1649
|
-
const resolvedPath = baseGen ? await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash) : filePath;
|
|
1650
|
-
if (resolvedPath !== filePath) {
|
|
1651
|
-
resolvedFiles[filePath] = resolvedPath;
|
|
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);
|
|
1652
1688
|
}
|
|
1653
1689
|
}
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1690
|
+
return null;
|
|
1691
|
+
}
|
|
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;
|
|
1705
|
+
continue;
|
|
1661
1706
|
}
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
patch,
|
|
1667
|
-
status: "applied",
|
|
1668
|
-
method: "git-am",
|
|
1669
|
-
...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
|
|
1670
|
-
};
|
|
1671
|
-
} catch {
|
|
1672
|
-
for (const [filePath, content] of snapshots) {
|
|
1673
|
-
const fullPath = (0, import_node_path2.join)(this.outputDir, filePath);
|
|
1674
|
-
if (content != null) {
|
|
1675
|
-
await (0, import_promises.writeFile)(fullPath, content);
|
|
1676
|
-
} else {
|
|
1677
|
-
await (0, import_promises.unlink)(fullPath).catch(() => {
|
|
1678
|
-
});
|
|
1679
|
-
}
|
|
1707
|
+
if (!inTargetFile) continue;
|
|
1708
|
+
if (!inHunk) {
|
|
1709
|
+
if (line === "--- /dev/null" || line.startsWith("new file mode")) {
|
|
1710
|
+
isNewFile = true;
|
|
1680
1711
|
}
|
|
1681
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
|
+
}
|
|
1682
1726
|
}
|
|
1683
|
-
|
|
1727
|
+
if (addedLines.length === 0) return null;
|
|
1728
|
+
return addedLines.join("\n") + (noTrailingNewline ? "" : "\n");
|
|
1684
1729
|
}
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
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;
|
|
1709
1785
|
}
|
|
1710
|
-
}
|
|
1711
|
-
|
|
1712
|
-
|
|
1786
|
+
} else if (currentPath !== null) {
|
|
1787
|
+
if (line.startsWith("new file mode ")) {
|
|
1788
|
+
currentIsCreate = true;
|
|
1789
|
+
} else if (line.startsWith("deleted file mode ")) {
|
|
1790
|
+
currentIsDelete = true;
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
flush();
|
|
1795
|
+
return results;
|
|
1796
|
+
}
|
|
1797
|
+
var ReplayDetector = class {
|
|
1798
|
+
git;
|
|
1799
|
+
lockManager;
|
|
1800
|
+
sdkOutputDir;
|
|
1801
|
+
warnings = [];
|
|
1802
|
+
constructor(git, lockManager, sdkOutputDir) {
|
|
1803
|
+
this.git = git;
|
|
1804
|
+
this.lockManager = lockManager;
|
|
1805
|
+
this.sdkOutputDir = sdkOutputDir;
|
|
1806
|
+
}
|
|
1807
|
+
async detectNewPatches() {
|
|
1808
|
+
const lock = this.lockManager.read();
|
|
1809
|
+
const lastGen = this.getLastGeneration(lock);
|
|
1810
|
+
if (!lastGen) {
|
|
1811
|
+
return { patches: [], revertedPatchIds: [] };
|
|
1713
1812
|
}
|
|
1714
|
-
const
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
}
|
|
1813
|
+
const exists = await this.git.commitExists(lastGen.commit_sha);
|
|
1814
|
+
if (!exists) {
|
|
1815
|
+
this.warnings.push(
|
|
1816
|
+
`Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to alternate detection.`
|
|
1817
|
+
);
|
|
1818
|
+
return this.detectPatchesViaTreeDiff(
|
|
1819
|
+
lastGen,
|
|
1820
|
+
/* commitKnownMissing */
|
|
1821
|
+
true
|
|
1822
|
+
);
|
|
1823
|
+
}
|
|
1824
|
+
const isAncestor = await this.git.isAncestor(lastGen.commit_sha, "HEAD");
|
|
1825
|
+
if (!isAncestor) {
|
|
1826
|
+
return this.detectPatchesViaMergeBase(lastGen, lock);
|
|
1827
|
+
}
|
|
1828
|
+
return this.detectPatchesInRange(lastGen.commit_sha, lastGen, lock);
|
|
1725
1829
|
}
|
|
1726
|
-
|
|
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 = "";
|
|
1727
1846
|
try {
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1847
|
+
mergeBase = (await this.git.exec(["merge-base", lastGen.commit_sha, "HEAD"])).trim();
|
|
1848
|
+
} catch {
|
|
1849
|
+
}
|
|
1850
|
+
if (!mergeBase) {
|
|
1851
|
+
this.warnings.push(
|
|
1852
|
+
`No common ancestor between previous generation ${lastGen.commit_sha.slice(0, 7)} and HEAD. Falling back to composite tree-diff detection.`
|
|
1853
|
+
);
|
|
1854
|
+
return this.detectPatchesViaTreeDiff(
|
|
1855
|
+
lastGen,
|
|
1856
|
+
/* commitKnownMissing */
|
|
1857
|
+
false
|
|
1858
|
+
);
|
|
1859
|
+
}
|
|
1860
|
+
return this.detectPatchesInRange(mergeBase, lastGen, lock);
|
|
1861
|
+
}
|
|
1862
|
+
/**
|
|
1863
|
+
* FER-10201 — Walk `${rangeStart}..HEAD`, classify each commit, emit one
|
|
1864
|
+
* `StoredPatch` per customer commit. Body shared by the linear path
|
|
1865
|
+
* (rangeStart = lastGen.commit_sha) and the merge-base path.
|
|
1866
|
+
*
|
|
1867
|
+
* Patch `base_generation` is always `lastGen.commit_sha` — the
|
|
1868
|
+
* lockfile-tracked reference the applicator uses to fetch base file
|
|
1869
|
+
* content, regardless of where the walk actually started.
|
|
1870
|
+
*/
|
|
1871
|
+
async detectPatchesInRange(rangeStart, lastGen, lock) {
|
|
1872
|
+
const log = await this.git.exec([
|
|
1873
|
+
"log",
|
|
1874
|
+
"--first-parent",
|
|
1875
|
+
"--format=%H%x00%an%x00%ae%x00%s",
|
|
1876
|
+
`${rangeStart}..HEAD`,
|
|
1877
|
+
"--",
|
|
1878
|
+
this.sdkOutputDir
|
|
1879
|
+
]);
|
|
1880
|
+
if (!log.trim()) {
|
|
1881
|
+
return { patches: [], revertedPatchIds: [] };
|
|
1882
|
+
}
|
|
1883
|
+
const commits = this.parseGitLog(log);
|
|
1884
|
+
const newPatches = [];
|
|
1885
|
+
const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
|
|
1886
|
+
for (const commit of commits) {
|
|
1887
|
+
if (isGenerationCommit(commit)) {
|
|
1888
|
+
continue;
|
|
1731
1889
|
}
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
const currentTreeHash = currentGen?.tree_hash ?? baseGen.tree_hash;
|
|
1735
|
-
const resolvedPath = await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
|
|
1736
|
-
const metadata = {
|
|
1737
|
-
patchId: patch.id,
|
|
1738
|
-
patchMessage: patch.original_message,
|
|
1739
|
-
baseGeneration: patch.base_generation,
|
|
1740
|
-
currentGeneration: lock.current_generation
|
|
1741
|
-
};
|
|
1742
|
-
let base = await this.git.showFile(baseGen.tree_hash, filePath);
|
|
1743
|
-
let renameSourcePath;
|
|
1744
|
-
if (!base) {
|
|
1745
|
-
const renameSource = this.extractRenameSource(patch.patch_content, filePath);
|
|
1746
|
-
if (renameSource) {
|
|
1747
|
-
base = await this.git.showFile(baseGen.tree_hash, renameSource);
|
|
1748
|
-
renameSourcePath = renameSource;
|
|
1749
|
-
}
|
|
1890
|
+
if (lock.patches.find((p) => p.original_commit === commit.sha)) {
|
|
1891
|
+
continue;
|
|
1750
1892
|
}
|
|
1751
|
-
const
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
const result = reconstructFromGhostPatch2(fileDiff, ours);
|
|
1762
|
-
if (result) {
|
|
1763
|
-
base = result.base;
|
|
1764
|
-
theirs = result.theirs;
|
|
1765
|
-
ghostReconstructed = true;
|
|
1766
|
-
}
|
|
1767
|
-
}
|
|
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);
|
|
1768
1903
|
}
|
|
1904
|
+
continue;
|
|
1769
1905
|
}
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
tempDir,
|
|
1777
|
-
renameSourcePath
|
|
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.`
|
|
1778
1912
|
);
|
|
1913
|
+
continue;
|
|
1779
1914
|
}
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
if (theirsHasMarkers && !baseHasMarkers) {
|
|
1784
|
-
return {
|
|
1785
|
-
file: resolvedPath,
|
|
1786
|
-
status: "skipped",
|
|
1787
|
-
reason: "stale-conflict-markers"
|
|
1788
|
-
};
|
|
1789
|
-
}
|
|
1915
|
+
let contentHash = this.computeContentHash(patchContent);
|
|
1916
|
+
if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
|
|
1917
|
+
continue;
|
|
1790
1918
|
}
|
|
1791
|
-
|
|
1792
|
-
const
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
tempGit,
|
|
1799
|
-
tempDir
|
|
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.`
|
|
1800
1926
|
);
|
|
1801
|
-
if (
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
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;
|
|
1934
|
+
}
|
|
1935
|
+
if (!patchContent.trim()) continue;
|
|
1936
|
+
contentHash = this.computeContentHash(patchContent);
|
|
1937
|
+
}
|
|
1812
1938
|
}
|
|
1813
1939
|
}
|
|
1814
|
-
if (
|
|
1815
|
-
|
|
1816
|
-
const accBaseHasMarkers = accumulatorEntry != null && (accumulatorEntry.content.includes("<<<<<<< Generated") || accumulatorEntry.content.includes(">>>>>>> Your customization"));
|
|
1817
|
-
if (theirsHasMarkers && !accBaseHasMarkers && !(base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization")))) {
|
|
1818
|
-
return {
|
|
1819
|
-
file: resolvedPath,
|
|
1820
|
-
status: "skipped",
|
|
1821
|
-
reason: "stale-conflict-markers"
|
|
1822
|
-
};
|
|
1823
|
-
}
|
|
1940
|
+
if (files.length === 0) {
|
|
1941
|
+
continue;
|
|
1824
1942
|
}
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
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;
|
|
1948
|
+
}
|
|
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 {
|
|
1973
|
+
}
|
|
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;
|
|
1841
1983
|
}
|
|
1842
1984
|
}
|
|
1843
|
-
if (
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
await (0, import_promises.writeFile)(oursPath, effective_theirs);
|
|
1851
|
-
return { file: resolvedPath, status: "merged", reason: "new-file" };
|
|
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
|
+
}
|
|
1852
1992
|
}
|
|
1853
|
-
if (
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
conflictReason: "new-file-both",
|
|
1864
|
-
conflictMetadata: metadata
|
|
1865
|
-
};
|
|
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;
|
|
1866
2003
|
}
|
|
1867
|
-
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
1868
|
-
content: merged2.content,
|
|
1869
|
-
baseGeneration: patch.base_generation
|
|
1870
|
-
});
|
|
1871
|
-
return { file: resolvedPath, status: "merged" };
|
|
1872
2004
|
}
|
|
1873
|
-
if (
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
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
|
+
}
|
|
1879
2013
|
}
|
|
1880
|
-
if (
|
|
1881
|
-
|
|
1882
|
-
file: resolvedPath,
|
|
1883
|
-
status: "skipped",
|
|
1884
|
-
reason: "missing-content"
|
|
1885
|
-
};
|
|
2014
|
+
if (!matchedExisting && !matchedNew) {
|
|
2015
|
+
revertIndicesToRemove.add(i);
|
|
1886
2016
|
}
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
2017
|
+
}
|
|
2018
|
+
const filteredPatches = survivingPatches.filter((_, i) => !revertIndicesToRemove.has(i));
|
|
2019
|
+
return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
|
|
2020
|
+
}
|
|
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")}`;
|
|
2044
|
+
}
|
|
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);
|
|
1894
2085
|
}
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
content: effective_theirs,
|
|
1902
|
-
baseGeneration: patch.base_generation
|
|
1903
|
-
});
|
|
2086
|
+
}
|
|
2087
|
+
let anyCandidate = false;
|
|
2088
|
+
for (const state of stateByFile.values()) {
|
|
2089
|
+
if (state.created && state.deleted) {
|
|
2090
|
+
anyCandidate = true;
|
|
2091
|
+
break;
|
|
1904
2092
|
}
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
};
|
|
2093
|
+
}
|
|
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);
|
|
1913
2100
|
}
|
|
1914
|
-
return { file: resolvedPath, status: "merged" };
|
|
1915
|
-
} catch (error) {
|
|
1916
|
-
return {
|
|
1917
|
-
file: filePath,
|
|
1918
|
-
status: "skipped",
|
|
1919
|
-
reason: `error: ${error instanceof Error ? error.message : String(error)}`
|
|
1920
|
-
};
|
|
1921
2101
|
}
|
|
2102
|
+
if (transient.size === 0) return patches;
|
|
2103
|
+
return patches.filter((patch) => !patch.files.every((f) => transient.has(f)));
|
|
1922
2104
|
}
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
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() {
|
|
2112
|
+
try {
|
|
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));
|
|
2115
|
+
} catch {
|
|
2116
|
+
return /* @__PURE__ */ new Set();
|
|
1928
2117
|
}
|
|
1929
|
-
return result;
|
|
1930
|
-
}
|
|
1931
|
-
isExcluded(patch) {
|
|
1932
|
-
const config = this.lockManager.getCustomizationsConfig();
|
|
1933
|
-
if (!config.exclude) return false;
|
|
1934
|
-
return patch.files.some((file) => config.exclude.some((pattern) => (0, import_minimatch.minimatch)(file, pattern)));
|
|
1935
2118
|
}
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
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
|
+
);
|
|
1951
2141
|
}
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
2142
|
+
if (files.length === 0) return null;
|
|
2143
|
+
const diff = await this.git.exec([
|
|
2144
|
+
"diff",
|
|
2145
|
+
firstParent,
|
|
2146
|
+
mergeCommit.sha,
|
|
2147
|
+
"--",
|
|
2148
|
+
...files
|
|
2149
|
+
]);
|
|
2150
|
+
if (!diff.trim()) return null;
|
|
2151
|
+
const contentHash = this.computeContentHash(diff);
|
|
2152
|
+
if (lock.patches.some((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
|
|
2153
|
+
return null;
|
|
1957
2154
|
}
|
|
1958
|
-
const
|
|
1959
|
-
|
|
1960
|
-
|
|
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;
|
|
1961
2160
|
}
|
|
1962
|
-
return
|
|
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
|
+
};
|
|
1963
2173
|
}
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
2174
|
+
/**
|
|
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.
|
|
2178
|
+
*/
|
|
2179
|
+
async detectPatchesViaTreeDiff(lastGen, commitKnownMissing) {
|
|
2180
|
+
const diffBase = await this.resolveDiffBase(lastGen, commitKnownMissing);
|
|
2181
|
+
if (!diffBase) {
|
|
2182
|
+
return this.detectPatchesViaCommitScan();
|
|
1967
2183
|
}
|
|
1968
|
-
const
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
await
|
|
1990
|
-
|
|
1991
|
-
await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
|
|
1992
|
-
return await (0, import_promises.readFile)(tempFilePath, "utf-8");
|
|
1993
|
-
} catch {
|
|
1994
|
-
return null;
|
|
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;
|
|
1995
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: [] };
|
|
1996
2223
|
}
|
|
1997
2224
|
/**
|
|
1998
|
-
*
|
|
1999
|
-
*
|
|
2000
|
-
*
|
|
2001
|
-
*
|
|
2002
|
-
*
|
|
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).
|
|
2003
2231
|
*/
|
|
2004
|
-
async
|
|
2005
|
-
const
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
return false;
|
|
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: [] };
|
|
2017
2244
|
}
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
const
|
|
2021
|
-
const
|
|
2022
|
-
|
|
2023
|
-
for (const
|
|
2024
|
-
if (
|
|
2025
|
-
if (inTargetFile) {
|
|
2026
|
-
break;
|
|
2027
|
-
}
|
|
2028
|
-
if (isDiffLineForFile(line, filePath)) {
|
|
2029
|
-
inTargetFile = true;
|
|
2030
|
-
diffLines.push(line);
|
|
2031
|
-
}
|
|
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)) {
|
|
2032
2252
|
continue;
|
|
2033
2253
|
}
|
|
2034
|
-
|
|
2035
|
-
|
|
2254
|
+
const parents = await this.git.getCommitParents(commit.sha);
|
|
2255
|
+
if (parents.length > 1) {
|
|
2256
|
+
continue;
|
|
2036
2257
|
}
|
|
2037
|
-
|
|
2038
|
-
return diffLines.length > 0 ? diffLines.join("\n") + "\n" : null;
|
|
2039
|
-
}
|
|
2040
|
-
extractRenameSource(patchContent, targetFilePath) {
|
|
2041
|
-
const lines = patchContent.split("\n");
|
|
2042
|
-
let inTargetFile = false;
|
|
2043
|
-
for (const line of lines) {
|
|
2044
|
-
if (line.startsWith("diff --git")) {
|
|
2045
|
-
if (inTargetFile) break;
|
|
2046
|
-
inTargetFile = isDiffLineForFile(line, targetFilePath);
|
|
2258
|
+
if (existingCommits.has(commit.sha)) {
|
|
2047
2259
|
continue;
|
|
2048
2260
|
}
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2261
|
+
let patchContent;
|
|
2262
|
+
try {
|
|
2263
|
+
patchContent = await this.git.formatPatch(commit.sha);
|
|
2264
|
+
} catch {
|
|
2265
|
+
continue;
|
|
2053
2266
|
}
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
}
|
|
2057
|
-
extractNewFileFromPatch(patchContent, filePath) {
|
|
2058
|
-
const lines = patchContent.split("\n");
|
|
2059
|
-
const addedLines = [];
|
|
2060
|
-
let inTargetFile = false;
|
|
2061
|
-
let inHunk = false;
|
|
2062
|
-
let isNewFile = false;
|
|
2063
|
-
let noTrailingNewline = false;
|
|
2064
|
-
for (const line of lines) {
|
|
2065
|
-
if (line.startsWith("diff --git")) {
|
|
2066
|
-
if (inTargetFile) break;
|
|
2067
|
-
inTargetFile = isDiffLineForFile(line, filePath);
|
|
2068
|
-
inHunk = false;
|
|
2069
|
-
isNewFile = false;
|
|
2267
|
+
let contentHash = this.computeContentHash(patchContent);
|
|
2268
|
+
if (existingHashes.has(contentHash) || forgottenHashes.has(contentHash)) {
|
|
2070
2269
|
continue;
|
|
2071
2270
|
}
|
|
2072
|
-
if (
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
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);
|
|
2076
2293
|
}
|
|
2077
2294
|
}
|
|
2078
|
-
if (
|
|
2079
|
-
if (!isNewFile) return null;
|
|
2080
|
-
inHunk = true;
|
|
2295
|
+
if (files.length === 0) {
|
|
2081
2296
|
continue;
|
|
2082
2297
|
}
|
|
2083
|
-
if (
|
|
2084
|
-
if (line === "\") {
|
|
2085
|
-
noTrailingNewline = true;
|
|
2298
|
+
if (parents.length === 0) {
|
|
2086
2299
|
continue;
|
|
2087
2300
|
}
|
|
2088
|
-
|
|
2089
|
-
|
|
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;
|
|
2090
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
|
+
});
|
|
2319
|
+
}
|
|
2320
|
+
newPatches.reverse();
|
|
2321
|
+
return { patches: newPatches, revertedPatchIds: [] };
|
|
2322
|
+
}
|
|
2323
|
+
/**
|
|
2324
|
+
* Check if a format-patch consists entirely of new-file creations.
|
|
2325
|
+
* Used to identify squashed commits after force push, which create all files
|
|
2326
|
+
* from scratch (--- /dev/null) rather than modifying existing files.
|
|
2327
|
+
*/
|
|
2328
|
+
isCreationOnlyPatch(patchContent) {
|
|
2329
|
+
const diffOldHeaders = patchContent.split("\n").filter((l) => l.startsWith("--- "));
|
|
2330
|
+
if (diffOldHeaders.length === 0) {
|
|
2331
|
+
return false;
|
|
2091
2332
|
}
|
|
2092
|
-
|
|
2093
|
-
|
|
2333
|
+
return diffOldHeaders.every((l) => l === "--- /dev/null");
|
|
2334
|
+
}
|
|
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);
|
|
2094
2357
|
}
|
|
2095
2358
|
};
|
|
2096
|
-
function isBinaryFile(filePath) {
|
|
2097
|
-
const ext = (0, import_node_path2.extname)(filePath).toLowerCase();
|
|
2098
|
-
return BINARY_EXTENSIONS.has(ext);
|
|
2099
|
-
}
|
|
2100
|
-
function isDiffLineForFile(diffLine, filePath) {
|
|
2101
|
-
const match = diffLine.match(/^diff --git a\/.+ b\/(.+)$/);
|
|
2102
|
-
return match !== null && match[1] === filePath;
|
|
2103
|
-
}
|
|
2104
2359
|
|
|
2105
2360
|
// src/ReplayCommitter.ts
|
|
2106
2361
|
var ReplayCommitter = class {
|
|
@@ -2132,13 +2387,46 @@ CLI Version: ${options.cliVersion}`;
|
|
|
2132
2387
|
await this.git.exec(["commit", "-m", fullMessage]);
|
|
2133
2388
|
return (await this.git.exec(["rev-parse", "HEAD"])).trim();
|
|
2134
2389
|
}
|
|
2135
|
-
async commitReplay(_patchCount, patches, message) {
|
|
2390
|
+
async commitReplay(_patchCount, patches, message, options) {
|
|
2136
2391
|
await this.stageAll();
|
|
2137
2392
|
if (!await this.hasStagedChanges()) {
|
|
2138
2393
|
return (await this.git.exec(["rev-parse", "HEAD"])).trim();
|
|
2139
2394
|
}
|
|
2140
2395
|
let fullMessage = message ?? `[fern-replay] Applied customizations`;
|
|
2141
|
-
|
|
2396
|
+
const buckets = options?.buckets;
|
|
2397
|
+
if (buckets) {
|
|
2398
|
+
if (buckets.applied.length > 0) {
|
|
2399
|
+
fullMessage += `
|
|
2400
|
+
|
|
2401
|
+
Patches applied (${buckets.applied.length}):`;
|
|
2402
|
+
for (const p of buckets.applied) {
|
|
2403
|
+
fullMessage += `
|
|
2404
|
+
- ${p.id}: ${p.original_message}`;
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
2407
|
+
if (buckets.unresolved.length > 0) {
|
|
2408
|
+
fullMessage += `
|
|
2409
|
+
|
|
2410
|
+
Patches with unresolved conflicts (${buckets.unresolved.length}):`;
|
|
2411
|
+
for (const p of buckets.unresolved) {
|
|
2412
|
+
fullMessage += `
|
|
2413
|
+
- ${p.id}: ${p.original_message}`;
|
|
2414
|
+
}
|
|
2415
|
+
fullMessage += `
|
|
2416
|
+
Run \`fern-replay resolve\` to apply these customizations.`;
|
|
2417
|
+
}
|
|
2418
|
+
if (buckets.absorbed.length > 0) {
|
|
2419
|
+
fullMessage += `
|
|
2420
|
+
|
|
2421
|
+
Patches absorbed by generator (${buckets.absorbed.length}):`;
|
|
2422
|
+
for (const p of buckets.absorbed) {
|
|
2423
|
+
fullMessage += `
|
|
2424
|
+
- ${p.id}: ${p.original_message}`;
|
|
2425
|
+
}
|
|
2426
|
+
fullMessage += `
|
|
2427
|
+
The generator now produces these customizations natively.`;
|
|
2428
|
+
}
|
|
2429
|
+
} else if (patches && patches.length > 0) {
|
|
2142
2430
|
fullMessage += "\n\nPatches replayed:";
|
|
2143
2431
|
for (const patch of patches) {
|
|
2144
2432
|
fullMessage += `
|
|
@@ -2174,9 +2462,304 @@ CLI Version: ${options.cliVersion}`;
|
|
|
2174
2462
|
|
|
2175
2463
|
// src/ReplayService.ts
|
|
2176
2464
|
var import_node_fs2 = require("fs");
|
|
2177
|
-
var
|
|
2465
|
+
var import_promises4 = require("fs/promises");
|
|
2466
|
+
var import_node_path5 = require("path");
|
|
2178
2467
|
var import_minimatch2 = require("minimatch");
|
|
2179
2468
|
init_GitClient();
|
|
2469
|
+
|
|
2470
|
+
// src/PatchRegionDiff.ts
|
|
2471
|
+
var import_promises2 = require("fs/promises");
|
|
2472
|
+
var import_node_os2 = require("os");
|
|
2473
|
+
var import_node_path3 = require("path");
|
|
2474
|
+
var import_node_child_process = require("child_process");
|
|
2475
|
+
init_HybridReconstruction();
|
|
2476
|
+
function normalizeHunkBody(hunk) {
|
|
2477
|
+
let contextC = 0;
|
|
2478
|
+
let removeC = 0;
|
|
2479
|
+
let addC = 0;
|
|
2480
|
+
const trimmed = [];
|
|
2481
|
+
for (const line of hunk.lines) {
|
|
2482
|
+
const oldUsed = contextC + removeC;
|
|
2483
|
+
const newUsed = contextC + addC;
|
|
2484
|
+
if (oldUsed >= hunk.oldCount && newUsed >= hunk.newCount) break;
|
|
2485
|
+
trimmed.push(line);
|
|
2486
|
+
if (line.type === "context") contextC++;
|
|
2487
|
+
else if (line.type === "remove") removeC++;
|
|
2488
|
+
else if (line.type === "add") addC++;
|
|
2489
|
+
}
|
|
2490
|
+
return { ...hunk, lines: trimmed };
|
|
2491
|
+
}
|
|
2492
|
+
function extractFileDiffForFile(patchContent, filePath) {
|
|
2493
|
+
const lines = patchContent.split("\n");
|
|
2494
|
+
const out = [];
|
|
2495
|
+
let inTarget = false;
|
|
2496
|
+
for (const line of lines) {
|
|
2497
|
+
if (line.startsWith("diff --git")) {
|
|
2498
|
+
if (inTarget) break;
|
|
2499
|
+
if (isDiffLineForFile2(line, filePath)) {
|
|
2500
|
+
inTarget = true;
|
|
2501
|
+
out.push(line);
|
|
2502
|
+
}
|
|
2503
|
+
continue;
|
|
2504
|
+
}
|
|
2505
|
+
if (inTarget) out.push(line);
|
|
2506
|
+
}
|
|
2507
|
+
return out.length > 0 ? out.join("\n") + "\n" : null;
|
|
2508
|
+
}
|
|
2509
|
+
function isDiffLineForFile2(line, filePath) {
|
|
2510
|
+
const match = line.match(/^diff --git a\/(.+) b\/(.+)$/);
|
|
2511
|
+
return match !== null && (match[2] === filePath || match[1] === filePath);
|
|
2512
|
+
}
|
|
2513
|
+
async function computePerPatchDiff(patch, getCurrentGenContent, getWorkingTreeContent) {
|
|
2514
|
+
const fragments = [];
|
|
2515
|
+
const unlocatableFiles = [];
|
|
2516
|
+
for (const file of patch.files) {
|
|
2517
|
+
const fileDiff = extractFileDiffForFile(patch.patch_content, file);
|
|
2518
|
+
if (fileDiff == null) {
|
|
2519
|
+
unlocatableFiles.push(file);
|
|
2520
|
+
continue;
|
|
2521
|
+
}
|
|
2522
|
+
const hunksRaw = parseHunks(fileDiff).map(normalizeHunkBody);
|
|
2523
|
+
const hunks = hunksRaw.filter((h) => {
|
|
2524
|
+
if (h.lines.length === 0) return false;
|
|
2525
|
+
let ctx = 0, rm4 = 0, add = 0;
|
|
2526
|
+
for (const ln of h.lines) {
|
|
2527
|
+
if (ln.type === "context") ctx++;
|
|
2528
|
+
else if (ln.type === "remove") rm4++;
|
|
2529
|
+
else if (ln.type === "add") add++;
|
|
2530
|
+
}
|
|
2531
|
+
return ctx + rm4 === h.oldCount && ctx + add === h.newCount;
|
|
2532
|
+
});
|
|
2533
|
+
if (hunks.length === 0) {
|
|
2534
|
+
if (hunksRaw.length > 0) unlocatableFiles.push(file);
|
|
2535
|
+
continue;
|
|
2536
|
+
}
|
|
2537
|
+
const currentGen = await getCurrentGenContent(file);
|
|
2538
|
+
const workingTree = await getWorkingTreeContent(file);
|
|
2539
|
+
const isPureNewFile = hunks.every((h) => h.oldCount === 0);
|
|
2540
|
+
const isPureDelete = hunks.every((h) => h.newCount === 0);
|
|
2541
|
+
if (currentGen == null) {
|
|
2542
|
+
if (!isPureNewFile) {
|
|
2543
|
+
unlocatableFiles.push(file);
|
|
2544
|
+
continue;
|
|
2545
|
+
}
|
|
2546
|
+
if (workingTree == null) continue;
|
|
2547
|
+
fragments.push(synthesizeNewFileDiff(file, workingTree));
|
|
2548
|
+
continue;
|
|
2549
|
+
}
|
|
2550
|
+
if (isPureNewFile) {
|
|
2551
|
+
if (workingTree == null) {
|
|
2552
|
+
fragments.push(synthesizeDeleteFileDiff(file, currentGen));
|
|
2553
|
+
continue;
|
|
2554
|
+
}
|
|
2555
|
+
if (workingTree === currentGen) {
|
|
2556
|
+
continue;
|
|
2557
|
+
}
|
|
2558
|
+
const fullDiff = await unifiedDiff(currentGen, workingTree, file);
|
|
2559
|
+
if (fullDiff && fullDiff.trim()) fragments.push(fullDiff);
|
|
2560
|
+
continue;
|
|
2561
|
+
}
|
|
2562
|
+
if (workingTree == null) {
|
|
2563
|
+
if (!isPureDelete) {
|
|
2564
|
+
unlocatableFiles.push(file);
|
|
2565
|
+
continue;
|
|
2566
|
+
}
|
|
2567
|
+
fragments.push(synthesizeDeleteFileDiff(file, currentGen));
|
|
2568
|
+
continue;
|
|
2569
|
+
}
|
|
2570
|
+
const currentGenLines = splitLines(currentGen);
|
|
2571
|
+
const workingTreeLines = splitLines(workingTree);
|
|
2572
|
+
const locInCurrentGenRaw = locateHunksInOurs(hunks, currentGenLines);
|
|
2573
|
+
const locInWorkingTreeRaw = locateHunksInOurs(hunks, workingTreeLines);
|
|
2574
|
+
if (locInCurrentGenRaw == null || locInWorkingTreeRaw == null) {
|
|
2575
|
+
unlocatableFiles.push(file);
|
|
2576
|
+
continue;
|
|
2577
|
+
}
|
|
2578
|
+
const locInCurrentGen = locInCurrentGenRaw.map(
|
|
2579
|
+
(l) => resolveSpan(l, currentGenLines, "old")
|
|
2580
|
+
);
|
|
2581
|
+
const locInWorkingTree = locInWorkingTreeRaw.map(
|
|
2582
|
+
(l) => resolveSpan(l, workingTreeLines, "new")
|
|
2583
|
+
);
|
|
2584
|
+
const overlay = overlayRegions(
|
|
2585
|
+
currentGenLines,
|
|
2586
|
+
locInCurrentGen,
|
|
2587
|
+
workingTreeLines,
|
|
2588
|
+
locInWorkingTree
|
|
2589
|
+
);
|
|
2590
|
+
if (overlay === null) {
|
|
2591
|
+
unlocatableFiles.push(file);
|
|
2592
|
+
continue;
|
|
2593
|
+
}
|
|
2594
|
+
if (overlay === currentGen) {
|
|
2595
|
+
continue;
|
|
2596
|
+
}
|
|
2597
|
+
const fileUnifiedDiff = await unifiedDiff(currentGen, overlay, file);
|
|
2598
|
+
if (fileUnifiedDiff && fileUnifiedDiff.trim()) {
|
|
2599
|
+
fragments.push(fileUnifiedDiff);
|
|
2600
|
+
}
|
|
2601
|
+
}
|
|
2602
|
+
return {
|
|
2603
|
+
diff: fragments.join(""),
|
|
2604
|
+
unlocatableFiles
|
|
2605
|
+
};
|
|
2606
|
+
}
|
|
2607
|
+
async function computePerPatchDiffWithFallback(patch, getCurrentGenContent, getWorkingTreeContent, runCumulativeDiff) {
|
|
2608
|
+
const ppDiff = await computePerPatchDiff(
|
|
2609
|
+
patch,
|
|
2610
|
+
getCurrentGenContent,
|
|
2611
|
+
getWorkingTreeContent
|
|
2612
|
+
);
|
|
2613
|
+
if (ppDiff.unlocatableFiles.length === 0) {
|
|
2614
|
+
return { diff: ppDiff.diff, hadFallback: false, fallbackFiles: [] };
|
|
2615
|
+
}
|
|
2616
|
+
const cumulative = await runCumulativeDiff(ppDiff.unlocatableFiles);
|
|
2617
|
+
if (cumulative === null) {
|
|
2618
|
+
return null;
|
|
2619
|
+
}
|
|
2620
|
+
const merged = ppDiff.diff + (cumulative.trim() ? cumulative : "");
|
|
2621
|
+
return { diff: merged, hadFallback: true, fallbackFiles: ppDiff.unlocatableFiles };
|
|
2622
|
+
}
|
|
2623
|
+
function splitLines(content) {
|
|
2624
|
+
return content.split("\n");
|
|
2625
|
+
}
|
|
2626
|
+
function resolveSpan(loc, oursLines, prefer) {
|
|
2627
|
+
const { hunk, oursOffset } = loc;
|
|
2628
|
+
const oldSide = [];
|
|
2629
|
+
const newSide = [];
|
|
2630
|
+
for (const line of hunk.lines) {
|
|
2631
|
+
if (line.type === "context") {
|
|
2632
|
+
oldSide.push(line.content);
|
|
2633
|
+
newSide.push(line.content);
|
|
2634
|
+
} else if (line.type === "remove") {
|
|
2635
|
+
oldSide.push(line.content);
|
|
2636
|
+
} else if (line.type === "add") {
|
|
2637
|
+
newSide.push(line.content);
|
|
2638
|
+
}
|
|
2639
|
+
}
|
|
2640
|
+
const oldFits = sequenceMatches(oldSide, oursLines, oursOffset);
|
|
2641
|
+
const newFits = sequenceMatches(newSide, oursLines, oursOffset);
|
|
2642
|
+
if (prefer === "old") {
|
|
2643
|
+
if (oldFits) return { ...loc, oursSpan: hunk.oldCount };
|
|
2644
|
+
if (newFits) return { ...loc, oursSpan: hunk.newCount };
|
|
2645
|
+
} else {
|
|
2646
|
+
if (newFits) return { ...loc, oursSpan: hunk.newCount };
|
|
2647
|
+
if (oldFits) return { ...loc, oursSpan: hunk.oldCount };
|
|
2648
|
+
}
|
|
2649
|
+
return loc;
|
|
2650
|
+
}
|
|
2651
|
+
function sequenceMatches(needle, haystack, offset) {
|
|
2652
|
+
if (offset + needle.length > haystack.length) return false;
|
|
2653
|
+
for (let i = 0; i < needle.length; i++) {
|
|
2654
|
+
if (haystack[offset + i] !== needle[i]) return false;
|
|
2655
|
+
}
|
|
2656
|
+
return true;
|
|
2657
|
+
}
|
|
2658
|
+
function overlayRegions(currentGenLines, locInCurrentGen, workingTreeLines, locInWorkingTree) {
|
|
2659
|
+
if (locInCurrentGen.length !== locInWorkingTree.length) {
|
|
2660
|
+
return null;
|
|
2661
|
+
}
|
|
2662
|
+
const out = [];
|
|
2663
|
+
let cursor = 0;
|
|
2664
|
+
for (let i = 0; i < locInCurrentGen.length; i++) {
|
|
2665
|
+
const cg = locInCurrentGen[i];
|
|
2666
|
+
const wt = locInWorkingTree[i];
|
|
2667
|
+
if (cg.oursOffset > cursor) {
|
|
2668
|
+
out.push(...currentGenLines.slice(cursor, cg.oursOffset));
|
|
2669
|
+
}
|
|
2670
|
+
out.push(...workingTreeLines.slice(wt.oursOffset, wt.oursOffset + wt.oursSpan));
|
|
2671
|
+
cursor = cg.oursOffset + cg.oursSpan;
|
|
2672
|
+
}
|
|
2673
|
+
if (cursor < currentGenLines.length) {
|
|
2674
|
+
out.push(...currentGenLines.slice(cursor));
|
|
2675
|
+
}
|
|
2676
|
+
return out.join("\n");
|
|
2677
|
+
}
|
|
2678
|
+
async function unifiedDiff(beforeContent, afterContent, filePath) {
|
|
2679
|
+
if (beforeContent === afterContent) return "";
|
|
2680
|
+
const tmp = await (0, import_promises2.mkdtemp)((0, import_node_path3.join)((0, import_node_os2.tmpdir)(), "fern-replay-pp-"));
|
|
2681
|
+
try {
|
|
2682
|
+
const beforePath = (0, import_node_path3.join)(tmp, "before");
|
|
2683
|
+
const afterPath = (0, import_node_path3.join)(tmp, "after");
|
|
2684
|
+
await (0, import_promises2.writeFile)(beforePath, beforeContent, "utf-8");
|
|
2685
|
+
await (0, import_promises2.writeFile)(afterPath, afterContent, "utf-8");
|
|
2686
|
+
const raw = await runGitDiffNoIndex(beforePath, afterPath);
|
|
2687
|
+
if (!raw.trim()) return "";
|
|
2688
|
+
return rewriteDiffHeaders(raw, filePath);
|
|
2689
|
+
} finally {
|
|
2690
|
+
await (0, import_promises2.rm)(tmp, { recursive: true, force: true });
|
|
2691
|
+
}
|
|
2692
|
+
}
|
|
2693
|
+
function runGitDiffNoIndex(beforePath, afterPath) {
|
|
2694
|
+
return new Promise((resolve2, reject) => {
|
|
2695
|
+
const proc = (0, import_node_child_process.spawn)("git", ["diff", "--no-index", "--", beforePath, afterPath]);
|
|
2696
|
+
let stdout = "";
|
|
2697
|
+
let stderr = "";
|
|
2698
|
+
proc.stdout.on("data", (d) => stdout += d.toString());
|
|
2699
|
+
proc.stderr.on("data", (d) => stderr += d.toString());
|
|
2700
|
+
proc.on("close", (code) => {
|
|
2701
|
+
if (code === 0 || code === 1) resolve2(stdout);
|
|
2702
|
+
else reject(new Error(`git diff --no-index failed (code ${code}): ${stderr}`));
|
|
2703
|
+
});
|
|
2704
|
+
});
|
|
2705
|
+
}
|
|
2706
|
+
function rewriteDiffHeaders(raw, filePath) {
|
|
2707
|
+
const lines = raw.split("\n");
|
|
2708
|
+
const out = [];
|
|
2709
|
+
let seenFirstHeader = false;
|
|
2710
|
+
for (const line of lines) {
|
|
2711
|
+
if (line.startsWith("diff --git ")) {
|
|
2712
|
+
out.push(`diff --git a/${filePath} b/${filePath}`);
|
|
2713
|
+
seenFirstHeader = true;
|
|
2714
|
+
continue;
|
|
2715
|
+
}
|
|
2716
|
+
if (!seenFirstHeader) {
|
|
2717
|
+
continue;
|
|
2718
|
+
}
|
|
2719
|
+
if (line.startsWith("--- ")) {
|
|
2720
|
+
out.push(`--- a/${filePath}`);
|
|
2721
|
+
continue;
|
|
2722
|
+
}
|
|
2723
|
+
if (line.startsWith("+++ ")) {
|
|
2724
|
+
out.push(`+++ b/${filePath}`);
|
|
2725
|
+
continue;
|
|
2726
|
+
}
|
|
2727
|
+
out.push(line);
|
|
2728
|
+
}
|
|
2729
|
+
return out.join("\n");
|
|
2730
|
+
}
|
|
2731
|
+
function synthesizeNewFileDiff(file, content) {
|
|
2732
|
+
const lines = content.split("\n");
|
|
2733
|
+
const hasTrailingNewline = content.endsWith("\n");
|
|
2734
|
+
const bodyLines = hasTrailingNewline ? lines.slice(0, -1) : lines;
|
|
2735
|
+
const header = [
|
|
2736
|
+
`diff --git a/${file} b/${file}`,
|
|
2737
|
+
"new file mode 100644",
|
|
2738
|
+
"--- /dev/null",
|
|
2739
|
+
`+++ b/${file}`,
|
|
2740
|
+
`@@ -0,0 +1,${bodyLines.length} @@`
|
|
2741
|
+
].join("\n");
|
|
2742
|
+
const body = bodyLines.map((l) => `+${l}`).join("\n");
|
|
2743
|
+
const trailer = hasTrailingNewline ? "" : "\n\";
|
|
2744
|
+
return header + "\n" + body + trailer + "\n";
|
|
2745
|
+
}
|
|
2746
|
+
function synthesizeDeleteFileDiff(file, content) {
|
|
2747
|
+
const lines = content.split("\n");
|
|
2748
|
+
const hasTrailingNewline = content.endsWith("\n");
|
|
2749
|
+
const bodyLines = hasTrailingNewline ? lines.slice(0, -1) : lines;
|
|
2750
|
+
const header = [
|
|
2751
|
+
`diff --git a/${file} b/${file}`,
|
|
2752
|
+
"deleted file mode 100644",
|
|
2753
|
+
`--- a/${file}`,
|
|
2754
|
+
"+++ /dev/null",
|
|
2755
|
+
`@@ -1,${bodyLines.length} +0,0 @@`
|
|
2756
|
+
].join("\n");
|
|
2757
|
+
const body = bodyLines.map((l) => `-${l}`).join("\n");
|
|
2758
|
+
const trailer = hasTrailingNewline ? "" : "\n\";
|
|
2759
|
+
return header + "\n" + body + trailer + "\n";
|
|
2760
|
+
}
|
|
2761
|
+
|
|
2762
|
+
// src/ReplayService.ts
|
|
2180
2763
|
var ReplayService = class {
|
|
2181
2764
|
git;
|
|
2182
2765
|
detector;
|
|
@@ -2570,6 +3153,7 @@ var ReplayService = class {
|
|
|
2570
3153
|
if (newPatches.length > 0) {
|
|
2571
3154
|
results = await this.applicator.applyPatches(newPatches);
|
|
2572
3155
|
this._lastApplyResults = results;
|
|
3156
|
+
this.recordDroppedContextLineWarnings(results);
|
|
2573
3157
|
this.revertConflictingFiles(results);
|
|
2574
3158
|
for (const result of results) {
|
|
2575
3159
|
if (result.status === "conflict") {
|
|
@@ -2588,7 +3172,8 @@ var ReplayService = class {
|
|
|
2588
3172
|
if (newPatches.length > 0) {
|
|
2589
3173
|
if (!options?.stageOnly) {
|
|
2590
3174
|
const appliedCount = results.filter((r) => r.status === "applied").length;
|
|
2591
|
-
|
|
3175
|
+
const buckets = computeCommitBuckets(results, rebaseCounts.absorbedPatchIds, newPatches);
|
|
3176
|
+
await this.committer.commitReplay(appliedCount, newPatches, void 0, { buckets });
|
|
2592
3177
|
} else {
|
|
2593
3178
|
await this.committer.stageAll();
|
|
2594
3179
|
}
|
|
@@ -2651,12 +3236,16 @@ var ReplayService = class {
|
|
|
2651
3236
|
(p) => preRebasePatchIds.has(p.id) && !postRebasePatchIds.has(p.id)
|
|
2652
3237
|
);
|
|
2653
3238
|
existingPatches = this.lockManager.getPatches();
|
|
2654
|
-
const seenHashes = /* @__PURE__ */ new
|
|
3239
|
+
const seenHashes = /* @__PURE__ */ new Map();
|
|
2655
3240
|
for (const p of existingPatches) {
|
|
2656
|
-
|
|
3241
|
+
const priorPatchId = seenHashes.get(p.content_hash);
|
|
3242
|
+
if (priorPatchId !== void 0) {
|
|
2657
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
|
+
);
|
|
2658
3247
|
} else {
|
|
2659
|
-
seenHashes.
|
|
3248
|
+
seenHashes.set(p.content_hash, p.id);
|
|
2660
3249
|
}
|
|
2661
3250
|
}
|
|
2662
3251
|
existingPatches = this.lockManager.getPatches();
|
|
@@ -2717,6 +3306,7 @@ var ReplayService = class {
|
|
|
2717
3306
|
const genSha = prep._prepared.genSha;
|
|
2718
3307
|
const results = await this.applicator.applyPatches(allPatches);
|
|
2719
3308
|
this._lastApplyResults = results;
|
|
3309
|
+
this.recordDroppedContextLineWarnings(results);
|
|
2720
3310
|
this.revertConflictingFiles(results);
|
|
2721
3311
|
for (const result of results) {
|
|
2722
3312
|
if (result.status === "conflict") {
|
|
@@ -2744,7 +3334,8 @@ var ReplayService = class {
|
|
|
2744
3334
|
await this.committer.stageAll();
|
|
2745
3335
|
} else {
|
|
2746
3336
|
const appliedCount = results.filter((r) => r.status === "applied").length;
|
|
2747
|
-
|
|
3337
|
+
const buckets = computeCommitBuckets(results, rebaseCounts.absorbedPatchIds, allPatches);
|
|
3338
|
+
await this.committer.commitReplay(appliedCount, allPatches, void 0, { buckets });
|
|
2748
3339
|
}
|
|
2749
3340
|
const warnings = [...detectorWarnings, ...this.warnings];
|
|
2750
3341
|
return this.buildReport(
|
|
@@ -2768,7 +3359,7 @@ var ReplayService = class {
|
|
|
2768
3359
|
let repointed = 0;
|
|
2769
3360
|
let contentRebased = 0;
|
|
2770
3361
|
let keptAsUserOwned = 0;
|
|
2771
|
-
const seenContentHashes = /* @__PURE__ */ new
|
|
3362
|
+
const seenContentHashes = /* @__PURE__ */ new Map();
|
|
2772
3363
|
const absorbedPatchIds = /* @__PURE__ */ new Set();
|
|
2773
3364
|
const fernignorePatterns = this.readFernignorePatterns();
|
|
2774
3365
|
for (const result of results) {
|
|
@@ -2782,6 +3373,23 @@ var ReplayService = class {
|
|
|
2782
3373
|
}
|
|
2783
3374
|
}
|
|
2784
3375
|
}
|
|
3376
|
+
const conflictedFilePaths = /* @__PURE__ */ new Set();
|
|
3377
|
+
for (const r of results) {
|
|
3378
|
+
if (r.status !== "conflict") continue;
|
|
3379
|
+
if (r.fileResults) {
|
|
3380
|
+
for (const fr of r.fileResults) {
|
|
3381
|
+
if (fr.status !== "conflict") continue;
|
|
3382
|
+
conflictedFilePaths.add(fr.file);
|
|
3383
|
+
if (r.resolvedFiles) {
|
|
3384
|
+
for (const [orig, resolved] of Object.entries(r.resolvedFiles)) {
|
|
3385
|
+
if (resolved === fr.file) conflictedFilePaths.add(orig);
|
|
3386
|
+
}
|
|
3387
|
+
}
|
|
3388
|
+
}
|
|
3389
|
+
} else {
|
|
3390
|
+
for (const f of r.patch.files) conflictedFilePaths.add(f);
|
|
3391
|
+
}
|
|
3392
|
+
}
|
|
2785
3393
|
for (const result of results) {
|
|
2786
3394
|
if (result.status === "conflict" && result.fileResults) {
|
|
2787
3395
|
await this.trimAbsorbedFiles(result, currentGenSha);
|
|
@@ -2818,17 +3426,27 @@ var ReplayService = class {
|
|
|
2818
3426
|
if (allUserOwned && patch.files.length > 0) {
|
|
2819
3427
|
const baseChanged = patch.base_generation !== currentGenSha;
|
|
2820
3428
|
const userDiff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
|
|
3429
|
+
let userDiffSnapshot = null;
|
|
2821
3430
|
if (userDiff != null && userDiff.trim()) {
|
|
2822
3431
|
const newHash = this.detector.computeContentHash(userDiff);
|
|
2823
3432
|
patch.patch_content = userDiff;
|
|
2824
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
|
+
}
|
|
2825
3439
|
}
|
|
2826
3440
|
patch.base_generation = currentGenSha;
|
|
2827
3441
|
try {
|
|
2828
3442
|
this.lockManager.updatePatch(patch.id, {
|
|
2829
3443
|
base_generation: currentGenSha,
|
|
2830
3444
|
...!patch.user_owned ? { user_owned: true } : {},
|
|
2831
|
-
...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
|
+
} : {}
|
|
2832
3450
|
});
|
|
2833
3451
|
} catch {
|
|
2834
3452
|
}
|
|
@@ -2849,6 +3467,18 @@ var ReplayService = class {
|
|
|
2849
3467
|
keptAsUserOwned++;
|
|
2850
3468
|
continue;
|
|
2851
3469
|
}
|
|
3470
|
+
const overlapsConflicted = patch.files.some((f) => conflictedFilePaths.has(f));
|
|
3471
|
+
if (overlapsConflicted) {
|
|
3472
|
+
patch.status = "unresolved";
|
|
3473
|
+
try {
|
|
3474
|
+
this.lockManager.updatePatch(patch.id, { status: "unresolved" });
|
|
3475
|
+
} catch {
|
|
3476
|
+
}
|
|
3477
|
+
this.warnings.push(
|
|
3478
|
+
`Patch ${patch.id} (${patch.original_message}) was preserved as unresolved because an earlier patch conflicted on the same file(s) (${patch.files.join(", ")}). Run \`fern replay resolve\` to apply this customization manually.`
|
|
3479
|
+
);
|
|
3480
|
+
continue;
|
|
3481
|
+
}
|
|
2852
3482
|
this.lockManager.removePatch(patch.id);
|
|
2853
3483
|
absorbedPatchIds.add(patch.id);
|
|
2854
3484
|
absorbed++;
|
|
@@ -2864,21 +3494,34 @@ var ReplayService = class {
|
|
|
2864
3494
|
continue;
|
|
2865
3495
|
}
|
|
2866
3496
|
const newContentHash = this.detector.computeContentHash(diff);
|
|
2867
|
-
|
|
3497
|
+
const priorPatchId = seenContentHashes.get(newContentHash);
|
|
3498
|
+
if (priorPatchId !== void 0) {
|
|
2868
3499
|
this.lockManager.removePatch(patch.id);
|
|
2869
3500
|
absorbedPatchIds.add(patch.id);
|
|
2870
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
|
+
);
|
|
3506
|
+
}
|
|
2871
3507
|
continue;
|
|
3508
|
+
} else {
|
|
3509
|
+
seenContentHashes.set(newContentHash, patch.id);
|
|
2872
3510
|
}
|
|
2873
|
-
seenContentHashes.add(newContentHash);
|
|
2874
3511
|
patch.base_generation = currentGenSha;
|
|
2875
3512
|
patch.patch_content = diff;
|
|
2876
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
|
+
}
|
|
2877
3519
|
try {
|
|
2878
3520
|
this.lockManager.updatePatch(patch.id, {
|
|
2879
3521
|
base_generation: currentGenSha,
|
|
2880
3522
|
patch_content: diff,
|
|
2881
|
-
content_hash: newContentHash
|
|
3523
|
+
content_hash: newContentHash,
|
|
3524
|
+
...snapshotIsNonEmpty ? { theirs_snapshot: snapshot } : {}
|
|
2882
3525
|
});
|
|
2883
3526
|
} catch {
|
|
2884
3527
|
}
|
|
@@ -2888,6 +3531,112 @@ var ReplayService = class {
|
|
|
2888
3531
|
}
|
|
2889
3532
|
return { absorbed, repointed, contentRebased, keptAsUserOwned, absorbedPatchIds };
|
|
2890
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
|
+
}
|
|
2891
3640
|
/**
|
|
2892
3641
|
* Determine whether `file` is user-owned (never produced by the generator).
|
|
2893
3642
|
*
|
|
@@ -3008,6 +3757,20 @@ var ReplayService = class {
|
|
|
3008
3757
|
this.lockManager.clearReplaySkippedAt();
|
|
3009
3758
|
return { conflictResolved: 0, conflictAbsorbed: 0, contentRefreshed: 0 };
|
|
3010
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
|
+
};
|
|
3011
3774
|
let conflictResolved = 0;
|
|
3012
3775
|
let conflictAbsorbed = 0;
|
|
3013
3776
|
let contentRefreshed = 0;
|
|
@@ -3044,10 +3807,23 @@ var ReplayService = class {
|
|
|
3044
3807
|
delete patch.status;
|
|
3045
3808
|
continue;
|
|
3046
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
|
+
}
|
|
3047
3817
|
if (patch.base_generation === currentGen) {
|
|
3048
3818
|
try {
|
|
3049
|
-
const
|
|
3050
|
-
|
|
3819
|
+
const ppResult = await computePerPatchDiffWithFallback(
|
|
3820
|
+
patch,
|
|
3821
|
+
(f) => this.git.showFile(currentGen, f),
|
|
3822
|
+
(f) => this.git.showFile("HEAD", f),
|
|
3823
|
+
(files) => this.git.exec(["diff", currentGen, "HEAD", "--", ...files]).catch(() => null)
|
|
3824
|
+
);
|
|
3825
|
+
if (ppResult === null) continue;
|
|
3826
|
+
const diff = ppResult.diff;
|
|
3051
3827
|
if (!diff.trim()) {
|
|
3052
3828
|
if (await allPatchFilesUserOwned(patch)) continue;
|
|
3053
3829
|
this.lockManager.removePatch(patch.id);
|
|
@@ -3074,10 +3850,12 @@ var ReplayService = class {
|
|
|
3074
3850
|
if (newFiles.length === 0) {
|
|
3075
3851
|
this.lockManager.removePatch(patch.id);
|
|
3076
3852
|
} else {
|
|
3853
|
+
const snapshot = await this.readSnapshotFromTree("HEAD", newFiles);
|
|
3077
3854
|
this.lockManager.updatePatch(patch.id, {
|
|
3078
3855
|
patch_content: diff,
|
|
3079
3856
|
content_hash: newContentHash,
|
|
3080
|
-
files: newFiles
|
|
3857
|
+
files: newFiles,
|
|
3858
|
+
...Object.keys(snapshot).length > 0 ? { theirs_snapshot: snapshot } : {}
|
|
3081
3859
|
});
|
|
3082
3860
|
}
|
|
3083
3861
|
contentRefreshed++;
|
|
@@ -3089,8 +3867,14 @@ var ReplayService = class {
|
|
|
3089
3867
|
try {
|
|
3090
3868
|
const markerFiles = await this.git.exec(["grep", "-l", "<<<<<<< Generated", "HEAD", "--", ...patch.files]).catch(() => "");
|
|
3091
3869
|
if (markerFiles.trim()) continue;
|
|
3092
|
-
const
|
|
3093
|
-
|
|
3870
|
+
const ppResult = await computePerPatchDiffWithFallback(
|
|
3871
|
+
patch,
|
|
3872
|
+
(f) => this.git.showFile(currentGen, f),
|
|
3873
|
+
(f) => this.git.showFile("HEAD", f),
|
|
3874
|
+
(files) => this.git.exec(["diff", currentGen, "HEAD", "--", ...files]).catch(() => null)
|
|
3875
|
+
);
|
|
3876
|
+
if (ppResult === null) continue;
|
|
3877
|
+
const diff = ppResult.diff;
|
|
3094
3878
|
if (!diff.trim()) {
|
|
3095
3879
|
if (await allPatchFilesUserOwned(patch)) {
|
|
3096
3880
|
try {
|
|
@@ -3104,10 +3888,12 @@ var ReplayService = class {
|
|
|
3104
3888
|
continue;
|
|
3105
3889
|
}
|
|
3106
3890
|
const newContentHash = this.detector.computeContentHash(diff);
|
|
3891
|
+
const snapshot = await this.readSnapshotFromTree("HEAD", patch.files);
|
|
3107
3892
|
this.lockManager.updatePatch(patch.id, {
|
|
3108
3893
|
base_generation: currentGen,
|
|
3109
3894
|
patch_content: diff,
|
|
3110
|
-
content_hash: newContentHash
|
|
3895
|
+
content_hash: newContentHash,
|
|
3896
|
+
...Object.keys(snapshot).length > 0 ? { theirs_snapshot: snapshot } : {}
|
|
3111
3897
|
});
|
|
3112
3898
|
conflictResolved++;
|
|
3113
3899
|
} catch {
|
|
@@ -3115,6 +3901,32 @@ var ReplayService = class {
|
|
|
3115
3901
|
}
|
|
3116
3902
|
return { conflictResolved, conflictAbsorbed, contentRefreshed };
|
|
3117
3903
|
}
|
|
3904
|
+
/**
|
|
3905
|
+
* After applyPatches(), surface a warning for each (patch × file) where
|
|
3906
|
+
* diff3 silently dropped lines that were unchanged context in the
|
|
3907
|
+
* customer's THEIRS. The deletion stays on disk (correct diff3 outcome),
|
|
3908
|
+
* but the customer must learn so they can re-add the lines if they
|
|
3909
|
+
* relied on them. This is the "surface or preserve, never silently drop"
|
|
3910
|
+
* contract for legitimate-deletion cases.
|
|
3911
|
+
*/
|
|
3912
|
+
recordDroppedContextLineWarnings(results) {
|
|
3913
|
+
const PREVIEW_COUNT = 5;
|
|
3914
|
+
for (const result of results) {
|
|
3915
|
+
if (!result.fileResults) continue;
|
|
3916
|
+
for (const fr of result.fileResults) {
|
|
3917
|
+
const dropped = fr.droppedContextLines;
|
|
3918
|
+
if (!dropped || dropped.length === 0) continue;
|
|
3919
|
+
const preview = dropped.slice(0, PREVIEW_COUNT).map((line) => ` ${line}`).join("\n");
|
|
3920
|
+
const more = dropped.length > PREVIEW_COUNT ? `
|
|
3921
|
+
... and ${dropped.length - PREVIEW_COUNT} more` : "";
|
|
3922
|
+
this.warnings.push(
|
|
3923
|
+
`${fr.file}: ${dropped.length} line(s) that appeared as unchanged context in your customization were deleted by the new generator output. The merge followed the deletion. First lines deleted:
|
|
3924
|
+
${preview}${more}
|
|
3925
|
+
If you want to keep these lines, restore them in a follow-up commit and re-run replay; otherwise this warning can be safely ignored.`
|
|
3926
|
+
);
|
|
3927
|
+
}
|
|
3928
|
+
}
|
|
3929
|
+
}
|
|
3118
3930
|
/**
|
|
3119
3931
|
* After applyPatches(), strip conflict markers from conflicting files
|
|
3120
3932
|
* so only clean content is committed. Keeps the Generated (OURS) side.
|
|
@@ -3124,7 +3936,7 @@ var ReplayService = class {
|
|
|
3124
3936
|
if (result.status !== "conflict" || !result.fileResults) continue;
|
|
3125
3937
|
for (const fileResult of result.fileResults) {
|
|
3126
3938
|
if (fileResult.status !== "conflict") continue;
|
|
3127
|
-
const filePath = (0,
|
|
3939
|
+
const filePath = (0, import_node_path5.join)(this.outputDir, fileResult.file);
|
|
3128
3940
|
try {
|
|
3129
3941
|
const content = (0, import_node_fs2.readFileSync)(filePath, "utf-8");
|
|
3130
3942
|
const stripped = stripConflictMarkers(content);
|
|
@@ -3154,7 +3966,7 @@ var ReplayService = class {
|
|
|
3154
3966
|
}
|
|
3155
3967
|
}
|
|
3156
3968
|
readFernignorePatterns() {
|
|
3157
|
-
const fernignorePath = (0,
|
|
3969
|
+
const fernignorePath = (0, import_node_path5.join)(this.outputDir, ".fernignore");
|
|
3158
3970
|
if (!(0, import_node_fs2.existsSync)(fernignorePath)) return [];
|
|
3159
3971
|
return (0, import_node_fs2.readFileSync)(fernignorePath, "utf-8").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
|
|
3160
3972
|
}
|
|
@@ -3172,6 +3984,9 @@ var ReplayService = class {
|
|
|
3172
3984
|
};
|
|
3173
3985
|
}).filter((d) => d.files.length > 0);
|
|
3174
3986
|
const partialCount = conflictDetails.filter((d) => d.cleanFiles && d.cleanFiles.length > 0).length;
|
|
3987
|
+
const unresolvedResults = results.filter(
|
|
3988
|
+
(r) => r.status === "conflict" || r.patch.status === "unresolved"
|
|
3989
|
+
);
|
|
3175
3990
|
return {
|
|
3176
3991
|
flow,
|
|
3177
3992
|
patchesDetected: patches.length,
|
|
@@ -3188,7 +4003,7 @@ var ReplayService = class {
|
|
|
3188
4003
|
patchesRefreshed: preRebaseCounts && preRebaseCounts.contentRefreshed > 0 ? preRebaseCounts.contentRefreshed : void 0,
|
|
3189
4004
|
conflicts: conflictResults.flatMap((r) => r.fileResults?.filter((f) => f.status === "conflict") ?? []),
|
|
3190
4005
|
conflictDetails: conflictDetails.length > 0 ? conflictDetails : void 0,
|
|
3191
|
-
unresolvedPatches:
|
|
4006
|
+
unresolvedPatches: unresolvedResults.length > 0 ? unresolvedResults.map((r) => ({
|
|
3192
4007
|
patchId: r.patch.id,
|
|
3193
4008
|
patchMessage: r.patch.original_message,
|
|
3194
4009
|
files: r.patch.files,
|
|
@@ -3199,11 +4014,38 @@ var ReplayService = class {
|
|
|
3199
4014
|
};
|
|
3200
4015
|
}
|
|
3201
4016
|
};
|
|
4017
|
+
function computeCommitBuckets(results, absorbedPatchIds, patchesPassedToCommit) {
|
|
4018
|
+
const resultByPatchId = /* @__PURE__ */ new Map();
|
|
4019
|
+
for (const r of results) resultByPatchId.set(r.patch.id, r);
|
|
4020
|
+
const applied = [];
|
|
4021
|
+
const unresolved = [];
|
|
4022
|
+
const absorbed = [];
|
|
4023
|
+
for (const patch of patchesPassedToCommit) {
|
|
4024
|
+
if (absorbedPatchIds.has(patch.id)) {
|
|
4025
|
+
absorbed.push(patch);
|
|
4026
|
+
continue;
|
|
4027
|
+
}
|
|
4028
|
+
const r = resultByPatchId.get(patch.id);
|
|
4029
|
+
if (!r) {
|
|
4030
|
+
applied.push(patch);
|
|
4031
|
+
continue;
|
|
4032
|
+
}
|
|
4033
|
+
if (r.status === "conflict" || patch.status === "unresolved") {
|
|
4034
|
+
unresolved.push(patch);
|
|
4035
|
+
continue;
|
|
4036
|
+
}
|
|
4037
|
+
if (r.status === "applied") {
|
|
4038
|
+
applied.push(patch);
|
|
4039
|
+
continue;
|
|
4040
|
+
}
|
|
4041
|
+
}
|
|
4042
|
+
return { applied, unresolved, absorbed };
|
|
4043
|
+
}
|
|
3202
4044
|
|
|
3203
4045
|
// src/FernignoreMigrator.ts
|
|
3204
4046
|
var import_node_crypto2 = require("crypto");
|
|
3205
4047
|
var import_node_fs3 = require("fs");
|
|
3206
|
-
var
|
|
4048
|
+
var import_node_path6 = require("path");
|
|
3207
4049
|
var import_minimatch3 = require("minimatch");
|
|
3208
4050
|
var import_yaml2 = require("yaml");
|
|
3209
4051
|
var FernignoreMigrator = class {
|
|
@@ -3216,10 +4058,10 @@ var FernignoreMigrator = class {
|
|
|
3216
4058
|
this.outputDir = outputDir;
|
|
3217
4059
|
}
|
|
3218
4060
|
fernignoreExists() {
|
|
3219
|
-
return (0, import_node_fs3.existsSync)((0,
|
|
4061
|
+
return (0, import_node_fs3.existsSync)((0, import_node_path6.join)(this.outputDir, ".fernignore"));
|
|
3220
4062
|
}
|
|
3221
4063
|
readFernignorePatterns() {
|
|
3222
|
-
const fernignorePath = (0,
|
|
4064
|
+
const fernignorePath = (0, import_node_path6.join)(this.outputDir, ".fernignore");
|
|
3223
4065
|
if (!(0, import_node_fs3.existsSync)(fernignorePath)) {
|
|
3224
4066
|
return [];
|
|
3225
4067
|
}
|
|
@@ -3271,6 +4113,12 @@ var FernignoreMigrator = class {
|
|
|
3271
4113
|
if (patchFiles.length > 0) {
|
|
3272
4114
|
const patchContent = diffParts.join("\n");
|
|
3273
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
|
+
}
|
|
3274
4122
|
syntheticPatches.push({
|
|
3275
4123
|
id: `patch-fernignore-${(0, import_node_crypto2.createHash)("sha256").update(pattern).digest("hex").slice(0, 8)}`,
|
|
3276
4124
|
content_hash: contentHash,
|
|
@@ -3279,7 +4127,17 @@ var FernignoreMigrator = class {
|
|
|
3279
4127
|
original_author: "Fern Replay <replay@buildwithfern.com>",
|
|
3280
4128
|
base_generation: currentGen.commit_sha,
|
|
3281
4129
|
files: patchFiles,
|
|
3282
|
-
patch_content: patchContent
|
|
4130
|
+
patch_content: patchContent,
|
|
4131
|
+
...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
|
|
4132
|
+
// Synthetic patches captured from .fernignore-protected files
|
|
4133
|
+
// are user-owned by definition — the customer's content
|
|
4134
|
+
// diverges from the generator's pristine output, and the
|
|
4135
|
+
// generator never produced this divergent content. Without
|
|
4136
|
+
// this flag, removing the file from .fernignore later (so
|
|
4137
|
+
// it leaves the protection check in `isFileUserOwned`) would
|
|
4138
|
+
// expose the patch to absorption on the next regen,
|
|
4139
|
+
// silently losing the customization.
|
|
4140
|
+
user_owned: true
|
|
3283
4141
|
});
|
|
3284
4142
|
trackedByBoth.push({
|
|
3285
4143
|
file: pattern,
|
|
@@ -3312,7 +4170,7 @@ var FernignoreMigrator = class {
|
|
|
3312
4170
|
return results;
|
|
3313
4171
|
}
|
|
3314
4172
|
readFileContent(filePath) {
|
|
3315
|
-
const fullPath = (0,
|
|
4173
|
+
const fullPath = (0, import_node_path6.join)(this.outputDir, filePath);
|
|
3316
4174
|
if (!(0, import_node_fs3.existsSync)(fullPath)) {
|
|
3317
4175
|
return null;
|
|
3318
4176
|
}
|
|
@@ -3377,7 +4235,7 @@ var FernignoreMigrator = class {
|
|
|
3377
4235
|
};
|
|
3378
4236
|
}
|
|
3379
4237
|
movePatternsToReplayYml(patterns) {
|
|
3380
|
-
const replayYmlPath = (0,
|
|
4238
|
+
const replayYmlPath = (0, import_node_path6.join)(this.outputDir, ".fern", "replay.yml");
|
|
3381
4239
|
let config = {};
|
|
3382
4240
|
if ((0, import_node_fs3.existsSync)(replayYmlPath)) {
|
|
3383
4241
|
const content = (0, import_node_fs3.readFileSync)(replayYmlPath, "utf-8");
|
|
@@ -3386,7 +4244,7 @@ var FernignoreMigrator = class {
|
|
|
3386
4244
|
const existing = config.exclude ?? [];
|
|
3387
4245
|
const merged = [.../* @__PURE__ */ new Set([...existing, ...patterns])];
|
|
3388
4246
|
config.exclude = merged;
|
|
3389
|
-
const dir = (0,
|
|
4247
|
+
const dir = (0, import_node_path6.dirname)(replayYmlPath);
|
|
3390
4248
|
if (!(0, import_node_fs3.existsSync)(dir)) {
|
|
3391
4249
|
(0, import_node_fs3.mkdirSync)(dir, { recursive: true });
|
|
3392
4250
|
}
|
|
@@ -3397,7 +4255,7 @@ var FernignoreMigrator = class {
|
|
|
3397
4255
|
// src/commands/bootstrap.ts
|
|
3398
4256
|
var import_node_crypto3 = require("crypto");
|
|
3399
4257
|
var import_node_fs4 = require("fs");
|
|
3400
|
-
var
|
|
4258
|
+
var import_node_path7 = require("path");
|
|
3401
4259
|
init_GitClient();
|
|
3402
4260
|
async function bootstrap(outputDir, options) {
|
|
3403
4261
|
const git = new GitClient(outputDir);
|
|
@@ -3564,7 +4422,7 @@ function parseGitLog(log) {
|
|
|
3564
4422
|
}
|
|
3565
4423
|
var REPLAY_FERNIGNORE_ENTRIES = [".fern/replay.lock", ".fern/replay.yml", ".gitattributes"];
|
|
3566
4424
|
function ensureFernignoreEntries(outputDir) {
|
|
3567
|
-
const fernignorePath = (0,
|
|
4425
|
+
const fernignorePath = (0, import_node_path7.join)(outputDir, ".fernignore");
|
|
3568
4426
|
let content = "";
|
|
3569
4427
|
if ((0, import_node_fs4.existsSync)(fernignorePath)) {
|
|
3570
4428
|
content = (0, import_node_fs4.readFileSync)(fernignorePath, "utf-8");
|
|
@@ -3588,7 +4446,7 @@ function ensureFernignoreEntries(outputDir) {
|
|
|
3588
4446
|
}
|
|
3589
4447
|
var GITATTRIBUTES_ENTRIES = [".fern/replay.lock linguist-generated=true"];
|
|
3590
4448
|
function ensureGitattributesEntries(outputDir) {
|
|
3591
|
-
const gitattributesPath = (0,
|
|
4449
|
+
const gitattributesPath = (0, import_node_path7.join)(outputDir, ".gitattributes");
|
|
3592
4450
|
let content = "";
|
|
3593
4451
|
if ((0, import_node_fs4.existsSync)(gitattributesPath)) {
|
|
3594
4452
|
content = (0, import_node_fs4.readFileSync)(gitattributesPath, "utf-8");
|
|
@@ -3786,6 +4644,8 @@ function reset(outputDir, options) {
|
|
|
3786
4644
|
}
|
|
3787
4645
|
|
|
3788
4646
|
// src/commands/resolve.ts
|
|
4647
|
+
var import_promises5 = require("fs/promises");
|
|
4648
|
+
var import_node_path8 = require("path");
|
|
3789
4649
|
init_GitClient();
|
|
3790
4650
|
async function resolve(outputDir, options) {
|
|
3791
4651
|
const lockManager = new LockfileManager(outputDir);
|
|
@@ -3801,7 +4661,7 @@ async function resolve(outputDir, options) {
|
|
|
3801
4661
|
const resolvingPatches = lockManager.getResolvingPatches();
|
|
3802
4662
|
if (unresolvedPatches.length > 0) {
|
|
3803
4663
|
const applicator = new ReplayApplicator(git, lockManager, outputDir);
|
|
3804
|
-
await applicator.applyPatches(unresolvedPatches);
|
|
4664
|
+
await applicator.applyPatches(unresolvedPatches, { applyMode: "resolve" });
|
|
3805
4665
|
const markerFiles = await findConflictMarkerFiles(git);
|
|
3806
4666
|
if (markerFiles.length > 0) {
|
|
3807
4667
|
for (const patch of unresolvedPatches) {
|
|
@@ -3827,20 +4687,80 @@ async function resolve(outputDir, options) {
|
|
|
3827
4687
|
if (patchesToCommit.length > 0) {
|
|
3828
4688
|
const currentGen = lock.current_generation;
|
|
3829
4689
|
const detector = new ReplayDetector(git, lockManager, outputDir);
|
|
4690
|
+
const warnings = [];
|
|
3830
4691
|
let patchesResolved = 0;
|
|
4692
|
+
const pass1 = [];
|
|
3831
4693
|
for (const patch of patchesToCommit) {
|
|
3832
|
-
const
|
|
3833
|
-
|
|
4694
|
+
const result = await computePerPatchDiffWithFallback(
|
|
4695
|
+
patch,
|
|
4696
|
+
(f) => git.showFile(currentGen, f),
|
|
4697
|
+
(f) => (0, import_promises5.readFile)((0, import_node_path8.join)(outputDir, f), "utf-8").catch(() => null),
|
|
4698
|
+
(files) => git.exec(["diff", currentGen, "--", ...files]).catch(() => null)
|
|
4699
|
+
);
|
|
4700
|
+
if (result === null) {
|
|
4701
|
+
warnings.push(
|
|
4702
|
+
`Patch ${patch.id}: cumulative-diff fallback failed for unlocatable files; preserving patch unchanged`
|
|
4703
|
+
);
|
|
4704
|
+
pass1.push({ kind: "skip" });
|
|
4705
|
+
continue;
|
|
4706
|
+
}
|
|
4707
|
+
const diff = result.diff;
|
|
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") {
|
|
3834
4731
|
lockManager.removePatch(patch.id);
|
|
3835
4732
|
continue;
|
|
3836
4733
|
}
|
|
3837
|
-
|
|
3838
|
-
|
|
4734
|
+
if (lastIndexByHash.get(r.hash) !== i) {
|
|
4735
|
+
const canonicalIdx = lastIndexByHash.get(r.hash);
|
|
4736
|
+
const canonical = patchesToCommit[canonicalIdx];
|
|
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
|
+
);
|
|
4741
|
+
continue;
|
|
4742
|
+
}
|
|
4743
|
+
if (r.hadFallback) {
|
|
4744
|
+
warnings.push(
|
|
4745
|
+
`Patch ${patch.id}: ${r.fallbackFiles.join(", ")} could not be anchored; cumulative-diff fallback applied for those files`
|
|
4746
|
+
);
|
|
4747
|
+
}
|
|
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
|
+
}
|
|
3839
4758
|
lockManager.markPatchResolved(patch.id, {
|
|
3840
|
-
patch_content: diff,
|
|
3841
|
-
content_hash:
|
|
4759
|
+
patch_content: r.diff,
|
|
4760
|
+
content_hash: r.hash,
|
|
3842
4761
|
base_generation: currentGen,
|
|
3843
|
-
files:
|
|
4762
|
+
files: filesForSnapshot,
|
|
4763
|
+
...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {}
|
|
3844
4764
|
});
|
|
3845
4765
|
patchesResolved++;
|
|
3846
4766
|
}
|
|
@@ -3856,7 +4776,8 @@ async function resolve(outputDir, options) {
|
|
|
3856
4776
|
success: true,
|
|
3857
4777
|
commitSha: commitSha2,
|
|
3858
4778
|
phase: "committed",
|
|
3859
|
-
patchesResolved
|
|
4779
|
+
patchesResolved,
|
|
4780
|
+
warnings: warnings.length > 0 ? warnings : void 0
|
|
3860
4781
|
};
|
|
3861
4782
|
}
|
|
3862
4783
|
const committer = new ReplayCommitter(git, outputDir);
|
|
@@ -3874,6 +4795,24 @@ async function getChangedFiles(git, currentGen, files) {
|
|
|
3874
4795
|
const changed = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !f.startsWith(".fern/"));
|
|
3875
4796
|
return changed.length > 0 ? changed : files;
|
|
3876
4797
|
}
|
|
4798
|
+
function extractFilesFromDiff(unifiedDiff2) {
|
|
4799
|
+
const out = /* @__PURE__ */ new Set();
|
|
4800
|
+
const renameSources = /* @__PURE__ */ new Set();
|
|
4801
|
+
let currentTarget = null;
|
|
4802
|
+
for (const line of unifiedDiff2.split("\n")) {
|
|
4803
|
+
const headerMatch = line.match(/^diff --git a\/(.+?) b\/(.+)$/);
|
|
4804
|
+
if (headerMatch) {
|
|
4805
|
+
currentTarget = headerMatch[2];
|
|
4806
|
+
if (!currentTarget.startsWith(".fern/")) out.add(currentTarget);
|
|
4807
|
+
continue;
|
|
4808
|
+
}
|
|
4809
|
+
if (currentTarget && line.startsWith("rename from ")) {
|
|
4810
|
+
renameSources.add(line.slice("rename from ".length));
|
|
4811
|
+
}
|
|
4812
|
+
}
|
|
4813
|
+
for (const src of renameSources) out.delete(src);
|
|
4814
|
+
return Array.from(out);
|
|
4815
|
+
}
|
|
3877
4816
|
|
|
3878
4817
|
// src/commands/status.ts
|
|
3879
4818
|
function status(outputDir) {
|