@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.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
|
|
@@ -437,7 +493,7 @@ var FERN_SUPPORT_NAMES = ["fern-support", "Fern Support"];
|
|
|
437
493
|
function isGenerationCommit(commit) {
|
|
438
494
|
const isFernSupport = FERN_SUPPORT_NAMES.includes(commit.authorName);
|
|
439
495
|
const isBotAuthor = !isFernSupport && (commit.authorLogin === FERN_BOT_LOGIN || commit.authorEmail === FERN_BOT_EMAIL || commit.authorName === FERN_BOT_NAME);
|
|
440
|
-
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.
|
|
496
|
+
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.
|
|
441
497
|
// The default PR title is "SDK Generation" (from GithubStep's commitMessage default).
|
|
442
498
|
// GitHub appends "(#N)" for the PR number, e.g. "SDK Generation (#70)".
|
|
443
499
|
commit.message.startsWith("SDK Generation");
|
|
@@ -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;
|
|
@@ -1846,20 +2180,66 @@ var ReplayService = class {
|
|
|
1846
2180
|
this.applicator = new ReplayApplicator(git, this.lockManager, outputDir);
|
|
1847
2181
|
this.committer = new ReplayCommitter(git, outputDir);
|
|
1848
2182
|
}
|
|
1849
|
-
|
|
2183
|
+
/**
|
|
2184
|
+
* Phase 1 of the two-phase replay flow. Runs preGenerationRebase (when applicable),
|
|
2185
|
+
* detects new patches, and creates the `[fern-generated]` commit. Leaves HEAD at
|
|
2186
|
+
* the generation commit (or unchanged for dry-run).
|
|
2187
|
+
*
|
|
2188
|
+
* Does NOT apply patches — the returned handle must be passed to
|
|
2189
|
+
* `applyPreparedReplay()` to complete the run. No disk writes to the lockfile
|
|
2190
|
+
* happen here; lockfile persistence is deferred to phase 2.
|
|
2191
|
+
*
|
|
2192
|
+
* Callers may land additional commits on HEAD between `prepareReplay` and
|
|
2193
|
+
* `applyPreparedReplay` (e.g., `[fern-autoversion]`).
|
|
2194
|
+
*/
|
|
2195
|
+
async prepareReplay(options) {
|
|
2196
|
+
this.warnings = [];
|
|
2197
|
+
this.detector.warnings.length = 0;
|
|
1850
2198
|
if (options?.skipApplication) {
|
|
1851
|
-
return this.
|
|
2199
|
+
return this._prepareSkipApplication(options);
|
|
1852
2200
|
}
|
|
1853
2201
|
const flow = this.determineFlow();
|
|
1854
2202
|
switch (flow) {
|
|
1855
2203
|
case "first-generation":
|
|
1856
|
-
return this.
|
|
2204
|
+
return this._prepareFirstGeneration(options);
|
|
2205
|
+
case "no-patches":
|
|
2206
|
+
return this._prepareNoPatchesRegeneration(options);
|
|
2207
|
+
case "normal-regeneration":
|
|
2208
|
+
return this._prepareNormalRegeneration(options);
|
|
2209
|
+
}
|
|
2210
|
+
}
|
|
2211
|
+
/**
|
|
2212
|
+
* Phase 2 of the two-phase replay flow. Applies patches, rebases them against
|
|
2213
|
+
* the generation, saves the lockfile, and commits `[fern-replay]` (or stages
|
|
2214
|
+
* under `stageOnly`). Operates on HEAD at call time — mid-phase commits are
|
|
2215
|
+
* respected.
|
|
2216
|
+
*
|
|
2217
|
+
* For terminal handles (dry-run, first-gen has no patch work, skip-app does
|
|
2218
|
+
* its own post-commit logic), this returns the precomputed report.
|
|
2219
|
+
*/
|
|
2220
|
+
async applyPreparedReplay(prep, options) {
|
|
2221
|
+
if (prep._prepared.terminal) {
|
|
2222
|
+
return prep._prepared.preparedReport;
|
|
2223
|
+
}
|
|
2224
|
+
switch (prep._prepared.flow) {
|
|
2225
|
+
case "first-generation":
|
|
2226
|
+
return this._applyFirstGeneration(prep);
|
|
2227
|
+
case "skip-application":
|
|
2228
|
+
return this._applySkipApplication(prep, options);
|
|
1857
2229
|
case "no-patches":
|
|
1858
|
-
return this.
|
|
2230
|
+
return this._applyNoPatchesRegeneration(prep, options);
|
|
1859
2231
|
case "normal-regeneration":
|
|
1860
|
-
return this.
|
|
2232
|
+
return this._applyNormalRegeneration(prep, options);
|
|
1861
2233
|
}
|
|
1862
2234
|
}
|
|
2235
|
+
/**
|
|
2236
|
+
* Backwards-compatible atomic entry point. Composes `prepareReplay` + `applyPreparedReplay`.
|
|
2237
|
+
* Behavior is byte-identical to the pre-split implementation for all existing callers.
|
|
2238
|
+
*/
|
|
2239
|
+
async runReplay(options) {
|
|
2240
|
+
const prep = await this.prepareReplay(options);
|
|
2241
|
+
return this.applyPreparedReplay(prep, options);
|
|
2242
|
+
}
|
|
1863
2243
|
/**
|
|
1864
2244
|
* Sync the lockfile after a divergent PR was squash-merged.
|
|
1865
2245
|
* Call this BEFORE runReplay() when the CLI detects a merged divergent PR.
|
|
@@ -1920,6 +2300,7 @@ var ReplayService = class {
|
|
|
1920
2300
|
);
|
|
1921
2301
|
}
|
|
1922
2302
|
this.lockManager.save();
|
|
2303
|
+
this.detector.warnings.push(...this.lockManager.warnings);
|
|
1923
2304
|
}
|
|
1924
2305
|
determineFlow() {
|
|
1925
2306
|
try {
|
|
@@ -1932,15 +2313,35 @@ var ReplayService = class {
|
|
|
1932
2313
|
throw error;
|
|
1933
2314
|
}
|
|
1934
2315
|
}
|
|
1935
|
-
|
|
2316
|
+
firstGenerationReport() {
|
|
2317
|
+
return {
|
|
2318
|
+
flow: "first-generation",
|
|
2319
|
+
patchesDetected: 0,
|
|
2320
|
+
patchesApplied: 0,
|
|
2321
|
+
patchesWithConflicts: 0,
|
|
2322
|
+
patchesSkipped: 0,
|
|
2323
|
+
conflicts: []
|
|
2324
|
+
};
|
|
2325
|
+
}
|
|
2326
|
+
async _prepareFirstGeneration(options) {
|
|
1936
2327
|
if (options?.dryRun) {
|
|
2328
|
+
const headSha = await this.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
|
|
1937
2329
|
return {
|
|
1938
2330
|
flow: "first-generation",
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
2331
|
+
previousGenerationSha: null,
|
|
2332
|
+
currentGenerationSha: headSha,
|
|
2333
|
+
baseBranchHead: options.baseBranchHead ?? null,
|
|
2334
|
+
_prepared: {
|
|
2335
|
+
flow: "first-generation",
|
|
2336
|
+
terminal: true,
|
|
2337
|
+
preparedReport: this.firstGenerationReport(),
|
|
2338
|
+
patchesToApply: [],
|
|
2339
|
+
newPatchIds: /* @__PURE__ */ new Set(),
|
|
2340
|
+
detectorWarnings: [],
|
|
2341
|
+
revertedCount: 0,
|
|
2342
|
+
genSha: headSha,
|
|
2343
|
+
optionsSnapshot: options
|
|
2344
|
+
}
|
|
1944
2345
|
};
|
|
1945
2346
|
}
|
|
1946
2347
|
const commitOpts = options ? {
|
|
@@ -1950,22 +2351,36 @@ var ReplayService = class {
|
|
|
1950
2351
|
} : void 0;
|
|
1951
2352
|
await this.committer.commitGeneration("Initial SDK generation", commitOpts);
|
|
1952
2353
|
const genRecord = await this.committer.createGenerationRecord(commitOpts);
|
|
1953
|
-
this.lockManager.
|
|
2354
|
+
this.lockManager.initializeInMemory(genRecord);
|
|
1954
2355
|
return {
|
|
1955
2356
|
flow: "first-generation",
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
2357
|
+
previousGenerationSha: null,
|
|
2358
|
+
currentGenerationSha: genRecord.commit_sha,
|
|
2359
|
+
baseBranchHead: options?.baseBranchHead ?? null,
|
|
2360
|
+
_prepared: {
|
|
2361
|
+
flow: "first-generation",
|
|
2362
|
+
terminal: false,
|
|
2363
|
+
patchesToApply: [],
|
|
2364
|
+
newPatchIds: /* @__PURE__ */ new Set(),
|
|
2365
|
+
detectorWarnings: [],
|
|
2366
|
+
revertedCount: 0,
|
|
2367
|
+
genSha: genRecord.commit_sha,
|
|
2368
|
+
optionsSnapshot: options
|
|
2369
|
+
}
|
|
1961
2370
|
};
|
|
1962
2371
|
}
|
|
2372
|
+
async _applyFirstGeneration(_prep) {
|
|
2373
|
+
this.lockManager.save();
|
|
2374
|
+
return this.firstGenerationReport();
|
|
2375
|
+
}
|
|
1963
2376
|
/**
|
|
1964
|
-
* Skip-application mode: commit the generation and update the
|
|
1965
|
-
* but don't detect or apply patches. Sets a marker
|
|
1966
|
-
* run skips revert detection in preGenerationRebase().
|
|
2377
|
+
* Skip-application mode phase 1: commit the generation and update the
|
|
2378
|
+
* in-memory lockfile, but don't detect or apply patches. Sets a marker
|
|
2379
|
+
* so the next normal run skips revert detection in preGenerationRebase().
|
|
2380
|
+
*
|
|
2381
|
+
* Disk save is deferred to `_applySkipApplication`.
|
|
1967
2382
|
*/
|
|
1968
|
-
async
|
|
2383
|
+
async _prepareSkipApplication(options) {
|
|
1969
2384
|
const commitOpts = options ? {
|
|
1970
2385
|
cliVersion: options.cliVersion ?? "unknown",
|
|
1971
2386
|
generatorVersions: options.generatorVersions ?? {},
|
|
@@ -1974,8 +2389,10 @@ var ReplayService = class {
|
|
|
1974
2389
|
await this.committer.commitGeneration("Update SDK (replay skipped)", commitOpts);
|
|
1975
2390
|
await this.cleanupStaleConflictMarkers();
|
|
1976
2391
|
const genRecord = await this.committer.createGenerationRecord(commitOpts);
|
|
2392
|
+
let previousGenerationSha = null;
|
|
1977
2393
|
try {
|
|
1978
|
-
this.lockManager.read();
|
|
2394
|
+
const lock = this.lockManager.read();
|
|
2395
|
+
previousGenerationSha = lock.current_generation ?? null;
|
|
1979
2396
|
this.lockManager.addGeneration(genRecord);
|
|
1980
2397
|
} catch (error) {
|
|
1981
2398
|
if (error instanceof LockfileNotFoundError) {
|
|
@@ -1985,7 +2402,26 @@ var ReplayService = class {
|
|
|
1985
2402
|
}
|
|
1986
2403
|
}
|
|
1987
2404
|
this.lockManager.setReplaySkippedAt((/* @__PURE__ */ new Date()).toISOString());
|
|
2405
|
+
return {
|
|
2406
|
+
flow: "skip-application",
|
|
2407
|
+
previousGenerationSha,
|
|
2408
|
+
currentGenerationSha: genRecord.commit_sha,
|
|
2409
|
+
baseBranchHead: options?.baseBranchHead ?? null,
|
|
2410
|
+
_prepared: {
|
|
2411
|
+
flow: "skip-application",
|
|
2412
|
+
terminal: false,
|
|
2413
|
+
patchesToApply: [],
|
|
2414
|
+
newPatchIds: /* @__PURE__ */ new Set(),
|
|
2415
|
+
detectorWarnings: [],
|
|
2416
|
+
revertedCount: 0,
|
|
2417
|
+
genSha: genRecord.commit_sha,
|
|
2418
|
+
optionsSnapshot: options
|
|
2419
|
+
}
|
|
2420
|
+
};
|
|
2421
|
+
}
|
|
2422
|
+
async _applySkipApplication(_prep, options) {
|
|
1988
2423
|
this.lockManager.save();
|
|
2424
|
+
const credentialWarnings = [...this.lockManager.warnings];
|
|
1989
2425
|
if (!options?.stageOnly) {
|
|
1990
2426
|
await this.committer.commitReplay(0);
|
|
1991
2427
|
} else {
|
|
@@ -1997,14 +2433,18 @@ var ReplayService = class {
|
|
|
1997
2433
|
patchesApplied: 0,
|
|
1998
2434
|
patchesWithConflicts: 0,
|
|
1999
2435
|
patchesSkipped: 0,
|
|
2000
|
-
conflicts: []
|
|
2436
|
+
conflicts: [],
|
|
2437
|
+
warnings: credentialWarnings.length > 0 ? credentialWarnings : void 0
|
|
2001
2438
|
};
|
|
2002
2439
|
}
|
|
2003
|
-
async
|
|
2004
|
-
const
|
|
2005
|
-
const
|
|
2440
|
+
async _prepareNoPatchesRegeneration(options) {
|
|
2441
|
+
const preLock = this.lockManager.read();
|
|
2442
|
+
const previousGenerationSha = preLock.current_generation ?? null;
|
|
2443
|
+
let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
|
|
2444
|
+
const detectorWarnings = [...this.detector.warnings];
|
|
2006
2445
|
if (options?.dryRun) {
|
|
2007
|
-
|
|
2446
|
+
const dryRunWarnings = [...detectorWarnings, ...this.warnings];
|
|
2447
|
+
const preparedReport = {
|
|
2008
2448
|
flow: "no-patches",
|
|
2009
2449
|
patchesDetected: newPatches.length,
|
|
2010
2450
|
patchesApplied: 0,
|
|
@@ -2013,9 +2453,43 @@ var ReplayService = class {
|
|
|
2013
2453
|
patchesReverted: revertedPatchIds.length,
|
|
2014
2454
|
conflicts: [],
|
|
2015
2455
|
wouldApply: newPatches,
|
|
2016
|
-
warnings:
|
|
2456
|
+
warnings: dryRunWarnings.length > 0 ? dryRunWarnings : void 0
|
|
2457
|
+
};
|
|
2458
|
+
const headSha = await this.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
|
|
2459
|
+
return {
|
|
2460
|
+
flow: "no-patches",
|
|
2461
|
+
previousGenerationSha,
|
|
2462
|
+
currentGenerationSha: headSha,
|
|
2463
|
+
baseBranchHead: options.baseBranchHead ?? null,
|
|
2464
|
+
_prepared: {
|
|
2465
|
+
flow: "no-patches",
|
|
2466
|
+
terminal: true,
|
|
2467
|
+
preparedReport,
|
|
2468
|
+
patchesToApply: [],
|
|
2469
|
+
newPatchIds: /* @__PURE__ */ new Set(),
|
|
2470
|
+
detectorWarnings,
|
|
2471
|
+
revertedCount: revertedPatchIds.length,
|
|
2472
|
+
genSha: headSha,
|
|
2473
|
+
optionsSnapshot: options
|
|
2474
|
+
}
|
|
2017
2475
|
};
|
|
2018
2476
|
}
|
|
2477
|
+
{
|
|
2478
|
+
const preCurrentGen = preLock.current_generation;
|
|
2479
|
+
const uniqueFiles = [...new Set(newPatches.flatMap((p) => p.files))];
|
|
2480
|
+
const absorbedFiles = /* @__PURE__ */ new Set();
|
|
2481
|
+
for (const file of uniqueFiles) {
|
|
2482
|
+
const diff = await this.git.exec(["diff", preCurrentGen, "HEAD", "--", file]).catch(() => null);
|
|
2483
|
+
if (diff !== null && !diff.trim()) {
|
|
2484
|
+
absorbedFiles.add(file);
|
|
2485
|
+
}
|
|
2486
|
+
}
|
|
2487
|
+
if (absorbedFiles.size > 0) {
|
|
2488
|
+
newPatches = newPatches.filter(
|
|
2489
|
+
(p) => p.files.length === 0 || !p.files.every((f) => absorbedFiles.has(f))
|
|
2490
|
+
);
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2019
2493
|
for (const id of revertedPatchIds) {
|
|
2020
2494
|
try {
|
|
2021
2495
|
this.lockManager.removePatch(id);
|
|
@@ -2031,9 +2505,31 @@ var ReplayService = class {
|
|
|
2031
2505
|
await this.cleanupStaleConflictMarkers();
|
|
2032
2506
|
const genRecord = await this.committer.createGenerationRecord(commitOpts);
|
|
2033
2507
|
this.lockManager.addGeneration(genRecord);
|
|
2508
|
+
return {
|
|
2509
|
+
flow: "no-patches",
|
|
2510
|
+
previousGenerationSha,
|
|
2511
|
+
currentGenerationSha: genRecord.commit_sha,
|
|
2512
|
+
baseBranchHead: options?.baseBranchHead ?? null,
|
|
2513
|
+
_prepared: {
|
|
2514
|
+
flow: "no-patches",
|
|
2515
|
+
terminal: false,
|
|
2516
|
+
patchesToApply: newPatches,
|
|
2517
|
+
newPatchIds: new Set(newPatches.map((p) => p.id)),
|
|
2518
|
+
detectorWarnings,
|
|
2519
|
+
revertedCount: revertedPatchIds.length,
|
|
2520
|
+
genSha: genRecord.commit_sha,
|
|
2521
|
+
optionsSnapshot: options
|
|
2522
|
+
}
|
|
2523
|
+
};
|
|
2524
|
+
}
|
|
2525
|
+
async _applyNoPatchesRegeneration(prep, options) {
|
|
2526
|
+
const newPatches = prep._prepared.patchesToApply;
|
|
2527
|
+
const detectorWarnings = prep._prepared.detectorWarnings;
|
|
2528
|
+
const genSha = prep._prepared.genSha;
|
|
2034
2529
|
let results = [];
|
|
2035
2530
|
if (newPatches.length > 0) {
|
|
2036
2531
|
results = await this.applicator.applyPatches(newPatches);
|
|
2532
|
+
this._lastApplyResults = results;
|
|
2037
2533
|
this.revertConflictingFiles(results);
|
|
2038
2534
|
for (const result of results) {
|
|
2039
2535
|
if (result.status === "conflict") {
|
|
@@ -2041,13 +2537,14 @@ var ReplayService = class {
|
|
|
2041
2537
|
}
|
|
2042
2538
|
}
|
|
2043
2539
|
}
|
|
2044
|
-
const rebaseCounts = await this.rebasePatches(results,
|
|
2540
|
+
const rebaseCounts = await this.rebasePatches(results, genSha);
|
|
2045
2541
|
for (const patch of newPatches) {
|
|
2046
2542
|
if (!rebaseCounts.absorbedPatchIds.has(patch.id)) {
|
|
2047
2543
|
this.lockManager.addPatch(patch);
|
|
2048
2544
|
}
|
|
2049
2545
|
}
|
|
2050
2546
|
this.lockManager.save();
|
|
2547
|
+
this.warnings.push(...this.lockManager.warnings);
|
|
2051
2548
|
if (newPatches.length > 0) {
|
|
2052
2549
|
if (!options?.stageOnly) {
|
|
2053
2550
|
const appliedCount = results.filter((r) => r.status === "applied").length;
|
|
@@ -2056,15 +2553,27 @@ var ReplayService = class {
|
|
|
2056
2553
|
await this.committer.stageAll();
|
|
2057
2554
|
}
|
|
2058
2555
|
}
|
|
2059
|
-
|
|
2556
|
+
const warnings = [...detectorWarnings, ...this.warnings];
|
|
2557
|
+
return this.buildReport(
|
|
2558
|
+
"no-patches",
|
|
2559
|
+
newPatches,
|
|
2560
|
+
results,
|
|
2561
|
+
prep._prepared.optionsSnapshot,
|
|
2562
|
+
warnings,
|
|
2563
|
+
rebaseCounts,
|
|
2564
|
+
void 0,
|
|
2565
|
+
prep._prepared.revertedCount
|
|
2566
|
+
);
|
|
2060
2567
|
}
|
|
2061
|
-
async
|
|
2568
|
+
async _prepareNormalRegeneration(options) {
|
|
2569
|
+
const preLock = this.lockManager.read();
|
|
2570
|
+
const previousGenerationSha = preLock.current_generation ?? null;
|
|
2062
2571
|
if (options?.dryRun) {
|
|
2063
2572
|
const existingPatches2 = this.lockManager.getPatches();
|
|
2064
2573
|
const { patches: newPatches2, revertedPatchIds: dryRunReverted } = await this.detector.detectNewPatches();
|
|
2065
|
-
const
|
|
2574
|
+
const warnings = [...this.detector.warnings, ...this.warnings];
|
|
2066
2575
|
const allPatches2 = [...existingPatches2, ...newPatches2];
|
|
2067
|
-
|
|
2576
|
+
const preparedReport = {
|
|
2068
2577
|
flow: "normal-regeneration",
|
|
2069
2578
|
patchesDetected: allPatches2.length,
|
|
2070
2579
|
patchesApplied: 0,
|
|
@@ -2073,7 +2582,25 @@ var ReplayService = class {
|
|
|
2073
2582
|
patchesReverted: dryRunReverted.length,
|
|
2074
2583
|
conflicts: [],
|
|
2075
2584
|
wouldApply: allPatches2,
|
|
2076
|
-
warnings:
|
|
2585
|
+
warnings: warnings.length > 0 ? warnings : void 0
|
|
2586
|
+
};
|
|
2587
|
+
const headSha = await this.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
|
|
2588
|
+
return {
|
|
2589
|
+
flow: "normal-regeneration",
|
|
2590
|
+
previousGenerationSha,
|
|
2591
|
+
currentGenerationSha: headSha,
|
|
2592
|
+
baseBranchHead: options.baseBranchHead ?? null,
|
|
2593
|
+
_prepared: {
|
|
2594
|
+
flow: "normal-regeneration",
|
|
2595
|
+
terminal: true,
|
|
2596
|
+
preparedReport,
|
|
2597
|
+
patchesToApply: [],
|
|
2598
|
+
newPatchIds: /* @__PURE__ */ new Set(),
|
|
2599
|
+
detectorWarnings: [...this.detector.warnings],
|
|
2600
|
+
revertedCount: dryRunReverted.length,
|
|
2601
|
+
genSha: headSha,
|
|
2602
|
+
optionsSnapshot: options
|
|
2603
|
+
}
|
|
2077
2604
|
};
|
|
2078
2605
|
}
|
|
2079
2606
|
let existingPatches = this.lockManager.getPatches();
|
|
@@ -2094,7 +2621,7 @@ var ReplayService = class {
|
|
|
2094
2621
|
}
|
|
2095
2622
|
existingPatches = this.lockManager.getPatches();
|
|
2096
2623
|
let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
|
|
2097
|
-
const
|
|
2624
|
+
const detectorWarnings = [...this.detector.warnings];
|
|
2098
2625
|
if (removedByPreRebase.length > 0) {
|
|
2099
2626
|
const removedOriginalCommits = new Set(removedByPreRebase.map((p) => p.original_commit));
|
|
2100
2627
|
const removedOriginalMessages = new Set(removedByPreRebase.map((p) => p.original_message));
|
|
@@ -2125,14 +2652,39 @@ var ReplayService = class {
|
|
|
2125
2652
|
await this.cleanupStaleConflictMarkers();
|
|
2126
2653
|
const genRecord = await this.committer.createGenerationRecord(commitOpts);
|
|
2127
2654
|
this.lockManager.addGeneration(genRecord);
|
|
2655
|
+
return {
|
|
2656
|
+
flow: "normal-regeneration",
|
|
2657
|
+
previousGenerationSha,
|
|
2658
|
+
currentGenerationSha: genRecord.commit_sha,
|
|
2659
|
+
baseBranchHead: options?.baseBranchHead ?? null,
|
|
2660
|
+
_prepared: {
|
|
2661
|
+
flow: "normal-regeneration",
|
|
2662
|
+
terminal: false,
|
|
2663
|
+
patchesToApply: allPatches,
|
|
2664
|
+
newPatchIds: new Set(newPatches.map((p) => p.id)),
|
|
2665
|
+
preRebaseCounts,
|
|
2666
|
+
detectorWarnings,
|
|
2667
|
+
revertedCount: revertedPatchIds.length,
|
|
2668
|
+
genSha: genRecord.commit_sha,
|
|
2669
|
+
optionsSnapshot: options
|
|
2670
|
+
}
|
|
2671
|
+
};
|
|
2672
|
+
}
|
|
2673
|
+
async _applyNormalRegeneration(prep, options) {
|
|
2674
|
+
const allPatches = prep._prepared.patchesToApply;
|
|
2675
|
+
const newPatchIds = prep._prepared.newPatchIds;
|
|
2676
|
+
const detectorWarnings = prep._prepared.detectorWarnings;
|
|
2677
|
+
const genSha = prep._prepared.genSha;
|
|
2128
2678
|
const results = await this.applicator.applyPatches(allPatches);
|
|
2679
|
+
this._lastApplyResults = results;
|
|
2129
2680
|
this.revertConflictingFiles(results);
|
|
2130
2681
|
for (const result of results) {
|
|
2131
2682
|
if (result.status === "conflict") {
|
|
2132
2683
|
result.patch.status = "unresolved";
|
|
2133
2684
|
}
|
|
2134
2685
|
}
|
|
2135
|
-
const rebaseCounts = await this.rebasePatches(results,
|
|
2686
|
+
const rebaseCounts = await this.rebasePatches(results, genSha);
|
|
2687
|
+
const newPatches = allPatches.filter((p) => newPatchIds.has(p.id));
|
|
2136
2688
|
for (const patch of newPatches) {
|
|
2137
2689
|
if (!rebaseCounts.absorbedPatchIds.has(patch.id)) {
|
|
2138
2690
|
this.lockManager.addPatch(patch);
|
|
@@ -2147,21 +2699,23 @@ var ReplayService = class {
|
|
|
2147
2699
|
}
|
|
2148
2700
|
}
|
|
2149
2701
|
this.lockManager.save();
|
|
2702
|
+
this.warnings.push(...this.lockManager.warnings);
|
|
2150
2703
|
if (options?.stageOnly) {
|
|
2151
2704
|
await this.committer.stageAll();
|
|
2152
2705
|
} else {
|
|
2153
2706
|
const appliedCount = results.filter((r) => r.status === "applied").length;
|
|
2154
2707
|
await this.committer.commitReplay(appliedCount, allPatches);
|
|
2155
2708
|
}
|
|
2709
|
+
const warnings = [...detectorWarnings, ...this.warnings];
|
|
2156
2710
|
return this.buildReport(
|
|
2157
2711
|
"normal-regeneration",
|
|
2158
2712
|
allPatches,
|
|
2159
2713
|
results,
|
|
2160
|
-
|
|
2714
|
+
prep._prepared.optionsSnapshot,
|
|
2161
2715
|
warnings,
|
|
2162
2716
|
rebaseCounts,
|
|
2163
|
-
preRebaseCounts,
|
|
2164
|
-
|
|
2717
|
+
prep._prepared.preRebaseCounts,
|
|
2718
|
+
prep._prepared.revertedCount
|
|
2165
2719
|
);
|
|
2166
2720
|
}
|
|
2167
2721
|
/**
|
|
@@ -2176,6 +2730,7 @@ var ReplayService = class {
|
|
|
2176
2730
|
let keptAsUserOwned = 0;
|
|
2177
2731
|
const seenContentHashes = /* @__PURE__ */ new Set();
|
|
2178
2732
|
const absorbedPatchIds = /* @__PURE__ */ new Set();
|
|
2733
|
+
const fernignorePatterns = this.readFernignorePatterns();
|
|
2179
2734
|
for (const result of results) {
|
|
2180
2735
|
if (result.resolvedFiles && Object.keys(result.resolvedFiles).length > 0) {
|
|
2181
2736
|
const patch = result.patch;
|
|
@@ -2196,34 +2751,78 @@ var ReplayService = class {
|
|
|
2196
2751
|
const patch = result.patch;
|
|
2197
2752
|
if (patch.base_generation === currentGenSha) continue;
|
|
2198
2753
|
try {
|
|
2199
|
-
const
|
|
2200
|
-
|
|
2754
|
+
const originalByResolved = {};
|
|
2755
|
+
if (result.resolvedFiles) {
|
|
2756
|
+
for (const [orig, resolved] of Object.entries(result.resolvedFiles)) {
|
|
2757
|
+
originalByResolved[resolved] = orig;
|
|
2758
|
+
}
|
|
2759
|
+
}
|
|
2760
|
+
const isUserOwnedPerFile = await Promise.all(
|
|
2201
2761
|
patch.files.map(async (file) => {
|
|
2202
|
-
if (file === ".fernignore")
|
|
2762
|
+
if (file === ".fernignore") return true;
|
|
2763
|
+
if (fernignorePatterns.some((p) => file === p || (0, import_minimatch2.minimatch)(file, p))) {
|
|
2203
2764
|
return true;
|
|
2204
2765
|
}
|
|
2205
|
-
if (
|
|
2766
|
+
if (patch.user_owned) return true;
|
|
2767
|
+
if (this.fileIsCreationInPatchContent(patch.patch_content, originalByResolved[file] ?? file)) {
|
|
2206
2768
|
return true;
|
|
2207
2769
|
}
|
|
2208
2770
|
const content = await this.git.showFile(currentGenSha, file);
|
|
2209
2771
|
return content === null;
|
|
2210
2772
|
})
|
|
2211
2773
|
);
|
|
2212
|
-
const
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2774
|
+
const hasProtectedFiles = patch.files.some(
|
|
2775
|
+
(file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || (0, import_minimatch2.minimatch)(file, p))
|
|
2776
|
+
);
|
|
2777
|
+
const allUserOwned = isUserOwnedPerFile.every(Boolean);
|
|
2778
|
+
if (allUserOwned && patch.files.length > 0) {
|
|
2779
|
+
const baseChanged = patch.base_generation !== currentGenSha;
|
|
2780
|
+
const userDiff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
|
|
2781
|
+
if (userDiff != null && userDiff.trim()) {
|
|
2782
|
+
const newHash = this.detector.computeContentHash(userDiff);
|
|
2783
|
+
patch.patch_content = userDiff;
|
|
2784
|
+
patch.content_hash = newHash;
|
|
2785
|
+
}
|
|
2786
|
+
patch.base_generation = currentGenSha;
|
|
2787
|
+
try {
|
|
2788
|
+
this.lockManager.updatePatch(patch.id, {
|
|
2789
|
+
base_generation: currentGenSha,
|
|
2790
|
+
...!patch.user_owned ? { user_owned: true } : {},
|
|
2791
|
+
...userDiff != null && userDiff.trim() ? { patch_content: patch.patch_content, content_hash: patch.content_hash } : {}
|
|
2792
|
+
});
|
|
2793
|
+
} catch {
|
|
2794
|
+
}
|
|
2795
|
+
patch.user_owned = true;
|
|
2217
2796
|
keptAsUserOwned++;
|
|
2797
|
+
if (baseChanged) repointed++;
|
|
2218
2798
|
continue;
|
|
2219
2799
|
}
|
|
2220
2800
|
const diff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
|
|
2221
|
-
if (
|
|
2801
|
+
if (diff === null) continue;
|
|
2802
|
+
if (!diff.trim()) {
|
|
2803
|
+
if (hasProtectedFiles) {
|
|
2804
|
+
patch.base_generation = currentGenSha;
|
|
2805
|
+
try {
|
|
2806
|
+
this.lockManager.updatePatch(patch.id, { base_generation: currentGenSha });
|
|
2807
|
+
} catch {
|
|
2808
|
+
}
|
|
2809
|
+
keptAsUserOwned++;
|
|
2810
|
+
continue;
|
|
2811
|
+
}
|
|
2222
2812
|
this.lockManager.removePatch(patch.id);
|
|
2223
2813
|
absorbedPatchIds.add(patch.id);
|
|
2224
2814
|
absorbed++;
|
|
2225
2815
|
continue;
|
|
2226
2816
|
}
|
|
2817
|
+
if (hasProtectedFiles) {
|
|
2818
|
+
patch.base_generation = currentGenSha;
|
|
2819
|
+
try {
|
|
2820
|
+
this.lockManager.updatePatch(patch.id, { base_generation: currentGenSha });
|
|
2821
|
+
} catch {
|
|
2822
|
+
}
|
|
2823
|
+
keptAsUserOwned++;
|
|
2824
|
+
continue;
|
|
2825
|
+
}
|
|
2227
2826
|
const newContentHash = this.detector.computeContentHash(diff);
|
|
2228
2827
|
if (seenContentHashes.has(newContentHash)) {
|
|
2229
2828
|
this.lockManager.removePatch(patch.id);
|
|
@@ -2232,17 +2831,89 @@ var ReplayService = class {
|
|
|
2232
2831
|
continue;
|
|
2233
2832
|
}
|
|
2234
2833
|
seenContentHashes.add(newContentHash);
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2834
|
+
patch.base_generation = currentGenSha;
|
|
2835
|
+
patch.patch_content = diff;
|
|
2836
|
+
patch.content_hash = newContentHash;
|
|
2837
|
+
try {
|
|
2838
|
+
this.lockManager.updatePatch(patch.id, {
|
|
2839
|
+
base_generation: currentGenSha,
|
|
2840
|
+
patch_content: diff,
|
|
2841
|
+
content_hash: newContentHash
|
|
2842
|
+
});
|
|
2843
|
+
} catch {
|
|
2844
|
+
}
|
|
2240
2845
|
contentRebased++;
|
|
2241
2846
|
} catch {
|
|
2242
2847
|
}
|
|
2243
2848
|
}
|
|
2244
2849
|
return { absorbed, repointed, contentRebased, keptAsUserOwned, absorbedPatchIds };
|
|
2245
2850
|
}
|
|
2851
|
+
/**
|
|
2852
|
+
* Determine whether `file` is user-owned (never produced by the generator).
|
|
2853
|
+
*
|
|
2854
|
+
* Resolution order (first match wins):
|
|
2855
|
+
* 1. `patch.user_owned === true` — authoritative, persisted across cycles.
|
|
2856
|
+
* 2. `.fernignore` itself, or file matches a .fernignore pattern.
|
|
2857
|
+
* 3. Legacy-safe signal: `patch_content` shows this file as a `--- /dev/null`
|
|
2858
|
+
* creation. Works for patches that predate the `user_owned` field or
|
|
2859
|
+
* whose flag was lost. Immune to rename tricks — relies on raw patch text.
|
|
2860
|
+
* 4. Tree-based check against `patch.base_generation` when it is reachable
|
|
2861
|
+
* AND distinct from `currentGenSha`. If the base tree is unreachable
|
|
2862
|
+
* (shallow clone / aggressive `git gc --prune`), emit a one-time
|
|
2863
|
+
* customer-actionable warning (deduplicated via warnedGens) and
|
|
2864
|
+
* conservatively treat as user-owned — strictly safer than silent
|
|
2865
|
+
* absorption (FER-9809).
|
|
2866
|
+
* 5. Final fallback — check `currentGenSha` when there is no usable base
|
|
2867
|
+
* (legacy one-gen lockfile). Preserves pre-fix behavior for that edge.
|
|
2868
|
+
*
|
|
2869
|
+
* `originalFileName` is the pre-rename path when the generator moved the
|
|
2870
|
+
* file between the patch's base and the current generation. Tree lookups
|
|
2871
|
+
* at ancestor generations use the original path — that's where the
|
|
2872
|
+
* generator produced the content.
|
|
2873
|
+
*/
|
|
2874
|
+
async isFileUserOwned(file, patch, currentGenSha, fernignorePatterns, warnedGens, originalFileName) {
|
|
2875
|
+
if (patch.user_owned) return true;
|
|
2876
|
+
if (file === ".fernignore") return true;
|
|
2877
|
+
if (fernignorePatterns.some((p) => file === p || (0, import_minimatch2.minimatch)(file, p))) return true;
|
|
2878
|
+
if (this.fileIsCreationInPatchContent(patch.patch_content, originalFileName ?? file)) {
|
|
2879
|
+
return true;
|
|
2880
|
+
}
|
|
2881
|
+
const base = patch.base_generation;
|
|
2882
|
+
if (base && base !== currentGenSha) {
|
|
2883
|
+
const reachable = await this.git.commitExists(base) || await this.git.treeExists(base);
|
|
2884
|
+
if (!reachable) {
|
|
2885
|
+
if (!warnedGens.has(base)) {
|
|
2886
|
+
warnedGens.add(base);
|
|
2887
|
+
this.warnings.push(
|
|
2888
|
+
`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.`
|
|
2889
|
+
);
|
|
2890
|
+
}
|
|
2891
|
+
return true;
|
|
2892
|
+
}
|
|
2893
|
+
return await this.git.showFile(base, originalFileName ?? file) === null;
|
|
2894
|
+
}
|
|
2895
|
+
return await this.git.showFile(currentGenSha, originalFileName ?? file) === null;
|
|
2896
|
+
}
|
|
2897
|
+
/**
|
|
2898
|
+
* Returns true if `file`'s diff section in `patchContent` starts with
|
|
2899
|
+
* `--- /dev/null`, meaning this file was introduced by the patch (user
|
|
2900
|
+
* creation). Used as a legacy-safe fallback when `patch.user_owned` is
|
|
2901
|
+
* absent or cleared.
|
|
2902
|
+
*/
|
|
2903
|
+
fileIsCreationInPatchContent(patchContent, file) {
|
|
2904
|
+
const lines = patchContent.split("\n");
|
|
2905
|
+
let inFileSection = false;
|
|
2906
|
+
for (const line of lines) {
|
|
2907
|
+
if (line.startsWith("diff --git ")) {
|
|
2908
|
+
inFileSection = line.includes(` b/${file}`) || line.endsWith(` b/${file}`);
|
|
2909
|
+
continue;
|
|
2910
|
+
}
|
|
2911
|
+
if (inFileSection && line.startsWith("--- ")) {
|
|
2912
|
+
return line === "--- /dev/null";
|
|
2913
|
+
}
|
|
2914
|
+
}
|
|
2915
|
+
return false;
|
|
2916
|
+
}
|
|
2246
2917
|
/**
|
|
2247
2918
|
* For conflict patches with mixed results (some files merged, some conflicted),
|
|
2248
2919
|
* check if the cleanly merged files were absorbed by the generator (empty diff).
|
|
@@ -2256,13 +2927,14 @@ var ReplayService = class {
|
|
|
2256
2927
|
const cleanFiles = result.fileResults.filter((f) => f.status === "merged").map((f) => f.file);
|
|
2257
2928
|
if (cleanFiles.length === 0) return;
|
|
2258
2929
|
const patch = result.patch;
|
|
2930
|
+
if (patch.user_owned) return;
|
|
2259
2931
|
const fernignorePatterns = this.readFernignorePatterns();
|
|
2932
|
+
const warnedGens = /* @__PURE__ */ new Set();
|
|
2260
2933
|
const generatorCleanFiles = [];
|
|
2261
2934
|
for (const file of cleanFiles) {
|
|
2262
|
-
if (file
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
if (content === null) continue;
|
|
2935
|
+
if (await this.isFileUserOwned(file, patch, currentGenSha, fernignorePatterns, warnedGens)) {
|
|
2936
|
+
continue;
|
|
2937
|
+
}
|
|
2266
2938
|
generatorCleanFiles.push(file);
|
|
2267
2939
|
}
|
|
2268
2940
|
if (generatorCleanFiles.length === 0) return;
|
|
@@ -2299,7 +2971,35 @@ var ReplayService = class {
|
|
|
2299
2971
|
let conflictResolved = 0;
|
|
2300
2972
|
let conflictAbsorbed = 0;
|
|
2301
2973
|
let contentRefreshed = 0;
|
|
2974
|
+
const fernignorePatterns = this.readFernignorePatterns();
|
|
2975
|
+
const warnedGens = /* @__PURE__ */ new Set();
|
|
2976
|
+
const reachabilityCache = /* @__PURE__ */ new Map();
|
|
2977
|
+
const isReachable = async (sha) => {
|
|
2978
|
+
const cached = reachabilityCache.get(sha);
|
|
2979
|
+
if (cached !== void 0) return cached;
|
|
2980
|
+
const reachable = await this.git.commitExists(sha) || await this.git.treeExists(sha);
|
|
2981
|
+
reachabilityCache.set(sha, reachable);
|
|
2982
|
+
return reachable;
|
|
2983
|
+
};
|
|
2984
|
+
const allPatchFilesUserOwned = async (patch) => {
|
|
2985
|
+
if (patch.user_owned) return true;
|
|
2986
|
+
if (patch.files.length === 0) return false;
|
|
2987
|
+
for (const file of patch.files) {
|
|
2988
|
+
if (!await this.isFileUserOwned(file, patch, currentGen, fernignorePatterns, warnedGens)) {
|
|
2989
|
+
return false;
|
|
2990
|
+
}
|
|
2991
|
+
}
|
|
2992
|
+
return true;
|
|
2993
|
+
};
|
|
2302
2994
|
for (const patch of patches) {
|
|
2995
|
+
if (patch.base_generation && patch.base_generation !== currentGen && !warnedGens.has(patch.base_generation)) {
|
|
2996
|
+
if (!await isReachable(patch.base_generation)) {
|
|
2997
|
+
warnedGens.add(patch.base_generation);
|
|
2998
|
+
this.warnings.push(
|
|
2999
|
+
`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.`
|
|
3000
|
+
);
|
|
3001
|
+
}
|
|
3002
|
+
}
|
|
2303
3003
|
if (patch.status != null) {
|
|
2304
3004
|
delete patch.status;
|
|
2305
3005
|
continue;
|
|
@@ -2309,6 +3009,7 @@ var ReplayService = class {
|
|
|
2309
3009
|
const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
|
|
2310
3010
|
if (diff === null) continue;
|
|
2311
3011
|
if (!diff.trim()) {
|
|
3012
|
+
if (await allPatchFilesUserOwned(patch)) continue;
|
|
2312
3013
|
this.lockManager.removePatch(patch.id);
|
|
2313
3014
|
contentRefreshed++;
|
|
2314
3015
|
continue;
|
|
@@ -2320,6 +3021,12 @@ var ReplayService = class {
|
|
|
2320
3021
|
if (hasStaleMarkers) {
|
|
2321
3022
|
continue;
|
|
2322
3023
|
}
|
|
3024
|
+
const isProtected = patch.files.some(
|
|
3025
|
+
(file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || (0, import_minimatch2.minimatch)(file, p))
|
|
3026
|
+
);
|
|
3027
|
+
if (isProtected) {
|
|
3028
|
+
continue;
|
|
3029
|
+
}
|
|
2323
3030
|
const newContentHash = this.detector.computeContentHash(diff);
|
|
2324
3031
|
if (newContentHash !== patch.content_hash) {
|
|
2325
3032
|
const filesOutput = await this.git.exec(["diff", "--name-only", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
|
|
@@ -2345,6 +3052,13 @@ var ReplayService = class {
|
|
|
2345
3052
|
const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
|
|
2346
3053
|
if (diff === null) continue;
|
|
2347
3054
|
if (!diff.trim()) {
|
|
3055
|
+
if (await allPatchFilesUserOwned(patch)) {
|
|
3056
|
+
try {
|
|
3057
|
+
this.lockManager.updatePatch(patch.id, { base_generation: currentGen });
|
|
3058
|
+
} catch {
|
|
3059
|
+
}
|
|
3060
|
+
continue;
|
|
3061
|
+
}
|
|
2348
3062
|
this.lockManager.removePatch(patch.id);
|
|
2349
3063
|
conflictAbsorbed++;
|
|
2350
3064
|
continue;
|
|
@@ -2856,7 +3570,10 @@ function ensureGitattributesEntries(outputDir) {
|
|
|
2856
3570
|
(0, import_node_fs4.writeFileSync)(gitattributesPath, content, "utf-8");
|
|
2857
3571
|
}
|
|
2858
3572
|
function computeContentHash(patchContent) {
|
|
2859
|
-
const
|
|
3573
|
+
const lines = patchContent.split("\n");
|
|
3574
|
+
const diffStart = lines.findIndex((l) => l.startsWith("diff --git "));
|
|
3575
|
+
const relevantLines = diffStart > 0 ? lines.slice(diffStart) : lines;
|
|
3576
|
+
const normalized = relevantLines.filter((line) => !line.startsWith("index ")).join("\n").replace(/\n-- \n[\d]+\.[\d]+[^\n]*\s*$/, "");
|
|
2860
3577
|
return `sha256:${(0, import_node_crypto3.createHash)("sha256").update(normalized).digest("hex")}`;
|
|
2861
3578
|
}
|
|
2862
3579
|
|
|
@@ -3168,6 +3885,7 @@ function status(outputDir) {
|
|
|
3168
3885
|
}
|
|
3169
3886
|
// Annotate the CommonJS export names for ESM import in node:
|
|
3170
3887
|
0 && (module.exports = {
|
|
3888
|
+
CREDENTIAL_PATTERNS,
|
|
3171
3889
|
FERN_BOT_EMAIL,
|
|
3172
3890
|
FERN_BOT_LOGIN,
|
|
3173
3891
|
FERN_BOT_NAME,
|
|
@@ -3179,15 +3897,18 @@ function status(outputDir) {
|
|
|
3179
3897
|
ReplayCommitter,
|
|
3180
3898
|
ReplayDetector,
|
|
3181
3899
|
ReplayService,
|
|
3900
|
+
SENSITIVE_FILE_PATTERNS,
|
|
3182
3901
|
bootstrap,
|
|
3183
3902
|
forget,
|
|
3184
3903
|
isGenerationCommit,
|
|
3185
3904
|
isReplayCommit,
|
|
3186
3905
|
isRevertCommit,
|
|
3906
|
+
isSensitiveFile,
|
|
3187
3907
|
parseRevertedMessage,
|
|
3188
3908
|
parseRevertedSha,
|
|
3189
3909
|
reset,
|
|
3190
3910
|
resolve,
|
|
3911
|
+
scanForCredentials,
|
|
3191
3912
|
status,
|
|
3192
3913
|
threeWayMerge
|
|
3193
3914
|
});
|