@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.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;
@@ -654,12 +757,31 @@ var ReplayDetector = class {
654
757
  );
655
758
  continue;
656
759
  }
657
- const contentHash = this.computeContentHash(patchContent);
760
+ let contentHash = this.computeContentHash(patchContent);
658
761
  if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
659
762
  continue;
660
763
  }
661
764
  const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
662
- 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
+ }
663
785
  if (files.length === 0) {
664
786
  continue;
665
787
  }
@@ -671,14 +793,16 @@ var ReplayDetector = class {
671
793
  original_author: `${commit.authorName} <${commit.authorEmail}>`,
672
794
  base_generation: lastGen.commit_sha,
673
795
  files,
674
- patch_content: patchContent
796
+ patch_content: patchContent,
797
+ ...this.isCreationOnlyPatch(patchContent) ? { user_owned: true } : {}
675
798
  });
676
799
  }
677
- newPatches.reverse();
800
+ const survivingPatches = await this.collapseNetZeroFiles(newPatches);
801
+ survivingPatches.reverse();
678
802
  const revertedPatchIdSet = /* @__PURE__ */ new Set();
679
803
  const revertIndicesToRemove = /* @__PURE__ */ new Set();
680
- for (let i = 0; i < newPatches.length; i++) {
681
- const patch = newPatches[i];
804
+ for (let i = 0; i < survivingPatches.length; i++) {
805
+ const patch = survivingPatches[i];
682
806
  if (!isRevertCommit(patch.original_message)) continue;
683
807
  let body = "";
684
808
  try {
@@ -707,7 +831,7 @@ var ReplayDetector = class {
707
831
  if (matchedExisting) continue;
708
832
  let matchedNew = false;
709
833
  if (revertedSha) {
710
- const idx = newPatches.findIndex(
834
+ const idx = survivingPatches.findIndex(
711
835
  (p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_commit === revertedSha
712
836
  );
713
837
  if (idx !== -1) {
@@ -717,7 +841,7 @@ var ReplayDetector = class {
717
841
  }
718
842
  }
719
843
  if (!matchedNew && revertedMessage) {
720
- const idx = newPatches.findIndex(
844
+ const idx = survivingPatches.findIndex(
721
845
  (p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_message === revertedMessage
722
846
  );
723
847
  if (idx !== -1) {
@@ -729,18 +853,107 @@ var ReplayDetector = class {
729
853
  revertIndicesToRemove.add(i);
730
854
  }
731
855
  }
732
- const filteredPatches = newPatches.filter((_, i) => !revertIndicesToRemove.has(i));
856
+ const filteredPatches = survivingPatches.filter((_, i) => !revertIndicesToRemove.has(i));
733
857
  return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
734
858
  }
735
859
  /**
736
860
  * Compute content hash for deduplication.
737
- * Removes commit SHA line and index lines before hashing,
738
- * 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).
739
875
  */
740
876
  computeContentHash(patchContent) {
741
- 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*$/, "");
742
881
  return `sha256:${createHash("sha256").update(normalized).digest("hex")}`;
743
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
+ }
744
957
  /**
745
958
  * Create a single composite patch for a merge commit by diffing the merge
746
959
  * commit against its first parent. This captures the net change of the
@@ -756,7 +969,14 @@ var ReplayDetector = class {
756
969
  firstParent,
757
970
  mergeCommit.sha
758
971
  ]);
759
- const files = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
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
+ }
760
980
  if (files.length === 0) return null;
761
981
  const diff = await this.git.exec([
762
982
  "diff",
@@ -778,7 +998,8 @@ var ReplayDetector = class {
778
998
  original_author: `${mergeCommit.authorName} <${mergeCommit.authorEmail}>`,
779
999
  base_generation: lastGen.commit_sha,
780
1000
  files,
781
- patch_content: diff
1001
+ patch_content: diff,
1002
+ ...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
782
1003
  };
783
1004
  }
784
1005
  /**
@@ -792,7 +1013,14 @@ var ReplayDetector = class {
792
1013
  return this.detectPatchesViaCommitScan();
793
1014
  }
794
1015
  const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
795
- 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
+ }
796
1024
  if (files.length === 0) return { patches: [], revertedPatchIds: [] };
797
1025
  const diff = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
798
1026
  if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
@@ -812,7 +1040,8 @@ var ReplayDetector = class {
812
1040
  // reference to find base file content. diffBase may be the tree_hash.
813
1041
  base_generation: commitKnownMissing ? diffBase : lastGen.commit_sha,
814
1042
  files,
815
- patch_content: diff
1043
+ patch_content: diff,
1044
+ ...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
816
1045
  };
817
1046
  return { patches: [compositePatch], revertedPatchIds: [] };
818
1047
  }
@@ -859,7 +1088,7 @@ var ReplayDetector = class {
859
1088
  } catch {
860
1089
  continue;
861
1090
  }
862
- const contentHash = this.computeContentHash(patchContent);
1091
+ let contentHash = this.computeContentHash(patchContent);
863
1092
  if (existingHashes.has(contentHash) || forgottenHashes.has(contentHash)) {
864
1093
  continue;
865
1094
  }
@@ -867,7 +1096,26 @@ var ReplayDetector = class {
867
1096
  continue;
868
1097
  }
869
1098
  const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
870
- 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
+ }
871
1119
  if (files.length === 0) {
872
1120
  continue;
873
1121
  }
@@ -1199,6 +1447,20 @@ var ReplayApplicator = class {
1199
1447
  resetAccumulator() {
1200
1448
  this.fileTheirsAccumulator.clear();
1201
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
+ }
1202
1464
  /**
1203
1465
  * Apply all patches, returning results for each.
1204
1466
  * Skips patches that match exclude patterns in replay.yml
@@ -1264,7 +1526,7 @@ var ReplayApplicator = class {
1264
1526
  const base = await this.git.showFile(baseGen.tree_hash, filePath);
1265
1527
  const theirs = await this.applyPatchToContent(base, patch.patch_content, filePath, tempGit, tempDir);
1266
1528
  const effectiveTheirs = theirs ?? await readFile(join2(this.outputDir, resolvedPath), "utf-8").catch(() => null);
1267
- if (effectiveTheirs) {
1529
+ if (effectiveTheirs != null) {
1268
1530
  this.fileTheirsAccumulator.set(resolvedPath, {
1269
1531
  content: effectiveTheirs,
1270
1532
  baseGeneration: patch.base_generation
@@ -1288,16 +1550,20 @@ var ReplayApplicator = class {
1288
1550
  return this.fileTheirsAccumulator.has(resolved);
1289
1551
  })
1290
1552
  ).then((results) => results.some(Boolean));
1291
- 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) {
1292
1563
  const snapshots = /* @__PURE__ */ new Map();
1293
- const resolvedFiles = {};
1294
1564
  for (const filePath of patch.files) {
1295
- const resolvedPath = baseGen ? await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash) : filePath;
1296
- if (resolvedPath !== filePath) {
1297
- resolvedFiles[filePath] = resolvedPath;
1298
- }
1299
- const fullPath = join2(this.outputDir, resolvedPath);
1300
- 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));
1301
1567
  }
1302
1568
  try {
1303
1569
  await this.git.execWithInput(["apply", "--3way"], patch.patch_content);
@@ -1309,8 +1575,8 @@ var ReplayApplicator = class {
1309
1575
  ...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
1310
1576
  };
1311
1577
  } catch {
1312
- for (const [resolvedPath, content] of snapshots) {
1313
- const fullPath = join2(this.outputDir, resolvedPath);
1578
+ for (const [filePath, content] of snapshots) {
1579
+ const fullPath = join2(this.outputDir, filePath);
1314
1580
  if (content != null) {
1315
1581
  await writeFile(fullPath, content);
1316
1582
  } else {
@@ -1430,7 +1696,7 @@ var ReplayApplicator = class {
1430
1696
  }
1431
1697
  let useAccumulatorAsMergeBase = false;
1432
1698
  const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
1433
- if (accumulatorEntry && (!theirs || base == null)) {
1699
+ if (accumulatorEntry && (theirs == null || base == null)) {
1434
1700
  theirs = await this.applyPatchToContent(
1435
1701
  accumulatorEntry.content,
1436
1702
  patch.patch_content,
@@ -1438,7 +1704,16 @@ var ReplayApplicator = class {
1438
1704
  tempGit,
1439
1705
  tempDir
1440
1706
  );
1441
- 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;
1442
1717
  useAccumulatorAsMergeBase = true;
1443
1718
  }
1444
1719
  }
@@ -1455,7 +1730,7 @@ var ReplayApplicator = class {
1455
1730
  }
1456
1731
  let effective_theirs = theirs;
1457
1732
  let baseMismatchSkipped = false;
1458
- if (theirs && base && !useAccumulatorAsMergeBase) {
1733
+ if (theirs != null && base != null && !useAccumulatorAsMergeBase) {
1459
1734
  if (accumulatorEntry && accumulatorEntry.baseGeneration === patch.base_generation) {
1460
1735
  try {
1461
1736
  const preMerged = threeWayMerge(base, accumulatorEntry.content, theirs);
@@ -1471,7 +1746,7 @@ var ReplayApplicator = class {
1471
1746
  baseMismatchSkipped = true;
1472
1747
  }
1473
1748
  }
1474
- if (base == null && !ours && effective_theirs) {
1749
+ if (base == null && ours == null && effective_theirs != null) {
1475
1750
  this.fileTheirsAccumulator.set(resolvedPath, {
1476
1751
  content: effective_theirs,
1477
1752
  baseGeneration: patch.base_generation
@@ -1481,7 +1756,7 @@ var ReplayApplicator = class {
1481
1756
  await writeFile(oursPath, effective_theirs);
1482
1757
  return { file: resolvedPath, status: "merged", reason: "new-file" };
1483
1758
  }
1484
- if (base == null && ours && effective_theirs && !useAccumulatorAsMergeBase) {
1759
+ if (base == null && ours != null && effective_theirs != null && !useAccumulatorAsMergeBase) {
1485
1760
  const merged2 = threeWayMerge("", ours, effective_theirs);
1486
1761
  const outDir2 = dirname2(oursPath);
1487
1762
  await mkdir(outDir2, { recursive: true });
@@ -1495,16 +1770,20 @@ var ReplayApplicator = class {
1495
1770
  conflictMetadata: metadata
1496
1771
  };
1497
1772
  }
1773
+ this.fileTheirsAccumulator.set(resolvedPath, {
1774
+ content: merged2.content,
1775
+ baseGeneration: patch.base_generation
1776
+ });
1498
1777
  return { file: resolvedPath, status: "merged" };
1499
1778
  }
1500
- if (!effective_theirs) {
1779
+ if (effective_theirs == null) {
1501
1780
  return {
1502
1781
  file: resolvedPath,
1503
1782
  status: "skipped",
1504
1783
  reason: "missing-content"
1505
1784
  };
1506
1785
  }
1507
- if (base == null && !useAccumulatorAsMergeBase || !ours) {
1786
+ if (base == null && !useAccumulatorAsMergeBase || ours == null) {
1508
1787
  return {
1509
1788
  file: resolvedPath,
1510
1789
  status: "skipped",
@@ -1523,7 +1802,7 @@ var ReplayApplicator = class {
1523
1802
  const outDir = dirname2(oursPath);
1524
1803
  await mkdir(outDir, { recursive: true });
1525
1804
  await writeFile(oursPath, merged.content);
1526
- if (effective_theirs && !merged.hasConflicts) {
1805
+ if (effective_theirs != null && !merged.hasConflicts) {
1527
1806
  this.fileTheirsAccumulator.set(resolvedPath, {
1528
1807
  content: effective_theirs,
1529
1808
  baseGeneration: patch.base_generation
@@ -1621,6 +1900,28 @@ var ReplayApplicator = class {
1621
1900
  return null;
1622
1901
  }
1623
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
+ }
1624
1925
  extractFileDiff(patchContent, filePath) {
1625
1926
  const lines = patchContent.split("\n");
1626
1927
  const diffLines = [];
@@ -1789,6 +2090,33 @@ var ReplayService = class {
1789
2090
  committer;
1790
2091
  lockManager;
1791
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
+ }
1792
2120
  constructor(outputDir, _config) {
1793
2121
  const git = new GitClient(outputDir);
1794
2122
  this.git = git;
@@ -1799,6 +2127,7 @@ var ReplayService = class {
1799
2127
  this.committer = new ReplayCommitter(git, outputDir);
1800
2128
  }
1801
2129
  async runReplay(options) {
2130
+ this.warnings = [];
1802
2131
  if (options?.skipApplication) {
1803
2132
  return this.handleSkipApplication(options);
1804
2133
  }
@@ -1872,6 +2201,7 @@ var ReplayService = class {
1872
2201
  );
1873
2202
  }
1874
2203
  this.lockManager.save();
2204
+ this.detector.warnings.push(...this.lockManager.warnings);
1875
2205
  }
1876
2206
  determineFlow() {
1877
2207
  try {
@@ -1938,6 +2268,7 @@ var ReplayService = class {
1938
2268
  }
1939
2269
  this.lockManager.setReplaySkippedAt((/* @__PURE__ */ new Date()).toISOString());
1940
2270
  this.lockManager.save();
2271
+ const credentialWarnings = [...this.lockManager.warnings];
1941
2272
  if (!options?.stageOnly) {
1942
2273
  await this.committer.commitReplay(0);
1943
2274
  } else {
@@ -1949,13 +2280,15 @@ var ReplayService = class {
1949
2280
  patchesApplied: 0,
1950
2281
  patchesWithConflicts: 0,
1951
2282
  patchesSkipped: 0,
1952
- conflicts: []
2283
+ conflicts: [],
2284
+ warnings: credentialWarnings.length > 0 ? credentialWarnings : void 0
1953
2285
  };
1954
2286
  }
1955
2287
  async handleNoPatchesRegeneration(options) {
1956
- const { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
1957
- const warnings = [...this.detector.warnings];
2288
+ let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
2289
+ const detectorWarnings = [...this.detector.warnings];
1958
2290
  if (options?.dryRun) {
2291
+ const dryRunWarnings = [...detectorWarnings, ...this.warnings];
1959
2292
  return {
1960
2293
  flow: "no-patches",
1961
2294
  patchesDetected: newPatches.length,
@@ -1965,9 +2298,26 @@ var ReplayService = class {
1965
2298
  patchesReverted: revertedPatchIds.length,
1966
2299
  conflicts: [],
1967
2300
  wouldApply: newPatches,
1968
- warnings: warnings.length > 0 ? warnings : void 0
2301
+ warnings: dryRunWarnings.length > 0 ? dryRunWarnings : void 0
1969
2302
  };
1970
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
+ }
1971
2321
  for (const id of revertedPatchIds) {
1972
2322
  try {
1973
2323
  this.lockManager.removePatch(id);
@@ -1986,6 +2336,7 @@ var ReplayService = class {
1986
2336
  let results = [];
1987
2337
  if (newPatches.length > 0) {
1988
2338
  results = await this.applicator.applyPatches(newPatches);
2339
+ this._lastApplyResults = results;
1989
2340
  this.revertConflictingFiles(results);
1990
2341
  for (const result of results) {
1991
2342
  if (result.status === "conflict") {
@@ -2000,6 +2351,7 @@ var ReplayService = class {
2000
2351
  }
2001
2352
  }
2002
2353
  this.lockManager.save();
2354
+ this.warnings.push(...this.lockManager.warnings);
2003
2355
  if (newPatches.length > 0) {
2004
2356
  if (!options?.stageOnly) {
2005
2357
  const appliedCount = results.filter((r) => r.status === "applied").length;
@@ -2008,13 +2360,14 @@ var ReplayService = class {
2008
2360
  await this.committer.stageAll();
2009
2361
  }
2010
2362
  }
2363
+ const warnings = [...detectorWarnings, ...this.warnings];
2011
2364
  return this.buildReport("no-patches", newPatches, results, options, warnings, rebaseCounts, void 0, revertedPatchIds.length);
2012
2365
  }
2013
2366
  async handleNormalRegeneration(options) {
2014
2367
  if (options?.dryRun) {
2015
2368
  const existingPatches2 = this.lockManager.getPatches();
2016
2369
  const { patches: newPatches2, revertedPatchIds: dryRunReverted } = await this.detector.detectNewPatches();
2017
- const warnings2 = [...this.detector.warnings];
2370
+ const warnings2 = [...this.detector.warnings, ...this.warnings];
2018
2371
  const allPatches2 = [...existingPatches2, ...newPatches2];
2019
2372
  return {
2020
2373
  flow: "normal-regeneration",
@@ -2046,7 +2399,7 @@ var ReplayService = class {
2046
2399
  }
2047
2400
  existingPatches = this.lockManager.getPatches();
2048
2401
  let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
2049
- const warnings = [...this.detector.warnings];
2402
+ const detectorWarnings = [...this.detector.warnings];
2050
2403
  if (removedByPreRebase.length > 0) {
2051
2404
  const removedOriginalCommits = new Set(removedByPreRebase.map((p) => p.original_commit));
2052
2405
  const removedOriginalMessages = new Set(removedByPreRebase.map((p) => p.original_message));
@@ -2078,6 +2431,7 @@ var ReplayService = class {
2078
2431
  const genRecord = await this.committer.createGenerationRecord(commitOpts);
2079
2432
  this.lockManager.addGeneration(genRecord);
2080
2433
  const results = await this.applicator.applyPatches(allPatches);
2434
+ this._lastApplyResults = results;
2081
2435
  this.revertConflictingFiles(results);
2082
2436
  for (const result of results) {
2083
2437
  if (result.status === "conflict") {
@@ -2099,12 +2453,14 @@ var ReplayService = class {
2099
2453
  }
2100
2454
  }
2101
2455
  this.lockManager.save();
2456
+ this.warnings.push(...this.lockManager.warnings);
2102
2457
  if (options?.stageOnly) {
2103
2458
  await this.committer.stageAll();
2104
2459
  } else {
2105
2460
  const appliedCount = results.filter((r) => r.status === "applied").length;
2106
2461
  await this.committer.commitReplay(appliedCount, allPatches);
2107
2462
  }
2463
+ const warnings = [...detectorWarnings, ...this.warnings];
2108
2464
  return this.buildReport(
2109
2465
  "normal-regeneration",
2110
2466
  allPatches,
@@ -2128,6 +2484,7 @@ var ReplayService = class {
2128
2484
  let keptAsUserOwned = 0;
2129
2485
  const seenContentHashes = /* @__PURE__ */ new Set();
2130
2486
  const absorbedPatchIds = /* @__PURE__ */ new Set();
2487
+ const fernignorePatterns = this.readFernignorePatterns();
2131
2488
  for (const result of results) {
2132
2489
  if (result.resolvedFiles && Object.keys(result.resolvedFiles).length > 0) {
2133
2490
  const patch = result.patch;
@@ -2148,34 +2505,78 @@ var ReplayService = class {
2148
2505
  const patch = result.patch;
2149
2506
  if (patch.base_generation === currentGenSha) continue;
2150
2507
  try {
2151
- const fernignorePatterns = this.readFernignorePatterns();
2152
- 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(
2153
2515
  patch.files.map(async (file) => {
2154
- if (file === ".fernignore") {
2516
+ if (file === ".fernignore") return true;
2517
+ if (fernignorePatterns.some((p) => file === p || minimatch2(file, p))) {
2155
2518
  return true;
2156
2519
  }
2157
- 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)) {
2158
2522
  return true;
2159
2523
  }
2160
2524
  const content = await this.git.showFile(currentGenSha, file);
2161
2525
  return content === null;
2162
2526
  })
2163
2527
  );
2164
- const hasUserOwnedFiles = isUserOwned.some(Boolean);
2165
- if (hasUserOwnedFiles) {
2166
- this.lockManager.updatePatch(patch.id, {
2167
- base_generation: currentGenSha
2168
- });
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;
2169
2550
  keptAsUserOwned++;
2551
+ if (baseChanged) repointed++;
2170
2552
  continue;
2171
2553
  }
2172
2554
  const diff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
2173
- 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
+ }
2174
2566
  this.lockManager.removePatch(patch.id);
2175
2567
  absorbedPatchIds.add(patch.id);
2176
2568
  absorbed++;
2177
2569
  continue;
2178
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
+ }
2179
2580
  const newContentHash = this.detector.computeContentHash(diff);
2180
2581
  if (seenContentHashes.has(newContentHash)) {
2181
2582
  this.lockManager.removePatch(patch.id);
@@ -2184,17 +2585,89 @@ var ReplayService = class {
2184
2585
  continue;
2185
2586
  }
2186
2587
  seenContentHashes.add(newContentHash);
2187
- this.lockManager.updatePatch(patch.id, {
2188
- base_generation: currentGenSha,
2189
- patch_content: diff,
2190
- content_hash: newContentHash
2191
- });
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
+ }
2192
2599
  contentRebased++;
2193
2600
  } catch {
2194
2601
  }
2195
2602
  }
2196
2603
  return { absorbed, repointed, contentRebased, keptAsUserOwned, absorbedPatchIds };
2197
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
+ }
2198
2671
  /**
2199
2672
  * For conflict patches with mixed results (some files merged, some conflicted),
2200
2673
  * check if the cleanly merged files were absorbed by the generator (empty diff).
@@ -2208,13 +2681,14 @@ var ReplayService = class {
2208
2681
  const cleanFiles = result.fileResults.filter((f) => f.status === "merged").map((f) => f.file);
2209
2682
  if (cleanFiles.length === 0) return;
2210
2683
  const patch = result.patch;
2684
+ if (patch.user_owned) return;
2211
2685
  const fernignorePatterns = this.readFernignorePatterns();
2686
+ const warnedGens = /* @__PURE__ */ new Set();
2212
2687
  const generatorCleanFiles = [];
2213
2688
  for (const file of cleanFiles) {
2214
- if (file === ".fernignore") continue;
2215
- if (fernignorePatterns.some((p) => file === p || minimatch2(file, p))) continue;
2216
- const content = await this.git.showFile(currentGenSha, file);
2217
- if (content === null) continue;
2689
+ if (await this.isFileUserOwned(file, patch, currentGenSha, fernignorePatterns, warnedGens)) {
2690
+ continue;
2691
+ }
2218
2692
  generatorCleanFiles.push(file);
2219
2693
  }
2220
2694
  if (generatorCleanFiles.length === 0) return;
@@ -2251,7 +2725,35 @@ var ReplayService = class {
2251
2725
  let conflictResolved = 0;
2252
2726
  let conflictAbsorbed = 0;
2253
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
+ };
2254
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
+ }
2255
2757
  if (patch.status != null) {
2256
2758
  delete patch.status;
2257
2759
  continue;
@@ -2261,6 +2763,7 @@ var ReplayService = class {
2261
2763
  const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
2262
2764
  if (diff === null) continue;
2263
2765
  if (!diff.trim()) {
2766
+ if (await allPatchFilesUserOwned(patch)) continue;
2264
2767
  this.lockManager.removePatch(patch.id);
2265
2768
  contentRefreshed++;
2266
2769
  continue;
@@ -2272,6 +2775,12 @@ var ReplayService = class {
2272
2775
  if (hasStaleMarkers) {
2273
2776
  continue;
2274
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
+ }
2275
2784
  const newContentHash = this.detector.computeContentHash(diff);
2276
2785
  if (newContentHash !== patch.content_hash) {
2277
2786
  const filesOutput = await this.git.exec(["diff", "--name-only", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
@@ -2297,6 +2806,13 @@ var ReplayService = class {
2297
2806
  const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
2298
2807
  if (diff === null) continue;
2299
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
+ }
2300
2816
  this.lockManager.removePatch(patch.id);
2301
2817
  conflictAbsorbed++;
2302
2818
  continue;
@@ -2808,7 +3324,10 @@ function ensureGitattributesEntries(outputDir) {
2808
3324
  writeFileSync4(gitattributesPath, content, "utf-8");
2809
3325
  }
2810
3326
  function computeContentHash(patchContent) {
2811
- 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*$/, "");
2812
3331
  return `sha256:${createHash3("sha256").update(normalized).digest("hex")}`;
2813
3332
  }
2814
3333
 
@@ -3119,6 +3638,7 @@ function status(outputDir) {
3119
3638
  };
3120
3639
  }
3121
3640
  export {
3641
+ CREDENTIAL_PATTERNS,
3122
3642
  FERN_BOT_EMAIL,
3123
3643
  FERN_BOT_LOGIN,
3124
3644
  FERN_BOT_NAME,
@@ -3130,15 +3650,18 @@ export {
3130
3650
  ReplayCommitter,
3131
3651
  ReplayDetector,
3132
3652
  ReplayService,
3653
+ SENSITIVE_FILE_PATTERNS,
3133
3654
  bootstrap,
3134
3655
  forget,
3135
3656
  isGenerationCommit,
3136
3657
  isReplayCommit,
3137
3658
  isRevertCommit,
3659
+ isSensitiveFile,
3138
3660
  parseRevertedMessage,
3139
3661
  parseRevertedSha,
3140
3662
  reset,
3141
3663
  resolve,
3664
+ scanForCredentials,
3142
3665
  status,
3143
3666
  threeWayMerge
3144
3667
  };