@fern-api/replay 0.18.0 → 0.19.1
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 +522 -125
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +467 -116
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +208 -3
- package/dist/index.d.ts +208 -3
- package/dist/index.js +450 -99
- 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
|
}
|
|
@@ -1955,6 +2027,51 @@ async function buildMergePlan(args) {
|
|
|
1955
2027
|
// src/applicator/runMergeAndRecord.ts
|
|
1956
2028
|
import { mkdir, writeFile } from "fs/promises";
|
|
1957
2029
|
import { dirname as dirname2 } from "path";
|
|
2030
|
+
|
|
2031
|
+
// src/autoversion.ts
|
|
2032
|
+
var AUTOVERSION_PLACEHOLDERS = [
|
|
2033
|
+
"v0.0.0-fern-placeholder",
|
|
2034
|
+
// Go -- a superstring of the default; check first
|
|
2035
|
+
"0.0.0-fern-placeholder",
|
|
2036
|
+
// TS/Java/C#/Ruby/PHP/Rust/default
|
|
2037
|
+
"0.0.0.dev0"
|
|
2038
|
+
// Python (PEP 440 -- Poetry rejects hyphens)
|
|
2039
|
+
];
|
|
2040
|
+
var VERSION_TOKEN = /(?:v)?\d+\.\d+\.\d+[0-9A-Za-z.+-]*/g;
|
|
2041
|
+
var VERSION_MASK = "@@FERN_VERSION@@";
|
|
2042
|
+
function maskVersions(line) {
|
|
2043
|
+
return line.replace(VERSION_TOKEN, VERSION_MASK);
|
|
2044
|
+
}
|
|
2045
|
+
function containsPlaceholder(content) {
|
|
2046
|
+
if (!content) return false;
|
|
2047
|
+
return AUTOVERSION_PLACEHOLDERS.some((p) => content.includes(p));
|
|
2048
|
+
}
|
|
2049
|
+
function remapManagedLines(genBase, target, theirs) {
|
|
2050
|
+
if (!containsPlaceholder(genBase)) return theirs;
|
|
2051
|
+
const managedMasks = /* @__PURE__ */ new Set();
|
|
2052
|
+
for (const line of genBase.split("\n")) {
|
|
2053
|
+
if (AUTOVERSION_PLACEHOLDERS.some((p) => line.includes(p))) managedMasks.add(maskVersions(line));
|
|
2054
|
+
}
|
|
2055
|
+
if (managedMasks.size === 0) return theirs;
|
|
2056
|
+
const targetByMask = /* @__PURE__ */ new Map();
|
|
2057
|
+
for (const line of target.split("\n")) {
|
|
2058
|
+
const mask = maskVersions(line);
|
|
2059
|
+
if (managedMasks.has(mask) && !targetByMask.has(mask)) targetByMask.set(mask, line);
|
|
2060
|
+
}
|
|
2061
|
+
return theirs.split("\n").map((line) => {
|
|
2062
|
+
const mask = maskVersions(line);
|
|
2063
|
+
const replacement = managedMasks.has(mask) ? targetByMask.get(mask) : void 0;
|
|
2064
|
+
return replacement !== void 0 && replacement !== line ? replacement : line;
|
|
2065
|
+
}).join("\n");
|
|
2066
|
+
}
|
|
2067
|
+
function neutralizeAutoversionTheirs(genBase, theirs) {
|
|
2068
|
+
return remapManagedLines(genBase, genBase, theirs);
|
|
2069
|
+
}
|
|
2070
|
+
function alignAutoversionToOurs(genBase, ours, theirs) {
|
|
2071
|
+
return remapManagedLines(genBase, ours, theirs);
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
// src/applicator/runMergeAndRecord.ts
|
|
1958
2075
|
async function runMergeAndRecord(args) {
|
|
1959
2076
|
const { plan, inputs, patch, accumulator } = args;
|
|
1960
2077
|
const { resolvedPath, metadata, oursPath, ours } = inputs;
|
|
@@ -1985,11 +2102,13 @@ async function runMergeAndRecord(args) {
|
|
|
1985
2102
|
accumulator.recordMerge(resolvedPath, merged2.content, patch.base_generation);
|
|
1986
2103
|
return { file: resolvedPath, status: "merged", ...unreachableFlag };
|
|
1987
2104
|
}
|
|
1988
|
-
const
|
|
2105
|
+
const mergeBase = inputs.base != null ? alignAutoversionToOurs(inputs.base, ours, plan.mergeBase) : plan.mergeBase;
|
|
2106
|
+
const theirs = inputs.base != null ? alignAutoversionToOurs(inputs.base, ours, plan.theirs) : plan.theirs;
|
|
2107
|
+
const merged = threeWayMerge(mergeBase, ours, theirs);
|
|
1989
2108
|
await mkdir(dirname2(oursPath), { recursive: true });
|
|
1990
2109
|
await writeFile(oursPath, merged.content);
|
|
1991
2110
|
if (merged.hasConflicts) {
|
|
1992
|
-
accumulator.recordConflict(resolvedPath,
|
|
2111
|
+
accumulator.recordConflict(resolvedPath, theirs, patch.base_generation);
|
|
1993
2112
|
return {
|
|
1994
2113
|
file: resolvedPath,
|
|
1995
2114
|
status: "conflict",
|
|
@@ -1998,7 +2117,7 @@ async function runMergeAndRecord(args) {
|
|
|
1998
2117
|
conflictMetadata: metadata
|
|
1999
2118
|
};
|
|
2000
2119
|
}
|
|
2001
|
-
accumulator.recordMerge(resolvedPath,
|
|
2120
|
+
accumulator.recordMerge(resolvedPath, theirs, patch.base_generation);
|
|
2002
2121
|
return {
|
|
2003
2122
|
file: resolvedPath,
|
|
2004
2123
|
status: "merged",
|
|
@@ -2616,17 +2735,26 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
|
|
|
2616
2735
|
};
|
|
2617
2736
|
|
|
2618
2737
|
// src/ReplayService.ts
|
|
2619
|
-
import {
|
|
2738
|
+
import { readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
|
|
2620
2739
|
import { readFile as readFileAsync } from "fs/promises";
|
|
2621
|
-
import { join as
|
|
2740
|
+
import { join as join7 } from "path";
|
|
2622
2741
|
import { minimatch as minimatch5 } from "minimatch";
|
|
2623
2742
|
init_GitClient();
|
|
2624
2743
|
|
|
2744
|
+
// src/shared/fernignore.ts
|
|
2745
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
|
2746
|
+
import { join as join4 } from "path";
|
|
2747
|
+
function readFernignorePatterns(outputDir) {
|
|
2748
|
+
const fernignorePath = join4(outputDir, ".fernignore");
|
|
2749
|
+
if (!existsSync2(fernignorePath)) return [];
|
|
2750
|
+
return readFileSync2(fernignorePath, "utf-8").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2625
2753
|
// src/PatchRegionDiff.ts
|
|
2626
2754
|
init_HybridReconstruction();
|
|
2627
2755
|
import { mkdtemp as mkdtemp2, writeFile as writeFile3, rm as rm2 } from "fs/promises";
|
|
2628
2756
|
import { tmpdir as tmpdir2 } from "os";
|
|
2629
|
-
import { join as
|
|
2757
|
+
import { join as join5 } from "path";
|
|
2630
2758
|
import { spawn } from "child_process";
|
|
2631
2759
|
function normalizeHunkBody(hunk) {
|
|
2632
2760
|
let contextC = 0;
|
|
@@ -2843,10 +2971,10 @@ function overlayRegions(currentGenLines, locInCurrentGen, workingTreeLines, locI
|
|
|
2843
2971
|
}
|
|
2844
2972
|
async function unifiedDiff(beforeContent, afterContent, filePath) {
|
|
2845
2973
|
if (beforeContent === afterContent) return "";
|
|
2846
|
-
const tmp = await mkdtemp2(
|
|
2974
|
+
const tmp = await mkdtemp2(join5(tmpdir2(), "fern-replay-pp-"));
|
|
2847
2975
|
try {
|
|
2848
|
-
const beforePath =
|
|
2849
|
-
const afterPath =
|
|
2976
|
+
const beforePath = join5(tmp, "before");
|
|
2977
|
+
const afterPath = join5(tmp, "after");
|
|
2850
2978
|
await writeFile3(beforePath, beforeContent, "utf-8");
|
|
2851
2979
|
await writeFile3(afterPath, afterContent, "utf-8");
|
|
2852
2980
|
const raw = await runGitDiffNoIndex(beforePath, afterPath);
|
|
@@ -3022,6 +3150,7 @@ var FirstGenerationFlow = class {
|
|
|
3022
3150
|
patchesToApply: [],
|
|
3023
3151
|
newPatchIds: /* @__PURE__ */ new Set(),
|
|
3024
3152
|
detectorWarnings: [],
|
|
3153
|
+
detectorDegraded: [],
|
|
3025
3154
|
revertedCount: 0,
|
|
3026
3155
|
genSha: genRecord.commit_sha,
|
|
3027
3156
|
optionsSnapshot: options
|
|
@@ -3081,6 +3210,7 @@ var SkipApplicationFlow = class {
|
|
|
3081
3210
|
patchesToApply: [],
|
|
3082
3211
|
newPatchIds: /* @__PURE__ */ new Set(),
|
|
3083
3212
|
detectorWarnings: [],
|
|
3213
|
+
detectorDegraded: [],
|
|
3084
3214
|
revertedCount: 0,
|
|
3085
3215
|
genSha: genRecord.commit_sha,
|
|
3086
3216
|
optionsSnapshot: options
|
|
@@ -3118,8 +3248,10 @@ var NoPatchesFlow = class {
|
|
|
3118
3248
|
const previousGenerationSha = preLock.current_generation ?? null;
|
|
3119
3249
|
let { patches: newPatches, revertedPatchIds } = await this.service.detector.detectNewPatches();
|
|
3120
3250
|
const detectorWarnings = [...this.service.detector.warnings];
|
|
3251
|
+
const detectorDegraded = [...this.service.detector.degradedReasons];
|
|
3121
3252
|
if (options?.dryRun) {
|
|
3122
3253
|
const dryRunWarnings = [...detectorWarnings, ...this.service.warnings];
|
|
3254
|
+
const dryRunDegraded = dedupeDegradedReasons([...detectorDegraded, ...this.service.degradedReasons]);
|
|
3123
3255
|
const preparedReport = {
|
|
3124
3256
|
flow: "no-patches",
|
|
3125
3257
|
patchesDetected: newPatches.length,
|
|
@@ -3129,7 +3261,9 @@ var NoPatchesFlow = class {
|
|
|
3129
3261
|
patchesReverted: revertedPatchIds.length,
|
|
3130
3262
|
conflicts: [],
|
|
3131
3263
|
wouldApply: newPatches,
|
|
3132
|
-
warnings: dryRunWarnings.length > 0 ? dryRunWarnings : void 0
|
|
3264
|
+
warnings: dryRunWarnings.length > 0 ? dryRunWarnings : void 0,
|
|
3265
|
+
degraded: dryRunDegraded.length > 0 ? true : void 0,
|
|
3266
|
+
degradedReasons: dryRunDegraded.length > 0 ? dryRunDegraded : void 0
|
|
3133
3267
|
};
|
|
3134
3268
|
const headSha = await this.service.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
|
|
3135
3269
|
return {
|
|
@@ -3183,6 +3317,7 @@ var NoPatchesFlow = class {
|
|
|
3183
3317
|
patchesToApply: newPatches,
|
|
3184
3318
|
newPatchIds: new Set(newPatches.map((p) => p.id)),
|
|
3185
3319
|
detectorWarnings,
|
|
3320
|
+
detectorDegraded,
|
|
3186
3321
|
revertedCount: revertedPatchIds.length,
|
|
3187
3322
|
genSha: genRecord.commit_sha,
|
|
3188
3323
|
optionsSnapshot: options
|
|
@@ -3226,6 +3361,7 @@ var NoPatchesFlow = class {
|
|
|
3226
3361
|
}
|
|
3227
3362
|
}
|
|
3228
3363
|
const warnings = [...detectorWarnings, ...this.service.warnings];
|
|
3364
|
+
const degradedReasons = [...prep._prepared.detectorDegraded, ...this.service.degradedReasons];
|
|
3229
3365
|
return this.service.buildReport(
|
|
3230
3366
|
"no-patches",
|
|
3231
3367
|
newPatches,
|
|
@@ -3234,7 +3370,8 @@ var NoPatchesFlow = class {
|
|
|
3234
3370
|
warnings,
|
|
3235
3371
|
rebaseCounts,
|
|
3236
3372
|
void 0,
|
|
3237
|
-
prep._prepared.revertedCount
|
|
3373
|
+
prep._prepared.revertedCount,
|
|
3374
|
+
degradedReasons
|
|
3238
3375
|
);
|
|
3239
3376
|
}
|
|
3240
3377
|
};
|
|
@@ -3251,6 +3388,10 @@ var NormalRegenerationFlow = class {
|
|
|
3251
3388
|
const existingPatches2 = this.service.lockManager.getPatches();
|
|
3252
3389
|
const { patches: newPatches2, revertedPatchIds: dryRunReverted } = await this.service.detector.detectNewPatches();
|
|
3253
3390
|
const warnings = [...this.service.detector.warnings, ...this.service.warnings];
|
|
3391
|
+
const dryRunDegraded = dedupeDegradedReasons([
|
|
3392
|
+
...this.service.detector.degradedReasons,
|
|
3393
|
+
...this.service.degradedReasons
|
|
3394
|
+
]);
|
|
3254
3395
|
const allPatches2 = [...existingPatches2, ...newPatches2];
|
|
3255
3396
|
const preparedReport = {
|
|
3256
3397
|
flow: "normal-regeneration",
|
|
@@ -3261,7 +3402,9 @@ var NormalRegenerationFlow = class {
|
|
|
3261
3402
|
patchesReverted: dryRunReverted.length,
|
|
3262
3403
|
conflicts: [],
|
|
3263
3404
|
wouldApply: allPatches2,
|
|
3264
|
-
warnings: warnings.length > 0 ? warnings : void 0
|
|
3405
|
+
warnings: warnings.length > 0 ? warnings : void 0,
|
|
3406
|
+
degraded: dryRunDegraded.length > 0 ? true : void 0,
|
|
3407
|
+
degradedReasons: dryRunDegraded.length > 0 ? dryRunDegraded : void 0
|
|
3265
3408
|
};
|
|
3266
3409
|
const headSha = await this.service.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
|
|
3267
3410
|
return {
|
|
@@ -3300,6 +3443,7 @@ var NormalRegenerationFlow = class {
|
|
|
3300
3443
|
existingPatches = this.service.lockManager.getPatches();
|
|
3301
3444
|
let { patches: newPatches, revertedPatchIds } = await this.service.detector.detectNewPatches();
|
|
3302
3445
|
const detectorWarnings = [...this.service.detector.warnings];
|
|
3446
|
+
const detectorDegraded = [...this.service.detector.degradedReasons];
|
|
3303
3447
|
if (removedByPreRebase.length > 0) {
|
|
3304
3448
|
const removedOriginalCommits = new Set(removedByPreRebase.map((p) => p.original_commit));
|
|
3305
3449
|
const removedOriginalMessages = new Set(removedByPreRebase.map((p) => p.original_message));
|
|
@@ -3340,6 +3484,7 @@ var NormalRegenerationFlow = class {
|
|
|
3340
3484
|
newPatchIds: new Set(newPatches.map((p) => p.id)),
|
|
3341
3485
|
preRebaseCounts,
|
|
3342
3486
|
detectorWarnings,
|
|
3487
|
+
detectorDegraded,
|
|
3343
3488
|
revertedCount: revertedPatchIds.length,
|
|
3344
3489
|
genSha: genRecord.commit_sha,
|
|
3345
3490
|
optionsSnapshot: options
|
|
@@ -3388,6 +3533,7 @@ var NormalRegenerationFlow = class {
|
|
|
3388
3533
|
await this.service.committer.commitReplay(appliedCount, allPatches, void 0, { buckets });
|
|
3389
3534
|
}
|
|
3390
3535
|
const warnings = [...detectorWarnings, ...this.service.warnings];
|
|
3536
|
+
const degradedReasons = [...prep._prepared.detectorDegraded, ...this.service.degradedReasons];
|
|
3391
3537
|
return this.service.buildReport(
|
|
3392
3538
|
"normal-regeneration",
|
|
3393
3539
|
allPatches,
|
|
@@ -3396,7 +3542,8 @@ var NormalRegenerationFlow = class {
|
|
|
3396
3542
|
warnings,
|
|
3397
3543
|
rebaseCounts,
|
|
3398
3544
|
prep._prepared.preRebaseCounts,
|
|
3399
|
-
prep._prepared.revertedCount
|
|
3545
|
+
prep._prepared.revertedCount,
|
|
3546
|
+
degradedReasons
|
|
3400
3547
|
);
|
|
3401
3548
|
}
|
|
3402
3549
|
};
|
|
@@ -3436,6 +3583,16 @@ var ReplayService = class {
|
|
|
3436
3583
|
* @internal Used by Flow implementations in `src/flows/`.
|
|
3437
3584
|
*/
|
|
3438
3585
|
warnings = [];
|
|
3586
|
+
/**
|
|
3587
|
+
* Service-level structured degraded reasons, mirroring `warnings`.
|
|
3588
|
+
* Populated at apply time (e.g. `recordUnreachableBaseWarnings` when a
|
|
3589
|
+
* patch's base-generation tree is unreachable). Merged with
|
|
3590
|
+
* `detector.degradedReasons` into `ReplayReport.degradedReasons` by each
|
|
3591
|
+
* flow handler. Cleared at the top of `prepareReplay()`.
|
|
3592
|
+
*
|
|
3593
|
+
* @internal Used by Flow implementations in `src/flows/`.
|
|
3594
|
+
*/
|
|
3595
|
+
degradedReasons = [];
|
|
3439
3596
|
/**
|
|
3440
3597
|
* @internal Test-only accessor.
|
|
3441
3598
|
* Returns the ReplayApplicator instance used for the last run. Tests use this
|
|
@@ -3479,7 +3636,10 @@ var ReplayService = class {
|
|
|
3479
3636
|
async prepareReplay(options) {
|
|
3480
3637
|
this.warnings = [];
|
|
3481
3638
|
this.detector.warnings.length = 0;
|
|
3639
|
+
this.degradedReasons = [];
|
|
3640
|
+
this.detector.degradedReasons.length = 0;
|
|
3482
3641
|
this.cleanseLegacyFernignoreEntries();
|
|
3642
|
+
this.cleanseInfrastructureFileEntries();
|
|
3483
3643
|
return this.selectFlow(options).prepare(options);
|
|
3484
3644
|
}
|
|
3485
3645
|
/**
|
|
@@ -3533,6 +3693,50 @@ var ReplayService = class {
|
|
|
3533
3693
|
this.lockManager.save();
|
|
3534
3694
|
}
|
|
3535
3695
|
}
|
|
3696
|
+
/**
|
|
3697
|
+
* Migration step: strips infrastructure file diffs (`.fernignore`, `.fern/*`)
|
|
3698
|
+
* from existing patches' `patch_content`. These diffs were bundled by older
|
|
3699
|
+
* detection logic that did not scope `git diff` when only infrastructure files
|
|
3700
|
+
* were stripped. Patches that end up with no surviving diff sections are
|
|
3701
|
+
* removed entirely.
|
|
3702
|
+
*
|
|
3703
|
+
* Idempotent: a second run is a no-op because the first run already removed
|
|
3704
|
+
* all infrastructure sections. Per-patch try/catch ensures isolation.
|
|
3705
|
+
*/
|
|
3706
|
+
cleanseInfrastructureFileEntries() {
|
|
3707
|
+
if (!this.lockManager.exists()) return;
|
|
3708
|
+
this.lockManager.read();
|
|
3709
|
+
let lockfileMutated = false;
|
|
3710
|
+
const patches = this.lockManager.getPatches();
|
|
3711
|
+
for (const patch of patches) {
|
|
3712
|
+
try {
|
|
3713
|
+
const result = computeInfrastructureFileCleanse(
|
|
3714
|
+
patch,
|
|
3715
|
+
(content) => this.detector.computeContentHash(content)
|
|
3716
|
+
);
|
|
3717
|
+
if (result.unchanged) continue;
|
|
3718
|
+
if (result.remove) {
|
|
3719
|
+
this.lockManager.removePatch(patch.id);
|
|
3720
|
+
this.warnings.push(
|
|
3721
|
+
`Stripped infrastructure file diffs from patch ${patch.id}: patch contained only infrastructure file changes (${result.strippedPaths.join(", ")}) \u2014 removed.`
|
|
3722
|
+
);
|
|
3723
|
+
} else {
|
|
3724
|
+
this.lockManager.updatePatch(patch.id, {
|
|
3725
|
+
patch_content: result.patch_content,
|
|
3726
|
+
content_hash: result.content_hash
|
|
3727
|
+
});
|
|
3728
|
+
this.warnings.push(
|
|
3729
|
+
`Stripped infrastructure file diffs from patch ${patch.id}: removed bundled diffs for ${result.strippedPaths.join(", ")}.`
|
|
3730
|
+
);
|
|
3731
|
+
}
|
|
3732
|
+
lockfileMutated = true;
|
|
3733
|
+
} catch {
|
|
3734
|
+
}
|
|
3735
|
+
}
|
|
3736
|
+
if (lockfileMutated) {
|
|
3737
|
+
this.lockManager.save();
|
|
3738
|
+
}
|
|
3739
|
+
}
|
|
3536
3740
|
/**
|
|
3537
3741
|
* Phase 2 of the two-phase replay flow. Applies patches, rebases them against
|
|
3538
3742
|
* the generation, saves the lockfile, and commits `[fern-replay]` (or stages
|
|
@@ -3699,7 +3903,9 @@ var ReplayService = class {
|
|
|
3699
3903
|
if (baseChanged) repointed++;
|
|
3700
3904
|
continue;
|
|
3701
3905
|
}
|
|
3702
|
-
const
|
|
3906
|
+
const rawDiff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
|
|
3907
|
+
const neutralizedRebase = rawDiff != null && containsPlaceholder(rawDiff) ? await this.computeAutoversionNeutralizedRebase(patch.files, currentGenSha) : null;
|
|
3908
|
+
const diff = neutralizedRebase ? neutralizedRebase.diff : rawDiff;
|
|
3703
3909
|
if (diff === null) continue;
|
|
3704
3910
|
if (!diff.trim()) {
|
|
3705
3911
|
if (hasProtectedFiles) {
|
|
@@ -3755,7 +3961,7 @@ var ReplayService = class {
|
|
|
3755
3961
|
patch.base_generation = currentGenSha;
|
|
3756
3962
|
patch.patch_content = diff;
|
|
3757
3963
|
patch.content_hash = newContentHash;
|
|
3758
|
-
const snapshot = await this.readSnapshotFromDisk(patch.files);
|
|
3964
|
+
const snapshot = neutralizedRebase ? neutralizedRebase.snapshot : await this.readSnapshotFromDisk(patch.files);
|
|
3759
3965
|
const snapshotIsNonEmpty = Object.keys(snapshot).length > 0;
|
|
3760
3966
|
if (snapshotIsNonEmpty) {
|
|
3761
3967
|
patch.theirs_snapshot = snapshot;
|
|
@@ -3788,13 +3994,54 @@ var ReplayService = class {
|
|
|
3788
3994
|
for (const file of files) {
|
|
3789
3995
|
if (isBinaryFile(file)) continue;
|
|
3790
3996
|
try {
|
|
3791
|
-
const content = await readFileAsync(
|
|
3997
|
+
const content = await readFileAsync(join7(this.outputDir, file), "utf-8");
|
|
3792
3998
|
snapshot[file] = content;
|
|
3793
3999
|
} catch {
|
|
3794
4000
|
}
|
|
3795
4001
|
}
|
|
3796
4002
|
return snapshot;
|
|
3797
4003
|
}
|
|
4004
|
+
/**
|
|
4005
|
+
* Autoversion-aware replacement for the naive `git diff currentGen -- files`
|
|
4006
|
+
* + on-disk snapshot used when rebasing a cleanly-applied patch.
|
|
4007
|
+
*
|
|
4008
|
+
* The generation tree (BASE) still carries the placeholder on version lines
|
|
4009
|
+
* (autoversion runs after `[fern-generated]`), but the working tree holds
|
|
4010
|
+
* autoversion's resolved semver. A raw diff/snapshot would fold the
|
|
4011
|
+
* placeholder->semver swap into the customer's patch_content/theirs_snapshot,
|
|
4012
|
+
* pinning a stale version. This re-derives both against a version-neutralized
|
|
4013
|
+
* THEIRS so the pipeline-owned version line never enters the patch.
|
|
4014
|
+
*
|
|
4015
|
+
* Returns null when no file is under autoversion (fixed `--version`, i.e. no
|
|
4016
|
+
* placeholder in the generation tree) — callers keep their existing behavior.
|
|
4017
|
+
*/
|
|
4018
|
+
async computeAutoversionNeutralizedRebase(files, currentGenSha) {
|
|
4019
|
+
const genByFile = /* @__PURE__ */ new Map();
|
|
4020
|
+
let anyPlaceholder = false;
|
|
4021
|
+
for (const file of files) {
|
|
4022
|
+
const gen = await this.git.showFile(currentGenSha, file).catch(() => null);
|
|
4023
|
+
genByFile.set(file, gen);
|
|
4024
|
+
if (containsPlaceholder(gen)) anyPlaceholder = true;
|
|
4025
|
+
}
|
|
4026
|
+
if (!anyPlaceholder) return null;
|
|
4027
|
+
const snapshot = {};
|
|
4028
|
+
const fragments = [];
|
|
4029
|
+
for (const file of files) {
|
|
4030
|
+
if (isBinaryFile(file)) continue;
|
|
4031
|
+
const disk = await readFileAsync(join7(this.outputDir, file), "utf-8").catch(() => null);
|
|
4032
|
+
if (disk == null) continue;
|
|
4033
|
+
const gen = genByFile.get(file) ?? null;
|
|
4034
|
+
const neutralized = gen != null ? neutralizeAutoversionTheirs(gen, disk) : disk;
|
|
4035
|
+
snapshot[file] = neutralized;
|
|
4036
|
+
if (gen == null) {
|
|
4037
|
+
fragments.push(synthesizeNewFileDiff(file, neutralized));
|
|
4038
|
+
} else if (gen !== neutralized) {
|
|
4039
|
+
const fileDiff = await unifiedDiff(gen, neutralized, file);
|
|
4040
|
+
if (fileDiff.trim()) fragments.push(fileDiff);
|
|
4041
|
+
}
|
|
4042
|
+
}
|
|
4043
|
+
return { diff: fragments.join(""), snapshot };
|
|
4044
|
+
}
|
|
3798
4045
|
/**
|
|
3799
4046
|
* Detects autoversion contamination — returns true when a
|
|
3800
4047
|
* `[fern-autoversion]` commit between `fromSha` and `toSha` touched any
|
|
@@ -4305,6 +4552,12 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
|
|
|
4305
4552
|
);
|
|
4306
4553
|
}
|
|
4307
4554
|
}
|
|
4555
|
+
if (warned.size > 0) {
|
|
4556
|
+
this.degradedReasons.push({
|
|
4557
|
+
code: "patch-base-unreachable",
|
|
4558
|
+
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.`
|
|
4559
|
+
});
|
|
4560
|
+
}
|
|
4308
4561
|
}
|
|
4309
4562
|
/**
|
|
4310
4563
|
* After applyPatches(), strip conflict markers from conflicting files
|
|
@@ -4316,9 +4569,9 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
|
|
|
4316
4569
|
if (result.status !== "conflict" || !result.fileResults) continue;
|
|
4317
4570
|
for (const fileResult of result.fileResults) {
|
|
4318
4571
|
if (fileResult.status !== "conflict") continue;
|
|
4319
|
-
const filePath =
|
|
4572
|
+
const filePath = join7(this.outputDir, fileResult.file);
|
|
4320
4573
|
try {
|
|
4321
|
-
const content =
|
|
4574
|
+
const content = readFileSync3(filePath, "utf-8");
|
|
4322
4575
|
const stripped = stripConflictMarkers(content);
|
|
4323
4576
|
writeFileSync2(filePath, stripped);
|
|
4324
4577
|
} catch {
|
|
@@ -4348,12 +4601,11 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
|
|
|
4348
4601
|
}
|
|
4349
4602
|
/** @internal Used by Flow implementations in `src/flows/`. */
|
|
4350
4603
|
readFernignorePatterns() {
|
|
4351
|
-
|
|
4352
|
-
if (!existsSync2(fernignorePath)) return [];
|
|
4353
|
-
return readFileSync2(fernignorePath, "utf-8").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
|
|
4604
|
+
return readFernignorePatterns(this.outputDir);
|
|
4354
4605
|
}
|
|
4355
4606
|
/** @internal Used by Flow implementations in `src/flows/`. */
|
|
4356
|
-
buildReport(flow, patches, results, options, warnings, rebaseCounts, preRebaseCounts, patchesReverted) {
|
|
4607
|
+
buildReport(flow, patches, results, options, warnings, rebaseCounts, preRebaseCounts, patchesReverted, degradedReasons) {
|
|
4608
|
+
const degraded = dedupeDegradedReasons(degradedReasons ?? []);
|
|
4357
4609
|
const conflictResults = results.filter((r) => r.status === "conflict");
|
|
4358
4610
|
const conflictDetails = conflictResults.map((r) => {
|
|
4359
4611
|
const conflictFiles = r.fileResults?.filter((f) => f.status === "conflict") ?? [];
|
|
@@ -4393,10 +4645,23 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
|
|
|
4393
4645
|
conflictDetails: r.fileResults?.filter((f) => f.status === "conflict") ?? []
|
|
4394
4646
|
})) : void 0,
|
|
4395
4647
|
wouldApply: options?.dryRun ? patches : void 0,
|
|
4396
|
-
warnings: warnings && warnings.length > 0 ? warnings : void 0
|
|
4648
|
+
warnings: warnings && warnings.length > 0 ? warnings : void 0,
|
|
4649
|
+
degraded: degraded.length > 0 ? true : void 0,
|
|
4650
|
+
degradedReasons: degraded.length > 0 ? degraded : void 0
|
|
4397
4651
|
};
|
|
4398
4652
|
}
|
|
4399
4653
|
};
|
|
4654
|
+
function dedupeDegradedReasons(reasons) {
|
|
4655
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4656
|
+
const out = [];
|
|
4657
|
+
for (const reason of reasons) {
|
|
4658
|
+
const key = `${reason.code}\0${reason.message}`;
|
|
4659
|
+
if (seen.has(key)) continue;
|
|
4660
|
+
seen.add(key);
|
|
4661
|
+
out.push(reason);
|
|
4662
|
+
}
|
|
4663
|
+
return out;
|
|
4664
|
+
}
|
|
4400
4665
|
function computeCommitBuckets(results, absorbedPatchIds, patchesPassedToCommit) {
|
|
4401
4666
|
const resultByPatchId = /* @__PURE__ */ new Map();
|
|
4402
4667
|
for (const r of results) resultByPatchId.set(r.patch.id, r);
|
|
@@ -4445,7 +4710,7 @@ function computeLegacyFernignoreCleanse(patch, fernignorePatterns, computeConten
|
|
|
4445
4710
|
}
|
|
4446
4711
|
}
|
|
4447
4712
|
}
|
|
4448
|
-
const { stripped: patchContentStripped, content: newPatchContent, strippedSectionPaths } =
|
|
4713
|
+
const { stripped: patchContentStripped, content: newPatchContent, strippedSectionPaths } = stripDiffSectionsByPredicate(patch.patch_content, matchesFernignore);
|
|
4449
4714
|
const unchanged = strippedFromFiles.length === 0 && !snapshotChanged && !patchContentStripped;
|
|
4450
4715
|
if (unchanged) {
|
|
4451
4716
|
return {
|
|
@@ -4480,7 +4745,38 @@ function computeLegacyFernignoreCleanse(patch, fernignorePatterns, computeConten
|
|
|
4480
4745
|
strippedPaths
|
|
4481
4746
|
};
|
|
4482
4747
|
}
|
|
4483
|
-
function
|
|
4748
|
+
function isInfrastructureFile(filePath) {
|
|
4749
|
+
return filePath === ".fernignore" || filePath.startsWith(".fern/");
|
|
4750
|
+
}
|
|
4751
|
+
function computeInfrastructureFileCleanse(patch, computeContentHash2) {
|
|
4752
|
+
const { stripped, content: newPatchContent, strippedSectionPaths } = stripDiffSectionsByPredicate(patch.patch_content, isInfrastructureFile);
|
|
4753
|
+
if (!stripped) {
|
|
4754
|
+
return {
|
|
4755
|
+
unchanged: true,
|
|
4756
|
+
remove: false,
|
|
4757
|
+
patch_content: patch.patch_content,
|
|
4758
|
+
content_hash: patch.content_hash,
|
|
4759
|
+
strippedPaths: []
|
|
4760
|
+
};
|
|
4761
|
+
}
|
|
4762
|
+
if (newPatchContent.trim() === "" || patch.files.length === 0) {
|
|
4763
|
+
return {
|
|
4764
|
+
unchanged: false,
|
|
4765
|
+
remove: true,
|
|
4766
|
+
patch_content: "",
|
|
4767
|
+
content_hash: "",
|
|
4768
|
+
strippedPaths: strippedSectionPaths
|
|
4769
|
+
};
|
|
4770
|
+
}
|
|
4771
|
+
return {
|
|
4772
|
+
unchanged: false,
|
|
4773
|
+
remove: false,
|
|
4774
|
+
patch_content: newPatchContent,
|
|
4775
|
+
content_hash: computeContentHash2(newPatchContent),
|
|
4776
|
+
strippedPaths: strippedSectionPaths
|
|
4777
|
+
};
|
|
4778
|
+
}
|
|
4779
|
+
function stripDiffSectionsByPredicate(patchContent, shouldStrip) {
|
|
4484
4780
|
const lines = patchContent.split("\n");
|
|
4485
4781
|
const sectionStarts = [];
|
|
4486
4782
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -4502,7 +4798,7 @@ function stripFernignoreDiffSections(patchContent, matchesFernignore) {
|
|
|
4502
4798
|
const section = sectionStarts[i];
|
|
4503
4799
|
const next = sectionStarts[i + 1];
|
|
4504
4800
|
const endIdx = next ? next.idx : lines.length;
|
|
4505
|
-
if (section.path !== null &&
|
|
4801
|
+
if (section.path !== null && shouldStrip(section.path)) {
|
|
4506
4802
|
stripped = true;
|
|
4507
4803
|
strippedSectionPaths.push(section.path);
|
|
4508
4804
|
continue;
|
|
@@ -4514,8 +4810,8 @@ function stripFernignoreDiffSections(patchContent, matchesFernignore) {
|
|
|
4514
4810
|
|
|
4515
4811
|
// src/FernignoreMigrator.ts
|
|
4516
4812
|
import { createHash as createHash2 } from "crypto";
|
|
4517
|
-
import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as
|
|
4518
|
-
import { dirname as dirname5, join as
|
|
4813
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync4, statSync, writeFileSync as writeFileSync3 } from "fs";
|
|
4814
|
+
import { dirname as dirname5, join as join8 } from "path";
|
|
4519
4815
|
import { minimatch as minimatch6 } from "minimatch";
|
|
4520
4816
|
import { parse as parse2, stringify as stringify2 } from "yaml";
|
|
4521
4817
|
var FernignoreMigrator = class {
|
|
@@ -4528,14 +4824,14 @@ var FernignoreMigrator = class {
|
|
|
4528
4824
|
this.outputDir = outputDir;
|
|
4529
4825
|
}
|
|
4530
4826
|
fernignoreExists() {
|
|
4531
|
-
return existsSync3(
|
|
4827
|
+
return existsSync3(join8(this.outputDir, ".fernignore"));
|
|
4532
4828
|
}
|
|
4533
4829
|
readFernignorePatterns() {
|
|
4534
|
-
const fernignorePath =
|
|
4830
|
+
const fernignorePath = join8(this.outputDir, ".fernignore");
|
|
4535
4831
|
if (!existsSync3(fernignorePath)) {
|
|
4536
4832
|
return [];
|
|
4537
4833
|
}
|
|
4538
|
-
const content =
|
|
4834
|
+
const content = readFileSync4(fernignorePath, "utf-8");
|
|
4539
4835
|
return content.split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
|
|
4540
4836
|
}
|
|
4541
4837
|
/** Analyze .fernignore patterns vs git history. Creates synthetic patches for files differing from pristine generation. */
|
|
@@ -4640,7 +4936,7 @@ var FernignoreMigrator = class {
|
|
|
4640
4936
|
return results;
|
|
4641
4937
|
}
|
|
4642
4938
|
readFileContent(filePath) {
|
|
4643
|
-
const fullPath =
|
|
4939
|
+
const fullPath = join8(this.outputDir, filePath);
|
|
4644
4940
|
if (!existsSync3(fullPath)) {
|
|
4645
4941
|
return null;
|
|
4646
4942
|
}
|
|
@@ -4649,7 +4945,7 @@ var FernignoreMigrator = class {
|
|
|
4649
4945
|
if (stat.isDirectory()) {
|
|
4650
4946
|
return null;
|
|
4651
4947
|
}
|
|
4652
|
-
return
|
|
4948
|
+
return readFileSync4(fullPath, "utf-8");
|
|
4653
4949
|
} catch {
|
|
4654
4950
|
return null;
|
|
4655
4951
|
}
|
|
@@ -4705,10 +5001,10 @@ var FernignoreMigrator = class {
|
|
|
4705
5001
|
};
|
|
4706
5002
|
}
|
|
4707
5003
|
movePatternsToReplayYml(patterns) {
|
|
4708
|
-
const replayYmlPath =
|
|
5004
|
+
const replayYmlPath = join8(this.outputDir, ".fern", "replay.yml");
|
|
4709
5005
|
let config = {};
|
|
4710
5006
|
if (existsSync3(replayYmlPath)) {
|
|
4711
|
-
const content =
|
|
5007
|
+
const content = readFileSync4(replayYmlPath, "utf-8");
|
|
4712
5008
|
config = parse2(content) ?? {};
|
|
4713
5009
|
}
|
|
4714
5010
|
const existing = config.exclude ?? [];
|
|
@@ -4724,8 +5020,8 @@ var FernignoreMigrator = class {
|
|
|
4724
5020
|
|
|
4725
5021
|
// src/commands/bootstrap.ts
|
|
4726
5022
|
import { createHash as createHash3 } from "crypto";
|
|
4727
|
-
import { existsSync as existsSync4, readFileSync as
|
|
4728
|
-
import { join as
|
|
5023
|
+
import { existsSync as existsSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
|
|
5024
|
+
import { join as join9 } from "path";
|
|
4729
5025
|
init_GitClient();
|
|
4730
5026
|
async function bootstrap(outputDir, options) {
|
|
4731
5027
|
const git = new GitClient(outputDir);
|
|
@@ -4892,10 +5188,10 @@ function parseGitLog(log) {
|
|
|
4892
5188
|
}
|
|
4893
5189
|
var REPLAY_FERNIGNORE_ENTRIES = [".fern/replay.lock", ".fern/replay.yml", ".gitattributes"];
|
|
4894
5190
|
function ensureFernignoreEntries(outputDir) {
|
|
4895
|
-
const fernignorePath =
|
|
5191
|
+
const fernignorePath = join9(outputDir, ".fernignore");
|
|
4896
5192
|
let content = "";
|
|
4897
5193
|
if (existsSync4(fernignorePath)) {
|
|
4898
|
-
content =
|
|
5194
|
+
content = readFileSync5(fernignorePath, "utf-8");
|
|
4899
5195
|
}
|
|
4900
5196
|
const lines = content.split("\n");
|
|
4901
5197
|
const toAdd = [];
|
|
@@ -4916,10 +5212,10 @@ function ensureFernignoreEntries(outputDir) {
|
|
|
4916
5212
|
}
|
|
4917
5213
|
var GITATTRIBUTES_ENTRIES = [".fern/replay.lock linguist-generated=true"];
|
|
4918
5214
|
function ensureGitattributesEntries(outputDir) {
|
|
4919
|
-
const gitattributesPath =
|
|
5215
|
+
const gitattributesPath = join9(outputDir, ".gitattributes");
|
|
4920
5216
|
let content = "";
|
|
4921
5217
|
if (existsSync4(gitattributesPath)) {
|
|
4922
|
-
content =
|
|
5218
|
+
content = readFileSync5(gitattributesPath, "utf-8");
|
|
4923
5219
|
}
|
|
4924
5220
|
const lines = content.split("\n");
|
|
4925
5221
|
const toAdd = [];
|
|
@@ -4946,6 +5242,7 @@ function computeContentHash(patchContent) {
|
|
|
4946
5242
|
}
|
|
4947
5243
|
|
|
4948
5244
|
// src/commands/forget.ts
|
|
5245
|
+
init_GitClient();
|
|
4949
5246
|
import { minimatch as minimatch7 } from "minimatch";
|
|
4950
5247
|
function parseDiffStat(patchContent) {
|
|
4951
5248
|
let additions = 0;
|
|
@@ -5005,7 +5302,28 @@ var EMPTY_RESULT = {
|
|
|
5005
5302
|
totalPatches: 0,
|
|
5006
5303
|
warnings: []
|
|
5007
5304
|
};
|
|
5008
|
-
function
|
|
5305
|
+
async function dismissPatches(outputDir, lockManager, patches) {
|
|
5306
|
+
const git = new GitClient(outputDir);
|
|
5307
|
+
const detector = new ReplayDetector(git, lockManager, outputDir, readFernignorePatterns(outputDir));
|
|
5308
|
+
for (const patch of patches) {
|
|
5309
|
+
const hashes = [patch.content_hash];
|
|
5310
|
+
if (patch.original_commit) {
|
|
5311
|
+
const materialized = await detector.materializeCommitPatch(patch.original_commit);
|
|
5312
|
+
if (materialized.status === "ok" && !hashes.includes(materialized.contentHash)) {
|
|
5313
|
+
hashes.push(materialized.contentHash);
|
|
5314
|
+
}
|
|
5315
|
+
}
|
|
5316
|
+
lockManager.addDismissal({
|
|
5317
|
+
patch_id: patch.id,
|
|
5318
|
+
original_commit: patch.original_commit,
|
|
5319
|
+
original_message: patch.original_message,
|
|
5320
|
+
content_hashes: hashes,
|
|
5321
|
+
dismissed_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5322
|
+
via: "forget"
|
|
5323
|
+
});
|
|
5324
|
+
}
|
|
5325
|
+
}
|
|
5326
|
+
async function forget(outputDir, options) {
|
|
5009
5327
|
const lockManager = new LockfileManager(outputDir);
|
|
5010
5328
|
if (!lockManager.exists()) {
|
|
5011
5329
|
return { ...EMPTY_RESULT };
|
|
@@ -5016,9 +5334,7 @@ function forget(outputDir, options) {
|
|
|
5016
5334
|
const removed = lock.patches.map(toMatchedPatch);
|
|
5017
5335
|
const warnings = buildWarnings(lock.patches);
|
|
5018
5336
|
if (!options.dryRun) {
|
|
5019
|
-
|
|
5020
|
-
lockManager.addForgottenHash(patch.content_hash);
|
|
5021
|
-
}
|
|
5337
|
+
await dismissPatches(outputDir, lockManager, lock.patches);
|
|
5022
5338
|
lockManager.clearPatches();
|
|
5023
5339
|
lockManager.save();
|
|
5024
5340
|
}
|
|
@@ -5047,8 +5363,8 @@ function forget(outputDir, options) {
|
|
|
5047
5363
|
}
|
|
5048
5364
|
const warnings = buildWarnings(patchesToRemove);
|
|
5049
5365
|
if (!options.dryRun) {
|
|
5366
|
+
await dismissPatches(outputDir, lockManager, patchesToRemove);
|
|
5050
5367
|
for (const patch of patchesToRemove) {
|
|
5051
|
-
lockManager.addForgottenHash(patch.content_hash);
|
|
5052
5368
|
lockManager.removePatch(patch.id);
|
|
5053
5369
|
}
|
|
5054
5370
|
lockManager.save();
|
|
@@ -5116,7 +5432,7 @@ function reset(outputDir, options) {
|
|
|
5116
5432
|
// src/commands/resolve.ts
|
|
5117
5433
|
init_GitClient();
|
|
5118
5434
|
import { readFile as readFile4 } from "fs/promises";
|
|
5119
|
-
import { join as
|
|
5435
|
+
import { join as join10 } from "path";
|
|
5120
5436
|
async function resolve(outputDir, options) {
|
|
5121
5437
|
const lockManager = new LockfileManager(outputDir);
|
|
5122
5438
|
if (!lockManager.exists()) {
|
|
@@ -5156,15 +5472,16 @@ async function resolve(outputDir, options) {
|
|
|
5156
5472
|
const patchesToCommit = [...resolvingPatches, ...unresolvedPatches];
|
|
5157
5473
|
if (patchesToCommit.length > 0) {
|
|
5158
5474
|
const currentGen = lock.current_generation;
|
|
5159
|
-
const detector = new ReplayDetector(git, lockManager, outputDir);
|
|
5475
|
+
const detector = new ReplayDetector(git, lockManager, outputDir, readFernignorePatterns(outputDir));
|
|
5160
5476
|
const warnings = [];
|
|
5477
|
+
const patchesDismissed = [];
|
|
5161
5478
|
let patchesResolved = 0;
|
|
5162
5479
|
const pass1 = [];
|
|
5163
5480
|
for (const patch of patchesToCommit) {
|
|
5164
5481
|
const result = await computePerPatchDiffWithFallback(
|
|
5165
5482
|
patch,
|
|
5166
5483
|
(f) => git.showFile(currentGen, f),
|
|
5167
|
-
(f) => readFile4(
|
|
5484
|
+
(f) => readFile4(join10(outputDir, f), "utf-8").catch(() => null),
|
|
5168
5485
|
(files) => git.exec(["diff", currentGen, "--", ...files]).catch(() => null)
|
|
5169
5486
|
);
|
|
5170
5487
|
if (result === null) {
|
|
@@ -5198,7 +5515,27 @@ async function resolve(outputDir, options) {
|
|
|
5198
5515
|
const r = pass1[i];
|
|
5199
5516
|
if (r.kind === "skip") continue;
|
|
5200
5517
|
if (r.kind === "revert") {
|
|
5518
|
+
const hashes = [patch.content_hash];
|
|
5519
|
+
if (patch.original_commit) {
|
|
5520
|
+
const materialized = await detector.materializeCommitPatch(patch.original_commit);
|
|
5521
|
+
if (materialized.status === "ok" && !hashes.includes(materialized.contentHash)) {
|
|
5522
|
+
hashes.push(materialized.contentHash);
|
|
5523
|
+
} else if (materialized.status === "unreachable") {
|
|
5524
|
+
warnings.push(
|
|
5525
|
+
`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.`
|
|
5526
|
+
);
|
|
5527
|
+
}
|
|
5528
|
+
}
|
|
5529
|
+
lockManager.addDismissal({
|
|
5530
|
+
patch_id: patch.id,
|
|
5531
|
+
original_commit: patch.original_commit,
|
|
5532
|
+
original_message: patch.original_message,
|
|
5533
|
+
content_hashes: hashes,
|
|
5534
|
+
dismissed_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5535
|
+
via: "resolve"
|
|
5536
|
+
});
|
|
5201
5537
|
lockManager.removePatch(patch.id);
|
|
5538
|
+
patchesDismissed.push({ id: patch.id, message: patch.original_message });
|
|
5202
5539
|
continue;
|
|
5203
5540
|
}
|
|
5204
5541
|
if (lastIndexByHash.get(r.hash) !== i) {
|
|
@@ -5220,7 +5557,7 @@ async function resolve(outputDir, options) {
|
|
|
5220
5557
|
const theirsSnapshot = {};
|
|
5221
5558
|
for (const file of filesForSnapshot) {
|
|
5222
5559
|
if (isBinaryFile(file)) continue;
|
|
5223
|
-
const content = await readFile4(
|
|
5560
|
+
const content = await readFile4(join10(outputDir, file), "utf-8").catch(() => null);
|
|
5224
5561
|
if (content != null) {
|
|
5225
5562
|
theirsSnapshot[file] = content;
|
|
5226
5563
|
}
|
|
@@ -5247,6 +5584,7 @@ async function resolve(outputDir, options) {
|
|
|
5247
5584
|
commitSha: commitSha2,
|
|
5248
5585
|
phase: "committed",
|
|
5249
5586
|
patchesResolved,
|
|
5587
|
+
patchesDismissed: patchesDismissed.length > 0 ? patchesDismissed : void 0,
|
|
5250
5588
|
warnings: warnings.length > 0 ? warnings : void 0
|
|
5251
5589
|
};
|
|
5252
5590
|
}
|
|
@@ -5294,7 +5632,9 @@ function status(outputDir) {
|
|
|
5294
5632
|
lastGeneration: void 0,
|
|
5295
5633
|
patches: [],
|
|
5296
5634
|
unresolvedCount: 0,
|
|
5297
|
-
excludePatterns: []
|
|
5635
|
+
excludePatterns: [],
|
|
5636
|
+
dismissed: [],
|
|
5637
|
+
legacyDismissedCount: 0
|
|
5298
5638
|
};
|
|
5299
5639
|
}
|
|
5300
5640
|
const lock = lockManager.read();
|
|
@@ -5323,13 +5663,24 @@ function status(outputDir) {
|
|
|
5323
5663
|
}
|
|
5324
5664
|
const config = lockManager.getCustomizationsConfig();
|
|
5325
5665
|
const excludePatterns = config.exclude ?? [];
|
|
5666
|
+
const dismissals = lock.dismissals ?? [];
|
|
5667
|
+
const dismissed = dismissals.map((d) => ({
|
|
5668
|
+
message: d.original_message,
|
|
5669
|
+
sha: d.original_commit.slice(0, 7),
|
|
5670
|
+
via: d.via,
|
|
5671
|
+
dismissedAt: d.dismissed_at
|
|
5672
|
+
}));
|
|
5673
|
+
const coveredHashes = new Set(dismissals.flatMap((d) => d.content_hashes));
|
|
5674
|
+
const legacyDismissedCount = (lock.forgotten_hashes ?? []).filter((h) => !coveredHashes.has(h)).length;
|
|
5326
5675
|
return {
|
|
5327
5676
|
initialized: true,
|
|
5328
5677
|
generationCount: lock.generations.length,
|
|
5329
5678
|
lastGeneration,
|
|
5330
5679
|
patches,
|
|
5331
5680
|
unresolvedCount,
|
|
5332
|
-
excludePatterns
|
|
5681
|
+
excludePatterns,
|
|
5682
|
+
dismissed,
|
|
5683
|
+
legacyDismissedCount
|
|
5333
5684
|
};
|
|
5334
5685
|
}
|
|
5335
5686
|
export {
|