@fern-api/replay 0.14.0 → 0.15.0
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 +3151 -2693
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +1879 -1417
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +100 -2
- package/dist/index.d.ts +100 -2
- package/dist/index.js +1879 -1418
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -395,6 +395,63 @@ var init_HybridReconstruction = __esm({
|
|
|
395
395
|
}
|
|
396
396
|
});
|
|
397
397
|
|
|
398
|
+
// src/PatchApplyTheirs.ts
|
|
399
|
+
var PatchApplyTheirs_exports = {};
|
|
400
|
+
__export(PatchApplyTheirs_exports, {
|
|
401
|
+
applyPatchToContent: () => applyPatchToContent
|
|
402
|
+
});
|
|
403
|
+
import { mkdtemp as mkdtemp3, mkdir as mkdir2, writeFile as writeFile3, readFile as readFile2, rm as rm3 } from "fs/promises";
|
|
404
|
+
import { tmpdir as tmpdir3 } from "os";
|
|
405
|
+
import { dirname as dirname3, join as join4 } from "path";
|
|
406
|
+
async function applyPatchToContent(base, patchContent, filePath) {
|
|
407
|
+
const fileDiff = extractFileDiff(patchContent, filePath);
|
|
408
|
+
if (!fileDiff) return null;
|
|
409
|
+
const tempDir = await mkdtemp3(join4(tmpdir3(), "replay-migrate-"));
|
|
410
|
+
const tempGit = new GitClient(tempDir);
|
|
411
|
+
try {
|
|
412
|
+
await tempGit.exec(["init"]);
|
|
413
|
+
await tempGit.exec(["config", "user.email", "migrate@fern.com"]);
|
|
414
|
+
await tempGit.exec(["config", "user.name", "Fern Replay Migration"]);
|
|
415
|
+
await tempGit.exec(["config", "commit.gpgSign", "false"]);
|
|
416
|
+
const tempFilePath = join4(tempDir, filePath);
|
|
417
|
+
await mkdir2(dirname3(tempFilePath), { recursive: true });
|
|
418
|
+
await writeFile3(tempFilePath, base);
|
|
419
|
+
await tempGit.exec(["add", filePath]);
|
|
420
|
+
await tempGit.exec(["commit", "-m", `base for ${filePath}`, "--allow-empty"]);
|
|
421
|
+
await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
|
|
422
|
+
return await readFile2(tempFilePath, "utf-8");
|
|
423
|
+
} catch {
|
|
424
|
+
return null;
|
|
425
|
+
} finally {
|
|
426
|
+
await rm3(tempDir, { recursive: true, force: true }).catch(() => {
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
function extractFileDiff(patchContent, filePath) {
|
|
431
|
+
const lines = patchContent.split("\n");
|
|
432
|
+
const out = [];
|
|
433
|
+
let inTarget = false;
|
|
434
|
+
for (const line of lines) {
|
|
435
|
+
if (line.startsWith("diff --git")) {
|
|
436
|
+
if (inTarget) break;
|
|
437
|
+
const match = line.match(/^diff --git a\/.+ b\/(.+)$/);
|
|
438
|
+
if (match && match[1] === filePath) {
|
|
439
|
+
inTarget = true;
|
|
440
|
+
out.push(line);
|
|
441
|
+
}
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
if (inTarget) out.push(line);
|
|
445
|
+
}
|
|
446
|
+
return out.length > 0 ? out.join("\n") + "\n" : null;
|
|
447
|
+
}
|
|
448
|
+
var init_PatchApplyTheirs = __esm({
|
|
449
|
+
"src/PatchApplyTheirs.ts"() {
|
|
450
|
+
"use strict";
|
|
451
|
+
init_GitClient();
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
|
|
398
455
|
// src/credentials.ts
|
|
399
456
|
var CREDENTIAL_PATTERNS = [
|
|
400
457
|
{ name: "PRIVATE KEY block", re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
|
|
@@ -526,7 +583,17 @@ var LockfileManager = class {
|
|
|
526
583
|
this.warnings.length = 0;
|
|
527
584
|
const cleanPatches = [];
|
|
528
585
|
for (const patch of this.lock.patches) {
|
|
529
|
-
const
|
|
586
|
+
const diffMatches = scanForCredentials(patch.patch_content);
|
|
587
|
+
const snapshotMatches = [];
|
|
588
|
+
if (patch.theirs_snapshot) {
|
|
589
|
+
for (const content2 of Object.values(patch.theirs_snapshot)) {
|
|
590
|
+
const m = scanForCredentials(content2);
|
|
591
|
+
for (const x of m) {
|
|
592
|
+
if (!snapshotMatches.includes(x)) snapshotMatches.push(x);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
const credentialMatches = [...diffMatches, ...snapshotMatches.filter((m) => !diffMatches.includes(m))];
|
|
530
597
|
const sensitiveFiles = patch.files.filter((f) => isSensitiveFile(f));
|
|
531
598
|
if (credentialMatches.length > 0 || sensitiveFiles.length > 0) {
|
|
532
599
|
const reasons = [];
|
|
@@ -647,1520 +714,1593 @@ var LockfileManager = class {
|
|
|
647
714
|
|
|
648
715
|
// src/ReplayDetector.ts
|
|
649
716
|
import { createHash } from "crypto";
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
717
|
+
|
|
718
|
+
// src/ReplayApplicator.ts
|
|
719
|
+
import { mkdir, mkdtemp, readFile, rm, unlink, writeFile } from "fs/promises";
|
|
720
|
+
import { tmpdir } from "os";
|
|
721
|
+
import { dirname as dirname2, extname, join as join2 } from "path";
|
|
722
|
+
import { minimatch } from "minimatch";
|
|
723
|
+
|
|
724
|
+
// src/conflict-utils.ts
|
|
725
|
+
var CONFLICT_OPENER = "<<<<<<< Generated";
|
|
726
|
+
var CONFLICT_SEPARATOR = "=======";
|
|
727
|
+
var CONFLICT_CLOSER = ">>>>>>> Your customization";
|
|
728
|
+
function trimCR(line) {
|
|
729
|
+
return line.endsWith("\r") ? line.slice(0, -1) : line;
|
|
730
|
+
}
|
|
731
|
+
function findConflictRanges(lines) {
|
|
732
|
+
const ranges = [];
|
|
733
|
+
let i = 0;
|
|
734
|
+
while (i < lines.length) {
|
|
735
|
+
if (trimCR(lines[i]) === CONFLICT_OPENER) {
|
|
736
|
+
let separatorIdx = -1;
|
|
737
|
+
let j = i + 1;
|
|
738
|
+
let found = false;
|
|
739
|
+
while (j < lines.length) {
|
|
740
|
+
const trimmed = trimCR(lines[j]);
|
|
741
|
+
if (trimmed === CONFLICT_OPENER) {
|
|
742
|
+
break;
|
|
743
|
+
}
|
|
744
|
+
if (separatorIdx === -1 && trimmed === CONFLICT_SEPARATOR) {
|
|
745
|
+
separatorIdx = j;
|
|
746
|
+
} else if (separatorIdx !== -1 && trimmed === CONFLICT_CLOSER) {
|
|
747
|
+
ranges.push({ start: i, separator: separatorIdx, end: j });
|
|
748
|
+
i = j;
|
|
749
|
+
found = true;
|
|
750
|
+
break;
|
|
751
|
+
}
|
|
752
|
+
j++;
|
|
670
753
|
}
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
} else if (line.startsWith("deleted file mode ")) {
|
|
675
|
-
currentIsDelete = true;
|
|
754
|
+
if (!found) {
|
|
755
|
+
i++;
|
|
756
|
+
continue;
|
|
676
757
|
}
|
|
677
758
|
}
|
|
759
|
+
i++;
|
|
678
760
|
}
|
|
679
|
-
|
|
680
|
-
return results;
|
|
761
|
+
return ranges;
|
|
681
762
|
}
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
constructor(git, lockManager, sdkOutputDir) {
|
|
688
|
-
this.git = git;
|
|
689
|
-
this.lockManager = lockManager;
|
|
690
|
-
this.sdkOutputDir = sdkOutputDir;
|
|
691
|
-
}
|
|
692
|
-
async detectNewPatches() {
|
|
693
|
-
const lock = this.lockManager.read();
|
|
694
|
-
const lastGen = this.getLastGeneration(lock);
|
|
695
|
-
if (!lastGen) {
|
|
696
|
-
return { patches: [], revertedPatchIds: [] };
|
|
697
|
-
}
|
|
698
|
-
const exists = await this.git.commitExists(lastGen.commit_sha);
|
|
699
|
-
if (!exists) {
|
|
700
|
-
this.warnings.push(
|
|
701
|
-
`Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to alternate detection.`
|
|
702
|
-
);
|
|
703
|
-
return this.detectPatchesViaTreeDiff(
|
|
704
|
-
lastGen,
|
|
705
|
-
/* commitKnownMissing */
|
|
706
|
-
true
|
|
707
|
-
);
|
|
708
|
-
}
|
|
709
|
-
const isAncestor = await this.git.isAncestor(lastGen.commit_sha, "HEAD");
|
|
710
|
-
if (!isAncestor) {
|
|
711
|
-
return this.detectPatchesViaMergeBase(lastGen, lock);
|
|
712
|
-
}
|
|
713
|
-
return this.detectPatchesInRange(lastGen.commit_sha, lastGen, lock);
|
|
714
|
-
}
|
|
715
|
-
/**
|
|
716
|
-
* FER-10201 — Non-linear-history detection via `merge-base(prevGen, HEAD)`.
|
|
717
|
-
*
|
|
718
|
-
* After a squash-merge of a Fern-bot PR, `lastGen.commit_sha` (the previous
|
|
719
|
-
* `[fern-generated]`) is orphaned but still in git's object database. The
|
|
720
|
-
* merge-base is the commit on main from which the bot's branch was created —
|
|
721
|
-
* a reachable ancestor of HEAD. Walking that range and classifying each
|
|
722
|
-
* commit via `isGenerationCommit` mirrors the linear path and eliminates
|
|
723
|
-
* the bug class where pipeline-driven changes (autoversion, replay,
|
|
724
|
-
* generation) get bundled as customer customizations.
|
|
725
|
-
*
|
|
726
|
-
* Falls through to the legacy composite path when no common ancestor exists
|
|
727
|
-
* (truly disjoint history — orphan branches with no shared root).
|
|
728
|
-
*/
|
|
729
|
-
async detectPatchesViaMergeBase(lastGen, lock) {
|
|
730
|
-
let mergeBase = "";
|
|
731
|
-
try {
|
|
732
|
-
mergeBase = (await this.git.exec(["merge-base", lastGen.commit_sha, "HEAD"])).trim();
|
|
733
|
-
} catch {
|
|
734
|
-
}
|
|
735
|
-
if (!mergeBase) {
|
|
736
|
-
this.warnings.push(
|
|
737
|
-
`No common ancestor between previous generation ${lastGen.commit_sha.slice(0, 7)} and HEAD. Falling back to composite tree-diff detection.`
|
|
738
|
-
);
|
|
739
|
-
return this.detectPatchesViaTreeDiff(
|
|
740
|
-
lastGen,
|
|
741
|
-
/* commitKnownMissing */
|
|
742
|
-
false
|
|
743
|
-
);
|
|
744
|
-
}
|
|
745
|
-
return this.detectPatchesInRange(mergeBase, lastGen, lock);
|
|
763
|
+
function stripConflictMarkers(content) {
|
|
764
|
+
const lines = content.split(/\r?\n/);
|
|
765
|
+
const ranges = findConflictRanges(lines);
|
|
766
|
+
if (ranges.length === 0) {
|
|
767
|
+
return content;
|
|
746
768
|
}
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
* lockfile-tracked reference the applicator uses to fetch base file
|
|
754
|
-
* content, regardless of where the walk actually started.
|
|
755
|
-
*/
|
|
756
|
-
async detectPatchesInRange(rangeStart, lastGen, lock) {
|
|
757
|
-
const log = await this.git.exec([
|
|
758
|
-
"log",
|
|
759
|
-
"--first-parent",
|
|
760
|
-
"--format=%H%x00%an%x00%ae%x00%s",
|
|
761
|
-
`${rangeStart}..HEAD`,
|
|
762
|
-
"--",
|
|
763
|
-
this.sdkOutputDir
|
|
764
|
-
]);
|
|
765
|
-
if (!log.trim()) {
|
|
766
|
-
return { patches: [], revertedPatchIds: [] };
|
|
767
|
-
}
|
|
768
|
-
const commits = this.parseGitLog(log);
|
|
769
|
-
const newPatches = [];
|
|
770
|
-
const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
|
|
771
|
-
for (const commit of commits) {
|
|
772
|
-
if (isGenerationCommit(commit)) {
|
|
773
|
-
continue;
|
|
774
|
-
}
|
|
775
|
-
if (lock.patches.find((p) => p.original_commit === commit.sha)) {
|
|
769
|
+
const result = [];
|
|
770
|
+
let rangeIdx = 0;
|
|
771
|
+
for (let i = 0; i < lines.length; i++) {
|
|
772
|
+
if (rangeIdx < ranges.length) {
|
|
773
|
+
const range = ranges[rangeIdx];
|
|
774
|
+
if (i === range.start) {
|
|
776
775
|
continue;
|
|
777
776
|
}
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
const compositePatch = await this.createMergeCompositePatch({
|
|
781
|
-
mergeCommit: commit,
|
|
782
|
-
firstParent: parents[0],
|
|
783
|
-
lastGen,
|
|
784
|
-
lock
|
|
785
|
-
});
|
|
786
|
-
if (compositePatch) {
|
|
787
|
-
newPatches.push(compositePatch);
|
|
788
|
-
}
|
|
777
|
+
if (i > range.start && i < range.separator) {
|
|
778
|
+
result.push(lines[i]);
|
|
789
779
|
continue;
|
|
790
780
|
}
|
|
791
|
-
|
|
792
|
-
try {
|
|
793
|
-
patchContent = await this.git.formatPatch(commit.sha);
|
|
794
|
-
} catch {
|
|
795
|
-
this.warnings.push(
|
|
796
|
-
`Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
|
|
797
|
-
);
|
|
781
|
+
if (i === range.separator) {
|
|
798
782
|
continue;
|
|
799
783
|
}
|
|
800
|
-
|
|
801
|
-
if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
|
|
784
|
+
if (i > range.separator && i < range.end) {
|
|
802
785
|
continue;
|
|
803
786
|
}
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
const sensitiveFiles = allFiles.filter((f) => isSensitiveFile(f));
|
|
807
|
-
const files = allFiles.filter((f) => !isSensitiveFile(f));
|
|
808
|
-
if (sensitiveFiles.length > 0) {
|
|
809
|
-
this.warnings.push(
|
|
810
|
-
`Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
|
|
811
|
-
);
|
|
812
|
-
if (files.length > 0) {
|
|
813
|
-
const parentSha = parents[0];
|
|
814
|
-
if (parentSha) {
|
|
815
|
-
try {
|
|
816
|
-
patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
|
|
817
|
-
} catch {
|
|
818
|
-
continue;
|
|
819
|
-
}
|
|
820
|
-
if (!patchContent.trim()) continue;
|
|
821
|
-
contentHash = this.computeContentHash(patchContent);
|
|
822
|
-
}
|
|
823
|
-
}
|
|
824
|
-
}
|
|
825
|
-
if (files.length === 0) {
|
|
787
|
+
if (i === range.end) {
|
|
788
|
+
rangeIdx++;
|
|
826
789
|
continue;
|
|
827
790
|
}
|
|
828
|
-
newPatches.push({
|
|
829
|
-
id: `patch-${commit.sha.slice(0, 8)}`,
|
|
830
|
-
content_hash: contentHash,
|
|
831
|
-
original_commit: commit.sha,
|
|
832
|
-
original_message: commit.message,
|
|
833
|
-
original_author: `${commit.authorName} <${commit.authorEmail}>`,
|
|
834
|
-
base_generation: lastGen.commit_sha,
|
|
835
|
-
files,
|
|
836
|
-
patch_content: patchContent,
|
|
837
|
-
...this.isCreationOnlyPatch(patchContent) ? { user_owned: true } : {}
|
|
838
|
-
});
|
|
839
791
|
}
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
);
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
);
|
|
887
|
-
if (idx !== -1) {
|
|
888
|
-
revertIndicesToRemove.add(i);
|
|
889
|
-
revertIndicesToRemove.add(idx);
|
|
890
|
-
}
|
|
891
|
-
}
|
|
892
|
-
if (!matchedExisting && !matchedNew) {
|
|
893
|
-
revertIndicesToRemove.add(i);
|
|
792
|
+
result.push(lines[i]);
|
|
793
|
+
}
|
|
794
|
+
return result.join("\n");
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// src/ThreeWayMerge.ts
|
|
798
|
+
import { diff3Merge, diffPatch } from "node-diff3";
|
|
799
|
+
function threeWayMerge(base, ours, theirs) {
|
|
800
|
+
const baseLines = base.split("\n");
|
|
801
|
+
const oursLines = ours.split("\n");
|
|
802
|
+
const theirsLines = theirs.split("\n");
|
|
803
|
+
const regions = diff3Merge(oursLines, baseLines, theirsLines);
|
|
804
|
+
const outputLines = [];
|
|
805
|
+
const conflicts = [];
|
|
806
|
+
let currentLine = 1;
|
|
807
|
+
for (const region of regions) {
|
|
808
|
+
if (region.ok) {
|
|
809
|
+
outputLines.push(...region.ok);
|
|
810
|
+
currentLine += region.ok.length;
|
|
811
|
+
} else if (region.conflict) {
|
|
812
|
+
const resolved = tryResolveConflict(
|
|
813
|
+
region.conflict.a,
|
|
814
|
+
// ours (generator)
|
|
815
|
+
region.conflict.o,
|
|
816
|
+
// base
|
|
817
|
+
region.conflict.b
|
|
818
|
+
// theirs (user)
|
|
819
|
+
);
|
|
820
|
+
if (resolved !== null) {
|
|
821
|
+
outputLines.push(...resolved);
|
|
822
|
+
currentLine += resolved.length;
|
|
823
|
+
} else {
|
|
824
|
+
const startLine = currentLine;
|
|
825
|
+
outputLines.push("<<<<<<< Generated");
|
|
826
|
+
outputLines.push(...region.conflict.a);
|
|
827
|
+
outputLines.push("=======");
|
|
828
|
+
outputLines.push(...region.conflict.b);
|
|
829
|
+
outputLines.push(">>>>>>> Your customization");
|
|
830
|
+
const conflictLines = region.conflict.a.length + region.conflict.b.length + 3;
|
|
831
|
+
conflicts.push({
|
|
832
|
+
startLine,
|
|
833
|
+
endLine: startLine + conflictLines - 1,
|
|
834
|
+
ours: region.conflict.a,
|
|
835
|
+
theirs: region.conflict.b
|
|
836
|
+
});
|
|
837
|
+
currentLine += conflictLines;
|
|
894
838
|
}
|
|
895
839
|
}
|
|
896
|
-
const filteredPatches = survivingPatches.filter((_, i) => !revertIndicesToRemove.has(i));
|
|
897
|
-
return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
|
|
898
840
|
}
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
* 2. Strip `index` lines (blob SHA pairs change across rebases).
|
|
910
|
-
* 3. Strip trailing git version marker (`-- \n<version>`).
|
|
911
|
-
*
|
|
912
|
-
* This ensures content hashes match across the no-patches and normal-
|
|
913
|
-
* regeneration flows, which store formatPatch and git-diff formats
|
|
914
|
-
* respectively (FER-9850, D5-D9).
|
|
915
|
-
*/
|
|
916
|
-
computeContentHash(patchContent) {
|
|
917
|
-
const lines = patchContent.split("\n");
|
|
918
|
-
const diffStart = lines.findIndex((l) => l.startsWith("diff --git "));
|
|
919
|
-
const relevantLines = diffStart > 0 ? lines.slice(diffStart) : lines;
|
|
920
|
-
const normalized = relevantLines.filter((line) => !line.startsWith("index ")).join("\n").replace(/\n-- \n[\d]+\.[\d]+[^\n]*\s*$/, "");
|
|
921
|
-
return `sha256:${createHash("sha256").update(normalized).digest("hex")}`;
|
|
841
|
+
const result = {
|
|
842
|
+
content: outputLines.join("\n"),
|
|
843
|
+
hasConflicts: conflicts.length > 0,
|
|
844
|
+
conflicts
|
|
845
|
+
};
|
|
846
|
+
if (conflicts.length === 0) {
|
|
847
|
+
const dropped = computeDroppedContextLines(baseLines, theirsLines, outputLines);
|
|
848
|
+
if (dropped.length > 0) {
|
|
849
|
+
result.droppedContextLines = dropped;
|
|
850
|
+
}
|
|
922
851
|
}
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
*
|
|
940
|
-
* The HEAD guard (#3) prevents false positives when a file is deleted
|
|
941
|
-
* and then recreated with different content — the cumulative diff of
|
|
942
|
-
* such a pair is non-empty and must be preserved.
|
|
943
|
-
*
|
|
944
|
-
* This only drops patches whose `files` are a subset of the transient
|
|
945
|
-
* set. A partial-transient patch (one that touches a transient file
|
|
946
|
-
* alongside a persistent one) is left unmodified; surgical stripping of
|
|
947
|
-
* single files from a multi-file patch would require a follow-up that
|
|
948
|
-
* regenerates the patch content via `git format-patch -- <files>`. No
|
|
949
|
-
* current failing test exercises this, and leaving the patch intact
|
|
950
|
-
* preserves today's behaviour. TODO(FER-9805): revisit if a future
|
|
951
|
-
* adversarial test demands per-file stripping.
|
|
952
|
-
*/
|
|
953
|
-
async collapseNetZeroFiles(patches) {
|
|
954
|
-
if (patches.length === 0) return patches;
|
|
955
|
-
const stateByFile = /* @__PURE__ */ new Map();
|
|
956
|
-
for (const patch of patches) {
|
|
957
|
-
for (const header of parsePatchFileHeaders(patch.patch_content)) {
|
|
958
|
-
if (!header.isCreate && !header.isDelete) continue;
|
|
959
|
-
const prior = stateByFile.get(header.path) ?? { created: false, deleted: false };
|
|
960
|
-
if (header.isCreate) prior.created = true;
|
|
961
|
-
if (header.isDelete) prior.deleted = true;
|
|
962
|
-
stateByFile.set(header.path, prior);
|
|
963
|
-
}
|
|
852
|
+
return result;
|
|
853
|
+
}
|
|
854
|
+
function computeDroppedContextLines(baseLines, theirsLines, mergedLines) {
|
|
855
|
+
const baseCounts = countLines(baseLines);
|
|
856
|
+
const theirsCounts = countLines(theirsLines);
|
|
857
|
+
const mergedCounts = countLines(mergedLines);
|
|
858
|
+
const dropped = [];
|
|
859
|
+
const seen = /* @__PURE__ */ new Set();
|
|
860
|
+
for (const line of baseLines) {
|
|
861
|
+
if (seen.has(line)) continue;
|
|
862
|
+
const inBase = baseCounts.get(line) ?? 0;
|
|
863
|
+
const inTheirs = theirsCounts.get(line) ?? 0;
|
|
864
|
+
const inMerged = mergedCounts.get(line) ?? 0;
|
|
865
|
+
if (inBase > 0 && inTheirs > 0 && inMerged < Math.min(inBase, inTheirs)) {
|
|
866
|
+
dropped.push(line);
|
|
867
|
+
seen.add(line);
|
|
964
868
|
}
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
869
|
+
}
|
|
870
|
+
return dropped;
|
|
871
|
+
}
|
|
872
|
+
function countLines(lines) {
|
|
873
|
+
const counts = /* @__PURE__ */ new Map();
|
|
874
|
+
for (const line of lines) {
|
|
875
|
+
counts.set(line, (counts.get(line) ?? 0) + 1);
|
|
876
|
+
}
|
|
877
|
+
return counts;
|
|
878
|
+
}
|
|
879
|
+
function tryResolveConflict(oursLines, baseLines, theirsLines) {
|
|
880
|
+
if (baseLines.length === 0) {
|
|
881
|
+
return null;
|
|
882
|
+
}
|
|
883
|
+
const oursPatches = diffPatch(baseLines, oursLines);
|
|
884
|
+
const theirsPatches = diffPatch(baseLines, theirsLines);
|
|
885
|
+
if (oursPatches.length === 0) return theirsLines;
|
|
886
|
+
if (theirsPatches.length === 0) return oursLines;
|
|
887
|
+
if (patchesOverlap(oursPatches, theirsPatches)) {
|
|
888
|
+
return null;
|
|
889
|
+
}
|
|
890
|
+
return applyBothPatches(baseLines, oursPatches, theirsPatches);
|
|
891
|
+
}
|
|
892
|
+
function patchesOverlap(oursPatches, theirsPatches) {
|
|
893
|
+
for (const op of oursPatches) {
|
|
894
|
+
const oStart = op.buffer1.offset;
|
|
895
|
+
const oEnd = oStart + op.buffer1.length;
|
|
896
|
+
for (const tp of theirsPatches) {
|
|
897
|
+
const tStart = tp.buffer1.offset;
|
|
898
|
+
const tEnd = tStart + tp.buffer1.length;
|
|
899
|
+
if (op.buffer1.length === 0 && tp.buffer1.length === 0 && oStart === tStart) {
|
|
900
|
+
return true;
|
|
970
901
|
}
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
902
|
+
if (tp.buffer1.length === 0 && tStart === oEnd) {
|
|
903
|
+
return true;
|
|
904
|
+
}
|
|
905
|
+
if (op.buffer1.length === 0 && oStart === tEnd) {
|
|
906
|
+
return true;
|
|
907
|
+
}
|
|
908
|
+
if (oStart < tEnd && tStart < oEnd) {
|
|
909
|
+
return true;
|
|
978
910
|
}
|
|
979
911
|
}
|
|
980
|
-
if (transient.size === 0) return patches;
|
|
981
|
-
return patches.filter((patch) => !patch.files.every((f) => transient.has(f)));
|
|
982
912
|
}
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
913
|
+
return false;
|
|
914
|
+
}
|
|
915
|
+
function applyBothPatches(baseLines, oursPatches, theirsPatches) {
|
|
916
|
+
const allPatches = [
|
|
917
|
+
...oursPatches.map((p) => ({
|
|
918
|
+
offset: p.buffer1.offset,
|
|
919
|
+
length: p.buffer1.length,
|
|
920
|
+
replacement: p.buffer2.chunk
|
|
921
|
+
})),
|
|
922
|
+
...theirsPatches.map((p) => ({
|
|
923
|
+
offset: p.buffer1.offset,
|
|
924
|
+
length: p.buffer1.length,
|
|
925
|
+
replacement: p.buffer2.chunk
|
|
926
|
+
}))
|
|
927
|
+
];
|
|
928
|
+
allPatches.sort((a, b) => b.offset - a.offset);
|
|
929
|
+
const result = [...baseLines];
|
|
930
|
+
for (const p of allPatches) {
|
|
931
|
+
result.splice(p.offset, p.length, ...p.replacement);
|
|
932
|
+
}
|
|
933
|
+
return result;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// src/ReplayApplicator.ts
|
|
937
|
+
var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
938
|
+
".png",
|
|
939
|
+
".jpg",
|
|
940
|
+
".jpeg",
|
|
941
|
+
".gif",
|
|
942
|
+
".bmp",
|
|
943
|
+
".ico",
|
|
944
|
+
".webp",
|
|
945
|
+
".svg",
|
|
946
|
+
".pdf",
|
|
947
|
+
".doc",
|
|
948
|
+
".docx",
|
|
949
|
+
".xls",
|
|
950
|
+
".xlsx",
|
|
951
|
+
".ppt",
|
|
952
|
+
".pptx",
|
|
953
|
+
".zip",
|
|
954
|
+
".gz",
|
|
955
|
+
".tar",
|
|
956
|
+
".bz2",
|
|
957
|
+
".7z",
|
|
958
|
+
".rar",
|
|
959
|
+
".jar",
|
|
960
|
+
".war",
|
|
961
|
+
".ear",
|
|
962
|
+
".class",
|
|
963
|
+
".exe",
|
|
964
|
+
".dll",
|
|
965
|
+
".so",
|
|
966
|
+
".dylib",
|
|
967
|
+
".o",
|
|
968
|
+
".a",
|
|
969
|
+
".woff",
|
|
970
|
+
".woff2",
|
|
971
|
+
".ttf",
|
|
972
|
+
".eot",
|
|
973
|
+
".otf",
|
|
974
|
+
".mp3",
|
|
975
|
+
".mp4",
|
|
976
|
+
".avi",
|
|
977
|
+
".mov",
|
|
978
|
+
".wav",
|
|
979
|
+
".flac",
|
|
980
|
+
".sqlite",
|
|
981
|
+
".db",
|
|
982
|
+
".pyc",
|
|
983
|
+
".pyo",
|
|
984
|
+
".DS_Store"
|
|
985
|
+
]);
|
|
986
|
+
var ReplayApplicator = class {
|
|
987
|
+
git;
|
|
988
|
+
lockManager;
|
|
989
|
+
outputDir;
|
|
990
|
+
renameCache = /* @__PURE__ */ new Map();
|
|
991
|
+
treeExistsCache = /* @__PURE__ */ new Map();
|
|
992
|
+
fileTheirsAccumulator = /* @__PURE__ */ new Map();
|
|
993
|
+
/**
|
|
994
|
+
* Apply mode for the current `applyPatches` invocation. Set at the start
|
|
995
|
+
* of `applyPatches`, read by `mergeFile` to decide:
|
|
996
|
+
* - whether to skip the intra-loop marker strip (kept in `applyPatches`)
|
|
997
|
+
* - whether to populate the accumulator after a conflicted merge
|
|
998
|
+
* (resolve mode populates so subsequent patches on the same file
|
|
999
|
+
* can use patch A's THEIRS as a structurally-correct merge base
|
|
1000
|
+
* when their diff was authored against post-A structure)
|
|
1001
|
+
* - whether to retry THEIRS reconstruction against the accumulator
|
|
1002
|
+
* when the BASE-relative reconstruction produced markers from a
|
|
1003
|
+
* cross-patch context mismatch
|
|
988
1004
|
*/
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
1005
|
+
currentApplyMode = "replay";
|
|
1006
|
+
/**
|
|
1007
|
+
* Set of files that appear in 2+ patches in the current `applyPatches`
|
|
1008
|
+
* invocation. Computed at the top of `applyPatches`. Read by `mergeFile`
|
|
1009
|
+
* and `populateAccumulatorForPatch` to decide whether to use the patch's
|
|
1010
|
+
* `theirs_snapshot` directly as THEIRS (snapshot-as-primary) or fall
|
|
1011
|
+
* back to line-anchored reconstruction (FER-9525 cross-patch isolation).
|
|
1012
|
+
*
|
|
1013
|
+
* Snapshot-as-primary is correct when the patch owns its file (no other
|
|
1014
|
+
* patch in this run touches it): the snapshot is what the customer
|
|
1015
|
+
* intends for that file post-regen, regardless of generator structural
|
|
1016
|
+
* changes that would invalidate the diff's line anchors.
|
|
1017
|
+
*
|
|
1018
|
+
* When a file IS shared, snapshots are CUMULATIVE across the patches
|
|
1019
|
+
* touching it (each one's snapshot includes prior patches' contributions).
|
|
1020
|
+
* Using a later patch's cumulative snapshot as its individual THEIRS
|
|
1021
|
+
* would propagate prior patches' edits into the merge — surfacing nested
|
|
1022
|
+
* conflicts at regions the patch alone never touched. Reconstruction
|
|
1023
|
+
* via `applyPatchToContent` is required there.
|
|
1024
|
+
*/
|
|
1025
|
+
sharedFiles = /* @__PURE__ */ new Set();
|
|
1026
|
+
constructor(git, lockManager, outputDir) {
|
|
1027
|
+
this.git = git;
|
|
1028
|
+
this.lockManager = lockManager;
|
|
1029
|
+
this.outputDir = outputDir;
|
|
996
1030
|
}
|
|
997
1031
|
/**
|
|
998
|
-
*
|
|
999
|
-
*
|
|
1000
|
-
*
|
|
1001
|
-
* zero-net-change ghost conflicts.
|
|
1032
|
+
* Resolve the GenerationRecord for a patch's base_generation.
|
|
1033
|
+
* Falls back to constructing an ad-hoc record from the commit's tree
|
|
1034
|
+
* when base_generation isn't a tracked generation (commit-scan patches).
|
|
1002
1035
|
*/
|
|
1003
|
-
async
|
|
1004
|
-
const
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
`Sensitive file(s) excluded from merge patch for commit ${mergeCommit.sha.slice(0, 7)}: ${sensitiveMergeFiles.join(", ")}. These files will not be tracked by replay.`
|
|
1018
|
-
);
|
|
1019
|
-
}
|
|
1020
|
-
if (files.length === 0) return null;
|
|
1021
|
-
const diff = await this.git.exec([
|
|
1022
|
-
"diff",
|
|
1023
|
-
firstParent,
|
|
1024
|
-
mergeCommit.sha,
|
|
1025
|
-
"--",
|
|
1026
|
-
...files
|
|
1027
|
-
]);
|
|
1028
|
-
if (!diff.trim()) return null;
|
|
1029
|
-
const contentHash = this.computeContentHash(diff);
|
|
1030
|
-
if (lock.patches.some((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
|
|
1031
|
-
return null;
|
|
1036
|
+
async resolveBaseGeneration(baseGeneration) {
|
|
1037
|
+
const gen = this.lockManager.getGeneration(baseGeneration);
|
|
1038
|
+
if (gen) return gen;
|
|
1039
|
+
try {
|
|
1040
|
+
const treeHash = await this.git.getTreeHash(baseGeneration);
|
|
1041
|
+
return {
|
|
1042
|
+
commit_sha: baseGeneration,
|
|
1043
|
+
tree_hash: treeHash,
|
|
1044
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1045
|
+
cli_version: "unknown",
|
|
1046
|
+
generator_versions: {}
|
|
1047
|
+
};
|
|
1048
|
+
} catch {
|
|
1049
|
+
return void 0;
|
|
1032
1050
|
}
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
original_message: mergeCommit.message,
|
|
1038
|
-
original_author: `${mergeCommit.authorName} <${mergeCommit.authorEmail}>`,
|
|
1039
|
-
base_generation: lastGen.commit_sha,
|
|
1040
|
-
files,
|
|
1041
|
-
patch_content: diff,
|
|
1042
|
-
...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
|
|
1043
|
-
};
|
|
1051
|
+
}
|
|
1052
|
+
/** Reset inter-patch accumulator for a new cycle. */
|
|
1053
|
+
resetAccumulator() {
|
|
1054
|
+
this.fileTheirsAccumulator.clear();
|
|
1044
1055
|
}
|
|
1045
1056
|
/**
|
|
1046
|
-
*
|
|
1047
|
-
*
|
|
1048
|
-
*
|
|
1057
|
+
* @internal Test-only.
|
|
1058
|
+
* Returns a defensive copy of the inter-patch accumulator. Used by the
|
|
1059
|
+
* adversarial test suite (FER-9791) to assert invariant I2: after every
|
|
1060
|
+
* applied patch, the accumulator has an entry for each file the patch
|
|
1061
|
+
* touched, and the entry's content matches on-disk.
|
|
1049
1062
|
*/
|
|
1050
|
-
|
|
1051
|
-
const
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
}
|
|
1055
|
-
const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
|
|
1056
|
-
const allTreeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
|
|
1057
|
-
const sensitiveTreeFiles = allTreeFiles.filter((f) => isSensitiveFile(f));
|
|
1058
|
-
const files = allTreeFiles.filter((f) => !isSensitiveFile(f));
|
|
1059
|
-
if (sensitiveTreeFiles.length > 0) {
|
|
1060
|
-
this.warnings.push(
|
|
1061
|
-
`Sensitive file(s) excluded from composite patch: ${sensitiveTreeFiles.join(", ")}. These files will not be tracked by replay.`
|
|
1062
|
-
);
|
|
1063
|
-
}
|
|
1064
|
-
if (files.length === 0) return { patches: [], revertedPatchIds: [] };
|
|
1065
|
-
const diff = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
|
|
1066
|
-
if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
|
|
1067
|
-
const contentHash = this.computeContentHash(diff);
|
|
1068
|
-
const lock = this.lockManager.read();
|
|
1069
|
-
if (lock.patches.some((p) => p.content_hash === contentHash) || (lock.forgotten_hashes ?? []).includes(contentHash)) {
|
|
1070
|
-
return { patches: [], revertedPatchIds: [] };
|
|
1063
|
+
getAccumulatorSnapshot() {
|
|
1064
|
+
const snapshot = /* @__PURE__ */ new Map();
|
|
1065
|
+
for (const [path, entry] of this.fileTheirsAccumulator) {
|
|
1066
|
+
snapshot.set(path, { content: entry.content, baseGeneration: entry.baseGeneration });
|
|
1071
1067
|
}
|
|
1072
|
-
|
|
1073
|
-
const compositePatch = {
|
|
1074
|
-
id: `patch-composite-${headSha.slice(0, 8)}`,
|
|
1075
|
-
content_hash: contentHash,
|
|
1076
|
-
original_commit: headSha,
|
|
1077
|
-
original_message: "Customer customizations (composite)",
|
|
1078
|
-
original_author: "composite",
|
|
1079
|
-
// Use diffBase when commit is unreachable — the applicator needs a reachable
|
|
1080
|
-
// reference to find base file content. diffBase may be the tree_hash.
|
|
1081
|
-
base_generation: commitKnownMissing ? diffBase : lastGen.commit_sha,
|
|
1082
|
-
files,
|
|
1083
|
-
patch_content: diff,
|
|
1084
|
-
...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
|
|
1085
|
-
};
|
|
1086
|
-
return { patches: [compositePatch], revertedPatchIds: [] };
|
|
1068
|
+
return snapshot;
|
|
1087
1069
|
}
|
|
1088
1070
|
/**
|
|
1089
|
-
*
|
|
1090
|
-
*
|
|
1091
|
-
* skips creation-only commits (squashed history after force push).
|
|
1071
|
+
* Apply all patches, returning results for each.
|
|
1072
|
+
* Skips patches that match exclude patterns in replay.yml
|
|
1092
1073
|
*
|
|
1093
|
-
*
|
|
1094
|
-
*
|
|
1074
|
+
* `applyMode` controls the post-conflict marker strategy:
|
|
1075
|
+
* - `"replay"` (default): strip conflict markers from disk between
|
|
1076
|
+
* iterations whenever a later patch touches the same file. Lets the
|
|
1077
|
+
* follow-up patch's `git apply --3way` see clean OURS content,
|
|
1078
|
+
* which is what the silent-loss fix (PR #73) relies on. The replay
|
|
1079
|
+
* pipeline calls `revertConflictingFiles` after the loop to clean
|
|
1080
|
+
* any markers that survive.
|
|
1081
|
+
* - `"resolve"`: keep markers on disk. The resolve command needs the
|
|
1082
|
+
* customer to see them. Slow-path 3-way merge naturally preserves
|
|
1083
|
+
* A's markers and B's clean writes when their regions don't overlap;
|
|
1084
|
+
* when they do overlap, the customer gets nested markers and
|
|
1085
|
+
* resolves manually. Either way they aren't silently dropped.
|
|
1095
1086
|
*/
|
|
1096
|
-
async
|
|
1097
|
-
const
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
this.sdkOutputDir
|
|
1105
|
-
]);
|
|
1106
|
-
if (!log.trim()) {
|
|
1107
|
-
return { patches: [], revertedPatchIds: [] };
|
|
1108
|
-
}
|
|
1109
|
-
const commits = this.parseGitLog(log);
|
|
1110
|
-
const newPatches = [];
|
|
1111
|
-
const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
|
|
1112
|
-
const existingHashes = new Set(lock.patches.map((p) => p.content_hash));
|
|
1113
|
-
const existingCommits = new Set(lock.patches.map((p) => p.original_commit));
|
|
1114
|
-
for (const commit of commits) {
|
|
1115
|
-
if (isGenerationCommit(commit)) {
|
|
1116
|
-
continue;
|
|
1117
|
-
}
|
|
1118
|
-
const parents = await this.git.getCommitParents(commit.sha);
|
|
1119
|
-
if (parents.length > 1) {
|
|
1120
|
-
continue;
|
|
1121
|
-
}
|
|
1122
|
-
if (existingCommits.has(commit.sha)) {
|
|
1123
|
-
continue;
|
|
1124
|
-
}
|
|
1125
|
-
let patchContent;
|
|
1126
|
-
try {
|
|
1127
|
-
patchContent = await this.git.formatPatch(commit.sha);
|
|
1128
|
-
} catch {
|
|
1129
|
-
continue;
|
|
1130
|
-
}
|
|
1131
|
-
let contentHash = this.computeContentHash(patchContent);
|
|
1132
|
-
if (existingHashes.has(contentHash) || forgottenHashes.has(contentHash)) {
|
|
1133
|
-
continue;
|
|
1087
|
+
async applyPatches(patches, opts) {
|
|
1088
|
+
const applyMode = opts?.applyMode ?? "replay";
|
|
1089
|
+
this.currentApplyMode = applyMode;
|
|
1090
|
+
this.resetAccumulator();
|
|
1091
|
+
const fileFreq = /* @__PURE__ */ new Map();
|
|
1092
|
+
for (const p of patches) {
|
|
1093
|
+
for (const f of p.files) {
|
|
1094
|
+
fileFreq.set(f, (fileFreq.get(f) ?? 0) + 1);
|
|
1134
1095
|
}
|
|
1135
|
-
|
|
1096
|
+
}
|
|
1097
|
+
this.sharedFiles = new Set(
|
|
1098
|
+
Array.from(fileFreq.entries()).filter(([, n]) => n > 1).map(([f]) => f)
|
|
1099
|
+
);
|
|
1100
|
+
const results = [];
|
|
1101
|
+
for (let i = 0; i < patches.length; i++) {
|
|
1102
|
+
const patch = patches[i];
|
|
1103
|
+
if (this.isExcluded(patch)) {
|
|
1104
|
+
results.push({
|
|
1105
|
+
patch,
|
|
1106
|
+
status: "skipped",
|
|
1107
|
+
method: "git-am"
|
|
1108
|
+
});
|
|
1136
1109
|
continue;
|
|
1137
1110
|
}
|
|
1138
|
-
const
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
);
|
|
1146
|
-
if (files.length > 0) {
|
|
1147
|
-
if (parents.length === 0) {
|
|
1148
|
-
continue;
|
|
1111
|
+
const result = await this.applyPatchWithFallback(patch);
|
|
1112
|
+
results.push(result);
|
|
1113
|
+
if (applyMode !== "resolve" && result.status === "conflict" && result.fileResults) {
|
|
1114
|
+
const laterFiles = /* @__PURE__ */ new Set();
|
|
1115
|
+
for (let j = i + 1; j < patches.length; j++) {
|
|
1116
|
+
for (const f of patches[j].files) {
|
|
1117
|
+
laterFiles.add(f);
|
|
1149
1118
|
}
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1119
|
+
}
|
|
1120
|
+
const resolvedToOriginal = /* @__PURE__ */ new Map();
|
|
1121
|
+
if (result.resolvedFiles) {
|
|
1122
|
+
for (const [orig, resolved] of Object.entries(result.resolvedFiles)) {
|
|
1123
|
+
resolvedToOriginal.set(resolved, orig);
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
for (const fileResult of result.fileResults) {
|
|
1127
|
+
if (fileResult.status !== "conflict") continue;
|
|
1128
|
+
const originalPath = resolvedToOriginal.get(fileResult.file) ?? fileResult.file;
|
|
1129
|
+
if (laterFiles.has(fileResult.file) || laterFiles.has(originalPath)) {
|
|
1130
|
+
const filePath = join2(this.outputDir, fileResult.file);
|
|
1131
|
+
try {
|
|
1132
|
+
const content = await readFile(filePath, "utf-8");
|
|
1133
|
+
const stripped = stripConflictMarkers(content);
|
|
1134
|
+
await writeFile(filePath, stripped);
|
|
1135
|
+
} catch {
|
|
1136
|
+
}
|
|
1154
1137
|
}
|
|
1155
|
-
if (!patchContent.trim()) continue;
|
|
1156
|
-
contentHash = this.computeContentHash(patchContent);
|
|
1157
1138
|
}
|
|
1158
1139
|
}
|
|
1159
|
-
if (files.length === 0) {
|
|
1160
|
-
continue;
|
|
1161
|
-
}
|
|
1162
|
-
if (parents.length === 0) {
|
|
1163
|
-
continue;
|
|
1164
|
-
}
|
|
1165
|
-
const parentSha = parents[0];
|
|
1166
|
-
newPatches.push({
|
|
1167
|
-
id: `patch-${commit.sha.slice(0, 8)}`,
|
|
1168
|
-
content_hash: contentHash,
|
|
1169
|
-
original_commit: commit.sha,
|
|
1170
|
-
original_message: commit.message,
|
|
1171
|
-
original_author: `${commit.authorName} <${commit.authorEmail}>`,
|
|
1172
|
-
base_generation: parentSha,
|
|
1173
|
-
files,
|
|
1174
|
-
patch_content: patchContent
|
|
1175
|
-
});
|
|
1176
1140
|
}
|
|
1177
|
-
|
|
1178
|
-
return { patches: newPatches, revertedPatchIds: [] };
|
|
1141
|
+
return results;
|
|
1179
1142
|
}
|
|
1180
1143
|
/**
|
|
1181
|
-
*
|
|
1182
|
-
*
|
|
1183
|
-
*
|
|
1144
|
+
* Populate accumulator after git apply succeeds, AND collect per-file
|
|
1145
|
+
* results including any "dropped context lines" — lines that were
|
|
1146
|
+
* present in BASE and THEIRS (unchanged context) but absent from the
|
|
1147
|
+
* final on-disk state because OURS deleted them. This is the fast-path
|
|
1148
|
+
* counterpart to ThreeWayMerge.computeDroppedContextLines used by the
|
|
1149
|
+
* 3-way slow path; both must surface the same warning.
|
|
1184
1150
|
*/
|
|
1185
|
-
|
|
1186
|
-
const
|
|
1187
|
-
if (
|
|
1188
|
-
|
|
1151
|
+
async populateAccumulatorForPatch(patch, baseGen, currentTreeHash) {
|
|
1152
|
+
const fileResults = [];
|
|
1153
|
+
if (!baseGen) return fileResults;
|
|
1154
|
+
const tempDir = await mkdtemp(join2(tmpdir(), "replay-acc-"));
|
|
1155
|
+
const { GitClient: GitClient2 } = await Promise.resolve().then(() => (init_GitClient(), GitClient_exports));
|
|
1156
|
+
const tempGit = new GitClient2(tempDir);
|
|
1157
|
+
await tempGit.exec(["init"]);
|
|
1158
|
+
await tempGit.exec(["config", "user.email", "replay@fern.com"]);
|
|
1159
|
+
await tempGit.exec(["config", "user.name", "Fern Replay"]);
|
|
1160
|
+
try {
|
|
1161
|
+
for (const filePath of patch.files) {
|
|
1162
|
+
if (isBinaryFile(filePath)) continue;
|
|
1163
|
+
const resolvedPath = await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
|
|
1164
|
+
const base = await this.git.showFile(baseGen.tree_hash, filePath);
|
|
1165
|
+
const snapshotForFile = patch.theirs_snapshot?.[resolvedPath] ?? patch.theirs_snapshot?.[filePath];
|
|
1166
|
+
const fileIsShared = this.sharedFiles.has(resolvedPath) || this.sharedFiles.has(filePath);
|
|
1167
|
+
const theirs = !fileIsShared && snapshotForFile != null ? snapshotForFile : await this.applyPatchToContent(base, patch.patch_content, filePath, tempGit, tempDir);
|
|
1168
|
+
const finalOnDisk = await readFile(join2(this.outputDir, resolvedPath), "utf-8").catch(() => null);
|
|
1169
|
+
const effectiveTheirs = theirs ?? finalOnDisk;
|
|
1170
|
+
if (effectiveTheirs != null) {
|
|
1171
|
+
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
1172
|
+
content: effectiveTheirs,
|
|
1173
|
+
baseGeneration: patch.base_generation
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
if (base != null && effectiveTheirs != null && finalOnDisk != null) {
|
|
1177
|
+
const dropped = computeDroppedContextLinesForFile(base, effectiveTheirs, finalOnDisk);
|
|
1178
|
+
if (dropped.length > 0) {
|
|
1179
|
+
fileResults.push({
|
|
1180
|
+
file: resolvedPath,
|
|
1181
|
+
status: "merged",
|
|
1182
|
+
droppedContextLines: dropped
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
} finally {
|
|
1188
|
+
await rm(tempDir, { recursive: true }).catch(() => {
|
|
1189
|
+
});
|
|
1189
1190
|
}
|
|
1190
|
-
return
|
|
1191
|
+
return fileResults;
|
|
1191
1192
|
}
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1193
|
+
async applyPatchWithFallback(patch) {
|
|
1194
|
+
const baseGen = await this.resolveBaseGeneration(patch.base_generation);
|
|
1195
|
+
const lock = this.lockManager.read();
|
|
1196
|
+
const currentGen = lock.generations.find((g) => g.commit_sha === lock.current_generation);
|
|
1197
|
+
const currentTreeHash = currentGen?.tree_hash ?? baseGen?.tree_hash ?? "";
|
|
1198
|
+
const needsAccumulation = await Promise.all(
|
|
1199
|
+
patch.files.map(async (f) => {
|
|
1200
|
+
if (!baseGen) return false;
|
|
1201
|
+
const resolved = await this.resolveFilePath(f, baseGen.tree_hash, currentTreeHash);
|
|
1202
|
+
return this.fileTheirsAccumulator.has(resolved);
|
|
1203
|
+
})
|
|
1204
|
+
).then((results) => results.some(Boolean));
|
|
1205
|
+
const resolvedFiles = {};
|
|
1206
|
+
for (const filePath of patch.files) {
|
|
1207
|
+
const resolvedPath = baseGen ? await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash) : filePath;
|
|
1208
|
+
if (resolvedPath !== filePath) {
|
|
1209
|
+
resolvedFiles[filePath] = resolvedPath;
|
|
1210
|
+
}
|
|
1200
1211
|
}
|
|
1201
|
-
|
|
1202
|
-
|
|
1212
|
+
const patchIsRenameAware = /^rename from /m.test(patch.patch_content);
|
|
1213
|
+
const hasGeneratorRename = Object.keys(resolvedFiles).length > 0 && !patchIsRenameAware;
|
|
1214
|
+
if (!needsAccumulation && !hasGeneratorRename) {
|
|
1215
|
+
const snapshots = /* @__PURE__ */ new Map();
|
|
1216
|
+
for (const filePath of patch.files) {
|
|
1217
|
+
const fullPath = join2(this.outputDir, filePath);
|
|
1218
|
+
snapshots.set(filePath, await readFile(fullPath, "utf-8").catch(() => null));
|
|
1219
|
+
}
|
|
1220
|
+
try {
|
|
1221
|
+
await this.git.execWithInput(["apply", "--3way"], patch.patch_content);
|
|
1222
|
+
const fastPathFileResults = await this.populateAccumulatorForPatch(patch, baseGen, currentTreeHash);
|
|
1223
|
+
return {
|
|
1224
|
+
patch,
|
|
1225
|
+
status: "applied",
|
|
1226
|
+
method: "git-am",
|
|
1227
|
+
...fastPathFileResults.length > 0 && { fileResults: fastPathFileResults },
|
|
1228
|
+
...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
|
|
1229
|
+
};
|
|
1230
|
+
} catch {
|
|
1231
|
+
for (const [filePath, content] of snapshots) {
|
|
1232
|
+
const fullPath = join2(this.outputDir, filePath);
|
|
1233
|
+
if (content != null) {
|
|
1234
|
+
await writeFile(fullPath, content);
|
|
1235
|
+
} else {
|
|
1236
|
+
await unlink(fullPath).catch(() => {
|
|
1237
|
+
});
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1203
1241
|
}
|
|
1204
|
-
return
|
|
1242
|
+
return this.applyWithThreeWayMerge(patch);
|
|
1205
1243
|
}
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
});
|
|
1244
|
+
async applyWithThreeWayMerge(patch) {
|
|
1245
|
+
const fileResults = [];
|
|
1246
|
+
const resolvedFiles = {};
|
|
1247
|
+
const tempDir = await mkdtemp(join2(tmpdir(), "replay-"));
|
|
1248
|
+
const { GitClient: GitClient2 } = await Promise.resolve().then(() => (init_GitClient(), GitClient_exports));
|
|
1249
|
+
const tempGit = new GitClient2(tempDir);
|
|
1250
|
+
await tempGit.exec(["init"]);
|
|
1251
|
+
await tempGit.exec(["config", "user.email", "replay@fern.com"]);
|
|
1252
|
+
await tempGit.exec(["config", "user.name", "Fern Replay"]);
|
|
1253
|
+
try {
|
|
1254
|
+
for (const filePath of patch.files) {
|
|
1255
|
+
if (isBinaryFile(filePath)) {
|
|
1256
|
+
fileResults.push({
|
|
1257
|
+
file: filePath,
|
|
1258
|
+
status: "skipped",
|
|
1259
|
+
reason: "binary-file"
|
|
1260
|
+
});
|
|
1261
|
+
continue;
|
|
1262
|
+
}
|
|
1263
|
+
const result = await this.mergeFile(patch, filePath, tempGit, tempDir);
|
|
1264
|
+
if (result.file !== filePath) {
|
|
1265
|
+
resolvedFiles[filePath] = result.file;
|
|
1266
|
+
}
|
|
1267
|
+
fileResults.push(result);
|
|
1268
|
+
}
|
|
1269
|
+
} finally {
|
|
1270
|
+
await rm(tempDir, { recursive: true }).catch(() => {
|
|
1271
|
+
});
|
|
1272
|
+
}
|
|
1273
|
+
const conflictFiles = fileResults.filter((r) => r.status === "conflict");
|
|
1274
|
+
const hasConflicts = conflictFiles.length > 0;
|
|
1275
|
+
const conflictReason = hasConflicts ? conflictFiles.some((f) => f.conflictReason === "base-generation-mismatch") ? "base-generation-mismatch" : conflictFiles[0]?.conflictReason : void 0;
|
|
1276
|
+
return {
|
|
1277
|
+
patch,
|
|
1278
|
+
status: hasConflicts ? "conflict" : "applied",
|
|
1279
|
+
method: "3way-merge",
|
|
1280
|
+
fileResults,
|
|
1281
|
+
conflictReason,
|
|
1282
|
+
...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
|
|
1283
|
+
};
|
|
1211
1284
|
}
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
// src/ThreeWayMerge.ts
|
|
1218
|
-
import { diff3Merge, diffPatch } from "node-diff3";
|
|
1219
|
-
function threeWayMerge(base, ours, theirs) {
|
|
1220
|
-
const baseLines = base.split("\n");
|
|
1221
|
-
const oursLines = ours.split("\n");
|
|
1222
|
-
const theirsLines = theirs.split("\n");
|
|
1223
|
-
const regions = diff3Merge(oursLines, baseLines, theirsLines);
|
|
1224
|
-
const outputLines = [];
|
|
1225
|
-
const conflicts = [];
|
|
1226
|
-
let currentLine = 1;
|
|
1227
|
-
for (const region of regions) {
|
|
1228
|
-
if (region.ok) {
|
|
1229
|
-
outputLines.push(...region.ok);
|
|
1230
|
-
currentLine += region.ok.length;
|
|
1231
|
-
} else if (region.conflict) {
|
|
1232
|
-
const resolved = tryResolveConflict(
|
|
1233
|
-
region.conflict.a,
|
|
1234
|
-
// ours (generator)
|
|
1235
|
-
region.conflict.o,
|
|
1236
|
-
// base
|
|
1237
|
-
region.conflict.b
|
|
1238
|
-
// theirs (user)
|
|
1239
|
-
);
|
|
1240
|
-
if (resolved !== null) {
|
|
1241
|
-
outputLines.push(...resolved);
|
|
1242
|
-
currentLine += resolved.length;
|
|
1243
|
-
} else {
|
|
1244
|
-
const startLine = currentLine;
|
|
1245
|
-
outputLines.push("<<<<<<< Generated");
|
|
1246
|
-
outputLines.push(...region.conflict.a);
|
|
1247
|
-
outputLines.push("=======");
|
|
1248
|
-
outputLines.push(...region.conflict.b);
|
|
1249
|
-
outputLines.push(">>>>>>> Your customization");
|
|
1250
|
-
const conflictLines = region.conflict.a.length + region.conflict.b.length + 3;
|
|
1251
|
-
conflicts.push({
|
|
1252
|
-
startLine,
|
|
1253
|
-
endLine: startLine + conflictLines - 1,
|
|
1254
|
-
ours: region.conflict.a,
|
|
1255
|
-
theirs: region.conflict.b
|
|
1256
|
-
});
|
|
1257
|
-
currentLine += conflictLines;
|
|
1285
|
+
async mergeFile(patch, filePath, tempGit, tempDir) {
|
|
1286
|
+
try {
|
|
1287
|
+
const baseGen = await this.resolveBaseGeneration(patch.base_generation);
|
|
1288
|
+
if (!baseGen) {
|
|
1289
|
+
return { file: filePath, status: "skipped", reason: "base-generation-not-found" };
|
|
1258
1290
|
}
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
const mergedCounts = countLines(mergedLines);
|
|
1278
|
-
const dropped = [];
|
|
1279
|
-
const seen = /* @__PURE__ */ new Set();
|
|
1280
|
-
for (const line of baseLines) {
|
|
1281
|
-
if (seen.has(line)) continue;
|
|
1282
|
-
const inBase = baseCounts.get(line) ?? 0;
|
|
1283
|
-
const inTheirs = theirsCounts.get(line) ?? 0;
|
|
1284
|
-
const inMerged = mergedCounts.get(line) ?? 0;
|
|
1285
|
-
if (inBase > 0 && inTheirs > 0 && inMerged < Math.min(inBase, inTheirs)) {
|
|
1286
|
-
dropped.push(line);
|
|
1287
|
-
seen.add(line);
|
|
1288
|
-
}
|
|
1289
|
-
}
|
|
1290
|
-
return dropped;
|
|
1291
|
-
}
|
|
1292
|
-
function countLines(lines) {
|
|
1293
|
-
const counts = /* @__PURE__ */ new Map();
|
|
1294
|
-
for (const line of lines) {
|
|
1295
|
-
counts.set(line, (counts.get(line) ?? 0) + 1);
|
|
1296
|
-
}
|
|
1297
|
-
return counts;
|
|
1298
|
-
}
|
|
1299
|
-
function tryResolveConflict(oursLines, baseLines, theirsLines) {
|
|
1300
|
-
if (baseLines.length === 0) {
|
|
1301
|
-
return null;
|
|
1302
|
-
}
|
|
1303
|
-
const oursPatches = diffPatch(baseLines, oursLines);
|
|
1304
|
-
const theirsPatches = diffPatch(baseLines, theirsLines);
|
|
1305
|
-
if (oursPatches.length === 0) return theirsLines;
|
|
1306
|
-
if (theirsPatches.length === 0) return oursLines;
|
|
1307
|
-
if (patchesOverlap(oursPatches, theirsPatches)) {
|
|
1308
|
-
return null;
|
|
1309
|
-
}
|
|
1310
|
-
return applyBothPatches(baseLines, oursPatches, theirsPatches);
|
|
1311
|
-
}
|
|
1312
|
-
function patchesOverlap(oursPatches, theirsPatches) {
|
|
1313
|
-
for (const op of oursPatches) {
|
|
1314
|
-
const oStart = op.buffer1.offset;
|
|
1315
|
-
const oEnd = oStart + op.buffer1.length;
|
|
1316
|
-
for (const tp of theirsPatches) {
|
|
1317
|
-
const tStart = tp.buffer1.offset;
|
|
1318
|
-
const tEnd = tStart + tp.buffer1.length;
|
|
1319
|
-
if (op.buffer1.length === 0 && tp.buffer1.length === 0 && oStart === tStart) {
|
|
1320
|
-
return true;
|
|
1291
|
+
const lock = this.lockManager.read();
|
|
1292
|
+
const currentGen = lock.generations.find((g) => g.commit_sha === lock.current_generation);
|
|
1293
|
+
const currentTreeHash = currentGen?.tree_hash ?? baseGen.tree_hash;
|
|
1294
|
+
const resolvedPath = await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
|
|
1295
|
+
const metadata = {
|
|
1296
|
+
patchId: patch.id,
|
|
1297
|
+
patchMessage: patch.original_message,
|
|
1298
|
+
baseGeneration: patch.base_generation,
|
|
1299
|
+
currentGeneration: lock.current_generation
|
|
1300
|
+
};
|
|
1301
|
+
let base = await this.git.showFile(baseGen.tree_hash, filePath);
|
|
1302
|
+
let renameSourcePath;
|
|
1303
|
+
if (!base) {
|
|
1304
|
+
const renameSource = this.extractRenameSource(patch.patch_content, filePath);
|
|
1305
|
+
if (renameSource) {
|
|
1306
|
+
base = await this.git.showFile(baseGen.tree_hash, renameSource);
|
|
1307
|
+
renameSourcePath = renameSource;
|
|
1308
|
+
}
|
|
1321
1309
|
}
|
|
1322
|
-
|
|
1323
|
-
|
|
1310
|
+
const oursPath = join2(this.outputDir, resolvedPath);
|
|
1311
|
+
const ours = await readFile(oursPath, "utf-8").catch(() => null);
|
|
1312
|
+
let ghostReconstructed = false;
|
|
1313
|
+
let theirs = null;
|
|
1314
|
+
if (!base && ours && !renameSourcePath) {
|
|
1315
|
+
const treeReachable = await this.isTreeReachable(baseGen.tree_hash);
|
|
1316
|
+
if (!treeReachable) {
|
|
1317
|
+
const fileDiff = this.extractFileDiff(patch.patch_content, filePath);
|
|
1318
|
+
if (fileDiff) {
|
|
1319
|
+
const { reconstructFromGhostPatch: reconstructFromGhostPatch2 } = await Promise.resolve().then(() => (init_HybridReconstruction(), HybridReconstruction_exports));
|
|
1320
|
+
const result = reconstructFromGhostPatch2(fileDiff, ours);
|
|
1321
|
+
if (result) {
|
|
1322
|
+
base = result.base;
|
|
1323
|
+
theirs = result.theirs;
|
|
1324
|
+
ghostReconstructed = true;
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1324
1328
|
}
|
|
1325
|
-
|
|
1326
|
-
|
|
1329
|
+
const snapshotForFile = patch.theirs_snapshot?.[resolvedPath] ?? patch.theirs_snapshot?.[filePath];
|
|
1330
|
+
const fileIsShared = this.sharedFiles.has(resolvedPath) || this.sharedFiles.has(filePath);
|
|
1331
|
+
const useSnapshotAsPrimary = !fileIsShared && snapshotForFile != null;
|
|
1332
|
+
if (!ghostReconstructed) {
|
|
1333
|
+
if (useSnapshotAsPrimary) {
|
|
1334
|
+
theirs = snapshotForFile;
|
|
1335
|
+
} else {
|
|
1336
|
+
theirs = await this.applyPatchToContent(
|
|
1337
|
+
base,
|
|
1338
|
+
patch.patch_content,
|
|
1339
|
+
filePath,
|
|
1340
|
+
tempGit,
|
|
1341
|
+
tempDir,
|
|
1342
|
+
renameSourcePath
|
|
1343
|
+
);
|
|
1344
|
+
const reconstructionHasMarkers = theirs != null && (theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization"));
|
|
1345
|
+
const baseHadMarkers = base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization"));
|
|
1346
|
+
if (reconstructionHasMarkers && !baseHadMarkers && snapshotForFile != null) {
|
|
1347
|
+
theirs = snapshotForFile;
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1327
1350
|
}
|
|
1328
|
-
|
|
1329
|
-
|
|
1351
|
+
const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
|
|
1352
|
+
if (theirs) {
|
|
1353
|
+
const theirsHasMarkers = theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization");
|
|
1354
|
+
const baseHasMarkers = base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization"));
|
|
1355
|
+
if (theirsHasMarkers && !baseHasMarkers) {
|
|
1356
|
+
if (accumulatorEntry) {
|
|
1357
|
+
theirs = null;
|
|
1358
|
+
} else {
|
|
1359
|
+
return {
|
|
1360
|
+
file: resolvedPath,
|
|
1361
|
+
status: "skipped",
|
|
1362
|
+
reason: "stale-conflict-markers"
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1330
1366
|
}
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
result.splice(p.offset, p.length, ...p.replacement);
|
|
1352
|
-
}
|
|
1353
|
-
return result;
|
|
1354
|
-
}
|
|
1355
|
-
|
|
1356
|
-
// src/ReplayApplicator.ts
|
|
1357
|
-
import { mkdir, mkdtemp, readFile, rm, unlink, writeFile } from "fs/promises";
|
|
1358
|
-
import { tmpdir } from "os";
|
|
1359
|
-
import { dirname as dirname2, extname, join as join2 } from "path";
|
|
1360
|
-
import { minimatch } from "minimatch";
|
|
1361
|
-
|
|
1362
|
-
// src/conflict-utils.ts
|
|
1363
|
-
var CONFLICT_OPENER = "<<<<<<< Generated";
|
|
1364
|
-
var CONFLICT_SEPARATOR = "=======";
|
|
1365
|
-
var CONFLICT_CLOSER = ">>>>>>> Your customization";
|
|
1366
|
-
function trimCR(line) {
|
|
1367
|
-
return line.endsWith("\r") ? line.slice(0, -1) : line;
|
|
1368
|
-
}
|
|
1369
|
-
function findConflictRanges(lines) {
|
|
1370
|
-
const ranges = [];
|
|
1371
|
-
let i = 0;
|
|
1372
|
-
while (i < lines.length) {
|
|
1373
|
-
if (trimCR(lines[i]) === CONFLICT_OPENER) {
|
|
1374
|
-
let separatorIdx = -1;
|
|
1375
|
-
let j = i + 1;
|
|
1376
|
-
let found = false;
|
|
1377
|
-
while (j < lines.length) {
|
|
1378
|
-
const trimmed = trimCR(lines[j]);
|
|
1379
|
-
if (trimmed === CONFLICT_OPENER) {
|
|
1380
|
-
break;
|
|
1367
|
+
let useAccumulatorAsMergeBase = false;
|
|
1368
|
+
if (accumulatorEntry && (theirs == null || base == null)) {
|
|
1369
|
+
theirs = await this.applyPatchToContent(
|
|
1370
|
+
accumulatorEntry.content,
|
|
1371
|
+
patch.patch_content,
|
|
1372
|
+
filePath,
|
|
1373
|
+
tempGit,
|
|
1374
|
+
tempDir
|
|
1375
|
+
);
|
|
1376
|
+
if (theirs != null) {
|
|
1377
|
+
useAccumulatorAsMergeBase = true;
|
|
1378
|
+
} else if (await this.isPatchAlreadyApplied(
|
|
1379
|
+
patch.patch_content,
|
|
1380
|
+
filePath,
|
|
1381
|
+
accumulatorEntry.content,
|
|
1382
|
+
tempGit,
|
|
1383
|
+
tempDir
|
|
1384
|
+
)) {
|
|
1385
|
+
theirs = accumulatorEntry.content;
|
|
1386
|
+
useAccumulatorAsMergeBase = true;
|
|
1381
1387
|
}
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1388
|
+
}
|
|
1389
|
+
if (theirs) {
|
|
1390
|
+
const theirsHasMarkers = theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization");
|
|
1391
|
+
const accBaseHasMarkers = accumulatorEntry != null && (accumulatorEntry.content.includes("<<<<<<< Generated") || accumulatorEntry.content.includes(">>>>>>> Your customization"));
|
|
1392
|
+
if (theirsHasMarkers && !accBaseHasMarkers && !(base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization")))) {
|
|
1393
|
+
return {
|
|
1394
|
+
file: resolvedPath,
|
|
1395
|
+
status: "skipped",
|
|
1396
|
+
reason: "stale-conflict-markers"
|
|
1397
|
+
};
|
|
1389
1398
|
}
|
|
1390
|
-
j++;
|
|
1391
1399
|
}
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1400
|
+
let effective_theirs = theirs;
|
|
1401
|
+
let baseMismatchSkipped = false;
|
|
1402
|
+
if (theirs != null && base != null && !useAccumulatorAsMergeBase) {
|
|
1403
|
+
if (accumulatorEntry && accumulatorEntry.baseGeneration === patch.base_generation) {
|
|
1404
|
+
try {
|
|
1405
|
+
const preMerged = threeWayMerge(base, accumulatorEntry.content, theirs);
|
|
1406
|
+
if (!preMerged.hasConflicts) {
|
|
1407
|
+
effective_theirs = preMerged.content;
|
|
1408
|
+
} else {
|
|
1409
|
+
effective_theirs = theirs;
|
|
1410
|
+
}
|
|
1411
|
+
} catch {
|
|
1412
|
+
effective_theirs = theirs;
|
|
1413
|
+
}
|
|
1414
|
+
} else if (accumulatorEntry) {
|
|
1415
|
+
baseMismatchSkipped = true;
|
|
1416
|
+
}
|
|
1395
1417
|
}
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
}
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1418
|
+
if (base == null && ours == null && effective_theirs != null) {
|
|
1419
|
+
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
1420
|
+
content: effective_theirs,
|
|
1421
|
+
baseGeneration: patch.base_generation
|
|
1422
|
+
});
|
|
1423
|
+
const outDir2 = dirname2(oursPath);
|
|
1424
|
+
await mkdir(outDir2, { recursive: true });
|
|
1425
|
+
await writeFile(oursPath, effective_theirs);
|
|
1426
|
+
return { file: resolvedPath, status: "merged", reason: "new-file" };
|
|
1427
|
+
}
|
|
1428
|
+
if (base == null && ours != null && effective_theirs != null && !useAccumulatorAsMergeBase) {
|
|
1429
|
+
const merged2 = threeWayMerge("", ours, effective_theirs);
|
|
1430
|
+
const outDir2 = dirname2(oursPath);
|
|
1431
|
+
await mkdir(outDir2, { recursive: true });
|
|
1432
|
+
await writeFile(oursPath, merged2.content);
|
|
1433
|
+
if (merged2.hasConflicts) {
|
|
1434
|
+
return {
|
|
1435
|
+
file: resolvedPath,
|
|
1436
|
+
status: "conflict",
|
|
1437
|
+
conflicts: merged2.conflicts,
|
|
1438
|
+
conflictReason: "new-file-both",
|
|
1439
|
+
conflictMetadata: metadata
|
|
1440
|
+
};
|
|
1441
|
+
}
|
|
1442
|
+
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
1443
|
+
content: merged2.content,
|
|
1444
|
+
baseGeneration: patch.base_generation
|
|
1445
|
+
});
|
|
1446
|
+
return { file: resolvedPath, status: "merged" };
|
|
1414
1447
|
}
|
|
1415
|
-
if (
|
|
1416
|
-
|
|
1417
|
-
|
|
1448
|
+
if (effective_theirs == null) {
|
|
1449
|
+
return {
|
|
1450
|
+
file: resolvedPath,
|
|
1451
|
+
status: "skipped",
|
|
1452
|
+
reason: "missing-content"
|
|
1453
|
+
};
|
|
1418
1454
|
}
|
|
1419
|
-
if (
|
|
1420
|
-
|
|
1455
|
+
if (base == null && !useAccumulatorAsMergeBase || ours == null) {
|
|
1456
|
+
return {
|
|
1457
|
+
file: resolvedPath,
|
|
1458
|
+
status: "skipped",
|
|
1459
|
+
reason: "missing-content"
|
|
1460
|
+
};
|
|
1421
1461
|
}
|
|
1422
|
-
|
|
1423
|
-
|
|
1462
|
+
const mergeBase = useAccumulatorAsMergeBase && accumulatorEntry ? accumulatorEntry.content : base;
|
|
1463
|
+
if (mergeBase == null) {
|
|
1464
|
+
return {
|
|
1465
|
+
file: resolvedPath,
|
|
1466
|
+
status: "skipped",
|
|
1467
|
+
reason: "missing-content"
|
|
1468
|
+
};
|
|
1424
1469
|
}
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1470
|
+
const merged = threeWayMerge(mergeBase, ours, effective_theirs);
|
|
1471
|
+
const outDir = dirname2(oursPath);
|
|
1472
|
+
await mkdir(outDir, { recursive: true });
|
|
1473
|
+
await writeFile(oursPath, merged.content);
|
|
1474
|
+
const populateAccumulator = effective_theirs != null && (!merged.hasConflicts || this.currentApplyMode === "resolve");
|
|
1475
|
+
if (populateAccumulator) {
|
|
1476
|
+
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
1477
|
+
content: effective_theirs,
|
|
1478
|
+
baseGeneration: patch.base_generation
|
|
1479
|
+
});
|
|
1480
|
+
}
|
|
1481
|
+
if (merged.hasConflicts) {
|
|
1482
|
+
return {
|
|
1483
|
+
file: resolvedPath,
|
|
1484
|
+
status: "conflict",
|
|
1485
|
+
conflicts: merged.conflicts,
|
|
1486
|
+
conflictReason: baseMismatchSkipped ? "base-generation-mismatch" : "same-line-edit",
|
|
1487
|
+
conflictMetadata: metadata
|
|
1488
|
+
};
|
|
1428
1489
|
}
|
|
1429
|
-
}
|
|
1430
|
-
result.push(lines[i]);
|
|
1431
|
-
}
|
|
1432
|
-
return result.join("\n");
|
|
1433
|
-
}
|
|
1434
|
-
|
|
1435
|
-
// src/ReplayApplicator.ts
|
|
1436
|
-
var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
1437
|
-
".png",
|
|
1438
|
-
".jpg",
|
|
1439
|
-
".jpeg",
|
|
1440
|
-
".gif",
|
|
1441
|
-
".bmp",
|
|
1442
|
-
".ico",
|
|
1443
|
-
".webp",
|
|
1444
|
-
".svg",
|
|
1445
|
-
".pdf",
|
|
1446
|
-
".doc",
|
|
1447
|
-
".docx",
|
|
1448
|
-
".xls",
|
|
1449
|
-
".xlsx",
|
|
1450
|
-
".ppt",
|
|
1451
|
-
".pptx",
|
|
1452
|
-
".zip",
|
|
1453
|
-
".gz",
|
|
1454
|
-
".tar",
|
|
1455
|
-
".bz2",
|
|
1456
|
-
".7z",
|
|
1457
|
-
".rar",
|
|
1458
|
-
".jar",
|
|
1459
|
-
".war",
|
|
1460
|
-
".ear",
|
|
1461
|
-
".class",
|
|
1462
|
-
".exe",
|
|
1463
|
-
".dll",
|
|
1464
|
-
".so",
|
|
1465
|
-
".dylib",
|
|
1466
|
-
".o",
|
|
1467
|
-
".a",
|
|
1468
|
-
".woff",
|
|
1469
|
-
".woff2",
|
|
1470
|
-
".ttf",
|
|
1471
|
-
".eot",
|
|
1472
|
-
".otf",
|
|
1473
|
-
".mp3",
|
|
1474
|
-
".mp4",
|
|
1475
|
-
".avi",
|
|
1476
|
-
".mov",
|
|
1477
|
-
".wav",
|
|
1478
|
-
".flac",
|
|
1479
|
-
".sqlite",
|
|
1480
|
-
".db",
|
|
1481
|
-
".pyc",
|
|
1482
|
-
".pyo",
|
|
1483
|
-
".DS_Store"
|
|
1484
|
-
]);
|
|
1485
|
-
var ReplayApplicator = class {
|
|
1486
|
-
git;
|
|
1487
|
-
lockManager;
|
|
1488
|
-
outputDir;
|
|
1489
|
-
renameCache = /* @__PURE__ */ new Map();
|
|
1490
|
-
treeExistsCache = /* @__PURE__ */ new Map();
|
|
1491
|
-
fileTheirsAccumulator = /* @__PURE__ */ new Map();
|
|
1492
|
-
/**
|
|
1493
|
-
* Apply mode for the current `applyPatches` invocation. Set at the start
|
|
1494
|
-
* of `applyPatches`, read by `mergeFile` to decide:
|
|
1495
|
-
* - whether to skip the intra-loop marker strip (kept in `applyPatches`)
|
|
1496
|
-
* - whether to populate the accumulator after a conflicted merge
|
|
1497
|
-
* (resolve mode populates so subsequent patches on the same file
|
|
1498
|
-
* can use patch A's THEIRS as a structurally-correct merge base
|
|
1499
|
-
* when their diff was authored against post-A structure)
|
|
1500
|
-
* - whether to retry THEIRS reconstruction against the accumulator
|
|
1501
|
-
* when the BASE-relative reconstruction produced markers from a
|
|
1502
|
-
* cross-patch context mismatch
|
|
1503
|
-
*/
|
|
1504
|
-
currentApplyMode = "replay";
|
|
1505
|
-
constructor(git, lockManager, outputDir) {
|
|
1506
|
-
this.git = git;
|
|
1507
|
-
this.lockManager = lockManager;
|
|
1508
|
-
this.outputDir = outputDir;
|
|
1509
|
-
}
|
|
1510
|
-
/**
|
|
1511
|
-
* Resolve the GenerationRecord for a patch's base_generation.
|
|
1512
|
-
* Falls back to constructing an ad-hoc record from the commit's tree
|
|
1513
|
-
* when base_generation isn't a tracked generation (commit-scan patches).
|
|
1514
|
-
*/
|
|
1515
|
-
async resolveBaseGeneration(baseGeneration) {
|
|
1516
|
-
const gen = this.lockManager.getGeneration(baseGeneration);
|
|
1517
|
-
if (gen) return gen;
|
|
1518
|
-
try {
|
|
1519
|
-
const treeHash = await this.git.getTreeHash(baseGeneration);
|
|
1520
1490
|
return {
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1491
|
+
file: resolvedPath,
|
|
1492
|
+
status: "merged",
|
|
1493
|
+
...merged.droppedContextLines && merged.droppedContextLines.length > 0 ? { droppedContextLines: merged.droppedContextLines } : {}
|
|
1494
|
+
};
|
|
1495
|
+
} catch (error) {
|
|
1496
|
+
return {
|
|
1497
|
+
file: filePath,
|
|
1498
|
+
status: "skipped",
|
|
1499
|
+
reason: `error: ${error instanceof Error ? error.message : String(error)}`
|
|
1526
1500
|
};
|
|
1527
|
-
} catch {
|
|
1528
|
-
return void 0;
|
|
1529
1501
|
}
|
|
1530
1502
|
}
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
* @internal Test-only.
|
|
1537
|
-
* Returns a defensive copy of the inter-patch accumulator. Used by the
|
|
1538
|
-
* adversarial test suite (FER-9791) to assert invariant I2: after every
|
|
1539
|
-
* applied patch, the accumulator has an entry for each file the patch
|
|
1540
|
-
* touched, and the entry's content matches on-disk.
|
|
1541
|
-
*/
|
|
1542
|
-
getAccumulatorSnapshot() {
|
|
1543
|
-
const snapshot = /* @__PURE__ */ new Map();
|
|
1544
|
-
for (const [path, entry] of this.fileTheirsAccumulator) {
|
|
1545
|
-
snapshot.set(path, { content: entry.content, baseGeneration: entry.baseGeneration });
|
|
1503
|
+
async isTreeReachable(treeHash) {
|
|
1504
|
+
let result = this.treeExistsCache.get(treeHash);
|
|
1505
|
+
if (result === void 0) {
|
|
1506
|
+
result = await this.git.treeExists(treeHash);
|
|
1507
|
+
this.treeExistsCache.set(treeHash, result);
|
|
1546
1508
|
}
|
|
1547
|
-
return
|
|
1509
|
+
return result;
|
|
1548
1510
|
}
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
* customer to see them. Slow-path 3-way merge naturally preserves
|
|
1562
|
-
* A's markers and B's clean writes when their regions don't overlap;
|
|
1563
|
-
* when they do overlap, the customer gets nested markers and
|
|
1564
|
-
* resolves manually. Either way they aren't silently dropped.
|
|
1565
|
-
*/
|
|
1566
|
-
async applyPatches(patches, opts) {
|
|
1567
|
-
const applyMode = opts?.applyMode ?? "replay";
|
|
1568
|
-
this.currentApplyMode = applyMode;
|
|
1569
|
-
this.resetAccumulator();
|
|
1570
|
-
const results = [];
|
|
1571
|
-
for (let i = 0; i < patches.length; i++) {
|
|
1572
|
-
const patch = patches[i];
|
|
1573
|
-
if (this.isExcluded(patch)) {
|
|
1574
|
-
results.push({
|
|
1575
|
-
patch,
|
|
1576
|
-
status: "skipped",
|
|
1577
|
-
method: "git-am"
|
|
1578
|
-
});
|
|
1579
|
-
continue;
|
|
1580
|
-
}
|
|
1581
|
-
const result = await this.applyPatchWithFallback(patch);
|
|
1582
|
-
results.push(result);
|
|
1583
|
-
if (applyMode !== "resolve" && result.status === "conflict" && result.fileResults) {
|
|
1584
|
-
const laterFiles = /* @__PURE__ */ new Set();
|
|
1585
|
-
for (let j = i + 1; j < patches.length; j++) {
|
|
1586
|
-
for (const f of patches[j].files) {
|
|
1587
|
-
laterFiles.add(f);
|
|
1588
|
-
}
|
|
1589
|
-
}
|
|
1590
|
-
const resolvedToOriginal = /* @__PURE__ */ new Map();
|
|
1591
|
-
if (result.resolvedFiles) {
|
|
1592
|
-
for (const [orig, resolved] of Object.entries(result.resolvedFiles)) {
|
|
1593
|
-
resolvedToOriginal.set(resolved, orig);
|
|
1511
|
+
isExcluded(patch) {
|
|
1512
|
+
const config = this.lockManager.getCustomizationsConfig();
|
|
1513
|
+
if (!config.exclude) return false;
|
|
1514
|
+
return patch.files.some((file) => config.exclude.some((pattern) => minimatch(file, pattern)));
|
|
1515
|
+
}
|
|
1516
|
+
async resolveFilePath(filePath, baseTreeHash, currentTreeHash) {
|
|
1517
|
+
const config = this.lockManager.getCustomizationsConfig();
|
|
1518
|
+
if (config.moves) {
|
|
1519
|
+
for (const move of config.moves) {
|
|
1520
|
+
if (minimatch(filePath, move.from) || filePath === move.from) {
|
|
1521
|
+
if (filePath === move.from) {
|
|
1522
|
+
return move.to;
|
|
1594
1523
|
}
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
if (
|
|
1598
|
-
|
|
1599
|
-
if (laterFiles.has(fileResult.file) || laterFiles.has(originalPath)) {
|
|
1600
|
-
const filePath = join2(this.outputDir, fileResult.file);
|
|
1601
|
-
try {
|
|
1602
|
-
const content = await readFile(filePath, "utf-8");
|
|
1603
|
-
const stripped = stripConflictMarkers(content);
|
|
1604
|
-
await writeFile(filePath, stripped);
|
|
1605
|
-
} catch {
|
|
1606
|
-
}
|
|
1524
|
+
const fromBase = move.from.replace(/\*\*.*$/, "");
|
|
1525
|
+
const toBase = move.to.replace(/\*\*.*$/, "");
|
|
1526
|
+
if (filePath.startsWith(fromBase)) {
|
|
1527
|
+
return toBase + filePath.slice(fromBase.length);
|
|
1607
1528
|
}
|
|
1608
1529
|
}
|
|
1609
1530
|
}
|
|
1610
1531
|
}
|
|
1611
|
-
|
|
1532
|
+
const cacheKey = `${baseTreeHash}:${currentTreeHash}`;
|
|
1533
|
+
let renames = this.renameCache.get(cacheKey);
|
|
1534
|
+
if (!renames) {
|
|
1535
|
+
renames = await this.git.detectRenames(baseTreeHash, currentTreeHash);
|
|
1536
|
+
this.renameCache.set(cacheKey, renames);
|
|
1537
|
+
}
|
|
1538
|
+
const gitRename = renames.find((r) => r.from === filePath);
|
|
1539
|
+
if (gitRename) {
|
|
1540
|
+
return gitRename.to;
|
|
1541
|
+
}
|
|
1542
|
+
return filePath;
|
|
1543
|
+
}
|
|
1544
|
+
async applyPatchToContent(base, patchContent, filePath, tempGit, tempDir, sourceFilePath) {
|
|
1545
|
+
if (!base) {
|
|
1546
|
+
return this.extractNewFileFromPatch(patchContent, filePath);
|
|
1547
|
+
}
|
|
1548
|
+
const fileDiff = this.extractFileDiff(patchContent, filePath);
|
|
1549
|
+
if (!fileDiff) return null;
|
|
1550
|
+
try {
|
|
1551
|
+
if (sourceFilePath) {
|
|
1552
|
+
const tempSourcePath = join2(tempDir, sourceFilePath);
|
|
1553
|
+
await mkdir(dirname2(tempSourcePath), { recursive: true });
|
|
1554
|
+
await writeFile(tempSourcePath, base);
|
|
1555
|
+
await tempGit.exec(["add", sourceFilePath]);
|
|
1556
|
+
await tempGit.exec([
|
|
1557
|
+
"commit",
|
|
1558
|
+
"-m",
|
|
1559
|
+
`base for rename ${sourceFilePath} -> ${filePath}`,
|
|
1560
|
+
"--allow-empty"
|
|
1561
|
+
]);
|
|
1562
|
+
await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
|
|
1563
|
+
const tempTargetPath = join2(tempDir, filePath);
|
|
1564
|
+
return await readFile(tempTargetPath, "utf-8");
|
|
1565
|
+
}
|
|
1566
|
+
const tempFilePath = join2(tempDir, filePath);
|
|
1567
|
+
await mkdir(dirname2(tempFilePath), { recursive: true });
|
|
1568
|
+
await writeFile(tempFilePath, base);
|
|
1569
|
+
await tempGit.exec(["add", filePath]);
|
|
1570
|
+
await tempGit.exec(["commit", "-m", `base for ${filePath}`, "--allow-empty"]);
|
|
1571
|
+
await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
|
|
1572
|
+
return await readFile(tempFilePath, "utf-8");
|
|
1573
|
+
} catch {
|
|
1574
|
+
return null;
|
|
1575
|
+
}
|
|
1612
1576
|
}
|
|
1613
1577
|
/**
|
|
1614
|
-
*
|
|
1615
|
-
*
|
|
1616
|
-
*
|
|
1617
|
-
*
|
|
1618
|
-
*
|
|
1619
|
-
* 3-way slow path; both must surface the same warning.
|
|
1578
|
+
* Detects whether a patch's additions are already present in `content`.
|
|
1579
|
+
* Uses `git apply --reverse --check` — if the reverse patch applies cleanly,
|
|
1580
|
+
* the forward patch is effectively a no-op (its +lines are already there and
|
|
1581
|
+
* its -lines are already gone). Used to distinguish "patch already applied"
|
|
1582
|
+
* from "patch base mismatch" after a forward-apply fails.
|
|
1620
1583
|
*/
|
|
1621
|
-
async
|
|
1622
|
-
const
|
|
1623
|
-
if (!
|
|
1624
|
-
const tempDir = await mkdtemp(join2(tmpdir(), "replay-acc-"));
|
|
1625
|
-
const { GitClient: GitClient2 } = await Promise.resolve().then(() => (init_GitClient(), GitClient_exports));
|
|
1626
|
-
const tempGit = new GitClient2(tempDir);
|
|
1627
|
-
await tempGit.exec(["init"]);
|
|
1628
|
-
await tempGit.exec(["config", "user.email", "replay@fern.com"]);
|
|
1629
|
-
await tempGit.exec(["config", "user.name", "Fern Replay"]);
|
|
1584
|
+
async isPatchAlreadyApplied(patchContent, filePath, content, tempGit, tempDir) {
|
|
1585
|
+
const fileDiff = this.extractFileDiff(patchContent, filePath);
|
|
1586
|
+
if (!fileDiff) return false;
|
|
1630
1587
|
try {
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1588
|
+
const tempFilePath = join2(tempDir, filePath);
|
|
1589
|
+
await mkdir(dirname2(tempFilePath), { recursive: true });
|
|
1590
|
+
await writeFile(tempFilePath, content);
|
|
1591
|
+
await tempGit.exec(["add", filePath]);
|
|
1592
|
+
await tempGit.exec(["commit", "-m", `check already-applied ${filePath}`, "--allow-empty"]);
|
|
1593
|
+
await tempGit.execWithInput(["apply", "--reverse", "--check", "--allow-empty"], fileDiff);
|
|
1594
|
+
return true;
|
|
1595
|
+
} catch {
|
|
1596
|
+
return false;
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
extractFileDiff(patchContent, filePath) {
|
|
1600
|
+
const lines = patchContent.split("\n");
|
|
1601
|
+
const diffLines = [];
|
|
1602
|
+
let inTargetFile = false;
|
|
1603
|
+
for (const line of lines) {
|
|
1604
|
+
if (line.startsWith("diff --git")) {
|
|
1605
|
+
if (inTargetFile) {
|
|
1606
|
+
break;
|
|
1643
1607
|
}
|
|
1644
|
-
if (
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
fileResults.push({
|
|
1648
|
-
file: resolvedPath,
|
|
1649
|
-
status: "merged",
|
|
1650
|
-
droppedContextLines: dropped
|
|
1651
|
-
});
|
|
1652
|
-
}
|
|
1608
|
+
if (isDiffLineForFile(line, filePath)) {
|
|
1609
|
+
inTargetFile = true;
|
|
1610
|
+
diffLines.push(line);
|
|
1653
1611
|
}
|
|
1612
|
+
continue;
|
|
1613
|
+
}
|
|
1614
|
+
if (inTargetFile) {
|
|
1615
|
+
diffLines.push(line);
|
|
1654
1616
|
}
|
|
1655
|
-
} finally {
|
|
1656
|
-
await rm(tempDir, { recursive: true }).catch(() => {
|
|
1657
|
-
});
|
|
1658
1617
|
}
|
|
1659
|
-
return
|
|
1618
|
+
return diffLines.length > 0 ? diffLines.join("\n") + "\n" : null;
|
|
1660
1619
|
}
|
|
1661
|
-
|
|
1662
|
-
const
|
|
1663
|
-
|
|
1664
|
-
const
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
for (const filePath of patch.files) {
|
|
1675
|
-
const resolvedPath = baseGen ? await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash) : filePath;
|
|
1676
|
-
if (resolvedPath !== filePath) {
|
|
1677
|
-
resolvedFiles[filePath] = resolvedPath;
|
|
1620
|
+
extractRenameSource(patchContent, targetFilePath) {
|
|
1621
|
+
const lines = patchContent.split("\n");
|
|
1622
|
+
let inTargetFile = false;
|
|
1623
|
+
for (const line of lines) {
|
|
1624
|
+
if (line.startsWith("diff --git")) {
|
|
1625
|
+
if (inTargetFile) break;
|
|
1626
|
+
inTargetFile = isDiffLineForFile(line, targetFilePath);
|
|
1627
|
+
continue;
|
|
1628
|
+
}
|
|
1629
|
+
if (!inTargetFile) continue;
|
|
1630
|
+
if (line.startsWith("@@")) break;
|
|
1631
|
+
if (line.startsWith("rename from ")) {
|
|
1632
|
+
return line.slice("rename from ".length);
|
|
1678
1633
|
}
|
|
1679
1634
|
}
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1635
|
+
return null;
|
|
1636
|
+
}
|
|
1637
|
+
extractNewFileFromPatch(patchContent, filePath) {
|
|
1638
|
+
const lines = patchContent.split("\n");
|
|
1639
|
+
const addedLines = [];
|
|
1640
|
+
let inTargetFile = false;
|
|
1641
|
+
let inHunk = false;
|
|
1642
|
+
let isNewFile = false;
|
|
1643
|
+
let noTrailingNewline = false;
|
|
1644
|
+
for (const line of lines) {
|
|
1645
|
+
if (line.startsWith("diff --git")) {
|
|
1646
|
+
if (inTargetFile) break;
|
|
1647
|
+
inTargetFile = isDiffLineForFile(line, filePath);
|
|
1648
|
+
inHunk = false;
|
|
1649
|
+
isNewFile = false;
|
|
1650
|
+
continue;
|
|
1687
1651
|
}
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
patch,
|
|
1693
|
-
status: "applied",
|
|
1694
|
-
method: "git-am",
|
|
1695
|
-
...fastPathFileResults.length > 0 && { fileResults: fastPathFileResults },
|
|
1696
|
-
...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
|
|
1697
|
-
};
|
|
1698
|
-
} catch {
|
|
1699
|
-
for (const [filePath, content] of snapshots) {
|
|
1700
|
-
const fullPath = join2(this.outputDir, filePath);
|
|
1701
|
-
if (content != null) {
|
|
1702
|
-
await writeFile(fullPath, content);
|
|
1703
|
-
} else {
|
|
1704
|
-
await unlink(fullPath).catch(() => {
|
|
1705
|
-
});
|
|
1706
|
-
}
|
|
1652
|
+
if (!inTargetFile) continue;
|
|
1653
|
+
if (!inHunk) {
|
|
1654
|
+
if (line === "--- /dev/null" || line.startsWith("new file mode")) {
|
|
1655
|
+
isNewFile = true;
|
|
1707
1656
|
}
|
|
1708
1657
|
}
|
|
1658
|
+
if (line.startsWith("@@")) {
|
|
1659
|
+
if (!isNewFile) return null;
|
|
1660
|
+
inHunk = true;
|
|
1661
|
+
continue;
|
|
1662
|
+
}
|
|
1663
|
+
if (!inHunk) continue;
|
|
1664
|
+
if (line === "\") {
|
|
1665
|
+
noTrailingNewline = true;
|
|
1666
|
+
continue;
|
|
1667
|
+
}
|
|
1668
|
+
if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
1669
|
+
addedLines.push(line.slice(1));
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
if (addedLines.length === 0) return null;
|
|
1673
|
+
return addedLines.join("\n") + (noTrailingNewline ? "" : "\n");
|
|
1674
|
+
}
|
|
1675
|
+
};
|
|
1676
|
+
function isBinaryFile(filePath) {
|
|
1677
|
+
const ext = extname(filePath).toLowerCase();
|
|
1678
|
+
return BINARY_EXTENSIONS.has(ext);
|
|
1679
|
+
}
|
|
1680
|
+
function computeDroppedContextLinesForFile(base, theirs, finalContent) {
|
|
1681
|
+
const baseLines = base.split("\n");
|
|
1682
|
+
const theirsLines = theirs.split("\n");
|
|
1683
|
+
const finalLines = finalContent.split("\n");
|
|
1684
|
+
const baseCounts = /* @__PURE__ */ new Map();
|
|
1685
|
+
const theirsCounts = /* @__PURE__ */ new Map();
|
|
1686
|
+
const finalCounts = /* @__PURE__ */ new Map();
|
|
1687
|
+
for (const l of baseLines) baseCounts.set(l, (baseCounts.get(l) ?? 0) + 1);
|
|
1688
|
+
for (const l of theirsLines) theirsCounts.set(l, (theirsCounts.get(l) ?? 0) + 1);
|
|
1689
|
+
for (const l of finalLines) finalCounts.set(l, (finalCounts.get(l) ?? 0) + 1);
|
|
1690
|
+
const dropped = [];
|
|
1691
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1692
|
+
for (const line of baseLines) {
|
|
1693
|
+
if (seen.has(line)) continue;
|
|
1694
|
+
const inBase = baseCounts.get(line) ?? 0;
|
|
1695
|
+
const inTheirs = theirsCounts.get(line) ?? 0;
|
|
1696
|
+
const inFinal = finalCounts.get(line) ?? 0;
|
|
1697
|
+
if (inBase > 0 && inTheirs > 0 && inFinal < Math.min(inBase, inTheirs)) {
|
|
1698
|
+
dropped.push(line);
|
|
1699
|
+
seen.add(line);
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
return dropped;
|
|
1703
|
+
}
|
|
1704
|
+
function isDiffLineForFile(diffLine, filePath) {
|
|
1705
|
+
const match = diffLine.match(/^diff --git a\/.+ b\/(.+)$/);
|
|
1706
|
+
return match !== null && match[1] === filePath;
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
// src/ReplayDetector.ts
|
|
1710
|
+
var INFRASTRUCTURE_FILES = /* @__PURE__ */ new Set([".fernignore"]);
|
|
1711
|
+
function parsePatchFileHeaders(patchContent) {
|
|
1712
|
+
const results = [];
|
|
1713
|
+
let currentPath = null;
|
|
1714
|
+
let currentIsCreate = false;
|
|
1715
|
+
let currentIsDelete = false;
|
|
1716
|
+
const flush = () => {
|
|
1717
|
+
if (currentPath !== null) {
|
|
1718
|
+
results.push({ path: currentPath, isCreate: currentIsCreate, isDelete: currentIsDelete });
|
|
1719
|
+
}
|
|
1720
|
+
};
|
|
1721
|
+
for (const line of patchContent.split("\n")) {
|
|
1722
|
+
if (line.startsWith("diff --git ")) {
|
|
1723
|
+
flush();
|
|
1724
|
+
currentPath = null;
|
|
1725
|
+
currentIsCreate = false;
|
|
1726
|
+
currentIsDelete = false;
|
|
1727
|
+
const match = line.match(/^diff --git a\/(.+?) b\/(.+?)$/);
|
|
1728
|
+
if (match) {
|
|
1729
|
+
currentPath = match[2] ?? null;
|
|
1730
|
+
}
|
|
1731
|
+
} else if (currentPath !== null) {
|
|
1732
|
+
if (line.startsWith("new file mode ")) {
|
|
1733
|
+
currentIsCreate = true;
|
|
1734
|
+
} else if (line.startsWith("deleted file mode ")) {
|
|
1735
|
+
currentIsDelete = true;
|
|
1736
|
+
}
|
|
1709
1737
|
}
|
|
1710
|
-
return this.applyWithThreeWayMerge(patch);
|
|
1711
1738
|
}
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
}
|
|
1731
|
-
const result = await this.mergeFile(patch, filePath, tempGit, tempDir);
|
|
1732
|
-
if (result.file !== filePath) {
|
|
1733
|
-
resolvedFiles[filePath] = result.file;
|
|
1734
|
-
}
|
|
1735
|
-
fileResults.push(result);
|
|
1736
|
-
}
|
|
1737
|
-
} finally {
|
|
1738
|
-
await rm(tempDir, { recursive: true }).catch(() => {
|
|
1739
|
-
});
|
|
1739
|
+
flush();
|
|
1740
|
+
return results;
|
|
1741
|
+
}
|
|
1742
|
+
var ReplayDetector = class {
|
|
1743
|
+
git;
|
|
1744
|
+
lockManager;
|
|
1745
|
+
sdkOutputDir;
|
|
1746
|
+
warnings = [];
|
|
1747
|
+
constructor(git, lockManager, sdkOutputDir) {
|
|
1748
|
+
this.git = git;
|
|
1749
|
+
this.lockManager = lockManager;
|
|
1750
|
+
this.sdkOutputDir = sdkOutputDir;
|
|
1751
|
+
}
|
|
1752
|
+
async detectNewPatches() {
|
|
1753
|
+
const lock = this.lockManager.read();
|
|
1754
|
+
const lastGen = this.getLastGeneration(lock);
|
|
1755
|
+
if (!lastGen) {
|
|
1756
|
+
return { patches: [], revertedPatchIds: [] };
|
|
1740
1757
|
}
|
|
1741
|
-
const
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
}
|
|
1758
|
+
const exists = await this.git.commitExists(lastGen.commit_sha);
|
|
1759
|
+
if (!exists) {
|
|
1760
|
+
this.warnings.push(
|
|
1761
|
+
`Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to alternate detection.`
|
|
1762
|
+
);
|
|
1763
|
+
return this.detectPatchesViaTreeDiff(
|
|
1764
|
+
lastGen,
|
|
1765
|
+
/* commitKnownMissing */
|
|
1766
|
+
true
|
|
1767
|
+
);
|
|
1768
|
+
}
|
|
1769
|
+
const isAncestor = await this.git.isAncestor(lastGen.commit_sha, "HEAD");
|
|
1770
|
+
if (!isAncestor) {
|
|
1771
|
+
return this.detectPatchesViaMergeBase(lastGen, lock);
|
|
1772
|
+
}
|
|
1773
|
+
return this.detectPatchesInRange(lastGen.commit_sha, lastGen, lock);
|
|
1752
1774
|
}
|
|
1753
|
-
|
|
1775
|
+
/**
|
|
1776
|
+
* FER-10201 — Non-linear-history detection via `merge-base(prevGen, HEAD)`.
|
|
1777
|
+
*
|
|
1778
|
+
* After a squash-merge of a Fern-bot PR, `lastGen.commit_sha` (the previous
|
|
1779
|
+
* `[fern-generated]`) is orphaned but still in git's object database. The
|
|
1780
|
+
* merge-base is the commit on main from which the bot's branch was created —
|
|
1781
|
+
* a reachable ancestor of HEAD. Walking that range and classifying each
|
|
1782
|
+
* commit via `isGenerationCommit` mirrors the linear path and eliminates
|
|
1783
|
+
* the bug class where pipeline-driven changes (autoversion, replay,
|
|
1784
|
+
* generation) get bundled as customer customizations.
|
|
1785
|
+
*
|
|
1786
|
+
* Falls through to the legacy composite path when no common ancestor exists
|
|
1787
|
+
* (truly disjoint history — orphan branches with no shared root).
|
|
1788
|
+
*/
|
|
1789
|
+
async detectPatchesViaMergeBase(lastGen, lock) {
|
|
1790
|
+
let mergeBase = "";
|
|
1754
1791
|
try {
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1792
|
+
mergeBase = (await this.git.exec(["merge-base", lastGen.commit_sha, "HEAD"])).trim();
|
|
1793
|
+
} catch {
|
|
1794
|
+
}
|
|
1795
|
+
if (!mergeBase) {
|
|
1796
|
+
this.warnings.push(
|
|
1797
|
+
`No common ancestor between previous generation ${lastGen.commit_sha.slice(0, 7)} and HEAD. Falling back to composite tree-diff detection.`
|
|
1798
|
+
);
|
|
1799
|
+
return this.detectPatchesViaTreeDiff(
|
|
1800
|
+
lastGen,
|
|
1801
|
+
/* commitKnownMissing */
|
|
1802
|
+
false
|
|
1803
|
+
);
|
|
1804
|
+
}
|
|
1805
|
+
return this.detectPatchesInRange(mergeBase, lastGen, lock);
|
|
1806
|
+
}
|
|
1807
|
+
/**
|
|
1808
|
+
* FER-10201 — Walk `${rangeStart}..HEAD`, classify each commit, emit one
|
|
1809
|
+
* `StoredPatch` per customer commit. Body shared by the linear path
|
|
1810
|
+
* (rangeStart = lastGen.commit_sha) and the merge-base path.
|
|
1811
|
+
*
|
|
1812
|
+
* Patch `base_generation` is always `lastGen.commit_sha` — the
|
|
1813
|
+
* lockfile-tracked reference the applicator uses to fetch base file
|
|
1814
|
+
* content, regardless of where the walk actually started.
|
|
1815
|
+
*/
|
|
1816
|
+
async detectPatchesInRange(rangeStart, lastGen, lock) {
|
|
1817
|
+
const log = await this.git.exec([
|
|
1818
|
+
"log",
|
|
1819
|
+
"--first-parent",
|
|
1820
|
+
"--format=%H%x00%an%x00%ae%x00%s",
|
|
1821
|
+
`${rangeStart}..HEAD`,
|
|
1822
|
+
"--",
|
|
1823
|
+
this.sdkOutputDir
|
|
1824
|
+
]);
|
|
1825
|
+
if (!log.trim()) {
|
|
1826
|
+
return { patches: [], revertedPatchIds: [] };
|
|
1827
|
+
}
|
|
1828
|
+
const commits = this.parseGitLog(log);
|
|
1829
|
+
const newPatches = [];
|
|
1830
|
+
const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
|
|
1831
|
+
for (const commit of commits) {
|
|
1832
|
+
if (isGenerationCommit(commit)) {
|
|
1833
|
+
continue;
|
|
1796
1834
|
}
|
|
1797
|
-
if (
|
|
1798
|
-
|
|
1799
|
-
base,
|
|
1800
|
-
patch.patch_content,
|
|
1801
|
-
filePath,
|
|
1802
|
-
tempGit,
|
|
1803
|
-
tempDir,
|
|
1804
|
-
renameSourcePath
|
|
1805
|
-
);
|
|
1835
|
+
if (lock.patches.find((p) => p.original_commit === commit.sha)) {
|
|
1836
|
+
continue;
|
|
1806
1837
|
}
|
|
1807
|
-
const
|
|
1808
|
-
if (
|
|
1809
|
-
const
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
status: "skipped",
|
|
1818
|
-
reason: "stale-conflict-markers"
|
|
1819
|
-
};
|
|
1820
|
-
}
|
|
1838
|
+
const parents = await this.git.getCommitParents(commit.sha);
|
|
1839
|
+
if (parents.length > 1) {
|
|
1840
|
+
const compositePatch = await this.createMergeCompositePatch({
|
|
1841
|
+
mergeCommit: commit,
|
|
1842
|
+
firstParent: parents[0],
|
|
1843
|
+
lastGen,
|
|
1844
|
+
lock
|
|
1845
|
+
});
|
|
1846
|
+
if (compositePatch) {
|
|
1847
|
+
newPatches.push(compositePatch);
|
|
1821
1848
|
}
|
|
1849
|
+
continue;
|
|
1822
1850
|
}
|
|
1823
|
-
let
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
tempGit,
|
|
1830
|
-
tempDir
|
|
1851
|
+
let patchContent;
|
|
1852
|
+
try {
|
|
1853
|
+
patchContent = await this.git.formatPatch(commit.sha);
|
|
1854
|
+
} catch {
|
|
1855
|
+
this.warnings.push(
|
|
1856
|
+
`Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
|
|
1831
1857
|
);
|
|
1832
|
-
|
|
1833
|
-
useAccumulatorAsMergeBase = true;
|
|
1834
|
-
} else if (await this.isPatchAlreadyApplied(
|
|
1835
|
-
patch.patch_content,
|
|
1836
|
-
filePath,
|
|
1837
|
-
accumulatorEntry.content,
|
|
1838
|
-
tempGit,
|
|
1839
|
-
tempDir
|
|
1840
|
-
)) {
|
|
1841
|
-
theirs = accumulatorEntry.content;
|
|
1842
|
-
useAccumulatorAsMergeBase = true;
|
|
1843
|
-
}
|
|
1858
|
+
continue;
|
|
1844
1859
|
}
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
if (theirsHasMarkers && !accBaseHasMarkers && !(base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization")))) {
|
|
1849
|
-
return {
|
|
1850
|
-
file: resolvedPath,
|
|
1851
|
-
status: "skipped",
|
|
1852
|
-
reason: "stale-conflict-markers"
|
|
1853
|
-
};
|
|
1854
|
-
}
|
|
1860
|
+
let contentHash = this.computeContentHash(patchContent);
|
|
1861
|
+
if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
|
|
1862
|
+
continue;
|
|
1855
1863
|
}
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1864
|
+
const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
|
|
1865
|
+
const allFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
|
|
1866
|
+
const sensitiveFiles = allFiles.filter((f) => isSensitiveFile(f));
|
|
1867
|
+
const files = allFiles.filter((f) => !isSensitiveFile(f));
|
|
1868
|
+
if (sensitiveFiles.length > 0) {
|
|
1869
|
+
this.warnings.push(
|
|
1870
|
+
`Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
|
|
1871
|
+
);
|
|
1872
|
+
if (files.length > 0) {
|
|
1873
|
+
const parentSha = parents[0];
|
|
1874
|
+
if (parentSha) {
|
|
1875
|
+
try {
|
|
1876
|
+
patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
|
|
1877
|
+
} catch {
|
|
1878
|
+
continue;
|
|
1866
1879
|
}
|
|
1867
|
-
|
|
1868
|
-
|
|
1880
|
+
if (!patchContent.trim()) continue;
|
|
1881
|
+
contentHash = this.computeContentHash(patchContent);
|
|
1869
1882
|
}
|
|
1870
|
-
} else if (accumulatorEntry) {
|
|
1871
|
-
baseMismatchSkipped = true;
|
|
1872
1883
|
}
|
|
1873
1884
|
}
|
|
1874
|
-
if (
|
|
1875
|
-
|
|
1876
|
-
content: effective_theirs,
|
|
1877
|
-
baseGeneration: patch.base_generation
|
|
1878
|
-
});
|
|
1879
|
-
const outDir2 = dirname2(oursPath);
|
|
1880
|
-
await mkdir(outDir2, { recursive: true });
|
|
1881
|
-
await writeFile(oursPath, effective_theirs);
|
|
1882
|
-
return { file: resolvedPath, status: "merged", reason: "new-file" };
|
|
1885
|
+
if (files.length === 0) {
|
|
1886
|
+
continue;
|
|
1883
1887
|
}
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
await
|
|
1888
|
-
|
|
1889
|
-
if (merged2.hasConflicts) {
|
|
1890
|
-
return {
|
|
1891
|
-
file: resolvedPath,
|
|
1892
|
-
status: "conflict",
|
|
1893
|
-
conflicts: merged2.conflicts,
|
|
1894
|
-
conflictReason: "new-file-both",
|
|
1895
|
-
conflictMetadata: metadata
|
|
1896
|
-
};
|
|
1897
|
-
}
|
|
1898
|
-
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
1899
|
-
content: merged2.content,
|
|
1900
|
-
baseGeneration: patch.base_generation
|
|
1901
|
-
});
|
|
1902
|
-
return { file: resolvedPath, status: "merged" };
|
|
1888
|
+
const theirsSnapshot = {};
|
|
1889
|
+
for (const file of files) {
|
|
1890
|
+
if (isBinaryFile(file)) continue;
|
|
1891
|
+
const content = await this.git.showFile(commit.sha, file).catch(() => null);
|
|
1892
|
+
if (content != null) theirsSnapshot[file] = content;
|
|
1903
1893
|
}
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
}
|
|
1894
|
+
newPatches.push({
|
|
1895
|
+
id: `patch-${commit.sha.slice(0, 8)}`,
|
|
1896
|
+
content_hash: contentHash,
|
|
1897
|
+
original_commit: commit.sha,
|
|
1898
|
+
original_message: commit.message,
|
|
1899
|
+
original_author: `${commit.authorName} <${commit.authorEmail}>`,
|
|
1900
|
+
base_generation: lastGen.commit_sha,
|
|
1901
|
+
files,
|
|
1902
|
+
patch_content: patchContent,
|
|
1903
|
+
...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
|
|
1904
|
+
...this.isCreationOnlyPatch(patchContent) ? { user_owned: true } : {}
|
|
1905
|
+
});
|
|
1906
|
+
}
|
|
1907
|
+
const survivingPatches = await this.collapseNetZeroFiles(newPatches);
|
|
1908
|
+
survivingPatches.reverse();
|
|
1909
|
+
const revertedPatchIdSet = /* @__PURE__ */ new Set();
|
|
1910
|
+
const revertIndicesToRemove = /* @__PURE__ */ new Set();
|
|
1911
|
+
for (let i = 0; i < survivingPatches.length; i++) {
|
|
1912
|
+
const patch = survivingPatches[i];
|
|
1913
|
+
if (!isRevertCommit(patch.original_message)) continue;
|
|
1914
|
+
let body = "";
|
|
1915
|
+
try {
|
|
1916
|
+
body = await this.git.getCommitBody(patch.original_commit);
|
|
1917
|
+
} catch {
|
|
1910
1918
|
}
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1919
|
+
const revertedSha = parseRevertedSha(body);
|
|
1920
|
+
const revertedMessage = parseRevertedMessage(patch.original_message);
|
|
1921
|
+
let matchedExisting = false;
|
|
1922
|
+
if (revertedSha) {
|
|
1923
|
+
const existing = lock.patches.find((p) => p.original_commit === revertedSha);
|
|
1924
|
+
if (existing) {
|
|
1925
|
+
revertedPatchIdSet.add(existing.id);
|
|
1926
|
+
revertIndicesToRemove.add(i);
|
|
1927
|
+
matchedExisting = true;
|
|
1928
|
+
}
|
|
1917
1929
|
}
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
}
|
|
1930
|
+
if (!matchedExisting && revertedMessage) {
|
|
1931
|
+
const existing = lock.patches.find((p) => p.original_message === revertedMessage);
|
|
1932
|
+
if (existing) {
|
|
1933
|
+
revertedPatchIdSet.add(existing.id);
|
|
1934
|
+
revertIndicesToRemove.add(i);
|
|
1935
|
+
matchedExisting = true;
|
|
1936
|
+
}
|
|
1925
1937
|
}
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1938
|
+
if (matchedExisting) continue;
|
|
1939
|
+
let matchedNew = false;
|
|
1940
|
+
if (revertedSha) {
|
|
1941
|
+
const idx = survivingPatches.findIndex(
|
|
1942
|
+
(p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_commit === revertedSha
|
|
1943
|
+
);
|
|
1944
|
+
if (idx !== -1) {
|
|
1945
|
+
revertIndicesToRemove.add(i);
|
|
1946
|
+
revertIndicesToRemove.add(idx);
|
|
1947
|
+
matchedNew = true;
|
|
1948
|
+
}
|
|
1936
1949
|
}
|
|
1937
|
-
if (
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
}
|
|
1950
|
+
if (!matchedNew && revertedMessage) {
|
|
1951
|
+
const idx = survivingPatches.findIndex(
|
|
1952
|
+
(p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_message === revertedMessage
|
|
1953
|
+
);
|
|
1954
|
+
if (idx !== -1) {
|
|
1955
|
+
revertIndicesToRemove.add(i);
|
|
1956
|
+
revertIndicesToRemove.add(idx);
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
if (!matchedExisting && !matchedNew) {
|
|
1960
|
+
revertIndicesToRemove.add(i);
|
|
1945
1961
|
}
|
|
1946
|
-
return {
|
|
1947
|
-
file: resolvedPath,
|
|
1948
|
-
status: "merged",
|
|
1949
|
-
...merged.droppedContextLines && merged.droppedContextLines.length > 0 ? { droppedContextLines: merged.droppedContextLines } : {}
|
|
1950
|
-
};
|
|
1951
|
-
} catch (error) {
|
|
1952
|
-
return {
|
|
1953
|
-
file: filePath,
|
|
1954
|
-
status: "skipped",
|
|
1955
|
-
reason: `error: ${error instanceof Error ? error.message : String(error)}`
|
|
1956
|
-
};
|
|
1957
|
-
}
|
|
1958
|
-
}
|
|
1959
|
-
async isTreeReachable(treeHash) {
|
|
1960
|
-
let result = this.treeExistsCache.get(treeHash);
|
|
1961
|
-
if (result === void 0) {
|
|
1962
|
-
result = await this.git.treeExists(treeHash);
|
|
1963
|
-
this.treeExistsCache.set(treeHash, result);
|
|
1964
1962
|
}
|
|
1965
|
-
|
|
1963
|
+
const filteredPatches = survivingPatches.filter((_, i) => !revertIndicesToRemove.has(i));
|
|
1964
|
+
return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
|
|
1966
1965
|
}
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1966
|
+
/**
|
|
1967
|
+
* Compute content hash for deduplication.
|
|
1968
|
+
*
|
|
1969
|
+
* Produces a format-agnostic hash so both `git format-patch` output
|
|
1970
|
+
* (email-wrapped) and plain `git diff` output hash to the same value
|
|
1971
|
+
* when the underlying diff hunks are identical.
|
|
1972
|
+
*
|
|
1973
|
+
* Normalization:
|
|
1974
|
+
* 1. Strip email wrapper: everything before the first `diff --git` line
|
|
1975
|
+
* (From:, Subject:, Date:, diffstat, blank separators).
|
|
1976
|
+
* 2. Strip `index` lines (blob SHA pairs change across rebases).
|
|
1977
|
+
* 3. Strip trailing git version marker (`-- \n<version>`).
|
|
1978
|
+
*
|
|
1979
|
+
* This ensures content hashes match across the no-patches and normal-
|
|
1980
|
+
* regeneration flows, which store formatPatch and git-diff formats
|
|
1981
|
+
* respectively (FER-9850, D5-D9).
|
|
1982
|
+
*/
|
|
1983
|
+
computeContentHash(patchContent) {
|
|
1984
|
+
const lines = patchContent.split("\n");
|
|
1985
|
+
const diffStart = lines.findIndex((l) => l.startsWith("diff --git "));
|
|
1986
|
+
const relevantLines = diffStart > 0 ? lines.slice(diffStart) : lines;
|
|
1987
|
+
const normalized = relevantLines.filter((line) => !line.startsWith("index ")).join("\n").replace(/\n-- \n[\d]+\.[\d]+[^\n]*\s*$/, "");
|
|
1988
|
+
return `sha256:${createHash("sha256").update(normalized).digest("hex")}`;
|
|
1971
1989
|
}
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1990
|
+
/**
|
|
1991
|
+
* FER-9805 — Drop patches whose files were transiently created and then
|
|
1992
|
+
* deleted within the current linear detection window, and are absent from
|
|
1993
|
+
* HEAD at the end of the window.
|
|
1994
|
+
*
|
|
1995
|
+
* Rationale: on a merge commit, createMergeCompositePatch already diffs
|
|
1996
|
+
* firstParent..mergeCommit so net-zero files never enter the composite. On
|
|
1997
|
+
* linear history each commit becomes its own patch, so a file that was
|
|
1998
|
+
* briefly added and later removed survives as a create+delete pair whose
|
|
1999
|
+
* cumulative diff is empty. We drop such patches before they reach the
|
|
2000
|
+
* lockfile.
|
|
2001
|
+
*
|
|
2002
|
+
* A file is "transient" when:
|
|
2003
|
+
* 1. some patch in the window sets `new file mode` for it (a creation), AND
|
|
2004
|
+
* 2. some patch in the window sets `deleted file mode` for it (a deletion), AND
|
|
2005
|
+
* 3. it is absent from HEAD's tree.
|
|
2006
|
+
*
|
|
2007
|
+
* The HEAD guard (#3) prevents false positives when a file is deleted
|
|
2008
|
+
* and then recreated with different content — the cumulative diff of
|
|
2009
|
+
* such a pair is non-empty and must be preserved.
|
|
2010
|
+
*
|
|
2011
|
+
* This only drops patches whose `files` are a subset of the transient
|
|
2012
|
+
* set. A partial-transient patch (one that touches a transient file
|
|
2013
|
+
* alongside a persistent one) is left unmodified; surgical stripping of
|
|
2014
|
+
* single files from a multi-file patch would require a follow-up that
|
|
2015
|
+
* regenerates the patch content via `git format-patch -- <files>`. No
|
|
2016
|
+
* current failing test exercises this, and leaving the patch intact
|
|
2017
|
+
* preserves today's behaviour. TODO(FER-9805): revisit if a future
|
|
2018
|
+
* adversarial test demands per-file stripping.
|
|
2019
|
+
*/
|
|
2020
|
+
async collapseNetZeroFiles(patches) {
|
|
2021
|
+
if (patches.length === 0) return patches;
|
|
2022
|
+
const stateByFile = /* @__PURE__ */ new Map();
|
|
2023
|
+
for (const patch of patches) {
|
|
2024
|
+
for (const header of parsePatchFileHeaders(patch.patch_content)) {
|
|
2025
|
+
if (!header.isCreate && !header.isDelete) continue;
|
|
2026
|
+
const prior = stateByFile.get(header.path) ?? { created: false, deleted: false };
|
|
2027
|
+
if (header.isCreate) prior.created = true;
|
|
2028
|
+
if (header.isDelete) prior.deleted = true;
|
|
2029
|
+
stateByFile.set(header.path, prior);
|
|
1986
2030
|
}
|
|
1987
2031
|
}
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
2032
|
+
let anyCandidate = false;
|
|
2033
|
+
for (const state of stateByFile.values()) {
|
|
2034
|
+
if (state.created && state.deleted) {
|
|
2035
|
+
anyCandidate = true;
|
|
2036
|
+
break;
|
|
2037
|
+
}
|
|
1993
2038
|
}
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
2039
|
+
if (!anyCandidate) return patches;
|
|
2040
|
+
const headFiles = await this.listHeadFiles();
|
|
2041
|
+
const transient = /* @__PURE__ */ new Set();
|
|
2042
|
+
for (const [file, state] of stateByFile) {
|
|
2043
|
+
if (state.created && state.deleted && !headFiles.has(file)) {
|
|
2044
|
+
transient.add(file);
|
|
2045
|
+
}
|
|
1997
2046
|
}
|
|
1998
|
-
return
|
|
2047
|
+
if (transient.size === 0) return patches;
|
|
2048
|
+
return patches.filter((patch) => !patch.files.every((f) => transient.has(f)));
|
|
1999
2049
|
}
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2050
|
+
/**
|
|
2051
|
+
* List every tracked path at HEAD (one `git ls-tree -r` invocation).
|
|
2052
|
+
* Returns an empty Set if HEAD is unborn or the invocation fails — the
|
|
2053
|
+
* caller then treats every candidate as "not in HEAD" and may collapse
|
|
2054
|
+
* more aggressively. On typical SDK repos this is a single sub-10ms call.
|
|
2055
|
+
*/
|
|
2056
|
+
async listHeadFiles() {
|
|
2006
2057
|
try {
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
await mkdir(dirname2(tempSourcePath), { recursive: true });
|
|
2010
|
-
await writeFile(tempSourcePath, base);
|
|
2011
|
-
await tempGit.exec(["add", sourceFilePath]);
|
|
2012
|
-
await tempGit.exec([
|
|
2013
|
-
"commit",
|
|
2014
|
-
"-m",
|
|
2015
|
-
`base for rename ${sourceFilePath} -> ${filePath}`,
|
|
2016
|
-
"--allow-empty"
|
|
2017
|
-
]);
|
|
2018
|
-
await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
|
|
2019
|
-
const tempTargetPath = join2(tempDir, filePath);
|
|
2020
|
-
return await readFile(tempTargetPath, "utf-8");
|
|
2021
|
-
}
|
|
2022
|
-
const tempFilePath = join2(tempDir, filePath);
|
|
2023
|
-
await mkdir(dirname2(tempFilePath), { recursive: true });
|
|
2024
|
-
await writeFile(tempFilePath, base);
|
|
2025
|
-
await tempGit.exec(["add", filePath]);
|
|
2026
|
-
await tempGit.exec(["commit", "-m", `base for ${filePath}`, "--allow-empty"]);
|
|
2027
|
-
await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
|
|
2028
|
-
return await readFile(tempFilePath, "utf-8");
|
|
2058
|
+
const out = await this.git.exec(["ls-tree", "-r", "--name-only", "HEAD"]);
|
|
2059
|
+
return new Set(out.split("\n").map((l) => l.trim()).filter(Boolean));
|
|
2029
2060
|
} catch {
|
|
2030
|
-
return
|
|
2061
|
+
return /* @__PURE__ */ new Set();
|
|
2031
2062
|
}
|
|
2032
2063
|
}
|
|
2033
2064
|
/**
|
|
2034
|
-
*
|
|
2035
|
-
*
|
|
2036
|
-
*
|
|
2037
|
-
*
|
|
2038
|
-
* from "patch base mismatch" after a forward-apply fails.
|
|
2065
|
+
* Create a single composite patch for a merge commit by diffing the merge
|
|
2066
|
+
* commit against its first parent. This captures the net change of the
|
|
2067
|
+
* entire merged branch as one patch, eliminating accumulator chaining and
|
|
2068
|
+
* zero-net-change ghost conflicts.
|
|
2039
2069
|
*/
|
|
2040
|
-
async
|
|
2041
|
-
const
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2070
|
+
async createMergeCompositePatch(input) {
|
|
2071
|
+
const { mergeCommit, firstParent, lastGen, lock } = input;
|
|
2072
|
+
const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
|
|
2073
|
+
const filesOutput = await this.git.exec([
|
|
2074
|
+
"diff",
|
|
2075
|
+
"--name-only",
|
|
2076
|
+
firstParent,
|
|
2077
|
+
mergeCommit.sha
|
|
2078
|
+
]);
|
|
2079
|
+
const allMergeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
|
|
2080
|
+
const sensitiveMergeFiles = allMergeFiles.filter((f) => isSensitiveFile(f));
|
|
2081
|
+
const files = allMergeFiles.filter((f) => !isSensitiveFile(f));
|
|
2082
|
+
if (sensitiveMergeFiles.length > 0) {
|
|
2083
|
+
this.warnings.push(
|
|
2084
|
+
`Sensitive file(s) excluded from merge patch for commit ${mergeCommit.sha.slice(0, 7)}: ${sensitiveMergeFiles.join(", ")}. These files will not be tracked by replay.`
|
|
2085
|
+
);
|
|
2086
|
+
}
|
|
2087
|
+
if (files.length === 0) return null;
|
|
2088
|
+
const diff = await this.git.exec([
|
|
2089
|
+
"diff",
|
|
2090
|
+
firstParent,
|
|
2091
|
+
mergeCommit.sha,
|
|
2092
|
+
"--",
|
|
2093
|
+
...files
|
|
2094
|
+
]);
|
|
2095
|
+
if (!diff.trim()) return null;
|
|
2096
|
+
const contentHash = this.computeContentHash(diff);
|
|
2097
|
+
if (lock.patches.some((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
|
|
2098
|
+
return null;
|
|
2099
|
+
}
|
|
2100
|
+
const theirsSnapshot = {};
|
|
2101
|
+
for (const file of files) {
|
|
2102
|
+
if (isBinaryFile(file)) continue;
|
|
2103
|
+
const content = await this.git.showFile(mergeCommit.sha, file).catch(() => null);
|
|
2104
|
+
if (content != null) theirsSnapshot[file] = content;
|
|
2053
2105
|
}
|
|
2106
|
+
return {
|
|
2107
|
+
id: `patch-merge-${mergeCommit.sha.slice(0, 8)}`,
|
|
2108
|
+
content_hash: contentHash,
|
|
2109
|
+
original_commit: mergeCommit.sha,
|
|
2110
|
+
original_message: mergeCommit.message,
|
|
2111
|
+
original_author: `${mergeCommit.authorName} <${mergeCommit.authorEmail}>`,
|
|
2112
|
+
base_generation: lastGen.commit_sha,
|
|
2113
|
+
files,
|
|
2114
|
+
patch_content: diff,
|
|
2115
|
+
...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
|
|
2116
|
+
...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
|
|
2117
|
+
};
|
|
2054
2118
|
}
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
if (isDiffLineForFile(line, filePath)) {
|
|
2065
|
-
inTargetFile = true;
|
|
2066
|
-
diffLines.push(line);
|
|
2067
|
-
}
|
|
2068
|
-
continue;
|
|
2069
|
-
}
|
|
2070
|
-
if (inTargetFile) {
|
|
2071
|
-
diffLines.push(line);
|
|
2072
|
-
}
|
|
2119
|
+
/**
|
|
2120
|
+
* Detect patches via tree diff for non-linear history. Returns a composite patch.
|
|
2121
|
+
* Revert reconciliation is skipped here because tree-diff produces a single composite
|
|
2122
|
+
* patch from the aggregate diff — individual revert commits are not distinguishable.
|
|
2123
|
+
*/
|
|
2124
|
+
async detectPatchesViaTreeDiff(lastGen, commitKnownMissing) {
|
|
2125
|
+
const diffBase = await this.resolveDiffBase(lastGen, commitKnownMissing);
|
|
2126
|
+
if (!diffBase) {
|
|
2127
|
+
return this.detectPatchesViaCommitScan();
|
|
2073
2128
|
}
|
|
2074
|
-
|
|
2129
|
+
const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
|
|
2130
|
+
const allTreeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
|
|
2131
|
+
const sensitiveTreeFiles = allTreeFiles.filter((f) => isSensitiveFile(f));
|
|
2132
|
+
const files = allTreeFiles.filter((f) => !isSensitiveFile(f));
|
|
2133
|
+
if (sensitiveTreeFiles.length > 0) {
|
|
2134
|
+
this.warnings.push(
|
|
2135
|
+
`Sensitive file(s) excluded from composite patch: ${sensitiveTreeFiles.join(", ")}. These files will not be tracked by replay.`
|
|
2136
|
+
);
|
|
2137
|
+
}
|
|
2138
|
+
if (files.length === 0) return { patches: [], revertedPatchIds: [] };
|
|
2139
|
+
const diff = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
|
|
2140
|
+
if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
|
|
2141
|
+
const contentHash = this.computeContentHash(diff);
|
|
2142
|
+
const lock = this.lockManager.read();
|
|
2143
|
+
if (lock.patches.some((p) => p.content_hash === contentHash) || (lock.forgotten_hashes ?? []).includes(contentHash)) {
|
|
2144
|
+
return { patches: [], revertedPatchIds: [] };
|
|
2145
|
+
}
|
|
2146
|
+
const headSha = (await this.git.exec(["rev-parse", "HEAD"])).trim();
|
|
2147
|
+
const theirsSnapshot = {};
|
|
2148
|
+
for (const file of files) {
|
|
2149
|
+
if (isBinaryFile(file)) continue;
|
|
2150
|
+
const content = await this.git.showFile("HEAD", file).catch(() => null);
|
|
2151
|
+
if (content != null) theirsSnapshot[file] = content;
|
|
2152
|
+
}
|
|
2153
|
+
const compositePatch = {
|
|
2154
|
+
id: `patch-composite-${headSha.slice(0, 8)}`,
|
|
2155
|
+
content_hash: contentHash,
|
|
2156
|
+
original_commit: headSha,
|
|
2157
|
+
original_message: "Customer customizations (composite)",
|
|
2158
|
+
original_author: "composite",
|
|
2159
|
+
// Use diffBase when commit is unreachable — the applicator needs a reachable
|
|
2160
|
+
// reference to find base file content. diffBase may be the tree_hash.
|
|
2161
|
+
base_generation: commitKnownMissing ? diffBase : lastGen.commit_sha,
|
|
2162
|
+
files,
|
|
2163
|
+
patch_content: diff,
|
|
2164
|
+
...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
|
|
2165
|
+
...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
|
|
2166
|
+
};
|
|
2167
|
+
return { patches: [compositePatch], revertedPatchIds: [] };
|
|
2075
2168
|
}
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2169
|
+
/**
|
|
2170
|
+
* Last-resort detection when both generation commit and tree are unreachable.
|
|
2171
|
+
* Scans all commits from HEAD, filters against known lockfile patches, and
|
|
2172
|
+
* skips creation-only commits (squashed history after force push).
|
|
2173
|
+
*
|
|
2174
|
+
* Detected patches use the commit's parent as base_generation so the applicator
|
|
2175
|
+
* can find base file content from the parent's tree (which IS reachable).
|
|
2176
|
+
*/
|
|
2177
|
+
async detectPatchesViaCommitScan() {
|
|
2178
|
+
const lock = this.lockManager.read();
|
|
2179
|
+
const log = await this.git.exec([
|
|
2180
|
+
"log",
|
|
2181
|
+
"--max-count=200",
|
|
2182
|
+
"--format=%H%x00%an%x00%ae%x00%s",
|
|
2183
|
+
"HEAD",
|
|
2184
|
+
"--",
|
|
2185
|
+
this.sdkOutputDir
|
|
2186
|
+
]);
|
|
2187
|
+
if (!log.trim()) {
|
|
2188
|
+
return { patches: [], revertedPatchIds: [] };
|
|
2189
|
+
}
|
|
2190
|
+
const commits = this.parseGitLog(log);
|
|
2191
|
+
const newPatches = [];
|
|
2192
|
+
const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
|
|
2193
|
+
const existingHashes = new Set(lock.patches.map((p) => p.content_hash));
|
|
2194
|
+
const existingCommits = new Set(lock.patches.map((p) => p.original_commit));
|
|
2195
|
+
for (const commit of commits) {
|
|
2196
|
+
if (isGenerationCommit(commit)) {
|
|
2083
2197
|
continue;
|
|
2084
2198
|
}
|
|
2085
|
-
|
|
2086
|
-
if (
|
|
2087
|
-
if (line.startsWith("rename from ")) {
|
|
2088
|
-
return line.slice("rename from ".length);
|
|
2089
|
-
}
|
|
2090
|
-
}
|
|
2091
|
-
return null;
|
|
2092
|
-
}
|
|
2093
|
-
extractNewFileFromPatch(patchContent, filePath) {
|
|
2094
|
-
const lines = patchContent.split("\n");
|
|
2095
|
-
const addedLines = [];
|
|
2096
|
-
let inTargetFile = false;
|
|
2097
|
-
let inHunk = false;
|
|
2098
|
-
let isNewFile = false;
|
|
2099
|
-
let noTrailingNewline = false;
|
|
2100
|
-
for (const line of lines) {
|
|
2101
|
-
if (line.startsWith("diff --git")) {
|
|
2102
|
-
if (inTargetFile) break;
|
|
2103
|
-
inTargetFile = isDiffLineForFile(line, filePath);
|
|
2104
|
-
inHunk = false;
|
|
2105
|
-
isNewFile = false;
|
|
2199
|
+
const parents = await this.git.getCommitParents(commit.sha);
|
|
2200
|
+
if (parents.length > 1) {
|
|
2106
2201
|
continue;
|
|
2107
2202
|
}
|
|
2108
|
-
if (
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2203
|
+
if (existingCommits.has(commit.sha)) {
|
|
2204
|
+
continue;
|
|
2205
|
+
}
|
|
2206
|
+
let patchContent;
|
|
2207
|
+
try {
|
|
2208
|
+
patchContent = await this.git.formatPatch(commit.sha);
|
|
2209
|
+
} catch {
|
|
2210
|
+
continue;
|
|
2211
|
+
}
|
|
2212
|
+
let contentHash = this.computeContentHash(patchContent);
|
|
2213
|
+
if (existingHashes.has(contentHash) || forgottenHashes.has(contentHash)) {
|
|
2214
|
+
continue;
|
|
2215
|
+
}
|
|
2216
|
+
if (this.isCreationOnlyPatch(patchContent)) {
|
|
2217
|
+
continue;
|
|
2218
|
+
}
|
|
2219
|
+
const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
|
|
2220
|
+
const allScanFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f));
|
|
2221
|
+
const sensitiveScanFiles = allScanFiles.filter((f) => isSensitiveFile(f));
|
|
2222
|
+
const files = allScanFiles.filter((f) => !isSensitiveFile(f));
|
|
2223
|
+
if (sensitiveScanFiles.length > 0) {
|
|
2224
|
+
this.warnings.push(
|
|
2225
|
+
`Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveScanFiles.join(", ")}. These files will not be tracked by replay.`
|
|
2226
|
+
);
|
|
2227
|
+
if (files.length > 0) {
|
|
2228
|
+
if (parents.length === 0) {
|
|
2229
|
+
continue;
|
|
2230
|
+
}
|
|
2231
|
+
try {
|
|
2232
|
+
patchContent = await this.git.exec(["diff", parents[0], commit.sha, "--", ...files]);
|
|
2233
|
+
} catch {
|
|
2234
|
+
continue;
|
|
2235
|
+
}
|
|
2236
|
+
if (!patchContent.trim()) continue;
|
|
2237
|
+
contentHash = this.computeContentHash(patchContent);
|
|
2112
2238
|
}
|
|
2113
2239
|
}
|
|
2114
|
-
if (
|
|
2115
|
-
if (!isNewFile) return null;
|
|
2116
|
-
inHunk = true;
|
|
2240
|
+
if (files.length === 0) {
|
|
2117
2241
|
continue;
|
|
2118
2242
|
}
|
|
2119
|
-
if (
|
|
2120
|
-
if (line === "\") {
|
|
2121
|
-
noTrailingNewline = true;
|
|
2243
|
+
if (parents.length === 0) {
|
|
2122
2244
|
continue;
|
|
2123
2245
|
}
|
|
2124
|
-
|
|
2125
|
-
|
|
2246
|
+
const parentSha = parents[0];
|
|
2247
|
+
const theirsSnapshot = {};
|
|
2248
|
+
for (const file of files) {
|
|
2249
|
+
if (isBinaryFile(file)) continue;
|
|
2250
|
+
const content = await this.git.showFile(commit.sha, file).catch(() => null);
|
|
2251
|
+
if (content != null) theirsSnapshot[file] = content;
|
|
2126
2252
|
}
|
|
2253
|
+
newPatches.push({
|
|
2254
|
+
id: `patch-${commit.sha.slice(0, 8)}`,
|
|
2255
|
+
content_hash: contentHash,
|
|
2256
|
+
original_commit: commit.sha,
|
|
2257
|
+
original_message: commit.message,
|
|
2258
|
+
original_author: `${commit.authorName} <${commit.authorEmail}>`,
|
|
2259
|
+
base_generation: parentSha,
|
|
2260
|
+
files,
|
|
2261
|
+
patch_content: patchContent,
|
|
2262
|
+
...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {}
|
|
2263
|
+
});
|
|
2127
2264
|
}
|
|
2128
|
-
|
|
2129
|
-
return
|
|
2265
|
+
newPatches.reverse();
|
|
2266
|
+
return { patches: newPatches, revertedPatchIds: [] };
|
|
2130
2267
|
}
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
const baseCounts = /* @__PURE__ */ new Map();
|
|
2141
|
-
const theirsCounts = /* @__PURE__ */ new Map();
|
|
2142
|
-
const finalCounts = /* @__PURE__ */ new Map();
|
|
2143
|
-
for (const l of baseLines) baseCounts.set(l, (baseCounts.get(l) ?? 0) + 1);
|
|
2144
|
-
for (const l of theirsLines) theirsCounts.set(l, (theirsCounts.get(l) ?? 0) + 1);
|
|
2145
|
-
for (const l of finalLines) finalCounts.set(l, (finalCounts.get(l) ?? 0) + 1);
|
|
2146
|
-
const dropped = [];
|
|
2147
|
-
const seen = /* @__PURE__ */ new Set();
|
|
2148
|
-
for (const line of baseLines) {
|
|
2149
|
-
if (seen.has(line)) continue;
|
|
2150
|
-
const inBase = baseCounts.get(line) ?? 0;
|
|
2151
|
-
const inTheirs = theirsCounts.get(line) ?? 0;
|
|
2152
|
-
const inFinal = finalCounts.get(line) ?? 0;
|
|
2153
|
-
if (inBase > 0 && inTheirs > 0 && inFinal < Math.min(inBase, inTheirs)) {
|
|
2154
|
-
dropped.push(line);
|
|
2155
|
-
seen.add(line);
|
|
2268
|
+
/**
|
|
2269
|
+
* Check if a format-patch consists entirely of new-file creations.
|
|
2270
|
+
* Used to identify squashed commits after force push, which create all files
|
|
2271
|
+
* from scratch (--- /dev/null) rather than modifying existing files.
|
|
2272
|
+
*/
|
|
2273
|
+
isCreationOnlyPatch(patchContent) {
|
|
2274
|
+
const diffOldHeaders = patchContent.split("\n").filter((l) => l.startsWith("--- "));
|
|
2275
|
+
if (diffOldHeaders.length === 0) {
|
|
2276
|
+
return false;
|
|
2156
2277
|
}
|
|
2278
|
+
return diffOldHeaders.every((l) => l === "--- /dev/null");
|
|
2157
2279
|
}
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2280
|
+
/**
|
|
2281
|
+
* Resolve the best available diff base for a generation record.
|
|
2282
|
+
* Prefers commit_sha, falls back to tree_hash for unreachable commits.
|
|
2283
|
+
* When commitKnownMissing is true, skips the redundant commitExists check.
|
|
2284
|
+
*/
|
|
2285
|
+
async resolveDiffBase(gen, commitKnownMissing) {
|
|
2286
|
+
if (!commitKnownMissing && await this.git.commitExists(gen.commit_sha)) {
|
|
2287
|
+
return gen.commit_sha;
|
|
2288
|
+
}
|
|
2289
|
+
if (await this.git.treeExists(gen.tree_hash)) {
|
|
2290
|
+
return gen.tree_hash;
|
|
2291
|
+
}
|
|
2292
|
+
return null;
|
|
2293
|
+
}
|
|
2294
|
+
parseGitLog(log) {
|
|
2295
|
+
return log.trim().split("\n").map((line) => {
|
|
2296
|
+
const [sha, authorName, authorEmail, message] = line.split("\0");
|
|
2297
|
+
return { sha, authorName, authorEmail, message };
|
|
2298
|
+
});
|
|
2299
|
+
}
|
|
2300
|
+
getLastGeneration(lock) {
|
|
2301
|
+
return lock.generations.find((g) => g.commit_sha === lock.current_generation);
|
|
2302
|
+
}
|
|
2303
|
+
};
|
|
2164
2304
|
|
|
2165
2305
|
// src/ReplayCommitter.ts
|
|
2166
2306
|
var ReplayCommitter = class {
|
|
@@ -2267,7 +2407,8 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
|
|
|
2267
2407
|
|
|
2268
2408
|
// src/ReplayService.ts
|
|
2269
2409
|
import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
2270
|
-
import {
|
|
2410
|
+
import { readFile as readFileAsync } from "fs/promises";
|
|
2411
|
+
import { join as join5 } from "path";
|
|
2271
2412
|
import { minimatch as minimatch2 } from "minimatch";
|
|
2272
2413
|
init_GitClient();
|
|
2273
2414
|
|
|
@@ -2326,13 +2467,13 @@ async function computePerPatchDiff(patch, getCurrentGenContent, getWorkingTreeCo
|
|
|
2326
2467
|
const hunksRaw = parseHunks(fileDiff).map(normalizeHunkBody);
|
|
2327
2468
|
const hunks = hunksRaw.filter((h) => {
|
|
2328
2469
|
if (h.lines.length === 0) return false;
|
|
2329
|
-
let ctx = 0,
|
|
2470
|
+
let ctx = 0, rm4 = 0, add = 0;
|
|
2330
2471
|
for (const ln of h.lines) {
|
|
2331
2472
|
if (ln.type === "context") ctx++;
|
|
2332
|
-
else if (ln.type === "remove")
|
|
2473
|
+
else if (ln.type === "remove") rm4++;
|
|
2333
2474
|
else if (ln.type === "add") add++;
|
|
2334
2475
|
}
|
|
2335
|
-
return ctx +
|
|
2476
|
+
return ctx + rm4 === h.oldCount && ctx + add === h.newCount;
|
|
2336
2477
|
});
|
|
2337
2478
|
if (hunks.length === 0) {
|
|
2338
2479
|
if (hunksRaw.length > 0) unlocatableFiles.push(file);
|
|
@@ -3042,19 +3183,14 @@ var ReplayService = class {
|
|
|
3042
3183
|
existingPatches = this.lockManager.getPatches();
|
|
3043
3184
|
const seenHashes = /* @__PURE__ */ new Map();
|
|
3044
3185
|
for (const p of existingPatches) {
|
|
3045
|
-
const
|
|
3046
|
-
if (
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
} else {
|
|
3052
|
-
this.warnings.push(
|
|
3053
|
-
`Preserving patch ${p.id} (commit ${(p.original_commit || "<missing>").slice(0, 7)}): shares content_hash with a prior patch ${provenanceMissing ? "(provenance missing on at least one side)" : "from a different source commit"}. Both retained to avoid silent customization loss (FER-9983).`
|
|
3054
|
-
);
|
|
3055
|
-
}
|
|
3186
|
+
const priorPatchId = seenHashes.get(p.content_hash);
|
|
3187
|
+
if (priorPatchId !== void 0) {
|
|
3188
|
+
this.lockManager.removePatch(p.id);
|
|
3189
|
+
this.warnings.push(
|
|
3190
|
+
`Consolidated patch ${p.id} (commit ${(p.original_commit || "<missing>").slice(0, 7)}) into prior patch ${priorPatchId}: byte-identical patch_content. Per-commit attribution preserved in git log; lockfile collapsed to avoid duplicate-apply corruption on next regen.`
|
|
3191
|
+
);
|
|
3056
3192
|
} else {
|
|
3057
|
-
seenHashes.set(p.content_hash, p.
|
|
3193
|
+
seenHashes.set(p.content_hash, p.id);
|
|
3058
3194
|
}
|
|
3059
3195
|
}
|
|
3060
3196
|
existingPatches = this.lockManager.getPatches();
|
|
@@ -3235,17 +3371,27 @@ var ReplayService = class {
|
|
|
3235
3371
|
if (allUserOwned && patch.files.length > 0) {
|
|
3236
3372
|
const baseChanged = patch.base_generation !== currentGenSha;
|
|
3237
3373
|
const userDiff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
|
|
3374
|
+
let userDiffSnapshot = null;
|
|
3238
3375
|
if (userDiff != null && userDiff.trim()) {
|
|
3239
3376
|
const newHash = this.detector.computeContentHash(userDiff);
|
|
3240
3377
|
patch.patch_content = userDiff;
|
|
3241
3378
|
patch.content_hash = newHash;
|
|
3379
|
+
const snap = await this.readSnapshotFromDisk(patch.files);
|
|
3380
|
+
if (Object.keys(snap).length > 0) {
|
|
3381
|
+
userDiffSnapshot = snap;
|
|
3382
|
+
patch.theirs_snapshot = snap;
|
|
3383
|
+
}
|
|
3242
3384
|
}
|
|
3243
3385
|
patch.base_generation = currentGenSha;
|
|
3244
3386
|
try {
|
|
3245
3387
|
this.lockManager.updatePatch(patch.id, {
|
|
3246
3388
|
base_generation: currentGenSha,
|
|
3247
3389
|
...!patch.user_owned ? { user_owned: true } : {},
|
|
3248
|
-
...userDiff != null && userDiff.trim() ? {
|
|
3390
|
+
...userDiff != null && userDiff.trim() ? {
|
|
3391
|
+
patch_content: patch.patch_content,
|
|
3392
|
+
content_hash: patch.content_hash,
|
|
3393
|
+
...userDiffSnapshot != null && Object.keys(userDiffSnapshot).length > 0 ? { theirs_snapshot: userDiffSnapshot } : {}
|
|
3394
|
+
} : {}
|
|
3249
3395
|
});
|
|
3250
3396
|
} catch {
|
|
3251
3397
|
}
|
|
@@ -3293,30 +3439,34 @@ var ReplayService = class {
|
|
|
3293
3439
|
continue;
|
|
3294
3440
|
}
|
|
3295
3441
|
const newContentHash = this.detector.computeContentHash(diff);
|
|
3296
|
-
const
|
|
3297
|
-
if (
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3442
|
+
const priorPatchId = seenContentHashes.get(newContentHash);
|
|
3443
|
+
if (priorPatchId !== void 0) {
|
|
3444
|
+
this.lockManager.removePatch(patch.id);
|
|
3445
|
+
absorbedPatchIds.add(patch.id);
|
|
3446
|
+
absorbed++;
|
|
3447
|
+
if (priorPatchId !== patch.id) {
|
|
3448
|
+
this.warnings.push(
|
|
3449
|
+
`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.`
|
|
3450
|
+
);
|
|
3305
3451
|
}
|
|
3306
|
-
|
|
3307
|
-
`Preserving patch ${patch.id} (commit ${(patch.original_commit || "<missing>").slice(0, 7)}): shares content_hash with a prior patch ${provenanceMissing ? "(provenance missing on at least one side)" : "from a different source commit"}. Both retained to avoid silent customization loss (FER-9983).`
|
|
3308
|
-
);
|
|
3452
|
+
continue;
|
|
3309
3453
|
} else {
|
|
3310
|
-
seenContentHashes.set(newContentHash, patch.
|
|
3454
|
+
seenContentHashes.set(newContentHash, patch.id);
|
|
3311
3455
|
}
|
|
3312
3456
|
patch.base_generation = currentGenSha;
|
|
3313
3457
|
patch.patch_content = diff;
|
|
3314
3458
|
patch.content_hash = newContentHash;
|
|
3459
|
+
const snapshot = await this.readSnapshotFromDisk(patch.files);
|
|
3460
|
+
const snapshotIsNonEmpty = Object.keys(snapshot).length > 0;
|
|
3461
|
+
if (snapshotIsNonEmpty) {
|
|
3462
|
+
patch.theirs_snapshot = snapshot;
|
|
3463
|
+
}
|
|
3315
3464
|
try {
|
|
3316
3465
|
this.lockManager.updatePatch(patch.id, {
|
|
3317
3466
|
base_generation: currentGenSha,
|
|
3318
3467
|
patch_content: diff,
|
|
3319
|
-
content_hash: newContentHash
|
|
3468
|
+
content_hash: newContentHash,
|
|
3469
|
+
...snapshotIsNonEmpty ? { theirs_snapshot: snapshot } : {}
|
|
3320
3470
|
});
|
|
3321
3471
|
} catch {
|
|
3322
3472
|
}
|
|
@@ -3326,6 +3476,172 @@ var ReplayService = class {
|
|
|
3326
3476
|
}
|
|
3327
3477
|
return { absorbed, repointed, contentRebased, keptAsUserOwned, absorbedPatchIds };
|
|
3328
3478
|
}
|
|
3479
|
+
/**
|
|
3480
|
+
* Read THEIRS snapshot (post-customer-edit content) for a list of files
|
|
3481
|
+
* from the working tree on disk. Used by `rebasePatches` after a clean
|
|
3482
|
+
* apply — disk = correctly-merged customer state at the new generation.
|
|
3483
|
+
* Skips binary files (matches `mergeFile`/`populateAccumulatorForPatch`
|
|
3484
|
+
* policy — binary content as `utf-8` is lossy and snapshots of it would
|
|
3485
|
+
* never be used during apply anyway).
|
|
3486
|
+
*/
|
|
3487
|
+
async readSnapshotFromDisk(files) {
|
|
3488
|
+
const snapshot = {};
|
|
3489
|
+
for (const file of files) {
|
|
3490
|
+
if (isBinaryFile(file)) continue;
|
|
3491
|
+
try {
|
|
3492
|
+
const content = await readFileAsync(join5(this.outputDir, file), "utf-8");
|
|
3493
|
+
snapshot[file] = content;
|
|
3494
|
+
} catch {
|
|
3495
|
+
}
|
|
3496
|
+
}
|
|
3497
|
+
return snapshot;
|
|
3498
|
+
}
|
|
3499
|
+
/**
|
|
3500
|
+
* FER-10203: returns true when a `[fern-autoversion]` commit between
|
|
3501
|
+
* `fromSha` and `toSha` touched any of the patch's `files`. Re-classifies
|
|
3502
|
+
* each grep match via `isGenerationCommit` so a customer commit that
|
|
3503
|
+
* merely quotes the marker in its body doesn't false-positive.
|
|
3504
|
+
*/
|
|
3505
|
+
async hasAutoversionContamination(fromSha, toSha, files) {
|
|
3506
|
+
if (files.length === 0) return false;
|
|
3507
|
+
const log = await this.git.exec([
|
|
3508
|
+
"log",
|
|
3509
|
+
`${fromSha}..${toSha}`,
|
|
3510
|
+
"--grep=\\[fern-autoversion\\]",
|
|
3511
|
+
"--extended-regexp",
|
|
3512
|
+
"--format=%H%x09%aN%x09%ae%x09%s",
|
|
3513
|
+
"--",
|
|
3514
|
+
...files
|
|
3515
|
+
]).catch(() => "");
|
|
3516
|
+
if (!log.trim()) return false;
|
|
3517
|
+
for (const line of log.trim().split("\n")) {
|
|
3518
|
+
const [sha, authorName, authorEmail, message] = line.split(" ");
|
|
3519
|
+
if (sha == null) continue;
|
|
3520
|
+
if (isGenerationCommit({
|
|
3521
|
+
sha,
|
|
3522
|
+
authorName: authorName ?? "",
|
|
3523
|
+
authorEmail: authorEmail ?? "",
|
|
3524
|
+
message: message ?? ""
|
|
3525
|
+
})) {
|
|
3526
|
+
return true;
|
|
3527
|
+
}
|
|
3528
|
+
}
|
|
3529
|
+
return false;
|
|
3530
|
+
}
|
|
3531
|
+
/**
|
|
3532
|
+
* FER-10203: rebuild patch_content from `theirs_snapshot` (captured at
|
|
3533
|
+
* detection time, predates pipeline content) rather than a HEAD-relative
|
|
3534
|
+
* diff. Returns `null` when snapshot is missing or doesn't cover every
|
|
3535
|
+
* file in `patch.files` — caller leaves the patch unchanged.
|
|
3536
|
+
*/
|
|
3537
|
+
async computeSnapshotBasedPatchDiff(patch, currentGen) {
|
|
3538
|
+
if (!patch.theirs_snapshot) return null;
|
|
3539
|
+
if (Object.keys(patch.theirs_snapshot).length === 0) return null;
|
|
3540
|
+
for (const file of patch.files) {
|
|
3541
|
+
if (patch.theirs_snapshot[file] == null) return null;
|
|
3542
|
+
}
|
|
3543
|
+
const fragments = [];
|
|
3544
|
+
for (const file of patch.files) {
|
|
3545
|
+
const theirsContent = patch.theirs_snapshot[file];
|
|
3546
|
+
const oursContent = await this.git.showFile(currentGen, file).catch(() => null);
|
|
3547
|
+
if (oursContent == null) {
|
|
3548
|
+
fragments.push(synthesizeNewFileDiff(file, theirsContent));
|
|
3549
|
+
continue;
|
|
3550
|
+
}
|
|
3551
|
+
if (oursContent === theirsContent) continue;
|
|
3552
|
+
const fileDiff = await unifiedDiff(oursContent, theirsContent, file);
|
|
3553
|
+
if (fileDiff.trim()) {
|
|
3554
|
+
fragments.push(fileDiff);
|
|
3555
|
+
}
|
|
3556
|
+
}
|
|
3557
|
+
return fragments.join("");
|
|
3558
|
+
}
|
|
3559
|
+
/**
|
|
3560
|
+
* Read THEIRS snapshot from a git tree-ish (commit, branch, or tree).
|
|
3561
|
+
* Used when the diff that produced `patch_content` was relative to that
|
|
3562
|
+
* tree (e.g., `git diff currentGen HEAD --` → snapshot from HEAD).
|
|
3563
|
+
* Skips binary files.
|
|
3564
|
+
*/
|
|
3565
|
+
async readSnapshotFromTree(treeRef, files) {
|
|
3566
|
+
const snapshot = {};
|
|
3567
|
+
for (const file of files) {
|
|
3568
|
+
if (isBinaryFile(file)) continue;
|
|
3569
|
+
const content = await this.git.showFile(treeRef, file).catch(() => null);
|
|
3570
|
+
if (content != null) snapshot[file] = content;
|
|
3571
|
+
}
|
|
3572
|
+
return snapshot;
|
|
3573
|
+
}
|
|
3574
|
+
/**
|
|
3575
|
+
* One-shot auto-migration: backfill `theirs_snapshot` on existing
|
|
3576
|
+
* patches that predate the snapshot encoding. Computes the snapshot by
|
|
3577
|
+
* applying the patch's `patch_content` to its `base_generation` tree —
|
|
3578
|
+
* yielding the patch's INDIVIDUAL contribution.
|
|
3579
|
+
*
|
|
3580
|
+
* **Skips patches sharing files with other patches.** HEAD's content
|
|
3581
|
+
* for a file shared by N patches is cumulative across all of them, so
|
|
3582
|
+
* a HEAD-fallback would falsely encode other patches' contributions
|
|
3583
|
+
* into one patch's snapshot — breaking FER-9525 cross-patch isolation
|
|
3584
|
+
* if the snapshot fallback later fires. Per-patch snapshots only
|
|
3585
|
+
* make sense when the patch owns its file. Multi-patch-same-file
|
|
3586
|
+
* legacy lockfiles keep using line-anchored reconstruction (the
|
|
3587
|
+
* existing path); they're no worse than pre-upgrade.
|
|
3588
|
+
*
|
|
3589
|
+
* **Does not fall back to HEAD when BASE-reconstruction fails.** Same
|
|
3590
|
+
* reason — HEAD content can be cumulative. Patches whose
|
|
3591
|
+
* reconstruction fails just don't get a snapshot; legacy paths
|
|
3592
|
+
* (PR #73's by-association gate, accumulator fallback) handle them.
|
|
3593
|
+
*
|
|
3594
|
+
* Skips patches that already have a snapshot.
|
|
3595
|
+
*/
|
|
3596
|
+
async migratePatchesToSnapshot(patches) {
|
|
3597
|
+
const { applyPatchToContent: applyPatchToContent2 } = await Promise.resolve().then(() => (init_PatchApplyTheirs(), PatchApplyTheirs_exports));
|
|
3598
|
+
const fileFreq = /* @__PURE__ */ new Map();
|
|
3599
|
+
for (const p of patches) {
|
|
3600
|
+
for (const f of p.files) {
|
|
3601
|
+
fileFreq.set(f, (fileFreq.get(f) ?? 0) + 1);
|
|
3602
|
+
}
|
|
3603
|
+
}
|
|
3604
|
+
let migrated = 0;
|
|
3605
|
+
let skipped = 0;
|
|
3606
|
+
for (const patch of patches) {
|
|
3607
|
+
if (patch.theirs_snapshot != null) continue;
|
|
3608
|
+
const sharesAnyFile = patch.files.some((f) => (fileFreq.get(f) ?? 0) > 1);
|
|
3609
|
+
if (sharesAnyFile) {
|
|
3610
|
+
skipped++;
|
|
3611
|
+
continue;
|
|
3612
|
+
}
|
|
3613
|
+
const snapshot = {};
|
|
3614
|
+
for (const file of patch.files) {
|
|
3615
|
+
if (isBinaryFile(file)) continue;
|
|
3616
|
+
try {
|
|
3617
|
+
const base = await this.git.showFile(patch.base_generation, file).catch(() => null);
|
|
3618
|
+
if (base != null) {
|
|
3619
|
+
const content = await applyPatchToContent2(base, patch.patch_content, file);
|
|
3620
|
+
if (content != null) snapshot[file] = content;
|
|
3621
|
+
}
|
|
3622
|
+
} catch {
|
|
3623
|
+
}
|
|
3624
|
+
}
|
|
3625
|
+
if (Object.keys(snapshot).length > 0) {
|
|
3626
|
+
patch.theirs_snapshot = snapshot;
|
|
3627
|
+
try {
|
|
3628
|
+
this.lockManager.updatePatch(patch.id, { theirs_snapshot: snapshot });
|
|
3629
|
+
migrated++;
|
|
3630
|
+
} catch {
|
|
3631
|
+
}
|
|
3632
|
+
}
|
|
3633
|
+
}
|
|
3634
|
+
if (migrated > 0) {
|
|
3635
|
+
this.warnings.push(
|
|
3636
|
+
`Migrated ${migrated} patch(es) to snapshot encoding. Future regens use 3-way merge with stored THEIRS, robust to generator structural changes.`
|
|
3637
|
+
);
|
|
3638
|
+
}
|
|
3639
|
+
if (skipped > 0) {
|
|
3640
|
+
this.warnings.push(
|
|
3641
|
+
`Skipped snapshot migration for ${skipped} patch(es) sharing files with other patches. These continue to use line-anchored reconstruction (the existing path).`
|
|
3642
|
+
);
|
|
3643
|
+
}
|
|
3644
|
+
}
|
|
3329
3645
|
/**
|
|
3330
3646
|
* Determine whether `file` is user-owned (never produced by the generator).
|
|
3331
3647
|
*
|
|
@@ -3446,6 +3762,20 @@ var ReplayService = class {
|
|
|
3446
3762
|
this.lockManager.clearReplaySkippedAt();
|
|
3447
3763
|
return { conflictResolved: 0, conflictAbsorbed: 0, contentRefreshed: 0 };
|
|
3448
3764
|
}
|
|
3765
|
+
await this.migratePatchesToSnapshot(patches);
|
|
3766
|
+
const headMessage = await this.git.exec(["log", "-1", "--format=%s%n%aN%n%ae", "HEAD"]).catch(() => "");
|
|
3767
|
+
const [headMsgLine, headAuthorName, headAuthorEmail] = headMessage.split("\n");
|
|
3768
|
+
const headIsGeneration = headMsgLine ? isGenerationCommit({
|
|
3769
|
+
sha: "HEAD",
|
|
3770
|
+
authorName: headAuthorName ?? "",
|
|
3771
|
+
authorEmail: headAuthorEmail ?? "",
|
|
3772
|
+
message: headMsgLine
|
|
3773
|
+
}) : false;
|
|
3774
|
+
const headParentChangedFiles = async (patchFiles) => {
|
|
3775
|
+
if (patchFiles.length === 0) return /* @__PURE__ */ new Set();
|
|
3776
|
+
const diff = await this.git.exec(["diff", "--name-only", "HEAD~1", "HEAD", "--", ...patchFiles]).catch(() => "");
|
|
3777
|
+
return new Set(diff.trim().split("\n").filter(Boolean));
|
|
3778
|
+
};
|
|
3449
3779
|
let conflictResolved = 0;
|
|
3450
3780
|
let conflictAbsorbed = 0;
|
|
3451
3781
|
let contentRefreshed = 0;
|
|
@@ -3482,8 +3812,50 @@ var ReplayService = class {
|
|
|
3482
3812
|
delete patch.status;
|
|
3483
3813
|
continue;
|
|
3484
3814
|
}
|
|
3815
|
+
if (headIsGeneration) {
|
|
3816
|
+
const parentChangedFiles = await headParentChangedFiles(patch.files);
|
|
3817
|
+
const headTouchedSource = patch.files.some((f) => parentChangedFiles.has(f));
|
|
3818
|
+
if (headTouchedSource) {
|
|
3819
|
+
continue;
|
|
3820
|
+
}
|
|
3821
|
+
}
|
|
3485
3822
|
if (patch.base_generation === currentGen) {
|
|
3486
3823
|
try {
|
|
3824
|
+
const contaminated = await this.hasAutoversionContamination(
|
|
3825
|
+
currentGen,
|
|
3826
|
+
"HEAD",
|
|
3827
|
+
patch.files
|
|
3828
|
+
);
|
|
3829
|
+
if (contaminated) {
|
|
3830
|
+
const snapshotDiff = await this.computeSnapshotBasedPatchDiff(
|
|
3831
|
+
patch,
|
|
3832
|
+
currentGen
|
|
3833
|
+
);
|
|
3834
|
+
if (snapshotDiff === null) continue;
|
|
3835
|
+
if (!snapshotDiff.trim()) {
|
|
3836
|
+
if (await allPatchFilesUserOwned(patch)) continue;
|
|
3837
|
+
this.lockManager.removePatch(patch.id);
|
|
3838
|
+
contentRefreshed++;
|
|
3839
|
+
continue;
|
|
3840
|
+
}
|
|
3841
|
+
const snapHasStaleMarkers = snapshotDiff.split("\n").some(
|
|
3842
|
+
(l) => l.startsWith("+<<<<<<< Generated") || l.startsWith("+>>>>>>> Your customization")
|
|
3843
|
+
);
|
|
3844
|
+
if (snapHasStaleMarkers) continue;
|
|
3845
|
+
const isProtectedSnap = patch.files.some(
|
|
3846
|
+
(file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch2(file, p))
|
|
3847
|
+
);
|
|
3848
|
+
if (isProtectedSnap) continue;
|
|
3849
|
+
const snapHash = this.detector.computeContentHash(snapshotDiff);
|
|
3850
|
+
if (snapHash !== patch.content_hash) {
|
|
3851
|
+
this.lockManager.updatePatch(patch.id, {
|
|
3852
|
+
patch_content: snapshotDiff,
|
|
3853
|
+
content_hash: snapHash
|
|
3854
|
+
});
|
|
3855
|
+
contentRefreshed++;
|
|
3856
|
+
}
|
|
3857
|
+
continue;
|
|
3858
|
+
}
|
|
3487
3859
|
const ppResult = await computePerPatchDiffWithFallback(
|
|
3488
3860
|
patch,
|
|
3489
3861
|
(f) => this.git.showFile(currentGen, f),
|
|
@@ -3518,10 +3890,12 @@ var ReplayService = class {
|
|
|
3518
3890
|
if (newFiles.length === 0) {
|
|
3519
3891
|
this.lockManager.removePatch(patch.id);
|
|
3520
3892
|
} else {
|
|
3893
|
+
const snapshot = await this.readSnapshotFromTree("HEAD", newFiles);
|
|
3521
3894
|
this.lockManager.updatePatch(patch.id, {
|
|
3522
3895
|
patch_content: diff,
|
|
3523
3896
|
content_hash: newContentHash,
|
|
3524
|
-
files: newFiles
|
|
3897
|
+
files: newFiles,
|
|
3898
|
+
...Object.keys(snapshot).length > 0 ? { theirs_snapshot: snapshot } : {}
|
|
3525
3899
|
});
|
|
3526
3900
|
}
|
|
3527
3901
|
contentRefreshed++;
|
|
@@ -3533,6 +3907,42 @@ var ReplayService = class {
|
|
|
3533
3907
|
try {
|
|
3534
3908
|
const markerFiles = await this.git.exec(["grep", "-l", "<<<<<<< Generated", "HEAD", "--", ...patch.files]).catch(() => "");
|
|
3535
3909
|
if (markerFiles.trim()) continue;
|
|
3910
|
+
const contaminated = await this.hasAutoversionContamination(
|
|
3911
|
+
currentGen,
|
|
3912
|
+
"HEAD",
|
|
3913
|
+
patch.files
|
|
3914
|
+
);
|
|
3915
|
+
if (contaminated) {
|
|
3916
|
+
const snapshotDiff = await this.computeSnapshotBasedPatchDiff(
|
|
3917
|
+
patch,
|
|
3918
|
+
currentGen
|
|
3919
|
+
);
|
|
3920
|
+
if (snapshotDiff === null) continue;
|
|
3921
|
+
if (!snapshotDiff.trim()) {
|
|
3922
|
+
if (await allPatchFilesUserOwned(patch)) {
|
|
3923
|
+
try {
|
|
3924
|
+
this.lockManager.updatePatch(patch.id, { base_generation: currentGen });
|
|
3925
|
+
} catch {
|
|
3926
|
+
}
|
|
3927
|
+
continue;
|
|
3928
|
+
}
|
|
3929
|
+
this.lockManager.removePatch(patch.id);
|
|
3930
|
+
conflictAbsorbed++;
|
|
3931
|
+
continue;
|
|
3932
|
+
}
|
|
3933
|
+
const snapHasStaleMarkers = snapshotDiff.split("\n").some(
|
|
3934
|
+
(l) => l.startsWith("+<<<<<<< Generated") || l.startsWith("+>>>>>>> Your customization")
|
|
3935
|
+
);
|
|
3936
|
+
if (snapHasStaleMarkers) continue;
|
|
3937
|
+
const snapHash = this.detector.computeContentHash(snapshotDiff);
|
|
3938
|
+
this.lockManager.updatePatch(patch.id, {
|
|
3939
|
+
base_generation: currentGen,
|
|
3940
|
+
patch_content: snapshotDiff,
|
|
3941
|
+
content_hash: snapHash
|
|
3942
|
+
});
|
|
3943
|
+
conflictResolved++;
|
|
3944
|
+
continue;
|
|
3945
|
+
}
|
|
3536
3946
|
const ppResult = await computePerPatchDiffWithFallback(
|
|
3537
3947
|
patch,
|
|
3538
3948
|
(f) => this.git.showFile(currentGen, f),
|
|
@@ -3554,10 +3964,12 @@ var ReplayService = class {
|
|
|
3554
3964
|
continue;
|
|
3555
3965
|
}
|
|
3556
3966
|
const newContentHash = this.detector.computeContentHash(diff);
|
|
3967
|
+
const snapshot = await this.readSnapshotFromTree("HEAD", patch.files);
|
|
3557
3968
|
this.lockManager.updatePatch(patch.id, {
|
|
3558
3969
|
base_generation: currentGen,
|
|
3559
3970
|
patch_content: diff,
|
|
3560
|
-
content_hash: newContentHash
|
|
3971
|
+
content_hash: newContentHash,
|
|
3972
|
+
...Object.keys(snapshot).length > 0 ? { theirs_snapshot: snapshot } : {}
|
|
3561
3973
|
});
|
|
3562
3974
|
conflictResolved++;
|
|
3563
3975
|
} catch {
|
|
@@ -3600,7 +4012,7 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
|
|
|
3600
4012
|
if (result.status !== "conflict" || !result.fileResults) continue;
|
|
3601
4013
|
for (const fileResult of result.fileResults) {
|
|
3602
4014
|
if (fileResult.status !== "conflict") continue;
|
|
3603
|
-
const filePath =
|
|
4015
|
+
const filePath = join5(this.outputDir, fileResult.file);
|
|
3604
4016
|
try {
|
|
3605
4017
|
const content = readFileSync2(filePath, "utf-8");
|
|
3606
4018
|
const stripped = stripConflictMarkers(content);
|
|
@@ -3630,7 +4042,7 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
|
|
|
3630
4042
|
}
|
|
3631
4043
|
}
|
|
3632
4044
|
readFernignorePatterns() {
|
|
3633
|
-
const fernignorePath =
|
|
4045
|
+
const fernignorePath = join5(this.outputDir, ".fernignore");
|
|
3634
4046
|
if (!existsSync2(fernignorePath)) return [];
|
|
3635
4047
|
return readFileSync2(fernignorePath, "utf-8").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
|
|
3636
4048
|
}
|
|
@@ -3709,7 +4121,7 @@ function computeCommitBuckets(results, absorbedPatchIds, patchesPassedToCommit)
|
|
|
3709
4121
|
// src/FernignoreMigrator.ts
|
|
3710
4122
|
import { createHash as createHash2 } from "crypto";
|
|
3711
4123
|
import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, statSync, writeFileSync as writeFileSync3 } from "fs";
|
|
3712
|
-
import { dirname as
|
|
4124
|
+
import { dirname as dirname4, join as join6 } from "path";
|
|
3713
4125
|
import { minimatch as minimatch3 } from "minimatch";
|
|
3714
4126
|
import { parse as parse2, stringify as stringify2 } from "yaml";
|
|
3715
4127
|
var FernignoreMigrator = class {
|
|
@@ -3722,10 +4134,10 @@ var FernignoreMigrator = class {
|
|
|
3722
4134
|
this.outputDir = outputDir;
|
|
3723
4135
|
}
|
|
3724
4136
|
fernignoreExists() {
|
|
3725
|
-
return existsSync3(
|
|
4137
|
+
return existsSync3(join6(this.outputDir, ".fernignore"));
|
|
3726
4138
|
}
|
|
3727
4139
|
readFernignorePatterns() {
|
|
3728
|
-
const fernignorePath =
|
|
4140
|
+
const fernignorePath = join6(this.outputDir, ".fernignore");
|
|
3729
4141
|
if (!existsSync3(fernignorePath)) {
|
|
3730
4142
|
return [];
|
|
3731
4143
|
}
|
|
@@ -3777,6 +4189,12 @@ var FernignoreMigrator = class {
|
|
|
3777
4189
|
if (patchFiles.length > 0) {
|
|
3778
4190
|
const patchContent = diffParts.join("\n");
|
|
3779
4191
|
const contentHash = `sha256:${createHash2("sha256").update(patchContent).digest("hex")}`;
|
|
4192
|
+
const theirsSnapshot = {};
|
|
4193
|
+
for (const filePath of patchFiles) {
|
|
4194
|
+
if (isBinaryFile(filePath)) continue;
|
|
4195
|
+
const c = this.readFileContent(filePath);
|
|
4196
|
+
if (c != null) theirsSnapshot[filePath] = c;
|
|
4197
|
+
}
|
|
3780
4198
|
syntheticPatches.push({
|
|
3781
4199
|
id: `patch-fernignore-${createHash2("sha256").update(pattern).digest("hex").slice(0, 8)}`,
|
|
3782
4200
|
content_hash: contentHash,
|
|
@@ -3786,6 +4204,7 @@ var FernignoreMigrator = class {
|
|
|
3786
4204
|
base_generation: currentGen.commit_sha,
|
|
3787
4205
|
files: patchFiles,
|
|
3788
4206
|
patch_content: patchContent,
|
|
4207
|
+
...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
|
|
3789
4208
|
// Synthetic patches captured from .fernignore-protected files
|
|
3790
4209
|
// are user-owned by definition — the customer's content
|
|
3791
4210
|
// diverges from the generator's pristine output, and the
|
|
@@ -3827,7 +4246,7 @@ var FernignoreMigrator = class {
|
|
|
3827
4246
|
return results;
|
|
3828
4247
|
}
|
|
3829
4248
|
readFileContent(filePath) {
|
|
3830
|
-
const fullPath =
|
|
4249
|
+
const fullPath = join6(this.outputDir, filePath);
|
|
3831
4250
|
if (!existsSync3(fullPath)) {
|
|
3832
4251
|
return null;
|
|
3833
4252
|
}
|
|
@@ -3892,7 +4311,7 @@ var FernignoreMigrator = class {
|
|
|
3892
4311
|
};
|
|
3893
4312
|
}
|
|
3894
4313
|
movePatternsToReplayYml(patterns) {
|
|
3895
|
-
const replayYmlPath =
|
|
4314
|
+
const replayYmlPath = join6(this.outputDir, ".fern", "replay.yml");
|
|
3896
4315
|
let config = {};
|
|
3897
4316
|
if (existsSync3(replayYmlPath)) {
|
|
3898
4317
|
const content = readFileSync3(replayYmlPath, "utf-8");
|
|
@@ -3901,7 +4320,7 @@ var FernignoreMigrator = class {
|
|
|
3901
4320
|
const existing = config.exclude ?? [];
|
|
3902
4321
|
const merged = [.../* @__PURE__ */ new Set([...existing, ...patterns])];
|
|
3903
4322
|
config.exclude = merged;
|
|
3904
|
-
const dir =
|
|
4323
|
+
const dir = dirname4(replayYmlPath);
|
|
3905
4324
|
if (!existsSync3(dir)) {
|
|
3906
4325
|
mkdirSync2(dir, { recursive: true });
|
|
3907
4326
|
}
|
|
@@ -3912,7 +4331,7 @@ var FernignoreMigrator = class {
|
|
|
3912
4331
|
// src/commands/bootstrap.ts
|
|
3913
4332
|
import { createHash as createHash3 } from "crypto";
|
|
3914
4333
|
import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
|
|
3915
|
-
import { join as
|
|
4334
|
+
import { join as join7 } from "path";
|
|
3916
4335
|
init_GitClient();
|
|
3917
4336
|
async function bootstrap(outputDir, options) {
|
|
3918
4337
|
const git = new GitClient(outputDir);
|
|
@@ -4079,7 +4498,7 @@ function parseGitLog(log) {
|
|
|
4079
4498
|
}
|
|
4080
4499
|
var REPLAY_FERNIGNORE_ENTRIES = [".fern/replay.lock", ".fern/replay.yml", ".gitattributes"];
|
|
4081
4500
|
function ensureFernignoreEntries(outputDir) {
|
|
4082
|
-
const fernignorePath =
|
|
4501
|
+
const fernignorePath = join7(outputDir, ".fernignore");
|
|
4083
4502
|
let content = "";
|
|
4084
4503
|
if (existsSync4(fernignorePath)) {
|
|
4085
4504
|
content = readFileSync4(fernignorePath, "utf-8");
|
|
@@ -4103,7 +4522,7 @@ function ensureFernignoreEntries(outputDir) {
|
|
|
4103
4522
|
}
|
|
4104
4523
|
var GITATTRIBUTES_ENTRIES = [".fern/replay.lock linguist-generated=true"];
|
|
4105
4524
|
function ensureGitattributesEntries(outputDir) {
|
|
4106
|
-
const gitattributesPath =
|
|
4525
|
+
const gitattributesPath = join7(outputDir, ".gitattributes");
|
|
4107
4526
|
let content = "";
|
|
4108
4527
|
if (existsSync4(gitattributesPath)) {
|
|
4109
4528
|
content = readFileSync4(gitattributesPath, "utf-8");
|
|
@@ -4302,8 +4721,8 @@ function reset(outputDir, options) {
|
|
|
4302
4721
|
|
|
4303
4722
|
// src/commands/resolve.ts
|
|
4304
4723
|
init_GitClient();
|
|
4305
|
-
import { readFile as
|
|
4306
|
-
import { join as
|
|
4724
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
4725
|
+
import { join as join8 } from "path";
|
|
4307
4726
|
async function resolve(outputDir, options) {
|
|
4308
4727
|
const lockManager = new LockfileManager(outputDir);
|
|
4309
4728
|
if (!lockManager.exists()) {
|
|
@@ -4346,36 +4765,78 @@ async function resolve(outputDir, options) {
|
|
|
4346
4765
|
const detector = new ReplayDetector(git, lockManager, outputDir);
|
|
4347
4766
|
const warnings = [];
|
|
4348
4767
|
let patchesResolved = 0;
|
|
4768
|
+
const pass1 = [];
|
|
4349
4769
|
for (const patch of patchesToCommit) {
|
|
4350
4770
|
const result = await computePerPatchDiffWithFallback(
|
|
4351
4771
|
patch,
|
|
4352
4772
|
(f) => git.showFile(currentGen, f),
|
|
4353
|
-
(f) =>
|
|
4773
|
+
(f) => readFile3(join8(outputDir, f), "utf-8").catch(() => null),
|
|
4354
4774
|
(files) => git.exec(["diff", currentGen, "--", ...files]).catch(() => null)
|
|
4355
4775
|
);
|
|
4356
4776
|
if (result === null) {
|
|
4357
4777
|
warnings.push(
|
|
4358
4778
|
`Patch ${patch.id}: cumulative-diff fallback failed for unlocatable files; preserving patch unchanged`
|
|
4359
4779
|
);
|
|
4780
|
+
pass1.push({ kind: "skip" });
|
|
4360
4781
|
continue;
|
|
4361
4782
|
}
|
|
4362
4783
|
const diff = result.diff;
|
|
4363
4784
|
if (!diff.trim()) {
|
|
4785
|
+
pass1.push({ kind: "revert" });
|
|
4786
|
+
continue;
|
|
4787
|
+
}
|
|
4788
|
+
const hash = detector.computeContentHash(diff);
|
|
4789
|
+
pass1.push({
|
|
4790
|
+
kind: "apply",
|
|
4791
|
+
diff,
|
|
4792
|
+
hash,
|
|
4793
|
+
hadFallback: result.hadFallback,
|
|
4794
|
+
fallbackFiles: result.fallbackFiles
|
|
4795
|
+
});
|
|
4796
|
+
}
|
|
4797
|
+
const lastIndexByHash = /* @__PURE__ */ new Map();
|
|
4798
|
+
for (let i = 0; i < pass1.length; i++) {
|
|
4799
|
+
const r = pass1[i];
|
|
4800
|
+
if (r.kind === "apply") lastIndexByHash.set(r.hash, i);
|
|
4801
|
+
}
|
|
4802
|
+
for (let i = 0; i < patchesToCommit.length; i++) {
|
|
4803
|
+
const patch = patchesToCommit[i];
|
|
4804
|
+
const r = pass1[i];
|
|
4805
|
+
if (r.kind === "skip") continue;
|
|
4806
|
+
if (r.kind === "revert") {
|
|
4807
|
+
lockManager.removePatch(patch.id);
|
|
4808
|
+
continue;
|
|
4809
|
+
}
|
|
4810
|
+
if (lastIndexByHash.get(r.hash) !== i) {
|
|
4811
|
+
const canonicalIdx = lastIndexByHash.get(r.hash);
|
|
4812
|
+
const canonical = patchesToCommit[canonicalIdx];
|
|
4364
4813
|
lockManager.removePatch(patch.id);
|
|
4814
|
+
warnings.push(
|
|
4815
|
+
`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.`
|
|
4816
|
+
);
|
|
4365
4817
|
continue;
|
|
4366
4818
|
}
|
|
4367
|
-
if (
|
|
4819
|
+
if (r.hadFallback) {
|
|
4368
4820
|
warnings.push(
|
|
4369
|
-
`Patch ${patch.id}: ${
|
|
4821
|
+
`Patch ${patch.id}: ${r.fallbackFiles.join(", ")} could not be anchored; cumulative-diff fallback applied for those files`
|
|
4370
4822
|
);
|
|
4371
4823
|
}
|
|
4372
|
-
const
|
|
4373
|
-
const
|
|
4824
|
+
const changedFiles = r.hadFallback ? await getChangedFiles(git, currentGen, patch.files) : extractFilesFromDiff(r.diff);
|
|
4825
|
+
const filesForSnapshot = changedFiles.length > 0 ? changedFiles : patch.files;
|
|
4826
|
+
const theirsSnapshot = {};
|
|
4827
|
+
for (const file of filesForSnapshot) {
|
|
4828
|
+
if (isBinaryFile(file)) continue;
|
|
4829
|
+
const content = await readFile3(join8(outputDir, file), "utf-8").catch(() => null);
|
|
4830
|
+
if (content != null) {
|
|
4831
|
+
theirsSnapshot[file] = content;
|
|
4832
|
+
}
|
|
4833
|
+
}
|
|
4374
4834
|
lockManager.markPatchResolved(patch.id, {
|
|
4375
|
-
patch_content: diff,
|
|
4376
|
-
content_hash:
|
|
4835
|
+
patch_content: r.diff,
|
|
4836
|
+
content_hash: r.hash,
|
|
4377
4837
|
base_generation: currentGen,
|
|
4378
|
-
files:
|
|
4838
|
+
files: filesForSnapshot,
|
|
4839
|
+
...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {}
|
|
4379
4840
|
});
|
|
4380
4841
|
patchesResolved++;
|
|
4381
4842
|
}
|