@fern-api/generator-cli 0.9.20 → 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 +2142 -1212
  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 join12 = 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 = join12(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) {
@@ -18885,7 +18885,7 @@ var require_resolve_block_scalar = __commonJS({
18885
18885
  if (!header)
18886
18886
  return { value: "", type: null, comment: "", range: [start, start, start] };
18887
18887
  const type = header.mode === ">" ? Scalar.Scalar.BLOCK_FOLDED : Scalar.Scalar.BLOCK_LITERAL;
18888
- const lines = scalar.source ? splitLines(scalar.source) : [];
18888
+ const lines = scalar.source ? splitLines2(scalar.source) : [];
18889
18889
  let chompStart = lines.length;
18890
18890
  for (let i = lines.length - 1; i >= 0; --i) {
18891
18891
  const content = lines[i][1];
@@ -19043,7 +19043,7 @@ var require_resolve_block_scalar = __commonJS({
19043
19043
  }
19044
19044
  return { mode, indent, chomp, comment, length };
19045
19045
  }
19046
- function splitLines(source) {
19046
+ function splitLines2(source) {
19047
19047
  const split = source.split(/\n( *)/);
19048
19048
  const first2 = split[0];
19049
19049
  const m = first2.match(/^( *)/);
@@ -22028,7 +22028,7 @@ var init_diff3 = __esm({
22028
22028
  }
22029
22029
  });
22030
22030
 
22031
- // ../../node_modules/.pnpm/@fern-api+replay@0.13.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");
@@ -22401,11 +22480,43 @@ function threeWayMerge(base, ours, theirs) {
22401
22480
  }
22402
22481
  }
22403
22482
  }
22404
- return {
22483
+ const result = {
22405
22484
  content: outputLines.join("\n"),
22406
22485
  hasConflicts: conflicts2.length > 0,
22407
22486
  conflicts: conflicts2
22408
22487
  };
22488
+ if (conflicts2.length === 0) {
22489
+ const dropped = computeDroppedContextLines(baseLines, theirsLines, outputLines);
22490
+ if (dropped.length > 0) {
22491
+ result.droppedContextLines = dropped;
22492
+ }
22493
+ }
22494
+ return result;
22495
+ }
22496
+ function computeDroppedContextLines(baseLines, theirsLines, mergedLines) {
22497
+ const baseCounts = countLines(baseLines);
22498
+ const theirsCounts = countLines(theirsLines);
22499
+ const mergedCounts = countLines(mergedLines);
22500
+ const dropped = [];
22501
+ const seen = /* @__PURE__ */ new Set();
22502
+ for (const line of baseLines) {
22503
+ if (seen.has(line)) continue;
22504
+ const inBase = baseCounts.get(line) ?? 0;
22505
+ const inTheirs = theirsCounts.get(line) ?? 0;
22506
+ const inMerged = mergedCounts.get(line) ?? 0;
22507
+ if (inBase > 0 && inTheirs > 0 && inMerged < Math.min(inBase, inTheirs)) {
22508
+ dropped.push(line);
22509
+ seen.add(line);
22510
+ }
22511
+ }
22512
+ return dropped;
22513
+ }
22514
+ function countLines(lines) {
22515
+ const counts = /* @__PURE__ */ new Map();
22516
+ for (const line of lines) {
22517
+ counts.set(line, (counts.get(line) ?? 0) + 1);
22518
+ }
22519
+ return counts;
22409
22520
  }
22410
22521
  function tryResolveConflict(oursLines, baseLines, theirsLines) {
22411
22522
  if (baseLines.length === 0) {
@@ -22463,81 +22574,380 @@ function applyBothPatches(baseLines, oursPatches, theirsPatches) {
22463
22574
  }
22464
22575
  return result;
22465
22576
  }
22466
- function trimCR(line) {
22467
- return line.endsWith("\r") ? line.slice(0, -1) : line;
22577
+ function isBinaryFile(filePath) {
22578
+ const ext2 = (0, import_path9.extname)(filePath).toLowerCase();
22579
+ return BINARY_EXTENSIONS.has(ext2);
22468
22580
  }
22469
- function findConflictRanges(lines) {
22470
- const ranges = [];
22471
- let i = 0;
22472
- while (i < lines.length) {
22473
- if (trimCR(lines[i]) === CONFLICT_OPENER) {
22474
- let separatorIdx = -1;
22475
- let j2 = i + 1;
22476
- let found = false;
22477
- while (j2 < lines.length) {
22478
- const trimmed2 = trimCR(lines[j2]);
22479
- if (trimmed2 === CONFLICT_OPENER) {
22480
- break;
22481
- }
22482
- if (separatorIdx === -1 && trimmed2 === CONFLICT_SEPARATOR) {
22483
- separatorIdx = j2;
22484
- } else if (separatorIdx !== -1 && trimmed2 === CONFLICT_CLOSER) {
22485
- ranges.push({ start: i, separator: separatorIdx, end: j2 });
22486
- i = j2;
22487
- found = true;
22488
- break;
22489
- }
22490
- j2++;
22581
+ function computeDroppedContextLinesForFile(base, theirs, finalContent) {
22582
+ const baseLines = base.split("\n");
22583
+ const theirsLines = theirs.split("\n");
22584
+ const finalLines = finalContent.split("\n");
22585
+ const baseCounts = /* @__PURE__ */ new Map();
22586
+ const theirsCounts = /* @__PURE__ */ new Map();
22587
+ const finalCounts = /* @__PURE__ */ new Map();
22588
+ for (const l of baseLines) baseCounts.set(l, (baseCounts.get(l) ?? 0) + 1);
22589
+ for (const l of theirsLines) theirsCounts.set(l, (theirsCounts.get(l) ?? 0) + 1);
22590
+ for (const l of finalLines) finalCounts.set(l, (finalCounts.get(l) ?? 0) + 1);
22591
+ const dropped = [];
22592
+ const seen = /* @__PURE__ */ new Set();
22593
+ for (const line of baseLines) {
22594
+ if (seen.has(line)) continue;
22595
+ const inBase = baseCounts.get(line) ?? 0;
22596
+ const inTheirs = theirsCounts.get(line) ?? 0;
22597
+ const inFinal = finalCounts.get(line) ?? 0;
22598
+ if (inBase > 0 && inTheirs > 0 && inFinal < Math.min(inBase, inTheirs)) {
22599
+ dropped.push(line);
22600
+ seen.add(line);
22601
+ }
22602
+ }
22603
+ return dropped;
22604
+ }
22605
+ function isDiffLineForFile(diffLine, filePath) {
22606
+ const match2 = diffLine.match(/^diff --git a\/.+ b\/(.+)$/);
22607
+ return match2 !== null && match2[1] === filePath;
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;
22491
22628
  }
22492
- if (!found) {
22493
- i++;
22494
- continue;
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;
22495
22634
  }
22496
22635
  }
22497
- i++;
22498
22636
  }
22499
- return ranges;
22637
+ flush2();
22638
+ return results;
22500
22639
  }
22501
- function stripConflictMarkers(content) {
22502
- const lines = content.split(/\r?\n/);
22503
- const ranges = findConflictRanges(lines);
22504
- if (ranges.length === 0) {
22505
- return content;
22640
+ function normalizeHunkBody(hunk) {
22641
+ let contextC = 0;
22642
+ let removeC = 0;
22643
+ let addC = 0;
22644
+ const trimmed2 = [];
22645
+ for (const line of hunk.lines) {
22646
+ const oldUsed = contextC + removeC;
22647
+ const newUsed = contextC + addC;
22648
+ if (oldUsed >= hunk.oldCount && newUsed >= hunk.newCount) break;
22649
+ trimmed2.push(line);
22650
+ if (line.type === "context") contextC++;
22651
+ else if (line.type === "remove") removeC++;
22652
+ else if (line.type === "add") addC++;
22506
22653
  }
22507
- const result = [];
22508
- let rangeIdx = 0;
22509
- for (let i = 0; i < lines.length; i++) {
22510
- if (rangeIdx < ranges.length) {
22511
- const range2 = ranges[rangeIdx];
22512
- if (i === range2.start) {
22513
- continue;
22654
+ return { ...hunk, lines: trimmed2 };
22655
+ }
22656
+ function extractFileDiffForFile(patchContent, filePath) {
22657
+ const lines = patchContent.split("\n");
22658
+ const out = [];
22659
+ let inTarget = false;
22660
+ for (const line of lines) {
22661
+ if (line.startsWith("diff --git")) {
22662
+ if (inTarget) break;
22663
+ if (isDiffLineForFile2(line, filePath)) {
22664
+ inTarget = true;
22665
+ out.push(line);
22514
22666
  }
22515
- if (i > range2.start && i < range2.separator) {
22516
- result.push(lines[i]);
22667
+ continue;
22668
+ }
22669
+ if (inTarget) out.push(line);
22670
+ }
22671
+ return out.length > 0 ? out.join("\n") + "\n" : null;
22672
+ }
22673
+ function isDiffLineForFile2(line, filePath) {
22674
+ const match2 = line.match(/^diff --git a\/(.+) b\/(.+)$/);
22675
+ return match2 !== null && (match2[2] === filePath || match2[1] === filePath);
22676
+ }
22677
+ async function computePerPatchDiff(patch, getCurrentGenContent, getWorkingTreeContent) {
22678
+ const fragments = [];
22679
+ const unlocatableFiles = [];
22680
+ for (const file of patch.files) {
22681
+ const fileDiff = extractFileDiffForFile(patch.patch_content, file);
22682
+ if (fileDiff == null) {
22683
+ unlocatableFiles.push(file);
22684
+ continue;
22685
+ }
22686
+ const hunksRaw = parseHunks(fileDiff).map(normalizeHunkBody);
22687
+ const hunks = hunksRaw.filter((h) => {
22688
+ if (h.lines.length === 0) return false;
22689
+ let ctx = 0, rm4 = 0, add = 0;
22690
+ for (const ln of h.lines) {
22691
+ if (ln.type === "context") ctx++;
22692
+ else if (ln.type === "remove") rm4++;
22693
+ else if (ln.type === "add") add++;
22694
+ }
22695
+ return ctx + rm4 === h.oldCount && ctx + add === h.newCount;
22696
+ });
22697
+ if (hunks.length === 0) {
22698
+ if (hunksRaw.length > 0) unlocatableFiles.push(file);
22699
+ continue;
22700
+ }
22701
+ const currentGen = await getCurrentGenContent(file);
22702
+ const workingTree = await getWorkingTreeContent(file);
22703
+ const isPureNewFile = hunks.every((h) => h.oldCount === 0);
22704
+ const isPureDelete = hunks.every((h) => h.newCount === 0);
22705
+ if (currentGen == null) {
22706
+ if (!isPureNewFile) {
22707
+ unlocatableFiles.push(file);
22517
22708
  continue;
22518
22709
  }
22519
- if (i === range2.separator) {
22710
+ if (workingTree == null) continue;
22711
+ fragments.push(synthesizeNewFileDiff(file, workingTree));
22712
+ continue;
22713
+ }
22714
+ if (isPureNewFile) {
22715
+ if (workingTree == null) {
22716
+ fragments.push(synthesizeDeleteFileDiff(file, currentGen));
22520
22717
  continue;
22521
22718
  }
22522
- if (i > range2.separator && i < range2.end) {
22719
+ if (workingTree === currentGen) {
22523
22720
  continue;
22524
22721
  }
22525
- if (i === range2.end) {
22526
- rangeIdx++;
22722
+ const fullDiff = await unifiedDiff(currentGen, workingTree, file);
22723
+ if (fullDiff && fullDiff.trim()) fragments.push(fullDiff);
22724
+ continue;
22725
+ }
22726
+ if (workingTree == null) {
22727
+ if (!isPureDelete) {
22728
+ unlocatableFiles.push(file);
22527
22729
  continue;
22528
22730
  }
22731
+ fragments.push(synthesizeDeleteFileDiff(file, currentGen));
22732
+ continue;
22733
+ }
22734
+ const currentGenLines = splitLines(currentGen);
22735
+ const workingTreeLines = splitLines(workingTree);
22736
+ const locInCurrentGenRaw = locateHunksInOurs(hunks, currentGenLines);
22737
+ const locInWorkingTreeRaw = locateHunksInOurs(hunks, workingTreeLines);
22738
+ if (locInCurrentGenRaw == null || locInWorkingTreeRaw == null) {
22739
+ unlocatableFiles.push(file);
22740
+ continue;
22741
+ }
22742
+ const locInCurrentGen = locInCurrentGenRaw.map(
22743
+ (l) => resolveSpan(l, currentGenLines, "old")
22744
+ );
22745
+ const locInWorkingTree = locInWorkingTreeRaw.map(
22746
+ (l) => resolveSpan(l, workingTreeLines, "new")
22747
+ );
22748
+ const overlay = overlayRegions(
22749
+ currentGenLines,
22750
+ locInCurrentGen,
22751
+ workingTreeLines,
22752
+ locInWorkingTree
22753
+ );
22754
+ if (overlay === null) {
22755
+ unlocatableFiles.push(file);
22756
+ continue;
22757
+ }
22758
+ if (overlay === currentGen) {
22759
+ continue;
22760
+ }
22761
+ const fileUnifiedDiff = await unifiedDiff(currentGen, overlay, file);
22762
+ if (fileUnifiedDiff && fileUnifiedDiff.trim()) {
22763
+ fragments.push(fileUnifiedDiff);
22529
22764
  }
22530
- result.push(lines[i]);
22531
22765
  }
22532
- return result.join("\n");
22766
+ return {
22767
+ diff: fragments.join(""),
22768
+ unlocatableFiles
22769
+ };
22533
22770
  }
22534
- function isBinaryFile(filePath) {
22535
- const ext2 = (0, import_path8.extname)(filePath).toLowerCase();
22536
- return BINARY_EXTENSIONS.has(ext2);
22771
+ async function computePerPatchDiffWithFallback(patch, getCurrentGenContent, getWorkingTreeContent, runCumulativeDiff) {
22772
+ const ppDiff = await computePerPatchDiff(
22773
+ patch,
22774
+ getCurrentGenContent,
22775
+ getWorkingTreeContent
22776
+ );
22777
+ if (ppDiff.unlocatableFiles.length === 0) {
22778
+ return { diff: ppDiff.diff, hadFallback: false, fallbackFiles: [] };
22779
+ }
22780
+ const cumulative = await runCumulativeDiff(ppDiff.unlocatableFiles);
22781
+ if (cumulative === null) {
22782
+ return null;
22783
+ }
22784
+ const merged = ppDiff.diff + (cumulative.trim() ? cumulative : "");
22785
+ return { diff: merged, hadFallback: true, fallbackFiles: ppDiff.unlocatableFiles };
22537
22786
  }
22538
- function isDiffLineForFile(diffLine, filePath) {
22539
- const match2 = diffLine.match(/^diff --git a\/.+ b\/(.+)$/);
22540
- return match2 !== null && match2[1] === filePath;
22787
+ function splitLines(content) {
22788
+ return content.split("\n");
22789
+ }
22790
+ function resolveSpan(loc, oursLines, prefer) {
22791
+ const { hunk, oursOffset } = loc;
22792
+ const oldSide = [];
22793
+ const newSide = [];
22794
+ for (const line of hunk.lines) {
22795
+ if (line.type === "context") {
22796
+ oldSide.push(line.content);
22797
+ newSide.push(line.content);
22798
+ } else if (line.type === "remove") {
22799
+ oldSide.push(line.content);
22800
+ } else if (line.type === "add") {
22801
+ newSide.push(line.content);
22802
+ }
22803
+ }
22804
+ const oldFits = sequenceMatches(oldSide, oursLines, oursOffset);
22805
+ const newFits = sequenceMatches(newSide, oursLines, oursOffset);
22806
+ if (prefer === "old") {
22807
+ if (oldFits) return { ...loc, oursSpan: hunk.oldCount };
22808
+ if (newFits) return { ...loc, oursSpan: hunk.newCount };
22809
+ } else {
22810
+ if (newFits) return { ...loc, oursSpan: hunk.newCount };
22811
+ if (oldFits) return { ...loc, oursSpan: hunk.oldCount };
22812
+ }
22813
+ return loc;
22814
+ }
22815
+ function sequenceMatches(needle, haystack, offset) {
22816
+ if (offset + needle.length > haystack.length) return false;
22817
+ for (let i = 0; i < needle.length; i++) {
22818
+ if (haystack[offset + i] !== needle[i]) return false;
22819
+ }
22820
+ return true;
22821
+ }
22822
+ function overlayRegions(currentGenLines, locInCurrentGen, workingTreeLines, locInWorkingTree) {
22823
+ if (locInCurrentGen.length !== locInWorkingTree.length) {
22824
+ return null;
22825
+ }
22826
+ const out = [];
22827
+ let cursor = 0;
22828
+ for (let i = 0; i < locInCurrentGen.length; i++) {
22829
+ const cg = locInCurrentGen[i];
22830
+ const wt = locInWorkingTree[i];
22831
+ if (cg.oursOffset > cursor) {
22832
+ out.push(...currentGenLines.slice(cursor, cg.oursOffset));
22833
+ }
22834
+ out.push(...workingTreeLines.slice(wt.oursOffset, wt.oursOffset + wt.oursSpan));
22835
+ cursor = cg.oursOffset + cg.oursSpan;
22836
+ }
22837
+ if (cursor < currentGenLines.length) {
22838
+ out.push(...currentGenLines.slice(cursor));
22839
+ }
22840
+ return out.join("\n");
22841
+ }
22842
+ async function unifiedDiff(beforeContent, afterContent, filePath) {
22843
+ if (beforeContent === afterContent) return "";
22844
+ const tmp3 = await (0, import_promises6.mkdtemp)((0, import_path11.join)((0, import_os3.tmpdir)(), "fern-replay-pp-"));
22845
+ try {
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");
22850
+ const raw = await runGitDiffNoIndex(beforePath, afterPath);
22851
+ if (!raw.trim()) return "";
22852
+ return rewriteDiffHeaders(raw, filePath);
22853
+ } finally {
22854
+ await (0, import_promises6.rm)(tmp3, { recursive: true, force: true });
22855
+ }
22856
+ }
22857
+ function runGitDiffNoIndex(beforePath, afterPath) {
22858
+ return new Promise((resolve22, reject) => {
22859
+ const proc = (0, import_child_process3.spawn)("git", ["diff", "--no-index", "--", beforePath, afterPath]);
22860
+ let stdout = "";
22861
+ let stderr = "";
22862
+ proc.stdout.on("data", (d) => stdout += d.toString());
22863
+ proc.stderr.on("data", (d) => stderr += d.toString());
22864
+ proc.on("close", (code) => {
22865
+ if (code === 0 || code === 1) resolve22(stdout);
22866
+ else reject(new Error(`git diff --no-index failed (code ${code}): ${stderr}`));
22867
+ });
22868
+ });
22869
+ }
22870
+ function rewriteDiffHeaders(raw, filePath) {
22871
+ const lines = raw.split("\n");
22872
+ const out = [];
22873
+ let seenFirstHeader = false;
22874
+ for (const line of lines) {
22875
+ if (line.startsWith("diff --git ")) {
22876
+ out.push(`diff --git a/${filePath} b/${filePath}`);
22877
+ seenFirstHeader = true;
22878
+ continue;
22879
+ }
22880
+ if (!seenFirstHeader) {
22881
+ continue;
22882
+ }
22883
+ if (line.startsWith("--- ")) {
22884
+ out.push(`--- a/${filePath}`);
22885
+ continue;
22886
+ }
22887
+ if (line.startsWith("+++ ")) {
22888
+ out.push(`+++ b/${filePath}`);
22889
+ continue;
22890
+ }
22891
+ out.push(line);
22892
+ }
22893
+ return out.join("\n");
22894
+ }
22895
+ function synthesizeNewFileDiff(file, content) {
22896
+ const lines = content.split("\n");
22897
+ const hasTrailingNewline = content.endsWith("\n");
22898
+ const bodyLines = hasTrailingNewline ? lines.slice(0, -1) : lines;
22899
+ const header = [
22900
+ `diff --git a/${file} b/${file}`,
22901
+ "new file mode 100644",
22902
+ "--- /dev/null",
22903
+ `+++ b/${file}`,
22904
+ `@@ -0,0 +1,${bodyLines.length} @@`
22905
+ ].join("\n");
22906
+ const body = bodyLines.map((l) => `+${l}`).join("\n");
22907
+ const trailer = hasTrailingNewline ? "" : "\n\";
22908
+ return header + "\n" + body + trailer + "\n";
22909
+ }
22910
+ function synthesizeDeleteFileDiff(file, content) {
22911
+ const lines = content.split("\n");
22912
+ const hasTrailingNewline = content.endsWith("\n");
22913
+ const bodyLines = hasTrailingNewline ? lines.slice(0, -1) : lines;
22914
+ const header = [
22915
+ `diff --git a/${file} b/${file}`,
22916
+ "deleted file mode 100644",
22917
+ `--- a/${file}`,
22918
+ "+++ /dev/null",
22919
+ `@@ -1,${bodyLines.length} +0,0 @@`
22920
+ ].join("\n");
22921
+ const body = bodyLines.map((l) => `-${l}`).join("\n");
22922
+ const trailer = hasTrailingNewline ? "" : "\n\";
22923
+ return header + "\n" + body + trailer + "\n";
22924
+ }
22925
+ function computeCommitBuckets(results, absorbedPatchIds, patchesPassedToCommit) {
22926
+ const resultByPatchId = /* @__PURE__ */ new Map();
22927
+ for (const r2 of results) resultByPatchId.set(r2.patch.id, r2);
22928
+ const applied = [];
22929
+ const unresolved = [];
22930
+ const absorbed = [];
22931
+ for (const patch of patchesPassedToCommit) {
22932
+ if (absorbedPatchIds.has(patch.id)) {
22933
+ absorbed.push(patch);
22934
+ continue;
22935
+ }
22936
+ const r2 = resultByPatchId.get(patch.id);
22937
+ if (!r2) {
22938
+ applied.push(patch);
22939
+ continue;
22940
+ }
22941
+ if (r2.status === "conflict" || patch.status === "unresolved") {
22942
+ unresolved.push(patch);
22943
+ continue;
22944
+ }
22945
+ if (r2.status === "applied") {
22946
+ applied.push(patch);
22947
+ continue;
22948
+ }
22949
+ }
22950
+ return { applied, unresolved, absorbed };
22541
22951
  }
22542
22952
  async function bootstrap(outputDir, options) {
22543
22953
  const git = new GitClient(outputDir);
@@ -22703,7 +23113,7 @@ function parseGitLog(log) {
22703
23113
  });
22704
23114
  }
22705
23115
  function ensureFernignoreEntries(outputDir) {
22706
- const fernignorePath = (0, import_path11.join)(outputDir, ".fernignore");
23116
+ const fernignorePath = (0, import_path13.join)(outputDir, ".fernignore");
22707
23117
  let content = "";
22708
23118
  if ((0, import_fs9.existsSync)(fernignorePath)) {
22709
23119
  content = (0, import_fs9.readFileSync)(fernignorePath, "utf-8");
@@ -22726,7 +23136,7 @@ function ensureFernignoreEntries(outputDir) {
22726
23136
  return true;
22727
23137
  }
22728
23138
  function ensureGitattributesEntries(outputDir) {
22729
- const gitattributesPath = (0, import_path11.join)(outputDir, ".gitattributes");
23139
+ const gitattributesPath = (0, import_path13.join)(outputDir, ".gitattributes");
22730
23140
  let content = "";
22731
23141
  if ((0, import_fs9.existsSync)(gitattributesPath)) {
22732
23142
  content = (0, import_fs9.readFileSync)(gitattributesPath, "utf-8");
@@ -22921,7 +23331,7 @@ async function resolve7(outputDir, options) {
22921
23331
  const resolvingPatches = lockManager.getResolvingPatches();
22922
23332
  if (unresolvedPatches.length > 0) {
22923
23333
  const applicator = new ReplayApplicator(git, lockManager, outputDir);
22924
- await applicator.applyPatches(unresolvedPatches);
23334
+ await applicator.applyPatches(unresolvedPatches, { applyMode: "resolve" });
22925
23335
  const markerFiles = await findConflictMarkerFiles(git);
22926
23336
  if (markerFiles.length > 0) {
22927
23337
  for (const patch of unresolvedPatches) {
@@ -22947,20 +23357,80 @@ async function resolve7(outputDir, options) {
22947
23357
  if (patchesToCommit.length > 0) {
22948
23358
  const currentGen = lock.current_generation;
22949
23359
  const detector = new ReplayDetector(git, lockManager, outputDir);
23360
+ const warnings = [];
22950
23361
  let patchesResolved = 0;
23362
+ const pass1 = [];
22951
23363
  for (const patch of patchesToCommit) {
22952
- const diff = await git.exec(["diff", currentGen, "--", ...patch.files]).catch(() => null);
22953
- if (!diff || !diff.trim()) {
23364
+ const result = await computePerPatchDiffWithFallback(
23365
+ patch,
23366
+ (f2) => git.showFile(currentGen, f2),
23367
+ (f2) => (0, import_promises7.readFile)((0, import_path14.join)(outputDir, f2), "utf-8").catch(() => null),
23368
+ (files) => git.exec(["diff", currentGen, "--", ...files]).catch(() => null)
23369
+ );
23370
+ if (result === null) {
23371
+ warnings.push(
23372
+ `Patch ${patch.id}: cumulative-diff fallback failed for unlocatable files; preserving patch unchanged`
23373
+ );
23374
+ pass1.push({ kind: "skip" });
23375
+ continue;
23376
+ }
23377
+ const diff = result.diff;
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];
22954
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
+ );
22955
23411
  continue;
22956
23412
  }
22957
- const newContentHash = detector.computeContentHash(diff);
22958
- const changedFiles = await getChangedFiles(git, currentGen, patch.files);
23413
+ if (r2.hadFallback) {
23414
+ warnings.push(
23415
+ `Patch ${patch.id}: ${r2.fallbackFiles.join(", ")} could not be anchored; cumulative-diff fallback applied for those files`
23416
+ );
23417
+ }
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
+ }
22959
23428
  lockManager.markPatchResolved(patch.id, {
22960
- patch_content: diff,
22961
- content_hash: newContentHash,
23429
+ patch_content: r2.diff,
23430
+ content_hash: r2.hash,
22962
23431
  base_generation: currentGen,
22963
- files: changedFiles
23432
+ files: filesForSnapshot,
23433
+ ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {}
22964
23434
  });
22965
23435
  patchesResolved++;
22966
23436
  }
@@ -22976,7 +23446,8 @@ async function resolve7(outputDir, options) {
22976
23446
  success: true,
22977
23447
  commitSha: commitSha2,
22978
23448
  phase: "committed",
22979
- patchesResolved
23449
+ patchesResolved,
23450
+ warnings: warnings.length > 0 ? warnings : void 0
22980
23451
  };
22981
23452
  }
22982
23453
  const committer = new ReplayCommitter(git, outputDir);
@@ -22994,6 +23465,24 @@ async function getChangedFiles(git, currentGen, files) {
22994
23465
  const changed = filesOutput.trim().split("\n").filter(Boolean).filter((f2) => !f2.startsWith(".fern/"));
22995
23466
  return changed.length > 0 ? changed : files;
22996
23467
  }
23468
+ function extractFilesFromDiff(unifiedDiff2) {
23469
+ const out = /* @__PURE__ */ new Set();
23470
+ const renameSources = /* @__PURE__ */ new Set();
23471
+ let currentTarget = null;
23472
+ for (const line of unifiedDiff2.split("\n")) {
23473
+ const headerMatch = line.match(/^diff --git a\/(.+?) b\/(.+)$/);
23474
+ if (headerMatch) {
23475
+ currentTarget = headerMatch[2];
23476
+ if (!currentTarget.startsWith(".fern/")) out.add(currentTarget);
23477
+ continue;
23478
+ }
23479
+ if (currentTarget && line.startsWith("rename from ")) {
23480
+ renameSources.add(line.slice("rename from ".length));
23481
+ }
23482
+ }
23483
+ for (const src of renameSources) out.delete(src);
23484
+ return Array.from(out);
23485
+ }
22997
23486
  function status(outputDir) {
22998
23487
  const lockManager = new LockfileManager(outputDir);
22999
23488
  if (!lockManager.exists()) {
@@ -23041,33 +23530,43 @@ function status(outputDir) {
23041
23530
  excludePatterns
23042
23531
  };
23043
23532
  }
23044
- var import_fs6, import_path7, import_yaml, import_crypto, import_promises3, import_os, import_path8, import_fs7, import_path9, import_crypto2, import_fs8, import_path10, import_yaml2, import_crypto3, import_fs9, import_path11, import_fs10, __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;
23045
23534
  var init_dist3 = __esm({
23046
- "../../node_modules/.pnpm/@fern-api+replay@0.13.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"() {
23047
23536
  "use strict";
23048
23537
  init_esm4();
23049
- import_fs6 = require("fs");
23050
- import_path7 = require("path");
23051
- import_yaml = __toESM(require_dist3(), 1);
23052
- import_crypto = require("crypto");
23053
- init_diff3();
23054
23538
  import_promises3 = require("fs/promises");
23055
23539
  import_os = require("os");
23540
+ import_path7 = require("path");
23541
+ import_fs6 = require("fs");
23056
23542
  import_path8 = require("path");
23543
+ import_yaml = __toESM(require_dist3(), 1);
23544
+ import_crypto = require("crypto");
23545
+ import_promises4 = require("fs/promises");
23546
+ import_os2 = require("os");
23547
+ import_path9 = require("path");
23057
23548
  init_esm3();
23549
+ init_diff3();
23058
23550
  import_fs7 = require("fs");
23059
- import_path9 = require("path");
23551
+ import_promises5 = require("fs/promises");
23552
+ import_path10 = require("path");
23060
23553
  init_esm3();
23554
+ import_promises6 = require("fs/promises");
23555
+ import_os3 = require("os");
23556
+ import_path11 = require("path");
23557
+ import_child_process3 = require("child_process");
23061
23558
  import_crypto2 = require("crypto");
23062
23559
  import_fs8 = require("fs");
23063
- import_path10 = require("path");
23560
+ import_path12 = require("path");
23064
23561
  init_esm3();
23065
23562
  import_yaml2 = __toESM(require_dist3(), 1);
23066
23563
  import_crypto3 = require("crypto");
23067
23564
  import_fs9 = require("fs");
23068
- import_path11 = require("path");
23565
+ import_path13 = require("path");
23069
23566
  init_esm3();
23070
23567
  import_fs10 = require("fs");
23568
+ import_promises7 = require("fs/promises");
23569
+ import_path14 = require("path");
23071
23570
  __defProp3 = Object.defineProperty;
23072
23571
  __getOwnPropNames3 = Object.getOwnPropertyNames;
23073
23572
  __esm3 = (fn, res) => function __init() {
@@ -23095,9 +23594,9 @@ var init_dist3 = __esm({
23095
23594
  return this.git.raw(args);
23096
23595
  }
23097
23596
  async execWithInput(args, input) {
23098
- const { spawn: spawn2 } = await import("child_process");
23597
+ const { spawn: spawn22 } = await import("child_process");
23099
23598
  return new Promise((resolve22, reject) => {
23100
- const proc = spawn2("git", args, { cwd: this.repoPath });
23599
+ const proc = spawn22("git", args, { cwd: this.repoPath });
23101
23600
  let stdout = "";
23102
23601
  let stderr = "";
23103
23602
  proc.stdout.on("data", (data) => {
@@ -23223,6 +23722,16 @@ var init_dist3 = __esm({
23223
23722
  "use strict";
23224
23723
  }
23225
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
+ });
23226
23735
  CREDENTIAL_PATTERNS = [
23227
23736
  { name: "PRIVATE KEY block", re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
23228
23737
  { name: "OPENSSH key", re: /-----BEGIN OPENSSH PRIVATE KEY-----/ },
@@ -23262,10 +23771,10 @@ var init_dist3 = __esm({
23262
23771
  this.outputDir = outputDir;
23263
23772
  }
23264
23773
  get lockfilePath() {
23265
- return (0, import_path7.join)(this.outputDir, ".fern", "replay.lock");
23774
+ return (0, import_path8.join)(this.outputDir, ".fern", "replay.lock");
23266
23775
  }
23267
23776
  get customizationsPath() {
23268
- return (0, import_path7.join)(this.outputDir, ".fern", "replay.yml");
23777
+ return (0, import_path8.join)(this.outputDir, ".fern", "replay.yml");
23269
23778
  }
23270
23779
  exists() {
23271
23780
  return (0, import_fs6.existsSync)(this.lockfilePath);
@@ -23309,7 +23818,17 @@ var init_dist3 = __esm({
23309
23818
  this.warnings.length = 0;
23310
23819
  const cleanPatches = [];
23311
23820
  for (const patch of this.lock.patches) {
23312
- 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))];
23313
23832
  const sensitiveFiles = patch.files.filter((f2) => isSensitiveFile(f2));
23314
23833
  if (credentialMatches.length > 0 || sensitiveFiles.length > 0) {
23315
23834
  const reasons = [];
@@ -23327,7 +23846,7 @@ var init_dist3 = __esm({
23327
23846
  }
23328
23847
  }
23329
23848
  this.lock.patches = cleanPatches;
23330
- const dir = (0, import_path7.dirname)(this.lockfilePath);
23849
+ const dir = (0, import_path8.dirname)(this.lockfilePath);
23331
23850
  if (!(0, import_fs6.existsSync)(dir)) {
23332
23851
  (0, import_fs6.mkdirSync)(dir, { recursive: true });
23333
23852
  }
@@ -23427,879 +23946,440 @@ var init_dist3 = __esm({
23427
23946
  }
23428
23947
  }
23429
23948
  };
23430
- INFRASTRUCTURE_FILES = /* @__PURE__ */ new Set([".fernignore"]);
23431
- 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 {
23432
24002
  git;
23433
24003
  lockManager;
23434
- sdkOutputDir;
23435
- warnings = [];
23436
- 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) {
23437
24042
  this.git = git;
23438
24043
  this.lockManager = lockManager;
23439
- this.sdkOutputDir = sdkOutputDir;
23440
- }
23441
- async detectNewPatches() {
23442
- const lock = this.lockManager.read();
23443
- const lastGen = this.getLastGeneration(lock);
23444
- if (!lastGen) {
23445
- return { patches: [], revertedPatchIds: [] };
23446
- }
23447
- const exists2 = await this.git.commitExists(lastGen.commit_sha);
23448
- if (!exists2) {
23449
- this.warnings.push(
23450
- `Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to alternate detection.`
23451
- );
23452
- return this.detectPatchesViaTreeDiff(
23453
- lastGen,
23454
- /* commitKnownMissing */
23455
- true
23456
- );
23457
- }
23458
- const isAncestor = await this.git.isAncestor(lastGen.commit_sha, "HEAD");
23459
- if (!isAncestor) {
23460
- return this.detectPatchesViaMergeBase(lastGen, lock);
23461
- }
23462
- return this.detectPatchesInRange(lastGen.commit_sha, lastGen, lock);
24044
+ this.outputDir = outputDir;
23463
24045
  }
23464
24046
  /**
23465
- * FER-10201 — Non-linear-history detection via `merge-base(prevGen, HEAD)`.
23466
- *
23467
- * After a squash-merge of a Fern-bot PR, `lastGen.commit_sha` (the previous
23468
- * `[fern-generated]`) is orphaned but still in git's object database. The
23469
- * merge-base is the commit on main from which the bot's branch was created —
23470
- * a reachable ancestor of HEAD. Walking that range and classifying each
23471
- * commit via `isGenerationCommit` mirrors the linear path and eliminates
23472
- * the bug class where pipeline-driven changes (autoversion, replay,
23473
- * generation) get bundled as customer customizations.
23474
- *
23475
- * Falls through to the legacy composite path when no common ancestor exists
23476
- * (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).
23477
24050
  */
23478
- async detectPatchesViaMergeBase(lastGen, lock) {
23479
- let mergeBase = "";
24051
+ async resolveBaseGeneration(baseGeneration) {
24052
+ const gen = this.lockManager.getGeneration(baseGeneration);
24053
+ if (gen) return gen;
23480
24054
  try {
23481
- 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
+ };
23482
24063
  } catch {
24064
+ return void 0;
23483
24065
  }
23484
- if (!mergeBase) {
23485
- this.warnings.push(
23486
- `No common ancestor between previous generation ${lastGen.commit_sha.slice(0, 7)} and HEAD. Falling back to composite tree-diff detection.`
23487
- );
23488
- return this.detectPatchesViaTreeDiff(
23489
- lastGen,
23490
- /* commitKnownMissing */
23491
- false
23492
- );
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 });
23493
24082
  }
23494
- return this.detectPatchesInRange(mergeBase, lastGen, lock);
24083
+ return snapshot;
23495
24084
  }
23496
24085
  /**
23497
- * FER-10201 — Walk `${rangeStart}..HEAD`, classify each commit, emit one
23498
- * `StoredPatch` per customer commit. Body shared by the linear path
23499
- * (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
23500
24088
  *
23501
- * Patch `base_generation` is always `lastGen.commit_sha` — the
23502
- * lockfile-tracked reference the applicator uses to fetch base file
23503
- * 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.
23504
24101
  */
23505
- async detectPatchesInRange(rangeStart, lastGen, lock) {
23506
- const log = await this.git.exec([
23507
- "log",
23508
- "--first-parent",
23509
- "--format=%H%x00%an%x00%ae%x00%s",
23510
- `${rangeStart}..HEAD`,
23511
- "--",
23512
- this.sdkOutputDir
23513
- ]);
23514
- if (!log.trim()) {
23515
- return { patches: [], revertedPatchIds: [] };
23516
- }
23517
- const commits = this.parseGitLog(log);
23518
- const newPatches = [];
23519
- const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
23520
- for (const commit of commits) {
23521
- if (isGenerationCommit(commit)) {
23522
- continue;
23523
- }
23524
- if (lock.patches.find((p) => p.original_commit === commit.sha)) {
23525
- 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);
23526
24110
  }
23527
- const parents = await this.git.getCommitParents(commit.sha);
23528
- if (parents.length > 1) {
23529
- const compositePatch = await this.createMergeCompositePatch({
23530
- mergeCommit: commit,
23531
- firstParent: parents[0],
23532
- lastGen,
23533
- lock
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"
23534
24123
  });
23535
- if (compositePatch) {
23536
- newPatches.push(compositePatch);
23537
- }
23538
- continue;
23539
- }
23540
- let patchContent;
23541
- try {
23542
- patchContent = await this.git.formatPatch(commit.sha);
23543
- } catch {
23544
- this.warnings.push(
23545
- `Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
23546
- );
23547
- continue;
23548
- }
23549
- let contentHash = this.computeContentHash(patchContent);
23550
- if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
23551
24124
  continue;
23552
24125
  }
23553
- const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
23554
- const allFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f2) => !INFRASTRUCTURE_FILES.has(f2)).filter((f2) => !f2.startsWith(".fern/"));
23555
- const sensitiveFiles = allFiles.filter((f2) => isSensitiveFile(f2));
23556
- const files = allFiles.filter((f2) => !isSensitiveFile(f2));
23557
- if (sensitiveFiles.length > 0) {
23558
- this.warnings.push(
23559
- `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
23560
- );
23561
- if (files.length > 0) {
23562
- const parentSha = parents[0];
23563
- if (parentSha) {
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);
23564
24146
  try {
23565
- patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
24147
+ const content = await (0, import_promises4.readFile)(filePath, "utf-8");
24148
+ const stripped = stripConflictMarkers(content);
24149
+ await (0, import_promises4.writeFile)(filePath, stripped);
23566
24150
  } catch {
23567
- continue;
23568
24151
  }
23569
- if (!patchContent.trim()) continue;
23570
- contentHash = this.computeContentHash(patchContent);
23571
24152
  }
23572
24153
  }
23573
24154
  }
23574
- if (files.length === 0) {
23575
- continue;
24155
+ }
24156
+ return results;
24157
+ }
24158
+ /**
24159
+ * Populate accumulator after git apply succeeds, AND collect per-file
24160
+ * results including any "dropped context lines" — lines that were
24161
+ * present in BASE and THEIRS (unchanged context) but absent from the
24162
+ * final on-disk state because OURS deleted them. This is the fast-path
24163
+ * counterpart to ThreeWayMerge.computeDroppedContextLines used by the
24164
+ * 3-way slow path; both must surface the same warning.
24165
+ */
24166
+ async populateAccumulatorForPatch(patch, baseGen, currentTreeHash) {
24167
+ const fileResults = [];
24168
+ if (!baseGen) return fileResults;
24169
+ const tempDir = await (0, import_promises4.mkdtemp)((0, import_path9.join)((0, import_os2.tmpdir)(), "replay-acc-"));
24170
+ const { GitClient: GitClient2 } = await Promise.resolve().then(() => (init_GitClient(), GitClient_exports));
24171
+ const tempGit = new GitClient2(tempDir);
24172
+ await tempGit.exec(["init"]);
24173
+ await tempGit.exec(["config", "user.email", "replay@fern.com"]);
24174
+ await tempGit.exec(["config", "user.name", "Fern Replay"]);
24175
+ try {
24176
+ for (const filePath of patch.files) {
24177
+ if (isBinaryFile(filePath)) continue;
24178
+ const resolvedPath = await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
24179
+ const base = await this.git.showFile(baseGen.tree_hash, filePath);
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);
24184
+ const effectiveTheirs = theirs ?? finalOnDisk;
24185
+ if (effectiveTheirs != null) {
24186
+ this.fileTheirsAccumulator.set(resolvedPath, {
24187
+ content: effectiveTheirs,
24188
+ baseGeneration: patch.base_generation
24189
+ });
24190
+ }
24191
+ if (base != null && effectiveTheirs != null && finalOnDisk != null) {
24192
+ const dropped = computeDroppedContextLinesForFile(base, effectiveTheirs, finalOnDisk);
24193
+ if (dropped.length > 0) {
24194
+ fileResults.push({
24195
+ file: resolvedPath,
24196
+ status: "merged",
24197
+ droppedContextLines: dropped
24198
+ });
24199
+ }
24200
+ }
23576
24201
  }
23577
- newPatches.push({
23578
- id: `patch-${commit.sha.slice(0, 8)}`,
23579
- content_hash: contentHash,
23580
- original_commit: commit.sha,
23581
- original_message: commit.message,
23582
- original_author: `${commit.authorName} <${commit.authorEmail}>`,
23583
- base_generation: lastGen.commit_sha,
23584
- files,
23585
- patch_content: patchContent,
23586
- ...this.isCreationOnlyPatch(patchContent) ? { user_owned: true } : {}
24202
+ } finally {
24203
+ await (0, import_promises4.rm)(tempDir, { recursive: true }).catch(() => {
23587
24204
  });
23588
24205
  }
23589
- const survivingPatches = await this.collapseNetZeroFiles(newPatches);
23590
- survivingPatches.reverse();
23591
- const revertedPatchIdSet = /* @__PURE__ */ new Set();
23592
- const revertIndicesToRemove = /* @__PURE__ */ new Set();
23593
- for (let i = 0; i < survivingPatches.length; i++) {
23594
- const patch = survivingPatches[i];
23595
- if (!isRevertCommit(patch.original_message)) continue;
23596
- let body = "";
24206
+ return fileResults;
24207
+ }
24208
+ async applyPatchWithFallback(patch) {
24209
+ const baseGen = await this.resolveBaseGeneration(patch.base_generation);
24210
+ const lock = this.lockManager.read();
24211
+ const currentGen = lock.generations.find((g2) => g2.commit_sha === lock.current_generation);
24212
+ const currentTreeHash = currentGen?.tree_hash ?? baseGen?.tree_hash ?? "";
24213
+ const needsAccumulation = await Promise.all(
24214
+ patch.files.map(async (f2) => {
24215
+ if (!baseGen) return false;
24216
+ const resolved = await this.resolveFilePath(f2, baseGen.tree_hash, currentTreeHash);
24217
+ return this.fileTheirsAccumulator.has(resolved);
24218
+ })
24219
+ ).then((results) => results.some(Boolean));
24220
+ const resolvedFiles = {};
24221
+ for (const filePath of patch.files) {
24222
+ const resolvedPath = baseGen ? await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash) : filePath;
24223
+ if (resolvedPath !== filePath) {
24224
+ resolvedFiles[filePath] = resolvedPath;
24225
+ }
24226
+ }
24227
+ const patchIsRenameAware = /^rename from /m.test(patch.patch_content);
24228
+ const hasGeneratorRename = Object.keys(resolvedFiles).length > 0 && !patchIsRenameAware;
24229
+ if (!needsAccumulation && !hasGeneratorRename) {
24230
+ const snapshots = /* @__PURE__ */ new Map();
24231
+ for (const filePath of patch.files) {
24232
+ const fullPath = (0, import_path9.join)(this.outputDir, filePath);
24233
+ snapshots.set(filePath, await (0, import_promises4.readFile)(fullPath, "utf-8").catch(() => null));
24234
+ }
23597
24235
  try {
23598
- body = await this.git.getCommitBody(patch.original_commit);
24236
+ await this.git.execWithInput(["apply", "--3way"], patch.patch_content);
24237
+ const fastPathFileResults = await this.populateAccumulatorForPatch(patch, baseGen, currentTreeHash);
24238
+ return {
24239
+ patch,
24240
+ status: "applied",
24241
+ method: "git-am",
24242
+ ...fastPathFileResults.length > 0 && { fileResults: fastPathFileResults },
24243
+ ...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
24244
+ };
23599
24245
  } catch {
24246
+ for (const [filePath, content] of snapshots) {
24247
+ const fullPath = (0, import_path9.join)(this.outputDir, filePath);
24248
+ if (content != null) {
24249
+ await (0, import_promises4.writeFile)(fullPath, content);
24250
+ } else {
24251
+ await (0, import_promises4.unlink)(fullPath).catch(() => {
24252
+ });
24253
+ }
24254
+ }
23600
24255
  }
23601
- const revertedSha = parseRevertedSha(body);
23602
- const revertedMessage = parseRevertedMessage(patch.original_message);
23603
- let matchedExisting = false;
23604
- if (revertedSha) {
23605
- const existing = lock.patches.find((p) => p.original_commit === revertedSha);
23606
- if (existing) {
23607
- revertedPatchIdSet.add(existing.id);
23608
- revertIndicesToRemove.add(i);
23609
- matchedExisting = true;
24256
+ }
24257
+ return this.applyWithThreeWayMerge(patch);
24258
+ }
24259
+ async applyWithThreeWayMerge(patch) {
24260
+ const fileResults = [];
24261
+ const resolvedFiles = {};
24262
+ const tempDir = await (0, import_promises4.mkdtemp)((0, import_path9.join)((0, import_os2.tmpdir)(), "replay-"));
24263
+ const { GitClient: GitClient2 } = await Promise.resolve().then(() => (init_GitClient(), GitClient_exports));
24264
+ const tempGit = new GitClient2(tempDir);
24265
+ await tempGit.exec(["init"]);
24266
+ await tempGit.exec(["config", "user.email", "replay@fern.com"]);
24267
+ await tempGit.exec(["config", "user.name", "Fern Replay"]);
24268
+ try {
24269
+ for (const filePath of patch.files) {
24270
+ if (isBinaryFile(filePath)) {
24271
+ fileResults.push({
24272
+ file: filePath,
24273
+ status: "skipped",
24274
+ reason: "binary-file"
24275
+ });
24276
+ continue;
24277
+ }
24278
+ const result = await this.mergeFile(patch, filePath, tempGit, tempDir);
24279
+ if (result.file !== filePath) {
24280
+ resolvedFiles[filePath] = result.file;
23610
24281
  }
24282
+ fileResults.push(result);
23611
24283
  }
23612
- if (!matchedExisting && revertedMessage) {
23613
- const existing = lock.patches.find((p) => p.original_message === revertedMessage);
23614
- if (existing) {
23615
- revertedPatchIdSet.add(existing.id);
23616
- revertIndicesToRemove.add(i);
23617
- matchedExisting = true;
24284
+ } finally {
24285
+ await (0, import_promises4.rm)(tempDir, { recursive: true }).catch(() => {
24286
+ });
24287
+ }
24288
+ const conflictFiles = fileResults.filter((r2) => r2.status === "conflict");
24289
+ const hasConflicts = conflictFiles.length > 0;
24290
+ const conflictReason = hasConflicts ? conflictFiles.some((f2) => f2.conflictReason === "base-generation-mismatch") ? "base-generation-mismatch" : conflictFiles[0]?.conflictReason : void 0;
24291
+ return {
24292
+ patch,
24293
+ status: hasConflicts ? "conflict" : "applied",
24294
+ method: "3way-merge",
24295
+ fileResults,
24296
+ conflictReason,
24297
+ ...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
24298
+ };
24299
+ }
24300
+ async mergeFile(patch, filePath, tempGit, tempDir) {
24301
+ try {
24302
+ const baseGen = await this.resolveBaseGeneration(patch.base_generation);
24303
+ if (!baseGen) {
24304
+ return { file: filePath, status: "skipped", reason: "base-generation-not-found" };
24305
+ }
24306
+ const lock = this.lockManager.read();
24307
+ const currentGen = lock.generations.find((g2) => g2.commit_sha === lock.current_generation);
24308
+ const currentTreeHash = currentGen?.tree_hash ?? baseGen.tree_hash;
24309
+ const resolvedPath = await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
24310
+ const metadata = {
24311
+ patchId: patch.id,
24312
+ patchMessage: patch.original_message,
24313
+ baseGeneration: patch.base_generation,
24314
+ currentGeneration: lock.current_generation
24315
+ };
24316
+ let base = await this.git.showFile(baseGen.tree_hash, filePath);
24317
+ let renameSourcePath;
24318
+ if (!base) {
24319
+ const renameSource = this.extractRenameSource(patch.patch_content, filePath);
24320
+ if (renameSource) {
24321
+ base = await this.git.showFile(baseGen.tree_hash, renameSource);
24322
+ renameSourcePath = renameSource;
23618
24323
  }
23619
24324
  }
23620
- if (matchedExisting) continue;
23621
- let matchedNew = false;
23622
- if (revertedSha) {
23623
- const idx = survivingPatches.findIndex(
23624
- (p, j2) => j2 !== i && !revertIndicesToRemove.has(j2) && p.original_commit === revertedSha
23625
- );
23626
- if (idx !== -1) {
23627
- revertIndicesToRemove.add(i);
23628
- revertIndicesToRemove.add(idx);
23629
- matchedNew = true;
24325
+ const oursPath = (0, import_path9.join)(this.outputDir, resolvedPath);
24326
+ const ours = await (0, import_promises4.readFile)(oursPath, "utf-8").catch(() => null);
24327
+ let ghostReconstructed = false;
24328
+ let theirs = null;
24329
+ if (!base && ours && !renameSourcePath) {
24330
+ const treeReachable = await this.isTreeReachable(baseGen.tree_hash);
24331
+ if (!treeReachable) {
24332
+ const fileDiff = this.extractFileDiff(patch.patch_content, filePath);
24333
+ if (fileDiff) {
24334
+ const { reconstructFromGhostPatch: reconstructFromGhostPatch2 } = await Promise.resolve().then(() => (init_HybridReconstruction(), HybridReconstruction_exports));
24335
+ const result = reconstructFromGhostPatch2(fileDiff, ours);
24336
+ if (result) {
24337
+ base = result.base;
24338
+ theirs = result.theirs;
24339
+ ghostReconstructed = true;
24340
+ }
24341
+ }
23630
24342
  }
23631
24343
  }
23632
- if (!matchedNew && revertedMessage) {
23633
- const idx = survivingPatches.findIndex(
23634
- (p, j2) => j2 !== i && !revertIndicesToRemove.has(j2) && p.original_message === revertedMessage
23635
- );
23636
- if (idx !== -1) {
23637
- revertIndicesToRemove.add(i);
23638
- revertIndicesToRemove.add(idx);
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;
24347
+ if (!ghostReconstructed) {
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
+ }
23639
24364
  }
23640
24365
  }
23641
- if (!matchedExisting && !matchedNew) {
23642
- revertIndicesToRemove.add(i);
23643
- }
23644
- }
23645
- const filteredPatches = survivingPatches.filter((_2, i) => !revertIndicesToRemove.has(i));
23646
- return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
23647
- }
23648
- /**
23649
- * Compute content hash for deduplication.
23650
- *
23651
- * Produces a format-agnostic hash so both `git format-patch` output
23652
- * (email-wrapped) and plain `git diff` output hash to the same value
23653
- * when the underlying diff hunks are identical.
23654
- *
23655
- * Normalization:
23656
- * 1. Strip email wrapper: everything before the first `diff --git` line
23657
- * (From:, Subject:, Date:, diffstat, blank separators).
23658
- * 2. Strip `index` lines (blob SHA pairs change across rebases).
23659
- * 3. Strip trailing git version marker (`-- \n<version>`).
23660
- *
23661
- * This ensures content hashes match across the no-patches and normal-
23662
- * regeneration flows, which store formatPatch and git-diff formats
23663
- * respectively (FER-9850, D5-D9).
23664
- */
23665
- computeContentHash(patchContent) {
23666
- const lines = patchContent.split("\n");
23667
- const diffStart = lines.findIndex((l) => l.startsWith("diff --git "));
23668
- const relevantLines = diffStart > 0 ? lines.slice(diffStart) : lines;
23669
- const normalized = relevantLines.filter((line) => !line.startsWith("index ")).join("\n").replace(/\n-- \n[\d]+\.[\d]+[^\n]*\s*$/, "");
23670
- return `sha256:${(0, import_crypto.createHash)("sha256").update(normalized).digest("hex")}`;
23671
- }
23672
- /**
23673
- * FER-9805 — Drop patches whose files were transiently created and then
23674
- * deleted within the current linear detection window, and are absent from
23675
- * HEAD at the end of the window.
23676
- *
23677
- * Rationale: on a merge commit, createMergeCompositePatch already diffs
23678
- * firstParent..mergeCommit so net-zero files never enter the composite. On
23679
- * linear history each commit becomes its own patch, so a file that was
23680
- * briefly added and later removed survives as a create+delete pair whose
23681
- * cumulative diff is empty. We drop such patches before they reach the
23682
- * lockfile.
23683
- *
23684
- * A file is "transient" when:
23685
- * 1. some patch in the window sets `new file mode` for it (a creation), AND
23686
- * 2. some patch in the window sets `deleted file mode` for it (a deletion), AND
23687
- * 3. it is absent from HEAD's tree.
23688
- *
23689
- * The HEAD guard (#3) prevents false positives when a file is deleted
23690
- * and then recreated with different content — the cumulative diff of
23691
- * such a pair is non-empty and must be preserved.
23692
- *
23693
- * This only drops patches whose `files` are a subset of the transient
23694
- * set. A partial-transient patch (one that touches a transient file
23695
- * alongside a persistent one) is left unmodified; surgical stripping of
23696
- * single files from a multi-file patch would require a follow-up that
23697
- * regenerates the patch content via `git format-patch -- <files>`. No
23698
- * current failing test exercises this, and leaving the patch intact
23699
- * preserves today's behaviour. TODO(FER-9805): revisit if a future
23700
- * adversarial test demands per-file stripping.
23701
- */
23702
- async collapseNetZeroFiles(patches) {
23703
- if (patches.length === 0) return patches;
23704
- const stateByFile = /* @__PURE__ */ new Map();
23705
- for (const patch of patches) {
23706
- for (const header of parsePatchFileHeaders(patch.patch_content)) {
23707
- if (!header.isCreate && !header.isDelete) continue;
23708
- const prior = stateByFile.get(header.path) ?? { created: false, deleted: false };
23709
- if (header.isCreate) prior.created = true;
23710
- if (header.isDelete) prior.deleted = true;
23711
- stateByFile.set(header.path, prior);
23712
- }
23713
- }
23714
- let anyCandidate = false;
23715
- for (const state of stateByFile.values()) {
23716
- if (state.created && state.deleted) {
23717
- anyCandidate = true;
23718
- break;
23719
- }
23720
- }
23721
- if (!anyCandidate) return patches;
23722
- const headFiles = await this.listHeadFiles();
23723
- const transient = /* @__PURE__ */ new Set();
23724
- for (const [file, state] of stateByFile) {
23725
- if (state.created && state.deleted && !headFiles.has(file)) {
23726
- transient.add(file);
23727
- }
23728
- }
23729
- if (transient.size === 0) return patches;
23730
- return patches.filter((patch) => !patch.files.every((f2) => transient.has(f2)));
23731
- }
23732
- /**
23733
- * List every tracked path at HEAD (one `git ls-tree -r` invocation).
23734
- * Returns an empty Set if HEAD is unborn or the invocation fails — the
23735
- * caller then treats every candidate as "not in HEAD" and may collapse
23736
- * more aggressively. On typical SDK repos this is a single sub-10ms call.
23737
- */
23738
- async listHeadFiles() {
23739
- try {
23740
- const out = await this.git.exec(["ls-tree", "-r", "--name-only", "HEAD"]);
23741
- return new Set(out.split("\n").map((l) => l.trim()).filter(Boolean));
23742
- } catch {
23743
- return /* @__PURE__ */ new Set();
23744
- }
23745
- }
23746
- /**
23747
- * Create a single composite patch for a merge commit by diffing the merge
23748
- * commit against its first parent. This captures the net change of the
23749
- * entire merged branch as one patch, eliminating accumulator chaining and
23750
- * zero-net-change ghost conflicts.
23751
- */
23752
- async createMergeCompositePatch(input) {
23753
- const { mergeCommit, firstParent, lastGen, lock } = input;
23754
- const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
23755
- const filesOutput = await this.git.exec([
23756
- "diff",
23757
- "--name-only",
23758
- firstParent,
23759
- mergeCommit.sha
23760
- ]);
23761
- const allMergeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f2) => !INFRASTRUCTURE_FILES.has(f2)).filter((f2) => !f2.startsWith(".fern/"));
23762
- const sensitiveMergeFiles = allMergeFiles.filter((f2) => isSensitiveFile(f2));
23763
- const files = allMergeFiles.filter((f2) => !isSensitiveFile(f2));
23764
- if (sensitiveMergeFiles.length > 0) {
23765
- this.warnings.push(
23766
- `Sensitive file(s) excluded from merge patch for commit ${mergeCommit.sha.slice(0, 7)}: ${sensitiveMergeFiles.join(", ")}. These files will not be tracked by replay.`
23767
- );
23768
- }
23769
- if (files.length === 0) return null;
23770
- const diff = await this.git.exec([
23771
- "diff",
23772
- firstParent,
23773
- mergeCommit.sha,
23774
- "--",
23775
- ...files
23776
- ]);
23777
- if (!diff.trim()) return null;
23778
- const contentHash = this.computeContentHash(diff);
23779
- if (lock.patches.some((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
23780
- return null;
23781
- }
23782
- return {
23783
- id: `patch-merge-${mergeCommit.sha.slice(0, 8)}`,
23784
- content_hash: contentHash,
23785
- original_commit: mergeCommit.sha,
23786
- original_message: mergeCommit.message,
23787
- original_author: `${mergeCommit.authorName} <${mergeCommit.authorEmail}>`,
23788
- base_generation: lastGen.commit_sha,
23789
- files,
23790
- patch_content: diff,
23791
- ...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
23792
- };
23793
- }
23794
- /**
23795
- * Detect patches via tree diff for non-linear history. Returns a composite patch.
23796
- * Revert reconciliation is skipped here because tree-diff produces a single composite
23797
- * patch from the aggregate diff — individual revert commits are not distinguishable.
23798
- */
23799
- async detectPatchesViaTreeDiff(lastGen, commitKnownMissing) {
23800
- const diffBase = await this.resolveDiffBase(lastGen, commitKnownMissing);
23801
- if (!diffBase) {
23802
- return this.detectPatchesViaCommitScan();
23803
- }
23804
- const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
23805
- const allTreeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f2) => !INFRASTRUCTURE_FILES.has(f2)).filter((f2) => !f2.startsWith(".fern/"));
23806
- const sensitiveTreeFiles = allTreeFiles.filter((f2) => isSensitiveFile(f2));
23807
- const files = allTreeFiles.filter((f2) => !isSensitiveFile(f2));
23808
- if (sensitiveTreeFiles.length > 0) {
23809
- this.warnings.push(
23810
- `Sensitive file(s) excluded from composite patch: ${sensitiveTreeFiles.join(", ")}. These files will not be tracked by replay.`
23811
- );
23812
- }
23813
- if (files.length === 0) return { patches: [], revertedPatchIds: [] };
23814
- const diff = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
23815
- if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
23816
- const contentHash = this.computeContentHash(diff);
23817
- const lock = this.lockManager.read();
23818
- if (lock.patches.some((p) => p.content_hash === contentHash) || (lock.forgotten_hashes ?? []).includes(contentHash)) {
23819
- return { patches: [], revertedPatchIds: [] };
23820
- }
23821
- const headSha = (await this.git.exec(["rev-parse", "HEAD"])).trim();
23822
- const compositePatch = {
23823
- id: `patch-composite-${headSha.slice(0, 8)}`,
23824
- content_hash: contentHash,
23825
- original_commit: headSha,
23826
- original_message: "Customer customizations (composite)",
23827
- original_author: "composite",
23828
- // Use diffBase when commit is unreachable — the applicator needs a reachable
23829
- // reference to find base file content. diffBase may be the tree_hash.
23830
- base_generation: commitKnownMissing ? diffBase : lastGen.commit_sha,
23831
- files,
23832
- patch_content: diff,
23833
- ...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
23834
- };
23835
- return { patches: [compositePatch], revertedPatchIds: [] };
23836
- }
23837
- /**
23838
- * Last-resort detection when both generation commit and tree are unreachable.
23839
- * Scans all commits from HEAD, filters against known lockfile patches, and
23840
- * skips creation-only commits (squashed history after force push).
23841
- *
23842
- * Detected patches use the commit's parent as base_generation so the applicator
23843
- * can find base file content from the parent's tree (which IS reachable).
23844
- */
23845
- async detectPatchesViaCommitScan() {
23846
- const lock = this.lockManager.read();
23847
- const log = await this.git.exec([
23848
- "log",
23849
- "--max-count=200",
23850
- "--format=%H%x00%an%x00%ae%x00%s",
23851
- "HEAD",
23852
- "--",
23853
- this.sdkOutputDir
23854
- ]);
23855
- if (!log.trim()) {
23856
- return { patches: [], revertedPatchIds: [] };
23857
- }
23858
- const commits = this.parseGitLog(log);
23859
- const newPatches = [];
23860
- const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
23861
- const existingHashes = new Set(lock.patches.map((p) => p.content_hash));
23862
- const existingCommits = new Set(lock.patches.map((p) => p.original_commit));
23863
- for (const commit of commits) {
23864
- if (isGenerationCommit(commit)) {
23865
- continue;
23866
- }
23867
- const parents = await this.git.getCommitParents(commit.sha);
23868
- if (parents.length > 1) {
23869
- continue;
23870
- }
23871
- if (existingCommits.has(commit.sha)) {
23872
- continue;
23873
- }
23874
- let patchContent;
23875
- try {
23876
- patchContent = await this.git.formatPatch(commit.sha);
23877
- } catch {
23878
- continue;
23879
- }
23880
- let contentHash = this.computeContentHash(patchContent);
23881
- if (existingHashes.has(contentHash) || forgottenHashes.has(contentHash)) {
23882
- continue;
23883
- }
23884
- if (this.isCreationOnlyPatch(patchContent)) {
23885
- continue;
23886
- }
23887
- const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
23888
- const allScanFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f2) => !INFRASTRUCTURE_FILES.has(f2));
23889
- const sensitiveScanFiles = allScanFiles.filter((f2) => isSensitiveFile(f2));
23890
- const files = allScanFiles.filter((f2) => !isSensitiveFile(f2));
23891
- if (sensitiveScanFiles.length > 0) {
23892
- this.warnings.push(
23893
- `Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveScanFiles.join(", ")}. These files will not be tracked by replay.`
23894
- );
23895
- if (files.length > 0) {
23896
- if (parents.length === 0) {
23897
- continue;
23898
- }
23899
- try {
23900
- patchContent = await this.git.exec(["diff", parents[0], commit.sha, "--", ...files]);
23901
- } catch {
23902
- continue;
23903
- }
23904
- if (!patchContent.trim()) continue;
23905
- contentHash = this.computeContentHash(patchContent);
23906
- }
23907
- }
23908
- if (files.length === 0) {
23909
- continue;
23910
- }
23911
- if (parents.length === 0) {
23912
- continue;
23913
- }
23914
- const parentSha = parents[0];
23915
- newPatches.push({
23916
- id: `patch-${commit.sha.slice(0, 8)}`,
23917
- content_hash: contentHash,
23918
- original_commit: commit.sha,
23919
- original_message: commit.message,
23920
- original_author: `${commit.authorName} <${commit.authorEmail}>`,
23921
- base_generation: parentSha,
23922
- files,
23923
- patch_content: patchContent
23924
- });
23925
- }
23926
- newPatches.reverse();
23927
- return { patches: newPatches, revertedPatchIds: [] };
23928
- }
23929
- /**
23930
- * Check if a format-patch consists entirely of new-file creations.
23931
- * Used to identify squashed commits after force push, which create all files
23932
- * from scratch (--- /dev/null) rather than modifying existing files.
23933
- */
23934
- isCreationOnlyPatch(patchContent) {
23935
- const diffOldHeaders = patchContent.split("\n").filter((l) => l.startsWith("--- "));
23936
- if (diffOldHeaders.length === 0) {
23937
- return false;
23938
- }
23939
- return diffOldHeaders.every((l) => l === "--- /dev/null");
23940
- }
23941
- /**
23942
- * Resolve the best available diff base for a generation record.
23943
- * Prefers commit_sha, falls back to tree_hash for unreachable commits.
23944
- * When commitKnownMissing is true, skips the redundant commitExists check.
23945
- */
23946
- async resolveDiffBase(gen, commitKnownMissing) {
23947
- if (!commitKnownMissing && await this.git.commitExists(gen.commit_sha)) {
23948
- return gen.commit_sha;
23949
- }
23950
- if (await this.git.treeExists(gen.tree_hash)) {
23951
- return gen.tree_hash;
23952
- }
23953
- return null;
23954
- }
23955
- parseGitLog(log) {
23956
- return log.trim().split("\n").map((line) => {
23957
- const [sha, authorName, authorEmail, message] = line.split("\0");
23958
- return { sha, authorName, authorEmail, message };
23959
- });
23960
- }
23961
- getLastGeneration(lock) {
23962
- return lock.generations.find((g2) => g2.commit_sha === lock.current_generation);
23963
- }
23964
- };
23965
- CONFLICT_OPENER = "<<<<<<< Generated";
23966
- CONFLICT_SEPARATOR = "=======";
23967
- CONFLICT_CLOSER = ">>>>>>> Your customization";
23968
- BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
23969
- ".png",
23970
- ".jpg",
23971
- ".jpeg",
23972
- ".gif",
23973
- ".bmp",
23974
- ".ico",
23975
- ".webp",
23976
- ".svg",
23977
- ".pdf",
23978
- ".doc",
23979
- ".docx",
23980
- ".xls",
23981
- ".xlsx",
23982
- ".ppt",
23983
- ".pptx",
23984
- ".zip",
23985
- ".gz",
23986
- ".tar",
23987
- ".bz2",
23988
- ".7z",
23989
- ".rar",
23990
- ".jar",
23991
- ".war",
23992
- ".ear",
23993
- ".class",
23994
- ".exe",
23995
- ".dll",
23996
- ".so",
23997
- ".dylib",
23998
- ".o",
23999
- ".a",
24000
- ".woff",
24001
- ".woff2",
24002
- ".ttf",
24003
- ".eot",
24004
- ".otf",
24005
- ".mp3",
24006
- ".mp4",
24007
- ".avi",
24008
- ".mov",
24009
- ".wav",
24010
- ".flac",
24011
- ".sqlite",
24012
- ".db",
24013
- ".pyc",
24014
- ".pyo",
24015
- ".DS_Store"
24016
- ]);
24017
- ReplayApplicator = class {
24018
- git;
24019
- lockManager;
24020
- outputDir;
24021
- renameCache = /* @__PURE__ */ new Map();
24022
- treeExistsCache = /* @__PURE__ */ new Map();
24023
- fileTheirsAccumulator = /* @__PURE__ */ new Map();
24024
- constructor(git, lockManager, outputDir) {
24025
- this.git = git;
24026
- this.lockManager = lockManager;
24027
- this.outputDir = outputDir;
24028
- }
24029
- /**
24030
- * Resolve the GenerationRecord for a patch's base_generation.
24031
- * Falls back to constructing an ad-hoc record from the commit's tree
24032
- * when base_generation isn't a tracked generation (commit-scan patches).
24033
- */
24034
- async resolveBaseGeneration(baseGeneration) {
24035
- const gen = this.lockManager.getGeneration(baseGeneration);
24036
- if (gen) return gen;
24037
- try {
24038
- const treeHash = await this.git.getTreeHash(baseGeneration);
24039
- return {
24040
- commit_sha: baseGeneration,
24041
- tree_hash: treeHash,
24042
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
24043
- cli_version: "unknown",
24044
- generator_versions: {}
24045
- };
24046
- } catch {
24047
- return void 0;
24048
- }
24049
- }
24050
- /** Reset inter-patch accumulator for a new cycle. */
24051
- resetAccumulator() {
24052
- this.fileTheirsAccumulator.clear();
24053
- }
24054
- /**
24055
- * @internal Test-only.
24056
- * Returns a defensive copy of the inter-patch accumulator. Used by the
24057
- * adversarial test suite (FER-9791) to assert invariant I2: after every
24058
- * applied patch, the accumulator has an entry for each file the patch
24059
- * touched, and the entry's content matches on-disk.
24060
- */
24061
- getAccumulatorSnapshot() {
24062
- const snapshot = /* @__PURE__ */ new Map();
24063
- for (const [path5, entry] of this.fileTheirsAccumulator) {
24064
- snapshot.set(path5, { content: entry.content, baseGeneration: entry.baseGeneration });
24065
- }
24066
- return snapshot;
24067
- }
24068
- /**
24069
- * Apply all patches, returning results for each.
24070
- * Skips patches that match exclude patterns in replay.yml
24071
- */
24072
- async applyPatches(patches) {
24073
- this.resetAccumulator();
24074
- const results = [];
24075
- for (let i = 0; i < patches.length; i++) {
24076
- const patch = patches[i];
24077
- if (this.isExcluded(patch)) {
24078
- results.push({
24079
- patch,
24080
- status: "skipped",
24081
- method: "git-am"
24082
- });
24083
- continue;
24084
- }
24085
- const result = await this.applyPatchWithFallback(patch);
24086
- results.push(result);
24087
- if (result.status === "conflict" && result.fileResults) {
24088
- const laterFiles = /* @__PURE__ */ new Set();
24089
- for (let j2 = i + 1; j2 < patches.length; j2++) {
24090
- for (const f2 of patches[j2].files) {
24091
- laterFiles.add(f2);
24092
- }
24093
- }
24094
- const resolvedToOriginal = /* @__PURE__ */ new Map();
24095
- if (result.resolvedFiles) {
24096
- for (const [orig, resolved] of Object.entries(result.resolvedFiles)) {
24097
- resolvedToOriginal.set(resolved, orig);
24098
- }
24099
- }
24100
- for (const fileResult of result.fileResults) {
24101
- if (fileResult.status !== "conflict") continue;
24102
- const originalPath = resolvedToOriginal.get(fileResult.file) ?? fileResult.file;
24103
- if (laterFiles.has(fileResult.file) || laterFiles.has(originalPath)) {
24104
- const filePath = (0, import_path8.join)(this.outputDir, fileResult.file);
24105
- try {
24106
- const content = await (0, import_promises3.readFile)(filePath, "utf-8");
24107
- const stripped = stripConflictMarkers(content);
24108
- await (0, import_promises3.writeFile)(filePath, stripped);
24109
- } catch {
24110
- }
24111
- }
24112
- }
24113
- }
24114
- }
24115
- return results;
24116
- }
24117
- /** Populate accumulator after git apply succeeds. */
24118
- async populateAccumulatorForPatch(patch, baseGen, currentTreeHash) {
24119
- if (!baseGen) return;
24120
- const tempDir = await (0, import_promises3.mkdtemp)((0, import_path8.join)((0, import_os.tmpdir)(), "replay-acc-"));
24121
- const { GitClient: GitClient2 } = await Promise.resolve().then(() => (init_GitClient(), GitClient_exports));
24122
- const tempGit = new GitClient2(tempDir);
24123
- await tempGit.exec(["init"]);
24124
- await tempGit.exec(["config", "user.email", "replay@fern.com"]);
24125
- await tempGit.exec(["config", "user.name", "Fern Replay"]);
24126
- try {
24127
- for (const filePath of patch.files) {
24128
- if (isBinaryFile(filePath)) continue;
24129
- const resolvedPath = await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
24130
- const base = await this.git.showFile(baseGen.tree_hash, filePath);
24131
- const theirs = await this.applyPatchToContent(base, patch.patch_content, filePath, tempGit, tempDir);
24132
- const effectiveTheirs = theirs ?? await (0, import_promises3.readFile)((0, import_path8.join)(this.outputDir, resolvedPath), "utf-8").catch(() => null);
24133
- if (effectiveTheirs != null) {
24134
- this.fileTheirsAccumulator.set(resolvedPath, {
24135
- content: effectiveTheirs,
24136
- baseGeneration: patch.base_generation
24137
- });
24138
- }
24139
- }
24140
- } finally {
24141
- await (0, import_promises3.rm)(tempDir, { recursive: true }).catch(() => {
24142
- });
24143
- }
24144
- }
24145
- async applyPatchWithFallback(patch) {
24146
- const baseGen = await this.resolveBaseGeneration(patch.base_generation);
24147
- const lock = this.lockManager.read();
24148
- const currentGen = lock.generations.find((g2) => g2.commit_sha === lock.current_generation);
24149
- const currentTreeHash = currentGen?.tree_hash ?? baseGen?.tree_hash ?? "";
24150
- const needsAccumulation = await Promise.all(
24151
- patch.files.map(async (f2) => {
24152
- if (!baseGen) return false;
24153
- const resolved = await this.resolveFilePath(f2, baseGen.tree_hash, currentTreeHash);
24154
- return this.fileTheirsAccumulator.has(resolved);
24155
- })
24156
- ).then((results) => results.some(Boolean));
24157
- const resolvedFiles = {};
24158
- for (const filePath of patch.files) {
24159
- const resolvedPath = baseGen ? await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash) : filePath;
24160
- if (resolvedPath !== filePath) {
24161
- resolvedFiles[filePath] = resolvedPath;
24162
- }
24163
- }
24164
- const patchIsRenameAware = /^rename from /m.test(patch.patch_content);
24165
- const hasGeneratorRename = Object.keys(resolvedFiles).length > 0 && !patchIsRenameAware;
24166
- if (!needsAccumulation && !hasGeneratorRename) {
24167
- const snapshots = /* @__PURE__ */ new Map();
24168
- for (const filePath of patch.files) {
24169
- const fullPath = (0, import_path8.join)(this.outputDir, filePath);
24170
- snapshots.set(filePath, await (0, import_promises3.readFile)(fullPath, "utf-8").catch(() => null));
24171
- }
24172
- try {
24173
- await this.git.execWithInput(["apply", "--3way"], patch.patch_content);
24174
- await this.populateAccumulatorForPatch(patch, baseGen, currentTreeHash);
24175
- return {
24176
- patch,
24177
- status: "applied",
24178
- method: "git-am",
24179
- ...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
24180
- };
24181
- } catch {
24182
- for (const [filePath, content] of snapshots) {
24183
- const fullPath = (0, import_path8.join)(this.outputDir, filePath);
24184
- if (content != null) {
24185
- await (0, import_promises3.writeFile)(fullPath, content);
24186
- } else {
24187
- await (0, import_promises3.unlink)(fullPath).catch(() => {
24188
- });
24189
- }
24190
- }
24191
- }
24192
- }
24193
- return this.applyWithThreeWayMerge(patch);
24194
- }
24195
- async applyWithThreeWayMerge(patch) {
24196
- const fileResults = [];
24197
- const resolvedFiles = {};
24198
- const tempDir = await (0, import_promises3.mkdtemp)((0, import_path8.join)((0, import_os.tmpdir)(), "replay-"));
24199
- const { GitClient: GitClient2 } = await Promise.resolve().then(() => (init_GitClient(), GitClient_exports));
24200
- const tempGit = new GitClient2(tempDir);
24201
- await tempGit.exec(["init"]);
24202
- await tempGit.exec(["config", "user.email", "replay@fern.com"]);
24203
- await tempGit.exec(["config", "user.name", "Fern Replay"]);
24204
- try {
24205
- for (const filePath of patch.files) {
24206
- if (isBinaryFile(filePath)) {
24207
- fileResults.push({
24208
- file: filePath,
24209
- status: "skipped",
24210
- reason: "binary-file"
24211
- });
24212
- continue;
24213
- }
24214
- const result = await this.mergeFile(patch, filePath, tempGit, tempDir);
24215
- if (result.file !== filePath) {
24216
- resolvedFiles[filePath] = result.file;
24217
- }
24218
- fileResults.push(result);
24219
- }
24220
- } finally {
24221
- await (0, import_promises3.rm)(tempDir, { recursive: true }).catch(() => {
24222
- });
24223
- }
24224
- const conflictFiles = fileResults.filter((r2) => r2.status === "conflict");
24225
- const hasConflicts = conflictFiles.length > 0;
24226
- const conflictReason = hasConflicts ? conflictFiles.some((f2) => f2.conflictReason === "base-generation-mismatch") ? "base-generation-mismatch" : conflictFiles[0]?.conflictReason : void 0;
24227
- return {
24228
- patch,
24229
- status: hasConflicts ? "conflict" : "applied",
24230
- method: "3way-merge",
24231
- fileResults,
24232
- conflictReason,
24233
- ...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
24234
- };
24235
- }
24236
- async mergeFile(patch, filePath, tempGit, tempDir) {
24237
- try {
24238
- const baseGen = await this.resolveBaseGeneration(patch.base_generation);
24239
- if (!baseGen) {
24240
- return { file: filePath, status: "skipped", reason: "base-generation-not-found" };
24241
- }
24242
- const lock = this.lockManager.read();
24243
- const currentGen = lock.generations.find((g2) => g2.commit_sha === lock.current_generation);
24244
- const currentTreeHash = currentGen?.tree_hash ?? baseGen.tree_hash;
24245
- const resolvedPath = await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
24246
- const metadata = {
24247
- patchId: patch.id,
24248
- patchMessage: patch.original_message,
24249
- baseGeneration: patch.base_generation,
24250
- currentGeneration: lock.current_generation
24251
- };
24252
- let base = await this.git.showFile(baseGen.tree_hash, filePath);
24253
- let renameSourcePath;
24254
- if (!base) {
24255
- const renameSource = this.extractRenameSource(patch.patch_content, filePath);
24256
- if (renameSource) {
24257
- base = await this.git.showFile(baseGen.tree_hash, renameSource);
24258
- renameSourcePath = renameSource;
24259
- }
24260
- }
24261
- const oursPath = (0, import_path8.join)(this.outputDir, resolvedPath);
24262
- const ours = await (0, import_promises3.readFile)(oursPath, "utf-8").catch(() => null);
24263
- let ghostReconstructed = false;
24264
- let theirs = null;
24265
- if (!base && ours && !renameSourcePath) {
24266
- const treeReachable = await this.isTreeReachable(baseGen.tree_hash);
24267
- if (!treeReachable) {
24268
- const fileDiff = this.extractFileDiff(patch.patch_content, filePath);
24269
- if (fileDiff) {
24270
- const { reconstructFromGhostPatch: reconstructFromGhostPatch2 } = await Promise.resolve().then(() => (init_HybridReconstruction(), HybridReconstruction_exports));
24271
- const result = reconstructFromGhostPatch2(fileDiff, ours);
24272
- if (result) {
24273
- base = result.base;
24274
- theirs = result.theirs;
24275
- ghostReconstructed = true;
24276
- }
24277
- }
24278
- }
24279
- }
24280
- if (!ghostReconstructed) {
24281
- theirs = await this.applyPatchToContent(
24282
- base,
24283
- patch.patch_content,
24284
- filePath,
24285
- tempGit,
24286
- tempDir,
24287
- renameSourcePath
24288
- );
24289
- }
24290
- if (theirs) {
24291
- const theirsHasMarkers = theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization");
24292
- const baseHasMarkers = base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization"));
24293
- if (theirsHasMarkers && !baseHasMarkers) {
24294
- return {
24295
- file: resolvedPath,
24296
- status: "skipped",
24297
- reason: "stale-conflict-markers"
24298
- };
24299
- }
24366
+ const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
24367
+ if (theirs) {
24368
+ const theirsHasMarkers = theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization");
24369
+ const baseHasMarkers = base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization"));
24370
+ if (theirsHasMarkers && !baseHasMarkers) {
24371
+ if (accumulatorEntry) {
24372
+ theirs = null;
24373
+ } else {
24374
+ return {
24375
+ file: resolvedPath,
24376
+ status: "skipped",
24377
+ reason: "stale-conflict-markers"
24378
+ };
24379
+ }
24380
+ }
24300
24381
  }
24301
24382
  let useAccumulatorAsMergeBase = false;
24302
- const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
24303
24383
  if (accumulatorEntry && (theirs == null || base == null)) {
24304
24384
  theirs = await this.applyPatchToContent(
24305
24385
  accumulatorEntry.content,
@@ -24355,16 +24435,16 @@ var init_dist3 = __esm({
24355
24435
  content: effective_theirs,
24356
24436
  baseGeneration: patch.base_generation
24357
24437
  });
24358
- const outDir2 = (0, import_path8.dirname)(oursPath);
24359
- await (0, import_promises3.mkdir)(outDir2, { recursive: true });
24360
- 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);
24361
24441
  return { file: resolvedPath, status: "merged", reason: "new-file" };
24362
24442
  }
24363
24443
  if (base == null && ours != null && effective_theirs != null && !useAccumulatorAsMergeBase) {
24364
24444
  const merged2 = threeWayMerge("", ours, effective_theirs);
24365
- const outDir2 = (0, import_path8.dirname)(oursPath);
24366
- await (0, import_promises3.mkdir)(outDir2, { recursive: true });
24367
- 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);
24368
24448
  if (merged2.hasConflicts) {
24369
24449
  return {
24370
24450
  file: resolvedPath,
@@ -24402,205 +24482,773 @@ var init_dist3 = __esm({
24402
24482
  reason: "missing-content"
24403
24483
  };
24404
24484
  }
24405
- const merged = threeWayMerge(mergeBase, ours, effective_theirs);
24406
- const outDir = (0, import_path8.dirname)(oursPath);
24407
- await (0, import_promises3.mkdir)(outDir, { recursive: true });
24408
- await (0, import_promises3.writeFile)(oursPath, merged.content);
24409
- if (effective_theirs != null && !merged.hasConflicts) {
24410
- this.fileTheirsAccumulator.set(resolvedPath, {
24411
- content: effective_theirs,
24412
- baseGeneration: patch.base_generation
24413
- });
24485
+ const merged = threeWayMerge(mergeBase, ours, effective_theirs);
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);
24489
+ const populateAccumulator = effective_theirs != null && (!merged.hasConflicts || this.currentApplyMode === "resolve");
24490
+ if (populateAccumulator) {
24491
+ this.fileTheirsAccumulator.set(resolvedPath, {
24492
+ content: effective_theirs,
24493
+ baseGeneration: patch.base_generation
24494
+ });
24495
+ }
24496
+ if (merged.hasConflicts) {
24497
+ return {
24498
+ file: resolvedPath,
24499
+ status: "conflict",
24500
+ conflicts: merged.conflicts,
24501
+ conflictReason: baseMismatchSkipped ? "base-generation-mismatch" : "same-line-edit",
24502
+ conflictMetadata: metadata
24503
+ };
24504
+ }
24505
+ return {
24506
+ file: resolvedPath,
24507
+ status: "merged",
24508
+ ...merged.droppedContextLines && merged.droppedContextLines.length > 0 ? { droppedContextLines: merged.droppedContextLines } : {}
24509
+ };
24510
+ } catch (error) {
24511
+ return {
24512
+ file: filePath,
24513
+ status: "skipped",
24514
+ reason: `error: ${error instanceof Error ? error.message : String(error)}`
24515
+ };
24516
+ }
24517
+ }
24518
+ async isTreeReachable(treeHash) {
24519
+ let result = this.treeExistsCache.get(treeHash);
24520
+ if (result === void 0) {
24521
+ result = await this.git.treeExists(treeHash);
24522
+ this.treeExistsCache.set(treeHash, result);
24523
+ }
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] };
24915
+ }
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")}`;
24939
+ }
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);
24980
+ }
24981
+ }
24982
+ let anyCandidate = false;
24983
+ for (const state of stateByFile.values()) {
24984
+ if (state.created && state.deleted) {
24985
+ anyCandidate = true;
24986
+ break;
24414
24987
  }
24415
- if (merged.hasConflicts) {
24416
- return {
24417
- file: resolvedPath,
24418
- status: "conflict",
24419
- conflicts: merged.conflicts,
24420
- conflictReason: baseMismatchSkipped ? "base-generation-mismatch" : "same-line-edit",
24421
- conflictMetadata: metadata
24422
- };
24988
+ }
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);
24423
24995
  }
24424
- return { file: resolvedPath, status: "merged" };
24425
- } catch (error) {
24426
- return {
24427
- file: filePath,
24428
- status: "skipped",
24429
- reason: `error: ${error instanceof Error ? error.message : String(error)}`
24430
- };
24431
24996
  }
24997
+ if (transient.size === 0) return patches;
24998
+ return patches.filter((patch) => !patch.files.every((f2) => transient.has(f2)));
24432
24999
  }
24433
- async isTreeReachable(treeHash) {
24434
- let result = this.treeExistsCache.get(treeHash);
24435
- if (result === void 0) {
24436
- result = await this.git.treeExists(treeHash);
24437
- this.treeExistsCache.set(treeHash, result);
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() {
25007
+ try {
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));
25010
+ } catch {
25011
+ return /* @__PURE__ */ new Set();
24438
25012
  }
24439
- return result;
24440
- }
24441
- isExcluded(patch) {
24442
- const config = this.lockManager.getCustomizationsConfig();
24443
- if (!config.exclude) return false;
24444
- return patch.files.some((file) => config.exclude.some((pattern) => minimatch(file, pattern)));
24445
25013
  }
24446
- async resolveFilePath(filePath, baseTreeHash, currentTreeHash) {
24447
- const config = this.lockManager.getCustomizationsConfig();
24448
- if (config.moves) {
24449
- for (const move of config.moves) {
24450
- if (minimatch(filePath, move.from) || filePath === move.from) {
24451
- if (filePath === move.from) {
24452
- return move.to;
24453
- }
24454
- const fromBase = move.from.replace(/\*\*.*$/, "");
24455
- const toBase = move.to.replace(/\*\*.*$/, "");
24456
- if (filePath.startsWith(fromBase)) {
24457
- return toBase + filePath.slice(fromBase.length);
24458
- }
24459
- }
24460
- }
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
+ );
24461
25036
  }
24462
- const cacheKey = `${baseTreeHash}:${currentTreeHash}`;
24463
- let renames = this.renameCache.get(cacheKey);
24464
- if (!renames) {
24465
- renames = await this.git.detectRenames(baseTreeHash, currentTreeHash);
24466
- this.renameCache.set(cacheKey, renames);
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)) {
25048
+ return null;
24467
25049
  }
24468
- const gitRename = renames.find((r2) => r2.from === filePath);
24469
- if (gitRename) {
24470
- return gitRename.to;
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;
24471
25055
  }
24472
- return filePath;
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
+ };
24473
25068
  }
24474
- async applyPatchToContent(base, patchContent, filePath, tempGit, tempDir, sourceFilePath) {
24475
- if (!base) {
24476
- return this.extractNewFileFromPatch(patchContent, filePath);
25069
+ /**
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.
25073
+ */
25074
+ async detectPatchesViaTreeDiff(lastGen, commitKnownMissing) {
25075
+ const diffBase = await this.resolveDiffBase(lastGen, commitKnownMissing);
25076
+ if (!diffBase) {
25077
+ return this.detectPatchesViaCommitScan();
24477
25078
  }
24478
- const fileDiff = this.extractFileDiff(patchContent, filePath);
24479
- if (!fileDiff) return null;
24480
- try {
24481
- if (sourceFilePath) {
24482
- const tempSourcePath = (0, import_path8.join)(tempDir, sourceFilePath);
24483
- await (0, import_promises3.mkdir)((0, import_path8.dirname)(tempSourcePath), { recursive: true });
24484
- await (0, import_promises3.writeFile)(tempSourcePath, base);
24485
- await tempGit.exec(["add", sourceFilePath]);
24486
- await tempGit.exec([
24487
- "commit",
24488
- "-m",
24489
- `base for rename ${sourceFilePath} -> ${filePath}`,
24490
- "--allow-empty"
24491
- ]);
24492
- await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
24493
- const tempTargetPath = (0, import_path8.join)(tempDir, filePath);
24494
- return await (0, import_promises3.readFile)(tempTargetPath, "utf-8");
24495
- }
24496
- const tempFilePath = (0, import_path8.join)(tempDir, filePath);
24497
- await (0, import_promises3.mkdir)((0, import_path8.dirname)(tempFilePath), { recursive: true });
24498
- await (0, import_promises3.writeFile)(tempFilePath, base);
24499
- await tempGit.exec(["add", filePath]);
24500
- await tempGit.exec(["commit", "-m", `base for ${filePath}`, "--allow-empty"]);
24501
- await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
24502
- return await (0, import_promises3.readFile)(tempFilePath, "utf-8");
24503
- } catch {
24504
- return null;
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;
24505
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: [] };
24506
25118
  }
24507
25119
  /**
24508
- * Detects whether a patch's additions are already present in `content`.
24509
- * Uses `git apply --reverse --check` — if the reverse patch applies cleanly,
24510
- * the forward patch is effectively a no-op (its +lines are already there and
24511
- * its -lines are already gone). Used to distinguish "patch already applied"
24512
- * from "patch base mismatch" after a forward-apply fails.
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).
24513
25126
  */
24514
- async isPatchAlreadyApplied(patchContent, filePath, content, tempGit, tempDir) {
24515
- const fileDiff = this.extractFileDiff(patchContent, filePath);
24516
- if (!fileDiff) return false;
24517
- try {
24518
- const tempFilePath = (0, import_path8.join)(tempDir, filePath);
24519
- await (0, import_promises3.mkdir)((0, import_path8.dirname)(tempFilePath), { recursive: true });
24520
- await (0, import_promises3.writeFile)(tempFilePath, content);
24521
- await tempGit.exec(["add", filePath]);
24522
- await tempGit.exec(["commit", "-m", `check already-applied ${filePath}`, "--allow-empty"]);
24523
- await tempGit.execWithInput(["apply", "--reverse", "--check", "--allow-empty"], fileDiff);
24524
- return true;
24525
- } catch {
24526
- return false;
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: [] };
24527
25139
  }
24528
- }
24529
- extractFileDiff(patchContent, filePath) {
24530
- const lines = patchContent.split("\n");
24531
- const diffLines = [];
24532
- let inTargetFile = false;
24533
- for (const line of lines) {
24534
- if (line.startsWith("diff --git")) {
24535
- if (inTargetFile) {
24536
- break;
24537
- }
24538
- if (isDiffLineForFile(line, filePath)) {
24539
- inTargetFile = true;
24540
- diffLines.push(line);
24541
- }
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)) {
24542
25147
  continue;
24543
25148
  }
24544
- if (inTargetFile) {
24545
- diffLines.push(line);
25149
+ const parents = await this.git.getCommitParents(commit.sha);
25150
+ if (parents.length > 1) {
25151
+ continue;
24546
25152
  }
24547
- }
24548
- return diffLines.length > 0 ? diffLines.join("\n") + "\n" : null;
24549
- }
24550
- extractRenameSource(patchContent, targetFilePath) {
24551
- const lines = patchContent.split("\n");
24552
- let inTargetFile = false;
24553
- for (const line of lines) {
24554
- if (line.startsWith("diff --git")) {
24555
- if (inTargetFile) break;
24556
- inTargetFile = isDiffLineForFile(line, targetFilePath);
25153
+ if (existingCommits.has(commit.sha)) {
24557
25154
  continue;
24558
25155
  }
24559
- if (!inTargetFile) continue;
24560
- if (line.startsWith("@@")) break;
24561
- if (line.startsWith("rename from ")) {
24562
- return line.slice("rename from ".length);
25156
+ let patchContent;
25157
+ try {
25158
+ patchContent = await this.git.formatPatch(commit.sha);
25159
+ } catch {
25160
+ continue;
24563
25161
  }
24564
- }
24565
- return null;
24566
- }
24567
- extractNewFileFromPatch(patchContent, filePath) {
24568
- const lines = patchContent.split("\n");
24569
- const addedLines = [];
24570
- let inTargetFile = false;
24571
- let inHunk = false;
24572
- let isNewFile = false;
24573
- let noTrailingNewline = false;
24574
- for (const line of lines) {
24575
- if (line.startsWith("diff --git")) {
24576
- if (inTargetFile) break;
24577
- inTargetFile = isDiffLineForFile(line, filePath);
24578
- inHunk = false;
24579
- isNewFile = false;
25162
+ let contentHash = this.computeContentHash(patchContent);
25163
+ if (existingHashes.has(contentHash) || forgottenHashes.has(contentHash)) {
24580
25164
  continue;
24581
25165
  }
24582
- if (!inTargetFile) continue;
24583
- if (!inHunk) {
24584
- if (line === "--- /dev/null" || line.startsWith("new file mode")) {
24585
- 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);
24586
25188
  }
24587
25189
  }
24588
- if (line.startsWith("@@")) {
24589
- if (!isNewFile) return null;
24590
- inHunk = true;
25190
+ if (files.length === 0) {
24591
25191
  continue;
24592
25192
  }
24593
- if (!inHunk) continue;
24594
- if (line === "\") {
24595
- noTrailingNewline = true;
25193
+ if (parents.length === 0) {
24596
25194
  continue;
24597
25195
  }
24598
- if (line.startsWith("+") && !line.startsWith("+++")) {
24599
- 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;
24600
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
+ });
24601
25214
  }
24602
- if (addedLines.length === 0) return null;
24603
- 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);
24604
25252
  }
24605
25253
  };
24606
25254
  ReplayCommitter = class {
@@ -24632,13 +25280,46 @@ CLI Version: ${options.cliVersion}`;
24632
25280
  await this.git.exec(["commit", "-m", fullMessage]);
24633
25281
  return (await this.git.exec(["rev-parse", "HEAD"])).trim();
24634
25282
  }
24635
- async commitReplay(_patchCount, patches, message) {
25283
+ async commitReplay(_patchCount, patches, message, options) {
24636
25284
  await this.stageAll();
24637
25285
  if (!await this.hasStagedChanges()) {
24638
25286
  return (await this.git.exec(["rev-parse", "HEAD"])).trim();
24639
25287
  }
24640
25288
  let fullMessage = message ?? `[fern-replay] Applied customizations`;
24641
- if (patches && patches.length > 0) {
25289
+ const buckets = options?.buckets;
25290
+ if (buckets) {
25291
+ if (buckets.applied.length > 0) {
25292
+ fullMessage += `
25293
+
25294
+ Patches applied (${buckets.applied.length}):`;
25295
+ for (const p of buckets.applied) {
25296
+ fullMessage += `
25297
+ - ${p.id}: ${p.original_message}`;
25298
+ }
25299
+ }
25300
+ if (buckets.unresolved.length > 0) {
25301
+ fullMessage += `
25302
+
25303
+ Patches with unresolved conflicts (${buckets.unresolved.length}):`;
25304
+ for (const p of buckets.unresolved) {
25305
+ fullMessage += `
25306
+ - ${p.id}: ${p.original_message}`;
25307
+ }
25308
+ fullMessage += `
25309
+ Run \`fern-replay resolve\` to apply these customizations.`;
25310
+ }
25311
+ if (buckets.absorbed.length > 0) {
25312
+ fullMessage += `
25313
+
25314
+ Patches absorbed by generator (${buckets.absorbed.length}):`;
25315
+ for (const p of buckets.absorbed) {
25316
+ fullMessage += `
25317
+ - ${p.id}: ${p.original_message}`;
25318
+ }
25319
+ fullMessage += `
25320
+ The generator now produces these customizations natively.`;
25321
+ }
25322
+ } else if (patches && patches.length > 0) {
24642
25323
  fullMessage += "\n\nPatches replayed:";
24643
25324
  for (const patch of patches) {
24644
25325
  fullMessage += `
@@ -24672,6 +25353,7 @@ CLI Version: ${options.cliVersion}`;
24672
25353
  }
24673
25354
  };
24674
25355
  init_GitClient();
25356
+ init_HybridReconstruction();
24675
25357
  ReplayService = class {
24676
25358
  git;
24677
25359
  detector;
@@ -25065,6 +25747,7 @@ CLI Version: ${options.cliVersion}`;
25065
25747
  if (newPatches.length > 0) {
25066
25748
  results = await this.applicator.applyPatches(newPatches);
25067
25749
  this._lastApplyResults = results;
25750
+ this.recordDroppedContextLineWarnings(results);
25068
25751
  this.revertConflictingFiles(results);
25069
25752
  for (const result of results) {
25070
25753
  if (result.status === "conflict") {
@@ -25083,7 +25766,8 @@ CLI Version: ${options.cliVersion}`;
25083
25766
  if (newPatches.length > 0) {
25084
25767
  if (!options?.stageOnly) {
25085
25768
  const appliedCount = results.filter((r2) => r2.status === "applied").length;
25086
- await this.committer.commitReplay(appliedCount, newPatches);
25769
+ const buckets = computeCommitBuckets(results, rebaseCounts.absorbedPatchIds, newPatches);
25770
+ await this.committer.commitReplay(appliedCount, newPatches, void 0, { buckets });
25087
25771
  } else {
25088
25772
  await this.committer.stageAll();
25089
25773
  }
@@ -25146,12 +25830,16 @@ CLI Version: ${options.cliVersion}`;
25146
25830
  (p) => preRebasePatchIds.has(p.id) && !postRebasePatchIds.has(p.id)
25147
25831
  );
25148
25832
  existingPatches = this.lockManager.getPatches();
25149
- const seenHashes = /* @__PURE__ */ new Set();
25833
+ const seenHashes = /* @__PURE__ */ new Map();
25150
25834
  for (const p of existingPatches) {
25151
- if (seenHashes.has(p.content_hash)) {
25835
+ const priorPatchId = seenHashes.get(p.content_hash);
25836
+ if (priorPatchId !== void 0) {
25152
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
+ );
25153
25841
  } else {
25154
- seenHashes.add(p.content_hash);
25842
+ seenHashes.set(p.content_hash, p.id);
25155
25843
  }
25156
25844
  }
25157
25845
  existingPatches = this.lockManager.getPatches();
@@ -25212,6 +25900,7 @@ CLI Version: ${options.cliVersion}`;
25212
25900
  const genSha = prep._prepared.genSha;
25213
25901
  const results = await this.applicator.applyPatches(allPatches);
25214
25902
  this._lastApplyResults = results;
25903
+ this.recordDroppedContextLineWarnings(results);
25215
25904
  this.revertConflictingFiles(results);
25216
25905
  for (const result of results) {
25217
25906
  if (result.status === "conflict") {
@@ -25239,7 +25928,8 @@ CLI Version: ${options.cliVersion}`;
25239
25928
  await this.committer.stageAll();
25240
25929
  } else {
25241
25930
  const appliedCount = results.filter((r2) => r2.status === "applied").length;
25242
- await this.committer.commitReplay(appliedCount, allPatches);
25931
+ const buckets = computeCommitBuckets(results, rebaseCounts.absorbedPatchIds, allPatches);
25932
+ await this.committer.commitReplay(appliedCount, allPatches, void 0, { buckets });
25243
25933
  }
25244
25934
  const warnings = [...detectorWarnings, ...this.warnings];
25245
25935
  return this.buildReport(
@@ -25263,7 +25953,7 @@ CLI Version: ${options.cliVersion}`;
25263
25953
  let repointed = 0;
25264
25954
  let contentRebased = 0;
25265
25955
  let keptAsUserOwned = 0;
25266
- const seenContentHashes = /* @__PURE__ */ new Set();
25956
+ const seenContentHashes = /* @__PURE__ */ new Map();
25267
25957
  const absorbedPatchIds = /* @__PURE__ */ new Set();
25268
25958
  const fernignorePatterns = this.readFernignorePatterns();
25269
25959
  for (const result of results) {
@@ -25277,6 +25967,23 @@ CLI Version: ${options.cliVersion}`;
25277
25967
  }
25278
25968
  }
25279
25969
  }
25970
+ const conflictedFilePaths = /* @__PURE__ */ new Set();
25971
+ for (const r2 of results) {
25972
+ if (r2.status !== "conflict") continue;
25973
+ if (r2.fileResults) {
25974
+ for (const fr of r2.fileResults) {
25975
+ if (fr.status !== "conflict") continue;
25976
+ conflictedFilePaths.add(fr.file);
25977
+ if (r2.resolvedFiles) {
25978
+ for (const [orig, resolved] of Object.entries(r2.resolvedFiles)) {
25979
+ if (resolved === fr.file) conflictedFilePaths.add(orig);
25980
+ }
25981
+ }
25982
+ }
25983
+ } else {
25984
+ for (const f2 of r2.patch.files) conflictedFilePaths.add(f2);
25985
+ }
25986
+ }
25280
25987
  for (const result of results) {
25281
25988
  if (result.status === "conflict" && result.fileResults) {
25282
25989
  await this.trimAbsorbedFiles(result, currentGenSha);
@@ -25313,17 +26020,27 @@ CLI Version: ${options.cliVersion}`;
25313
26020
  if (allUserOwned && patch.files.length > 0) {
25314
26021
  const baseChanged = patch.base_generation !== currentGenSha;
25315
26022
  const userDiff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
26023
+ let userDiffSnapshot = null;
25316
26024
  if (userDiff != null && userDiff.trim()) {
25317
26025
  const newHash = this.detector.computeContentHash(userDiff);
25318
26026
  patch.patch_content = userDiff;
25319
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
+ }
25320
26033
  }
25321
26034
  patch.base_generation = currentGenSha;
25322
26035
  try {
25323
26036
  this.lockManager.updatePatch(patch.id, {
25324
26037
  base_generation: currentGenSha,
25325
26038
  ...!patch.user_owned ? { user_owned: true } : {},
25326
- ...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
+ } : {}
25327
26044
  });
25328
26045
  } catch {
25329
26046
  }
@@ -25344,6 +26061,18 @@ CLI Version: ${options.cliVersion}`;
25344
26061
  keptAsUserOwned++;
25345
26062
  continue;
25346
26063
  }
26064
+ const overlapsConflicted = patch.files.some((f2) => conflictedFilePaths.has(f2));
26065
+ if (overlapsConflicted) {
26066
+ patch.status = "unresolved";
26067
+ try {
26068
+ this.lockManager.updatePatch(patch.id, { status: "unresolved" });
26069
+ } catch {
26070
+ }
26071
+ this.warnings.push(
26072
+ `Patch ${patch.id} (${patch.original_message}) was preserved as unresolved because an earlier patch conflicted on the same file(s) (${patch.files.join(", ")}). Run \`fern replay resolve\` to apply this customization manually.`
26073
+ );
26074
+ continue;
26075
+ }
25347
26076
  this.lockManager.removePatch(patch.id);
25348
26077
  absorbedPatchIds.add(patch.id);
25349
26078
  absorbed++;
@@ -25359,21 +26088,34 @@ CLI Version: ${options.cliVersion}`;
25359
26088
  continue;
25360
26089
  }
25361
26090
  const newContentHash = this.detector.computeContentHash(diff);
25362
- if (seenContentHashes.has(newContentHash)) {
26091
+ const priorPatchId = seenContentHashes.get(newContentHash);
26092
+ if (priorPatchId !== void 0) {
25363
26093
  this.lockManager.removePatch(patch.id);
25364
26094
  absorbedPatchIds.add(patch.id);
25365
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
+ );
26100
+ }
25366
26101
  continue;
26102
+ } else {
26103
+ seenContentHashes.set(newContentHash, patch.id);
25367
26104
  }
25368
- seenContentHashes.add(newContentHash);
25369
26105
  patch.base_generation = currentGenSha;
25370
26106
  patch.patch_content = diff;
25371
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
+ }
25372
26113
  try {
25373
26114
  this.lockManager.updatePatch(patch.id, {
25374
26115
  base_generation: currentGenSha,
25375
26116
  patch_content: diff,
25376
- content_hash: newContentHash
26117
+ content_hash: newContentHash,
26118
+ ...snapshotIsNonEmpty ? { theirs_snapshot: snapshot } : {}
25377
26119
  });
25378
26120
  } catch {
25379
26121
  }
@@ -25383,6 +26125,112 @@ CLI Version: ${options.cliVersion}`;
25383
26125
  }
25384
26126
  return { absorbed, repointed, contentRebased, keptAsUserOwned, absorbedPatchIds };
25385
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
+ }
25386
26234
  /**
25387
26235
  * Determine whether `file` is user-owned (never produced by the generator).
25388
26236
  *
@@ -25503,6 +26351,20 @@ CLI Version: ${options.cliVersion}`;
25503
26351
  this.lockManager.clearReplaySkippedAt();
25504
26352
  return { conflictResolved: 0, conflictAbsorbed: 0, contentRefreshed: 0 };
25505
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
+ };
25506
26368
  let conflictResolved = 0;
25507
26369
  let conflictAbsorbed = 0;
25508
26370
  let contentRefreshed = 0;
@@ -25539,10 +26401,23 @@ CLI Version: ${options.cliVersion}`;
25539
26401
  delete patch.status;
25540
26402
  continue;
25541
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
+ }
25542
26411
  if (patch.base_generation === currentGen) {
25543
26412
  try {
25544
- const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
25545
- if (diff === null) continue;
26413
+ const ppResult = await computePerPatchDiffWithFallback(
26414
+ patch,
26415
+ (f2) => this.git.showFile(currentGen, f2),
26416
+ (f2) => this.git.showFile("HEAD", f2),
26417
+ (files) => this.git.exec(["diff", currentGen, "HEAD", "--", ...files]).catch(() => null)
26418
+ );
26419
+ if (ppResult === null) continue;
26420
+ const diff = ppResult.diff;
25546
26421
  if (!diff.trim()) {
25547
26422
  if (await allPatchFilesUserOwned(patch)) continue;
25548
26423
  this.lockManager.removePatch(patch.id);
@@ -25569,10 +26444,12 @@ CLI Version: ${options.cliVersion}`;
25569
26444
  if (newFiles.length === 0) {
25570
26445
  this.lockManager.removePatch(patch.id);
25571
26446
  } else {
26447
+ const snapshot = await this.readSnapshotFromTree("HEAD", newFiles);
25572
26448
  this.lockManager.updatePatch(patch.id, {
25573
26449
  patch_content: diff,
25574
26450
  content_hash: newContentHash,
25575
- files: newFiles
26451
+ files: newFiles,
26452
+ ...Object.keys(snapshot).length > 0 ? { theirs_snapshot: snapshot } : {}
25576
26453
  });
25577
26454
  }
25578
26455
  contentRefreshed++;
@@ -25584,8 +26461,14 @@ CLI Version: ${options.cliVersion}`;
25584
26461
  try {
25585
26462
  const markerFiles = await this.git.exec(["grep", "-l", "<<<<<<< Generated", "HEAD", "--", ...patch.files]).catch(() => "");
25586
26463
  if (markerFiles.trim()) continue;
25587
- const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
25588
- if (diff === null) continue;
26464
+ const ppResult = await computePerPatchDiffWithFallback(
26465
+ patch,
26466
+ (f2) => this.git.showFile(currentGen, f2),
26467
+ (f2) => this.git.showFile("HEAD", f2),
26468
+ (files) => this.git.exec(["diff", currentGen, "HEAD", "--", ...files]).catch(() => null)
26469
+ );
26470
+ if (ppResult === null) continue;
26471
+ const diff = ppResult.diff;
25589
26472
  if (!diff.trim()) {
25590
26473
  if (await allPatchFilesUserOwned(patch)) {
25591
26474
  try {
@@ -25599,10 +26482,12 @@ CLI Version: ${options.cliVersion}`;
25599
26482
  continue;
25600
26483
  }
25601
26484
  const newContentHash = this.detector.computeContentHash(diff);
26485
+ const snapshot = await this.readSnapshotFromTree("HEAD", patch.files);
25602
26486
  this.lockManager.updatePatch(patch.id, {
25603
26487
  base_generation: currentGen,
25604
26488
  patch_content: diff,
25605
- content_hash: newContentHash
26489
+ content_hash: newContentHash,
26490
+ ...Object.keys(snapshot).length > 0 ? { theirs_snapshot: snapshot } : {}
25606
26491
  });
25607
26492
  conflictResolved++;
25608
26493
  } catch {
@@ -25610,6 +26495,32 @@ CLI Version: ${options.cliVersion}`;
25610
26495
  }
25611
26496
  return { conflictResolved, conflictAbsorbed, contentRefreshed };
25612
26497
  }
26498
+ /**
26499
+ * After applyPatches(), surface a warning for each (patch × file) where
26500
+ * diff3 silently dropped lines that were unchanged context in the
26501
+ * customer's THEIRS. The deletion stays on disk (correct diff3 outcome),
26502
+ * but the customer must learn so they can re-add the lines if they
26503
+ * relied on them. This is the "surface or preserve, never silently drop"
26504
+ * contract for legitimate-deletion cases.
26505
+ */
26506
+ recordDroppedContextLineWarnings(results) {
26507
+ const PREVIEW_COUNT = 5;
26508
+ for (const result of results) {
26509
+ if (!result.fileResults) continue;
26510
+ for (const fr of result.fileResults) {
26511
+ const dropped = fr.droppedContextLines;
26512
+ if (!dropped || dropped.length === 0) continue;
26513
+ const preview = dropped.slice(0, PREVIEW_COUNT).map((line) => ` ${line}`).join("\n");
26514
+ const more = dropped.length > PREVIEW_COUNT ? `
26515
+ ... and ${dropped.length - PREVIEW_COUNT} more` : "";
26516
+ this.warnings.push(
26517
+ `${fr.file}: ${dropped.length} line(s) that appeared as unchanged context in your customization were deleted by the new generator output. The merge followed the deletion. First lines deleted:
26518
+ ${preview}${more}
26519
+ If you want to keep these lines, restore them in a follow-up commit and re-run replay; otherwise this warning can be safely ignored.`
26520
+ );
26521
+ }
26522
+ }
26523
+ }
25613
26524
  /**
25614
26525
  * After applyPatches(), strip conflict markers from conflicting files
25615
26526
  * so only clean content is committed. Keeps the Generated (OURS) side.
@@ -25619,7 +26530,7 @@ CLI Version: ${options.cliVersion}`;
25619
26530
  if (result.status !== "conflict" || !result.fileResults) continue;
25620
26531
  for (const fileResult of result.fileResults) {
25621
26532
  if (fileResult.status !== "conflict") continue;
25622
- const filePath = (0, import_path9.join)(this.outputDir, fileResult.file);
26533
+ const filePath = (0, import_path10.join)(this.outputDir, fileResult.file);
25623
26534
  try {
25624
26535
  const content = (0, import_fs7.readFileSync)(filePath, "utf-8");
25625
26536
  const stripped = stripConflictMarkers(content);
@@ -25649,7 +26560,7 @@ CLI Version: ${options.cliVersion}`;
25649
26560
  }
25650
26561
  }
25651
26562
  readFernignorePatterns() {
25652
- const fernignorePath = (0, import_path9.join)(this.outputDir, ".fernignore");
26563
+ const fernignorePath = (0, import_path10.join)(this.outputDir, ".fernignore");
25653
26564
  if (!(0, import_fs7.existsSync)(fernignorePath)) return [];
25654
26565
  return (0, import_fs7.readFileSync)(fernignorePath, "utf-8").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
25655
26566
  }
@@ -25667,6 +26578,9 @@ CLI Version: ${options.cliVersion}`;
25667
26578
  };
25668
26579
  }).filter((d) => d.files.length > 0);
25669
26580
  const partialCount = conflictDetails.filter((d) => d.cleanFiles && d.cleanFiles.length > 0).length;
26581
+ const unresolvedResults = results.filter(
26582
+ (r2) => r2.status === "conflict" || r2.patch.status === "unresolved"
26583
+ );
25670
26584
  return {
25671
26585
  flow,
25672
26586
  patchesDetected: patches.length,
@@ -25683,7 +26597,7 @@ CLI Version: ${options.cliVersion}`;
25683
26597
  patchesRefreshed: preRebaseCounts && preRebaseCounts.contentRefreshed > 0 ? preRebaseCounts.contentRefreshed : void 0,
25684
26598
  conflicts: conflictResults.flatMap((r2) => r2.fileResults?.filter((f2) => f2.status === "conflict") ?? []),
25685
26599
  conflictDetails: conflictDetails.length > 0 ? conflictDetails : void 0,
25686
- unresolvedPatches: conflictResults.length > 0 ? conflictResults.map((r2) => ({
26600
+ unresolvedPatches: unresolvedResults.length > 0 ? unresolvedResults.map((r2) => ({
25687
26601
  patchId: r2.patch.id,
25688
26602
  patchMessage: r2.patch.original_message,
25689
26603
  files: r2.patch.files,
@@ -25704,10 +26618,10 @@ CLI Version: ${options.cliVersion}`;
25704
26618
  this.outputDir = outputDir;
25705
26619
  }
25706
26620
  fernignoreExists() {
25707
- return (0, import_fs8.existsSync)((0, import_path10.join)(this.outputDir, ".fernignore"));
26621
+ return (0, import_fs8.existsSync)((0, import_path12.join)(this.outputDir, ".fernignore"));
25708
26622
  }
25709
26623
  readFernignorePatterns() {
25710
- const fernignorePath = (0, import_path10.join)(this.outputDir, ".fernignore");
26624
+ const fernignorePath = (0, import_path12.join)(this.outputDir, ".fernignore");
25711
26625
  if (!(0, import_fs8.existsSync)(fernignorePath)) {
25712
26626
  return [];
25713
26627
  }
@@ -25759,6 +26673,12 @@ CLI Version: ${options.cliVersion}`;
25759
26673
  if (patchFiles.length > 0) {
25760
26674
  const patchContent = diffParts.join("\n");
25761
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
+ }
25762
26682
  syntheticPatches.push({
25763
26683
  id: `patch-fernignore-${(0, import_crypto2.createHash)("sha256").update(pattern).digest("hex").slice(0, 8)}`,
25764
26684
  content_hash: contentHash,
@@ -25767,7 +26687,17 @@ CLI Version: ${options.cliVersion}`;
25767
26687
  original_author: "Fern Replay <replay@buildwithfern.com>",
25768
26688
  base_generation: currentGen.commit_sha,
25769
26689
  files: patchFiles,
25770
- patch_content: patchContent
26690
+ patch_content: patchContent,
26691
+ ...Object.keys(theirsSnapshot).length > 0 ? { theirs_snapshot: theirsSnapshot } : {},
26692
+ // Synthetic patches captured from .fernignore-protected files
26693
+ // are user-owned by definition — the customer's content
26694
+ // diverges from the generator's pristine output, and the
26695
+ // generator never produced this divergent content. Without
26696
+ // this flag, removing the file from .fernignore later (so
26697
+ // it leaves the protection check in `isFileUserOwned`) would
26698
+ // expose the patch to absorption on the next regen,
26699
+ // silently losing the customization.
26700
+ user_owned: true
25771
26701
  });
25772
26702
  trackedByBoth.push({
25773
26703
  file: pattern,
@@ -25800,7 +26730,7 @@ CLI Version: ${options.cliVersion}`;
25800
26730
  return results;
25801
26731
  }
25802
26732
  readFileContent(filePath) {
25803
- const fullPath = (0, import_path10.join)(this.outputDir, filePath);
26733
+ const fullPath = (0, import_path12.join)(this.outputDir, filePath);
25804
26734
  if (!(0, import_fs8.existsSync)(fullPath)) {
25805
26735
  return null;
25806
26736
  }
@@ -25865,7 +26795,7 @@ CLI Version: ${options.cliVersion}`;
25865
26795
  };
25866
26796
  }
25867
26797
  movePatternsToReplayYml(patterns) {
25868
- const replayYmlPath = (0, import_path10.join)(this.outputDir, ".fern", "replay.yml");
26798
+ const replayYmlPath = (0, import_path12.join)(this.outputDir, ".fern", "replay.yml");
25869
26799
  let config = {};
25870
26800
  if ((0, import_fs8.existsSync)(replayYmlPath)) {
25871
26801
  const content = (0, import_fs8.readFileSync)(replayYmlPath, "utf-8");
@@ -25874,7 +26804,7 @@ CLI Version: ${options.cliVersion}`;
25874
26804
  const existing = config.exclude ?? [];
25875
26805
  const merged = [.../* @__PURE__ */ new Set([...existing, ...patterns])];
25876
26806
  config.exclude = merged;
25877
- const dir = (0, import_path10.dirname)(replayYmlPath);
26807
+ const dir = (0, import_path12.dirname)(replayYmlPath);
25878
26808
  if (!(0, import_fs8.existsSync)(dir)) {
25879
26809
  (0, import_fs8.mkdirSync)(dir, { recursive: true });
25880
26810
  }
@@ -26378,7 +27308,7 @@ var require_cross_spawn = __commonJS({
26378
27308
  var cp2 = require("child_process");
26379
27309
  var parse3 = require_parse2();
26380
27310
  var enoent = require_enoent();
26381
- function spawn2(command, args, options) {
27311
+ function spawn3(command, args, options) {
26382
27312
  const parsed = parse3(command, args, options);
26383
27313
  const spawned = cp2.spawn(parsed.command, parsed.args, parsed.options);
26384
27314
  enoent.hookChildProcess(spawned, parsed);
@@ -26390,8 +27320,8 @@ var require_cross_spawn = __commonJS({
26390
27320
  result.error = result.error || enoent.verifyENOENTSync(result.status, parsed);
26391
27321
  return result;
26392
27322
  }
26393
- module2.exports = spawn2;
26394
- module2.exports.spawn = spawn2;
27323
+ module2.exports = spawn3;
27324
+ module2.exports.spawn = spawn3;
26395
27325
  module2.exports.sync = spawnSync;
26396
27326
  module2.exports._parse = parse3;
26397
27327
  module2.exports._enoent = enoent;
@@ -28430,8 +29360,8 @@ function resolve(...paths) {
28430
29360
 
28431
29361
  // src/cli.ts
28432
29362
  var import_fs15 = __toESM(require("fs"));
28433
- var import_promises11 = require("fs/promises");
28434
- var import_path18 = __toESM(require("path"));
29363
+ var import_promises15 = require("fs/promises");
29364
+ var import_path21 = __toESM(require("path"));
28435
29365
 
28436
29366
  // ../../node_modules/.pnpm/yargs@17.7.2/node_modules/yargs/build/lib/yerror.js
28437
29367
  var YError = class _YError extends Error {
@@ -38132,42 +39062,42 @@ function replayForget(params) {
38132
39062
  // src/replay/replay-init.ts
38133
39063
  init_dist3();
38134
39064
  var import_fs11 = require("fs");
38135
- var import_path13 = require("path");
39065
+ var import_path16 = require("path");
38136
39066
  var import_tmp_promise2 = __toESM(require_tmp_promise());
38137
39067
 
38138
39068
  // src/replay/fernignore.ts
38139
- var import_promises4 = require("fs/promises");
38140
- var import_path12 = require("path");
39069
+ var import_promises8 = require("fs/promises");
39070
+ var import_path15 = require("path");
38141
39071
  var REPLAY_FERNIGNORE_ENTRIES2 = [".fern/replay.lock", ".fern/replay.yml", ".gitattributes"];
38142
39072
  var GITATTRIBUTES_ENTRIES2 = [".fern/replay.lock linguist-generated=true"];
38143
39073
  async function ensureReplayFernignoreEntries(outputDir) {
38144
- const fernignorePath = (0, import_path12.join)(outputDir, ".fernignore");
39074
+ const fernignorePath = (0, import_path15.join)(outputDir, ".fernignore");
38145
39075
  let content = "";
38146
39076
  try {
38147
- content = await (0, import_promises4.readFile)(fernignorePath, "utf-8");
39077
+ content = await (0, import_promises8.readFile)(fernignorePath, "utf-8");
38148
39078
  } catch {
38149
39079
  }
38150
39080
  const lines = content.split("\n");
38151
39081
  const toAdd = REPLAY_FERNIGNORE_ENTRIES2.filter((entry) => !lines.some((line) => line.trim() === entry));
38152
39082
  if (toAdd.length > 0) {
38153
39083
  const suffix = content.length > 0 && !content.endsWith("\n") ? "\n" : "";
38154
- await (0, import_promises4.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");
38155
39085
  return true;
38156
39086
  }
38157
39087
  return false;
38158
39088
  }
38159
39089
  async function ensureGitattributesEntries2(outputDir) {
38160
- const gitattributesPath = (0, import_path12.join)(outputDir, ".gitattributes");
39090
+ const gitattributesPath = (0, import_path15.join)(outputDir, ".gitattributes");
38161
39091
  let content = "";
38162
39092
  try {
38163
- content = await (0, import_promises4.readFile)(gitattributesPath, "utf-8");
39093
+ content = await (0, import_promises8.readFile)(gitattributesPath, "utf-8");
38164
39094
  } catch {
38165
39095
  }
38166
39096
  const lines = content.split("\n");
38167
39097
  const toAdd = GITATTRIBUTES_ENTRIES2.filter((entry) => !lines.some((line) => line.trim() === entry));
38168
39098
  if (toAdd.length > 0) {
38169
39099
  const suffix = content.length > 0 && !content.endsWith("\n") ? "\n" : "";
38170
- await (0, import_promises4.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");
38171
39101
  }
38172
39102
  }
38173
39103
 
@@ -38194,7 +39124,7 @@ async function replayInit(params) {
38194
39124
  if (dryRun) {
38195
39125
  return { bootstrap: bootstrapResult, fernignoreEntries: [], gitattributesEntries: [] };
38196
39126
  }
38197
- const lockfilePath = (0, import_path13.join)(repoPath, ".fern", "replay.lock");
39127
+ const lockfilePath = (0, import_path16.join)(repoPath, ".fern", "replay.lock");
38198
39128
  const lockfileContent = (0, import_fs11.existsSync)(lockfilePath) ? (0, import_fs11.readFileSync)(lockfilePath, "utf-8") : void 0;
38199
39129
  return {
38200
39130
  bootstrap: bootstrapResult,
@@ -38315,9 +39245,9 @@ init_dist3();
38315
39245
 
38316
39246
  // src/replay/replay-run.ts
38317
39247
  init_dist3();
38318
- var import_child_process3 = require("child_process");
39248
+ var import_child_process4 = require("child_process");
38319
39249
  var import_fs12 = require("fs");
38320
- var import_path14 = require("path");
39250
+ var import_path17 = require("path");
38321
39251
  async function replayPrepare(params) {
38322
39252
  const {
38323
39253
  outputDir,
@@ -38328,7 +39258,7 @@ async function replayPrepare(params) {
38328
39258
  skipApplication,
38329
39259
  logger
38330
39260
  } = params;
38331
- const lockfilePath = (0, import_path14.join)(outputDir, ".fern", "replay.lock");
39261
+ const lockfilePath = (0, import_path17.join)(outputDir, ".fern", "replay.lock");
38332
39262
  if (!(0, import_fs12.existsSync)(lockfilePath)) {
38333
39263
  return null;
38334
39264
  }
@@ -38460,7 +39390,7 @@ async function replayRun(params) {
38460
39390
  }
38461
39391
  function isTagMergedIntoHead(cwd2, tagSha) {
38462
39392
  try {
38463
- (0, import_child_process3.execFileSync)("git", ["merge-base", "--is-ancestor", tagSha, "HEAD"], {
39393
+ (0, import_child_process4.execFileSync)("git", ["merge-base", "--is-ancestor", tagSha, "HEAD"], {
38464
39394
  cwd: cwd2,
38465
39395
  stdio: "pipe"
38466
39396
  });
@@ -38472,7 +39402,7 @@ function isTagMergedIntoHead(cwd2, tagSha) {
38472
39402
  return false;
38473
39403
  }
38474
39404
  try {
38475
- const trees = (0, import_child_process3.execFileSync)("git", ["log", "HEAD", "--format=%T", "--max-count=500"], {
39405
+ const trees = (0, import_child_process4.execFileSync)("git", ["log", "HEAD", "--format=%T", "--max-count=500"], {
38476
39406
  cwd: cwd2,
38477
39407
  encoding: "utf-8",
38478
39408
  stdio: "pipe"
@@ -38484,7 +39414,7 @@ function isTagMergedIntoHead(cwd2, tagSha) {
38484
39414
  }
38485
39415
  function gitRevParse(cwd2, rev) {
38486
39416
  try {
38487
- return (0, import_child_process3.execFileSync)("git", ["rev-parse", "--verify", rev], {
39417
+ return (0, import_child_process4.execFileSync)("git", ["rev-parse", "--verify", rev], {
38488
39418
  cwd: cwd2,
38489
39419
  encoding: "utf-8",
38490
39420
  stdio: "pipe"
@@ -38495,7 +39425,7 @@ function gitRevParse(cwd2, rev) {
38495
39425
  }
38496
39426
  function gitDiffNameOnly(cwd2, from, to) {
38497
39427
  try {
38498
- const output = (0, import_child_process3.execFileSync)("git", ["diff", "--name-only", from, to], {
39428
+ const output = (0, import_child_process4.execFileSync)("git", ["diff", "--name-only", from, to], {
38499
39429
  cwd: cwd2,
38500
39430
  encoding: "utf-8",
38501
39431
  stdio: "pipe"
@@ -38513,29 +39443,29 @@ function replayStatus(params) {
38513
39443
  }
38514
39444
 
38515
39445
  // src/configuration/loadGitHubConfig.ts
38516
- var import_promises5 = require("fs/promises");
39446
+ var import_promises9 = require("fs/promises");
38517
39447
  async function loadGitHubConfig({
38518
39448
  absolutePathToConfig
38519
39449
  }) {
38520
- const rawContents = await (0, import_promises5.readFile)(absolutePathToConfig, "utf8");
39450
+ const rawContents = await (0, import_promises9.readFile)(absolutePathToConfig, "utf8");
38521
39451
  return JSON.parse(rawContents);
38522
39452
  }
38523
39453
 
38524
39454
  // src/configuration/loadReadmeConfig.ts
38525
- var import_promises6 = require("fs/promises");
39455
+ var import_promises10 = require("fs/promises");
38526
39456
  async function loadReadmeConfig({
38527
39457
  absolutePathToConfig
38528
39458
  }) {
38529
- const rawContents = await (0, import_promises6.readFile)(absolutePathToConfig, "utf8");
39459
+ const rawContents = await (0, import_promises10.readFile)(absolutePathToConfig, "utf8");
38530
39460
  return JSON.parse(rawContents);
38531
39461
  }
38532
39462
 
38533
39463
  // src/configuration/loadReferenceConfig.ts
38534
- var import_promises7 = require("fs/promises");
39464
+ var import_promises11 = require("fs/promises");
38535
39465
  async function loadReferenceConfig({
38536
39466
  absolutePathToConfig
38537
39467
  }) {
38538
- const rawContents = await (0, import_promises7.readFile)(absolutePathToConfig, "utf8");
39468
+ const rawContents = await (0, import_promises11.readFile)(absolutePathToConfig, "utf8");
38539
39469
  return JSON.parse(rawContents);
38540
39470
  }
38541
39471
 
@@ -38552,7 +39482,7 @@ var consolePipelineLogger = {
38552
39482
  };
38553
39483
 
38554
39484
  // src/pipeline/PostGenerationPipeline.ts
38555
- var import_child_process5 = require("child_process");
39485
+ var import_child_process6 = require("child_process");
38556
39486
 
38557
39487
  // src/pipeline/github/constants.ts
38558
39488
  var FERN_BOT_NAME2 = "fern-api";
@@ -38560,10 +39490,10 @@ var FERN_BOT_EMAIL2 = "115122769+fern-api[bot]@users.noreply.github.com";
38560
39490
  var FERN_BOT_LOGIN2 = "fern-api[bot]";
38561
39491
 
38562
39492
  // src/pipeline/steps/AutoVersionStep.ts
38563
- var import_child_process4 = require("child_process");
39493
+ var import_child_process5 = require("child_process");
38564
39494
  var import_fs14 = require("fs");
38565
- var import_promises9 = require("fs/promises");
38566
- var import_path16 = require("path");
39495
+ var import_promises13 = require("fs/promises");
39496
+ var import_path19 = require("path");
38567
39497
 
38568
39498
  // ../commons/logging-execa/lib/loggingExeca.js
38569
39499
  var import_execa = __toESM(require_execa(), 1);
@@ -38622,8 +39552,8 @@ async function loggingExeca(logger, executable, args = [], { doNotPipeOutput = f
38622
39552
 
38623
39553
  // src/autoversion/AutoVersioningService.ts
38624
39554
  var import_fs13 = require("fs");
38625
- var import_promises8 = require("fs/promises");
38626
- var import_path15 = require("path");
39555
+ var import_promises12 = require("fs/promises");
39556
+ var import_path18 = require("path");
38627
39557
  var import_semver = __toESM(require_semver2());
38628
39558
  var AutoVersioningException = class extends Error {
38629
39559
  /**
@@ -39310,7 +40240,7 @@ var AutoVersioningService = class _AutoVersioningService {
39310
40240
  );
39311
40241
  return;
39312
40242
  }
39313
- const goModPath = (0, import_path15.join)(workingDirectory, "go.mod");
40243
+ const goModPath = (0, import_path18.join)(workingDirectory, "go.mod");
39314
40244
  if (!(0, import_fs13.existsSync)(goModPath)) {
39315
40245
  this.logger.debug(`No go.mod found in ${workingDirectory}, skipping Go module path update`);
39316
40246
  return;
@@ -39344,7 +40274,7 @@ var AutoVersioningService = class _AutoVersioningService {
39344
40274
  * Reads the module path from a go.mod file.
39345
40275
  */
39346
40276
  async readGoModulePath(goModPath) {
39347
- const content = await (0, import_promises8.readFile)(goModPath, "utf-8");
40277
+ const content = await (0, import_promises12.readFile)(goModPath, "utf-8");
39348
40278
  for (const line of content.split("\n")) {
39349
40279
  const trimmed2 = line.trim();
39350
40280
  if (trimmed2.startsWith("module ")) {
@@ -39357,7 +40287,7 @@ var AutoVersioningService = class _AutoVersioningService {
39357
40287
  * Adds a version suffix to the module line in go.mod.
39358
40288
  */
39359
40289
  async addSuffixToGoMod(goModPath, modulePath, suffix) {
39360
- const content = await (0, import_promises8.readFile)(goModPath, "utf-8");
40290
+ const content = await (0, import_promises12.readFile)(goModPath, "utf-8");
39361
40291
  const lines = content.split("\n");
39362
40292
  let changed = false;
39363
40293
  for (let i = 0; i < lines.length; i++) {
@@ -39369,7 +40299,7 @@ var AutoVersioningService = class _AutoVersioningService {
39369
40299
  }
39370
40300
  }
39371
40301
  if (changed) {
39372
- await (0, import_promises8.writeFile)(goModPath, lines.join("\n"), "utf-8");
40302
+ await (0, import_promises12.writeFile)(goModPath, lines.join("\n"), "utf-8");
39373
40303
  this.logger.debug(`Updated go.mod: added ${suffix} to module path`);
39374
40304
  }
39375
40305
  }
@@ -39378,10 +40308,10 @@ var AutoVersioningService = class _AutoVersioningService {
39378
40308
  */
39379
40309
  async walkDirectory(dir, filter2) {
39380
40310
  const results = [];
39381
- const entries2 = await (0, import_promises8.readdir)(dir);
40311
+ const entries2 = await (0, import_promises12.readdir)(dir);
39382
40312
  for (const entry of entries2) {
39383
- const fullPath = (0, import_path15.join)(dir, entry);
39384
- const entryStat = await (0, import_promises8.stat)(fullPath);
40313
+ const fullPath = (0, import_path18.join)(dir, entry);
40314
+ const entryStat = await (0, import_promises12.stat)(fullPath);
39385
40315
  if (entryStat.isDirectory()) {
39386
40316
  if (entry === ".git" || entry === "vendor") {
39387
40317
  continue;
@@ -39398,7 +40328,7 @@ var AutoVersioningService = class _AutoVersioningService {
39398
40328
  * Adds a version suffix to import paths in all .go files in the repository.
39399
40329
  */
39400
40330
  async addSuffixToGoFiles(repoRoot, modulePath, suffix) {
39401
- const goFiles = await this.walkDirectory(repoRoot, (filePath) => (0, import_path15.extname)(filePath) === ".go");
40331
+ const goFiles = await this.walkDirectory(repoRoot, (filePath) => (0, import_path18.extname)(filePath) === ".go");
39402
40332
  for (const filePath of goFiles) {
39403
40333
  await this.addSuffixToGoFile(filePath, modulePath, suffix);
39404
40334
  }
@@ -39408,7 +40338,7 @@ var AutoVersioningService = class _AutoVersioningService {
39408
40338
  * Replaces occurrences of the module path with modulePath + suffix in import statements.
39409
40339
  */
39410
40340
  async addSuffixToGoFile(filePath, modulePath, suffix) {
39411
- const content = await (0, import_promises8.readFile)(filePath, "utf-8");
40341
+ const content = await (0, import_promises12.readFile)(filePath, "utf-8");
39412
40342
  const lines = content.split("\n");
39413
40343
  let changed = false;
39414
40344
  let inImportBlock = false;
@@ -39437,7 +40367,7 @@ var AutoVersioningService = class _AutoVersioningService {
39437
40367
  }
39438
40368
  }
39439
40369
  if (changed) {
39440
- await (0, import_promises8.writeFile)(filePath, lines.join("\n"), "utf-8");
40370
+ await (0, import_promises12.writeFile)(filePath, lines.join("\n"), "utf-8");
39441
40371
  this.logger.debug(`Updated Go imports in ${filePath}`);
39442
40372
  }
39443
40373
  }
@@ -39800,7 +40730,7 @@ var AutoVersionStep = class extends BaseStep {
39800
40730
  }
39801
40731
  async readVersionFromMetadata() {
39802
40732
  try {
39803
- const output = (0, import_child_process4.execFileSync)("git", ["show", "HEAD~1:.fern/metadata.json"], {
40733
+ const output = (0, import_child_process5.execFileSync)("git", ["show", "HEAD~1:.fern/metadata.json"], {
39804
40734
  cwd: this.outputDir,
39805
40735
  encoding: "utf-8",
39806
40736
  stdio: "pipe"
@@ -39816,7 +40746,7 @@ var AutoVersionStep = class extends BaseStep {
39816
40746
  return mappedMagicVersion.startsWith("v") ? `v${stripped}` : stripped;
39817
40747
  }
39818
40748
  gitDiff(from, to) {
39819
- return (0, import_child_process4.execFileSync)("git", ["diff", from, to, "--", ".", ":(exclude).fern/metadata.json"], {
40749
+ return (0, import_child_process5.execFileSync)("git", ["diff", from, to, "--", ".", ":(exclude).fern/metadata.json"], {
39820
40750
  cwd: this.outputDir,
39821
40751
  encoding: "utf-8",
39822
40752
  stdio: "pipe",
@@ -39829,12 +40759,12 @@ var AutoVersionStep = class extends BaseStep {
39829
40759
  const fullMessage = body.length > 0 ? `${COMMIT_MARKER} ${subject}
39830
40760
 
39831
40761
  ${body}` : `${COMMIT_MARKER} ${subject}`;
39832
- (0, import_child_process4.execFileSync)("git", ["add", "-A"], { cwd: this.outputDir, stdio: "pipe" });
39833
- (0, import_child_process4.execFileSync)("git", ["commit", "--allow-empty", "-m", fullMessage], {
40762
+ (0, import_child_process5.execFileSync)("git", ["add", "-A"], { cwd: this.outputDir, stdio: "pipe" });
40763
+ (0, import_child_process5.execFileSync)("git", ["commit", "--allow-empty", "-m", fullMessage], {
39834
40764
  cwd: this.outputDir,
39835
40765
  stdio: "pipe"
39836
40766
  });
39837
- return (0, import_child_process4.execFileSync)("git", ["rev-parse", "HEAD"], {
40767
+ return (0, import_child_process5.execFileSync)("git", ["rev-parse", "HEAD"], {
39838
40768
  cwd: this.outputDir,
39839
40769
  encoding: "utf-8",
39840
40770
  stdio: "pipe"
@@ -39842,7 +40772,7 @@ ${body}` : `${COMMIT_MARKER} ${subject}`;
39842
40772
  }
39843
40773
  async prependChangelogEntry(params) {
39844
40774
  const { version: version2, entry } = params;
39845
- const changelogPath = (0, import_path16.join)(this.outputDir, "changelog.md");
40775
+ const changelogPath = (0, import_path19.join)(this.outputDir, "changelog.md");
39846
40776
  const now = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
39847
40777
  const header = `## [${version2}] - ${now}
39848
40778
  `;
@@ -39851,7 +40781,7 @@ ${body}` : `${COMMIT_MARKER} ${subject}`;
39851
40781
  `;
39852
40782
  let existing = "";
39853
40783
  if ((0, import_fs14.existsSync)(changelogPath)) {
39854
- existing = await (0, import_promises9.readFile)(changelogPath, "utf-8");
40784
+ existing = await (0, import_promises13.readFile)(changelogPath, "utf-8");
39855
40785
  }
39856
40786
  let output;
39857
40787
  if (existing.trim().length === 0) {
@@ -39868,7 +40798,7 @@ ${newBlock}${remainder}`;
39868
40798
  } else {
39869
40799
  output = `${newBlock}${existing}`;
39870
40800
  }
39871
- await (0, import_promises9.writeFile)(changelogPath, output, "utf-8");
40801
+ await (0, import_promises13.writeFile)(changelogPath, output, "utf-8");
39872
40802
  }
39873
40803
  brandMessage(message) {
39874
40804
  if (this.config.isWhitelabel) {
@@ -40050,8 +40980,8 @@ var GenerationCommitStep = class extends BaseStep {
40050
40980
  };
40051
40981
 
40052
40982
  // src/pipeline/steps/GithubStep.ts
40053
- var import_promises10 = require("fs/promises");
40054
- var import_path17 = require("path");
40983
+ var import_promises14 = require("fs/promises");
40984
+ var import_path20 = require("path");
40055
40985
 
40056
40986
  // src/pipeline/github/createReplayBranch.ts
40057
40987
  async function createReplayBranch(repository, branchName, commitMessage, replayConflictInfo, logger) {
@@ -40668,13 +41598,13 @@ var GithubStep = class extends BaseStep {
40668
41598
  }
40669
41599
  async ensureFernignore() {
40670
41600
  this.logger.debug("Checking for .fernignore file...");
40671
- const fernignorePath = (0, import_path17.join)(this.outputDir, ".fernignore");
41601
+ const fernignorePath = (0, import_path20.join)(this.outputDir, ".fernignore");
40672
41602
  try {
40673
- await (0, import_promises10.access)(fernignorePath);
41603
+ await (0, import_promises14.access)(fernignorePath);
40674
41604
  this.logger.debug(".fernignore already exists");
40675
41605
  } catch {
40676
41606
  this.logger.debug("Creating .fernignore file...");
40677
- await (0, import_promises10.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");
40678
41608
  }
40679
41609
  }
40680
41610
  deriveSkipCommit(replayResult) {
@@ -40896,8 +41826,8 @@ var PostGenerationPipeline = class {
40896
41826
  async run() {
40897
41827
  if (this.config.github?.enabled) {
40898
41828
  try {
40899
- (0, import_child_process5.execFileSync)("git", ["config", "user.name", FERN_BOT_NAME2], { cwd: this.config.outputDir });
40900
- (0, import_child_process5.execFileSync)("git", ["config", "user.email", FERN_BOT_EMAIL2], { cwd: this.config.outputDir });
41829
+ (0, import_child_process6.execFileSync)("git", ["config", "user.name", FERN_BOT_NAME2], { cwd: this.config.outputDir });
41830
+ (0, import_child_process6.execFileSync)("git", ["config", "user.email", FERN_BOT_EMAIL2], { cwd: this.config.outputDir });
40901
41831
  } catch {
40902
41832
  }
40903
41833
  }
@@ -40967,7 +41897,7 @@ void yargs_default(hideBin(process.argv)).scriptName(process.env.CLI_NAME ?? "ge
40967
41897
  const readmeConfig = await loadReadmeConfig({
40968
41898
  absolutePathToConfig: resolve(wd, argv.config)
40969
41899
  });
40970
- const originalReadmeContent = argv.originalReadme != null ? await (0, import_promises11.readFile)(argv.originalReadme, "utf8") : void 0;
41900
+ const originalReadmeContent = argv.originalReadme != null ? await (0, import_promises15.readFile)(argv.originalReadme, "utf8") : void 0;
40971
41901
  await generateReadmeToStream({
40972
41902
  originalReadmeContent,
40973
41903
  readmeConfig,
@@ -41095,7 +42025,7 @@ void yargs_default(hideBin(process.argv)).scriptName(process.env.CLI_NAME ?? "ge
41095
42025
  try {
41096
42026
  const wd = cwd();
41097
42027
  const configValue = argv.config;
41098
- const configContent = configValue === STDIN_MARKER ? await readStdin() : configValue.trimStart().startsWith("{") ? configValue : await (0, import_promises11.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");
41099
42029
  let config;
41100
42030
  try {
41101
42031
  config = JSON.parse(configContent);
@@ -41391,7 +42321,7 @@ async function createWriteStream(outputPath) {
41391
42321
  }
41392
42322
  async function createWriteStreamFromFile(filepath) {
41393
42323
  if (!await doesPathExist(filepath)) {
41394
- await (0, import_promises11.mkdir)(import_path18.default.dirname(filepath), { recursive: true });
42324
+ await (0, import_promises15.mkdir)(import_path21.default.dirname(filepath), { recursive: true });
41395
42325
  }
41396
42326
  return import_fs15.default.createWriteStream(filepath);
41397
42327
  }