@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.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((p) => {
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 patchContent;
1051
+ let filesOutput;
1025
1052
  try {
1026
- patchContent = await this.git.formatPatch(commit.sha);
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 ((sensitiveFiles.length > 0 || fernignoredFiles.length > 0) && files.length > 0) {
1053
- const parentSha = parents[0];
1054
- if (parentSha) {
1055
- try {
1056
- patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
1057
- } catch {
1058
- continue;
1059
- }
1060
- if (!patchContent.trim()) continue;
1061
- contentHash = this.computeContentHash(patchContent);
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
- if (files.length === 0) {
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
- if (!base && ours && !renameSourcePath) {
1765
+ let baseTreeUnreachable = false;
1766
+ if (!base && !renameSourcePath) {
1732
1767
  const treeReachable = await isTreeReachable(baseGen.tree_hash);
1733
1768
  if (!treeReachable) {
1734
- const fileDiff = extractFileDiff2(patch.patch_content, filePath);
1735
- if (fileDiff) {
1736
- const { reconstructFromGhostPatch: reconstructFromGhostPatch2 } = await Promise.resolve().then(() => (init_HybridReconstruction(), HybridReconstruction_exports));
1737
- const result = reconstructFromGhostPatch2(fileDiff, ours);
1738
- if (result) {
1739
- base = result.base;
1740
- ghostTheirs = result.theirs;
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 { kind: "new-file-both", theirs: effective_theirs };
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.