@fern-api/replay 0.10.4 → 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;
@@ -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;
@@ -1847,6 +2181,7 @@ var ReplayService = class {
1847
2181
  this.committer = new ReplayCommitter(git, outputDir);
1848
2182
  }
1849
2183
  async runReplay(options) {
2184
+ this.warnings = [];
1850
2185
  if (options?.skipApplication) {
1851
2186
  return this.handleSkipApplication(options);
1852
2187
  }
@@ -1920,6 +2255,7 @@ var ReplayService = class {
1920
2255
  );
1921
2256
  }
1922
2257
  this.lockManager.save();
2258
+ this.detector.warnings.push(...this.lockManager.warnings);
1923
2259
  }
1924
2260
  determineFlow() {
1925
2261
  try {
@@ -1986,6 +2322,7 @@ var ReplayService = class {
1986
2322
  }
1987
2323
  this.lockManager.setReplaySkippedAt((/* @__PURE__ */ new Date()).toISOString());
1988
2324
  this.lockManager.save();
2325
+ const credentialWarnings = [...this.lockManager.warnings];
1989
2326
  if (!options?.stageOnly) {
1990
2327
  await this.committer.commitReplay(0);
1991
2328
  } else {
@@ -1997,13 +2334,15 @@ var ReplayService = class {
1997
2334
  patchesApplied: 0,
1998
2335
  patchesWithConflicts: 0,
1999
2336
  patchesSkipped: 0,
2000
- conflicts: []
2337
+ conflicts: [],
2338
+ warnings: credentialWarnings.length > 0 ? credentialWarnings : void 0
2001
2339
  };
2002
2340
  }
2003
2341
  async handleNoPatchesRegeneration(options) {
2004
- const { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
2005
- const warnings = [...this.detector.warnings];
2342
+ let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
2343
+ const detectorWarnings = [...this.detector.warnings];
2006
2344
  if (options?.dryRun) {
2345
+ const dryRunWarnings = [...detectorWarnings, ...this.warnings];
2007
2346
  return {
2008
2347
  flow: "no-patches",
2009
2348
  patchesDetected: newPatches.length,
@@ -2013,9 +2352,26 @@ var ReplayService = class {
2013
2352
  patchesReverted: revertedPatchIds.length,
2014
2353
  conflicts: [],
2015
2354
  wouldApply: newPatches,
2016
- warnings: warnings.length > 0 ? warnings : void 0
2355
+ warnings: dryRunWarnings.length > 0 ? dryRunWarnings : void 0
2017
2356
  };
2018
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
+ }
2019
2375
  for (const id of revertedPatchIds) {
2020
2376
  try {
2021
2377
  this.lockManager.removePatch(id);
@@ -2034,6 +2390,7 @@ var ReplayService = class {
2034
2390
  let results = [];
2035
2391
  if (newPatches.length > 0) {
2036
2392
  results = await this.applicator.applyPatches(newPatches);
2393
+ this._lastApplyResults = results;
2037
2394
  this.revertConflictingFiles(results);
2038
2395
  for (const result of results) {
2039
2396
  if (result.status === "conflict") {
@@ -2048,6 +2405,7 @@ var ReplayService = class {
2048
2405
  }
2049
2406
  }
2050
2407
  this.lockManager.save();
2408
+ this.warnings.push(...this.lockManager.warnings);
2051
2409
  if (newPatches.length > 0) {
2052
2410
  if (!options?.stageOnly) {
2053
2411
  const appliedCount = results.filter((r) => r.status === "applied").length;
@@ -2056,13 +2414,14 @@ var ReplayService = class {
2056
2414
  await this.committer.stageAll();
2057
2415
  }
2058
2416
  }
2417
+ const warnings = [...detectorWarnings, ...this.warnings];
2059
2418
  return this.buildReport("no-patches", newPatches, results, options, warnings, rebaseCounts, void 0, revertedPatchIds.length);
2060
2419
  }
2061
2420
  async handleNormalRegeneration(options) {
2062
2421
  if (options?.dryRun) {
2063
2422
  const existingPatches2 = this.lockManager.getPatches();
2064
2423
  const { patches: newPatches2, revertedPatchIds: dryRunReverted } = await this.detector.detectNewPatches();
2065
- const warnings2 = [...this.detector.warnings];
2424
+ const warnings2 = [...this.detector.warnings, ...this.warnings];
2066
2425
  const allPatches2 = [...existingPatches2, ...newPatches2];
2067
2426
  return {
2068
2427
  flow: "normal-regeneration",
@@ -2094,7 +2453,7 @@ var ReplayService = class {
2094
2453
  }
2095
2454
  existingPatches = this.lockManager.getPatches();
2096
2455
  let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
2097
- const warnings = [...this.detector.warnings];
2456
+ const detectorWarnings = [...this.detector.warnings];
2098
2457
  if (removedByPreRebase.length > 0) {
2099
2458
  const removedOriginalCommits = new Set(removedByPreRebase.map((p) => p.original_commit));
2100
2459
  const removedOriginalMessages = new Set(removedByPreRebase.map((p) => p.original_message));
@@ -2126,6 +2485,7 @@ var ReplayService = class {
2126
2485
  const genRecord = await this.committer.createGenerationRecord(commitOpts);
2127
2486
  this.lockManager.addGeneration(genRecord);
2128
2487
  const results = await this.applicator.applyPatches(allPatches);
2488
+ this._lastApplyResults = results;
2129
2489
  this.revertConflictingFiles(results);
2130
2490
  for (const result of results) {
2131
2491
  if (result.status === "conflict") {
@@ -2147,12 +2507,14 @@ var ReplayService = class {
2147
2507
  }
2148
2508
  }
2149
2509
  this.lockManager.save();
2510
+ this.warnings.push(...this.lockManager.warnings);
2150
2511
  if (options?.stageOnly) {
2151
2512
  await this.committer.stageAll();
2152
2513
  } else {
2153
2514
  const appliedCount = results.filter((r) => r.status === "applied").length;
2154
2515
  await this.committer.commitReplay(appliedCount, allPatches);
2155
2516
  }
2517
+ const warnings = [...detectorWarnings, ...this.warnings];
2156
2518
  return this.buildReport(
2157
2519
  "normal-regeneration",
2158
2520
  allPatches,
@@ -2176,6 +2538,7 @@ var ReplayService = class {
2176
2538
  let keptAsUserOwned = 0;
2177
2539
  const seenContentHashes = /* @__PURE__ */ new Set();
2178
2540
  const absorbedPatchIds = /* @__PURE__ */ new Set();
2541
+ const fernignorePatterns = this.readFernignorePatterns();
2179
2542
  for (const result of results) {
2180
2543
  if (result.resolvedFiles && Object.keys(result.resolvedFiles).length > 0) {
2181
2544
  const patch = result.patch;
@@ -2196,34 +2559,78 @@ var ReplayService = class {
2196
2559
  const patch = result.patch;
2197
2560
  if (patch.base_generation === currentGenSha) continue;
2198
2561
  try {
2199
- const fernignorePatterns = this.readFernignorePatterns();
2200
- 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(
2201
2569
  patch.files.map(async (file) => {
2202
- if (file === ".fernignore") {
2570
+ if (file === ".fernignore") return true;
2571
+ if (fernignorePatterns.some((p) => file === p || (0, import_minimatch2.minimatch)(file, p))) {
2203
2572
  return true;
2204
2573
  }
2205
- 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)) {
2206
2576
  return true;
2207
2577
  }
2208
2578
  const content = await this.git.showFile(currentGenSha, file);
2209
2579
  return content === null;
2210
2580
  })
2211
2581
  );
2212
- const hasUserOwnedFiles = isUserOwned.some(Boolean);
2213
- if (hasUserOwnedFiles) {
2214
- this.lockManager.updatePatch(patch.id, {
2215
- base_generation: currentGenSha
2216
- });
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;
2217
2604
  keptAsUserOwned++;
2605
+ if (baseChanged) repointed++;
2218
2606
  continue;
2219
2607
  }
2220
2608
  const diff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
2221
- 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
+ }
2222
2620
  this.lockManager.removePatch(patch.id);
2223
2621
  absorbedPatchIds.add(patch.id);
2224
2622
  absorbed++;
2225
2623
  continue;
2226
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
+ }
2227
2634
  const newContentHash = this.detector.computeContentHash(diff);
2228
2635
  if (seenContentHashes.has(newContentHash)) {
2229
2636
  this.lockManager.removePatch(patch.id);
@@ -2232,17 +2639,89 @@ var ReplayService = class {
2232
2639
  continue;
2233
2640
  }
2234
2641
  seenContentHashes.add(newContentHash);
2235
- this.lockManager.updatePatch(patch.id, {
2236
- base_generation: currentGenSha,
2237
- patch_content: diff,
2238
- content_hash: newContentHash
2239
- });
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
+ }
2240
2653
  contentRebased++;
2241
2654
  } catch {
2242
2655
  }
2243
2656
  }
2244
2657
  return { absorbed, repointed, contentRebased, keptAsUserOwned, absorbedPatchIds };
2245
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
+ }
2246
2725
  /**
2247
2726
  * For conflict patches with mixed results (some files merged, some conflicted),
2248
2727
  * check if the cleanly merged files were absorbed by the generator (empty diff).
@@ -2256,13 +2735,14 @@ var ReplayService = class {
2256
2735
  const cleanFiles = result.fileResults.filter((f) => f.status === "merged").map((f) => f.file);
2257
2736
  if (cleanFiles.length === 0) return;
2258
2737
  const patch = result.patch;
2738
+ if (patch.user_owned) return;
2259
2739
  const fernignorePatterns = this.readFernignorePatterns();
2740
+ const warnedGens = /* @__PURE__ */ new Set();
2260
2741
  const generatorCleanFiles = [];
2261
2742
  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;
2743
+ if (await this.isFileUserOwned(file, patch, currentGenSha, fernignorePatterns, warnedGens)) {
2744
+ continue;
2745
+ }
2266
2746
  generatorCleanFiles.push(file);
2267
2747
  }
2268
2748
  if (generatorCleanFiles.length === 0) return;
@@ -2299,7 +2779,35 @@ var ReplayService = class {
2299
2779
  let conflictResolved = 0;
2300
2780
  let conflictAbsorbed = 0;
2301
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
+ };
2302
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
+ }
2303
2811
  if (patch.status != null) {
2304
2812
  delete patch.status;
2305
2813
  continue;
@@ -2309,6 +2817,7 @@ var ReplayService = class {
2309
2817
  const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
2310
2818
  if (diff === null) continue;
2311
2819
  if (!diff.trim()) {
2820
+ if (await allPatchFilesUserOwned(patch)) continue;
2312
2821
  this.lockManager.removePatch(patch.id);
2313
2822
  contentRefreshed++;
2314
2823
  continue;
@@ -2320,6 +2829,12 @@ var ReplayService = class {
2320
2829
  if (hasStaleMarkers) {
2321
2830
  continue;
2322
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
+ }
2323
2838
  const newContentHash = this.detector.computeContentHash(diff);
2324
2839
  if (newContentHash !== patch.content_hash) {
2325
2840
  const filesOutput = await this.git.exec(["diff", "--name-only", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
@@ -2345,6 +2860,13 @@ var ReplayService = class {
2345
2860
  const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
2346
2861
  if (diff === null) continue;
2347
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
+ }
2348
2870
  this.lockManager.removePatch(patch.id);
2349
2871
  conflictAbsorbed++;
2350
2872
  continue;
@@ -2856,7 +3378,10 @@ function ensureGitattributesEntries(outputDir) {
2856
3378
  (0, import_node_fs4.writeFileSync)(gitattributesPath, content, "utf-8");
2857
3379
  }
2858
3380
  function computeContentHash(patchContent) {
2859
- 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*$/, "");
2860
3385
  return `sha256:${(0, import_node_crypto3.createHash)("sha256").update(normalized).digest("hex")}`;
2861
3386
  }
2862
3387
 
@@ -3168,6 +3693,7 @@ function status(outputDir) {
3168
3693
  }
3169
3694
  // Annotate the CommonJS export names for ESM import in node:
3170
3695
  0 && (module.exports = {
3696
+ CREDENTIAL_PATTERNS,
3171
3697
  FERN_BOT_EMAIL,
3172
3698
  FERN_BOT_LOGIN,
3173
3699
  FERN_BOT_NAME,
@@ -3179,15 +3705,18 @@ function status(outputDir) {
3179
3705
  ReplayCommitter,
3180
3706
  ReplayDetector,
3181
3707
  ReplayService,
3708
+ SENSITIVE_FILE_PATTERNS,
3182
3709
  bootstrap,
3183
3710
  forget,
3184
3711
  isGenerationCommit,
3185
3712
  isReplayCommit,
3186
3713
  isRevertCommit,
3714
+ isSensitiveFile,
3187
3715
  parseRevertedMessage,
3188
3716
  parseRevertedSha,
3189
3717
  reset,
3190
3718
  resolve,
3719
+ scanForCredentials,
3191
3720
  status,
3192
3721
  threeWayMerge
3193
3722
  });