@fern-api/fern-api-dev 4.75.0 → 4.76.0-1-g4629aee782f

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.
Files changed (2) hide show
  1. package/cli.cjs +660 -87
  2. package/package.json +1 -1
package/cli.cjs CHANGED
@@ -345167,6 +345167,7 @@ var require_GithubStep = __commonJS({
345167
345167
  exports2.GithubStep = void 0;
345168
345168
  exports2.enrichPrBodyForAutomation = enrichPrBodyForAutomation;
345169
345169
  exports2.shouldEnableAutomerge = shouldEnableAutomerge;
345170
+ exports2.shouldPushGenerationBaseTag = shouldPushGenerationBaseTag;
345170
345171
  exports2.resolveBranchAction = resolveBranchAction;
345171
345172
  var core_utils_1 = (init_lib4(), __toCommonJS(lib_exports));
345172
345173
  var github_1 = (init_lib7(), __toCommonJS(lib_exports3));
@@ -345246,7 +345247,11 @@ var require_GithubStep = __commonJS({
345246
345247
  });
345247
345248
  switch (branchAction) {
345248
345249
  case "replay-branch":
345249
- generationBaseSha = await (0, createReplayBranch_1.createReplayBranch)(repository, prBranch, this.config.commitMessage, replayConflictInfo, this.logger);
345250
+ if (shouldPushGenerationBaseTag(replayResult)) {
345251
+ generationBaseSha = await (0, createReplayBranch_1.createReplayBranch)(repository, prBranch, this.config.commitMessage, replayConflictInfo, this.logger);
345252
+ } else {
345253
+ await repository.createBranchFromHead(prBranch);
345254
+ }
345250
345255
  break;
345251
345256
  case "create-from-head":
345252
345257
  await repository.createBranchFromHead(prBranch);
@@ -345444,6 +345449,9 @@ _Fern Run ID: \`${config5.runId}\`_`;
345444
345449
  function shouldEnableAutomerge(config5) {
345445
345450
  return config5.automationMode === true && config5.autoMerge === true && config5.hasBreakingChanges !== true;
345446
345451
  }
345452
+ function shouldPushGenerationBaseTag(replayResult) {
345453
+ return (replayResult?.patchesWithConflicts ?? 0) > 0;
345454
+ }
345447
345455
  function resolveBranchAction({ automationMode, skipCommit, existingPR }) {
345448
345456
  if (skipCommit) {
345449
345457
  return "replay-branch";
@@ -350912,9 +350920,9 @@ var require_diff3 = __commonJS({
350912
350920
  }
350913
350921
  });
350914
350922
 
350915
- // ../../../node_modules/.pnpm/@fern-api+replay@0.10.4/node_modules/@fern-api/replay/dist/index.cjs
350923
+ // ../../../node_modules/.pnpm/@fern-api+replay@0.11.0/node_modules/@fern-api/replay/dist/index.cjs
350916
350924
  var require_dist14 = __commonJS({
350917
- "../../../node_modules/.pnpm/@fern-api+replay@0.10.4/node_modules/@fern-api/replay/dist/index.cjs"(exports2, module4) {
350925
+ "../../../node_modules/.pnpm/@fern-api+replay@0.11.0/node_modules/@fern-api/replay/dist/index.cjs"(exports2, module4) {
350918
350926
  "use strict";
350919
350927
  var __defProp4 = Object.defineProperty;
350920
350928
  var __getOwnPropDesc4 = Object.getOwnPropertyDescriptor;
@@ -351005,13 +351013,30 @@ var require_dist14 = __commonJS({
351005
351013
  const output2 = await this.exec(["rev-parse", `${commitSha}^@`]);
351006
351014
  return output2.trim().split("\n").filter(Boolean);
351007
351015
  }
351016
+ /**
351017
+ * Detects renames and copies between two trees.
351018
+ *
351019
+ * Both `R` (rename) and `C` (copy) entries are returned as `{from, to}` pairs.
351020
+ * Copies are treated as rename-equivalent because the generator may produce the
351021
+ * new path while the old path remains in the tree (e.g., imperfect cleanup,
351022
+ * customer edits at the old path, or test helpers that only write new files).
351023
+ * Callers that care about "is the source still present" should verify via
351024
+ * `showFile(toTree, from)`.
351025
+ */
351008
351026
  async detectRenames(fromTree, toTree) {
351009
351027
  try {
351010
- const output2 = await this.exec(["diff", "--find-renames", "--name-status", fromTree, toTree]);
351028
+ const output2 = await this.exec([
351029
+ "diff",
351030
+ "--find-renames",
351031
+ "--find-copies",
351032
+ "--name-status",
351033
+ fromTree,
351034
+ toTree
351035
+ ]);
351011
351036
  const renames = [];
351012
351037
  for (const line of output2.trim().split("\n")) {
351013
351038
  if (!line) continue;
351014
- if (line.startsWith("R")) {
351039
+ if (line.startsWith("R") || line.startsWith("C")) {
351015
351040
  const parts = line.split(" ");
351016
351041
  if (parts.length >= 3) {
351017
351042
  renames.push({ from: parts[1], to: parts[2] });
@@ -351305,6 +351330,7 @@ var require_dist14 = __commonJS({
351305
351330
  });
351306
351331
  var index_exports = {};
351307
351332
  __export4(index_exports, {
351333
+ CREDENTIAL_PATTERNS: () => CREDENTIAL_PATTERNS,
351308
351334
  FERN_BOT_EMAIL: () => FERN_BOT_EMAIL,
351309
351335
  FERN_BOT_LOGIN: () => FERN_BOT_LOGIN,
351310
351336
  FERN_BOT_NAME: () => FERN_BOT_NAME,
@@ -351316,19 +351342,53 @@ var require_dist14 = __commonJS({
351316
351342
  ReplayCommitter: () => ReplayCommitter,
351317
351343
  ReplayDetector: () => ReplayDetector,
351318
351344
  ReplayService: () => ReplayService,
351345
+ SENSITIVE_FILE_PATTERNS: () => SENSITIVE_FILE_PATTERNS,
351319
351346
  bootstrap: () => bootstrap,
351320
351347
  forget: () => forget,
351321
351348
  isGenerationCommit: () => isGenerationCommit,
351322
351349
  isReplayCommit: () => isReplayCommit,
351323
351350
  isRevertCommit: () => isRevertCommit,
351351
+ isSensitiveFile: () => isSensitiveFile,
351324
351352
  parseRevertedMessage: () => parseRevertedMessage,
351325
351353
  parseRevertedSha: () => parseRevertedSha,
351326
351354
  reset: () => reset2,
351327
351355
  resolve: () => resolve13,
351356
+ scanForCredentials: () => scanForCredentials,
351328
351357
  status: () => status,
351329
351358
  threeWayMerge: () => threeWayMerge
351330
351359
  });
351331
351360
  module4.exports = __toCommonJS3(index_exports);
351361
+ var CREDENTIAL_PATTERNS = [
351362
+ { name: "PRIVATE KEY block", re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
351363
+ { name: "OPENSSH key", re: /-----BEGIN OPENSSH PRIVATE KEY-----/ },
351364
+ { name: "RSA key", re: /-----BEGIN RSA PRIVATE KEY-----/ },
351365
+ { name: "CERTIFICATE", re: /-----BEGIN CERTIFICATE-----/ },
351366
+ { name: "TOKEN=", re: /\bTOKEN\s*=\s*["']?[A-Za-z0-9._\-+/=]{10,}/ },
351367
+ { name: "api_key=", re: /\bapi[_-]?key\s*=\s*["']?[A-Za-z0-9._\-+/=]{10,}/i },
351368
+ { name: "password=", re: /\bpassword\s*=\s*["']?[^\s"']{6,}/i },
351369
+ { name: "AWS_SECRET_ACCESS_KEY", re: /AWS_SECRET_ACCESS_KEY/ },
351370
+ { name: "JWT (eyJ...)", re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/ }
351371
+ ];
351372
+ var SENSITIVE_FILE_PATTERNS = [
351373
+ /(^|\/)\.env(\.[^/]+)?$/,
351374
+ /\.pem$/,
351375
+ /\.key$/,
351376
+ /\.pkce_state\.json$/,
351377
+ /(^|\/)id_rsa$/,
351378
+ /(^|\/)id_ed25519$/
351379
+ ];
351380
+ function isSensitiveFile(filePath) {
351381
+ return SENSITIVE_FILE_PATTERNS.some((re17) => re17.test(filePath));
351382
+ }
351383
+ function scanForCredentials(content5) {
351384
+ const matches2 = [];
351385
+ for (const { name: name2, re: re17 } of CREDENTIAL_PATTERNS) {
351386
+ if (re17.test(content5)) {
351387
+ matches2.push(name2);
351388
+ }
351389
+ }
351390
+ return matches2;
351391
+ }
351332
351392
  init_GitClient();
351333
351393
  var FERN_BOT_NAME = "fern-api";
351334
351394
  var FERN_BOT_EMAIL = "115122769+fern-api[bot]@users.noreply.github.com";
@@ -351370,6 +351430,7 @@ var require_dist14 = __commonJS({
351370
351430
  var LockfileManager = class {
351371
351431
  outputDir;
351372
351432
  lock = null;
351433
+ warnings = [];
351373
351434
  constructor(outputDir) {
351374
351435
  this.outputDir = outputDir;
351375
351436
  }
@@ -351418,6 +351479,27 @@ var require_dist14 = __commonJS({
351418
351479
  if (!this.lock) {
351419
351480
  throw new Error("No lockfile data to save. Call read() or initialize() first.");
351420
351481
  }
351482
+ this.warnings.length = 0;
351483
+ const cleanPatches = [];
351484
+ for (const patch5 of this.lock.patches) {
351485
+ const credentialMatches = scanForCredentials(patch5.patch_content);
351486
+ const sensitiveFiles = patch5.files.filter((f10) => isSensitiveFile(f10));
351487
+ if (credentialMatches.length > 0 || sensitiveFiles.length > 0) {
351488
+ const reasons = [];
351489
+ if (credentialMatches.length > 0) {
351490
+ reasons.push(`credential patterns: ${credentialMatches.join(", ")}`);
351491
+ }
351492
+ if (sensitiveFiles.length > 0) {
351493
+ reasons.push(`sensitive files: ${sensitiveFiles.join(", ")}`);
351494
+ }
351495
+ this.warnings.push(
351496
+ `Patch ${patch5.id} removed from lockfile: ${reasons.join("; ")}. This prevents credential exposure in committed lockfiles.`
351497
+ );
351498
+ } else {
351499
+ cleanPatches.push(patch5);
351500
+ }
351501
+ }
351502
+ this.lock.patches = cleanPatches;
351421
351503
  const dir = (0, import_node_path41.dirname)(this.lockfilePath);
351422
351504
  if (!(0, import_node_fs32.existsSync)(dir)) {
351423
351505
  (0, import_node_fs32.mkdirSync)(dir, { recursive: true });
@@ -351520,6 +351602,37 @@ var require_dist14 = __commonJS({
351520
351602
  };
351521
351603
  var import_node_crypto7 = require("crypto");
351522
351604
  var INFRASTRUCTURE_FILES = /* @__PURE__ */ new Set([".fernignore"]);
351605
+ function parsePatchFileHeaders(patchContent) {
351606
+ const results = [];
351607
+ let currentPath = null;
351608
+ let currentIsCreate = false;
351609
+ let currentIsDelete = false;
351610
+ const flush2 = () => {
351611
+ if (currentPath !== null) {
351612
+ results.push({ path: currentPath, isCreate: currentIsCreate, isDelete: currentIsDelete });
351613
+ }
351614
+ };
351615
+ for (const line of patchContent.split("\n")) {
351616
+ if (line.startsWith("diff --git ")) {
351617
+ flush2();
351618
+ currentPath = null;
351619
+ currentIsCreate = false;
351620
+ currentIsDelete = false;
351621
+ const match3 = line.match(/^diff --git a\/(.+?) b\/(.+?)$/);
351622
+ if (match3) {
351623
+ currentPath = match3[2] ?? null;
351624
+ }
351625
+ } else if (currentPath !== null) {
351626
+ if (line.startsWith("new file mode ")) {
351627
+ currentIsCreate = true;
351628
+ } else if (line.startsWith("deleted file mode ")) {
351629
+ currentIsDelete = true;
351630
+ }
351631
+ }
351632
+ }
351633
+ flush2();
351634
+ return results;
351635
+ }
351523
351636
  var ReplayDetector = class {
351524
351637
  git;
351525
351638
  lockManager;
@@ -351598,12 +351711,31 @@ var require_dist14 = __commonJS({
351598
351711
  );
351599
351712
  continue;
351600
351713
  }
351601
- const contentHash = this.computeContentHash(patchContent);
351714
+ let contentHash = this.computeContentHash(patchContent);
351602
351715
  if (lock.patches.find((p10) => p10.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
351603
351716
  continue;
351604
351717
  }
351605
351718
  const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
351606
- const files = filesOutput.trim().split("\n").filter(Boolean).filter((f10) => !INFRASTRUCTURE_FILES.has(f10));
351719
+ const allFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f10) => !INFRASTRUCTURE_FILES.has(f10)).filter((f10) => !f10.startsWith(".fern/"));
351720
+ const sensitiveFiles = allFiles.filter((f10) => isSensitiveFile(f10));
351721
+ const files = allFiles.filter((f10) => !isSensitiveFile(f10));
351722
+ if (sensitiveFiles.length > 0) {
351723
+ this.warnings.push(
351724
+ `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
351725
+ );
351726
+ if (files.length > 0) {
351727
+ const parentSha = parents[0];
351728
+ if (parentSha) {
351729
+ try {
351730
+ patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
351731
+ } catch {
351732
+ continue;
351733
+ }
351734
+ if (!patchContent.trim()) continue;
351735
+ contentHash = this.computeContentHash(patchContent);
351736
+ }
351737
+ }
351738
+ }
351607
351739
  if (files.length === 0) {
351608
351740
  continue;
351609
351741
  }
@@ -351615,14 +351747,16 @@ var require_dist14 = __commonJS({
351615
351747
  original_author: `${commit.authorName} <${commit.authorEmail}>`,
351616
351748
  base_generation: lastGen.commit_sha,
351617
351749
  files,
351618
- patch_content: patchContent
351750
+ patch_content: patchContent,
351751
+ ...this.isCreationOnlyPatch(patchContent) ? { user_owned: true } : {}
351619
351752
  });
351620
351753
  }
351621
- newPatches.reverse();
351754
+ const survivingPatches = await this.collapseNetZeroFiles(newPatches);
351755
+ survivingPatches.reverse();
351622
351756
  const revertedPatchIdSet = /* @__PURE__ */ new Set();
351623
351757
  const revertIndicesToRemove = /* @__PURE__ */ new Set();
351624
- for (let i11 = 0; i11 < newPatches.length; i11++) {
351625
- const patch5 = newPatches[i11];
351758
+ for (let i11 = 0; i11 < survivingPatches.length; i11++) {
351759
+ const patch5 = survivingPatches[i11];
351626
351760
  if (!isRevertCommit(patch5.original_message)) continue;
351627
351761
  let body = "";
351628
351762
  try {
@@ -351651,7 +351785,7 @@ var require_dist14 = __commonJS({
351651
351785
  if (matchedExisting) continue;
351652
351786
  let matchedNew = false;
351653
351787
  if (revertedSha) {
351654
- const idx = newPatches.findIndex(
351788
+ const idx = survivingPatches.findIndex(
351655
351789
  (p10, j16) => j16 !== i11 && !revertIndicesToRemove.has(j16) && p10.original_commit === revertedSha
351656
351790
  );
351657
351791
  if (idx !== -1) {
@@ -351661,7 +351795,7 @@ var require_dist14 = __commonJS({
351661
351795
  }
351662
351796
  }
351663
351797
  if (!matchedNew && revertedMessage) {
351664
- const idx = newPatches.findIndex(
351798
+ const idx = survivingPatches.findIndex(
351665
351799
  (p10, j16) => j16 !== i11 && !revertIndicesToRemove.has(j16) && p10.original_message === revertedMessage
351666
351800
  );
351667
351801
  if (idx !== -1) {
@@ -351673,18 +351807,107 @@ var require_dist14 = __commonJS({
351673
351807
  revertIndicesToRemove.add(i11);
351674
351808
  }
351675
351809
  }
351676
- const filteredPatches = newPatches.filter((_16, i11) => !revertIndicesToRemove.has(i11));
351810
+ const filteredPatches = survivingPatches.filter((_16, i11) => !revertIndicesToRemove.has(i11));
351677
351811
  return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
351678
351812
  }
351679
351813
  /**
351680
351814
  * Compute content hash for deduplication.
351681
- * Removes commit SHA line and index lines before hashing,
351682
- * so rebased commits with same content produce the same hash.
351815
+ *
351816
+ * Produces a format-agnostic hash so both `git format-patch` output
351817
+ * (email-wrapped) and plain `git diff` output hash to the same value
351818
+ * when the underlying diff hunks are identical.
351819
+ *
351820
+ * Normalization:
351821
+ * 1. Strip email wrapper: everything before the first `diff --git` line
351822
+ * (From:, Subject:, Date:, diffstat, blank separators).
351823
+ * 2. Strip `index` lines (blob SHA pairs change across rebases).
351824
+ * 3. Strip trailing git version marker (`-- \n<version>`).
351825
+ *
351826
+ * This ensures content hashes match across the no-patches and normal-
351827
+ * regeneration flows, which store formatPatch and git-diff formats
351828
+ * respectively (FER-9850, D5-D9).
351683
351829
  */
351684
351830
  computeContentHash(patchContent) {
351685
- const normalized = patchContent.split("\n").filter((line) => !line.startsWith("From ") && !line.startsWith("index ") && !line.startsWith("Date: ")).join("\n");
351831
+ const lines = patchContent.split("\n");
351832
+ const diffStart = lines.findIndex((l14) => l14.startsWith("diff --git "));
351833
+ const relevantLines = diffStart > 0 ? lines.slice(diffStart) : lines;
351834
+ const normalized = relevantLines.filter((line) => !line.startsWith("index ")).join("\n").replace(/\n-- \n[\d]+\.[\d]+[^\n]*\s*$/, "");
351686
351835
  return `sha256:${(0, import_node_crypto7.createHash)("sha256").update(normalized).digest("hex")}`;
351687
351836
  }
351837
+ /**
351838
+ * FER-9805 — Drop patches whose files were transiently created and then
351839
+ * deleted within the current linear detection window, and are absent from
351840
+ * HEAD at the end of the window.
351841
+ *
351842
+ * Rationale: on a merge commit, createMergeCompositePatch already diffs
351843
+ * firstParent..mergeCommit so net-zero files never enter the composite. On
351844
+ * linear history each commit becomes its own patch, so a file that was
351845
+ * briefly added and later removed survives as a create+delete pair whose
351846
+ * cumulative diff is empty. We drop such patches before they reach the
351847
+ * lockfile.
351848
+ *
351849
+ * A file is "transient" when:
351850
+ * 1. some patch in the window sets `new file mode` for it (a creation), AND
351851
+ * 2. some patch in the window sets `deleted file mode` for it (a deletion), AND
351852
+ * 3. it is absent from HEAD's tree.
351853
+ *
351854
+ * The HEAD guard (#3) prevents false positives when a file is deleted
351855
+ * and then recreated with different content — the cumulative diff of
351856
+ * such a pair is non-empty and must be preserved.
351857
+ *
351858
+ * This only drops patches whose `files` are a subset of the transient
351859
+ * set. A partial-transient patch (one that touches a transient file
351860
+ * alongside a persistent one) is left unmodified; surgical stripping of
351861
+ * single files from a multi-file patch would require a follow-up that
351862
+ * regenerates the patch content via `git format-patch -- <files>`. No
351863
+ * current failing test exercises this, and leaving the patch intact
351864
+ * preserves today's behaviour. TODO(FER-9805): revisit if a future
351865
+ * adversarial test demands per-file stripping.
351866
+ */
351867
+ async collapseNetZeroFiles(patches) {
351868
+ if (patches.length === 0) return patches;
351869
+ const stateByFile = /* @__PURE__ */ new Map();
351870
+ for (const patch5 of patches) {
351871
+ for (const header of parsePatchFileHeaders(patch5.patch_content)) {
351872
+ if (!header.isCreate && !header.isDelete) continue;
351873
+ const prior = stateByFile.get(header.path) ?? { created: false, deleted: false };
351874
+ if (header.isCreate) prior.created = true;
351875
+ if (header.isDelete) prior.deleted = true;
351876
+ stateByFile.set(header.path, prior);
351877
+ }
351878
+ }
351879
+ let anyCandidate = false;
351880
+ for (const state of stateByFile.values()) {
351881
+ if (state.created && state.deleted) {
351882
+ anyCandidate = true;
351883
+ break;
351884
+ }
351885
+ }
351886
+ if (!anyCandidate) return patches;
351887
+ const headFiles = await this.listHeadFiles();
351888
+ const transient = /* @__PURE__ */ new Set();
351889
+ for (const [file4, state] of stateByFile) {
351890
+ if (state.created && state.deleted && !headFiles.has(file4)) {
351891
+ transient.add(file4);
351892
+ }
351893
+ }
351894
+ if (transient.size === 0) return patches;
351895
+ return patches.filter((patch5) => !patch5.files.every((f10) => transient.has(f10)));
351896
+ }
351897
+ /**
351898
+ * List every tracked path at HEAD (one `git ls-tree -r` invocation).
351899
+ * Returns an empty Set if HEAD is unborn or the invocation fails — the
351900
+ * caller then treats every candidate as "not in HEAD" and may collapse
351901
+ * more aggressively. On typical SDK repos this is a single sub-10ms call.
351902
+ */
351903
+ async listHeadFiles() {
351904
+ try {
351905
+ const out = await this.git.exec(["ls-tree", "-r", "--name-only", "HEAD"]);
351906
+ return new Set(out.split("\n").map((l14) => l14.trim()).filter(Boolean));
351907
+ } catch {
351908
+ return /* @__PURE__ */ new Set();
351909
+ }
351910
+ }
351688
351911
  /**
351689
351912
  * Create a single composite patch for a merge commit by diffing the merge
351690
351913
  * commit against its first parent. This captures the net change of the
@@ -351700,7 +351923,14 @@ var require_dist14 = __commonJS({
351700
351923
  firstParent,
351701
351924
  mergeCommit.sha
351702
351925
  ]);
351703
- const files = filesOutput.trim().split("\n").filter(Boolean).filter((f10) => !INFRASTRUCTURE_FILES.has(f10)).filter((f10) => !f10.startsWith(".fern/"));
351926
+ const allMergeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f10) => !INFRASTRUCTURE_FILES.has(f10)).filter((f10) => !f10.startsWith(".fern/"));
351927
+ const sensitiveMergeFiles = allMergeFiles.filter((f10) => isSensitiveFile(f10));
351928
+ const files = allMergeFiles.filter((f10) => !isSensitiveFile(f10));
351929
+ if (sensitiveMergeFiles.length > 0) {
351930
+ this.warnings.push(
351931
+ `Sensitive file(s) excluded from merge patch for commit ${mergeCommit.sha.slice(0, 7)}: ${sensitiveMergeFiles.join(", ")}. These files will not be tracked by replay.`
351932
+ );
351933
+ }
351704
351934
  if (files.length === 0) return null;
351705
351935
  const diff2 = await this.git.exec([
351706
351936
  "diff",
@@ -351722,7 +351952,8 @@ var require_dist14 = __commonJS({
351722
351952
  original_author: `${mergeCommit.authorName} <${mergeCommit.authorEmail}>`,
351723
351953
  base_generation: lastGen.commit_sha,
351724
351954
  files,
351725
- patch_content: diff2
351955
+ patch_content: diff2,
351956
+ ...this.isCreationOnlyPatch(diff2) ? { user_owned: true } : {}
351726
351957
  };
351727
351958
  }
351728
351959
  /**
@@ -351736,7 +351967,14 @@ var require_dist14 = __commonJS({
351736
351967
  return this.detectPatchesViaCommitScan();
351737
351968
  }
351738
351969
  const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
351739
- const files = filesOutput.trim().split("\n").filter(Boolean).filter((f10) => !INFRASTRUCTURE_FILES.has(f10)).filter((f10) => !f10.startsWith(".fern/"));
351970
+ const allTreeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f10) => !INFRASTRUCTURE_FILES.has(f10)).filter((f10) => !f10.startsWith(".fern/"));
351971
+ const sensitiveTreeFiles = allTreeFiles.filter((f10) => isSensitiveFile(f10));
351972
+ const files = allTreeFiles.filter((f10) => !isSensitiveFile(f10));
351973
+ if (sensitiveTreeFiles.length > 0) {
351974
+ this.warnings.push(
351975
+ `Sensitive file(s) excluded from composite patch: ${sensitiveTreeFiles.join(", ")}. These files will not be tracked by replay.`
351976
+ );
351977
+ }
351740
351978
  if (files.length === 0) return { patches: [], revertedPatchIds: [] };
351741
351979
  const diff2 = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
351742
351980
  if (!diff2.trim()) return { patches: [], revertedPatchIds: [] };
@@ -351756,7 +351994,8 @@ var require_dist14 = __commonJS({
351756
351994
  // reference to find base file content. diffBase may be the tree_hash.
351757
351995
  base_generation: commitKnownMissing ? diffBase : lastGen.commit_sha,
351758
351996
  files,
351759
- patch_content: diff2
351997
+ patch_content: diff2,
351998
+ ...this.isCreationOnlyPatch(diff2) ? { user_owned: true } : {}
351760
351999
  };
351761
352000
  return { patches: [compositePatch], revertedPatchIds: [] };
351762
352001
  }
@@ -351803,7 +352042,7 @@ var require_dist14 = __commonJS({
351803
352042
  } catch {
351804
352043
  continue;
351805
352044
  }
351806
- const contentHash = this.computeContentHash(patchContent);
352045
+ let contentHash = this.computeContentHash(patchContent);
351807
352046
  if (existingHashes.has(contentHash) || forgottenHashes.has(contentHash)) {
351808
352047
  continue;
351809
352048
  }
@@ -351811,7 +352050,26 @@ var require_dist14 = __commonJS({
351811
352050
  continue;
351812
352051
  }
351813
352052
  const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
351814
- const files = filesOutput.trim().split("\n").filter(Boolean).filter((f10) => !INFRASTRUCTURE_FILES.has(f10));
352053
+ const allScanFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f10) => !INFRASTRUCTURE_FILES.has(f10));
352054
+ const sensitiveScanFiles = allScanFiles.filter((f10) => isSensitiveFile(f10));
352055
+ const files = allScanFiles.filter((f10) => !isSensitiveFile(f10));
352056
+ if (sensitiveScanFiles.length > 0) {
352057
+ this.warnings.push(
352058
+ `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveScanFiles.join(", ")}. These files will not be tracked by replay.`
352059
+ );
352060
+ if (files.length > 0) {
352061
+ if (parents.length === 0) {
352062
+ continue;
352063
+ }
352064
+ try {
352065
+ patchContent = await this.git.exec(["diff", parents[0], commit.sha, "--", ...files]);
352066
+ } catch {
352067
+ continue;
352068
+ }
352069
+ if (!patchContent.trim()) continue;
352070
+ contentHash = this.computeContentHash(patchContent);
352071
+ }
352072
+ }
351815
352073
  if (files.length === 0) {
351816
352074
  continue;
351817
352075
  }
@@ -352135,6 +352393,20 @@ var require_dist14 = __commonJS({
352135
352393
  resetAccumulator() {
352136
352394
  this.fileTheirsAccumulator.clear();
352137
352395
  }
352396
+ /**
352397
+ * @internal Test-only.
352398
+ * Returns a defensive copy of the inter-patch accumulator. Used by the
352399
+ * adversarial test suite (FER-9791) to assert invariant I2: after every
352400
+ * applied patch, the accumulator has an entry for each file the patch
352401
+ * touched, and the entry's content matches on-disk.
352402
+ */
352403
+ getAccumulatorSnapshot() {
352404
+ const snapshot = /* @__PURE__ */ new Map();
352405
+ for (const [path98, entry] of this.fileTheirsAccumulator) {
352406
+ snapshot.set(path98, { content: entry.content, baseGeneration: entry.baseGeneration });
352407
+ }
352408
+ return snapshot;
352409
+ }
352138
352410
  /**
352139
352411
  * Apply all patches, returning results for each.
352140
352412
  * Skips patches that match exclude patterns in replay.yml
@@ -352200,7 +352472,7 @@ var require_dist14 = __commonJS({
352200
352472
  const base4 = await this.git.showFile(baseGen.tree_hash, filePath);
352201
352473
  const theirs = await this.applyPatchToContent(base4, patch5.patch_content, filePath, tempGit, tempDir2);
352202
352474
  const effectiveTheirs = theirs ?? await (0, import_promises183.readFile)((0, import_node_path210.join)(this.outputDir, resolvedPath), "utf-8").catch(() => null);
352203
- if (effectiveTheirs) {
352475
+ if (effectiveTheirs != null) {
352204
352476
  this.fileTheirsAccumulator.set(resolvedPath, {
352205
352477
  content: effectiveTheirs,
352206
352478
  baseGeneration: patch5.base_generation
@@ -352224,16 +352496,20 @@ var require_dist14 = __commonJS({
352224
352496
  return this.fileTheirsAccumulator.has(resolved);
352225
352497
  })
352226
352498
  ).then((results) => results.some(Boolean));
352227
- if (!needsAccumulation) {
352499
+ const resolvedFiles = {};
352500
+ for (const filePath of patch5.files) {
352501
+ const resolvedPath = baseGen ? await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash) : filePath;
352502
+ if (resolvedPath !== filePath) {
352503
+ resolvedFiles[filePath] = resolvedPath;
352504
+ }
352505
+ }
352506
+ const patchIsRenameAware = /^rename from /m.test(patch5.patch_content);
352507
+ const hasGeneratorRename = Object.keys(resolvedFiles).length > 0 && !patchIsRenameAware;
352508
+ if (!needsAccumulation && !hasGeneratorRename) {
352228
352509
  const snapshots = /* @__PURE__ */ new Map();
352229
- const resolvedFiles = {};
352230
352510
  for (const filePath of patch5.files) {
352231
- const resolvedPath = baseGen ? await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash) : filePath;
352232
- if (resolvedPath !== filePath) {
352233
- resolvedFiles[filePath] = resolvedPath;
352234
- }
352235
- const fullPath = (0, import_node_path210.join)(this.outputDir, resolvedPath);
352236
- snapshots.set(resolvedPath, await (0, import_promises183.readFile)(fullPath, "utf-8").catch(() => null));
352511
+ const fullPath = (0, import_node_path210.join)(this.outputDir, filePath);
352512
+ snapshots.set(filePath, await (0, import_promises183.readFile)(fullPath, "utf-8").catch(() => null));
352237
352513
  }
352238
352514
  try {
352239
352515
  await this.git.execWithInput(["apply", "--3way"], patch5.patch_content);
@@ -352245,8 +352521,8 @@ var require_dist14 = __commonJS({
352245
352521
  ...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
352246
352522
  };
352247
352523
  } catch {
352248
- for (const [resolvedPath, content5] of snapshots) {
352249
- const fullPath = (0, import_node_path210.join)(this.outputDir, resolvedPath);
352524
+ for (const [filePath, content5] of snapshots) {
352525
+ const fullPath = (0, import_node_path210.join)(this.outputDir, filePath);
352250
352526
  if (content5 != null) {
352251
352527
  await (0, import_promises183.writeFile)(fullPath, content5);
352252
352528
  } else {
@@ -352366,7 +352642,7 @@ var require_dist14 = __commonJS({
352366
352642
  }
352367
352643
  let useAccumulatorAsMergeBase = false;
352368
352644
  const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
352369
- if (accumulatorEntry && (!theirs || base4 == null)) {
352645
+ if (accumulatorEntry && (theirs == null || base4 == null)) {
352370
352646
  theirs = await this.applyPatchToContent(
352371
352647
  accumulatorEntry.content,
352372
352648
  patch5.patch_content,
@@ -352374,7 +352650,16 @@ var require_dist14 = __commonJS({
352374
352650
  tempGit,
352375
352651
  tempDir2
352376
352652
  );
352377
- if (theirs) {
352653
+ if (theirs != null) {
352654
+ useAccumulatorAsMergeBase = true;
352655
+ } else if (await this.isPatchAlreadyApplied(
352656
+ patch5.patch_content,
352657
+ filePath,
352658
+ accumulatorEntry.content,
352659
+ tempGit,
352660
+ tempDir2
352661
+ )) {
352662
+ theirs = accumulatorEntry.content;
352378
352663
  useAccumulatorAsMergeBase = true;
352379
352664
  }
352380
352665
  }
@@ -352391,7 +352676,7 @@ var require_dist14 = __commonJS({
352391
352676
  }
352392
352677
  let effective_theirs = theirs;
352393
352678
  let baseMismatchSkipped = false;
352394
- if (theirs && base4 && !useAccumulatorAsMergeBase) {
352679
+ if (theirs != null && base4 != null && !useAccumulatorAsMergeBase) {
352395
352680
  if (accumulatorEntry && accumulatorEntry.baseGeneration === patch5.base_generation) {
352396
352681
  try {
352397
352682
  const preMerged = threeWayMerge(base4, accumulatorEntry.content, theirs);
@@ -352407,7 +352692,7 @@ var require_dist14 = __commonJS({
352407
352692
  baseMismatchSkipped = true;
352408
352693
  }
352409
352694
  }
352410
- if (base4 == null && !ours && effective_theirs) {
352695
+ if (base4 == null && ours == null && effective_theirs != null) {
352411
352696
  this.fileTheirsAccumulator.set(resolvedPath, {
352412
352697
  content: effective_theirs,
352413
352698
  baseGeneration: patch5.base_generation
@@ -352417,7 +352702,7 @@ var require_dist14 = __commonJS({
352417
352702
  await (0, import_promises183.writeFile)(oursPath, effective_theirs);
352418
352703
  return { file: resolvedPath, status: "merged", reason: "new-file" };
352419
352704
  }
352420
- if (base4 == null && ours && effective_theirs && !useAccumulatorAsMergeBase) {
352705
+ if (base4 == null && ours != null && effective_theirs != null && !useAccumulatorAsMergeBase) {
352421
352706
  const merged2 = threeWayMerge("", ours, effective_theirs);
352422
352707
  const outDir2 = (0, import_node_path210.dirname)(oursPath);
352423
352708
  await (0, import_promises183.mkdir)(outDir2, { recursive: true });
@@ -352431,16 +352716,20 @@ var require_dist14 = __commonJS({
352431
352716
  conflictMetadata: metadata
352432
352717
  };
352433
352718
  }
352719
+ this.fileTheirsAccumulator.set(resolvedPath, {
352720
+ content: merged2.content,
352721
+ baseGeneration: patch5.base_generation
352722
+ });
352434
352723
  return { file: resolvedPath, status: "merged" };
352435
352724
  }
352436
- if (!effective_theirs) {
352725
+ if (effective_theirs == null) {
352437
352726
  return {
352438
352727
  file: resolvedPath,
352439
352728
  status: "skipped",
352440
352729
  reason: "missing-content"
352441
352730
  };
352442
352731
  }
352443
- if (base4 == null && !useAccumulatorAsMergeBase || !ours) {
352732
+ if (base4 == null && !useAccumulatorAsMergeBase || ours == null) {
352444
352733
  return {
352445
352734
  file: resolvedPath,
352446
352735
  status: "skipped",
@@ -352459,7 +352748,7 @@ var require_dist14 = __commonJS({
352459
352748
  const outDir = (0, import_node_path210.dirname)(oursPath);
352460
352749
  await (0, import_promises183.mkdir)(outDir, { recursive: true });
352461
352750
  await (0, import_promises183.writeFile)(oursPath, merged.content);
352462
- if (effective_theirs && !merged.hasConflicts) {
352751
+ if (effective_theirs != null && !merged.hasConflicts) {
352463
352752
  this.fileTheirsAccumulator.set(resolvedPath, {
352464
352753
  content: effective_theirs,
352465
352754
  baseGeneration: patch5.base_generation
@@ -352557,6 +352846,28 @@ var require_dist14 = __commonJS({
352557
352846
  return null;
352558
352847
  }
352559
352848
  }
352849
+ /**
352850
+ * Detects whether a patch's additions are already present in `content`.
352851
+ * Uses `git apply --reverse --check` — if the reverse patch applies cleanly,
352852
+ * the forward patch is effectively a no-op (its +lines are already there and
352853
+ * its -lines are already gone). Used to distinguish "patch already applied"
352854
+ * from "patch base mismatch" after a forward-apply fails.
352855
+ */
352856
+ async isPatchAlreadyApplied(patchContent, filePath, content5, tempGit, tempDir2) {
352857
+ const fileDiff = this.extractFileDiff(patchContent, filePath);
352858
+ if (!fileDiff) return false;
352859
+ try {
352860
+ const tempFilePath = (0, import_node_path210.join)(tempDir2, filePath);
352861
+ await (0, import_promises183.mkdir)((0, import_node_path210.dirname)(tempFilePath), { recursive: true });
352862
+ await (0, import_promises183.writeFile)(tempFilePath, content5);
352863
+ await tempGit.exec(["add", filePath]);
352864
+ await tempGit.exec(["commit", "-m", `check already-applied ${filePath}`, "--allow-empty"]);
352865
+ await tempGit.execWithInput(["apply", "--reverse", "--check", "--allow-empty"], fileDiff);
352866
+ return true;
352867
+ } catch {
352868
+ return false;
352869
+ }
352870
+ }
352560
352871
  extractFileDiff(patchContent, filePath) {
352561
352872
  const lines = patchContent.split("\n");
352562
352873
  const diffLines = [];
@@ -352721,6 +353032,33 @@ CLI Version: ${options2.cliVersion}`;
352721
353032
  committer;
352722
353033
  lockManager;
352723
353034
  outputDir;
353035
+ /** @internal Test-only. Captures the last ReplayResult[] returned from applicator.applyPatches(). */
353036
+ _lastApplyResults = [];
353037
+ /**
353038
+ * Service-level warnings accumulated during a single runReplay() call.
353039
+ * Merged with `detector.warnings` into `ReplayReport.warnings` by each flow handler.
353040
+ * Populated by `isFileUserOwned` when a patch's base generation tree is unreachable
353041
+ * (shallow clone / aggressive `git gc --prune`) and we fall back to a conservative
353042
+ * heuristic. Cleared at the top of `runReplay()`.
353043
+ */
353044
+ warnings = [];
353045
+ /**
353046
+ * @internal Test-only accessor.
353047
+ * Returns the ReplayApplicator instance used for the last run. Tests use this
353048
+ * to inspect the inter-patch accumulator after runReplay() completes (FER-9791, I2).
353049
+ */
353050
+ get applicatorRef() {
353051
+ return this.applicator;
353052
+ }
353053
+ /**
353054
+ * @internal Test-only accessor.
353055
+ * Returns the ReplayResult[] from the most recent applyPatches() call. Tests use
353056
+ * this to filter applied-vs-conflicted-vs-absorbed patches for invariant assertions
353057
+ * (FER-9791). Returns an empty array if no patches have been applied yet.
353058
+ */
353059
+ getLastResults() {
353060
+ return this._lastApplyResults;
353061
+ }
352724
353062
  constructor(outputDir, _config) {
352725
353063
  const git = new GitClient2(outputDir);
352726
353064
  this.git = git;
@@ -352731,6 +353069,7 @@ CLI Version: ${options2.cliVersion}`;
352731
353069
  this.committer = new ReplayCommitter(git, outputDir);
352732
353070
  }
352733
353071
  async runReplay(options2) {
353072
+ this.warnings = [];
352734
353073
  if (options2?.skipApplication) {
352735
353074
  return this.handleSkipApplication(options2);
352736
353075
  }
@@ -352804,6 +353143,7 @@ CLI Version: ${options2.cliVersion}`;
352804
353143
  );
352805
353144
  }
352806
353145
  this.lockManager.save();
353146
+ this.detector.warnings.push(...this.lockManager.warnings);
352807
353147
  }
352808
353148
  determineFlow() {
352809
353149
  try {
@@ -352870,6 +353210,7 @@ CLI Version: ${options2.cliVersion}`;
352870
353210
  }
352871
353211
  this.lockManager.setReplaySkippedAt((/* @__PURE__ */ new Date()).toISOString());
352872
353212
  this.lockManager.save();
353213
+ const credentialWarnings = [...this.lockManager.warnings];
352873
353214
  if (!options2?.stageOnly) {
352874
353215
  await this.committer.commitReplay(0);
352875
353216
  } else {
@@ -352881,13 +353222,15 @@ CLI Version: ${options2.cliVersion}`;
352881
353222
  patchesApplied: 0,
352882
353223
  patchesWithConflicts: 0,
352883
353224
  patchesSkipped: 0,
352884
- conflicts: []
353225
+ conflicts: [],
353226
+ warnings: credentialWarnings.length > 0 ? credentialWarnings : void 0
352885
353227
  };
352886
353228
  }
352887
353229
  async handleNoPatchesRegeneration(options2) {
352888
- const { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
352889
- const warnings = [...this.detector.warnings];
353230
+ let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
353231
+ const detectorWarnings = [...this.detector.warnings];
352890
353232
  if (options2?.dryRun) {
353233
+ const dryRunWarnings = [...detectorWarnings, ...this.warnings];
352891
353234
  return {
352892
353235
  flow: "no-patches",
352893
353236
  patchesDetected: newPatches.length,
@@ -352897,9 +353240,26 @@ CLI Version: ${options2.cliVersion}`;
352897
353240
  patchesReverted: revertedPatchIds.length,
352898
353241
  conflicts: [],
352899
353242
  wouldApply: newPatches,
352900
- warnings: warnings.length > 0 ? warnings : void 0
353243
+ warnings: dryRunWarnings.length > 0 ? dryRunWarnings : void 0
352901
353244
  };
352902
353245
  }
353246
+ {
353247
+ const preLock = this.lockManager.read();
353248
+ const preCurrentGen = preLock.current_generation;
353249
+ const uniqueFiles = [...new Set(newPatches.flatMap((p10) => p10.files))];
353250
+ const absorbedFiles = /* @__PURE__ */ new Set();
353251
+ for (const file4 of uniqueFiles) {
353252
+ const diff2 = await this.git.exec(["diff", preCurrentGen, "HEAD", "--", file4]).catch(() => null);
353253
+ if (diff2 !== null && !diff2.trim()) {
353254
+ absorbedFiles.add(file4);
353255
+ }
353256
+ }
353257
+ if (absorbedFiles.size > 0) {
353258
+ newPatches = newPatches.filter(
353259
+ (p10) => p10.files.length === 0 || !p10.files.every((f10) => absorbedFiles.has(f10))
353260
+ );
353261
+ }
353262
+ }
352903
353263
  for (const id2 of revertedPatchIds) {
352904
353264
  try {
352905
353265
  this.lockManager.removePatch(id2);
@@ -352918,6 +353278,7 @@ CLI Version: ${options2.cliVersion}`;
352918
353278
  let results = [];
352919
353279
  if (newPatches.length > 0) {
352920
353280
  results = await this.applicator.applyPatches(newPatches);
353281
+ this._lastApplyResults = results;
352921
353282
  this.revertConflictingFiles(results);
352922
353283
  for (const result of results) {
352923
353284
  if (result.status === "conflict") {
@@ -352932,6 +353293,7 @@ CLI Version: ${options2.cliVersion}`;
352932
353293
  }
352933
353294
  }
352934
353295
  this.lockManager.save();
353296
+ this.warnings.push(...this.lockManager.warnings);
352935
353297
  if (newPatches.length > 0) {
352936
353298
  if (!options2?.stageOnly) {
352937
353299
  const appliedCount = results.filter((r23) => r23.status === "applied").length;
@@ -352940,13 +353302,14 @@ CLI Version: ${options2.cliVersion}`;
352940
353302
  await this.committer.stageAll();
352941
353303
  }
352942
353304
  }
353305
+ const warnings = [...detectorWarnings, ...this.warnings];
352943
353306
  return this.buildReport("no-patches", newPatches, results, options2, warnings, rebaseCounts, void 0, revertedPatchIds.length);
352944
353307
  }
352945
353308
  async handleNormalRegeneration(options2) {
352946
353309
  if (options2?.dryRun) {
352947
353310
  const existingPatches2 = this.lockManager.getPatches();
352948
353311
  const { patches: newPatches2, revertedPatchIds: dryRunReverted } = await this.detector.detectNewPatches();
352949
- const warnings2 = [...this.detector.warnings];
353312
+ const warnings2 = [...this.detector.warnings, ...this.warnings];
352950
353313
  const allPatches2 = [...existingPatches2, ...newPatches2];
352951
353314
  return {
352952
353315
  flow: "normal-regeneration",
@@ -352978,7 +353341,7 @@ CLI Version: ${options2.cliVersion}`;
352978
353341
  }
352979
353342
  existingPatches = this.lockManager.getPatches();
352980
353343
  let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
352981
- const warnings = [...this.detector.warnings];
353344
+ const detectorWarnings = [...this.detector.warnings];
352982
353345
  if (removedByPreRebase.length > 0) {
352983
353346
  const removedOriginalCommits = new Set(removedByPreRebase.map((p10) => p10.original_commit));
352984
353347
  const removedOriginalMessages = new Set(removedByPreRebase.map((p10) => p10.original_message));
@@ -353010,6 +353373,7 @@ CLI Version: ${options2.cliVersion}`;
353010
353373
  const genRecord = await this.committer.createGenerationRecord(commitOpts);
353011
353374
  this.lockManager.addGeneration(genRecord);
353012
353375
  const results = await this.applicator.applyPatches(allPatches);
353376
+ this._lastApplyResults = results;
353013
353377
  this.revertConflictingFiles(results);
353014
353378
  for (const result of results) {
353015
353379
  if (result.status === "conflict") {
@@ -353031,12 +353395,14 @@ CLI Version: ${options2.cliVersion}`;
353031
353395
  }
353032
353396
  }
353033
353397
  this.lockManager.save();
353398
+ this.warnings.push(...this.lockManager.warnings);
353034
353399
  if (options2?.stageOnly) {
353035
353400
  await this.committer.stageAll();
353036
353401
  } else {
353037
353402
  const appliedCount = results.filter((r23) => r23.status === "applied").length;
353038
353403
  await this.committer.commitReplay(appliedCount, allPatches);
353039
353404
  }
353405
+ const warnings = [...detectorWarnings, ...this.warnings];
353040
353406
  return this.buildReport(
353041
353407
  "normal-regeneration",
353042
353408
  allPatches,
@@ -353060,6 +353426,7 @@ CLI Version: ${options2.cliVersion}`;
353060
353426
  let keptAsUserOwned = 0;
353061
353427
  const seenContentHashes = /* @__PURE__ */ new Set();
353062
353428
  const absorbedPatchIds = /* @__PURE__ */ new Set();
353429
+ const fernignorePatterns = this.readFernignorePatterns();
353063
353430
  for (const result of results) {
353064
353431
  if (result.resolvedFiles && Object.keys(result.resolvedFiles).length > 0) {
353065
353432
  const patch5 = result.patch;
@@ -353080,34 +353447,78 @@ CLI Version: ${options2.cliVersion}`;
353080
353447
  const patch5 = result.patch;
353081
353448
  if (patch5.base_generation === currentGenSha) continue;
353082
353449
  try {
353083
- const fernignorePatterns = this.readFernignorePatterns();
353084
- const isUserOwned = await Promise.all(
353450
+ const originalByResolved = {};
353451
+ if (result.resolvedFiles) {
353452
+ for (const [orig, resolved] of Object.entries(result.resolvedFiles)) {
353453
+ originalByResolved[resolved] = orig;
353454
+ }
353455
+ }
353456
+ const isUserOwnedPerFile = await Promise.all(
353085
353457
  patch5.files.map(async (file4) => {
353086
- if (file4 === ".fernignore") {
353458
+ if (file4 === ".fernignore") return true;
353459
+ if (fernignorePatterns.some((p10) => file4 === p10 || (0, import_minimatch22.minimatch)(file4, p10))) {
353087
353460
  return true;
353088
353461
  }
353089
- if (fernignorePatterns.some((p10) => file4 === p10 || (0, import_minimatch22.minimatch)(file4, p10))) {
353462
+ if (patch5.user_owned) return true;
353463
+ if (this.fileIsCreationInPatchContent(patch5.patch_content, originalByResolved[file4] ?? file4)) {
353090
353464
  return true;
353091
353465
  }
353092
353466
  const content5 = await this.git.showFile(currentGenSha, file4);
353093
353467
  return content5 === null;
353094
353468
  })
353095
353469
  );
353096
- const hasUserOwnedFiles = isUserOwned.some(Boolean);
353097
- if (hasUserOwnedFiles) {
353098
- this.lockManager.updatePatch(patch5.id, {
353099
- base_generation: currentGenSha
353100
- });
353470
+ const hasProtectedFiles = patch5.files.some(
353471
+ (file4) => file4 === ".fernignore" || fernignorePatterns.some((p10) => file4 === p10 || (0, import_minimatch22.minimatch)(file4, p10))
353472
+ );
353473
+ const allUserOwned = isUserOwnedPerFile.every(Boolean);
353474
+ if (allUserOwned && patch5.files.length > 0) {
353475
+ const baseChanged = patch5.base_generation !== currentGenSha;
353476
+ const userDiff = await this.git.exec(["diff", currentGenSha, "--", ...patch5.files]).catch(() => null);
353477
+ if (userDiff != null && userDiff.trim()) {
353478
+ const newHash = this.detector.computeContentHash(userDiff);
353479
+ patch5.patch_content = userDiff;
353480
+ patch5.content_hash = newHash;
353481
+ }
353482
+ patch5.base_generation = currentGenSha;
353483
+ try {
353484
+ this.lockManager.updatePatch(patch5.id, {
353485
+ base_generation: currentGenSha,
353486
+ ...!patch5.user_owned ? { user_owned: true } : {},
353487
+ ...userDiff != null && userDiff.trim() ? { patch_content: patch5.patch_content, content_hash: patch5.content_hash } : {}
353488
+ });
353489
+ } catch {
353490
+ }
353491
+ patch5.user_owned = true;
353101
353492
  keptAsUserOwned++;
353493
+ if (baseChanged) repointed++;
353102
353494
  continue;
353103
353495
  }
353104
353496
  const diff2 = await this.git.exec(["diff", currentGenSha, "--", ...patch5.files]).catch(() => null);
353105
- if (!diff2 || !diff2.trim()) {
353497
+ if (diff2 === null) continue;
353498
+ if (!diff2.trim()) {
353499
+ if (hasProtectedFiles) {
353500
+ patch5.base_generation = currentGenSha;
353501
+ try {
353502
+ this.lockManager.updatePatch(patch5.id, { base_generation: currentGenSha });
353503
+ } catch {
353504
+ }
353505
+ keptAsUserOwned++;
353506
+ continue;
353507
+ }
353106
353508
  this.lockManager.removePatch(patch5.id);
353107
353509
  absorbedPatchIds.add(patch5.id);
353108
353510
  absorbed++;
353109
353511
  continue;
353110
353512
  }
353513
+ if (hasProtectedFiles) {
353514
+ patch5.base_generation = currentGenSha;
353515
+ try {
353516
+ this.lockManager.updatePatch(patch5.id, { base_generation: currentGenSha });
353517
+ } catch {
353518
+ }
353519
+ keptAsUserOwned++;
353520
+ continue;
353521
+ }
353111
353522
  const newContentHash = this.detector.computeContentHash(diff2);
353112
353523
  if (seenContentHashes.has(newContentHash)) {
353113
353524
  this.lockManager.removePatch(patch5.id);
@@ -353116,17 +353527,89 @@ CLI Version: ${options2.cliVersion}`;
353116
353527
  continue;
353117
353528
  }
353118
353529
  seenContentHashes.add(newContentHash);
353119
- this.lockManager.updatePatch(patch5.id, {
353120
- base_generation: currentGenSha,
353121
- patch_content: diff2,
353122
- content_hash: newContentHash
353123
- });
353530
+ patch5.base_generation = currentGenSha;
353531
+ patch5.patch_content = diff2;
353532
+ patch5.content_hash = newContentHash;
353533
+ try {
353534
+ this.lockManager.updatePatch(patch5.id, {
353535
+ base_generation: currentGenSha,
353536
+ patch_content: diff2,
353537
+ content_hash: newContentHash
353538
+ });
353539
+ } catch {
353540
+ }
353124
353541
  contentRebased++;
353125
353542
  } catch {
353126
353543
  }
353127
353544
  }
353128
353545
  return { absorbed, repointed, contentRebased, keptAsUserOwned, absorbedPatchIds };
353129
353546
  }
353547
+ /**
353548
+ * Determine whether `file` is user-owned (never produced by the generator).
353549
+ *
353550
+ * Resolution order (first match wins):
353551
+ * 1. `patch.user_owned === true` — authoritative, persisted across cycles.
353552
+ * 2. `.fernignore` itself, or file matches a .fernignore pattern.
353553
+ * 3. Legacy-safe signal: `patch_content` shows this file as a `--- /dev/null`
353554
+ * creation. Works for patches that predate the `user_owned` field or
353555
+ * whose flag was lost. Immune to rename tricks — relies on raw patch text.
353556
+ * 4. Tree-based check against `patch.base_generation` when it is reachable
353557
+ * AND distinct from `currentGenSha`. If the base tree is unreachable
353558
+ * (shallow clone / aggressive `git gc --prune`), emit a one-time
353559
+ * customer-actionable warning (deduplicated via warnedGens) and
353560
+ * conservatively treat as user-owned — strictly safer than silent
353561
+ * absorption (FER-9809).
353562
+ * 5. Final fallback — check `currentGenSha` when there is no usable base
353563
+ * (legacy one-gen lockfile). Preserves pre-fix behavior for that edge.
353564
+ *
353565
+ * `originalFileName` is the pre-rename path when the generator moved the
353566
+ * file between the patch's base and the current generation. Tree lookups
353567
+ * at ancestor generations use the original path — that's where the
353568
+ * generator produced the content.
353569
+ */
353570
+ async isFileUserOwned(file4, patch5, currentGenSha, fernignorePatterns, warnedGens, originalFileName) {
353571
+ if (patch5.user_owned) return true;
353572
+ if (file4 === ".fernignore") return true;
353573
+ if (fernignorePatterns.some((p10) => file4 === p10 || (0, import_minimatch22.minimatch)(file4, p10))) return true;
353574
+ if (this.fileIsCreationInPatchContent(patch5.patch_content, originalFileName ?? file4)) {
353575
+ return true;
353576
+ }
353577
+ const base4 = patch5.base_generation;
353578
+ if (base4 && base4 !== currentGenSha) {
353579
+ const reachable = await this.git.commitExists(base4) || await this.git.treeExists(base4);
353580
+ if (!reachable) {
353581
+ if (!warnedGens.has(base4)) {
353582
+ warnedGens.add(base4);
353583
+ this.warnings.push(
353584
+ `Base generation ${base4.slice(0, 7)} is unreachable (shallow clone or pruned history). Treating affected files as user-owned to avoid silent data loss. Run \`git fetch --unshallow\` or avoid \`git gc --prune\` to restore full history.`
353585
+ );
353586
+ }
353587
+ return true;
353588
+ }
353589
+ return await this.git.showFile(base4, originalFileName ?? file4) === null;
353590
+ }
353591
+ return await this.git.showFile(currentGenSha, originalFileName ?? file4) === null;
353592
+ }
353593
+ /**
353594
+ * Returns true if `file`'s diff section in `patchContent` starts with
353595
+ * `--- /dev/null`, meaning this file was introduced by the patch (user
353596
+ * creation). Used as a legacy-safe fallback when `patch.user_owned` is
353597
+ * absent or cleared.
353598
+ */
353599
+ fileIsCreationInPatchContent(patchContent, file4) {
353600
+ const lines = patchContent.split("\n");
353601
+ let inFileSection = false;
353602
+ for (const line of lines) {
353603
+ if (line.startsWith("diff --git ")) {
353604
+ inFileSection = line.includes(` b/${file4}`) || line.endsWith(` b/${file4}`);
353605
+ continue;
353606
+ }
353607
+ if (inFileSection && line.startsWith("--- ")) {
353608
+ return line === "--- /dev/null";
353609
+ }
353610
+ }
353611
+ return false;
353612
+ }
353130
353613
  /**
353131
353614
  * For conflict patches with mixed results (some files merged, some conflicted),
353132
353615
  * check if the cleanly merged files were absorbed by the generator (empty diff).
@@ -353140,13 +353623,14 @@ CLI Version: ${options2.cliVersion}`;
353140
353623
  const cleanFiles = result.fileResults.filter((f10) => f10.status === "merged").map((f10) => f10.file);
353141
353624
  if (cleanFiles.length === 0) return;
353142
353625
  const patch5 = result.patch;
353626
+ if (patch5.user_owned) return;
353143
353627
  const fernignorePatterns = this.readFernignorePatterns();
353628
+ const warnedGens = /* @__PURE__ */ new Set();
353144
353629
  const generatorCleanFiles = [];
353145
353630
  for (const file4 of cleanFiles) {
353146
- if (file4 === ".fernignore") continue;
353147
- if (fernignorePatterns.some((p10) => file4 === p10 || (0, import_minimatch22.minimatch)(file4, p10))) continue;
353148
- const content5 = await this.git.showFile(currentGenSha, file4);
353149
- if (content5 === null) continue;
353631
+ if (await this.isFileUserOwned(file4, patch5, currentGenSha, fernignorePatterns, warnedGens)) {
353632
+ continue;
353633
+ }
353150
353634
  generatorCleanFiles.push(file4);
353151
353635
  }
353152
353636
  if (generatorCleanFiles.length === 0) return;
@@ -353183,7 +353667,35 @@ CLI Version: ${options2.cliVersion}`;
353183
353667
  let conflictResolved = 0;
353184
353668
  let conflictAbsorbed = 0;
353185
353669
  let contentRefreshed = 0;
353670
+ const fernignorePatterns = this.readFernignorePatterns();
353671
+ const warnedGens = /* @__PURE__ */ new Set();
353672
+ const reachabilityCache = /* @__PURE__ */ new Map();
353673
+ const isReachable = async (sha) => {
353674
+ const cached2 = reachabilityCache.get(sha);
353675
+ if (cached2 !== void 0) return cached2;
353676
+ const reachable = await this.git.commitExists(sha) || await this.git.treeExists(sha);
353677
+ reachabilityCache.set(sha, reachable);
353678
+ return reachable;
353679
+ };
353680
+ const allPatchFilesUserOwned = async (patch5) => {
353681
+ if (patch5.user_owned) return true;
353682
+ if (patch5.files.length === 0) return false;
353683
+ for (const file4 of patch5.files) {
353684
+ if (!await this.isFileUserOwned(file4, patch5, currentGen, fernignorePatterns, warnedGens)) {
353685
+ return false;
353686
+ }
353687
+ }
353688
+ return true;
353689
+ };
353186
353690
  for (const patch5 of patches) {
353691
+ if (patch5.base_generation && patch5.base_generation !== currentGen && !warnedGens.has(patch5.base_generation)) {
353692
+ if (!await isReachable(patch5.base_generation)) {
353693
+ warnedGens.add(patch5.base_generation);
353694
+ this.warnings.push(
353695
+ `Base generation ${patch5.base_generation.slice(0, 7)} is unreachable (shallow clone or pruned history). Treating affected files as user-owned to avoid silent data loss. Run \`git fetch --unshallow\` or avoid \`git gc --prune\` to restore full history.`
353696
+ );
353697
+ }
353698
+ }
353187
353699
  if (patch5.status != null) {
353188
353700
  delete patch5.status;
353189
353701
  continue;
@@ -353193,6 +353705,7 @@ CLI Version: ${options2.cliVersion}`;
353193
353705
  const diff2 = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch5.files]).catch(() => null);
353194
353706
  if (diff2 === null) continue;
353195
353707
  if (!diff2.trim()) {
353708
+ if (await allPatchFilesUserOwned(patch5)) continue;
353196
353709
  this.lockManager.removePatch(patch5.id);
353197
353710
  contentRefreshed++;
353198
353711
  continue;
@@ -353204,6 +353717,12 @@ CLI Version: ${options2.cliVersion}`;
353204
353717
  if (hasStaleMarkers) {
353205
353718
  continue;
353206
353719
  }
353720
+ const isProtected = patch5.files.some(
353721
+ (file4) => file4 === ".fernignore" || fernignorePatterns.some((p10) => file4 === p10 || (0, import_minimatch22.minimatch)(file4, p10))
353722
+ );
353723
+ if (isProtected) {
353724
+ continue;
353725
+ }
353207
353726
  const newContentHash = this.detector.computeContentHash(diff2);
353208
353727
  if (newContentHash !== patch5.content_hash) {
353209
353728
  const filesOutput = await this.git.exec(["diff", "--name-only", currentGen, "HEAD", "--", ...patch5.files]).catch(() => null);
@@ -353229,6 +353748,13 @@ CLI Version: ${options2.cliVersion}`;
353229
353748
  const diff2 = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch5.files]).catch(() => null);
353230
353749
  if (diff2 === null) continue;
353231
353750
  if (!diff2.trim()) {
353751
+ if (await allPatchFilesUserOwned(patch5)) {
353752
+ try {
353753
+ this.lockManager.updatePatch(patch5.id, { base_generation: currentGen });
353754
+ } catch {
353755
+ }
353756
+ continue;
353757
+ }
353232
353758
  this.lockManager.removePatch(patch5.id);
353233
353759
  conflictAbsorbed++;
353234
353760
  continue;
@@ -353736,7 +354262,10 @@ CLI Version: ${options2.cliVersion}`;
353736
354262
  (0, import_node_fs42.writeFileSync)(gitattributesPath, content5, "utf-8");
353737
354263
  }
353738
354264
  function computeContentHash(patchContent) {
353739
- const normalized = patchContent.split("\n").filter((line) => !line.startsWith("From ") && !line.startsWith("index ") && !line.startsWith("Date: ")).join("\n");
354265
+ const lines = patchContent.split("\n");
354266
+ const diffStart = lines.findIndex((l14) => l14.startsWith("diff --git "));
354267
+ const relevantLines = diffStart > 0 ? lines.slice(diffStart) : lines;
354268
+ const normalized = relevantLines.filter((line) => !line.startsWith("index ")).join("\n").replace(/\n-- \n[\d]+\.[\d]+[^\n]*\s*$/, "");
353740
354269
  return `sha256:${(0, import_node_crypto32.createHash)("sha256").update(normalized).digest("hex")}`;
353741
354270
  }
353742
354271
  var import_minimatch42 = require_commonjs3();
@@ -354121,6 +354650,7 @@ var require_replay_run = __commonJS({
354121
354650
  "use strict";
354122
354651
  Object.defineProperty(exports2, "__esModule", { value: true });
354123
354652
  exports2.replayRun = replayRun;
354653
+ exports2.isTagMergedIntoHead = isTagMergedIntoHead;
354124
354654
  var replay_1 = require_dist14();
354125
354655
  var child_process_1 = require("child_process");
354126
354656
  var fs_1 = require("fs");
@@ -354165,18 +354695,22 @@ var require_replay_run = __commonJS({
354165
354695
  }
354166
354696
  }
354167
354697
  if (shouldSync && tagSha != null) {
354168
- const syncService = new replay_1.ReplayService(outputDir, { enabled: true });
354169
- await syncService.syncFromDivergentMerge(tagSha, {
354170
- cliVersion,
354171
- generatorVersions,
354172
- baseBranchHead: baseBranchHead ?? void 0
354173
- });
354174
- try {
354175
- const freshLockManager = new replay_1.LockfileManager(outputDir);
354176
- if (freshLockManager.exists()) {
354177
- previousGenerationSha = freshLockManager.read().current_generation;
354698
+ if (!isTagMergedIntoHead(outputDir, tagSha)) {
354699
+ logger4?.warn(`fern-generation-base tag ${tagSha} is not reachable from HEAD \u2014 skipping divergent-merge sync. The tag likely points at an unmerged generation (PR closed without merge).`);
354700
+ } else {
354701
+ const syncService = new replay_1.ReplayService(outputDir, { enabled: true });
354702
+ await syncService.syncFromDivergentMerge(tagSha, {
354703
+ cliVersion,
354704
+ generatorVersions,
354705
+ baseBranchHead: baseBranchHead ?? void 0
354706
+ });
354707
+ try {
354708
+ const freshLockManager = new replay_1.LockfileManager(outputDir);
354709
+ if (freshLockManager.exists()) {
354710
+ previousGenerationSha = freshLockManager.read().current_generation;
354711
+ }
354712
+ } catch {
354178
354713
  }
354179
- } catch {
354180
354714
  }
354181
354715
  }
354182
354716
  }
@@ -354226,6 +354760,30 @@ var require_replay_run = __commonJS({
354226
354760
  baseBranchHead: resolvedBaseBranchHead
354227
354761
  };
354228
354762
  }
354763
+ function isTagMergedIntoHead(cwd2, tagSha) {
354764
+ try {
354765
+ (0, child_process_1.execFileSync)("git", ["merge-base", "--is-ancestor", tagSha, "HEAD"], {
354766
+ cwd: cwd2,
354767
+ stdio: "pipe"
354768
+ });
354769
+ return true;
354770
+ } catch {
354771
+ }
354772
+ const tagTree = gitRevParse(cwd2, `${tagSha}^{tree}`);
354773
+ if (tagTree == null) {
354774
+ return false;
354775
+ }
354776
+ try {
354777
+ const trees = (0, child_process_1.execFileSync)("git", ["log", "HEAD", "--format=%T", "--max-count=500"], {
354778
+ cwd: cwd2,
354779
+ encoding: "utf-8",
354780
+ stdio: "pipe"
354781
+ });
354782
+ return trees.split("\n").some((t3) => t3.trim() === tagTree);
354783
+ } catch {
354784
+ return false;
354785
+ }
354786
+ }
354229
354787
  function gitRevParse(cwd2, rev) {
354230
354788
  try {
354231
354789
  return (0, child_process_1.execFileSync)("git", ["rev-parse", "--verify", rev], {
@@ -621394,7 +621952,7 @@ var AccessTokenPosthogManager = class {
621394
621952
  properties: {
621395
621953
  ...event,
621396
621954
  ...event.properties,
621397
- version: "4.75.0",
621955
+ version: "4.76.0-1-g4629aee782f",
621398
621956
  usingAccessToken: true
621399
621957
  }
621400
621958
  });
@@ -621448,7 +622006,7 @@ var UserPosthogManager = class {
621448
622006
  distinctId: this.userId ?? await this.getPersistedDistinctId(),
621449
622007
  event: "CLI",
621450
622008
  properties: {
621451
- version: "4.75.0",
622009
+ version: "4.76.0-1-g4629aee782f",
621452
622010
  ...event,
621453
622011
  ...event.properties,
621454
622012
  usingAccessToken: false,
@@ -838658,7 +839216,7 @@ var LOCAL_STORAGE_FOLDER4 = ".fern-dev";
838658
839216
  var LOGS_FOLDER_NAME = "logs";
838659
839217
  var MAX_LOGS_DIR_SIZE_BYTES = 100 * 1024 * 1024;
838660
839218
  function getCliSource() {
838661
- const version7 = "4.75.0";
839219
+ const version7 = "4.76.0-1-g4629aee782f";
838662
839220
  return `cli@${version7}`;
838663
839221
  }
838664
839222
  var DebugLogger = class {
@@ -839024,7 +839582,7 @@ function isWithinOutputDir(resolvedPath, outputDir) {
839024
839582
  const rel2 = import_path55.default.relative(outputDir, resolvedPath);
839025
839583
  return !rel2.startsWith("..") && !import_path55.default.isAbsolute(rel2);
839026
839584
  }
839027
- function resolveWindowsSymlinks(outputDir, symlinks, logger4) {
839585
+ function resolveWindowsSymlinks(outputDir, symlinks, logger4, onProgress) {
839028
839586
  if (symlinks.length === 0) {
839029
839587
  return;
839030
839588
  }
@@ -839094,6 +839652,7 @@ function resolveWindowsSymlinks(outputDir, symlinks, logger4) {
839094
839652
  logger4.debug(`Failed to resolve symlink ${symlinkPath}: ${error50}`);
839095
839653
  failed++;
839096
839654
  }
839655
+ onProgress?.(junctions + copies + alreadyExisted + failed, symlinks.length);
839097
839656
  }
839098
839657
  logger4.debug(`Symlink resolution: ${junctions} junctions, ${copies} file copies, ${alreadyExisted} pre-existing, ${failed} failed (${fallbackUsed} via fallback) out of ${symlinks.length}`);
839099
839658
  const standaloneNM = import_path55.default.join(outputDir, "standalone", "node_modules");
@@ -839232,7 +839791,7 @@ async function downloadBundle({ bucketUrl, logger: logger4, preferCached, app =
839232
839791
  hideCursor: true
839233
839792
  });
839234
839793
  unzipProgressBar.start(100, 0);
839235
- const UNZIP_DURATION_MS = 3e4;
839794
+ const UNZIP_DURATION_MS = PLATFORM_IS_WINDOWS ? 6e4 : 3e4;
839236
839795
  const startTime = Date.now();
839237
839796
  unzipInterval = setInterval(() => {
839238
839797
  const elapsed = Date.now() - startTime;
@@ -839269,7 +839828,21 @@ async function downloadBundle({ bucketUrl, logger: logger4, preferCached, app =
839269
839828
  }
839270
839829
  }
839271
839830
  if (PLATFORM_IS_WINDOWS && collectedSymlinks.length > 0) {
839272
- resolveWindowsSymlinks(absolutePathToBundleFolder, collectedSymlinks, logger4);
839831
+ let symlinkProgressBar;
839832
+ if (app) {
839833
+ symlinkProgressBar = new import_cli_progress.default.SingleBar({
839834
+ format: `${DOCS_PREFIX} ${formatProgressLabel("Patching symlinks")} [{bar}] {percentage}% | {value}/{total}`,
839835
+ barCompleteChar: "\u2588",
839836
+ barIncompleteChar: "\u2591",
839837
+ hideCursor: true
839838
+ });
839839
+ symlinkProgressBar.start(collectedSymlinks.length, 0);
839840
+ }
839841
+ resolveWindowsSymlinks(absolutePathToBundleFolder, collectedSymlinks, logger4, symlinkProgressBar ? (resolved, total) => symlinkProgressBar?.update(resolved) : void 0);
839842
+ if (symlinkProgressBar) {
839843
+ symlinkProgressBar.update(collectedSymlinks.length);
839844
+ symlinkProgressBar.stop();
839845
+ }
839273
839846
  }
839274
839847
  await (0, import_promises78.writeFile)(eTagFilepath, eTag);
839275
839848
  logger4.debug(`Downloaded bundle to ${absolutePathToBundleFolder}`);
@@ -849958,7 +850531,7 @@ var LegacyDocsPublisher = class {
849958
850531
  previewId,
849959
850532
  disableTemplates: void 0,
849960
850533
  skipUpload,
849961
- cliVersion: "4.75.0"
850534
+ cliVersion: "4.76.0-1-g4629aee782f"
849962
850535
  });
849963
850536
  if (taskContext.getResult() === TaskResult.Failure) {
849964
850537
  return { success: false };
@@ -924416,7 +924989,7 @@ var CliContext = class _CliContext {
924416
924989
  if (false) {
924417
924990
  this.logger.error("CLI_VERSION is not defined");
924418
924991
  }
924419
- return "4.75.0";
924992
+ return "4.76.0-1-g4629aee782f";
924420
924993
  }
924421
924994
  getCliName() {
924422
924995
  if (false) {
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "4.75.0",
2
+ "version": "4.76.0-1-g4629aee782f",
3
3
  "repository": {
4
4
  "type": "git",
5
5
  "url": "git+https://github.com/fern-api/fern.git",