@fern-api/generator-cli 0.9.21 → 0.9.22

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/dist/cli.js +1402 -1076
  2. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -7031,18 +7031,18 @@ var require_build3 = __commonJS({
7031
7031
  var require_sync = __commonJS({
7032
7032
  "../../node_modules/.pnpm/escalade@3.2.0/node_modules/escalade/sync/index.js"(exports2, module2) {
7033
7033
  "use strict";
7034
- var { dirname: dirname4, resolve: resolve8 } = require("path");
7034
+ var { dirname: dirname5, resolve: resolve8 } = require("path");
7035
7035
  var { readdirSync: readdirSync2, statSync: statSync4 } = require("fs");
7036
7036
  module2.exports = function(start, callback) {
7037
7037
  let dir = resolve8(".", start);
7038
7038
  let tmp3, stats = statSync4(dir);
7039
7039
  if (!stats.isDirectory()) {
7040
- dir = dirname4(dir);
7040
+ dir = dirname5(dir);
7041
7041
  }
7042
7042
  while (true) {
7043
7043
  tmp3 = callback(dir, readdirSync2(dir));
7044
7044
  if (tmp3) return resolve8(dir, tmp3);
7045
- dir = dirname4(tmp3 = dir);
7045
+ dir = dirname5(tmp3 = dir);
7046
7046
  if (tmp3 === dir) break;
7047
7047
  }
7048
7048
  };
@@ -7078,9 +7078,9 @@ var require_require_directory = __commonJS({
7078
7078
  "../../node_modules/.pnpm/require-directory@2.1.1/node_modules/require-directory/index.js"(exports2, module2) {
7079
7079
  "use strict";
7080
7080
  var fs3 = require("fs");
7081
- var join14 = require("path").join;
7081
+ var join15 = require("path").join;
7082
7082
  var resolve8 = require("path").resolve;
7083
- var dirname4 = require("path").dirname;
7083
+ var dirname5 = require("path").dirname;
7084
7084
  var defaultOptions2 = {
7085
7085
  extensions: ["js", "json", "coffee"],
7086
7086
  recurse: true,
@@ -7113,9 +7113,9 @@ var require_require_directory = __commonJS({
7113
7113
  options[prop] = defaultOptions2[prop];
7114
7114
  }
7115
7115
  }
7116
- path5 = !path5 ? dirname4(m.filename) : resolve8(dirname4(m.filename), path5);
7116
+ path5 = !path5 ? dirname5(m.filename) : resolve8(dirname5(m.filename), path5);
7117
7117
  fs3.readdirSync(path5).forEach(function(filename) {
7118
- var joined = join14(path5, filename), files, key, obj;
7118
+ var joined = join15(path5, filename), files, key, obj;
7119
7119
  if (fs3.statSync(joined).isDirectory() && options.recurse) {
7120
7120
  files = requireDirectory(m, joined, options);
7121
7121
  if (Object.keys(files).length) {
@@ -22028,7 +22028,7 @@ var init_diff3 = __esm({
22028
22028
  }
22029
22029
  });
22030
22030
 
22031
- // ../../node_modules/.pnpm/@fern-api+replay@0.14.0/node_modules/@fern-api/replay/dist/index.js
22031
+ // ../../node_modules/.pnpm/@fern-api+replay@0.14.1/node_modules/@fern-api/replay/dist/index.js
22032
22032
  var dist_exports = {};
22033
22033
  __export(dist_exports, {
22034
22034
  CREDENTIAL_PATTERNS: () => CREDENTIAL_PATTERNS,
@@ -22293,6 +22293,48 @@ function reconstructFromGhostPatch(fileDiff, ours) {
22293
22293
  }
22294
22294
  return assembleHybrid(located, oursLines);
22295
22295
  }
22296
+ async function applyPatchToContent(base, patchContent, filePath) {
22297
+ const fileDiff = extractFileDiff(patchContent, filePath);
22298
+ if (!fileDiff) return null;
22299
+ const tempDir = await (0, import_promises3.mkdtemp)((0, import_path7.join)((0, import_os.tmpdir)(), "replay-migrate-"));
22300
+ const tempGit = new GitClient(tempDir);
22301
+ try {
22302
+ await tempGit.exec(["init"]);
22303
+ await tempGit.exec(["config", "user.email", "migrate@fern.com"]);
22304
+ await tempGit.exec(["config", "user.name", "Fern Replay Migration"]);
22305
+ await tempGit.exec(["config", "commit.gpgSign", "false"]);
22306
+ const tempFilePath = (0, import_path7.join)(tempDir, filePath);
22307
+ await (0, import_promises3.mkdir)((0, import_path7.dirname)(tempFilePath), { recursive: true });
22308
+ await (0, import_promises3.writeFile)(tempFilePath, base);
22309
+ await tempGit.exec(["add", filePath]);
22310
+ await tempGit.exec(["commit", "-m", `base for ${filePath}`, "--allow-empty"]);
22311
+ await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
22312
+ return await (0, import_promises3.readFile)(tempFilePath, "utf-8");
22313
+ } catch {
22314
+ return null;
22315
+ } finally {
22316
+ await (0, import_promises3.rm)(tempDir, { recursive: true, force: true }).catch(() => {
22317
+ });
22318
+ }
22319
+ }
22320
+ function extractFileDiff(patchContent, filePath) {
22321
+ const lines = patchContent.split("\n");
22322
+ const out = [];
22323
+ let inTarget = false;
22324
+ for (const line of lines) {
22325
+ if (line.startsWith("diff --git")) {
22326
+ if (inTarget) break;
22327
+ const match2 = line.match(/^diff --git a\/.+ b\/(.+)$/);
22328
+ if (match2 && match2[1] === filePath) {
22329
+ inTarget = true;
22330
+ out.push(line);
22331
+ }
22332
+ continue;
22333
+ }
22334
+ if (inTarget) out.push(line);
22335
+ }
22336
+ return out.length > 0 ? out.join("\n") + "\n" : null;
22337
+ }
22296
22338
  function isSensitiveFile(filePath) {
22297
22339
  return SENSITIVE_FILE_PATTERNS.some((re) => re.test(filePath));
22298
22340
  }
@@ -22328,36 +22370,73 @@ function parseRevertedMessage(subject) {
22328
22370
  const match2 = subject.match(/^Revert "(.+)"$/);
22329
22371
  return match2?.[1];
22330
22372
  }
22331
- function parsePatchFileHeaders(patchContent) {
22332
- const results = [];
22333
- let currentPath = null;
22334
- let currentIsCreate = false;
22335
- let currentIsDelete = false;
22336
- const flush2 = () => {
22337
- if (currentPath !== null) {
22338
- results.push({ path: currentPath, isCreate: currentIsCreate, isDelete: currentIsDelete });
22373
+ function trimCR(line) {
22374
+ return line.endsWith("\r") ? line.slice(0, -1) : line;
22375
+ }
22376
+ function findConflictRanges(lines) {
22377
+ const ranges = [];
22378
+ let i = 0;
22379
+ while (i < lines.length) {
22380
+ if (trimCR(lines[i]) === CONFLICT_OPENER) {
22381
+ let separatorIdx = -1;
22382
+ let j2 = i + 1;
22383
+ let found = false;
22384
+ while (j2 < lines.length) {
22385
+ const trimmed2 = trimCR(lines[j2]);
22386
+ if (trimmed2 === CONFLICT_OPENER) {
22387
+ break;
22388
+ }
22389
+ if (separatorIdx === -1 && trimmed2 === CONFLICT_SEPARATOR) {
22390
+ separatorIdx = j2;
22391
+ } else if (separatorIdx !== -1 && trimmed2 === CONFLICT_CLOSER) {
22392
+ ranges.push({ start: i, separator: separatorIdx, end: j2 });
22393
+ i = j2;
22394
+ found = true;
22395
+ break;
22396
+ }
22397
+ j2++;
22398
+ }
22399
+ if (!found) {
22400
+ i++;
22401
+ continue;
22402
+ }
22339
22403
  }
22340
- };
22341
- for (const line of patchContent.split("\n")) {
22342
- if (line.startsWith("diff --git ")) {
22343
- flush2();
22344
- currentPath = null;
22345
- currentIsCreate = false;
22346
- currentIsDelete = false;
22347
- const match2 = line.match(/^diff --git a\/(.+?) b\/(.+?)$/);
22348
- if (match2) {
22349
- currentPath = match2[2] ?? null;
22404
+ i++;
22405
+ }
22406
+ return ranges;
22407
+ }
22408
+ function stripConflictMarkers(content) {
22409
+ const lines = content.split(/\r?\n/);
22410
+ const ranges = findConflictRanges(lines);
22411
+ if (ranges.length === 0) {
22412
+ return content;
22413
+ }
22414
+ const result = [];
22415
+ let rangeIdx = 0;
22416
+ for (let i = 0; i < lines.length; i++) {
22417
+ if (rangeIdx < ranges.length) {
22418
+ const range2 = ranges[rangeIdx];
22419
+ if (i === range2.start) {
22420
+ continue;
22350
22421
  }
22351
- } else if (currentPath !== null) {
22352
- if (line.startsWith("new file mode ")) {
22353
- currentIsCreate = true;
22354
- } else if (line.startsWith("deleted file mode ")) {
22355
- currentIsDelete = true;
22422
+ if (i > range2.start && i < range2.separator) {
22423
+ result.push(lines[i]);
22424
+ continue;
22425
+ }
22426
+ if (i === range2.separator) {
22427
+ continue;
22428
+ }
22429
+ if (i > range2.separator && i < range2.end) {
22430
+ continue;
22431
+ }
22432
+ if (i === range2.end) {
22433
+ rangeIdx++;
22434
+ continue;
22356
22435
  }
22357
22436
  }
22437
+ result.push(lines[i]);
22358
22438
  }
22359
- flush2();
22360
- return results;
22439
+ return result.join("\n");
22361
22440
  }
22362
22441
  function threeWayMerge(base, ours, theirs) {
22363
22442
  const baseLines = base.split("\n");
@@ -22495,76 +22574,8 @@ function applyBothPatches(baseLines, oursPatches, theirsPatches) {
22495
22574
  }
22496
22575
  return result;
22497
22576
  }
22498
- function trimCR(line) {
22499
- return line.endsWith("\r") ? line.slice(0, -1) : line;
22500
- }
22501
- function findConflictRanges(lines) {
22502
- const ranges = [];
22503
- let i = 0;
22504
- while (i < lines.length) {
22505
- if (trimCR(lines[i]) === CONFLICT_OPENER) {
22506
- let separatorIdx = -1;
22507
- let j2 = i + 1;
22508
- let found = false;
22509
- while (j2 < lines.length) {
22510
- const trimmed2 = trimCR(lines[j2]);
22511
- if (trimmed2 === CONFLICT_OPENER) {
22512
- break;
22513
- }
22514
- if (separatorIdx === -1 && trimmed2 === CONFLICT_SEPARATOR) {
22515
- separatorIdx = j2;
22516
- } else if (separatorIdx !== -1 && trimmed2 === CONFLICT_CLOSER) {
22517
- ranges.push({ start: i, separator: separatorIdx, end: j2 });
22518
- i = j2;
22519
- found = true;
22520
- break;
22521
- }
22522
- j2++;
22523
- }
22524
- if (!found) {
22525
- i++;
22526
- continue;
22527
- }
22528
- }
22529
- i++;
22530
- }
22531
- return ranges;
22532
- }
22533
- function stripConflictMarkers(content) {
22534
- const lines = content.split(/\r?\n/);
22535
- const ranges = findConflictRanges(lines);
22536
- if (ranges.length === 0) {
22537
- return content;
22538
- }
22539
- const result = [];
22540
- let rangeIdx = 0;
22541
- for (let i = 0; i < lines.length; i++) {
22542
- if (rangeIdx < ranges.length) {
22543
- const range2 = ranges[rangeIdx];
22544
- if (i === range2.start) {
22545
- continue;
22546
- }
22547
- if (i > range2.start && i < range2.separator) {
22548
- result.push(lines[i]);
22549
- continue;
22550
- }
22551
- if (i === range2.separator) {
22552
- continue;
22553
- }
22554
- if (i > range2.separator && i < range2.end) {
22555
- continue;
22556
- }
22557
- if (i === range2.end) {
22558
- rangeIdx++;
22559
- continue;
22560
- }
22561
- }
22562
- result.push(lines[i]);
22563
- }
22564
- return result.join("\n");
22565
- }
22566
22577
  function isBinaryFile(filePath) {
22567
- const ext2 = (0, import_path8.extname)(filePath).toLowerCase();
22578
+ const ext2 = (0, import_path9.extname)(filePath).toLowerCase();
22568
22579
  return BINARY_EXTENSIONS.has(ext2);
22569
22580
  }
22570
22581
  function computeDroppedContextLinesForFile(base, theirs, finalContent) {
@@ -22595,6 +22606,37 @@ function isDiffLineForFile(diffLine, filePath) {
22595
22606
  const match2 = diffLine.match(/^diff --git a\/.+ b\/(.+)$/);
22596
22607
  return match2 !== null && match2[1] === filePath;
22597
22608
  }
22609
+ function parsePatchFileHeaders(patchContent) {
22610
+ const results = [];
22611
+ let currentPath = null;
22612
+ let currentIsCreate = false;
22613
+ let currentIsDelete = false;
22614
+ const flush2 = () => {
22615
+ if (currentPath !== null) {
22616
+ results.push({ path: currentPath, isCreate: currentIsCreate, isDelete: currentIsDelete });
22617
+ }
22618
+ };
22619
+ for (const line of patchContent.split("\n")) {
22620
+ if (line.startsWith("diff --git ")) {
22621
+ flush2();
22622
+ currentPath = null;
22623
+ currentIsCreate = false;
22624
+ currentIsDelete = false;
22625
+ const match2 = line.match(/^diff --git a\/(.+?) b\/(.+?)$/);
22626
+ if (match2) {
22627
+ currentPath = match2[2] ?? null;
22628
+ }
22629
+ } else if (currentPath !== null) {
22630
+ if (line.startsWith("new file mode ")) {
22631
+ currentIsCreate = true;
22632
+ } else if (line.startsWith("deleted file mode ")) {
22633
+ currentIsDelete = true;
22634
+ }
22635
+ }
22636
+ }
22637
+ flush2();
22638
+ return results;
22639
+ }
22598
22640
  function normalizeHunkBody(hunk) {
22599
22641
  let contextC = 0;
22600
22642
  let removeC = 0;
@@ -22644,13 +22686,13 @@ async function computePerPatchDiff(patch, getCurrentGenContent, getWorkingTreeCo
22644
22686
  const hunksRaw = parseHunks(fileDiff).map(normalizeHunkBody);
22645
22687
  const hunks = hunksRaw.filter((h) => {
22646
22688
  if (h.lines.length === 0) return false;
22647
- let ctx = 0, rm3 = 0, add = 0;
22689
+ let ctx = 0, rm4 = 0, add = 0;
22648
22690
  for (const ln of h.lines) {
22649
22691
  if (ln.type === "context") ctx++;
22650
- else if (ln.type === "remove") rm3++;
22692
+ else if (ln.type === "remove") rm4++;
22651
22693
  else if (ln.type === "add") add++;
22652
22694
  }
22653
- return ctx + rm3 === h.oldCount && ctx + add === h.newCount;
22695
+ return ctx + rm4 === h.oldCount && ctx + add === h.newCount;
22654
22696
  });
22655
22697
  if (hunks.length === 0) {
22656
22698
  if (hunksRaw.length > 0) unlocatableFiles.push(file);
@@ -22799,17 +22841,17 @@ function overlayRegions(currentGenLines, locInCurrentGen, workingTreeLines, locI
22799
22841
  }
22800
22842
  async function unifiedDiff(beforeContent, afterContent, filePath) {
22801
22843
  if (beforeContent === afterContent) return "";
22802
- const tmp3 = await (0, import_promises4.mkdtemp)((0, import_path10.join)((0, import_os2.tmpdir)(), "fern-replay-pp-"));
22844
+ const tmp3 = await (0, import_promises6.mkdtemp)((0, import_path11.join)((0, import_os3.tmpdir)(), "fern-replay-pp-"));
22803
22845
  try {
22804
- const beforePath = (0, import_path10.join)(tmp3, "before");
22805
- const afterPath = (0, import_path10.join)(tmp3, "after");
22806
- await (0, import_promises4.writeFile)(beforePath, beforeContent, "utf-8");
22807
- await (0, import_promises4.writeFile)(afterPath, afterContent, "utf-8");
22846
+ const beforePath = (0, import_path11.join)(tmp3, "before");
22847
+ const afterPath = (0, import_path11.join)(tmp3, "after");
22848
+ await (0, import_promises6.writeFile)(beforePath, beforeContent, "utf-8");
22849
+ await (0, import_promises6.writeFile)(afterPath, afterContent, "utf-8");
22808
22850
  const raw = await runGitDiffNoIndex(beforePath, afterPath);
22809
22851
  if (!raw.trim()) return "";
22810
22852
  return rewriteDiffHeaders(raw, filePath);
22811
22853
  } finally {
22812
- await (0, import_promises4.rm)(tmp3, { recursive: true, force: true });
22854
+ await (0, import_promises6.rm)(tmp3, { recursive: true, force: true });
22813
22855
  }
22814
22856
  }
22815
22857
  function runGitDiffNoIndex(beforePath, afterPath) {
@@ -23071,7 +23113,7 @@ function parseGitLog(log) {
23071
23113
  });
23072
23114
  }
23073
23115
  function ensureFernignoreEntries(outputDir) {
23074
- const fernignorePath = (0, import_path12.join)(outputDir, ".fernignore");
23116
+ const fernignorePath = (0, import_path13.join)(outputDir, ".fernignore");
23075
23117
  let content = "";
23076
23118
  if ((0, import_fs9.existsSync)(fernignorePath)) {
23077
23119
  content = (0, import_fs9.readFileSync)(fernignorePath, "utf-8");
@@ -23094,7 +23136,7 @@ function ensureFernignoreEntries(outputDir) {
23094
23136
  return true;
23095
23137
  }
23096
23138
  function ensureGitattributesEntries(outputDir) {
23097
- const gitattributesPath = (0, import_path12.join)(outputDir, ".gitattributes");
23139
+ const gitattributesPath = (0, import_path13.join)(outputDir, ".gitattributes");
23098
23140
  let content = "";
23099
23141
  if ((0, import_fs9.existsSync)(gitattributesPath)) {
23100
23142
  content = (0, import_fs9.readFileSync)(gitattributesPath, "utf-8");
@@ -23317,36 +23359,78 @@ async function resolve7(outputDir, options) {
23317
23359
  const detector = new ReplayDetector(git, lockManager, outputDir);
23318
23360
  const warnings = [];
23319
23361
  let patchesResolved = 0;
23362
+ const pass1 = [];
23320
23363
  for (const patch of patchesToCommit) {
23321
23364
  const result = await computePerPatchDiffWithFallback(
23322
23365
  patch,
23323
23366
  (f2) => git.showFile(currentGen, f2),
23324
- (f2) => (0, import_promises5.readFile)((0, import_path13.join)(outputDir, f2), "utf-8").catch(() => null),
23367
+ (f2) => (0, import_promises7.readFile)((0, import_path14.join)(outputDir, f2), "utf-8").catch(() => null),
23325
23368
  (files) => git.exec(["diff", currentGen, "--", ...files]).catch(() => null)
23326
23369
  );
23327
23370
  if (result === null) {
23328
23371
  warnings.push(
23329
23372
  `Patch ${patch.id}: cumulative-diff fallback failed for unlocatable files; preserving patch unchanged`
23330
23373
  );
23374
+ pass1.push({ kind: "skip" });
23331
23375
  continue;
23332
23376
  }
23333
23377
  const diff = result.diff;
23334
23378
  if (!diff.trim()) {
23379
+ pass1.push({ kind: "revert" });
23380
+ continue;
23381
+ }
23382
+ const hash = detector.computeContentHash(diff);
23383
+ pass1.push({
23384
+ kind: "apply",
23385
+ diff,
23386
+ hash,
23387
+ hadFallback: result.hadFallback,
23388
+ fallbackFiles: result.fallbackFiles
23389
+ });
23390
+ }
23391
+ const lastIndexByHash = /* @__PURE__ */ new Map();
23392
+ for (let i = 0; i < pass1.length; i++) {
23393
+ const r2 = pass1[i];
23394
+ if (r2.kind === "apply") lastIndexByHash.set(r2.hash, i);
23395
+ }
23396
+ for (let i = 0; i < patchesToCommit.length; i++) {
23397
+ const patch = patchesToCommit[i];
23398
+ const r2 = pass1[i];
23399
+ if (r2.kind === "skip") continue;
23400
+ if (r2.kind === "revert") {
23401
+ lockManager.removePatch(patch.id);
23402
+ continue;
23403
+ }
23404
+ if (lastIndexByHash.get(r2.hash) !== i) {
23405
+ const canonicalIdx = lastIndexByHash.get(r2.hash);
23406
+ const canonical = patchesToCommit[canonicalIdx];
23335
23407
  lockManager.removePatch(patch.id);
23408
+ warnings.push(
23409
+ `Consolidated patch ${patch.id} (commit ${(patch.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.`
23410
+ );
23336
23411
  continue;
23337
23412
  }
23338
- if (result.hadFallback) {
23413
+ if (r2.hadFallback) {
23339
23414
  warnings.push(
23340
- `Patch ${patch.id}: ${result.fallbackFiles.join(", ")} could not be anchored; cumulative-diff fallback applied for those files`
23415
+ `Patch ${patch.id}: ${r2.fallbackFiles.join(", ")} could not be anchored; cumulative-diff fallback applied for those files`
23341
23416
  );
23342
23417
  }
23343
- const newContentHash = detector.computeContentHash(diff);
23344
- const changedFiles = result.hadFallback ? await getChangedFiles(git, currentGen, patch.files) : extractFilesFromDiff(diff);
23418
+ const changedFiles = r2.hadFallback ? await getChangedFiles(git, currentGen, patch.files) : extractFilesFromDiff(r2.diff);
23419
+ const filesForSnapshot = changedFiles.length > 0 ? changedFiles : patch.files;
23420
+ const theirsSnapshot = {};
23421
+ for (const file of filesForSnapshot) {
23422
+ if (isBinaryFile(file)) continue;
23423
+ const content = await (0, import_promises7.readFile)((0, import_path14.join)(outputDir, file), "utf-8").catch(() => null);
23424
+ if (content != null) {
23425
+ theirsSnapshot[file] = content;
23426
+ }
23427
+ }
23345
23428
  lockManager.markPatchResolved(patch.id, {
23346
- patch_content: diff,
23347
- content_hash: newContentHash,
23429
+ patch_content: r2.diff,
23430
+ content_hash: r2.hash,
23348
23431
  base_generation: currentGen,
23349
- files: changedFiles.length > 0 ? changedFiles : patch.files
23432
+ files: filesForSnapshot,
23433
+ ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {}
23350
23434
  });
23351
23435
  patchesResolved++;
23352
23436
  }
@@ -23446,39 +23530,43 @@ function status(outputDir) {
23446
23530
  excludePatterns
23447
23531
  };
23448
23532
  }
23449
- var import_fs6, import_path7, import_yaml, import_crypto, import_promises3, import_os, import_path8, import_fs7, import_path9, import_promises4, import_os2, import_path10, import_child_process3, import_crypto2, import_fs8, import_path11, import_yaml2, import_crypto3, import_fs9, import_path12, import_fs10, import_promises5, import_path13, __defProp3, __getOwnPropNames3, __esm3, __export3, GitClient_exports, GitClient, init_GitClient, HybridReconstruction_exports, init_HybridReconstruction, CREDENTIAL_PATTERNS, SENSITIVE_FILE_PATTERNS, FERN_BOT_NAME, FERN_BOT_EMAIL, FERN_BOT_LOGIN, FERN_SUPPORT_NAMES, LOCKFILE_HEADER, LockfileNotFoundError, LockfileManager, INFRASTRUCTURE_FILES, ReplayDetector, CONFLICT_OPENER, CONFLICT_SEPARATOR, CONFLICT_CLOSER, BINARY_EXTENSIONS, ReplayApplicator, ReplayCommitter, ReplayService, FernignoreMigrator, REPLAY_FERNIGNORE_ENTRIES, GITATTRIBUTES_ENTRIES, EMPTY_RESULT;
23533
+ var import_promises3, import_os, import_path7, import_fs6, import_path8, import_yaml, import_crypto, import_promises4, import_os2, import_path9, import_fs7, import_promises5, import_path10, import_promises6, import_os3, import_path11, import_child_process3, import_crypto2, import_fs8, import_path12, import_yaml2, import_crypto3, import_fs9, import_path13, import_fs10, import_promises7, import_path14, __defProp3, __getOwnPropNames3, __esm3, __export3, GitClient_exports, GitClient, init_GitClient, HybridReconstruction_exports, init_HybridReconstruction, PatchApplyTheirs_exports, init_PatchApplyTheirs, CREDENTIAL_PATTERNS, SENSITIVE_FILE_PATTERNS, FERN_BOT_NAME, FERN_BOT_EMAIL, FERN_BOT_LOGIN, FERN_SUPPORT_NAMES, LOCKFILE_HEADER, LockfileNotFoundError, LockfileManager, CONFLICT_OPENER, CONFLICT_SEPARATOR, CONFLICT_CLOSER, BINARY_EXTENSIONS, ReplayApplicator, INFRASTRUCTURE_FILES, ReplayDetector, ReplayCommitter, ReplayService, FernignoreMigrator, REPLAY_FERNIGNORE_ENTRIES, GITATTRIBUTES_ENTRIES, EMPTY_RESULT;
23450
23534
  var init_dist3 = __esm({
23451
- "../../node_modules/.pnpm/@fern-api+replay@0.14.0/node_modules/@fern-api/replay/dist/index.js"() {
23535
+ "../../node_modules/.pnpm/@fern-api+replay@0.14.1/node_modules/@fern-api/replay/dist/index.js"() {
23452
23536
  "use strict";
23453
23537
  init_esm4();
23454
- import_fs6 = require("fs");
23455
- import_path7 = require("path");
23456
- import_yaml = __toESM(require_dist3(), 1);
23457
- import_crypto = require("crypto");
23458
- init_diff3();
23459
23538
  import_promises3 = require("fs/promises");
23460
23539
  import_os = require("os");
23540
+ import_path7 = require("path");
23541
+ import_fs6 = require("fs");
23461
23542
  import_path8 = require("path");
23462
- init_esm3();
23463
- import_fs7 = require("fs");
23464
- import_path9 = require("path");
23465
- init_esm3();
23543
+ import_yaml = __toESM(require_dist3(), 1);
23544
+ import_crypto = require("crypto");
23466
23545
  import_promises4 = require("fs/promises");
23467
23546
  import_os2 = require("os");
23547
+ import_path9 = require("path");
23548
+ init_esm3();
23549
+ init_diff3();
23550
+ import_fs7 = require("fs");
23551
+ import_promises5 = require("fs/promises");
23468
23552
  import_path10 = require("path");
23553
+ init_esm3();
23554
+ import_promises6 = require("fs/promises");
23555
+ import_os3 = require("os");
23556
+ import_path11 = require("path");
23469
23557
  import_child_process3 = require("child_process");
23470
23558
  import_crypto2 = require("crypto");
23471
23559
  import_fs8 = require("fs");
23472
- import_path11 = require("path");
23560
+ import_path12 = require("path");
23473
23561
  init_esm3();
23474
23562
  import_yaml2 = __toESM(require_dist3(), 1);
23475
23563
  import_crypto3 = require("crypto");
23476
23564
  import_fs9 = require("fs");
23477
- import_path12 = require("path");
23565
+ import_path13 = require("path");
23478
23566
  init_esm3();
23479
23567
  import_fs10 = require("fs");
23480
- import_promises5 = require("fs/promises");
23481
- import_path13 = require("path");
23568
+ import_promises7 = require("fs/promises");
23569
+ import_path14 = require("path");
23482
23570
  __defProp3 = Object.defineProperty;
23483
23571
  __getOwnPropNames3 = Object.getOwnPropertyNames;
23484
23572
  __esm3 = (fn, res) => function __init() {
@@ -23634,6 +23722,16 @@ var init_dist3 = __esm({
23634
23722
  "use strict";
23635
23723
  }
23636
23724
  });
23725
+ PatchApplyTheirs_exports = {};
23726
+ __export3(PatchApplyTheirs_exports, {
23727
+ applyPatchToContent: () => applyPatchToContent
23728
+ });
23729
+ init_PatchApplyTheirs = __esm3({
23730
+ "src/PatchApplyTheirs.ts"() {
23731
+ "use strict";
23732
+ init_GitClient();
23733
+ }
23734
+ });
23637
23735
  CREDENTIAL_PATTERNS = [
23638
23736
  { name: "PRIVATE KEY block", re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
23639
23737
  { name: "OPENSSH key", re: /-----BEGIN OPENSSH PRIVATE KEY-----/ },
@@ -23673,10 +23771,10 @@ var init_dist3 = __esm({
23673
23771
  this.outputDir = outputDir;
23674
23772
  }
23675
23773
  get lockfilePath() {
23676
- return (0, import_path7.join)(this.outputDir, ".fern", "replay.lock");
23774
+ return (0, import_path8.join)(this.outputDir, ".fern", "replay.lock");
23677
23775
  }
23678
23776
  get customizationsPath() {
23679
- return (0, import_path7.join)(this.outputDir, ".fern", "replay.yml");
23777
+ return (0, import_path8.join)(this.outputDir, ".fern", "replay.yml");
23680
23778
  }
23681
23779
  exists() {
23682
23780
  return (0, import_fs6.existsSync)(this.lockfilePath);
@@ -23720,7 +23818,17 @@ var init_dist3 = __esm({
23720
23818
  this.warnings.length = 0;
23721
23819
  const cleanPatches = [];
23722
23820
  for (const patch of this.lock.patches) {
23723
- const credentialMatches = scanForCredentials(patch.patch_content);
23821
+ const diffMatches = scanForCredentials(patch.patch_content);
23822
+ const snapshotMatches = [];
23823
+ if (patch.theirs_snapshot) {
23824
+ for (const content2 of Object.values(patch.theirs_snapshot)) {
23825
+ const m = scanForCredentials(content2);
23826
+ for (const x2 of m) {
23827
+ if (!snapshotMatches.includes(x2)) snapshotMatches.push(x2);
23828
+ }
23829
+ }
23830
+ }
23831
+ const credentialMatches = [...diffMatches, ...snapshotMatches.filter((m) => !diffMatches.includes(m))];
23724
23832
  const sensitiveFiles = patch.files.filter((f2) => isSensitiveFile(f2));
23725
23833
  if (credentialMatches.length > 0 || sensitiveFiles.length > 0) {
23726
23834
  const reasons = [];
@@ -23738,7 +23846,7 @@ var init_dist3 = __esm({
23738
23846
  }
23739
23847
  }
23740
23848
  this.lock.patches = cleanPatches;
23741
- const dir = (0, import_path7.dirname)(this.lockfilePath);
23849
+ const dir = (0, import_path8.dirname)(this.lockfilePath);
23742
23850
  if (!(0, import_fs6.existsSync)(dir)) {
23743
23851
  (0, import_fs6.mkdirSync)(dir, { recursive: true });
23744
23852
  }
@@ -23838,716 +23946,210 @@ var init_dist3 = __esm({
23838
23946
  }
23839
23947
  }
23840
23948
  };
23841
- INFRASTRUCTURE_FILES = /* @__PURE__ */ new Set([".fernignore"]);
23842
- ReplayDetector = class {
23949
+ CONFLICT_OPENER = "<<<<<<< Generated";
23950
+ CONFLICT_SEPARATOR = "=======";
23951
+ CONFLICT_CLOSER = ">>>>>>> Your customization";
23952
+ BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
23953
+ ".png",
23954
+ ".jpg",
23955
+ ".jpeg",
23956
+ ".gif",
23957
+ ".bmp",
23958
+ ".ico",
23959
+ ".webp",
23960
+ ".svg",
23961
+ ".pdf",
23962
+ ".doc",
23963
+ ".docx",
23964
+ ".xls",
23965
+ ".xlsx",
23966
+ ".ppt",
23967
+ ".pptx",
23968
+ ".zip",
23969
+ ".gz",
23970
+ ".tar",
23971
+ ".bz2",
23972
+ ".7z",
23973
+ ".rar",
23974
+ ".jar",
23975
+ ".war",
23976
+ ".ear",
23977
+ ".class",
23978
+ ".exe",
23979
+ ".dll",
23980
+ ".so",
23981
+ ".dylib",
23982
+ ".o",
23983
+ ".a",
23984
+ ".woff",
23985
+ ".woff2",
23986
+ ".ttf",
23987
+ ".eot",
23988
+ ".otf",
23989
+ ".mp3",
23990
+ ".mp4",
23991
+ ".avi",
23992
+ ".mov",
23993
+ ".wav",
23994
+ ".flac",
23995
+ ".sqlite",
23996
+ ".db",
23997
+ ".pyc",
23998
+ ".pyo",
23999
+ ".DS_Store"
24000
+ ]);
24001
+ ReplayApplicator = class {
23843
24002
  git;
23844
24003
  lockManager;
23845
- sdkOutputDir;
23846
- warnings = [];
23847
- constructor(git, lockManager, sdkOutputDir) {
24004
+ outputDir;
24005
+ renameCache = /* @__PURE__ */ new Map();
24006
+ treeExistsCache = /* @__PURE__ */ new Map();
24007
+ fileTheirsAccumulator = /* @__PURE__ */ new Map();
24008
+ /**
24009
+ * Apply mode for the current `applyPatches` invocation. Set at the start
24010
+ * of `applyPatches`, read by `mergeFile` to decide:
24011
+ * - whether to skip the intra-loop marker strip (kept in `applyPatches`)
24012
+ * - whether to populate the accumulator after a conflicted merge
24013
+ * (resolve mode populates so subsequent patches on the same file
24014
+ * can use patch A's THEIRS as a structurally-correct merge base
24015
+ * when their diff was authored against post-A structure)
24016
+ * - whether to retry THEIRS reconstruction against the accumulator
24017
+ * when the BASE-relative reconstruction produced markers from a
24018
+ * cross-patch context mismatch
24019
+ */
24020
+ currentApplyMode = "replay";
24021
+ /**
24022
+ * Set of files that appear in 2+ patches in the current `applyPatches`
24023
+ * invocation. Computed at the top of `applyPatches`. Read by `mergeFile`
24024
+ * and `populateAccumulatorForPatch` to decide whether to use the patch's
24025
+ * `theirs_snapshot` directly as THEIRS (snapshot-as-primary) or fall
24026
+ * back to line-anchored reconstruction (FER-9525 cross-patch isolation).
24027
+ *
24028
+ * Snapshot-as-primary is correct when the patch owns its file (no other
24029
+ * patch in this run touches it): the snapshot is what the customer
24030
+ * intends for that file post-regen, regardless of generator structural
24031
+ * changes that would invalidate the diff's line anchors.
24032
+ *
24033
+ * When a file IS shared, snapshots are CUMULATIVE across the patches
24034
+ * touching it (each one's snapshot includes prior patches' contributions).
24035
+ * Using a later patch's cumulative snapshot as its individual THEIRS
24036
+ * would propagate prior patches' edits into the merge — surfacing nested
24037
+ * conflicts at regions the patch alone never touched. Reconstruction
24038
+ * via `applyPatchToContent` is required there.
24039
+ */
24040
+ sharedFiles = /* @__PURE__ */ new Set();
24041
+ constructor(git, lockManager, outputDir) {
23848
24042
  this.git = git;
23849
24043
  this.lockManager = lockManager;
23850
- this.sdkOutputDir = sdkOutputDir;
23851
- }
23852
- async detectNewPatches() {
23853
- const lock = this.lockManager.read();
23854
- const lastGen = this.getLastGeneration(lock);
23855
- if (!lastGen) {
23856
- return { patches: [], revertedPatchIds: [] };
23857
- }
23858
- const exists2 = await this.git.commitExists(lastGen.commit_sha);
23859
- if (!exists2) {
23860
- this.warnings.push(
23861
- `Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to alternate detection.`
23862
- );
23863
- return this.detectPatchesViaTreeDiff(
23864
- lastGen,
23865
- /* commitKnownMissing */
23866
- true
23867
- );
23868
- }
23869
- const isAncestor = await this.git.isAncestor(lastGen.commit_sha, "HEAD");
23870
- if (!isAncestor) {
23871
- return this.detectPatchesViaMergeBase(lastGen, lock);
23872
- }
23873
- return this.detectPatchesInRange(lastGen.commit_sha, lastGen, lock);
24044
+ this.outputDir = outputDir;
23874
24045
  }
23875
24046
  /**
23876
- * FER-10201 — Non-linear-history detection via `merge-base(prevGen, HEAD)`.
23877
- *
23878
- * After a squash-merge of a Fern-bot PR, `lastGen.commit_sha` (the previous
23879
- * `[fern-generated]`) is orphaned but still in git's object database. The
23880
- * merge-base is the commit on main from which the bot's branch was created —
23881
- * a reachable ancestor of HEAD. Walking that range and classifying each
23882
- * commit via `isGenerationCommit` mirrors the linear path and eliminates
23883
- * the bug class where pipeline-driven changes (autoversion, replay,
23884
- * generation) get bundled as customer customizations.
23885
- *
23886
- * Falls through to the legacy composite path when no common ancestor exists
23887
- * (truly disjoint history — orphan branches with no shared root).
24047
+ * Resolve the GenerationRecord for a patch's base_generation.
24048
+ * Falls back to constructing an ad-hoc record from the commit's tree
24049
+ * when base_generation isn't a tracked generation (commit-scan patches).
23888
24050
  */
23889
- async detectPatchesViaMergeBase(lastGen, lock) {
23890
- let mergeBase = "";
24051
+ async resolveBaseGeneration(baseGeneration) {
24052
+ const gen = this.lockManager.getGeneration(baseGeneration);
24053
+ if (gen) return gen;
23891
24054
  try {
23892
- mergeBase = (await this.git.exec(["merge-base", lastGen.commit_sha, "HEAD"])).trim();
24055
+ const treeHash = await this.git.getTreeHash(baseGeneration);
24056
+ return {
24057
+ commit_sha: baseGeneration,
24058
+ tree_hash: treeHash,
24059
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
24060
+ cli_version: "unknown",
24061
+ generator_versions: {}
24062
+ };
23893
24063
  } catch {
24064
+ return void 0;
23894
24065
  }
23895
- if (!mergeBase) {
23896
- this.warnings.push(
23897
- `No common ancestor between previous generation ${lastGen.commit_sha.slice(0, 7)} and HEAD. Falling back to composite tree-diff detection.`
23898
- );
23899
- return this.detectPatchesViaTreeDiff(
23900
- lastGen,
23901
- /* commitKnownMissing */
23902
- false
23903
- );
24066
+ }
24067
+ /** Reset inter-patch accumulator for a new cycle. */
24068
+ resetAccumulator() {
24069
+ this.fileTheirsAccumulator.clear();
24070
+ }
24071
+ /**
24072
+ * @internal Test-only.
24073
+ * Returns a defensive copy of the inter-patch accumulator. Used by the
24074
+ * adversarial test suite (FER-9791) to assert invariant I2: after every
24075
+ * applied patch, the accumulator has an entry for each file the patch
24076
+ * touched, and the entry's content matches on-disk.
24077
+ */
24078
+ getAccumulatorSnapshot() {
24079
+ const snapshot = /* @__PURE__ */ new Map();
24080
+ for (const [path5, entry] of this.fileTheirsAccumulator) {
24081
+ snapshot.set(path5, { content: entry.content, baseGeneration: entry.baseGeneration });
23904
24082
  }
23905
- return this.detectPatchesInRange(mergeBase, lastGen, lock);
24083
+ return snapshot;
23906
24084
  }
23907
24085
  /**
23908
- * FER-10201 — Walk `${rangeStart}..HEAD`, classify each commit, emit one
23909
- * `StoredPatch` per customer commit. Body shared by the linear path
23910
- * (rangeStart = lastGen.commit_sha) and the merge-base path.
24086
+ * Apply all patches, returning results for each.
24087
+ * Skips patches that match exclude patterns in replay.yml
23911
24088
  *
23912
- * Patch `base_generation` is always `lastGen.commit_sha` — the
23913
- * lockfile-tracked reference the applicator uses to fetch base file
23914
- * content, regardless of where the walk actually started.
24089
+ * `applyMode` controls the post-conflict marker strategy:
24090
+ * - `"replay"` (default): strip conflict markers from disk between
24091
+ * iterations whenever a later patch touches the same file. Lets the
24092
+ * follow-up patch's `git apply --3way` see clean OURS content,
24093
+ * which is what the silent-loss fix (PR #73) relies on. The replay
24094
+ * pipeline calls `revertConflictingFiles` after the loop to clean
24095
+ * any markers that survive.
24096
+ * - `"resolve"`: keep markers on disk. The resolve command needs the
24097
+ * customer to see them. Slow-path 3-way merge naturally preserves
24098
+ * A's markers and B's clean writes when their regions don't overlap;
24099
+ * when they do overlap, the customer gets nested markers and
24100
+ * resolves manually. Either way they aren't silently dropped.
23915
24101
  */
23916
- async detectPatchesInRange(rangeStart, lastGen, lock) {
23917
- const log = await this.git.exec([
23918
- "log",
23919
- "--first-parent",
23920
- "--format=%H%x00%an%x00%ae%x00%s",
23921
- `${rangeStart}..HEAD`,
23922
- "--",
23923
- this.sdkOutputDir
23924
- ]);
23925
- if (!log.trim()) {
23926
- return { patches: [], revertedPatchIds: [] };
23927
- }
23928
- const commits = this.parseGitLog(log);
23929
- const newPatches = [];
23930
- const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
23931
- for (const commit of commits) {
23932
- if (isGenerationCommit(commit)) {
23933
- continue;
24102
+ async applyPatches(patches, opts) {
24103
+ const applyMode = opts?.applyMode ?? "replay";
24104
+ this.currentApplyMode = applyMode;
24105
+ this.resetAccumulator();
24106
+ const fileFreq = /* @__PURE__ */ new Map();
24107
+ for (const p of patches) {
24108
+ for (const f2 of p.files) {
24109
+ fileFreq.set(f2, (fileFreq.get(f2) ?? 0) + 1);
23934
24110
  }
23935
- if (lock.patches.find((p) => p.original_commit === commit.sha)) {
24111
+ }
24112
+ this.sharedFiles = new Set(
24113
+ Array.from(fileFreq.entries()).filter(([, n]) => n > 1).map(([f2]) => f2)
24114
+ );
24115
+ const results = [];
24116
+ for (let i = 0; i < patches.length; i++) {
24117
+ const patch = patches[i];
24118
+ if (this.isExcluded(patch)) {
24119
+ results.push({
24120
+ patch,
24121
+ status: "skipped",
24122
+ method: "git-am"
24123
+ });
23936
24124
  continue;
23937
24125
  }
23938
- const parents = await this.git.getCommitParents(commit.sha);
23939
- if (parents.length > 1) {
23940
- const compositePatch = await this.createMergeCompositePatch({
23941
- mergeCommit: commit,
23942
- firstParent: parents[0],
23943
- lastGen,
23944
- lock
23945
- });
23946
- if (compositePatch) {
23947
- newPatches.push(compositePatch);
23948
- }
23949
- continue;
23950
- }
23951
- let patchContent;
23952
- try {
23953
- patchContent = await this.git.formatPatch(commit.sha);
23954
- } catch {
23955
- this.warnings.push(
23956
- `Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
23957
- );
23958
- continue;
23959
- }
23960
- let contentHash = this.computeContentHash(patchContent);
23961
- if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
23962
- continue;
23963
- }
23964
- const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
23965
- const allFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f2) => !INFRASTRUCTURE_FILES.has(f2)).filter((f2) => !f2.startsWith(".fern/"));
23966
- const sensitiveFiles = allFiles.filter((f2) => isSensitiveFile(f2));
23967
- const files = allFiles.filter((f2) => !isSensitiveFile(f2));
23968
- if (sensitiveFiles.length > 0) {
23969
- this.warnings.push(
23970
- `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
23971
- );
23972
- if (files.length > 0) {
23973
- const parentSha = parents[0];
23974
- if (parentSha) {
23975
- try {
23976
- patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
23977
- } catch {
23978
- continue;
23979
- }
23980
- if (!patchContent.trim()) continue;
23981
- contentHash = this.computeContentHash(patchContent);
23982
- }
23983
- }
23984
- }
23985
- if (files.length === 0) {
23986
- continue;
23987
- }
23988
- newPatches.push({
23989
- id: `patch-${commit.sha.slice(0, 8)}`,
23990
- content_hash: contentHash,
23991
- original_commit: commit.sha,
23992
- original_message: commit.message,
23993
- original_author: `${commit.authorName} <${commit.authorEmail}>`,
23994
- base_generation: lastGen.commit_sha,
23995
- files,
23996
- patch_content: patchContent,
23997
- ...this.isCreationOnlyPatch(patchContent) ? { user_owned: true } : {}
23998
- });
23999
- }
24000
- const survivingPatches = await this.collapseNetZeroFiles(newPatches);
24001
- survivingPatches.reverse();
24002
- const revertedPatchIdSet = /* @__PURE__ */ new Set();
24003
- const revertIndicesToRemove = /* @__PURE__ */ new Set();
24004
- for (let i = 0; i < survivingPatches.length; i++) {
24005
- const patch = survivingPatches[i];
24006
- if (!isRevertCommit(patch.original_message)) continue;
24007
- let body = "";
24008
- try {
24009
- body = await this.git.getCommitBody(patch.original_commit);
24010
- } catch {
24011
- }
24012
- const revertedSha = parseRevertedSha(body);
24013
- const revertedMessage = parseRevertedMessage(patch.original_message);
24014
- let matchedExisting = false;
24015
- if (revertedSha) {
24016
- const existing = lock.patches.find((p) => p.original_commit === revertedSha);
24017
- if (existing) {
24018
- revertedPatchIdSet.add(existing.id);
24019
- revertIndicesToRemove.add(i);
24020
- matchedExisting = true;
24021
- }
24022
- }
24023
- if (!matchedExisting && revertedMessage) {
24024
- const existing = lock.patches.find((p) => p.original_message === revertedMessage);
24025
- if (existing) {
24026
- revertedPatchIdSet.add(existing.id);
24027
- revertIndicesToRemove.add(i);
24028
- matchedExisting = true;
24029
- }
24030
- }
24031
- if (matchedExisting) continue;
24032
- let matchedNew = false;
24033
- if (revertedSha) {
24034
- const idx = survivingPatches.findIndex(
24035
- (p, j2) => j2 !== i && !revertIndicesToRemove.has(j2) && p.original_commit === revertedSha
24036
- );
24037
- if (idx !== -1) {
24038
- revertIndicesToRemove.add(i);
24039
- revertIndicesToRemove.add(idx);
24040
- matchedNew = true;
24041
- }
24042
- }
24043
- if (!matchedNew && revertedMessage) {
24044
- const idx = survivingPatches.findIndex(
24045
- (p, j2) => j2 !== i && !revertIndicesToRemove.has(j2) && p.original_message === revertedMessage
24046
- );
24047
- if (idx !== -1) {
24048
- revertIndicesToRemove.add(i);
24049
- revertIndicesToRemove.add(idx);
24050
- }
24051
- }
24052
- if (!matchedExisting && !matchedNew) {
24053
- revertIndicesToRemove.add(i);
24054
- }
24055
- }
24056
- const filteredPatches = survivingPatches.filter((_2, i) => !revertIndicesToRemove.has(i));
24057
- return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
24058
- }
24059
- /**
24060
- * Compute content hash for deduplication.
24061
- *
24062
- * Produces a format-agnostic hash so both `git format-patch` output
24063
- * (email-wrapped) and plain `git diff` output hash to the same value
24064
- * when the underlying diff hunks are identical.
24065
- *
24066
- * Normalization:
24067
- * 1. Strip email wrapper: everything before the first `diff --git` line
24068
- * (From:, Subject:, Date:, diffstat, blank separators).
24069
- * 2. Strip `index` lines (blob SHA pairs change across rebases).
24070
- * 3. Strip trailing git version marker (`-- \n<version>`).
24071
- *
24072
- * This ensures content hashes match across the no-patches and normal-
24073
- * regeneration flows, which store formatPatch and git-diff formats
24074
- * respectively (FER-9850, D5-D9).
24075
- */
24076
- computeContentHash(patchContent) {
24077
- const lines = patchContent.split("\n");
24078
- const diffStart = lines.findIndex((l) => l.startsWith("diff --git "));
24079
- const relevantLines = diffStart > 0 ? lines.slice(diffStart) : lines;
24080
- const normalized = relevantLines.filter((line) => !line.startsWith("index ")).join("\n").replace(/\n-- \n[\d]+\.[\d]+[^\n]*\s*$/, "");
24081
- return `sha256:${(0, import_crypto.createHash)("sha256").update(normalized).digest("hex")}`;
24082
- }
24083
- /**
24084
- * FER-9805 — Drop patches whose files were transiently created and then
24085
- * deleted within the current linear detection window, and are absent from
24086
- * HEAD at the end of the window.
24087
- *
24088
- * Rationale: on a merge commit, createMergeCompositePatch already diffs
24089
- * firstParent..mergeCommit so net-zero files never enter the composite. On
24090
- * linear history each commit becomes its own patch, so a file that was
24091
- * briefly added and later removed survives as a create+delete pair whose
24092
- * cumulative diff is empty. We drop such patches before they reach the
24093
- * lockfile.
24094
- *
24095
- * A file is "transient" when:
24096
- * 1. some patch in the window sets `new file mode` for it (a creation), AND
24097
- * 2. some patch in the window sets `deleted file mode` for it (a deletion), AND
24098
- * 3. it is absent from HEAD's tree.
24099
- *
24100
- * The HEAD guard (#3) prevents false positives when a file is deleted
24101
- * and then recreated with different content — the cumulative diff of
24102
- * such a pair is non-empty and must be preserved.
24103
- *
24104
- * This only drops patches whose `files` are a subset of the transient
24105
- * set. A partial-transient patch (one that touches a transient file
24106
- * alongside a persistent one) is left unmodified; surgical stripping of
24107
- * single files from a multi-file patch would require a follow-up that
24108
- * regenerates the patch content via `git format-patch -- <files>`. No
24109
- * current failing test exercises this, and leaving the patch intact
24110
- * preserves today's behaviour. TODO(FER-9805): revisit if a future
24111
- * adversarial test demands per-file stripping.
24112
- */
24113
- async collapseNetZeroFiles(patches) {
24114
- if (patches.length === 0) return patches;
24115
- const stateByFile = /* @__PURE__ */ new Map();
24116
- for (const patch of patches) {
24117
- for (const header of parsePatchFileHeaders(patch.patch_content)) {
24118
- if (!header.isCreate && !header.isDelete) continue;
24119
- const prior = stateByFile.get(header.path) ?? { created: false, deleted: false };
24120
- if (header.isCreate) prior.created = true;
24121
- if (header.isDelete) prior.deleted = true;
24122
- stateByFile.set(header.path, prior);
24123
- }
24124
- }
24125
- let anyCandidate = false;
24126
- for (const state of stateByFile.values()) {
24127
- if (state.created && state.deleted) {
24128
- anyCandidate = true;
24129
- break;
24130
- }
24131
- }
24132
- if (!anyCandidate) return patches;
24133
- const headFiles = await this.listHeadFiles();
24134
- const transient = /* @__PURE__ */ new Set();
24135
- for (const [file, state] of stateByFile) {
24136
- if (state.created && state.deleted && !headFiles.has(file)) {
24137
- transient.add(file);
24138
- }
24139
- }
24140
- if (transient.size === 0) return patches;
24141
- return patches.filter((patch) => !patch.files.every((f2) => transient.has(f2)));
24142
- }
24143
- /**
24144
- * List every tracked path at HEAD (one `git ls-tree -r` invocation).
24145
- * Returns an empty Set if HEAD is unborn or the invocation fails — the
24146
- * caller then treats every candidate as "not in HEAD" and may collapse
24147
- * more aggressively. On typical SDK repos this is a single sub-10ms call.
24148
- */
24149
- async listHeadFiles() {
24150
- try {
24151
- const out = await this.git.exec(["ls-tree", "-r", "--name-only", "HEAD"]);
24152
- return new Set(out.split("\n").map((l) => l.trim()).filter(Boolean));
24153
- } catch {
24154
- return /* @__PURE__ */ new Set();
24155
- }
24156
- }
24157
- /**
24158
- * Create a single composite patch for a merge commit by diffing the merge
24159
- * commit against its first parent. This captures the net change of the
24160
- * entire merged branch as one patch, eliminating accumulator chaining and
24161
- * zero-net-change ghost conflicts.
24162
- */
24163
- async createMergeCompositePatch(input) {
24164
- const { mergeCommit, firstParent, lastGen, lock } = input;
24165
- const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
24166
- const filesOutput = await this.git.exec([
24167
- "diff",
24168
- "--name-only",
24169
- firstParent,
24170
- mergeCommit.sha
24171
- ]);
24172
- const allMergeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f2) => !INFRASTRUCTURE_FILES.has(f2)).filter((f2) => !f2.startsWith(".fern/"));
24173
- const sensitiveMergeFiles = allMergeFiles.filter((f2) => isSensitiveFile(f2));
24174
- const files = allMergeFiles.filter((f2) => !isSensitiveFile(f2));
24175
- if (sensitiveMergeFiles.length > 0) {
24176
- this.warnings.push(
24177
- `Sensitive file(s) excluded from merge patch for commit ${mergeCommit.sha.slice(0, 7)}: ${sensitiveMergeFiles.join(", ")}. These files will not be tracked by replay.`
24178
- );
24179
- }
24180
- if (files.length === 0) return null;
24181
- const diff = await this.git.exec([
24182
- "diff",
24183
- firstParent,
24184
- mergeCommit.sha,
24185
- "--",
24186
- ...files
24187
- ]);
24188
- if (!diff.trim()) return null;
24189
- const contentHash = this.computeContentHash(diff);
24190
- if (lock.patches.some((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
24191
- return null;
24192
- }
24193
- return {
24194
- id: `patch-merge-${mergeCommit.sha.slice(0, 8)}`,
24195
- content_hash: contentHash,
24196
- original_commit: mergeCommit.sha,
24197
- original_message: mergeCommit.message,
24198
- original_author: `${mergeCommit.authorName} <${mergeCommit.authorEmail}>`,
24199
- base_generation: lastGen.commit_sha,
24200
- files,
24201
- patch_content: diff,
24202
- ...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
24203
- };
24204
- }
24205
- /**
24206
- * Detect patches via tree diff for non-linear history. Returns a composite patch.
24207
- * Revert reconciliation is skipped here because tree-diff produces a single composite
24208
- * patch from the aggregate diff — individual revert commits are not distinguishable.
24209
- */
24210
- async detectPatchesViaTreeDiff(lastGen, commitKnownMissing) {
24211
- const diffBase = await this.resolveDiffBase(lastGen, commitKnownMissing);
24212
- if (!diffBase) {
24213
- return this.detectPatchesViaCommitScan();
24214
- }
24215
- const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
24216
- const allTreeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f2) => !INFRASTRUCTURE_FILES.has(f2)).filter((f2) => !f2.startsWith(".fern/"));
24217
- const sensitiveTreeFiles = allTreeFiles.filter((f2) => isSensitiveFile(f2));
24218
- const files = allTreeFiles.filter((f2) => !isSensitiveFile(f2));
24219
- if (sensitiveTreeFiles.length > 0) {
24220
- this.warnings.push(
24221
- `Sensitive file(s) excluded from composite patch: ${sensitiveTreeFiles.join(", ")}. These files will not be tracked by replay.`
24222
- );
24223
- }
24224
- if (files.length === 0) return { patches: [], revertedPatchIds: [] };
24225
- const diff = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
24226
- if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
24227
- const contentHash = this.computeContentHash(diff);
24228
- const lock = this.lockManager.read();
24229
- if (lock.patches.some((p) => p.content_hash === contentHash) || (lock.forgotten_hashes ?? []).includes(contentHash)) {
24230
- return { patches: [], revertedPatchIds: [] };
24231
- }
24232
- const headSha = (await this.git.exec(["rev-parse", "HEAD"])).trim();
24233
- const compositePatch = {
24234
- id: `patch-composite-${headSha.slice(0, 8)}`,
24235
- content_hash: contentHash,
24236
- original_commit: headSha,
24237
- original_message: "Customer customizations (composite)",
24238
- original_author: "composite",
24239
- // Use diffBase when commit is unreachable — the applicator needs a reachable
24240
- // reference to find base file content. diffBase may be the tree_hash.
24241
- base_generation: commitKnownMissing ? diffBase : lastGen.commit_sha,
24242
- files,
24243
- patch_content: diff,
24244
- ...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
24245
- };
24246
- return { patches: [compositePatch], revertedPatchIds: [] };
24247
- }
24248
- /**
24249
- * Last-resort detection when both generation commit and tree are unreachable.
24250
- * Scans all commits from HEAD, filters against known lockfile patches, and
24251
- * skips creation-only commits (squashed history after force push).
24252
- *
24253
- * Detected patches use the commit's parent as base_generation so the applicator
24254
- * can find base file content from the parent's tree (which IS reachable).
24255
- */
24256
- async detectPatchesViaCommitScan() {
24257
- const lock = this.lockManager.read();
24258
- const log = await this.git.exec([
24259
- "log",
24260
- "--max-count=200",
24261
- "--format=%H%x00%an%x00%ae%x00%s",
24262
- "HEAD",
24263
- "--",
24264
- this.sdkOutputDir
24265
- ]);
24266
- if (!log.trim()) {
24267
- return { patches: [], revertedPatchIds: [] };
24268
- }
24269
- const commits = this.parseGitLog(log);
24270
- const newPatches = [];
24271
- const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
24272
- const existingHashes = new Set(lock.patches.map((p) => p.content_hash));
24273
- const existingCommits = new Set(lock.patches.map((p) => p.original_commit));
24274
- for (const commit of commits) {
24275
- if (isGenerationCommit(commit)) {
24276
- continue;
24277
- }
24278
- const parents = await this.git.getCommitParents(commit.sha);
24279
- if (parents.length > 1) {
24280
- continue;
24281
- }
24282
- if (existingCommits.has(commit.sha)) {
24283
- continue;
24284
- }
24285
- let patchContent;
24286
- try {
24287
- patchContent = await this.git.formatPatch(commit.sha);
24288
- } catch {
24289
- continue;
24290
- }
24291
- let contentHash = this.computeContentHash(patchContent);
24292
- if (existingHashes.has(contentHash) || forgottenHashes.has(contentHash)) {
24293
- continue;
24294
- }
24295
- if (this.isCreationOnlyPatch(patchContent)) {
24296
- continue;
24297
- }
24298
- const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
24299
- const allScanFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f2) => !INFRASTRUCTURE_FILES.has(f2));
24300
- const sensitiveScanFiles = allScanFiles.filter((f2) => isSensitiveFile(f2));
24301
- const files = allScanFiles.filter((f2) => !isSensitiveFile(f2));
24302
- if (sensitiveScanFiles.length > 0) {
24303
- this.warnings.push(
24304
- `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveScanFiles.join(", ")}. These files will not be tracked by replay.`
24305
- );
24306
- if (files.length > 0) {
24307
- if (parents.length === 0) {
24308
- continue;
24309
- }
24310
- try {
24311
- patchContent = await this.git.exec(["diff", parents[0], commit.sha, "--", ...files]);
24312
- } catch {
24313
- continue;
24314
- }
24315
- if (!patchContent.trim()) continue;
24316
- contentHash = this.computeContentHash(patchContent);
24317
- }
24318
- }
24319
- if (files.length === 0) {
24320
- continue;
24321
- }
24322
- if (parents.length === 0) {
24323
- continue;
24324
- }
24325
- const parentSha = parents[0];
24326
- newPatches.push({
24327
- id: `patch-${commit.sha.slice(0, 8)}`,
24328
- content_hash: contentHash,
24329
- original_commit: commit.sha,
24330
- original_message: commit.message,
24331
- original_author: `${commit.authorName} <${commit.authorEmail}>`,
24332
- base_generation: parentSha,
24333
- files,
24334
- patch_content: patchContent
24335
- });
24336
- }
24337
- newPatches.reverse();
24338
- return { patches: newPatches, revertedPatchIds: [] };
24339
- }
24340
- /**
24341
- * Check if a format-patch consists entirely of new-file creations.
24342
- * Used to identify squashed commits after force push, which create all files
24343
- * from scratch (--- /dev/null) rather than modifying existing files.
24344
- */
24345
- isCreationOnlyPatch(patchContent) {
24346
- const diffOldHeaders = patchContent.split("\n").filter((l) => l.startsWith("--- "));
24347
- if (diffOldHeaders.length === 0) {
24348
- return false;
24349
- }
24350
- return diffOldHeaders.every((l) => l === "--- /dev/null");
24351
- }
24352
- /**
24353
- * Resolve the best available diff base for a generation record.
24354
- * Prefers commit_sha, falls back to tree_hash for unreachable commits.
24355
- * When commitKnownMissing is true, skips the redundant commitExists check.
24356
- */
24357
- async resolveDiffBase(gen, commitKnownMissing) {
24358
- if (!commitKnownMissing && await this.git.commitExists(gen.commit_sha)) {
24359
- return gen.commit_sha;
24360
- }
24361
- if (await this.git.treeExists(gen.tree_hash)) {
24362
- return gen.tree_hash;
24363
- }
24364
- return null;
24365
- }
24366
- parseGitLog(log) {
24367
- return log.trim().split("\n").map((line) => {
24368
- const [sha, authorName, authorEmail, message] = line.split("\0");
24369
- return { sha, authorName, authorEmail, message };
24370
- });
24371
- }
24372
- getLastGeneration(lock) {
24373
- return lock.generations.find((g2) => g2.commit_sha === lock.current_generation);
24374
- }
24375
- };
24376
- CONFLICT_OPENER = "<<<<<<< Generated";
24377
- CONFLICT_SEPARATOR = "=======";
24378
- CONFLICT_CLOSER = ">>>>>>> Your customization";
24379
- BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
24380
- ".png",
24381
- ".jpg",
24382
- ".jpeg",
24383
- ".gif",
24384
- ".bmp",
24385
- ".ico",
24386
- ".webp",
24387
- ".svg",
24388
- ".pdf",
24389
- ".doc",
24390
- ".docx",
24391
- ".xls",
24392
- ".xlsx",
24393
- ".ppt",
24394
- ".pptx",
24395
- ".zip",
24396
- ".gz",
24397
- ".tar",
24398
- ".bz2",
24399
- ".7z",
24400
- ".rar",
24401
- ".jar",
24402
- ".war",
24403
- ".ear",
24404
- ".class",
24405
- ".exe",
24406
- ".dll",
24407
- ".so",
24408
- ".dylib",
24409
- ".o",
24410
- ".a",
24411
- ".woff",
24412
- ".woff2",
24413
- ".ttf",
24414
- ".eot",
24415
- ".otf",
24416
- ".mp3",
24417
- ".mp4",
24418
- ".avi",
24419
- ".mov",
24420
- ".wav",
24421
- ".flac",
24422
- ".sqlite",
24423
- ".db",
24424
- ".pyc",
24425
- ".pyo",
24426
- ".DS_Store"
24427
- ]);
24428
- ReplayApplicator = class {
24429
- git;
24430
- lockManager;
24431
- outputDir;
24432
- renameCache = /* @__PURE__ */ new Map();
24433
- treeExistsCache = /* @__PURE__ */ new Map();
24434
- fileTheirsAccumulator = /* @__PURE__ */ new Map();
24435
- /**
24436
- * Apply mode for the current `applyPatches` invocation. Set at the start
24437
- * of `applyPatches`, read by `mergeFile` to decide:
24438
- * - whether to skip the intra-loop marker strip (kept in `applyPatches`)
24439
- * - whether to populate the accumulator after a conflicted merge
24440
- * (resolve mode populates so subsequent patches on the same file
24441
- * can use patch A's THEIRS as a structurally-correct merge base
24442
- * when their diff was authored against post-A structure)
24443
- * - whether to retry THEIRS reconstruction against the accumulator
24444
- * when the BASE-relative reconstruction produced markers from a
24445
- * cross-patch context mismatch
24446
- */
24447
- currentApplyMode = "replay";
24448
- constructor(git, lockManager, outputDir) {
24449
- this.git = git;
24450
- this.lockManager = lockManager;
24451
- this.outputDir = outputDir;
24452
- }
24453
- /**
24454
- * Resolve the GenerationRecord for a patch's base_generation.
24455
- * Falls back to constructing an ad-hoc record from the commit's tree
24456
- * when base_generation isn't a tracked generation (commit-scan patches).
24457
- */
24458
- async resolveBaseGeneration(baseGeneration) {
24459
- const gen = this.lockManager.getGeneration(baseGeneration);
24460
- if (gen) return gen;
24461
- try {
24462
- const treeHash = await this.git.getTreeHash(baseGeneration);
24463
- return {
24464
- commit_sha: baseGeneration,
24465
- tree_hash: treeHash,
24466
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
24467
- cli_version: "unknown",
24468
- generator_versions: {}
24469
- };
24470
- } catch {
24471
- return void 0;
24472
- }
24473
- }
24474
- /** Reset inter-patch accumulator for a new cycle. */
24475
- resetAccumulator() {
24476
- this.fileTheirsAccumulator.clear();
24477
- }
24478
- /**
24479
- * @internal Test-only.
24480
- * Returns a defensive copy of the inter-patch accumulator. Used by the
24481
- * adversarial test suite (FER-9791) to assert invariant I2: after every
24482
- * applied patch, the accumulator has an entry for each file the patch
24483
- * touched, and the entry's content matches on-disk.
24484
- */
24485
- getAccumulatorSnapshot() {
24486
- const snapshot = /* @__PURE__ */ new Map();
24487
- for (const [path5, entry] of this.fileTheirsAccumulator) {
24488
- snapshot.set(path5, { content: entry.content, baseGeneration: entry.baseGeneration });
24489
- }
24490
- return snapshot;
24491
- }
24492
- /**
24493
- * Apply all patches, returning results for each.
24494
- * Skips patches that match exclude patterns in replay.yml
24495
- *
24496
- * `applyMode` controls the post-conflict marker strategy:
24497
- * - `"replay"` (default): strip conflict markers from disk between
24498
- * iterations whenever a later patch touches the same file. Lets the
24499
- * follow-up patch's `git apply --3way` see clean OURS content,
24500
- * which is what the silent-loss fix (PR #73) relies on. The replay
24501
- * pipeline calls `revertConflictingFiles` after the loop to clean
24502
- * any markers that survive.
24503
- * - `"resolve"`: keep markers on disk. The resolve command needs the
24504
- * customer to see them. Slow-path 3-way merge naturally preserves
24505
- * A's markers and B's clean writes when their regions don't overlap;
24506
- * when they do overlap, the customer gets nested markers and
24507
- * resolves manually. Either way they aren't silently dropped.
24508
- */
24509
- async applyPatches(patches, opts) {
24510
- const applyMode = opts?.applyMode ?? "replay";
24511
- this.currentApplyMode = applyMode;
24512
- this.resetAccumulator();
24513
- const results = [];
24514
- for (let i = 0; i < patches.length; i++) {
24515
- const patch = patches[i];
24516
- if (this.isExcluded(patch)) {
24517
- results.push({
24518
- patch,
24519
- status: "skipped",
24520
- method: "git-am"
24521
- });
24522
- continue;
24523
- }
24524
- const result = await this.applyPatchWithFallback(patch);
24525
- results.push(result);
24526
- if (applyMode !== "resolve" && result.status === "conflict" && result.fileResults) {
24527
- const laterFiles = /* @__PURE__ */ new Set();
24528
- for (let j2 = i + 1; j2 < patches.length; j2++) {
24529
- for (const f2 of patches[j2].files) {
24530
- laterFiles.add(f2);
24531
- }
24532
- }
24533
- const resolvedToOriginal = /* @__PURE__ */ new Map();
24534
- if (result.resolvedFiles) {
24535
- for (const [orig, resolved] of Object.entries(result.resolvedFiles)) {
24536
- resolvedToOriginal.set(resolved, orig);
24537
- }
24538
- }
24539
- for (const fileResult of result.fileResults) {
24540
- if (fileResult.status !== "conflict") continue;
24541
- const originalPath = resolvedToOriginal.get(fileResult.file) ?? fileResult.file;
24542
- if (laterFiles.has(fileResult.file) || laterFiles.has(originalPath)) {
24543
- const filePath = (0, import_path8.join)(this.outputDir, fileResult.file);
24544
- try {
24545
- const content = await (0, import_promises3.readFile)(filePath, "utf-8");
24546
- const stripped = stripConflictMarkers(content);
24547
- await (0, import_promises3.writeFile)(filePath, stripped);
24548
- } catch {
24549
- }
24550
- }
24126
+ const result = await this.applyPatchWithFallback(patch);
24127
+ results.push(result);
24128
+ if (applyMode !== "resolve" && result.status === "conflict" && result.fileResults) {
24129
+ const laterFiles = /* @__PURE__ */ new Set();
24130
+ for (let j2 = i + 1; j2 < patches.length; j2++) {
24131
+ for (const f2 of patches[j2].files) {
24132
+ laterFiles.add(f2);
24133
+ }
24134
+ }
24135
+ const resolvedToOriginal = /* @__PURE__ */ new Map();
24136
+ if (result.resolvedFiles) {
24137
+ for (const [orig, resolved] of Object.entries(result.resolvedFiles)) {
24138
+ resolvedToOriginal.set(resolved, orig);
24139
+ }
24140
+ }
24141
+ for (const fileResult of result.fileResults) {
24142
+ if (fileResult.status !== "conflict") continue;
24143
+ const originalPath = resolvedToOriginal.get(fileResult.file) ?? fileResult.file;
24144
+ if (laterFiles.has(fileResult.file) || laterFiles.has(originalPath)) {
24145
+ const filePath = (0, import_path9.join)(this.outputDir, fileResult.file);
24146
+ try {
24147
+ const content = await (0, import_promises4.readFile)(filePath, "utf-8");
24148
+ const stripped = stripConflictMarkers(content);
24149
+ await (0, import_promises4.writeFile)(filePath, stripped);
24150
+ } catch {
24151
+ }
24152
+ }
24551
24153
  }
24552
24154
  }
24553
24155
  }
@@ -24564,7 +24166,7 @@ var init_dist3 = __esm({
24564
24166
  async populateAccumulatorForPatch(patch, baseGen, currentTreeHash) {
24565
24167
  const fileResults = [];
24566
24168
  if (!baseGen) return fileResults;
24567
- const tempDir = await (0, import_promises3.mkdtemp)((0, import_path8.join)((0, import_os.tmpdir)(), "replay-acc-"));
24169
+ const tempDir = await (0, import_promises4.mkdtemp)((0, import_path9.join)((0, import_os2.tmpdir)(), "replay-acc-"));
24568
24170
  const { GitClient: GitClient2 } = await Promise.resolve().then(() => (init_GitClient(), GitClient_exports));
24569
24171
  const tempGit = new GitClient2(tempDir);
24570
24172
  await tempGit.exec(["init"]);
@@ -24575,8 +24177,10 @@ var init_dist3 = __esm({
24575
24177
  if (isBinaryFile(filePath)) continue;
24576
24178
  const resolvedPath = await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
24577
24179
  const base = await this.git.showFile(baseGen.tree_hash, filePath);
24578
- const theirs = await this.applyPatchToContent(base, patch.patch_content, filePath, tempGit, tempDir);
24579
- const finalOnDisk = await (0, import_promises3.readFile)((0, import_path8.join)(this.outputDir, resolvedPath), "utf-8").catch(() => null);
24180
+ const snapshotForFile = patch.theirs_snapshot?.[resolvedPath] ?? patch.theirs_snapshot?.[filePath];
24181
+ const fileIsShared = this.sharedFiles.has(resolvedPath) || this.sharedFiles.has(filePath);
24182
+ const theirs = !fileIsShared && snapshotForFile != null ? snapshotForFile : await this.applyPatchToContent(base, patch.patch_content, filePath, tempGit, tempDir);
24183
+ const finalOnDisk = await (0, import_promises4.readFile)((0, import_path9.join)(this.outputDir, resolvedPath), "utf-8").catch(() => null);
24580
24184
  const effectiveTheirs = theirs ?? finalOnDisk;
24581
24185
  if (effectiveTheirs != null) {
24582
24186
  this.fileTheirsAccumulator.set(resolvedPath, {
@@ -24596,7 +24200,7 @@ var init_dist3 = __esm({
24596
24200
  }
24597
24201
  }
24598
24202
  } finally {
24599
- await (0, import_promises3.rm)(tempDir, { recursive: true }).catch(() => {
24203
+ await (0, import_promises4.rm)(tempDir, { recursive: true }).catch(() => {
24600
24204
  });
24601
24205
  }
24602
24206
  return fileResults;
@@ -24625,8 +24229,8 @@ var init_dist3 = __esm({
24625
24229
  if (!needsAccumulation && !hasGeneratorRename) {
24626
24230
  const snapshots = /* @__PURE__ */ new Map();
24627
24231
  for (const filePath of patch.files) {
24628
- const fullPath = (0, import_path8.join)(this.outputDir, filePath);
24629
- snapshots.set(filePath, await (0, import_promises3.readFile)(fullPath, "utf-8").catch(() => null));
24232
+ const fullPath = (0, import_path9.join)(this.outputDir, filePath);
24233
+ snapshots.set(filePath, await (0, import_promises4.readFile)(fullPath, "utf-8").catch(() => null));
24630
24234
  }
24631
24235
  try {
24632
24236
  await this.git.execWithInput(["apply", "--3way"], patch.patch_content);
@@ -24640,11 +24244,11 @@ var init_dist3 = __esm({
24640
24244
  };
24641
24245
  } catch {
24642
24246
  for (const [filePath, content] of snapshots) {
24643
- const fullPath = (0, import_path8.join)(this.outputDir, filePath);
24247
+ const fullPath = (0, import_path9.join)(this.outputDir, filePath);
24644
24248
  if (content != null) {
24645
- await (0, import_promises3.writeFile)(fullPath, content);
24249
+ await (0, import_promises4.writeFile)(fullPath, content);
24646
24250
  } else {
24647
- await (0, import_promises3.unlink)(fullPath).catch(() => {
24251
+ await (0, import_promises4.unlink)(fullPath).catch(() => {
24648
24252
  });
24649
24253
  }
24650
24254
  }
@@ -24655,7 +24259,7 @@ var init_dist3 = __esm({
24655
24259
  async applyWithThreeWayMerge(patch) {
24656
24260
  const fileResults = [];
24657
24261
  const resolvedFiles = {};
24658
- const tempDir = await (0, import_promises3.mkdtemp)((0, import_path8.join)((0, import_os.tmpdir)(), "replay-"));
24262
+ const tempDir = await (0, import_promises4.mkdtemp)((0, import_path9.join)((0, import_os2.tmpdir)(), "replay-"));
24659
24263
  const { GitClient: GitClient2 } = await Promise.resolve().then(() => (init_GitClient(), GitClient_exports));
24660
24264
  const tempGit = new GitClient2(tempDir);
24661
24265
  await tempGit.exec(["init"]);
@@ -24678,7 +24282,7 @@ var init_dist3 = __esm({
24678
24282
  fileResults.push(result);
24679
24283
  }
24680
24284
  } finally {
24681
- await (0, import_promises3.rm)(tempDir, { recursive: true }).catch(() => {
24285
+ await (0, import_promises4.rm)(tempDir, { recursive: true }).catch(() => {
24682
24286
  });
24683
24287
  }
24684
24288
  const conflictFiles = fileResults.filter((r2) => r2.status === "conflict");
@@ -24718,8 +24322,8 @@ var init_dist3 = __esm({
24718
24322
  renameSourcePath = renameSource;
24719
24323
  }
24720
24324
  }
24721
- const oursPath = (0, import_path8.join)(this.outputDir, resolvedPath);
24722
- const ours = await (0, import_promises3.readFile)(oursPath, "utf-8").catch(() => null);
24325
+ const oursPath = (0, import_path9.join)(this.outputDir, resolvedPath);
24326
+ const ours = await (0, import_promises4.readFile)(oursPath, "utf-8").catch(() => null);
24723
24327
  let ghostReconstructed = false;
24724
24328
  let theirs = null;
24725
24329
  if (!base && ours && !renameSourcePath) {
@@ -24737,15 +24341,27 @@ var init_dist3 = __esm({
24737
24341
  }
24738
24342
  }
24739
24343
  }
24344
+ const snapshotForFile = patch.theirs_snapshot?.[resolvedPath] ?? patch.theirs_snapshot?.[filePath];
24345
+ const fileIsShared = this.sharedFiles.has(resolvedPath) || this.sharedFiles.has(filePath);
24346
+ const useSnapshotAsPrimary = !fileIsShared && snapshotForFile != null;
24740
24347
  if (!ghostReconstructed) {
24741
- theirs = await this.applyPatchToContent(
24742
- base,
24743
- patch.patch_content,
24744
- filePath,
24745
- tempGit,
24746
- tempDir,
24747
- renameSourcePath
24748
- );
24348
+ if (useSnapshotAsPrimary) {
24349
+ theirs = snapshotForFile;
24350
+ } else {
24351
+ theirs = await this.applyPatchToContent(
24352
+ base,
24353
+ patch.patch_content,
24354
+ filePath,
24355
+ tempGit,
24356
+ tempDir,
24357
+ renameSourcePath
24358
+ );
24359
+ const reconstructionHasMarkers = theirs != null && (theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization"));
24360
+ const baseHadMarkers = base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization"));
24361
+ if (reconstructionHasMarkers && !baseHadMarkers && snapshotForFile != null) {
24362
+ theirs = snapshotForFile;
24363
+ }
24364
+ }
24749
24365
  }
24750
24366
  const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
24751
24367
  if (theirs) {
@@ -24819,16 +24435,16 @@ var init_dist3 = __esm({
24819
24435
  content: effective_theirs,
24820
24436
  baseGeneration: patch.base_generation
24821
24437
  });
24822
- const outDir2 = (0, import_path8.dirname)(oursPath);
24823
- await (0, import_promises3.mkdir)(outDir2, { recursive: true });
24824
- await (0, import_promises3.writeFile)(oursPath, effective_theirs);
24438
+ const outDir2 = (0, import_path9.dirname)(oursPath);
24439
+ await (0, import_promises4.mkdir)(outDir2, { recursive: true });
24440
+ await (0, import_promises4.writeFile)(oursPath, effective_theirs);
24825
24441
  return { file: resolvedPath, status: "merged", reason: "new-file" };
24826
24442
  }
24827
24443
  if (base == null && ours != null && effective_theirs != null && !useAccumulatorAsMergeBase) {
24828
24444
  const merged2 = threeWayMerge("", ours, effective_theirs);
24829
- const outDir2 = (0, import_path8.dirname)(oursPath);
24830
- await (0, import_promises3.mkdir)(outDir2, { recursive: true });
24831
- await (0, import_promises3.writeFile)(oursPath, merged2.content);
24445
+ const outDir2 = (0, import_path9.dirname)(oursPath);
24446
+ await (0, import_promises4.mkdir)(outDir2, { recursive: true });
24447
+ await (0, import_promises4.writeFile)(oursPath, merged2.content);
24832
24448
  if (merged2.hasConflicts) {
24833
24449
  return {
24834
24450
  file: resolvedPath,
@@ -24867,9 +24483,9 @@ var init_dist3 = __esm({
24867
24483
  };
24868
24484
  }
24869
24485
  const merged = threeWayMerge(mergeBase, ours, effective_theirs);
24870
- const outDir = (0, import_path8.dirname)(oursPath);
24871
- await (0, import_promises3.mkdir)(outDir, { recursive: true });
24872
- await (0, import_promises3.writeFile)(oursPath, merged.content);
24486
+ const outDir = (0, import_path9.dirname)(oursPath);
24487
+ await (0, import_promises4.mkdir)(outDir, { recursive: true });
24488
+ await (0, import_promises4.writeFile)(oursPath, merged.content);
24873
24489
  const populateAccumulator = effective_theirs != null && (!merged.hasConflicts || this.currentApplyMode === "resolve");
24874
24490
  if (populateAccumulator) {
24875
24491
  this.fileTheirsAccumulator.set(resolvedPath, {
@@ -24905,171 +24521,734 @@ var init_dist3 = __esm({
24905
24521
  result = await this.git.treeExists(treeHash);
24906
24522
  this.treeExistsCache.set(treeHash, result);
24907
24523
  }
24908
- return result;
24524
+ return result;
24525
+ }
24526
+ isExcluded(patch) {
24527
+ const config = this.lockManager.getCustomizationsConfig();
24528
+ if (!config.exclude) return false;
24529
+ return patch.files.some((file) => config.exclude.some((pattern) => minimatch(file, pattern)));
24530
+ }
24531
+ async resolveFilePath(filePath, baseTreeHash, currentTreeHash) {
24532
+ const config = this.lockManager.getCustomizationsConfig();
24533
+ if (config.moves) {
24534
+ for (const move of config.moves) {
24535
+ if (minimatch(filePath, move.from) || filePath === move.from) {
24536
+ if (filePath === move.from) {
24537
+ return move.to;
24538
+ }
24539
+ const fromBase = move.from.replace(/\*\*.*$/, "");
24540
+ const toBase = move.to.replace(/\*\*.*$/, "");
24541
+ if (filePath.startsWith(fromBase)) {
24542
+ return toBase + filePath.slice(fromBase.length);
24543
+ }
24544
+ }
24545
+ }
24546
+ }
24547
+ const cacheKey = `${baseTreeHash}:${currentTreeHash}`;
24548
+ let renames = this.renameCache.get(cacheKey);
24549
+ if (!renames) {
24550
+ renames = await this.git.detectRenames(baseTreeHash, currentTreeHash);
24551
+ this.renameCache.set(cacheKey, renames);
24552
+ }
24553
+ const gitRename = renames.find((r2) => r2.from === filePath);
24554
+ if (gitRename) {
24555
+ return gitRename.to;
24556
+ }
24557
+ return filePath;
24558
+ }
24559
+ async applyPatchToContent(base, patchContent, filePath, tempGit, tempDir, sourceFilePath) {
24560
+ if (!base) {
24561
+ return this.extractNewFileFromPatch(patchContent, filePath);
24562
+ }
24563
+ const fileDiff = this.extractFileDiff(patchContent, filePath);
24564
+ if (!fileDiff) return null;
24565
+ try {
24566
+ if (sourceFilePath) {
24567
+ const tempSourcePath = (0, import_path9.join)(tempDir, sourceFilePath);
24568
+ await (0, import_promises4.mkdir)((0, import_path9.dirname)(tempSourcePath), { recursive: true });
24569
+ await (0, import_promises4.writeFile)(tempSourcePath, base);
24570
+ await tempGit.exec(["add", sourceFilePath]);
24571
+ await tempGit.exec([
24572
+ "commit",
24573
+ "-m",
24574
+ `base for rename ${sourceFilePath} -> ${filePath}`,
24575
+ "--allow-empty"
24576
+ ]);
24577
+ await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
24578
+ const tempTargetPath = (0, import_path9.join)(tempDir, filePath);
24579
+ return await (0, import_promises4.readFile)(tempTargetPath, "utf-8");
24580
+ }
24581
+ const tempFilePath = (0, import_path9.join)(tempDir, filePath);
24582
+ await (0, import_promises4.mkdir)((0, import_path9.dirname)(tempFilePath), { recursive: true });
24583
+ await (0, import_promises4.writeFile)(tempFilePath, base);
24584
+ await tempGit.exec(["add", filePath]);
24585
+ await tempGit.exec(["commit", "-m", `base for ${filePath}`, "--allow-empty"]);
24586
+ await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
24587
+ return await (0, import_promises4.readFile)(tempFilePath, "utf-8");
24588
+ } catch {
24589
+ return null;
24590
+ }
24591
+ }
24592
+ /**
24593
+ * Detects whether a patch's additions are already present in `content`.
24594
+ * Uses `git apply --reverse --check` — if the reverse patch applies cleanly,
24595
+ * the forward patch is effectively a no-op (its +lines are already there and
24596
+ * its -lines are already gone). Used to distinguish "patch already applied"
24597
+ * from "patch base mismatch" after a forward-apply fails.
24598
+ */
24599
+ async isPatchAlreadyApplied(patchContent, filePath, content, tempGit, tempDir) {
24600
+ const fileDiff = this.extractFileDiff(patchContent, filePath);
24601
+ if (!fileDiff) return false;
24602
+ try {
24603
+ const tempFilePath = (0, import_path9.join)(tempDir, filePath);
24604
+ await (0, import_promises4.mkdir)((0, import_path9.dirname)(tempFilePath), { recursive: true });
24605
+ await (0, import_promises4.writeFile)(tempFilePath, content);
24606
+ await tempGit.exec(["add", filePath]);
24607
+ await tempGit.exec(["commit", "-m", `check already-applied ${filePath}`, "--allow-empty"]);
24608
+ await tempGit.execWithInput(["apply", "--reverse", "--check", "--allow-empty"], fileDiff);
24609
+ return true;
24610
+ } catch {
24611
+ return false;
24612
+ }
24613
+ }
24614
+ extractFileDiff(patchContent, filePath) {
24615
+ const lines = patchContent.split("\n");
24616
+ const diffLines = [];
24617
+ let inTargetFile = false;
24618
+ for (const line of lines) {
24619
+ if (line.startsWith("diff --git")) {
24620
+ if (inTargetFile) {
24621
+ break;
24622
+ }
24623
+ if (isDiffLineForFile(line, filePath)) {
24624
+ inTargetFile = true;
24625
+ diffLines.push(line);
24626
+ }
24627
+ continue;
24628
+ }
24629
+ if (inTargetFile) {
24630
+ diffLines.push(line);
24631
+ }
24632
+ }
24633
+ return diffLines.length > 0 ? diffLines.join("\n") + "\n" : null;
24634
+ }
24635
+ extractRenameSource(patchContent, targetFilePath) {
24636
+ const lines = patchContent.split("\n");
24637
+ let inTargetFile = false;
24638
+ for (const line of lines) {
24639
+ if (line.startsWith("diff --git")) {
24640
+ if (inTargetFile) break;
24641
+ inTargetFile = isDiffLineForFile(line, targetFilePath);
24642
+ continue;
24643
+ }
24644
+ if (!inTargetFile) continue;
24645
+ if (line.startsWith("@@")) break;
24646
+ if (line.startsWith("rename from ")) {
24647
+ return line.slice("rename from ".length);
24648
+ }
24649
+ }
24650
+ return null;
24651
+ }
24652
+ extractNewFileFromPatch(patchContent, filePath) {
24653
+ const lines = patchContent.split("\n");
24654
+ const addedLines = [];
24655
+ let inTargetFile = false;
24656
+ let inHunk = false;
24657
+ let isNewFile = false;
24658
+ let noTrailingNewline = false;
24659
+ for (const line of lines) {
24660
+ if (line.startsWith("diff --git")) {
24661
+ if (inTargetFile) break;
24662
+ inTargetFile = isDiffLineForFile(line, filePath);
24663
+ inHunk = false;
24664
+ isNewFile = false;
24665
+ continue;
24666
+ }
24667
+ if (!inTargetFile) continue;
24668
+ if (!inHunk) {
24669
+ if (line === "--- /dev/null" || line.startsWith("new file mode")) {
24670
+ isNewFile = true;
24671
+ }
24672
+ }
24673
+ if (line.startsWith("@@")) {
24674
+ if (!isNewFile) return null;
24675
+ inHunk = true;
24676
+ continue;
24677
+ }
24678
+ if (!inHunk) continue;
24679
+ if (line === "\") {
24680
+ noTrailingNewline = true;
24681
+ continue;
24682
+ }
24683
+ if (line.startsWith("+") && !line.startsWith("+++")) {
24684
+ addedLines.push(line.slice(1));
24685
+ }
24686
+ }
24687
+ if (addedLines.length === 0) return null;
24688
+ return addedLines.join("\n") + (noTrailingNewline ? "" : "\n");
24689
+ }
24690
+ };
24691
+ INFRASTRUCTURE_FILES = /* @__PURE__ */ new Set([".fernignore"]);
24692
+ ReplayDetector = class {
24693
+ git;
24694
+ lockManager;
24695
+ sdkOutputDir;
24696
+ warnings = [];
24697
+ constructor(git, lockManager, sdkOutputDir) {
24698
+ this.git = git;
24699
+ this.lockManager = lockManager;
24700
+ this.sdkOutputDir = sdkOutputDir;
24701
+ }
24702
+ async detectNewPatches() {
24703
+ const lock = this.lockManager.read();
24704
+ const lastGen = this.getLastGeneration(lock);
24705
+ if (!lastGen) {
24706
+ return { patches: [], revertedPatchIds: [] };
24707
+ }
24708
+ const exists2 = await this.git.commitExists(lastGen.commit_sha);
24709
+ if (!exists2) {
24710
+ this.warnings.push(
24711
+ `Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to alternate detection.`
24712
+ );
24713
+ return this.detectPatchesViaTreeDiff(
24714
+ lastGen,
24715
+ /* commitKnownMissing */
24716
+ true
24717
+ );
24718
+ }
24719
+ const isAncestor = await this.git.isAncestor(lastGen.commit_sha, "HEAD");
24720
+ if (!isAncestor) {
24721
+ return this.detectPatchesViaMergeBase(lastGen, lock);
24722
+ }
24723
+ return this.detectPatchesInRange(lastGen.commit_sha, lastGen, lock);
24724
+ }
24725
+ /**
24726
+ * FER-10201 — Non-linear-history detection via `merge-base(prevGen, HEAD)`.
24727
+ *
24728
+ * After a squash-merge of a Fern-bot PR, `lastGen.commit_sha` (the previous
24729
+ * `[fern-generated]`) is orphaned but still in git's object database. The
24730
+ * merge-base is the commit on main from which the bot's branch was created —
24731
+ * a reachable ancestor of HEAD. Walking that range and classifying each
24732
+ * commit via `isGenerationCommit` mirrors the linear path and eliminates
24733
+ * the bug class where pipeline-driven changes (autoversion, replay,
24734
+ * generation) get bundled as customer customizations.
24735
+ *
24736
+ * Falls through to the legacy composite path when no common ancestor exists
24737
+ * (truly disjoint history — orphan branches with no shared root).
24738
+ */
24739
+ async detectPatchesViaMergeBase(lastGen, lock) {
24740
+ let mergeBase = "";
24741
+ try {
24742
+ mergeBase = (await this.git.exec(["merge-base", lastGen.commit_sha, "HEAD"])).trim();
24743
+ } catch {
24744
+ }
24745
+ if (!mergeBase) {
24746
+ this.warnings.push(
24747
+ `No common ancestor between previous generation ${lastGen.commit_sha.slice(0, 7)} and HEAD. Falling back to composite tree-diff detection.`
24748
+ );
24749
+ return this.detectPatchesViaTreeDiff(
24750
+ lastGen,
24751
+ /* commitKnownMissing */
24752
+ false
24753
+ );
24754
+ }
24755
+ return this.detectPatchesInRange(mergeBase, lastGen, lock);
24756
+ }
24757
+ /**
24758
+ * FER-10201 — Walk `${rangeStart}..HEAD`, classify each commit, emit one
24759
+ * `StoredPatch` per customer commit. Body shared by the linear path
24760
+ * (rangeStart = lastGen.commit_sha) and the merge-base path.
24761
+ *
24762
+ * Patch `base_generation` is always `lastGen.commit_sha` — the
24763
+ * lockfile-tracked reference the applicator uses to fetch base file
24764
+ * content, regardless of where the walk actually started.
24765
+ */
24766
+ async detectPatchesInRange(rangeStart, lastGen, lock) {
24767
+ const log = await this.git.exec([
24768
+ "log",
24769
+ "--first-parent",
24770
+ "--format=%H%x00%an%x00%ae%x00%s",
24771
+ `${rangeStart}..HEAD`,
24772
+ "--",
24773
+ this.sdkOutputDir
24774
+ ]);
24775
+ if (!log.trim()) {
24776
+ return { patches: [], revertedPatchIds: [] };
24777
+ }
24778
+ const commits = this.parseGitLog(log);
24779
+ const newPatches = [];
24780
+ const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
24781
+ for (const commit of commits) {
24782
+ if (isGenerationCommit(commit)) {
24783
+ continue;
24784
+ }
24785
+ if (lock.patches.find((p) => p.original_commit === commit.sha)) {
24786
+ continue;
24787
+ }
24788
+ const parents = await this.git.getCommitParents(commit.sha);
24789
+ if (parents.length > 1) {
24790
+ const compositePatch = await this.createMergeCompositePatch({
24791
+ mergeCommit: commit,
24792
+ firstParent: parents[0],
24793
+ lastGen,
24794
+ lock
24795
+ });
24796
+ if (compositePatch) {
24797
+ newPatches.push(compositePatch);
24798
+ }
24799
+ continue;
24800
+ }
24801
+ let patchContent;
24802
+ try {
24803
+ patchContent = await this.git.formatPatch(commit.sha);
24804
+ } catch {
24805
+ this.warnings.push(
24806
+ `Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
24807
+ );
24808
+ continue;
24809
+ }
24810
+ let contentHash = this.computeContentHash(patchContent);
24811
+ if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
24812
+ continue;
24813
+ }
24814
+ const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
24815
+ const allFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f2) => !INFRASTRUCTURE_FILES.has(f2)).filter((f2) => !f2.startsWith(".fern/"));
24816
+ const sensitiveFiles = allFiles.filter((f2) => isSensitiveFile(f2));
24817
+ const files = allFiles.filter((f2) => !isSensitiveFile(f2));
24818
+ if (sensitiveFiles.length > 0) {
24819
+ this.warnings.push(
24820
+ `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
24821
+ );
24822
+ if (files.length > 0) {
24823
+ const parentSha = parents[0];
24824
+ if (parentSha) {
24825
+ try {
24826
+ patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
24827
+ } catch {
24828
+ continue;
24829
+ }
24830
+ if (!patchContent.trim()) continue;
24831
+ contentHash = this.computeContentHash(patchContent);
24832
+ }
24833
+ }
24834
+ }
24835
+ if (files.length === 0) {
24836
+ continue;
24837
+ }
24838
+ const theirsSnapshot = {};
24839
+ for (const file of files) {
24840
+ if (isBinaryFile(file)) continue;
24841
+ const content = await this.git.showFile(commit.sha, file).catch(() => null);
24842
+ if (content != null) theirsSnapshot[file] = content;
24843
+ }
24844
+ newPatches.push({
24845
+ id: `patch-${commit.sha.slice(0, 8)}`,
24846
+ content_hash: contentHash,
24847
+ original_commit: commit.sha,
24848
+ original_message: commit.message,
24849
+ original_author: `${commit.authorName} <${commit.authorEmail}>`,
24850
+ base_generation: lastGen.commit_sha,
24851
+ files,
24852
+ patch_content: patchContent,
24853
+ ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
24854
+ ...this.isCreationOnlyPatch(patchContent) ? { user_owned: true } : {}
24855
+ });
24856
+ }
24857
+ const survivingPatches = await this.collapseNetZeroFiles(newPatches);
24858
+ survivingPatches.reverse();
24859
+ const revertedPatchIdSet = /* @__PURE__ */ new Set();
24860
+ const revertIndicesToRemove = /* @__PURE__ */ new Set();
24861
+ for (let i = 0; i < survivingPatches.length; i++) {
24862
+ const patch = survivingPatches[i];
24863
+ if (!isRevertCommit(patch.original_message)) continue;
24864
+ let body = "";
24865
+ try {
24866
+ body = await this.git.getCommitBody(patch.original_commit);
24867
+ } catch {
24868
+ }
24869
+ const revertedSha = parseRevertedSha(body);
24870
+ const revertedMessage = parseRevertedMessage(patch.original_message);
24871
+ let matchedExisting = false;
24872
+ if (revertedSha) {
24873
+ const existing = lock.patches.find((p) => p.original_commit === revertedSha);
24874
+ if (existing) {
24875
+ revertedPatchIdSet.add(existing.id);
24876
+ revertIndicesToRemove.add(i);
24877
+ matchedExisting = true;
24878
+ }
24879
+ }
24880
+ if (!matchedExisting && revertedMessage) {
24881
+ const existing = lock.patches.find((p) => p.original_message === revertedMessage);
24882
+ if (existing) {
24883
+ revertedPatchIdSet.add(existing.id);
24884
+ revertIndicesToRemove.add(i);
24885
+ matchedExisting = true;
24886
+ }
24887
+ }
24888
+ if (matchedExisting) continue;
24889
+ let matchedNew = false;
24890
+ if (revertedSha) {
24891
+ const idx = survivingPatches.findIndex(
24892
+ (p, j2) => j2 !== i && !revertIndicesToRemove.has(j2) && p.original_commit === revertedSha
24893
+ );
24894
+ if (idx !== -1) {
24895
+ revertIndicesToRemove.add(i);
24896
+ revertIndicesToRemove.add(idx);
24897
+ matchedNew = true;
24898
+ }
24899
+ }
24900
+ if (!matchedNew && revertedMessage) {
24901
+ const idx = survivingPatches.findIndex(
24902
+ (p, j2) => j2 !== i && !revertIndicesToRemove.has(j2) && p.original_message === revertedMessage
24903
+ );
24904
+ if (idx !== -1) {
24905
+ revertIndicesToRemove.add(i);
24906
+ revertIndicesToRemove.add(idx);
24907
+ }
24908
+ }
24909
+ if (!matchedExisting && !matchedNew) {
24910
+ revertIndicesToRemove.add(i);
24911
+ }
24912
+ }
24913
+ const filteredPatches = survivingPatches.filter((_2, i) => !revertIndicesToRemove.has(i));
24914
+ return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
24909
24915
  }
24910
- isExcluded(patch) {
24911
- const config = this.lockManager.getCustomizationsConfig();
24912
- if (!config.exclude) return false;
24913
- return patch.files.some((file) => config.exclude.some((pattern) => minimatch(file, pattern)));
24916
+ /**
24917
+ * Compute content hash for deduplication.
24918
+ *
24919
+ * Produces a format-agnostic hash so both `git format-patch` output
24920
+ * (email-wrapped) and plain `git diff` output hash to the same value
24921
+ * when the underlying diff hunks are identical.
24922
+ *
24923
+ * Normalization:
24924
+ * 1. Strip email wrapper: everything before the first `diff --git` line
24925
+ * (From:, Subject:, Date:, diffstat, blank separators).
24926
+ * 2. Strip `index` lines (blob SHA pairs change across rebases).
24927
+ * 3. Strip trailing git version marker (`-- \n<version>`).
24928
+ *
24929
+ * This ensures content hashes match across the no-patches and normal-
24930
+ * regeneration flows, which store formatPatch and git-diff formats
24931
+ * respectively (FER-9850, D5-D9).
24932
+ */
24933
+ computeContentHash(patchContent) {
24934
+ const lines = patchContent.split("\n");
24935
+ const diffStart = lines.findIndex((l) => l.startsWith("diff --git "));
24936
+ const relevantLines = diffStart > 0 ? lines.slice(diffStart) : lines;
24937
+ const normalized = relevantLines.filter((line) => !line.startsWith("index ")).join("\n").replace(/\n-- \n[\d]+\.[\d]+[^\n]*\s*$/, "");
24938
+ return `sha256:${(0, import_crypto.createHash)("sha256").update(normalized).digest("hex")}`;
24914
24939
  }
24915
- async resolveFilePath(filePath, baseTreeHash, currentTreeHash) {
24916
- const config = this.lockManager.getCustomizationsConfig();
24917
- if (config.moves) {
24918
- for (const move of config.moves) {
24919
- if (minimatch(filePath, move.from) || filePath === move.from) {
24920
- if (filePath === move.from) {
24921
- return move.to;
24922
- }
24923
- const fromBase = move.from.replace(/\*\*.*$/, "");
24924
- const toBase = move.to.replace(/\*\*.*$/, "");
24925
- if (filePath.startsWith(fromBase)) {
24926
- return toBase + filePath.slice(fromBase.length);
24927
- }
24928
- }
24940
+ /**
24941
+ * FER-9805 — Drop patches whose files were transiently created and then
24942
+ * deleted within the current linear detection window, and are absent from
24943
+ * HEAD at the end of the window.
24944
+ *
24945
+ * Rationale: on a merge commit, createMergeCompositePatch already diffs
24946
+ * firstParent..mergeCommit so net-zero files never enter the composite. On
24947
+ * linear history each commit becomes its own patch, so a file that was
24948
+ * briefly added and later removed survives as a create+delete pair whose
24949
+ * cumulative diff is empty. We drop such patches before they reach the
24950
+ * lockfile.
24951
+ *
24952
+ * A file is "transient" when:
24953
+ * 1. some patch in the window sets `new file mode` for it (a creation), AND
24954
+ * 2. some patch in the window sets `deleted file mode` for it (a deletion), AND
24955
+ * 3. it is absent from HEAD's tree.
24956
+ *
24957
+ * The HEAD guard (#3) prevents false positives when a file is deleted
24958
+ * and then recreated with different content — the cumulative diff of
24959
+ * such a pair is non-empty and must be preserved.
24960
+ *
24961
+ * This only drops patches whose `files` are a subset of the transient
24962
+ * set. A partial-transient patch (one that touches a transient file
24963
+ * alongside a persistent one) is left unmodified; surgical stripping of
24964
+ * single files from a multi-file patch would require a follow-up that
24965
+ * regenerates the patch content via `git format-patch -- <files>`. No
24966
+ * current failing test exercises this, and leaving the patch intact
24967
+ * preserves today's behaviour. TODO(FER-9805): revisit if a future
24968
+ * adversarial test demands per-file stripping.
24969
+ */
24970
+ async collapseNetZeroFiles(patches) {
24971
+ if (patches.length === 0) return patches;
24972
+ const stateByFile = /* @__PURE__ */ new Map();
24973
+ for (const patch of patches) {
24974
+ for (const header of parsePatchFileHeaders(patch.patch_content)) {
24975
+ if (!header.isCreate && !header.isDelete) continue;
24976
+ const prior = stateByFile.get(header.path) ?? { created: false, deleted: false };
24977
+ if (header.isCreate) prior.created = true;
24978
+ if (header.isDelete) prior.deleted = true;
24979
+ stateByFile.set(header.path, prior);
24929
24980
  }
24930
24981
  }
24931
- const cacheKey = `${baseTreeHash}:${currentTreeHash}`;
24932
- let renames = this.renameCache.get(cacheKey);
24933
- if (!renames) {
24934
- renames = await this.git.detectRenames(baseTreeHash, currentTreeHash);
24935
- this.renameCache.set(cacheKey, renames);
24982
+ let anyCandidate = false;
24983
+ for (const state of stateByFile.values()) {
24984
+ if (state.created && state.deleted) {
24985
+ anyCandidate = true;
24986
+ break;
24987
+ }
24936
24988
  }
24937
- const gitRename = renames.find((r2) => r2.from === filePath);
24938
- if (gitRename) {
24939
- return gitRename.to;
24989
+ if (!anyCandidate) return patches;
24990
+ const headFiles = await this.listHeadFiles();
24991
+ const transient = /* @__PURE__ */ new Set();
24992
+ for (const [file, state] of stateByFile) {
24993
+ if (state.created && state.deleted && !headFiles.has(file)) {
24994
+ transient.add(file);
24995
+ }
24940
24996
  }
24941
- return filePath;
24997
+ if (transient.size === 0) return patches;
24998
+ return patches.filter((patch) => !patch.files.every((f2) => transient.has(f2)));
24942
24999
  }
24943
- async applyPatchToContent(base, patchContent, filePath, tempGit, tempDir, sourceFilePath) {
24944
- if (!base) {
24945
- return this.extractNewFileFromPatch(patchContent, filePath);
24946
- }
24947
- const fileDiff = this.extractFileDiff(patchContent, filePath);
24948
- if (!fileDiff) return null;
25000
+ /**
25001
+ * List every tracked path at HEAD (one `git ls-tree -r` invocation).
25002
+ * Returns an empty Set if HEAD is unborn or the invocation fails — the
25003
+ * caller then treats every candidate as "not in HEAD" and may collapse
25004
+ * more aggressively. On typical SDK repos this is a single sub-10ms call.
25005
+ */
25006
+ async listHeadFiles() {
24949
25007
  try {
24950
- if (sourceFilePath) {
24951
- const tempSourcePath = (0, import_path8.join)(tempDir, sourceFilePath);
24952
- await (0, import_promises3.mkdir)((0, import_path8.dirname)(tempSourcePath), { recursive: true });
24953
- await (0, import_promises3.writeFile)(tempSourcePath, base);
24954
- await tempGit.exec(["add", sourceFilePath]);
24955
- await tempGit.exec([
24956
- "commit",
24957
- "-m",
24958
- `base for rename ${sourceFilePath} -> ${filePath}`,
24959
- "--allow-empty"
24960
- ]);
24961
- await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
24962
- const tempTargetPath = (0, import_path8.join)(tempDir, filePath);
24963
- return await (0, import_promises3.readFile)(tempTargetPath, "utf-8");
24964
- }
24965
- const tempFilePath = (0, import_path8.join)(tempDir, filePath);
24966
- await (0, import_promises3.mkdir)((0, import_path8.dirname)(tempFilePath), { recursive: true });
24967
- await (0, import_promises3.writeFile)(tempFilePath, base);
24968
- await tempGit.exec(["add", filePath]);
24969
- await tempGit.exec(["commit", "-m", `base for ${filePath}`, "--allow-empty"]);
24970
- await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
24971
- return await (0, import_promises3.readFile)(tempFilePath, "utf-8");
25008
+ const out = await this.git.exec(["ls-tree", "-r", "--name-only", "HEAD"]);
25009
+ return new Set(out.split("\n").map((l) => l.trim()).filter(Boolean));
24972
25010
  } catch {
25011
+ return /* @__PURE__ */ new Set();
25012
+ }
25013
+ }
25014
+ /**
25015
+ * Create a single composite patch for a merge commit by diffing the merge
25016
+ * commit against its first parent. This captures the net change of the
25017
+ * entire merged branch as one patch, eliminating accumulator chaining and
25018
+ * zero-net-change ghost conflicts.
25019
+ */
25020
+ async createMergeCompositePatch(input) {
25021
+ const { mergeCommit, firstParent, lastGen, lock } = input;
25022
+ const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
25023
+ const filesOutput = await this.git.exec([
25024
+ "diff",
25025
+ "--name-only",
25026
+ firstParent,
25027
+ mergeCommit.sha
25028
+ ]);
25029
+ const allMergeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f2) => !INFRASTRUCTURE_FILES.has(f2)).filter((f2) => !f2.startsWith(".fern/"));
25030
+ const sensitiveMergeFiles = allMergeFiles.filter((f2) => isSensitiveFile(f2));
25031
+ const files = allMergeFiles.filter((f2) => !isSensitiveFile(f2));
25032
+ if (sensitiveMergeFiles.length > 0) {
25033
+ this.warnings.push(
25034
+ `Sensitive file(s) excluded from merge patch for commit ${mergeCommit.sha.slice(0, 7)}: ${sensitiveMergeFiles.join(", ")}. These files will not be tracked by replay.`
25035
+ );
25036
+ }
25037
+ if (files.length === 0) return null;
25038
+ const diff = await this.git.exec([
25039
+ "diff",
25040
+ firstParent,
25041
+ mergeCommit.sha,
25042
+ "--",
25043
+ ...files
25044
+ ]);
25045
+ if (!diff.trim()) return null;
25046
+ const contentHash = this.computeContentHash(diff);
25047
+ if (lock.patches.some((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
24973
25048
  return null;
24974
25049
  }
25050
+ const theirsSnapshot = {};
25051
+ for (const file of files) {
25052
+ if (isBinaryFile(file)) continue;
25053
+ const content = await this.git.showFile(mergeCommit.sha, file).catch(() => null);
25054
+ if (content != null) theirsSnapshot[file] = content;
25055
+ }
25056
+ return {
25057
+ id: `patch-merge-${mergeCommit.sha.slice(0, 8)}`,
25058
+ content_hash: contentHash,
25059
+ original_commit: mergeCommit.sha,
25060
+ original_message: mergeCommit.message,
25061
+ original_author: `${mergeCommit.authorName} <${mergeCommit.authorEmail}>`,
25062
+ base_generation: lastGen.commit_sha,
25063
+ files,
25064
+ patch_content: diff,
25065
+ ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
25066
+ ...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
25067
+ };
24975
25068
  }
24976
25069
  /**
24977
- * Detects whether a patch's additions are already present in `content`.
24978
- * Uses `git apply --reverse --check` — if the reverse patch applies cleanly,
24979
- * the forward patch is effectively a no-op (its +lines are already there and
24980
- * its -lines are already gone). Used to distinguish "patch already applied"
24981
- * from "patch base mismatch" after a forward-apply fails.
25070
+ * Detect patches via tree diff for non-linear history. Returns a composite patch.
25071
+ * Revert reconciliation is skipped here because tree-diff produces a single composite
25072
+ * patch from the aggregate diff — individual revert commits are not distinguishable.
24982
25073
  */
24983
- async isPatchAlreadyApplied(patchContent, filePath, content, tempGit, tempDir) {
24984
- const fileDiff = this.extractFileDiff(patchContent, filePath);
24985
- if (!fileDiff) return false;
24986
- try {
24987
- const tempFilePath = (0, import_path8.join)(tempDir, filePath);
24988
- await (0, import_promises3.mkdir)((0, import_path8.dirname)(tempFilePath), { recursive: true });
24989
- await (0, import_promises3.writeFile)(tempFilePath, content);
24990
- await tempGit.exec(["add", filePath]);
24991
- await tempGit.exec(["commit", "-m", `check already-applied ${filePath}`, "--allow-empty"]);
24992
- await tempGit.execWithInput(["apply", "--reverse", "--check", "--allow-empty"], fileDiff);
24993
- return true;
24994
- } catch {
24995
- return false;
25074
+ async detectPatchesViaTreeDiff(lastGen, commitKnownMissing) {
25075
+ const diffBase = await this.resolveDiffBase(lastGen, commitKnownMissing);
25076
+ if (!diffBase) {
25077
+ return this.detectPatchesViaCommitScan();
25078
+ }
25079
+ const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
25080
+ const allTreeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f2) => !INFRASTRUCTURE_FILES.has(f2)).filter((f2) => !f2.startsWith(".fern/"));
25081
+ const sensitiveTreeFiles = allTreeFiles.filter((f2) => isSensitiveFile(f2));
25082
+ const files = allTreeFiles.filter((f2) => !isSensitiveFile(f2));
25083
+ if (sensitiveTreeFiles.length > 0) {
25084
+ this.warnings.push(
25085
+ `Sensitive file(s) excluded from composite patch: ${sensitiveTreeFiles.join(", ")}. These files will not be tracked by replay.`
25086
+ );
25087
+ }
25088
+ if (files.length === 0) return { patches: [], revertedPatchIds: [] };
25089
+ const diff = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
25090
+ if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
25091
+ const contentHash = this.computeContentHash(diff);
25092
+ const lock = this.lockManager.read();
25093
+ if (lock.patches.some((p) => p.content_hash === contentHash) || (lock.forgotten_hashes ?? []).includes(contentHash)) {
25094
+ return { patches: [], revertedPatchIds: [] };
25095
+ }
25096
+ const headSha = (await this.git.exec(["rev-parse", "HEAD"])).trim();
25097
+ const theirsSnapshot = {};
25098
+ for (const file of files) {
25099
+ if (isBinaryFile(file)) continue;
25100
+ const content = await this.git.showFile("HEAD", file).catch(() => null);
25101
+ if (content != null) theirsSnapshot[file] = content;
24996
25102
  }
25103
+ const compositePatch = {
25104
+ id: `patch-composite-${headSha.slice(0, 8)}`,
25105
+ content_hash: contentHash,
25106
+ original_commit: headSha,
25107
+ original_message: "Customer customizations (composite)",
25108
+ original_author: "composite",
25109
+ // Use diffBase when commit is unreachable — the applicator needs a reachable
25110
+ // reference to find base file content. diffBase may be the tree_hash.
25111
+ base_generation: commitKnownMissing ? diffBase : lastGen.commit_sha,
25112
+ files,
25113
+ patch_content: diff,
25114
+ ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
25115
+ ...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
25116
+ };
25117
+ return { patches: [compositePatch], revertedPatchIds: [] };
24997
25118
  }
24998
- extractFileDiff(patchContent, filePath) {
24999
- const lines = patchContent.split("\n");
25000
- const diffLines = [];
25001
- let inTargetFile = false;
25002
- for (const line of lines) {
25003
- if (line.startsWith("diff --git")) {
25004
- if (inTargetFile) {
25005
- break;
25006
- }
25007
- if (isDiffLineForFile(line, filePath)) {
25008
- inTargetFile = true;
25009
- diffLines.push(line);
25010
- }
25119
+ /**
25120
+ * Last-resort detection when both generation commit and tree are unreachable.
25121
+ * Scans all commits from HEAD, filters against known lockfile patches, and
25122
+ * skips creation-only commits (squashed history after force push).
25123
+ *
25124
+ * Detected patches use the commit's parent as base_generation so the applicator
25125
+ * can find base file content from the parent's tree (which IS reachable).
25126
+ */
25127
+ async detectPatchesViaCommitScan() {
25128
+ const lock = this.lockManager.read();
25129
+ const log = await this.git.exec([
25130
+ "log",
25131
+ "--max-count=200",
25132
+ "--format=%H%x00%an%x00%ae%x00%s",
25133
+ "HEAD",
25134
+ "--",
25135
+ this.sdkOutputDir
25136
+ ]);
25137
+ if (!log.trim()) {
25138
+ return { patches: [], revertedPatchIds: [] };
25139
+ }
25140
+ const commits = this.parseGitLog(log);
25141
+ const newPatches = [];
25142
+ const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
25143
+ const existingHashes = new Set(lock.patches.map((p) => p.content_hash));
25144
+ const existingCommits = new Set(lock.patches.map((p) => p.original_commit));
25145
+ for (const commit of commits) {
25146
+ if (isGenerationCommit(commit)) {
25011
25147
  continue;
25012
25148
  }
25013
- if (inTargetFile) {
25014
- diffLines.push(line);
25149
+ const parents = await this.git.getCommitParents(commit.sha);
25150
+ if (parents.length > 1) {
25151
+ continue;
25015
25152
  }
25016
- }
25017
- return diffLines.length > 0 ? diffLines.join("\n") + "\n" : null;
25018
- }
25019
- extractRenameSource(patchContent, targetFilePath) {
25020
- const lines = patchContent.split("\n");
25021
- let inTargetFile = false;
25022
- for (const line of lines) {
25023
- if (line.startsWith("diff --git")) {
25024
- if (inTargetFile) break;
25025
- inTargetFile = isDiffLineForFile(line, targetFilePath);
25153
+ if (existingCommits.has(commit.sha)) {
25026
25154
  continue;
25027
25155
  }
25028
- if (!inTargetFile) continue;
25029
- if (line.startsWith("@@")) break;
25030
- if (line.startsWith("rename from ")) {
25031
- return line.slice("rename from ".length);
25156
+ let patchContent;
25157
+ try {
25158
+ patchContent = await this.git.formatPatch(commit.sha);
25159
+ } catch {
25160
+ continue;
25032
25161
  }
25033
- }
25034
- return null;
25035
- }
25036
- extractNewFileFromPatch(patchContent, filePath) {
25037
- const lines = patchContent.split("\n");
25038
- const addedLines = [];
25039
- let inTargetFile = false;
25040
- let inHunk = false;
25041
- let isNewFile = false;
25042
- let noTrailingNewline = false;
25043
- for (const line of lines) {
25044
- if (line.startsWith("diff --git")) {
25045
- if (inTargetFile) break;
25046
- inTargetFile = isDiffLineForFile(line, filePath);
25047
- inHunk = false;
25048
- isNewFile = false;
25162
+ let contentHash = this.computeContentHash(patchContent);
25163
+ if (existingHashes.has(contentHash) || forgottenHashes.has(contentHash)) {
25049
25164
  continue;
25050
25165
  }
25051
- if (!inTargetFile) continue;
25052
- if (!inHunk) {
25053
- if (line === "--- /dev/null" || line.startsWith("new file mode")) {
25054
- isNewFile = true;
25166
+ if (this.isCreationOnlyPatch(patchContent)) {
25167
+ continue;
25168
+ }
25169
+ const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
25170
+ const allScanFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f2) => !INFRASTRUCTURE_FILES.has(f2));
25171
+ const sensitiveScanFiles = allScanFiles.filter((f2) => isSensitiveFile(f2));
25172
+ const files = allScanFiles.filter((f2) => !isSensitiveFile(f2));
25173
+ if (sensitiveScanFiles.length > 0) {
25174
+ this.warnings.push(
25175
+ `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveScanFiles.join(", ")}. These files will not be tracked by replay.`
25176
+ );
25177
+ if (files.length > 0) {
25178
+ if (parents.length === 0) {
25179
+ continue;
25180
+ }
25181
+ try {
25182
+ patchContent = await this.git.exec(["diff", parents[0], commit.sha, "--", ...files]);
25183
+ } catch {
25184
+ continue;
25185
+ }
25186
+ if (!patchContent.trim()) continue;
25187
+ contentHash = this.computeContentHash(patchContent);
25055
25188
  }
25056
25189
  }
25057
- if (line.startsWith("@@")) {
25058
- if (!isNewFile) return null;
25059
- inHunk = true;
25190
+ if (files.length === 0) {
25060
25191
  continue;
25061
25192
  }
25062
- if (!inHunk) continue;
25063
- if (line === "\") {
25064
- noTrailingNewline = true;
25193
+ if (parents.length === 0) {
25065
25194
  continue;
25066
25195
  }
25067
- if (line.startsWith("+") && !line.startsWith("+++")) {
25068
- addedLines.push(line.slice(1));
25196
+ const parentSha = parents[0];
25197
+ const theirsSnapshot = {};
25198
+ for (const file of files) {
25199
+ if (isBinaryFile(file)) continue;
25200
+ const content = await this.git.showFile(commit.sha, file).catch(() => null);
25201
+ if (content != null) theirsSnapshot[file] = content;
25069
25202
  }
25203
+ newPatches.push({
25204
+ id: `patch-${commit.sha.slice(0, 8)}`,
25205
+ content_hash: contentHash,
25206
+ original_commit: commit.sha,
25207
+ original_message: commit.message,
25208
+ original_author: `${commit.authorName} <${commit.authorEmail}>`,
25209
+ base_generation: parentSha,
25210
+ files,
25211
+ patch_content: patchContent,
25212
+ ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {}
25213
+ });
25070
25214
  }
25071
- if (addedLines.length === 0) return null;
25072
- return addedLines.join("\n") + (noTrailingNewline ? "" : "\n");
25215
+ newPatches.reverse();
25216
+ return { patches: newPatches, revertedPatchIds: [] };
25217
+ }
25218
+ /**
25219
+ * Check if a format-patch consists entirely of new-file creations.
25220
+ * Used to identify squashed commits after force push, which create all files
25221
+ * from scratch (--- /dev/null) rather than modifying existing files.
25222
+ */
25223
+ isCreationOnlyPatch(patchContent) {
25224
+ const diffOldHeaders = patchContent.split("\n").filter((l) => l.startsWith("--- "));
25225
+ if (diffOldHeaders.length === 0) {
25226
+ return false;
25227
+ }
25228
+ return diffOldHeaders.every((l) => l === "--- /dev/null");
25229
+ }
25230
+ /**
25231
+ * Resolve the best available diff base for a generation record.
25232
+ * Prefers commit_sha, falls back to tree_hash for unreachable commits.
25233
+ * When commitKnownMissing is true, skips the redundant commitExists check.
25234
+ */
25235
+ async resolveDiffBase(gen, commitKnownMissing) {
25236
+ if (!commitKnownMissing && await this.git.commitExists(gen.commit_sha)) {
25237
+ return gen.commit_sha;
25238
+ }
25239
+ if (await this.git.treeExists(gen.tree_hash)) {
25240
+ return gen.tree_hash;
25241
+ }
25242
+ return null;
25243
+ }
25244
+ parseGitLog(log) {
25245
+ return log.trim().split("\n").map((line) => {
25246
+ const [sha, authorName, authorEmail, message] = line.split("\0");
25247
+ return { sha, authorName, authorEmail, message };
25248
+ });
25249
+ }
25250
+ getLastGeneration(lock) {
25251
+ return lock.generations.find((g2) => g2.commit_sha === lock.current_generation);
25073
25252
  }
25074
25253
  };
25075
25254
  ReplayCommitter = class {
@@ -25653,19 +25832,14 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
25653
25832
  existingPatches = this.lockManager.getPatches();
25654
25833
  const seenHashes = /* @__PURE__ */ new Map();
25655
25834
  for (const p of existingPatches) {
25656
- const priorOrigin = seenHashes.get(p.content_hash);
25657
- if (priorOrigin !== void 0) {
25658
- const provenanceMissing = !priorOrigin || !p.original_commit;
25659
- const sameOrigin = priorOrigin === p.original_commit;
25660
- if (sameOrigin && !provenanceMissing) {
25661
- this.lockManager.removePatch(p.id);
25662
- } else {
25663
- this.warnings.push(
25664
- `Preserving patch ${p.id} (commit ${(p.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).`
25665
- );
25666
- }
25835
+ const priorPatchId = seenHashes.get(p.content_hash);
25836
+ if (priorPatchId !== void 0) {
25837
+ this.lockManager.removePatch(p.id);
25838
+ this.warnings.push(
25839
+ `Consolidated patch ${p.id} (commit ${(p.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.`
25840
+ );
25667
25841
  } else {
25668
- seenHashes.set(p.content_hash, p.original_commit);
25842
+ seenHashes.set(p.content_hash, p.id);
25669
25843
  }
25670
25844
  }
25671
25845
  existingPatches = this.lockManager.getPatches();
@@ -25846,17 +26020,27 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
25846
26020
  if (allUserOwned && patch.files.length > 0) {
25847
26021
  const baseChanged = patch.base_generation !== currentGenSha;
25848
26022
  const userDiff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
26023
+ let userDiffSnapshot = null;
25849
26024
  if (userDiff != null && userDiff.trim()) {
25850
26025
  const newHash = this.detector.computeContentHash(userDiff);
25851
26026
  patch.patch_content = userDiff;
25852
26027
  patch.content_hash = newHash;
26028
+ const snap = await this.readSnapshotFromDisk(patch.files);
26029
+ if (Object.keys(snap).length > 0) {
26030
+ userDiffSnapshot = snap;
26031
+ patch.theirs_snapshot = snap;
26032
+ }
25853
26033
  }
25854
26034
  patch.base_generation = currentGenSha;
25855
26035
  try {
25856
26036
  this.lockManager.updatePatch(patch.id, {
25857
26037
  base_generation: currentGenSha,
25858
26038
  ...!patch.user_owned ? { user_owned: true } : {},
25859
- ...userDiff != null && userDiff.trim() ? { patch_content: patch.patch_content, content_hash: patch.content_hash } : {}
26039
+ ...userDiff != null && userDiff.trim() ? {
26040
+ patch_content: patch.patch_content,
26041
+ content_hash: patch.content_hash,
26042
+ ...userDiffSnapshot != null && Object.keys(userDiffSnapshot).length > 0 ? { theirs_snapshot: userDiffSnapshot } : {}
26043
+ } : {}
25860
26044
  });
25861
26045
  } catch {
25862
26046
  }
@@ -25904,30 +26088,34 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
25904
26088
  continue;
25905
26089
  }
25906
26090
  const newContentHash = this.detector.computeContentHash(diff);
25907
- const priorOrigin = seenContentHashes.get(newContentHash);
25908
- if (priorOrigin !== void 0) {
25909
- const provenanceMissing = !priorOrigin || !patch.original_commit;
25910
- const sameOrigin = priorOrigin === patch.original_commit;
25911
- if (sameOrigin && !provenanceMissing) {
25912
- this.lockManager.removePatch(patch.id);
25913
- absorbedPatchIds.add(patch.id);
25914
- absorbed++;
25915
- continue;
26091
+ const priorPatchId = seenContentHashes.get(newContentHash);
26092
+ if (priorPatchId !== void 0) {
26093
+ this.lockManager.removePatch(patch.id);
26094
+ absorbedPatchIds.add(patch.id);
26095
+ absorbed++;
26096
+ if (priorPatchId !== patch.id) {
26097
+ this.warnings.push(
26098
+ `Consolidated patch ${patch.id} (commit ${(patch.original_commit || "<missing>").slice(0, 7)}) into prior patch ${priorPatchId}: byte-identical patch_content after rebase. Per-commit attribution preserved in git log.`
26099
+ );
25916
26100
  }
25917
- this.warnings.push(
25918
- `Preserving patch ${patch.id} (commit ${(patch.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).`
25919
- );
26101
+ continue;
25920
26102
  } else {
25921
- seenContentHashes.set(newContentHash, patch.original_commit);
26103
+ seenContentHashes.set(newContentHash, patch.id);
25922
26104
  }
25923
26105
  patch.base_generation = currentGenSha;
25924
26106
  patch.patch_content = diff;
25925
26107
  patch.content_hash = newContentHash;
26108
+ const snapshot = await this.readSnapshotFromDisk(patch.files);
26109
+ const snapshotIsNonEmpty = Object.keys(snapshot).length > 0;
26110
+ if (snapshotIsNonEmpty) {
26111
+ patch.theirs_snapshot = snapshot;
26112
+ }
25926
26113
  try {
25927
26114
  this.lockManager.updatePatch(patch.id, {
25928
26115
  base_generation: currentGenSha,
25929
26116
  patch_content: diff,
25930
- content_hash: newContentHash
26117
+ content_hash: newContentHash,
26118
+ ...snapshotIsNonEmpty ? { theirs_snapshot: snapshot } : {}
25931
26119
  });
25932
26120
  } catch {
25933
26121
  }
@@ -25937,6 +26125,112 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
25937
26125
  }
25938
26126
  return { absorbed, repointed, contentRebased, keptAsUserOwned, absorbedPatchIds };
25939
26127
  }
26128
+ /**
26129
+ * Read THEIRS snapshot (post-customer-edit content) for a list of files
26130
+ * from the working tree on disk. Used by `rebasePatches` after a clean
26131
+ * apply — disk = correctly-merged customer state at the new generation.
26132
+ * Skips binary files (matches `mergeFile`/`populateAccumulatorForPatch`
26133
+ * policy — binary content as `utf-8` is lossy and snapshots of it would
26134
+ * never be used during apply anyway).
26135
+ */
26136
+ async readSnapshotFromDisk(files) {
26137
+ const snapshot = {};
26138
+ for (const file of files) {
26139
+ if (isBinaryFile(file)) continue;
26140
+ try {
26141
+ const content = await (0, import_promises5.readFile)((0, import_path10.join)(this.outputDir, file), "utf-8");
26142
+ snapshot[file] = content;
26143
+ } catch {
26144
+ }
26145
+ }
26146
+ return snapshot;
26147
+ }
26148
+ /**
26149
+ * Read THEIRS snapshot from a git tree-ish (commit, branch, or tree).
26150
+ * Used when the diff that produced `patch_content` was relative to that
26151
+ * tree (e.g., `git diff currentGen HEAD --` → snapshot from HEAD).
26152
+ * Skips binary files.
26153
+ */
26154
+ async readSnapshotFromTree(treeRef, files) {
26155
+ const snapshot = {};
26156
+ for (const file of files) {
26157
+ if (isBinaryFile(file)) continue;
26158
+ const content = await this.git.showFile(treeRef, file).catch(() => null);
26159
+ if (content != null) snapshot[file] = content;
26160
+ }
26161
+ return snapshot;
26162
+ }
26163
+ /**
26164
+ * One-shot auto-migration: backfill `theirs_snapshot` on existing
26165
+ * patches that predate the snapshot encoding. Computes the snapshot by
26166
+ * applying the patch's `patch_content` to its `base_generation` tree —
26167
+ * yielding the patch's INDIVIDUAL contribution.
26168
+ *
26169
+ * **Skips patches sharing files with other patches.** HEAD's content
26170
+ * for a file shared by N patches is cumulative across all of them, so
26171
+ * a HEAD-fallback would falsely encode other patches' contributions
26172
+ * into one patch's snapshot — breaking FER-9525 cross-patch isolation
26173
+ * if the snapshot fallback later fires. Per-patch snapshots only
26174
+ * make sense when the patch owns its file. Multi-patch-same-file
26175
+ * legacy lockfiles keep using line-anchored reconstruction (the
26176
+ * existing path); they're no worse than pre-upgrade.
26177
+ *
26178
+ * **Does not fall back to HEAD when BASE-reconstruction fails.** Same
26179
+ * reason — HEAD content can be cumulative. Patches whose
26180
+ * reconstruction fails just don't get a snapshot; legacy paths
26181
+ * (PR #73's by-association gate, accumulator fallback) handle them.
26182
+ *
26183
+ * Skips patches that already have a snapshot.
26184
+ */
26185
+ async migratePatchesToSnapshot(patches) {
26186
+ const { applyPatchToContent: applyPatchToContent2 } = await Promise.resolve().then(() => (init_PatchApplyTheirs(), PatchApplyTheirs_exports));
26187
+ const fileFreq = /* @__PURE__ */ new Map();
26188
+ for (const p of patches) {
26189
+ for (const f2 of p.files) {
26190
+ fileFreq.set(f2, (fileFreq.get(f2) ?? 0) + 1);
26191
+ }
26192
+ }
26193
+ let migrated = 0;
26194
+ let skipped = 0;
26195
+ for (const patch of patches) {
26196
+ if (patch.theirs_snapshot != null) continue;
26197
+ const sharesAnyFile = patch.files.some((f2) => (fileFreq.get(f2) ?? 0) > 1);
26198
+ if (sharesAnyFile) {
26199
+ skipped++;
26200
+ continue;
26201
+ }
26202
+ const snapshot = {};
26203
+ for (const file of patch.files) {
26204
+ if (isBinaryFile(file)) continue;
26205
+ try {
26206
+ const base = await this.git.showFile(patch.base_generation, file).catch(() => null);
26207
+ if (base != null) {
26208
+ const content = await applyPatchToContent2(base, patch.patch_content, file);
26209
+ if (content != null) snapshot[file] = content;
26210
+ }
26211
+ } catch {
26212
+ }
26213
+ }
26214
+ if (Object.keys(snapshot).length > 0) {
26215
+ patch.theirs_snapshot = snapshot;
26216
+ try {
26217
+ this.lockManager.updatePatch(patch.id, { theirs_snapshot: snapshot });
26218
+ migrated++;
26219
+ } catch {
26220
+ }
26221
+ }
26222
+ }
26223
+ if (migrated > 0) {
26224
+ this.warnings.push(
26225
+ `Migrated ${migrated} patch(es) to snapshot encoding. Future regens use 3-way merge with stored THEIRS, robust to generator structural changes.`
26226
+ );
26227
+ }
26228
+ if (skipped > 0) {
26229
+ this.warnings.push(
26230
+ `Skipped snapshot migration for ${skipped} patch(es) sharing files with other patches. These continue to use line-anchored reconstruction (the existing path).`
26231
+ );
26232
+ }
26233
+ }
25940
26234
  /**
25941
26235
  * Determine whether `file` is user-owned (never produced by the generator).
25942
26236
  *
@@ -26057,6 +26351,20 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
26057
26351
  this.lockManager.clearReplaySkippedAt();
26058
26352
  return { conflictResolved: 0, conflictAbsorbed: 0, contentRefreshed: 0 };
26059
26353
  }
26354
+ await this.migratePatchesToSnapshot(patches);
26355
+ const headMessage = await this.git.exec(["log", "-1", "--format=%s%n%aN%n%ae", "HEAD"]).catch(() => "");
26356
+ const [headMsgLine, headAuthorName, headAuthorEmail] = headMessage.split("\n");
26357
+ const headIsGeneration = headMsgLine ? isGenerationCommit({
26358
+ sha: "HEAD",
26359
+ authorName: headAuthorName ?? "",
26360
+ authorEmail: headAuthorEmail ?? "",
26361
+ message: headMsgLine
26362
+ }) : false;
26363
+ const headParentChangedFiles = async (patchFiles) => {
26364
+ if (patchFiles.length === 0) return /* @__PURE__ */ new Set();
26365
+ const diff = await this.git.exec(["diff", "--name-only", "HEAD~1", "HEAD", "--", ...patchFiles]).catch(() => "");
26366
+ return new Set(diff.trim().split("\n").filter(Boolean));
26367
+ };
26060
26368
  let conflictResolved = 0;
26061
26369
  let conflictAbsorbed = 0;
26062
26370
  let contentRefreshed = 0;
@@ -26093,6 +26401,13 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
26093
26401
  delete patch.status;
26094
26402
  continue;
26095
26403
  }
26404
+ if (headIsGeneration) {
26405
+ const parentChangedFiles = await headParentChangedFiles(patch.files);
26406
+ const headTouchedSource = patch.files.some((f2) => parentChangedFiles.has(f2));
26407
+ if (headTouchedSource) {
26408
+ continue;
26409
+ }
26410
+ }
26096
26411
  if (patch.base_generation === currentGen) {
26097
26412
  try {
26098
26413
  const ppResult = await computePerPatchDiffWithFallback(
@@ -26129,10 +26444,12 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
26129
26444
  if (newFiles.length === 0) {
26130
26445
  this.lockManager.removePatch(patch.id);
26131
26446
  } else {
26447
+ const snapshot = await this.readSnapshotFromTree("HEAD", newFiles);
26132
26448
  this.lockManager.updatePatch(patch.id, {
26133
26449
  patch_content: diff,
26134
26450
  content_hash: newContentHash,
26135
- files: newFiles
26451
+ files: newFiles,
26452
+ ...Object.keys(snapshot).length > 0 ? { theirs_snapshot: snapshot } : {}
26136
26453
  });
26137
26454
  }
26138
26455
  contentRefreshed++;
@@ -26165,10 +26482,12 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
26165
26482
  continue;
26166
26483
  }
26167
26484
  const newContentHash = this.detector.computeContentHash(diff);
26485
+ const snapshot = await this.readSnapshotFromTree("HEAD", patch.files);
26168
26486
  this.lockManager.updatePatch(patch.id, {
26169
26487
  base_generation: currentGen,
26170
26488
  patch_content: diff,
26171
- content_hash: newContentHash
26489
+ content_hash: newContentHash,
26490
+ ...Object.keys(snapshot).length > 0 ? { theirs_snapshot: snapshot } : {}
26172
26491
  });
26173
26492
  conflictResolved++;
26174
26493
  } catch {
@@ -26211,7 +26530,7 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
26211
26530
  if (result.status !== "conflict" || !result.fileResults) continue;
26212
26531
  for (const fileResult of result.fileResults) {
26213
26532
  if (fileResult.status !== "conflict") continue;
26214
- const filePath = (0, import_path9.join)(this.outputDir, fileResult.file);
26533
+ const filePath = (0, import_path10.join)(this.outputDir, fileResult.file);
26215
26534
  try {
26216
26535
  const content = (0, import_fs7.readFileSync)(filePath, "utf-8");
26217
26536
  const stripped = stripConflictMarkers(content);
@@ -26241,7 +26560,7 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
26241
26560
  }
26242
26561
  }
26243
26562
  readFernignorePatterns() {
26244
- const fernignorePath = (0, import_path9.join)(this.outputDir, ".fernignore");
26563
+ const fernignorePath = (0, import_path10.join)(this.outputDir, ".fernignore");
26245
26564
  if (!(0, import_fs7.existsSync)(fernignorePath)) return [];
26246
26565
  return (0, import_fs7.readFileSync)(fernignorePath, "utf-8").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
26247
26566
  }
@@ -26299,10 +26618,10 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
26299
26618
  this.outputDir = outputDir;
26300
26619
  }
26301
26620
  fernignoreExists() {
26302
- return (0, import_fs8.existsSync)((0, import_path11.join)(this.outputDir, ".fernignore"));
26621
+ return (0, import_fs8.existsSync)((0, import_path12.join)(this.outputDir, ".fernignore"));
26303
26622
  }
26304
26623
  readFernignorePatterns() {
26305
- const fernignorePath = (0, import_path11.join)(this.outputDir, ".fernignore");
26624
+ const fernignorePath = (0, import_path12.join)(this.outputDir, ".fernignore");
26306
26625
  if (!(0, import_fs8.existsSync)(fernignorePath)) {
26307
26626
  return [];
26308
26627
  }
@@ -26354,6 +26673,12 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
26354
26673
  if (patchFiles.length > 0) {
26355
26674
  const patchContent = diffParts.join("\n");
26356
26675
  const contentHash = `sha256:${(0, import_crypto2.createHash)("sha256").update(patchContent).digest("hex")}`;
26676
+ const theirsSnapshot = {};
26677
+ for (const filePath of patchFiles) {
26678
+ if (isBinaryFile(filePath)) continue;
26679
+ const c2 = this.readFileContent(filePath);
26680
+ if (c2 != null) theirsSnapshot[filePath] = c2;
26681
+ }
26357
26682
  syntheticPatches.push({
26358
26683
  id: `patch-fernignore-${(0, import_crypto2.createHash)("sha256").update(pattern).digest("hex").slice(0, 8)}`,
26359
26684
  content_hash: contentHash,
@@ -26363,6 +26688,7 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
26363
26688
  base_generation: currentGen.commit_sha,
26364
26689
  files: patchFiles,
26365
26690
  patch_content: patchContent,
26691
+ ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
26366
26692
  // Synthetic patches captured from .fernignore-protected files
26367
26693
  // are user-owned by definition — the customer's content
26368
26694
  // diverges from the generator's pristine output, and the
@@ -26404,7 +26730,7 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
26404
26730
  return results;
26405
26731
  }
26406
26732
  readFileContent(filePath) {
26407
- const fullPath = (0, import_path11.join)(this.outputDir, filePath);
26733
+ const fullPath = (0, import_path12.join)(this.outputDir, filePath);
26408
26734
  if (!(0, import_fs8.existsSync)(fullPath)) {
26409
26735
  return null;
26410
26736
  }
@@ -26469,7 +26795,7 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
26469
26795
  };
26470
26796
  }
26471
26797
  movePatternsToReplayYml(patterns) {
26472
- const replayYmlPath = (0, import_path11.join)(this.outputDir, ".fern", "replay.yml");
26798
+ const replayYmlPath = (0, import_path12.join)(this.outputDir, ".fern", "replay.yml");
26473
26799
  let config = {};
26474
26800
  if ((0, import_fs8.existsSync)(replayYmlPath)) {
26475
26801
  const content = (0, import_fs8.readFileSync)(replayYmlPath, "utf-8");
@@ -26478,7 +26804,7 @@ If you want to keep these lines, restore them in a follow-up commit and re-run r
26478
26804
  const existing = config.exclude ?? [];
26479
26805
  const merged = [.../* @__PURE__ */ new Set([...existing, ...patterns])];
26480
26806
  config.exclude = merged;
26481
- const dir = (0, import_path11.dirname)(replayYmlPath);
26807
+ const dir = (0, import_path12.dirname)(replayYmlPath);
26482
26808
  if (!(0, import_fs8.existsSync)(dir)) {
26483
26809
  (0, import_fs8.mkdirSync)(dir, { recursive: true });
26484
26810
  }
@@ -29034,8 +29360,8 @@ function resolve(...paths) {
29034
29360
 
29035
29361
  // src/cli.ts
29036
29362
  var import_fs15 = __toESM(require("fs"));
29037
- var import_promises13 = require("fs/promises");
29038
- var import_path20 = __toESM(require("path"));
29363
+ var import_promises15 = require("fs/promises");
29364
+ var import_path21 = __toESM(require("path"));
29039
29365
 
29040
29366
  // ../../node_modules/.pnpm/yargs@17.7.2/node_modules/yargs/build/lib/yerror.js
29041
29367
  var YError = class _YError extends Error {
@@ -38736,42 +39062,42 @@ function replayForget(params) {
38736
39062
  // src/replay/replay-init.ts
38737
39063
  init_dist3();
38738
39064
  var import_fs11 = require("fs");
38739
- var import_path15 = require("path");
39065
+ var import_path16 = require("path");
38740
39066
  var import_tmp_promise2 = __toESM(require_tmp_promise());
38741
39067
 
38742
39068
  // src/replay/fernignore.ts
38743
- var import_promises6 = require("fs/promises");
38744
- var import_path14 = require("path");
39069
+ var import_promises8 = require("fs/promises");
39070
+ var import_path15 = require("path");
38745
39071
  var REPLAY_FERNIGNORE_ENTRIES2 = [".fern/replay.lock", ".fern/replay.yml", ".gitattributes"];
38746
39072
  var GITATTRIBUTES_ENTRIES2 = [".fern/replay.lock linguist-generated=true"];
38747
39073
  async function ensureReplayFernignoreEntries(outputDir) {
38748
- const fernignorePath = (0, import_path14.join)(outputDir, ".fernignore");
39074
+ const fernignorePath = (0, import_path15.join)(outputDir, ".fernignore");
38749
39075
  let content = "";
38750
39076
  try {
38751
- content = await (0, import_promises6.readFile)(fernignorePath, "utf-8");
39077
+ content = await (0, import_promises8.readFile)(fernignorePath, "utf-8");
38752
39078
  } catch {
38753
39079
  }
38754
39080
  const lines = content.split("\n");
38755
39081
  const toAdd = REPLAY_FERNIGNORE_ENTRIES2.filter((entry) => !lines.some((line) => line.trim() === entry));
38756
39082
  if (toAdd.length > 0) {
38757
39083
  const suffix = content.length > 0 && !content.endsWith("\n") ? "\n" : "";
38758
- await (0, import_promises6.writeFile)(fernignorePath, content + suffix + toAdd.join("\n") + "\n", "utf-8");
39084
+ await (0, import_promises8.writeFile)(fernignorePath, content + suffix + toAdd.join("\n") + "\n", "utf-8");
38759
39085
  return true;
38760
39086
  }
38761
39087
  return false;
38762
39088
  }
38763
39089
  async function ensureGitattributesEntries2(outputDir) {
38764
- const gitattributesPath = (0, import_path14.join)(outputDir, ".gitattributes");
39090
+ const gitattributesPath = (0, import_path15.join)(outputDir, ".gitattributes");
38765
39091
  let content = "";
38766
39092
  try {
38767
- content = await (0, import_promises6.readFile)(gitattributesPath, "utf-8");
39093
+ content = await (0, import_promises8.readFile)(gitattributesPath, "utf-8");
38768
39094
  } catch {
38769
39095
  }
38770
39096
  const lines = content.split("\n");
38771
39097
  const toAdd = GITATTRIBUTES_ENTRIES2.filter((entry) => !lines.some((line) => line.trim() === entry));
38772
39098
  if (toAdd.length > 0) {
38773
39099
  const suffix = content.length > 0 && !content.endsWith("\n") ? "\n" : "";
38774
- await (0, import_promises6.writeFile)(gitattributesPath, content + suffix + toAdd.join("\n") + "\n", "utf-8");
39100
+ await (0, import_promises8.writeFile)(gitattributesPath, content + suffix + toAdd.join("\n") + "\n", "utf-8");
38775
39101
  }
38776
39102
  }
38777
39103
 
@@ -38798,7 +39124,7 @@ async function replayInit(params) {
38798
39124
  if (dryRun) {
38799
39125
  return { bootstrap: bootstrapResult, fernignoreEntries: [], gitattributesEntries: [] };
38800
39126
  }
38801
- const lockfilePath = (0, import_path15.join)(repoPath, ".fern", "replay.lock");
39127
+ const lockfilePath = (0, import_path16.join)(repoPath, ".fern", "replay.lock");
38802
39128
  const lockfileContent = (0, import_fs11.existsSync)(lockfilePath) ? (0, import_fs11.readFileSync)(lockfilePath, "utf-8") : void 0;
38803
39129
  return {
38804
39130
  bootstrap: bootstrapResult,
@@ -38921,7 +39247,7 @@ init_dist3();
38921
39247
  init_dist3();
38922
39248
  var import_child_process4 = require("child_process");
38923
39249
  var import_fs12 = require("fs");
38924
- var import_path16 = require("path");
39250
+ var import_path17 = require("path");
38925
39251
  async function replayPrepare(params) {
38926
39252
  const {
38927
39253
  outputDir,
@@ -38932,7 +39258,7 @@ async function replayPrepare(params) {
38932
39258
  skipApplication,
38933
39259
  logger
38934
39260
  } = params;
38935
- const lockfilePath = (0, import_path16.join)(outputDir, ".fern", "replay.lock");
39261
+ const lockfilePath = (0, import_path17.join)(outputDir, ".fern", "replay.lock");
38936
39262
  if (!(0, import_fs12.existsSync)(lockfilePath)) {
38937
39263
  return null;
38938
39264
  }
@@ -39117,29 +39443,29 @@ function replayStatus(params) {
39117
39443
  }
39118
39444
 
39119
39445
  // src/configuration/loadGitHubConfig.ts
39120
- var import_promises7 = require("fs/promises");
39446
+ var import_promises9 = require("fs/promises");
39121
39447
  async function loadGitHubConfig({
39122
39448
  absolutePathToConfig
39123
39449
  }) {
39124
- const rawContents = await (0, import_promises7.readFile)(absolutePathToConfig, "utf8");
39450
+ const rawContents = await (0, import_promises9.readFile)(absolutePathToConfig, "utf8");
39125
39451
  return JSON.parse(rawContents);
39126
39452
  }
39127
39453
 
39128
39454
  // src/configuration/loadReadmeConfig.ts
39129
- var import_promises8 = require("fs/promises");
39455
+ var import_promises10 = require("fs/promises");
39130
39456
  async function loadReadmeConfig({
39131
39457
  absolutePathToConfig
39132
39458
  }) {
39133
- const rawContents = await (0, import_promises8.readFile)(absolutePathToConfig, "utf8");
39459
+ const rawContents = await (0, import_promises10.readFile)(absolutePathToConfig, "utf8");
39134
39460
  return JSON.parse(rawContents);
39135
39461
  }
39136
39462
 
39137
39463
  // src/configuration/loadReferenceConfig.ts
39138
- var import_promises9 = require("fs/promises");
39464
+ var import_promises11 = require("fs/promises");
39139
39465
  async function loadReferenceConfig({
39140
39466
  absolutePathToConfig
39141
39467
  }) {
39142
- const rawContents = await (0, import_promises9.readFile)(absolutePathToConfig, "utf8");
39468
+ const rawContents = await (0, import_promises11.readFile)(absolutePathToConfig, "utf8");
39143
39469
  return JSON.parse(rawContents);
39144
39470
  }
39145
39471
 
@@ -39166,8 +39492,8 @@ var FERN_BOT_LOGIN2 = "fern-api[bot]";
39166
39492
  // src/pipeline/steps/AutoVersionStep.ts
39167
39493
  var import_child_process5 = require("child_process");
39168
39494
  var import_fs14 = require("fs");
39169
- var import_promises11 = require("fs/promises");
39170
- var import_path18 = require("path");
39495
+ var import_promises13 = require("fs/promises");
39496
+ var import_path19 = require("path");
39171
39497
 
39172
39498
  // ../commons/logging-execa/lib/loggingExeca.js
39173
39499
  var import_execa = __toESM(require_execa(), 1);
@@ -39226,8 +39552,8 @@ async function loggingExeca(logger, executable, args = [], { doNotPipeOutput = f
39226
39552
 
39227
39553
  // src/autoversion/AutoVersioningService.ts
39228
39554
  var import_fs13 = require("fs");
39229
- var import_promises10 = require("fs/promises");
39230
- var import_path17 = require("path");
39555
+ var import_promises12 = require("fs/promises");
39556
+ var import_path18 = require("path");
39231
39557
  var import_semver = __toESM(require_semver2());
39232
39558
  var AutoVersioningException = class extends Error {
39233
39559
  /**
@@ -39914,7 +40240,7 @@ var AutoVersioningService = class _AutoVersioningService {
39914
40240
  );
39915
40241
  return;
39916
40242
  }
39917
- const goModPath = (0, import_path17.join)(workingDirectory, "go.mod");
40243
+ const goModPath = (0, import_path18.join)(workingDirectory, "go.mod");
39918
40244
  if (!(0, import_fs13.existsSync)(goModPath)) {
39919
40245
  this.logger.debug(`No go.mod found in ${workingDirectory}, skipping Go module path update`);
39920
40246
  return;
@@ -39948,7 +40274,7 @@ var AutoVersioningService = class _AutoVersioningService {
39948
40274
  * Reads the module path from a go.mod file.
39949
40275
  */
39950
40276
  async readGoModulePath(goModPath) {
39951
- const content = await (0, import_promises10.readFile)(goModPath, "utf-8");
40277
+ const content = await (0, import_promises12.readFile)(goModPath, "utf-8");
39952
40278
  for (const line of content.split("\n")) {
39953
40279
  const trimmed2 = line.trim();
39954
40280
  if (trimmed2.startsWith("module ")) {
@@ -39961,7 +40287,7 @@ var AutoVersioningService = class _AutoVersioningService {
39961
40287
  * Adds a version suffix to the module line in go.mod.
39962
40288
  */
39963
40289
  async addSuffixToGoMod(goModPath, modulePath, suffix) {
39964
- const content = await (0, import_promises10.readFile)(goModPath, "utf-8");
40290
+ const content = await (0, import_promises12.readFile)(goModPath, "utf-8");
39965
40291
  const lines = content.split("\n");
39966
40292
  let changed = false;
39967
40293
  for (let i = 0; i < lines.length; i++) {
@@ -39973,7 +40299,7 @@ var AutoVersioningService = class _AutoVersioningService {
39973
40299
  }
39974
40300
  }
39975
40301
  if (changed) {
39976
- await (0, import_promises10.writeFile)(goModPath, lines.join("\n"), "utf-8");
40302
+ await (0, import_promises12.writeFile)(goModPath, lines.join("\n"), "utf-8");
39977
40303
  this.logger.debug(`Updated go.mod: added ${suffix} to module path`);
39978
40304
  }
39979
40305
  }
@@ -39982,10 +40308,10 @@ var AutoVersioningService = class _AutoVersioningService {
39982
40308
  */
39983
40309
  async walkDirectory(dir, filter2) {
39984
40310
  const results = [];
39985
- const entries2 = await (0, import_promises10.readdir)(dir);
40311
+ const entries2 = await (0, import_promises12.readdir)(dir);
39986
40312
  for (const entry of entries2) {
39987
- const fullPath = (0, import_path17.join)(dir, entry);
39988
- const entryStat = await (0, import_promises10.stat)(fullPath);
40313
+ const fullPath = (0, import_path18.join)(dir, entry);
40314
+ const entryStat = await (0, import_promises12.stat)(fullPath);
39989
40315
  if (entryStat.isDirectory()) {
39990
40316
  if (entry === ".git" || entry === "vendor") {
39991
40317
  continue;
@@ -40002,7 +40328,7 @@ var AutoVersioningService = class _AutoVersioningService {
40002
40328
  * Adds a version suffix to import paths in all .go files in the repository.
40003
40329
  */
40004
40330
  async addSuffixToGoFiles(repoRoot, modulePath, suffix) {
40005
- const goFiles = await this.walkDirectory(repoRoot, (filePath) => (0, import_path17.extname)(filePath) === ".go");
40331
+ const goFiles = await this.walkDirectory(repoRoot, (filePath) => (0, import_path18.extname)(filePath) === ".go");
40006
40332
  for (const filePath of goFiles) {
40007
40333
  await this.addSuffixToGoFile(filePath, modulePath, suffix);
40008
40334
  }
@@ -40012,7 +40338,7 @@ var AutoVersioningService = class _AutoVersioningService {
40012
40338
  * Replaces occurrences of the module path with modulePath + suffix in import statements.
40013
40339
  */
40014
40340
  async addSuffixToGoFile(filePath, modulePath, suffix) {
40015
- const content = await (0, import_promises10.readFile)(filePath, "utf-8");
40341
+ const content = await (0, import_promises12.readFile)(filePath, "utf-8");
40016
40342
  const lines = content.split("\n");
40017
40343
  let changed = false;
40018
40344
  let inImportBlock = false;
@@ -40041,7 +40367,7 @@ var AutoVersioningService = class _AutoVersioningService {
40041
40367
  }
40042
40368
  }
40043
40369
  if (changed) {
40044
- await (0, import_promises10.writeFile)(filePath, lines.join("\n"), "utf-8");
40370
+ await (0, import_promises12.writeFile)(filePath, lines.join("\n"), "utf-8");
40045
40371
  this.logger.debug(`Updated Go imports in ${filePath}`);
40046
40372
  }
40047
40373
  }
@@ -40446,7 +40772,7 @@ ${body}` : `${COMMIT_MARKER} ${subject}`;
40446
40772
  }
40447
40773
  async prependChangelogEntry(params) {
40448
40774
  const { version: version2, entry } = params;
40449
- const changelogPath = (0, import_path18.join)(this.outputDir, "changelog.md");
40775
+ const changelogPath = (0, import_path19.join)(this.outputDir, "changelog.md");
40450
40776
  const now = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
40451
40777
  const header = `## [${version2}] - ${now}
40452
40778
  `;
@@ -40455,7 +40781,7 @@ ${body}` : `${COMMIT_MARKER} ${subject}`;
40455
40781
  `;
40456
40782
  let existing = "";
40457
40783
  if ((0, import_fs14.existsSync)(changelogPath)) {
40458
- existing = await (0, import_promises11.readFile)(changelogPath, "utf-8");
40784
+ existing = await (0, import_promises13.readFile)(changelogPath, "utf-8");
40459
40785
  }
40460
40786
  let output;
40461
40787
  if (existing.trim().length === 0) {
@@ -40472,7 +40798,7 @@ ${newBlock}${remainder}`;
40472
40798
  } else {
40473
40799
  output = `${newBlock}${existing}`;
40474
40800
  }
40475
- await (0, import_promises11.writeFile)(changelogPath, output, "utf-8");
40801
+ await (0, import_promises13.writeFile)(changelogPath, output, "utf-8");
40476
40802
  }
40477
40803
  brandMessage(message) {
40478
40804
  if (this.config.isWhitelabel) {
@@ -40654,8 +40980,8 @@ var GenerationCommitStep = class extends BaseStep {
40654
40980
  };
40655
40981
 
40656
40982
  // src/pipeline/steps/GithubStep.ts
40657
- var import_promises12 = require("fs/promises");
40658
- var import_path19 = require("path");
40983
+ var import_promises14 = require("fs/promises");
40984
+ var import_path20 = require("path");
40659
40985
 
40660
40986
  // src/pipeline/github/createReplayBranch.ts
40661
40987
  async function createReplayBranch(repository, branchName, commitMessage, replayConflictInfo, logger) {
@@ -41272,13 +41598,13 @@ var GithubStep = class extends BaseStep {
41272
41598
  }
41273
41599
  async ensureFernignore() {
41274
41600
  this.logger.debug("Checking for .fernignore file...");
41275
- const fernignorePath = (0, import_path19.join)(this.outputDir, ".fernignore");
41601
+ const fernignorePath = (0, import_path20.join)(this.outputDir, ".fernignore");
41276
41602
  try {
41277
- await (0, import_promises12.access)(fernignorePath);
41603
+ await (0, import_promises14.access)(fernignorePath);
41278
41604
  this.logger.debug(".fernignore already exists");
41279
41605
  } catch {
41280
41606
  this.logger.debug("Creating .fernignore file...");
41281
- await (0, import_promises12.writeFile)(fernignorePath, "# Specify files that shouldn't be modified by Fern\n", "utf-8");
41607
+ await (0, import_promises14.writeFile)(fernignorePath, "# Specify files that shouldn't be modified by Fern\n", "utf-8");
41282
41608
  }
41283
41609
  }
41284
41610
  deriveSkipCommit(replayResult) {
@@ -41571,7 +41897,7 @@ void yargs_default(hideBin(process.argv)).scriptName(process.env.CLI_NAME ?? "ge
41571
41897
  const readmeConfig = await loadReadmeConfig({
41572
41898
  absolutePathToConfig: resolve(wd, argv.config)
41573
41899
  });
41574
- const originalReadmeContent = argv.originalReadme != null ? await (0, import_promises13.readFile)(argv.originalReadme, "utf8") : void 0;
41900
+ const originalReadmeContent = argv.originalReadme != null ? await (0, import_promises15.readFile)(argv.originalReadme, "utf8") : void 0;
41575
41901
  await generateReadmeToStream({
41576
41902
  originalReadmeContent,
41577
41903
  readmeConfig,
@@ -41699,7 +42025,7 @@ void yargs_default(hideBin(process.argv)).scriptName(process.env.CLI_NAME ?? "ge
41699
42025
  try {
41700
42026
  const wd = cwd();
41701
42027
  const configValue = argv.config;
41702
- const configContent = configValue === STDIN_MARKER ? await readStdin() : configValue.trimStart().startsWith("{") ? configValue : await (0, import_promises13.readFile)(resolve(wd, configValue), "utf-8");
42028
+ const configContent = configValue === STDIN_MARKER ? await readStdin() : configValue.trimStart().startsWith("{") ? configValue : await (0, import_promises15.readFile)(resolve(wd, configValue), "utf-8");
41703
42029
  let config;
41704
42030
  try {
41705
42031
  config = JSON.parse(configContent);
@@ -41995,7 +42321,7 @@ async function createWriteStream(outputPath) {
41995
42321
  }
41996
42322
  async function createWriteStreamFromFile(filepath) {
41997
42323
  if (!await doesPathExist(filepath)) {
41998
- await (0, import_promises13.mkdir)(import_path20.default.dirname(filepath), { recursive: true });
42324
+ await (0, import_promises15.mkdir)(import_path21.default.dirname(filepath), { recursive: true });
41999
42325
  }
42000
42326
  return import_fs15.default.createWriteStream(filepath);
42001
42327
  }