@fern-api/replay 0.16.2 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.cjs +112 -31
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +112 -31
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +27 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +112 -31
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -62,6 +62,11 @@ var init_GitClient = __esm({
|
|
|
62
62
|
proc.stderr.on("data", (data) => {
|
|
63
63
|
stderr += data.toString();
|
|
64
64
|
});
|
|
65
|
+
proc.on("error", (err) => {
|
|
66
|
+
reject(new Error(`git ${args.join(" ")} failed to spawn: ${err.message}`));
|
|
67
|
+
});
|
|
68
|
+
proc.stdin.on("error", () => {
|
|
69
|
+
});
|
|
65
70
|
proc.on("close", (code) => {
|
|
66
71
|
if (code === 0) {
|
|
67
72
|
resolve2(stdout);
|
|
@@ -863,7 +868,8 @@ function isBinaryFile(filePath) {
|
|
|
863
868
|
var INFRASTRUCTURE_FILES = /* @__PURE__ */ new Set([".fernignore"]);
|
|
864
869
|
function matchesFernignorePattern(filePath, patterns) {
|
|
865
870
|
if (patterns.length === 0) return false;
|
|
866
|
-
return patterns.some((
|
|
871
|
+
return patterns.some((rawPattern) => {
|
|
872
|
+
const p = rawPattern.endsWith("/") ? rawPattern.slice(0, -1) : rawPattern;
|
|
867
873
|
if (filePath === p) return true;
|
|
868
874
|
if ((0, import_minimatch2.minimatch)(filePath, p)) return true;
|
|
869
875
|
if (!p.includes("*") && !p.includes("?") && filePath.startsWith(p + "/")) return true;
|
|
@@ -963,6 +969,22 @@ var ReplayDetector = class {
|
|
|
963
969
|
return commit.sha;
|
|
964
970
|
}
|
|
965
971
|
}
|
|
972
|
+
let fullLog;
|
|
973
|
+
try {
|
|
974
|
+
fullLog = await this.git.exec([
|
|
975
|
+
"log",
|
|
976
|
+
"--no-merges",
|
|
977
|
+
"--format=%H%x00%an%x00%ae%x00%s",
|
|
978
|
+
"HEAD"
|
|
979
|
+
]);
|
|
980
|
+
} catch {
|
|
981
|
+
return null;
|
|
982
|
+
}
|
|
983
|
+
for (const commit of this.parseGitLog(fullLog)) {
|
|
984
|
+
if (isGenerationBoundary(commit)) {
|
|
985
|
+
return commit.sha;
|
|
986
|
+
}
|
|
987
|
+
}
|
|
966
988
|
return null;
|
|
967
989
|
}
|
|
968
990
|
async detectNewPatches() {
|
|
@@ -974,6 +996,11 @@ var ReplayDetector = class {
|
|
|
974
996
|
}
|
|
975
997
|
const lastGen = this.getLastGeneration(lock);
|
|
976
998
|
if (!lastGen) {
|
|
999
|
+
if (lock.current_generation) {
|
|
1000
|
+
this.warnings.push(
|
|
1001
|
+
`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.`
|
|
1002
|
+
);
|
|
1003
|
+
}
|
|
977
1004
|
return { patches: [], revertedPatchIds: [] };
|
|
978
1005
|
}
|
|
979
1006
|
const exists = await this.git.commitExists(lastGen.commit_sha);
|
|
@@ -1021,20 +1048,15 @@ var ReplayDetector = class {
|
|
|
1021
1048
|
continue;
|
|
1022
1049
|
}
|
|
1023
1050
|
const parents = await this.git.getCommitParents(commit.sha);
|
|
1024
|
-
let
|
|
1051
|
+
let filesOutput;
|
|
1025
1052
|
try {
|
|
1026
|
-
|
|
1053
|
+
filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
|
|
1027
1054
|
} catch {
|
|
1028
1055
|
this.warnings.push(
|
|
1029
1056
|
`Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
|
|
1030
1057
|
);
|
|
1031
1058
|
continue;
|
|
1032
1059
|
}
|
|
1033
|
-
let contentHash = this.computeContentHash(patchContent);
|
|
1034
|
-
if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
|
|
1035
|
-
continue;
|
|
1036
|
-
}
|
|
1037
|
-
const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
|
|
1038
1060
|
const { files, sensitiveFiles, fernignoredFiles } = filterInfrastructureAndSensitiveFiles(
|
|
1039
1061
|
filesOutput,
|
|
1040
1062
|
this.fernignorePatterns
|
|
@@ -1049,19 +1071,31 @@ var ReplayDetector = class {
|
|
|
1049
1071
|
`.fernignore-protected file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${fernignoredFiles.join(", ")}. These files are managed by .fernignore, not Replay.`
|
|
1050
1072
|
);
|
|
1051
1073
|
}
|
|
1052
|
-
if (
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1074
|
+
if (files.length === 0) {
|
|
1075
|
+
continue;
|
|
1076
|
+
}
|
|
1077
|
+
const wasFiltered = sensitiveFiles.length > 0 || fernignoredFiles.length > 0;
|
|
1078
|
+
const parentSha = parents[0];
|
|
1079
|
+
let patchContent;
|
|
1080
|
+
if (wasFiltered && parentSha) {
|
|
1081
|
+
try {
|
|
1082
|
+
patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
|
|
1083
|
+
} catch {
|
|
1084
|
+
continue;
|
|
1085
|
+
}
|
|
1086
|
+
if (!patchContent.trim()) continue;
|
|
1087
|
+
} else {
|
|
1088
|
+
try {
|
|
1089
|
+
patchContent = await this.git.formatPatch(commit.sha);
|
|
1090
|
+
} catch {
|
|
1091
|
+
this.warnings.push(
|
|
1092
|
+
`Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
|
|
1093
|
+
);
|
|
1094
|
+
continue;
|
|
1062
1095
|
}
|
|
1063
1096
|
}
|
|
1064
|
-
|
|
1097
|
+
const contentHash = this.computeContentHash(patchContent);
|
|
1098
|
+
if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
|
|
1065
1099
|
continue;
|
|
1066
1100
|
}
|
|
1067
1101
|
const theirsSnapshot = await capturePatchSnapshot(this.git, files, commit.sha);
|
|
@@ -1728,16 +1762,20 @@ async function resolveInputs(args) {
|
|
|
1728
1762
|
const oursPath = (0, import_node_path3.join)(outputDir, resolvedPath);
|
|
1729
1763
|
const ours = await (0, import_promises.readFile)(oursPath, "utf-8").catch(() => null);
|
|
1730
1764
|
let ghostTheirs = null;
|
|
1731
|
-
|
|
1765
|
+
let baseTreeUnreachable = false;
|
|
1766
|
+
if (!base && !renameSourcePath) {
|
|
1732
1767
|
const treeReachable = await isTreeReachable(baseGen.tree_hash);
|
|
1733
1768
|
if (!treeReachable) {
|
|
1734
|
-
|
|
1735
|
-
if (
|
|
1736
|
-
const
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1769
|
+
baseTreeUnreachable = true;
|
|
1770
|
+
if (ours) {
|
|
1771
|
+
const fileDiff = extractFileDiff2(patch.patch_content, filePath);
|
|
1772
|
+
if (fileDiff) {
|
|
1773
|
+
const { reconstructFromGhostPatch: reconstructFromGhostPatch2 } = await Promise.resolve().then(() => (init_HybridReconstruction(), HybridReconstruction_exports));
|
|
1774
|
+
const result = reconstructFromGhostPatch2(fileDiff, ours);
|
|
1775
|
+
if (result) {
|
|
1776
|
+
base = result.base;
|
|
1777
|
+
ghostTheirs = result.theirs;
|
|
1778
|
+
}
|
|
1741
1779
|
}
|
|
1742
1780
|
}
|
|
1743
1781
|
}
|
|
@@ -1749,7 +1787,11 @@ async function resolveInputs(args) {
|
|
|
1749
1787
|
ours,
|
|
1750
1788
|
oursPath,
|
|
1751
1789
|
renameSourcePath,
|
|
1752
|
-
ghostTheirs
|
|
1790
|
+
ghostTheirs,
|
|
1791
|
+
// Only meaningful when ghost reconstruction did NOT recover a base; if it
|
|
1792
|
+
// did, `base` is non-null and the merge proceeds normally. We still set
|
|
1793
|
+
// the flag truthfully (the tree IS unreachable) so Phase 3 can decide.
|
|
1794
|
+
baseTreeUnreachable
|
|
1753
1795
|
};
|
|
1754
1796
|
}
|
|
1755
1797
|
|
|
@@ -1932,7 +1974,11 @@ async function buildMergePlan(args) {
|
|
|
1932
1974
|
return { kind: "new-file-only-user", theirs: effective_theirs };
|
|
1933
1975
|
}
|
|
1934
1976
|
if (base == null && ours != null && effective_theirs != null && !useAccumulatorAsMergeBase) {
|
|
1935
|
-
return {
|
|
1977
|
+
return {
|
|
1978
|
+
kind: "new-file-both",
|
|
1979
|
+
theirs: effective_theirs,
|
|
1980
|
+
...inputs.baseTreeUnreachable ? { baseTreeUnreachable: true } : {}
|
|
1981
|
+
};
|
|
1936
1982
|
}
|
|
1937
1983
|
if (effective_theirs == null) {
|
|
1938
1984
|
return { kind: "skipped", reason: "missing-content" };
|
|
@@ -1980,17 +2026,19 @@ async function runMergeAndRecord(args) {
|
|
|
1980
2026
|
const merged2 = threeWayMerge("", ours, plan.theirs);
|
|
1981
2027
|
await (0, import_promises2.mkdir)((0, import_node_path4.dirname)(oursPath), { recursive: true });
|
|
1982
2028
|
await (0, import_promises2.writeFile)(oursPath, merged2.content);
|
|
2029
|
+
const unreachableFlag = plan.baseTreeUnreachable ? { baseTreeUnreachable: true } : {};
|
|
1983
2030
|
if (merged2.hasConflicts) {
|
|
1984
2031
|
return {
|
|
1985
2032
|
file: resolvedPath,
|
|
1986
2033
|
status: "conflict",
|
|
1987
2034
|
conflicts: merged2.conflicts,
|
|
1988
2035
|
conflictReason: "new-file-both",
|
|
1989
|
-
conflictMetadata: metadata
|
|
2036
|
+
conflictMetadata: metadata,
|
|
2037
|
+
...unreachableFlag
|
|
1990
2038
|
};
|
|
1991
2039
|
}
|
|
1992
2040
|
accumulator.recordMerge(resolvedPath, merged2.content, patch.base_generation);
|
|
1993
|
-
return { file: resolvedPath, status: "merged" };
|
|
2041
|
+
return { file: resolvedPath, status: "merged", ...unreachableFlag };
|
|
1994
2042
|
}
|
|
1995
2043
|
const merged = threeWayMerge(plan.mergeBase, ours, plan.theirs);
|
|
1996
2044
|
await (0, import_promises2.mkdir)((0, import_node_path4.dirname)(oursPath), { recursive: true });
|
|
@@ -3206,6 +3254,7 @@ var NoPatchesFlow = class {
|
|
|
3206
3254
|
results = await this.service.applicator.applyPatches(newPatches);
|
|
3207
3255
|
this.service._lastApplyResults = results;
|
|
3208
3256
|
this.service.recordDroppedContextLineWarnings(results);
|
|
3257
|
+
this.service.recordUnreachableBaseWarnings(results);
|
|
3209
3258
|
this.service.revertConflictingFiles(results);
|
|
3210
3259
|
for (const result of results) {
|
|
3211
3260
|
if (result.status === "conflict") {
|
|
@@ -3361,6 +3410,7 @@ var NormalRegenerationFlow = class {
|
|
|
3361
3410
|
const results = await this.service.applicator.applyPatches(allPatches);
|
|
3362
3411
|
this.service._lastApplyResults = results;
|
|
3363
3412
|
this.service.recordDroppedContextLineWarnings(results);
|
|
3413
|
+
this.service.recordUnreachableBaseWarnings(results);
|
|
3364
3414
|
this.service.revertConflictingFiles(results);
|
|
3365
3415
|
for (const result of results) {
|
|
3366
3416
|
if (result.status === "conflict") {
|
|
@@ -4280,6 +4330,37 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
|
|
|
4280
4330
|
}
|
|
4281
4331
|
}
|
|
4282
4332
|
}
|
|
4333
|
+
/**
|
|
4334
|
+
* After applyPatches(), surface a warning for each (patch × file) where a
|
|
4335
|
+
* customization's BASE could not be read because the patch's
|
|
4336
|
+
* base-generation tree is unreachable in this clone (shallow-clone window
|
|
4337
|
+
* too small / pruned history). In that state the merge pipeline falls
|
|
4338
|
+
* through to `new-file-both` and the customer's edit can be dropped with NO
|
|
4339
|
+
* conflict and NO error — the INC-866 silent-loss class. This converts that
|
|
4340
|
+
* silence into an explicit, actionable warning naming the file and the
|
|
4341
|
+
* likely cause (insufficient clone window).
|
|
4342
|
+
*
|
|
4343
|
+
* Mirrors `recordDroppedContextLineWarnings`'s "surface, never silently
|
|
4344
|
+
* drop" contract. De-duplicated per file path so a single insufficient
|
|
4345
|
+
* window doesn't spam one warning per touched file repeatedly.
|
|
4346
|
+
*/
|
|
4347
|
+
/** @internal Used by Flow implementations in `src/flows/`. */
|
|
4348
|
+
recordUnreachableBaseWarnings(results) {
|
|
4349
|
+
const warned = /* @__PURE__ */ new Set();
|
|
4350
|
+
for (const result of results) {
|
|
4351
|
+
if (!result.fileResults) continue;
|
|
4352
|
+
for (const fr of result.fileResults) {
|
|
4353
|
+
if (!fr.baseTreeUnreachable) continue;
|
|
4354
|
+
if (warned.has(fr.file)) continue;
|
|
4355
|
+
warned.add(fr.file);
|
|
4356
|
+
const base = fr.conflictMetadata?.baseGeneration;
|
|
4357
|
+
const baseRef = base ? ` (base generation ${base.slice(0, 7)})` : "";
|
|
4358
|
+
this.warnings.push(
|
|
4359
|
+
`${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.`
|
|
4360
|
+
);
|
|
4361
|
+
}
|
|
4362
|
+
}
|
|
4363
|
+
}
|
|
4283
4364
|
/**
|
|
4284
4365
|
* After applyPatches(), strip conflict markers from conflicting files
|
|
4285
4366
|
* so only clean content is committed. Keeps the Generated (OURS) side.
|