@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 CHANGED
@@ -5379,6 +5379,11 @@ var init_GitClient = __esm({
5379
5379
  proc.stderr.on("data", (data) => {
5380
5380
  stderr += data.toString();
5381
5381
  });
5382
+ proc.on("error", (err) => {
5383
+ reject(new Error(`git ${args.join(" ")} failed to spawn: ${err.message}`));
5384
+ });
5385
+ proc.stdin.on("error", () => {
5386
+ });
5382
5387
  proc.on("close", (code) => {
5383
5388
  if (code === 0) {
5384
5389
  resolve2(stdout);
@@ -14981,7 +14986,8 @@ function parseRevertedMessage(subject) {
14981
14986
  var INFRASTRUCTURE_FILES = /* @__PURE__ */ new Set([".fernignore"]);
14982
14987
  function matchesFernignorePattern(filePath, patterns) {
14983
14988
  if (patterns.length === 0) return false;
14984
- return patterns.some((p) => {
14989
+ return patterns.some((rawPattern) => {
14990
+ const p = rawPattern.endsWith("/") ? rawPattern.slice(0, -1) : rawPattern;
14985
14991
  if (filePath === p) return true;
14986
14992
  if (minimatch(filePath, p)) return true;
14987
14993
  if (!p.includes("*") && !p.includes("?") && filePath.startsWith(p + "/")) return true;
@@ -15081,6 +15087,22 @@ var ReplayDetector = class {
15081
15087
  return commit.sha;
15082
15088
  }
15083
15089
  }
15090
+ let fullLog;
15091
+ try {
15092
+ fullLog = await this.git.exec([
15093
+ "log",
15094
+ "--no-merges",
15095
+ "--format=%H%x00%an%x00%ae%x00%s",
15096
+ "HEAD"
15097
+ ]);
15098
+ } catch {
15099
+ return null;
15100
+ }
15101
+ for (const commit of this.parseGitLog(fullLog)) {
15102
+ if (isGenerationBoundary(commit)) {
15103
+ return commit.sha;
15104
+ }
15105
+ }
15084
15106
  return null;
15085
15107
  }
15086
15108
  async detectNewPatches() {
@@ -15092,6 +15114,11 @@ var ReplayDetector = class {
15092
15114
  }
15093
15115
  const lastGen = this.getLastGeneration(lock);
15094
15116
  if (!lastGen) {
15117
+ if (lock.current_generation) {
15118
+ this.warnings.push(
15119
+ `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.`
15120
+ );
15121
+ }
15095
15122
  return { patches: [], revertedPatchIds: [] };
15096
15123
  }
15097
15124
  const exists2 = await this.git.commitExists(lastGen.commit_sha);
@@ -15139,20 +15166,15 @@ var ReplayDetector = class {
15139
15166
  continue;
15140
15167
  }
15141
15168
  const parents = await this.git.getCommitParents(commit.sha);
15142
- let patchContent;
15169
+ let filesOutput;
15143
15170
  try {
15144
- patchContent = await this.git.formatPatch(commit.sha);
15171
+ filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
15145
15172
  } catch {
15146
15173
  this.warnings.push(
15147
15174
  `Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
15148
15175
  );
15149
15176
  continue;
15150
15177
  }
15151
- let contentHash = this.computeContentHash(patchContent);
15152
- if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
15153
- continue;
15154
- }
15155
- const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
15156
15178
  const { files, sensitiveFiles, fernignoredFiles } = filterInfrastructureAndSensitiveFiles(
15157
15179
  filesOutput,
15158
15180
  this.fernignorePatterns
@@ -15167,19 +15189,31 @@ var ReplayDetector = class {
15167
15189
  `.fernignore-protected file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${fernignoredFiles.join(", ")}. These files are managed by .fernignore, not Replay.`
15168
15190
  );
15169
15191
  }
15170
- if ((sensitiveFiles.length > 0 || fernignoredFiles.length > 0) && files.length > 0) {
15171
- const parentSha = parents[0];
15172
- if (parentSha) {
15173
- try {
15174
- patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
15175
- } catch {
15176
- continue;
15177
- }
15178
- if (!patchContent.trim()) continue;
15179
- contentHash = this.computeContentHash(patchContent);
15192
+ if (files.length === 0) {
15193
+ continue;
15194
+ }
15195
+ const wasFiltered = sensitiveFiles.length > 0 || fernignoredFiles.length > 0;
15196
+ const parentSha = parents[0];
15197
+ let patchContent;
15198
+ if (wasFiltered && parentSha) {
15199
+ try {
15200
+ patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
15201
+ } catch {
15202
+ continue;
15203
+ }
15204
+ if (!patchContent.trim()) continue;
15205
+ } else {
15206
+ try {
15207
+ patchContent = await this.git.formatPatch(commit.sha);
15208
+ } catch {
15209
+ this.warnings.push(
15210
+ `Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
15211
+ );
15212
+ continue;
15180
15213
  }
15181
15214
  }
15182
- if (files.length === 0) {
15215
+ const contentHash = this.computeContentHash(patchContent);
15216
+ if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
15183
15217
  continue;
15184
15218
  }
15185
15219
  const theirsSnapshot = await capturePatchSnapshot(this.git, files, commit.sha);
@@ -15712,16 +15746,20 @@ async function resolveInputs(args) {
15712
15746
  const oursPath = (0, import_node_path4.join)(outputDir, resolvedPath);
15713
15747
  const ours = await (0, import_promises.readFile)(oursPath, "utf-8").catch(() => null);
15714
15748
  let ghostTheirs = null;
15715
- if (!base && ours && !renameSourcePath) {
15749
+ let baseTreeUnreachable = false;
15750
+ if (!base && !renameSourcePath) {
15716
15751
  const treeReachable = await isTreeReachable(baseGen.tree_hash);
15717
15752
  if (!treeReachable) {
15718
- const fileDiff = extractFileDiff2(patch.patch_content, filePath);
15719
- if (fileDiff) {
15720
- const { reconstructFromGhostPatch: reconstructFromGhostPatch2 } = await Promise.resolve().then(() => (init_HybridReconstruction(), HybridReconstruction_exports));
15721
- const result = reconstructFromGhostPatch2(fileDiff, ours);
15722
- if (result) {
15723
- base = result.base;
15724
- ghostTheirs = result.theirs;
15753
+ baseTreeUnreachable = true;
15754
+ if (ours) {
15755
+ const fileDiff = extractFileDiff2(patch.patch_content, filePath);
15756
+ if (fileDiff) {
15757
+ const { reconstructFromGhostPatch: reconstructFromGhostPatch2 } = await Promise.resolve().then(() => (init_HybridReconstruction(), HybridReconstruction_exports));
15758
+ const result = reconstructFromGhostPatch2(fileDiff, ours);
15759
+ if (result) {
15760
+ base = result.base;
15761
+ ghostTheirs = result.theirs;
15762
+ }
15725
15763
  }
15726
15764
  }
15727
15765
  }
@@ -15733,7 +15771,11 @@ async function resolveInputs(args) {
15733
15771
  ours,
15734
15772
  oursPath,
15735
15773
  renameSourcePath,
15736
- ghostTheirs
15774
+ ghostTheirs,
15775
+ // Only meaningful when ghost reconstruction did NOT recover a base; if it
15776
+ // did, `base` is non-null and the merge proceeds normally. We still set
15777
+ // the flag truthfully (the tree IS unreachable) so Phase 3 can decide.
15778
+ baseTreeUnreachable
15737
15779
  };
15738
15780
  }
15739
15781
 
@@ -16299,7 +16341,11 @@ async function buildMergePlan(args) {
16299
16341
  return { kind: "new-file-only-user", theirs: effective_theirs };
16300
16342
  }
16301
16343
  if (base == null && ours != null && effective_theirs != null && !useAccumulatorAsMergeBase) {
16302
- return { kind: "new-file-both", theirs: effective_theirs };
16344
+ return {
16345
+ kind: "new-file-both",
16346
+ theirs: effective_theirs,
16347
+ ...inputs.baseTreeUnreachable ? { baseTreeUnreachable: true } : {}
16348
+ };
16303
16349
  }
16304
16350
  if (effective_theirs == null) {
16305
16351
  return { kind: "skipped", reason: "missing-content" };
@@ -16347,17 +16393,19 @@ async function runMergeAndRecord(args) {
16347
16393
  const merged2 = threeWayMerge("", ours, plan.theirs);
16348
16394
  await (0, import_promises2.mkdir)((0, import_node_path5.dirname)(oursPath), { recursive: true });
16349
16395
  await (0, import_promises2.writeFile)(oursPath, merged2.content);
16396
+ const unreachableFlag = plan.baseTreeUnreachable ? { baseTreeUnreachable: true } : {};
16350
16397
  if (merged2.hasConflicts) {
16351
16398
  return {
16352
16399
  file: resolvedPath,
16353
16400
  status: "conflict",
16354
16401
  conflicts: merged2.conflicts,
16355
16402
  conflictReason: "new-file-both",
16356
- conflictMetadata: metadata
16403
+ conflictMetadata: metadata,
16404
+ ...unreachableFlag
16357
16405
  };
16358
16406
  }
16359
16407
  accumulator.recordMerge(resolvedPath, merged2.content, patch.base_generation);
16360
- return { file: resolvedPath, status: "merged" };
16408
+ return { file: resolvedPath, status: "merged", ...unreachableFlag };
16361
16409
  }
16362
16410
  const merged = threeWayMerge(plan.mergeBase, ours, plan.theirs);
16363
16411
  await (0, import_promises2.mkdir)((0, import_node_path5.dirname)(oursPath), { recursive: true });
@@ -17565,6 +17613,7 @@ var NoPatchesFlow = class {
17565
17613
  results = await this.service.applicator.applyPatches(newPatches);
17566
17614
  this.service._lastApplyResults = results;
17567
17615
  this.service.recordDroppedContextLineWarnings(results);
17616
+ this.service.recordUnreachableBaseWarnings(results);
17568
17617
  this.service.revertConflictingFiles(results);
17569
17618
  for (const result of results) {
17570
17619
  if (result.status === "conflict") {
@@ -17720,6 +17769,7 @@ var NormalRegenerationFlow = class {
17720
17769
  const results = await this.service.applicator.applyPatches(allPatches);
17721
17770
  this.service._lastApplyResults = results;
17722
17771
  this.service.recordDroppedContextLineWarnings(results);
17772
+ this.service.recordUnreachableBaseWarnings(results);
17723
17773
  this.service.revertConflictingFiles(results);
17724
17774
  for (const result of results) {
17725
17775
  if (result.status === "conflict") {
@@ -18639,6 +18689,37 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
18639
18689
  }
18640
18690
  }
18641
18691
  }
18692
+ /**
18693
+ * After applyPatches(), surface a warning for each (patch × file) where a
18694
+ * customization's BASE could not be read because the patch's
18695
+ * base-generation tree is unreachable in this clone (shallow-clone window
18696
+ * too small / pruned history). In that state the merge pipeline falls
18697
+ * through to `new-file-both` and the customer's edit can be dropped with NO
18698
+ * conflict and NO error — the INC-866 silent-loss class. This converts that
18699
+ * silence into an explicit, actionable warning naming the file and the
18700
+ * likely cause (insufficient clone window).
18701
+ *
18702
+ * Mirrors `recordDroppedContextLineWarnings`'s "surface, never silently
18703
+ * drop" contract. De-duplicated per file path so a single insufficient
18704
+ * window doesn't spam one warning per touched file repeatedly.
18705
+ */
18706
+ /** @internal Used by Flow implementations in `src/flows/`. */
18707
+ recordUnreachableBaseWarnings(results) {
18708
+ const warned = /* @__PURE__ */ new Set();
18709
+ for (const result of results) {
18710
+ if (!result.fileResults) continue;
18711
+ for (const fr of result.fileResults) {
18712
+ if (!fr.baseTreeUnreachable) continue;
18713
+ if (warned.has(fr.file)) continue;
18714
+ warned.add(fr.file);
18715
+ const base = fr.conflictMetadata?.baseGeneration;
18716
+ const baseRef = base ? ` (base generation ${base.slice(0, 7)})` : "";
18717
+ this.warnings.push(
18718
+ `${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.`
18719
+ );
18720
+ }
18721
+ }
18722
+ }
18642
18723
  /**
18643
18724
  * After applyPatches(), strip conflict markers from conflicting files
18644
18725
  * so only clean content is committed. Keeps the Generated (OURS) side.