@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/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
@@ -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;
@@ -661,6 +770,7 @@ var ReplayDetector = class {
661
770
  }
662
771
  const log = await this.git.exec([
663
772
  "log",
773
+ "--first-parent",
664
774
  "--format=%H%x00%an%x00%ae%x00%s",
665
775
  `${lastGen.commit_sha}..HEAD`,
666
776
  "--",
@@ -676,11 +786,20 @@ var ReplayDetector = class {
676
786
  if (isGenerationCommit(commit)) {
677
787
  continue;
678
788
  }
679
- const parents = await this.git.getCommitParents(commit.sha);
680
- if (parents.length > 1) {
789
+ if (lock.patches.find((p) => p.original_commit === commit.sha)) {
681
790
  continue;
682
791
  }
683
- if (lock.patches.find((p) => p.original_commit === commit.sha)) {
792
+ const parents = await this.git.getCommitParents(commit.sha);
793
+ if (parents.length > 1) {
794
+ const compositePatch = await this.createMergeCompositePatch({
795
+ mergeCommit: commit,
796
+ firstParent: parents[0],
797
+ lastGen,
798
+ lock
799
+ });
800
+ if (compositePatch) {
801
+ newPatches.push(compositePatch);
802
+ }
684
803
  continue;
685
804
  }
686
805
  let patchContent;
@@ -692,12 +811,31 @@ var ReplayDetector = class {
692
811
  );
693
812
  continue;
694
813
  }
695
- const contentHash = this.computeContentHash(patchContent);
814
+ let contentHash = this.computeContentHash(patchContent);
696
815
  if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
697
816
  continue;
698
817
  }
699
818
  const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
700
- 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
+ }
701
839
  if (files.length === 0) {
702
840
  continue;
703
841
  }
@@ -709,14 +847,16 @@ var ReplayDetector = class {
709
847
  original_author: `${commit.authorName} <${commit.authorEmail}>`,
710
848
  base_generation: lastGen.commit_sha,
711
849
  files,
712
- patch_content: patchContent
850
+ patch_content: patchContent,
851
+ ...this.isCreationOnlyPatch(patchContent) ? { user_owned: true } : {}
713
852
  });
714
853
  }
715
- newPatches.reverse();
854
+ const survivingPatches = await this.collapseNetZeroFiles(newPatches);
855
+ survivingPatches.reverse();
716
856
  const revertedPatchIdSet = /* @__PURE__ */ new Set();
717
857
  const revertIndicesToRemove = /* @__PURE__ */ new Set();
718
- for (let i = 0; i < newPatches.length; i++) {
719
- const patch = newPatches[i];
858
+ for (let i = 0; i < survivingPatches.length; i++) {
859
+ const patch = survivingPatches[i];
720
860
  if (!isRevertCommit(patch.original_message)) continue;
721
861
  let body = "";
722
862
  try {
@@ -745,7 +885,7 @@ var ReplayDetector = class {
745
885
  if (matchedExisting) continue;
746
886
  let matchedNew = false;
747
887
  if (revertedSha) {
748
- const idx = newPatches.findIndex(
888
+ const idx = survivingPatches.findIndex(
749
889
  (p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_commit === revertedSha
750
890
  );
751
891
  if (idx !== -1) {
@@ -755,7 +895,7 @@ var ReplayDetector = class {
755
895
  }
756
896
  }
757
897
  if (!matchedNew && revertedMessage) {
758
- const idx = newPatches.findIndex(
898
+ const idx = survivingPatches.findIndex(
759
899
  (p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_message === revertedMessage
760
900
  );
761
901
  if (idx !== -1) {
@@ -767,18 +907,155 @@ var ReplayDetector = class {
767
907
  revertIndicesToRemove.add(i);
768
908
  }
769
909
  }
770
- const filteredPatches = newPatches.filter((_, i) => !revertIndicesToRemove.has(i));
910
+ const filteredPatches = survivingPatches.filter((_, i) => !revertIndicesToRemove.has(i));
771
911
  return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
772
912
  }
773
913
  /**
774
914
  * Compute content hash for deduplication.
775
- * Removes commit SHA line and index lines before hashing,
776
- * 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).
777
929
  */
778
930
  computeContentHash(patchContent) {
779
- 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*$/, "");
780
935
  return `sha256:${(0, import_node_crypto.createHash)("sha256").update(normalized).digest("hex")}`;
781
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
+ }
1011
+ /**
1012
+ * Create a single composite patch for a merge commit by diffing the merge
1013
+ * commit against its first parent. This captures the net change of the
1014
+ * entire merged branch as one patch, eliminating accumulator chaining and
1015
+ * zero-net-change ghost conflicts.
1016
+ */
1017
+ async createMergeCompositePatch(input) {
1018
+ const { mergeCommit, firstParent, lastGen, lock } = input;
1019
+ const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
1020
+ const filesOutput = await this.git.exec([
1021
+ "diff",
1022
+ "--name-only",
1023
+ firstParent,
1024
+ mergeCommit.sha
1025
+ ]);
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
+ }
1034
+ if (files.length === 0) return null;
1035
+ const diff = await this.git.exec([
1036
+ "diff",
1037
+ firstParent,
1038
+ mergeCommit.sha,
1039
+ "--",
1040
+ ...files
1041
+ ]);
1042
+ if (!diff.trim()) return null;
1043
+ const contentHash = this.computeContentHash(diff);
1044
+ if (lock.patches.some((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
1045
+ return null;
1046
+ }
1047
+ return {
1048
+ id: `patch-merge-${mergeCommit.sha.slice(0, 8)}`,
1049
+ content_hash: contentHash,
1050
+ original_commit: mergeCommit.sha,
1051
+ original_message: mergeCommit.message,
1052
+ original_author: `${mergeCommit.authorName} <${mergeCommit.authorEmail}>`,
1053
+ base_generation: lastGen.commit_sha,
1054
+ files,
1055
+ patch_content: diff,
1056
+ ...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
1057
+ };
1058
+ }
782
1059
  /**
783
1060
  * Detect patches via tree diff for non-linear history. Returns a composite patch.
784
1061
  * Revert reconciliation is skipped here because tree-diff produces a single composite
@@ -790,7 +1067,14 @@ var ReplayDetector = class {
790
1067
  return this.detectPatchesViaCommitScan();
791
1068
  }
792
1069
  const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
793
- 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
+ }
794
1078
  if (files.length === 0) return { patches: [], revertedPatchIds: [] };
795
1079
  const diff = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
796
1080
  if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
@@ -810,7 +1094,8 @@ var ReplayDetector = class {
810
1094
  // reference to find base file content. diffBase may be the tree_hash.
811
1095
  base_generation: commitKnownMissing ? diffBase : lastGen.commit_sha,
812
1096
  files,
813
- patch_content: diff
1097
+ patch_content: diff,
1098
+ ...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
814
1099
  };
815
1100
  return { patches: [compositePatch], revertedPatchIds: [] };
816
1101
  }
@@ -857,7 +1142,7 @@ var ReplayDetector = class {
857
1142
  } catch {
858
1143
  continue;
859
1144
  }
860
- const contentHash = this.computeContentHash(patchContent);
1145
+ let contentHash = this.computeContentHash(patchContent);
861
1146
  if (existingHashes.has(contentHash) || forgottenHashes.has(contentHash)) {
862
1147
  continue;
863
1148
  }
@@ -865,7 +1150,26 @@ var ReplayDetector = class {
865
1150
  continue;
866
1151
  }
867
1152
  const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
868
- 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
+ }
869
1173
  if (files.length === 0) {
870
1174
  continue;
871
1175
  }
@@ -1197,6 +1501,20 @@ var ReplayApplicator = class {
1197
1501
  resetAccumulator() {
1198
1502
  this.fileTheirsAccumulator.clear();
1199
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
+ }
1200
1518
  /**
1201
1519
  * Apply all patches, returning results for each.
1202
1520
  * Skips patches that match exclude patterns in replay.yml
@@ -1262,7 +1580,7 @@ var ReplayApplicator = class {
1262
1580
  const base = await this.git.showFile(baseGen.tree_hash, filePath);
1263
1581
  const theirs = await this.applyPatchToContent(base, patch.patch_content, filePath, tempGit, tempDir);
1264
1582
  const effectiveTheirs = theirs ?? await (0, import_promises.readFile)((0, import_node_path2.join)(this.outputDir, resolvedPath), "utf-8").catch(() => null);
1265
- if (effectiveTheirs) {
1583
+ if (effectiveTheirs != null) {
1266
1584
  this.fileTheirsAccumulator.set(resolvedPath, {
1267
1585
  content: effectiveTheirs,
1268
1586
  baseGeneration: patch.base_generation
@@ -1286,16 +1604,20 @@ var ReplayApplicator = class {
1286
1604
  return this.fileTheirsAccumulator.has(resolved);
1287
1605
  })
1288
1606
  ).then((results) => results.some(Boolean));
1289
- 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) {
1290
1617
  const snapshots = /* @__PURE__ */ new Map();
1291
- const resolvedFiles = {};
1292
1618
  for (const filePath of patch.files) {
1293
- const resolvedPath = baseGen ? await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash) : filePath;
1294
- if (resolvedPath !== filePath) {
1295
- resolvedFiles[filePath] = resolvedPath;
1296
- }
1297
- const fullPath = (0, import_node_path2.join)(this.outputDir, resolvedPath);
1298
- 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));
1299
1621
  }
1300
1622
  try {
1301
1623
  await this.git.execWithInput(["apply", "--3way"], patch.patch_content);
@@ -1307,8 +1629,8 @@ var ReplayApplicator = class {
1307
1629
  ...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
1308
1630
  };
1309
1631
  } catch {
1310
- for (const [resolvedPath, content] of snapshots) {
1311
- 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);
1312
1634
  if (content != null) {
1313
1635
  await (0, import_promises.writeFile)(fullPath, content);
1314
1636
  } else {
@@ -1428,7 +1750,7 @@ var ReplayApplicator = class {
1428
1750
  }
1429
1751
  let useAccumulatorAsMergeBase = false;
1430
1752
  const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
1431
- if (accumulatorEntry && (!theirs || base == null)) {
1753
+ if (accumulatorEntry && (theirs == null || base == null)) {
1432
1754
  theirs = await this.applyPatchToContent(
1433
1755
  accumulatorEntry.content,
1434
1756
  patch.patch_content,
@@ -1436,7 +1758,16 @@ var ReplayApplicator = class {
1436
1758
  tempGit,
1437
1759
  tempDir
1438
1760
  );
1439
- 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;
1440
1771
  useAccumulatorAsMergeBase = true;
1441
1772
  }
1442
1773
  }
@@ -1453,7 +1784,7 @@ var ReplayApplicator = class {
1453
1784
  }
1454
1785
  let effective_theirs = theirs;
1455
1786
  let baseMismatchSkipped = false;
1456
- if (theirs && base && !useAccumulatorAsMergeBase) {
1787
+ if (theirs != null && base != null && !useAccumulatorAsMergeBase) {
1457
1788
  if (accumulatorEntry && accumulatorEntry.baseGeneration === patch.base_generation) {
1458
1789
  try {
1459
1790
  const preMerged = threeWayMerge(base, accumulatorEntry.content, theirs);
@@ -1469,7 +1800,7 @@ var ReplayApplicator = class {
1469
1800
  baseMismatchSkipped = true;
1470
1801
  }
1471
1802
  }
1472
- if (base == null && !ours && effective_theirs) {
1803
+ if (base == null && ours == null && effective_theirs != null) {
1473
1804
  this.fileTheirsAccumulator.set(resolvedPath, {
1474
1805
  content: effective_theirs,
1475
1806
  baseGeneration: patch.base_generation
@@ -1479,7 +1810,7 @@ var ReplayApplicator = class {
1479
1810
  await (0, import_promises.writeFile)(oursPath, effective_theirs);
1480
1811
  return { file: resolvedPath, status: "merged", reason: "new-file" };
1481
1812
  }
1482
- if (base == null && ours && effective_theirs && !useAccumulatorAsMergeBase) {
1813
+ if (base == null && ours != null && effective_theirs != null && !useAccumulatorAsMergeBase) {
1483
1814
  const merged2 = threeWayMerge("", ours, effective_theirs);
1484
1815
  const outDir2 = (0, import_node_path2.dirname)(oursPath);
1485
1816
  await (0, import_promises.mkdir)(outDir2, { recursive: true });
@@ -1493,16 +1824,20 @@ var ReplayApplicator = class {
1493
1824
  conflictMetadata: metadata
1494
1825
  };
1495
1826
  }
1827
+ this.fileTheirsAccumulator.set(resolvedPath, {
1828
+ content: merged2.content,
1829
+ baseGeneration: patch.base_generation
1830
+ });
1496
1831
  return { file: resolvedPath, status: "merged" };
1497
1832
  }
1498
- if (!effective_theirs) {
1833
+ if (effective_theirs == null) {
1499
1834
  return {
1500
1835
  file: resolvedPath,
1501
1836
  status: "skipped",
1502
1837
  reason: "missing-content"
1503
1838
  };
1504
1839
  }
1505
- if (base == null && !useAccumulatorAsMergeBase || !ours) {
1840
+ if (base == null && !useAccumulatorAsMergeBase || ours == null) {
1506
1841
  return {
1507
1842
  file: resolvedPath,
1508
1843
  status: "skipped",
@@ -1521,7 +1856,7 @@ var ReplayApplicator = class {
1521
1856
  const outDir = (0, import_node_path2.dirname)(oursPath);
1522
1857
  await (0, import_promises.mkdir)(outDir, { recursive: true });
1523
1858
  await (0, import_promises.writeFile)(oursPath, merged.content);
1524
- if (effective_theirs && !merged.hasConflicts) {
1859
+ if (effective_theirs != null && !merged.hasConflicts) {
1525
1860
  this.fileTheirsAccumulator.set(resolvedPath, {
1526
1861
  content: effective_theirs,
1527
1862
  baseGeneration: patch.base_generation
@@ -1619,6 +1954,28 @@ var ReplayApplicator = class {
1619
1954
  return null;
1620
1955
  }
1621
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
+ }
1622
1979
  extractFileDiff(patchContent, filePath) {
1623
1980
  const lines = patchContent.split("\n");
1624
1981
  const diffLines = [];
@@ -1787,6 +2144,33 @@ var ReplayService = class {
1787
2144
  committer;
1788
2145
  lockManager;
1789
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
+ }
1790
2174
  constructor(outputDir, _config) {
1791
2175
  const git = new GitClient(outputDir);
1792
2176
  this.git = git;
@@ -1797,6 +2181,7 @@ var ReplayService = class {
1797
2181
  this.committer = new ReplayCommitter(git, outputDir);
1798
2182
  }
1799
2183
  async runReplay(options) {
2184
+ this.warnings = [];
1800
2185
  if (options?.skipApplication) {
1801
2186
  return this.handleSkipApplication(options);
1802
2187
  }
@@ -1870,6 +2255,7 @@ var ReplayService = class {
1870
2255
  );
1871
2256
  }
1872
2257
  this.lockManager.save();
2258
+ this.detector.warnings.push(...this.lockManager.warnings);
1873
2259
  }
1874
2260
  determineFlow() {
1875
2261
  try {
@@ -1936,6 +2322,7 @@ var ReplayService = class {
1936
2322
  }
1937
2323
  this.lockManager.setReplaySkippedAt((/* @__PURE__ */ new Date()).toISOString());
1938
2324
  this.lockManager.save();
2325
+ const credentialWarnings = [...this.lockManager.warnings];
1939
2326
  if (!options?.stageOnly) {
1940
2327
  await this.committer.commitReplay(0);
1941
2328
  } else {
@@ -1947,13 +2334,15 @@ var ReplayService = class {
1947
2334
  patchesApplied: 0,
1948
2335
  patchesWithConflicts: 0,
1949
2336
  patchesSkipped: 0,
1950
- conflicts: []
2337
+ conflicts: [],
2338
+ warnings: credentialWarnings.length > 0 ? credentialWarnings : void 0
1951
2339
  };
1952
2340
  }
1953
2341
  async handleNoPatchesRegeneration(options) {
1954
- const { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
1955
- const warnings = [...this.detector.warnings];
2342
+ let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
2343
+ const detectorWarnings = [...this.detector.warnings];
1956
2344
  if (options?.dryRun) {
2345
+ const dryRunWarnings = [...detectorWarnings, ...this.warnings];
1957
2346
  return {
1958
2347
  flow: "no-patches",
1959
2348
  patchesDetected: newPatches.length,
@@ -1963,9 +2352,26 @@ var ReplayService = class {
1963
2352
  patchesReverted: revertedPatchIds.length,
1964
2353
  conflicts: [],
1965
2354
  wouldApply: newPatches,
1966
- warnings: warnings.length > 0 ? warnings : void 0
2355
+ warnings: dryRunWarnings.length > 0 ? dryRunWarnings : void 0
1967
2356
  };
1968
2357
  }
2358
+ {
2359
+ const preLock = this.lockManager.read();
2360
+ const preCurrentGen = preLock.current_generation;
2361
+ const uniqueFiles = [...new Set(newPatches.flatMap((p) => p.files))];
2362
+ const absorbedFiles = /* @__PURE__ */ new Set();
2363
+ for (const file of uniqueFiles) {
2364
+ const diff = await this.git.exec(["diff", preCurrentGen, "HEAD", "--", file]).catch(() => null);
2365
+ if (diff !== null && !diff.trim()) {
2366
+ absorbedFiles.add(file);
2367
+ }
2368
+ }
2369
+ if (absorbedFiles.size > 0) {
2370
+ newPatches = newPatches.filter(
2371
+ (p) => p.files.length === 0 || !p.files.every((f) => absorbedFiles.has(f))
2372
+ );
2373
+ }
2374
+ }
1969
2375
  for (const id of revertedPatchIds) {
1970
2376
  try {
1971
2377
  this.lockManager.removePatch(id);
@@ -1984,6 +2390,7 @@ var ReplayService = class {
1984
2390
  let results = [];
1985
2391
  if (newPatches.length > 0) {
1986
2392
  results = await this.applicator.applyPatches(newPatches);
2393
+ this._lastApplyResults = results;
1987
2394
  this.revertConflictingFiles(results);
1988
2395
  for (const result of results) {
1989
2396
  if (result.status === "conflict") {
@@ -1998,6 +2405,7 @@ var ReplayService = class {
1998
2405
  }
1999
2406
  }
2000
2407
  this.lockManager.save();
2408
+ this.warnings.push(...this.lockManager.warnings);
2001
2409
  if (newPatches.length > 0) {
2002
2410
  if (!options?.stageOnly) {
2003
2411
  const appliedCount = results.filter((r) => r.status === "applied").length;
@@ -2006,13 +2414,14 @@ var ReplayService = class {
2006
2414
  await this.committer.stageAll();
2007
2415
  }
2008
2416
  }
2417
+ const warnings = [...detectorWarnings, ...this.warnings];
2009
2418
  return this.buildReport("no-patches", newPatches, results, options, warnings, rebaseCounts, void 0, revertedPatchIds.length);
2010
2419
  }
2011
2420
  async handleNormalRegeneration(options) {
2012
2421
  if (options?.dryRun) {
2013
2422
  const existingPatches2 = this.lockManager.getPatches();
2014
2423
  const { patches: newPatches2, revertedPatchIds: dryRunReverted } = await this.detector.detectNewPatches();
2015
- const warnings2 = [...this.detector.warnings];
2424
+ const warnings2 = [...this.detector.warnings, ...this.warnings];
2016
2425
  const allPatches2 = [...existingPatches2, ...newPatches2];
2017
2426
  return {
2018
2427
  flow: "normal-regeneration",
@@ -2044,7 +2453,7 @@ var ReplayService = class {
2044
2453
  }
2045
2454
  existingPatches = this.lockManager.getPatches();
2046
2455
  let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
2047
- const warnings = [...this.detector.warnings];
2456
+ const detectorWarnings = [...this.detector.warnings];
2048
2457
  if (removedByPreRebase.length > 0) {
2049
2458
  const removedOriginalCommits = new Set(removedByPreRebase.map((p) => p.original_commit));
2050
2459
  const removedOriginalMessages = new Set(removedByPreRebase.map((p) => p.original_message));
@@ -2076,6 +2485,7 @@ var ReplayService = class {
2076
2485
  const genRecord = await this.committer.createGenerationRecord(commitOpts);
2077
2486
  this.lockManager.addGeneration(genRecord);
2078
2487
  const results = await this.applicator.applyPatches(allPatches);
2488
+ this._lastApplyResults = results;
2079
2489
  this.revertConflictingFiles(results);
2080
2490
  for (const result of results) {
2081
2491
  if (result.status === "conflict") {
@@ -2097,12 +2507,14 @@ var ReplayService = class {
2097
2507
  }
2098
2508
  }
2099
2509
  this.lockManager.save();
2510
+ this.warnings.push(...this.lockManager.warnings);
2100
2511
  if (options?.stageOnly) {
2101
2512
  await this.committer.stageAll();
2102
2513
  } else {
2103
2514
  const appliedCount = results.filter((r) => r.status === "applied").length;
2104
2515
  await this.committer.commitReplay(appliedCount, allPatches);
2105
2516
  }
2517
+ const warnings = [...detectorWarnings, ...this.warnings];
2106
2518
  return this.buildReport(
2107
2519
  "normal-regeneration",
2108
2520
  allPatches,
@@ -2126,6 +2538,7 @@ var ReplayService = class {
2126
2538
  let keptAsUserOwned = 0;
2127
2539
  const seenContentHashes = /* @__PURE__ */ new Set();
2128
2540
  const absorbedPatchIds = /* @__PURE__ */ new Set();
2541
+ const fernignorePatterns = this.readFernignorePatterns();
2129
2542
  for (const result of results) {
2130
2543
  if (result.resolvedFiles && Object.keys(result.resolvedFiles).length > 0) {
2131
2544
  const patch = result.patch;
@@ -2146,34 +2559,78 @@ var ReplayService = class {
2146
2559
  const patch = result.patch;
2147
2560
  if (patch.base_generation === currentGenSha) continue;
2148
2561
  try {
2149
- const fernignorePatterns = this.readFernignorePatterns();
2150
- const isUserOwned = await Promise.all(
2562
+ const originalByResolved = {};
2563
+ if (result.resolvedFiles) {
2564
+ for (const [orig, resolved] of Object.entries(result.resolvedFiles)) {
2565
+ originalByResolved[resolved] = orig;
2566
+ }
2567
+ }
2568
+ const isUserOwnedPerFile = await Promise.all(
2151
2569
  patch.files.map(async (file) => {
2152
- if (file === ".fernignore") {
2570
+ if (file === ".fernignore") return true;
2571
+ if (fernignorePatterns.some((p) => file === p || (0, import_minimatch2.minimatch)(file, p))) {
2153
2572
  return true;
2154
2573
  }
2155
- if (fernignorePatterns.some((p) => file === p || (0, import_minimatch2.minimatch)(file, p))) {
2574
+ if (patch.user_owned) return true;
2575
+ if (this.fileIsCreationInPatchContent(patch.patch_content, originalByResolved[file] ?? file)) {
2156
2576
  return true;
2157
2577
  }
2158
2578
  const content = await this.git.showFile(currentGenSha, file);
2159
2579
  return content === null;
2160
2580
  })
2161
2581
  );
2162
- const hasUserOwnedFiles = isUserOwned.some(Boolean);
2163
- if (hasUserOwnedFiles) {
2164
- this.lockManager.updatePatch(patch.id, {
2165
- base_generation: currentGenSha
2166
- });
2582
+ const hasProtectedFiles = patch.files.some(
2583
+ (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || (0, import_minimatch2.minimatch)(file, p))
2584
+ );
2585
+ const allUserOwned = isUserOwnedPerFile.every(Boolean);
2586
+ if (allUserOwned && patch.files.length > 0) {
2587
+ const baseChanged = patch.base_generation !== currentGenSha;
2588
+ const userDiff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
2589
+ if (userDiff != null && userDiff.trim()) {
2590
+ const newHash = this.detector.computeContentHash(userDiff);
2591
+ patch.patch_content = userDiff;
2592
+ patch.content_hash = newHash;
2593
+ }
2594
+ patch.base_generation = currentGenSha;
2595
+ try {
2596
+ this.lockManager.updatePatch(patch.id, {
2597
+ base_generation: currentGenSha,
2598
+ ...!patch.user_owned ? { user_owned: true } : {},
2599
+ ...userDiff != null && userDiff.trim() ? { patch_content: patch.patch_content, content_hash: patch.content_hash } : {}
2600
+ });
2601
+ } catch {
2602
+ }
2603
+ patch.user_owned = true;
2167
2604
  keptAsUserOwned++;
2605
+ if (baseChanged) repointed++;
2168
2606
  continue;
2169
2607
  }
2170
2608
  const diff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
2171
- if (!diff || !diff.trim()) {
2609
+ if (diff === null) continue;
2610
+ if (!diff.trim()) {
2611
+ if (hasProtectedFiles) {
2612
+ patch.base_generation = currentGenSha;
2613
+ try {
2614
+ this.lockManager.updatePatch(patch.id, { base_generation: currentGenSha });
2615
+ } catch {
2616
+ }
2617
+ keptAsUserOwned++;
2618
+ continue;
2619
+ }
2172
2620
  this.lockManager.removePatch(patch.id);
2173
2621
  absorbedPatchIds.add(patch.id);
2174
2622
  absorbed++;
2175
2623
  continue;
2176
2624
  }
2625
+ if (hasProtectedFiles) {
2626
+ patch.base_generation = currentGenSha;
2627
+ try {
2628
+ this.lockManager.updatePatch(patch.id, { base_generation: currentGenSha });
2629
+ } catch {
2630
+ }
2631
+ keptAsUserOwned++;
2632
+ continue;
2633
+ }
2177
2634
  const newContentHash = this.detector.computeContentHash(diff);
2178
2635
  if (seenContentHashes.has(newContentHash)) {
2179
2636
  this.lockManager.removePatch(patch.id);
@@ -2182,17 +2639,89 @@ var ReplayService = class {
2182
2639
  continue;
2183
2640
  }
2184
2641
  seenContentHashes.add(newContentHash);
2185
- this.lockManager.updatePatch(patch.id, {
2186
- base_generation: currentGenSha,
2187
- patch_content: diff,
2188
- content_hash: newContentHash
2189
- });
2642
+ patch.base_generation = currentGenSha;
2643
+ patch.patch_content = diff;
2644
+ patch.content_hash = newContentHash;
2645
+ try {
2646
+ this.lockManager.updatePatch(patch.id, {
2647
+ base_generation: currentGenSha,
2648
+ patch_content: diff,
2649
+ content_hash: newContentHash
2650
+ });
2651
+ } catch {
2652
+ }
2190
2653
  contentRebased++;
2191
2654
  } catch {
2192
2655
  }
2193
2656
  }
2194
2657
  return { absorbed, repointed, contentRebased, keptAsUserOwned, absorbedPatchIds };
2195
2658
  }
2659
+ /**
2660
+ * Determine whether `file` is user-owned (never produced by the generator).
2661
+ *
2662
+ * Resolution order (first match wins):
2663
+ * 1. `patch.user_owned === true` — authoritative, persisted across cycles.
2664
+ * 2. `.fernignore` itself, or file matches a .fernignore pattern.
2665
+ * 3. Legacy-safe signal: `patch_content` shows this file as a `--- /dev/null`
2666
+ * creation. Works for patches that predate the `user_owned` field or
2667
+ * whose flag was lost. Immune to rename tricks — relies on raw patch text.
2668
+ * 4. Tree-based check against `patch.base_generation` when it is reachable
2669
+ * AND distinct from `currentGenSha`. If the base tree is unreachable
2670
+ * (shallow clone / aggressive `git gc --prune`), emit a one-time
2671
+ * customer-actionable warning (deduplicated via warnedGens) and
2672
+ * conservatively treat as user-owned — strictly safer than silent
2673
+ * absorption (FER-9809).
2674
+ * 5. Final fallback — check `currentGenSha` when there is no usable base
2675
+ * (legacy one-gen lockfile). Preserves pre-fix behavior for that edge.
2676
+ *
2677
+ * `originalFileName` is the pre-rename path when the generator moved the
2678
+ * file between the patch's base and the current generation. Tree lookups
2679
+ * at ancestor generations use the original path — that's where the
2680
+ * generator produced the content.
2681
+ */
2682
+ async isFileUserOwned(file, patch, currentGenSha, fernignorePatterns, warnedGens, originalFileName) {
2683
+ if (patch.user_owned) return true;
2684
+ if (file === ".fernignore") return true;
2685
+ if (fernignorePatterns.some((p) => file === p || (0, import_minimatch2.minimatch)(file, p))) return true;
2686
+ if (this.fileIsCreationInPatchContent(patch.patch_content, originalFileName ?? file)) {
2687
+ return true;
2688
+ }
2689
+ const base = patch.base_generation;
2690
+ if (base && base !== currentGenSha) {
2691
+ const reachable = await this.git.commitExists(base) || await this.git.treeExists(base);
2692
+ if (!reachable) {
2693
+ if (!warnedGens.has(base)) {
2694
+ warnedGens.add(base);
2695
+ this.warnings.push(
2696
+ `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.`
2697
+ );
2698
+ }
2699
+ return true;
2700
+ }
2701
+ return await this.git.showFile(base, originalFileName ?? file) === null;
2702
+ }
2703
+ return await this.git.showFile(currentGenSha, originalFileName ?? file) === null;
2704
+ }
2705
+ /**
2706
+ * Returns true if `file`'s diff section in `patchContent` starts with
2707
+ * `--- /dev/null`, meaning this file was introduced by the patch (user
2708
+ * creation). Used as a legacy-safe fallback when `patch.user_owned` is
2709
+ * absent or cleared.
2710
+ */
2711
+ fileIsCreationInPatchContent(patchContent, file) {
2712
+ const lines = patchContent.split("\n");
2713
+ let inFileSection = false;
2714
+ for (const line of lines) {
2715
+ if (line.startsWith("diff --git ")) {
2716
+ inFileSection = line.includes(` b/${file}`) || line.endsWith(` b/${file}`);
2717
+ continue;
2718
+ }
2719
+ if (inFileSection && line.startsWith("--- ")) {
2720
+ return line === "--- /dev/null";
2721
+ }
2722
+ }
2723
+ return false;
2724
+ }
2196
2725
  /**
2197
2726
  * For conflict patches with mixed results (some files merged, some conflicted),
2198
2727
  * check if the cleanly merged files were absorbed by the generator (empty diff).
@@ -2206,13 +2735,14 @@ var ReplayService = class {
2206
2735
  const cleanFiles = result.fileResults.filter((f) => f.status === "merged").map((f) => f.file);
2207
2736
  if (cleanFiles.length === 0) return;
2208
2737
  const patch = result.patch;
2738
+ if (patch.user_owned) return;
2209
2739
  const fernignorePatterns = this.readFernignorePatterns();
2740
+ const warnedGens = /* @__PURE__ */ new Set();
2210
2741
  const generatorCleanFiles = [];
2211
2742
  for (const file of cleanFiles) {
2212
- if (file === ".fernignore") continue;
2213
- if (fernignorePatterns.some((p) => file === p || (0, import_minimatch2.minimatch)(file, p))) continue;
2214
- const content = await this.git.showFile(currentGenSha, file);
2215
- if (content === null) continue;
2743
+ if (await this.isFileUserOwned(file, patch, currentGenSha, fernignorePatterns, warnedGens)) {
2744
+ continue;
2745
+ }
2216
2746
  generatorCleanFiles.push(file);
2217
2747
  }
2218
2748
  if (generatorCleanFiles.length === 0) return;
@@ -2249,7 +2779,35 @@ var ReplayService = class {
2249
2779
  let conflictResolved = 0;
2250
2780
  let conflictAbsorbed = 0;
2251
2781
  let contentRefreshed = 0;
2782
+ const fernignorePatterns = this.readFernignorePatterns();
2783
+ const warnedGens = /* @__PURE__ */ new Set();
2784
+ const reachabilityCache = /* @__PURE__ */ new Map();
2785
+ const isReachable = async (sha) => {
2786
+ const cached = reachabilityCache.get(sha);
2787
+ if (cached !== void 0) return cached;
2788
+ const reachable = await this.git.commitExists(sha) || await this.git.treeExists(sha);
2789
+ reachabilityCache.set(sha, reachable);
2790
+ return reachable;
2791
+ };
2792
+ const allPatchFilesUserOwned = async (patch) => {
2793
+ if (patch.user_owned) return true;
2794
+ if (patch.files.length === 0) return false;
2795
+ for (const file of patch.files) {
2796
+ if (!await this.isFileUserOwned(file, patch, currentGen, fernignorePatterns, warnedGens)) {
2797
+ return false;
2798
+ }
2799
+ }
2800
+ return true;
2801
+ };
2252
2802
  for (const patch of patches) {
2803
+ if (patch.base_generation && patch.base_generation !== currentGen && !warnedGens.has(patch.base_generation)) {
2804
+ if (!await isReachable(patch.base_generation)) {
2805
+ warnedGens.add(patch.base_generation);
2806
+ this.warnings.push(
2807
+ `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.`
2808
+ );
2809
+ }
2810
+ }
2253
2811
  if (patch.status != null) {
2254
2812
  delete patch.status;
2255
2813
  continue;
@@ -2259,6 +2817,7 @@ var ReplayService = class {
2259
2817
  const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
2260
2818
  if (diff === null) continue;
2261
2819
  if (!diff.trim()) {
2820
+ if (await allPatchFilesUserOwned(patch)) continue;
2262
2821
  this.lockManager.removePatch(patch.id);
2263
2822
  contentRefreshed++;
2264
2823
  continue;
@@ -2270,6 +2829,12 @@ var ReplayService = class {
2270
2829
  if (hasStaleMarkers) {
2271
2830
  continue;
2272
2831
  }
2832
+ const isProtected = patch.files.some(
2833
+ (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || (0, import_minimatch2.minimatch)(file, p))
2834
+ );
2835
+ if (isProtected) {
2836
+ continue;
2837
+ }
2273
2838
  const newContentHash = this.detector.computeContentHash(diff);
2274
2839
  if (newContentHash !== patch.content_hash) {
2275
2840
  const filesOutput = await this.git.exec(["diff", "--name-only", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
@@ -2295,6 +2860,13 @@ var ReplayService = class {
2295
2860
  const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
2296
2861
  if (diff === null) continue;
2297
2862
  if (!diff.trim()) {
2863
+ if (await allPatchFilesUserOwned(patch)) {
2864
+ try {
2865
+ this.lockManager.updatePatch(patch.id, { base_generation: currentGen });
2866
+ } catch {
2867
+ }
2868
+ continue;
2869
+ }
2298
2870
  this.lockManager.removePatch(patch.id);
2299
2871
  conflictAbsorbed++;
2300
2872
  continue;
@@ -2806,7 +3378,10 @@ function ensureGitattributesEntries(outputDir) {
2806
3378
  (0, import_node_fs4.writeFileSync)(gitattributesPath, content, "utf-8");
2807
3379
  }
2808
3380
  function computeContentHash(patchContent) {
2809
- const normalized = patchContent.split("\n").filter((line) => !line.startsWith("From ") && !line.startsWith("index ") && !line.startsWith("Date: ")).join("\n");
3381
+ const lines = patchContent.split("\n");
3382
+ const diffStart = lines.findIndex((l) => l.startsWith("diff --git "));
3383
+ const relevantLines = diffStart > 0 ? lines.slice(diffStart) : lines;
3384
+ const normalized = relevantLines.filter((line) => !line.startsWith("index ")).join("\n").replace(/\n-- \n[\d]+\.[\d]+[^\n]*\s*$/, "");
2810
3385
  return `sha256:${(0, import_node_crypto3.createHash)("sha256").update(normalized).digest("hex")}`;
2811
3386
  }
2812
3387
 
@@ -3118,6 +3693,7 @@ function status(outputDir) {
3118
3693
  }
3119
3694
  // Annotate the CommonJS export names for ESM import in node:
3120
3695
  0 && (module.exports = {
3696
+ CREDENTIAL_PATTERNS,
3121
3697
  FERN_BOT_EMAIL,
3122
3698
  FERN_BOT_LOGIN,
3123
3699
  FERN_BOT_NAME,
@@ -3129,15 +3705,18 @@ function status(outputDir) {
3129
3705
  ReplayCommitter,
3130
3706
  ReplayDetector,
3131
3707
  ReplayService,
3708
+ SENSITIVE_FILE_PATTERNS,
3132
3709
  bootstrap,
3133
3710
  forget,
3134
3711
  isGenerationCommit,
3135
3712
  isReplayCommit,
3136
3713
  isRevertCommit,
3714
+ isSensitiveFile,
3137
3715
  parseRevertedMessage,
3138
3716
  parseRevertedSha,
3139
3717
  reset,
3140
3718
  resolve,
3719
+ scanForCredentials,
3141
3720
  status,
3142
3721
  threeWayMerge
3143
3722
  });