@fern-api/replay 0.16.2 → 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/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
@@ -908,6 +908,22 @@ var ReplayDetector = class {
908
908
  return commit.sha;
909
909
  }
910
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
+ }
911
927
  return null;
912
928
  }
913
929
  async detectNewPatches() {
@@ -919,6 +935,11 @@ var ReplayDetector = class {
919
935
  }
920
936
  const lastGen = this.getLastGeneration(lock);
921
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
+ }
922
943
  return { patches: [], revertedPatchIds: [] };
923
944
  }
924
945
  const exists = await this.git.commitExists(lastGen.commit_sha);
@@ -1673,16 +1694,20 @@ async function resolveInputs(args) {
1673
1694
  const oursPath = join2(outputDir, resolvedPath);
1674
1695
  const ours = await readFile(oursPath, "utf-8").catch(() => null);
1675
1696
  let ghostTheirs = null;
1676
- if (!base && ours && !renameSourcePath) {
1697
+ let baseTreeUnreachable = false;
1698
+ if (!base && !renameSourcePath) {
1677
1699
  const treeReachable = await isTreeReachable(baseGen.tree_hash);
1678
1700
  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;
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
+ }
1686
1711
  }
1687
1712
  }
1688
1713
  }
@@ -1694,7 +1719,11 @@ async function resolveInputs(args) {
1694
1719
  ours,
1695
1720
  oursPath,
1696
1721
  renameSourcePath,
1697
- 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
1698
1727
  };
1699
1728
  }
1700
1729
 
@@ -1877,7 +1906,11 @@ async function buildMergePlan(args) {
1877
1906
  return { kind: "new-file-only-user", theirs: effective_theirs };
1878
1907
  }
1879
1908
  if (base == null && ours != null && effective_theirs != null && !useAccumulatorAsMergeBase) {
1880
- return { kind: "new-file-both", theirs: effective_theirs };
1909
+ return {
1910
+ kind: "new-file-both",
1911
+ theirs: effective_theirs,
1912
+ ...inputs.baseTreeUnreachable ? { baseTreeUnreachable: true } : {}
1913
+ };
1881
1914
  }
1882
1915
  if (effective_theirs == null) {
1883
1916
  return { kind: "skipped", reason: "missing-content" };
@@ -1925,17 +1958,19 @@ async function runMergeAndRecord(args) {
1925
1958
  const merged2 = threeWayMerge("", ours, plan.theirs);
1926
1959
  await mkdir(dirname2(oursPath), { recursive: true });
1927
1960
  await writeFile(oursPath, merged2.content);
1961
+ const unreachableFlag = plan.baseTreeUnreachable ? { baseTreeUnreachable: true } : {};
1928
1962
  if (merged2.hasConflicts) {
1929
1963
  return {
1930
1964
  file: resolvedPath,
1931
1965
  status: "conflict",
1932
1966
  conflicts: merged2.conflicts,
1933
1967
  conflictReason: "new-file-both",
1934
- conflictMetadata: metadata
1968
+ conflictMetadata: metadata,
1969
+ ...unreachableFlag
1935
1970
  };
1936
1971
  }
1937
1972
  accumulator.recordMerge(resolvedPath, merged2.content, patch.base_generation);
1938
- return { file: resolvedPath, status: "merged" };
1973
+ return { file: resolvedPath, status: "merged", ...unreachableFlag };
1939
1974
  }
1940
1975
  const merged = threeWayMerge(plan.mergeBase, ours, plan.theirs);
1941
1976
  await mkdir(dirname2(oursPath), { recursive: true });
@@ -3151,6 +3186,7 @@ var NoPatchesFlow = class {
3151
3186
  results = await this.service.applicator.applyPatches(newPatches);
3152
3187
  this.service._lastApplyResults = results;
3153
3188
  this.service.recordDroppedContextLineWarnings(results);
3189
+ this.service.recordUnreachableBaseWarnings(results);
3154
3190
  this.service.revertConflictingFiles(results);
3155
3191
  for (const result of results) {
3156
3192
  if (result.status === "conflict") {
@@ -3306,6 +3342,7 @@ var NormalRegenerationFlow = class {
3306
3342
  const results = await this.service.applicator.applyPatches(allPatches);
3307
3343
  this.service._lastApplyResults = results;
3308
3344
  this.service.recordDroppedContextLineWarnings(results);
3345
+ this.service.recordUnreachableBaseWarnings(results);
3309
3346
  this.service.revertConflictingFiles(results);
3310
3347
  for (const result of results) {
3311
3348
  if (result.status === "conflict") {
@@ -4225,6 +4262,37 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
4225
4262
  }
4226
4263
  }
4227
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
+ }
4228
4296
  /**
4229
4297
  * After applyPatches(), strip conflict markers from conflicting files
4230
4298
  * so only clean content is committed. Keeps the Generated (OURS) side.