@fern-api/replay 0.10.4 → 0.12.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 +928 -100
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +820 -99
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +277 -11
- package/dist/index.d.ts +277 -11
- package/dist/index.js +814 -99
- package/dist/index.js.map +1 -1
- package/package.json +4 -1
package/dist/index.js
CHANGED
|
@@ -77,13 +77,30 @@ var init_GitClient = __esm({
|
|
|
77
77
|
const output = await this.exec(["rev-parse", `${commitSha}^@`]);
|
|
78
78
|
return output.trim().split("\n").filter(Boolean);
|
|
79
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* Detects renames and copies between two trees.
|
|
82
|
+
*
|
|
83
|
+
* Both `R` (rename) and `C` (copy) entries are returned as `{from, to}` pairs.
|
|
84
|
+
* Copies are treated as rename-equivalent because the generator may produce the
|
|
85
|
+
* new path while the old path remains in the tree (e.g., imperfect cleanup,
|
|
86
|
+
* customer edits at the old path, or test helpers that only write new files).
|
|
87
|
+
* Callers that care about "is the source still present" should verify via
|
|
88
|
+
* `showFile(toTree, from)`.
|
|
89
|
+
*/
|
|
80
90
|
async detectRenames(fromTree, toTree) {
|
|
81
91
|
try {
|
|
82
|
-
const output = await this.exec([
|
|
92
|
+
const output = await this.exec([
|
|
93
|
+
"diff",
|
|
94
|
+
"--find-renames",
|
|
95
|
+
"--find-copies",
|
|
96
|
+
"--name-status",
|
|
97
|
+
fromTree,
|
|
98
|
+
toTree
|
|
99
|
+
]);
|
|
83
100
|
const renames = [];
|
|
84
101
|
for (const line of output.trim().split("\n")) {
|
|
85
102
|
if (!line) continue;
|
|
86
|
-
if (line.startsWith("R")) {
|
|
103
|
+
if (line.startsWith("R") || line.startsWith("C")) {
|
|
87
104
|
const parts = line.split(" ");
|
|
88
105
|
if (parts.length >= 3) {
|
|
89
106
|
renames.push({ from: parts[1], to: parts[2] });
|
|
@@ -378,6 +395,39 @@ var init_HybridReconstruction = __esm({
|
|
|
378
395
|
}
|
|
379
396
|
});
|
|
380
397
|
|
|
398
|
+
// src/credentials.ts
|
|
399
|
+
var CREDENTIAL_PATTERNS = [
|
|
400
|
+
{ name: "PRIVATE KEY block", re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
|
|
401
|
+
{ name: "OPENSSH key", re: /-----BEGIN OPENSSH PRIVATE KEY-----/ },
|
|
402
|
+
{ name: "RSA key", re: /-----BEGIN RSA PRIVATE KEY-----/ },
|
|
403
|
+
{ name: "CERTIFICATE", re: /-----BEGIN CERTIFICATE-----/ },
|
|
404
|
+
{ name: "TOKEN=", re: /\bTOKEN\s*=\s*["']?[A-Za-z0-9._\-+/=]{10,}/ },
|
|
405
|
+
{ name: "api_key=", re: /\bapi[_-]?key\s*=\s*["']?[A-Za-z0-9._\-+/=]{10,}/i },
|
|
406
|
+
{ name: "password=", re: /\bpassword\s*=\s*["']?[^\s"']{6,}/i },
|
|
407
|
+
{ name: "AWS_SECRET_ACCESS_KEY", re: /AWS_SECRET_ACCESS_KEY/ },
|
|
408
|
+
{ name: "JWT (eyJ...)", re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/ }
|
|
409
|
+
];
|
|
410
|
+
var SENSITIVE_FILE_PATTERNS = [
|
|
411
|
+
/(^|\/)\.env(\.[^/]+)?$/,
|
|
412
|
+
/\.pem$/,
|
|
413
|
+
/\.key$/,
|
|
414
|
+
/\.pkce_state\.json$/,
|
|
415
|
+
/(^|\/)id_rsa$/,
|
|
416
|
+
/(^|\/)id_ed25519$/
|
|
417
|
+
];
|
|
418
|
+
function isSensitiveFile(filePath) {
|
|
419
|
+
return SENSITIVE_FILE_PATTERNS.some((re) => re.test(filePath));
|
|
420
|
+
}
|
|
421
|
+
function scanForCredentials(content) {
|
|
422
|
+
const matches = [];
|
|
423
|
+
for (const { name, re } of CREDENTIAL_PATTERNS) {
|
|
424
|
+
if (re.test(content)) {
|
|
425
|
+
matches.push(name);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return matches;
|
|
429
|
+
}
|
|
430
|
+
|
|
381
431
|
// src/index.ts
|
|
382
432
|
init_GitClient();
|
|
383
433
|
|
|
@@ -389,7 +439,7 @@ var FERN_SUPPORT_NAMES = ["fern-support", "Fern Support"];
|
|
|
389
439
|
function isGenerationCommit(commit) {
|
|
390
440
|
const isFernSupport = FERN_SUPPORT_NAMES.includes(commit.authorName);
|
|
391
441
|
const isBotAuthor = !isFernSupport && (commit.authorLogin === FERN_BOT_LOGIN || commit.authorEmail === FERN_BOT_EMAIL || commit.authorName === FERN_BOT_NAME);
|
|
392
|
-
const hasGenerationMarker = commit.message.startsWith("[fern-generated]") || commit.message.startsWith("[fern-replay]") || commit.message.includes("Generated by Fern") || commit.message.includes("\u{1F916} Generated with Fern") || // Squash merge of a Fern-generated PR uses the PR title as commit message.
|
|
442
|
+
const hasGenerationMarker = commit.message.startsWith("[fern-generated]") || commit.message.startsWith("[fern-replay]") || commit.message.startsWith("[fern-autoversion]") || commit.message.includes("Generated by Fern") || commit.message.includes("\u{1F916} Generated with Fern") || // Squash merge of a Fern-generated PR uses the PR title as commit message.
|
|
393
443
|
// The default PR title is "SDK Generation" (from GithubStep's commitMessage default).
|
|
394
444
|
// GitHub appends "(#N)" for the PR number, e.g. "SDK Generation (#70)".
|
|
395
445
|
commit.message.startsWith("SDK Generation");
|
|
@@ -424,6 +474,7 @@ var LockfileNotFoundError = class extends Error {
|
|
|
424
474
|
var LockfileManager = class {
|
|
425
475
|
outputDir;
|
|
426
476
|
lock = null;
|
|
477
|
+
warnings = [];
|
|
427
478
|
constructor(outputDir) {
|
|
428
479
|
this.outputDir = outputDir;
|
|
429
480
|
}
|
|
@@ -472,6 +523,27 @@ var LockfileManager = class {
|
|
|
472
523
|
if (!this.lock) {
|
|
473
524
|
throw new Error("No lockfile data to save. Call read() or initialize() first.");
|
|
474
525
|
}
|
|
526
|
+
this.warnings.length = 0;
|
|
527
|
+
const cleanPatches = [];
|
|
528
|
+
for (const patch of this.lock.patches) {
|
|
529
|
+
const credentialMatches = scanForCredentials(patch.patch_content);
|
|
530
|
+
const sensitiveFiles = patch.files.filter((f) => isSensitiveFile(f));
|
|
531
|
+
if (credentialMatches.length > 0 || sensitiveFiles.length > 0) {
|
|
532
|
+
const reasons = [];
|
|
533
|
+
if (credentialMatches.length > 0) {
|
|
534
|
+
reasons.push(`credential patterns: ${credentialMatches.join(", ")}`);
|
|
535
|
+
}
|
|
536
|
+
if (sensitiveFiles.length > 0) {
|
|
537
|
+
reasons.push(`sensitive files: ${sensitiveFiles.join(", ")}`);
|
|
538
|
+
}
|
|
539
|
+
this.warnings.push(
|
|
540
|
+
`Patch ${patch.id} removed from lockfile: ${reasons.join("; ")}. This prevents credential exposure in committed lockfiles.`
|
|
541
|
+
);
|
|
542
|
+
} else {
|
|
543
|
+
cleanPatches.push(patch);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
this.lock.patches = cleanPatches;
|
|
475
547
|
const dir = dirname(this.lockfilePath);
|
|
476
548
|
if (!existsSync(dir)) {
|
|
477
549
|
mkdirSync(dir, { recursive: true });
|
|
@@ -576,6 +648,37 @@ var LockfileManager = class {
|
|
|
576
648
|
// src/ReplayDetector.ts
|
|
577
649
|
import { createHash } from "crypto";
|
|
578
650
|
var INFRASTRUCTURE_FILES = /* @__PURE__ */ new Set([".fernignore"]);
|
|
651
|
+
function parsePatchFileHeaders(patchContent) {
|
|
652
|
+
const results = [];
|
|
653
|
+
let currentPath = null;
|
|
654
|
+
let currentIsCreate = false;
|
|
655
|
+
let currentIsDelete = false;
|
|
656
|
+
const flush = () => {
|
|
657
|
+
if (currentPath !== null) {
|
|
658
|
+
results.push({ path: currentPath, isCreate: currentIsCreate, isDelete: currentIsDelete });
|
|
659
|
+
}
|
|
660
|
+
};
|
|
661
|
+
for (const line of patchContent.split("\n")) {
|
|
662
|
+
if (line.startsWith("diff --git ")) {
|
|
663
|
+
flush();
|
|
664
|
+
currentPath = null;
|
|
665
|
+
currentIsCreate = false;
|
|
666
|
+
currentIsDelete = false;
|
|
667
|
+
const match = line.match(/^diff --git a\/(.+?) b\/(.+?)$/);
|
|
668
|
+
if (match) {
|
|
669
|
+
currentPath = match[2] ?? null;
|
|
670
|
+
}
|
|
671
|
+
} else if (currentPath !== null) {
|
|
672
|
+
if (line.startsWith("new file mode ")) {
|
|
673
|
+
currentIsCreate = true;
|
|
674
|
+
} else if (line.startsWith("deleted file mode ")) {
|
|
675
|
+
currentIsDelete = true;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
flush();
|
|
680
|
+
return results;
|
|
681
|
+
}
|
|
579
682
|
var ReplayDetector = class {
|
|
580
683
|
git;
|
|
581
684
|
lockManager;
|
|
@@ -654,12 +757,31 @@ var ReplayDetector = class {
|
|
|
654
757
|
);
|
|
655
758
|
continue;
|
|
656
759
|
}
|
|
657
|
-
|
|
760
|
+
let contentHash = this.computeContentHash(patchContent);
|
|
658
761
|
if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
|
|
659
762
|
continue;
|
|
660
763
|
}
|
|
661
764
|
const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
|
|
662
|
-
const
|
|
765
|
+
const allFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
|
|
766
|
+
const sensitiveFiles = allFiles.filter((f) => isSensitiveFile(f));
|
|
767
|
+
const files = allFiles.filter((f) => !isSensitiveFile(f));
|
|
768
|
+
if (sensitiveFiles.length > 0) {
|
|
769
|
+
this.warnings.push(
|
|
770
|
+
`Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
|
|
771
|
+
);
|
|
772
|
+
if (files.length > 0) {
|
|
773
|
+
const parentSha = parents[0];
|
|
774
|
+
if (parentSha) {
|
|
775
|
+
try {
|
|
776
|
+
patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
|
|
777
|
+
} catch {
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
if (!patchContent.trim()) continue;
|
|
781
|
+
contentHash = this.computeContentHash(patchContent);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
}
|
|
663
785
|
if (files.length === 0) {
|
|
664
786
|
continue;
|
|
665
787
|
}
|
|
@@ -671,14 +793,16 @@ var ReplayDetector = class {
|
|
|
671
793
|
original_author: `${commit.authorName} <${commit.authorEmail}>`,
|
|
672
794
|
base_generation: lastGen.commit_sha,
|
|
673
795
|
files,
|
|
674
|
-
patch_content: patchContent
|
|
796
|
+
patch_content: patchContent,
|
|
797
|
+
...this.isCreationOnlyPatch(patchContent) ? { user_owned: true } : {}
|
|
675
798
|
});
|
|
676
799
|
}
|
|
677
|
-
|
|
800
|
+
const survivingPatches = await this.collapseNetZeroFiles(newPatches);
|
|
801
|
+
survivingPatches.reverse();
|
|
678
802
|
const revertedPatchIdSet = /* @__PURE__ */ new Set();
|
|
679
803
|
const revertIndicesToRemove = /* @__PURE__ */ new Set();
|
|
680
|
-
for (let i = 0; i <
|
|
681
|
-
const patch =
|
|
804
|
+
for (let i = 0; i < survivingPatches.length; i++) {
|
|
805
|
+
const patch = survivingPatches[i];
|
|
682
806
|
if (!isRevertCommit(patch.original_message)) continue;
|
|
683
807
|
let body = "";
|
|
684
808
|
try {
|
|
@@ -707,7 +831,7 @@ var ReplayDetector = class {
|
|
|
707
831
|
if (matchedExisting) continue;
|
|
708
832
|
let matchedNew = false;
|
|
709
833
|
if (revertedSha) {
|
|
710
|
-
const idx =
|
|
834
|
+
const idx = survivingPatches.findIndex(
|
|
711
835
|
(p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_commit === revertedSha
|
|
712
836
|
);
|
|
713
837
|
if (idx !== -1) {
|
|
@@ -717,7 +841,7 @@ var ReplayDetector = class {
|
|
|
717
841
|
}
|
|
718
842
|
}
|
|
719
843
|
if (!matchedNew && revertedMessage) {
|
|
720
|
-
const idx =
|
|
844
|
+
const idx = survivingPatches.findIndex(
|
|
721
845
|
(p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_message === revertedMessage
|
|
722
846
|
);
|
|
723
847
|
if (idx !== -1) {
|
|
@@ -729,18 +853,107 @@ var ReplayDetector = class {
|
|
|
729
853
|
revertIndicesToRemove.add(i);
|
|
730
854
|
}
|
|
731
855
|
}
|
|
732
|
-
const filteredPatches =
|
|
856
|
+
const filteredPatches = survivingPatches.filter((_, i) => !revertIndicesToRemove.has(i));
|
|
733
857
|
return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
|
|
734
858
|
}
|
|
735
859
|
/**
|
|
736
860
|
* Compute content hash for deduplication.
|
|
737
|
-
*
|
|
738
|
-
*
|
|
861
|
+
*
|
|
862
|
+
* Produces a format-agnostic hash so both `git format-patch` output
|
|
863
|
+
* (email-wrapped) and plain `git diff` output hash to the same value
|
|
864
|
+
* when the underlying diff hunks are identical.
|
|
865
|
+
*
|
|
866
|
+
* Normalization:
|
|
867
|
+
* 1. Strip email wrapper: everything before the first `diff --git` line
|
|
868
|
+
* (From:, Subject:, Date:, diffstat, blank separators).
|
|
869
|
+
* 2. Strip `index` lines (blob SHA pairs change across rebases).
|
|
870
|
+
* 3. Strip trailing git version marker (`-- \n<version>`).
|
|
871
|
+
*
|
|
872
|
+
* This ensures content hashes match across the no-patches and normal-
|
|
873
|
+
* regeneration flows, which store formatPatch and git-diff formats
|
|
874
|
+
* respectively (FER-9850, D5-D9).
|
|
739
875
|
*/
|
|
740
876
|
computeContentHash(patchContent) {
|
|
741
|
-
const
|
|
877
|
+
const lines = patchContent.split("\n");
|
|
878
|
+
const diffStart = lines.findIndex((l) => l.startsWith("diff --git "));
|
|
879
|
+
const relevantLines = diffStart > 0 ? lines.slice(diffStart) : lines;
|
|
880
|
+
const normalized = relevantLines.filter((line) => !line.startsWith("index ")).join("\n").replace(/\n-- \n[\d]+\.[\d]+[^\n]*\s*$/, "");
|
|
742
881
|
return `sha256:${createHash("sha256").update(normalized).digest("hex")}`;
|
|
743
882
|
}
|
|
883
|
+
/**
|
|
884
|
+
* FER-9805 — Drop patches whose files were transiently created and then
|
|
885
|
+
* deleted within the current linear detection window, and are absent from
|
|
886
|
+
* HEAD at the end of the window.
|
|
887
|
+
*
|
|
888
|
+
* Rationale: on a merge commit, createMergeCompositePatch already diffs
|
|
889
|
+
* firstParent..mergeCommit so net-zero files never enter the composite. On
|
|
890
|
+
* linear history each commit becomes its own patch, so a file that was
|
|
891
|
+
* briefly added and later removed survives as a create+delete pair whose
|
|
892
|
+
* cumulative diff is empty. We drop such patches before they reach the
|
|
893
|
+
* lockfile.
|
|
894
|
+
*
|
|
895
|
+
* A file is "transient" when:
|
|
896
|
+
* 1. some patch in the window sets `new file mode` for it (a creation), AND
|
|
897
|
+
* 2. some patch in the window sets `deleted file mode` for it (a deletion), AND
|
|
898
|
+
* 3. it is absent from HEAD's tree.
|
|
899
|
+
*
|
|
900
|
+
* The HEAD guard (#3) prevents false positives when a file is deleted
|
|
901
|
+
* and then recreated with different content — the cumulative diff of
|
|
902
|
+
* such a pair is non-empty and must be preserved.
|
|
903
|
+
*
|
|
904
|
+
* This only drops patches whose `files` are a subset of the transient
|
|
905
|
+
* set. A partial-transient patch (one that touches a transient file
|
|
906
|
+
* alongside a persistent one) is left unmodified; surgical stripping of
|
|
907
|
+
* single files from a multi-file patch would require a follow-up that
|
|
908
|
+
* regenerates the patch content via `git format-patch -- <files>`. No
|
|
909
|
+
* current failing test exercises this, and leaving the patch intact
|
|
910
|
+
* preserves today's behaviour. TODO(FER-9805): revisit if a future
|
|
911
|
+
* adversarial test demands per-file stripping.
|
|
912
|
+
*/
|
|
913
|
+
async collapseNetZeroFiles(patches) {
|
|
914
|
+
if (patches.length === 0) return patches;
|
|
915
|
+
const stateByFile = /* @__PURE__ */ new Map();
|
|
916
|
+
for (const patch of patches) {
|
|
917
|
+
for (const header of parsePatchFileHeaders(patch.patch_content)) {
|
|
918
|
+
if (!header.isCreate && !header.isDelete) continue;
|
|
919
|
+
const prior = stateByFile.get(header.path) ?? { created: false, deleted: false };
|
|
920
|
+
if (header.isCreate) prior.created = true;
|
|
921
|
+
if (header.isDelete) prior.deleted = true;
|
|
922
|
+
stateByFile.set(header.path, prior);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
let anyCandidate = false;
|
|
926
|
+
for (const state of stateByFile.values()) {
|
|
927
|
+
if (state.created && state.deleted) {
|
|
928
|
+
anyCandidate = true;
|
|
929
|
+
break;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
if (!anyCandidate) return patches;
|
|
933
|
+
const headFiles = await this.listHeadFiles();
|
|
934
|
+
const transient = /* @__PURE__ */ new Set();
|
|
935
|
+
for (const [file, state] of stateByFile) {
|
|
936
|
+
if (state.created && state.deleted && !headFiles.has(file)) {
|
|
937
|
+
transient.add(file);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
if (transient.size === 0) return patches;
|
|
941
|
+
return patches.filter((patch) => !patch.files.every((f) => transient.has(f)));
|
|
942
|
+
}
|
|
943
|
+
/**
|
|
944
|
+
* List every tracked path at HEAD (one `git ls-tree -r` invocation).
|
|
945
|
+
* Returns an empty Set if HEAD is unborn or the invocation fails — the
|
|
946
|
+
* caller then treats every candidate as "not in HEAD" and may collapse
|
|
947
|
+
* more aggressively. On typical SDK repos this is a single sub-10ms call.
|
|
948
|
+
*/
|
|
949
|
+
async listHeadFiles() {
|
|
950
|
+
try {
|
|
951
|
+
const out = await this.git.exec(["ls-tree", "-r", "--name-only", "HEAD"]);
|
|
952
|
+
return new Set(out.split("\n").map((l) => l.trim()).filter(Boolean));
|
|
953
|
+
} catch {
|
|
954
|
+
return /* @__PURE__ */ new Set();
|
|
955
|
+
}
|
|
956
|
+
}
|
|
744
957
|
/**
|
|
745
958
|
* Create a single composite patch for a merge commit by diffing the merge
|
|
746
959
|
* commit against its first parent. This captures the net change of the
|
|
@@ -756,7 +969,14 @@ var ReplayDetector = class {
|
|
|
756
969
|
firstParent,
|
|
757
970
|
mergeCommit.sha
|
|
758
971
|
]);
|
|
759
|
-
const
|
|
972
|
+
const allMergeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
|
|
973
|
+
const sensitiveMergeFiles = allMergeFiles.filter((f) => isSensitiveFile(f));
|
|
974
|
+
const files = allMergeFiles.filter((f) => !isSensitiveFile(f));
|
|
975
|
+
if (sensitiveMergeFiles.length > 0) {
|
|
976
|
+
this.warnings.push(
|
|
977
|
+
`Sensitive file(s) excluded from merge patch for commit ${mergeCommit.sha.slice(0, 7)}: ${sensitiveMergeFiles.join(", ")}. These files will not be tracked by replay.`
|
|
978
|
+
);
|
|
979
|
+
}
|
|
760
980
|
if (files.length === 0) return null;
|
|
761
981
|
const diff = await this.git.exec([
|
|
762
982
|
"diff",
|
|
@@ -778,7 +998,8 @@ var ReplayDetector = class {
|
|
|
778
998
|
original_author: `${mergeCommit.authorName} <${mergeCommit.authorEmail}>`,
|
|
779
999
|
base_generation: lastGen.commit_sha,
|
|
780
1000
|
files,
|
|
781
|
-
patch_content: diff
|
|
1001
|
+
patch_content: diff,
|
|
1002
|
+
...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
|
|
782
1003
|
};
|
|
783
1004
|
}
|
|
784
1005
|
/**
|
|
@@ -792,7 +1013,14 @@ var ReplayDetector = class {
|
|
|
792
1013
|
return this.detectPatchesViaCommitScan();
|
|
793
1014
|
}
|
|
794
1015
|
const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
|
|
795
|
-
const
|
|
1016
|
+
const allTreeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
|
|
1017
|
+
const sensitiveTreeFiles = allTreeFiles.filter((f) => isSensitiveFile(f));
|
|
1018
|
+
const files = allTreeFiles.filter((f) => !isSensitiveFile(f));
|
|
1019
|
+
if (sensitiveTreeFiles.length > 0) {
|
|
1020
|
+
this.warnings.push(
|
|
1021
|
+
`Sensitive file(s) excluded from composite patch: ${sensitiveTreeFiles.join(", ")}. These files will not be tracked by replay.`
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
796
1024
|
if (files.length === 0) return { patches: [], revertedPatchIds: [] };
|
|
797
1025
|
const diff = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
|
|
798
1026
|
if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
|
|
@@ -812,7 +1040,8 @@ var ReplayDetector = class {
|
|
|
812
1040
|
// reference to find base file content. diffBase may be the tree_hash.
|
|
813
1041
|
base_generation: commitKnownMissing ? diffBase : lastGen.commit_sha,
|
|
814
1042
|
files,
|
|
815
|
-
patch_content: diff
|
|
1043
|
+
patch_content: diff,
|
|
1044
|
+
...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
|
|
816
1045
|
};
|
|
817
1046
|
return { patches: [compositePatch], revertedPatchIds: [] };
|
|
818
1047
|
}
|
|
@@ -859,7 +1088,7 @@ var ReplayDetector = class {
|
|
|
859
1088
|
} catch {
|
|
860
1089
|
continue;
|
|
861
1090
|
}
|
|
862
|
-
|
|
1091
|
+
let contentHash = this.computeContentHash(patchContent);
|
|
863
1092
|
if (existingHashes.has(contentHash) || forgottenHashes.has(contentHash)) {
|
|
864
1093
|
continue;
|
|
865
1094
|
}
|
|
@@ -867,7 +1096,26 @@ var ReplayDetector = class {
|
|
|
867
1096
|
continue;
|
|
868
1097
|
}
|
|
869
1098
|
const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
|
|
870
|
-
const
|
|
1099
|
+
const allScanFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f));
|
|
1100
|
+
const sensitiveScanFiles = allScanFiles.filter((f) => isSensitiveFile(f));
|
|
1101
|
+
const files = allScanFiles.filter((f) => !isSensitiveFile(f));
|
|
1102
|
+
if (sensitiveScanFiles.length > 0) {
|
|
1103
|
+
this.warnings.push(
|
|
1104
|
+
`Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveScanFiles.join(", ")}. These files will not be tracked by replay.`
|
|
1105
|
+
);
|
|
1106
|
+
if (files.length > 0) {
|
|
1107
|
+
if (parents.length === 0) {
|
|
1108
|
+
continue;
|
|
1109
|
+
}
|
|
1110
|
+
try {
|
|
1111
|
+
patchContent = await this.git.exec(["diff", parents[0], commit.sha, "--", ...files]);
|
|
1112
|
+
} catch {
|
|
1113
|
+
continue;
|
|
1114
|
+
}
|
|
1115
|
+
if (!patchContent.trim()) continue;
|
|
1116
|
+
contentHash = this.computeContentHash(patchContent);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
871
1119
|
if (files.length === 0) {
|
|
872
1120
|
continue;
|
|
873
1121
|
}
|
|
@@ -1199,6 +1447,20 @@ var ReplayApplicator = class {
|
|
|
1199
1447
|
resetAccumulator() {
|
|
1200
1448
|
this.fileTheirsAccumulator.clear();
|
|
1201
1449
|
}
|
|
1450
|
+
/**
|
|
1451
|
+
* @internal Test-only.
|
|
1452
|
+
* Returns a defensive copy of the inter-patch accumulator. Used by the
|
|
1453
|
+
* adversarial test suite (FER-9791) to assert invariant I2: after every
|
|
1454
|
+
* applied patch, the accumulator has an entry for each file the patch
|
|
1455
|
+
* touched, and the entry's content matches on-disk.
|
|
1456
|
+
*/
|
|
1457
|
+
getAccumulatorSnapshot() {
|
|
1458
|
+
const snapshot = /* @__PURE__ */ new Map();
|
|
1459
|
+
for (const [path, entry] of this.fileTheirsAccumulator) {
|
|
1460
|
+
snapshot.set(path, { content: entry.content, baseGeneration: entry.baseGeneration });
|
|
1461
|
+
}
|
|
1462
|
+
return snapshot;
|
|
1463
|
+
}
|
|
1202
1464
|
/**
|
|
1203
1465
|
* Apply all patches, returning results for each.
|
|
1204
1466
|
* Skips patches that match exclude patterns in replay.yml
|
|
@@ -1264,7 +1526,7 @@ var ReplayApplicator = class {
|
|
|
1264
1526
|
const base = await this.git.showFile(baseGen.tree_hash, filePath);
|
|
1265
1527
|
const theirs = await this.applyPatchToContent(base, patch.patch_content, filePath, tempGit, tempDir);
|
|
1266
1528
|
const effectiveTheirs = theirs ?? await readFile(join2(this.outputDir, resolvedPath), "utf-8").catch(() => null);
|
|
1267
|
-
if (effectiveTheirs) {
|
|
1529
|
+
if (effectiveTheirs != null) {
|
|
1268
1530
|
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
1269
1531
|
content: effectiveTheirs,
|
|
1270
1532
|
baseGeneration: patch.base_generation
|
|
@@ -1288,16 +1550,20 @@ var ReplayApplicator = class {
|
|
|
1288
1550
|
return this.fileTheirsAccumulator.has(resolved);
|
|
1289
1551
|
})
|
|
1290
1552
|
).then((results) => results.some(Boolean));
|
|
1291
|
-
|
|
1553
|
+
const resolvedFiles = {};
|
|
1554
|
+
for (const filePath of patch.files) {
|
|
1555
|
+
const resolvedPath = baseGen ? await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash) : filePath;
|
|
1556
|
+
if (resolvedPath !== filePath) {
|
|
1557
|
+
resolvedFiles[filePath] = resolvedPath;
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
const patchIsRenameAware = /^rename from /m.test(patch.patch_content);
|
|
1561
|
+
const hasGeneratorRename = Object.keys(resolvedFiles).length > 0 && !patchIsRenameAware;
|
|
1562
|
+
if (!needsAccumulation && !hasGeneratorRename) {
|
|
1292
1563
|
const snapshots = /* @__PURE__ */ new Map();
|
|
1293
|
-
const resolvedFiles = {};
|
|
1294
1564
|
for (const filePath of patch.files) {
|
|
1295
|
-
const
|
|
1296
|
-
|
|
1297
|
-
resolvedFiles[filePath] = resolvedPath;
|
|
1298
|
-
}
|
|
1299
|
-
const fullPath = join2(this.outputDir, resolvedPath);
|
|
1300
|
-
snapshots.set(resolvedPath, await readFile(fullPath, "utf-8").catch(() => null));
|
|
1565
|
+
const fullPath = join2(this.outputDir, filePath);
|
|
1566
|
+
snapshots.set(filePath, await readFile(fullPath, "utf-8").catch(() => null));
|
|
1301
1567
|
}
|
|
1302
1568
|
try {
|
|
1303
1569
|
await this.git.execWithInput(["apply", "--3way"], patch.patch_content);
|
|
@@ -1309,8 +1575,8 @@ var ReplayApplicator = class {
|
|
|
1309
1575
|
...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
|
|
1310
1576
|
};
|
|
1311
1577
|
} catch {
|
|
1312
|
-
for (const [
|
|
1313
|
-
const fullPath = join2(this.outputDir,
|
|
1578
|
+
for (const [filePath, content] of snapshots) {
|
|
1579
|
+
const fullPath = join2(this.outputDir, filePath);
|
|
1314
1580
|
if (content != null) {
|
|
1315
1581
|
await writeFile(fullPath, content);
|
|
1316
1582
|
} else {
|
|
@@ -1430,7 +1696,7 @@ var ReplayApplicator = class {
|
|
|
1430
1696
|
}
|
|
1431
1697
|
let useAccumulatorAsMergeBase = false;
|
|
1432
1698
|
const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
|
|
1433
|
-
if (accumulatorEntry && (
|
|
1699
|
+
if (accumulatorEntry && (theirs == null || base == null)) {
|
|
1434
1700
|
theirs = await this.applyPatchToContent(
|
|
1435
1701
|
accumulatorEntry.content,
|
|
1436
1702
|
patch.patch_content,
|
|
@@ -1438,7 +1704,16 @@ var ReplayApplicator = class {
|
|
|
1438
1704
|
tempGit,
|
|
1439
1705
|
tempDir
|
|
1440
1706
|
);
|
|
1441
|
-
if (theirs) {
|
|
1707
|
+
if (theirs != null) {
|
|
1708
|
+
useAccumulatorAsMergeBase = true;
|
|
1709
|
+
} else if (await this.isPatchAlreadyApplied(
|
|
1710
|
+
patch.patch_content,
|
|
1711
|
+
filePath,
|
|
1712
|
+
accumulatorEntry.content,
|
|
1713
|
+
tempGit,
|
|
1714
|
+
tempDir
|
|
1715
|
+
)) {
|
|
1716
|
+
theirs = accumulatorEntry.content;
|
|
1442
1717
|
useAccumulatorAsMergeBase = true;
|
|
1443
1718
|
}
|
|
1444
1719
|
}
|
|
@@ -1455,7 +1730,7 @@ var ReplayApplicator = class {
|
|
|
1455
1730
|
}
|
|
1456
1731
|
let effective_theirs = theirs;
|
|
1457
1732
|
let baseMismatchSkipped = false;
|
|
1458
|
-
if (theirs && base && !useAccumulatorAsMergeBase) {
|
|
1733
|
+
if (theirs != null && base != null && !useAccumulatorAsMergeBase) {
|
|
1459
1734
|
if (accumulatorEntry && accumulatorEntry.baseGeneration === patch.base_generation) {
|
|
1460
1735
|
try {
|
|
1461
1736
|
const preMerged = threeWayMerge(base, accumulatorEntry.content, theirs);
|
|
@@ -1471,7 +1746,7 @@ var ReplayApplicator = class {
|
|
|
1471
1746
|
baseMismatchSkipped = true;
|
|
1472
1747
|
}
|
|
1473
1748
|
}
|
|
1474
|
-
if (base == null &&
|
|
1749
|
+
if (base == null && ours == null && effective_theirs != null) {
|
|
1475
1750
|
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
1476
1751
|
content: effective_theirs,
|
|
1477
1752
|
baseGeneration: patch.base_generation
|
|
@@ -1481,7 +1756,7 @@ var ReplayApplicator = class {
|
|
|
1481
1756
|
await writeFile(oursPath, effective_theirs);
|
|
1482
1757
|
return { file: resolvedPath, status: "merged", reason: "new-file" };
|
|
1483
1758
|
}
|
|
1484
|
-
if (base == null && ours && effective_theirs && !useAccumulatorAsMergeBase) {
|
|
1759
|
+
if (base == null && ours != null && effective_theirs != null && !useAccumulatorAsMergeBase) {
|
|
1485
1760
|
const merged2 = threeWayMerge("", ours, effective_theirs);
|
|
1486
1761
|
const outDir2 = dirname2(oursPath);
|
|
1487
1762
|
await mkdir(outDir2, { recursive: true });
|
|
@@ -1495,16 +1770,20 @@ var ReplayApplicator = class {
|
|
|
1495
1770
|
conflictMetadata: metadata
|
|
1496
1771
|
};
|
|
1497
1772
|
}
|
|
1773
|
+
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
1774
|
+
content: merged2.content,
|
|
1775
|
+
baseGeneration: patch.base_generation
|
|
1776
|
+
});
|
|
1498
1777
|
return { file: resolvedPath, status: "merged" };
|
|
1499
1778
|
}
|
|
1500
|
-
if (
|
|
1779
|
+
if (effective_theirs == null) {
|
|
1501
1780
|
return {
|
|
1502
1781
|
file: resolvedPath,
|
|
1503
1782
|
status: "skipped",
|
|
1504
1783
|
reason: "missing-content"
|
|
1505
1784
|
};
|
|
1506
1785
|
}
|
|
1507
|
-
if (base == null && !useAccumulatorAsMergeBase ||
|
|
1786
|
+
if (base == null && !useAccumulatorAsMergeBase || ours == null) {
|
|
1508
1787
|
return {
|
|
1509
1788
|
file: resolvedPath,
|
|
1510
1789
|
status: "skipped",
|
|
@@ -1523,7 +1802,7 @@ var ReplayApplicator = class {
|
|
|
1523
1802
|
const outDir = dirname2(oursPath);
|
|
1524
1803
|
await mkdir(outDir, { recursive: true });
|
|
1525
1804
|
await writeFile(oursPath, merged.content);
|
|
1526
|
-
if (effective_theirs && !merged.hasConflicts) {
|
|
1805
|
+
if (effective_theirs != null && !merged.hasConflicts) {
|
|
1527
1806
|
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
1528
1807
|
content: effective_theirs,
|
|
1529
1808
|
baseGeneration: patch.base_generation
|
|
@@ -1621,6 +1900,28 @@ var ReplayApplicator = class {
|
|
|
1621
1900
|
return null;
|
|
1622
1901
|
}
|
|
1623
1902
|
}
|
|
1903
|
+
/**
|
|
1904
|
+
* Detects whether a patch's additions are already present in `content`.
|
|
1905
|
+
* Uses `git apply --reverse --check` — if the reverse patch applies cleanly,
|
|
1906
|
+
* the forward patch is effectively a no-op (its +lines are already there and
|
|
1907
|
+
* its -lines are already gone). Used to distinguish "patch already applied"
|
|
1908
|
+
* from "patch base mismatch" after a forward-apply fails.
|
|
1909
|
+
*/
|
|
1910
|
+
async isPatchAlreadyApplied(patchContent, filePath, content, tempGit, tempDir) {
|
|
1911
|
+
const fileDiff = this.extractFileDiff(patchContent, filePath);
|
|
1912
|
+
if (!fileDiff) return false;
|
|
1913
|
+
try {
|
|
1914
|
+
const tempFilePath = join2(tempDir, filePath);
|
|
1915
|
+
await mkdir(dirname2(tempFilePath), { recursive: true });
|
|
1916
|
+
await writeFile(tempFilePath, content);
|
|
1917
|
+
await tempGit.exec(["add", filePath]);
|
|
1918
|
+
await tempGit.exec(["commit", "-m", `check already-applied ${filePath}`, "--allow-empty"]);
|
|
1919
|
+
await tempGit.execWithInput(["apply", "--reverse", "--check", "--allow-empty"], fileDiff);
|
|
1920
|
+
return true;
|
|
1921
|
+
} catch {
|
|
1922
|
+
return false;
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1624
1925
|
extractFileDiff(patchContent, filePath) {
|
|
1625
1926
|
const lines = patchContent.split("\n");
|
|
1626
1927
|
const diffLines = [];
|
|
@@ -1789,6 +2090,33 @@ var ReplayService = class {
|
|
|
1789
2090
|
committer;
|
|
1790
2091
|
lockManager;
|
|
1791
2092
|
outputDir;
|
|
2093
|
+
/** @internal Test-only. Captures the last ReplayResult[] returned from applicator.applyPatches(). */
|
|
2094
|
+
_lastApplyResults = [];
|
|
2095
|
+
/**
|
|
2096
|
+
* Service-level warnings accumulated during a single runReplay() call.
|
|
2097
|
+
* Merged with `detector.warnings` into `ReplayReport.warnings` by each flow handler.
|
|
2098
|
+
* Populated by `isFileUserOwned` when a patch's base generation tree is unreachable
|
|
2099
|
+
* (shallow clone / aggressive `git gc --prune`) and we fall back to a conservative
|
|
2100
|
+
* heuristic. Cleared at the top of `runReplay()`.
|
|
2101
|
+
*/
|
|
2102
|
+
warnings = [];
|
|
2103
|
+
/**
|
|
2104
|
+
* @internal Test-only accessor.
|
|
2105
|
+
* Returns the ReplayApplicator instance used for the last run. Tests use this
|
|
2106
|
+
* to inspect the inter-patch accumulator after runReplay() completes (FER-9791, I2).
|
|
2107
|
+
*/
|
|
2108
|
+
get applicatorRef() {
|
|
2109
|
+
return this.applicator;
|
|
2110
|
+
}
|
|
2111
|
+
/**
|
|
2112
|
+
* @internal Test-only accessor.
|
|
2113
|
+
* Returns the ReplayResult[] from the most recent applyPatches() call. Tests use
|
|
2114
|
+
* this to filter applied-vs-conflicted-vs-absorbed patches for invariant assertions
|
|
2115
|
+
* (FER-9791). Returns an empty array if no patches have been applied yet.
|
|
2116
|
+
*/
|
|
2117
|
+
getLastResults() {
|
|
2118
|
+
return this._lastApplyResults;
|
|
2119
|
+
}
|
|
1792
2120
|
constructor(outputDir, _config) {
|
|
1793
2121
|
const git = new GitClient(outputDir);
|
|
1794
2122
|
this.git = git;
|
|
@@ -1798,20 +2126,66 @@ var ReplayService = class {
|
|
|
1798
2126
|
this.applicator = new ReplayApplicator(git, this.lockManager, outputDir);
|
|
1799
2127
|
this.committer = new ReplayCommitter(git, outputDir);
|
|
1800
2128
|
}
|
|
1801
|
-
|
|
2129
|
+
/**
|
|
2130
|
+
* Phase 1 of the two-phase replay flow. Runs preGenerationRebase (when applicable),
|
|
2131
|
+
* detects new patches, and creates the `[fern-generated]` commit. Leaves HEAD at
|
|
2132
|
+
* the generation commit (or unchanged for dry-run).
|
|
2133
|
+
*
|
|
2134
|
+
* Does NOT apply patches — the returned handle must be passed to
|
|
2135
|
+
* `applyPreparedReplay()` to complete the run. No disk writes to the lockfile
|
|
2136
|
+
* happen here; lockfile persistence is deferred to phase 2.
|
|
2137
|
+
*
|
|
2138
|
+
* Callers may land additional commits on HEAD between `prepareReplay` and
|
|
2139
|
+
* `applyPreparedReplay` (e.g., `[fern-autoversion]`).
|
|
2140
|
+
*/
|
|
2141
|
+
async prepareReplay(options) {
|
|
2142
|
+
this.warnings = [];
|
|
2143
|
+
this.detector.warnings.length = 0;
|
|
1802
2144
|
if (options?.skipApplication) {
|
|
1803
|
-
return this.
|
|
2145
|
+
return this._prepareSkipApplication(options);
|
|
1804
2146
|
}
|
|
1805
2147
|
const flow = this.determineFlow();
|
|
1806
2148
|
switch (flow) {
|
|
1807
2149
|
case "first-generation":
|
|
1808
|
-
return this.
|
|
2150
|
+
return this._prepareFirstGeneration(options);
|
|
1809
2151
|
case "no-patches":
|
|
1810
|
-
return this.
|
|
2152
|
+
return this._prepareNoPatchesRegeneration(options);
|
|
1811
2153
|
case "normal-regeneration":
|
|
1812
|
-
return this.
|
|
2154
|
+
return this._prepareNormalRegeneration(options);
|
|
1813
2155
|
}
|
|
1814
2156
|
}
|
|
2157
|
+
/**
|
|
2158
|
+
* Phase 2 of the two-phase replay flow. Applies patches, rebases them against
|
|
2159
|
+
* the generation, saves the lockfile, and commits `[fern-replay]` (or stages
|
|
2160
|
+
* under `stageOnly`). Operates on HEAD at call time — mid-phase commits are
|
|
2161
|
+
* respected.
|
|
2162
|
+
*
|
|
2163
|
+
* For terminal handles (dry-run, first-gen has no patch work, skip-app does
|
|
2164
|
+
* its own post-commit logic), this returns the precomputed report.
|
|
2165
|
+
*/
|
|
2166
|
+
async applyPreparedReplay(prep, options) {
|
|
2167
|
+
if (prep._prepared.terminal) {
|
|
2168
|
+
return prep._prepared.preparedReport;
|
|
2169
|
+
}
|
|
2170
|
+
switch (prep._prepared.flow) {
|
|
2171
|
+
case "first-generation":
|
|
2172
|
+
return this._applyFirstGeneration(prep);
|
|
2173
|
+
case "skip-application":
|
|
2174
|
+
return this._applySkipApplication(prep, options);
|
|
2175
|
+
case "no-patches":
|
|
2176
|
+
return this._applyNoPatchesRegeneration(prep, options);
|
|
2177
|
+
case "normal-regeneration":
|
|
2178
|
+
return this._applyNormalRegeneration(prep, options);
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
/**
|
|
2182
|
+
* Backwards-compatible atomic entry point. Composes `prepareReplay` + `applyPreparedReplay`.
|
|
2183
|
+
* Behavior is byte-identical to the pre-split implementation for all existing callers.
|
|
2184
|
+
*/
|
|
2185
|
+
async runReplay(options) {
|
|
2186
|
+
const prep = await this.prepareReplay(options);
|
|
2187
|
+
return this.applyPreparedReplay(prep, options);
|
|
2188
|
+
}
|
|
1815
2189
|
/**
|
|
1816
2190
|
* Sync the lockfile after a divergent PR was squash-merged.
|
|
1817
2191
|
* Call this BEFORE runReplay() when the CLI detects a merged divergent PR.
|
|
@@ -1872,6 +2246,7 @@ var ReplayService = class {
|
|
|
1872
2246
|
);
|
|
1873
2247
|
}
|
|
1874
2248
|
this.lockManager.save();
|
|
2249
|
+
this.detector.warnings.push(...this.lockManager.warnings);
|
|
1875
2250
|
}
|
|
1876
2251
|
determineFlow() {
|
|
1877
2252
|
try {
|
|
@@ -1884,15 +2259,35 @@ var ReplayService = class {
|
|
|
1884
2259
|
throw error;
|
|
1885
2260
|
}
|
|
1886
2261
|
}
|
|
1887
|
-
|
|
2262
|
+
firstGenerationReport() {
|
|
2263
|
+
return {
|
|
2264
|
+
flow: "first-generation",
|
|
2265
|
+
patchesDetected: 0,
|
|
2266
|
+
patchesApplied: 0,
|
|
2267
|
+
patchesWithConflicts: 0,
|
|
2268
|
+
patchesSkipped: 0,
|
|
2269
|
+
conflicts: []
|
|
2270
|
+
};
|
|
2271
|
+
}
|
|
2272
|
+
async _prepareFirstGeneration(options) {
|
|
1888
2273
|
if (options?.dryRun) {
|
|
2274
|
+
const headSha = await this.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
|
|
1889
2275
|
return {
|
|
1890
2276
|
flow: "first-generation",
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
2277
|
+
previousGenerationSha: null,
|
|
2278
|
+
currentGenerationSha: headSha,
|
|
2279
|
+
baseBranchHead: options.baseBranchHead ?? null,
|
|
2280
|
+
_prepared: {
|
|
2281
|
+
flow: "first-generation",
|
|
2282
|
+
terminal: true,
|
|
2283
|
+
preparedReport: this.firstGenerationReport(),
|
|
2284
|
+
patchesToApply: [],
|
|
2285
|
+
newPatchIds: /* @__PURE__ */ new Set(),
|
|
2286
|
+
detectorWarnings: [],
|
|
2287
|
+
revertedCount: 0,
|
|
2288
|
+
genSha: headSha,
|
|
2289
|
+
optionsSnapshot: options
|
|
2290
|
+
}
|
|
1896
2291
|
};
|
|
1897
2292
|
}
|
|
1898
2293
|
const commitOpts = options ? {
|
|
@@ -1902,22 +2297,36 @@ var ReplayService = class {
|
|
|
1902
2297
|
} : void 0;
|
|
1903
2298
|
await this.committer.commitGeneration("Initial SDK generation", commitOpts);
|
|
1904
2299
|
const genRecord = await this.committer.createGenerationRecord(commitOpts);
|
|
1905
|
-
this.lockManager.
|
|
2300
|
+
this.lockManager.initializeInMemory(genRecord);
|
|
1906
2301
|
return {
|
|
1907
2302
|
flow: "first-generation",
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
2303
|
+
previousGenerationSha: null,
|
|
2304
|
+
currentGenerationSha: genRecord.commit_sha,
|
|
2305
|
+
baseBranchHead: options?.baseBranchHead ?? null,
|
|
2306
|
+
_prepared: {
|
|
2307
|
+
flow: "first-generation",
|
|
2308
|
+
terminal: false,
|
|
2309
|
+
patchesToApply: [],
|
|
2310
|
+
newPatchIds: /* @__PURE__ */ new Set(),
|
|
2311
|
+
detectorWarnings: [],
|
|
2312
|
+
revertedCount: 0,
|
|
2313
|
+
genSha: genRecord.commit_sha,
|
|
2314
|
+
optionsSnapshot: options
|
|
2315
|
+
}
|
|
1913
2316
|
};
|
|
1914
2317
|
}
|
|
2318
|
+
async _applyFirstGeneration(_prep) {
|
|
2319
|
+
this.lockManager.save();
|
|
2320
|
+
return this.firstGenerationReport();
|
|
2321
|
+
}
|
|
1915
2322
|
/**
|
|
1916
|
-
* Skip-application mode: commit the generation and update the
|
|
1917
|
-
* but don't detect or apply patches. Sets a marker
|
|
1918
|
-
* run skips revert detection in preGenerationRebase().
|
|
2323
|
+
* Skip-application mode phase 1: commit the generation and update the
|
|
2324
|
+
* in-memory lockfile, but don't detect or apply patches. Sets a marker
|
|
2325
|
+
* so the next normal run skips revert detection in preGenerationRebase().
|
|
2326
|
+
*
|
|
2327
|
+
* Disk save is deferred to `_applySkipApplication`.
|
|
1919
2328
|
*/
|
|
1920
|
-
async
|
|
2329
|
+
async _prepareSkipApplication(options) {
|
|
1921
2330
|
const commitOpts = options ? {
|
|
1922
2331
|
cliVersion: options.cliVersion ?? "unknown",
|
|
1923
2332
|
generatorVersions: options.generatorVersions ?? {},
|
|
@@ -1926,8 +2335,10 @@ var ReplayService = class {
|
|
|
1926
2335
|
await this.committer.commitGeneration("Update SDK (replay skipped)", commitOpts);
|
|
1927
2336
|
await this.cleanupStaleConflictMarkers();
|
|
1928
2337
|
const genRecord = await this.committer.createGenerationRecord(commitOpts);
|
|
2338
|
+
let previousGenerationSha = null;
|
|
1929
2339
|
try {
|
|
1930
|
-
this.lockManager.read();
|
|
2340
|
+
const lock = this.lockManager.read();
|
|
2341
|
+
previousGenerationSha = lock.current_generation ?? null;
|
|
1931
2342
|
this.lockManager.addGeneration(genRecord);
|
|
1932
2343
|
} catch (error) {
|
|
1933
2344
|
if (error instanceof LockfileNotFoundError) {
|
|
@@ -1937,7 +2348,26 @@ var ReplayService = class {
|
|
|
1937
2348
|
}
|
|
1938
2349
|
}
|
|
1939
2350
|
this.lockManager.setReplaySkippedAt((/* @__PURE__ */ new Date()).toISOString());
|
|
2351
|
+
return {
|
|
2352
|
+
flow: "skip-application",
|
|
2353
|
+
previousGenerationSha,
|
|
2354
|
+
currentGenerationSha: genRecord.commit_sha,
|
|
2355
|
+
baseBranchHead: options?.baseBranchHead ?? null,
|
|
2356
|
+
_prepared: {
|
|
2357
|
+
flow: "skip-application",
|
|
2358
|
+
terminal: false,
|
|
2359
|
+
patchesToApply: [],
|
|
2360
|
+
newPatchIds: /* @__PURE__ */ new Set(),
|
|
2361
|
+
detectorWarnings: [],
|
|
2362
|
+
revertedCount: 0,
|
|
2363
|
+
genSha: genRecord.commit_sha,
|
|
2364
|
+
optionsSnapshot: options
|
|
2365
|
+
}
|
|
2366
|
+
};
|
|
2367
|
+
}
|
|
2368
|
+
async _applySkipApplication(_prep, options) {
|
|
1940
2369
|
this.lockManager.save();
|
|
2370
|
+
const credentialWarnings = [...this.lockManager.warnings];
|
|
1941
2371
|
if (!options?.stageOnly) {
|
|
1942
2372
|
await this.committer.commitReplay(0);
|
|
1943
2373
|
} else {
|
|
@@ -1949,14 +2379,18 @@ var ReplayService = class {
|
|
|
1949
2379
|
patchesApplied: 0,
|
|
1950
2380
|
patchesWithConflicts: 0,
|
|
1951
2381
|
patchesSkipped: 0,
|
|
1952
|
-
conflicts: []
|
|
2382
|
+
conflicts: [],
|
|
2383
|
+
warnings: credentialWarnings.length > 0 ? credentialWarnings : void 0
|
|
1953
2384
|
};
|
|
1954
2385
|
}
|
|
1955
|
-
async
|
|
1956
|
-
const
|
|
1957
|
-
const
|
|
2386
|
+
async _prepareNoPatchesRegeneration(options) {
|
|
2387
|
+
const preLock = this.lockManager.read();
|
|
2388
|
+
const previousGenerationSha = preLock.current_generation ?? null;
|
|
2389
|
+
let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
|
|
2390
|
+
const detectorWarnings = [...this.detector.warnings];
|
|
1958
2391
|
if (options?.dryRun) {
|
|
1959
|
-
|
|
2392
|
+
const dryRunWarnings = [...detectorWarnings, ...this.warnings];
|
|
2393
|
+
const preparedReport = {
|
|
1960
2394
|
flow: "no-patches",
|
|
1961
2395
|
patchesDetected: newPatches.length,
|
|
1962
2396
|
patchesApplied: 0,
|
|
@@ -1965,9 +2399,43 @@ var ReplayService = class {
|
|
|
1965
2399
|
patchesReverted: revertedPatchIds.length,
|
|
1966
2400
|
conflicts: [],
|
|
1967
2401
|
wouldApply: newPatches,
|
|
1968
|
-
warnings:
|
|
2402
|
+
warnings: dryRunWarnings.length > 0 ? dryRunWarnings : void 0
|
|
2403
|
+
};
|
|
2404
|
+
const headSha = await this.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
|
|
2405
|
+
return {
|
|
2406
|
+
flow: "no-patches",
|
|
2407
|
+
previousGenerationSha,
|
|
2408
|
+
currentGenerationSha: headSha,
|
|
2409
|
+
baseBranchHead: options.baseBranchHead ?? null,
|
|
2410
|
+
_prepared: {
|
|
2411
|
+
flow: "no-patches",
|
|
2412
|
+
terminal: true,
|
|
2413
|
+
preparedReport,
|
|
2414
|
+
patchesToApply: [],
|
|
2415
|
+
newPatchIds: /* @__PURE__ */ new Set(),
|
|
2416
|
+
detectorWarnings,
|
|
2417
|
+
revertedCount: revertedPatchIds.length,
|
|
2418
|
+
genSha: headSha,
|
|
2419
|
+
optionsSnapshot: options
|
|
2420
|
+
}
|
|
1969
2421
|
};
|
|
1970
2422
|
}
|
|
2423
|
+
{
|
|
2424
|
+
const preCurrentGen = preLock.current_generation;
|
|
2425
|
+
const uniqueFiles = [...new Set(newPatches.flatMap((p) => p.files))];
|
|
2426
|
+
const absorbedFiles = /* @__PURE__ */ new Set();
|
|
2427
|
+
for (const file of uniqueFiles) {
|
|
2428
|
+
const diff = await this.git.exec(["diff", preCurrentGen, "HEAD", "--", file]).catch(() => null);
|
|
2429
|
+
if (diff !== null && !diff.trim()) {
|
|
2430
|
+
absorbedFiles.add(file);
|
|
2431
|
+
}
|
|
2432
|
+
}
|
|
2433
|
+
if (absorbedFiles.size > 0) {
|
|
2434
|
+
newPatches = newPatches.filter(
|
|
2435
|
+
(p) => p.files.length === 0 || !p.files.every((f) => absorbedFiles.has(f))
|
|
2436
|
+
);
|
|
2437
|
+
}
|
|
2438
|
+
}
|
|
1971
2439
|
for (const id of revertedPatchIds) {
|
|
1972
2440
|
try {
|
|
1973
2441
|
this.lockManager.removePatch(id);
|
|
@@ -1983,9 +2451,31 @@ var ReplayService = class {
|
|
|
1983
2451
|
await this.cleanupStaleConflictMarkers();
|
|
1984
2452
|
const genRecord = await this.committer.createGenerationRecord(commitOpts);
|
|
1985
2453
|
this.lockManager.addGeneration(genRecord);
|
|
2454
|
+
return {
|
|
2455
|
+
flow: "no-patches",
|
|
2456
|
+
previousGenerationSha,
|
|
2457
|
+
currentGenerationSha: genRecord.commit_sha,
|
|
2458
|
+
baseBranchHead: options?.baseBranchHead ?? null,
|
|
2459
|
+
_prepared: {
|
|
2460
|
+
flow: "no-patches",
|
|
2461
|
+
terminal: false,
|
|
2462
|
+
patchesToApply: newPatches,
|
|
2463
|
+
newPatchIds: new Set(newPatches.map((p) => p.id)),
|
|
2464
|
+
detectorWarnings,
|
|
2465
|
+
revertedCount: revertedPatchIds.length,
|
|
2466
|
+
genSha: genRecord.commit_sha,
|
|
2467
|
+
optionsSnapshot: options
|
|
2468
|
+
}
|
|
2469
|
+
};
|
|
2470
|
+
}
|
|
2471
|
+
async _applyNoPatchesRegeneration(prep, options) {
|
|
2472
|
+
const newPatches = prep._prepared.patchesToApply;
|
|
2473
|
+
const detectorWarnings = prep._prepared.detectorWarnings;
|
|
2474
|
+
const genSha = prep._prepared.genSha;
|
|
1986
2475
|
let results = [];
|
|
1987
2476
|
if (newPatches.length > 0) {
|
|
1988
2477
|
results = await this.applicator.applyPatches(newPatches);
|
|
2478
|
+
this._lastApplyResults = results;
|
|
1989
2479
|
this.revertConflictingFiles(results);
|
|
1990
2480
|
for (const result of results) {
|
|
1991
2481
|
if (result.status === "conflict") {
|
|
@@ -1993,13 +2483,14 @@ var ReplayService = class {
|
|
|
1993
2483
|
}
|
|
1994
2484
|
}
|
|
1995
2485
|
}
|
|
1996
|
-
const rebaseCounts = await this.rebasePatches(results,
|
|
2486
|
+
const rebaseCounts = await this.rebasePatches(results, genSha);
|
|
1997
2487
|
for (const patch of newPatches) {
|
|
1998
2488
|
if (!rebaseCounts.absorbedPatchIds.has(patch.id)) {
|
|
1999
2489
|
this.lockManager.addPatch(patch);
|
|
2000
2490
|
}
|
|
2001
2491
|
}
|
|
2002
2492
|
this.lockManager.save();
|
|
2493
|
+
this.warnings.push(...this.lockManager.warnings);
|
|
2003
2494
|
if (newPatches.length > 0) {
|
|
2004
2495
|
if (!options?.stageOnly) {
|
|
2005
2496
|
const appliedCount = results.filter((r) => r.status === "applied").length;
|
|
@@ -2008,15 +2499,27 @@ var ReplayService = class {
|
|
|
2008
2499
|
await this.committer.stageAll();
|
|
2009
2500
|
}
|
|
2010
2501
|
}
|
|
2011
|
-
|
|
2502
|
+
const warnings = [...detectorWarnings, ...this.warnings];
|
|
2503
|
+
return this.buildReport(
|
|
2504
|
+
"no-patches",
|
|
2505
|
+
newPatches,
|
|
2506
|
+
results,
|
|
2507
|
+
prep._prepared.optionsSnapshot,
|
|
2508
|
+
warnings,
|
|
2509
|
+
rebaseCounts,
|
|
2510
|
+
void 0,
|
|
2511
|
+
prep._prepared.revertedCount
|
|
2512
|
+
);
|
|
2012
2513
|
}
|
|
2013
|
-
async
|
|
2514
|
+
async _prepareNormalRegeneration(options) {
|
|
2515
|
+
const preLock = this.lockManager.read();
|
|
2516
|
+
const previousGenerationSha = preLock.current_generation ?? null;
|
|
2014
2517
|
if (options?.dryRun) {
|
|
2015
2518
|
const existingPatches2 = this.lockManager.getPatches();
|
|
2016
2519
|
const { patches: newPatches2, revertedPatchIds: dryRunReverted } = await this.detector.detectNewPatches();
|
|
2017
|
-
const
|
|
2520
|
+
const warnings = [...this.detector.warnings, ...this.warnings];
|
|
2018
2521
|
const allPatches2 = [...existingPatches2, ...newPatches2];
|
|
2019
|
-
|
|
2522
|
+
const preparedReport = {
|
|
2020
2523
|
flow: "normal-regeneration",
|
|
2021
2524
|
patchesDetected: allPatches2.length,
|
|
2022
2525
|
patchesApplied: 0,
|
|
@@ -2025,7 +2528,25 @@ var ReplayService = class {
|
|
|
2025
2528
|
patchesReverted: dryRunReverted.length,
|
|
2026
2529
|
conflicts: [],
|
|
2027
2530
|
wouldApply: allPatches2,
|
|
2028
|
-
warnings:
|
|
2531
|
+
warnings: warnings.length > 0 ? warnings : void 0
|
|
2532
|
+
};
|
|
2533
|
+
const headSha = await this.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
|
|
2534
|
+
return {
|
|
2535
|
+
flow: "normal-regeneration",
|
|
2536
|
+
previousGenerationSha,
|
|
2537
|
+
currentGenerationSha: headSha,
|
|
2538
|
+
baseBranchHead: options.baseBranchHead ?? null,
|
|
2539
|
+
_prepared: {
|
|
2540
|
+
flow: "normal-regeneration",
|
|
2541
|
+
terminal: true,
|
|
2542
|
+
preparedReport,
|
|
2543
|
+
patchesToApply: [],
|
|
2544
|
+
newPatchIds: /* @__PURE__ */ new Set(),
|
|
2545
|
+
detectorWarnings: [...this.detector.warnings],
|
|
2546
|
+
revertedCount: dryRunReverted.length,
|
|
2547
|
+
genSha: headSha,
|
|
2548
|
+
optionsSnapshot: options
|
|
2549
|
+
}
|
|
2029
2550
|
};
|
|
2030
2551
|
}
|
|
2031
2552
|
let existingPatches = this.lockManager.getPatches();
|
|
@@ -2046,7 +2567,7 @@ var ReplayService = class {
|
|
|
2046
2567
|
}
|
|
2047
2568
|
existingPatches = this.lockManager.getPatches();
|
|
2048
2569
|
let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
|
|
2049
|
-
const
|
|
2570
|
+
const detectorWarnings = [...this.detector.warnings];
|
|
2050
2571
|
if (removedByPreRebase.length > 0) {
|
|
2051
2572
|
const removedOriginalCommits = new Set(removedByPreRebase.map((p) => p.original_commit));
|
|
2052
2573
|
const removedOriginalMessages = new Set(removedByPreRebase.map((p) => p.original_message));
|
|
@@ -2077,14 +2598,39 @@ var ReplayService = class {
|
|
|
2077
2598
|
await this.cleanupStaleConflictMarkers();
|
|
2078
2599
|
const genRecord = await this.committer.createGenerationRecord(commitOpts);
|
|
2079
2600
|
this.lockManager.addGeneration(genRecord);
|
|
2601
|
+
return {
|
|
2602
|
+
flow: "normal-regeneration",
|
|
2603
|
+
previousGenerationSha,
|
|
2604
|
+
currentGenerationSha: genRecord.commit_sha,
|
|
2605
|
+
baseBranchHead: options?.baseBranchHead ?? null,
|
|
2606
|
+
_prepared: {
|
|
2607
|
+
flow: "normal-regeneration",
|
|
2608
|
+
terminal: false,
|
|
2609
|
+
patchesToApply: allPatches,
|
|
2610
|
+
newPatchIds: new Set(newPatches.map((p) => p.id)),
|
|
2611
|
+
preRebaseCounts,
|
|
2612
|
+
detectorWarnings,
|
|
2613
|
+
revertedCount: revertedPatchIds.length,
|
|
2614
|
+
genSha: genRecord.commit_sha,
|
|
2615
|
+
optionsSnapshot: options
|
|
2616
|
+
}
|
|
2617
|
+
};
|
|
2618
|
+
}
|
|
2619
|
+
async _applyNormalRegeneration(prep, options) {
|
|
2620
|
+
const allPatches = prep._prepared.patchesToApply;
|
|
2621
|
+
const newPatchIds = prep._prepared.newPatchIds;
|
|
2622
|
+
const detectorWarnings = prep._prepared.detectorWarnings;
|
|
2623
|
+
const genSha = prep._prepared.genSha;
|
|
2080
2624
|
const results = await this.applicator.applyPatches(allPatches);
|
|
2625
|
+
this._lastApplyResults = results;
|
|
2081
2626
|
this.revertConflictingFiles(results);
|
|
2082
2627
|
for (const result of results) {
|
|
2083
2628
|
if (result.status === "conflict") {
|
|
2084
2629
|
result.patch.status = "unresolved";
|
|
2085
2630
|
}
|
|
2086
2631
|
}
|
|
2087
|
-
const rebaseCounts = await this.rebasePatches(results,
|
|
2632
|
+
const rebaseCounts = await this.rebasePatches(results, genSha);
|
|
2633
|
+
const newPatches = allPatches.filter((p) => newPatchIds.has(p.id));
|
|
2088
2634
|
for (const patch of newPatches) {
|
|
2089
2635
|
if (!rebaseCounts.absorbedPatchIds.has(patch.id)) {
|
|
2090
2636
|
this.lockManager.addPatch(patch);
|
|
@@ -2099,21 +2645,23 @@ var ReplayService = class {
|
|
|
2099
2645
|
}
|
|
2100
2646
|
}
|
|
2101
2647
|
this.lockManager.save();
|
|
2648
|
+
this.warnings.push(...this.lockManager.warnings);
|
|
2102
2649
|
if (options?.stageOnly) {
|
|
2103
2650
|
await this.committer.stageAll();
|
|
2104
2651
|
} else {
|
|
2105
2652
|
const appliedCount = results.filter((r) => r.status === "applied").length;
|
|
2106
2653
|
await this.committer.commitReplay(appliedCount, allPatches);
|
|
2107
2654
|
}
|
|
2655
|
+
const warnings = [...detectorWarnings, ...this.warnings];
|
|
2108
2656
|
return this.buildReport(
|
|
2109
2657
|
"normal-regeneration",
|
|
2110
2658
|
allPatches,
|
|
2111
2659
|
results,
|
|
2112
|
-
|
|
2660
|
+
prep._prepared.optionsSnapshot,
|
|
2113
2661
|
warnings,
|
|
2114
2662
|
rebaseCounts,
|
|
2115
|
-
preRebaseCounts,
|
|
2116
|
-
|
|
2663
|
+
prep._prepared.preRebaseCounts,
|
|
2664
|
+
prep._prepared.revertedCount
|
|
2117
2665
|
);
|
|
2118
2666
|
}
|
|
2119
2667
|
/**
|
|
@@ -2128,6 +2676,7 @@ var ReplayService = class {
|
|
|
2128
2676
|
let keptAsUserOwned = 0;
|
|
2129
2677
|
const seenContentHashes = /* @__PURE__ */ new Set();
|
|
2130
2678
|
const absorbedPatchIds = /* @__PURE__ */ new Set();
|
|
2679
|
+
const fernignorePatterns = this.readFernignorePatterns();
|
|
2131
2680
|
for (const result of results) {
|
|
2132
2681
|
if (result.resolvedFiles && Object.keys(result.resolvedFiles).length > 0) {
|
|
2133
2682
|
const patch = result.patch;
|
|
@@ -2148,34 +2697,78 @@ var ReplayService = class {
|
|
|
2148
2697
|
const patch = result.patch;
|
|
2149
2698
|
if (patch.base_generation === currentGenSha) continue;
|
|
2150
2699
|
try {
|
|
2151
|
-
const
|
|
2152
|
-
|
|
2700
|
+
const originalByResolved = {};
|
|
2701
|
+
if (result.resolvedFiles) {
|
|
2702
|
+
for (const [orig, resolved] of Object.entries(result.resolvedFiles)) {
|
|
2703
|
+
originalByResolved[resolved] = orig;
|
|
2704
|
+
}
|
|
2705
|
+
}
|
|
2706
|
+
const isUserOwnedPerFile = await Promise.all(
|
|
2153
2707
|
patch.files.map(async (file) => {
|
|
2154
|
-
if (file === ".fernignore")
|
|
2708
|
+
if (file === ".fernignore") return true;
|
|
2709
|
+
if (fernignorePatterns.some((p) => file === p || minimatch2(file, p))) {
|
|
2155
2710
|
return true;
|
|
2156
2711
|
}
|
|
2157
|
-
if (
|
|
2712
|
+
if (patch.user_owned) return true;
|
|
2713
|
+
if (this.fileIsCreationInPatchContent(patch.patch_content, originalByResolved[file] ?? file)) {
|
|
2158
2714
|
return true;
|
|
2159
2715
|
}
|
|
2160
2716
|
const content = await this.git.showFile(currentGenSha, file);
|
|
2161
2717
|
return content === null;
|
|
2162
2718
|
})
|
|
2163
2719
|
);
|
|
2164
|
-
const
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2720
|
+
const hasProtectedFiles = patch.files.some(
|
|
2721
|
+
(file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch2(file, p))
|
|
2722
|
+
);
|
|
2723
|
+
const allUserOwned = isUserOwnedPerFile.every(Boolean);
|
|
2724
|
+
if (allUserOwned && patch.files.length > 0) {
|
|
2725
|
+
const baseChanged = patch.base_generation !== currentGenSha;
|
|
2726
|
+
const userDiff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
|
|
2727
|
+
if (userDiff != null && userDiff.trim()) {
|
|
2728
|
+
const newHash = this.detector.computeContentHash(userDiff);
|
|
2729
|
+
patch.patch_content = userDiff;
|
|
2730
|
+
patch.content_hash = newHash;
|
|
2731
|
+
}
|
|
2732
|
+
patch.base_generation = currentGenSha;
|
|
2733
|
+
try {
|
|
2734
|
+
this.lockManager.updatePatch(patch.id, {
|
|
2735
|
+
base_generation: currentGenSha,
|
|
2736
|
+
...!patch.user_owned ? { user_owned: true } : {},
|
|
2737
|
+
...userDiff != null && userDiff.trim() ? { patch_content: patch.patch_content, content_hash: patch.content_hash } : {}
|
|
2738
|
+
});
|
|
2739
|
+
} catch {
|
|
2740
|
+
}
|
|
2741
|
+
patch.user_owned = true;
|
|
2169
2742
|
keptAsUserOwned++;
|
|
2743
|
+
if (baseChanged) repointed++;
|
|
2170
2744
|
continue;
|
|
2171
2745
|
}
|
|
2172
2746
|
const diff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
|
|
2173
|
-
if (
|
|
2747
|
+
if (diff === null) continue;
|
|
2748
|
+
if (!diff.trim()) {
|
|
2749
|
+
if (hasProtectedFiles) {
|
|
2750
|
+
patch.base_generation = currentGenSha;
|
|
2751
|
+
try {
|
|
2752
|
+
this.lockManager.updatePatch(patch.id, { base_generation: currentGenSha });
|
|
2753
|
+
} catch {
|
|
2754
|
+
}
|
|
2755
|
+
keptAsUserOwned++;
|
|
2756
|
+
continue;
|
|
2757
|
+
}
|
|
2174
2758
|
this.lockManager.removePatch(patch.id);
|
|
2175
2759
|
absorbedPatchIds.add(patch.id);
|
|
2176
2760
|
absorbed++;
|
|
2177
2761
|
continue;
|
|
2178
2762
|
}
|
|
2763
|
+
if (hasProtectedFiles) {
|
|
2764
|
+
patch.base_generation = currentGenSha;
|
|
2765
|
+
try {
|
|
2766
|
+
this.lockManager.updatePatch(patch.id, { base_generation: currentGenSha });
|
|
2767
|
+
} catch {
|
|
2768
|
+
}
|
|
2769
|
+
keptAsUserOwned++;
|
|
2770
|
+
continue;
|
|
2771
|
+
}
|
|
2179
2772
|
const newContentHash = this.detector.computeContentHash(diff);
|
|
2180
2773
|
if (seenContentHashes.has(newContentHash)) {
|
|
2181
2774
|
this.lockManager.removePatch(patch.id);
|
|
@@ -2184,17 +2777,89 @@ var ReplayService = class {
|
|
|
2184
2777
|
continue;
|
|
2185
2778
|
}
|
|
2186
2779
|
seenContentHashes.add(newContentHash);
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2780
|
+
patch.base_generation = currentGenSha;
|
|
2781
|
+
patch.patch_content = diff;
|
|
2782
|
+
patch.content_hash = newContentHash;
|
|
2783
|
+
try {
|
|
2784
|
+
this.lockManager.updatePatch(patch.id, {
|
|
2785
|
+
base_generation: currentGenSha,
|
|
2786
|
+
patch_content: diff,
|
|
2787
|
+
content_hash: newContentHash
|
|
2788
|
+
});
|
|
2789
|
+
} catch {
|
|
2790
|
+
}
|
|
2192
2791
|
contentRebased++;
|
|
2193
2792
|
} catch {
|
|
2194
2793
|
}
|
|
2195
2794
|
}
|
|
2196
2795
|
return { absorbed, repointed, contentRebased, keptAsUserOwned, absorbedPatchIds };
|
|
2197
2796
|
}
|
|
2797
|
+
/**
|
|
2798
|
+
* Determine whether `file` is user-owned (never produced by the generator).
|
|
2799
|
+
*
|
|
2800
|
+
* Resolution order (first match wins):
|
|
2801
|
+
* 1. `patch.user_owned === true` — authoritative, persisted across cycles.
|
|
2802
|
+
* 2. `.fernignore` itself, or file matches a .fernignore pattern.
|
|
2803
|
+
* 3. Legacy-safe signal: `patch_content` shows this file as a `--- /dev/null`
|
|
2804
|
+
* creation. Works for patches that predate the `user_owned` field or
|
|
2805
|
+
* whose flag was lost. Immune to rename tricks — relies on raw patch text.
|
|
2806
|
+
* 4. Tree-based check against `patch.base_generation` when it is reachable
|
|
2807
|
+
* AND distinct from `currentGenSha`. If the base tree is unreachable
|
|
2808
|
+
* (shallow clone / aggressive `git gc --prune`), emit a one-time
|
|
2809
|
+
* customer-actionable warning (deduplicated via warnedGens) and
|
|
2810
|
+
* conservatively treat as user-owned — strictly safer than silent
|
|
2811
|
+
* absorption (FER-9809).
|
|
2812
|
+
* 5. Final fallback — check `currentGenSha` when there is no usable base
|
|
2813
|
+
* (legacy one-gen lockfile). Preserves pre-fix behavior for that edge.
|
|
2814
|
+
*
|
|
2815
|
+
* `originalFileName` is the pre-rename path when the generator moved the
|
|
2816
|
+
* file between the patch's base and the current generation. Tree lookups
|
|
2817
|
+
* at ancestor generations use the original path — that's where the
|
|
2818
|
+
* generator produced the content.
|
|
2819
|
+
*/
|
|
2820
|
+
async isFileUserOwned(file, patch, currentGenSha, fernignorePatterns, warnedGens, originalFileName) {
|
|
2821
|
+
if (patch.user_owned) return true;
|
|
2822
|
+
if (file === ".fernignore") return true;
|
|
2823
|
+
if (fernignorePatterns.some((p) => file === p || minimatch2(file, p))) return true;
|
|
2824
|
+
if (this.fileIsCreationInPatchContent(patch.patch_content, originalFileName ?? file)) {
|
|
2825
|
+
return true;
|
|
2826
|
+
}
|
|
2827
|
+
const base = patch.base_generation;
|
|
2828
|
+
if (base && base !== currentGenSha) {
|
|
2829
|
+
const reachable = await this.git.commitExists(base) || await this.git.treeExists(base);
|
|
2830
|
+
if (!reachable) {
|
|
2831
|
+
if (!warnedGens.has(base)) {
|
|
2832
|
+
warnedGens.add(base);
|
|
2833
|
+
this.warnings.push(
|
|
2834
|
+
`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.`
|
|
2835
|
+
);
|
|
2836
|
+
}
|
|
2837
|
+
return true;
|
|
2838
|
+
}
|
|
2839
|
+
return await this.git.showFile(base, originalFileName ?? file) === null;
|
|
2840
|
+
}
|
|
2841
|
+
return await this.git.showFile(currentGenSha, originalFileName ?? file) === null;
|
|
2842
|
+
}
|
|
2843
|
+
/**
|
|
2844
|
+
* Returns true if `file`'s diff section in `patchContent` starts with
|
|
2845
|
+
* `--- /dev/null`, meaning this file was introduced by the patch (user
|
|
2846
|
+
* creation). Used as a legacy-safe fallback when `patch.user_owned` is
|
|
2847
|
+
* absent or cleared.
|
|
2848
|
+
*/
|
|
2849
|
+
fileIsCreationInPatchContent(patchContent, file) {
|
|
2850
|
+
const lines = patchContent.split("\n");
|
|
2851
|
+
let inFileSection = false;
|
|
2852
|
+
for (const line of lines) {
|
|
2853
|
+
if (line.startsWith("diff --git ")) {
|
|
2854
|
+
inFileSection = line.includes(` b/${file}`) || line.endsWith(` b/${file}`);
|
|
2855
|
+
continue;
|
|
2856
|
+
}
|
|
2857
|
+
if (inFileSection && line.startsWith("--- ")) {
|
|
2858
|
+
return line === "--- /dev/null";
|
|
2859
|
+
}
|
|
2860
|
+
}
|
|
2861
|
+
return false;
|
|
2862
|
+
}
|
|
2198
2863
|
/**
|
|
2199
2864
|
* For conflict patches with mixed results (some files merged, some conflicted),
|
|
2200
2865
|
* check if the cleanly merged files were absorbed by the generator (empty diff).
|
|
@@ -2208,13 +2873,14 @@ var ReplayService = class {
|
|
|
2208
2873
|
const cleanFiles = result.fileResults.filter((f) => f.status === "merged").map((f) => f.file);
|
|
2209
2874
|
if (cleanFiles.length === 0) return;
|
|
2210
2875
|
const patch = result.patch;
|
|
2876
|
+
if (patch.user_owned) return;
|
|
2211
2877
|
const fernignorePatterns = this.readFernignorePatterns();
|
|
2878
|
+
const warnedGens = /* @__PURE__ */ new Set();
|
|
2212
2879
|
const generatorCleanFiles = [];
|
|
2213
2880
|
for (const file of cleanFiles) {
|
|
2214
|
-
if (file
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
if (content === null) continue;
|
|
2881
|
+
if (await this.isFileUserOwned(file, patch, currentGenSha, fernignorePatterns, warnedGens)) {
|
|
2882
|
+
continue;
|
|
2883
|
+
}
|
|
2218
2884
|
generatorCleanFiles.push(file);
|
|
2219
2885
|
}
|
|
2220
2886
|
if (generatorCleanFiles.length === 0) return;
|
|
@@ -2251,7 +2917,35 @@ var ReplayService = class {
|
|
|
2251
2917
|
let conflictResolved = 0;
|
|
2252
2918
|
let conflictAbsorbed = 0;
|
|
2253
2919
|
let contentRefreshed = 0;
|
|
2920
|
+
const fernignorePatterns = this.readFernignorePatterns();
|
|
2921
|
+
const warnedGens = /* @__PURE__ */ new Set();
|
|
2922
|
+
const reachabilityCache = /* @__PURE__ */ new Map();
|
|
2923
|
+
const isReachable = async (sha) => {
|
|
2924
|
+
const cached = reachabilityCache.get(sha);
|
|
2925
|
+
if (cached !== void 0) return cached;
|
|
2926
|
+
const reachable = await this.git.commitExists(sha) || await this.git.treeExists(sha);
|
|
2927
|
+
reachabilityCache.set(sha, reachable);
|
|
2928
|
+
return reachable;
|
|
2929
|
+
};
|
|
2930
|
+
const allPatchFilesUserOwned = async (patch) => {
|
|
2931
|
+
if (patch.user_owned) return true;
|
|
2932
|
+
if (patch.files.length === 0) return false;
|
|
2933
|
+
for (const file of patch.files) {
|
|
2934
|
+
if (!await this.isFileUserOwned(file, patch, currentGen, fernignorePatterns, warnedGens)) {
|
|
2935
|
+
return false;
|
|
2936
|
+
}
|
|
2937
|
+
}
|
|
2938
|
+
return true;
|
|
2939
|
+
};
|
|
2254
2940
|
for (const patch of patches) {
|
|
2941
|
+
if (patch.base_generation && patch.base_generation !== currentGen && !warnedGens.has(patch.base_generation)) {
|
|
2942
|
+
if (!await isReachable(patch.base_generation)) {
|
|
2943
|
+
warnedGens.add(patch.base_generation);
|
|
2944
|
+
this.warnings.push(
|
|
2945
|
+
`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.`
|
|
2946
|
+
);
|
|
2947
|
+
}
|
|
2948
|
+
}
|
|
2255
2949
|
if (patch.status != null) {
|
|
2256
2950
|
delete patch.status;
|
|
2257
2951
|
continue;
|
|
@@ -2261,6 +2955,7 @@ var ReplayService = class {
|
|
|
2261
2955
|
const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
|
|
2262
2956
|
if (diff === null) continue;
|
|
2263
2957
|
if (!diff.trim()) {
|
|
2958
|
+
if (await allPatchFilesUserOwned(patch)) continue;
|
|
2264
2959
|
this.lockManager.removePatch(patch.id);
|
|
2265
2960
|
contentRefreshed++;
|
|
2266
2961
|
continue;
|
|
@@ -2272,6 +2967,12 @@ var ReplayService = class {
|
|
|
2272
2967
|
if (hasStaleMarkers) {
|
|
2273
2968
|
continue;
|
|
2274
2969
|
}
|
|
2970
|
+
const isProtected = patch.files.some(
|
|
2971
|
+
(file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch2(file, p))
|
|
2972
|
+
);
|
|
2973
|
+
if (isProtected) {
|
|
2974
|
+
continue;
|
|
2975
|
+
}
|
|
2275
2976
|
const newContentHash = this.detector.computeContentHash(diff);
|
|
2276
2977
|
if (newContentHash !== patch.content_hash) {
|
|
2277
2978
|
const filesOutput = await this.git.exec(["diff", "--name-only", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
|
|
@@ -2297,6 +2998,13 @@ var ReplayService = class {
|
|
|
2297
2998
|
const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
|
|
2298
2999
|
if (diff === null) continue;
|
|
2299
3000
|
if (!diff.trim()) {
|
|
3001
|
+
if (await allPatchFilesUserOwned(patch)) {
|
|
3002
|
+
try {
|
|
3003
|
+
this.lockManager.updatePatch(patch.id, { base_generation: currentGen });
|
|
3004
|
+
} catch {
|
|
3005
|
+
}
|
|
3006
|
+
continue;
|
|
3007
|
+
}
|
|
2300
3008
|
this.lockManager.removePatch(patch.id);
|
|
2301
3009
|
conflictAbsorbed++;
|
|
2302
3010
|
continue;
|
|
@@ -2808,7 +3516,10 @@ function ensureGitattributesEntries(outputDir) {
|
|
|
2808
3516
|
writeFileSync4(gitattributesPath, content, "utf-8");
|
|
2809
3517
|
}
|
|
2810
3518
|
function computeContentHash(patchContent) {
|
|
2811
|
-
const
|
|
3519
|
+
const lines = patchContent.split("\n");
|
|
3520
|
+
const diffStart = lines.findIndex((l) => l.startsWith("diff --git "));
|
|
3521
|
+
const relevantLines = diffStart > 0 ? lines.slice(diffStart) : lines;
|
|
3522
|
+
const normalized = relevantLines.filter((line) => !line.startsWith("index ")).join("\n").replace(/\n-- \n[\d]+\.[\d]+[^\n]*\s*$/, "");
|
|
2812
3523
|
return `sha256:${createHash3("sha256").update(normalized).digest("hex")}`;
|
|
2813
3524
|
}
|
|
2814
3525
|
|
|
@@ -3119,6 +3830,7 @@ function status(outputDir) {
|
|
|
3119
3830
|
};
|
|
3120
3831
|
}
|
|
3121
3832
|
export {
|
|
3833
|
+
CREDENTIAL_PATTERNS,
|
|
3122
3834
|
FERN_BOT_EMAIL,
|
|
3123
3835
|
FERN_BOT_LOGIN,
|
|
3124
3836
|
FERN_BOT_NAME,
|
|
@@ -3130,15 +3842,18 @@ export {
|
|
|
3130
3842
|
ReplayCommitter,
|
|
3131
3843
|
ReplayDetector,
|
|
3132
3844
|
ReplayService,
|
|
3845
|
+
SENSITIVE_FILE_PATTERNS,
|
|
3133
3846
|
bootstrap,
|
|
3134
3847
|
forget,
|
|
3135
3848
|
isGenerationCommit,
|
|
3136
3849
|
isReplayCommit,
|
|
3137
3850
|
isRevertCommit,
|
|
3851
|
+
isSensitiveFile,
|
|
3138
3852
|
parseRevertedMessage,
|
|
3139
3853
|
parseRevertedSha,
|
|
3140
3854
|
reset,
|
|
3141
3855
|
resolve,
|
|
3856
|
+
scanForCredentials,
|
|
3142
3857
|
status,
|
|
3143
3858
|
threeWayMerge
|
|
3144
3859
|
};
|