@fern-api/replay 0.16.1 → 0.16.2

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/cli.cjs CHANGED
@@ -14979,6 +14979,15 @@ function parseRevertedMessage(subject) {
14979
14979
 
14980
14980
  // src/ReplayDetector.ts
14981
14981
  var INFRASTRUCTURE_FILES = /* @__PURE__ */ new Set([".fernignore"]);
14982
+ function matchesFernignorePattern(filePath, patterns) {
14983
+ if (patterns.length === 0) return false;
14984
+ return patterns.some((p) => {
14985
+ if (filePath === p) return true;
14986
+ if (minimatch(filePath, p)) return true;
14987
+ if (!p.includes("*") && !p.includes("?") && filePath.startsWith(p + "/")) return true;
14988
+ return false;
14989
+ });
14990
+ }
14982
14991
  function parsePatchFileHeaders(patchContent) {
14983
14992
  const results = [];
14984
14993
  let currentPath = null;
@@ -15019,21 +15028,25 @@ async function capturePatchSnapshot(git, files, treeish) {
15019
15028
  }
15020
15029
  return snapshot;
15021
15030
  }
15022
- function filterInfrastructureAndSensitiveFiles(rawFilesOutput) {
15031
+ function filterInfrastructureAndSensitiveFiles(rawFilesOutput, fernignorePatterns) {
15023
15032
  const allFiles = rawFilesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
15024
15033
  const sensitiveFiles = allFiles.filter((f) => isSensitiveFile(f));
15025
- const files = allFiles.filter((f) => !isSensitiveFile(f));
15026
- return { files, sensitiveFiles };
15034
+ const nonSensitive = allFiles.filter((f) => !isSensitiveFile(f));
15035
+ const fernignoredFiles = nonSensitive.filter((f) => matchesFernignorePattern(f, fernignorePatterns));
15036
+ const files = nonSensitive.filter((f) => !matchesFernignorePattern(f, fernignorePatterns));
15037
+ return { files, sensitiveFiles, fernignoredFiles };
15027
15038
  }
15028
15039
  var ReplayDetector = class {
15029
15040
  git;
15030
15041
  lockManager;
15031
15042
  sdkOutputDir;
15043
+ fernignorePatterns;
15032
15044
  warnings = [];
15033
- constructor(git, lockManager, sdkOutputDir) {
15045
+ constructor(git, lockManager, sdkOutputDir, fernignorePatterns = []) {
15034
15046
  this.git = git;
15035
15047
  this.lockManager = lockManager;
15036
15048
  this.sdkOutputDir = sdkOutputDir;
15049
+ this.fernignorePatterns = fernignorePatterns;
15037
15050
  }
15038
15051
  /**
15039
15052
  * Derive the previous-generation SHA from git history instead of trusting
@@ -15140,22 +15153,30 @@ var ReplayDetector = class {
15140
15153
  continue;
15141
15154
  }
15142
15155
  const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
15143
- const { files, sensitiveFiles } = filterInfrastructureAndSensitiveFiles(filesOutput);
15156
+ const { files, sensitiveFiles, fernignoredFiles } = filterInfrastructureAndSensitiveFiles(
15157
+ filesOutput,
15158
+ this.fernignorePatterns
15159
+ );
15144
15160
  if (sensitiveFiles.length > 0) {
15145
15161
  this.warnings.push(
15146
15162
  `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
15147
15163
  );
15148
- if (files.length > 0) {
15149
- const parentSha = parents[0];
15150
- if (parentSha) {
15151
- try {
15152
- patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
15153
- } catch {
15154
- continue;
15155
- }
15156
- if (!patchContent.trim()) continue;
15157
- contentHash = this.computeContentHash(patchContent);
15164
+ }
15165
+ if (fernignoredFiles.length > 0) {
15166
+ this.warnings.push(
15167
+ `.fernignore-protected file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${fernignoredFiles.join(", ")}. These files are managed by .fernignore, not Replay.`
15168
+ );
15169
+ }
15170
+ if ((sensitiveFiles.length > 0 || fernignoredFiles.length > 0) && files.length > 0) {
15171
+ const parentSha = parents[0];
15172
+ if (parentSha) {
15173
+ try {
15174
+ patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
15175
+ } catch {
15176
+ continue;
15158
15177
  }
15178
+ if (!patchContent.trim()) continue;
15179
+ contentHash = this.computeContentHash(patchContent);
15159
15180
  }
15160
15181
  }
15161
15182
  if (files.length === 0) {
@@ -15354,12 +15375,20 @@ var ReplayDetector = class {
15354
15375
  resolvedBaseGeneration = fallback;
15355
15376
  }
15356
15377
  const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
15357
- const { files, sensitiveFiles } = filterInfrastructureAndSensitiveFiles(filesOutput);
15378
+ const { files, sensitiveFiles, fernignoredFiles } = filterInfrastructureAndSensitiveFiles(
15379
+ filesOutput,
15380
+ this.fernignorePatterns
15381
+ );
15358
15382
  if (sensitiveFiles.length > 0) {
15359
15383
  this.warnings.push(
15360
15384
  `Sensitive file(s) excluded from composite patch: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
15361
15385
  );
15362
15386
  }
15387
+ if (fernignoredFiles.length > 0) {
15388
+ this.warnings.push(
15389
+ `.fernignore-protected file(s) excluded from composite patch: ${fernignoredFiles.join(", ")}. These files are managed by .fernignore, not Replay.`
15390
+ );
15391
+ }
15363
15392
  if (files.length === 0) return { patches: [], revertedPatchIds: [] };
15364
15393
  const diff = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
15365
15394
  if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
@@ -15442,23 +15471,31 @@ var ReplayDetector = class {
15442
15471
  continue;
15443
15472
  }
15444
15473
  const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
15445
- const { files, sensitiveFiles } = filterInfrastructureAndSensitiveFiles(filesOutput);
15474
+ const { files, sensitiveFiles, fernignoredFiles } = filterInfrastructureAndSensitiveFiles(
15475
+ filesOutput,
15476
+ this.fernignorePatterns
15477
+ );
15446
15478
  if (sensitiveFiles.length > 0) {
15447
15479
  this.warnings.push(
15448
15480
  `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
15449
15481
  );
15450
- if (files.length > 0) {
15451
- if (parents.length === 0) {
15452
- continue;
15453
- }
15454
- try {
15455
- patchContent = await this.git.exec(["diff", parents[0], commit.sha, "--", ...files]);
15456
- } catch {
15457
- continue;
15458
- }
15459
- if (!patchContent.trim()) continue;
15460
- contentHash = this.computeContentHash(patchContent);
15482
+ }
15483
+ if (fernignoredFiles.length > 0) {
15484
+ this.warnings.push(
15485
+ `.fernignore-protected file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${fernignoredFiles.join(", ")}. These files are managed by .fernignore, not Replay.`
15486
+ );
15487
+ }
15488
+ if ((sensitiveFiles.length > 0 || fernignoredFiles.length > 0) && files.length > 0) {
15489
+ if (parents.length === 0) {
15490
+ continue;
15491
+ }
15492
+ try {
15493
+ patchContent = await this.git.exec(["diff", parents[0], commit.sha, "--", ...files]);
15494
+ } catch {
15495
+ continue;
15461
15496
  }
15497
+ if (!patchContent.trim()) continue;
15498
+ contentHash = this.computeContentHash(patchContent);
15462
15499
  }
15463
15500
  if (files.length === 0) {
15464
15501
  continue;
@@ -17785,7 +17822,8 @@ var ReplayService = class {
17785
17822
  this.git = git;
17786
17823
  this.outputDir = outputDir;
17787
17824
  this.lockManager = new LockfileManager(outputDir);
17788
- this.detector = new ReplayDetector(git, this.lockManager, outputDir);
17825
+ const fernignorePatterns = this.readFernignorePatterns();
17826
+ this.detector = new ReplayDetector(git, this.lockManager, outputDir, fernignorePatterns);
17789
17827
  this.applicator = new ReplayApplicator(git, this.lockManager, outputDir);
17790
17828
  this.committer = new ReplayCommitter(git, outputDir);
17791
17829
  this.fileOwnership = new FileOwnership(git);
@@ -17805,8 +17843,60 @@ var ReplayService = class {
17805
17843
  async prepareReplay(options) {
17806
17844
  this.warnings = [];
17807
17845
  this.detector.warnings.length = 0;
17846
+ this.cleanseLegacyFernignoreEntries();
17808
17847
  return this.selectFlow(options).prepare(options);
17809
17848
  }
17849
+ /**
17850
+ * ADR 0002 migration step. Strips `.fernignore`-matched entries from
17851
+ * `patch.files`, `patch.theirs_snapshot` keys, and `diff --git` sections
17852
+ * in `patch.patch_content` for every patch in the lockfile. Patches that
17853
+ * end up with no surviving files are removed entirely.
17854
+ *
17855
+ * Idempotent: a second run is a no-op because the first run already
17856
+ * matches the contract. Per-patch try/catch ensures one bad patch does
17857
+ * not block the rest. Each affected patch emits a warning naming the
17858
+ * stripped paths. `patch_content` is left untouched if no parseable
17859
+ * `diff --git` headers are found (fail-safe for exotic legacy formats).
17860
+ */
17861
+ cleanseLegacyFernignoreEntries() {
17862
+ const patterns = this.readFernignorePatterns();
17863
+ if (patterns.length === 0) return;
17864
+ if (!this.lockManager.exists()) return;
17865
+ this.lockManager.read();
17866
+ let lockfileMutated = false;
17867
+ const patches = this.lockManager.getPatches();
17868
+ for (const patch of patches) {
17869
+ try {
17870
+ const result = computeLegacyFernignoreCleanse(
17871
+ patch,
17872
+ patterns,
17873
+ (content) => this.detector.computeContentHash(content)
17874
+ );
17875
+ if (result.unchanged) continue;
17876
+ if (result.remove) {
17877
+ this.lockManager.removePatch(patch.id);
17878
+ this.warnings.push(
17879
+ `Cleansed legacy patch ${patch.id}: all referenced files match .fernignore \u2014 patch removed. Customer's on-disk content for those files is preserved by .fernignore. (ADR 0002: .fernignore-protected files are not tracked by Replay.)`
17880
+ );
17881
+ } else {
17882
+ this.lockManager.updatePatch(patch.id, {
17883
+ files: result.files,
17884
+ patch_content: result.patch_content,
17885
+ content_hash: result.content_hash,
17886
+ ...result.theirs_snapshot !== void 0 ? { theirs_snapshot: result.theirs_snapshot } : {}
17887
+ });
17888
+ this.warnings.push(
17889
+ `Cleansed legacy patch ${patch.id}: stripped .fernignore-matched entries (${result.strippedPaths.join(", ")}). Customer's on-disk content for those files is preserved by .fernignore. (ADR 0002.)`
17890
+ );
17891
+ }
17892
+ lockfileMutated = true;
17893
+ } catch {
17894
+ }
17895
+ }
17896
+ if (lockfileMutated) {
17897
+ this.lockManager.save();
17898
+ }
17899
+ }
17810
17900
  /**
17811
17901
  * Phase 2 of the two-phase replay flow. Applies patches, rebases them against
17812
17902
  * the generation, saves the lockfile, and commits `[fern-replay]` (or stages
@@ -18667,6 +18757,93 @@ function computeCommitBuckets(results, absorbedPatchIds, patchesPassedToCommit)
18667
18757
  }
18668
18758
  return { applied, unresolved, absorbed };
18669
18759
  }
18760
+ function computeLegacyFernignoreCleanse(patch, fernignorePatterns, computeContentHash2) {
18761
+ const matchesFernignore = (filePath) => fernignorePatterns.some((p) => {
18762
+ if (filePath === p) return true;
18763
+ if (minimatch(filePath, p)) return true;
18764
+ if (!p.includes("*") && !p.includes("?") && filePath.startsWith(p + "/")) return true;
18765
+ return false;
18766
+ });
18767
+ const strippedFromFiles = patch.files.filter(matchesFernignore);
18768
+ const survivingFiles = patch.files.filter((f) => !matchesFernignore(f));
18769
+ let survivingSnapshot = void 0;
18770
+ let snapshotChanged = false;
18771
+ if (patch.theirs_snapshot) {
18772
+ survivingSnapshot = {};
18773
+ for (const [key, value] of Object.entries(patch.theirs_snapshot)) {
18774
+ if (matchesFernignore(key)) {
18775
+ snapshotChanged = true;
18776
+ } else {
18777
+ survivingSnapshot[key] = value;
18778
+ }
18779
+ }
18780
+ }
18781
+ const { stripped: patchContentStripped, content: newPatchContent, strippedSectionPaths } = stripFernignoreDiffSections(patch.patch_content, matchesFernignore);
18782
+ const unchanged = strippedFromFiles.length === 0 && !snapshotChanged && !patchContentStripped;
18783
+ if (unchanged) {
18784
+ return {
18785
+ unchanged: true,
18786
+ remove: false,
18787
+ files: patch.files,
18788
+ patch_content: patch.patch_content,
18789
+ content_hash: patch.content_hash,
18790
+ theirs_snapshot: patch.theirs_snapshot,
18791
+ strippedPaths: []
18792
+ };
18793
+ }
18794
+ const strippedPaths = Array.from(/* @__PURE__ */ new Set([...strippedFromFiles, ...strippedSectionPaths]));
18795
+ if (survivingFiles.length === 0 || newPatchContent.trim() === "") {
18796
+ return {
18797
+ unchanged: false,
18798
+ remove: true,
18799
+ files: [],
18800
+ patch_content: "",
18801
+ content_hash: "",
18802
+ strippedPaths
18803
+ };
18804
+ }
18805
+ const newContentHash = patchContentStripped ? computeContentHash2(newPatchContent) : patch.content_hash;
18806
+ return {
18807
+ unchanged: false,
18808
+ remove: false,
18809
+ files: survivingFiles,
18810
+ patch_content: newPatchContent,
18811
+ content_hash: newContentHash,
18812
+ ...survivingSnapshot !== void 0 ? { theirs_snapshot: survivingSnapshot } : {},
18813
+ strippedPaths
18814
+ };
18815
+ }
18816
+ function stripFernignoreDiffSections(patchContent, matchesFernignore) {
18817
+ const lines = patchContent.split("\n");
18818
+ const sectionStarts = [];
18819
+ for (let i = 0; i < lines.length; i++) {
18820
+ const line = lines[i];
18821
+ if (line.startsWith("diff --git ")) {
18822
+ const match2 = line.match(/^diff --git a\/(.+?) b\/(.+?)$/);
18823
+ const path2 = match2 ? match2[2] : null;
18824
+ sectionStarts.push({ idx: i, path: path2 });
18825
+ }
18826
+ }
18827
+ if (sectionStarts.length === 0) {
18828
+ return { stripped: false, content: patchContent, strippedSectionPaths: [] };
18829
+ }
18830
+ const preamble = lines.slice(0, sectionStarts[0].idx);
18831
+ const newLines = [...preamble];
18832
+ const strippedSectionPaths = [];
18833
+ let stripped = false;
18834
+ for (let i = 0; i < sectionStarts.length; i++) {
18835
+ const section = sectionStarts[i];
18836
+ const next = sectionStarts[i + 1];
18837
+ const endIdx = next ? next.idx : lines.length;
18838
+ if (section.path !== null && matchesFernignore(section.path)) {
18839
+ stripped = true;
18840
+ strippedSectionPaths.push(section.path);
18841
+ continue;
18842
+ }
18843
+ newLines.push(...lines.slice(section.idx, endIdx));
18844
+ }
18845
+ return { stripped, content: newLines.join("\n"), strippedSectionPaths };
18846
+ }
18670
18847
 
18671
18848
  // src/FernignoreMigrator.ts
18672
18849
  var import_node_crypto2 = require("crypto");