@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.cjs
CHANGED
|
@@ -99,13 +99,30 @@ var init_GitClient = __esm({
|
|
|
99
99
|
const output = await this.exec(["rev-parse", `${commitSha}^@`]);
|
|
100
100
|
return output.trim().split("\n").filter(Boolean);
|
|
101
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* Detects renames and copies between two trees.
|
|
104
|
+
*
|
|
105
|
+
* Both `R` (rename) and `C` (copy) entries are returned as `{from, to}` pairs.
|
|
106
|
+
* Copies are treated as rename-equivalent because the generator may produce the
|
|
107
|
+
* new path while the old path remains in the tree (e.g., imperfect cleanup,
|
|
108
|
+
* customer edits at the old path, or test helpers that only write new files).
|
|
109
|
+
* Callers that care about "is the source still present" should verify via
|
|
110
|
+
* `showFile(toTree, from)`.
|
|
111
|
+
*/
|
|
102
112
|
async detectRenames(fromTree, toTree) {
|
|
103
113
|
try {
|
|
104
|
-
const output = await this.exec([
|
|
114
|
+
const output = await this.exec([
|
|
115
|
+
"diff",
|
|
116
|
+
"--find-renames",
|
|
117
|
+
"--find-copies",
|
|
118
|
+
"--name-status",
|
|
119
|
+
fromTree,
|
|
120
|
+
toTree
|
|
121
|
+
]);
|
|
105
122
|
const renames = [];
|
|
106
123
|
for (const line of output.trim().split("\n")) {
|
|
107
124
|
if (!line) continue;
|
|
108
|
-
if (line.startsWith("R")) {
|
|
125
|
+
if (line.startsWith("R") || line.startsWith("C")) {
|
|
109
126
|
const parts = line.split(" ");
|
|
110
127
|
if (parts.length >= 3) {
|
|
111
128
|
renames.push({ from: parts[1], to: parts[2] });
|
|
@@ -403,6 +420,7 @@ var init_HybridReconstruction = __esm({
|
|
|
403
420
|
// src/index.ts
|
|
404
421
|
var index_exports = {};
|
|
405
422
|
__export(index_exports, {
|
|
423
|
+
CREDENTIAL_PATTERNS: () => CREDENTIAL_PATTERNS,
|
|
406
424
|
FERN_BOT_EMAIL: () => FERN_BOT_EMAIL,
|
|
407
425
|
FERN_BOT_LOGIN: () => FERN_BOT_LOGIN,
|
|
408
426
|
FERN_BOT_NAME: () => FERN_BOT_NAME,
|
|
@@ -414,19 +432,57 @@ __export(index_exports, {
|
|
|
414
432
|
ReplayCommitter: () => ReplayCommitter,
|
|
415
433
|
ReplayDetector: () => ReplayDetector,
|
|
416
434
|
ReplayService: () => ReplayService,
|
|
435
|
+
SENSITIVE_FILE_PATTERNS: () => SENSITIVE_FILE_PATTERNS,
|
|
417
436
|
bootstrap: () => bootstrap,
|
|
418
437
|
forget: () => forget,
|
|
419
438
|
isGenerationCommit: () => isGenerationCommit,
|
|
420
439
|
isReplayCommit: () => isReplayCommit,
|
|
421
440
|
isRevertCommit: () => isRevertCommit,
|
|
441
|
+
isSensitiveFile: () => isSensitiveFile,
|
|
422
442
|
parseRevertedMessage: () => parseRevertedMessage,
|
|
423
443
|
parseRevertedSha: () => parseRevertedSha,
|
|
424
444
|
reset: () => reset,
|
|
425
445
|
resolve: () => resolve,
|
|
446
|
+
scanForCredentials: () => scanForCredentials,
|
|
426
447
|
status: () => status,
|
|
427
448
|
threeWayMerge: () => threeWayMerge
|
|
428
449
|
});
|
|
429
450
|
module.exports = __toCommonJS(index_exports);
|
|
451
|
+
|
|
452
|
+
// src/credentials.ts
|
|
453
|
+
var CREDENTIAL_PATTERNS = [
|
|
454
|
+
{ name: "PRIVATE KEY block", re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
|
|
455
|
+
{ name: "OPENSSH key", re: /-----BEGIN OPENSSH PRIVATE KEY-----/ },
|
|
456
|
+
{ name: "RSA key", re: /-----BEGIN RSA PRIVATE KEY-----/ },
|
|
457
|
+
{ name: "CERTIFICATE", re: /-----BEGIN CERTIFICATE-----/ },
|
|
458
|
+
{ name: "TOKEN=", re: /\bTOKEN\s*=\s*["']?[A-Za-z0-9._\-+/=]{10,}/ },
|
|
459
|
+
{ name: "api_key=", re: /\bapi[_-]?key\s*=\s*["']?[A-Za-z0-9._\-+/=]{10,}/i },
|
|
460
|
+
{ name: "password=", re: /\bpassword\s*=\s*["']?[^\s"']{6,}/i },
|
|
461
|
+
{ name: "AWS_SECRET_ACCESS_KEY", re: /AWS_SECRET_ACCESS_KEY/ },
|
|
462
|
+
{ name: "JWT (eyJ...)", re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/ }
|
|
463
|
+
];
|
|
464
|
+
var SENSITIVE_FILE_PATTERNS = [
|
|
465
|
+
/(^|\/)\.env(\.[^/]+)?$/,
|
|
466
|
+
/\.pem$/,
|
|
467
|
+
/\.key$/,
|
|
468
|
+
/\.pkce_state\.json$/,
|
|
469
|
+
/(^|\/)id_rsa$/,
|
|
470
|
+
/(^|\/)id_ed25519$/
|
|
471
|
+
];
|
|
472
|
+
function isSensitiveFile(filePath) {
|
|
473
|
+
return SENSITIVE_FILE_PATTERNS.some((re) => re.test(filePath));
|
|
474
|
+
}
|
|
475
|
+
function scanForCredentials(content) {
|
|
476
|
+
const matches = [];
|
|
477
|
+
for (const { name, re } of CREDENTIAL_PATTERNS) {
|
|
478
|
+
if (re.test(content)) {
|
|
479
|
+
matches.push(name);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return matches;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// src/index.ts
|
|
430
486
|
init_GitClient();
|
|
431
487
|
|
|
432
488
|
// src/git/CommitDetection.ts
|
|
@@ -472,6 +528,7 @@ var LockfileNotFoundError = class extends Error {
|
|
|
472
528
|
var LockfileManager = class {
|
|
473
529
|
outputDir;
|
|
474
530
|
lock = null;
|
|
531
|
+
warnings = [];
|
|
475
532
|
constructor(outputDir) {
|
|
476
533
|
this.outputDir = outputDir;
|
|
477
534
|
}
|
|
@@ -520,6 +577,27 @@ var LockfileManager = class {
|
|
|
520
577
|
if (!this.lock) {
|
|
521
578
|
throw new Error("No lockfile data to save. Call read() or initialize() first.");
|
|
522
579
|
}
|
|
580
|
+
this.warnings.length = 0;
|
|
581
|
+
const cleanPatches = [];
|
|
582
|
+
for (const patch of this.lock.patches) {
|
|
583
|
+
const credentialMatches = scanForCredentials(patch.patch_content);
|
|
584
|
+
const sensitiveFiles = patch.files.filter((f) => isSensitiveFile(f));
|
|
585
|
+
if (credentialMatches.length > 0 || sensitiveFiles.length > 0) {
|
|
586
|
+
const reasons = [];
|
|
587
|
+
if (credentialMatches.length > 0) {
|
|
588
|
+
reasons.push(`credential patterns: ${credentialMatches.join(", ")}`);
|
|
589
|
+
}
|
|
590
|
+
if (sensitiveFiles.length > 0) {
|
|
591
|
+
reasons.push(`sensitive files: ${sensitiveFiles.join(", ")}`);
|
|
592
|
+
}
|
|
593
|
+
this.warnings.push(
|
|
594
|
+
`Patch ${patch.id} removed from lockfile: ${reasons.join("; ")}. This prevents credential exposure in committed lockfiles.`
|
|
595
|
+
);
|
|
596
|
+
} else {
|
|
597
|
+
cleanPatches.push(patch);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
this.lock.patches = cleanPatches;
|
|
523
601
|
const dir = (0, import_node_path.dirname)(this.lockfilePath);
|
|
524
602
|
if (!(0, import_node_fs.existsSync)(dir)) {
|
|
525
603
|
(0, import_node_fs.mkdirSync)(dir, { recursive: true });
|
|
@@ -624,6 +702,37 @@ var LockfileManager = class {
|
|
|
624
702
|
// src/ReplayDetector.ts
|
|
625
703
|
var import_node_crypto = require("crypto");
|
|
626
704
|
var INFRASTRUCTURE_FILES = /* @__PURE__ */ new Set([".fernignore"]);
|
|
705
|
+
function parsePatchFileHeaders(patchContent) {
|
|
706
|
+
const results = [];
|
|
707
|
+
let currentPath = null;
|
|
708
|
+
let currentIsCreate = false;
|
|
709
|
+
let currentIsDelete = false;
|
|
710
|
+
const flush = () => {
|
|
711
|
+
if (currentPath !== null) {
|
|
712
|
+
results.push({ path: currentPath, isCreate: currentIsCreate, isDelete: currentIsDelete });
|
|
713
|
+
}
|
|
714
|
+
};
|
|
715
|
+
for (const line of patchContent.split("\n")) {
|
|
716
|
+
if (line.startsWith("diff --git ")) {
|
|
717
|
+
flush();
|
|
718
|
+
currentPath = null;
|
|
719
|
+
currentIsCreate = false;
|
|
720
|
+
currentIsDelete = false;
|
|
721
|
+
const match = line.match(/^diff --git a\/(.+?) b\/(.+?)$/);
|
|
722
|
+
if (match) {
|
|
723
|
+
currentPath = match[2] ?? null;
|
|
724
|
+
}
|
|
725
|
+
} else if (currentPath !== null) {
|
|
726
|
+
if (line.startsWith("new file mode ")) {
|
|
727
|
+
currentIsCreate = true;
|
|
728
|
+
} else if (line.startsWith("deleted file mode ")) {
|
|
729
|
+
currentIsDelete = true;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
flush();
|
|
734
|
+
return results;
|
|
735
|
+
}
|
|
627
736
|
var ReplayDetector = class {
|
|
628
737
|
git;
|
|
629
738
|
lockManager;
|
|
@@ -661,6 +770,7 @@ var ReplayDetector = class {
|
|
|
661
770
|
}
|
|
662
771
|
const log = await this.git.exec([
|
|
663
772
|
"log",
|
|
773
|
+
"--first-parent",
|
|
664
774
|
"--format=%H%x00%an%x00%ae%x00%s",
|
|
665
775
|
`${lastGen.commit_sha}..HEAD`,
|
|
666
776
|
"--",
|
|
@@ -676,11 +786,20 @@ var ReplayDetector = class {
|
|
|
676
786
|
if (isGenerationCommit(commit)) {
|
|
677
787
|
continue;
|
|
678
788
|
}
|
|
679
|
-
|
|
680
|
-
if (parents.length > 1) {
|
|
789
|
+
if (lock.patches.find((p) => p.original_commit === commit.sha)) {
|
|
681
790
|
continue;
|
|
682
791
|
}
|
|
683
|
-
|
|
792
|
+
const parents = await this.git.getCommitParents(commit.sha);
|
|
793
|
+
if (parents.length > 1) {
|
|
794
|
+
const compositePatch = await this.createMergeCompositePatch({
|
|
795
|
+
mergeCommit: commit,
|
|
796
|
+
firstParent: parents[0],
|
|
797
|
+
lastGen,
|
|
798
|
+
lock
|
|
799
|
+
});
|
|
800
|
+
if (compositePatch) {
|
|
801
|
+
newPatches.push(compositePatch);
|
|
802
|
+
}
|
|
684
803
|
continue;
|
|
685
804
|
}
|
|
686
805
|
let patchContent;
|
|
@@ -692,12 +811,31 @@ var ReplayDetector = class {
|
|
|
692
811
|
);
|
|
693
812
|
continue;
|
|
694
813
|
}
|
|
695
|
-
|
|
814
|
+
let contentHash = this.computeContentHash(patchContent);
|
|
696
815
|
if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
|
|
697
816
|
continue;
|
|
698
817
|
}
|
|
699
818
|
const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
|
|
700
|
-
const
|
|
819
|
+
const allFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
|
|
820
|
+
const sensitiveFiles = allFiles.filter((f) => isSensitiveFile(f));
|
|
821
|
+
const files = allFiles.filter((f) => !isSensitiveFile(f));
|
|
822
|
+
if (sensitiveFiles.length > 0) {
|
|
823
|
+
this.warnings.push(
|
|
824
|
+
`Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
|
|
825
|
+
);
|
|
826
|
+
if (files.length > 0) {
|
|
827
|
+
const parentSha = parents[0];
|
|
828
|
+
if (parentSha) {
|
|
829
|
+
try {
|
|
830
|
+
patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
|
|
831
|
+
} catch {
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
if (!patchContent.trim()) continue;
|
|
835
|
+
contentHash = this.computeContentHash(patchContent);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
}
|
|
701
839
|
if (files.length === 0) {
|
|
702
840
|
continue;
|
|
703
841
|
}
|
|
@@ -709,14 +847,16 @@ var ReplayDetector = class {
|
|
|
709
847
|
original_author: `${commit.authorName} <${commit.authorEmail}>`,
|
|
710
848
|
base_generation: lastGen.commit_sha,
|
|
711
849
|
files,
|
|
712
|
-
patch_content: patchContent
|
|
850
|
+
patch_content: patchContent,
|
|
851
|
+
...this.isCreationOnlyPatch(patchContent) ? { user_owned: true } : {}
|
|
713
852
|
});
|
|
714
853
|
}
|
|
715
|
-
|
|
854
|
+
const survivingPatches = await this.collapseNetZeroFiles(newPatches);
|
|
855
|
+
survivingPatches.reverse();
|
|
716
856
|
const revertedPatchIdSet = /* @__PURE__ */ new Set();
|
|
717
857
|
const revertIndicesToRemove = /* @__PURE__ */ new Set();
|
|
718
|
-
for (let i = 0; i <
|
|
719
|
-
const patch =
|
|
858
|
+
for (let i = 0; i < survivingPatches.length; i++) {
|
|
859
|
+
const patch = survivingPatches[i];
|
|
720
860
|
if (!isRevertCommit(patch.original_message)) continue;
|
|
721
861
|
let body = "";
|
|
722
862
|
try {
|
|
@@ -745,7 +885,7 @@ var ReplayDetector = class {
|
|
|
745
885
|
if (matchedExisting) continue;
|
|
746
886
|
let matchedNew = false;
|
|
747
887
|
if (revertedSha) {
|
|
748
|
-
const idx =
|
|
888
|
+
const idx = survivingPatches.findIndex(
|
|
749
889
|
(p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_commit === revertedSha
|
|
750
890
|
);
|
|
751
891
|
if (idx !== -1) {
|
|
@@ -755,7 +895,7 @@ var ReplayDetector = class {
|
|
|
755
895
|
}
|
|
756
896
|
}
|
|
757
897
|
if (!matchedNew && revertedMessage) {
|
|
758
|
-
const idx =
|
|
898
|
+
const idx = survivingPatches.findIndex(
|
|
759
899
|
(p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_message === revertedMessage
|
|
760
900
|
);
|
|
761
901
|
if (idx !== -1) {
|
|
@@ -767,18 +907,155 @@ var ReplayDetector = class {
|
|
|
767
907
|
revertIndicesToRemove.add(i);
|
|
768
908
|
}
|
|
769
909
|
}
|
|
770
|
-
const filteredPatches =
|
|
910
|
+
const filteredPatches = survivingPatches.filter((_, i) => !revertIndicesToRemove.has(i));
|
|
771
911
|
return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
|
|
772
912
|
}
|
|
773
913
|
/**
|
|
774
914
|
* Compute content hash for deduplication.
|
|
775
|
-
*
|
|
776
|
-
*
|
|
915
|
+
*
|
|
916
|
+
* Produces a format-agnostic hash so both `git format-patch` output
|
|
917
|
+
* (email-wrapped) and plain `git diff` output hash to the same value
|
|
918
|
+
* when the underlying diff hunks are identical.
|
|
919
|
+
*
|
|
920
|
+
* Normalization:
|
|
921
|
+
* 1. Strip email wrapper: everything before the first `diff --git` line
|
|
922
|
+
* (From:, Subject:, Date:, diffstat, blank separators).
|
|
923
|
+
* 2. Strip `index` lines (blob SHA pairs change across rebases).
|
|
924
|
+
* 3. Strip trailing git version marker (`-- \n<version>`).
|
|
925
|
+
*
|
|
926
|
+
* This ensures content hashes match across the no-patches and normal-
|
|
927
|
+
* regeneration flows, which store formatPatch and git-diff formats
|
|
928
|
+
* respectively (FER-9850, D5-D9).
|
|
777
929
|
*/
|
|
778
930
|
computeContentHash(patchContent) {
|
|
779
|
-
const
|
|
931
|
+
const lines = patchContent.split("\n");
|
|
932
|
+
const diffStart = lines.findIndex((l) => l.startsWith("diff --git "));
|
|
933
|
+
const relevantLines = diffStart > 0 ? lines.slice(diffStart) : lines;
|
|
934
|
+
const normalized = relevantLines.filter((line) => !line.startsWith("index ")).join("\n").replace(/\n-- \n[\d]+\.[\d]+[^\n]*\s*$/, "");
|
|
780
935
|
return `sha256:${(0, import_node_crypto.createHash)("sha256").update(normalized).digest("hex")}`;
|
|
781
936
|
}
|
|
937
|
+
/**
|
|
938
|
+
* FER-9805 — Drop patches whose files were transiently created and then
|
|
939
|
+
* deleted within the current linear detection window, and are absent from
|
|
940
|
+
* HEAD at the end of the window.
|
|
941
|
+
*
|
|
942
|
+
* Rationale: on a merge commit, createMergeCompositePatch already diffs
|
|
943
|
+
* firstParent..mergeCommit so net-zero files never enter the composite. On
|
|
944
|
+
* linear history each commit becomes its own patch, so a file that was
|
|
945
|
+
* briefly added and later removed survives as a create+delete pair whose
|
|
946
|
+
* cumulative diff is empty. We drop such patches before they reach the
|
|
947
|
+
* lockfile.
|
|
948
|
+
*
|
|
949
|
+
* A file is "transient" when:
|
|
950
|
+
* 1. some patch in the window sets `new file mode` for it (a creation), AND
|
|
951
|
+
* 2. some patch in the window sets `deleted file mode` for it (a deletion), AND
|
|
952
|
+
* 3. it is absent from HEAD's tree.
|
|
953
|
+
*
|
|
954
|
+
* The HEAD guard (#3) prevents false positives when a file is deleted
|
|
955
|
+
* and then recreated with different content — the cumulative diff of
|
|
956
|
+
* such a pair is non-empty and must be preserved.
|
|
957
|
+
*
|
|
958
|
+
* This only drops patches whose `files` are a subset of the transient
|
|
959
|
+
* set. A partial-transient patch (one that touches a transient file
|
|
960
|
+
* alongside a persistent one) is left unmodified; surgical stripping of
|
|
961
|
+
* single files from a multi-file patch would require a follow-up that
|
|
962
|
+
* regenerates the patch content via `git format-patch -- <files>`. No
|
|
963
|
+
* current failing test exercises this, and leaving the patch intact
|
|
964
|
+
* preserves today's behaviour. TODO(FER-9805): revisit if a future
|
|
965
|
+
* adversarial test demands per-file stripping.
|
|
966
|
+
*/
|
|
967
|
+
async collapseNetZeroFiles(patches) {
|
|
968
|
+
if (patches.length === 0) return patches;
|
|
969
|
+
const stateByFile = /* @__PURE__ */ new Map();
|
|
970
|
+
for (const patch of patches) {
|
|
971
|
+
for (const header of parsePatchFileHeaders(patch.patch_content)) {
|
|
972
|
+
if (!header.isCreate && !header.isDelete) continue;
|
|
973
|
+
const prior = stateByFile.get(header.path) ?? { created: false, deleted: false };
|
|
974
|
+
if (header.isCreate) prior.created = true;
|
|
975
|
+
if (header.isDelete) prior.deleted = true;
|
|
976
|
+
stateByFile.set(header.path, prior);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
let anyCandidate = false;
|
|
980
|
+
for (const state of stateByFile.values()) {
|
|
981
|
+
if (state.created && state.deleted) {
|
|
982
|
+
anyCandidate = true;
|
|
983
|
+
break;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
if (!anyCandidate) return patches;
|
|
987
|
+
const headFiles = await this.listHeadFiles();
|
|
988
|
+
const transient = /* @__PURE__ */ new Set();
|
|
989
|
+
for (const [file, state] of stateByFile) {
|
|
990
|
+
if (state.created && state.deleted && !headFiles.has(file)) {
|
|
991
|
+
transient.add(file);
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
if (transient.size === 0) return patches;
|
|
995
|
+
return patches.filter((patch) => !patch.files.every((f) => transient.has(f)));
|
|
996
|
+
}
|
|
997
|
+
/**
|
|
998
|
+
* List every tracked path at HEAD (one `git ls-tree -r` invocation).
|
|
999
|
+
* Returns an empty Set if HEAD is unborn or the invocation fails — the
|
|
1000
|
+
* caller then treats every candidate as "not in HEAD" and may collapse
|
|
1001
|
+
* more aggressively. On typical SDK repos this is a single sub-10ms call.
|
|
1002
|
+
*/
|
|
1003
|
+
async listHeadFiles() {
|
|
1004
|
+
try {
|
|
1005
|
+
const out = await this.git.exec(["ls-tree", "-r", "--name-only", "HEAD"]);
|
|
1006
|
+
return new Set(out.split("\n").map((l) => l.trim()).filter(Boolean));
|
|
1007
|
+
} catch {
|
|
1008
|
+
return /* @__PURE__ */ new Set();
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
/**
|
|
1012
|
+
* Create a single composite patch for a merge commit by diffing the merge
|
|
1013
|
+
* commit against its first parent. This captures the net change of the
|
|
1014
|
+
* entire merged branch as one patch, eliminating accumulator chaining and
|
|
1015
|
+
* zero-net-change ghost conflicts.
|
|
1016
|
+
*/
|
|
1017
|
+
async createMergeCompositePatch(input) {
|
|
1018
|
+
const { mergeCommit, firstParent, lastGen, lock } = input;
|
|
1019
|
+
const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
|
|
1020
|
+
const filesOutput = await this.git.exec([
|
|
1021
|
+
"diff",
|
|
1022
|
+
"--name-only",
|
|
1023
|
+
firstParent,
|
|
1024
|
+
mergeCommit.sha
|
|
1025
|
+
]);
|
|
1026
|
+
const allMergeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
|
|
1027
|
+
const sensitiveMergeFiles = allMergeFiles.filter((f) => isSensitiveFile(f));
|
|
1028
|
+
const files = allMergeFiles.filter((f) => !isSensitiveFile(f));
|
|
1029
|
+
if (sensitiveMergeFiles.length > 0) {
|
|
1030
|
+
this.warnings.push(
|
|
1031
|
+
`Sensitive file(s) excluded from merge patch for commit ${mergeCommit.sha.slice(0, 7)}: ${sensitiveMergeFiles.join(", ")}. These files will not be tracked by replay.`
|
|
1032
|
+
);
|
|
1033
|
+
}
|
|
1034
|
+
if (files.length === 0) return null;
|
|
1035
|
+
const diff = await this.git.exec([
|
|
1036
|
+
"diff",
|
|
1037
|
+
firstParent,
|
|
1038
|
+
mergeCommit.sha,
|
|
1039
|
+
"--",
|
|
1040
|
+
...files
|
|
1041
|
+
]);
|
|
1042
|
+
if (!diff.trim()) return null;
|
|
1043
|
+
const contentHash = this.computeContentHash(diff);
|
|
1044
|
+
if (lock.patches.some((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
|
|
1045
|
+
return null;
|
|
1046
|
+
}
|
|
1047
|
+
return {
|
|
1048
|
+
id: `patch-merge-${mergeCommit.sha.slice(0, 8)}`,
|
|
1049
|
+
content_hash: contentHash,
|
|
1050
|
+
original_commit: mergeCommit.sha,
|
|
1051
|
+
original_message: mergeCommit.message,
|
|
1052
|
+
original_author: `${mergeCommit.authorName} <${mergeCommit.authorEmail}>`,
|
|
1053
|
+
base_generation: lastGen.commit_sha,
|
|
1054
|
+
files,
|
|
1055
|
+
patch_content: diff,
|
|
1056
|
+
...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
782
1059
|
/**
|
|
783
1060
|
* Detect patches via tree diff for non-linear history. Returns a composite patch.
|
|
784
1061
|
* Revert reconciliation is skipped here because tree-diff produces a single composite
|
|
@@ -790,7 +1067,14 @@ var ReplayDetector = class {
|
|
|
790
1067
|
return this.detectPatchesViaCommitScan();
|
|
791
1068
|
}
|
|
792
1069
|
const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
|
|
793
|
-
const
|
|
1070
|
+
const allTreeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
|
|
1071
|
+
const sensitiveTreeFiles = allTreeFiles.filter((f) => isSensitiveFile(f));
|
|
1072
|
+
const files = allTreeFiles.filter((f) => !isSensitiveFile(f));
|
|
1073
|
+
if (sensitiveTreeFiles.length > 0) {
|
|
1074
|
+
this.warnings.push(
|
|
1075
|
+
`Sensitive file(s) excluded from composite patch: ${sensitiveTreeFiles.join(", ")}. These files will not be tracked by replay.`
|
|
1076
|
+
);
|
|
1077
|
+
}
|
|
794
1078
|
if (files.length === 0) return { patches: [], revertedPatchIds: [] };
|
|
795
1079
|
const diff = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
|
|
796
1080
|
if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
|
|
@@ -810,7 +1094,8 @@ var ReplayDetector = class {
|
|
|
810
1094
|
// reference to find base file content. diffBase may be the tree_hash.
|
|
811
1095
|
base_generation: commitKnownMissing ? diffBase : lastGen.commit_sha,
|
|
812
1096
|
files,
|
|
813
|
-
patch_content: diff
|
|
1097
|
+
patch_content: diff,
|
|
1098
|
+
...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
|
|
814
1099
|
};
|
|
815
1100
|
return { patches: [compositePatch], revertedPatchIds: [] };
|
|
816
1101
|
}
|
|
@@ -857,7 +1142,7 @@ var ReplayDetector = class {
|
|
|
857
1142
|
} catch {
|
|
858
1143
|
continue;
|
|
859
1144
|
}
|
|
860
|
-
|
|
1145
|
+
let contentHash = this.computeContentHash(patchContent);
|
|
861
1146
|
if (existingHashes.has(contentHash) || forgottenHashes.has(contentHash)) {
|
|
862
1147
|
continue;
|
|
863
1148
|
}
|
|
@@ -865,7 +1150,26 @@ var ReplayDetector = class {
|
|
|
865
1150
|
continue;
|
|
866
1151
|
}
|
|
867
1152
|
const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
|
|
868
|
-
const
|
|
1153
|
+
const allScanFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f));
|
|
1154
|
+
const sensitiveScanFiles = allScanFiles.filter((f) => isSensitiveFile(f));
|
|
1155
|
+
const files = allScanFiles.filter((f) => !isSensitiveFile(f));
|
|
1156
|
+
if (sensitiveScanFiles.length > 0) {
|
|
1157
|
+
this.warnings.push(
|
|
1158
|
+
`Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveScanFiles.join(", ")}. These files will not be tracked by replay.`
|
|
1159
|
+
);
|
|
1160
|
+
if (files.length > 0) {
|
|
1161
|
+
if (parents.length === 0) {
|
|
1162
|
+
continue;
|
|
1163
|
+
}
|
|
1164
|
+
try {
|
|
1165
|
+
patchContent = await this.git.exec(["diff", parents[0], commit.sha, "--", ...files]);
|
|
1166
|
+
} catch {
|
|
1167
|
+
continue;
|
|
1168
|
+
}
|
|
1169
|
+
if (!patchContent.trim()) continue;
|
|
1170
|
+
contentHash = this.computeContentHash(patchContent);
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
869
1173
|
if (files.length === 0) {
|
|
870
1174
|
continue;
|
|
871
1175
|
}
|
|
@@ -1197,6 +1501,20 @@ var ReplayApplicator = class {
|
|
|
1197
1501
|
resetAccumulator() {
|
|
1198
1502
|
this.fileTheirsAccumulator.clear();
|
|
1199
1503
|
}
|
|
1504
|
+
/**
|
|
1505
|
+
* @internal Test-only.
|
|
1506
|
+
* Returns a defensive copy of the inter-patch accumulator. Used by the
|
|
1507
|
+
* adversarial test suite (FER-9791) to assert invariant I2: after every
|
|
1508
|
+
* applied patch, the accumulator has an entry for each file the patch
|
|
1509
|
+
* touched, and the entry's content matches on-disk.
|
|
1510
|
+
*/
|
|
1511
|
+
getAccumulatorSnapshot() {
|
|
1512
|
+
const snapshot = /* @__PURE__ */ new Map();
|
|
1513
|
+
for (const [path, entry] of this.fileTheirsAccumulator) {
|
|
1514
|
+
snapshot.set(path, { content: entry.content, baseGeneration: entry.baseGeneration });
|
|
1515
|
+
}
|
|
1516
|
+
return snapshot;
|
|
1517
|
+
}
|
|
1200
1518
|
/**
|
|
1201
1519
|
* Apply all patches, returning results for each.
|
|
1202
1520
|
* Skips patches that match exclude patterns in replay.yml
|
|
@@ -1262,7 +1580,7 @@ var ReplayApplicator = class {
|
|
|
1262
1580
|
const base = await this.git.showFile(baseGen.tree_hash, filePath);
|
|
1263
1581
|
const theirs = await this.applyPatchToContent(base, patch.patch_content, filePath, tempGit, tempDir);
|
|
1264
1582
|
const effectiveTheirs = theirs ?? await (0, import_promises.readFile)((0, import_node_path2.join)(this.outputDir, resolvedPath), "utf-8").catch(() => null);
|
|
1265
|
-
if (effectiveTheirs) {
|
|
1583
|
+
if (effectiveTheirs != null) {
|
|
1266
1584
|
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
1267
1585
|
content: effectiveTheirs,
|
|
1268
1586
|
baseGeneration: patch.base_generation
|
|
@@ -1286,16 +1604,20 @@ var ReplayApplicator = class {
|
|
|
1286
1604
|
return this.fileTheirsAccumulator.has(resolved);
|
|
1287
1605
|
})
|
|
1288
1606
|
).then((results) => results.some(Boolean));
|
|
1289
|
-
|
|
1607
|
+
const resolvedFiles = {};
|
|
1608
|
+
for (const filePath of patch.files) {
|
|
1609
|
+
const resolvedPath = baseGen ? await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash) : filePath;
|
|
1610
|
+
if (resolvedPath !== filePath) {
|
|
1611
|
+
resolvedFiles[filePath] = resolvedPath;
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
const patchIsRenameAware = /^rename from /m.test(patch.patch_content);
|
|
1615
|
+
const hasGeneratorRename = Object.keys(resolvedFiles).length > 0 && !patchIsRenameAware;
|
|
1616
|
+
if (!needsAccumulation && !hasGeneratorRename) {
|
|
1290
1617
|
const snapshots = /* @__PURE__ */ new Map();
|
|
1291
|
-
const resolvedFiles = {};
|
|
1292
1618
|
for (const filePath of patch.files) {
|
|
1293
|
-
const
|
|
1294
|
-
|
|
1295
|
-
resolvedFiles[filePath] = resolvedPath;
|
|
1296
|
-
}
|
|
1297
|
-
const fullPath = (0, import_node_path2.join)(this.outputDir, resolvedPath);
|
|
1298
|
-
snapshots.set(resolvedPath, await (0, import_promises.readFile)(fullPath, "utf-8").catch(() => null));
|
|
1619
|
+
const fullPath = (0, import_node_path2.join)(this.outputDir, filePath);
|
|
1620
|
+
snapshots.set(filePath, await (0, import_promises.readFile)(fullPath, "utf-8").catch(() => null));
|
|
1299
1621
|
}
|
|
1300
1622
|
try {
|
|
1301
1623
|
await this.git.execWithInput(["apply", "--3way"], patch.patch_content);
|
|
@@ -1307,8 +1629,8 @@ var ReplayApplicator = class {
|
|
|
1307
1629
|
...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
|
|
1308
1630
|
};
|
|
1309
1631
|
} catch {
|
|
1310
|
-
for (const [
|
|
1311
|
-
const fullPath = (0, import_node_path2.join)(this.outputDir,
|
|
1632
|
+
for (const [filePath, content] of snapshots) {
|
|
1633
|
+
const fullPath = (0, import_node_path2.join)(this.outputDir, filePath);
|
|
1312
1634
|
if (content != null) {
|
|
1313
1635
|
await (0, import_promises.writeFile)(fullPath, content);
|
|
1314
1636
|
} else {
|
|
@@ -1428,7 +1750,7 @@ var ReplayApplicator = class {
|
|
|
1428
1750
|
}
|
|
1429
1751
|
let useAccumulatorAsMergeBase = false;
|
|
1430
1752
|
const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
|
|
1431
|
-
if (accumulatorEntry && (
|
|
1753
|
+
if (accumulatorEntry && (theirs == null || base == null)) {
|
|
1432
1754
|
theirs = await this.applyPatchToContent(
|
|
1433
1755
|
accumulatorEntry.content,
|
|
1434
1756
|
patch.patch_content,
|
|
@@ -1436,7 +1758,16 @@ var ReplayApplicator = class {
|
|
|
1436
1758
|
tempGit,
|
|
1437
1759
|
tempDir
|
|
1438
1760
|
);
|
|
1439
|
-
if (theirs) {
|
|
1761
|
+
if (theirs != null) {
|
|
1762
|
+
useAccumulatorAsMergeBase = true;
|
|
1763
|
+
} else if (await this.isPatchAlreadyApplied(
|
|
1764
|
+
patch.patch_content,
|
|
1765
|
+
filePath,
|
|
1766
|
+
accumulatorEntry.content,
|
|
1767
|
+
tempGit,
|
|
1768
|
+
tempDir
|
|
1769
|
+
)) {
|
|
1770
|
+
theirs = accumulatorEntry.content;
|
|
1440
1771
|
useAccumulatorAsMergeBase = true;
|
|
1441
1772
|
}
|
|
1442
1773
|
}
|
|
@@ -1453,7 +1784,7 @@ var ReplayApplicator = class {
|
|
|
1453
1784
|
}
|
|
1454
1785
|
let effective_theirs = theirs;
|
|
1455
1786
|
let baseMismatchSkipped = false;
|
|
1456
|
-
if (theirs && base && !useAccumulatorAsMergeBase) {
|
|
1787
|
+
if (theirs != null && base != null && !useAccumulatorAsMergeBase) {
|
|
1457
1788
|
if (accumulatorEntry && accumulatorEntry.baseGeneration === patch.base_generation) {
|
|
1458
1789
|
try {
|
|
1459
1790
|
const preMerged = threeWayMerge(base, accumulatorEntry.content, theirs);
|
|
@@ -1469,7 +1800,7 @@ var ReplayApplicator = class {
|
|
|
1469
1800
|
baseMismatchSkipped = true;
|
|
1470
1801
|
}
|
|
1471
1802
|
}
|
|
1472
|
-
if (base == null &&
|
|
1803
|
+
if (base == null && ours == null && effective_theirs != null) {
|
|
1473
1804
|
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
1474
1805
|
content: effective_theirs,
|
|
1475
1806
|
baseGeneration: patch.base_generation
|
|
@@ -1479,7 +1810,7 @@ var ReplayApplicator = class {
|
|
|
1479
1810
|
await (0, import_promises.writeFile)(oursPath, effective_theirs);
|
|
1480
1811
|
return { file: resolvedPath, status: "merged", reason: "new-file" };
|
|
1481
1812
|
}
|
|
1482
|
-
if (base == null && ours && effective_theirs && !useAccumulatorAsMergeBase) {
|
|
1813
|
+
if (base == null && ours != null && effective_theirs != null && !useAccumulatorAsMergeBase) {
|
|
1483
1814
|
const merged2 = threeWayMerge("", ours, effective_theirs);
|
|
1484
1815
|
const outDir2 = (0, import_node_path2.dirname)(oursPath);
|
|
1485
1816
|
await (0, import_promises.mkdir)(outDir2, { recursive: true });
|
|
@@ -1493,16 +1824,20 @@ var ReplayApplicator = class {
|
|
|
1493
1824
|
conflictMetadata: metadata
|
|
1494
1825
|
};
|
|
1495
1826
|
}
|
|
1827
|
+
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
1828
|
+
content: merged2.content,
|
|
1829
|
+
baseGeneration: patch.base_generation
|
|
1830
|
+
});
|
|
1496
1831
|
return { file: resolvedPath, status: "merged" };
|
|
1497
1832
|
}
|
|
1498
|
-
if (
|
|
1833
|
+
if (effective_theirs == null) {
|
|
1499
1834
|
return {
|
|
1500
1835
|
file: resolvedPath,
|
|
1501
1836
|
status: "skipped",
|
|
1502
1837
|
reason: "missing-content"
|
|
1503
1838
|
};
|
|
1504
1839
|
}
|
|
1505
|
-
if (base == null && !useAccumulatorAsMergeBase ||
|
|
1840
|
+
if (base == null && !useAccumulatorAsMergeBase || ours == null) {
|
|
1506
1841
|
return {
|
|
1507
1842
|
file: resolvedPath,
|
|
1508
1843
|
status: "skipped",
|
|
@@ -1521,7 +1856,7 @@ var ReplayApplicator = class {
|
|
|
1521
1856
|
const outDir = (0, import_node_path2.dirname)(oursPath);
|
|
1522
1857
|
await (0, import_promises.mkdir)(outDir, { recursive: true });
|
|
1523
1858
|
await (0, import_promises.writeFile)(oursPath, merged.content);
|
|
1524
|
-
if (effective_theirs && !merged.hasConflicts) {
|
|
1859
|
+
if (effective_theirs != null && !merged.hasConflicts) {
|
|
1525
1860
|
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
1526
1861
|
content: effective_theirs,
|
|
1527
1862
|
baseGeneration: patch.base_generation
|
|
@@ -1619,6 +1954,28 @@ var ReplayApplicator = class {
|
|
|
1619
1954
|
return null;
|
|
1620
1955
|
}
|
|
1621
1956
|
}
|
|
1957
|
+
/**
|
|
1958
|
+
* Detects whether a patch's additions are already present in `content`.
|
|
1959
|
+
* Uses `git apply --reverse --check` — if the reverse patch applies cleanly,
|
|
1960
|
+
* the forward patch is effectively a no-op (its +lines are already there and
|
|
1961
|
+
* its -lines are already gone). Used to distinguish "patch already applied"
|
|
1962
|
+
* from "patch base mismatch" after a forward-apply fails.
|
|
1963
|
+
*/
|
|
1964
|
+
async isPatchAlreadyApplied(patchContent, filePath, content, tempGit, tempDir) {
|
|
1965
|
+
const fileDiff = this.extractFileDiff(patchContent, filePath);
|
|
1966
|
+
if (!fileDiff) return false;
|
|
1967
|
+
try {
|
|
1968
|
+
const tempFilePath = (0, import_node_path2.join)(tempDir, filePath);
|
|
1969
|
+
await (0, import_promises.mkdir)((0, import_node_path2.dirname)(tempFilePath), { recursive: true });
|
|
1970
|
+
await (0, import_promises.writeFile)(tempFilePath, content);
|
|
1971
|
+
await tempGit.exec(["add", filePath]);
|
|
1972
|
+
await tempGit.exec(["commit", "-m", `check already-applied ${filePath}`, "--allow-empty"]);
|
|
1973
|
+
await tempGit.execWithInput(["apply", "--reverse", "--check", "--allow-empty"], fileDiff);
|
|
1974
|
+
return true;
|
|
1975
|
+
} catch {
|
|
1976
|
+
return false;
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1622
1979
|
extractFileDiff(patchContent, filePath) {
|
|
1623
1980
|
const lines = patchContent.split("\n");
|
|
1624
1981
|
const diffLines = [];
|
|
@@ -1787,6 +2144,33 @@ var ReplayService = class {
|
|
|
1787
2144
|
committer;
|
|
1788
2145
|
lockManager;
|
|
1789
2146
|
outputDir;
|
|
2147
|
+
/** @internal Test-only. Captures the last ReplayResult[] returned from applicator.applyPatches(). */
|
|
2148
|
+
_lastApplyResults = [];
|
|
2149
|
+
/**
|
|
2150
|
+
* Service-level warnings accumulated during a single runReplay() call.
|
|
2151
|
+
* Merged with `detector.warnings` into `ReplayReport.warnings` by each flow handler.
|
|
2152
|
+
* Populated by `isFileUserOwned` when a patch's base generation tree is unreachable
|
|
2153
|
+
* (shallow clone / aggressive `git gc --prune`) and we fall back to a conservative
|
|
2154
|
+
* heuristic. Cleared at the top of `runReplay()`.
|
|
2155
|
+
*/
|
|
2156
|
+
warnings = [];
|
|
2157
|
+
/**
|
|
2158
|
+
* @internal Test-only accessor.
|
|
2159
|
+
* Returns the ReplayApplicator instance used for the last run. Tests use this
|
|
2160
|
+
* to inspect the inter-patch accumulator after runReplay() completes (FER-9791, I2).
|
|
2161
|
+
*/
|
|
2162
|
+
get applicatorRef() {
|
|
2163
|
+
return this.applicator;
|
|
2164
|
+
}
|
|
2165
|
+
/**
|
|
2166
|
+
* @internal Test-only accessor.
|
|
2167
|
+
* Returns the ReplayResult[] from the most recent applyPatches() call. Tests use
|
|
2168
|
+
* this to filter applied-vs-conflicted-vs-absorbed patches for invariant assertions
|
|
2169
|
+
* (FER-9791). Returns an empty array if no patches have been applied yet.
|
|
2170
|
+
*/
|
|
2171
|
+
getLastResults() {
|
|
2172
|
+
return this._lastApplyResults;
|
|
2173
|
+
}
|
|
1790
2174
|
constructor(outputDir, _config) {
|
|
1791
2175
|
const git = new GitClient(outputDir);
|
|
1792
2176
|
this.git = git;
|
|
@@ -1797,6 +2181,7 @@ var ReplayService = class {
|
|
|
1797
2181
|
this.committer = new ReplayCommitter(git, outputDir);
|
|
1798
2182
|
}
|
|
1799
2183
|
async runReplay(options) {
|
|
2184
|
+
this.warnings = [];
|
|
1800
2185
|
if (options?.skipApplication) {
|
|
1801
2186
|
return this.handleSkipApplication(options);
|
|
1802
2187
|
}
|
|
@@ -1870,6 +2255,7 @@ var ReplayService = class {
|
|
|
1870
2255
|
);
|
|
1871
2256
|
}
|
|
1872
2257
|
this.lockManager.save();
|
|
2258
|
+
this.detector.warnings.push(...this.lockManager.warnings);
|
|
1873
2259
|
}
|
|
1874
2260
|
determineFlow() {
|
|
1875
2261
|
try {
|
|
@@ -1936,6 +2322,7 @@ var ReplayService = class {
|
|
|
1936
2322
|
}
|
|
1937
2323
|
this.lockManager.setReplaySkippedAt((/* @__PURE__ */ new Date()).toISOString());
|
|
1938
2324
|
this.lockManager.save();
|
|
2325
|
+
const credentialWarnings = [...this.lockManager.warnings];
|
|
1939
2326
|
if (!options?.stageOnly) {
|
|
1940
2327
|
await this.committer.commitReplay(0);
|
|
1941
2328
|
} else {
|
|
@@ -1947,13 +2334,15 @@ var ReplayService = class {
|
|
|
1947
2334
|
patchesApplied: 0,
|
|
1948
2335
|
patchesWithConflicts: 0,
|
|
1949
2336
|
patchesSkipped: 0,
|
|
1950
|
-
conflicts: []
|
|
2337
|
+
conflicts: [],
|
|
2338
|
+
warnings: credentialWarnings.length > 0 ? credentialWarnings : void 0
|
|
1951
2339
|
};
|
|
1952
2340
|
}
|
|
1953
2341
|
async handleNoPatchesRegeneration(options) {
|
|
1954
|
-
|
|
1955
|
-
const
|
|
2342
|
+
let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
|
|
2343
|
+
const detectorWarnings = [...this.detector.warnings];
|
|
1956
2344
|
if (options?.dryRun) {
|
|
2345
|
+
const dryRunWarnings = [...detectorWarnings, ...this.warnings];
|
|
1957
2346
|
return {
|
|
1958
2347
|
flow: "no-patches",
|
|
1959
2348
|
patchesDetected: newPatches.length,
|
|
@@ -1963,9 +2352,26 @@ var ReplayService = class {
|
|
|
1963
2352
|
patchesReverted: revertedPatchIds.length,
|
|
1964
2353
|
conflicts: [],
|
|
1965
2354
|
wouldApply: newPatches,
|
|
1966
|
-
warnings:
|
|
2355
|
+
warnings: dryRunWarnings.length > 0 ? dryRunWarnings : void 0
|
|
1967
2356
|
};
|
|
1968
2357
|
}
|
|
2358
|
+
{
|
|
2359
|
+
const preLock = this.lockManager.read();
|
|
2360
|
+
const preCurrentGen = preLock.current_generation;
|
|
2361
|
+
const uniqueFiles = [...new Set(newPatches.flatMap((p) => p.files))];
|
|
2362
|
+
const absorbedFiles = /* @__PURE__ */ new Set();
|
|
2363
|
+
for (const file of uniqueFiles) {
|
|
2364
|
+
const diff = await this.git.exec(["diff", preCurrentGen, "HEAD", "--", file]).catch(() => null);
|
|
2365
|
+
if (diff !== null && !diff.trim()) {
|
|
2366
|
+
absorbedFiles.add(file);
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
if (absorbedFiles.size > 0) {
|
|
2370
|
+
newPatches = newPatches.filter(
|
|
2371
|
+
(p) => p.files.length === 0 || !p.files.every((f) => absorbedFiles.has(f))
|
|
2372
|
+
);
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
1969
2375
|
for (const id of revertedPatchIds) {
|
|
1970
2376
|
try {
|
|
1971
2377
|
this.lockManager.removePatch(id);
|
|
@@ -1984,6 +2390,7 @@ var ReplayService = class {
|
|
|
1984
2390
|
let results = [];
|
|
1985
2391
|
if (newPatches.length > 0) {
|
|
1986
2392
|
results = await this.applicator.applyPatches(newPatches);
|
|
2393
|
+
this._lastApplyResults = results;
|
|
1987
2394
|
this.revertConflictingFiles(results);
|
|
1988
2395
|
for (const result of results) {
|
|
1989
2396
|
if (result.status === "conflict") {
|
|
@@ -1998,6 +2405,7 @@ var ReplayService = class {
|
|
|
1998
2405
|
}
|
|
1999
2406
|
}
|
|
2000
2407
|
this.lockManager.save();
|
|
2408
|
+
this.warnings.push(...this.lockManager.warnings);
|
|
2001
2409
|
if (newPatches.length > 0) {
|
|
2002
2410
|
if (!options?.stageOnly) {
|
|
2003
2411
|
const appliedCount = results.filter((r) => r.status === "applied").length;
|
|
@@ -2006,13 +2414,14 @@ var ReplayService = class {
|
|
|
2006
2414
|
await this.committer.stageAll();
|
|
2007
2415
|
}
|
|
2008
2416
|
}
|
|
2417
|
+
const warnings = [...detectorWarnings, ...this.warnings];
|
|
2009
2418
|
return this.buildReport("no-patches", newPatches, results, options, warnings, rebaseCounts, void 0, revertedPatchIds.length);
|
|
2010
2419
|
}
|
|
2011
2420
|
async handleNormalRegeneration(options) {
|
|
2012
2421
|
if (options?.dryRun) {
|
|
2013
2422
|
const existingPatches2 = this.lockManager.getPatches();
|
|
2014
2423
|
const { patches: newPatches2, revertedPatchIds: dryRunReverted } = await this.detector.detectNewPatches();
|
|
2015
|
-
const warnings2 = [...this.detector.warnings];
|
|
2424
|
+
const warnings2 = [...this.detector.warnings, ...this.warnings];
|
|
2016
2425
|
const allPatches2 = [...existingPatches2, ...newPatches2];
|
|
2017
2426
|
return {
|
|
2018
2427
|
flow: "normal-regeneration",
|
|
@@ -2044,7 +2453,7 @@ var ReplayService = class {
|
|
|
2044
2453
|
}
|
|
2045
2454
|
existingPatches = this.lockManager.getPatches();
|
|
2046
2455
|
let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
|
|
2047
|
-
const
|
|
2456
|
+
const detectorWarnings = [...this.detector.warnings];
|
|
2048
2457
|
if (removedByPreRebase.length > 0) {
|
|
2049
2458
|
const removedOriginalCommits = new Set(removedByPreRebase.map((p) => p.original_commit));
|
|
2050
2459
|
const removedOriginalMessages = new Set(removedByPreRebase.map((p) => p.original_message));
|
|
@@ -2076,6 +2485,7 @@ var ReplayService = class {
|
|
|
2076
2485
|
const genRecord = await this.committer.createGenerationRecord(commitOpts);
|
|
2077
2486
|
this.lockManager.addGeneration(genRecord);
|
|
2078
2487
|
const results = await this.applicator.applyPatches(allPatches);
|
|
2488
|
+
this._lastApplyResults = results;
|
|
2079
2489
|
this.revertConflictingFiles(results);
|
|
2080
2490
|
for (const result of results) {
|
|
2081
2491
|
if (result.status === "conflict") {
|
|
@@ -2097,12 +2507,14 @@ var ReplayService = class {
|
|
|
2097
2507
|
}
|
|
2098
2508
|
}
|
|
2099
2509
|
this.lockManager.save();
|
|
2510
|
+
this.warnings.push(...this.lockManager.warnings);
|
|
2100
2511
|
if (options?.stageOnly) {
|
|
2101
2512
|
await this.committer.stageAll();
|
|
2102
2513
|
} else {
|
|
2103
2514
|
const appliedCount = results.filter((r) => r.status === "applied").length;
|
|
2104
2515
|
await this.committer.commitReplay(appliedCount, allPatches);
|
|
2105
2516
|
}
|
|
2517
|
+
const warnings = [...detectorWarnings, ...this.warnings];
|
|
2106
2518
|
return this.buildReport(
|
|
2107
2519
|
"normal-regeneration",
|
|
2108
2520
|
allPatches,
|
|
@@ -2126,6 +2538,7 @@ var ReplayService = class {
|
|
|
2126
2538
|
let keptAsUserOwned = 0;
|
|
2127
2539
|
const seenContentHashes = /* @__PURE__ */ new Set();
|
|
2128
2540
|
const absorbedPatchIds = /* @__PURE__ */ new Set();
|
|
2541
|
+
const fernignorePatterns = this.readFernignorePatterns();
|
|
2129
2542
|
for (const result of results) {
|
|
2130
2543
|
if (result.resolvedFiles && Object.keys(result.resolvedFiles).length > 0) {
|
|
2131
2544
|
const patch = result.patch;
|
|
@@ -2146,34 +2559,78 @@ var ReplayService = class {
|
|
|
2146
2559
|
const patch = result.patch;
|
|
2147
2560
|
if (patch.base_generation === currentGenSha) continue;
|
|
2148
2561
|
try {
|
|
2149
|
-
const
|
|
2150
|
-
|
|
2562
|
+
const originalByResolved = {};
|
|
2563
|
+
if (result.resolvedFiles) {
|
|
2564
|
+
for (const [orig, resolved] of Object.entries(result.resolvedFiles)) {
|
|
2565
|
+
originalByResolved[resolved] = orig;
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
const isUserOwnedPerFile = await Promise.all(
|
|
2151
2569
|
patch.files.map(async (file) => {
|
|
2152
|
-
if (file === ".fernignore")
|
|
2570
|
+
if (file === ".fernignore") return true;
|
|
2571
|
+
if (fernignorePatterns.some((p) => file === p || (0, import_minimatch2.minimatch)(file, p))) {
|
|
2153
2572
|
return true;
|
|
2154
2573
|
}
|
|
2155
|
-
if (
|
|
2574
|
+
if (patch.user_owned) return true;
|
|
2575
|
+
if (this.fileIsCreationInPatchContent(patch.patch_content, originalByResolved[file] ?? file)) {
|
|
2156
2576
|
return true;
|
|
2157
2577
|
}
|
|
2158
2578
|
const content = await this.git.showFile(currentGenSha, file);
|
|
2159
2579
|
return content === null;
|
|
2160
2580
|
})
|
|
2161
2581
|
);
|
|
2162
|
-
const
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2582
|
+
const hasProtectedFiles = patch.files.some(
|
|
2583
|
+
(file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || (0, import_minimatch2.minimatch)(file, p))
|
|
2584
|
+
);
|
|
2585
|
+
const allUserOwned = isUserOwnedPerFile.every(Boolean);
|
|
2586
|
+
if (allUserOwned && patch.files.length > 0) {
|
|
2587
|
+
const baseChanged = patch.base_generation !== currentGenSha;
|
|
2588
|
+
const userDiff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
|
|
2589
|
+
if (userDiff != null && userDiff.trim()) {
|
|
2590
|
+
const newHash = this.detector.computeContentHash(userDiff);
|
|
2591
|
+
patch.patch_content = userDiff;
|
|
2592
|
+
patch.content_hash = newHash;
|
|
2593
|
+
}
|
|
2594
|
+
patch.base_generation = currentGenSha;
|
|
2595
|
+
try {
|
|
2596
|
+
this.lockManager.updatePatch(patch.id, {
|
|
2597
|
+
base_generation: currentGenSha,
|
|
2598
|
+
...!patch.user_owned ? { user_owned: true } : {},
|
|
2599
|
+
...userDiff != null && userDiff.trim() ? { patch_content: patch.patch_content, content_hash: patch.content_hash } : {}
|
|
2600
|
+
});
|
|
2601
|
+
} catch {
|
|
2602
|
+
}
|
|
2603
|
+
patch.user_owned = true;
|
|
2167
2604
|
keptAsUserOwned++;
|
|
2605
|
+
if (baseChanged) repointed++;
|
|
2168
2606
|
continue;
|
|
2169
2607
|
}
|
|
2170
2608
|
const diff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
|
|
2171
|
-
if (
|
|
2609
|
+
if (diff === null) continue;
|
|
2610
|
+
if (!diff.trim()) {
|
|
2611
|
+
if (hasProtectedFiles) {
|
|
2612
|
+
patch.base_generation = currentGenSha;
|
|
2613
|
+
try {
|
|
2614
|
+
this.lockManager.updatePatch(patch.id, { base_generation: currentGenSha });
|
|
2615
|
+
} catch {
|
|
2616
|
+
}
|
|
2617
|
+
keptAsUserOwned++;
|
|
2618
|
+
continue;
|
|
2619
|
+
}
|
|
2172
2620
|
this.lockManager.removePatch(patch.id);
|
|
2173
2621
|
absorbedPatchIds.add(patch.id);
|
|
2174
2622
|
absorbed++;
|
|
2175
2623
|
continue;
|
|
2176
2624
|
}
|
|
2625
|
+
if (hasProtectedFiles) {
|
|
2626
|
+
patch.base_generation = currentGenSha;
|
|
2627
|
+
try {
|
|
2628
|
+
this.lockManager.updatePatch(patch.id, { base_generation: currentGenSha });
|
|
2629
|
+
} catch {
|
|
2630
|
+
}
|
|
2631
|
+
keptAsUserOwned++;
|
|
2632
|
+
continue;
|
|
2633
|
+
}
|
|
2177
2634
|
const newContentHash = this.detector.computeContentHash(diff);
|
|
2178
2635
|
if (seenContentHashes.has(newContentHash)) {
|
|
2179
2636
|
this.lockManager.removePatch(patch.id);
|
|
@@ -2182,17 +2639,89 @@ var ReplayService = class {
|
|
|
2182
2639
|
continue;
|
|
2183
2640
|
}
|
|
2184
2641
|
seenContentHashes.add(newContentHash);
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2642
|
+
patch.base_generation = currentGenSha;
|
|
2643
|
+
patch.patch_content = diff;
|
|
2644
|
+
patch.content_hash = newContentHash;
|
|
2645
|
+
try {
|
|
2646
|
+
this.lockManager.updatePatch(patch.id, {
|
|
2647
|
+
base_generation: currentGenSha,
|
|
2648
|
+
patch_content: diff,
|
|
2649
|
+
content_hash: newContentHash
|
|
2650
|
+
});
|
|
2651
|
+
} catch {
|
|
2652
|
+
}
|
|
2190
2653
|
contentRebased++;
|
|
2191
2654
|
} catch {
|
|
2192
2655
|
}
|
|
2193
2656
|
}
|
|
2194
2657
|
return { absorbed, repointed, contentRebased, keptAsUserOwned, absorbedPatchIds };
|
|
2195
2658
|
}
|
|
2659
|
+
/**
|
|
2660
|
+
* Determine whether `file` is user-owned (never produced by the generator).
|
|
2661
|
+
*
|
|
2662
|
+
* Resolution order (first match wins):
|
|
2663
|
+
* 1. `patch.user_owned === true` — authoritative, persisted across cycles.
|
|
2664
|
+
* 2. `.fernignore` itself, or file matches a .fernignore pattern.
|
|
2665
|
+
* 3. Legacy-safe signal: `patch_content` shows this file as a `--- /dev/null`
|
|
2666
|
+
* creation. Works for patches that predate the `user_owned` field or
|
|
2667
|
+
* whose flag was lost. Immune to rename tricks — relies on raw patch text.
|
|
2668
|
+
* 4. Tree-based check against `patch.base_generation` when it is reachable
|
|
2669
|
+
* AND distinct from `currentGenSha`. If the base tree is unreachable
|
|
2670
|
+
* (shallow clone / aggressive `git gc --prune`), emit a one-time
|
|
2671
|
+
* customer-actionable warning (deduplicated via warnedGens) and
|
|
2672
|
+
* conservatively treat as user-owned — strictly safer than silent
|
|
2673
|
+
* absorption (FER-9809).
|
|
2674
|
+
* 5. Final fallback — check `currentGenSha` when there is no usable base
|
|
2675
|
+
* (legacy one-gen lockfile). Preserves pre-fix behavior for that edge.
|
|
2676
|
+
*
|
|
2677
|
+
* `originalFileName` is the pre-rename path when the generator moved the
|
|
2678
|
+
* file between the patch's base and the current generation. Tree lookups
|
|
2679
|
+
* at ancestor generations use the original path — that's where the
|
|
2680
|
+
* generator produced the content.
|
|
2681
|
+
*/
|
|
2682
|
+
async isFileUserOwned(file, patch, currentGenSha, fernignorePatterns, warnedGens, originalFileName) {
|
|
2683
|
+
if (patch.user_owned) return true;
|
|
2684
|
+
if (file === ".fernignore") return true;
|
|
2685
|
+
if (fernignorePatterns.some((p) => file === p || (0, import_minimatch2.minimatch)(file, p))) return true;
|
|
2686
|
+
if (this.fileIsCreationInPatchContent(patch.patch_content, originalFileName ?? file)) {
|
|
2687
|
+
return true;
|
|
2688
|
+
}
|
|
2689
|
+
const base = patch.base_generation;
|
|
2690
|
+
if (base && base !== currentGenSha) {
|
|
2691
|
+
const reachable = await this.git.commitExists(base) || await this.git.treeExists(base);
|
|
2692
|
+
if (!reachable) {
|
|
2693
|
+
if (!warnedGens.has(base)) {
|
|
2694
|
+
warnedGens.add(base);
|
|
2695
|
+
this.warnings.push(
|
|
2696
|
+
`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.`
|
|
2697
|
+
);
|
|
2698
|
+
}
|
|
2699
|
+
return true;
|
|
2700
|
+
}
|
|
2701
|
+
return await this.git.showFile(base, originalFileName ?? file) === null;
|
|
2702
|
+
}
|
|
2703
|
+
return await this.git.showFile(currentGenSha, originalFileName ?? file) === null;
|
|
2704
|
+
}
|
|
2705
|
+
/**
|
|
2706
|
+
* Returns true if `file`'s diff section in `patchContent` starts with
|
|
2707
|
+
* `--- /dev/null`, meaning this file was introduced by the patch (user
|
|
2708
|
+
* creation). Used as a legacy-safe fallback when `patch.user_owned` is
|
|
2709
|
+
* absent or cleared.
|
|
2710
|
+
*/
|
|
2711
|
+
fileIsCreationInPatchContent(patchContent, file) {
|
|
2712
|
+
const lines = patchContent.split("\n");
|
|
2713
|
+
let inFileSection = false;
|
|
2714
|
+
for (const line of lines) {
|
|
2715
|
+
if (line.startsWith("diff --git ")) {
|
|
2716
|
+
inFileSection = line.includes(` b/${file}`) || line.endsWith(` b/${file}`);
|
|
2717
|
+
continue;
|
|
2718
|
+
}
|
|
2719
|
+
if (inFileSection && line.startsWith("--- ")) {
|
|
2720
|
+
return line === "--- /dev/null";
|
|
2721
|
+
}
|
|
2722
|
+
}
|
|
2723
|
+
return false;
|
|
2724
|
+
}
|
|
2196
2725
|
/**
|
|
2197
2726
|
* For conflict patches with mixed results (some files merged, some conflicted),
|
|
2198
2727
|
* check if the cleanly merged files were absorbed by the generator (empty diff).
|
|
@@ -2206,13 +2735,14 @@ var ReplayService = class {
|
|
|
2206
2735
|
const cleanFiles = result.fileResults.filter((f) => f.status === "merged").map((f) => f.file);
|
|
2207
2736
|
if (cleanFiles.length === 0) return;
|
|
2208
2737
|
const patch = result.patch;
|
|
2738
|
+
if (patch.user_owned) return;
|
|
2209
2739
|
const fernignorePatterns = this.readFernignorePatterns();
|
|
2740
|
+
const warnedGens = /* @__PURE__ */ new Set();
|
|
2210
2741
|
const generatorCleanFiles = [];
|
|
2211
2742
|
for (const file of cleanFiles) {
|
|
2212
|
-
if (file
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
if (content === null) continue;
|
|
2743
|
+
if (await this.isFileUserOwned(file, patch, currentGenSha, fernignorePatterns, warnedGens)) {
|
|
2744
|
+
continue;
|
|
2745
|
+
}
|
|
2216
2746
|
generatorCleanFiles.push(file);
|
|
2217
2747
|
}
|
|
2218
2748
|
if (generatorCleanFiles.length === 0) return;
|
|
@@ -2249,7 +2779,35 @@ var ReplayService = class {
|
|
|
2249
2779
|
let conflictResolved = 0;
|
|
2250
2780
|
let conflictAbsorbed = 0;
|
|
2251
2781
|
let contentRefreshed = 0;
|
|
2782
|
+
const fernignorePatterns = this.readFernignorePatterns();
|
|
2783
|
+
const warnedGens = /* @__PURE__ */ new Set();
|
|
2784
|
+
const reachabilityCache = /* @__PURE__ */ new Map();
|
|
2785
|
+
const isReachable = async (sha) => {
|
|
2786
|
+
const cached = reachabilityCache.get(sha);
|
|
2787
|
+
if (cached !== void 0) return cached;
|
|
2788
|
+
const reachable = await this.git.commitExists(sha) || await this.git.treeExists(sha);
|
|
2789
|
+
reachabilityCache.set(sha, reachable);
|
|
2790
|
+
return reachable;
|
|
2791
|
+
};
|
|
2792
|
+
const allPatchFilesUserOwned = async (patch) => {
|
|
2793
|
+
if (patch.user_owned) return true;
|
|
2794
|
+
if (patch.files.length === 0) return false;
|
|
2795
|
+
for (const file of patch.files) {
|
|
2796
|
+
if (!await this.isFileUserOwned(file, patch, currentGen, fernignorePatterns, warnedGens)) {
|
|
2797
|
+
return false;
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2800
|
+
return true;
|
|
2801
|
+
};
|
|
2252
2802
|
for (const patch of patches) {
|
|
2803
|
+
if (patch.base_generation && patch.base_generation !== currentGen && !warnedGens.has(patch.base_generation)) {
|
|
2804
|
+
if (!await isReachable(patch.base_generation)) {
|
|
2805
|
+
warnedGens.add(patch.base_generation);
|
|
2806
|
+
this.warnings.push(
|
|
2807
|
+
`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.`
|
|
2808
|
+
);
|
|
2809
|
+
}
|
|
2810
|
+
}
|
|
2253
2811
|
if (patch.status != null) {
|
|
2254
2812
|
delete patch.status;
|
|
2255
2813
|
continue;
|
|
@@ -2259,6 +2817,7 @@ var ReplayService = class {
|
|
|
2259
2817
|
const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
|
|
2260
2818
|
if (diff === null) continue;
|
|
2261
2819
|
if (!diff.trim()) {
|
|
2820
|
+
if (await allPatchFilesUserOwned(patch)) continue;
|
|
2262
2821
|
this.lockManager.removePatch(patch.id);
|
|
2263
2822
|
contentRefreshed++;
|
|
2264
2823
|
continue;
|
|
@@ -2270,6 +2829,12 @@ var ReplayService = class {
|
|
|
2270
2829
|
if (hasStaleMarkers) {
|
|
2271
2830
|
continue;
|
|
2272
2831
|
}
|
|
2832
|
+
const isProtected = patch.files.some(
|
|
2833
|
+
(file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || (0, import_minimatch2.minimatch)(file, p))
|
|
2834
|
+
);
|
|
2835
|
+
if (isProtected) {
|
|
2836
|
+
continue;
|
|
2837
|
+
}
|
|
2273
2838
|
const newContentHash = this.detector.computeContentHash(diff);
|
|
2274
2839
|
if (newContentHash !== patch.content_hash) {
|
|
2275
2840
|
const filesOutput = await this.git.exec(["diff", "--name-only", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
|
|
@@ -2295,6 +2860,13 @@ var ReplayService = class {
|
|
|
2295
2860
|
const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
|
|
2296
2861
|
if (diff === null) continue;
|
|
2297
2862
|
if (!diff.trim()) {
|
|
2863
|
+
if (await allPatchFilesUserOwned(patch)) {
|
|
2864
|
+
try {
|
|
2865
|
+
this.lockManager.updatePatch(patch.id, { base_generation: currentGen });
|
|
2866
|
+
} catch {
|
|
2867
|
+
}
|
|
2868
|
+
continue;
|
|
2869
|
+
}
|
|
2298
2870
|
this.lockManager.removePatch(patch.id);
|
|
2299
2871
|
conflictAbsorbed++;
|
|
2300
2872
|
continue;
|
|
@@ -2806,7 +3378,10 @@ function ensureGitattributesEntries(outputDir) {
|
|
|
2806
3378
|
(0, import_node_fs4.writeFileSync)(gitattributesPath, content, "utf-8");
|
|
2807
3379
|
}
|
|
2808
3380
|
function computeContentHash(patchContent) {
|
|
2809
|
-
const
|
|
3381
|
+
const lines = patchContent.split("\n");
|
|
3382
|
+
const diffStart = lines.findIndex((l) => l.startsWith("diff --git "));
|
|
3383
|
+
const relevantLines = diffStart > 0 ? lines.slice(diffStart) : lines;
|
|
3384
|
+
const normalized = relevantLines.filter((line) => !line.startsWith("index ")).join("\n").replace(/\n-- \n[\d]+\.[\d]+[^\n]*\s*$/, "");
|
|
2810
3385
|
return `sha256:${(0, import_node_crypto3.createHash)("sha256").update(normalized).digest("hex")}`;
|
|
2811
3386
|
}
|
|
2812
3387
|
|
|
@@ -3118,6 +3693,7 @@ function status(outputDir) {
|
|
|
3118
3693
|
}
|
|
3119
3694
|
// Annotate the CommonJS export names for ESM import in node:
|
|
3120
3695
|
0 && (module.exports = {
|
|
3696
|
+
CREDENTIAL_PATTERNS,
|
|
3121
3697
|
FERN_BOT_EMAIL,
|
|
3122
3698
|
FERN_BOT_LOGIN,
|
|
3123
3699
|
FERN_BOT_NAME,
|
|
@@ -3129,15 +3705,18 @@ function status(outputDir) {
|
|
|
3129
3705
|
ReplayCommitter,
|
|
3130
3706
|
ReplayDetector,
|
|
3131
3707
|
ReplayService,
|
|
3708
|
+
SENSITIVE_FILE_PATTERNS,
|
|
3132
3709
|
bootstrap,
|
|
3133
3710
|
forget,
|
|
3134
3711
|
isGenerationCommit,
|
|
3135
3712
|
isReplayCommit,
|
|
3136
3713
|
isRevertCommit,
|
|
3714
|
+
isSensitiveFile,
|
|
3137
3715
|
parseRevertedMessage,
|
|
3138
3716
|
parseRevertedSha,
|
|
3139
3717
|
reset,
|
|
3140
3718
|
resolve,
|
|
3719
|
+
scanForCredentials,
|
|
3141
3720
|
status,
|
|
3142
3721
|
threeWayMerge
|
|
3143
3722
|
});
|