@fern-api/replay 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -101,6 +101,7 @@ declare class GitClient {
101
101
  }>>;
102
102
  isAncestor(commit: string, descendant: string): Promise<boolean>;
103
103
  commitExists(sha: string): Promise<boolean>;
104
+ treeExists(treeHash: string): Promise<boolean>;
104
105
  getCommitBody(commitSha: string): Promise<string>;
105
106
  getRepoPath(): string;
106
107
  }
@@ -194,6 +195,7 @@ declare class ReplayApplicator {
194
195
  private lockManager;
195
196
  private outputDir;
196
197
  private renameCache;
198
+ private treeExistsCache;
197
199
  private fileTheirsAccumulator;
198
200
  constructor(git: GitClient, lockManager: LockfileManager, outputDir: string);
199
201
  /** Reset inter-patch accumulator for a new cycle. */
@@ -208,6 +210,7 @@ declare class ReplayApplicator {
208
210
  private applyPatchWithFallback;
209
211
  private applyWithThreeWayMerge;
210
212
  private mergeFile;
213
+ private isTreeReachable;
211
214
  private isExcluded;
212
215
  private resolveFilePath;
213
216
  private applyPatchToContent;
package/dist/index.d.ts CHANGED
@@ -101,6 +101,7 @@ declare class GitClient {
101
101
  }>>;
102
102
  isAncestor(commit: string, descendant: string): Promise<boolean>;
103
103
  commitExists(sha: string): Promise<boolean>;
104
+ treeExists(treeHash: string): Promise<boolean>;
104
105
  getCommitBody(commitSha: string): Promise<string>;
105
106
  getRepoPath(): string;
106
107
  }
@@ -194,6 +195,7 @@ declare class ReplayApplicator {
194
195
  private lockManager;
195
196
  private outputDir;
196
197
  private renameCache;
198
+ private treeExistsCache;
197
199
  private fileTheirsAccumulator;
198
200
  constructor(git: GitClient, lockManager: LockfileManager, outputDir: string);
199
201
  /** Reset inter-patch accumulator for a new cycle. */
@@ -208,6 +210,7 @@ declare class ReplayApplicator {
208
210
  private applyPatchWithFallback;
209
211
  private applyWithThreeWayMerge;
210
212
  private mergeFile;
213
+ private isTreeReachable;
211
214
  private isExcluded;
212
215
  private resolveFilePath;
213
216
  private applyPatchToContent;
package/dist/index.js CHANGED
@@ -111,6 +111,14 @@ var init_GitClient = __esm({
111
111
  return false;
112
112
  }
113
113
  }
114
+ async treeExists(treeHash) {
115
+ try {
116
+ const type = await this.exec(["cat-file", "-t", treeHash]);
117
+ return type.trim() === "tree";
118
+ } catch {
119
+ return false;
120
+ }
121
+ }
114
122
  async getCommitBody(commitSha) {
115
123
  return this.exec(["log", "-1", "--format=%B", commitSha]);
116
124
  }
@@ -121,6 +129,255 @@ var init_GitClient = __esm({
121
129
  }
122
130
  });
123
131
 
132
+ // src/HybridReconstruction.ts
133
+ var HybridReconstruction_exports = {};
134
+ __export(HybridReconstruction_exports, {
135
+ assembleHybrid: () => assembleHybrid,
136
+ locateHunksInOurs: () => locateHunksInOurs,
137
+ parseHunks: () => parseHunks,
138
+ reconstructFromGhostPatch: () => reconstructFromGhostPatch
139
+ });
140
+ function parseHunks(fileDiff) {
141
+ const lines = fileDiff.split("\n");
142
+ const hunks = [];
143
+ let currentHunk = null;
144
+ for (const line of lines) {
145
+ const headerMatch = line.match(
146
+ /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/
147
+ );
148
+ if (headerMatch) {
149
+ if (currentHunk) {
150
+ hunks.push(currentHunk);
151
+ }
152
+ currentHunk = {
153
+ oldStart: parseInt(headerMatch[1], 10),
154
+ oldCount: headerMatch[2] != null ? parseInt(headerMatch[2], 10) : 1,
155
+ newStart: parseInt(headerMatch[3], 10),
156
+ newCount: headerMatch[4] != null ? parseInt(headerMatch[4], 10) : 1,
157
+ lines: []
158
+ };
159
+ continue;
160
+ }
161
+ if (!currentHunk) continue;
162
+ 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")) {
163
+ continue;
164
+ }
165
+ if (line === "\") {
166
+ continue;
167
+ }
168
+ if (line.startsWith("-")) {
169
+ currentHunk.lines.push({ type: "remove", content: line.slice(1) });
170
+ } else if (line.startsWith("+")) {
171
+ currentHunk.lines.push({ type: "add", content: line.slice(1) });
172
+ } else if (line.startsWith(" ") || line === "") {
173
+ currentHunk.lines.push({
174
+ type: "context",
175
+ content: line.startsWith(" ") ? line.slice(1) : line
176
+ });
177
+ }
178
+ }
179
+ if (currentHunk) {
180
+ hunks.push(currentHunk);
181
+ }
182
+ return hunks;
183
+ }
184
+ function extractLeadingContext(hunk) {
185
+ const result = [];
186
+ for (const line of hunk.lines) {
187
+ if (line.type !== "context") break;
188
+ result.push(line.content);
189
+ }
190
+ return result;
191
+ }
192
+ function extractTrailingContext(hunk) {
193
+ const result = [];
194
+ for (let i = hunk.lines.length - 1; i >= 0; i--) {
195
+ if (hunk.lines[i].type !== "context") break;
196
+ result.unshift(hunk.lines[i].content);
197
+ }
198
+ return result;
199
+ }
200
+ function countOursLinesBeforeTrailing(hunk) {
201
+ let count = 0;
202
+ const trailingStart = findTrailingContextStart(hunk);
203
+ for (let i = 0; i < trailingStart; i++) {
204
+ if (hunk.lines[i].type === "context") count++;
205
+ }
206
+ return count;
207
+ }
208
+ function findTrailingContextStart(hunk) {
209
+ let i = hunk.lines.length - 1;
210
+ while (i >= 0 && hunk.lines[i].type === "context") {
211
+ i--;
212
+ }
213
+ return i + 1;
214
+ }
215
+ function matchesAt(needle, haystack, offset) {
216
+ for (let i = 0; i < needle.length; i++) {
217
+ if (haystack[offset + i] !== needle[i]) return false;
218
+ }
219
+ return true;
220
+ }
221
+ function findContextInOurs(contextLines, oursLines, minIndex, hint) {
222
+ const SEARCH_WINDOW = 200;
223
+ const maxStart = oursLines.length - contextLines.length;
224
+ const clampedHint = Math.max(minIndex, Math.min(hint, maxStart));
225
+ if (clampedHint >= minIndex && clampedHint <= maxStart) {
226
+ if (matchesAt(contextLines, oursLines, clampedHint)) {
227
+ return clampedHint;
228
+ }
229
+ }
230
+ for (let delta = 1; delta <= SEARCH_WINDOW; delta++) {
231
+ for (const sign of [1, -1]) {
232
+ const idx = clampedHint + delta * sign;
233
+ if (idx < minIndex || idx > maxStart) continue;
234
+ if (matchesAt(contextLines, oursLines, idx)) {
235
+ return idx;
236
+ }
237
+ }
238
+ }
239
+ return -1;
240
+ }
241
+ function computeOursSpan(hunk, oursLines, oursOffset) {
242
+ const leading = extractLeadingContext(hunk);
243
+ const trailing = extractTrailingContext(hunk);
244
+ if (trailing.length === 0) {
245
+ const contextCount2 = hunk.lines.filter(
246
+ (l) => l.type === "context"
247
+ ).length;
248
+ return Math.min(contextCount2, oursLines.length - oursOffset);
249
+ }
250
+ const searchStart = oursOffset + leading.length;
251
+ for (let i = searchStart; i <= oursLines.length - trailing.length; i++) {
252
+ if (matchesAt(trailing, oursLines, i)) {
253
+ return i + trailing.length - oursOffset;
254
+ }
255
+ }
256
+ const contextCount = hunk.lines.filter(
257
+ (l) => l.type === "context"
258
+ ).length;
259
+ return Math.min(contextCount, oursLines.length - oursOffset);
260
+ }
261
+ function locateHunksInOurs(hunks, oursLines) {
262
+ const located = [];
263
+ let minOursIndex = 0;
264
+ for (const hunk of hunks) {
265
+ const contextLines = extractLeadingContext(hunk);
266
+ let oursOffset;
267
+ if (contextLines.length > 0) {
268
+ const found = findContextInOurs(
269
+ contextLines,
270
+ oursLines,
271
+ minOursIndex,
272
+ hunk.newStart - 1
273
+ );
274
+ if (found === -1) {
275
+ const trailingContext = extractTrailingContext(hunk);
276
+ if (trailingContext.length > 0) {
277
+ const trailingFound = findContextInOurs(
278
+ trailingContext,
279
+ oursLines,
280
+ minOursIndex,
281
+ hunk.newStart - 1
282
+ );
283
+ if (trailingFound === -1) return null;
284
+ const nonTrailingCount = countOursLinesBeforeTrailing(hunk);
285
+ oursOffset = trailingFound - nonTrailingCount;
286
+ if (oursOffset < minOursIndex) return null;
287
+ } else {
288
+ return null;
289
+ }
290
+ } else {
291
+ oursOffset = found;
292
+ }
293
+ } else if (hunk.oldStart === 1 && hunk.oldCount === 0) {
294
+ oursOffset = 0;
295
+ } else {
296
+ oursOffset = Math.max(hunk.newStart - 1, minOursIndex);
297
+ }
298
+ const oursSpan = computeOursSpan(hunk, oursLines, oursOffset);
299
+ located.push({ hunk, oursOffset, oursSpan });
300
+ minOursIndex = oursOffset + oursSpan;
301
+ }
302
+ return located;
303
+ }
304
+ function assembleHybrid(locatedHunks, oursLines) {
305
+ const baseLines = [];
306
+ const theirsLines = [];
307
+ let oursCursor = 0;
308
+ for (const { hunk, oursOffset, oursSpan } of locatedHunks) {
309
+ if (oursOffset > oursCursor) {
310
+ const gapLines = oursLines.slice(oursCursor, oursOffset);
311
+ baseLines.push(...gapLines);
312
+ theirsLines.push(...gapLines);
313
+ }
314
+ for (const line of hunk.lines) {
315
+ switch (line.type) {
316
+ case "context":
317
+ baseLines.push(line.content);
318
+ theirsLines.push(line.content);
319
+ break;
320
+ case "remove":
321
+ baseLines.push(line.content);
322
+ break;
323
+ case "add":
324
+ theirsLines.push(line.content);
325
+ break;
326
+ }
327
+ }
328
+ oursCursor = oursOffset + oursSpan;
329
+ }
330
+ if (oursCursor < oursLines.length) {
331
+ const gapLines = oursLines.slice(oursCursor);
332
+ baseLines.push(...gapLines);
333
+ theirsLines.push(...gapLines);
334
+ }
335
+ return {
336
+ base: baseLines.join("\n"),
337
+ theirs: theirsLines.join("\n")
338
+ };
339
+ }
340
+ function reconstructFromGhostPatch(fileDiff, ours) {
341
+ const hunks = parseHunks(fileDiff);
342
+ if (hunks.length === 0) {
343
+ return null;
344
+ }
345
+ const isPureAddition = hunks.every(
346
+ (h) => h.oldCount === 0 && h.lines.every((l) => l.type !== "remove")
347
+ );
348
+ if (isPureAddition) {
349
+ return null;
350
+ }
351
+ const isPureDeletion = hunks.every(
352
+ (h) => h.newCount === 0 && h.lines.every((l) => l.type !== "add")
353
+ );
354
+ if (isPureDeletion) {
355
+ const baseLines = [];
356
+ for (const hunk of hunks) {
357
+ for (const line of hunk.lines) {
358
+ if (line.type === "context" || line.type === "remove") {
359
+ baseLines.push(line.content);
360
+ }
361
+ }
362
+ }
363
+ return {
364
+ base: baseLines.join("\n"),
365
+ theirs: ""
366
+ };
367
+ }
368
+ const oursLines = ours.split("\n");
369
+ const located = locateHunksInOurs(hunks, oursLines);
370
+ if (!located) {
371
+ return null;
372
+ }
373
+ return assembleHybrid(located, oursLines);
374
+ }
375
+ var init_HybridReconstruction = __esm({
376
+ "src/HybridReconstruction.ts"() {
377
+ "use strict";
378
+ }
379
+ });
380
+
124
381
  // src/index.ts
125
382
  init_GitClient();
126
383
 
@@ -615,6 +872,7 @@ var ReplayApplicator = class {
615
872
  lockManager;
616
873
  outputDir;
617
874
  renameCache = /* @__PURE__ */ new Map();
875
+ treeExistsCache = /* @__PURE__ */ new Map();
618
876
  fileTheirsAccumulator = /* @__PURE__ */ new Map();
619
877
  constructor(git, lockManager, outputDir) {
620
878
  this.git = git;
@@ -811,14 +1069,33 @@ var ReplayApplicator = class {
811
1069
  }
812
1070
  const oursPath = join2(this.outputDir, resolvedPath);
813
1071
  const ours = await readFile(oursPath, "utf-8").catch(() => null);
814
- let theirs = await this.applyPatchToContent(
815
- base,
816
- patch.patch_content,
817
- filePath,
818
- tempGit,
819
- tempDir,
820
- renameSourcePath
821
- );
1072
+ let ghostReconstructed = false;
1073
+ let theirs = null;
1074
+ if (!base && ours && !renameSourcePath) {
1075
+ const treeReachable = await this.isTreeReachable(baseGen.tree_hash);
1076
+ if (!treeReachable) {
1077
+ const fileDiff = this.extractFileDiff(patch.patch_content, filePath);
1078
+ if (fileDiff) {
1079
+ const { reconstructFromGhostPatch: reconstructFromGhostPatch2 } = await Promise.resolve().then(() => (init_HybridReconstruction(), HybridReconstruction_exports));
1080
+ const result = reconstructFromGhostPatch2(fileDiff, ours);
1081
+ if (result) {
1082
+ base = result.base;
1083
+ theirs = result.theirs;
1084
+ ghostReconstructed = true;
1085
+ }
1086
+ }
1087
+ }
1088
+ }
1089
+ if (!ghostReconstructed) {
1090
+ theirs = await this.applyPatchToContent(
1091
+ base,
1092
+ patch.patch_content,
1093
+ filePath,
1094
+ tempGit,
1095
+ tempDir,
1096
+ renameSourcePath
1097
+ );
1098
+ }
822
1099
  let useAccumulatorAsMergeBase = false;
823
1100
  const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
824
1101
  if (!theirs && accumulatorEntry) {
@@ -851,13 +1128,13 @@ var ReplayApplicator = class {
851
1128
  baseMismatchSkipped = true;
852
1129
  }
853
1130
  }
854
- if (!base && !ours && effective_theirs) {
1131
+ if (base == null && !ours && effective_theirs) {
855
1132
  const outDir2 = dirname2(oursPath);
856
1133
  await mkdir(outDir2, { recursive: true });
857
1134
  await writeFile(oursPath, effective_theirs);
858
1135
  return { file: resolvedPath, status: "merged", reason: "new-file" };
859
1136
  }
860
- if (!base && ours && effective_theirs) {
1137
+ if (base == null && ours && effective_theirs) {
861
1138
  const merged2 = threeWayMerge("", ours, effective_theirs);
862
1139
  const outDir2 = dirname2(oursPath);
863
1140
  await mkdir(outDir2, { recursive: true });
@@ -880,7 +1157,7 @@ var ReplayApplicator = class {
880
1157
  reason: "missing-content"
881
1158
  };
882
1159
  }
883
- if (!base && !useAccumulatorAsMergeBase || !ours) {
1160
+ if (base == null && !useAccumulatorAsMergeBase || !ours) {
884
1161
  return {
885
1162
  file: resolvedPath,
886
1163
  status: "skipped",
@@ -923,6 +1200,14 @@ var ReplayApplicator = class {
923
1200
  };
924
1201
  }
925
1202
  }
1203
+ async isTreeReachable(treeHash) {
1204
+ let result = this.treeExistsCache.get(treeHash);
1205
+ if (result === void 0) {
1206
+ result = await this.git.treeExists(treeHash);
1207
+ this.treeExistsCache.set(treeHash, result);
1208
+ }
1209
+ return result;
1210
+ }
926
1211
  isExcluded(patch) {
927
1212
  const config = this.lockManager.getCustomizationsConfig();
928
1213
  if (!config.exclude) return false;
@@ -1997,6 +2282,7 @@ async function bootstrap(outputDir, options) {
1997
2282
  }
1998
2283
  lockManager.save();
1999
2284
  const fernignoreUpdated = ensureFernignoreEntries(outputDir);
2285
+ ensureGitattributesEntries(outputDir);
2000
2286
  if (migrator.fernignoreExists() && fernignorePatterns.length > 0) {
2001
2287
  const action = options?.fernignoreAction ?? "skip";
2002
2288
  if (action === "migrate") {
@@ -2078,7 +2364,7 @@ function parseGitLog(log) {
2078
2364
  return { sha, authorName, authorEmail, message };
2079
2365
  });
2080
2366
  }
2081
- var REPLAY_FERNIGNORE_ENTRIES = [".fern/replay.lock", ".fern/replay.yml"];
2367
+ var REPLAY_FERNIGNORE_ENTRIES = [".fern/replay.lock", ".fern/replay.yml", ".gitattributes"];
2082
2368
  function ensureFernignoreEntries(outputDir) {
2083
2369
  const fernignorePath = join5(outputDir, ".fernignore");
2084
2370
  let content = "";
@@ -2102,6 +2388,29 @@ function ensureFernignoreEntries(outputDir) {
2102
2388
  writeFileSync4(fernignorePath, content, "utf-8");
2103
2389
  return true;
2104
2390
  }
2391
+ var GITATTRIBUTES_ENTRIES = [".fern/replay.lock linguist-generated=true"];
2392
+ function ensureGitattributesEntries(outputDir) {
2393
+ const gitattributesPath = join5(outputDir, ".gitattributes");
2394
+ let content = "";
2395
+ if (existsSync4(gitattributesPath)) {
2396
+ content = readFileSync4(gitattributesPath, "utf-8");
2397
+ }
2398
+ const lines = content.split("\n");
2399
+ const toAdd = [];
2400
+ for (const entry of GITATTRIBUTES_ENTRIES) {
2401
+ if (!lines.some((line) => line.trim() === entry)) {
2402
+ toAdd.push(entry);
2403
+ }
2404
+ }
2405
+ if (toAdd.length === 0) {
2406
+ return;
2407
+ }
2408
+ if (content && !content.endsWith("\n")) {
2409
+ content += "\n";
2410
+ }
2411
+ content += toAdd.join("\n") + "\n";
2412
+ writeFileSync4(gitattributesPath, content, "utf-8");
2413
+ }
2105
2414
  function computeContentHash(patchContent) {
2106
2415
  const normalized = patchContent.split("\n").filter((line) => !line.startsWith("From ") && !line.startsWith("index ") && !line.startsWith("Date: ")).join("\n");
2107
2416
  return `sha256:${createHash3("sha256").update(normalized).digest("hex")}`;