@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/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((p) => {
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 patchContent;
996
+ let filesOutput;
970
997
  try {
971
- patchContent = await this.git.formatPatch(commit.sha);
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 ((sensitiveFiles.length > 0 || fernignoredFiles.length > 0) && files.length > 0) {
998
- const parentSha = parents[0];
999
- if (parentSha) {
1000
- try {
1001
- patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
1002
- } catch {
1003
- continue;
1004
- }
1005
- if (!patchContent.trim()) continue;
1006
- contentHash = this.computeContentHash(patchContent);
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
- if (files.length === 0) {
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
- if (!base && ours && !renameSourcePath) {
1710
+ let baseTreeUnreachable = false;
1711
+ if (!base && !renameSourcePath) {
1677
1712
  const treeReachable = await isTreeReachable(baseGen.tree_hash);
1678
1713
  if (!treeReachable) {
1679
- const fileDiff = extractFileDiff2(patch.patch_content, filePath);
1680
- if (fileDiff) {
1681
- const { reconstructFromGhostPatch: reconstructFromGhostPatch2 } = await Promise.resolve().then(() => (init_HybridReconstruction(), HybridReconstruction_exports));
1682
- const result = reconstructFromGhostPatch2(fileDiff, ours);
1683
- if (result) {
1684
- base = result.base;
1685
- ghostTheirs = result.theirs;
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 { kind: "new-file-both", theirs: effective_theirs };
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.