@fern-api/replay 0.10.3 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.cjs +753 -67
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +645 -66
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +177 -4
- package/dist/index.d.ts +177 -4
- package/dist/index.js +639 -66
- package/dist/index.js.map +1 -1
- package/package.json +4 -1
package/dist/index.js
CHANGED
|
@@ -77,13 +77,30 @@ var init_GitClient = __esm({
|
|
|
77
77
|
const output = await this.exec(["rev-parse", `${commitSha}^@`]);
|
|
78
78
|
return output.trim().split("\n").filter(Boolean);
|
|
79
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* Detects renames and copies between two trees.
|
|
82
|
+
*
|
|
83
|
+
* Both `R` (rename) and `C` (copy) entries are returned as `{from, to}` pairs.
|
|
84
|
+
* Copies are treated as rename-equivalent because the generator may produce the
|
|
85
|
+
* new path while the old path remains in the tree (e.g., imperfect cleanup,
|
|
86
|
+
* customer edits at the old path, or test helpers that only write new files).
|
|
87
|
+
* Callers that care about "is the source still present" should verify via
|
|
88
|
+
* `showFile(toTree, from)`.
|
|
89
|
+
*/
|
|
80
90
|
async detectRenames(fromTree, toTree) {
|
|
81
91
|
try {
|
|
82
|
-
const output = await this.exec([
|
|
92
|
+
const output = await this.exec([
|
|
93
|
+
"diff",
|
|
94
|
+
"--find-renames",
|
|
95
|
+
"--find-copies",
|
|
96
|
+
"--name-status",
|
|
97
|
+
fromTree,
|
|
98
|
+
toTree
|
|
99
|
+
]);
|
|
83
100
|
const renames = [];
|
|
84
101
|
for (const line of output.trim().split("\n")) {
|
|
85
102
|
if (!line) continue;
|
|
86
|
-
if (line.startsWith("R")) {
|
|
103
|
+
if (line.startsWith("R") || line.startsWith("C")) {
|
|
87
104
|
const parts = line.split(" ");
|
|
88
105
|
if (parts.length >= 3) {
|
|
89
106
|
renames.push({ from: parts[1], to: parts[2] });
|
|
@@ -378,6 +395,39 @@ var init_HybridReconstruction = __esm({
|
|
|
378
395
|
}
|
|
379
396
|
});
|
|
380
397
|
|
|
398
|
+
// src/credentials.ts
|
|
399
|
+
var CREDENTIAL_PATTERNS = [
|
|
400
|
+
{ name: "PRIVATE KEY block", re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
|
|
401
|
+
{ name: "OPENSSH key", re: /-----BEGIN OPENSSH PRIVATE KEY-----/ },
|
|
402
|
+
{ name: "RSA key", re: /-----BEGIN RSA PRIVATE KEY-----/ },
|
|
403
|
+
{ name: "CERTIFICATE", re: /-----BEGIN CERTIFICATE-----/ },
|
|
404
|
+
{ name: "TOKEN=", re: /\bTOKEN\s*=\s*["']?[A-Za-z0-9._\-+/=]{10,}/ },
|
|
405
|
+
{ name: "api_key=", re: /\bapi[_-]?key\s*=\s*["']?[A-Za-z0-9._\-+/=]{10,}/i },
|
|
406
|
+
{ name: "password=", re: /\bpassword\s*=\s*["']?[^\s"']{6,}/i },
|
|
407
|
+
{ name: "AWS_SECRET_ACCESS_KEY", re: /AWS_SECRET_ACCESS_KEY/ },
|
|
408
|
+
{ name: "JWT (eyJ...)", re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/ }
|
|
409
|
+
];
|
|
410
|
+
var SENSITIVE_FILE_PATTERNS = [
|
|
411
|
+
/(^|\/)\.env(\.[^/]+)?$/,
|
|
412
|
+
/\.pem$/,
|
|
413
|
+
/\.key$/,
|
|
414
|
+
/\.pkce_state\.json$/,
|
|
415
|
+
/(^|\/)id_rsa$/,
|
|
416
|
+
/(^|\/)id_ed25519$/
|
|
417
|
+
];
|
|
418
|
+
function isSensitiveFile(filePath) {
|
|
419
|
+
return SENSITIVE_FILE_PATTERNS.some((re) => re.test(filePath));
|
|
420
|
+
}
|
|
421
|
+
function scanForCredentials(content) {
|
|
422
|
+
const matches = [];
|
|
423
|
+
for (const { name, re } of CREDENTIAL_PATTERNS) {
|
|
424
|
+
if (re.test(content)) {
|
|
425
|
+
matches.push(name);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return matches;
|
|
429
|
+
}
|
|
430
|
+
|
|
381
431
|
// src/index.ts
|
|
382
432
|
init_GitClient();
|
|
383
433
|
|
|
@@ -424,6 +474,7 @@ var LockfileNotFoundError = class extends Error {
|
|
|
424
474
|
var LockfileManager = class {
|
|
425
475
|
outputDir;
|
|
426
476
|
lock = null;
|
|
477
|
+
warnings = [];
|
|
427
478
|
constructor(outputDir) {
|
|
428
479
|
this.outputDir = outputDir;
|
|
429
480
|
}
|
|
@@ -472,6 +523,27 @@ var LockfileManager = class {
|
|
|
472
523
|
if (!this.lock) {
|
|
473
524
|
throw new Error("No lockfile data to save. Call read() or initialize() first.");
|
|
474
525
|
}
|
|
526
|
+
this.warnings.length = 0;
|
|
527
|
+
const cleanPatches = [];
|
|
528
|
+
for (const patch of this.lock.patches) {
|
|
529
|
+
const credentialMatches = scanForCredentials(patch.patch_content);
|
|
530
|
+
const sensitiveFiles = patch.files.filter((f) => isSensitiveFile(f));
|
|
531
|
+
if (credentialMatches.length > 0 || sensitiveFiles.length > 0) {
|
|
532
|
+
const reasons = [];
|
|
533
|
+
if (credentialMatches.length > 0) {
|
|
534
|
+
reasons.push(`credential patterns: ${credentialMatches.join(", ")}`);
|
|
535
|
+
}
|
|
536
|
+
if (sensitiveFiles.length > 0) {
|
|
537
|
+
reasons.push(`sensitive files: ${sensitiveFiles.join(", ")}`);
|
|
538
|
+
}
|
|
539
|
+
this.warnings.push(
|
|
540
|
+
`Patch ${patch.id} removed from lockfile: ${reasons.join("; ")}. This prevents credential exposure in committed lockfiles.`
|
|
541
|
+
);
|
|
542
|
+
} else {
|
|
543
|
+
cleanPatches.push(patch);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
this.lock.patches = cleanPatches;
|
|
475
547
|
const dir = dirname(this.lockfilePath);
|
|
476
548
|
if (!existsSync(dir)) {
|
|
477
549
|
mkdirSync(dir, { recursive: true });
|
|
@@ -576,6 +648,37 @@ var LockfileManager = class {
|
|
|
576
648
|
// src/ReplayDetector.ts
|
|
577
649
|
import { createHash } from "crypto";
|
|
578
650
|
var INFRASTRUCTURE_FILES = /* @__PURE__ */ new Set([".fernignore"]);
|
|
651
|
+
function parsePatchFileHeaders(patchContent) {
|
|
652
|
+
const results = [];
|
|
653
|
+
let currentPath = null;
|
|
654
|
+
let currentIsCreate = false;
|
|
655
|
+
let currentIsDelete = false;
|
|
656
|
+
const flush = () => {
|
|
657
|
+
if (currentPath !== null) {
|
|
658
|
+
results.push({ path: currentPath, isCreate: currentIsCreate, isDelete: currentIsDelete });
|
|
659
|
+
}
|
|
660
|
+
};
|
|
661
|
+
for (const line of patchContent.split("\n")) {
|
|
662
|
+
if (line.startsWith("diff --git ")) {
|
|
663
|
+
flush();
|
|
664
|
+
currentPath = null;
|
|
665
|
+
currentIsCreate = false;
|
|
666
|
+
currentIsDelete = false;
|
|
667
|
+
const match = line.match(/^diff --git a\/(.+?) b\/(.+?)$/);
|
|
668
|
+
if (match) {
|
|
669
|
+
currentPath = match[2] ?? null;
|
|
670
|
+
}
|
|
671
|
+
} else if (currentPath !== null) {
|
|
672
|
+
if (line.startsWith("new file mode ")) {
|
|
673
|
+
currentIsCreate = true;
|
|
674
|
+
} else if (line.startsWith("deleted file mode ")) {
|
|
675
|
+
currentIsDelete = true;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
flush();
|
|
680
|
+
return results;
|
|
681
|
+
}
|
|
579
682
|
var ReplayDetector = class {
|
|
580
683
|
git;
|
|
581
684
|
lockManager;
|
|
@@ -613,6 +716,7 @@ var ReplayDetector = class {
|
|
|
613
716
|
}
|
|
614
717
|
const log = await this.git.exec([
|
|
615
718
|
"log",
|
|
719
|
+
"--first-parent",
|
|
616
720
|
"--format=%H%x00%an%x00%ae%x00%s",
|
|
617
721
|
`${lastGen.commit_sha}..HEAD`,
|
|
618
722
|
"--",
|
|
@@ -628,11 +732,20 @@ var ReplayDetector = class {
|
|
|
628
732
|
if (isGenerationCommit(commit)) {
|
|
629
733
|
continue;
|
|
630
734
|
}
|
|
631
|
-
|
|
632
|
-
if (parents.length > 1) {
|
|
735
|
+
if (lock.patches.find((p) => p.original_commit === commit.sha)) {
|
|
633
736
|
continue;
|
|
634
737
|
}
|
|
635
|
-
|
|
738
|
+
const parents = await this.git.getCommitParents(commit.sha);
|
|
739
|
+
if (parents.length > 1) {
|
|
740
|
+
const compositePatch = await this.createMergeCompositePatch({
|
|
741
|
+
mergeCommit: commit,
|
|
742
|
+
firstParent: parents[0],
|
|
743
|
+
lastGen,
|
|
744
|
+
lock
|
|
745
|
+
});
|
|
746
|
+
if (compositePatch) {
|
|
747
|
+
newPatches.push(compositePatch);
|
|
748
|
+
}
|
|
636
749
|
continue;
|
|
637
750
|
}
|
|
638
751
|
let patchContent;
|
|
@@ -644,12 +757,31 @@ var ReplayDetector = class {
|
|
|
644
757
|
);
|
|
645
758
|
continue;
|
|
646
759
|
}
|
|
647
|
-
|
|
760
|
+
let contentHash = this.computeContentHash(patchContent);
|
|
648
761
|
if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
|
|
649
762
|
continue;
|
|
650
763
|
}
|
|
651
764
|
const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
|
|
652
|
-
const
|
|
765
|
+
const allFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
|
|
766
|
+
const sensitiveFiles = allFiles.filter((f) => isSensitiveFile(f));
|
|
767
|
+
const files = allFiles.filter((f) => !isSensitiveFile(f));
|
|
768
|
+
if (sensitiveFiles.length > 0) {
|
|
769
|
+
this.warnings.push(
|
|
770
|
+
`Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
|
|
771
|
+
);
|
|
772
|
+
if (files.length > 0) {
|
|
773
|
+
const parentSha = parents[0];
|
|
774
|
+
if (parentSha) {
|
|
775
|
+
try {
|
|
776
|
+
patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
|
|
777
|
+
} catch {
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
if (!patchContent.trim()) continue;
|
|
781
|
+
contentHash = this.computeContentHash(patchContent);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
}
|
|
653
785
|
if (files.length === 0) {
|
|
654
786
|
continue;
|
|
655
787
|
}
|
|
@@ -661,14 +793,16 @@ var ReplayDetector = class {
|
|
|
661
793
|
original_author: `${commit.authorName} <${commit.authorEmail}>`,
|
|
662
794
|
base_generation: lastGen.commit_sha,
|
|
663
795
|
files,
|
|
664
|
-
patch_content: patchContent
|
|
796
|
+
patch_content: patchContent,
|
|
797
|
+
...this.isCreationOnlyPatch(patchContent) ? { user_owned: true } : {}
|
|
665
798
|
});
|
|
666
799
|
}
|
|
667
|
-
|
|
800
|
+
const survivingPatches = await this.collapseNetZeroFiles(newPatches);
|
|
801
|
+
survivingPatches.reverse();
|
|
668
802
|
const revertedPatchIdSet = /* @__PURE__ */ new Set();
|
|
669
803
|
const revertIndicesToRemove = /* @__PURE__ */ new Set();
|
|
670
|
-
for (let i = 0; i <
|
|
671
|
-
const patch =
|
|
804
|
+
for (let i = 0; i < survivingPatches.length; i++) {
|
|
805
|
+
const patch = survivingPatches[i];
|
|
672
806
|
if (!isRevertCommit(patch.original_message)) continue;
|
|
673
807
|
let body = "";
|
|
674
808
|
try {
|
|
@@ -697,7 +831,7 @@ var ReplayDetector = class {
|
|
|
697
831
|
if (matchedExisting) continue;
|
|
698
832
|
let matchedNew = false;
|
|
699
833
|
if (revertedSha) {
|
|
700
|
-
const idx =
|
|
834
|
+
const idx = survivingPatches.findIndex(
|
|
701
835
|
(p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_commit === revertedSha
|
|
702
836
|
);
|
|
703
837
|
if (idx !== -1) {
|
|
@@ -707,7 +841,7 @@ var ReplayDetector = class {
|
|
|
707
841
|
}
|
|
708
842
|
}
|
|
709
843
|
if (!matchedNew && revertedMessage) {
|
|
710
|
-
const idx =
|
|
844
|
+
const idx = survivingPatches.findIndex(
|
|
711
845
|
(p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_message === revertedMessage
|
|
712
846
|
);
|
|
713
847
|
if (idx !== -1) {
|
|
@@ -719,18 +853,155 @@ var ReplayDetector = class {
|
|
|
719
853
|
revertIndicesToRemove.add(i);
|
|
720
854
|
}
|
|
721
855
|
}
|
|
722
|
-
const filteredPatches =
|
|
856
|
+
const filteredPatches = survivingPatches.filter((_, i) => !revertIndicesToRemove.has(i));
|
|
723
857
|
return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
|
|
724
858
|
}
|
|
725
859
|
/**
|
|
726
860
|
* Compute content hash for deduplication.
|
|
727
|
-
*
|
|
728
|
-
*
|
|
861
|
+
*
|
|
862
|
+
* Produces a format-agnostic hash so both `git format-patch` output
|
|
863
|
+
* (email-wrapped) and plain `git diff` output hash to the same value
|
|
864
|
+
* when the underlying diff hunks are identical.
|
|
865
|
+
*
|
|
866
|
+
* Normalization:
|
|
867
|
+
* 1. Strip email wrapper: everything before the first `diff --git` line
|
|
868
|
+
* (From:, Subject:, Date:, diffstat, blank separators).
|
|
869
|
+
* 2. Strip `index` lines (blob SHA pairs change across rebases).
|
|
870
|
+
* 3. Strip trailing git version marker (`-- \n<version>`).
|
|
871
|
+
*
|
|
872
|
+
* This ensures content hashes match across the no-patches and normal-
|
|
873
|
+
* regeneration flows, which store formatPatch and git-diff formats
|
|
874
|
+
* respectively (FER-9850, D5-D9).
|
|
729
875
|
*/
|
|
730
876
|
computeContentHash(patchContent) {
|
|
731
|
-
const
|
|
877
|
+
const lines = patchContent.split("\n");
|
|
878
|
+
const diffStart = lines.findIndex((l) => l.startsWith("diff --git "));
|
|
879
|
+
const relevantLines = diffStart > 0 ? lines.slice(diffStart) : lines;
|
|
880
|
+
const normalized = relevantLines.filter((line) => !line.startsWith("index ")).join("\n").replace(/\n-- \n[\d]+\.[\d]+[^\n]*\s*$/, "");
|
|
732
881
|
return `sha256:${createHash("sha256").update(normalized).digest("hex")}`;
|
|
733
882
|
}
|
|
883
|
+
/**
|
|
884
|
+
* FER-9805 — Drop patches whose files were transiently created and then
|
|
885
|
+
* deleted within the current linear detection window, and are absent from
|
|
886
|
+
* HEAD at the end of the window.
|
|
887
|
+
*
|
|
888
|
+
* Rationale: on a merge commit, createMergeCompositePatch already diffs
|
|
889
|
+
* firstParent..mergeCommit so net-zero files never enter the composite. On
|
|
890
|
+
* linear history each commit becomes its own patch, so a file that was
|
|
891
|
+
* briefly added and later removed survives as a create+delete pair whose
|
|
892
|
+
* cumulative diff is empty. We drop such patches before they reach the
|
|
893
|
+
* lockfile.
|
|
894
|
+
*
|
|
895
|
+
* A file is "transient" when:
|
|
896
|
+
* 1. some patch in the window sets `new file mode` for it (a creation), AND
|
|
897
|
+
* 2. some patch in the window sets `deleted file mode` for it (a deletion), AND
|
|
898
|
+
* 3. it is absent from HEAD's tree.
|
|
899
|
+
*
|
|
900
|
+
* The HEAD guard (#3) prevents false positives when a file is deleted
|
|
901
|
+
* and then recreated with different content — the cumulative diff of
|
|
902
|
+
* such a pair is non-empty and must be preserved.
|
|
903
|
+
*
|
|
904
|
+
* This only drops patches whose `files` are a subset of the transient
|
|
905
|
+
* set. A partial-transient patch (one that touches a transient file
|
|
906
|
+
* alongside a persistent one) is left unmodified; surgical stripping of
|
|
907
|
+
* single files from a multi-file patch would require a follow-up that
|
|
908
|
+
* regenerates the patch content via `git format-patch -- <files>`. No
|
|
909
|
+
* current failing test exercises this, and leaving the patch intact
|
|
910
|
+
* preserves today's behaviour. TODO(FER-9805): revisit if a future
|
|
911
|
+
* adversarial test demands per-file stripping.
|
|
912
|
+
*/
|
|
913
|
+
async collapseNetZeroFiles(patches) {
|
|
914
|
+
if (patches.length === 0) return patches;
|
|
915
|
+
const stateByFile = /* @__PURE__ */ new Map();
|
|
916
|
+
for (const patch of patches) {
|
|
917
|
+
for (const header of parsePatchFileHeaders(patch.patch_content)) {
|
|
918
|
+
if (!header.isCreate && !header.isDelete) continue;
|
|
919
|
+
const prior = stateByFile.get(header.path) ?? { created: false, deleted: false };
|
|
920
|
+
if (header.isCreate) prior.created = true;
|
|
921
|
+
if (header.isDelete) prior.deleted = true;
|
|
922
|
+
stateByFile.set(header.path, prior);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
let anyCandidate = false;
|
|
926
|
+
for (const state of stateByFile.values()) {
|
|
927
|
+
if (state.created && state.deleted) {
|
|
928
|
+
anyCandidate = true;
|
|
929
|
+
break;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
if (!anyCandidate) return patches;
|
|
933
|
+
const headFiles = await this.listHeadFiles();
|
|
934
|
+
const transient = /* @__PURE__ */ new Set();
|
|
935
|
+
for (const [file, state] of stateByFile) {
|
|
936
|
+
if (state.created && state.deleted && !headFiles.has(file)) {
|
|
937
|
+
transient.add(file);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
if (transient.size === 0) return patches;
|
|
941
|
+
return patches.filter((patch) => !patch.files.every((f) => transient.has(f)));
|
|
942
|
+
}
|
|
943
|
+
/**
|
|
944
|
+
* List every tracked path at HEAD (one `git ls-tree -r` invocation).
|
|
945
|
+
* Returns an empty Set if HEAD is unborn or the invocation fails — the
|
|
946
|
+
* caller then treats every candidate as "not in HEAD" and may collapse
|
|
947
|
+
* more aggressively. On typical SDK repos this is a single sub-10ms call.
|
|
948
|
+
*/
|
|
949
|
+
async listHeadFiles() {
|
|
950
|
+
try {
|
|
951
|
+
const out = await this.git.exec(["ls-tree", "-r", "--name-only", "HEAD"]);
|
|
952
|
+
return new Set(out.split("\n").map((l) => l.trim()).filter(Boolean));
|
|
953
|
+
} catch {
|
|
954
|
+
return /* @__PURE__ */ new Set();
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
/**
|
|
958
|
+
* Create a single composite patch for a merge commit by diffing the merge
|
|
959
|
+
* commit against its first parent. This captures the net change of the
|
|
960
|
+
* entire merged branch as one patch, eliminating accumulator chaining and
|
|
961
|
+
* zero-net-change ghost conflicts.
|
|
962
|
+
*/
|
|
963
|
+
async createMergeCompositePatch(input) {
|
|
964
|
+
const { mergeCommit, firstParent, lastGen, lock } = input;
|
|
965
|
+
const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
|
|
966
|
+
const filesOutput = await this.git.exec([
|
|
967
|
+
"diff",
|
|
968
|
+
"--name-only",
|
|
969
|
+
firstParent,
|
|
970
|
+
mergeCommit.sha
|
|
971
|
+
]);
|
|
972
|
+
const allMergeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
|
|
973
|
+
const sensitiveMergeFiles = allMergeFiles.filter((f) => isSensitiveFile(f));
|
|
974
|
+
const files = allMergeFiles.filter((f) => !isSensitiveFile(f));
|
|
975
|
+
if (sensitiveMergeFiles.length > 0) {
|
|
976
|
+
this.warnings.push(
|
|
977
|
+
`Sensitive file(s) excluded from merge patch for commit ${mergeCommit.sha.slice(0, 7)}: ${sensitiveMergeFiles.join(", ")}. These files will not be tracked by replay.`
|
|
978
|
+
);
|
|
979
|
+
}
|
|
980
|
+
if (files.length === 0) return null;
|
|
981
|
+
const diff = await this.git.exec([
|
|
982
|
+
"diff",
|
|
983
|
+
firstParent,
|
|
984
|
+
mergeCommit.sha,
|
|
985
|
+
"--",
|
|
986
|
+
...files
|
|
987
|
+
]);
|
|
988
|
+
if (!diff.trim()) return null;
|
|
989
|
+
const contentHash = this.computeContentHash(diff);
|
|
990
|
+
if (lock.patches.some((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
|
|
991
|
+
return null;
|
|
992
|
+
}
|
|
993
|
+
return {
|
|
994
|
+
id: `patch-merge-${mergeCommit.sha.slice(0, 8)}`,
|
|
995
|
+
content_hash: contentHash,
|
|
996
|
+
original_commit: mergeCommit.sha,
|
|
997
|
+
original_message: mergeCommit.message,
|
|
998
|
+
original_author: `${mergeCommit.authorName} <${mergeCommit.authorEmail}>`,
|
|
999
|
+
base_generation: lastGen.commit_sha,
|
|
1000
|
+
files,
|
|
1001
|
+
patch_content: diff,
|
|
1002
|
+
...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
734
1005
|
/**
|
|
735
1006
|
* Detect patches via tree diff for non-linear history. Returns a composite patch.
|
|
736
1007
|
* Revert reconciliation is skipped here because tree-diff produces a single composite
|
|
@@ -742,7 +1013,14 @@ var ReplayDetector = class {
|
|
|
742
1013
|
return this.detectPatchesViaCommitScan();
|
|
743
1014
|
}
|
|
744
1015
|
const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
|
|
745
|
-
const
|
|
1016
|
+
const allTreeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
|
|
1017
|
+
const sensitiveTreeFiles = allTreeFiles.filter((f) => isSensitiveFile(f));
|
|
1018
|
+
const files = allTreeFiles.filter((f) => !isSensitiveFile(f));
|
|
1019
|
+
if (sensitiveTreeFiles.length > 0) {
|
|
1020
|
+
this.warnings.push(
|
|
1021
|
+
`Sensitive file(s) excluded from composite patch: ${sensitiveTreeFiles.join(", ")}. These files will not be tracked by replay.`
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
746
1024
|
if (files.length === 0) return { patches: [], revertedPatchIds: [] };
|
|
747
1025
|
const diff = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
|
|
748
1026
|
if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
|
|
@@ -762,7 +1040,8 @@ var ReplayDetector = class {
|
|
|
762
1040
|
// reference to find base file content. diffBase may be the tree_hash.
|
|
763
1041
|
base_generation: commitKnownMissing ? diffBase : lastGen.commit_sha,
|
|
764
1042
|
files,
|
|
765
|
-
patch_content: diff
|
|
1043
|
+
patch_content: diff,
|
|
1044
|
+
...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
|
|
766
1045
|
};
|
|
767
1046
|
return { patches: [compositePatch], revertedPatchIds: [] };
|
|
768
1047
|
}
|
|
@@ -809,7 +1088,7 @@ var ReplayDetector = class {
|
|
|
809
1088
|
} catch {
|
|
810
1089
|
continue;
|
|
811
1090
|
}
|
|
812
|
-
|
|
1091
|
+
let contentHash = this.computeContentHash(patchContent);
|
|
813
1092
|
if (existingHashes.has(contentHash) || forgottenHashes.has(contentHash)) {
|
|
814
1093
|
continue;
|
|
815
1094
|
}
|
|
@@ -817,7 +1096,26 @@ var ReplayDetector = class {
|
|
|
817
1096
|
continue;
|
|
818
1097
|
}
|
|
819
1098
|
const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
|
|
820
|
-
const
|
|
1099
|
+
const allScanFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f));
|
|
1100
|
+
const sensitiveScanFiles = allScanFiles.filter((f) => isSensitiveFile(f));
|
|
1101
|
+
const files = allScanFiles.filter((f) => !isSensitiveFile(f));
|
|
1102
|
+
if (sensitiveScanFiles.length > 0) {
|
|
1103
|
+
this.warnings.push(
|
|
1104
|
+
`Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveScanFiles.join(", ")}. These files will not be tracked by replay.`
|
|
1105
|
+
);
|
|
1106
|
+
if (files.length > 0) {
|
|
1107
|
+
if (parents.length === 0) {
|
|
1108
|
+
continue;
|
|
1109
|
+
}
|
|
1110
|
+
try {
|
|
1111
|
+
patchContent = await this.git.exec(["diff", parents[0], commit.sha, "--", ...files]);
|
|
1112
|
+
} catch {
|
|
1113
|
+
continue;
|
|
1114
|
+
}
|
|
1115
|
+
if (!patchContent.trim()) continue;
|
|
1116
|
+
contentHash = this.computeContentHash(patchContent);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
821
1119
|
if (files.length === 0) {
|
|
822
1120
|
continue;
|
|
823
1121
|
}
|
|
@@ -1149,6 +1447,20 @@ var ReplayApplicator = class {
|
|
|
1149
1447
|
resetAccumulator() {
|
|
1150
1448
|
this.fileTheirsAccumulator.clear();
|
|
1151
1449
|
}
|
|
1450
|
+
/**
|
|
1451
|
+
* @internal Test-only.
|
|
1452
|
+
* Returns a defensive copy of the inter-patch accumulator. Used by the
|
|
1453
|
+
* adversarial test suite (FER-9791) to assert invariant I2: after every
|
|
1454
|
+
* applied patch, the accumulator has an entry for each file the patch
|
|
1455
|
+
* touched, and the entry's content matches on-disk.
|
|
1456
|
+
*/
|
|
1457
|
+
getAccumulatorSnapshot() {
|
|
1458
|
+
const snapshot = /* @__PURE__ */ new Map();
|
|
1459
|
+
for (const [path, entry] of this.fileTheirsAccumulator) {
|
|
1460
|
+
snapshot.set(path, { content: entry.content, baseGeneration: entry.baseGeneration });
|
|
1461
|
+
}
|
|
1462
|
+
return snapshot;
|
|
1463
|
+
}
|
|
1152
1464
|
/**
|
|
1153
1465
|
* Apply all patches, returning results for each.
|
|
1154
1466
|
* Skips patches that match exclude patterns in replay.yml
|
|
@@ -1214,7 +1526,7 @@ var ReplayApplicator = class {
|
|
|
1214
1526
|
const base = await this.git.showFile(baseGen.tree_hash, filePath);
|
|
1215
1527
|
const theirs = await this.applyPatchToContent(base, patch.patch_content, filePath, tempGit, tempDir);
|
|
1216
1528
|
const effectiveTheirs = theirs ?? await readFile(join2(this.outputDir, resolvedPath), "utf-8").catch(() => null);
|
|
1217
|
-
if (effectiveTheirs) {
|
|
1529
|
+
if (effectiveTheirs != null) {
|
|
1218
1530
|
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
1219
1531
|
content: effectiveTheirs,
|
|
1220
1532
|
baseGeneration: patch.base_generation
|
|
@@ -1238,16 +1550,20 @@ var ReplayApplicator = class {
|
|
|
1238
1550
|
return this.fileTheirsAccumulator.has(resolved);
|
|
1239
1551
|
})
|
|
1240
1552
|
).then((results) => results.some(Boolean));
|
|
1241
|
-
|
|
1553
|
+
const resolvedFiles = {};
|
|
1554
|
+
for (const filePath of patch.files) {
|
|
1555
|
+
const resolvedPath = baseGen ? await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash) : filePath;
|
|
1556
|
+
if (resolvedPath !== filePath) {
|
|
1557
|
+
resolvedFiles[filePath] = resolvedPath;
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
const patchIsRenameAware = /^rename from /m.test(patch.patch_content);
|
|
1561
|
+
const hasGeneratorRename = Object.keys(resolvedFiles).length > 0 && !patchIsRenameAware;
|
|
1562
|
+
if (!needsAccumulation && !hasGeneratorRename) {
|
|
1242
1563
|
const snapshots = /* @__PURE__ */ new Map();
|
|
1243
|
-
const resolvedFiles = {};
|
|
1244
1564
|
for (const filePath of patch.files) {
|
|
1245
|
-
const
|
|
1246
|
-
|
|
1247
|
-
resolvedFiles[filePath] = resolvedPath;
|
|
1248
|
-
}
|
|
1249
|
-
const fullPath = join2(this.outputDir, resolvedPath);
|
|
1250
|
-
snapshots.set(resolvedPath, await readFile(fullPath, "utf-8").catch(() => null));
|
|
1565
|
+
const fullPath = join2(this.outputDir, filePath);
|
|
1566
|
+
snapshots.set(filePath, await readFile(fullPath, "utf-8").catch(() => null));
|
|
1251
1567
|
}
|
|
1252
1568
|
try {
|
|
1253
1569
|
await this.git.execWithInput(["apply", "--3way"], patch.patch_content);
|
|
@@ -1259,8 +1575,8 @@ var ReplayApplicator = class {
|
|
|
1259
1575
|
...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
|
|
1260
1576
|
};
|
|
1261
1577
|
} catch {
|
|
1262
|
-
for (const [
|
|
1263
|
-
const fullPath = join2(this.outputDir,
|
|
1578
|
+
for (const [filePath, content] of snapshots) {
|
|
1579
|
+
const fullPath = join2(this.outputDir, filePath);
|
|
1264
1580
|
if (content != null) {
|
|
1265
1581
|
await writeFile(fullPath, content);
|
|
1266
1582
|
} else {
|
|
@@ -1380,7 +1696,7 @@ var ReplayApplicator = class {
|
|
|
1380
1696
|
}
|
|
1381
1697
|
let useAccumulatorAsMergeBase = false;
|
|
1382
1698
|
const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
|
|
1383
|
-
if (accumulatorEntry && (
|
|
1699
|
+
if (accumulatorEntry && (theirs == null || base == null)) {
|
|
1384
1700
|
theirs = await this.applyPatchToContent(
|
|
1385
1701
|
accumulatorEntry.content,
|
|
1386
1702
|
patch.patch_content,
|
|
@@ -1388,7 +1704,16 @@ var ReplayApplicator = class {
|
|
|
1388
1704
|
tempGit,
|
|
1389
1705
|
tempDir
|
|
1390
1706
|
);
|
|
1391
|
-
if (theirs) {
|
|
1707
|
+
if (theirs != null) {
|
|
1708
|
+
useAccumulatorAsMergeBase = true;
|
|
1709
|
+
} else if (await this.isPatchAlreadyApplied(
|
|
1710
|
+
patch.patch_content,
|
|
1711
|
+
filePath,
|
|
1712
|
+
accumulatorEntry.content,
|
|
1713
|
+
tempGit,
|
|
1714
|
+
tempDir
|
|
1715
|
+
)) {
|
|
1716
|
+
theirs = accumulatorEntry.content;
|
|
1392
1717
|
useAccumulatorAsMergeBase = true;
|
|
1393
1718
|
}
|
|
1394
1719
|
}
|
|
@@ -1405,7 +1730,7 @@ var ReplayApplicator = class {
|
|
|
1405
1730
|
}
|
|
1406
1731
|
let effective_theirs = theirs;
|
|
1407
1732
|
let baseMismatchSkipped = false;
|
|
1408
|
-
if (theirs && base && !useAccumulatorAsMergeBase) {
|
|
1733
|
+
if (theirs != null && base != null && !useAccumulatorAsMergeBase) {
|
|
1409
1734
|
if (accumulatorEntry && accumulatorEntry.baseGeneration === patch.base_generation) {
|
|
1410
1735
|
try {
|
|
1411
1736
|
const preMerged = threeWayMerge(base, accumulatorEntry.content, theirs);
|
|
@@ -1421,7 +1746,7 @@ var ReplayApplicator = class {
|
|
|
1421
1746
|
baseMismatchSkipped = true;
|
|
1422
1747
|
}
|
|
1423
1748
|
}
|
|
1424
|
-
if (base == null &&
|
|
1749
|
+
if (base == null && ours == null && effective_theirs != null) {
|
|
1425
1750
|
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
1426
1751
|
content: effective_theirs,
|
|
1427
1752
|
baseGeneration: patch.base_generation
|
|
@@ -1431,7 +1756,7 @@ var ReplayApplicator = class {
|
|
|
1431
1756
|
await writeFile(oursPath, effective_theirs);
|
|
1432
1757
|
return { file: resolvedPath, status: "merged", reason: "new-file" };
|
|
1433
1758
|
}
|
|
1434
|
-
if (base == null && ours && effective_theirs && !useAccumulatorAsMergeBase) {
|
|
1759
|
+
if (base == null && ours != null && effective_theirs != null && !useAccumulatorAsMergeBase) {
|
|
1435
1760
|
const merged2 = threeWayMerge("", ours, effective_theirs);
|
|
1436
1761
|
const outDir2 = dirname2(oursPath);
|
|
1437
1762
|
await mkdir(outDir2, { recursive: true });
|
|
@@ -1445,16 +1770,20 @@ var ReplayApplicator = class {
|
|
|
1445
1770
|
conflictMetadata: metadata
|
|
1446
1771
|
};
|
|
1447
1772
|
}
|
|
1773
|
+
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
1774
|
+
content: merged2.content,
|
|
1775
|
+
baseGeneration: patch.base_generation
|
|
1776
|
+
});
|
|
1448
1777
|
return { file: resolvedPath, status: "merged" };
|
|
1449
1778
|
}
|
|
1450
|
-
if (
|
|
1779
|
+
if (effective_theirs == null) {
|
|
1451
1780
|
return {
|
|
1452
1781
|
file: resolvedPath,
|
|
1453
1782
|
status: "skipped",
|
|
1454
1783
|
reason: "missing-content"
|
|
1455
1784
|
};
|
|
1456
1785
|
}
|
|
1457
|
-
if (base == null && !useAccumulatorAsMergeBase ||
|
|
1786
|
+
if (base == null && !useAccumulatorAsMergeBase || ours == null) {
|
|
1458
1787
|
return {
|
|
1459
1788
|
file: resolvedPath,
|
|
1460
1789
|
status: "skipped",
|
|
@@ -1473,7 +1802,7 @@ var ReplayApplicator = class {
|
|
|
1473
1802
|
const outDir = dirname2(oursPath);
|
|
1474
1803
|
await mkdir(outDir, { recursive: true });
|
|
1475
1804
|
await writeFile(oursPath, merged.content);
|
|
1476
|
-
if (effective_theirs && !merged.hasConflicts) {
|
|
1805
|
+
if (effective_theirs != null && !merged.hasConflicts) {
|
|
1477
1806
|
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
1478
1807
|
content: effective_theirs,
|
|
1479
1808
|
baseGeneration: patch.base_generation
|
|
@@ -1571,6 +1900,28 @@ var ReplayApplicator = class {
|
|
|
1571
1900
|
return null;
|
|
1572
1901
|
}
|
|
1573
1902
|
}
|
|
1903
|
+
/**
|
|
1904
|
+
* Detects whether a patch's additions are already present in `content`.
|
|
1905
|
+
* Uses `git apply --reverse --check` — if the reverse patch applies cleanly,
|
|
1906
|
+
* the forward patch is effectively a no-op (its +lines are already there and
|
|
1907
|
+
* its -lines are already gone). Used to distinguish "patch already applied"
|
|
1908
|
+
* from "patch base mismatch" after a forward-apply fails.
|
|
1909
|
+
*/
|
|
1910
|
+
async isPatchAlreadyApplied(patchContent, filePath, content, tempGit, tempDir) {
|
|
1911
|
+
const fileDiff = this.extractFileDiff(patchContent, filePath);
|
|
1912
|
+
if (!fileDiff) return false;
|
|
1913
|
+
try {
|
|
1914
|
+
const tempFilePath = join2(tempDir, filePath);
|
|
1915
|
+
await mkdir(dirname2(tempFilePath), { recursive: true });
|
|
1916
|
+
await writeFile(tempFilePath, content);
|
|
1917
|
+
await tempGit.exec(["add", filePath]);
|
|
1918
|
+
await tempGit.exec(["commit", "-m", `check already-applied ${filePath}`, "--allow-empty"]);
|
|
1919
|
+
await tempGit.execWithInput(["apply", "--reverse", "--check", "--allow-empty"], fileDiff);
|
|
1920
|
+
return true;
|
|
1921
|
+
} catch {
|
|
1922
|
+
return false;
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1574
1925
|
extractFileDiff(patchContent, filePath) {
|
|
1575
1926
|
const lines = patchContent.split("\n");
|
|
1576
1927
|
const diffLines = [];
|
|
@@ -1739,6 +2090,33 @@ var ReplayService = class {
|
|
|
1739
2090
|
committer;
|
|
1740
2091
|
lockManager;
|
|
1741
2092
|
outputDir;
|
|
2093
|
+
/** @internal Test-only. Captures the last ReplayResult[] returned from applicator.applyPatches(). */
|
|
2094
|
+
_lastApplyResults = [];
|
|
2095
|
+
/**
|
|
2096
|
+
* Service-level warnings accumulated during a single runReplay() call.
|
|
2097
|
+
* Merged with `detector.warnings` into `ReplayReport.warnings` by each flow handler.
|
|
2098
|
+
* Populated by `isFileUserOwned` when a patch's base generation tree is unreachable
|
|
2099
|
+
* (shallow clone / aggressive `git gc --prune`) and we fall back to a conservative
|
|
2100
|
+
* heuristic. Cleared at the top of `runReplay()`.
|
|
2101
|
+
*/
|
|
2102
|
+
warnings = [];
|
|
2103
|
+
/**
|
|
2104
|
+
* @internal Test-only accessor.
|
|
2105
|
+
* Returns the ReplayApplicator instance used for the last run. Tests use this
|
|
2106
|
+
* to inspect the inter-patch accumulator after runReplay() completes (FER-9791, I2).
|
|
2107
|
+
*/
|
|
2108
|
+
get applicatorRef() {
|
|
2109
|
+
return this.applicator;
|
|
2110
|
+
}
|
|
2111
|
+
/**
|
|
2112
|
+
* @internal Test-only accessor.
|
|
2113
|
+
* Returns the ReplayResult[] from the most recent applyPatches() call. Tests use
|
|
2114
|
+
* this to filter applied-vs-conflicted-vs-absorbed patches for invariant assertions
|
|
2115
|
+
* (FER-9791). Returns an empty array if no patches have been applied yet.
|
|
2116
|
+
*/
|
|
2117
|
+
getLastResults() {
|
|
2118
|
+
return this._lastApplyResults;
|
|
2119
|
+
}
|
|
1742
2120
|
constructor(outputDir, _config) {
|
|
1743
2121
|
const git = new GitClient(outputDir);
|
|
1744
2122
|
this.git = git;
|
|
@@ -1749,6 +2127,7 @@ var ReplayService = class {
|
|
|
1749
2127
|
this.committer = new ReplayCommitter(git, outputDir);
|
|
1750
2128
|
}
|
|
1751
2129
|
async runReplay(options) {
|
|
2130
|
+
this.warnings = [];
|
|
1752
2131
|
if (options?.skipApplication) {
|
|
1753
2132
|
return this.handleSkipApplication(options);
|
|
1754
2133
|
}
|
|
@@ -1822,6 +2201,7 @@ var ReplayService = class {
|
|
|
1822
2201
|
);
|
|
1823
2202
|
}
|
|
1824
2203
|
this.lockManager.save();
|
|
2204
|
+
this.detector.warnings.push(...this.lockManager.warnings);
|
|
1825
2205
|
}
|
|
1826
2206
|
determineFlow() {
|
|
1827
2207
|
try {
|
|
@@ -1888,6 +2268,7 @@ var ReplayService = class {
|
|
|
1888
2268
|
}
|
|
1889
2269
|
this.lockManager.setReplaySkippedAt((/* @__PURE__ */ new Date()).toISOString());
|
|
1890
2270
|
this.lockManager.save();
|
|
2271
|
+
const credentialWarnings = [...this.lockManager.warnings];
|
|
1891
2272
|
if (!options?.stageOnly) {
|
|
1892
2273
|
await this.committer.commitReplay(0);
|
|
1893
2274
|
} else {
|
|
@@ -1899,13 +2280,15 @@ var ReplayService = class {
|
|
|
1899
2280
|
patchesApplied: 0,
|
|
1900
2281
|
patchesWithConflicts: 0,
|
|
1901
2282
|
patchesSkipped: 0,
|
|
1902
|
-
conflicts: []
|
|
2283
|
+
conflicts: [],
|
|
2284
|
+
warnings: credentialWarnings.length > 0 ? credentialWarnings : void 0
|
|
1903
2285
|
};
|
|
1904
2286
|
}
|
|
1905
2287
|
async handleNoPatchesRegeneration(options) {
|
|
1906
|
-
|
|
1907
|
-
const
|
|
2288
|
+
let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
|
|
2289
|
+
const detectorWarnings = [...this.detector.warnings];
|
|
1908
2290
|
if (options?.dryRun) {
|
|
2291
|
+
const dryRunWarnings = [...detectorWarnings, ...this.warnings];
|
|
1909
2292
|
return {
|
|
1910
2293
|
flow: "no-patches",
|
|
1911
2294
|
patchesDetected: newPatches.length,
|
|
@@ -1915,9 +2298,26 @@ var ReplayService = class {
|
|
|
1915
2298
|
patchesReverted: revertedPatchIds.length,
|
|
1916
2299
|
conflicts: [],
|
|
1917
2300
|
wouldApply: newPatches,
|
|
1918
|
-
warnings:
|
|
2301
|
+
warnings: dryRunWarnings.length > 0 ? dryRunWarnings : void 0
|
|
1919
2302
|
};
|
|
1920
2303
|
}
|
|
2304
|
+
{
|
|
2305
|
+
const preLock = this.lockManager.read();
|
|
2306
|
+
const preCurrentGen = preLock.current_generation;
|
|
2307
|
+
const uniqueFiles = [...new Set(newPatches.flatMap((p) => p.files))];
|
|
2308
|
+
const absorbedFiles = /* @__PURE__ */ new Set();
|
|
2309
|
+
for (const file of uniqueFiles) {
|
|
2310
|
+
const diff = await this.git.exec(["diff", preCurrentGen, "HEAD", "--", file]).catch(() => null);
|
|
2311
|
+
if (diff !== null && !diff.trim()) {
|
|
2312
|
+
absorbedFiles.add(file);
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
if (absorbedFiles.size > 0) {
|
|
2316
|
+
newPatches = newPatches.filter(
|
|
2317
|
+
(p) => p.files.length === 0 || !p.files.every((f) => absorbedFiles.has(f))
|
|
2318
|
+
);
|
|
2319
|
+
}
|
|
2320
|
+
}
|
|
1921
2321
|
for (const id of revertedPatchIds) {
|
|
1922
2322
|
try {
|
|
1923
2323
|
this.lockManager.removePatch(id);
|
|
@@ -1936,6 +2336,7 @@ var ReplayService = class {
|
|
|
1936
2336
|
let results = [];
|
|
1937
2337
|
if (newPatches.length > 0) {
|
|
1938
2338
|
results = await this.applicator.applyPatches(newPatches);
|
|
2339
|
+
this._lastApplyResults = results;
|
|
1939
2340
|
this.revertConflictingFiles(results);
|
|
1940
2341
|
for (const result of results) {
|
|
1941
2342
|
if (result.status === "conflict") {
|
|
@@ -1950,6 +2351,7 @@ var ReplayService = class {
|
|
|
1950
2351
|
}
|
|
1951
2352
|
}
|
|
1952
2353
|
this.lockManager.save();
|
|
2354
|
+
this.warnings.push(...this.lockManager.warnings);
|
|
1953
2355
|
if (newPatches.length > 0) {
|
|
1954
2356
|
if (!options?.stageOnly) {
|
|
1955
2357
|
const appliedCount = results.filter((r) => r.status === "applied").length;
|
|
@@ -1958,13 +2360,14 @@ var ReplayService = class {
|
|
|
1958
2360
|
await this.committer.stageAll();
|
|
1959
2361
|
}
|
|
1960
2362
|
}
|
|
2363
|
+
const warnings = [...detectorWarnings, ...this.warnings];
|
|
1961
2364
|
return this.buildReport("no-patches", newPatches, results, options, warnings, rebaseCounts, void 0, revertedPatchIds.length);
|
|
1962
2365
|
}
|
|
1963
2366
|
async handleNormalRegeneration(options) {
|
|
1964
2367
|
if (options?.dryRun) {
|
|
1965
2368
|
const existingPatches2 = this.lockManager.getPatches();
|
|
1966
2369
|
const { patches: newPatches2, revertedPatchIds: dryRunReverted } = await this.detector.detectNewPatches();
|
|
1967
|
-
const warnings2 = [...this.detector.warnings];
|
|
2370
|
+
const warnings2 = [...this.detector.warnings, ...this.warnings];
|
|
1968
2371
|
const allPatches2 = [...existingPatches2, ...newPatches2];
|
|
1969
2372
|
return {
|
|
1970
2373
|
flow: "normal-regeneration",
|
|
@@ -1996,7 +2399,7 @@ var ReplayService = class {
|
|
|
1996
2399
|
}
|
|
1997
2400
|
existingPatches = this.lockManager.getPatches();
|
|
1998
2401
|
let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
|
|
1999
|
-
const
|
|
2402
|
+
const detectorWarnings = [...this.detector.warnings];
|
|
2000
2403
|
if (removedByPreRebase.length > 0) {
|
|
2001
2404
|
const removedOriginalCommits = new Set(removedByPreRebase.map((p) => p.original_commit));
|
|
2002
2405
|
const removedOriginalMessages = new Set(removedByPreRebase.map((p) => p.original_message));
|
|
@@ -2028,6 +2431,7 @@ var ReplayService = class {
|
|
|
2028
2431
|
const genRecord = await this.committer.createGenerationRecord(commitOpts);
|
|
2029
2432
|
this.lockManager.addGeneration(genRecord);
|
|
2030
2433
|
const results = await this.applicator.applyPatches(allPatches);
|
|
2434
|
+
this._lastApplyResults = results;
|
|
2031
2435
|
this.revertConflictingFiles(results);
|
|
2032
2436
|
for (const result of results) {
|
|
2033
2437
|
if (result.status === "conflict") {
|
|
@@ -2049,12 +2453,14 @@ var ReplayService = class {
|
|
|
2049
2453
|
}
|
|
2050
2454
|
}
|
|
2051
2455
|
this.lockManager.save();
|
|
2456
|
+
this.warnings.push(...this.lockManager.warnings);
|
|
2052
2457
|
if (options?.stageOnly) {
|
|
2053
2458
|
await this.committer.stageAll();
|
|
2054
2459
|
} else {
|
|
2055
2460
|
const appliedCount = results.filter((r) => r.status === "applied").length;
|
|
2056
2461
|
await this.committer.commitReplay(appliedCount, allPatches);
|
|
2057
2462
|
}
|
|
2463
|
+
const warnings = [...detectorWarnings, ...this.warnings];
|
|
2058
2464
|
return this.buildReport(
|
|
2059
2465
|
"normal-regeneration",
|
|
2060
2466
|
allPatches,
|
|
@@ -2078,6 +2484,7 @@ var ReplayService = class {
|
|
|
2078
2484
|
let keptAsUserOwned = 0;
|
|
2079
2485
|
const seenContentHashes = /* @__PURE__ */ new Set();
|
|
2080
2486
|
const absorbedPatchIds = /* @__PURE__ */ new Set();
|
|
2487
|
+
const fernignorePatterns = this.readFernignorePatterns();
|
|
2081
2488
|
for (const result of results) {
|
|
2082
2489
|
if (result.resolvedFiles && Object.keys(result.resolvedFiles).length > 0) {
|
|
2083
2490
|
const patch = result.patch;
|
|
@@ -2098,34 +2505,78 @@ var ReplayService = class {
|
|
|
2098
2505
|
const patch = result.patch;
|
|
2099
2506
|
if (patch.base_generation === currentGenSha) continue;
|
|
2100
2507
|
try {
|
|
2101
|
-
const
|
|
2102
|
-
|
|
2508
|
+
const originalByResolved = {};
|
|
2509
|
+
if (result.resolvedFiles) {
|
|
2510
|
+
for (const [orig, resolved] of Object.entries(result.resolvedFiles)) {
|
|
2511
|
+
originalByResolved[resolved] = orig;
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
const isUserOwnedPerFile = await Promise.all(
|
|
2103
2515
|
patch.files.map(async (file) => {
|
|
2104
|
-
if (file === ".fernignore")
|
|
2516
|
+
if (file === ".fernignore") return true;
|
|
2517
|
+
if (fernignorePatterns.some((p) => file === p || minimatch2(file, p))) {
|
|
2105
2518
|
return true;
|
|
2106
2519
|
}
|
|
2107
|
-
if (
|
|
2520
|
+
if (patch.user_owned) return true;
|
|
2521
|
+
if (this.fileIsCreationInPatchContent(patch.patch_content, originalByResolved[file] ?? file)) {
|
|
2108
2522
|
return true;
|
|
2109
2523
|
}
|
|
2110
2524
|
const content = await this.git.showFile(currentGenSha, file);
|
|
2111
2525
|
return content === null;
|
|
2112
2526
|
})
|
|
2113
2527
|
);
|
|
2114
|
-
const
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2528
|
+
const hasProtectedFiles = patch.files.some(
|
|
2529
|
+
(file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch2(file, p))
|
|
2530
|
+
);
|
|
2531
|
+
const allUserOwned = isUserOwnedPerFile.every(Boolean);
|
|
2532
|
+
if (allUserOwned && patch.files.length > 0) {
|
|
2533
|
+
const baseChanged = patch.base_generation !== currentGenSha;
|
|
2534
|
+
const userDiff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
|
|
2535
|
+
if (userDiff != null && userDiff.trim()) {
|
|
2536
|
+
const newHash = this.detector.computeContentHash(userDiff);
|
|
2537
|
+
patch.patch_content = userDiff;
|
|
2538
|
+
patch.content_hash = newHash;
|
|
2539
|
+
}
|
|
2540
|
+
patch.base_generation = currentGenSha;
|
|
2541
|
+
try {
|
|
2542
|
+
this.lockManager.updatePatch(patch.id, {
|
|
2543
|
+
base_generation: currentGenSha,
|
|
2544
|
+
...!patch.user_owned ? { user_owned: true } : {},
|
|
2545
|
+
...userDiff != null && userDiff.trim() ? { patch_content: patch.patch_content, content_hash: patch.content_hash } : {}
|
|
2546
|
+
});
|
|
2547
|
+
} catch {
|
|
2548
|
+
}
|
|
2549
|
+
patch.user_owned = true;
|
|
2119
2550
|
keptAsUserOwned++;
|
|
2551
|
+
if (baseChanged) repointed++;
|
|
2120
2552
|
continue;
|
|
2121
2553
|
}
|
|
2122
2554
|
const diff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
|
|
2123
|
-
if (
|
|
2555
|
+
if (diff === null) continue;
|
|
2556
|
+
if (!diff.trim()) {
|
|
2557
|
+
if (hasProtectedFiles) {
|
|
2558
|
+
patch.base_generation = currentGenSha;
|
|
2559
|
+
try {
|
|
2560
|
+
this.lockManager.updatePatch(patch.id, { base_generation: currentGenSha });
|
|
2561
|
+
} catch {
|
|
2562
|
+
}
|
|
2563
|
+
keptAsUserOwned++;
|
|
2564
|
+
continue;
|
|
2565
|
+
}
|
|
2124
2566
|
this.lockManager.removePatch(patch.id);
|
|
2125
2567
|
absorbedPatchIds.add(patch.id);
|
|
2126
2568
|
absorbed++;
|
|
2127
2569
|
continue;
|
|
2128
2570
|
}
|
|
2571
|
+
if (hasProtectedFiles) {
|
|
2572
|
+
patch.base_generation = currentGenSha;
|
|
2573
|
+
try {
|
|
2574
|
+
this.lockManager.updatePatch(patch.id, { base_generation: currentGenSha });
|
|
2575
|
+
} catch {
|
|
2576
|
+
}
|
|
2577
|
+
keptAsUserOwned++;
|
|
2578
|
+
continue;
|
|
2579
|
+
}
|
|
2129
2580
|
const newContentHash = this.detector.computeContentHash(diff);
|
|
2130
2581
|
if (seenContentHashes.has(newContentHash)) {
|
|
2131
2582
|
this.lockManager.removePatch(patch.id);
|
|
@@ -2134,17 +2585,89 @@ var ReplayService = class {
|
|
|
2134
2585
|
continue;
|
|
2135
2586
|
}
|
|
2136
2587
|
seenContentHashes.add(newContentHash);
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2588
|
+
patch.base_generation = currentGenSha;
|
|
2589
|
+
patch.patch_content = diff;
|
|
2590
|
+
patch.content_hash = newContentHash;
|
|
2591
|
+
try {
|
|
2592
|
+
this.lockManager.updatePatch(patch.id, {
|
|
2593
|
+
base_generation: currentGenSha,
|
|
2594
|
+
patch_content: diff,
|
|
2595
|
+
content_hash: newContentHash
|
|
2596
|
+
});
|
|
2597
|
+
} catch {
|
|
2598
|
+
}
|
|
2142
2599
|
contentRebased++;
|
|
2143
2600
|
} catch {
|
|
2144
2601
|
}
|
|
2145
2602
|
}
|
|
2146
2603
|
return { absorbed, repointed, contentRebased, keptAsUserOwned, absorbedPatchIds };
|
|
2147
2604
|
}
|
|
2605
|
+
/**
|
|
2606
|
+
* Determine whether `file` is user-owned (never produced by the generator).
|
|
2607
|
+
*
|
|
2608
|
+
* Resolution order (first match wins):
|
|
2609
|
+
* 1. `patch.user_owned === true` — authoritative, persisted across cycles.
|
|
2610
|
+
* 2. `.fernignore` itself, or file matches a .fernignore pattern.
|
|
2611
|
+
* 3. Legacy-safe signal: `patch_content` shows this file as a `--- /dev/null`
|
|
2612
|
+
* creation. Works for patches that predate the `user_owned` field or
|
|
2613
|
+
* whose flag was lost. Immune to rename tricks — relies on raw patch text.
|
|
2614
|
+
* 4. Tree-based check against `patch.base_generation` when it is reachable
|
|
2615
|
+
* AND distinct from `currentGenSha`. If the base tree is unreachable
|
|
2616
|
+
* (shallow clone / aggressive `git gc --prune`), emit a one-time
|
|
2617
|
+
* customer-actionable warning (deduplicated via warnedGens) and
|
|
2618
|
+
* conservatively treat as user-owned — strictly safer than silent
|
|
2619
|
+
* absorption (FER-9809).
|
|
2620
|
+
* 5. Final fallback — check `currentGenSha` when there is no usable base
|
|
2621
|
+
* (legacy one-gen lockfile). Preserves pre-fix behavior for that edge.
|
|
2622
|
+
*
|
|
2623
|
+
* `originalFileName` is the pre-rename path when the generator moved the
|
|
2624
|
+
* file between the patch's base and the current generation. Tree lookups
|
|
2625
|
+
* at ancestor generations use the original path — that's where the
|
|
2626
|
+
* generator produced the content.
|
|
2627
|
+
*/
|
|
2628
|
+
async isFileUserOwned(file, patch, currentGenSha, fernignorePatterns, warnedGens, originalFileName) {
|
|
2629
|
+
if (patch.user_owned) return true;
|
|
2630
|
+
if (file === ".fernignore") return true;
|
|
2631
|
+
if (fernignorePatterns.some((p) => file === p || minimatch2(file, p))) return true;
|
|
2632
|
+
if (this.fileIsCreationInPatchContent(patch.patch_content, originalFileName ?? file)) {
|
|
2633
|
+
return true;
|
|
2634
|
+
}
|
|
2635
|
+
const base = patch.base_generation;
|
|
2636
|
+
if (base && base !== currentGenSha) {
|
|
2637
|
+
const reachable = await this.git.commitExists(base) || await this.git.treeExists(base);
|
|
2638
|
+
if (!reachable) {
|
|
2639
|
+
if (!warnedGens.has(base)) {
|
|
2640
|
+
warnedGens.add(base);
|
|
2641
|
+
this.warnings.push(
|
|
2642
|
+
`Base generation ${base.slice(0, 7)} is unreachable (shallow clone or pruned history). Treating affected files as user-owned to avoid silent data loss. Run \`git fetch --unshallow\` or avoid \`git gc --prune\` to restore full history.`
|
|
2643
|
+
);
|
|
2644
|
+
}
|
|
2645
|
+
return true;
|
|
2646
|
+
}
|
|
2647
|
+
return await this.git.showFile(base, originalFileName ?? file) === null;
|
|
2648
|
+
}
|
|
2649
|
+
return await this.git.showFile(currentGenSha, originalFileName ?? file) === null;
|
|
2650
|
+
}
|
|
2651
|
+
/**
|
|
2652
|
+
* Returns true if `file`'s diff section in `patchContent` starts with
|
|
2653
|
+
* `--- /dev/null`, meaning this file was introduced by the patch (user
|
|
2654
|
+
* creation). Used as a legacy-safe fallback when `patch.user_owned` is
|
|
2655
|
+
* absent or cleared.
|
|
2656
|
+
*/
|
|
2657
|
+
fileIsCreationInPatchContent(patchContent, file) {
|
|
2658
|
+
const lines = patchContent.split("\n");
|
|
2659
|
+
let inFileSection = false;
|
|
2660
|
+
for (const line of lines) {
|
|
2661
|
+
if (line.startsWith("diff --git ")) {
|
|
2662
|
+
inFileSection = line.includes(` b/${file}`) || line.endsWith(` b/${file}`);
|
|
2663
|
+
continue;
|
|
2664
|
+
}
|
|
2665
|
+
if (inFileSection && line.startsWith("--- ")) {
|
|
2666
|
+
return line === "--- /dev/null";
|
|
2667
|
+
}
|
|
2668
|
+
}
|
|
2669
|
+
return false;
|
|
2670
|
+
}
|
|
2148
2671
|
/**
|
|
2149
2672
|
* For conflict patches with mixed results (some files merged, some conflicted),
|
|
2150
2673
|
* check if the cleanly merged files were absorbed by the generator (empty diff).
|
|
@@ -2158,13 +2681,14 @@ var ReplayService = class {
|
|
|
2158
2681
|
const cleanFiles = result.fileResults.filter((f) => f.status === "merged").map((f) => f.file);
|
|
2159
2682
|
if (cleanFiles.length === 0) return;
|
|
2160
2683
|
const patch = result.patch;
|
|
2684
|
+
if (patch.user_owned) return;
|
|
2161
2685
|
const fernignorePatterns = this.readFernignorePatterns();
|
|
2686
|
+
const warnedGens = /* @__PURE__ */ new Set();
|
|
2162
2687
|
const generatorCleanFiles = [];
|
|
2163
2688
|
for (const file of cleanFiles) {
|
|
2164
|
-
if (file
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
if (content === null) continue;
|
|
2689
|
+
if (await this.isFileUserOwned(file, patch, currentGenSha, fernignorePatterns, warnedGens)) {
|
|
2690
|
+
continue;
|
|
2691
|
+
}
|
|
2168
2692
|
generatorCleanFiles.push(file);
|
|
2169
2693
|
}
|
|
2170
2694
|
if (generatorCleanFiles.length === 0) return;
|
|
@@ -2201,7 +2725,35 @@ var ReplayService = class {
|
|
|
2201
2725
|
let conflictResolved = 0;
|
|
2202
2726
|
let conflictAbsorbed = 0;
|
|
2203
2727
|
let contentRefreshed = 0;
|
|
2728
|
+
const fernignorePatterns = this.readFernignorePatterns();
|
|
2729
|
+
const warnedGens = /* @__PURE__ */ new Set();
|
|
2730
|
+
const reachabilityCache = /* @__PURE__ */ new Map();
|
|
2731
|
+
const isReachable = async (sha) => {
|
|
2732
|
+
const cached = reachabilityCache.get(sha);
|
|
2733
|
+
if (cached !== void 0) return cached;
|
|
2734
|
+
const reachable = await this.git.commitExists(sha) || await this.git.treeExists(sha);
|
|
2735
|
+
reachabilityCache.set(sha, reachable);
|
|
2736
|
+
return reachable;
|
|
2737
|
+
};
|
|
2738
|
+
const allPatchFilesUserOwned = async (patch) => {
|
|
2739
|
+
if (patch.user_owned) return true;
|
|
2740
|
+
if (patch.files.length === 0) return false;
|
|
2741
|
+
for (const file of patch.files) {
|
|
2742
|
+
if (!await this.isFileUserOwned(file, patch, currentGen, fernignorePatterns, warnedGens)) {
|
|
2743
|
+
return false;
|
|
2744
|
+
}
|
|
2745
|
+
}
|
|
2746
|
+
return true;
|
|
2747
|
+
};
|
|
2204
2748
|
for (const patch of patches) {
|
|
2749
|
+
if (patch.base_generation && patch.base_generation !== currentGen && !warnedGens.has(patch.base_generation)) {
|
|
2750
|
+
if (!await isReachable(patch.base_generation)) {
|
|
2751
|
+
warnedGens.add(patch.base_generation);
|
|
2752
|
+
this.warnings.push(
|
|
2753
|
+
`Base generation ${patch.base_generation.slice(0, 7)} is unreachable (shallow clone or pruned history). Treating affected files as user-owned to avoid silent data loss. Run \`git fetch --unshallow\` or avoid \`git gc --prune\` to restore full history.`
|
|
2754
|
+
);
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2205
2757
|
if (patch.status != null) {
|
|
2206
2758
|
delete patch.status;
|
|
2207
2759
|
continue;
|
|
@@ -2211,6 +2763,7 @@ var ReplayService = class {
|
|
|
2211
2763
|
const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
|
|
2212
2764
|
if (diff === null) continue;
|
|
2213
2765
|
if (!diff.trim()) {
|
|
2766
|
+
if (await allPatchFilesUserOwned(patch)) continue;
|
|
2214
2767
|
this.lockManager.removePatch(patch.id);
|
|
2215
2768
|
contentRefreshed++;
|
|
2216
2769
|
continue;
|
|
@@ -2222,6 +2775,12 @@ var ReplayService = class {
|
|
|
2222
2775
|
if (hasStaleMarkers) {
|
|
2223
2776
|
continue;
|
|
2224
2777
|
}
|
|
2778
|
+
const isProtected = patch.files.some(
|
|
2779
|
+
(file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch2(file, p))
|
|
2780
|
+
);
|
|
2781
|
+
if (isProtected) {
|
|
2782
|
+
continue;
|
|
2783
|
+
}
|
|
2225
2784
|
const newContentHash = this.detector.computeContentHash(diff);
|
|
2226
2785
|
if (newContentHash !== patch.content_hash) {
|
|
2227
2786
|
const filesOutput = await this.git.exec(["diff", "--name-only", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
|
|
@@ -2247,6 +2806,13 @@ var ReplayService = class {
|
|
|
2247
2806
|
const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
|
|
2248
2807
|
if (diff === null) continue;
|
|
2249
2808
|
if (!diff.trim()) {
|
|
2809
|
+
if (await allPatchFilesUserOwned(patch)) {
|
|
2810
|
+
try {
|
|
2811
|
+
this.lockManager.updatePatch(patch.id, { base_generation: currentGen });
|
|
2812
|
+
} catch {
|
|
2813
|
+
}
|
|
2814
|
+
continue;
|
|
2815
|
+
}
|
|
2250
2816
|
this.lockManager.removePatch(patch.id);
|
|
2251
2817
|
conflictAbsorbed++;
|
|
2252
2818
|
continue;
|
|
@@ -2758,7 +3324,10 @@ function ensureGitattributesEntries(outputDir) {
|
|
|
2758
3324
|
writeFileSync4(gitattributesPath, content, "utf-8");
|
|
2759
3325
|
}
|
|
2760
3326
|
function computeContentHash(patchContent) {
|
|
2761
|
-
const
|
|
3327
|
+
const lines = patchContent.split("\n");
|
|
3328
|
+
const diffStart = lines.findIndex((l) => l.startsWith("diff --git "));
|
|
3329
|
+
const relevantLines = diffStart > 0 ? lines.slice(diffStart) : lines;
|
|
3330
|
+
const normalized = relevantLines.filter((line) => !line.startsWith("index ")).join("\n").replace(/\n-- \n[\d]+\.[\d]+[^\n]*\s*$/, "");
|
|
2762
3331
|
return `sha256:${createHash3("sha256").update(normalized).digest("hex")}`;
|
|
2763
3332
|
}
|
|
2764
3333
|
|
|
@@ -3069,6 +3638,7 @@ function status(outputDir) {
|
|
|
3069
3638
|
};
|
|
3070
3639
|
}
|
|
3071
3640
|
export {
|
|
3641
|
+
CREDENTIAL_PATTERNS,
|
|
3072
3642
|
FERN_BOT_EMAIL,
|
|
3073
3643
|
FERN_BOT_LOGIN,
|
|
3074
3644
|
FERN_BOT_NAME,
|
|
@@ -3080,15 +3650,18 @@ export {
|
|
|
3080
3650
|
ReplayCommitter,
|
|
3081
3651
|
ReplayDetector,
|
|
3082
3652
|
ReplayService,
|
|
3653
|
+
SENSITIVE_FILE_PATTERNS,
|
|
3083
3654
|
bootstrap,
|
|
3084
3655
|
forget,
|
|
3085
3656
|
isGenerationCommit,
|
|
3086
3657
|
isReplayCommit,
|
|
3087
3658
|
isRevertCommit,
|
|
3659
|
+
isSensitiveFile,
|
|
3088
3660
|
parseRevertedMessage,
|
|
3089
3661
|
parseRevertedSha,
|
|
3090
3662
|
reset,
|
|
3091
3663
|
resolve,
|
|
3664
|
+
scanForCredentials,
|
|
3092
3665
|
status,
|
|
3093
3666
|
threeWayMerge
|
|
3094
3667
|
};
|