@fern-api/replay 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.cjs CHANGED
@@ -5335,6 +5335,14 @@ var init_GitClient = __esm({
5335
5335
  return false;
5336
5336
  }
5337
5337
  }
5338
+ async treeExists(treeHash) {
5339
+ try {
5340
+ const type = await this.exec(["cat-file", "-t", treeHash]);
5341
+ return type.trim() === "tree";
5342
+ } catch {
5343
+ return false;
5344
+ }
5345
+ }
5338
5346
  async getCommitBody(commitSha) {
5339
5347
  return this.exec(["log", "-1", "--format=%B", commitSha]);
5340
5348
  }
@@ -12843,6 +12851,255 @@ var require_brace_expansion = __commonJS({
12843
12851
  }
12844
12852
  });
12845
12853
 
12854
+ // src/HybridReconstruction.ts
12855
+ var HybridReconstruction_exports = {};
12856
+ __export(HybridReconstruction_exports, {
12857
+ assembleHybrid: () => assembleHybrid,
12858
+ locateHunksInOurs: () => locateHunksInOurs,
12859
+ parseHunks: () => parseHunks,
12860
+ reconstructFromGhostPatch: () => reconstructFromGhostPatch
12861
+ });
12862
+ function parseHunks(fileDiff) {
12863
+ const lines = fileDiff.split("\n");
12864
+ const hunks = [];
12865
+ let currentHunk = null;
12866
+ for (const line of lines) {
12867
+ const headerMatch = line.match(
12868
+ /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/
12869
+ );
12870
+ if (headerMatch) {
12871
+ if (currentHunk) {
12872
+ hunks.push(currentHunk);
12873
+ }
12874
+ currentHunk = {
12875
+ oldStart: parseInt(headerMatch[1], 10),
12876
+ oldCount: headerMatch[2] != null ? parseInt(headerMatch[2], 10) : 1,
12877
+ newStart: parseInt(headerMatch[3], 10),
12878
+ newCount: headerMatch[4] != null ? parseInt(headerMatch[4], 10) : 1,
12879
+ lines: []
12880
+ };
12881
+ continue;
12882
+ }
12883
+ if (!currentHunk) continue;
12884
+ if (line.startsWith("diff --git") || line.startsWith("index ") || line.startsWith("---") || line.startsWith("+++") || line.startsWith("old mode") || line.startsWith("new mode") || line.startsWith("similarity index") || line.startsWith("rename from") || line.startsWith("rename to") || line.startsWith("new file mode") || line.startsWith("deleted file mode")) {
12885
+ continue;
12886
+ }
12887
+ if (line === "\") {
12888
+ continue;
12889
+ }
12890
+ if (line.startsWith("-")) {
12891
+ currentHunk.lines.push({ type: "remove", content: line.slice(1) });
12892
+ } else if (line.startsWith("+")) {
12893
+ currentHunk.lines.push({ type: "add", content: line.slice(1) });
12894
+ } else if (line.startsWith(" ") || line === "") {
12895
+ currentHunk.lines.push({
12896
+ type: "context",
12897
+ content: line.startsWith(" ") ? line.slice(1) : line
12898
+ });
12899
+ }
12900
+ }
12901
+ if (currentHunk) {
12902
+ hunks.push(currentHunk);
12903
+ }
12904
+ return hunks;
12905
+ }
12906
+ function extractLeadingContext(hunk) {
12907
+ const result = [];
12908
+ for (const line of hunk.lines) {
12909
+ if (line.type !== "context") break;
12910
+ result.push(line.content);
12911
+ }
12912
+ return result;
12913
+ }
12914
+ function extractTrailingContext(hunk) {
12915
+ const result = [];
12916
+ for (let i = hunk.lines.length - 1; i >= 0; i--) {
12917
+ if (hunk.lines[i].type !== "context") break;
12918
+ result.unshift(hunk.lines[i].content);
12919
+ }
12920
+ return result;
12921
+ }
12922
+ function countOursLinesBeforeTrailing(hunk) {
12923
+ let count = 0;
12924
+ const trailingStart = findTrailingContextStart(hunk);
12925
+ for (let i = 0; i < trailingStart; i++) {
12926
+ if (hunk.lines[i].type === "context") count++;
12927
+ }
12928
+ return count;
12929
+ }
12930
+ function findTrailingContextStart(hunk) {
12931
+ let i = hunk.lines.length - 1;
12932
+ while (i >= 0 && hunk.lines[i].type === "context") {
12933
+ i--;
12934
+ }
12935
+ return i + 1;
12936
+ }
12937
+ function matchesAt(needle, haystack, offset) {
12938
+ for (let i = 0; i < needle.length; i++) {
12939
+ if (haystack[offset + i] !== needle[i]) return false;
12940
+ }
12941
+ return true;
12942
+ }
12943
+ function findContextInOurs(contextLines, oursLines, minIndex, hint) {
12944
+ const SEARCH_WINDOW = 200;
12945
+ const maxStart = oursLines.length - contextLines.length;
12946
+ const clampedHint = Math.max(minIndex, Math.min(hint, maxStart));
12947
+ if (clampedHint >= minIndex && clampedHint <= maxStart) {
12948
+ if (matchesAt(contextLines, oursLines, clampedHint)) {
12949
+ return clampedHint;
12950
+ }
12951
+ }
12952
+ for (let delta = 1; delta <= SEARCH_WINDOW; delta++) {
12953
+ for (const sign of [1, -1]) {
12954
+ const idx = clampedHint + delta * sign;
12955
+ if (idx < minIndex || idx > maxStart) continue;
12956
+ if (matchesAt(contextLines, oursLines, idx)) {
12957
+ return idx;
12958
+ }
12959
+ }
12960
+ }
12961
+ return -1;
12962
+ }
12963
+ function computeOursSpan(hunk, oursLines, oursOffset) {
12964
+ const leading = extractLeadingContext(hunk);
12965
+ const trailing = extractTrailingContext(hunk);
12966
+ if (trailing.length === 0) {
12967
+ const contextCount2 = hunk.lines.filter(
12968
+ (l) => l.type === "context"
12969
+ ).length;
12970
+ return Math.min(contextCount2, oursLines.length - oursOffset);
12971
+ }
12972
+ const searchStart = oursOffset + leading.length;
12973
+ for (let i = searchStart; i <= oursLines.length - trailing.length; i++) {
12974
+ if (matchesAt(trailing, oursLines, i)) {
12975
+ return i + trailing.length - oursOffset;
12976
+ }
12977
+ }
12978
+ const contextCount = hunk.lines.filter(
12979
+ (l) => l.type === "context"
12980
+ ).length;
12981
+ return Math.min(contextCount, oursLines.length - oursOffset);
12982
+ }
12983
+ function locateHunksInOurs(hunks, oursLines) {
12984
+ const located = [];
12985
+ let minOursIndex = 0;
12986
+ for (const hunk of hunks) {
12987
+ const contextLines = extractLeadingContext(hunk);
12988
+ let oursOffset;
12989
+ if (contextLines.length > 0) {
12990
+ const found = findContextInOurs(
12991
+ contextLines,
12992
+ oursLines,
12993
+ minOursIndex,
12994
+ hunk.newStart - 1
12995
+ );
12996
+ if (found === -1) {
12997
+ const trailingContext = extractTrailingContext(hunk);
12998
+ if (trailingContext.length > 0) {
12999
+ const trailingFound = findContextInOurs(
13000
+ trailingContext,
13001
+ oursLines,
13002
+ minOursIndex,
13003
+ hunk.newStart - 1
13004
+ );
13005
+ if (trailingFound === -1) return null;
13006
+ const nonTrailingCount = countOursLinesBeforeTrailing(hunk);
13007
+ oursOffset = trailingFound - nonTrailingCount;
13008
+ if (oursOffset < minOursIndex) return null;
13009
+ } else {
13010
+ return null;
13011
+ }
13012
+ } else {
13013
+ oursOffset = found;
13014
+ }
13015
+ } else if (hunk.oldStart === 1 && hunk.oldCount === 0) {
13016
+ oursOffset = 0;
13017
+ } else {
13018
+ oursOffset = Math.max(hunk.newStart - 1, minOursIndex);
13019
+ }
13020
+ const oursSpan = computeOursSpan(hunk, oursLines, oursOffset);
13021
+ located.push({ hunk, oursOffset, oursSpan });
13022
+ minOursIndex = oursOffset + oursSpan;
13023
+ }
13024
+ return located;
13025
+ }
13026
+ function assembleHybrid(locatedHunks, oursLines) {
13027
+ const baseLines = [];
13028
+ const theirsLines = [];
13029
+ let oursCursor = 0;
13030
+ for (const { hunk, oursOffset, oursSpan } of locatedHunks) {
13031
+ if (oursOffset > oursCursor) {
13032
+ const gapLines = oursLines.slice(oursCursor, oursOffset);
13033
+ baseLines.push(...gapLines);
13034
+ theirsLines.push(...gapLines);
13035
+ }
13036
+ for (const line of hunk.lines) {
13037
+ switch (line.type) {
13038
+ case "context":
13039
+ baseLines.push(line.content);
13040
+ theirsLines.push(line.content);
13041
+ break;
13042
+ case "remove":
13043
+ baseLines.push(line.content);
13044
+ break;
13045
+ case "add":
13046
+ theirsLines.push(line.content);
13047
+ break;
13048
+ }
13049
+ }
13050
+ oursCursor = oursOffset + oursSpan;
13051
+ }
13052
+ if (oursCursor < oursLines.length) {
13053
+ const gapLines = oursLines.slice(oursCursor);
13054
+ baseLines.push(...gapLines);
13055
+ theirsLines.push(...gapLines);
13056
+ }
13057
+ return {
13058
+ base: baseLines.join("\n"),
13059
+ theirs: theirsLines.join("\n")
13060
+ };
13061
+ }
13062
+ function reconstructFromGhostPatch(fileDiff, ours) {
13063
+ const hunks = parseHunks(fileDiff);
13064
+ if (hunks.length === 0) {
13065
+ return null;
13066
+ }
13067
+ const isPureAddition = hunks.every(
13068
+ (h) => h.oldCount === 0 && h.lines.every((l) => l.type !== "remove")
13069
+ );
13070
+ if (isPureAddition) {
13071
+ return null;
13072
+ }
13073
+ const isPureDeletion = hunks.every(
13074
+ (h) => h.newCount === 0 && h.lines.every((l) => l.type !== "add")
13075
+ );
13076
+ if (isPureDeletion) {
13077
+ const baseLines = [];
13078
+ for (const hunk of hunks) {
13079
+ for (const line of hunk.lines) {
13080
+ if (line.type === "context" || line.type === "remove") {
13081
+ baseLines.push(line.content);
13082
+ }
13083
+ }
13084
+ }
13085
+ return {
13086
+ base: baseLines.join("\n"),
13087
+ theirs: ""
13088
+ };
13089
+ }
13090
+ const oursLines = ours.split("\n");
13091
+ const located = locateHunksInOurs(hunks, oursLines);
13092
+ if (!located) {
13093
+ return null;
13094
+ }
13095
+ return assembleHybrid(located, oursLines);
13096
+ }
13097
+ var init_HybridReconstruction = __esm({
13098
+ "src/HybridReconstruction.ts"() {
13099
+ "use strict";
13100
+ }
13101
+ });
13102
+
12846
13103
  // src/cli.ts
12847
13104
  var import_node_path7 = require("path");
12848
13105
  var import_node_fs6 = require("fs");
@@ -12854,6 +13111,12 @@ var import_node_fs = require("fs");
12854
13111
  var import_node_path2 = require("path");
12855
13112
  var import_yaml = __toESM(require_dist3(), 1);
12856
13113
  var LOCKFILE_HEADER = "# DO NOT EDIT MANUALLY - Managed by Fern Replay\n";
13114
+ var LockfileNotFoundError = class extends Error {
13115
+ constructor(path2) {
13116
+ super(`Lockfile not found: ${path2}`);
13117
+ this.name = "LockfileNotFoundError";
13118
+ }
13119
+ };
12857
13120
  var LockfileManager = class {
12858
13121
  outputDir;
12859
13122
  lock = null;
@@ -12873,12 +13136,16 @@ var LockfileManager = class {
12873
13136
  if (this.lock) {
12874
13137
  return this.lock;
12875
13138
  }
12876
- if (!this.exists()) {
12877
- throw new Error(`Lockfile not found: ${this.lockfilePath}`);
13139
+ try {
13140
+ const content = (0, import_node_fs.readFileSync)(this.lockfilePath, "utf-8");
13141
+ this.lock = (0, import_yaml.parse)(content);
13142
+ return this.lock;
13143
+ } catch (error) {
13144
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
13145
+ throw new LockfileNotFoundError(this.lockfilePath);
13146
+ }
13147
+ throw error;
12878
13148
  }
12879
- const content = (0, import_node_fs.readFileSync)(this.lockfilePath, "utf-8");
12880
- this.lock = (0, import_yaml.parse)(content);
12881
- return this.lock;
12882
13149
  }
12883
13150
  initialize(firstGeneration) {
12884
13151
  this.initializeInMemory(firstGeneration);
@@ -13055,13 +13322,21 @@ var ReplayDetector = class {
13055
13322
  const exists2 = await this.git.commitExists(lastGen.commit_sha);
13056
13323
  if (!exists2) {
13057
13324
  this.warnings.push(
13058
- `Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Skipping new patch detection. Existing lockfile patches will still be applied.`
13325
+ `Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to alternate detection.`
13326
+ );
13327
+ return this.detectPatchesViaTreeDiff(
13328
+ lastGen,
13329
+ /* commitKnownMissing */
13330
+ true
13059
13331
  );
13060
- return { patches: [], revertedPatchIds: [] };
13061
13332
  }
13062
13333
  const isAncestor = await this.git.isAncestor(lastGen.commit_sha, "HEAD");
13063
13334
  if (!isAncestor) {
13064
- return this.detectPatchesViaTreeDiff(lastGen);
13335
+ return this.detectPatchesViaTreeDiff(
13336
+ lastGen,
13337
+ /* commitKnownMissing */
13338
+ false
13339
+ );
13065
13340
  }
13066
13341
  const log = await this.git.exec([
13067
13342
  "log",
@@ -13087,7 +13362,15 @@ var ReplayDetector = class {
13087
13362
  if (lock.patches.find((p) => p.original_commit === commit.sha)) {
13088
13363
  continue;
13089
13364
  }
13090
- const patchContent = await this.git.formatPatch(commit.sha);
13365
+ let patchContent;
13366
+ try {
13367
+ patchContent = await this.git.formatPatch(commit.sha);
13368
+ } catch {
13369
+ this.warnings.push(
13370
+ `Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
13371
+ );
13372
+ continue;
13373
+ }
13091
13374
  const contentHash = this.computeContentHash(patchContent);
13092
13375
  if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
13093
13376
  continue;
@@ -13180,11 +13463,15 @@ var ReplayDetector = class {
13180
13463
  * Revert reconciliation is skipped here because tree-diff produces a single composite
13181
13464
  * patch from the aggregate diff — individual revert commits are not distinguishable.
13182
13465
  */
13183
- async detectPatchesViaTreeDiff(lastGen) {
13184
- const filesOutput = await this.git.exec(["diff", "--name-only", lastGen.commit_sha, "HEAD"]);
13466
+ async detectPatchesViaTreeDiff(lastGen, commitKnownMissing) {
13467
+ const diffBase = await this.resolveDiffBase(lastGen, commitKnownMissing);
13468
+ if (!diffBase) {
13469
+ return this.detectPatchesViaCommitScan();
13470
+ }
13471
+ const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
13185
13472
  const files = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
13186
13473
  if (files.length === 0) return { patches: [], revertedPatchIds: [] };
13187
- const diff = await this.git.exec(["diff", lastGen.commit_sha, "HEAD", "--", ...files]);
13474
+ const diff = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
13188
13475
  if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
13189
13476
  const contentHash = this.computeContentHash(diff);
13190
13477
  const lock = this.lockManager.read();
@@ -13198,12 +13485,113 @@ var ReplayDetector = class {
13198
13485
  original_commit: headSha,
13199
13486
  original_message: "Customer customizations (composite)",
13200
13487
  original_author: "composite",
13201
- base_generation: lastGen.commit_sha,
13488
+ // Use diffBase when commit is unreachable — the applicator needs a reachable
13489
+ // reference to find base file content. diffBase may be the tree_hash.
13490
+ base_generation: commitKnownMissing ? diffBase : lastGen.commit_sha,
13202
13491
  files,
13203
13492
  patch_content: diff
13204
13493
  };
13205
13494
  return { patches: [compositePatch], revertedPatchIds: [] };
13206
13495
  }
13496
+ /**
13497
+ * Last-resort detection when both generation commit and tree are unreachable.
13498
+ * Scans all commits from HEAD, filters against known lockfile patches, and
13499
+ * skips creation-only commits (squashed history after force push).
13500
+ *
13501
+ * Detected patches use the commit's parent as base_generation so the applicator
13502
+ * can find base file content from the parent's tree (which IS reachable).
13503
+ */
13504
+ async detectPatchesViaCommitScan() {
13505
+ const lock = this.lockManager.read();
13506
+ const log = await this.git.exec([
13507
+ "log",
13508
+ "--max-count=200",
13509
+ "--format=%H%x00%an%x00%ae%x00%s",
13510
+ "HEAD",
13511
+ "--",
13512
+ this.sdkOutputDir
13513
+ ]);
13514
+ if (!log.trim()) {
13515
+ return { patches: [], revertedPatchIds: [] };
13516
+ }
13517
+ const commits = this.parseGitLog(log);
13518
+ const newPatches = [];
13519
+ const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
13520
+ const existingHashes = new Set(lock.patches.map((p) => p.content_hash));
13521
+ const existingCommits = new Set(lock.patches.map((p) => p.original_commit));
13522
+ for (const commit of commits) {
13523
+ if (isGenerationCommit(commit)) {
13524
+ continue;
13525
+ }
13526
+ const parents = await this.git.getCommitParents(commit.sha);
13527
+ if (parents.length > 1) {
13528
+ continue;
13529
+ }
13530
+ if (existingCommits.has(commit.sha)) {
13531
+ continue;
13532
+ }
13533
+ let patchContent;
13534
+ try {
13535
+ patchContent = await this.git.formatPatch(commit.sha);
13536
+ } catch {
13537
+ continue;
13538
+ }
13539
+ const contentHash = this.computeContentHash(patchContent);
13540
+ if (existingHashes.has(contentHash) || forgottenHashes.has(contentHash)) {
13541
+ continue;
13542
+ }
13543
+ if (this.isCreationOnlyPatch(patchContent)) {
13544
+ continue;
13545
+ }
13546
+ const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
13547
+ const files = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f));
13548
+ if (files.length === 0) {
13549
+ continue;
13550
+ }
13551
+ if (parents.length === 0) {
13552
+ continue;
13553
+ }
13554
+ const parentSha = parents[0];
13555
+ newPatches.push({
13556
+ id: `patch-${commit.sha.slice(0, 8)}`,
13557
+ content_hash: contentHash,
13558
+ original_commit: commit.sha,
13559
+ original_message: commit.message,
13560
+ original_author: `${commit.authorName} <${commit.authorEmail}>`,
13561
+ base_generation: parentSha,
13562
+ files,
13563
+ patch_content: patchContent
13564
+ });
13565
+ }
13566
+ newPatches.reverse();
13567
+ return { patches: newPatches, revertedPatchIds: [] };
13568
+ }
13569
+ /**
13570
+ * Check if a format-patch consists entirely of new-file creations.
13571
+ * Used to identify squashed commits after force push, which create all files
13572
+ * from scratch (--- /dev/null) rather than modifying existing files.
13573
+ */
13574
+ isCreationOnlyPatch(patchContent) {
13575
+ const diffOldHeaders = patchContent.split("\n").filter((l) => l.startsWith("--- "));
13576
+ if (diffOldHeaders.length === 0) {
13577
+ return false;
13578
+ }
13579
+ return diffOldHeaders.every((l) => l === "--- /dev/null");
13580
+ }
13581
+ /**
13582
+ * Resolve the best available diff base for a generation record.
13583
+ * Prefers commit_sha, falls back to tree_hash for unreachable commits.
13584
+ * When commitKnownMissing is true, skips the redundant commitExists check.
13585
+ */
13586
+ async resolveDiffBase(gen, commitKnownMissing) {
13587
+ if (!commitKnownMissing && await this.git.commitExists(gen.commit_sha)) {
13588
+ return gen.commit_sha;
13589
+ }
13590
+ if (await this.git.treeExists(gen.tree_hash)) {
13591
+ return gen.tree_hash;
13592
+ }
13593
+ return null;
13594
+ }
13207
13595
  parseGitLog(log) {
13208
13596
  return log.trim().split("\n").map((line) => {
13209
13597
  const [sha, authorName, authorEmail, message] = line.split("\0");
@@ -14558,29 +14946,74 @@ var import_node_os = require("os");
14558
14946
  var import_node_path3 = require("path");
14559
14947
 
14560
14948
  // src/conflict-utils.ts
14949
+ var CONFLICT_OPENER = "<<<<<<< Generated";
14950
+ var CONFLICT_SEPARATOR = "=======";
14951
+ var CONFLICT_CLOSER = ">>>>>>> Your customization";
14952
+ function trimCR(line) {
14953
+ return line.endsWith("\r") ? line.slice(0, -1) : line;
14954
+ }
14955
+ function findConflictRanges(lines) {
14956
+ const ranges = [];
14957
+ let i = 0;
14958
+ while (i < lines.length) {
14959
+ if (trimCR(lines[i]) === CONFLICT_OPENER) {
14960
+ let separatorIdx = -1;
14961
+ let j = i + 1;
14962
+ let found = false;
14963
+ while (j < lines.length) {
14964
+ const trimmed2 = trimCR(lines[j]);
14965
+ if (trimmed2 === CONFLICT_OPENER) {
14966
+ break;
14967
+ }
14968
+ if (separatorIdx === -1 && trimmed2 === CONFLICT_SEPARATOR) {
14969
+ separatorIdx = j;
14970
+ } else if (separatorIdx !== -1 && trimmed2 === CONFLICT_CLOSER) {
14971
+ ranges.push({ start: i, separator: separatorIdx, end: j });
14972
+ i = j;
14973
+ found = true;
14974
+ break;
14975
+ }
14976
+ j++;
14977
+ }
14978
+ if (!found) {
14979
+ i++;
14980
+ continue;
14981
+ }
14982
+ }
14983
+ i++;
14984
+ }
14985
+ return ranges;
14986
+ }
14561
14987
  function stripConflictMarkers(content) {
14562
- const lines = content.split("\n");
14988
+ const lines = content.split(/\r?\n/);
14989
+ const ranges = findConflictRanges(lines);
14990
+ if (ranges.length === 0) {
14991
+ return content;
14992
+ }
14563
14993
  const result = [];
14564
- let inConflict = false;
14565
- let inOurs = false;
14566
- for (const line of lines) {
14567
- if (line.startsWith("<<<<<<< ")) {
14568
- inConflict = true;
14569
- inOurs = true;
14570
- continue;
14571
- }
14572
- if (inConflict && line === "=======") {
14573
- inOurs = false;
14574
- continue;
14575
- }
14576
- if (inConflict && line.startsWith(">>>>>>> ")) {
14577
- inConflict = false;
14578
- inOurs = false;
14579
- continue;
14580
- }
14581
- if (!inConflict || inOurs) {
14582
- result.push(line);
14994
+ let rangeIdx = 0;
14995
+ for (let i = 0; i < lines.length; i++) {
14996
+ if (rangeIdx < ranges.length) {
14997
+ const range = ranges[rangeIdx];
14998
+ if (i === range.start) {
14999
+ continue;
15000
+ }
15001
+ if (i > range.start && i < range.separator) {
15002
+ result.push(lines[i]);
15003
+ continue;
15004
+ }
15005
+ if (i === range.separator) {
15006
+ continue;
15007
+ }
15008
+ if (i > range.separator && i < range.end) {
15009
+ continue;
15010
+ }
15011
+ if (i === range.end) {
15012
+ rangeIdx++;
15013
+ continue;
15014
+ }
14583
15015
  }
15016
+ result.push(lines[i]);
14584
15017
  }
14585
15018
  return result.join("\n");
14586
15019
  }
@@ -14651,6 +15084,37 @@ function diffIndices(buffer1, buffer2) {
14651
15084
  result.reverse();
14652
15085
  return result;
14653
15086
  }
15087
+ function diffPatch(buffer1, buffer2) {
15088
+ const lcs = LCS(buffer1, buffer2);
15089
+ let result = [];
15090
+ let tail1 = buffer1.length;
15091
+ let tail2 = buffer2.length;
15092
+ function chunkDescription(buffer, offset, length) {
15093
+ let chunk = [];
15094
+ for (let i = 0; i < length; i++) {
15095
+ chunk.push(buffer[offset + i]);
15096
+ }
15097
+ return {
15098
+ offset,
15099
+ length,
15100
+ chunk
15101
+ };
15102
+ }
15103
+ for (let candidate = lcs; candidate !== null; candidate = candidate.chain) {
15104
+ const mismatchLength1 = tail1 - candidate.buffer1index - 1;
15105
+ const mismatchLength2 = tail2 - candidate.buffer2index - 1;
15106
+ tail1 = candidate.buffer1index;
15107
+ tail2 = candidate.buffer2index;
15108
+ if (mismatchLength1 || mismatchLength2) {
15109
+ result.push({
15110
+ buffer1: chunkDescription(buffer1, candidate.buffer1index + 1, mismatchLength1),
15111
+ buffer2: chunkDescription(buffer2, candidate.buffer2index + 1, mismatchLength2)
15112
+ });
15113
+ }
15114
+ }
15115
+ result.reverse();
15116
+ return result;
15117
+ }
14654
15118
  function diff3MergeRegions(a, o, b) {
14655
15119
  let hunks = [];
14656
15120
  function addHunk(h, ab) {
@@ -14813,20 +15277,33 @@ function threeWayMerge(base, ours, theirs) {
14813
15277
  outputLines.push(...region.ok);
14814
15278
  currentLine += region.ok.length;
14815
15279
  } else if (region.conflict) {
14816
- const startLine = currentLine;
14817
- outputLines.push("<<<<<<< Generated");
14818
- outputLines.push(...region.conflict.a);
14819
- outputLines.push("=======");
14820
- outputLines.push(...region.conflict.b);
14821
- outputLines.push(">>>>>>> Your customization");
14822
- const conflictLines = region.conflict.a.length + region.conflict.b.length + 3;
14823
- conflicts2.push({
14824
- startLine,
14825
- endLine: startLine + conflictLines - 1,
14826
- ours: region.conflict.a,
14827
- theirs: region.conflict.b
14828
- });
14829
- currentLine += conflictLines;
15280
+ const resolved = tryResolveConflict(
15281
+ region.conflict.a,
15282
+ // ours (generator)
15283
+ region.conflict.o,
15284
+ // base
15285
+ region.conflict.b
15286
+ // theirs (user)
15287
+ );
15288
+ if (resolved !== null) {
15289
+ outputLines.push(...resolved);
15290
+ currentLine += resolved.length;
15291
+ } else {
15292
+ const startLine = currentLine;
15293
+ outputLines.push("<<<<<<< Generated");
15294
+ outputLines.push(...region.conflict.a);
15295
+ outputLines.push("=======");
15296
+ outputLines.push(...region.conflict.b);
15297
+ outputLines.push(">>>>>>> Your customization");
15298
+ const conflictLines = region.conflict.a.length + region.conflict.b.length + 3;
15299
+ conflicts2.push({
15300
+ startLine,
15301
+ endLine: startLine + conflictLines - 1,
15302
+ ours: region.conflict.a,
15303
+ theirs: region.conflict.b
15304
+ });
15305
+ currentLine += conflictLines;
15306
+ }
14830
15307
  }
14831
15308
  }
14832
15309
  return {
@@ -14835,6 +15312,62 @@ function threeWayMerge(base, ours, theirs) {
14835
15312
  conflicts: conflicts2
14836
15313
  };
14837
15314
  }
15315
+ function tryResolveConflict(oursLines, baseLines, theirsLines) {
15316
+ if (baseLines.length === 0) {
15317
+ return null;
15318
+ }
15319
+ const oursPatches = diffPatch(baseLines, oursLines);
15320
+ const theirsPatches = diffPatch(baseLines, theirsLines);
15321
+ if (oursPatches.length === 0) return theirsLines;
15322
+ if (theirsPatches.length === 0) return oursLines;
15323
+ if (patchesOverlap(oursPatches, theirsPatches)) {
15324
+ return null;
15325
+ }
15326
+ return applyBothPatches(baseLines, oursPatches, theirsPatches);
15327
+ }
15328
+ function patchesOverlap(oursPatches, theirsPatches) {
15329
+ for (const op of oursPatches) {
15330
+ const oStart = op.buffer1.offset;
15331
+ const oEnd = oStart + op.buffer1.length;
15332
+ for (const tp of theirsPatches) {
15333
+ const tStart = tp.buffer1.offset;
15334
+ const tEnd = tStart + tp.buffer1.length;
15335
+ if (op.buffer1.length === 0 && tp.buffer1.length === 0 && oStart === tStart) {
15336
+ return true;
15337
+ }
15338
+ if (tp.buffer1.length === 0 && tStart === oEnd) {
15339
+ return true;
15340
+ }
15341
+ if (op.buffer1.length === 0 && oStart === tEnd) {
15342
+ return true;
15343
+ }
15344
+ if (oStart < tEnd && tStart < oEnd) {
15345
+ return true;
15346
+ }
15347
+ }
15348
+ }
15349
+ return false;
15350
+ }
15351
+ function applyBothPatches(baseLines, oursPatches, theirsPatches) {
15352
+ const allPatches = [
15353
+ ...oursPatches.map((p) => ({
15354
+ offset: p.buffer1.offset,
15355
+ length: p.buffer1.length,
15356
+ replacement: p.buffer2.chunk
15357
+ })),
15358
+ ...theirsPatches.map((p) => ({
15359
+ offset: p.buffer1.offset,
15360
+ length: p.buffer1.length,
15361
+ replacement: p.buffer2.chunk
15362
+ }))
15363
+ ];
15364
+ allPatches.sort((a, b) => b.offset - a.offset);
15365
+ const result = [...baseLines];
15366
+ for (const p of allPatches) {
15367
+ result.splice(p.offset, p.length, ...p.replacement);
15368
+ }
15369
+ return result;
15370
+ }
14838
15371
 
14839
15372
  // src/ReplayApplicator.ts
14840
15373
  var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
@@ -14891,12 +15424,34 @@ var ReplayApplicator = class {
14891
15424
  lockManager;
14892
15425
  outputDir;
14893
15426
  renameCache = /* @__PURE__ */ new Map();
15427
+ treeExistsCache = /* @__PURE__ */ new Map();
14894
15428
  fileTheirsAccumulator = /* @__PURE__ */ new Map();
14895
15429
  constructor(git, lockManager, outputDir) {
14896
15430
  this.git = git;
14897
15431
  this.lockManager = lockManager;
14898
15432
  this.outputDir = outputDir;
14899
15433
  }
15434
+ /**
15435
+ * Resolve the GenerationRecord for a patch's base_generation.
15436
+ * Falls back to constructing an ad-hoc record from the commit's tree
15437
+ * when base_generation isn't a tracked generation (commit-scan patches).
15438
+ */
15439
+ async resolveBaseGeneration(baseGeneration) {
15440
+ const gen = this.lockManager.getGeneration(baseGeneration);
15441
+ if (gen) return gen;
15442
+ try {
15443
+ const treeHash = await this.git.getTreeHash(baseGeneration);
15444
+ return {
15445
+ commit_sha: baseGeneration,
15446
+ tree_hash: treeHash,
15447
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
15448
+ cli_version: "unknown",
15449
+ generator_versions: {}
15450
+ };
15451
+ } catch {
15452
+ return void 0;
15453
+ }
15454
+ }
14900
15455
  /** Reset inter-patch accumulator for a new cycle. */
14901
15456
  resetAccumulator() {
14902
15457
  this.fileTheirsAccumulator.clear();
@@ -14978,7 +15533,7 @@ var ReplayApplicator = class {
14978
15533
  }
14979
15534
  }
14980
15535
  async applyPatchWithFallback(patch) {
14981
- const baseGen = this.lockManager.getGeneration(patch.base_generation);
15536
+ const baseGen = await this.resolveBaseGeneration(patch.base_generation);
14982
15537
  const lock = this.lockManager.read();
14983
15538
  const currentGen = lock.generations.find((g) => g.commit_sha === lock.current_generation);
14984
15539
  const currentTreeHash = currentGen?.tree_hash ?? baseGen?.tree_hash ?? "";
@@ -15062,7 +15617,7 @@ var ReplayApplicator = class {
15062
15617
  }
15063
15618
  async mergeFile(patch, filePath, tempGit, tempDir) {
15064
15619
  try {
15065
- const baseGen = this.lockManager.getGeneration(patch.base_generation);
15620
+ const baseGen = await this.resolveBaseGeneration(patch.base_generation);
15066
15621
  if (!baseGen) {
15067
15622
  return { file: filePath, status: "skipped", reason: "base-generation-not-found" };
15068
15623
  }
@@ -15087,14 +15642,44 @@ var ReplayApplicator = class {
15087
15642
  }
15088
15643
  const oursPath = (0, import_node_path3.join)(this.outputDir, resolvedPath);
15089
15644
  const ours = await (0, import_promises.readFile)(oursPath, "utf-8").catch(() => null);
15090
- let theirs = await this.applyPatchToContent(
15091
- base,
15092
- patch.patch_content,
15093
- filePath,
15094
- tempGit,
15095
- tempDir,
15096
- renameSourcePath
15097
- );
15645
+ let ghostReconstructed = false;
15646
+ let theirs = null;
15647
+ if (!base && ours && !renameSourcePath) {
15648
+ const treeReachable = await this.isTreeReachable(baseGen.tree_hash);
15649
+ if (!treeReachable) {
15650
+ const fileDiff = this.extractFileDiff(patch.patch_content, filePath);
15651
+ if (fileDiff) {
15652
+ const { reconstructFromGhostPatch: reconstructFromGhostPatch2 } = await Promise.resolve().then(() => (init_HybridReconstruction(), HybridReconstruction_exports));
15653
+ const result = reconstructFromGhostPatch2(fileDiff, ours);
15654
+ if (result) {
15655
+ base = result.base;
15656
+ theirs = result.theirs;
15657
+ ghostReconstructed = true;
15658
+ }
15659
+ }
15660
+ }
15661
+ }
15662
+ if (!ghostReconstructed) {
15663
+ theirs = await this.applyPatchToContent(
15664
+ base,
15665
+ patch.patch_content,
15666
+ filePath,
15667
+ tempGit,
15668
+ tempDir,
15669
+ renameSourcePath
15670
+ );
15671
+ }
15672
+ if (theirs) {
15673
+ const theirsHasMarkers = theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization");
15674
+ const baseHasMarkers = base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization"));
15675
+ if (theirsHasMarkers && !baseHasMarkers) {
15676
+ return {
15677
+ file: resolvedPath,
15678
+ status: "skipped",
15679
+ reason: "stale-conflict-markers"
15680
+ };
15681
+ }
15682
+ }
15098
15683
  let useAccumulatorAsMergeBase = false;
15099
15684
  const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
15100
15685
  if (!theirs && accumulatorEntry) {
@@ -15109,6 +15694,17 @@ var ReplayApplicator = class {
15109
15694
  useAccumulatorAsMergeBase = true;
15110
15695
  }
15111
15696
  }
15697
+ if (theirs) {
15698
+ const theirsHasMarkers = theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization");
15699
+ const accBaseHasMarkers = accumulatorEntry != null && (accumulatorEntry.content.includes("<<<<<<< Generated") || accumulatorEntry.content.includes(">>>>>>> Your customization"));
15700
+ if (theirsHasMarkers && !accBaseHasMarkers && !(base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization")))) {
15701
+ return {
15702
+ file: resolvedPath,
15703
+ status: "skipped",
15704
+ reason: "stale-conflict-markers"
15705
+ };
15706
+ }
15707
+ }
15112
15708
  let effective_theirs = theirs;
15113
15709
  let baseMismatchSkipped = false;
15114
15710
  if (theirs && base && !useAccumulatorAsMergeBase) {
@@ -15127,13 +15723,13 @@ var ReplayApplicator = class {
15127
15723
  baseMismatchSkipped = true;
15128
15724
  }
15129
15725
  }
15130
- if (!base && !ours && effective_theirs) {
15726
+ if (base == null && !ours && effective_theirs) {
15131
15727
  const outDir2 = (0, import_node_path3.dirname)(oursPath);
15132
15728
  await (0, import_promises.mkdir)(outDir2, { recursive: true });
15133
15729
  await (0, import_promises.writeFile)(oursPath, effective_theirs);
15134
15730
  return { file: resolvedPath, status: "merged", reason: "new-file" };
15135
15731
  }
15136
- if (!base && ours && effective_theirs) {
15732
+ if (base == null && ours && effective_theirs) {
15137
15733
  const merged2 = threeWayMerge("", ours, effective_theirs);
15138
15734
  const outDir2 = (0, import_node_path3.dirname)(oursPath);
15139
15735
  await (0, import_promises.mkdir)(outDir2, { recursive: true });
@@ -15156,7 +15752,7 @@ var ReplayApplicator = class {
15156
15752
  reason: "missing-content"
15157
15753
  };
15158
15754
  }
15159
- if (!base && !useAccumulatorAsMergeBase || !ours) {
15755
+ if (base == null && !useAccumulatorAsMergeBase || !ours) {
15160
15756
  return {
15161
15757
  file: resolvedPath,
15162
15758
  status: "skipped",
@@ -15175,7 +15771,7 @@ var ReplayApplicator = class {
15175
15771
  const outDir = (0, import_node_path3.dirname)(oursPath);
15176
15772
  await (0, import_promises.mkdir)(outDir, { recursive: true });
15177
15773
  await (0, import_promises.writeFile)(oursPath, merged.content);
15178
- if (effective_theirs) {
15774
+ if (effective_theirs && !merged.hasConflicts) {
15179
15775
  this.fileTheirsAccumulator.set(resolvedPath, {
15180
15776
  content: effective_theirs,
15181
15777
  baseGeneration: patch.base_generation
@@ -15199,6 +15795,14 @@ var ReplayApplicator = class {
15199
15795
  };
15200
15796
  }
15201
15797
  }
15798
+ async isTreeReachable(treeHash) {
15799
+ let result = this.treeExistsCache.get(treeHash);
15800
+ if (result === void 0) {
15801
+ result = await this.git.treeExists(treeHash);
15802
+ this.treeExistsCache.set(treeHash, result);
15803
+ }
15804
+ return result;
15805
+ }
15202
15806
  isExcluded(patch) {
15203
15807
  const config = this.lockManager.getCustomizationsConfig();
15204
15808
  if (!config.exclude) return false;
@@ -15465,6 +16069,7 @@ var ReplayService = class {
15465
16069
  generator_versions: options?.generatorVersions ?? {},
15466
16070
  base_branch_head: options?.baseBranchHead
15467
16071
  };
16072
+ let resolvedPatches;
15468
16073
  if (!this.lockManager.exists()) {
15469
16074
  this.lockManager.initializeInMemory(record);
15470
16075
  } else {
@@ -15473,13 +16078,13 @@ var ReplayService = class {
15473
16078
  ...this.lockManager.getUnresolvedPatches(),
15474
16079
  ...this.lockManager.getResolvingPatches()
15475
16080
  ];
16081
+ resolvedPatches = this.lockManager.getPatches().filter((p) => p.status == null);
15476
16082
  this.lockManager.addGeneration(record);
15477
16083
  this.lockManager.clearPatches();
15478
16084
  for (const patch of unresolvedPatches) {
15479
16085
  this.lockManager.addPatch(patch);
15480
16086
  }
15481
16087
  }
15482
- this.lockManager.save();
15483
16088
  try {
15484
16089
  const { patches: redetectedPatches } = await this.detector.detectNewPatches();
15485
16090
  if (redetectedPatches.length > 0) {
@@ -15493,20 +16098,27 @@ var ReplayService = class {
15493
16098
  for (const patch of redetectedPatches) {
15494
16099
  this.lockManager.addPatch(patch);
15495
16100
  }
15496
- this.lockManager.save();
15497
16101
  }
15498
- } catch {
16102
+ } catch (error) {
16103
+ for (const patch of resolvedPatches ?? []) {
16104
+ this.lockManager.addPatch(patch);
16105
+ }
16106
+ this.detector.warnings.push(
16107
+ `Patch re-detection failed after divergent merge sync. ${(resolvedPatches ?? []).length} previously resolved patch(es) preserved. Error: ${error instanceof Error ? error.message : String(error)}`
16108
+ );
15499
16109
  }
16110
+ this.lockManager.save();
15500
16111
  }
15501
16112
  determineFlow() {
15502
- if (!this.lockManager.exists()) {
15503
- return "first-generation";
15504
- }
15505
- const lock = this.lockManager.read();
15506
- if (lock.patches.length === 0) {
15507
- return "no-patches";
16113
+ try {
16114
+ const lock = this.lockManager.read();
16115
+ return lock.patches.length === 0 ? "no-patches" : "normal-regeneration";
16116
+ } catch (error) {
16117
+ if (error instanceof LockfileNotFoundError) {
16118
+ return "first-generation";
16119
+ }
16120
+ throw error;
15508
16121
  }
15509
- return "normal-regeneration";
15510
16122
  }
15511
16123
  async handleFirstGeneration(options) {
15512
16124
  if (options?.dryRun) {
@@ -15548,12 +16160,17 @@ var ReplayService = class {
15548
16160
  baseBranchHead: options.baseBranchHead
15549
16161
  } : void 0;
15550
16162
  await this.committer.commitGeneration("Update SDK (replay skipped)", commitOpts);
16163
+ await this.cleanupStaleConflictMarkers();
15551
16164
  const genRecord = await this.committer.createGenerationRecord(commitOpts);
15552
- if (this.lockManager.exists()) {
16165
+ try {
15553
16166
  this.lockManager.read();
15554
16167
  this.lockManager.addGeneration(genRecord);
15555
- } else {
15556
- this.lockManager.initializeInMemory(genRecord);
16168
+ } catch (error) {
16169
+ if (error instanceof LockfileNotFoundError) {
16170
+ this.lockManager.initializeInMemory(genRecord);
16171
+ } else {
16172
+ throw error;
16173
+ }
15557
16174
  }
15558
16175
  this.lockManager.setReplaySkippedAt((/* @__PURE__ */ new Date()).toISOString());
15559
16176
  this.lockManager.save();
@@ -15599,6 +16216,7 @@ var ReplayService = class {
15599
16216
  baseBranchHead: options.baseBranchHead
15600
16217
  } : void 0;
15601
16218
  await this.committer.commitGeneration("Update SDK", commitOpts);
16219
+ await this.cleanupStaleConflictMarkers();
15602
16220
  const genRecord = await this.committer.createGenerationRecord(commitOpts);
15603
16221
  this.lockManager.addGeneration(genRecord);
15604
16222
  let results = [];
@@ -15692,6 +16310,7 @@ var ReplayService = class {
15692
16310
  baseBranchHead: options.baseBranchHead
15693
16311
  } : void 0;
15694
16312
  await this.committer.commitGeneration("Update SDK", commitOpts);
16313
+ await this.cleanupStaleConflictMarkers();
15695
16314
  const genRecord = await this.committer.createGenerationRecord(commitOpts);
15696
16315
  this.lockManager.addGeneration(genRecord);
15697
16316
  const results = await this.applicator.applyPatches(allPatches);
@@ -15882,6 +16501,13 @@ var ReplayService = class {
15882
16501
  contentRefreshed++;
15883
16502
  continue;
15884
16503
  }
16504
+ const diffLines = diff.split("\n");
16505
+ const hasStaleMarkers = diffLines.some(
16506
+ (l) => l.startsWith("+<<<<<<< Generated") || l.startsWith("+>>>>>>> Your customization")
16507
+ );
16508
+ if (hasStaleMarkers) {
16509
+ continue;
16510
+ }
15885
16511
  const newContentHash = this.detector.computeContentHash(diff);
15886
16512
  if (newContentHash !== patch.content_hash) {
15887
16513
  const filesOutput = await this.git.exec(["diff", "--name-only", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
@@ -15902,7 +16528,7 @@ var ReplayService = class {
15902
16528
  continue;
15903
16529
  }
15904
16530
  try {
15905
- const markerFiles = await this.git.exec(["grep", "-l", "<<<<<<<", "HEAD", "--", ...patch.files]).catch(() => "");
16531
+ const markerFiles = await this.git.exec(["grep", "-l", "<<<<<<< Generated", "HEAD", "--", ...patch.files]).catch(() => "");
15906
16532
  if (markerFiles.trim()) continue;
15907
16533
  const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
15908
16534
  if (diff === null) continue;
@@ -15942,6 +16568,25 @@ var ReplayService = class {
15942
16568
  }
15943
16569
  }
15944
16570
  }
16571
+ /**
16572
+ * Clean up stale conflict markers left by a previous crashed run.
16573
+ * Called after commitGeneration() when HEAD is the [fern-generated] commit.
16574
+ * Restores files to their clean generated state from HEAD.
16575
+ * Skips .fernignore-protected files to prevent overwriting user content.
16576
+ */
16577
+ async cleanupStaleConflictMarkers() {
16578
+ const markerFiles = await this.git.exec(["grep", "-l", "<<<<<<< Generated", "--", "."]).catch(() => "");
16579
+ const files = markerFiles.trim().split("\n").filter(Boolean);
16580
+ if (files.length === 0) return;
16581
+ const fernignorePatterns = this.readFernignorePatterns();
16582
+ for (const file of files) {
16583
+ if (fernignorePatterns.some((pattern) => minimatch(file, pattern))) continue;
16584
+ try {
16585
+ await this.git.exec(["checkout", "HEAD", "--", file]);
16586
+ } catch {
16587
+ }
16588
+ }
16589
+ }
15945
16590
  readFernignorePatterns() {
15946
16591
  const fernignorePath = (0, import_node_path4.join)(this.outputDir, ".fernignore");
15947
16592
  if (!(0, import_node_fs2.existsSync)(fernignorePath)) return [];
@@ -16268,6 +16913,7 @@ async function bootstrap(outputDir, options) {
16268
16913
  }
16269
16914
  lockManager.save();
16270
16915
  const fernignoreUpdated = ensureFernignoreEntries(outputDir);
16916
+ ensureGitattributesEntries(outputDir);
16271
16917
  if (migrator.fernignoreExists() && fernignorePatterns.length > 0) {
16272
16918
  const action = options?.fernignoreAction ?? "skip";
16273
16919
  if (action === "migrate") {
@@ -16349,7 +16995,7 @@ function parseGitLog(log) {
16349
16995
  return { sha, authorName, authorEmail, message };
16350
16996
  });
16351
16997
  }
16352
- var REPLAY_FERNIGNORE_ENTRIES = [".fern/replay.lock", ".fern/replay.yml"];
16998
+ var REPLAY_FERNIGNORE_ENTRIES = [".fern/replay.lock", ".fern/replay.yml", ".gitattributes"];
16353
16999
  function ensureFernignoreEntries(outputDir) {
16354
17000
  const fernignorePath = (0, import_node_path6.join)(outputDir, ".fernignore");
16355
17001
  let content = "";
@@ -16373,6 +17019,29 @@ function ensureFernignoreEntries(outputDir) {
16373
17019
  (0, import_node_fs4.writeFileSync)(fernignorePath, content, "utf-8");
16374
17020
  return true;
16375
17021
  }
17022
+ var GITATTRIBUTES_ENTRIES = [".fern/replay.lock linguist-generated=true"];
17023
+ function ensureGitattributesEntries(outputDir) {
17024
+ const gitattributesPath = (0, import_node_path6.join)(outputDir, ".gitattributes");
17025
+ let content = "";
17026
+ if ((0, import_node_fs4.existsSync)(gitattributesPath)) {
17027
+ content = (0, import_node_fs4.readFileSync)(gitattributesPath, "utf-8");
17028
+ }
17029
+ const lines = content.split("\n");
17030
+ const toAdd = [];
17031
+ for (const entry of GITATTRIBUTES_ENTRIES) {
17032
+ if (!lines.some((line) => line.trim() === entry)) {
17033
+ toAdd.push(entry);
17034
+ }
17035
+ }
17036
+ if (toAdd.length === 0) {
17037
+ return;
17038
+ }
17039
+ if (content && !content.endsWith("\n")) {
17040
+ content += "\n";
17041
+ }
17042
+ content += toAdd.join("\n") + "\n";
17043
+ (0, import_node_fs4.writeFileSync)(gitattributesPath, content, "utf-8");
17044
+ }
16376
17045
  function computeContentHash(patchContent) {
16377
17046
  const normalized = patchContent.split("\n").filter((line) => !line.startsWith("From ") && !line.startsWith("index ") && !line.startsWith("Date: ")).join("\n");
16378
17047
  return `sha256:${(0, import_node_crypto3.createHash)("sha256").update(normalized).digest("hex")}`;