@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/index.cjs CHANGED
@@ -99,13 +99,30 @@ var init_GitClient = __esm({
99
99
  const output = await this.exec(["rev-parse", `${commitSha}^@`]);
100
100
  return output.trim().split("\n").filter(Boolean);
101
101
  }
102
+ /**
103
+ * Detects renames and copies between two trees.
104
+ *
105
+ * Both `R` (rename) and `C` (copy) entries are returned as `{from, to}` pairs.
106
+ * Copies are treated as rename-equivalent because the generator may produce the
107
+ * new path while the old path remains in the tree (e.g., imperfect cleanup,
108
+ * customer edits at the old path, or test helpers that only write new files).
109
+ * Callers that care about "is the source still present" should verify via
110
+ * `showFile(toTree, from)`.
111
+ */
102
112
  async detectRenames(fromTree, toTree) {
103
113
  try {
104
- const output = await this.exec(["diff", "--find-renames", "--name-status", fromTree, toTree]);
114
+ const output = await this.exec([
115
+ "diff",
116
+ "--find-renames",
117
+ "--find-copies",
118
+ "--name-status",
119
+ fromTree,
120
+ toTree
121
+ ]);
105
122
  const renames = [];
106
123
  for (const line of output.trim().split("\n")) {
107
124
  if (!line) continue;
108
- if (line.startsWith("R")) {
125
+ if (line.startsWith("R") || line.startsWith("C")) {
109
126
  const parts = line.split(" ");
110
127
  if (parts.length >= 3) {
111
128
  renames.push({ from: parts[1], to: parts[2] });
@@ -403,6 +420,7 @@ var init_HybridReconstruction = __esm({
403
420
  // src/index.ts
404
421
  var index_exports = {};
405
422
  __export(index_exports, {
423
+ CREDENTIAL_PATTERNS: () => CREDENTIAL_PATTERNS,
406
424
  FERN_BOT_EMAIL: () => FERN_BOT_EMAIL,
407
425
  FERN_BOT_LOGIN: () => FERN_BOT_LOGIN,
408
426
  FERN_BOT_NAME: () => FERN_BOT_NAME,
@@ -414,19 +432,57 @@ __export(index_exports, {
414
432
  ReplayCommitter: () => ReplayCommitter,
415
433
  ReplayDetector: () => ReplayDetector,
416
434
  ReplayService: () => ReplayService,
435
+ SENSITIVE_FILE_PATTERNS: () => SENSITIVE_FILE_PATTERNS,
417
436
  bootstrap: () => bootstrap,
418
437
  forget: () => forget,
419
438
  isGenerationCommit: () => isGenerationCommit,
420
439
  isReplayCommit: () => isReplayCommit,
421
440
  isRevertCommit: () => isRevertCommit,
441
+ isSensitiveFile: () => isSensitiveFile,
422
442
  parseRevertedMessage: () => parseRevertedMessage,
423
443
  parseRevertedSha: () => parseRevertedSha,
424
444
  reset: () => reset,
425
445
  resolve: () => resolve,
446
+ scanForCredentials: () => scanForCredentials,
426
447
  status: () => status,
427
448
  threeWayMerge: () => threeWayMerge
428
449
  });
429
450
  module.exports = __toCommonJS(index_exports);
451
+
452
+ // src/credentials.ts
453
+ var CREDENTIAL_PATTERNS = [
454
+ { name: "PRIVATE KEY block", re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
455
+ { name: "OPENSSH key", re: /-----BEGIN OPENSSH PRIVATE KEY-----/ },
456
+ { name: "RSA key", re: /-----BEGIN RSA PRIVATE KEY-----/ },
457
+ { name: "CERTIFICATE", re: /-----BEGIN CERTIFICATE-----/ },
458
+ { name: "TOKEN=", re: /\bTOKEN\s*=\s*["']?[A-Za-z0-9._\-+/=]{10,}/ },
459
+ { name: "api_key=", re: /\bapi[_-]?key\s*=\s*["']?[A-Za-z0-9._\-+/=]{10,}/i },
460
+ { name: "password=", re: /\bpassword\s*=\s*["']?[^\s"']{6,}/i },
461
+ { name: "AWS_SECRET_ACCESS_KEY", re: /AWS_SECRET_ACCESS_KEY/ },
462
+ { name: "JWT (eyJ...)", re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/ }
463
+ ];
464
+ var SENSITIVE_FILE_PATTERNS = [
465
+ /(^|\/)\.env(\.[^/]+)?$/,
466
+ /\.pem$/,
467
+ /\.key$/,
468
+ /\.pkce_state\.json$/,
469
+ /(^|\/)id_rsa$/,
470
+ /(^|\/)id_ed25519$/
471
+ ];
472
+ function isSensitiveFile(filePath) {
473
+ return SENSITIVE_FILE_PATTERNS.some((re) => re.test(filePath));
474
+ }
475
+ function scanForCredentials(content) {
476
+ const matches = [];
477
+ for (const { name, re } of CREDENTIAL_PATTERNS) {
478
+ if (re.test(content)) {
479
+ matches.push(name);
480
+ }
481
+ }
482
+ return matches;
483
+ }
484
+
485
+ // src/index.ts
430
486
  init_GitClient();
431
487
 
432
488
  // src/git/CommitDetection.ts
@@ -437,7 +493,7 @@ var FERN_SUPPORT_NAMES = ["fern-support", "Fern Support"];
437
493
  function isGenerationCommit(commit) {
438
494
  const isFernSupport = FERN_SUPPORT_NAMES.includes(commit.authorName);
439
495
  const isBotAuthor = !isFernSupport && (commit.authorLogin === FERN_BOT_LOGIN || commit.authorEmail === FERN_BOT_EMAIL || commit.authorName === FERN_BOT_NAME);
440
- 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.
496
+ 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.
441
497
  // The default PR title is "SDK Generation" (from GithubStep's commitMessage default).
442
498
  // GitHub appends "(#N)" for the PR number, e.g. "SDK Generation (#70)".
443
499
  commit.message.startsWith("SDK Generation");
@@ -472,6 +528,7 @@ var LockfileNotFoundError = class extends Error {
472
528
  var LockfileManager = class {
473
529
  outputDir;
474
530
  lock = null;
531
+ warnings = [];
475
532
  constructor(outputDir) {
476
533
  this.outputDir = outputDir;
477
534
  }
@@ -520,6 +577,27 @@ var LockfileManager = class {
520
577
  if (!this.lock) {
521
578
  throw new Error("No lockfile data to save. Call read() or initialize() first.");
522
579
  }
580
+ this.warnings.length = 0;
581
+ const cleanPatches = [];
582
+ for (const patch of this.lock.patches) {
583
+ const credentialMatches = scanForCredentials(patch.patch_content);
584
+ const sensitiveFiles = patch.files.filter((f) => isSensitiveFile(f));
585
+ if (credentialMatches.length > 0 || sensitiveFiles.length > 0) {
586
+ const reasons = [];
587
+ if (credentialMatches.length > 0) {
588
+ reasons.push(`credential patterns: ${credentialMatches.join(", ")}`);
589
+ }
590
+ if (sensitiveFiles.length > 0) {
591
+ reasons.push(`sensitive files: ${sensitiveFiles.join(", ")}`);
592
+ }
593
+ this.warnings.push(
594
+ `Patch ${patch.id} removed from lockfile: ${reasons.join("; ")}. This prevents credential exposure in committed lockfiles.`
595
+ );
596
+ } else {
597
+ cleanPatches.push(patch);
598
+ }
599
+ }
600
+ this.lock.patches = cleanPatches;
523
601
  const dir = (0, import_node_path.dirname)(this.lockfilePath);
524
602
  if (!(0, import_node_fs.existsSync)(dir)) {
525
603
  (0, import_node_fs.mkdirSync)(dir, { recursive: true });
@@ -624,6 +702,37 @@ var LockfileManager = class {
624
702
  // src/ReplayDetector.ts
625
703
  var import_node_crypto = require("crypto");
626
704
  var INFRASTRUCTURE_FILES = /* @__PURE__ */ new Set([".fernignore"]);
705
+ function parsePatchFileHeaders(patchContent) {
706
+ const results = [];
707
+ let currentPath = null;
708
+ let currentIsCreate = false;
709
+ let currentIsDelete = false;
710
+ const flush = () => {
711
+ if (currentPath !== null) {
712
+ results.push({ path: currentPath, isCreate: currentIsCreate, isDelete: currentIsDelete });
713
+ }
714
+ };
715
+ for (const line of patchContent.split("\n")) {
716
+ if (line.startsWith("diff --git ")) {
717
+ flush();
718
+ currentPath = null;
719
+ currentIsCreate = false;
720
+ currentIsDelete = false;
721
+ const match = line.match(/^diff --git a\/(.+?) b\/(.+?)$/);
722
+ if (match) {
723
+ currentPath = match[2] ?? null;
724
+ }
725
+ } else if (currentPath !== null) {
726
+ if (line.startsWith("new file mode ")) {
727
+ currentIsCreate = true;
728
+ } else if (line.startsWith("deleted file mode ")) {
729
+ currentIsDelete = true;
730
+ }
731
+ }
732
+ }
733
+ flush();
734
+ return results;
735
+ }
627
736
  var ReplayDetector = class {
628
737
  git;
629
738
  lockManager;
@@ -702,12 +811,31 @@ var ReplayDetector = class {
702
811
  );
703
812
  continue;
704
813
  }
705
- const contentHash = this.computeContentHash(patchContent);
814
+ let contentHash = this.computeContentHash(patchContent);
706
815
  if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
707
816
  continue;
708
817
  }
709
818
  const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
710
- const files = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f));
819
+ const allFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
820
+ const sensitiveFiles = allFiles.filter((f) => isSensitiveFile(f));
821
+ const files = allFiles.filter((f) => !isSensitiveFile(f));
822
+ if (sensitiveFiles.length > 0) {
823
+ this.warnings.push(
824
+ `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
825
+ );
826
+ if (files.length > 0) {
827
+ const parentSha = parents[0];
828
+ if (parentSha) {
829
+ try {
830
+ patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
831
+ } catch {
832
+ continue;
833
+ }
834
+ if (!patchContent.trim()) continue;
835
+ contentHash = this.computeContentHash(patchContent);
836
+ }
837
+ }
838
+ }
711
839
  if (files.length === 0) {
712
840
  continue;
713
841
  }
@@ -719,14 +847,16 @@ var ReplayDetector = class {
719
847
  original_author: `${commit.authorName} <${commit.authorEmail}>`,
720
848
  base_generation: lastGen.commit_sha,
721
849
  files,
722
- patch_content: patchContent
850
+ patch_content: patchContent,
851
+ ...this.isCreationOnlyPatch(patchContent) ? { user_owned: true } : {}
723
852
  });
724
853
  }
725
- newPatches.reverse();
854
+ const survivingPatches = await this.collapseNetZeroFiles(newPatches);
855
+ survivingPatches.reverse();
726
856
  const revertedPatchIdSet = /* @__PURE__ */ new Set();
727
857
  const revertIndicesToRemove = /* @__PURE__ */ new Set();
728
- for (let i = 0; i < newPatches.length; i++) {
729
- const patch = newPatches[i];
858
+ for (let i = 0; i < survivingPatches.length; i++) {
859
+ const patch = survivingPatches[i];
730
860
  if (!isRevertCommit(patch.original_message)) continue;
731
861
  let body = "";
732
862
  try {
@@ -755,7 +885,7 @@ var ReplayDetector = class {
755
885
  if (matchedExisting) continue;
756
886
  let matchedNew = false;
757
887
  if (revertedSha) {
758
- const idx = newPatches.findIndex(
888
+ const idx = survivingPatches.findIndex(
759
889
  (p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_commit === revertedSha
760
890
  );
761
891
  if (idx !== -1) {
@@ -765,7 +895,7 @@ var ReplayDetector = class {
765
895
  }
766
896
  }
767
897
  if (!matchedNew && revertedMessage) {
768
- const idx = newPatches.findIndex(
898
+ const idx = survivingPatches.findIndex(
769
899
  (p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_message === revertedMessage
770
900
  );
771
901
  if (idx !== -1) {
@@ -777,18 +907,107 @@ var ReplayDetector = class {
777
907
  revertIndicesToRemove.add(i);
778
908
  }
779
909
  }
780
- const filteredPatches = newPatches.filter((_, i) => !revertIndicesToRemove.has(i));
910
+ const filteredPatches = survivingPatches.filter((_, i) => !revertIndicesToRemove.has(i));
781
911
  return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
782
912
  }
783
913
  /**
784
914
  * Compute content hash for deduplication.
785
- * Removes commit SHA line and index lines before hashing,
786
- * so rebased commits with same content produce the same hash.
915
+ *
916
+ * Produces a format-agnostic hash so both `git format-patch` output
917
+ * (email-wrapped) and plain `git diff` output hash to the same value
918
+ * when the underlying diff hunks are identical.
919
+ *
920
+ * Normalization:
921
+ * 1. Strip email wrapper: everything before the first `diff --git` line
922
+ * (From:, Subject:, Date:, diffstat, blank separators).
923
+ * 2. Strip `index` lines (blob SHA pairs change across rebases).
924
+ * 3. Strip trailing git version marker (`-- \n<version>`).
925
+ *
926
+ * This ensures content hashes match across the no-patches and normal-
927
+ * regeneration flows, which store formatPatch and git-diff formats
928
+ * respectively (FER-9850, D5-D9).
787
929
  */
788
930
  computeContentHash(patchContent) {
789
- const normalized = patchContent.split("\n").filter((line) => !line.startsWith("From ") && !line.startsWith("index ") && !line.startsWith("Date: ")).join("\n");
931
+ const lines = patchContent.split("\n");
932
+ const diffStart = lines.findIndex((l) => l.startsWith("diff --git "));
933
+ const relevantLines = diffStart > 0 ? lines.slice(diffStart) : lines;
934
+ const normalized = relevantLines.filter((line) => !line.startsWith("index ")).join("\n").replace(/\n-- \n[\d]+\.[\d]+[^\n]*\s*$/, "");
790
935
  return `sha256:${(0, import_node_crypto.createHash)("sha256").update(normalized).digest("hex")}`;
791
936
  }
937
+ /**
938
+ * FER-9805 — Drop patches whose files were transiently created and then
939
+ * deleted within the current linear detection window, and are absent from
940
+ * HEAD at the end of the window.
941
+ *
942
+ * Rationale: on a merge commit, createMergeCompositePatch already diffs
943
+ * firstParent..mergeCommit so net-zero files never enter the composite. On
944
+ * linear history each commit becomes its own patch, so a file that was
945
+ * briefly added and later removed survives as a create+delete pair whose
946
+ * cumulative diff is empty. We drop such patches before they reach the
947
+ * lockfile.
948
+ *
949
+ * A file is "transient" when:
950
+ * 1. some patch in the window sets `new file mode` for it (a creation), AND
951
+ * 2. some patch in the window sets `deleted file mode` for it (a deletion), AND
952
+ * 3. it is absent from HEAD's tree.
953
+ *
954
+ * The HEAD guard (#3) prevents false positives when a file is deleted
955
+ * and then recreated with different content — the cumulative diff of
956
+ * such a pair is non-empty and must be preserved.
957
+ *
958
+ * This only drops patches whose `files` are a subset of the transient
959
+ * set. A partial-transient patch (one that touches a transient file
960
+ * alongside a persistent one) is left unmodified; surgical stripping of
961
+ * single files from a multi-file patch would require a follow-up that
962
+ * regenerates the patch content via `git format-patch -- <files>`. No
963
+ * current failing test exercises this, and leaving the patch intact
964
+ * preserves today's behaviour. TODO(FER-9805): revisit if a future
965
+ * adversarial test demands per-file stripping.
966
+ */
967
+ async collapseNetZeroFiles(patches) {
968
+ if (patches.length === 0) return patches;
969
+ const stateByFile = /* @__PURE__ */ new Map();
970
+ for (const patch of patches) {
971
+ for (const header of parsePatchFileHeaders(patch.patch_content)) {
972
+ if (!header.isCreate && !header.isDelete) continue;
973
+ const prior = stateByFile.get(header.path) ?? { created: false, deleted: false };
974
+ if (header.isCreate) prior.created = true;
975
+ if (header.isDelete) prior.deleted = true;
976
+ stateByFile.set(header.path, prior);
977
+ }
978
+ }
979
+ let anyCandidate = false;
980
+ for (const state of stateByFile.values()) {
981
+ if (state.created && state.deleted) {
982
+ anyCandidate = true;
983
+ break;
984
+ }
985
+ }
986
+ if (!anyCandidate) return patches;
987
+ const headFiles = await this.listHeadFiles();
988
+ const transient = /* @__PURE__ */ new Set();
989
+ for (const [file, state] of stateByFile) {
990
+ if (state.created && state.deleted && !headFiles.has(file)) {
991
+ transient.add(file);
992
+ }
993
+ }
994
+ if (transient.size === 0) return patches;
995
+ return patches.filter((patch) => !patch.files.every((f) => transient.has(f)));
996
+ }
997
+ /**
998
+ * List every tracked path at HEAD (one `git ls-tree -r` invocation).
999
+ * Returns an empty Set if HEAD is unborn or the invocation fails — the
1000
+ * caller then treats every candidate as "not in HEAD" and may collapse
1001
+ * more aggressively. On typical SDK repos this is a single sub-10ms call.
1002
+ */
1003
+ async listHeadFiles() {
1004
+ try {
1005
+ const out = await this.git.exec(["ls-tree", "-r", "--name-only", "HEAD"]);
1006
+ return new Set(out.split("\n").map((l) => l.trim()).filter(Boolean));
1007
+ } catch {
1008
+ return /* @__PURE__ */ new Set();
1009
+ }
1010
+ }
792
1011
  /**
793
1012
  * Create a single composite patch for a merge commit by diffing the merge
794
1013
  * commit against its first parent. This captures the net change of the
@@ -804,7 +1023,14 @@ var ReplayDetector = class {
804
1023
  firstParent,
805
1024
  mergeCommit.sha
806
1025
  ]);
807
- const files = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
1026
+ const allMergeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
1027
+ const sensitiveMergeFiles = allMergeFiles.filter((f) => isSensitiveFile(f));
1028
+ const files = allMergeFiles.filter((f) => !isSensitiveFile(f));
1029
+ if (sensitiveMergeFiles.length > 0) {
1030
+ this.warnings.push(
1031
+ `Sensitive file(s) excluded from merge patch for commit ${mergeCommit.sha.slice(0, 7)}: ${sensitiveMergeFiles.join(", ")}. These files will not be tracked by replay.`
1032
+ );
1033
+ }
808
1034
  if (files.length === 0) return null;
809
1035
  const diff = await this.git.exec([
810
1036
  "diff",
@@ -826,7 +1052,8 @@ var ReplayDetector = class {
826
1052
  original_author: `${mergeCommit.authorName} <${mergeCommit.authorEmail}>`,
827
1053
  base_generation: lastGen.commit_sha,
828
1054
  files,
829
- patch_content: diff
1055
+ patch_content: diff,
1056
+ ...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
830
1057
  };
831
1058
  }
832
1059
  /**
@@ -840,7 +1067,14 @@ var ReplayDetector = class {
840
1067
  return this.detectPatchesViaCommitScan();
841
1068
  }
842
1069
  const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
843
- const files = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
1070
+ const allTreeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
1071
+ const sensitiveTreeFiles = allTreeFiles.filter((f) => isSensitiveFile(f));
1072
+ const files = allTreeFiles.filter((f) => !isSensitiveFile(f));
1073
+ if (sensitiveTreeFiles.length > 0) {
1074
+ this.warnings.push(
1075
+ `Sensitive file(s) excluded from composite patch: ${sensitiveTreeFiles.join(", ")}. These files will not be tracked by replay.`
1076
+ );
1077
+ }
844
1078
  if (files.length === 0) return { patches: [], revertedPatchIds: [] };
845
1079
  const diff = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
846
1080
  if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
@@ -860,7 +1094,8 @@ var ReplayDetector = class {
860
1094
  // reference to find base file content. diffBase may be the tree_hash.
861
1095
  base_generation: commitKnownMissing ? diffBase : lastGen.commit_sha,
862
1096
  files,
863
- patch_content: diff
1097
+ patch_content: diff,
1098
+ ...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
864
1099
  };
865
1100
  return { patches: [compositePatch], revertedPatchIds: [] };
866
1101
  }
@@ -907,7 +1142,7 @@ var ReplayDetector = class {
907
1142
  } catch {
908
1143
  continue;
909
1144
  }
910
- const contentHash = this.computeContentHash(patchContent);
1145
+ let contentHash = this.computeContentHash(patchContent);
911
1146
  if (existingHashes.has(contentHash) || forgottenHashes.has(contentHash)) {
912
1147
  continue;
913
1148
  }
@@ -915,7 +1150,26 @@ var ReplayDetector = class {
915
1150
  continue;
916
1151
  }
917
1152
  const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
918
- const files = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f));
1153
+ const allScanFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f));
1154
+ const sensitiveScanFiles = allScanFiles.filter((f) => isSensitiveFile(f));
1155
+ const files = allScanFiles.filter((f) => !isSensitiveFile(f));
1156
+ if (sensitiveScanFiles.length > 0) {
1157
+ this.warnings.push(
1158
+ `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveScanFiles.join(", ")}. These files will not be tracked by replay.`
1159
+ );
1160
+ if (files.length > 0) {
1161
+ if (parents.length === 0) {
1162
+ continue;
1163
+ }
1164
+ try {
1165
+ patchContent = await this.git.exec(["diff", parents[0], commit.sha, "--", ...files]);
1166
+ } catch {
1167
+ continue;
1168
+ }
1169
+ if (!patchContent.trim()) continue;
1170
+ contentHash = this.computeContentHash(patchContent);
1171
+ }
1172
+ }
919
1173
  if (files.length === 0) {
920
1174
  continue;
921
1175
  }
@@ -1247,6 +1501,20 @@ var ReplayApplicator = class {
1247
1501
  resetAccumulator() {
1248
1502
  this.fileTheirsAccumulator.clear();
1249
1503
  }
1504
+ /**
1505
+ * @internal Test-only.
1506
+ * Returns a defensive copy of the inter-patch accumulator. Used by the
1507
+ * adversarial test suite (FER-9791) to assert invariant I2: after every
1508
+ * applied patch, the accumulator has an entry for each file the patch
1509
+ * touched, and the entry's content matches on-disk.
1510
+ */
1511
+ getAccumulatorSnapshot() {
1512
+ const snapshot = /* @__PURE__ */ new Map();
1513
+ for (const [path, entry] of this.fileTheirsAccumulator) {
1514
+ snapshot.set(path, { content: entry.content, baseGeneration: entry.baseGeneration });
1515
+ }
1516
+ return snapshot;
1517
+ }
1250
1518
  /**
1251
1519
  * Apply all patches, returning results for each.
1252
1520
  * Skips patches that match exclude patterns in replay.yml
@@ -1312,7 +1580,7 @@ var ReplayApplicator = class {
1312
1580
  const base = await this.git.showFile(baseGen.tree_hash, filePath);
1313
1581
  const theirs = await this.applyPatchToContent(base, patch.patch_content, filePath, tempGit, tempDir);
1314
1582
  const effectiveTheirs = theirs ?? await (0, import_promises.readFile)((0, import_node_path2.join)(this.outputDir, resolvedPath), "utf-8").catch(() => null);
1315
- if (effectiveTheirs) {
1583
+ if (effectiveTheirs != null) {
1316
1584
  this.fileTheirsAccumulator.set(resolvedPath, {
1317
1585
  content: effectiveTheirs,
1318
1586
  baseGeneration: patch.base_generation
@@ -1336,16 +1604,20 @@ var ReplayApplicator = class {
1336
1604
  return this.fileTheirsAccumulator.has(resolved);
1337
1605
  })
1338
1606
  ).then((results) => results.some(Boolean));
1339
- if (!needsAccumulation) {
1607
+ const resolvedFiles = {};
1608
+ for (const filePath of patch.files) {
1609
+ const resolvedPath = baseGen ? await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash) : filePath;
1610
+ if (resolvedPath !== filePath) {
1611
+ resolvedFiles[filePath] = resolvedPath;
1612
+ }
1613
+ }
1614
+ const patchIsRenameAware = /^rename from /m.test(patch.patch_content);
1615
+ const hasGeneratorRename = Object.keys(resolvedFiles).length > 0 && !patchIsRenameAware;
1616
+ if (!needsAccumulation && !hasGeneratorRename) {
1340
1617
  const snapshots = /* @__PURE__ */ new Map();
1341
- const resolvedFiles = {};
1342
1618
  for (const filePath of patch.files) {
1343
- const resolvedPath = baseGen ? await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash) : filePath;
1344
- if (resolvedPath !== filePath) {
1345
- resolvedFiles[filePath] = resolvedPath;
1346
- }
1347
- const fullPath = (0, import_node_path2.join)(this.outputDir, resolvedPath);
1348
- snapshots.set(resolvedPath, await (0, import_promises.readFile)(fullPath, "utf-8").catch(() => null));
1619
+ const fullPath = (0, import_node_path2.join)(this.outputDir, filePath);
1620
+ snapshots.set(filePath, await (0, import_promises.readFile)(fullPath, "utf-8").catch(() => null));
1349
1621
  }
1350
1622
  try {
1351
1623
  await this.git.execWithInput(["apply", "--3way"], patch.patch_content);
@@ -1357,8 +1629,8 @@ var ReplayApplicator = class {
1357
1629
  ...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
1358
1630
  };
1359
1631
  } catch {
1360
- for (const [resolvedPath, content] of snapshots) {
1361
- const fullPath = (0, import_node_path2.join)(this.outputDir, resolvedPath);
1632
+ for (const [filePath, content] of snapshots) {
1633
+ const fullPath = (0, import_node_path2.join)(this.outputDir, filePath);
1362
1634
  if (content != null) {
1363
1635
  await (0, import_promises.writeFile)(fullPath, content);
1364
1636
  } else {
@@ -1478,7 +1750,7 @@ var ReplayApplicator = class {
1478
1750
  }
1479
1751
  let useAccumulatorAsMergeBase = false;
1480
1752
  const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
1481
- if (accumulatorEntry && (!theirs || base == null)) {
1753
+ if (accumulatorEntry && (theirs == null || base == null)) {
1482
1754
  theirs = await this.applyPatchToContent(
1483
1755
  accumulatorEntry.content,
1484
1756
  patch.patch_content,
@@ -1486,7 +1758,16 @@ var ReplayApplicator = class {
1486
1758
  tempGit,
1487
1759
  tempDir
1488
1760
  );
1489
- if (theirs) {
1761
+ if (theirs != null) {
1762
+ useAccumulatorAsMergeBase = true;
1763
+ } else if (await this.isPatchAlreadyApplied(
1764
+ patch.patch_content,
1765
+ filePath,
1766
+ accumulatorEntry.content,
1767
+ tempGit,
1768
+ tempDir
1769
+ )) {
1770
+ theirs = accumulatorEntry.content;
1490
1771
  useAccumulatorAsMergeBase = true;
1491
1772
  }
1492
1773
  }
@@ -1503,7 +1784,7 @@ var ReplayApplicator = class {
1503
1784
  }
1504
1785
  let effective_theirs = theirs;
1505
1786
  let baseMismatchSkipped = false;
1506
- if (theirs && base && !useAccumulatorAsMergeBase) {
1787
+ if (theirs != null && base != null && !useAccumulatorAsMergeBase) {
1507
1788
  if (accumulatorEntry && accumulatorEntry.baseGeneration === patch.base_generation) {
1508
1789
  try {
1509
1790
  const preMerged = threeWayMerge(base, accumulatorEntry.content, theirs);
@@ -1519,7 +1800,7 @@ var ReplayApplicator = class {
1519
1800
  baseMismatchSkipped = true;
1520
1801
  }
1521
1802
  }
1522
- if (base == null && !ours && effective_theirs) {
1803
+ if (base == null && ours == null && effective_theirs != null) {
1523
1804
  this.fileTheirsAccumulator.set(resolvedPath, {
1524
1805
  content: effective_theirs,
1525
1806
  baseGeneration: patch.base_generation
@@ -1529,7 +1810,7 @@ var ReplayApplicator = class {
1529
1810
  await (0, import_promises.writeFile)(oursPath, effective_theirs);
1530
1811
  return { file: resolvedPath, status: "merged", reason: "new-file" };
1531
1812
  }
1532
- if (base == null && ours && effective_theirs && !useAccumulatorAsMergeBase) {
1813
+ if (base == null && ours != null && effective_theirs != null && !useAccumulatorAsMergeBase) {
1533
1814
  const merged2 = threeWayMerge("", ours, effective_theirs);
1534
1815
  const outDir2 = (0, import_node_path2.dirname)(oursPath);
1535
1816
  await (0, import_promises.mkdir)(outDir2, { recursive: true });
@@ -1543,16 +1824,20 @@ var ReplayApplicator = class {
1543
1824
  conflictMetadata: metadata
1544
1825
  };
1545
1826
  }
1827
+ this.fileTheirsAccumulator.set(resolvedPath, {
1828
+ content: merged2.content,
1829
+ baseGeneration: patch.base_generation
1830
+ });
1546
1831
  return { file: resolvedPath, status: "merged" };
1547
1832
  }
1548
- if (!effective_theirs) {
1833
+ if (effective_theirs == null) {
1549
1834
  return {
1550
1835
  file: resolvedPath,
1551
1836
  status: "skipped",
1552
1837
  reason: "missing-content"
1553
1838
  };
1554
1839
  }
1555
- if (base == null && !useAccumulatorAsMergeBase || !ours) {
1840
+ if (base == null && !useAccumulatorAsMergeBase || ours == null) {
1556
1841
  return {
1557
1842
  file: resolvedPath,
1558
1843
  status: "skipped",
@@ -1571,7 +1856,7 @@ var ReplayApplicator = class {
1571
1856
  const outDir = (0, import_node_path2.dirname)(oursPath);
1572
1857
  await (0, import_promises.mkdir)(outDir, { recursive: true });
1573
1858
  await (0, import_promises.writeFile)(oursPath, merged.content);
1574
- if (effective_theirs && !merged.hasConflicts) {
1859
+ if (effective_theirs != null && !merged.hasConflicts) {
1575
1860
  this.fileTheirsAccumulator.set(resolvedPath, {
1576
1861
  content: effective_theirs,
1577
1862
  baseGeneration: patch.base_generation
@@ -1669,6 +1954,28 @@ var ReplayApplicator = class {
1669
1954
  return null;
1670
1955
  }
1671
1956
  }
1957
+ /**
1958
+ * Detects whether a patch's additions are already present in `content`.
1959
+ * Uses `git apply --reverse --check` — if the reverse patch applies cleanly,
1960
+ * the forward patch is effectively a no-op (its +lines are already there and
1961
+ * its -lines are already gone). Used to distinguish "patch already applied"
1962
+ * from "patch base mismatch" after a forward-apply fails.
1963
+ */
1964
+ async isPatchAlreadyApplied(patchContent, filePath, content, tempGit, tempDir) {
1965
+ const fileDiff = this.extractFileDiff(patchContent, filePath);
1966
+ if (!fileDiff) return false;
1967
+ try {
1968
+ const tempFilePath = (0, import_node_path2.join)(tempDir, filePath);
1969
+ await (0, import_promises.mkdir)((0, import_node_path2.dirname)(tempFilePath), { recursive: true });
1970
+ await (0, import_promises.writeFile)(tempFilePath, content);
1971
+ await tempGit.exec(["add", filePath]);
1972
+ await tempGit.exec(["commit", "-m", `check already-applied ${filePath}`, "--allow-empty"]);
1973
+ await tempGit.execWithInput(["apply", "--reverse", "--check", "--allow-empty"], fileDiff);
1974
+ return true;
1975
+ } catch {
1976
+ return false;
1977
+ }
1978
+ }
1672
1979
  extractFileDiff(patchContent, filePath) {
1673
1980
  const lines = patchContent.split("\n");
1674
1981
  const diffLines = [];
@@ -1837,6 +2144,33 @@ var ReplayService = class {
1837
2144
  committer;
1838
2145
  lockManager;
1839
2146
  outputDir;
2147
+ /** @internal Test-only. Captures the last ReplayResult[] returned from applicator.applyPatches(). */
2148
+ _lastApplyResults = [];
2149
+ /**
2150
+ * Service-level warnings accumulated during a single runReplay() call.
2151
+ * Merged with `detector.warnings` into `ReplayReport.warnings` by each flow handler.
2152
+ * Populated by `isFileUserOwned` when a patch's base generation tree is unreachable
2153
+ * (shallow clone / aggressive `git gc --prune`) and we fall back to a conservative
2154
+ * heuristic. Cleared at the top of `runReplay()`.
2155
+ */
2156
+ warnings = [];
2157
+ /**
2158
+ * @internal Test-only accessor.
2159
+ * Returns the ReplayApplicator instance used for the last run. Tests use this
2160
+ * to inspect the inter-patch accumulator after runReplay() completes (FER-9791, I2).
2161
+ */
2162
+ get applicatorRef() {
2163
+ return this.applicator;
2164
+ }
2165
+ /**
2166
+ * @internal Test-only accessor.
2167
+ * Returns the ReplayResult[] from the most recent applyPatches() call. Tests use
2168
+ * this to filter applied-vs-conflicted-vs-absorbed patches for invariant assertions
2169
+ * (FER-9791). Returns an empty array if no patches have been applied yet.
2170
+ */
2171
+ getLastResults() {
2172
+ return this._lastApplyResults;
2173
+ }
1840
2174
  constructor(outputDir, _config) {
1841
2175
  const git = new GitClient(outputDir);
1842
2176
  this.git = git;
@@ -1846,20 +2180,66 @@ var ReplayService = class {
1846
2180
  this.applicator = new ReplayApplicator(git, this.lockManager, outputDir);
1847
2181
  this.committer = new ReplayCommitter(git, outputDir);
1848
2182
  }
1849
- async runReplay(options) {
2183
+ /**
2184
+ * Phase 1 of the two-phase replay flow. Runs preGenerationRebase (when applicable),
2185
+ * detects new patches, and creates the `[fern-generated]` commit. Leaves HEAD at
2186
+ * the generation commit (or unchanged for dry-run).
2187
+ *
2188
+ * Does NOT apply patches — the returned handle must be passed to
2189
+ * `applyPreparedReplay()` to complete the run. No disk writes to the lockfile
2190
+ * happen here; lockfile persistence is deferred to phase 2.
2191
+ *
2192
+ * Callers may land additional commits on HEAD between `prepareReplay` and
2193
+ * `applyPreparedReplay` (e.g., `[fern-autoversion]`).
2194
+ */
2195
+ async prepareReplay(options) {
2196
+ this.warnings = [];
2197
+ this.detector.warnings.length = 0;
1850
2198
  if (options?.skipApplication) {
1851
- return this.handleSkipApplication(options);
2199
+ return this._prepareSkipApplication(options);
1852
2200
  }
1853
2201
  const flow = this.determineFlow();
1854
2202
  switch (flow) {
1855
2203
  case "first-generation":
1856
- return this.handleFirstGeneration(options);
2204
+ return this._prepareFirstGeneration(options);
2205
+ case "no-patches":
2206
+ return this._prepareNoPatchesRegeneration(options);
2207
+ case "normal-regeneration":
2208
+ return this._prepareNormalRegeneration(options);
2209
+ }
2210
+ }
2211
+ /**
2212
+ * Phase 2 of the two-phase replay flow. Applies patches, rebases them against
2213
+ * the generation, saves the lockfile, and commits `[fern-replay]` (or stages
2214
+ * under `stageOnly`). Operates on HEAD at call time — mid-phase commits are
2215
+ * respected.
2216
+ *
2217
+ * For terminal handles (dry-run, first-gen has no patch work, skip-app does
2218
+ * its own post-commit logic), this returns the precomputed report.
2219
+ */
2220
+ async applyPreparedReplay(prep, options) {
2221
+ if (prep._prepared.terminal) {
2222
+ return prep._prepared.preparedReport;
2223
+ }
2224
+ switch (prep._prepared.flow) {
2225
+ case "first-generation":
2226
+ return this._applyFirstGeneration(prep);
2227
+ case "skip-application":
2228
+ return this._applySkipApplication(prep, options);
1857
2229
  case "no-patches":
1858
- return this.handleNoPatchesRegeneration(options);
2230
+ return this._applyNoPatchesRegeneration(prep, options);
1859
2231
  case "normal-regeneration":
1860
- return this.handleNormalRegeneration(options);
2232
+ return this._applyNormalRegeneration(prep, options);
1861
2233
  }
1862
2234
  }
2235
+ /**
2236
+ * Backwards-compatible atomic entry point. Composes `prepareReplay` + `applyPreparedReplay`.
2237
+ * Behavior is byte-identical to the pre-split implementation for all existing callers.
2238
+ */
2239
+ async runReplay(options) {
2240
+ const prep = await this.prepareReplay(options);
2241
+ return this.applyPreparedReplay(prep, options);
2242
+ }
1863
2243
  /**
1864
2244
  * Sync the lockfile after a divergent PR was squash-merged.
1865
2245
  * Call this BEFORE runReplay() when the CLI detects a merged divergent PR.
@@ -1920,6 +2300,7 @@ var ReplayService = class {
1920
2300
  );
1921
2301
  }
1922
2302
  this.lockManager.save();
2303
+ this.detector.warnings.push(...this.lockManager.warnings);
1923
2304
  }
1924
2305
  determineFlow() {
1925
2306
  try {
@@ -1932,15 +2313,35 @@ var ReplayService = class {
1932
2313
  throw error;
1933
2314
  }
1934
2315
  }
1935
- async handleFirstGeneration(options) {
2316
+ firstGenerationReport() {
2317
+ return {
2318
+ flow: "first-generation",
2319
+ patchesDetected: 0,
2320
+ patchesApplied: 0,
2321
+ patchesWithConflicts: 0,
2322
+ patchesSkipped: 0,
2323
+ conflicts: []
2324
+ };
2325
+ }
2326
+ async _prepareFirstGeneration(options) {
1936
2327
  if (options?.dryRun) {
2328
+ const headSha = await this.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
1937
2329
  return {
1938
2330
  flow: "first-generation",
1939
- patchesDetected: 0,
1940
- patchesApplied: 0,
1941
- patchesWithConflicts: 0,
1942
- patchesSkipped: 0,
1943
- conflicts: []
2331
+ previousGenerationSha: null,
2332
+ currentGenerationSha: headSha,
2333
+ baseBranchHead: options.baseBranchHead ?? null,
2334
+ _prepared: {
2335
+ flow: "first-generation",
2336
+ terminal: true,
2337
+ preparedReport: this.firstGenerationReport(),
2338
+ patchesToApply: [],
2339
+ newPatchIds: /* @__PURE__ */ new Set(),
2340
+ detectorWarnings: [],
2341
+ revertedCount: 0,
2342
+ genSha: headSha,
2343
+ optionsSnapshot: options
2344
+ }
1944
2345
  };
1945
2346
  }
1946
2347
  const commitOpts = options ? {
@@ -1950,22 +2351,36 @@ var ReplayService = class {
1950
2351
  } : void 0;
1951
2352
  await this.committer.commitGeneration("Initial SDK generation", commitOpts);
1952
2353
  const genRecord = await this.committer.createGenerationRecord(commitOpts);
1953
- this.lockManager.initialize(genRecord);
2354
+ this.lockManager.initializeInMemory(genRecord);
1954
2355
  return {
1955
2356
  flow: "first-generation",
1956
- patchesDetected: 0,
1957
- patchesApplied: 0,
1958
- patchesWithConflicts: 0,
1959
- patchesSkipped: 0,
1960
- conflicts: []
2357
+ previousGenerationSha: null,
2358
+ currentGenerationSha: genRecord.commit_sha,
2359
+ baseBranchHead: options?.baseBranchHead ?? null,
2360
+ _prepared: {
2361
+ flow: "first-generation",
2362
+ terminal: false,
2363
+ patchesToApply: [],
2364
+ newPatchIds: /* @__PURE__ */ new Set(),
2365
+ detectorWarnings: [],
2366
+ revertedCount: 0,
2367
+ genSha: genRecord.commit_sha,
2368
+ optionsSnapshot: options
2369
+ }
1961
2370
  };
1962
2371
  }
2372
+ async _applyFirstGeneration(_prep) {
2373
+ this.lockManager.save();
2374
+ return this.firstGenerationReport();
2375
+ }
1963
2376
  /**
1964
- * Skip-application mode: commit the generation and update the lockfile
1965
- * but don't detect or apply patches. Sets a marker so the next normal
1966
- * run skips revert detection in preGenerationRebase().
2377
+ * Skip-application mode phase 1: commit the generation and update the
2378
+ * in-memory lockfile, but don't detect or apply patches. Sets a marker
2379
+ * so the next normal run skips revert detection in preGenerationRebase().
2380
+ *
2381
+ * Disk save is deferred to `_applySkipApplication`.
1967
2382
  */
1968
- async handleSkipApplication(options) {
2383
+ async _prepareSkipApplication(options) {
1969
2384
  const commitOpts = options ? {
1970
2385
  cliVersion: options.cliVersion ?? "unknown",
1971
2386
  generatorVersions: options.generatorVersions ?? {},
@@ -1974,8 +2389,10 @@ var ReplayService = class {
1974
2389
  await this.committer.commitGeneration("Update SDK (replay skipped)", commitOpts);
1975
2390
  await this.cleanupStaleConflictMarkers();
1976
2391
  const genRecord = await this.committer.createGenerationRecord(commitOpts);
2392
+ let previousGenerationSha = null;
1977
2393
  try {
1978
- this.lockManager.read();
2394
+ const lock = this.lockManager.read();
2395
+ previousGenerationSha = lock.current_generation ?? null;
1979
2396
  this.lockManager.addGeneration(genRecord);
1980
2397
  } catch (error) {
1981
2398
  if (error instanceof LockfileNotFoundError) {
@@ -1985,7 +2402,26 @@ var ReplayService = class {
1985
2402
  }
1986
2403
  }
1987
2404
  this.lockManager.setReplaySkippedAt((/* @__PURE__ */ new Date()).toISOString());
2405
+ return {
2406
+ flow: "skip-application",
2407
+ previousGenerationSha,
2408
+ currentGenerationSha: genRecord.commit_sha,
2409
+ baseBranchHead: options?.baseBranchHead ?? null,
2410
+ _prepared: {
2411
+ flow: "skip-application",
2412
+ terminal: false,
2413
+ patchesToApply: [],
2414
+ newPatchIds: /* @__PURE__ */ new Set(),
2415
+ detectorWarnings: [],
2416
+ revertedCount: 0,
2417
+ genSha: genRecord.commit_sha,
2418
+ optionsSnapshot: options
2419
+ }
2420
+ };
2421
+ }
2422
+ async _applySkipApplication(_prep, options) {
1988
2423
  this.lockManager.save();
2424
+ const credentialWarnings = [...this.lockManager.warnings];
1989
2425
  if (!options?.stageOnly) {
1990
2426
  await this.committer.commitReplay(0);
1991
2427
  } else {
@@ -1997,14 +2433,18 @@ var ReplayService = class {
1997
2433
  patchesApplied: 0,
1998
2434
  patchesWithConflicts: 0,
1999
2435
  patchesSkipped: 0,
2000
- conflicts: []
2436
+ conflicts: [],
2437
+ warnings: credentialWarnings.length > 0 ? credentialWarnings : void 0
2001
2438
  };
2002
2439
  }
2003
- async handleNoPatchesRegeneration(options) {
2004
- const { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
2005
- const warnings = [...this.detector.warnings];
2440
+ async _prepareNoPatchesRegeneration(options) {
2441
+ const preLock = this.lockManager.read();
2442
+ const previousGenerationSha = preLock.current_generation ?? null;
2443
+ let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
2444
+ const detectorWarnings = [...this.detector.warnings];
2006
2445
  if (options?.dryRun) {
2007
- return {
2446
+ const dryRunWarnings = [...detectorWarnings, ...this.warnings];
2447
+ const preparedReport = {
2008
2448
  flow: "no-patches",
2009
2449
  patchesDetected: newPatches.length,
2010
2450
  patchesApplied: 0,
@@ -2013,9 +2453,43 @@ var ReplayService = class {
2013
2453
  patchesReverted: revertedPatchIds.length,
2014
2454
  conflicts: [],
2015
2455
  wouldApply: newPatches,
2016
- warnings: warnings.length > 0 ? warnings : void 0
2456
+ warnings: dryRunWarnings.length > 0 ? dryRunWarnings : void 0
2457
+ };
2458
+ const headSha = await this.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
2459
+ return {
2460
+ flow: "no-patches",
2461
+ previousGenerationSha,
2462
+ currentGenerationSha: headSha,
2463
+ baseBranchHead: options.baseBranchHead ?? null,
2464
+ _prepared: {
2465
+ flow: "no-patches",
2466
+ terminal: true,
2467
+ preparedReport,
2468
+ patchesToApply: [],
2469
+ newPatchIds: /* @__PURE__ */ new Set(),
2470
+ detectorWarnings,
2471
+ revertedCount: revertedPatchIds.length,
2472
+ genSha: headSha,
2473
+ optionsSnapshot: options
2474
+ }
2017
2475
  };
2018
2476
  }
2477
+ {
2478
+ const preCurrentGen = preLock.current_generation;
2479
+ const uniqueFiles = [...new Set(newPatches.flatMap((p) => p.files))];
2480
+ const absorbedFiles = /* @__PURE__ */ new Set();
2481
+ for (const file of uniqueFiles) {
2482
+ const diff = await this.git.exec(["diff", preCurrentGen, "HEAD", "--", file]).catch(() => null);
2483
+ if (diff !== null && !diff.trim()) {
2484
+ absorbedFiles.add(file);
2485
+ }
2486
+ }
2487
+ if (absorbedFiles.size > 0) {
2488
+ newPatches = newPatches.filter(
2489
+ (p) => p.files.length === 0 || !p.files.every((f) => absorbedFiles.has(f))
2490
+ );
2491
+ }
2492
+ }
2019
2493
  for (const id of revertedPatchIds) {
2020
2494
  try {
2021
2495
  this.lockManager.removePatch(id);
@@ -2031,9 +2505,31 @@ var ReplayService = class {
2031
2505
  await this.cleanupStaleConflictMarkers();
2032
2506
  const genRecord = await this.committer.createGenerationRecord(commitOpts);
2033
2507
  this.lockManager.addGeneration(genRecord);
2508
+ return {
2509
+ flow: "no-patches",
2510
+ previousGenerationSha,
2511
+ currentGenerationSha: genRecord.commit_sha,
2512
+ baseBranchHead: options?.baseBranchHead ?? null,
2513
+ _prepared: {
2514
+ flow: "no-patches",
2515
+ terminal: false,
2516
+ patchesToApply: newPatches,
2517
+ newPatchIds: new Set(newPatches.map((p) => p.id)),
2518
+ detectorWarnings,
2519
+ revertedCount: revertedPatchIds.length,
2520
+ genSha: genRecord.commit_sha,
2521
+ optionsSnapshot: options
2522
+ }
2523
+ };
2524
+ }
2525
+ async _applyNoPatchesRegeneration(prep, options) {
2526
+ const newPatches = prep._prepared.patchesToApply;
2527
+ const detectorWarnings = prep._prepared.detectorWarnings;
2528
+ const genSha = prep._prepared.genSha;
2034
2529
  let results = [];
2035
2530
  if (newPatches.length > 0) {
2036
2531
  results = await this.applicator.applyPatches(newPatches);
2532
+ this._lastApplyResults = results;
2037
2533
  this.revertConflictingFiles(results);
2038
2534
  for (const result of results) {
2039
2535
  if (result.status === "conflict") {
@@ -2041,13 +2537,14 @@ var ReplayService = class {
2041
2537
  }
2042
2538
  }
2043
2539
  }
2044
- const rebaseCounts = await this.rebasePatches(results, genRecord.commit_sha);
2540
+ const rebaseCounts = await this.rebasePatches(results, genSha);
2045
2541
  for (const patch of newPatches) {
2046
2542
  if (!rebaseCounts.absorbedPatchIds.has(patch.id)) {
2047
2543
  this.lockManager.addPatch(patch);
2048
2544
  }
2049
2545
  }
2050
2546
  this.lockManager.save();
2547
+ this.warnings.push(...this.lockManager.warnings);
2051
2548
  if (newPatches.length > 0) {
2052
2549
  if (!options?.stageOnly) {
2053
2550
  const appliedCount = results.filter((r) => r.status === "applied").length;
@@ -2056,15 +2553,27 @@ var ReplayService = class {
2056
2553
  await this.committer.stageAll();
2057
2554
  }
2058
2555
  }
2059
- return this.buildReport("no-patches", newPatches, results, options, warnings, rebaseCounts, void 0, revertedPatchIds.length);
2556
+ const warnings = [...detectorWarnings, ...this.warnings];
2557
+ return this.buildReport(
2558
+ "no-patches",
2559
+ newPatches,
2560
+ results,
2561
+ prep._prepared.optionsSnapshot,
2562
+ warnings,
2563
+ rebaseCounts,
2564
+ void 0,
2565
+ prep._prepared.revertedCount
2566
+ );
2060
2567
  }
2061
- async handleNormalRegeneration(options) {
2568
+ async _prepareNormalRegeneration(options) {
2569
+ const preLock = this.lockManager.read();
2570
+ const previousGenerationSha = preLock.current_generation ?? null;
2062
2571
  if (options?.dryRun) {
2063
2572
  const existingPatches2 = this.lockManager.getPatches();
2064
2573
  const { patches: newPatches2, revertedPatchIds: dryRunReverted } = await this.detector.detectNewPatches();
2065
- const warnings2 = [...this.detector.warnings];
2574
+ const warnings = [...this.detector.warnings, ...this.warnings];
2066
2575
  const allPatches2 = [...existingPatches2, ...newPatches2];
2067
- return {
2576
+ const preparedReport = {
2068
2577
  flow: "normal-regeneration",
2069
2578
  patchesDetected: allPatches2.length,
2070
2579
  patchesApplied: 0,
@@ -2073,7 +2582,25 @@ var ReplayService = class {
2073
2582
  patchesReverted: dryRunReverted.length,
2074
2583
  conflicts: [],
2075
2584
  wouldApply: allPatches2,
2076
- warnings: warnings2.length > 0 ? warnings2 : void 0
2585
+ warnings: warnings.length > 0 ? warnings : void 0
2586
+ };
2587
+ const headSha = await this.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
2588
+ return {
2589
+ flow: "normal-regeneration",
2590
+ previousGenerationSha,
2591
+ currentGenerationSha: headSha,
2592
+ baseBranchHead: options.baseBranchHead ?? null,
2593
+ _prepared: {
2594
+ flow: "normal-regeneration",
2595
+ terminal: true,
2596
+ preparedReport,
2597
+ patchesToApply: [],
2598
+ newPatchIds: /* @__PURE__ */ new Set(),
2599
+ detectorWarnings: [...this.detector.warnings],
2600
+ revertedCount: dryRunReverted.length,
2601
+ genSha: headSha,
2602
+ optionsSnapshot: options
2603
+ }
2077
2604
  };
2078
2605
  }
2079
2606
  let existingPatches = this.lockManager.getPatches();
@@ -2094,7 +2621,7 @@ var ReplayService = class {
2094
2621
  }
2095
2622
  existingPatches = this.lockManager.getPatches();
2096
2623
  let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
2097
- const warnings = [...this.detector.warnings];
2624
+ const detectorWarnings = [...this.detector.warnings];
2098
2625
  if (removedByPreRebase.length > 0) {
2099
2626
  const removedOriginalCommits = new Set(removedByPreRebase.map((p) => p.original_commit));
2100
2627
  const removedOriginalMessages = new Set(removedByPreRebase.map((p) => p.original_message));
@@ -2125,14 +2652,39 @@ var ReplayService = class {
2125
2652
  await this.cleanupStaleConflictMarkers();
2126
2653
  const genRecord = await this.committer.createGenerationRecord(commitOpts);
2127
2654
  this.lockManager.addGeneration(genRecord);
2655
+ return {
2656
+ flow: "normal-regeneration",
2657
+ previousGenerationSha,
2658
+ currentGenerationSha: genRecord.commit_sha,
2659
+ baseBranchHead: options?.baseBranchHead ?? null,
2660
+ _prepared: {
2661
+ flow: "normal-regeneration",
2662
+ terminal: false,
2663
+ patchesToApply: allPatches,
2664
+ newPatchIds: new Set(newPatches.map((p) => p.id)),
2665
+ preRebaseCounts,
2666
+ detectorWarnings,
2667
+ revertedCount: revertedPatchIds.length,
2668
+ genSha: genRecord.commit_sha,
2669
+ optionsSnapshot: options
2670
+ }
2671
+ };
2672
+ }
2673
+ async _applyNormalRegeneration(prep, options) {
2674
+ const allPatches = prep._prepared.patchesToApply;
2675
+ const newPatchIds = prep._prepared.newPatchIds;
2676
+ const detectorWarnings = prep._prepared.detectorWarnings;
2677
+ const genSha = prep._prepared.genSha;
2128
2678
  const results = await this.applicator.applyPatches(allPatches);
2679
+ this._lastApplyResults = results;
2129
2680
  this.revertConflictingFiles(results);
2130
2681
  for (const result of results) {
2131
2682
  if (result.status === "conflict") {
2132
2683
  result.patch.status = "unresolved";
2133
2684
  }
2134
2685
  }
2135
- const rebaseCounts = await this.rebasePatches(results, genRecord.commit_sha);
2686
+ const rebaseCounts = await this.rebasePatches(results, genSha);
2687
+ const newPatches = allPatches.filter((p) => newPatchIds.has(p.id));
2136
2688
  for (const patch of newPatches) {
2137
2689
  if (!rebaseCounts.absorbedPatchIds.has(patch.id)) {
2138
2690
  this.lockManager.addPatch(patch);
@@ -2147,21 +2699,23 @@ var ReplayService = class {
2147
2699
  }
2148
2700
  }
2149
2701
  this.lockManager.save();
2702
+ this.warnings.push(...this.lockManager.warnings);
2150
2703
  if (options?.stageOnly) {
2151
2704
  await this.committer.stageAll();
2152
2705
  } else {
2153
2706
  const appliedCount = results.filter((r) => r.status === "applied").length;
2154
2707
  await this.committer.commitReplay(appliedCount, allPatches);
2155
2708
  }
2709
+ const warnings = [...detectorWarnings, ...this.warnings];
2156
2710
  return this.buildReport(
2157
2711
  "normal-regeneration",
2158
2712
  allPatches,
2159
2713
  results,
2160
- options,
2714
+ prep._prepared.optionsSnapshot,
2161
2715
  warnings,
2162
2716
  rebaseCounts,
2163
- preRebaseCounts,
2164
- revertedPatchIds.length
2717
+ prep._prepared.preRebaseCounts,
2718
+ prep._prepared.revertedCount
2165
2719
  );
2166
2720
  }
2167
2721
  /**
@@ -2176,6 +2730,7 @@ var ReplayService = class {
2176
2730
  let keptAsUserOwned = 0;
2177
2731
  const seenContentHashes = /* @__PURE__ */ new Set();
2178
2732
  const absorbedPatchIds = /* @__PURE__ */ new Set();
2733
+ const fernignorePatterns = this.readFernignorePatterns();
2179
2734
  for (const result of results) {
2180
2735
  if (result.resolvedFiles && Object.keys(result.resolvedFiles).length > 0) {
2181
2736
  const patch = result.patch;
@@ -2196,34 +2751,78 @@ var ReplayService = class {
2196
2751
  const patch = result.patch;
2197
2752
  if (patch.base_generation === currentGenSha) continue;
2198
2753
  try {
2199
- const fernignorePatterns = this.readFernignorePatterns();
2200
- const isUserOwned = await Promise.all(
2754
+ const originalByResolved = {};
2755
+ if (result.resolvedFiles) {
2756
+ for (const [orig, resolved] of Object.entries(result.resolvedFiles)) {
2757
+ originalByResolved[resolved] = orig;
2758
+ }
2759
+ }
2760
+ const isUserOwnedPerFile = await Promise.all(
2201
2761
  patch.files.map(async (file) => {
2202
- if (file === ".fernignore") {
2762
+ if (file === ".fernignore") return true;
2763
+ if (fernignorePatterns.some((p) => file === p || (0, import_minimatch2.minimatch)(file, p))) {
2203
2764
  return true;
2204
2765
  }
2205
- if (fernignorePatterns.some((p) => file === p || (0, import_minimatch2.minimatch)(file, p))) {
2766
+ if (patch.user_owned) return true;
2767
+ if (this.fileIsCreationInPatchContent(patch.patch_content, originalByResolved[file] ?? file)) {
2206
2768
  return true;
2207
2769
  }
2208
2770
  const content = await this.git.showFile(currentGenSha, file);
2209
2771
  return content === null;
2210
2772
  })
2211
2773
  );
2212
- const hasUserOwnedFiles = isUserOwned.some(Boolean);
2213
- if (hasUserOwnedFiles) {
2214
- this.lockManager.updatePatch(patch.id, {
2215
- base_generation: currentGenSha
2216
- });
2774
+ const hasProtectedFiles = patch.files.some(
2775
+ (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || (0, import_minimatch2.minimatch)(file, p))
2776
+ );
2777
+ const allUserOwned = isUserOwnedPerFile.every(Boolean);
2778
+ if (allUserOwned && patch.files.length > 0) {
2779
+ const baseChanged = patch.base_generation !== currentGenSha;
2780
+ const userDiff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
2781
+ if (userDiff != null && userDiff.trim()) {
2782
+ const newHash = this.detector.computeContentHash(userDiff);
2783
+ patch.patch_content = userDiff;
2784
+ patch.content_hash = newHash;
2785
+ }
2786
+ patch.base_generation = currentGenSha;
2787
+ try {
2788
+ this.lockManager.updatePatch(patch.id, {
2789
+ base_generation: currentGenSha,
2790
+ ...!patch.user_owned ? { user_owned: true } : {},
2791
+ ...userDiff != null && userDiff.trim() ? { patch_content: patch.patch_content, content_hash: patch.content_hash } : {}
2792
+ });
2793
+ } catch {
2794
+ }
2795
+ patch.user_owned = true;
2217
2796
  keptAsUserOwned++;
2797
+ if (baseChanged) repointed++;
2218
2798
  continue;
2219
2799
  }
2220
2800
  const diff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
2221
- if (!diff || !diff.trim()) {
2801
+ if (diff === null) continue;
2802
+ if (!diff.trim()) {
2803
+ if (hasProtectedFiles) {
2804
+ patch.base_generation = currentGenSha;
2805
+ try {
2806
+ this.lockManager.updatePatch(patch.id, { base_generation: currentGenSha });
2807
+ } catch {
2808
+ }
2809
+ keptAsUserOwned++;
2810
+ continue;
2811
+ }
2222
2812
  this.lockManager.removePatch(patch.id);
2223
2813
  absorbedPatchIds.add(patch.id);
2224
2814
  absorbed++;
2225
2815
  continue;
2226
2816
  }
2817
+ if (hasProtectedFiles) {
2818
+ patch.base_generation = currentGenSha;
2819
+ try {
2820
+ this.lockManager.updatePatch(patch.id, { base_generation: currentGenSha });
2821
+ } catch {
2822
+ }
2823
+ keptAsUserOwned++;
2824
+ continue;
2825
+ }
2227
2826
  const newContentHash = this.detector.computeContentHash(diff);
2228
2827
  if (seenContentHashes.has(newContentHash)) {
2229
2828
  this.lockManager.removePatch(patch.id);
@@ -2232,17 +2831,89 @@ var ReplayService = class {
2232
2831
  continue;
2233
2832
  }
2234
2833
  seenContentHashes.add(newContentHash);
2235
- this.lockManager.updatePatch(patch.id, {
2236
- base_generation: currentGenSha,
2237
- patch_content: diff,
2238
- content_hash: newContentHash
2239
- });
2834
+ patch.base_generation = currentGenSha;
2835
+ patch.patch_content = diff;
2836
+ patch.content_hash = newContentHash;
2837
+ try {
2838
+ this.lockManager.updatePatch(patch.id, {
2839
+ base_generation: currentGenSha,
2840
+ patch_content: diff,
2841
+ content_hash: newContentHash
2842
+ });
2843
+ } catch {
2844
+ }
2240
2845
  contentRebased++;
2241
2846
  } catch {
2242
2847
  }
2243
2848
  }
2244
2849
  return { absorbed, repointed, contentRebased, keptAsUserOwned, absorbedPatchIds };
2245
2850
  }
2851
+ /**
2852
+ * Determine whether `file` is user-owned (never produced by the generator).
2853
+ *
2854
+ * Resolution order (first match wins):
2855
+ * 1. `patch.user_owned === true` — authoritative, persisted across cycles.
2856
+ * 2. `.fernignore` itself, or file matches a .fernignore pattern.
2857
+ * 3. Legacy-safe signal: `patch_content` shows this file as a `--- /dev/null`
2858
+ * creation. Works for patches that predate the `user_owned` field or
2859
+ * whose flag was lost. Immune to rename tricks — relies on raw patch text.
2860
+ * 4. Tree-based check against `patch.base_generation` when it is reachable
2861
+ * AND distinct from `currentGenSha`. If the base tree is unreachable
2862
+ * (shallow clone / aggressive `git gc --prune`), emit a one-time
2863
+ * customer-actionable warning (deduplicated via warnedGens) and
2864
+ * conservatively treat as user-owned — strictly safer than silent
2865
+ * absorption (FER-9809).
2866
+ * 5. Final fallback — check `currentGenSha` when there is no usable base
2867
+ * (legacy one-gen lockfile). Preserves pre-fix behavior for that edge.
2868
+ *
2869
+ * `originalFileName` is the pre-rename path when the generator moved the
2870
+ * file between the patch's base and the current generation. Tree lookups
2871
+ * at ancestor generations use the original path — that's where the
2872
+ * generator produced the content.
2873
+ */
2874
+ async isFileUserOwned(file, patch, currentGenSha, fernignorePatterns, warnedGens, originalFileName) {
2875
+ if (patch.user_owned) return true;
2876
+ if (file === ".fernignore") return true;
2877
+ if (fernignorePatterns.some((p) => file === p || (0, import_minimatch2.minimatch)(file, p))) return true;
2878
+ if (this.fileIsCreationInPatchContent(patch.patch_content, originalFileName ?? file)) {
2879
+ return true;
2880
+ }
2881
+ const base = patch.base_generation;
2882
+ if (base && base !== currentGenSha) {
2883
+ const reachable = await this.git.commitExists(base) || await this.git.treeExists(base);
2884
+ if (!reachable) {
2885
+ if (!warnedGens.has(base)) {
2886
+ warnedGens.add(base);
2887
+ this.warnings.push(
2888
+ `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.`
2889
+ );
2890
+ }
2891
+ return true;
2892
+ }
2893
+ return await this.git.showFile(base, originalFileName ?? file) === null;
2894
+ }
2895
+ return await this.git.showFile(currentGenSha, originalFileName ?? file) === null;
2896
+ }
2897
+ /**
2898
+ * Returns true if `file`'s diff section in `patchContent` starts with
2899
+ * `--- /dev/null`, meaning this file was introduced by the patch (user
2900
+ * creation). Used as a legacy-safe fallback when `patch.user_owned` is
2901
+ * absent or cleared.
2902
+ */
2903
+ fileIsCreationInPatchContent(patchContent, file) {
2904
+ const lines = patchContent.split("\n");
2905
+ let inFileSection = false;
2906
+ for (const line of lines) {
2907
+ if (line.startsWith("diff --git ")) {
2908
+ inFileSection = line.includes(` b/${file}`) || line.endsWith(` b/${file}`);
2909
+ continue;
2910
+ }
2911
+ if (inFileSection && line.startsWith("--- ")) {
2912
+ return line === "--- /dev/null";
2913
+ }
2914
+ }
2915
+ return false;
2916
+ }
2246
2917
  /**
2247
2918
  * For conflict patches with mixed results (some files merged, some conflicted),
2248
2919
  * check if the cleanly merged files were absorbed by the generator (empty diff).
@@ -2256,13 +2927,14 @@ var ReplayService = class {
2256
2927
  const cleanFiles = result.fileResults.filter((f) => f.status === "merged").map((f) => f.file);
2257
2928
  if (cleanFiles.length === 0) return;
2258
2929
  const patch = result.patch;
2930
+ if (patch.user_owned) return;
2259
2931
  const fernignorePatterns = this.readFernignorePatterns();
2932
+ const warnedGens = /* @__PURE__ */ new Set();
2260
2933
  const generatorCleanFiles = [];
2261
2934
  for (const file of cleanFiles) {
2262
- if (file === ".fernignore") continue;
2263
- if (fernignorePatterns.some((p) => file === p || (0, import_minimatch2.minimatch)(file, p))) continue;
2264
- const content = await this.git.showFile(currentGenSha, file);
2265
- if (content === null) continue;
2935
+ if (await this.isFileUserOwned(file, patch, currentGenSha, fernignorePatterns, warnedGens)) {
2936
+ continue;
2937
+ }
2266
2938
  generatorCleanFiles.push(file);
2267
2939
  }
2268
2940
  if (generatorCleanFiles.length === 0) return;
@@ -2299,7 +2971,35 @@ var ReplayService = class {
2299
2971
  let conflictResolved = 0;
2300
2972
  let conflictAbsorbed = 0;
2301
2973
  let contentRefreshed = 0;
2974
+ const fernignorePatterns = this.readFernignorePatterns();
2975
+ const warnedGens = /* @__PURE__ */ new Set();
2976
+ const reachabilityCache = /* @__PURE__ */ new Map();
2977
+ const isReachable = async (sha) => {
2978
+ const cached = reachabilityCache.get(sha);
2979
+ if (cached !== void 0) return cached;
2980
+ const reachable = await this.git.commitExists(sha) || await this.git.treeExists(sha);
2981
+ reachabilityCache.set(sha, reachable);
2982
+ return reachable;
2983
+ };
2984
+ const allPatchFilesUserOwned = async (patch) => {
2985
+ if (patch.user_owned) return true;
2986
+ if (patch.files.length === 0) return false;
2987
+ for (const file of patch.files) {
2988
+ if (!await this.isFileUserOwned(file, patch, currentGen, fernignorePatterns, warnedGens)) {
2989
+ return false;
2990
+ }
2991
+ }
2992
+ return true;
2993
+ };
2302
2994
  for (const patch of patches) {
2995
+ if (patch.base_generation && patch.base_generation !== currentGen && !warnedGens.has(patch.base_generation)) {
2996
+ if (!await isReachable(patch.base_generation)) {
2997
+ warnedGens.add(patch.base_generation);
2998
+ this.warnings.push(
2999
+ `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.`
3000
+ );
3001
+ }
3002
+ }
2303
3003
  if (patch.status != null) {
2304
3004
  delete patch.status;
2305
3005
  continue;
@@ -2309,6 +3009,7 @@ var ReplayService = class {
2309
3009
  const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
2310
3010
  if (diff === null) continue;
2311
3011
  if (!diff.trim()) {
3012
+ if (await allPatchFilesUserOwned(patch)) continue;
2312
3013
  this.lockManager.removePatch(patch.id);
2313
3014
  contentRefreshed++;
2314
3015
  continue;
@@ -2320,6 +3021,12 @@ var ReplayService = class {
2320
3021
  if (hasStaleMarkers) {
2321
3022
  continue;
2322
3023
  }
3024
+ const isProtected = patch.files.some(
3025
+ (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || (0, import_minimatch2.minimatch)(file, p))
3026
+ );
3027
+ if (isProtected) {
3028
+ continue;
3029
+ }
2323
3030
  const newContentHash = this.detector.computeContentHash(diff);
2324
3031
  if (newContentHash !== patch.content_hash) {
2325
3032
  const filesOutput = await this.git.exec(["diff", "--name-only", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
@@ -2345,6 +3052,13 @@ var ReplayService = class {
2345
3052
  const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
2346
3053
  if (diff === null) continue;
2347
3054
  if (!diff.trim()) {
3055
+ if (await allPatchFilesUserOwned(patch)) {
3056
+ try {
3057
+ this.lockManager.updatePatch(patch.id, { base_generation: currentGen });
3058
+ } catch {
3059
+ }
3060
+ continue;
3061
+ }
2348
3062
  this.lockManager.removePatch(patch.id);
2349
3063
  conflictAbsorbed++;
2350
3064
  continue;
@@ -2856,7 +3570,10 @@ function ensureGitattributesEntries(outputDir) {
2856
3570
  (0, import_node_fs4.writeFileSync)(gitattributesPath, content, "utf-8");
2857
3571
  }
2858
3572
  function computeContentHash(patchContent) {
2859
- const normalized = patchContent.split("\n").filter((line) => !line.startsWith("From ") && !line.startsWith("index ") && !line.startsWith("Date: ")).join("\n");
3573
+ const lines = patchContent.split("\n");
3574
+ const diffStart = lines.findIndex((l) => l.startsWith("diff --git "));
3575
+ const relevantLines = diffStart > 0 ? lines.slice(diffStart) : lines;
3576
+ const normalized = relevantLines.filter((line) => !line.startsWith("index ")).join("\n").replace(/\n-- \n[\d]+\.[\d]+[^\n]*\s*$/, "");
2860
3577
  return `sha256:${(0, import_node_crypto3.createHash)("sha256").update(normalized).digest("hex")}`;
2861
3578
  }
2862
3579
 
@@ -3168,6 +3885,7 @@ function status(outputDir) {
3168
3885
  }
3169
3886
  // Annotate the CommonJS export names for ESM import in node:
3170
3887
  0 && (module.exports = {
3888
+ CREDENTIAL_PATTERNS,
3171
3889
  FERN_BOT_EMAIL,
3172
3890
  FERN_BOT_LOGIN,
3173
3891
  FERN_BOT_NAME,
@@ -3179,15 +3897,18 @@ function status(outputDir) {
3179
3897
  ReplayCommitter,
3180
3898
  ReplayDetector,
3181
3899
  ReplayService,
3900
+ SENSITIVE_FILE_PATTERNS,
3182
3901
  bootstrap,
3183
3902
  forget,
3184
3903
  isGenerationCommit,
3185
3904
  isReplayCommit,
3186
3905
  isRevertCommit,
3906
+ isSensitiveFile,
3187
3907
  parseRevertedMessage,
3188
3908
  parseRevertedSha,
3189
3909
  reset,
3190
3910
  resolve,
3911
+ scanForCredentials,
3191
3912
  status,
3192
3913
  threeWayMerge
3193
3914
  });