@fern-api/replay 0.17.4 → 0.19.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 +436 -116
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +381 -107
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +193 -3
- package/dist/index.d.ts +193 -3
- package/dist/index.js +364 -90
- package/dist/index.js.map +1 -1
- package/package.json +3 -1
package/dist/index.js
CHANGED
|
@@ -40,6 +40,11 @@ var init_GitClient = __esm({
|
|
|
40
40
|
proc.stderr.on("data", (data) => {
|
|
41
41
|
stderr += data.toString();
|
|
42
42
|
});
|
|
43
|
+
proc.on("error", (err) => {
|
|
44
|
+
reject(new Error(`git ${args.join(" ")} failed to spawn: ${err.message}`));
|
|
45
|
+
});
|
|
46
|
+
proc.stdin.on("error", () => {
|
|
47
|
+
});
|
|
43
48
|
proc.on("close", (code) => {
|
|
44
49
|
if (code === 0) {
|
|
45
50
|
resolve2(stdout);
|
|
@@ -402,18 +407,18 @@ __export(PatchApplyTheirs_exports, {
|
|
|
402
407
|
});
|
|
403
408
|
import { mkdtemp as mkdtemp3, mkdir as mkdir3, writeFile as writeFile4, readFile as readFile3, rm as rm3 } from "fs/promises";
|
|
404
409
|
import { tmpdir as tmpdir3 } from "os";
|
|
405
|
-
import { dirname as dirname4, join as
|
|
410
|
+
import { dirname as dirname4, join as join6 } from "path";
|
|
406
411
|
async function applyPatchToContent(base, patchContent, filePath) {
|
|
407
412
|
const fileDiff = extractFileDiff(patchContent, filePath);
|
|
408
413
|
if (!fileDiff) return null;
|
|
409
|
-
const tempDir = await mkdtemp3(
|
|
414
|
+
const tempDir = await mkdtemp3(join6(tmpdir3(), "replay-migrate-"));
|
|
410
415
|
const tempGit = new GitClient(tempDir);
|
|
411
416
|
try {
|
|
412
417
|
await tempGit.exec(["init"]);
|
|
413
418
|
await tempGit.exec(["config", "user.email", "migrate@fern.com"]);
|
|
414
419
|
await tempGit.exec(["config", "user.name", "Fern Replay Migration"]);
|
|
415
420
|
await tempGit.exec(["config", "commit.gpgSign", "false"]);
|
|
416
|
-
const tempFilePath =
|
|
421
|
+
const tempFilePath = join6(tempDir, filePath);
|
|
417
422
|
await mkdir3(dirname4(tempFilePath), { recursive: true });
|
|
418
423
|
await writeFile4(tempFilePath, base);
|
|
419
424
|
await tempGit.exec(["add", filePath]);
|
|
@@ -690,6 +695,22 @@ var LockfileManager = class {
|
|
|
690
695
|
this.lock.forgotten_hashes.push(hash);
|
|
691
696
|
}
|
|
692
697
|
}
|
|
698
|
+
/**
|
|
699
|
+
* Record a dismissed customization: visibility metadata in `dismissals`
|
|
700
|
+
* plus a tombstone in `forgotten_hashes` for every content hash.
|
|
701
|
+
* `forgotten_hashes` stays the only list the detector consults; the
|
|
702
|
+
* metadata exists so `status` can show what was dismissed.
|
|
703
|
+
*/
|
|
704
|
+
addDismissal(record) {
|
|
705
|
+
this.ensureLoaded();
|
|
706
|
+
if (!this.lock.dismissals) {
|
|
707
|
+
this.lock.dismissals = [];
|
|
708
|
+
}
|
|
709
|
+
this.lock.dismissals.push(record);
|
|
710
|
+
for (const hash of record.content_hashes) {
|
|
711
|
+
this.addForgottenHash(hash);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
693
714
|
getUnresolvedPatches() {
|
|
694
715
|
this.ensureLoaded();
|
|
695
716
|
return this.lock.patches.filter((p) => p.status === "unresolved");
|
|
@@ -808,7 +829,8 @@ function isBinaryFile(filePath) {
|
|
|
808
829
|
var INFRASTRUCTURE_FILES = /* @__PURE__ */ new Set([".fernignore"]);
|
|
809
830
|
function matchesFernignorePattern(filePath, patterns) {
|
|
810
831
|
if (patterns.length === 0) return false;
|
|
811
|
-
return patterns.some((
|
|
832
|
+
return patterns.some((rawPattern) => {
|
|
833
|
+
const p = rawPattern.endsWith("/") ? rawPattern.slice(0, -1) : rawPattern;
|
|
812
834
|
if (filePath === p) return true;
|
|
813
835
|
if (minimatch2(filePath, p)) return true;
|
|
814
836
|
if (!p.includes("*") && !p.includes("?") && filePath.startsWith(p + "/")) return true;
|
|
@@ -856,12 +878,14 @@ async function capturePatchSnapshot(git, files, treeish) {
|
|
|
856
878
|
return snapshot;
|
|
857
879
|
}
|
|
858
880
|
function filterInfrastructureAndSensitiveFiles(rawFilesOutput, fernignorePatterns) {
|
|
859
|
-
const
|
|
881
|
+
const rawFiles = rawFilesOutput.trim().split("\n").filter(Boolean);
|
|
882
|
+
const infrastructureFiles = rawFiles.filter((f) => INFRASTRUCTURE_FILES.has(f) || f.startsWith(".fern/"));
|
|
883
|
+
const allFiles = rawFiles.filter((f) => !INFRASTRUCTURE_FILES.has(f) && !f.startsWith(".fern/"));
|
|
860
884
|
const sensitiveFiles = allFiles.filter((f) => isSensitiveFile(f));
|
|
861
885
|
const nonSensitive = allFiles.filter((f) => !isSensitiveFile(f));
|
|
862
886
|
const fernignoredFiles = nonSensitive.filter((f) => matchesFernignorePattern(f, fernignorePatterns));
|
|
863
887
|
const files = nonSensitive.filter((f) => !matchesFernignorePattern(f, fernignorePatterns));
|
|
864
|
-
return { files, sensitiveFiles, fernignoredFiles };
|
|
888
|
+
return { files, sensitiveFiles, fernignoredFiles, infrastructureFiles };
|
|
865
889
|
}
|
|
866
890
|
var ReplayDetector = class {
|
|
867
891
|
git;
|
|
@@ -869,6 +893,15 @@ var ReplayDetector = class {
|
|
|
869
893
|
sdkOutputDir;
|
|
870
894
|
fernignorePatterns;
|
|
871
895
|
warnings = [];
|
|
896
|
+
/**
|
|
897
|
+
* Structured degraded-state channel, mirroring `warnings`. Populated when
|
|
898
|
+
* detection cannot guarantee customizations were carried forward (baseline
|
|
899
|
+
* unreachable in this clone). Cleared per-run by `ReplayService.prepareReplay`
|
|
900
|
+
* alongside `warnings`. Merged into `ReplayReport.degradedReasons` by each
|
|
901
|
+
* flow handler. Degraded is observability only: detection results are
|
|
902
|
+
* unchanged.
|
|
903
|
+
*/
|
|
904
|
+
degradedReasons = [];
|
|
872
905
|
constructor(git, lockManager, sdkOutputDir, fernignorePatterns = []) {
|
|
873
906
|
this.git = git;
|
|
874
907
|
this.lockManager = lockManager;
|
|
@@ -939,6 +972,10 @@ var ReplayDetector = class {
|
|
|
939
972
|
this.warnings.push(
|
|
940
973
|
`No generation boundary found in git history and the lockfile's recorded generation (${lock.current_generation.slice(0, 7)}) matches no entry in its generation records. Customer customizations may differ from the last known generation and could be lost on the next regeneration. Run \`fern-replay bootstrap --force\` to re-initialize tracking.`
|
|
941
974
|
);
|
|
975
|
+
this.degradedReasons.push({
|
|
976
|
+
code: "baseline-unresolvable",
|
|
977
|
+
message: `The previous generation recorded in the lockfile (${lock.current_generation.slice(0, 7)}) could not be resolved to any reachable baseline during this run. Customizations may not have been carried forward \u2014 review the result before merging.`
|
|
978
|
+
});
|
|
942
979
|
}
|
|
943
980
|
return { patches: [], revertedPatchIds: [] };
|
|
944
981
|
}
|
|
@@ -947,6 +984,12 @@ var ReplayDetector = class {
|
|
|
947
984
|
this.warnings.push(
|
|
948
985
|
`Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to tree-diff detection (likely a shallow clone).`
|
|
949
986
|
);
|
|
987
|
+
if (lock.patches.length > 0) {
|
|
988
|
+
this.degradedReasons.push({
|
|
989
|
+
code: "generation-anchor-unreachable",
|
|
990
|
+
message: `The previous generation (${lastGen.commit_sha.slice(0, 7)}) was not reachable during this run, and ${lock.patches.length} tracked customization(s) could not be verified against it. Customizations may not have been carried forward \u2014 review the result before merging.`
|
|
991
|
+
});
|
|
992
|
+
}
|
|
950
993
|
return this.detectPatchesViaTreeDiff(
|
|
951
994
|
lastGen,
|
|
952
995
|
/* commitKnownMissing */
|
|
@@ -986,48 +1029,28 @@ var ReplayDetector = class {
|
|
|
986
1029
|
if (lock.patches.find((p) => p.original_commit === commit.sha)) {
|
|
987
1030
|
continue;
|
|
988
1031
|
}
|
|
989
|
-
const
|
|
990
|
-
|
|
991
|
-
try {
|
|
992
|
-
patchContent = await this.git.formatPatch(commit.sha);
|
|
993
|
-
} catch {
|
|
1032
|
+
const materialized = await this.materializeCommitPatch(commit.sha);
|
|
1033
|
+
if (materialized.sensitiveFiles.length > 0) {
|
|
994
1034
|
this.warnings.push(
|
|
995
|
-
`
|
|
1035
|
+
`Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${materialized.sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
|
|
996
1036
|
);
|
|
997
|
-
continue;
|
|
998
|
-
}
|
|
999
|
-
let contentHash = this.computeContentHash(patchContent);
|
|
1000
|
-
if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
|
|
1001
|
-
continue;
|
|
1002
1037
|
}
|
|
1003
|
-
|
|
1004
|
-
const { files, sensitiveFiles, fernignoredFiles } = filterInfrastructureAndSensitiveFiles(
|
|
1005
|
-
filesOutput,
|
|
1006
|
-
this.fernignorePatterns
|
|
1007
|
-
);
|
|
1008
|
-
if (sensitiveFiles.length > 0) {
|
|
1038
|
+
if (materialized.fernignoredFiles.length > 0) {
|
|
1009
1039
|
this.warnings.push(
|
|
1010
|
-
|
|
1040
|
+
`.fernignore-protected file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${materialized.fernignoredFiles.join(", ")}. These files are managed by .fernignore, not Replay.`
|
|
1011
1041
|
);
|
|
1012
1042
|
}
|
|
1013
|
-
if (
|
|
1043
|
+
if (materialized.status === "unreachable") {
|
|
1014
1044
|
this.warnings.push(
|
|
1015
|
-
|
|
1045
|
+
`Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
|
|
1016
1046
|
);
|
|
1047
|
+
continue;
|
|
1017
1048
|
}
|
|
1018
|
-
if (
|
|
1019
|
-
|
|
1020
|
-
if (parentSha) {
|
|
1021
|
-
try {
|
|
1022
|
-
patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
|
|
1023
|
-
} catch {
|
|
1024
|
-
continue;
|
|
1025
|
-
}
|
|
1026
|
-
if (!patchContent.trim()) continue;
|
|
1027
|
-
contentHash = this.computeContentHash(patchContent);
|
|
1028
|
-
}
|
|
1049
|
+
if (materialized.status === "empty") {
|
|
1050
|
+
continue;
|
|
1029
1051
|
}
|
|
1030
|
-
|
|
1052
|
+
const { files, patchContent, contentHash } = materialized;
|
|
1053
|
+
if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
|
|
1031
1054
|
continue;
|
|
1032
1055
|
}
|
|
1033
1056
|
const theirsSnapshot = await capturePatchSnapshot(this.git, files, commit.sha);
|
|
@@ -1103,6 +1126,63 @@ var ReplayDetector = class {
|
|
|
1103
1126
|
const filteredPatches = survivingPatches.filter((_, i) => !revertIndicesToRemove.has(i));
|
|
1104
1127
|
return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
|
|
1105
1128
|
}
|
|
1129
|
+
/**
|
|
1130
|
+
* Materialize a commit into patch content exactly as detection does.
|
|
1131
|
+
*
|
|
1132
|
+
* Pipeline: `diff-tree --name-only` → filter infrastructure / sensitive /
|
|
1133
|
+
* `.fernignore`d files → scoped `git diff` when files were stripped,
|
|
1134
|
+
* otherwise full `git format-patch` → content hash.
|
|
1135
|
+
*
|
|
1136
|
+
* This is THE definition of a commit's re-emission identity: the content
|
|
1137
|
+
* hash this method returns is what `detectPatchesInRange` will compute if
|
|
1138
|
+
* the commit re-enters the scan range (e.g. after a regen PR is merged
|
|
1139
|
+
* with a merge commit). Callers that tombstone a dismissed patch must use
|
|
1140
|
+
* this hash — the stored `content_hash` drifts every time the patch is
|
|
1141
|
+
* carried forward, so it does not identify the commit on re-detection.
|
|
1142
|
+
*
|
|
1143
|
+
* Never throws; failures are reported through the `status` discriminant.
|
|
1144
|
+
*/
|
|
1145
|
+
async materializeCommitPatch(sha) {
|
|
1146
|
+
let parents;
|
|
1147
|
+
let filesOutput;
|
|
1148
|
+
try {
|
|
1149
|
+
parents = await this.git.getCommitParents(sha);
|
|
1150
|
+
filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", sha]);
|
|
1151
|
+
} catch {
|
|
1152
|
+
return { status: "unreachable", sensitiveFiles: [], fernignoredFiles: [] };
|
|
1153
|
+
}
|
|
1154
|
+
const { files, sensitiveFiles, fernignoredFiles, infrastructureFiles } = filterInfrastructureAndSensitiveFiles(filesOutput, this.fernignorePatterns);
|
|
1155
|
+
if (files.length === 0) {
|
|
1156
|
+
return { status: "empty", sensitiveFiles, fernignoredFiles };
|
|
1157
|
+
}
|
|
1158
|
+
const wasFiltered = sensitiveFiles.length > 0 || fernignoredFiles.length > 0 || infrastructureFiles.length > 0;
|
|
1159
|
+
const parentSha = parents[0];
|
|
1160
|
+
let patchContent;
|
|
1161
|
+
if (wasFiltered && parentSha) {
|
|
1162
|
+
try {
|
|
1163
|
+
patchContent = await this.git.exec(["diff", parentSha, sha, "--", ...files]);
|
|
1164
|
+
} catch {
|
|
1165
|
+
return { status: "empty", sensitiveFiles, fernignoredFiles };
|
|
1166
|
+
}
|
|
1167
|
+
if (!patchContent.trim()) {
|
|
1168
|
+
return { status: "empty", sensitiveFiles, fernignoredFiles };
|
|
1169
|
+
}
|
|
1170
|
+
} else {
|
|
1171
|
+
try {
|
|
1172
|
+
patchContent = await this.git.formatPatch(sha);
|
|
1173
|
+
} catch {
|
|
1174
|
+
return { status: "unreachable", sensitiveFiles, fernignoredFiles };
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
return {
|
|
1178
|
+
status: "ok",
|
|
1179
|
+
files,
|
|
1180
|
+
sensitiveFiles,
|
|
1181
|
+
fernignoredFiles,
|
|
1182
|
+
patchContent,
|
|
1183
|
+
contentHash: this.computeContentHash(patchContent)
|
|
1184
|
+
};
|
|
1185
|
+
}
|
|
1106
1186
|
/**
|
|
1107
1187
|
* Compute content hash for deduplication.
|
|
1108
1188
|
*
|
|
@@ -1207,6 +1287,13 @@ var ReplayDetector = class {
|
|
|
1207
1287
|
async detectPatchesViaTreeDiff(lastGen, commitKnownMissing) {
|
|
1208
1288
|
const diffBase = await this.resolveDiffBase(lastGen, commitKnownMissing);
|
|
1209
1289
|
if (!diffBase) {
|
|
1290
|
+
this.warnings.push(
|
|
1291
|
+
`Neither the previous generation's commit (${lastGen.commit_sha.slice(0, 7)}) nor its recorded tree was reachable during this run. Falling back to a bounded commit scan, which cannot see customizations older than the clone window. Widen the clone window (e.g. \`git fetch --unshallow\`) and re-run replay to verify customizations were carried forward.`
|
|
1292
|
+
);
|
|
1293
|
+
this.degradedReasons.push({
|
|
1294
|
+
code: "generation-tree-unreachable",
|
|
1295
|
+
message: `The previous generation (${lastGen.commit_sha.slice(0, 7)}) was not reachable during this run \u2014 neither its commit nor its recorded tree. Customizations may not have been carried forward \u2014 review the result before merging, or widen the clone window and re-run replay.`
|
|
1296
|
+
});
|
|
1210
1297
|
return this.detectPatchesViaCommitScan();
|
|
1211
1298
|
}
|
|
1212
1299
|
const lockForGuard = this.lockManager.read();
|
|
@@ -1319,10 +1406,7 @@ var ReplayDetector = class {
|
|
|
1319
1406
|
continue;
|
|
1320
1407
|
}
|
|
1321
1408
|
const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
|
|
1322
|
-
const { files, sensitiveFiles, fernignoredFiles } = filterInfrastructureAndSensitiveFiles(
|
|
1323
|
-
filesOutput,
|
|
1324
|
-
this.fernignorePatterns
|
|
1325
|
-
);
|
|
1409
|
+
const { files, sensitiveFiles, fernignoredFiles, infrastructureFiles } = filterInfrastructureAndSensitiveFiles(filesOutput, this.fernignorePatterns);
|
|
1326
1410
|
if (sensitiveFiles.length > 0) {
|
|
1327
1411
|
this.warnings.push(
|
|
1328
1412
|
`Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
|
|
@@ -1333,7 +1417,8 @@ var ReplayDetector = class {
|
|
|
1333
1417
|
`.fernignore-protected file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${fernignoredFiles.join(", ")}. These files are managed by .fernignore, not Replay.`
|
|
1334
1418
|
);
|
|
1335
1419
|
}
|
|
1336
|
-
|
|
1420
|
+
const wasFiltered = sensitiveFiles.length > 0 || fernignoredFiles.length > 0 || infrastructureFiles.length > 0;
|
|
1421
|
+
if (wasFiltered && files.length > 0) {
|
|
1337
1422
|
if (parents.length === 0) {
|
|
1338
1423
|
continue;
|
|
1339
1424
|
}
|
|
@@ -2603,17 +2688,26 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
|
|
|
2603
2688
|
};
|
|
2604
2689
|
|
|
2605
2690
|
// src/ReplayService.ts
|
|
2606
|
-
import {
|
|
2691
|
+
import { readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
|
|
2607
2692
|
import { readFile as readFileAsync } from "fs/promises";
|
|
2608
|
-
import { join as
|
|
2693
|
+
import { join as join7 } from "path";
|
|
2609
2694
|
import { minimatch as minimatch5 } from "minimatch";
|
|
2610
2695
|
init_GitClient();
|
|
2611
2696
|
|
|
2697
|
+
// src/shared/fernignore.ts
|
|
2698
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
|
2699
|
+
import { join as join4 } from "path";
|
|
2700
|
+
function readFernignorePatterns(outputDir) {
|
|
2701
|
+
const fernignorePath = join4(outputDir, ".fernignore");
|
|
2702
|
+
if (!existsSync2(fernignorePath)) return [];
|
|
2703
|
+
return readFileSync2(fernignorePath, "utf-8").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2612
2706
|
// src/PatchRegionDiff.ts
|
|
2613
2707
|
init_HybridReconstruction();
|
|
2614
2708
|
import { mkdtemp as mkdtemp2, writeFile as writeFile3, rm as rm2 } from "fs/promises";
|
|
2615
2709
|
import { tmpdir as tmpdir2 } from "os";
|
|
2616
|
-
import { join as
|
|
2710
|
+
import { join as join5 } from "path";
|
|
2617
2711
|
import { spawn } from "child_process";
|
|
2618
2712
|
function normalizeHunkBody(hunk) {
|
|
2619
2713
|
let contextC = 0;
|
|
@@ -2830,10 +2924,10 @@ function overlayRegions(currentGenLines, locInCurrentGen, workingTreeLines, locI
|
|
|
2830
2924
|
}
|
|
2831
2925
|
async function unifiedDiff(beforeContent, afterContent, filePath) {
|
|
2832
2926
|
if (beforeContent === afterContent) return "";
|
|
2833
|
-
const tmp = await mkdtemp2(
|
|
2927
|
+
const tmp = await mkdtemp2(join5(tmpdir2(), "fern-replay-pp-"));
|
|
2834
2928
|
try {
|
|
2835
|
-
const beforePath =
|
|
2836
|
-
const afterPath =
|
|
2929
|
+
const beforePath = join5(tmp, "before");
|
|
2930
|
+
const afterPath = join5(tmp, "after");
|
|
2837
2931
|
await writeFile3(beforePath, beforeContent, "utf-8");
|
|
2838
2932
|
await writeFile3(afterPath, afterContent, "utf-8");
|
|
2839
2933
|
const raw = await runGitDiffNoIndex(beforePath, afterPath);
|
|
@@ -3009,6 +3103,7 @@ var FirstGenerationFlow = class {
|
|
|
3009
3103
|
patchesToApply: [],
|
|
3010
3104
|
newPatchIds: /* @__PURE__ */ new Set(),
|
|
3011
3105
|
detectorWarnings: [],
|
|
3106
|
+
detectorDegraded: [],
|
|
3012
3107
|
revertedCount: 0,
|
|
3013
3108
|
genSha: genRecord.commit_sha,
|
|
3014
3109
|
optionsSnapshot: options
|
|
@@ -3068,6 +3163,7 @@ var SkipApplicationFlow = class {
|
|
|
3068
3163
|
patchesToApply: [],
|
|
3069
3164
|
newPatchIds: /* @__PURE__ */ new Set(),
|
|
3070
3165
|
detectorWarnings: [],
|
|
3166
|
+
detectorDegraded: [],
|
|
3071
3167
|
revertedCount: 0,
|
|
3072
3168
|
genSha: genRecord.commit_sha,
|
|
3073
3169
|
optionsSnapshot: options
|
|
@@ -3105,8 +3201,10 @@ var NoPatchesFlow = class {
|
|
|
3105
3201
|
const previousGenerationSha = preLock.current_generation ?? null;
|
|
3106
3202
|
let { patches: newPatches, revertedPatchIds } = await this.service.detector.detectNewPatches();
|
|
3107
3203
|
const detectorWarnings = [...this.service.detector.warnings];
|
|
3204
|
+
const detectorDegraded = [...this.service.detector.degradedReasons];
|
|
3108
3205
|
if (options?.dryRun) {
|
|
3109
3206
|
const dryRunWarnings = [...detectorWarnings, ...this.service.warnings];
|
|
3207
|
+
const dryRunDegraded = dedupeDegradedReasons([...detectorDegraded, ...this.service.degradedReasons]);
|
|
3110
3208
|
const preparedReport = {
|
|
3111
3209
|
flow: "no-patches",
|
|
3112
3210
|
patchesDetected: newPatches.length,
|
|
@@ -3116,7 +3214,9 @@ var NoPatchesFlow = class {
|
|
|
3116
3214
|
patchesReverted: revertedPatchIds.length,
|
|
3117
3215
|
conflicts: [],
|
|
3118
3216
|
wouldApply: newPatches,
|
|
3119
|
-
warnings: dryRunWarnings.length > 0 ? dryRunWarnings : void 0
|
|
3217
|
+
warnings: dryRunWarnings.length > 0 ? dryRunWarnings : void 0,
|
|
3218
|
+
degraded: dryRunDegraded.length > 0 ? true : void 0,
|
|
3219
|
+
degradedReasons: dryRunDegraded.length > 0 ? dryRunDegraded : void 0
|
|
3120
3220
|
};
|
|
3121
3221
|
const headSha = await this.service.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
|
|
3122
3222
|
return {
|
|
@@ -3170,6 +3270,7 @@ var NoPatchesFlow = class {
|
|
|
3170
3270
|
patchesToApply: newPatches,
|
|
3171
3271
|
newPatchIds: new Set(newPatches.map((p) => p.id)),
|
|
3172
3272
|
detectorWarnings,
|
|
3273
|
+
detectorDegraded,
|
|
3173
3274
|
revertedCount: revertedPatchIds.length,
|
|
3174
3275
|
genSha: genRecord.commit_sha,
|
|
3175
3276
|
optionsSnapshot: options
|
|
@@ -3213,6 +3314,7 @@ var NoPatchesFlow = class {
|
|
|
3213
3314
|
}
|
|
3214
3315
|
}
|
|
3215
3316
|
const warnings = [...detectorWarnings, ...this.service.warnings];
|
|
3317
|
+
const degradedReasons = [...prep._prepared.detectorDegraded, ...this.service.degradedReasons];
|
|
3216
3318
|
return this.service.buildReport(
|
|
3217
3319
|
"no-patches",
|
|
3218
3320
|
newPatches,
|
|
@@ -3221,7 +3323,8 @@ var NoPatchesFlow = class {
|
|
|
3221
3323
|
warnings,
|
|
3222
3324
|
rebaseCounts,
|
|
3223
3325
|
void 0,
|
|
3224
|
-
prep._prepared.revertedCount
|
|
3326
|
+
prep._prepared.revertedCount,
|
|
3327
|
+
degradedReasons
|
|
3225
3328
|
);
|
|
3226
3329
|
}
|
|
3227
3330
|
};
|
|
@@ -3238,6 +3341,10 @@ var NormalRegenerationFlow = class {
|
|
|
3238
3341
|
const existingPatches2 = this.service.lockManager.getPatches();
|
|
3239
3342
|
const { patches: newPatches2, revertedPatchIds: dryRunReverted } = await this.service.detector.detectNewPatches();
|
|
3240
3343
|
const warnings = [...this.service.detector.warnings, ...this.service.warnings];
|
|
3344
|
+
const dryRunDegraded = dedupeDegradedReasons([
|
|
3345
|
+
...this.service.detector.degradedReasons,
|
|
3346
|
+
...this.service.degradedReasons
|
|
3347
|
+
]);
|
|
3241
3348
|
const allPatches2 = [...existingPatches2, ...newPatches2];
|
|
3242
3349
|
const preparedReport = {
|
|
3243
3350
|
flow: "normal-regeneration",
|
|
@@ -3248,7 +3355,9 @@ var NormalRegenerationFlow = class {
|
|
|
3248
3355
|
patchesReverted: dryRunReverted.length,
|
|
3249
3356
|
conflicts: [],
|
|
3250
3357
|
wouldApply: allPatches2,
|
|
3251
|
-
warnings: warnings.length > 0 ? warnings : void 0
|
|
3358
|
+
warnings: warnings.length > 0 ? warnings : void 0,
|
|
3359
|
+
degraded: dryRunDegraded.length > 0 ? true : void 0,
|
|
3360
|
+
degradedReasons: dryRunDegraded.length > 0 ? dryRunDegraded : void 0
|
|
3252
3361
|
};
|
|
3253
3362
|
const headSha = await this.service.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
|
|
3254
3363
|
return {
|
|
@@ -3287,6 +3396,7 @@ var NormalRegenerationFlow = class {
|
|
|
3287
3396
|
existingPatches = this.service.lockManager.getPatches();
|
|
3288
3397
|
let { patches: newPatches, revertedPatchIds } = await this.service.detector.detectNewPatches();
|
|
3289
3398
|
const detectorWarnings = [...this.service.detector.warnings];
|
|
3399
|
+
const detectorDegraded = [...this.service.detector.degradedReasons];
|
|
3290
3400
|
if (removedByPreRebase.length > 0) {
|
|
3291
3401
|
const removedOriginalCommits = new Set(removedByPreRebase.map((p) => p.original_commit));
|
|
3292
3402
|
const removedOriginalMessages = new Set(removedByPreRebase.map((p) => p.original_message));
|
|
@@ -3327,6 +3437,7 @@ var NormalRegenerationFlow = class {
|
|
|
3327
3437
|
newPatchIds: new Set(newPatches.map((p) => p.id)),
|
|
3328
3438
|
preRebaseCounts,
|
|
3329
3439
|
detectorWarnings,
|
|
3440
|
+
detectorDegraded,
|
|
3330
3441
|
revertedCount: revertedPatchIds.length,
|
|
3331
3442
|
genSha: genRecord.commit_sha,
|
|
3332
3443
|
optionsSnapshot: options
|
|
@@ -3375,6 +3486,7 @@ var NormalRegenerationFlow = class {
|
|
|
3375
3486
|
await this.service.committer.commitReplay(appliedCount, allPatches, void 0, { buckets });
|
|
3376
3487
|
}
|
|
3377
3488
|
const warnings = [...detectorWarnings, ...this.service.warnings];
|
|
3489
|
+
const degradedReasons = [...prep._prepared.detectorDegraded, ...this.service.degradedReasons];
|
|
3378
3490
|
return this.service.buildReport(
|
|
3379
3491
|
"normal-regeneration",
|
|
3380
3492
|
allPatches,
|
|
@@ -3383,7 +3495,8 @@ var NormalRegenerationFlow = class {
|
|
|
3383
3495
|
warnings,
|
|
3384
3496
|
rebaseCounts,
|
|
3385
3497
|
prep._prepared.preRebaseCounts,
|
|
3386
|
-
prep._prepared.revertedCount
|
|
3498
|
+
prep._prepared.revertedCount,
|
|
3499
|
+
degradedReasons
|
|
3387
3500
|
);
|
|
3388
3501
|
}
|
|
3389
3502
|
};
|
|
@@ -3423,6 +3536,16 @@ var ReplayService = class {
|
|
|
3423
3536
|
* @internal Used by Flow implementations in `src/flows/`.
|
|
3424
3537
|
*/
|
|
3425
3538
|
warnings = [];
|
|
3539
|
+
/**
|
|
3540
|
+
* Service-level structured degraded reasons, mirroring `warnings`.
|
|
3541
|
+
* Populated at apply time (e.g. `recordUnreachableBaseWarnings` when a
|
|
3542
|
+
* patch's base-generation tree is unreachable). Merged with
|
|
3543
|
+
* `detector.degradedReasons` into `ReplayReport.degradedReasons` by each
|
|
3544
|
+
* flow handler. Cleared at the top of `prepareReplay()`.
|
|
3545
|
+
*
|
|
3546
|
+
* @internal Used by Flow implementations in `src/flows/`.
|
|
3547
|
+
*/
|
|
3548
|
+
degradedReasons = [];
|
|
3426
3549
|
/**
|
|
3427
3550
|
* @internal Test-only accessor.
|
|
3428
3551
|
* Returns the ReplayApplicator instance used for the last run. Tests use this
|
|
@@ -3466,7 +3589,10 @@ var ReplayService = class {
|
|
|
3466
3589
|
async prepareReplay(options) {
|
|
3467
3590
|
this.warnings = [];
|
|
3468
3591
|
this.detector.warnings.length = 0;
|
|
3592
|
+
this.degradedReasons = [];
|
|
3593
|
+
this.detector.degradedReasons.length = 0;
|
|
3469
3594
|
this.cleanseLegacyFernignoreEntries();
|
|
3595
|
+
this.cleanseInfrastructureFileEntries();
|
|
3470
3596
|
return this.selectFlow(options).prepare(options);
|
|
3471
3597
|
}
|
|
3472
3598
|
/**
|
|
@@ -3520,6 +3646,50 @@ var ReplayService = class {
|
|
|
3520
3646
|
this.lockManager.save();
|
|
3521
3647
|
}
|
|
3522
3648
|
}
|
|
3649
|
+
/**
|
|
3650
|
+
* Migration step: strips infrastructure file diffs (`.fernignore`, `.fern/*`)
|
|
3651
|
+
* from existing patches' `patch_content`. These diffs were bundled by older
|
|
3652
|
+
* detection logic that did not scope `git diff` when only infrastructure files
|
|
3653
|
+
* were stripped. Patches that end up with no surviving diff sections are
|
|
3654
|
+
* removed entirely.
|
|
3655
|
+
*
|
|
3656
|
+
* Idempotent: a second run is a no-op because the first run already removed
|
|
3657
|
+
* all infrastructure sections. Per-patch try/catch ensures isolation.
|
|
3658
|
+
*/
|
|
3659
|
+
cleanseInfrastructureFileEntries() {
|
|
3660
|
+
if (!this.lockManager.exists()) return;
|
|
3661
|
+
this.lockManager.read();
|
|
3662
|
+
let lockfileMutated = false;
|
|
3663
|
+
const patches = this.lockManager.getPatches();
|
|
3664
|
+
for (const patch of patches) {
|
|
3665
|
+
try {
|
|
3666
|
+
const result = computeInfrastructureFileCleanse(
|
|
3667
|
+
patch,
|
|
3668
|
+
(content) => this.detector.computeContentHash(content)
|
|
3669
|
+
);
|
|
3670
|
+
if (result.unchanged) continue;
|
|
3671
|
+
if (result.remove) {
|
|
3672
|
+
this.lockManager.removePatch(patch.id);
|
|
3673
|
+
this.warnings.push(
|
|
3674
|
+
`Stripped infrastructure file diffs from patch ${patch.id}: patch contained only infrastructure file changes (${result.strippedPaths.join(", ")}) \u2014 removed.`
|
|
3675
|
+
);
|
|
3676
|
+
} else {
|
|
3677
|
+
this.lockManager.updatePatch(patch.id, {
|
|
3678
|
+
patch_content: result.patch_content,
|
|
3679
|
+
content_hash: result.content_hash
|
|
3680
|
+
});
|
|
3681
|
+
this.warnings.push(
|
|
3682
|
+
`Stripped infrastructure file diffs from patch ${patch.id}: removed bundled diffs for ${result.strippedPaths.join(", ")}.`
|
|
3683
|
+
);
|
|
3684
|
+
}
|
|
3685
|
+
lockfileMutated = true;
|
|
3686
|
+
} catch {
|
|
3687
|
+
}
|
|
3688
|
+
}
|
|
3689
|
+
if (lockfileMutated) {
|
|
3690
|
+
this.lockManager.save();
|
|
3691
|
+
}
|
|
3692
|
+
}
|
|
3523
3693
|
/**
|
|
3524
3694
|
* Phase 2 of the two-phase replay flow. Applies patches, rebases them against
|
|
3525
3695
|
* the generation, saves the lockfile, and commits `[fern-replay]` (or stages
|
|
@@ -3775,7 +3945,7 @@ var ReplayService = class {
|
|
|
3775
3945
|
for (const file of files) {
|
|
3776
3946
|
if (isBinaryFile(file)) continue;
|
|
3777
3947
|
try {
|
|
3778
|
-
const content = await readFileAsync(
|
|
3948
|
+
const content = await readFileAsync(join7(this.outputDir, file), "utf-8");
|
|
3779
3949
|
snapshot[file] = content;
|
|
3780
3950
|
} catch {
|
|
3781
3951
|
}
|
|
@@ -4292,6 +4462,12 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
|
|
|
4292
4462
|
);
|
|
4293
4463
|
}
|
|
4294
4464
|
}
|
|
4465
|
+
if (warned.size > 0) {
|
|
4466
|
+
this.degradedReasons.push({
|
|
4467
|
+
code: "patch-base-unreachable",
|
|
4468
|
+
message: `${warned.size} customization file(s) could not be checked against the generation they were made on because that generation's tree was not reachable during this run. Customizations may not have been carried forward \u2014 review the result before merging, or widen the clone window and re-run replay.`
|
|
4469
|
+
});
|
|
4470
|
+
}
|
|
4295
4471
|
}
|
|
4296
4472
|
/**
|
|
4297
4473
|
* After applyPatches(), strip conflict markers from conflicting files
|
|
@@ -4303,9 +4479,9 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
|
|
|
4303
4479
|
if (result.status !== "conflict" || !result.fileResults) continue;
|
|
4304
4480
|
for (const fileResult of result.fileResults) {
|
|
4305
4481
|
if (fileResult.status !== "conflict") continue;
|
|
4306
|
-
const filePath =
|
|
4482
|
+
const filePath = join7(this.outputDir, fileResult.file);
|
|
4307
4483
|
try {
|
|
4308
|
-
const content =
|
|
4484
|
+
const content = readFileSync3(filePath, "utf-8");
|
|
4309
4485
|
const stripped = stripConflictMarkers(content);
|
|
4310
4486
|
writeFileSync2(filePath, stripped);
|
|
4311
4487
|
} catch {
|
|
@@ -4335,12 +4511,11 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
|
|
|
4335
4511
|
}
|
|
4336
4512
|
/** @internal Used by Flow implementations in `src/flows/`. */
|
|
4337
4513
|
readFernignorePatterns() {
|
|
4338
|
-
|
|
4339
|
-
if (!existsSync2(fernignorePath)) return [];
|
|
4340
|
-
return readFileSync2(fernignorePath, "utf-8").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
|
|
4514
|
+
return readFernignorePatterns(this.outputDir);
|
|
4341
4515
|
}
|
|
4342
4516
|
/** @internal Used by Flow implementations in `src/flows/`. */
|
|
4343
|
-
buildReport(flow, patches, results, options, warnings, rebaseCounts, preRebaseCounts, patchesReverted) {
|
|
4517
|
+
buildReport(flow, patches, results, options, warnings, rebaseCounts, preRebaseCounts, patchesReverted, degradedReasons) {
|
|
4518
|
+
const degraded = dedupeDegradedReasons(degradedReasons ?? []);
|
|
4344
4519
|
const conflictResults = results.filter((r) => r.status === "conflict");
|
|
4345
4520
|
const conflictDetails = conflictResults.map((r) => {
|
|
4346
4521
|
const conflictFiles = r.fileResults?.filter((f) => f.status === "conflict") ?? [];
|
|
@@ -4380,10 +4555,23 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
|
|
|
4380
4555
|
conflictDetails: r.fileResults?.filter((f) => f.status === "conflict") ?? []
|
|
4381
4556
|
})) : void 0,
|
|
4382
4557
|
wouldApply: options?.dryRun ? patches : void 0,
|
|
4383
|
-
warnings: warnings && warnings.length > 0 ? warnings : void 0
|
|
4558
|
+
warnings: warnings && warnings.length > 0 ? warnings : void 0,
|
|
4559
|
+
degraded: degraded.length > 0 ? true : void 0,
|
|
4560
|
+
degradedReasons: degraded.length > 0 ? degraded : void 0
|
|
4384
4561
|
};
|
|
4385
4562
|
}
|
|
4386
4563
|
};
|
|
4564
|
+
function dedupeDegradedReasons(reasons) {
|
|
4565
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4566
|
+
const out = [];
|
|
4567
|
+
for (const reason of reasons) {
|
|
4568
|
+
const key = `${reason.code}\0${reason.message}`;
|
|
4569
|
+
if (seen.has(key)) continue;
|
|
4570
|
+
seen.add(key);
|
|
4571
|
+
out.push(reason);
|
|
4572
|
+
}
|
|
4573
|
+
return out;
|
|
4574
|
+
}
|
|
4387
4575
|
function computeCommitBuckets(results, absorbedPatchIds, patchesPassedToCommit) {
|
|
4388
4576
|
const resultByPatchId = /* @__PURE__ */ new Map();
|
|
4389
4577
|
for (const r of results) resultByPatchId.set(r.patch.id, r);
|
|
@@ -4432,7 +4620,7 @@ function computeLegacyFernignoreCleanse(patch, fernignorePatterns, computeConten
|
|
|
4432
4620
|
}
|
|
4433
4621
|
}
|
|
4434
4622
|
}
|
|
4435
|
-
const { stripped: patchContentStripped, content: newPatchContent, strippedSectionPaths } =
|
|
4623
|
+
const { stripped: patchContentStripped, content: newPatchContent, strippedSectionPaths } = stripDiffSectionsByPredicate(patch.patch_content, matchesFernignore);
|
|
4436
4624
|
const unchanged = strippedFromFiles.length === 0 && !snapshotChanged && !patchContentStripped;
|
|
4437
4625
|
if (unchanged) {
|
|
4438
4626
|
return {
|
|
@@ -4467,7 +4655,38 @@ function computeLegacyFernignoreCleanse(patch, fernignorePatterns, computeConten
|
|
|
4467
4655
|
strippedPaths
|
|
4468
4656
|
};
|
|
4469
4657
|
}
|
|
4470
|
-
function
|
|
4658
|
+
function isInfrastructureFile(filePath) {
|
|
4659
|
+
return filePath === ".fernignore" || filePath.startsWith(".fern/");
|
|
4660
|
+
}
|
|
4661
|
+
function computeInfrastructureFileCleanse(patch, computeContentHash2) {
|
|
4662
|
+
const { stripped, content: newPatchContent, strippedSectionPaths } = stripDiffSectionsByPredicate(patch.patch_content, isInfrastructureFile);
|
|
4663
|
+
if (!stripped) {
|
|
4664
|
+
return {
|
|
4665
|
+
unchanged: true,
|
|
4666
|
+
remove: false,
|
|
4667
|
+
patch_content: patch.patch_content,
|
|
4668
|
+
content_hash: patch.content_hash,
|
|
4669
|
+
strippedPaths: []
|
|
4670
|
+
};
|
|
4671
|
+
}
|
|
4672
|
+
if (newPatchContent.trim() === "" || patch.files.length === 0) {
|
|
4673
|
+
return {
|
|
4674
|
+
unchanged: false,
|
|
4675
|
+
remove: true,
|
|
4676
|
+
patch_content: "",
|
|
4677
|
+
content_hash: "",
|
|
4678
|
+
strippedPaths: strippedSectionPaths
|
|
4679
|
+
};
|
|
4680
|
+
}
|
|
4681
|
+
return {
|
|
4682
|
+
unchanged: false,
|
|
4683
|
+
remove: false,
|
|
4684
|
+
patch_content: newPatchContent,
|
|
4685
|
+
content_hash: computeContentHash2(newPatchContent),
|
|
4686
|
+
strippedPaths: strippedSectionPaths
|
|
4687
|
+
};
|
|
4688
|
+
}
|
|
4689
|
+
function stripDiffSectionsByPredicate(patchContent, shouldStrip) {
|
|
4471
4690
|
const lines = patchContent.split("\n");
|
|
4472
4691
|
const sectionStarts = [];
|
|
4473
4692
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -4489,7 +4708,7 @@ function stripFernignoreDiffSections(patchContent, matchesFernignore) {
|
|
|
4489
4708
|
const section = sectionStarts[i];
|
|
4490
4709
|
const next = sectionStarts[i + 1];
|
|
4491
4710
|
const endIdx = next ? next.idx : lines.length;
|
|
4492
|
-
if (section.path !== null &&
|
|
4711
|
+
if (section.path !== null && shouldStrip(section.path)) {
|
|
4493
4712
|
stripped = true;
|
|
4494
4713
|
strippedSectionPaths.push(section.path);
|
|
4495
4714
|
continue;
|
|
@@ -4501,8 +4720,8 @@ function stripFernignoreDiffSections(patchContent, matchesFernignore) {
|
|
|
4501
4720
|
|
|
4502
4721
|
// src/FernignoreMigrator.ts
|
|
4503
4722
|
import { createHash as createHash2 } from "crypto";
|
|
4504
|
-
import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as
|
|
4505
|
-
import { dirname as dirname5, join as
|
|
4723
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync4, statSync, writeFileSync as writeFileSync3 } from "fs";
|
|
4724
|
+
import { dirname as dirname5, join as join8 } from "path";
|
|
4506
4725
|
import { minimatch as minimatch6 } from "minimatch";
|
|
4507
4726
|
import { parse as parse2, stringify as stringify2 } from "yaml";
|
|
4508
4727
|
var FernignoreMigrator = class {
|
|
@@ -4515,14 +4734,14 @@ var FernignoreMigrator = class {
|
|
|
4515
4734
|
this.outputDir = outputDir;
|
|
4516
4735
|
}
|
|
4517
4736
|
fernignoreExists() {
|
|
4518
|
-
return existsSync3(
|
|
4737
|
+
return existsSync3(join8(this.outputDir, ".fernignore"));
|
|
4519
4738
|
}
|
|
4520
4739
|
readFernignorePatterns() {
|
|
4521
|
-
const fernignorePath =
|
|
4740
|
+
const fernignorePath = join8(this.outputDir, ".fernignore");
|
|
4522
4741
|
if (!existsSync3(fernignorePath)) {
|
|
4523
4742
|
return [];
|
|
4524
4743
|
}
|
|
4525
|
-
const content =
|
|
4744
|
+
const content = readFileSync4(fernignorePath, "utf-8");
|
|
4526
4745
|
return content.split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
|
|
4527
4746
|
}
|
|
4528
4747
|
/** Analyze .fernignore patterns vs git history. Creates synthetic patches for files differing from pristine generation. */
|
|
@@ -4627,7 +4846,7 @@ var FernignoreMigrator = class {
|
|
|
4627
4846
|
return results;
|
|
4628
4847
|
}
|
|
4629
4848
|
readFileContent(filePath) {
|
|
4630
|
-
const fullPath =
|
|
4849
|
+
const fullPath = join8(this.outputDir, filePath);
|
|
4631
4850
|
if (!existsSync3(fullPath)) {
|
|
4632
4851
|
return null;
|
|
4633
4852
|
}
|
|
@@ -4636,7 +4855,7 @@ var FernignoreMigrator = class {
|
|
|
4636
4855
|
if (stat.isDirectory()) {
|
|
4637
4856
|
return null;
|
|
4638
4857
|
}
|
|
4639
|
-
return
|
|
4858
|
+
return readFileSync4(fullPath, "utf-8");
|
|
4640
4859
|
} catch {
|
|
4641
4860
|
return null;
|
|
4642
4861
|
}
|
|
@@ -4692,10 +4911,10 @@ var FernignoreMigrator = class {
|
|
|
4692
4911
|
};
|
|
4693
4912
|
}
|
|
4694
4913
|
movePatternsToReplayYml(patterns) {
|
|
4695
|
-
const replayYmlPath =
|
|
4914
|
+
const replayYmlPath = join8(this.outputDir, ".fern", "replay.yml");
|
|
4696
4915
|
let config = {};
|
|
4697
4916
|
if (existsSync3(replayYmlPath)) {
|
|
4698
|
-
const content =
|
|
4917
|
+
const content = readFileSync4(replayYmlPath, "utf-8");
|
|
4699
4918
|
config = parse2(content) ?? {};
|
|
4700
4919
|
}
|
|
4701
4920
|
const existing = config.exclude ?? [];
|
|
@@ -4711,8 +4930,8 @@ var FernignoreMigrator = class {
|
|
|
4711
4930
|
|
|
4712
4931
|
// src/commands/bootstrap.ts
|
|
4713
4932
|
import { createHash as createHash3 } from "crypto";
|
|
4714
|
-
import { existsSync as existsSync4, readFileSync as
|
|
4715
|
-
import { join as
|
|
4933
|
+
import { existsSync as existsSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
|
|
4934
|
+
import { join as join9 } from "path";
|
|
4716
4935
|
init_GitClient();
|
|
4717
4936
|
async function bootstrap(outputDir, options) {
|
|
4718
4937
|
const git = new GitClient(outputDir);
|
|
@@ -4879,10 +5098,10 @@ function parseGitLog(log) {
|
|
|
4879
5098
|
}
|
|
4880
5099
|
var REPLAY_FERNIGNORE_ENTRIES = [".fern/replay.lock", ".fern/replay.yml", ".gitattributes"];
|
|
4881
5100
|
function ensureFernignoreEntries(outputDir) {
|
|
4882
|
-
const fernignorePath =
|
|
5101
|
+
const fernignorePath = join9(outputDir, ".fernignore");
|
|
4883
5102
|
let content = "";
|
|
4884
5103
|
if (existsSync4(fernignorePath)) {
|
|
4885
|
-
content =
|
|
5104
|
+
content = readFileSync5(fernignorePath, "utf-8");
|
|
4886
5105
|
}
|
|
4887
5106
|
const lines = content.split("\n");
|
|
4888
5107
|
const toAdd = [];
|
|
@@ -4903,10 +5122,10 @@ function ensureFernignoreEntries(outputDir) {
|
|
|
4903
5122
|
}
|
|
4904
5123
|
var GITATTRIBUTES_ENTRIES = [".fern/replay.lock linguist-generated=true"];
|
|
4905
5124
|
function ensureGitattributesEntries(outputDir) {
|
|
4906
|
-
const gitattributesPath =
|
|
5125
|
+
const gitattributesPath = join9(outputDir, ".gitattributes");
|
|
4907
5126
|
let content = "";
|
|
4908
5127
|
if (existsSync4(gitattributesPath)) {
|
|
4909
|
-
content =
|
|
5128
|
+
content = readFileSync5(gitattributesPath, "utf-8");
|
|
4910
5129
|
}
|
|
4911
5130
|
const lines = content.split("\n");
|
|
4912
5131
|
const toAdd = [];
|
|
@@ -4933,6 +5152,7 @@ function computeContentHash(patchContent) {
|
|
|
4933
5152
|
}
|
|
4934
5153
|
|
|
4935
5154
|
// src/commands/forget.ts
|
|
5155
|
+
init_GitClient();
|
|
4936
5156
|
import { minimatch as minimatch7 } from "minimatch";
|
|
4937
5157
|
function parseDiffStat(patchContent) {
|
|
4938
5158
|
let additions = 0;
|
|
@@ -4992,7 +5212,28 @@ var EMPTY_RESULT = {
|
|
|
4992
5212
|
totalPatches: 0,
|
|
4993
5213
|
warnings: []
|
|
4994
5214
|
};
|
|
4995
|
-
function
|
|
5215
|
+
async function dismissPatches(outputDir, lockManager, patches) {
|
|
5216
|
+
const git = new GitClient(outputDir);
|
|
5217
|
+
const detector = new ReplayDetector(git, lockManager, outputDir, readFernignorePatterns(outputDir));
|
|
5218
|
+
for (const patch of patches) {
|
|
5219
|
+
const hashes = [patch.content_hash];
|
|
5220
|
+
if (patch.original_commit) {
|
|
5221
|
+
const materialized = await detector.materializeCommitPatch(patch.original_commit);
|
|
5222
|
+
if (materialized.status === "ok" && !hashes.includes(materialized.contentHash)) {
|
|
5223
|
+
hashes.push(materialized.contentHash);
|
|
5224
|
+
}
|
|
5225
|
+
}
|
|
5226
|
+
lockManager.addDismissal({
|
|
5227
|
+
patch_id: patch.id,
|
|
5228
|
+
original_commit: patch.original_commit,
|
|
5229
|
+
original_message: patch.original_message,
|
|
5230
|
+
content_hashes: hashes,
|
|
5231
|
+
dismissed_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5232
|
+
via: "forget"
|
|
5233
|
+
});
|
|
5234
|
+
}
|
|
5235
|
+
}
|
|
5236
|
+
async function forget(outputDir, options) {
|
|
4996
5237
|
const lockManager = new LockfileManager(outputDir);
|
|
4997
5238
|
if (!lockManager.exists()) {
|
|
4998
5239
|
return { ...EMPTY_RESULT };
|
|
@@ -5003,9 +5244,7 @@ function forget(outputDir, options) {
|
|
|
5003
5244
|
const removed = lock.patches.map(toMatchedPatch);
|
|
5004
5245
|
const warnings = buildWarnings(lock.patches);
|
|
5005
5246
|
if (!options.dryRun) {
|
|
5006
|
-
|
|
5007
|
-
lockManager.addForgottenHash(patch.content_hash);
|
|
5008
|
-
}
|
|
5247
|
+
await dismissPatches(outputDir, lockManager, lock.patches);
|
|
5009
5248
|
lockManager.clearPatches();
|
|
5010
5249
|
lockManager.save();
|
|
5011
5250
|
}
|
|
@@ -5034,8 +5273,8 @@ function forget(outputDir, options) {
|
|
|
5034
5273
|
}
|
|
5035
5274
|
const warnings = buildWarnings(patchesToRemove);
|
|
5036
5275
|
if (!options.dryRun) {
|
|
5276
|
+
await dismissPatches(outputDir, lockManager, patchesToRemove);
|
|
5037
5277
|
for (const patch of patchesToRemove) {
|
|
5038
|
-
lockManager.addForgottenHash(patch.content_hash);
|
|
5039
5278
|
lockManager.removePatch(patch.id);
|
|
5040
5279
|
}
|
|
5041
5280
|
lockManager.save();
|
|
@@ -5103,7 +5342,7 @@ function reset(outputDir, options) {
|
|
|
5103
5342
|
// src/commands/resolve.ts
|
|
5104
5343
|
init_GitClient();
|
|
5105
5344
|
import { readFile as readFile4 } from "fs/promises";
|
|
5106
|
-
import { join as
|
|
5345
|
+
import { join as join10 } from "path";
|
|
5107
5346
|
async function resolve(outputDir, options) {
|
|
5108
5347
|
const lockManager = new LockfileManager(outputDir);
|
|
5109
5348
|
if (!lockManager.exists()) {
|
|
@@ -5143,15 +5382,16 @@ async function resolve(outputDir, options) {
|
|
|
5143
5382
|
const patchesToCommit = [...resolvingPatches, ...unresolvedPatches];
|
|
5144
5383
|
if (patchesToCommit.length > 0) {
|
|
5145
5384
|
const currentGen = lock.current_generation;
|
|
5146
|
-
const detector = new ReplayDetector(git, lockManager, outputDir);
|
|
5385
|
+
const detector = new ReplayDetector(git, lockManager, outputDir, readFernignorePatterns(outputDir));
|
|
5147
5386
|
const warnings = [];
|
|
5387
|
+
const patchesDismissed = [];
|
|
5148
5388
|
let patchesResolved = 0;
|
|
5149
5389
|
const pass1 = [];
|
|
5150
5390
|
for (const patch of patchesToCommit) {
|
|
5151
5391
|
const result = await computePerPatchDiffWithFallback(
|
|
5152
5392
|
patch,
|
|
5153
5393
|
(f) => git.showFile(currentGen, f),
|
|
5154
|
-
(f) => readFile4(
|
|
5394
|
+
(f) => readFile4(join10(outputDir, f), "utf-8").catch(() => null),
|
|
5155
5395
|
(files) => git.exec(["diff", currentGen, "--", ...files]).catch(() => null)
|
|
5156
5396
|
);
|
|
5157
5397
|
if (result === null) {
|
|
@@ -5185,7 +5425,27 @@ async function resolve(outputDir, options) {
|
|
|
5185
5425
|
const r = pass1[i];
|
|
5186
5426
|
if (r.kind === "skip") continue;
|
|
5187
5427
|
if (r.kind === "revert") {
|
|
5428
|
+
const hashes = [patch.content_hash];
|
|
5429
|
+
if (patch.original_commit) {
|
|
5430
|
+
const materialized = await detector.materializeCommitPatch(patch.original_commit);
|
|
5431
|
+
if (materialized.status === "ok" && !hashes.includes(materialized.contentHash)) {
|
|
5432
|
+
hashes.push(materialized.contentHash);
|
|
5433
|
+
} else if (materialized.status === "unreachable") {
|
|
5434
|
+
warnings.push(
|
|
5435
|
+
`Patch ${patch.id}: original commit ${patch.original_commit.slice(0, 7)} is unreachable; dismissed using the stored content hash only. If this commit reappears in history, the customization may be re-detected once.`
|
|
5436
|
+
);
|
|
5437
|
+
}
|
|
5438
|
+
}
|
|
5439
|
+
lockManager.addDismissal({
|
|
5440
|
+
patch_id: patch.id,
|
|
5441
|
+
original_commit: patch.original_commit,
|
|
5442
|
+
original_message: patch.original_message,
|
|
5443
|
+
content_hashes: hashes,
|
|
5444
|
+
dismissed_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5445
|
+
via: "resolve"
|
|
5446
|
+
});
|
|
5188
5447
|
lockManager.removePatch(patch.id);
|
|
5448
|
+
patchesDismissed.push({ id: patch.id, message: patch.original_message });
|
|
5189
5449
|
continue;
|
|
5190
5450
|
}
|
|
5191
5451
|
if (lastIndexByHash.get(r.hash) !== i) {
|
|
@@ -5207,7 +5467,7 @@ async function resolve(outputDir, options) {
|
|
|
5207
5467
|
const theirsSnapshot = {};
|
|
5208
5468
|
for (const file of filesForSnapshot) {
|
|
5209
5469
|
if (isBinaryFile(file)) continue;
|
|
5210
|
-
const content = await readFile4(
|
|
5470
|
+
const content = await readFile4(join10(outputDir, file), "utf-8").catch(() => null);
|
|
5211
5471
|
if (content != null) {
|
|
5212
5472
|
theirsSnapshot[file] = content;
|
|
5213
5473
|
}
|
|
@@ -5234,6 +5494,7 @@ async function resolve(outputDir, options) {
|
|
|
5234
5494
|
commitSha: commitSha2,
|
|
5235
5495
|
phase: "committed",
|
|
5236
5496
|
patchesResolved,
|
|
5497
|
+
patchesDismissed: patchesDismissed.length > 0 ? patchesDismissed : void 0,
|
|
5237
5498
|
warnings: warnings.length > 0 ? warnings : void 0
|
|
5238
5499
|
};
|
|
5239
5500
|
}
|
|
@@ -5281,7 +5542,9 @@ function status(outputDir) {
|
|
|
5281
5542
|
lastGeneration: void 0,
|
|
5282
5543
|
patches: [],
|
|
5283
5544
|
unresolvedCount: 0,
|
|
5284
|
-
excludePatterns: []
|
|
5545
|
+
excludePatterns: [],
|
|
5546
|
+
dismissed: [],
|
|
5547
|
+
legacyDismissedCount: 0
|
|
5285
5548
|
};
|
|
5286
5549
|
}
|
|
5287
5550
|
const lock = lockManager.read();
|
|
@@ -5310,13 +5573,24 @@ function status(outputDir) {
|
|
|
5310
5573
|
}
|
|
5311
5574
|
const config = lockManager.getCustomizationsConfig();
|
|
5312
5575
|
const excludePatterns = config.exclude ?? [];
|
|
5576
|
+
const dismissals = lock.dismissals ?? [];
|
|
5577
|
+
const dismissed = dismissals.map((d) => ({
|
|
5578
|
+
message: d.original_message,
|
|
5579
|
+
sha: d.original_commit.slice(0, 7),
|
|
5580
|
+
via: d.via,
|
|
5581
|
+
dismissedAt: d.dismissed_at
|
|
5582
|
+
}));
|
|
5583
|
+
const coveredHashes = new Set(dismissals.flatMap((d) => d.content_hashes));
|
|
5584
|
+
const legacyDismissedCount = (lock.forgotten_hashes ?? []).filter((h) => !coveredHashes.has(h)).length;
|
|
5313
5585
|
return {
|
|
5314
5586
|
initialized: true,
|
|
5315
5587
|
generationCount: lock.generations.length,
|
|
5316
5588
|
lastGeneration,
|
|
5317
5589
|
patches,
|
|
5318
5590
|
unresolvedCount,
|
|
5319
|
-
excludePatterns
|
|
5591
|
+
excludePatterns,
|
|
5592
|
+
dismissed,
|
|
5593
|
+
legacyDismissedCount
|
|
5320
5594
|
};
|
|
5321
5595
|
}
|
|
5322
5596
|
export {
|