@fern-api/replay 0.13.0 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.cjs +3722 -2789
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +2227 -1288
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +158 -5
- package/dist/index.d.ts +158 -5
- package/dist/index.js +2226 -1288
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -29,9 +29,9 @@ var init_GitClient = __esm({
|
|
|
29
29
|
return this.git.raw(args);
|
|
30
30
|
}
|
|
31
31
|
async execWithInput(args, input) {
|
|
32
|
-
const { spawn } = await import("child_process");
|
|
32
|
+
const { spawn: spawn2 } = await import("child_process");
|
|
33
33
|
return new Promise((resolve2, reject) => {
|
|
34
|
-
const proc =
|
|
34
|
+
const proc = spawn2("git", args, { cwd: this.repoPath });
|
|
35
35
|
let stdout = "";
|
|
36
36
|
let stderr = "";
|
|
37
37
|
proc.stdout.on("data", (data) => {
|
|
@@ -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,1406 +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
|
-
|
|
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;
|
|
862
838
|
}
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
const result = {
|
|
842
|
+
content: outputLines.join("\n"),
|
|
843
|
+
hasConflicts: conflicts.length > 0,
|
|
844
|
+
conflicts
|
|
845
|
+
};
|
|
846
|
+
if (conflicts.length === 0) {
|
|
847
|
+
const dropped = computeDroppedContextLines(baseLines, theirsLines, outputLines);
|
|
848
|
+
if (dropped.length > 0) {
|
|
849
|
+
result.droppedContextLines = dropped;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
return result;
|
|
853
|
+
}
|
|
854
|
+
function computeDroppedContextLines(baseLines, theirsLines, mergedLines) {
|
|
855
|
+
const baseCounts = countLines(baseLines);
|
|
856
|
+
const theirsCounts = countLines(theirsLines);
|
|
857
|
+
const mergedCounts = countLines(mergedLines);
|
|
858
|
+
const dropped = [];
|
|
859
|
+
const seen = /* @__PURE__ */ new Set();
|
|
860
|
+
for (const line of baseLines) {
|
|
861
|
+
if (seen.has(line)) continue;
|
|
862
|
+
const inBase = baseCounts.get(line) ?? 0;
|
|
863
|
+
const inTheirs = theirsCounts.get(line) ?? 0;
|
|
864
|
+
const inMerged = mergedCounts.get(line) ?? 0;
|
|
865
|
+
if (inBase > 0 && inTheirs > 0 && inMerged < Math.min(inBase, inTheirs)) {
|
|
866
|
+
dropped.push(line);
|
|
867
|
+
seen.add(line);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
return dropped;
|
|
871
|
+
}
|
|
872
|
+
function countLines(lines) {
|
|
873
|
+
const counts = /* @__PURE__ */ new Map();
|
|
874
|
+
for (const line of lines) {
|
|
875
|
+
counts.set(line, (counts.get(line) ?? 0) + 1);
|
|
876
|
+
}
|
|
877
|
+
return counts;
|
|
878
|
+
}
|
|
879
|
+
function tryResolveConflict(oursLines, baseLines, theirsLines) {
|
|
880
|
+
if (baseLines.length === 0) {
|
|
881
|
+
return null;
|
|
882
|
+
}
|
|
883
|
+
const oursPatches = diffPatch(baseLines, oursLines);
|
|
884
|
+
const theirsPatches = diffPatch(baseLines, theirsLines);
|
|
885
|
+
if (oursPatches.length === 0) return theirsLines;
|
|
886
|
+
if (theirsPatches.length === 0) return oursLines;
|
|
887
|
+
if (patchesOverlap(oursPatches, theirsPatches)) {
|
|
888
|
+
return null;
|
|
889
|
+
}
|
|
890
|
+
return applyBothPatches(baseLines, oursPatches, theirsPatches);
|
|
891
|
+
}
|
|
892
|
+
function patchesOverlap(oursPatches, theirsPatches) {
|
|
893
|
+
for (const op of oursPatches) {
|
|
894
|
+
const oStart = op.buffer1.offset;
|
|
895
|
+
const oEnd = oStart + op.buffer1.length;
|
|
896
|
+
for (const tp of theirsPatches) {
|
|
897
|
+
const tStart = tp.buffer1.offset;
|
|
898
|
+
const tEnd = tStart + tp.buffer1.length;
|
|
899
|
+
if (op.buffer1.length === 0 && tp.buffer1.length === 0 && oStart === tStart) {
|
|
900
|
+
return true;
|
|
870
901
|
}
|
|
871
|
-
if (
|
|
872
|
-
|
|
873
|
-
if (revertedSha) {
|
|
874
|
-
const idx = survivingPatches.findIndex(
|
|
875
|
-
(p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_commit === revertedSha
|
|
876
|
-
);
|
|
877
|
-
if (idx !== -1) {
|
|
878
|
-
revertIndicesToRemove.add(i);
|
|
879
|
-
revertIndicesToRemove.add(idx);
|
|
880
|
-
matchedNew = true;
|
|
881
|
-
}
|
|
902
|
+
if (tp.buffer1.length === 0 && tStart === oEnd) {
|
|
903
|
+
return true;
|
|
882
904
|
}
|
|
883
|
-
if (
|
|
884
|
-
|
|
885
|
-
(p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_message === revertedMessage
|
|
886
|
-
);
|
|
887
|
-
if (idx !== -1) {
|
|
888
|
-
revertIndicesToRemove.add(i);
|
|
889
|
-
revertIndicesToRemove.add(idx);
|
|
890
|
-
}
|
|
905
|
+
if (op.buffer1.length === 0 && oStart === tEnd) {
|
|
906
|
+
return true;
|
|
891
907
|
}
|
|
892
|
-
if (
|
|
893
|
-
|
|
908
|
+
if (oStart < tEnd && tStart < oEnd) {
|
|
909
|
+
return true;
|
|
894
910
|
}
|
|
895
911
|
}
|
|
896
|
-
const filteredPatches = survivingPatches.filter((_, i) => !revertIndicesToRemove.has(i));
|
|
897
|
-
return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
|
|
898
912
|
}
|
|
913
|
+
return false;
|
|
914
|
+
}
|
|
915
|
+
function applyBothPatches(baseLines, oursPatches, theirsPatches) {
|
|
916
|
+
const allPatches = [
|
|
917
|
+
...oursPatches.map((p) => ({
|
|
918
|
+
offset: p.buffer1.offset,
|
|
919
|
+
length: p.buffer1.length,
|
|
920
|
+
replacement: p.buffer2.chunk
|
|
921
|
+
})),
|
|
922
|
+
...theirsPatches.map((p) => ({
|
|
923
|
+
offset: p.buffer1.offset,
|
|
924
|
+
length: p.buffer1.length,
|
|
925
|
+
replacement: p.buffer2.chunk
|
|
926
|
+
}))
|
|
927
|
+
];
|
|
928
|
+
allPatches.sort((a, b) => b.offset - a.offset);
|
|
929
|
+
const result = [...baseLines];
|
|
930
|
+
for (const p of allPatches) {
|
|
931
|
+
result.splice(p.offset, p.length, ...p.replacement);
|
|
932
|
+
}
|
|
933
|
+
return result;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// src/ReplayApplicator.ts
|
|
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();
|
|
899
993
|
/**
|
|
900
|
-
*
|
|
901
|
-
*
|
|
902
|
-
*
|
|
903
|
-
*
|
|
904
|
-
*
|
|
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
|
|
1004
|
+
*/
|
|
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).
|
|
905
1012
|
*
|
|
906
|
-
*
|
|
907
|
-
*
|
|
908
|
-
*
|
|
909
|
-
*
|
|
910
|
-
* 3. Strip trailing git version marker (`-- \n<version>`).
|
|
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.
|
|
911
1017
|
*
|
|
912
|
-
*
|
|
913
|
-
*
|
|
914
|
-
*
|
|
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.
|
|
915
1024
|
*/
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
return `sha256:${createHash("sha256").update(normalized).digest("hex")}`;
|
|
1025
|
+
sharedFiles = /* @__PURE__ */ new Set();
|
|
1026
|
+
constructor(git, lockManager, outputDir) {
|
|
1027
|
+
this.git = git;
|
|
1028
|
+
this.lockManager = lockManager;
|
|
1029
|
+
this.outputDir = outputDir;
|
|
922
1030
|
}
|
|
923
1031
|
/**
|
|
924
|
-
*
|
|
925
|
-
*
|
|
926
|
-
*
|
|
927
|
-
*
|
|
928
|
-
* Rationale: on a merge commit, createMergeCompositePatch already diffs
|
|
929
|
-
* firstParent..mergeCommit so net-zero files never enter the composite. On
|
|
930
|
-
* linear history each commit becomes its own patch, so a file that was
|
|
931
|
-
* briefly added and later removed survives as a create+delete pair whose
|
|
932
|
-
* cumulative diff is empty. We drop such patches before they reach the
|
|
933
|
-
* lockfile.
|
|
934
|
-
*
|
|
935
|
-
* A file is "transient" when:
|
|
936
|
-
* 1. some patch in the window sets `new file mode` for it (a creation), AND
|
|
937
|
-
* 2. some patch in the window sets `deleted file mode` for it (a deletion), AND
|
|
938
|
-
* 3. it is absent from HEAD's tree.
|
|
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
|
-
}
|
|
964
|
-
}
|
|
965
|
-
let anyCandidate = false;
|
|
966
|
-
for (const state of stateByFile.values()) {
|
|
967
|
-
if (state.created && state.deleted) {
|
|
968
|
-
anyCandidate = true;
|
|
969
|
-
break;
|
|
970
|
-
}
|
|
971
|
-
}
|
|
972
|
-
if (!anyCandidate) return patches;
|
|
973
|
-
const headFiles = await this.listHeadFiles();
|
|
974
|
-
const transient = /* @__PURE__ */ new Set();
|
|
975
|
-
for (const [file, state] of stateByFile) {
|
|
976
|
-
if (state.created && state.deleted && !headFiles.has(file)) {
|
|
977
|
-
transient.add(file);
|
|
978
|
-
}
|
|
979
|
-
}
|
|
980
|
-
if (transient.size === 0) return patches;
|
|
981
|
-
return patches.filter((patch) => !patch.files.every((f) => transient.has(f)));
|
|
982
|
-
}
|
|
983
|
-
/**
|
|
984
|
-
* List every tracked path at HEAD (one `git ls-tree -r` invocation).
|
|
985
|
-
* Returns an empty Set if HEAD is unborn or the invocation fails — the
|
|
986
|
-
* caller then treats every candidate as "not in HEAD" and may collapse
|
|
987
|
-
* more aggressively. On typical SDK repos this is a single sub-10ms call.
|
|
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).
|
|
988
1035
|
*/
|
|
989
|
-
async
|
|
1036
|
+
async resolveBaseGeneration(baseGeneration) {
|
|
1037
|
+
const gen = this.lockManager.getGeneration(baseGeneration);
|
|
1038
|
+
if (gen) return gen;
|
|
990
1039
|
try {
|
|
991
|
-
const
|
|
992
|
-
return
|
|
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
|
+
};
|
|
993
1048
|
} catch {
|
|
994
|
-
return
|
|
1049
|
+
return void 0;
|
|
995
1050
|
}
|
|
996
1051
|
}
|
|
997
|
-
/**
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
* entire merged branch as one patch, eliminating accumulator chaining and
|
|
1001
|
-
* zero-net-change ghost conflicts.
|
|
1002
|
-
*/
|
|
1003
|
-
async createMergeCompositePatch(input) {
|
|
1004
|
-
const { mergeCommit, firstParent, lastGen, lock } = input;
|
|
1005
|
-
const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
|
|
1006
|
-
const filesOutput = await this.git.exec([
|
|
1007
|
-
"diff",
|
|
1008
|
-
"--name-only",
|
|
1009
|
-
firstParent,
|
|
1010
|
-
mergeCommit.sha
|
|
1011
|
-
]);
|
|
1012
|
-
const allMergeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
|
|
1013
|
-
const sensitiveMergeFiles = allMergeFiles.filter((f) => isSensitiveFile(f));
|
|
1014
|
-
const files = allMergeFiles.filter((f) => !isSensitiveFile(f));
|
|
1015
|
-
if (sensitiveMergeFiles.length > 0) {
|
|
1016
|
-
this.warnings.push(
|
|
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;
|
|
1032
|
-
}
|
|
1033
|
-
return {
|
|
1034
|
-
id: `patch-merge-${mergeCommit.sha.slice(0, 8)}`,
|
|
1035
|
-
content_hash: contentHash,
|
|
1036
|
-
original_commit: mergeCommit.sha,
|
|
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
|
-
};
|
|
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
|
|
1205
|
-
}
|
|
1206
|
-
parseGitLog(log) {
|
|
1207
|
-
return log.trim().split("\n").map((line) => {
|
|
1208
|
-
const [sha, authorName, authorEmail, message] = line.split("\0");
|
|
1209
|
-
return { sha, authorName, authorEmail, message };
|
|
1210
|
-
});
|
|
1211
|
-
}
|
|
1212
|
-
getLastGeneration(lock) {
|
|
1213
|
-
return lock.generations.find((g) => g.commit_sha === lock.current_generation);
|
|
1242
|
+
return this.applyWithThreeWayMerge(patch);
|
|
1214
1243
|
}
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
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;
|
|
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);
|
|
1258
1268
|
}
|
|
1269
|
+
} finally {
|
|
1270
|
+
await rm(tempDir, { recursive: true }).catch(() => {
|
|
1271
|
+
});
|
|
1259
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
|
+
};
|
|
1260
1284
|
}
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
}
|
|
1267
|
-
function tryResolveConflict(oursLines, baseLines, theirsLines) {
|
|
1268
|
-
if (baseLines.length === 0) {
|
|
1269
|
-
return null;
|
|
1270
|
-
}
|
|
1271
|
-
const oursPatches = diffPatch(baseLines, oursLines);
|
|
1272
|
-
const theirsPatches = diffPatch(baseLines, theirsLines);
|
|
1273
|
-
if (oursPatches.length === 0) return theirsLines;
|
|
1274
|
-
if (theirsPatches.length === 0) return oursLines;
|
|
1275
|
-
if (patchesOverlap(oursPatches, theirsPatches)) {
|
|
1276
|
-
return null;
|
|
1277
|
-
}
|
|
1278
|
-
return applyBothPatches(baseLines, oursPatches, theirsPatches);
|
|
1279
|
-
}
|
|
1280
|
-
function patchesOverlap(oursPatches, theirsPatches) {
|
|
1281
|
-
for (const op of oursPatches) {
|
|
1282
|
-
const oStart = op.buffer1.offset;
|
|
1283
|
-
const oEnd = oStart + op.buffer1.length;
|
|
1284
|
-
for (const tp of theirsPatches) {
|
|
1285
|
-
const tStart = tp.buffer1.offset;
|
|
1286
|
-
const tEnd = tStart + tp.buffer1.length;
|
|
1287
|
-
if (op.buffer1.length === 0 && tp.buffer1.length === 0 && oStart === tStart) {
|
|
1288
|
-
return true;
|
|
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" };
|
|
1289
1290
|
}
|
|
1290
|
-
|
|
1291
|
-
|
|
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
|
+
}
|
|
1292
1309
|
}
|
|
1293
|
-
|
|
1294
|
-
|
|
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
|
+
}
|
|
1295
1328
|
}
|
|
1296
|
-
|
|
1297
|
-
|
|
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
|
+
}
|
|
1298
1350
|
}
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
replacement: p.buffer2.chunk
|
|
1314
|
-
}))
|
|
1315
|
-
];
|
|
1316
|
-
allPatches.sort((a, b) => b.offset - a.offset);
|
|
1317
|
-
const result = [...baseLines];
|
|
1318
|
-
for (const p of allPatches) {
|
|
1319
|
-
result.splice(p.offset, p.length, ...p.replacement);
|
|
1320
|
-
}
|
|
1321
|
-
return result;
|
|
1322
|
-
}
|
|
1323
|
-
|
|
1324
|
-
// src/ReplayApplicator.ts
|
|
1325
|
-
import { mkdir, mkdtemp, readFile, rm, unlink, writeFile } from "fs/promises";
|
|
1326
|
-
import { tmpdir } from "os";
|
|
1327
|
-
import { dirname as dirname2, extname, join as join2 } from "path";
|
|
1328
|
-
import { minimatch } from "minimatch";
|
|
1329
|
-
|
|
1330
|
-
// src/conflict-utils.ts
|
|
1331
|
-
var CONFLICT_OPENER = "<<<<<<< Generated";
|
|
1332
|
-
var CONFLICT_SEPARATOR = "=======";
|
|
1333
|
-
var CONFLICT_CLOSER = ">>>>>>> Your customization";
|
|
1334
|
-
function trimCR(line) {
|
|
1335
|
-
return line.endsWith("\r") ? line.slice(0, -1) : line;
|
|
1336
|
-
}
|
|
1337
|
-
function findConflictRanges(lines) {
|
|
1338
|
-
const ranges = [];
|
|
1339
|
-
let i = 0;
|
|
1340
|
-
while (i < lines.length) {
|
|
1341
|
-
if (trimCR(lines[i]) === CONFLICT_OPENER) {
|
|
1342
|
-
let separatorIdx = -1;
|
|
1343
|
-
let j = i + 1;
|
|
1344
|
-
let found = false;
|
|
1345
|
-
while (j < lines.length) {
|
|
1346
|
-
const trimmed = trimCR(lines[j]);
|
|
1347
|
-
if (trimmed === CONFLICT_OPENER) {
|
|
1348
|
-
break;
|
|
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
|
+
}
|
|
1349
1365
|
}
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1366
|
+
}
|
|
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;
|
|
1357
1387
|
}
|
|
1358
|
-
j++;
|
|
1359
1388
|
}
|
|
1360
|
-
if (
|
|
1361
|
-
|
|
1362
|
-
|
|
1389
|
+
if (theirs) {
|
|
1390
|
+
const theirsHasMarkers = theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization");
|
|
1391
|
+
const accBaseHasMarkers = accumulatorEntry != null && (accumulatorEntry.content.includes("<<<<<<< Generated") || accumulatorEntry.content.includes(">>>>>>> Your customization"));
|
|
1392
|
+
if (theirsHasMarkers && !accBaseHasMarkers && !(base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization")))) {
|
|
1393
|
+
return {
|
|
1394
|
+
file: resolvedPath,
|
|
1395
|
+
status: "skipped",
|
|
1396
|
+
reason: "stale-conflict-markers"
|
|
1397
|
+
};
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
let effective_theirs = theirs;
|
|
1401
|
+
let baseMismatchSkipped = false;
|
|
1402
|
+
if (theirs != null && base != null && !useAccumulatorAsMergeBase) {
|
|
1403
|
+
if (accumulatorEntry && accumulatorEntry.baseGeneration === patch.base_generation) {
|
|
1404
|
+
try {
|
|
1405
|
+
const preMerged = threeWayMerge(base, accumulatorEntry.content, theirs);
|
|
1406
|
+
if (!preMerged.hasConflicts) {
|
|
1407
|
+
effective_theirs = preMerged.content;
|
|
1408
|
+
} else {
|
|
1409
|
+
effective_theirs = theirs;
|
|
1410
|
+
}
|
|
1411
|
+
} catch {
|
|
1412
|
+
effective_theirs = theirs;
|
|
1413
|
+
}
|
|
1414
|
+
} else if (accumulatorEntry) {
|
|
1415
|
+
baseMismatchSkipped = true;
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
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" };
|
|
1363
1447
|
}
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
const lines = content.split(/\r?\n/);
|
|
1371
|
-
const ranges = findConflictRanges(lines);
|
|
1372
|
-
if (ranges.length === 0) {
|
|
1373
|
-
return content;
|
|
1374
|
-
}
|
|
1375
|
-
const result = [];
|
|
1376
|
-
let rangeIdx = 0;
|
|
1377
|
-
for (let i = 0; i < lines.length; i++) {
|
|
1378
|
-
if (rangeIdx < ranges.length) {
|
|
1379
|
-
const range = ranges[rangeIdx];
|
|
1380
|
-
if (i === range.start) {
|
|
1381
|
-
continue;
|
|
1448
|
+
if (effective_theirs == null) {
|
|
1449
|
+
return {
|
|
1450
|
+
file: resolvedPath,
|
|
1451
|
+
status: "skipped",
|
|
1452
|
+
reason: "missing-content"
|
|
1453
|
+
};
|
|
1382
1454
|
}
|
|
1383
|
-
if (
|
|
1384
|
-
|
|
1385
|
-
|
|
1455
|
+
if (base == null && !useAccumulatorAsMergeBase || ours == null) {
|
|
1456
|
+
return {
|
|
1457
|
+
file: resolvedPath,
|
|
1458
|
+
status: "skipped",
|
|
1459
|
+
reason: "missing-content"
|
|
1460
|
+
};
|
|
1386
1461
|
}
|
|
1387
|
-
|
|
1388
|
-
|
|
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
|
+
};
|
|
1389
1469
|
}
|
|
1390
|
-
|
|
1391
|
-
|
|
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
|
+
});
|
|
1392
1480
|
}
|
|
1393
|
-
if (
|
|
1394
|
-
|
|
1395
|
-
|
|
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
|
+
};
|
|
1396
1489
|
}
|
|
1397
|
-
}
|
|
1398
|
-
result.push(lines[i]);
|
|
1399
|
-
}
|
|
1400
|
-
return result.join("\n");
|
|
1401
|
-
}
|
|
1402
|
-
|
|
1403
|
-
// src/ReplayApplicator.ts
|
|
1404
|
-
var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
1405
|
-
".png",
|
|
1406
|
-
".jpg",
|
|
1407
|
-
".jpeg",
|
|
1408
|
-
".gif",
|
|
1409
|
-
".bmp",
|
|
1410
|
-
".ico",
|
|
1411
|
-
".webp",
|
|
1412
|
-
".svg",
|
|
1413
|
-
".pdf",
|
|
1414
|
-
".doc",
|
|
1415
|
-
".docx",
|
|
1416
|
-
".xls",
|
|
1417
|
-
".xlsx",
|
|
1418
|
-
".ppt",
|
|
1419
|
-
".pptx",
|
|
1420
|
-
".zip",
|
|
1421
|
-
".gz",
|
|
1422
|
-
".tar",
|
|
1423
|
-
".bz2",
|
|
1424
|
-
".7z",
|
|
1425
|
-
".rar",
|
|
1426
|
-
".jar",
|
|
1427
|
-
".war",
|
|
1428
|
-
".ear",
|
|
1429
|
-
".class",
|
|
1430
|
-
".exe",
|
|
1431
|
-
".dll",
|
|
1432
|
-
".so",
|
|
1433
|
-
".dylib",
|
|
1434
|
-
".o",
|
|
1435
|
-
".a",
|
|
1436
|
-
".woff",
|
|
1437
|
-
".woff2",
|
|
1438
|
-
".ttf",
|
|
1439
|
-
".eot",
|
|
1440
|
-
".otf",
|
|
1441
|
-
".mp3",
|
|
1442
|
-
".mp4",
|
|
1443
|
-
".avi",
|
|
1444
|
-
".mov",
|
|
1445
|
-
".wav",
|
|
1446
|
-
".flac",
|
|
1447
|
-
".sqlite",
|
|
1448
|
-
".db",
|
|
1449
|
-
".pyc",
|
|
1450
|
-
".pyo",
|
|
1451
|
-
".DS_Store"
|
|
1452
|
-
]);
|
|
1453
|
-
var ReplayApplicator = class {
|
|
1454
|
-
git;
|
|
1455
|
-
lockManager;
|
|
1456
|
-
outputDir;
|
|
1457
|
-
renameCache = /* @__PURE__ */ new Map();
|
|
1458
|
-
treeExistsCache = /* @__PURE__ */ new Map();
|
|
1459
|
-
fileTheirsAccumulator = /* @__PURE__ */ new Map();
|
|
1460
|
-
constructor(git, lockManager, outputDir) {
|
|
1461
|
-
this.git = git;
|
|
1462
|
-
this.lockManager = lockManager;
|
|
1463
|
-
this.outputDir = outputDir;
|
|
1464
|
-
}
|
|
1465
|
-
/**
|
|
1466
|
-
* Resolve the GenerationRecord for a patch's base_generation.
|
|
1467
|
-
* Falls back to constructing an ad-hoc record from the commit's tree
|
|
1468
|
-
* when base_generation isn't a tracked generation (commit-scan patches).
|
|
1469
|
-
*/
|
|
1470
|
-
async resolveBaseGeneration(baseGeneration) {
|
|
1471
|
-
const gen = this.lockManager.getGeneration(baseGeneration);
|
|
1472
|
-
if (gen) return gen;
|
|
1473
|
-
try {
|
|
1474
|
-
const treeHash = await this.git.getTreeHash(baseGeneration);
|
|
1475
1490
|
return {
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
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)}`
|
|
1481
1500
|
};
|
|
1482
|
-
} catch {
|
|
1483
|
-
return void 0;
|
|
1484
1501
|
}
|
|
1485
1502
|
}
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
* @internal Test-only.
|
|
1492
|
-
* Returns a defensive copy of the inter-patch accumulator. Used by the
|
|
1493
|
-
* adversarial test suite (FER-9791) to assert invariant I2: after every
|
|
1494
|
-
* applied patch, the accumulator has an entry for each file the patch
|
|
1495
|
-
* touched, and the entry's content matches on-disk.
|
|
1496
|
-
*/
|
|
1497
|
-
getAccumulatorSnapshot() {
|
|
1498
|
-
const snapshot = /* @__PURE__ */ new Map();
|
|
1499
|
-
for (const [path, entry] of this.fileTheirsAccumulator) {
|
|
1500
|
-
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);
|
|
1501
1508
|
}
|
|
1502
|
-
return
|
|
1509
|
+
return result;
|
|
1503
1510
|
}
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
const
|
|
1511
|
-
|
|
1512
|
-
const
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
status: "skipped",
|
|
1517
|
-
method: "git-am"
|
|
1518
|
-
});
|
|
1519
|
-
continue;
|
|
1520
|
-
}
|
|
1521
|
-
const result = await this.applyPatchWithFallback(patch);
|
|
1522
|
-
results.push(result);
|
|
1523
|
-
if (result.status === "conflict" && result.fileResults) {
|
|
1524
|
-
const laterFiles = /* @__PURE__ */ new Set();
|
|
1525
|
-
for (let j = i + 1; j < patches.length; j++) {
|
|
1526
|
-
for (const f of patches[j].files) {
|
|
1527
|
-
laterFiles.add(f);
|
|
1528
|
-
}
|
|
1529
|
-
}
|
|
1530
|
-
const resolvedToOriginal = /* @__PURE__ */ new Map();
|
|
1531
|
-
if (result.resolvedFiles) {
|
|
1532
|
-
for (const [orig, resolved] of Object.entries(result.resolvedFiles)) {
|
|
1533
|
-
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;
|
|
1534
1523
|
}
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
if (
|
|
1538
|
-
|
|
1539
|
-
if (laterFiles.has(fileResult.file) || laterFiles.has(originalPath)) {
|
|
1540
|
-
const filePath = join2(this.outputDir, fileResult.file);
|
|
1541
|
-
try {
|
|
1542
|
-
const content = await readFile(filePath, "utf-8");
|
|
1543
|
-
const stripped = stripConflictMarkers(content);
|
|
1544
|
-
await writeFile(filePath, stripped);
|
|
1545
|
-
} catch {
|
|
1546
|
-
}
|
|
1524
|
+
const fromBase = move.from.replace(/\*\*.*$/, "");
|
|
1525
|
+
const toBase = move.to.replace(/\*\*.*$/, "");
|
|
1526
|
+
if (filePath.startsWith(fromBase)) {
|
|
1527
|
+
return toBase + filePath.slice(fromBase.length);
|
|
1547
1528
|
}
|
|
1548
1529
|
}
|
|
1549
1530
|
}
|
|
1550
1531
|
}
|
|
1551
|
-
|
|
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
|
+
}
|
|
1552
1576
|
}
|
|
1553
|
-
/**
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1577
|
+
/**
|
|
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.
|
|
1583
|
+
*/
|
|
1584
|
+
async isPatchAlreadyApplied(patchContent, filePath, content, tempGit, tempDir) {
|
|
1585
|
+
const fileDiff = this.extractFileDiff(patchContent, filePath);
|
|
1586
|
+
if (!fileDiff) return false;
|
|
1562
1587
|
try {
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
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;
|
|
1607
|
+
}
|
|
1608
|
+
if (isDiffLineForFile(line, filePath)) {
|
|
1609
|
+
inTargetFile = true;
|
|
1610
|
+
diffLines.push(line);
|
|
1574
1611
|
}
|
|
1612
|
+
continue;
|
|
1613
|
+
}
|
|
1614
|
+
if (inTargetFile) {
|
|
1615
|
+
diffLines.push(line);
|
|
1575
1616
|
}
|
|
1576
|
-
} finally {
|
|
1577
|
-
await rm(tempDir, { recursive: true }).catch(() => {
|
|
1578
|
-
});
|
|
1579
1617
|
}
|
|
1618
|
+
return diffLines.length > 0 ? diffLines.join("\n") + "\n" : null;
|
|
1580
1619
|
}
|
|
1581
|
-
|
|
1582
|
-
const
|
|
1583
|
-
|
|
1584
|
-
const
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
for (const filePath of patch.files) {
|
|
1595
|
-
const resolvedPath = baseGen ? await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash) : filePath;
|
|
1596
|
-
if (resolvedPath !== filePath) {
|
|
1597
|
-
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);
|
|
1598
1633
|
}
|
|
1599
1634
|
}
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
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;
|
|
1607
1651
|
}
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
patch,
|
|
1613
|
-
status: "applied",
|
|
1614
|
-
method: "git-am",
|
|
1615
|
-
...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
|
|
1616
|
-
};
|
|
1617
|
-
} catch {
|
|
1618
|
-
for (const [filePath, content] of snapshots) {
|
|
1619
|
-
const fullPath = join2(this.outputDir, filePath);
|
|
1620
|
-
if (content != null) {
|
|
1621
|
-
await writeFile(fullPath, content);
|
|
1622
|
-
} else {
|
|
1623
|
-
await unlink(fullPath).catch(() => {
|
|
1624
|
-
});
|
|
1625
|
-
}
|
|
1652
|
+
if (!inTargetFile) continue;
|
|
1653
|
+
if (!inHunk) {
|
|
1654
|
+
if (line === "--- /dev/null" || line.startsWith("new file mode")) {
|
|
1655
|
+
isNewFile = true;
|
|
1626
1656
|
}
|
|
1627
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
|
+
}
|
|
1628
1671
|
}
|
|
1629
|
-
|
|
1672
|
+
if (addedLines.length === 0) return null;
|
|
1673
|
+
return addedLines.join("\n") + (noTrailingNewline ? "" : "\n");
|
|
1630
1674
|
}
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
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;
|
|
1655
1730
|
}
|
|
1656
|
-
}
|
|
1657
|
-
|
|
1658
|
-
|
|
1731
|
+
} else if (currentPath !== null) {
|
|
1732
|
+
if (line.startsWith("new file mode ")) {
|
|
1733
|
+
currentIsCreate = true;
|
|
1734
|
+
} else if (line.startsWith("deleted file mode ")) {
|
|
1735
|
+
currentIsDelete = true;
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
flush();
|
|
1740
|
+
return results;
|
|
1741
|
+
}
|
|
1742
|
+
var ReplayDetector = class {
|
|
1743
|
+
git;
|
|
1744
|
+
lockManager;
|
|
1745
|
+
sdkOutputDir;
|
|
1746
|
+
warnings = [];
|
|
1747
|
+
constructor(git, lockManager, sdkOutputDir) {
|
|
1748
|
+
this.git = git;
|
|
1749
|
+
this.lockManager = lockManager;
|
|
1750
|
+
this.sdkOutputDir = sdkOutputDir;
|
|
1751
|
+
}
|
|
1752
|
+
async detectNewPatches() {
|
|
1753
|
+
const lock = this.lockManager.read();
|
|
1754
|
+
const lastGen = this.getLastGeneration(lock);
|
|
1755
|
+
if (!lastGen) {
|
|
1756
|
+
return { patches: [], revertedPatchIds: [] };
|
|
1659
1757
|
}
|
|
1660
|
-
const
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
}
|
|
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);
|
|
1671
1774
|
}
|
|
1672
|
-
|
|
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 = "";
|
|
1673
1791
|
try {
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
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;
|
|
1677
1834
|
}
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
const currentTreeHash = currentGen?.tree_hash ?? baseGen.tree_hash;
|
|
1681
|
-
const resolvedPath = await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
|
|
1682
|
-
const metadata = {
|
|
1683
|
-
patchId: patch.id,
|
|
1684
|
-
patchMessage: patch.original_message,
|
|
1685
|
-
baseGeneration: patch.base_generation,
|
|
1686
|
-
currentGeneration: lock.current_generation
|
|
1687
|
-
};
|
|
1688
|
-
let base = await this.git.showFile(baseGen.tree_hash, filePath);
|
|
1689
|
-
let renameSourcePath;
|
|
1690
|
-
if (!base) {
|
|
1691
|
-
const renameSource = this.extractRenameSource(patch.patch_content, filePath);
|
|
1692
|
-
if (renameSource) {
|
|
1693
|
-
base = await this.git.showFile(baseGen.tree_hash, renameSource);
|
|
1694
|
-
renameSourcePath = renameSource;
|
|
1695
|
-
}
|
|
1835
|
+
if (lock.patches.find((p) => p.original_commit === commit.sha)) {
|
|
1836
|
+
continue;
|
|
1696
1837
|
}
|
|
1697
|
-
const
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
const result = reconstructFromGhostPatch2(fileDiff, ours);
|
|
1708
|
-
if (result) {
|
|
1709
|
-
base = result.base;
|
|
1710
|
-
theirs = result.theirs;
|
|
1711
|
-
ghostReconstructed = true;
|
|
1712
|
-
}
|
|
1713
|
-
}
|
|
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);
|
|
1714
1848
|
}
|
|
1849
|
+
continue;
|
|
1715
1850
|
}
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
tempDir,
|
|
1723
|
-
renameSourcePath
|
|
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.`
|
|
1724
1857
|
);
|
|
1858
|
+
continue;
|
|
1725
1859
|
}
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
if (theirsHasMarkers && !baseHasMarkers) {
|
|
1730
|
-
return {
|
|
1731
|
-
file: resolvedPath,
|
|
1732
|
-
status: "skipped",
|
|
1733
|
-
reason: "stale-conflict-markers"
|
|
1734
|
-
};
|
|
1735
|
-
}
|
|
1860
|
+
let contentHash = this.computeContentHash(patchContent);
|
|
1861
|
+
if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
|
|
1862
|
+
continue;
|
|
1736
1863
|
}
|
|
1737
|
-
|
|
1738
|
-
const
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
tempGit,
|
|
1745
|
-
tempDir
|
|
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.`
|
|
1746
1871
|
);
|
|
1747
|
-
if (
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
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;
|
|
1879
|
+
}
|
|
1880
|
+
if (!patchContent.trim()) continue;
|
|
1881
|
+
contentHash = this.computeContentHash(patchContent);
|
|
1882
|
+
}
|
|
1758
1883
|
}
|
|
1759
1884
|
}
|
|
1760
|
-
if (
|
|
1761
|
-
|
|
1762
|
-
const accBaseHasMarkers = accumulatorEntry != null && (accumulatorEntry.content.includes("<<<<<<< Generated") || accumulatorEntry.content.includes(">>>>>>> Your customization"));
|
|
1763
|
-
if (theirsHasMarkers && !accBaseHasMarkers && !(base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization")))) {
|
|
1764
|
-
return {
|
|
1765
|
-
file: resolvedPath,
|
|
1766
|
-
status: "skipped",
|
|
1767
|
-
reason: "stale-conflict-markers"
|
|
1768
|
-
};
|
|
1769
|
-
}
|
|
1885
|
+
if (files.length === 0) {
|
|
1886
|
+
continue;
|
|
1770
1887
|
}
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
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;
|
|
1893
|
+
}
|
|
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 {
|
|
1918
|
+
}
|
|
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;
|
|
1787
1928
|
}
|
|
1788
1929
|
}
|
|
1789
|
-
if (
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
await writeFile(oursPath, effective_theirs);
|
|
1797
|
-
return { file: resolvedPath, status: "merged", reason: "new-file" };
|
|
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
|
+
}
|
|
1798
1937
|
}
|
|
1799
|
-
if (
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
conflictReason: "new-file-both",
|
|
1810
|
-
conflictMetadata: metadata
|
|
1811
|
-
};
|
|
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;
|
|
1812
1948
|
}
|
|
1813
|
-
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
1814
|
-
content: merged2.content,
|
|
1815
|
-
baseGeneration: patch.base_generation
|
|
1816
|
-
});
|
|
1817
|
-
return { file: resolvedPath, status: "merged" };
|
|
1818
1949
|
}
|
|
1819
|
-
if (
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
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
|
+
}
|
|
1825
1958
|
}
|
|
1826
|
-
if (
|
|
1827
|
-
|
|
1828
|
-
file: resolvedPath,
|
|
1829
|
-
status: "skipped",
|
|
1830
|
-
reason: "missing-content"
|
|
1831
|
-
};
|
|
1959
|
+
if (!matchedExisting && !matchedNew) {
|
|
1960
|
+
revertIndicesToRemove.add(i);
|
|
1832
1961
|
}
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1962
|
+
}
|
|
1963
|
+
const filteredPatches = survivingPatches.filter((_, i) => !revertIndicesToRemove.has(i));
|
|
1964
|
+
return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
|
|
1965
|
+
}
|
|
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")}`;
|
|
1989
|
+
}
|
|
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);
|
|
1840
2030
|
}
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
content: effective_theirs,
|
|
1848
|
-
baseGeneration: patch.base_generation
|
|
1849
|
-
});
|
|
2031
|
+
}
|
|
2032
|
+
let anyCandidate = false;
|
|
2033
|
+
for (const state of stateByFile.values()) {
|
|
2034
|
+
if (state.created && state.deleted) {
|
|
2035
|
+
anyCandidate = true;
|
|
2036
|
+
break;
|
|
1850
2037
|
}
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
};
|
|
2038
|
+
}
|
|
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);
|
|
1859
2045
|
}
|
|
1860
|
-
return { file: resolvedPath, status: "merged" };
|
|
1861
|
-
} catch (error) {
|
|
1862
|
-
return {
|
|
1863
|
-
file: filePath,
|
|
1864
|
-
status: "skipped",
|
|
1865
|
-
reason: `error: ${error instanceof Error ? error.message : String(error)}`
|
|
1866
|
-
};
|
|
1867
2046
|
}
|
|
2047
|
+
if (transient.size === 0) return patches;
|
|
2048
|
+
return patches.filter((patch) => !patch.files.every((f) => transient.has(f)));
|
|
1868
2049
|
}
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
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() {
|
|
2057
|
+
try {
|
|
2058
|
+
const out = await this.git.exec(["ls-tree", "-r", "--name-only", "HEAD"]);
|
|
2059
|
+
return new Set(out.split("\n").map((l) => l.trim()).filter(Boolean));
|
|
2060
|
+
} catch {
|
|
2061
|
+
return /* @__PURE__ */ new Set();
|
|
1874
2062
|
}
|
|
1875
|
-
return result;
|
|
1876
|
-
}
|
|
1877
|
-
isExcluded(patch) {
|
|
1878
|
-
const config = this.lockManager.getCustomizationsConfig();
|
|
1879
|
-
if (!config.exclude) return false;
|
|
1880
|
-
return patch.files.some((file) => config.exclude.some((pattern) => minimatch(file, pattern)));
|
|
1881
2063
|
}
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
2064
|
+
/**
|
|
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.
|
|
2069
|
+
*/
|
|
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
|
+
);
|
|
1897
2086
|
}
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
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;
|
|
1903
2099
|
}
|
|
1904
|
-
const
|
|
1905
|
-
|
|
1906
|
-
|
|
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;
|
|
1907
2105
|
}
|
|
1908
|
-
return
|
|
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
|
+
};
|
|
1909
2118
|
}
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
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();
|
|
1913
2128
|
}
|
|
1914
|
-
const
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
return await readFile(tempTargetPath, "utf-8");
|
|
1931
|
-
}
|
|
1932
|
-
const tempFilePath = join2(tempDir, filePath);
|
|
1933
|
-
await mkdir(dirname2(tempFilePath), { recursive: true });
|
|
1934
|
-
await writeFile(tempFilePath, base);
|
|
1935
|
-
await tempGit.exec(["add", filePath]);
|
|
1936
|
-
await tempGit.exec(["commit", "-m", `base for ${filePath}`, "--allow-empty"]);
|
|
1937
|
-
await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
|
|
1938
|
-
return await readFile(tempFilePath, "utf-8");
|
|
1939
|
-
} catch {
|
|
1940
|
-
return null;
|
|
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: [] };
|
|
1941
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: [] };
|
|
1942
2168
|
}
|
|
1943
2169
|
/**
|
|
1944
|
-
*
|
|
1945
|
-
*
|
|
1946
|
-
*
|
|
1947
|
-
*
|
|
1948
|
-
*
|
|
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).
|
|
1949
2176
|
*/
|
|
1950
|
-
async
|
|
1951
|
-
const
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
return false;
|
|
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: [] };
|
|
1963
2189
|
}
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
const
|
|
1967
|
-
const
|
|
1968
|
-
|
|
1969
|
-
for (const
|
|
1970
|
-
if (
|
|
1971
|
-
if (inTargetFile) {
|
|
1972
|
-
break;
|
|
1973
|
-
}
|
|
1974
|
-
if (isDiffLineForFile(line, filePath)) {
|
|
1975
|
-
inTargetFile = true;
|
|
1976
|
-
diffLines.push(line);
|
|
1977
|
-
}
|
|
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)) {
|
|
1978
2197
|
continue;
|
|
1979
2198
|
}
|
|
1980
|
-
|
|
1981
|
-
|
|
2199
|
+
const parents = await this.git.getCommitParents(commit.sha);
|
|
2200
|
+
if (parents.length > 1) {
|
|
2201
|
+
continue;
|
|
1982
2202
|
}
|
|
1983
|
-
|
|
1984
|
-
return diffLines.length > 0 ? diffLines.join("\n") + "\n" : null;
|
|
1985
|
-
}
|
|
1986
|
-
extractRenameSource(patchContent, targetFilePath) {
|
|
1987
|
-
const lines = patchContent.split("\n");
|
|
1988
|
-
let inTargetFile = false;
|
|
1989
|
-
for (const line of lines) {
|
|
1990
|
-
if (line.startsWith("diff --git")) {
|
|
1991
|
-
if (inTargetFile) break;
|
|
1992
|
-
inTargetFile = isDiffLineForFile(line, targetFilePath);
|
|
2203
|
+
if (existingCommits.has(commit.sha)) {
|
|
1993
2204
|
continue;
|
|
1994
2205
|
}
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
2206
|
+
let patchContent;
|
|
2207
|
+
try {
|
|
2208
|
+
patchContent = await this.git.formatPatch(commit.sha);
|
|
2209
|
+
} catch {
|
|
2210
|
+
continue;
|
|
1999
2211
|
}
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
}
|
|
2003
|
-
extractNewFileFromPatch(patchContent, filePath) {
|
|
2004
|
-
const lines = patchContent.split("\n");
|
|
2005
|
-
const addedLines = [];
|
|
2006
|
-
let inTargetFile = false;
|
|
2007
|
-
let inHunk = false;
|
|
2008
|
-
let isNewFile = false;
|
|
2009
|
-
let noTrailingNewline = false;
|
|
2010
|
-
for (const line of lines) {
|
|
2011
|
-
if (line.startsWith("diff --git")) {
|
|
2012
|
-
if (inTargetFile) break;
|
|
2013
|
-
inTargetFile = isDiffLineForFile(line, filePath);
|
|
2014
|
-
inHunk = false;
|
|
2015
|
-
isNewFile = false;
|
|
2212
|
+
let contentHash = this.computeContentHash(patchContent);
|
|
2213
|
+
if (existingHashes.has(contentHash) || forgottenHashes.has(contentHash)) {
|
|
2016
2214
|
continue;
|
|
2017
2215
|
}
|
|
2018
|
-
if (
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
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);
|
|
2022
2238
|
}
|
|
2023
2239
|
}
|
|
2024
|
-
if (
|
|
2025
|
-
if (!isNewFile) return null;
|
|
2026
|
-
inHunk = true;
|
|
2240
|
+
if (files.length === 0) {
|
|
2027
2241
|
continue;
|
|
2028
2242
|
}
|
|
2029
|
-
if (
|
|
2030
|
-
if (line === "\") {
|
|
2031
|
-
noTrailingNewline = true;
|
|
2243
|
+
if (parents.length === 0) {
|
|
2032
2244
|
continue;
|
|
2033
2245
|
}
|
|
2034
|
-
|
|
2035
|
-
|
|
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;
|
|
2036
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
|
+
});
|
|
2264
|
+
}
|
|
2265
|
+
newPatches.reverse();
|
|
2266
|
+
return { patches: newPatches, revertedPatchIds: [] };
|
|
2267
|
+
}
|
|
2268
|
+
/**
|
|
2269
|
+
* Check if a format-patch consists entirely of new-file creations.
|
|
2270
|
+
* Used to identify squashed commits after force push, which create all files
|
|
2271
|
+
* from scratch (--- /dev/null) rather than modifying existing files.
|
|
2272
|
+
*/
|
|
2273
|
+
isCreationOnlyPatch(patchContent) {
|
|
2274
|
+
const diffOldHeaders = patchContent.split("\n").filter((l) => l.startsWith("--- "));
|
|
2275
|
+
if (diffOldHeaders.length === 0) {
|
|
2276
|
+
return false;
|
|
2037
2277
|
}
|
|
2038
|
-
|
|
2039
|
-
|
|
2278
|
+
return diffOldHeaders.every((l) => l === "--- /dev/null");
|
|
2279
|
+
}
|
|
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);
|
|
2040
2302
|
}
|
|
2041
2303
|
};
|
|
2042
|
-
function isBinaryFile(filePath) {
|
|
2043
|
-
const ext = extname(filePath).toLowerCase();
|
|
2044
|
-
return BINARY_EXTENSIONS.has(ext);
|
|
2045
|
-
}
|
|
2046
|
-
function isDiffLineForFile(diffLine, filePath) {
|
|
2047
|
-
const match = diffLine.match(/^diff --git a\/.+ b\/(.+)$/);
|
|
2048
|
-
return match !== null && match[1] === filePath;
|
|
2049
|
-
}
|
|
2050
2304
|
|
|
2051
2305
|
// src/ReplayCommitter.ts
|
|
2052
2306
|
var ReplayCommitter = class {
|
|
@@ -2078,13 +2332,46 @@ CLI Version: ${options.cliVersion}`;
|
|
|
2078
2332
|
await this.git.exec(["commit", "-m", fullMessage]);
|
|
2079
2333
|
return (await this.git.exec(["rev-parse", "HEAD"])).trim();
|
|
2080
2334
|
}
|
|
2081
|
-
async commitReplay(_patchCount, patches, message) {
|
|
2335
|
+
async commitReplay(_patchCount, patches, message, options) {
|
|
2082
2336
|
await this.stageAll();
|
|
2083
2337
|
if (!await this.hasStagedChanges()) {
|
|
2084
2338
|
return (await this.git.exec(["rev-parse", "HEAD"])).trim();
|
|
2085
2339
|
}
|
|
2086
2340
|
let fullMessage = message ?? `[fern-replay] Applied customizations`;
|
|
2087
|
-
|
|
2341
|
+
const buckets = options?.buckets;
|
|
2342
|
+
if (buckets) {
|
|
2343
|
+
if (buckets.applied.length > 0) {
|
|
2344
|
+
fullMessage += `
|
|
2345
|
+
|
|
2346
|
+
Patches applied (${buckets.applied.length}):`;
|
|
2347
|
+
for (const p of buckets.applied) {
|
|
2348
|
+
fullMessage += `
|
|
2349
|
+
- ${p.id}: ${p.original_message}`;
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
2352
|
+
if (buckets.unresolved.length > 0) {
|
|
2353
|
+
fullMessage += `
|
|
2354
|
+
|
|
2355
|
+
Patches with unresolved conflicts (${buckets.unresolved.length}):`;
|
|
2356
|
+
for (const p of buckets.unresolved) {
|
|
2357
|
+
fullMessage += `
|
|
2358
|
+
- ${p.id}: ${p.original_message}`;
|
|
2359
|
+
}
|
|
2360
|
+
fullMessage += `
|
|
2361
|
+
Run \`fern-replay resolve\` to apply these customizations.`;
|
|
2362
|
+
}
|
|
2363
|
+
if (buckets.absorbed.length > 0) {
|
|
2364
|
+
fullMessage += `
|
|
2365
|
+
|
|
2366
|
+
Patches absorbed by generator (${buckets.absorbed.length}):`;
|
|
2367
|
+
for (const p of buckets.absorbed) {
|
|
2368
|
+
fullMessage += `
|
|
2369
|
+
- ${p.id}: ${p.original_message}`;
|
|
2370
|
+
}
|
|
2371
|
+
fullMessage += `
|
|
2372
|
+
The generator now produces these customizations natively.`;
|
|
2373
|
+
}
|
|
2374
|
+
} else if (patches && patches.length > 0) {
|
|
2088
2375
|
fullMessage += "\n\nPatches replayed:";
|
|
2089
2376
|
for (const patch of patches) {
|
|
2090
2377
|
fullMessage += `
|
|
@@ -2120,9 +2407,304 @@ CLI Version: ${options.cliVersion}`;
|
|
|
2120
2407
|
|
|
2121
2408
|
// src/ReplayService.ts
|
|
2122
2409
|
import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
2123
|
-
import {
|
|
2410
|
+
import { readFile as readFileAsync } from "fs/promises";
|
|
2411
|
+
import { join as join5 } from "path";
|
|
2124
2412
|
import { minimatch as minimatch2 } from "minimatch";
|
|
2125
2413
|
init_GitClient();
|
|
2414
|
+
|
|
2415
|
+
// src/PatchRegionDiff.ts
|
|
2416
|
+
init_HybridReconstruction();
|
|
2417
|
+
import { mkdtemp as mkdtemp2, writeFile as writeFile2, rm as rm2 } from "fs/promises";
|
|
2418
|
+
import { tmpdir as tmpdir2 } from "os";
|
|
2419
|
+
import { join as join3 } from "path";
|
|
2420
|
+
import { spawn } from "child_process";
|
|
2421
|
+
function normalizeHunkBody(hunk) {
|
|
2422
|
+
let contextC = 0;
|
|
2423
|
+
let removeC = 0;
|
|
2424
|
+
let addC = 0;
|
|
2425
|
+
const trimmed = [];
|
|
2426
|
+
for (const line of hunk.lines) {
|
|
2427
|
+
const oldUsed = contextC + removeC;
|
|
2428
|
+
const newUsed = contextC + addC;
|
|
2429
|
+
if (oldUsed >= hunk.oldCount && newUsed >= hunk.newCount) break;
|
|
2430
|
+
trimmed.push(line);
|
|
2431
|
+
if (line.type === "context") contextC++;
|
|
2432
|
+
else if (line.type === "remove") removeC++;
|
|
2433
|
+
else if (line.type === "add") addC++;
|
|
2434
|
+
}
|
|
2435
|
+
return { ...hunk, lines: trimmed };
|
|
2436
|
+
}
|
|
2437
|
+
function extractFileDiffForFile(patchContent, filePath) {
|
|
2438
|
+
const lines = patchContent.split("\n");
|
|
2439
|
+
const out = [];
|
|
2440
|
+
let inTarget = false;
|
|
2441
|
+
for (const line of lines) {
|
|
2442
|
+
if (line.startsWith("diff --git")) {
|
|
2443
|
+
if (inTarget) break;
|
|
2444
|
+
if (isDiffLineForFile2(line, filePath)) {
|
|
2445
|
+
inTarget = true;
|
|
2446
|
+
out.push(line);
|
|
2447
|
+
}
|
|
2448
|
+
continue;
|
|
2449
|
+
}
|
|
2450
|
+
if (inTarget) out.push(line);
|
|
2451
|
+
}
|
|
2452
|
+
return out.length > 0 ? out.join("\n") + "\n" : null;
|
|
2453
|
+
}
|
|
2454
|
+
function isDiffLineForFile2(line, filePath) {
|
|
2455
|
+
const match = line.match(/^diff --git a\/(.+) b\/(.+)$/);
|
|
2456
|
+
return match !== null && (match[2] === filePath || match[1] === filePath);
|
|
2457
|
+
}
|
|
2458
|
+
async function computePerPatchDiff(patch, getCurrentGenContent, getWorkingTreeContent) {
|
|
2459
|
+
const fragments = [];
|
|
2460
|
+
const unlocatableFiles = [];
|
|
2461
|
+
for (const file of patch.files) {
|
|
2462
|
+
const fileDiff = extractFileDiffForFile(patch.patch_content, file);
|
|
2463
|
+
if (fileDiff == null) {
|
|
2464
|
+
unlocatableFiles.push(file);
|
|
2465
|
+
continue;
|
|
2466
|
+
}
|
|
2467
|
+
const hunksRaw = parseHunks(fileDiff).map(normalizeHunkBody);
|
|
2468
|
+
const hunks = hunksRaw.filter((h) => {
|
|
2469
|
+
if (h.lines.length === 0) return false;
|
|
2470
|
+
let ctx = 0, rm4 = 0, add = 0;
|
|
2471
|
+
for (const ln of h.lines) {
|
|
2472
|
+
if (ln.type === "context") ctx++;
|
|
2473
|
+
else if (ln.type === "remove") rm4++;
|
|
2474
|
+
else if (ln.type === "add") add++;
|
|
2475
|
+
}
|
|
2476
|
+
return ctx + rm4 === h.oldCount && ctx + add === h.newCount;
|
|
2477
|
+
});
|
|
2478
|
+
if (hunks.length === 0) {
|
|
2479
|
+
if (hunksRaw.length > 0) unlocatableFiles.push(file);
|
|
2480
|
+
continue;
|
|
2481
|
+
}
|
|
2482
|
+
const currentGen = await getCurrentGenContent(file);
|
|
2483
|
+
const workingTree = await getWorkingTreeContent(file);
|
|
2484
|
+
const isPureNewFile = hunks.every((h) => h.oldCount === 0);
|
|
2485
|
+
const isPureDelete = hunks.every((h) => h.newCount === 0);
|
|
2486
|
+
if (currentGen == null) {
|
|
2487
|
+
if (!isPureNewFile) {
|
|
2488
|
+
unlocatableFiles.push(file);
|
|
2489
|
+
continue;
|
|
2490
|
+
}
|
|
2491
|
+
if (workingTree == null) continue;
|
|
2492
|
+
fragments.push(synthesizeNewFileDiff(file, workingTree));
|
|
2493
|
+
continue;
|
|
2494
|
+
}
|
|
2495
|
+
if (isPureNewFile) {
|
|
2496
|
+
if (workingTree == null) {
|
|
2497
|
+
fragments.push(synthesizeDeleteFileDiff(file, currentGen));
|
|
2498
|
+
continue;
|
|
2499
|
+
}
|
|
2500
|
+
if (workingTree === currentGen) {
|
|
2501
|
+
continue;
|
|
2502
|
+
}
|
|
2503
|
+
const fullDiff = await unifiedDiff(currentGen, workingTree, file);
|
|
2504
|
+
if (fullDiff && fullDiff.trim()) fragments.push(fullDiff);
|
|
2505
|
+
continue;
|
|
2506
|
+
}
|
|
2507
|
+
if (workingTree == null) {
|
|
2508
|
+
if (!isPureDelete) {
|
|
2509
|
+
unlocatableFiles.push(file);
|
|
2510
|
+
continue;
|
|
2511
|
+
}
|
|
2512
|
+
fragments.push(synthesizeDeleteFileDiff(file, currentGen));
|
|
2513
|
+
continue;
|
|
2514
|
+
}
|
|
2515
|
+
const currentGenLines = splitLines(currentGen);
|
|
2516
|
+
const workingTreeLines = splitLines(workingTree);
|
|
2517
|
+
const locInCurrentGenRaw = locateHunksInOurs(hunks, currentGenLines);
|
|
2518
|
+
const locInWorkingTreeRaw = locateHunksInOurs(hunks, workingTreeLines);
|
|
2519
|
+
if (locInCurrentGenRaw == null || locInWorkingTreeRaw == null) {
|
|
2520
|
+
unlocatableFiles.push(file);
|
|
2521
|
+
continue;
|
|
2522
|
+
}
|
|
2523
|
+
const locInCurrentGen = locInCurrentGenRaw.map(
|
|
2524
|
+
(l) => resolveSpan(l, currentGenLines, "old")
|
|
2525
|
+
);
|
|
2526
|
+
const locInWorkingTree = locInWorkingTreeRaw.map(
|
|
2527
|
+
(l) => resolveSpan(l, workingTreeLines, "new")
|
|
2528
|
+
);
|
|
2529
|
+
const overlay = overlayRegions(
|
|
2530
|
+
currentGenLines,
|
|
2531
|
+
locInCurrentGen,
|
|
2532
|
+
workingTreeLines,
|
|
2533
|
+
locInWorkingTree
|
|
2534
|
+
);
|
|
2535
|
+
if (overlay === null) {
|
|
2536
|
+
unlocatableFiles.push(file);
|
|
2537
|
+
continue;
|
|
2538
|
+
}
|
|
2539
|
+
if (overlay === currentGen) {
|
|
2540
|
+
continue;
|
|
2541
|
+
}
|
|
2542
|
+
const fileUnifiedDiff = await unifiedDiff(currentGen, overlay, file);
|
|
2543
|
+
if (fileUnifiedDiff && fileUnifiedDiff.trim()) {
|
|
2544
|
+
fragments.push(fileUnifiedDiff);
|
|
2545
|
+
}
|
|
2546
|
+
}
|
|
2547
|
+
return {
|
|
2548
|
+
diff: fragments.join(""),
|
|
2549
|
+
unlocatableFiles
|
|
2550
|
+
};
|
|
2551
|
+
}
|
|
2552
|
+
async function computePerPatchDiffWithFallback(patch, getCurrentGenContent, getWorkingTreeContent, runCumulativeDiff) {
|
|
2553
|
+
const ppDiff = await computePerPatchDiff(
|
|
2554
|
+
patch,
|
|
2555
|
+
getCurrentGenContent,
|
|
2556
|
+
getWorkingTreeContent
|
|
2557
|
+
);
|
|
2558
|
+
if (ppDiff.unlocatableFiles.length === 0) {
|
|
2559
|
+
return { diff: ppDiff.diff, hadFallback: false, fallbackFiles: [] };
|
|
2560
|
+
}
|
|
2561
|
+
const cumulative = await runCumulativeDiff(ppDiff.unlocatableFiles);
|
|
2562
|
+
if (cumulative === null) {
|
|
2563
|
+
return null;
|
|
2564
|
+
}
|
|
2565
|
+
const merged = ppDiff.diff + (cumulative.trim() ? cumulative : "");
|
|
2566
|
+
return { diff: merged, hadFallback: true, fallbackFiles: ppDiff.unlocatableFiles };
|
|
2567
|
+
}
|
|
2568
|
+
function splitLines(content) {
|
|
2569
|
+
return content.split("\n");
|
|
2570
|
+
}
|
|
2571
|
+
function resolveSpan(loc, oursLines, prefer) {
|
|
2572
|
+
const { hunk, oursOffset } = loc;
|
|
2573
|
+
const oldSide = [];
|
|
2574
|
+
const newSide = [];
|
|
2575
|
+
for (const line of hunk.lines) {
|
|
2576
|
+
if (line.type === "context") {
|
|
2577
|
+
oldSide.push(line.content);
|
|
2578
|
+
newSide.push(line.content);
|
|
2579
|
+
} else if (line.type === "remove") {
|
|
2580
|
+
oldSide.push(line.content);
|
|
2581
|
+
} else if (line.type === "add") {
|
|
2582
|
+
newSide.push(line.content);
|
|
2583
|
+
}
|
|
2584
|
+
}
|
|
2585
|
+
const oldFits = sequenceMatches(oldSide, oursLines, oursOffset);
|
|
2586
|
+
const newFits = sequenceMatches(newSide, oursLines, oursOffset);
|
|
2587
|
+
if (prefer === "old") {
|
|
2588
|
+
if (oldFits) return { ...loc, oursSpan: hunk.oldCount };
|
|
2589
|
+
if (newFits) return { ...loc, oursSpan: hunk.newCount };
|
|
2590
|
+
} else {
|
|
2591
|
+
if (newFits) return { ...loc, oursSpan: hunk.newCount };
|
|
2592
|
+
if (oldFits) return { ...loc, oursSpan: hunk.oldCount };
|
|
2593
|
+
}
|
|
2594
|
+
return loc;
|
|
2595
|
+
}
|
|
2596
|
+
function sequenceMatches(needle, haystack, offset) {
|
|
2597
|
+
if (offset + needle.length > haystack.length) return false;
|
|
2598
|
+
for (let i = 0; i < needle.length; i++) {
|
|
2599
|
+
if (haystack[offset + i] !== needle[i]) return false;
|
|
2600
|
+
}
|
|
2601
|
+
return true;
|
|
2602
|
+
}
|
|
2603
|
+
function overlayRegions(currentGenLines, locInCurrentGen, workingTreeLines, locInWorkingTree) {
|
|
2604
|
+
if (locInCurrentGen.length !== locInWorkingTree.length) {
|
|
2605
|
+
return null;
|
|
2606
|
+
}
|
|
2607
|
+
const out = [];
|
|
2608
|
+
let cursor = 0;
|
|
2609
|
+
for (let i = 0; i < locInCurrentGen.length; i++) {
|
|
2610
|
+
const cg = locInCurrentGen[i];
|
|
2611
|
+
const wt = locInWorkingTree[i];
|
|
2612
|
+
if (cg.oursOffset > cursor) {
|
|
2613
|
+
out.push(...currentGenLines.slice(cursor, cg.oursOffset));
|
|
2614
|
+
}
|
|
2615
|
+
out.push(...workingTreeLines.slice(wt.oursOffset, wt.oursOffset + wt.oursSpan));
|
|
2616
|
+
cursor = cg.oursOffset + cg.oursSpan;
|
|
2617
|
+
}
|
|
2618
|
+
if (cursor < currentGenLines.length) {
|
|
2619
|
+
out.push(...currentGenLines.slice(cursor));
|
|
2620
|
+
}
|
|
2621
|
+
return out.join("\n");
|
|
2622
|
+
}
|
|
2623
|
+
async function unifiedDiff(beforeContent, afterContent, filePath) {
|
|
2624
|
+
if (beforeContent === afterContent) return "";
|
|
2625
|
+
const tmp = await mkdtemp2(join3(tmpdir2(), "fern-replay-pp-"));
|
|
2626
|
+
try {
|
|
2627
|
+
const beforePath = join3(tmp, "before");
|
|
2628
|
+
const afterPath = join3(tmp, "after");
|
|
2629
|
+
await writeFile2(beforePath, beforeContent, "utf-8");
|
|
2630
|
+
await writeFile2(afterPath, afterContent, "utf-8");
|
|
2631
|
+
const raw = await runGitDiffNoIndex(beforePath, afterPath);
|
|
2632
|
+
if (!raw.trim()) return "";
|
|
2633
|
+
return rewriteDiffHeaders(raw, filePath);
|
|
2634
|
+
} finally {
|
|
2635
|
+
await rm2(tmp, { recursive: true, force: true });
|
|
2636
|
+
}
|
|
2637
|
+
}
|
|
2638
|
+
function runGitDiffNoIndex(beforePath, afterPath) {
|
|
2639
|
+
return new Promise((resolve2, reject) => {
|
|
2640
|
+
const proc = spawn("git", ["diff", "--no-index", "--", beforePath, afterPath]);
|
|
2641
|
+
let stdout = "";
|
|
2642
|
+
let stderr = "";
|
|
2643
|
+
proc.stdout.on("data", (d) => stdout += d.toString());
|
|
2644
|
+
proc.stderr.on("data", (d) => stderr += d.toString());
|
|
2645
|
+
proc.on("close", (code) => {
|
|
2646
|
+
if (code === 0 || code === 1) resolve2(stdout);
|
|
2647
|
+
else reject(new Error(`git diff --no-index failed (code ${code}): ${stderr}`));
|
|
2648
|
+
});
|
|
2649
|
+
});
|
|
2650
|
+
}
|
|
2651
|
+
function rewriteDiffHeaders(raw, filePath) {
|
|
2652
|
+
const lines = raw.split("\n");
|
|
2653
|
+
const out = [];
|
|
2654
|
+
let seenFirstHeader = false;
|
|
2655
|
+
for (const line of lines) {
|
|
2656
|
+
if (line.startsWith("diff --git ")) {
|
|
2657
|
+
out.push(`diff --git a/${filePath} b/${filePath}`);
|
|
2658
|
+
seenFirstHeader = true;
|
|
2659
|
+
continue;
|
|
2660
|
+
}
|
|
2661
|
+
if (!seenFirstHeader) {
|
|
2662
|
+
continue;
|
|
2663
|
+
}
|
|
2664
|
+
if (line.startsWith("--- ")) {
|
|
2665
|
+
out.push(`--- a/${filePath}`);
|
|
2666
|
+
continue;
|
|
2667
|
+
}
|
|
2668
|
+
if (line.startsWith("+++ ")) {
|
|
2669
|
+
out.push(`+++ b/${filePath}`);
|
|
2670
|
+
continue;
|
|
2671
|
+
}
|
|
2672
|
+
out.push(line);
|
|
2673
|
+
}
|
|
2674
|
+
return out.join("\n");
|
|
2675
|
+
}
|
|
2676
|
+
function synthesizeNewFileDiff(file, content) {
|
|
2677
|
+
const lines = content.split("\n");
|
|
2678
|
+
const hasTrailingNewline = content.endsWith("\n");
|
|
2679
|
+
const bodyLines = hasTrailingNewline ? lines.slice(0, -1) : lines;
|
|
2680
|
+
const header = [
|
|
2681
|
+
`diff --git a/${file} b/${file}`,
|
|
2682
|
+
"new file mode 100644",
|
|
2683
|
+
"--- /dev/null",
|
|
2684
|
+
`+++ b/${file}`,
|
|
2685
|
+
`@@ -0,0 +1,${bodyLines.length} @@`
|
|
2686
|
+
].join("\n");
|
|
2687
|
+
const body = bodyLines.map((l) => `+${l}`).join("\n");
|
|
2688
|
+
const trailer = hasTrailingNewline ? "" : "\n\";
|
|
2689
|
+
return header + "\n" + body + trailer + "\n";
|
|
2690
|
+
}
|
|
2691
|
+
function synthesizeDeleteFileDiff(file, content) {
|
|
2692
|
+
const lines = content.split("\n");
|
|
2693
|
+
const hasTrailingNewline = content.endsWith("\n");
|
|
2694
|
+
const bodyLines = hasTrailingNewline ? lines.slice(0, -1) : lines;
|
|
2695
|
+
const header = [
|
|
2696
|
+
`diff --git a/${file} b/${file}`,
|
|
2697
|
+
"deleted file mode 100644",
|
|
2698
|
+
`--- a/${file}`,
|
|
2699
|
+
"+++ /dev/null",
|
|
2700
|
+
`@@ -1,${bodyLines.length} +0,0 @@`
|
|
2701
|
+
].join("\n");
|
|
2702
|
+
const body = bodyLines.map((l) => `-${l}`).join("\n");
|
|
2703
|
+
const trailer = hasTrailingNewline ? "" : "\n\";
|
|
2704
|
+
return header + "\n" + body + trailer + "\n";
|
|
2705
|
+
}
|
|
2706
|
+
|
|
2707
|
+
// src/ReplayService.ts
|
|
2126
2708
|
var ReplayService = class {
|
|
2127
2709
|
git;
|
|
2128
2710
|
detector;
|
|
@@ -2516,6 +3098,7 @@ var ReplayService = class {
|
|
|
2516
3098
|
if (newPatches.length > 0) {
|
|
2517
3099
|
results = await this.applicator.applyPatches(newPatches);
|
|
2518
3100
|
this._lastApplyResults = results;
|
|
3101
|
+
this.recordDroppedContextLineWarnings(results);
|
|
2519
3102
|
this.revertConflictingFiles(results);
|
|
2520
3103
|
for (const result of results) {
|
|
2521
3104
|
if (result.status === "conflict") {
|
|
@@ -2534,7 +3117,8 @@ var ReplayService = class {
|
|
|
2534
3117
|
if (newPatches.length > 0) {
|
|
2535
3118
|
if (!options?.stageOnly) {
|
|
2536
3119
|
const appliedCount = results.filter((r) => r.status === "applied").length;
|
|
2537
|
-
|
|
3120
|
+
const buckets = computeCommitBuckets(results, rebaseCounts.absorbedPatchIds, newPatches);
|
|
3121
|
+
await this.committer.commitReplay(appliedCount, newPatches, void 0, { buckets });
|
|
2538
3122
|
} else {
|
|
2539
3123
|
await this.committer.stageAll();
|
|
2540
3124
|
}
|
|
@@ -2597,12 +3181,16 @@ var ReplayService = class {
|
|
|
2597
3181
|
(p) => preRebasePatchIds.has(p.id) && !postRebasePatchIds.has(p.id)
|
|
2598
3182
|
);
|
|
2599
3183
|
existingPatches = this.lockManager.getPatches();
|
|
2600
|
-
const seenHashes = /* @__PURE__ */ new
|
|
3184
|
+
const seenHashes = /* @__PURE__ */ new Map();
|
|
2601
3185
|
for (const p of existingPatches) {
|
|
2602
|
-
|
|
3186
|
+
const priorPatchId = seenHashes.get(p.content_hash);
|
|
3187
|
+
if (priorPatchId !== void 0) {
|
|
2603
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
|
+
);
|
|
2604
3192
|
} else {
|
|
2605
|
-
seenHashes.
|
|
3193
|
+
seenHashes.set(p.content_hash, p.id);
|
|
2606
3194
|
}
|
|
2607
3195
|
}
|
|
2608
3196
|
existingPatches = this.lockManager.getPatches();
|
|
@@ -2663,6 +3251,7 @@ var ReplayService = class {
|
|
|
2663
3251
|
const genSha = prep._prepared.genSha;
|
|
2664
3252
|
const results = await this.applicator.applyPatches(allPatches);
|
|
2665
3253
|
this._lastApplyResults = results;
|
|
3254
|
+
this.recordDroppedContextLineWarnings(results);
|
|
2666
3255
|
this.revertConflictingFiles(results);
|
|
2667
3256
|
for (const result of results) {
|
|
2668
3257
|
if (result.status === "conflict") {
|
|
@@ -2690,7 +3279,8 @@ var ReplayService = class {
|
|
|
2690
3279
|
await this.committer.stageAll();
|
|
2691
3280
|
} else {
|
|
2692
3281
|
const appliedCount = results.filter((r) => r.status === "applied").length;
|
|
2693
|
-
|
|
3282
|
+
const buckets = computeCommitBuckets(results, rebaseCounts.absorbedPatchIds, allPatches);
|
|
3283
|
+
await this.committer.commitReplay(appliedCount, allPatches, void 0, { buckets });
|
|
2694
3284
|
}
|
|
2695
3285
|
const warnings = [...detectorWarnings, ...this.warnings];
|
|
2696
3286
|
return this.buildReport(
|
|
@@ -2714,7 +3304,7 @@ var ReplayService = class {
|
|
|
2714
3304
|
let repointed = 0;
|
|
2715
3305
|
let contentRebased = 0;
|
|
2716
3306
|
let keptAsUserOwned = 0;
|
|
2717
|
-
const seenContentHashes = /* @__PURE__ */ new
|
|
3307
|
+
const seenContentHashes = /* @__PURE__ */ new Map();
|
|
2718
3308
|
const absorbedPatchIds = /* @__PURE__ */ new Set();
|
|
2719
3309
|
const fernignorePatterns = this.readFernignorePatterns();
|
|
2720
3310
|
for (const result of results) {
|
|
@@ -2728,6 +3318,23 @@ var ReplayService = class {
|
|
|
2728
3318
|
}
|
|
2729
3319
|
}
|
|
2730
3320
|
}
|
|
3321
|
+
const conflictedFilePaths = /* @__PURE__ */ new Set();
|
|
3322
|
+
for (const r of results) {
|
|
3323
|
+
if (r.status !== "conflict") continue;
|
|
3324
|
+
if (r.fileResults) {
|
|
3325
|
+
for (const fr of r.fileResults) {
|
|
3326
|
+
if (fr.status !== "conflict") continue;
|
|
3327
|
+
conflictedFilePaths.add(fr.file);
|
|
3328
|
+
if (r.resolvedFiles) {
|
|
3329
|
+
for (const [orig, resolved] of Object.entries(r.resolvedFiles)) {
|
|
3330
|
+
if (resolved === fr.file) conflictedFilePaths.add(orig);
|
|
3331
|
+
}
|
|
3332
|
+
}
|
|
3333
|
+
}
|
|
3334
|
+
} else {
|
|
3335
|
+
for (const f of r.patch.files) conflictedFilePaths.add(f);
|
|
3336
|
+
}
|
|
3337
|
+
}
|
|
2731
3338
|
for (const result of results) {
|
|
2732
3339
|
if (result.status === "conflict" && result.fileResults) {
|
|
2733
3340
|
await this.trimAbsorbedFiles(result, currentGenSha);
|
|
@@ -2764,17 +3371,27 @@ var ReplayService = class {
|
|
|
2764
3371
|
if (allUserOwned && patch.files.length > 0) {
|
|
2765
3372
|
const baseChanged = patch.base_generation !== currentGenSha;
|
|
2766
3373
|
const userDiff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
|
|
3374
|
+
let userDiffSnapshot = null;
|
|
2767
3375
|
if (userDiff != null && userDiff.trim()) {
|
|
2768
3376
|
const newHash = this.detector.computeContentHash(userDiff);
|
|
2769
3377
|
patch.patch_content = userDiff;
|
|
2770
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
|
+
}
|
|
2771
3384
|
}
|
|
2772
3385
|
patch.base_generation = currentGenSha;
|
|
2773
3386
|
try {
|
|
2774
3387
|
this.lockManager.updatePatch(patch.id, {
|
|
2775
3388
|
base_generation: currentGenSha,
|
|
2776
3389
|
...!patch.user_owned ? { user_owned: true } : {},
|
|
2777
|
-
...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
|
+
} : {}
|
|
2778
3395
|
});
|
|
2779
3396
|
} catch {
|
|
2780
3397
|
}
|
|
@@ -2795,6 +3412,18 @@ var ReplayService = class {
|
|
|
2795
3412
|
keptAsUserOwned++;
|
|
2796
3413
|
continue;
|
|
2797
3414
|
}
|
|
3415
|
+
const overlapsConflicted = patch.files.some((f) => conflictedFilePaths.has(f));
|
|
3416
|
+
if (overlapsConflicted) {
|
|
3417
|
+
patch.status = "unresolved";
|
|
3418
|
+
try {
|
|
3419
|
+
this.lockManager.updatePatch(patch.id, { status: "unresolved" });
|
|
3420
|
+
} catch {
|
|
3421
|
+
}
|
|
3422
|
+
this.warnings.push(
|
|
3423
|
+
`Patch ${patch.id} (${patch.original_message}) was preserved as unresolved because an earlier patch conflicted on the same file(s) (${patch.files.join(", ")}). Run \`fern replay resolve\` to apply this customization manually.`
|
|
3424
|
+
);
|
|
3425
|
+
continue;
|
|
3426
|
+
}
|
|
2798
3427
|
this.lockManager.removePatch(patch.id);
|
|
2799
3428
|
absorbedPatchIds.add(patch.id);
|
|
2800
3429
|
absorbed++;
|
|
@@ -2810,21 +3439,34 @@ var ReplayService = class {
|
|
|
2810
3439
|
continue;
|
|
2811
3440
|
}
|
|
2812
3441
|
const newContentHash = this.detector.computeContentHash(diff);
|
|
2813
|
-
|
|
3442
|
+
const priorPatchId = seenContentHashes.get(newContentHash);
|
|
3443
|
+
if (priorPatchId !== void 0) {
|
|
2814
3444
|
this.lockManager.removePatch(patch.id);
|
|
2815
3445
|
absorbedPatchIds.add(patch.id);
|
|
2816
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
|
+
);
|
|
3451
|
+
}
|
|
2817
3452
|
continue;
|
|
3453
|
+
} else {
|
|
3454
|
+
seenContentHashes.set(newContentHash, patch.id);
|
|
2818
3455
|
}
|
|
2819
|
-
seenContentHashes.add(newContentHash);
|
|
2820
3456
|
patch.base_generation = currentGenSha;
|
|
2821
3457
|
patch.patch_content = diff;
|
|
2822
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
|
+
}
|
|
2823
3464
|
try {
|
|
2824
3465
|
this.lockManager.updatePatch(patch.id, {
|
|
2825
3466
|
base_generation: currentGenSha,
|
|
2826
3467
|
patch_content: diff,
|
|
2827
|
-
content_hash: newContentHash
|
|
3468
|
+
content_hash: newContentHash,
|
|
3469
|
+
...snapshotIsNonEmpty ? { theirs_snapshot: snapshot } : {}
|
|
2828
3470
|
});
|
|
2829
3471
|
} catch {
|
|
2830
3472
|
}
|
|
@@ -2834,6 +3476,112 @@ var ReplayService = class {
|
|
|
2834
3476
|
}
|
|
2835
3477
|
return { absorbed, repointed, contentRebased, keptAsUserOwned, absorbedPatchIds };
|
|
2836
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
|
+
* Read THEIRS snapshot from a git tree-ish (commit, branch, or tree).
|
|
3501
|
+
* Used when the diff that produced `patch_content` was relative to that
|
|
3502
|
+
* tree (e.g., `git diff currentGen HEAD --` → snapshot from HEAD).
|
|
3503
|
+
* Skips binary files.
|
|
3504
|
+
*/
|
|
3505
|
+
async readSnapshotFromTree(treeRef, files) {
|
|
3506
|
+
const snapshot = {};
|
|
3507
|
+
for (const file of files) {
|
|
3508
|
+
if (isBinaryFile(file)) continue;
|
|
3509
|
+
const content = await this.git.showFile(treeRef, file).catch(() => null);
|
|
3510
|
+
if (content != null) snapshot[file] = content;
|
|
3511
|
+
}
|
|
3512
|
+
return snapshot;
|
|
3513
|
+
}
|
|
3514
|
+
/**
|
|
3515
|
+
* One-shot auto-migration: backfill `theirs_snapshot` on existing
|
|
3516
|
+
* patches that predate the snapshot encoding. Computes the snapshot by
|
|
3517
|
+
* applying the patch's `patch_content` to its `base_generation` tree —
|
|
3518
|
+
* yielding the patch's INDIVIDUAL contribution.
|
|
3519
|
+
*
|
|
3520
|
+
* **Skips patches sharing files with other patches.** HEAD's content
|
|
3521
|
+
* for a file shared by N patches is cumulative across all of them, so
|
|
3522
|
+
* a HEAD-fallback would falsely encode other patches' contributions
|
|
3523
|
+
* into one patch's snapshot — breaking FER-9525 cross-patch isolation
|
|
3524
|
+
* if the snapshot fallback later fires. Per-patch snapshots only
|
|
3525
|
+
* make sense when the patch owns its file. Multi-patch-same-file
|
|
3526
|
+
* legacy lockfiles keep using line-anchored reconstruction (the
|
|
3527
|
+
* existing path); they're no worse than pre-upgrade.
|
|
3528
|
+
*
|
|
3529
|
+
* **Does not fall back to HEAD when BASE-reconstruction fails.** Same
|
|
3530
|
+
* reason — HEAD content can be cumulative. Patches whose
|
|
3531
|
+
* reconstruction fails just don't get a snapshot; legacy paths
|
|
3532
|
+
* (PR #73's by-association gate, accumulator fallback) handle them.
|
|
3533
|
+
*
|
|
3534
|
+
* Skips patches that already have a snapshot.
|
|
3535
|
+
*/
|
|
3536
|
+
async migratePatchesToSnapshot(patches) {
|
|
3537
|
+
const { applyPatchToContent: applyPatchToContent2 } = await Promise.resolve().then(() => (init_PatchApplyTheirs(), PatchApplyTheirs_exports));
|
|
3538
|
+
const fileFreq = /* @__PURE__ */ new Map();
|
|
3539
|
+
for (const p of patches) {
|
|
3540
|
+
for (const f of p.files) {
|
|
3541
|
+
fileFreq.set(f, (fileFreq.get(f) ?? 0) + 1);
|
|
3542
|
+
}
|
|
3543
|
+
}
|
|
3544
|
+
let migrated = 0;
|
|
3545
|
+
let skipped = 0;
|
|
3546
|
+
for (const patch of patches) {
|
|
3547
|
+
if (patch.theirs_snapshot != null) continue;
|
|
3548
|
+
const sharesAnyFile = patch.files.some((f) => (fileFreq.get(f) ?? 0) > 1);
|
|
3549
|
+
if (sharesAnyFile) {
|
|
3550
|
+
skipped++;
|
|
3551
|
+
continue;
|
|
3552
|
+
}
|
|
3553
|
+
const snapshot = {};
|
|
3554
|
+
for (const file of patch.files) {
|
|
3555
|
+
if (isBinaryFile(file)) continue;
|
|
3556
|
+
try {
|
|
3557
|
+
const base = await this.git.showFile(patch.base_generation, file).catch(() => null);
|
|
3558
|
+
if (base != null) {
|
|
3559
|
+
const content = await applyPatchToContent2(base, patch.patch_content, file);
|
|
3560
|
+
if (content != null) snapshot[file] = content;
|
|
3561
|
+
}
|
|
3562
|
+
} catch {
|
|
3563
|
+
}
|
|
3564
|
+
}
|
|
3565
|
+
if (Object.keys(snapshot).length > 0) {
|
|
3566
|
+
patch.theirs_snapshot = snapshot;
|
|
3567
|
+
try {
|
|
3568
|
+
this.lockManager.updatePatch(patch.id, { theirs_snapshot: snapshot });
|
|
3569
|
+
migrated++;
|
|
3570
|
+
} catch {
|
|
3571
|
+
}
|
|
3572
|
+
}
|
|
3573
|
+
}
|
|
3574
|
+
if (migrated > 0) {
|
|
3575
|
+
this.warnings.push(
|
|
3576
|
+
`Migrated ${migrated} patch(es) to snapshot encoding. Future regens use 3-way merge with stored THEIRS, robust to generator structural changes.`
|
|
3577
|
+
);
|
|
3578
|
+
}
|
|
3579
|
+
if (skipped > 0) {
|
|
3580
|
+
this.warnings.push(
|
|
3581
|
+
`Skipped snapshot migration for ${skipped} patch(es) sharing files with other patches. These continue to use line-anchored reconstruction (the existing path).`
|
|
3582
|
+
);
|
|
3583
|
+
}
|
|
3584
|
+
}
|
|
2837
3585
|
/**
|
|
2838
3586
|
* Determine whether `file` is user-owned (never produced by the generator).
|
|
2839
3587
|
*
|
|
@@ -2954,6 +3702,20 @@ var ReplayService = class {
|
|
|
2954
3702
|
this.lockManager.clearReplaySkippedAt();
|
|
2955
3703
|
return { conflictResolved: 0, conflictAbsorbed: 0, contentRefreshed: 0 };
|
|
2956
3704
|
}
|
|
3705
|
+
await this.migratePatchesToSnapshot(patches);
|
|
3706
|
+
const headMessage = await this.git.exec(["log", "-1", "--format=%s%n%aN%n%ae", "HEAD"]).catch(() => "");
|
|
3707
|
+
const [headMsgLine, headAuthorName, headAuthorEmail] = headMessage.split("\n");
|
|
3708
|
+
const headIsGeneration = headMsgLine ? isGenerationCommit({
|
|
3709
|
+
sha: "HEAD",
|
|
3710
|
+
authorName: headAuthorName ?? "",
|
|
3711
|
+
authorEmail: headAuthorEmail ?? "",
|
|
3712
|
+
message: headMsgLine
|
|
3713
|
+
}) : false;
|
|
3714
|
+
const headParentChangedFiles = async (patchFiles) => {
|
|
3715
|
+
if (patchFiles.length === 0) return /* @__PURE__ */ new Set();
|
|
3716
|
+
const diff = await this.git.exec(["diff", "--name-only", "HEAD~1", "HEAD", "--", ...patchFiles]).catch(() => "");
|
|
3717
|
+
return new Set(diff.trim().split("\n").filter(Boolean));
|
|
3718
|
+
};
|
|
2957
3719
|
let conflictResolved = 0;
|
|
2958
3720
|
let conflictAbsorbed = 0;
|
|
2959
3721
|
let contentRefreshed = 0;
|
|
@@ -2990,10 +3752,23 @@ var ReplayService = class {
|
|
|
2990
3752
|
delete patch.status;
|
|
2991
3753
|
continue;
|
|
2992
3754
|
}
|
|
3755
|
+
if (headIsGeneration) {
|
|
3756
|
+
const parentChangedFiles = await headParentChangedFiles(patch.files);
|
|
3757
|
+
const headTouchedSource = patch.files.some((f) => parentChangedFiles.has(f));
|
|
3758
|
+
if (headTouchedSource) {
|
|
3759
|
+
continue;
|
|
3760
|
+
}
|
|
3761
|
+
}
|
|
2993
3762
|
if (patch.base_generation === currentGen) {
|
|
2994
3763
|
try {
|
|
2995
|
-
const
|
|
2996
|
-
|
|
3764
|
+
const ppResult = await computePerPatchDiffWithFallback(
|
|
3765
|
+
patch,
|
|
3766
|
+
(f) => this.git.showFile(currentGen, f),
|
|
3767
|
+
(f) => this.git.showFile("HEAD", f),
|
|
3768
|
+
(files) => this.git.exec(["diff", currentGen, "HEAD", "--", ...files]).catch(() => null)
|
|
3769
|
+
);
|
|
3770
|
+
if (ppResult === null) continue;
|
|
3771
|
+
const diff = ppResult.diff;
|
|
2997
3772
|
if (!diff.trim()) {
|
|
2998
3773
|
if (await allPatchFilesUserOwned(patch)) continue;
|
|
2999
3774
|
this.lockManager.removePatch(patch.id);
|
|
@@ -3020,10 +3795,12 @@ var ReplayService = class {
|
|
|
3020
3795
|
if (newFiles.length === 0) {
|
|
3021
3796
|
this.lockManager.removePatch(patch.id);
|
|
3022
3797
|
} else {
|
|
3798
|
+
const snapshot = await this.readSnapshotFromTree("HEAD", newFiles);
|
|
3023
3799
|
this.lockManager.updatePatch(patch.id, {
|
|
3024
3800
|
patch_content: diff,
|
|
3025
3801
|
content_hash: newContentHash,
|
|
3026
|
-
files: newFiles
|
|
3802
|
+
files: newFiles,
|
|
3803
|
+
...Object.keys(snapshot).length > 0 ? { theirs_snapshot: snapshot } : {}
|
|
3027
3804
|
});
|
|
3028
3805
|
}
|
|
3029
3806
|
contentRefreshed++;
|
|
@@ -3035,8 +3812,14 @@ var ReplayService = class {
|
|
|
3035
3812
|
try {
|
|
3036
3813
|
const markerFiles = await this.git.exec(["grep", "-l", "<<<<<<< Generated", "HEAD", "--", ...patch.files]).catch(() => "");
|
|
3037
3814
|
if (markerFiles.trim()) continue;
|
|
3038
|
-
const
|
|
3039
|
-
|
|
3815
|
+
const ppResult = await computePerPatchDiffWithFallback(
|
|
3816
|
+
patch,
|
|
3817
|
+
(f) => this.git.showFile(currentGen, f),
|
|
3818
|
+
(f) => this.git.showFile("HEAD", f),
|
|
3819
|
+
(files) => this.git.exec(["diff", currentGen, "HEAD", "--", ...files]).catch(() => null)
|
|
3820
|
+
);
|
|
3821
|
+
if (ppResult === null) continue;
|
|
3822
|
+
const diff = ppResult.diff;
|
|
3040
3823
|
if (!diff.trim()) {
|
|
3041
3824
|
if (await allPatchFilesUserOwned(patch)) {
|
|
3042
3825
|
try {
|
|
@@ -3050,10 +3833,12 @@ var ReplayService = class {
|
|
|
3050
3833
|
continue;
|
|
3051
3834
|
}
|
|
3052
3835
|
const newContentHash = this.detector.computeContentHash(diff);
|
|
3836
|
+
const snapshot = await this.readSnapshotFromTree("HEAD", patch.files);
|
|
3053
3837
|
this.lockManager.updatePatch(patch.id, {
|
|
3054
3838
|
base_generation: currentGen,
|
|
3055
3839
|
patch_content: diff,
|
|
3056
|
-
content_hash: newContentHash
|
|
3840
|
+
content_hash: newContentHash,
|
|
3841
|
+
...Object.keys(snapshot).length > 0 ? { theirs_snapshot: snapshot } : {}
|
|
3057
3842
|
});
|
|
3058
3843
|
conflictResolved++;
|
|
3059
3844
|
} catch {
|
|
@@ -3061,6 +3846,32 @@ var ReplayService = class {
|
|
|
3061
3846
|
}
|
|
3062
3847
|
return { conflictResolved, conflictAbsorbed, contentRefreshed };
|
|
3063
3848
|
}
|
|
3849
|
+
/**
|
|
3850
|
+
* After applyPatches(), surface a warning for each (patch × file) where
|
|
3851
|
+
* diff3 silently dropped lines that were unchanged context in the
|
|
3852
|
+
* customer's THEIRS. The deletion stays on disk (correct diff3 outcome),
|
|
3853
|
+
* but the customer must learn so they can re-add the lines if they
|
|
3854
|
+
* relied on them. This is the "surface or preserve, never silently drop"
|
|
3855
|
+
* contract for legitimate-deletion cases.
|
|
3856
|
+
*/
|
|
3857
|
+
recordDroppedContextLineWarnings(results) {
|
|
3858
|
+
const PREVIEW_COUNT = 5;
|
|
3859
|
+
for (const result of results) {
|
|
3860
|
+
if (!result.fileResults) continue;
|
|
3861
|
+
for (const fr of result.fileResults) {
|
|
3862
|
+
const dropped = fr.droppedContextLines;
|
|
3863
|
+
if (!dropped || dropped.length === 0) continue;
|
|
3864
|
+
const preview = dropped.slice(0, PREVIEW_COUNT).map((line) => ` ${line}`).join("\n");
|
|
3865
|
+
const more = dropped.length > PREVIEW_COUNT ? `
|
|
3866
|
+
... and ${dropped.length - PREVIEW_COUNT} more` : "";
|
|
3867
|
+
this.warnings.push(
|
|
3868
|
+
`${fr.file}: ${dropped.length} line(s) that appeared as unchanged context in your customization were deleted by the new generator output. The merge followed the deletion. First lines deleted:
|
|
3869
|
+
${preview}${more}
|
|
3870
|
+
If you want to keep these lines, restore them in a follow-up commit and re-run replay; otherwise this warning can be safely ignored.`
|
|
3871
|
+
);
|
|
3872
|
+
}
|
|
3873
|
+
}
|
|
3874
|
+
}
|
|
3064
3875
|
/**
|
|
3065
3876
|
* After applyPatches(), strip conflict markers from conflicting files
|
|
3066
3877
|
* so only clean content is committed. Keeps the Generated (OURS) side.
|
|
@@ -3070,7 +3881,7 @@ var ReplayService = class {
|
|
|
3070
3881
|
if (result.status !== "conflict" || !result.fileResults) continue;
|
|
3071
3882
|
for (const fileResult of result.fileResults) {
|
|
3072
3883
|
if (fileResult.status !== "conflict") continue;
|
|
3073
|
-
const filePath =
|
|
3884
|
+
const filePath = join5(this.outputDir, fileResult.file);
|
|
3074
3885
|
try {
|
|
3075
3886
|
const content = readFileSync2(filePath, "utf-8");
|
|
3076
3887
|
const stripped = stripConflictMarkers(content);
|
|
@@ -3100,7 +3911,7 @@ var ReplayService = class {
|
|
|
3100
3911
|
}
|
|
3101
3912
|
}
|
|
3102
3913
|
readFernignorePatterns() {
|
|
3103
|
-
const fernignorePath =
|
|
3914
|
+
const fernignorePath = join5(this.outputDir, ".fernignore");
|
|
3104
3915
|
if (!existsSync2(fernignorePath)) return [];
|
|
3105
3916
|
return readFileSync2(fernignorePath, "utf-8").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
|
|
3106
3917
|
}
|
|
@@ -3118,6 +3929,9 @@ var ReplayService = class {
|
|
|
3118
3929
|
};
|
|
3119
3930
|
}).filter((d) => d.files.length > 0);
|
|
3120
3931
|
const partialCount = conflictDetails.filter((d) => d.cleanFiles && d.cleanFiles.length > 0).length;
|
|
3932
|
+
const unresolvedResults = results.filter(
|
|
3933
|
+
(r) => r.status === "conflict" || r.patch.status === "unresolved"
|
|
3934
|
+
);
|
|
3121
3935
|
return {
|
|
3122
3936
|
flow,
|
|
3123
3937
|
patchesDetected: patches.length,
|
|
@@ -3134,7 +3948,7 @@ var ReplayService = class {
|
|
|
3134
3948
|
patchesRefreshed: preRebaseCounts && preRebaseCounts.contentRefreshed > 0 ? preRebaseCounts.contentRefreshed : void 0,
|
|
3135
3949
|
conflicts: conflictResults.flatMap((r) => r.fileResults?.filter((f) => f.status === "conflict") ?? []),
|
|
3136
3950
|
conflictDetails: conflictDetails.length > 0 ? conflictDetails : void 0,
|
|
3137
|
-
unresolvedPatches:
|
|
3951
|
+
unresolvedPatches: unresolvedResults.length > 0 ? unresolvedResults.map((r) => ({
|
|
3138
3952
|
patchId: r.patch.id,
|
|
3139
3953
|
patchMessage: r.patch.original_message,
|
|
3140
3954
|
files: r.patch.files,
|
|
@@ -3145,11 +3959,38 @@ var ReplayService = class {
|
|
|
3145
3959
|
};
|
|
3146
3960
|
}
|
|
3147
3961
|
};
|
|
3962
|
+
function computeCommitBuckets(results, absorbedPatchIds, patchesPassedToCommit) {
|
|
3963
|
+
const resultByPatchId = /* @__PURE__ */ new Map();
|
|
3964
|
+
for (const r of results) resultByPatchId.set(r.patch.id, r);
|
|
3965
|
+
const applied = [];
|
|
3966
|
+
const unresolved = [];
|
|
3967
|
+
const absorbed = [];
|
|
3968
|
+
for (const patch of patchesPassedToCommit) {
|
|
3969
|
+
if (absorbedPatchIds.has(patch.id)) {
|
|
3970
|
+
absorbed.push(patch);
|
|
3971
|
+
continue;
|
|
3972
|
+
}
|
|
3973
|
+
const r = resultByPatchId.get(patch.id);
|
|
3974
|
+
if (!r) {
|
|
3975
|
+
applied.push(patch);
|
|
3976
|
+
continue;
|
|
3977
|
+
}
|
|
3978
|
+
if (r.status === "conflict" || patch.status === "unresolved") {
|
|
3979
|
+
unresolved.push(patch);
|
|
3980
|
+
continue;
|
|
3981
|
+
}
|
|
3982
|
+
if (r.status === "applied") {
|
|
3983
|
+
applied.push(patch);
|
|
3984
|
+
continue;
|
|
3985
|
+
}
|
|
3986
|
+
}
|
|
3987
|
+
return { applied, unresolved, absorbed };
|
|
3988
|
+
}
|
|
3148
3989
|
|
|
3149
3990
|
// src/FernignoreMigrator.ts
|
|
3150
3991
|
import { createHash as createHash2 } from "crypto";
|
|
3151
3992
|
import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, statSync, writeFileSync as writeFileSync3 } from "fs";
|
|
3152
|
-
import { dirname as
|
|
3993
|
+
import { dirname as dirname4, join as join6 } from "path";
|
|
3153
3994
|
import { minimatch as minimatch3 } from "minimatch";
|
|
3154
3995
|
import { parse as parse2, stringify as stringify2 } from "yaml";
|
|
3155
3996
|
var FernignoreMigrator = class {
|
|
@@ -3162,10 +4003,10 @@ var FernignoreMigrator = class {
|
|
|
3162
4003
|
this.outputDir = outputDir;
|
|
3163
4004
|
}
|
|
3164
4005
|
fernignoreExists() {
|
|
3165
|
-
return existsSync3(
|
|
4006
|
+
return existsSync3(join6(this.outputDir, ".fernignore"));
|
|
3166
4007
|
}
|
|
3167
4008
|
readFernignorePatterns() {
|
|
3168
|
-
const fernignorePath =
|
|
4009
|
+
const fernignorePath = join6(this.outputDir, ".fernignore");
|
|
3169
4010
|
if (!existsSync3(fernignorePath)) {
|
|
3170
4011
|
return [];
|
|
3171
4012
|
}
|
|
@@ -3217,6 +4058,12 @@ var FernignoreMigrator = class {
|
|
|
3217
4058
|
if (patchFiles.length > 0) {
|
|
3218
4059
|
const patchContent = diffParts.join("\n");
|
|
3219
4060
|
const contentHash = `sha256:${createHash2("sha256").update(patchContent).digest("hex")}`;
|
|
4061
|
+
const theirsSnapshot = {};
|
|
4062
|
+
for (const filePath of patchFiles) {
|
|
4063
|
+
if (isBinaryFile(filePath)) continue;
|
|
4064
|
+
const c = this.readFileContent(filePath);
|
|
4065
|
+
if (c != null) theirsSnapshot[filePath] = c;
|
|
4066
|
+
}
|
|
3220
4067
|
syntheticPatches.push({
|
|
3221
4068
|
id: `patch-fernignore-${createHash2("sha256").update(pattern).digest("hex").slice(0, 8)}`,
|
|
3222
4069
|
content_hash: contentHash,
|
|
@@ -3225,7 +4072,17 @@ var FernignoreMigrator = class {
|
|
|
3225
4072
|
original_author: "Fern Replay <replay@buildwithfern.com>",
|
|
3226
4073
|
base_generation: currentGen.commit_sha,
|
|
3227
4074
|
files: patchFiles,
|
|
3228
|
-
patch_content: patchContent
|
|
4075
|
+
patch_content: patchContent,
|
|
4076
|
+
...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
|
|
4077
|
+
// Synthetic patches captured from .fernignore-protected files
|
|
4078
|
+
// are user-owned by definition — the customer's content
|
|
4079
|
+
// diverges from the generator's pristine output, and the
|
|
4080
|
+
// generator never produced this divergent content. Without
|
|
4081
|
+
// this flag, removing the file from .fernignore later (so
|
|
4082
|
+
// it leaves the protection check in `isFileUserOwned`) would
|
|
4083
|
+
// expose the patch to absorption on the next regen,
|
|
4084
|
+
// silently losing the customization.
|
|
4085
|
+
user_owned: true
|
|
3229
4086
|
});
|
|
3230
4087
|
trackedByBoth.push({
|
|
3231
4088
|
file: pattern,
|
|
@@ -3258,7 +4115,7 @@ var FernignoreMigrator = class {
|
|
|
3258
4115
|
return results;
|
|
3259
4116
|
}
|
|
3260
4117
|
readFileContent(filePath) {
|
|
3261
|
-
const fullPath =
|
|
4118
|
+
const fullPath = join6(this.outputDir, filePath);
|
|
3262
4119
|
if (!existsSync3(fullPath)) {
|
|
3263
4120
|
return null;
|
|
3264
4121
|
}
|
|
@@ -3323,7 +4180,7 @@ var FernignoreMigrator = class {
|
|
|
3323
4180
|
};
|
|
3324
4181
|
}
|
|
3325
4182
|
movePatternsToReplayYml(patterns) {
|
|
3326
|
-
const replayYmlPath =
|
|
4183
|
+
const replayYmlPath = join6(this.outputDir, ".fern", "replay.yml");
|
|
3327
4184
|
let config = {};
|
|
3328
4185
|
if (existsSync3(replayYmlPath)) {
|
|
3329
4186
|
const content = readFileSync3(replayYmlPath, "utf-8");
|
|
@@ -3332,7 +4189,7 @@ var FernignoreMigrator = class {
|
|
|
3332
4189
|
const existing = config.exclude ?? [];
|
|
3333
4190
|
const merged = [.../* @__PURE__ */ new Set([...existing, ...patterns])];
|
|
3334
4191
|
config.exclude = merged;
|
|
3335
|
-
const dir =
|
|
4192
|
+
const dir = dirname4(replayYmlPath);
|
|
3336
4193
|
if (!existsSync3(dir)) {
|
|
3337
4194
|
mkdirSync2(dir, { recursive: true });
|
|
3338
4195
|
}
|
|
@@ -3343,7 +4200,7 @@ var FernignoreMigrator = class {
|
|
|
3343
4200
|
// src/commands/bootstrap.ts
|
|
3344
4201
|
import { createHash as createHash3 } from "crypto";
|
|
3345
4202
|
import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
|
|
3346
|
-
import { join as
|
|
4203
|
+
import { join as join7 } from "path";
|
|
3347
4204
|
init_GitClient();
|
|
3348
4205
|
async function bootstrap(outputDir, options) {
|
|
3349
4206
|
const git = new GitClient(outputDir);
|
|
@@ -3510,7 +4367,7 @@ function parseGitLog(log) {
|
|
|
3510
4367
|
}
|
|
3511
4368
|
var REPLAY_FERNIGNORE_ENTRIES = [".fern/replay.lock", ".fern/replay.yml", ".gitattributes"];
|
|
3512
4369
|
function ensureFernignoreEntries(outputDir) {
|
|
3513
|
-
const fernignorePath =
|
|
4370
|
+
const fernignorePath = join7(outputDir, ".fernignore");
|
|
3514
4371
|
let content = "";
|
|
3515
4372
|
if (existsSync4(fernignorePath)) {
|
|
3516
4373
|
content = readFileSync4(fernignorePath, "utf-8");
|
|
@@ -3534,7 +4391,7 @@ function ensureFernignoreEntries(outputDir) {
|
|
|
3534
4391
|
}
|
|
3535
4392
|
var GITATTRIBUTES_ENTRIES = [".fern/replay.lock linguist-generated=true"];
|
|
3536
4393
|
function ensureGitattributesEntries(outputDir) {
|
|
3537
|
-
const gitattributesPath =
|
|
4394
|
+
const gitattributesPath = join7(outputDir, ".gitattributes");
|
|
3538
4395
|
let content = "";
|
|
3539
4396
|
if (existsSync4(gitattributesPath)) {
|
|
3540
4397
|
content = readFileSync4(gitattributesPath, "utf-8");
|
|
@@ -3733,6 +4590,8 @@ function reset(outputDir, options) {
|
|
|
3733
4590
|
|
|
3734
4591
|
// src/commands/resolve.ts
|
|
3735
4592
|
init_GitClient();
|
|
4593
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
4594
|
+
import { join as join8 } from "path";
|
|
3736
4595
|
async function resolve(outputDir, options) {
|
|
3737
4596
|
const lockManager = new LockfileManager(outputDir);
|
|
3738
4597
|
if (!lockManager.exists()) {
|
|
@@ -3747,7 +4606,7 @@ async function resolve(outputDir, options) {
|
|
|
3747
4606
|
const resolvingPatches = lockManager.getResolvingPatches();
|
|
3748
4607
|
if (unresolvedPatches.length > 0) {
|
|
3749
4608
|
const applicator = new ReplayApplicator(git, lockManager, outputDir);
|
|
3750
|
-
await applicator.applyPatches(unresolvedPatches);
|
|
4609
|
+
await applicator.applyPatches(unresolvedPatches, { applyMode: "resolve" });
|
|
3751
4610
|
const markerFiles = await findConflictMarkerFiles(git);
|
|
3752
4611
|
if (markerFiles.length > 0) {
|
|
3753
4612
|
for (const patch of unresolvedPatches) {
|
|
@@ -3773,20 +4632,80 @@ async function resolve(outputDir, options) {
|
|
|
3773
4632
|
if (patchesToCommit.length > 0) {
|
|
3774
4633
|
const currentGen = lock.current_generation;
|
|
3775
4634
|
const detector = new ReplayDetector(git, lockManager, outputDir);
|
|
4635
|
+
const warnings = [];
|
|
3776
4636
|
let patchesResolved = 0;
|
|
4637
|
+
const pass1 = [];
|
|
3777
4638
|
for (const patch of patchesToCommit) {
|
|
3778
|
-
const
|
|
3779
|
-
|
|
4639
|
+
const result = await computePerPatchDiffWithFallback(
|
|
4640
|
+
patch,
|
|
4641
|
+
(f) => git.showFile(currentGen, f),
|
|
4642
|
+
(f) => readFile3(join8(outputDir, f), "utf-8").catch(() => null),
|
|
4643
|
+
(files) => git.exec(["diff", currentGen, "--", ...files]).catch(() => null)
|
|
4644
|
+
);
|
|
4645
|
+
if (result === null) {
|
|
4646
|
+
warnings.push(
|
|
4647
|
+
`Patch ${patch.id}: cumulative-diff fallback failed for unlocatable files; preserving patch unchanged`
|
|
4648
|
+
);
|
|
4649
|
+
pass1.push({ kind: "skip" });
|
|
4650
|
+
continue;
|
|
4651
|
+
}
|
|
4652
|
+
const diff = result.diff;
|
|
4653
|
+
if (!diff.trim()) {
|
|
4654
|
+
pass1.push({ kind: "revert" });
|
|
4655
|
+
continue;
|
|
4656
|
+
}
|
|
4657
|
+
const hash = detector.computeContentHash(diff);
|
|
4658
|
+
pass1.push({
|
|
4659
|
+
kind: "apply",
|
|
4660
|
+
diff,
|
|
4661
|
+
hash,
|
|
4662
|
+
hadFallback: result.hadFallback,
|
|
4663
|
+
fallbackFiles: result.fallbackFiles
|
|
4664
|
+
});
|
|
4665
|
+
}
|
|
4666
|
+
const lastIndexByHash = /* @__PURE__ */ new Map();
|
|
4667
|
+
for (let i = 0; i < pass1.length; i++) {
|
|
4668
|
+
const r = pass1[i];
|
|
4669
|
+
if (r.kind === "apply") lastIndexByHash.set(r.hash, i);
|
|
4670
|
+
}
|
|
4671
|
+
for (let i = 0; i < patchesToCommit.length; i++) {
|
|
4672
|
+
const patch = patchesToCommit[i];
|
|
4673
|
+
const r = pass1[i];
|
|
4674
|
+
if (r.kind === "skip") continue;
|
|
4675
|
+
if (r.kind === "revert") {
|
|
3780
4676
|
lockManager.removePatch(patch.id);
|
|
3781
4677
|
continue;
|
|
3782
4678
|
}
|
|
3783
|
-
|
|
3784
|
-
|
|
4679
|
+
if (lastIndexByHash.get(r.hash) !== i) {
|
|
4680
|
+
const canonicalIdx = lastIndexByHash.get(r.hash);
|
|
4681
|
+
const canonical = patchesToCommit[canonicalIdx];
|
|
4682
|
+
lockManager.removePatch(patch.id);
|
|
4683
|
+
warnings.push(
|
|
4684
|
+
`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.`
|
|
4685
|
+
);
|
|
4686
|
+
continue;
|
|
4687
|
+
}
|
|
4688
|
+
if (r.hadFallback) {
|
|
4689
|
+
warnings.push(
|
|
4690
|
+
`Patch ${patch.id}: ${r.fallbackFiles.join(", ")} could not be anchored; cumulative-diff fallback applied for those files`
|
|
4691
|
+
);
|
|
4692
|
+
}
|
|
4693
|
+
const changedFiles = r.hadFallback ? await getChangedFiles(git, currentGen, patch.files) : extractFilesFromDiff(r.diff);
|
|
4694
|
+
const filesForSnapshot = changedFiles.length > 0 ? changedFiles : patch.files;
|
|
4695
|
+
const theirsSnapshot = {};
|
|
4696
|
+
for (const file of filesForSnapshot) {
|
|
4697
|
+
if (isBinaryFile(file)) continue;
|
|
4698
|
+
const content = await readFile3(join8(outputDir, file), "utf-8").catch(() => null);
|
|
4699
|
+
if (content != null) {
|
|
4700
|
+
theirsSnapshot[file] = content;
|
|
4701
|
+
}
|
|
4702
|
+
}
|
|
3785
4703
|
lockManager.markPatchResolved(patch.id, {
|
|
3786
|
-
patch_content: diff,
|
|
3787
|
-
content_hash:
|
|
4704
|
+
patch_content: r.diff,
|
|
4705
|
+
content_hash: r.hash,
|
|
3788
4706
|
base_generation: currentGen,
|
|
3789
|
-
files:
|
|
4707
|
+
files: filesForSnapshot,
|
|
4708
|
+
...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {}
|
|
3790
4709
|
});
|
|
3791
4710
|
patchesResolved++;
|
|
3792
4711
|
}
|
|
@@ -3802,7 +4721,8 @@ async function resolve(outputDir, options) {
|
|
|
3802
4721
|
success: true,
|
|
3803
4722
|
commitSha: commitSha2,
|
|
3804
4723
|
phase: "committed",
|
|
3805
|
-
patchesResolved
|
|
4724
|
+
patchesResolved,
|
|
4725
|
+
warnings: warnings.length > 0 ? warnings : void 0
|
|
3806
4726
|
};
|
|
3807
4727
|
}
|
|
3808
4728
|
const committer = new ReplayCommitter(git, outputDir);
|
|
@@ -3820,6 +4740,24 @@ async function getChangedFiles(git, currentGen, files) {
|
|
|
3820
4740
|
const changed = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !f.startsWith(".fern/"));
|
|
3821
4741
|
return changed.length > 0 ? changed : files;
|
|
3822
4742
|
}
|
|
4743
|
+
function extractFilesFromDiff(unifiedDiff2) {
|
|
4744
|
+
const out = /* @__PURE__ */ new Set();
|
|
4745
|
+
const renameSources = /* @__PURE__ */ new Set();
|
|
4746
|
+
let currentTarget = null;
|
|
4747
|
+
for (const line of unifiedDiff2.split("\n")) {
|
|
4748
|
+
const headerMatch = line.match(/^diff --git a\/(.+?) b\/(.+)$/);
|
|
4749
|
+
if (headerMatch) {
|
|
4750
|
+
currentTarget = headerMatch[2];
|
|
4751
|
+
if (!currentTarget.startsWith(".fern/")) out.add(currentTarget);
|
|
4752
|
+
continue;
|
|
4753
|
+
}
|
|
4754
|
+
if (currentTarget && line.startsWith("rename from ")) {
|
|
4755
|
+
renameSources.add(line.slice("rename from ".length));
|
|
4756
|
+
}
|
|
4757
|
+
}
|
|
4758
|
+
for (const src of renameSources) out.delete(src);
|
|
4759
|
+
return Array.from(out);
|
|
4760
|
+
}
|
|
3823
4761
|
|
|
3824
4762
|
// src/commands/status.ts
|
|
3825
4763
|
function status(outputDir) {
|