@fern-api/replay 0.8.1 → 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.cjs CHANGED
@@ -133,6 +133,14 @@ var init_GitClient = __esm({
133
133
  return false;
134
134
  }
135
135
  }
136
+ async treeExists(treeHash) {
137
+ try {
138
+ const type = await this.exec(["cat-file", "-t", treeHash]);
139
+ return type.trim() === "tree";
140
+ } catch {
141
+ return false;
142
+ }
143
+ }
136
144
  async getCommitBody(commitSha) {
137
145
  return this.exec(["log", "-1", "--format=%B", commitSha]);
138
146
  }
@@ -143,6 +151,255 @@ var init_GitClient = __esm({
143
151
  }
144
152
  });
145
153
 
154
+ // src/HybridReconstruction.ts
155
+ var HybridReconstruction_exports = {};
156
+ __export(HybridReconstruction_exports, {
157
+ assembleHybrid: () => assembleHybrid,
158
+ locateHunksInOurs: () => locateHunksInOurs,
159
+ parseHunks: () => parseHunks,
160
+ reconstructFromGhostPatch: () => reconstructFromGhostPatch
161
+ });
162
+ function parseHunks(fileDiff) {
163
+ const lines = fileDiff.split("\n");
164
+ const hunks = [];
165
+ let currentHunk = null;
166
+ for (const line of lines) {
167
+ const headerMatch = line.match(
168
+ /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/
169
+ );
170
+ if (headerMatch) {
171
+ if (currentHunk) {
172
+ hunks.push(currentHunk);
173
+ }
174
+ currentHunk = {
175
+ oldStart: parseInt(headerMatch[1], 10),
176
+ oldCount: headerMatch[2] != null ? parseInt(headerMatch[2], 10) : 1,
177
+ newStart: parseInt(headerMatch[3], 10),
178
+ newCount: headerMatch[4] != null ? parseInt(headerMatch[4], 10) : 1,
179
+ lines: []
180
+ };
181
+ continue;
182
+ }
183
+ if (!currentHunk) continue;
184
+ 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")) {
185
+ continue;
186
+ }
187
+ if (line === "\") {
188
+ continue;
189
+ }
190
+ if (line.startsWith("-")) {
191
+ currentHunk.lines.push({ type: "remove", content: line.slice(1) });
192
+ } else if (line.startsWith("+")) {
193
+ currentHunk.lines.push({ type: "add", content: line.slice(1) });
194
+ } else if (line.startsWith(" ") || line === "") {
195
+ currentHunk.lines.push({
196
+ type: "context",
197
+ content: line.startsWith(" ") ? line.slice(1) : line
198
+ });
199
+ }
200
+ }
201
+ if (currentHunk) {
202
+ hunks.push(currentHunk);
203
+ }
204
+ return hunks;
205
+ }
206
+ function extractLeadingContext(hunk) {
207
+ const result = [];
208
+ for (const line of hunk.lines) {
209
+ if (line.type !== "context") break;
210
+ result.push(line.content);
211
+ }
212
+ return result;
213
+ }
214
+ function extractTrailingContext(hunk) {
215
+ const result = [];
216
+ for (let i = hunk.lines.length - 1; i >= 0; i--) {
217
+ if (hunk.lines[i].type !== "context") break;
218
+ result.unshift(hunk.lines[i].content);
219
+ }
220
+ return result;
221
+ }
222
+ function countOursLinesBeforeTrailing(hunk) {
223
+ let count = 0;
224
+ const trailingStart = findTrailingContextStart(hunk);
225
+ for (let i = 0; i < trailingStart; i++) {
226
+ if (hunk.lines[i].type === "context") count++;
227
+ }
228
+ return count;
229
+ }
230
+ function findTrailingContextStart(hunk) {
231
+ let i = hunk.lines.length - 1;
232
+ while (i >= 0 && hunk.lines[i].type === "context") {
233
+ i--;
234
+ }
235
+ return i + 1;
236
+ }
237
+ function matchesAt(needle, haystack, offset) {
238
+ for (let i = 0; i < needle.length; i++) {
239
+ if (haystack[offset + i] !== needle[i]) return false;
240
+ }
241
+ return true;
242
+ }
243
+ function findContextInOurs(contextLines, oursLines, minIndex, hint) {
244
+ const SEARCH_WINDOW = 200;
245
+ const maxStart = oursLines.length - contextLines.length;
246
+ const clampedHint = Math.max(minIndex, Math.min(hint, maxStart));
247
+ if (clampedHint >= minIndex && clampedHint <= maxStart) {
248
+ if (matchesAt(contextLines, oursLines, clampedHint)) {
249
+ return clampedHint;
250
+ }
251
+ }
252
+ for (let delta = 1; delta <= SEARCH_WINDOW; delta++) {
253
+ for (const sign of [1, -1]) {
254
+ const idx = clampedHint + delta * sign;
255
+ if (idx < minIndex || idx > maxStart) continue;
256
+ if (matchesAt(contextLines, oursLines, idx)) {
257
+ return idx;
258
+ }
259
+ }
260
+ }
261
+ return -1;
262
+ }
263
+ function computeOursSpan(hunk, oursLines, oursOffset) {
264
+ const leading = extractLeadingContext(hunk);
265
+ const trailing = extractTrailingContext(hunk);
266
+ if (trailing.length === 0) {
267
+ const contextCount2 = hunk.lines.filter(
268
+ (l) => l.type === "context"
269
+ ).length;
270
+ return Math.min(contextCount2, oursLines.length - oursOffset);
271
+ }
272
+ const searchStart = oursOffset + leading.length;
273
+ for (let i = searchStart; i <= oursLines.length - trailing.length; i++) {
274
+ if (matchesAt(trailing, oursLines, i)) {
275
+ return i + trailing.length - oursOffset;
276
+ }
277
+ }
278
+ const contextCount = hunk.lines.filter(
279
+ (l) => l.type === "context"
280
+ ).length;
281
+ return Math.min(contextCount, oursLines.length - oursOffset);
282
+ }
283
+ function locateHunksInOurs(hunks, oursLines) {
284
+ const located = [];
285
+ let minOursIndex = 0;
286
+ for (const hunk of hunks) {
287
+ const contextLines = extractLeadingContext(hunk);
288
+ let oursOffset;
289
+ if (contextLines.length > 0) {
290
+ const found = findContextInOurs(
291
+ contextLines,
292
+ oursLines,
293
+ minOursIndex,
294
+ hunk.newStart - 1
295
+ );
296
+ if (found === -1) {
297
+ const trailingContext = extractTrailingContext(hunk);
298
+ if (trailingContext.length > 0) {
299
+ const trailingFound = findContextInOurs(
300
+ trailingContext,
301
+ oursLines,
302
+ minOursIndex,
303
+ hunk.newStart - 1
304
+ );
305
+ if (trailingFound === -1) return null;
306
+ const nonTrailingCount = countOursLinesBeforeTrailing(hunk);
307
+ oursOffset = trailingFound - nonTrailingCount;
308
+ if (oursOffset < minOursIndex) return null;
309
+ } else {
310
+ return null;
311
+ }
312
+ } else {
313
+ oursOffset = found;
314
+ }
315
+ } else if (hunk.oldStart === 1 && hunk.oldCount === 0) {
316
+ oursOffset = 0;
317
+ } else {
318
+ oursOffset = Math.max(hunk.newStart - 1, minOursIndex);
319
+ }
320
+ const oursSpan = computeOursSpan(hunk, oursLines, oursOffset);
321
+ located.push({ hunk, oursOffset, oursSpan });
322
+ minOursIndex = oursOffset + oursSpan;
323
+ }
324
+ return located;
325
+ }
326
+ function assembleHybrid(locatedHunks, oursLines) {
327
+ const baseLines = [];
328
+ const theirsLines = [];
329
+ let oursCursor = 0;
330
+ for (const { hunk, oursOffset, oursSpan } of locatedHunks) {
331
+ if (oursOffset > oursCursor) {
332
+ const gapLines = oursLines.slice(oursCursor, oursOffset);
333
+ baseLines.push(...gapLines);
334
+ theirsLines.push(...gapLines);
335
+ }
336
+ for (const line of hunk.lines) {
337
+ switch (line.type) {
338
+ case "context":
339
+ baseLines.push(line.content);
340
+ theirsLines.push(line.content);
341
+ break;
342
+ case "remove":
343
+ baseLines.push(line.content);
344
+ break;
345
+ case "add":
346
+ theirsLines.push(line.content);
347
+ break;
348
+ }
349
+ }
350
+ oursCursor = oursOffset + oursSpan;
351
+ }
352
+ if (oursCursor < oursLines.length) {
353
+ const gapLines = oursLines.slice(oursCursor);
354
+ baseLines.push(...gapLines);
355
+ theirsLines.push(...gapLines);
356
+ }
357
+ return {
358
+ base: baseLines.join("\n"),
359
+ theirs: theirsLines.join("\n")
360
+ };
361
+ }
362
+ function reconstructFromGhostPatch(fileDiff, ours) {
363
+ const hunks = parseHunks(fileDiff);
364
+ if (hunks.length === 0) {
365
+ return null;
366
+ }
367
+ const isPureAddition = hunks.every(
368
+ (h) => h.oldCount === 0 && h.lines.every((l) => l.type !== "remove")
369
+ );
370
+ if (isPureAddition) {
371
+ return null;
372
+ }
373
+ const isPureDeletion = hunks.every(
374
+ (h) => h.newCount === 0 && h.lines.every((l) => l.type !== "add")
375
+ );
376
+ if (isPureDeletion) {
377
+ const baseLines = [];
378
+ for (const hunk of hunks) {
379
+ for (const line of hunk.lines) {
380
+ if (line.type === "context" || line.type === "remove") {
381
+ baseLines.push(line.content);
382
+ }
383
+ }
384
+ }
385
+ return {
386
+ base: baseLines.join("\n"),
387
+ theirs: ""
388
+ };
389
+ }
390
+ const oursLines = ours.split("\n");
391
+ const located = locateHunksInOurs(hunks, oursLines);
392
+ if (!located) {
393
+ return null;
394
+ }
395
+ return assembleHybrid(located, oursLines);
396
+ }
397
+ var init_HybridReconstruction = __esm({
398
+ "src/HybridReconstruction.ts"() {
399
+ "use strict";
400
+ }
401
+ });
402
+
146
403
  // src/index.ts
147
404
  var index_exports = {};
148
405
  __export(index_exports, {
@@ -290,6 +547,15 @@ var LockfileManager = class {
290
547
  this.ensureLoaded();
291
548
  this.lock.patches = [];
292
549
  }
550
+ addForgottenHash(hash) {
551
+ this.ensureLoaded();
552
+ if (!this.lock.forgotten_hashes) {
553
+ this.lock.forgotten_hashes = [];
554
+ }
555
+ if (!this.lock.forgotten_hashes.includes(hash)) {
556
+ this.lock.forgotten_hashes.push(hash);
557
+ }
558
+ }
293
559
  getUnresolvedPatches() {
294
560
  this.ensureLoaded();
295
561
  return this.lock.patches.filter((p) => p.status === "unresolved");
@@ -386,6 +652,7 @@ var ReplayDetector = class {
386
652
  }
387
653
  const commits = this.parseGitLog(log);
388
654
  const newPatches = [];
655
+ const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
389
656
  for (const commit of commits) {
390
657
  if (isGenerationCommit(commit)) {
391
658
  continue;
@@ -399,7 +666,7 @@ var ReplayDetector = class {
399
666
  }
400
667
  const patchContent = await this.git.formatPatch(commit.sha);
401
668
  const contentHash = this.computeContentHash(patchContent);
402
- if (lock.patches.find((p) => p.content_hash === contentHash)) {
669
+ if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
403
670
  continue;
404
671
  }
405
672
  const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
@@ -498,7 +765,7 @@ var ReplayDetector = class {
498
765
  if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
499
766
  const contentHash = this.computeContentHash(diff);
500
767
  const lock = this.lockManager.read();
501
- if (lock.patches.some((p) => p.content_hash === contentHash)) {
768
+ if (lock.patches.some((p) => p.content_hash === contentHash) || (lock.forgotten_hashes ?? []).includes(contentHash)) {
502
769
  return { patches: [], revertedPatchIds: [] };
503
770
  }
504
771
  const headSha = (await this.git.exec(["rev-parse", "HEAD"])).trim();
@@ -568,6 +835,36 @@ var import_promises = require("fs/promises");
568
835
  var import_node_os = require("os");
569
836
  var import_node_path2 = require("path");
570
837
  var import_minimatch = require("minimatch");
838
+
839
+ // src/conflict-utils.ts
840
+ function stripConflictMarkers(content) {
841
+ const lines = content.split("\n");
842
+ const result = [];
843
+ let inConflict = false;
844
+ let inOurs = false;
845
+ for (const line of lines) {
846
+ if (line.startsWith("<<<<<<< ")) {
847
+ inConflict = true;
848
+ inOurs = true;
849
+ continue;
850
+ }
851
+ if (inConflict && line === "=======") {
852
+ inOurs = false;
853
+ continue;
854
+ }
855
+ if (inConflict && line.startsWith(">>>>>>> ")) {
856
+ inConflict = false;
857
+ inOurs = false;
858
+ continue;
859
+ }
860
+ if (!inConflict || inOurs) {
861
+ result.push(line);
862
+ }
863
+ }
864
+ return result.join("\n");
865
+ }
866
+
867
+ // src/ReplayApplicator.ts
571
868
  var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
572
869
  ".png",
573
870
  ".jpg",
@@ -622,6 +919,7 @@ var ReplayApplicator = class {
622
919
  lockManager;
623
920
  outputDir;
624
921
  renameCache = /* @__PURE__ */ new Map();
922
+ treeExistsCache = /* @__PURE__ */ new Map();
625
923
  fileTheirsAccumulator = /* @__PURE__ */ new Map();
626
924
  constructor(git, lockManager, outputDir) {
627
925
  this.git = git;
@@ -639,7 +937,8 @@ var ReplayApplicator = class {
639
937
  async applyPatches(patches) {
640
938
  this.resetAccumulator();
641
939
  const results = [];
642
- for (const patch of patches) {
940
+ for (let i = 0; i < patches.length; i++) {
941
+ const patch = patches[i];
643
942
  if (this.isExcluded(patch)) {
644
943
  results.push({
645
944
  patch,
@@ -650,6 +949,33 @@ var ReplayApplicator = class {
650
949
  }
651
950
  const result = await this.applyPatchWithFallback(patch);
652
951
  results.push(result);
952
+ if (result.status === "conflict" && result.fileResults) {
953
+ const laterFiles = /* @__PURE__ */ new Set();
954
+ for (let j = i + 1; j < patches.length; j++) {
955
+ for (const f of patches[j].files) {
956
+ laterFiles.add(f);
957
+ }
958
+ }
959
+ const resolvedToOriginal = /* @__PURE__ */ new Map();
960
+ if (result.resolvedFiles) {
961
+ for (const [orig, resolved] of Object.entries(result.resolvedFiles)) {
962
+ resolvedToOriginal.set(resolved, orig);
963
+ }
964
+ }
965
+ for (const fileResult of result.fileResults) {
966
+ if (fileResult.status !== "conflict") continue;
967
+ const originalPath = resolvedToOriginal.get(fileResult.file) ?? fileResult.file;
968
+ if (laterFiles.has(fileResult.file) || laterFiles.has(originalPath)) {
969
+ const filePath = (0, import_node_path2.join)(this.outputDir, fileResult.file);
970
+ try {
971
+ const content = await (0, import_promises.readFile)(filePath, "utf-8");
972
+ const stripped = stripConflictMarkers(content);
973
+ await (0, import_promises.writeFile)(filePath, stripped);
974
+ } catch {
975
+ }
976
+ }
977
+ }
978
+ }
653
979
  }
654
980
  return results;
655
981
  }
@@ -668,7 +994,7 @@ var ReplayApplicator = class {
668
994
  const resolvedPath = await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
669
995
  const base = await this.git.showFile(baseGen.tree_hash, filePath);
670
996
  const theirs = await this.applyPatchToContent(base, patch.patch_content, filePath, tempGit, tempDir);
671
- if (theirs && base) {
997
+ if (theirs) {
672
998
  this.fileTheirsAccumulator.set(resolvedPath, {
673
999
  content: theirs,
674
1000
  baseGeneration: patch.base_generation
@@ -790,17 +1116,36 @@ var ReplayApplicator = class {
790
1116
  }
791
1117
  const oursPath = (0, import_node_path2.join)(this.outputDir, resolvedPath);
792
1118
  const ours = await (0, import_promises.readFile)(oursPath, "utf-8").catch(() => null);
793
- let theirs = await this.applyPatchToContent(
794
- base,
795
- patch.patch_content,
796
- filePath,
797
- tempGit,
798
- tempDir,
799
- renameSourcePath
800
- );
1119
+ let ghostReconstructed = false;
1120
+ let theirs = null;
1121
+ if (!base && ours && !renameSourcePath) {
1122
+ const treeReachable = await this.isTreeReachable(baseGen.tree_hash);
1123
+ if (!treeReachable) {
1124
+ const fileDiff = this.extractFileDiff(patch.patch_content, filePath);
1125
+ if (fileDiff) {
1126
+ const { reconstructFromGhostPatch: reconstructFromGhostPatch2 } = await Promise.resolve().then(() => (init_HybridReconstruction(), HybridReconstruction_exports));
1127
+ const result = reconstructFromGhostPatch2(fileDiff, ours);
1128
+ if (result) {
1129
+ base = result.base;
1130
+ theirs = result.theirs;
1131
+ ghostReconstructed = true;
1132
+ }
1133
+ }
1134
+ }
1135
+ }
1136
+ if (!ghostReconstructed) {
1137
+ theirs = await this.applyPatchToContent(
1138
+ base,
1139
+ patch.patch_content,
1140
+ filePath,
1141
+ tempGit,
1142
+ tempDir,
1143
+ renameSourcePath
1144
+ );
1145
+ }
801
1146
  let useAccumulatorAsMergeBase = false;
802
1147
  const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
803
- if (!theirs && base && accumulatorEntry) {
1148
+ if (!theirs && accumulatorEntry) {
804
1149
  theirs = await this.applyPatchToContent(
805
1150
  accumulatorEntry.content,
806
1151
  patch.patch_content,
@@ -830,13 +1175,13 @@ var ReplayApplicator = class {
830
1175
  baseMismatchSkipped = true;
831
1176
  }
832
1177
  }
833
- if (!base && !ours && effective_theirs) {
1178
+ if (base == null && !ours && effective_theirs) {
834
1179
  const outDir2 = (0, import_node_path2.dirname)(oursPath);
835
1180
  await (0, import_promises.mkdir)(outDir2, { recursive: true });
836
1181
  await (0, import_promises.writeFile)(oursPath, effective_theirs);
837
1182
  return { file: resolvedPath, status: "merged", reason: "new-file" };
838
1183
  }
839
- if (!base && ours && effective_theirs) {
1184
+ if (base == null && ours && effective_theirs) {
840
1185
  const merged2 = threeWayMerge("", ours, effective_theirs);
841
1186
  const outDir2 = (0, import_node_path2.dirname)(oursPath);
842
1187
  await (0, import_promises.mkdir)(outDir2, { recursive: true });
@@ -859,7 +1204,7 @@ var ReplayApplicator = class {
859
1204
  reason: "missing-content"
860
1205
  };
861
1206
  }
862
- if (!base || !ours) {
1207
+ if (base == null && !useAccumulatorAsMergeBase || !ours) {
863
1208
  return {
864
1209
  file: resolvedPath,
865
1210
  status: "skipped",
@@ -867,11 +1212,18 @@ var ReplayApplicator = class {
867
1212
  };
868
1213
  }
869
1214
  const mergeBase = useAccumulatorAsMergeBase && accumulatorEntry ? accumulatorEntry.content : base;
1215
+ if (mergeBase == null) {
1216
+ return {
1217
+ file: resolvedPath,
1218
+ status: "skipped",
1219
+ reason: "missing-content"
1220
+ };
1221
+ }
870
1222
  const merged = threeWayMerge(mergeBase, ours, effective_theirs);
871
1223
  const outDir = (0, import_node_path2.dirname)(oursPath);
872
1224
  await (0, import_promises.mkdir)(outDir, { recursive: true });
873
1225
  await (0, import_promises.writeFile)(oursPath, merged.content);
874
- if (effective_theirs && base) {
1226
+ if (effective_theirs) {
875
1227
  this.fileTheirsAccumulator.set(resolvedPath, {
876
1228
  content: effective_theirs,
877
1229
  baseGeneration: patch.base_generation
@@ -895,6 +1247,14 @@ var ReplayApplicator = class {
895
1247
  };
896
1248
  }
897
1249
  }
1250
+ async isTreeReachable(treeHash) {
1251
+ let result = this.treeExistsCache.get(treeHash);
1252
+ if (result === void 0) {
1253
+ result = await this.git.treeExists(treeHash);
1254
+ this.treeExistsCache.set(treeHash, result);
1255
+ }
1256
+ return result;
1257
+ }
898
1258
  isExcluded(patch) {
899
1259
  const config = this.lockManager.getCustomizationsConfig();
900
1260
  if (!config.exclude) return false;
@@ -1114,36 +1474,6 @@ var import_node_fs2 = require("fs");
1114
1474
  var import_node_path3 = require("path");
1115
1475
  var import_minimatch2 = require("minimatch");
1116
1476
  init_GitClient();
1117
-
1118
- // src/conflict-utils.ts
1119
- function stripConflictMarkers(content) {
1120
- const lines = content.split("\n");
1121
- const result = [];
1122
- let inConflict = false;
1123
- let inOurs = false;
1124
- for (const line of lines) {
1125
- if (line.startsWith("<<<<<<< ")) {
1126
- inConflict = true;
1127
- inOurs = true;
1128
- continue;
1129
- }
1130
- if (inConflict && line === "=======") {
1131
- inOurs = false;
1132
- continue;
1133
- }
1134
- if (inConflict && line.startsWith(">>>>>>> ")) {
1135
- inConflict = false;
1136
- inOurs = false;
1137
- continue;
1138
- }
1139
- if (!inConflict || inOurs) {
1140
- result.push(line);
1141
- }
1142
- }
1143
- return result.join("\n");
1144
- }
1145
-
1146
- // src/ReplayService.ts
1147
1477
  var ReplayService = class {
1148
1478
  git;
1149
1479
  detector;
@@ -1999,6 +2329,7 @@ async function bootstrap(outputDir, options) {
1999
2329
  }
2000
2330
  lockManager.save();
2001
2331
  const fernignoreUpdated = ensureFernignoreEntries(outputDir);
2332
+ ensureGitattributesEntries(outputDir);
2002
2333
  if (migrator.fernignoreExists() && fernignorePatterns.length > 0) {
2003
2334
  const action = options?.fernignoreAction ?? "skip";
2004
2335
  if (action === "migrate") {
@@ -2080,7 +2411,7 @@ function parseGitLog(log) {
2080
2411
  return { sha, authorName, authorEmail, message };
2081
2412
  });
2082
2413
  }
2083
- var REPLAY_FERNIGNORE_ENTRIES = [".fern/replay.lock", ".fern/replay.yml"];
2414
+ var REPLAY_FERNIGNORE_ENTRIES = [".fern/replay.lock", ".fern/replay.yml", ".gitattributes"];
2084
2415
  function ensureFernignoreEntries(outputDir) {
2085
2416
  const fernignorePath = (0, import_node_path5.join)(outputDir, ".fernignore");
2086
2417
  let content = "";
@@ -2104,6 +2435,29 @@ function ensureFernignoreEntries(outputDir) {
2104
2435
  (0, import_node_fs4.writeFileSync)(fernignorePath, content, "utf-8");
2105
2436
  return true;
2106
2437
  }
2438
+ var GITATTRIBUTES_ENTRIES = [".fern/replay.lock linguist-generated=true"];
2439
+ function ensureGitattributesEntries(outputDir) {
2440
+ const gitattributesPath = (0, import_node_path5.join)(outputDir, ".gitattributes");
2441
+ let content = "";
2442
+ if ((0, import_node_fs4.existsSync)(gitattributesPath)) {
2443
+ content = (0, import_node_fs4.readFileSync)(gitattributesPath, "utf-8");
2444
+ }
2445
+ const lines = content.split("\n");
2446
+ const toAdd = [];
2447
+ for (const entry of GITATTRIBUTES_ENTRIES) {
2448
+ if (!lines.some((line) => line.trim() === entry)) {
2449
+ toAdd.push(entry);
2450
+ }
2451
+ }
2452
+ if (toAdd.length === 0) {
2453
+ return;
2454
+ }
2455
+ if (content && !content.endsWith("\n")) {
2456
+ content += "\n";
2457
+ }
2458
+ content += toAdd.join("\n") + "\n";
2459
+ (0, import_node_fs4.writeFileSync)(gitattributesPath, content, "utf-8");
2460
+ }
2107
2461
  function computeContentHash(patchContent) {
2108
2462
  const normalized = patchContent.split("\n").filter((line) => !line.startsWith("From ") && !line.startsWith("index ") && !line.startsWith("Date: ")).join("\n");
2109
2463
  return `sha256:${(0, import_node_crypto3.createHash)("sha256").update(normalized).digest("hex")}`;
@@ -2111,30 +2465,145 @@ function computeContentHash(patchContent) {
2111
2465
 
2112
2466
  // src/commands/forget.ts
2113
2467
  var import_minimatch4 = require("minimatch");
2114
- function forget(outputDir, filePattern, options) {
2468
+ function parseDiffStat(patchContent) {
2469
+ let additions = 0;
2470
+ let deletions = 0;
2471
+ let inDiffHunk = false;
2472
+ for (const line of patchContent.split("\n")) {
2473
+ if (line.startsWith("diff --git ")) {
2474
+ inDiffHunk = true;
2475
+ continue;
2476
+ }
2477
+ if (!inDiffHunk) continue;
2478
+ if (line.startsWith("+") && !line.startsWith("+++")) {
2479
+ additions++;
2480
+ } else if (line.startsWith("-") && !line.startsWith("---")) {
2481
+ deletions++;
2482
+ }
2483
+ }
2484
+ return { additions, deletions };
2485
+ }
2486
+ function toMatchedPatch(patch) {
2487
+ return {
2488
+ id: patch.id,
2489
+ message: patch.original_message,
2490
+ files: patch.files,
2491
+ diffstat: parseDiffStat(patch.patch_content),
2492
+ ...patch.status ? { status: patch.status } : {}
2493
+ };
2494
+ }
2495
+ function matchesPatch(patch, pattern) {
2496
+ const fileMatch = patch.files.some(
2497
+ (file) => file === pattern || (0, import_minimatch4.minimatch)(file, pattern)
2498
+ );
2499
+ if (fileMatch) return true;
2500
+ return patch.original_message.toLowerCase().includes(pattern.toLowerCase());
2501
+ }
2502
+ function buildWarnings(patches) {
2503
+ const warnings = [];
2504
+ for (const patch of patches) {
2505
+ if (patch.status === "resolving") {
2506
+ warnings.push(
2507
+ `patch ${patch.id} has conflict markers in these files: ${patch.files.join(", ")}. Run \`git checkout -- <files>\` to restore the generated versions.`
2508
+ );
2509
+ } else if (patch.status === "unresolved") {
2510
+ warnings.push(
2511
+ `patch ${patch.id} had unresolved conflicts (files: ${patch.files.join(", ")}).`
2512
+ );
2513
+ }
2514
+ }
2515
+ return warnings;
2516
+ }
2517
+ var EMPTY_RESULT = {
2518
+ initialized: false,
2519
+ removed: [],
2520
+ remaining: 0,
2521
+ notFound: false,
2522
+ alreadyForgotten: [],
2523
+ totalPatches: 0,
2524
+ warnings: []
2525
+ };
2526
+ function forget(outputDir, options) {
2115
2527
  const lockManager = new LockfileManager(outputDir);
2116
2528
  if (!lockManager.exists()) {
2117
- return { removed: [], notFound: true };
2529
+ return { ...EMPTY_RESULT };
2118
2530
  }
2119
2531
  const lock = lockManager.read();
2120
- const matchingPatches = lock.patches.filter(
2121
- (patch) => patch.files.some((file) => file === filePattern || (0, import_minimatch4.minimatch)(file, filePattern))
2122
- );
2123
- if (matchingPatches.length === 0) {
2124
- return { removed: [], notFound: true };
2532
+ const totalPatches = lock.patches.length;
2533
+ if (options?.all) {
2534
+ const removed = lock.patches.map(toMatchedPatch);
2535
+ const warnings = buildWarnings(lock.patches);
2536
+ if (!options.dryRun) {
2537
+ for (const patch of lock.patches) {
2538
+ lockManager.addForgottenHash(patch.content_hash);
2539
+ }
2540
+ lockManager.clearPatches();
2541
+ lockManager.save();
2542
+ }
2543
+ return {
2544
+ initialized: true,
2545
+ removed,
2546
+ remaining: 0,
2547
+ notFound: false,
2548
+ alreadyForgotten: [],
2549
+ totalPatches,
2550
+ warnings
2551
+ };
2125
2552
  }
2126
- const removed = matchingPatches.map((p) => ({
2127
- id: p.id,
2128
- message: p.original_message,
2129
- files: p.files
2130
- }));
2131
- if (!options?.dryRun) {
2132
- for (const patch of matchingPatches) {
2133
- lockManager.removePatch(patch.id);
2553
+ if (options?.patchIds && options.patchIds.length > 0) {
2554
+ const removed = [];
2555
+ const alreadyForgotten = [];
2556
+ const patchesToRemove = [];
2557
+ for (const id of options.patchIds) {
2558
+ const patch = lock.patches.find((p) => p.id === id);
2559
+ if (patch) {
2560
+ removed.push(toMatchedPatch(patch));
2561
+ patchesToRemove.push(patch);
2562
+ } else {
2563
+ alreadyForgotten.push(id);
2564
+ }
2134
2565
  }
2135
- lockManager.save();
2566
+ const warnings = buildWarnings(patchesToRemove);
2567
+ if (!options.dryRun) {
2568
+ for (const patch of patchesToRemove) {
2569
+ lockManager.addForgottenHash(patch.content_hash);
2570
+ lockManager.removePatch(patch.id);
2571
+ }
2572
+ lockManager.save();
2573
+ }
2574
+ return {
2575
+ initialized: true,
2576
+ removed,
2577
+ remaining: totalPatches - removed.length,
2578
+ notFound: removed.length === 0 && alreadyForgotten.length > 0,
2579
+ alreadyForgotten,
2580
+ totalPatches,
2581
+ warnings
2582
+ };
2583
+ }
2584
+ if (options?.pattern) {
2585
+ const matched = lock.patches.filter((p) => matchesPatch(p, options.pattern)).map(toMatchedPatch);
2586
+ return {
2587
+ initialized: true,
2588
+ removed: [],
2589
+ remaining: totalPatches,
2590
+ notFound: matched.length === 0,
2591
+ alreadyForgotten: [],
2592
+ totalPatches,
2593
+ warnings: [],
2594
+ matched
2595
+ };
2136
2596
  }
2137
- return { removed, notFound: false };
2597
+ return {
2598
+ initialized: true,
2599
+ removed: [],
2600
+ remaining: totalPatches,
2601
+ notFound: totalPatches === 0,
2602
+ alreadyForgotten: [],
2603
+ totalPatches,
2604
+ warnings: [],
2605
+ matched: lock.patches.map(toMatchedPatch)
2606
+ };
2138
2607
  }
2139
2608
 
2140
2609
  // src/commands/reset.ts
@@ -2256,24 +2725,49 @@ async function getChangedFiles(git, currentGen, files) {
2256
2725
  function status(outputDir) {
2257
2726
  const lockManager = new LockfileManager(outputDir);
2258
2727
  if (!lockManager.exists()) {
2259
- return { initialized: false, patches: [], lastGeneration: void 0 };
2728
+ return {
2729
+ initialized: false,
2730
+ generationCount: 0,
2731
+ lastGeneration: void 0,
2732
+ patches: [],
2733
+ unresolvedCount: 0,
2734
+ excludePatterns: []
2735
+ };
2260
2736
  }
2261
2737
  const lock = lockManager.read();
2262
2738
  const patches = lock.patches.map((patch) => ({
2263
- sha: patch.original_commit.slice(0, 7),
2264
- author: patch.original_author.split("<")[0]?.trim() ?? "unknown",
2739
+ id: patch.id,
2740
+ type: patch.patch_content.includes("new file mode") ? "added" : "modified",
2265
2741
  message: patch.original_message,
2266
- files: patch.files
2742
+ author: patch.original_author.split("<")[0]?.trim() || "unknown",
2743
+ sha: patch.original_commit.slice(0, 7),
2744
+ files: patch.files,
2745
+ fileCount: patch.files.length,
2746
+ ...patch.status ? { status: patch.status } : {}
2267
2747
  }));
2748
+ const unresolvedCount = lock.patches.filter(
2749
+ (p) => p.status === "unresolved" || p.status === "resolving"
2750
+ ).length;
2268
2751
  let lastGeneration;
2269
2752
  const lastGen = lock.generations.find((g) => g.commit_sha === lock.current_generation);
2270
2753
  if (lastGen) {
2271
2754
  lastGeneration = {
2272
- sha: lastGen.commit_sha,
2273
- timestamp: lastGen.timestamp
2755
+ sha: lastGen.commit_sha.slice(0, 7),
2756
+ timestamp: lastGen.timestamp,
2757
+ cliVersion: lastGen.cli_version,
2758
+ generatorVersions: lastGen.generator_versions
2274
2759
  };
2275
2760
  }
2276
- return { initialized: true, patches, lastGeneration };
2761
+ const config = lockManager.getCustomizationsConfig();
2762
+ const excludePatterns = config.exclude ?? [];
2763
+ return {
2764
+ initialized: true,
2765
+ generationCount: lock.generations.length,
2766
+ lastGeneration,
2767
+ patches,
2768
+ unresolvedCount,
2769
+ excludePatterns
2770
+ };
2277
2771
  }
2278
2772
  // Annotate the CommonJS export names for ESM import in node:
2279
2773
  0 && (module.exports = {