@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.d.cts
CHANGED
|
@@ -101,6 +101,17 @@ interface FileResult {
|
|
|
101
101
|
* if needed. Empty/undefined when no such lines exist.
|
|
102
102
|
*/
|
|
103
103
|
droppedContextLines?: string[];
|
|
104
|
+
/**
|
|
105
|
+
* INC-866 silent-loss signal. Set on a `new-file-both` result when BASE was
|
|
106
|
+
* null because the patch's base-generation tree is unreachable (shallow
|
|
107
|
+
* clone window too small / pruned history) rather than because the file is
|
|
108
|
+
* genuinely new. In that state an edited-existing-file customization can be
|
|
109
|
+
* dropped with no conflict and no error. Surfaced as a ReplayReport warning
|
|
110
|
+
* (`recordUnreachableBaseWarnings`) so callers/CLI learn the clone window
|
|
111
|
+
* was insufficient instead of losing the edit silently. Undefined/false in
|
|
112
|
+
* the benign case.
|
|
113
|
+
*/
|
|
114
|
+
baseTreeUnreachable?: boolean;
|
|
104
115
|
}
|
|
105
116
|
interface ConflictRegion {
|
|
106
117
|
startLine: number;
|
|
@@ -885,6 +896,22 @@ declare class ReplayService {
|
|
|
885
896
|
*/
|
|
886
897
|
/** @internal Used by Flow implementations in `src/flows/`. */
|
|
887
898
|
recordDroppedContextLineWarnings(results: ReplayResult[]): void;
|
|
899
|
+
/**
|
|
900
|
+
* After applyPatches(), surface a warning for each (patch × file) where a
|
|
901
|
+
* customization's BASE could not be read because the patch's
|
|
902
|
+
* base-generation tree is unreachable in this clone (shallow-clone window
|
|
903
|
+
* too small / pruned history). In that state the merge pipeline falls
|
|
904
|
+
* through to `new-file-both` and the customer's edit can be dropped with NO
|
|
905
|
+
* conflict and NO error — the INC-866 silent-loss class. This converts that
|
|
906
|
+
* silence into an explicit, actionable warning naming the file and the
|
|
907
|
+
* likely cause (insufficient clone window).
|
|
908
|
+
*
|
|
909
|
+
* Mirrors `recordDroppedContextLineWarnings`'s "surface, never silently
|
|
910
|
+
* drop" contract. De-duplicated per file path so a single insufficient
|
|
911
|
+
* window doesn't spam one warning per touched file repeatedly.
|
|
912
|
+
*/
|
|
913
|
+
/** @internal Used by Flow implementations in `src/flows/`. */
|
|
914
|
+
recordUnreachableBaseWarnings(results: ReplayResult[]): void;
|
|
888
915
|
/**
|
|
889
916
|
* After applyPatches(), strip conflict markers from conflicting files
|
|
890
917
|
* so only clean content is committed. Keeps the Generated (OURS) side.
|
package/dist/index.d.ts
CHANGED
|
@@ -101,6 +101,17 @@ interface FileResult {
|
|
|
101
101
|
* if needed. Empty/undefined when no such lines exist.
|
|
102
102
|
*/
|
|
103
103
|
droppedContextLines?: string[];
|
|
104
|
+
/**
|
|
105
|
+
* INC-866 silent-loss signal. Set on a `new-file-both` result when BASE was
|
|
106
|
+
* null because the patch's base-generation tree is unreachable (shallow
|
|
107
|
+
* clone window too small / pruned history) rather than because the file is
|
|
108
|
+
* genuinely new. In that state an edited-existing-file customization can be
|
|
109
|
+
* dropped with no conflict and no error. Surfaced as a ReplayReport warning
|
|
110
|
+
* (`recordUnreachableBaseWarnings`) so callers/CLI learn the clone window
|
|
111
|
+
* was insufficient instead of losing the edit silently. Undefined/false in
|
|
112
|
+
* the benign case.
|
|
113
|
+
*/
|
|
114
|
+
baseTreeUnreachable?: boolean;
|
|
104
115
|
}
|
|
105
116
|
interface ConflictRegion {
|
|
106
117
|
startLine: number;
|
|
@@ -885,6 +896,22 @@ declare class ReplayService {
|
|
|
885
896
|
*/
|
|
886
897
|
/** @internal Used by Flow implementations in `src/flows/`. */
|
|
887
898
|
recordDroppedContextLineWarnings(results: ReplayResult[]): void;
|
|
899
|
+
/**
|
|
900
|
+
* After applyPatches(), surface a warning for each (patch × file) where a
|
|
901
|
+
* customization's BASE could not be read because the patch's
|
|
902
|
+
* base-generation tree is unreachable in this clone (shallow-clone window
|
|
903
|
+
* too small / pruned history). In that state the merge pipeline falls
|
|
904
|
+
* through to `new-file-both` and the customer's edit can be dropped with NO
|
|
905
|
+
* conflict and NO error — the INC-866 silent-loss class. This converts that
|
|
906
|
+
* silence into an explicit, actionable warning naming the file and the
|
|
907
|
+
* likely cause (insufficient clone window).
|
|
908
|
+
*
|
|
909
|
+
* Mirrors `recordDroppedContextLineWarnings`'s "surface, never silently
|
|
910
|
+
* drop" contract. De-duplicated per file path so a single insufficient
|
|
911
|
+
* window doesn't spam one warning per touched file repeatedly.
|
|
912
|
+
*/
|
|
913
|
+
/** @internal Used by Flow implementations in `src/flows/`. */
|
|
914
|
+
recordUnreachableBaseWarnings(results: ReplayResult[]): void;
|
|
888
915
|
/**
|
|
889
916
|
* After applyPatches(), strip conflict markers from conflicting files
|
|
890
917
|
* so only clean content is committed. Keeps the Generated (OURS) side.
|
package/dist/index.js
CHANGED
|
@@ -40,6 +40,11 @@ var init_GitClient = __esm({
|
|
|
40
40
|
proc.stderr.on("data", (data) => {
|
|
41
41
|
stderr += data.toString();
|
|
42
42
|
});
|
|
43
|
+
proc.on("error", (err) => {
|
|
44
|
+
reject(new Error(`git ${args.join(" ")} failed to spawn: ${err.message}`));
|
|
45
|
+
});
|
|
46
|
+
proc.stdin.on("error", () => {
|
|
47
|
+
});
|
|
43
48
|
proc.on("close", (code) => {
|
|
44
49
|
if (code === 0) {
|
|
45
50
|
resolve2(stdout);
|
|
@@ -808,7 +813,8 @@ function isBinaryFile(filePath) {
|
|
|
808
813
|
var INFRASTRUCTURE_FILES = /* @__PURE__ */ new Set([".fernignore"]);
|
|
809
814
|
function matchesFernignorePattern(filePath, patterns) {
|
|
810
815
|
if (patterns.length === 0) return false;
|
|
811
|
-
return patterns.some((
|
|
816
|
+
return patterns.some((rawPattern) => {
|
|
817
|
+
const p = rawPattern.endsWith("/") ? rawPattern.slice(0, -1) : rawPattern;
|
|
812
818
|
if (filePath === p) return true;
|
|
813
819
|
if (minimatch2(filePath, p)) return true;
|
|
814
820
|
if (!p.includes("*") && !p.includes("?") && filePath.startsWith(p + "/")) return true;
|
|
@@ -908,6 +914,22 @@ var ReplayDetector = class {
|
|
|
908
914
|
return commit.sha;
|
|
909
915
|
}
|
|
910
916
|
}
|
|
917
|
+
let fullLog;
|
|
918
|
+
try {
|
|
919
|
+
fullLog = await this.git.exec([
|
|
920
|
+
"log",
|
|
921
|
+
"--no-merges",
|
|
922
|
+
"--format=%H%x00%an%x00%ae%x00%s",
|
|
923
|
+
"HEAD"
|
|
924
|
+
]);
|
|
925
|
+
} catch {
|
|
926
|
+
return null;
|
|
927
|
+
}
|
|
928
|
+
for (const commit of this.parseGitLog(fullLog)) {
|
|
929
|
+
if (isGenerationBoundary(commit)) {
|
|
930
|
+
return commit.sha;
|
|
931
|
+
}
|
|
932
|
+
}
|
|
911
933
|
return null;
|
|
912
934
|
}
|
|
913
935
|
async detectNewPatches() {
|
|
@@ -919,6 +941,11 @@ var ReplayDetector = class {
|
|
|
919
941
|
}
|
|
920
942
|
const lastGen = this.getLastGeneration(lock);
|
|
921
943
|
if (!lastGen) {
|
|
944
|
+
if (lock.current_generation) {
|
|
945
|
+
this.warnings.push(
|
|
946
|
+
`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
|
+
);
|
|
948
|
+
}
|
|
922
949
|
return { patches: [], revertedPatchIds: [] };
|
|
923
950
|
}
|
|
924
951
|
const exists = await this.git.commitExists(lastGen.commit_sha);
|
|
@@ -966,20 +993,15 @@ var ReplayDetector = class {
|
|
|
966
993
|
continue;
|
|
967
994
|
}
|
|
968
995
|
const parents = await this.git.getCommitParents(commit.sha);
|
|
969
|
-
let
|
|
996
|
+
let filesOutput;
|
|
970
997
|
try {
|
|
971
|
-
|
|
998
|
+
filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
|
|
972
999
|
} catch {
|
|
973
1000
|
this.warnings.push(
|
|
974
1001
|
`Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
|
|
975
1002
|
);
|
|
976
1003
|
continue;
|
|
977
1004
|
}
|
|
978
|
-
let contentHash = this.computeContentHash(patchContent);
|
|
979
|
-
if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
|
|
980
|
-
continue;
|
|
981
|
-
}
|
|
982
|
-
const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
|
|
983
1005
|
const { files, sensitiveFiles, fernignoredFiles } = filterInfrastructureAndSensitiveFiles(
|
|
984
1006
|
filesOutput,
|
|
985
1007
|
this.fernignorePatterns
|
|
@@ -994,19 +1016,31 @@ var ReplayDetector = class {
|
|
|
994
1016
|
`.fernignore-protected file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${fernignoredFiles.join(", ")}. These files are managed by .fernignore, not Replay.`
|
|
995
1017
|
);
|
|
996
1018
|
}
|
|
997
|
-
if (
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1019
|
+
if (files.length === 0) {
|
|
1020
|
+
continue;
|
|
1021
|
+
}
|
|
1022
|
+
const wasFiltered = sensitiveFiles.length > 0 || fernignoredFiles.length > 0;
|
|
1023
|
+
const parentSha = parents[0];
|
|
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;
|
|
1007
1040
|
}
|
|
1008
1041
|
}
|
|
1009
|
-
|
|
1042
|
+
const contentHash = this.computeContentHash(patchContent);
|
|
1043
|
+
if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
|
|
1010
1044
|
continue;
|
|
1011
1045
|
}
|
|
1012
1046
|
const theirsSnapshot = await capturePatchSnapshot(this.git, files, commit.sha);
|
|
@@ -1673,16 +1707,20 @@ async function resolveInputs(args) {
|
|
|
1673
1707
|
const oursPath = join2(outputDir, resolvedPath);
|
|
1674
1708
|
const ours = await readFile(oursPath, "utf-8").catch(() => null);
|
|
1675
1709
|
let ghostTheirs = null;
|
|
1676
|
-
|
|
1710
|
+
let baseTreeUnreachable = false;
|
|
1711
|
+
if (!base && !renameSourcePath) {
|
|
1677
1712
|
const treeReachable = await isTreeReachable(baseGen.tree_hash);
|
|
1678
1713
|
if (!treeReachable) {
|
|
1679
|
-
|
|
1680
|
-
if (
|
|
1681
|
-
const
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1714
|
+
baseTreeUnreachable = true;
|
|
1715
|
+
if (ours) {
|
|
1716
|
+
const fileDiff = extractFileDiff2(patch.patch_content, filePath);
|
|
1717
|
+
if (fileDiff) {
|
|
1718
|
+
const { reconstructFromGhostPatch: reconstructFromGhostPatch2 } = await Promise.resolve().then(() => (init_HybridReconstruction(), HybridReconstruction_exports));
|
|
1719
|
+
const result = reconstructFromGhostPatch2(fileDiff, ours);
|
|
1720
|
+
if (result) {
|
|
1721
|
+
base = result.base;
|
|
1722
|
+
ghostTheirs = result.theirs;
|
|
1723
|
+
}
|
|
1686
1724
|
}
|
|
1687
1725
|
}
|
|
1688
1726
|
}
|
|
@@ -1694,7 +1732,11 @@ async function resolveInputs(args) {
|
|
|
1694
1732
|
ours,
|
|
1695
1733
|
oursPath,
|
|
1696
1734
|
renameSourcePath,
|
|
1697
|
-
ghostTheirs
|
|
1735
|
+
ghostTheirs,
|
|
1736
|
+
// Only meaningful when ghost reconstruction did NOT recover a base; if it
|
|
1737
|
+
// did, `base` is non-null and the merge proceeds normally. We still set
|
|
1738
|
+
// the flag truthfully (the tree IS unreachable) so Phase 3 can decide.
|
|
1739
|
+
baseTreeUnreachable
|
|
1698
1740
|
};
|
|
1699
1741
|
}
|
|
1700
1742
|
|
|
@@ -1877,7 +1919,11 @@ async function buildMergePlan(args) {
|
|
|
1877
1919
|
return { kind: "new-file-only-user", theirs: effective_theirs };
|
|
1878
1920
|
}
|
|
1879
1921
|
if (base == null && ours != null && effective_theirs != null && !useAccumulatorAsMergeBase) {
|
|
1880
|
-
return {
|
|
1922
|
+
return {
|
|
1923
|
+
kind: "new-file-both",
|
|
1924
|
+
theirs: effective_theirs,
|
|
1925
|
+
...inputs.baseTreeUnreachable ? { baseTreeUnreachable: true } : {}
|
|
1926
|
+
};
|
|
1881
1927
|
}
|
|
1882
1928
|
if (effective_theirs == null) {
|
|
1883
1929
|
return { kind: "skipped", reason: "missing-content" };
|
|
@@ -1925,17 +1971,19 @@ async function runMergeAndRecord(args) {
|
|
|
1925
1971
|
const merged2 = threeWayMerge("", ours, plan.theirs);
|
|
1926
1972
|
await mkdir(dirname2(oursPath), { recursive: true });
|
|
1927
1973
|
await writeFile(oursPath, merged2.content);
|
|
1974
|
+
const unreachableFlag = plan.baseTreeUnreachable ? { baseTreeUnreachable: true } : {};
|
|
1928
1975
|
if (merged2.hasConflicts) {
|
|
1929
1976
|
return {
|
|
1930
1977
|
file: resolvedPath,
|
|
1931
1978
|
status: "conflict",
|
|
1932
1979
|
conflicts: merged2.conflicts,
|
|
1933
1980
|
conflictReason: "new-file-both",
|
|
1934
|
-
conflictMetadata: metadata
|
|
1981
|
+
conflictMetadata: metadata,
|
|
1982
|
+
...unreachableFlag
|
|
1935
1983
|
};
|
|
1936
1984
|
}
|
|
1937
1985
|
accumulator.recordMerge(resolvedPath, merged2.content, patch.base_generation);
|
|
1938
|
-
return { file: resolvedPath, status: "merged" };
|
|
1986
|
+
return { file: resolvedPath, status: "merged", ...unreachableFlag };
|
|
1939
1987
|
}
|
|
1940
1988
|
const merged = threeWayMerge(plan.mergeBase, ours, plan.theirs);
|
|
1941
1989
|
await mkdir(dirname2(oursPath), { recursive: true });
|
|
@@ -3151,6 +3199,7 @@ var NoPatchesFlow = class {
|
|
|
3151
3199
|
results = await this.service.applicator.applyPatches(newPatches);
|
|
3152
3200
|
this.service._lastApplyResults = results;
|
|
3153
3201
|
this.service.recordDroppedContextLineWarnings(results);
|
|
3202
|
+
this.service.recordUnreachableBaseWarnings(results);
|
|
3154
3203
|
this.service.revertConflictingFiles(results);
|
|
3155
3204
|
for (const result of results) {
|
|
3156
3205
|
if (result.status === "conflict") {
|
|
@@ -3306,6 +3355,7 @@ var NormalRegenerationFlow = class {
|
|
|
3306
3355
|
const results = await this.service.applicator.applyPatches(allPatches);
|
|
3307
3356
|
this.service._lastApplyResults = results;
|
|
3308
3357
|
this.service.recordDroppedContextLineWarnings(results);
|
|
3358
|
+
this.service.recordUnreachableBaseWarnings(results);
|
|
3309
3359
|
this.service.revertConflictingFiles(results);
|
|
3310
3360
|
for (const result of results) {
|
|
3311
3361
|
if (result.status === "conflict") {
|
|
@@ -4225,6 +4275,37 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
|
|
|
4225
4275
|
}
|
|
4226
4276
|
}
|
|
4227
4277
|
}
|
|
4278
|
+
/**
|
|
4279
|
+
* After applyPatches(), surface a warning for each (patch × file) where a
|
|
4280
|
+
* customization's BASE could not be read because the patch's
|
|
4281
|
+
* base-generation tree is unreachable in this clone (shallow-clone window
|
|
4282
|
+
* too small / pruned history). In that state the merge pipeline falls
|
|
4283
|
+
* through to `new-file-both` and the customer's edit can be dropped with NO
|
|
4284
|
+
* conflict and NO error — the INC-866 silent-loss class. This converts that
|
|
4285
|
+
* silence into an explicit, actionable warning naming the file and the
|
|
4286
|
+
* likely cause (insufficient clone window).
|
|
4287
|
+
*
|
|
4288
|
+
* Mirrors `recordDroppedContextLineWarnings`'s "surface, never silently
|
|
4289
|
+
* drop" contract. De-duplicated per file path so a single insufficient
|
|
4290
|
+
* window doesn't spam one warning per touched file repeatedly.
|
|
4291
|
+
*/
|
|
4292
|
+
/** @internal Used by Flow implementations in `src/flows/`. */
|
|
4293
|
+
recordUnreachableBaseWarnings(results) {
|
|
4294
|
+
const warned = /* @__PURE__ */ new Set();
|
|
4295
|
+
for (const result of results) {
|
|
4296
|
+
if (!result.fileResults) continue;
|
|
4297
|
+
for (const fr of result.fileResults) {
|
|
4298
|
+
if (!fr.baseTreeUnreachable) continue;
|
|
4299
|
+
if (warned.has(fr.file)) continue;
|
|
4300
|
+
warned.add(fr.file);
|
|
4301
|
+
const base = fr.conflictMetadata?.baseGeneration;
|
|
4302
|
+
const baseRef = base ? ` (base generation ${base.slice(0, 7)})` : "";
|
|
4303
|
+
this.warnings.push(
|
|
4304
|
+
`${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.`
|
|
4305
|
+
);
|
|
4306
|
+
}
|
|
4307
|
+
}
|
|
4308
|
+
}
|
|
4228
4309
|
/**
|
|
4229
4310
|
* After applyPatches(), strip conflict markers from conflicting files
|
|
4230
4311
|
* so only clean content is committed. Keeps the Generated (OURS) side.
|