@fern-api/fern-api-dev 5.4.0 → 5.4.1

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.
Files changed (2) hide show
  1. package/cli.cjs +1096 -703
  2. package/package.json +1 -1
package/cli.cjs CHANGED
@@ -340394,9 +340394,9 @@ var require_diff3 = __commonJS({
340394
340394
  }
340395
340395
  });
340396
340396
 
340397
- // ../../../node_modules/.pnpm/@fern-api+replay@0.14.0/node_modules/@fern-api/replay/dist/index.cjs
340397
+ // ../../../node_modules/.pnpm/@fern-api+replay@0.14.1/node_modules/@fern-api/replay/dist/index.cjs
340398
340398
  var require_dist14 = __commonJS({
340399
- "../../../node_modules/.pnpm/@fern-api+replay@0.14.0/node_modules/@fern-api/replay/dist/index.cjs"(exports2, module4) {
340399
+ "../../../node_modules/.pnpm/@fern-api+replay@0.14.1/node_modules/@fern-api/replay/dist/index.cjs"(exports2, module4) {
340400
340400
  "use strict";
340401
340401
  var __defProp4 = Object.defineProperty;
340402
340402
  var __getOwnPropDesc4 = Object.getOwnPropertyDescriptor;
@@ -340802,6 +340802,64 @@ var require_dist14 = __commonJS({
340802
340802
  "use strict";
340803
340803
  }
340804
340804
  });
340805
+ var PatchApplyTheirs_exports = {};
340806
+ __export4(PatchApplyTheirs_exports, {
340807
+ applyPatchToContent: () => applyPatchToContent
340808
+ });
340809
+ async function applyPatchToContent(base4, patchContent, filePath) {
340810
+ const fileDiff = extractFileDiff(patchContent, filePath);
340811
+ if (!fileDiff) return null;
340812
+ const tempDir2 = await (0, import_promises310.mkdtemp)((0, import_node_path42.join)((0, import_node_os32.tmpdir)(), "replay-migrate-"));
340813
+ const tempGit = new GitClient(tempDir2);
340814
+ try {
340815
+ await tempGit.exec(["init"]);
340816
+ await tempGit.exec(["config", "user.email", "migrate@fern.com"]);
340817
+ await tempGit.exec(["config", "user.name", "Fern Replay Migration"]);
340818
+ await tempGit.exec(["config", "commit.gpgSign", "false"]);
340819
+ const tempFilePath = (0, import_node_path42.join)(tempDir2, filePath);
340820
+ await (0, import_promises310.mkdir)((0, import_node_path42.dirname)(tempFilePath), { recursive: true });
340821
+ await (0, import_promises310.writeFile)(tempFilePath, base4);
340822
+ await tempGit.exec(["add", filePath]);
340823
+ await tempGit.exec(["commit", "-m", `base for ${filePath}`, "--allow-empty"]);
340824
+ await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
340825
+ return await (0, import_promises310.readFile)(tempFilePath, "utf-8");
340826
+ } catch {
340827
+ return null;
340828
+ } finally {
340829
+ await (0, import_promises310.rm)(tempDir2, { recursive: true, force: true }).catch(() => {
340830
+ });
340831
+ }
340832
+ }
340833
+ function extractFileDiff(patchContent, filePath) {
340834
+ const lines = patchContent.split("\n");
340835
+ const out = [];
340836
+ let inTarget = false;
340837
+ for (const line of lines) {
340838
+ if (line.startsWith("diff --git")) {
340839
+ if (inTarget) break;
340840
+ const match3 = line.match(/^diff --git a\/.+ b\/(.+)$/);
340841
+ if (match3 && match3[1] === filePath) {
340842
+ inTarget = true;
340843
+ out.push(line);
340844
+ }
340845
+ continue;
340846
+ }
340847
+ if (inTarget) out.push(line);
340848
+ }
340849
+ return out.length > 0 ? out.join("\n") + "\n" : null;
340850
+ }
340851
+ var import_promises310;
340852
+ var import_node_os32;
340853
+ var import_node_path42;
340854
+ var init_PatchApplyTheirs = __esm3({
340855
+ "src/PatchApplyTheirs.ts"() {
340856
+ "use strict";
340857
+ import_promises310 = require("fs/promises");
340858
+ import_node_os32 = require("os");
340859
+ import_node_path42 = require("path");
340860
+ init_GitClient();
340861
+ }
340862
+ });
340805
340863
  var index_exports = {};
340806
340864
  __export4(index_exports, {
340807
340865
  CREDENTIAL_PATTERNS: () => CREDENTIAL_PATTERNS,
@@ -340892,7 +340950,7 @@ var require_dist14 = __commonJS({
340892
340950
  return match3?.[1];
340893
340951
  }
340894
340952
  var import_node_fs32 = require("fs");
340895
- var import_node_path42 = require("path");
340953
+ var import_node_path43 = require("path");
340896
340954
  var import_yaml18 = require_dist9();
340897
340955
  var LOCKFILE_HEADER = "# DO NOT EDIT MANUALLY - Managed by Fern Replay\n";
340898
340956
  var LockfileNotFoundError = class extends Error {
@@ -340909,10 +340967,10 @@ var require_dist14 = __commonJS({
340909
340967
  this.outputDir = outputDir;
340910
340968
  }
340911
340969
  get lockfilePath() {
340912
- return (0, import_node_path42.join)(this.outputDir, ".fern", "replay.lock");
340970
+ return (0, import_node_path43.join)(this.outputDir, ".fern", "replay.lock");
340913
340971
  }
340914
340972
  get customizationsPath() {
340915
- return (0, import_node_path42.join)(this.outputDir, ".fern", "replay.yml");
340973
+ return (0, import_node_path43.join)(this.outputDir, ".fern", "replay.yml");
340916
340974
  }
340917
340975
  exists() {
340918
340976
  return (0, import_node_fs32.existsSync)(this.lockfilePath);
@@ -340956,7 +341014,17 @@ var require_dist14 = __commonJS({
340956
341014
  this.warnings.length = 0;
340957
341015
  const cleanPatches = [];
340958
341016
  for (const patch5 of this.lock.patches) {
340959
- const credentialMatches = scanForCredentials(patch5.patch_content);
341017
+ const diffMatches = scanForCredentials(patch5.patch_content);
341018
+ const snapshotMatches = [];
341019
+ if (patch5.theirs_snapshot) {
341020
+ for (const content22 of Object.values(patch5.theirs_snapshot)) {
341021
+ const m7 = scanForCredentials(content22);
341022
+ for (const x5 of m7) {
341023
+ if (!snapshotMatches.includes(x5)) snapshotMatches.push(x5);
341024
+ }
341025
+ }
341026
+ }
341027
+ const credentialMatches = [...diffMatches, ...snapshotMatches.filter((m7) => !diffMatches.includes(m7))];
340960
341028
  const sensitiveFiles = patch5.files.filter((f5) => isSensitiveFile(f5));
340961
341029
  if (credentialMatches.length > 0 || sensitiveFiles.length > 0) {
340962
341030
  const reasons = [];
@@ -340974,7 +341042,7 @@ var require_dist14 = __commonJS({
340974
341042
  }
340975
341043
  }
340976
341044
  this.lock.patches = cleanPatches;
340977
- const dir = (0, import_node_path42.dirname)(this.lockfilePath);
341045
+ const dir = (0, import_node_path43.dirname)(this.lockfilePath);
340978
341046
  if (!(0, import_node_fs32.existsSync)(dir)) {
340979
341047
  (0, import_node_fs32.mkdirSync)(dir, { recursive: true });
340980
341048
  }
@@ -341075,572 +341143,81 @@ var require_dist14 = __commonJS({
341075
341143
  }
341076
341144
  };
341077
341145
  var import_node_crypto7 = require("crypto");
341078
- var INFRASTRUCTURE_FILES = /* @__PURE__ */ new Set([".fernignore"]);
341079
- function parsePatchFileHeaders(patchContent) {
341080
- const results = [];
341081
- let currentPath = null;
341082
- let currentIsCreate = false;
341083
- let currentIsDelete = false;
341084
- const flush3 = () => {
341085
- if (currentPath !== null) {
341086
- results.push({ path: currentPath, isCreate: currentIsCreate, isDelete: currentIsDelete });
341087
- }
341088
- };
341089
- for (const line of patchContent.split("\n")) {
341090
- if (line.startsWith("diff --git ")) {
341091
- flush3();
341092
- currentPath = null;
341093
- currentIsCreate = false;
341094
- currentIsDelete = false;
341095
- const match3 = line.match(/^diff --git a\/(.+?) b\/(.+?)$/);
341096
- if (match3) {
341097
- currentPath = match3[2] ?? null;
341098
- }
341099
- } else if (currentPath !== null) {
341100
- if (line.startsWith("new file mode ")) {
341101
- currentIsCreate = true;
341102
- } else if (line.startsWith("deleted file mode ")) {
341103
- currentIsDelete = true;
341104
- }
341105
- }
341106
- }
341107
- flush3();
341108
- return results;
341146
+ var import_promises193 = require("fs/promises");
341147
+ var import_node_os15 = require("os");
341148
+ var import_node_path210 = require("path");
341149
+ var import_minimatch8 = require_commonjs3();
341150
+ var CONFLICT_OPENER = "<<<<<<< Generated";
341151
+ var CONFLICT_SEPARATOR = "=======";
341152
+ var CONFLICT_CLOSER = ">>>>>>> Your customization";
341153
+ function trimCR(line) {
341154
+ return line.endsWith("\r") ? line.slice(0, -1) : line;
341109
341155
  }
341110
- var ReplayDetector = class {
341111
- git;
341112
- lockManager;
341113
- sdkOutputDir;
341114
- warnings = [];
341115
- constructor(git, lockManager, sdkOutputDir) {
341116
- this.git = git;
341117
- this.lockManager = lockManager;
341118
- this.sdkOutputDir = sdkOutputDir;
341119
- }
341120
- async detectNewPatches() {
341121
- const lock = this.lockManager.read();
341122
- const lastGen = this.getLastGeneration(lock);
341123
- if (!lastGen) {
341124
- return { patches: [], revertedPatchIds: [] };
341125
- }
341126
- const exists2 = await this.git.commitExists(lastGen.commit_sha);
341127
- if (!exists2) {
341128
- this.warnings.push(
341129
- `Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to alternate detection.`
341130
- );
341131
- return this.detectPatchesViaTreeDiff(
341132
- lastGen,
341133
- /* commitKnownMissing */
341134
- true
341135
- );
341136
- }
341137
- const isAncestor = await this.git.isAncestor(lastGen.commit_sha, "HEAD");
341138
- if (!isAncestor) {
341139
- return this.detectPatchesViaMergeBase(lastGen, lock);
341140
- }
341141
- return this.detectPatchesInRange(lastGen.commit_sha, lastGen, lock);
341142
- }
341143
- /**
341144
- * FER-10201 — Non-linear-history detection via `merge-base(prevGen, HEAD)`.
341145
- *
341146
- * After a squash-merge of a Fern-bot PR, `lastGen.commit_sha` (the previous
341147
- * `[fern-generated]`) is orphaned but still in git's object database. The
341148
- * merge-base is the commit on main from which the bot's branch was created —
341149
- * a reachable ancestor of HEAD. Walking that range and classifying each
341150
- * commit via `isGenerationCommit` mirrors the linear path and eliminates
341151
- * the bug class where pipeline-driven changes (autoversion, replay,
341152
- * generation) get bundled as customer customizations.
341153
- *
341154
- * Falls through to the legacy composite path when no common ancestor exists
341155
- * (truly disjoint history — orphan branches with no shared root).
341156
- */
341157
- async detectPatchesViaMergeBase(lastGen, lock) {
341158
- let mergeBase = "";
341159
- try {
341160
- mergeBase = (await this.git.exec(["merge-base", lastGen.commit_sha, "HEAD"])).trim();
341161
- } catch {
341162
- }
341163
- if (!mergeBase) {
341164
- this.warnings.push(
341165
- `No common ancestor between previous generation ${lastGen.commit_sha.slice(0, 7)} and HEAD. Falling back to composite tree-diff detection.`
341166
- );
341167
- return this.detectPatchesViaTreeDiff(
341168
- lastGen,
341169
- /* commitKnownMissing */
341170
- false
341171
- );
341172
- }
341173
- return this.detectPatchesInRange(mergeBase, lastGen, lock);
341174
- }
341175
- /**
341176
- * FER-10201 — Walk `${rangeStart}..HEAD`, classify each commit, emit one
341177
- * `StoredPatch` per customer commit. Body shared by the linear path
341178
- * (rangeStart = lastGen.commit_sha) and the merge-base path.
341179
- *
341180
- * Patch `base_generation` is always `lastGen.commit_sha` — the
341181
- * lockfile-tracked reference the applicator uses to fetch base file
341182
- * content, regardless of where the walk actually started.
341183
- */
341184
- async detectPatchesInRange(rangeStart, lastGen, lock) {
341185
- const log4 = await this.git.exec([
341186
- "log",
341187
- "--first-parent",
341188
- "--format=%H%x00%an%x00%ae%x00%s",
341189
- `${rangeStart}..HEAD`,
341190
- "--",
341191
- this.sdkOutputDir
341192
- ]);
341193
- if (!log4.trim()) {
341194
- return { patches: [], revertedPatchIds: [] };
341195
- }
341196
- const commits = this.parseGitLog(log4);
341197
- const newPatches = [];
341198
- const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
341199
- for (const commit of commits) {
341200
- if (isGenerationCommit(commit)) {
341201
- continue;
341202
- }
341203
- if (lock.patches.find((p14) => p14.original_commit === commit.sha)) {
341204
- continue;
341205
- }
341206
- const parents = await this.git.getCommitParents(commit.sha);
341207
- if (parents.length > 1) {
341208
- const compositePatch = await this.createMergeCompositePatch({
341209
- mergeCommit: commit,
341210
- firstParent: parents[0],
341211
- lastGen,
341212
- lock
341213
- });
341214
- if (compositePatch) {
341215
- newPatches.push(compositePatch);
341156
+ function findConflictRanges(lines) {
341157
+ const ranges = [];
341158
+ let i9 = 0;
341159
+ while (i9 < lines.length) {
341160
+ if (trimCR(lines[i9]) === CONFLICT_OPENER) {
341161
+ let separatorIdx = -1;
341162
+ let j7 = i9 + 1;
341163
+ let found = false;
341164
+ while (j7 < lines.length) {
341165
+ const trimmed2 = trimCR(lines[j7]);
341166
+ if (trimmed2 === CONFLICT_OPENER) {
341167
+ break;
341216
341168
  }
341217
- continue;
341218
- }
341219
- let patchContent;
341220
- try {
341221
- patchContent = await this.git.formatPatch(commit.sha);
341222
- } catch {
341223
- this.warnings.push(
341224
- `Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
341225
- );
341226
- continue;
341227
- }
341228
- let contentHash = this.computeContentHash(patchContent);
341229
- if (lock.patches.find((p14) => p14.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
341230
- continue;
341231
- }
341232
- const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
341233
- const allFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f5) => !INFRASTRUCTURE_FILES.has(f5)).filter((f5) => !f5.startsWith(".fern/"));
341234
- const sensitiveFiles = allFiles.filter((f5) => isSensitiveFile(f5));
341235
- const files = allFiles.filter((f5) => !isSensitiveFile(f5));
341236
- if (sensitiveFiles.length > 0) {
341237
- this.warnings.push(
341238
- `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
341239
- );
341240
- if (files.length > 0) {
341241
- const parentSha = parents[0];
341242
- if (parentSha) {
341243
- try {
341244
- patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
341245
- } catch {
341246
- continue;
341247
- }
341248
- if (!patchContent.trim()) continue;
341249
- contentHash = this.computeContentHash(patchContent);
341250
- }
341169
+ if (separatorIdx === -1 && trimmed2 === CONFLICT_SEPARATOR) {
341170
+ separatorIdx = j7;
341171
+ } else if (separatorIdx !== -1 && trimmed2 === CONFLICT_CLOSER) {
341172
+ ranges.push({ start: i9, separator: separatorIdx, end: j7 });
341173
+ i9 = j7;
341174
+ found = true;
341175
+ break;
341251
341176
  }
341177
+ j7++;
341252
341178
  }
341253
- if (files.length === 0) {
341179
+ if (!found) {
341180
+ i9++;
341254
341181
  continue;
341255
341182
  }
341256
- newPatches.push({
341257
- id: `patch-${commit.sha.slice(0, 8)}`,
341258
- content_hash: contentHash,
341259
- original_commit: commit.sha,
341260
- original_message: commit.message,
341261
- original_author: `${commit.authorName} <${commit.authorEmail}>`,
341262
- base_generation: lastGen.commit_sha,
341263
- files,
341264
- patch_content: patchContent,
341265
- ...this.isCreationOnlyPatch(patchContent) ? { user_owned: true } : {}
341266
- });
341267
- }
341268
- const survivingPatches = await this.collapseNetZeroFiles(newPatches);
341269
- survivingPatches.reverse();
341270
- const revertedPatchIdSet = /* @__PURE__ */ new Set();
341271
- const revertIndicesToRemove = /* @__PURE__ */ new Set();
341272
- for (let i9 = 0; i9 < survivingPatches.length; i9++) {
341273
- const patch5 = survivingPatches[i9];
341274
- if (!isRevertCommit(patch5.original_message)) continue;
341275
- let body = "";
341276
- try {
341277
- body = await this.git.getCommitBody(patch5.original_commit);
341278
- } catch {
341279
- }
341280
- const revertedSha = parseRevertedSha(body);
341281
- const revertedMessage = parseRevertedMessage(patch5.original_message);
341282
- let matchedExisting = false;
341283
- if (revertedSha) {
341284
- const existing = lock.patches.find((p14) => p14.original_commit === revertedSha);
341285
- if (existing) {
341286
- revertedPatchIdSet.add(existing.id);
341287
- revertIndicesToRemove.add(i9);
341288
- matchedExisting = true;
341289
- }
341290
- }
341291
- if (!matchedExisting && revertedMessage) {
341292
- const existing = lock.patches.find((p14) => p14.original_message === revertedMessage);
341293
- if (existing) {
341294
- revertedPatchIdSet.add(existing.id);
341295
- revertIndicesToRemove.add(i9);
341296
- matchedExisting = true;
341297
- }
341298
- }
341299
- if (matchedExisting) continue;
341300
- let matchedNew = false;
341301
- if (revertedSha) {
341302
- const idx = survivingPatches.findIndex(
341303
- (p14, j7) => j7 !== i9 && !revertIndicesToRemove.has(j7) && p14.original_commit === revertedSha
341304
- );
341305
- if (idx !== -1) {
341306
- revertIndicesToRemove.add(i9);
341307
- revertIndicesToRemove.add(idx);
341308
- matchedNew = true;
341309
- }
341310
- }
341311
- if (!matchedNew && revertedMessage) {
341312
- const idx = survivingPatches.findIndex(
341313
- (p14, j7) => j7 !== i9 && !revertIndicesToRemove.has(j7) && p14.original_message === revertedMessage
341314
- );
341315
- if (idx !== -1) {
341316
- revertIndicesToRemove.add(i9);
341317
- revertIndicesToRemove.add(idx);
341318
- }
341319
- }
341320
- if (!matchedExisting && !matchedNew) {
341321
- revertIndicesToRemove.add(i9);
341322
- }
341323
341183
  }
341324
- const filteredPatches = survivingPatches.filter((_7, i9) => !revertIndicesToRemove.has(i9));
341325
- return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
341326
- }
341327
- /**
341328
- * Compute content hash for deduplication.
341329
- *
341330
- * Produces a format-agnostic hash so both `git format-patch` output
341331
- * (email-wrapped) and plain `git diff` output hash to the same value
341332
- * when the underlying diff hunks are identical.
341333
- *
341334
- * Normalization:
341335
- * 1. Strip email wrapper: everything before the first `diff --git` line
341336
- * (From:, Subject:, Date:, diffstat, blank separators).
341337
- * 2. Strip `index` lines (blob SHA pairs change across rebases).
341338
- * 3. Strip trailing git version marker (`-- \n<version>`).
341339
- *
341340
- * This ensures content hashes match across the no-patches and normal-
341341
- * regeneration flows, which store formatPatch and git-diff formats
341342
- * respectively (FER-9850, D5-D9).
341343
- */
341344
- computeContentHash(patchContent) {
341345
- const lines = patchContent.split("\n");
341346
- const diffStart = lines.findIndex((l9) => l9.startsWith("diff --git "));
341347
- const relevantLines = diffStart > 0 ? lines.slice(diffStart) : lines;
341348
- const normalized = relevantLines.filter((line) => !line.startsWith("index ")).join("\n").replace(/\n-- \n[\d]+\.[\d]+[^\n]*\s*$/, "");
341349
- return `sha256:${(0, import_node_crypto7.createHash)("sha256").update(normalized).digest("hex")}`;
341350
- }
341351
- /**
341352
- * FER-9805 — Drop patches whose files were transiently created and then
341353
- * deleted within the current linear detection window, and are absent from
341354
- * HEAD at the end of the window.
341355
- *
341356
- * Rationale: on a merge commit, createMergeCompositePatch already diffs
341357
- * firstParent..mergeCommit so net-zero files never enter the composite. On
341358
- * linear history each commit becomes its own patch, so a file that was
341359
- * briefly added and later removed survives as a create+delete pair whose
341360
- * cumulative diff is empty. We drop such patches before they reach the
341361
- * lockfile.
341362
- *
341363
- * A file is "transient" when:
341364
- * 1. some patch in the window sets `new file mode` for it (a creation), AND
341365
- * 2. some patch in the window sets `deleted file mode` for it (a deletion), AND
341366
- * 3. it is absent from HEAD's tree.
341367
- *
341368
- * The HEAD guard (#3) prevents false positives when a file is deleted
341369
- * and then recreated with different content — the cumulative diff of
341370
- * such a pair is non-empty and must be preserved.
341371
- *
341372
- * This only drops patches whose `files` are a subset of the transient
341373
- * set. A partial-transient patch (one that touches a transient file
341374
- * alongside a persistent one) is left unmodified; surgical stripping of
341375
- * single files from a multi-file patch would require a follow-up that
341376
- * regenerates the patch content via `git format-patch -- <files>`. No
341377
- * current failing test exercises this, and leaving the patch intact
341378
- * preserves today's behaviour. TODO(FER-9805): revisit if a future
341379
- * adversarial test demands per-file stripping.
341380
- */
341381
- async collapseNetZeroFiles(patches) {
341382
- if (patches.length === 0) return patches;
341383
- const stateByFile = /* @__PURE__ */ new Map();
341384
- for (const patch5 of patches) {
341385
- for (const header of parsePatchFileHeaders(patch5.patch_content)) {
341386
- if (!header.isCreate && !header.isDelete) continue;
341387
- const prior = stateByFile.get(header.path) ?? { created: false, deleted: false };
341388
- if (header.isCreate) prior.created = true;
341389
- if (header.isDelete) prior.deleted = true;
341390
- stateByFile.set(header.path, prior);
341391
- }
341392
- }
341393
- let anyCandidate = false;
341394
- for (const state of stateByFile.values()) {
341395
- if (state.created && state.deleted) {
341396
- anyCandidate = true;
341397
- break;
341398
- }
341399
- }
341400
- if (!anyCandidate) return patches;
341401
- const headFiles = await this.listHeadFiles();
341402
- const transient = /* @__PURE__ */ new Set();
341403
- for (const [file4, state] of stateByFile) {
341404
- if (state.created && state.deleted && !headFiles.has(file4)) {
341405
- transient.add(file4);
341406
- }
341407
- }
341408
- if (transient.size === 0) return patches;
341409
- return patches.filter((patch5) => !patch5.files.every((f5) => transient.has(f5)));
341410
- }
341411
- /**
341412
- * List every tracked path at HEAD (one `git ls-tree -r` invocation).
341413
- * Returns an empty Set if HEAD is unborn or the invocation fails — the
341414
- * caller then treats every candidate as "not in HEAD" and may collapse
341415
- * more aggressively. On typical SDK repos this is a single sub-10ms call.
341416
- */
341417
- async listHeadFiles() {
341418
- try {
341419
- const out = await this.git.exec(["ls-tree", "-r", "--name-only", "HEAD"]);
341420
- return new Set(out.split("\n").map((l9) => l9.trim()).filter(Boolean));
341421
- } catch {
341422
- return /* @__PURE__ */ new Set();
341423
- }
341424
- }
341425
- /**
341426
- * Create a single composite patch for a merge commit by diffing the merge
341427
- * commit against its first parent. This captures the net change of the
341428
- * entire merged branch as one patch, eliminating accumulator chaining and
341429
- * zero-net-change ghost conflicts.
341430
- */
341431
- async createMergeCompositePatch(input3) {
341432
- const { mergeCommit, firstParent, lastGen, lock } = input3;
341433
- const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
341434
- const filesOutput = await this.git.exec([
341435
- "diff",
341436
- "--name-only",
341437
- firstParent,
341438
- mergeCommit.sha
341439
- ]);
341440
- const allMergeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f5) => !INFRASTRUCTURE_FILES.has(f5)).filter((f5) => !f5.startsWith(".fern/"));
341441
- const sensitiveMergeFiles = allMergeFiles.filter((f5) => isSensitiveFile(f5));
341442
- const files = allMergeFiles.filter((f5) => !isSensitiveFile(f5));
341443
- if (sensitiveMergeFiles.length > 0) {
341444
- this.warnings.push(
341445
- `Sensitive file(s) excluded from merge patch for commit ${mergeCommit.sha.slice(0, 7)}: ${sensitiveMergeFiles.join(", ")}. These files will not be tracked by replay.`
341446
- );
341447
- }
341448
- if (files.length === 0) return null;
341449
- const diff2 = await this.git.exec([
341450
- "diff",
341451
- firstParent,
341452
- mergeCommit.sha,
341453
- "--",
341454
- ...files
341455
- ]);
341456
- if (!diff2.trim()) return null;
341457
- const contentHash = this.computeContentHash(diff2);
341458
- if (lock.patches.some((p14) => p14.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
341459
- return null;
341460
- }
341461
- return {
341462
- id: `patch-merge-${mergeCommit.sha.slice(0, 8)}`,
341463
- content_hash: contentHash,
341464
- original_commit: mergeCommit.sha,
341465
- original_message: mergeCommit.message,
341466
- original_author: `${mergeCommit.authorName} <${mergeCommit.authorEmail}>`,
341467
- base_generation: lastGen.commit_sha,
341468
- files,
341469
- patch_content: diff2,
341470
- ...this.isCreationOnlyPatch(diff2) ? { user_owned: true } : {}
341471
- };
341184
+ i9++;
341472
341185
  }
341473
- /**
341474
- * Detect patches via tree diff for non-linear history. Returns a composite patch.
341475
- * Revert reconciliation is skipped here because tree-diff produces a single composite
341476
- * patch from the aggregate diff — individual revert commits are not distinguishable.
341477
- */
341478
- async detectPatchesViaTreeDiff(lastGen, commitKnownMissing) {
341479
- const diffBase = await this.resolveDiffBase(lastGen, commitKnownMissing);
341480
- if (!diffBase) {
341481
- return this.detectPatchesViaCommitScan();
341482
- }
341483
- const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
341484
- const allTreeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f5) => !INFRASTRUCTURE_FILES.has(f5)).filter((f5) => !f5.startsWith(".fern/"));
341485
- const sensitiveTreeFiles = allTreeFiles.filter((f5) => isSensitiveFile(f5));
341486
- const files = allTreeFiles.filter((f5) => !isSensitiveFile(f5));
341487
- if (sensitiveTreeFiles.length > 0) {
341488
- this.warnings.push(
341489
- `Sensitive file(s) excluded from composite patch: ${sensitiveTreeFiles.join(", ")}. These files will not be tracked by replay.`
341490
- );
341491
- }
341492
- if (files.length === 0) return { patches: [], revertedPatchIds: [] };
341493
- const diff2 = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
341494
- if (!diff2.trim()) return { patches: [], revertedPatchIds: [] };
341495
- const contentHash = this.computeContentHash(diff2);
341496
- const lock = this.lockManager.read();
341497
- if (lock.patches.some((p14) => p14.content_hash === contentHash) || (lock.forgotten_hashes ?? []).includes(contentHash)) {
341498
- return { patches: [], revertedPatchIds: [] };
341499
- }
341500
- const headSha = (await this.git.exec(["rev-parse", "HEAD"])).trim();
341501
- const compositePatch = {
341502
- id: `patch-composite-${headSha.slice(0, 8)}`,
341503
- content_hash: contentHash,
341504
- original_commit: headSha,
341505
- original_message: "Customer customizations (composite)",
341506
- original_author: "composite",
341507
- // Use diffBase when commit is unreachable — the applicator needs a reachable
341508
- // reference to find base file content. diffBase may be the tree_hash.
341509
- base_generation: commitKnownMissing ? diffBase : lastGen.commit_sha,
341510
- files,
341511
- patch_content: diff2,
341512
- ...this.isCreationOnlyPatch(diff2) ? { user_owned: true } : {}
341513
- };
341514
- return { patches: [compositePatch], revertedPatchIds: [] };
341186
+ return ranges;
341187
+ }
341188
+ function stripConflictMarkers(content5) {
341189
+ const lines = content5.split(/\r?\n/);
341190
+ const ranges = findConflictRanges(lines);
341191
+ if (ranges.length === 0) {
341192
+ return content5;
341515
341193
  }
341516
- /**
341517
- * Last-resort detection when both generation commit and tree are unreachable.
341518
- * Scans all commits from HEAD, filters against known lockfile patches, and
341519
- * skips creation-only commits (squashed history after force push).
341520
- *
341521
- * Detected patches use the commit's parent as base_generation so the applicator
341522
- * can find base file content from the parent's tree (which IS reachable).
341523
- */
341524
- async detectPatchesViaCommitScan() {
341525
- const lock = this.lockManager.read();
341526
- const log4 = await this.git.exec([
341527
- "log",
341528
- "--max-count=200",
341529
- "--format=%H%x00%an%x00%ae%x00%s",
341530
- "HEAD",
341531
- "--",
341532
- this.sdkOutputDir
341533
- ]);
341534
- if (!log4.trim()) {
341535
- return { patches: [], revertedPatchIds: [] };
341536
- }
341537
- const commits = this.parseGitLog(log4);
341538
- const newPatches = [];
341539
- const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
341540
- const existingHashes = new Set(lock.patches.map((p14) => p14.content_hash));
341541
- const existingCommits = new Set(lock.patches.map((p14) => p14.original_commit));
341542
- for (const commit of commits) {
341543
- if (isGenerationCommit(commit)) {
341544
- continue;
341545
- }
341546
- const parents = await this.git.getCommitParents(commit.sha);
341547
- if (parents.length > 1) {
341548
- continue;
341549
- }
341550
- if (existingCommits.has(commit.sha)) {
341551
- continue;
341552
- }
341553
- let patchContent;
341554
- try {
341555
- patchContent = await this.git.formatPatch(commit.sha);
341556
- } catch {
341194
+ const result = [];
341195
+ let rangeIdx = 0;
341196
+ for (let i9 = 0; i9 < lines.length; i9++) {
341197
+ if (rangeIdx < ranges.length) {
341198
+ const range2 = ranges[rangeIdx];
341199
+ if (i9 === range2.start) {
341557
341200
  continue;
341558
341201
  }
341559
- let contentHash = this.computeContentHash(patchContent);
341560
- if (existingHashes.has(contentHash) || forgottenHashes.has(contentHash)) {
341202
+ if (i9 > range2.start && i9 < range2.separator) {
341203
+ result.push(lines[i9]);
341561
341204
  continue;
341562
341205
  }
341563
- if (this.isCreationOnlyPatch(patchContent)) {
341206
+ if (i9 === range2.separator) {
341564
341207
  continue;
341565
341208
  }
341566
- const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
341567
- const allScanFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f5) => !INFRASTRUCTURE_FILES.has(f5));
341568
- const sensitiveScanFiles = allScanFiles.filter((f5) => isSensitiveFile(f5));
341569
- const files = allScanFiles.filter((f5) => !isSensitiveFile(f5));
341570
- if (sensitiveScanFiles.length > 0) {
341571
- this.warnings.push(
341572
- `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveScanFiles.join(", ")}. These files will not be tracked by replay.`
341573
- );
341574
- if (files.length > 0) {
341575
- if (parents.length === 0) {
341576
- continue;
341577
- }
341578
- try {
341579
- patchContent = await this.git.exec(["diff", parents[0], commit.sha, "--", ...files]);
341580
- } catch {
341581
- continue;
341582
- }
341583
- if (!patchContent.trim()) continue;
341584
- contentHash = this.computeContentHash(patchContent);
341585
- }
341586
- }
341587
- if (files.length === 0) {
341209
+ if (i9 > range2.separator && i9 < range2.end) {
341588
341210
  continue;
341589
341211
  }
341590
- if (parents.length === 0) {
341212
+ if (i9 === range2.end) {
341213
+ rangeIdx++;
341591
341214
  continue;
341592
341215
  }
341593
- const parentSha = parents[0];
341594
- newPatches.push({
341595
- id: `patch-${commit.sha.slice(0, 8)}`,
341596
- content_hash: contentHash,
341597
- original_commit: commit.sha,
341598
- original_message: commit.message,
341599
- original_author: `${commit.authorName} <${commit.authorEmail}>`,
341600
- base_generation: parentSha,
341601
- files,
341602
- patch_content: patchContent
341603
- });
341604
341216
  }
341605
- newPatches.reverse();
341606
- return { patches: newPatches, revertedPatchIds: [] };
341607
- }
341608
- /**
341609
- * Check if a format-patch consists entirely of new-file creations.
341610
- * Used to identify squashed commits after force push, which create all files
341611
- * from scratch (--- /dev/null) rather than modifying existing files.
341612
- */
341613
- isCreationOnlyPatch(patchContent) {
341614
- const diffOldHeaders = patchContent.split("\n").filter((l9) => l9.startsWith("--- "));
341615
- if (diffOldHeaders.length === 0) {
341616
- return false;
341617
- }
341618
- return diffOldHeaders.every((l9) => l9 === "--- /dev/null");
341619
- }
341620
- /**
341621
- * Resolve the best available diff base for a generation record.
341622
- * Prefers commit_sha, falls back to tree_hash for unreachable commits.
341623
- * When commitKnownMissing is true, skips the redundant commitExists check.
341624
- */
341625
- async resolveDiffBase(gen, commitKnownMissing) {
341626
- if (!commitKnownMissing && await this.git.commitExists(gen.commit_sha)) {
341627
- return gen.commit_sha;
341628
- }
341629
- if (await this.git.treeExists(gen.tree_hash)) {
341630
- return gen.tree_hash;
341631
- }
341632
- return null;
341633
- }
341634
- parseGitLog(log4) {
341635
- return log4.trim().split("\n").map((line) => {
341636
- const [sha, authorName, authorEmail, message] = line.split("\0");
341637
- return { sha, authorName, authorEmail, message };
341638
- });
341639
- }
341640
- getLastGeneration(lock) {
341641
- return lock.generations.find((g8) => g8.commit_sha === lock.current_generation);
341217
+ result.push(lines[i9]);
341642
341218
  }
341643
- };
341219
+ return result.join("\n");
341220
+ }
341644
341221
  var import_node_diff3 = require_diff3();
341645
341222
  function threeWayMerge(base4, ours, theirs) {
341646
341223
  const baseLines = base4.split("\n");
@@ -341778,81 +341355,6 @@ var require_dist14 = __commonJS({
341778
341355
  }
341779
341356
  return result;
341780
341357
  }
341781
- var import_promises193 = require("fs/promises");
341782
- var import_node_os15 = require("os");
341783
- var import_node_path210 = require("path");
341784
- var import_minimatch8 = require_commonjs3();
341785
- var CONFLICT_OPENER = "<<<<<<< Generated";
341786
- var CONFLICT_SEPARATOR = "=======";
341787
- var CONFLICT_CLOSER = ">>>>>>> Your customization";
341788
- function trimCR(line) {
341789
- return line.endsWith("\r") ? line.slice(0, -1) : line;
341790
- }
341791
- function findConflictRanges(lines) {
341792
- const ranges = [];
341793
- let i9 = 0;
341794
- while (i9 < lines.length) {
341795
- if (trimCR(lines[i9]) === CONFLICT_OPENER) {
341796
- let separatorIdx = -1;
341797
- let j7 = i9 + 1;
341798
- let found = false;
341799
- while (j7 < lines.length) {
341800
- const trimmed2 = trimCR(lines[j7]);
341801
- if (trimmed2 === CONFLICT_OPENER) {
341802
- break;
341803
- }
341804
- if (separatorIdx === -1 && trimmed2 === CONFLICT_SEPARATOR) {
341805
- separatorIdx = j7;
341806
- } else if (separatorIdx !== -1 && trimmed2 === CONFLICT_CLOSER) {
341807
- ranges.push({ start: i9, separator: separatorIdx, end: j7 });
341808
- i9 = j7;
341809
- found = true;
341810
- break;
341811
- }
341812
- j7++;
341813
- }
341814
- if (!found) {
341815
- i9++;
341816
- continue;
341817
- }
341818
- }
341819
- i9++;
341820
- }
341821
- return ranges;
341822
- }
341823
- function stripConflictMarkers(content5) {
341824
- const lines = content5.split(/\r?\n/);
341825
- const ranges = findConflictRanges(lines);
341826
- if (ranges.length === 0) {
341827
- return content5;
341828
- }
341829
- const result = [];
341830
- let rangeIdx = 0;
341831
- for (let i9 = 0; i9 < lines.length; i9++) {
341832
- if (rangeIdx < ranges.length) {
341833
- const range2 = ranges[rangeIdx];
341834
- if (i9 === range2.start) {
341835
- continue;
341836
- }
341837
- if (i9 > range2.start && i9 < range2.separator) {
341838
- result.push(lines[i9]);
341839
- continue;
341840
- }
341841
- if (i9 === range2.separator) {
341842
- continue;
341843
- }
341844
- if (i9 > range2.separator && i9 < range2.end) {
341845
- continue;
341846
- }
341847
- if (i9 === range2.end) {
341848
- rangeIdx++;
341849
- continue;
341850
- }
341851
- }
341852
- result.push(lines[i9]);
341853
- }
341854
- return result.join("\n");
341855
- }
341856
341358
  var BINARY_EXTENSIONS2 = /* @__PURE__ */ new Set([
341857
341359
  ".png",
341858
341360
  ".jpg",
@@ -341922,6 +341424,26 @@ var require_dist14 = __commonJS({
341922
341424
  * cross-patch context mismatch
341923
341425
  */
341924
341426
  currentApplyMode = "replay";
341427
+ /**
341428
+ * Set of files that appear in 2+ patches in the current `applyPatches`
341429
+ * invocation. Computed at the top of `applyPatches`. Read by `mergeFile`
341430
+ * and `populateAccumulatorForPatch` to decide whether to use the patch's
341431
+ * `theirs_snapshot` directly as THEIRS (snapshot-as-primary) or fall
341432
+ * back to line-anchored reconstruction (FER-9525 cross-patch isolation).
341433
+ *
341434
+ * Snapshot-as-primary is correct when the patch owns its file (no other
341435
+ * patch in this run touches it): the snapshot is what the customer
341436
+ * intends for that file post-regen, regardless of generator structural
341437
+ * changes that would invalidate the diff's line anchors.
341438
+ *
341439
+ * When a file IS shared, snapshots are CUMULATIVE across the patches
341440
+ * touching it (each one's snapshot includes prior patches' contributions).
341441
+ * Using a later patch's cumulative snapshot as its individual THEIRS
341442
+ * would propagate prior patches' edits into the merge — surfacing nested
341443
+ * conflicts at regions the patch alone never touched. Reconstruction
341444
+ * via `applyPatchToContent` is required there.
341445
+ */
341446
+ sharedFiles = /* @__PURE__ */ new Set();
341925
341447
  constructor(git, lockManager, outputDir) {
341926
341448
  this.git = git;
341927
341449
  this.lockManager = lockManager;
@@ -341987,6 +341509,15 @@ var require_dist14 = __commonJS({
341987
341509
  const applyMode = opts?.applyMode ?? "replay";
341988
341510
  this.currentApplyMode = applyMode;
341989
341511
  this.resetAccumulator();
341512
+ const fileFreq = /* @__PURE__ */ new Map();
341513
+ for (const p14 of patches) {
341514
+ for (const f5 of p14.files) {
341515
+ fileFreq.set(f5, (fileFreq.get(f5) ?? 0) + 1);
341516
+ }
341517
+ }
341518
+ this.sharedFiles = new Set(
341519
+ Array.from(fileFreq.entries()).filter(([, n19]) => n19 > 1).map(([f5]) => f5)
341520
+ );
341990
341521
  const results = [];
341991
341522
  for (let i9 = 0; i9 < patches.length; i9++) {
341992
341523
  const patch5 = patches[i9];
@@ -342052,7 +341583,9 @@ var require_dist14 = __commonJS({
342052
341583
  if (isBinaryFile(filePath)) continue;
342053
341584
  const resolvedPath = await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
342054
341585
  const base4 = await this.git.showFile(baseGen.tree_hash, filePath);
342055
- const theirs = await this.applyPatchToContent(base4, patch5.patch_content, filePath, tempGit, tempDir2);
341586
+ const snapshotForFile = patch5.theirs_snapshot?.[resolvedPath] ?? patch5.theirs_snapshot?.[filePath];
341587
+ const fileIsShared = this.sharedFiles.has(resolvedPath) || this.sharedFiles.has(filePath);
341588
+ const theirs = !fileIsShared && snapshotForFile != null ? snapshotForFile : await this.applyPatchToContent(base4, patch5.patch_content, filePath, tempGit, tempDir2);
342056
341589
  const finalOnDisk = await (0, import_promises193.readFile)((0, import_node_path210.join)(this.outputDir, resolvedPath), "utf-8").catch(() => null);
342057
341590
  const effectiveTheirs = theirs ?? finalOnDisk;
342058
341591
  if (effectiveTheirs != null) {
@@ -342214,15 +341747,27 @@ var require_dist14 = __commonJS({
342214
341747
  }
342215
341748
  }
342216
341749
  }
341750
+ const snapshotForFile = patch5.theirs_snapshot?.[resolvedPath] ?? patch5.theirs_snapshot?.[filePath];
341751
+ const fileIsShared = this.sharedFiles.has(resolvedPath) || this.sharedFiles.has(filePath);
341752
+ const useSnapshotAsPrimary = !fileIsShared && snapshotForFile != null;
342217
341753
  if (!ghostReconstructed) {
342218
- theirs = await this.applyPatchToContent(
342219
- base4,
342220
- patch5.patch_content,
342221
- filePath,
342222
- tempGit,
342223
- tempDir2,
342224
- renameSourcePath
342225
- );
341754
+ if (useSnapshotAsPrimary) {
341755
+ theirs = snapshotForFile;
341756
+ } else {
341757
+ theirs = await this.applyPatchToContent(
341758
+ base4,
341759
+ patch5.patch_content,
341760
+ filePath,
341761
+ tempGit,
341762
+ tempDir2,
341763
+ renameSourcePath
341764
+ );
341765
+ const reconstructionHasMarkers = theirs != null && (theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization"));
341766
+ const baseHadMarkers = base4 != null && (base4.includes("<<<<<<< Generated") || base4.includes(">>>>>>> Your customization"));
341767
+ if (reconstructionHasMarkers && !baseHadMarkers && snapshotForFile != null) {
341768
+ theirs = snapshotForFile;
341769
+ }
341770
+ }
342226
341771
  }
342227
341772
  const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
342228
341773
  if (theirs) {
@@ -342581,6 +342126,600 @@ var require_dist14 = __commonJS({
342581
342126
  const match3 = diffLine.match(/^diff --git a\/.+ b\/(.+)$/);
342582
342127
  return match3 !== null && match3[1] === filePath;
342583
342128
  }
342129
+ var INFRASTRUCTURE_FILES = /* @__PURE__ */ new Set([".fernignore"]);
342130
+ function parsePatchFileHeaders(patchContent) {
342131
+ const results = [];
342132
+ let currentPath = null;
342133
+ let currentIsCreate = false;
342134
+ let currentIsDelete = false;
342135
+ const flush3 = () => {
342136
+ if (currentPath !== null) {
342137
+ results.push({ path: currentPath, isCreate: currentIsCreate, isDelete: currentIsDelete });
342138
+ }
342139
+ };
342140
+ for (const line of patchContent.split("\n")) {
342141
+ if (line.startsWith("diff --git ")) {
342142
+ flush3();
342143
+ currentPath = null;
342144
+ currentIsCreate = false;
342145
+ currentIsDelete = false;
342146
+ const match3 = line.match(/^diff --git a\/(.+?) b\/(.+?)$/);
342147
+ if (match3) {
342148
+ currentPath = match3[2] ?? null;
342149
+ }
342150
+ } else if (currentPath !== null) {
342151
+ if (line.startsWith("new file mode ")) {
342152
+ currentIsCreate = true;
342153
+ } else if (line.startsWith("deleted file mode ")) {
342154
+ currentIsDelete = true;
342155
+ }
342156
+ }
342157
+ }
342158
+ flush3();
342159
+ return results;
342160
+ }
342161
+ var ReplayDetector = class {
342162
+ git;
342163
+ lockManager;
342164
+ sdkOutputDir;
342165
+ warnings = [];
342166
+ constructor(git, lockManager, sdkOutputDir) {
342167
+ this.git = git;
342168
+ this.lockManager = lockManager;
342169
+ this.sdkOutputDir = sdkOutputDir;
342170
+ }
342171
+ async detectNewPatches() {
342172
+ const lock = this.lockManager.read();
342173
+ const lastGen = this.getLastGeneration(lock);
342174
+ if (!lastGen) {
342175
+ return { patches: [], revertedPatchIds: [] };
342176
+ }
342177
+ const exists2 = await this.git.commitExists(lastGen.commit_sha);
342178
+ if (!exists2) {
342179
+ this.warnings.push(
342180
+ `Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to alternate detection.`
342181
+ );
342182
+ return this.detectPatchesViaTreeDiff(
342183
+ lastGen,
342184
+ /* commitKnownMissing */
342185
+ true
342186
+ );
342187
+ }
342188
+ const isAncestor = await this.git.isAncestor(lastGen.commit_sha, "HEAD");
342189
+ if (!isAncestor) {
342190
+ return this.detectPatchesViaMergeBase(lastGen, lock);
342191
+ }
342192
+ return this.detectPatchesInRange(lastGen.commit_sha, lastGen, lock);
342193
+ }
342194
+ /**
342195
+ * FER-10201 — Non-linear-history detection via `merge-base(prevGen, HEAD)`.
342196
+ *
342197
+ * After a squash-merge of a Fern-bot PR, `lastGen.commit_sha` (the previous
342198
+ * `[fern-generated]`) is orphaned but still in git's object database. The
342199
+ * merge-base is the commit on main from which the bot's branch was created —
342200
+ * a reachable ancestor of HEAD. Walking that range and classifying each
342201
+ * commit via `isGenerationCommit` mirrors the linear path and eliminates
342202
+ * the bug class where pipeline-driven changes (autoversion, replay,
342203
+ * generation) get bundled as customer customizations.
342204
+ *
342205
+ * Falls through to the legacy composite path when no common ancestor exists
342206
+ * (truly disjoint history — orphan branches with no shared root).
342207
+ */
342208
+ async detectPatchesViaMergeBase(lastGen, lock) {
342209
+ let mergeBase = "";
342210
+ try {
342211
+ mergeBase = (await this.git.exec(["merge-base", lastGen.commit_sha, "HEAD"])).trim();
342212
+ } catch {
342213
+ }
342214
+ if (!mergeBase) {
342215
+ this.warnings.push(
342216
+ `No common ancestor between previous generation ${lastGen.commit_sha.slice(0, 7)} and HEAD. Falling back to composite tree-diff detection.`
342217
+ );
342218
+ return this.detectPatchesViaTreeDiff(
342219
+ lastGen,
342220
+ /* commitKnownMissing */
342221
+ false
342222
+ );
342223
+ }
342224
+ return this.detectPatchesInRange(mergeBase, lastGen, lock);
342225
+ }
342226
+ /**
342227
+ * FER-10201 — Walk `${rangeStart}..HEAD`, classify each commit, emit one
342228
+ * `StoredPatch` per customer commit. Body shared by the linear path
342229
+ * (rangeStart = lastGen.commit_sha) and the merge-base path.
342230
+ *
342231
+ * Patch `base_generation` is always `lastGen.commit_sha` — the
342232
+ * lockfile-tracked reference the applicator uses to fetch base file
342233
+ * content, regardless of where the walk actually started.
342234
+ */
342235
+ async detectPatchesInRange(rangeStart, lastGen, lock) {
342236
+ const log4 = await this.git.exec([
342237
+ "log",
342238
+ "--first-parent",
342239
+ "--format=%H%x00%an%x00%ae%x00%s",
342240
+ `${rangeStart}..HEAD`,
342241
+ "--",
342242
+ this.sdkOutputDir
342243
+ ]);
342244
+ if (!log4.trim()) {
342245
+ return { patches: [], revertedPatchIds: [] };
342246
+ }
342247
+ const commits = this.parseGitLog(log4);
342248
+ const newPatches = [];
342249
+ const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
342250
+ for (const commit of commits) {
342251
+ if (isGenerationCommit(commit)) {
342252
+ continue;
342253
+ }
342254
+ if (lock.patches.find((p14) => p14.original_commit === commit.sha)) {
342255
+ continue;
342256
+ }
342257
+ const parents = await this.git.getCommitParents(commit.sha);
342258
+ if (parents.length > 1) {
342259
+ const compositePatch = await this.createMergeCompositePatch({
342260
+ mergeCommit: commit,
342261
+ firstParent: parents[0],
342262
+ lastGen,
342263
+ lock
342264
+ });
342265
+ if (compositePatch) {
342266
+ newPatches.push(compositePatch);
342267
+ }
342268
+ continue;
342269
+ }
342270
+ let patchContent;
342271
+ try {
342272
+ patchContent = await this.git.formatPatch(commit.sha);
342273
+ } catch {
342274
+ this.warnings.push(
342275
+ `Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
342276
+ );
342277
+ continue;
342278
+ }
342279
+ let contentHash = this.computeContentHash(patchContent);
342280
+ if (lock.patches.find((p14) => p14.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
342281
+ continue;
342282
+ }
342283
+ const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
342284
+ const allFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f5) => !INFRASTRUCTURE_FILES.has(f5)).filter((f5) => !f5.startsWith(".fern/"));
342285
+ const sensitiveFiles = allFiles.filter((f5) => isSensitiveFile(f5));
342286
+ const files = allFiles.filter((f5) => !isSensitiveFile(f5));
342287
+ if (sensitiveFiles.length > 0) {
342288
+ this.warnings.push(
342289
+ `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
342290
+ );
342291
+ if (files.length > 0) {
342292
+ const parentSha = parents[0];
342293
+ if (parentSha) {
342294
+ try {
342295
+ patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
342296
+ } catch {
342297
+ continue;
342298
+ }
342299
+ if (!patchContent.trim()) continue;
342300
+ contentHash = this.computeContentHash(patchContent);
342301
+ }
342302
+ }
342303
+ }
342304
+ if (files.length === 0) {
342305
+ continue;
342306
+ }
342307
+ const theirsSnapshot = {};
342308
+ for (const file4 of files) {
342309
+ if (isBinaryFile(file4)) continue;
342310
+ const content5 = await this.git.showFile(commit.sha, file4).catch(() => null);
342311
+ if (content5 != null) theirsSnapshot[file4] = content5;
342312
+ }
342313
+ newPatches.push({
342314
+ id: `patch-${commit.sha.slice(0, 8)}`,
342315
+ content_hash: contentHash,
342316
+ original_commit: commit.sha,
342317
+ original_message: commit.message,
342318
+ original_author: `${commit.authorName} <${commit.authorEmail}>`,
342319
+ base_generation: lastGen.commit_sha,
342320
+ files,
342321
+ patch_content: patchContent,
342322
+ ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
342323
+ ...this.isCreationOnlyPatch(patchContent) ? { user_owned: true } : {}
342324
+ });
342325
+ }
342326
+ const survivingPatches = await this.collapseNetZeroFiles(newPatches);
342327
+ survivingPatches.reverse();
342328
+ const revertedPatchIdSet = /* @__PURE__ */ new Set();
342329
+ const revertIndicesToRemove = /* @__PURE__ */ new Set();
342330
+ for (let i9 = 0; i9 < survivingPatches.length; i9++) {
342331
+ const patch5 = survivingPatches[i9];
342332
+ if (!isRevertCommit(patch5.original_message)) continue;
342333
+ let body = "";
342334
+ try {
342335
+ body = await this.git.getCommitBody(patch5.original_commit);
342336
+ } catch {
342337
+ }
342338
+ const revertedSha = parseRevertedSha(body);
342339
+ const revertedMessage = parseRevertedMessage(patch5.original_message);
342340
+ let matchedExisting = false;
342341
+ if (revertedSha) {
342342
+ const existing = lock.patches.find((p14) => p14.original_commit === revertedSha);
342343
+ if (existing) {
342344
+ revertedPatchIdSet.add(existing.id);
342345
+ revertIndicesToRemove.add(i9);
342346
+ matchedExisting = true;
342347
+ }
342348
+ }
342349
+ if (!matchedExisting && revertedMessage) {
342350
+ const existing = lock.patches.find((p14) => p14.original_message === revertedMessage);
342351
+ if (existing) {
342352
+ revertedPatchIdSet.add(existing.id);
342353
+ revertIndicesToRemove.add(i9);
342354
+ matchedExisting = true;
342355
+ }
342356
+ }
342357
+ if (matchedExisting) continue;
342358
+ let matchedNew = false;
342359
+ if (revertedSha) {
342360
+ const idx = survivingPatches.findIndex(
342361
+ (p14, j7) => j7 !== i9 && !revertIndicesToRemove.has(j7) && p14.original_commit === revertedSha
342362
+ );
342363
+ if (idx !== -1) {
342364
+ revertIndicesToRemove.add(i9);
342365
+ revertIndicesToRemove.add(idx);
342366
+ matchedNew = true;
342367
+ }
342368
+ }
342369
+ if (!matchedNew && revertedMessage) {
342370
+ const idx = survivingPatches.findIndex(
342371
+ (p14, j7) => j7 !== i9 && !revertIndicesToRemove.has(j7) && p14.original_message === revertedMessage
342372
+ );
342373
+ if (idx !== -1) {
342374
+ revertIndicesToRemove.add(i9);
342375
+ revertIndicesToRemove.add(idx);
342376
+ }
342377
+ }
342378
+ if (!matchedExisting && !matchedNew) {
342379
+ revertIndicesToRemove.add(i9);
342380
+ }
342381
+ }
342382
+ const filteredPatches = survivingPatches.filter((_7, i9) => !revertIndicesToRemove.has(i9));
342383
+ return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
342384
+ }
342385
+ /**
342386
+ * Compute content hash for deduplication.
342387
+ *
342388
+ * Produces a format-agnostic hash so both `git format-patch` output
342389
+ * (email-wrapped) and plain `git diff` output hash to the same value
342390
+ * when the underlying diff hunks are identical.
342391
+ *
342392
+ * Normalization:
342393
+ * 1. Strip email wrapper: everything before the first `diff --git` line
342394
+ * (From:, Subject:, Date:, diffstat, blank separators).
342395
+ * 2. Strip `index` lines (blob SHA pairs change across rebases).
342396
+ * 3. Strip trailing git version marker (`-- \n<version>`).
342397
+ *
342398
+ * This ensures content hashes match across the no-patches and normal-
342399
+ * regeneration flows, which store formatPatch and git-diff formats
342400
+ * respectively (FER-9850, D5-D9).
342401
+ */
342402
+ computeContentHash(patchContent) {
342403
+ const lines = patchContent.split("\n");
342404
+ const diffStart = lines.findIndex((l9) => l9.startsWith("diff --git "));
342405
+ const relevantLines = diffStart > 0 ? lines.slice(diffStart) : lines;
342406
+ const normalized = relevantLines.filter((line) => !line.startsWith("index ")).join("\n").replace(/\n-- \n[\d]+\.[\d]+[^\n]*\s*$/, "");
342407
+ return `sha256:${(0, import_node_crypto7.createHash)("sha256").update(normalized).digest("hex")}`;
342408
+ }
342409
+ /**
342410
+ * FER-9805 — Drop patches whose files were transiently created and then
342411
+ * deleted within the current linear detection window, and are absent from
342412
+ * HEAD at the end of the window.
342413
+ *
342414
+ * Rationale: on a merge commit, createMergeCompositePatch already diffs
342415
+ * firstParent..mergeCommit so net-zero files never enter the composite. On
342416
+ * linear history each commit becomes its own patch, so a file that was
342417
+ * briefly added and later removed survives as a create+delete pair whose
342418
+ * cumulative diff is empty. We drop such patches before they reach the
342419
+ * lockfile.
342420
+ *
342421
+ * A file is "transient" when:
342422
+ * 1. some patch in the window sets `new file mode` for it (a creation), AND
342423
+ * 2. some patch in the window sets `deleted file mode` for it (a deletion), AND
342424
+ * 3. it is absent from HEAD's tree.
342425
+ *
342426
+ * The HEAD guard (#3) prevents false positives when a file is deleted
342427
+ * and then recreated with different content — the cumulative diff of
342428
+ * such a pair is non-empty and must be preserved.
342429
+ *
342430
+ * This only drops patches whose `files` are a subset of the transient
342431
+ * set. A partial-transient patch (one that touches a transient file
342432
+ * alongside a persistent one) is left unmodified; surgical stripping of
342433
+ * single files from a multi-file patch would require a follow-up that
342434
+ * regenerates the patch content via `git format-patch -- <files>`. No
342435
+ * current failing test exercises this, and leaving the patch intact
342436
+ * preserves today's behaviour. TODO(FER-9805): revisit if a future
342437
+ * adversarial test demands per-file stripping.
342438
+ */
342439
+ async collapseNetZeroFiles(patches) {
342440
+ if (patches.length === 0) return patches;
342441
+ const stateByFile = /* @__PURE__ */ new Map();
342442
+ for (const patch5 of patches) {
342443
+ for (const header of parsePatchFileHeaders(patch5.patch_content)) {
342444
+ if (!header.isCreate && !header.isDelete) continue;
342445
+ const prior = stateByFile.get(header.path) ?? { created: false, deleted: false };
342446
+ if (header.isCreate) prior.created = true;
342447
+ if (header.isDelete) prior.deleted = true;
342448
+ stateByFile.set(header.path, prior);
342449
+ }
342450
+ }
342451
+ let anyCandidate = false;
342452
+ for (const state of stateByFile.values()) {
342453
+ if (state.created && state.deleted) {
342454
+ anyCandidate = true;
342455
+ break;
342456
+ }
342457
+ }
342458
+ if (!anyCandidate) return patches;
342459
+ const headFiles = await this.listHeadFiles();
342460
+ const transient = /* @__PURE__ */ new Set();
342461
+ for (const [file4, state] of stateByFile) {
342462
+ if (state.created && state.deleted && !headFiles.has(file4)) {
342463
+ transient.add(file4);
342464
+ }
342465
+ }
342466
+ if (transient.size === 0) return patches;
342467
+ return patches.filter((patch5) => !patch5.files.every((f5) => transient.has(f5)));
342468
+ }
342469
+ /**
342470
+ * List every tracked path at HEAD (one `git ls-tree -r` invocation).
342471
+ * Returns an empty Set if HEAD is unborn or the invocation fails — the
342472
+ * caller then treats every candidate as "not in HEAD" and may collapse
342473
+ * more aggressively. On typical SDK repos this is a single sub-10ms call.
342474
+ */
342475
+ async listHeadFiles() {
342476
+ try {
342477
+ const out = await this.git.exec(["ls-tree", "-r", "--name-only", "HEAD"]);
342478
+ return new Set(out.split("\n").map((l9) => l9.trim()).filter(Boolean));
342479
+ } catch {
342480
+ return /* @__PURE__ */ new Set();
342481
+ }
342482
+ }
342483
+ /**
342484
+ * Create a single composite patch for a merge commit by diffing the merge
342485
+ * commit against its first parent. This captures the net change of the
342486
+ * entire merged branch as one patch, eliminating accumulator chaining and
342487
+ * zero-net-change ghost conflicts.
342488
+ */
342489
+ async createMergeCompositePatch(input3) {
342490
+ const { mergeCommit, firstParent, lastGen, lock } = input3;
342491
+ const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
342492
+ const filesOutput = await this.git.exec([
342493
+ "diff",
342494
+ "--name-only",
342495
+ firstParent,
342496
+ mergeCommit.sha
342497
+ ]);
342498
+ const allMergeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f5) => !INFRASTRUCTURE_FILES.has(f5)).filter((f5) => !f5.startsWith(".fern/"));
342499
+ const sensitiveMergeFiles = allMergeFiles.filter((f5) => isSensitiveFile(f5));
342500
+ const files = allMergeFiles.filter((f5) => !isSensitiveFile(f5));
342501
+ if (sensitiveMergeFiles.length > 0) {
342502
+ this.warnings.push(
342503
+ `Sensitive file(s) excluded from merge patch for commit ${mergeCommit.sha.slice(0, 7)}: ${sensitiveMergeFiles.join(", ")}. These files will not be tracked by replay.`
342504
+ );
342505
+ }
342506
+ if (files.length === 0) return null;
342507
+ const diff2 = await this.git.exec([
342508
+ "diff",
342509
+ firstParent,
342510
+ mergeCommit.sha,
342511
+ "--",
342512
+ ...files
342513
+ ]);
342514
+ if (!diff2.trim()) return null;
342515
+ const contentHash = this.computeContentHash(diff2);
342516
+ if (lock.patches.some((p14) => p14.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
342517
+ return null;
342518
+ }
342519
+ const theirsSnapshot = {};
342520
+ for (const file4 of files) {
342521
+ if (isBinaryFile(file4)) continue;
342522
+ const content5 = await this.git.showFile(mergeCommit.sha, file4).catch(() => null);
342523
+ if (content5 != null) theirsSnapshot[file4] = content5;
342524
+ }
342525
+ return {
342526
+ id: `patch-merge-${mergeCommit.sha.slice(0, 8)}`,
342527
+ content_hash: contentHash,
342528
+ original_commit: mergeCommit.sha,
342529
+ original_message: mergeCommit.message,
342530
+ original_author: `${mergeCommit.authorName} <${mergeCommit.authorEmail}>`,
342531
+ base_generation: lastGen.commit_sha,
342532
+ files,
342533
+ patch_content: diff2,
342534
+ ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
342535
+ ...this.isCreationOnlyPatch(diff2) ? { user_owned: true } : {}
342536
+ };
342537
+ }
342538
+ /**
342539
+ * Detect patches via tree diff for non-linear history. Returns a composite patch.
342540
+ * Revert reconciliation is skipped here because tree-diff produces a single composite
342541
+ * patch from the aggregate diff — individual revert commits are not distinguishable.
342542
+ */
342543
+ async detectPatchesViaTreeDiff(lastGen, commitKnownMissing) {
342544
+ const diffBase = await this.resolveDiffBase(lastGen, commitKnownMissing);
342545
+ if (!diffBase) {
342546
+ return this.detectPatchesViaCommitScan();
342547
+ }
342548
+ const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
342549
+ const allTreeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f5) => !INFRASTRUCTURE_FILES.has(f5)).filter((f5) => !f5.startsWith(".fern/"));
342550
+ const sensitiveTreeFiles = allTreeFiles.filter((f5) => isSensitiveFile(f5));
342551
+ const files = allTreeFiles.filter((f5) => !isSensitiveFile(f5));
342552
+ if (sensitiveTreeFiles.length > 0) {
342553
+ this.warnings.push(
342554
+ `Sensitive file(s) excluded from composite patch: ${sensitiveTreeFiles.join(", ")}. These files will not be tracked by replay.`
342555
+ );
342556
+ }
342557
+ if (files.length === 0) return { patches: [], revertedPatchIds: [] };
342558
+ const diff2 = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
342559
+ if (!diff2.trim()) return { patches: [], revertedPatchIds: [] };
342560
+ const contentHash = this.computeContentHash(diff2);
342561
+ const lock = this.lockManager.read();
342562
+ if (lock.patches.some((p14) => p14.content_hash === contentHash) || (lock.forgotten_hashes ?? []).includes(contentHash)) {
342563
+ return { patches: [], revertedPatchIds: [] };
342564
+ }
342565
+ const headSha = (await this.git.exec(["rev-parse", "HEAD"])).trim();
342566
+ const theirsSnapshot = {};
342567
+ for (const file4 of files) {
342568
+ if (isBinaryFile(file4)) continue;
342569
+ const content5 = await this.git.showFile("HEAD", file4).catch(() => null);
342570
+ if (content5 != null) theirsSnapshot[file4] = content5;
342571
+ }
342572
+ const compositePatch = {
342573
+ id: `patch-composite-${headSha.slice(0, 8)}`,
342574
+ content_hash: contentHash,
342575
+ original_commit: headSha,
342576
+ original_message: "Customer customizations (composite)",
342577
+ original_author: "composite",
342578
+ // Use diffBase when commit is unreachable — the applicator needs a reachable
342579
+ // reference to find base file content. diffBase may be the tree_hash.
342580
+ base_generation: commitKnownMissing ? diffBase : lastGen.commit_sha,
342581
+ files,
342582
+ patch_content: diff2,
342583
+ ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
342584
+ ...this.isCreationOnlyPatch(diff2) ? { user_owned: true } : {}
342585
+ };
342586
+ return { patches: [compositePatch], revertedPatchIds: [] };
342587
+ }
342588
+ /**
342589
+ * Last-resort detection when both generation commit and tree are unreachable.
342590
+ * Scans all commits from HEAD, filters against known lockfile patches, and
342591
+ * skips creation-only commits (squashed history after force push).
342592
+ *
342593
+ * Detected patches use the commit's parent as base_generation so the applicator
342594
+ * can find base file content from the parent's tree (which IS reachable).
342595
+ */
342596
+ async detectPatchesViaCommitScan() {
342597
+ const lock = this.lockManager.read();
342598
+ const log4 = await this.git.exec([
342599
+ "log",
342600
+ "--max-count=200",
342601
+ "--format=%H%x00%an%x00%ae%x00%s",
342602
+ "HEAD",
342603
+ "--",
342604
+ this.sdkOutputDir
342605
+ ]);
342606
+ if (!log4.trim()) {
342607
+ return { patches: [], revertedPatchIds: [] };
342608
+ }
342609
+ const commits = this.parseGitLog(log4);
342610
+ const newPatches = [];
342611
+ const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
342612
+ const existingHashes = new Set(lock.patches.map((p14) => p14.content_hash));
342613
+ const existingCommits = new Set(lock.patches.map((p14) => p14.original_commit));
342614
+ for (const commit of commits) {
342615
+ if (isGenerationCommit(commit)) {
342616
+ continue;
342617
+ }
342618
+ const parents = await this.git.getCommitParents(commit.sha);
342619
+ if (parents.length > 1) {
342620
+ continue;
342621
+ }
342622
+ if (existingCommits.has(commit.sha)) {
342623
+ continue;
342624
+ }
342625
+ let patchContent;
342626
+ try {
342627
+ patchContent = await this.git.formatPatch(commit.sha);
342628
+ } catch {
342629
+ continue;
342630
+ }
342631
+ let contentHash = this.computeContentHash(patchContent);
342632
+ if (existingHashes.has(contentHash) || forgottenHashes.has(contentHash)) {
342633
+ continue;
342634
+ }
342635
+ if (this.isCreationOnlyPatch(patchContent)) {
342636
+ continue;
342637
+ }
342638
+ const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
342639
+ const allScanFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f5) => !INFRASTRUCTURE_FILES.has(f5));
342640
+ const sensitiveScanFiles = allScanFiles.filter((f5) => isSensitiveFile(f5));
342641
+ const files = allScanFiles.filter((f5) => !isSensitiveFile(f5));
342642
+ if (sensitiveScanFiles.length > 0) {
342643
+ this.warnings.push(
342644
+ `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveScanFiles.join(", ")}. These files will not be tracked by replay.`
342645
+ );
342646
+ if (files.length > 0) {
342647
+ if (parents.length === 0) {
342648
+ continue;
342649
+ }
342650
+ try {
342651
+ patchContent = await this.git.exec(["diff", parents[0], commit.sha, "--", ...files]);
342652
+ } catch {
342653
+ continue;
342654
+ }
342655
+ if (!patchContent.trim()) continue;
342656
+ contentHash = this.computeContentHash(patchContent);
342657
+ }
342658
+ }
342659
+ if (files.length === 0) {
342660
+ continue;
342661
+ }
342662
+ if (parents.length === 0) {
342663
+ continue;
342664
+ }
342665
+ const parentSha = parents[0];
342666
+ const theirsSnapshot = {};
342667
+ for (const file4 of files) {
342668
+ if (isBinaryFile(file4)) continue;
342669
+ const content5 = await this.git.showFile(commit.sha, file4).catch(() => null);
342670
+ if (content5 != null) theirsSnapshot[file4] = content5;
342671
+ }
342672
+ newPatches.push({
342673
+ id: `patch-${commit.sha.slice(0, 8)}`,
342674
+ content_hash: contentHash,
342675
+ original_commit: commit.sha,
342676
+ original_message: commit.message,
342677
+ original_author: `${commit.authorName} <${commit.authorEmail}>`,
342678
+ base_generation: parentSha,
342679
+ files,
342680
+ patch_content: patchContent,
342681
+ ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {}
342682
+ });
342683
+ }
342684
+ newPatches.reverse();
342685
+ return { patches: newPatches, revertedPatchIds: [] };
342686
+ }
342687
+ /**
342688
+ * Check if a format-patch consists entirely of new-file creations.
342689
+ * Used to identify squashed commits after force push, which create all files
342690
+ * from scratch (--- /dev/null) rather than modifying existing files.
342691
+ */
342692
+ isCreationOnlyPatch(patchContent) {
342693
+ const diffOldHeaders = patchContent.split("\n").filter((l9) => l9.startsWith("--- "));
342694
+ if (diffOldHeaders.length === 0) {
342695
+ return false;
342696
+ }
342697
+ return diffOldHeaders.every((l9) => l9 === "--- /dev/null");
342698
+ }
342699
+ /**
342700
+ * Resolve the best available diff base for a generation record.
342701
+ * Prefers commit_sha, falls back to tree_hash for unreachable commits.
342702
+ * When commitKnownMissing is true, skips the redundant commitExists check.
342703
+ */
342704
+ async resolveDiffBase(gen, commitKnownMissing) {
342705
+ if (!commitKnownMissing && await this.git.commitExists(gen.commit_sha)) {
342706
+ return gen.commit_sha;
342707
+ }
342708
+ if (await this.git.treeExists(gen.tree_hash)) {
342709
+ return gen.tree_hash;
342710
+ }
342711
+ return null;
342712
+ }
342713
+ parseGitLog(log4) {
342714
+ return log4.trim().split("\n").map((line) => {
342715
+ const [sha, authorName, authorEmail, message] = line.split("\0");
342716
+ return { sha, authorName, authorEmail, message };
342717
+ });
342718
+ }
342719
+ getLastGeneration(lock) {
342720
+ return lock.generations.find((g8) => g8.commit_sha === lock.current_generation);
342721
+ }
342722
+ };
342584
342723
  var ReplayCommitter = class {
342585
342724
  git;
342586
342725
  outputDir;
@@ -342683,7 +342822,8 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
342683
342822
  }
342684
342823
  };
342685
342824
  var import_node_fs210 = require("fs");
342686
- var import_node_path43 = require("path");
342825
+ var import_promises410 = require("fs/promises");
342826
+ var import_node_path52 = require("path");
342687
342827
  var import_minimatch22 = require_commonjs3();
342688
342828
  init_GitClient();
342689
342829
  var import_promises210 = require("fs/promises");
@@ -342740,13 +342880,13 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
342740
342880
  const hunksRaw = parseHunks(fileDiff).map(normalizeHunkBody);
342741
342881
  const hunks = hunksRaw.filter((h4) => {
342742
342882
  if (h4.lines.length === 0) return false;
342743
- let ctx = 0, rm32 = 0, add = 0;
342883
+ let ctx = 0, rm42 = 0, add = 0;
342744
342884
  for (const ln10 of h4.lines) {
342745
342885
  if (ln10.type === "context") ctx++;
342746
- else if (ln10.type === "remove") rm32++;
342886
+ else if (ln10.type === "remove") rm42++;
342747
342887
  else if (ln10.type === "add") add++;
342748
342888
  }
342749
- return ctx + rm32 === h4.oldCount && ctx + add === h4.newCount;
342889
+ return ctx + rm42 === h4.oldCount && ctx + add === h4.newCount;
342750
342890
  });
342751
342891
  if (hunks.length === 0) {
342752
342892
  if (hunksRaw.length > 0) unlocatableFiles.push(file4);
@@ -343454,19 +343594,14 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
343454
343594
  existingPatches = this.lockManager.getPatches();
343455
343595
  const seenHashes = /* @__PURE__ */ new Map();
343456
343596
  for (const p14 of existingPatches) {
343457
- const priorOrigin = seenHashes.get(p14.content_hash);
343458
- if (priorOrigin !== void 0) {
343459
- const provenanceMissing = !priorOrigin || !p14.original_commit;
343460
- const sameOrigin = priorOrigin === p14.original_commit;
343461
- if (sameOrigin && !provenanceMissing) {
343462
- this.lockManager.removePatch(p14.id);
343463
- } else {
343464
- this.warnings.push(
343465
- `Preserving patch ${p14.id} (commit ${(p14.original_commit || "<missing>").slice(0, 7)}): shares content_hash with a prior patch ${provenanceMissing ? "(provenance missing on at least one side)" : "from a different source commit"}. Both retained to avoid silent customization loss (FER-9983).`
343466
- );
343467
- }
343597
+ const priorPatchId = seenHashes.get(p14.content_hash);
343598
+ if (priorPatchId !== void 0) {
343599
+ this.lockManager.removePatch(p14.id);
343600
+ this.warnings.push(
343601
+ `Consolidated patch ${p14.id} (commit ${(p14.original_commit || "<missing>").slice(0, 7)}) into prior patch ${priorPatchId}: byte-identical patch_content. Per-commit attribution preserved in git log; lockfile collapsed to avoid duplicate-apply corruption on next regen.`
343602
+ );
343468
343603
  } else {
343469
- seenHashes.set(p14.content_hash, p14.original_commit);
343604
+ seenHashes.set(p14.content_hash, p14.id);
343470
343605
  }
343471
343606
  }
343472
343607
  existingPatches = this.lockManager.getPatches();
@@ -343647,17 +343782,27 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
343647
343782
  if (allUserOwned && patch5.files.length > 0) {
343648
343783
  const baseChanged = patch5.base_generation !== currentGenSha;
343649
343784
  const userDiff = await this.git.exec(["diff", currentGenSha, "--", ...patch5.files]).catch(() => null);
343785
+ let userDiffSnapshot = null;
343650
343786
  if (userDiff != null && userDiff.trim()) {
343651
343787
  const newHash = this.detector.computeContentHash(userDiff);
343652
343788
  patch5.patch_content = userDiff;
343653
343789
  patch5.content_hash = newHash;
343790
+ const snap = await this.readSnapshotFromDisk(patch5.files);
343791
+ if (Object.keys(snap).length > 0) {
343792
+ userDiffSnapshot = snap;
343793
+ patch5.theirs_snapshot = snap;
343794
+ }
343654
343795
  }
343655
343796
  patch5.base_generation = currentGenSha;
343656
343797
  try {
343657
343798
  this.lockManager.updatePatch(patch5.id, {
343658
343799
  base_generation: currentGenSha,
343659
343800
  ...!patch5.user_owned ? { user_owned: true } : {},
343660
- ...userDiff != null && userDiff.trim() ? { patch_content: patch5.patch_content, content_hash: patch5.content_hash } : {}
343801
+ ...userDiff != null && userDiff.trim() ? {
343802
+ patch_content: patch5.patch_content,
343803
+ content_hash: patch5.content_hash,
343804
+ ...userDiffSnapshot != null && Object.keys(userDiffSnapshot).length > 0 ? { theirs_snapshot: userDiffSnapshot } : {}
343805
+ } : {}
343661
343806
  });
343662
343807
  } catch {
343663
343808
  }
@@ -343705,30 +343850,34 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
343705
343850
  continue;
343706
343851
  }
343707
343852
  const newContentHash = this.detector.computeContentHash(diff2);
343708
- const priorOrigin = seenContentHashes.get(newContentHash);
343709
- if (priorOrigin !== void 0) {
343710
- const provenanceMissing = !priorOrigin || !patch5.original_commit;
343711
- const sameOrigin = priorOrigin === patch5.original_commit;
343712
- if (sameOrigin && !provenanceMissing) {
343713
- this.lockManager.removePatch(patch5.id);
343714
- absorbedPatchIds.add(patch5.id);
343715
- absorbed++;
343716
- continue;
343853
+ const priorPatchId = seenContentHashes.get(newContentHash);
343854
+ if (priorPatchId !== void 0) {
343855
+ this.lockManager.removePatch(patch5.id);
343856
+ absorbedPatchIds.add(patch5.id);
343857
+ absorbed++;
343858
+ if (priorPatchId !== patch5.id) {
343859
+ this.warnings.push(
343860
+ `Consolidated patch ${patch5.id} (commit ${(patch5.original_commit || "<missing>").slice(0, 7)}) into prior patch ${priorPatchId}: byte-identical patch_content after rebase. Per-commit attribution preserved in git log.`
343861
+ );
343717
343862
  }
343718
- this.warnings.push(
343719
- `Preserving patch ${patch5.id} (commit ${(patch5.original_commit || "<missing>").slice(0, 7)}): shares content_hash with a prior patch ${provenanceMissing ? "(provenance missing on at least one side)" : "from a different source commit"}. Both retained to avoid silent customization loss (FER-9983).`
343720
- );
343863
+ continue;
343721
343864
  } else {
343722
- seenContentHashes.set(newContentHash, patch5.original_commit);
343865
+ seenContentHashes.set(newContentHash, patch5.id);
343723
343866
  }
343724
343867
  patch5.base_generation = currentGenSha;
343725
343868
  patch5.patch_content = diff2;
343726
343869
  patch5.content_hash = newContentHash;
343870
+ const snapshot = await this.readSnapshotFromDisk(patch5.files);
343871
+ const snapshotIsNonEmpty = Object.keys(snapshot).length > 0;
343872
+ if (snapshotIsNonEmpty) {
343873
+ patch5.theirs_snapshot = snapshot;
343874
+ }
343727
343875
  try {
343728
343876
  this.lockManager.updatePatch(patch5.id, {
343729
343877
  base_generation: currentGenSha,
343730
343878
  patch_content: diff2,
343731
- content_hash: newContentHash
343879
+ content_hash: newContentHash,
343880
+ ...snapshotIsNonEmpty ? { theirs_snapshot: snapshot } : {}
343732
343881
  });
343733
343882
  } catch {
343734
343883
  }
@@ -343738,6 +343887,112 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
343738
343887
  }
343739
343888
  return { absorbed, repointed, contentRebased, keptAsUserOwned, absorbedPatchIds };
343740
343889
  }
343890
+ /**
343891
+ * Read THEIRS snapshot (post-customer-edit content) for a list of files
343892
+ * from the working tree on disk. Used by `rebasePatches` after a clean
343893
+ * apply — disk = correctly-merged customer state at the new generation.
343894
+ * Skips binary files (matches `mergeFile`/`populateAccumulatorForPatch`
343895
+ * policy — binary content as `utf-8` is lossy and snapshots of it would
343896
+ * never be used during apply anyway).
343897
+ */
343898
+ async readSnapshotFromDisk(files) {
343899
+ const snapshot = {};
343900
+ for (const file4 of files) {
343901
+ if (isBinaryFile(file4)) continue;
343902
+ try {
343903
+ const content5 = await (0, import_promises410.readFile)((0, import_node_path52.join)(this.outputDir, file4), "utf-8");
343904
+ snapshot[file4] = content5;
343905
+ } catch {
343906
+ }
343907
+ }
343908
+ return snapshot;
343909
+ }
343910
+ /**
343911
+ * Read THEIRS snapshot from a git tree-ish (commit, branch, or tree).
343912
+ * Used when the diff that produced `patch_content` was relative to that
343913
+ * tree (e.g., `git diff currentGen HEAD --` → snapshot from HEAD).
343914
+ * Skips binary files.
343915
+ */
343916
+ async readSnapshotFromTree(treeRef, files) {
343917
+ const snapshot = {};
343918
+ for (const file4 of files) {
343919
+ if (isBinaryFile(file4)) continue;
343920
+ const content5 = await this.git.showFile(treeRef, file4).catch(() => null);
343921
+ if (content5 != null) snapshot[file4] = content5;
343922
+ }
343923
+ return snapshot;
343924
+ }
343925
+ /**
343926
+ * One-shot auto-migration: backfill `theirs_snapshot` on existing
343927
+ * patches that predate the snapshot encoding. Computes the snapshot by
343928
+ * applying the patch's `patch_content` to its `base_generation` tree —
343929
+ * yielding the patch's INDIVIDUAL contribution.
343930
+ *
343931
+ * **Skips patches sharing files with other patches.** HEAD's content
343932
+ * for a file shared by N patches is cumulative across all of them, so
343933
+ * a HEAD-fallback would falsely encode other patches' contributions
343934
+ * into one patch's snapshot — breaking FER-9525 cross-patch isolation
343935
+ * if the snapshot fallback later fires. Per-patch snapshots only
343936
+ * make sense when the patch owns its file. Multi-patch-same-file
343937
+ * legacy lockfiles keep using line-anchored reconstruction (the
343938
+ * existing path); they're no worse than pre-upgrade.
343939
+ *
343940
+ * **Does not fall back to HEAD when BASE-reconstruction fails.** Same
343941
+ * reason — HEAD content can be cumulative. Patches whose
343942
+ * reconstruction fails just don't get a snapshot; legacy paths
343943
+ * (PR #73's by-association gate, accumulator fallback) handle them.
343944
+ *
343945
+ * Skips patches that already have a snapshot.
343946
+ */
343947
+ async migratePatchesToSnapshot(patches) {
343948
+ const { applyPatchToContent: applyPatchToContent2 } = await Promise.resolve().then(() => (init_PatchApplyTheirs(), PatchApplyTheirs_exports));
343949
+ const fileFreq = /* @__PURE__ */ new Map();
343950
+ for (const p14 of patches) {
343951
+ for (const f5 of p14.files) {
343952
+ fileFreq.set(f5, (fileFreq.get(f5) ?? 0) + 1);
343953
+ }
343954
+ }
343955
+ let migrated = 0;
343956
+ let skipped = 0;
343957
+ for (const patch5 of patches) {
343958
+ if (patch5.theirs_snapshot != null) continue;
343959
+ const sharesAnyFile = patch5.files.some((f5) => (fileFreq.get(f5) ?? 0) > 1);
343960
+ if (sharesAnyFile) {
343961
+ skipped++;
343962
+ continue;
343963
+ }
343964
+ const snapshot = {};
343965
+ for (const file4 of patch5.files) {
343966
+ if (isBinaryFile(file4)) continue;
343967
+ try {
343968
+ const base4 = await this.git.showFile(patch5.base_generation, file4).catch(() => null);
343969
+ if (base4 != null) {
343970
+ const content5 = await applyPatchToContent2(base4, patch5.patch_content, file4);
343971
+ if (content5 != null) snapshot[file4] = content5;
343972
+ }
343973
+ } catch {
343974
+ }
343975
+ }
343976
+ if (Object.keys(snapshot).length > 0) {
343977
+ patch5.theirs_snapshot = snapshot;
343978
+ try {
343979
+ this.lockManager.updatePatch(patch5.id, { theirs_snapshot: snapshot });
343980
+ migrated++;
343981
+ } catch {
343982
+ }
343983
+ }
343984
+ }
343985
+ if (migrated > 0) {
343986
+ this.warnings.push(
343987
+ `Migrated ${migrated} patch(es) to snapshot encoding. Future regens use 3-way merge with stored THEIRS, robust to generator structural changes.`
343988
+ );
343989
+ }
343990
+ if (skipped > 0) {
343991
+ this.warnings.push(
343992
+ `Skipped snapshot migration for ${skipped} patch(es) sharing files with other patches. These continue to use line-anchored reconstruction (the existing path).`
343993
+ );
343994
+ }
343995
+ }
343741
343996
  /**
343742
343997
  * Determine whether `file` is user-owned (never produced by the generator).
343743
343998
  *
@@ -343858,6 +344113,20 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
343858
344113
  this.lockManager.clearReplaySkippedAt();
343859
344114
  return { conflictResolved: 0, conflictAbsorbed: 0, contentRefreshed: 0 };
343860
344115
  }
344116
+ await this.migratePatchesToSnapshot(patches);
344117
+ const headMessage = await this.git.exec(["log", "-1", "--format=%s%n%aN%n%ae", "HEAD"]).catch(() => "");
344118
+ const [headMsgLine, headAuthorName, headAuthorEmail] = headMessage.split("\n");
344119
+ const headIsGeneration = headMsgLine ? isGenerationCommit({
344120
+ sha: "HEAD",
344121
+ authorName: headAuthorName ?? "",
344122
+ authorEmail: headAuthorEmail ?? "",
344123
+ message: headMsgLine
344124
+ }) : false;
344125
+ const headParentChangedFiles = async (patchFiles) => {
344126
+ if (patchFiles.length === 0) return /* @__PURE__ */ new Set();
344127
+ const diff2 = await this.git.exec(["diff", "--name-only", "HEAD~1", "HEAD", "--", ...patchFiles]).catch(() => "");
344128
+ return new Set(diff2.trim().split("\n").filter(Boolean));
344129
+ };
343861
344130
  let conflictResolved = 0;
343862
344131
  let conflictAbsorbed = 0;
343863
344132
  let contentRefreshed = 0;
@@ -343894,6 +344163,13 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
343894
344163
  delete patch5.status;
343895
344164
  continue;
343896
344165
  }
344166
+ if (headIsGeneration) {
344167
+ const parentChangedFiles = await headParentChangedFiles(patch5.files);
344168
+ const headTouchedSource = patch5.files.some((f5) => parentChangedFiles.has(f5));
344169
+ if (headTouchedSource) {
344170
+ continue;
344171
+ }
344172
+ }
343897
344173
  if (patch5.base_generation === currentGen) {
343898
344174
  try {
343899
344175
  const ppResult = await computePerPatchDiffWithFallback(
@@ -343930,10 +344206,12 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
343930
344206
  if (newFiles.length === 0) {
343931
344207
  this.lockManager.removePatch(patch5.id);
343932
344208
  } else {
344209
+ const snapshot = await this.readSnapshotFromTree("HEAD", newFiles);
343933
344210
  this.lockManager.updatePatch(patch5.id, {
343934
344211
  patch_content: diff2,
343935
344212
  content_hash: newContentHash,
343936
- files: newFiles
344213
+ files: newFiles,
344214
+ ...Object.keys(snapshot).length > 0 ? { theirs_snapshot: snapshot } : {}
343937
344215
  });
343938
344216
  }
343939
344217
  contentRefreshed++;
@@ -343966,10 +344244,12 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
343966
344244
  continue;
343967
344245
  }
343968
344246
  const newContentHash = this.detector.computeContentHash(diff2);
344247
+ const snapshot = await this.readSnapshotFromTree("HEAD", patch5.files);
343969
344248
  this.lockManager.updatePatch(patch5.id, {
343970
344249
  base_generation: currentGen,
343971
344250
  patch_content: diff2,
343972
- content_hash: newContentHash
344251
+ content_hash: newContentHash,
344252
+ ...Object.keys(snapshot).length > 0 ? { theirs_snapshot: snapshot } : {}
343973
344253
  });
343974
344254
  conflictResolved++;
343975
344255
  } catch {
@@ -344012,7 +344292,7 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
344012
344292
  if (result.status !== "conflict" || !result.fileResults) continue;
344013
344293
  for (const fileResult of result.fileResults) {
344014
344294
  if (fileResult.status !== "conflict") continue;
344015
- const filePath = (0, import_node_path43.join)(this.outputDir, fileResult.file);
344295
+ const filePath = (0, import_node_path52.join)(this.outputDir, fileResult.file);
344016
344296
  try {
344017
344297
  const content5 = (0, import_node_fs210.readFileSync)(filePath, "utf-8");
344018
344298
  const stripped = stripConflictMarkers(content5);
@@ -344042,7 +344322,7 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
344042
344322
  }
344043
344323
  }
344044
344324
  readFernignorePatterns() {
344045
- const fernignorePath = (0, import_node_path43.join)(this.outputDir, ".fernignore");
344325
+ const fernignorePath = (0, import_node_path52.join)(this.outputDir, ".fernignore");
344046
344326
  if (!(0, import_node_fs210.existsSync)(fernignorePath)) return [];
344047
344327
  return (0, import_node_fs210.readFileSync)(fernignorePath, "utf-8").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
344048
344328
  }
@@ -344119,7 +344399,7 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
344119
344399
  }
344120
344400
  var import_node_crypto22 = require("crypto");
344121
344401
  var import_node_fs33 = require("fs");
344122
- var import_node_path52 = require("path");
344402
+ var import_node_path62 = require("path");
344123
344403
  var import_minimatch32 = require_commonjs3();
344124
344404
  var import_yaml22 = require_dist9();
344125
344405
  var FernignoreMigrator = class {
@@ -344132,10 +344412,10 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
344132
344412
  this.outputDir = outputDir;
344133
344413
  }
344134
344414
  fernignoreExists() {
344135
- return (0, import_node_fs33.existsSync)((0, import_node_path52.join)(this.outputDir, ".fernignore"));
344415
+ return (0, import_node_fs33.existsSync)((0, import_node_path62.join)(this.outputDir, ".fernignore"));
344136
344416
  }
344137
344417
  readFernignorePatterns() {
344138
- const fernignorePath = (0, import_node_path52.join)(this.outputDir, ".fernignore");
344418
+ const fernignorePath = (0, import_node_path62.join)(this.outputDir, ".fernignore");
344139
344419
  if (!(0, import_node_fs33.existsSync)(fernignorePath)) {
344140
344420
  return [];
344141
344421
  }
@@ -344187,6 +344467,12 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
344187
344467
  if (patchFiles.length > 0) {
344188
344468
  const patchContent = diffParts.join("\n");
344189
344469
  const contentHash = `sha256:${(0, import_node_crypto22.createHash)("sha256").update(patchContent).digest("hex")}`;
344470
+ const theirsSnapshot = {};
344471
+ for (const filePath of patchFiles) {
344472
+ if (isBinaryFile(filePath)) continue;
344473
+ const c5 = this.readFileContent(filePath);
344474
+ if (c5 != null) theirsSnapshot[filePath] = c5;
344475
+ }
344190
344476
  syntheticPatches.push({
344191
344477
  id: `patch-fernignore-${(0, import_node_crypto22.createHash)("sha256").update(pattern).digest("hex").slice(0, 8)}`,
344192
344478
  content_hash: contentHash,
@@ -344196,6 +344482,7 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
344196
344482
  base_generation: currentGen.commit_sha,
344197
344483
  files: patchFiles,
344198
344484
  patch_content: patchContent,
344485
+ ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
344199
344486
  // Synthetic patches captured from .fernignore-protected files
344200
344487
  // are user-owned by definition — the customer's content
344201
344488
  // diverges from the generator's pristine output, and the
@@ -344237,7 +344524,7 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
344237
344524
  return results;
344238
344525
  }
344239
344526
  readFileContent(filePath) {
344240
- const fullPath = (0, import_node_path52.join)(this.outputDir, filePath);
344527
+ const fullPath = (0, import_node_path62.join)(this.outputDir, filePath);
344241
344528
  if (!(0, import_node_fs33.existsSync)(fullPath)) {
344242
344529
  return null;
344243
344530
  }
@@ -344302,7 +344589,7 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
344302
344589
  };
344303
344590
  }
344304
344591
  movePatternsToReplayYml(patterns) {
344305
- const replayYmlPath = (0, import_node_path52.join)(this.outputDir, ".fern", "replay.yml");
344592
+ const replayYmlPath = (0, import_node_path62.join)(this.outputDir, ".fern", "replay.yml");
344306
344593
  let config5 = {};
344307
344594
  if ((0, import_node_fs33.existsSync)(replayYmlPath)) {
344308
344595
  const content5 = (0, import_node_fs33.readFileSync)(replayYmlPath, "utf-8");
@@ -344311,7 +344598,7 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
344311
344598
  const existing = config5.exclude ?? [];
344312
344599
  const merged = [.../* @__PURE__ */ new Set([...existing, ...patterns])];
344313
344600
  config5.exclude = merged;
344314
- const dir = (0, import_node_path52.dirname)(replayYmlPath);
344601
+ const dir = (0, import_node_path62.dirname)(replayYmlPath);
344315
344602
  if (!(0, import_node_fs33.existsSync)(dir)) {
344316
344603
  (0, import_node_fs33.mkdirSync)(dir, { recursive: true });
344317
344604
  }
@@ -344320,7 +344607,7 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
344320
344607
  };
344321
344608
  var import_node_crypto32 = require("crypto");
344322
344609
  var import_node_fs42 = require("fs");
344323
- var import_node_path62 = require("path");
344610
+ var import_node_path72 = require("path");
344324
344611
  init_GitClient();
344325
344612
  async function bootstrap(outputDir, options2) {
344326
344613
  const git = new GitClient(outputDir);
@@ -344487,7 +344774,7 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
344487
344774
  }
344488
344775
  var REPLAY_FERNIGNORE_ENTRIES = [".fern/replay.lock", ".fern/replay.yml", ".gitattributes"];
344489
344776
  function ensureFernignoreEntries(outputDir) {
344490
- const fernignorePath = (0, import_node_path62.join)(outputDir, ".fernignore");
344777
+ const fernignorePath = (0, import_node_path72.join)(outputDir, ".fernignore");
344491
344778
  let content5 = "";
344492
344779
  if ((0, import_node_fs42.existsSync)(fernignorePath)) {
344493
344780
  content5 = (0, import_node_fs42.readFileSync)(fernignorePath, "utf-8");
@@ -344511,7 +344798,7 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
344511
344798
  }
344512
344799
  var GITATTRIBUTES_ENTRIES = [".fern/replay.lock linguist-generated=true"];
344513
344800
  function ensureGitattributesEntries(outputDir) {
344514
- const gitattributesPath = (0, import_node_path62.join)(outputDir, ".gitattributes");
344801
+ const gitattributesPath = (0, import_node_path72.join)(outputDir, ".gitattributes");
344515
344802
  let content5 = "";
344516
344803
  if ((0, import_node_fs42.existsSync)(gitattributesPath)) {
344517
344804
  content5 = (0, import_node_fs42.readFileSync)(gitattributesPath, "utf-8");
@@ -344703,8 +344990,8 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
344703
344990
  nothingToReset: false
344704
344991
  };
344705
344992
  }
344706
- var import_promises310 = require("fs/promises");
344707
- var import_node_path72 = require("path");
344993
+ var import_promises510 = require("fs/promises");
344994
+ var import_node_path82 = require("path");
344708
344995
  init_GitClient();
344709
344996
  async function resolve15(outputDir, options2) {
344710
344997
  const lockManager = new LockfileManager(outputDir);
@@ -344748,36 +345035,78 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
344748
345035
  const detector = new ReplayDetector(git, lockManager, outputDir);
344749
345036
  const warnings = [];
344750
345037
  let patchesResolved = 0;
345038
+ const pass1 = [];
344751
345039
  for (const patch5 of patchesToCommit) {
344752
345040
  const result = await computePerPatchDiffWithFallback(
344753
345041
  patch5,
344754
345042
  (f5) => git.showFile(currentGen, f5),
344755
- (f5) => (0, import_promises310.readFile)((0, import_node_path72.join)(outputDir, f5), "utf-8").catch(() => null),
345043
+ (f5) => (0, import_promises510.readFile)((0, import_node_path82.join)(outputDir, f5), "utf-8").catch(() => null),
344756
345044
  (files) => git.exec(["diff", currentGen, "--", ...files]).catch(() => null)
344757
345045
  );
344758
345046
  if (result === null) {
344759
345047
  warnings.push(
344760
345048
  `Patch ${patch5.id}: cumulative-diff fallback failed for unlocatable files; preserving patch unchanged`
344761
345049
  );
345050
+ pass1.push({ kind: "skip" });
344762
345051
  continue;
344763
345052
  }
344764
345053
  const diff2 = result.diff;
344765
345054
  if (!diff2.trim()) {
345055
+ pass1.push({ kind: "revert" });
345056
+ continue;
345057
+ }
345058
+ const hash5 = detector.computeContentHash(diff2);
345059
+ pass1.push({
345060
+ kind: "apply",
345061
+ diff: diff2,
345062
+ hash: hash5,
345063
+ hadFallback: result.hadFallback,
345064
+ fallbackFiles: result.fallbackFiles
345065
+ });
345066
+ }
345067
+ const lastIndexByHash = /* @__PURE__ */ new Map();
345068
+ for (let i9 = 0; i9 < pass1.length; i9++) {
345069
+ const r5 = pass1[i9];
345070
+ if (r5.kind === "apply") lastIndexByHash.set(r5.hash, i9);
345071
+ }
345072
+ for (let i9 = 0; i9 < patchesToCommit.length; i9++) {
345073
+ const patch5 = patchesToCommit[i9];
345074
+ const r5 = pass1[i9];
345075
+ if (r5.kind === "skip") continue;
345076
+ if (r5.kind === "revert") {
345077
+ lockManager.removePatch(patch5.id);
345078
+ continue;
345079
+ }
345080
+ if (lastIndexByHash.get(r5.hash) !== i9) {
345081
+ const canonicalIdx = lastIndexByHash.get(r5.hash);
345082
+ const canonical = patchesToCommit[canonicalIdx];
344766
345083
  lockManager.removePatch(patch5.id);
345084
+ warnings.push(
345085
+ `Consolidated patch ${patch5.id} (commit ${(patch5.original_commit || "<missing>").slice(0, 7)}) into patch ${canonical.id} (commit ${(canonical.original_commit || "<missing>").slice(0, 7)}): byte-identical patch_content after resolution. Per-commit attribution preserved in git log; lockfile collapsed to avoid duplicate-apply corruption on next regen.`
345086
+ );
344767
345087
  continue;
344768
345088
  }
344769
- if (result.hadFallback) {
345089
+ if (r5.hadFallback) {
344770
345090
  warnings.push(
344771
- `Patch ${patch5.id}: ${result.fallbackFiles.join(", ")} could not be anchored; cumulative-diff fallback applied for those files`
345091
+ `Patch ${patch5.id}: ${r5.fallbackFiles.join(", ")} could not be anchored; cumulative-diff fallback applied for those files`
344772
345092
  );
344773
345093
  }
344774
- const newContentHash = detector.computeContentHash(diff2);
344775
- const changedFiles = result.hadFallback ? await getChangedFiles(git, currentGen, patch5.files) : extractFilesFromDiff(diff2);
345094
+ const changedFiles = r5.hadFallback ? await getChangedFiles(git, currentGen, patch5.files) : extractFilesFromDiff(r5.diff);
345095
+ const filesForSnapshot = changedFiles.length > 0 ? changedFiles : patch5.files;
345096
+ const theirsSnapshot = {};
345097
+ for (const file4 of filesForSnapshot) {
345098
+ if (isBinaryFile(file4)) continue;
345099
+ const content5 = await (0, import_promises510.readFile)((0, import_node_path82.join)(outputDir, file4), "utf-8").catch(() => null);
345100
+ if (content5 != null) {
345101
+ theirsSnapshot[file4] = content5;
345102
+ }
345103
+ }
344776
345104
  lockManager.markPatchResolved(patch5.id, {
344777
- patch_content: diff2,
344778
- content_hash: newContentHash,
345105
+ patch_content: r5.diff,
345106
+ content_hash: r5.hash,
344779
345107
  base_generation: currentGen,
344780
- files: changedFiles.length > 0 ? changedFiles : patch5.files
345108
+ files: filesForSnapshot,
345109
+ ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {}
344781
345110
  });
344782
345111
  patchesResolved++;
344783
345112
  }
@@ -531075,6 +531404,9 @@ function isAdditionalPropertiesAny(additionalProperties7, options2) {
531075
531404
  if (typeof additionalProperties7 === "boolean") {
531076
531405
  return additionalProperties7;
531077
531406
  }
531407
+ if (typeof additionalProperties7 !== "object" || additionalProperties7 === null) {
531408
+ return true;
531409
+ }
531078
531410
  if (isReferenceObject(additionalProperties7)) {
531079
531411
  return false;
531080
531412
  }
@@ -603250,6 +603582,9 @@ var ExampleConverter = class _ExampleConverter extends AbstractConverter {
603250
603582
  * These schemas are used in allOf compositions to add constraints to the merged schema.
603251
603583
  */
603252
603584
  isConstraintOnlySchema(schema2) {
603585
+ if (typeof schema2 !== "object" || schema2 === null) {
603586
+ return false;
603587
+ }
603253
603588
  if (schema2.type !== void 0 || schema2.properties !== void 0 || schema2.allOf !== void 0 || schema2.oneOf !== void 0 || schema2.anyOf !== void 0 || "items" in schema2 || schema2.additionalProperties !== void 0 || schema2.enum !== void 0 || schema2.const !== void 0) {
603254
603589
  return false;
603255
603590
  }
@@ -604478,7 +604813,7 @@ var OneOfSchemaConverter = class extends AbstractConverter {
604478
604813
  if (schemaArray == null) {
604479
604814
  return void 0;
604480
604815
  }
604481
- const withoutNull = schemaArray.filter((subSchema) => !("type" in subSchema && subSchema.type === "null"));
604816
+ const withoutNull = schemaArray.filter((subSchema) => !(typeof subSchema === "object" && subSchema !== null && "type" in subSchema && subSchema.type === "null"));
604482
604817
  if (withoutNull.length === 0) {
604483
604818
  this.context.errorCollector.collect({
604484
604819
  message: `Received ${schemaType} schema with no valid non-null types`,
@@ -605406,7 +605741,7 @@ var SchemaOrReferenceConverter = class extends AbstractConverter {
605406
605741
  return void 0;
605407
605742
  }
605408
605743
  const refElements = this.schemaOrReference.allOf.filter((s9) => this.context.isReferenceObject(s9));
605409
- const inlineElements = this.schemaOrReference.allOf.filter((s9) => !this.context.isReferenceObject(s9));
605744
+ const inlineElements = this.schemaOrReference.allOf.filter((s9) => typeof s9 === "object" && s9 !== null && !this.context.isReferenceObject(s9));
605410
605745
  const singleRef = refElements.length === 1 ? refElements[0] : void 0;
605411
605746
  if (singleRef != null && inlineElements.every((s9) => !s9.properties && !s9.enum && !s9.oneOf && !s9.anyOf && !s9.allOf && !s9.format && !("items" in s9)) && !inlineElements.some((s9) => s9.type === "null" || Array.isArray(s9.type) && s9.type.includes("null"))) {
605412
605747
  const resolved = this.context.resolveMaybeReference({
@@ -622052,7 +622387,7 @@ var AccessTokenPosthogManager = class {
622052
622387
  properties: {
622053
622388
  ...event,
622054
622389
  ...event.properties,
622055
- version: "5.4.0",
622390
+ version: "5.4.1",
622056
622391
  usingAccessToken: true
622057
622392
  }
622058
622393
  });
@@ -622106,7 +622441,7 @@ var UserPosthogManager = class {
622106
622441
  distinctId: this.userId ?? await this.getPersistedDistinctId(),
622107
622442
  event: "CLI",
622108
622443
  properties: {
622109
- version: "5.4.0",
622444
+ version: "5.4.1",
622110
622445
  ...event,
622111
622446
  ...event.properties,
622112
622447
  usingAccessToken: false,
@@ -829125,6 +829460,39 @@ function collectPathnamesToCheck(content5, { absoluteFilepath, instanceUrls }) {
829125
829460
 
829126
829461
  // ../yaml/docs-validator/lib/rules/valid-markdown-link/valid-markdown-link.js
829127
829462
  var NOOP_CONTEXT2 = createMockTaskContext({ logger: createLogger(noop2) });
829463
+ function createDocsResolverValidationContext() {
829464
+ const base4 = createMockTaskContext({ logger: createLogger(noop2) });
829465
+ return {
829466
+ ...base4,
829467
+ failAndThrow: (message, error50, _options) => {
829468
+ const messageParts = [];
829469
+ if (message != null) {
829470
+ messageParts.push(message);
829471
+ }
829472
+ if (error50 instanceof Error) {
829473
+ messageParts.push(error50.message);
829474
+ } else if (error50 != null) {
829475
+ messageParts.push(JSON.stringify(error50));
829476
+ }
829477
+ const combined = messageParts.length > 0 ? messageParts.join(": ") : "Docs validation failed";
829478
+ throw new Error(combined);
829479
+ },
829480
+ failWithoutThrowing: (message, error50, _options) => {
829481
+ const messageParts = [];
829482
+ if (message != null) {
829483
+ messageParts.push(message);
829484
+ }
829485
+ if (error50 instanceof Error) {
829486
+ messageParts.push(error50.message);
829487
+ } else if (error50 != null) {
829488
+ messageParts.push(JSON.stringify(error50));
829489
+ }
829490
+ if (messageParts.length > 0) {
829491
+ base4.logger.error(messageParts.join(": "));
829492
+ }
829493
+ }
829494
+ };
829495
+ }
829128
829496
  function isV1RootNode2(value2) {
829129
829497
  return "type" in value2 && value2.type === "root";
829130
829498
  }
@@ -829139,7 +829507,7 @@ var ValidMarkdownLinks = {
829139
829507
  docsWorkspace: workspace,
829140
829508
  ossWorkspaces,
829141
829509
  apiWorkspaces,
829142
- taskContext: NOOP_CONTEXT2,
829510
+ taskContext: createDocsResolverValidationContext(),
829143
829511
  editThisPage: void 0,
829144
829512
  uploadFiles: void 0,
829145
829513
  registerApi: void 0,
@@ -830363,12 +830731,37 @@ async function runRulesOnDocsWorkspace({ workspace, rules: selectedRules, contex
830363
830731
  context3.logger.debug(`Initial memory usage: RSS=${(startMemory.rss / 1024 / 1024).toFixed(2)}MB, Heap=${(startMemory.heapUsed / 1024 / 1024).toFixed(2)}MB`);
830364
830732
  const violations = [];
830365
830733
  const ruleCreationStart = performance.now();
830366
- const allRulesWithVisitors = await Promise.all(rules.map(async (rule) => ({
830367
- ruleName: rule.name,
830368
- visitor: await rule.create({ workspace, apiWorkspaces, ossWorkspaces, logger: context3.logger })
830369
- })));
830734
+ const ruleCreationResults = await Promise.all(rules.map(async (rule) => {
830735
+ try {
830736
+ const visitor = await rule.create({
830737
+ workspace,
830738
+ apiWorkspaces,
830739
+ ossWorkspaces,
830740
+ logger: context3.logger
830741
+ });
830742
+ return { ruleName: rule.name, visitor };
830743
+ } catch (error50) {
830744
+ return { ruleName: rule.name, error: error50 };
830745
+ }
830746
+ }));
830747
+ const allRulesWithVisitors = [];
830748
+ for (const result of ruleCreationResults) {
830749
+ if ("error" in result) {
830750
+ const message = result.error instanceof Error ? result.error.message : String(result.error);
830751
+ violations.push({
830752
+ name: result.ruleName,
830753
+ severity: "fatal",
830754
+ relativeFilepath: RelativeFilePath2.of(DOCS_CONFIGURATION_FILENAME),
830755
+ nodePath: [],
830756
+ message: `Rule "${result.ruleName}" failed to initialize: ${message}`
830757
+ });
830758
+ context3.logger.debug(`Rule "${result.ruleName}" failed to initialize: ${result.error instanceof Error ? result.error.stack ?? result.error.message : String(result.error)}`);
830759
+ } else {
830760
+ allRulesWithVisitors.push(result);
830761
+ }
830762
+ }
830370
830763
  const ruleCreationTime = performance.now() - ruleCreationStart;
830371
- context3.logger.debug(`Created ${rules.length} rule visitors in ${ruleCreationTime.toFixed(0)}ms`);
830764
+ context3.logger.debug(`Created ${allRulesWithVisitors.length} rule visitors in ${ruleCreationTime.toFixed(0)}ms (${rules.length - allRulesWithVisitors.length} failed to initialize)`);
830372
830765
  const astVisitor = createDocsConfigFileAstVisitorForRules({
830373
830766
  relativeFilepath: RelativeFilePath2.of(DOCS_CONFIGURATION_FILENAME),
830374
830767
  allRulesWithVisitors,
@@ -834386,7 +834779,7 @@ var LOCAL_STORAGE_FOLDER4 = ".fern-dev";
834386
834779
  var LOGS_FOLDER_NAME = "logs";
834387
834780
  var MAX_LOGS_DIR_SIZE_BYTES = 100 * 1024 * 1024;
834388
834781
  function getCliSource() {
834389
- const version7 = "5.4.0";
834782
+ const version7 = "5.4.1";
834390
834783
  return `cli@${version7}`;
834391
834784
  }
834392
834785
  var DebugLogger = class {
@@ -846848,7 +847241,7 @@ var LegacyDocsPublisher = class {
846848
847241
  previewId,
846849
847242
  disableTemplates: void 0,
846850
847243
  skipUpload,
846851
- cliVersion: "5.4.0",
847244
+ cliVersion: "5.4.1",
846852
847245
  loginCommand: "fern auth login"
846853
847246
  });
846854
847247
  if (taskContext.getResult() === TaskResult.Failure) {
@@ -921180,7 +921573,7 @@ var CliContext = class _CliContext {
921180
921573
  if (false) {
921181
921574
  this.logger.error("CLI_VERSION is not defined");
921182
921575
  }
921183
- return "5.4.0";
921576
+ return "5.4.1";
921184
921577
  }
921185
921578
  getCliName() {
921186
921579
  if (false) {