@fern-api/replay 0.18.0 → 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 +427 -120
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +372 -111
- 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 +355 -94
- package/dist/index.js.map +1 -1
- package/package.json +3 -1
package/dist/index.js
CHANGED
|
@@ -407,18 +407,18 @@ __export(PatchApplyTheirs_exports, {
|
|
|
407
407
|
});
|
|
408
408
|
import { mkdtemp as mkdtemp3, mkdir as mkdir3, writeFile as writeFile4, readFile as readFile3, rm as rm3 } from "fs/promises";
|
|
409
409
|
import { tmpdir as tmpdir3 } from "os";
|
|
410
|
-
import { dirname as dirname4, join as
|
|
410
|
+
import { dirname as dirname4, join as join6 } from "path";
|
|
411
411
|
async function applyPatchToContent(base, patchContent, filePath) {
|
|
412
412
|
const fileDiff = extractFileDiff(patchContent, filePath);
|
|
413
413
|
if (!fileDiff) return null;
|
|
414
|
-
const tempDir = await mkdtemp3(
|
|
414
|
+
const tempDir = await mkdtemp3(join6(tmpdir3(), "replay-migrate-"));
|
|
415
415
|
const tempGit = new GitClient(tempDir);
|
|
416
416
|
try {
|
|
417
417
|
await tempGit.exec(["init"]);
|
|
418
418
|
await tempGit.exec(["config", "user.email", "migrate@fern.com"]);
|
|
419
419
|
await tempGit.exec(["config", "user.name", "Fern Replay Migration"]);
|
|
420
420
|
await tempGit.exec(["config", "commit.gpgSign", "false"]);
|
|
421
|
-
const tempFilePath =
|
|
421
|
+
const tempFilePath = join6(tempDir, filePath);
|
|
422
422
|
await mkdir3(dirname4(tempFilePath), { recursive: true });
|
|
423
423
|
await writeFile4(tempFilePath, base);
|
|
424
424
|
await tempGit.exec(["add", filePath]);
|
|
@@ -695,6 +695,22 @@ var LockfileManager = class {
|
|
|
695
695
|
this.lock.forgotten_hashes.push(hash);
|
|
696
696
|
}
|
|
697
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
|
+
}
|
|
698
714
|
getUnresolvedPatches() {
|
|
699
715
|
this.ensureLoaded();
|
|
700
716
|
return this.lock.patches.filter((p) => p.status === "unresolved");
|
|
@@ -862,12 +878,14 @@ async function capturePatchSnapshot(git, files, treeish) {
|
|
|
862
878
|
return snapshot;
|
|
863
879
|
}
|
|
864
880
|
function filterInfrastructureAndSensitiveFiles(rawFilesOutput, fernignorePatterns) {
|
|
865
|
-
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/"));
|
|
866
884
|
const sensitiveFiles = allFiles.filter((f) => isSensitiveFile(f));
|
|
867
885
|
const nonSensitive = allFiles.filter((f) => !isSensitiveFile(f));
|
|
868
886
|
const fernignoredFiles = nonSensitive.filter((f) => matchesFernignorePattern(f, fernignorePatterns));
|
|
869
887
|
const files = nonSensitive.filter((f) => !matchesFernignorePattern(f, fernignorePatterns));
|
|
870
|
-
return { files, sensitiveFiles, fernignoredFiles };
|
|
888
|
+
return { files, sensitiveFiles, fernignoredFiles, infrastructureFiles };
|
|
871
889
|
}
|
|
872
890
|
var ReplayDetector = class {
|
|
873
891
|
git;
|
|
@@ -875,6 +893,15 @@ var ReplayDetector = class {
|
|
|
875
893
|
sdkOutputDir;
|
|
876
894
|
fernignorePatterns;
|
|
877
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 = [];
|
|
878
905
|
constructor(git, lockManager, sdkOutputDir, fernignorePatterns = []) {
|
|
879
906
|
this.git = git;
|
|
880
907
|
this.lockManager = lockManager;
|
|
@@ -945,6 +972,10 @@ var ReplayDetector = class {
|
|
|
945
972
|
this.warnings.push(
|
|
946
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.`
|
|
947
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
|
+
});
|
|
948
979
|
}
|
|
949
980
|
return { patches: [], revertedPatchIds: [] };
|
|
950
981
|
}
|
|
@@ -953,6 +984,12 @@ var ReplayDetector = class {
|
|
|
953
984
|
this.warnings.push(
|
|
954
985
|
`Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to tree-diff detection (likely a shallow clone).`
|
|
955
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
|
+
}
|
|
956
993
|
return this.detectPatchesViaTreeDiff(
|
|
957
994
|
lastGen,
|
|
958
995
|
/* commitKnownMissing */
|
|
@@ -992,54 +1029,27 @@ var ReplayDetector = class {
|
|
|
992
1029
|
if (lock.patches.find((p) => p.original_commit === commit.sha)) {
|
|
993
1030
|
continue;
|
|
994
1031
|
}
|
|
995
|
-
const
|
|
996
|
-
|
|
997
|
-
try {
|
|
998
|
-
filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
|
|
999
|
-
} catch {
|
|
1032
|
+
const materialized = await this.materializeCommitPatch(commit.sha);
|
|
1033
|
+
if (materialized.sensitiveFiles.length > 0) {
|
|
1000
1034
|
this.warnings.push(
|
|
1001
|
-
`
|
|
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.`
|
|
1002
1036
|
);
|
|
1003
|
-
continue;
|
|
1004
1037
|
}
|
|
1005
|
-
|
|
1006
|
-
filesOutput,
|
|
1007
|
-
this.fernignorePatterns
|
|
1008
|
-
);
|
|
1009
|
-
if (sensitiveFiles.length > 0) {
|
|
1038
|
+
if (materialized.fernignoredFiles.length > 0) {
|
|
1010
1039
|
this.warnings.push(
|
|
1011
|
-
|
|
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.`
|
|
1012
1041
|
);
|
|
1013
1042
|
}
|
|
1014
|
-
if (
|
|
1043
|
+
if (materialized.status === "unreachable") {
|
|
1015
1044
|
this.warnings.push(
|
|
1016
|
-
|
|
1045
|
+
`Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
|
|
1017
1046
|
);
|
|
1018
|
-
}
|
|
1019
|
-
if (files.length === 0) {
|
|
1020
1047
|
continue;
|
|
1021
1048
|
}
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
let patchContent;
|
|
1025
|
-
if (wasFiltered && parentSha) {
|
|
1026
|
-
try {
|
|
1027
|
-
patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
|
|
1028
|
-
} catch {
|
|
1029
|
-
continue;
|
|
1030
|
-
}
|
|
1031
|
-
if (!patchContent.trim()) continue;
|
|
1032
|
-
} else {
|
|
1033
|
-
try {
|
|
1034
|
-
patchContent = await this.git.formatPatch(commit.sha);
|
|
1035
|
-
} catch {
|
|
1036
|
-
this.warnings.push(
|
|
1037
|
-
`Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
|
|
1038
|
-
);
|
|
1039
|
-
continue;
|
|
1040
|
-
}
|
|
1049
|
+
if (materialized.status === "empty") {
|
|
1050
|
+
continue;
|
|
1041
1051
|
}
|
|
1042
|
-
const contentHash =
|
|
1052
|
+
const { files, patchContent, contentHash } = materialized;
|
|
1043
1053
|
if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
|
|
1044
1054
|
continue;
|
|
1045
1055
|
}
|
|
@@ -1116,6 +1126,63 @@ var ReplayDetector = class {
|
|
|
1116
1126
|
const filteredPatches = survivingPatches.filter((_, i) => !revertIndicesToRemove.has(i));
|
|
1117
1127
|
return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
|
|
1118
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
|
+
}
|
|
1119
1186
|
/**
|
|
1120
1187
|
* Compute content hash for deduplication.
|
|
1121
1188
|
*
|
|
@@ -1220,6 +1287,13 @@ var ReplayDetector = class {
|
|
|
1220
1287
|
async detectPatchesViaTreeDiff(lastGen, commitKnownMissing) {
|
|
1221
1288
|
const diffBase = await this.resolveDiffBase(lastGen, commitKnownMissing);
|
|
1222
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
|
+
});
|
|
1223
1297
|
return this.detectPatchesViaCommitScan();
|
|
1224
1298
|
}
|
|
1225
1299
|
const lockForGuard = this.lockManager.read();
|
|
@@ -1332,10 +1406,7 @@ var ReplayDetector = class {
|
|
|
1332
1406
|
continue;
|
|
1333
1407
|
}
|
|
1334
1408
|
const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
|
|
1335
|
-
const { files, sensitiveFiles, fernignoredFiles } = filterInfrastructureAndSensitiveFiles(
|
|
1336
|
-
filesOutput,
|
|
1337
|
-
this.fernignorePatterns
|
|
1338
|
-
);
|
|
1409
|
+
const { files, sensitiveFiles, fernignoredFiles, infrastructureFiles } = filterInfrastructureAndSensitiveFiles(filesOutput, this.fernignorePatterns);
|
|
1339
1410
|
if (sensitiveFiles.length > 0) {
|
|
1340
1411
|
this.warnings.push(
|
|
1341
1412
|
`Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
|
|
@@ -1346,7 +1417,8 @@ var ReplayDetector = class {
|
|
|
1346
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.`
|
|
1347
1418
|
);
|
|
1348
1419
|
}
|
|
1349
|
-
|
|
1420
|
+
const wasFiltered = sensitiveFiles.length > 0 || fernignoredFiles.length > 0 || infrastructureFiles.length > 0;
|
|
1421
|
+
if (wasFiltered && files.length > 0) {
|
|
1350
1422
|
if (parents.length === 0) {
|
|
1351
1423
|
continue;
|
|
1352
1424
|
}
|
|
@@ -2616,17 +2688,26 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
|
|
|
2616
2688
|
};
|
|
2617
2689
|
|
|
2618
2690
|
// src/ReplayService.ts
|
|
2619
|
-
import {
|
|
2691
|
+
import { readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
|
|
2620
2692
|
import { readFile as readFileAsync } from "fs/promises";
|
|
2621
|
-
import { join as
|
|
2693
|
+
import { join as join7 } from "path";
|
|
2622
2694
|
import { minimatch as minimatch5 } from "minimatch";
|
|
2623
2695
|
init_GitClient();
|
|
2624
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
|
+
|
|
2625
2706
|
// src/PatchRegionDiff.ts
|
|
2626
2707
|
init_HybridReconstruction();
|
|
2627
2708
|
import { mkdtemp as mkdtemp2, writeFile as writeFile3, rm as rm2 } from "fs/promises";
|
|
2628
2709
|
import { tmpdir as tmpdir2 } from "os";
|
|
2629
|
-
import { join as
|
|
2710
|
+
import { join as join5 } from "path";
|
|
2630
2711
|
import { spawn } from "child_process";
|
|
2631
2712
|
function normalizeHunkBody(hunk) {
|
|
2632
2713
|
let contextC = 0;
|
|
@@ -2843,10 +2924,10 @@ function overlayRegions(currentGenLines, locInCurrentGen, workingTreeLines, locI
|
|
|
2843
2924
|
}
|
|
2844
2925
|
async function unifiedDiff(beforeContent, afterContent, filePath) {
|
|
2845
2926
|
if (beforeContent === afterContent) return "";
|
|
2846
|
-
const tmp = await mkdtemp2(
|
|
2927
|
+
const tmp = await mkdtemp2(join5(tmpdir2(), "fern-replay-pp-"));
|
|
2847
2928
|
try {
|
|
2848
|
-
const beforePath =
|
|
2849
|
-
const afterPath =
|
|
2929
|
+
const beforePath = join5(tmp, "before");
|
|
2930
|
+
const afterPath = join5(tmp, "after");
|
|
2850
2931
|
await writeFile3(beforePath, beforeContent, "utf-8");
|
|
2851
2932
|
await writeFile3(afterPath, afterContent, "utf-8");
|
|
2852
2933
|
const raw = await runGitDiffNoIndex(beforePath, afterPath);
|
|
@@ -3022,6 +3103,7 @@ var FirstGenerationFlow = class {
|
|
|
3022
3103
|
patchesToApply: [],
|
|
3023
3104
|
newPatchIds: /* @__PURE__ */ new Set(),
|
|
3024
3105
|
detectorWarnings: [],
|
|
3106
|
+
detectorDegraded: [],
|
|
3025
3107
|
revertedCount: 0,
|
|
3026
3108
|
genSha: genRecord.commit_sha,
|
|
3027
3109
|
optionsSnapshot: options
|
|
@@ -3081,6 +3163,7 @@ var SkipApplicationFlow = class {
|
|
|
3081
3163
|
patchesToApply: [],
|
|
3082
3164
|
newPatchIds: /* @__PURE__ */ new Set(),
|
|
3083
3165
|
detectorWarnings: [],
|
|
3166
|
+
detectorDegraded: [],
|
|
3084
3167
|
revertedCount: 0,
|
|
3085
3168
|
genSha: genRecord.commit_sha,
|
|
3086
3169
|
optionsSnapshot: options
|
|
@@ -3118,8 +3201,10 @@ var NoPatchesFlow = class {
|
|
|
3118
3201
|
const previousGenerationSha = preLock.current_generation ?? null;
|
|
3119
3202
|
let { patches: newPatches, revertedPatchIds } = await this.service.detector.detectNewPatches();
|
|
3120
3203
|
const detectorWarnings = [...this.service.detector.warnings];
|
|
3204
|
+
const detectorDegraded = [...this.service.detector.degradedReasons];
|
|
3121
3205
|
if (options?.dryRun) {
|
|
3122
3206
|
const dryRunWarnings = [...detectorWarnings, ...this.service.warnings];
|
|
3207
|
+
const dryRunDegraded = dedupeDegradedReasons([...detectorDegraded, ...this.service.degradedReasons]);
|
|
3123
3208
|
const preparedReport = {
|
|
3124
3209
|
flow: "no-patches",
|
|
3125
3210
|
patchesDetected: newPatches.length,
|
|
@@ -3129,7 +3214,9 @@ var NoPatchesFlow = class {
|
|
|
3129
3214
|
patchesReverted: revertedPatchIds.length,
|
|
3130
3215
|
conflicts: [],
|
|
3131
3216
|
wouldApply: newPatches,
|
|
3132
|
-
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
|
|
3133
3220
|
};
|
|
3134
3221
|
const headSha = await this.service.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
|
|
3135
3222
|
return {
|
|
@@ -3183,6 +3270,7 @@ var NoPatchesFlow = class {
|
|
|
3183
3270
|
patchesToApply: newPatches,
|
|
3184
3271
|
newPatchIds: new Set(newPatches.map((p) => p.id)),
|
|
3185
3272
|
detectorWarnings,
|
|
3273
|
+
detectorDegraded,
|
|
3186
3274
|
revertedCount: revertedPatchIds.length,
|
|
3187
3275
|
genSha: genRecord.commit_sha,
|
|
3188
3276
|
optionsSnapshot: options
|
|
@@ -3226,6 +3314,7 @@ var NoPatchesFlow = class {
|
|
|
3226
3314
|
}
|
|
3227
3315
|
}
|
|
3228
3316
|
const warnings = [...detectorWarnings, ...this.service.warnings];
|
|
3317
|
+
const degradedReasons = [...prep._prepared.detectorDegraded, ...this.service.degradedReasons];
|
|
3229
3318
|
return this.service.buildReport(
|
|
3230
3319
|
"no-patches",
|
|
3231
3320
|
newPatches,
|
|
@@ -3234,7 +3323,8 @@ var NoPatchesFlow = class {
|
|
|
3234
3323
|
warnings,
|
|
3235
3324
|
rebaseCounts,
|
|
3236
3325
|
void 0,
|
|
3237
|
-
prep._prepared.revertedCount
|
|
3326
|
+
prep._prepared.revertedCount,
|
|
3327
|
+
degradedReasons
|
|
3238
3328
|
);
|
|
3239
3329
|
}
|
|
3240
3330
|
};
|
|
@@ -3251,6 +3341,10 @@ var NormalRegenerationFlow = class {
|
|
|
3251
3341
|
const existingPatches2 = this.service.lockManager.getPatches();
|
|
3252
3342
|
const { patches: newPatches2, revertedPatchIds: dryRunReverted } = await this.service.detector.detectNewPatches();
|
|
3253
3343
|
const warnings = [...this.service.detector.warnings, ...this.service.warnings];
|
|
3344
|
+
const dryRunDegraded = dedupeDegradedReasons([
|
|
3345
|
+
...this.service.detector.degradedReasons,
|
|
3346
|
+
...this.service.degradedReasons
|
|
3347
|
+
]);
|
|
3254
3348
|
const allPatches2 = [...existingPatches2, ...newPatches2];
|
|
3255
3349
|
const preparedReport = {
|
|
3256
3350
|
flow: "normal-regeneration",
|
|
@@ -3261,7 +3355,9 @@ var NormalRegenerationFlow = class {
|
|
|
3261
3355
|
patchesReverted: dryRunReverted.length,
|
|
3262
3356
|
conflicts: [],
|
|
3263
3357
|
wouldApply: allPatches2,
|
|
3264
|
-
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
|
|
3265
3361
|
};
|
|
3266
3362
|
const headSha = await this.service.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
|
|
3267
3363
|
return {
|
|
@@ -3300,6 +3396,7 @@ var NormalRegenerationFlow = class {
|
|
|
3300
3396
|
existingPatches = this.service.lockManager.getPatches();
|
|
3301
3397
|
let { patches: newPatches, revertedPatchIds } = await this.service.detector.detectNewPatches();
|
|
3302
3398
|
const detectorWarnings = [...this.service.detector.warnings];
|
|
3399
|
+
const detectorDegraded = [...this.service.detector.degradedReasons];
|
|
3303
3400
|
if (removedByPreRebase.length > 0) {
|
|
3304
3401
|
const removedOriginalCommits = new Set(removedByPreRebase.map((p) => p.original_commit));
|
|
3305
3402
|
const removedOriginalMessages = new Set(removedByPreRebase.map((p) => p.original_message));
|
|
@@ -3340,6 +3437,7 @@ var NormalRegenerationFlow = class {
|
|
|
3340
3437
|
newPatchIds: new Set(newPatches.map((p) => p.id)),
|
|
3341
3438
|
preRebaseCounts,
|
|
3342
3439
|
detectorWarnings,
|
|
3440
|
+
detectorDegraded,
|
|
3343
3441
|
revertedCount: revertedPatchIds.length,
|
|
3344
3442
|
genSha: genRecord.commit_sha,
|
|
3345
3443
|
optionsSnapshot: options
|
|
@@ -3388,6 +3486,7 @@ var NormalRegenerationFlow = class {
|
|
|
3388
3486
|
await this.service.committer.commitReplay(appliedCount, allPatches, void 0, { buckets });
|
|
3389
3487
|
}
|
|
3390
3488
|
const warnings = [...detectorWarnings, ...this.service.warnings];
|
|
3489
|
+
const degradedReasons = [...prep._prepared.detectorDegraded, ...this.service.degradedReasons];
|
|
3391
3490
|
return this.service.buildReport(
|
|
3392
3491
|
"normal-regeneration",
|
|
3393
3492
|
allPatches,
|
|
@@ -3396,7 +3495,8 @@ var NormalRegenerationFlow = class {
|
|
|
3396
3495
|
warnings,
|
|
3397
3496
|
rebaseCounts,
|
|
3398
3497
|
prep._prepared.preRebaseCounts,
|
|
3399
|
-
prep._prepared.revertedCount
|
|
3498
|
+
prep._prepared.revertedCount,
|
|
3499
|
+
degradedReasons
|
|
3400
3500
|
);
|
|
3401
3501
|
}
|
|
3402
3502
|
};
|
|
@@ -3436,6 +3536,16 @@ var ReplayService = class {
|
|
|
3436
3536
|
* @internal Used by Flow implementations in `src/flows/`.
|
|
3437
3537
|
*/
|
|
3438
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 = [];
|
|
3439
3549
|
/**
|
|
3440
3550
|
* @internal Test-only accessor.
|
|
3441
3551
|
* Returns the ReplayApplicator instance used for the last run. Tests use this
|
|
@@ -3479,7 +3589,10 @@ var ReplayService = class {
|
|
|
3479
3589
|
async prepareReplay(options) {
|
|
3480
3590
|
this.warnings = [];
|
|
3481
3591
|
this.detector.warnings.length = 0;
|
|
3592
|
+
this.degradedReasons = [];
|
|
3593
|
+
this.detector.degradedReasons.length = 0;
|
|
3482
3594
|
this.cleanseLegacyFernignoreEntries();
|
|
3595
|
+
this.cleanseInfrastructureFileEntries();
|
|
3483
3596
|
return this.selectFlow(options).prepare(options);
|
|
3484
3597
|
}
|
|
3485
3598
|
/**
|
|
@@ -3533,6 +3646,50 @@ var ReplayService = class {
|
|
|
3533
3646
|
this.lockManager.save();
|
|
3534
3647
|
}
|
|
3535
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
|
+
}
|
|
3536
3693
|
/**
|
|
3537
3694
|
* Phase 2 of the two-phase replay flow. Applies patches, rebases them against
|
|
3538
3695
|
* the generation, saves the lockfile, and commits `[fern-replay]` (or stages
|
|
@@ -3788,7 +3945,7 @@ var ReplayService = class {
|
|
|
3788
3945
|
for (const file of files) {
|
|
3789
3946
|
if (isBinaryFile(file)) continue;
|
|
3790
3947
|
try {
|
|
3791
|
-
const content = await readFileAsync(
|
|
3948
|
+
const content = await readFileAsync(join7(this.outputDir, file), "utf-8");
|
|
3792
3949
|
snapshot[file] = content;
|
|
3793
3950
|
} catch {
|
|
3794
3951
|
}
|
|
@@ -4305,6 +4462,12 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
|
|
|
4305
4462
|
);
|
|
4306
4463
|
}
|
|
4307
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
|
+
}
|
|
4308
4471
|
}
|
|
4309
4472
|
/**
|
|
4310
4473
|
* After applyPatches(), strip conflict markers from conflicting files
|
|
@@ -4316,9 +4479,9 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
|
|
|
4316
4479
|
if (result.status !== "conflict" || !result.fileResults) continue;
|
|
4317
4480
|
for (const fileResult of result.fileResults) {
|
|
4318
4481
|
if (fileResult.status !== "conflict") continue;
|
|
4319
|
-
const filePath =
|
|
4482
|
+
const filePath = join7(this.outputDir, fileResult.file);
|
|
4320
4483
|
try {
|
|
4321
|
-
const content =
|
|
4484
|
+
const content = readFileSync3(filePath, "utf-8");
|
|
4322
4485
|
const stripped = stripConflictMarkers(content);
|
|
4323
4486
|
writeFileSync2(filePath, stripped);
|
|
4324
4487
|
} catch {
|
|
@@ -4348,12 +4511,11 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
|
|
|
4348
4511
|
}
|
|
4349
4512
|
/** @internal Used by Flow implementations in `src/flows/`. */
|
|
4350
4513
|
readFernignorePatterns() {
|
|
4351
|
-
|
|
4352
|
-
if (!existsSync2(fernignorePath)) return [];
|
|
4353
|
-
return readFileSync2(fernignorePath, "utf-8").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
|
|
4514
|
+
return readFernignorePatterns(this.outputDir);
|
|
4354
4515
|
}
|
|
4355
4516
|
/** @internal Used by Flow implementations in `src/flows/`. */
|
|
4356
|
-
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 ?? []);
|
|
4357
4519
|
const conflictResults = results.filter((r) => r.status === "conflict");
|
|
4358
4520
|
const conflictDetails = conflictResults.map((r) => {
|
|
4359
4521
|
const conflictFiles = r.fileResults?.filter((f) => f.status === "conflict") ?? [];
|
|
@@ -4393,10 +4555,23 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
|
|
|
4393
4555
|
conflictDetails: r.fileResults?.filter((f) => f.status === "conflict") ?? []
|
|
4394
4556
|
})) : void 0,
|
|
4395
4557
|
wouldApply: options?.dryRun ? patches : void 0,
|
|
4396
|
-
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
|
|
4397
4561
|
};
|
|
4398
4562
|
}
|
|
4399
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
|
+
}
|
|
4400
4575
|
function computeCommitBuckets(results, absorbedPatchIds, patchesPassedToCommit) {
|
|
4401
4576
|
const resultByPatchId = /* @__PURE__ */ new Map();
|
|
4402
4577
|
for (const r of results) resultByPatchId.set(r.patch.id, r);
|
|
@@ -4445,7 +4620,7 @@ function computeLegacyFernignoreCleanse(patch, fernignorePatterns, computeConten
|
|
|
4445
4620
|
}
|
|
4446
4621
|
}
|
|
4447
4622
|
}
|
|
4448
|
-
const { stripped: patchContentStripped, content: newPatchContent, strippedSectionPaths } =
|
|
4623
|
+
const { stripped: patchContentStripped, content: newPatchContent, strippedSectionPaths } = stripDiffSectionsByPredicate(patch.patch_content, matchesFernignore);
|
|
4449
4624
|
const unchanged = strippedFromFiles.length === 0 && !snapshotChanged && !patchContentStripped;
|
|
4450
4625
|
if (unchanged) {
|
|
4451
4626
|
return {
|
|
@@ -4480,7 +4655,38 @@ function computeLegacyFernignoreCleanse(patch, fernignorePatterns, computeConten
|
|
|
4480
4655
|
strippedPaths
|
|
4481
4656
|
};
|
|
4482
4657
|
}
|
|
4483
|
-
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) {
|
|
4484
4690
|
const lines = patchContent.split("\n");
|
|
4485
4691
|
const sectionStarts = [];
|
|
4486
4692
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -4502,7 +4708,7 @@ function stripFernignoreDiffSections(patchContent, matchesFernignore) {
|
|
|
4502
4708
|
const section = sectionStarts[i];
|
|
4503
4709
|
const next = sectionStarts[i + 1];
|
|
4504
4710
|
const endIdx = next ? next.idx : lines.length;
|
|
4505
|
-
if (section.path !== null &&
|
|
4711
|
+
if (section.path !== null && shouldStrip(section.path)) {
|
|
4506
4712
|
stripped = true;
|
|
4507
4713
|
strippedSectionPaths.push(section.path);
|
|
4508
4714
|
continue;
|
|
@@ -4514,8 +4720,8 @@ function stripFernignoreDiffSections(patchContent, matchesFernignore) {
|
|
|
4514
4720
|
|
|
4515
4721
|
// src/FernignoreMigrator.ts
|
|
4516
4722
|
import { createHash as createHash2 } from "crypto";
|
|
4517
|
-
import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as
|
|
4518
|
-
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";
|
|
4519
4725
|
import { minimatch as minimatch6 } from "minimatch";
|
|
4520
4726
|
import { parse as parse2, stringify as stringify2 } from "yaml";
|
|
4521
4727
|
var FernignoreMigrator = class {
|
|
@@ -4528,14 +4734,14 @@ var FernignoreMigrator = class {
|
|
|
4528
4734
|
this.outputDir = outputDir;
|
|
4529
4735
|
}
|
|
4530
4736
|
fernignoreExists() {
|
|
4531
|
-
return existsSync3(
|
|
4737
|
+
return existsSync3(join8(this.outputDir, ".fernignore"));
|
|
4532
4738
|
}
|
|
4533
4739
|
readFernignorePatterns() {
|
|
4534
|
-
const fernignorePath =
|
|
4740
|
+
const fernignorePath = join8(this.outputDir, ".fernignore");
|
|
4535
4741
|
if (!existsSync3(fernignorePath)) {
|
|
4536
4742
|
return [];
|
|
4537
4743
|
}
|
|
4538
|
-
const content =
|
|
4744
|
+
const content = readFileSync4(fernignorePath, "utf-8");
|
|
4539
4745
|
return content.split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
|
|
4540
4746
|
}
|
|
4541
4747
|
/** Analyze .fernignore patterns vs git history. Creates synthetic patches for files differing from pristine generation. */
|
|
@@ -4640,7 +4846,7 @@ var FernignoreMigrator = class {
|
|
|
4640
4846
|
return results;
|
|
4641
4847
|
}
|
|
4642
4848
|
readFileContent(filePath) {
|
|
4643
|
-
const fullPath =
|
|
4849
|
+
const fullPath = join8(this.outputDir, filePath);
|
|
4644
4850
|
if (!existsSync3(fullPath)) {
|
|
4645
4851
|
return null;
|
|
4646
4852
|
}
|
|
@@ -4649,7 +4855,7 @@ var FernignoreMigrator = class {
|
|
|
4649
4855
|
if (stat.isDirectory()) {
|
|
4650
4856
|
return null;
|
|
4651
4857
|
}
|
|
4652
|
-
return
|
|
4858
|
+
return readFileSync4(fullPath, "utf-8");
|
|
4653
4859
|
} catch {
|
|
4654
4860
|
return null;
|
|
4655
4861
|
}
|
|
@@ -4705,10 +4911,10 @@ var FernignoreMigrator = class {
|
|
|
4705
4911
|
};
|
|
4706
4912
|
}
|
|
4707
4913
|
movePatternsToReplayYml(patterns) {
|
|
4708
|
-
const replayYmlPath =
|
|
4914
|
+
const replayYmlPath = join8(this.outputDir, ".fern", "replay.yml");
|
|
4709
4915
|
let config = {};
|
|
4710
4916
|
if (existsSync3(replayYmlPath)) {
|
|
4711
|
-
const content =
|
|
4917
|
+
const content = readFileSync4(replayYmlPath, "utf-8");
|
|
4712
4918
|
config = parse2(content) ?? {};
|
|
4713
4919
|
}
|
|
4714
4920
|
const existing = config.exclude ?? [];
|
|
@@ -4724,8 +4930,8 @@ var FernignoreMigrator = class {
|
|
|
4724
4930
|
|
|
4725
4931
|
// src/commands/bootstrap.ts
|
|
4726
4932
|
import { createHash as createHash3 } from "crypto";
|
|
4727
|
-
import { existsSync as existsSync4, readFileSync as
|
|
4728
|
-
import { join as
|
|
4933
|
+
import { existsSync as existsSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
|
|
4934
|
+
import { join as join9 } from "path";
|
|
4729
4935
|
init_GitClient();
|
|
4730
4936
|
async function bootstrap(outputDir, options) {
|
|
4731
4937
|
const git = new GitClient(outputDir);
|
|
@@ -4892,10 +5098,10 @@ function parseGitLog(log) {
|
|
|
4892
5098
|
}
|
|
4893
5099
|
var REPLAY_FERNIGNORE_ENTRIES = [".fern/replay.lock", ".fern/replay.yml", ".gitattributes"];
|
|
4894
5100
|
function ensureFernignoreEntries(outputDir) {
|
|
4895
|
-
const fernignorePath =
|
|
5101
|
+
const fernignorePath = join9(outputDir, ".fernignore");
|
|
4896
5102
|
let content = "";
|
|
4897
5103
|
if (existsSync4(fernignorePath)) {
|
|
4898
|
-
content =
|
|
5104
|
+
content = readFileSync5(fernignorePath, "utf-8");
|
|
4899
5105
|
}
|
|
4900
5106
|
const lines = content.split("\n");
|
|
4901
5107
|
const toAdd = [];
|
|
@@ -4916,10 +5122,10 @@ function ensureFernignoreEntries(outputDir) {
|
|
|
4916
5122
|
}
|
|
4917
5123
|
var GITATTRIBUTES_ENTRIES = [".fern/replay.lock linguist-generated=true"];
|
|
4918
5124
|
function ensureGitattributesEntries(outputDir) {
|
|
4919
|
-
const gitattributesPath =
|
|
5125
|
+
const gitattributesPath = join9(outputDir, ".gitattributes");
|
|
4920
5126
|
let content = "";
|
|
4921
5127
|
if (existsSync4(gitattributesPath)) {
|
|
4922
|
-
content =
|
|
5128
|
+
content = readFileSync5(gitattributesPath, "utf-8");
|
|
4923
5129
|
}
|
|
4924
5130
|
const lines = content.split("\n");
|
|
4925
5131
|
const toAdd = [];
|
|
@@ -4946,6 +5152,7 @@ function computeContentHash(patchContent) {
|
|
|
4946
5152
|
}
|
|
4947
5153
|
|
|
4948
5154
|
// src/commands/forget.ts
|
|
5155
|
+
init_GitClient();
|
|
4949
5156
|
import { minimatch as minimatch7 } from "minimatch";
|
|
4950
5157
|
function parseDiffStat(patchContent) {
|
|
4951
5158
|
let additions = 0;
|
|
@@ -5005,7 +5212,28 @@ var EMPTY_RESULT = {
|
|
|
5005
5212
|
totalPatches: 0,
|
|
5006
5213
|
warnings: []
|
|
5007
5214
|
};
|
|
5008
|
-
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) {
|
|
5009
5237
|
const lockManager = new LockfileManager(outputDir);
|
|
5010
5238
|
if (!lockManager.exists()) {
|
|
5011
5239
|
return { ...EMPTY_RESULT };
|
|
@@ -5016,9 +5244,7 @@ function forget(outputDir, options) {
|
|
|
5016
5244
|
const removed = lock.patches.map(toMatchedPatch);
|
|
5017
5245
|
const warnings = buildWarnings(lock.patches);
|
|
5018
5246
|
if (!options.dryRun) {
|
|
5019
|
-
|
|
5020
|
-
lockManager.addForgottenHash(patch.content_hash);
|
|
5021
|
-
}
|
|
5247
|
+
await dismissPatches(outputDir, lockManager, lock.patches);
|
|
5022
5248
|
lockManager.clearPatches();
|
|
5023
5249
|
lockManager.save();
|
|
5024
5250
|
}
|
|
@@ -5047,8 +5273,8 @@ function forget(outputDir, options) {
|
|
|
5047
5273
|
}
|
|
5048
5274
|
const warnings = buildWarnings(patchesToRemove);
|
|
5049
5275
|
if (!options.dryRun) {
|
|
5276
|
+
await dismissPatches(outputDir, lockManager, patchesToRemove);
|
|
5050
5277
|
for (const patch of patchesToRemove) {
|
|
5051
|
-
lockManager.addForgottenHash(patch.content_hash);
|
|
5052
5278
|
lockManager.removePatch(patch.id);
|
|
5053
5279
|
}
|
|
5054
5280
|
lockManager.save();
|
|
@@ -5116,7 +5342,7 @@ function reset(outputDir, options) {
|
|
|
5116
5342
|
// src/commands/resolve.ts
|
|
5117
5343
|
init_GitClient();
|
|
5118
5344
|
import { readFile as readFile4 } from "fs/promises";
|
|
5119
|
-
import { join as
|
|
5345
|
+
import { join as join10 } from "path";
|
|
5120
5346
|
async function resolve(outputDir, options) {
|
|
5121
5347
|
const lockManager = new LockfileManager(outputDir);
|
|
5122
5348
|
if (!lockManager.exists()) {
|
|
@@ -5156,15 +5382,16 @@ async function resolve(outputDir, options) {
|
|
|
5156
5382
|
const patchesToCommit = [...resolvingPatches, ...unresolvedPatches];
|
|
5157
5383
|
if (patchesToCommit.length > 0) {
|
|
5158
5384
|
const currentGen = lock.current_generation;
|
|
5159
|
-
const detector = new ReplayDetector(git, lockManager, outputDir);
|
|
5385
|
+
const detector = new ReplayDetector(git, lockManager, outputDir, readFernignorePatterns(outputDir));
|
|
5160
5386
|
const warnings = [];
|
|
5387
|
+
const patchesDismissed = [];
|
|
5161
5388
|
let patchesResolved = 0;
|
|
5162
5389
|
const pass1 = [];
|
|
5163
5390
|
for (const patch of patchesToCommit) {
|
|
5164
5391
|
const result = await computePerPatchDiffWithFallback(
|
|
5165
5392
|
patch,
|
|
5166
5393
|
(f) => git.showFile(currentGen, f),
|
|
5167
|
-
(f) => readFile4(
|
|
5394
|
+
(f) => readFile4(join10(outputDir, f), "utf-8").catch(() => null),
|
|
5168
5395
|
(files) => git.exec(["diff", currentGen, "--", ...files]).catch(() => null)
|
|
5169
5396
|
);
|
|
5170
5397
|
if (result === null) {
|
|
@@ -5198,7 +5425,27 @@ async function resolve(outputDir, options) {
|
|
|
5198
5425
|
const r = pass1[i];
|
|
5199
5426
|
if (r.kind === "skip") continue;
|
|
5200
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
|
+
});
|
|
5201
5447
|
lockManager.removePatch(patch.id);
|
|
5448
|
+
patchesDismissed.push({ id: patch.id, message: patch.original_message });
|
|
5202
5449
|
continue;
|
|
5203
5450
|
}
|
|
5204
5451
|
if (lastIndexByHash.get(r.hash) !== i) {
|
|
@@ -5220,7 +5467,7 @@ async function resolve(outputDir, options) {
|
|
|
5220
5467
|
const theirsSnapshot = {};
|
|
5221
5468
|
for (const file of filesForSnapshot) {
|
|
5222
5469
|
if (isBinaryFile(file)) continue;
|
|
5223
|
-
const content = await readFile4(
|
|
5470
|
+
const content = await readFile4(join10(outputDir, file), "utf-8").catch(() => null);
|
|
5224
5471
|
if (content != null) {
|
|
5225
5472
|
theirsSnapshot[file] = content;
|
|
5226
5473
|
}
|
|
@@ -5247,6 +5494,7 @@ async function resolve(outputDir, options) {
|
|
|
5247
5494
|
commitSha: commitSha2,
|
|
5248
5495
|
phase: "committed",
|
|
5249
5496
|
patchesResolved,
|
|
5497
|
+
patchesDismissed: patchesDismissed.length > 0 ? patchesDismissed : void 0,
|
|
5250
5498
|
warnings: warnings.length > 0 ? warnings : void 0
|
|
5251
5499
|
};
|
|
5252
5500
|
}
|
|
@@ -5294,7 +5542,9 @@ function status(outputDir) {
|
|
|
5294
5542
|
lastGeneration: void 0,
|
|
5295
5543
|
patches: [],
|
|
5296
5544
|
unresolvedCount: 0,
|
|
5297
|
-
excludePatterns: []
|
|
5545
|
+
excludePatterns: [],
|
|
5546
|
+
dismissed: [],
|
|
5547
|
+
legacyDismissedCount: 0
|
|
5298
5548
|
};
|
|
5299
5549
|
}
|
|
5300
5550
|
const lock = lockManager.read();
|
|
@@ -5323,13 +5573,24 @@ function status(outputDir) {
|
|
|
5323
5573
|
}
|
|
5324
5574
|
const config = lockManager.getCustomizationsConfig();
|
|
5325
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;
|
|
5326
5585
|
return {
|
|
5327
5586
|
initialized: true,
|
|
5328
5587
|
generationCount: lock.generations.length,
|
|
5329
5588
|
lastGeneration,
|
|
5330
5589
|
patches,
|
|
5331
5590
|
unresolvedCount,
|
|
5332
|
-
excludePatterns
|
|
5591
|
+
excludePatterns,
|
|
5592
|
+
dismissed,
|
|
5593
|
+
legacyDismissedCount
|
|
5333
5594
|
};
|
|
5334
5595
|
}
|
|
5335
5596
|
export {
|