@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.js CHANGED
@@ -77,13 +77,30 @@ var init_GitClient = __esm({
77
77
  const output = await this.exec(["rev-parse", `${commitSha}^@`]);
78
78
  return output.trim().split("\n").filter(Boolean);
79
79
  }
80
+ /**
81
+ * Detects renames and copies between two trees.
82
+ *
83
+ * Both `R` (rename) and `C` (copy) entries are returned as `{from, to}` pairs.
84
+ * Copies are treated as rename-equivalent because the generator may produce the
85
+ * new path while the old path remains in the tree (e.g., imperfect cleanup,
86
+ * customer edits at the old path, or test helpers that only write new files).
87
+ * Callers that care about "is the source still present" should verify via
88
+ * `showFile(toTree, from)`.
89
+ */
80
90
  async detectRenames(fromTree, toTree) {
81
91
  try {
82
- const output = await this.exec(["diff", "--find-renames", "--name-status", fromTree, toTree]);
92
+ const output = await this.exec([
93
+ "diff",
94
+ "--find-renames",
95
+ "--find-copies",
96
+ "--name-status",
97
+ fromTree,
98
+ toTree
99
+ ]);
83
100
  const renames = [];
84
101
  for (const line of output.trim().split("\n")) {
85
102
  if (!line) continue;
86
- if (line.startsWith("R")) {
103
+ if (line.startsWith("R") || line.startsWith("C")) {
87
104
  const parts = line.split(" ");
88
105
  if (parts.length >= 3) {
89
106
  renames.push({ from: parts[1], to: parts[2] });
@@ -378,6 +395,39 @@ var init_HybridReconstruction = __esm({
378
395
  }
379
396
  });
380
397
 
398
+ // src/credentials.ts
399
+ var CREDENTIAL_PATTERNS = [
400
+ { name: "PRIVATE KEY block", re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
401
+ { name: "OPENSSH key", re: /-----BEGIN OPENSSH PRIVATE KEY-----/ },
402
+ { name: "RSA key", re: /-----BEGIN RSA PRIVATE KEY-----/ },
403
+ { name: "CERTIFICATE", re: /-----BEGIN CERTIFICATE-----/ },
404
+ { name: "TOKEN=", re: /\bTOKEN\s*=\s*["']?[A-Za-z0-9._\-+/=]{10,}/ },
405
+ { name: "api_key=", re: /\bapi[_-]?key\s*=\s*["']?[A-Za-z0-9._\-+/=]{10,}/i },
406
+ { name: "password=", re: /\bpassword\s*=\s*["']?[^\s"']{6,}/i },
407
+ { name: "AWS_SECRET_ACCESS_KEY", re: /AWS_SECRET_ACCESS_KEY/ },
408
+ { name: "JWT (eyJ...)", re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/ }
409
+ ];
410
+ var SENSITIVE_FILE_PATTERNS = [
411
+ /(^|\/)\.env(\.[^/]+)?$/,
412
+ /\.pem$/,
413
+ /\.key$/,
414
+ /\.pkce_state\.json$/,
415
+ /(^|\/)id_rsa$/,
416
+ /(^|\/)id_ed25519$/
417
+ ];
418
+ function isSensitiveFile(filePath) {
419
+ return SENSITIVE_FILE_PATTERNS.some((re) => re.test(filePath));
420
+ }
421
+ function scanForCredentials(content) {
422
+ const matches = [];
423
+ for (const { name, re } of CREDENTIAL_PATTERNS) {
424
+ if (re.test(content)) {
425
+ matches.push(name);
426
+ }
427
+ }
428
+ return matches;
429
+ }
430
+
381
431
  // src/index.ts
382
432
  init_GitClient();
383
433
 
@@ -424,6 +474,7 @@ var LockfileNotFoundError = class extends Error {
424
474
  var LockfileManager = class {
425
475
  outputDir;
426
476
  lock = null;
477
+ warnings = [];
427
478
  constructor(outputDir) {
428
479
  this.outputDir = outputDir;
429
480
  }
@@ -472,6 +523,27 @@ var LockfileManager = class {
472
523
  if (!this.lock) {
473
524
  throw new Error("No lockfile data to save. Call read() or initialize() first.");
474
525
  }
526
+ this.warnings.length = 0;
527
+ const cleanPatches = [];
528
+ for (const patch of this.lock.patches) {
529
+ const credentialMatches = scanForCredentials(patch.patch_content);
530
+ const sensitiveFiles = patch.files.filter((f) => isSensitiveFile(f));
531
+ if (credentialMatches.length > 0 || sensitiveFiles.length > 0) {
532
+ const reasons = [];
533
+ if (credentialMatches.length > 0) {
534
+ reasons.push(`credential patterns: ${credentialMatches.join(", ")}`);
535
+ }
536
+ if (sensitiveFiles.length > 0) {
537
+ reasons.push(`sensitive files: ${sensitiveFiles.join(", ")}`);
538
+ }
539
+ this.warnings.push(
540
+ `Patch ${patch.id} removed from lockfile: ${reasons.join("; ")}. This prevents credential exposure in committed lockfiles.`
541
+ );
542
+ } else {
543
+ cleanPatches.push(patch);
544
+ }
545
+ }
546
+ this.lock.patches = cleanPatches;
475
547
  const dir = dirname(this.lockfilePath);
476
548
  if (!existsSync(dir)) {
477
549
  mkdirSync(dir, { recursive: true });
@@ -576,6 +648,37 @@ var LockfileManager = class {
576
648
  // src/ReplayDetector.ts
577
649
  import { createHash } from "crypto";
578
650
  var INFRASTRUCTURE_FILES = /* @__PURE__ */ new Set([".fernignore"]);
651
+ function parsePatchFileHeaders(patchContent) {
652
+ const results = [];
653
+ let currentPath = null;
654
+ let currentIsCreate = false;
655
+ let currentIsDelete = false;
656
+ const flush = () => {
657
+ if (currentPath !== null) {
658
+ results.push({ path: currentPath, isCreate: currentIsCreate, isDelete: currentIsDelete });
659
+ }
660
+ };
661
+ for (const line of patchContent.split("\n")) {
662
+ if (line.startsWith("diff --git ")) {
663
+ flush();
664
+ currentPath = null;
665
+ currentIsCreate = false;
666
+ currentIsDelete = false;
667
+ const match = line.match(/^diff --git a\/(.+?) b\/(.+?)$/);
668
+ if (match) {
669
+ currentPath = match[2] ?? null;
670
+ }
671
+ } else if (currentPath !== null) {
672
+ if (line.startsWith("new file mode ")) {
673
+ currentIsCreate = true;
674
+ } else if (line.startsWith("deleted file mode ")) {
675
+ currentIsDelete = true;
676
+ }
677
+ }
678
+ }
679
+ flush();
680
+ return results;
681
+ }
579
682
  var ReplayDetector = class {
580
683
  git;
581
684
  lockManager;
@@ -613,6 +716,7 @@ var ReplayDetector = class {
613
716
  }
614
717
  const log = await this.git.exec([
615
718
  "log",
719
+ "--first-parent",
616
720
  "--format=%H%x00%an%x00%ae%x00%s",
617
721
  `${lastGen.commit_sha}..HEAD`,
618
722
  "--",
@@ -628,11 +732,20 @@ var ReplayDetector = class {
628
732
  if (isGenerationCommit(commit)) {
629
733
  continue;
630
734
  }
631
- const parents = await this.git.getCommitParents(commit.sha);
632
- if (parents.length > 1) {
735
+ if (lock.patches.find((p) => p.original_commit === commit.sha)) {
633
736
  continue;
634
737
  }
635
- if (lock.patches.find((p) => p.original_commit === commit.sha)) {
738
+ const parents = await this.git.getCommitParents(commit.sha);
739
+ if (parents.length > 1) {
740
+ const compositePatch = await this.createMergeCompositePatch({
741
+ mergeCommit: commit,
742
+ firstParent: parents[0],
743
+ lastGen,
744
+ lock
745
+ });
746
+ if (compositePatch) {
747
+ newPatches.push(compositePatch);
748
+ }
636
749
  continue;
637
750
  }
638
751
  let patchContent;
@@ -644,12 +757,31 @@ var ReplayDetector = class {
644
757
  );
645
758
  continue;
646
759
  }
647
- const contentHash = this.computeContentHash(patchContent);
760
+ let contentHash = this.computeContentHash(patchContent);
648
761
  if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
649
762
  continue;
650
763
  }
651
764
  const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
652
- const files = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f));
765
+ const allFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
766
+ const sensitiveFiles = allFiles.filter((f) => isSensitiveFile(f));
767
+ const files = allFiles.filter((f) => !isSensitiveFile(f));
768
+ if (sensitiveFiles.length > 0) {
769
+ this.warnings.push(
770
+ `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
771
+ );
772
+ if (files.length > 0) {
773
+ const parentSha = parents[0];
774
+ if (parentSha) {
775
+ try {
776
+ patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
777
+ } catch {
778
+ continue;
779
+ }
780
+ if (!patchContent.trim()) continue;
781
+ contentHash = this.computeContentHash(patchContent);
782
+ }
783
+ }
784
+ }
653
785
  if (files.length === 0) {
654
786
  continue;
655
787
  }
@@ -661,14 +793,16 @@ var ReplayDetector = class {
661
793
  original_author: `${commit.authorName} <${commit.authorEmail}>`,
662
794
  base_generation: lastGen.commit_sha,
663
795
  files,
664
- patch_content: patchContent
796
+ patch_content: patchContent,
797
+ ...this.isCreationOnlyPatch(patchContent) ? { user_owned: true } : {}
665
798
  });
666
799
  }
667
- newPatches.reverse();
800
+ const survivingPatches = await this.collapseNetZeroFiles(newPatches);
801
+ survivingPatches.reverse();
668
802
  const revertedPatchIdSet = /* @__PURE__ */ new Set();
669
803
  const revertIndicesToRemove = /* @__PURE__ */ new Set();
670
- for (let i = 0; i < newPatches.length; i++) {
671
- const patch = newPatches[i];
804
+ for (let i = 0; i < survivingPatches.length; i++) {
805
+ const patch = survivingPatches[i];
672
806
  if (!isRevertCommit(patch.original_message)) continue;
673
807
  let body = "";
674
808
  try {
@@ -697,7 +831,7 @@ var ReplayDetector = class {
697
831
  if (matchedExisting) continue;
698
832
  let matchedNew = false;
699
833
  if (revertedSha) {
700
- const idx = newPatches.findIndex(
834
+ const idx = survivingPatches.findIndex(
701
835
  (p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_commit === revertedSha
702
836
  );
703
837
  if (idx !== -1) {
@@ -707,7 +841,7 @@ var ReplayDetector = class {
707
841
  }
708
842
  }
709
843
  if (!matchedNew && revertedMessage) {
710
- const idx = newPatches.findIndex(
844
+ const idx = survivingPatches.findIndex(
711
845
  (p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_message === revertedMessage
712
846
  );
713
847
  if (idx !== -1) {
@@ -719,18 +853,155 @@ var ReplayDetector = class {
719
853
  revertIndicesToRemove.add(i);
720
854
  }
721
855
  }
722
- const filteredPatches = newPatches.filter((_, i) => !revertIndicesToRemove.has(i));
856
+ const filteredPatches = survivingPatches.filter((_, i) => !revertIndicesToRemove.has(i));
723
857
  return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
724
858
  }
725
859
  /**
726
860
  * Compute content hash for deduplication.
727
- * Removes commit SHA line and index lines before hashing,
728
- * so rebased commits with same content produce the same hash.
861
+ *
862
+ * Produces a format-agnostic hash so both `git format-patch` output
863
+ * (email-wrapped) and plain `git diff` output hash to the same value
864
+ * when the underlying diff hunks are identical.
865
+ *
866
+ * Normalization:
867
+ * 1. Strip email wrapper: everything before the first `diff --git` line
868
+ * (From:, Subject:, Date:, diffstat, blank separators).
869
+ * 2. Strip `index` lines (blob SHA pairs change across rebases).
870
+ * 3. Strip trailing git version marker (`-- \n<version>`).
871
+ *
872
+ * This ensures content hashes match across the no-patches and normal-
873
+ * regeneration flows, which store formatPatch and git-diff formats
874
+ * respectively (FER-9850, D5-D9).
729
875
  */
730
876
  computeContentHash(patchContent) {
731
- const normalized = patchContent.split("\n").filter((line) => !line.startsWith("From ") && !line.startsWith("index ") && !line.startsWith("Date: ")).join("\n");
877
+ const lines = patchContent.split("\n");
878
+ const diffStart = lines.findIndex((l) => l.startsWith("diff --git "));
879
+ const relevantLines = diffStart > 0 ? lines.slice(diffStart) : lines;
880
+ const normalized = relevantLines.filter((line) => !line.startsWith("index ")).join("\n").replace(/\n-- \n[\d]+\.[\d]+[^\n]*\s*$/, "");
732
881
  return `sha256:${createHash("sha256").update(normalized).digest("hex")}`;
733
882
  }
883
+ /**
884
+ * FER-9805 — Drop patches whose files were transiently created and then
885
+ * deleted within the current linear detection window, and are absent from
886
+ * HEAD at the end of the window.
887
+ *
888
+ * Rationale: on a merge commit, createMergeCompositePatch already diffs
889
+ * firstParent..mergeCommit so net-zero files never enter the composite. On
890
+ * linear history each commit becomes its own patch, so a file that was
891
+ * briefly added and later removed survives as a create+delete pair whose
892
+ * cumulative diff is empty. We drop such patches before they reach the
893
+ * lockfile.
894
+ *
895
+ * A file is "transient" when:
896
+ * 1. some patch in the window sets `new file mode` for it (a creation), AND
897
+ * 2. some patch in the window sets `deleted file mode` for it (a deletion), AND
898
+ * 3. it is absent from HEAD's tree.
899
+ *
900
+ * The HEAD guard (#3) prevents false positives when a file is deleted
901
+ * and then recreated with different content — the cumulative diff of
902
+ * such a pair is non-empty and must be preserved.
903
+ *
904
+ * This only drops patches whose `files` are a subset of the transient
905
+ * set. A partial-transient patch (one that touches a transient file
906
+ * alongside a persistent one) is left unmodified; surgical stripping of
907
+ * single files from a multi-file patch would require a follow-up that
908
+ * regenerates the patch content via `git format-patch -- <files>`. No
909
+ * current failing test exercises this, and leaving the patch intact
910
+ * preserves today's behaviour. TODO(FER-9805): revisit if a future
911
+ * adversarial test demands per-file stripping.
912
+ */
913
+ async collapseNetZeroFiles(patches) {
914
+ if (patches.length === 0) return patches;
915
+ const stateByFile = /* @__PURE__ */ new Map();
916
+ for (const patch of patches) {
917
+ for (const header of parsePatchFileHeaders(patch.patch_content)) {
918
+ if (!header.isCreate && !header.isDelete) continue;
919
+ const prior = stateByFile.get(header.path) ?? { created: false, deleted: false };
920
+ if (header.isCreate) prior.created = true;
921
+ if (header.isDelete) prior.deleted = true;
922
+ stateByFile.set(header.path, prior);
923
+ }
924
+ }
925
+ let anyCandidate = false;
926
+ for (const state of stateByFile.values()) {
927
+ if (state.created && state.deleted) {
928
+ anyCandidate = true;
929
+ break;
930
+ }
931
+ }
932
+ if (!anyCandidate) return patches;
933
+ const headFiles = await this.listHeadFiles();
934
+ const transient = /* @__PURE__ */ new Set();
935
+ for (const [file, state] of stateByFile) {
936
+ if (state.created && state.deleted && !headFiles.has(file)) {
937
+ transient.add(file);
938
+ }
939
+ }
940
+ if (transient.size === 0) return patches;
941
+ return patches.filter((patch) => !patch.files.every((f) => transient.has(f)));
942
+ }
943
+ /**
944
+ * List every tracked path at HEAD (one `git ls-tree -r` invocation).
945
+ * Returns an empty Set if HEAD is unborn or the invocation fails — the
946
+ * caller then treats every candidate as "not in HEAD" and may collapse
947
+ * more aggressively. On typical SDK repos this is a single sub-10ms call.
948
+ */
949
+ async listHeadFiles() {
950
+ try {
951
+ const out = await this.git.exec(["ls-tree", "-r", "--name-only", "HEAD"]);
952
+ return new Set(out.split("\n").map((l) => l.trim()).filter(Boolean));
953
+ } catch {
954
+ return /* @__PURE__ */ new Set();
955
+ }
956
+ }
957
+ /**
958
+ * Create a single composite patch for a merge commit by diffing the merge
959
+ * commit against its first parent. This captures the net change of the
960
+ * entire merged branch as one patch, eliminating accumulator chaining and
961
+ * zero-net-change ghost conflicts.
962
+ */
963
+ async createMergeCompositePatch(input) {
964
+ const { mergeCommit, firstParent, lastGen, lock } = input;
965
+ const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
966
+ const filesOutput = await this.git.exec([
967
+ "diff",
968
+ "--name-only",
969
+ firstParent,
970
+ mergeCommit.sha
971
+ ]);
972
+ const allMergeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
973
+ const sensitiveMergeFiles = allMergeFiles.filter((f) => isSensitiveFile(f));
974
+ const files = allMergeFiles.filter((f) => !isSensitiveFile(f));
975
+ if (sensitiveMergeFiles.length > 0) {
976
+ this.warnings.push(
977
+ `Sensitive file(s) excluded from merge patch for commit ${mergeCommit.sha.slice(0, 7)}: ${sensitiveMergeFiles.join(", ")}. These files will not be tracked by replay.`
978
+ );
979
+ }
980
+ if (files.length === 0) return null;
981
+ const diff = await this.git.exec([
982
+ "diff",
983
+ firstParent,
984
+ mergeCommit.sha,
985
+ "--",
986
+ ...files
987
+ ]);
988
+ if (!diff.trim()) return null;
989
+ const contentHash = this.computeContentHash(diff);
990
+ if (lock.patches.some((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
991
+ return null;
992
+ }
993
+ return {
994
+ id: `patch-merge-${mergeCommit.sha.slice(0, 8)}`,
995
+ content_hash: contentHash,
996
+ original_commit: mergeCommit.sha,
997
+ original_message: mergeCommit.message,
998
+ original_author: `${mergeCommit.authorName} <${mergeCommit.authorEmail}>`,
999
+ base_generation: lastGen.commit_sha,
1000
+ files,
1001
+ patch_content: diff,
1002
+ ...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
1003
+ };
1004
+ }
734
1005
  /**
735
1006
  * Detect patches via tree diff for non-linear history. Returns a composite patch.
736
1007
  * Revert reconciliation is skipped here because tree-diff produces a single composite
@@ -742,7 +1013,14 @@ var ReplayDetector = class {
742
1013
  return this.detectPatchesViaCommitScan();
743
1014
  }
744
1015
  const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
745
- const files = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
1016
+ const allTreeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
1017
+ const sensitiveTreeFiles = allTreeFiles.filter((f) => isSensitiveFile(f));
1018
+ const files = allTreeFiles.filter((f) => !isSensitiveFile(f));
1019
+ if (sensitiveTreeFiles.length > 0) {
1020
+ this.warnings.push(
1021
+ `Sensitive file(s) excluded from composite patch: ${sensitiveTreeFiles.join(", ")}. These files will not be tracked by replay.`
1022
+ );
1023
+ }
746
1024
  if (files.length === 0) return { patches: [], revertedPatchIds: [] };
747
1025
  const diff = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
748
1026
  if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
@@ -762,7 +1040,8 @@ var ReplayDetector = class {
762
1040
  // reference to find base file content. diffBase may be the tree_hash.
763
1041
  base_generation: commitKnownMissing ? diffBase : lastGen.commit_sha,
764
1042
  files,
765
- patch_content: diff
1043
+ patch_content: diff,
1044
+ ...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
766
1045
  };
767
1046
  return { patches: [compositePatch], revertedPatchIds: [] };
768
1047
  }
@@ -809,7 +1088,7 @@ var ReplayDetector = class {
809
1088
  } catch {
810
1089
  continue;
811
1090
  }
812
- const contentHash = this.computeContentHash(patchContent);
1091
+ let contentHash = this.computeContentHash(patchContent);
813
1092
  if (existingHashes.has(contentHash) || forgottenHashes.has(contentHash)) {
814
1093
  continue;
815
1094
  }
@@ -817,7 +1096,26 @@ var ReplayDetector = class {
817
1096
  continue;
818
1097
  }
819
1098
  const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
820
- const files = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f));
1099
+ const allScanFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f));
1100
+ const sensitiveScanFiles = allScanFiles.filter((f) => isSensitiveFile(f));
1101
+ const files = allScanFiles.filter((f) => !isSensitiveFile(f));
1102
+ if (sensitiveScanFiles.length > 0) {
1103
+ this.warnings.push(
1104
+ `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveScanFiles.join(", ")}. These files will not be tracked by replay.`
1105
+ );
1106
+ if (files.length > 0) {
1107
+ if (parents.length === 0) {
1108
+ continue;
1109
+ }
1110
+ try {
1111
+ patchContent = await this.git.exec(["diff", parents[0], commit.sha, "--", ...files]);
1112
+ } catch {
1113
+ continue;
1114
+ }
1115
+ if (!patchContent.trim()) continue;
1116
+ contentHash = this.computeContentHash(patchContent);
1117
+ }
1118
+ }
821
1119
  if (files.length === 0) {
822
1120
  continue;
823
1121
  }
@@ -1149,6 +1447,20 @@ var ReplayApplicator = class {
1149
1447
  resetAccumulator() {
1150
1448
  this.fileTheirsAccumulator.clear();
1151
1449
  }
1450
+ /**
1451
+ * @internal Test-only.
1452
+ * Returns a defensive copy of the inter-patch accumulator. Used by the
1453
+ * adversarial test suite (FER-9791) to assert invariant I2: after every
1454
+ * applied patch, the accumulator has an entry for each file the patch
1455
+ * touched, and the entry's content matches on-disk.
1456
+ */
1457
+ getAccumulatorSnapshot() {
1458
+ const snapshot = /* @__PURE__ */ new Map();
1459
+ for (const [path, entry] of this.fileTheirsAccumulator) {
1460
+ snapshot.set(path, { content: entry.content, baseGeneration: entry.baseGeneration });
1461
+ }
1462
+ return snapshot;
1463
+ }
1152
1464
  /**
1153
1465
  * Apply all patches, returning results for each.
1154
1466
  * Skips patches that match exclude patterns in replay.yml
@@ -1214,7 +1526,7 @@ var ReplayApplicator = class {
1214
1526
  const base = await this.git.showFile(baseGen.tree_hash, filePath);
1215
1527
  const theirs = await this.applyPatchToContent(base, patch.patch_content, filePath, tempGit, tempDir);
1216
1528
  const effectiveTheirs = theirs ?? await readFile(join2(this.outputDir, resolvedPath), "utf-8").catch(() => null);
1217
- if (effectiveTheirs) {
1529
+ if (effectiveTheirs != null) {
1218
1530
  this.fileTheirsAccumulator.set(resolvedPath, {
1219
1531
  content: effectiveTheirs,
1220
1532
  baseGeneration: patch.base_generation
@@ -1238,16 +1550,20 @@ var ReplayApplicator = class {
1238
1550
  return this.fileTheirsAccumulator.has(resolved);
1239
1551
  })
1240
1552
  ).then((results) => results.some(Boolean));
1241
- if (!needsAccumulation) {
1553
+ const resolvedFiles = {};
1554
+ for (const filePath of patch.files) {
1555
+ const resolvedPath = baseGen ? await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash) : filePath;
1556
+ if (resolvedPath !== filePath) {
1557
+ resolvedFiles[filePath] = resolvedPath;
1558
+ }
1559
+ }
1560
+ const patchIsRenameAware = /^rename from /m.test(patch.patch_content);
1561
+ const hasGeneratorRename = Object.keys(resolvedFiles).length > 0 && !patchIsRenameAware;
1562
+ if (!needsAccumulation && !hasGeneratorRename) {
1242
1563
  const snapshots = /* @__PURE__ */ new Map();
1243
- const resolvedFiles = {};
1244
1564
  for (const filePath of patch.files) {
1245
- const resolvedPath = baseGen ? await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash) : filePath;
1246
- if (resolvedPath !== filePath) {
1247
- resolvedFiles[filePath] = resolvedPath;
1248
- }
1249
- const fullPath = join2(this.outputDir, resolvedPath);
1250
- snapshots.set(resolvedPath, await readFile(fullPath, "utf-8").catch(() => null));
1565
+ const fullPath = join2(this.outputDir, filePath);
1566
+ snapshots.set(filePath, await readFile(fullPath, "utf-8").catch(() => null));
1251
1567
  }
1252
1568
  try {
1253
1569
  await this.git.execWithInput(["apply", "--3way"], patch.patch_content);
@@ -1259,8 +1575,8 @@ var ReplayApplicator = class {
1259
1575
  ...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
1260
1576
  };
1261
1577
  } catch {
1262
- for (const [resolvedPath, content] of snapshots) {
1263
- const fullPath = join2(this.outputDir, resolvedPath);
1578
+ for (const [filePath, content] of snapshots) {
1579
+ const fullPath = join2(this.outputDir, filePath);
1264
1580
  if (content != null) {
1265
1581
  await writeFile(fullPath, content);
1266
1582
  } else {
@@ -1380,7 +1696,7 @@ var ReplayApplicator = class {
1380
1696
  }
1381
1697
  let useAccumulatorAsMergeBase = false;
1382
1698
  const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
1383
- if (accumulatorEntry && (!theirs || base == null)) {
1699
+ if (accumulatorEntry && (theirs == null || base == null)) {
1384
1700
  theirs = await this.applyPatchToContent(
1385
1701
  accumulatorEntry.content,
1386
1702
  patch.patch_content,
@@ -1388,7 +1704,16 @@ var ReplayApplicator = class {
1388
1704
  tempGit,
1389
1705
  tempDir
1390
1706
  );
1391
- if (theirs) {
1707
+ if (theirs != null) {
1708
+ useAccumulatorAsMergeBase = true;
1709
+ } else if (await this.isPatchAlreadyApplied(
1710
+ patch.patch_content,
1711
+ filePath,
1712
+ accumulatorEntry.content,
1713
+ tempGit,
1714
+ tempDir
1715
+ )) {
1716
+ theirs = accumulatorEntry.content;
1392
1717
  useAccumulatorAsMergeBase = true;
1393
1718
  }
1394
1719
  }
@@ -1405,7 +1730,7 @@ var ReplayApplicator = class {
1405
1730
  }
1406
1731
  let effective_theirs = theirs;
1407
1732
  let baseMismatchSkipped = false;
1408
- if (theirs && base && !useAccumulatorAsMergeBase) {
1733
+ if (theirs != null && base != null && !useAccumulatorAsMergeBase) {
1409
1734
  if (accumulatorEntry && accumulatorEntry.baseGeneration === patch.base_generation) {
1410
1735
  try {
1411
1736
  const preMerged = threeWayMerge(base, accumulatorEntry.content, theirs);
@@ -1421,7 +1746,7 @@ var ReplayApplicator = class {
1421
1746
  baseMismatchSkipped = true;
1422
1747
  }
1423
1748
  }
1424
- if (base == null && !ours && effective_theirs) {
1749
+ if (base == null && ours == null && effective_theirs != null) {
1425
1750
  this.fileTheirsAccumulator.set(resolvedPath, {
1426
1751
  content: effective_theirs,
1427
1752
  baseGeneration: patch.base_generation
@@ -1431,7 +1756,7 @@ var ReplayApplicator = class {
1431
1756
  await writeFile(oursPath, effective_theirs);
1432
1757
  return { file: resolvedPath, status: "merged", reason: "new-file" };
1433
1758
  }
1434
- if (base == null && ours && effective_theirs && !useAccumulatorAsMergeBase) {
1759
+ if (base == null && ours != null && effective_theirs != null && !useAccumulatorAsMergeBase) {
1435
1760
  const merged2 = threeWayMerge("", ours, effective_theirs);
1436
1761
  const outDir2 = dirname2(oursPath);
1437
1762
  await mkdir(outDir2, { recursive: true });
@@ -1445,16 +1770,20 @@ var ReplayApplicator = class {
1445
1770
  conflictMetadata: metadata
1446
1771
  };
1447
1772
  }
1773
+ this.fileTheirsAccumulator.set(resolvedPath, {
1774
+ content: merged2.content,
1775
+ baseGeneration: patch.base_generation
1776
+ });
1448
1777
  return { file: resolvedPath, status: "merged" };
1449
1778
  }
1450
- if (!effective_theirs) {
1779
+ if (effective_theirs == null) {
1451
1780
  return {
1452
1781
  file: resolvedPath,
1453
1782
  status: "skipped",
1454
1783
  reason: "missing-content"
1455
1784
  };
1456
1785
  }
1457
- if (base == null && !useAccumulatorAsMergeBase || !ours) {
1786
+ if (base == null && !useAccumulatorAsMergeBase || ours == null) {
1458
1787
  return {
1459
1788
  file: resolvedPath,
1460
1789
  status: "skipped",
@@ -1473,7 +1802,7 @@ var ReplayApplicator = class {
1473
1802
  const outDir = dirname2(oursPath);
1474
1803
  await mkdir(outDir, { recursive: true });
1475
1804
  await writeFile(oursPath, merged.content);
1476
- if (effective_theirs && !merged.hasConflicts) {
1805
+ if (effective_theirs != null && !merged.hasConflicts) {
1477
1806
  this.fileTheirsAccumulator.set(resolvedPath, {
1478
1807
  content: effective_theirs,
1479
1808
  baseGeneration: patch.base_generation
@@ -1571,6 +1900,28 @@ var ReplayApplicator = class {
1571
1900
  return null;
1572
1901
  }
1573
1902
  }
1903
+ /**
1904
+ * Detects whether a patch's additions are already present in `content`.
1905
+ * Uses `git apply --reverse --check` — if the reverse patch applies cleanly,
1906
+ * the forward patch is effectively a no-op (its +lines are already there and
1907
+ * its -lines are already gone). Used to distinguish "patch already applied"
1908
+ * from "patch base mismatch" after a forward-apply fails.
1909
+ */
1910
+ async isPatchAlreadyApplied(patchContent, filePath, content, tempGit, tempDir) {
1911
+ const fileDiff = this.extractFileDiff(patchContent, filePath);
1912
+ if (!fileDiff) return false;
1913
+ try {
1914
+ const tempFilePath = join2(tempDir, filePath);
1915
+ await mkdir(dirname2(tempFilePath), { recursive: true });
1916
+ await writeFile(tempFilePath, content);
1917
+ await tempGit.exec(["add", filePath]);
1918
+ await tempGit.exec(["commit", "-m", `check already-applied ${filePath}`, "--allow-empty"]);
1919
+ await tempGit.execWithInput(["apply", "--reverse", "--check", "--allow-empty"], fileDiff);
1920
+ return true;
1921
+ } catch {
1922
+ return false;
1923
+ }
1924
+ }
1574
1925
  extractFileDiff(patchContent, filePath) {
1575
1926
  const lines = patchContent.split("\n");
1576
1927
  const diffLines = [];
@@ -1739,6 +2090,33 @@ var ReplayService = class {
1739
2090
  committer;
1740
2091
  lockManager;
1741
2092
  outputDir;
2093
+ /** @internal Test-only. Captures the last ReplayResult[] returned from applicator.applyPatches(). */
2094
+ _lastApplyResults = [];
2095
+ /**
2096
+ * Service-level warnings accumulated during a single runReplay() call.
2097
+ * Merged with `detector.warnings` into `ReplayReport.warnings` by each flow handler.
2098
+ * Populated by `isFileUserOwned` when a patch's base generation tree is unreachable
2099
+ * (shallow clone / aggressive `git gc --prune`) and we fall back to a conservative
2100
+ * heuristic. Cleared at the top of `runReplay()`.
2101
+ */
2102
+ warnings = [];
2103
+ /**
2104
+ * @internal Test-only accessor.
2105
+ * Returns the ReplayApplicator instance used for the last run. Tests use this
2106
+ * to inspect the inter-patch accumulator after runReplay() completes (FER-9791, I2).
2107
+ */
2108
+ get applicatorRef() {
2109
+ return this.applicator;
2110
+ }
2111
+ /**
2112
+ * @internal Test-only accessor.
2113
+ * Returns the ReplayResult[] from the most recent applyPatches() call. Tests use
2114
+ * this to filter applied-vs-conflicted-vs-absorbed patches for invariant assertions
2115
+ * (FER-9791). Returns an empty array if no patches have been applied yet.
2116
+ */
2117
+ getLastResults() {
2118
+ return this._lastApplyResults;
2119
+ }
1742
2120
  constructor(outputDir, _config) {
1743
2121
  const git = new GitClient(outputDir);
1744
2122
  this.git = git;
@@ -1749,6 +2127,7 @@ var ReplayService = class {
1749
2127
  this.committer = new ReplayCommitter(git, outputDir);
1750
2128
  }
1751
2129
  async runReplay(options) {
2130
+ this.warnings = [];
1752
2131
  if (options?.skipApplication) {
1753
2132
  return this.handleSkipApplication(options);
1754
2133
  }
@@ -1822,6 +2201,7 @@ var ReplayService = class {
1822
2201
  );
1823
2202
  }
1824
2203
  this.lockManager.save();
2204
+ this.detector.warnings.push(...this.lockManager.warnings);
1825
2205
  }
1826
2206
  determineFlow() {
1827
2207
  try {
@@ -1888,6 +2268,7 @@ var ReplayService = class {
1888
2268
  }
1889
2269
  this.lockManager.setReplaySkippedAt((/* @__PURE__ */ new Date()).toISOString());
1890
2270
  this.lockManager.save();
2271
+ const credentialWarnings = [...this.lockManager.warnings];
1891
2272
  if (!options?.stageOnly) {
1892
2273
  await this.committer.commitReplay(0);
1893
2274
  } else {
@@ -1899,13 +2280,15 @@ var ReplayService = class {
1899
2280
  patchesApplied: 0,
1900
2281
  patchesWithConflicts: 0,
1901
2282
  patchesSkipped: 0,
1902
- conflicts: []
2283
+ conflicts: [],
2284
+ warnings: credentialWarnings.length > 0 ? credentialWarnings : void 0
1903
2285
  };
1904
2286
  }
1905
2287
  async handleNoPatchesRegeneration(options) {
1906
- const { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
1907
- const warnings = [...this.detector.warnings];
2288
+ let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
2289
+ const detectorWarnings = [...this.detector.warnings];
1908
2290
  if (options?.dryRun) {
2291
+ const dryRunWarnings = [...detectorWarnings, ...this.warnings];
1909
2292
  return {
1910
2293
  flow: "no-patches",
1911
2294
  patchesDetected: newPatches.length,
@@ -1915,9 +2298,26 @@ var ReplayService = class {
1915
2298
  patchesReverted: revertedPatchIds.length,
1916
2299
  conflicts: [],
1917
2300
  wouldApply: newPatches,
1918
- warnings: warnings.length > 0 ? warnings : void 0
2301
+ warnings: dryRunWarnings.length > 0 ? dryRunWarnings : void 0
1919
2302
  };
1920
2303
  }
2304
+ {
2305
+ const preLock = this.lockManager.read();
2306
+ const preCurrentGen = preLock.current_generation;
2307
+ const uniqueFiles = [...new Set(newPatches.flatMap((p) => p.files))];
2308
+ const absorbedFiles = /* @__PURE__ */ new Set();
2309
+ for (const file of uniqueFiles) {
2310
+ const diff = await this.git.exec(["diff", preCurrentGen, "HEAD", "--", file]).catch(() => null);
2311
+ if (diff !== null && !diff.trim()) {
2312
+ absorbedFiles.add(file);
2313
+ }
2314
+ }
2315
+ if (absorbedFiles.size > 0) {
2316
+ newPatches = newPatches.filter(
2317
+ (p) => p.files.length === 0 || !p.files.every((f) => absorbedFiles.has(f))
2318
+ );
2319
+ }
2320
+ }
1921
2321
  for (const id of revertedPatchIds) {
1922
2322
  try {
1923
2323
  this.lockManager.removePatch(id);
@@ -1936,6 +2336,7 @@ var ReplayService = class {
1936
2336
  let results = [];
1937
2337
  if (newPatches.length > 0) {
1938
2338
  results = await this.applicator.applyPatches(newPatches);
2339
+ this._lastApplyResults = results;
1939
2340
  this.revertConflictingFiles(results);
1940
2341
  for (const result of results) {
1941
2342
  if (result.status === "conflict") {
@@ -1950,6 +2351,7 @@ var ReplayService = class {
1950
2351
  }
1951
2352
  }
1952
2353
  this.lockManager.save();
2354
+ this.warnings.push(...this.lockManager.warnings);
1953
2355
  if (newPatches.length > 0) {
1954
2356
  if (!options?.stageOnly) {
1955
2357
  const appliedCount = results.filter((r) => r.status === "applied").length;
@@ -1958,13 +2360,14 @@ var ReplayService = class {
1958
2360
  await this.committer.stageAll();
1959
2361
  }
1960
2362
  }
2363
+ const warnings = [...detectorWarnings, ...this.warnings];
1961
2364
  return this.buildReport("no-patches", newPatches, results, options, warnings, rebaseCounts, void 0, revertedPatchIds.length);
1962
2365
  }
1963
2366
  async handleNormalRegeneration(options) {
1964
2367
  if (options?.dryRun) {
1965
2368
  const existingPatches2 = this.lockManager.getPatches();
1966
2369
  const { patches: newPatches2, revertedPatchIds: dryRunReverted } = await this.detector.detectNewPatches();
1967
- const warnings2 = [...this.detector.warnings];
2370
+ const warnings2 = [...this.detector.warnings, ...this.warnings];
1968
2371
  const allPatches2 = [...existingPatches2, ...newPatches2];
1969
2372
  return {
1970
2373
  flow: "normal-regeneration",
@@ -1996,7 +2399,7 @@ var ReplayService = class {
1996
2399
  }
1997
2400
  existingPatches = this.lockManager.getPatches();
1998
2401
  let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
1999
- const warnings = [...this.detector.warnings];
2402
+ const detectorWarnings = [...this.detector.warnings];
2000
2403
  if (removedByPreRebase.length > 0) {
2001
2404
  const removedOriginalCommits = new Set(removedByPreRebase.map((p) => p.original_commit));
2002
2405
  const removedOriginalMessages = new Set(removedByPreRebase.map((p) => p.original_message));
@@ -2028,6 +2431,7 @@ var ReplayService = class {
2028
2431
  const genRecord = await this.committer.createGenerationRecord(commitOpts);
2029
2432
  this.lockManager.addGeneration(genRecord);
2030
2433
  const results = await this.applicator.applyPatches(allPatches);
2434
+ this._lastApplyResults = results;
2031
2435
  this.revertConflictingFiles(results);
2032
2436
  for (const result of results) {
2033
2437
  if (result.status === "conflict") {
@@ -2049,12 +2453,14 @@ var ReplayService = class {
2049
2453
  }
2050
2454
  }
2051
2455
  this.lockManager.save();
2456
+ this.warnings.push(...this.lockManager.warnings);
2052
2457
  if (options?.stageOnly) {
2053
2458
  await this.committer.stageAll();
2054
2459
  } else {
2055
2460
  const appliedCount = results.filter((r) => r.status === "applied").length;
2056
2461
  await this.committer.commitReplay(appliedCount, allPatches);
2057
2462
  }
2463
+ const warnings = [...detectorWarnings, ...this.warnings];
2058
2464
  return this.buildReport(
2059
2465
  "normal-regeneration",
2060
2466
  allPatches,
@@ -2078,6 +2484,7 @@ var ReplayService = class {
2078
2484
  let keptAsUserOwned = 0;
2079
2485
  const seenContentHashes = /* @__PURE__ */ new Set();
2080
2486
  const absorbedPatchIds = /* @__PURE__ */ new Set();
2487
+ const fernignorePatterns = this.readFernignorePatterns();
2081
2488
  for (const result of results) {
2082
2489
  if (result.resolvedFiles && Object.keys(result.resolvedFiles).length > 0) {
2083
2490
  const patch = result.patch;
@@ -2098,34 +2505,78 @@ var ReplayService = class {
2098
2505
  const patch = result.patch;
2099
2506
  if (patch.base_generation === currentGenSha) continue;
2100
2507
  try {
2101
- const fernignorePatterns = this.readFernignorePatterns();
2102
- const isUserOwned = await Promise.all(
2508
+ const originalByResolved = {};
2509
+ if (result.resolvedFiles) {
2510
+ for (const [orig, resolved] of Object.entries(result.resolvedFiles)) {
2511
+ originalByResolved[resolved] = orig;
2512
+ }
2513
+ }
2514
+ const isUserOwnedPerFile = await Promise.all(
2103
2515
  patch.files.map(async (file) => {
2104
- if (file === ".fernignore") {
2516
+ if (file === ".fernignore") return true;
2517
+ if (fernignorePatterns.some((p) => file === p || minimatch2(file, p))) {
2105
2518
  return true;
2106
2519
  }
2107
- if (fernignorePatterns.some((p) => file === p || minimatch2(file, p))) {
2520
+ if (patch.user_owned) return true;
2521
+ if (this.fileIsCreationInPatchContent(patch.patch_content, originalByResolved[file] ?? file)) {
2108
2522
  return true;
2109
2523
  }
2110
2524
  const content = await this.git.showFile(currentGenSha, file);
2111
2525
  return content === null;
2112
2526
  })
2113
2527
  );
2114
- const hasUserOwnedFiles = isUserOwned.some(Boolean);
2115
- if (hasUserOwnedFiles) {
2116
- this.lockManager.updatePatch(patch.id, {
2117
- base_generation: currentGenSha
2118
- });
2528
+ const hasProtectedFiles = patch.files.some(
2529
+ (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch2(file, p))
2530
+ );
2531
+ const allUserOwned = isUserOwnedPerFile.every(Boolean);
2532
+ if (allUserOwned && patch.files.length > 0) {
2533
+ const baseChanged = patch.base_generation !== currentGenSha;
2534
+ const userDiff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
2535
+ if (userDiff != null && userDiff.trim()) {
2536
+ const newHash = this.detector.computeContentHash(userDiff);
2537
+ patch.patch_content = userDiff;
2538
+ patch.content_hash = newHash;
2539
+ }
2540
+ patch.base_generation = currentGenSha;
2541
+ try {
2542
+ this.lockManager.updatePatch(patch.id, {
2543
+ base_generation: currentGenSha,
2544
+ ...!patch.user_owned ? { user_owned: true } : {},
2545
+ ...userDiff != null && userDiff.trim() ? { patch_content: patch.patch_content, content_hash: patch.content_hash } : {}
2546
+ });
2547
+ } catch {
2548
+ }
2549
+ patch.user_owned = true;
2119
2550
  keptAsUserOwned++;
2551
+ if (baseChanged) repointed++;
2120
2552
  continue;
2121
2553
  }
2122
2554
  const diff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
2123
- if (!diff || !diff.trim()) {
2555
+ if (diff === null) continue;
2556
+ if (!diff.trim()) {
2557
+ if (hasProtectedFiles) {
2558
+ patch.base_generation = currentGenSha;
2559
+ try {
2560
+ this.lockManager.updatePatch(patch.id, { base_generation: currentGenSha });
2561
+ } catch {
2562
+ }
2563
+ keptAsUserOwned++;
2564
+ continue;
2565
+ }
2124
2566
  this.lockManager.removePatch(patch.id);
2125
2567
  absorbedPatchIds.add(patch.id);
2126
2568
  absorbed++;
2127
2569
  continue;
2128
2570
  }
2571
+ if (hasProtectedFiles) {
2572
+ patch.base_generation = currentGenSha;
2573
+ try {
2574
+ this.lockManager.updatePatch(patch.id, { base_generation: currentGenSha });
2575
+ } catch {
2576
+ }
2577
+ keptAsUserOwned++;
2578
+ continue;
2579
+ }
2129
2580
  const newContentHash = this.detector.computeContentHash(diff);
2130
2581
  if (seenContentHashes.has(newContentHash)) {
2131
2582
  this.lockManager.removePatch(patch.id);
@@ -2134,17 +2585,89 @@ var ReplayService = class {
2134
2585
  continue;
2135
2586
  }
2136
2587
  seenContentHashes.add(newContentHash);
2137
- this.lockManager.updatePatch(patch.id, {
2138
- base_generation: currentGenSha,
2139
- patch_content: diff,
2140
- content_hash: newContentHash
2141
- });
2588
+ patch.base_generation = currentGenSha;
2589
+ patch.patch_content = diff;
2590
+ patch.content_hash = newContentHash;
2591
+ try {
2592
+ this.lockManager.updatePatch(patch.id, {
2593
+ base_generation: currentGenSha,
2594
+ patch_content: diff,
2595
+ content_hash: newContentHash
2596
+ });
2597
+ } catch {
2598
+ }
2142
2599
  contentRebased++;
2143
2600
  } catch {
2144
2601
  }
2145
2602
  }
2146
2603
  return { absorbed, repointed, contentRebased, keptAsUserOwned, absorbedPatchIds };
2147
2604
  }
2605
+ /**
2606
+ * Determine whether `file` is user-owned (never produced by the generator).
2607
+ *
2608
+ * Resolution order (first match wins):
2609
+ * 1. `patch.user_owned === true` — authoritative, persisted across cycles.
2610
+ * 2. `.fernignore` itself, or file matches a .fernignore pattern.
2611
+ * 3. Legacy-safe signal: `patch_content` shows this file as a `--- /dev/null`
2612
+ * creation. Works for patches that predate the `user_owned` field or
2613
+ * whose flag was lost. Immune to rename tricks — relies on raw patch text.
2614
+ * 4. Tree-based check against `patch.base_generation` when it is reachable
2615
+ * AND distinct from `currentGenSha`. If the base tree is unreachable
2616
+ * (shallow clone / aggressive `git gc --prune`), emit a one-time
2617
+ * customer-actionable warning (deduplicated via warnedGens) and
2618
+ * conservatively treat as user-owned — strictly safer than silent
2619
+ * absorption (FER-9809).
2620
+ * 5. Final fallback — check `currentGenSha` when there is no usable base
2621
+ * (legacy one-gen lockfile). Preserves pre-fix behavior for that edge.
2622
+ *
2623
+ * `originalFileName` is the pre-rename path when the generator moved the
2624
+ * file between the patch's base and the current generation. Tree lookups
2625
+ * at ancestor generations use the original path — that's where the
2626
+ * generator produced the content.
2627
+ */
2628
+ async isFileUserOwned(file, patch, currentGenSha, fernignorePatterns, warnedGens, originalFileName) {
2629
+ if (patch.user_owned) return true;
2630
+ if (file === ".fernignore") return true;
2631
+ if (fernignorePatterns.some((p) => file === p || minimatch2(file, p))) return true;
2632
+ if (this.fileIsCreationInPatchContent(patch.patch_content, originalFileName ?? file)) {
2633
+ return true;
2634
+ }
2635
+ const base = patch.base_generation;
2636
+ if (base && base !== currentGenSha) {
2637
+ const reachable = await this.git.commitExists(base) || await this.git.treeExists(base);
2638
+ if (!reachable) {
2639
+ if (!warnedGens.has(base)) {
2640
+ warnedGens.add(base);
2641
+ this.warnings.push(
2642
+ `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.`
2643
+ );
2644
+ }
2645
+ return true;
2646
+ }
2647
+ return await this.git.showFile(base, originalFileName ?? file) === null;
2648
+ }
2649
+ return await this.git.showFile(currentGenSha, originalFileName ?? file) === null;
2650
+ }
2651
+ /**
2652
+ * Returns true if `file`'s diff section in `patchContent` starts with
2653
+ * `--- /dev/null`, meaning this file was introduced by the patch (user
2654
+ * creation). Used as a legacy-safe fallback when `patch.user_owned` is
2655
+ * absent or cleared.
2656
+ */
2657
+ fileIsCreationInPatchContent(patchContent, file) {
2658
+ const lines = patchContent.split("\n");
2659
+ let inFileSection = false;
2660
+ for (const line of lines) {
2661
+ if (line.startsWith("diff --git ")) {
2662
+ inFileSection = line.includes(` b/${file}`) || line.endsWith(` b/${file}`);
2663
+ continue;
2664
+ }
2665
+ if (inFileSection && line.startsWith("--- ")) {
2666
+ return line === "--- /dev/null";
2667
+ }
2668
+ }
2669
+ return false;
2670
+ }
2148
2671
  /**
2149
2672
  * For conflict patches with mixed results (some files merged, some conflicted),
2150
2673
  * check if the cleanly merged files were absorbed by the generator (empty diff).
@@ -2158,13 +2681,14 @@ var ReplayService = class {
2158
2681
  const cleanFiles = result.fileResults.filter((f) => f.status === "merged").map((f) => f.file);
2159
2682
  if (cleanFiles.length === 0) return;
2160
2683
  const patch = result.patch;
2684
+ if (patch.user_owned) return;
2161
2685
  const fernignorePatterns = this.readFernignorePatterns();
2686
+ const warnedGens = /* @__PURE__ */ new Set();
2162
2687
  const generatorCleanFiles = [];
2163
2688
  for (const file of cleanFiles) {
2164
- if (file === ".fernignore") continue;
2165
- if (fernignorePatterns.some((p) => file === p || minimatch2(file, p))) continue;
2166
- const content = await this.git.showFile(currentGenSha, file);
2167
- if (content === null) continue;
2689
+ if (await this.isFileUserOwned(file, patch, currentGenSha, fernignorePatterns, warnedGens)) {
2690
+ continue;
2691
+ }
2168
2692
  generatorCleanFiles.push(file);
2169
2693
  }
2170
2694
  if (generatorCleanFiles.length === 0) return;
@@ -2201,7 +2725,35 @@ var ReplayService = class {
2201
2725
  let conflictResolved = 0;
2202
2726
  let conflictAbsorbed = 0;
2203
2727
  let contentRefreshed = 0;
2728
+ const fernignorePatterns = this.readFernignorePatterns();
2729
+ const warnedGens = /* @__PURE__ */ new Set();
2730
+ const reachabilityCache = /* @__PURE__ */ new Map();
2731
+ const isReachable = async (sha) => {
2732
+ const cached = reachabilityCache.get(sha);
2733
+ if (cached !== void 0) return cached;
2734
+ const reachable = await this.git.commitExists(sha) || await this.git.treeExists(sha);
2735
+ reachabilityCache.set(sha, reachable);
2736
+ return reachable;
2737
+ };
2738
+ const allPatchFilesUserOwned = async (patch) => {
2739
+ if (patch.user_owned) return true;
2740
+ if (patch.files.length === 0) return false;
2741
+ for (const file of patch.files) {
2742
+ if (!await this.isFileUserOwned(file, patch, currentGen, fernignorePatterns, warnedGens)) {
2743
+ return false;
2744
+ }
2745
+ }
2746
+ return true;
2747
+ };
2204
2748
  for (const patch of patches) {
2749
+ if (patch.base_generation && patch.base_generation !== currentGen && !warnedGens.has(patch.base_generation)) {
2750
+ if (!await isReachable(patch.base_generation)) {
2751
+ warnedGens.add(patch.base_generation);
2752
+ this.warnings.push(
2753
+ `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.`
2754
+ );
2755
+ }
2756
+ }
2205
2757
  if (patch.status != null) {
2206
2758
  delete patch.status;
2207
2759
  continue;
@@ -2211,6 +2763,7 @@ var ReplayService = class {
2211
2763
  const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
2212
2764
  if (diff === null) continue;
2213
2765
  if (!diff.trim()) {
2766
+ if (await allPatchFilesUserOwned(patch)) continue;
2214
2767
  this.lockManager.removePatch(patch.id);
2215
2768
  contentRefreshed++;
2216
2769
  continue;
@@ -2222,6 +2775,12 @@ var ReplayService = class {
2222
2775
  if (hasStaleMarkers) {
2223
2776
  continue;
2224
2777
  }
2778
+ const isProtected = patch.files.some(
2779
+ (file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch2(file, p))
2780
+ );
2781
+ if (isProtected) {
2782
+ continue;
2783
+ }
2225
2784
  const newContentHash = this.detector.computeContentHash(diff);
2226
2785
  if (newContentHash !== patch.content_hash) {
2227
2786
  const filesOutput = await this.git.exec(["diff", "--name-only", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
@@ -2247,6 +2806,13 @@ var ReplayService = class {
2247
2806
  const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
2248
2807
  if (diff === null) continue;
2249
2808
  if (!diff.trim()) {
2809
+ if (await allPatchFilesUserOwned(patch)) {
2810
+ try {
2811
+ this.lockManager.updatePatch(patch.id, { base_generation: currentGen });
2812
+ } catch {
2813
+ }
2814
+ continue;
2815
+ }
2250
2816
  this.lockManager.removePatch(patch.id);
2251
2817
  conflictAbsorbed++;
2252
2818
  continue;
@@ -2758,7 +3324,10 @@ function ensureGitattributesEntries(outputDir) {
2758
3324
  writeFileSync4(gitattributesPath, content, "utf-8");
2759
3325
  }
2760
3326
  function computeContentHash(patchContent) {
2761
- const normalized = patchContent.split("\n").filter((line) => !line.startsWith("From ") && !line.startsWith("index ") && !line.startsWith("Date: ")).join("\n");
3327
+ const lines = patchContent.split("\n");
3328
+ const diffStart = lines.findIndex((l) => l.startsWith("diff --git "));
3329
+ const relevantLines = diffStart > 0 ? lines.slice(diffStart) : lines;
3330
+ const normalized = relevantLines.filter((line) => !line.startsWith("index ")).join("\n").replace(/\n-- \n[\d]+\.[\d]+[^\n]*\s*$/, "");
2762
3331
  return `sha256:${createHash3("sha256").update(normalized).digest("hex")}`;
2763
3332
  }
2764
3333
 
@@ -3069,6 +3638,7 @@ function status(outputDir) {
3069
3638
  };
3070
3639
  }
3071
3640
  export {
3641
+ CREDENTIAL_PATTERNS,
3072
3642
  FERN_BOT_EMAIL,
3073
3643
  FERN_BOT_LOGIN,
3074
3644
  FERN_BOT_NAME,
@@ -3080,15 +3650,18 @@ export {
3080
3650
  ReplayCommitter,
3081
3651
  ReplayDetector,
3082
3652
  ReplayService,
3653
+ SENSITIVE_FILE_PATTERNS,
3083
3654
  bootstrap,
3084
3655
  forget,
3085
3656
  isGenerationCommit,
3086
3657
  isReplayCommit,
3087
3658
  isRevertCommit,
3659
+ isSensitiveFile,
3088
3660
  parseRevertedMessage,
3089
3661
  parseRevertedSha,
3090
3662
  reset,
3091
3663
  resolve,
3664
+ scanForCredentials,
3092
3665
  status,
3093
3666
  threeWayMerge
3094
3667
  };