@fern-api/replay 0.10.4 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.cjs CHANGED
@@ -499,6 +499,121 @@ var require_browser = __commonJS({
499
499
  }
500
500
  });
501
501
 
502
+ // node_modules/has-flag/index.js
503
+ var require_has_flag = __commonJS({
504
+ "node_modules/has-flag/index.js"(exports2, module2) {
505
+ "use strict";
506
+ module2.exports = (flag, argv = process.argv) => {
507
+ const prefix = flag.startsWith("-") ? "" : flag.length === 1 ? "-" : "--";
508
+ const position = argv.indexOf(prefix + flag);
509
+ const terminatorPosition = argv.indexOf("--");
510
+ return position !== -1 && (terminatorPosition === -1 || position < terminatorPosition);
511
+ };
512
+ }
513
+ });
514
+
515
+ // node_modules/supports-color/index.js
516
+ var require_supports_color = __commonJS({
517
+ "node_modules/supports-color/index.js"(exports2, module2) {
518
+ "use strict";
519
+ var os = require("os");
520
+ var tty = require("tty");
521
+ var hasFlag = require_has_flag();
522
+ var { env } = process;
523
+ var forceColor;
524
+ if (hasFlag("no-color") || hasFlag("no-colors") || hasFlag("color=false") || hasFlag("color=never")) {
525
+ forceColor = 0;
526
+ } else if (hasFlag("color") || hasFlag("colors") || hasFlag("color=true") || hasFlag("color=always")) {
527
+ forceColor = 1;
528
+ }
529
+ if ("FORCE_COLOR" in env) {
530
+ if (env.FORCE_COLOR === "true") {
531
+ forceColor = 1;
532
+ } else if (env.FORCE_COLOR === "false") {
533
+ forceColor = 0;
534
+ } else {
535
+ forceColor = env.FORCE_COLOR.length === 0 ? 1 : Math.min(parseInt(env.FORCE_COLOR, 10), 3);
536
+ }
537
+ }
538
+ function translateLevel(level) {
539
+ if (level === 0) {
540
+ return false;
541
+ }
542
+ return {
543
+ level,
544
+ hasBasic: true,
545
+ has256: level >= 2,
546
+ has16m: level >= 3
547
+ };
548
+ }
549
+ function supportsColor(haveStream, streamIsTTY) {
550
+ if (forceColor === 0) {
551
+ return 0;
552
+ }
553
+ if (hasFlag("color=16m") || hasFlag("color=full") || hasFlag("color=truecolor")) {
554
+ return 3;
555
+ }
556
+ if (hasFlag("color=256")) {
557
+ return 2;
558
+ }
559
+ if (haveStream && !streamIsTTY && forceColor === void 0) {
560
+ return 0;
561
+ }
562
+ const min = forceColor || 0;
563
+ if (env.TERM === "dumb") {
564
+ return min;
565
+ }
566
+ if (process.platform === "win32") {
567
+ const osRelease = os.release().split(".");
568
+ if (Number(osRelease[0]) >= 10 && Number(osRelease[2]) >= 10586) {
569
+ return Number(osRelease[2]) >= 14931 ? 3 : 2;
570
+ }
571
+ return 1;
572
+ }
573
+ if ("CI" in env) {
574
+ if (["TRAVIS", "CIRCLECI", "APPVEYOR", "GITLAB_CI", "GITHUB_ACTIONS", "BUILDKITE"].some((sign) => sign in env) || env.CI_NAME === "codeship") {
575
+ return 1;
576
+ }
577
+ return min;
578
+ }
579
+ if ("TEAMCITY_VERSION" in env) {
580
+ return /^(9\.(0*[1-9]\d*)\.|\d{2,}\.)/.test(env.TEAMCITY_VERSION) ? 1 : 0;
581
+ }
582
+ if (env.COLORTERM === "truecolor") {
583
+ return 3;
584
+ }
585
+ if ("TERM_PROGRAM" in env) {
586
+ const version = parseInt((env.TERM_PROGRAM_VERSION || "").split(".")[0], 10);
587
+ switch (env.TERM_PROGRAM) {
588
+ case "iTerm.app":
589
+ return version >= 3 ? 3 : 2;
590
+ case "Apple_Terminal":
591
+ return 2;
592
+ }
593
+ }
594
+ if (/-256(color)?$/i.test(env.TERM)) {
595
+ return 2;
596
+ }
597
+ if (/^screen|^xterm|^vt100|^vt220|^rxvt|color|ansi|cygwin|linux/i.test(env.TERM)) {
598
+ return 1;
599
+ }
600
+ if ("COLORTERM" in env) {
601
+ return 1;
602
+ }
603
+ return min;
604
+ }
605
+ function getSupportLevel(stream) {
606
+ const level = supportsColor(stream, stream && stream.isTTY);
607
+ return translateLevel(level);
608
+ }
609
+ module2.exports = {
610
+ supportsColor: getSupportLevel,
611
+ stdout: translateLevel(supportsColor(true, tty.isatty(1))),
612
+ stderr: translateLevel(supportsColor(true, tty.isatty(2)))
613
+ };
614
+ }
615
+ });
616
+
502
617
  // node_modules/debug/src/node.js
503
618
  var require_node = __commonJS({
504
619
  "node_modules/debug/src/node.js"(exports2, module2) {
@@ -518,7 +633,7 @@ var require_node = __commonJS({
518
633
  );
519
634
  exports2.colors = [6, 2, 3, 4, 5, 1];
520
635
  try {
521
- const supportsColor = require("supports-color");
636
+ const supportsColor = require_supports_color();
522
637
  if (supportsColor && (supportsColor.stderr || supportsColor).level >= 2) {
523
638
  exports2.colors = [
524
639
  20,
@@ -5301,13 +5416,30 @@ var init_GitClient = __esm({
5301
5416
  const output = await this.exec(["rev-parse", `${commitSha}^@`]);
5302
5417
  return output.trim().split("\n").filter(Boolean);
5303
5418
  }
5419
+ /**
5420
+ * Detects renames and copies between two trees.
5421
+ *
5422
+ * Both `R` (rename) and `C` (copy) entries are returned as `{from, to}` pairs.
5423
+ * Copies are treated as rename-equivalent because the generator may produce the
5424
+ * new path while the old path remains in the tree (e.g., imperfect cleanup,
5425
+ * customer edits at the old path, or test helpers that only write new files).
5426
+ * Callers that care about "is the source still present" should verify via
5427
+ * `showFile(toTree, from)`.
5428
+ */
5304
5429
  async detectRenames(fromTree, toTree) {
5305
5430
  try {
5306
- const output = await this.exec(["diff", "--find-renames", "--name-status", fromTree, toTree]);
5431
+ const output = await this.exec([
5432
+ "diff",
5433
+ "--find-renames",
5434
+ "--find-copies",
5435
+ "--name-status",
5436
+ fromTree,
5437
+ toTree
5438
+ ]);
5307
5439
  const renames = [];
5308
5440
  for (const line of output.trim().split("\n")) {
5309
5441
  if (!line) continue;
5310
- if (line.startsWith("R")) {
5442
+ if (line.startsWith("R") || line.startsWith("C")) {
5311
5443
  const parts = line.split(" ");
5312
5444
  if (parts.length >= 3) {
5313
5445
  renames.push({ from: parts[1], to: parts[2] });
@@ -13110,6 +13242,41 @@ init_GitClient();
13110
13242
  var import_node_fs = require("fs");
13111
13243
  var import_node_path2 = require("path");
13112
13244
  var import_yaml = __toESM(require_dist3(), 1);
13245
+
13246
+ // src/credentials.ts
13247
+ var CREDENTIAL_PATTERNS = [
13248
+ { name: "PRIVATE KEY block", re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
13249
+ { name: "OPENSSH key", re: /-----BEGIN OPENSSH PRIVATE KEY-----/ },
13250
+ { name: "RSA key", re: /-----BEGIN RSA PRIVATE KEY-----/ },
13251
+ { name: "CERTIFICATE", re: /-----BEGIN CERTIFICATE-----/ },
13252
+ { name: "TOKEN=", re: /\bTOKEN\s*=\s*["']?[A-Za-z0-9._\-+/=]{10,}/ },
13253
+ { name: "api_key=", re: /\bapi[_-]?key\s*=\s*["']?[A-Za-z0-9._\-+/=]{10,}/i },
13254
+ { name: "password=", re: /\bpassword\s*=\s*["']?[^\s"']{6,}/i },
13255
+ { name: "AWS_SECRET_ACCESS_KEY", re: /AWS_SECRET_ACCESS_KEY/ },
13256
+ { name: "JWT (eyJ...)", re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/ }
13257
+ ];
13258
+ var SENSITIVE_FILE_PATTERNS = [
13259
+ /(^|\/)\.env(\.[^/]+)?$/,
13260
+ /\.pem$/,
13261
+ /\.key$/,
13262
+ /\.pkce_state\.json$/,
13263
+ /(^|\/)id_rsa$/,
13264
+ /(^|\/)id_ed25519$/
13265
+ ];
13266
+ function isSensitiveFile(filePath) {
13267
+ return SENSITIVE_FILE_PATTERNS.some((re) => re.test(filePath));
13268
+ }
13269
+ function scanForCredentials(content) {
13270
+ const matches = [];
13271
+ for (const { name, re } of CREDENTIAL_PATTERNS) {
13272
+ if (re.test(content)) {
13273
+ matches.push(name);
13274
+ }
13275
+ }
13276
+ return matches;
13277
+ }
13278
+
13279
+ // src/LockfileManager.ts
13113
13280
  var LOCKFILE_HEADER = "# DO NOT EDIT MANUALLY - Managed by Fern Replay\n";
13114
13281
  var LockfileNotFoundError = class extends Error {
13115
13282
  constructor(path2) {
@@ -13120,6 +13287,7 @@ var LockfileNotFoundError = class extends Error {
13120
13287
  var LockfileManager = class {
13121
13288
  outputDir;
13122
13289
  lock = null;
13290
+ warnings = [];
13123
13291
  constructor(outputDir) {
13124
13292
  this.outputDir = outputDir;
13125
13293
  }
@@ -13168,6 +13336,27 @@ var LockfileManager = class {
13168
13336
  if (!this.lock) {
13169
13337
  throw new Error("No lockfile data to save. Call read() or initialize() first.");
13170
13338
  }
13339
+ this.warnings.length = 0;
13340
+ const cleanPatches = [];
13341
+ for (const patch of this.lock.patches) {
13342
+ const credentialMatches = scanForCredentials(patch.patch_content);
13343
+ const sensitiveFiles = patch.files.filter((f) => isSensitiveFile(f));
13344
+ if (credentialMatches.length > 0 || sensitiveFiles.length > 0) {
13345
+ const reasons = [];
13346
+ if (credentialMatches.length > 0) {
13347
+ reasons.push(`credential patterns: ${credentialMatches.join(", ")}`);
13348
+ }
13349
+ if (sensitiveFiles.length > 0) {
13350
+ reasons.push(`sensitive files: ${sensitiveFiles.join(", ")}`);
13351
+ }
13352
+ this.warnings.push(
13353
+ `Patch ${patch.id} removed from lockfile: ${reasons.join("; ")}. This prevents credential exposure in committed lockfiles.`
13354
+ );
13355
+ } else {
13356
+ cleanPatches.push(patch);
13357
+ }
13358
+ }
13359
+ this.lock.patches = cleanPatches;
13171
13360
  const dir = (0, import_node_path2.dirname)(this.lockfilePath);
13172
13361
  if (!(0, import_node_fs.existsSync)(dir)) {
13173
13362
  (0, import_node_fs.mkdirSync)(dir, { recursive: true });
@@ -13280,7 +13469,7 @@ var FERN_SUPPORT_NAMES = ["fern-support", "Fern Support"];
13280
13469
  function isGenerationCommit(commit) {
13281
13470
  const isFernSupport = FERN_SUPPORT_NAMES.includes(commit.authorName);
13282
13471
  const isBotAuthor = !isFernSupport && (commit.authorLogin === FERN_BOT_LOGIN || commit.authorEmail === FERN_BOT_EMAIL || commit.authorName === FERN_BOT_NAME);
13283
- const hasGenerationMarker = commit.message.startsWith("[fern-generated]") || commit.message.startsWith("[fern-replay]") || commit.message.includes("Generated by Fern") || commit.message.includes("\u{1F916} Generated with Fern") || // Squash merge of a Fern-generated PR uses the PR title as commit message.
13472
+ const hasGenerationMarker = commit.message.startsWith("[fern-generated]") || commit.message.startsWith("[fern-replay]") || commit.message.startsWith("[fern-autoversion]") || commit.message.includes("Generated by Fern") || commit.message.includes("\u{1F916} Generated with Fern") || // Squash merge of a Fern-generated PR uses the PR title as commit message.
13284
13473
  // The default PR title is "SDK Generation" (from GithubStep's commitMessage default).
13285
13474
  // GitHub appends "(#N)" for the PR number, e.g. "SDK Generation (#70)".
13286
13475
  commit.message.startsWith("SDK Generation");
@@ -13303,6 +13492,37 @@ function parseRevertedMessage(subject) {
13303
13492
 
13304
13493
  // src/ReplayDetector.ts
13305
13494
  var INFRASTRUCTURE_FILES = /* @__PURE__ */ new Set([".fernignore"]);
13495
+ function parsePatchFileHeaders(patchContent) {
13496
+ const results = [];
13497
+ let currentPath = null;
13498
+ let currentIsCreate = false;
13499
+ let currentIsDelete = false;
13500
+ const flush = () => {
13501
+ if (currentPath !== null) {
13502
+ results.push({ path: currentPath, isCreate: currentIsCreate, isDelete: currentIsDelete });
13503
+ }
13504
+ };
13505
+ for (const line of patchContent.split("\n")) {
13506
+ if (line.startsWith("diff --git ")) {
13507
+ flush();
13508
+ currentPath = null;
13509
+ currentIsCreate = false;
13510
+ currentIsDelete = false;
13511
+ const match2 = line.match(/^diff --git a\/(.+?) b\/(.+?)$/);
13512
+ if (match2) {
13513
+ currentPath = match2[2] ?? null;
13514
+ }
13515
+ } else if (currentPath !== null) {
13516
+ if (line.startsWith("new file mode ")) {
13517
+ currentIsCreate = true;
13518
+ } else if (line.startsWith("deleted file mode ")) {
13519
+ currentIsDelete = true;
13520
+ }
13521
+ }
13522
+ }
13523
+ flush();
13524
+ return results;
13525
+ }
13306
13526
  var ReplayDetector = class {
13307
13527
  git;
13308
13528
  lockManager;
@@ -13381,12 +13601,31 @@ var ReplayDetector = class {
13381
13601
  );
13382
13602
  continue;
13383
13603
  }
13384
- const contentHash = this.computeContentHash(patchContent);
13604
+ let contentHash = this.computeContentHash(patchContent);
13385
13605
  if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
13386
13606
  continue;
13387
13607
  }
13388
13608
  const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
13389
- const files = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f));
13609
+ const allFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
13610
+ const sensitiveFiles = allFiles.filter((f) => isSensitiveFile(f));
13611
+ const files = allFiles.filter((f) => !isSensitiveFile(f));
13612
+ if (sensitiveFiles.length > 0) {
13613
+ this.warnings.push(
13614
+ `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
13615
+ );
13616
+ if (files.length > 0) {
13617
+ const parentSha = parents[0];
13618
+ if (parentSha) {
13619
+ try {
13620
+ patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
13621
+ } catch {
13622
+ continue;
13623
+ }
13624
+ if (!patchContent.trim()) continue;
13625
+ contentHash = this.computeContentHash(patchContent);
13626
+ }
13627
+ }
13628
+ }
13390
13629
  if (files.length === 0) {
13391
13630
  continue;
13392
13631
  }
@@ -13398,14 +13637,16 @@ var ReplayDetector = class {
13398
13637
  original_author: `${commit.authorName} <${commit.authorEmail}>`,
13399
13638
  base_generation: lastGen.commit_sha,
13400
13639
  files,
13401
- patch_content: patchContent
13640
+ patch_content: patchContent,
13641
+ ...this.isCreationOnlyPatch(patchContent) ? { user_owned: true } : {}
13402
13642
  });
13403
13643
  }
13404
- newPatches.reverse();
13644
+ const survivingPatches = await this.collapseNetZeroFiles(newPatches);
13645
+ survivingPatches.reverse();
13405
13646
  const revertedPatchIdSet = /* @__PURE__ */ new Set();
13406
13647
  const revertIndicesToRemove = /* @__PURE__ */ new Set();
13407
- for (let i = 0; i < newPatches.length; i++) {
13408
- const patch = newPatches[i];
13648
+ for (let i = 0; i < survivingPatches.length; i++) {
13649
+ const patch = survivingPatches[i];
13409
13650
  if (!isRevertCommit(patch.original_message)) continue;
13410
13651
  let body = "";
13411
13652
  try {
@@ -13434,7 +13675,7 @@ var ReplayDetector = class {
13434
13675
  if (matchedExisting) continue;
13435
13676
  let matchedNew = false;
13436
13677
  if (revertedSha) {
13437
- const idx = newPatches.findIndex(
13678
+ const idx = survivingPatches.findIndex(
13438
13679
  (p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_commit === revertedSha
13439
13680
  );
13440
13681
  if (idx !== -1) {
@@ -13444,7 +13685,7 @@ var ReplayDetector = class {
13444
13685
  }
13445
13686
  }
13446
13687
  if (!matchedNew && revertedMessage) {
13447
- const idx = newPatches.findIndex(
13688
+ const idx = survivingPatches.findIndex(
13448
13689
  (p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_message === revertedMessage
13449
13690
  );
13450
13691
  if (idx !== -1) {
@@ -13456,18 +13697,107 @@ var ReplayDetector = class {
13456
13697
  revertIndicesToRemove.add(i);
13457
13698
  }
13458
13699
  }
13459
- const filteredPatches = newPatches.filter((_, i) => !revertIndicesToRemove.has(i));
13700
+ const filteredPatches = survivingPatches.filter((_, i) => !revertIndicesToRemove.has(i));
13460
13701
  return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
13461
13702
  }
13462
13703
  /**
13463
13704
  * Compute content hash for deduplication.
13464
- * Removes commit SHA line and index lines before hashing,
13465
- * so rebased commits with same content produce the same hash.
13705
+ *
13706
+ * Produces a format-agnostic hash so both `git format-patch` output
13707
+ * (email-wrapped) and plain `git diff` output hash to the same value
13708
+ * when the underlying diff hunks are identical.
13709
+ *
13710
+ * Normalization:
13711
+ * 1. Strip email wrapper: everything before the first `diff --git` line
13712
+ * (From:, Subject:, Date:, diffstat, blank separators).
13713
+ * 2. Strip `index` lines (blob SHA pairs change across rebases).
13714
+ * 3. Strip trailing git version marker (`-- \n<version>`).
13715
+ *
13716
+ * This ensures content hashes match across the no-patches and normal-
13717
+ * regeneration flows, which store formatPatch and git-diff formats
13718
+ * respectively (FER-9850, D5-D9).
13466
13719
  */
13467
13720
  computeContentHash(patchContent) {
13468
- const normalized = patchContent.split("\n").filter((line) => !line.startsWith("From ") && !line.startsWith("index ") && !line.startsWith("Date: ")).join("\n");
13721
+ const lines = patchContent.split("\n");
13722
+ const diffStart = lines.findIndex((l) => l.startsWith("diff --git "));
13723
+ const relevantLines = diffStart > 0 ? lines.slice(diffStart) : lines;
13724
+ const normalized = relevantLines.filter((line) => !line.startsWith("index ")).join("\n").replace(/\n-- \n[\d]+\.[\d]+[^\n]*\s*$/, "");
13469
13725
  return `sha256:${(0, import_node_crypto.createHash)("sha256").update(normalized).digest("hex")}`;
13470
13726
  }
13727
+ /**
13728
+ * FER-9805 — Drop patches whose files were transiently created and then
13729
+ * deleted within the current linear detection window, and are absent from
13730
+ * HEAD at the end of the window.
13731
+ *
13732
+ * Rationale: on a merge commit, createMergeCompositePatch already diffs
13733
+ * firstParent..mergeCommit so net-zero files never enter the composite. On
13734
+ * linear history each commit becomes its own patch, so a file that was
13735
+ * briefly added and later removed survives as a create+delete pair whose
13736
+ * cumulative diff is empty. We drop such patches before they reach the
13737
+ * lockfile.
13738
+ *
13739
+ * A file is "transient" when:
13740
+ * 1. some patch in the window sets `new file mode` for it (a creation), AND
13741
+ * 2. some patch in the window sets `deleted file mode` for it (a deletion), AND
13742
+ * 3. it is absent from HEAD's tree.
13743
+ *
13744
+ * The HEAD guard (#3) prevents false positives when a file is deleted
13745
+ * and then recreated with different content — the cumulative diff of
13746
+ * such a pair is non-empty and must be preserved.
13747
+ *
13748
+ * This only drops patches whose `files` are a subset of the transient
13749
+ * set. A partial-transient patch (one that touches a transient file
13750
+ * alongside a persistent one) is left unmodified; surgical stripping of
13751
+ * single files from a multi-file patch would require a follow-up that
13752
+ * regenerates the patch content via `git format-patch -- <files>`. No
13753
+ * current failing test exercises this, and leaving the patch intact
13754
+ * preserves today's behaviour. TODO(FER-9805): revisit if a future
13755
+ * adversarial test demands per-file stripping.
13756
+ */
13757
+ async collapseNetZeroFiles(patches) {
13758
+ if (patches.length === 0) return patches;
13759
+ const stateByFile = /* @__PURE__ */ new Map();
13760
+ for (const patch of patches) {
13761
+ for (const header of parsePatchFileHeaders(patch.patch_content)) {
13762
+ if (!header.isCreate && !header.isDelete) continue;
13763
+ const prior = stateByFile.get(header.path) ?? { created: false, deleted: false };
13764
+ if (header.isCreate) prior.created = true;
13765
+ if (header.isDelete) prior.deleted = true;
13766
+ stateByFile.set(header.path, prior);
13767
+ }
13768
+ }
13769
+ let anyCandidate = false;
13770
+ for (const state of stateByFile.values()) {
13771
+ if (state.created && state.deleted) {
13772
+ anyCandidate = true;
13773
+ break;
13774
+ }
13775
+ }
13776
+ if (!anyCandidate) return patches;
13777
+ const headFiles = await this.listHeadFiles();
13778
+ const transient = /* @__PURE__ */ new Set();
13779
+ for (const [file, state] of stateByFile) {
13780
+ if (state.created && state.deleted && !headFiles.has(file)) {
13781
+ transient.add(file);
13782
+ }
13783
+ }
13784
+ if (transient.size === 0) return patches;
13785
+ return patches.filter((patch) => !patch.files.every((f) => transient.has(f)));
13786
+ }
13787
+ /**
13788
+ * List every tracked path at HEAD (one `git ls-tree -r` invocation).
13789
+ * Returns an empty Set if HEAD is unborn or the invocation fails — the
13790
+ * caller then treats every candidate as "not in HEAD" and may collapse
13791
+ * more aggressively. On typical SDK repos this is a single sub-10ms call.
13792
+ */
13793
+ async listHeadFiles() {
13794
+ try {
13795
+ const out = await this.git.exec(["ls-tree", "-r", "--name-only", "HEAD"]);
13796
+ return new Set(out.split("\n").map((l) => l.trim()).filter(Boolean));
13797
+ } catch {
13798
+ return /* @__PURE__ */ new Set();
13799
+ }
13800
+ }
13471
13801
  /**
13472
13802
  * Create a single composite patch for a merge commit by diffing the merge
13473
13803
  * commit against its first parent. This captures the net change of the
@@ -13483,7 +13813,14 @@ var ReplayDetector = class {
13483
13813
  firstParent,
13484
13814
  mergeCommit.sha
13485
13815
  ]);
13486
- const files = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
13816
+ const allMergeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
13817
+ const sensitiveMergeFiles = allMergeFiles.filter((f) => isSensitiveFile(f));
13818
+ const files = allMergeFiles.filter((f) => !isSensitiveFile(f));
13819
+ if (sensitiveMergeFiles.length > 0) {
13820
+ this.warnings.push(
13821
+ `Sensitive file(s) excluded from merge patch for commit ${mergeCommit.sha.slice(0, 7)}: ${sensitiveMergeFiles.join(", ")}. These files will not be tracked by replay.`
13822
+ );
13823
+ }
13487
13824
  if (files.length === 0) return null;
13488
13825
  const diff = await this.git.exec([
13489
13826
  "diff",
@@ -13505,7 +13842,8 @@ var ReplayDetector = class {
13505
13842
  original_author: `${mergeCommit.authorName} <${mergeCommit.authorEmail}>`,
13506
13843
  base_generation: lastGen.commit_sha,
13507
13844
  files,
13508
- patch_content: diff
13845
+ patch_content: diff,
13846
+ ...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
13509
13847
  };
13510
13848
  }
13511
13849
  /**
@@ -13519,7 +13857,14 @@ var ReplayDetector = class {
13519
13857
  return this.detectPatchesViaCommitScan();
13520
13858
  }
13521
13859
  const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
13522
- const files = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
13860
+ const allTreeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
13861
+ const sensitiveTreeFiles = allTreeFiles.filter((f) => isSensitiveFile(f));
13862
+ const files = allTreeFiles.filter((f) => !isSensitiveFile(f));
13863
+ if (sensitiveTreeFiles.length > 0) {
13864
+ this.warnings.push(
13865
+ `Sensitive file(s) excluded from composite patch: ${sensitiveTreeFiles.join(", ")}. These files will not be tracked by replay.`
13866
+ );
13867
+ }
13523
13868
  if (files.length === 0) return { patches: [], revertedPatchIds: [] };
13524
13869
  const diff = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
13525
13870
  if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
@@ -13539,7 +13884,8 @@ var ReplayDetector = class {
13539
13884
  // reference to find base file content. diffBase may be the tree_hash.
13540
13885
  base_generation: commitKnownMissing ? diffBase : lastGen.commit_sha,
13541
13886
  files,
13542
- patch_content: diff
13887
+ patch_content: diff,
13888
+ ...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
13543
13889
  };
13544
13890
  return { patches: [compositePatch], revertedPatchIds: [] };
13545
13891
  }
@@ -13586,7 +13932,7 @@ var ReplayDetector = class {
13586
13932
  } catch {
13587
13933
  continue;
13588
13934
  }
13589
- const contentHash = this.computeContentHash(patchContent);
13935
+ let contentHash = this.computeContentHash(patchContent);
13590
13936
  if (existingHashes.has(contentHash) || forgottenHashes.has(contentHash)) {
13591
13937
  continue;
13592
13938
  }
@@ -13594,7 +13940,26 @@ var ReplayDetector = class {
13594
13940
  continue;
13595
13941
  }
13596
13942
  const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
13597
- const files = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f));
13943
+ const allScanFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f));
13944
+ const sensitiveScanFiles = allScanFiles.filter((f) => isSensitiveFile(f));
13945
+ const files = allScanFiles.filter((f) => !isSensitiveFile(f));
13946
+ if (sensitiveScanFiles.length > 0) {
13947
+ this.warnings.push(
13948
+ `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveScanFiles.join(", ")}. These files will not be tracked by replay.`
13949
+ );
13950
+ if (files.length > 0) {
13951
+ if (parents.length === 0) {
13952
+ continue;
13953
+ }
13954
+ try {
13955
+ patchContent = await this.git.exec(["diff", parents[0], commit.sha, "--", ...files]);
13956
+ } catch {
13957
+ continue;
13958
+ }
13959
+ if (!patchContent.trim()) continue;
13960
+ contentHash = this.computeContentHash(patchContent);
13961
+ }
13962
+ }
13598
13963
  if (files.length === 0) {
13599
13964
  continue;
13600
13965
  }
@@ -15506,6 +15871,20 @@ var ReplayApplicator = class {
15506
15871
  resetAccumulator() {
15507
15872
  this.fileTheirsAccumulator.clear();
15508
15873
  }
15874
+ /**
15875
+ * @internal Test-only.
15876
+ * Returns a defensive copy of the inter-patch accumulator. Used by the
15877
+ * adversarial test suite (FER-9791) to assert invariant I2: after every
15878
+ * applied patch, the accumulator has an entry for each file the patch
15879
+ * touched, and the entry's content matches on-disk.
15880
+ */
15881
+ getAccumulatorSnapshot() {
15882
+ const snapshot = /* @__PURE__ */ new Map();
15883
+ for (const [path2, entry] of this.fileTheirsAccumulator) {
15884
+ snapshot.set(path2, { content: entry.content, baseGeneration: entry.baseGeneration });
15885
+ }
15886
+ return snapshot;
15887
+ }
15509
15888
  /**
15510
15889
  * Apply all patches, returning results for each.
15511
15890
  * Skips patches that match exclude patterns in replay.yml
@@ -15571,7 +15950,7 @@ var ReplayApplicator = class {
15571
15950
  const base = await this.git.showFile(baseGen.tree_hash, filePath);
15572
15951
  const theirs = await this.applyPatchToContent(base, patch.patch_content, filePath, tempGit, tempDir);
15573
15952
  const effectiveTheirs = theirs ?? await (0, import_promises.readFile)((0, import_node_path3.join)(this.outputDir, resolvedPath), "utf-8").catch(() => null);
15574
- if (effectiveTheirs) {
15953
+ if (effectiveTheirs != null) {
15575
15954
  this.fileTheirsAccumulator.set(resolvedPath, {
15576
15955
  content: effectiveTheirs,
15577
15956
  baseGeneration: patch.base_generation
@@ -15595,16 +15974,20 @@ var ReplayApplicator = class {
15595
15974
  return this.fileTheirsAccumulator.has(resolved);
15596
15975
  })
15597
15976
  ).then((results) => results.some(Boolean));
15598
- if (!needsAccumulation) {
15977
+ const resolvedFiles = {};
15978
+ for (const filePath of patch.files) {
15979
+ const resolvedPath = baseGen ? await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash) : filePath;
15980
+ if (resolvedPath !== filePath) {
15981
+ resolvedFiles[filePath] = resolvedPath;
15982
+ }
15983
+ }
15984
+ const patchIsRenameAware = /^rename from /m.test(patch.patch_content);
15985
+ const hasGeneratorRename = Object.keys(resolvedFiles).length > 0 && !patchIsRenameAware;
15986
+ if (!needsAccumulation && !hasGeneratorRename) {
15599
15987
  const snapshots = /* @__PURE__ */ new Map();
15600
- const resolvedFiles = {};
15601
15988
  for (const filePath of patch.files) {
15602
- const resolvedPath = baseGen ? await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash) : filePath;
15603
- if (resolvedPath !== filePath) {
15604
- resolvedFiles[filePath] = resolvedPath;
15605
- }
15606
- const fullPath = (0, import_node_path3.join)(this.outputDir, resolvedPath);
15607
- snapshots.set(resolvedPath, await (0, import_promises.readFile)(fullPath, "utf-8").catch(() => null));
15989
+ const fullPath = (0, import_node_path3.join)(this.outputDir, filePath);
15990
+ snapshots.set(filePath, await (0, import_promises.readFile)(fullPath, "utf-8").catch(() => null));
15608
15991
  }
15609
15992
  try {
15610
15993
  await this.git.execWithInput(["apply", "--3way"], patch.patch_content);
@@ -15616,8 +15999,8 @@ var ReplayApplicator = class {
15616
15999
  ...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
15617
16000
  };
15618
16001
  } catch {
15619
- for (const [resolvedPath, content] of snapshots) {
15620
- const fullPath = (0, import_node_path3.join)(this.outputDir, resolvedPath);
16002
+ for (const [filePath, content] of snapshots) {
16003
+ const fullPath = (0, import_node_path3.join)(this.outputDir, filePath);
15621
16004
  if (content != null) {
15622
16005
  await (0, import_promises.writeFile)(fullPath, content);
15623
16006
  } else {
@@ -15737,7 +16120,7 @@ var ReplayApplicator = class {
15737
16120
  }
15738
16121
  let useAccumulatorAsMergeBase = false;
15739
16122
  const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
15740
- if (accumulatorEntry && (!theirs || base == null)) {
16123
+ if (accumulatorEntry && (theirs == null || base == null)) {
15741
16124
  theirs = await this.applyPatchToContent(
15742
16125
  accumulatorEntry.content,
15743
16126
  patch.patch_content,
@@ -15745,7 +16128,16 @@ var ReplayApplicator = class {
15745
16128
  tempGit,
15746
16129
  tempDir
15747
16130
  );
15748
- if (theirs) {
16131
+ if (theirs != null) {
16132
+ useAccumulatorAsMergeBase = true;
16133
+ } else if (await this.isPatchAlreadyApplied(
16134
+ patch.patch_content,
16135
+ filePath,
16136
+ accumulatorEntry.content,
16137
+ tempGit,
16138
+ tempDir
16139
+ )) {
16140
+ theirs = accumulatorEntry.content;
15749
16141
  useAccumulatorAsMergeBase = true;
15750
16142
  }
15751
16143
  }
@@ -15762,7 +16154,7 @@ var ReplayApplicator = class {
15762
16154
  }
15763
16155
  let effective_theirs = theirs;
15764
16156
  let baseMismatchSkipped = false;
15765
- if (theirs && base && !useAccumulatorAsMergeBase) {
16157
+ if (theirs != null && base != null && !useAccumulatorAsMergeBase) {
15766
16158
  if (accumulatorEntry && accumulatorEntry.baseGeneration === patch.base_generation) {
15767
16159
  try {
15768
16160
  const preMerged = threeWayMerge(base, accumulatorEntry.content, theirs);
@@ -15778,7 +16170,7 @@ var ReplayApplicator = class {
15778
16170
  baseMismatchSkipped = true;
15779
16171
  }
15780
16172
  }
15781
- if (base == null && !ours && effective_theirs) {
16173
+ if (base == null && ours == null && effective_theirs != null) {
15782
16174
  this.fileTheirsAccumulator.set(resolvedPath, {
15783
16175
  content: effective_theirs,
15784
16176
  baseGeneration: patch.base_generation
@@ -15788,7 +16180,7 @@ var ReplayApplicator = class {
15788
16180
  await (0, import_promises.writeFile)(oursPath, effective_theirs);
15789
16181
  return { file: resolvedPath, status: "merged", reason: "new-file" };
15790
16182
  }
15791
- if (base == null && ours && effective_theirs && !useAccumulatorAsMergeBase) {
16183
+ if (base == null && ours != null && effective_theirs != null && !useAccumulatorAsMergeBase) {
15792
16184
  const merged2 = threeWayMerge("", ours, effective_theirs);
15793
16185
  const outDir2 = (0, import_node_path3.dirname)(oursPath);
15794
16186
  await (0, import_promises.mkdir)(outDir2, { recursive: true });
@@ -15802,16 +16194,20 @@ var ReplayApplicator = class {
15802
16194
  conflictMetadata: metadata
15803
16195
  };
15804
16196
  }
16197
+ this.fileTheirsAccumulator.set(resolvedPath, {
16198
+ content: merged2.content,
16199
+ baseGeneration: patch.base_generation
16200
+ });
15805
16201
  return { file: resolvedPath, status: "merged" };
15806
16202
  }
15807
- if (!effective_theirs) {
16203
+ if (effective_theirs == null) {
15808
16204
  return {
15809
16205
  file: resolvedPath,
15810
16206
  status: "skipped",
15811
16207
  reason: "missing-content"
15812
16208
  };
15813
16209
  }
15814
- if (base == null && !useAccumulatorAsMergeBase || !ours) {
16210
+ if (base == null && !useAccumulatorAsMergeBase || ours == null) {
15815
16211
  return {
15816
16212
  file: resolvedPath,
15817
16213
  status: "skipped",
@@ -15830,7 +16226,7 @@ var ReplayApplicator = class {
15830
16226
  const outDir = (0, import_node_path3.dirname)(oursPath);
15831
16227
  await (0, import_promises.mkdir)(outDir, { recursive: true });
15832
16228
  await (0, import_promises.writeFile)(oursPath, merged.content);
15833
- if (effective_theirs && !merged.hasConflicts) {
16229
+ if (effective_theirs != null && !merged.hasConflicts) {
15834
16230
  this.fileTheirsAccumulator.set(resolvedPath, {
15835
16231
  content: effective_theirs,
15836
16232
  baseGeneration: patch.base_generation
@@ -15928,6 +16324,28 @@ var ReplayApplicator = class {
15928
16324
  return null;
15929
16325
  }
15930
16326
  }
16327
+ /**
16328
+ * Detects whether a patch's additions are already present in `content`.
16329
+ * Uses `git apply --reverse --check` — if the reverse patch applies cleanly,
16330
+ * the forward patch is effectively a no-op (its +lines are already there and
16331
+ * its -lines are already gone). Used to distinguish "patch already applied"
16332
+ * from "patch base mismatch" after a forward-apply fails.
16333
+ */
16334
+ async isPatchAlreadyApplied(patchContent, filePath, content, tempGit, tempDir) {
16335
+ const fileDiff = this.extractFileDiff(patchContent, filePath);
16336
+ if (!fileDiff) return false;
16337
+ try {
16338
+ const tempFilePath = (0, import_node_path3.join)(tempDir, filePath);
16339
+ await (0, import_promises.mkdir)((0, import_node_path3.dirname)(tempFilePath), { recursive: true });
16340
+ await (0, import_promises.writeFile)(tempFilePath, content);
16341
+ await tempGit.exec(["add", filePath]);
16342
+ await tempGit.exec(["commit", "-m", `check already-applied ${filePath}`, "--allow-empty"]);
16343
+ await tempGit.execWithInput(["apply", "--reverse", "--check", "--allow-empty"], fileDiff);
16344
+ return true;
16345
+ } catch {
16346
+ return false;
16347
+ }
16348
+ }
15931
16349
  extractFileDiff(patchContent, filePath) {
15932
16350
  const lines = patchContent.split("\n");
15933
16351
  const diffLines = [];
@@ -16092,6 +16510,33 @@ var ReplayService = class {
16092
16510
  committer;
16093
16511
  lockManager;
16094
16512
  outputDir;
16513
+ /** @internal Test-only. Captures the last ReplayResult[] returned from applicator.applyPatches(). */
16514
+ _lastApplyResults = [];
16515
+ /**
16516
+ * Service-level warnings accumulated during a single runReplay() call.
16517
+ * Merged with `detector.warnings` into `ReplayReport.warnings` by each flow handler.
16518
+ * Populated by `isFileUserOwned` when a patch's base generation tree is unreachable
16519
+ * (shallow clone / aggressive `git gc --prune`) and we fall back to a conservative
16520
+ * heuristic. Cleared at the top of `runReplay()`.
16521
+ */
16522
+ warnings = [];
16523
+ /**
16524
+ * @internal Test-only accessor.
16525
+ * Returns the ReplayApplicator instance used for the last run. Tests use this
16526
+ * to inspect the inter-patch accumulator after runReplay() completes (FER-9791, I2).
16527
+ */
16528
+ get applicatorRef() {
16529
+ return this.applicator;
16530
+ }
16531
+ /**
16532
+ * @internal Test-only accessor.
16533
+ * Returns the ReplayResult[] from the most recent applyPatches() call. Tests use
16534
+ * this to filter applied-vs-conflicted-vs-absorbed patches for invariant assertions
16535
+ * (FER-9791). Returns an empty array if no patches have been applied yet.
16536
+ */
16537
+ getLastResults() {
16538
+ return this._lastApplyResults;
16539
+ }
16095
16540
  constructor(outputDir, _config) {
16096
16541
  const git = new GitClient(outputDir);
16097
16542
  this.git = git;
@@ -16101,20 +16546,66 @@ var ReplayService = class {
16101
16546
  this.applicator = new ReplayApplicator(git, this.lockManager, outputDir);
16102
16547
  this.committer = new ReplayCommitter(git, outputDir);
16103
16548
  }
16104
- async runReplay(options) {
16549
+ /**
16550
+ * Phase 1 of the two-phase replay flow. Runs preGenerationRebase (when applicable),
16551
+ * detects new patches, and creates the `[fern-generated]` commit. Leaves HEAD at
16552
+ * the generation commit (or unchanged for dry-run).
16553
+ *
16554
+ * Does NOT apply patches — the returned handle must be passed to
16555
+ * `applyPreparedReplay()` to complete the run. No disk writes to the lockfile
16556
+ * happen here; lockfile persistence is deferred to phase 2.
16557
+ *
16558
+ * Callers may land additional commits on HEAD between `prepareReplay` and
16559
+ * `applyPreparedReplay` (e.g., `[fern-autoversion]`).
16560
+ */
16561
+ async prepareReplay(options) {
16562
+ this.warnings = [];
16563
+ this.detector.warnings.length = 0;
16105
16564
  if (options?.skipApplication) {
16106
- return this.handleSkipApplication(options);
16565
+ return this._prepareSkipApplication(options);
16107
16566
  }
16108
16567
  const flow = this.determineFlow();
16109
16568
  switch (flow) {
16110
16569
  case "first-generation":
16111
- return this.handleFirstGeneration(options);
16570
+ return this._prepareFirstGeneration(options);
16112
16571
  case "no-patches":
16113
- return this.handleNoPatchesRegeneration(options);
16572
+ return this._prepareNoPatchesRegeneration(options);
16114
16573
  case "normal-regeneration":
16115
- return this.handleNormalRegeneration(options);
16574
+ return this._prepareNormalRegeneration(options);
16116
16575
  }
16117
16576
  }
16577
+ /**
16578
+ * Phase 2 of the two-phase replay flow. Applies patches, rebases them against
16579
+ * the generation, saves the lockfile, and commits `[fern-replay]` (or stages
16580
+ * under `stageOnly`). Operates on HEAD at call time — mid-phase commits are
16581
+ * respected.
16582
+ *
16583
+ * For terminal handles (dry-run, first-gen has no patch work, skip-app does
16584
+ * its own post-commit logic), this returns the precomputed report.
16585
+ */
16586
+ async applyPreparedReplay(prep, options) {
16587
+ if (prep._prepared.terminal) {
16588
+ return prep._prepared.preparedReport;
16589
+ }
16590
+ switch (prep._prepared.flow) {
16591
+ case "first-generation":
16592
+ return this._applyFirstGeneration(prep);
16593
+ case "skip-application":
16594
+ return this._applySkipApplication(prep, options);
16595
+ case "no-patches":
16596
+ return this._applyNoPatchesRegeneration(prep, options);
16597
+ case "normal-regeneration":
16598
+ return this._applyNormalRegeneration(prep, options);
16599
+ }
16600
+ }
16601
+ /**
16602
+ * Backwards-compatible atomic entry point. Composes `prepareReplay` + `applyPreparedReplay`.
16603
+ * Behavior is byte-identical to the pre-split implementation for all existing callers.
16604
+ */
16605
+ async runReplay(options) {
16606
+ const prep = await this.prepareReplay(options);
16607
+ return this.applyPreparedReplay(prep, options);
16608
+ }
16118
16609
  /**
16119
16610
  * Sync the lockfile after a divergent PR was squash-merged.
16120
16611
  * Call this BEFORE runReplay() when the CLI detects a merged divergent PR.
@@ -16175,6 +16666,7 @@ var ReplayService = class {
16175
16666
  );
16176
16667
  }
16177
16668
  this.lockManager.save();
16669
+ this.detector.warnings.push(...this.lockManager.warnings);
16178
16670
  }
16179
16671
  determineFlow() {
16180
16672
  try {
@@ -16187,15 +16679,35 @@ var ReplayService = class {
16187
16679
  throw error;
16188
16680
  }
16189
16681
  }
16190
- async handleFirstGeneration(options) {
16682
+ firstGenerationReport() {
16683
+ return {
16684
+ flow: "first-generation",
16685
+ patchesDetected: 0,
16686
+ patchesApplied: 0,
16687
+ patchesWithConflicts: 0,
16688
+ patchesSkipped: 0,
16689
+ conflicts: []
16690
+ };
16691
+ }
16692
+ async _prepareFirstGeneration(options) {
16191
16693
  if (options?.dryRun) {
16694
+ const headSha = await this.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
16192
16695
  return {
16193
16696
  flow: "first-generation",
16194
- patchesDetected: 0,
16195
- patchesApplied: 0,
16196
- patchesWithConflicts: 0,
16197
- patchesSkipped: 0,
16198
- conflicts: []
16697
+ previousGenerationSha: null,
16698
+ currentGenerationSha: headSha,
16699
+ baseBranchHead: options.baseBranchHead ?? null,
16700
+ _prepared: {
16701
+ flow: "first-generation",
16702
+ terminal: true,
16703
+ preparedReport: this.firstGenerationReport(),
16704
+ patchesToApply: [],
16705
+ newPatchIds: /* @__PURE__ */ new Set(),
16706
+ detectorWarnings: [],
16707
+ revertedCount: 0,
16708
+ genSha: headSha,
16709
+ optionsSnapshot: options
16710
+ }
16199
16711
  };
16200
16712
  }
16201
16713
  const commitOpts = options ? {
@@ -16205,22 +16717,36 @@ var ReplayService = class {
16205
16717
  } : void 0;
16206
16718
  await this.committer.commitGeneration("Initial SDK generation", commitOpts);
16207
16719
  const genRecord = await this.committer.createGenerationRecord(commitOpts);
16208
- this.lockManager.initialize(genRecord);
16720
+ this.lockManager.initializeInMemory(genRecord);
16209
16721
  return {
16210
16722
  flow: "first-generation",
16211
- patchesDetected: 0,
16212
- patchesApplied: 0,
16213
- patchesWithConflicts: 0,
16214
- patchesSkipped: 0,
16215
- conflicts: []
16723
+ previousGenerationSha: null,
16724
+ currentGenerationSha: genRecord.commit_sha,
16725
+ baseBranchHead: options?.baseBranchHead ?? null,
16726
+ _prepared: {
16727
+ flow: "first-generation",
16728
+ terminal: false,
16729
+ patchesToApply: [],
16730
+ newPatchIds: /* @__PURE__ */ new Set(),
16731
+ detectorWarnings: [],
16732
+ revertedCount: 0,
16733
+ genSha: genRecord.commit_sha,
16734
+ optionsSnapshot: options
16735
+ }
16216
16736
  };
16217
16737
  }
16738
+ async _applyFirstGeneration(_prep) {
16739
+ this.lockManager.save();
16740
+ return this.firstGenerationReport();
16741
+ }
16218
16742
  /**
16219
- * Skip-application mode: commit the generation and update the lockfile
16220
- * but don't detect or apply patches. Sets a marker so the next normal
16221
- * run skips revert detection in preGenerationRebase().
16743
+ * Skip-application mode phase 1: commit the generation and update the
16744
+ * in-memory lockfile, but don't detect or apply patches. Sets a marker
16745
+ * so the next normal run skips revert detection in preGenerationRebase().
16746
+ *
16747
+ * Disk save is deferred to `_applySkipApplication`.
16222
16748
  */
16223
- async handleSkipApplication(options) {
16749
+ async _prepareSkipApplication(options) {
16224
16750
  const commitOpts = options ? {
16225
16751
  cliVersion: options.cliVersion ?? "unknown",
16226
16752
  generatorVersions: options.generatorVersions ?? {},
@@ -16229,8 +16755,10 @@ var ReplayService = class {
16229
16755
  await this.committer.commitGeneration("Update SDK (replay skipped)", commitOpts);
16230
16756
  await this.cleanupStaleConflictMarkers();
16231
16757
  const genRecord = await this.committer.createGenerationRecord(commitOpts);
16758
+ let previousGenerationSha = null;
16232
16759
  try {
16233
- this.lockManager.read();
16760
+ const lock = this.lockManager.read();
16761
+ previousGenerationSha = lock.current_generation ?? null;
16234
16762
  this.lockManager.addGeneration(genRecord);
16235
16763
  } catch (error) {
16236
16764
  if (error instanceof LockfileNotFoundError) {
@@ -16240,7 +16768,26 @@ var ReplayService = class {
16240
16768
  }
16241
16769
  }
16242
16770
  this.lockManager.setReplaySkippedAt((/* @__PURE__ */ new Date()).toISOString());
16771
+ return {
16772
+ flow: "skip-application",
16773
+ previousGenerationSha,
16774
+ currentGenerationSha: genRecord.commit_sha,
16775
+ baseBranchHead: options?.baseBranchHead ?? null,
16776
+ _prepared: {
16777
+ flow: "skip-application",
16778
+ terminal: false,
16779
+ patchesToApply: [],
16780
+ newPatchIds: /* @__PURE__ */ new Set(),
16781
+ detectorWarnings: [],
16782
+ revertedCount: 0,
16783
+ genSha: genRecord.commit_sha,
16784
+ optionsSnapshot: options
16785
+ }
16786
+ };
16787
+ }
16788
+ async _applySkipApplication(_prep, options) {
16243
16789
  this.lockManager.save();
16790
+ const credentialWarnings = [...this.lockManager.warnings];
16244
16791
  if (!options?.stageOnly) {
16245
16792
  await this.committer.commitReplay(0);
16246
16793
  } else {
@@ -16252,14 +16799,18 @@ var ReplayService = class {
16252
16799
  patchesApplied: 0,
16253
16800
  patchesWithConflicts: 0,
16254
16801
  patchesSkipped: 0,
16255
- conflicts: []
16802
+ conflicts: [],
16803
+ warnings: credentialWarnings.length > 0 ? credentialWarnings : void 0
16256
16804
  };
16257
16805
  }
16258
- async handleNoPatchesRegeneration(options) {
16259
- const { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
16260
- const warnings = [...this.detector.warnings];
16806
+ async _prepareNoPatchesRegeneration(options) {
16807
+ const preLock = this.lockManager.read();
16808
+ const previousGenerationSha = preLock.current_generation ?? null;
16809
+ let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
16810
+ const detectorWarnings = [...this.detector.warnings];
16261
16811
  if (options?.dryRun) {
16262
- return {
16812
+ const dryRunWarnings = [...detectorWarnings, ...this.warnings];
16813
+ const preparedReport = {
16263
16814
  flow: "no-patches",
16264
16815
  patchesDetected: newPatches.length,
16265
16816
  patchesApplied: 0,
@@ -16268,9 +16819,43 @@ var ReplayService = class {
16268
16819
  patchesReverted: revertedPatchIds.length,
16269
16820
  conflicts: [],
16270
16821
  wouldApply: newPatches,
16271
- warnings: warnings.length > 0 ? warnings : void 0
16822
+ warnings: dryRunWarnings.length > 0 ? dryRunWarnings : void 0
16823
+ };
16824
+ const headSha = await this.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
16825
+ return {
16826
+ flow: "no-patches",
16827
+ previousGenerationSha,
16828
+ currentGenerationSha: headSha,
16829
+ baseBranchHead: options.baseBranchHead ?? null,
16830
+ _prepared: {
16831
+ flow: "no-patches",
16832
+ terminal: true,
16833
+ preparedReport,
16834
+ patchesToApply: [],
16835
+ newPatchIds: /* @__PURE__ */ new Set(),
16836
+ detectorWarnings,
16837
+ revertedCount: revertedPatchIds.length,
16838
+ genSha: headSha,
16839
+ optionsSnapshot: options
16840
+ }
16272
16841
  };
16273
16842
  }
16843
+ {
16844
+ const preCurrentGen = preLock.current_generation;
16845
+ const uniqueFiles = [...new Set(newPatches.flatMap((p) => p.files))];
16846
+ const absorbedFiles = /* @__PURE__ */ new Set();
16847
+ for (const file of uniqueFiles) {
16848
+ const diff = await this.git.exec(["diff", preCurrentGen, "HEAD", "--", file]).catch(() => null);
16849
+ if (diff !== null && !diff.trim()) {
16850
+ absorbedFiles.add(file);
16851
+ }
16852
+ }
16853
+ if (absorbedFiles.size > 0) {
16854
+ newPatches = newPatches.filter(
16855
+ (p) => p.files.length === 0 || !p.files.every((f) => absorbedFiles.has(f))
16856
+ );
16857
+ }
16858
+ }
16274
16859
  for (const id of revertedPatchIds) {
16275
16860
  try {
16276
16861
  this.lockManager.removePatch(id);
@@ -16286,9 +16871,31 @@ var ReplayService = class {
16286
16871
  await this.cleanupStaleConflictMarkers();
16287
16872
  const genRecord = await this.committer.createGenerationRecord(commitOpts);
16288
16873
  this.lockManager.addGeneration(genRecord);
16874
+ return {
16875
+ flow: "no-patches",
16876
+ previousGenerationSha,
16877
+ currentGenerationSha: genRecord.commit_sha,
16878
+ baseBranchHead: options?.baseBranchHead ?? null,
16879
+ _prepared: {
16880
+ flow: "no-patches",
16881
+ terminal: false,
16882
+ patchesToApply: newPatches,
16883
+ newPatchIds: new Set(newPatches.map((p) => p.id)),
16884
+ detectorWarnings,
16885
+ revertedCount: revertedPatchIds.length,
16886
+ genSha: genRecord.commit_sha,
16887
+ optionsSnapshot: options
16888
+ }
16889
+ };
16890
+ }
16891
+ async _applyNoPatchesRegeneration(prep, options) {
16892
+ const newPatches = prep._prepared.patchesToApply;
16893
+ const detectorWarnings = prep._prepared.detectorWarnings;
16894
+ const genSha = prep._prepared.genSha;
16289
16895
  let results = [];
16290
16896
  if (newPatches.length > 0) {
16291
16897
  results = await this.applicator.applyPatches(newPatches);
16898
+ this._lastApplyResults = results;
16292
16899
  this.revertConflictingFiles(results);
16293
16900
  for (const result of results) {
16294
16901
  if (result.status === "conflict") {
@@ -16296,13 +16903,14 @@ var ReplayService = class {
16296
16903
  }
16297
16904
  }
16298
16905
  }
16299
- const rebaseCounts = await this.rebasePatches(results, genRecord.commit_sha);
16906
+ const rebaseCounts = await this.rebasePatches(results, genSha);
16300
16907
  for (const patch of newPatches) {
16301
16908
  if (!rebaseCounts.absorbedPatchIds.has(patch.id)) {
16302
16909
  this.lockManager.addPatch(patch);
16303
16910
  }
16304
16911
  }
16305
16912
  this.lockManager.save();
16913
+ this.warnings.push(...this.lockManager.warnings);
16306
16914
  if (newPatches.length > 0) {
16307
16915
  if (!options?.stageOnly) {
16308
16916
  const appliedCount = results.filter((r) => r.status === "applied").length;
@@ -16311,15 +16919,27 @@ var ReplayService = class {
16311
16919
  await this.committer.stageAll();
16312
16920
  }
16313
16921
  }
16314
- return this.buildReport("no-patches", newPatches, results, options, warnings, rebaseCounts, void 0, revertedPatchIds.length);
16922
+ const warnings = [...detectorWarnings, ...this.warnings];
16923
+ return this.buildReport(
16924
+ "no-patches",
16925
+ newPatches,
16926
+ results,
16927
+ prep._prepared.optionsSnapshot,
16928
+ warnings,
16929
+ rebaseCounts,
16930
+ void 0,
16931
+ prep._prepared.revertedCount
16932
+ );
16315
16933
  }
16316
- async handleNormalRegeneration(options) {
16934
+ async _prepareNormalRegeneration(options) {
16935
+ const preLock = this.lockManager.read();
16936
+ const previousGenerationSha = preLock.current_generation ?? null;
16317
16937
  if (options?.dryRun) {
16318
16938
  const existingPatches2 = this.lockManager.getPatches();
16319
16939
  const { patches: newPatches2, revertedPatchIds: dryRunReverted } = await this.detector.detectNewPatches();
16320
- const warnings2 = [...this.detector.warnings];
16940
+ const warnings = [...this.detector.warnings, ...this.warnings];
16321
16941
  const allPatches2 = [...existingPatches2, ...newPatches2];
16322
- return {
16942
+ const preparedReport = {
16323
16943
  flow: "normal-regeneration",
16324
16944
  patchesDetected: allPatches2.length,
16325
16945
  patchesApplied: 0,
@@ -16328,7 +16948,25 @@ var ReplayService = class {
16328
16948
  patchesReverted: dryRunReverted.length,
16329
16949
  conflicts: [],
16330
16950
  wouldApply: allPatches2,
16331
- warnings: warnings2.length > 0 ? warnings2 : void 0
16951
+ warnings: warnings.length > 0 ? warnings : void 0
16952
+ };
16953
+ const headSha = await this.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
16954
+ return {
16955
+ flow: "normal-regeneration",
16956
+ previousGenerationSha,
16957
+ currentGenerationSha: headSha,
16958
+ baseBranchHead: options.baseBranchHead ?? null,
16959
+ _prepared: {
16960
+ flow: "normal-regeneration",
16961
+ terminal: true,
16962
+ preparedReport,
16963
+ patchesToApply: [],
16964
+ newPatchIds: /* @__PURE__ */ new Set(),
16965
+ detectorWarnings: [...this.detector.warnings],
16966
+ revertedCount: dryRunReverted.length,
16967
+ genSha: headSha,
16968
+ optionsSnapshot: options
16969
+ }
16332
16970
  };
16333
16971
  }
16334
16972
  let existingPatches = this.lockManager.getPatches();
@@ -16349,7 +16987,7 @@ var ReplayService = class {
16349
16987
  }
16350
16988
  existingPatches = this.lockManager.getPatches();
16351
16989
  let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
16352
- const warnings = [...this.detector.warnings];
16990
+ const detectorWarnings = [...this.detector.warnings];
16353
16991
  if (removedByPreRebase.length > 0) {
16354
16992
  const removedOriginalCommits = new Set(removedByPreRebase.map((p) => p.original_commit));
16355
16993
  const removedOriginalMessages = new Set(removedByPreRebase.map((p) => p.original_message));
@@ -16380,14 +17018,39 @@ var ReplayService = class {
16380
17018
  await this.cleanupStaleConflictMarkers();
16381
17019
  const genRecord = await this.committer.createGenerationRecord(commitOpts);
16382
17020
  this.lockManager.addGeneration(genRecord);
17021
+ return {
17022
+ flow: "normal-regeneration",
17023
+ previousGenerationSha,
17024
+ currentGenerationSha: genRecord.commit_sha,
17025
+ baseBranchHead: options?.baseBranchHead ?? null,
17026
+ _prepared: {
17027
+ flow: "normal-regeneration",
17028
+ terminal: false,
17029
+ patchesToApply: allPatches,
17030
+ newPatchIds: new Set(newPatches.map((p) => p.id)),
17031
+ preRebaseCounts,
17032
+ detectorWarnings,
17033
+ revertedCount: revertedPatchIds.length,
17034
+ genSha: genRecord.commit_sha,
17035
+ optionsSnapshot: options
17036
+ }
17037
+ };
17038
+ }
17039
+ async _applyNormalRegeneration(prep, options) {
17040
+ const allPatches = prep._prepared.patchesToApply;
17041
+ const newPatchIds = prep._prepared.newPatchIds;
17042
+ const detectorWarnings = prep._prepared.detectorWarnings;
17043
+ const genSha = prep._prepared.genSha;
16383
17044
  const results = await this.applicator.applyPatches(allPatches);
17045
+ this._lastApplyResults = results;
16384
17046
  this.revertConflictingFiles(results);
16385
17047
  for (const result of results) {
16386
17048
  if (result.status === "conflict") {
16387
17049
  result.patch.status = "unresolved";
16388
17050
  }
16389
17051
  }
16390
- const rebaseCounts = await this.rebasePatches(results, genRecord.commit_sha);
17052
+ const rebaseCounts = await this.rebasePatches(results, genSha);
17053
+ const newPatches = allPatches.filter((p) => newPatchIds.has(p.id));
16391
17054
  for (const patch of newPatches) {
16392
17055
  if (!rebaseCounts.absorbedPatchIds.has(patch.id)) {
16393
17056
  this.lockManager.addPatch(patch);
@@ -16402,21 +17065,23 @@ var ReplayService = class {
16402
17065
  }
16403
17066
  }
16404
17067
  this.lockManager.save();
17068
+ this.warnings.push(...this.lockManager.warnings);
16405
17069
  if (options?.stageOnly) {
16406
17070
  await this.committer.stageAll();
16407
17071
  } else {
16408
17072
  const appliedCount = results.filter((r) => r.status === "applied").length;
16409
17073
  await this.committer.commitReplay(appliedCount, allPatches);
16410
17074
  }
17075
+ const warnings = [...detectorWarnings, ...this.warnings];
16411
17076
  return this.buildReport(
16412
17077
  "normal-regeneration",
16413
17078
  allPatches,
16414
17079
  results,
16415
- options,
17080
+ prep._prepared.optionsSnapshot,
16416
17081
  warnings,
16417
17082
  rebaseCounts,
16418
- preRebaseCounts,
16419
- revertedPatchIds.length
17083
+ prep._prepared.preRebaseCounts,
17084
+ prep._prepared.revertedCount
16420
17085
  );
16421
17086
  }
16422
17087
  /**
@@ -16431,6 +17096,7 @@ var ReplayService = class {
16431
17096
  let keptAsUserOwned = 0;
16432
17097
  const seenContentHashes = /* @__PURE__ */ new Set();
16433
17098
  const absorbedPatchIds = /* @__PURE__ */ new Set();
17099
+ const fernignorePatterns = this.readFernignorePatterns();
16434
17100
  for (const result of results) {
16435
17101
  if (result.resolvedFiles && Object.keys(result.resolvedFiles).length > 0) {
16436
17102
  const patch = result.patch;
@@ -16451,34 +17117,78 @@ var ReplayService = class {
16451
17117
  const patch = result.patch;
16452
17118
  if (patch.base_generation === currentGenSha) continue;
16453
17119
  try {
16454
- const fernignorePatterns = this.readFernignorePatterns();
16455
- const isUserOwned = await Promise.all(
17120
+ const originalByResolved = {};
17121
+ if (result.resolvedFiles) {
17122
+ for (const [orig, resolved] of Object.entries(result.resolvedFiles)) {
17123
+ originalByResolved[resolved] = orig;
17124
+ }
17125
+ }
17126
+ const isUserOwnedPerFile = await Promise.all(
16456
17127
  patch.files.map(async (file) => {
16457
- if (file === ".fernignore") {
17128
+ if (file === ".fernignore") return true;
17129
+ if (fernignorePatterns.some((p) => file === p || minimatch(file, p))) {
16458
17130
  return true;
16459
17131
  }
16460
- if (fernignorePatterns.some((p) => file === p || minimatch(file, p))) {
17132
+ if (patch.user_owned) return true;
17133
+ if (this.fileIsCreationInPatchContent(patch.patch_content, originalByResolved[file] ?? file)) {
16461
17134
  return true;
16462
17135
  }
16463
17136
  const content = await this.git.showFile(currentGenSha, file);
16464
17137
  return content === null;
16465
17138
  })
16466
17139
  );
16467
- const hasUserOwnedFiles = isUserOwned.some(Boolean);
16468
- if (hasUserOwnedFiles) {
16469
- this.lockManager.updatePatch(patch.id, {
16470
- base_generation: currentGenSha
16471
- });
17140
+ const hasProtectedFiles = patch.files.some(
17141
+ (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch(file, p))
17142
+ );
17143
+ const allUserOwned = isUserOwnedPerFile.every(Boolean);
17144
+ if (allUserOwned && patch.files.length > 0) {
17145
+ const baseChanged = patch.base_generation !== currentGenSha;
17146
+ const userDiff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
17147
+ if (userDiff != null && userDiff.trim()) {
17148
+ const newHash = this.detector.computeContentHash(userDiff);
17149
+ patch.patch_content = userDiff;
17150
+ patch.content_hash = newHash;
17151
+ }
17152
+ patch.base_generation = currentGenSha;
17153
+ try {
17154
+ this.lockManager.updatePatch(patch.id, {
17155
+ base_generation: currentGenSha,
17156
+ ...!patch.user_owned ? { user_owned: true } : {},
17157
+ ...userDiff != null && userDiff.trim() ? { patch_content: patch.patch_content, content_hash: patch.content_hash } : {}
17158
+ });
17159
+ } catch {
17160
+ }
17161
+ patch.user_owned = true;
16472
17162
  keptAsUserOwned++;
17163
+ if (baseChanged) repointed++;
16473
17164
  continue;
16474
17165
  }
16475
17166
  const diff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
16476
- if (!diff || !diff.trim()) {
17167
+ if (diff === null) continue;
17168
+ if (!diff.trim()) {
17169
+ if (hasProtectedFiles) {
17170
+ patch.base_generation = currentGenSha;
17171
+ try {
17172
+ this.lockManager.updatePatch(patch.id, { base_generation: currentGenSha });
17173
+ } catch {
17174
+ }
17175
+ keptAsUserOwned++;
17176
+ continue;
17177
+ }
16477
17178
  this.lockManager.removePatch(patch.id);
16478
17179
  absorbedPatchIds.add(patch.id);
16479
17180
  absorbed++;
16480
17181
  continue;
16481
17182
  }
17183
+ if (hasProtectedFiles) {
17184
+ patch.base_generation = currentGenSha;
17185
+ try {
17186
+ this.lockManager.updatePatch(patch.id, { base_generation: currentGenSha });
17187
+ } catch {
17188
+ }
17189
+ keptAsUserOwned++;
17190
+ continue;
17191
+ }
16482
17192
  const newContentHash = this.detector.computeContentHash(diff);
16483
17193
  if (seenContentHashes.has(newContentHash)) {
16484
17194
  this.lockManager.removePatch(patch.id);
@@ -16487,17 +17197,89 @@ var ReplayService = class {
16487
17197
  continue;
16488
17198
  }
16489
17199
  seenContentHashes.add(newContentHash);
16490
- this.lockManager.updatePatch(patch.id, {
16491
- base_generation: currentGenSha,
16492
- patch_content: diff,
16493
- content_hash: newContentHash
16494
- });
17200
+ patch.base_generation = currentGenSha;
17201
+ patch.patch_content = diff;
17202
+ patch.content_hash = newContentHash;
17203
+ try {
17204
+ this.lockManager.updatePatch(patch.id, {
17205
+ base_generation: currentGenSha,
17206
+ patch_content: diff,
17207
+ content_hash: newContentHash
17208
+ });
17209
+ } catch {
17210
+ }
16495
17211
  contentRebased++;
16496
17212
  } catch {
16497
17213
  }
16498
17214
  }
16499
17215
  return { absorbed, repointed, contentRebased, keptAsUserOwned, absorbedPatchIds };
16500
17216
  }
17217
+ /**
17218
+ * Determine whether `file` is user-owned (never produced by the generator).
17219
+ *
17220
+ * Resolution order (first match wins):
17221
+ * 1. `patch.user_owned === true` — authoritative, persisted across cycles.
17222
+ * 2. `.fernignore` itself, or file matches a .fernignore pattern.
17223
+ * 3. Legacy-safe signal: `patch_content` shows this file as a `--- /dev/null`
17224
+ * creation. Works for patches that predate the `user_owned` field or
17225
+ * whose flag was lost. Immune to rename tricks — relies on raw patch text.
17226
+ * 4. Tree-based check against `patch.base_generation` when it is reachable
17227
+ * AND distinct from `currentGenSha`. If the base tree is unreachable
17228
+ * (shallow clone / aggressive `git gc --prune`), emit a one-time
17229
+ * customer-actionable warning (deduplicated via warnedGens) and
17230
+ * conservatively treat as user-owned — strictly safer than silent
17231
+ * absorption (FER-9809).
17232
+ * 5. Final fallback — check `currentGenSha` when there is no usable base
17233
+ * (legacy one-gen lockfile). Preserves pre-fix behavior for that edge.
17234
+ *
17235
+ * `originalFileName` is the pre-rename path when the generator moved the
17236
+ * file between the patch's base and the current generation. Tree lookups
17237
+ * at ancestor generations use the original path — that's where the
17238
+ * generator produced the content.
17239
+ */
17240
+ async isFileUserOwned(file, patch, currentGenSha, fernignorePatterns, warnedGens, originalFileName) {
17241
+ if (patch.user_owned) return true;
17242
+ if (file === ".fernignore") return true;
17243
+ if (fernignorePatterns.some((p) => file === p || minimatch(file, p))) return true;
17244
+ if (this.fileIsCreationInPatchContent(patch.patch_content, originalFileName ?? file)) {
17245
+ return true;
17246
+ }
17247
+ const base = patch.base_generation;
17248
+ if (base && base !== currentGenSha) {
17249
+ const reachable = await this.git.commitExists(base) || await this.git.treeExists(base);
17250
+ if (!reachable) {
17251
+ if (!warnedGens.has(base)) {
17252
+ warnedGens.add(base);
17253
+ this.warnings.push(
17254
+ `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.`
17255
+ );
17256
+ }
17257
+ return true;
17258
+ }
17259
+ return await this.git.showFile(base, originalFileName ?? file) === null;
17260
+ }
17261
+ return await this.git.showFile(currentGenSha, originalFileName ?? file) === null;
17262
+ }
17263
+ /**
17264
+ * Returns true if `file`'s diff section in `patchContent` starts with
17265
+ * `--- /dev/null`, meaning this file was introduced by the patch (user
17266
+ * creation). Used as a legacy-safe fallback when `patch.user_owned` is
17267
+ * absent or cleared.
17268
+ */
17269
+ fileIsCreationInPatchContent(patchContent, file) {
17270
+ const lines = patchContent.split("\n");
17271
+ let inFileSection = false;
17272
+ for (const line of lines) {
17273
+ if (line.startsWith("diff --git ")) {
17274
+ inFileSection = line.includes(` b/${file}`) || line.endsWith(` b/${file}`);
17275
+ continue;
17276
+ }
17277
+ if (inFileSection && line.startsWith("--- ")) {
17278
+ return line === "--- /dev/null";
17279
+ }
17280
+ }
17281
+ return false;
17282
+ }
16501
17283
  /**
16502
17284
  * For conflict patches with mixed results (some files merged, some conflicted),
16503
17285
  * check if the cleanly merged files were absorbed by the generator (empty diff).
@@ -16511,13 +17293,14 @@ var ReplayService = class {
16511
17293
  const cleanFiles = result.fileResults.filter((f) => f.status === "merged").map((f) => f.file);
16512
17294
  if (cleanFiles.length === 0) return;
16513
17295
  const patch = result.patch;
17296
+ if (patch.user_owned) return;
16514
17297
  const fernignorePatterns = this.readFernignorePatterns();
17298
+ const warnedGens = /* @__PURE__ */ new Set();
16515
17299
  const generatorCleanFiles = [];
16516
17300
  for (const file of cleanFiles) {
16517
- if (file === ".fernignore") continue;
16518
- if (fernignorePatterns.some((p) => file === p || minimatch(file, p))) continue;
16519
- const content = await this.git.showFile(currentGenSha, file);
16520
- if (content === null) continue;
17301
+ if (await this.isFileUserOwned(file, patch, currentGenSha, fernignorePatterns, warnedGens)) {
17302
+ continue;
17303
+ }
16521
17304
  generatorCleanFiles.push(file);
16522
17305
  }
16523
17306
  if (generatorCleanFiles.length === 0) return;
@@ -16554,7 +17337,35 @@ var ReplayService = class {
16554
17337
  let conflictResolved = 0;
16555
17338
  let conflictAbsorbed = 0;
16556
17339
  let contentRefreshed = 0;
17340
+ const fernignorePatterns = this.readFernignorePatterns();
17341
+ const warnedGens = /* @__PURE__ */ new Set();
17342
+ const reachabilityCache = /* @__PURE__ */ new Map();
17343
+ const isReachable = async (sha) => {
17344
+ const cached = reachabilityCache.get(sha);
17345
+ if (cached !== void 0) return cached;
17346
+ const reachable = await this.git.commitExists(sha) || await this.git.treeExists(sha);
17347
+ reachabilityCache.set(sha, reachable);
17348
+ return reachable;
17349
+ };
17350
+ const allPatchFilesUserOwned = async (patch) => {
17351
+ if (patch.user_owned) return true;
17352
+ if (patch.files.length === 0) return false;
17353
+ for (const file of patch.files) {
17354
+ if (!await this.isFileUserOwned(file, patch, currentGen, fernignorePatterns, warnedGens)) {
17355
+ return false;
17356
+ }
17357
+ }
17358
+ return true;
17359
+ };
16557
17360
  for (const patch of patches) {
17361
+ if (patch.base_generation && patch.base_generation !== currentGen && !warnedGens.has(patch.base_generation)) {
17362
+ if (!await isReachable(patch.base_generation)) {
17363
+ warnedGens.add(patch.base_generation);
17364
+ this.warnings.push(
17365
+ `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.`
17366
+ );
17367
+ }
17368
+ }
16558
17369
  if (patch.status != null) {
16559
17370
  delete patch.status;
16560
17371
  continue;
@@ -16564,6 +17375,7 @@ var ReplayService = class {
16564
17375
  const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
16565
17376
  if (diff === null) continue;
16566
17377
  if (!diff.trim()) {
17378
+ if (await allPatchFilesUserOwned(patch)) continue;
16567
17379
  this.lockManager.removePatch(patch.id);
16568
17380
  contentRefreshed++;
16569
17381
  continue;
@@ -16575,6 +17387,12 @@ var ReplayService = class {
16575
17387
  if (hasStaleMarkers) {
16576
17388
  continue;
16577
17389
  }
17390
+ const isProtected = patch.files.some(
17391
+ (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch(file, p))
17392
+ );
17393
+ if (isProtected) {
17394
+ continue;
17395
+ }
16578
17396
  const newContentHash = this.detector.computeContentHash(diff);
16579
17397
  if (newContentHash !== patch.content_hash) {
16580
17398
  const filesOutput = await this.git.exec(["diff", "--name-only", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
@@ -16600,6 +17418,13 @@ var ReplayService = class {
16600
17418
  const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
16601
17419
  if (diff === null) continue;
16602
17420
  if (!diff.trim()) {
17421
+ if (await allPatchFilesUserOwned(patch)) {
17422
+ try {
17423
+ this.lockManager.updatePatch(patch.id, { base_generation: currentGen });
17424
+ } catch {
17425
+ }
17426
+ continue;
17427
+ }
16603
17428
  this.lockManager.removePatch(patch.id);
16604
17429
  conflictAbsorbed++;
16605
17430
  continue;
@@ -17110,7 +17935,10 @@ function ensureGitattributesEntries(outputDir) {
17110
17935
  (0, import_node_fs4.writeFileSync)(gitattributesPath, content, "utf-8");
17111
17936
  }
17112
17937
  function computeContentHash(patchContent) {
17113
- const normalized = patchContent.split("\n").filter((line) => !line.startsWith("From ") && !line.startsWith("index ") && !line.startsWith("Date: ")).join("\n");
17938
+ const lines = patchContent.split("\n");
17939
+ const diffStart = lines.findIndex((l) => l.startsWith("diff --git "));
17940
+ const relevantLines = diffStart > 0 ? lines.slice(diffStart) : lines;
17941
+ const normalized = relevantLines.filter((line) => !line.startsWith("index ")).join("\n").replace(/\n-- \n[\d]+\.[\d]+[^\n]*\s*$/, "");
17114
17942
  return `sha256:${(0, import_node_crypto3.createHash)("sha256").update(normalized).digest("hex")}`;
17115
17943
  }
17116
17944