@fern-api/replay 0.16.1 → 0.17.4
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 +286 -41
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +303 -57
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +42 -1
- package/dist/index.d.ts +42 -1
- package/dist/index.js +303 -57
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -801,6 +801,7 @@ var LockfileManager = class {
|
|
|
801
801
|
|
|
802
802
|
// src/ReplayDetector.ts
|
|
803
803
|
var import_node_crypto = require("crypto");
|
|
804
|
+
var import_minimatch2 = require("minimatch");
|
|
804
805
|
|
|
805
806
|
// src/shared/binary.ts
|
|
806
807
|
var import_node_path2 = require("path");
|
|
@@ -860,6 +861,15 @@ function isBinaryFile(filePath) {
|
|
|
860
861
|
|
|
861
862
|
// src/ReplayDetector.ts
|
|
862
863
|
var INFRASTRUCTURE_FILES = /* @__PURE__ */ new Set([".fernignore"]);
|
|
864
|
+
function matchesFernignorePattern(filePath, patterns) {
|
|
865
|
+
if (patterns.length === 0) return false;
|
|
866
|
+
return patterns.some((p) => {
|
|
867
|
+
if (filePath === p) return true;
|
|
868
|
+
if ((0, import_minimatch2.minimatch)(filePath, p)) return true;
|
|
869
|
+
if (!p.includes("*") && !p.includes("?") && filePath.startsWith(p + "/")) return true;
|
|
870
|
+
return false;
|
|
871
|
+
});
|
|
872
|
+
}
|
|
863
873
|
function parsePatchFileHeaders(patchContent) {
|
|
864
874
|
const results = [];
|
|
865
875
|
let currentPath = null;
|
|
@@ -900,21 +910,25 @@ async function capturePatchSnapshot(git, files, treeish) {
|
|
|
900
910
|
}
|
|
901
911
|
return snapshot;
|
|
902
912
|
}
|
|
903
|
-
function filterInfrastructureAndSensitiveFiles(rawFilesOutput) {
|
|
913
|
+
function filterInfrastructureAndSensitiveFiles(rawFilesOutput, fernignorePatterns) {
|
|
904
914
|
const allFiles = rawFilesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
|
|
905
915
|
const sensitiveFiles = allFiles.filter((f) => isSensitiveFile(f));
|
|
906
|
-
const
|
|
907
|
-
|
|
916
|
+
const nonSensitive = allFiles.filter((f) => !isSensitiveFile(f));
|
|
917
|
+
const fernignoredFiles = nonSensitive.filter((f) => matchesFernignorePattern(f, fernignorePatterns));
|
|
918
|
+
const files = nonSensitive.filter((f) => !matchesFernignorePattern(f, fernignorePatterns));
|
|
919
|
+
return { files, sensitiveFiles, fernignoredFiles };
|
|
908
920
|
}
|
|
909
921
|
var ReplayDetector = class {
|
|
910
922
|
git;
|
|
911
923
|
lockManager;
|
|
912
924
|
sdkOutputDir;
|
|
925
|
+
fernignorePatterns;
|
|
913
926
|
warnings = [];
|
|
914
|
-
constructor(git, lockManager, sdkOutputDir) {
|
|
927
|
+
constructor(git, lockManager, sdkOutputDir, fernignorePatterns = []) {
|
|
915
928
|
this.git = git;
|
|
916
929
|
this.lockManager = lockManager;
|
|
917
930
|
this.sdkOutputDir = sdkOutputDir;
|
|
931
|
+
this.fernignorePatterns = fernignorePatterns;
|
|
918
932
|
}
|
|
919
933
|
/**
|
|
920
934
|
* Derive the previous-generation SHA from git history instead of trusting
|
|
@@ -949,6 +963,22 @@ var ReplayDetector = class {
|
|
|
949
963
|
return commit.sha;
|
|
950
964
|
}
|
|
951
965
|
}
|
|
966
|
+
let fullLog;
|
|
967
|
+
try {
|
|
968
|
+
fullLog = await this.git.exec([
|
|
969
|
+
"log",
|
|
970
|
+
"--no-merges",
|
|
971
|
+
"--format=%H%x00%an%x00%ae%x00%s",
|
|
972
|
+
"HEAD"
|
|
973
|
+
]);
|
|
974
|
+
} catch {
|
|
975
|
+
return null;
|
|
976
|
+
}
|
|
977
|
+
for (const commit of this.parseGitLog(fullLog)) {
|
|
978
|
+
if (isGenerationBoundary(commit)) {
|
|
979
|
+
return commit.sha;
|
|
980
|
+
}
|
|
981
|
+
}
|
|
952
982
|
return null;
|
|
953
983
|
}
|
|
954
984
|
async detectNewPatches() {
|
|
@@ -960,6 +990,11 @@ var ReplayDetector = class {
|
|
|
960
990
|
}
|
|
961
991
|
const lastGen = this.getLastGeneration(lock);
|
|
962
992
|
if (!lastGen) {
|
|
993
|
+
if (lock.current_generation) {
|
|
994
|
+
this.warnings.push(
|
|
995
|
+
`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.`
|
|
996
|
+
);
|
|
997
|
+
}
|
|
963
998
|
return { patches: [], revertedPatchIds: [] };
|
|
964
999
|
}
|
|
965
1000
|
const exists = await this.git.commitExists(lastGen.commit_sha);
|
|
@@ -1021,22 +1056,30 @@ var ReplayDetector = class {
|
|
|
1021
1056
|
continue;
|
|
1022
1057
|
}
|
|
1023
1058
|
const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
|
|
1024
|
-
const { files, sensitiveFiles } = filterInfrastructureAndSensitiveFiles(
|
|
1059
|
+
const { files, sensitiveFiles, fernignoredFiles } = filterInfrastructureAndSensitiveFiles(
|
|
1060
|
+
filesOutput,
|
|
1061
|
+
this.fernignorePatterns
|
|
1062
|
+
);
|
|
1025
1063
|
if (sensitiveFiles.length > 0) {
|
|
1026
1064
|
this.warnings.push(
|
|
1027
1065
|
`Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
|
|
1028
1066
|
);
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1067
|
+
}
|
|
1068
|
+
if (fernignoredFiles.length > 0) {
|
|
1069
|
+
this.warnings.push(
|
|
1070
|
+
`.fernignore-protected file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${fernignoredFiles.join(", ")}. These files are managed by .fernignore, not Replay.`
|
|
1071
|
+
);
|
|
1072
|
+
}
|
|
1073
|
+
if ((sensitiveFiles.length > 0 || fernignoredFiles.length > 0) && files.length > 0) {
|
|
1074
|
+
const parentSha = parents[0];
|
|
1075
|
+
if (parentSha) {
|
|
1076
|
+
try {
|
|
1077
|
+
patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
|
|
1078
|
+
} catch {
|
|
1079
|
+
continue;
|
|
1039
1080
|
}
|
|
1081
|
+
if (!patchContent.trim()) continue;
|
|
1082
|
+
contentHash = this.computeContentHash(patchContent);
|
|
1040
1083
|
}
|
|
1041
1084
|
}
|
|
1042
1085
|
if (files.length === 0) {
|
|
@@ -1235,12 +1278,20 @@ var ReplayDetector = class {
|
|
|
1235
1278
|
resolvedBaseGeneration = fallback;
|
|
1236
1279
|
}
|
|
1237
1280
|
const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
|
|
1238
|
-
const { files, sensitiveFiles } = filterInfrastructureAndSensitiveFiles(
|
|
1281
|
+
const { files, sensitiveFiles, fernignoredFiles } = filterInfrastructureAndSensitiveFiles(
|
|
1282
|
+
filesOutput,
|
|
1283
|
+
this.fernignorePatterns
|
|
1284
|
+
);
|
|
1239
1285
|
if (sensitiveFiles.length > 0) {
|
|
1240
1286
|
this.warnings.push(
|
|
1241
1287
|
`Sensitive file(s) excluded from composite patch: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
|
|
1242
1288
|
);
|
|
1243
1289
|
}
|
|
1290
|
+
if (fernignoredFiles.length > 0) {
|
|
1291
|
+
this.warnings.push(
|
|
1292
|
+
`.fernignore-protected file(s) excluded from composite patch: ${fernignoredFiles.join(", ")}. These files are managed by .fernignore, not Replay.`
|
|
1293
|
+
);
|
|
1294
|
+
}
|
|
1244
1295
|
if (files.length === 0) return { patches: [], revertedPatchIds: [] };
|
|
1245
1296
|
const diff = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
|
|
1246
1297
|
if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
|
|
@@ -1323,23 +1374,31 @@ var ReplayDetector = class {
|
|
|
1323
1374
|
continue;
|
|
1324
1375
|
}
|
|
1325
1376
|
const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
|
|
1326
|
-
const { files, sensitiveFiles } = filterInfrastructureAndSensitiveFiles(
|
|
1377
|
+
const { files, sensitiveFiles, fernignoredFiles } = filterInfrastructureAndSensitiveFiles(
|
|
1378
|
+
filesOutput,
|
|
1379
|
+
this.fernignorePatterns
|
|
1380
|
+
);
|
|
1327
1381
|
if (sensitiveFiles.length > 0) {
|
|
1328
1382
|
this.warnings.push(
|
|
1329
1383
|
`Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
|
|
1330
1384
|
);
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
}
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1385
|
+
}
|
|
1386
|
+
if (fernignoredFiles.length > 0) {
|
|
1387
|
+
this.warnings.push(
|
|
1388
|
+
`.fernignore-protected file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${fernignoredFiles.join(", ")}. These files are managed by .fernignore, not Replay.`
|
|
1389
|
+
);
|
|
1390
|
+
}
|
|
1391
|
+
if ((sensitiveFiles.length > 0 || fernignoredFiles.length > 0) && files.length > 0) {
|
|
1392
|
+
if (parents.length === 0) {
|
|
1393
|
+
continue;
|
|
1394
|
+
}
|
|
1395
|
+
try {
|
|
1396
|
+
patchContent = await this.git.exec(["diff", parents[0], commit.sha, "--", ...files]);
|
|
1397
|
+
} catch {
|
|
1398
|
+
continue;
|
|
1342
1399
|
}
|
|
1400
|
+
if (!patchContent.trim()) continue;
|
|
1401
|
+
contentHash = this.computeContentHash(patchContent);
|
|
1343
1402
|
}
|
|
1344
1403
|
if (files.length === 0) {
|
|
1345
1404
|
continue;
|
|
@@ -1575,7 +1634,7 @@ function applyBothPatches(baseLines, oursPatches, theirsPatches) {
|
|
|
1575
1634
|
var import_promises3 = require("fs/promises");
|
|
1576
1635
|
var import_node_os = require("os");
|
|
1577
1636
|
var import_node_path5 = require("path");
|
|
1578
|
-
var
|
|
1637
|
+
var import_minimatch3 = require("minimatch");
|
|
1579
1638
|
|
|
1580
1639
|
// src/accumulator/MergeAccumulator.ts
|
|
1581
1640
|
var MergeAccumulator = class {
|
|
@@ -1690,16 +1749,20 @@ async function resolveInputs(args) {
|
|
|
1690
1749
|
const oursPath = (0, import_node_path3.join)(outputDir, resolvedPath);
|
|
1691
1750
|
const ours = await (0, import_promises.readFile)(oursPath, "utf-8").catch(() => null);
|
|
1692
1751
|
let ghostTheirs = null;
|
|
1693
|
-
|
|
1752
|
+
let baseTreeUnreachable = false;
|
|
1753
|
+
if (!base && !renameSourcePath) {
|
|
1694
1754
|
const treeReachable = await isTreeReachable(baseGen.tree_hash);
|
|
1695
1755
|
if (!treeReachable) {
|
|
1696
|
-
|
|
1697
|
-
if (
|
|
1698
|
-
const
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1756
|
+
baseTreeUnreachable = true;
|
|
1757
|
+
if (ours) {
|
|
1758
|
+
const fileDiff = extractFileDiff2(patch.patch_content, filePath);
|
|
1759
|
+
if (fileDiff) {
|
|
1760
|
+
const { reconstructFromGhostPatch: reconstructFromGhostPatch2 } = await Promise.resolve().then(() => (init_HybridReconstruction(), HybridReconstruction_exports));
|
|
1761
|
+
const result = reconstructFromGhostPatch2(fileDiff, ours);
|
|
1762
|
+
if (result) {
|
|
1763
|
+
base = result.base;
|
|
1764
|
+
ghostTheirs = result.theirs;
|
|
1765
|
+
}
|
|
1703
1766
|
}
|
|
1704
1767
|
}
|
|
1705
1768
|
}
|
|
@@ -1711,7 +1774,11 @@ async function resolveInputs(args) {
|
|
|
1711
1774
|
ours,
|
|
1712
1775
|
oursPath,
|
|
1713
1776
|
renameSourcePath,
|
|
1714
|
-
ghostTheirs
|
|
1777
|
+
ghostTheirs,
|
|
1778
|
+
// Only meaningful when ghost reconstruction did NOT recover a base; if it
|
|
1779
|
+
// did, `base` is non-null and the merge proceeds normally. We still set
|
|
1780
|
+
// the flag truthfully (the tree IS unreachable) so Phase 3 can decide.
|
|
1781
|
+
baseTreeUnreachable
|
|
1715
1782
|
};
|
|
1716
1783
|
}
|
|
1717
1784
|
|
|
@@ -1894,7 +1961,11 @@ async function buildMergePlan(args) {
|
|
|
1894
1961
|
return { kind: "new-file-only-user", theirs: effective_theirs };
|
|
1895
1962
|
}
|
|
1896
1963
|
if (base == null && ours != null && effective_theirs != null && !useAccumulatorAsMergeBase) {
|
|
1897
|
-
return {
|
|
1964
|
+
return {
|
|
1965
|
+
kind: "new-file-both",
|
|
1966
|
+
theirs: effective_theirs,
|
|
1967
|
+
...inputs.baseTreeUnreachable ? { baseTreeUnreachable: true } : {}
|
|
1968
|
+
};
|
|
1898
1969
|
}
|
|
1899
1970
|
if (effective_theirs == null) {
|
|
1900
1971
|
return { kind: "skipped", reason: "missing-content" };
|
|
@@ -1942,17 +2013,19 @@ async function runMergeAndRecord(args) {
|
|
|
1942
2013
|
const merged2 = threeWayMerge("", ours, plan.theirs);
|
|
1943
2014
|
await (0, import_promises2.mkdir)((0, import_node_path4.dirname)(oursPath), { recursive: true });
|
|
1944
2015
|
await (0, import_promises2.writeFile)(oursPath, merged2.content);
|
|
2016
|
+
const unreachableFlag = plan.baseTreeUnreachable ? { baseTreeUnreachable: true } : {};
|
|
1945
2017
|
if (merged2.hasConflicts) {
|
|
1946
2018
|
return {
|
|
1947
2019
|
file: resolvedPath,
|
|
1948
2020
|
status: "conflict",
|
|
1949
2021
|
conflicts: merged2.conflicts,
|
|
1950
2022
|
conflictReason: "new-file-both",
|
|
1951
|
-
conflictMetadata: metadata
|
|
2023
|
+
conflictMetadata: metadata,
|
|
2024
|
+
...unreachableFlag
|
|
1952
2025
|
};
|
|
1953
2026
|
}
|
|
1954
2027
|
accumulator.recordMerge(resolvedPath, merged2.content, patch.base_generation);
|
|
1955
|
-
return { file: resolvedPath, status: "merged" };
|
|
2028
|
+
return { file: resolvedPath, status: "merged", ...unreachableFlag };
|
|
1956
2029
|
}
|
|
1957
2030
|
const merged = threeWayMerge(plan.mergeBase, ours, plan.theirs);
|
|
1958
2031
|
await (0, import_promises2.mkdir)((0, import_node_path4.dirname)(oursPath), { recursive: true });
|
|
@@ -2291,13 +2364,13 @@ var ReplayApplicator = class {
|
|
|
2291
2364
|
isExcluded(patch) {
|
|
2292
2365
|
const config = this.lockManager.getCustomizationsConfig();
|
|
2293
2366
|
if (!config.exclude) return false;
|
|
2294
|
-
return patch.files.some((file) => config.exclude.some((pattern) => (0,
|
|
2367
|
+
return patch.files.some((file) => config.exclude.some((pattern) => (0, import_minimatch3.minimatch)(file, pattern)));
|
|
2295
2368
|
}
|
|
2296
2369
|
async resolveFilePath(filePath, baseTreeHash, currentTreeHash) {
|
|
2297
2370
|
const config = this.lockManager.getCustomizationsConfig();
|
|
2298
2371
|
if (config.moves) {
|
|
2299
2372
|
for (const move of config.moves) {
|
|
2300
|
-
if ((0,
|
|
2373
|
+
if ((0, import_minimatch3.minimatch)(filePath, move.from) || filePath === move.from) {
|
|
2301
2374
|
if (filePath === move.from) {
|
|
2302
2375
|
return move.to;
|
|
2303
2376
|
}
|
|
@@ -2588,7 +2661,7 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
|
|
|
2588
2661
|
var import_node_fs2 = require("fs");
|
|
2589
2662
|
var import_promises6 = require("fs/promises");
|
|
2590
2663
|
var import_node_path8 = require("path");
|
|
2591
|
-
var
|
|
2664
|
+
var import_minimatch5 = require("minimatch");
|
|
2592
2665
|
init_GitClient();
|
|
2593
2666
|
|
|
2594
2667
|
// src/PatchRegionDiff.ts
|
|
@@ -2895,7 +2968,7 @@ function synthesizeDeleteFileDiff(file, content) {
|
|
|
2895
2968
|
}
|
|
2896
2969
|
|
|
2897
2970
|
// src/classifier/FileOwnership.ts
|
|
2898
|
-
var
|
|
2971
|
+
var import_minimatch4 = require("minimatch");
|
|
2899
2972
|
var FileOwnership = class {
|
|
2900
2973
|
constructor(git) {
|
|
2901
2974
|
this.git = git;
|
|
@@ -2919,7 +2992,7 @@ var FileOwnership = class {
|
|
|
2919
2992
|
const { file, originalFileName, patch, currentGenSha, fernignorePatterns, warnedGens, warnings } = ctx;
|
|
2920
2993
|
if (patch.user_owned) return true;
|
|
2921
2994
|
if (file === ".fernignore") return true;
|
|
2922
|
-
if (fernignorePatterns.some((p) => file === p || (0,
|
|
2995
|
+
if (fernignorePatterns.some((p) => file === p || (0, import_minimatch4.minimatch)(file, p))) return true;
|
|
2923
2996
|
if (fileIsCreationInPatchContent(patch.patch_content, originalFileName ?? file)) {
|
|
2924
2997
|
return true;
|
|
2925
2998
|
}
|
|
@@ -3168,6 +3241,7 @@ var NoPatchesFlow = class {
|
|
|
3168
3241
|
results = await this.service.applicator.applyPatches(newPatches);
|
|
3169
3242
|
this.service._lastApplyResults = results;
|
|
3170
3243
|
this.service.recordDroppedContextLineWarnings(results);
|
|
3244
|
+
this.service.recordUnreachableBaseWarnings(results);
|
|
3171
3245
|
this.service.revertConflictingFiles(results);
|
|
3172
3246
|
for (const result of results) {
|
|
3173
3247
|
if (result.status === "conflict") {
|
|
@@ -3323,6 +3397,7 @@ var NormalRegenerationFlow = class {
|
|
|
3323
3397
|
const results = await this.service.applicator.applyPatches(allPatches);
|
|
3324
3398
|
this.service._lastApplyResults = results;
|
|
3325
3399
|
this.service.recordDroppedContextLineWarnings(results);
|
|
3400
|
+
this.service.recordUnreachableBaseWarnings(results);
|
|
3326
3401
|
this.service.revertConflictingFiles(results);
|
|
3327
3402
|
for (const result of results) {
|
|
3328
3403
|
if (result.status === "conflict") {
|
|
@@ -3425,7 +3500,8 @@ var ReplayService = class {
|
|
|
3425
3500
|
this.git = git;
|
|
3426
3501
|
this.outputDir = outputDir;
|
|
3427
3502
|
this.lockManager = new LockfileManager(outputDir);
|
|
3428
|
-
|
|
3503
|
+
const fernignorePatterns = this.readFernignorePatterns();
|
|
3504
|
+
this.detector = new ReplayDetector(git, this.lockManager, outputDir, fernignorePatterns);
|
|
3429
3505
|
this.applicator = new ReplayApplicator(git, this.lockManager, outputDir);
|
|
3430
3506
|
this.committer = new ReplayCommitter(git, outputDir);
|
|
3431
3507
|
this.fileOwnership = new FileOwnership(git);
|
|
@@ -3445,8 +3521,60 @@ var ReplayService = class {
|
|
|
3445
3521
|
async prepareReplay(options) {
|
|
3446
3522
|
this.warnings = [];
|
|
3447
3523
|
this.detector.warnings.length = 0;
|
|
3524
|
+
this.cleanseLegacyFernignoreEntries();
|
|
3448
3525
|
return this.selectFlow(options).prepare(options);
|
|
3449
3526
|
}
|
|
3527
|
+
/**
|
|
3528
|
+
* ADR 0002 migration step. Strips `.fernignore`-matched entries from
|
|
3529
|
+
* `patch.files`, `patch.theirs_snapshot` keys, and `diff --git` sections
|
|
3530
|
+
* in `patch.patch_content` for every patch in the lockfile. Patches that
|
|
3531
|
+
* end up with no surviving files are removed entirely.
|
|
3532
|
+
*
|
|
3533
|
+
* Idempotent: a second run is a no-op because the first run already
|
|
3534
|
+
* matches the contract. Per-patch try/catch ensures one bad patch does
|
|
3535
|
+
* not block the rest. Each affected patch emits a warning naming the
|
|
3536
|
+
* stripped paths. `patch_content` is left untouched if no parseable
|
|
3537
|
+
* `diff --git` headers are found (fail-safe for exotic legacy formats).
|
|
3538
|
+
*/
|
|
3539
|
+
cleanseLegacyFernignoreEntries() {
|
|
3540
|
+
const patterns = this.readFernignorePatterns();
|
|
3541
|
+
if (patterns.length === 0) return;
|
|
3542
|
+
if (!this.lockManager.exists()) return;
|
|
3543
|
+
this.lockManager.read();
|
|
3544
|
+
let lockfileMutated = false;
|
|
3545
|
+
const patches = this.lockManager.getPatches();
|
|
3546
|
+
for (const patch of patches) {
|
|
3547
|
+
try {
|
|
3548
|
+
const result = computeLegacyFernignoreCleanse(
|
|
3549
|
+
patch,
|
|
3550
|
+
patterns,
|
|
3551
|
+
(content) => this.detector.computeContentHash(content)
|
|
3552
|
+
);
|
|
3553
|
+
if (result.unchanged) continue;
|
|
3554
|
+
if (result.remove) {
|
|
3555
|
+
this.lockManager.removePatch(patch.id);
|
|
3556
|
+
this.warnings.push(
|
|
3557
|
+
`Cleansed legacy patch ${patch.id}: all referenced files match .fernignore \u2014 patch removed. Customer's on-disk content for those files is preserved by .fernignore. (ADR 0002: .fernignore-protected files are not tracked by Replay.)`
|
|
3558
|
+
);
|
|
3559
|
+
} else {
|
|
3560
|
+
this.lockManager.updatePatch(patch.id, {
|
|
3561
|
+
files: result.files,
|
|
3562
|
+
patch_content: result.patch_content,
|
|
3563
|
+
content_hash: result.content_hash,
|
|
3564
|
+
...result.theirs_snapshot !== void 0 ? { theirs_snapshot: result.theirs_snapshot } : {}
|
|
3565
|
+
});
|
|
3566
|
+
this.warnings.push(
|
|
3567
|
+
`Cleansed legacy patch ${patch.id}: stripped .fernignore-matched entries (${result.strippedPaths.join(", ")}). Customer's on-disk content for those files is preserved by .fernignore. (ADR 0002.)`
|
|
3568
|
+
);
|
|
3569
|
+
}
|
|
3570
|
+
lockfileMutated = true;
|
|
3571
|
+
} catch {
|
|
3572
|
+
}
|
|
3573
|
+
}
|
|
3574
|
+
if (lockfileMutated) {
|
|
3575
|
+
this.lockManager.save();
|
|
3576
|
+
}
|
|
3577
|
+
}
|
|
3450
3578
|
/**
|
|
3451
3579
|
* Phase 2 of the two-phase replay flow. Applies patches, rebases them against
|
|
3452
3580
|
* the generation, saves the lockfile, and commits `[fern-replay]` (or stages
|
|
@@ -3578,7 +3706,7 @@ var ReplayService = class {
|
|
|
3578
3706
|
)
|
|
3579
3707
|
);
|
|
3580
3708
|
const hasProtectedFiles = patch.files.some(
|
|
3581
|
-
(file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || (0,
|
|
3709
|
+
(file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || (0, import_minimatch5.minimatch)(file, p))
|
|
3582
3710
|
);
|
|
3583
3711
|
const allUserOwned = isUserOwnedPerFile.every(Boolean);
|
|
3584
3712
|
if (allUserOwned && patch.files.length > 0) {
|
|
@@ -4018,7 +4146,7 @@ var ReplayService = class {
|
|
|
4018
4146
|
);
|
|
4019
4147
|
if (snapHasStaleMarkers) continue;
|
|
4020
4148
|
const isProtectedSnap = patch.files.some(
|
|
4021
|
-
(file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || (0,
|
|
4149
|
+
(file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || (0, import_minimatch5.minimatch)(file, p))
|
|
4022
4150
|
);
|
|
4023
4151
|
if (isProtectedSnap) continue;
|
|
4024
4152
|
const snapHash = this.detector.computeContentHash(snapshotDiff);
|
|
@@ -4058,7 +4186,7 @@ var ReplayService = class {
|
|
|
4058
4186
|
continue;
|
|
4059
4187
|
}
|
|
4060
4188
|
const isProtected = patch.files.some(
|
|
4061
|
-
(file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || (0,
|
|
4189
|
+
(file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || (0, import_minimatch5.minimatch)(file, p))
|
|
4062
4190
|
);
|
|
4063
4191
|
if (isProtected) {
|
|
4064
4192
|
continue;
|
|
@@ -4189,6 +4317,37 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
|
|
|
4189
4317
|
}
|
|
4190
4318
|
}
|
|
4191
4319
|
}
|
|
4320
|
+
/**
|
|
4321
|
+
* After applyPatches(), surface a warning for each (patch × file) where a
|
|
4322
|
+
* customization's BASE could not be read because the patch's
|
|
4323
|
+
* base-generation tree is unreachable in this clone (shallow-clone window
|
|
4324
|
+
* too small / pruned history). In that state the merge pipeline falls
|
|
4325
|
+
* through to `new-file-both` and the customer's edit can be dropped with NO
|
|
4326
|
+
* conflict and NO error — the INC-866 silent-loss class. This converts that
|
|
4327
|
+
* silence into an explicit, actionable warning naming the file and the
|
|
4328
|
+
* likely cause (insufficient clone window).
|
|
4329
|
+
*
|
|
4330
|
+
* Mirrors `recordDroppedContextLineWarnings`'s "surface, never silently
|
|
4331
|
+
* drop" contract. De-duplicated per file path so a single insufficient
|
|
4332
|
+
* window doesn't spam one warning per touched file repeatedly.
|
|
4333
|
+
*/
|
|
4334
|
+
/** @internal Used by Flow implementations in `src/flows/`. */
|
|
4335
|
+
recordUnreachableBaseWarnings(results) {
|
|
4336
|
+
const warned = /* @__PURE__ */ new Set();
|
|
4337
|
+
for (const result of results) {
|
|
4338
|
+
if (!result.fileResults) continue;
|
|
4339
|
+
for (const fr of result.fileResults) {
|
|
4340
|
+
if (!fr.baseTreeUnreachable) continue;
|
|
4341
|
+
if (warned.has(fr.file)) continue;
|
|
4342
|
+
warned.add(fr.file);
|
|
4343
|
+
const base = fr.conflictMetadata?.baseGeneration;
|
|
4344
|
+
const baseRef = base ? ` (base generation ${base.slice(0, 7)})` : "";
|
|
4345
|
+
this.warnings.push(
|
|
4346
|
+
`${fr.file}: could not read the prior generation's version of this file${baseRef} because that generation's tree is outside the clone window (insufficient shallow-clone depth or pruned history). The customization was treated as a new file, which can drop your edits silently. Re-run with the prior generation in the clone window \u2014 e.g. \`git fetch --shallow-since=<date just before the prior [fern-generated] commit>\` or \`git fetch --unshallow\` \u2014 then re-run replay.`
|
|
4347
|
+
);
|
|
4348
|
+
}
|
|
4349
|
+
}
|
|
4350
|
+
}
|
|
4192
4351
|
/**
|
|
4193
4352
|
* After applyPatches(), strip conflict markers from conflicting files
|
|
4194
4353
|
* so only clean content is committed. Keeps the Generated (OURS) side.
|
|
@@ -4222,7 +4381,7 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
|
|
|
4222
4381
|
if (files.length === 0) return;
|
|
4223
4382
|
const fernignorePatterns = this.readFernignorePatterns();
|
|
4224
4383
|
for (const file of files) {
|
|
4225
|
-
if (fernignorePatterns.some((pattern) => (0,
|
|
4384
|
+
if (fernignorePatterns.some((pattern) => (0, import_minimatch5.minimatch)(file, pattern))) continue;
|
|
4226
4385
|
try {
|
|
4227
4386
|
await this.git.exec(["checkout", "HEAD", "--", file]);
|
|
4228
4387
|
} catch {
|
|
@@ -4307,12 +4466,99 @@ function computeCommitBuckets(results, absorbedPatchIds, patchesPassedToCommit)
|
|
|
4307
4466
|
}
|
|
4308
4467
|
return { applied, unresolved, absorbed };
|
|
4309
4468
|
}
|
|
4469
|
+
function computeLegacyFernignoreCleanse(patch, fernignorePatterns, computeContentHash2) {
|
|
4470
|
+
const matchesFernignore = (filePath) => fernignorePatterns.some((p) => {
|
|
4471
|
+
if (filePath === p) return true;
|
|
4472
|
+
if ((0, import_minimatch5.minimatch)(filePath, p)) return true;
|
|
4473
|
+
if (!p.includes("*") && !p.includes("?") && filePath.startsWith(p + "/")) return true;
|
|
4474
|
+
return false;
|
|
4475
|
+
});
|
|
4476
|
+
const strippedFromFiles = patch.files.filter(matchesFernignore);
|
|
4477
|
+
const survivingFiles = patch.files.filter((f) => !matchesFernignore(f));
|
|
4478
|
+
let survivingSnapshot = void 0;
|
|
4479
|
+
let snapshotChanged = false;
|
|
4480
|
+
if (patch.theirs_snapshot) {
|
|
4481
|
+
survivingSnapshot = {};
|
|
4482
|
+
for (const [key, value] of Object.entries(patch.theirs_snapshot)) {
|
|
4483
|
+
if (matchesFernignore(key)) {
|
|
4484
|
+
snapshotChanged = true;
|
|
4485
|
+
} else {
|
|
4486
|
+
survivingSnapshot[key] = value;
|
|
4487
|
+
}
|
|
4488
|
+
}
|
|
4489
|
+
}
|
|
4490
|
+
const { stripped: patchContentStripped, content: newPatchContent, strippedSectionPaths } = stripFernignoreDiffSections(patch.patch_content, matchesFernignore);
|
|
4491
|
+
const unchanged = strippedFromFiles.length === 0 && !snapshotChanged && !patchContentStripped;
|
|
4492
|
+
if (unchanged) {
|
|
4493
|
+
return {
|
|
4494
|
+
unchanged: true,
|
|
4495
|
+
remove: false,
|
|
4496
|
+
files: patch.files,
|
|
4497
|
+
patch_content: patch.patch_content,
|
|
4498
|
+
content_hash: patch.content_hash,
|
|
4499
|
+
theirs_snapshot: patch.theirs_snapshot,
|
|
4500
|
+
strippedPaths: []
|
|
4501
|
+
};
|
|
4502
|
+
}
|
|
4503
|
+
const strippedPaths = Array.from(/* @__PURE__ */ new Set([...strippedFromFiles, ...strippedSectionPaths]));
|
|
4504
|
+
if (survivingFiles.length === 0 || newPatchContent.trim() === "") {
|
|
4505
|
+
return {
|
|
4506
|
+
unchanged: false,
|
|
4507
|
+
remove: true,
|
|
4508
|
+
files: [],
|
|
4509
|
+
patch_content: "",
|
|
4510
|
+
content_hash: "",
|
|
4511
|
+
strippedPaths
|
|
4512
|
+
};
|
|
4513
|
+
}
|
|
4514
|
+
const newContentHash = patchContentStripped ? computeContentHash2(newPatchContent) : patch.content_hash;
|
|
4515
|
+
return {
|
|
4516
|
+
unchanged: false,
|
|
4517
|
+
remove: false,
|
|
4518
|
+
files: survivingFiles,
|
|
4519
|
+
patch_content: newPatchContent,
|
|
4520
|
+
content_hash: newContentHash,
|
|
4521
|
+
...survivingSnapshot !== void 0 ? { theirs_snapshot: survivingSnapshot } : {},
|
|
4522
|
+
strippedPaths
|
|
4523
|
+
};
|
|
4524
|
+
}
|
|
4525
|
+
function stripFernignoreDiffSections(patchContent, matchesFernignore) {
|
|
4526
|
+
const lines = patchContent.split("\n");
|
|
4527
|
+
const sectionStarts = [];
|
|
4528
|
+
for (let i = 0; i < lines.length; i++) {
|
|
4529
|
+
const line = lines[i];
|
|
4530
|
+
if (line.startsWith("diff --git ")) {
|
|
4531
|
+
const match = line.match(/^diff --git a\/(.+?) b\/(.+?)$/);
|
|
4532
|
+
const path = match ? match[2] : null;
|
|
4533
|
+
sectionStarts.push({ idx: i, path });
|
|
4534
|
+
}
|
|
4535
|
+
}
|
|
4536
|
+
if (sectionStarts.length === 0) {
|
|
4537
|
+
return { stripped: false, content: patchContent, strippedSectionPaths: [] };
|
|
4538
|
+
}
|
|
4539
|
+
const preamble = lines.slice(0, sectionStarts[0].idx);
|
|
4540
|
+
const newLines = [...preamble];
|
|
4541
|
+
const strippedSectionPaths = [];
|
|
4542
|
+
let stripped = false;
|
|
4543
|
+
for (let i = 0; i < sectionStarts.length; i++) {
|
|
4544
|
+
const section = sectionStarts[i];
|
|
4545
|
+
const next = sectionStarts[i + 1];
|
|
4546
|
+
const endIdx = next ? next.idx : lines.length;
|
|
4547
|
+
if (section.path !== null && matchesFernignore(section.path)) {
|
|
4548
|
+
stripped = true;
|
|
4549
|
+
strippedSectionPaths.push(section.path);
|
|
4550
|
+
continue;
|
|
4551
|
+
}
|
|
4552
|
+
newLines.push(...lines.slice(section.idx, endIdx));
|
|
4553
|
+
}
|
|
4554
|
+
return { stripped, content: newLines.join("\n"), strippedSectionPaths };
|
|
4555
|
+
}
|
|
4310
4556
|
|
|
4311
4557
|
// src/FernignoreMigrator.ts
|
|
4312
4558
|
var import_node_crypto2 = require("crypto");
|
|
4313
4559
|
var import_node_fs3 = require("fs");
|
|
4314
4560
|
var import_node_path9 = require("path");
|
|
4315
|
-
var
|
|
4561
|
+
var import_minimatch6 = require("minimatch");
|
|
4316
4562
|
var import_yaml2 = require("yaml");
|
|
4317
4563
|
var FernignoreMigrator = class {
|
|
4318
4564
|
git;
|
|
@@ -4344,7 +4590,7 @@ var FernignoreMigrator = class {
|
|
|
4344
4590
|
const commitsOnly = [];
|
|
4345
4591
|
const syntheticPatches = [];
|
|
4346
4592
|
for (const pattern of patterns) {
|
|
4347
|
-
const matchingPatch = patches.find((p) => p.files.some((f) => (0,
|
|
4593
|
+
const matchingPatch = patches.find((p) => p.files.some((f) => (0, import_minimatch6.minimatch)(f, pattern) || f === pattern));
|
|
4348
4594
|
if (matchingPatch) {
|
|
4349
4595
|
trackedByBoth.push({
|
|
4350
4596
|
file: pattern,
|
|
@@ -4417,7 +4663,7 @@ var FernignoreMigrator = class {
|
|
|
4417
4663
|
fernignoreOnly.push(...remainingFernignoreOnly);
|
|
4418
4664
|
}
|
|
4419
4665
|
for (const patch of patches) {
|
|
4420
|
-
const hasUnprotectedFiles = patch.files.some((f) => !patterns.some((p) => (0,
|
|
4666
|
+
const hasUnprotectedFiles = patch.files.some((f) => !patterns.some((p) => (0, import_minimatch6.minimatch)(f, p) || f === p));
|
|
4421
4667
|
if (hasUnprotectedFiles) {
|
|
4422
4668
|
commitsOnly.push(patch);
|
|
4423
4669
|
}
|
|
@@ -4429,7 +4675,7 @@ var FernignoreMigrator = class {
|
|
|
4429
4675
|
const results = [];
|
|
4430
4676
|
for (const pattern of patterns) {
|
|
4431
4677
|
const matching = allFiles.filter(
|
|
4432
|
-
(f) => (0,
|
|
4678
|
+
(f) => (0, import_minimatch6.minimatch)(f, pattern) || f === pattern || f.startsWith(pattern + "/")
|
|
4433
4679
|
);
|
|
4434
4680
|
results.push({ pattern, files: matching.length > 0 ? matching : [pattern] });
|
|
4435
4681
|
}
|
|
@@ -4742,7 +4988,7 @@ function computeContentHash(patchContent) {
|
|
|
4742
4988
|
}
|
|
4743
4989
|
|
|
4744
4990
|
// src/commands/forget.ts
|
|
4745
|
-
var
|
|
4991
|
+
var import_minimatch7 = require("minimatch");
|
|
4746
4992
|
function parseDiffStat(patchContent) {
|
|
4747
4993
|
let additions = 0;
|
|
4748
4994
|
let deletions = 0;
|
|
@@ -4772,7 +5018,7 @@ function toMatchedPatch(patch) {
|
|
|
4772
5018
|
}
|
|
4773
5019
|
function matchesPatch(patch, pattern) {
|
|
4774
5020
|
const fileMatch = patch.files.some(
|
|
4775
|
-
(file) => file === pattern || (0,
|
|
5021
|
+
(file) => file === pattern || (0, import_minimatch7.minimatch)(file, pattern)
|
|
4776
5022
|
);
|
|
4777
5023
|
if (fileMatch) return true;
|
|
4778
5024
|
return patch.original_message.toLowerCase().includes(pattern.toLowerCase());
|