@fern-api/fern-api-dev 4.75.0 → 4.76.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.
Files changed (2) hide show
  1. package/cli.cjs +611 -75
  2. package/package.json +1 -1
package/cli.cjs CHANGED
@@ -350912,9 +350912,9 @@ var require_diff3 = __commonJS({
350912
350912
  }
350913
350913
  });
350914
350914
 
350915
- // ../../../node_modules/.pnpm/@fern-api+replay@0.10.4/node_modules/@fern-api/replay/dist/index.cjs
350915
+ // ../../../node_modules/.pnpm/@fern-api+replay@0.11.0/node_modules/@fern-api/replay/dist/index.cjs
350916
350916
  var require_dist14 = __commonJS({
350917
- "../../../node_modules/.pnpm/@fern-api+replay@0.10.4/node_modules/@fern-api/replay/dist/index.cjs"(exports2, module4) {
350917
+ "../../../node_modules/.pnpm/@fern-api+replay@0.11.0/node_modules/@fern-api/replay/dist/index.cjs"(exports2, module4) {
350918
350918
  "use strict";
350919
350919
  var __defProp4 = Object.defineProperty;
350920
350920
  var __getOwnPropDesc4 = Object.getOwnPropertyDescriptor;
@@ -351005,13 +351005,30 @@ var require_dist14 = __commonJS({
351005
351005
  const output2 = await this.exec(["rev-parse", `${commitSha}^@`]);
351006
351006
  return output2.trim().split("\n").filter(Boolean);
351007
351007
  }
351008
+ /**
351009
+ * Detects renames and copies between two trees.
351010
+ *
351011
+ * Both `R` (rename) and `C` (copy) entries are returned as `{from, to}` pairs.
351012
+ * Copies are treated as rename-equivalent because the generator may produce the
351013
+ * new path while the old path remains in the tree (e.g., imperfect cleanup,
351014
+ * customer edits at the old path, or test helpers that only write new files).
351015
+ * Callers that care about "is the source still present" should verify via
351016
+ * `showFile(toTree, from)`.
351017
+ */
351008
351018
  async detectRenames(fromTree, toTree) {
351009
351019
  try {
351010
- const output2 = await this.exec(["diff", "--find-renames", "--name-status", fromTree, toTree]);
351020
+ const output2 = await this.exec([
351021
+ "diff",
351022
+ "--find-renames",
351023
+ "--find-copies",
351024
+ "--name-status",
351025
+ fromTree,
351026
+ toTree
351027
+ ]);
351011
351028
  const renames = [];
351012
351029
  for (const line of output2.trim().split("\n")) {
351013
351030
  if (!line) continue;
351014
- if (line.startsWith("R")) {
351031
+ if (line.startsWith("R") || line.startsWith("C")) {
351015
351032
  const parts = line.split(" ");
351016
351033
  if (parts.length >= 3) {
351017
351034
  renames.push({ from: parts[1], to: parts[2] });
@@ -351305,6 +351322,7 @@ var require_dist14 = __commonJS({
351305
351322
  });
351306
351323
  var index_exports = {};
351307
351324
  __export4(index_exports, {
351325
+ CREDENTIAL_PATTERNS: () => CREDENTIAL_PATTERNS,
351308
351326
  FERN_BOT_EMAIL: () => FERN_BOT_EMAIL,
351309
351327
  FERN_BOT_LOGIN: () => FERN_BOT_LOGIN,
351310
351328
  FERN_BOT_NAME: () => FERN_BOT_NAME,
@@ -351316,19 +351334,53 @@ var require_dist14 = __commonJS({
351316
351334
  ReplayCommitter: () => ReplayCommitter,
351317
351335
  ReplayDetector: () => ReplayDetector,
351318
351336
  ReplayService: () => ReplayService,
351337
+ SENSITIVE_FILE_PATTERNS: () => SENSITIVE_FILE_PATTERNS,
351319
351338
  bootstrap: () => bootstrap,
351320
351339
  forget: () => forget,
351321
351340
  isGenerationCommit: () => isGenerationCommit,
351322
351341
  isReplayCommit: () => isReplayCommit,
351323
351342
  isRevertCommit: () => isRevertCommit,
351343
+ isSensitiveFile: () => isSensitiveFile,
351324
351344
  parseRevertedMessage: () => parseRevertedMessage,
351325
351345
  parseRevertedSha: () => parseRevertedSha,
351326
351346
  reset: () => reset2,
351327
351347
  resolve: () => resolve13,
351348
+ scanForCredentials: () => scanForCredentials,
351328
351349
  status: () => status,
351329
351350
  threeWayMerge: () => threeWayMerge
351330
351351
  });
351331
351352
  module4.exports = __toCommonJS3(index_exports);
351353
+ var CREDENTIAL_PATTERNS = [
351354
+ { name: "PRIVATE KEY block", re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
351355
+ { name: "OPENSSH key", re: /-----BEGIN OPENSSH PRIVATE KEY-----/ },
351356
+ { name: "RSA key", re: /-----BEGIN RSA PRIVATE KEY-----/ },
351357
+ { name: "CERTIFICATE", re: /-----BEGIN CERTIFICATE-----/ },
351358
+ { name: "TOKEN=", re: /\bTOKEN\s*=\s*["']?[A-Za-z0-9._\-+/=]{10,}/ },
351359
+ { name: "api_key=", re: /\bapi[_-]?key\s*=\s*["']?[A-Za-z0-9._\-+/=]{10,}/i },
351360
+ { name: "password=", re: /\bpassword\s*=\s*["']?[^\s"']{6,}/i },
351361
+ { name: "AWS_SECRET_ACCESS_KEY", re: /AWS_SECRET_ACCESS_KEY/ },
351362
+ { name: "JWT (eyJ...)", re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/ }
351363
+ ];
351364
+ var SENSITIVE_FILE_PATTERNS = [
351365
+ /(^|\/)\.env(\.[^/]+)?$/,
351366
+ /\.pem$/,
351367
+ /\.key$/,
351368
+ /\.pkce_state\.json$/,
351369
+ /(^|\/)id_rsa$/,
351370
+ /(^|\/)id_ed25519$/
351371
+ ];
351372
+ function isSensitiveFile(filePath) {
351373
+ return SENSITIVE_FILE_PATTERNS.some((re17) => re17.test(filePath));
351374
+ }
351375
+ function scanForCredentials(content5) {
351376
+ const matches2 = [];
351377
+ for (const { name: name2, re: re17 } of CREDENTIAL_PATTERNS) {
351378
+ if (re17.test(content5)) {
351379
+ matches2.push(name2);
351380
+ }
351381
+ }
351382
+ return matches2;
351383
+ }
351332
351384
  init_GitClient();
351333
351385
  var FERN_BOT_NAME = "fern-api";
351334
351386
  var FERN_BOT_EMAIL = "115122769+fern-api[bot]@users.noreply.github.com";
@@ -351370,6 +351422,7 @@ var require_dist14 = __commonJS({
351370
351422
  var LockfileManager = class {
351371
351423
  outputDir;
351372
351424
  lock = null;
351425
+ warnings = [];
351373
351426
  constructor(outputDir) {
351374
351427
  this.outputDir = outputDir;
351375
351428
  }
@@ -351418,6 +351471,27 @@ var require_dist14 = __commonJS({
351418
351471
  if (!this.lock) {
351419
351472
  throw new Error("No lockfile data to save. Call read() or initialize() first.");
351420
351473
  }
351474
+ this.warnings.length = 0;
351475
+ const cleanPatches = [];
351476
+ for (const patch5 of this.lock.patches) {
351477
+ const credentialMatches = scanForCredentials(patch5.patch_content);
351478
+ const sensitiveFiles = patch5.files.filter((f10) => isSensitiveFile(f10));
351479
+ if (credentialMatches.length > 0 || sensitiveFiles.length > 0) {
351480
+ const reasons = [];
351481
+ if (credentialMatches.length > 0) {
351482
+ reasons.push(`credential patterns: ${credentialMatches.join(", ")}`);
351483
+ }
351484
+ if (sensitiveFiles.length > 0) {
351485
+ reasons.push(`sensitive files: ${sensitiveFiles.join(", ")}`);
351486
+ }
351487
+ this.warnings.push(
351488
+ `Patch ${patch5.id} removed from lockfile: ${reasons.join("; ")}. This prevents credential exposure in committed lockfiles.`
351489
+ );
351490
+ } else {
351491
+ cleanPatches.push(patch5);
351492
+ }
351493
+ }
351494
+ this.lock.patches = cleanPatches;
351421
351495
  const dir = (0, import_node_path41.dirname)(this.lockfilePath);
351422
351496
  if (!(0, import_node_fs32.existsSync)(dir)) {
351423
351497
  (0, import_node_fs32.mkdirSync)(dir, { recursive: true });
@@ -351520,6 +351594,37 @@ var require_dist14 = __commonJS({
351520
351594
  };
351521
351595
  var import_node_crypto7 = require("crypto");
351522
351596
  var INFRASTRUCTURE_FILES = /* @__PURE__ */ new Set([".fernignore"]);
351597
+ function parsePatchFileHeaders(patchContent) {
351598
+ const results = [];
351599
+ let currentPath = null;
351600
+ let currentIsCreate = false;
351601
+ let currentIsDelete = false;
351602
+ const flush2 = () => {
351603
+ if (currentPath !== null) {
351604
+ results.push({ path: currentPath, isCreate: currentIsCreate, isDelete: currentIsDelete });
351605
+ }
351606
+ };
351607
+ for (const line of patchContent.split("\n")) {
351608
+ if (line.startsWith("diff --git ")) {
351609
+ flush2();
351610
+ currentPath = null;
351611
+ currentIsCreate = false;
351612
+ currentIsDelete = false;
351613
+ const match3 = line.match(/^diff --git a\/(.+?) b\/(.+?)$/);
351614
+ if (match3) {
351615
+ currentPath = match3[2] ?? null;
351616
+ }
351617
+ } else if (currentPath !== null) {
351618
+ if (line.startsWith("new file mode ")) {
351619
+ currentIsCreate = true;
351620
+ } else if (line.startsWith("deleted file mode ")) {
351621
+ currentIsDelete = true;
351622
+ }
351623
+ }
351624
+ }
351625
+ flush2();
351626
+ return results;
351627
+ }
351523
351628
  var ReplayDetector = class {
351524
351629
  git;
351525
351630
  lockManager;
@@ -351598,12 +351703,31 @@ var require_dist14 = __commonJS({
351598
351703
  );
351599
351704
  continue;
351600
351705
  }
351601
- const contentHash = this.computeContentHash(patchContent);
351706
+ let contentHash = this.computeContentHash(patchContent);
351602
351707
  if (lock.patches.find((p10) => p10.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
351603
351708
  continue;
351604
351709
  }
351605
351710
  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));
351711
+ const allFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f10) => !INFRASTRUCTURE_FILES.has(f10)).filter((f10) => !f10.startsWith(".fern/"));
351712
+ const sensitiveFiles = allFiles.filter((f10) => isSensitiveFile(f10));
351713
+ const files = allFiles.filter((f10) => !isSensitiveFile(f10));
351714
+ if (sensitiveFiles.length > 0) {
351715
+ this.warnings.push(
351716
+ `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
351717
+ );
351718
+ if (files.length > 0) {
351719
+ const parentSha = parents[0];
351720
+ if (parentSha) {
351721
+ try {
351722
+ patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
351723
+ } catch {
351724
+ continue;
351725
+ }
351726
+ if (!patchContent.trim()) continue;
351727
+ contentHash = this.computeContentHash(patchContent);
351728
+ }
351729
+ }
351730
+ }
351607
351731
  if (files.length === 0) {
351608
351732
  continue;
351609
351733
  }
@@ -351615,14 +351739,16 @@ var require_dist14 = __commonJS({
351615
351739
  original_author: `${commit.authorName} <${commit.authorEmail}>`,
351616
351740
  base_generation: lastGen.commit_sha,
351617
351741
  files,
351618
- patch_content: patchContent
351742
+ patch_content: patchContent,
351743
+ ...this.isCreationOnlyPatch(patchContent) ? { user_owned: true } : {}
351619
351744
  });
351620
351745
  }
351621
- newPatches.reverse();
351746
+ const survivingPatches = await this.collapseNetZeroFiles(newPatches);
351747
+ survivingPatches.reverse();
351622
351748
  const revertedPatchIdSet = /* @__PURE__ */ new Set();
351623
351749
  const revertIndicesToRemove = /* @__PURE__ */ new Set();
351624
- for (let i11 = 0; i11 < newPatches.length; i11++) {
351625
- const patch5 = newPatches[i11];
351750
+ for (let i11 = 0; i11 < survivingPatches.length; i11++) {
351751
+ const patch5 = survivingPatches[i11];
351626
351752
  if (!isRevertCommit(patch5.original_message)) continue;
351627
351753
  let body = "";
351628
351754
  try {
@@ -351651,7 +351777,7 @@ var require_dist14 = __commonJS({
351651
351777
  if (matchedExisting) continue;
351652
351778
  let matchedNew = false;
351653
351779
  if (revertedSha) {
351654
- const idx = newPatches.findIndex(
351780
+ const idx = survivingPatches.findIndex(
351655
351781
  (p10, j16) => j16 !== i11 && !revertIndicesToRemove.has(j16) && p10.original_commit === revertedSha
351656
351782
  );
351657
351783
  if (idx !== -1) {
@@ -351661,7 +351787,7 @@ var require_dist14 = __commonJS({
351661
351787
  }
351662
351788
  }
351663
351789
  if (!matchedNew && revertedMessage) {
351664
- const idx = newPatches.findIndex(
351790
+ const idx = survivingPatches.findIndex(
351665
351791
  (p10, j16) => j16 !== i11 && !revertIndicesToRemove.has(j16) && p10.original_message === revertedMessage
351666
351792
  );
351667
351793
  if (idx !== -1) {
@@ -351673,18 +351799,107 @@ var require_dist14 = __commonJS({
351673
351799
  revertIndicesToRemove.add(i11);
351674
351800
  }
351675
351801
  }
351676
- const filteredPatches = newPatches.filter((_16, i11) => !revertIndicesToRemove.has(i11));
351802
+ const filteredPatches = survivingPatches.filter((_16, i11) => !revertIndicesToRemove.has(i11));
351677
351803
  return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
351678
351804
  }
351679
351805
  /**
351680
351806
  * 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.
351807
+ *
351808
+ * Produces a format-agnostic hash so both `git format-patch` output
351809
+ * (email-wrapped) and plain `git diff` output hash to the same value
351810
+ * when the underlying diff hunks are identical.
351811
+ *
351812
+ * Normalization:
351813
+ * 1. Strip email wrapper: everything before the first `diff --git` line
351814
+ * (From:, Subject:, Date:, diffstat, blank separators).
351815
+ * 2. Strip `index` lines (blob SHA pairs change across rebases).
351816
+ * 3. Strip trailing git version marker (`-- \n<version>`).
351817
+ *
351818
+ * This ensures content hashes match across the no-patches and normal-
351819
+ * regeneration flows, which store formatPatch and git-diff formats
351820
+ * respectively (FER-9850, D5-D9).
351683
351821
  */
351684
351822
  computeContentHash(patchContent) {
351685
- const normalized = patchContent.split("\n").filter((line) => !line.startsWith("From ") && !line.startsWith("index ") && !line.startsWith("Date: ")).join("\n");
351823
+ const lines = patchContent.split("\n");
351824
+ const diffStart = lines.findIndex((l14) => l14.startsWith("diff --git "));
351825
+ const relevantLines = diffStart > 0 ? lines.slice(diffStart) : lines;
351826
+ const normalized = relevantLines.filter((line) => !line.startsWith("index ")).join("\n").replace(/\n-- \n[\d]+\.[\d]+[^\n]*\s*$/, "");
351686
351827
  return `sha256:${(0, import_node_crypto7.createHash)("sha256").update(normalized).digest("hex")}`;
351687
351828
  }
351829
+ /**
351830
+ * FER-9805 — Drop patches whose files were transiently created and then
351831
+ * deleted within the current linear detection window, and are absent from
351832
+ * HEAD at the end of the window.
351833
+ *
351834
+ * Rationale: on a merge commit, createMergeCompositePatch already diffs
351835
+ * firstParent..mergeCommit so net-zero files never enter the composite. On
351836
+ * linear history each commit becomes its own patch, so a file that was
351837
+ * briefly added and later removed survives as a create+delete pair whose
351838
+ * cumulative diff is empty. We drop such patches before they reach the
351839
+ * lockfile.
351840
+ *
351841
+ * A file is "transient" when:
351842
+ * 1. some patch in the window sets `new file mode` for it (a creation), AND
351843
+ * 2. some patch in the window sets `deleted file mode` for it (a deletion), AND
351844
+ * 3. it is absent from HEAD's tree.
351845
+ *
351846
+ * The HEAD guard (#3) prevents false positives when a file is deleted
351847
+ * and then recreated with different content — the cumulative diff of
351848
+ * such a pair is non-empty and must be preserved.
351849
+ *
351850
+ * This only drops patches whose `files` are a subset of the transient
351851
+ * set. A partial-transient patch (one that touches a transient file
351852
+ * alongside a persistent one) is left unmodified; surgical stripping of
351853
+ * single files from a multi-file patch would require a follow-up that
351854
+ * regenerates the patch content via `git format-patch -- <files>`. No
351855
+ * current failing test exercises this, and leaving the patch intact
351856
+ * preserves today's behaviour. TODO(FER-9805): revisit if a future
351857
+ * adversarial test demands per-file stripping.
351858
+ */
351859
+ async collapseNetZeroFiles(patches) {
351860
+ if (patches.length === 0) return patches;
351861
+ const stateByFile = /* @__PURE__ */ new Map();
351862
+ for (const patch5 of patches) {
351863
+ for (const header of parsePatchFileHeaders(patch5.patch_content)) {
351864
+ if (!header.isCreate && !header.isDelete) continue;
351865
+ const prior = stateByFile.get(header.path) ?? { created: false, deleted: false };
351866
+ if (header.isCreate) prior.created = true;
351867
+ if (header.isDelete) prior.deleted = true;
351868
+ stateByFile.set(header.path, prior);
351869
+ }
351870
+ }
351871
+ let anyCandidate = false;
351872
+ for (const state of stateByFile.values()) {
351873
+ if (state.created && state.deleted) {
351874
+ anyCandidate = true;
351875
+ break;
351876
+ }
351877
+ }
351878
+ if (!anyCandidate) return patches;
351879
+ const headFiles = await this.listHeadFiles();
351880
+ const transient = /* @__PURE__ */ new Set();
351881
+ for (const [file4, state] of stateByFile) {
351882
+ if (state.created && state.deleted && !headFiles.has(file4)) {
351883
+ transient.add(file4);
351884
+ }
351885
+ }
351886
+ if (transient.size === 0) return patches;
351887
+ return patches.filter((patch5) => !patch5.files.every((f10) => transient.has(f10)));
351888
+ }
351889
+ /**
351890
+ * List every tracked path at HEAD (one `git ls-tree -r` invocation).
351891
+ * Returns an empty Set if HEAD is unborn or the invocation fails — the
351892
+ * caller then treats every candidate as "not in HEAD" and may collapse
351893
+ * more aggressively. On typical SDK repos this is a single sub-10ms call.
351894
+ */
351895
+ async listHeadFiles() {
351896
+ try {
351897
+ const out = await this.git.exec(["ls-tree", "-r", "--name-only", "HEAD"]);
351898
+ return new Set(out.split("\n").map((l14) => l14.trim()).filter(Boolean));
351899
+ } catch {
351900
+ return /* @__PURE__ */ new Set();
351901
+ }
351902
+ }
351688
351903
  /**
351689
351904
  * Create a single composite patch for a merge commit by diffing the merge
351690
351905
  * commit against its first parent. This captures the net change of the
@@ -351700,7 +351915,14 @@ var require_dist14 = __commonJS({
351700
351915
  firstParent,
351701
351916
  mergeCommit.sha
351702
351917
  ]);
351703
- const files = filesOutput.trim().split("\n").filter(Boolean).filter((f10) => !INFRASTRUCTURE_FILES.has(f10)).filter((f10) => !f10.startsWith(".fern/"));
351918
+ const allMergeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f10) => !INFRASTRUCTURE_FILES.has(f10)).filter((f10) => !f10.startsWith(".fern/"));
351919
+ const sensitiveMergeFiles = allMergeFiles.filter((f10) => isSensitiveFile(f10));
351920
+ const files = allMergeFiles.filter((f10) => !isSensitiveFile(f10));
351921
+ if (sensitiveMergeFiles.length > 0) {
351922
+ this.warnings.push(
351923
+ `Sensitive file(s) excluded from merge patch for commit ${mergeCommit.sha.slice(0, 7)}: ${sensitiveMergeFiles.join(", ")}. These files will not be tracked by replay.`
351924
+ );
351925
+ }
351704
351926
  if (files.length === 0) return null;
351705
351927
  const diff2 = await this.git.exec([
351706
351928
  "diff",
@@ -351722,7 +351944,8 @@ var require_dist14 = __commonJS({
351722
351944
  original_author: `${mergeCommit.authorName} <${mergeCommit.authorEmail}>`,
351723
351945
  base_generation: lastGen.commit_sha,
351724
351946
  files,
351725
- patch_content: diff2
351947
+ patch_content: diff2,
351948
+ ...this.isCreationOnlyPatch(diff2) ? { user_owned: true } : {}
351726
351949
  };
351727
351950
  }
351728
351951
  /**
@@ -351736,7 +351959,14 @@ var require_dist14 = __commonJS({
351736
351959
  return this.detectPatchesViaCommitScan();
351737
351960
  }
351738
351961
  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/"));
351962
+ const allTreeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f10) => !INFRASTRUCTURE_FILES.has(f10)).filter((f10) => !f10.startsWith(".fern/"));
351963
+ const sensitiveTreeFiles = allTreeFiles.filter((f10) => isSensitiveFile(f10));
351964
+ const files = allTreeFiles.filter((f10) => !isSensitiveFile(f10));
351965
+ if (sensitiveTreeFiles.length > 0) {
351966
+ this.warnings.push(
351967
+ `Sensitive file(s) excluded from composite patch: ${sensitiveTreeFiles.join(", ")}. These files will not be tracked by replay.`
351968
+ );
351969
+ }
351740
351970
  if (files.length === 0) return { patches: [], revertedPatchIds: [] };
351741
351971
  const diff2 = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
351742
351972
  if (!diff2.trim()) return { patches: [], revertedPatchIds: [] };
@@ -351756,7 +351986,8 @@ var require_dist14 = __commonJS({
351756
351986
  // reference to find base file content. diffBase may be the tree_hash.
351757
351987
  base_generation: commitKnownMissing ? diffBase : lastGen.commit_sha,
351758
351988
  files,
351759
- patch_content: diff2
351989
+ patch_content: diff2,
351990
+ ...this.isCreationOnlyPatch(diff2) ? { user_owned: true } : {}
351760
351991
  };
351761
351992
  return { patches: [compositePatch], revertedPatchIds: [] };
351762
351993
  }
@@ -351803,7 +352034,7 @@ var require_dist14 = __commonJS({
351803
352034
  } catch {
351804
352035
  continue;
351805
352036
  }
351806
- const contentHash = this.computeContentHash(patchContent);
352037
+ let contentHash = this.computeContentHash(patchContent);
351807
352038
  if (existingHashes.has(contentHash) || forgottenHashes.has(contentHash)) {
351808
352039
  continue;
351809
352040
  }
@@ -351811,7 +352042,26 @@ var require_dist14 = __commonJS({
351811
352042
  continue;
351812
352043
  }
351813
352044
  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));
352045
+ const allScanFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f10) => !INFRASTRUCTURE_FILES.has(f10));
352046
+ const sensitiveScanFiles = allScanFiles.filter((f10) => isSensitiveFile(f10));
352047
+ const files = allScanFiles.filter((f10) => !isSensitiveFile(f10));
352048
+ if (sensitiveScanFiles.length > 0) {
352049
+ this.warnings.push(
352050
+ `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveScanFiles.join(", ")}. These files will not be tracked by replay.`
352051
+ );
352052
+ if (files.length > 0) {
352053
+ if (parents.length === 0) {
352054
+ continue;
352055
+ }
352056
+ try {
352057
+ patchContent = await this.git.exec(["diff", parents[0], commit.sha, "--", ...files]);
352058
+ } catch {
352059
+ continue;
352060
+ }
352061
+ if (!patchContent.trim()) continue;
352062
+ contentHash = this.computeContentHash(patchContent);
352063
+ }
352064
+ }
351815
352065
  if (files.length === 0) {
351816
352066
  continue;
351817
352067
  }
@@ -352135,6 +352385,20 @@ var require_dist14 = __commonJS({
352135
352385
  resetAccumulator() {
352136
352386
  this.fileTheirsAccumulator.clear();
352137
352387
  }
352388
+ /**
352389
+ * @internal Test-only.
352390
+ * Returns a defensive copy of the inter-patch accumulator. Used by the
352391
+ * adversarial test suite (FER-9791) to assert invariant I2: after every
352392
+ * applied patch, the accumulator has an entry for each file the patch
352393
+ * touched, and the entry's content matches on-disk.
352394
+ */
352395
+ getAccumulatorSnapshot() {
352396
+ const snapshot = /* @__PURE__ */ new Map();
352397
+ for (const [path98, entry] of this.fileTheirsAccumulator) {
352398
+ snapshot.set(path98, { content: entry.content, baseGeneration: entry.baseGeneration });
352399
+ }
352400
+ return snapshot;
352401
+ }
352138
352402
  /**
352139
352403
  * Apply all patches, returning results for each.
352140
352404
  * Skips patches that match exclude patterns in replay.yml
@@ -352200,7 +352464,7 @@ var require_dist14 = __commonJS({
352200
352464
  const base4 = await this.git.showFile(baseGen.tree_hash, filePath);
352201
352465
  const theirs = await this.applyPatchToContent(base4, patch5.patch_content, filePath, tempGit, tempDir2);
352202
352466
  const effectiveTheirs = theirs ?? await (0, import_promises183.readFile)((0, import_node_path210.join)(this.outputDir, resolvedPath), "utf-8").catch(() => null);
352203
- if (effectiveTheirs) {
352467
+ if (effectiveTheirs != null) {
352204
352468
  this.fileTheirsAccumulator.set(resolvedPath, {
352205
352469
  content: effectiveTheirs,
352206
352470
  baseGeneration: patch5.base_generation
@@ -352224,16 +352488,20 @@ var require_dist14 = __commonJS({
352224
352488
  return this.fileTheirsAccumulator.has(resolved);
352225
352489
  })
352226
352490
  ).then((results) => results.some(Boolean));
352227
- if (!needsAccumulation) {
352491
+ const resolvedFiles = {};
352492
+ for (const filePath of patch5.files) {
352493
+ const resolvedPath = baseGen ? await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash) : filePath;
352494
+ if (resolvedPath !== filePath) {
352495
+ resolvedFiles[filePath] = resolvedPath;
352496
+ }
352497
+ }
352498
+ const patchIsRenameAware = /^rename from /m.test(patch5.patch_content);
352499
+ const hasGeneratorRename = Object.keys(resolvedFiles).length > 0 && !patchIsRenameAware;
352500
+ if (!needsAccumulation && !hasGeneratorRename) {
352228
352501
  const snapshots = /* @__PURE__ */ new Map();
352229
- const resolvedFiles = {};
352230
352502
  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));
352503
+ const fullPath = (0, import_node_path210.join)(this.outputDir, filePath);
352504
+ snapshots.set(filePath, await (0, import_promises183.readFile)(fullPath, "utf-8").catch(() => null));
352237
352505
  }
352238
352506
  try {
352239
352507
  await this.git.execWithInput(["apply", "--3way"], patch5.patch_content);
@@ -352245,8 +352513,8 @@ var require_dist14 = __commonJS({
352245
352513
  ...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
352246
352514
  };
352247
352515
  } catch {
352248
- for (const [resolvedPath, content5] of snapshots) {
352249
- const fullPath = (0, import_node_path210.join)(this.outputDir, resolvedPath);
352516
+ for (const [filePath, content5] of snapshots) {
352517
+ const fullPath = (0, import_node_path210.join)(this.outputDir, filePath);
352250
352518
  if (content5 != null) {
352251
352519
  await (0, import_promises183.writeFile)(fullPath, content5);
352252
352520
  } else {
@@ -352366,7 +352634,7 @@ var require_dist14 = __commonJS({
352366
352634
  }
352367
352635
  let useAccumulatorAsMergeBase = false;
352368
352636
  const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
352369
- if (accumulatorEntry && (!theirs || base4 == null)) {
352637
+ if (accumulatorEntry && (theirs == null || base4 == null)) {
352370
352638
  theirs = await this.applyPatchToContent(
352371
352639
  accumulatorEntry.content,
352372
352640
  patch5.patch_content,
@@ -352374,7 +352642,16 @@ var require_dist14 = __commonJS({
352374
352642
  tempGit,
352375
352643
  tempDir2
352376
352644
  );
352377
- if (theirs) {
352645
+ if (theirs != null) {
352646
+ useAccumulatorAsMergeBase = true;
352647
+ } else if (await this.isPatchAlreadyApplied(
352648
+ patch5.patch_content,
352649
+ filePath,
352650
+ accumulatorEntry.content,
352651
+ tempGit,
352652
+ tempDir2
352653
+ )) {
352654
+ theirs = accumulatorEntry.content;
352378
352655
  useAccumulatorAsMergeBase = true;
352379
352656
  }
352380
352657
  }
@@ -352391,7 +352668,7 @@ var require_dist14 = __commonJS({
352391
352668
  }
352392
352669
  let effective_theirs = theirs;
352393
352670
  let baseMismatchSkipped = false;
352394
- if (theirs && base4 && !useAccumulatorAsMergeBase) {
352671
+ if (theirs != null && base4 != null && !useAccumulatorAsMergeBase) {
352395
352672
  if (accumulatorEntry && accumulatorEntry.baseGeneration === patch5.base_generation) {
352396
352673
  try {
352397
352674
  const preMerged = threeWayMerge(base4, accumulatorEntry.content, theirs);
@@ -352407,7 +352684,7 @@ var require_dist14 = __commonJS({
352407
352684
  baseMismatchSkipped = true;
352408
352685
  }
352409
352686
  }
352410
- if (base4 == null && !ours && effective_theirs) {
352687
+ if (base4 == null && ours == null && effective_theirs != null) {
352411
352688
  this.fileTheirsAccumulator.set(resolvedPath, {
352412
352689
  content: effective_theirs,
352413
352690
  baseGeneration: patch5.base_generation
@@ -352417,7 +352694,7 @@ var require_dist14 = __commonJS({
352417
352694
  await (0, import_promises183.writeFile)(oursPath, effective_theirs);
352418
352695
  return { file: resolvedPath, status: "merged", reason: "new-file" };
352419
352696
  }
352420
- if (base4 == null && ours && effective_theirs && !useAccumulatorAsMergeBase) {
352697
+ if (base4 == null && ours != null && effective_theirs != null && !useAccumulatorAsMergeBase) {
352421
352698
  const merged2 = threeWayMerge("", ours, effective_theirs);
352422
352699
  const outDir2 = (0, import_node_path210.dirname)(oursPath);
352423
352700
  await (0, import_promises183.mkdir)(outDir2, { recursive: true });
@@ -352431,16 +352708,20 @@ var require_dist14 = __commonJS({
352431
352708
  conflictMetadata: metadata
352432
352709
  };
352433
352710
  }
352711
+ this.fileTheirsAccumulator.set(resolvedPath, {
352712
+ content: merged2.content,
352713
+ baseGeneration: patch5.base_generation
352714
+ });
352434
352715
  return { file: resolvedPath, status: "merged" };
352435
352716
  }
352436
- if (!effective_theirs) {
352717
+ if (effective_theirs == null) {
352437
352718
  return {
352438
352719
  file: resolvedPath,
352439
352720
  status: "skipped",
352440
352721
  reason: "missing-content"
352441
352722
  };
352442
352723
  }
352443
- if (base4 == null && !useAccumulatorAsMergeBase || !ours) {
352724
+ if (base4 == null && !useAccumulatorAsMergeBase || ours == null) {
352444
352725
  return {
352445
352726
  file: resolvedPath,
352446
352727
  status: "skipped",
@@ -352459,7 +352740,7 @@ var require_dist14 = __commonJS({
352459
352740
  const outDir = (0, import_node_path210.dirname)(oursPath);
352460
352741
  await (0, import_promises183.mkdir)(outDir, { recursive: true });
352461
352742
  await (0, import_promises183.writeFile)(oursPath, merged.content);
352462
- if (effective_theirs && !merged.hasConflicts) {
352743
+ if (effective_theirs != null && !merged.hasConflicts) {
352463
352744
  this.fileTheirsAccumulator.set(resolvedPath, {
352464
352745
  content: effective_theirs,
352465
352746
  baseGeneration: patch5.base_generation
@@ -352557,6 +352838,28 @@ var require_dist14 = __commonJS({
352557
352838
  return null;
352558
352839
  }
352559
352840
  }
352841
+ /**
352842
+ * Detects whether a patch's additions are already present in `content`.
352843
+ * Uses `git apply --reverse --check` — if the reverse patch applies cleanly,
352844
+ * the forward patch is effectively a no-op (its +lines are already there and
352845
+ * its -lines are already gone). Used to distinguish "patch already applied"
352846
+ * from "patch base mismatch" after a forward-apply fails.
352847
+ */
352848
+ async isPatchAlreadyApplied(patchContent, filePath, content5, tempGit, tempDir2) {
352849
+ const fileDiff = this.extractFileDiff(patchContent, filePath);
352850
+ if (!fileDiff) return false;
352851
+ try {
352852
+ const tempFilePath = (0, import_node_path210.join)(tempDir2, filePath);
352853
+ await (0, import_promises183.mkdir)((0, import_node_path210.dirname)(tempFilePath), { recursive: true });
352854
+ await (0, import_promises183.writeFile)(tempFilePath, content5);
352855
+ await tempGit.exec(["add", filePath]);
352856
+ await tempGit.exec(["commit", "-m", `check already-applied ${filePath}`, "--allow-empty"]);
352857
+ await tempGit.execWithInput(["apply", "--reverse", "--check", "--allow-empty"], fileDiff);
352858
+ return true;
352859
+ } catch {
352860
+ return false;
352861
+ }
352862
+ }
352560
352863
  extractFileDiff(patchContent, filePath) {
352561
352864
  const lines = patchContent.split("\n");
352562
352865
  const diffLines = [];
@@ -352721,6 +353024,33 @@ CLI Version: ${options2.cliVersion}`;
352721
353024
  committer;
352722
353025
  lockManager;
352723
353026
  outputDir;
353027
+ /** @internal Test-only. Captures the last ReplayResult[] returned from applicator.applyPatches(). */
353028
+ _lastApplyResults = [];
353029
+ /**
353030
+ * Service-level warnings accumulated during a single runReplay() call.
353031
+ * Merged with `detector.warnings` into `ReplayReport.warnings` by each flow handler.
353032
+ * Populated by `isFileUserOwned` when a patch's base generation tree is unreachable
353033
+ * (shallow clone / aggressive `git gc --prune`) and we fall back to a conservative
353034
+ * heuristic. Cleared at the top of `runReplay()`.
353035
+ */
353036
+ warnings = [];
353037
+ /**
353038
+ * @internal Test-only accessor.
353039
+ * Returns the ReplayApplicator instance used for the last run. Tests use this
353040
+ * to inspect the inter-patch accumulator after runReplay() completes (FER-9791, I2).
353041
+ */
353042
+ get applicatorRef() {
353043
+ return this.applicator;
353044
+ }
353045
+ /**
353046
+ * @internal Test-only accessor.
353047
+ * Returns the ReplayResult[] from the most recent applyPatches() call. Tests use
353048
+ * this to filter applied-vs-conflicted-vs-absorbed patches for invariant assertions
353049
+ * (FER-9791). Returns an empty array if no patches have been applied yet.
353050
+ */
353051
+ getLastResults() {
353052
+ return this._lastApplyResults;
353053
+ }
352724
353054
  constructor(outputDir, _config) {
352725
353055
  const git = new GitClient2(outputDir);
352726
353056
  this.git = git;
@@ -352731,6 +353061,7 @@ CLI Version: ${options2.cliVersion}`;
352731
353061
  this.committer = new ReplayCommitter(git, outputDir);
352732
353062
  }
352733
353063
  async runReplay(options2) {
353064
+ this.warnings = [];
352734
353065
  if (options2?.skipApplication) {
352735
353066
  return this.handleSkipApplication(options2);
352736
353067
  }
@@ -352804,6 +353135,7 @@ CLI Version: ${options2.cliVersion}`;
352804
353135
  );
352805
353136
  }
352806
353137
  this.lockManager.save();
353138
+ this.detector.warnings.push(...this.lockManager.warnings);
352807
353139
  }
352808
353140
  determineFlow() {
352809
353141
  try {
@@ -352870,6 +353202,7 @@ CLI Version: ${options2.cliVersion}`;
352870
353202
  }
352871
353203
  this.lockManager.setReplaySkippedAt((/* @__PURE__ */ new Date()).toISOString());
352872
353204
  this.lockManager.save();
353205
+ const credentialWarnings = [...this.lockManager.warnings];
352873
353206
  if (!options2?.stageOnly) {
352874
353207
  await this.committer.commitReplay(0);
352875
353208
  } else {
@@ -352881,13 +353214,15 @@ CLI Version: ${options2.cliVersion}`;
352881
353214
  patchesApplied: 0,
352882
353215
  patchesWithConflicts: 0,
352883
353216
  patchesSkipped: 0,
352884
- conflicts: []
353217
+ conflicts: [],
353218
+ warnings: credentialWarnings.length > 0 ? credentialWarnings : void 0
352885
353219
  };
352886
353220
  }
352887
353221
  async handleNoPatchesRegeneration(options2) {
352888
- const { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
352889
- const warnings = [...this.detector.warnings];
353222
+ let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
353223
+ const detectorWarnings = [...this.detector.warnings];
352890
353224
  if (options2?.dryRun) {
353225
+ const dryRunWarnings = [...detectorWarnings, ...this.warnings];
352891
353226
  return {
352892
353227
  flow: "no-patches",
352893
353228
  patchesDetected: newPatches.length,
@@ -352897,9 +353232,26 @@ CLI Version: ${options2.cliVersion}`;
352897
353232
  patchesReverted: revertedPatchIds.length,
352898
353233
  conflicts: [],
352899
353234
  wouldApply: newPatches,
352900
- warnings: warnings.length > 0 ? warnings : void 0
353235
+ warnings: dryRunWarnings.length > 0 ? dryRunWarnings : void 0
352901
353236
  };
352902
353237
  }
353238
+ {
353239
+ const preLock = this.lockManager.read();
353240
+ const preCurrentGen = preLock.current_generation;
353241
+ const uniqueFiles = [...new Set(newPatches.flatMap((p10) => p10.files))];
353242
+ const absorbedFiles = /* @__PURE__ */ new Set();
353243
+ for (const file4 of uniqueFiles) {
353244
+ const diff2 = await this.git.exec(["diff", preCurrentGen, "HEAD", "--", file4]).catch(() => null);
353245
+ if (diff2 !== null && !diff2.trim()) {
353246
+ absorbedFiles.add(file4);
353247
+ }
353248
+ }
353249
+ if (absorbedFiles.size > 0) {
353250
+ newPatches = newPatches.filter(
353251
+ (p10) => p10.files.length === 0 || !p10.files.every((f10) => absorbedFiles.has(f10))
353252
+ );
353253
+ }
353254
+ }
352903
353255
  for (const id2 of revertedPatchIds) {
352904
353256
  try {
352905
353257
  this.lockManager.removePatch(id2);
@@ -352918,6 +353270,7 @@ CLI Version: ${options2.cliVersion}`;
352918
353270
  let results = [];
352919
353271
  if (newPatches.length > 0) {
352920
353272
  results = await this.applicator.applyPatches(newPatches);
353273
+ this._lastApplyResults = results;
352921
353274
  this.revertConflictingFiles(results);
352922
353275
  for (const result of results) {
352923
353276
  if (result.status === "conflict") {
@@ -352932,6 +353285,7 @@ CLI Version: ${options2.cliVersion}`;
352932
353285
  }
352933
353286
  }
352934
353287
  this.lockManager.save();
353288
+ this.warnings.push(...this.lockManager.warnings);
352935
353289
  if (newPatches.length > 0) {
352936
353290
  if (!options2?.stageOnly) {
352937
353291
  const appliedCount = results.filter((r23) => r23.status === "applied").length;
@@ -352940,13 +353294,14 @@ CLI Version: ${options2.cliVersion}`;
352940
353294
  await this.committer.stageAll();
352941
353295
  }
352942
353296
  }
353297
+ const warnings = [...detectorWarnings, ...this.warnings];
352943
353298
  return this.buildReport("no-patches", newPatches, results, options2, warnings, rebaseCounts, void 0, revertedPatchIds.length);
352944
353299
  }
352945
353300
  async handleNormalRegeneration(options2) {
352946
353301
  if (options2?.dryRun) {
352947
353302
  const existingPatches2 = this.lockManager.getPatches();
352948
353303
  const { patches: newPatches2, revertedPatchIds: dryRunReverted } = await this.detector.detectNewPatches();
352949
- const warnings2 = [...this.detector.warnings];
353304
+ const warnings2 = [...this.detector.warnings, ...this.warnings];
352950
353305
  const allPatches2 = [...existingPatches2, ...newPatches2];
352951
353306
  return {
352952
353307
  flow: "normal-regeneration",
@@ -352978,7 +353333,7 @@ CLI Version: ${options2.cliVersion}`;
352978
353333
  }
352979
353334
  existingPatches = this.lockManager.getPatches();
352980
353335
  let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
352981
- const warnings = [...this.detector.warnings];
353336
+ const detectorWarnings = [...this.detector.warnings];
352982
353337
  if (removedByPreRebase.length > 0) {
352983
353338
  const removedOriginalCommits = new Set(removedByPreRebase.map((p10) => p10.original_commit));
352984
353339
  const removedOriginalMessages = new Set(removedByPreRebase.map((p10) => p10.original_message));
@@ -353010,6 +353365,7 @@ CLI Version: ${options2.cliVersion}`;
353010
353365
  const genRecord = await this.committer.createGenerationRecord(commitOpts);
353011
353366
  this.lockManager.addGeneration(genRecord);
353012
353367
  const results = await this.applicator.applyPatches(allPatches);
353368
+ this._lastApplyResults = results;
353013
353369
  this.revertConflictingFiles(results);
353014
353370
  for (const result of results) {
353015
353371
  if (result.status === "conflict") {
@@ -353031,12 +353387,14 @@ CLI Version: ${options2.cliVersion}`;
353031
353387
  }
353032
353388
  }
353033
353389
  this.lockManager.save();
353390
+ this.warnings.push(...this.lockManager.warnings);
353034
353391
  if (options2?.stageOnly) {
353035
353392
  await this.committer.stageAll();
353036
353393
  } else {
353037
353394
  const appliedCount = results.filter((r23) => r23.status === "applied").length;
353038
353395
  await this.committer.commitReplay(appliedCount, allPatches);
353039
353396
  }
353397
+ const warnings = [...detectorWarnings, ...this.warnings];
353040
353398
  return this.buildReport(
353041
353399
  "normal-regeneration",
353042
353400
  allPatches,
@@ -353060,6 +353418,7 @@ CLI Version: ${options2.cliVersion}`;
353060
353418
  let keptAsUserOwned = 0;
353061
353419
  const seenContentHashes = /* @__PURE__ */ new Set();
353062
353420
  const absorbedPatchIds = /* @__PURE__ */ new Set();
353421
+ const fernignorePatterns = this.readFernignorePatterns();
353063
353422
  for (const result of results) {
353064
353423
  if (result.resolvedFiles && Object.keys(result.resolvedFiles).length > 0) {
353065
353424
  const patch5 = result.patch;
@@ -353080,34 +353439,78 @@ CLI Version: ${options2.cliVersion}`;
353080
353439
  const patch5 = result.patch;
353081
353440
  if (patch5.base_generation === currentGenSha) continue;
353082
353441
  try {
353083
- const fernignorePatterns = this.readFernignorePatterns();
353084
- const isUserOwned = await Promise.all(
353442
+ const originalByResolved = {};
353443
+ if (result.resolvedFiles) {
353444
+ for (const [orig, resolved] of Object.entries(result.resolvedFiles)) {
353445
+ originalByResolved[resolved] = orig;
353446
+ }
353447
+ }
353448
+ const isUserOwnedPerFile = await Promise.all(
353085
353449
  patch5.files.map(async (file4) => {
353086
- if (file4 === ".fernignore") {
353450
+ if (file4 === ".fernignore") return true;
353451
+ if (fernignorePatterns.some((p10) => file4 === p10 || (0, import_minimatch22.minimatch)(file4, p10))) {
353087
353452
  return true;
353088
353453
  }
353089
- if (fernignorePatterns.some((p10) => file4 === p10 || (0, import_minimatch22.minimatch)(file4, p10))) {
353454
+ if (patch5.user_owned) return true;
353455
+ if (this.fileIsCreationInPatchContent(patch5.patch_content, originalByResolved[file4] ?? file4)) {
353090
353456
  return true;
353091
353457
  }
353092
353458
  const content5 = await this.git.showFile(currentGenSha, file4);
353093
353459
  return content5 === null;
353094
353460
  })
353095
353461
  );
353096
- const hasUserOwnedFiles = isUserOwned.some(Boolean);
353097
- if (hasUserOwnedFiles) {
353098
- this.lockManager.updatePatch(patch5.id, {
353099
- base_generation: currentGenSha
353100
- });
353462
+ const hasProtectedFiles = patch5.files.some(
353463
+ (file4) => file4 === ".fernignore" || fernignorePatterns.some((p10) => file4 === p10 || (0, import_minimatch22.minimatch)(file4, p10))
353464
+ );
353465
+ const allUserOwned = isUserOwnedPerFile.every(Boolean);
353466
+ if (allUserOwned && patch5.files.length > 0) {
353467
+ const baseChanged = patch5.base_generation !== currentGenSha;
353468
+ const userDiff = await this.git.exec(["diff", currentGenSha, "--", ...patch5.files]).catch(() => null);
353469
+ if (userDiff != null && userDiff.trim()) {
353470
+ const newHash = this.detector.computeContentHash(userDiff);
353471
+ patch5.patch_content = userDiff;
353472
+ patch5.content_hash = newHash;
353473
+ }
353474
+ patch5.base_generation = currentGenSha;
353475
+ try {
353476
+ this.lockManager.updatePatch(patch5.id, {
353477
+ base_generation: currentGenSha,
353478
+ ...!patch5.user_owned ? { user_owned: true } : {},
353479
+ ...userDiff != null && userDiff.trim() ? { patch_content: patch5.patch_content, content_hash: patch5.content_hash } : {}
353480
+ });
353481
+ } catch {
353482
+ }
353483
+ patch5.user_owned = true;
353101
353484
  keptAsUserOwned++;
353485
+ if (baseChanged) repointed++;
353102
353486
  continue;
353103
353487
  }
353104
353488
  const diff2 = await this.git.exec(["diff", currentGenSha, "--", ...patch5.files]).catch(() => null);
353105
- if (!diff2 || !diff2.trim()) {
353489
+ if (diff2 === null) continue;
353490
+ if (!diff2.trim()) {
353491
+ if (hasProtectedFiles) {
353492
+ patch5.base_generation = currentGenSha;
353493
+ try {
353494
+ this.lockManager.updatePatch(patch5.id, { base_generation: currentGenSha });
353495
+ } catch {
353496
+ }
353497
+ keptAsUserOwned++;
353498
+ continue;
353499
+ }
353106
353500
  this.lockManager.removePatch(patch5.id);
353107
353501
  absorbedPatchIds.add(patch5.id);
353108
353502
  absorbed++;
353109
353503
  continue;
353110
353504
  }
353505
+ if (hasProtectedFiles) {
353506
+ patch5.base_generation = currentGenSha;
353507
+ try {
353508
+ this.lockManager.updatePatch(patch5.id, { base_generation: currentGenSha });
353509
+ } catch {
353510
+ }
353511
+ keptAsUserOwned++;
353512
+ continue;
353513
+ }
353111
353514
  const newContentHash = this.detector.computeContentHash(diff2);
353112
353515
  if (seenContentHashes.has(newContentHash)) {
353113
353516
  this.lockManager.removePatch(patch5.id);
@@ -353116,17 +353519,89 @@ CLI Version: ${options2.cliVersion}`;
353116
353519
  continue;
353117
353520
  }
353118
353521
  seenContentHashes.add(newContentHash);
353119
- this.lockManager.updatePatch(patch5.id, {
353120
- base_generation: currentGenSha,
353121
- patch_content: diff2,
353122
- content_hash: newContentHash
353123
- });
353522
+ patch5.base_generation = currentGenSha;
353523
+ patch5.patch_content = diff2;
353524
+ patch5.content_hash = newContentHash;
353525
+ try {
353526
+ this.lockManager.updatePatch(patch5.id, {
353527
+ base_generation: currentGenSha,
353528
+ patch_content: diff2,
353529
+ content_hash: newContentHash
353530
+ });
353531
+ } catch {
353532
+ }
353124
353533
  contentRebased++;
353125
353534
  } catch {
353126
353535
  }
353127
353536
  }
353128
353537
  return { absorbed, repointed, contentRebased, keptAsUserOwned, absorbedPatchIds };
353129
353538
  }
353539
+ /**
353540
+ * Determine whether `file` is user-owned (never produced by the generator).
353541
+ *
353542
+ * Resolution order (first match wins):
353543
+ * 1. `patch.user_owned === true` — authoritative, persisted across cycles.
353544
+ * 2. `.fernignore` itself, or file matches a .fernignore pattern.
353545
+ * 3. Legacy-safe signal: `patch_content` shows this file as a `--- /dev/null`
353546
+ * creation. Works for patches that predate the `user_owned` field or
353547
+ * whose flag was lost. Immune to rename tricks — relies on raw patch text.
353548
+ * 4. Tree-based check against `patch.base_generation` when it is reachable
353549
+ * AND distinct from `currentGenSha`. If the base tree is unreachable
353550
+ * (shallow clone / aggressive `git gc --prune`), emit a one-time
353551
+ * customer-actionable warning (deduplicated via warnedGens) and
353552
+ * conservatively treat as user-owned — strictly safer than silent
353553
+ * absorption (FER-9809).
353554
+ * 5. Final fallback — check `currentGenSha` when there is no usable base
353555
+ * (legacy one-gen lockfile). Preserves pre-fix behavior for that edge.
353556
+ *
353557
+ * `originalFileName` is the pre-rename path when the generator moved the
353558
+ * file between the patch's base and the current generation. Tree lookups
353559
+ * at ancestor generations use the original path — that's where the
353560
+ * generator produced the content.
353561
+ */
353562
+ async isFileUserOwned(file4, patch5, currentGenSha, fernignorePatterns, warnedGens, originalFileName) {
353563
+ if (patch5.user_owned) return true;
353564
+ if (file4 === ".fernignore") return true;
353565
+ if (fernignorePatterns.some((p10) => file4 === p10 || (0, import_minimatch22.minimatch)(file4, p10))) return true;
353566
+ if (this.fileIsCreationInPatchContent(patch5.patch_content, originalFileName ?? file4)) {
353567
+ return true;
353568
+ }
353569
+ const base4 = patch5.base_generation;
353570
+ if (base4 && base4 !== currentGenSha) {
353571
+ const reachable = await this.git.commitExists(base4) || await this.git.treeExists(base4);
353572
+ if (!reachable) {
353573
+ if (!warnedGens.has(base4)) {
353574
+ warnedGens.add(base4);
353575
+ this.warnings.push(
353576
+ `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.`
353577
+ );
353578
+ }
353579
+ return true;
353580
+ }
353581
+ return await this.git.showFile(base4, originalFileName ?? file4) === null;
353582
+ }
353583
+ return await this.git.showFile(currentGenSha, originalFileName ?? file4) === null;
353584
+ }
353585
+ /**
353586
+ * Returns true if `file`'s diff section in `patchContent` starts with
353587
+ * `--- /dev/null`, meaning this file was introduced by the patch (user
353588
+ * creation). Used as a legacy-safe fallback when `patch.user_owned` is
353589
+ * absent or cleared.
353590
+ */
353591
+ fileIsCreationInPatchContent(patchContent, file4) {
353592
+ const lines = patchContent.split("\n");
353593
+ let inFileSection = false;
353594
+ for (const line of lines) {
353595
+ if (line.startsWith("diff --git ")) {
353596
+ inFileSection = line.includes(` b/${file4}`) || line.endsWith(` b/${file4}`);
353597
+ continue;
353598
+ }
353599
+ if (inFileSection && line.startsWith("--- ")) {
353600
+ return line === "--- /dev/null";
353601
+ }
353602
+ }
353603
+ return false;
353604
+ }
353130
353605
  /**
353131
353606
  * For conflict patches with mixed results (some files merged, some conflicted),
353132
353607
  * check if the cleanly merged files were absorbed by the generator (empty diff).
@@ -353140,13 +353615,14 @@ CLI Version: ${options2.cliVersion}`;
353140
353615
  const cleanFiles = result.fileResults.filter((f10) => f10.status === "merged").map((f10) => f10.file);
353141
353616
  if (cleanFiles.length === 0) return;
353142
353617
  const patch5 = result.patch;
353618
+ if (patch5.user_owned) return;
353143
353619
  const fernignorePatterns = this.readFernignorePatterns();
353620
+ const warnedGens = /* @__PURE__ */ new Set();
353144
353621
  const generatorCleanFiles = [];
353145
353622
  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;
353623
+ if (await this.isFileUserOwned(file4, patch5, currentGenSha, fernignorePatterns, warnedGens)) {
353624
+ continue;
353625
+ }
353150
353626
  generatorCleanFiles.push(file4);
353151
353627
  }
353152
353628
  if (generatorCleanFiles.length === 0) return;
@@ -353183,7 +353659,35 @@ CLI Version: ${options2.cliVersion}`;
353183
353659
  let conflictResolved = 0;
353184
353660
  let conflictAbsorbed = 0;
353185
353661
  let contentRefreshed = 0;
353662
+ const fernignorePatterns = this.readFernignorePatterns();
353663
+ const warnedGens = /* @__PURE__ */ new Set();
353664
+ const reachabilityCache = /* @__PURE__ */ new Map();
353665
+ const isReachable = async (sha) => {
353666
+ const cached2 = reachabilityCache.get(sha);
353667
+ if (cached2 !== void 0) return cached2;
353668
+ const reachable = await this.git.commitExists(sha) || await this.git.treeExists(sha);
353669
+ reachabilityCache.set(sha, reachable);
353670
+ return reachable;
353671
+ };
353672
+ const allPatchFilesUserOwned = async (patch5) => {
353673
+ if (patch5.user_owned) return true;
353674
+ if (patch5.files.length === 0) return false;
353675
+ for (const file4 of patch5.files) {
353676
+ if (!await this.isFileUserOwned(file4, patch5, currentGen, fernignorePatterns, warnedGens)) {
353677
+ return false;
353678
+ }
353679
+ }
353680
+ return true;
353681
+ };
353186
353682
  for (const patch5 of patches) {
353683
+ if (patch5.base_generation && patch5.base_generation !== currentGen && !warnedGens.has(patch5.base_generation)) {
353684
+ if (!await isReachable(patch5.base_generation)) {
353685
+ warnedGens.add(patch5.base_generation);
353686
+ this.warnings.push(
353687
+ `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.`
353688
+ );
353689
+ }
353690
+ }
353187
353691
  if (patch5.status != null) {
353188
353692
  delete patch5.status;
353189
353693
  continue;
@@ -353193,6 +353697,7 @@ CLI Version: ${options2.cliVersion}`;
353193
353697
  const diff2 = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch5.files]).catch(() => null);
353194
353698
  if (diff2 === null) continue;
353195
353699
  if (!diff2.trim()) {
353700
+ if (await allPatchFilesUserOwned(patch5)) continue;
353196
353701
  this.lockManager.removePatch(patch5.id);
353197
353702
  contentRefreshed++;
353198
353703
  continue;
@@ -353204,6 +353709,12 @@ CLI Version: ${options2.cliVersion}`;
353204
353709
  if (hasStaleMarkers) {
353205
353710
  continue;
353206
353711
  }
353712
+ const isProtected = patch5.files.some(
353713
+ (file4) => file4 === ".fernignore" || fernignorePatterns.some((p10) => file4 === p10 || (0, import_minimatch22.minimatch)(file4, p10))
353714
+ );
353715
+ if (isProtected) {
353716
+ continue;
353717
+ }
353207
353718
  const newContentHash = this.detector.computeContentHash(diff2);
353208
353719
  if (newContentHash !== patch5.content_hash) {
353209
353720
  const filesOutput = await this.git.exec(["diff", "--name-only", currentGen, "HEAD", "--", ...patch5.files]).catch(() => null);
@@ -353229,6 +353740,13 @@ CLI Version: ${options2.cliVersion}`;
353229
353740
  const diff2 = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch5.files]).catch(() => null);
353230
353741
  if (diff2 === null) continue;
353231
353742
  if (!diff2.trim()) {
353743
+ if (await allPatchFilesUserOwned(patch5)) {
353744
+ try {
353745
+ this.lockManager.updatePatch(patch5.id, { base_generation: currentGen });
353746
+ } catch {
353747
+ }
353748
+ continue;
353749
+ }
353232
353750
  this.lockManager.removePatch(patch5.id);
353233
353751
  conflictAbsorbed++;
353234
353752
  continue;
@@ -353736,7 +354254,10 @@ CLI Version: ${options2.cliVersion}`;
353736
354254
  (0, import_node_fs42.writeFileSync)(gitattributesPath, content5, "utf-8");
353737
354255
  }
353738
354256
  function computeContentHash(patchContent) {
353739
- const normalized = patchContent.split("\n").filter((line) => !line.startsWith("From ") && !line.startsWith("index ") && !line.startsWith("Date: ")).join("\n");
354257
+ const lines = patchContent.split("\n");
354258
+ const diffStart = lines.findIndex((l14) => l14.startsWith("diff --git "));
354259
+ const relevantLines = diffStart > 0 ? lines.slice(diffStart) : lines;
354260
+ const normalized = relevantLines.filter((line) => !line.startsWith("index ")).join("\n").replace(/\n-- \n[\d]+\.[\d]+[^\n]*\s*$/, "");
353740
354261
  return `sha256:${(0, import_node_crypto32.createHash)("sha256").update(normalized).digest("hex")}`;
353741
354262
  }
353742
354263
  var import_minimatch42 = require_commonjs3();
@@ -621394,7 +621915,7 @@ var AccessTokenPosthogManager = class {
621394
621915
  properties: {
621395
621916
  ...event,
621396
621917
  ...event.properties,
621397
- version: "4.75.0",
621918
+ version: "4.76.0",
621398
621919
  usingAccessToken: true
621399
621920
  }
621400
621921
  });
@@ -621448,7 +621969,7 @@ var UserPosthogManager = class {
621448
621969
  distinctId: this.userId ?? await this.getPersistedDistinctId(),
621449
621970
  event: "CLI",
621450
621971
  properties: {
621451
- version: "4.75.0",
621972
+ version: "4.76.0",
621452
621973
  ...event,
621453
621974
  ...event.properties,
621454
621975
  usingAccessToken: false,
@@ -838658,7 +839179,7 @@ var LOCAL_STORAGE_FOLDER4 = ".fern-dev";
838658
839179
  var LOGS_FOLDER_NAME = "logs";
838659
839180
  var MAX_LOGS_DIR_SIZE_BYTES = 100 * 1024 * 1024;
838660
839181
  function getCliSource() {
838661
- const version7 = "4.75.0";
839182
+ const version7 = "4.76.0";
838662
839183
  return `cli@${version7}`;
838663
839184
  }
838664
839185
  var DebugLogger = class {
@@ -839024,7 +839545,7 @@ function isWithinOutputDir(resolvedPath, outputDir) {
839024
839545
  const rel2 = import_path55.default.relative(outputDir, resolvedPath);
839025
839546
  return !rel2.startsWith("..") && !import_path55.default.isAbsolute(rel2);
839026
839547
  }
839027
- function resolveWindowsSymlinks(outputDir, symlinks, logger4) {
839548
+ function resolveWindowsSymlinks(outputDir, symlinks, logger4, onProgress) {
839028
839549
  if (symlinks.length === 0) {
839029
839550
  return;
839030
839551
  }
@@ -839094,6 +839615,7 @@ function resolveWindowsSymlinks(outputDir, symlinks, logger4) {
839094
839615
  logger4.debug(`Failed to resolve symlink ${symlinkPath}: ${error50}`);
839095
839616
  failed++;
839096
839617
  }
839618
+ onProgress?.(junctions + copies + alreadyExisted + failed, symlinks.length);
839097
839619
  }
839098
839620
  logger4.debug(`Symlink resolution: ${junctions} junctions, ${copies} file copies, ${alreadyExisted} pre-existing, ${failed} failed (${fallbackUsed} via fallback) out of ${symlinks.length}`);
839099
839621
  const standaloneNM = import_path55.default.join(outputDir, "standalone", "node_modules");
@@ -839232,7 +839754,7 @@ async function downloadBundle({ bucketUrl, logger: logger4, preferCached, app =
839232
839754
  hideCursor: true
839233
839755
  });
839234
839756
  unzipProgressBar.start(100, 0);
839235
- const UNZIP_DURATION_MS = 3e4;
839757
+ const UNZIP_DURATION_MS = PLATFORM_IS_WINDOWS ? 6e4 : 3e4;
839236
839758
  const startTime = Date.now();
839237
839759
  unzipInterval = setInterval(() => {
839238
839760
  const elapsed = Date.now() - startTime;
@@ -839269,7 +839791,21 @@ async function downloadBundle({ bucketUrl, logger: logger4, preferCached, app =
839269
839791
  }
839270
839792
  }
839271
839793
  if (PLATFORM_IS_WINDOWS && collectedSymlinks.length > 0) {
839272
- resolveWindowsSymlinks(absolutePathToBundleFolder, collectedSymlinks, logger4);
839794
+ let symlinkProgressBar;
839795
+ if (app) {
839796
+ symlinkProgressBar = new import_cli_progress.default.SingleBar({
839797
+ format: `${DOCS_PREFIX} ${formatProgressLabel("Patching symlinks")} [{bar}] {percentage}% | {value}/{total}`,
839798
+ barCompleteChar: "\u2588",
839799
+ barIncompleteChar: "\u2591",
839800
+ hideCursor: true
839801
+ });
839802
+ symlinkProgressBar.start(collectedSymlinks.length, 0);
839803
+ }
839804
+ resolveWindowsSymlinks(absolutePathToBundleFolder, collectedSymlinks, logger4, symlinkProgressBar ? (resolved, total) => symlinkProgressBar?.update(resolved) : void 0);
839805
+ if (symlinkProgressBar) {
839806
+ symlinkProgressBar.update(collectedSymlinks.length);
839807
+ symlinkProgressBar.stop();
839808
+ }
839273
839809
  }
839274
839810
  await (0, import_promises78.writeFile)(eTagFilepath, eTag);
839275
839811
  logger4.debug(`Downloaded bundle to ${absolutePathToBundleFolder}`);
@@ -849958,7 +850494,7 @@ var LegacyDocsPublisher = class {
849958
850494
  previewId,
849959
850495
  disableTemplates: void 0,
849960
850496
  skipUpload,
849961
- cliVersion: "4.75.0"
850497
+ cliVersion: "4.76.0"
849962
850498
  });
849963
850499
  if (taskContext.getResult() === TaskResult.Failure) {
849964
850500
  return { success: false };
@@ -924416,7 +924952,7 @@ var CliContext = class _CliContext {
924416
924952
  if (false) {
924417
924953
  this.logger.error("CLI_VERSION is not defined");
924418
924954
  }
924419
- return "4.75.0";
924955
+ return "4.76.0";
924420
924956
  }
924421
924957
  getCliName() {
924422
924958
  if (false) {
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "4.75.0",
2
+ "version": "4.76.0",
3
3
  "repository": {
4
4
  "type": "git",
5
5
  "url": "git+https://github.com/fern-api/fern.git",