@fern-api/replay 0.10.3 → 0.11.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 });
@@ -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;
@@ -13340,6 +13560,7 @@ var ReplayDetector = class {
13340
13560
  }
13341
13561
  const log = await this.git.exec([
13342
13562
  "log",
13563
+ "--first-parent",
13343
13564
  "--format=%H%x00%an%x00%ae%x00%s",
13344
13565
  `${lastGen.commit_sha}..HEAD`,
13345
13566
  "--",
@@ -13355,11 +13576,20 @@ var ReplayDetector = class {
13355
13576
  if (isGenerationCommit(commit)) {
13356
13577
  continue;
13357
13578
  }
13358
- const parents = await this.git.getCommitParents(commit.sha);
13359
- if (parents.length > 1) {
13579
+ if (lock.patches.find((p) => p.original_commit === commit.sha)) {
13360
13580
  continue;
13361
13581
  }
13362
- if (lock.patches.find((p) => p.original_commit === commit.sha)) {
13582
+ const parents = await this.git.getCommitParents(commit.sha);
13583
+ if (parents.length > 1) {
13584
+ const compositePatch = await this.createMergeCompositePatch({
13585
+ mergeCommit: commit,
13586
+ firstParent: parents[0],
13587
+ lastGen,
13588
+ lock
13589
+ });
13590
+ if (compositePatch) {
13591
+ newPatches.push(compositePatch);
13592
+ }
13363
13593
  continue;
13364
13594
  }
13365
13595
  let patchContent;
@@ -13371,12 +13601,31 @@ var ReplayDetector = class {
13371
13601
  );
13372
13602
  continue;
13373
13603
  }
13374
- const contentHash = this.computeContentHash(patchContent);
13604
+ let contentHash = this.computeContentHash(patchContent);
13375
13605
  if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
13376
13606
  continue;
13377
13607
  }
13378
13608
  const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
13379
- 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
+ }
13380
13629
  if (files.length === 0) {
13381
13630
  continue;
13382
13631
  }
@@ -13388,14 +13637,16 @@ var ReplayDetector = class {
13388
13637
  original_author: `${commit.authorName} <${commit.authorEmail}>`,
13389
13638
  base_generation: lastGen.commit_sha,
13390
13639
  files,
13391
- patch_content: patchContent
13640
+ patch_content: patchContent,
13641
+ ...this.isCreationOnlyPatch(patchContent) ? { user_owned: true } : {}
13392
13642
  });
13393
13643
  }
13394
- newPatches.reverse();
13644
+ const survivingPatches = await this.collapseNetZeroFiles(newPatches);
13645
+ survivingPatches.reverse();
13395
13646
  const revertedPatchIdSet = /* @__PURE__ */ new Set();
13396
13647
  const revertIndicesToRemove = /* @__PURE__ */ new Set();
13397
- for (let i = 0; i < newPatches.length; i++) {
13398
- const patch = newPatches[i];
13648
+ for (let i = 0; i < survivingPatches.length; i++) {
13649
+ const patch = survivingPatches[i];
13399
13650
  if (!isRevertCommit(patch.original_message)) continue;
13400
13651
  let body = "";
13401
13652
  try {
@@ -13424,7 +13675,7 @@ var ReplayDetector = class {
13424
13675
  if (matchedExisting) continue;
13425
13676
  let matchedNew = false;
13426
13677
  if (revertedSha) {
13427
- const idx = newPatches.findIndex(
13678
+ const idx = survivingPatches.findIndex(
13428
13679
  (p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_commit === revertedSha
13429
13680
  );
13430
13681
  if (idx !== -1) {
@@ -13434,7 +13685,7 @@ var ReplayDetector = class {
13434
13685
  }
13435
13686
  }
13436
13687
  if (!matchedNew && revertedMessage) {
13437
- const idx = newPatches.findIndex(
13688
+ const idx = survivingPatches.findIndex(
13438
13689
  (p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_message === revertedMessage
13439
13690
  );
13440
13691
  if (idx !== -1) {
@@ -13446,18 +13697,155 @@ var ReplayDetector = class {
13446
13697
  revertIndicesToRemove.add(i);
13447
13698
  }
13448
13699
  }
13449
- const filteredPatches = newPatches.filter((_, i) => !revertIndicesToRemove.has(i));
13700
+ const filteredPatches = survivingPatches.filter((_, i) => !revertIndicesToRemove.has(i));
13450
13701
  return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
13451
13702
  }
13452
13703
  /**
13453
13704
  * Compute content hash for deduplication.
13454
- * Removes commit SHA line and index lines before hashing,
13455
- * 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).
13456
13719
  */
13457
13720
  computeContentHash(patchContent) {
13458
- 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*$/, "");
13459
13725
  return `sha256:${(0, import_node_crypto.createHash)("sha256").update(normalized).digest("hex")}`;
13460
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
+ }
13801
+ /**
13802
+ * Create a single composite patch for a merge commit by diffing the merge
13803
+ * commit against its first parent. This captures the net change of the
13804
+ * entire merged branch as one patch, eliminating accumulator chaining and
13805
+ * zero-net-change ghost conflicts.
13806
+ */
13807
+ async createMergeCompositePatch(input) {
13808
+ const { mergeCommit, firstParent, lastGen, lock } = input;
13809
+ const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
13810
+ const filesOutput = await this.git.exec([
13811
+ "diff",
13812
+ "--name-only",
13813
+ firstParent,
13814
+ mergeCommit.sha
13815
+ ]);
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
+ }
13824
+ if (files.length === 0) return null;
13825
+ const diff = await this.git.exec([
13826
+ "diff",
13827
+ firstParent,
13828
+ mergeCommit.sha,
13829
+ "--",
13830
+ ...files
13831
+ ]);
13832
+ if (!diff.trim()) return null;
13833
+ const contentHash = this.computeContentHash(diff);
13834
+ if (lock.patches.some((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
13835
+ return null;
13836
+ }
13837
+ return {
13838
+ id: `patch-merge-${mergeCommit.sha.slice(0, 8)}`,
13839
+ content_hash: contentHash,
13840
+ original_commit: mergeCommit.sha,
13841
+ original_message: mergeCommit.message,
13842
+ original_author: `${mergeCommit.authorName} <${mergeCommit.authorEmail}>`,
13843
+ base_generation: lastGen.commit_sha,
13844
+ files,
13845
+ patch_content: diff,
13846
+ ...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
13847
+ };
13848
+ }
13461
13849
  /**
13462
13850
  * Detect patches via tree diff for non-linear history. Returns a composite patch.
13463
13851
  * Revert reconciliation is skipped here because tree-diff produces a single composite
@@ -13469,7 +13857,14 @@ var ReplayDetector = class {
13469
13857
  return this.detectPatchesViaCommitScan();
13470
13858
  }
13471
13859
  const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
13472
- 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
+ }
13473
13868
  if (files.length === 0) return { patches: [], revertedPatchIds: [] };
13474
13869
  const diff = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
13475
13870
  if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
@@ -13489,7 +13884,8 @@ var ReplayDetector = class {
13489
13884
  // reference to find base file content. diffBase may be the tree_hash.
13490
13885
  base_generation: commitKnownMissing ? diffBase : lastGen.commit_sha,
13491
13886
  files,
13492
- patch_content: diff
13887
+ patch_content: diff,
13888
+ ...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
13493
13889
  };
13494
13890
  return { patches: [compositePatch], revertedPatchIds: [] };
13495
13891
  }
@@ -13536,7 +13932,7 @@ var ReplayDetector = class {
13536
13932
  } catch {
13537
13933
  continue;
13538
13934
  }
13539
- const contentHash = this.computeContentHash(patchContent);
13935
+ let contentHash = this.computeContentHash(patchContent);
13540
13936
  if (existingHashes.has(contentHash) || forgottenHashes.has(contentHash)) {
13541
13937
  continue;
13542
13938
  }
@@ -13544,7 +13940,26 @@ var ReplayDetector = class {
13544
13940
  continue;
13545
13941
  }
13546
13942
  const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
13547
- 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
+ }
13548
13963
  if (files.length === 0) {
13549
13964
  continue;
13550
13965
  }
@@ -15456,6 +15871,20 @@ var ReplayApplicator = class {
15456
15871
  resetAccumulator() {
15457
15872
  this.fileTheirsAccumulator.clear();
15458
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
+ }
15459
15888
  /**
15460
15889
  * Apply all patches, returning results for each.
15461
15890
  * Skips patches that match exclude patterns in replay.yml
@@ -15521,7 +15950,7 @@ var ReplayApplicator = class {
15521
15950
  const base = await this.git.showFile(baseGen.tree_hash, filePath);
15522
15951
  const theirs = await this.applyPatchToContent(base, patch.patch_content, filePath, tempGit, tempDir);
15523
15952
  const effectiveTheirs = theirs ?? await (0, import_promises.readFile)((0, import_node_path3.join)(this.outputDir, resolvedPath), "utf-8").catch(() => null);
15524
- if (effectiveTheirs) {
15953
+ if (effectiveTheirs != null) {
15525
15954
  this.fileTheirsAccumulator.set(resolvedPath, {
15526
15955
  content: effectiveTheirs,
15527
15956
  baseGeneration: patch.base_generation
@@ -15545,16 +15974,20 @@ var ReplayApplicator = class {
15545
15974
  return this.fileTheirsAccumulator.has(resolved);
15546
15975
  })
15547
15976
  ).then((results) => results.some(Boolean));
15548
- 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) {
15549
15987
  const snapshots = /* @__PURE__ */ new Map();
15550
- const resolvedFiles = {};
15551
15988
  for (const filePath of patch.files) {
15552
- const resolvedPath = baseGen ? await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash) : filePath;
15553
- if (resolvedPath !== filePath) {
15554
- resolvedFiles[filePath] = resolvedPath;
15555
- }
15556
- const fullPath = (0, import_node_path3.join)(this.outputDir, resolvedPath);
15557
- 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));
15558
15991
  }
15559
15992
  try {
15560
15993
  await this.git.execWithInput(["apply", "--3way"], patch.patch_content);
@@ -15566,8 +15999,8 @@ var ReplayApplicator = class {
15566
15999
  ...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
15567
16000
  };
15568
16001
  } catch {
15569
- for (const [resolvedPath, content] of snapshots) {
15570
- 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);
15571
16004
  if (content != null) {
15572
16005
  await (0, import_promises.writeFile)(fullPath, content);
15573
16006
  } else {
@@ -15687,7 +16120,7 @@ var ReplayApplicator = class {
15687
16120
  }
15688
16121
  let useAccumulatorAsMergeBase = false;
15689
16122
  const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
15690
- if (accumulatorEntry && (!theirs || base == null)) {
16123
+ if (accumulatorEntry && (theirs == null || base == null)) {
15691
16124
  theirs = await this.applyPatchToContent(
15692
16125
  accumulatorEntry.content,
15693
16126
  patch.patch_content,
@@ -15695,7 +16128,16 @@ var ReplayApplicator = class {
15695
16128
  tempGit,
15696
16129
  tempDir
15697
16130
  );
15698
- 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;
15699
16141
  useAccumulatorAsMergeBase = true;
15700
16142
  }
15701
16143
  }
@@ -15712,7 +16154,7 @@ var ReplayApplicator = class {
15712
16154
  }
15713
16155
  let effective_theirs = theirs;
15714
16156
  let baseMismatchSkipped = false;
15715
- if (theirs && base && !useAccumulatorAsMergeBase) {
16157
+ if (theirs != null && base != null && !useAccumulatorAsMergeBase) {
15716
16158
  if (accumulatorEntry && accumulatorEntry.baseGeneration === patch.base_generation) {
15717
16159
  try {
15718
16160
  const preMerged = threeWayMerge(base, accumulatorEntry.content, theirs);
@@ -15728,7 +16170,7 @@ var ReplayApplicator = class {
15728
16170
  baseMismatchSkipped = true;
15729
16171
  }
15730
16172
  }
15731
- if (base == null && !ours && effective_theirs) {
16173
+ if (base == null && ours == null && effective_theirs != null) {
15732
16174
  this.fileTheirsAccumulator.set(resolvedPath, {
15733
16175
  content: effective_theirs,
15734
16176
  baseGeneration: patch.base_generation
@@ -15738,7 +16180,7 @@ var ReplayApplicator = class {
15738
16180
  await (0, import_promises.writeFile)(oursPath, effective_theirs);
15739
16181
  return { file: resolvedPath, status: "merged", reason: "new-file" };
15740
16182
  }
15741
- if (base == null && ours && effective_theirs && !useAccumulatorAsMergeBase) {
16183
+ if (base == null && ours != null && effective_theirs != null && !useAccumulatorAsMergeBase) {
15742
16184
  const merged2 = threeWayMerge("", ours, effective_theirs);
15743
16185
  const outDir2 = (0, import_node_path3.dirname)(oursPath);
15744
16186
  await (0, import_promises.mkdir)(outDir2, { recursive: true });
@@ -15752,16 +16194,20 @@ var ReplayApplicator = class {
15752
16194
  conflictMetadata: metadata
15753
16195
  };
15754
16196
  }
16197
+ this.fileTheirsAccumulator.set(resolvedPath, {
16198
+ content: merged2.content,
16199
+ baseGeneration: patch.base_generation
16200
+ });
15755
16201
  return { file: resolvedPath, status: "merged" };
15756
16202
  }
15757
- if (!effective_theirs) {
16203
+ if (effective_theirs == null) {
15758
16204
  return {
15759
16205
  file: resolvedPath,
15760
16206
  status: "skipped",
15761
16207
  reason: "missing-content"
15762
16208
  };
15763
16209
  }
15764
- if (base == null && !useAccumulatorAsMergeBase || !ours) {
16210
+ if (base == null && !useAccumulatorAsMergeBase || ours == null) {
15765
16211
  return {
15766
16212
  file: resolvedPath,
15767
16213
  status: "skipped",
@@ -15780,7 +16226,7 @@ var ReplayApplicator = class {
15780
16226
  const outDir = (0, import_node_path3.dirname)(oursPath);
15781
16227
  await (0, import_promises.mkdir)(outDir, { recursive: true });
15782
16228
  await (0, import_promises.writeFile)(oursPath, merged.content);
15783
- if (effective_theirs && !merged.hasConflicts) {
16229
+ if (effective_theirs != null && !merged.hasConflicts) {
15784
16230
  this.fileTheirsAccumulator.set(resolvedPath, {
15785
16231
  content: effective_theirs,
15786
16232
  baseGeneration: patch.base_generation
@@ -15878,6 +16324,28 @@ var ReplayApplicator = class {
15878
16324
  return null;
15879
16325
  }
15880
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
+ }
15881
16349
  extractFileDiff(patchContent, filePath) {
15882
16350
  const lines = patchContent.split("\n");
15883
16351
  const diffLines = [];
@@ -16042,6 +16510,33 @@ var ReplayService = class {
16042
16510
  committer;
16043
16511
  lockManager;
16044
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
+ }
16045
16540
  constructor(outputDir, _config) {
16046
16541
  const git = new GitClient(outputDir);
16047
16542
  this.git = git;
@@ -16052,6 +16547,7 @@ var ReplayService = class {
16052
16547
  this.committer = new ReplayCommitter(git, outputDir);
16053
16548
  }
16054
16549
  async runReplay(options) {
16550
+ this.warnings = [];
16055
16551
  if (options?.skipApplication) {
16056
16552
  return this.handleSkipApplication(options);
16057
16553
  }
@@ -16125,6 +16621,7 @@ var ReplayService = class {
16125
16621
  );
16126
16622
  }
16127
16623
  this.lockManager.save();
16624
+ this.detector.warnings.push(...this.lockManager.warnings);
16128
16625
  }
16129
16626
  determineFlow() {
16130
16627
  try {
@@ -16191,6 +16688,7 @@ var ReplayService = class {
16191
16688
  }
16192
16689
  this.lockManager.setReplaySkippedAt((/* @__PURE__ */ new Date()).toISOString());
16193
16690
  this.lockManager.save();
16691
+ const credentialWarnings = [...this.lockManager.warnings];
16194
16692
  if (!options?.stageOnly) {
16195
16693
  await this.committer.commitReplay(0);
16196
16694
  } else {
@@ -16202,13 +16700,15 @@ var ReplayService = class {
16202
16700
  patchesApplied: 0,
16203
16701
  patchesWithConflicts: 0,
16204
16702
  patchesSkipped: 0,
16205
- conflicts: []
16703
+ conflicts: [],
16704
+ warnings: credentialWarnings.length > 0 ? credentialWarnings : void 0
16206
16705
  };
16207
16706
  }
16208
16707
  async handleNoPatchesRegeneration(options) {
16209
- const { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
16210
- const warnings = [...this.detector.warnings];
16708
+ let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
16709
+ const detectorWarnings = [...this.detector.warnings];
16211
16710
  if (options?.dryRun) {
16711
+ const dryRunWarnings = [...detectorWarnings, ...this.warnings];
16212
16712
  return {
16213
16713
  flow: "no-patches",
16214
16714
  patchesDetected: newPatches.length,
@@ -16218,9 +16718,26 @@ var ReplayService = class {
16218
16718
  patchesReverted: revertedPatchIds.length,
16219
16719
  conflicts: [],
16220
16720
  wouldApply: newPatches,
16221
- warnings: warnings.length > 0 ? warnings : void 0
16721
+ warnings: dryRunWarnings.length > 0 ? dryRunWarnings : void 0
16222
16722
  };
16223
16723
  }
16724
+ {
16725
+ const preLock = this.lockManager.read();
16726
+ const preCurrentGen = preLock.current_generation;
16727
+ const uniqueFiles = [...new Set(newPatches.flatMap((p) => p.files))];
16728
+ const absorbedFiles = /* @__PURE__ */ new Set();
16729
+ for (const file of uniqueFiles) {
16730
+ const diff = await this.git.exec(["diff", preCurrentGen, "HEAD", "--", file]).catch(() => null);
16731
+ if (diff !== null && !diff.trim()) {
16732
+ absorbedFiles.add(file);
16733
+ }
16734
+ }
16735
+ if (absorbedFiles.size > 0) {
16736
+ newPatches = newPatches.filter(
16737
+ (p) => p.files.length === 0 || !p.files.every((f) => absorbedFiles.has(f))
16738
+ );
16739
+ }
16740
+ }
16224
16741
  for (const id of revertedPatchIds) {
16225
16742
  try {
16226
16743
  this.lockManager.removePatch(id);
@@ -16239,6 +16756,7 @@ var ReplayService = class {
16239
16756
  let results = [];
16240
16757
  if (newPatches.length > 0) {
16241
16758
  results = await this.applicator.applyPatches(newPatches);
16759
+ this._lastApplyResults = results;
16242
16760
  this.revertConflictingFiles(results);
16243
16761
  for (const result of results) {
16244
16762
  if (result.status === "conflict") {
@@ -16253,6 +16771,7 @@ var ReplayService = class {
16253
16771
  }
16254
16772
  }
16255
16773
  this.lockManager.save();
16774
+ this.warnings.push(...this.lockManager.warnings);
16256
16775
  if (newPatches.length > 0) {
16257
16776
  if (!options?.stageOnly) {
16258
16777
  const appliedCount = results.filter((r) => r.status === "applied").length;
@@ -16261,13 +16780,14 @@ var ReplayService = class {
16261
16780
  await this.committer.stageAll();
16262
16781
  }
16263
16782
  }
16783
+ const warnings = [...detectorWarnings, ...this.warnings];
16264
16784
  return this.buildReport("no-patches", newPatches, results, options, warnings, rebaseCounts, void 0, revertedPatchIds.length);
16265
16785
  }
16266
16786
  async handleNormalRegeneration(options) {
16267
16787
  if (options?.dryRun) {
16268
16788
  const existingPatches2 = this.lockManager.getPatches();
16269
16789
  const { patches: newPatches2, revertedPatchIds: dryRunReverted } = await this.detector.detectNewPatches();
16270
- const warnings2 = [...this.detector.warnings];
16790
+ const warnings2 = [...this.detector.warnings, ...this.warnings];
16271
16791
  const allPatches2 = [...existingPatches2, ...newPatches2];
16272
16792
  return {
16273
16793
  flow: "normal-regeneration",
@@ -16299,7 +16819,7 @@ var ReplayService = class {
16299
16819
  }
16300
16820
  existingPatches = this.lockManager.getPatches();
16301
16821
  let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
16302
- const warnings = [...this.detector.warnings];
16822
+ const detectorWarnings = [...this.detector.warnings];
16303
16823
  if (removedByPreRebase.length > 0) {
16304
16824
  const removedOriginalCommits = new Set(removedByPreRebase.map((p) => p.original_commit));
16305
16825
  const removedOriginalMessages = new Set(removedByPreRebase.map((p) => p.original_message));
@@ -16331,6 +16851,7 @@ var ReplayService = class {
16331
16851
  const genRecord = await this.committer.createGenerationRecord(commitOpts);
16332
16852
  this.lockManager.addGeneration(genRecord);
16333
16853
  const results = await this.applicator.applyPatches(allPatches);
16854
+ this._lastApplyResults = results;
16334
16855
  this.revertConflictingFiles(results);
16335
16856
  for (const result of results) {
16336
16857
  if (result.status === "conflict") {
@@ -16352,12 +16873,14 @@ var ReplayService = class {
16352
16873
  }
16353
16874
  }
16354
16875
  this.lockManager.save();
16876
+ this.warnings.push(...this.lockManager.warnings);
16355
16877
  if (options?.stageOnly) {
16356
16878
  await this.committer.stageAll();
16357
16879
  } else {
16358
16880
  const appliedCount = results.filter((r) => r.status === "applied").length;
16359
16881
  await this.committer.commitReplay(appliedCount, allPatches);
16360
16882
  }
16883
+ const warnings = [...detectorWarnings, ...this.warnings];
16361
16884
  return this.buildReport(
16362
16885
  "normal-regeneration",
16363
16886
  allPatches,
@@ -16381,6 +16904,7 @@ var ReplayService = class {
16381
16904
  let keptAsUserOwned = 0;
16382
16905
  const seenContentHashes = /* @__PURE__ */ new Set();
16383
16906
  const absorbedPatchIds = /* @__PURE__ */ new Set();
16907
+ const fernignorePatterns = this.readFernignorePatterns();
16384
16908
  for (const result of results) {
16385
16909
  if (result.resolvedFiles && Object.keys(result.resolvedFiles).length > 0) {
16386
16910
  const patch = result.patch;
@@ -16401,34 +16925,78 @@ var ReplayService = class {
16401
16925
  const patch = result.patch;
16402
16926
  if (patch.base_generation === currentGenSha) continue;
16403
16927
  try {
16404
- const fernignorePatterns = this.readFernignorePatterns();
16405
- const isUserOwned = await Promise.all(
16928
+ const originalByResolved = {};
16929
+ if (result.resolvedFiles) {
16930
+ for (const [orig, resolved] of Object.entries(result.resolvedFiles)) {
16931
+ originalByResolved[resolved] = orig;
16932
+ }
16933
+ }
16934
+ const isUserOwnedPerFile = await Promise.all(
16406
16935
  patch.files.map(async (file) => {
16407
- if (file === ".fernignore") {
16936
+ if (file === ".fernignore") return true;
16937
+ if (fernignorePatterns.some((p) => file === p || minimatch(file, p))) {
16408
16938
  return true;
16409
16939
  }
16410
- if (fernignorePatterns.some((p) => file === p || minimatch(file, p))) {
16940
+ if (patch.user_owned) return true;
16941
+ if (this.fileIsCreationInPatchContent(patch.patch_content, originalByResolved[file] ?? file)) {
16411
16942
  return true;
16412
16943
  }
16413
16944
  const content = await this.git.showFile(currentGenSha, file);
16414
16945
  return content === null;
16415
16946
  })
16416
16947
  );
16417
- const hasUserOwnedFiles = isUserOwned.some(Boolean);
16418
- if (hasUserOwnedFiles) {
16419
- this.lockManager.updatePatch(patch.id, {
16420
- base_generation: currentGenSha
16421
- });
16948
+ const hasProtectedFiles = patch.files.some(
16949
+ (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch(file, p))
16950
+ );
16951
+ const allUserOwned = isUserOwnedPerFile.every(Boolean);
16952
+ if (allUserOwned && patch.files.length > 0) {
16953
+ const baseChanged = patch.base_generation !== currentGenSha;
16954
+ const userDiff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
16955
+ if (userDiff != null && userDiff.trim()) {
16956
+ const newHash = this.detector.computeContentHash(userDiff);
16957
+ patch.patch_content = userDiff;
16958
+ patch.content_hash = newHash;
16959
+ }
16960
+ patch.base_generation = currentGenSha;
16961
+ try {
16962
+ this.lockManager.updatePatch(patch.id, {
16963
+ base_generation: currentGenSha,
16964
+ ...!patch.user_owned ? { user_owned: true } : {},
16965
+ ...userDiff != null && userDiff.trim() ? { patch_content: patch.patch_content, content_hash: patch.content_hash } : {}
16966
+ });
16967
+ } catch {
16968
+ }
16969
+ patch.user_owned = true;
16422
16970
  keptAsUserOwned++;
16971
+ if (baseChanged) repointed++;
16423
16972
  continue;
16424
16973
  }
16425
16974
  const diff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
16426
- if (!diff || !diff.trim()) {
16975
+ if (diff === null) continue;
16976
+ if (!diff.trim()) {
16977
+ if (hasProtectedFiles) {
16978
+ patch.base_generation = currentGenSha;
16979
+ try {
16980
+ this.lockManager.updatePatch(patch.id, { base_generation: currentGenSha });
16981
+ } catch {
16982
+ }
16983
+ keptAsUserOwned++;
16984
+ continue;
16985
+ }
16427
16986
  this.lockManager.removePatch(patch.id);
16428
16987
  absorbedPatchIds.add(patch.id);
16429
16988
  absorbed++;
16430
16989
  continue;
16431
16990
  }
16991
+ if (hasProtectedFiles) {
16992
+ patch.base_generation = currentGenSha;
16993
+ try {
16994
+ this.lockManager.updatePatch(patch.id, { base_generation: currentGenSha });
16995
+ } catch {
16996
+ }
16997
+ keptAsUserOwned++;
16998
+ continue;
16999
+ }
16432
17000
  const newContentHash = this.detector.computeContentHash(diff);
16433
17001
  if (seenContentHashes.has(newContentHash)) {
16434
17002
  this.lockManager.removePatch(patch.id);
@@ -16437,17 +17005,89 @@ var ReplayService = class {
16437
17005
  continue;
16438
17006
  }
16439
17007
  seenContentHashes.add(newContentHash);
16440
- this.lockManager.updatePatch(patch.id, {
16441
- base_generation: currentGenSha,
16442
- patch_content: diff,
16443
- content_hash: newContentHash
16444
- });
17008
+ patch.base_generation = currentGenSha;
17009
+ patch.patch_content = diff;
17010
+ patch.content_hash = newContentHash;
17011
+ try {
17012
+ this.lockManager.updatePatch(patch.id, {
17013
+ base_generation: currentGenSha,
17014
+ patch_content: diff,
17015
+ content_hash: newContentHash
17016
+ });
17017
+ } catch {
17018
+ }
16445
17019
  contentRebased++;
16446
17020
  } catch {
16447
17021
  }
16448
17022
  }
16449
17023
  return { absorbed, repointed, contentRebased, keptAsUserOwned, absorbedPatchIds };
16450
17024
  }
17025
+ /**
17026
+ * Determine whether `file` is user-owned (never produced by the generator).
17027
+ *
17028
+ * Resolution order (first match wins):
17029
+ * 1. `patch.user_owned === true` — authoritative, persisted across cycles.
17030
+ * 2. `.fernignore` itself, or file matches a .fernignore pattern.
17031
+ * 3. Legacy-safe signal: `patch_content` shows this file as a `--- /dev/null`
17032
+ * creation. Works for patches that predate the `user_owned` field or
17033
+ * whose flag was lost. Immune to rename tricks — relies on raw patch text.
17034
+ * 4. Tree-based check against `patch.base_generation` when it is reachable
17035
+ * AND distinct from `currentGenSha`. If the base tree is unreachable
17036
+ * (shallow clone / aggressive `git gc --prune`), emit a one-time
17037
+ * customer-actionable warning (deduplicated via warnedGens) and
17038
+ * conservatively treat as user-owned — strictly safer than silent
17039
+ * absorption (FER-9809).
17040
+ * 5. Final fallback — check `currentGenSha` when there is no usable base
17041
+ * (legacy one-gen lockfile). Preserves pre-fix behavior for that edge.
17042
+ *
17043
+ * `originalFileName` is the pre-rename path when the generator moved the
17044
+ * file between the patch's base and the current generation. Tree lookups
17045
+ * at ancestor generations use the original path — that's where the
17046
+ * generator produced the content.
17047
+ */
17048
+ async isFileUserOwned(file, patch, currentGenSha, fernignorePatterns, warnedGens, originalFileName) {
17049
+ if (patch.user_owned) return true;
17050
+ if (file === ".fernignore") return true;
17051
+ if (fernignorePatterns.some((p) => file === p || minimatch(file, p))) return true;
17052
+ if (this.fileIsCreationInPatchContent(patch.patch_content, originalFileName ?? file)) {
17053
+ return true;
17054
+ }
17055
+ const base = patch.base_generation;
17056
+ if (base && base !== currentGenSha) {
17057
+ const reachable = await this.git.commitExists(base) || await this.git.treeExists(base);
17058
+ if (!reachable) {
17059
+ if (!warnedGens.has(base)) {
17060
+ warnedGens.add(base);
17061
+ this.warnings.push(
17062
+ `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.`
17063
+ );
17064
+ }
17065
+ return true;
17066
+ }
17067
+ return await this.git.showFile(base, originalFileName ?? file) === null;
17068
+ }
17069
+ return await this.git.showFile(currentGenSha, originalFileName ?? file) === null;
17070
+ }
17071
+ /**
17072
+ * Returns true if `file`'s diff section in `patchContent` starts with
17073
+ * `--- /dev/null`, meaning this file was introduced by the patch (user
17074
+ * creation). Used as a legacy-safe fallback when `patch.user_owned` is
17075
+ * absent or cleared.
17076
+ */
17077
+ fileIsCreationInPatchContent(patchContent, file) {
17078
+ const lines = patchContent.split("\n");
17079
+ let inFileSection = false;
17080
+ for (const line of lines) {
17081
+ if (line.startsWith("diff --git ")) {
17082
+ inFileSection = line.includes(` b/${file}`) || line.endsWith(` b/${file}`);
17083
+ continue;
17084
+ }
17085
+ if (inFileSection && line.startsWith("--- ")) {
17086
+ return line === "--- /dev/null";
17087
+ }
17088
+ }
17089
+ return false;
17090
+ }
16451
17091
  /**
16452
17092
  * For conflict patches with mixed results (some files merged, some conflicted),
16453
17093
  * check if the cleanly merged files were absorbed by the generator (empty diff).
@@ -16461,13 +17101,14 @@ var ReplayService = class {
16461
17101
  const cleanFiles = result.fileResults.filter((f) => f.status === "merged").map((f) => f.file);
16462
17102
  if (cleanFiles.length === 0) return;
16463
17103
  const patch = result.patch;
17104
+ if (patch.user_owned) return;
16464
17105
  const fernignorePatterns = this.readFernignorePatterns();
17106
+ const warnedGens = /* @__PURE__ */ new Set();
16465
17107
  const generatorCleanFiles = [];
16466
17108
  for (const file of cleanFiles) {
16467
- if (file === ".fernignore") continue;
16468
- if (fernignorePatterns.some((p) => file === p || minimatch(file, p))) continue;
16469
- const content = await this.git.showFile(currentGenSha, file);
16470
- if (content === null) continue;
17109
+ if (await this.isFileUserOwned(file, patch, currentGenSha, fernignorePatterns, warnedGens)) {
17110
+ continue;
17111
+ }
16471
17112
  generatorCleanFiles.push(file);
16472
17113
  }
16473
17114
  if (generatorCleanFiles.length === 0) return;
@@ -16504,7 +17145,35 @@ var ReplayService = class {
16504
17145
  let conflictResolved = 0;
16505
17146
  let conflictAbsorbed = 0;
16506
17147
  let contentRefreshed = 0;
17148
+ const fernignorePatterns = this.readFernignorePatterns();
17149
+ const warnedGens = /* @__PURE__ */ new Set();
17150
+ const reachabilityCache = /* @__PURE__ */ new Map();
17151
+ const isReachable = async (sha) => {
17152
+ const cached = reachabilityCache.get(sha);
17153
+ if (cached !== void 0) return cached;
17154
+ const reachable = await this.git.commitExists(sha) || await this.git.treeExists(sha);
17155
+ reachabilityCache.set(sha, reachable);
17156
+ return reachable;
17157
+ };
17158
+ const allPatchFilesUserOwned = async (patch) => {
17159
+ if (patch.user_owned) return true;
17160
+ if (patch.files.length === 0) return false;
17161
+ for (const file of patch.files) {
17162
+ if (!await this.isFileUserOwned(file, patch, currentGen, fernignorePatterns, warnedGens)) {
17163
+ return false;
17164
+ }
17165
+ }
17166
+ return true;
17167
+ };
16507
17168
  for (const patch of patches) {
17169
+ if (patch.base_generation && patch.base_generation !== currentGen && !warnedGens.has(patch.base_generation)) {
17170
+ if (!await isReachable(patch.base_generation)) {
17171
+ warnedGens.add(patch.base_generation);
17172
+ this.warnings.push(
17173
+ `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.`
17174
+ );
17175
+ }
17176
+ }
16508
17177
  if (patch.status != null) {
16509
17178
  delete patch.status;
16510
17179
  continue;
@@ -16514,6 +17183,7 @@ var ReplayService = class {
16514
17183
  const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
16515
17184
  if (diff === null) continue;
16516
17185
  if (!diff.trim()) {
17186
+ if (await allPatchFilesUserOwned(patch)) continue;
16517
17187
  this.lockManager.removePatch(patch.id);
16518
17188
  contentRefreshed++;
16519
17189
  continue;
@@ -16525,6 +17195,12 @@ var ReplayService = class {
16525
17195
  if (hasStaleMarkers) {
16526
17196
  continue;
16527
17197
  }
17198
+ const isProtected = patch.files.some(
17199
+ (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch(file, p))
17200
+ );
17201
+ if (isProtected) {
17202
+ continue;
17203
+ }
16528
17204
  const newContentHash = this.detector.computeContentHash(diff);
16529
17205
  if (newContentHash !== patch.content_hash) {
16530
17206
  const filesOutput = await this.git.exec(["diff", "--name-only", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
@@ -16550,6 +17226,13 @@ var ReplayService = class {
16550
17226
  const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
16551
17227
  if (diff === null) continue;
16552
17228
  if (!diff.trim()) {
17229
+ if (await allPatchFilesUserOwned(patch)) {
17230
+ try {
17231
+ this.lockManager.updatePatch(patch.id, { base_generation: currentGen });
17232
+ } catch {
17233
+ }
17234
+ continue;
17235
+ }
16553
17236
  this.lockManager.removePatch(patch.id);
16554
17237
  conflictAbsorbed++;
16555
17238
  continue;
@@ -17060,7 +17743,10 @@ function ensureGitattributesEntries(outputDir) {
17060
17743
  (0, import_node_fs4.writeFileSync)(gitattributesPath, content, "utf-8");
17061
17744
  }
17062
17745
  function computeContentHash(patchContent) {
17063
- const normalized = patchContent.split("\n").filter((line) => !line.startsWith("From ") && !line.startsWith("index ") && !line.startsWith("Date: ")).join("\n");
17746
+ const lines = patchContent.split("\n");
17747
+ const diffStart = lines.findIndex((l) => l.startsWith("diff --git "));
17748
+ const relevantLines = diffStart > 0 ? lines.slice(diffStart) : lines;
17749
+ const normalized = relevantLines.filter((line) => !line.startsWith("index ")).join("\n").replace(/\n-- \n[\d]+\.[\d]+[^\n]*\s*$/, "");
17064
17750
  return `sha256:${(0, import_node_crypto3.createHash)("sha256").update(normalized).digest("hex")}`;
17065
17751
  }
17066
17752