@fern-api/replay 0.10.4 → 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 +702 -66
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +594 -65
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +170 -4
- package/dist/index.d.ts +170 -4
- package/dist/index.js +588 -65
- 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;
|
|
@@ -702,12 +811,31 @@ var ReplayDetector = class {
|
|
|
702
811
|
);
|
|
703
812
|
continue;
|
|
704
813
|
}
|
|
705
|
-
|
|
814
|
+
let contentHash = this.computeContentHash(patchContent);
|
|
706
815
|
if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
|
|
707
816
|
continue;
|
|
708
817
|
}
|
|
709
818
|
const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
|
|
710
|
-
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
|
+
}
|
|
711
839
|
if (files.length === 0) {
|
|
712
840
|
continue;
|
|
713
841
|
}
|
|
@@ -719,14 +847,16 @@ var ReplayDetector = class {
|
|
|
719
847
|
original_author: `${commit.authorName} <${commit.authorEmail}>`,
|
|
720
848
|
base_generation: lastGen.commit_sha,
|
|
721
849
|
files,
|
|
722
|
-
patch_content: patchContent
|
|
850
|
+
patch_content: patchContent,
|
|
851
|
+
...this.isCreationOnlyPatch(patchContent) ? { user_owned: true } : {}
|
|
723
852
|
});
|
|
724
853
|
}
|
|
725
|
-
|
|
854
|
+
const survivingPatches = await this.collapseNetZeroFiles(newPatches);
|
|
855
|
+
survivingPatches.reverse();
|
|
726
856
|
const revertedPatchIdSet = /* @__PURE__ */ new Set();
|
|
727
857
|
const revertIndicesToRemove = /* @__PURE__ */ new Set();
|
|
728
|
-
for (let i = 0; i <
|
|
729
|
-
const patch =
|
|
858
|
+
for (let i = 0; i < survivingPatches.length; i++) {
|
|
859
|
+
const patch = survivingPatches[i];
|
|
730
860
|
if (!isRevertCommit(patch.original_message)) continue;
|
|
731
861
|
let body = "";
|
|
732
862
|
try {
|
|
@@ -755,7 +885,7 @@ var ReplayDetector = class {
|
|
|
755
885
|
if (matchedExisting) continue;
|
|
756
886
|
let matchedNew = false;
|
|
757
887
|
if (revertedSha) {
|
|
758
|
-
const idx =
|
|
888
|
+
const idx = survivingPatches.findIndex(
|
|
759
889
|
(p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_commit === revertedSha
|
|
760
890
|
);
|
|
761
891
|
if (idx !== -1) {
|
|
@@ -765,7 +895,7 @@ var ReplayDetector = class {
|
|
|
765
895
|
}
|
|
766
896
|
}
|
|
767
897
|
if (!matchedNew && revertedMessage) {
|
|
768
|
-
const idx =
|
|
898
|
+
const idx = survivingPatches.findIndex(
|
|
769
899
|
(p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_message === revertedMessage
|
|
770
900
|
);
|
|
771
901
|
if (idx !== -1) {
|
|
@@ -777,18 +907,107 @@ var ReplayDetector = class {
|
|
|
777
907
|
revertIndicesToRemove.add(i);
|
|
778
908
|
}
|
|
779
909
|
}
|
|
780
|
-
const filteredPatches =
|
|
910
|
+
const filteredPatches = survivingPatches.filter((_, i) => !revertIndicesToRemove.has(i));
|
|
781
911
|
return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
|
|
782
912
|
}
|
|
783
913
|
/**
|
|
784
914
|
* Compute content hash for deduplication.
|
|
785
|
-
*
|
|
786
|
-
*
|
|
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).
|
|
787
929
|
*/
|
|
788
930
|
computeContentHash(patchContent) {
|
|
789
|
-
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*$/, "");
|
|
790
935
|
return `sha256:${(0, import_node_crypto.createHash)("sha256").update(normalized).digest("hex")}`;
|
|
791
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
|
+
}
|
|
792
1011
|
/**
|
|
793
1012
|
* Create a single composite patch for a merge commit by diffing the merge
|
|
794
1013
|
* commit against its first parent. This captures the net change of the
|
|
@@ -804,7 +1023,14 @@ var ReplayDetector = class {
|
|
|
804
1023
|
firstParent,
|
|
805
1024
|
mergeCommit.sha
|
|
806
1025
|
]);
|
|
807
|
-
const
|
|
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
|
+
}
|
|
808
1034
|
if (files.length === 0) return null;
|
|
809
1035
|
const diff = await this.git.exec([
|
|
810
1036
|
"diff",
|
|
@@ -826,7 +1052,8 @@ var ReplayDetector = class {
|
|
|
826
1052
|
original_author: `${mergeCommit.authorName} <${mergeCommit.authorEmail}>`,
|
|
827
1053
|
base_generation: lastGen.commit_sha,
|
|
828
1054
|
files,
|
|
829
|
-
patch_content: diff
|
|
1055
|
+
patch_content: diff,
|
|
1056
|
+
...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
|
|
830
1057
|
};
|
|
831
1058
|
}
|
|
832
1059
|
/**
|
|
@@ -840,7 +1067,14 @@ var ReplayDetector = class {
|
|
|
840
1067
|
return this.detectPatchesViaCommitScan();
|
|
841
1068
|
}
|
|
842
1069
|
const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
|
|
843
|
-
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
|
+
}
|
|
844
1078
|
if (files.length === 0) return { patches: [], revertedPatchIds: [] };
|
|
845
1079
|
const diff = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
|
|
846
1080
|
if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
|
|
@@ -860,7 +1094,8 @@ var ReplayDetector = class {
|
|
|
860
1094
|
// reference to find base file content. diffBase may be the tree_hash.
|
|
861
1095
|
base_generation: commitKnownMissing ? diffBase : lastGen.commit_sha,
|
|
862
1096
|
files,
|
|
863
|
-
patch_content: diff
|
|
1097
|
+
patch_content: diff,
|
|
1098
|
+
...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
|
|
864
1099
|
};
|
|
865
1100
|
return { patches: [compositePatch], revertedPatchIds: [] };
|
|
866
1101
|
}
|
|
@@ -907,7 +1142,7 @@ var ReplayDetector = class {
|
|
|
907
1142
|
} catch {
|
|
908
1143
|
continue;
|
|
909
1144
|
}
|
|
910
|
-
|
|
1145
|
+
let contentHash = this.computeContentHash(patchContent);
|
|
911
1146
|
if (existingHashes.has(contentHash) || forgottenHashes.has(contentHash)) {
|
|
912
1147
|
continue;
|
|
913
1148
|
}
|
|
@@ -915,7 +1150,26 @@ var ReplayDetector = class {
|
|
|
915
1150
|
continue;
|
|
916
1151
|
}
|
|
917
1152
|
const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
|
|
918
|
-
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
|
+
}
|
|
919
1173
|
if (files.length === 0) {
|
|
920
1174
|
continue;
|
|
921
1175
|
}
|
|
@@ -1247,6 +1501,20 @@ var ReplayApplicator = class {
|
|
|
1247
1501
|
resetAccumulator() {
|
|
1248
1502
|
this.fileTheirsAccumulator.clear();
|
|
1249
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
|
+
}
|
|
1250
1518
|
/**
|
|
1251
1519
|
* Apply all patches, returning results for each.
|
|
1252
1520
|
* Skips patches that match exclude patterns in replay.yml
|
|
@@ -1312,7 +1580,7 @@ var ReplayApplicator = class {
|
|
|
1312
1580
|
const base = await this.git.showFile(baseGen.tree_hash, filePath);
|
|
1313
1581
|
const theirs = await this.applyPatchToContent(base, patch.patch_content, filePath, tempGit, tempDir);
|
|
1314
1582
|
const effectiveTheirs = theirs ?? await (0, import_promises.readFile)((0, import_node_path2.join)(this.outputDir, resolvedPath), "utf-8").catch(() => null);
|
|
1315
|
-
if (effectiveTheirs) {
|
|
1583
|
+
if (effectiveTheirs != null) {
|
|
1316
1584
|
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
1317
1585
|
content: effectiveTheirs,
|
|
1318
1586
|
baseGeneration: patch.base_generation
|
|
@@ -1336,16 +1604,20 @@ var ReplayApplicator = class {
|
|
|
1336
1604
|
return this.fileTheirsAccumulator.has(resolved);
|
|
1337
1605
|
})
|
|
1338
1606
|
).then((results) => results.some(Boolean));
|
|
1339
|
-
|
|
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) {
|
|
1340
1617
|
const snapshots = /* @__PURE__ */ new Map();
|
|
1341
|
-
const resolvedFiles = {};
|
|
1342
1618
|
for (const filePath of patch.files) {
|
|
1343
|
-
const
|
|
1344
|
-
|
|
1345
|
-
resolvedFiles[filePath] = resolvedPath;
|
|
1346
|
-
}
|
|
1347
|
-
const fullPath = (0, import_node_path2.join)(this.outputDir, resolvedPath);
|
|
1348
|
-
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));
|
|
1349
1621
|
}
|
|
1350
1622
|
try {
|
|
1351
1623
|
await this.git.execWithInput(["apply", "--3way"], patch.patch_content);
|
|
@@ -1357,8 +1629,8 @@ var ReplayApplicator = class {
|
|
|
1357
1629
|
...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
|
|
1358
1630
|
};
|
|
1359
1631
|
} catch {
|
|
1360
|
-
for (const [
|
|
1361
|
-
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);
|
|
1362
1634
|
if (content != null) {
|
|
1363
1635
|
await (0, import_promises.writeFile)(fullPath, content);
|
|
1364
1636
|
} else {
|
|
@@ -1478,7 +1750,7 @@ var ReplayApplicator = class {
|
|
|
1478
1750
|
}
|
|
1479
1751
|
let useAccumulatorAsMergeBase = false;
|
|
1480
1752
|
const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
|
|
1481
|
-
if (accumulatorEntry && (
|
|
1753
|
+
if (accumulatorEntry && (theirs == null || base == null)) {
|
|
1482
1754
|
theirs = await this.applyPatchToContent(
|
|
1483
1755
|
accumulatorEntry.content,
|
|
1484
1756
|
patch.patch_content,
|
|
@@ -1486,7 +1758,16 @@ var ReplayApplicator = class {
|
|
|
1486
1758
|
tempGit,
|
|
1487
1759
|
tempDir
|
|
1488
1760
|
);
|
|
1489
|
-
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;
|
|
1490
1771
|
useAccumulatorAsMergeBase = true;
|
|
1491
1772
|
}
|
|
1492
1773
|
}
|
|
@@ -1503,7 +1784,7 @@ var ReplayApplicator = class {
|
|
|
1503
1784
|
}
|
|
1504
1785
|
let effective_theirs = theirs;
|
|
1505
1786
|
let baseMismatchSkipped = false;
|
|
1506
|
-
if (theirs && base && !useAccumulatorAsMergeBase) {
|
|
1787
|
+
if (theirs != null && base != null && !useAccumulatorAsMergeBase) {
|
|
1507
1788
|
if (accumulatorEntry && accumulatorEntry.baseGeneration === patch.base_generation) {
|
|
1508
1789
|
try {
|
|
1509
1790
|
const preMerged = threeWayMerge(base, accumulatorEntry.content, theirs);
|
|
@@ -1519,7 +1800,7 @@ var ReplayApplicator = class {
|
|
|
1519
1800
|
baseMismatchSkipped = true;
|
|
1520
1801
|
}
|
|
1521
1802
|
}
|
|
1522
|
-
if (base == null &&
|
|
1803
|
+
if (base == null && ours == null && effective_theirs != null) {
|
|
1523
1804
|
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
1524
1805
|
content: effective_theirs,
|
|
1525
1806
|
baseGeneration: patch.base_generation
|
|
@@ -1529,7 +1810,7 @@ var ReplayApplicator = class {
|
|
|
1529
1810
|
await (0, import_promises.writeFile)(oursPath, effective_theirs);
|
|
1530
1811
|
return { file: resolvedPath, status: "merged", reason: "new-file" };
|
|
1531
1812
|
}
|
|
1532
|
-
if (base == null && ours && effective_theirs && !useAccumulatorAsMergeBase) {
|
|
1813
|
+
if (base == null && ours != null && effective_theirs != null && !useAccumulatorAsMergeBase) {
|
|
1533
1814
|
const merged2 = threeWayMerge("", ours, effective_theirs);
|
|
1534
1815
|
const outDir2 = (0, import_node_path2.dirname)(oursPath);
|
|
1535
1816
|
await (0, import_promises.mkdir)(outDir2, { recursive: true });
|
|
@@ -1543,16 +1824,20 @@ var ReplayApplicator = class {
|
|
|
1543
1824
|
conflictMetadata: metadata
|
|
1544
1825
|
};
|
|
1545
1826
|
}
|
|
1827
|
+
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
1828
|
+
content: merged2.content,
|
|
1829
|
+
baseGeneration: patch.base_generation
|
|
1830
|
+
});
|
|
1546
1831
|
return { file: resolvedPath, status: "merged" };
|
|
1547
1832
|
}
|
|
1548
|
-
if (
|
|
1833
|
+
if (effective_theirs == null) {
|
|
1549
1834
|
return {
|
|
1550
1835
|
file: resolvedPath,
|
|
1551
1836
|
status: "skipped",
|
|
1552
1837
|
reason: "missing-content"
|
|
1553
1838
|
};
|
|
1554
1839
|
}
|
|
1555
|
-
if (base == null && !useAccumulatorAsMergeBase ||
|
|
1840
|
+
if (base == null && !useAccumulatorAsMergeBase || ours == null) {
|
|
1556
1841
|
return {
|
|
1557
1842
|
file: resolvedPath,
|
|
1558
1843
|
status: "skipped",
|
|
@@ -1571,7 +1856,7 @@ var ReplayApplicator = class {
|
|
|
1571
1856
|
const outDir = (0, import_node_path2.dirname)(oursPath);
|
|
1572
1857
|
await (0, import_promises.mkdir)(outDir, { recursive: true });
|
|
1573
1858
|
await (0, import_promises.writeFile)(oursPath, merged.content);
|
|
1574
|
-
if (effective_theirs && !merged.hasConflicts) {
|
|
1859
|
+
if (effective_theirs != null && !merged.hasConflicts) {
|
|
1575
1860
|
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
1576
1861
|
content: effective_theirs,
|
|
1577
1862
|
baseGeneration: patch.base_generation
|
|
@@ -1669,6 +1954,28 @@ var ReplayApplicator = class {
|
|
|
1669
1954
|
return null;
|
|
1670
1955
|
}
|
|
1671
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
|
+
}
|
|
1672
1979
|
extractFileDiff(patchContent, filePath) {
|
|
1673
1980
|
const lines = patchContent.split("\n");
|
|
1674
1981
|
const diffLines = [];
|
|
@@ -1837,6 +2144,33 @@ var ReplayService = class {
|
|
|
1837
2144
|
committer;
|
|
1838
2145
|
lockManager;
|
|
1839
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
|
+
}
|
|
1840
2174
|
constructor(outputDir, _config) {
|
|
1841
2175
|
const git = new GitClient(outputDir);
|
|
1842
2176
|
this.git = git;
|
|
@@ -1847,6 +2181,7 @@ var ReplayService = class {
|
|
|
1847
2181
|
this.committer = new ReplayCommitter(git, outputDir);
|
|
1848
2182
|
}
|
|
1849
2183
|
async runReplay(options) {
|
|
2184
|
+
this.warnings = [];
|
|
1850
2185
|
if (options?.skipApplication) {
|
|
1851
2186
|
return this.handleSkipApplication(options);
|
|
1852
2187
|
}
|
|
@@ -1920,6 +2255,7 @@ var ReplayService = class {
|
|
|
1920
2255
|
);
|
|
1921
2256
|
}
|
|
1922
2257
|
this.lockManager.save();
|
|
2258
|
+
this.detector.warnings.push(...this.lockManager.warnings);
|
|
1923
2259
|
}
|
|
1924
2260
|
determineFlow() {
|
|
1925
2261
|
try {
|
|
@@ -1986,6 +2322,7 @@ var ReplayService = class {
|
|
|
1986
2322
|
}
|
|
1987
2323
|
this.lockManager.setReplaySkippedAt((/* @__PURE__ */ new Date()).toISOString());
|
|
1988
2324
|
this.lockManager.save();
|
|
2325
|
+
const credentialWarnings = [...this.lockManager.warnings];
|
|
1989
2326
|
if (!options?.stageOnly) {
|
|
1990
2327
|
await this.committer.commitReplay(0);
|
|
1991
2328
|
} else {
|
|
@@ -1997,13 +2334,15 @@ var ReplayService = class {
|
|
|
1997
2334
|
patchesApplied: 0,
|
|
1998
2335
|
patchesWithConflicts: 0,
|
|
1999
2336
|
patchesSkipped: 0,
|
|
2000
|
-
conflicts: []
|
|
2337
|
+
conflicts: [],
|
|
2338
|
+
warnings: credentialWarnings.length > 0 ? credentialWarnings : void 0
|
|
2001
2339
|
};
|
|
2002
2340
|
}
|
|
2003
2341
|
async handleNoPatchesRegeneration(options) {
|
|
2004
|
-
|
|
2005
|
-
const
|
|
2342
|
+
let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
|
|
2343
|
+
const detectorWarnings = [...this.detector.warnings];
|
|
2006
2344
|
if (options?.dryRun) {
|
|
2345
|
+
const dryRunWarnings = [...detectorWarnings, ...this.warnings];
|
|
2007
2346
|
return {
|
|
2008
2347
|
flow: "no-patches",
|
|
2009
2348
|
patchesDetected: newPatches.length,
|
|
@@ -2013,9 +2352,26 @@ var ReplayService = class {
|
|
|
2013
2352
|
patchesReverted: revertedPatchIds.length,
|
|
2014
2353
|
conflicts: [],
|
|
2015
2354
|
wouldApply: newPatches,
|
|
2016
|
-
warnings:
|
|
2355
|
+
warnings: dryRunWarnings.length > 0 ? dryRunWarnings : void 0
|
|
2017
2356
|
};
|
|
2018
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
|
+
}
|
|
2019
2375
|
for (const id of revertedPatchIds) {
|
|
2020
2376
|
try {
|
|
2021
2377
|
this.lockManager.removePatch(id);
|
|
@@ -2034,6 +2390,7 @@ var ReplayService = class {
|
|
|
2034
2390
|
let results = [];
|
|
2035
2391
|
if (newPatches.length > 0) {
|
|
2036
2392
|
results = await this.applicator.applyPatches(newPatches);
|
|
2393
|
+
this._lastApplyResults = results;
|
|
2037
2394
|
this.revertConflictingFiles(results);
|
|
2038
2395
|
for (const result of results) {
|
|
2039
2396
|
if (result.status === "conflict") {
|
|
@@ -2048,6 +2405,7 @@ var ReplayService = class {
|
|
|
2048
2405
|
}
|
|
2049
2406
|
}
|
|
2050
2407
|
this.lockManager.save();
|
|
2408
|
+
this.warnings.push(...this.lockManager.warnings);
|
|
2051
2409
|
if (newPatches.length > 0) {
|
|
2052
2410
|
if (!options?.stageOnly) {
|
|
2053
2411
|
const appliedCount = results.filter((r) => r.status === "applied").length;
|
|
@@ -2056,13 +2414,14 @@ var ReplayService = class {
|
|
|
2056
2414
|
await this.committer.stageAll();
|
|
2057
2415
|
}
|
|
2058
2416
|
}
|
|
2417
|
+
const warnings = [...detectorWarnings, ...this.warnings];
|
|
2059
2418
|
return this.buildReport("no-patches", newPatches, results, options, warnings, rebaseCounts, void 0, revertedPatchIds.length);
|
|
2060
2419
|
}
|
|
2061
2420
|
async handleNormalRegeneration(options) {
|
|
2062
2421
|
if (options?.dryRun) {
|
|
2063
2422
|
const existingPatches2 = this.lockManager.getPatches();
|
|
2064
2423
|
const { patches: newPatches2, revertedPatchIds: dryRunReverted } = await this.detector.detectNewPatches();
|
|
2065
|
-
const warnings2 = [...this.detector.warnings];
|
|
2424
|
+
const warnings2 = [...this.detector.warnings, ...this.warnings];
|
|
2066
2425
|
const allPatches2 = [...existingPatches2, ...newPatches2];
|
|
2067
2426
|
return {
|
|
2068
2427
|
flow: "normal-regeneration",
|
|
@@ -2094,7 +2453,7 @@ var ReplayService = class {
|
|
|
2094
2453
|
}
|
|
2095
2454
|
existingPatches = this.lockManager.getPatches();
|
|
2096
2455
|
let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
|
|
2097
|
-
const
|
|
2456
|
+
const detectorWarnings = [...this.detector.warnings];
|
|
2098
2457
|
if (removedByPreRebase.length > 0) {
|
|
2099
2458
|
const removedOriginalCommits = new Set(removedByPreRebase.map((p) => p.original_commit));
|
|
2100
2459
|
const removedOriginalMessages = new Set(removedByPreRebase.map((p) => p.original_message));
|
|
@@ -2126,6 +2485,7 @@ var ReplayService = class {
|
|
|
2126
2485
|
const genRecord = await this.committer.createGenerationRecord(commitOpts);
|
|
2127
2486
|
this.lockManager.addGeneration(genRecord);
|
|
2128
2487
|
const results = await this.applicator.applyPatches(allPatches);
|
|
2488
|
+
this._lastApplyResults = results;
|
|
2129
2489
|
this.revertConflictingFiles(results);
|
|
2130
2490
|
for (const result of results) {
|
|
2131
2491
|
if (result.status === "conflict") {
|
|
@@ -2147,12 +2507,14 @@ var ReplayService = class {
|
|
|
2147
2507
|
}
|
|
2148
2508
|
}
|
|
2149
2509
|
this.lockManager.save();
|
|
2510
|
+
this.warnings.push(...this.lockManager.warnings);
|
|
2150
2511
|
if (options?.stageOnly) {
|
|
2151
2512
|
await this.committer.stageAll();
|
|
2152
2513
|
} else {
|
|
2153
2514
|
const appliedCount = results.filter((r) => r.status === "applied").length;
|
|
2154
2515
|
await this.committer.commitReplay(appliedCount, allPatches);
|
|
2155
2516
|
}
|
|
2517
|
+
const warnings = [...detectorWarnings, ...this.warnings];
|
|
2156
2518
|
return this.buildReport(
|
|
2157
2519
|
"normal-regeneration",
|
|
2158
2520
|
allPatches,
|
|
@@ -2176,6 +2538,7 @@ var ReplayService = class {
|
|
|
2176
2538
|
let keptAsUserOwned = 0;
|
|
2177
2539
|
const seenContentHashes = /* @__PURE__ */ new Set();
|
|
2178
2540
|
const absorbedPatchIds = /* @__PURE__ */ new Set();
|
|
2541
|
+
const fernignorePatterns = this.readFernignorePatterns();
|
|
2179
2542
|
for (const result of results) {
|
|
2180
2543
|
if (result.resolvedFiles && Object.keys(result.resolvedFiles).length > 0) {
|
|
2181
2544
|
const patch = result.patch;
|
|
@@ -2196,34 +2559,78 @@ var ReplayService = class {
|
|
|
2196
2559
|
const patch = result.patch;
|
|
2197
2560
|
if (patch.base_generation === currentGenSha) continue;
|
|
2198
2561
|
try {
|
|
2199
|
-
const
|
|
2200
|
-
|
|
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(
|
|
2201
2569
|
patch.files.map(async (file) => {
|
|
2202
|
-
if (file === ".fernignore")
|
|
2570
|
+
if (file === ".fernignore") return true;
|
|
2571
|
+
if (fernignorePatterns.some((p) => file === p || (0, import_minimatch2.minimatch)(file, p))) {
|
|
2203
2572
|
return true;
|
|
2204
2573
|
}
|
|
2205
|
-
if (
|
|
2574
|
+
if (patch.user_owned) return true;
|
|
2575
|
+
if (this.fileIsCreationInPatchContent(patch.patch_content, originalByResolved[file] ?? file)) {
|
|
2206
2576
|
return true;
|
|
2207
2577
|
}
|
|
2208
2578
|
const content = await this.git.showFile(currentGenSha, file);
|
|
2209
2579
|
return content === null;
|
|
2210
2580
|
})
|
|
2211
2581
|
);
|
|
2212
|
-
const
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
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;
|
|
2217
2604
|
keptAsUserOwned++;
|
|
2605
|
+
if (baseChanged) repointed++;
|
|
2218
2606
|
continue;
|
|
2219
2607
|
}
|
|
2220
2608
|
const diff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
|
|
2221
|
-
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
|
+
}
|
|
2222
2620
|
this.lockManager.removePatch(patch.id);
|
|
2223
2621
|
absorbedPatchIds.add(patch.id);
|
|
2224
2622
|
absorbed++;
|
|
2225
2623
|
continue;
|
|
2226
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
|
+
}
|
|
2227
2634
|
const newContentHash = this.detector.computeContentHash(diff);
|
|
2228
2635
|
if (seenContentHashes.has(newContentHash)) {
|
|
2229
2636
|
this.lockManager.removePatch(patch.id);
|
|
@@ -2232,17 +2639,89 @@ var ReplayService = class {
|
|
|
2232
2639
|
continue;
|
|
2233
2640
|
}
|
|
2234
2641
|
seenContentHashes.add(newContentHash);
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
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
|
+
}
|
|
2240
2653
|
contentRebased++;
|
|
2241
2654
|
} catch {
|
|
2242
2655
|
}
|
|
2243
2656
|
}
|
|
2244
2657
|
return { absorbed, repointed, contentRebased, keptAsUserOwned, absorbedPatchIds };
|
|
2245
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
|
+
}
|
|
2246
2725
|
/**
|
|
2247
2726
|
* For conflict patches with mixed results (some files merged, some conflicted),
|
|
2248
2727
|
* check if the cleanly merged files were absorbed by the generator (empty diff).
|
|
@@ -2256,13 +2735,14 @@ var ReplayService = class {
|
|
|
2256
2735
|
const cleanFiles = result.fileResults.filter((f) => f.status === "merged").map((f) => f.file);
|
|
2257
2736
|
if (cleanFiles.length === 0) return;
|
|
2258
2737
|
const patch = result.patch;
|
|
2738
|
+
if (patch.user_owned) return;
|
|
2259
2739
|
const fernignorePatterns = this.readFernignorePatterns();
|
|
2740
|
+
const warnedGens = /* @__PURE__ */ new Set();
|
|
2260
2741
|
const generatorCleanFiles = [];
|
|
2261
2742
|
for (const file of cleanFiles) {
|
|
2262
|
-
if (file
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
if (content === null) continue;
|
|
2743
|
+
if (await this.isFileUserOwned(file, patch, currentGenSha, fernignorePatterns, warnedGens)) {
|
|
2744
|
+
continue;
|
|
2745
|
+
}
|
|
2266
2746
|
generatorCleanFiles.push(file);
|
|
2267
2747
|
}
|
|
2268
2748
|
if (generatorCleanFiles.length === 0) return;
|
|
@@ -2299,7 +2779,35 @@ var ReplayService = class {
|
|
|
2299
2779
|
let conflictResolved = 0;
|
|
2300
2780
|
let conflictAbsorbed = 0;
|
|
2301
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
|
+
};
|
|
2302
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
|
+
}
|
|
2303
2811
|
if (patch.status != null) {
|
|
2304
2812
|
delete patch.status;
|
|
2305
2813
|
continue;
|
|
@@ -2309,6 +2817,7 @@ var ReplayService = class {
|
|
|
2309
2817
|
const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
|
|
2310
2818
|
if (diff === null) continue;
|
|
2311
2819
|
if (!diff.trim()) {
|
|
2820
|
+
if (await allPatchFilesUserOwned(patch)) continue;
|
|
2312
2821
|
this.lockManager.removePatch(patch.id);
|
|
2313
2822
|
contentRefreshed++;
|
|
2314
2823
|
continue;
|
|
@@ -2320,6 +2829,12 @@ var ReplayService = class {
|
|
|
2320
2829
|
if (hasStaleMarkers) {
|
|
2321
2830
|
continue;
|
|
2322
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
|
+
}
|
|
2323
2838
|
const newContentHash = this.detector.computeContentHash(diff);
|
|
2324
2839
|
if (newContentHash !== patch.content_hash) {
|
|
2325
2840
|
const filesOutput = await this.git.exec(["diff", "--name-only", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
|
|
@@ -2345,6 +2860,13 @@ var ReplayService = class {
|
|
|
2345
2860
|
const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
|
|
2346
2861
|
if (diff === null) continue;
|
|
2347
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
|
+
}
|
|
2348
2870
|
this.lockManager.removePatch(patch.id);
|
|
2349
2871
|
conflictAbsorbed++;
|
|
2350
2872
|
continue;
|
|
@@ -2856,7 +3378,10 @@ function ensureGitattributesEntries(outputDir) {
|
|
|
2856
3378
|
(0, import_node_fs4.writeFileSync)(gitattributesPath, content, "utf-8");
|
|
2857
3379
|
}
|
|
2858
3380
|
function computeContentHash(patchContent) {
|
|
2859
|
-
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*$/, "");
|
|
2860
3385
|
return `sha256:${(0, import_node_crypto3.createHash)("sha256").update(normalized).digest("hex")}`;
|
|
2861
3386
|
}
|
|
2862
3387
|
|
|
@@ -3168,6 +3693,7 @@ function status(outputDir) {
|
|
|
3168
3693
|
}
|
|
3169
3694
|
// Annotate the CommonJS export names for ESM import in node:
|
|
3170
3695
|
0 && (module.exports = {
|
|
3696
|
+
CREDENTIAL_PATTERNS,
|
|
3171
3697
|
FERN_BOT_EMAIL,
|
|
3172
3698
|
FERN_BOT_LOGIN,
|
|
3173
3699
|
FERN_BOT_NAME,
|
|
@@ -3179,15 +3705,18 @@ function status(outputDir) {
|
|
|
3179
3705
|
ReplayCommitter,
|
|
3180
3706
|
ReplayDetector,
|
|
3181
3707
|
ReplayService,
|
|
3708
|
+
SENSITIVE_FILE_PATTERNS,
|
|
3182
3709
|
bootstrap,
|
|
3183
3710
|
forget,
|
|
3184
3711
|
isGenerationCommit,
|
|
3185
3712
|
isReplayCommit,
|
|
3186
3713
|
isRevertCommit,
|
|
3714
|
+
isSensitiveFile,
|
|
3187
3715
|
parseRevertedMessage,
|
|
3188
3716
|
parseRevertedSha,
|
|
3189
3717
|
reset,
|
|
3190
3718
|
resolve,
|
|
3719
|
+
scanForCredentials,
|
|
3191
3720
|
status,
|
|
3192
3721
|
threeWayMerge
|
|
3193
3722
|
});
|