@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.js
CHANGED
|
@@ -746,6 +746,7 @@ var LockfileManager = class {
|
|
|
746
746
|
|
|
747
747
|
// src/ReplayDetector.ts
|
|
748
748
|
import { createHash } from "crypto";
|
|
749
|
+
import { minimatch as minimatch2 } from "minimatch";
|
|
749
750
|
|
|
750
751
|
// src/shared/binary.ts
|
|
751
752
|
import { extname } from "path";
|
|
@@ -805,6 +806,15 @@ function isBinaryFile(filePath) {
|
|
|
805
806
|
|
|
806
807
|
// src/ReplayDetector.ts
|
|
807
808
|
var INFRASTRUCTURE_FILES = /* @__PURE__ */ new Set([".fernignore"]);
|
|
809
|
+
function matchesFernignorePattern(filePath, patterns) {
|
|
810
|
+
if (patterns.length === 0) return false;
|
|
811
|
+
return patterns.some((p) => {
|
|
812
|
+
if (filePath === p) return true;
|
|
813
|
+
if (minimatch2(filePath, p)) return true;
|
|
814
|
+
if (!p.includes("*") && !p.includes("?") && filePath.startsWith(p + "/")) return true;
|
|
815
|
+
return false;
|
|
816
|
+
});
|
|
817
|
+
}
|
|
808
818
|
function parsePatchFileHeaders(patchContent) {
|
|
809
819
|
const results = [];
|
|
810
820
|
let currentPath = null;
|
|
@@ -845,21 +855,25 @@ async function capturePatchSnapshot(git, files, treeish) {
|
|
|
845
855
|
}
|
|
846
856
|
return snapshot;
|
|
847
857
|
}
|
|
848
|
-
function filterInfrastructureAndSensitiveFiles(rawFilesOutput) {
|
|
858
|
+
function filterInfrastructureAndSensitiveFiles(rawFilesOutput, fernignorePatterns) {
|
|
849
859
|
const allFiles = rawFilesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
|
|
850
860
|
const sensitiveFiles = allFiles.filter((f) => isSensitiveFile(f));
|
|
851
|
-
const
|
|
852
|
-
|
|
861
|
+
const nonSensitive = allFiles.filter((f) => !isSensitiveFile(f));
|
|
862
|
+
const fernignoredFiles = nonSensitive.filter((f) => matchesFernignorePattern(f, fernignorePatterns));
|
|
863
|
+
const files = nonSensitive.filter((f) => !matchesFernignorePattern(f, fernignorePatterns));
|
|
864
|
+
return { files, sensitiveFiles, fernignoredFiles };
|
|
853
865
|
}
|
|
854
866
|
var ReplayDetector = class {
|
|
855
867
|
git;
|
|
856
868
|
lockManager;
|
|
857
869
|
sdkOutputDir;
|
|
870
|
+
fernignorePatterns;
|
|
858
871
|
warnings = [];
|
|
859
|
-
constructor(git, lockManager, sdkOutputDir) {
|
|
872
|
+
constructor(git, lockManager, sdkOutputDir, fernignorePatterns = []) {
|
|
860
873
|
this.git = git;
|
|
861
874
|
this.lockManager = lockManager;
|
|
862
875
|
this.sdkOutputDir = sdkOutputDir;
|
|
876
|
+
this.fernignorePatterns = fernignorePatterns;
|
|
863
877
|
}
|
|
864
878
|
/**
|
|
865
879
|
* Derive the previous-generation SHA from git history instead of trusting
|
|
@@ -894,6 +908,22 @@ var ReplayDetector = class {
|
|
|
894
908
|
return commit.sha;
|
|
895
909
|
}
|
|
896
910
|
}
|
|
911
|
+
let fullLog;
|
|
912
|
+
try {
|
|
913
|
+
fullLog = await this.git.exec([
|
|
914
|
+
"log",
|
|
915
|
+
"--no-merges",
|
|
916
|
+
"--format=%H%x00%an%x00%ae%x00%s",
|
|
917
|
+
"HEAD"
|
|
918
|
+
]);
|
|
919
|
+
} catch {
|
|
920
|
+
return null;
|
|
921
|
+
}
|
|
922
|
+
for (const commit of this.parseGitLog(fullLog)) {
|
|
923
|
+
if (isGenerationBoundary(commit)) {
|
|
924
|
+
return commit.sha;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
897
927
|
return null;
|
|
898
928
|
}
|
|
899
929
|
async detectNewPatches() {
|
|
@@ -905,6 +935,11 @@ var ReplayDetector = class {
|
|
|
905
935
|
}
|
|
906
936
|
const lastGen = this.getLastGeneration(lock);
|
|
907
937
|
if (!lastGen) {
|
|
938
|
+
if (lock.current_generation) {
|
|
939
|
+
this.warnings.push(
|
|
940
|
+
`No generation boundary found in git history and the lockfile's recorded generation (${lock.current_generation.slice(0, 7)}) matches no entry in its generation records. Customer customizations may differ from the last known generation and could be lost on the next regeneration. Run \`fern-replay bootstrap --force\` to re-initialize tracking.`
|
|
941
|
+
);
|
|
942
|
+
}
|
|
908
943
|
return { patches: [], revertedPatchIds: [] };
|
|
909
944
|
}
|
|
910
945
|
const exists = await this.git.commitExists(lastGen.commit_sha);
|
|
@@ -966,22 +1001,30 @@ var ReplayDetector = class {
|
|
|
966
1001
|
continue;
|
|
967
1002
|
}
|
|
968
1003
|
const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
|
|
969
|
-
const { files, sensitiveFiles } = filterInfrastructureAndSensitiveFiles(
|
|
1004
|
+
const { files, sensitiveFiles, fernignoredFiles } = filterInfrastructureAndSensitiveFiles(
|
|
1005
|
+
filesOutput,
|
|
1006
|
+
this.fernignorePatterns
|
|
1007
|
+
);
|
|
970
1008
|
if (sensitiveFiles.length > 0) {
|
|
971
1009
|
this.warnings.push(
|
|
972
1010
|
`Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
|
|
973
1011
|
);
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
1012
|
+
}
|
|
1013
|
+
if (fernignoredFiles.length > 0) {
|
|
1014
|
+
this.warnings.push(
|
|
1015
|
+
`.fernignore-protected file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${fernignoredFiles.join(", ")}. These files are managed by .fernignore, not Replay.`
|
|
1016
|
+
);
|
|
1017
|
+
}
|
|
1018
|
+
if ((sensitiveFiles.length > 0 || fernignoredFiles.length > 0) && files.length > 0) {
|
|
1019
|
+
const parentSha = parents[0];
|
|
1020
|
+
if (parentSha) {
|
|
1021
|
+
try {
|
|
1022
|
+
patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
|
|
1023
|
+
} catch {
|
|
1024
|
+
continue;
|
|
984
1025
|
}
|
|
1026
|
+
if (!patchContent.trim()) continue;
|
|
1027
|
+
contentHash = this.computeContentHash(patchContent);
|
|
985
1028
|
}
|
|
986
1029
|
}
|
|
987
1030
|
if (files.length === 0) {
|
|
@@ -1180,12 +1223,20 @@ var ReplayDetector = class {
|
|
|
1180
1223
|
resolvedBaseGeneration = fallback;
|
|
1181
1224
|
}
|
|
1182
1225
|
const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
|
|
1183
|
-
const { files, sensitiveFiles } = filterInfrastructureAndSensitiveFiles(
|
|
1226
|
+
const { files, sensitiveFiles, fernignoredFiles } = filterInfrastructureAndSensitiveFiles(
|
|
1227
|
+
filesOutput,
|
|
1228
|
+
this.fernignorePatterns
|
|
1229
|
+
);
|
|
1184
1230
|
if (sensitiveFiles.length > 0) {
|
|
1185
1231
|
this.warnings.push(
|
|
1186
1232
|
`Sensitive file(s) excluded from composite patch: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
|
|
1187
1233
|
);
|
|
1188
1234
|
}
|
|
1235
|
+
if (fernignoredFiles.length > 0) {
|
|
1236
|
+
this.warnings.push(
|
|
1237
|
+
`.fernignore-protected file(s) excluded from composite patch: ${fernignoredFiles.join(", ")}. These files are managed by .fernignore, not Replay.`
|
|
1238
|
+
);
|
|
1239
|
+
}
|
|
1189
1240
|
if (files.length === 0) return { patches: [], revertedPatchIds: [] };
|
|
1190
1241
|
const diff = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
|
|
1191
1242
|
if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
|
|
@@ -1268,23 +1319,31 @@ var ReplayDetector = class {
|
|
|
1268
1319
|
continue;
|
|
1269
1320
|
}
|
|
1270
1321
|
const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
|
|
1271
|
-
const { files, sensitiveFiles } = filterInfrastructureAndSensitiveFiles(
|
|
1322
|
+
const { files, sensitiveFiles, fernignoredFiles } = filterInfrastructureAndSensitiveFiles(
|
|
1323
|
+
filesOutput,
|
|
1324
|
+
this.fernignorePatterns
|
|
1325
|
+
);
|
|
1272
1326
|
if (sensitiveFiles.length > 0) {
|
|
1273
1327
|
this.warnings.push(
|
|
1274
1328
|
`Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
|
|
1275
1329
|
);
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
}
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1330
|
+
}
|
|
1331
|
+
if (fernignoredFiles.length > 0) {
|
|
1332
|
+
this.warnings.push(
|
|
1333
|
+
`.fernignore-protected file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${fernignoredFiles.join(", ")}. These files are managed by .fernignore, not Replay.`
|
|
1334
|
+
);
|
|
1335
|
+
}
|
|
1336
|
+
if ((sensitiveFiles.length > 0 || fernignoredFiles.length > 0) && files.length > 0) {
|
|
1337
|
+
if (parents.length === 0) {
|
|
1338
|
+
continue;
|
|
1339
|
+
}
|
|
1340
|
+
try {
|
|
1341
|
+
patchContent = await this.git.exec(["diff", parents[0], commit.sha, "--", ...files]);
|
|
1342
|
+
} catch {
|
|
1343
|
+
continue;
|
|
1287
1344
|
}
|
|
1345
|
+
if (!patchContent.trim()) continue;
|
|
1346
|
+
contentHash = this.computeContentHash(patchContent);
|
|
1288
1347
|
}
|
|
1289
1348
|
if (files.length === 0) {
|
|
1290
1349
|
continue;
|
|
@@ -1520,7 +1579,7 @@ function applyBothPatches(baseLines, oursPatches, theirsPatches) {
|
|
|
1520
1579
|
import { mkdir as mkdir2, mkdtemp, readFile as readFile2, rm, unlink, writeFile as writeFile2 } from "fs/promises";
|
|
1521
1580
|
import { tmpdir } from "os";
|
|
1522
1581
|
import { dirname as dirname3, join as join3 } from "path";
|
|
1523
|
-
import { minimatch as
|
|
1582
|
+
import { minimatch as minimatch3 } from "minimatch";
|
|
1524
1583
|
|
|
1525
1584
|
// src/accumulator/MergeAccumulator.ts
|
|
1526
1585
|
var MergeAccumulator = class {
|
|
@@ -1635,16 +1694,20 @@ async function resolveInputs(args) {
|
|
|
1635
1694
|
const oursPath = join2(outputDir, resolvedPath);
|
|
1636
1695
|
const ours = await readFile(oursPath, "utf-8").catch(() => null);
|
|
1637
1696
|
let ghostTheirs = null;
|
|
1638
|
-
|
|
1697
|
+
let baseTreeUnreachable = false;
|
|
1698
|
+
if (!base && !renameSourcePath) {
|
|
1639
1699
|
const treeReachable = await isTreeReachable(baseGen.tree_hash);
|
|
1640
1700
|
if (!treeReachable) {
|
|
1641
|
-
|
|
1642
|
-
if (
|
|
1643
|
-
const
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1701
|
+
baseTreeUnreachable = true;
|
|
1702
|
+
if (ours) {
|
|
1703
|
+
const fileDiff = extractFileDiff2(patch.patch_content, filePath);
|
|
1704
|
+
if (fileDiff) {
|
|
1705
|
+
const { reconstructFromGhostPatch: reconstructFromGhostPatch2 } = await Promise.resolve().then(() => (init_HybridReconstruction(), HybridReconstruction_exports));
|
|
1706
|
+
const result = reconstructFromGhostPatch2(fileDiff, ours);
|
|
1707
|
+
if (result) {
|
|
1708
|
+
base = result.base;
|
|
1709
|
+
ghostTheirs = result.theirs;
|
|
1710
|
+
}
|
|
1648
1711
|
}
|
|
1649
1712
|
}
|
|
1650
1713
|
}
|
|
@@ -1656,7 +1719,11 @@ async function resolveInputs(args) {
|
|
|
1656
1719
|
ours,
|
|
1657
1720
|
oursPath,
|
|
1658
1721
|
renameSourcePath,
|
|
1659
|
-
ghostTheirs
|
|
1722
|
+
ghostTheirs,
|
|
1723
|
+
// Only meaningful when ghost reconstruction did NOT recover a base; if it
|
|
1724
|
+
// did, `base` is non-null and the merge proceeds normally. We still set
|
|
1725
|
+
// the flag truthfully (the tree IS unreachable) so Phase 3 can decide.
|
|
1726
|
+
baseTreeUnreachable
|
|
1660
1727
|
};
|
|
1661
1728
|
}
|
|
1662
1729
|
|
|
@@ -1839,7 +1906,11 @@ async function buildMergePlan(args) {
|
|
|
1839
1906
|
return { kind: "new-file-only-user", theirs: effective_theirs };
|
|
1840
1907
|
}
|
|
1841
1908
|
if (base == null && ours != null && effective_theirs != null && !useAccumulatorAsMergeBase) {
|
|
1842
|
-
return {
|
|
1909
|
+
return {
|
|
1910
|
+
kind: "new-file-both",
|
|
1911
|
+
theirs: effective_theirs,
|
|
1912
|
+
...inputs.baseTreeUnreachable ? { baseTreeUnreachable: true } : {}
|
|
1913
|
+
};
|
|
1843
1914
|
}
|
|
1844
1915
|
if (effective_theirs == null) {
|
|
1845
1916
|
return { kind: "skipped", reason: "missing-content" };
|
|
@@ -1887,17 +1958,19 @@ async function runMergeAndRecord(args) {
|
|
|
1887
1958
|
const merged2 = threeWayMerge("", ours, plan.theirs);
|
|
1888
1959
|
await mkdir(dirname2(oursPath), { recursive: true });
|
|
1889
1960
|
await writeFile(oursPath, merged2.content);
|
|
1961
|
+
const unreachableFlag = plan.baseTreeUnreachable ? { baseTreeUnreachable: true } : {};
|
|
1890
1962
|
if (merged2.hasConflicts) {
|
|
1891
1963
|
return {
|
|
1892
1964
|
file: resolvedPath,
|
|
1893
1965
|
status: "conflict",
|
|
1894
1966
|
conflicts: merged2.conflicts,
|
|
1895
1967
|
conflictReason: "new-file-both",
|
|
1896
|
-
conflictMetadata: metadata
|
|
1968
|
+
conflictMetadata: metadata,
|
|
1969
|
+
...unreachableFlag
|
|
1897
1970
|
};
|
|
1898
1971
|
}
|
|
1899
1972
|
accumulator.recordMerge(resolvedPath, merged2.content, patch.base_generation);
|
|
1900
|
-
return { file: resolvedPath, status: "merged" };
|
|
1973
|
+
return { file: resolvedPath, status: "merged", ...unreachableFlag };
|
|
1901
1974
|
}
|
|
1902
1975
|
const merged = threeWayMerge(plan.mergeBase, ours, plan.theirs);
|
|
1903
1976
|
await mkdir(dirname2(oursPath), { recursive: true });
|
|
@@ -2236,13 +2309,13 @@ var ReplayApplicator = class {
|
|
|
2236
2309
|
isExcluded(patch) {
|
|
2237
2310
|
const config = this.lockManager.getCustomizationsConfig();
|
|
2238
2311
|
if (!config.exclude) return false;
|
|
2239
|
-
return patch.files.some((file) => config.exclude.some((pattern) =>
|
|
2312
|
+
return patch.files.some((file) => config.exclude.some((pattern) => minimatch3(file, pattern)));
|
|
2240
2313
|
}
|
|
2241
2314
|
async resolveFilePath(filePath, baseTreeHash, currentTreeHash) {
|
|
2242
2315
|
const config = this.lockManager.getCustomizationsConfig();
|
|
2243
2316
|
if (config.moves) {
|
|
2244
2317
|
for (const move of config.moves) {
|
|
2245
|
-
if (
|
|
2318
|
+
if (minimatch3(filePath, move.from) || filePath === move.from) {
|
|
2246
2319
|
if (filePath === move.from) {
|
|
2247
2320
|
return move.to;
|
|
2248
2321
|
}
|
|
@@ -2533,7 +2606,7 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
|
|
|
2533
2606
|
import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
2534
2607
|
import { readFile as readFileAsync } from "fs/promises";
|
|
2535
2608
|
import { join as join6 } from "path";
|
|
2536
|
-
import { minimatch as
|
|
2609
|
+
import { minimatch as minimatch5 } from "minimatch";
|
|
2537
2610
|
init_GitClient();
|
|
2538
2611
|
|
|
2539
2612
|
// src/PatchRegionDiff.ts
|
|
@@ -2840,7 +2913,7 @@ function synthesizeDeleteFileDiff(file, content) {
|
|
|
2840
2913
|
}
|
|
2841
2914
|
|
|
2842
2915
|
// src/classifier/FileOwnership.ts
|
|
2843
|
-
import { minimatch as
|
|
2916
|
+
import { minimatch as minimatch4 } from "minimatch";
|
|
2844
2917
|
var FileOwnership = class {
|
|
2845
2918
|
constructor(git) {
|
|
2846
2919
|
this.git = git;
|
|
@@ -2864,7 +2937,7 @@ var FileOwnership = class {
|
|
|
2864
2937
|
const { file, originalFileName, patch, currentGenSha, fernignorePatterns, warnedGens, warnings } = ctx;
|
|
2865
2938
|
if (patch.user_owned) return true;
|
|
2866
2939
|
if (file === ".fernignore") return true;
|
|
2867
|
-
if (fernignorePatterns.some((p) => file === p ||
|
|
2940
|
+
if (fernignorePatterns.some((p) => file === p || minimatch4(file, p))) return true;
|
|
2868
2941
|
if (fileIsCreationInPatchContent(patch.patch_content, originalFileName ?? file)) {
|
|
2869
2942
|
return true;
|
|
2870
2943
|
}
|
|
@@ -3113,6 +3186,7 @@ var NoPatchesFlow = class {
|
|
|
3113
3186
|
results = await this.service.applicator.applyPatches(newPatches);
|
|
3114
3187
|
this.service._lastApplyResults = results;
|
|
3115
3188
|
this.service.recordDroppedContextLineWarnings(results);
|
|
3189
|
+
this.service.recordUnreachableBaseWarnings(results);
|
|
3116
3190
|
this.service.revertConflictingFiles(results);
|
|
3117
3191
|
for (const result of results) {
|
|
3118
3192
|
if (result.status === "conflict") {
|
|
@@ -3268,6 +3342,7 @@ var NormalRegenerationFlow = class {
|
|
|
3268
3342
|
const results = await this.service.applicator.applyPatches(allPatches);
|
|
3269
3343
|
this.service._lastApplyResults = results;
|
|
3270
3344
|
this.service.recordDroppedContextLineWarnings(results);
|
|
3345
|
+
this.service.recordUnreachableBaseWarnings(results);
|
|
3271
3346
|
this.service.revertConflictingFiles(results);
|
|
3272
3347
|
for (const result of results) {
|
|
3273
3348
|
if (result.status === "conflict") {
|
|
@@ -3370,7 +3445,8 @@ var ReplayService = class {
|
|
|
3370
3445
|
this.git = git;
|
|
3371
3446
|
this.outputDir = outputDir;
|
|
3372
3447
|
this.lockManager = new LockfileManager(outputDir);
|
|
3373
|
-
|
|
3448
|
+
const fernignorePatterns = this.readFernignorePatterns();
|
|
3449
|
+
this.detector = new ReplayDetector(git, this.lockManager, outputDir, fernignorePatterns);
|
|
3374
3450
|
this.applicator = new ReplayApplicator(git, this.lockManager, outputDir);
|
|
3375
3451
|
this.committer = new ReplayCommitter(git, outputDir);
|
|
3376
3452
|
this.fileOwnership = new FileOwnership(git);
|
|
@@ -3390,8 +3466,60 @@ var ReplayService = class {
|
|
|
3390
3466
|
async prepareReplay(options) {
|
|
3391
3467
|
this.warnings = [];
|
|
3392
3468
|
this.detector.warnings.length = 0;
|
|
3469
|
+
this.cleanseLegacyFernignoreEntries();
|
|
3393
3470
|
return this.selectFlow(options).prepare(options);
|
|
3394
3471
|
}
|
|
3472
|
+
/**
|
|
3473
|
+
* ADR 0002 migration step. Strips `.fernignore`-matched entries from
|
|
3474
|
+
* `patch.files`, `patch.theirs_snapshot` keys, and `diff --git` sections
|
|
3475
|
+
* in `patch.patch_content` for every patch in the lockfile. Patches that
|
|
3476
|
+
* end up with no surviving files are removed entirely.
|
|
3477
|
+
*
|
|
3478
|
+
* Idempotent: a second run is a no-op because the first run already
|
|
3479
|
+
* matches the contract. Per-patch try/catch ensures one bad patch does
|
|
3480
|
+
* not block the rest. Each affected patch emits a warning naming the
|
|
3481
|
+
* stripped paths. `patch_content` is left untouched if no parseable
|
|
3482
|
+
* `diff --git` headers are found (fail-safe for exotic legacy formats).
|
|
3483
|
+
*/
|
|
3484
|
+
cleanseLegacyFernignoreEntries() {
|
|
3485
|
+
const patterns = this.readFernignorePatterns();
|
|
3486
|
+
if (patterns.length === 0) return;
|
|
3487
|
+
if (!this.lockManager.exists()) return;
|
|
3488
|
+
this.lockManager.read();
|
|
3489
|
+
let lockfileMutated = false;
|
|
3490
|
+
const patches = this.lockManager.getPatches();
|
|
3491
|
+
for (const patch of patches) {
|
|
3492
|
+
try {
|
|
3493
|
+
const result = computeLegacyFernignoreCleanse(
|
|
3494
|
+
patch,
|
|
3495
|
+
patterns,
|
|
3496
|
+
(content) => this.detector.computeContentHash(content)
|
|
3497
|
+
);
|
|
3498
|
+
if (result.unchanged) continue;
|
|
3499
|
+
if (result.remove) {
|
|
3500
|
+
this.lockManager.removePatch(patch.id);
|
|
3501
|
+
this.warnings.push(
|
|
3502
|
+
`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.)`
|
|
3503
|
+
);
|
|
3504
|
+
} else {
|
|
3505
|
+
this.lockManager.updatePatch(patch.id, {
|
|
3506
|
+
files: result.files,
|
|
3507
|
+
patch_content: result.patch_content,
|
|
3508
|
+
content_hash: result.content_hash,
|
|
3509
|
+
...result.theirs_snapshot !== void 0 ? { theirs_snapshot: result.theirs_snapshot } : {}
|
|
3510
|
+
});
|
|
3511
|
+
this.warnings.push(
|
|
3512
|
+
`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.)`
|
|
3513
|
+
);
|
|
3514
|
+
}
|
|
3515
|
+
lockfileMutated = true;
|
|
3516
|
+
} catch {
|
|
3517
|
+
}
|
|
3518
|
+
}
|
|
3519
|
+
if (lockfileMutated) {
|
|
3520
|
+
this.lockManager.save();
|
|
3521
|
+
}
|
|
3522
|
+
}
|
|
3395
3523
|
/**
|
|
3396
3524
|
* Phase 2 of the two-phase replay flow. Applies patches, rebases them against
|
|
3397
3525
|
* the generation, saves the lockfile, and commits `[fern-replay]` (or stages
|
|
@@ -3523,7 +3651,7 @@ var ReplayService = class {
|
|
|
3523
3651
|
)
|
|
3524
3652
|
);
|
|
3525
3653
|
const hasProtectedFiles = patch.files.some(
|
|
3526
|
-
(file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p ||
|
|
3654
|
+
(file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch5(file, p))
|
|
3527
3655
|
);
|
|
3528
3656
|
const allUserOwned = isUserOwnedPerFile.every(Boolean);
|
|
3529
3657
|
if (allUserOwned && patch.files.length > 0) {
|
|
@@ -3963,7 +4091,7 @@ var ReplayService = class {
|
|
|
3963
4091
|
);
|
|
3964
4092
|
if (snapHasStaleMarkers) continue;
|
|
3965
4093
|
const isProtectedSnap = patch.files.some(
|
|
3966
|
-
(file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p ||
|
|
4094
|
+
(file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch5(file, p))
|
|
3967
4095
|
);
|
|
3968
4096
|
if (isProtectedSnap) continue;
|
|
3969
4097
|
const snapHash = this.detector.computeContentHash(snapshotDiff);
|
|
@@ -4003,7 +4131,7 @@ var ReplayService = class {
|
|
|
4003
4131
|
continue;
|
|
4004
4132
|
}
|
|
4005
4133
|
const isProtected = patch.files.some(
|
|
4006
|
-
(file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p ||
|
|
4134
|
+
(file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch5(file, p))
|
|
4007
4135
|
);
|
|
4008
4136
|
if (isProtected) {
|
|
4009
4137
|
continue;
|
|
@@ -4134,6 +4262,37 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
|
|
|
4134
4262
|
}
|
|
4135
4263
|
}
|
|
4136
4264
|
}
|
|
4265
|
+
/**
|
|
4266
|
+
* After applyPatches(), surface a warning for each (patch × file) where a
|
|
4267
|
+
* customization's BASE could not be read because the patch's
|
|
4268
|
+
* base-generation tree is unreachable in this clone (shallow-clone window
|
|
4269
|
+
* too small / pruned history). In that state the merge pipeline falls
|
|
4270
|
+
* through to `new-file-both` and the customer's edit can be dropped with NO
|
|
4271
|
+
* conflict and NO error — the INC-866 silent-loss class. This converts that
|
|
4272
|
+
* silence into an explicit, actionable warning naming the file and the
|
|
4273
|
+
* likely cause (insufficient clone window).
|
|
4274
|
+
*
|
|
4275
|
+
* Mirrors `recordDroppedContextLineWarnings`'s "surface, never silently
|
|
4276
|
+
* drop" contract. De-duplicated per file path so a single insufficient
|
|
4277
|
+
* window doesn't spam one warning per touched file repeatedly.
|
|
4278
|
+
*/
|
|
4279
|
+
/** @internal Used by Flow implementations in `src/flows/`. */
|
|
4280
|
+
recordUnreachableBaseWarnings(results) {
|
|
4281
|
+
const warned = /* @__PURE__ */ new Set();
|
|
4282
|
+
for (const result of results) {
|
|
4283
|
+
if (!result.fileResults) continue;
|
|
4284
|
+
for (const fr of result.fileResults) {
|
|
4285
|
+
if (!fr.baseTreeUnreachable) continue;
|
|
4286
|
+
if (warned.has(fr.file)) continue;
|
|
4287
|
+
warned.add(fr.file);
|
|
4288
|
+
const base = fr.conflictMetadata?.baseGeneration;
|
|
4289
|
+
const baseRef = base ? ` (base generation ${base.slice(0, 7)})` : "";
|
|
4290
|
+
this.warnings.push(
|
|
4291
|
+
`${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.`
|
|
4292
|
+
);
|
|
4293
|
+
}
|
|
4294
|
+
}
|
|
4295
|
+
}
|
|
4137
4296
|
/**
|
|
4138
4297
|
* After applyPatches(), strip conflict markers from conflicting files
|
|
4139
4298
|
* so only clean content is committed. Keeps the Generated (OURS) side.
|
|
@@ -4167,7 +4326,7 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
|
|
|
4167
4326
|
if (files.length === 0) return;
|
|
4168
4327
|
const fernignorePatterns = this.readFernignorePatterns();
|
|
4169
4328
|
for (const file of files) {
|
|
4170
|
-
if (fernignorePatterns.some((pattern) =>
|
|
4329
|
+
if (fernignorePatterns.some((pattern) => minimatch5(file, pattern))) continue;
|
|
4171
4330
|
try {
|
|
4172
4331
|
await this.git.exec(["checkout", "HEAD", "--", file]);
|
|
4173
4332
|
} catch {
|
|
@@ -4252,12 +4411,99 @@ function computeCommitBuckets(results, absorbedPatchIds, patchesPassedToCommit)
|
|
|
4252
4411
|
}
|
|
4253
4412
|
return { applied, unresolved, absorbed };
|
|
4254
4413
|
}
|
|
4414
|
+
function computeLegacyFernignoreCleanse(patch, fernignorePatterns, computeContentHash2) {
|
|
4415
|
+
const matchesFernignore = (filePath) => fernignorePatterns.some((p) => {
|
|
4416
|
+
if (filePath === p) return true;
|
|
4417
|
+
if (minimatch5(filePath, p)) return true;
|
|
4418
|
+
if (!p.includes("*") && !p.includes("?") && filePath.startsWith(p + "/")) return true;
|
|
4419
|
+
return false;
|
|
4420
|
+
});
|
|
4421
|
+
const strippedFromFiles = patch.files.filter(matchesFernignore);
|
|
4422
|
+
const survivingFiles = patch.files.filter((f) => !matchesFernignore(f));
|
|
4423
|
+
let survivingSnapshot = void 0;
|
|
4424
|
+
let snapshotChanged = false;
|
|
4425
|
+
if (patch.theirs_snapshot) {
|
|
4426
|
+
survivingSnapshot = {};
|
|
4427
|
+
for (const [key, value] of Object.entries(patch.theirs_snapshot)) {
|
|
4428
|
+
if (matchesFernignore(key)) {
|
|
4429
|
+
snapshotChanged = true;
|
|
4430
|
+
} else {
|
|
4431
|
+
survivingSnapshot[key] = value;
|
|
4432
|
+
}
|
|
4433
|
+
}
|
|
4434
|
+
}
|
|
4435
|
+
const { stripped: patchContentStripped, content: newPatchContent, strippedSectionPaths } = stripFernignoreDiffSections(patch.patch_content, matchesFernignore);
|
|
4436
|
+
const unchanged = strippedFromFiles.length === 0 && !snapshotChanged && !patchContentStripped;
|
|
4437
|
+
if (unchanged) {
|
|
4438
|
+
return {
|
|
4439
|
+
unchanged: true,
|
|
4440
|
+
remove: false,
|
|
4441
|
+
files: patch.files,
|
|
4442
|
+
patch_content: patch.patch_content,
|
|
4443
|
+
content_hash: patch.content_hash,
|
|
4444
|
+
theirs_snapshot: patch.theirs_snapshot,
|
|
4445
|
+
strippedPaths: []
|
|
4446
|
+
};
|
|
4447
|
+
}
|
|
4448
|
+
const strippedPaths = Array.from(/* @__PURE__ */ new Set([...strippedFromFiles, ...strippedSectionPaths]));
|
|
4449
|
+
if (survivingFiles.length === 0 || newPatchContent.trim() === "") {
|
|
4450
|
+
return {
|
|
4451
|
+
unchanged: false,
|
|
4452
|
+
remove: true,
|
|
4453
|
+
files: [],
|
|
4454
|
+
patch_content: "",
|
|
4455
|
+
content_hash: "",
|
|
4456
|
+
strippedPaths
|
|
4457
|
+
};
|
|
4458
|
+
}
|
|
4459
|
+
const newContentHash = patchContentStripped ? computeContentHash2(newPatchContent) : patch.content_hash;
|
|
4460
|
+
return {
|
|
4461
|
+
unchanged: false,
|
|
4462
|
+
remove: false,
|
|
4463
|
+
files: survivingFiles,
|
|
4464
|
+
patch_content: newPatchContent,
|
|
4465
|
+
content_hash: newContentHash,
|
|
4466
|
+
...survivingSnapshot !== void 0 ? { theirs_snapshot: survivingSnapshot } : {},
|
|
4467
|
+
strippedPaths
|
|
4468
|
+
};
|
|
4469
|
+
}
|
|
4470
|
+
function stripFernignoreDiffSections(patchContent, matchesFernignore) {
|
|
4471
|
+
const lines = patchContent.split("\n");
|
|
4472
|
+
const sectionStarts = [];
|
|
4473
|
+
for (let i = 0; i < lines.length; i++) {
|
|
4474
|
+
const line = lines[i];
|
|
4475
|
+
if (line.startsWith("diff --git ")) {
|
|
4476
|
+
const match = line.match(/^diff --git a\/(.+?) b\/(.+?)$/);
|
|
4477
|
+
const path = match ? match[2] : null;
|
|
4478
|
+
sectionStarts.push({ idx: i, path });
|
|
4479
|
+
}
|
|
4480
|
+
}
|
|
4481
|
+
if (sectionStarts.length === 0) {
|
|
4482
|
+
return { stripped: false, content: patchContent, strippedSectionPaths: [] };
|
|
4483
|
+
}
|
|
4484
|
+
const preamble = lines.slice(0, sectionStarts[0].idx);
|
|
4485
|
+
const newLines = [...preamble];
|
|
4486
|
+
const strippedSectionPaths = [];
|
|
4487
|
+
let stripped = false;
|
|
4488
|
+
for (let i = 0; i < sectionStarts.length; i++) {
|
|
4489
|
+
const section = sectionStarts[i];
|
|
4490
|
+
const next = sectionStarts[i + 1];
|
|
4491
|
+
const endIdx = next ? next.idx : lines.length;
|
|
4492
|
+
if (section.path !== null && matchesFernignore(section.path)) {
|
|
4493
|
+
stripped = true;
|
|
4494
|
+
strippedSectionPaths.push(section.path);
|
|
4495
|
+
continue;
|
|
4496
|
+
}
|
|
4497
|
+
newLines.push(...lines.slice(section.idx, endIdx));
|
|
4498
|
+
}
|
|
4499
|
+
return { stripped, content: newLines.join("\n"), strippedSectionPaths };
|
|
4500
|
+
}
|
|
4255
4501
|
|
|
4256
4502
|
// src/FernignoreMigrator.ts
|
|
4257
4503
|
import { createHash as createHash2 } from "crypto";
|
|
4258
4504
|
import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, statSync, writeFileSync as writeFileSync3 } from "fs";
|
|
4259
4505
|
import { dirname as dirname5, join as join7 } from "path";
|
|
4260
|
-
import { minimatch as
|
|
4506
|
+
import { minimatch as minimatch6 } from "minimatch";
|
|
4261
4507
|
import { parse as parse2, stringify as stringify2 } from "yaml";
|
|
4262
4508
|
var FernignoreMigrator = class {
|
|
4263
4509
|
git;
|
|
@@ -4289,7 +4535,7 @@ var FernignoreMigrator = class {
|
|
|
4289
4535
|
const commitsOnly = [];
|
|
4290
4536
|
const syntheticPatches = [];
|
|
4291
4537
|
for (const pattern of patterns) {
|
|
4292
|
-
const matchingPatch = patches.find((p) => p.files.some((f) =>
|
|
4538
|
+
const matchingPatch = patches.find((p) => p.files.some((f) => minimatch6(f, pattern) || f === pattern));
|
|
4293
4539
|
if (matchingPatch) {
|
|
4294
4540
|
trackedByBoth.push({
|
|
4295
4541
|
file: pattern,
|
|
@@ -4362,7 +4608,7 @@ var FernignoreMigrator = class {
|
|
|
4362
4608
|
fernignoreOnly.push(...remainingFernignoreOnly);
|
|
4363
4609
|
}
|
|
4364
4610
|
for (const patch of patches) {
|
|
4365
|
-
const hasUnprotectedFiles = patch.files.some((f) => !patterns.some((p) =>
|
|
4611
|
+
const hasUnprotectedFiles = patch.files.some((f) => !patterns.some((p) => minimatch6(f, p) || f === p));
|
|
4366
4612
|
if (hasUnprotectedFiles) {
|
|
4367
4613
|
commitsOnly.push(patch);
|
|
4368
4614
|
}
|
|
@@ -4374,7 +4620,7 @@ var FernignoreMigrator = class {
|
|
|
4374
4620
|
const results = [];
|
|
4375
4621
|
for (const pattern of patterns) {
|
|
4376
4622
|
const matching = allFiles.filter(
|
|
4377
|
-
(f) =>
|
|
4623
|
+
(f) => minimatch6(f, pattern) || f === pattern || f.startsWith(pattern + "/")
|
|
4378
4624
|
);
|
|
4379
4625
|
results.push({ pattern, files: matching.length > 0 ? matching : [pattern] });
|
|
4380
4626
|
}
|
|
@@ -4687,7 +4933,7 @@ function computeContentHash(patchContent) {
|
|
|
4687
4933
|
}
|
|
4688
4934
|
|
|
4689
4935
|
// src/commands/forget.ts
|
|
4690
|
-
import { minimatch as
|
|
4936
|
+
import { minimatch as minimatch7 } from "minimatch";
|
|
4691
4937
|
function parseDiffStat(patchContent) {
|
|
4692
4938
|
let additions = 0;
|
|
4693
4939
|
let deletions = 0;
|
|
@@ -4717,7 +4963,7 @@ function toMatchedPatch(patch) {
|
|
|
4717
4963
|
}
|
|
4718
4964
|
function matchesPatch(patch, pattern) {
|
|
4719
4965
|
const fileMatch = patch.files.some(
|
|
4720
|
-
(file) => file === pattern ||
|
|
4966
|
+
(file) => file === pattern || minimatch7(file, pattern)
|
|
4721
4967
|
);
|
|
4722
4968
|
if (fileMatch) return true;
|
|
4723
4969
|
return patch.original_message.toLowerCase().includes(pattern.toLowerCase());
|