@fern-api/generator-cli 0.9.8 → 0.9.9

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/dist/cli.js +589 -68
  2. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -20098,9 +20098,10 @@ var init_diff3 = __esm({
20098
20098
  }
20099
20099
  });
20100
20100
 
20101
- // ../../node_modules/.pnpm/@fern-api+replay@0.10.4/node_modules/@fern-api/replay/dist/index.js
20101
+ // ../../node_modules/.pnpm/@fern-api+replay@0.11.0/node_modules/@fern-api/replay/dist/index.js
20102
20102
  var dist_exports = {};
20103
20103
  __export(dist_exports, {
20104
+ CREDENTIAL_PATTERNS: () => CREDENTIAL_PATTERNS,
20104
20105
  FERN_BOT_EMAIL: () => FERN_BOT_EMAIL2,
20105
20106
  FERN_BOT_LOGIN: () => FERN_BOT_LOGIN2,
20106
20107
  FERN_BOT_NAME: () => FERN_BOT_NAME2,
@@ -20112,15 +20113,18 @@ __export(dist_exports, {
20112
20113
  ReplayCommitter: () => ReplayCommitter,
20113
20114
  ReplayDetector: () => ReplayDetector,
20114
20115
  ReplayService: () => ReplayService,
20116
+ SENSITIVE_FILE_PATTERNS: () => SENSITIVE_FILE_PATTERNS,
20115
20117
  bootstrap: () => bootstrap,
20116
20118
  forget: () => forget,
20117
20119
  isGenerationCommit: () => isGenerationCommit,
20118
20120
  isReplayCommit: () => isReplayCommit,
20119
20121
  isRevertCommit: () => isRevertCommit,
20122
+ isSensitiveFile: () => isSensitiveFile,
20120
20123
  parseRevertedMessage: () => parseRevertedMessage,
20121
20124
  parseRevertedSha: () => parseRevertedSha,
20122
20125
  reset: () => reset,
20123
20126
  resolve: () => resolve7,
20127
+ scanForCredentials: () => scanForCredentials,
20124
20128
  status: () => status,
20125
20129
  threeWayMerge: () => threeWayMerge
20126
20130
  });
@@ -20359,6 +20363,18 @@ function reconstructFromGhostPatch(fileDiff, ours) {
20359
20363
  }
20360
20364
  return assembleHybrid(located, oursLines);
20361
20365
  }
20366
+ function isSensitiveFile(filePath) {
20367
+ return SENSITIVE_FILE_PATTERNS.some((re) => re.test(filePath));
20368
+ }
20369
+ function scanForCredentials(content) {
20370
+ const matches = [];
20371
+ for (const { name, re } of CREDENTIAL_PATTERNS) {
20372
+ if (re.test(content)) {
20373
+ matches.push(name);
20374
+ }
20375
+ }
20376
+ return matches;
20377
+ }
20362
20378
  function isGenerationCommit(commit) {
20363
20379
  const isFernSupport = FERN_SUPPORT_NAMES.includes(commit.authorName);
20364
20380
  const isBotAuthor = !isFernSupport && (commit.authorLogin === FERN_BOT_LOGIN2 || commit.authorEmail === FERN_BOT_EMAIL2 || commit.authorName === FERN_BOT_NAME2);
@@ -20382,6 +20398,37 @@ function parseRevertedMessage(subject) {
20382
20398
  const match2 = subject.match(/^Revert "(.+)"$/);
20383
20399
  return match2?.[1];
20384
20400
  }
20401
+ function parsePatchFileHeaders(patchContent) {
20402
+ const results = [];
20403
+ let currentPath = null;
20404
+ let currentIsCreate = false;
20405
+ let currentIsDelete = false;
20406
+ const flush = () => {
20407
+ if (currentPath !== null) {
20408
+ results.push({ path: currentPath, isCreate: currentIsCreate, isDelete: currentIsDelete });
20409
+ }
20410
+ };
20411
+ for (const line of patchContent.split("\n")) {
20412
+ if (line.startsWith("diff --git ")) {
20413
+ flush();
20414
+ currentPath = null;
20415
+ currentIsCreate = false;
20416
+ currentIsDelete = false;
20417
+ const match2 = line.match(/^diff --git a\/(.+?) b\/(.+?)$/);
20418
+ if (match2) {
20419
+ currentPath = match2[2] ?? null;
20420
+ }
20421
+ } else if (currentPath !== null) {
20422
+ if (line.startsWith("new file mode ")) {
20423
+ currentIsCreate = true;
20424
+ } else if (line.startsWith("deleted file mode ")) {
20425
+ currentIsDelete = true;
20426
+ }
20427
+ }
20428
+ }
20429
+ flush();
20430
+ return results;
20431
+ }
20385
20432
  function threeWayMerge(base, ours, theirs) {
20386
20433
  const baseLines = base.split("\n");
20387
20434
  const oursLines = ours.split("\n");
@@ -20771,7 +20818,10 @@ function ensureGitattributesEntries(outputDir) {
20771
20818
  (0, import_fs9.writeFileSync)(gitattributesPath, content, "utf-8");
20772
20819
  }
20773
20820
  function computeContentHash(patchContent) {
20774
- const normalized = patchContent.split("\n").filter((line) => !line.startsWith("From ") && !line.startsWith("index ") && !line.startsWith("Date: ")).join("\n");
20821
+ const lines = patchContent.split("\n");
20822
+ const diffStart = lines.findIndex((l) => l.startsWith("diff --git "));
20823
+ const relevantLines = diffStart > 0 ? lines.slice(diffStart) : lines;
20824
+ const normalized = relevantLines.filter((line) => !line.startsWith("index ")).join("\n").replace(/\n-- \n[\d]+\.[\d]+[^\n]*\s*$/, "");
20775
20825
  return `sha256:${(0, import_crypto3.createHash)("sha256").update(normalized).digest("hex")}`;
20776
20826
  }
20777
20827
  function parseDiffStat(patchContent) {
@@ -21061,9 +21111,9 @@ function status(outputDir) {
21061
21111
  excludePatterns
21062
21112
  };
21063
21113
  }
21064
- var import_fs6, import_path8, import_yaml, import_crypto, import_promises4, import_os, import_path9, import_fs7, import_path10, import_crypto2, import_fs8, import_path11, import_yaml2, import_crypto3, import_fs9, import_path12, import_fs10, __defProp3, __getOwnPropNames3, __esm3, __export3, GitClient_exports, GitClient, init_GitClient, HybridReconstruction_exports, init_HybridReconstruction, FERN_BOT_NAME2, FERN_BOT_EMAIL2, FERN_BOT_LOGIN2, FERN_SUPPORT_NAMES, LOCKFILE_HEADER, LockfileNotFoundError, LockfileManager, INFRASTRUCTURE_FILES, ReplayDetector, CONFLICT_OPENER, CONFLICT_SEPARATOR, CONFLICT_CLOSER, BINARY_EXTENSIONS, ReplayApplicator, ReplayCommitter, ReplayService, FernignoreMigrator, REPLAY_FERNIGNORE_ENTRIES, GITATTRIBUTES_ENTRIES, EMPTY_RESULT;
21114
+ var import_fs6, import_path8, import_yaml, import_crypto, import_promises4, import_os, import_path9, import_fs7, import_path10, import_crypto2, import_fs8, import_path11, import_yaml2, import_crypto3, import_fs9, import_path12, import_fs10, __defProp3, __getOwnPropNames3, __esm3, __export3, GitClient_exports, GitClient, init_GitClient, HybridReconstruction_exports, init_HybridReconstruction, CREDENTIAL_PATTERNS, SENSITIVE_FILE_PATTERNS, FERN_BOT_NAME2, FERN_BOT_EMAIL2, FERN_BOT_LOGIN2, FERN_SUPPORT_NAMES, LOCKFILE_HEADER, LockfileNotFoundError, LockfileManager, INFRASTRUCTURE_FILES, ReplayDetector, CONFLICT_OPENER, CONFLICT_SEPARATOR, CONFLICT_CLOSER, BINARY_EXTENSIONS, ReplayApplicator, ReplayCommitter, ReplayService, FernignoreMigrator, REPLAY_FERNIGNORE_ENTRIES, GITATTRIBUTES_ENTRIES, EMPTY_RESULT;
21065
21115
  var init_dist3 = __esm({
21066
- "../../node_modules/.pnpm/@fern-api+replay@0.10.4/node_modules/@fern-api/replay/dist/index.js"() {
21116
+ "../../node_modules/.pnpm/@fern-api+replay@0.11.0/node_modules/@fern-api/replay/dist/index.js"() {
21067
21117
  "use strict";
21068
21118
  init_esm4();
21069
21119
  import_fs6 = require("fs");
@@ -21163,13 +21213,30 @@ var init_dist3 = __esm({
21163
21213
  const output = await this.exec(["rev-parse", `${commitSha}^@`]);
21164
21214
  return output.trim().split("\n").filter(Boolean);
21165
21215
  }
21216
+ /**
21217
+ * Detects renames and copies between two trees.
21218
+ *
21219
+ * Both `R` (rename) and `C` (copy) entries are returned as `{from, to}` pairs.
21220
+ * Copies are treated as rename-equivalent because the generator may produce the
21221
+ * new path while the old path remains in the tree (e.g., imperfect cleanup,
21222
+ * customer edits at the old path, or test helpers that only write new files).
21223
+ * Callers that care about "is the source still present" should verify via
21224
+ * `showFile(toTree, from)`.
21225
+ */
21166
21226
  async detectRenames(fromTree, toTree) {
21167
21227
  try {
21168
- const output = await this.exec(["diff", "--find-renames", "--name-status", fromTree, toTree]);
21228
+ const output = await this.exec([
21229
+ "diff",
21230
+ "--find-renames",
21231
+ "--find-copies",
21232
+ "--name-status",
21233
+ fromTree,
21234
+ toTree
21235
+ ]);
21169
21236
  const renames = [];
21170
21237
  for (const line of output.trim().split("\n")) {
21171
21238
  if (!line) continue;
21172
- if (line.startsWith("R")) {
21239
+ if (line.startsWith("R") || line.startsWith("C")) {
21173
21240
  const parts = line.split(" ");
21174
21241
  if (parts.length >= 3) {
21175
21242
  renames.push({ from: parts[1], to: parts[2] });
@@ -21226,6 +21293,25 @@ var init_dist3 = __esm({
21226
21293
  "use strict";
21227
21294
  }
21228
21295
  });
21296
+ CREDENTIAL_PATTERNS = [
21297
+ { name: "PRIVATE KEY block", re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
21298
+ { name: "OPENSSH key", re: /-----BEGIN OPENSSH PRIVATE KEY-----/ },
21299
+ { name: "RSA key", re: /-----BEGIN RSA PRIVATE KEY-----/ },
21300
+ { name: "CERTIFICATE", re: /-----BEGIN CERTIFICATE-----/ },
21301
+ { name: "TOKEN=", re: /\bTOKEN\s*=\s*["']?[A-Za-z0-9._\-+/=]{10,}/ },
21302
+ { name: "api_key=", re: /\bapi[_-]?key\s*=\s*["']?[A-Za-z0-9._\-+/=]{10,}/i },
21303
+ { name: "password=", re: /\bpassword\s*=\s*["']?[^\s"']{6,}/i },
21304
+ { name: "AWS_SECRET_ACCESS_KEY", re: /AWS_SECRET_ACCESS_KEY/ },
21305
+ { name: "JWT (eyJ...)", re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/ }
21306
+ ];
21307
+ SENSITIVE_FILE_PATTERNS = [
21308
+ /(^|\/)\.env(\.[^/]+)?$/,
21309
+ /\.pem$/,
21310
+ /\.key$/,
21311
+ /\.pkce_state\.json$/,
21312
+ /(^|\/)id_rsa$/,
21313
+ /(^|\/)id_ed25519$/
21314
+ ];
21229
21315
  init_GitClient();
21230
21316
  FERN_BOT_NAME2 = "fern-api";
21231
21317
  FERN_BOT_EMAIL2 = "115122769+fern-api[bot]@users.noreply.github.com";
@@ -21241,6 +21327,7 @@ var init_dist3 = __esm({
21241
21327
  LockfileManager = class {
21242
21328
  outputDir;
21243
21329
  lock = null;
21330
+ warnings = [];
21244
21331
  constructor(outputDir) {
21245
21332
  this.outputDir = outputDir;
21246
21333
  }
@@ -21289,6 +21376,27 @@ var init_dist3 = __esm({
21289
21376
  if (!this.lock) {
21290
21377
  throw new Error("No lockfile data to save. Call read() or initialize() first.");
21291
21378
  }
21379
+ this.warnings.length = 0;
21380
+ const cleanPatches = [];
21381
+ for (const patch of this.lock.patches) {
21382
+ const credentialMatches = scanForCredentials(patch.patch_content);
21383
+ const sensitiveFiles = patch.files.filter((f2) => isSensitiveFile(f2));
21384
+ if (credentialMatches.length > 0 || sensitiveFiles.length > 0) {
21385
+ const reasons = [];
21386
+ if (credentialMatches.length > 0) {
21387
+ reasons.push(`credential patterns: ${credentialMatches.join(", ")}`);
21388
+ }
21389
+ if (sensitiveFiles.length > 0) {
21390
+ reasons.push(`sensitive files: ${sensitiveFiles.join(", ")}`);
21391
+ }
21392
+ this.warnings.push(
21393
+ `Patch ${patch.id} removed from lockfile: ${reasons.join("; ")}. This prevents credential exposure in committed lockfiles.`
21394
+ );
21395
+ } else {
21396
+ cleanPatches.push(patch);
21397
+ }
21398
+ }
21399
+ this.lock.patches = cleanPatches;
21292
21400
  const dir = (0, import_path8.dirname)(this.lockfilePath);
21293
21401
  if (!(0, import_fs6.existsSync)(dir)) {
21294
21402
  (0, import_fs6.mkdirSync)(dir, { recursive: true });
@@ -21468,12 +21576,31 @@ var init_dist3 = __esm({
21468
21576
  );
21469
21577
  continue;
21470
21578
  }
21471
- const contentHash = this.computeContentHash(patchContent);
21579
+ let contentHash = this.computeContentHash(patchContent);
21472
21580
  if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
21473
21581
  continue;
21474
21582
  }
21475
21583
  const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
21476
- const files = filesOutput.trim().split("\n").filter(Boolean).filter((f2) => !INFRASTRUCTURE_FILES.has(f2));
21584
+ const allFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f2) => !INFRASTRUCTURE_FILES.has(f2)).filter((f2) => !f2.startsWith(".fern/"));
21585
+ const sensitiveFiles = allFiles.filter((f2) => isSensitiveFile(f2));
21586
+ const files = allFiles.filter((f2) => !isSensitiveFile(f2));
21587
+ if (sensitiveFiles.length > 0) {
21588
+ this.warnings.push(
21589
+ `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
21590
+ );
21591
+ if (files.length > 0) {
21592
+ const parentSha = parents[0];
21593
+ if (parentSha) {
21594
+ try {
21595
+ patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
21596
+ } catch {
21597
+ continue;
21598
+ }
21599
+ if (!patchContent.trim()) continue;
21600
+ contentHash = this.computeContentHash(patchContent);
21601
+ }
21602
+ }
21603
+ }
21477
21604
  if (files.length === 0) {
21478
21605
  continue;
21479
21606
  }
@@ -21485,14 +21612,16 @@ var init_dist3 = __esm({
21485
21612
  original_author: `${commit.authorName} <${commit.authorEmail}>`,
21486
21613
  base_generation: lastGen.commit_sha,
21487
21614
  files,
21488
- patch_content: patchContent
21615
+ patch_content: patchContent,
21616
+ ...this.isCreationOnlyPatch(patchContent) ? { user_owned: true } : {}
21489
21617
  });
21490
21618
  }
21491
- newPatches.reverse();
21619
+ const survivingPatches = await this.collapseNetZeroFiles(newPatches);
21620
+ survivingPatches.reverse();
21492
21621
  const revertedPatchIdSet = /* @__PURE__ */ new Set();
21493
21622
  const revertIndicesToRemove = /* @__PURE__ */ new Set();
21494
- for (let i = 0; i < newPatches.length; i++) {
21495
- const patch = newPatches[i];
21623
+ for (let i = 0; i < survivingPatches.length; i++) {
21624
+ const patch = survivingPatches[i];
21496
21625
  if (!isRevertCommit(patch.original_message)) continue;
21497
21626
  let body = "";
21498
21627
  try {
@@ -21521,7 +21650,7 @@ var init_dist3 = __esm({
21521
21650
  if (matchedExisting) continue;
21522
21651
  let matchedNew = false;
21523
21652
  if (revertedSha) {
21524
- const idx = newPatches.findIndex(
21653
+ const idx = survivingPatches.findIndex(
21525
21654
  (p, j2) => j2 !== i && !revertIndicesToRemove.has(j2) && p.original_commit === revertedSha
21526
21655
  );
21527
21656
  if (idx !== -1) {
@@ -21531,7 +21660,7 @@ var init_dist3 = __esm({
21531
21660
  }
21532
21661
  }
21533
21662
  if (!matchedNew && revertedMessage) {
21534
- const idx = newPatches.findIndex(
21663
+ const idx = survivingPatches.findIndex(
21535
21664
  (p, j2) => j2 !== i && !revertIndicesToRemove.has(j2) && p.original_message === revertedMessage
21536
21665
  );
21537
21666
  if (idx !== -1) {
@@ -21543,18 +21672,107 @@ var init_dist3 = __esm({
21543
21672
  revertIndicesToRemove.add(i);
21544
21673
  }
21545
21674
  }
21546
- const filteredPatches = newPatches.filter((_2, i) => !revertIndicesToRemove.has(i));
21675
+ const filteredPatches = survivingPatches.filter((_2, i) => !revertIndicesToRemove.has(i));
21547
21676
  return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
21548
21677
  }
21549
21678
  /**
21550
21679
  * Compute content hash for deduplication.
21551
- * Removes commit SHA line and index lines before hashing,
21552
- * so rebased commits with same content produce the same hash.
21680
+ *
21681
+ * Produces a format-agnostic hash so both `git format-patch` output
21682
+ * (email-wrapped) and plain `git diff` output hash to the same value
21683
+ * when the underlying diff hunks are identical.
21684
+ *
21685
+ * Normalization:
21686
+ * 1. Strip email wrapper: everything before the first `diff --git` line
21687
+ * (From:, Subject:, Date:, diffstat, blank separators).
21688
+ * 2. Strip `index` lines (blob SHA pairs change across rebases).
21689
+ * 3. Strip trailing git version marker (`-- \n<version>`).
21690
+ *
21691
+ * This ensures content hashes match across the no-patches and normal-
21692
+ * regeneration flows, which store formatPatch and git-diff formats
21693
+ * respectively (FER-9850, D5-D9).
21553
21694
  */
21554
21695
  computeContentHash(patchContent) {
21555
- const normalized = patchContent.split("\n").filter((line) => !line.startsWith("From ") && !line.startsWith("index ") && !line.startsWith("Date: ")).join("\n");
21696
+ const lines = patchContent.split("\n");
21697
+ const diffStart = lines.findIndex((l) => l.startsWith("diff --git "));
21698
+ const relevantLines = diffStart > 0 ? lines.slice(diffStart) : lines;
21699
+ const normalized = relevantLines.filter((line) => !line.startsWith("index ")).join("\n").replace(/\n-- \n[\d]+\.[\d]+[^\n]*\s*$/, "");
21556
21700
  return `sha256:${(0, import_crypto.createHash)("sha256").update(normalized).digest("hex")}`;
21557
21701
  }
21702
+ /**
21703
+ * FER-9805 — Drop patches whose files were transiently created and then
21704
+ * deleted within the current linear detection window, and are absent from
21705
+ * HEAD at the end of the window.
21706
+ *
21707
+ * Rationale: on a merge commit, createMergeCompositePatch already diffs
21708
+ * firstParent..mergeCommit so net-zero files never enter the composite. On
21709
+ * linear history each commit becomes its own patch, so a file that was
21710
+ * briefly added and later removed survives as a create+delete pair whose
21711
+ * cumulative diff is empty. We drop such patches before they reach the
21712
+ * lockfile.
21713
+ *
21714
+ * A file is "transient" when:
21715
+ * 1. some patch in the window sets `new file mode` for it (a creation), AND
21716
+ * 2. some patch in the window sets `deleted file mode` for it (a deletion), AND
21717
+ * 3. it is absent from HEAD's tree.
21718
+ *
21719
+ * The HEAD guard (#3) prevents false positives when a file is deleted
21720
+ * and then recreated with different content — the cumulative diff of
21721
+ * such a pair is non-empty and must be preserved.
21722
+ *
21723
+ * This only drops patches whose `files` are a subset of the transient
21724
+ * set. A partial-transient patch (one that touches a transient file
21725
+ * alongside a persistent one) is left unmodified; surgical stripping of
21726
+ * single files from a multi-file patch would require a follow-up that
21727
+ * regenerates the patch content via `git format-patch -- <files>`. No
21728
+ * current failing test exercises this, and leaving the patch intact
21729
+ * preserves today's behaviour. TODO(FER-9805): revisit if a future
21730
+ * adversarial test demands per-file stripping.
21731
+ */
21732
+ async collapseNetZeroFiles(patches) {
21733
+ if (patches.length === 0) return patches;
21734
+ const stateByFile = /* @__PURE__ */ new Map();
21735
+ for (const patch of patches) {
21736
+ for (const header of parsePatchFileHeaders(patch.patch_content)) {
21737
+ if (!header.isCreate && !header.isDelete) continue;
21738
+ const prior = stateByFile.get(header.path) ?? { created: false, deleted: false };
21739
+ if (header.isCreate) prior.created = true;
21740
+ if (header.isDelete) prior.deleted = true;
21741
+ stateByFile.set(header.path, prior);
21742
+ }
21743
+ }
21744
+ let anyCandidate = false;
21745
+ for (const state of stateByFile.values()) {
21746
+ if (state.created && state.deleted) {
21747
+ anyCandidate = true;
21748
+ break;
21749
+ }
21750
+ }
21751
+ if (!anyCandidate) return patches;
21752
+ const headFiles = await this.listHeadFiles();
21753
+ const transient = /* @__PURE__ */ new Set();
21754
+ for (const [file, state] of stateByFile) {
21755
+ if (state.created && state.deleted && !headFiles.has(file)) {
21756
+ transient.add(file);
21757
+ }
21758
+ }
21759
+ if (transient.size === 0) return patches;
21760
+ return patches.filter((patch) => !patch.files.every((f2) => transient.has(f2)));
21761
+ }
21762
+ /**
21763
+ * List every tracked path at HEAD (one `git ls-tree -r` invocation).
21764
+ * Returns an empty Set if HEAD is unborn or the invocation fails — the
21765
+ * caller then treats every candidate as "not in HEAD" and may collapse
21766
+ * more aggressively. On typical SDK repos this is a single sub-10ms call.
21767
+ */
21768
+ async listHeadFiles() {
21769
+ try {
21770
+ const out = await this.git.exec(["ls-tree", "-r", "--name-only", "HEAD"]);
21771
+ return new Set(out.split("\n").map((l) => l.trim()).filter(Boolean));
21772
+ } catch {
21773
+ return /* @__PURE__ */ new Set();
21774
+ }
21775
+ }
21558
21776
  /**
21559
21777
  * Create a single composite patch for a merge commit by diffing the merge
21560
21778
  * commit against its first parent. This captures the net change of the
@@ -21570,7 +21788,14 @@ var init_dist3 = __esm({
21570
21788
  firstParent,
21571
21789
  mergeCommit.sha
21572
21790
  ]);
21573
- const files = filesOutput.trim().split("\n").filter(Boolean).filter((f2) => !INFRASTRUCTURE_FILES.has(f2)).filter((f2) => !f2.startsWith(".fern/"));
21791
+ const allMergeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f2) => !INFRASTRUCTURE_FILES.has(f2)).filter((f2) => !f2.startsWith(".fern/"));
21792
+ const sensitiveMergeFiles = allMergeFiles.filter((f2) => isSensitiveFile(f2));
21793
+ const files = allMergeFiles.filter((f2) => !isSensitiveFile(f2));
21794
+ if (sensitiveMergeFiles.length > 0) {
21795
+ this.warnings.push(
21796
+ `Sensitive file(s) excluded from merge patch for commit ${mergeCommit.sha.slice(0, 7)}: ${sensitiveMergeFiles.join(", ")}. These files will not be tracked by replay.`
21797
+ );
21798
+ }
21574
21799
  if (files.length === 0) return null;
21575
21800
  const diff = await this.git.exec([
21576
21801
  "diff",
@@ -21592,7 +21817,8 @@ var init_dist3 = __esm({
21592
21817
  original_author: `${mergeCommit.authorName} <${mergeCommit.authorEmail}>`,
21593
21818
  base_generation: lastGen.commit_sha,
21594
21819
  files,
21595
- patch_content: diff
21820
+ patch_content: diff,
21821
+ ...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
21596
21822
  };
21597
21823
  }
21598
21824
  /**
@@ -21606,7 +21832,14 @@ var init_dist3 = __esm({
21606
21832
  return this.detectPatchesViaCommitScan();
21607
21833
  }
21608
21834
  const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
21609
- const files = filesOutput.trim().split("\n").filter(Boolean).filter((f2) => !INFRASTRUCTURE_FILES.has(f2)).filter((f2) => !f2.startsWith(".fern/"));
21835
+ const allTreeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f2) => !INFRASTRUCTURE_FILES.has(f2)).filter((f2) => !f2.startsWith(".fern/"));
21836
+ const sensitiveTreeFiles = allTreeFiles.filter((f2) => isSensitiveFile(f2));
21837
+ const files = allTreeFiles.filter((f2) => !isSensitiveFile(f2));
21838
+ if (sensitiveTreeFiles.length > 0) {
21839
+ this.warnings.push(
21840
+ `Sensitive file(s) excluded from composite patch: ${sensitiveTreeFiles.join(", ")}. These files will not be tracked by replay.`
21841
+ );
21842
+ }
21610
21843
  if (files.length === 0) return { patches: [], revertedPatchIds: [] };
21611
21844
  const diff = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
21612
21845
  if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
@@ -21626,7 +21859,8 @@ var init_dist3 = __esm({
21626
21859
  // reference to find base file content. diffBase may be the tree_hash.
21627
21860
  base_generation: commitKnownMissing ? diffBase : lastGen.commit_sha,
21628
21861
  files,
21629
- patch_content: diff
21862
+ patch_content: diff,
21863
+ ...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
21630
21864
  };
21631
21865
  return { patches: [compositePatch], revertedPatchIds: [] };
21632
21866
  }
@@ -21673,7 +21907,7 @@ var init_dist3 = __esm({
21673
21907
  } catch {
21674
21908
  continue;
21675
21909
  }
21676
- const contentHash = this.computeContentHash(patchContent);
21910
+ let contentHash = this.computeContentHash(patchContent);
21677
21911
  if (existingHashes.has(contentHash) || forgottenHashes.has(contentHash)) {
21678
21912
  continue;
21679
21913
  }
@@ -21681,7 +21915,26 @@ var init_dist3 = __esm({
21681
21915
  continue;
21682
21916
  }
21683
21917
  const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
21684
- const files = filesOutput.trim().split("\n").filter(Boolean).filter((f2) => !INFRASTRUCTURE_FILES.has(f2));
21918
+ const allScanFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f2) => !INFRASTRUCTURE_FILES.has(f2));
21919
+ const sensitiveScanFiles = allScanFiles.filter((f2) => isSensitiveFile(f2));
21920
+ const files = allScanFiles.filter((f2) => !isSensitiveFile(f2));
21921
+ if (sensitiveScanFiles.length > 0) {
21922
+ this.warnings.push(
21923
+ `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveScanFiles.join(", ")}. These files will not be tracked by replay.`
21924
+ );
21925
+ if (files.length > 0) {
21926
+ if (parents.length === 0) {
21927
+ continue;
21928
+ }
21929
+ try {
21930
+ patchContent = await this.git.exec(["diff", parents[0], commit.sha, "--", ...files]);
21931
+ } catch {
21932
+ continue;
21933
+ }
21934
+ if (!patchContent.trim()) continue;
21935
+ contentHash = this.computeContentHash(patchContent);
21936
+ }
21937
+ }
21685
21938
  if (files.length === 0) {
21686
21939
  continue;
21687
21940
  }
@@ -21828,6 +22081,20 @@ var init_dist3 = __esm({
21828
22081
  resetAccumulator() {
21829
22082
  this.fileTheirsAccumulator.clear();
21830
22083
  }
22084
+ /**
22085
+ * @internal Test-only.
22086
+ * Returns a defensive copy of the inter-patch accumulator. Used by the
22087
+ * adversarial test suite (FER-9791) to assert invariant I2: after every
22088
+ * applied patch, the accumulator has an entry for each file the patch
22089
+ * touched, and the entry's content matches on-disk.
22090
+ */
22091
+ getAccumulatorSnapshot() {
22092
+ const snapshot = /* @__PURE__ */ new Map();
22093
+ for (const [path5, entry] of this.fileTheirsAccumulator) {
22094
+ snapshot.set(path5, { content: entry.content, baseGeneration: entry.baseGeneration });
22095
+ }
22096
+ return snapshot;
22097
+ }
21831
22098
  /**
21832
22099
  * Apply all patches, returning results for each.
21833
22100
  * Skips patches that match exclude patterns in replay.yml
@@ -21893,7 +22160,7 @@ var init_dist3 = __esm({
21893
22160
  const base = await this.git.showFile(baseGen.tree_hash, filePath);
21894
22161
  const theirs = await this.applyPatchToContent(base, patch.patch_content, filePath, tempGit, tempDir);
21895
22162
  const effectiveTheirs = theirs ?? await (0, import_promises4.readFile)((0, import_path9.join)(this.outputDir, resolvedPath), "utf-8").catch(() => null);
21896
- if (effectiveTheirs) {
22163
+ if (effectiveTheirs != null) {
21897
22164
  this.fileTheirsAccumulator.set(resolvedPath, {
21898
22165
  content: effectiveTheirs,
21899
22166
  baseGeneration: patch.base_generation
@@ -21917,16 +22184,20 @@ var init_dist3 = __esm({
21917
22184
  return this.fileTheirsAccumulator.has(resolved);
21918
22185
  })
21919
22186
  ).then((results) => results.some(Boolean));
21920
- if (!needsAccumulation) {
22187
+ const resolvedFiles = {};
22188
+ for (const filePath of patch.files) {
22189
+ const resolvedPath = baseGen ? await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash) : filePath;
22190
+ if (resolvedPath !== filePath) {
22191
+ resolvedFiles[filePath] = resolvedPath;
22192
+ }
22193
+ }
22194
+ const patchIsRenameAware = /^rename from /m.test(patch.patch_content);
22195
+ const hasGeneratorRename = Object.keys(resolvedFiles).length > 0 && !patchIsRenameAware;
22196
+ if (!needsAccumulation && !hasGeneratorRename) {
21921
22197
  const snapshots = /* @__PURE__ */ new Map();
21922
- const resolvedFiles = {};
21923
22198
  for (const filePath of patch.files) {
21924
- const resolvedPath = baseGen ? await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash) : filePath;
21925
- if (resolvedPath !== filePath) {
21926
- resolvedFiles[filePath] = resolvedPath;
21927
- }
21928
- const fullPath = (0, import_path9.join)(this.outputDir, resolvedPath);
21929
- snapshots.set(resolvedPath, await (0, import_promises4.readFile)(fullPath, "utf-8").catch(() => null));
22199
+ const fullPath = (0, import_path9.join)(this.outputDir, filePath);
22200
+ snapshots.set(filePath, await (0, import_promises4.readFile)(fullPath, "utf-8").catch(() => null));
21930
22201
  }
21931
22202
  try {
21932
22203
  await this.git.execWithInput(["apply", "--3way"], patch.patch_content);
@@ -21938,8 +22209,8 @@ var init_dist3 = __esm({
21938
22209
  ...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
21939
22210
  };
21940
22211
  } catch {
21941
- for (const [resolvedPath, content] of snapshots) {
21942
- const fullPath = (0, import_path9.join)(this.outputDir, resolvedPath);
22212
+ for (const [filePath, content] of snapshots) {
22213
+ const fullPath = (0, import_path9.join)(this.outputDir, filePath);
21943
22214
  if (content != null) {
21944
22215
  await (0, import_promises4.writeFile)(fullPath, content);
21945
22216
  } else {
@@ -22059,7 +22330,7 @@ var init_dist3 = __esm({
22059
22330
  }
22060
22331
  let useAccumulatorAsMergeBase = false;
22061
22332
  const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
22062
- if (accumulatorEntry && (!theirs || base == null)) {
22333
+ if (accumulatorEntry && (theirs == null || base == null)) {
22063
22334
  theirs = await this.applyPatchToContent(
22064
22335
  accumulatorEntry.content,
22065
22336
  patch.patch_content,
@@ -22067,7 +22338,16 @@ var init_dist3 = __esm({
22067
22338
  tempGit,
22068
22339
  tempDir
22069
22340
  );
22070
- if (theirs) {
22341
+ if (theirs != null) {
22342
+ useAccumulatorAsMergeBase = true;
22343
+ } else if (await this.isPatchAlreadyApplied(
22344
+ patch.patch_content,
22345
+ filePath,
22346
+ accumulatorEntry.content,
22347
+ tempGit,
22348
+ tempDir
22349
+ )) {
22350
+ theirs = accumulatorEntry.content;
22071
22351
  useAccumulatorAsMergeBase = true;
22072
22352
  }
22073
22353
  }
@@ -22084,7 +22364,7 @@ var init_dist3 = __esm({
22084
22364
  }
22085
22365
  let effective_theirs = theirs;
22086
22366
  let baseMismatchSkipped = false;
22087
- if (theirs && base && !useAccumulatorAsMergeBase) {
22367
+ if (theirs != null && base != null && !useAccumulatorAsMergeBase) {
22088
22368
  if (accumulatorEntry && accumulatorEntry.baseGeneration === patch.base_generation) {
22089
22369
  try {
22090
22370
  const preMerged = threeWayMerge(base, accumulatorEntry.content, theirs);
@@ -22100,7 +22380,7 @@ var init_dist3 = __esm({
22100
22380
  baseMismatchSkipped = true;
22101
22381
  }
22102
22382
  }
22103
- if (base == null && !ours && effective_theirs) {
22383
+ if (base == null && ours == null && effective_theirs != null) {
22104
22384
  this.fileTheirsAccumulator.set(resolvedPath, {
22105
22385
  content: effective_theirs,
22106
22386
  baseGeneration: patch.base_generation
@@ -22110,7 +22390,7 @@ var init_dist3 = __esm({
22110
22390
  await (0, import_promises4.writeFile)(oursPath, effective_theirs);
22111
22391
  return { file: resolvedPath, status: "merged", reason: "new-file" };
22112
22392
  }
22113
- if (base == null && ours && effective_theirs && !useAccumulatorAsMergeBase) {
22393
+ if (base == null && ours != null && effective_theirs != null && !useAccumulatorAsMergeBase) {
22114
22394
  const merged2 = threeWayMerge("", ours, effective_theirs);
22115
22395
  const outDir2 = (0, import_path9.dirname)(oursPath);
22116
22396
  await (0, import_promises4.mkdir)(outDir2, { recursive: true });
@@ -22124,16 +22404,20 @@ var init_dist3 = __esm({
22124
22404
  conflictMetadata: metadata
22125
22405
  };
22126
22406
  }
22407
+ this.fileTheirsAccumulator.set(resolvedPath, {
22408
+ content: merged2.content,
22409
+ baseGeneration: patch.base_generation
22410
+ });
22127
22411
  return { file: resolvedPath, status: "merged" };
22128
22412
  }
22129
- if (!effective_theirs) {
22413
+ if (effective_theirs == null) {
22130
22414
  return {
22131
22415
  file: resolvedPath,
22132
22416
  status: "skipped",
22133
22417
  reason: "missing-content"
22134
22418
  };
22135
22419
  }
22136
- if (base == null && !useAccumulatorAsMergeBase || !ours) {
22420
+ if (base == null && !useAccumulatorAsMergeBase || ours == null) {
22137
22421
  return {
22138
22422
  file: resolvedPath,
22139
22423
  status: "skipped",
@@ -22152,7 +22436,7 @@ var init_dist3 = __esm({
22152
22436
  const outDir = (0, import_path9.dirname)(oursPath);
22153
22437
  await (0, import_promises4.mkdir)(outDir, { recursive: true });
22154
22438
  await (0, import_promises4.writeFile)(oursPath, merged.content);
22155
- if (effective_theirs && !merged.hasConflicts) {
22439
+ if (effective_theirs != null && !merged.hasConflicts) {
22156
22440
  this.fileTheirsAccumulator.set(resolvedPath, {
22157
22441
  content: effective_theirs,
22158
22442
  baseGeneration: patch.base_generation
@@ -22250,6 +22534,28 @@ var init_dist3 = __esm({
22250
22534
  return null;
22251
22535
  }
22252
22536
  }
22537
+ /**
22538
+ * Detects whether a patch's additions are already present in `content`.
22539
+ * Uses `git apply --reverse --check` — if the reverse patch applies cleanly,
22540
+ * the forward patch is effectively a no-op (its +lines are already there and
22541
+ * its -lines are already gone). Used to distinguish "patch already applied"
22542
+ * from "patch base mismatch" after a forward-apply fails.
22543
+ */
22544
+ async isPatchAlreadyApplied(patchContent, filePath, content, tempGit, tempDir) {
22545
+ const fileDiff = this.extractFileDiff(patchContent, filePath);
22546
+ if (!fileDiff) return false;
22547
+ try {
22548
+ const tempFilePath = (0, import_path9.join)(tempDir, filePath);
22549
+ await (0, import_promises4.mkdir)((0, import_path9.dirname)(tempFilePath), { recursive: true });
22550
+ await (0, import_promises4.writeFile)(tempFilePath, content);
22551
+ await tempGit.exec(["add", filePath]);
22552
+ await tempGit.exec(["commit", "-m", `check already-applied ${filePath}`, "--allow-empty"]);
22553
+ await tempGit.execWithInput(["apply", "--reverse", "--check", "--allow-empty"], fileDiff);
22554
+ return true;
22555
+ } catch {
22556
+ return false;
22557
+ }
22558
+ }
22253
22559
  extractFileDiff(patchContent, filePath) {
22254
22560
  const lines = patchContent.split("\n");
22255
22561
  const diffLines = [];
@@ -22403,6 +22709,33 @@ CLI Version: ${options.cliVersion}`;
22403
22709
  committer;
22404
22710
  lockManager;
22405
22711
  outputDir;
22712
+ /** @internal Test-only. Captures the last ReplayResult[] returned from applicator.applyPatches(). */
22713
+ _lastApplyResults = [];
22714
+ /**
22715
+ * Service-level warnings accumulated during a single runReplay() call.
22716
+ * Merged with `detector.warnings` into `ReplayReport.warnings` by each flow handler.
22717
+ * Populated by `isFileUserOwned` when a patch's base generation tree is unreachable
22718
+ * (shallow clone / aggressive `git gc --prune`) and we fall back to a conservative
22719
+ * heuristic. Cleared at the top of `runReplay()`.
22720
+ */
22721
+ warnings = [];
22722
+ /**
22723
+ * @internal Test-only accessor.
22724
+ * Returns the ReplayApplicator instance used for the last run. Tests use this
22725
+ * to inspect the inter-patch accumulator after runReplay() completes (FER-9791, I2).
22726
+ */
22727
+ get applicatorRef() {
22728
+ return this.applicator;
22729
+ }
22730
+ /**
22731
+ * @internal Test-only accessor.
22732
+ * Returns the ReplayResult[] from the most recent applyPatches() call. Tests use
22733
+ * this to filter applied-vs-conflicted-vs-absorbed patches for invariant assertions
22734
+ * (FER-9791). Returns an empty array if no patches have been applied yet.
22735
+ */
22736
+ getLastResults() {
22737
+ return this._lastApplyResults;
22738
+ }
22406
22739
  constructor(outputDir, _config) {
22407
22740
  const git = new GitClient(outputDir);
22408
22741
  this.git = git;
@@ -22413,6 +22746,7 @@ CLI Version: ${options.cliVersion}`;
22413
22746
  this.committer = new ReplayCommitter(git, outputDir);
22414
22747
  }
22415
22748
  async runReplay(options) {
22749
+ this.warnings = [];
22416
22750
  if (options?.skipApplication) {
22417
22751
  return this.handleSkipApplication(options);
22418
22752
  }
@@ -22486,6 +22820,7 @@ CLI Version: ${options.cliVersion}`;
22486
22820
  );
22487
22821
  }
22488
22822
  this.lockManager.save();
22823
+ this.detector.warnings.push(...this.lockManager.warnings);
22489
22824
  }
22490
22825
  determineFlow() {
22491
22826
  try {
@@ -22552,6 +22887,7 @@ CLI Version: ${options.cliVersion}`;
22552
22887
  }
22553
22888
  this.lockManager.setReplaySkippedAt((/* @__PURE__ */ new Date()).toISOString());
22554
22889
  this.lockManager.save();
22890
+ const credentialWarnings = [...this.lockManager.warnings];
22555
22891
  if (!options?.stageOnly) {
22556
22892
  await this.committer.commitReplay(0);
22557
22893
  } else {
@@ -22563,13 +22899,15 @@ CLI Version: ${options.cliVersion}`;
22563
22899
  patchesApplied: 0,
22564
22900
  patchesWithConflicts: 0,
22565
22901
  patchesSkipped: 0,
22566
- conflicts: []
22902
+ conflicts: [],
22903
+ warnings: credentialWarnings.length > 0 ? credentialWarnings : void 0
22567
22904
  };
22568
22905
  }
22569
22906
  async handleNoPatchesRegeneration(options) {
22570
- const { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
22571
- const warnings = [...this.detector.warnings];
22907
+ let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
22908
+ const detectorWarnings = [...this.detector.warnings];
22572
22909
  if (options?.dryRun) {
22910
+ const dryRunWarnings = [...detectorWarnings, ...this.warnings];
22573
22911
  return {
22574
22912
  flow: "no-patches",
22575
22913
  patchesDetected: newPatches.length,
@@ -22579,9 +22917,26 @@ CLI Version: ${options.cliVersion}`;
22579
22917
  patchesReverted: revertedPatchIds.length,
22580
22918
  conflicts: [],
22581
22919
  wouldApply: newPatches,
22582
- warnings: warnings.length > 0 ? warnings : void 0
22920
+ warnings: dryRunWarnings.length > 0 ? dryRunWarnings : void 0
22583
22921
  };
22584
22922
  }
22923
+ {
22924
+ const preLock = this.lockManager.read();
22925
+ const preCurrentGen = preLock.current_generation;
22926
+ const uniqueFiles = [...new Set(newPatches.flatMap((p) => p.files))];
22927
+ const absorbedFiles = /* @__PURE__ */ new Set();
22928
+ for (const file of uniqueFiles) {
22929
+ const diff = await this.git.exec(["diff", preCurrentGen, "HEAD", "--", file]).catch(() => null);
22930
+ if (diff !== null && !diff.trim()) {
22931
+ absorbedFiles.add(file);
22932
+ }
22933
+ }
22934
+ if (absorbedFiles.size > 0) {
22935
+ newPatches = newPatches.filter(
22936
+ (p) => p.files.length === 0 || !p.files.every((f2) => absorbedFiles.has(f2))
22937
+ );
22938
+ }
22939
+ }
22585
22940
  for (const id of revertedPatchIds) {
22586
22941
  try {
22587
22942
  this.lockManager.removePatch(id);
@@ -22600,6 +22955,7 @@ CLI Version: ${options.cliVersion}`;
22600
22955
  let results = [];
22601
22956
  if (newPatches.length > 0) {
22602
22957
  results = await this.applicator.applyPatches(newPatches);
22958
+ this._lastApplyResults = results;
22603
22959
  this.revertConflictingFiles(results);
22604
22960
  for (const result of results) {
22605
22961
  if (result.status === "conflict") {
@@ -22614,6 +22970,7 @@ CLI Version: ${options.cliVersion}`;
22614
22970
  }
22615
22971
  }
22616
22972
  this.lockManager.save();
22973
+ this.warnings.push(...this.lockManager.warnings);
22617
22974
  if (newPatches.length > 0) {
22618
22975
  if (!options?.stageOnly) {
22619
22976
  const appliedCount = results.filter((r2) => r2.status === "applied").length;
@@ -22622,13 +22979,14 @@ CLI Version: ${options.cliVersion}`;
22622
22979
  await this.committer.stageAll();
22623
22980
  }
22624
22981
  }
22982
+ const warnings = [...detectorWarnings, ...this.warnings];
22625
22983
  return this.buildReport("no-patches", newPatches, results, options, warnings, rebaseCounts, void 0, revertedPatchIds.length);
22626
22984
  }
22627
22985
  async handleNormalRegeneration(options) {
22628
22986
  if (options?.dryRun) {
22629
22987
  const existingPatches2 = this.lockManager.getPatches();
22630
22988
  const { patches: newPatches2, revertedPatchIds: dryRunReverted } = await this.detector.detectNewPatches();
22631
- const warnings2 = [...this.detector.warnings];
22989
+ const warnings2 = [...this.detector.warnings, ...this.warnings];
22632
22990
  const allPatches2 = [...existingPatches2, ...newPatches2];
22633
22991
  return {
22634
22992
  flow: "normal-regeneration",
@@ -22660,7 +23018,7 @@ CLI Version: ${options.cliVersion}`;
22660
23018
  }
22661
23019
  existingPatches = this.lockManager.getPatches();
22662
23020
  let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
22663
- const warnings = [...this.detector.warnings];
23021
+ const detectorWarnings = [...this.detector.warnings];
22664
23022
  if (removedByPreRebase.length > 0) {
22665
23023
  const removedOriginalCommits = new Set(removedByPreRebase.map((p) => p.original_commit));
22666
23024
  const removedOriginalMessages = new Set(removedByPreRebase.map((p) => p.original_message));
@@ -22692,6 +23050,7 @@ CLI Version: ${options.cliVersion}`;
22692
23050
  const genRecord = await this.committer.createGenerationRecord(commitOpts);
22693
23051
  this.lockManager.addGeneration(genRecord);
22694
23052
  const results = await this.applicator.applyPatches(allPatches);
23053
+ this._lastApplyResults = results;
22695
23054
  this.revertConflictingFiles(results);
22696
23055
  for (const result of results) {
22697
23056
  if (result.status === "conflict") {
@@ -22713,12 +23072,14 @@ CLI Version: ${options.cliVersion}`;
22713
23072
  }
22714
23073
  }
22715
23074
  this.lockManager.save();
23075
+ this.warnings.push(...this.lockManager.warnings);
22716
23076
  if (options?.stageOnly) {
22717
23077
  await this.committer.stageAll();
22718
23078
  } else {
22719
23079
  const appliedCount = results.filter((r2) => r2.status === "applied").length;
22720
23080
  await this.committer.commitReplay(appliedCount, allPatches);
22721
23081
  }
23082
+ const warnings = [...detectorWarnings, ...this.warnings];
22722
23083
  return this.buildReport(
22723
23084
  "normal-regeneration",
22724
23085
  allPatches,
@@ -22742,6 +23103,7 @@ CLI Version: ${options.cliVersion}`;
22742
23103
  let keptAsUserOwned = 0;
22743
23104
  const seenContentHashes = /* @__PURE__ */ new Set();
22744
23105
  const absorbedPatchIds = /* @__PURE__ */ new Set();
23106
+ const fernignorePatterns = this.readFernignorePatterns();
22745
23107
  for (const result of results) {
22746
23108
  if (result.resolvedFiles && Object.keys(result.resolvedFiles).length > 0) {
22747
23109
  const patch = result.patch;
@@ -22762,34 +23124,78 @@ CLI Version: ${options.cliVersion}`;
22762
23124
  const patch = result.patch;
22763
23125
  if (patch.base_generation === currentGenSha) continue;
22764
23126
  try {
22765
- const fernignorePatterns = this.readFernignorePatterns();
22766
- const isUserOwned = await Promise.all(
23127
+ const originalByResolved = {};
23128
+ if (result.resolvedFiles) {
23129
+ for (const [orig, resolved] of Object.entries(result.resolvedFiles)) {
23130
+ originalByResolved[resolved] = orig;
23131
+ }
23132
+ }
23133
+ const isUserOwnedPerFile = await Promise.all(
22767
23134
  patch.files.map(async (file) => {
22768
- if (file === ".fernignore") {
23135
+ if (file === ".fernignore") return true;
23136
+ if (fernignorePatterns.some((p) => file === p || minimatch(file, p))) {
22769
23137
  return true;
22770
23138
  }
22771
- if (fernignorePatterns.some((p) => file === p || minimatch(file, p))) {
23139
+ if (patch.user_owned) return true;
23140
+ if (this.fileIsCreationInPatchContent(patch.patch_content, originalByResolved[file] ?? file)) {
22772
23141
  return true;
22773
23142
  }
22774
23143
  const content = await this.git.showFile(currentGenSha, file);
22775
23144
  return content === null;
22776
23145
  })
22777
23146
  );
22778
- const hasUserOwnedFiles = isUserOwned.some(Boolean);
22779
- if (hasUserOwnedFiles) {
22780
- this.lockManager.updatePatch(patch.id, {
22781
- base_generation: currentGenSha
22782
- });
23147
+ const hasProtectedFiles = patch.files.some(
23148
+ (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch(file, p))
23149
+ );
23150
+ const allUserOwned = isUserOwnedPerFile.every(Boolean);
23151
+ if (allUserOwned && patch.files.length > 0) {
23152
+ const baseChanged = patch.base_generation !== currentGenSha;
23153
+ const userDiff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
23154
+ if (userDiff != null && userDiff.trim()) {
23155
+ const newHash = this.detector.computeContentHash(userDiff);
23156
+ patch.patch_content = userDiff;
23157
+ patch.content_hash = newHash;
23158
+ }
23159
+ patch.base_generation = currentGenSha;
23160
+ try {
23161
+ this.lockManager.updatePatch(patch.id, {
23162
+ base_generation: currentGenSha,
23163
+ ...!patch.user_owned ? { user_owned: true } : {},
23164
+ ...userDiff != null && userDiff.trim() ? { patch_content: patch.patch_content, content_hash: patch.content_hash } : {}
23165
+ });
23166
+ } catch {
23167
+ }
23168
+ patch.user_owned = true;
22783
23169
  keptAsUserOwned++;
23170
+ if (baseChanged) repointed++;
22784
23171
  continue;
22785
23172
  }
22786
23173
  const diff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
22787
- if (!diff || !diff.trim()) {
23174
+ if (diff === null) continue;
23175
+ if (!diff.trim()) {
23176
+ if (hasProtectedFiles) {
23177
+ patch.base_generation = currentGenSha;
23178
+ try {
23179
+ this.lockManager.updatePatch(patch.id, { base_generation: currentGenSha });
23180
+ } catch {
23181
+ }
23182
+ keptAsUserOwned++;
23183
+ continue;
23184
+ }
22788
23185
  this.lockManager.removePatch(patch.id);
22789
23186
  absorbedPatchIds.add(patch.id);
22790
23187
  absorbed++;
22791
23188
  continue;
22792
23189
  }
23190
+ if (hasProtectedFiles) {
23191
+ patch.base_generation = currentGenSha;
23192
+ try {
23193
+ this.lockManager.updatePatch(patch.id, { base_generation: currentGenSha });
23194
+ } catch {
23195
+ }
23196
+ keptAsUserOwned++;
23197
+ continue;
23198
+ }
22793
23199
  const newContentHash = this.detector.computeContentHash(diff);
22794
23200
  if (seenContentHashes.has(newContentHash)) {
22795
23201
  this.lockManager.removePatch(patch.id);
@@ -22798,17 +23204,89 @@ CLI Version: ${options.cliVersion}`;
22798
23204
  continue;
22799
23205
  }
22800
23206
  seenContentHashes.add(newContentHash);
22801
- this.lockManager.updatePatch(patch.id, {
22802
- base_generation: currentGenSha,
22803
- patch_content: diff,
22804
- content_hash: newContentHash
22805
- });
23207
+ patch.base_generation = currentGenSha;
23208
+ patch.patch_content = diff;
23209
+ patch.content_hash = newContentHash;
23210
+ try {
23211
+ this.lockManager.updatePatch(patch.id, {
23212
+ base_generation: currentGenSha,
23213
+ patch_content: diff,
23214
+ content_hash: newContentHash
23215
+ });
23216
+ } catch {
23217
+ }
22806
23218
  contentRebased++;
22807
23219
  } catch {
22808
23220
  }
22809
23221
  }
22810
23222
  return { absorbed, repointed, contentRebased, keptAsUserOwned, absorbedPatchIds };
22811
23223
  }
23224
+ /**
23225
+ * Determine whether `file` is user-owned (never produced by the generator).
23226
+ *
23227
+ * Resolution order (first match wins):
23228
+ * 1. `patch.user_owned === true` — authoritative, persisted across cycles.
23229
+ * 2. `.fernignore` itself, or file matches a .fernignore pattern.
23230
+ * 3. Legacy-safe signal: `patch_content` shows this file as a `--- /dev/null`
23231
+ * creation. Works for patches that predate the `user_owned` field or
23232
+ * whose flag was lost. Immune to rename tricks — relies on raw patch text.
23233
+ * 4. Tree-based check against `patch.base_generation` when it is reachable
23234
+ * AND distinct from `currentGenSha`. If the base tree is unreachable
23235
+ * (shallow clone / aggressive `git gc --prune`), emit a one-time
23236
+ * customer-actionable warning (deduplicated via warnedGens) and
23237
+ * conservatively treat as user-owned — strictly safer than silent
23238
+ * absorption (FER-9809).
23239
+ * 5. Final fallback — check `currentGenSha` when there is no usable base
23240
+ * (legacy one-gen lockfile). Preserves pre-fix behavior for that edge.
23241
+ *
23242
+ * `originalFileName` is the pre-rename path when the generator moved the
23243
+ * file between the patch's base and the current generation. Tree lookups
23244
+ * at ancestor generations use the original path — that's where the
23245
+ * generator produced the content.
23246
+ */
23247
+ async isFileUserOwned(file, patch, currentGenSha, fernignorePatterns, warnedGens, originalFileName) {
23248
+ if (patch.user_owned) return true;
23249
+ if (file === ".fernignore") return true;
23250
+ if (fernignorePatterns.some((p) => file === p || minimatch(file, p))) return true;
23251
+ if (this.fileIsCreationInPatchContent(patch.patch_content, originalFileName ?? file)) {
23252
+ return true;
23253
+ }
23254
+ const base = patch.base_generation;
23255
+ if (base && base !== currentGenSha) {
23256
+ const reachable = await this.git.commitExists(base) || await this.git.treeExists(base);
23257
+ if (!reachable) {
23258
+ if (!warnedGens.has(base)) {
23259
+ warnedGens.add(base);
23260
+ this.warnings.push(
23261
+ `Base generation ${base.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.`
23262
+ );
23263
+ }
23264
+ return true;
23265
+ }
23266
+ return await this.git.showFile(base, originalFileName ?? file) === null;
23267
+ }
23268
+ return await this.git.showFile(currentGenSha, originalFileName ?? file) === null;
23269
+ }
23270
+ /**
23271
+ * Returns true if `file`'s diff section in `patchContent` starts with
23272
+ * `--- /dev/null`, meaning this file was introduced by the patch (user
23273
+ * creation). Used as a legacy-safe fallback when `patch.user_owned` is
23274
+ * absent or cleared.
23275
+ */
23276
+ fileIsCreationInPatchContent(patchContent, file) {
23277
+ const lines = patchContent.split("\n");
23278
+ let inFileSection = false;
23279
+ for (const line of lines) {
23280
+ if (line.startsWith("diff --git ")) {
23281
+ inFileSection = line.includes(` b/${file}`) || line.endsWith(` b/${file}`);
23282
+ continue;
23283
+ }
23284
+ if (inFileSection && line.startsWith("--- ")) {
23285
+ return line === "--- /dev/null";
23286
+ }
23287
+ }
23288
+ return false;
23289
+ }
22812
23290
  /**
22813
23291
  * For conflict patches with mixed results (some files merged, some conflicted),
22814
23292
  * check if the cleanly merged files were absorbed by the generator (empty diff).
@@ -22822,13 +23300,14 @@ CLI Version: ${options.cliVersion}`;
22822
23300
  const cleanFiles = result.fileResults.filter((f2) => f2.status === "merged").map((f2) => f2.file);
22823
23301
  if (cleanFiles.length === 0) return;
22824
23302
  const patch = result.patch;
23303
+ if (patch.user_owned) return;
22825
23304
  const fernignorePatterns = this.readFernignorePatterns();
23305
+ const warnedGens = /* @__PURE__ */ new Set();
22826
23306
  const generatorCleanFiles = [];
22827
23307
  for (const file of cleanFiles) {
22828
- if (file === ".fernignore") continue;
22829
- if (fernignorePatterns.some((p) => file === p || minimatch(file, p))) continue;
22830
- const content = await this.git.showFile(currentGenSha, file);
22831
- if (content === null) continue;
23308
+ if (await this.isFileUserOwned(file, patch, currentGenSha, fernignorePatterns, warnedGens)) {
23309
+ continue;
23310
+ }
22832
23311
  generatorCleanFiles.push(file);
22833
23312
  }
22834
23313
  if (generatorCleanFiles.length === 0) return;
@@ -22865,7 +23344,35 @@ CLI Version: ${options.cliVersion}`;
22865
23344
  let conflictResolved = 0;
22866
23345
  let conflictAbsorbed = 0;
22867
23346
  let contentRefreshed = 0;
23347
+ const fernignorePatterns = this.readFernignorePatterns();
23348
+ const warnedGens = /* @__PURE__ */ new Set();
23349
+ const reachabilityCache = /* @__PURE__ */ new Map();
23350
+ const isReachable = async (sha) => {
23351
+ const cached = reachabilityCache.get(sha);
23352
+ if (cached !== void 0) return cached;
23353
+ const reachable = await this.git.commitExists(sha) || await this.git.treeExists(sha);
23354
+ reachabilityCache.set(sha, reachable);
23355
+ return reachable;
23356
+ };
23357
+ const allPatchFilesUserOwned = async (patch) => {
23358
+ if (patch.user_owned) return true;
23359
+ if (patch.files.length === 0) return false;
23360
+ for (const file of patch.files) {
23361
+ if (!await this.isFileUserOwned(file, patch, currentGen, fernignorePatterns, warnedGens)) {
23362
+ return false;
23363
+ }
23364
+ }
23365
+ return true;
23366
+ };
22868
23367
  for (const patch of patches) {
23368
+ if (patch.base_generation && patch.base_generation !== currentGen && !warnedGens.has(patch.base_generation)) {
23369
+ if (!await isReachable(patch.base_generation)) {
23370
+ warnedGens.add(patch.base_generation);
23371
+ this.warnings.push(
23372
+ `Base generation ${patch.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.`
23373
+ );
23374
+ }
23375
+ }
22869
23376
  if (patch.status != null) {
22870
23377
  delete patch.status;
22871
23378
  continue;
@@ -22875,6 +23382,7 @@ CLI Version: ${options.cliVersion}`;
22875
23382
  const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
22876
23383
  if (diff === null) continue;
22877
23384
  if (!diff.trim()) {
23385
+ if (await allPatchFilesUserOwned(patch)) continue;
22878
23386
  this.lockManager.removePatch(patch.id);
22879
23387
  contentRefreshed++;
22880
23388
  continue;
@@ -22886,6 +23394,12 @@ CLI Version: ${options.cliVersion}`;
22886
23394
  if (hasStaleMarkers) {
22887
23395
  continue;
22888
23396
  }
23397
+ const isProtected = patch.files.some(
23398
+ (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch(file, p))
23399
+ );
23400
+ if (isProtected) {
23401
+ continue;
23402
+ }
22889
23403
  const newContentHash = this.detector.computeContentHash(diff);
22890
23404
  if (newContentHash !== patch.content_hash) {
22891
23405
  const filesOutput = await this.git.exec(["diff", "--name-only", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
@@ -22911,6 +23425,13 @@ CLI Version: ${options.cliVersion}`;
22911
23425
  const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
22912
23426
  if (diff === null) continue;
22913
23427
  if (!diff.trim()) {
23428
+ if (await allPatchFilesUserOwned(patch)) {
23429
+ try {
23430
+ this.lockManager.updatePatch(patch.id, { base_generation: currentGen });
23431
+ } catch {
23432
+ }
23433
+ continue;
23434
+ }
22914
23435
  this.lockManager.removePatch(patch.id);
22915
23436
  conflictAbsorbed++;
22916
23437
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fern-api/generator-cli",
3
- "version": "0.9.8",
3
+ "version": "0.9.9",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/fern-api/fern.git",
@@ -22,7 +22,7 @@
22
22
  "**"
23
23
  ],
24
24
  "dependencies": {
25
- "@fern-api/replay": "0.10.4",
25
+ "@fern-api/replay": "0.11.0",
26
26
  "@octokit/rest": "^22.0.1",
27
27
  "es-toolkit": "^1.45.1",
28
28
  "tmp-promise": "^3.0.3"